diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 0a0a5e4..9ab1d50 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -413,6 +413,34 @@ class FeishuChannel(BaseChannel): elements.extend(self._split_headings(remaining)) 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]: """Split content by headings, converting headings to div elements.""" protected = content @@ -653,11 +681,13 @@ class FeishuChannel(BaseChannel): ) if msg.content and msg.content.strip(): - card = {"config": {"wide_screen_mode": True}, "elements": self._build_card_elements(msg.content)} - await loop.run_in_executor( - None, self._send_message_sync, - receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False), - ) + elements = self._build_card_elements(msg.content) + for chunk in self._split_elements_by_table_limit(elements): + card = {"config": {"wide_screen_mode": True}, "elements": chunk} + 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: logger.error("Error sending Feishu message: {}", e) diff --git a/tests/test_feishu_table_split.py b/tests/test_feishu_table_split.py new file mode 100644 index 0000000..af8fa16 --- /dev/null +++ b/tests/test_feishu_table_split.py @@ -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]