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/config.py ADDED
@@ -0,0 +1,1698 @@
1
+ """The `config` group: this installation's settings, and Telegram's own.
2
+
3
+ Four different things wear the word "config", and keeping them apart is most
4
+ of this module's job:
5
+
6
+ * `config get/set/list/keys/path/init/validate` — **local** settings, in
7
+ `config.toml`. Identity, transport, proxy, flood budget, presence policy,
8
+ event buffer.
9
+ * `config server get` — Telegram's MTProto configuration (`help.getConfig`):
10
+ `message_length_max`, `edit_time_limit`, the DC list.
11
+ * `config app get` — the client configuration (`help.getAppConfig`): the
12
+ limits and kill switches almost every feature is gated on, plus the
13
+ account-freeze fields that turn a bare `FROZEN_METHOD_INVALID` into
14
+ something actionable.
15
+ * `config info get`, `config country list`, `config promo get`,
16
+ `config suggestion list` — the flat read-only `help.*` endpoints.
17
+
18
+ v1 had nine documented keys and parsed the file with `raw.get(x, default)` at
19
+ every call site, so a typo was silently the default. `config keys` is now
20
+ machine-readable and `config set` validates against it, which is the
21
+ difference between "tlgr ignored your setting" and an error naming the key.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import contextlib
27
+ from typing import Annotated, Any
28
+
29
+ from tlgr.core.errors import EXIT_EMPTY, NotFoundError, UsageError
30
+ from tlgr.core.pagination import PageKind, build_page
31
+ from tlgr.models.base import Request
32
+ from tlgr.models.config import (
33
+ ConfigEntry,
34
+ ConfigKey,
35
+ ConfigPaths,
36
+ ConfigValue,
37
+ InitResult,
38
+ ValidationIssue,
39
+ ValidationReport,
40
+ )
41
+ from tlgr.models.net import (
42
+ AppConfigDoc,
43
+ Country,
44
+ CountryCode,
45
+ DcOption,
46
+ InfoTopic,
47
+ PromoData,
48
+ ServerConfig,
49
+ )
50
+ from tlgr.models.page import Page
51
+ from tlgr.ops._params import arg, choice, opt
52
+ from tlgr.ops._spec import OpContext, OperationSpec, Surface
53
+
54
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # The key catalogue (§10.2)
59
+ # ---------------------------------------------------------------------------
60
+
61
+
62
+ def _key(
63
+ key: str,
64
+ section: str,
65
+ field: str,
66
+ type_: str,
67
+ default: Any,
68
+ help_: str,
69
+ *,
70
+ scope: str = "global",
71
+ restart: bool = False,
72
+ secret: bool = False,
73
+ choices: tuple[str, ...] = (),
74
+ ) -> tuple[ConfigKey, tuple[str, str]]:
75
+ return (
76
+ ConfigKey(
77
+ key=key,
78
+ type=type_,
79
+ default=default,
80
+ scope=scope,
81
+ section=section,
82
+ requires_restart=restart,
83
+ secret=secret,
84
+ help=help_,
85
+ choices=list(choices),
86
+ ),
87
+ (section, field),
88
+ )
89
+
90
+
91
+ #: Every documented knob, machine-readable so an agent can discover them
92
+ #: without reading prose. `requires_restart` is not decoration: an identity or
93
+ #: transport key only takes effect on the next `initConnection`, and a `set`
94
+ #: that pretended otherwise would be a lie about what happened.
95
+ _CATALOGUE: tuple[tuple[ConfigKey, tuple[str, str]], ...] = (
96
+ # -- identity: what Settings → Devices shows the user ------------------
97
+ _key(
98
+ "client.device_model",
99
+ "identity",
100
+ "device_model",
101
+ "string",
102
+ "",
103
+ "Device name shown in Settings → Devices. Must be honest: it is how a "
104
+ "user recognises — and safely terminates — the tlgr session.",
105
+ restart=True,
106
+ ),
107
+ _key(
108
+ "client.system_version",
109
+ "identity",
110
+ "system_version",
111
+ "string",
112
+ "",
113
+ "System version sent in initConnection.",
114
+ restart=True,
115
+ ),
116
+ _key(
117
+ "client.lang_code",
118
+ "identity",
119
+ "lang_code",
120
+ "string",
121
+ "",
122
+ "Language tlgr asks the server to localise its own error and service messages into.",
123
+ restart=True,
124
+ ),
125
+ _key(
126
+ "client.system_lang_code",
127
+ "identity",
128
+ "system_lang_code",
129
+ "string",
130
+ "",
131
+ "System language sent in initConnection.",
132
+ restart=True,
133
+ ),
134
+ _key(
135
+ "client.tz_offset",
136
+ "identity",
137
+ "tz_offset",
138
+ "bool",
139
+ True,
140
+ "Send this host's UTC offset in initConnection params. Business hours "
141
+ "and scheduled-message display depend on it.",
142
+ restart=True,
143
+ ),
144
+ # -- network -----------------------------------------------------------
145
+ _key(
146
+ "net.proxy",
147
+ "network",
148
+ "proxy",
149
+ "string",
150
+ "",
151
+ "Active proxy URL. Prefer `tlgr proxy set`, which reconnects for you.",
152
+ restart=True,
153
+ secret=True,
154
+ ),
155
+ _key(
156
+ "net.ipv6",
157
+ "network",
158
+ "ipv6",
159
+ "bool",
160
+ False,
161
+ "Connect over IPv6. One argument; matters on IPv6-only hosts.",
162
+ restart=True,
163
+ ),
164
+ _key(
165
+ "net.connect_timeout",
166
+ "network",
167
+ "connect_timeout",
168
+ "int",
169
+ 10,
170
+ "Seconds to wait for a connection before giving up.",
171
+ restart=True,
172
+ ),
173
+ _key(
174
+ "net.connection",
175
+ "network",
176
+ "connection",
177
+ "string",
178
+ "tcp_full",
179
+ "MTProto transport: tcp_full, tcp_abridged, tcp_intermediate, "
180
+ "tcp_obfuscated or http. Obfuscated helps on hostile networks.",
181
+ restart=True,
182
+ choices=("tcp_full", "tcp_abridged", "tcp_intermediate", "tcp_obfuscated", "http"),
183
+ ),
184
+ # -- daemon ------------------------------------------------------------
185
+ _key("daemon.auto_start", "daemon", "auto_start", "bool", True, "Start the daemon on CLI use."),
186
+ _key(
187
+ "daemon.log_level",
188
+ "daemon",
189
+ "log_level",
190
+ "string",
191
+ "info",
192
+ "Daemon log level.",
193
+ choices=("debug", "info", "warning", "error"),
194
+ ),
195
+ _key(
196
+ "daemon.idle_timeout",
197
+ "daemon",
198
+ "idle_timeout",
199
+ "int",
200
+ 1800,
201
+ "Seconds of inactivity before the daemon stops; 0 disables. An idle "
202
+ "stop with catch-up off is a permanent sync hole.",
203
+ ),
204
+ _key(
205
+ "daemon.event_buffer",
206
+ "daemon",
207
+ "event_buffer",
208
+ "int",
209
+ 4096,
210
+ "Events kept per account for `--since` replay. Older ones produce a "
211
+ "`gap` frame rather than silence.",
212
+ restart=True,
213
+ ),
214
+ _key(
215
+ "daemon.event_workers",
216
+ "daemon",
217
+ "event_workers",
218
+ "int",
219
+ 8,
220
+ "Worker lanes the bus dispatches handlers on, keyed by chat.",
221
+ restart=True,
222
+ ),
223
+ _key(
224
+ "daemon.state_save_interval",
225
+ "daemon",
226
+ "state_save_interval",
227
+ "int",
228
+ 60,
229
+ "How often pts/qts and the entity cache are flushed. Telethon only "
230
+ "writes them on a clean disconnect.",
231
+ ),
232
+ _key(
233
+ "daemon.drain_seconds",
234
+ "daemon",
235
+ "drain_seconds",
236
+ "int",
237
+ 30,
238
+ "How long a shutdown waits for in-flight requests.",
239
+ ),
240
+ _key(
241
+ "daemon.resync_depth",
242
+ "daemon",
243
+ "resync_depth",
244
+ "int",
245
+ 50,
246
+ "Messages re-read per channel after a differenceTooLong.",
247
+ ),
248
+ # -- flood -------------------------------------------------------------
249
+ _key(
250
+ "flood.sleep_threshold",
251
+ "flood",
252
+ "sleep_threshold",
253
+ "int",
254
+ 120,
255
+ "Seconds of FLOOD_WAIT tlgr will sleep off inside a request. Longer "
256
+ "waits come back as RATE_LIMITED with the deadline.",
257
+ ),
258
+ _key(
259
+ "flood.max_wait",
260
+ "flood",
261
+ "max_wait",
262
+ "int",
263
+ 600,
264
+ "Ceiling on the sleep threshold, whatever a caller asks for.",
265
+ ),
266
+ _key(
267
+ "flood.persist",
268
+ "flood",
269
+ "persist",
270
+ "bool",
271
+ True,
272
+ "Remember flood deadlines across restarts. Off means a fresh process "
273
+ "re-trips every wait it had already earned.",
274
+ ),
275
+ # -- presence ----------------------------------------------------------
276
+ _key(
277
+ "presence.mode",
278
+ "presence",
279
+ "mode",
280
+ "string",
281
+ "off",
282
+ "off | online | mirror. Default off: tlgr announces nothing rather "
283
+ "than claiming to be offline while reading, which api terms 1.4 "
284
+ "forbids.",
285
+ choices=("off", "online", "mirror"),
286
+ ),
287
+ # -- limits ------------------------------------------------------------
288
+ _key(
289
+ "limits.entity_cache",
290
+ "limits",
291
+ "entity_cache",
292
+ "int",
293
+ 20000,
294
+ "Peers Telethon keeps access hashes for in memory.",
295
+ restart=True,
296
+ ),
297
+ _key(
298
+ "limits.request_retries",
299
+ "limits",
300
+ "request_retries",
301
+ "int",
302
+ 5,
303
+ "Attempts per request before giving up.",
304
+ restart=True,
305
+ ),
306
+ _key(
307
+ "limits.dialog_scan_max",
308
+ "limits",
309
+ "dialog_scan_max",
310
+ "int",
311
+ 5000,
312
+ "How many dialogs a peer resolution will scan before answering "
313
+ "INDETERMINATE rather than 'not found'.",
314
+ ),
315
+ _key(
316
+ "limits.max_album",
317
+ "limits",
318
+ "max_album",
319
+ "int",
320
+ 10,
321
+ "Maximum files in one album.",
322
+ ),
323
+ # -- defaults ----------------------------------------------------------
324
+ _key(
325
+ "defaults.output",
326
+ "defaults",
327
+ "output",
328
+ "string",
329
+ "human",
330
+ "Default output mode.",
331
+ choices=("human", "json", "plain"),
332
+ ),
333
+ _key(
334
+ "defaults.parse_mode",
335
+ "defaults",
336
+ "parse_mode",
337
+ "string",
338
+ "none",
339
+ "Default message parse mode. `none` because v1's markdown default "
340
+ "silently ate underscores and asterisks in ordinary text.",
341
+ choices=("none", "md", "html"),
342
+ ),
343
+ _key(
344
+ "defaults.require_account",
345
+ "defaults",
346
+ "require_account",
347
+ "bool",
348
+ False,
349
+ "Require -a on every command instead of falling back to a default.",
350
+ ),
351
+ _key(
352
+ "defaults.confirm_destructive",
353
+ "defaults",
354
+ "confirm_destructive",
355
+ "bool",
356
+ True,
357
+ "Prompt before a destructive command off a TTY.",
358
+ ),
359
+ _key(
360
+ "defaults.timezone",
361
+ "defaults",
362
+ "timezone",
363
+ "string",
364
+ "",
365
+ "Timezone for human date rendering. Empty means the host's.",
366
+ ),
367
+ _key(
368
+ "defaults.legacy_dates",
369
+ "defaults",
370
+ "legacy_dates",
371
+ "bool",
372
+ False,
373
+ "Print v1's `str(datetime)` spelling instead of RFC-3339.",
374
+ ),
375
+ _key(
376
+ "accounts.default",
377
+ "accounts",
378
+ "default",
379
+ "string",
380
+ "",
381
+ "Account used when -a is not given.",
382
+ ),
383
+ # -- security / policy -------------------------------------------------
384
+ _key(
385
+ "security.require_token",
386
+ "security",
387
+ "require_token",
388
+ "bool",
389
+ False,
390
+ "Require X-Tlgr-Token on the IPC socket as well as the peer-uid check.",
391
+ restart=True,
392
+ ),
393
+ _key(
394
+ "security.peer_uid_check",
395
+ "security",
396
+ "peer_uid_check",
397
+ "bool",
398
+ True,
399
+ "Refuse socket connections from another uid.",
400
+ restart=True,
401
+ ),
402
+ _key(
403
+ "logging.redact",
404
+ "logging",
405
+ "redact",
406
+ "bool",
407
+ True,
408
+ "Redact access hashes, tokens and secrets from the log.",
409
+ ),
410
+ )
411
+
412
+ KEYS: dict[str, ConfigKey] = {entry[0].key: entry[0] for entry in _CATALOGUE}
413
+ _FIELDS: dict[str, tuple[str, str]] = {entry[0].key: entry[1] for entry in _CATALOGUE}
414
+
415
+ #: v1 spelled nine of these without a section prefix. §12.4: a documented name
416
+ #: does not stop working because the catalogue grew a namespace.
417
+ _LEGACY_KEYS: dict[str, str] = {
418
+ "output": "defaults.output",
419
+ "drop_author": "defaults.drop_author",
420
+ "delete_after": "defaults.delete_after",
421
+ "default_account": "accounts.default",
422
+ "require_account": "defaults.require_account",
423
+ "auto_start": "daemon.auto_start",
424
+ "log_level": "daemon.log_level",
425
+ "idle_timeout": "daemon.idle_timeout",
426
+ "flood_wait_max": "flood.sleep_threshold",
427
+ }
428
+
429
+
430
+ def _resolve_key(name: str) -> ConfigKey:
431
+ key = _LEGACY_KEYS.get(name, name)
432
+ found = KEYS.get(key)
433
+ if found is None:
434
+ raise NotFoundError(f"unknown config key {name!r}. Run: tlgr config keys")
435
+ return found
436
+
437
+
438
+ def _paths() -> Any:
439
+ from tlgr.core.paths import TlgrPaths
440
+
441
+ return TlgrPaths()
442
+
443
+
444
+ def _raw() -> dict[str, Any]:
445
+ from tlgr.core.config import _load_toml
446
+
447
+ return _load_toml(_paths().config)
448
+
449
+
450
+ def _write(document: dict[str, Any]) -> None:
451
+ from tlgr.core.config import _save_toml
452
+
453
+ _save_toml(_paths().config, document)
454
+
455
+
456
+ def _stored(document: dict[str, Any], key: ConfigKey) -> tuple[Any, bool]:
457
+ section, field = _FIELDS[key.key]
458
+ block = document.get(section)
459
+ if isinstance(block, dict) and field in block:
460
+ return block[field], True
461
+ return key.default, False
462
+
463
+
464
+ def _coerce(key: ConfigKey, raw: str) -> Any:
465
+ """A CLI string into the key's declared type, or a usage error naming it."""
466
+ if key.type == "bool":
467
+ lowered = raw.strip().lower()
468
+ if lowered in ("true", "yes", "on", "1"):
469
+ return True
470
+ if lowered in ("false", "no", "off", "0"):
471
+ return False
472
+ raise UsageError(f"{key.key} is a boolean; got {raw!r}", field="value")
473
+ if key.type == "int":
474
+ try:
475
+ return int(raw)
476
+ except ValueError as exc:
477
+ raise UsageError(f"{key.key} is an integer; got {raw!r}", field="value") from exc
478
+ if key.choices and raw not in key.choices:
479
+ raise UsageError(
480
+ f"{key.key} must be one of {', '.join(key.choices)}; got {raw!r}", field="value"
481
+ )
482
+ return raw
483
+
484
+
485
+ def _redact(key: ConfigKey, value: Any) -> Any:
486
+ return "<redacted>" if key.secret and value else value
487
+
488
+
489
+ # ---------------------------------------------------------------------------
490
+ # Local settings
491
+ # ---------------------------------------------------------------------------
492
+
493
+
494
+ class ConfigGetReq(Request):
495
+ key: Annotated[str, arg(0, metavar="KEY")]
496
+ source: Annotated[
497
+ bool, opt("--source", help="Also report which file and section the value came from.")
498
+ ] = False
499
+
500
+
501
+ async def config_get(ctx: OpContext, req: ConfigGetReq) -> ConfigValue:
502
+ """Read one local key. Server-side configuration is a different noun."""
503
+ key = _resolve_key(req.key)
504
+ value, present = _stored(_raw(), key)
505
+ return ConfigValue(
506
+ key=key.key,
507
+ value=_redact(key, value),
508
+ default=key.default,
509
+ source=(f"{_paths().config} [{key.section}]" if present else "default")
510
+ if req.source
511
+ else ("file" if present else "default"),
512
+ help=key.help,
513
+ requires_restart=key.requires_restart,
514
+ )
515
+
516
+
517
+ SPEC_CONFIG_GET = OperationSpec(
518
+ id="config.get",
519
+ request=ConfigGetReq,
520
+ response=ConfigValue,
521
+ impl=config_get,
522
+ summary="Read one local configuration key",
523
+ legacy_paths=("config get",),
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=15,
531
+ columns=("key", "value", "source"),
532
+ example={"key": "daemon.idle_timeout", "value": 0, "default": 1800, "source": "file"},
533
+ example_args="config get daemon.idle_timeout",
534
+ tags=frozenset({"infrastructure", "agent-safe"}),
535
+ )
536
+
537
+
538
+ class ConfigSetReq(Request):
539
+ key: Annotated[str, arg(0, metavar="KEY")]
540
+ value: Annotated[str, arg(1, metavar="VALUE")]
541
+ apply: Annotated[
542
+ bool, opt("--apply/--no-apply", help="Ask a running daemon to adopt the change now.")
543
+ ] = True
544
+
545
+
546
+ async def config_set(ctx: OpContext, req: ConfigSetReq) -> ConfigValue:
547
+ """Write one local key, validated against the catalogue.
548
+
549
+ Validation is the point. v1 read the file with `raw.get(key, default)` at
550
+ every call site, so a typo or a wrong type was silently the default and
551
+ the user's setting simply never happened.
552
+ """
553
+ key = _resolve_key(req.key)
554
+ section, field = _FIELDS[key.key]
555
+ value = _coerce(key, req.value)
556
+ document = _raw()
557
+ previous, present = _stored(document, key)
558
+ if present and previous == value:
559
+ ctx.mark_already()
560
+ return ConfigValue(
561
+ key=key.key,
562
+ value=_redact(key, value),
563
+ previous=_redact(key, previous),
564
+ already=True,
565
+ requires_restart=key.requires_restart,
566
+ )
567
+
568
+ document.setdefault(section, {})[field] = value
569
+ _write(document)
570
+
571
+ applied = False
572
+ if req.apply:
573
+ applied = _reload_daemon()
574
+ if key.requires_restart and not applied:
575
+ ctx.warn(f"{key.key} takes effect on the next reconnect: tlgr daemon reconnect")
576
+ return ConfigValue(
577
+ key=key.key,
578
+ value=_redact(key, value),
579
+ previous=_redact(key, previous) if present else None,
580
+ default=key.default,
581
+ updated=True,
582
+ requires_restart=key.requires_restart,
583
+ applied=applied,
584
+ )
585
+
586
+
587
+ def _reload_daemon() -> bool:
588
+ """Ask a running daemon to re-read the file. Absent daemon is not an error."""
589
+ from tlgr.core.paths import default_base
590
+ from tlgr.transport.client import DaemonClient
591
+
592
+ client = DaemonClient(default_base(), timeout=10.0, auto_start=False, no_restart=True)
593
+ with contextlib.suppress(Exception):
594
+ client.admin("reload", {"what": ["config", "policy"]})
595
+ return True
596
+ return False
597
+
598
+
599
+ SPEC_CONFIG_SET = OperationSpec(
600
+ id="config.set",
601
+ request=ConfigSetReq,
602
+ response=ConfigValue,
603
+ impl=config_set,
604
+ summary="Write one local configuration key",
605
+ description=(
606
+ "Validated against `config keys`: a wrong type or an unknown key is "
607
+ "an error naming it, not a silent fallback to the default. Identity "
608
+ "and transport keys only take effect on the next `initConnection`, "
609
+ "and the response says so."
610
+ ),
611
+ legacy_paths=("config set",),
612
+ mutating=True,
613
+ idempotent=True,
614
+ needs_account=False,
615
+ needs_auth=False,
616
+ needs_client=False,
617
+ surface=Surface.LOCAL,
618
+ rate_class="local",
619
+ timeout_s=30,
620
+ example={"key": "daemon.idle_timeout", "value": 0, "previous": 1800, "updated": True},
621
+ example_args="config set daemon.idle_timeout 0",
622
+ covers=(
623
+ "updates.event-report-message-delivery",
624
+ "updates.invoke-init-connection",
625
+ "updates.net-local-addr",
626
+ "updates.net-network-type",
627
+ "updates.net-parallel-connections",
628
+ "updates.net-proxy-for-calls",
629
+ "updates.net-transport-mode",
630
+ "updates.sync-dispatch-ordering",
631
+ ),
632
+ covers_partial=(
633
+ "updates.config-dns-fallback",
634
+ "updates.invoke-client-proxy-declare",
635
+ "updates.invoke-init-params-json",
636
+ "updates.net-flood-wait",
637
+ "updates.net-ipv6",
638
+ "updates.net-proxy-autoswitch",
639
+ "updates.net-proxy-system",
640
+ "updates.net-test-dc",
641
+ "updates.net-timeouts-retries",
642
+ "updates.ops-reconnect-health",
643
+ "updates.ops-single-updates-consumer",
644
+ "updates.presence-keepalive-period",
645
+ "updates.presence-set-online",
646
+ "updates.sync-catch-up-on-start",
647
+ "updates.sync-channel-short-poll",
648
+ "updates.sync-disable-updates",
649
+ "updates.sync-new-session-triggers-diff",
650
+ ),
651
+ coverage_note=(
652
+ "sets the switch; the behaviour it selects belongs to the group that "
653
+ "implements it (`proxy`, `sync`, `net`, `daemon`)."
654
+ ),
655
+ tags=frozenset({"infrastructure", "agent-safe"}),
656
+ )
657
+
658
+
659
+ class ConfigUnsetReq(Request):
660
+ key: Annotated[str, arg(0, metavar="KEY")]
661
+ apply: Annotated[
662
+ bool, opt("--apply/--no-apply", help="Ask a running daemon to adopt the change now.")
663
+ ] = True
664
+
665
+
666
+ async def config_unset(ctx: OpContext, req: ConfigUnsetReq) -> ConfigValue:
667
+ """Remove a key, reverting it to its documented default."""
668
+ key = _resolve_key(req.key)
669
+ section, field = _FIELDS[key.key]
670
+ document = _raw()
671
+ previous, present = _stored(document, key)
672
+ if not present:
673
+ ctx.mark_already()
674
+ return ConfigValue(key=key.key, default=key.default, already=True)
675
+ del document[section][field]
676
+ if not document[section]:
677
+ del document[section]
678
+ _write(document)
679
+ if req.apply:
680
+ _reload_daemon()
681
+ return ConfigValue(
682
+ key=key.key, previous=_redact(key, previous), default=key.default, removed=True
683
+ )
684
+
685
+
686
+ SPEC_CONFIG_UNSET = OperationSpec(
687
+ id="config.unset",
688
+ request=ConfigUnsetReq,
689
+ response=ConfigValue,
690
+ impl=config_unset,
691
+ summary="Remove a local configuration key (revert to its default)",
692
+ legacy_paths=("config unset",),
693
+ mutating=True,
694
+ idempotent=True,
695
+ needs_account=False,
696
+ needs_auth=False,
697
+ needs_client=False,
698
+ surface=Surface.LOCAL,
699
+ rate_class="local",
700
+ timeout_s=30,
701
+ example={"key": "daemon.idle_timeout", "previous": 0, "default": 1800, "removed": True},
702
+ example_args="config unset daemon.idle_timeout",
703
+ tags=frozenset({"infrastructure", "agent-safe"}),
704
+ )
705
+
706
+
707
+ class ConfigListReq(Request):
708
+ section: Annotated[str | None, opt("--section", metavar="NAME", help="Only this section.")] = (
709
+ None
710
+ )
711
+ defaults: Annotated[
712
+ bool, opt("--defaults", help="Include keys still at their default value.")
713
+ ] = False
714
+
715
+
716
+ async def config_list(ctx: OpContext, req: ConfigListReq) -> Page[ConfigEntry]:
717
+ """The effective configuration, with where each value came from.
718
+
719
+ Secrets — proxy passwords, MTProxy secrets, the webhook token, `api_hash`
720
+ — are redacted. `config list` is the command people paste into bug
721
+ reports.
722
+ """
723
+ document = _raw()
724
+ rows: list[ConfigEntry] = []
725
+ for key in KEYS.values():
726
+ if req.section and key.section != req.section and not key.key.startswith(f"{req.section}."):
727
+ continue
728
+ value, present = _stored(document, key)
729
+ if not present and not req.defaults:
730
+ continue
731
+ rows.append(
732
+ ConfigEntry(
733
+ key=key.key,
734
+ value=_redact(key, value),
735
+ default=key.default,
736
+ source="file" if present else "default",
737
+ scope=key.scope,
738
+ )
739
+ )
740
+ return build_page(rows, op="config.list", kind=PageKind.LOCAL, has_more=False, total=len(rows))
741
+
742
+
743
+ SPEC_CONFIG_LIST = OperationSpec(
744
+ id="config.list",
745
+ request=ConfigListReq,
746
+ response=Page[ConfigEntry],
747
+ impl=config_list,
748
+ summary="Show the effective local configuration",
749
+ description="Secrets are redacted: this is the command people paste into bug reports.",
750
+ legacy_paths=("config list",),
751
+ paginated=PageKind.LOCAL,
752
+ needs_account=False,
753
+ needs_auth=False,
754
+ needs_client=False,
755
+ surface=Surface.LOCAL,
756
+ idempotent=True,
757
+ rate_class="local",
758
+ timeout_s=15,
759
+ columns=("key", "value", "source"),
760
+ example={
761
+ "items": [{"key": "daemon.idle_timeout", "value": 0, "source": "file"}],
762
+ "has_more": False,
763
+ },
764
+ example_args="config list --defaults",
765
+ tags=frozenset({"infrastructure", "agent-safe"}),
766
+ )
767
+
768
+
769
+ class ConfigKeysReq(Request):
770
+ section: Annotated[str | None, opt("--section", metavar="NAME", help="Only this section.")] = (
771
+ None
772
+ )
773
+ search: Annotated[
774
+ str | None, opt("--search", metavar="TEXT", help="Substring match on key or help.")
775
+ ] = None
776
+
777
+
778
+ async def config_keys(ctx: OpContext, req: ConfigKeysReq) -> Page[ConfigKey]:
779
+ """Every documented knob, with its type, default and restart requirement.
780
+
781
+ Machine-readable on purpose: an agent that has to discover the knobs from
782
+ prose will get one wrong, and a wrong key was silently the default in v1.
783
+ """
784
+ rows = list(KEYS.values())
785
+ if req.section:
786
+ rows = [
787
+ row
788
+ for row in rows
789
+ if row.section == req.section or row.key.startswith(f"{req.section}.")
790
+ ]
791
+ if req.search:
792
+ needle = req.search.lower()
793
+ rows = [row for row in rows if needle in row.key.lower() or needle in row.help.lower()]
794
+ rows.sort(key=lambda row: row.key)
795
+ return build_page(rows, op="config.keys", kind=PageKind.LOCAL, has_more=False, total=len(rows))
796
+
797
+
798
+ SPEC_CONFIG_KEYS = OperationSpec(
799
+ id="config.keys",
800
+ request=ConfigKeysReq,
801
+ response=Page[ConfigKey],
802
+ impl=config_keys,
803
+ summary="List every documented configuration key with its type and default",
804
+ legacy_paths=("config keys",),
805
+ paginated=PageKind.LOCAL,
806
+ needs_account=False,
807
+ needs_auth=False,
808
+ needs_client=False,
809
+ surface=Surface.LOCAL,
810
+ idempotent=True,
811
+ rate_class="local",
812
+ timeout_s=15,
813
+ columns=("key", "type", "default", "requires_restart", "help"),
814
+ example={
815
+ "items": [
816
+ {
817
+ "key": "presence.mode",
818
+ "type": "string",
819
+ "default": "off",
820
+ "help": "off | online | mirror.",
821
+ }
822
+ ],
823
+ "has_more": False,
824
+ },
825
+ example_args="config keys --section presence",
826
+ covers=(
827
+ "updates.invoke-init-params-json",
828
+ "updates.net-timeouts-retries",
829
+ "updates.presence-read-receipts-policy",
830
+ "updates.presence-set-online",
831
+ ),
832
+ covers_partial=("updates.invoke-init-connection",),
833
+ coverage_note="documents the knobs; writing one is `config set`.",
834
+ tags=frozenset({"infrastructure", "agent-safe"}),
835
+ )
836
+
837
+
838
+ class ConfigPathReq(Request):
839
+ file: Annotated[
840
+ str | None,
841
+ choice(
842
+ "config",
843
+ "jobs",
844
+ "webhook",
845
+ "sessions",
846
+ "logs",
847
+ "dead-letter",
848
+ "socket",
849
+ "pid",
850
+ "secrets",
851
+ help="Print just one path.",
852
+ ),
853
+ ] = None
854
+
855
+
856
+ async def config_path(ctx: OpContext, req: ConfigPathReq) -> ConfigPaths:
857
+ """Where everything lives.
858
+
859
+ The session files and the secrets file are credential material at mode
860
+ 0600: exclude them from backups, and never paste their contents.
861
+ """
862
+ paths = _paths()
863
+ report = ConfigPaths(
864
+ config_dir=str(paths.base),
865
+ config=str(paths.config),
866
+ jobs=str(paths.jobs),
867
+ webhook=str(paths.webhook),
868
+ secrets=str(paths.token),
869
+ sessions=str(paths.accounts),
870
+ logs=str(paths.logs),
871
+ socket=str(paths.socket),
872
+ pid=str(paths.pid),
873
+ dead_letter=str(paths.dead_letter),
874
+ )
875
+ if req.file:
876
+ chosen = {
877
+ "config": report.config,
878
+ "jobs": report.jobs,
879
+ "webhook": report.webhook,
880
+ "sessions": report.sessions,
881
+ "logs": report.logs,
882
+ "dead-letter": report.dead_letter,
883
+ "socket": report.socket,
884
+ "pid": report.pid,
885
+ "secrets": report.secrets,
886
+ }[req.file]
887
+ return ConfigPaths(config_dir=report.config_dir, path=chosen)
888
+ return report
889
+
890
+
891
+ SPEC_CONFIG_PATH = OperationSpec(
892
+ id="config.path",
893
+ request=ConfigPathReq,
894
+ response=ConfigPaths,
895
+ impl=config_path,
896
+ summary="Print the paths of the configuration, jobs, webhook, session and log files",
897
+ legacy_paths=("config path",),
898
+ needs_account=False,
899
+ needs_auth=False,
900
+ needs_client=False,
901
+ surface=Surface.LOCAL,
902
+ idempotent=True,
903
+ rate_class="local",
904
+ timeout_s=15,
905
+ columns=("config_dir", "config", "jobs", "webhook"),
906
+ example={"config_dir": "~/.tlgr", "config": "~/.tlgr/config.toml"},
907
+ example_args="config path --file socket",
908
+ covers_partial=("updates.session-persistence",),
909
+ coverage_note="says where the session lives; persisting it is the session supervisor's.",
910
+ tags=frozenset({"infrastructure", "agent-safe"}),
911
+ )
912
+
913
+
914
+ class ConfigInitReq(Request):
915
+ overwrite: Annotated[bool, opt("--overwrite", help="Replace files that already exist.")] = False
916
+
917
+
918
+ async def config_init(ctx: OpContext, req: ConfigInitReq) -> InitResult:
919
+ """Create the default configuration files, all at mode 0600.
920
+
921
+ v1 wrote all three with `write_text`, world-readable — and `webhook.toml`
922
+ holds a token (SEC-07).
923
+ """
924
+ from tlgr.core.paths import write_private
925
+
926
+ paths = _paths()
927
+ paths.ensure_base()
928
+ created: list[str] = []
929
+ skipped: list[str] = []
930
+ for name, path, body in (
931
+ ("config.toml", paths.config, _DEFAULT_CONFIG),
932
+ ("jobs.yaml", paths.jobs, _DEFAULT_JOBS),
933
+ ("webhook.toml", paths.webhook, _DEFAULT_WEBHOOK),
934
+ ):
935
+ if path.exists() and not req.overwrite:
936
+ skipped.append(name)
937
+ continue
938
+ write_private(path, body)
939
+ created.append(name)
940
+ if not created:
941
+ ctx.mark_already()
942
+ return InitResult(created=created, skipped=skipped, path=str(paths.base))
943
+
944
+
945
+ _DEFAULT_CONFIG = """\
946
+ [defaults]
947
+ output = "human"
948
+ # `none` because v1's markdown default silently ate `_`, `*` and backticks.
949
+ parse_mode = "none"
950
+
951
+ [accounts]
952
+ default = ""
953
+
954
+ [daemon]
955
+ auto_start = true
956
+ log_level = "info"
957
+ # 0 disables the idle stop. An idle stop with catch-up off is a sync hole.
958
+ idle_timeout = 0
959
+
960
+ [presence]
961
+ # tlgr announces nothing rather than claiming to be offline while reading.
962
+ mode = "off"
963
+ """
964
+
965
+ _DEFAULT_JOBS = """\
966
+ # Gateway jobs. Add one non-interactively with:
967
+ # tlgr job add --name NAME --action 'reply:hello'
968
+ jobs: []
969
+ """
970
+
971
+ _DEFAULT_WEBHOOK = """\
972
+ [webhook]
973
+ enabled = false
974
+ url = ""
975
+ # Prefer the HMAC signature over a bearer token: it authenticates the body.
976
+ secret = ""
977
+ events = ["message_new"]
978
+
979
+ [webhook.retry]
980
+ enabled = true
981
+ max_attempts = 5
982
+ backoff_base = 2
983
+
984
+ [webhook.filters]
985
+ chats = []
986
+ """
987
+
988
+
989
+ SPEC_CONFIG_INIT = OperationSpec(
990
+ id="config.init",
991
+ request=ConfigInitReq,
992
+ response=InitResult,
993
+ impl=config_init,
994
+ summary="Create the default configuration files",
995
+ description="Written 0600 through the one writer that chmods before it renames (SEC-07).",
996
+ legacy_paths=("config init",),
997
+ mutating=True,
998
+ idempotent=True,
999
+ needs_account=False,
1000
+ needs_auth=False,
1001
+ needs_client=False,
1002
+ surface=Surface.LOCAL,
1003
+ rate_class="local",
1004
+ timeout_s=30,
1005
+ example={"created": ["config.toml", "jobs.yaml", "webhook.toml"], "path": "~/.tlgr"},
1006
+ example_args="config init",
1007
+ tags=frozenset({"infrastructure", "agent-safe"}),
1008
+ )
1009
+
1010
+
1011
+ class ConfigValidateReq(Request):
1012
+ strict: Annotated[bool, opt("--strict", help="Treat warnings (unknown keys) as errors.")] = (
1013
+ False
1014
+ )
1015
+ file: Annotated[
1016
+ str | None, choice("config", "jobs", "webhook", help="Only validate one file.")
1017
+ ] = None
1018
+
1019
+
1020
+ async def config_validate(ctx: OpContext, req: ConfigValidateReq) -> ValidationReport:
1021
+ """Check the three configuration files before the daemon has to.
1022
+
1023
+ Also checks the *names*: a filter, processor, action or event name nobody
1024
+ registered parses fine and then silently never matches, which is the
1025
+ failure mode this command exists to convert into a message.
1026
+ """
1027
+ from tlgr.core.config import load_app_config, load_webhook_config
1028
+
1029
+ errors: list[ValidationIssue] = []
1030
+ warnings: list[ValidationIssue] = []
1031
+ checked: list[str] = []
1032
+ paths = _paths()
1033
+
1034
+ if req.file in (None, "config"):
1035
+ checked.append("config.toml")
1036
+ try:
1037
+ load_app_config(paths.base)
1038
+ except Exception as exc:
1039
+ errors.append(ValidationIssue(file="config.toml", message=str(exc)))
1040
+ for section, block in _raw().items():
1041
+ if not isinstance(block, dict):
1042
+ continue
1043
+ for field in block:
1044
+ if not any(pair == (section, field) for pair in _FIELDS.values()):
1045
+ warnings.append(
1046
+ ValidationIssue(
1047
+ file="config.toml",
1048
+ key=f"{section}.{field}",
1049
+ message="not a documented key; run `tlgr config keys`",
1050
+ )
1051
+ )
1052
+
1053
+ if req.file in (None, "jobs"):
1054
+ checked.append("jobs.yaml")
1055
+ errors.extend(_validate_jobs(paths.base))
1056
+
1057
+ if req.file in (None, "webhook"):
1058
+ checked.append("webhook.toml")
1059
+ try:
1060
+ webhook = load_webhook_config(paths.base)
1061
+ if webhook.enabled and not webhook.url:
1062
+ errors.append(
1063
+ ValidationIssue(file="webhook.toml", message="enabled but no url is set")
1064
+ )
1065
+ for event in webhook.events:
1066
+ _check_event(event, "webhook.toml", errors)
1067
+ except Exception as exc:
1068
+ errors.append(ValidationIssue(file="webhook.toml", message=str(exc)))
1069
+
1070
+ if req.strict:
1071
+ errors.extend(warnings)
1072
+ warnings = []
1073
+ return ValidationReport(
1074
+ ok=not errors, valid=not errors, files=checked, errors=errors, warnings=warnings
1075
+ )
1076
+
1077
+
1078
+ def _validate_jobs(base: Any) -> list[ValidationIssue]:
1079
+ from tlgr.actions import get_action
1080
+ from tlgr.gateway.config import load_gateway_configs
1081
+
1082
+ issues: list[ValidationIssue] = []
1083
+ try:
1084
+ configs = load_gateway_configs(base)
1085
+ except Exception as exc:
1086
+ return [ValidationIssue(file="jobs.yaml", message=str(exc))]
1087
+ for config in configs:
1088
+ if not config.name:
1089
+ issues.append(ValidationIssue(file="jobs.yaml", message="a job has no `name`"))
1090
+ if not config.actions:
1091
+ issues.append(
1092
+ ValidationIssue(
1093
+ file="jobs.yaml", key=config.name, message="has no actions and would do nothing"
1094
+ )
1095
+ )
1096
+ for action in config.actions:
1097
+ if get_action(action.name) is None:
1098
+ issues.append(
1099
+ ValidationIssue(
1100
+ file="jobs.yaml",
1101
+ key=config.name,
1102
+ message=f"unknown action {action.name!r}",
1103
+ )
1104
+ )
1105
+ for event in config.events:
1106
+ _check_event(event, "jobs.yaml", issues, key=config.name)
1107
+ return issues
1108
+
1109
+
1110
+ def _check_event(name: str, file: str, into: list[ValidationIssue], key: str | None = None) -> None:
1111
+ from tlgr.core import eventtypes
1112
+
1113
+ try:
1114
+ eventtypes.resolve_selectors(name)
1115
+ except Exception:
1116
+ into.append(
1117
+ ValidationIssue(
1118
+ file=file,
1119
+ key=key,
1120
+ message=f"unknown event type {name!r}; run `tlgr events list`",
1121
+ )
1122
+ )
1123
+
1124
+
1125
+ SPEC_CONFIG_VALIDATE = OperationSpec(
1126
+ id="config.validate",
1127
+ request=ConfigValidateReq,
1128
+ response=ValidationReport,
1129
+ impl=config_validate,
1130
+ summary="Validate the configuration, jobs and webhook files",
1131
+ description=(
1132
+ "Names as well as syntax: a filter, action or event name nobody "
1133
+ "registered parses fine and then silently never matches."
1134
+ ),
1135
+ legacy_paths=("config validate",),
1136
+ needs_account=False,
1137
+ needs_auth=False,
1138
+ needs_client=False,
1139
+ surface=Surface.LOCAL,
1140
+ idempotent=True,
1141
+ rate_class="local",
1142
+ timeout_s=30,
1143
+ columns=("ok", "files"),
1144
+ example={"ok": True, "valid": True, "files": ["config.toml", "jobs.yaml", "webhook.toml"]},
1145
+ example_args="config validate --strict",
1146
+ tags=frozenset({"infrastructure", "agent-safe"}),
1147
+ )
1148
+
1149
+
1150
+ # ---------------------------------------------------------------------------
1151
+ # Server-side configuration
1152
+ # ---------------------------------------------------------------------------
1153
+
1154
+
1155
+ def _client(ctx: OpContext) -> Any:
1156
+ client = getattr(ctx, "client", None)
1157
+ if client is None:
1158
+ raise UsageError("this operation needs a connected account")
1159
+ return client
1160
+
1161
+
1162
+ def _tl(value: Any) -> Any:
1163
+ from tlgr.core.tl import tl_to_builtins
1164
+
1165
+ return tl_to_builtins(value)
1166
+
1167
+
1168
+ def _dc_options(config: Any) -> list[DcOption]:
1169
+ out: list[DcOption] = []
1170
+ for option in getattr(config, "dc_options", None) or []:
1171
+ out.append(
1172
+ DcOption(
1173
+ id=int(getattr(option, "id", 0) or 0),
1174
+ ip_address=str(getattr(option, "ip_address", "") or ""),
1175
+ port=int(getattr(option, "port", 0) or 0),
1176
+ ipv6=bool(getattr(option, "ipv6", False)),
1177
+ media_only=bool(getattr(option, "media_only", False)),
1178
+ tcpo_only=bool(getattr(option, "tcpo_only", False)),
1179
+ cdn=bool(getattr(option, "cdn", False)),
1180
+ static=bool(getattr(option, "static", False)),
1181
+ this_port_only=bool(getattr(option, "this_port_only", False)),
1182
+ )
1183
+ )
1184
+ return out
1185
+
1186
+
1187
+ class ConfigServerReq(Request):
1188
+ key: Annotated[
1189
+ list[str], opt("--key", metavar="NAME", help="Print one field (repeatable).")
1190
+ ] = []
1191
+ dc_options: Annotated[bool, opt("--dc-options", help="Include the dc_options array.")] = False
1192
+
1193
+
1194
+ async def config_server_get(ctx: OpContext, req: ConfigServerReq) -> ServerConfig:
1195
+ """`help.getConfig` — the server's own limits and endpoints.
1196
+
1197
+ Feeding `message_length_max` into `message send` is what avoids a
1198
+ MESSAGE_TOO_LONG round trip; `online_update_period_ms` is what
1199
+ `presence.mode = online` refreshes on instead of a hard-coded minute.
1200
+ """
1201
+ from telethon.tl import functions
1202
+
1203
+ from tlgr.core.timefmt import fmt_dt, to_unix
1204
+
1205
+ config = await _client(ctx)(functions.help.GetConfigRequest())
1206
+ date = getattr(config, "date", None)
1207
+ report = ServerConfig(
1208
+ expires=fmt_dt(getattr(config, "expires", None)),
1209
+ test_mode=bool(getattr(config, "test_mode", False)),
1210
+ this_dc=int(getattr(config, "this_dc", 0) or 0),
1211
+ date=fmt_dt(date),
1212
+ date_unix=to_unix(date),
1213
+ chat_size_max=int(getattr(config, "chat_size_max", 0) or 0),
1214
+ megagroup_size_max=int(getattr(config, "megagroup_size_max", 0) or 0),
1215
+ message_length_max=int(getattr(config, "message_length_max", 0) or 0),
1216
+ caption_length_max=int(getattr(config, "caption_length_max", 0) or 0),
1217
+ online_update_period_ms=int(getattr(config, "online_update_period_ms", 0) or 0),
1218
+ offline_blur_timeout_ms=int(getattr(config, "offline_blur_timeout_ms", 0) or 0),
1219
+ offline_idle_timeout_ms=int(getattr(config, "offline_idle_timeout_ms", 0) or 0),
1220
+ edit_time_limit=int(getattr(config, "edit_time_limit", 0) or 0),
1221
+ revoke_time_limit=int(getattr(config, "revoke_time_limit", 0) or 0),
1222
+ rating_e_decay=int(getattr(config, "rating_e_decay", 0) or 0),
1223
+ forwarded_count_max=int(getattr(config, "forwarded_count_max", 0) or 0),
1224
+ push_chat_period_ms=int(getattr(config, "push_chat_period_ms", 0) or 0),
1225
+ dc_options=_dc_options(config) if req.dc_options else [],
1226
+ )
1227
+ if req.key:
1228
+ whole = _tl(config)
1229
+ report.values = {
1230
+ name: whole.get(name) for name in req.key if isinstance(whole, dict) and name in whole
1231
+ }
1232
+ missing = [name for name in req.key if name not in report.values]
1233
+ if missing:
1234
+ ctx.warn(f"help.getConfig has no field(s): {', '.join(missing)}")
1235
+ return report
1236
+
1237
+
1238
+ SPEC_CONFIG_SERVER = OperationSpec(
1239
+ id="config.server.get",
1240
+ request=ConfigServerReq,
1241
+ response=ServerConfig,
1242
+ impl=config_server_get,
1243
+ summary="Read the MTProto server configuration (help.getConfig)",
1244
+ aliases=("net.config",),
1245
+ surface=Surface.DAEMON,
1246
+ idempotent=True,
1247
+ rate_class="read",
1248
+ timeout_s=30,
1249
+ columns=("this_dc", "message_length_max", "edit_time_limit", "revoke_time_limit"),
1250
+ example={"this_dc": 4, "message_length_max": 4096, "edit_time_limit": 172800},
1251
+ example_args="config server get --dc-options",
1252
+ covers=("updates.config-mtproto", "updates.presence-keepalive-period"),
1253
+ covers_partial=("updates.config-dc-options",),
1254
+ coverage_note="reads the config; enumerating the endpoints is `net dc list`.",
1255
+ tags=frozenset({"agent-safe"}),
1256
+ )
1257
+
1258
+
1259
+ class ConfigAppReq(Request):
1260
+ prefix: Annotated[
1261
+ str | None,
1262
+ arg(0, metavar="KEY", required=False, help="Dotted key or prefix to filter."),
1263
+ ] = None
1264
+ frozen: Annotated[bool, opt("--frozen", help="Print only the account-freeze fields.")] = False
1265
+ include_config: Annotated[bool, opt("--config", help="Also include help.getConfig.")] = False
1266
+
1267
+
1268
+ async def config_app_get(ctx: OpContext, req: ConfigAppReq) -> AppConfigDoc:
1269
+ """`help.getAppConfig` — the limits and kill switches everything is gated on.
1270
+
1271
+ The freeze fields are why this is not merely diagnostic: without
1272
+ `freeze_since_date`, `freeze_until_date` and `freeze_appeal_url`, a frozen
1273
+ account produces a bare `FROZEN_METHOD_INVALID` on every send and nothing
1274
+ that tells the user what to do about it.
1275
+ """
1276
+ from telethon.tl import functions
1277
+
1278
+ from tlgr.core.timefmt import fmt_unix
1279
+
1280
+ result = await _client(ctx)(functions.help.GetAppConfigRequest(hash=0))
1281
+ if type(result).__name__ == "HelpAppConfigNotModified":
1282
+ return AppConfigDoc(not_modified=True)
1283
+
1284
+ values = _tl(getattr(result, "config", None))
1285
+ flat = _flatten_json_object(values)
1286
+ report = AppConfigDoc(hash=int(getattr(result, "hash", 0) or 0), values=flat)
1287
+
1288
+ for field, target in (
1289
+ ("freeze_since_date", "freeze_since_date"),
1290
+ ("freeze_until_date", "freeze_until_date"),
1291
+ ):
1292
+ raw = flat.get(field)
1293
+ if isinstance(raw, (int, float)):
1294
+ setattr(report, target, fmt_unix(int(raw)))
1295
+ appeal = flat.get("freeze_appeal_url")
1296
+ if isinstance(appeal, str):
1297
+ report.freeze_appeal_url = appeal
1298
+
1299
+ if req.frozen:
1300
+ report.values = {k: v for k, v in flat.items() if k.startswith("freeze_")}
1301
+ elif req.prefix:
1302
+ report.values = {k: v for k, v in flat.items() if k.startswith(req.prefix)}
1303
+ if not report.values:
1304
+ raise NotFoundError(f"no app-config key starts with {req.prefix!r}")
1305
+
1306
+ if req.include_config:
1307
+ report.config = await config_server_get(ctx, ConfigServerReq(dc_options=True))
1308
+ return report
1309
+
1310
+
1311
+ def _flatten_json_object(value: Any) -> dict[str, Any]:
1312
+ """A TL `JsonObject` tree → a flat `{key: value}` dict.
1313
+
1314
+ `help.getAppConfig` returns a JSON document encoded as TL objects; leaving
1315
+ it in that shape would make every consumer walk `{"_": "JsonObjectValue",
1316
+ "key": …, "value": {"_": "JsonString", "value": …}}` by hand.
1317
+ """
1318
+ out: dict[str, Any] = {}
1319
+ for entry in (value or {}).get("value", []) if isinstance(value, dict) else []:
1320
+ if not isinstance(entry, dict):
1321
+ continue
1322
+ key = entry.get("key")
1323
+ if not isinstance(key, str):
1324
+ continue
1325
+ out[key] = _json_value(entry.get("value"))
1326
+ return out
1327
+
1328
+
1329
+ def _json_value(node: Any) -> Any:
1330
+ if not isinstance(node, dict):
1331
+ return node
1332
+ kind = node.get("_")
1333
+ if kind == "JsonNull":
1334
+ return None
1335
+ if kind == "JsonArray":
1336
+ return [_json_value(item) for item in node.get("value", []) or []]
1337
+ if kind == "JsonObject":
1338
+ return _flatten_json_object(node)
1339
+ return node.get("value")
1340
+
1341
+
1342
+ SPEC_CONFIG_APP = OperationSpec(
1343
+ id="config.app.get",
1344
+ request=ConfigAppReq,
1345
+ response=AppConfigDoc,
1346
+ impl=config_app_get,
1347
+ summary="Read the client (app) configuration (help.getAppConfig)",
1348
+ description=(
1349
+ "Almost every feature in Telegram has a limit or a kill switch here. "
1350
+ "`--frozen` prints the account-freeze fields, which are what turn a "
1351
+ "bare FROZEN_METHOD_INVALID into an actionable message."
1352
+ ),
1353
+ aliases=("settings.app-config",),
1354
+ surface=Surface.DAEMON,
1355
+ idempotent=True,
1356
+ rate_class="read",
1357
+ timeout_s=30,
1358
+ example={"hash": 1834712, "values": {"reactions_user_max_default": 1}},
1359
+ example_args="config app get --frozen",
1360
+ covers=("account.app-config", "updates.config-account-frozen", "updates.config-app"),
1361
+ tags=frozenset({"agent-safe"}),
1362
+ )
1363
+
1364
+
1365
+ # ---------------------------------------------------------------------------
1366
+ # The flat help.* endpoints
1367
+ # ---------------------------------------------------------------------------
1368
+
1369
+ _INFO_TOPICS = (
1370
+ "support",
1371
+ "invite-text",
1372
+ "premium-promo",
1373
+ "peer-colors",
1374
+ "timezones",
1375
+ "languages",
1376
+ "cdn",
1377
+ "recent-links",
1378
+ "emoji-keywords",
1379
+ "deep-link",
1380
+ )
1381
+
1382
+
1383
+ class ConfigInfoReq(Request):
1384
+ topic: Annotated[str, choice(*_INFO_TOPICS, help="Which endpoint to read.")]
1385
+ value: Annotated[
1386
+ str | None,
1387
+ arg(
1388
+ 0,
1389
+ metavar="VALUE",
1390
+ required=False,
1391
+ help="The tg:// link for deep-link, the query for emoji-keywords.",
1392
+ ),
1393
+ ] = None
1394
+ lang: Annotated[
1395
+ str | None, opt("--lang", metavar="CODE", help="Language where the endpoint takes one.")
1396
+ ] = None
1397
+ search: Annotated[
1398
+ str | None, opt("--search", metavar="TEXT", help="Filter the returned list.")
1399
+ ] = None
1400
+
1401
+
1402
+ async def config_info_get(ctx: OpContext, req: ConfigInfoReq) -> InfoTopic:
1403
+ """One of the flat read-only `help.*` endpoints.
1404
+
1405
+ One command rather than ten thin siblings, because none of them carries an
1406
+ option of its own. What they are *for* differs, though: `timezones` feeds
1407
+ business hours, `peer-colors` ids are required by `account.updateColor`,
1408
+ `languages` exists to choose `lang_code` (tlgr does not localise its own
1409
+ output), and `premium-promo` prints prices — subscribing is a payment a
1410
+ person performs.
1411
+ """
1412
+ from telethon.tl import functions
1413
+
1414
+ client = _client(ctx)
1415
+ lang = req.lang or "en"
1416
+ request, items_key = _info_request(req, lang, functions)
1417
+ result = await client(request)
1418
+ body = _tl(result)
1419
+ items = body.get(items_key) if isinstance(body, dict) and items_key else None
1420
+ rows = [row for row in (items or []) if isinstance(row, dict)]
1421
+ if req.search:
1422
+ needle = req.search.lower()
1423
+ rows = [row for row in rows if needle in str(row).lower()]
1424
+ topic = InfoTopic(topic=req.topic, items=rows, raw=body if isinstance(body, dict) else {})
1425
+ if not rows and not topic.raw:
1426
+ raise NotFoundError(f"{req.topic} returned nothing")
1427
+ return topic
1428
+
1429
+
1430
+ def _info_request(req: ConfigInfoReq, lang: str, functions: Any) -> tuple[Any, str]:
1431
+ """The request for a topic, and the field its list lives in."""
1432
+ if req.topic == "support":
1433
+ return functions.help.GetSupportRequest(), ""
1434
+ if req.topic == "invite-text":
1435
+ return functions.help.GetInviteTextRequest(), ""
1436
+ if req.topic == "premium-promo":
1437
+ return functions.help.GetPremiumPromoRequest(), "period_options"
1438
+ if req.topic == "peer-colors":
1439
+ return functions.help.GetPeerColorsRequest(hash=0), "colors"
1440
+ if req.topic == "timezones":
1441
+ return functions.help.GetTimezonesListRequest(hash=0), "timezones"
1442
+ if req.topic == "cdn":
1443
+ return functions.help.GetCdnConfigRequest(), "public_keys"
1444
+ if req.topic == "recent-links":
1445
+ return functions.help.GetRecentMeUrlsRequest(referer=""), "urls"
1446
+ if req.topic == "languages":
1447
+ return functions.langpack.GetLanguagesRequest(lang_pack=""), ""
1448
+ if req.topic == "emoji-keywords":
1449
+ return functions.messages.GetEmojiKeywordsRequest(lang_code=lang), "keywords"
1450
+ if req.topic == "deep-link":
1451
+ if not req.value:
1452
+ raise UsageError("deep-link needs the tg:// link as its argument", field="value")
1453
+ return functions.help.GetDeepLinkInfoRequest(path=_deep_link_path(req.value)), ""
1454
+ raise UsageError(f"unknown topic {req.topic!r}", field="topic")
1455
+
1456
+
1457
+ def _deep_link_path(link: str) -> str:
1458
+ """`tg://resolve?domain=x` → `resolve?domain=x`, which is what the API wants."""
1459
+ return link.removeprefix("tg://").removeprefix("https://t.me/").lstrip("/")
1460
+
1461
+
1462
+ SPEC_CONFIG_INFO = OperationSpec(
1463
+ id="config.info.get",
1464
+ request=ConfigInfoReq,
1465
+ response=InfoTopic,
1466
+ impl=config_info_get,
1467
+ summary="Read one of the server's flat informational endpoints",
1468
+ surface=Surface.DAEMON,
1469
+ idempotent=True,
1470
+ rate_class="read",
1471
+ timeout_s=60,
1472
+ empty_exit=EXIT_EMPTY,
1473
+ example={"topic": "timezones", "items": [{"id": "Europe/London", "utc_offset": 0}]},
1474
+ example_args="config info get timezones",
1475
+ covers=(
1476
+ "updates.config-cdn",
1477
+ "updates.config-deep-link-info",
1478
+ "updates.config-emoji-keywords",
1479
+ "updates.config-invite-text",
1480
+ "updates.config-peer-colors",
1481
+ "updates.config-premium-promo",
1482
+ "updates.config-recent-me-urls",
1483
+ "updates.config-support",
1484
+ "updates.config-timezones",
1485
+ ),
1486
+ tags=frozenset({"agent-safe"}),
1487
+ )
1488
+
1489
+
1490
+ # ---------------------------------------------------------------------------
1491
+ # Countries
1492
+ # ---------------------------------------------------------------------------
1493
+
1494
+ #: ISO-3166 alpha-2 → the regional-indicator pair that renders as its flag.
1495
+ #: A pure client-side derivation: TDLib's getCountryFlagEmoji has no MTProto
1496
+ #: counterpart, and neither does the preferred-language hint.
1497
+ _FLAG_BASE = 0x1F1E6
1498
+
1499
+
1500
+ def _flag(iso2: str) -> str:
1501
+ if len(iso2) != 2 or not iso2.isalpha():
1502
+ return ""
1503
+ return "".join(chr(_FLAG_BASE + ord(char.upper()) - ord("A")) for char in iso2)
1504
+
1505
+
1506
+ class CountryListReq(Request):
1507
+ code: Annotated[str | None, opt("--code", metavar="ISO2", help="One country by ISO code.")] = (
1508
+ None
1509
+ )
1510
+ search: Annotated[
1511
+ str | None, opt("--search", metavar="TEXT", help="Match on country name.")
1512
+ ] = None
1513
+ phone: Annotated[
1514
+ str | None,
1515
+ opt("--phone", metavar="NUMBER", help="Classify a number: country, prefix, validity."),
1516
+ ] = None
1517
+ lang: Annotated[
1518
+ str | None, opt("--lang", metavar="CODE", help="Language for the localised names.")
1519
+ ] = None
1520
+
1521
+
1522
+ async def country_list(ctx: OpContext, req: CountryListReq) -> Page[Country]:
1523
+ """Countries, phone prefixes and number patterns.
1524
+
1525
+ `--phone` is the reason to have it: validating a number *before* `tlgr
1526
+ login` turns a wasted `auth.sendCode` — and the flood budget it costs —
1527
+ into a local error.
1528
+ """
1529
+ from telethon.tl import functions
1530
+
1531
+ result = await _client(ctx)(
1532
+ functions.help.GetCountriesListRequest(lang_code=req.lang or "", hash=0)
1533
+ )
1534
+ rows: list[Country] = []
1535
+ digits = "".join(char for char in (req.phone or "") if char.isdigit())
1536
+
1537
+ for entry in getattr(result, "countries", None) or []:
1538
+ iso2 = str(getattr(entry, "iso2", "") or "")
1539
+ codes = [
1540
+ CountryCode(
1541
+ country_code=str(getattr(code, "country_code", "") or ""),
1542
+ prefixes=[str(p) for p in (getattr(code, "prefixes", None) or [])],
1543
+ patterns=[str(p) for p in (getattr(code, "patterns", None) or [])],
1544
+ )
1545
+ for code in getattr(entry, "country_codes", None) or []
1546
+ ]
1547
+ country = Country(
1548
+ iso2=iso2,
1549
+ name=str(getattr(entry, "name", "") or getattr(entry, "default_name", "") or ""),
1550
+ default_name=str(getattr(entry, "default_name", "") or ""),
1551
+ hidden=bool(getattr(entry, "hidden", False)),
1552
+ flag_emoji=_flag(iso2),
1553
+ preferred_language=iso2.lower(),
1554
+ codes=codes,
1555
+ )
1556
+ if digits:
1557
+ matched = _match_phone(digits, codes)
1558
+ if matched is None:
1559
+ continue
1560
+ country.matched_prefix, country.valid = matched
1561
+ elif (req.code and iso2.upper() != req.code.upper()) or (
1562
+ req.search and req.search.lower() not in country.name.lower()
1563
+ ):
1564
+ continue
1565
+ rows.append(country)
1566
+
1567
+ if digits and not rows:
1568
+ raise NotFoundError(f"no country claims the prefix of {req.phone!r}")
1569
+ limit = int(getattr(ctx, "limit", None) or 300)
1570
+ return build_page(
1571
+ rows[:limit],
1572
+ op="config.country.list",
1573
+ kind=PageKind.LOCAL,
1574
+ has_more=len(rows) > limit,
1575
+ total=len(rows),
1576
+ )
1577
+
1578
+
1579
+ def _match_phone(digits: str, codes: list[CountryCode]) -> tuple[str, bool] | None:
1580
+ """The longest matching dial prefix, and whether the rest fits a pattern."""
1581
+ best: tuple[str, bool] | None = None
1582
+ for code in codes:
1583
+ if not digits.startswith(code.country_code):
1584
+ continue
1585
+ rest = digits[len(code.country_code) :]
1586
+ prefixes = code.prefixes or [""]
1587
+ for prefix in prefixes:
1588
+ if not rest.startswith(prefix):
1589
+ continue
1590
+ valid = not code.patterns or any(
1591
+ len(rest) == len(pattern.replace(" ", "")) for pattern in code.patterns
1592
+ )
1593
+ candidate = (code.country_code + prefix, valid)
1594
+ if best is None or len(candidate[0]) > len(best[0]):
1595
+ best = candidate
1596
+ return best
1597
+
1598
+
1599
+ SPEC_COUNTRY_LIST = OperationSpec(
1600
+ id="config.country.list",
1601
+ request=CountryListReq,
1602
+ response=Page[Country],
1603
+ impl=country_list,
1604
+ summary="List or look up countries, phone prefixes and number patterns",
1605
+ description=(
1606
+ "`--phone` classifies a number locally, which turns a wasted "
1607
+ "`auth.sendCode` into an error before it costs the flood budget. The "
1608
+ "flag emoji and the preferred language are derived client-side: "
1609
+ "neither has an MTProto counterpart."
1610
+ ),
1611
+ aliases=("config.countries", "auth.countries"),
1612
+ paginated=PageKind.LOCAL,
1613
+ surface=Surface.DAEMON,
1614
+ idempotent=True,
1615
+ rate_class="read",
1616
+ timeout_s=30,
1617
+ columns=("iso2", "name", "flag_emoji", "codes"),
1618
+ empty_exit=EXIT_EMPTY,
1619
+ example={
1620
+ "items": [{"iso2": "GB", "name": "United Kingdom", "flag_emoji": "🇬🇧"}],
1621
+ "has_more": False,
1622
+ },
1623
+ example_args="config country list --phone +447700900000",
1624
+ covers=(
1625
+ "auth.countries-list",
1626
+ "auth.prelogin-language",
1627
+ "updates.config-countries",
1628
+ "updates.config-country-lookup",
1629
+ ),
1630
+ tags=frozenset({"agent-safe"}),
1631
+ )
1632
+
1633
+
1634
+ # ---------------------------------------------------------------------------
1635
+ # Promo and suggestions
1636
+ # ---------------------------------------------------------------------------
1637
+
1638
+
1639
+ class PromoReq(Request):
1640
+ hide: Annotated[bool, opt("--hide", help="Hide the current promo dialog.")] = False
1641
+
1642
+
1643
+ async def config_promo_get(ctx: OpContext, req: PromoReq) -> PromoData:
1644
+ """The promoted / PSA / sponsored chat the server pins to the dialog list.
1645
+
1646
+ Official clients render it specially at the top of the list, so `chat
1647
+ list` needs it for parity. A `proxy` flag means the promo arrived because
1648
+ of an MTProxy sponsor — which is the reason tlgr does not declare its
1649
+ proxy to the server by default.
1650
+ """
1651
+ from telethon.tl import functions
1652
+
1653
+ from tlgr.core.timefmt import fmt_unix
1654
+
1655
+ client = _client(ctx)
1656
+ result = await client(functions.help.GetPromoDataRequest())
1657
+ kind = type(result).__name__
1658
+ if kind == "HelpPromoDataEmpty":
1659
+ return PromoData(kind="none", expires=fmt_unix(getattr(result, "expires", None)))
1660
+
1661
+ peer = getattr(result, "peer", None)
1662
+ from tlgr.core.tl import peer_marked_id
1663
+
1664
+ report = PromoData(
1665
+ kind="psa" if getattr(result, "psa_type", None) else "promo",
1666
+ chat_id=peer_marked_id(peer),
1667
+ psa_type=getattr(result, "psa_type", None),
1668
+ psa_message=getattr(result, "psa_message", None),
1669
+ proxy=bool(getattr(result, "proxy", False)),
1670
+ expires=fmt_unix(getattr(result, "expires", None)),
1671
+ pending_suggestions=[str(s) for s in (getattr(result, "pending_suggestions", None) or [])],
1672
+ )
1673
+ if req.hide:
1674
+ if peer is None:
1675
+ ctx.mark_already()
1676
+ return report
1677
+ await client(functions.help.HidePromoDataRequest(peer=peer))
1678
+ report.hidden = True
1679
+ return report
1680
+
1681
+
1682
+ SPEC_PROMO = OperationSpec(
1683
+ id="config.promo.get",
1684
+ request=PromoReq,
1685
+ response=PromoData,
1686
+ impl=config_promo_get,
1687
+ summary="Show (or hide) the promoted / PSA chat the server pins to the dialog list",
1688
+ mutating=True,
1689
+ idempotent=True,
1690
+ surface=Surface.DAEMON,
1691
+ rate_class="read",
1692
+ timeout_s=30,
1693
+ example={"kind": "psa", "psa_type": "covid", "expires": "2026-09-04T00:00:00Z"},
1694
+ example_args="config promo get",
1695
+ covers=("updates.config-promo-psa", "updates.invoke-client-proxy-declare"),
1696
+ coverage_note="",
1697
+ tags=frozenset({"agent-safe", "mutating-checked"}),
1698
+ )