Skip to content

Commit 686f741

Browse files
hetaoBackendclaude
andauthored
Fix scheduled task timezone handling and Feishu card UX (#10)
* fix: stabilize forwarded message timestamps * fix lint * Simplify Feishu result cards * fix lint * Rebuild bundled backend before packaging * Fix scheduled_at timezone handling and Feishu card UX * fix lint --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 35638ba commit 686f741

6 files changed

Lines changed: 291 additions & 35 deletions

File tree

.claude/settings.local.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
"Bash(grep -v \"^$\\\\|^\\\\s*$\")",
1313
"WebFetch(domain:wttr.in)",
1414
"WebFetch(domain:richerculture.cn)",
15-
"Skill(agent-browser)"
15+
"Skill(agent-browser)",
16+
"Bash(git fetch:*)",
17+
"Bash(git merge:*)",
18+
"Bash(uv run:*)",
19+
"Bash(git:*)"
1620
]
1721
}
1822
}

channels/feishu_channel.py

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@
6565
• 回复任意结果通知即可继续对话。
6666
"""
6767

68+
FEISHU_RESULT_PREVIEW_LIMIT = 500
69+
FEISHU_INLINE_RESULT_LIMIT = 1200
70+
FEISHU_CARD_MARKDOWN_CHUNK = 7000
71+
FEISHU_FALLBACK_MARKDOWN_LIMIT = 8000
72+
6873

6974
class FeishuChannel(Channel):
7075
"""Feishu/Lark channel integration using WebSocket long-connection."""
@@ -201,8 +206,6 @@ def send(self, msg: OutboundMessage) -> None:
201206

202207
if is_completed:
203208
result_text = (msg.payload.get("result") or task.get("result") or "").strip()
204-
if len(result_text) > 10000:
205-
result_text = result_text[:10000] + "\n…(truncated)"
206209
content = result_text or "Done."
207210
else:
208211
error_text = (msg.payload.get("error") or task.get("error") or "Unknown error").strip()[
@@ -233,7 +236,12 @@ def send(self, msg: OutboundMessage) -> None:
233236
if not sent_id:
234237
chat_id = self.db.get_setting("feishu_default_chat_id")
235238
if chat_id:
236-
sent_id = self._send_message(chat_id, content, card=card, fallback_content=content)
239+
sent_id = self._send_message(
240+
chat_id,
241+
content,
242+
card=card,
243+
fallback_content=self._truncate_text(content, FEISHU_FALLBACK_MARKDOWN_LIMIT),
244+
)
237245

238246
if sent_id:
239247
print(f"[Feishu] Notification sent successfully, message_id: {sent_id}")
@@ -415,21 +423,19 @@ def _build_notification_card(
415423

416424
def _build_result_elements(self, body_text: str) -> list[dict[str, Any]]:
417425
clean_body = (body_text or "").strip() or "Done."
418-
if len(clean_body) <= 1200:
426+
if len(clean_body) <= FEISHU_INLINE_RESULT_LIMIT:
419427
return [
420428
{
421429
"tag": "markdown",
422430
"content": clean_body,
423431
}
424432
]
425433

426-
preview = self._truncate_text(clean_body, 500)
427-
full_text = self._truncate_text(clean_body, 8000)
434+
panel_elements = [
435+
{"tag": "markdown", "content": chunk}
436+
for chunk in self._chunk_text(clean_body, FEISHU_CARD_MARKDOWN_CHUNK)
437+
]
428438
return [
429-
{
430-
"tag": "markdown",
431-
"content": preview,
432-
},
433439
{
434440
"tag": "collapsible_panel",
435441
"expanded": False,
@@ -439,12 +445,7 @@ def _build_result_elements(self, body_text: str) -> list[dict[str, Any]]:
439445
"content": "展开查看完整结果",
440446
}
441447
},
442-
"elements": [
443-
{
444-
"tag": "markdown",
445-
"content": full_text,
446-
}
447-
],
448+
"elements": panel_elements,
448449
},
449450
]
450451

@@ -460,6 +461,12 @@ def _truncate_text(self, text: str, limit: int) -> str:
460461
return normalized
461462
return normalized[:limit].rstrip() + "\n…(truncated)"
462463

464+
def _chunk_text(self, text: str, limit: int) -> list[str]:
465+
normalized = text.replace("\r\n", "\n")
466+
if not normalized:
467+
return [""]
468+
return [normalized[i : i + limit] for i in range(0, len(normalized), limit)]
469+
463470
def _escape_feishu_markdown(self, text: str) -> str:
464471
return text.replace("\\", "\\\\")
465472

docs/todo.md

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

33
## In Progress
44

5+
- [x] **修复 Feishu 卡片展开后仍显示 truncated 内容** — 排查折叠面板完整结果的组装逻辑,确保展开后显示完整正文而不是再次裁剪后的剩余片段
6+
- ✅ 已修复:长结果卡片改为”折叠态仅显示 summary,展开态显示完整正文”,不再在面板外额外渲染截断预览
7+
- ✅ 已修复:展开区按 chunk 承载完整正文,避免 `truncated` 出现在展开内容里,也避免预览与正文重复阅读
8+
- ✅ 验证:`pytest -q tests/test_feishu_message_rendering.py` 通过,`7 passed`
9+
- [ ] **起草前后端大文件拆分 RFC** — 基于 `taskboard.py``taskboard-electron/src/renderer/App.jsx` 的现状,设计可渐进落地的重构方案、模块边界与迁移节奏
10+
- [x] **修复 macOS App 中 scheduled_at 时间未按系统时区展示** — 统一 `scheduled_at` 在前端列表展示、详情展示、编辑弹窗回填的本地时区格式,避免 `toISOString()` 导致的 UTC 偏移
11+
- ✅ 已修复:新增 renderer 时间工具,统一解析 offset-aware/naive 时间;`scheduled_at` 编辑弹窗回填不再走 `toISOString()`
12+
- ✅ 已修复:任务卡片、任务详情、heartbeat 面板和事件时间展示统一走本地时区格式化
13+
- ✅ 验证:`TZ=Asia/Shanghai node --test taskboard-electron/src/renderer/dateTime.test.mjs` 通过;`cd taskboard-electron && npx vite build --config vite.renderer.config.mjs` 通过
14+
- [x] **修复 scheduler 的 naive/aware datetime 比较崩溃** — 统一 `scheduled_at``next_run_at` 存储格式,并兼容旧数据中的 offset-aware ISO 时间,避免 scheduler tick 抛出 `can't compare offset-naive and offset-aware datetimes`
15+
- ✅ 已修复:后端会把 offset-aware 的 `next_run_at` 归一化为本地 naive 时间存储,`get_due_tasks()` / `get_due_heartbeats()` 改为 Python 层按规范化后的 datetime 判断 due
16+
- ✅ 已修复:scheduler tick 与 heartbeat dedupe cooldown 都兼容旧数据里的 offset-aware ISO 时间,不再触发 naive/aware 比较异常
17+
- ✅ 验证:`uv run pytest -q` 通过,`61 passed`(存在既有 warning:Telegram 测试里有一个未 awaited coroutine)
518
- [ ] **修复 Feishu 默认通知接收人 `open_id cross app`** — 排查默认通知 fallback 使用 `open_id` 发送时的跨应用问题,并补充更安全的接收人解析与提示文案
619
- [x] **优化 Feishu 结果展示** — 参考 `agentara` 的消息渲染实现,梳理当前 Feishu 输出格式并改进可读性与结构化展示
720
- ✅ 已完成:`channels/feishu_channel.py` 改为生成更简洁的结构化 Feishu 卡片,成功时直接展示最终结果,长文本使用折叠面板承载完整内容

taskboard.py

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,33 @@ def _get_env() -> dict:
9090
return env
9191

9292

93+
def _parse_comparable_datetime(value: Optional[str]) -> Optional[datetime]:
94+
"""Parse ISO datetimes and collapse aware values into local naive datetimes.
95+
96+
The app historically stored naive local timestamps, but the Electron UI can
97+
submit offset-aware ISO strings for `scheduled_at`. Converting aware values
98+
into the local timezone and stripping tzinfo keeps storage/comparisons
99+
consistent with the rest of the backend while remaining backward compatible
100+
with legacy rows.
101+
"""
102+
if not value:
103+
return None
104+
dt = datetime.fromisoformat(value)
105+
if dt.tzinfo is not None:
106+
return dt.astimezone().replace(tzinfo=None)
107+
return dt
108+
109+
110+
def _normalize_datetime_for_storage(value: Optional[str]) -> Optional[str]:
111+
if value is None:
112+
return None
113+
try:
114+
dt = _parse_comparable_datetime(value)
115+
except ValueError:
116+
return value
117+
return dt.isoformat() if dt else None
118+
119+
93120
# ──────────────────────────── Models ────────────────────────────
94121

95122

@@ -623,18 +650,25 @@ def get_all_heartbeats(self) -> list[dict]:
623650
return [self._deserialize_heartbeat(r) for r in rows]
624651

625652
def get_due_heartbeats(self) -> list[dict]:
626-
now = datetime.now().isoformat()
627653
with self.lock:
628654
rows = self.conn.execute(
629655
"""
630656
SELECT * FROM heartbeats
631657
WHERE enabled = 1
632658
AND next_run_at IS NOT NULL
633-
AND next_run_at <= ?
634-
""",
635-
(now,),
659+
"""
636660
).fetchall()
637-
return [self._deserialize_heartbeat(r) for r in rows]
661+
now = datetime.now()
662+
due = []
663+
for row in rows:
664+
heartbeat = self._deserialize_heartbeat(row)
665+
try:
666+
next_run_at = _parse_comparable_datetime(heartbeat.get("next_run_at"))
667+
except ValueError:
668+
continue
669+
if next_run_at and next_run_at <= now:
670+
due.append(heartbeat)
671+
return due
638672

639673
def delete_heartbeat(self, heartbeat_id: int):
640674
with self.transaction():
@@ -783,6 +817,8 @@ def update_task(self, task_id: int, **kwargs):
783817
if invalid:
784818
raise ValueError(f"Invalid task column(s): {invalid}")
785819
with self.lock:
820+
if "next_run_at" in kwargs:
821+
kwargs["next_run_at"] = _normalize_datetime_for_storage(kwargs["next_run_at"])
786822
kwargs["updated_at"] = datetime.now().isoformat()
787823
sets = ", ".join(f"{k} = ?" for k in kwargs)
788824
vals = list(kwargs.values()) + [task_id]
@@ -822,17 +858,24 @@ def get_all_tasks(self) -> list[dict]:
822858
return [self._deserialize_task(r) for r in rows]
823859

824860
def get_due_tasks(self) -> list[dict]:
825-
now = datetime.now().isoformat()
826861
with self.lock:
827862
rows = self.conn.execute(
828863
"""
829864
SELECT * FROM tasks
830865
WHERE status IN ('pending', 'scheduled')
831-
AND (next_run_at IS NULL OR next_run_at <= ?)
832-
""",
833-
(now,),
866+
"""
834867
).fetchall()
835-
return [self._deserialize_task(r) for r in rows]
868+
now = datetime.now()
869+
due = []
870+
for row in rows:
871+
task = self._deserialize_task(row)
872+
try:
873+
next_run_at = _parse_comparable_datetime(task.get("next_run_at"))
874+
except ValueError:
875+
continue
876+
if next_run_at is None or next_run_at <= now:
877+
due.append(task)
878+
return due
836879

837880
def add_run(self, task_id: int) -> int:
838881
with self.lock:
@@ -1195,15 +1238,18 @@ def _tick(self):
11951238
self._schedule_delayed(task)
11961239
elif task["schedule_type"] == "delayed" and task["status"] == "scheduled":
11971240
nra = task.get("next_run_at")
1198-
if nra and datetime.fromisoformat(nra) <= datetime.now():
1241+
run_at = _parse_comparable_datetime(nra) if nra else None
1242+
if run_at and run_at <= datetime.now():
11991243
self._spawn_task(task)
12001244
elif task["schedule_type"] == "scheduled_at" and task["status"] == "scheduled":
12011245
nra = task.get("next_run_at")
1202-
if nra and datetime.fromisoformat(nra) <= datetime.now():
1246+
run_at = _parse_comparable_datetime(nra) if nra else None
1247+
if run_at and run_at <= datetime.now():
12031248
self._spawn_task(task)
12041249
elif task["schedule_type"] == "cron" and task["status"] == "scheduled":
12051250
nra = task.get("next_run_at")
1206-
if nra and datetime.fromisoformat(nra) <= datetime.now():
1251+
run_at = _parse_comparable_datetime(nra) if nra else None
1252+
if run_at and run_at <= datetime.now():
12071253
self._spawn_task(task)
12081254
due_heartbeats = self.db.get_due_heartbeats()
12091255
for heartbeat in due_heartbeats:
@@ -1427,7 +1473,7 @@ def _heartbeat_trigger_suppressed(self, heartbeat: dict, dedupe_key: str) -> boo
14271473
triggered_at = existing.get("triggered_at")
14281474
if triggered_at:
14291475
try:
1430-
triggered_dt = datetime.fromisoformat(triggered_at)
1476+
triggered_dt = _parse_comparable_datetime(triggered_at)
14311477
if cooldown > 0 and datetime.now() < triggered_dt + timedelta(seconds=cooldown):
14321478
return True
14331479
except ValueError:
@@ -2211,6 +2257,7 @@ def submit_task(self, task: Task, depends_on: list = None) -> int:
22112257
task.status = TaskStatus.SCHEDULED
22122258
if not task.next_run_at:
22132259
raise ValueError("scheduled_at requires next_run_at to be set")
2260+
task.next_run_at = _normalize_datetime_for_storage(task.next_run_at)
22142261
elif task.schedule_type == ScheduleType.CRON:
22152262
task.status = TaskStatus.SCHEDULED
22162263
if task.cron_expr:

tests/test_feishu_message_rendering.py

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,74 @@ def test_build_completed_card_wraps_long_body_in_collapsible_panel(self, mock_fe
9393
body_text=long_text,
9494
)
9595

96-
assert any(
97-
element.get("tag") == "collapsible_panel" for element in card["body"]["elements"]
96+
assert card["body"]["elements"][0]["tag"] == "collapsible_panel"
97+
assert card["body"]["elements"][0]["elements"][0]["content"] == long_text
98+
99+
def test_build_completed_card_expanded_panel_keeps_full_remainder(self, mock_feishu_channel):
100+
task = {
101+
"id": 100,
102+
"title": "Long output task",
103+
"prompt": "输出一份很长的结果",
104+
"agent": "codex",
105+
"working_dir": "~/workspace/agentforge",
106+
}
107+
long_text = "A" * 500 + "B" * 7600
108+
109+
card = mock_feishu_channel._build_notification_card(
110+
task_id=100,
111+
task=task,
112+
is_completed=True,
113+
body_text=long_text,
98114
)
99-
assert any(
100-
element.get("tag") == "markdown" and "AAAA" in element.get("content", "")
115+
116+
panel = next(
117+
element
101118
for element in card["body"]["elements"]
119+
if element.get("tag") == "collapsible_panel"
120+
)
121+
expanded_text = panel["elements"][0]["content"]
122+
123+
assert expanded_text.startswith("A" * 20)
124+
assert expanded_text.endswith("B" * 20)
125+
assert "truncated" not in expanded_text
126+
127+
def test_build_completed_card_long_body_uses_summary_plus_full_panel(self, mock_feishu_channel):
128+
task = {
129+
"id": 101,
130+
"title": "Long output task",
131+
"prompt": "输出一份很长的结果",
132+
"agent": "codex",
133+
"working_dir": "~/workspace/agentforge",
134+
}
135+
long_text = "Summary line\n" + ("A" * 1600)
136+
137+
card = mock_feishu_channel._build_notification_card(
138+
task_id=101,
139+
task=task,
140+
is_completed=True,
141+
body_text=long_text,
102142
)
103143

144+
assert card["config"]["summary"]["content"] == "Summary line"
145+
assert card["body"]["elements"] == [
146+
{
147+
"tag": "collapsible_panel",
148+
"expanded": False,
149+
"header": {
150+
"title": {
151+
"tag": "plain_text",
152+
"content": "展开查看完整结果",
153+
}
154+
},
155+
"elements": [
156+
{
157+
"tag": "markdown",
158+
"content": long_text,
159+
}
160+
],
161+
}
162+
]
163+
104164
def test_send_uses_structured_card_and_fallback_content(self, mock_feishu_channel):
105165
task = {
106166
"id": 5,
@@ -126,3 +186,32 @@ def test_send_uses_structured_card_and_fallback_content(self, mock_feishu_channe
126186
_, kwargs = mock_feishu_channel._send_message.call_args
127187
assert kwargs["fallback_content"] == "done"
128188
assert kwargs["card"]["schema"] == "2.0"
189+
190+
def test_send_completed_notification_keeps_full_result_for_card(self, mock_feishu_channel):
191+
task = {
192+
"id": 6,
193+
"title": "Long result",
194+
"prompt": "发布长结果",
195+
"result": None,
196+
"error": None,
197+
"agent": "codex",
198+
"working_dir": "~/workspace/agentforge",
199+
}
200+
long_result = "A" * 12050
201+
mock_feishu_channel.db.get_task.return_value = task
202+
mock_feishu_channel.db.get_setting.return_value = "oc_test_chat"
203+
mock_feishu_channel._send_message = Mock(return_value="msg_456")
204+
mock_feishu_channel._build_notification_card = Mock(
205+
wraps=mock_feishu_channel._build_notification_card
206+
)
207+
208+
msg = OutboundMessage(
209+
type=OutboundMessageType.TASK_COMPLETED,
210+
task_id=6,
211+
payload={"result": long_result, "title": "Long result"},
212+
)
213+
214+
mock_feishu_channel.send(msg)
215+
216+
_, kwargs = mock_feishu_channel._build_notification_card.call_args
217+
assert kwargs["body_text"] == long_result

0 commit comments

Comments
 (0)