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,213 @@
1
+ """Current-chat session defaults form; state travels on the real card."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import re
8
+ from collections.abc import Mapping, Sequence
9
+ from typing import Any
10
+ from uuid import uuid4
11
+
12
+ from lark_channel import OutboundCard
13
+
14
+ from ..domain import FeishuScope, ScopeKind
15
+ from ..model_settings import ModelCatalog
16
+ from ..projects import Project
17
+ from ..session_settings import SessionSettings
18
+ from .callbacks import CardActionError, _builder, _notice, _plain, _plain_text
19
+ from .controls import decode_session_settings_form, session_settings_form_elements
20
+
21
+
22
+ _VERSION = 1
23
+ _FORM_PREFIX = "defaults_project_v1__"
24
+ _SESSION_PREFIX = "defaults_session"
25
+ _STATE_FIELDS = {"v", "scope", "chat_kind", "id", "expected_revision"}
26
+ _PROJECT_ALIAS = re.compile(r"[a-z0-9][a-z0-9_-]{0,63}")
27
+ _RECORD_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}")
28
+ _NONCE = re.compile(r"[0-9a-f]{32}")
29
+
30
+
31
+ def _encoded(value: Mapping[str, Any]) -> str:
32
+ return base64.urlsafe_b64encode(
33
+ json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode()
34
+ ).decode().rstrip("=")
35
+
36
+
37
+ def _decoded(value: Any) -> dict[str, Any]:
38
+ if not isinstance(value, str) or not value or len(value) > 8192:
39
+ raise CardActionError("请选择默认会话使用的 Project。")
40
+ try:
41
+ result = json.loads(base64.b64decode(
42
+ value + "=" * (-len(value) % 4), altchars=b"-_", validate=True,
43
+ ))
44
+ except (ValueError, UnicodeError) as error:
45
+ raise CardActionError("默认会话配置选项无效,请重新发送 /defaults。") from error
46
+ if not isinstance(result, dict):
47
+ raise CardActionError("默认会话配置选项无效,请重新发送 /defaults。")
48
+ return result
49
+
50
+
51
+ def _validate_state(scope: FeishuScope, state: Mapping[str, Any]) -> None:
52
+ if type(state.get("v")) is not int or state["v"] != _VERSION:
53
+ raise CardActionError("默认会话配置卡片已过期,请重新发送 /defaults。")
54
+ if state.get("scope") != scope.key:
55
+ raise CardActionError("默认会话配置卡片与原消息位置不一致。")
56
+ if not isinstance(state.get("chat_kind"), str) or state["chat_kind"] not in {"group", "p2p"}:
57
+ raise CardActionError("默认会话配置缺少聊天类型,请重新发送 /defaults。")
58
+ if scope.kind is ScopeKind.DIRECT and state["chat_kind"] != "p2p":
59
+ raise CardActionError("默认会话配置卡片与聊天类型不一致。")
60
+ if scope.kind is ScopeKind.GROUP and state["chat_kind"] != "group":
61
+ raise CardActionError("默认会话配置卡片与聊天类型不一致。")
62
+ record_id, revision = state.get("id"), state.get("expected_revision")
63
+ if record_id is None and revision is None:
64
+ return
65
+ if (not isinstance(record_id, str) or _RECORD_ID.fullmatch(record_id) is None
66
+ or type(revision) is not int or revision < 1):
67
+ raise CardActionError("默认会话配置版本无效,请重新发送 /defaults。")
68
+
69
+
70
+ def defaults_card(
71
+ scope: FeishuScope,
72
+ view: Mapping[str, Any],
73
+ projects: Sequence[Project],
74
+ catalog: ModelCatalog | None,
75
+ notice: str | None = None,
76
+ notice_is_error: bool = False,
77
+ ) -> OutboundCard:
78
+ """One editable form for the enclosing chat, including from topic scopes."""
79
+ exact = view.get("exact")
80
+ effective = exact if exact is not None else view.get("effective")
81
+ state = {
82
+ "v": _VERSION, "scope": scope.key, "chat_kind": view.get("chat_kind"),
83
+ "id": exact["id"] if exact is not None else None,
84
+ "expected_revision": exact["revision"] if exact is not None else None,
85
+ }
86
+ _validate_state(scope, state)
87
+ if effective is None:
88
+ settings = SessionSettings.new_defaults(catalog)
89
+ selected_project = None
90
+ else:
91
+ raw_settings = effective["session_settings"]
92
+ settings = raw_settings if isinstance(raw_settings, SessionSettings) else SessionSettings.from_dict(raw_settings)
93
+ selected_project = effective["project"]
94
+
95
+ builder = _builder("默认会话配置", "当前聊天")
96
+ kind_label = "单聊" if state["chat_kind"] == "p2p" else "群聊"
97
+ builder.raw(_plain(f"作用于当前{kind_label}:{scope.chat_id}(包含其中的话题)。"))
98
+ if exact is not None:
99
+ builder.raw(_plain("当前使用本聊天的精确配置;修改后保存,仅影响之后自动创建的会话。"))
100
+ elif effective is not None:
101
+ builder.raw(_plain(f"当前使用群名包含「{effective['keyword']}」的规则;保存后将成为本聊天的精确配置。"))
102
+ else:
103
+ builder.raw(_plain("尚未配置默认会话,请选择 Project 后保存。"))
104
+ if view.get("match_error"):
105
+ builder.raw(_notice(str(view["match_error"]), error=True))
106
+ if notice:
107
+ builder.raw(_notice(notice, error=notice_is_error))
108
+
109
+ enabled_projects = [project for project in projects if project.enabled]
110
+ project_options: list[dict[str, Any]] = []
111
+ selected_value = None
112
+ for project in enabled_projects:
113
+ option = _encoded({**state, "project": project.alias, "project_revision": project.revision})
114
+ project_options.append({"text": _plain_text(f"{project.alias} · {project.cwd}"), "value": option})
115
+ if project.alias == selected_project:
116
+ selected_value = option
117
+ if selected_project is not None and selected_value is None:
118
+ selected_value = _encoded({**state, "project": selected_project, "project_revision": None})
119
+ project_options.append({"text": _plain_text(f"{selected_project} · 不可用(保留原配置)"), "value": selected_value})
120
+ builder.raw(_notice(f"Project「{selected_project}」已停用或不可用;请选择可用项目后保存,或删除本聊天的默认配置。", error=True))
121
+ if not enabled_projects:
122
+ builder.raw(_notice("没有可用 Project,请先通过 /settings 登记或启用项目。"))
123
+ if not project_options:
124
+ # Feishu cannot render an empty static select. There is no saved exact
125
+ # record to delete in this branch; opening again after /settings gives
126
+ # the ordinary single form without inventing a default Project.
127
+ return OutboundCard(card=builder.to_dict())
128
+
129
+ # Each redraw gives form submissions a fresh SDK dedup identity. This is
130
+ # transport state, not a persisted card session or a management revision.
131
+ project_select: dict[str, Any] = {
132
+ "tag": "select_static", "name": _FORM_PREFIX + uuid4().hex,
133
+ "required": True, "placeholder": _plain_text("选择 Project"),
134
+ "options": project_options,
135
+ }
136
+ if selected_value is not None:
137
+ project_select["initial_option"] = selected_value
138
+ builder.raw({"tag": "form", "name": "defaults_v1", "elements": [
139
+ _plain("Project"), project_select,
140
+ *session_settings_form_elements(
141
+ prefix=_SESSION_PREFIX, settings=settings, catalog=catalog,
142
+ allow_context_mode=state["chat_kind"] == "group",
143
+ ),
144
+ {"tag": "button", "name": "defaults_save", "text": _plain_text("保存默认配置"),
145
+ "type": "primary_filled", "width": "fill", "form_action_type": "submit"},
146
+ ]})
147
+ if exact is not None:
148
+ builder.raw({
149
+ "tag": "button", "text": _plain_text("删除默认配置"), "type": "danger", "width": "fill",
150
+ "behaviors": [{"type": "callback", "value": {
151
+ "kind": "netizen_defaults", "action": "delete", "nonce": uuid4().hex, **state,
152
+ }}],
153
+ })
154
+ builder.raw(_plain("删除后仍可能使用匹配的群名规则;已有会话不受影响。"))
155
+ return OutboundCard(card=builder.to_dict())
156
+
157
+
158
+ def is_defaults_card_action(value: Any, form: Any = None) -> bool:
159
+ return (isinstance(value, Mapping) and value.get("kind") == "netizen_defaults") or (
160
+ isinstance(form, Mapping)
161
+ and any(isinstance(name, str) and name.startswith(_FORM_PREFIX) for name in form)
162
+ )
163
+
164
+
165
+ def _request(scope: FeishuScope, state: Mapping[str, Any], mode: str) -> dict[str, Any]:
166
+ result = {
167
+ "mode": mode, "kind": "chat", "chat_id": scope.chat_id,
168
+ "expected_revision": state["expected_revision"],
169
+ }
170
+ if state["id"] is not None:
171
+ result["id"] = state["id"]
172
+ return result
173
+
174
+
175
+ def decode_defaults_action(scope: FeishuScope, value: Any, form: Any = None) -> dict[str, Any]:
176
+ """Decode strict form values; the caller verifies the real card location."""
177
+ if form is not None and form != {}:
178
+ if value is not None and value != {}:
179
+ raise CardActionError("默认会话配置表单混入其他操作。")
180
+ if not isinstance(form, Mapping):
181
+ raise CardActionError("默认会话配置表单无效。")
182
+ names = [name for name in form if isinstance(name, str) and name.startswith(_FORM_PREFIX)]
183
+ if len(names) != 1 or _NONCE.fullmatch(names[0][len(_FORM_PREFIX):]) is None:
184
+ raise CardActionError("默认会话配置表单身份无效,请重新发送 /defaults。")
185
+ state = _decoded(form[names[0]])
186
+ if set(state) != _STATE_FIELDS | {"project", "project_revision"}:
187
+ raise CardActionError("默认会话配置选项字段无效。")
188
+ _validate_state(scope, state)
189
+ project = state["project"]
190
+ if not isinstance(project, str) or _PROJECT_ALIAS.fullmatch(project) is None:
191
+ raise CardActionError("默认会话配置 Project 无效。")
192
+ revision = state["project_revision"]
193
+ if revision is not None and (type(revision) is not int or revision < 1):
194
+ raise CardActionError("默认会话配置 Project 版本无效。")
195
+ fields = {key: item for key, item in form.items() if key != names[0]}
196
+ if state["chat_kind"] == "p2p" and f"{_SESSION_PREFIX}_context_mode" in fields:
197
+ raise CardActionError("单聊不支持补充历史上下文。")
198
+ if state["chat_kind"] == "group" and f"{_SESSION_PREFIX}_context_mode" not in fields:
199
+ raise CardActionError("默认会话配置表单缺少上下文设置。")
200
+ settings = decode_session_settings_form(fields, prefix=_SESSION_PREFIX)
201
+ return {
202
+ **_request(scope, state, "save"), "project": project,
203
+ "expected_project_revision": revision, "session_settings": settings.to_dict(),
204
+ }
205
+ if not isinstance(value, Mapping) or set(value) != _STATE_FIELDS | {"kind", "action", "nonce"}:
206
+ raise CardActionError("默认会话配置动作无效,请重新发送 /defaults。")
207
+ if (value["kind"] != "netizen_defaults" or value["action"] != "delete"
208
+ or not isinstance(value["nonce"], str) or _NONCE.fullmatch(value["nonce"]) is None):
209
+ raise CardActionError("默认会话配置动作无效,请重新发送 /defaults。")
210
+ _validate_state(scope, value)
211
+ if value["id"] is None:
212
+ raise CardActionError("当前聊天没有可删除的精确配置。")
213
+ return _request(scope, value, "delete")
@@ -0,0 +1,80 @@
1
+ """Read-only model catalog information, with no selection callbacks or state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timezone
7
+ from typing import Any
8
+
9
+ from ..model_settings import ModelCatalog, ModelOption
10
+ from .callbacks import _plain, _plain_text
11
+ from .reply import TURN_FILE_CARD_JSON_LIMIT_BYTES
12
+
13
+
14
+ def _text(value: object, limit: int = 600) -> str:
15
+ if not isinstance(value, str):
16
+ return ""
17
+ value = value.strip()
18
+ return value if len(value) <= limit else value[:limit] + "…(已省略)"
19
+
20
+
21
+ def _model_description(model: ModelOption) -> str:
22
+ lines = [_text(model.description) or "模型目录未提供简介。"]
23
+ if model.input_modalities:
24
+ labels = {"text": "文字", "image": "图片", "audio": "音频"}
25
+ lines.append("支持输入:" + "、".join(
26
+ labels.get(item, _text(item, 40)) for item in model.input_modalities
27
+ ))
28
+ info = model.upgrade_info
29
+ upgrade = _text(info.model if info is not None else model.upgrade, 120)
30
+ if upgrade:
31
+ lines.append("建议迁移至:" + upgrade + "(不会自动切换)")
32
+ if info is not None:
33
+ if copy := _text(info.upgrade_copy):
34
+ lines.append(copy)
35
+ if isinstance(info.retirement_at, int) and not isinstance(info.retirement_at, bool):
36
+ try:
37
+ retirement = datetime.fromtimestamp(info.retirement_at, timezone.utc)
38
+ except (ValueError, OverflowError, OSError):
39
+ pass
40
+ else:
41
+ lines.append("目录提示的退役时间:" + retirement.strftime("%Y-%m-%d %H:%M UTC"))
42
+ if migration := _text(info.migration_markdown, 1000):
43
+ lines.append("迁移说明:" + migration)
44
+ if link := _text(info.model_link, 500):
45
+ lines.append("详情:" + link)
46
+ # Catalog prose is plain text: HTML, Markdown and mention-like strings
47
+ # must never become interactive card markup or callback payloads.
48
+ return "\n\n".join(lines)
49
+
50
+
51
+ def with_model_details(card: dict[str, Any], catalog: ModelCatalog | None) -> dict[str, Any]:
52
+ """Append bounded optional information without changing the original form."""
53
+ if catalog is None or not catalog.models:
54
+ return card
55
+ note = _plain("以下为打开卡片时的模型目录说明;展开对应模型查阅,不会改变选择或保存配置。")
56
+ panel: dict[str, Any] = {
57
+ "tag": "collapsible_panel", "expanded": False,
58
+ "header": {"title": _plain_text("模型介绍与迁移提示")},
59
+ "elements": [note],
60
+ }
61
+ card["body"]["elements"].append(panel)
62
+ shown = 0
63
+ for model in catalog.models[:40]:
64
+ entry = {
65
+ "tag": "collapsible_panel", "expanded": False,
66
+ "header": {"title": _plain_text(_text(model.display_name, 100))},
67
+ "elements": [_plain(_model_description(model))],
68
+ }
69
+ panel["elements"].append(entry)
70
+ # Reserve space for the omission notice. Presentation cannot prevent
71
+ # users from receiving or submitting the original model picker.
72
+ if len(json.dumps(card, ensure_ascii=False).encode("utf-8")) > TURN_FILE_CARD_JSON_LIMIT_BYTES - 600:
73
+ panel["elements"].pop()
74
+ break
75
+ shown += 1
76
+ if shown < len(catalog.models):
77
+ panel["elements"].append(_plain("卡片空间有限,部分模型介绍未展示;模型选项和保存逻辑不受影响。"))
78
+ if len(json.dumps(card, ensure_ascii=False).encode("utf-8")) > TURN_FILE_CARD_JSON_LIMIT_BYTES:
79
+ card["body"]["elements"].pop()
80
+ return card
@@ -0,0 +1,220 @@
1
+ """Self-contained question forms; callback answers use ordinary input admission."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+ from uuid import uuid4
10
+
11
+ from lark_channel import OutboundCard
12
+
13
+ from ..user_questions import (
14
+ BindingQuestionTarget,
15
+ QuestionRequest,
16
+ QuestionTarget,
17
+ SideQuestionTarget,
18
+ UserQuestion,
19
+ format_question_answer,
20
+ question_target_payload,
21
+ )
22
+ from .callbacks import CardActionError, _builder, _notice, _plain, _plain_text
23
+ from .reply import TURN_FILE_CARD_JSON_LIMIT_BYTES
24
+
25
+
26
+ _KIND = "netizen_question"
27
+ _VERSION = 2
28
+ _CHOICE = "netizen_question_choice"
29
+ _TEXT = "netizen_question_text"
30
+ _MAX_ANSWER_CHARS = 1000
31
+ QUESTION_CARD_JSON_LIMIT_BYTES = TURN_FILE_CARD_JSON_LIMIT_BYTES
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class QuestionCardContext:
36
+ target: QuestionTarget
37
+ item_id: str
38
+ question_index: int
39
+ question: UserQuestion
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class QuestionAnswer(QuestionCardContext):
44
+ answer: str
45
+ choice: str
46
+
47
+ @property
48
+ def prompt(self) -> str:
49
+ return format_question_answer(
50
+ self.item_id, self.question_index, self.question.title, self.answer
51
+ )
52
+
53
+
54
+ def is_question_card_action(value: Any, form_value: Any = None) -> bool:
55
+ return (
56
+ isinstance(value, Mapping) and value.get("kind") == _KIND
57
+ ) or (
58
+ isinstance(form_value, Mapping)
59
+ and bool({_CHOICE, _TEXT} & form_value.keys())
60
+ )
61
+
62
+
63
+ def _payload(context: QuestionCardContext) -> dict[str, Any]:
64
+ return {
65
+ "kind": _KIND,
66
+ "v": _VERSION,
67
+ "target": question_target_payload(context.target),
68
+ "item_id": context.item_id,
69
+ "question_index": context.question_index,
70
+ "title": context.question.title,
71
+ "options": list(context.question.options),
72
+ "nonce": uuid4().hex,
73
+ }
74
+
75
+
76
+ def _target(payload: Mapping[str, Any]) -> QuestionTarget:
77
+ try:
78
+ if payload["v"] == 1:
79
+ return BindingQuestionTarget(payload["binding_id"])
80
+ target = payload["target"]
81
+ if isinstance(target, Mapping) and set(target) == {"kind", "id"}:
82
+ if target["kind"] == "binding":
83
+ return BindingQuestionTarget(target["id"])
84
+ if target["kind"] == "side":
85
+ return SideQuestionTarget(target["id"])
86
+ except (TypeError, ValueError):
87
+ pass
88
+ raise CardActionError("问题卡片的会话目标无效。")
89
+
90
+
91
+ def _decoded(payload: Any) -> dict[str, Any]:
92
+ if not isinstance(payload, Mapping):
93
+ raise CardActionError("问题卡片字段不完整或含未知字段。")
94
+ payload = dict(payload)
95
+ payload.pop("nonce", None) # Transport dedup only, never a business precondition.
96
+ version = payload.get("v")
97
+ if payload.get("kind") != _KIND or type(version) is not int or version not in {1, _VERSION}:
98
+ raise CardActionError("问题卡片版本无效,请在会话中直接回答。")
99
+ fields = {"kind", "v", "item_id", "question_index", "title", "options"}
100
+ fields.add("binding_id" if version == 1 else "target")
101
+ if set(payload) != fields:
102
+ raise CardActionError("问题卡片字段不完整或含未知字段。")
103
+ _target(payload)
104
+ item_id = payload["item_id"]
105
+ index = payload["question_index"]
106
+ title = payload["title"]
107
+ options = payload["options"]
108
+ if (
109
+ not isinstance(item_id, str)
110
+ or not item_id.strip()
111
+ or len(item_id) > 2048
112
+ or type(index) is not int
113
+ or not 0 <= index < 1000
114
+ or not isinstance(title, str)
115
+ or not title.strip()
116
+ or not isinstance(options, list)
117
+ or len(options) > 99
118
+ or any(not isinstance(option, str) or not option.strip() for option in options)
119
+ ):
120
+ raise CardActionError("问题卡片的会话、题目或选项无效。")
121
+ result = dict(payload)
122
+ if len(json.dumps(result, ensure_ascii=False).encode("utf-8")) > QUESTION_CARD_JSON_LIMIT_BYTES:
123
+ raise CardActionError("问题内容较长,无法展示完整选择卡片;请在会话中直接回答。")
124
+ return result
125
+
126
+
127
+ def _context(payload: Mapping[str, Any]) -> QuestionCardContext:
128
+ return QuestionCardContext(
129
+ target=_target(payload),
130
+ item_id=payload["item_id"],
131
+ question_index=payload["question_index"],
132
+ question=UserQuestion(payload["title"], tuple(payload["options"])),
133
+ )
134
+
135
+
136
+ def decode_question_context(value: Any) -> QuestionCardContext:
137
+ """Recover retry context even when the submitted answer is empty."""
138
+
139
+ return _context(_decoded(value))
140
+
141
+
142
+ def decode_question_answer(value: Any, form_value: Any = None) -> QuestionAnswer:
143
+ payload = _decoded(value)
144
+ if not isinstance(form_value, Mapping) or set(form_value) - {_CHOICE, _TEXT}:
145
+ raise CardActionError("问题表单字段不匹配,请重新打开卡片。")
146
+ choice = form_value.get(_CHOICE, "free")
147
+ if not isinstance(choice, str) or choice not in {"free", *(str(index) for index in range(len(payload["options"])))}:
148
+ raise CardActionError("问题选项无效,请重新选择。")
149
+ if choice == "free":
150
+ answer = form_value.get(_TEXT) or ""
151
+ if not isinstance(answer, str) or len(answer) > _MAX_ANSWER_CHARS:
152
+ raise CardActionError("填写的回答无效或过长,请在会话中直接发送。")
153
+ if not answer.strip():
154
+ raise CardActionError("请选择一个建议,或选择自行填写并输入回答,然后点击提交。")
155
+ else:
156
+ answer = payload["options"][int(choice)]
157
+ context = _context(payload)
158
+ return QuestionAnswer(
159
+ context.target, context.item_id, context.question_index,
160
+ context.question, answer, choice,
161
+ )
162
+
163
+
164
+ def render_question_card(
165
+ target: QuestionTarget,
166
+ request: QuestionRequest,
167
+ question_index: int,
168
+ *,
169
+ notice: str | None = None,
170
+ ) -> OutboundCard:
171
+ if type(question_index) is not int or not 0 <= question_index < len(request.questions):
172
+ raise CardActionError("问题序号无效。")
173
+ return render_question_context_card(
174
+ QuestionCardContext(target, request.item_id, question_index, request.questions[question_index]),
175
+ notice=notice,
176
+ )
177
+
178
+
179
+ def render_question_context_card(
180
+ context: QuestionCardContext,
181
+ *,
182
+ notice: str | None = None,
183
+ ) -> OutboundCard:
184
+ payload = _payload(context)
185
+ _decoded(payload)
186
+ builder = _builder("Codex 提问", "选择建议或自行填写,再提交回答")
187
+ if notice:
188
+ builder.raw(_notice(notice))
189
+ builder.raw(_plain(context.question.title))
190
+ for index, option in enumerate(context.question.options):
191
+ builder.raw(_plain(f"{index + 1}. {option}"))
192
+ options = [{"text": _plain_text("自行填写"), "value": "free"}]
193
+ options.extend(
194
+ {"text": _plain_text(f"选项 {index + 1}"), "value": str(index)}
195
+ for index in range(len(context.question.options))
196
+ )
197
+ initial_choice = context.choice if isinstance(context, QuestionAnswer) else "free"
198
+ initial_text = context.answer if isinstance(context, QuestionAnswer) and context.choice == "free" else ""
199
+ builder.raw({
200
+ "tag": "form",
201
+ "name": "netizen_question",
202
+ "elements": [
203
+ {"tag": "select_static", "name": _CHOICE, "required": True,
204
+ "width": "fill", "options": options, "initial_option": initial_choice,
205
+ "placeholder": _plain_text("选择建议或自行填写")},
206
+ {"tag": "input", "name": _TEXT, "required": False,
207
+ "input_type": "multiline_text", "max_length": _MAX_ANSWER_CHARS,
208
+ "width": "fill", "placeholder": _plain_text("仅选择“自行填写”时提交此处内容"),
209
+ "default_value": initial_text},
210
+ {"tag": "button", "name": "netizen_question_submit", "text": _plain_text("提交回答"),
211
+ "type": "primary_filled", "width": "fill", "form_action_type": "submit",
212
+ "behaviors": [{"type": "callback", "value": payload}]},
213
+ ],
214
+ })
215
+ builder.raw(_plain("提交后请查看聊天中的处理反馈;如需重发,请在原会话直接发送回答。"))
216
+ card = builder.to_dict()
217
+ card["config"]["width_mode"] = "compact"
218
+ if len(json.dumps(card, ensure_ascii=False).encode("utf-8")) > QUESTION_CARD_JSON_LIMIT_BYTES:
219
+ raise CardActionError("问题内容较长,无法展示完整选择卡片;请在会话中直接回答。")
220
+ return OutboundCard(card=card)