tlgr-cli 2.0.1__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.
- tlgr/__init__.py +3 -0
- tlgr/__main__.py +6 -0
- tlgr/actions/__init__.py +45 -0
- tlgr/actions/forward.py +74 -0
- tlgr/actions/reply.py +32 -0
- tlgr/cli/__init__.py +259 -0
- tlgr/cli/confirm.py +55 -0
- tlgr/cli/errors.py +84 -0
- tlgr/cli/gen.py +690 -0
- tlgr/cli/globals.py +273 -0
- tlgr/cli/introspect.py +170 -0
- tlgr/cli/params.py +189 -0
- tlgr/cli/render.py +418 -0
- tlgr/core/__init__.py +0 -0
- tlgr/core/accounts.py +384 -0
- tlgr/core/config.py +358 -0
- tlgr/core/custom_tl.py +170 -0
- tlgr/core/errors.py +687 -0
- tlgr/core/eventtypes.py +1170 -0
- tlgr/core/identity.py +127 -0
- tlgr/core/launchd.py +122 -0
- tlgr/core/logging.py +194 -0
- tlgr/core/media.py +134 -0
- tlgr/core/output.py +251 -0
- tlgr/core/pagination.py +227 -0
- tlgr/core/paths.py +360 -0
- tlgr/core/peers.py +427 -0
- tlgr/core/process.py +138 -0
- tlgr/core/signing.py +38 -0
- tlgr/core/systemd.py +96 -0
- tlgr/core/telethon_compat.py +295 -0
- tlgr/core/text.py +211 -0
- tlgr/core/timefmt.py +199 -0
- tlgr/core/tl.py +98 -0
- tlgr/daemon/__init__.py +0 -0
- tlgr/daemon/app.py +869 -0
- tlgr/daemon/dispatch.py +446 -0
- tlgr/daemon/events.py +723 -0
- tlgr/daemon/files.py +431 -0
- tlgr/daemon/idle.py +119 -0
- tlgr/daemon/jobs.py +68 -0
- tlgr/daemon/main.py +161 -0
- tlgr/daemon/peercred.py +75 -0
- tlgr/daemon/policy.py +113 -0
- tlgr/daemon/preauth.py +366 -0
- tlgr/daemon/ratelimit.py +391 -0
- tlgr/daemon/server.py +24 -0
- tlgr/daemon/session.py +648 -0
- tlgr/daemon/sessions.py +274 -0
- tlgr/daemon/singleton.py +114 -0
- tlgr/daemon/stream.py +193 -0
- tlgr/daemon/transfers.py +219 -0
- tlgr/daemon/webhook.py +390 -0
- tlgr/data/catalog_index.json +1 -0
- tlgr/data/parity_waivers.toml +90 -0
- tlgr/filters/__init__.py +42 -0
- tlgr/filters/compose.py +121 -0
- tlgr/filters/content.py +85 -0
- tlgr/filters/context.py +114 -0
- tlgr/filters/message.py +161 -0
- tlgr/filters/temporal.py +87 -0
- tlgr/filters/user.py +36 -0
- tlgr/gateway/__init__.py +1 -0
- tlgr/gateway/config.py +161 -0
- tlgr/gateway/engine.py +215 -0
- tlgr/gateway/event.py +22 -0
- tlgr/jobs/__init__.py +0 -0
- tlgr/jobs/base.py +81 -0
- tlgr/jobs/client.py +37 -0
- tlgr/models/__init__.py +1220 -0
- tlgr/models/admin.py +744 -0
- tlgr/models/auth.py +510 -0
- tlgr/models/base.py +81 -0
- tlgr/models/bot.py +576 -0
- tlgr/models/business.py +265 -0
- tlgr/models/call.py +586 -0
- tlgr/models/config.py +101 -0
- tlgr/models/contact.py +481 -0
- tlgr/models/daemon.py +336 -0
- tlgr/models/dialog.py +626 -0
- tlgr/models/envelope.py +68 -0
- tlgr/models/error.py +30 -0
- tlgr/models/event.py +79 -0
- tlgr/models/export.py +66 -0
- tlgr/models/gift.py +275 -0
- tlgr/models/inline.py +84 -0
- tlgr/models/location.py +115 -0
- tlgr/models/media.py +507 -0
- tlgr/models/message.py +584 -0
- tlgr/models/net.py +232 -0
- tlgr/models/notify.py +105 -0
- tlgr/models/page.py +32 -0
- tlgr/models/payment.py +172 -0
- tlgr/models/peer.py +400 -0
- tlgr/models/poll.py +119 -0
- tlgr/models/premium.py +161 -0
- tlgr/models/privacy.py +93 -0
- tlgr/models/profile.py +217 -0
- tlgr/models/reaction.py +160 -0
- tlgr/models/resolve.py +175 -0
- tlgr/models/settings.py +103 -0
- tlgr/models/stars.py +101 -0
- tlgr/models/sticker.py +243 -0
- tlgr/models/story.py +467 -0
- tlgr/models/sync.py +105 -0
- tlgr/models/todo.py +36 -0
- tlgr/models/webapp.py +89 -0
- tlgr/ops/__init__.py +63 -0
- tlgr/ops/_admin.py +313 -0
- tlgr/ops/_auth.py +599 -0
- tlgr/ops/_bots.py +586 -0
- tlgr/ops/_calls.py +535 -0
- tlgr/ops/_common.py +160 -0
- tlgr/ops/_layer.py +46 -0
- tlgr/ops/_media.py +592 -0
- tlgr/ops/_params.py +212 -0
- tlgr/ops/_rights.py +402 -0
- tlgr/ops/_send.py +593 -0
- tlgr/ops/_serialize.py +667 -0
- tlgr/ops/_settings.py +306 -0
- tlgr/ops/_spec.py +167 -0
- tlgr/ops/_story.py +743 -0
- tlgr/ops/account.py +2604 -0
- tlgr/ops/agent.py +937 -0
- tlgr/ops/auth.py +1282 -0
- tlgr/ops/bot.py +4880 -0
- tlgr/ops/business.py +1520 -0
- tlgr/ops/call.py +1610 -0
- tlgr/ops/chat.py +4025 -0
- tlgr/ops/chat_admin.py +929 -0
- tlgr/ops/chat_extra.py +1061 -0
- tlgr/ops/chat_invite.py +716 -0
- tlgr/ops/chat_manage.py +1691 -0
- tlgr/ops/chat_member.py +1357 -0
- tlgr/ops/chat_stats.py +902 -0
- tlgr/ops/chat_topic.py +905 -0
- tlgr/ops/conference.py +791 -0
- tlgr/ops/config.py +1698 -0
- tlgr/ops/contact.py +2330 -0
- tlgr/ops/daemon.py +1397 -0
- tlgr/ops/draft.py +299 -0
- tlgr/ops/emoji.py +343 -0
- tlgr/ops/events.py +1327 -0
- tlgr/ops/export.py +596 -0
- tlgr/ops/folder.py +1322 -0
- tlgr/ops/gif.py +522 -0
- tlgr/ops/gift.py +1546 -0
- tlgr/ops/giveaway.py +541 -0
- tlgr/ops/inline.py +773 -0
- tlgr/ops/job.py +799 -0
- tlgr/ops/location.py +917 -0
- tlgr/ops/media.py +4495 -0
- tlgr/ops/message.py +3769 -0
- tlgr/ops/net.py +536 -0
- tlgr/ops/notify.py +840 -0
- tlgr/ops/passport.py +464 -0
- tlgr/ops/payment.py +907 -0
- tlgr/ops/poll.py +1078 -0
- tlgr/ops/premium.py +488 -0
- tlgr/ops/privacy.py +794 -0
- tlgr/ops/profile.py +1481 -0
- tlgr/ops/proxy.py +750 -0
- tlgr/ops/reaction.py +1475 -0
- tlgr/ops/resolve.py +1140 -0
- tlgr/ops/search.py +521 -0
- tlgr/ops/settings.py +1066 -0
- tlgr/ops/stars.py +594 -0
- tlgr/ops/sticker.py +1602 -0
- tlgr/ops/story.py +3216 -0
- tlgr/ops/sync.py +788 -0
- tlgr/ops/todo.py +514 -0
- tlgr/ops/user.py +1406 -0
- tlgr/ops/vc.py +2351 -0
- tlgr/ops/webapp.py +717 -0
- tlgr/ops/webhook.py +418 -0
- tlgr/parity.py +386 -0
- tlgr/processors/__init__.py +125 -0
- tlgr/processors/regex.py +26 -0
- tlgr/processors/text.py +56 -0
- tlgr/registry.py +519 -0
- tlgr/schema.py +173 -0
- tlgr/transport/__init__.py +30 -0
- tlgr/transport/autostart.py +293 -0
- tlgr/transport/client.py +805 -0
- tlgr/transport/ndjson.py +44 -0
- tlgr/version.py +31 -0
- tlgr_cli-2.0.1.dist-info/METADATA +957 -0
- tlgr_cli-2.0.1.dist-info/RECORD +192 -0
- tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
- tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
- tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
- tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/ops/agent.py
ADDED
|
@@ -0,0 +1,937 @@
|
|
|
1
|
+
"""Local operations — the ones that need no account and no daemon.
|
|
2
|
+
|
|
3
|
+
They are the proof that the registry can carry an operation end to end: these
|
|
4
|
+
two were hand-written Click commands in v1 and are now generated, with the
|
|
5
|
+
same JSON a v1 agent already parses.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
from typing import Annotated, Any
|
|
12
|
+
|
|
13
|
+
from tlgr.core.errors import EXIT_CODE_MAP, DaemonError, DaemonNotRunningError, UsageError
|
|
14
|
+
from tlgr.models.base import Model, Request
|
|
15
|
+
from tlgr.models.daemon import HealthSummary
|
|
16
|
+
from tlgr.ops._params import arg, choice, opt
|
|
17
|
+
from tlgr.ops._spec import OpContext, OperationSpec, Surface
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"SPEC_CAPABILITIES",
|
|
21
|
+
"SPEC_COMPLETION",
|
|
22
|
+
"SPEC_EXIT_CODES",
|
|
23
|
+
"SPEC_PARITY",
|
|
24
|
+
"SPEC_SCHEMA",
|
|
25
|
+
"SPEC_STATUS",
|
|
26
|
+
"SPEC_WHOAMI",
|
|
27
|
+
"Capabilities",
|
|
28
|
+
"CompletionScript",
|
|
29
|
+
"ErrorEntry",
|
|
30
|
+
"ExitCodeEntry",
|
|
31
|
+
"ExitCodes",
|
|
32
|
+
"SchemaDoc",
|
|
33
|
+
"WhoAmI",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ExitCodeEntry(Model):
|
|
38
|
+
code: int
|
|
39
|
+
description: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ErrorEntry(Model):
|
|
43
|
+
"""One row of the RPC-error taxonomy (§7.2)."""
|
|
44
|
+
|
|
45
|
+
name: str
|
|
46
|
+
code: str
|
|
47
|
+
exit: int
|
|
48
|
+
http: int
|
|
49
|
+
retryable: bool = False
|
|
50
|
+
hint: str = ""
|
|
51
|
+
#: The field a regex error captures: `FLOOD_WAIT_X` carries a wait,
|
|
52
|
+
#: `*_MIGRATE_X` a data centre, `FILE_PART_X_MISSING` a part number. An
|
|
53
|
+
#: agent that cannot see it can only retry blindly.
|
|
54
|
+
extra: str = ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ExitCodes(Model):
|
|
58
|
+
"""Deliberately a mapping, not a list: this is the exact JSON v1 printed."""
|
|
59
|
+
|
|
60
|
+
exit_codes: dict[str, ExitCodeEntry]
|
|
61
|
+
errors: list[ErrorEntry] = []
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ExitCodesReq(Request):
|
|
65
|
+
errors: Annotated[
|
|
66
|
+
bool,
|
|
67
|
+
opt("--errors", help="Also print the RPC error to exit-code mapping."),
|
|
68
|
+
] = False
|
|
69
|
+
search: Annotated[
|
|
70
|
+
str | None, opt("--search", metavar="TEXT", help="Filter the error table.")
|
|
71
|
+
] = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
#: Which regex-matched errors carry a parameter, and what it means. The number
|
|
75
|
+
#: in `FLOOD_WAIT_42` is not part of the name; dropping it would leave a
|
|
76
|
+
#: caller knowing it must wait and not for how long.
|
|
77
|
+
_ERROR_EXTRA: dict[str, str] = {
|
|
78
|
+
"FloodWaitError": "wait_seconds",
|
|
79
|
+
"SlowModeWaitError": "wait_seconds",
|
|
80
|
+
"FloodPremiumWaitError": "wait_seconds",
|
|
81
|
+
"FloodTestPhoneWaitError": "wait_seconds",
|
|
82
|
+
"TakeoutInitDelayError": "wait_seconds",
|
|
83
|
+
"PhoneMigrateError": "new_dc",
|
|
84
|
+
"NetworkMigrateError": "new_dc",
|
|
85
|
+
"UserMigrateError": "new_dc",
|
|
86
|
+
"FileMigrateError": "new_dc",
|
|
87
|
+
"FilePartMissingError": "which",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _error_table() -> list[ErrorEntry]:
|
|
92
|
+
"""The §7.2 mapping, rendered from the one table that implements it."""
|
|
93
|
+
from tlgr.core.errors import ERROR_MAP
|
|
94
|
+
|
|
95
|
+
rows = [
|
|
96
|
+
ErrorEntry(
|
|
97
|
+
name=name,
|
|
98
|
+
code=rule.code,
|
|
99
|
+
exit=rule.exit_code,
|
|
100
|
+
http=rule.http,
|
|
101
|
+
retryable=rule.retryable,
|
|
102
|
+
hint=rule.hint,
|
|
103
|
+
extra=_ERROR_EXTRA.get(name, ""),
|
|
104
|
+
)
|
|
105
|
+
for name, rule in ERROR_MAP.items()
|
|
106
|
+
]
|
|
107
|
+
rows.sort(key=lambda row: (row.exit, row.name))
|
|
108
|
+
return rows
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def exit_codes(ctx: OpContext, req: ExitCodesReq) -> ExitCodes:
|
|
112
|
+
"""Return the stable exit-code table, and optionally the error taxonomy.
|
|
113
|
+
|
|
114
|
+
The exit codes are a compatibility contract: a code never changes meaning.
|
|
115
|
+
`--errors` adds the row *above* them — which Telethon exception becomes
|
|
116
|
+
which code — so an agent can decide whether to retry without catching the
|
|
117
|
+
exception itself.
|
|
118
|
+
"""
|
|
119
|
+
table = ExitCodes(
|
|
120
|
+
exit_codes={
|
|
121
|
+
name: ExitCodeEntry(code=int(info["code"]), description=str(info["description"]))
|
|
122
|
+
for name, info in EXIT_CODE_MAP.items()
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
if req.errors:
|
|
126
|
+
rows = _error_table()
|
|
127
|
+
if req.search:
|
|
128
|
+
needle = req.search.lower()
|
|
129
|
+
rows = [row for row in rows if needle in row.name.lower() or needle in row.code.lower()]
|
|
130
|
+
table.errors = rows
|
|
131
|
+
return table
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
SPEC_EXIT_CODES = OperationSpec(
|
|
135
|
+
id="agent.exit-codes",
|
|
136
|
+
request=ExitCodesReq,
|
|
137
|
+
response=ExitCodes,
|
|
138
|
+
impl=exit_codes,
|
|
139
|
+
summary="Print the stable exit codes, and the RPC error to exit-code mapping",
|
|
140
|
+
description=(
|
|
141
|
+
"Every tlgr command exits with one of these codes. They are a "
|
|
142
|
+
"compatibility contract: a code never changes meaning. `--errors` "
|
|
143
|
+
"adds the row above them — which Telethon exception becomes which "
|
|
144
|
+
"code, whether it is retryable, and the parameter a regex error "
|
|
145
|
+
"carries (`FLOOD_WAIT_42` is a wait of 42 seconds, not a distinct "
|
|
146
|
+
"error)."
|
|
147
|
+
),
|
|
148
|
+
aliases=("exit-codes",),
|
|
149
|
+
legacy_paths=("agent exit-codes",),
|
|
150
|
+
needs_account=False,
|
|
151
|
+
needs_auth=False,
|
|
152
|
+
surface=Surface.LOCAL,
|
|
153
|
+
idempotent=True,
|
|
154
|
+
rate_class="local",
|
|
155
|
+
timeout_s=5,
|
|
156
|
+
example={
|
|
157
|
+
"exit_codes": {
|
|
158
|
+
"SUCCESS": {"code": 0, "description": "Success"},
|
|
159
|
+
"USAGE": {"code": 2, "description": "Usage or parse error"},
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
example_args="agent exit-codes",
|
|
163
|
+
covers=("updates.net-error-taxonomy", "updates.net-migrate-errors"),
|
|
164
|
+
tags=frozenset({"agent-safe"}),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class SchemaDoc(Model):
|
|
169
|
+
"""`tlgr schema` output. Free-form on purpose: it *is* a schema document."""
|
|
170
|
+
|
|
171
|
+
schema_version: int
|
|
172
|
+
build: str
|
|
173
|
+
ops: dict[str, Any] = {}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
#: What `tlgr schema <what>` can print. `commands` is the default because it
|
|
177
|
+
#: is what the word meant in v1 and a documented spelling does not change
|
|
178
|
+
#: meaning underneath its callers (§12.4).
|
|
179
|
+
_SCHEMA_KINDS = ("commands", "events", "config", "errors", "exit-codes", "all")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class SchemaReq(Request):
|
|
183
|
+
path: Annotated[
|
|
184
|
+
tuple[str, ...],
|
|
185
|
+
arg(
|
|
186
|
+
0,
|
|
187
|
+
metavar="PATH",
|
|
188
|
+
required=False,
|
|
189
|
+
variadic=True,
|
|
190
|
+
help=(
|
|
191
|
+
"A schema kind (commands, events, config, errors, exit-codes, all), "
|
|
192
|
+
"or a command path to limit the document to, e.g. `schema message send`."
|
|
193
|
+
),
|
|
194
|
+
),
|
|
195
|
+
] = ()
|
|
196
|
+
include_hidden: Annotated[
|
|
197
|
+
bool,
|
|
198
|
+
opt("--include-hidden", help="Include hidden commands and flags."),
|
|
199
|
+
] = False
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def schema(ctx: OpContext, req: SchemaReq) -> dict[str, Any]:
|
|
203
|
+
"""Build the machine-readable schema document.
|
|
204
|
+
|
|
205
|
+
The first positional does double duty, because v1's `tlgr schema message`
|
|
206
|
+
meant "the message commands" and `tlgr schema events` has to mean "the
|
|
207
|
+
event taxonomy". A kind name wins; anything else is a command path. The
|
|
208
|
+
two vocabularies do not overlap — no command group is called `errors` or
|
|
209
|
+
`exit-codes` — and `schema commands` still narrows the way v1 did.
|
|
210
|
+
|
|
211
|
+
The Click command tree is handed in through the context rather than
|
|
212
|
+
imported: `ops/` must not import `cli/` (§2.2), and the tree is a CLI
|
|
213
|
+
concern that only the CLI can describe.
|
|
214
|
+
"""
|
|
215
|
+
from tlgr.schema import build_schema
|
|
216
|
+
|
|
217
|
+
kind = req.path[0] if req.path and req.path[0] in _SCHEMA_KINDS else ""
|
|
218
|
+
path = req.path[1:] if kind else req.path
|
|
219
|
+
|
|
220
|
+
if kind in ("events", "config", "errors", "exit-codes"):
|
|
221
|
+
return {"schema_version": 2, kind.replace("-", "_"): _schema_section(kind)}
|
|
222
|
+
|
|
223
|
+
provider = getattr(ctx, "command_tree", None)
|
|
224
|
+
command = provider(path, req.include_hidden) if callable(provider) else None
|
|
225
|
+
document = build_schema(path=path, command=command, include_hidden=req.include_hidden)
|
|
226
|
+
if kind == "all":
|
|
227
|
+
for section in ("events", "config", "errors", "exit-codes"):
|
|
228
|
+
document[section.replace("-", "_")] = _schema_section(section)
|
|
229
|
+
return document
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _schema_section(kind: str) -> Any:
|
|
233
|
+
"""One non-command schema section, from the module that owns it."""
|
|
234
|
+
from tlgr.models.base import to_builtins
|
|
235
|
+
|
|
236
|
+
if kind == "events":
|
|
237
|
+
from tlgr.core import eventtypes
|
|
238
|
+
|
|
239
|
+
return [
|
|
240
|
+
{
|
|
241
|
+
"type": name,
|
|
242
|
+
"group": spec.group,
|
|
243
|
+
"box": spec.box,
|
|
244
|
+
"summary": spec.summary,
|
|
245
|
+
"sources": list(eventtypes.constructors_for(name)),
|
|
246
|
+
"bot_only": spec.bot_only,
|
|
247
|
+
"since_layer": spec.since_layer,
|
|
248
|
+
"payload": dict(spec.payload),
|
|
249
|
+
}
|
|
250
|
+
for name, spec in sorted(eventtypes.TYPES.items())
|
|
251
|
+
]
|
|
252
|
+
if kind == "config":
|
|
253
|
+
from tlgr.ops.config import KEYS
|
|
254
|
+
|
|
255
|
+
return [to_builtins(key) for key in sorted(KEYS.values(), key=lambda k: k.key)]
|
|
256
|
+
if kind == "errors":
|
|
257
|
+
return [to_builtins(row) for row in _error_table()]
|
|
258
|
+
return {
|
|
259
|
+
name: {"code": int(info["code"]), "description": str(info["description"])}
|
|
260
|
+
for name, info in EXIT_CODE_MAP.items()
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
SPEC_SCHEMA = OperationSpec(
|
|
265
|
+
id="agent.schema",
|
|
266
|
+
request=SchemaReq,
|
|
267
|
+
response=dict,
|
|
268
|
+
impl=schema,
|
|
269
|
+
summary="Print machine-readable schemas: commands, events, config keys, errors",
|
|
270
|
+
description=(
|
|
271
|
+
"One JSON document: the command tree, and for every registered "
|
|
272
|
+
"operation its request and response JSON Schema plus a validated "
|
|
273
|
+
"example. Draft 2020-12. `tlgr schema events`, `schema config`, "
|
|
274
|
+
"`schema errors` and `schema exit-codes` print the other four "
|
|
275
|
+
"vocabularies an agent has to know, and `schema all` prints "
|
|
276
|
+
"everything."
|
|
277
|
+
),
|
|
278
|
+
legacy_paths=("schema",),
|
|
279
|
+
needs_account=False,
|
|
280
|
+
needs_auth=False,
|
|
281
|
+
surface=Surface.LOCAL,
|
|
282
|
+
idempotent=True,
|
|
283
|
+
rate_class="local",
|
|
284
|
+
timeout_s=30,
|
|
285
|
+
example={"schema_version": 2, "build": "2.0.0", "ops": {}},
|
|
286
|
+
covers_partial=("updates.stream-event-types",),
|
|
287
|
+
coverage_note="prints the taxonomy; `events list` is its first-class surface.",
|
|
288
|
+
example_args="schema events",
|
|
289
|
+
tags=frozenset({"infrastructure", "agent-safe", "json-only"}),
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class ParityReq(Request):
|
|
294
|
+
domain: Annotated[
|
|
295
|
+
str | None, opt("--domain", metavar="NAME", help="Only this catalog domain.")
|
|
296
|
+
] = None
|
|
297
|
+
priority: Annotated[str | None, choice("P0", "P1", "P2", "P3", help="Only this priority.")] = (
|
|
298
|
+
None
|
|
299
|
+
)
|
|
300
|
+
uncovered: Annotated[
|
|
301
|
+
bool, opt("--uncovered", help="List the uncovered ids and why they are uncovered.")
|
|
302
|
+
] = False
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
async def parity(ctx: OpContext, req: ParityReq) -> dict[str, Any]:
|
|
306
|
+
"""Report catalog coverage, computed from the registry.
|
|
307
|
+
|
|
308
|
+
The number is derived, never asserted: `tlgr.parity` subtracts what every
|
|
309
|
+
`OperationSpec` declares it covers from what the catalog index says
|
|
310
|
+
exists. A waived id stays in the denominator and is reported with the PR
|
|
311
|
+
that closes it, so coverage cannot be improved by moving the goalposts.
|
|
312
|
+
"""
|
|
313
|
+
from tlgr.parity import compute
|
|
314
|
+
|
|
315
|
+
report = compute().to_dict()
|
|
316
|
+
if req.domain:
|
|
317
|
+
report["by_domain"] = {
|
|
318
|
+
name: stats for name, stats in report["by_domain"].items() if name == req.domain
|
|
319
|
+
}
|
|
320
|
+
report["uncovered"] = [u for u in report["uncovered"] if u["domain"] == req.domain]
|
|
321
|
+
if req.priority:
|
|
322
|
+
report["by_priority"] = {
|
|
323
|
+
name: stats for name, stats in report["by_priority"].items() if name == req.priority
|
|
324
|
+
}
|
|
325
|
+
report["uncovered"] = [u for u in report["uncovered"] if u["priority"] == req.priority]
|
|
326
|
+
if not req.uncovered:
|
|
327
|
+
report["uncovered"] = report["uncovered"][:20]
|
|
328
|
+
return report
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
SPEC_PARITY = OperationSpec(
|
|
332
|
+
id="agent.parity",
|
|
333
|
+
request=ParityReq,
|
|
334
|
+
response=dict,
|
|
335
|
+
impl=parity,
|
|
336
|
+
summary="Report feature-parity coverage against the Telegram catalog",
|
|
337
|
+
description=(
|
|
338
|
+
"Coverage is computed, not claimed: every operation declares the "
|
|
339
|
+
"catalog ids it covers and this subtracts them from the index that "
|
|
340
|
+
"ships in the package. `--uncovered` prints the full gap list with "
|
|
341
|
+
"the PR that closes each one."
|
|
342
|
+
),
|
|
343
|
+
needs_account=False,
|
|
344
|
+
needs_auth=False,
|
|
345
|
+
surface=Surface.LOCAL,
|
|
346
|
+
idempotent=True,
|
|
347
|
+
rate_class="local",
|
|
348
|
+
timeout_s=30,
|
|
349
|
+
example={"catalog_version": "2026-09-02", "required": 1797, "covered": 133, "percent": 7.4},
|
|
350
|
+
example_args="agent parity",
|
|
351
|
+
tags=frozenset({"infrastructure", "agent-safe"}),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# ---------------------------------------------------------------------------
|
|
356
|
+
# whoami
|
|
357
|
+
# ---------------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class WhoAmI(Model, omit_defaults=False):
|
|
361
|
+
"""What an agent needs before its first real command.
|
|
362
|
+
|
|
363
|
+
`output_schema_version` is the field to branch on: v2 changed a handful of
|
|
364
|
+
documented shapes (RFC-3339 dates, marked ids, `Page` envelopes, `none` as
|
|
365
|
+
the default parse mode), and a consumer must be able to tell which set it
|
|
366
|
+
is looking at without probing for one of them.
|
|
367
|
+
|
|
368
|
+
`omit_defaults=False` for the whole struct: v1 printed `daemon_running`
|
|
369
|
+
even when it was false, and a consumer that reads `info["daemon_running"]`
|
|
370
|
+
must not get a KeyError because the answer happened to be "no".
|
|
371
|
+
"""
|
|
372
|
+
|
|
373
|
+
#: No default, deliberately: `Model` omits a field equal to its default,
|
|
374
|
+
#: and the one field a consumer branches on must never be absent.
|
|
375
|
+
output_schema_version: int
|
|
376
|
+
account: str = ""
|
|
377
|
+
user_id: int | None = None
|
|
378
|
+
username: str | None = None
|
|
379
|
+
phone: str | None = None
|
|
380
|
+
is_bot: bool = False
|
|
381
|
+
premium: bool = False
|
|
382
|
+
frozen: bool = False
|
|
383
|
+
daemon_running: bool = False
|
|
384
|
+
daemon_healthy: bool | None = None
|
|
385
|
+
daemon_version: str | None = None
|
|
386
|
+
daemon_uptime: int | None = None
|
|
387
|
+
accounts_connected: list[str] = []
|
|
388
|
+
accounts_disconnected: list[str] = []
|
|
389
|
+
active_jobs: list[str] = []
|
|
390
|
+
config_dir: str = ""
|
|
391
|
+
layer: int = 0
|
|
392
|
+
telethon_version: str = ""
|
|
393
|
+
tlgr_version: str = ""
|
|
394
|
+
device_model: str = ""
|
|
395
|
+
app_version: str = ""
|
|
396
|
+
enabled_commands: list[str] = []
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class WhoAmIReq(Request):
|
|
400
|
+
pass
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
async def whoami(ctx: OpContext, req: WhoAmIReq) -> WhoAmI:
|
|
404
|
+
"""Report the active account, the daemon's health and this client's identity.
|
|
405
|
+
|
|
406
|
+
Local, and it must stay local: this is what an agent calls to find out
|
|
407
|
+
that the daemon is *not* running, so needing the daemon to answer would
|
|
408
|
+
make the question unanswerable.
|
|
409
|
+
|
|
410
|
+
`layer` is reported because it is the honest bound on everything else: a
|
|
411
|
+
build on Telethon's layer 227 meets constructors from layer 229 in the
|
|
412
|
+
wild and cannot parse them, and an agent that knows the number can predict
|
|
413
|
+
which features will be missing instead of discovering them one failure at
|
|
414
|
+
a time.
|
|
415
|
+
"""
|
|
416
|
+
from tlgr import __version__
|
|
417
|
+
from tlgr.core.accounts import AccountManager
|
|
418
|
+
from tlgr.core.identity import load_identity
|
|
419
|
+
from tlgr.core.paths import default_base
|
|
420
|
+
|
|
421
|
+
base = default_base()
|
|
422
|
+
manager = AccountManager(base)
|
|
423
|
+
alias = ctx.account or manager.get_active() or ""
|
|
424
|
+
account = manager.get_account(alias) if alias else None
|
|
425
|
+
|
|
426
|
+
info = WhoAmI(
|
|
427
|
+
output_schema_version=2,
|
|
428
|
+
account=alias,
|
|
429
|
+
user_id=account.user_id if account else None,
|
|
430
|
+
username=account.username if account else None,
|
|
431
|
+
phone=account.phone if account else None,
|
|
432
|
+
frozen=bool(account and account.health.state == "frozen"),
|
|
433
|
+
config_dir=str(base),
|
|
434
|
+
layer=_layer(),
|
|
435
|
+
telethon_version=_telethon_version(),
|
|
436
|
+
tlgr_version=__version__,
|
|
437
|
+
)
|
|
438
|
+
with contextlib.suppress(Exception):
|
|
439
|
+
identity = load_identity(base)
|
|
440
|
+
info.device_model = identity.device_model
|
|
441
|
+
info.app_version = identity.app_version
|
|
442
|
+
|
|
443
|
+
enabled = getattr(ctx, "enable_commands", "") or ""
|
|
444
|
+
if enabled:
|
|
445
|
+
info.enabled_commands = [part.strip() for part in enabled.split(",") if part.strip()]
|
|
446
|
+
|
|
447
|
+
status = _daemon_status()
|
|
448
|
+
if status is None:
|
|
449
|
+
return info
|
|
450
|
+
|
|
451
|
+
daemon = status.get("daemon", {})
|
|
452
|
+
info.daemon_running = True
|
|
453
|
+
info.daemon_version = daemon.get("version")
|
|
454
|
+
info.daemon_uptime = daemon.get("uptime_s")
|
|
455
|
+
rows = status.get("accounts", []) or []
|
|
456
|
+
info.accounts_connected = sorted(
|
|
457
|
+
str(row.get("alias", "")) for row in rows if row.get("state") == "online"
|
|
458
|
+
)
|
|
459
|
+
info.accounts_disconnected = sorted(
|
|
460
|
+
str(row.get("alias", "")) for row in rows if row.get("state") != "online"
|
|
461
|
+
)
|
|
462
|
+
# `healthy` is about the accounts, not about the process: v1 reported every
|
|
463
|
+
# client the daemon held as connected, so a fully deaf daemon looked fine.
|
|
464
|
+
info.daemon_healthy = bool(daemon.get("ready")) and not info.accounts_disconnected
|
|
465
|
+
info.active_jobs = [
|
|
466
|
+
str(job.get("name", "")) for job in (status.get("jobs") or []) if job.get("running")
|
|
467
|
+
]
|
|
468
|
+
for row in rows:
|
|
469
|
+
if row.get("alias") == alias:
|
|
470
|
+
info.user_id = row.get("user_id") or info.user_id
|
|
471
|
+
info.username = row.get("username") or info.username
|
|
472
|
+
info.frozen = row.get("state") == "frozen"
|
|
473
|
+
return info
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _telethon_layer() -> int:
|
|
477
|
+
return _layer()
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _layer() -> int:
|
|
481
|
+
with contextlib.suppress(Exception):
|
|
482
|
+
from telethon.tl.alltlobjects import LAYER
|
|
483
|
+
|
|
484
|
+
return int(LAYER)
|
|
485
|
+
return 0
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _telethon_version() -> str:
|
|
489
|
+
with contextlib.suppress(Exception):
|
|
490
|
+
from tlgr.core.telethon_compat import telethon_version
|
|
491
|
+
|
|
492
|
+
return telethon_version()
|
|
493
|
+
return ""
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _probe() -> dict[str, Any] | None:
|
|
497
|
+
"""`/v1/status`, or None. Never starts a daemon to answer for it."""
|
|
498
|
+
return _daemon_status()
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _daemon_status() -> dict[str, Any] | None:
|
|
502
|
+
"""`/v1/status`, never starting a daemon to answer a question about it."""
|
|
503
|
+
from tlgr.core.paths import default_base
|
|
504
|
+
from tlgr.transport.client import DaemonClient
|
|
505
|
+
|
|
506
|
+
client = DaemonClient(default_base(), timeout=2.0, auto_start=False, no_restart=True)
|
|
507
|
+
with contextlib.suppress(Exception):
|
|
508
|
+
return client.probe_status()
|
|
509
|
+
return None
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
SPEC_WHOAMI = OperationSpec(
|
|
513
|
+
id="agent.whoami",
|
|
514
|
+
request=WhoAmIReq,
|
|
515
|
+
response=WhoAmI,
|
|
516
|
+
impl=whoami,
|
|
517
|
+
summary="Report the active account, daemon health and client identity",
|
|
518
|
+
description=(
|
|
519
|
+
"The first call an agent should make. `output_schema_version` says "
|
|
520
|
+
"which output contract it is talking to, and `layer` says how far "
|
|
521
|
+
"behind Telegram's current schema this build is."
|
|
522
|
+
),
|
|
523
|
+
legacy_paths=("agent whoami",),
|
|
524
|
+
needs_account=False,
|
|
525
|
+
needs_auth=False,
|
|
526
|
+
needs_client=False,
|
|
527
|
+
surface=Surface.LOCAL,
|
|
528
|
+
idempotent=True,
|
|
529
|
+
rate_class="local",
|
|
530
|
+
timeout_s=30,
|
|
531
|
+
example={
|
|
532
|
+
"output_schema_version": 2,
|
|
533
|
+
"account": "work",
|
|
534
|
+
"user_id": 777,
|
|
535
|
+
"daemon_running": True,
|
|
536
|
+
"daemon_healthy": True,
|
|
537
|
+
"layer": 227,
|
|
538
|
+
"tlgr_version": "2.0.0",
|
|
539
|
+
},
|
|
540
|
+
example_args="agent whoami",
|
|
541
|
+
covers_partial=("updates.invoke-init-connection", "updates.invoke-with-layer"),
|
|
542
|
+
coverage_note=(
|
|
543
|
+
"reports the identity and layer this build declares; setting them is "
|
|
544
|
+
"`config set`, and `status` reports the connection."
|
|
545
|
+
),
|
|
546
|
+
tags=frozenset({"agent-safe"}),
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
# ---------------------------------------------------------------------------
|
|
551
|
+
# completion
|
|
552
|
+
# ---------------------------------------------------------------------------
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
class CompletionScript(Model):
|
|
556
|
+
shell: str = ""
|
|
557
|
+
text: str = ""
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
class CompletionReq(Request):
|
|
561
|
+
shell: Annotated[
|
|
562
|
+
str,
|
|
563
|
+
arg(0, metavar="SHELL", help="bash, zsh or fish."),
|
|
564
|
+
]
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
_COMPLETION_RC = {
|
|
568
|
+
"bash": "~/.bashrc",
|
|
569
|
+
"zsh": "~/.zshrc",
|
|
570
|
+
"fish": "~/.config/fish/completions/tlgr.fish",
|
|
571
|
+
}
|
|
572
|
+
_COMPLETION_SOURCE = {
|
|
573
|
+
"bash": 'eval "$(_TLGR_COMPLETE=bash_source tlgr)"',
|
|
574
|
+
"zsh": 'eval "$(_TLGR_COMPLETE=zsh_source tlgr)"',
|
|
575
|
+
"fish": "_TLGR_COMPLETE=fish_source tlgr | source",
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
async def completion(ctx: OpContext, req: CompletionReq) -> CompletionScript:
|
|
580
|
+
"""Print the shell completion script, exactly as v1 printed it.
|
|
581
|
+
|
|
582
|
+
Click generates the completion itself; what this emits is the line that
|
|
583
|
+
asks it to, plus where to put it. Tagged `text` so the human and plain
|
|
584
|
+
forms are the script and nothing else — a table of one long cell would
|
|
585
|
+
be unpasteable.
|
|
586
|
+
"""
|
|
587
|
+
shell = req.shell.strip().lower()
|
|
588
|
+
if shell not in _COMPLETION_SOURCE:
|
|
589
|
+
raise UsageError(f"unknown shell {req.shell!r}: one of bash, zsh, fish", field="shell")
|
|
590
|
+
line = _COMPLETION_SOURCE[shell]
|
|
591
|
+
return CompletionScript(
|
|
592
|
+
shell=shell, text=f"# Add to {_COMPLETION_RC[shell]}:\n# {line}\n\n{line}"
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
SPEC_COMPLETION = OperationSpec(
|
|
597
|
+
id="agent.completion",
|
|
598
|
+
request=CompletionReq,
|
|
599
|
+
response=CompletionScript,
|
|
600
|
+
impl=completion,
|
|
601
|
+
summary="Print the shell completion script for bash, zsh or fish",
|
|
602
|
+
aliases=("completion",),
|
|
603
|
+
legacy_paths=("completion",),
|
|
604
|
+
needs_account=False,
|
|
605
|
+
needs_auth=False,
|
|
606
|
+
surface=Surface.LOCAL,
|
|
607
|
+
idempotent=True,
|
|
608
|
+
rate_class="local",
|
|
609
|
+
timeout_s=5,
|
|
610
|
+
columns=("shell",),
|
|
611
|
+
example={"shell": "bash", "text": '# Add to ~/.bashrc:\n# eval "$(…)"'},
|
|
612
|
+
example_args="completion bash",
|
|
613
|
+
tags=frozenset({"infrastructure", "agent-safe", "text"}),
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
# ---------------------------------------------------------------------------
|
|
618
|
+
# capabilities
|
|
619
|
+
# ---------------------------------------------------------------------------
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
class Capabilities(Model):
|
|
623
|
+
"""What this build can do, cannot do, and will not do.
|
|
624
|
+
|
|
625
|
+
The third list is the one that matters. "Cannot" is a gap somebody may
|
|
626
|
+
close; "will not" is a decision, and an agent that cannot tell them apart
|
|
627
|
+
will keep asking for the second kind for ever.
|
|
628
|
+
"""
|
|
629
|
+
|
|
630
|
+
layer: int = 0
|
|
631
|
+
telethon_version: str = ""
|
|
632
|
+
tlgr_version: str = ""
|
|
633
|
+
event_types: int = 0
|
|
634
|
+
unsupported_constructors: list[str] = []
|
|
635
|
+
secret_chats: str = ""
|
|
636
|
+
pfs: str = ""
|
|
637
|
+
calls_media: str = ""
|
|
638
|
+
push: str = ""
|
|
639
|
+
presence_policy: str = ""
|
|
640
|
+
read_receipt_policy: str = ""
|
|
641
|
+
prohibited: list[dict[str, str]] = []
|
|
642
|
+
premium_gated: list[str] = []
|
|
643
|
+
bot_only: list[str] = []
|
|
644
|
+
admin_only: list[str] = []
|
|
645
|
+
limits: dict[str, Any] = {}
|
|
646
|
+
operations: int = 0
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
class CapabilitiesReq(Request):
|
|
650
|
+
section: Annotated[
|
|
651
|
+
str | None,
|
|
652
|
+
choice("protocol", "policy", "gates", "events", "limits", help="Restrict the report."),
|
|
653
|
+
] = None
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
#: Things tlgr will not do, and why. Not a list of missing features: each of
|
|
657
|
+
#: these is a decision, and an agent that reads it stops asking.
|
|
658
|
+
_PROHIBITED: tuple[tuple[str, str], ...] = (
|
|
659
|
+
(
|
|
660
|
+
"fake a read receipt",
|
|
661
|
+
"Not calling messages.readHistory is fine — you simply did not read it. "
|
|
662
|
+
"Reading and then suppressing the receipt violates api terms 1.4.",
|
|
663
|
+
),
|
|
664
|
+
(
|
|
665
|
+
"suppress typing status",
|
|
666
|
+
"Same clause. tlgr sends setTyping when it is composing and never lies about it.",
|
|
667
|
+
),
|
|
668
|
+
(
|
|
669
|
+
"misrepresent online status",
|
|
670
|
+
"'Ghost mode' is explicitly forbidden by api terms 1.4. presence.mode defaults to "
|
|
671
|
+
"'off', which announces nothing rather than claiming to be offline while reading.",
|
|
672
|
+
),
|
|
673
|
+
(
|
|
674
|
+
"pass an integrity attestation",
|
|
675
|
+
"invokeWithGooglePlayIntegrity, invokeWithApnsSecret and invokeWithReCaptcha are "
|
|
676
|
+
"device-attestation flows. tlgr reports the demand and stops rather than "
|
|
677
|
+
"impersonating a phone.",
|
|
678
|
+
),
|
|
679
|
+
(
|
|
680
|
+
"execute a payment",
|
|
681
|
+
"Buying stars, paying an invoice, bidding in a gift auction and withdrawing "
|
|
682
|
+
"revenue are financial actions a person performs, not an agent.",
|
|
683
|
+
),
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
_PREMIUM_GATED = (
|
|
687
|
+
"voice transcription",
|
|
688
|
+
"saved-message reaction tags",
|
|
689
|
+
"story stealth mode",
|
|
690
|
+
"uploading notification sounds",
|
|
691
|
+
"larger upload limits and folder counts",
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
_BOT_ONLY = (
|
|
695
|
+
"inline queries and callback answers",
|
|
696
|
+
"shipping and pre-checkout answers",
|
|
697
|
+
"business-connection messages",
|
|
698
|
+
"chat-boost updates",
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
_ADMIN_ONLY = (
|
|
702
|
+
"channel participant updates for other users",
|
|
703
|
+
"pending join requests",
|
|
704
|
+
"the admin log",
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
async def capabilities(ctx: OpContext, req: CapabilitiesReq) -> Capabilities:
|
|
709
|
+
"""The honest-limits report, meant to be read before planning.
|
|
710
|
+
|
|
711
|
+
Everything here is derived or fixed, never guessed: the layer and the
|
|
712
|
+
unparseable constructors come from the event taxonomy, the operation count
|
|
713
|
+
from the registry, and the policy entries are the decisions recorded in
|
|
714
|
+
ARCHITECTURE and the API terms.
|
|
715
|
+
"""
|
|
716
|
+
from tlgr import __version__
|
|
717
|
+
from tlgr.core import eventtypes
|
|
718
|
+
from tlgr.registry import REGISTRY
|
|
719
|
+
|
|
720
|
+
report = Capabilities(
|
|
721
|
+
layer=_layer(),
|
|
722
|
+
telethon_version=_telethon_version(),
|
|
723
|
+
tlgr_version=__version__,
|
|
724
|
+
event_types=len(eventtypes.TYPES),
|
|
725
|
+
unsupported_constructors=sorted(eventtypes.NEWER_THAN_LAYER_227),
|
|
726
|
+
operations=len(REGISTRY),
|
|
727
|
+
secret_chats=(
|
|
728
|
+
"envelope only: Telethon implements no MTProto 2.0 end-to-end layer, so tlgr "
|
|
729
|
+
"can report that encrypted traffic exists and acknowledge the qts, and cannot "
|
|
730
|
+
"read or send it"
|
|
731
|
+
),
|
|
732
|
+
pfs=(
|
|
733
|
+
"not implemented: auth.bindTempAuthKey needs changes inside Telethon's "
|
|
734
|
+
"MTProtoSender. The practical mitigation is protecting the session file, which "
|
|
735
|
+
"is written 0600 and audited at start"
|
|
736
|
+
),
|
|
737
|
+
calls_media=(
|
|
738
|
+
"signalling only: ring, accept, reject and hang up work; carrying the audio or "
|
|
739
|
+
"video stream needs tgcalls and is out of scope for a CLI"
|
|
740
|
+
),
|
|
741
|
+
push=(
|
|
742
|
+
"not registered: the daemon holds a socket, so it has no need of push. "
|
|
743
|
+
"`tlgr events decode --push` reads a payload a phone relayed"
|
|
744
|
+
),
|
|
745
|
+
presence_policy=(
|
|
746
|
+
"presence.mode defaults to 'off': tlgr announces nothing rather than claiming "
|
|
747
|
+
"to be offline while reading"
|
|
748
|
+
),
|
|
749
|
+
read_receipt_policy=(
|
|
750
|
+
"never faked: reading without calling messages.readHistory is allowed, "
|
|
751
|
+
"suppressing a receipt after reading is not"
|
|
752
|
+
),
|
|
753
|
+
prohibited=[{"action": action, "reason": reason} for action, reason in _PROHIBITED],
|
|
754
|
+
premium_gated=list(_PREMIUM_GATED),
|
|
755
|
+
bot_only=list(_BOT_ONLY),
|
|
756
|
+
admin_only=list(_ADMIN_ONLY),
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
if req.section:
|
|
760
|
+
return _section(report, req.section)
|
|
761
|
+
return report
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _section(report: Capabilities, section: str) -> Capabilities:
|
|
765
|
+
"""Blank everything outside the requested section, keeping the shape."""
|
|
766
|
+
keep = {
|
|
767
|
+
"protocol": {"layer", "telethon_version", "tlgr_version", "unsupported_constructors"},
|
|
768
|
+
"policy": {"prohibited", "presence_policy", "read_receipt_policy"},
|
|
769
|
+
"gates": {"premium_gated", "bot_only", "admin_only"},
|
|
770
|
+
"events": {"event_types", "unsupported_constructors"},
|
|
771
|
+
"limits": {"limits", "operations"},
|
|
772
|
+
}[section]
|
|
773
|
+
trimmed = Capabilities()
|
|
774
|
+
for field in keep:
|
|
775
|
+
setattr(trimmed, field, getattr(report, field))
|
|
776
|
+
return trimmed
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
SPEC_CAPABILITIES = OperationSpec(
|
|
780
|
+
id="agent.capabilities",
|
|
781
|
+
request=CapabilitiesReq,
|
|
782
|
+
response=Capabilities,
|
|
783
|
+
impl=capabilities,
|
|
784
|
+
summary="Report what this build can do, cannot do, and will not do",
|
|
785
|
+
description=(
|
|
786
|
+
"Three different things, deliberately separated. `unsupported_*` is "
|
|
787
|
+
"what this Telethon layer cannot parse; `premium_gated`/`bot_only`/"
|
|
788
|
+
"`admin_only` is what this *account* may not reach; `prohibited` is "
|
|
789
|
+
"what tlgr refuses on purpose, with the reason. Only the first is a "
|
|
790
|
+
"gap somebody might close."
|
|
791
|
+
),
|
|
792
|
+
needs_account=False,
|
|
793
|
+
needs_auth=False,
|
|
794
|
+
needs_client=False,
|
|
795
|
+
surface=Surface.LOCAL,
|
|
796
|
+
idempotent=True,
|
|
797
|
+
rate_class="local",
|
|
798
|
+
timeout_s=30,
|
|
799
|
+
example={
|
|
800
|
+
"layer": 227,
|
|
801
|
+
"tlgr_version": "2.0.0",
|
|
802
|
+
"event_types": 114,
|
|
803
|
+
"prohibited": [{"action": "fake a read receipt", "reason": "api terms 1.4"}],
|
|
804
|
+
},
|
|
805
|
+
example_args="agent capabilities --section policy",
|
|
806
|
+
covers=("updates.session-pfs", "updates.sync-disable-updates"),
|
|
807
|
+
covers_partial=(
|
|
808
|
+
"updates.invoke-with-layer",
|
|
809
|
+
"updates.ops-single-updates-consumer",
|
|
810
|
+
"updates.presence-read-receipts-policy",
|
|
811
|
+
"updates.sync-old-layer-socket-reset",
|
|
812
|
+
),
|
|
813
|
+
coverage_note=(
|
|
814
|
+
"states the policy and the layer bound; the switches themselves are "
|
|
815
|
+
"`config set`, and recovery is `daemon reconnect`."
|
|
816
|
+
),
|
|
817
|
+
tags=frozenset({"agent-safe"}),
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
# ---------------------------------------------------------------------------
|
|
822
|
+
# status — the one-screen summary
|
|
823
|
+
# ---------------------------------------------------------------------------
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
class HealthReq(Request):
|
|
827
|
+
check: Annotated[bool, opt("--check", help="Exit non-zero when anything is unhealthy.")] = False
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
async def account_status(ctx: OpContext, req: HealthReq) -> HealthSummary:
|
|
831
|
+
"""One screen: account, connection, sync lag, daemon, floods.
|
|
832
|
+
|
|
833
|
+
Deliberately the union of several groups rather than a link to them. The
|
|
834
|
+
states it surfaces — frozen, terms not accepted, an unconfirmed new login,
|
|
835
|
+
a flood the account still owes — are the ones in which *every other
|
|
836
|
+
command* starts failing, and a user whose sends are being refused should
|
|
837
|
+
not have to know which of five nouns to ask first.
|
|
838
|
+
"""
|
|
839
|
+
from tlgr.core.accounts import AccountManager
|
|
840
|
+
from tlgr.core.paths import default_base
|
|
841
|
+
|
|
842
|
+
base = default_base()
|
|
843
|
+
manager = AccountManager(base)
|
|
844
|
+
alias = ctx.account or manager.get_active() or ""
|
|
845
|
+
account = manager.get_account(alias) if alias else None
|
|
846
|
+
|
|
847
|
+
summary = HealthSummary(
|
|
848
|
+
account=alias,
|
|
849
|
+
user_id=account.user_id if account else None,
|
|
850
|
+
username=account.username if account else None,
|
|
851
|
+
layer=_telethon_layer(),
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
status = _probe()
|
|
855
|
+
if status is None:
|
|
856
|
+
summary.problems.append("the daemon is not running (tlgr daemon start)")
|
|
857
|
+
if req.check:
|
|
858
|
+
raise DaemonNotRunningError("the daemon is not answering on its socket")
|
|
859
|
+
return summary
|
|
860
|
+
|
|
861
|
+
daemon = status.get("daemon", {})
|
|
862
|
+
summary.daemon_running = True
|
|
863
|
+
summary.jobs_running = len([j for j in (status.get("jobs") or []) if j.get("running")])
|
|
864
|
+
summary.webhook_enabled = bool((status.get("webhook") or {}).get("enabled"))
|
|
865
|
+
|
|
866
|
+
rows = [row for row in (status.get("accounts") or []) if not alias or row.get("alias") == alias]
|
|
867
|
+
row = rows[0] if rows else {}
|
|
868
|
+
state = str(row.get("state", "unknown"))
|
|
869
|
+
summary.authorized = state not in ("needs_login", "unknown", "not_connected")
|
|
870
|
+
summary.connected = state == "online"
|
|
871
|
+
summary.dc_id = row.get("dc_id")
|
|
872
|
+
summary.proxy = row.get("proxy")
|
|
873
|
+
summary.behind_seconds = row.get("behind_seconds")
|
|
874
|
+
summary.flood_waits = int(row.get("flood_entries") or 0)
|
|
875
|
+
summary.daemon_healthy = bool(daemon.get("ready")) and state == "online"
|
|
876
|
+
|
|
877
|
+
if state == "needs_login":
|
|
878
|
+
summary.problems.append(f"{alias} needs to log in again (tlgr account add)")
|
|
879
|
+
if state == "frozen":
|
|
880
|
+
summary.frozen = {"state": "frozen", "reason": row.get("reason") or ""}
|
|
881
|
+
summary.problems.append(
|
|
882
|
+
f"{alias} is frozen by Telegram; see `tlgr config app get --frozen` for the appeal link"
|
|
883
|
+
)
|
|
884
|
+
if str(row.get("circuit", "closed")) != "closed":
|
|
885
|
+
summary.problems.append(
|
|
886
|
+
f"the send circuit breaker is open for {alias}: {row.get('circuit_reason') or 'spam flagged'}"
|
|
887
|
+
)
|
|
888
|
+
if summary.flood_waits:
|
|
889
|
+
summary.problems.append(
|
|
890
|
+
f"{summary.flood_waits} rate-limit deadline(s) outstanding (tlgr daemon flood list)"
|
|
891
|
+
)
|
|
892
|
+
if not daemon.get("ready"):
|
|
893
|
+
summary.problems.append("the daemon is running but not ready")
|
|
894
|
+
|
|
895
|
+
if req.check and summary.problems:
|
|
896
|
+
raise DaemonError("; ".join(summary.problems))
|
|
897
|
+
return summary
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
SPEC_STATUS = OperationSpec(
|
|
901
|
+
id="agent.status",
|
|
902
|
+
request=HealthReq,
|
|
903
|
+
response=HealthSummary,
|
|
904
|
+
impl=account_status,
|
|
905
|
+
summary="One-screen health summary: account, connection, sync lag, daemon, floods",
|
|
906
|
+
description=(
|
|
907
|
+
"The union of several groups on purpose. A frozen account, an open "
|
|
908
|
+
"circuit breaker, an outstanding flood deadline and a daemon that is "
|
|
909
|
+
"up but not ready are the states in which every *other* command "
|
|
910
|
+
"starts failing, and `--check` turns them into an exit code a monitor "
|
|
911
|
+
"can read."
|
|
912
|
+
),
|
|
913
|
+
legacy_paths=("status",),
|
|
914
|
+
needs_account=False,
|
|
915
|
+
needs_auth=False,
|
|
916
|
+
needs_client=False,
|
|
917
|
+
surface=Surface.LOCAL,
|
|
918
|
+
idempotent=True,
|
|
919
|
+
rate_class="local",
|
|
920
|
+
timeout_s=30,
|
|
921
|
+
columns=("account", "connected", "daemon_healthy", "behind_seconds", "problems"),
|
|
922
|
+
example={
|
|
923
|
+
"account": "work",
|
|
924
|
+
"authorized": True,
|
|
925
|
+
"connected": True,
|
|
926
|
+
"daemon_running": True,
|
|
927
|
+
"daemon_healthy": True,
|
|
928
|
+
"layer": 227,
|
|
929
|
+
},
|
|
930
|
+
example_args="status --check",
|
|
931
|
+
covers=("updates.invoke-with-layer",),
|
|
932
|
+
covers_partial=("updates.config-account-frozen", "updates.net-flood-wait"),
|
|
933
|
+
coverage_note=(
|
|
934
|
+
"surfaces the states; the detail is `config app get --frozen` and `daemon flood list`."
|
|
935
|
+
),
|
|
936
|
+
tags=frozenset({"agent-safe"}),
|
|
937
|
+
)
|