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,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,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]
|