card_builder.py
6.34 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
"""Feishu card JSON builder for Hermes Feishu plugin.
Builds Feishu interactive card JSON structures from parsed table data
and markdown content.
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional
from .table_parser import ParsedTable, TableColumn, TableCell, parse_table, split_table_and_text
def _build_table_columns(columns: List[TableColumn]) -> List[Dict[str, Any]]:
"""Build Feishu Table column definitions.
Args:
columns: Parsed table column definitions.
Returns:
List of Feishu column spec dicts.
Feishu column format:
{"name": "field_name", "display_name": "显示名称", "width": "auto"}
"""
feishu_cols: List[Dict[str, Any]] = []
for idx, col in enumerate(columns):
spec: Dict[str, Any] = {
"name": f"col_{idx}", # Use col_0, col_1, etc. as field names
"display_name": col.name, # Column header text
"width": "auto",
"field_type": "text", # Feishu requires this field
}
if col.width:
spec["width"] = col.width
feishu_cols.append(spec)
return feishu_cols
def _build_table_rows(
rows: List[List[TableCell]],
columns: List[TableColumn],
) -> List[Dict[str, Any]]:
"""Build Feishu Table row data.
Args:
rows: Parsed table cell data.
columns: Column definitions (for type info).
Returns:
List of Feishu row dicts.
Feishu row format:
{"col_0": {"tag": "plain_text", "content": "..."}, "col_1": {...}}
"""
feishu_rows: List[Dict[str, Any]] = []
for row in rows:
feishu_row: Dict[str, Any] = {}
for idx, cell in enumerate(row):
# Build cell: key is column name (col_0, col_1, etc.)
# When field_type is "text", value should be just the string
feishu_row[f"col_{idx}"] = cell.text
feishu_rows.append(feishu_row)
return feishu_rows
def build_table_card(
table: ParsedTable,
title: str = "📊 数据表格",
template: str = "blue",
) -> Dict[str, Any]:
"""Build a Feishu interactive card containing a Table component.
Args:
table: A parsed table from table_parser.
title: Card header title.
template: Card header color template (blue, wathet, turquoise, green,
yellow, orange, red, carmine, violet, purple, indigo, grey).
Returns:
Complete Feishu card JSON dict.
"""
columns = _build_table_columns(table.headers)
rows = _build_table_rows(table.rows, table.headers)
card: Dict[str, Any] = {
"config": {"wide_screen_mode": True},
"header": {
"title": {"content": title, "tag": "plain_text"},
"template": template,
},
"elements": [
{
"tag": "table",
"columns": columns,
"rows": rows,
}
],
}
return card
def build_content_card(
content: str,
title: Optional[str] = None,
template: str = "blue",
) -> Dict[str, Any]:
"""Build a Feishu card with markdown content (no table).
Used for non-table content that should be sent as a card.
Args:
content: Markdown content for the card body.
title: Optional card header title.
template: Card header color template.
Returns:
Complete Feishu card JSON dict.
"""
card: Dict[str, Any] = {
"config": {"wide_screen_mode": True},
}
if title:
card["header"] = {
"title": {"content": title, "tag": "plain_text"},
"template": template,
}
card["elements"] = [
{
"tag": "markdown",
"content": content,
}
]
return card
def build_mixed_card(
markdown: str,
title: Optional[str] = None,
template: str = "blue",
) -> Optional[Dict[str, Any]]:
"""Build a Feishu card that handles mixed content (text + tables).
If the content contains tables, they are rendered as Table components.
Non-table text is rendered as markdown elements.
Args:
markdown: Full markdown content that may include tables.
title: Optional card header title.
template: Card header color template.
Returns:
Complete Feishu card JSON dict, or None if no tables found
(in which case use build_content_card or send as post message).
"""
tables = parse_table(markdown)
if not tables:
return None
card: Dict[str, Any] = {
"config": {"wide_screen_mode": True},
}
if title:
card["header"] = {
"title": {"content": title, "tag": "plain_text"},
"template": template,
}
elements: List[Dict[str, Any]] = []
table_blocks, text_segments = split_table_and_text(markdown)
# Interleave text and table elements in original order
table_idx = 0
text_idx = 0
# Walk through the original markdown to maintain order
import re
_TABLE_BLOCK_RE = re.compile(
r"((?:^\|[^\n]+\|\s*\n"
r"^\|[\s:|-]+\|\s*\n"
r"(?:^\|[^\n]+\|\s*\n?)*)+)",
re.MULTILINE,
)
last_end = 0
for match in _TABLE_BLOCK_RE.finditer(markdown):
# Text before this table
before = markdown[last_end:match.start()].strip()
if before:
elements.append({
"tag": "markdown",
"content": before,
})
# Table element
if table_idx < len(tables):
table = tables[table_idx]
columns = _build_table_columns(table.headers)
rows = _build_table_rows(table.rows, table.headers)
elements.append({
"tag": "table",
"columns": columns,
"rows": rows,
})
table_idx += 1
last_end = match.end()
# Remaining text after last table
remaining = markdown[last_end:].strip()
if remaining:
elements.append({
"tag": "markdown",
"content": remaining,
})
card["elements"] = elements
return card
def card_to_json(card: Dict[str, Any]) -> str:
"""Serialize a card dict to JSON string.
Args:
card: Feishu card JSON dict.
Returns:
Compact JSON string (ensure_ascii=False for CJK support).
"""
return json.dumps(card, ensure_ascii=False, separators=(",", ":"))