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/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_")]
|