Merge PR #1384: fix(feishu): split card messages when content has multiple tables
This commit is contained in:
@@ -410,6 +410,34 @@ class FeishuChannel(BaseChannel):
|
|||||||
elements.extend(self._split_headings(remaining))
|
elements.extend(self._split_headings(remaining))
|
||||||
return elements or [{"tag": "markdown", "content": content}]
|
return elements or [{"tag": "markdown", "content": content}]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _split_elements_by_table_limit(elements: list[dict], max_tables: int = 1) -> list[list[dict]]:
|
||||||
|
"""Split card elements into groups with at most *max_tables* table elements each.
|
||||||
|
|
||||||
|
Feishu cards have a hard limit of one table per card (API error 11310).
|
||||||
|
When the rendered content contains multiple markdown tables each table is
|
||||||
|
placed in a separate card message so every table reaches the user.
|
||||||
|
"""
|
||||||
|
if not elements:
|
||||||
|
return [[]]
|
||||||
|
groups: list[list[dict]] = []
|
||||||
|
current: list[dict] = []
|
||||||
|
table_count = 0
|
||||||
|
for el in elements:
|
||||||
|
if el.get("tag") == "table":
|
||||||
|
if table_count >= max_tables:
|
||||||
|
if current:
|
||||||
|
groups.append(current)
|
||||||
|
current = []
|
||||||
|
table_count = 0
|
||||||
|
current.append(el)
|
||||||
|
table_count += 1
|
||||||
|
else:
|
||||||
|
current.append(el)
|
||||||
|
if current:
|
||||||
|
groups.append(current)
|
||||||
|
return groups or [[]]
|
||||||
|
|
||||||
def _split_headings(self, content: str) -> list[dict]:
|
def _split_headings(self, content: str) -> list[dict]:
|
||||||
"""Split content by headings, converting headings to div elements."""
|
"""Split content by headings, converting headings to div elements."""
|
||||||
protected = content
|
protected = content
|
||||||
@@ -661,11 +689,13 @@ class FeishuChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if msg.content and msg.content.strip():
|
if msg.content and msg.content.strip():
|
||||||
card = {"config": {"wide_screen_mode": True}, "elements": self._build_card_elements(msg.content)}
|
elements = self._build_card_elements(msg.content)
|
||||||
await loop.run_in_executor(
|
for chunk in self._split_elements_by_table_limit(elements):
|
||||||
None, self._send_message_sync,
|
card = {"config": {"wide_screen_mode": True}, "elements": chunk}
|
||||||
receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False),
|
await loop.run_in_executor(
|
||||||
)
|
None, self._send_message_sync,
|
||||||
|
receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error sending Feishu message: {}", e)
|
logger.error("Error sending Feishu message: {}", e)
|
||||||
|
|||||||
104
tests/test_feishu_table_split.py
Normal file
104
tests/test_feishu_table_split.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"""Tests for FeishuChannel._split_elements_by_table_limit.
|
||||||
|
|
||||||
|
Feishu cards reject messages that contain more than one table element
|
||||||
|
(API error 11310: card table number over limit). The helper splits a flat
|
||||||
|
list of card elements into groups so that each group contains at most one
|
||||||
|
table, allowing nanobot to send multiple cards instead of failing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from nanobot.channels.feishu import FeishuChannel
|
||||||
|
|
||||||
|
|
||||||
|
def _md(text: str) -> dict:
|
||||||
|
return {"tag": "markdown", "content": text}
|
||||||
|
|
||||||
|
|
||||||
|
def _table() -> dict:
|
||||||
|
return {
|
||||||
|
"tag": "table",
|
||||||
|
"columns": [{"tag": "column", "name": "c0", "display_name": "A", "width": "auto"}],
|
||||||
|
"rows": [{"c0": "v"}],
|
||||||
|
"page_size": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
split = FeishuChannel._split_elements_by_table_limit
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_list_returns_single_empty_group() -> None:
|
||||||
|
assert split([]) == [[]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_tables_returns_single_group() -> None:
|
||||||
|
els = [_md("hello"), _md("world")]
|
||||||
|
result = split(els)
|
||||||
|
assert result == [els]
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_table_stays_in_one_group() -> None:
|
||||||
|
els = [_md("intro"), _table(), _md("outro")]
|
||||||
|
result = split(els)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0] == els
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_tables_split_into_two_groups() -> None:
|
||||||
|
# Use different row values so the two tables are not equal
|
||||||
|
t1 = {
|
||||||
|
"tag": "table",
|
||||||
|
"columns": [{"tag": "column", "name": "c0", "display_name": "A", "width": "auto"}],
|
||||||
|
"rows": [{"c0": "table-one"}],
|
||||||
|
"page_size": 2,
|
||||||
|
}
|
||||||
|
t2 = {
|
||||||
|
"tag": "table",
|
||||||
|
"columns": [{"tag": "column", "name": "c0", "display_name": "B", "width": "auto"}],
|
||||||
|
"rows": [{"c0": "table-two"}],
|
||||||
|
"page_size": 2,
|
||||||
|
}
|
||||||
|
els = [_md("before"), t1, _md("between"), t2, _md("after")]
|
||||||
|
result = split(els)
|
||||||
|
assert len(result) == 2
|
||||||
|
# First group: text before table-1 + table-1
|
||||||
|
assert t1 in result[0]
|
||||||
|
assert t2 not in result[0]
|
||||||
|
# Second group: text between tables + table-2 + text after
|
||||||
|
assert t2 in result[1]
|
||||||
|
assert t1 not in result[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_three_tables_split_into_three_groups() -> None:
|
||||||
|
tables = [
|
||||||
|
{"tag": "table", "columns": [], "rows": [{"c0": f"t{i}"}], "page_size": 1}
|
||||||
|
for i in range(3)
|
||||||
|
]
|
||||||
|
els = tables[:]
|
||||||
|
result = split(els)
|
||||||
|
assert len(result) == 3
|
||||||
|
for i, group in enumerate(result):
|
||||||
|
assert tables[i] in group
|
||||||
|
|
||||||
|
|
||||||
|
def test_leading_markdown_stays_with_first_table() -> None:
|
||||||
|
intro = _md("intro")
|
||||||
|
t = _table()
|
||||||
|
result = split([intro, t])
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0] == [intro, t]
|
||||||
|
|
||||||
|
|
||||||
|
def test_trailing_markdown_after_second_table() -> None:
|
||||||
|
t1, t2 = _table(), _table()
|
||||||
|
tail = _md("end")
|
||||||
|
result = split([t1, t2, tail])
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[1] == [t2, tail]
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_table_elements_before_first_table_kept_in_first_group() -> None:
|
||||||
|
head = _md("head")
|
||||||
|
t1, t2 = _table(), _table()
|
||||||
|
result = split([head, t1, t2])
|
||||||
|
# head + t1 in group 0; t2 in group 1
|
||||||
|
assert result[0] == [head, t1]
|
||||||
|
assert result[1] == [t2]
|
||||||
Reference in New Issue
Block a user