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,766 @@
1
+ """One management boundary for the MCP tool, Feishu cards and Admin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import binascii
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import time
12
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
13
+ from dataclasses import asdict
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from typing import Any
17
+ from urllib.parse import urlencode
18
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
19
+
20
+ from ..bindings import BindingNotFound, BindingQuery, BindingQueryBusy, BindingQueryClosed, BindingQueryTimeout, BindingStore, ProjectConflict, ProjectDeleting, ProjectDisabled, ProjectNotFound, ScopeNotFound
21
+ from ..channel.messages import public_chat_kind
22
+ from ..model_settings import ModelCatalog, ModelCatalogError, STANDARD_SERVICE_TIER_ID
23
+ from ..session_settings import SessionSettings, SessionSettingsError
24
+ from .models import MutationResult, Plan, Run, ScheduleError, ScheduleNotFound, ScheduleRule, plan_lifecycle
25
+
26
+
27
+ _NATIVE_STATUSES = frozenset({"inProgress", "completed", "interrupted", "failed"})
28
+ _TERMINAL = _NATIVE_STATUSES - {"inProgress"}
29
+ _READ_CONCURRENCY = 4
30
+ _READ_TIMEOUT_SECONDS = 5.0
31
+ _FIELDS = {
32
+ "options": {"chat_id", "binding_query"},
33
+ "list": {"chat_id", "project", "enabled", "ended", "all", "cursor", "limit", "name", "target_kind", "target_binding_id"},
34
+ "view": {"plan_id"},
35
+ "create": {"name", "instructions", "project", "chat_id", "schedule", "timezone", "enabled", "request_id", "session_settings", "target_kind", "target_binding_id"},
36
+ "update": {"plan_id", "expected_revision", "request_id", "name", "instructions", "project", "chat_id", "schedule", "timezone", "enabled", "session_settings", "target_kind", "target_binding_id"},
37
+ "delete": {"plan_id", "expected_revision", "request_id"},
38
+ "run_now": {"plan_id", "expected_revision", "request_id"},
39
+ "runs": {"plan_id", "cursor", "limit"},
40
+ }
41
+
42
+
43
+ class _InputError(ScheduleError):
44
+ def __init__(self, code: str, message: str) -> None:
45
+ super().__init__(message)
46
+ self.code = code
47
+
48
+
49
+ def local_timezone() -> str | None:
50
+ """Resolve an IANA name once; never guess from an ambiguous abbreviation."""
51
+ candidates = [os.environ.get("TZ", "").lstrip(":")]
52
+ try:
53
+ target = str(Path("/etc/localtime").resolve(strict=True))
54
+ if "/zoneinfo/" in target:
55
+ candidates.append(target.split("/zoneinfo/", 1)[1])
56
+ except OSError:
57
+ pass
58
+ try:
59
+ candidates.append(Path("/etc/timezone").read_text().strip())
60
+ except OSError:
61
+ pass
62
+ for candidate in candidates:
63
+ try:
64
+ if candidate:
65
+ ZoneInfo(candidate)
66
+ return candidate
67
+ except (ZoneInfoNotFoundError, ValueError):
68
+ continue
69
+ return None
70
+
71
+
72
+ class ScheduleService:
73
+ def __init__(
74
+ self, *, bindings: BindingStore, runtime: Any, app_id: str,
75
+ chat_info: Any = None, wall_clock: Callable[[], float] = time.time,
76
+ default_timezone: str | None = None,
77
+ ) -> None:
78
+ self.app_id = app_id
79
+ self._bindings = bindings
80
+ self._store = bindings.schedules
81
+ self._runtime = runtime
82
+ self._chat_info = chat_info
83
+ self._clock = wall_clock
84
+ self.default_timezone = default_timezone or local_timezone()
85
+ self._wake: Callable[[], None] = lambda: None
86
+ self._refresh: Callable[[str], Awaitable[str | None]] | None = None
87
+ self._run_now: Callable[[str, int, str, object], MutationResult] | None = None
88
+ self._accepting = True
89
+
90
+ def set_wake_handler(self, callback: Callable[[], None]) -> None:
91
+ self._wake = callback
92
+
93
+ def set_refresh_handler(self, callback: Callable[[str], Awaitable[str | None]]) -> None:
94
+ self._refresh = callback
95
+
96
+ def set_run_now_handler(self, callback: Callable[[str, int, str, object], MutationResult]) -> None:
97
+ self._run_now = callback
98
+
99
+ def close_admission(self) -> None:
100
+ self._accepting = False
101
+
102
+ def _source(self, native_thread_id: str | None, scope_key: str | None) -> tuple[Any, Any]:
103
+ binding = None
104
+ scope = None
105
+ if native_thread_id:
106
+ binding = self._bindings.find_by_native_thread_id(native_thread_id)
107
+ if binding is not None:
108
+ scope = self._bindings.get_scope(binding.scope_key)
109
+ elif scope_key:
110
+ try:
111
+ scope = self._bindings.get_scope(scope_key)
112
+ binding = self._bindings.active_binding(scope_key)
113
+ except ScopeNotFound:
114
+ pass
115
+ if scope is None or scope.app_id != self.app_id:
116
+ return None, None
117
+ return scope, binding
118
+
119
+ async def manage(
120
+ self, request: Mapping[str, Any], *, native_thread_id: str | None = None,
121
+ scope_key: str | None = None, source: str = "mcp",
122
+ ) -> dict[str, Any]:
123
+ try:
124
+ if not self._accepting:
125
+ raise _InputError("unavailable", "服务正在停止,暂不接受定时任务管理。")
126
+ if not isinstance(request, Mapping):
127
+ raise ScheduleError("管理请求必须是对象。")
128
+ mode = request.get("mode")
129
+ if not isinstance(mode, str) or mode not in _FIELDS:
130
+ raise ScheduleError("请选择 options/list/view/create/update/delete/run_now/runs 操作。")
131
+ # Optional arguments treat null as omission, except the settings
132
+ # object. False remains a real pause/filter value.
133
+ data = {key: value for key, value in request.items()
134
+ if mode == "run_now" or value is not None or key == "session_settings"}
135
+ if set(data) - (_FIELDS[mode] | {"mode"}):
136
+ raise ScheduleError("该操作包含不支持的字段。")
137
+ now = self._clock()
138
+ # Hash the original structured intent, before clock-dependent
139
+ # anchors or context defaults are materialized. Replays must also
140
+ # work after the plan was edited/deleted or its once time passed.
141
+ request_payload = {
142
+ "app_id": self.app_id, "source": source,
143
+ "context": [native_thread_id, scope_key],
144
+ "request": {key: value for key, value in data.items() if key != "request_id"},
145
+ }
146
+ if mode in {"create", "update", "delete", "run_now"}:
147
+ request_id = _text(data.get("request_id"), "request_id")
148
+ replay = self._store.lookup_request(request_id, mode, request_payload, now=now)
149
+ if replay is not None:
150
+ if mode == "run_now":
151
+ return await self._run_receipt(replay)
152
+ return await self._mutation(replay, now)
153
+ scope, binding = self._source(native_thread_id, scope_key)
154
+ chat_default = scope.chat_id if scope else None
155
+ project_default = binding.project_alias if binding else None
156
+ if mode == "options":
157
+ return await self.options(native_thread_id=native_thread_id, scope_key=scope_key, **{key: data[key] for key in ("chat_id", "binding_query") if key in data})
158
+ if mode == "list":
159
+ all_plans = data.get("all", False)
160
+ _boolean(all_plans, "all")
161
+ chat = data["chat_id"] if "chat_id" in data else (None if all_plans else chat_default)
162
+ if not all_plans and not chat:
163
+ raise _InputError("context_required", "无法确定当前会话;请提供 chat_id 或 all=true。")
164
+ filters = {"app_id": self.app_id, "chat_id": chat,
165
+ "project_alias": data.get("project"), "enabled": data.get("enabled"),
166
+ "ended": data.get("ended"), "target_kind": data.get("target_kind"),
167
+ "target_binding_id": data.get("target_binding_id")}
168
+ if filters["target_kind"] is not None:
169
+ filters["target_kind"] = _target_kind(filters["target_kind"])
170
+ for field in ("chat_id", "project_alias", "target_binding_id"):
171
+ if filters[field] is not None:
172
+ filters[field] = _text(filters[field], field)
173
+ for field in ("enabled", "ended"):
174
+ if filters[field] is not None:
175
+ _boolean(filters[field], field)
176
+ name = data.get("name")
177
+ if name is not None:
178
+ _text(name, "name")
179
+ size = _page_size(data)
180
+ identity = {**filters, "name": name}
181
+ after = _decode_cursor(data.get("cursor"), identity)
182
+ plans = self._store.list(**filters, now=now, name=name, after=after, limit=size + 1)
183
+ return {"ok": True, "plans": await self._plans(plans[:size], now), "snapshot_at": now,
184
+ "next_cursor": _encode_cursor(plans[size - 1].id, identity) if len(plans) > size else None,
185
+ "default_timezone": self.default_timezone}
186
+ if mode == "create":
187
+ target_kind = _target_kind(data.get("target_kind", "new_topic"))
188
+ target_binding_id = None
189
+ if target_kind == "binding":
190
+ self._reject_binding_overrides(data)
191
+ target_scope, target = self._binding_target(data, binding=binding)
192
+ target_binding_id = target.id
193
+ chat, project = target_scope.chat_id, target.project_alias
194
+ await self._validate_chat(chat)
195
+ settings = None
196
+ else:
197
+ if "target_binding_id" in data:
198
+ raise ScheduleError("新话题模式不接受 target_binding_id。")
199
+ chat = _text(data["chat_id"] if "chat_id" in data else chat_default, "chat_id")
200
+ project = _text(data["project"] if "project" in data else project_default, "project")
201
+ chat_kind = await self._validate_chat(chat)
202
+ settings = await self._resolve_session_settings(data, binding=binding, chat_kind=chat_kind)
203
+ rule = self._rule(data, now=now)
204
+ enabled = data.get("enabled", True)
205
+ _boolean(enabled, "enabled")
206
+ result = self._store.create(
207
+ name=_text(data.get("name"), "name"),
208
+ instructions=_text(data.get("instructions"), "instructions"),
209
+ project_alias=project, app_id=self.app_id, chat_id=chat,
210
+ schedule=rule, enabled=enabled, source=source,
211
+ session_settings=settings,
212
+ target_kind=target_kind, target_binding_id=target_binding_id,
213
+ request_id=_text(data.get("request_id"), "request_id"), now=now,
214
+ request_payload=request_payload,
215
+ )
216
+ self._wake()
217
+ return await self._mutation(result, now)
218
+ plan = self._store.get(
219
+ _text(data.get("plan_id"), "plan_id"), include_deleted=mode == "runs",
220
+ )
221
+ if plan.app_id != self.app_id:
222
+ raise ScheduleNotFound("当前应用下没有这个定时计划。")
223
+ if mode == "runs":
224
+ size = _page_size(data)
225
+ identity = {"plan_id": plan.id}
226
+ after = _decode_cursor(data.get("cursor"), identity)
227
+ observed: dict[str, str] = {}
228
+ if mode in {"view", "runs"}:
229
+ observed = await self._refresh_pending(plan)
230
+ plan = self._store.get(plan.id, include_deleted=mode == "runs")
231
+ now = self._clock()
232
+ if mode == "view":
233
+ return await self._detail(plan, now, observed=observed)
234
+ if mode == "runs":
235
+ runs = self._store.list_runs(plan.id, after=after, limit=size + 1)
236
+ pending = self._store.pending_for_plan(plan.id)
237
+ lifecycle = plan_lifecycle(plan, now=now, has_pending=pending is not None)
238
+ selected = pending or (runs[0] if runs and not data.get("cursor") else None)
239
+ timezone = plan.schedule.timezone if plan.schedule else "UTC"
240
+ projections = await self._observe_runs([(run, timezone) for run in runs[:size]], observed=observed)
241
+ for value in projections:
242
+ value["is_last"] = not lifecycle.has_trigger and selected is not None and value["id"] == selected.id
243
+ return {"ok": True, "runs": projections, "snapshot_at": now,
244
+ "next_cursor": _encode_cursor(runs[size - 1].id, identity) if len(runs) > size else None}
245
+ revision = data.get("expected_revision")
246
+ if type(revision) is not int or revision < 1:
247
+ raise ScheduleError("修改、删除或立即运行必须提供当前 expected_revision。")
248
+ request_id = _text(data.get("request_id"), "request_id")
249
+ if mode == "run_now":
250
+ if self._run_now is None:
251
+ raise _InputError("unavailable", "调度服务尚未就绪,暂不能立即运行。")
252
+ # The synchronous handler rechecks exact identity, revision and
253
+ # admission, then owns dispatch before this request can cancel.
254
+ result = self._run_now(plan.id, revision, request_id, request_payload)
255
+ return await self._run_receipt(result)
256
+ if mode == "delete":
257
+ inflight = self._store.pending_for_plan(plan.id) is not None
258
+ result = self._store.delete(plan.id, expected_revision=revision, request_id=request_id, now=now, request_payload=request_payload)
259
+ self._wake()
260
+ return {"ok": True, "plan_id": result.plan_id, "revision": result.revision,
261
+ "inflight": inflight, "replayed": result.replayed}
262
+ self._validate_target_update(data, plan)
263
+ changes = {key: data[key] for key in ("name", "instructions", "chat_id", "enabled") if key in data}
264
+ if "enabled" in changes:
265
+ _boolean(changes["enabled"], "enabled")
266
+ if "project" in data:
267
+ changes["project_alias"] = _text(data["project"], "project")
268
+ if "session_settings" in data or ("chat_id" in changes and changes["chat_id"] != plan.chat_id):
269
+ chat_kind = await self._validate_chat(_text(changes.get("chat_id", plan.chat_id), "chat_id"))
270
+ changes["session_settings"] = await self._resolve_session_settings(data, previous=plan.session_settings, chat_kind=chat_kind)
271
+ if "schedule" in data or "timezone" in data:
272
+ changes["schedule"] = self._rule(data, now=now, previous=plan.schedule)
273
+ result = self._store.update(plan.id, expected_revision=revision, request_id=request_id, changes=changes, now=now, request_payload=request_payload)
274
+ self._wake()
275
+ return await self._mutation(result, now)
276
+ except (ScheduleError, SessionSettingsError, BindingNotFound, ScopeNotFound, ProjectNotFound, ProjectConflict) as error:
277
+ return _failure(error)
278
+
279
+ async def preview(self, request: Mapping[str, Any], *, native_thread_id: str | None = None, scope_key: str | None = None) -> dict[str, Any]:
280
+ try:
281
+ if not isinstance(request, Mapping):
282
+ raise ScheduleError("预览请求必须是对象。")
283
+ now = self._clock()
284
+ scope, binding = self._source(native_thread_id, scope_key)
285
+ plan = self._store.get(_text(request["plan_id"], "plan_id")) if request.get("plan_id") else None
286
+ if plan is not None and plan.app_id != self.app_id:
287
+ raise ScheduleNotFound("当前应用下没有这个定时计划。")
288
+ target_kind = _target_kind(request.get("target_kind", plan.target_kind if plan else "new_topic"))
289
+ if plan is not None:
290
+ self._validate_target_update(request, plan)
291
+ if target_kind == "binding":
292
+ self._reject_binding_overrides(request)
293
+ target_scope, target = self._binding_target(
294
+ {"target_binding_id": plan.target_binding_id} if plan else request, binding=binding,
295
+ )
296
+ await self._validate_chat(target_scope.chat_id)
297
+ settings = None
298
+ effective = SessionSettings.from_binding(target).to_dict()
299
+ else:
300
+ if request.get("target_binding_id") is not None:
301
+ raise ScheduleError("新话题模式不接受 target_binding_id。")
302
+ chat = request["chat_id"] if request.get("chat_id") is not None else (plan.chat_id if plan else scope.chat_id if scope else None)
303
+ kind = await self._validate_chat(_text(chat, "chat_id")) if chat is not None else None
304
+ settings = await self._resolve_session_settings(request, binding=binding, previous=plan.session_settings if plan else None, chat_kind=kind)
305
+ effective = settings.to_dict()
306
+ rule = self._rule(request, now=now, previous=plan.schedule if plan else None)
307
+ boundary = max(now, plan.processed_through or now) if plan else now
308
+ return {"ok": True, "schedule": rule.to_dict(),
309
+ "preview": _preview(rule, boundary),
310
+ "target_kind": target_kind,
311
+ "session_settings": settings.to_dict() if settings is not None else None,
312
+ "effective_session_settings": effective, "default_timezone": self.default_timezone}
313
+ except (ScheduleError, SessionSettingsError, BindingNotFound, ScopeNotFound, ProjectNotFound, ProjectConflict) as error:
314
+ return _failure(error)
315
+
316
+ @staticmethod
317
+ def _reject_binding_overrides(request: Mapping[str, Any]) -> None:
318
+ if {"project", "chat_id", "session_settings"}.intersection(request):
319
+ raise ScheduleError("原会话计划沿用目标 Binding 的 Project、位置和配置,不接受独立覆盖。")
320
+
321
+ def _binding_target(self, request: Mapping[str, Any], *, binding: Any = None) -> tuple[Any, Any]:
322
+ target_id = request.get("target_binding_id")
323
+ if target_id is None:
324
+ if binding is None:
325
+ raise _InputError("context_required", "无法确定原会话;请提供 options 返回的准确 target_binding_id。")
326
+ target_id = binding.id
327
+ target = self._bindings.get(_text(target_id, "target_binding_id"))
328
+ scope = self._bindings.get_scope(target.scope_key)
329
+ if scope.app_id != self.app_id:
330
+ raise BindingNotFound("当前应用下没有这个目标会话。")
331
+ return scope, target
332
+
333
+ def _validate_target_update(self, request: Mapping[str, Any], plan: Plan) -> None:
334
+ if "target_kind" in request and _target_kind(request["target_kind"]) != plan.target_kind:
335
+ raise ScheduleError("计划的执行目标类型不可修改;请新建计划。")
336
+ if "target_binding_id" in request and request["target_binding_id"] != plan.target_binding_id:
337
+ raise ScheduleError("计划的目标 Binding 不可修改;请新建计划。")
338
+ if plan.target_kind == "binding":
339
+ self._reject_binding_overrides(request)
340
+
341
+ async def _catalog(self) -> tuple[ModelCatalog | None, dict[str, str] | None]:
342
+ try:
343
+ async with asyncio.timeout(5):
344
+ return await self._runtime.model_catalog(), None
345
+ except Exception:
346
+ return None, {"code": "model_catalog_unavailable", "message": "Codex 模型目录暂不可用;可保留已有模型设置或选择继承 Codex,稍后再选择新模型。"}
347
+
348
+ async def _resolve_session_settings(self, request: Mapping[str, Any], *, binding: Any = None,
349
+ previous: SessionSettings | None = None, chat_kind: str | None = None) -> SessionSettings:
350
+ patch = request.get("session_settings", {})
351
+ SessionSettings().merge(patch) # Reject malformed input before native I/O.
352
+ catalog = None
353
+ catalog_error = None
354
+ if previous is not None:
355
+ base = previous
356
+ elif binding is not None:
357
+ base = SessionSettings.from_binding(binding)
358
+ elif "turn_settings" not in patch:
359
+ catalog, catalog_error = await self._catalog()
360
+ base = SessionSettings.new_defaults(catalog)
361
+ else:
362
+ base = SessionSettings.new_defaults(None)
363
+ settings = base.merge(patch)
364
+ if chat_kind == "p2p" and settings.message_context_mode.value == "catch-up":
365
+ if "message_context_mode" in patch:
366
+ raise SessionSettingsError("私聊目标只支持 current-only;请将 message_context_mode 设为 current-only。")
367
+ settings = settings.merge({"message_context_mode": "current-only"})
368
+ if settings.turn_settings is not None and (previous is None or settings.turn_settings != previous.turn_settings):
369
+ if catalog is None and catalog_error is None:
370
+ catalog, catalog_error = await self._catalog()
371
+ if catalog is None:
372
+ assert catalog_error is not None
373
+ raise _InputError(catalog_error["code"], catalog_error["message"])
374
+ try:
375
+ settings.validate_catalog(catalog)
376
+ except ModelCatalogError as error:
377
+ raise _InputError("invalid_model_settings", "所选模型、思考强度或速度组合已不可用。请通过 options 读取当前可选项后重新选择;已有计划的其他设置可以单独修改。") from error
378
+ return settings
379
+
380
+ async def options(self, *, native_thread_id: str | None = None, scope_key: str | None = None, chat_id: str | None = None, binding_query: str | None = None) -> dict[str, Any]:
381
+ result, _ = await self.form_options(native_thread_id=native_thread_id, scope_key=scope_key, chat_id=chat_id, binding_query=binding_query)
382
+ return result
383
+
384
+ async def form_options(self, *, native_thread_id: str | None = None, scope_key: str | None = None, chat_id: str | None = None, binding_query: str | None = None) -> tuple[dict[str, Any], ModelCatalog | None]:
385
+ try:
386
+ if binding_query is not None:
387
+ if not isinstance(binding_query, str) or len(binding_query) > 200:
388
+ raise ScheduleError("binding_query 须为最多 200 字符的查询文本。")
389
+ binding_query = binding_query.strip() or None
390
+ scope, binding = self._source(native_thread_id, scope_key)
391
+ chat = _text(chat_id, "chat_id") if chat_id is not None else scope.chat_id if scope else None
392
+ kind = await self._validate_chat(chat) if chat is not None else None
393
+ catalog, error = await self._catalog()
394
+ settings = SessionSettings.from_binding(binding) if binding else SessionSettings.new_defaults(catalog)
395
+ if kind == "p2p":
396
+ settings = settings.merge({"message_context_mode": "current-only"})
397
+ binding_error = None
398
+ try:
399
+ targets, truncated = await self._binding_options(chat=chat, binding=binding, search=binding_query)
400
+ except (BindingQueryBusy, BindingQueryClosed, BindingQueryTimeout):
401
+ targets, truncated = [], False
402
+ binding_error = {"code": "binding_options_unavailable", "message": "会话选项暂不可用,请稍后重试。"}
403
+ return {"ok": True, "session_settings": settings.to_dict(), "models": _models(catalog),
404
+ "source_binding_id": binding.id if binding else None,
405
+ "binding_targets": targets, "bindings_truncated": truncated,
406
+ "binding_options_error": binding_error,
407
+ "context_mode_available": kind != "p2p" if kind is not None else None,
408
+ "model_catalog_error": error}, catalog
409
+ except (ScheduleError, SessionSettingsError, BindingNotFound, ScopeNotFound) as error:
410
+ return _failure(error), None
411
+
412
+ async def _binding_options(self, *, chat: str | None, binding: Any, search: str | None) -> tuple[list[dict[str, Any]], bool]:
413
+ page = await self._bindings.query_bindings(
414
+ query=BindingQuery(chat_id=chat, app_id=self.app_id, search=search), limit=100,
415
+ )
416
+ targets = {}
417
+ if binding is not None:
418
+ scope = self._bindings.get_scope(binding.scope_key)
419
+ matches = search is None or any(
420
+ search.lower() in value.lower()
421
+ for value in (binding.id, binding.project_alias, scope.chat_id, scope.topic_id or "")
422
+ )
423
+ if matches and (chat is None or scope.chat_id == chat):
424
+ targets[binding.id] = (scope, binding)
425
+ targets.update((item.binding.id, (item.scope, item.binding)) for item in page.items)
426
+ result = []
427
+ for scope, target in targets.values():
428
+ available = False
429
+ try:
430
+ available = (
431
+ self._bindings.get_project(target.project_alias).enabled and target.active
432
+ and not self._bindings.project_delete_in_progress(target.project_alias)
433
+ )
434
+ except ProjectNotFound:
435
+ pass
436
+ result.append({
437
+ "id": target.id, "label": self._binding_label(scope, target),
438
+ "scope_key": scope.scope_key, "chat_id": scope.chat_id,
439
+ "project_alias": target.project_alias, "current": target.active,
440
+ "available": available,
441
+ })
442
+ return result, page.next_cursor is not None
443
+
444
+ @staticmethod
445
+ def _binding_label(scope: Any, binding: Any) -> str:
446
+ location = scope.chat_id + (f" / {scope.topic_id}" if scope.topic_id else "")
447
+ return f"{binding.project_alias} · {binding.short_id} · {location}"
448
+
449
+ def _rule(self, data: Mapping[str, Any], *, now: float, previous: ScheduleRule | None = None) -> ScheduleRule:
450
+ raw = data.get("schedule")
451
+ if raw is None and previous is not None:
452
+ raw = previous.to_dict()
453
+ if data.get("timezone"):
454
+ raw.pop("timezone", None)
455
+ if not isinstance(raw, dict):
456
+ raise ScheduleError("请提供结构化 schedule。")
457
+ raw = dict(raw)
458
+ timezone = data.get("timezone")
459
+ if timezone is not None and not isinstance(timezone, str):
460
+ raise ScheduleError("timezone 必须是 IANA 时区名称。")
461
+ if raw.get("timezone") is not None and not isinstance(raw["timezone"], str):
462
+ raise ScheduleError("schedule.timezone 必须是 IANA 时区名称。")
463
+ if timezone:
464
+ if raw.get("timezone") and raw["timezone"] != timezone:
465
+ raise ScheduleError("schedule 与外层 timezone 不一致。")
466
+ raw["timezone"] = timezone
467
+ if not raw.get("timezone"):
468
+ raw["timezone"] = previous.timezone if previous else self.default_timezone
469
+ if not raw["timezone"]:
470
+ raise _InputError("timezone_required", "无法确定服务的本地时区,请显式选择 IANA 时区。")
471
+ if raw.get("kind") == "interval" and raw.get("anchor") is None:
472
+ raw["anchor"] = previous.anchor if previous and previous.kind == "interval" else now
473
+ rule = ScheduleRule.from_dict(raw)
474
+ # An unchanged ended definition remains editable. A recurring rule may
475
+ # also be deliberately shortened to end now; only a new definition or
476
+ # a changed one-shot time must provide a future opportunity.
477
+ if (previous is None or (rule.kind == "once" and rule != previous)) and rule.next_after(now) is None:
478
+ raise ScheduleError("时间规则没有未来触发,请检查执行时间和截止条件。")
479
+ return rule
480
+
481
+ async def _validate_chat(self, chat_id: str) -> str:
482
+ if self._chat_info is None:
483
+ raise _InputError("unavailable", "会话信息服务暂不可用。")
484
+ try:
485
+ async with asyncio.timeout(5):
486
+ info = await self._chat_info.get_chat_info(chat_id)
487
+ except Exception as error:
488
+ raise _InputError("chat_unavailable", "无法访问目标会话,请检查 chat_id 和机器人是否可访问该会话。") from error
489
+ kind = public_chat_kind(info)
490
+ if kind is None:
491
+ raise _InputError("chat_kind_unknown", "无法确认目标会话类型,请稍后重试或检查 chat_id;这不代表当前会话是私聊。")
492
+ return kind
493
+
494
+ async def _run_receipt(self, result: MutationResult) -> dict[str, Any]:
495
+ if not result.run_id:
496
+ raise ScheduleError("手动执行回执缺少 Run ID,请检查数据完整性;不能重新触发。")
497
+ receipt: dict[str, Any] = {
498
+ "ok": True, "accepted": True, "plan_id": result.plan_id,
499
+ "revision": result.revision, "run_id": result.run_id,
500
+ "replayed": result.replayed, "run": None,
501
+ }
502
+ try:
503
+ run = self._store.get_run(result.run_id)
504
+ except ScheduleNotFound:
505
+ # A bounded request receipt can outlive a released Run. It still
506
+ # proves acceptance of that exact run, never permission to rerun.
507
+ return receipt
508
+ plan = self._store.get(result.plan_id, include_deleted=True)
509
+ receipt["run"] = await self._run(
510
+ run, timezone=plan.schedule.timezone if plan.schedule else "UTC",
511
+ deadline=asyncio.get_running_loop().time() + _READ_TIMEOUT_SECONDS,
512
+ slots=asyncio.Semaphore(1),
513
+ )
514
+ return receipt
515
+
516
+ async def _mutation(self, result: Any, now: float) -> dict[str, Any]:
517
+ plan = self._store.get(result.plan_id, include_deleted=True)
518
+ if plan.deleted:
519
+ return {"ok": True, "plan_id": result.plan_id, "revision": result.revision,
520
+ "replayed": result.replayed, "deleted": True,
521
+ "inflight": self._store.pending_for_plan(plan.id) is not None}
522
+ return {**await self._detail(plan, now), "replayed": result.replayed,
523
+ "operation_revision": result.revision}
524
+
525
+ async def _detail(self, plan: Plan, now: float, *, observed: Mapping[str, str] | None = None) -> dict[str, Any]:
526
+ value, = await self._plans((plan,), now, observed=observed)
527
+ return {"ok": True, "plan": value, "snapshot_at": now,
528
+ "preview": _preview(plan.schedule, max(now, plan.processed_through or now)) if plan.schedule else [],
529
+ "inflight": value["inflight"]}
530
+
531
+ async def _plans(self, plans: Sequence[Plan], now: float, *, observed: Mapping[str, str] | None = None) -> list[dict[str, Any]]:
532
+ # Snapshot all Channel-owned lifecycle inputs before the first await.
533
+ # Native observation enriches execution only; it never changes barriers
534
+ # or the ended predicate used to select this page.
535
+ snapshots = [self._plan(plan, now) for plan in plans]
536
+ projections = await self._observe_runs([
537
+ (run, plan.schedule.timezone if plan.schedule else "UTC")
538
+ for plan, (_value, run) in zip(plans, snapshots) if run is not None
539
+ ], observed=observed)
540
+ by_id = {run["id"]: run for run in projections}
541
+ for value, run in snapshots:
542
+ if run is not None:
543
+ projection = by_id[run.id]
544
+ value["execution"].update({
545
+ key: projection[key] for key in (
546
+ "status", "native_thread_id", "scope_key", "feishu_url",
547
+ )
548
+ })
549
+ return [value for value, _run in snapshots]
550
+
551
+ def _plan(self, plan: Plan, now: float) -> tuple[dict[str, Any], Run | None]:
552
+ value = asdict(plan)
553
+ value["session_settings"] = plan.session_settings.to_dict() if plan.session_settings is not None else None
554
+ value["effective_session_settings"] = value["session_settings"]
555
+ value["target_label"] = plan.chat_id
556
+ value["schedule"] = plan.schedule.to_dict() if plan.schedule else None
557
+ timezone = plan.schedule.timezone if plan.schedule else "UTC"
558
+ value["next_due_local"] = _iso(plan.next_due_at, timezone) if plan.next_due_at is not None else None
559
+ pending = self._store.pending_for_plan(plan.id)
560
+ lifecycle = plan_lifecycle(plan, now=now, has_pending=pending is not None)
561
+ value["lifecycle"] = asdict(lifecycle)
562
+ value["status"] = "ended" if lifecycle.ended else "enabled" if plan.enabled else "paused"
563
+ value["inflight"] = pending is not None
564
+ value["blocked_reason"] = "blocked_unknown" if pending and pending.barrier == "unknown" else None
565
+ latest = self._store.list_runs(plan.id, limit=1)
566
+ value["latest_run"] = None
567
+ if latest:
568
+ run = latest[0]
569
+ value["latest_run"] = {
570
+ "id": run.id, "due_at": run.due_at, "phase": run.phase,
571
+ "trigger_source": run.trigger_source,
572
+ "error_code": run.error_code, "binding_id": run.binding_id,
573
+ "due_local": _iso(run.due_at, timezone),
574
+ }
575
+ selected = pending or (latest[0] if latest else None)
576
+ value["execution"] = {
577
+ "kind": "current" if pending else "latest" if selected else "none",
578
+ "status": "expired" if lifecycle.ended else "not_started",
579
+ "is_last": selected is not None and not lifecycle.has_trigger,
580
+ "run_id": selected.id if selected else None,
581
+ "trigger_source": selected.trigger_source if selected else None,
582
+ "due_at": selected.due_at if selected else None,
583
+ "due_local": _iso(selected.due_at, timezone) if selected else None,
584
+ "native_thread_id": None, "scope_key": None, "feishu_url": None,
585
+ }
586
+ try:
587
+ if not self._bindings.get_project(plan.project_alias).enabled:
588
+ value["blocked_reason"] = "project_disabled"
589
+ except ProjectNotFound:
590
+ value["blocked_reason"] = "project_unavailable"
591
+ if self._bindings.project_delete_in_progress(plan.project_alias):
592
+ value["blocked_reason"] = "project_deleting"
593
+ value["suspended_reason"] = value["blocked_reason"] if value["blocked_reason"] in {"project_disabled", "project_unavailable", "project_deleting"} else None
594
+ if plan.target_kind == "binding":
595
+ reason = self._store.binding_target_error(plan)
596
+ if reason is not None:
597
+ value["suspended_reason"] = reason
598
+ value["blocked_reason"] = reason
599
+ try:
600
+ scope, binding = self._binding_target({"target_binding_id": plan.target_binding_id})
601
+ value["target_label"] = self._binding_label(scope, binding)
602
+ value["effective_session_settings"] = SessionSettings.from_binding(binding).to_dict()
603
+ except (BindingNotFound, ScopeNotFound):
604
+ pass
605
+ value["can_run_now"] = not plan.deleted and not value["blocked_reason"] and not value["inflight"]
606
+ return value, selected
607
+
608
+ async def _refresh_pending(self, plan: Plan) -> dict[str, str]:
609
+ """Keep explicit detail/history refresh as the existing recovery entry."""
610
+ if plan.target_kind == "binding":
611
+ # A Turn's later state never establishes whether this input was
612
+ # accepted. Binding runs describe input handoff only.
613
+ return {}
614
+ pending = self._store.pending_for_plan(plan.id)
615
+ if pending is None:
616
+ return {}
617
+ if self._refresh is not None:
618
+ return {pending.id: await self._refresh(plan.id) or "unavailable"}
619
+ if pending.initial_turn_id is None:
620
+ return {}
621
+ timezone = plan.schedule.timezone if plan.schedule else "UTC"
622
+ value, = await self._observe_runs([(pending, timezone)])
623
+ status = value["status"]
624
+ try:
625
+ if status in _TERMINAL:
626
+ self._store.release(pending.id)
627
+ self._wake()
628
+ elif status == "unavailable":
629
+ self._store.set_run(pending.id, barrier="unknown", error_code="observation_unavailable")
630
+ except ScheduleNotFound:
631
+ pass
632
+ return {pending.id: status}
633
+
634
+ async def _observe_runs(self, runs: Sequence[tuple[Run, str]], *, observed: Mapping[str, str] | None = None) -> list[dict[str, Any]]:
635
+ deadline = asyncio.get_running_loop().time() + _READ_TIMEOUT_SECONDS
636
+ slots = asyncio.Semaphore(_READ_CONCURRENCY)
637
+ return list(await asyncio.gather(*(
638
+ self._run(run, timezone=timezone, deadline=deadline, slots=slots, observed=(observed or {}).get(run.id))
639
+ for run, timezone in runs
640
+ )))
641
+
642
+ async def _run(self, run: Run, *, timezone: str, deadline: float, slots: asyncio.Semaphore, observed: str | None = None) -> dict[str, Any]:
643
+ value = asdict(run)
644
+ status = run.error_code or ("starting" if run.barrier == "held" else "unknown")
645
+ if run.barrier == "unknown" or run.error_code == "publishing_unknown":
646
+ status = "unknown"
647
+ value.update(status=status, due_local=_iso(run.due_at, timezone), native_thread_id=None, scope_key=None, feishu_url=None)
648
+ anchor_id = run.origin_message_id if run.target_kind == "binding" else run.root_message_id
649
+ if anchor_id:
650
+ value["feishu_url"] = "https://applink.feishu.cn/client/chat/open?" + urlencode({"openChatId": run.chat_id, "messageId": anchor_id})
651
+ if run.binding_removed:
652
+ value["status"] = "deleted"
653
+ return value
654
+ if run.target_kind == "binding":
655
+ if run.disposition in {"started", "steered"}:
656
+ value["status"] = "input_" + run.disposition
657
+ elif status == "unknown" or run.error_code in {"input_unknown", "publishing_unknown"}:
658
+ value["status"] = "input_unknown"
659
+ if run.binding_id:
660
+ try:
661
+ binding = self._bindings.get(run.binding_id)
662
+ value.update(native_thread_id=binding.native_thread_id, scope_key=binding.scope_key)
663
+ except BindingNotFound:
664
+ pass
665
+ return value
666
+ if run.binding_id:
667
+ try:
668
+ binding = self._bindings.get(run.binding_id)
669
+ except BindingNotFound:
670
+ value["status"] = "unavailable"
671
+ return value
672
+ value.update(native_thread_id=binding.native_thread_id, scope_key=binding.scope_key)
673
+ if run.initial_turn_id:
674
+ if observed is not None:
675
+ value["status"] = observed
676
+ return value
677
+ try:
678
+ async with asyncio.timeout_at(deadline):
679
+ async with slots:
680
+ status = await self._runtime.read_scheduled_turn(binding.id, run.initial_turn_id, deadline=deadline)
681
+ value["status"] = status if status in _NATIVE_STATUSES else "unknown"
682
+ except Exception:
683
+ value["status"] = "unavailable"
684
+ return value
685
+
686
+
687
+ def _models(catalog: ModelCatalog | None) -> list[dict[str, Any]]:
688
+ if catalog is None:
689
+ return []
690
+ return [{
691
+ "id": model.id, "model": model.model, "display_name": model.display_name,
692
+ "description": model.description, "is_default": model.is_default,
693
+ "default_effort_id": model.default_effort_id, "default_service_tier_id": model.default_service_tier_id,
694
+ "efforts": [{"id": effort.id, "description": effort.description} for effort in model.efforts],
695
+ "service_tiers": [{"id": STANDARD_SERVICE_TIER_ID, "name": "Standard", "description": "Codex 标准服务层"},
696
+ *[{"id": tier.id, "name": tier.name, "description": tier.description}
697
+ for tier in model.service_tiers if tier.id != STANDARD_SERVICE_TIER_ID]],
698
+ } for model in catalog.models]
699
+
700
+
701
+ def _text(value: Any, field: str) -> str:
702
+ if not isinstance(value, str) or not value.strip():
703
+ raise ScheduleError(f"请提供 {field}。")
704
+ return value.strip()
705
+
706
+
707
+ def _target_kind(value: Any) -> str:
708
+ if not isinstance(value, str) or value not in {"new_topic", "binding"}:
709
+ raise ScheduleError("target_kind 必须为 new_topic 或 binding。")
710
+ return value
711
+
712
+
713
+ def _boolean(value: Any, field: str) -> None:
714
+ if type(value) is not bool:
715
+ raise ScheduleError(f"{field} 必须为 true 或 false。")
716
+
717
+
718
+ def _page_size(data: Mapping[str, Any]) -> int:
719
+ size = data.get("limit", 20)
720
+ if type(size) is not int or not 1 <= size <= 50:
721
+ raise ScheduleError("每页数量须为 1 到 50。")
722
+ return size
723
+
724
+
725
+ def _fingerprint(value: object) -> str:
726
+ return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False).encode()).hexdigest()[:24]
727
+
728
+
729
+ def _encode_cursor(after: str, identity: object) -> str:
730
+ return base64.urlsafe_b64encode(json.dumps([after, _fingerprint(identity)]).encode()).decode().rstrip("=")
731
+
732
+
733
+ def _decode_cursor(value: Any, identity: object) -> str | None:
734
+ if value is None:
735
+ return None
736
+ try:
737
+ if not isinstance(value, str) or len(value) > 512:
738
+ raise ValueError
739
+ decoded = json.loads(base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True))
740
+ if not isinstance(decoded, list) or len(decoded) != 2 or not isinstance(decoded[0], str) or decoded[1] != _fingerprint(identity):
741
+ raise ValueError
742
+ return decoded[0]
743
+ except (ValueError, binascii.Error, UnicodeDecodeError) as error:
744
+ raise ScheduleError("分页游标无效或筛选条件已改变,请从第一页重新查询。") from error
745
+
746
+
747
+ def _iso(stamp: float, timezone: str = "UTC") -> str:
748
+ return datetime.fromtimestamp(stamp, ZoneInfo(timezone)).isoformat(timespec="minutes")
749
+
750
+
751
+ def _preview(rule: ScheduleRule, now: float) -> list[dict[str, str]]:
752
+ return [{"utc": _iso(stamp), "local": _iso(stamp, rule.timezone)}
753
+ for stamp in rule.preview(now)]
754
+
755
+
756
+ def _failure(error: Exception) -> dict[str, Any]:
757
+ if isinstance(error, ProjectDeleting):
758
+ return {"ok": False, "error": {"code": "project_deleting", "message": "该 Project 正在删除,请等待操作完成后刷新,或选择其他可用的 Project。"}}
759
+ if isinstance(error, ProjectDisabled):
760
+ return {"ok": False, "error": {"code": "project_disabled", "message": "该 Project 已停用,请选择其他可用的 Project,或通过 /settings 重新启用后重试。"}}
761
+ if isinstance(error, ProjectNotFound):
762
+ return {"ok": False, "error": {"code": "not_found", "message": "找不到该 Project,请选择已登记的 Project,或先通过 /settings 登记并启用。"}}
763
+ code = getattr(error, "code", "invalid_schedule")
764
+ if isinstance(error, (ScheduleNotFound, ProjectNotFound, BindingNotFound, ScopeNotFound)):
765
+ code = "not_found"
766
+ return {"ok": False, "error": {"code": code, "message": str(error)}}