tlgr-cli 2.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tlgr/__init__.py +3 -0
- tlgr/__main__.py +6 -0
- tlgr/actions/__init__.py +45 -0
- tlgr/actions/forward.py +74 -0
- tlgr/actions/reply.py +32 -0
- tlgr/cli/__init__.py +259 -0
- tlgr/cli/confirm.py +55 -0
- tlgr/cli/errors.py +84 -0
- tlgr/cli/gen.py +690 -0
- tlgr/cli/globals.py +273 -0
- tlgr/cli/introspect.py +170 -0
- tlgr/cli/params.py +189 -0
- tlgr/cli/render.py +418 -0
- tlgr/core/__init__.py +0 -0
- tlgr/core/accounts.py +384 -0
- tlgr/core/config.py +358 -0
- tlgr/core/custom_tl.py +170 -0
- tlgr/core/errors.py +687 -0
- tlgr/core/eventtypes.py +1170 -0
- tlgr/core/identity.py +127 -0
- tlgr/core/launchd.py +122 -0
- tlgr/core/logging.py +194 -0
- tlgr/core/media.py +134 -0
- tlgr/core/output.py +251 -0
- tlgr/core/pagination.py +227 -0
- tlgr/core/paths.py +360 -0
- tlgr/core/peers.py +427 -0
- tlgr/core/process.py +138 -0
- tlgr/core/signing.py +38 -0
- tlgr/core/systemd.py +96 -0
- tlgr/core/telethon_compat.py +295 -0
- tlgr/core/text.py +211 -0
- tlgr/core/timefmt.py +199 -0
- tlgr/core/tl.py +98 -0
- tlgr/daemon/__init__.py +0 -0
- tlgr/daemon/app.py +869 -0
- tlgr/daemon/dispatch.py +446 -0
- tlgr/daemon/events.py +723 -0
- tlgr/daemon/files.py +431 -0
- tlgr/daemon/idle.py +119 -0
- tlgr/daemon/jobs.py +68 -0
- tlgr/daemon/main.py +161 -0
- tlgr/daemon/peercred.py +75 -0
- tlgr/daemon/policy.py +113 -0
- tlgr/daemon/preauth.py +366 -0
- tlgr/daemon/ratelimit.py +391 -0
- tlgr/daemon/server.py +24 -0
- tlgr/daemon/session.py +648 -0
- tlgr/daemon/sessions.py +274 -0
- tlgr/daemon/singleton.py +114 -0
- tlgr/daemon/stream.py +193 -0
- tlgr/daemon/transfers.py +219 -0
- tlgr/daemon/webhook.py +390 -0
- tlgr/data/catalog_index.json +1 -0
- tlgr/data/parity_waivers.toml +90 -0
- tlgr/filters/__init__.py +42 -0
- tlgr/filters/compose.py +121 -0
- tlgr/filters/content.py +85 -0
- tlgr/filters/context.py +114 -0
- tlgr/filters/message.py +161 -0
- tlgr/filters/temporal.py +87 -0
- tlgr/filters/user.py +36 -0
- tlgr/gateway/__init__.py +1 -0
- tlgr/gateway/config.py +161 -0
- tlgr/gateway/engine.py +215 -0
- tlgr/gateway/event.py +22 -0
- tlgr/jobs/__init__.py +0 -0
- tlgr/jobs/base.py +81 -0
- tlgr/jobs/client.py +37 -0
- tlgr/models/__init__.py +1220 -0
- tlgr/models/admin.py +744 -0
- tlgr/models/auth.py +510 -0
- tlgr/models/base.py +81 -0
- tlgr/models/bot.py +576 -0
- tlgr/models/business.py +265 -0
- tlgr/models/call.py +586 -0
- tlgr/models/config.py +101 -0
- tlgr/models/contact.py +481 -0
- tlgr/models/daemon.py +336 -0
- tlgr/models/dialog.py +626 -0
- tlgr/models/envelope.py +68 -0
- tlgr/models/error.py +30 -0
- tlgr/models/event.py +79 -0
- tlgr/models/export.py +66 -0
- tlgr/models/gift.py +275 -0
- tlgr/models/inline.py +84 -0
- tlgr/models/location.py +115 -0
- tlgr/models/media.py +507 -0
- tlgr/models/message.py +584 -0
- tlgr/models/net.py +232 -0
- tlgr/models/notify.py +105 -0
- tlgr/models/page.py +32 -0
- tlgr/models/payment.py +172 -0
- tlgr/models/peer.py +400 -0
- tlgr/models/poll.py +119 -0
- tlgr/models/premium.py +161 -0
- tlgr/models/privacy.py +93 -0
- tlgr/models/profile.py +217 -0
- tlgr/models/reaction.py +160 -0
- tlgr/models/resolve.py +175 -0
- tlgr/models/settings.py +103 -0
- tlgr/models/stars.py +101 -0
- tlgr/models/sticker.py +243 -0
- tlgr/models/story.py +467 -0
- tlgr/models/sync.py +105 -0
- tlgr/models/todo.py +36 -0
- tlgr/models/webapp.py +89 -0
- tlgr/ops/__init__.py +63 -0
- tlgr/ops/_admin.py +313 -0
- tlgr/ops/_auth.py +599 -0
- tlgr/ops/_bots.py +586 -0
- tlgr/ops/_calls.py +535 -0
- tlgr/ops/_common.py +160 -0
- tlgr/ops/_layer.py +46 -0
- tlgr/ops/_media.py +592 -0
- tlgr/ops/_params.py +212 -0
- tlgr/ops/_rights.py +402 -0
- tlgr/ops/_send.py +593 -0
- tlgr/ops/_serialize.py +667 -0
- tlgr/ops/_settings.py +306 -0
- tlgr/ops/_spec.py +167 -0
- tlgr/ops/_story.py +743 -0
- tlgr/ops/account.py +2604 -0
- tlgr/ops/agent.py +937 -0
- tlgr/ops/auth.py +1282 -0
- tlgr/ops/bot.py +4880 -0
- tlgr/ops/business.py +1520 -0
- tlgr/ops/call.py +1610 -0
- tlgr/ops/chat.py +4025 -0
- tlgr/ops/chat_admin.py +929 -0
- tlgr/ops/chat_extra.py +1061 -0
- tlgr/ops/chat_invite.py +716 -0
- tlgr/ops/chat_manage.py +1691 -0
- tlgr/ops/chat_member.py +1357 -0
- tlgr/ops/chat_stats.py +902 -0
- tlgr/ops/chat_topic.py +905 -0
- tlgr/ops/conference.py +791 -0
- tlgr/ops/config.py +1698 -0
- tlgr/ops/contact.py +2330 -0
- tlgr/ops/daemon.py +1397 -0
- tlgr/ops/draft.py +299 -0
- tlgr/ops/emoji.py +343 -0
- tlgr/ops/events.py +1327 -0
- tlgr/ops/export.py +596 -0
- tlgr/ops/folder.py +1322 -0
- tlgr/ops/gif.py +522 -0
- tlgr/ops/gift.py +1546 -0
- tlgr/ops/giveaway.py +541 -0
- tlgr/ops/inline.py +773 -0
- tlgr/ops/job.py +799 -0
- tlgr/ops/location.py +917 -0
- tlgr/ops/media.py +4495 -0
- tlgr/ops/message.py +3769 -0
- tlgr/ops/net.py +536 -0
- tlgr/ops/notify.py +840 -0
- tlgr/ops/passport.py +464 -0
- tlgr/ops/payment.py +907 -0
- tlgr/ops/poll.py +1078 -0
- tlgr/ops/premium.py +488 -0
- tlgr/ops/privacy.py +794 -0
- tlgr/ops/profile.py +1481 -0
- tlgr/ops/proxy.py +750 -0
- tlgr/ops/reaction.py +1475 -0
- tlgr/ops/resolve.py +1140 -0
- tlgr/ops/search.py +521 -0
- tlgr/ops/settings.py +1066 -0
- tlgr/ops/stars.py +594 -0
- tlgr/ops/sticker.py +1602 -0
- tlgr/ops/story.py +3216 -0
- tlgr/ops/sync.py +788 -0
- tlgr/ops/todo.py +514 -0
- tlgr/ops/user.py +1406 -0
- tlgr/ops/vc.py +2351 -0
- tlgr/ops/webapp.py +717 -0
- tlgr/ops/webhook.py +418 -0
- tlgr/parity.py +386 -0
- tlgr/processors/__init__.py +125 -0
- tlgr/processors/regex.py +26 -0
- tlgr/processors/text.py +56 -0
- tlgr/registry.py +519 -0
- tlgr/schema.py +173 -0
- tlgr/transport/__init__.py +30 -0
- tlgr/transport/autostart.py +293 -0
- tlgr/transport/client.py +805 -0
- tlgr/transport/ndjson.py +44 -0
- tlgr/version.py +31 -0
- tlgr_cli-2.0.1.dist-info/METADATA +957 -0
- tlgr_cli-2.0.1.dist-info/RECORD +192 -0
- tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
- tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
- tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
- tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/ops/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
|
+
)
|