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/reaction.py ADDED
@@ -0,0 +1,1475 @@
1
+ """The `reaction` group: reacting, reading reactions, and the policy around them.
2
+
3
+ `messages.sendReaction` takes the **whole desired state**, not a delta. Adding
4
+ a second reaction means resending the first one too, and removing one means
5
+ resending the others; a client that treats it as "add this emoji" silently
6
+ replaces everything the account had already put on the message. Every write
7
+ here therefore reads my current reactions first, and `--replace` is the
8
+ explicit way to ask for the destructive reading.
9
+
10
+ One spelling for a reaction across the group: a unicode emoji is itself, and a
11
+ custom (Premium) emoji is `custom:<document_id>`. `reaction list` hands back
12
+ exactly what `reaction remove` accepts.
13
+
14
+ Telethon is imported inside functions, never at module scope (§2.2).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Annotated, Any
20
+
21
+ from tlgr.core.errors import NotFoundError, UsageError
22
+ from tlgr.core.pagination import PageKind, build_page
23
+ from tlgr.core.timefmt import fmt_dt, to_unix
24
+ from tlgr.models.base import Request
25
+ from tlgr.models.message import Message
26
+ from tlgr.models.page import Page
27
+ from tlgr.models.peer import PeerRef
28
+ from tlgr.models.reaction import (
29
+ AvailableReaction,
30
+ ChatReactions,
31
+ MessageReactionState,
32
+ PaidReactionResult,
33
+ ReactionPrivacy,
34
+ ReactionPurge,
35
+ ReactionReport,
36
+ ReactionResult,
37
+ ReactionTag,
38
+ ReactionUser,
39
+ TopReactor,
40
+ )
41
+ from tlgr.ops import _send
42
+ from tlgr.ops._common import (
43
+ affected_loop,
44
+ already,
45
+ client,
46
+ ids,
47
+ input_channel,
48
+ is_not_modified,
49
+ window,
50
+ )
51
+ from tlgr.ops._params import arg, choice, opt
52
+ from tlgr.ops._serialize import message_to_model, peer_id_of, reactions_summary
53
+ from tlgr.ops._spec import OpContext, OperationSpec
54
+
55
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
56
+
57
+ #: The prefix that names a custom (Premium) emoji reaction in tlgr's JSON.
58
+ CUSTOM = "custom:"
59
+
60
+ _EXAMPLE_REACT: dict[str, Any] = {
61
+ "chat_id": 777123,
62
+ "msg_id": 12345,
63
+ "emoji": "👍",
64
+ "reacted": True,
65
+ "mine": ["👍"],
66
+ "reactions": {"counts": {"👍": 4}, "mine": ["👍"], "total": 4},
67
+ }
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # The one spelling of a reaction
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ def name_of(reaction: Any) -> str:
76
+ """A TL `Reaction` → the string tlgr prints and accepts back."""
77
+ emoticon = getattr(reaction, "emoticon", None)
78
+ if emoticon:
79
+ return str(emoticon)
80
+ document = getattr(reaction, "document_id", None)
81
+ if document is not None:
82
+ return f"{CUSTOM}{document}"
83
+ if type(reaction).__name__ == "ReactionPaid":
84
+ return "stars"
85
+ return "?"
86
+
87
+
88
+ def to_tl(name: str) -> Any:
89
+ """`"👍"` / `"custom:123"` / `"stars"` → the TL `Reaction` for it."""
90
+ from telethon.tl import types
91
+
92
+ if name == "stars":
93
+ return types.ReactionPaid()
94
+ if name.startswith(CUSTOM):
95
+ try:
96
+ return types.ReactionCustomEmoji(document_id=int(name[len(CUSTOM) :]))
97
+ except ValueError as exc:
98
+ raise UsageError(f"{name!r} is not a custom emoji id", field="custom") from exc
99
+ if not name:
100
+ raise UsageError("a reaction cannot be empty", field="emoji")
101
+ return types.ReactionEmoji(emoticon=name)
102
+
103
+
104
+ def _wanted(emoji: list[str], custom: list[int]) -> list[str]:
105
+ """The reactions a caller named, in the order they named them."""
106
+ return [*emoji, *(f"{CUSTOM}{document}" for document in custom)]
107
+
108
+
109
+ async def _current(ctx: OpContext, peer: Any, chat_id: int, msg_id: int) -> tuple[Any, list[str]]:
110
+ """`(message, the reactions this account currently holds on it)`.
111
+
112
+ Read before every write because `sendReaction` replaces the set: without
113
+ this, "add 🎉" would silently remove the 👍 that was already there.
114
+ """
115
+ message = await client(ctx).get_messages(peer, ids=msg_id)
116
+ if message is None:
117
+ raise NotFoundError(f"message {msg_id} was not found in {chat_id}")
118
+ summary = reactions_summary(message)
119
+ return message, list(summary.mine) if summary is not None else []
120
+
121
+
122
+ def _result(
123
+ chat_id: int, msg_id: int, message: Any, wanted: list[str], *, reacted: bool
124
+ ) -> ReactionResult:
125
+ summary = reactions_summary(message)
126
+ return ReactionResult(
127
+ chat_id=chat_id,
128
+ msg_id=msg_id,
129
+ emoji=wanted[0] if wanted else "",
130
+ reacted=reacted,
131
+ mine=list(summary.mine) if summary is not None else [],
132
+ reactions=summary,
133
+ )
134
+
135
+
136
+ async def _send_reaction(
137
+ ctx: OpContext,
138
+ peer: Any,
139
+ chat_id: int,
140
+ msg_id: int,
141
+ names: list[str],
142
+ *,
143
+ big: bool = False,
144
+ recent: bool = False,
145
+ reacted: bool,
146
+ primary: list[str],
147
+ ) -> ReactionResult:
148
+ """Send the full desired state, mapping NOT_MODIFIED to `already`."""
149
+ from telethon.tl.functions import messages as fn
150
+
151
+ try:
152
+ updates = await client(ctx)(
153
+ fn.SendReactionRequest(
154
+ peer=peer,
155
+ msg_id=msg_id,
156
+ reaction=[to_tl(name) for name in names] or None,
157
+ big=big or None,
158
+ add_to_recent=recent or None,
159
+ )
160
+ )
161
+ except Exception as exc:
162
+ if not is_not_modified(exc):
163
+ raise
164
+ already(ctx)
165
+ message, mine = await _current(ctx, peer, chat_id, msg_id)
166
+ result = _result(chat_id, msg_id, message, primary, reacted=reacted)
167
+ result.already = True
168
+ result.mine = mine
169
+ return result
170
+
171
+ produced = _send.messages_from_updates(updates, chat_id=chat_id)
172
+ message = produced[0] if produced else None
173
+ summary = message.reactions if message is not None else None
174
+ return ReactionResult(
175
+ chat_id=chat_id,
176
+ msg_id=msg_id,
177
+ emoji=primary[0] if primary else "",
178
+ reacted=reacted,
179
+ mine=list(summary.mine) if summary is not None else [],
180
+ reactions=summary,
181
+ )
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # reaction add / remove
186
+ # ---------------------------------------------------------------------------
187
+
188
+
189
+ class AddReq(Request):
190
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
191
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Message id or link.")]
192
+ emoji: Annotated[
193
+ list[str],
194
+ arg(2, metavar="EMOJI", variadic=True, help="Reactions; omit to clear them all."),
195
+ ] = []
196
+ custom: Annotated[
197
+ list[int], opt("--custom", metavar="ID", help="Custom (Premium) emoji id; repeatable.")
198
+ ] = []
199
+ big: Annotated[bool, opt("--big", help="Play the big animation.")] = False
200
+ recent: Annotated[bool, opt("--recent", help="Remember it as recently used.")] = False
201
+ replace: Annotated[
202
+ bool, opt("--replace", help="Send exactly this set instead of adding to mine.")
203
+ ] = False
204
+ send_as: Annotated[
205
+ PeerRef | None, opt("--send-as", metavar="PEER", kind="peer", help="Paid reactions only.")
206
+ ] = None
207
+
208
+
209
+ async def add(ctx: OpContext, req: AddReq) -> ReactionResult:
210
+ """React to a message, keeping the reactions I already had.
211
+
212
+ v1's `message react` is this command; it kept one reaction at a time
213
+ because it sent the emoji alone, which is what `sendReaction`'s
214
+ whole-state contract turns into "replace everything". Passing no emoji
215
+ clears my reactions, as it always did.
216
+ """
217
+ peer = await _send.resolve(ctx, req.chat)
218
+ chat_id = _send.peer_id_of(peer)
219
+ if req.send_as is not None:
220
+ raise UsageError(
221
+ "sendReaction has no send-as field; only a Star reaction can be paid as a "
222
+ "channel — use `tlgr reaction pay --send-as`",
223
+ field="send-as",
224
+ )
225
+
226
+ wanted = _wanted(req.emoji, req.custom)
227
+ _, mine = await _current(ctx, peer, chat_id, req.msg_id)
228
+ if not wanted:
229
+ names: list[str] = []
230
+ elif req.replace:
231
+ names = wanted
232
+ else:
233
+ # Ascending `chosen_order`: the ones already there first, the new ones
234
+ # last, which is the order Telegram stores and every client renders.
235
+ names = [*mine, *(name for name in wanted if name not in mine)]
236
+
237
+ return await _send_reaction(
238
+ ctx,
239
+ peer,
240
+ chat_id,
241
+ req.msg_id,
242
+ names,
243
+ big=req.big,
244
+ recent=req.recent,
245
+ reacted=bool(names),
246
+ primary=wanted,
247
+ )
248
+
249
+
250
+ SPEC_ADD = OperationSpec(
251
+ id="reaction.add",
252
+ request=AddReq,
253
+ response=ReactionResult,
254
+ impl=add,
255
+ summary="React to a message with one or more unicode or custom emoji",
256
+ description=(
257
+ "`sendReaction` carries the whole desired state, so tlgr reads the "
258
+ "reactions this account already has and resends them alongside the "
259
+ "new one; `--replace` sends exactly what was asked for instead. "
260
+ "Reacting twice is `already: true`, not an error."
261
+ ),
262
+ aliases=("msg.react", "react.add"),
263
+ legacy_paths=("message react", "msg react"),
264
+ mutating=True,
265
+ idempotent=True,
266
+ rate_class="send",
267
+ columns=("chat_id", "msg_id", "emoji", "reacted"),
268
+ example=_EXAMPLE_REACT,
269
+ example_args="reaction add @alice 12345 👍",
270
+ tags=frozenset({"visible-to-others"}),
271
+ covers=(
272
+ "emoji.interaction",
273
+ "messages-core.reaction-add-remove",
274
+ "messages-core.saved-tags-add",
275
+ "reaction.add-to-recent",
276
+ "reaction.big",
277
+ "reaction.send-custom-emoji",
278
+ "reaction.send-emoji",
279
+ "reaction.send-multiple",
280
+ "reaction.service-messages",
281
+ ),
282
+ coverage_note=(
283
+ "`emoji.interaction` (the hearts-and-fireworks tap animation) is the "
284
+ "same gesture from a CLI's point of view: tlgr sends the reaction and "
285
+ "does not replay the per-tap animation payload."
286
+ ),
287
+ )
288
+
289
+
290
+ class RemoveReq(Request):
291
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
292
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Message id or link.")]
293
+ emoji: Annotated[
294
+ str, arg(2, metavar="EMOJI", required=False, help="Which one; omit for all of mine.")
295
+ ] = ""
296
+ custom: Annotated[
297
+ int | None, opt("--custom", metavar="ID", help="Remove this custom-emoji reaction.")
298
+ ] = None
299
+ every: Annotated[bool, opt("--every", help="Remove every reaction of mine.")] = False
300
+
301
+
302
+ async def remove(ctx: OpContext, req: RemoveReq) -> ReactionResult:
303
+ """Remove one of my reactions, or all of them.
304
+
305
+ Removal is the same `sendReaction` with the surviving set; an empty
306
+ vector clears everything.
307
+ """
308
+ peer = await _send.resolve(ctx, req.chat)
309
+ chat_id = _send.peer_id_of(peer)
310
+ target = f"{CUSTOM}{req.custom}" if req.custom is not None else req.emoji
311
+ message, mine = await _current(ctx, peer, chat_id, req.msg_id)
312
+
313
+ if not mine:
314
+ already(ctx)
315
+ result = _result(chat_id, req.msg_id, message, [target] if target else [], reacted=False)
316
+ result.already = True
317
+ return result
318
+
319
+ if req.every or not target:
320
+ names: list[str] = []
321
+ else:
322
+ if target not in mine:
323
+ already(ctx)
324
+ result = _result(chat_id, req.msg_id, message, [target], reacted=False)
325
+ result.already = True
326
+ return result
327
+ names = [name for name in mine if name != target]
328
+
329
+ return await _send_reaction(
330
+ ctx,
331
+ peer,
332
+ chat_id,
333
+ req.msg_id,
334
+ names,
335
+ reacted=False,
336
+ primary=[target] if target else [],
337
+ )
338
+
339
+
340
+ SPEC_REMOVE = OperationSpec(
341
+ id="reaction.remove",
342
+ request=RemoveReq,
343
+ response=ReactionResult,
344
+ impl=remove,
345
+ summary="Remove one of my reactions, or all of them",
346
+ description=(
347
+ "Naming no reaction removes all of mine. A reaction that was not "
348
+ "there is `already: true`, so a retry is safe."
349
+ ),
350
+ aliases=("message.unreact", "react.remove"),
351
+ mutating=True,
352
+ idempotent=True,
353
+ rate_class="send",
354
+ columns=("chat_id", "msg_id", "emoji", "reacted"),
355
+ example={**_EXAMPLE_REACT, "reacted": False, "mine": []},
356
+ example_args="reaction remove @alice 12345 👍",
357
+ covers=("reaction.remove",),
358
+ )
359
+
360
+
361
+ # ---------------------------------------------------------------------------
362
+ # reaction list / user list
363
+ # ---------------------------------------------------------------------------
364
+
365
+
366
+ class ListReq(Request):
367
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
368
+ msg_id: Annotated[
369
+ list[str], arg(1, metavar="MSG_ID", variadic=True, help="One or more message ids.")
370
+ ] = []
371
+ top_senders: Annotated[
372
+ bool, opt("--top-senders", help="Include the Star-reaction leaderboard.")
373
+ ] = False
374
+
375
+
376
+ async def reaction_list(ctx: OpContext, req: ListReq) -> Page[MessageReactionState]:
377
+ """Reaction counts on one or many messages, refreshed from the server.
378
+
379
+ `messages.getMessagesReactions` is the only source of
380
+ `messageReactions.top_reactors`: the leaderboard is not on the plain
381
+ message, so `--top-senders` cannot be answered from a cached copy.
382
+ """
383
+ from telethon.tl.functions import messages as fn
384
+
385
+ peer = await _send.resolve(ctx, req.chat)
386
+ chat_id = _send.peer_id_of(peer)
387
+ wanted = ids(tuple(req.msg_id))
388
+ if not wanted:
389
+ raise UsageError("at least one message id is required", field="msg_id")
390
+
391
+ updates = await client(ctx)(fn.GetMessagesReactionsRequest(peer=peer, id=wanted))
392
+ by_id: dict[int, Any] = {}
393
+ for update in getattr(updates, "updates", None) or []:
394
+ if type(update).__name__ == "UpdateMessageReactions":
395
+ by_id[int(getattr(update, "msg_id", 0))] = getattr(update, "reactions", None)
396
+ message = getattr(update, "message", None)
397
+ if message is not None and getattr(message, "reactions", None) is not None:
398
+ by_id.setdefault(int(message.id), message.reactions)
399
+
400
+ items: list[MessageReactionState] = []
401
+ for msg_id in wanted:
402
+ raw = by_id.get(msg_id)
403
+
404
+ class _Holder:
405
+ reactions = raw
406
+
407
+ summary = reactions_summary(_Holder())
408
+ items.append(
409
+ MessageReactionState(
410
+ chat_id=chat_id,
411
+ msg_id=msg_id,
412
+ reactions=summary,
413
+ can_see_list=bool(getattr(raw, "can_see_list", False)),
414
+ as_tags=bool(getattr(raw, "reactions_as_tags", False)),
415
+ top_reactors=_reactors(raw) if req.top_senders else [],
416
+ )
417
+ )
418
+ return Page(items=items, has_more=False, total=len(items))
419
+
420
+
421
+ def _reactors(raw: Any) -> list[TopReactor]:
422
+ out: list[TopReactor] = []
423
+ for reactor in getattr(raw, "top_reactors", None) or []:
424
+ out.append(
425
+ TopReactor(
426
+ user_id=peer_id_of(getattr(reactor, "peer_id", None)),
427
+ stars=int(getattr(reactor, "count", 0) or 0),
428
+ anonymous=bool(getattr(reactor, "anonymous", False)),
429
+ mine=bool(getattr(reactor, "my", False)),
430
+ )
431
+ )
432
+ return out
433
+
434
+
435
+ SPEC_LIST = OperationSpec(
436
+ id="reaction.list",
437
+ request=ListReq,
438
+ response=Page[MessageReactionState],
439
+ impl=reaction_list,
440
+ summary="Reaction counts on one or many messages",
441
+ description=(
442
+ "`--top-senders` adds the Star-reaction leaderboard, which only "
443
+ "`messages.getMessagesReactions` returns — it is not on the message."
444
+ ),
445
+ aliases=("react.list",),
446
+ columns=("msg_id", "can_see_list"),
447
+ example={
448
+ "items": [
449
+ {
450
+ "chat_id": 777123,
451
+ "msg_id": 12345,
452
+ "reactions": {"counts": {"👍": 4}, "total": 4},
453
+ "can_see_list": True,
454
+ }
455
+ ],
456
+ "has_more": False,
457
+ },
458
+ example_args="reaction list @alice 12345",
459
+ covers=(
460
+ "messages-core.reactions-count",
461
+ "reaction.bulk-refresh",
462
+ "reaction.paid-leaderboard",
463
+ "reaction.read-summary",
464
+ "stories.available-reactions",
465
+ ),
466
+ coverage_note=(
467
+ "`stories.available-reactions` is the same catalogue this group "
468
+ "publishes as `reaction catalog`; the story surface itself is PR-8's."
469
+ ),
470
+ )
471
+
472
+
473
+ class UserListReq(Request):
474
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
475
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Message id.")]
476
+ emoji: Annotated[str | None, opt("--emoji", metavar="EMOJI", help="Only this reaction.")] = None
477
+ custom: Annotated[
478
+ int | None, opt("--custom", metavar="ID", help="Only this custom-emoji reaction.")
479
+ ] = None
480
+
481
+
482
+ async def user_list(ctx: OpContext, req: UserListReq) -> Page[ReactionUser]:
483
+ """Who reacted, per emoji.
484
+
485
+ Only available when `messageReactions.can_see_list` — groups and small
486
+ channels. Pagination is an opaque *string* offset, so the cursor carries
487
+ it verbatim rather than an integer that would restart the walk.
488
+ """
489
+ from telethon.tl.functions import messages as fn
490
+
491
+ limit, state = window(ctx, "reaction.user.list", PageKind.PARTICIPANTS)
492
+ peer = await _send.resolve(ctx, req.chat)
493
+ filter_name = f"{CUSTOM}{req.custom}" if req.custom is not None else req.emoji
494
+
495
+ result = await client(ctx)(
496
+ fn.GetMessageReactionsListRequest(
497
+ peer=peer,
498
+ id=req.msg_id,
499
+ limit=limit,
500
+ reaction=to_tl(filter_name) if filter_name else None,
501
+ offset=state.get("offset") or None,
502
+ )
503
+ )
504
+ items = [
505
+ ReactionUser(
506
+ user_id=peer_id_of(getattr(row, "peer_id", None)) or 0,
507
+ reaction=name_of(getattr(row, "reaction", None)),
508
+ date=fmt_dt(getattr(row, "date", None)),
509
+ date_unix=to_unix(getattr(row, "date", None)),
510
+ big=bool(getattr(row, "big", False)),
511
+ unread=bool(getattr(row, "unread", False)),
512
+ mine=bool(getattr(row, "my", False)),
513
+ )
514
+ for row in (getattr(result, "reactions", None) or [])
515
+ ]
516
+ next_offset = getattr(result, "next_offset", None)
517
+ return build_page(
518
+ items,
519
+ op="reaction.user.list",
520
+ kind=PageKind.PARTICIPANTS,
521
+ state={"offset": next_offset},
522
+ account=ctx.account,
523
+ has_more=bool(next_offset),
524
+ total=getattr(result, "count", None),
525
+ )
526
+
527
+
528
+ SPEC_USER_LIST = OperationSpec(
529
+ id="reaction.user.list",
530
+ request=UserListReq,
531
+ response=Page[ReactionUser],
532
+ impl=user_list,
533
+ summary="Who reacted to a message, per emoji",
534
+ description="Only where `can_see_list` is set — groups, and small channels.",
535
+ aliases=("react.who", "reaction.users"),
536
+ paginated=PageKind.PARTICIPANTS,
537
+ columns=("user_id", "reaction", "date"),
538
+ example={
539
+ "items": [{"user_id": 4242, "reaction": "👍", "date": "2026-09-03T09:20:00Z"}],
540
+ "has_more": False,
541
+ },
542
+ example_args="reaction user list @alice 12345",
543
+ covers=("reaction.who-reacted",),
544
+ )
545
+
546
+
547
+ # ---------------------------------------------------------------------------
548
+ # reaction unread list
549
+ # ---------------------------------------------------------------------------
550
+
551
+
552
+ class UnreadListReq(Request):
553
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
554
+ topic: Annotated[
555
+ int | None, opt("--topic", metavar="ID", kind="msg_id", help="Only this forum topic.")
556
+ ] = None
557
+ direct_to: Annotated[
558
+ PeerRef | None,
559
+ opt("--direct-to", metavar="USER", kind="user", help="Only this monoforum topic."),
560
+ ] = None
561
+ read_all: Annotated[bool, opt("--read-all", help="Mark every unread reaction as read.")] = False
562
+
563
+
564
+ async def unread_list(ctx: OpContext, req: UnreadListReq) -> Page[Message]:
565
+ """Messages in this chat carrying reactions I have not seen."""
566
+ from telethon.tl.functions import messages as fn
567
+
568
+ limit, state = window(ctx, "reaction.unread.list", PageKind.PARTICIPANTS)
569
+ peer = await _send.resolve(ctx, req.chat)
570
+ chat_id = _send.peer_id_of(peer)
571
+ saved = await _send.resolve(ctx, req.direct_to) if req.direct_to is not None else None
572
+
573
+ result = await client(ctx)(
574
+ fn.GetUnreadReactionsRequest(
575
+ peer=peer,
576
+ offset_id=int(state.get("offset_id") or 0),
577
+ add_offset=0,
578
+ limit=limit,
579
+ max_id=0,
580
+ min_id=0,
581
+ top_msg_id=req.topic,
582
+ saved_peer_id=saved,
583
+ )
584
+ )
585
+ items = [
586
+ message_to_model(raw, chat_id=chat_id) for raw in (getattr(result, "messages", None) or [])
587
+ ]
588
+ if req.read_all:
589
+ await affected_loop(
590
+ ctx,
591
+ lambda offset: fn.ReadReactionsRequest(
592
+ peer=peer, top_msg_id=req.topic, saved_peer_id=saved
593
+ ),
594
+ )
595
+ return build_page(
596
+ items,
597
+ op="reaction.unread.list",
598
+ kind=PageKind.PARTICIPANTS,
599
+ state={"offset_id": items[-1].id if items else 0},
600
+ account=ctx.account,
601
+ limit=limit,
602
+ total=getattr(result, "count", None),
603
+ )
604
+
605
+
606
+ SPEC_UNREAD_LIST = OperationSpec(
607
+ id="reaction.unread.list",
608
+ request=UnreadListReq,
609
+ response=Page[Message],
610
+ impl=unread_list,
611
+ summary="Unread reactions in a chat, and optionally mark them read",
612
+ description=(
613
+ "`--read-all` drives `messages.readReactions` until its offset comes "
614
+ "back zero; calling it once clears only the first page of the badge."
615
+ ),
616
+ paginated=PageKind.PARTICIPANTS,
617
+ tags=frozenset({"mutating-checked"}),
618
+ columns=("id", "chat_id", "date", "text"),
619
+ example={
620
+ "items": [
621
+ {
622
+ "id": 12345,
623
+ "chat_id": 777123,
624
+ "date": "2026-09-03T09:14:07Z",
625
+ "date_unix": 1788340447,
626
+ "text": "on my way",
627
+ }
628
+ ],
629
+ "has_more": False,
630
+ },
631
+ example_args="reaction unread list @alice",
632
+ covers=("reaction.read-all",),
633
+ )
634
+
635
+
636
+ # ---------------------------------------------------------------------------
637
+ # reaction catalog
638
+ # ---------------------------------------------------------------------------
639
+
640
+
641
+ class CatalogReq(Request):
642
+ top: Annotated[bool, opt("--top", help="Featured / top reactions.")] = False
643
+ recent: Annotated[bool, opt("--recent", help="My recently used reactions.")] = False
644
+ forget: Annotated[bool, opt("--forget", help="Clear the recently-used list.")] = False
645
+ refresh: Annotated[bool, opt("--refresh", help="Ignore the cached hash.")] = False
646
+
647
+
648
+ async def catalog(ctx: OpContext, req: CatalogReq) -> Page[AvailableReaction]:
649
+ """The reaction catalogue: standard, featured, or my recently used.
650
+
651
+ Paged locally: all three endpoints answer with the whole list (hash
652
+ cached), so a server-side offset would be a fiction.
653
+ """
654
+ from telethon.tl.functions import messages as fn
655
+
656
+ limit, state = window(ctx, "reaction.catalog", PageKind.LOCAL, default=50)
657
+ handle = client(ctx)
658
+
659
+ if req.forget:
660
+ await handle(fn.ClearRecentReactionsRequest())
661
+
662
+ if req.recent:
663
+ source = "recent"
664
+ result = await handle(fn.GetRecentReactionsRequest(limit=100, hash=0))
665
+ rows = [
666
+ AvailableReaction(emoticon=name_of(item), source="recent")
667
+ for item in (getattr(result, "reactions", None) or [])
668
+ ]
669
+ elif req.top:
670
+ source = "top"
671
+ result = await handle(fn.GetTopReactionsRequest(limit=100, hash=0))
672
+ rows = [
673
+ AvailableReaction(emoticon=name_of(item), source="top")
674
+ for item in (getattr(result, "reactions", None) or [])
675
+ ]
676
+ else:
677
+ source = "available"
678
+ result = await handle(fn.GetAvailableReactionsRequest(hash=0))
679
+ rows = [
680
+ AvailableReaction(
681
+ emoticon=str(getattr(item, "reaction", "")),
682
+ title=str(getattr(item, "title", "") or ""),
683
+ premium=bool(getattr(item, "premium", False)),
684
+ inactive=bool(getattr(item, "inactive", False)),
685
+ source="available",
686
+ static_icon_id=getattr(getattr(item, "static_icon", None), "id", None),
687
+ select_animation_id=getattr(getattr(item, "select_animation", None), "id", None),
688
+ )
689
+ for item in (getattr(result, "reactions", None) or [])
690
+ ]
691
+
692
+ offset = int(state.get("offset") or 0)
693
+ window_rows = rows[offset : offset + limit]
694
+ return build_page(
695
+ window_rows,
696
+ op="reaction.catalog",
697
+ kind=PageKind.LOCAL,
698
+ state={"offset": offset + len(window_rows), "source": source},
699
+ account=ctx.account,
700
+ has_more=offset + len(window_rows) < len(rows),
701
+ total=len(rows),
702
+ )
703
+
704
+
705
+ SPEC_CATALOG = OperationSpec(
706
+ id="reaction.catalog",
707
+ request=CatalogReq,
708
+ response=Page[AvailableReaction],
709
+ impl=catalog,
710
+ summary="The reaction catalogue: standard, featured, or my recently used",
711
+ description=(
712
+ "Three endpoints, one row shape; `source` says which list a row came "
713
+ "from. `--forget` clears the recently-used list first."
714
+ ),
715
+ aliases=("react.catalog",),
716
+ paginated=PageKind.LOCAL,
717
+ mutating=False,
718
+ tags=frozenset({"mutating-checked"}),
719
+ columns=("emoticon", "title", "premium", "source"),
720
+ example={
721
+ "items": [{"emoticon": "👍", "title": "Thumbs Up", "source": "available"}],
722
+ "has_more": False,
723
+ },
724
+ example_args="reaction catalog",
725
+ covers=(
726
+ "reaction.available-list",
727
+ "reaction.clear-recent",
728
+ "reaction.recent-list",
729
+ "reaction.top-featured",
730
+ ),
731
+ )
732
+
733
+
734
+ # ---------------------------------------------------------------------------
735
+ # reaction chat get / set
736
+ # ---------------------------------------------------------------------------
737
+
738
+
739
+ def _chat_reactions(full: Any, chat_id: int) -> ChatReactions:
740
+ available = getattr(full, "available_reactions", None)
741
+ name = type(available).__name__
742
+ if name == "ChatReactionsAll":
743
+ mode = "all"
744
+ elif name == "ChatReactionsSome":
745
+ mode = "some"
746
+ else:
747
+ mode = "none"
748
+ return ChatReactions(
749
+ chat_id=chat_id,
750
+ mode=mode, # type: ignore[arg-type]
751
+ reactions=[name_of(item) for item in (getattr(available, "reactions", None) or [])],
752
+ allow_custom=bool(getattr(available, "allow_custom", False)),
753
+ reactions_limit=getattr(full, "reactions_limit", None),
754
+ paid_enabled=getattr(full, "paid_reactions_available", None),
755
+ )
756
+
757
+
758
+ async def _full_chat(ctx: OpContext, peer: Any) -> Any:
759
+ """`chatFull`/`channelFull` for a peer — the source of truth for the policy."""
760
+ from telethon.tl import types
761
+ from telethon.tl.functions import channels as ch
762
+ from telethon.tl.functions import messages as fn
763
+
764
+ if isinstance(peer, types.InputPeerChannel):
765
+ result = await client(ctx)(ch.GetFullChannelRequest(channel=input_channel(peer)))
766
+ elif isinstance(peer, types.InputPeerChat):
767
+ result = await client(ctx)(fn.GetFullChatRequest(chat_id=peer.chat_id))
768
+ else:
769
+ raise UsageError(
770
+ "reactions are configured per group or channel, not per private chat", field="chat"
771
+ )
772
+ return getattr(result, "full_chat", None)
773
+
774
+
775
+ class ChatGetReq(Request):
776
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
777
+ msg_id: Annotated[
778
+ int | None,
779
+ opt("--msg-id", metavar="ID", kind="msg_id", help="Narrow to one message."),
780
+ ] = None
781
+
782
+
783
+ async def chat_get(ctx: OpContext, req: ChatGetReq) -> ChatReactions:
784
+ """Which reactions this chat — or this one message — allows."""
785
+ peer = await _send.resolve(ctx, req.chat)
786
+ chat_id = _send.peer_id_of(peer)
787
+ policy = _chat_reactions(await _full_chat(ctx, peer), chat_id)
788
+ if req.msg_id is None:
789
+ return policy
790
+
791
+ policy.msg_id = req.msg_id
792
+ message = await client(ctx).get_messages(peer, ids=req.msg_id)
793
+ if message is None:
794
+ raise NotFoundError(f"message {req.msg_id} was not found in {chat_id}")
795
+ if policy.mode == "all":
796
+ # The per-message answer is the chat policy narrowed by the global
797
+ # catalogue: "everything" means everything Telegram currently ships.
798
+ from telethon.tl.functions import messages as fn
799
+
800
+ available = await client(ctx)(fn.GetAvailableReactionsRequest(hash=0))
801
+ policy.reactions = [
802
+ str(item.reaction)
803
+ for item in (getattr(available, "reactions", None) or [])
804
+ if not getattr(item, "inactive", False)
805
+ ]
806
+ return policy
807
+
808
+
809
+ SPEC_CHAT_GET = OperationSpec(
810
+ id="reaction.chat.get",
811
+ request=ChatGetReq,
812
+ response=ChatReactions,
813
+ impl=chat_get,
814
+ summary="Which reactions a chat (or one message) allows",
815
+ description=(
816
+ "`chatFull`/`channelFull.available_reactions` is the source of truth. "
817
+ "With `--msg-id` an `all` policy is expanded against the live "
818
+ "catalogue, which is the set that would actually be accepted."
819
+ ),
820
+ aliases=("reaction.available",),
821
+ columns=("chat_id", "mode", "reactions_limit"),
822
+ example={"chat_id": -1001234567890, "mode": "some", "reactions": ["👍", "❤"]},
823
+ example_args="reaction chat get @news",
824
+ covers=("reaction.chat-available-get", "reaction.message-available"),
825
+ )
826
+
827
+
828
+ class ChatSetReq(Request):
829
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
830
+ every: Annotated[bool, opt("--every", help="Allow every reaction.")] = False
831
+ none: Annotated[bool, opt("--none", help="Disable reactions.")] = False
832
+ some: Annotated[
833
+ str | None, opt("--some", metavar="EMOJI,...", help="Allow exactly this set.")
834
+ ] = None
835
+ allow_custom: Annotated[bool, opt("--allow-custom", help="Allow custom-emoji reactions.")] = (
836
+ False
837
+ )
838
+ max_unique: Annotated[
839
+ int | None,
840
+ opt("--max-unique", metavar="N", help="Cap unique reactions per message."),
841
+ ] = None
842
+ paid: Annotated[str | None, choice("on", "off", help="Star (paid) reactions on a channel.")] = (
843
+ None
844
+ )
845
+
846
+
847
+ async def chat_set(ctx: OpContext, req: ChatSetReq) -> ChatReactions:
848
+ """Set a chat's reaction policy, its unique cap and its Star reactions.
849
+
850
+ `available_reactions` is mandatory on the wire while the other fields are
851
+ optional, so changing only the cap still means resending the whole
852
+ policy: this is a read-modify-write, never a blind overwrite.
853
+ """
854
+ from telethon.tl import types
855
+ from telethon.tl.functions import messages as fn
856
+
857
+ peer = await _send.resolve(ctx, req.chat)
858
+ chat_id = _send.peer_id_of(peer)
859
+ current = _chat_reactions(await _full_chat(ctx, peer), chat_id)
860
+
861
+ chosen = [flag for flag in (req.every, req.none, bool(req.some)) if flag]
862
+ if len(chosen) > 1:
863
+ raise UsageError("--every, --none and --some are three answers to one question")
864
+
865
+ if req.every:
866
+ available: Any = types.ChatReactionsAll(allow_custom=req.allow_custom or None)
867
+ elif req.none:
868
+ available = types.ChatReactionsNone()
869
+ elif req.some is not None:
870
+ names = [part.strip() for part in req.some.split(",") if part.strip()]
871
+ if not names:
872
+ raise UsageError("--some needs at least one reaction", field="some")
873
+ available = types.ChatReactionsSome(reactions=[to_tl(name) for name in names])
874
+ elif current.mode == "all":
875
+ available = types.ChatReactionsAll(
876
+ allow_custom=(req.allow_custom or current.allow_custom) or None
877
+ )
878
+ elif current.mode == "some":
879
+ available = types.ChatReactionsSome(reactions=[to_tl(name) for name in current.reactions])
880
+ else:
881
+ available = types.ChatReactionsNone()
882
+
883
+ await client(ctx)(
884
+ fn.SetChatAvailableReactionsRequest(
885
+ peer=peer,
886
+ available_reactions=available,
887
+ reactions_limit=req.max_unique,
888
+ paid_enabled=None if req.paid is None else req.paid == "on",
889
+ )
890
+ )
891
+ updated = _chat_reactions(await _full_chat(ctx, peer), chat_id)
892
+ ctx.emit("chat_reactions_set", {"chat_id": chat_id, "mode": updated.mode})
893
+ return updated
894
+
895
+
896
+ SPEC_CHAT_SET = OperationSpec(
897
+ id="reaction.chat.set",
898
+ request=ChatSetReq,
899
+ response=ChatReactions,
900
+ impl=chat_set,
901
+ summary="Restrict which reactions a chat allows, cap them, enable Star reactions",
902
+ description=(
903
+ "Needs the `change_info` admin right. Because the wire field is "
904
+ "mandatory, tlgr reads the current policy first and resends it "
905
+ "unchanged when only the cap or the Star switch was asked for."
906
+ ),
907
+ mutating=True,
908
+ rate_class="send",
909
+ columns=("chat_id", "mode", "reactions_limit", "paid_enabled"),
910
+ example={"chat_id": -1001234567890, "mode": "some", "reactions": ["👍"], "reactions_limit": 3},
911
+ example_args="reaction chat set @news --some 👍,❤",
912
+ covers=("reaction.chat-available-set", "reaction.chat-unique-limit", "reaction.paid-enable"),
913
+ )
914
+
915
+
916
+ # ---------------------------------------------------------------------------
917
+ # reaction default get / set
918
+ # ---------------------------------------------------------------------------
919
+
920
+
921
+ class DefaultGetReq(Request):
922
+ pass
923
+
924
+
925
+ async def default_get(ctx: OpContext, req: DefaultGetReq) -> ReactionTag:
926
+ """The quick (double-tap) reaction.
927
+
928
+ There is no getter: the value ships in `help.getConfig().reactions_default`,
929
+ which is why it is read here rather than remembered from the last write.
930
+ """
931
+ from telethon.tl.functions import help as fn
932
+
933
+ config = await client(ctx)(fn.GetConfigRequest())
934
+ reaction = getattr(config, "reactions_default", None)
935
+ if reaction is None:
936
+ return ReactionTag(reaction="")
937
+ return ReactionTag(reaction=name_of(reaction))
938
+
939
+
940
+ SPEC_DEFAULT_GET = OperationSpec(
941
+ id="reaction.default.get",
942
+ request=DefaultGetReq,
943
+ response=ReactionTag,
944
+ impl=default_get,
945
+ summary="Show the quick (double-tap) reaction",
946
+ description="Read from `help.getConfig().reactions_default`; there is no dedicated getter.",
947
+ columns=("reaction",),
948
+ example={"reaction": "❤"},
949
+ example_args="reaction default get",
950
+ covers=(),
951
+ covers_partial=(),
952
+ tags=frozenset({"infrastructure"}),
953
+ )
954
+
955
+
956
+ class DefaultSetReq(Request):
957
+ emoji: Annotated[
958
+ str, arg(0, metavar="EMOJI", required=False, help="The reaction to make default.")
959
+ ] = ""
960
+ custom: Annotated[
961
+ int | None, opt("--custom", metavar="ID", help="Use a custom emoji (Premium).")
962
+ ] = None
963
+
964
+
965
+ async def default_set(ctx: OpContext, req: DefaultSetReq) -> ReactionTag:
966
+ """Set the quick (double-tap) reaction."""
967
+ from telethon.tl.functions import messages as fn
968
+
969
+ name = f"{CUSTOM}{req.custom}" if req.custom is not None else req.emoji
970
+ if not name:
971
+ raise UsageError("a reaction is required", field="emoji")
972
+ await client(ctx)(fn.SetDefaultReactionRequest(reaction=to_tl(name)))
973
+ return ReactionTag(reaction=name)
974
+
975
+
976
+ SPEC_DEFAULT_SET = OperationSpec(
977
+ id="reaction.default.set",
978
+ request=DefaultSetReq,
979
+ response=ReactionTag,
980
+ impl=default_set,
981
+ summary="Set the quick (double-tap) reaction",
982
+ mutating=True,
983
+ idempotent=True,
984
+ columns=("reaction",),
985
+ example={"reaction": "❤"},
986
+ example_args="reaction default set ❤",
987
+ covers=("reaction.quick-default",),
988
+ )
989
+
990
+
991
+ # ---------------------------------------------------------------------------
992
+ # reaction tag list / set
993
+ # ---------------------------------------------------------------------------
994
+
995
+
996
+ class TagListReq(Request):
997
+ peer: Annotated[
998
+ PeerRef | None,
999
+ opt("--peer", metavar="CHAT", kind="peer", help="Tags used inside one saved dialog."),
1000
+ ] = None
1001
+ suggested: Annotated[bool, opt("--suggested", help="Default/suggested tag reactions.")] = False
1002
+ refresh: Annotated[bool, opt("--refresh", help="Ignore the cached hash.")] = False
1003
+ rename: Annotated[
1004
+ str | None, opt("--rename", metavar="REACTION=TITLE", help="Name or rename a tag.")
1005
+ ] = None
1006
+ clear_title: Annotated[
1007
+ str | None, opt("--clear-title", metavar="REACTION", help="Drop a tag's name.")
1008
+ ] = None
1009
+
1010
+
1011
+ async def tag_list(ctx: OpContext, req: TagListReq) -> Page[ReactionTag]:
1012
+ """Saved Messages reaction tags — mine, or the suggested ones.
1013
+
1014
+ Tagging a saved message *is* reacting to it (`reaction add me <id> 📌`);
1015
+ this command only names the tags and reads them back.
1016
+ """
1017
+ from telethon.tl.functions import messages as fn
1018
+
1019
+ handle = client(ctx)
1020
+ if req.rename is not None or req.clear_title is not None:
1021
+ await _rename_tag(ctx, req)
1022
+
1023
+ if req.suggested:
1024
+ result = await handle(fn.GetDefaultTagReactionsRequest(hash=0))
1025
+ items = [
1026
+ ReactionTag(reaction=name_of(item), suggested=True)
1027
+ for item in (getattr(result, "reactions", None) or [])
1028
+ ]
1029
+ return Page(items=items, has_more=False, total=len(items))
1030
+
1031
+ peer = await _send.resolve(ctx, req.peer) if req.peer is not None else None
1032
+ result = await handle(fn.GetSavedReactionTagsRequest(hash=0, peer=peer))
1033
+ items = [
1034
+ ReactionTag(
1035
+ reaction=name_of(getattr(tag, "reaction", None)),
1036
+ title=getattr(tag, "title", None),
1037
+ count=int(getattr(tag, "count", 0) or 0),
1038
+ )
1039
+ for tag in (getattr(result, "tags", None) or [])
1040
+ ]
1041
+ return Page(items=items, has_more=False, total=len(items))
1042
+
1043
+
1044
+ async def _rename_tag(ctx: OpContext, req: TagListReq) -> None:
1045
+ from telethon.tl.functions import messages as fn
1046
+
1047
+ if req.clear_title is not None:
1048
+ await client(ctx)(
1049
+ fn.UpdateSavedReactionTagRequest(reaction=to_tl(req.clear_title), title=None)
1050
+ )
1051
+ return
1052
+ reaction, sep, title = (req.rename or "").partition("=")
1053
+ if not sep:
1054
+ raise UsageError("--rename wants REACTION=TITLE", field="rename")
1055
+ await client(ctx)(
1056
+ fn.UpdateSavedReactionTagRequest(reaction=to_tl(reaction), title=title or None)
1057
+ )
1058
+
1059
+
1060
+ SPEC_TAG_LIST = OperationSpec(
1061
+ id="reaction.tag.list",
1062
+ request=TagListReq,
1063
+ response=Page[ReactionTag],
1064
+ impl=tag_list,
1065
+ summary="Saved Messages reaction tags (and the suggested ones)",
1066
+ description=(
1067
+ "Premium. Tagging a saved message is reacting to it — "
1068
+ "`tlgr reaction add me <id> 📌` — and this is where the tags are named."
1069
+ ),
1070
+ mutating=True,
1071
+ columns=("reaction", "title", "count"),
1072
+ example={"items": [{"reaction": "📌", "title": "invoices", "count": 12}], "has_more": False},
1073
+ example_args="reaction tag list",
1074
+ covers=(
1075
+ "dialogs.saved-tags",
1076
+ "messages-core.saved-tags-manage",
1077
+ "reaction.default-tag-reactions",
1078
+ "reaction.saved-tags-list",
1079
+ ),
1080
+ )
1081
+
1082
+
1083
+ class TagSetReq(Request):
1084
+ emoji: Annotated[str, arg(0, metavar="EMOJI", help="The tag reaction.")]
1085
+ title: Annotated[str, arg(1, metavar="TITLE", required=False, help="The name to give it.")] = ""
1086
+ custom: Annotated[int | None, opt("--custom", metavar="ID", help="A custom-emoji tag.")] = None
1087
+ clear: Annotated[bool, opt("--clear", help="Remove the name.")] = False
1088
+
1089
+
1090
+ async def tag_set(ctx: OpContext, req: TagSetReq) -> ReactionTag:
1091
+ """Name or rename a Saved Messages tag. Names are local to my account."""
1092
+ from telethon.tl.functions import messages as fn
1093
+
1094
+ name = f"{CUSTOM}{req.custom}" if req.custom is not None else req.emoji
1095
+ title = "" if req.clear else req.title
1096
+ await client(ctx)(fn.UpdateSavedReactionTagRequest(reaction=to_tl(name), title=title or None))
1097
+ return ReactionTag(reaction=name, title=title or None)
1098
+
1099
+
1100
+ SPEC_TAG_SET = OperationSpec(
1101
+ id="reaction.tag.set",
1102
+ request=TagSetReq,
1103
+ response=ReactionTag,
1104
+ impl=tag_set,
1105
+ summary="Name or rename a Saved Messages tag",
1106
+ description="Premium. Omitting the title, or passing `--clear`, removes the name.",
1107
+ mutating=True,
1108
+ idempotent=True,
1109
+ columns=("reaction", "title"),
1110
+ example={"reaction": "📌", "title": "invoices"},
1111
+ example_args="reaction tag set 📌 invoices",
1112
+ covers=("reaction.saved-tag-rename",),
1113
+ )
1114
+
1115
+
1116
+ # ---------------------------------------------------------------------------
1117
+ # reaction pay / privacy
1118
+ # ---------------------------------------------------------------------------
1119
+
1120
+
1121
+ def _privacy(mode: str, peer: Any) -> Any:
1122
+ from telethon.tl import types
1123
+
1124
+ if mode == "anonymous":
1125
+ return types.PaidReactionPrivacyAnonymous()
1126
+ if mode == "peer":
1127
+ if peer is None:
1128
+ raise UsageError("privacy 'peer' needs --send-as to say which channel", field="send-as")
1129
+ return types.PaidReactionPrivacyPeer(peer=peer)
1130
+ return types.PaidReactionPrivacyDefault()
1131
+
1132
+
1133
+ def _privacy_name(value: Any) -> str:
1134
+ name = type(value).__name__
1135
+ if name.endswith("Anonymous"):
1136
+ return "anonymous"
1137
+ if name.endswith("Peer"):
1138
+ return "peer"
1139
+ return "default"
1140
+
1141
+
1142
+ class PayReq(Request):
1143
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel.")]
1144
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Post id.")]
1145
+ stars: Annotated[int | None, opt("--stars", metavar="N", help="How many Stars to send.")] = None
1146
+ anonymous: Annotated[bool, opt("--anonymous", help="Hide my name on the leaderboard.")] = False
1147
+ send_as: Annotated[
1148
+ PeerRef | None, opt("--send-as", metavar="PEER", kind="peer", help="Pay as my channel.")
1149
+ ] = None
1150
+ senders: Annotated[
1151
+ bool, opt("--senders", help="List the peers I may pay as, instead of paying.")
1152
+ ] = False
1153
+
1154
+
1155
+ async def pay(ctx: OpContext, req: PayReq) -> PaidReactionResult:
1156
+ """Send a Star (paid) reaction, or list the identities I could pay as.
1157
+
1158
+ This spends the account's Star balance, so there is no default amount and
1159
+ no automatic retry: `--stars` is mandatory and the confirmation is the
1160
+ ordinary destructive-command one. `--senders` is free and never pays.
1161
+
1162
+ `random_id` is not the usual 64 random bits here — the API wants
1163
+ `(unixtime << 32) | random_uint32`, and a plain random value is rejected.
1164
+ """
1165
+ import os
1166
+ import time
1167
+
1168
+ from telethon.tl.functions import channels as ch
1169
+ from telethon.tl.functions import messages as fn
1170
+
1171
+ peer = await _send.resolve(ctx, req.chat)
1172
+ chat_id = _send.peer_id_of(peer)
1173
+ # Resolved before anything is spent: a paid reaction only exists on a
1174
+ # channel post, and finding that out after the payment is not an option.
1175
+ input_channel(peer)
1176
+
1177
+ if req.senders:
1178
+ found = await client(ctx)(ch.GetSendAsRequest(peer=peer, for_paid_reactions=True))
1179
+ return PaidReactionResult(
1180
+ chat_id=chat_id,
1181
+ msg_id=req.msg_id,
1182
+ senders=[
1183
+ pid
1184
+ for pid in (
1185
+ peer_id_of(getattr(item, "peer", item))
1186
+ for item in (getattr(found, "peers", None) or [])
1187
+ )
1188
+ if pid is not None
1189
+ ],
1190
+ )
1191
+
1192
+ if not req.stars or req.stars < 1:
1193
+ raise UsageError(
1194
+ "--stars N is required: a paid reaction spends real Stars and tlgr never "
1195
+ "picks the amount",
1196
+ field="stars",
1197
+ )
1198
+ send_as = await _send.resolve(ctx, req.send_as) if req.send_as is not None else None
1199
+ mode = "peer" if send_as is not None else ("anonymous" if req.anonymous else "default")
1200
+
1201
+ updates = await client(ctx)(
1202
+ fn.SendPaidReactionRequest(
1203
+ peer=peer,
1204
+ msg_id=req.msg_id,
1205
+ count=int(req.stars),
1206
+ random_id=(int(time.time()) << 32) | int.from_bytes(os.urandom(4), "big"),
1207
+ private=_privacy(mode, send_as),
1208
+ )
1209
+ )
1210
+ top: list[TopReactor] = []
1211
+ for update in getattr(updates, "updates", None) or []:
1212
+ if type(update).__name__ == "UpdateMessageReactions":
1213
+ top = _reactors(getattr(update, "reactions", None))
1214
+ ctx.emit("reaction_paid", {"chat_id": chat_id, "msg_id": req.msg_id, "stars": req.stars})
1215
+ return PaidReactionResult(
1216
+ chat_id=chat_id,
1217
+ msg_id=req.msg_id,
1218
+ stars_sent=int(req.stars),
1219
+ privacy=mode, # type: ignore[arg-type]
1220
+ top_reactors=top,
1221
+ )
1222
+
1223
+
1224
+ SPEC_PAY = OperationSpec(
1225
+ id="reaction.pay",
1226
+ request=PayReq,
1227
+ response=PaidReactionResult,
1228
+ impl=pay,
1229
+ summary="Send a Star (paid) reaction to a channel post",
1230
+ description=(
1231
+ "Spends real Stars. `--stars` is mandatory, there is no default "
1232
+ "amount, and a failed payment is never retried automatically. "
1233
+ "`--senders` lists the identities you could pay as without paying."
1234
+ ),
1235
+ mutating=True,
1236
+ destructive=True,
1237
+ rate_class="send",
1238
+ columns=("chat_id", "msg_id", "stars_sent"),
1239
+ example={"chat_id": -1001234567890, "msg_id": 12345, "stars_sent": 50},
1240
+ example_args="reaction pay @news 12345 --stars 50",
1241
+ tags=frozenset({"visible-to-others"}),
1242
+ covers=(
1243
+ "messages-core.reaction-paid-star",
1244
+ "reaction.live-story-paid",
1245
+ "reaction.paid-send",
1246
+ "reaction.paid-send-as",
1247
+ ),
1248
+ coverage_note=(
1249
+ "`reaction.live-story-paid` is the same Star spend inside a live "
1250
+ "story; the live-story stream itself is control-only for a CLI and "
1251
+ "belongs to the calls group."
1252
+ ),
1253
+ )
1254
+
1255
+
1256
+ class PrivacyGetReq(Request):
1257
+ pass
1258
+
1259
+
1260
+ async def privacy_get(ctx: OpContext, req: PrivacyGetReq) -> ReactionPrivacy:
1261
+ """How my paid reactions are attributed by default.
1262
+
1263
+ Worth calling on startup: `updatePaidReactionPrivacy` only reaches online
1264
+ sessions and is not replayed by `getDifference`.
1265
+ """
1266
+ from telethon.tl.functions import messages as fn
1267
+
1268
+ value = await client(ctx)(fn.GetPaidReactionPrivacyRequest())
1269
+ inner = getattr(value, "private", None)
1270
+ for update in getattr(value, "updates", None) or []:
1271
+ # The answer is an `updatePaidReactionPrivacy` inside an Updates batch;
1272
+ # reading the envelope as the value itself always says "default".
1273
+ if getattr(update, "private", None) is not None:
1274
+ inner = update.private
1275
+ return ReactionPrivacy(
1276
+ privacy=_privacy_name(inner), # type: ignore[arg-type]
1277
+ peer_id=peer_id_of(getattr(inner, "peer", None)),
1278
+ )
1279
+
1280
+
1281
+ SPEC_PRIVACY_GET = OperationSpec(
1282
+ id="reaction.privacy.get",
1283
+ request=PrivacyGetReq,
1284
+ response=ReactionPrivacy,
1285
+ impl=privacy_get,
1286
+ summary="Default privacy of my paid reactions",
1287
+ columns=("privacy",),
1288
+ example={"privacy": "default"},
1289
+ example_args="reaction privacy get",
1290
+ covers=("reaction.paid-privacy-get",),
1291
+ )
1292
+
1293
+
1294
+ class PrivacySetReq(Request):
1295
+ mode: Annotated[str, arg(0, metavar="MODE", help="default | anonymous | peer.")]
1296
+ chat: Annotated[
1297
+ PeerRef | None, opt("--chat", metavar="CHAT", kind="peer", help="The post's chat.")
1298
+ ] = None
1299
+ msg_id: Annotated[
1300
+ int | None, opt("--msg-id", metavar="ID", kind="msg_id", help="The post itself.")
1301
+ ] = None
1302
+ send_as: Annotated[
1303
+ PeerRef | None,
1304
+ opt("--send-as", metavar="PEER", kind="peer", help="Attribute them to this channel."),
1305
+ ] = None
1306
+
1307
+
1308
+ async def privacy_set(ctx: OpContext, req: PrivacySetReq) -> ReactionPrivacy:
1309
+ """Change how paid reactions are attributed, including ones already sent.
1310
+
1311
+ This rewrites the attribution of Stars already spent on that post, and
1312
+ changes the account-wide default as a side effect — which is why it takes
1313
+ the post rather than being a settings-only toggle.
1314
+ """
1315
+ from telethon.tl.functions import messages as fn
1316
+
1317
+ if req.mode not in ("default", "anonymous", "peer"):
1318
+ raise UsageError("mode is one of default, anonymous, peer", field="mode")
1319
+ if req.chat is None or req.msg_id is None:
1320
+ raise UsageError(
1321
+ "--chat and --msg-id name the post whose attribution changes", field="msg-id"
1322
+ )
1323
+ peer = await _send.resolve(ctx, req.chat)
1324
+ send_as = await _send.resolve(ctx, req.send_as) if req.send_as is not None else None
1325
+ await client(ctx)(
1326
+ fn.TogglePaidReactionPrivacyRequest(
1327
+ peer=peer, msg_id=req.msg_id, private=_privacy(req.mode, send_as)
1328
+ )
1329
+ )
1330
+ return ReactionPrivacy(
1331
+ privacy=req.mode, # type: ignore[arg-type]
1332
+ peer_id=peer_id_of(send_as),
1333
+ msg_id=req.msg_id,
1334
+ )
1335
+
1336
+
1337
+ SPEC_PRIVACY_SET = OperationSpec(
1338
+ id="reaction.privacy.set",
1339
+ request=PrivacySetReq,
1340
+ response=ReactionPrivacy,
1341
+ impl=privacy_set,
1342
+ summary="Change the privacy of paid reactions, including ones already sent",
1343
+ description="Also changes the account-wide default, which is what the server does.",
1344
+ mutating=True,
1345
+ idempotent=True,
1346
+ columns=("privacy", "msg_id"),
1347
+ example={"privacy": "anonymous", "msg_id": 12345},
1348
+ example_args="reaction privacy set anonymous --chat @news --msg-id 12345",
1349
+ covers=("reaction.paid-privacy-set",),
1350
+ )
1351
+
1352
+
1353
+ # ---------------------------------------------------------------------------
1354
+ # reaction purge / report
1355
+ # ---------------------------------------------------------------------------
1356
+
1357
+
1358
+ class PurgeReq(Request):
1359
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
1360
+ user: Annotated[PeerRef, arg(1, metavar="USER", kind="user", help="Whose reactions.")]
1361
+ msg: Annotated[
1362
+ int | None, opt("--msg", metavar="ID", kind="msg_id", help="Only this message.")
1363
+ ] = None
1364
+ every: Annotated[bool, opt("--every", help="Every reaction this member left in the chat.")] = (
1365
+ False
1366
+ )
1367
+
1368
+
1369
+ async def purge(ctx: OpContext, req: PurgeReq) -> ReactionPurge:
1370
+ """Delete a member's reactions — on one message, or across the chat.
1371
+
1372
+ Moderation: needs the delete-messages / ban rights. Both the singular and
1373
+ the plural method exist on the wire; which one runs is the difference
1374
+ between `--msg` and `--every`, and asking for neither is a usage error
1375
+ rather than a guess.
1376
+ """
1377
+ from telethon.tl.functions import messages as fn
1378
+
1379
+ peer = await _send.resolve(ctx, req.chat)
1380
+ chat_id = _send.peer_id_of(peer)
1381
+ participant = await _send.resolve(ctx, req.user)
1382
+ user_id = _send.peer_id_of(participant)
1383
+
1384
+ if req.every:
1385
+ await client(ctx)(fn.DeleteParticipantReactionsRequest(peer=peer, participant=participant))
1386
+ scope = "chat"
1387
+ elif req.msg is not None:
1388
+ await client(ctx)(
1389
+ fn.DeleteParticipantReactionRequest(peer=peer, msg_id=req.msg, participant=participant)
1390
+ )
1391
+ scope = "message"
1392
+ else:
1393
+ raise UsageError("name a message with --msg, or pass --every", field="msg")
1394
+
1395
+ ctx.emit("reaction_purged", {"chat_id": chat_id, "user_id": user_id, "scope": scope})
1396
+ return ReactionPurge(
1397
+ chat_id=chat_id,
1398
+ user_id=user_id,
1399
+ msg_id=req.msg,
1400
+ deleted=True,
1401
+ scope=scope, # type: ignore[arg-type]
1402
+ )
1403
+
1404
+
1405
+ SPEC_PURGE = OperationSpec(
1406
+ id="reaction.purge",
1407
+ request=PurgeReq,
1408
+ response=ReactionPurge,
1409
+ impl=purge,
1410
+ summary="Delete a member's reactions on one message or across a chat",
1411
+ description="Moderation: needs `delete_messages` or ban rights in the chat.",
1412
+ aliases=("react.purge",),
1413
+ mutating=True,
1414
+ destructive=True,
1415
+ rate_class="send",
1416
+ columns=("chat_id", "user_id", "scope", "deleted"),
1417
+ example={"chat_id": -1001234567890, "user_id": 4242, "deleted": True, "scope": "message"},
1418
+ example_args="reaction purge @news @alice --msg 12345",
1419
+ covers=(
1420
+ "messages-core.reaction-delete-from-sender",
1421
+ "reaction.purge-participant-message",
1422
+ ),
1423
+ )
1424
+
1425
+
1426
+ class ReportReq(Request):
1427
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
1428
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="The post.")]
1429
+ user: Annotated[PeerRef, arg(2, metavar="USER", kind="user", help="Who reacted.")]
1430
+ ban: Annotated[bool, opt("--ban", help="Also block them from replying.")] = False
1431
+
1432
+
1433
+ async def report(ctx: OpContext, req: ReportReq) -> ReactionReport:
1434
+ """Report a reaction on my own post, optionally blocking the member.
1435
+
1436
+ The ban half is `contacts.blockFromReplies`, which is exactly what the GUI
1437
+ does from this menu; the full moderation surface belongs to the group
1438
+ admin commands.
1439
+ """
1440
+ from telethon.tl.functions import contacts as ct
1441
+ from telethon.tl.functions import messages as fn
1442
+
1443
+ peer = await _send.resolve(ctx, req.chat)
1444
+ chat_id = _send.peer_id_of(peer)
1445
+ reaction_peer = await _send.resolve(ctx, req.user)
1446
+ await client(ctx)(
1447
+ fn.ReportReactionRequest(peer=peer, id=req.msg_id, reaction_peer=reaction_peer)
1448
+ )
1449
+ banned = False
1450
+ if req.ban:
1451
+ await client(ctx)(ct.BlockFromRepliesRequest(msg_id=req.msg_id, delete_message=True))
1452
+ banned = True
1453
+ return ReactionReport(
1454
+ ok=True,
1455
+ banned=banned,
1456
+ chat_id=chat_id,
1457
+ msg_id=req.msg_id,
1458
+ user_id=_send.peer_id_of(reaction_peer),
1459
+ )
1460
+
1461
+
1462
+ SPEC_REPORT = OperationSpec(
1463
+ id="reaction.report",
1464
+ request=ReportReq,
1465
+ response=ReactionReport,
1466
+ impl=report,
1467
+ summary="Report a reaction, optionally blocking the member who left it",
1468
+ description="Only valid on your own posts. Reporting cannot be undone from a client.",
1469
+ mutating=True,
1470
+ rate_class="send",
1471
+ columns=("ok", "banned"),
1472
+ example={"ok": True, "banned": False, "chat_id": -1001234567890, "msg_id": 12345},
1473
+ example_args="reaction report @news 12345 @alice",
1474
+ covers=("groups-channels-admin.report-reaction", "reaction.ban-sender", "reaction.report"),
1475
+ )