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/settings.py ADDED
@@ -0,0 +1,1066 @@
1
+ """The `settings` group: the cloud-synced switches, languages and themes.
2
+
3
+ `settings get` and `settings set` are one generic pair over a dozen unrelated
4
+ RPCs. That is a decision, not a shortcut: a dozen thin toggle commands would
5
+ be a dozen names to learn, a dozen response shapes and a dozen places for
6
+ the same "read-modify-write" mistake. Here every key prints the exact token
7
+ vocabulary its setter accepts, so `settings get X` and `settings set X <value>`
8
+ are a genuine round trip.
9
+
10
+ Where the group that owns a setting already implements it — sensitive media,
11
+ auto-download presets, the quick reaction, paid-reaction privacy, saved tags,
12
+ top peers, folder tags — this dispatches to that operation instead of issuing
13
+ the RPC a second time. One server call, one implementation, two entry points.
14
+
15
+ `settings theme *` is metadata only. tlgr has no theming engine and renders
16
+ nothing; what it can do is publish a theme file, install one for the account
17
+ and list what is installed, which is the server-side half the GUI shares.
18
+
19
+ Telethon is imported inside functions, never at module scope (§2.2).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ from pathlib import Path
26
+ from typing import Annotated, Any
27
+
28
+ from tlgr.core.errors import NotFoundError, UsageError
29
+ from tlgr.core.pagination import PageKind
30
+ from tlgr.core.timefmt import parse_duration
31
+ from tlgr.models.base import Request
32
+ from tlgr.models.media import AutoSaveSaved
33
+ from tlgr.models.page import Page
34
+ from tlgr.models.settings import (
35
+ CloudTheme,
36
+ Language,
37
+ SettingChange,
38
+ SettingUnset,
39
+ SettingValue,
40
+ ThemeInstalled,
41
+ )
42
+ from tlgr.ops import _settings
43
+ from tlgr.ops._common import client
44
+ from tlgr.ops._params import arg, opt
45
+ from tlgr.ops._spec import OpContext, OperationSpec
46
+
47
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
48
+
49
+ #: key → the token vocabulary its setter accepts. Printed with every read so
50
+ #: the output of `settings get` can be piped back into `settings set`.
51
+ ACCEPTS: dict[str, str] = {
52
+ "sensitive-content": "on|off",
53
+ "auto-delete": "1d|1w|1m|<duration>|off",
54
+ "top-peers": "on|off",
55
+ "quick-reaction": "<emoji>|custom:<doc-id>",
56
+ "folder-tags": "on|off",
57
+ "paid-reaction-privacy": "default|anonymous|peer:<channel>",
58
+ "sponsored-ads": "on|off",
59
+ "browser": "external|in-app",
60
+ "browser-close-button": "on|off",
61
+ "browser-exception": "<url> external|in-app",
62
+ "no-forwards": "<peer> on|off (use --peer)",
63
+ "saved-tag": "<emoji> <title>",
64
+ "language": "<lang-code>",
65
+ "auto-download": "auto-download.<low|medium|high>.<field> <value>",
66
+ "age-verification": "(read-only)",
67
+ }
68
+
69
+ #: The `auto-download.<preset>.<field>` names, in three spellings: the tlgr
70
+ #: key, the field on `AutoDownloadPreset` a read comes back on, and the flag
71
+ #: `media auto-download set` takes. Keeping the three in one table is what
72
+ #: stops the dotted key and the delegate drifting apart.
73
+ PRESETS = ("low", "medium", "high")
74
+ DOWNLOAD_FIELDS = {
75
+ "photo-max": "photo_size_max",
76
+ "video-max": "video_size_max",
77
+ "file-size-max": "file_size_max",
78
+ "video-preload-large": "video_preload_large",
79
+ "audio-preload-next": "audio_preload_next",
80
+ "stories-preload": "stories_preload",
81
+ "disabled": "disabled",
82
+ }
83
+ DOWNLOAD_FLAGS = {
84
+ "photo-max": "photo_max",
85
+ "video-max": "video_max",
86
+ "file-size-max": "file_max",
87
+ "video-preload-large": "preload_large_video",
88
+ "audio-preload-next": "preload_next_audio",
89
+ "stories-preload": "preload_stories",
90
+ "disabled": "disabled",
91
+ }
92
+
93
+
94
+ def _key_of(raw: str) -> tuple[str, str]:
95
+ """`auto-download.low.photo-max` → `("auto-download", "low.photo-max")`."""
96
+ head, _, tail = raw.strip().lower().partition(".")
97
+ if head not in ACCEPTS:
98
+ raise UsageError(
99
+ f"unknown setting {raw!r}; one of: {' '.join(sorted(ACCEPTS))}", field="key"
100
+ )
101
+ return head, tail
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # The readers
106
+ # ---------------------------------------------------------------------------
107
+
108
+
109
+ async def _read(ctx: OpContext, key: str, tail: str, peer: str | None) -> SettingValue:
110
+ """One key's current value, with where it came from and whether it may be set."""
111
+ from telethon.tl import types
112
+ from telethon.tl.functions import account as afn
113
+ from telethon.tl.functions import messages as mfn
114
+ from telethon.tl.functions import users as ufn
115
+
116
+ handle = client(ctx)
117
+ value: Any = None
118
+ source = "server"
119
+ changeable = True
120
+ reason: str | None = None
121
+
122
+ if key == "sensitive-content":
123
+ from tlgr.ops.media import SensitiveGetReq, sensitive_get
124
+
125
+ content = await sensitive_get(ctx, SensitiveGetReq())
126
+ value = "on" if content.sensitive_enabled else "off"
127
+ changeable = content.sensitive_can_change
128
+ reason = content.reason
129
+ elif key == "auto-delete":
130
+ period = int(getattr(await handle(mfn.GetDefaultHistoryTTLRequest()), "period", 0) or 0)
131
+ value = f"{period}s" if period else "off"
132
+ elif key == "top-peers":
133
+ # There is no getter for the switch itself: `contacts.getTopPeers`
134
+ # answers `topPeersDisabled` when collection is off, which is the
135
+ # only signal the server gives.
136
+ answer = await handle(_top_peers_request())
137
+ value = "off" if type(answer).__name__ == "ContactsTopPeersDisabled" else "on"
138
+ elif key == "quick-reaction":
139
+ from tlgr.ops.reaction import DefaultGetReq, default_get
140
+
141
+ quick = await default_get(ctx, DefaultGetReq())
142
+ value = quick.reaction or None
143
+ elif key == "folder-tags":
144
+ from tlgr.ops.folder import raw_filters
145
+
146
+ _, enabled = await raw_filters(ctx)
147
+ value = "on" if enabled else "off"
148
+ elif key == "paid-reaction-privacy":
149
+ raw = await handle(mfn.GetPaidReactionPrivacyRequest())
150
+ value = _paid_privacy_word(raw)
151
+ elif key == "sponsored-ads":
152
+ answer = await handle(ufn.GetFullUserRequest(id=types.InputUserSelf()))
153
+ value = (
154
+ "on"
155
+ if getattr(getattr(answer, "full_user", None), "sponsored_enabled", False)
156
+ else "off"
157
+ )
158
+ elif key in ("browser", "browser-close-button", "browser-exception"):
159
+ settings = await handle(afn.GetWebBrowserSettingsRequest(hash=0))
160
+ if key == "browser":
161
+ value = "external" if getattr(settings, "open_external_browser", False) else "in-app"
162
+ elif key == "browser-close-button":
163
+ value = "on" if getattr(settings, "display_close_button", False) else "off"
164
+ else:
165
+ # The server keeps two vectors, not one list with a flag on each
166
+ # row, so the mode is the vector a domain is *in*.
167
+ value = [
168
+ {
169
+ "domain": getattr(entry, "domain", ""),
170
+ "url": getattr(entry, "url", ""),
171
+ "title": getattr(entry, "title", ""),
172
+ "mode": mode,
173
+ }
174
+ for mode, vector in (
175
+ ("external", getattr(settings, "external_exceptions", None) or []),
176
+ ("in-app", getattr(settings, "inapp_exceptions", None) or []),
177
+ )
178
+ for entry in vector
179
+ ]
180
+ elif key == "no-forwards":
181
+ answer = await handle(ufn.GetFullUserRequest(id=types.InputUserSelf()))
182
+ full = getattr(answer, "full_user", None)
183
+ field = "noforwards_peer_enabled" if peer else "noforwards_my_enabled"
184
+ value = "on" if getattr(full, field, False) else "off"
185
+ elif key == "saved-tag":
186
+ from tlgr.ops.reaction import TagListReq, tag_list
187
+
188
+ tags = await tag_list(ctx, TagListReq())
189
+ value = [
190
+ {"reaction": tag.reaction, "title": tag.title, "count": tag.count} for tag in tags.items
191
+ ]
192
+ elif key == "language":
193
+ from tlgr.ops.config import ConfigGetReq, config_get
194
+
195
+ stored = await config_get(ctx, ConfigGetReq(key="identity.lang_code"))
196
+ value = stored.value or None
197
+ source = "local"
198
+ elif key == "auto-download":
199
+ from tlgr.ops.media import AutoDownloadGetReq, auto_download_get
200
+
201
+ presets = await auto_download_get(ctx, AutoDownloadGetReq())
202
+ value = [
203
+ {"preset": preset.preset, **{k: getattr(preset, v) for k, v in DOWNLOAD_FIELDS.items()}}
204
+ for preset in presets.presets
205
+ if not tail or preset.preset == tail.partition(".")[0]
206
+ ]
207
+ elif key == "age-verification":
208
+ config = await _settings.app_config(ctx)
209
+ value = {
210
+ "need_age_video_verification": bool(config.get("need_age_video_verification")),
211
+ "verify_age_min": config.get("verify_age_min"),
212
+ "verify_age_bot_username": config.get("verify_age_bot_username"),
213
+ }
214
+ changeable = False
215
+ reason = (
216
+ "age verification runs in a Telegram-designated bot's Main Mini App "
217
+ "with a camera; a terminal cannot complete it"
218
+ )
219
+ source = "app-config"
220
+
221
+ return SettingValue(
222
+ key=f"{key}.{tail}" if tail else key,
223
+ value=value,
224
+ changeable=changeable,
225
+ source=source,
226
+ accepts=ACCEPTS[key],
227
+ reason=reason,
228
+ )
229
+
230
+
231
+ def _top_peers_request() -> Any:
232
+ from telethon.tl.functions import contacts as fn
233
+
234
+ return fn.GetTopPeersRequest(correspondents=True, offset=0, limit=1, hash=0)
235
+
236
+
237
+ def _paid_privacy_word(raw: Any) -> str:
238
+ name = type(raw).__name__
239
+ if name == "PaidReactionPrivacyAnonymous":
240
+ return "anonymous"
241
+ if name == "PaidReactionPrivacyPeer":
242
+ return "peer"
243
+ return "default"
244
+
245
+
246
+ class GetReq(Request):
247
+ key: Annotated[
248
+ str | None, arg(0, metavar="KEY", required=False, help="One key; omit for every key.")
249
+ ] = None
250
+ peer: Annotated[
251
+ str | None, opt("--peer", metavar="CHAT", help="Target peer for per-peer keys.")
252
+ ] = None
253
+
254
+
255
+ async def get(ctx: OpContext, req: GetReq) -> Page[SettingValue]:
256
+ """Read cloud-synced account settings — one key, or all of them.
257
+
258
+ Every row carries `accepts`, the exact token vocabulary its setter takes,
259
+ so a caller never has to guess whether a switch wants `on` or `true`.
260
+ """
261
+ wanted = [_key_of(req.key)] if req.key else [(name, "") for name in sorted(ACCEPTS)]
262
+ rows: list[SettingValue] = []
263
+ for name, tail in wanted:
264
+ try:
265
+ rows.append(await _read(ctx, name, tail, req.peer))
266
+ except Exception as exc:
267
+ if req.key:
268
+ raise
269
+ ctx.warn(f"could not read {name}: {exc}")
270
+ return Page(items=rows, has_more=False, total=len(rows))
271
+
272
+
273
+ SPEC_GET = OperationSpec(
274
+ id="settings.get",
275
+ request=GetReq,
276
+ response=Page[SettingValue],
277
+ impl=get,
278
+ summary="Read cloud-synced account settings (one key or all of them)",
279
+ description=(
280
+ "One generic pair instead of a dozen thin toggles. `accepts` on each "
281
+ "row is the vocabulary `settings set` takes for that key, so the read "
282
+ "and the write are the same words."
283
+ ),
284
+ paginated=PageKind.LOCAL,
285
+ idempotent=True,
286
+ columns=("key", "value", "changeable", "accepts"),
287
+ headers=("Key", "Value", "Changeable", "Accepts"),
288
+ example={
289
+ "items": [
290
+ {"key": "auto-delete", "value": "off", "changeable": True, "accepts": "1d|1w|1m|off"}
291
+ ],
292
+ "has_more": False,
293
+ },
294
+ example_args="settings get auto-delete",
295
+ covers=(
296
+ "appearance.default-reaction",
297
+ "data.auto-download",
298
+ "privacy.paid-reaction-anonymity",
299
+ "privacy.pm-content-protection",
300
+ "privacy.sensitive-content",
301
+ ),
302
+ covers_partial=(
303
+ "appearance.folder-tags",
304
+ "appearance.saved-tags",
305
+ "business.reenable-ads",
306
+ "data.web-browser-settings",
307
+ "lang.set",
308
+ "privacy.age-verification",
309
+ "privacy.default-ttl",
310
+ "privacy.top-peers-suggest",
311
+ ),
312
+ coverage_note="Writing any of these keys is `settings set`.",
313
+ tags=frozenset({"agent-safe"}),
314
+ )
315
+
316
+
317
+ # ---------------------------------------------------------------------------
318
+ # settings set
319
+ # ---------------------------------------------------------------------------
320
+
321
+
322
+ class SetReq(Request):
323
+ key: Annotated[str, arg(0, metavar="KEY", help="The setting to change.")]
324
+ value: Annotated[
325
+ tuple[str, ...], arg(1, metavar="VALUE", variadic=True, help="Value(s) for the key.")
326
+ ] = ()
327
+ apply_to_existing: Annotated[
328
+ bool, opt("--apply-to-existing", help="auto-delete: rewrite every chat's TTL too.")
329
+ ] = False
330
+ peer: Annotated[
331
+ str | None, opt("--peer", metavar="CHAT", help="Target peer for per-peer keys.")
332
+ ] = None
333
+
334
+
335
+ async def set_(ctx: OpContext, req: SetReq) -> SettingChange:
336
+ """Change one cloud-synced account setting.
337
+
338
+ Settings whose server call replaces a whole constructor — the
339
+ auto-download presets, the web-browser settings — are read-modify-written
340
+ here, and the dotted key names the one field being changed.
341
+ """
342
+ from telethon.tl.functions import account as afn
343
+ from telethon.tl.functions import messages as mfn
344
+
345
+ handle = client(ctx)
346
+ key, tail = _key_of(req.key)
347
+ values = [value for value in req.value if value != ""]
348
+ before = await _read(ctx, key, tail, req.peer)
349
+ if not before.changeable:
350
+ from tlgr.core.errors import PermissionError_
351
+
352
+ raise PermissionError_(before.reason or f"{key} cannot be changed from an API client")
353
+ if not values:
354
+ raise UsageError(f"{key} wants a value: {ACCEPTS[key]}", field="value")
355
+ word = values[0].strip()
356
+ result = SettingChange(key=before.key, previous=before.value)
357
+
358
+ if key == "sensitive-content":
359
+ from tlgr.ops.media import SensitiveSetReq, sensitive_set
360
+
361
+ content = await sensitive_set(ctx, SensitiveSetReq(state=word))
362
+ result.value = "on" if content.sensitive_enabled else "off"
363
+ result.already = content.already
364
+ elif key == "auto-delete":
365
+ period = 0 if word.lower() in ("off", "none", "0") else int(parse_duration(word) or 0)
366
+ if word.lower() not in ("off", "none", "0") and not period:
367
+ raise UsageError("auto-delete takes a duration (1d, 1w, 1m) or 'off'", field="value")
368
+ await handle(mfn.SetDefaultHistoryTTLRequest(period=period))
369
+ result.value = f"{period}s" if period else "off"
370
+ if req.apply_to_existing:
371
+ result.applied_to = await _apply_ttl_everywhere(ctx, period)
372
+ elif key == "top-peers":
373
+ from tlgr.ops.contact import TopSetReq, top_set
374
+
375
+ state = _settings.on_off(word, field="value")
376
+ await top_set(ctx, TopSetReq(state="on" if state else "off"))
377
+ result.value = "on" if state else "off"
378
+ elif key == "quick-reaction":
379
+ from tlgr.ops.reaction import DefaultSetReq, default_set
380
+
381
+ await default_set(ctx, DefaultSetReq(emoji=word))
382
+ result.value = word
383
+ elif key == "folder-tags":
384
+ state = _settings.on_off(word, field="value")
385
+ await handle(mfn.ToggleDialogFilterTagsRequest(enabled=bool(state)))
386
+ result.value = "on" if state else "off"
387
+ elif key == "paid-reaction-privacy":
388
+ from tlgr.ops.reaction import PrivacySetReq, privacy_set
389
+
390
+ await privacy_set(ctx, PrivacySetReq(mode=word))
391
+ result.value = word
392
+ elif key == "sponsored-ads":
393
+ state = _settings.on_off(word, field="value")
394
+ await handle(afn.ToggleSponsoredMessagesRequest(enabled=bool(state)))
395
+ result.value = "on" if state else "off"
396
+ elif key in ("browser", "browser-close-button"):
397
+ settings = await handle(afn.GetWebBrowserSettingsRequest(hash=0))
398
+ external = bool(getattr(settings, "open_external_browser", False))
399
+ close = bool(getattr(settings, "display_close_button", False))
400
+ if key == "browser":
401
+ if word not in ("external", "in-app"):
402
+ raise UsageError("browser takes external or in-app", field="value")
403
+ external = word == "external"
404
+ result.value = word
405
+ else:
406
+ close = bool(_settings.on_off(word, field="value"))
407
+ result.value = "on" if close else "off"
408
+ await handle(
409
+ afn.UpdateWebBrowserSettingsRequest(
410
+ open_external_browser=external or None, display_close_button=close or None
411
+ )
412
+ )
413
+ elif key == "browser-exception":
414
+ if len(values) != 2 or values[1] not in ("external", "in-app"):
415
+ raise UsageError("browser-exception takes '<url> external|in-app'", field="value")
416
+ await handle(
417
+ afn.ToggleWebBrowserSettingsExceptionRequest(
418
+ url=values[0], open_external_browser=values[1] == "external" or None
419
+ )
420
+ )
421
+ result.value = {"url": values[0], "mode": values[1]}
422
+ elif key == "no-forwards":
423
+ if not req.peer:
424
+ raise UsageError("no-forwards needs --peer <chat>", field="peer")
425
+ state = _settings.on_off(word, field="value")
426
+ await handle(
427
+ mfn.ToggleNoForwardsRequest(
428
+ peer=await _settings.resolve(ctx, req.peer), enabled=bool(state)
429
+ )
430
+ )
431
+ result.value = "on" if state else "off"
432
+ elif key == "saved-tag":
433
+ from tlgr.ops.reaction import TagSetReq, tag_set
434
+
435
+ if len(values) < 2:
436
+ raise UsageError("saved-tag takes '<emoji> <title>'", field="value")
437
+ await tag_set(ctx, TagSetReq(emoji=values[0], title=" ".join(values[1:])))
438
+ result.value = {"emoji": values[0], "title": " ".join(values[1:])}
439
+ elif key == "language":
440
+ from tlgr.ops.config import ConfigSetReq, config_set
441
+
442
+ stored = await config_set(ctx, ConfigSetReq(key="identity.lang_code", value=word))
443
+ result.value = stored.value
444
+ result.already = stored.already
445
+ elif key == "auto-download":
446
+ result.value = await _set_auto_download(ctx, tail, word)
447
+ else: # pragma: no cover - `age-verification` is read-only and refused above
448
+ raise UsageError(f"{key} cannot be written", field="key")
449
+
450
+ if result.value == result.previous:
451
+ result.already = True
452
+ ctx.mark_already()
453
+ ctx.emit("settings_set", {"key": result.key})
454
+ return result
455
+
456
+
457
+ async def _apply_ttl_everywhere(ctx: OpContext, period: int) -> int:
458
+ """Push the default TTL onto every existing chat, one `setHistoryTTL` each.
459
+
460
+ Gated behind `--apply-to-existing` and `--yes` because it rewrites a
461
+ setting on every dialog, and the server has no bulk form.
462
+ """
463
+ from telethon.tl.functions import messages as fn
464
+
465
+ from tlgr.ops.chat import ListReq, list_chats
466
+
467
+ handle = client(ctx)
468
+ page = await list_chats(ctx, ListReq())
469
+ touched = 0
470
+ for dialog in page.items:
471
+ chat = getattr(dialog, "chat", None)
472
+ if chat is None:
473
+ continue
474
+ peer = await _settings.resolve(ctx, str(chat.id))
475
+ try:
476
+ await handle(fn.SetHistoryTTLRequest(peer=peer, period=period))
477
+ except Exception as exc:
478
+ ctx.warn(f"could not set the TTL on {chat.id}: {exc}")
479
+ continue
480
+ touched += 1
481
+ return touched
482
+
483
+
484
+ async def _set_auto_download(ctx: OpContext, tail: str, word: str) -> Any:
485
+ """`auto-download.<preset>.<field> <value>`, read-modify-written."""
486
+ from tlgr.ops.media import AutoDownloadSetReq, auto_download_set
487
+
488
+ preset, _, field = tail.partition(".")
489
+ if preset not in PRESETS or field not in DOWNLOAD_FLAGS:
490
+ raise UsageError(
491
+ f"auto-download.<{'|'.join(PRESETS)}>.<{'|'.join(sorted(DOWNLOAD_FIELDS))}>",
492
+ field="key",
493
+ )
494
+ flag = DOWNLOAD_FLAGS[field]
495
+ kwargs: dict[str, Any] = {"preset": preset}
496
+ kwargs[flag] = (
497
+ word
498
+ if field in ("photo-max", "video-max", "file-size-max")
499
+ else bool(_settings.on_off(word, field="value"))
500
+ )
501
+ saved = await auto_download_set(ctx, AutoDownloadSetReq(**kwargs))
502
+ return {
503
+ field: getattr(saved.settings, DOWNLOAD_FIELDS[field], None) if saved.settings else None
504
+ }
505
+
506
+
507
+ SPEC_SET = OperationSpec(
508
+ id="settings.set",
509
+ request=SetReq,
510
+ response=SettingChange,
511
+ impl=set_,
512
+ summary="Change a cloud-synced account setting",
513
+ description=(
514
+ "`previous` is always reported, so a script can tell 'I changed it' "
515
+ "from 'it was already like that'. Premium-only keys pass the server's "
516
+ "`PREMIUM_ACCOUNT_REQUIRED` through rather than pretending to succeed."
517
+ ),
518
+ mutating=True,
519
+ idempotent=True,
520
+ rate_class="send",
521
+ columns=("key", "value", "previous", "already"),
522
+ headers=("Key", "Value", "Previous", "Already"),
523
+ example={"key": "auto-delete", "value": "604800s", "previous": "off"},
524
+ example_args="settings set auto-delete 1w",
525
+ covers=(
526
+ "appearance.folder-tags",
527
+ "appearance.saved-tags",
528
+ "business.reenable-ads",
529
+ "gift.button-visibility",
530
+ "privacy.age-verification",
531
+ "privacy.default-ttl",
532
+ ),
533
+ covers_partial=(
534
+ "appearance.default-reaction",
535
+ "data.auto-download",
536
+ "data.web-browser-settings",
537
+ "lang.set",
538
+ "privacy.paid-reaction-anonymity",
539
+ "privacy.pm-content-protection",
540
+ "privacy.sensitive-content",
541
+ "privacy.top-peers-suggest",
542
+ ),
543
+ coverage_note="Reading any of these keys back is `settings get`.",
544
+ )
545
+
546
+
547
+ # ---------------------------------------------------------------------------
548
+ # settings unset
549
+ # ---------------------------------------------------------------------------
550
+
551
+
552
+ class UnsetReq(Request):
553
+ key: Annotated[
554
+ str, arg(0, metavar="KEY", help="top-peers | browser-exception | autosave | saved-tag.")
555
+ ]
556
+ value: Annotated[
557
+ str | None,
558
+ arg(1, metavar="VALUE", required=False, help="Peer, URL or emoji to forget."),
559
+ ] = None
560
+ category: Annotated[
561
+ str | None, opt("--category", metavar="NAME", help="top-peers: which rating to reset.")
562
+ ] = None
563
+ every: Annotated[bool, opt("--every", help="Clear every exception for that key.")] = False
564
+
565
+
566
+ async def unset(ctx: OpContext, req: UnsetReq) -> SettingUnset:
567
+ """Remove a per-peer or per-URL exception, or a single suggestion.
568
+
569
+ The opposite of `settings set` only for the keys that *have* exceptions;
570
+ everything else has a value and is changed rather than removed, which is
571
+ why this is a separate verb and not `settings set X none`.
572
+ """
573
+ from telethon.tl.functions import account as afn
574
+
575
+ handle = client(ctx)
576
+ key = req.key.strip().lower()
577
+
578
+ if key == "top-peers":
579
+ from tlgr.ops.contact import TopSetReq, top_set
580
+
581
+ if not req.value:
582
+ raise UsageError("top-peers wants the peer whose rating to reset", field="value")
583
+ from tlgr.models.peer import parse_peer_ref
584
+
585
+ await top_set(
586
+ ctx,
587
+ TopSetReq(reset=parse_peer_ref(req.value), category=req.category or "correspondents"),
588
+ )
589
+ return SettingUnset(key=key, removed=1, values=[req.value])
590
+
591
+ if key == "browser-exception":
592
+ if req.every:
593
+ await handle(afn.DeleteWebBrowserSettingsExceptionsRequest())
594
+ return SettingUnset(key=key, removed=-1)
595
+ if not req.value:
596
+ raise UsageError("browser-exception wants a URL, or --every", field="value")
597
+ await handle(afn.ToggleWebBrowserSettingsExceptionRequest(url=req.value, delete=True))
598
+ return SettingUnset(key=key, removed=1, values=[req.value])
599
+
600
+ if key == "autosave":
601
+ await handle(afn.DeleteAutoSaveExceptionsRequest())
602
+ return SettingUnset(key=key, removed=-1)
603
+
604
+ if key == "saved-tag":
605
+ from tlgr.ops.reaction import TagSetReq, tag_set
606
+
607
+ if not req.value:
608
+ raise UsageError("saved-tag wants the emoji whose title to clear", field="value")
609
+ await tag_set(ctx, TagSetReq(emoji=req.value, title=""))
610
+ return SettingUnset(key=key, removed=1, values=[req.value])
611
+
612
+ raise UsageError("unset takes top-peers, browser-exception, autosave or saved-tag", field="key")
613
+
614
+
615
+ SPEC_UNSET = OperationSpec(
616
+ id="settings.unset",
617
+ request=UnsetReq,
618
+ response=SettingUnset,
619
+ impl=unset,
620
+ summary="Remove a per-peer/per-URL exception or a single suggestion",
621
+ description="`removed: -1` means the server cleared the list without saying how many.",
622
+ mutating=True,
623
+ idempotent=True,
624
+ rate_class="send",
625
+ columns=("key", "removed", "values"),
626
+ headers=("Key", "Removed", "Values"),
627
+ example={"key": "browser-exception", "removed": 1, "values": ["https://example.org"]},
628
+ example_args="settings unset browser-exception https://example.org",
629
+ covers=("data.web-browser-settings", "privacy.top-peers-suggest"),
630
+ covers_partial=("data.autosave-gallery",),
631
+ coverage_note="Setting the autosave rules is `settings autosave set`.",
632
+ )
633
+
634
+
635
+ # ---------------------------------------------------------------------------
636
+ # settings autosave set
637
+ # ---------------------------------------------------------------------------
638
+
639
+
640
+ class AutosaveSetReq(Request):
641
+ scope: Annotated[
642
+ str | None, opt("--scope", metavar="SCOPE", help="users | chats | broadcasts.")
643
+ ] = None
644
+ peer: Annotated[
645
+ str | None, opt("--peer", metavar="CHAT", help="Set an exception for one chat instead.")
646
+ ] = None
647
+ photos: Annotated[str | None, opt("--photos", metavar="ON|OFF", help="Auto-save photos.")] = (
648
+ None
649
+ )
650
+ videos: Annotated[str | None, opt("--videos", metavar="ON|OFF", help="Auto-save videos.")] = (
651
+ None
652
+ )
653
+ max_size: Annotated[
654
+ str | None, opt("--max-size", metavar="SIZE", help="Largest video to auto-save (100M).")
655
+ ] = None
656
+ clear_exceptions: Annotated[
657
+ bool, opt("--clear-exceptions", help="Drop every per-chat exception.")
658
+ ] = False
659
+
660
+
661
+ async def autosave_set(ctx: OpContext, req: AutosaveSetReq) -> AutoSaveSaved:
662
+ """Save-to-gallery rules for incoming media, per scope or per chat.
663
+
664
+ Cloud-synced, so it is genuine parity even for a CLI that has no gallery:
665
+ the setting an official client obeys is the one written here, and tlgr's
666
+ own downloader can read it.
667
+ """
668
+ from telethon.tl.functions import account as fn
669
+
670
+ from tlgr.ops.media import AutoSaveSetReq, auto_save_set
671
+
672
+ if req.clear_exceptions and not (req.photos or req.videos or req.max_size):
673
+ await client(ctx)(fn.DeleteAutoSaveExceptionsRequest())
674
+ return AutoSaveSaved(scope=req.scope or "all", ok=True, cleared_exceptions=True)
675
+
676
+ scope = {"users": "users", "chats": "groups", "broadcasts": "channels"}.get(
677
+ (req.scope or "users").strip().lower()
678
+ )
679
+ if scope is None:
680
+ raise UsageError("--scope is users, chats or broadcasts", field="scope")
681
+ from tlgr.models.peer import parse_peer_ref
682
+
683
+ return await auto_save_set(
684
+ ctx,
685
+ AutoSaveSetReq(
686
+ scope=scope,
687
+ chat=parse_peer_ref(req.peer) if req.peer else None,
688
+ photos=_settings.on_off(req.photos, field="photos"),
689
+ videos=_settings.on_off(req.videos, field="videos"),
690
+ video_max=req.max_size,
691
+ ),
692
+ )
693
+
694
+
695
+ SPEC_AUTOSAVE_SET = OperationSpec(
696
+ id="settings.autosave.set",
697
+ request=AutosaveSetReq,
698
+ response=AutoSaveSaved,
699
+ impl=autosave_set,
700
+ summary="Save-to-gallery rules for incoming media (per scope or per chat)",
701
+ mutating=True,
702
+ idempotent=True,
703
+ rate_class="send",
704
+ columns=("scope", "ok", "cleared_exceptions"),
705
+ headers=("Scope", "OK", "Cleared"),
706
+ example={"scope": "users", "ok": True, "settings": {"photos": True, "videos": False}},
707
+ example_args="settings autosave set --scope users --photos on",
708
+ covers=("data.autosave-gallery",),
709
+ )
710
+
711
+
712
+ # ---------------------------------------------------------------------------
713
+ # settings language list
714
+ # ---------------------------------------------------------------------------
715
+
716
+
717
+ class LanguageListReq(Request):
718
+ pack: Annotated[
719
+ str, opt("--pack", metavar="NAME", help="lang_pack id (android/tdesktop/ios; '' generic).")
720
+ ] = ""
721
+ code: Annotated[
722
+ str | None, opt("--code", metavar="CODE", help="Fetch one language, custom slugs included.")
723
+ ] = None
724
+
725
+
726
+ async def language_list(ctx: OpContext, req: LanguageListReq) -> Page[Language]:
727
+ """Interface languages the server offers.
728
+
729
+ tlgr has no localised UI of its own, but `lang_code` is sent in
730
+ `initConnection` and decides the language of *server-side* strings —
731
+ service messages, error texts, country names. `settings set language
732
+ <code>` is what changes it.
733
+ """
734
+ from telethon.tl.functions import langpack as fn
735
+
736
+ handle = client(ctx)
737
+ if req.code:
738
+ rows = [await handle(fn.GetLanguageRequest(lang_pack=req.pack, lang_code=req.code))]
739
+ else:
740
+ rows = list(await handle(fn.GetLanguagesRequest(lang_pack=req.pack)) or [])
741
+ items = [
742
+ Language(
743
+ lang_code=str(getattr(row, "lang_code", "") or ""),
744
+ name=str(getattr(row, "name", "") or ""),
745
+ native_name=str(getattr(row, "native_name", "") or ""),
746
+ official=bool(getattr(row, "official", False)),
747
+ beta=bool(getattr(row, "beta", False)),
748
+ rtl=bool(getattr(row, "rtl", False)),
749
+ strings_count=int(getattr(row, "strings_count", 0) or 0),
750
+ translated_count=int(getattr(row, "translated_count", 0) or 0),
751
+ translations_url=getattr(row, "translations_url", None),
752
+ plural_code=getattr(row, "plural_code", None),
753
+ base_lang_code=getattr(row, "base_lang_code", None),
754
+ )
755
+ for row in rows
756
+ ]
757
+ return Page(items=items, has_more=False, total=len(items))
758
+
759
+
760
+ SPEC_LANGUAGE_LIST = OperationSpec(
761
+ id="settings.language.list",
762
+ request=LanguageListReq,
763
+ response=Page[Language],
764
+ impl=language_list,
765
+ summary="List interface languages available on the server",
766
+ paginated=PageKind.LOCAL,
767
+ idempotent=True,
768
+ columns=("lang_code", "name", "native_name", "official", "beta"),
769
+ headers=("Code", "Name", "Native", "Official", "Beta"),
770
+ example={
771
+ "items": [{"lang_code": "fa", "name": "Persian", "native_name": "فارسی", "official": True}],
772
+ "has_more": False,
773
+ },
774
+ example_args="settings language list",
775
+ covers=("lang.custom-pack", "lang.list", "lang.set"),
776
+ tags=frozenset({"agent-safe"}),
777
+ )
778
+
779
+
780
+ # ---------------------------------------------------------------------------
781
+ # settings theme list / create / install
782
+ # ---------------------------------------------------------------------------
783
+
784
+
785
+ def _theme_model(raw: Any) -> CloudTheme:
786
+ document = getattr(raw, "document", None)
787
+ slug = str(getattr(raw, "slug", "") or "")
788
+ return CloudTheme(
789
+ id=int(getattr(raw, "id", 0) or 0),
790
+ access_hash=getattr(raw, "access_hash", None),
791
+ slug=slug,
792
+ title=str(getattr(raw, "title", "") or ""),
793
+ creator=bool(getattr(raw, "creator", False)),
794
+ default=bool(getattr(raw, "default", False)),
795
+ for_chat=bool(getattr(raw, "for_chat", False)),
796
+ installs_count=getattr(raw, "installs_count", None),
797
+ document_id=getattr(document, "id", None),
798
+ emoticon=getattr(raw, "emoticon", None),
799
+ settings=[
800
+ {
801
+ "base_theme": type(getattr(entry, "base_theme", None)).__name__,
802
+ "accent_color": _settings.color_text(getattr(entry, "accent_color", 0)),
803
+ "message_colors": [
804
+ _settings.color_text(value)
805
+ for value in getattr(entry, "message_colors", None) or []
806
+ ],
807
+ }
808
+ for entry in getattr(raw, "settings", None) or []
809
+ ],
810
+ link=f"https://t.me/addtheme/{slug}" if slug else None,
811
+ )
812
+
813
+
814
+ class ThemeListReq(Request):
815
+ slug: Annotated[
816
+ str | None, opt("--slug", metavar="SLUG", help="One theme (a t.me/addtheme link works).")
817
+ ] = None
818
+ gift: Annotated[bool, opt("--gift", help="Collectible-gift chat themes instead.")] = False
819
+ format: Annotated[str, opt("--format", metavar="NAME", help="Theming engine identifier.")] = (
820
+ "tdesktop"
821
+ )
822
+
823
+
824
+ async def theme_list(ctx: OpContext, req: ThemeListReq) -> Page[CloudTheme]:
825
+ """Cloud themes: installed, one by slug, or the collectible-gift ones.
826
+
827
+ Metadata only. A theme is a rendering instruction and a CLI has nothing
828
+ to render it with; what is useful is knowing which one is installed and
829
+ being able to install another for the phone that shares the account.
830
+ """
831
+ from telethon.tl import types
832
+ from telethon.tl.functions import account as fn
833
+
834
+ handle = client(ctx)
835
+ if req.gift:
836
+ result = await handle(fn.GetUniqueGiftChatThemesRequest(offset="", limit=100, hash=0))
837
+ rows = [_theme_model(theme) for theme in getattr(result, "themes", None) or []]
838
+ return Page(items=rows, has_more=False, total=getattr(result, "count", None))
839
+
840
+ if req.slug:
841
+ slug = req.slug.rsplit("/", 1)[-1]
842
+ result = await handle(
843
+ fn.GetThemeRequest(format=req.format, theme=types.InputThemeSlug(slug=slug))
844
+ )
845
+ return Page(items=[_theme_model(result)], has_more=False, total=1)
846
+
847
+ result = await handle(fn.GetThemesRequest(format=req.format, hash=0))
848
+ rows = [_theme_model(theme) for theme in getattr(result, "themes", None) or []]
849
+ return Page(items=rows, has_more=False, total=len(rows))
850
+
851
+
852
+ SPEC_THEME_LIST = OperationSpec(
853
+ id="settings.theme.list",
854
+ request=ThemeListReq,
855
+ response=Page[CloudTheme],
856
+ impl=theme_list,
857
+ summary="List cloud themes (installed, one by slug, or the collectible-gift themes)",
858
+ description="Metadata only: tlgr has no theming engine and renders nothing.",
859
+ paginated=PageKind.LOCAL,
860
+ idempotent=True,
861
+ columns=("id", "slug", "title", "creator", "installs_count"),
862
+ headers=("Id", "Slug", "Title", "Mine", "Installs"),
863
+ example={
864
+ "items": [{"id": 991, "slug": "Nord", "title": "Nord", "installs_count": 4200}],
865
+ "has_more": False,
866
+ },
867
+ example_args="settings theme list",
868
+ covers=("theme.cloud-themes", "theme.get", "theme.gift-chat-themes", "theme.list-cloud"),
869
+ tags=frozenset({"agent-safe"}),
870
+ )
871
+
872
+
873
+ class ThemeCreateReq(Request):
874
+ title: Annotated[str | None, arg(0, metavar="TITLE", required=False, help="Theme title.")] = (
875
+ None
876
+ )
877
+ slug: Annotated[
878
+ str | None, opt("--slug", metavar="SLUG", help="Public slug; an existing one edits it.")
879
+ ] = None
880
+ file: Annotated[
881
+ str | None, opt("--file", metavar="PATH", kind="path", help="Theme file to upload.")
882
+ ] = None
883
+ base: Annotated[
884
+ str | None,
885
+ opt("--base", metavar="NAME", help="classic | day | night | tinted | arctic."),
886
+ ] = None
887
+ accent: Annotated[
888
+ str | None, opt("--accent", metavar="COLOR", help="Accent colour, #RRGGBB.")
889
+ ] = None
890
+ outbox_accent: Annotated[
891
+ str | None, opt("--outbox-accent", metavar="COLOR", help="Outgoing accent colour.")
892
+ ] = None
893
+ message_colors: Annotated[
894
+ str | None, opt("--message-colors", metavar="LIST", help="Message gradient colours.")
895
+ ] = None
896
+ wallpaper: Annotated[
897
+ str | None, opt("--wallpaper", metavar="SLUG", help="Wallpaper for the theme settings.")
898
+ ] = None
899
+ dark: Annotated[bool, opt("--dark", help="Mark the settings vector as the dark variant.")] = (
900
+ False
901
+ )
902
+
903
+
904
+ _BASE_THEMES = {
905
+ "classic": "BaseThemeClassic",
906
+ "day": "BaseThemeDay",
907
+ "night": "BaseThemeNight",
908
+ "tinted": "BaseThemeTinted",
909
+ "arctic": "BaseThemeArctic",
910
+ }
911
+
912
+
913
+ async def theme_create(ctx: OpContext, req: ThemeCreateReq) -> ThemeInstalled:
914
+ """Publish or edit a cloud theme you own.
915
+
916
+ Editing is creator-only, and a CLI cannot author or preview a theme file
917
+ — it can upload one somebody made and give it a public slug, which is the
918
+ part that needs an account.
919
+ """
920
+ import mimetypes
921
+
922
+ from telethon.tl import types
923
+ from telethon.tl.functions import account as fn
924
+
925
+ handle = client(ctx)
926
+ document: Any = None
927
+ if req.file:
928
+ path = Path(os.path.expanduser(req.file))
929
+ if not path.exists():
930
+ raise UsageError(f"{req.file} does not exist", field="file")
931
+ upload = getattr(ctx, "upload_file", None)
932
+ if upload is None: # pragma: no cover - the daemon always supplies one
933
+ raise UsageError("this context cannot upload files")
934
+ uploaded = await handle(
935
+ fn.UploadThemeRequest(
936
+ file=await upload(path),
937
+ file_name=path.name,
938
+ mime_type=mimetypes.guess_type(path.name)[0] or "application/x-tgtheme",
939
+ )
940
+ )
941
+ document = types.InputDocument(
942
+ id=getattr(uploaded, "id", 0),
943
+ access_hash=getattr(uploaded, "access_hash", 0),
944
+ file_reference=getattr(uploaded, "file_reference", b"") or b"",
945
+ )
946
+
947
+ settings = _theme_settings(req)
948
+ if req.slug:
949
+ existing = await theme_list(ctx, ThemeListReq(slug=req.slug))
950
+ if not existing.items:
951
+ raise NotFoundError(f"no theme with the slug {req.slug!r}")
952
+ theme = existing.items[0]
953
+ result = await handle(
954
+ fn.UpdateThemeRequest(
955
+ format="tdesktop",
956
+ theme=types.InputTheme(id=theme.id, access_hash=theme.access_hash or 0),
957
+ slug=req.slug,
958
+ title=req.title,
959
+ document=document,
960
+ settings=settings,
961
+ )
962
+ )
963
+ else:
964
+ if not req.title:
965
+ raise UsageError("give a TITLE (or --slug to edit an existing theme)", field="title")
966
+ result = await handle(
967
+ fn.CreateThemeRequest(slug="", title=req.title, document=document, settings=settings)
968
+ )
969
+ model = _theme_model(result)
970
+ ctx.emit("theme_created", {"slug": model.slug})
971
+ return ThemeInstalled(
972
+ slug=model.slug, id=model.id, title=model.title, document_id=model.document_id
973
+ )
974
+
975
+
976
+ def _theme_settings(req: ThemeCreateReq) -> list[Any] | None:
977
+ from telethon.tl import types
978
+
979
+ if req.base is None:
980
+ return None
981
+ if req.base not in _BASE_THEMES:
982
+ raise UsageError(f"--base is one of: {' '.join(sorted(_BASE_THEMES))}", field="base")
983
+ return [
984
+ types.InputThemeSettings(
985
+ base_theme=getattr(types, _BASE_THEMES[req.base])(),
986
+ accent_color=_settings.color_int(req.accent, field="accent") or 0,
987
+ outbox_accent_color=_settings.color_int(req.outbox_accent, field="outbox_accent"),
988
+ message_colors=[
989
+ value
990
+ for part in (req.message_colors or "").split(",")
991
+ if part.strip() and (value := _settings.color_int(part, field="message_colors"))
992
+ ]
993
+ or None,
994
+ wallpaper=types.InputWallPaperSlug(slug=req.wallpaper) if req.wallpaper else None,
995
+ )
996
+ ]
997
+
998
+
999
+ SPEC_THEME_CREATE = OperationSpec(
1000
+ id="settings.theme.create",
1001
+ request=ThemeCreateReq,
1002
+ response=ThemeInstalled,
1003
+ impl=theme_create,
1004
+ summary="Publish or edit a cloud theme you own",
1005
+ mutating=True,
1006
+ rate_class="file",
1007
+ timeout_s=300,
1008
+ columns=("slug", "id", "title", "document_id"),
1009
+ headers=("Slug", "Id", "Title", "Document"),
1010
+ example={"slug": "Nord", "id": 991, "title": "Nord"},
1011
+ example_args="settings theme create Nord --file nord.tdesktop-theme",
1012
+ covers=("theme.create", "theme.update"),
1013
+ )
1014
+
1015
+
1016
+ class ThemeInstallReq(Request):
1017
+ slug: Annotated[str, arg(0, metavar="SLUG", help="The theme to install or save.")]
1018
+ dark: Annotated[bool, opt("--dark", help="Install it as the dark theme.")] = False
1019
+ save: Annotated[bool, opt("--save/--no-save", help="Also add it to the saved list.")] = True
1020
+ remove: Annotated[bool, opt("--remove", help="Remove it from the saved list instead.")] = False
1021
+ format: Annotated[str, opt("--format", metavar="NAME", help="Theming engine identifier.")] = (
1022
+ "tdesktop"
1023
+ )
1024
+
1025
+
1026
+ async def theme_install(ctx: OpContext, req: ThemeInstallReq) -> ThemeInstalled:
1027
+ """Install, save or remove a cloud theme for this account.
1028
+
1029
+ Server-side bookkeeping shared with the GUI clients: what tlgr changes
1030
+ here is what the phone signed into the same account will draw.
1031
+ """
1032
+ from telethon.tl import types
1033
+ from telethon.tl.functions import account as fn
1034
+
1035
+ handle = client(ctx)
1036
+ slug = req.slug.rsplit("/", 1)[-1]
1037
+ theme = types.InputThemeSlug(slug=slug)
1038
+
1039
+ if req.remove:
1040
+ await handle(fn.SaveThemeRequest(theme=theme, unsave=True))
1041
+ return ThemeInstalled(slug=slug, removed=True)
1042
+
1043
+ if req.save:
1044
+ await handle(fn.SaveThemeRequest(theme=theme, unsave=False))
1045
+ await handle(fn.InstallThemeRequest(dark=req.dark or None, theme=theme, format=req.format))
1046
+ ctx.emit("theme_installed", {"slug": slug, "dark": req.dark})
1047
+ return ThemeInstalled(slug=slug, installed=True, saved=req.save, dark=req.dark)
1048
+
1049
+
1050
+ SPEC_THEME_INSTALL = OperationSpec(
1051
+ id="settings.theme.install",
1052
+ request=ThemeInstallReq,
1053
+ response=ThemeInstalled,
1054
+ impl=theme_install,
1055
+ summary="Install / save / remove a cloud theme for this account",
1056
+ mutating=True,
1057
+ idempotent=True,
1058
+ rate_class="send",
1059
+ columns=("slug", "installed", "saved", "removed"),
1060
+ headers=("Slug", "Installed", "Saved", "Removed"),
1061
+ example={"slug": "Nord", "installed": True, "saved": True},
1062
+ example_args="settings theme install Nord",
1063
+ covers=("theme.save-install",),
1064
+ )
1065
+
1066
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]