Skip to content
Open
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
185 changes: 176 additions & 9 deletions plugins/platforms/feishu/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,15 +154,183 @@
r"(^#{1,6}\s)|(^\s*[-*]\s)|(^\s*\d+\.\s)|(^\s*---+\s*$)|(```)|(`[^`\n]+`)|(\*\*[^*\n].+?\*\*)|(~~[^~\n].+?~~)|(<u>.+?</u>)|(\*[^*\n]+\*)|(\[[^\]]+\]\([^)]+\))|(^>\s)",
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.
_MARKDOWN_TABLE_RE = re.compile(r"^\|.*\|\n\|[-|: ]+\|", re.MULTILINE)
# Markdown table detection / conversion — Feishu `md` tag does not support table
# syntax, so we convert tables to CardKit v2 interactive card table components.
_MARKDOWN_TABLE_LINE_RE = re.compile(r"^\s*\|(.+)\|\s*$")
_MARKDOWN_TABLE_DIVIDER_RE = re.compile(r"^\s*\|(?:[-:]+\|)+\s*$")
_MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
_MARKDOWN_FENCE_OPEN_RE = re.compile(r"^```([^\n`]*)\s*$")
_MARKDOWN_FENCE_CLOSE_RE = re.compile(r"^```\s*$")
_MENTION_RE = re.compile(r"@_user_\d+")
_MULTISPACE_RE = re.compile(r"[ \t]{2,}")
_POST_CONTENT_INVALID_RE = re.compile(r"content format of the post type is incorrect", re.IGNORECASE)


def _parse_markdown_table(text: str) -> List[Dict[str, Any]]:
"""Parse markdown text and extract tables as Feishu CardKit v2 table components.

Returns a list of dicts: each dict is either {"type": "text", "content": str}
or {"type": "table", "headers": [...], "rows": [[...], ...]}.
"""
if not text or "|" not in text:
return [{"type": "text", "content": text}]

lines = text.splitlines(keepends=True)
segments: List[Dict[str, Any]] = []
non_table_parts: List[str] = []
table_lines: List[str] = []
in_table = False

def _flush_non_table() -> None:
if non_table_parts:
joined = "".join(non_table_parts)
if joined:
segments.append({"type": "text", "content": joined})
non_table_parts.clear()

def _flush_table() -> None:
nonlocal table_lines
if not table_lines:
return
header_line = table_lines[0].rstrip("\n")
row_lines = table_lines[2:] if len(table_lines) > 1 and _MARKDOWN_TABLE_DIVIDER_RE.match(table_lines[1]) else table_lines[1:]

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

headers = _parse_cells(header_line)
rows = [_parse_cells(r) for r in row_lines]
for row in rows:
while len(row) < len(headers):
row.append("")
if len(row) > len(headers):
row[:] = row[:len(headers)]

segments.append({"type": "table", "headers": headers, "rows": rows})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This emits a table even when table_lines contains only | literal | or otherwise lacks a GFM divider. Require a validated header-plus-divider sequence before appending a table segment; otherwise return these lines to the text segment.

table_lines = []

for line in lines:
if _MARKDOWN_TABLE_LINE_RE.match(line):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Table detection runs inside fenced code because this parser tracks no fence state. Preserve pipe-shaped examples inside ``` fences as text/code rather than turning them into CardKit tables.

if not in_table:
_flush_non_table()
in_table = True
table_lines.append(line)
continue

if in_table:
if line.strip() == "":
_flush_table()
in_table = False
non_table_parts.append(line)
continue
if _MARKDOWN_TABLE_DIVIDER_RE.match(line):
table_lines.append(line)
continue
if _MARKDOWN_TABLE_LINE_RE.match(line):
table_lines.append(line)
continue
_flush_table()
in_table = False
non_table_parts.append(line)
continue

non_table_parts.append(line)

if in_table:
_flush_table()
_flush_non_table()

return segments


def _build_table_card(headers: List[str], rows: List[List[str]]) -> Dict[str, Any]:
"""Build a Feishu CardKit v2 table component from headers and rows.

Ref: https://open.feishu.cn/document/feishu-cards/card-json-v2-components/content-components/table
"""

def _strip_md_bold(text: str) -> str:
if not text:
return text
text = re.sub(r'\*\*', '', text)
text = re.sub(r'__', '', text)
return text.strip()

columns = []
for i, h in enumerate(headers):
col_name = f"col_{i}"
columns.append({
"name": col_name,
"display_name": _strip_md_bold(h) or " ",
"data_type": "text",
"width": "auto",
})

data_rows = []
for row in rows:
row_obj = {}
for i, cell in enumerate(row):
if i < len(headers):
col_name = f"col_{i}"
row_obj[col_name] = _strip_md_bold(cell) or " "
data_rows.append(row_obj)

return {
"tag": "table",
"columns": columns,
"rows": data_rows,
"header_style": {
"bold": True,
"text_align": "left",
"text_size": "normal",
"background_style": "none",
"text_color": "default",
"lines": 1
},
}


def _build_interactive_card_with_tables(text: str) -> Optional[Dict[str, Any]]:
"""Build a CardKit v2 interactive card if the text contains markdown tables.

Returns None if no tables are found (caller should fall back to post message).
"""
segments = _parse_markdown_table(text)
has_table = any(s["type"] == "table" for s in segments)
if not has_table:
return None

elements: List[Dict[str, Any]] = []
for seg in segments:
if seg["type"] == "text":
content = seg["content"].strip()
if content:
elements.append({
"tag": "markdown",
"content": content,
})
elif seg["type"] == "table":
elements.append(_build_table_card(seg["headers"], seg["rows"]))

if not elements:
return None

return {
"schema": "2.0",
"config": {
"wide_screen_mode": True,
},
"body": {
"elements": elements,
},
}


# ---------------------------------------------------------------------------
# Media type sets and upload constants
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -4375,12 +4543,11 @@ 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)
# Check for markdown tables first — if found, send as CardKit v2 interactive card
# for real table rendering instead of code blocks.
card = _build_interactive_card_with_tables(content)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This helper is also used by edit_message() on current main. Please make the streaming-preview versus final-card behavior explicit and test both call paths before returning an interactive payload for every detected table.

if card is not None:
return "interactive", json.dumps(card, ensure_ascii=False)
if _MARKDOWN_HINT_RE.search(content):
return "post", _build_markdown_post_payload(content)
text_payload = {"text": content}
Expand Down