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/notify.py
ADDED
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
"""The `notify` group: Settings ▸ Notifications and Sounds.
|
|
2
|
+
|
|
3
|
+
One screen in every official client, three unrelated server APIs behind it —
|
|
4
|
+
`account.getNotifySettings` for the scopes and the chats,
|
|
5
|
+
`account.getReactionsNotifySettings` for the reaction alerts,
|
|
6
|
+
`account.getContactSignUpNotification` for "X joined Telegram". `notify get`
|
|
7
|
+
and `notify set` take a *target* and pick the right one, because which RPC
|
|
8
|
+
answers a question is the server's business and not the caller's.
|
|
9
|
+
|
|
10
|
+
Two hazards live here.
|
|
11
|
+
|
|
12
|
+
* **`mute_until` is an absolute UNIX timestamp.** v1 computed it from the
|
|
13
|
+
asyncio event loop's clock — an arbitrary monotonic origin — so "mute for
|
|
14
|
+
an hour" produced a timestamp in 1970 and the chat was never muted. The
|
|
15
|
+
arithmetic is `int(time.time()) + seconds`, once, in `_mute_until`.
|
|
16
|
+
* **`inputPeerNotifySettings` fields are optional.** A field you do not send
|
|
17
|
+
is left untouched, which is why every switch here is `on|off|default` and
|
|
18
|
+
`default` means *remove the exception* rather than "set it to off".
|
|
19
|
+
|
|
20
|
+
The two whole-constructor APIs — reactions and contact-joined — are
|
|
21
|
+
read-modify-written, like every other replace-the-world call in this PR.
|
|
22
|
+
|
|
23
|
+
Telethon is imported inside functions, never at module scope (§2.2).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import time
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Annotated, Any
|
|
32
|
+
|
|
33
|
+
from tlgr.core.errors import UsageError
|
|
34
|
+
from tlgr.core.pagination import PageKind
|
|
35
|
+
from tlgr.core.timefmt import parse_duration
|
|
36
|
+
from tlgr.models.base import Request
|
|
37
|
+
from tlgr.models.notify import (
|
|
38
|
+
ExceptionsCleared,
|
|
39
|
+
NotifyException,
|
|
40
|
+
NotifyReset,
|
|
41
|
+
NotifyTarget,
|
|
42
|
+
Ringtone,
|
|
43
|
+
RingtoneSaved,
|
|
44
|
+
)
|
|
45
|
+
from tlgr.models.page import Page
|
|
46
|
+
from tlgr.models.peer import PeerRef
|
|
47
|
+
from tlgr.ops import _settings
|
|
48
|
+
from tlgr.ops._common import client
|
|
49
|
+
from tlgr.ops._params import arg, opt
|
|
50
|
+
from tlgr.ops._serialize import notify_settings
|
|
51
|
+
from tlgr.ops._spec import OpContext, OperationSpec
|
|
52
|
+
|
|
53
|
+
__all__ = [name for name in dir() if name.startswith("SPEC_")]
|
|
54
|
+
|
|
55
|
+
#: `mute_until` for "forever". Telegram's own sentinel, and the reason the
|
|
56
|
+
#: field is an int rather than a duration.
|
|
57
|
+
FOREVER = 2**31 - 1
|
|
58
|
+
|
|
59
|
+
#: The scope words `notify get`/`notify set` accept, and the `inputNotify*`
|
|
60
|
+
#: class each one names.
|
|
61
|
+
SCOPES: dict[str, str] = {
|
|
62
|
+
"private": "InputNotifyUsers",
|
|
63
|
+
"users": "InputNotifyUsers",
|
|
64
|
+
"groups": "InputNotifyChats",
|
|
65
|
+
"chats": "InputNotifyChats",
|
|
66
|
+
"channels": "InputNotifyBroadcasts",
|
|
67
|
+
"broadcasts": "InputNotifyBroadcasts",
|
|
68
|
+
"stories": "InputNotifyUsers",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
#: The three targets that are not a notify scope at all.
|
|
72
|
+
SPECIAL = ("reactions", "contact-joined")
|
|
73
|
+
|
|
74
|
+
#: `reactionNotificationsFrom*` ⇄ the word `--messages`/`--stories` take.
|
|
75
|
+
FROM_WORDS = {
|
|
76
|
+
"ReactionNotificationsFromContacts": "contacts",
|
|
77
|
+
"ReactionNotificationsFromAll": "all",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _mute_until(value: str | None) -> int | None:
|
|
82
|
+
"""`5m`/`2h`/`forever` as the absolute UNIX second the server wants.
|
|
83
|
+
|
|
84
|
+
`int(time.time())`, not the event loop's clock: `loop.time()` counts from
|
|
85
|
+
an arbitrary origin, and v1 used it here.
|
|
86
|
+
"""
|
|
87
|
+
if value is None:
|
|
88
|
+
return None
|
|
89
|
+
text = value.strip().lower()
|
|
90
|
+
if text in ("forever", "always"):
|
|
91
|
+
return FOREVER
|
|
92
|
+
seconds = parse_duration(text)
|
|
93
|
+
if seconds is None:
|
|
94
|
+
raise UsageError("--mute takes a duration (30s, 5m, 2h, 7d) or 'forever'", field="mute")
|
|
95
|
+
return int(time.time()) + int(seconds)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _scope_tl(name: str) -> Any:
|
|
99
|
+
from telethon.tl import types
|
|
100
|
+
|
|
101
|
+
return getattr(types, SCOPES[name])()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _from_tl(word: str | None, *, field: str) -> Any:
|
|
105
|
+
"""`contacts|all|off` as a `ReactionNotificationsFrom*`, or None for off."""
|
|
106
|
+
from telethon.tl import types
|
|
107
|
+
|
|
108
|
+
if word is None:
|
|
109
|
+
return ...
|
|
110
|
+
text = word.strip().lower()
|
|
111
|
+
if text in ("off", "none", "nobody"):
|
|
112
|
+
return None
|
|
113
|
+
if text == "contacts":
|
|
114
|
+
return types.ReactionNotificationsFromContacts()
|
|
115
|
+
if text == "all":
|
|
116
|
+
return types.ReactionNotificationsFromAll()
|
|
117
|
+
raise UsageError(f"--{field} takes contacts, all or off", field=field)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def _target(ctx: OpContext, target: str, topic: int | None) -> tuple[str, Any, int | None]:
|
|
121
|
+
"""`(kind, inputNotifyPeer or None, chat id)` for a target word or a chat ref."""
|
|
122
|
+
from telethon.tl import types
|
|
123
|
+
|
|
124
|
+
name = target.strip().lower()
|
|
125
|
+
if name in SPECIAL:
|
|
126
|
+
return name, None, None
|
|
127
|
+
if name in SCOPES:
|
|
128
|
+
return "scope", _scope_tl(name), None
|
|
129
|
+
peer = await _settings.resolve(ctx, target)
|
|
130
|
+
chat_id = _settings.peer_of(peer)
|
|
131
|
+
notify = (
|
|
132
|
+
types.InputNotifyForumTopic(peer=peer, top_msg_id=topic)
|
|
133
|
+
if topic is not None
|
|
134
|
+
else types.InputNotifyPeer(peer=peer)
|
|
135
|
+
)
|
|
136
|
+
return "peer", notify, chat_id
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _fill(model: NotifyTarget, raw: Any) -> NotifyTarget:
|
|
140
|
+
"""Copy a `peerNotifySettings` onto the flat answer, tri-state intact."""
|
|
141
|
+
settings = notify_settings(raw)
|
|
142
|
+
model.settings = settings
|
|
143
|
+
if settings is None:
|
|
144
|
+
return model
|
|
145
|
+
model.muted = settings.muted
|
|
146
|
+
model.mute_until = settings.mute_until
|
|
147
|
+
model.mute_until_unix = settings.mute_until_unix
|
|
148
|
+
model.show_previews = settings.show_previews
|
|
149
|
+
model.sound = settings.sound
|
|
150
|
+
model.stories_muted = settings.stories_muted
|
|
151
|
+
model.stories_hide_sender = settings.stories_hide_sender
|
|
152
|
+
model.stories_sound = settings.stories_sound
|
|
153
|
+
return model
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# notify get / set
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class GetReq(Request):
|
|
162
|
+
target: Annotated[
|
|
163
|
+
str,
|
|
164
|
+
arg(
|
|
165
|
+
0,
|
|
166
|
+
metavar="TARGET",
|
|
167
|
+
help="private | groups | channels | stories | reactions | contact-joined | <chat>",
|
|
168
|
+
),
|
|
169
|
+
]
|
|
170
|
+
topic: Annotated[int | None, opt("--topic", metavar="ID", help="A forum topic id.")] = None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def get(ctx: OpContext, req: GetReq) -> NotifyTarget:
|
|
174
|
+
"""Read notification settings for a scope, a chat, a topic, reactions
|
|
175
|
+
or the contact-joined toggle.
|
|
176
|
+
|
|
177
|
+
The `sound` is normalised to `default | none | local:<title> |
|
|
178
|
+
ringtone:<id>` — the same vocabulary `notify set --sound` accepts — even
|
|
179
|
+
though `peerNotifySettings` carries three per-platform sound fields and
|
|
180
|
+
the input constructor takes exactly one.
|
|
181
|
+
"""
|
|
182
|
+
from telethon.tl.functions import account as fn
|
|
183
|
+
|
|
184
|
+
handle = client(ctx)
|
|
185
|
+
kind, notify, chat_id = await _target(ctx, req.target, req.topic)
|
|
186
|
+
model = NotifyTarget(target=req.target, kind=kind, chat_id=chat_id, topic=req.topic)
|
|
187
|
+
|
|
188
|
+
if kind == "reactions":
|
|
189
|
+
raw = await handle(fn.GetReactionsNotifySettingsRequest())
|
|
190
|
+
model.messages_from = FROM_WORDS.get(
|
|
191
|
+
type(getattr(raw, "messages_notify_from", None)).__name__, "off"
|
|
192
|
+
)
|
|
193
|
+
model.stories_from = FROM_WORDS.get(
|
|
194
|
+
type(getattr(raw, "stories_notify_from", None)).__name__, "off"
|
|
195
|
+
)
|
|
196
|
+
model.poll_votes_from = FROM_WORDS.get(
|
|
197
|
+
type(getattr(raw, "poll_votes_notify_from", None)).__name__, "off"
|
|
198
|
+
)
|
|
199
|
+
model.show_previews = getattr(raw, "show_previews", None)
|
|
200
|
+
model.sound = _settings.sound_text(getattr(raw, "sound", None))
|
|
201
|
+
return model
|
|
202
|
+
|
|
203
|
+
if kind == "contact-joined":
|
|
204
|
+
raw = await handle(fn.GetContactSignUpNotificationRequest())
|
|
205
|
+
# Stored inverted on the wire: `silent=true` means the notification
|
|
206
|
+
# is OFF, which is exactly the sort of double negative a CLI should
|
|
207
|
+
# absorb rather than pass on.
|
|
208
|
+
model.contact_joined = not bool(raw)
|
|
209
|
+
return model
|
|
210
|
+
|
|
211
|
+
return _fill(model, await handle(fn.GetNotifySettingsRequest(peer=notify)))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
SPEC_GET = OperationSpec(
|
|
215
|
+
id="notify.get",
|
|
216
|
+
request=GetReq,
|
|
217
|
+
response=NotifyTarget,
|
|
218
|
+
impl=get,
|
|
219
|
+
summary="Read notification settings for a scope, chat, topic, reactions or contact-joined",
|
|
220
|
+
description=(
|
|
221
|
+
"One command over three server APIs, because the GUI presents them as "
|
|
222
|
+
"one Notifications screen. `contact-joined` is reported the way a "
|
|
223
|
+
"human reads it: `true` means the notification is on, even though the "
|
|
224
|
+
"wire stores the opposite."
|
|
225
|
+
),
|
|
226
|
+
idempotent=True,
|
|
227
|
+
columns=("target", "muted", "mute_until", "show_previews", "sound"),
|
|
228
|
+
headers=("Target", "Muted", "Until", "Previews", "Sound"),
|
|
229
|
+
example={
|
|
230
|
+
"target": "private",
|
|
231
|
+
"kind": "scope",
|
|
232
|
+
"muted": False,
|
|
233
|
+
"show_previews": True,
|
|
234
|
+
"sound": "default",
|
|
235
|
+
},
|
|
236
|
+
example_args="notify get private",
|
|
237
|
+
covers=(
|
|
238
|
+
"dialogs.reactions-notify",
|
|
239
|
+
"notify.contact-joined",
|
|
240
|
+
"notify.peer",
|
|
241
|
+
"notify.scope-channels",
|
|
242
|
+
"notify.scope-groups",
|
|
243
|
+
"notify.scope-private",
|
|
244
|
+
"notify.stories",
|
|
245
|
+
),
|
|
246
|
+
covers_partial=("notify.forum-topic", "notify.reactions", "notify.sound-selection"),
|
|
247
|
+
coverage_note="Writing any of them is `notify set`; the sound list is `notify ringtone list`.",
|
|
248
|
+
tags=frozenset({"agent-safe"}),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class SetReq(Request):
|
|
253
|
+
target: Annotated[
|
|
254
|
+
str,
|
|
255
|
+
arg(
|
|
256
|
+
0,
|
|
257
|
+
metavar="TARGET",
|
|
258
|
+
help="private | groups | channels | stories | reactions | contact-joined | <chat>",
|
|
259
|
+
),
|
|
260
|
+
]
|
|
261
|
+
mute: Annotated[
|
|
262
|
+
str | None, opt("--mute", metavar="FOR", help="Mute for this long, or 'forever'.")
|
|
263
|
+
] = None
|
|
264
|
+
unmute: Annotated[bool, opt("--unmute", help="Unmute (mute_until = 0).")] = False
|
|
265
|
+
preview: Annotated[
|
|
266
|
+
str | None, opt("--preview", metavar="ON|OFF", help="Message text in notifications.")
|
|
267
|
+
] = None
|
|
268
|
+
sound: Annotated[
|
|
269
|
+
str | None,
|
|
270
|
+
opt("--sound", metavar="SOUND", help="default | none | local:<title> | ringtone:<id>."),
|
|
271
|
+
] = None
|
|
272
|
+
topic: Annotated[int | None, opt("--topic", metavar="ID", help="A forum topic id.")] = None
|
|
273
|
+
stories_mute: Annotated[
|
|
274
|
+
str | None, opt("--stories-mute", metavar="ON|OFF", help="Mute this peer's stories.")
|
|
275
|
+
] = None
|
|
276
|
+
stories_hide_sender: Annotated[
|
|
277
|
+
str | None,
|
|
278
|
+
opt("--stories-hide-sender", metavar="ON|OFF", help="Hide the author on story alerts."),
|
|
279
|
+
] = None
|
|
280
|
+
stories_sound: Annotated[
|
|
281
|
+
str | None, opt("--stories-sound", metavar="SOUND", help="Sound for story alerts.")
|
|
282
|
+
] = None
|
|
283
|
+
messages: Annotated[
|
|
284
|
+
str | None,
|
|
285
|
+
opt("--messages", metavar="WHO", help="reactions: contacts|all|off for message reactions."),
|
|
286
|
+
] = None
|
|
287
|
+
stories: Annotated[
|
|
288
|
+
str | None, opt("--stories", metavar="WHO", help="reactions: story-reaction alerts.")
|
|
289
|
+
] = None
|
|
290
|
+
poll_votes: Annotated[
|
|
291
|
+
str | None, opt("--poll-votes", metavar="WHO", help="reactions: poll-vote alerts.")
|
|
292
|
+
] = None
|
|
293
|
+
on: Annotated[bool, opt("--on", help="contact-joined: enable the notification.")] = False
|
|
294
|
+
off: Annotated[bool, opt("--off", help="contact-joined: disable the notification.")] = False
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
async def set_(ctx: OpContext, req: SetReq) -> NotifyTarget:
|
|
298
|
+
"""Change notification settings for a scope, chat, topic, reactions or
|
|
299
|
+
the contact-joined toggle.
|
|
300
|
+
|
|
301
|
+
For a scope or a chat only the named fields are sent, because
|
|
302
|
+
`inputPeerNotifySettings` leaves an omitted field alone — that is what
|
|
303
|
+
makes "mute this chat" not also reset its sound. The reactions and
|
|
304
|
+
contact-joined APIs replace their whole constructor, so those two are
|
|
305
|
+
read first and written back complete.
|
|
306
|
+
"""
|
|
307
|
+
from telethon.tl import types
|
|
308
|
+
from telethon.tl.functions import account as fn
|
|
309
|
+
|
|
310
|
+
handle = client(ctx)
|
|
311
|
+
kind, notify, chat_id = await _target(ctx, req.target, req.topic)
|
|
312
|
+
changed: list[str] = []
|
|
313
|
+
|
|
314
|
+
if kind == "contact-joined":
|
|
315
|
+
if req.on == req.off:
|
|
316
|
+
raise UsageError("contact-joined takes --on or --off", field="on")
|
|
317
|
+
await handle(fn.SetContactSignUpNotificationRequest(silent=req.off))
|
|
318
|
+
ctx.emit("notify_set", {"target": "contact-joined", "enabled": req.on})
|
|
319
|
+
return NotifyTarget(
|
|
320
|
+
target=req.target, kind=kind, contact_joined=req.on, changed=["contact_joined"]
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
if kind == "reactions":
|
|
324
|
+
current = await handle(fn.GetReactionsNotifySettingsRequest())
|
|
325
|
+
values: dict[str, Any] = {
|
|
326
|
+
"messages_notify_from": getattr(current, "messages_notify_from", None),
|
|
327
|
+
"stories_notify_from": getattr(current, "stories_notify_from", None),
|
|
328
|
+
"poll_votes_notify_from": getattr(current, "poll_votes_notify_from", None),
|
|
329
|
+
"sound": getattr(current, "sound", None) or types.NotificationSoundDefault(),
|
|
330
|
+
"show_previews": bool(getattr(current, "show_previews", False)),
|
|
331
|
+
}
|
|
332
|
+
for flag, field in (
|
|
333
|
+
("messages", "messages_notify_from"),
|
|
334
|
+
("stories", "stories_notify_from"),
|
|
335
|
+
("poll_votes", "poll_votes_notify_from"),
|
|
336
|
+
):
|
|
337
|
+
value = _from_tl(getattr(req, flag), field=flag)
|
|
338
|
+
if value is not ...:
|
|
339
|
+
values[field] = value
|
|
340
|
+
changed.append(field)
|
|
341
|
+
if req.sound is not None:
|
|
342
|
+
values["sound"] = _settings.sound_value(req.sound)
|
|
343
|
+
changed.append("sound")
|
|
344
|
+
preview = _settings.on_off(req.preview, field="preview")
|
|
345
|
+
if preview is not None:
|
|
346
|
+
values["show_previews"] = preview
|
|
347
|
+
changed.append("show_previews")
|
|
348
|
+
if not changed:
|
|
349
|
+
raise UsageError(
|
|
350
|
+
"nothing to change: pass --messages, --stories, --poll-votes, --sound or --preview",
|
|
351
|
+
field="messages",
|
|
352
|
+
)
|
|
353
|
+
await handle(
|
|
354
|
+
fn.SetReactionsNotifySettingsRequest(settings=types.ReactionsNotifySettings(**values))
|
|
355
|
+
)
|
|
356
|
+
ctx.emit("notify_set", {"target": "reactions", "changed": changed})
|
|
357
|
+
result = await get(ctx, GetReq(target=req.target))
|
|
358
|
+
result.changed = changed
|
|
359
|
+
return result
|
|
360
|
+
|
|
361
|
+
kwargs: dict[str, Any] = {}
|
|
362
|
+
if req.unmute:
|
|
363
|
+
kwargs["mute_until"] = 0
|
|
364
|
+
changed.append("mute_until")
|
|
365
|
+
elif req.mute is not None:
|
|
366
|
+
kwargs["mute_until"] = _mute_until(req.mute)
|
|
367
|
+
changed.append("mute_until")
|
|
368
|
+
for flag, field in (
|
|
369
|
+
("preview", "show_previews"),
|
|
370
|
+
("stories_mute", "stories_muted"),
|
|
371
|
+
("stories_hide_sender", "stories_hide_sender"),
|
|
372
|
+
):
|
|
373
|
+
value = _settings.on_off(getattr(req, flag), field=flag)
|
|
374
|
+
if value is not None:
|
|
375
|
+
kwargs[field] = value
|
|
376
|
+
changed.append(field)
|
|
377
|
+
if req.sound is not None:
|
|
378
|
+
kwargs["sound"] = _settings.sound_value(req.sound)
|
|
379
|
+
changed.append("sound")
|
|
380
|
+
if req.stories_sound is not None:
|
|
381
|
+
kwargs["stories_sound"] = _settings.sound_value(req.stories_sound)
|
|
382
|
+
changed.append("stories_sound")
|
|
383
|
+
if not changed:
|
|
384
|
+
raise UsageError(
|
|
385
|
+
"nothing to change: pass --mute, --unmute, --preview or --sound", field="mute"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
await handle(
|
|
389
|
+
fn.UpdateNotifySettingsRequest(
|
|
390
|
+
peer=notify, settings=types.InputPeerNotifySettings(**kwargs)
|
|
391
|
+
)
|
|
392
|
+
)
|
|
393
|
+
ctx.emit("notify_set", {"target": req.target, "chat_id": chat_id, "changed": changed})
|
|
394
|
+
result = await get(ctx, GetReq(target=req.target, topic=req.topic))
|
|
395
|
+
result.changed = changed
|
|
396
|
+
return result
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
SPEC_SET = OperationSpec(
|
|
400
|
+
id="notify.set",
|
|
401
|
+
request=SetReq,
|
|
402
|
+
response=NotifyTarget,
|
|
403
|
+
impl=set_,
|
|
404
|
+
summary="Change notification settings for a scope, chat, topic, reactions or contact-joined",
|
|
405
|
+
description=(
|
|
406
|
+
"`mute_until` is an absolute UNIX timestamp; `--mute 2h` is turned "
|
|
407
|
+
"into one from the wall clock, which is the bug v1 had (it used the "
|
|
408
|
+
"event loop's clock and muted nothing). v1's `chat mute` is still its "
|
|
409
|
+
"own operation and keeps that path; this is the scope-and-target form."
|
|
410
|
+
),
|
|
411
|
+
mutating=True,
|
|
412
|
+
idempotent=True,
|
|
413
|
+
rate_class="send",
|
|
414
|
+
columns=("target", "muted", "mute_until", "show_previews", "sound"),
|
|
415
|
+
headers=("Target", "Muted", "Until", "Previews", "Sound"),
|
|
416
|
+
example={"target": "private", "muted": True, "mute_until": "2026-09-04T12:00:00Z"},
|
|
417
|
+
example_args="notify set private --mute 2h",
|
|
418
|
+
covers=(
|
|
419
|
+
"dialogs.notify-scope-defaults",
|
|
420
|
+
"gifts.channel-notifications",
|
|
421
|
+
"notify.forum-topic",
|
|
422
|
+
"notify.reactions",
|
|
423
|
+
"notify.sound-selection",
|
|
424
|
+
"stories.notify-global",
|
|
425
|
+
"stories.notify-peer",
|
|
426
|
+
"stories.notify-reactions",
|
|
427
|
+
),
|
|
428
|
+
covers_partial=(
|
|
429
|
+
"notify.contact-joined",
|
|
430
|
+
"notify.peer",
|
|
431
|
+
"notify.scope-channels",
|
|
432
|
+
"notify.scope-groups",
|
|
433
|
+
"notify.scope-private",
|
|
434
|
+
"notify.stories",
|
|
435
|
+
),
|
|
436
|
+
coverage_note="Reading any of them back is `notify get`.",
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
# ---------------------------------------------------------------------------
|
|
441
|
+
# notify exception list / clear
|
|
442
|
+
# ---------------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
class ExceptionListReq(Request):
|
|
446
|
+
scope: Annotated[
|
|
447
|
+
str | None, opt("--scope", metavar="SCOPE", help="private | groups | channels.")
|
|
448
|
+
] = None
|
|
449
|
+
compare_sound: Annotated[
|
|
450
|
+
bool, opt("--compare-sound", help="Count a differing sound as an exception.")
|
|
451
|
+
] = False
|
|
452
|
+
compare_stories: Annotated[
|
|
453
|
+
bool, opt("--compare-stories", help="Count differing story settings as an exception.")
|
|
454
|
+
] = False
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _scope_of(chat_id: int) -> str:
|
|
458
|
+
"""Which scope a chat inherits from, decided from its marked id."""
|
|
459
|
+
if chat_id > 0:
|
|
460
|
+
return "private"
|
|
461
|
+
return "channels" if str(chat_id).startswith("-100") else "groups"
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
async def exception_list(ctx: OpContext, req: ExceptionListReq) -> Page[NotifyException]:
|
|
465
|
+
"""Chats whose notification settings differ from their scope default.
|
|
466
|
+
|
|
467
|
+
The server answers with an `Updates` container rather than a list: the
|
|
468
|
+
exceptions arrive as `updateNotifySettings` entries alongside the users
|
|
469
|
+
and chats vectors, so the rows are assembled from the updates and named
|
|
470
|
+
from the vectors.
|
|
471
|
+
"""
|
|
472
|
+
from telethon.tl.functions import account as fn
|
|
473
|
+
|
|
474
|
+
result = await client(ctx)(
|
|
475
|
+
fn.GetNotifyExceptionsRequest(
|
|
476
|
+
compare_sound=req.compare_sound or None,
|
|
477
|
+
compare_stories=req.compare_stories or None,
|
|
478
|
+
)
|
|
479
|
+
)
|
|
480
|
+
known = _settings.entity_map(result)
|
|
481
|
+
rows: list[NotifyException] = []
|
|
482
|
+
for update in getattr(result, "updates", None) or []:
|
|
483
|
+
peer = getattr(getattr(update, "peer", None), "peer", None)
|
|
484
|
+
if peer is None:
|
|
485
|
+
continue
|
|
486
|
+
from tlgr.ops._serialize import peer_id_of
|
|
487
|
+
|
|
488
|
+
chat_id = peer_id_of(peer)
|
|
489
|
+
if chat_id is None:
|
|
490
|
+
continue
|
|
491
|
+
settings = notify_settings(getattr(update, "notify_settings", None))
|
|
492
|
+
entity = known.get(abs(chat_id) if chat_id < 0 else chat_id)
|
|
493
|
+
scope = _scope_of(chat_id)
|
|
494
|
+
if req.scope and scope != req.scope.strip().lower():
|
|
495
|
+
continue
|
|
496
|
+
rows.append(
|
|
497
|
+
NotifyException(
|
|
498
|
+
chat_id=chat_id,
|
|
499
|
+
chat=_settings.peer_model(entity),
|
|
500
|
+
title=str(
|
|
501
|
+
getattr(entity, "title", None) or getattr(entity, "first_name", "") or ""
|
|
502
|
+
),
|
|
503
|
+
muted=bool(settings and settings.muted),
|
|
504
|
+
mute_until=settings.mute_until if settings else None,
|
|
505
|
+
mute_until_unix=settings.mute_until_unix if settings else None,
|
|
506
|
+
show_previews=settings.show_previews if settings else None,
|
|
507
|
+
sound=settings.sound if settings else None,
|
|
508
|
+
stories_muted=settings.stories_muted if settings else None,
|
|
509
|
+
scope=scope,
|
|
510
|
+
)
|
|
511
|
+
)
|
|
512
|
+
return Page(items=rows, has_more=False, total=len(rows))
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
SPEC_EXCEPTION_LIST = OperationSpec(
|
|
516
|
+
id="notify.exception.list",
|
|
517
|
+
request=ExceptionListReq,
|
|
518
|
+
response=Page[NotifyException],
|
|
519
|
+
impl=exception_list,
|
|
520
|
+
summary="List chats whose notification settings differ from their scope default",
|
|
521
|
+
paginated=PageKind.LOCAL,
|
|
522
|
+
idempotent=True,
|
|
523
|
+
columns=("chat_id", "title", "muted", "mute_until", "sound", "scope"),
|
|
524
|
+
headers=("Chat", "Title", "Muted", "Until", "Sound", "Scope"),
|
|
525
|
+
example={
|
|
526
|
+
"items": [{"chat_id": -1001, "title": "Noisy group", "muted": True, "scope": "groups"}],
|
|
527
|
+
"has_more": False,
|
|
528
|
+
},
|
|
529
|
+
example_args="notify exception list",
|
|
530
|
+
covers=("dialogs.notify-exceptions", "stories.notify-exceptions"),
|
|
531
|
+
covers_partial=("notify.exceptions-list",),
|
|
532
|
+
coverage_note="Dropping an exception is `notify exception clear`.",
|
|
533
|
+
tags=frozenset({"agent-safe"}),
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
class ExceptionClearReq(Request):
|
|
538
|
+
chat: Annotated[
|
|
539
|
+
tuple[PeerRef, ...],
|
|
540
|
+
arg(0, metavar="CHAT", required=False, variadic=True, kind="peer", help="Chats to reset."),
|
|
541
|
+
] = ()
|
|
542
|
+
every: Annotated[bool, opt("--every", help="Clear every exception in --scope.")] = False
|
|
543
|
+
scope: Annotated[str | None, opt("--scope", metavar="SCOPE", help="Scope for --every.")] = None
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
async def exception_clear(ctx: OpContext, req: ExceptionClearReq) -> ExceptionsCleared:
|
|
547
|
+
"""Drop per-chat overrides so those chats follow their scope default.
|
|
548
|
+
|
|
549
|
+
There is no "delete exception" method: an empty `inputPeerNotifySettings`
|
|
550
|
+
is what removes one, because every field of it is optional and an unset
|
|
551
|
+
field means "inherit".
|
|
552
|
+
"""
|
|
553
|
+
from telethon.tl import types
|
|
554
|
+
from telethon.tl.functions import account as fn
|
|
555
|
+
|
|
556
|
+
handle = client(ctx)
|
|
557
|
+
targets: list[int] = []
|
|
558
|
+
peers: list[Any] = []
|
|
559
|
+
for ref in req.chat:
|
|
560
|
+
peer = await _settings.resolve(ctx, ref)
|
|
561
|
+
peers.append(peer)
|
|
562
|
+
targets.append(_settings.peer_of(peer))
|
|
563
|
+
|
|
564
|
+
if req.every:
|
|
565
|
+
rows = await exception_list(ctx, ExceptionListReq(scope=req.scope))
|
|
566
|
+
for row in rows.items:
|
|
567
|
+
if row.chat_id in targets:
|
|
568
|
+
continue
|
|
569
|
+
peers.append(await _settings.resolve(ctx, str(row.chat_id)))
|
|
570
|
+
targets.append(row.chat_id)
|
|
571
|
+
elif not peers:
|
|
572
|
+
raise UsageError("give one or more chats, or --every --scope <scope>", field="chat")
|
|
573
|
+
|
|
574
|
+
if not peers:
|
|
575
|
+
return ExceptionsCleared(cleared=0, scope=req.scope, already=True)
|
|
576
|
+
for peer in peers:
|
|
577
|
+
await handle(
|
|
578
|
+
fn.UpdateNotifySettingsRequest(
|
|
579
|
+
peer=types.InputNotifyPeer(peer=peer),
|
|
580
|
+
settings=types.InputPeerNotifySettings(),
|
|
581
|
+
)
|
|
582
|
+
)
|
|
583
|
+
ctx.emit("notify_exceptions_cleared", {"chat_ids": targets})
|
|
584
|
+
return ExceptionsCleared(cleared=len(targets), chat_ids=targets, scope=req.scope)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
SPEC_EXCEPTION_CLEAR = OperationSpec(
|
|
588
|
+
id="notify.exception.clear",
|
|
589
|
+
request=ExceptionClearReq,
|
|
590
|
+
response=ExceptionsCleared,
|
|
591
|
+
impl=exception_clear,
|
|
592
|
+
summary="Drop per-chat notification overrides so the chats follow their scope default",
|
|
593
|
+
mutating=True,
|
|
594
|
+
idempotent=True,
|
|
595
|
+
rate_class="bulk",
|
|
596
|
+
columns=("cleared", "chat_ids", "scope"),
|
|
597
|
+
headers=("Cleared", "Chats", "Scope"),
|
|
598
|
+
example={"cleared": 2, "chat_ids": [-1001, 777123]},
|
|
599
|
+
example_args="notify exception clear @noisy",
|
|
600
|
+
covers=("notify.exceptions-list",),
|
|
601
|
+
covers_partial=("notify.peer",),
|
|
602
|
+
coverage_note="Setting one chat's exception is `notify set <chat>`.",
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
# ---------------------------------------------------------------------------
|
|
607
|
+
# notify reset
|
|
608
|
+
# ---------------------------------------------------------------------------
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
class ResetReq(Request):
|
|
612
|
+
pass
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
async def reset(ctx: OpContext, req: ResetReq) -> NotifyReset:
|
|
616
|
+
"""Reset every notification setting — scopes and per-chat — to the defaults.
|
|
617
|
+
|
|
618
|
+
Irreversible in the only sense that matters: the exceptions are gone and
|
|
619
|
+
the server does not say what they were. `notify exception list` before
|
|
620
|
+
running this is the backup.
|
|
621
|
+
"""
|
|
622
|
+
from telethon.tl.functions import account as fn
|
|
623
|
+
|
|
624
|
+
await client(ctx)(fn.ResetNotifySettingsRequest())
|
|
625
|
+
ctx.emit("notify_reset", {})
|
|
626
|
+
return NotifyReset(ok=True)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
SPEC_RESET = OperationSpec(
|
|
630
|
+
id="notify.reset",
|
|
631
|
+
request=ResetReq,
|
|
632
|
+
response=NotifyReset,
|
|
633
|
+
impl=reset,
|
|
634
|
+
summary="Reset every notification setting (scopes and per-chat) to Telegram's defaults",
|
|
635
|
+
aliases=("notify.reset-all",),
|
|
636
|
+
mutating=True,
|
|
637
|
+
destructive=True,
|
|
638
|
+
rate_class="send",
|
|
639
|
+
columns=("ok",),
|
|
640
|
+
headers=("OK",),
|
|
641
|
+
example={"ok": True},
|
|
642
|
+
example_args="notify reset",
|
|
643
|
+
covers=("notify.reset-all",),
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
# ---------------------------------------------------------------------------
|
|
648
|
+
# notify ringtone list / set
|
|
649
|
+
# ---------------------------------------------------------------------------
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
class RingtoneListReq(Request):
|
|
653
|
+
pass
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
async def ringtone_list(ctx: OpContext, req: RingtoneListReq) -> Page[Ringtone]:
|
|
657
|
+
"""Saved notification sounds, with the ids `notify set --sound` takes."""
|
|
658
|
+
from telethon.tl.functions import account as fn
|
|
659
|
+
|
|
660
|
+
result = await client(ctx)(fn.GetSavedRingtonesRequest(hash=0))
|
|
661
|
+
rows = [
|
|
662
|
+
Ringtone(
|
|
663
|
+
id=int(getattr(document, "id", 0) or 0),
|
|
664
|
+
access_hash=getattr(document, "access_hash", None),
|
|
665
|
+
file_name=next(
|
|
666
|
+
(
|
|
667
|
+
str(getattr(attribute, "file_name", ""))
|
|
668
|
+
for attribute in getattr(document, "attributes", None) or []
|
|
669
|
+
if type(attribute).__name__ == "DocumentAttributeFilename"
|
|
670
|
+
),
|
|
671
|
+
"",
|
|
672
|
+
),
|
|
673
|
+
mime_type=str(getattr(document, "mime_type", "") or ""),
|
|
674
|
+
size=int(getattr(document, "size", 0) or 0),
|
|
675
|
+
duration=next(
|
|
676
|
+
(
|
|
677
|
+
int(getattr(attribute, "duration", 0) or 0)
|
|
678
|
+
for attribute in getattr(document, "attributes", None) or []
|
|
679
|
+
if type(attribute).__name__ == "DocumentAttributeAudio"
|
|
680
|
+
),
|
|
681
|
+
None,
|
|
682
|
+
),
|
|
683
|
+
)
|
|
684
|
+
for document in getattr(result, "ringtones", None) or []
|
|
685
|
+
]
|
|
686
|
+
return Page(items=rows, has_more=False, total=len(rows))
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
SPEC_RINGTONE_LIST = OperationSpec(
|
|
690
|
+
id="notify.ringtone.list",
|
|
691
|
+
request=RingtoneListReq,
|
|
692
|
+
response=Page[Ringtone],
|
|
693
|
+
impl=ringtone_list,
|
|
694
|
+
summary="List saved notification sounds",
|
|
695
|
+
description="The `id` of a row is what `notify set --sound ringtone:<id>` takes.",
|
|
696
|
+
paginated=PageKind.LOCAL,
|
|
697
|
+
idempotent=True,
|
|
698
|
+
columns=("id", "file_name", "mime_type", "size", "duration"),
|
|
699
|
+
headers=("Id", "File", "Type", "Bytes", "Seconds"),
|
|
700
|
+
example={
|
|
701
|
+
"items": [{"id": 8811, "file_name": "chime.ogg", "mime_type": "audio/ogg", "size": 20480}],
|
|
702
|
+
"has_more": False,
|
|
703
|
+
},
|
|
704
|
+
example_args="notify ringtone list",
|
|
705
|
+
covers=("notify.ringtones-list",),
|
|
706
|
+
tags=frozenset({"agent-safe"}),
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
class RingtoneSetReq(Request):
|
|
711
|
+
file: Annotated[
|
|
712
|
+
str | None,
|
|
713
|
+
arg(0, metavar="FILE", required=False, kind="path", help="MP3 or OGG/OPUS to upload."),
|
|
714
|
+
] = None
|
|
715
|
+
from_message: Annotated[
|
|
716
|
+
str | None,
|
|
717
|
+
opt("--from-message", metavar="CHAT:ID", help="Save a voice message as a ringtone."),
|
|
718
|
+
] = None
|
|
719
|
+
remove: Annotated[
|
|
720
|
+
str | None, opt("--remove", metavar="ID", help="Document id of a saved ringtone.")
|
|
721
|
+
] = None
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
async def ringtone_set(ctx: OpContext, req: RingtoneSetReq) -> RingtoneSaved:
|
|
725
|
+
"""Upload a notification sound, save an existing voice message, or remove one.
|
|
726
|
+
|
|
727
|
+
Saving an *existing* document can hand back a different one:
|
|
728
|
+
`account.savedRingtoneConverted` carries a NEW document id, and using the
|
|
729
|
+
old one afterwards fails. `converted: true` says that happened, and `id`
|
|
730
|
+
is always the id that now works.
|
|
731
|
+
"""
|
|
732
|
+
import mimetypes
|
|
733
|
+
|
|
734
|
+
from telethon.tl import types
|
|
735
|
+
from telethon.tl.functions import account as fn
|
|
736
|
+
|
|
737
|
+
handle = client(ctx)
|
|
738
|
+
|
|
739
|
+
if req.remove:
|
|
740
|
+
if not req.remove.strip().isdigit():
|
|
741
|
+
raise UsageError("--remove wants a saved ringtone's document id", field="remove")
|
|
742
|
+
document = await _saved_ringtone(ctx, int(req.remove))
|
|
743
|
+
await handle(fn.SaveRingtoneRequest(id=document, unsave=True))
|
|
744
|
+
return RingtoneSaved(id=int(req.remove), removed=True)
|
|
745
|
+
|
|
746
|
+
if req.from_message:
|
|
747
|
+
from tlgr.ops import _media
|
|
748
|
+
|
|
749
|
+
chat, _, msg_id = req.from_message.rpartition(":")
|
|
750
|
+
if not chat or not msg_id.strip().lstrip("-").isdigit():
|
|
751
|
+
raise UsageError("--from-message wants '<chat>:<msg_id>'", field="from_message")
|
|
752
|
+
peer = await _settings.resolve(ctx, chat)
|
|
753
|
+
message = await _media.fetch_message(ctx, peer, int(msg_id))
|
|
754
|
+
document = _media.input_document(_media.document_of(getattr(message, "media", None)))
|
|
755
|
+
answer = await handle(fn.SaveRingtoneRequest(id=document, unsave=False))
|
|
756
|
+
converted = type(answer).__name__ == "AccountSavedRingtoneConverted"
|
|
757
|
+
new_document = getattr(answer, "document", None)
|
|
758
|
+
return RingtoneSaved(
|
|
759
|
+
id=int(getattr(new_document, "id", 0) or 0) or int(msg_id),
|
|
760
|
+
converted=converted,
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
if not req.file:
|
|
764
|
+
raise UsageError("give a FILE, --from-message or --remove", field="file")
|
|
765
|
+
path = Path(os.path.expanduser(req.file))
|
|
766
|
+
if not path.exists():
|
|
767
|
+
raise UsageError(f"{req.file} does not exist", field="file")
|
|
768
|
+
limits = await _settings.app_config(ctx)
|
|
769
|
+
size_max = int(limits.get("ringtone_size_max") or 0)
|
|
770
|
+
if size_max and path.stat().st_size > size_max:
|
|
771
|
+
raise UsageError(
|
|
772
|
+
f"{path.name} is larger than the server's ringtone_size_max ({size_max} bytes)",
|
|
773
|
+
field="file",
|
|
774
|
+
)
|
|
775
|
+
upload = getattr(ctx, "upload_file", None)
|
|
776
|
+
if upload is None: # pragma: no cover - the daemon always supplies one
|
|
777
|
+
raise UsageError("this context cannot upload files")
|
|
778
|
+
uploaded = await handle(
|
|
779
|
+
fn.UploadRingtoneRequest(
|
|
780
|
+
file=await upload(path),
|
|
781
|
+
file_name=path.name,
|
|
782
|
+
mime_type=mimetypes.guess_type(path.name)[0] or "audio/mpeg",
|
|
783
|
+
)
|
|
784
|
+
)
|
|
785
|
+
document = types.InputDocument(
|
|
786
|
+
id=getattr(uploaded, "id", 0),
|
|
787
|
+
access_hash=getattr(uploaded, "access_hash", 0),
|
|
788
|
+
file_reference=getattr(uploaded, "file_reference", b"") or b"",
|
|
789
|
+
)
|
|
790
|
+
answer = await handle(fn.SaveRingtoneRequest(id=document, unsave=False))
|
|
791
|
+
ctx.emit("ringtone_saved", {"file_name": path.name})
|
|
792
|
+
return RingtoneSaved(
|
|
793
|
+
id=int(getattr(uploaded, "id", 0) or 0),
|
|
794
|
+
file_name=path.name,
|
|
795
|
+
converted=type(answer).__name__ == "AccountSavedRingtoneConverted",
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
async def _saved_ringtone(ctx: OpContext, document_id: int) -> Any:
|
|
800
|
+
"""The `InputDocument` for a saved ringtone, with its live file reference."""
|
|
801
|
+
from telethon.tl import types
|
|
802
|
+
from telethon.tl.functions import account as fn
|
|
803
|
+
|
|
804
|
+
result = await client(ctx)(fn.GetSavedRingtonesRequest(hash=0))
|
|
805
|
+
for document in getattr(result, "ringtones", None) or []:
|
|
806
|
+
if int(getattr(document, "id", 0) or 0) == document_id:
|
|
807
|
+
return types.InputDocument(
|
|
808
|
+
id=document.id,
|
|
809
|
+
access_hash=document.access_hash,
|
|
810
|
+
file_reference=getattr(document, "file_reference", b"") or b"",
|
|
811
|
+
)
|
|
812
|
+
raise UsageError(f"{document_id} is not a saved ringtone", field="remove")
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
SPEC_RINGTONE_SET = OperationSpec(
|
|
816
|
+
id="notify.ringtone.set",
|
|
817
|
+
request=RingtoneSetReq,
|
|
818
|
+
response=RingtoneSaved,
|
|
819
|
+
impl=ringtone_set,
|
|
820
|
+
summary="Upload a notification sound, save a voice message as one, or remove one",
|
|
821
|
+
description=(
|
|
822
|
+
"Saving an existing document may return a *converted* one with a new "
|
|
823
|
+
"id; `converted: true` says so and `id` is always the usable one."
|
|
824
|
+
),
|
|
825
|
+
mutating=True,
|
|
826
|
+
rate_class="file",
|
|
827
|
+
timeout_s=300,
|
|
828
|
+
columns=("id", "file_name", "converted", "removed"),
|
|
829
|
+
headers=("Id", "File", "Converted", "Removed"),
|
|
830
|
+
example={"id": 8811, "file_name": "chime.ogg", "converted": False},
|
|
831
|
+
example_args="notify ringtone set chime.ogg",
|
|
832
|
+
covers=(
|
|
833
|
+
"notify.ringtone-remove",
|
|
834
|
+
"notify.ringtone-upload",
|
|
835
|
+
"ringtone.manage",
|
|
836
|
+
"ringtone.set-for-chat",
|
|
837
|
+
),
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
__all__ = [name for name in dir() if name.startswith("SPEC_")]
|