netizen-cli 0.10.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. netizen_cli/__init__.py +3 -0
  2. netizen_cli/__main__.py +4 -0
  3. netizen_cli/admin/__init__.py +1 -0
  4. netizen_cli/admin/auth.py +928 -0
  5. netizen_cli/admin/errors.py +9 -0
  6. netizen_cli/admin/port_config.py +115 -0
  7. netizen_cli/admin/presentation.py +257 -0
  8. netizen_cli/admin/queries.py +337 -0
  9. netizen_cli/admin/static/admin.css +260 -0
  10. netizen_cli/admin/static/admin.js +2898 -0
  11. netizen_cli/admin/static/index.html +327 -0
  12. netizen_cli/admin/transport.py +935 -0
  13. netizen_cli/admin/web.py +2717 -0
  14. netizen_cli/bindings.py +3215 -0
  15. netizen_cli/builtin_skills.py +93 -0
  16. netizen_cli/cards/__init__.py +105 -0
  17. netizen_cli/cards/callbacks.py +565 -0
  18. netizen_cli/cards/controls.py +2273 -0
  19. netizen_cli/cards/defaults.py +213 -0
  20. netizen_cli/cards/model_info.py +80 -0
  21. netizen_cli/cards/questions.py +220 -0
  22. netizen_cli/cards/reply.py +2247 -0
  23. netizen_cli/cards/scheduled.py +836 -0
  24. netizen_cli/channel/__init__.py +1 -0
  25. netizen_cli/channel/completion_mentions.py +60 -0
  26. netizen_cli/channel/input_preparation.py +644 -0
  27. netizen_cli/channel/messages.py +57 -0
  28. netizen_cli/channel/ports.py +52 -0
  29. netizen_cli/channel/question_inputs.py +51 -0
  30. netizen_cli/channel/reactions.py +293 -0
  31. netizen_cli/channel/reply_presenter.py +1505 -0
  32. netizen_cli/channel/topics.py +70 -0
  33. netizen_cli/channel_app.py +6593 -0
  34. netizen_cli/cli.py +287 -0
  35. netizen_cli/cli_data.py +536 -0
  36. netizen_cli/cli_packages.py +526 -0
  37. netizen_cli/cli_services.py +651 -0
  38. netizen_cli/cli_setup.py +242 -0
  39. netizen_cli/cli_update.py +303 -0
  40. netizen_cli/cli_update_restore.py +53 -0
  41. netizen_cli/cli_update_worker.py +333 -0
  42. netizen_cli/codex_runtime.py +7125 -0
  43. netizen_cli/completion_mention.py +16 -0
  44. netizen_cli/database_migrations.py +218 -0
  45. netizen_cli/defaults/__init__.py +5 -0
  46. netizen_cli/defaults/models.py +39 -0
  47. netizen_cli/defaults/service.py +232 -0
  48. netizen_cli/defaults/store.py +260 -0
  49. netizen_cli/deployment/__init__.py +1 -0
  50. netizen_cli/deployment/restart_worker.py +134 -0
  51. netizen_cli/deployment/update_executor.py +258 -0
  52. netizen_cli/deployment/update_protocol.py +281 -0
  53. netizen_cli/domain.py +416 -0
  54. netizen_cli/error_messages.py +124 -0
  55. netizen_cli/experience.py +531 -0
  56. netizen_cli/feishu_app_onboarding.py +187 -0
  57. netizen_cli/feishu_app_permissions.py +123 -0
  58. netizen_cli/git_status.py +63 -0
  59. netizen_cli/image_inputs.py +579 -0
  60. netizen_cli/instance.py +84 -0
  61. netizen_cli/lark_app.py +125 -0
  62. netizen_cli/main.py +903 -0
  63. netizen_cli/management/__init__.py +83 -0
  64. netizen_cli/management/blocking_io.py +352 -0
  65. netizen_cli/management/chat_labels.py +266 -0
  66. netizen_cli/management/coordination.py +32 -0
  67. netizen_cli/management/service.py +2187 -0
  68. netizen_cli/management/updates.py +214 -0
  69. netizen_cli/markdown_images.py +78 -0
  70. netizen_cli/message_content.py +786 -0
  71. netizen_cli/message_history.py +643 -0
  72. netizen_cli/message_preparation.py +60 -0
  73. netizen_cli/message_projection.py +923 -0
  74. netizen_cli/migrations/__init__.py +1 -0
  75. netizen_cli/migrations/schema.py +103 -0
  76. netizen_cli/migrations/v14.py +438 -0
  77. netizen_cli/model_settings.py +269 -0
  78. netizen_cli/package_resources.py +22 -0
  79. netizen_cli/projects.py +327 -0
  80. netizen_cli/prompt_projection.py +327 -0
  81. netizen_cli/quoted_context.py +312 -0
  82. netizen_cli/resources/config.example.yaml +35 -0
  83. netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
  84. netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
  85. netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
  86. netizen_cli/result_images.py +123 -0
  87. netizen_cli/runtime/__init__.py +1 -0
  88. netizen_cli/runtime/contracts.py +792 -0
  89. netizen_cli/runtime/name_writes.py +67 -0
  90. netizen_cli/runtime/thread_naming.py +451 -0
  91. netizen_cli/schedules/__init__.py +1 -0
  92. netizen_cli/schedules/mcp.py +535 -0
  93. netizen_cli/schedules/models.py +394 -0
  94. netizen_cli/schedules/scheduler.py +374 -0
  95. netizen_cli/schedules/service.py +766 -0
  96. netizen_cli/schedules/store.py +771 -0
  97. netizen_cli/sdk_gap_adapter.py +1151 -0
  98. netizen_cli/service_launcher.py +583 -0
  99. netizen_cli/session_settings.py +126 -0
  100. netizen_cli/settings.py +216 -0
  101. netizen_cli/skill_references.py +40 -0
  102. netizen_cli/terminal_cleanup.py +155 -0
  103. netizen_cli/turn_activity.py +688 -0
  104. netizen_cli/turn_files.py +812 -0
  105. netizen_cli/turn_patch_children.py +254 -0
  106. netizen_cli/turn_plan_observer.py +315 -0
  107. netizen_cli/user_questions.py +106 -0
  108. netizen_cli-0.10.0.dist-info/METADATA +18 -0
  109. netizen_cli-0.10.0.dist-info/RECORD +112 -0
  110. netizen_cli-0.10.0.dist-info/WHEEL +5 -0
  111. netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
  112. netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,836 @@
1
+ """Self-contained scheduled-plan cards; no persisted card sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ import re
9
+ from collections.abc import Mapping, Sequence
10
+ from dataclasses import dataclass
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+ from typing import Any
14
+ from urllib.parse import unquote
15
+ from uuid import uuid4
16
+ from zoneinfo import ZoneInfo
17
+
18
+ from lark_channel import OutboundCard
19
+
20
+ from ..domain import FeishuScope, MentionContextMode, ScopeKind
21
+ from ..model_settings import ModelCatalog
22
+ from ..projects import Project
23
+ from ..session_settings import SessionSettings
24
+ from ..schedules.models import AmbiguousLocalTime, ScheduleError, resolve_once_local
25
+ from .callbacks import CardActionError, _builder, _notice, _plain, _plain_text
26
+ from .controls import decode_session_settings_form, session_settings_form_elements, session_settings_summary
27
+ from .reply import TURN_FILE_CARD_JSON_LIMIT_BYTES
28
+
29
+
30
+ _VERSION = 4
31
+ _FORM_PREFIX = "cron_name_v6__"
32
+ _KINDS = {"once": "一次性", "daily": "每天", "weekly": "每周", "interval": "固定间隔"}
33
+ _FILTERS = {
34
+ "current": "当前会话 · 全部任务", "current_enabled": "当前会话 · 已启用",
35
+ "current_paused": "当前会话 · 已暂停", "all": "全部会话 · 全部任务",
36
+ "all_enabled": "全部会话 · 已启用", "all_paused": "全部会话 · 已暂停",
37
+ }
38
+ _ACTIONS = {"list", "new", "view", "edit", "delete", "runs", "enabled", "run_now"}
39
+ _FIELDS = {
40
+ "list": (set(), set()),
41
+ "new": (set(), {"target_kind", "target_binding_id"}),
42
+ "view": ({"plan_id"}, set()),
43
+ "edit": ({"plan_id", "expected_revision"}, set()),
44
+ "delete": ({"plan_id", "expected_revision"}, set()),
45
+ "runs": ({"plan_id"}, {"cursor"}),
46
+ "enabled": ({"plan_id", "expected_revision", "enabled"}, set()),
47
+ "run_now": ({"plan_id", "expected_revision"}, set()),
48
+ }
49
+ _MAX_INSTRUCTIONS = 8000
50
+ _MAX_FORM_INSTRUCTIONS = 1000
51
+ _MAX_INSTRUCTION_JSON_BYTES = 12_000
52
+ SCHEDULE_CARD_JSON_LIMIT_BYTES = TURN_FILE_CARD_JSON_LIMIT_BYTES
53
+ _STATES = {
54
+ "enabled": "已启用", "paused": "已暂停", "ended": "计划已结束",
55
+ "blocked_unknown": "执行状态待确认", "project_disabled": "Project 已停用",
56
+ "project_unavailable": "Project 不可用", "completed": "已完成",
57
+ "failed": "执行失败", "interrupted": "已停止", "inProgress": "运行中",
58
+ "running": "运行中", "deleted": "会话已删除", "unavailable": "状态暂不可读",
59
+ "claimed": "已触发", "publishing_topic": "正在创建话题", "binding_ready": "正在启动",
60
+ "starting": "正在启动", "starting_turn": "正在启动", "handed_off": "已启动", "released": "本次已处理",
61
+ "missed": "已错过", "skipped_busy": "上次仍在运行,本次跳过",
62
+ "scope_conflict": "话题已有会话,本次未启动", "publishing_unknown": "话题发布结果待确认",
63
+ "publishing_failed": "话题发布失败", "dispatch_rejected": "本次未启动",
64
+ "initial_start_rejected": "首次启动条件已改变", "initial_start_unknown": "启动结果待确认",
65
+ "observation_unavailable": "执行状态暂不可读", "sent": "已投递", "unknown": "待确认",
66
+ "target_inactive": "原会话已切走或归档,自动暂停", "target_missing": "原会话不可用",
67
+ "target_mismatch": "原会话位置不匹配", "input_started": "已启动新一轮",
68
+ "input_steered": "已追加当前任务", "input_unknown": "输入接收情况待确认",
69
+ "input_rejected": "输入被拒绝", "anchor_failed": "触发消息发送失败",
70
+ "anchor_unknown": "触发消息发送结果待确认",
71
+ }
72
+
73
+
74
+ def _display_state(value: Any) -> str:
75
+ return _STATES.get(str(value), "状态待确认")
76
+
77
+
78
+ def _trigger_source(value: Any) -> str:
79
+ return "手动触发" if value == "manual" else "定时触发"
80
+
81
+
82
+ def _utc_time(value: Any) -> str:
83
+ if type(value) not in {int, float}:
84
+ return "未记录"
85
+ return datetime.fromtimestamp(value, UTC).isoformat(timespec="minutes")
86
+
87
+
88
+ def _instructions_fit(value: str) -> bool:
89
+ # Count escaped UTF-8, including control characters, rather than visible
90
+ # glyphs when deciding whether the full instructions fit in the card.
91
+ return len(value) <= _MAX_INSTRUCTIONS and len(json.dumps(value, ensure_ascii=False).encode("utf-8")) <= _MAX_INSTRUCTION_JSON_BYTES
92
+
93
+
94
+ def _session_settings(value: Any) -> SessionSettings:
95
+ if isinstance(value, SessionSettings):
96
+ return value
97
+ try:
98
+ return SessionSettings.from_dict(dict(value) if isinstance(value, Mapping) else value)
99
+ except ValueError as error:
100
+ raise CardActionError("会话配置不完整,请重新发送 /cron。") from error
101
+
102
+
103
+ def _card(builder: Any) -> OutboundCard:
104
+ card = builder.to_dict()
105
+ card["config"]["width_mode"] = "compact"
106
+ card["header"]["icon"] = {"tag": "standard_icon", "token": "calendar_colorful"}
107
+ card["body"].update(padding="12px", vertical_spacing="12px")
108
+ # lark-channel-sdk 1.4.0 OutboundSender serializes cards this exact way,
109
+ # without splitting them. Use the existing, verified Reply Card ceiling.
110
+ if len(json.dumps(card, ensure_ascii=False).encode("utf-8")) > SCHEDULE_CARD_JSON_LIMIT_BYTES:
111
+ raise CardActionError("卡片内容较长,请通过 Admin 或自然语言管理这项计划;内容未被截断保存。")
112
+ return OutboundCard(card=card)
113
+
114
+
115
+ @dataclass(frozen=True, slots=True)
116
+ class ScheduleCardAction:
117
+ action: str
118
+ payload: dict[str, Any]
119
+ request_id: str
120
+ navigation: dict[str, Any]
121
+
122
+
123
+ def schedule_navigation(value: Any = None) -> dict[str, Any]:
124
+ """Validate self-contained UI state, separate from management requests."""
125
+ if value is None:
126
+ return {"filter": "current_enabled"}
127
+ if not isinstance(value, Mapping) or set(value) - {"filter", "cursor", "plan_id"}:
128
+ raise CardActionError("定时任务卡片导航无效,请重新发送 /cron。")
129
+ selected_filter = value.get("filter", "current_enabled")
130
+ if not isinstance(selected_filter, str) or selected_filter not in _FILTERS:
131
+ raise CardActionError("定时任务筛选条件无效。")
132
+ result = {"filter": selected_filter}
133
+ for field, limit in (("cursor", 512), ("plan_id", 64)):
134
+ if field in value:
135
+ item = value[field]
136
+ if not isinstance(item, str) or not item.strip() or len(item) > limit:
137
+ raise CardActionError("定时任务卡片导航无效,请重新发送 /cron。")
138
+ result[field] = item
139
+ return result
140
+
141
+
142
+ def schedule_query(navigation: Any = None) -> dict[str, Any]:
143
+ state = schedule_navigation(navigation)
144
+ selected_filter = state["filter"]
145
+ query: dict[str, Any] = {}
146
+ if selected_filter.startswith("all"):
147
+ query["all"] = True
148
+ if selected_filter.endswith("_enabled"):
149
+ query["enabled"] = True
150
+ query["ended"] = False
151
+ elif selected_filter.endswith("_paused"):
152
+ query["enabled"] = False
153
+ if "cursor" in state:
154
+ query["cursor"] = state["cursor"]
155
+ return query
156
+
157
+
158
+ def _encoded(value: Mapping[str, Any]) -> str:
159
+ return base64.urlsafe_b64encode(json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode()).decode().rstrip("=")
160
+
161
+
162
+ def _decoded(value: Any) -> dict[str, Any]:
163
+ if not isinstance(value, str) or not value or len(value) > 8192:
164
+ raise CardActionError("定时任务选项无效,请重新选择。")
165
+ try:
166
+ result = json.loads(base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True))
167
+ except (ValueError, UnicodeError) as error:
168
+ raise CardActionError("定时任务选项无效,请重新选择。") from error
169
+ if not isinstance(result, dict):
170
+ raise CardActionError("定时任务选项无效,请重新选择。")
171
+ return result
172
+
173
+
174
+ def is_schedule_card_action(value: Any, form: Any = None) -> bool:
175
+ return (
176
+ isinstance(value, Mapping) and value.get("kind") == "netizen_cron"
177
+ ) or (
178
+ isinstance(form, Mapping)
179
+ and any(isinstance(key, str) and key.startswith(_FORM_PREFIX) for key in form)
180
+ )
181
+
182
+
183
+ def decode_schedule_action(*, scope: FeishuScope, value: Any, form: Any = None) -> ScheduleCardAction:
184
+ if form is not None and form != {}:
185
+ return _decode_form(scope, form)
186
+ if not isinstance(value, Mapping) or set(value) != {"kind", "v", "scope", "action", "payload", "request_id", "navigation", "nonce"}:
187
+ raise CardActionError("定时任务卡片动作无效,请重新发送 /cron。")
188
+ if value["kind"] != "netizen_cron" or type(value["v"]) is not int or value["v"] != _VERSION:
189
+ raise CardActionError("定时任务卡片已过期,请重新发送 /cron。")
190
+ if value["scope"] != scope.key or value["action"] not in _ACTIONS:
191
+ raise CardActionError("定时任务卡片与当前会话不一致,请在当前会话重新发送 /cron。")
192
+ if not isinstance(value["payload"], dict) or not isinstance(value["request_id"], str) or not value["request_id"]:
193
+ raise CardActionError("定时任务卡片缺少操作凭据。")
194
+ if not isinstance(value["nonce"], str) or not re.fullmatch(r"[0-9a-f]{32}", value["nonce"]):
195
+ raise CardActionError("定时任务卡片缺少交互凭据。")
196
+ required, optional = _FIELDS[value["action"]]
197
+ fields = set(value["payload"])
198
+ if not required <= fields or fields - required - optional:
199
+ raise CardActionError("定时任务卡片动作字段不完整或含未知字段。")
200
+ if value["action"] == "new" and fields and (
201
+ fields != {"target_kind", "target_binding_id"}
202
+ or value["payload"]["target_kind"] != "binding"
203
+ or not isinstance(value["payload"]["target_binding_id"], str)
204
+ or not value["payload"]["target_binding_id"].strip()):
205
+ raise CardActionError("原会话目标无效,请重新打开定时任务卡片。")
206
+ return ScheduleCardAction(value["action"], dict(value["payload"]), value["request_id"], schedule_navigation(value["navigation"]))
207
+
208
+
209
+ def _button(scope: FeishuScope, label: str, action: str, payload: dict[str, Any] | None = None, *, navigation: Any = None, danger: bool = False, primary: bool = False) -> dict[str, Any]:
210
+ return {
211
+ "tag": "button", "text": _plain_text(label),
212
+ "type": "danger" if danger else "primary_filled" if primary else "default", "width": "fill",
213
+ "behaviors": [{"type": "callback", "value": {
214
+ "kind": "netizen_cron", "v": _VERSION, "scope": scope.key,
215
+ "action": action, "payload": payload or {}, "request_id": str(uuid4()),
216
+ "nonce": uuid4().hex,
217
+ "navigation": schedule_navigation(navigation),
218
+ }}],
219
+ }
220
+
221
+
222
+ def _row(*items: dict[str, Any]) -> dict[str, Any]:
223
+ return {"tag": "column_set", "flex_mode": "none", "horizontal_spacing": "8px", "columns": [
224
+ {"tag": "column", "width": "weighted", "weight": 1, "elements": [item]} for item in items
225
+ ]}
226
+
227
+
228
+ def _section(*items: dict[str, Any]) -> dict[str, Any]:
229
+ return {"tag": "column_set", "columns": [{"tag": "column", "width": "weighted", "weight": 1,
230
+ "background_style": "grey-50", "padding": "12px", "vertical_spacing": "8px", "elements": list(items)}]}
231
+
232
+
233
+ def _labeled(label: str, control: dict[str, Any]) -> dict[str, Any]:
234
+ # Unlike input, Feishu select/picker components do not accept `label`.
235
+ return {"tag": "column_set", "columns": [{"tag": "column", "width": "weighted", "weight": 1,
236
+ "vertical_spacing": "4px", "elements": [_plain(label), control]}]}
237
+
238
+
239
+ def _fold(title: str, *items: dict[str, Any], expanded: bool = False) -> dict[str, Any]:
240
+ return {"tag": "collapsible_panel", "expanded": expanded, "header": {"title": _plain_text(title)}, "elements": list(items)}
241
+
242
+
243
+ def _form_name(scope: FeishuScope, kind: str, nonce: str) -> str:
244
+ # Feishu caps each component name at 100 characters. Keep the common
245
+ # identity small; edit revision / rule evidence travels on its own field.
246
+ identity = [hashlib.sha256(scope.key.encode()).hexdigest()[:20], kind[0], nonce]
247
+ return _FORM_PREFIX + base64.urlsafe_b64encode(json.dumps(identity, separators=(",", ":")).encode()).decode().rstrip("=")
248
+
249
+
250
+ def _navigation_form(scope: FeishuScope, *, kind: str, label: str, options: Sequence[tuple[str, str]], selected: str | None, submit: str) -> dict[str, Any]:
251
+ # A fresh field identity prevents SDK event dedup from swallowing A → B → A
252
+ # selections on this same card. A form submit uses the public form_value;
253
+ # the pinned SDK does not forward standalone select_static's option field.
254
+ name = _form_name(scope, kind, uuid4().hex)
255
+ control = {"tag": "select_static", "name": name, "required": True, "width": "fill",
256
+ "placeholder": _plain_text(label), "options": [{"text": _plain_text(text), "value": value} for value, text in options]}
257
+ if selected is not None:
258
+ control["initial_option"] = selected
259
+ row = _row(control,
260
+ {"tag": "button", "name": "cron_" + kind + "_submit", "text": _plain_text(submit),
261
+ "type": "default", "width": "fill", "form_action_type": "submit"})
262
+ row["columns"][0]["weight"] = 3
263
+ return {"tag": "form", "name": "cron_" + kind, "elements": [row]}
264
+
265
+
266
+ def schedule_manager_card(scope: FeishuScope, result: dict[str, Any], *, navigation: Any = None,
267
+ selected: dict[str, Any] | None = None, runs: dict[str, Any] | None = None,
268
+ notice: str | None = None, current_binding_id: str | None = None) -> OutboundCard:
269
+ state = schedule_navigation(navigation)
270
+ plan = selected.get("plan") if selected else None
271
+ if plan is not None:
272
+ state = schedule_navigation({**state, "plan_id": plan["id"]})
273
+ builder = _builder("定时任务", _FILTERS[state["filter"]])
274
+ if notice:
275
+ builder.raw(_notice(notice))
276
+ builder.raw(_navigation_form(scope, kind="filter", label="筛选任务",
277
+ options=[(_encoded({"filter": key}), label) for key, label in _FILTERS.items()],
278
+ selected=_encoded({"filter": state["filter"]}), submit="确认筛选"))
279
+ plans = list(result.get("plans", []))
280
+ # A successful edit may move the selected record outside this page's ID
281
+ # window. The handler verifies its filter, then supplies the exact result.
282
+ if plan is not None and all(item["id"] != plan["id"] for item in plans):
283
+ plans.append(plan)
284
+ if plans:
285
+ builder.raw(_navigation_form(scope, kind="manage", label="选择定时任务",
286
+ options=[(_encoded({**state, "plan_id": item["id"]}),
287
+ f"{str(item.get('name', ''))[:60]} · {_display_state(item.get('status') or ('enabled' if item.get('enabled') else 'paused'))} · {str(item['id'])[:8]}") for item in plans],
288
+ selected=_encoded(state) if plan is not None else None, submit="查看任务"))
289
+ else:
290
+ builder.raw(_plain("还没有符合条件的任务。可新建独立话题任务,或在当前会话中定时继续。"))
291
+ pages = []
292
+ if state.get("cursor"):
293
+ pages.append(_button(scope, "回到首页", "list", navigation={"filter": state["filter"]}))
294
+ if result.get("next_cursor"):
295
+ pages.append(_button(scope, "下一页任务", "list", navigation={"filter": state["filter"], "cursor": result["next_cursor"]}))
296
+ if pages:
297
+ builder.raw(_row(*pages))
298
+ if plan is not None:
299
+ _render_selected_plan(builder, scope, selected, navigation=state)
300
+ if runs is not None:
301
+ builder.raw(_runs_panel(scope, runs, plan_id=plan["id"], navigation=state))
302
+ elif plans:
303
+ builder.raw(_plain("选择一个任务后,在这里查看详情和管理。"))
304
+ builder.divider()
305
+ builder.raw(_button(scope, "新建定时任务", "new", navigation=state, primary=True))
306
+ if current_binding_id:
307
+ builder.raw(_button(scope, "在当前会话定时执行", "new",
308
+ {"target_kind": "binding", "target_binding_id": current_binding_id}, navigation=state))
309
+ return _card(builder)
310
+
311
+
312
+ def _plan_summary(plan: Mapping[str, Any]) -> str:
313
+ rule = plan.get("schedule", {})
314
+ status = _display_state(plan.get("status") or ("enabled" if plan.get("enabled") else "paused"))
315
+ blocked = plan.get("blocked_reason")
316
+ rule_text = _KINDS.get(rule.get("kind"), str(rule.get("kind", "")))
317
+ if rule.get("at"):
318
+ rule_text += " " + str(rule["at"])
319
+ if rule.get("kind") == "weekly":
320
+ rule_text += " " + "、".join("周" + "一二三四五六日"[day] for day in rule.get("weekdays", []) if type(day) is int and 0 <= day <= 6)
321
+ if rule.get("kind") == "interval":
322
+ rule_text += f" {rule.get('every_minutes')} 分钟"
323
+ deadline = ""
324
+ if rule.get("kind") in {"daily", "weekly", "interval"}:
325
+ end_at = rule.get("end_at")
326
+ end = datetime.fromisoformat(end_at).astimezone(ZoneInfo(rule["timezone"])).isoformat(timespec="minutes") if end_at else "不限"
327
+ deadline = f"\n截止:{end}" + ("(含该时刻)" if end_at else "")
328
+ return (
329
+ f"{plan.get('name', '')} · {str(plan.get('id', ''))[:8]}\n"
330
+ f"Project:{plan.get('project_alias', plan.get('project', ''))}\n"
331
+ f"目标会话:{plan.get('chat_id', '')}\n"
332
+ + (f"执行目标:原会话 · {plan.get('target_label') or plan.get('target_binding_id', '')}\n" if plan.get("target_kind") == "binding" else "执行目标:每次新建独立话题\n")
333
+ + f"启停:{'已启用' if plan.get('enabled') else '已暂停'}\n"
334
+ + f"时间:{rule_text} · {rule.get('timezone', '')}\n"
335
+ f"状态:{status}" + (f" · {_display_state(blocked)}" if blocked else "")
336
+ + deadline
337
+ + f"\n下次:{plan.get('next_due_local') or '无'}"
338
+ + (f"\n最近触发:{plan['latest_run']['due_local']} · {_trigger_source(plan['latest_run'].get('trigger_source'))}" if plan.get("latest_run") else "")
339
+ )
340
+
341
+
342
+ def _render_selected_plan(builder: Any, scope: FeishuScope, result: dict[str, Any], *, navigation: dict[str, Any]) -> None:
343
+ plan = result["plan"]
344
+ builder.raw(_section(_plain(_plan_summary(plan))))
345
+ instructions = str(plan.get("instructions", ""))
346
+ if _instructions_fit(instructions):
347
+ builder.raw(_fold("执行指令", _plain(instructions), expanded=len(instructions) <= 200))
348
+ else:
349
+ builder.raw(_plain("执行指令(内容节选)\n" + instructions[:2000] + "…"))
350
+ builder.raw(_notice("完整指令较长,请通过 Admin 或自然语言查看、编辑。计划原文完整保留,仍可在此启停或删除。"))
351
+ if plan.get("target_kind") == "binding":
352
+ builder.raw(_plain("Project、模型、上下文和反馈随原会话当前配置;系统触发不新增 @ 通知。"))
353
+ else:
354
+ builder.raw(_fold("会话配置", _plain(session_settings_summary(_session_settings(plan.get("session_settings", SessionSettings()))))))
355
+ inflight = result.get("inflight") or plan.get("inflight")
356
+ if inflight:
357
+ builder.raw(_notice("本次已触发,修改、暂停或删除从后续触发生效;本次交接可以继续。"))
358
+ exact = {"plan_id": plan["id"], "expected_revision": plan["revision"]}
359
+ actions = []
360
+ if plan.get("can_run_now") is True:
361
+ builder.raw(_plain("立即运行会将保存的指令提交到原会话,空闲时启动新一轮,忙碌时追加当前任务;原定时安排保持不变。"
362
+ if plan.get("target_kind") == "binding" else "立即运行会按当前保存的内容执行一次,并新建话题;原定时安排保持不变。"))
363
+ actions.append(_button(scope, "立即运行", "run_now", exact, navigation=navigation, primary=True))
364
+ elif plan.get("blocked_reason"):
365
+ builder.raw(_plain("暂不能立即运行:" + _display_state(plan["blocked_reason"]) + "。请处理后刷新任务。"))
366
+ elif inflight:
367
+ builder.raw(_plain("本次输入正在交接,暂不能立即运行;可刷新任务查看最新状态。" if plan.get("target_kind") == "binding"
368
+ else "上次首轮执行尚未确认结束,暂不能立即运行;可刷新任务查看最新状态。"))
369
+ else:
370
+ builder.raw(_plain("暂不能立即运行;请刷新任务查看最新状态。"))
371
+ actions.extend((_button(scope, "编辑", "edit", exact, navigation=navigation),
372
+ _button(scope, "暂停" if plan.get("enabled") else "启用", "enabled", {**exact, "enabled": not plan.get("enabled")}, navigation=navigation)))
373
+ builder.raw(_row(*actions))
374
+ delete = _button(scope, "删除计划", "delete", exact, navigation=navigation, danger=True)
375
+ delete["confirm"] = {"title": _plain_text("删除定时任务?"),
376
+ "text": _plain_text("删除后停止后续触发,保留已有普通会话。已认领的本次交接仍可能继续。删除的计划不能恢复。")}
377
+ builder.raw(_row(_button(scope, "最近执行", "runs", {"plan_id": plan["id"]}, navigation=navigation),
378
+ _button(scope, "刷新任务", "view", {"plan_id": plan["id"]}, navigation=navigation), delete))
379
+
380
+
381
+ def _runs_panel(scope: FeishuScope, result: dict[str, Any], *, plan_id: str, navigation: dict[str, Any]) -> dict[str, Any]:
382
+ items = []
383
+ runs = result.get("runs", [])
384
+ if not runs:
385
+ items.append(_plain("还没有触发记录。"))
386
+ for run in runs:
387
+ delivery = ("反馈:随原会话当前任务交付" if run.get("target_kind") == "binding" else
388
+ f"结果投递:{ {'sent': '已投递', 'failed': '投递失败', 'unknown': '待确认'}.get(run.get('delivery_state'), '尚未投递')}")
389
+ items.append(_plain(
390
+ f"时间:{run.get('due_local') or _utc_time(run.get('due_at'))}\n"
391
+ f"来源:{_trigger_source(run.get('trigger_source'))}\n"
392
+ f"状态:{_display_state(run.get('status') or run.get('error_code') or run.get('phase'))}\n"
393
+ + delivery
394
+ + (f"\n合并漏跑:{run['missed_count']} 次" if run.get("missed_count", 0) > 1 else "")
395
+ ))
396
+ items.append({"tag": "hr"})
397
+ if result.get("next_cursor"):
398
+ items.append(_button(scope, "更早执行", "runs", {"plan_id": plan_id, "cursor": result["next_cursor"]}, navigation=navigation))
399
+ return _fold("最近执行", *items, expanded=True)
400
+
401
+
402
+ def _input(name: str, label: str, value: str = "", *, required: bool = True, multiline: bool = False) -> dict[str, Any]:
403
+ return {"tag": "input", "name": name, "label": _plain_text(label), "required": required,
404
+ "default_value": value, "input_type": "multiline_text" if multiline else "text", "width": "fill",
405
+ **({"rows": 3, "max_length": _MAX_FORM_INSTRUCTIONS, "placeholder": _plain_text("例如:检查项目进展,整理今天的更新并发到本话题。")} if multiline else {})}
406
+
407
+
408
+ def _select(name: str, label: str, options: Sequence[tuple[str, str]], selected: str | None = None) -> dict[str, Any]:
409
+ result: dict[str, Any] = {"tag": "select_static", "name": name, "required": True,
410
+ "width": "fill", "placeholder": _plain_text(label), "options": [{"text": _plain_text(text), "value": value} for value, text in options]}
411
+ if selected is not None:
412
+ result["initial_option"] = selected
413
+ return _labeled(label, result)
414
+
415
+
416
+ def schedule_form_card(scope: FeishuScope, *, projects: Sequence[Project], default_timezone: str | None,
417
+ plan: Mapping[str, Any] | None = None,
418
+ target_binding_id: str | None = None,
419
+ initial_project: str | None = None, session_settings: SessionSettings | Mapping[str, Any] | None = None,
420
+ catalog: ModelCatalog | None = None, catalog_error: str | None = None,
421
+ allow_context_mode: bool = False, navigation: Any = None,
422
+ _retry_form: Mapping[str, Any] | None = None, _request_id: str | None = None,
423
+ _notice_text: str | None = None) -> OutboundCard:
424
+ navigation = schedule_navigation(navigation)
425
+ builder = _builder("编辑定时任务" if plan else "新建定时任务", "填写内容与时间,保存后按计划执行")
426
+ existing = dict(plan or {})
427
+ target_binding_id = existing.get("target_binding_id") if plan else target_binding_id
428
+ binding_target = existing.get("target_kind") == "binding" if plan else target_binding_id is not None
429
+ settings = SessionSettings() if binding_target else _session_settings(existing.get("session_settings", session_settings if session_settings is not None else SessionSettings()))
430
+ if not allow_context_mode and settings.message_context_mode is MentionContextMode.CATCH_UP:
431
+ raise CardActionError("私聊不能自动读取群聊讨论,请先将消息范围设为仅当前消息。")
432
+ if len(str(existing.get("instructions", ""))) > _MAX_FORM_INSTRUCTIONS:
433
+ if plan:
434
+ return schedule_manager_card(scope, {"plans": [plan]}, selected={"plan": plan}, navigation=navigation,
435
+ notice="这项计划的完整指令超过卡片编辑容量,请通过 Admin 或自然语言修改。")
436
+ raise CardActionError("执行指令超过卡片编辑容量,请通过 Admin 或自然语言维护。")
437
+ if not projects and not binding_target:
438
+ notice = "没有可用的 Project,请先通过 /settings 登记并启用 Project 后再编辑。"
439
+ if plan:
440
+ return schedule_manager_card(scope, {"plans": [plan]}, selected={"plan": plan}, navigation=navigation, notice=notice)
441
+ builder.raw(_plain(notice))
442
+ builder.raw(_button(scope, "取消", "list", navigation=navigation))
443
+ return _card(builder)
444
+ rule = existing.get("schedule", {})
445
+ kind = rule.get("kind", "once")
446
+ if kind not in _KINDS:
447
+ raise CardActionError("未知时间规则。")
448
+ meta = {"request_id": _request_id or uuid4().hex}
449
+ if plan:
450
+ meta.update(plan_id=plan["id"], expected_revision=plan["revision"])
451
+ if kind == "interval" and rule.get("anchor") is not None:
452
+ meta["anchor"] = rule["anchor"]
453
+ timezone = str(rule.get("timezone") or default_timezone or "")
454
+ once_at = None
455
+ if kind == "once" and rule.get("at"):
456
+ once_at = datetime.fromisoformat(rule["at"]).astimezone(ZoneInfo(timezone))
457
+ # An unchanged edit preserves an explicit second DST-overlap occurrence.
458
+ meta["original_at"] = once_at.isoformat(timespec="minutes")
459
+ end_at = None
460
+ if kind != "once" and rule.get("end_at"):
461
+ end_at = datetime.fromisoformat(rule["end_at"]).astimezone(ZoneInfo(timezone))
462
+ meta["original_end_at"] = rule["end_at"]
463
+ instructions_name = "cron_instructions"
464
+ if meta.get("plan_id"):
465
+ instructions_name += f"__{meta['plan_id']}:{meta['expected_revision']}"
466
+ timezone_name = "cron_timezone" + (f"__{meta['original_at']}" if "original_at" in meta else "")
467
+ elements = [
468
+ _input(_form_name(scope, "plan", uuid4().hex), "名称", str(existing.get("name", ""))),
469
+ _input(instructions_name, "执行内容", str(existing.get("instructions", "")), multiline=True),
470
+ _row(_select("cron_kind", "执行频率", list(_KINDS.items()), kind), _input(timezone_name, "时区", timezone)),
471
+ ]
472
+ date = {"tag": "date_picker", "name": "cron_date", "required": False, "width": "fill", "placeholder": _plain_text("选择日期")}
473
+ if once_at:
474
+ date["initial_date"] = once_at.strftime("%Y-%m-%d")
475
+ time = {"tag": "picker_time", "name": "cron_at", "required": False, "width": "fill",
476
+ "placeholder": _plain_text("选择时间"), "initial_time": once_at.strftime("%H:%M") if once_at else rule.get("at") or "09:00"}
477
+ elements.append(_row(_labeled("日期 · 仅一次性", date), _labeled("时间 · 一次性 / 每天 / 每周", time)))
478
+ weekdays = {"tag": "multi_select_static", "name": "cron_weekdays", "required": False,
479
+ "width": "fill", "placeholder": _plain_text("选择星期"),
480
+ "options": [{"text": _plain_text("周" + "一二三四五六日"[day]), "value": str(day)} for day in range(7)],
481
+ "selected_values": [str(day) for day in rule.get("weekdays", (0, 1, 2, 3, 4))]}
482
+ minutes_name = "cron_every_minutes" + (f"__{meta['anchor']}" if "anchor" in meta else "")
483
+ elements.append(_row(_labeled("星期 · 仅每周", weekdays),
484
+ _input(minutes_name, "分钟间隔 · 仅固定间隔", str(rule.get("every_minutes") or 60), required=False)))
485
+ end_time_name = "cron_end_time" + (f"__{meta['original_end_at']}" if "original_end_at" in meta else "")
486
+ end_date = {"tag": "date_picker", "name": "cron_end_date", "required": False, "width": "fill", "placeholder": _plain_text("不限截止日期")}
487
+ end_time = {"tag": "picker_time", "name": end_time_name, "required": False, "width": "fill", "placeholder": _plain_text("选择截止时间")}
488
+ if end_at:
489
+ end_date["initial_date"] = end_at.strftime("%Y-%m-%d")
490
+ end_time["initial_time"] = end_at.strftime("%H:%M")
491
+ elements.append(_plain("截止时间 · 仅每天 / 每周 / 固定间隔(可选)"))
492
+ elements.append(_row(_labeled("截止日期", end_date), _labeled("截止时间(含该时刻)", end_time)))
493
+ elements.append(_plain("截止日期与时间使用上方时区;同时留空可取消截止。"))
494
+ project_meta = {"navigation": navigation, "scope": scope.key, "request_id": meta["request_id"]}
495
+ if binding_target:
496
+ target_meta = {**project_meta, "target_kind": "binding", "target_binding_id": target_binding_id}
497
+ label = "原会话 · " + str(existing.get("target_label") or target_binding_id)
498
+ elements.append(_select("cron_project", "执行目标(创建后固定)", [(_encoded(target_meta), label)], _encoded(target_meta)))
499
+ elements.append(_plain("沿用原会话的 Project、模型、上下文和反馈。到点提交一条输入,空闲时启动新一轮,忙碌时追加当前任务。切走或归档后自动暂停,恢复后继续;删除原会话也会删除计划。"))
500
+ else:
501
+ elements.extend(_new_topic_form_fields(existing, projects=projects, initial_project=initial_project,
502
+ project_meta=project_meta, settings=settings, catalog=catalog, catalog_error=catalog_error,
503
+ allow_context_mode=allow_context_mode))
504
+ elements.append(_plain("只使用所选频率对应的日期、时间、星期或间隔;日期与时间以所填时区为准。"))
505
+ elements.append({"tag": "button", "name": "cron_save", "text": _plain_text("保存修改" if plan else "创建任务"), "type": "primary_filled", "width": "fill", "form_action_type": "submit"})
506
+ if _retry_form is not None:
507
+ _restore_form_fields(elements, _retry_form)
508
+ if _notice_text:
509
+ builder.raw(_notice(_notice_text))
510
+ builder.raw({"tag": "form", "name": "cron_plan", "elements": elements})
511
+ builder.raw(_button(scope, "取消", "view" if plan else "list", {"plan_id": plan["id"]} if plan else {}, navigation=navigation))
512
+ return _card(builder)
513
+
514
+
515
+ def _new_topic_form_fields(existing: Mapping[str, Any], *, projects: Sequence[Project], initial_project: str | None,
516
+ project_meta: dict[str, Any], settings: SessionSettings, catalog: ModelCatalog | None,
517
+ catalog_error: str | None, allow_context_mode: bool) -> list[dict[str, Any]]:
518
+ elements: list[dict[str, Any]] = [_plain("执行目标:每次新建独立话题。")]
519
+ selected_project = existing.get("project_alias") or initial_project
520
+ if selected_project is not None and selected_project not in {project.alias for project in projects}:
521
+ elements.append(_notice(f"Project「{selected_project}」已停用或不可用,请选择可用的 Project;保存前不会更改计划。"))
522
+ selected_project = None
523
+ elif selected_project is None:
524
+ selected_project = projects[0].alias
525
+ project_options = [(_encoded({"project": p.alias, **project_meta}), p.alias) for p in projects]
526
+ selected_project_value = _encoded({"project": selected_project, **project_meta}) if selected_project is not None else None
527
+ elements.append(_select("cron_project", "执行 Project", project_options, selected_project_value))
528
+ elements.append(_fold("目标会话", _input("cron_chat_id", "目标会话 ID(留空使用当前会话)", str(existing.get("chat_id", "")), required=False)))
529
+ elements.append(_fold("会话配置",
530
+ _plain(session_settings_summary(settings, allow_context_mode=allow_context_mode)),
531
+ *session_settings_form_elements(prefix="cron_session", settings=settings, catalog=catalog,
532
+ catalog_error=catalog_error, allow_context_mode=allow_context_mode)))
533
+ return elements
534
+
535
+
536
+ def _form_identity(scope: FeishuScope, form: Any) -> tuple[str, list[str]]:
537
+ if not isinstance(form, Mapping):
538
+ raise CardActionError("定时任务表单无效。")
539
+ names = [name for name in form if isinstance(name, str) and name.startswith(_FORM_PREFIX)]
540
+ if len(names) != 1:
541
+ raise CardActionError("定时任务表单身份无效,请重新打开。")
542
+ encoded = names[0][len(_FORM_PREFIX):]
543
+ try:
544
+ identity = json.loads(base64.b64decode(encoded + "=" * (-len(encoded) % 4), altchars=b"-_", validate=True))
545
+ except (ValueError, UnicodeError) as error:
546
+ raise CardActionError("定时任务表单身份无效。") from error
547
+ if (not isinstance(identity, list) or len(identity) != 3 or identity[0] != hashlib.sha256(scope.key.encode()).hexdigest()[:20]
548
+ or not isinstance(identity[1], str) or identity[1] not in {"p", "f", "m"}
549
+ or not isinstance(identity[2], str) or not re.fullmatch(r"[0-9a-f]{32}", identity[2])):
550
+ raise CardActionError("定时任务表单与原消息会话不一致,请在当前会话重新发送 /cron。")
551
+ return names[0], identity
552
+
553
+
554
+ def _plan_form_metadata(form: Mapping[str, Any]) -> dict[str, Any]:
555
+ """Parse write identity only, without validating editable business values."""
556
+ meta = _decoded(_text(form.get("cron_project"), "Project"))
557
+ binding_target = meta.get("target_kind") == "binding"
558
+ target_fields = {"target_kind", "target_binding_id"} if binding_target else {"project"}
559
+ if set(meta) != target_fields | {"navigation", "scope", "request_id"}:
560
+ raise CardActionError("定时任务 Project 选项无效,请重新选择。")
561
+ if not isinstance(meta["request_id"], str) or not re.fullmatch(r"[0-9a-f]{32}", meta["request_id"]):
562
+ raise CardActionError("定时任务表单缺少操作凭据。")
563
+ if binding_target:
564
+ meta["target_binding_id"] = _text(meta["target_binding_id"], "原会话")
565
+ else:
566
+ meta["project"] = _text(meta["project"], "Project")
567
+ meta["navigation"] = schedule_navigation(meta["navigation"])
568
+ instructions = [name for name in form if isinstance(name, str) and (name == "cron_instructions" or name.startswith("cron_instructions__"))]
569
+ if len(instructions) != 1:
570
+ raise CardActionError("定时任务表单缺少执行指令字段或包含重复字段。")
571
+ if instructions[0] != "cron_instructions":
572
+ match = re.fullmatch(r"cron_instructions__([A-Za-z0-9_-]{1,64}):([1-9][0-9]{0,17})", instructions[0])
573
+ if not match:
574
+ raise CardActionError("定时任务表单的计划版本无效。")
575
+ meta.update(plan_id=match[1], expected_revision=int(match[2]))
576
+ return meta
577
+
578
+
579
+ def _decode_form(scope: FeishuScope, form: Any) -> ScheduleCardAction:
580
+ name, identity = _form_identity(scope, form)
581
+ names = [name]
582
+ form_kind = {"p": "plan", "f": "filter", "m": "manage"}[identity[1]]
583
+ form = dict(form)
584
+ if form_kind in {"filter", "manage"}:
585
+ if set(form) != {names[0]}:
586
+ raise CardActionError("定时任务选择字段无效。")
587
+ choice = _decoded(form[names[0]])
588
+ if (form_kind == "filter" and set(choice) != {"filter"}) or (form_kind == "manage" and "plan_id" not in choice):
589
+ raise CardActionError("定时任务选项无效,请重新选择。")
590
+ return ScheduleCardAction("list", {}, identity[2], schedule_navigation(choice))
591
+ meta = _plan_form_metadata(form)
592
+ if meta["scope"] != scope.key:
593
+ raise CardActionError("定时任务 Project 选项与当前会话不一致。")
594
+ kind = _text(form.get("cron_kind"), "执行频率")
595
+ if kind not in _KINDS:
596
+ raise CardActionError("未知时间规则。")
597
+ for name in tuple(form):
598
+ if not isinstance(name, str) or not name.startswith(("cron_instructions__", "cron_timezone__", "cron_every_minutes__", "cron_end_time__")):
599
+ continue
600
+ field, reference = name.split("__", 1)
601
+ if field in form:
602
+ raise CardActionError("定时任务表单包含重复字段。")
603
+ if field == "cron_timezone":
604
+ if kind == "once":
605
+ meta["original_at"] = reference
606
+ elif field == "cron_end_time":
607
+ if kind != "once":
608
+ meta["original_end_at"] = reference
609
+ elif field == "cron_every_minutes":
610
+ if kind == "interval":
611
+ try:
612
+ meta["anchor"] = float(reference)
613
+ except ValueError as error:
614
+ raise CardActionError("定时任务表单的间隔起点无效。") from error
615
+ form[field] = form.pop(name)
616
+ settings_fields = {key: form.pop(key) for key in tuple(form) if isinstance(key, str) and key.startswith("cron_session_")}
617
+ binding_target = meta.get("target_kind") == "binding"
618
+ if binding_target and settings_fields:
619
+ raise CardActionError("原会话任务沿用会话配置,请重新打开表单。")
620
+ settings = None if binding_target else decode_session_settings_form(settings_fields, prefix="cron_session")
621
+ expected = {names[0], "cron_instructions", "cron_project", "cron_timezone", "cron_kind"}
622
+ if not binding_target:
623
+ expected.add("cron_chat_id")
624
+ optional = {"cron_every_minutes", "cron_at", "cron_date", "cron_weekdays", "cron_end_date", "cron_end_time"}
625
+ if not expected <= set(form) or set(form) - expected - optional:
626
+ raise CardActionError("定时任务表单缺少字段或混入其他操作。")
627
+ rule: dict[str, Any] = {"kind": kind, "timezone": _text(form["cron_timezone"], "时区")}
628
+ if kind == "interval":
629
+ minutes = _text(form.get("cron_every_minutes"), "间隔")
630
+ if not minutes.isdecimal() or int(minutes) <= 0:
631
+ raise CardActionError("间隔必须是正整数分钟。")
632
+ rule["every_minutes"] = int(minutes)
633
+ if "anchor" in meta:
634
+ rule["anchor"] = meta["anchor"]
635
+ else:
636
+ rule["at"] = _picker_clock(_text(form.get("cron_at"), "执行时间"))
637
+ if kind == "once":
638
+ rule["at"] = _once_picker(form.get("cron_date"), rule["at"], rule["timezone"], meta.get("original_at"))
639
+ if kind == "weekly":
640
+ days = form.get("cron_weekdays")
641
+ if not isinstance(days, list) or not days or any(day not in [str(i) for i in range(7)] for day in days):
642
+ raise CardActionError("请选择要执行的星期。")
643
+ rule["weekdays"] = [int(day) for day in days]
644
+ if kind != "once":
645
+ end_date = _optional_text(form.get("cron_end_date"), "截止日期")
646
+ end_time = _optional_text(form.get("cron_end_time"), "截止时间")
647
+ if bool(end_date) != bool(end_time):
648
+ raise CardActionError("请同时填写截止日期和时间;如不限制截止,请同时留空。")
649
+ rule["end_at"] = _once_picker(end_date, _picker_clock(end_time, label="截止时间"), rule["timezone"], meta.get("original_end_at"), label="截止日期") if end_date else None
650
+ draft = {"name": _text(form[names[0]], "名称"), "instructions": _text(form["cron_instructions"], "执行指令"), "schedule": rule}
651
+ if binding_target:
652
+ draft.update(target_kind="binding", target_binding_id=meta["target_binding_id"])
653
+ else:
654
+ assert settings is not None
655
+ draft.update(project=meta["project"], session_settings=settings.to_dict())
656
+ if not _instructions_fit(draft["instructions"]) or len(draft["instructions"]) > _MAX_FORM_INSTRUCTIONS:
657
+ raise CardActionError("执行指令超过卡片编辑容量,请通过 Admin 或自然语言维护;尚未保存。")
658
+ if form.get("cron_chat_id"):
659
+ draft["chat_id"] = _text(form["cron_chat_id"], "目标会话")
660
+ if "plan_id" in meta:
661
+ draft.update(plan_id=meta["plan_id"], expected_revision=meta["expected_revision"])
662
+ return ScheduleCardAction("save", draft, meta["request_id"], meta["navigation"])
663
+
664
+
665
+ def schedule_retry_card(*, app_id: str, chat_id: str, value: Any, form: Any,
666
+ notice: str, projects: Sequence[Project],
667
+ scope: FeishuScope | None = None) -> tuple[FeishuScope, OutboundCard]:
668
+ """Restore only UI state; this scope never authorizes a management request.
669
+
670
+ Each renewed transport nonce is independent of the original write identity.
671
+ The next callback must fetch and validate the message's real scope again.
672
+ """
673
+ if isinstance(form, Mapping) and form and "cron_project" in form:
674
+ meta = _plan_form_metadata(form)
675
+ original_scope = _retry_scope(meta["scope"], app_id=app_id, chat_id=chat_id)
676
+ if scope is not None and scope != original_scope:
677
+ raise CardActionError("卡片位置已改变,请重新发送 /cron。")
678
+ _, identity = _form_identity(original_scope, form)
679
+ if identity[1] != "p":
680
+ raise CardActionError("无法恢复缺少身份的定时任务表单。")
681
+ binding_target = meta.get("target_kind") == "binding"
682
+ project = meta.get("project")
683
+ settings_fields = {key: item for key, item in form.items() if isinstance(key, str) and key.startswith("cron_session_")}
684
+ if binding_target and settings_fields:
685
+ raise CardActionError("原会话任务沿用会话配置,请重新打开表单。")
686
+ settings = None if binding_target else decode_session_settings_form(settings_fields, prefix="cron_session")
687
+ plan = {"id": meta["plan_id"], "revision": meta["expected_revision"], "project_alias": project} if "plan_id" in meta else None
688
+ if binding_target and plan:
689
+ plan.update(target_kind="binding", target_binding_id=meta["target_binding_id"])
690
+ # The retained option is display data, not a Project registration or
691
+ # availability decision. The service revalidates the exact submitted ID.
692
+ options = list(projects)
693
+ if project is not None and project not in {item.alias for item in options}:
694
+ options.append(Project(project, Path("."), False, 0))
695
+ return original_scope, schedule_form_card(original_scope, projects=options,
696
+ default_timezone="", plan=plan, initial_project=project, session_settings=settings,
697
+ target_binding_id=meta.get("target_binding_id"),
698
+ allow_context_mode="cron_session_context_mode" in form,
699
+ navigation=meta["navigation"], _retry_form=form,
700
+ _request_id=meta["request_id"], _notice_text=notice)
701
+ if form:
702
+ if scope is None:
703
+ raise CardActionError("暂时无法确认卡片位置,请稍后重新发送 /cron。")
704
+ original_scope = scope
705
+ decoded = decode_schedule_action(scope=scope, value=value, form=form)
706
+ else:
707
+ if not isinstance(value, Mapping):
708
+ raise CardActionError("无法恢复缺少身份的定时任务操作。")
709
+ original_scope = _retry_scope(value.get("scope"), app_id=app_id, chat_id=chat_id)
710
+ if scope is not None and scope != original_scope:
711
+ raise CardActionError("卡片位置已改变,请重新发送 /cron。")
712
+ decoded = decode_schedule_action(scope=original_scope, value=value)
713
+ retry = _button(original_scope, "重试刚才的操作", decoded.action, decoded.payload, navigation=decoded.navigation, primary=True)
714
+ retry["behaviors"][0]["value"].update(request_id=decoded.request_id)
715
+ builder = _builder("定时任务", "上次操作尚未确认")
716
+ builder.raw(_notice(notice))
717
+ builder.raw(retry)
718
+ builder.raw(_button(original_scope, "查看最新状态", "list", navigation=decoded.navigation))
719
+ return original_scope, _card(builder)
720
+
721
+
722
+ def _retry_scope(value: Any, *, app_id: str, chat_id: str) -> FeishuScope:
723
+ if not isinstance(value, str) or len(value) > 2048:
724
+ raise CardActionError("定时任务卡片会话身份无效。")
725
+ parts = value.split(":")
726
+ try:
727
+ if len(parts) != 6 or parts[:2] != ["scope", "v1"]:
728
+ raise ValueError("invalid scope")
729
+ app, kind, chat, topic = (unquote(part) for part in parts[2:])
730
+ scope = FeishuScope(app, chat, ScopeKind(kind), topic or None)
731
+ if scope.key != value or app != app_id or chat != chat_id:
732
+ raise ValueError("scope mismatch")
733
+ return scope
734
+ except ValueError as error:
735
+ raise CardActionError("定时任务卡片会话身份无效。") from error
736
+
737
+
738
+ def _restore_form_fields(elements: list[dict[str, Any]], submitted: Mapping[str, Any]) -> None:
739
+ fields: dict[str, tuple[str, Any]] = {}
740
+ for name, value in submitted.items():
741
+ if not isinstance(name, str):
742
+ raise CardActionError("表单字段格式无效,无法恢复。")
743
+ base = "cron_name" if name.startswith(_FORM_PREFIX) else name.split("__", 1)[0]
744
+ if base in fields or not (isinstance(value, str) or value is None or (isinstance(value, list) and all(isinstance(item, str) for item in value))):
745
+ raise CardActionError("表单字段格式无效,无法恢复。")
746
+ fields[base] = (name, value)
747
+ restored = set()
748
+ def visit(node: Any) -> None:
749
+ if isinstance(node, list):
750
+ for child in node:
751
+ visit(child)
752
+ elif isinstance(node, dict):
753
+ tag, name = node.get("tag"), node.get("name")
754
+ if tag in {"input", "select_static", "multi_select_static", "date_picker", "picker_time"} and isinstance(name, str):
755
+ base = "cron_name" if name.startswith(_FORM_PREFIX) else name.split("__", 1)[0]
756
+ original_name, value = fields.get(base, (name, ""))
757
+ restored.add(base)
758
+ node["name"] = name if base == "cron_name" else original_name
759
+ if tag == "multi_select_static":
760
+ if value is None or value == "":
761
+ value = []
762
+ if not isinstance(value, list) or any(item not in {option["value"] for option in node["options"]} for item in value):
763
+ raise CardActionError("星期字段格式无效,无法恢复。")
764
+ node["selected_values"] = value
765
+ else:
766
+ if value is None:
767
+ value = ""
768
+ if not isinstance(value, str):
769
+ raise CardActionError("表单字段格式无效,无法恢复。")
770
+ if tag == "select_static":
771
+ node.pop("initial_option", None)
772
+ if value:
773
+ node["initial_option"] = value
774
+ if value not in {option["value"] for option in node["options"]}:
775
+ raise CardActionError("选项字段格式无效,无法恢复。")
776
+ elif tag == "input":
777
+ node["default_value"] = value
778
+ else:
779
+ initial = "initial_date" if tag == "date_picker" else "initial_time"
780
+ node.pop(initial, None)
781
+ try:
782
+ if value and tag == "date_picker":
783
+ match = re.fullmatch(r"(\d{4}-\d{2}-\d{2})(?: [+-]\d{4})?", value)
784
+ if not match:
785
+ raise ValueError("invalid picker date")
786
+ datetime.fromisoformat(match[1])
787
+ node[initial] = match[1]
788
+ elif value:
789
+ node[initial] = _picker_clock(value)
790
+ except (ValueError, CardActionError) as error:
791
+ raise CardActionError("日期或时间字段格式无效,无法恢复,请重新打开卡片。") from error
792
+ return
793
+ for child in tuple(node.values()):
794
+ visit(child)
795
+ visit(elements)
796
+ for base in fields.keys() - restored:
797
+ name, value = fields[base]
798
+ if base not in {"cron_session_effort", "cron_session_speed"} or not isinstance(value, str):
799
+ raise CardActionError("表单包含无法恢复的字段。")
800
+ elements.insert(-1, _input(name, "Effort" if base.endswith("effort") else "Speed", value))
801
+
802
+
803
+ def _picker_clock(value: str, *, label: str = "执行时间") -> str:
804
+ match = re.fullmatch(r"((?:[01]\d|2[0-3]):[0-5]\d)(?: [+-]\d{4})?", value)
805
+ if not match:
806
+ raise CardActionError(f"请选择有效的{label}。")
807
+ return match[1]
808
+
809
+
810
+ def _once_picker(value: Any, clock: str, timezone: str, original_at: Any, *, label: str = "执行日期") -> str:
811
+ match = re.fullmatch(r"(\d{4}-\d{2}-\d{2})(?: [+-]\d{4})?", _text(value, label))
812
+ if not match:
813
+ raise CardActionError(f"请选择有效的{label}。")
814
+ try:
815
+ return resolve_once_local(
816
+ f"{match[1]}T{clock}", timezone,
817
+ original_at=original_at if isinstance(original_at, str) else None,
818
+ )
819
+ except AmbiguousLocalTime as error:
820
+ raise CardActionError("所选时间因夏令时回拨会出现两次,请换一个时间,或通过 Admin/自然语言明确指定偏移。") from error
821
+ except ScheduleError as error:
822
+ raise CardActionError(str(error)) from error
823
+
824
+
825
+ def _text(value: Any, label: str) -> str:
826
+ if not isinstance(value, str) or not value.strip():
827
+ raise CardActionError(label + "不能为空。")
828
+ return value.strip()
829
+
830
+
831
+ def _optional_text(value: Any, label: str) -> str:
832
+ if value is None:
833
+ return ""
834
+ if not isinstance(value, str):
835
+ raise CardActionError(f"{label}格式无效。")
836
+ return value.strip()