Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1147,6 +1147,10 @@ def load_gateway_config() -> GatewayConfig:
if isinstance(feishu_cfg, dict):
if "allow_bots" in feishu_cfg and not os.getenv("FEISHU_ALLOW_BOTS"):
os.environ["FEISHU_ALLOW_BOTS"] = str(feishu_cfg["allow_bots"]).lower()
if "outbound_format" in feishu_cfg and not os.getenv("HERMES_FEISHU_OUTBOUND_FORMAT"):
os.environ["HERMES_FEISHU_OUTBOUND_FORMAT"] = str(feishu_cfg["outbound_format"]).lower()
if "message_format" in feishu_cfg and not os.getenv("HERMES_FEISHU_OUTBOUND_FORMAT"):
os.environ["HERMES_FEISHU_OUTBOUND_FORMAT"] = str(feishu_cfg["message_format"]).lower()

except Exception as e:
logger.warning(
Expand Down
197 changes: 185 additions & 12 deletions gateway/platforms/feishu.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ class FeishuAdapterSettings:
group_rules: Dict[str, FeishuGroupRule] = field(default_factory=dict)
allow_bots: str = "none" # "none" | "mentions" | "all"
require_mention: bool = True
outbound_format: str = "auto" # "auto" | "card" | "post" | "text"


@dataclass
Expand Down Expand Up @@ -558,6 +559,128 @@ def _build_markdown_post_payload(content: str) -> str:
)


def _is_markdown_table_separator(line: str) -> bool:
return bool(re.match(r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$", line))


def _split_markdown_table_row(line: str) -> List[str]:
stripped = line.strip()
if stripped.startswith("|"):
stripped = stripped[1:]
if stripped.endswith("|"):
stripped = stripped[:-1]
return [cell.strip() or " " for cell in stripped.split("|")]


def _build_card_table_element(table_lines: List[str]) -> Dict[str, Any]:
"""Render a GitHub-style markdown table as a Feishu Card JSON v2 table.

Card JSON v2 rejects unknown v1 properties and requires card content under
``body.elements``. It also has a native table component; use it instead of
synthetic ``column_set`` rows so pipe tables render reliably in Feishu/Lark.
"""
if len(table_lines) < 2:
return {"tag": "markdown", "content": "\n".join(table_lines)}

header = _split_markdown_table_row(table_lines[0])
rows = [_split_markdown_table_row(line) for line in table_lines[2:]]
column_count = max([len(header), *(len(row) for row in rows)] or [len(header)])
column_count = min(max(column_count, 1), 50)

def _normalize_cells(cells: List[str]) -> List[str]:
padded = cells[:column_count] + [" "] * max(0, column_count - len(cells))
return padded[:column_count]

columns = []
for index, cell in enumerate(_normalize_cells(header)):
columns.append(
{
"name": f"c{index + 1}",
"display_name": cell.strip() or " ",
"data_type": "markdown",
"width": "auto",
"horizontal_align": "left",
"vertical_align": "top",
}
)

table_rows = []
for row in rows:
cells = _normalize_cells(row)
table_rows.append({f"c{index + 1}": cell or " " for index, cell in enumerate(cells)})

return {
"tag": "table",
"page_size": min(max(len(table_rows), 1), 10),
"row_height": "auto",
"header_style": {
"text_align": "left",
"text_size": "normal",
"background_style": "grey",
"text_color": "default",
"bold": True,
"lines": 1,
},
"columns": columns,
"rows": table_rows,
}


def _build_card_elements_from_markdown(content: str) -> List[Dict[str, Any]]:
"""Split markdown content into Feishu card elements, converting pipe tables."""
if not content:
return [{"tag": "markdown", "content": " "}]
if "|" not in content:
return [{"tag": "markdown", "content": content}]

lines = content.splitlines()
elements: List[Dict[str, Any]] = []
markdown_buffer: List[str] = []
index = 0

def _flush_markdown() -> None:
nonlocal markdown_buffer
text = "\n".join(markdown_buffer).strip("\n")
if text.strip():
elements.append({"tag": "markdown", "content": text})
markdown_buffer = []

while index < len(lines):
line = lines[index]
next_line = lines[index + 1] if index + 1 < len(lines) else ""
if "|" in line and _is_markdown_table_separator(next_line):
_flush_markdown()
table_lines = [line, next_line]
index += 2
while index < len(lines) and "|" in lines[index] and lines[index].strip():
table_lines.append(lines[index])
index += 1
elements.append(_build_card_table_element(table_lines))
continue
markdown_buffer.append(line)
index += 1

_flush_markdown()
return elements or [{"tag": "markdown", "content": content or " "}]


def _build_markdown_card_payload(content: str) -> str:
"""Build a Feishu/Lark Card JSON v2 interactive payload for markdown replies."""
return json.dumps(
{
"schema": "2.0",
"config": {
"update_multi": True,
"width_mode": "fill",
},
"body": {
"elements": _build_card_elements_from_markdown(content or " "),
},
},
ensure_ascii=False,
)


def _build_markdown_post_rows(content: str) -> List[List[Dict[str, str]]]:
"""Build Feishu post rows while isolating fenced code blocks.

Expand Down Expand Up @@ -1507,6 +1630,18 @@ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings:
)
allow_bots = "none"

outbound_format = str(
extra.get("outbound_format")
or extra.get("message_format")
or os.getenv("HERMES_FEISHU_OUTBOUND_FORMAT", "auto")
).strip().lower()
if outbound_format not in {"auto", "card", "post", "text"}:
logger.warning(
"[Feishu] Unknown outbound_format=%r, falling back to 'auto'. Valid: auto, card, post, text.",
outbound_format,
)
outbound_format = "auto"

return FeishuAdapterSettings(
app_id=str(extra.get("app_id") or os.getenv("FEISHU_APP_ID", "")).strip(),
app_secret=str(extra.get("app_secret") or os.getenv("FEISHU_APP_SECRET", "")).strip(),
Expand Down Expand Up @@ -1567,6 +1702,7 @@ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings:
require_mention=_to_boolean(
extra.get("require_mention", os.getenv("FEISHU_REQUIRE_MENTION", "true"))
),
outbound_format=outbound_format,
)

def _apply_settings(self, settings: FeishuAdapterSettings) -> None:
Expand Down Expand Up @@ -1599,6 +1735,7 @@ def _apply_settings(self, settings: FeishuAdapterSettings) -> None:
self._ws_ping_timeout = settings.ws_ping_timeout
self._allow_bots = settings.allow_bots
self._require_mention = settings.require_mention
self._outbound_format = settings.outbound_format

def _build_event_handler(self) -> Any:
if EventDispatcherHandler is None:
Expand Down Expand Up @@ -1781,22 +1918,41 @@ async def send(
metadata=metadata,
)
except Exception as exc:
if msg_type != "post" or not _POST_CONTENT_INVALID_RE.search(str(exc)):
if msg_type == "interactive":
logger.warning("[Feishu] Interactive card 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,
)
elif 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")
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",
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")
if msg_type == "interactive" and not self._response_succeeded(response):
logger.warning("[Feishu] Interactive card payload rejected by API response; falling back to plain text")
response = await self._feishu_send_with_retry(
chat_id=chat_id,
msg_type="text",
Expand Down Expand Up @@ -1839,6 +1995,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")
if not result.success and msg_type == "interactive":
logger.warning("[Feishu] Interactive card update 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
Expand Down Expand Up @@ -4222,12 +4387,20 @@ 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):
outbound_format = getattr(self, "_outbound_format", "auto")
if outbound_format == "card":
return "interactive", _build_markdown_card_payload(content)
if outbound_format == "text":
text_payload = {"text": content}
return "text", json.dumps(text_payload, ensure_ascii=False)
if outbound_format == "post":
return "post", _build_markdown_post_payload(content)

# Feishu post-type 'md' elements do not render markdown tables. Card JSON
# v2 has a native table component, so route pipe tables to interactive
# cards instead of sending a blank/unstyled post.
if _MARKDOWN_TABLE_RE.search(content):
return "interactive", _build_markdown_card_payload(content)
if _MARKDOWN_HINT_RE.search(content):
return "post", _build_markdown_post_payload(content)
text_payload = {"text": content}
Expand Down
44 changes: 44 additions & 0 deletions tests/gateway/test_feishu.py
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,50 @@ async def _direct(func, *args, **kwargs):
json.dumps({"text": "可以用 粗体 和 斜体。"}, ensure_ascii=False),
)

@patch.dict(os.environ, {}, clear=True)
def test_outbound_format_card_wraps_markdown_in_interactive_card(self):
from gateway.config import PlatformConfig
from gateway.platforms.feishu import FeishuAdapter

adapter = FeishuAdapter(PlatformConfig(extra={"outbound_format": "card"}))
msg_type, payload = adapter._build_outbound_payload("**Bold**\n\n| A | B |\n|---|---|")

self.assertEqual(msg_type, "interactive")
card = json.loads(payload)
self.assertEqual(card["schema"], "2.0")
self.assertEqual(card["config"]["width_mode"], "fill")
elements = card["body"]["elements"]
self.assertEqual(elements[0]["tag"], "markdown")
self.assertIn("**Bold**", elements[0]["content"])
self.assertEqual(elements[1]["tag"], "table")
self.assertEqual(elements[1]["header_style"]["background_style"], "grey")
self.assertEqual(elements[1]["columns"][0]["display_name"], "A")
self.assertEqual(elements[1]["columns"][0]["data_type"], "markdown")
self.assertEqual(elements[1]["columns"][1]["display_name"], "B")
self.assertEqual(elements[1]["rows"], [])
self.assertNotIn("wide_screen_mode", json.dumps(card, ensure_ascii=False))
self.assertNotIn("column_set", json.dumps(card, ensure_ascii=False))

@patch.dict(os.environ, {}, clear=True)
def test_auto_routes_markdown_tables_to_card_json_v2_table(self):
from gateway.config import PlatformConfig
from gateway.platforms.feishu import FeishuAdapter

adapter = FeishuAdapter(PlatformConfig())
msg_type, payload = adapter._build_outbound_payload(
"| Name | Value |\n|---|---|\n| Alpha | **1** |\n| Beta | [two](https://example.com) |"
)

self.assertEqual(msg_type, "interactive")
card = json.loads(payload)
table = card["body"]["elements"][0]
self.assertEqual(card["schema"], "2.0")
self.assertEqual(table["tag"], "table")
self.assertEqual(table["columns"][0]["display_name"], "Name")
self.assertEqual(table["columns"][1]["display_name"], "Value")
self.assertEqual(table["rows"][0], {"c1": "Alpha", "c2": "**1**"})
self.assertEqual(table["rows"][1], {"c1": "Beta", "c2": "[two](https://example.com)"})

@patch.dict(os.environ, {}, clear=True)
def test_get_chat_info_uses_real_feishu_chat_api(self):
from gateway.config import PlatformConfig
Expand Down