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
79 changes: 70 additions & 9 deletions gateway/platforms/feishu.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
import os
import re
import threading
import unicodedata
import time
import uuid
from collections import OrderedDict
Expand Down Expand Up @@ -153,15 +154,77 @@
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)
# Detect markdown tables: header + separator + optional body rows.
# Converted to box-drawing code blocks for monospace alignment in Feishu.
_MARKDOWN_TABLE_RE = re.compile(r"^\|.*\|\n\|[-|: ]+\|(?:\n\|.+\|)*", re.MULTILINE)
_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 _convert_md_tables(text: str) -> str:
"""Convert markdown tables to box-drawing characters inside code fences.

Feishu's ``md`` tag does not render markdown tables. Sending table
content as a plain ``text`` message loses all formatting. Instead we
parse the table, lay out columns with box-drawing characters, and wrap
the result in a fenced code block so Feishu renders it in monospace.
"""

def _table_to_box(table_text: str) -> str:
lines = table_text.strip().splitlines()
if len(lines) < 2:
return table_text

def _parse_row(line: str) -> list[str]:
return [c.strip() for c in line.strip().strip("|").split("|")]

header = _parse_row(lines[0])
sep = _parse_row(lines[1])
col_count = max(len(header), len(sep))
header.extend([""] * (col_count - len(header)))
body = []
for line in lines[2:]:
cols = _parse_row(line)
cols.extend([""] * (col_count - len(cols)))
body.append(cols)

# Measure widths (CJK chars count as 2)
def _display_width(s: str) -> int:
w = 0
for ch in s:
w += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
return w

widths = [_display_width(header[c]) for c in range(col_count)]
for row in body:
for c in range(col_count):
widths[c] = max(widths[c], _display_width(row[c]))

def _pad(cell: str, width: int) -> str:
return cell + " " * (width - _display_width(cell))

def _line(left: str, mid: str, right: str, fill: str) -> str:
return left + mid.join(fill * (w + 2) for w in widths) + right

out: list[str] = []
out.append(_line("┌", "┬", "┐", "─"))
out.append("│ " + " │ ".join(_pad(header[c], widths[c]) for c in range(col_count)) + " │")
out.append(_line("├", "┼", "┤", "─"))
for row in body:
out.append("│ " + " │ ".join(_pad(row[c], widths[c]) for c in range(col_count)) + " │")
out.append(_line("└", "┴", "┘", "─"))
return "\n".join(out)

def _replace(match: re.Match) -> str:
return "```\n" + _table_to_box(match.group(0)) + "\n```"

return _MARKDOWN_TABLE_RE.sub(_replace, text)


# ---------------------------------------------------------------------------
# Media type sets and upload constants
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -4005,12 +4068,10 @@ 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)
# Feishu post-type 'md' elements do not render markdown tables.
# Convert them to box-drawing characters inside code fences so the
# md renderer displays them in monospace with proper alignment.
content = _convert_md_tables(content)
if _MARKDOWN_HINT_RE.search(content):
return "post", _build_markdown_post_payload(content)
text_payload = {"text": content}
Expand Down
59 changes: 59 additions & 0 deletions tests/gateway/test_feishu.py
Original file line number Diff line number Diff line change
Expand Up @@ -4784,3 +4784,62 @@ def test_scenario_post_bot_plus_alice_filters_self_from_hint(self):
# Body: leading @Hermes stripped, Alice preserved, trailing text intact.
self.assertIn("@Alice review the spec with Alice", event.text)
self.assertNotIn("@Hermes @Alice", event.text)


class TestConvertMdTables(unittest.TestCase):
"""Tests for _convert_md_tables — markdown table to box-drawing code block."""

def _convert(self, text: str) -> str:
from gateway.platforms.feishu import _convert_md_tables
return _convert_md_tables(text)

def test_simple_table(self):
md = "| A | B |\n|---|---|\n| 1 | 2 |"
result = self._convert(md)
self.assertIn("```", result)
self.assertIn("┌───┬───┐", result)
self.assertIn("│ A │ B │", result)
self.assertIn("│ 1 │ 2 │", result)
self.assertIn("└───┴───┘", result)

def test_cjk_alignment(self):
md = "| 名称 | 描述 |\n|---|---|\n| 测试 | 这是一个描述 |"
result = self._convert(md)
self.assertIn("```", result)
# CJK chars should be padded to match widest column
self.assertIn("│ 名称 │ 描述 │", result)
self.assertIn("│ 测试 │ 这是一个描述 │", result)

def test_table_with_surrounding_text(self):
md = "Before\n\n| A |\n|---|\n| 1 |\n\nAfter"
result = self._convert(md)
self.assertTrue(result.startswith("Before"))
self.assertTrue(result.endswith("After"))
self.assertIn("```", result)
self.assertIn("│ A │", result)

def test_no_table_unchanged(self):
md = "Just plain text\nno tables here"
result = self._convert(md)
self.assertEqual(result, md)

def test_multiple_tables(self):
md = "| A |\n|---|\n| 1 |\n\nMiddle\n\n| X | Y |\n|---|---|\n| a | b |"
result = self._convert(md)
# Both tables converted
self.assertEqual(result.count("```"), 4) # 2 opening + 2 closing fences
self.assertIn("│ A │", result)
self.assertIn("│ X │ Y │", result)

def test_table_with_alignment_markers(self):
md = "| L | C | R |\n|:--|:-:|--:|\n| a | b | c |"
result = self._convert(md)
self.assertIn("```", result)
self.assertIn("│ L │ C │ R │", result)
self.assertIn("│ a │ b │ c │", result)

def test_empty_cells(self):
md = "| A | B |\n|---|---|\n| | 2 |"
result = self._convert(md)
self.assertIn("```", result)
self.assertIn("│ │ 2 │", result)