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,281 @@
|
|
|
1
|
+
"""Bounded Admin maintenance state and its per-instance lock.
|
|
2
|
+
|
|
3
|
+
Schema 3 describes a package/environment-bound restart. Historical schemas
|
|
4
|
+
remain readable for diagnosis; they do not grant package-update authority.
|
|
5
|
+
This module deliberately uses only the standard library.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fcntl
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import re
|
|
16
|
+
import secrets
|
|
17
|
+
import stat
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
from typing import Any
|
|
21
|
+
from collections.abc import Iterator
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
OPERATION_FILE = "update.json"
|
|
25
|
+
MAX_OPERATION_BYTES = 4096
|
|
26
|
+
OPERATION_ID = re.compile(r"[0-9a-f]{32}")
|
|
27
|
+
SHA256 = re.compile(r"[0-9a-f]{64}")
|
|
28
|
+
VERSION = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)")
|
|
29
|
+
PHASES = frozenset({
|
|
30
|
+
"accepted", "downloading", "preparing", "installing", "restarting",
|
|
31
|
+
"succeeded", "failed", "rolled_back", "requires_action", "recovery_required", "recovered",
|
|
32
|
+
})
|
|
33
|
+
TERMINAL_PHASES = frozenset({
|
|
34
|
+
"succeeded", "failed", "rolled_back", "requires_action", "recovery_required", "recovered",
|
|
35
|
+
})
|
|
36
|
+
CODES = frozenset({
|
|
37
|
+
"none", "download_failed", "installer_invalid", "preparation_failed",
|
|
38
|
+
"configuration_required", "permissions_required", "activation_failed",
|
|
39
|
+
"rollback_incomplete", "installer_failed", "worker_interrupted", "lock_busy",
|
|
40
|
+
"operation_invalid", "dispatch_failed", "dispatch_unknown", "worker_lost",
|
|
41
|
+
"previous_release_changed", "profile_failed", "manual_recovery",
|
|
42
|
+
"restart_failed", "service_ready",
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class UpdateProtocolError(RuntimeError):
|
|
47
|
+
"""The deployment state cannot safely be read or changed."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _matches(pattern: re.Pattern[str], value: object) -> bool:
|
|
51
|
+
return isinstance(value, str) and pattern.fullmatch(value) is not None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def validate_target(value: object) -> dict[str, Any]:
|
|
55
|
+
if (
|
|
56
|
+
not isinstance(value, dict)
|
|
57
|
+
or set(value) != {"version", "releaseId", "installerSha256", "archiveSha256"}
|
|
58
|
+
or not _matches(VERSION, value.get("version"))
|
|
59
|
+
or type(value.get("releaseId")) is not int
|
|
60
|
+
or not 0 < value["releaseId"] < 2**63
|
|
61
|
+
or not _matches(SHA256, value.get("installerSha256"))
|
|
62
|
+
or not _matches(SHA256, value.get("archiveSha256"))
|
|
63
|
+
):
|
|
64
|
+
raise UpdateProtocolError("invalid update target")
|
|
65
|
+
return dict(value)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def validate_operation(value: object) -> dict[str, Any]:
|
|
69
|
+
if isinstance(value, dict) and value.get("schema") == 3:
|
|
70
|
+
return _validate_cli_restart(value)
|
|
71
|
+
restart = isinstance(value, dict) and value.get("schema") == 2
|
|
72
|
+
keys = {
|
|
73
|
+
"schema", "operationId", "target", "previousRelease", "phase", "code",
|
|
74
|
+
"createdAt", "updatedAt",
|
|
75
|
+
}
|
|
76
|
+
if restart:
|
|
77
|
+
keys.add("kind")
|
|
78
|
+
if (
|
|
79
|
+
not isinstance(value, dict)
|
|
80
|
+
or set(value) != keys
|
|
81
|
+
or type(value.get("schema")) is not int or value["schema"] not in {1, 2}
|
|
82
|
+
or not _matches(OPERATION_ID, value.get("operationId"))
|
|
83
|
+
or not _matches(SHA256, value.get("previousRelease"))
|
|
84
|
+
or not isinstance(value.get("phase"), str) or value["phase"] not in PHASES
|
|
85
|
+
or not isinstance(value.get("code"), str) or value["code"] not in CODES
|
|
86
|
+
or type(value.get("createdAt")) is not int
|
|
87
|
+
or type(value.get("updatedAt")) is not int
|
|
88
|
+
or not 0 <= value["createdAt"] <= value["updatedAt"] < 2**63
|
|
89
|
+
):
|
|
90
|
+
raise UpdateProtocolError("invalid update operation")
|
|
91
|
+
if value["code"] == "service_ready" and (not restart or value["phase"] != "recovered"):
|
|
92
|
+
raise UpdateProtocolError("service readiness only recovers a restart")
|
|
93
|
+
if restart:
|
|
94
|
+
target = value["target"]
|
|
95
|
+
if (value["kind"] != "restart" or not isinstance(target, dict)
|
|
96
|
+
or set(target) != {"version", "releaseDigest"}
|
|
97
|
+
or not _matches(VERSION, target.get("version"))
|
|
98
|
+
or target.get("releaseDigest") != value["previousRelease"]
|
|
99
|
+
or value["phase"] not in {
|
|
100
|
+
"accepted", "restarting", "succeeded", "failed", "recovery_required", "recovered",
|
|
101
|
+
}):
|
|
102
|
+
raise UpdateProtocolError("invalid restart operation")
|
|
103
|
+
return {**value, "target": dict(target)}
|
|
104
|
+
return {**value, "target": validate_target(value["target"])}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _validate_cli_restart(value: dict[str, Any]) -> dict[str, Any]:
|
|
108
|
+
target = value.get("target")
|
|
109
|
+
if (set(value) != {"schema", "kind", "operationId", "target", "phase", "code",
|
|
110
|
+
"createdAt", "updatedAt"}
|
|
111
|
+
or type(value["schema"]) is not int or value["schema"] != 3
|
|
112
|
+
or value.get("kind") != "restart"
|
|
113
|
+
or not _matches(OPERATION_ID, value.get("operationId"))
|
|
114
|
+
or not isinstance(target, dict) or set(target) != {"version", "installationId"}
|
|
115
|
+
or not isinstance(target.get("version"), str)
|
|
116
|
+
or not 0 < len(target["version"]) <= 128
|
|
117
|
+
or any(ord(character) < 32 for character in target["version"])
|
|
118
|
+
or not _matches(SHA256, target.get("installationId"))
|
|
119
|
+
or not isinstance(value.get("phase"), str) or value["phase"] not in {
|
|
120
|
+
"accepted", "restarting", "succeeded", "failed", "recovery_required", "recovered",
|
|
121
|
+
}
|
|
122
|
+
or not isinstance(value.get("code"), str) or value["code"] not in CODES
|
|
123
|
+
or type(value.get("createdAt")) is not int
|
|
124
|
+
or type(value.get("updatedAt")) is not int
|
|
125
|
+
or not 0 <= value["createdAt"] <= value["updatedAt"] < 2**63
|
|
126
|
+
or (value["code"] == "service_ready" and value["phase"] != "recovered")):
|
|
127
|
+
raise UpdateProtocolError("invalid CLI restart operation")
|
|
128
|
+
return {**value, "target": dict(target)}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def new_cli_restart_operation(version: str, installation_id: str) -> dict[str, Any]:
|
|
132
|
+
now = int(time.time())
|
|
133
|
+
return validate_operation({
|
|
134
|
+
"schema": 3, "kind": "restart", "operationId": secrets.token_hex(16),
|
|
135
|
+
"target": {"version": version, "installationId": installation_id},
|
|
136
|
+
"phase": "accepted", "code": "none", "createdAt": now, "updatedAt": now,
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def terminal_phase(phase: str) -> bool:
|
|
141
|
+
return phase in TERMINAL_PHASES
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _state_directory(product_root: Path) -> Path:
|
|
145
|
+
for directory in (product_root, product_root / "state"):
|
|
146
|
+
info = directory.lstat()
|
|
147
|
+
if (
|
|
148
|
+
not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid()
|
|
149
|
+
or stat.S_IMODE(info.st_mode) & 0o022
|
|
150
|
+
):
|
|
151
|
+
raise UpdateProtocolError("unsafe update state directory")
|
|
152
|
+
return product_root / "state"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _check_file(info: os.stat_result, *, private: bool = True) -> None:
|
|
156
|
+
if (
|
|
157
|
+
not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid()
|
|
158
|
+
or info.st_nlink != 1
|
|
159
|
+
or (private and stat.S_IMODE(info.st_mode) != 0o600)
|
|
160
|
+
):
|
|
161
|
+
raise UpdateProtocolError("unsafe update state file")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _unique_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
165
|
+
value: dict[str, Any] = {}
|
|
166
|
+
for key, item in pairs:
|
|
167
|
+
if key in value:
|
|
168
|
+
raise ValueError("duplicate update state field")
|
|
169
|
+
value[key] = item
|
|
170
|
+
return value
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def read_operation(product_root: Path) -> dict[str, Any] | None:
|
|
174
|
+
try:
|
|
175
|
+
directory = _state_directory(product_root)
|
|
176
|
+
descriptor = os.open(directory / OPERATION_FILE, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
177
|
+
except FileNotFoundError:
|
|
178
|
+
return None
|
|
179
|
+
except OSError as error:
|
|
180
|
+
raise UpdateProtocolError("could not read update operation") from error
|
|
181
|
+
try:
|
|
182
|
+
_check_file(os.fstat(descriptor))
|
|
183
|
+
with os.fdopen(descriptor, "rb", closefd=False) as source:
|
|
184
|
+
payload = source.read(MAX_OPERATION_BYTES + 1)
|
|
185
|
+
if len(payload) > MAX_OPERATION_BYTES:
|
|
186
|
+
raise UpdateProtocolError("update operation exceeds size limit")
|
|
187
|
+
return validate_operation(json.loads(payload, object_pairs_hook=_unique_json_object))
|
|
188
|
+
except (ValueError, UnicodeError, OSError, RecursionError) as error:
|
|
189
|
+
raise UpdateProtocolError("could not read update operation") from error
|
|
190
|
+
finally:
|
|
191
|
+
os.close(descriptor)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def write_operation(product_root: Path, operation: object) -> None:
|
|
195
|
+
value = validate_operation(operation)
|
|
196
|
+
payload = (json.dumps(value, separators=(",", ":"), sort_keys=True) + "\n").encode()
|
|
197
|
+
if len(payload) > MAX_OPERATION_BYTES:
|
|
198
|
+
raise UpdateProtocolError("update operation exceeds size limit")
|
|
199
|
+
temporary: str | None = None
|
|
200
|
+
try:
|
|
201
|
+
directory = _state_directory(product_root)
|
|
202
|
+
destination = directory / OPERATION_FILE
|
|
203
|
+
try:
|
|
204
|
+
_check_file(destination.lstat())
|
|
205
|
+
except FileNotFoundError:
|
|
206
|
+
pass
|
|
207
|
+
descriptor, temporary = tempfile.mkstemp(prefix=".update-", dir=directory)
|
|
208
|
+
with os.fdopen(descriptor, "wb") as output:
|
|
209
|
+
output.write(payload)
|
|
210
|
+
output.flush()
|
|
211
|
+
os.fsync(output.fileno())
|
|
212
|
+
os.replace(temporary, destination)
|
|
213
|
+
temporary = None
|
|
214
|
+
descriptor = os.open(directory, os.O_RDONLY | os.O_DIRECTORY)
|
|
215
|
+
try:
|
|
216
|
+
os.fsync(descriptor)
|
|
217
|
+
finally:
|
|
218
|
+
os.close(descriptor)
|
|
219
|
+
except OSError as error:
|
|
220
|
+
raise UpdateProtocolError("could not write update operation") from error
|
|
221
|
+
finally:
|
|
222
|
+
if temporary is not None:
|
|
223
|
+
os.unlink(temporary)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def advance_operation(product_root: Path, operation_id: str, phase: str, code: str = "none") -> dict[str, Any]:
|
|
227
|
+
"""Write a phase while the caller owns the shared installation lock."""
|
|
228
|
+
operation = read_operation(product_root)
|
|
229
|
+
if operation is None or operation["operationId"] != operation_id:
|
|
230
|
+
raise UpdateProtocolError("update operation changed")
|
|
231
|
+
operation.update(phase=phase, code=code, updatedAt=max(int(time.time()), operation["updatedAt"]))
|
|
232
|
+
write_operation(product_root, operation)
|
|
233
|
+
return operation
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def acquire_install_lock(product_root: Path, *, blocking: bool = False) -> int:
|
|
237
|
+
"""Return the shared lock FD; closing it releases this acquisition."""
|
|
238
|
+
directory = _state_directory(product_root)
|
|
239
|
+
descriptor = os.open(directory / ".install.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
|
240
|
+
try:
|
|
241
|
+
_check_file(os.fstat(descriptor), private=False)
|
|
242
|
+
os.fchmod(descriptor, 0o600)
|
|
243
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB))
|
|
244
|
+
return descriptor
|
|
245
|
+
except BaseException:
|
|
246
|
+
os.close(descriptor)
|
|
247
|
+
raise
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def validate_inherited_lock(product_root: Path, descriptor: int) -> None:
|
|
251
|
+
"""Accept only the worker's exact locked open-file description."""
|
|
252
|
+
os.set_inheritable(descriptor, False)
|
|
253
|
+
directory = _state_directory(product_root)
|
|
254
|
+
inherited = os.fstat(descriptor)
|
|
255
|
+
_check_file(inherited)
|
|
256
|
+
expected = (directory / ".install.lock").lstat()
|
|
257
|
+
_check_file(expected)
|
|
258
|
+
if (inherited.st_dev, inherited.st_ino) != (expected.st_dev, expected.st_ino):
|
|
259
|
+
raise UpdateProtocolError("update lock descriptor does not match")
|
|
260
|
+
# A separately opened FD must see a held lock; the inherited open-file
|
|
261
|
+
# description must already own it, rather than some unrelated process.
|
|
262
|
+
probe = os.open(directory / ".install.lock", os.O_RDWR | os.O_NOFOLLOW)
|
|
263
|
+
try:
|
|
264
|
+
try:
|
|
265
|
+
fcntl.flock(probe, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
266
|
+
except BlockingIOError:
|
|
267
|
+
pass
|
|
268
|
+
else:
|
|
269
|
+
raise UpdateProtocolError("inherited update lock is not held")
|
|
270
|
+
finally:
|
|
271
|
+
os.close(probe)
|
|
272
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@contextmanager
|
|
276
|
+
def install_lock(product_root: Path, *, blocking: bool = False) -> Iterator[int]:
|
|
277
|
+
descriptor = acquire_install_lock(product_root, blocking=blocking)
|
|
278
|
+
try:
|
|
279
|
+
yield descriptor
|
|
280
|
+
finally:
|
|
281
|
+
os.close(descriptor)
|
netizen_cli/domain.py
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""Channel-facing domain types; native agent state stays in Codex."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ScopeKind(str, Enum):
|
|
11
|
+
DIRECT = "direct"
|
|
12
|
+
GROUP = "group"
|
|
13
|
+
TOPIC = "topic"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MentionContextMode(str, Enum):
|
|
17
|
+
"""Binding-scoped policy for context gathered by an explicit mention."""
|
|
18
|
+
|
|
19
|
+
CURRENT_ONLY = "current-only"
|
|
20
|
+
CATCH_UP = "catch-up"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class MessageContextAnchor:
|
|
25
|
+
"""Exact Feishu message boundary for one catch-up Binding."""
|
|
26
|
+
|
|
27
|
+
message_id: str
|
|
28
|
+
create_time_ms: int
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
if not isinstance(self.message_id, str) or not self.message_id:
|
|
32
|
+
raise ValueError("context anchor message_id must not be empty")
|
|
33
|
+
if (
|
|
34
|
+
isinstance(self.create_time_ms, bool)
|
|
35
|
+
or not isinstance(self.create_time_ms, int)
|
|
36
|
+
or self.create_time_ms <= 0
|
|
37
|
+
):
|
|
38
|
+
raise ValueError("context anchor create_time_ms must be positive")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class FeishuScope:
|
|
43
|
+
app_id: str
|
|
44
|
+
chat_id: str
|
|
45
|
+
kind: ScopeKind
|
|
46
|
+
topic_id: str | None = None
|
|
47
|
+
|
|
48
|
+
def __post_init__(self) -> None:
|
|
49
|
+
if not self.app_id or not self.chat_id:
|
|
50
|
+
raise ValueError("app_id and chat_id are required")
|
|
51
|
+
if self.kind is ScopeKind.TOPIC and not self.topic_id:
|
|
52
|
+
raise ValueError("a topic scope requires topic_id")
|
|
53
|
+
if self.kind is not ScopeKind.TOPIC and self.topic_id is not None:
|
|
54
|
+
raise ValueError("only a topic scope may carry topic_id")
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def key(self) -> str:
|
|
58
|
+
parts = (self.app_id, self.kind.value, self.chat_id, self.topic_id or "")
|
|
59
|
+
return "scope:v1:" + ":".join(quote(part, safe="") for part in parts)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class PromptInput:
|
|
64
|
+
scope: FeishuScope
|
|
65
|
+
source_id: str
|
|
66
|
+
sender_id: str
|
|
67
|
+
text: str
|
|
68
|
+
skill_names: tuple[str, ...] = ()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class ScheduledConversation:
|
|
73
|
+
thread_id: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True)
|
|
77
|
+
class ScheduledOrigin:
|
|
78
|
+
"""Automatic attribution and a real bot-message completion destination."""
|
|
79
|
+
|
|
80
|
+
app_id: str
|
|
81
|
+
chat_id: str
|
|
82
|
+
message_id: str
|
|
83
|
+
conversation: ScheduledConversation
|
|
84
|
+
plan_id: str
|
|
85
|
+
run_id: str
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def id(self) -> str:
|
|
89
|
+
return self.message_id
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class ScheduledBindingConversation:
|
|
94
|
+
chat_id: str
|
|
95
|
+
chat_type: str
|
|
96
|
+
thread_id: str | None = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True, slots=True)
|
|
100
|
+
class ScheduledBindingOrigin:
|
|
101
|
+
"""A real trigger anchor, distinct from a Feishu-authored input."""
|
|
102
|
+
|
|
103
|
+
app_id: str
|
|
104
|
+
chat_id: str
|
|
105
|
+
message_id: str
|
|
106
|
+
conversation: ScheduledBindingConversation
|
|
107
|
+
plan_id: str
|
|
108
|
+
run_id: str
|
|
109
|
+
binding_id: str
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def id(self) -> str:
|
|
113
|
+
return self.message_id
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class NativeCapability(str, Enum):
|
|
117
|
+
SKILLS = "skills"
|
|
118
|
+
GOAL = "goal"
|
|
119
|
+
SIDE = "side"
|
|
120
|
+
RELEASE = "release"
|
|
121
|
+
DELETE = "delete"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ActiveState(str, Enum):
|
|
125
|
+
RUNNING = "running"
|
|
126
|
+
STOPPING = "stopping"
|
|
127
|
+
OBSERVATION_UNAVAILABLE = "turn-observation-unavailable"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
ACTIVE_STATE_VALUES: frozenset[str] = frozenset(state.value for state in ActiveState)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class GoalOperationState(str, Enum):
|
|
134
|
+
STARTING = "goal-starting"
|
|
135
|
+
RUNNING = "goal-running"
|
|
136
|
+
PAUSING = "goal-pausing"
|
|
137
|
+
EXTERNAL_ACTIVE = "externally-active-goal"
|
|
138
|
+
UNKNOWN = "goal-unknown"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class GoalStatus(str, Enum):
|
|
142
|
+
ACTIVE = "active"
|
|
143
|
+
PAUSED = "paused"
|
|
144
|
+
BLOCKED = "blocked"
|
|
145
|
+
USAGE_LIMITED = "usageLimited"
|
|
146
|
+
BUDGET_LIMITED = "budgetLimited"
|
|
147
|
+
COMPLETE = "complete"
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def terminal_or_paused(self) -> bool:
|
|
151
|
+
return self is not GoalStatus.ACTIVE
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
SESSION_IDLE_STATE = "idle"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
SESSION_STOP_ACTION_STATES: frozenset[str] = frozenset(
|
|
158
|
+
{
|
|
159
|
+
ActiveState.RUNNING.value,
|
|
160
|
+
ActiveState.STOPPING.value,
|
|
161
|
+
ActiveState.OBSERVATION_UNAVAILABLE.value,
|
|
162
|
+
GoalOperationState.RUNNING.value,
|
|
163
|
+
GoalOperationState.PAUSING.value,
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def session_stop_available(primary_status: str | None) -> bool:
|
|
169
|
+
"""Return whether a Session status exposes the shared exact Stop control."""
|
|
170
|
+
|
|
171
|
+
return primary_status in SESSION_STOP_ACTION_STATES
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class ControlName(str, Enum):
|
|
175
|
+
MENU = "menu"
|
|
176
|
+
NEW = "new"
|
|
177
|
+
SIDE = "side"
|
|
178
|
+
CONFIG = "config"
|
|
179
|
+
DEFAULTS = "defaults"
|
|
180
|
+
COMPACT = "compact"
|
|
181
|
+
SETTINGS = "settings"
|
|
182
|
+
CRON = "cron"
|
|
183
|
+
SESSIONS = "sessions"
|
|
184
|
+
RESUME = "resume"
|
|
185
|
+
RENAME = "rename"
|
|
186
|
+
ARCHIVE = "archive"
|
|
187
|
+
DELETE = "delete"
|
|
188
|
+
UNARCHIVE = "unarchive"
|
|
189
|
+
STOP = "stop"
|
|
190
|
+
RELEASE = "release"
|
|
191
|
+
STATUS = "status"
|
|
192
|
+
ADMIN = "admin"
|
|
193
|
+
GOAL = "goal"
|
|
194
|
+
HELP = "help"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class SettingsSection(str, Enum):
|
|
198
|
+
PROJECTS = "projects"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True, slots=True)
|
|
202
|
+
class ControlIntent:
|
|
203
|
+
scope: FeishuScope
|
|
204
|
+
source_id: str
|
|
205
|
+
sender_id: str
|
|
206
|
+
name: ControlName
|
|
207
|
+
arguments: tuple[str, ...] = ()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class CardControlName(str, Enum):
|
|
211
|
+
OPEN_SETTINGS_SECTION = "settings.section.open"
|
|
212
|
+
REFRESH_SETTINGS = "settings.refresh"
|
|
213
|
+
REGISTER_PROJECT = "project.register"
|
|
214
|
+
SET_PROJECT_ENABLED = "project.enabled.set"
|
|
215
|
+
CREATE_BINDING = "binding.create"
|
|
216
|
+
CONFIGURE_BINDING = "binding.configure"
|
|
217
|
+
RENAME_BINDING = "binding.rename"
|
|
218
|
+
ARCHIVE_BINDING = "binding.archive"
|
|
219
|
+
DELETE_BINDING = "binding.delete"
|
|
220
|
+
PREPARE_EXACT_DELETE_BINDING = "binding.delete.exact.prepare"
|
|
221
|
+
DELETE_EXACT_BINDING = "binding.delete.exact"
|
|
222
|
+
PREPARE_ARCHIVED_DELETE_BINDING = "binding.delete.archived.prepare"
|
|
223
|
+
DELETE_ARCHIVED_BINDING = "binding.delete.archived"
|
|
224
|
+
UNARCHIVE_BINDING = "binding.unarchive"
|
|
225
|
+
ACTIVATE_BINDING = "binding.activate"
|
|
226
|
+
ARCHIVE_EXACT_BINDING = "binding.archive.exact"
|
|
227
|
+
STOP_EXACT_BINDING = "binding.stop.exact"
|
|
228
|
+
RECHECK_EXACT_TURN = "binding.turn.recheck"
|
|
229
|
+
SESSIONS_PAGE = "sessions.page"
|
|
230
|
+
REFRESH_ARCHIVED_SESSIONS = "sessions.archived.refresh"
|
|
231
|
+
GOAL_PAUSE = "goal.pause"
|
|
232
|
+
GOAL_RESUME = "goal.resume"
|
|
233
|
+
GOAL_CLEAR = "goal.clear"
|
|
234
|
+
SIDE_CLOSE = "side.close"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@dataclass(frozen=True, slots=True)
|
|
238
|
+
class CardControlIntent:
|
|
239
|
+
scope: FeishuScope
|
|
240
|
+
source_id: str
|
|
241
|
+
sender_id: str
|
|
242
|
+
name: CardControlName
|
|
243
|
+
settings_section: SettingsSection | None = None
|
|
244
|
+
project_alias: str | None = None
|
|
245
|
+
expected_revision: int | None = None
|
|
246
|
+
expected_settings_revision: int | None = None
|
|
247
|
+
expected_context_revision: int | None = None
|
|
248
|
+
feedback_revision: int | None = None
|
|
249
|
+
enabled: bool | None = None
|
|
250
|
+
project_path: str | None = None
|
|
251
|
+
create_directory: bool | None = None
|
|
252
|
+
binding_id: str | None = None
|
|
253
|
+
expected_active_binding_id: str | None = None
|
|
254
|
+
expected_native_thread_id: str | None = None
|
|
255
|
+
expected_activity_revision: int | None = None
|
|
256
|
+
expected_turn_id: str | None = None
|
|
257
|
+
thread_name: str | None = None
|
|
258
|
+
model_id: str | None = None
|
|
259
|
+
effort_id: str | None = None
|
|
260
|
+
service_tier_id: str | None = None
|
|
261
|
+
reaction_pulse_enabled: bool | None = None
|
|
262
|
+
progress_card_enabled: bool | None = None
|
|
263
|
+
completion_mention_enabled: bool | None = None
|
|
264
|
+
message_context_mode: MentionContextMode | None = None
|
|
265
|
+
side_id: str | None = None
|
|
266
|
+
page: int | None = None
|
|
267
|
+
goal_generation: str | None = None
|
|
268
|
+
expected_goal_status: str | None = None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class TurnFileActionName(str, Enum):
|
|
272
|
+
PAGE = "turn-file.page"
|
|
273
|
+
SEND = "turn-file.send"
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@dataclass(frozen=True, slots=True)
|
|
277
|
+
class TurnFileManifestItem:
|
|
278
|
+
path: str
|
|
279
|
+
label: str
|
|
280
|
+
additions: int | None = None
|
|
281
|
+
deletions: int | None = None
|
|
282
|
+
deleted: bool = False
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@dataclass(frozen=True, slots=True)
|
|
286
|
+
class TurnProgressManifestStep:
|
|
287
|
+
step: str
|
|
288
|
+
status: str
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@dataclass(frozen=True, slots=True)
|
|
292
|
+
class TurnActivityManifestEntry:
|
|
293
|
+
kind: str
|
|
294
|
+
status: str
|
|
295
|
+
event_timestamp_ms: int | None = None
|
|
296
|
+
text: str | None = None
|
|
297
|
+
count: int = 1
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@dataclass(frozen=True, slots=True)
|
|
301
|
+
class TurnCommentaryManifestEntry:
|
|
302
|
+
text: str
|
|
303
|
+
event_timestamp_ms: int | None = None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@dataclass(frozen=True, slots=True)
|
|
307
|
+
class TurnProgressManifest:
|
|
308
|
+
"""Sanitized, self-contained progress panel carried by file pagination."""
|
|
309
|
+
|
|
310
|
+
state: str
|
|
311
|
+
steer_count: int
|
|
312
|
+
plan_available: bool
|
|
313
|
+
plan_generated: bool
|
|
314
|
+
plan_may_be_stale: bool
|
|
315
|
+
steps: tuple[TurnProgressManifestStep, ...] = ()
|
|
316
|
+
commentary: tuple[TurnCommentaryManifestEntry, ...] = ()
|
|
317
|
+
operations: tuple[TurnActivityManifestEntry, ...] = ()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@dataclass(frozen=True, slots=True)
|
|
321
|
+
class ReplyCardGoalModule:
|
|
322
|
+
"""Frozen, display-only Goal control projection for one Goal generation."""
|
|
323
|
+
|
|
324
|
+
binding_id: str
|
|
325
|
+
short_id: str
|
|
326
|
+
project_alias: str
|
|
327
|
+
goal_generation: str | None
|
|
328
|
+
status: str | None
|
|
329
|
+
runtime_state: str | None
|
|
330
|
+
objective: str | None
|
|
331
|
+
token_budget: int | None
|
|
332
|
+
tokens_used: int
|
|
333
|
+
notice: str | None = None
|
|
334
|
+
notice_is_error: bool = False
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@dataclass(frozen=True, slots=True)
|
|
338
|
+
class ReplyCardActivityModule:
|
|
339
|
+
"""Bounded, sanitized Turn activity plus its presentation state."""
|
|
340
|
+
|
|
341
|
+
progress: TurnProgressManifest
|
|
342
|
+
terminal_status: str | None = None
|
|
343
|
+
collapsed: bool = False
|
|
344
|
+
hidden_steps: int = 0
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@dataclass(frozen=True, slots=True)
|
|
348
|
+
class ReplyCardResultModule:
|
|
349
|
+
content: str
|
|
350
|
+
# One terminal delivery only; never retained in file-page callbacks.
|
|
351
|
+
completion_mention_user_id: str | None = None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@dataclass(frozen=True, slots=True)
|
|
355
|
+
class ReplyCardFileItem:
|
|
356
|
+
"""One current file view plus optional exact-Turn line statistics."""
|
|
357
|
+
|
|
358
|
+
path: str
|
|
359
|
+
label: str
|
|
360
|
+
size: int | None
|
|
361
|
+
media_kind: str | None
|
|
362
|
+
additions: int | None = None
|
|
363
|
+
deletions: int | None = None
|
|
364
|
+
deleted: bool = False
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@dataclass(frozen=True, slots=True)
|
|
368
|
+
class ReplyCardFilesModule:
|
|
369
|
+
binding_id: str
|
|
370
|
+
turn_id: str
|
|
371
|
+
items: tuple[ReplyCardFileItem, ...]
|
|
372
|
+
page: int = 0
|
|
373
|
+
action_version: int = 5
|
|
374
|
+
additions: int | None = None
|
|
375
|
+
deletions: int | None = None
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@dataclass(frozen=True, slots=True)
|
|
379
|
+
class ReplyCardManifest:
|
|
380
|
+
"""Frozen non-file modules carried by a self-contained page callback."""
|
|
381
|
+
|
|
382
|
+
goal: ReplyCardGoalModule | None = None
|
|
383
|
+
activity: ReplyCardActivityModule | None = None
|
|
384
|
+
result: ReplyCardResultModule | None = None
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@dataclass(frozen=True, slots=True)
|
|
388
|
+
class ReplyCardProjection:
|
|
389
|
+
"""Closed Reply Card module set rendered atomically as one Card 2.0 body."""
|
|
390
|
+
|
|
391
|
+
scope: FeishuScope | None = None
|
|
392
|
+
goal: ReplyCardGoalModule | None = None
|
|
393
|
+
activity: ReplyCardActivityModule | None = None
|
|
394
|
+
result: ReplyCardResultModule | None = None
|
|
395
|
+
files: ReplyCardFilesModule | None = None
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@dataclass(frozen=True, slots=True)
|
|
399
|
+
class TurnFileActionIntent:
|
|
400
|
+
scope: FeishuScope
|
|
401
|
+
source_id: str
|
|
402
|
+
sender_id: str
|
|
403
|
+
name: TurnFileActionName
|
|
404
|
+
binding_id: str
|
|
405
|
+
turn_id: str
|
|
406
|
+
page: int | None = None
|
|
407
|
+
path: str | None = None
|
|
408
|
+
files: tuple[TurnFileManifestItem, ...] = ()
|
|
409
|
+
answer: str | None = None
|
|
410
|
+
progress: TurnProgressManifest | None = None
|
|
411
|
+
reply: ReplyCardManifest | None = None
|
|
412
|
+
additions: int | None = None
|
|
413
|
+
deletions: int | None = None
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
ChannelInteraction = PromptInput | ControlIntent
|