Skip to content
Merged
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
13 changes: 12 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -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)"
]
}
}
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)"; \
Expand Down
250 changes: 189 additions & 61 deletions channels/feishu_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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}")
Expand All @@ -246,95 +253,216 @@ 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")
return None
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

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)}"
)
if not self._client:
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

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:
Expand Down
11 changes: 11 additions & 0 deletions docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 全部通过
Expand Down Expand Up @@ -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`
2 changes: 2 additions & 0 deletions taskboard-electron/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading