Skip to content
Open
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,4 @@ website/static/api/skills-index.json
models-dev-upstream/
hermes_cli/tui_dist/*
hermes_cli/scripts/
docs/superpowers/*
docs/superpowers/*tinker-atropos/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This concatenates docs/superpowers/* and tinker-atropos/ into one pattern, so neither intended directory is ignored independently. Please remove this unrelated line or restore two newline-separated patterns.

281 changes: 268 additions & 13 deletions gateway/platforms/feishu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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*$")
Expand Down Expand Up @@ -1789,21 +1802,51 @@ 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",
payload=json.dumps({"text": _strip_markdown_to_plain_text(chunk)}, ensure_ascii=False),
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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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("```"):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This toggles the same leading fence already toggled at line 4478. If a heading precedes a fenced block, the opening fence is toggled twice and --- within it reaches the structural-boundary branch as non-code. Track each fence exactly once and add that regression case.

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,
*,
Expand Down Expand Up @@ -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":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not classify every interactive exception as a card rejection. This bypasses the retry logic below for transient transport failures, and send() then emits a text fallback. Restrict immediate fallback to verified card-validation failures or retain normal retries first.

# 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
Expand Down
Loading