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.
Files changed (192) hide show
  1. tlgr/__init__.py +3 -0
  2. tlgr/__main__.py +6 -0
  3. tlgr/actions/__init__.py +45 -0
  4. tlgr/actions/forward.py +74 -0
  5. tlgr/actions/reply.py +32 -0
  6. tlgr/cli/__init__.py +259 -0
  7. tlgr/cli/confirm.py +55 -0
  8. tlgr/cli/errors.py +84 -0
  9. tlgr/cli/gen.py +690 -0
  10. tlgr/cli/globals.py +273 -0
  11. tlgr/cli/introspect.py +170 -0
  12. tlgr/cli/params.py +189 -0
  13. tlgr/cli/render.py +418 -0
  14. tlgr/core/__init__.py +0 -0
  15. tlgr/core/accounts.py +384 -0
  16. tlgr/core/config.py +358 -0
  17. tlgr/core/custom_tl.py +170 -0
  18. tlgr/core/errors.py +687 -0
  19. tlgr/core/eventtypes.py +1170 -0
  20. tlgr/core/identity.py +127 -0
  21. tlgr/core/launchd.py +122 -0
  22. tlgr/core/logging.py +194 -0
  23. tlgr/core/media.py +134 -0
  24. tlgr/core/output.py +251 -0
  25. tlgr/core/pagination.py +227 -0
  26. tlgr/core/paths.py +360 -0
  27. tlgr/core/peers.py +427 -0
  28. tlgr/core/process.py +138 -0
  29. tlgr/core/signing.py +38 -0
  30. tlgr/core/systemd.py +96 -0
  31. tlgr/core/telethon_compat.py +295 -0
  32. tlgr/core/text.py +211 -0
  33. tlgr/core/timefmt.py +199 -0
  34. tlgr/core/tl.py +98 -0
  35. tlgr/daemon/__init__.py +0 -0
  36. tlgr/daemon/app.py +869 -0
  37. tlgr/daemon/dispatch.py +446 -0
  38. tlgr/daemon/events.py +723 -0
  39. tlgr/daemon/files.py +431 -0
  40. tlgr/daemon/idle.py +119 -0
  41. tlgr/daemon/jobs.py +68 -0
  42. tlgr/daemon/main.py +161 -0
  43. tlgr/daemon/peercred.py +75 -0
  44. tlgr/daemon/policy.py +113 -0
  45. tlgr/daemon/preauth.py +366 -0
  46. tlgr/daemon/ratelimit.py +391 -0
  47. tlgr/daemon/server.py +24 -0
  48. tlgr/daemon/session.py +648 -0
  49. tlgr/daemon/sessions.py +274 -0
  50. tlgr/daemon/singleton.py +114 -0
  51. tlgr/daemon/stream.py +193 -0
  52. tlgr/daemon/transfers.py +219 -0
  53. tlgr/daemon/webhook.py +390 -0
  54. tlgr/data/catalog_index.json +1 -0
  55. tlgr/data/parity_waivers.toml +90 -0
  56. tlgr/filters/__init__.py +42 -0
  57. tlgr/filters/compose.py +121 -0
  58. tlgr/filters/content.py +85 -0
  59. tlgr/filters/context.py +114 -0
  60. tlgr/filters/message.py +161 -0
  61. tlgr/filters/temporal.py +87 -0
  62. tlgr/filters/user.py +36 -0
  63. tlgr/gateway/__init__.py +1 -0
  64. tlgr/gateway/config.py +161 -0
  65. tlgr/gateway/engine.py +215 -0
  66. tlgr/gateway/event.py +22 -0
  67. tlgr/jobs/__init__.py +0 -0
  68. tlgr/jobs/base.py +81 -0
  69. tlgr/jobs/client.py +37 -0
  70. tlgr/models/__init__.py +1220 -0
  71. tlgr/models/admin.py +744 -0
  72. tlgr/models/auth.py +510 -0
  73. tlgr/models/base.py +81 -0
  74. tlgr/models/bot.py +576 -0
  75. tlgr/models/business.py +265 -0
  76. tlgr/models/call.py +586 -0
  77. tlgr/models/config.py +101 -0
  78. tlgr/models/contact.py +481 -0
  79. tlgr/models/daemon.py +336 -0
  80. tlgr/models/dialog.py +626 -0
  81. tlgr/models/envelope.py +68 -0
  82. tlgr/models/error.py +30 -0
  83. tlgr/models/event.py +79 -0
  84. tlgr/models/export.py +66 -0
  85. tlgr/models/gift.py +275 -0
  86. tlgr/models/inline.py +84 -0
  87. tlgr/models/location.py +115 -0
  88. tlgr/models/media.py +507 -0
  89. tlgr/models/message.py +584 -0
  90. tlgr/models/net.py +232 -0
  91. tlgr/models/notify.py +105 -0
  92. tlgr/models/page.py +32 -0
  93. tlgr/models/payment.py +172 -0
  94. tlgr/models/peer.py +400 -0
  95. tlgr/models/poll.py +119 -0
  96. tlgr/models/premium.py +161 -0
  97. tlgr/models/privacy.py +93 -0
  98. tlgr/models/profile.py +217 -0
  99. tlgr/models/reaction.py +160 -0
  100. tlgr/models/resolve.py +175 -0
  101. tlgr/models/settings.py +103 -0
  102. tlgr/models/stars.py +101 -0
  103. tlgr/models/sticker.py +243 -0
  104. tlgr/models/story.py +467 -0
  105. tlgr/models/sync.py +105 -0
  106. tlgr/models/todo.py +36 -0
  107. tlgr/models/webapp.py +89 -0
  108. tlgr/ops/__init__.py +63 -0
  109. tlgr/ops/_admin.py +313 -0
  110. tlgr/ops/_auth.py +599 -0
  111. tlgr/ops/_bots.py +586 -0
  112. tlgr/ops/_calls.py +535 -0
  113. tlgr/ops/_common.py +160 -0
  114. tlgr/ops/_layer.py +46 -0
  115. tlgr/ops/_media.py +592 -0
  116. tlgr/ops/_params.py +212 -0
  117. tlgr/ops/_rights.py +402 -0
  118. tlgr/ops/_send.py +593 -0
  119. tlgr/ops/_serialize.py +667 -0
  120. tlgr/ops/_settings.py +306 -0
  121. tlgr/ops/_spec.py +167 -0
  122. tlgr/ops/_story.py +743 -0
  123. tlgr/ops/account.py +2604 -0
  124. tlgr/ops/agent.py +937 -0
  125. tlgr/ops/auth.py +1282 -0
  126. tlgr/ops/bot.py +4880 -0
  127. tlgr/ops/business.py +1520 -0
  128. tlgr/ops/call.py +1610 -0
  129. tlgr/ops/chat.py +4025 -0
  130. tlgr/ops/chat_admin.py +929 -0
  131. tlgr/ops/chat_extra.py +1061 -0
  132. tlgr/ops/chat_invite.py +716 -0
  133. tlgr/ops/chat_manage.py +1691 -0
  134. tlgr/ops/chat_member.py +1357 -0
  135. tlgr/ops/chat_stats.py +902 -0
  136. tlgr/ops/chat_topic.py +905 -0
  137. tlgr/ops/conference.py +791 -0
  138. tlgr/ops/config.py +1698 -0
  139. tlgr/ops/contact.py +2330 -0
  140. tlgr/ops/daemon.py +1397 -0
  141. tlgr/ops/draft.py +299 -0
  142. tlgr/ops/emoji.py +343 -0
  143. tlgr/ops/events.py +1327 -0
  144. tlgr/ops/export.py +596 -0
  145. tlgr/ops/folder.py +1322 -0
  146. tlgr/ops/gif.py +522 -0
  147. tlgr/ops/gift.py +1546 -0
  148. tlgr/ops/giveaway.py +541 -0
  149. tlgr/ops/inline.py +773 -0
  150. tlgr/ops/job.py +799 -0
  151. tlgr/ops/location.py +917 -0
  152. tlgr/ops/media.py +4495 -0
  153. tlgr/ops/message.py +3769 -0
  154. tlgr/ops/net.py +536 -0
  155. tlgr/ops/notify.py +840 -0
  156. tlgr/ops/passport.py +464 -0
  157. tlgr/ops/payment.py +907 -0
  158. tlgr/ops/poll.py +1078 -0
  159. tlgr/ops/premium.py +488 -0
  160. tlgr/ops/privacy.py +794 -0
  161. tlgr/ops/profile.py +1481 -0
  162. tlgr/ops/proxy.py +750 -0
  163. tlgr/ops/reaction.py +1475 -0
  164. tlgr/ops/resolve.py +1140 -0
  165. tlgr/ops/search.py +521 -0
  166. tlgr/ops/settings.py +1066 -0
  167. tlgr/ops/stars.py +594 -0
  168. tlgr/ops/sticker.py +1602 -0
  169. tlgr/ops/story.py +3216 -0
  170. tlgr/ops/sync.py +788 -0
  171. tlgr/ops/todo.py +514 -0
  172. tlgr/ops/user.py +1406 -0
  173. tlgr/ops/vc.py +2351 -0
  174. tlgr/ops/webapp.py +717 -0
  175. tlgr/ops/webhook.py +418 -0
  176. tlgr/parity.py +386 -0
  177. tlgr/processors/__init__.py +125 -0
  178. tlgr/processors/regex.py +26 -0
  179. tlgr/processors/text.py +56 -0
  180. tlgr/registry.py +519 -0
  181. tlgr/schema.py +173 -0
  182. tlgr/transport/__init__.py +30 -0
  183. tlgr/transport/autostart.py +293 -0
  184. tlgr/transport/client.py +805 -0
  185. tlgr/transport/ndjson.py +44 -0
  186. tlgr/version.py +31 -0
  187. tlgr_cli-2.0.1.dist-info/METADATA +957 -0
  188. tlgr_cli-2.0.1.dist-info/RECORD +192 -0
  189. tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
  190. tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
  191. tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
  192. 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
+ )