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,394 @@
1
+ """Immutable scheduling facts and the shared wall-clock rule calculator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from dataclasses import dataclass
8
+ from datetime import UTC, datetime, timedelta
9
+ from typing import Any
10
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
11
+
12
+ from ..session_settings import SessionSettings
13
+
14
+
15
+ class ScheduleError(ValueError):
16
+ code = "invalid_schedule"
17
+
18
+
19
+ class ScheduleNotFound(ScheduleError):
20
+ code = "schedule_not_found"
21
+
22
+
23
+ class ScheduleConflict(ScheduleError):
24
+ code = "schedule_conflict"
25
+
26
+
27
+ class ScheduleRevisionConflict(ScheduleConflict):
28
+ code = "revision_conflict"
29
+
30
+
31
+ class ScheduleRequestConflict(ScheduleConflict):
32
+ code = "request_conflict"
33
+
34
+
35
+ class ScheduleRunInProgress(ScheduleConflict):
36
+ code = "run_in_progress"
37
+
38
+
39
+ class ScheduleBlockedUnknown(ScheduleConflict):
40
+ code = "blocked_unknown"
41
+
42
+
43
+ class AmbiguousLocalTime(ScheduleError):
44
+ code = "ambiguous_local_time"
45
+
46
+ def __init__(self, choices: tuple[dict[str, str], ...]) -> None:
47
+ super().__init__("所选时间因夏令时回拨会出现两次,请选择对应的 UTC 偏移。")
48
+ self.choices = choices
49
+
50
+
51
+ def resolve_once_local(
52
+ local_at: str, timezone: str, *, original_at: str | None = None,
53
+ utc_offset: str | None = None,
54
+ ) -> str:
55
+ """Resolve a minute-precision picker value without guessing a DST fold."""
56
+ try:
57
+ if not isinstance(local_at, str) or not re.fullmatch(
58
+ r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::00)?", local_at,
59
+ ):
60
+ raise ValueError("invalid local datetime")
61
+ zone = ZoneInfo(timezone)
62
+ local = datetime.fromisoformat(local_at)
63
+ moments = []
64
+ for fold in (0, 1):
65
+ moment = local.replace(tzinfo=zone, fold=fold)
66
+ if datetime.fromtimestamp(moment.timestamp(), zone).replace(tzinfo=None) != local:
67
+ continue
68
+ if not moments or moment.timestamp() != moments[0].timestamp():
69
+ moments.append(moment)
70
+ if not moments:
71
+ raise ScheduleError("该时区在这一天没有所选时间(夏令时跳时),请选择其他时间。")
72
+ choices = tuple({
73
+ "utc_offset": moment.isoformat(timespec="minutes")[16:],
74
+ "utc": moment.astimezone(UTC).isoformat(timespec="minutes"),
75
+ } for moment in moments)
76
+ if utc_offset is not None:
77
+ for moment, choice in zip(moments, choices, strict=True):
78
+ if choice["utc_offset"] == utc_offset:
79
+ return moment.isoformat(timespec="minutes")
80
+ raise ScheduleError("所选 UTC 偏移不适用于该日期和时区,请重新预览。")
81
+ if original_at is not None:
82
+ original = datetime.fromisoformat(original_at)
83
+ if original.tzinfo is not None and original.astimezone(zone).replace(tzinfo=None) == local:
84
+ return original.isoformat(timespec="minutes")
85
+ if len(moments) > 1:
86
+ raise AmbiguousLocalTime(choices)
87
+ return moments[0].isoformat(timespec="minutes")
88
+ except ScheduleError:
89
+ raise
90
+ except (TypeError, ValueError, ZoneInfoNotFoundError, OSError, OverflowError) as error:
91
+ raise ScheduleError("请检查执行日期与时区。") from error
92
+
93
+
94
+ @dataclass(frozen=True, slots=True)
95
+ class ScheduleRule:
96
+ kind: str
97
+ timezone: str
98
+ at: str | None = None
99
+ weekdays: tuple[int, ...] = ()
100
+ every_minutes: int | None = None
101
+ anchor: float | None = None
102
+ end_at: str | None = None
103
+
104
+ def __post_init__(self) -> None:
105
+ try:
106
+ ZoneInfo(self.timezone)
107
+ except (ZoneInfoNotFoundError, ValueError, TypeError, OSError) as error:
108
+ raise ScheduleError("请选择有效的 IANA 时区。") from error
109
+ if not isinstance(self.kind, str) or self.kind not in {"once", "daily", "weekly", "interval"}:
110
+ raise ScheduleError("仅支持一次性、每天、每周和固定间隔。")
111
+ if self.kind == "once" and self.end_at is not None:
112
+ raise ScheduleError("一次性计划不能设置截止时间。")
113
+ if self.end_at is not None:
114
+ try:
115
+ if not isinstance(self.end_at, str):
116
+ raise ValueError("invalid end time")
117
+ end = datetime.fromisoformat(self.end_at)
118
+ if end.tzinfo is None or end.second or end.microsecond:
119
+ raise ValueError("end time must be absolute and minute-precision")
120
+ datetime.fromtimestamp(end.timestamp(), UTC)
121
+ datetime.fromtimestamp(end.timestamp(), ZoneInfo(self.timezone))
122
+ object.__setattr__(self, "end_at", end.isoformat(timespec="minutes"))
123
+ except (TypeError, ValueError, OverflowError, OSError) as error:
124
+ raise ScheduleError("截止时间必须是有效的带偏移 ISO 时间,精度为分钟。") from error
125
+ if not isinstance(self.weekdays, (tuple, list)):
126
+ raise ScheduleError("weekdays 必须是星期列表。")
127
+ object.__setattr__(self, "weekdays", tuple(self.weekdays))
128
+ if self.kind == "interval":
129
+ if (
130
+ type(self.every_minutes) is not int or self.every_minutes < 1
131
+ or type(self.anchor) not in {int, float}
132
+ or self.at is not None or self.weekdays
133
+ ):
134
+ raise ScheduleError("固定间隔需要正整数分钟和 UTC anchor。")
135
+ try:
136
+ if not math.isfinite(self.anchor):
137
+ raise ScheduleError("固定间隔需要有限的 UTC anchor。")
138
+ datetime.fromtimestamp(self.anchor, UTC)
139
+ datetime.fromtimestamp(self.anchor + self.every_minutes * 60, UTC)
140
+ except (ValueError, OverflowError, OSError) as error:
141
+ raise ScheduleError("固定间隔超出支持的日期范围。") from error
142
+ return
143
+ if self.every_minutes is not None or self.anchor is not None:
144
+ raise ScheduleError("只有固定间隔可以指定间隔和 anchor。")
145
+ if self.kind == "once":
146
+ if not isinstance(self.at, str):
147
+ raise ScheduleError("一次性时间必须是带偏移的 ISO 时间。")
148
+ try:
149
+ moment = datetime.fromisoformat(self.at)
150
+ except (ValueError, TypeError) as error:
151
+ raise ScheduleError("一次性时间必须是带偏移的 ISO 时间。") from error
152
+ if moment.tzinfo is None or moment.second or moment.microsecond or self.weekdays:
153
+ raise ScheduleError("一次性时间须明确偏移,精度为分钟。")
154
+ try:
155
+ datetime.fromtimestamp(moment.timestamp(), UTC)
156
+ datetime.fromtimestamp(moment.timestamp(), ZoneInfo(self.timezone))
157
+ except (ValueError, OverflowError, OSError) as error:
158
+ raise ScheduleError("一次性时间超出支持的日期范围。") from error
159
+ object.__setattr__(self, "at", moment.isoformat(timespec="minutes"))
160
+ return
161
+ if not isinstance(self.at, str) or not re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", self.at):
162
+ raise ScheduleError("每天/每周时间格式为 HH:mm。")
163
+ if self.kind == "weekly":
164
+ if not self.weekdays or any(type(day) is not int or not 0 <= day <= 6 for day in self.weekdays):
165
+ raise ScheduleError("每周需要非空星期集合,周一为 0、周日为 6。")
166
+ object.__setattr__(self, "weekdays", tuple(sorted(set(self.weekdays))))
167
+ elif self.weekdays:
168
+ raise ScheduleError("只有每周规则可以指定星期。")
169
+
170
+ @classmethod
171
+ def from_dict(cls, value: dict[str, Any]) -> ScheduleRule:
172
+ if not isinstance(value, dict) or set(value) - {"kind", "timezone", "at", "weekdays", "every_minutes", "anchor", "end_at"}:
173
+ raise ScheduleError("时间规则包含不支持的字段。")
174
+ fields = dict(value)
175
+ if "weekdays" in fields:
176
+ if not isinstance(fields["weekdays"], (list, tuple)):
177
+ raise ScheduleError("weekdays 必须是星期列表。")
178
+ fields["weekdays"] = tuple(fields["weekdays"])
179
+ try:
180
+ return cls(**fields)
181
+ except TypeError as error:
182
+ raise ScheduleError("时间规则缺少类型或时区。") from error
183
+
184
+ def to_dict(self) -> dict[str, Any]:
185
+ result: dict[str, Any] = {"kind": self.kind, "timezone": self.timezone}
186
+ if self.at is not None:
187
+ result["at"] = self.at
188
+ if self.kind == "weekly":
189
+ result["weekdays"] = list(self.weekdays)
190
+ if self.kind == "interval":
191
+ result.update(every_minutes=self.every_minutes, anchor=self.anchor)
192
+ if self.end_at is not None:
193
+ result["end_at"] = self.end_at
194
+ return result
195
+
196
+ def _local_occurrence(self, date: datetime) -> float | None:
197
+ if self.kind == "weekly" and date.weekday() not in self.weekdays:
198
+ return None
199
+ hour, minute = map(int, (self.at or "").split(":"))
200
+ local = date.replace(hour=hour, minute=minute, second=0, microsecond=0, fold=0)
201
+ stamp = local.timestamp()
202
+ roundtrip = datetime.fromtimestamp(stamp, ZoneInfo(self.timezone))
203
+ # A nonexistent wall time normalizes to a different clock reading.
204
+ if roundtrip.replace(tzinfo=None) != local.replace(tzinfo=None):
205
+ return None
206
+ return stamp
207
+
208
+ def next_after(self, after: float) -> float | None:
209
+ """Return the first occurrence strictly after an absolute UTC boundary."""
210
+ try:
211
+ end = datetime.fromisoformat(self.end_at).timestamp() if self.end_at is not None else None
212
+ if end is not None and after >= end:
213
+ return None
214
+ result = self._next_after(after)
215
+ if result is not None and end is not None and result > end:
216
+ return None
217
+ if result is not None:
218
+ # Both projections are part of the common preview contract.
219
+ datetime.fromtimestamp(result, UTC)
220
+ datetime.fromtimestamp(result, ZoneInfo(self.timezone))
221
+ return result
222
+ except ScheduleError:
223
+ raise
224
+ except (TypeError, ValueError, OverflowError, OSError) as error:
225
+ raise ScheduleError("计划时间超出支持的日期范围。") from error
226
+
227
+ def _next_after(self, after: float) -> float | None:
228
+ if not math.isfinite(after):
229
+ raise ScheduleError("时间边界必须是有限 UTC 时间。")
230
+ if self.kind == "once":
231
+ stamp = datetime.fromisoformat(self.at or "").timestamp()
232
+ return stamp if stamp > after else None
233
+ if self.kind == "interval":
234
+ assert self.anchor is not None and self.every_minutes is not None
235
+ interval = self.every_minutes * 60
236
+ index = max(1, math.floor((after - self.anchor) / interval) + 1)
237
+ return self.anchor + index * interval
238
+ date = datetime.fromtimestamp(after, ZoneInfo(self.timezone)).replace(hour=0, minute=0, second=0, microsecond=0)
239
+ # At most a skipped DST day plus the next selected weekday.
240
+ for _ in range(16):
241
+ stamp = self._local_occurrence(date)
242
+ if stamp is not None and stamp > after:
243
+ return stamp
244
+ date += timedelta(days=1)
245
+ raise ScheduleError("无法计算下一次当地时间。")
246
+
247
+ def preview(self, after: float, count: int = 3) -> tuple[float, ...]:
248
+ if type(count) is not int or not 1 <= count <= 100:
249
+ raise ScheduleError("预览数量必须在 1 到 100 之间。")
250
+ result = []
251
+ for _ in range(count):
252
+ following = self.next_after(after)
253
+ if following is None:
254
+ break
255
+ result.append(following)
256
+ after = following
257
+ return tuple(result)
258
+
259
+ def through(self, first: float, until: float) -> tuple[float, int]:
260
+ """Last occurrence and count in [first, until], without minute iteration."""
261
+ if self.end_at is not None:
262
+ until = min(until, datetime.fromisoformat(self.end_at).timestamp())
263
+ if until < first:
264
+ return first, 0
265
+ if self.kind == "once":
266
+ return first, 1
267
+ if self.kind == "interval":
268
+ assert self.every_minutes is not None
269
+ count = math.floor((until - first) / (self.every_minutes * 60)) + 1
270
+ return first + (count - 1) * self.every_minutes * 60, count
271
+ last, count = first, 1
272
+ while (following := self.next_after(last)) is not None and following <= until:
273
+ last, count = following, count + 1
274
+ return last, count
275
+
276
+
277
+ @dataclass(frozen=True, slots=True)
278
+ class Plan:
279
+ id: str
280
+ revision: int
281
+ name: str
282
+ instructions: str
283
+ project_alias: str
284
+ app_id: str
285
+ chat_id: str
286
+ schedule: ScheduleRule | None
287
+ enabled: bool
288
+ next_due_at: float | None
289
+ processed_through: float | None
290
+ created_at: float
291
+ updated_at: float
292
+ source: str
293
+ deleted: bool = False
294
+ session_settings: SessionSettings | None = SessionSettings()
295
+ target_kind: str = "new_topic"
296
+ target_binding_id: str | None = None
297
+
298
+
299
+ @dataclass(frozen=True, slots=True)
300
+ class PlanLifecycle:
301
+ ended: bool
302
+ has_future: bool
303
+ has_trigger: bool
304
+
305
+ @property
306
+ def can_toggle(self) -> bool:
307
+ return self.has_trigger
308
+
309
+
310
+ def _trigger_opportunities(
311
+ rule: ScheduleRule | None, *, enabled: bool, next_due_at: float | None,
312
+ processed_through: float | None, now: float,
313
+ ) -> tuple[bool, bool]:
314
+ if rule is None:
315
+ return False, False
316
+ boundary = max(now, processed_through) if processed_through is not None else now
317
+ has_future = rule.next_after(boundary) is not None
318
+ # A due point remains claimable through the scheduler's inclusive 60-second
319
+ # grace period. Pausing removes that opportunity, and a clock rollback must
320
+ # never revive a point already covered by the processed high-water mark.
321
+ claimable_due = (
322
+ enabled and next_due_at is not None
323
+ and (processed_through is None or next_due_at > processed_through)
324
+ and (rule.end_at is None or next_due_at <= datetime.fromisoformat(rule.end_at).timestamp())
325
+ and 0 <= now - next_due_at <= 60
326
+ )
327
+ return has_future, has_future or claimable_due
328
+
329
+
330
+ def has_trigger_opportunity(
331
+ rule: ScheduleRule | None, *, enabled: bool, next_due_at: float | None,
332
+ processed_through: float | None, now: float,
333
+ ) -> bool:
334
+ """Whether the current rule has a future or still-claimable occurrence."""
335
+ return _trigger_opportunities(
336
+ rule, enabled=enabled, next_due_at=next_due_at,
337
+ processed_through=processed_through, now=now,
338
+ )[1]
339
+
340
+
341
+ def plan_lifecycle(plan: Plan, *, now: float, has_pending: bool) -> PlanLifecycle:
342
+ """Project lifecycle separately from enablement and native execution state."""
343
+ has_future, has_trigger = _trigger_opportunities(
344
+ plan.schedule, enabled=plan.enabled, next_due_at=plan.next_due_at,
345
+ processed_through=plan.processed_through, now=now,
346
+ )
347
+ return PlanLifecycle(
348
+ ended=not has_trigger and not has_pending,
349
+ has_future=has_future, has_trigger=has_trigger,
350
+ )
351
+
352
+
353
+ @dataclass(frozen=True, slots=True)
354
+ class Run:
355
+ id: str
356
+ plan_id: str
357
+ plan_revision: int
358
+ due_at: float
359
+ project_alias: str
360
+ app_id: str
361
+ chat_id: str
362
+ phase: str
363
+ barrier: str
364
+ root_uuid: str
365
+ seed_uuid: str
366
+ root_message_id: str | None
367
+ topic_id: str | None
368
+ origin_message_id: str | None
369
+ binding_id: str | None
370
+ initial_turn_id: str | None
371
+ error_code: str | None
372
+ delivery_state: str | None
373
+ created_at: float
374
+ updated_at: float
375
+ missed_from: float | None = None
376
+ missed_count: int = 0
377
+ binding_removed: bool = False
378
+ trigger_source: str = "scheduled"
379
+ target_kind: str = "new_topic"
380
+ disposition: str | None = None
381
+
382
+
383
+ @dataclass(frozen=True, slots=True)
384
+ class Claim:
385
+ run: Run
386
+ plan: Plan
387
+
388
+
389
+ @dataclass(frozen=True, slots=True)
390
+ class MutationResult:
391
+ plan_id: str
392
+ revision: int
393
+ replayed: bool = False
394
+ run_id: str | None = None