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,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("`", "ˋ")
|