diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e620ee8..82f4662 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,18 @@ { "permissions": { "allow": [ - "Bash(codex exec:*)" + "Bash(codex exec:*)", + "Bash(defuddle parse:*)", + "Bash(curl -s \"https://raw.githubusercontent.com/hetaoBackend/agentara/main/user-home/.claude/skills/daily-hunt/SKILL.md\")", + "Bash(curl:*)", + "Bash(agent-browser --version)", + "Bash(agent-browser open:*)", + "Bash(agent-browser snapshot:*)", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); c=d[''''current_condition''''][0]; print\\(c[''''weatherDesc''''][0][''''value''''], c[''''temp_C'''']+''''°C'''', c[''''humidity'''']+''''%''''\\)\")", + "Bash(grep -v \"^$\\\\|^\\\\s*$\")", + "WebFetch(domain:wttr.in)", + "WebFetch(domain:richerculture.cn)", + "Skill(agent-browser)" ] } } diff --git a/Makefile b/Makefile index 0985ae7..f1c7d4b 100644 --- a/Makefile +++ b/Makefile @@ -37,12 +37,12 @@ build-backend: build-electron: @echo "构建Electron应用..." - cd $(ELECTRON_DIR) && npm run package + cd $(ELECTRON_DIR) && SKIP_BACKEND_BUILD=1 npm run package @echo "Electron应用构建完成" package-dmg: build-backend build-electron @echo "打包DMG文件..." - cd $(ELECTRON_DIR) && npm run make + cd $(ELECTRON_DIR) && SKIP_BACKEND_BUILD=1 npm run make @if [ -f "$(DMG_OUTPUT)" ]; then \ echo "DMG文件生成成功: $(DMG_OUTPUT)"; \ ls -lh "$(DMG_OUTPUT)"; \ diff --git a/channels/feishu_channel.py b/channels/feishu_channel.py index 9e971c6..5b7db8c 100644 --- a/channels/feishu_channel.py +++ b/channels/feishu_channel.py @@ -17,7 +17,7 @@ import threading from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Any, Optional from taskboard_bus import Channel, MessageBus, OutboundMessage, OutboundMessageType @@ -210,6 +210,13 @@ def send(self, msg: OutboundMessage) -> None: ] content = error_text + card = self._build_notification_card( + task_id=task_id, + task=task, + is_completed=is_completed, + body_text=content, + ) + # Try to reply in thread if we have an origin message with self._origin_lock: origin = self._task_origin.get(task_id) @@ -220,13 +227,13 @@ def send(self, msg: OutboundMessage) -> None: # Add emoji reaction to the message that triggered the task (or resume) emoji = "DONE" if is_completed else "Cry" self._add_reaction(reaction_msg_id, emoji) - sent_id = self._reply_message(root_msg_id, content) + sent_id = self._reply_message(root_msg_id, content, card=card) # Fallback: send to default chat if no origin or reply failed if not sent_id: chat_id = self.db.get_setting("feishu_default_chat_id") if chat_id: - sent_id = self._send_message(chat_id, content) + sent_id = self._send_message(chat_id, content, card=card, fallback_content=content) if sent_id: print(f"[Feishu] Notification sent successfully, message_id: {sent_id}") @@ -246,8 +253,14 @@ def _on_outbound(self, msg: OutboundMessage) -> None: # ── outbound: low-level send ────────────────────────────────── - def _send_message(self, chat_id: str, content: str) -> Optional[str]: - """Send a markdown card to chat_id. Returns the sent message_id or None.""" + def _send_message( + self, + chat_id: str, + content: str, + card: Optional[dict[str, Any]] = None, + fallback_content: Optional[str] = None, + ) -> Optional[str]: + """Send a card to chat_id. Falls back to the legacy markdown card on failure.""" print(f"[Feishu] _send_message called, chat_id: {chat_id}, content length: {len(content)}") if not self._client: print("[Feishu] Client not initialized in _send_message") @@ -255,35 +268,24 @@ def _send_message(self, chat_id: str, content: str) -> Optional[str]: try: receive_id_type = "chat_id" if chat_id.startswith("oc_") else "open_id" print(f"[Feishu] receive_id_type: {receive_id_type}") - card = { - "config": {"wide_screen_mode": True}, - "elements": [{"tag": "markdown", "content": content}], - } - print("[Feishu] Building CreateMessageRequest...") - request = ( - CreateMessageRequest.builder() - .receive_id_type(receive_id_type) - .request_body( - CreateMessageRequestBody.builder() - .receive_id(chat_id) - .msg_type("interactive") - .content(json.dumps(card, ensure_ascii=False)) - .build() - ) - .build() + card_payload = card or self._build_legacy_markdown_card(content) + message_id = self._create_message( + receive_id_type=receive_id_type, + chat_id=chat_id, + card=card_payload, ) - print("[Feishu] Calling im.v1.message.create()...") - response = self._client.im.v1.message.create(request) - print( - f"[Feishu] Response received: success={response.success()}, code={response.code}, msg={response.msg}" - ) - if response.success(): - message_id = response.data.message_id - print(f"[Feishu] Message sent successfully, message_id: {message_id}") + if message_id: return message_id - else: - print(f"[Feishu] Send failed: {response.code} {response.msg}") - return None + + if card is not None: + legacy_content = fallback_content or content + print("[Feishu] Structured card send failed, retrying with legacy markdown card") + return self._create_message( + receive_id_type=receive_id_type, + chat_id=chat_id, + card=self._build_legacy_markdown_card(legacy_content), + ) + return None except Exception as e: print(f"[Feishu] Error sending message: {e}") import traceback @@ -291,8 +293,13 @@ def _send_message(self, chat_id: str, content: str) -> Optional[str]: traceback.print_exc() return None - def _reply_message(self, parent_message_id: str, content: str) -> Optional[str]: - """Reply to a specific message (thread-style). Returns the sent message_id or None.""" + def _reply_message( + self, + parent_message_id: str, + content: str, + card: Optional[dict[str, Any]] = None, + ) -> Optional[str]: + """Reply to a specific message (thread-style). Falls back to the legacy markdown card.""" print( f"[Feishu] _reply_message called, parent_message_id: {parent_message_id}, content length: {len(content)}" ) @@ -300,34 +307,18 @@ def _reply_message(self, parent_message_id: str, content: str) -> Optional[str]: print("[Feishu] Client not initialized in _reply_message") return None try: - card = { - "config": {"wide_screen_mode": True}, - "elements": [{"tag": "markdown", "content": content}], - } - request = ( - ReplyMessageRequest.builder() - .message_id(parent_message_id) - .request_body( - ReplyMessageRequestBody.builder() - .msg_type("interactive") - .content(json.dumps(card, ensure_ascii=False)) - .reply_in_thread(True) - .build() - ) - .build() - ) - print("[Feishu] Calling im.v1.message.reply()...") - response = self._client.im.v1.message.reply(request) - print( - f"[Feishu] Reply response: success={response.success()}, code={response.code}, msg={response.msg}" - ) - if response.success(): - message_id = response.data.message_id - print(f"[Feishu] Reply sent successfully, message_id: {message_id}") + reply_card = card or self._build_legacy_markdown_card(content) + message_id = self._create_reply(parent_message_id=parent_message_id, card=reply_card) + if message_id: return message_id - else: - print(f"[Feishu] Reply failed: {response.code} {response.msg}") - return None + + if card is not None: + print("[Feishu] Structured card reply failed, retrying with legacy markdown card") + return self._create_reply( + parent_message_id=parent_message_id, + card=self._build_legacy_markdown_card(content), + ) + return None except Exception as e: print(f"[Feishu] Error replying to message: {e}") import traceback @@ -335,6 +326,143 @@ def _reply_message(self, parent_message_id: str, content: str) -> Optional[str]: traceback.print_exc() return None + def _create_message( + self, receive_id_type: str, chat_id: str, card: dict[str, Any] + ) -> Optional[str]: + print("[Feishu] Building CreateMessageRequest...") + request = ( + CreateMessageRequest.builder() + .receive_id_type(receive_id_type) + .request_body( + CreateMessageRequestBody.builder() + .receive_id(chat_id) + .msg_type("interactive") + .content(json.dumps(card, ensure_ascii=False)) + .build() + ) + .build() + ) + print("[Feishu] Calling im.v1.message.create()...") + response = self._client.im.v1.message.create(request) + print( + f"[Feishu] Response received: success={response.success()}, code={response.code}, msg={response.msg}" + ) + if response.success(): + message_id = response.data.message_id + print(f"[Feishu] Message sent successfully, message_id: {message_id}") + return message_id + + print(f"[Feishu] Send failed: {response.code} {response.msg}") + return None + + def _create_reply(self, parent_message_id: str, card: dict[str, Any]) -> Optional[str]: + request = ( + ReplyMessageRequest.builder() + .message_id(parent_message_id) + .request_body( + ReplyMessageRequestBody.builder() + .msg_type("interactive") + .content(json.dumps(card, ensure_ascii=False)) + .reply_in_thread(True) + .build() + ) + .build() + ) + print("[Feishu] Calling im.v1.message.reply()...") + response = self._client.im.v1.message.reply(request) + print( + f"[Feishu] Reply response: success={response.success()}, code={response.code}, msg={response.msg}" + ) + if response.success(): + message_id = response.data.message_id + print(f"[Feishu] Reply sent successfully, message_id: {message_id}") + return message_id + + print(f"[Feishu] Reply failed: {response.code} {response.msg}") + return None + + def _build_notification_card( + self, + task_id: int, + task: dict[str, Any], + is_completed: bool, + body_text: str, + ) -> dict[str, Any]: + clean_body = (body_text or "").strip() or ("Done." if is_completed else "Unknown error") + summary = self._truncate_text(clean_body.splitlines()[0], 120) if clean_body else "" + elements = self._build_result_elements(body_text=clean_body) + + if not is_completed: + elements.append( + { + "tag": "markdown", + "content": f"`/status {task_id}` for full details", + } + ) + + return { + "schema": "2.0", + "config": { + "wide_screen_mode": True, + "enable_forward": True, + "width_mode": "fill", + "summary": {"content": summary}, + }, + "body": { + "elements": elements, + }, + } + + def _build_result_elements(self, body_text: str) -> list[dict[str, Any]]: + clean_body = (body_text or "").strip() or "Done." + if len(clean_body) <= 1200: + return [ + { + "tag": "markdown", + "content": clean_body, + } + ] + + preview = self._truncate_text(clean_body, 500) + full_text = self._truncate_text(clean_body, 8000) + return [ + { + "tag": "markdown", + "content": preview, + }, + { + "tag": "collapsible_panel", + "expanded": False, + "header": { + "title": { + "tag": "plain_text", + "content": "展开查看完整结果", + } + }, + "elements": [ + { + "tag": "markdown", + "content": full_text, + } + ], + }, + ] + + def _build_legacy_markdown_card(self, content: str) -> dict[str, Any]: + return { + "config": {"wide_screen_mode": True}, + "elements": [{"tag": "markdown", "content": content}], + } + + def _truncate_text(self, text: str, limit: int) -> str: + normalized = text.replace("\r\n", "\n").strip() + if len(normalized) <= limit: + return normalized + return normalized[:limit].rstrip() + "\n…(truncated)" + + def _escape_feishu_markdown(self, text: str) -> str: + return text.replace("\\", "\\\\") + def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP"): """Add an emoji reaction in a background thread (non-blocking).""" if not self._client or not FEISHU_AVAILABLE: diff --git a/docs/todo.md b/docs/todo.md index c1ac1e3..0ecddf0 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -2,6 +2,11 @@ ## In Progress +- [ ] **修复 Feishu 默认通知接收人 `open_id cross app`** — 排查默认通知 fallback 使用 `open_id` 发送时的跨应用问题,并补充更安全的接收人解析与提示文案 +- [x] **优化 Feishu 结果展示** — 参考 `agentara` 的消息渲染实现,梳理当前 Feishu 输出格式并改进可读性与结构化展示 + - ✅ 已完成:`channels/feishu_channel.py` 改为生成更简洁的结构化 Feishu 卡片,成功时直接展示最终结果,长文本使用折叠面板承载完整内容 + - ✅ 已完成:发送/回复链路增加 legacy markdown 卡片回退,避免新卡片不兼容时通知直接失败 + - ✅ 验证:`uv run pytest tests/test_feishu_message_rendering.py tests/test_feishu_forwarded_messages.py -q` 通过,`19 passed` - [x] **修复转发消息时间格式测试失败** — 统一 Feishu / Telegram 转发时间按北京时间 `UTC+8` 格式化 - ✅ 已修复:`channels/feishu_channel.py` 与 `channels/telegram_channel.py` 不再依赖进程本地时区 - ✅ 验证:相关 3 个失败用例通过;`tests/test_feishu_forwarded_messages.py` 和 `tests/test_telegram_forwarded_messages.py` 全部通过 @@ -169,3 +174,9 @@ - 测试验证:Python 进程 PID 变化确认热重载工作正常 - 预计工作量:2-3小时(实际完成时间:约30分钟) - 难度评估:⭐⭐⭐☆☆(中等偏易) + +- [x] **修复 macOS App 创建任务的 Feishu 卡片通知** — 排查 UI 创建任务的结果通知是否绕过当前 Feishu 渲染链路,并统一到优化后的卡片发送逻辑 + - ✅ 已完成:Electron `package/make` 现在会在打包前自动重建 `taskboard-electron/resources/taskboard`,避免 macOS App 继续携带旧的 Python backend + - ✅ 已完成:新增 `taskboard-electron/scripts/build-backend.mjs`,统一复用 `make build-backend` 构建 bundled backend;`Makefile` 同步避免重复构建 + - ✅ 验证:`uv run pytest tests/test_feishu_message_rendering.py tests/test_feishu_forwarded_messages.py -q` 通过,`19 passed` + - ✅ 验证:`node taskboard-electron/scripts/build-backend.mjs` 通过,并生成新的 `taskboard-electron/resources/taskboard` diff --git a/taskboard-electron/package.json b/taskboard-electron/package.json index 4cf3a08..8271d64 100644 --- a/taskboard-electron/package.json +++ b/taskboard-electron/package.json @@ -6,6 +6,8 @@ "main": ".vite/build/main.js", "private": true, "scripts": { + "prepackage": "node scripts/build-backend.mjs", + "premake": "node scripts/build-backend.mjs", "start": "electron-forge start", "package": "electron-forge package", "make": "electron-forge make", diff --git a/taskboard-electron/scripts/build-backend.mjs b/taskboard-electron/scripts/build-backend.mjs new file mode 100644 index 0000000..bc19f8a --- /dev/null +++ b/taskboard-electron/scripts/build-backend.mjs @@ -0,0 +1,22 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +if (process.env.SKIP_BACKEND_BUILD === "1") { + console.log("[build-backend] SKIP_BACKEND_BUILD=1, skipping bundled backend rebuild"); + process.exit(0); +} + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(scriptDir, "..", ".."); + +console.log("[build-backend] Rebuilding bundled Python backend..."); + +const result = spawnSync("make", ["build-backend"], { + cwd: projectRoot, + stdio: "inherit", +}); + +if (result.status !== 0) { + process.exit(result.status ?? 1); +} diff --git a/tests/test_feishu_message_rendering.py b/tests/test_feishu_message_rendering.py new file mode 100644 index 0000000..da1b228 --- /dev/null +++ b/tests/test_feishu_message_rendering.py @@ -0,0 +1,128 @@ +""" +Tests for Feishu outbound message card rendering. +""" + +from unittest.mock import Mock, patch + +import pytest + +from taskboard_bus import OutboundMessage, OutboundMessageType + + +@pytest.fixture +def mock_feishu_channel(): + """Create a FeishuChannel instance with mocked dependencies.""" + with patch("channels.feishu_channel.FEISHU_AVAILABLE", True): + from channels.feishu_channel import FeishuChannel + + bus = Mock() + db = Mock() + scheduler = Mock() + + channel = FeishuChannel(bus, db, scheduler) + channel._client = Mock() + return channel + + +class TestFeishuNotificationCards: + def test_build_completed_card_shows_result_only(self, mock_feishu_channel): + task = { + "id": 42, + "title": "Fix Feishu rendering", + "prompt": "请优化飞书结果展示,让内容更容易扫读。", + "agent": "codex", + "working_dir": "~/workspace/agentforge", + } + result_text = "已完成优化。\n\n- 增加摘要\n- 增加结果预览\n- 增加折叠面板" + + card = mock_feishu_channel._build_notification_card( + task_id=42, + task=task, + is_completed=True, + body_text=result_text, + ) + + assert card["schema"] == "2.0" + assert card["config"]["summary"]["content"] == "已完成优化。" + assert len(card["body"]["elements"]) == 1 + assert any( + element.get("tag") == "markdown" and "增加摘要" in element.get("content", "") + for element in card["body"]["elements"] + ) + assert not any( + "Task #42" in element.get("content", "") or "Prompt" in element.get("content", "") + for element in card["body"]["elements"] + if isinstance(element, dict) + ) + + def test_build_failed_card_adds_status_hint(self, mock_feishu_channel): + task = { + "id": 7, + "title": "Broken task", + "prompt": "调试失败任务", + "agent": "claude", + "working_dir": "~/workspace/agentforge", + } + + card = mock_feishu_channel._build_notification_card( + task_id=7, + task=task, + is_completed=False, + body_text="Traceback: something went wrong", + ) + + assert any( + element.get("tag") == "markdown" and "/status 7" in element.get("content", "") + for element in card["body"]["elements"] + ) + + def test_build_completed_card_wraps_long_body_in_collapsible_panel(self, mock_feishu_channel): + task = { + "id": 99, + "title": "Long output task", + "prompt": "输出一份很长的结果", + "agent": "codex", + "working_dir": "~/workspace/agentforge", + } + long_text = "A" * 2200 + + card = mock_feishu_channel._build_notification_card( + task_id=99, + task=task, + is_completed=True, + body_text=long_text, + ) + + assert any( + element.get("tag") == "collapsible_panel" for element in card["body"]["elements"] + ) + assert any( + element.get("tag") == "markdown" and "AAAA" in element.get("content", "") + for element in card["body"]["elements"] + ) + + def test_send_uses_structured_card_and_fallback_content(self, mock_feishu_channel): + task = { + "id": 5, + "title": "Ship update", + "prompt": "发布更新", + "result": "done", + "error": None, + "agent": "codex", + "working_dir": "~/workspace/agentforge", + } + mock_feishu_channel.db.get_task.return_value = task + mock_feishu_channel.db.get_setting.return_value = "oc_test_chat" + mock_feishu_channel._send_message = Mock(return_value="msg_123") + + msg = OutboundMessage( + type=OutboundMessageType.TASK_COMPLETED, + task_id=5, + payload={"result": "done", "title": "Ship update"}, + ) + + mock_feishu_channel.send(msg) + + _, kwargs = mock_feishu_channel._send_message.call_args + assert kwargs["fallback_content"] == "done" + assert kwargs["card"]["schema"] == "2.0"