Skip to content

Commit e30a454

Browse files
committed
Simplify Feishu result cards
1 parent ad2332c commit e30a454

3 files changed

Lines changed: 320 additions & 61 deletions

File tree

channels/feishu_channel.py

Lines changed: 187 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import threading
1818
from datetime import datetime, timedelta, timezone
1919
from pathlib import Path
20-
from typing import TYPE_CHECKING, Optional
20+
from typing import TYPE_CHECKING, Any, Optional
2121

2222
from taskboard_bus import Channel, MessageBus, OutboundMessage, OutboundMessageType
2323

@@ -210,6 +210,13 @@ def send(self, msg: OutboundMessage) -> None:
210210
]
211211
content = error_text
212212

213+
card = self._build_notification_card(
214+
task_id=task_id,
215+
task=task,
216+
is_completed=is_completed,
217+
body_text=content,
218+
)
219+
213220
# Try to reply in thread if we have an origin message
214221
with self._origin_lock:
215222
origin = self._task_origin.get(task_id)
@@ -220,13 +227,13 @@ def send(self, msg: OutboundMessage) -> None:
220227
# Add emoji reaction to the message that triggered the task (or resume)
221228
emoji = "DONE" if is_completed else "Cry"
222229
self._add_reaction(reaction_msg_id, emoji)
223-
sent_id = self._reply_message(root_msg_id, content)
230+
sent_id = self._reply_message(root_msg_id, content, card=card)
224231

225232
# Fallback: send to default chat if no origin or reply failed
226233
if not sent_id:
227234
chat_id = self.db.get_setting("feishu_default_chat_id")
228235
if chat_id:
229-
sent_id = self._send_message(chat_id, content)
236+
sent_id = self._send_message(chat_id, content, card=card, fallback_content=content)
230237

231238
if sent_id:
232239
print(f"[Feishu] Notification sent successfully, message_id: {sent_id}")
@@ -246,95 +253,214 @@ def _on_outbound(self, msg: OutboundMessage) -> None:
246253

247254
# ── outbound: low-level send ──────────────────────────────────
248255

249-
def _send_message(self, chat_id: str, content: str) -> Optional[str]:
250-
"""Send a markdown card to chat_id. Returns the sent message_id or None."""
256+
def _send_message(
257+
self,
258+
chat_id: str,
259+
content: str,
260+
card: Optional[dict[str, Any]] = None,
261+
fallback_content: Optional[str] = None,
262+
) -> Optional[str]:
263+
"""Send a card to chat_id. Falls back to the legacy markdown card on failure."""
251264
print(f"[Feishu] _send_message called, chat_id: {chat_id}, content length: {len(content)}")
252265
if not self._client:
253266
print("[Feishu] Client not initialized in _send_message")
254267
return None
255268
try:
256269
receive_id_type = "chat_id" if chat_id.startswith("oc_") else "open_id"
257270
print(f"[Feishu] receive_id_type: {receive_id_type}")
258-
card = {
259-
"config": {"wide_screen_mode": True},
260-
"elements": [{"tag": "markdown", "content": content}],
261-
}
262-
print("[Feishu] Building CreateMessageRequest...")
263-
request = (
264-
CreateMessageRequest.builder()
265-
.receive_id_type(receive_id_type)
266-
.request_body(
267-
CreateMessageRequestBody.builder()
268-
.receive_id(chat_id)
269-
.msg_type("interactive")
270-
.content(json.dumps(card, ensure_ascii=False))
271-
.build()
272-
)
273-
.build()
271+
card_payload = card or self._build_legacy_markdown_card(content)
272+
message_id = self._create_message(
273+
receive_id_type=receive_id_type,
274+
chat_id=chat_id,
275+
card=card_payload,
274276
)
275-
print("[Feishu] Calling im.v1.message.create()...")
276-
response = self._client.im.v1.message.create(request)
277-
print(
278-
f"[Feishu] Response received: success={response.success()}, code={response.code}, msg={response.msg}"
279-
)
280-
if response.success():
281-
message_id = response.data.message_id
282-
print(f"[Feishu] Message sent successfully, message_id: {message_id}")
277+
if message_id:
283278
return message_id
284-
else:
285-
print(f"[Feishu] Send failed: {response.code} {response.msg}")
286-
return None
279+
280+
if card is not None:
281+
legacy_content = fallback_content or content
282+
print("[Feishu] Structured card send failed, retrying with legacy markdown card")
283+
return self._create_message(
284+
receive_id_type=receive_id_type,
285+
chat_id=chat_id,
286+
card=self._build_legacy_markdown_card(legacy_content),
287+
)
288+
return None
287289
except Exception as e:
288290
print(f"[Feishu] Error sending message: {e}")
289291
import traceback
290292

291293
traceback.print_exc()
292294
return None
293295

294-
def _reply_message(self, parent_message_id: str, content: str) -> Optional[str]:
295-
"""Reply to a specific message (thread-style). Returns the sent message_id or None."""
296+
def _reply_message(
297+
self,
298+
parent_message_id: str,
299+
content: str,
300+
card: Optional[dict[str, Any]] = None,
301+
) -> Optional[str]:
302+
"""Reply to a specific message (thread-style). Falls back to the legacy markdown card."""
296303
print(
297304
f"[Feishu] _reply_message called, parent_message_id: {parent_message_id}, content length: {len(content)}"
298305
)
299306
if not self._client:
300307
print("[Feishu] Client not initialized in _reply_message")
301308
return None
302309
try:
303-
card = {
304-
"config": {"wide_screen_mode": True},
305-
"elements": [{"tag": "markdown", "content": content}],
306-
}
307-
request = (
308-
ReplyMessageRequest.builder()
309-
.message_id(parent_message_id)
310-
.request_body(
311-
ReplyMessageRequestBody.builder()
312-
.msg_type("interactive")
313-
.content(json.dumps(card, ensure_ascii=False))
314-
.reply_in_thread(True)
315-
.build()
316-
)
317-
.build()
318-
)
319-
print("[Feishu] Calling im.v1.message.reply()...")
320-
response = self._client.im.v1.message.reply(request)
321-
print(
322-
f"[Feishu] Reply response: success={response.success()}, code={response.code}, msg={response.msg}"
323-
)
324-
if response.success():
325-
message_id = response.data.message_id
326-
print(f"[Feishu] Reply sent successfully, message_id: {message_id}")
310+
reply_card = card or self._build_legacy_markdown_card(content)
311+
message_id = self._create_reply(parent_message_id=parent_message_id, card=reply_card)
312+
if message_id:
327313
return message_id
328-
else:
329-
print(f"[Feishu] Reply failed: {response.code} {response.msg}")
330-
return None
314+
315+
if card is not None:
316+
print("[Feishu] Structured card reply failed, retrying with legacy markdown card")
317+
return self._create_reply(
318+
parent_message_id=parent_message_id,
319+
card=self._build_legacy_markdown_card(content),
320+
)
321+
return None
331322
except Exception as e:
332323
print(f"[Feishu] Error replying to message: {e}")
333324
import traceback
334325

335326
traceback.print_exc()
336327
return None
337328

329+
def _create_message(self, receive_id_type: str, chat_id: str, card: dict[str, Any]) -> Optional[str]:
330+
print("[Feishu] Building CreateMessageRequest...")
331+
request = (
332+
CreateMessageRequest.builder()
333+
.receive_id_type(receive_id_type)
334+
.request_body(
335+
CreateMessageRequestBody.builder()
336+
.receive_id(chat_id)
337+
.msg_type("interactive")
338+
.content(json.dumps(card, ensure_ascii=False))
339+
.build()
340+
)
341+
.build()
342+
)
343+
print("[Feishu] Calling im.v1.message.create()...")
344+
response = self._client.im.v1.message.create(request)
345+
print(
346+
f"[Feishu] Response received: success={response.success()}, code={response.code}, msg={response.msg}"
347+
)
348+
if response.success():
349+
message_id = response.data.message_id
350+
print(f"[Feishu] Message sent successfully, message_id: {message_id}")
351+
return message_id
352+
353+
print(f"[Feishu] Send failed: {response.code} {response.msg}")
354+
return None
355+
356+
def _create_reply(self, parent_message_id: str, card: dict[str, Any]) -> Optional[str]:
357+
request = (
358+
ReplyMessageRequest.builder()
359+
.message_id(parent_message_id)
360+
.request_body(
361+
ReplyMessageRequestBody.builder()
362+
.msg_type("interactive")
363+
.content(json.dumps(card, ensure_ascii=False))
364+
.reply_in_thread(True)
365+
.build()
366+
)
367+
.build()
368+
)
369+
print("[Feishu] Calling im.v1.message.reply()...")
370+
response = self._client.im.v1.message.reply(request)
371+
print(
372+
f"[Feishu] Reply response: success={response.success()}, code={response.code}, msg={response.msg}"
373+
)
374+
if response.success():
375+
message_id = response.data.message_id
376+
print(f"[Feishu] Reply sent successfully, message_id: {message_id}")
377+
return message_id
378+
379+
print(f"[Feishu] Reply failed: {response.code} {response.msg}")
380+
return None
381+
382+
def _build_notification_card(
383+
self,
384+
task_id: int,
385+
task: dict[str, Any],
386+
is_completed: bool,
387+
body_text: str,
388+
) -> dict[str, Any]:
389+
clean_body = (body_text or "").strip() or ("Done." if is_completed else "Unknown error")
390+
summary = self._truncate_text(clean_body.splitlines()[0], 120) if clean_body else ""
391+
elements = self._build_result_elements(body_text=clean_body)
392+
393+
if not is_completed:
394+
elements.append(
395+
{
396+
"tag": "markdown",
397+
"content": f"`/status {task_id}` for full details",
398+
}
399+
)
400+
401+
return {
402+
"schema": "2.0",
403+
"config": {
404+
"wide_screen_mode": True,
405+
"enable_forward": True,
406+
"width_mode": "fill",
407+
"summary": {"content": summary},
408+
},
409+
"body": {
410+
"elements": elements,
411+
},
412+
}
413+
414+
def _build_result_elements(self, body_text: str) -> list[dict[str, Any]]:
415+
clean_body = (body_text or "").strip() or "Done."
416+
if len(clean_body) <= 1200:
417+
return [
418+
{
419+
"tag": "markdown",
420+
"content": clean_body,
421+
}
422+
]
423+
424+
preview = self._truncate_text(clean_body, 500)
425+
full_text = self._truncate_text(clean_body, 8000)
426+
return [
427+
{
428+
"tag": "markdown",
429+
"content": preview,
430+
},
431+
{
432+
"tag": "collapsible_panel",
433+
"expanded": False,
434+
"header": {
435+
"title": {
436+
"tag": "plain_text",
437+
"content": "展开查看完整结果",
438+
}
439+
},
440+
"elements": [
441+
{
442+
"tag": "markdown",
443+
"content": full_text,
444+
}
445+
],
446+
},
447+
]
448+
449+
def _build_legacy_markdown_card(self, content: str) -> dict[str, Any]:
450+
return {
451+
"config": {"wide_screen_mode": True},
452+
"elements": [{"tag": "markdown", "content": content}],
453+
}
454+
455+
def _truncate_text(self, text: str, limit: int) -> str:
456+
normalized = text.replace("\r\n", "\n").strip()
457+
if len(normalized) <= limit:
458+
return normalized
459+
return normalized[:limit].rstrip() + "\n…(truncated)"
460+
461+
def _escape_feishu_markdown(self, text: str) -> str:
462+
return text.replace("\\", "\\\\")
463+
338464
def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP"):
339465
"""Add an emoji reaction in a background thread (non-blocking)."""
340466
if not self._client or not FEISHU_AVAILABLE:

docs/todo.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
## In Progress
44

5+
- [ ] **修复 Feishu 默认通知接收人 `open_id cross app`** — 排查默认通知 fallback 使用 `open_id` 发送时的跨应用问题,并补充更安全的接收人解析与提示文案
6+
- [x] **优化 Feishu 结果展示** — 参考 `agentara` 的消息渲染实现,梳理当前 Feishu 输出格式并改进可读性与结构化展示
7+
- ✅ 已完成:`channels/feishu_channel.py` 改为生成更简洁的结构化 Feishu 卡片,成功时直接展示最终结果,长文本使用折叠面板承载完整内容
8+
- ✅ 已完成:发送/回复链路增加 legacy markdown 卡片回退,避免新卡片不兼容时通知直接失败
9+
- ✅ 验证:`uv run pytest tests/test_feishu_message_rendering.py tests/test_feishu_forwarded_messages.py -q` 通过,`19 passed`
510
- [x] **修复转发消息时间格式测试失败** — 统一 Feishu / Telegram 转发时间按北京时间 `UTC+8` 格式化
611
- ✅ 已修复:`channels/feishu_channel.py``channels/telegram_channel.py` 不再依赖进程本地时区
712
- ✅ 验证:相关 3 个失败用例通过;`tests/test_feishu_forwarded_messages.py``tests/test_telegram_forwarded_messages.py` 全部通过

0 commit comments

Comments
 (0)