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.
- netizen_cli/__init__.py +3 -0
- netizen_cli/__main__.py +4 -0
- netizen_cli/admin/__init__.py +1 -0
- netizen_cli/admin/auth.py +928 -0
- netizen_cli/admin/errors.py +9 -0
- netizen_cli/admin/port_config.py +115 -0
- netizen_cli/admin/presentation.py +257 -0
- netizen_cli/admin/queries.py +337 -0
- netizen_cli/admin/static/admin.css +260 -0
- netizen_cli/admin/static/admin.js +2898 -0
- netizen_cli/admin/static/index.html +327 -0
- netizen_cli/admin/transport.py +935 -0
- netizen_cli/admin/web.py +2717 -0
- netizen_cli/bindings.py +3215 -0
- netizen_cli/builtin_skills.py +93 -0
- netizen_cli/cards/__init__.py +105 -0
- netizen_cli/cards/callbacks.py +565 -0
- netizen_cli/cards/controls.py +2273 -0
- netizen_cli/cards/defaults.py +213 -0
- netizen_cli/cards/model_info.py +80 -0
- netizen_cli/cards/questions.py +220 -0
- netizen_cli/cards/reply.py +2247 -0
- netizen_cli/cards/scheduled.py +836 -0
- netizen_cli/channel/__init__.py +1 -0
- netizen_cli/channel/completion_mentions.py +60 -0
- netizen_cli/channel/input_preparation.py +644 -0
- netizen_cli/channel/messages.py +57 -0
- netizen_cli/channel/ports.py +52 -0
- netizen_cli/channel/question_inputs.py +51 -0
- netizen_cli/channel/reactions.py +293 -0
- netizen_cli/channel/reply_presenter.py +1505 -0
- netizen_cli/channel/topics.py +70 -0
- netizen_cli/channel_app.py +6593 -0
- netizen_cli/cli.py +287 -0
- netizen_cli/cli_data.py +536 -0
- netizen_cli/cli_packages.py +526 -0
- netizen_cli/cli_services.py +651 -0
- netizen_cli/cli_setup.py +242 -0
- netizen_cli/cli_update.py +303 -0
- netizen_cli/cli_update_restore.py +53 -0
- netizen_cli/cli_update_worker.py +333 -0
- netizen_cli/codex_runtime.py +7125 -0
- netizen_cli/completion_mention.py +16 -0
- netizen_cli/database_migrations.py +218 -0
- netizen_cli/defaults/__init__.py +5 -0
- netizen_cli/defaults/models.py +39 -0
- netizen_cli/defaults/service.py +232 -0
- netizen_cli/defaults/store.py +260 -0
- netizen_cli/deployment/__init__.py +1 -0
- netizen_cli/deployment/restart_worker.py +134 -0
- netizen_cli/deployment/update_executor.py +258 -0
- netizen_cli/deployment/update_protocol.py +281 -0
- netizen_cli/domain.py +416 -0
- netizen_cli/error_messages.py +124 -0
- netizen_cli/experience.py +531 -0
- netizen_cli/feishu_app_onboarding.py +187 -0
- netizen_cli/feishu_app_permissions.py +123 -0
- netizen_cli/git_status.py +63 -0
- netizen_cli/image_inputs.py +579 -0
- netizen_cli/instance.py +84 -0
- netizen_cli/lark_app.py +125 -0
- netizen_cli/main.py +903 -0
- netizen_cli/management/__init__.py +83 -0
- netizen_cli/management/blocking_io.py +352 -0
- netizen_cli/management/chat_labels.py +266 -0
- netizen_cli/management/coordination.py +32 -0
- netizen_cli/management/service.py +2187 -0
- netizen_cli/management/updates.py +214 -0
- netizen_cli/markdown_images.py +78 -0
- netizen_cli/message_content.py +786 -0
- netizen_cli/message_history.py +643 -0
- netizen_cli/message_preparation.py +60 -0
- netizen_cli/message_projection.py +923 -0
- netizen_cli/migrations/__init__.py +1 -0
- netizen_cli/migrations/schema.py +103 -0
- netizen_cli/migrations/v14.py +438 -0
- netizen_cli/model_settings.py +269 -0
- netizen_cli/package_resources.py +22 -0
- netizen_cli/projects.py +327 -0
- netizen_cli/prompt_projection.py +327 -0
- netizen_cli/quoted_context.py +312 -0
- netizen_cli/resources/config.example.yaml +35 -0
- netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
- netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
- netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
- netizen_cli/result_images.py +123 -0
- netizen_cli/runtime/__init__.py +1 -0
- netizen_cli/runtime/contracts.py +792 -0
- netizen_cli/runtime/name_writes.py +67 -0
- netizen_cli/runtime/thread_naming.py +451 -0
- netizen_cli/schedules/__init__.py +1 -0
- netizen_cli/schedules/mcp.py +535 -0
- netizen_cli/schedules/models.py +394 -0
- netizen_cli/schedules/scheduler.py +374 -0
- netizen_cli/schedules/service.py +766 -0
- netizen_cli/schedules/store.py +771 -0
- netizen_cli/sdk_gap_adapter.py +1151 -0
- netizen_cli/service_launcher.py +583 -0
- netizen_cli/session_settings.py +126 -0
- netizen_cli/settings.py +216 -0
- netizen_cli/skill_references.py +40 -0
- netizen_cli/terminal_cleanup.py +155 -0
- netizen_cli/turn_activity.py +688 -0
- netizen_cli/turn_files.py +812 -0
- netizen_cli/turn_patch_children.py +254 -0
- netizen_cli/turn_plan_observer.py +315 -0
- netizen_cli/user_questions.py +106 -0
- netizen_cli-0.10.0.dist-info/METADATA +18 -0
- netizen_cli-0.10.0.dist-info/RECORD +112 -0
- netizen_cli-0.10.0.dist-info/WHEEL +5 -0
- netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
- 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()
|