tlgr-cli 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. tlgr/__init__.py +3 -0
  2. tlgr/__main__.py +6 -0
  3. tlgr/actions/__init__.py +45 -0
  4. tlgr/actions/forward.py +74 -0
  5. tlgr/actions/reply.py +32 -0
  6. tlgr/cli/__init__.py +259 -0
  7. tlgr/cli/confirm.py +55 -0
  8. tlgr/cli/errors.py +84 -0
  9. tlgr/cli/gen.py +690 -0
  10. tlgr/cli/globals.py +273 -0
  11. tlgr/cli/introspect.py +170 -0
  12. tlgr/cli/params.py +189 -0
  13. tlgr/cli/render.py +418 -0
  14. tlgr/core/__init__.py +0 -0
  15. tlgr/core/accounts.py +384 -0
  16. tlgr/core/config.py +358 -0
  17. tlgr/core/custom_tl.py +170 -0
  18. tlgr/core/errors.py +687 -0
  19. tlgr/core/eventtypes.py +1170 -0
  20. tlgr/core/identity.py +127 -0
  21. tlgr/core/launchd.py +122 -0
  22. tlgr/core/logging.py +194 -0
  23. tlgr/core/media.py +134 -0
  24. tlgr/core/output.py +251 -0
  25. tlgr/core/pagination.py +227 -0
  26. tlgr/core/paths.py +360 -0
  27. tlgr/core/peers.py +427 -0
  28. tlgr/core/process.py +138 -0
  29. tlgr/core/signing.py +38 -0
  30. tlgr/core/systemd.py +96 -0
  31. tlgr/core/telethon_compat.py +295 -0
  32. tlgr/core/text.py +211 -0
  33. tlgr/core/timefmt.py +199 -0
  34. tlgr/core/tl.py +98 -0
  35. tlgr/daemon/__init__.py +0 -0
  36. tlgr/daemon/app.py +869 -0
  37. tlgr/daemon/dispatch.py +446 -0
  38. tlgr/daemon/events.py +723 -0
  39. tlgr/daemon/files.py +431 -0
  40. tlgr/daemon/idle.py +119 -0
  41. tlgr/daemon/jobs.py +68 -0
  42. tlgr/daemon/main.py +161 -0
  43. tlgr/daemon/peercred.py +75 -0
  44. tlgr/daemon/policy.py +113 -0
  45. tlgr/daemon/preauth.py +366 -0
  46. tlgr/daemon/ratelimit.py +391 -0
  47. tlgr/daemon/server.py +24 -0
  48. tlgr/daemon/session.py +648 -0
  49. tlgr/daemon/sessions.py +274 -0
  50. tlgr/daemon/singleton.py +114 -0
  51. tlgr/daemon/stream.py +193 -0
  52. tlgr/daemon/transfers.py +219 -0
  53. tlgr/daemon/webhook.py +390 -0
  54. tlgr/data/catalog_index.json +1 -0
  55. tlgr/data/parity_waivers.toml +90 -0
  56. tlgr/filters/__init__.py +42 -0
  57. tlgr/filters/compose.py +121 -0
  58. tlgr/filters/content.py +85 -0
  59. tlgr/filters/context.py +114 -0
  60. tlgr/filters/message.py +161 -0
  61. tlgr/filters/temporal.py +87 -0
  62. tlgr/filters/user.py +36 -0
  63. tlgr/gateway/__init__.py +1 -0
  64. tlgr/gateway/config.py +161 -0
  65. tlgr/gateway/engine.py +215 -0
  66. tlgr/gateway/event.py +22 -0
  67. tlgr/jobs/__init__.py +0 -0
  68. tlgr/jobs/base.py +81 -0
  69. tlgr/jobs/client.py +37 -0
  70. tlgr/models/__init__.py +1220 -0
  71. tlgr/models/admin.py +744 -0
  72. tlgr/models/auth.py +510 -0
  73. tlgr/models/base.py +81 -0
  74. tlgr/models/bot.py +576 -0
  75. tlgr/models/business.py +265 -0
  76. tlgr/models/call.py +586 -0
  77. tlgr/models/config.py +101 -0
  78. tlgr/models/contact.py +481 -0
  79. tlgr/models/daemon.py +336 -0
  80. tlgr/models/dialog.py +626 -0
  81. tlgr/models/envelope.py +68 -0
  82. tlgr/models/error.py +30 -0
  83. tlgr/models/event.py +79 -0
  84. tlgr/models/export.py +66 -0
  85. tlgr/models/gift.py +275 -0
  86. tlgr/models/inline.py +84 -0
  87. tlgr/models/location.py +115 -0
  88. tlgr/models/media.py +507 -0
  89. tlgr/models/message.py +584 -0
  90. tlgr/models/net.py +232 -0
  91. tlgr/models/notify.py +105 -0
  92. tlgr/models/page.py +32 -0
  93. tlgr/models/payment.py +172 -0
  94. tlgr/models/peer.py +400 -0
  95. tlgr/models/poll.py +119 -0
  96. tlgr/models/premium.py +161 -0
  97. tlgr/models/privacy.py +93 -0
  98. tlgr/models/profile.py +217 -0
  99. tlgr/models/reaction.py +160 -0
  100. tlgr/models/resolve.py +175 -0
  101. tlgr/models/settings.py +103 -0
  102. tlgr/models/stars.py +101 -0
  103. tlgr/models/sticker.py +243 -0
  104. tlgr/models/story.py +467 -0
  105. tlgr/models/sync.py +105 -0
  106. tlgr/models/todo.py +36 -0
  107. tlgr/models/webapp.py +89 -0
  108. tlgr/ops/__init__.py +63 -0
  109. tlgr/ops/_admin.py +313 -0
  110. tlgr/ops/_auth.py +599 -0
  111. tlgr/ops/_bots.py +586 -0
  112. tlgr/ops/_calls.py +535 -0
  113. tlgr/ops/_common.py +160 -0
  114. tlgr/ops/_layer.py +46 -0
  115. tlgr/ops/_media.py +592 -0
  116. tlgr/ops/_params.py +212 -0
  117. tlgr/ops/_rights.py +402 -0
  118. tlgr/ops/_send.py +593 -0
  119. tlgr/ops/_serialize.py +667 -0
  120. tlgr/ops/_settings.py +306 -0
  121. tlgr/ops/_spec.py +167 -0
  122. tlgr/ops/_story.py +743 -0
  123. tlgr/ops/account.py +2604 -0
  124. tlgr/ops/agent.py +937 -0
  125. tlgr/ops/auth.py +1282 -0
  126. tlgr/ops/bot.py +4880 -0
  127. tlgr/ops/business.py +1520 -0
  128. tlgr/ops/call.py +1610 -0
  129. tlgr/ops/chat.py +4025 -0
  130. tlgr/ops/chat_admin.py +929 -0
  131. tlgr/ops/chat_extra.py +1061 -0
  132. tlgr/ops/chat_invite.py +716 -0
  133. tlgr/ops/chat_manage.py +1691 -0
  134. tlgr/ops/chat_member.py +1357 -0
  135. tlgr/ops/chat_stats.py +902 -0
  136. tlgr/ops/chat_topic.py +905 -0
  137. tlgr/ops/conference.py +791 -0
  138. tlgr/ops/config.py +1698 -0
  139. tlgr/ops/contact.py +2330 -0
  140. tlgr/ops/daemon.py +1397 -0
  141. tlgr/ops/draft.py +299 -0
  142. tlgr/ops/emoji.py +343 -0
  143. tlgr/ops/events.py +1327 -0
  144. tlgr/ops/export.py +596 -0
  145. tlgr/ops/folder.py +1322 -0
  146. tlgr/ops/gif.py +522 -0
  147. tlgr/ops/gift.py +1546 -0
  148. tlgr/ops/giveaway.py +541 -0
  149. tlgr/ops/inline.py +773 -0
  150. tlgr/ops/job.py +799 -0
  151. tlgr/ops/location.py +917 -0
  152. tlgr/ops/media.py +4495 -0
  153. tlgr/ops/message.py +3769 -0
  154. tlgr/ops/net.py +536 -0
  155. tlgr/ops/notify.py +840 -0
  156. tlgr/ops/passport.py +464 -0
  157. tlgr/ops/payment.py +907 -0
  158. tlgr/ops/poll.py +1078 -0
  159. tlgr/ops/premium.py +488 -0
  160. tlgr/ops/privacy.py +794 -0
  161. tlgr/ops/profile.py +1481 -0
  162. tlgr/ops/proxy.py +750 -0
  163. tlgr/ops/reaction.py +1475 -0
  164. tlgr/ops/resolve.py +1140 -0
  165. tlgr/ops/search.py +521 -0
  166. tlgr/ops/settings.py +1066 -0
  167. tlgr/ops/stars.py +594 -0
  168. tlgr/ops/sticker.py +1602 -0
  169. tlgr/ops/story.py +3216 -0
  170. tlgr/ops/sync.py +788 -0
  171. tlgr/ops/todo.py +514 -0
  172. tlgr/ops/user.py +1406 -0
  173. tlgr/ops/vc.py +2351 -0
  174. tlgr/ops/webapp.py +717 -0
  175. tlgr/ops/webhook.py +418 -0
  176. tlgr/parity.py +386 -0
  177. tlgr/processors/__init__.py +125 -0
  178. tlgr/processors/regex.py +26 -0
  179. tlgr/processors/text.py +56 -0
  180. tlgr/registry.py +519 -0
  181. tlgr/schema.py +173 -0
  182. tlgr/transport/__init__.py +30 -0
  183. tlgr/transport/autostart.py +293 -0
  184. tlgr/transport/client.py +805 -0
  185. tlgr/transport/ndjson.py +44 -0
  186. tlgr/version.py +31 -0
  187. tlgr_cli-2.0.1.dist-info/METADATA +957 -0
  188. tlgr_cli-2.0.1.dist-info/RECORD +192 -0
  189. tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
  190. tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
  191. tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
  192. tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/ops/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_")]