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,565 @@
1
+ """Shared callback envelopes, references, validation, and card primitives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import re
8
+ import secrets
9
+ from collections.abc import Mapping
10
+ from typing import Any
11
+
12
+ from lark_channel import new_card
13
+
14
+ from ..domain import (
15
+ CardControlName,
16
+ FeishuScope,
17
+ MentionContextMode,
18
+ SettingsSection,
19
+ ScopeKind,
20
+ TurnFileActionName,
21
+ )
22
+ from ..projects import Project
23
+ from ..sdk_gap_adapter import GoalSnapshot
24
+
25
+
26
+ ACTION_VERSION = 4
27
+ TURN_FILE_ACTION_VERSION = 4
28
+ REPLY_CARD_ACTION_VERSION = 5
29
+ MAX_MODEL_ID_CHARS = 256
30
+ MAX_ENCODED_MODEL_ID_CHARS = 1368
31
+ _INHERIT_MODEL_CHOICE = "inherit"
32
+ _PROJECT_REFERENCE = re.compile(
33
+ r"project:v1:([a-z0-9][a-z0-9_-]{0,63}):([1-9][0-9]*)"
34
+ )
35
+ _BINDING_REFERENCE = re.compile(r"binding:v1:([A-Za-z0-9][A-Za-z0-9-]{0,127})")
36
+ _NATIVE_THREAD_REFERENCE = re.compile(
37
+ r"native-thread:v1:([A-Za-z0-9][A-Za-z0-9._-]{0,191})"
38
+ )
39
+ _NEW_MODEL_REFERENCE = re.compile(
40
+ r"new-model:v1:(inherit|explicit:([A-Za-z0-9_-]+))"
41
+ )
42
+ _CONFIG_MODEL_REFERENCE = re.compile(
43
+ r"config-model:v4:([A-Za-z0-9][A-Za-z0-9-]{0,127}):"
44
+ r"([1-9][0-9]{0,18}):([1-9][0-9]{0,18}):"
45
+ r"([1-9][0-9]{0,18}):(inherit|explicit:([A-Za-z0-9_-]+))"
46
+ )
47
+ _CONTEXT_MODE_REFERENCE = re.compile(
48
+ r"context-mode:v1:(current-only|catch-up)"
49
+ )
50
+ _TASK_FEEDBACK_REFERENCE = re.compile(r"task-feedback:v2:(off|on)")
51
+ _CALLBACK_NONCE = re.compile(r"[0-9a-f]{32}")
52
+ _PROJECT_MODE_REFERENCE = re.compile(
53
+ r"project-mode:v2:(create|existing):([0-9a-f]{32})"
54
+ )
55
+ _SIDE_REFERENCE = re.compile(r"side:v1:([A-Za-z0-9][A-Za-z0-9-]{0,127})")
56
+ _TURN_REFERENCE = re.compile(
57
+ r"turn:v1:([A-Za-z0-9][A-Za-z0-9._-]{0,191})"
58
+ )
59
+ _GOAL_GENERATION = re.compile(r"[A-Za-z0-9_-]{43}")
60
+ _REPEATABLE_CARD_CONTROL_NAMES = frozenset(
61
+ {
62
+ CardControlName.OPEN_SETTINGS_SECTION,
63
+ CardControlName.REFRESH_SETTINGS,
64
+ CardControlName.PREPARE_EXACT_DELETE_BINDING,
65
+ CardControlName.PREPARE_ARCHIVED_DELETE_BINDING,
66
+ CardControlName.ACTIVATE_BINDING,
67
+ CardControlName.RECHECK_EXACT_TURN,
68
+ CardControlName.SESSIONS_PAGE,
69
+ CardControlName.REFRESH_ARCHIVED_SESSIONS,
70
+ CardControlName.GOAL_PAUSE,
71
+ CardControlName.GOAL_RESUME,
72
+ CardControlName.SIDE_CLOSE,
73
+ }
74
+ )
75
+ # These actions can legitimately reappear with the same semantic payload on
76
+ # one updated message. Revision-bearing and one-shot actions stay excluded.
77
+ _REPEATABLE_CALLBACK_INTENTS = frozenset(
78
+ name.value for name in _REPEATABLE_CARD_CONTROL_NAMES
79
+ ) | {TurnFileActionName.PAGE.value}
80
+
81
+
82
+ class CardActionError(ValueError):
83
+ pass
84
+
85
+
86
+ class TurnFileCardLimitError(CardActionError):
87
+ pass
88
+
89
+
90
+ class SettingsCardActionError(CardActionError):
91
+ def __init__(
92
+ self,
93
+ message: str,
94
+ *,
95
+ scope: FeishuScope,
96
+ section: SettingsSection,
97
+ ) -> None:
98
+ super().__init__(message)
99
+ self.scope = scope
100
+ self.section = section
101
+
102
+
103
+ def goal_generation(snapshot: GoalSnapshot) -> str:
104
+ """Return the strongest stable native Goal fingerprint the SDK exposes."""
105
+
106
+ return _goal_generation(snapshot)
107
+
108
+
109
+ def _goal_generation(snapshot: GoalSnapshot) -> str:
110
+ if not snapshot.thread_id or isinstance(snapshot.created_at, bool):
111
+ raise ValueError("Goal generation requires a native identity and creation time")
112
+ material = (
113
+ "netizen-goal-generation:v2\0"
114
+ f"{snapshot.thread_id}\0{snapshot.created_at}\0"
115
+ f"{snapshot.objective}\0{snapshot.token_budget}"
116
+ ).encode("utf-8")
117
+ return base64.urlsafe_b64encode(hashlib.sha256(material).digest()).decode(
118
+ "ascii"
119
+ ).rstrip("=")
120
+
121
+
122
+ def _decode_goal_generation(value: Any) -> str:
123
+ generation = _required_string(value, "goal_generation")
124
+ if _GOAL_GENERATION.fullmatch(generation) is None:
125
+ raise CardActionError("Goal generation 无效或已过期。")
126
+ return generation
127
+
128
+
129
+ def scope_from_fetched_card(
130
+ *,
131
+ app_id: str,
132
+ callback_chat_id: str,
133
+ fetched_message: Any,
134
+ chat_type: str | None,
135
+ ) -> FeishuScope:
136
+ item = _fetched_card_item(
137
+ callback_chat_id=callback_chat_id,
138
+ fetched_message=fetched_message,
139
+ )
140
+ raw_topic_id = item.get("thread_id")
141
+ if isinstance(raw_topic_id, str) and raw_topic_id:
142
+ return FeishuScope(
143
+ app_id,
144
+ callback_chat_id,
145
+ ScopeKind.TOPIC,
146
+ raw_topic_id,
147
+ )
148
+ if chat_type == "p2p":
149
+ kind = ScopeKind.DIRECT
150
+ elif chat_type == "group":
151
+ kind = ScopeKind.GROUP
152
+ else:
153
+ raise CardActionError("无法判断卡片来自单聊还是群聊。")
154
+ return FeishuScope(app_id, callback_chat_id, kind)
155
+
156
+
157
+ def fetched_card_topic_id(
158
+ *,
159
+ callback_chat_id: str,
160
+ fetched_message: Any,
161
+ ) -> str | None:
162
+ """Return the public thread id without requiring an unrelated chat lookup."""
163
+ item = _fetched_card_item(
164
+ callback_chat_id=callback_chat_id,
165
+ fetched_message=fetched_message,
166
+ )
167
+ raw_topic_id = item.get("thread_id")
168
+ return raw_topic_id if isinstance(raw_topic_id, str) and raw_topic_id else None
169
+
170
+
171
+ def _fetched_card_item(
172
+ *,
173
+ callback_chat_id: str,
174
+ fetched_message: Any,
175
+ ) -> Mapping[str, Any]:
176
+ if not isinstance(fetched_message, Mapping):
177
+ raise CardActionError("无法读取卡片原消息。")
178
+ data = fetched_message.get("data")
179
+ if not isinstance(data, Mapping):
180
+ raise CardActionError("卡片原消息缺少 data。")
181
+ items = data.get("items")
182
+ if not isinstance(items, list) or not items or not isinstance(items[0], Mapping):
183
+ raise CardActionError("卡片原消息缺少 items。")
184
+ item = items[0]
185
+ if item.get("chat_id") != callback_chat_id:
186
+ raise CardActionError("卡片原消息与回调聊天不一致。")
187
+ return item
188
+
189
+
190
+ def _scope_from_envelope(
191
+ *,
192
+ app_id: str,
193
+ chat_id: str,
194
+ kind: Any,
195
+ topic_id: Any,
196
+ ) -> FeishuScope:
197
+ try:
198
+ scope_kind = ScopeKind(kind)
199
+ except (TypeError, ValueError) as error:
200
+ raise CardActionError("未知 Scope kind。") from error
201
+ if scope_kind is ScopeKind.TOPIC:
202
+ topic = _required_string(topic_id, "topic_id")
203
+ else:
204
+ if topic_id is not None:
205
+ raise CardActionError("非话题 Scope 不能携带 topic_id。")
206
+ topic = None
207
+ return FeishuScope(app_id, chat_id, scope_kind, topic)
208
+
209
+
210
+ def _envelope(
211
+ scope: FeishuScope,
212
+ name: CardControlName,
213
+ **extra: Any,
214
+ ) -> dict[str, Any]:
215
+ value = {
216
+ "v": ACTION_VERSION,
217
+ "intent": name.value,
218
+ "chat_id": scope.chat_id,
219
+ "scope_kind": scope.kind.value,
220
+ **extra,
221
+ }
222
+ if scope.kind is ScopeKind.TOPIC:
223
+ value["topic_id"] = scope.topic_id
224
+ return value
225
+
226
+
227
+ def _project_reference(project: Project) -> str:
228
+ return f"project:v1:{project.alias}:{project.revision}"
229
+
230
+
231
+ def _decode_project_reference(value: str) -> tuple[str, int]:
232
+ match = _PROJECT_REFERENCE.fullmatch(value)
233
+ if match is None:
234
+ raise CardActionError("Project 选择值无效,请刷新卡片后重试。")
235
+ return match.group(1), int(match.group(2))
236
+
237
+
238
+ def _project_mode_reference(mode: str, callback_nonce: str) -> str:
239
+ value = f"project-mode:v2:{mode}:{callback_nonce}"
240
+ if _PROJECT_MODE_REFERENCE.fullmatch(value) is None:
241
+ raise ValueError("invalid Project mode callback reference")
242
+ return value
243
+
244
+
245
+ def _decode_project_mode_reference(value: str) -> str:
246
+ if value in {"create", "existing"}:
247
+ return value
248
+ prefix = "project-mode:v2:"
249
+ if value.startswith(prefix):
250
+ mode = value[len(prefix) :].split(":", 1)[0]
251
+ if mode in {"create", "existing"}:
252
+ return mode
253
+ raise CardActionError("未知 Project 创建模式。")
254
+
255
+
256
+ def _binding_reference(binding_id: str) -> str:
257
+ return f"binding:v1:{binding_id}"
258
+
259
+
260
+ def _decode_binding_reference(value: str) -> str:
261
+ match = _BINDING_REFERENCE.fullmatch(value)
262
+ if match is None:
263
+ raise CardActionError("会话引用无效,请重新打开原卡片。")
264
+ return match.group(1)
265
+
266
+
267
+ def _native_thread_reference(thread_id: str) -> str:
268
+ value = f"native-thread:v1:{thread_id}"
269
+ if _NATIVE_THREAD_REFERENCE.fullmatch(value) is None:
270
+ raise ValueError("invalid native Thread reference")
271
+ return value
272
+
273
+
274
+ def _decode_native_thread_reference(value: str) -> str:
275
+ match = _NATIVE_THREAD_REFERENCE.fullmatch(value)
276
+ if match is None:
277
+ raise CardActionError("原生会话引用无效,请重新发送 /delete。")
278
+ return match.group(1)
279
+
280
+
281
+ def _side_reference(side_id: str) -> str:
282
+ value = f"side:v1:{side_id}"
283
+ if _SIDE_REFERENCE.fullmatch(value) is None:
284
+ raise ValueError("invalid Side reference")
285
+ return value
286
+
287
+
288
+ def _decode_side_reference(value: str) -> str:
289
+ match = _SIDE_REFERENCE.fullmatch(value)
290
+ if match is None:
291
+ raise CardActionError("Side 引用无效,请回到原 Side 话题重试。")
292
+ return match.group(1)
293
+
294
+
295
+ def _turn_reference(turn_id: str) -> str:
296
+ value = f"turn:v1:{turn_id}"
297
+ if _TURN_REFERENCE.fullmatch(value) is None:
298
+ raise ValueError("invalid native Turn reference")
299
+ return value
300
+
301
+
302
+ def _decode_turn_reference(value: str) -> str:
303
+ match = _TURN_REFERENCE.fullmatch(value)
304
+ if match is None:
305
+ raise CardActionError("原生 Turn 引用无效,请重新执行任务。")
306
+ return match.group(1)
307
+
308
+
309
+ def _rename_name_field(binding_id: str) -> str:
310
+ if _BINDING_REFERENCE.fullmatch(_binding_reference(binding_id)) is None:
311
+ raise ValueError("invalid Binding rename reference")
312
+ return f"rename_name_v1__{binding_id}"
313
+
314
+
315
+ def _new_model_reference(model_id: str | None) -> str:
316
+ choice = _encode_model_choice(model_id)
317
+ value = f"new-model:v1:{choice}"
318
+ if _NEW_MODEL_REFERENCE.fullmatch(value) is None:
319
+ raise ValueError("invalid new Binding model reference")
320
+ return value
321
+
322
+
323
+ def _decode_new_model_reference(value: str) -> str | None:
324
+ match = _NEW_MODEL_REFERENCE.fullmatch(value)
325
+ if match is None:
326
+ raise CardActionError("新建会话卡片已过期,请重新发送 /new。")
327
+ return _decode_model_choice(match.group(1), match.group(2), command="/new")
328
+
329
+
330
+ def _config_model_reference(
331
+ *,
332
+ binding_id: str,
333
+ settings_revision: int,
334
+ context_revision: int,
335
+ feedback_revision: int,
336
+ model_id: str | None,
337
+ ) -> str:
338
+ choice = _encode_model_choice(model_id)
339
+ value = (
340
+ f"config-model:v4:{binding_id}:{settings_revision}:"
341
+ f"{context_revision}:{feedback_revision}:{choice}"
342
+ )
343
+ if _CONFIG_MODEL_REFERENCE.fullmatch(value) is None:
344
+ raise ValueError("invalid Binding model settings reference")
345
+ return value
346
+
347
+
348
+ def _decode_config_model_reference(
349
+ value: str,
350
+ ) -> tuple[str, int, int, int, str | None]:
351
+ match = _CONFIG_MODEL_REFERENCE.fullmatch(value)
352
+ if match is None:
353
+ raise CardActionError("会话配置卡片已过期,请重新发送 /config。")
354
+ model_id = _decode_model_choice(
355
+ match.group(5),
356
+ match.group(6),
357
+ command="/config",
358
+ )
359
+ return (
360
+ match.group(1),
361
+ int(match.group(2)),
362
+ int(match.group(3)),
363
+ int(match.group(4)),
364
+ model_id,
365
+ )
366
+
367
+
368
+ def _encode_model_choice(model_id: str | None) -> str:
369
+ if model_id is None:
370
+ return _INHERIT_MODEL_CHOICE
371
+ if (
372
+ not isinstance(model_id, str)
373
+ or not model_id
374
+ or len(model_id) > MAX_MODEL_ID_CHARS
375
+ or "\x00" in model_id
376
+ ):
377
+ raise ValueError("invalid model ID")
378
+ encoded_model = base64.urlsafe_b64encode(model_id.encode("utf-8")).decode(
379
+ "ascii"
380
+ ).rstrip("=")
381
+ return f"explicit:{encoded_model}"
382
+
383
+
384
+ def _decode_model_choice(
385
+ choice: str,
386
+ encoded_model: str | None,
387
+ *,
388
+ command: str,
389
+ ) -> str | None:
390
+ if choice == _INHERIT_MODEL_CHOICE:
391
+ if encoded_model is not None:
392
+ raise CardActionError(
393
+ f"会话配置卡片已过期,请重新发送 {command}。"
394
+ )
395
+ return None
396
+ if encoded_model is None:
397
+ raise CardActionError(
398
+ f"会话配置卡片已过期,请重新发送 {command}。"
399
+ )
400
+ if len(encoded_model) > MAX_ENCODED_MODEL_ID_CHARS:
401
+ raise CardActionError(
402
+ f"会话配置卡片已过期,请重新发送 {command}。"
403
+ )
404
+ padding = "=" * (-len(encoded_model) % 4)
405
+ try:
406
+ model_id = base64.b64decode(
407
+ encoded_model + padding,
408
+ altchars=b"-_",
409
+ validate=True,
410
+ ).decode("utf-8")
411
+ except (UnicodeDecodeError, ValueError) as error:
412
+ raise CardActionError(
413
+ f"会话配置卡片已过期,请重新发送 {command}。"
414
+ ) from error
415
+ if (
416
+ not model_id
417
+ or len(model_id) > MAX_MODEL_ID_CHARS
418
+ or "\x00" in model_id
419
+ ):
420
+ raise CardActionError(
421
+ f"会话配置卡片已过期,请重新发送 {command}。"
422
+ )
423
+ return model_id
424
+
425
+
426
+ def _context_mode_reference(mode: MentionContextMode) -> str:
427
+ return f"context-mode:v1:{mode.value}"
428
+
429
+
430
+ def _decode_context_mode_reference(value: str) -> MentionContextMode:
431
+ match = _CONTEXT_MODE_REFERENCE.fullmatch(value)
432
+ if match is None:
433
+ raise CardActionError("消息范围选择已过期,请重新打开卡片。")
434
+ return MentionContextMode(match.group(1))
435
+
436
+
437
+ def _task_feedback_reference(enabled: bool) -> str:
438
+ return f"task-feedback:v2:{'on' if enabled else 'off'}"
439
+
440
+
441
+ def _decode_task_feedback_reference(value: Any, field: str) -> bool:
442
+ match = _TASK_FEEDBACK_REFERENCE.fullmatch(_required_string(value, field))
443
+ if match is None:
444
+ raise CardActionError("任务反馈选择已过期,请重新打开卡片。")
445
+ return match.group(1) == "on"
446
+
447
+
448
+ def _builder(title: str, subtitle: str, *, template: str = "blue"):
449
+ return (
450
+ new_card()
451
+ .config(update_multi=True, width_mode="default")
452
+ .header(title, subtitle=subtitle, template=template)
453
+ )
454
+
455
+
456
+ def _button_row(*buttons: dict[str, Any]) -> dict[str, Any]:
457
+ return {
458
+ "tag": "column_set",
459
+ "flex_mode": "none",
460
+ "columns": [
461
+ {
462
+ "tag": "column",
463
+ "width": "weighted",
464
+ "weight": 1,
465
+ "elements": [button],
466
+ }
467
+ for button in buttons
468
+ ],
469
+ }
470
+
471
+
472
+ def _new_callback_nonce() -> str:
473
+ return secrets.token_hex(16)
474
+
475
+
476
+ def _valid_callback_nonce(value: Any) -> bool:
477
+ return isinstance(value, str) and _CALLBACK_NONCE.fullmatch(value) is not None
478
+
479
+
480
+ def _repeatable_callback_button(
481
+ *,
482
+ label: str,
483
+ value: dict[str, Any],
484
+ style: str = "default",
485
+ confirm: tuple[str, str] | None = None,
486
+ ) -> dict[str, Any]:
487
+ if "nonce" in value:
488
+ raise ValueError("callback value already contains a nonce")
489
+ return _callback_button(
490
+ label=label,
491
+ value={**value, "nonce": _new_callback_nonce()},
492
+ style=style,
493
+ confirm=confirm,
494
+ )
495
+
496
+
497
+ def _callback_button(
498
+ *,
499
+ label: str,
500
+ value: dict[str, Any],
501
+ style: str = "default",
502
+ confirm: tuple[str, str] | None = None,
503
+ ) -> dict[str, Any]:
504
+ intent = value.get("intent")
505
+ is_repeatable = intent in _REPEATABLE_CALLBACK_INTENTS
506
+ has_valid_nonce = _valid_callback_nonce(value.get("nonce"))
507
+ if (is_repeatable and not has_valid_nonce) or (
508
+ not is_repeatable and "nonce" in value
509
+ ):
510
+ raise ValueError(
511
+ "repeatable callback values must carry exactly one valid nonce"
512
+ )
513
+ button: dict[str, Any] = {
514
+ "tag": "button",
515
+ "text": _plain_text(label),
516
+ "type": style,
517
+ "behaviors": [{"type": "callback", "value": value}],
518
+ }
519
+ if confirm is not None:
520
+ button["confirm"] = {
521
+ "title": _plain_text(confirm[0]),
522
+ "text": _plain_text(confirm[1]),
523
+ }
524
+ return button
525
+
526
+
527
+ def _notice(message: str, *, error: bool = False) -> dict[str, Any]:
528
+ return {
529
+ "tag": "column_set",
530
+ "background_style": "red-50" if error else "blue-50",
531
+ "columns": [
532
+ {
533
+ "tag": "column",
534
+ "width": "weighted",
535
+ "weight": 1,
536
+ "padding": "8px",
537
+ "elements": [_plain(message)],
538
+ }
539
+ ],
540
+ }
541
+
542
+
543
+ def _plain(content: str) -> dict[str, Any]:
544
+ return {"tag": "div", "text": _plain_text(content)}
545
+
546
+
547
+ def _plain_text(content: str) -> dict[str, str]:
548
+ return {"tag": "plain_text", "content": content}
549
+
550
+
551
+ def _required_string(value: Any, field: str) -> str:
552
+ if not isinstance(value, str) or not value:
553
+ raise CardActionError(f"{field} 必须是非空字符串。")
554
+ return value
555
+
556
+
557
+ def _bounded_string(value: Any, field: str, *, max_chars: int) -> str:
558
+ result = _required_string(value, field)
559
+ if len(result) > max_chars or "\x00" in result:
560
+ raise CardActionError(f"{field} 内容无效或超过 {max_chars} 个字符。")
561
+ return result
562
+
563
+
564
+ def _md_code(value: str) -> str:
565
+ return value.replace("`", "ˋ")