diff --git a/.gitignore b/.gitignore index 8bbe7235ee9f..56535411029d 100644 --- a/.gitignore +++ b/.gitignore @@ -74,4 +74,4 @@ website/static/api/skills-index.json models-dev-upstream/ hermes_cli/tui_dist/* hermes_cli/scripts/ -docs/superpowers/* \ No newline at end of file +docs/superpowers/*tinker-atropos/ diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index 2831476b5ba2..67f4f14cca2c 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -154,8 +154,21 @@ re.MULTILINE, ) # Detect markdown tables: a line starting with | followed by a separator line. -# Feishu post-type 'md' elements do not render tables, so we force text mode. +# Feishu post-type 'md' elements do not render tables, so we use card format. _MARKDOWN_TABLE_RE = re.compile(r"^\|.*\|\n\|[-|: ]+\|", re.MULTILINE) +# Detect markdown headings (h1-h6) โ€” post 'md' elements don't render these. +# These require interactive card format. +_MARKDOWN_HEADING_RE = re.compile(r"^#{1,6}\s", re.MULTILINE) +# Detect horizontal rules โ€” post 'md' elements don't render these. +_MARKDOWN_HR_RE = re.compile(r"^\s*---+$", re.MULTILINE) +# Card header template colours for different contexts. +_CARD_TEMPLATE_BLUE = "blue" +_CARD_TEMPLATE_GREY = "grey" +_CARD_TEMPLATE_GREEN = "green" +_CARD_TEMPLATE_ORANGE = "orange" +_CARD_TEMPLATE_RED = "red" +# Default card header title. +_DEFAULT_CARD_TITLE = "๐Ÿค– Hermes" _MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)") _MARKDOWN_FENCE_OPEN_RE = re.compile(r"^```([^\n`]*)\s*$") _MARKDOWN_FENCE_CLOSE_RE = re.compile(r"^```\s*$") @@ -1789,8 +1802,34 @@ async def send( ) except Exception as exc: if msg_type != "post" or not _POST_CONTENT_INVALID_RE.search(str(exc)): - raise - logger.warning("[Feishu] Invalid post payload rejected by API; falling back to plain text") + if msg_type != "interactive": + raise + # Interactive card rejected โ€” fall back to plain text. + logger.warning( + "[Feishu] Interactive card rejected by API; falling back to plain text" + ) + response = await self._feishu_send_with_retry( + chat_id=chat_id, + msg_type="text", + payload=json.dumps({"text": _strip_markdown_to_plain_text(chunk)}, ensure_ascii=False), + reply_to=reply_to, + metadata=metadata, + ) + else: + logger.warning("[Feishu] Invalid post payload rejected by API; falling back to plain text") + response = await self._feishu_send_with_retry( + chat_id=chat_id, + msg_type="text", + payload=json.dumps({"text": _strip_markdown_to_plain_text(chunk)}, ensure_ascii=False), + reply_to=reply_to, + metadata=metadata, + ) + if ( + msg_type == "post" + and not self._response_succeeded(response) + and _POST_CONTENT_INVALID_RE.search(str(getattr(response, "msg", "") or "")) + ): + logger.warning("[Feishu] Post payload rejected by API response; falling back to plain text") response = await self._feishu_send_with_retry( chat_id=chat_id, msg_type="text", @@ -1798,12 +1837,16 @@ async def send( reply_to=reply_to, metadata=metadata, ) - if ( - msg_type == "post" + elif ( + msg_type == "interactive" and not self._response_succeeded(response) - and _POST_CONTENT_INVALID_RE.search(str(getattr(response, "msg", "") or "")) ): - logger.warning("[Feishu] Post payload rejected by API response; falling back to plain text") + logger.warning( + "[Feishu] Interactive card rejected by API response" + " (code=%s, msg=%s); falling back to plain text", + getattr(response, "code", "?"), + getattr(response, "msg", "?"), + ) response = await self._feishu_send_with_retry( chat_id=chat_id, msg_type="text", @@ -1846,6 +1889,15 @@ async def edit_message( fallback_request = self._build_update_message_request(message_id=message_id, request_body=fallback_body) fallback_response = await asyncio.to_thread(self._client.im.v1.message.update, fallback_request) result = self._finalize_send_result(fallback_response, "update failed") + elif not result.success and msg_type == "interactive": + logger.warning("[Feishu] Invalid interactive card update payload rejected by API; falling back to plain text") + fallback_body = self._build_update_message_body( + msg_type="text", + content=json.dumps({"text": _strip_markdown_to_plain_text(content)}, ensure_ascii=False), + ) + fallback_request = self._build_update_message_request(message_id=message_id, request_body=fallback_body) + fallback_response = await asyncio.to_thread(self._client.im.v1.message.update, fallback_request) + result = self._finalize_send_result(fallback_response, "update failed") if result.success: result.message_id = message_id return result @@ -4284,17 +4336,215 @@ def _is_duplicate(self, message_id: str) -> bool: # ========================================================================= def _build_outbound_payload(self, content: str) -> tuple[str, str]: - # Feishu post-type 'md' elements do not render markdown tables; sending - # table content as post causes the message to appear blank on the client. - # Force plain text for anything that looks like a markdown table. - if _MARKDOWN_TABLE_RE.search(content): - text_payload = {"text": content} - return "text", json.dumps(text_payload, ensure_ascii=False) + # Tables, headings, and horizontal rules don't render in post 'md' elements. + # Use interactive cards which support full markdown (headings, tables, rules, etc.). + if _MARKDOWN_TABLE_RE.search(content) or _MARKDOWN_HEADING_RE.search(content) or _MARKDOWN_HR_RE.search(content): + return "interactive", self._build_card_payload(content) if _MARKDOWN_HINT_RE.search(content): return "post", _build_markdown_post_payload(content) text_payload = {"text": content} return "text", json.dumps(text_payload, ensure_ascii=False) + def _build_card_payload( + self, + content: str, + header_title: str = _DEFAULT_CARD_TITLE, + template: str = _CARD_TEMPLATE_BLUE, + ) -> str: + """Build a Feishu interactive card JSON string. + + Interactive cards support full markdown rendering including headings, + tables, horizontal rules, code blocks, and all standard markdown syntax + that Feishu post 'md' elements cannot handle. + + Horizontal rule lines (``---`` on its own line) are converted to native + Feishu ``hr`` card elements for proper visual rendering. Code blocks + containing ``---`` are not affected. + + Args: + content: The markdown text to render in the card body. + header_title: Text for the card header bar. + template: Colour template for the card header + (blue, grey, green, orange, red). + """ + elements = self._build_card_elements(content) + card = { + "config": {"wide_screen_mode": True}, + "header": { + "title": {"tag": "plain_text", "content": header_title}, + "template": template, + }, + "elements": elements, + } + return json.dumps(card, ensure_ascii=False) + + @staticmethod + def _build_card_table_element(lines: List[str]) -> Optional[Dict[str, Any]]: + """Build a Feishu card ``table`` element from markdown pipe-table lines. + + Returns ``None`` if the lines don't form a valid table (e.g. fewer than + two rows after filtering the separator). + + Feishu table rows use ``{column_name: cell_value}`` objects keyed by + each column's ``name`` field, not arrays of cell objects. + """ + if len(lines) < 2: + return None + + # Parse each line into cells, stripping leading / trailing empty cells + # from the outer pipes. + rows: List[List[str]] = [] + for line in lines: + if not line.strip(): + continue + parts = line.split("|") + # Strip leading/trailing empty cells from ``|...|`` wrapper. + start = 1 if parts and parts[0] == "" else 0 + end = -1 if len(parts) > 1 and parts[-1] == "" else len(parts) + cells = [p.strip() for p in parts[start:end]] + if any(c for c in cells): # at least one non-empty cell + rows.append(cells) + + if not rows: + return None + + # Identify and remove the separator row (e.g. ``|------|------|``). + if len(rows) >= 2: + sep_idx = None + for ri in range(len(rows)): + if all(re.fullmatch(r"[-: ]+", c) for c in rows[ri]): + sep_idx = ri + break + if sep_idx is not None: + rows.pop(sep_idx) + + if len(rows) < 1: + return None + + header_cells = rows[0] + data_rows = rows[1:] if len(rows) > 1 else [] + + # Column names: sequential c0, c1, c2... used as keys in row objects. + col_names = [f"c{i}" for i in range(len(header_cells))] + columns = [ + { + "name": col_names[i], + "display_name": header_cells[i] or "\u00a0", + "data_type": "text", + "width": "auto", + } + for i in range(len(header_cells)) + ] + + # Rows are objects keyed by column name, e.g. {"c0": "val1", "c1": "val2"} + table_rows: List[Dict[str, Any]] = [] + for row in data_rows: + row_obj: Dict[str, Any] = {} + for j in range(len(header_cells)): + value = row[j].strip() if j < len(row) else "" + row_obj[col_names[j]] = value or "\u00a0" + table_rows.append(row_obj) + + return {"tag": "table", "columns": columns, "rows": table_rows} + + @staticmethod + def _build_card_elements(content: str) -> List[Dict[str, Any]]: + """Parse markdown content into a list of Feishu card elements. + + Feishu's ``markdown`` element does **not** render headings (``##``) + or tables (``|...|``). This method converts them to native card elements: + + * ``## Heading`` โ†’ ``div`` with ``lark_md`` bold text + * ``|table|`` โ†’ native ``table`` element + * ``---`` โ†’ ``hr`` element + * Everything else โ†’ ``markdown`` element + + Fenced code blocks are tracked so ``---`` and ``|...|`` inside them + are preserved as literal text. + """ + if not content or not content.strip(): + return [{"tag": "markdown", "content": content or ""}] + + elements: List[Dict[str, Any]] = [] + lines = content.splitlines() + in_code_block = False + i = 0 + + while i < len(lines): + line = lines[i] + stripped = line.strip() + + # Track fenced code blocks + if stripped.startswith("```"): + in_code_block = not in_code_block + + # โ”€โ”€ Horizontal rule (outside code block) โ”€โ”€ + if not in_code_block and _MARKDOWN_HR_RE.match(stripped): + elements.append({"tag": "hr"}) + i += 1 + continue + + # โ”€โ”€ Heading (outside code block) โ”€โ”€ + if not in_code_block and _MARKDOWN_HEADING_RE.match(stripped): + m = re.match(r"^(#{1,6})\s+(.*)", stripped) + if m: + heading_text = m.group(2).strip() + if heading_text: + elements.append( + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": f"**{heading_text}**", + }, + } + ) + i += 1 + continue + + # โ”€โ”€ Table detection (outside code block) โ”€โ”€ + if not in_code_block and stripped.startswith("|"): + table_lines = [] + j = i + while j < len(lines) and lines[j].strip().startswith("|") and not lines[j].strip().startswith("```"): + table_lines.append(lines[j].strip()) + j += 1 + table_el = FeishuAdapter._build_card_table_element(table_lines) + if table_el is not None: + elements.append(table_el) + i = j + continue + # Fall through โ€” treat as regular markdown + + # โ”€โ”€ Regular markdown content โ”€โ”€ + block_lines = [] + j = i + while j < len(lines): + s = lines[j].strip() + + # Toggle code block state so | and --- inside fences are literal. + if s.startswith("```"): + in_code_block = not in_code_block + + # Stop at structural boundaries (only outside code blocks) + if not in_code_block and ( + _MARKDOWN_HR_RE.match(s) + or _MARKDOWN_HEADING_RE.match(s) + or (s.startswith("|") and s.count("|") >= 2 and not s.startswith("|```")) + ): + break + + block_lines.append(lines[j]) + j += 1 + + if block_lines: + text = "\n".join(block_lines).strip() + if text: + elements.append({"tag": "markdown", "content": text}) + i = j + + return elements if elements else [{"tag": "markdown", "content": content}] + async def _send_uploaded_file_message( self, *, @@ -4577,6 +4827,11 @@ async def _feishu_send_with_retry( last_error = exc if msg_type == "post" and _POST_CONTENT_INVALID_RE.search(str(exc)): raise + if msg_type == "interactive": + # Any exception during interactive card send means the card + # format is rejected โ€” propagate immediately so send() can + # fall back to plain text instead of retrying. + raise if attempt >= _FEISHU_SEND_ATTEMPTS - 1: raise wait_seconds = 2 ** attempt diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index 75f619239566..3736f9cef656 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -2796,7 +2796,7 @@ async def _direct(func, *args, **kwargs): ) @patch.dict(os.environ, {}, clear=True) - def test_send_uses_post_for_advanced_markdown_lines(self): + def test_send_uses_card_for_horizontal_rule_and_advanced_markdown(self): from gateway.config import PlatformConfig from gateway.platforms.feishu import FeishuAdapter @@ -2831,14 +2831,315 @@ async def _direct(func, *args, **kwargs): ) self.assertTrue(result.success) - self.assertEqual(captured["request"].request_body.msg_type, "post") + self.assertEqual(captured["request"].request_body.msg_type, "interactive") payload = json.loads(captured["request"].request_body.content) - rows = payload["zh_cn"]["content"] + # Interactive card payload has header + elements structure + self.assertIn("header", payload) + self.assertEqual(payload["header"]["title"]["content"], "๐Ÿค– Hermes") + self.assertIn("elements", payload) + # The content "---\n..." should produce: [hr, markdown] + self.assertEqual(len(payload["elements"]), 2) + self.assertEqual(payload["elements"][0]["tag"], "hr") + self.assertEqual(payload["elements"][1]["tag"], "markdown") + self.assertIn("1. ็ฌฌไธ€้กน", payload["elements"][1]["content"]) + self.assertIn("~~ๅˆ ้™ค็บฟ~~", payload["elements"][1]["content"]) + + @patch.dict(os.environ, {}, clear=True) + def test_send_uses_card_for_markdown_heading(self): + """Content with markdown headings should use interactive card.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + captured = {} + + class _MessageAPI: + def create(self, request): + captured["request"] = request + return SimpleNamespace( + success=lambda: True, + data=SimpleNamespace(message_id="om_heading"), + ) + + adapter._client = SimpleNamespace( + im=SimpleNamespace( + v1=SimpleNamespace( + message=_MessageAPI(), + ) + ) + ) + + async def _direct(func, *args, **kwargs): + return func(*args, **kwargs) + + with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + result = asyncio.run( + adapter.send( + chat_id="oc_chat", + content="## ้กน็›ฎๆ–‡ไปถ\n่ฟ™ๆ˜ฏไธ€ไบ›ๅ†…ๅฎนใ€‚", + ) + ) + + self.assertTrue(result.success) + self.assertEqual(captured["request"].request_body.msg_type, "interactive") + + @patch.dict(os.environ, {}, clear=True) + def test_send_uses_card_for_markdown_table(self): + """Content with markdown tables should use interactive card.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + captured = {} + + class _MessageAPI: + def create(self, request): + captured["request"] = request + return SimpleNamespace( + success=lambda: True, + data=SimpleNamespace(message_id="om_table"), + ) + + adapter._client = SimpleNamespace( + im=SimpleNamespace( + v1=SimpleNamespace( + message=_MessageAPI(), + ) + ) + ) + + async def _direct(func, *args, **kwargs): + return func(*args, **kwargs) + + with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + result = asyncio.run( + adapter.send( + chat_id="oc_chat", + content="| Name | Value |\n|------|-------|\n| Key | Val |", + ) + ) + + self.assertTrue(result.success) + self.assertEqual(captured["request"].request_body.msg_type, "interactive") + + @patch.dict(os.environ, {}, clear=True) + def test_build_card_payload_creates_valid_structure(self): + """_build_card_payload should return valid interactive card JSON.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + payload = json.loads( + adapter._build_card_payload("# Hello\nThis is **bold** and `code`") + ) + + self.assertIn("config", payload) + self.assertTrue(payload["config"]["wide_screen_mode"]) + self.assertIn("header", payload) + self.assertEqual(payload["header"]["title"]["content"], "๐Ÿค– Hermes") + self.assertEqual(payload["header"]["template"], "blue") + self.assertIn("elements", payload) + # Heading is extracted into a div (card markdown doesn't render ##) + self.assertEqual(len(payload["elements"]), 2) + self.assertEqual(payload["elements"][0]["tag"], "div") self.assertEqual( - rows, - [[{"tag": "md", "text": "---\n1. ็ฌฌไธ€้กน\nไธ‹ๅˆ’็บฟ\n~~ๅˆ ้™ค็บฟ~~"}]], + payload["elements"][0]["text"]["content"], + "**Hello**", + ) + self.assertEqual(payload["elements"][1]["tag"], "markdown") + self.assertIn("**bold**", payload["elements"][1]["content"]) + self.assertIn("`code`", payload["elements"][1]["content"]) + + @patch.dict(os.environ, {}, clear=True) + def test_build_card_payload_custom_header(self): + """_build_card_payload should accept custom header title and template.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + payload = json.loads( + adapter._build_card_payload("content", header_title="Custom", template="green") + ) + + self.assertEqual(payload["header"]["title"]["content"], "Custom") + self.assertEqual(payload["header"]["template"], "green") + + @patch.dict(os.environ, {}, clear=True) + def test_send_falls_back_to_text_when_card_rejected_by_exception(self): + """When interactive card raises, send() should fall back to plain text.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + captured = {"calls": []} + + class _MessageAPI: + def create(self, request): + captured["calls"].append(request) + if len(captured["calls"]) == 1: + raise RuntimeError("interactive card type error") + return SimpleNamespace( + success=lambda: True, + data=SimpleNamespace(message_id="om_plain"), + ) + + adapter._client = SimpleNamespace( + im=SimpleNamespace( + v1=SimpleNamespace( + message=_MessageAPI(), + ) + ) ) + async def _direct(func, *args, **kwargs): + return func(*args, **kwargs) + + with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + result = asyncio.run( + adapter.send( + chat_id="oc_chat", + content="## Heading triggers card path", + ) + ) + + self.assertTrue(result.success) + self.assertEqual(captured["calls"][0].request_body.msg_type, "interactive") + self.assertEqual(captured["calls"][1].request_body.msg_type, "text") + + @patch.dict(os.environ, {}, clear=True) + def test_send_falls_back_to_text_when_card_response_unsuccessful(self): + """When interactive card response is unsuccessful, send() should fall back.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + captured = {"calls": []} + + class _MessageAPI: + def create(self, request): + captured["calls"].append(request) + if len(captured["calls"]) == 1: + return SimpleNamespace( + success=lambda: False, + code=10001, + msg="interactive card error", + ) + return SimpleNamespace( + success=lambda: True, + data=SimpleNamespace(message_id="om_plain"), + ) + + adapter._client = SimpleNamespace( + im=SimpleNamespace( + v1=SimpleNamespace( + message=_MessageAPI(), + ) + ) + ) + + async def _direct(func, *args, **kwargs): + return func(*args, **kwargs) + + with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + result = asyncio.run( + adapter.send( + chat_id="oc_chat", + content="| A | B |\n|---|---|\n| 1 | 2 |", + ) + ) + + self.assertTrue(result.success) + self.assertEqual(captured["calls"][0].request_body.msg_type, "interactive") + self.assertEqual(captured["calls"][1].request_body.msg_type, "text") + + @patch.dict(os.environ, {}, clear=True) + def test_edit_message_falls_back_to_text_when_card_update_rejected(self): + """edit_message should fall back to text when interactive card update fails.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + captured = {"calls": []} + + class _MessageAPI: + def update(self, request): + captured["calls"].append(request) + if len(captured["calls"]) == 1: + return SimpleNamespace( + success=lambda: False, + code=10001, + msg="interactive card error", + ) + return SimpleNamespace(success=lambda: True) + + adapter._client = SimpleNamespace( + im=SimpleNamespace( + v1=SimpleNamespace( + message=_MessageAPI(), + ) + ) + ) + + async def _direct(func, *args, **kwargs): + return func(*args, **kwargs) + + with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + result = asyncio.run( + adapter.edit_message( + chat_id="oc_chat", + message_id="om_card", + content="## Updated heading", + ) + ) + + self.assertTrue(result.success) + # First attempt should be interactive card + self.assertEqual(captured["calls"][0].request_body.msg_type, "interactive") + # Fallback should be text + self.assertEqual(captured["calls"][1].request_body.msg_type, "text") + + @patch.dict(os.environ, {}, clear=True) + def test_build_card_elements_converts_horizontal_rules_to_hr(self): + """Horizontal rules in card content should produce hr elements.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + + # Content with multiple HRs + payload = json.loads(adapter._build_card_payload( + "Section 1\n\n---\n\nSection 2\n\n---\n\nSection 3" + )) + elements = payload["elements"] + self.assertEqual(len(elements), 5) + self.assertEqual(elements[0]["tag"], "markdown") + self.assertIn("Section 1", elements[0]["content"]) + self.assertEqual(elements[1]["tag"], "hr") + self.assertEqual(elements[2]["tag"], "markdown") + self.assertIn("Section 2", elements[2]["content"]) + self.assertEqual(elements[3]["tag"], "hr") + self.assertEqual(elements[4]["tag"], "markdown") + self.assertIn("Section 3", elements[4]["content"]) + + @patch.dict(os.environ, {}, clear=True) + def test_build_card_elements_preserves_hr_inside_code_blocks(self): + """--- inside fenced code blocks should stay literal, not become hr.""" + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + payload = json.loads(adapter._build_card_payload( + "Before\n```\n---\nstill code\n```\nAfter" + )) + elements = payload["elements"] + # Everything stays in one markdown element; the code block's --- + # must NOT be split out into a separate hr element. + self.assertEqual(len(elements), 1) + self.assertEqual(elements[0]["tag"], "markdown") + self.assertIn("Before", elements[0]["content"]) + self.assertIn("---", elements[0]["content"]) + self.assertIn("After", elements[0]["content"]) + @unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed") class TestHydrateBotIdentity(unittest.TestCase):