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,16 @@
1
+ """Validate the single Feishu user targeted by a completion mention."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+
8
+ _OPEN_ID = re.compile(r"ou_[A-Za-z0-9_]{1,125}")
9
+
10
+
11
+ def valid_completion_mention_user_id(value: object) -> str | None:
12
+ """Return an inert open_id, excluding broadcast IDs and markup injection."""
13
+
14
+ if isinstance(value, str) and _OPEN_ID.fullmatch(value) is not None:
15
+ return value
16
+ return None
@@ -0,0 +1,218 @@
1
+ """Forward Channel database migrations from the v14 baseline.
2
+
3
+ The actual-start preparation boundary owns the lifetime lock and optional backup;
4
+ the business Runtime remains current-schema-only. Published steps and validators
5
+ are immutable and must not call the current schema creator or manage transactions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sqlite3
11
+ from collections.abc import Callable
12
+ from contextlib import closing
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import TypedDict
16
+
17
+ from .bindings import SCHEMA_VERSION
18
+ from .migrations.v14 import validate as validate_v14
19
+
20
+
21
+ MIN_SUPPORTED_SCHEMA_VERSION = 14
22
+ Validator = Callable[[sqlite3.Connection], None]
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Migration:
27
+ from_version: int
28
+ to_version: int
29
+ apply: Callable[[sqlite3.Connection], None]
30
+ validate_target: Validator
31
+
32
+
33
+ # Add an adjacent step only when the persisted schema actually changes. The
34
+ # baseline release deliberately has no artificial v15 migration.
35
+ MIGRATIONS: tuple[Migration, ...] = ()
36
+
37
+
38
+ class MigrationStep(TypedDict):
39
+ from_version: int
40
+ to_version: int
41
+
42
+
43
+ class MigrationPlan(TypedDict):
44
+ source_version: int
45
+ target_version: int
46
+ steps: list[MigrationStep]
47
+
48
+
49
+ def _registered() -> tuple[dict[int, Migration], dict[int, Validator]]:
50
+ steps: dict[int, Migration] = {}
51
+ validators = {MIN_SUPPORTED_SCHEMA_VERSION: validate_v14}
52
+ for step in MIGRATIONS:
53
+ if (
54
+ type(step.from_version) is not int or type(step.to_version) is not int
55
+ or step.from_version < MIN_SUPPORTED_SCHEMA_VERSION
56
+ or step.to_version != step.from_version + 1
57
+ or step.to_version > SCHEMA_VERSION
58
+ or step.from_version in steps
59
+ or not all(callable(callback) for callback in (step.apply, step.validate_target))
60
+ ):
61
+ raise RuntimeError("invalid or ambiguous Channel database migration registry")
62
+ steps[step.from_version] = step
63
+ validators[step.to_version] = step.validate_target
64
+ return steps, validators
65
+
66
+
67
+ def _path(source: int) -> tuple[list[Migration], Validator]:
68
+ if source < MIN_SUPPORTED_SCHEMA_VERSION:
69
+ raise RuntimeError(
70
+ f"unsupported Channel database schema v{source}; automatic upgrades start at "
71
+ f"v{MIN_SUPPORTED_SCHEMA_VERSION}; existing data was not changed"
72
+ )
73
+ if source > SCHEMA_VERSION:
74
+ raise RuntimeError(
75
+ f"Channel database schema v{source} is newer than this release (v{SCHEMA_VERSION}); "
76
+ "downgrades are not supported; existing data was not changed"
77
+ )
78
+ steps, validators = _registered()
79
+ if source not in validators:
80
+ raise RuntimeError(f"unknown Channel database schema v{source}; existing data was not changed")
81
+ path = []
82
+ version = source
83
+ while version < SCHEMA_VERSION:
84
+ step = steps.get(version)
85
+ if step is None:
86
+ raise RuntimeError(f"missing Channel database migration from v{version}; existing data was not changed")
87
+ path.append(step)
88
+ version = step.to_version
89
+ return path, validators[source]
90
+
91
+
92
+ def _database_uri(path: str | Path, mode: str) -> str:
93
+ database = Path(path)
94
+ if database.is_symlink() or not database.is_file():
95
+ raise RuntimeError("Channel database must be an existing regular file")
96
+ return database.resolve().as_uri() + f"?mode={mode}"
97
+
98
+
99
+ def _version(connection: sqlite3.Connection) -> int:
100
+ rows = connection.execute("SELECT version FROM schema_version").fetchall()
101
+ if len(rows) != 1 or type(rows[0][0]) is not int:
102
+ raise RuntimeError("Channel database must contain exactly one integer schema version")
103
+ return rows[0][0]
104
+
105
+
106
+ def _integrity(connection: sqlite3.Connection) -> None:
107
+ if [row[0] for row in connection.execute("PRAGMA integrity_check")] != ["ok"]:
108
+ raise RuntimeError("Channel database integrity check failed")
109
+ if connection.execute("PRAGMA foreign_key_check").fetchone() is not None:
110
+ raise RuntimeError("Channel database foreign-key check failed")
111
+
112
+
113
+ def _plan(connection: sqlite3.Connection) -> tuple[MigrationPlan, list[Migration]]:
114
+ source = _version(connection)
115
+ steps, validate = _path(source)
116
+ _validate(connection, validate)
117
+ _integrity(connection)
118
+ return {
119
+ "source_version": source,
120
+ "target_version": SCHEMA_VERSION,
121
+ "steps": [{"from_version": step.from_version, "to_version": step.to_version} for step in steps],
122
+ }, steps
123
+
124
+
125
+ def plan_channel_database(path: str | Path) -> MigrationPlan:
126
+ """Read-only validation and complete-path preflight; never create or repair a DB."""
127
+ try:
128
+ with closing(sqlite3.connect(_database_uri(path, "ro"), uri=True, isolation_level=None, timeout=0.25)) as connection:
129
+ connection.row_factory = sqlite3.Row
130
+ connection.execute("PRAGMA query_only = ON")
131
+ connection.execute("BEGIN")
132
+ plan, _ = _plan(connection)
133
+ return plan
134
+ except sqlite3.Error as error:
135
+ raise RuntimeError(f"Channel database migration preflight failed: {error}") from error
136
+
137
+
138
+ def _migration_authorizer(action: int, first: str | None, _second: str | None,
139
+ _database: str | None, _trigger: str | None) -> int:
140
+ # executescript() implicitly COMMITs an open transaction, even with no COMMIT
141
+ # in the script. SQLite's authorizer blocks it before it can escape rollback.
142
+ if action in {
143
+ sqlite3.SQLITE_TRANSACTION, sqlite3.SQLITE_SAVEPOINT,
144
+ sqlite3.SQLITE_ATTACH, sqlite3.SQLITE_DETACH,
145
+ }:
146
+ return sqlite3.SQLITE_DENY
147
+ if action == sqlite3.SQLITE_PRAGMA and first not in {
148
+ "quick_check", "integrity_check", "foreign_key_check", "table_info", "table_xinfo",
149
+ "index_list", "index_info", "index_xinfo", "foreign_key_list",
150
+ }:
151
+ # SQLite itself uses quick_check when ALTER TABLE adds a CHECK constraint.
152
+ return sqlite3.SQLITE_DENY
153
+ return sqlite3.SQLITE_OK
154
+
155
+
156
+ def _validation_authorizer(action: int, first: str | None, second: str | None,
157
+ database: str | None, trigger: str | None) -> int:
158
+ if action not in {
159
+ sqlite3.SQLITE_SELECT, sqlite3.SQLITE_READ, sqlite3.SQLITE_FUNCTION,
160
+ sqlite3.SQLITE_RECURSIVE, sqlite3.SQLITE_PRAGMA,
161
+ }:
162
+ return sqlite3.SQLITE_DENY
163
+ return _migration_authorizer(action, first, second, database, trigger)
164
+
165
+
166
+ def _validate(connection: sqlite3.Connection, validate: Validator) -> None:
167
+ connection.set_authorizer(_validation_authorizer)
168
+ try:
169
+ validate(connection)
170
+ finally:
171
+ connection.set_authorizer(None)
172
+
173
+
174
+ def migrate_channel_database(path: str | Path, *, expected_source_version: int) -> MigrationPlan:
175
+ """Apply the entire pending path atomically under the caller's lifetime lock.
176
+
177
+ A source-version mismatch fails before opening a writer. The version is also
178
+ checked under BEGIN IMMEDIATE, so a preflight is never treated as a lock.
179
+ Migration callbacks are trusted release code; they must not replace the
180
+ connection authorizer or open independent connections.
181
+ """
182
+ plan = plan_channel_database(path)
183
+ if type(expected_source_version) is not int or plan["source_version"] != expected_source_version:
184
+ raise RuntimeError("Channel database source version changed since migration preflight")
185
+ if not plan["steps"]:
186
+ return plan
187
+ try:
188
+ with closing(sqlite3.connect(_database_uri(path, "rw"), uri=True, isolation_level=None, timeout=0.25)) as connection:
189
+ connection.row_factory = sqlite3.Row
190
+ connection.execute("PRAGMA foreign_keys = ON")
191
+ connection.execute("PRAGMA synchronous = FULL")
192
+ connection.execute("PRAGMA busy_timeout = 250")
193
+ connection.execute("BEGIN IMMEDIATE")
194
+ try:
195
+ locked_plan, steps = _plan(connection)
196
+ if locked_plan != plan:
197
+ raise RuntimeError("Channel database source version changed since migration preflight")
198
+ for step in steps:
199
+ # The initial source was validated by _plan under this
200
+ # transaction; each later source is the preceding target.
201
+ connection.set_authorizer(_migration_authorizer)
202
+ try:
203
+ step.apply(connection)
204
+ finally:
205
+ connection.set_authorizer(None)
206
+ if not connection.in_transaction or _version(connection) != step.from_version:
207
+ raise RuntimeError("migration changed its transaction or schema version")
208
+ connection.execute("UPDATE schema_version SET version = ?", (step.to_version,))
209
+ _validate(connection, step.validate_target)
210
+ _integrity(connection)
211
+ connection.execute("COMMIT")
212
+ except BaseException:
213
+ if connection.in_transaction:
214
+ connection.execute("ROLLBACK")
215
+ raise
216
+ except sqlite3.Error as error:
217
+ raise RuntimeError(f"Channel database migration failed: {error}") from error
218
+ return plan
@@ -0,0 +1,5 @@
1
+ """Chat-scoped defaults for automatically creating ordinary sessions."""
2
+
3
+ from .models import DefaultConfigurationError, DefaultRule
4
+
5
+ __all__ = ["DefaultConfigurationError", "DefaultRule"]
@@ -0,0 +1,39 @@
1
+ """Persisted creation choices; never effective native configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from ..session_settings import SessionSettings
9
+
10
+
11
+ class DefaultConfigurationError(ValueError):
12
+ def __init__(self, message: str, *, code: str = "invalid_defaults") -> None:
13
+ super().__init__(message)
14
+ self.code = code
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class DefaultRule:
19
+ id: str
20
+ app_id: str
21
+ kind: str
22
+ chat_id: str | None
23
+ keyword: str | None
24
+ project: str
25
+ session_settings: SessionSettings
26
+ revision: int
27
+ position: int | None
28
+
29
+ @property
30
+ def settings(self) -> SessionSettings:
31
+ return self.session_settings
32
+
33
+ def to_dict(self) -> dict[str, Any]:
34
+ return {
35
+ "id": self.id, "kind": self.kind, "chat_id": self.chat_id,
36
+ "keyword": self.keyword, "project": self.project,
37
+ "session_settings": self.session_settings.to_dict(),
38
+ "revision": self.revision, "position": self.position,
39
+ }
@@ -0,0 +1,232 @@
1
+ """Shared default-rule management and first-match resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any
7
+
8
+ from ..bindings import BindingQueryBusy, BindingQueryClosed, BindingQueryTimeout, BindingStore
9
+ from ..channel.messages import public_chat_kind
10
+ from ..domain import MentionContextMode
11
+ from ..model_settings import ModelCatalogError, STANDARD_SERVICE_TIER_ID
12
+ from ..projects import Project, ProjectError, ProjectRegistry
13
+ from ..session_settings import SessionSettings, SessionSettingsError
14
+ from .models import DefaultConfigurationError, DefaultRule
15
+ from .store import MAX_GROUP_RULES
16
+
17
+
18
+ _FIELDS = {
19
+ "view": {"chat_id"},
20
+ "list": {"kind", "offset", "limit"},
21
+ "save": {"kind", "chat_id", "keyword", "id", "expected_revision", "project", "expected_project_revision", "session_settings"},
22
+ "delete": {"id", "expected_revision", "chat_id", "kind"},
23
+ "reorder": {"rule_ids", "order_revision"},
24
+ "options": {"chat_id"},
25
+ }
26
+
27
+
28
+ class SessionDefaultsService:
29
+ def __init__(
30
+ self, *, bindings: BindingStore, projects: ProjectRegistry,
31
+ runtime: Any, app_id: str, chat_info: Any = None, blocking_io: Any = None,
32
+ ) -> None:
33
+ self.app_id = app_id
34
+ self._store = bindings.defaults
35
+ self._projects = projects
36
+ self._runtime = runtime
37
+ self._chat_info = chat_info
38
+ self._blocking_io = blocking_io
39
+
40
+ async def _chat(self, chat_id: str) -> tuple[str, Any]:
41
+ if self._chat_info is None:
42
+ raise DefaultConfigurationError("聊天信息服务暂不可用。", code="chat_unavailable")
43
+ try:
44
+ async with asyncio.timeout(5):
45
+ info = await self._chat_info.get_chat_info(chat_id)
46
+ except Exception as error:
47
+ raise DefaultConfigurationError("无法读取聊天信息,请检查 chat_id 和机器人是否可访问该聊天。", code="chat_unavailable") from error
48
+ kind = public_chat_kind(info)
49
+ if kind is None:
50
+ raise DefaultConfigurationError("无法确定聊天类型,请稍后重试。", code="chat_kind_unknown")
51
+ return kind, info
52
+
53
+ @staticmethod
54
+ def _match(rules: tuple[DefaultRule, ...], info: Any) -> DefaultRule | None:
55
+ name = getattr(info, "name", None)
56
+ if not isinstance(name, str) or not name.strip():
57
+ raise DefaultConfigurationError("无法读取群名称,不能判断默认配置匹配结果。", code="chat_name_unavailable")
58
+ folded = name.casefold()
59
+ return next((rule for rule in rules if rule.keyword is not None and rule.keyword.casefold() in folded), None)
60
+
61
+ async def resolve(self, chat_id: str, chat_kind: str | None = None) -> DefaultRule | None:
62
+ """Select one saved rule, without checking its Project or native model."""
63
+ chat_id = _text(chat_id, "chat_id")
64
+ exact = self._store.exact(self.app_id, chat_id)
65
+ if exact is not None:
66
+ return exact
67
+ if chat_kind == "p2p":
68
+ return None
69
+ rules = self._store.group_rules(self.app_id)
70
+ if not rules:
71
+ return None
72
+ kind, info = await self._chat(chat_id)
73
+ return self._match(rules, info) if kind == "group" else None
74
+
75
+ async def validate(self, rule: DefaultRule, *, expected_project_revision: int | None = None) -> Project:
76
+ """Validate at use time; no Project revision or effective config is saved."""
77
+ if rule.app_id != self.app_id:
78
+ raise DefaultConfigurationError("当前应用下没有这个默认配置。", code="not_found")
79
+ try:
80
+ if self._blocking_io is None:
81
+ project = self._projects.resolve_for_new(rule.project, expected_revision=expected_project_revision)
82
+ else:
83
+ project = await self._blocking_io.submit(
84
+ self._projects.resolve_for_new, rule.project,
85
+ expected_revision=expected_project_revision,
86
+ deadline=asyncio.get_running_loop().time() + 5,
87
+ )
88
+ except ProjectError as error:
89
+ raise DefaultConfigurationError(f"默认配置的 Project {rule.project} 不可用:{error}", code="project_unavailable") from error
90
+ except Exception as error:
91
+ raise DefaultConfigurationError("暂时无法校验默认配置的 Project,请稍后重试。", code="project_unavailable") from error
92
+ if rule.session_settings.turn_settings is None:
93
+ return project
94
+ try:
95
+ async with asyncio.timeout(5):
96
+ catalog = await self._runtime.model_catalog()
97
+ rule.session_settings.validate_catalog(catalog)
98
+ except ModelCatalogError as error:
99
+ raise DefaultConfigurationError(f"默认配置的模型、思考强度或速度不可用:{error}", code="invalid_model_settings") from error
100
+ except Exception as error:
101
+ raise DefaultConfigurationError("暂时无法校验默认配置的模型目录,请稍后重试。", code="model_catalog_unavailable") from error
102
+ return project
103
+
104
+ async def manage(self, request: dict[str, Any]) -> dict[str, Any]:
105
+ if not isinstance(request, dict):
106
+ raise DefaultConfigurationError("默认配置管理参数必须是对象。")
107
+ mode = request.get("mode")
108
+ if not isinstance(mode, str) or mode not in _FIELDS or set(request) - _FIELDS[mode] - {"mode"}:
109
+ raise DefaultConfigurationError("默认配置管理操作或参数无效。")
110
+ if mode == "view":
111
+ return await self._view(_text(request.get("chat_id"), "chat_id"))
112
+ if mode == "options":
113
+ chat = request.get("chat_id")
114
+ return await self._options(_text(chat, "chat_id") if chat is not None else None)
115
+ if mode == "list":
116
+ kind = _kind(request.get("kind"))
117
+ offset = _integer(request.get("offset", 0), "offset", minimum=0, maximum=2**31 - 1)
118
+ limit = _integer(request.get("limit", 50), "limit", maximum=MAX_GROUP_RULES if kind == "group_name" else 50)
119
+ try:
120
+ async with asyncio.timeout(3):
121
+ return await self._store.list(self.app_id, kind, offset=offset, limit=limit)
122
+ except (BindingQueryBusy, BindingQueryClosed, BindingQueryTimeout, TimeoutError) as error:
123
+ raise DefaultConfigurationError("默认配置列表暂不可用,请稍后刷新。", code="unavailable") from error
124
+ if mode == "save":
125
+ return {"rule": (await self._save(request)).to_dict()}
126
+ if mode == "delete":
127
+ rule_id = _text(request.get("id"), "id")
128
+ if "chat_id" in request or "kind" in request:
129
+ rule = self._store.get(self.app_id, rule_id)
130
+ if "kind" in request and rule.kind != _kind(request["kind"]):
131
+ raise DefaultConfigurationError("默认配置类型不匹配。", code="not_found")
132
+ if "chat_id" in request and (rule.kind != "chat" or rule.chat_id != _text(request["chat_id"], "chat_id")):
133
+ raise DefaultConfigurationError("这份默认配置不属于当前聊天。", code="not_found")
134
+ self._store.delete(self.app_id, rule_id, _integer(request.get("expected_revision"), "expected_revision"))
135
+ return {"deleted": rule_id}
136
+ ids = request.get("rule_ids")
137
+ if not isinstance(ids, list) or len(ids) > MAX_GROUP_RULES or not all(isinstance(item, str) and item for item in ids):
138
+ raise DefaultConfigurationError("rule_ids 必须为全部群名规则 ID 的有序数组。")
139
+ revision = self._store.reorder(self.app_id, ids, _integer(request.get("order_revision"), "order_revision"))
140
+ return {"order_revision": revision}
141
+
142
+ async def _view(self, chat_id: str) -> dict[str, Any]:
143
+ exact = self._store.exact(self.app_id, chat_id)
144
+ kind, info = await self._chat(chat_id)
145
+ effective = exact
146
+ error = None
147
+ if effective is None and kind == "group":
148
+ rules = self._store.group_rules(self.app_id)
149
+ if rules:
150
+ try:
151
+ effective = self._match(rules, info)
152
+ except DefaultConfigurationError as failure:
153
+ error = str(failure)
154
+ return {"exact": exact.to_dict() if exact else None,
155
+ "effective": effective.to_dict() if effective else None,
156
+ "chat_kind": kind, "match_error": error}
157
+
158
+ async def _save(self, request: dict[str, Any]) -> DefaultRule:
159
+ kind = _kind(request.get("kind"))
160
+ chat_id = _text(request.get("chat_id"), "chat_id") if kind == "chat" else None
161
+ keyword = _text(request.get("keyword"), "keyword", maximum=200) if kind == "group_name" else None
162
+ if (kind == "chat" and request.get("keyword") is not None) or (kind == "group_name" and request.get("chat_id") is not None):
163
+ raise DefaultConfigurationError("精确聊天配置和群名规则的条件不能混用。")
164
+ if "expected_revision" not in request:
165
+ raise DefaultConfigurationError("新增配置需明确 expected_revision 为 null;修改需当前 revision。")
166
+ expected = request["expected_revision"]
167
+ if expected is not None:
168
+ expected = _integer(expected, "expected_revision")
169
+ rule_id = _text(request["id"], "id") if request.get("id") is not None else None
170
+ try:
171
+ settings = SessionSettings.from_dict(request.get("session_settings"))
172
+ except SessionSettingsError as error:
173
+ raise DefaultConfigurationError(str(error)) from error
174
+ project = _text(request.get("project"), "project", maximum=64)
175
+ if chat_id is not None:
176
+ chat_kind, _ = await self._chat(chat_id)
177
+ if chat_kind == "p2p" and settings.message_context_mode is MentionContextMode.CATCH_UP:
178
+ raise DefaultConfigurationError("单聊默认配置只支持 current-only。")
179
+ candidate = DefaultRule(rule_id or "", self.app_id, kind, chat_id, keyword, project, settings, expected or 1, None)
180
+ project_revision = request.get("expected_project_revision")
181
+ if project_revision is not None:
182
+ project_revision = _integer(project_revision, "expected_project_revision")
183
+ checked_project = await self.validate(candidate, expected_project_revision=project_revision)
184
+ return self._store.save(
185
+ app_id=self.app_id, kind=kind, chat_id=chat_id, keyword=keyword,
186
+ project=project, session_settings=settings, rule_id=rule_id, expected_revision=expected,
187
+ checked_project_revision=checked_project.revision,
188
+ )
189
+
190
+ async def _options(self, chat_id: str | None) -> dict[str, Any]:
191
+ kind = (await self._chat(chat_id))[0] if chat_id is not None else "group"
192
+ catalog = None
193
+ error = None
194
+ try:
195
+ async with asyncio.timeout(5):
196
+ catalog = await self._runtime.model_catalog()
197
+ except Exception:
198
+ error = "Codex 模型目录暂不可用;可以选择继承 Codex,稍后再选择模型。"
199
+ return {"models": _models(catalog), "session_settings": SessionSettings.new_defaults(catalog).to_dict(),
200
+ "context_mode_available": kind != "p2p", "model_catalog_error": error}
201
+
202
+
203
+ def _kind(value: Any) -> str:
204
+ if not isinstance(value, str) or value not in {"chat", "group_name"}:
205
+ raise DefaultConfigurationError("kind 必须为 chat 或 group_name。")
206
+ return value
207
+
208
+
209
+ def _text(value: Any, field: str, *, maximum: int = 256) -> str:
210
+ if not isinstance(value, str) or not value.strip() or len(value.strip()) > maximum:
211
+ raise DefaultConfigurationError(f"{field} 必须是最多 {maximum} 字符的非空文本。")
212
+ return value.strip()
213
+
214
+
215
+ def _integer(value: Any, field: str, *, minimum: int = 1, maximum: int | None = None) -> int:
216
+ if type(value) is not int or value < minimum or (maximum is not None and value > maximum):
217
+ raise DefaultConfigurationError(f"{field} 超出允许范围。")
218
+ return value
219
+
220
+
221
+ def _models(catalog: Any) -> list[dict[str, Any]]:
222
+ if catalog is None:
223
+ return []
224
+ return [{
225
+ "id": model.id, "model": model.model, "display_name": model.display_name,
226
+ "description": model.description, "is_default": model.is_default,
227
+ "default_effort_id": model.default_effort_id, "default_service_tier_id": model.default_service_tier_id,
228
+ "efforts": [{"id": item.id, "description": item.description} for item in model.efforts],
229
+ "service_tiers": [{"id": STANDARD_SERVICE_TIER_ID, "name": "Standard", "description": "Codex 标准服务层"},
230
+ *[{"id": item.id, "name": item.name, "description": item.description}
231
+ for item in model.service_tiers if item.id != STANDARD_SERVICE_TIER_ID]],
232
+ } for model in catalog.models]