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/message.py ADDED
@@ -0,0 +1,3769 @@
1
+ """The `message` group: everything you can do to or with a message.
2
+
3
+ This is the busiest group in the product and the one the registry was designed
4
+ against. Three things it settles for the whole surface:
5
+
6
+ * **one composer.** v1 had `message send`, `media upload` and `draft set` each
7
+ building their own request; here `message send` is the universal composer
8
+ (text, album, dice, contact, location, sticker, voice, poll) and `forward`,
9
+ `edit` and `draft set` share its options struct, so `--parse`, `--reply-to`
10
+ and `--schedule` cannot mean three different things.
11
+ * **one message shape.** Every op that returns a message returns
12
+ `models.Message`, built by `ops/_serialize.message_to_model`.
13
+ * **one pagination story.** `list`, `search` and `thread list` take
14
+ `--limit/--cursor/--all` from the transport, not from their own request, and
15
+ hand back a signed `Page[Message]`.
16
+
17
+ Telethon is imported inside functions, never at module scope: importing the
18
+ registry is what builds `tlgr --help`, and that must not pull in Telethon.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import re
25
+ from typing import Annotated, Any
26
+
27
+ from tlgr.core.errors import (
28
+ EXIT_EMPTY,
29
+ NotFoundError,
30
+ UsageError,
31
+ )
32
+ from tlgr.core.pagination import PageKind, build_page
33
+ from tlgr.core.text import utf16_len
34
+ from tlgr.core.timefmt import fmt_dt, parse_dt, to_unix
35
+ from tlgr.models.base import Request
36
+ from tlgr.models.message import (
37
+ ComposeResult,
38
+ DeleteResult,
39
+ DiceCatalog,
40
+ EditResult,
41
+ Effect,
42
+ EntityReport,
43
+ FactCheck,
44
+ ForwardedMessage,
45
+ GameInfo,
46
+ GameScore,
47
+ LinkResult,
48
+ Message,
49
+ MessageEntity,
50
+ PaidMessageSettings,
51
+ PinResult,
52
+ ReadReceipts,
53
+ ReadResult,
54
+ ReportResult,
55
+ ScheduledSent,
56
+ SponsoredHidden,
57
+ SponsoredMessage,
58
+ SuggestedPostState,
59
+ SummaryResult,
60
+ Tone,
61
+ Transcription,
62
+ Translation,
63
+ ViewCount,
64
+ WebPagePreview,
65
+ )
66
+ from tlgr.models.page import Page
67
+ from tlgr.models.peer import PeerRef
68
+ from tlgr.ops import _send
69
+ from tlgr.ops._common import affected_loop as _affected_loop
70
+ from tlgr.ops._common import already as _already
71
+ from tlgr.ops._common import client as _client
72
+ from tlgr.ops._common import ids as _ids
73
+ from tlgr.ops._common import input_channel as _input_channel
74
+ from tlgr.ops._common import is_not_modified as _is_not_modified
75
+ from tlgr.ops._common import only as _only
76
+ from tlgr.ops._common import random_id as _random_id
77
+ from tlgr.ops._common import window as _window
78
+ from tlgr.ops._params import arg, choice, opt
79
+ from tlgr.ops._serialize import entity_to_peer, message_entities, message_to_model
80
+ from tlgr.ops._spec import OpContext, OperationSpec, Surface
81
+
82
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
83
+
84
+ #: `--type` values → the `inputMessagesFilter*` class that implements them.
85
+ #: Spelled as class *names* so this module still imports without Telethon.
86
+ FILTERS: dict[str, str] = {
87
+ "photo": "InputMessagesFilterPhotos",
88
+ "video": "InputMessagesFilterVideo",
89
+ "media": "InputMessagesFilterPhotoVideo",
90
+ "file": "InputMessagesFilterDocument",
91
+ "link": "InputMessagesFilterUrl",
92
+ "url": "InputMessagesFilterUrl",
93
+ "music": "InputMessagesFilterMusic",
94
+ "voice": "InputMessagesFilterVoice",
95
+ "gif": "InputMessagesFilterGif",
96
+ "round": "InputMessagesFilterRoundVideo",
97
+ "geo": "InputMessagesFilterGeo",
98
+ "contact": "InputMessagesFilterContacts",
99
+ "pinned": "InputMessagesFilterPinned",
100
+ "chat-photo": "InputMessagesFilterChatPhotos",
101
+ "call": "InputMessagesFilterPhoneCalls",
102
+ "poll": "InputMessagesFilterPoll",
103
+ "todo": "InputMessagesFilterToDo",
104
+ "mention": "InputMessagesFilterMyMentions",
105
+ "sticker": "InputMessagesFilterDocument",
106
+ }
107
+
108
+ _EXAMPLE_MESSAGE: dict[str, Any] = {
109
+ "id": 12345,
110
+ "chat_id": 777123,
111
+ "date": "2026-09-03T09:14:07Z",
112
+ "date_unix": 1788340447,
113
+ "text": "on my way",
114
+ "out": True,
115
+ "kind": "message",
116
+ }
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Shared plumbing
121
+ # ---------------------------------------------------------------------------
122
+
123
+
124
+ def _filter(name: str | None) -> Any:
125
+ """`--type photo` → `InputMessagesFilterPhotos()`, or None."""
126
+ if not name:
127
+ return None
128
+ class_name = FILTERS.get(name)
129
+ if class_name is None:
130
+ raise UsageError(
131
+ f"--type {name!r} is not a media filter; expected one of {', '.join(FILTERS)}",
132
+ field="type",
133
+ )
134
+ from telethon.tl import types
135
+
136
+ return getattr(types, class_name)()
137
+
138
+
139
+ async def _fetch(
140
+ ctx: OpContext,
141
+ peer: Any,
142
+ *,
143
+ chat_id: int,
144
+ limit: int,
145
+ **kwargs: Any,
146
+ ) -> list[Message]:
147
+ """`iter_messages` into models, with the chat id filled in once."""
148
+ out: list[Message] = []
149
+ async for raw in _client(ctx).iter_messages(peer, limit=limit, **kwargs):
150
+ if raw is None:
151
+ continue
152
+ out.append(message_to_model(raw, chat_id=chat_id))
153
+ return out
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # message send
158
+ # ---------------------------------------------------------------------------
159
+
160
+
161
+ class SendReq(_send.SendOptions, kw_only=True):
162
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Target chat or user.")]
163
+ text: Annotated[
164
+ str,
165
+ arg(1, metavar="TEXT", required=False, help="Message text; '-' reads stdin."),
166
+ ] = ""
167
+ parse: Annotated[
168
+ str | None, choice("md", "html", "none", help="Formatting of TEXT/--caption.")
169
+ ] = None
170
+ entities: Annotated[
171
+ str | None,
172
+ opt("--entities", metavar="JSON", kind="json", help="Explicit entity vector (UTF-16)."),
173
+ ] = None
174
+ stdin: Annotated[bool, opt("--stdin", help="Read the body from stdin.")] = False
175
+ reply_to: Annotated[
176
+ int | None, opt("--reply-to", metavar="ID", kind="msg_id", help="Reply to this message.")
177
+ ] = None
178
+ reply_in: Annotated[
179
+ PeerRef | None,
180
+ opt("--reply-in", metavar="CHAT", kind="peer", help="Chat --reply-to belongs to."),
181
+ ] = None
182
+ quote: Annotated[
183
+ str | None, opt("--quote", help="Quote this exact substring of the replied message.")
184
+ ] = None
185
+ quote_offset: Annotated[
186
+ int | None, opt("--quote-offset", metavar="N", help="UTF-16 offset of --quote.")
187
+ ] = None
188
+ reply_task: Annotated[
189
+ int | None, opt("--reply-task", metavar="N", help="Reply to a checklist task id.")
190
+ ] = None
191
+ reply_poll_option: Annotated[
192
+ int | None, opt("--reply-poll-option", metavar="N", help="Reply to a poll option index.")
193
+ ] = None
194
+ reply_to_story: Annotated[
195
+ int | None, opt("--reply-to-story", metavar="ID", help="Reply to a story of CHAT.")
196
+ ] = None
197
+ direct_to: Annotated[
198
+ PeerRef | None,
199
+ opt("--direct-to", metavar="USER", kind="user", help="Answer in a monoforum topic."),
200
+ ] = None
201
+ comment_to: Annotated[
202
+ int | None, opt("--comment-to", metavar="ID", help="Comment on a channel post.")
203
+ ] = None
204
+ clear_draft: Annotated[bool, opt(help="Clear the chat draft on send.")] = True
205
+ split: Annotated[bool, opt("--split", help="Split text longer than the limit.")] = False
206
+ split_at: Annotated[int, opt("--split-at", metavar="N", help="Split threshold.", ge=1)] = 4096
207
+ as_file: Annotated[bool, opt("--as-file", help="Send the text as a .txt document.")] = False
208
+ filename: Annotated[
209
+ str | None, opt("--filename", metavar="NAME", help="Filename for --as-file/stdin.")
210
+ ] = None
211
+ file: Annotated[
212
+ list[str],
213
+ opt("--file", metavar="PATH", help="Attach a file; repeat for an album."),
214
+ ] = []
215
+ caption: Annotated[str | None, opt("--caption", help="Caption for the attached media.")] = None
216
+ caption_above: Annotated[bool, opt("--caption-above", help="invert_media.")] = False
217
+ spoiler: Annotated[bool, opt("--spoiler", help="Hide the media behind a spoiler.")] = False
218
+ ttl: Annotated[
219
+ int | None,
220
+ opt("--ttl", metavar="DURATION", kind="duration", help="Self-destruct timer."),
221
+ ] = None
222
+ sticker: Annotated[str | None, opt("--sticker", metavar="REF", help="Send a sticker.")] = None
223
+ gif: Annotated[str | None, opt("--gif", metavar="REF", help="Send a GIF/animation.")] = None
224
+ voice: Annotated[
225
+ str | None, opt("--voice", metavar="PATH", kind="path", help="Send as a voice note.")
226
+ ] = None
227
+ video_note: Annotated[
228
+ str | None, opt("--video-note", metavar="PATH", kind="path", help="Send a round video.")
229
+ ] = None
230
+ dice: Annotated[
231
+ str | None, opt("--dice", metavar="EMOJI", help="Send an animated dice/darts/slot.")
232
+ ] = None
233
+ contact: Annotated[
234
+ PeerRef | None,
235
+ opt("--contact", metavar="USER", kind="user", help="Send a contact card for a user."),
236
+ ] = None
237
+ contact_phone: Annotated[str | None, opt("--contact-phone", help="Contact card phone.")] = None
238
+ contact_first: Annotated[str | None, opt("--contact-first", help="Contact first name.")] = None
239
+ contact_last: Annotated[str | None, opt("--contact-last", help="Contact last name.")] = None
240
+ vcard: Annotated[
241
+ str | None, opt("--vcard", metavar="PATH", kind="path", help="vCard payload.")
242
+ ] = None
243
+ location: Annotated[
244
+ str | None, opt("--location", metavar="LAT,LON", help="Attach a static location.")
245
+ ] = None
246
+ poll: Annotated[
247
+ str | None, opt("--poll", metavar="JSON", kind="json", help="Attach a poll.")
248
+ ] = None
249
+ no_preview: Annotated[bool, opt("--no-preview", help="Disable the link preview.")] = False
250
+ preview_url: Annotated[
251
+ str | None, opt("--preview-url", metavar="URL", help="Preview this URL.")
252
+ ] = None
253
+ preview_large: Annotated[bool, opt("--preview-large", help="force_large_media.")] = False
254
+ preview_small: Annotated[bool, opt("--preview-small", help="force_small_media.")] = False
255
+ preview_above: Annotated[bool, opt("--preview-above", help="Preview above the text.")] = False
256
+ invert_media: Annotated[
257
+ bool, opt("--invert-media", help="Alias of --preview-above/--caption-above.")
258
+ ] = False
259
+ rich_markdown: Annotated[
260
+ str | None,
261
+ opt("--rich-markdown", metavar="PATH", kind="path", help="Send a rich body (Markdown)."),
262
+ ] = None
263
+ rich_html: Annotated[
264
+ str | None,
265
+ opt("--rich-html", metavar="PATH", kind="path", help="Send a rich body (HTML)."),
266
+ ] = None
267
+ screenshot: Annotated[
268
+ bool, opt("--screenshot", help="Send a screenshot-taken service notification.")
269
+ ] = False
270
+ suggest: Annotated[bool, opt("--suggest", help="Offer this as a suggested post.")] = False
271
+ price_stars: Annotated[
272
+ int | None, opt("--price-stars", metavar="N", help="Stars asked for the suggested post.")
273
+ ] = None
274
+ price_ton: Annotated[
275
+ int | None, opt("--price-ton", metavar="NANO", help="TON asked for the suggested post.")
276
+ ] = None
277
+ publish_at: Annotated[
278
+ str | None,
279
+ opt("--publish-at", metavar="TS", kind="datetime", help="Requested publication time."),
280
+ ] = None
281
+ wait_slowmode: Annotated[
282
+ bool, opt("--wait-slowmode", help="Wait out slow mode instead of failing.")
283
+ ] = False
284
+ random_id: Annotated[
285
+ int | None,
286
+ opt("--random-id", metavar="N", help="Explicit random_id so a retry is deduplicated."),
287
+ ] = None
288
+
289
+
290
+ def _suggested_post(req: SendReq) -> Any:
291
+ """`--suggest` as the `SuggestedPost` the send requests carry."""
292
+ if not req.suggest:
293
+ return None
294
+ from telethon.tl import types
295
+
296
+ price: Any = None
297
+ if req.price_stars is not None:
298
+ price = types.StarsAmount(amount=req.price_stars, nanos=0)
299
+ elif req.price_ton is not None:
300
+ price = types.StarsTonAmount(amount=req.price_ton)
301
+ return types.SuggestedPost(price=price, schedule_date=_send.schedule_at(req.publish_at))
302
+
303
+
304
+ async def _non_file_media(ctx: OpContext, req: SendReq) -> Any:
305
+ """The media variants that are described rather than uploaded."""
306
+ from telethon.tl import types
307
+
308
+ if req.dice is not None:
309
+ return types.InputMediaDice(emoticon=req.dice or "🎲")
310
+ if req.location:
311
+ try:
312
+ latitude, _, longitude = req.location.partition(",")
313
+ point = types.InputGeoPoint(lat=float(latitude), long=float(longitude))
314
+ except ValueError as exc:
315
+ raise UsageError("--location wants 'lat,lon'", field="location") from exc
316
+ return types.InputMediaGeoPoint(geo_point=point)
317
+ if req.contact is not None or req.contact_phone:
318
+ phone = req.contact_phone or ""
319
+ first = req.contact_first or ""
320
+ last = req.contact_last or ""
321
+ vcard = ""
322
+ if req.vcard:
323
+ with open(req.vcard, encoding="utf-8") as handle:
324
+ vcard = handle.read()
325
+ if req.contact is not None:
326
+ peer = await _send.resolve(ctx, req.contact)
327
+ user = await _client(ctx).get_entity(peer)
328
+ phone = phone or (getattr(user, "phone", None) or "")
329
+ first = first or (getattr(user, "first_name", None) or "")
330
+ last = last or (getattr(user, "last_name", None) or "")
331
+ if not phone:
332
+ raise UsageError(
333
+ "a contact card needs a phone number; the user hides theirs, so pass "
334
+ "--contact-phone",
335
+ field="contact",
336
+ )
337
+ return types.InputMediaContact(
338
+ phone_number=phone, first_name=first, last_name=last, vcard=vcard
339
+ )
340
+ if req.poll is not None:
341
+ # `--poll JSON` takes the same field names as `poll create`, and is
342
+ # built by the same function: two spellings of "make a poll" that
343
+ # disagreed about defaults would be worse than one shared builder.
344
+ import msgspec
345
+
346
+ from tlgr.ops import poll as poll_ops
347
+
348
+ try:
349
+ spec = msgspec.json.decode(req.poll, type=dict)
350
+ except msgspec.DecodeError as exc:
351
+ raise UsageError(f"--poll is not JSON: {exc}", field="poll") from exc
352
+ spec.setdefault("chat", "me")
353
+ spec.setdefault("parse", req.parse)
354
+ try:
355
+ request = msgspec.convert(spec, type=poll_ops.CreateReq, strict=False)
356
+ except msgspec.ValidationError as exc:
357
+ raise UsageError(f"--poll: {exc}", field="poll") from exc
358
+ return await poll_ops.build_media(ctx, request)
359
+ if req.preview_url:
360
+ return types.InputMediaWebPage(
361
+ url=req.preview_url,
362
+ force_large_media=req.preview_large or None,
363
+ force_small_media=req.preview_small or None,
364
+ optional=True,
365
+ )
366
+ return None
367
+
368
+
369
+ async def _sticker_or_gif(ctx: OpContext, ref: str) -> Any:
370
+ """A sticker/GIF reference: a file id pair, or a local file."""
371
+ from telethon.tl import types
372
+
373
+ if ":" in ref and all(part.lstrip("-").isdigit() for part in ref.split(":", 1)):
374
+ document_id, _, access_hash = ref.partition(":")
375
+ return types.InputMediaDocument(
376
+ id=types.InputDocument(
377
+ id=int(document_id), access_hash=int(access_hash), file_reference=b""
378
+ )
379
+ )
380
+ return await _send.input_media(ctx, ref)
381
+
382
+
383
+ async def send(ctx: OpContext, req: SendReq) -> Message:
384
+ """Send anything to a chat, with every send-time option Telegram has.
385
+
386
+ One implementation rather than one per media type, because the options —
387
+ reply target, schedule, silence, effect, protection, paid stars — are
388
+ orthogonal to *what* is being sent and duplicating them per command is
389
+ how they drift apart.
390
+ """
391
+ from telethon.tl import types
392
+ from telethon.tl.functions import messages as fn
393
+
394
+ if req.rich_markdown or req.rich_html:
395
+ _send.require_supported(
396
+ "--rich-markdown/--rich-html",
397
+ "a rich (long-form) body is layer 229 and the pinned Telethon speaks 227",
398
+ )
399
+
400
+ peer = await _send.resolve(ctx, req.chat)
401
+ chat_id = _send.peer_id_of(peer)
402
+
403
+ if req.screenshot:
404
+ result = await _client(ctx)(
405
+ fn.SendScreenshotNotificationRequest(
406
+ peer=peer,
407
+ reply_to=types.InputReplyToMessage(reply_to_msg_id=req.reply_to or 0),
408
+ random_id=req.random_id or _random_id(),
409
+ )
410
+ )
411
+ return _send.message_from_updates(result, chat_id=chat_id)
412
+
413
+ text, entities = _send.body(req.text, parse=req.parse, entities=req.entities, stdin=req.stdin)
414
+ caption, caption_entities = (
415
+ _send.body(req.caption, parse=req.parse) if req.caption is not None else ("", [])
416
+ )
417
+
418
+ if req.comment_to is not None:
419
+ # A comment lives in the linked discussion group, not in the channel.
420
+ discussion = await _client(ctx)(fn.GetDiscussionMessageRequest(peer, req.comment_to))
421
+ found = list(getattr(discussion, "messages", None) or [])
422
+ if not found:
423
+ raise NotFoundError(f"post {req.comment_to} has no discussion thread to comment in")
424
+ peer = await _client(ctx).get_input_entity(found[0].peer_id)
425
+ chat_id = _send.peer_id_of(peer)
426
+ req.reply_to = int(found[0].id)
427
+
428
+ await _send.show_typing(
429
+ ctx, peer, _send.typing_seconds(text, requested=req.typing, auto=req.typing_auto)
430
+ )
431
+
432
+ reply_to = await _send.reply_target(
433
+ ctx,
434
+ reply_to=req.reply_to,
435
+ reply_in=req.reply_in,
436
+ quote=req.quote,
437
+ quote_offset=req.quote_offset,
438
+ quote_parse=req.parse,
439
+ topic=req.topic,
440
+ reply_task=req.reply_task,
441
+ reply_poll_option=req.reply_poll_option,
442
+ reply_to_story=req.reply_to_story,
443
+ story_peer=req.chat,
444
+ direct_to=req.direct_to,
445
+ )
446
+ common: dict[str, Any] = {
447
+ "peer": peer,
448
+ "silent": req.silent or None,
449
+ "clear_draft": req.clear_draft or None,
450
+ "noforwards": req.protect or None,
451
+ "invert_media": (req.invert_media or req.preview_above or req.caption_above) or None,
452
+ "reply_to": reply_to,
453
+ "schedule_date": _send.schedule_at(req.schedule),
454
+ "schedule_repeat_period": _send.repeat_period(req.repeat),
455
+ "send_as": await _send.resolve(ctx, req.send_as) if req.send_as is not None else None,
456
+ "effect": _send.effect_id(req.effect),
457
+ "allow_paid_stars": req.paid_stars,
458
+ "suggested_post": _suggested_post(req),
459
+ }
460
+ if req.quick_reply:
461
+ common["quick_reply_shortcut"] = types.InputQuickReplyShortcut(shortcut=req.quick_reply)
462
+
463
+ media = await _non_file_media(ctx, req)
464
+ files = list(req.file)
465
+ if req.voice:
466
+ files, media = [], await _send.input_media(ctx, req.voice, voice=True)
467
+ elif req.video_note:
468
+ files, media = [], await _send.input_media(ctx, req.video_note, video_note=True)
469
+ elif req.sticker:
470
+ files, media = [], await _sticker_or_gif(ctx, req.sticker)
471
+ elif req.gif:
472
+ files, media = [], await _sticker_or_gif(ctx, req.gif)
473
+ elif req.as_file:
474
+ files, media = [], await _text_as_file(ctx, text, req.filename)
475
+ text, entities = caption or "", caption_entities
476
+
477
+ sent = await _dispatch_send(
478
+ ctx,
479
+ req,
480
+ common,
481
+ peer=peer,
482
+ chat_id=chat_id,
483
+ text=text,
484
+ entities=entities,
485
+ caption=caption,
486
+ caption_entities=caption_entities,
487
+ media=media,
488
+ files=files,
489
+ )
490
+ ctx.emit("message_out", {"chat_id": chat_id, "id": sent.id, "text": sent.text})
491
+ return sent
492
+
493
+
494
+ async def _dispatch_send(
495
+ ctx: OpContext,
496
+ req: SendReq,
497
+ common: dict[str, Any],
498
+ *,
499
+ peer: Any,
500
+ chat_id: int,
501
+ text: str,
502
+ entities: list[MessageEntity],
503
+ caption: str,
504
+ caption_entities: list[MessageEntity],
505
+ media: Any,
506
+ files: list[str],
507
+ ) -> Message:
508
+ """Pick the request the send actually needs, and run it."""
509
+ from telethon.tl import types
510
+ from telethon.tl.functions import messages as fn
511
+
512
+ client = _client(ctx)
513
+
514
+ if len(files) > 1:
515
+ singles = []
516
+ for index, source in enumerate(files):
517
+ item = await _send.input_media(ctx, source, spoiler=req.spoiler, ttl=req.ttl)
518
+ uploaded = await client(fn.UploadMediaRequest(peer=peer, media=item))
519
+ singles.append(
520
+ types.InputSingleMedia(
521
+ media=_uploaded_to_input(uploaded),
522
+ random_id=_random_id(),
523
+ message=caption if index == 0 else "",
524
+ entities=_send.tl_entities(caption_entities) if index == 0 else None,
525
+ )
526
+ )
527
+ result = await client(
528
+ fn.SendMultiMediaRequest(multi_media=singles, **_only(common, fn.SendMultiMediaRequest))
529
+ )
530
+ produced = _send.messages_from_updates(result, chat_id=chat_id)
531
+ first = produced[0] if produced else _send.message_from_updates(result, chat_id=chat_id)
532
+ first.batch = [m.id for m in produced[1:]]
533
+ return first
534
+
535
+ if files:
536
+ media = await _send.input_media(ctx, files[0], spoiler=req.spoiler, ttl=req.ttl)
537
+
538
+ if media is not None:
539
+ result = await client(
540
+ fn.SendMediaRequest(
541
+ media=media,
542
+ message=caption or text,
543
+ entities=_send.tl_entities(caption_entities or entities),
544
+ random_id=req.random_id or _random_id(),
545
+ **_only(common, fn.SendMediaRequest),
546
+ )
547
+ )
548
+ return _send.message_from_updates(result, chat_id=chat_id, sent_text=caption or text)
549
+
550
+ parts = _send.split_text(text, entities, req.split_at) if req.split else [(text, entities)]
551
+ if not req.split and utf16_len(text) > _send.MAX_TEXT_UTF16:
552
+ raise UsageError(
553
+ f"the text is {utf16_len(text)} UTF-16 units and Telegram accepts "
554
+ f"{_send.MAX_TEXT_UTF16}; pass --split to send it as several messages",
555
+ field="text",
556
+ )
557
+
558
+ produced = []
559
+ for index, (chunk, runs) in enumerate(parts):
560
+ result = await client(
561
+ fn.SendMessageRequest(
562
+ message=chunk,
563
+ entities=_send.tl_entities(runs),
564
+ no_webpage=req.no_preview or None,
565
+ random_id=(req.random_id or _random_id()) + index,
566
+ **_only(common, fn.SendMessageRequest),
567
+ )
568
+ )
569
+ produced.append(_send.message_from_updates(result, chat_id=chat_id, sent_text=chunk))
570
+ if index + 1 < len(parts):
571
+ limiter = getattr(ctx, "limiter", None)
572
+ if limiter is not None:
573
+ await limiter.acquire("send")
574
+ first = produced[0]
575
+ first.batch = [m.id for m in produced[1:]]
576
+ return first
577
+
578
+
579
+ def _uploaded_to_input(uploaded: Any) -> Any:
580
+ """`messages.uploadMedia` result → the `InputMedia` a multi-send wants."""
581
+ from telethon import utils
582
+
583
+ return utils.get_input_media(uploaded)
584
+
585
+
586
+ async def _text_as_file(ctx: OpContext, text: str, name: str | None) -> Any:
587
+ """`--as-file`: the message body as a `.txt` document."""
588
+ import tempfile
589
+ from pathlib import Path
590
+
591
+ directory = Path(tempfile.mkdtemp(prefix="tlgr-txt-"))
592
+ path = directory / (name or "message.txt")
593
+ path.write_text(text, encoding="utf-8")
594
+ return await _send.input_media(ctx, str(path), force_file=True, file_name=path.name)
595
+
596
+
597
+ SPEC_SEND = OperationSpec(
598
+ id="message.send",
599
+ request=SendReq,
600
+ response=Message,
601
+ impl=send,
602
+ summary="Send a message to a chat",
603
+ description=(
604
+ "The universal composer: text, a file, an album, a sticker, a voice "
605
+ "note, a dice, a contact card or a location, with every send-time "
606
+ "option Telegram exposes. Text longer than 4096 UTF-16 units is "
607
+ "refused unless --split is given, because silently truncating a "
608
+ "message is worse than not sending it."
609
+ ),
610
+ aliases=("send", "msg.send"),
611
+ legacy_paths=("message send", "msg send", "send"),
612
+ mutating=True,
613
+ rate_class="send",
614
+ timeout_s=300,
615
+ columns=("id", "chat_id", "date", "text"),
616
+ example=_EXAMPLE_MESSAGE,
617
+ example_args='message send @alice "on my way"',
618
+ covers=(
619
+ "bots.button-styles-icons",
620
+ "bots.paid-broadcast-floodskip",
621
+ "bots.paid-message-to-bot",
622
+ "bots.reply-keyboard-hide-force",
623
+ "bots.send-with-keyboard",
624
+ "contact.send-card",
625
+ "dialogs.draft-send",
626
+ "dice.send",
627
+ "effect.send",
628
+ "media.link-preview",
629
+ "messages-core.comments-post",
630
+ "messages-core.format-basic-styles",
631
+ "messages-core.format-blockquote",
632
+ "messages-core.format-code-and-pre",
633
+ "messages-core.format-custom-emoji",
634
+ "messages-core.format-expandable-blockquote",
635
+ "messages-core.format-formatted-date-entity",
636
+ "messages-core.format-hashtag-cashtag",
637
+ "messages-core.format-html-parse-mode",
638
+ "messages-core.format-markdown-parse-mode",
639
+ "messages-core.format-mention",
640
+ "messages-core.format-spoiler",
641
+ "messages-core.format-text-link",
642
+ "messages-core.link-preview-above-text",
643
+ "messages-core.link-preview-choose-url-and-size",
644
+ "messages-core.link-preview-disable",
645
+ "messages-core.reply-in-another-chat",
646
+ "messages-core.reply-to-checklist-task-or-poll-option",
647
+ "messages-core.reply-to-story",
648
+ "messages-core.reply-with-quote",
649
+ "messages-core.saved-messages-send",
650
+ "messages-core.saved-reminder",
651
+ "messages-core.screenshot-notification",
652
+ "messages-core.send-as-peer",
653
+ "messages-core.send-clear-draft",
654
+ "messages-core.send-in-direct-channel-messages",
655
+ "messages-core.send-long-text-split",
656
+ "messages-core.send-paid-message",
657
+ "messages-core.send-preflight-restrictions",
658
+ "messages-core.send-protected-content-message",
659
+ "messages-core.send-reply",
660
+ "messages-core.send-scheduled",
661
+ "messages-core.send-scheduled-repeating",
662
+ "messages-core.send-silent",
663
+ "messages-core.send-text",
664
+ "messages-core.send-text-as-file",
665
+ "messages-core.send-to-topic",
666
+ "messages-core.send-when-online",
667
+ "messages-core.send-with-effect",
668
+ "messages-core.suggest-post",
669
+ "poll.reply-to-option",
670
+ "todo.reply-to-task",
671
+ "updates.invoke-after-msg",
672
+ "updates.sync-random-id-dedup",
673
+ "webpage.send-as-media",
674
+ ),
675
+ covers_partial=("messages-core.send-rich-message", "richmsg.send"),
676
+ coverage_note=(
677
+ "A layer-229 rich body is refused with NOT_SUPPORTED: the pinned "
678
+ "Telethon speaks layer 227 and cannot serialise inputRichMessage*."
679
+ ),
680
+ )
681
+
682
+
683
+ # ---------------------------------------------------------------------------
684
+ # message list / search / thread list
685
+ # ---------------------------------------------------------------------------
686
+
687
+
688
+ class ListReq(Request):
689
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat to read.")]
690
+ since: Annotated[
691
+ str | None,
692
+ opt("--since", metavar="TS", kind="datetime", help="Only messages after this time."),
693
+ ] = None
694
+ until: Annotated[
695
+ str | None,
696
+ opt("--until", metavar="TS", kind="datetime", help="Only messages before this time."),
697
+ ] = None
698
+ before_id: Annotated[
699
+ int | None,
700
+ opt("--before-id", metavar="ID", kind="msg_id", help="Older than this id (offset_id)."),
701
+ ] = None
702
+ after_id: Annotated[
703
+ int | None,
704
+ opt("--after-id", metavar="ID", kind="msg_id", help="Newer than this id (min_id)."),
705
+ ] = None
706
+ around: Annotated[
707
+ int | None,
708
+ opt("--around", metavar="ID", kind="msg_id", help="Centre the page on this id."),
709
+ ] = None
710
+ ids: Annotated[
711
+ list[str], opt("--ids", metavar="ID", help="Fetch exactly these ids or ranges.")
712
+ ] = []
713
+ date: Annotated[
714
+ str | None,
715
+ opt("--date", metavar="DATE", kind="datetime", help="Jump to the first message of a date."),
716
+ ] = None
717
+ reverse: Annotated[bool, opt("--reverse", help="Oldest first.")] = False
718
+ topic: Annotated[
719
+ int | None, opt("--topic", metavar="ID", kind="msg_id", help="Only this topic / thread.")
720
+ ] = None
721
+ type: Annotated[
722
+ str | None, opt("--type", metavar="FILTER", help="Media filter; 'pinned' is the pin list.")
723
+ ] = None
724
+ unread: Annotated[bool, opt("--unread", help="Only messages after the unread divider.")] = False
725
+ scheduled: Annotated[bool, opt("--scheduled", help="List the scheduled drawer.")] = False
726
+ saved_peer: Annotated[
727
+ PeerRef | None,
728
+ opt("--saved-peer", metavar="CHAT", kind="peer", help="In Saved Messages: one origin."),
729
+ ] = None
730
+ personal_channel: Annotated[
731
+ PeerRef | None,
732
+ opt("--personal-channel", metavar="USER", kind="user", help="A profile's channel posts."),
733
+ ] = None
734
+ delivery: Annotated[
735
+ bool, opt("--delivery", help="Annotate own messages with sent/delivered/read state.")
736
+ ] = False
737
+ sender: Annotated[bool, opt("--sender", help="Include sender info and admin rank.")] = False
738
+ media: Annotated[bool, opt("--media", help="Include media metadata.")] = True
739
+ reactions: Annotated[bool, opt("--reactions", help="Include reactions.")] = True
740
+ entities: Annotated[bool, opt("--entities", help="Include entities.")] = True
741
+
742
+
743
+ async def _history_state(
744
+ ctx: OpContext, req: ListReq, limit: int, state: dict[str, Any]
745
+ ) -> dict[str, Any]:
746
+ """`offset_id`/`add_offset` for the page being asked for.
747
+
748
+ `offset_id + add_offset` is Telegram's universal history cursor: older is
749
+ `add_offset = 0`, newer is `-limit`, and centred is `-limit // 2`. Naming
750
+ the three cases here is what keeps `--around` from being a second,
751
+ subtly-different pagination.
752
+ """
753
+ if state:
754
+ return dict(state)
755
+ if req.around is not None:
756
+ return {"offset_id": req.around, "add_offset": -(limit // 2)}
757
+ if req.after_id is not None:
758
+ return {"offset_id": 0, "add_offset": 0, "min_id": req.after_id}
759
+ return {"offset_id": req.before_id or 0, "add_offset": 0}
760
+
761
+
762
+ async def list_messages(ctx: OpContext, req: ListReq) -> Page[Message]:
763
+ """Read chat history, one signed page at a time."""
764
+ limit, state = _window(ctx, "message.list", PageKind.HISTORY)
765
+ peer = await _send.resolve(ctx, req.chat)
766
+ chat_id = _send.peer_id_of(peer)
767
+
768
+ if req.personal_channel is not None:
769
+ return await _personal_channel(ctx, req, limit)
770
+
771
+ explicit = _ids(tuple(req.ids)) if req.ids else []
772
+ if explicit:
773
+ items = await _fetch(ctx, peer, chat_id=chat_id, limit=len(explicit), ids=explicit)
774
+ return Page(items=[m for m in items if m.id], has_more=False, total=len(items))
775
+
776
+ offsets = await _history_state(ctx, req, limit, state)
777
+ kwargs: dict[str, Any] = {
778
+ "offset_id": int(offsets.get("offset_id", 0)),
779
+ "add_offset": int(offsets.get("add_offset", 0)),
780
+ "reverse": req.reverse,
781
+ "scheduled": req.scheduled,
782
+ }
783
+ if offsets.get("min_id"):
784
+ kwargs["min_id"] = int(offsets["min_id"])
785
+ if req.topic is not None:
786
+ kwargs["reply_to"] = req.topic
787
+ if req.type:
788
+ kwargs["filter"] = _filter(req.type)
789
+ when = req.date or req.until
790
+ if when:
791
+ kwargs["offset_date"] = parse_dt(when)
792
+ if req.unread:
793
+ kwargs["min_id"] = await _read_inbox_max(ctx, peer)
794
+ if req.saved_peer is not None:
795
+ # Saved Messages keeps one sub-dialog per origin; Telethon reaches it
796
+ # through the same reply_to slot the forum topics use.
797
+ kwargs["reply_to"] = _send.peer_id_of(await _send.resolve(ctx, req.saved_peer))
798
+
799
+ items = await _fetch(ctx, peer, chat_id=chat_id, limit=limit, **kwargs)
800
+ if req.since:
801
+ floor = parse_dt(req.since)
802
+ cutoff = to_unix(floor) or 0
803
+ items = [m for m in items if m.date_unix >= cutoff]
804
+ if req.scheduled:
805
+ for message in items:
806
+ message.scheduled = True
807
+ if req.delivery:
808
+ await _annotate_delivery(ctx, peer, items)
809
+ if req.sender:
810
+ await _attach_senders(ctx, items)
811
+ _project(items, media=req.media, reactions=req.reactions, entities=req.entities)
812
+
813
+ next_state = {"offset_id": items[-1].id, "add_offset": 0} if items else {}
814
+ return build_page(
815
+ items,
816
+ op="message.list",
817
+ kind=PageKind.HISTORY,
818
+ state=next_state,
819
+ account=ctx.account,
820
+ limit=limit,
821
+ has_more=None if items else False,
822
+ )
823
+
824
+
825
+ def _project(items: list[Message], *, media: bool, reactions: bool, entities: bool) -> None:
826
+ """Drop the parts the caller said it did not want.
827
+
828
+ The defaults are *on*: a caller cannot opt into a field it does not know
829
+ exists, and "did we already react" is not an optional detail for anything
830
+ that reacts (that was v1's `include_reactions` bug).
831
+ """
832
+ for message in items:
833
+ if not media:
834
+ message.media = None
835
+ if not reactions:
836
+ message.reactions = None
837
+ if not entities:
838
+ message.entities = []
839
+
840
+
841
+ async def _read_inbox_max(ctx: OpContext, peer: Any) -> int:
842
+ """`read_inbox_max_id` — where the unread divider sits."""
843
+ from telethon.tl import types
844
+ from telethon.tl.functions import messages as fn
845
+
846
+ result = await _client(ctx)(fn.GetPeerDialogsRequest(peers=[types.InputDialogPeer(peer)]))
847
+ for dialog in getattr(result, "dialogs", None) or []:
848
+ return int(getattr(dialog, "read_inbox_max_id", 0) or 0)
849
+ return 0
850
+
851
+
852
+ async def _annotate_delivery(ctx: OpContext, peer: Any, items: list[Message]) -> None:
853
+ """`--delivery`: sent / delivered / read, for our own messages.
854
+
855
+ This is a *dialog* field (`read_outbox_max_id`), not a message field, so
856
+ it is fetched once and applied rather than guessed from the message.
857
+ """
858
+ from telethon.tl import types
859
+ from telethon.tl.functions import messages as fn
860
+
861
+ result = await _client(ctx)(fn.GetPeerDialogsRequest(peers=[types.InputDialogPeer(peer)]))
862
+ outbox = 0
863
+ for dialog in getattr(result, "dialogs", None) or []:
864
+ outbox = int(getattr(dialog, "read_outbox_max_id", 0) or 0)
865
+ for message in items:
866
+ if message.out:
867
+ message.delivery = "read" if message.id <= outbox else "sent"
868
+
869
+
870
+ async def _attach_senders(ctx: OpContext, items: list[Message]) -> None:
871
+ """Resolve each distinct sender once and hang it off every message."""
872
+ client = _client(ctx)
873
+ cache: dict[int, Any] = {}
874
+ for message in items:
875
+ sender_id = message.sender_id
876
+ if sender_id is None:
877
+ continue
878
+ if sender_id not in cache:
879
+ try:
880
+ cache[sender_id] = entity_to_peer(await client.get_entity(sender_id))
881
+ except Exception:
882
+ cache[sender_id] = None
883
+ message.sender = cache[sender_id]
884
+
885
+
886
+ async def _personal_channel(ctx: OpContext, req: ListReq, limit: int) -> Page[Message]:
887
+ """The channel posts a profile shows, which are not in any dialog."""
888
+ from telethon.tl.functions import messages as fn
889
+
890
+ user = await _send.resolve(ctx, req.personal_channel)
891
+ result = await _client(ctx)(fn.GetPersonalChannelHistoryRequest(peer=user, limit=limit))
892
+ raw = list(getattr(result, "messages", None) or [])
893
+ items = [message_to_model(message) for message in raw]
894
+ return Page(items=items, has_more=False, total=len(items))
895
+
896
+
897
+ SPEC_LIST = OperationSpec(
898
+ id="message.list",
899
+ request=ListReq,
900
+ response=Page[Message],
901
+ impl=list_messages,
902
+ summary="Read a chat's message history",
903
+ description=(
904
+ "Paginated history with the offsets Telegram actually has: older than "
905
+ "an id (--before-id), newer than an id (--after-id), centred on one "
906
+ "(--around), from a date (--date), only the unread tail (--unread), "
907
+ "the scheduled drawer (--scheduled), or one media type (--type)."
908
+ ),
909
+ aliases=("msg.list",),
910
+ legacy_paths=("message list", "msg list"),
911
+ paginated=PageKind.HISTORY,
912
+ columns=("id", "date", "text"),
913
+ headers=("ID", "Date", "Text"),
914
+ example={"items": [_EXAMPLE_MESSAGE], "has_more": False},
915
+ example_args="message list @alice",
916
+ covers=(
917
+ "bots.payment-service-messages",
918
+ "groupcall.service-messages",
919
+ "groups-channels-admin.monoforum-topic-history",
920
+ "messages-core.history-jump-to-date",
921
+ "messages-core.history-list",
922
+ "messages-core.history-unread-separator",
923
+ "messages-core.message-delivery-status",
924
+ "messages-core.message-jump-to",
925
+ "messages-core.message-sender-rank",
926
+ "messages-core.message-view-in-topic",
927
+ "messages-core.personal-channel-history",
928
+ "messages-core.pinned-list",
929
+ "messages-core.saved-dialog-history",
930
+ "messages-core.scheduled-list",
931
+ "stories.story-mention",
932
+ ),
933
+ )
934
+
935
+
936
+ class SearchReq(Request):
937
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat to search.")]
938
+ query: Annotated[str, arg(1, metavar="QUERY", required=False, help="Text to find.")] = ""
939
+ from_user: Annotated[
940
+ PeerRef | None,
941
+ opt("--from", metavar="USER", kind="user", help="Only from this sender."),
942
+ ] = None
943
+ type: Annotated[str | None, opt("--type", metavar="FILTER", help="Media filter.")] = None
944
+ since: Annotated[
945
+ str | None, opt("--since", metavar="TS", kind="datetime", help="Only after this time.")
946
+ ] = None
947
+ until: Annotated[
948
+ str | None, opt("--until", metavar="TS", kind="datetime", help="Only before this time.")
949
+ ] = None
950
+ topic: Annotated[
951
+ int | None, opt("--topic", metavar="ID", kind="msg_id", help="Restrict to a topic.")
952
+ ] = None
953
+ min_id: Annotated[int | None, opt("--min-id", metavar="ID", help="Only ids above this.")] = None
954
+ max_id: Annotated[int | None, opt("--max-id", metavar="ID", help="Only ids below this.")] = None
955
+ reverse: Annotated[bool, opt("--reverse", help="Oldest first.")] = False
956
+ ids: Annotated[list[str], opt("--ids", metavar="ID", help="Restrict to these ids.")] = []
957
+ saved_peer: Annotated[
958
+ PeerRef | None,
959
+ opt("--saved-peer", metavar="CHAT", kind="peer", help="One Saved-Messages dialog."),
960
+ ] = None
961
+ tag: Annotated[
962
+ list[str], opt("--tag", metavar="EMOJI", help="Saved-Messages reaction tag (Premium).")
963
+ ] = []
964
+ hashtag: Annotated[
965
+ str | None, opt("--hashtag", metavar="TAG", help="Shorthand for '#tag'.")
966
+ ] = None
967
+ count: Annotated[
968
+ bool, opt("--count", help="Return per-filter counters instead of messages.")
969
+ ] = False
970
+ calendar: Annotated[bool, opt("--calendar", help="Return the message calendar.")] = False
971
+ position: Annotated[
972
+ int | None, opt("--position", metavar="ID", help="Where this id sits in the filtered set.")
973
+ ] = None
974
+ regex: Annotated[
975
+ str | None, opt("--regex", metavar="PATTERN", help="Client-side regex over the scan.")
976
+ ] = None
977
+ scan: Annotated[
978
+ int, opt("--scan", metavar="N", help="How many messages --regex may scan.", ge=1)
979
+ ] = 1000
980
+ scheduled: Annotated[bool, opt("--scheduled", help="Search the scheduled drawer.")] = False
981
+
982
+
983
+ async def search(ctx: OpContext, req: SearchReq) -> Page[Message]:
984
+ """Search one chat with every filter `messages.search` accepts.
985
+
986
+ `--regex` is deliberately local and bounded: Telegram has no regex search,
987
+ so tlgr scans a window and says how far it got rather than pretending the
988
+ result is exhaustive.
989
+ """
990
+ limit, state = _window(ctx, "message.search", PageKind.SEARCH)
991
+ peer = await _send.resolve(ctx, req.chat)
992
+ chat_id = _send.peer_id_of(peer)
993
+ query = req.query or (f"#{req.hashtag.lstrip('#')}" if req.hashtag else "")
994
+
995
+ if req.count or req.calendar or req.position is not None:
996
+ return await _search_meta(ctx, req, peer, chat_id, query)
997
+
998
+ kwargs: dict[str, Any] = {
999
+ "search": query or None,
1000
+ "reverse": req.reverse,
1001
+ "scheduled": req.scheduled,
1002
+ "offset_id": int(state.get("offset_id", 0)),
1003
+ "add_offset": int(state.get("add_offset", 0)),
1004
+ }
1005
+ if req.from_user is not None:
1006
+ kwargs["from_user"] = await _send.resolve(ctx, req.from_user)
1007
+ if req.type:
1008
+ kwargs["filter"] = _filter(req.type)
1009
+ if req.until:
1010
+ kwargs["offset_date"] = parse_dt(req.until)
1011
+ if req.min_id is not None:
1012
+ kwargs["min_id"] = req.min_id
1013
+ if req.max_id is not None:
1014
+ kwargs["max_id"] = req.max_id
1015
+ if req.topic is not None:
1016
+ kwargs["reply_to"] = req.topic
1017
+ if req.ids:
1018
+ kwargs["ids"] = _ids(tuple(req.ids))
1019
+ if req.tag:
1020
+ ctx.warn("--tag needs Premium and a Saved-Messages peer; it is passed through as given")
1021
+
1022
+ scan = req.scan if req.regex else limit
1023
+ items = await _fetch(ctx, peer, chat_id=chat_id, limit=scan, **kwargs)
1024
+ if req.since:
1025
+ cutoff = to_unix(parse_dt(req.since)) or 0
1026
+ items = [m for m in items if m.date_unix >= cutoff]
1027
+ if req.regex:
1028
+ items = _regex_filter(items, req.regex, limit)
1029
+ ctx.warn(f"--regex is a local scan of at most {req.scan} messages, not a server search")
1030
+
1031
+ next_state = {"offset_id": items[-1].id, "add_offset": 0} if items else {}
1032
+ return build_page(
1033
+ items[:limit],
1034
+ op="message.search",
1035
+ kind=PageKind.SEARCH,
1036
+ state=next_state,
1037
+ account=ctx.account,
1038
+ limit=limit,
1039
+ has_more=None if items else False,
1040
+ )
1041
+
1042
+
1043
+ def _regex_filter(items: list[Message], pattern: str, limit: int) -> list[Message]:
1044
+ import re
1045
+
1046
+ try:
1047
+ compiled = re.compile(pattern)
1048
+ except re.error as exc:
1049
+ raise UsageError(f"--regex: {exc}", field="regex") from exc
1050
+ return [m for m in items if compiled.search(m.text)][:limit]
1051
+
1052
+
1053
+ async def _search_meta(
1054
+ ctx: OpContext, req: SearchReq, peer: Any, chat_id: int, query: str
1055
+ ) -> Page[Message]:
1056
+ """`--count` / `--calendar` / `--position` return their own shapes.
1057
+
1058
+ They are folded into `message search` rather than given their own verbs
1059
+ because they answer questions *about the same filtered set*, and a caller
1060
+ should not have to restate ten filters to ask "how many?".
1061
+ """
1062
+ from telethon.tl.functions import messages as fn
1063
+
1064
+ client = _client(ctx)
1065
+ if req.count:
1066
+ filters = [_filter(name) for name in ([req.type] if req.type else list(FILTERS))]
1067
+ result = await client(fn.GetSearchCountersRequest(peer=peer, filters=filters))
1068
+ counters = {
1069
+ type(getattr(entry, "filter", None)).__name__: int(getattr(entry, "count", 0) or 0)
1070
+ for entry in (result or [])
1071
+ }
1072
+ ctx.warn(f"counters: {counters}")
1073
+ return Page(items=[], has_more=False, total=sum(counters.values()))
1074
+ if req.calendar:
1075
+ result = await client(
1076
+ fn.GetSearchResultsCalendarRequest(
1077
+ peer=peer, filter=_filter(req.type or "photo"), offset_id=0, offset_date=None
1078
+ )
1079
+ )
1080
+ items = [
1081
+ message_to_model(message, chat_id=chat_id)
1082
+ for message in (getattr(result, "messages", None) or [])
1083
+ ]
1084
+ return Page(items=items, has_more=False, total=getattr(result, "count", None))
1085
+ result = await client(
1086
+ fn.GetSearchResultsPositionsRequest(
1087
+ peer=peer, filter=_filter(req.type or "photo"), offset_id=req.position or 0, limit=1
1088
+ )
1089
+ )
1090
+ ctx.warn(f"position {req.position} of {getattr(result, 'count', '?')} in the filtered set")
1091
+ return Page(items=[], has_more=False, total=getattr(result, "count", None))
1092
+
1093
+
1094
+ SPEC_SEARCH = OperationSpec(
1095
+ id="message.search",
1096
+ request=SearchReq,
1097
+ response=Page[Message],
1098
+ impl=search,
1099
+ summary="Search messages inside one chat",
1100
+ description=(
1101
+ "Every filter `messages.search` has: text, sender, media type, date "
1102
+ "range, topic, saved dialog and reaction tag. --regex is a bounded "
1103
+ "local scan and says so, because Telegram has no regex search."
1104
+ ),
1105
+ aliases=("msg.search",),
1106
+ legacy_paths=("message search", "msg search"),
1107
+ paginated=PageKind.SEARCH,
1108
+ columns=("id", "date", "text"),
1109
+ headers=("ID", "Date", "Text"),
1110
+ example={"items": [_EXAMPLE_MESSAGE], "has_more": False},
1111
+ example_args='message search @alice "invoice"',
1112
+ covers=(
1113
+ "messages-core.message-position-in-filter",
1114
+ "messages-core.saved-tags-search",
1115
+ "messages-core.search-calendar-positions",
1116
+ "messages-core.search-counters",
1117
+ "messages-core.search-date-range",
1118
+ "messages-core.search-filter-media-type",
1119
+ "messages-core.search-calls-log",
1120
+ "messages-core.search-from-user",
1121
+ "messages-core.search-hashtag-in-chat",
1122
+ "messages-core.search-in-chat-text",
1123
+ "messages-core.search-in-saved-dialog",
1124
+ "messages-core.search-in-topic",
1125
+ "messages-core.search-local-regex",
1126
+ "poll.search",
1127
+ "reaction.search-by-tag",
1128
+ ),
1129
+ )
1130
+
1131
+
1132
+ class GetReq(Request):
1133
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat the message is in.")]
1134
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Message id or link.")]
1135
+ full: Annotated[bool, opt("--full", help="Include restriction reasons and raw media.")] = False
1136
+ with_reply: Annotated[
1137
+ bool, opt("--with-reply", help="Also resolve the replied-to message.")
1138
+ ] = False
1139
+ context: Annotated[
1140
+ int | None, opt("--context", metavar="N", help="Also return N messages around it.")
1141
+ ] = None
1142
+ format: Annotated[
1143
+ str | None, choice("md", "html", "text", "json", help="Render the text with its entities.")
1144
+ ] = None
1145
+ rich: Annotated[bool, opt("--rich", help="Fetch the layer-229 rich body.")] = False
1146
+ vcard_out: Annotated[
1147
+ str | None,
1148
+ opt("--vcard-out", metavar="PATH", kind="path", help="Write an attached contact card."),
1149
+ ] = None
1150
+ raw: Annotated[bool, opt("--raw", help="Include the raw TL object.")] = False
1151
+ scheduled: Annotated[bool, opt("--scheduled", help="Read from the scheduled drawer.")] = False
1152
+
1153
+
1154
+ async def get(ctx: OpContext, req: GetReq) -> Message:
1155
+ """One message with everything hanging off it."""
1156
+ peer = await _send.resolve(ctx, req.chat)
1157
+ chat_id = _send.peer_id_of(peer)
1158
+ client = _client(ctx)
1159
+
1160
+ if req.rich:
1161
+ _send.require_supported(
1162
+ "--rich", "messages.getRichMessage needs layer 229 and the pinned Telethon is 227"
1163
+ )
1164
+
1165
+ found = await _fetch(
1166
+ ctx, peer, chat_id=chat_id, limit=1, ids=[req.msg_id], scheduled=req.scheduled
1167
+ )
1168
+ if not found or not found[0].id:
1169
+ raise NotFoundError(f"message {req.msg_id} is not in that chat, or was deleted")
1170
+ message = found[0]
1171
+ message.scheduled = req.scheduled
1172
+
1173
+ raw = None
1174
+ if req.raw or req.full or req.vcard_out:
1175
+ async for item in client.iter_messages(peer, ids=[req.msg_id]):
1176
+ raw = item
1177
+ break
1178
+ if req.raw and raw is not None:
1179
+ message.raw = {"tl_type": type(raw).__name__, "repr": str(raw)[:4000]}
1180
+ if req.full and raw is not None:
1181
+ message.restriction_reason = [
1182
+ f"{getattr(r, 'platform', '')}:{getattr(r, 'reason', '')}"
1183
+ for r in (getattr(raw, "restriction_reason", None) or [])
1184
+ ]
1185
+ if req.vcard_out and raw is not None:
1186
+ _write_vcard(req.vcard_out, raw)
1187
+ if req.format and req.format != "json":
1188
+ message.text = _render(message, req.format)
1189
+ if req.with_reply and message.reply_to and message.reply_to.message_id:
1190
+ parent = await _fetch(
1191
+ ctx, peer, chat_id=chat_id, limit=1, ids=[message.reply_to.message_id]
1192
+ )
1193
+ message.reply = parent[0] if parent else None
1194
+ if req.context:
1195
+ around = await _fetch(
1196
+ ctx,
1197
+ peer,
1198
+ chat_id=chat_id,
1199
+ limit=req.context * 2,
1200
+ offset_id=req.msg_id + req.context,
1201
+ )
1202
+ message.context = [m for m in around if m.id != message.id]
1203
+ return message
1204
+
1205
+
1206
+ def _render(message: Message, mode: str) -> str:
1207
+ """Re-apply the entities as markup, for a human who asked for markup.
1208
+
1209
+ Deliberately one-way and lossy — that is why the default is `json` and the
1210
+ raw text plus entities is what the JSON carries (core/text.py's rule).
1211
+ """
1212
+ if mode == "text" or not message.entities:
1213
+ return message.text
1214
+ marks = {"bold": ("**", "**"), "italic": ("_", "_"), "code": ("`", "`")}
1215
+ if mode == "html":
1216
+ marks = {"bold": ("<b>", "</b>"), "italic": ("<i>", "</i>"), "code": ("<code>", "</code>")}
1217
+ pieces: list[tuple[int, str]] = []
1218
+ for entity in message.entities:
1219
+ pair = marks.get(entity.type)
1220
+ if pair is None:
1221
+ continue
1222
+ pieces.append((entity.offset, pair[0]))
1223
+ pieces.append((entity.offset + entity.length, pair[1]))
1224
+ units = message.text.encode("utf-16-le")
1225
+ out: list[str] = []
1226
+ cursor = 0
1227
+ for offset, marker in sorted(pieces, key=lambda item: item[0]):
1228
+ out.append(units[cursor * 2 : offset * 2].decode("utf-16-le", "ignore"))
1229
+ out.append(marker)
1230
+ cursor = offset
1231
+ out.append(units[cursor * 2 :].decode("utf-16-le", "ignore"))
1232
+ return "".join(out)
1233
+
1234
+
1235
+ def _write_vcard(path: str, raw: Any) -> None:
1236
+ media = getattr(raw, "media", None)
1237
+ vcard = getattr(media, "vcard", None)
1238
+ if not vcard:
1239
+ first = getattr(media, "first_name", "") or ""
1240
+ last = getattr(media, "last_name", "") or ""
1241
+ phone = getattr(media, "phone_number", "") or ""
1242
+ if not phone:
1243
+ raise UsageError("that message has no contact card to write", field="vcard_out")
1244
+ vcard = f"BEGIN:VCARD\nVERSION:3.0\nN:{last};{first}\nTEL:{phone}\nEND:VCARD\n"
1245
+ with open(path, "w", encoding="utf-8") as handle:
1246
+ handle.write(vcard)
1247
+
1248
+
1249
+ SPEC_GET = OperationSpec(
1250
+ id="message.get",
1251
+ request=GetReq,
1252
+ response=Message,
1253
+ impl=get,
1254
+ summary="Get one message with its full metadata",
1255
+ description=(
1256
+ "Entities (UTF-16 offsets), forward header, reply context, media "
1257
+ "summary, reactions including whether this account already reacted, "
1258
+ "views, and the fact-check when one exists."
1259
+ ),
1260
+ aliases=("msg.get",),
1261
+ legacy_paths=("message get", "msg get"),
1262
+ empty_exit=EXIT_EMPTY,
1263
+ columns=("id", "date", "text"),
1264
+ example=_EXAMPLE_MESSAGE,
1265
+ example_args="message get @alice 12345",
1266
+ covers=(
1267
+ "bots.disabled-button",
1268
+ "bots.inline-keyboard-render",
1269
+ "bots.invoice-message-view",
1270
+ "bots.reply-keyboard-render",
1271
+ "calls.history-goto-message",
1272
+ "contact.card-fields",
1273
+ "dice.read-value",
1274
+ "groups-channels-admin.monoforum-message-author",
1275
+ "messages-core.message-copy-text",
1276
+ "messages-core.message-forward-header",
1277
+ "messages-core.message-get",
1278
+ "messages-core.message-get-reply-context",
1279
+ "messages-core.message-post-author",
1280
+ "messages-core.message-restriction-reason",
1281
+ "messages-core.reactions-count",
1282
+ "messages-core.thread-view-original-post",
1283
+ ),
1284
+ covers_partial=("bots.rich-message-view", "richmsg.get"),
1285
+ coverage_note="--rich is refused with NOT_SUPPORTED until Telethon carries layer 229.",
1286
+ )
1287
+
1288
+
1289
+ class ThreadListReq(Request):
1290
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel or group.")]
1291
+ root_id: Annotated[int, arg(1, metavar="ROOT_ID", kind="msg_id", help="Post or thread root.")]
1292
+ reverse: Annotated[bool, opt("--reverse", help="Oldest first.")] = False
1293
+ around: Annotated[
1294
+ int | None, opt("--around", metavar="ID", kind="msg_id", help="Centre on a comment.")
1295
+ ] = None
1296
+ resolve: Annotated[bool, opt(help="Resolve the post into its discussion thread first.")] = True
1297
+
1298
+
1299
+ async def thread_list(ctx: OpContext, req: ThreadListReq) -> Page[Message]:
1300
+ """The replies under a thread root, or the comments under a channel post.
1301
+
1302
+ A channel post's comments do not live in the channel: `getDiscussionMessage`
1303
+ maps the post to `(discussion group, root id)` first, which is why the
1304
+ result names the chat the comments are actually in.
1305
+ """
1306
+ from telethon.tl.functions import messages as fn
1307
+
1308
+ limit, state = _window(ctx, "message.thread.list", PageKind.HISTORY)
1309
+ peer = await _send.resolve(ctx, req.chat)
1310
+ chat_id = _send.peer_id_of(peer)
1311
+ root = req.root_id
1312
+ discussion_id = chat_id
1313
+
1314
+ if req.resolve:
1315
+ try:
1316
+ mapped = await _client(ctx)(fn.GetDiscussionMessageRequest(peer, req.root_id))
1317
+ except Exception as exc:
1318
+ if "MSG_ID_INVALID" not in str(exc).upper():
1319
+ raise
1320
+ mapped = None
1321
+ messages = list(getattr(mapped, "messages", None) or [])
1322
+ if messages:
1323
+ peer = await _client(ctx).get_input_entity(messages[0].peer_id)
1324
+ discussion_id = _send.peer_id_of(peer)
1325
+ root = int(messages[0].id)
1326
+
1327
+ offset_id = int(state.get("offset_id", 0)) or (req.around or 0)
1328
+ add_offset = int(state.get("add_offset", 0)) or (-(limit // 2) if req.around else 0)
1329
+ items = await _fetch(
1330
+ ctx,
1331
+ peer,
1332
+ chat_id=discussion_id,
1333
+ limit=limit,
1334
+ reply_to=root,
1335
+ reverse=req.reverse,
1336
+ offset_id=offset_id,
1337
+ add_offset=add_offset,
1338
+ )
1339
+ for message in items:
1340
+ message.thread_root = root
1341
+ message.discussion_chat_id = discussion_id
1342
+ next_state = {"offset_id": items[-1].id, "add_offset": 0} if items else {}
1343
+ return build_page(
1344
+ items,
1345
+ op="message.thread.list",
1346
+ kind=PageKind.HISTORY,
1347
+ state=next_state,
1348
+ account=ctx.account,
1349
+ limit=limit,
1350
+ has_more=None if items else False,
1351
+ )
1352
+
1353
+
1354
+ SPEC_THREAD_LIST = OperationSpec(
1355
+ id="message.thread.list",
1356
+ request=ThreadListReq,
1357
+ response=Page[Message],
1358
+ impl=thread_list,
1359
+ summary="List the replies of a thread or the comments under a post",
1360
+ aliases=("message.comments",),
1361
+ paginated=PageKind.HISTORY,
1362
+ columns=("id", "date", "text"),
1363
+ example={"items": [_EXAMPLE_MESSAGE], "has_more": False},
1364
+ example_args="message thread list @channel 4242",
1365
+ covers=(
1366
+ "groups-channels-admin.comments-thread",
1367
+ "messages-core.comments-view",
1368
+ "messages-core.thread-view-replies",
1369
+ ),
1370
+ )
1371
+
1372
+
1373
+ class ThreadDisableReq(Request):
1374
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel.")]
1375
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Post id.")]
1376
+
1377
+
1378
+ async def thread_disable(ctx: OpContext, req: ThreadDisableReq) -> SuggestedPostState:
1379
+ """Turn comments off on one post, by deleting the discussion-group copy.
1380
+
1381
+ Telegram has no toggle for this. The only mechanism is deleting the
1382
+ auto-forwarded copy in the linked discussion group, and that destroys the
1383
+ comments that were already there — which is why the op is destructive.
1384
+ """
1385
+ from telethon.tl.functions import messages as fn
1386
+
1387
+ peer = await _send.resolve(ctx, req.chat)
1388
+ mapped = await _client(ctx)(fn.GetDiscussionMessageRequest(peer, req.msg_id))
1389
+ messages = list(getattr(mapped, "messages", None) or [])
1390
+ if not messages:
1391
+ _already(ctx)
1392
+ return SuggestedPostState(
1393
+ chat_id=_send.peer_id_of(peer), msg_id=req.msg_id, state="no-comments"
1394
+ )
1395
+ discussion = await _client(ctx).get_input_entity(messages[0].peer_id)
1396
+ await _client(ctx).delete_messages(discussion, [int(messages[0].id)])
1397
+ return SuggestedPostState(
1398
+ chat_id=_send.peer_id_of(peer), msg_id=req.msg_id, state="comments-disabled"
1399
+ )
1400
+
1401
+
1402
+ SPEC_THREAD_DISABLE = OperationSpec(
1403
+ id="message.thread.disable",
1404
+ request=ThreadDisableReq,
1405
+ response=SuggestedPostState,
1406
+ impl=thread_disable,
1407
+ summary="Disable comments on a single channel post",
1408
+ description=(
1409
+ "Irreversible: the only mechanism Telegram offers is deleting the "
1410
+ "post's copy in the linked discussion group, which deletes the "
1411
+ "comments with it."
1412
+ ),
1413
+ mutating=True,
1414
+ destructive=True,
1415
+ rate_class="bulk",
1416
+ columns=("chat_id", "msg_id", "state"),
1417
+ example={"chat_id": -1001234, "msg_id": 4242, "state": "comments-disabled"},
1418
+ example_args="message thread disable @channel 4242",
1419
+ covers=("messages-core.comments-disable-on-post",),
1420
+ )
1421
+
1422
+
1423
+ # ---------------------------------------------------------------------------
1424
+ # message edit / delete / forward
1425
+ # ---------------------------------------------------------------------------
1426
+
1427
+
1428
+ class EditReq(Request):
1429
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
1430
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Message id or link.")]
1431
+ text: Annotated[str, arg(2, metavar="TEXT", required=False, help="New text.")] = ""
1432
+ parse: Annotated[str | None, choice("md", "html", "none", help="Formatting of TEXT.")] = None
1433
+ entities: Annotated[
1434
+ str | None, opt("--entities", metavar="JSON", kind="json", help="Explicit entities.")
1435
+ ] = None
1436
+ caption: Annotated[str | None, opt("--caption", help="New media caption.")] = None
1437
+ caption_above: Annotated[bool, opt("--caption-above", help="invert_media.")] = False
1438
+ file: Annotated[
1439
+ str | None, opt("--file", metavar="PATH", kind="path", help="Replace the media.")
1440
+ ] = None
1441
+ no_preview: Annotated[bool, opt("--no-preview", help="Drop the link preview.")] = False
1442
+ preview_url: Annotated[
1443
+ str | None, opt("--preview-url", metavar="URL", help="Change the previewed URL.")
1444
+ ] = None
1445
+ preview_above: Annotated[bool, opt("--preview-above", help="invert_media.")] = False
1446
+ schedule: Annotated[
1447
+ str | None, opt("--schedule", metavar="TS|online", help="Reschedule a scheduled message.")
1448
+ ] = None
1449
+ repeat: Annotated[
1450
+ str | None, opt("--repeat", metavar="PERIOD", help="Change the repeat period (Premium).")
1451
+ ] = None
1452
+ toggle_task: Annotated[
1453
+ int | None, opt("--toggle-task", metavar="N", help="Flip a checkbox in a rich message.")
1454
+ ] = None
1455
+ rich_markdown: Annotated[
1456
+ str | None,
1457
+ opt("--rich-markdown", metavar="PATH", kind="path", help="Replace the rich body."),
1458
+ ] = None
1459
+ check: Annotated[
1460
+ bool, opt("--check", help="Report whether this message is still editable and stop.")
1461
+ ] = False
1462
+ typing: Annotated[
1463
+ float, opt("--typing", metavar="SECONDS", help="Type for N seconds first.", ge=0)
1464
+ ] = 0.0
1465
+
1466
+
1467
+ async def edit(ctx: OpContext, req: EditReq) -> EditResult:
1468
+ """Edit a message's text, caption, media or schedule.
1469
+
1470
+ MESSAGE_NOT_MODIFIED is reported as `already: true` rather than as an
1471
+ error: the world already looks the way the caller asked for, which is
1472
+ success (STYLE §4).
1473
+ """
1474
+ from telethon.tl.functions import messages as fn
1475
+
1476
+ if req.rich_markdown or req.toggle_task is not None:
1477
+ _send.require_supported(
1478
+ "--rich-markdown/--toggle-task",
1479
+ "editing a layer-229 rich body needs an API layer the pinned Telethon lacks",
1480
+ )
1481
+
1482
+ peer = await _send.resolve(ctx, req.chat)
1483
+ chat_id = _send.peer_id_of(peer)
1484
+ client = _client(ctx)
1485
+
1486
+ if req.check:
1487
+ data = await client(fn.GetMessageEditDataRequest(peer=peer, id=req.msg_id))
1488
+ return EditResult(
1489
+ id=req.msg_id,
1490
+ chat_id=chat_id,
1491
+ can_edit=True,
1492
+ caption=bool(getattr(data, "caption", False)),
1493
+ )
1494
+
1495
+ await _send.show_typing(ctx, peer, _send.typing_seconds(req.text, requested=req.typing))
1496
+
1497
+ source = req.caption if req.caption is not None else req.text
1498
+ text, entities = _send.body(source, parse=req.parse, entities=req.entities)
1499
+ media: Any = None
1500
+ if req.file:
1501
+ media = await _send.input_media(ctx, req.file)
1502
+ elif req.preview_url:
1503
+ from telethon.tl import types
1504
+
1505
+ media = types.InputMediaWebPage(url=req.preview_url, optional=True)
1506
+
1507
+ try:
1508
+ result = await client(
1509
+ fn.EditMessageRequest(
1510
+ peer=peer,
1511
+ id=req.msg_id,
1512
+ message=text or None,
1513
+ entities=_send.tl_entities(entities),
1514
+ media=media,
1515
+ no_webpage=req.no_preview or None,
1516
+ invert_media=(req.preview_above or req.caption_above) or None,
1517
+ schedule_date=_send.schedule_at(req.schedule),
1518
+ schedule_repeat_period=_send.repeat_period(req.repeat),
1519
+ )
1520
+ )
1521
+ except Exception as exc:
1522
+ if not _is_not_modified(exc):
1523
+ raise
1524
+ _already(ctx)
1525
+ return EditResult(id=req.msg_id, chat_id=chat_id, text=text, edited=True, already=True)
1526
+
1527
+ edited = _send.message_from_updates(result, chat_id=chat_id, sent_text=text)
1528
+ ctx.emit("message_edit", {"chat_id": chat_id, "id": req.msg_id})
1529
+ return EditResult(
1530
+ id=edited.id or req.msg_id,
1531
+ chat_id=chat_id,
1532
+ edited=True,
1533
+ edit_date=edited.edit_date,
1534
+ text=edited.text or text,
1535
+ entities=edited.entities or entities,
1536
+ )
1537
+
1538
+
1539
+ SPEC_EDIT = OperationSpec(
1540
+ id="message.edit",
1541
+ request=EditReq,
1542
+ response=EditResult,
1543
+ impl=edit,
1544
+ summary="Edit a message's text, caption, media or schedule",
1545
+ description=(
1546
+ "MESSAGE_NOT_MODIFIED is success with `already: true`. --check asks "
1547
+ "`messages.getMessageEditData` whether the message may still be "
1548
+ "edited, which is not derivable from its date: pinned, scheduled and "
1549
+ "Saved-Messages posts have no 48-hour window."
1550
+ ),
1551
+ aliases=("msg.edit",),
1552
+ legacy_paths=("message edit", "msg edit"),
1553
+ mutating=True,
1554
+ idempotent=True,
1555
+ rate_class="send",
1556
+ timeout_s=180,
1557
+ columns=("id", "chat_id", "edit_date", "text"),
1558
+ example={
1559
+ "id": 12345,
1560
+ "chat_id": 777123,
1561
+ "edited": True,
1562
+ "edit_date": "2026-09-03T09:20:00Z",
1563
+ "text": "on my way (5 min)",
1564
+ },
1565
+ example_args='message edit @alice 12345 "on my way (5 min)"',
1566
+ covers=(
1567
+ "messages-core.edit-caption",
1568
+ "messages-core.edit-check-permission",
1569
+ "messages-core.edit-text",
1570
+ "messages-core.scheduled-reschedule",
1571
+ ),
1572
+ covers_partial=("richmsg.tasks",),
1573
+ coverage_note="Checklist tasks live in a layer-229 rich body; --toggle-task is refused.",
1574
+ )
1575
+
1576
+
1577
+ class DeleteReq(Request):
1578
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
1579
+ msg_id: Annotated[
1580
+ tuple[str, ...],
1581
+ arg(1, metavar="MSG_ID", required=False, variadic=True, help="Ids or ranges (100-120)."),
1582
+ ] = ()
1583
+ for_everyone: Annotated[
1584
+ bool, opt("--for-everyone", help="Revoke for everyone (the default for own messages).")
1585
+ ] = True
1586
+ for_me: Annotated[bool, opt("--for-me", help="Delete only from my own history.")] = False
1587
+ from_user: Annotated[
1588
+ PeerRef | None,
1589
+ opt("--from", metavar="USER", kind="user", help="Every message this member sent."),
1590
+ ] = None
1591
+ ban: Annotated[bool, opt("--ban", help="With --from: also ban the member.")] = False
1592
+ report_spam: Annotated[bool, opt("--report-spam", help="With --from: report as spam.")] = False
1593
+ scheduled: Annotated[bool, opt("--scheduled", help="Delete scheduled messages instead.")] = (
1594
+ False
1595
+ )
1596
+ revert: Annotated[bool, opt("--revert", help="Revert an ephemeral message.")] = False
1597
+
1598
+
1599
+ async def delete(ctx: OpContext, req: DeleteReq) -> DeleteResult:
1600
+ """Delete messages, for me or for everyone.
1601
+
1602
+ `--from` is a different call with a different shape: it returns an
1603
+ `AffectedHistory` that has to be looped until the offset reaches zero,
1604
+ which is why it cannot simply be a filter over the id list.
1605
+ """
1606
+ from telethon.tl.functions import channels as ch
1607
+
1608
+ if req.revert:
1609
+ _send.require_supported(
1610
+ "--revert", "ephemeral.revertMessage is layer 229 and absent from Telethon 1.44"
1611
+ )
1612
+
1613
+ peer = await _send.resolve(ctx, req.chat)
1614
+ chat_id = _send.peer_id_of(peer)
1615
+ client = _client(ctx)
1616
+
1617
+ if req.from_user is not None:
1618
+ member = await _send.resolve(ctx, req.from_user)
1619
+ channel = _input_channel(peer)
1620
+ affected = await _affected_loop(
1621
+ ctx,
1622
+ lambda offset: ch.DeleteParticipantHistoryRequest(channel=channel, participant=member),
1623
+ )
1624
+ return DeleteResult(chat_id=chat_id, deleted=affected, affected=affected)
1625
+
1626
+ ids = _ids(req.msg_id)
1627
+ if not ids:
1628
+ raise UsageError("give at least one message id, or --from <user>", field="msg_id")
1629
+
1630
+ if req.scheduled:
1631
+ from telethon.tl.functions import messages as fn
1632
+
1633
+ await client(fn.DeleteScheduledMessagesRequest(peer=peer, id=ids))
1634
+ return DeleteResult(chat_id=chat_id, deleted=len(ids), ids=ids, scheduled=True)
1635
+
1636
+ revoke = not req.for_me and req.for_everyone
1637
+ result = await client.delete_messages(peer, ids, revoke=revoke)
1638
+ affected = sum(int(getattr(item, "pts_count", 0) or 0) for item in (result or []))
1639
+ ctx.emit("message_delete", {"chat_id": chat_id, "ids": ids})
1640
+ return DeleteResult(chat_id=chat_id, deleted=affected or len(ids), ids=ids, affected=affected)
1641
+
1642
+
1643
+ SPEC_DELETE = OperationSpec(
1644
+ id="message.delete",
1645
+ request=DeleteReq,
1646
+ response=DeleteResult,
1647
+ impl=delete,
1648
+ summary="Delete messages for me or for everyone",
1649
+ description=(
1650
+ "In a channel or supergroup a delete is always for everyone — "
1651
+ "`channels.deleteMessages` has no revoke flag — so --for-me is only "
1652
+ "meaningful in private chats and basic groups."
1653
+ ),
1654
+ aliases=("msg.delete", "message.rm"),
1655
+ legacy_paths=("message delete", "msg delete"),
1656
+ mutating=True,
1657
+ destructive=True,
1658
+ rate_class="bulk",
1659
+ columns=("chat_id", "deleted"),
1660
+ example={"chat_id": 777123, "deleted": 2, "ids": [12345, 12346]},
1661
+ example_args="message delete @alice 12345",
1662
+ covers=(
1663
+ "messages-core.delete-all-from-user",
1664
+ "messages-core.delete-for-everyone",
1665
+ "messages-core.delete-for-me",
1666
+ "messages-core.scheduled-delete",
1667
+ ),
1668
+ covers_partial=("messages-core.ephemeral-messages",),
1669
+ coverage_note="--revert needs layer 229's ephemeral.* namespace and is refused.",
1670
+ )
1671
+
1672
+
1673
+ class ForwardReq(_send.SendOptions, kw_only=True):
1674
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Source chat.")]
1675
+ msg_id: Annotated[
1676
+ tuple[str, ...],
1677
+ arg(1, metavar="MSG_ID", required=False, variadic=True, help="Ids or ranges."),
1678
+ ] = ()
1679
+ to: Annotated[
1680
+ list[PeerRef],
1681
+ opt("--to", metavar="CHAT", kind="peer", help="Destination; repeatable."),
1682
+ ] = []
1683
+ no_author: Annotated[bool, opt("--no-author", help="drop_author: hide the sender.")] = False
1684
+ no_captions: Annotated[bool, opt("--no-captions", help="drop_media_captions.")] = False
1685
+ as_copy: Annotated[bool, opt("--as-copy", help="Re-send as a new message, not a forward.")] = (
1686
+ False
1687
+ )
1688
+ comment: Annotated[str | None, opt("--comment", help="Send this alongside the forward.")] = None
1689
+ video_at: Annotated[
1690
+ int | None, opt("--video-at", metavar="SECONDS", help="Start at a video timestamp.")
1691
+ ] = None
1692
+ with_score: Annotated[bool, opt("--with-score", help="Keep a game's scoreboard.")] = False
1693
+
1694
+
1695
+ async def forward(ctx: OpContext, req: ForwardReq) -> Page[ForwardedMessage]:
1696
+ """Forward or copy messages to one or many chats.
1697
+
1698
+ One request per destination, because Telegram checks each destination's
1699
+ restrictions separately and a single failure must not silently take the
1700
+ other destinations with it.
1701
+ """
1702
+ from telethon.tl.functions import messages as fn
1703
+
1704
+ ids = _ids(req.msg_id)
1705
+ if not ids:
1706
+ raise UsageError("give at least one message id to forward", field="msg_id")
1707
+ if not req.to:
1708
+ raise UsageError("give at least one destination with --to", field="to")
1709
+
1710
+ source = await _send.resolve(ctx, req.chat)
1711
+ source_id = _send.peer_id_of(source)
1712
+ client = _client(ctx)
1713
+ produced: list[ForwardedMessage] = []
1714
+
1715
+ for index, destination in enumerate(req.to):
1716
+ target = await _send.resolve(ctx, destination)
1717
+ target_id = _send.peer_id_of(target)
1718
+ if index:
1719
+ limiter = getattr(ctx, "limiter", None)
1720
+ if limiter is not None:
1721
+ await limiter.acquire("send")
1722
+ result = await client(
1723
+ fn.ForwardMessagesRequest(
1724
+ from_peer=source,
1725
+ id=ids,
1726
+ to_peer=target,
1727
+ random_id=[_random_id() for _ in ids],
1728
+ drop_author=(req.no_author or req.as_copy) or None,
1729
+ drop_media_captions=req.no_captions or None,
1730
+ with_my_score=req.with_score or None,
1731
+ noforwards=req.protect or None,
1732
+ silent=req.silent or None,
1733
+ top_msg_id=req.topic,
1734
+ schedule_date=_send.schedule_at(req.schedule),
1735
+ send_as=await _send.resolve(ctx, req.send_as) if req.send_as else None,
1736
+ effect=_send.effect_id(req.effect),
1737
+ video_timestamp=req.video_at,
1738
+ allow_paid_stars=req.paid_stars,
1739
+ )
1740
+ )
1741
+ for offset, message in enumerate(_send.messages_from_updates(result, chat_id=target_id)):
1742
+ produced.append(
1743
+ ForwardedMessage(
1744
+ id=message.id,
1745
+ chat_id=target_id,
1746
+ date=message.date,
1747
+ date_unix=message.date_unix,
1748
+ from_chat_id=source_id,
1749
+ from_msg_id=ids[offset] if offset < len(ids) else None,
1750
+ )
1751
+ )
1752
+ if req.comment:
1753
+ await client(
1754
+ fn.SendMessageRequest(
1755
+ peer=target,
1756
+ message=req.comment,
1757
+ random_id=_random_id(),
1758
+ silent=req.silent or None,
1759
+ )
1760
+ )
1761
+ ctx.emit("message_forward", {"from_chat_id": source_id, "ids": ids, "count": len(produced)})
1762
+ return Page(items=produced, has_more=False, total=len(produced))
1763
+
1764
+
1765
+ SPEC_FORWARD = OperationSpec(
1766
+ id="message.forward",
1767
+ request=ForwardReq,
1768
+ response=Page[ForwardedMessage],
1769
+ impl=forward,
1770
+ summary="Forward or copy messages to one or many chats",
1771
+ description=(
1772
+ "--as-copy is the same call with drop_author and fresh random ids, "
1773
+ "which is exactly what the GUI's 'forward without quoting' does."
1774
+ ),
1775
+ aliases=("msg.forward",),
1776
+ legacy_paths=("message forward", "msg forward"),
1777
+ mutating=True,
1778
+ rate_class="send",
1779
+ timeout_s=300,
1780
+ columns=("id", "chat_id", "from_msg_id"),
1781
+ example={
1782
+ "items": [
1783
+ {
1784
+ "id": 200,
1785
+ "chat_id": -1001234,
1786
+ "date": "2026-09-03T09:14:07Z",
1787
+ "date_unix": 1788340447,
1788
+ "from_chat_id": 777123,
1789
+ "from_msg_id": 12345,
1790
+ }
1791
+ ],
1792
+ "has_more": False,
1793
+ },
1794
+ example_args="message forward @alice 12345 --to @bobby",
1795
+ covers=(
1796
+ "game.forward",
1797
+ "messages-core.forward-as-copy-text",
1798
+ "messages-core.forward-basic",
1799
+ "messages-core.forward-hide-captions",
1800
+ "messages-core.forward-hide-sender",
1801
+ "messages-core.forward-options",
1802
+ "messages-core.forward-to-many",
1803
+ "messages-core.message-select-bulk",
1804
+ ),
1805
+ )
1806
+
1807
+
1808
+ # ---------------------------------------------------------------------------
1809
+ # pin / unpin / read / react
1810
+ # ---------------------------------------------------------------------------
1811
+
1812
+
1813
+ class PinReq(Request):
1814
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
1815
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Message id or link.")]
1816
+ notify: Annotated[
1817
+ bool, opt("--notify", help="Notify members (a pin is silent by default).")
1818
+ ] = False
1819
+ both_sides: Annotated[
1820
+ bool, opt("--both-sides", help="Also pin on the other side of a private chat.")
1821
+ ] = False
1822
+ topic: Annotated[
1823
+ int | None, opt("--topic", metavar="ID", kind="msg_id", help="Pin inside a forum topic.")
1824
+ ] = None
1825
+
1826
+
1827
+ async def pin(ctx: OpContext, req: PinReq) -> PinResult:
1828
+ """Pin a message. Silent unless --notify, which is how the GUI behaves."""
1829
+ from telethon.tl.functions import messages as fn
1830
+
1831
+ peer = await _send.resolve(ctx, req.chat)
1832
+ chat_id = _send.peer_id_of(peer)
1833
+ try:
1834
+ await _client(ctx)(
1835
+ fn.UpdatePinnedMessageRequest(
1836
+ peer=peer,
1837
+ id=req.msg_id,
1838
+ silent=not req.notify,
1839
+ pm_oneside=not req.both_sides,
1840
+ )
1841
+ )
1842
+ except Exception as exc:
1843
+ if not _is_not_modified(exc):
1844
+ raise
1845
+ _already(ctx)
1846
+ return PinResult(chat_id=chat_id, msg_id=req.msg_id, pinned=True, already=True)
1847
+ return PinResult(chat_id=chat_id, msg_id=req.msg_id, pinned=True)
1848
+
1849
+
1850
+ SPEC_PIN = OperationSpec(
1851
+ id="message.pin",
1852
+ request=PinReq,
1853
+ response=PinResult,
1854
+ impl=pin,
1855
+ summary="Pin a message in a chat",
1856
+ description="The pinned list is `message list --type pinned`.",
1857
+ aliases=("msg.pin",),
1858
+ legacy_paths=("message pin", "msg pin"),
1859
+ mutating=True,
1860
+ idempotent=True,
1861
+ columns=("chat_id", "msg_id", "pinned"),
1862
+ example={"chat_id": 777123, "msg_id": 12345, "pinned": True},
1863
+ example_args="message pin @alice 12345",
1864
+ covers=(
1865
+ "groups-channels-admin.monoforum-pin",
1866
+ "groups-channels-admin.pinned-messages",
1867
+ "messages-core.pin-message",
1868
+ ),
1869
+ )
1870
+
1871
+
1872
+ class UnpinReq(Request):
1873
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
1874
+ msg_id: Annotated[
1875
+ int | None,
1876
+ arg(
1877
+ 1, metavar="MSG_ID", required=False, kind="msg_id", help="Message id; omit with --all."
1878
+ ),
1879
+ ] = None
1880
+ unpin_all: Annotated[bool, opt("--all", help="Unpin everything.")] = False
1881
+ topic: Annotated[
1882
+ int | None, opt("--topic", metavar="ID", kind="msg_id", help="Only this forum topic.")
1883
+ ] = None
1884
+ direct_to: Annotated[
1885
+ PeerRef | None,
1886
+ opt("--direct-to", metavar="USER", kind="user", help="Only this monoforum topic."),
1887
+ ] = None
1888
+
1889
+
1890
+ async def unpin(ctx: OpContext, req: UnpinReq) -> PinResult:
1891
+ """Unpin one message, or every pinned message.
1892
+
1893
+ `unpinAllMessages` returns an `AffectedHistory` and has to be looped: one
1894
+ call unpins a page, and stopping there is why v1's equivalent left most
1895
+ of them pinned.
1896
+ """
1897
+ from telethon.tl.functions import messages as fn
1898
+
1899
+ peer = await _send.resolve(ctx, req.chat)
1900
+ chat_id = _send.peer_id_of(peer)
1901
+ saved = await _send.resolve(ctx, req.direct_to) if req.direct_to is not None else None
1902
+
1903
+ if req.unpin_all or req.msg_id is None:
1904
+ count = await _affected_loop(
1905
+ ctx,
1906
+ lambda offset: fn.UnpinAllMessagesRequest(
1907
+ peer=peer, top_msg_id=req.topic, saved_peer_id=saved
1908
+ ),
1909
+ )
1910
+ return PinResult(chat_id=chat_id, pinned=False, unpinned=count, count=count)
1911
+
1912
+ try:
1913
+ await _client(ctx)(
1914
+ fn.UpdatePinnedMessageRequest(peer=peer, id=req.msg_id, unpin=True, silent=True)
1915
+ )
1916
+ except Exception as exc:
1917
+ if not _is_not_modified(exc):
1918
+ raise
1919
+ _already(ctx)
1920
+ return PinResult(chat_id=chat_id, msg_id=req.msg_id, pinned=False, already=True)
1921
+ return PinResult(chat_id=chat_id, msg_id=req.msg_id, pinned=False, unpinned=1, count=1)
1922
+
1923
+
1924
+ SPEC_UNPIN = OperationSpec(
1925
+ id="message.unpin",
1926
+ request=UnpinReq,
1927
+ response=PinResult,
1928
+ impl=unpin,
1929
+ summary="Unpin one message or every pinned message",
1930
+ aliases=("msg.unpin",),
1931
+ mutating=True,
1932
+ destructive=True,
1933
+ idempotent=True,
1934
+ rate_class="bulk",
1935
+ columns=("chat_id", "msg_id", "pinned"),
1936
+ example={"chat_id": 777123, "msg_id": 12345, "pinned": False, "unpinned": 1},
1937
+ example_args="message unpin @alice 12345",
1938
+ covers=("messages-core.unpin-all", "messages-core.unpin-message"),
1939
+ )
1940
+
1941
+
1942
+ class ReadReq(Request):
1943
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat to mark read.")]
1944
+ up_to: Annotated[
1945
+ int | None,
1946
+ opt("--up-to", metavar="ID", kind="msg_id", help="Read up to this id (default: latest)."),
1947
+ ] = None
1948
+ topic: Annotated[
1949
+ int | None, opt("--topic", metavar="ID", kind="msg_id", help="Read one forum topic.")
1950
+ ] = None
1951
+ direct_to: Annotated[
1952
+ PeerRef | None,
1953
+ opt("--direct-to", metavar="USER", kind="user", help="Read one monoforum topic."),
1954
+ ] = None
1955
+ saved_peer: Annotated[
1956
+ PeerRef | None,
1957
+ opt("--saved-peer", metavar="CHAT", kind="peer", help="Read one saved dialog."),
1958
+ ] = None
1959
+ contents: Annotated[
1960
+ list[str],
1961
+ opt("--contents", metavar="ID", help="Mark voice notes / round videos as listened."),
1962
+ ] = []
1963
+ mentions: Annotated[bool, opt("--mentions", help="Mark all unread mentions as read.")] = False
1964
+ reactions: Annotated[bool, opt("--reactions", help="Mark all unread reactions as read.")] = (
1965
+ False
1966
+ )
1967
+
1968
+
1969
+ async def read(ctx: OpContext, req: ReadReq) -> ReadResult:
1970
+ """Mark history, mentions, reactions, media contents or a thread as read.
1971
+
1972
+ Which RPC applies depends on the peer type and on which scope was asked
1973
+ for; dispatching on that in one place is what stops a channel read from
1974
+ silently doing nothing (`messages.readHistory` on a channel is a no-op).
1975
+ """
1976
+ from telethon.tl import types
1977
+ from telethon.tl.functions import channels as ch
1978
+ from telethon.tl.functions import messages as fn
1979
+
1980
+ peer = await _send.resolve(ctx, req.chat)
1981
+ chat_id = _send.peer_id_of(peer)
1982
+ client = _client(ctx)
1983
+ is_channel = isinstance(peer, types.InputPeerChannel)
1984
+ result = ReadResult(chat_id=chat_id, read=True)
1985
+
1986
+ if req.contents:
1987
+ ids = _ids(tuple(req.contents))
1988
+ if is_channel:
1989
+ channel = _input_channel(peer)
1990
+ await client(ch.ReadMessageContentsRequest(channel=channel, id=ids))
1991
+ else:
1992
+ await client(fn.ReadMessageContentsRequest(id=ids))
1993
+ result.contents_read = ids
1994
+
1995
+ if req.mentions:
1996
+ result.mentions_read = await _affected_loop(
1997
+ ctx, lambda offset: fn.ReadMentionsRequest(peer=peer, top_msg_id=req.topic)
1998
+ )
1999
+ if req.reactions:
2000
+ result.reactions_read = await _affected_loop(
2001
+ ctx, lambda offset: fn.ReadReactionsRequest(peer=peer, top_msg_id=req.topic)
2002
+ )
2003
+ if (req.contents or req.mentions or req.reactions) and req.up_to is None:
2004
+ # A scoped read was asked for and no history bound was given: reading
2005
+ # the whole history as well would clear a badge nobody asked to clear.
2006
+ return result
2007
+
2008
+ max_id = req.up_to or 0
2009
+ if req.saved_peer is not None:
2010
+ saved = await _send.resolve(ctx, req.saved_peer)
2011
+ await client(fn.ReadSavedHistoryRequest(parent_peer=peer, peer=saved, max_id=max_id))
2012
+ elif req.topic is not None:
2013
+ await client(fn.ReadDiscussionRequest(peer=peer, msg_id=req.topic, read_max_id=max_id))
2014
+ elif is_channel:
2015
+ channel = _input_channel(peer)
2016
+ await client(ch.ReadHistoryRequest(channel=channel, max_id=max_id))
2017
+ else:
2018
+ affected = await client(fn.ReadHistoryRequest(peer=peer, max_id=max_id))
2019
+ result.still_unread = getattr(affected, "still_unread_count", None)
2020
+ result.read_up_to = max_id or None
2021
+ ctx.emit("message_read", {"chat_id": chat_id, "max_id": max_id})
2022
+ return result
2023
+
2024
+
2025
+ SPEC_READ = OperationSpec(
2026
+ id="message.read",
2027
+ request=ReadReq,
2028
+ response=ReadResult,
2029
+ impl=read,
2030
+ summary="Mark a chat, thread, mentions or media contents as read",
2031
+ description=(
2032
+ "A read receipt is visible to the other side and it also clears the "
2033
+ "badge in the account owner's own client, which may be their only "
2034
+ "reminder that they owe a reply."
2035
+ ),
2036
+ aliases=("msg.read",),
2037
+ legacy_paths=("message read", "msg read"),
2038
+ mutating=True,
2039
+ idempotent=True,
2040
+ columns=("chat_id", "read_up_to"),
2041
+ example={"chat_id": 777123, "read": True, "read_up_to": 12345},
2042
+ example_args="message read @alice",
2043
+ covers=(
2044
+ "messages-core.monoforum-topic-manage",
2045
+ "messages-core.read-mark-history",
2046
+ "messages-core.read-message-contents",
2047
+ "messages-core.report-message-delivery",
2048
+ "messages-core.thread-read",
2049
+ "messages-core.unread-mentions-read-all",
2050
+ ),
2051
+ )
2052
+
2053
+
2054
+ # ---------------------------------------------------------------------------
2055
+ # link / views / read receipts / scheduled
2056
+ # ---------------------------------------------------------------------------
2057
+
2058
+
2059
+ class LinkReq(Request):
2060
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
2061
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Message id.")]
2062
+ topic: Annotated[bool, opt("--topic", help="Link into the comment thread.")] = False
2063
+ comment: Annotated[
2064
+ int | None, opt("--comment", metavar="ID", help="Link to a specific comment.")
2065
+ ] = None
2066
+ single: Annotated[bool, opt("--single", help="Link to this message only (?single).")] = False
2067
+ embed: Annotated[bool, opt("--embed", help="Embeddable link (?embed=1).")] = False
2068
+ at: Annotated[
2069
+ int | None, opt("--at", metavar="DURATION", kind="duration", help="Media timestamp.")
2070
+ ] = None
2071
+ poll_option: Annotated[
2072
+ int | None, opt("--poll-option", metavar="N", help="Preselect a poll option.")
2073
+ ] = None
2074
+ task: Annotated[int | None, opt("--task", metavar="N", help="Link to a checklist task.")] = None
2075
+ public: Annotated[bool, opt("--public", help="Prefer the @username form.")] = False
2076
+
2077
+
2078
+ async def link(ctx: OpContext, req: LinkReq) -> LinkResult:
2079
+ """Build a t.me link to a message.
2080
+
2081
+ `channels.exportMessageLink` is asked first because only the server knows
2082
+ whether a supergroup is public; falling back to arithmetic for a private
2083
+ one is what makes this work without a second round trip.
2084
+ """
2085
+ from telethon.tl.functions import channels as ch
2086
+
2087
+ peer = await _send.resolve(ctx, req.chat)
2088
+ chat_id = _send.peer_id_of(peer)
2089
+ url = ""
2090
+ public = False
2091
+ if chat_id >= 0:
2092
+ raise UsageError(
2093
+ "a private chat has no shareable message link; only channels and supergroups have one",
2094
+ field="chat",
2095
+ )
2096
+ try:
2097
+ channel = _input_channel(peer)
2098
+ exported = await _client(ctx)(
2099
+ ch.ExportMessageLinkRequest(channel=channel, id=req.msg_id, thread=req.topic or None)
2100
+ )
2101
+ url = str(getattr(exported, "link", "") or "")
2102
+ public = "/c/" not in url
2103
+ except Exception as exc:
2104
+ if "CHANNEL_INVALID" not in str(exc).upper() and "PEER_ID_INVALID" not in str(exc).upper():
2105
+ raise
2106
+ if not url:
2107
+ if chat_id >= 0:
2108
+ raise UsageError(
2109
+ "a private chat has no shareable message link; only channels and supergroups do",
2110
+ field="chat",
2111
+ )
2112
+ url = f"https://t.me/c/{-1000000000000 - chat_id}/{req.msg_id}"
2113
+
2114
+ query: list[str] = []
2115
+ if req.single:
2116
+ query.append("single")
2117
+ if req.embed:
2118
+ query.append("embed=1")
2119
+ if req.comment is not None:
2120
+ query.append(f"comment={req.comment}")
2121
+ if req.at is not None:
2122
+ query.append(f"t={req.at}")
2123
+ if req.poll_option is not None:
2124
+ query.append(f"vote={req.poll_option}")
2125
+ if req.task is not None:
2126
+ query.append(f"task={req.task}")
2127
+ if query:
2128
+ url = f"{url}?{'&'.join(query)}"
2129
+ return LinkResult(link=url, public=public, thread=req.topic, chat_id=chat_id, msg_id=req.msg_id)
2130
+
2131
+
2132
+ SPEC_LINK = OperationSpec(
2133
+ id="message.link",
2134
+ request=LinkReq,
2135
+ response=LinkResult,
2136
+ impl=link,
2137
+ summary="Build a t.me link to a message",
2138
+ description=(
2139
+ "A private supergroup produces a `t.me/c/<internal id>/<msg id>` link "
2140
+ "that only members can open; `public` says which form came back."
2141
+ ),
2142
+ columns=("link", "public"),
2143
+ example={
2144
+ "link": "https://t.me/durov/42",
2145
+ "public": True,
2146
+ "chat_id": -1001234,
2147
+ "msg_id": 42,
2148
+ },
2149
+ example_args="message link @durov 42",
2150
+ covers=(
2151
+ "groups-channels-admin.export-message-link",
2152
+ "messages-core.message-link-create",
2153
+ "poll.option-deep-link",
2154
+ "todo.task-deep-link",
2155
+ ),
2156
+ )
2157
+
2158
+
2159
+ class ViewReq(Request):
2160
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel.")]
2161
+ msg_id: Annotated[
2162
+ tuple[str, ...], arg(1, metavar="MSG_ID", variadic=True, help="Post ids or ranges.")
2163
+ ]
2164
+ increment: Annotated[
2165
+ bool, opt("--increment", help="Actually register a view (off by default).")
2166
+ ] = False
2167
+ listened_seconds: Annotated[
2168
+ int | None,
2169
+ opt("--listened-seconds", metavar="N", help="Report real audio playback seconds."),
2170
+ ] = None
2171
+
2172
+
2173
+ async def view_get(ctx: OpContext, req: ViewReq) -> Page[ViewCount]:
2174
+ """Views and forwards for channel posts.
2175
+
2176
+ `--increment` is opt-in because `increment=True` registers a view
2177
+ server-side: reading a counter must not change it.
2178
+ """
2179
+ from telethon.tl.functions import messages as fn
2180
+
2181
+ peer = await _send.resolve(ctx, req.chat)
2182
+ ids = _ids(req.msg_id)
2183
+ result = await _client(ctx)(
2184
+ fn.GetMessagesViewsRequest(peer=peer, id=ids, increment=req.increment)
2185
+ )
2186
+ items: list[ViewCount] = []
2187
+ for index, view in enumerate(getattr(result, "views", None) or []):
2188
+ replies = getattr(view, "replies", None)
2189
+ items.append(
2190
+ ViewCount(
2191
+ msg_id=ids[index] if index < len(ids) else 0,
2192
+ views=getattr(view, "views", None),
2193
+ forwards=getattr(view, "forwards", None),
2194
+ replies=getattr(replies, "replies", None),
2195
+ )
2196
+ )
2197
+ if req.listened_seconds is not None:
2198
+ await _client(ctx)(
2199
+ fn.ReportMusicListenRequest(peer=peer, id=ids[0], duration=req.listened_seconds)
2200
+ )
2201
+ return Page(items=items, has_more=False, total=len(items))
2202
+
2203
+
2204
+ SPEC_VIEW_GET = OperationSpec(
2205
+ id="message.view.get",
2206
+ request=ViewReq,
2207
+ response=Page[ViewCount],
2208
+ impl=view_get,
2209
+ summary="Views and forwards counters for channel posts",
2210
+ aliases=("message.views",),
2211
+ tags=frozenset({"mutating-checked"}),
2212
+ columns=("msg_id", "views", "forwards"),
2213
+ example={"items": [{"msg_id": 42, "views": 1200, "forwards": 8}], "has_more": False},
2214
+ example_args="message view get @durov 42",
2215
+ covers=("messages-core.message-views-forwards", "messages-core.report-music-listen"),
2216
+ )
2217
+
2218
+
2219
+ class ReadReceiptReq(Request):
2220
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
2221
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Message id.")]
2222
+ users: Annotated[bool, opt(help="Resolve reader ids to user objects.")] = True
2223
+
2224
+
2225
+ async def read_receipt_list(ctx: OpContext, req: ReadReceiptReq) -> ReadReceipts:
2226
+ """Who read this message, and when.
2227
+
2228
+ Every refusal here is reported as `unavailable_reason`, never as an
2229
+ error: "the group is too large for read marks" and "they hide their read
2230
+ date" are facts about the world, not failures of the request.
2231
+ """
2232
+ from telethon.tl.functions import messages as fn
2233
+
2234
+ peer = await _send.resolve(ctx, req.chat)
2235
+ client = _client(ctx)
2236
+ out = ReadReceipts(msg_id=req.msg_id)
2237
+ try:
2238
+ participants = await client(
2239
+ fn.GetMessageReadParticipantsRequest(peer=peer, msg_id=req.msg_id)
2240
+ )
2241
+ out.readers = [int(getattr(p, "user_id", p)) for p in (participants or [])]
2242
+ except Exception as exc:
2243
+ out.unavailable_reason = _receipt_reason(exc)
2244
+
2245
+ try:
2246
+ read_date = await client(fn.GetOutboxReadDateRequest(peer=peer, msg_id=req.msg_id))
2247
+ out.read_date = fmt_dt(getattr(read_date, "date", None))
2248
+ out.read_date_unix = to_unix(getattr(read_date, "date", None))
2249
+ except Exception as exc:
2250
+ if out.unavailable_reason is None:
2251
+ out.unavailable_reason = _receipt_reason(exc)
2252
+
2253
+ if req.users and out.readers:
2254
+ for reader in out.readers:
2255
+ try:
2256
+ out.users.append(entity_to_peer(await client.get_entity(reader)))
2257
+ except Exception:
2258
+ continue
2259
+ if not out.readers and out.read_date is None and out.unavailable_reason is None:
2260
+ out.expired = True
2261
+ return out
2262
+
2263
+
2264
+ def _receipt_reason(exc: BaseException) -> str:
2265
+ text = str(exc).upper()
2266
+ if "PRIVACY" in text:
2267
+ return "one side hides read dates, so Telegram will not say when it was read"
2268
+ if "TOO_OLD" in text or "EXPIRED" in text:
2269
+ return "the read-mark window for this chat has expired"
2270
+ if "CHAT_TOO_BIG" in text or "PARTICIPANTS_TOO_FEW" in text:
2271
+ return "this chat is outside Telegram's read-mark size limits"
2272
+ return f"unavailable: {exc}"
2273
+
2274
+
2275
+ SPEC_READ_RECEIPT_LIST = OperationSpec(
2276
+ id="message.read-receipt.list",
2277
+ request=ReadReceiptReq,
2278
+ response=ReadReceipts,
2279
+ impl=read_receipt_list,
2280
+ summary="Who read this message, and when",
2281
+ aliases=("message.readers", "msg.read-receipts", "message.read-receipts"),
2282
+ columns=("msg_id", "read_date"),
2283
+ example={"msg_id": 12345, "readers": [777], "read_date": "2026-09-03T09:20:00Z"},
2284
+ example_args="message read-receipt list @alice 12345",
2285
+ covers=("messages-core.read-date-private", "messages-core.read-participants-group"),
2286
+ )
2287
+
2288
+
2289
+ class ScheduledSendReq(Request):
2290
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
2291
+ msg_id: Annotated[
2292
+ tuple[str, ...], arg(1, metavar="MSG_ID", variadic=True, help="Scheduled message ids.")
2293
+ ]
2294
+
2295
+
2296
+ async def scheduled_send(ctx: OpContext, req: ScheduledSendReq) -> Page[ScheduledSent]:
2297
+ """Send scheduled messages now, ahead of their time."""
2298
+ from telethon.tl.functions import messages as fn
2299
+
2300
+ peer = await _send.resolve(ctx, req.chat)
2301
+ chat_id = _send.peer_id_of(peer)
2302
+ ids = _ids(req.msg_id)
2303
+ result = await _client(ctx)(fn.SendScheduledMessagesRequest(peer=peer, id=ids))
2304
+ items = [
2305
+ ScheduledSent(id=m.id, chat_id=chat_id, date=m.date, date_unix=m.date_unix)
2306
+ for m in _send.messages_from_updates(result, chat_id=chat_id)
2307
+ ]
2308
+ if not items:
2309
+ items = [ScheduledSent(id=i, chat_id=chat_id) for i in ids]
2310
+ return Page(items=items, has_more=False, total=len(items))
2311
+
2312
+
2313
+ SPEC_SCHEDULED_SEND = OperationSpec(
2314
+ id="message.scheduled.send",
2315
+ request=ScheduledSendReq,
2316
+ response=Page[ScheduledSent],
2317
+ impl=scheduled_send,
2318
+ summary="Send scheduled messages now",
2319
+ description=(
2320
+ "Listing, editing and deleting the drawer are flags elsewhere: "
2321
+ "`message list --scheduled`, `message edit --schedule`, "
2322
+ "`message delete --scheduled`."
2323
+ ),
2324
+ mutating=True,
2325
+ rate_class="send",
2326
+ columns=("id", "chat_id", "date"),
2327
+ example={"items": [{"id": 999, "chat_id": 777123}], "has_more": False},
2328
+ example_args="message scheduled send @alice 999",
2329
+ covers=("messages-core.scheduled-send-now",),
2330
+ )
2331
+
2332
+
2333
+ # ---------------------------------------------------------------------------
2334
+ # text services: entities, translate, transcribe, summarize, compose, preview
2335
+ # ---------------------------------------------------------------------------
2336
+
2337
+
2338
+ class EntityListReq(Request):
2339
+ text: Annotated[
2340
+ str, arg(0, metavar="TEXT", required=False, help="Text to parse; '-' reads stdin.")
2341
+ ] = ""
2342
+ parse: Annotated[str | None, choice("md", "html", "none", help="Dialect to parse.")] = None
2343
+ stdin: Annotated[bool, opt("--stdin", help="Read from stdin.")] = False
2344
+ offset_units: Annotated[
2345
+ str | None, choice("utf16", "codepoint", "byte", help="Units for the emitted offsets.")
2346
+ ] = None
2347
+ render: Annotated[
2348
+ str | None, choice("md", "html", "text", help="Render an entity vector back to text.")
2349
+ ] = None
2350
+ entities: Annotated[
2351
+ str | None, opt("--entities", metavar="JSON", kind="json", help="Entities to render.")
2352
+ ] = None
2353
+
2354
+
2355
+ #: Entities the server re-derives. Sending them back is an error, and telling
2356
+ #: a caller which ones they are is the whole point of splitting them out.
2357
+ AUTO_ENTITIES = frozenset(
2358
+ {"url", "email", "mention", "hashtag", "cashtag", "bot_command", "phone", "bank_card"}
2359
+ )
2360
+
2361
+ #: The automatic entities, detected locally. Telethon's markdown and HTML
2362
+ #: parsers do not emit them — the *server* does, on receipt — so a caller
2363
+ #: asking "what will Telegram find in this text" gets nothing back unless
2364
+ #: they are re-derived here. Deliberately close to Telegram's own rules and
2365
+ #: deliberately not authoritative: the report says these are what the server
2366
+ #: will add, not what tlgr sends.
2367
+ _AUTO_PATTERNS: tuple[tuple[str, str], ...] = (
2368
+ ("url", r"(?:https?://|www\.)[^\s<>\"]+"),
2369
+ ("email", r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"),
2370
+ ("mention", r"(?<![\w@])@[A-Za-z][A-Za-z0-9_]{3,31}"),
2371
+ ("hashtag", r"(?<![\w#])#[^\s#@$]{1,64}"),
2372
+ ("cashtag", r"(?<![\w$])\$[A-Z]{1,8}\b"),
2373
+ ("bot_command", r"(?<![\w/])/[A-Za-z0-9_]{1,64}(?:@[A-Za-z0-9_]{4,32})?"),
2374
+ ("phone", r"(?<![\d+])\+\d[\d \-()]{6,18}\d"),
2375
+ )
2376
+
2377
+
2378
+ def _auto_entities(text: str) -> list[MessageEntity]:
2379
+ """Re-derive the entities Telegram adds server-side, in UTF-16 offsets."""
2380
+ found: list[MessageEntity] = []
2381
+ taken: list[tuple[int, int]] = []
2382
+ for kind, pattern in _AUTO_PATTERNS:
2383
+ for match in re.finditer(pattern, text):
2384
+ start, end = match.span()
2385
+ if any(start < other_end and end > other_start for other_start, other_end in taken):
2386
+ continue
2387
+ taken.append((start, end))
2388
+ offset = utf16_len(text[:start])
2389
+ found.append(MessageEntity(type=kind, offset=offset, length=utf16_len(text[start:end])))
2390
+ found.sort(key=lambda entity: (entity.offset, entity.length))
2391
+ return found
2392
+
2393
+
2394
+ async def entity_list(ctx: OpContext, req: EntityListReq) -> EntityReport:
2395
+ """Show what a parse mode did, in the units Telegram counts in.
2396
+
2397
+ Runs locally, without an account: this is the answer to "why is my bold
2398
+ in the wrong place", and it is UTF-16 offsets every time.
2399
+ """
2400
+ text, entities = _send.body(req.text, parse=req.parse, entities=req.entities, stdin=req.stdin)
2401
+ manual = [e for e in entities if e.type not in AUTO_ENTITIES]
2402
+ automatic = [e for e in entities if e.type in AUTO_ENTITIES] + _auto_entities(text)
2403
+ if req.offset_units and req.offset_units != "utf16":
2404
+ manual = [_recount(text, e, req.offset_units) for e in manual]
2405
+ automatic = [_recount(text, e, req.offset_units) for e in automatic]
2406
+ report = EntityReport(
2407
+ text=text,
2408
+ entities=manual,
2409
+ auto_entities=automatic,
2410
+ length_utf16=utf16_len(text),
2411
+ would_split=len(_send.split_text(text, entities)),
2412
+ )
2413
+ if req.render:
2414
+ stub = Message(id=0, chat_id=0, date="", date_unix=0, text=text, entities=entities)
2415
+ report.rendered = _render(stub, req.render)
2416
+ return report
2417
+
2418
+
2419
+ def _recount(text: str, entity: MessageEntity, units: str) -> MessageEntity:
2420
+ """Re-express a UTF-16 offset in code points or bytes."""
2421
+ raw = text.encode("utf-16-le")
2422
+ prefix = raw[: entity.offset * 2].decode("utf-16-le", "ignore")
2423
+ body = raw[entity.offset * 2 : (entity.offset + entity.length) * 2].decode(
2424
+ "utf-16-le", "ignore"
2425
+ )
2426
+ if units == "codepoint":
2427
+ offset, length = len(prefix), len(body)
2428
+ else:
2429
+ offset, length = len(prefix.encode()), len(body.encode())
2430
+ return MessageEntity(
2431
+ type=entity.type,
2432
+ offset=offset,
2433
+ length=length,
2434
+ url=entity.url,
2435
+ user_id=entity.user_id,
2436
+ language=entity.language,
2437
+ document_id=entity.document_id,
2438
+ collapsed=entity.collapsed,
2439
+ )
2440
+
2441
+
2442
+ SPEC_ENTITY_LIST = OperationSpec(
2443
+ id="message.entity.list",
2444
+ request=EntityListReq,
2445
+ response=EntityReport,
2446
+ impl=entity_list,
2447
+ summary="Parse or inspect formatted text and its entities",
2448
+ description=(
2449
+ "Offsets are UTF-16 code units, which is the classic third-party "
2450
+ "client bug: an emoji is one character and two units. Automatic "
2451
+ "entities are listed separately because the server re-derives them "
2452
+ "and sending them back is an error."
2453
+ ),
2454
+ aliases=("message.entities",),
2455
+ needs_account=False,
2456
+ needs_auth=False,
2457
+ surface=Surface.LOCAL,
2458
+ rate_class="local",
2459
+ timeout_s=10,
2460
+ columns=("text", "length_utf16"),
2461
+ example={
2462
+ "text": "hello world",
2463
+ "entities": [{"type": "bold", "offset": 0, "length": 5}],
2464
+ "length_utf16": 11,
2465
+ "would_split": 1,
2466
+ },
2467
+ example_args='message entity list --parse md "**hello** world"',
2468
+ covers=(
2469
+ "messages-core.entities-detect-local",
2470
+ "messages-core.format-entity-offsets-utf16",
2471
+ "messages-core.format-raw-entities-json",
2472
+ ),
2473
+ )
2474
+
2475
+
2476
+ class TranslateReq(Request):
2477
+ chat: Annotated[
2478
+ PeerRef | None, arg(0, metavar="CHAT", required=False, kind="peer", help="Chat.")
2479
+ ] = None
2480
+ msg_id: Annotated[
2481
+ tuple[str, ...],
2482
+ arg(1, metavar="MSG_ID", required=False, variadic=True, help="Message ids."),
2483
+ ] = ()
2484
+ lang: Annotated[
2485
+ str | None, opt("--lang", "--to", metavar="CODE", help="Target language code.")
2486
+ ] = None
2487
+ text: Annotated[str | None, opt("--text", help="Translate this text instead.")] = None
2488
+ rich: Annotated[bool, opt("--rich", help="Translate a layer-229 rich body.")] = False
2489
+
2490
+
2491
+ async def translate(ctx: OpContext, req: TranslateReq) -> Page[Translation]:
2492
+ """Translate messages, or arbitrary text, into a target language."""
2493
+ from telethon.tl import types
2494
+ from telethon.tl.functions import messages as fn
2495
+
2496
+ if req.rich:
2497
+ _send.require_supported(
2498
+ "--rich", "messages.translateRichMessage is layer 229 and absent from Telethon 1.44"
2499
+ )
2500
+ if not req.lang:
2501
+ raise UsageError("--lang is required (a two-letter language code)", field="lang")
2502
+
2503
+ kwargs: dict[str, Any] = {"to_lang": req.lang}
2504
+ ids: list[int] = []
2505
+ if req.text is not None:
2506
+ plain, entities = _send.body(req.text, parse="none")
2507
+ kwargs["text"] = [
2508
+ types.TextWithEntities(text=plain, entities=_send.tl_entities(entities) or [])
2509
+ ]
2510
+ else:
2511
+ if req.chat is None:
2512
+ raise UsageError("give a chat and message ids, or --text", field="chat")
2513
+ kwargs["peer"] = await _send.resolve(ctx, req.chat)
2514
+ ids = _ids(req.msg_id)
2515
+ if not ids:
2516
+ raise UsageError("give at least one message id to translate", field="msg_id")
2517
+ kwargs["id"] = ids
2518
+
2519
+ result = await _client(ctx)(fn.TranslateTextRequest(**kwargs))
2520
+ items: list[Translation] = []
2521
+ for index, piece in enumerate(getattr(result, "result", None) or []):
2522
+ items.append(
2523
+ Translation(
2524
+ msg_id=ids[index] if index < len(ids) else None,
2525
+ text=str(getattr(piece, "text", "") or ""),
2526
+ entities=message_entities(piece),
2527
+ lang=req.lang,
2528
+ )
2529
+ )
2530
+ return Page(items=items, has_more=False, total=len(items))
2531
+
2532
+
2533
+ SPEC_TRANSLATE = OperationSpec(
2534
+ id="message.translate",
2535
+ request=TranslateReq,
2536
+ response=Page[Translation],
2537
+ impl=translate,
2538
+ summary="Translate messages or text into a target language",
2539
+ columns=("msg_id", "lang", "text"),
2540
+ example={"items": [{"msg_id": 12345, "text": "on my way", "lang": "en"}], "has_more": False},
2541
+ example_args="message translate @alice 12345 --lang en",
2542
+ covers=("dialogs.translate-messages", "messages-core.translate-message"),
2543
+ covers_partial=("bots.rich-message-translate", "richmsg.translate"),
2544
+ coverage_note="Rich-body translation is layer 229 and refused with NOT_SUPPORTED.",
2545
+ )
2546
+
2547
+
2548
+ class TranscribeReq(Request):
2549
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
2550
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Voice or round video.")]
2551
+ rate: Annotated[
2552
+ str | None, choice("good", "bad", help="Rate a transcription already produced.")
2553
+ ] = None
2554
+ wait: Annotated[bool, opt(help="Poll until the transcription is final.")] = True
2555
+
2556
+
2557
+ async def transcribe(ctx: OpContext, req: TranscribeReq) -> Transcription:
2558
+ """Transcribe a voice note or round video.
2559
+
2560
+ The first response is usually `pending`; the final text arrives in an
2561
+ update, so `--wait` re-asks rather than reporting an empty transcript as
2562
+ the answer.
2563
+ """
2564
+ from telethon.tl.functions import messages as fn
2565
+
2566
+ peer = await _send.resolve(ctx, req.chat)
2567
+ client = _client(ctx)
2568
+ result = await client(fn.TranscribeAudioRequest(peer=peer, msg_id=req.msg_id))
2569
+ out = Transcription(
2570
+ msg_id=req.msg_id,
2571
+ text=str(getattr(result, "text", "") or ""),
2572
+ pending=bool(getattr(result, "pending", False)),
2573
+ transcription_id=getattr(result, "transcription_id", None),
2574
+ )
2575
+ attempts = 0
2576
+ while req.wait and out.pending and attempts < 10:
2577
+ attempts += 1
2578
+ await asyncio.sleep(1.0)
2579
+ result = await client(fn.TranscribeAudioRequest(peer=peer, msg_id=req.msg_id))
2580
+ out.text = str(getattr(result, "text", "") or out.text)
2581
+ out.pending = bool(getattr(result, "pending", False))
2582
+ if req.rate and out.transcription_id is not None:
2583
+ await client(
2584
+ fn.RateTranscribedAudioRequest(
2585
+ peer=peer,
2586
+ msg_id=req.msg_id,
2587
+ transcription_id=out.transcription_id,
2588
+ good=req.rate == "good",
2589
+ )
2590
+ )
2591
+ out.rated = req.rate
2592
+ return out
2593
+
2594
+
2595
+ SPEC_TRANSCRIBE = OperationSpec(
2596
+ id="message.transcribe",
2597
+ request=TranscribeReq,
2598
+ response=Transcription,
2599
+ impl=transcribe,
2600
+ summary="Transcribe a voice note or round video",
2601
+ tags=frozenset({"mutating-checked"}),
2602
+ timeout_s=180,
2603
+ columns=("msg_id", "text", "pending"),
2604
+ example={"msg_id": 12345, "text": "call me back", "pending": False},
2605
+ example_args="message transcribe @alice 12345",
2606
+ covers=("media.transcribe-voice",),
2607
+ )
2608
+
2609
+
2610
+ class SummarizeReq(Request):
2611
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
2612
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Message id.")]
2613
+ lang: Annotated[
2614
+ str | None, opt("--lang", "--to", metavar="CODE", help="Summarize into this language.")
2615
+ ] = None
2616
+ tone: Annotated[str | None, opt("--tone", metavar="SLUG", help="Composition tone.")] = None
2617
+
2618
+
2619
+ async def summarize(ctx: OpContext, req: SummarizeReq) -> SummaryResult:
2620
+ """Summarize a long message with Telegram's own AI."""
2621
+ from telethon.tl.functions import messages as fn
2622
+
2623
+ peer = await _send.resolve(ctx, req.chat)
2624
+ result = await _client(ctx)(
2625
+ fn.SummarizeTextRequest(peer=peer, id=req.msg_id, to_lang=req.lang, tone=req.tone)
2626
+ )
2627
+ piece = getattr(result, "result", None) or result
2628
+ return SummaryResult(
2629
+ msg_id=req.msg_id,
2630
+ text=str(getattr(piece, "text", "") or ""),
2631
+ entities=message_entities(piece),
2632
+ quota_left=getattr(result, "quota_left", None),
2633
+ )
2634
+
2635
+
2636
+ SPEC_SUMMARIZE = OperationSpec(
2637
+ id="message.summarize",
2638
+ request=SummarizeReq,
2639
+ response=SummaryResult,
2640
+ impl=summarize,
2641
+ summary="Summarize a long message with AI",
2642
+ description="Shares its quota with `message compose`; Premium raises it.",
2643
+ timeout_s=180,
2644
+ columns=("msg_id", "text"),
2645
+ example={"msg_id": 12345, "text": "They are running late."},
2646
+ example_args="message summarize @alice 12345",
2647
+ covers=("messages-core.summarize-message",),
2648
+ )
2649
+
2650
+
2651
+ class ComposeReq(Request):
2652
+ text: Annotated[
2653
+ str, arg(0, metavar="TEXT", required=False, help="Text to rewrite; '-' reads stdin.")
2654
+ ] = ""
2655
+ stdin: Annotated[bool, opt("--stdin", help="Read from stdin.")] = False
2656
+ proofread: Annotated[bool, opt("--proofread", help="Fix grammar and spelling.")] = False
2657
+ translate: Annotated[
2658
+ str | None, opt("--translate", metavar="LANG", help="Translate into this language.")
2659
+ ] = None
2660
+ tone: Annotated[str | None, opt("--tone", metavar="SLUG", help="Rewrite in this tone.")] = None
2661
+ emojify: Annotated[bool, opt("--emojify", help="Add emoji.")] = False
2662
+ rich: Annotated[
2663
+ str | None, opt("--rich", metavar="PATH", kind="path", help="Compose a rich body.")
2664
+ ] = None
2665
+ diff: Annotated[
2666
+ str | None, choice("unified", "inline", "json", help="Shape of the proofreading diff.")
2667
+ ] = None
2668
+ send_to: Annotated[
2669
+ PeerRef | None,
2670
+ opt("--send-to", metavar="CHAT", kind="peer", help="Send the result instead of printing."),
2671
+ ] = None
2672
+
2673
+
2674
+ async def compose(ctx: OpContext, req: ComposeReq) -> ComposeResult:
2675
+ """Rewrite text with Telegram's AI: proofread, translate, tone, emojify.
2676
+
2677
+ The diff entities only mean something when proofreading is the sole mode,
2678
+ because they are expressed against the original text; mixing a
2679
+ translation in makes them unreadable, so that combination is warned about
2680
+ rather than silently reported.
2681
+ """
2682
+ from telethon.tl import types
2683
+ from telethon.tl.functions import messages as fn
2684
+
2685
+ if req.rich:
2686
+ _send.require_supported(
2687
+ "--rich", "messages.composeRichMessageWithAI is layer 229 and absent from Telethon 1.44"
2688
+ )
2689
+ text, entities = _send.body(req.text, parse="none", stdin=req.stdin)
2690
+ if not text:
2691
+ raise UsageError("give some text to rewrite", field="text")
2692
+ if req.proofread and (req.translate or req.tone or req.emojify):
2693
+ ctx.warn("diff entities are only produced when --proofread is the only mode")
2694
+
2695
+ tone = types.InputAiComposeToneSlug(slug=req.tone) if req.tone else None
2696
+ result = await _client(ctx)(
2697
+ fn.ComposeMessageWithAIRequest(
2698
+ text=types.TextWithEntities(text=text, entities=_send.tl_entities(entities) or []),
2699
+ proofread=req.proofread or None,
2700
+ emojify=req.emojify or None,
2701
+ translate_to_lang=req.translate,
2702
+ tone=tone,
2703
+ )
2704
+ )
2705
+ piece = getattr(result, "result", None) or result
2706
+ diff = getattr(result, "diff", None)
2707
+ out = ComposeResult(
2708
+ text=str(getattr(piece, "text", "") or ""),
2709
+ entities=message_entities(piece),
2710
+ diff_text=str(getattr(diff, "text", "")) if diff is not None else None,
2711
+ diff_entities=message_entities(diff) if diff is not None else [],
2712
+ quota_left=getattr(result, "quota_left", None),
2713
+ )
2714
+ if req.send_to is not None:
2715
+ out.sent = await send(ctx, SendReq(chat=req.send_to, text=out.text, parse="none"))
2716
+ return out
2717
+
2718
+
2719
+ SPEC_COMPOSE = OperationSpec(
2720
+ id="message.compose",
2721
+ request=ComposeReq,
2722
+ response=ComposeResult,
2723
+ impl=compose,
2724
+ summary="Rewrite text with Telegram's AI",
2725
+ tags=frozenset({"mutating-checked"}),
2726
+ timeout_s=180,
2727
+ columns=("text",),
2728
+ example={"text": "I am on my way.", "quota_left": 9},
2729
+ example_args='message compose --proofread "im on my way"',
2730
+ covers=(
2731
+ "ai.compose",
2732
+ "appearance.ai-compose-run",
2733
+ "messages-core.ai-compose",
2734
+ "messages-core.format-diff-entities",
2735
+ ),
2736
+ covers_partial=("richmsg.compose-ai",),
2737
+ coverage_note="Composing a rich body needs layer 229 and is refused with NOT_SUPPORTED.",
2738
+ )
2739
+
2740
+
2741
+ class PreviewReq(Request):
2742
+ url: Annotated[str, arg(0, metavar="URL", help="URL to preview.")]
2743
+ refresh: Annotated[bool, opt("--refresh", help="Bypass the cached webpage hash.")] = False
2744
+
2745
+
2746
+ async def preview(ctx: OpContext, req: PreviewReq) -> WebPagePreview:
2747
+ """Fetch the link preview Telegram would attach to a URL.
2748
+
2749
+ `hash=0` always: the cached-hash protocol saves a round trip only for a
2750
+ client keeping a webpage cache, and tlgr keeps none, so asking for the
2751
+ cached form would just answer `webPageNotModified` with nothing in it.
2752
+ """
2753
+ from telethon.tl.functions import messages as fn
2754
+
2755
+ from tlgr.ops._serialize import media_summary, photo_summary
2756
+
2757
+ result = await _client(ctx)(fn.GetWebPageRequest(url=req.url, hash=0))
2758
+ page = getattr(result, "webpage", None) or result
2759
+ name = type(page).__name__
2760
+ document = getattr(page, "document", None)
2761
+ photo = getattr(page, "photo", None)
2762
+
2763
+ return WebPagePreview(
2764
+ url=str(getattr(page, "url", req.url) or req.url),
2765
+ type=getattr(page, "type", None),
2766
+ site_name=getattr(page, "site_name", None),
2767
+ title=getattr(page, "title", None),
2768
+ description=getattr(page, "description", None),
2769
+ photo=photo_summary(photo),
2770
+ document=media_summary(page) if document is not None else None,
2771
+ has_large_media=bool(getattr(page, "has_large_media", False)),
2772
+ cached_page=getattr(page, "cached_page", None) is not None,
2773
+ pending=name == "WebPagePending",
2774
+ )
2775
+
2776
+
2777
+ SPEC_PREVIEW = OperationSpec(
2778
+ id="message.preview",
2779
+ request=PreviewReq,
2780
+ response=WebPagePreview,
2781
+ impl=preview,
2782
+ summary="Fetch the link preview Telegram would attach to a URL",
2783
+ description="Sending that preview as the media is `message send --preview-url`.",
2784
+ columns=("url", "site_name", "title"),
2785
+ example={"url": "https://telegram.org", "site_name": "Telegram", "title": "Telegram"},
2786
+ example_args="message preview https://telegram.org",
2787
+ covers=("webpage.preview-fetch",),
2788
+ )
2789
+
2790
+
2791
+ # ---------------------------------------------------------------------------
2792
+ # catalogs: effects and dice
2793
+ # ---------------------------------------------------------------------------
2794
+
2795
+
2796
+ class EffectListReq(Request):
2797
+ premium_only: Annotated[
2798
+ bool, opt("--premium-only", help="Only effects that require Premium.")
2799
+ ] = False
2800
+ refresh: Annotated[bool, opt("--refresh", help="Ignore the cached hash.")] = False
2801
+
2802
+
2803
+ async def effect_list(ctx: OpContext, req: EffectListReq) -> Page[Effect]:
2804
+ """The animated message effects `--effect` accepts."""
2805
+ from telethon.tl.functions import messages as fn
2806
+
2807
+ limit, _ = _window(ctx, "message.effect.list", PageKind.LOCAL, default=100)
2808
+ result = await _client(ctx)(fn.GetAvailableEffectsRequest(hash=0))
2809
+ items = [
2810
+ Effect(
2811
+ id=int(getattr(effect, "id", 0) or 0),
2812
+ emoticon=str(getattr(effect, "emoticon", "") or ""),
2813
+ premium_required=bool(getattr(effect, "premium_required", False)),
2814
+ static_icon_id=getattr(effect, "static_icon_id", None),
2815
+ effect_animation_id=getattr(effect, "effect_animation_id", None),
2816
+ effect_sticker_id=getattr(effect, "effect_sticker_id", None),
2817
+ )
2818
+ for effect in (getattr(result, "effects", None) or [])
2819
+ ]
2820
+ if req.premium_only:
2821
+ items = [effect for effect in items if effect.premium_required]
2822
+ return Page(items=items[:limit], has_more=len(items) > limit, total=len(items))
2823
+
2824
+
2825
+ SPEC_EFFECT_LIST = OperationSpec(
2826
+ id="message.effect.list",
2827
+ request=EffectListReq,
2828
+ response=Page[Effect],
2829
+ impl=effect_list,
2830
+ summary="Browse the animated message effects",
2831
+ description="Effects apply to private chats only, and some need Premium to send.",
2832
+ aliases=("effect.list",),
2833
+ paginated=PageKind.LOCAL,
2834
+ columns=("id", "emoticon", "premium_required"),
2835
+ example={"items": [{"id": 5104841245755180586, "emoticon": "🔥"}], "has_more": False},
2836
+ example_args="message effect list",
2837
+ covers=("messages-core.message-effects-catalog",),
2838
+ )
2839
+
2840
+
2841
+ class DiceListReq(Request):
2842
+ stake: Annotated[bool, opt("--stake", help="Also return the staked-dice game state.")] = False
2843
+ refresh: Annotated[bool, opt("--refresh", help="Re-read help.getAppConfig.")] = False
2844
+
2845
+
2846
+ async def dice_list(ctx: OpContext, req: DiceListReq) -> DiceCatalog:
2847
+ """Which dice emoji exist and what counts as a win.
2848
+
2849
+ Read from `help.getAppConfig` every time rather than hardcoded: a client
2850
+ with a frozen list reports a newly added dice emoji as unsupported media.
2851
+ """
2852
+ from telethon.tl.functions import help as help_fn
2853
+
2854
+ config = await _client(ctx)(help_fn.GetAppConfigRequest(hash=0))
2855
+ values = _app_config(config)
2856
+ emojis = [str(item) for item in (values.get("emojies_send_dice") or [])]
2857
+ raw_success = values.get("emojies_send_dice_success") or {}
2858
+ success = {
2859
+ str(emoji): int((info or {}).get("value", 0))
2860
+ for emoji, info in raw_success.items()
2861
+ if isinstance(info, dict)
2862
+ }
2863
+ catalog = DiceCatalog(emojis=emojis, success_values=success)
2864
+ if req.stake:
2865
+ from telethon.tl.functions import messages as fn
2866
+
2867
+ state = await _client(ctx)(fn.GetEmojiGameInfoRequest())
2868
+ catalog.stake = {"tl_type": type(state).__name__, "read_only": True}
2869
+ ctx.warn("staked dice is read-only in tlgr: placing a TON stake is refused")
2870
+ return catalog
2871
+
2872
+
2873
+ def _app_config(config: Any) -> dict[str, Any]:
2874
+ """`help.appConfig` as plain Python, whatever JSON node shape it uses."""
2875
+
2876
+ def unwrap(node: Any) -> Any:
2877
+ name = type(node).__name__
2878
+ if name == "JsonObject":
2879
+ return {str(v.key): unwrap(v.value) for v in (getattr(node, "value", None) or [])}
2880
+ if name == "JsonArray":
2881
+ return [unwrap(v) for v in (getattr(node, "value", None) or [])]
2882
+ if name == "JsonNull":
2883
+ return None
2884
+ return getattr(node, "value", node)
2885
+
2886
+ return unwrap(getattr(config, "config", config)) or {}
2887
+
2888
+
2889
+ SPEC_DICE_LIST = OperationSpec(
2890
+ id="message.dice.list",
2891
+ request=DiceListReq,
2892
+ response=DiceCatalog,
2893
+ impl=dice_list,
2894
+ summary="Which dice emoji exist and what counts as a win",
2895
+ aliases=("dice.list",),
2896
+ columns=("emojis",),
2897
+ example={"emojis": ["🎲", "🎯", "🏀"], "success_values": {"🎯": 6}},
2898
+ example_args="message dice list",
2899
+ covers=("dice.animation-assets", "dice.list-emojis", "dice.stake-info"),
2900
+ )
2901
+
2902
+
2903
+ # ---------------------------------------------------------------------------
2904
+ # report / fact-check / paid
2905
+ # ---------------------------------------------------------------------------
2906
+
2907
+
2908
+ class ReportReq(Request):
2909
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
2910
+ msg_id: Annotated[
2911
+ tuple[str, ...],
2912
+ arg(1, metavar="MSG_ID", required=False, variadic=True, help="Message ids."),
2913
+ ] = ()
2914
+ option: Annotated[
2915
+ list[str], opt("--option", metavar="KEY", help="Report-option key; repeatable.")
2916
+ ] = []
2917
+ comment: Annotated[str | None, opt("--comment", help="Free-text comment.")] = None
2918
+ list_options: Annotated[
2919
+ bool, opt("--list-options", help="Return the report menu instead of reporting.")
2920
+ ] = False
2921
+ from_user: Annotated[
2922
+ PeerRef | None,
2923
+ opt("--from", metavar="USER", kind="user", help="Report a member as spam."),
2924
+ ] = None
2925
+ not_spam: Annotated[bool, opt("--not-spam", help="Report an anti-spam false positive.")] = False
2926
+
2927
+
2928
+ async def report(ctx: OpContext, req: ReportReq) -> ReportResult:
2929
+ """Report messages, a member's spam, or an anti-spam false positive.
2930
+
2931
+ `messages.report` is a menu, not a single call: the first request answers
2932
+ with the options the server currently offers, and each `--option` walks
2933
+ one level deeper. Reporting blind would submit whatever the first option
2934
+ happened to be.
2935
+ """
2936
+ from telethon.tl.functions import channels as ch
2937
+ from telethon.tl.functions import messages as fn
2938
+
2939
+ peer = await _send.resolve(ctx, req.chat)
2940
+ client = _client(ctx)
2941
+ ids = _ids(req.msg_id)
2942
+
2943
+ if req.from_user is not None:
2944
+ channel = _input_channel(peer)
2945
+ member = await _send.resolve(ctx, req.from_user)
2946
+ await client(ch.ReportSpamRequest(channel=channel, participant=member, id=ids))
2947
+ return ReportResult(ok=True, title="reported as spam")
2948
+
2949
+ if req.not_spam:
2950
+ channel = _input_channel(peer)
2951
+ if len(ids) != 1:
2952
+ raise UsageError("--not-spam takes exactly one message id", field="msg_id")
2953
+ await client(ch.ReportAntiSpamFalsePositiveRequest(channel=channel, msg_id=ids[0]))
2954
+ return ReportResult(ok=True, title="reported as a false positive")
2955
+
2956
+ if not ids:
2957
+ raise UsageError("give at least one message id to report", field="msg_id")
2958
+
2959
+ option = req.option[-1].encode() if req.option else b""
2960
+ result = await client(
2961
+ fn.ReportRequest(peer=peer, id=ids, option=option, message=req.comment or "")
2962
+ )
2963
+ name = type(result).__name__
2964
+ if name == "ReportResultReported":
2965
+ return ReportResult(ok=True, title="reported")
2966
+ if name == "ReportResultAddComment":
2967
+ return ReportResult(
2968
+ ok=False, title="a comment is required", comment_required=True, options=[]
2969
+ )
2970
+ options = [
2971
+ {"key": _decode_key(getattr(entry, "option", b"")), "text": getattr(entry, "text", "")}
2972
+ for entry in (getattr(result, "options", None) or [])
2973
+ ]
2974
+ return ReportResult(ok=False, title=getattr(result, "title", None), options=options)
2975
+
2976
+
2977
+ def _decode_key(raw: bytes) -> str:
2978
+ try:
2979
+ return raw.decode()
2980
+ except UnicodeDecodeError:
2981
+ import base64
2982
+
2983
+ return base64.b64encode(raw).decode()
2984
+
2985
+
2986
+ SPEC_REPORT = OperationSpec(
2987
+ id="message.report",
2988
+ request=ReportReq,
2989
+ response=ReportResult,
2990
+ impl=report,
2991
+ summary="Report messages for abuse",
2992
+ description=(
2993
+ "The first call returns the option menu; pass --option to walk it. "
2994
+ "Reporting a chat or a user (rather than messages) belongs to the "
2995
+ "`chat`/`user` groups."
2996
+ ),
2997
+ mutating=True,
2998
+ columns=("ok", "title"),
2999
+ example={"ok": False, "title": "What is wrong with this message?", "options": []},
3000
+ example_args="message report @channel 42 --list-options",
3001
+ covers=(
3002
+ "messages-core.report-antispam-false-positive",
3003
+ "messages-core.report-message",
3004
+ "messages-core.report-spam-participant",
3005
+ ),
3006
+ )
3007
+
3008
+
3009
+ class FactCheckReq(Request):
3010
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
3011
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Message id.")]
3012
+ set: Annotated[str | None, opt("--set", metavar="TEXT", help="Fact-check text.")] = None
3013
+ parse: Annotated[str | None, choice("md", "html", "none", help="Formatting of --set.")] = None
3014
+ clear: Annotated[bool, opt("--clear", help="Remove the fact-check.")] = False
3015
+
3016
+
3017
+ async def fact_check_set(ctx: OpContext, req: FactCheckReq) -> FactCheck:
3018
+ """Read, set or remove a message's fact-check.
3019
+
3020
+ Reading is free for everyone; writing needs an account Telegram flagged
3021
+ as an independent fact-checker for the message's country, so the write
3022
+ path usually answers PERMISSION_DENIED — which is information, not a bug.
3023
+ """
3024
+ from telethon.tl import types
3025
+ from telethon.tl.functions import messages as fn
3026
+
3027
+ peer = await _send.resolve(ctx, req.chat)
3028
+ client = _client(ctx)
3029
+
3030
+ if req.clear:
3031
+ await client(fn.DeleteFactCheckRequest(peer=peer, msg_id=req.msg_id))
3032
+ return FactCheck(msg_id=req.msg_id)
3033
+ if req.set is not None:
3034
+ text, entities = _send.body(req.set, parse=req.parse)
3035
+ result = await client(
3036
+ fn.EditFactCheckRequest(
3037
+ peer=peer,
3038
+ msg_id=req.msg_id,
3039
+ text=types.TextWithEntities(text=text, entities=_send.tl_entities(entities) or []),
3040
+ )
3041
+ )
3042
+ _send.messages_from_updates(result)
3043
+ return FactCheck(msg_id=req.msg_id, text=text, entities=entities)
3044
+
3045
+ found = await client(fn.GetFactCheckRequest(peer=peer, msg_id=[req.msg_id]))
3046
+ for check in found or []:
3047
+ piece = getattr(check, "text", None)
3048
+ return FactCheck(
3049
+ msg_id=req.msg_id,
3050
+ country=getattr(check, "country", None),
3051
+ text=str(getattr(piece, "text", "") or ""),
3052
+ entities=message_entities(piece) if piece is not None else [],
3053
+ hash=getattr(check, "hash", None),
3054
+ need_check=bool(getattr(check, "need_check", False)),
3055
+ )
3056
+ return FactCheck(msg_id=req.msg_id)
3057
+
3058
+
3059
+ SPEC_FACT_CHECK_SET = OperationSpec(
3060
+ id="message.fact-check.set",
3061
+ request=FactCheckReq,
3062
+ response=FactCheck,
3063
+ impl=fact_check_set,
3064
+ summary="Read, set or remove a message's fact-check",
3065
+ mutating=True,
3066
+ tags=frozenset({"mutating-checked"}),
3067
+ columns=("msg_id", "country", "text"),
3068
+ example={"msg_id": 42, "country": "US", "text": "Context: …"},
3069
+ example_args="message fact-check set @channel 42",
3070
+ covers=("messages-core.factcheck-edit", "messages-core.factcheck-view"),
3071
+ )
3072
+
3073
+
3074
+ class PaidReq(Request):
3075
+ user: Annotated[
3076
+ PeerRef | None, arg(0, metavar="USER", required=False, kind="user", help="User.")
3077
+ ] = None
3078
+ exempt: Annotated[bool, opt("--exempt", help="Let this user message me for free.")] = False
3079
+ charge: Annotated[bool, opt("--charge", help="Re-enable the per-message fee.")] = False
3080
+ refund: Annotated[bool, opt("--refund", help="With --exempt: refund the Stars paid.")] = False
3081
+ revenue: Annotated[bool, opt("--revenue", help="Report Stars earned from paid messages.")] = (
3082
+ False
3083
+ )
3084
+ channel: Annotated[
3085
+ PeerRef | None,
3086
+ opt("--channel", metavar="CHAT", kind="peer", help="Scope --revenue to a channel."),
3087
+ ] = None
3088
+
3089
+
3090
+ async def paid_set(ctx: OpContext, req: PaidReq) -> PaidMessageSettings:
3091
+ """Per-user paid-message settings, and the Stars they earned."""
3092
+ from telethon.tl import types
3093
+ from telethon.tl.functions import account as acc
3094
+
3095
+ client = _client(ctx)
3096
+ if req.revenue:
3097
+ peer = (
3098
+ await _send.resolve(ctx, req.channel)
3099
+ if req.channel is not None
3100
+ else types.InputPeerSelf()
3101
+ )
3102
+ revenue = await client(acc.GetPaidMessagesRevenueRequest(user_id=peer))
3103
+ return PaidMessageSettings(revenue_stars=getattr(revenue, "stars_amount", None))
3104
+
3105
+ if req.user is None:
3106
+ raise UsageError("give a user, or --revenue", field="user")
3107
+ if req.exempt == req.charge:
3108
+ raise UsageError("pass exactly one of --exempt or --charge", field="exempt")
3109
+
3110
+ peer = await _send.resolve(ctx, req.user)
3111
+ await client(
3112
+ acc.ToggleNoPaidMessagesExceptionRequest(
3113
+ user_id=peer, refund_charged=req.refund or None, require_payment=req.charge or None
3114
+ )
3115
+ )
3116
+ return PaidMessageSettings(
3117
+ user_id=_send.peer_id_of(peer),
3118
+ exempt=req.exempt,
3119
+ refunded_stars=0 if req.refund else None,
3120
+ )
3121
+
3122
+
3123
+ SPEC_PAID_SET = OperationSpec(
3124
+ id="message.paid.set",
3125
+ request=PaidReq,
3126
+ response=PaidMessageSettings,
3127
+ impl=paid_set,
3128
+ summary="Per-user paid-message settings and the Stars they earned",
3129
+ description=(
3130
+ "--refund sends Stars back to the sender, so it is gated behind --yes. "
3131
+ "The global 'charge non-contacts' switch is a privacy setting and the "
3132
+ "per-group price is a chat setting; both belong to their own groups."
3133
+ ),
3134
+ mutating=True,
3135
+ columns=("user_id", "exempt", "revenue_stars"),
3136
+ example={"user_id": 777, "exempt": True},
3137
+ example_args="message paid set @alice --exempt",
3138
+ covers=("messages-core.paid-messages-exempt-user", "messages-core.paid-messages-revenue"),
3139
+ )
3140
+
3141
+
3142
+ # ---------------------------------------------------------------------------
3143
+ # sponsored messages
3144
+ # ---------------------------------------------------------------------------
3145
+
3146
+
3147
+ class SponsoredListReq(Request):
3148
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel or bot chat.")]
3149
+ mark_viewed: Annotated[bool, opt("--mark-viewed", help="Also report the impressions.")] = False
3150
+
3151
+
3152
+ async def sponsored_list(ctx: OpContext, req: SponsoredListReq) -> Page[SponsoredMessage]:
3153
+ """The sponsored messages a channel would show.
3154
+
3155
+ Opt-in: tlgr never mixes them into `message list`. Registering the
3156
+ impression is a separate flag because doing it silently would report
3157
+ views nobody saw.
3158
+ """
3159
+ from telethon.tl.functions import messages as fn
3160
+
3161
+ peer = await _send.resolve(ctx, req.chat)
3162
+ client = _client(ctx)
3163
+ result = await client(fn.GetSponsoredMessagesRequest(peer=peer))
3164
+ items: list[SponsoredMessage] = []
3165
+ for entry in getattr(result, "messages", None) or []:
3166
+ random_id = getattr(entry, "random_id", b"") or b""
3167
+ items.append(
3168
+ SponsoredMessage(
3169
+ random_id=_decode_key(random_id),
3170
+ title=getattr(entry, "title", None),
3171
+ message=str(getattr(entry, "message", "") or ""),
3172
+ entities=message_entities(entry),
3173
+ url=getattr(entry, "url", None),
3174
+ button_text=getattr(entry, "button_text", None),
3175
+ sponsor_info=getattr(entry, "sponsor_info", None),
3176
+ additional_info=getattr(entry, "additional_info", None),
3177
+ recommended=bool(getattr(entry, "recommended", False)),
3178
+ can_report=bool(getattr(entry, "can_report", False)),
3179
+ )
3180
+ )
3181
+ if req.mark_viewed:
3182
+ await client(fn.ViewSponsoredMessageRequest(random_id=random_id))
3183
+ items[-1].viewed = True
3184
+ return Page(items=items, has_more=False, total=len(items))
3185
+
3186
+
3187
+ SPEC_SPONSORED_LIST = OperationSpec(
3188
+ id="message.sponsored.list",
3189
+ request=SponsoredListReq,
3190
+ response=Page[SponsoredMessage],
3191
+ impl=sponsored_list,
3192
+ summary="List the sponsored messages a chat would show",
3193
+ aliases=("ads.list",),
3194
+ tags=frozenset({"mutating-checked"}),
3195
+ columns=("random_id", "title", "message"),
3196
+ example={"items": [{"random_id": "abc", "message": "An ad"}], "has_more": False},
3197
+ example_args="message sponsored list @durov",
3198
+ covers=("messages-core.sponsored-list",),
3199
+ )
3200
+
3201
+
3202
+ class SponsoredHideReq(Request):
3203
+ state: Annotated[str, arg(0, metavar="STATE", required=False, help="on = hide ads.")] = "on"
3204
+ chat: Annotated[
3205
+ PeerRef | None,
3206
+ opt("--chat", metavar="CHAT", kind="peer", help="Disable ads in this channel instead."),
3207
+ ] = None
3208
+
3209
+
3210
+ async def sponsored_hide(ctx: OpContext, req: SponsoredHideReq) -> SponsoredHidden:
3211
+ """Hide ads for my account (Premium), or disable them in my own channel."""
3212
+ from telethon.tl.functions import account as acc
3213
+ from telethon.tl.functions import channels as ch
3214
+
3215
+ hide = req.state.strip().lower() in ("on", "true", "yes", "1")
3216
+ client = _client(ctx)
3217
+ if req.chat is not None:
3218
+ peer = await _send.resolve(ctx, req.chat)
3219
+ channel = _input_channel(peer)
3220
+ await client(ch.RestrictSponsoredMessagesRequest(channel=channel, restricted=hide))
3221
+ return SponsoredHidden(hidden=hide, chat_id=_send.peer_id_of(peer))
3222
+ await client(acc.ToggleSponsoredMessagesRequest(enabled=not hide))
3223
+ return SponsoredHidden(hidden=hide)
3224
+
3225
+
3226
+ SPEC_SPONSORED_HIDE = OperationSpec(
3227
+ id="message.sponsored.hide",
3228
+ request=SponsoredHideReq,
3229
+ response=SponsoredHidden,
3230
+ impl=sponsored_hide,
3231
+ summary="Hide sponsored messages for my account or my channel",
3232
+ aliases=("ads.hide",),
3233
+ mutating=True,
3234
+ idempotent=True,
3235
+ columns=("hidden", "chat_id"),
3236
+ example={"hidden": True},
3237
+ example_args="message sponsored hide on",
3238
+ covers=("messages-core.sponsored-hide",),
3239
+ )
3240
+
3241
+
3242
+ class SponsoredReportReq(Request):
3243
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat the ad appeared in.")]
3244
+ random_id: Annotated[str, arg(1, metavar="RANDOM_ID", help="Ad id from `sponsored list`.")]
3245
+ option: Annotated[
3246
+ str | None, opt("--option", metavar="KEY", help="Report-option key from the menu.")
3247
+ ] = None
3248
+ click: Annotated[bool, opt("--click", help="Report a click instead of a report.")] = False
3249
+ media: Annotated[bool, opt("--media", help="With --click: the click was on the media.")] = False
3250
+
3251
+
3252
+ async def sponsored_report(ctx: OpContext, req: SponsoredReportReq) -> ReportResult:
3253
+ """Report a sponsored message, or register a click-through."""
3254
+ from telethon.tl.functions import messages as fn
3255
+
3256
+ client = _client(ctx)
3257
+ await _send.resolve(ctx, req.chat)
3258
+ random_id = req.random_id.encode()
3259
+ if req.click:
3260
+ await client(fn.ClickSponsoredMessageRequest(random_id=random_id, media=req.media or None))
3261
+ return ReportResult(ok=True, title="click reported")
3262
+ result = await client(
3263
+ fn.ReportSponsoredMessageRequest(random_id=random_id, option=(req.option or "").encode())
3264
+ )
3265
+ name = type(result).__name__
3266
+ if name in (
3267
+ "ChannelsSponsoredMessageReportResultReported",
3268
+ "SponsoredMessageReportResultReported",
3269
+ ):
3270
+ return ReportResult(ok=True, title="reported")
3271
+ options = [
3272
+ {"key": _decode_key(getattr(entry, "option", b"")), "text": getattr(entry, "text", "")}
3273
+ for entry in (getattr(result, "options", None) or [])
3274
+ ]
3275
+ return ReportResult(ok=False, title=getattr(result, "title", None), options=options)
3276
+
3277
+
3278
+ SPEC_SPONSORED_REPORT = OperationSpec(
3279
+ id="message.sponsored.report",
3280
+ request=SponsoredReportReq,
3281
+ response=ReportResult,
3282
+ impl=sponsored_report,
3283
+ summary="Report or click through a sponsored message",
3284
+ aliases=("ads.report",),
3285
+ mutating=True,
3286
+ columns=("ok", "title"),
3287
+ example={"ok": True, "title": "reported"},
3288
+ example_args="message sponsored report @durov abc123",
3289
+ covers=("messages-core.sponsored-interact",),
3290
+ )
3291
+
3292
+
3293
+ # ---------------------------------------------------------------------------
3294
+ # suggested posts
3295
+ # ---------------------------------------------------------------------------
3296
+
3297
+
3298
+ class SuggestedApproveReq(Request):
3299
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel.")]
3300
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Suggested post id.")]
3301
+ publish_at: Annotated[
3302
+ str | None,
3303
+ opt("--publish-at", metavar="TS", kind="datetime", help="Publish then, not now."),
3304
+ ] = None
3305
+ price_stars: Annotated[
3306
+ int | None, opt("--price-stars", metavar="N", help="Counter-offer in Stars.")
3307
+ ] = None
3308
+ price_ton: Annotated[
3309
+ int | None, opt("--price-ton", metavar="NANO", help="Counter-offer in TON.")
3310
+ ] = None
3311
+
3312
+
3313
+ async def suggested_approve(ctx: OpContext, req: SuggestedApproveReq) -> SuggestedPostState:
3314
+ """Approve a suggested post, optionally at another time or price."""
3315
+ from telethon.tl.functions import messages as fn
3316
+
3317
+ peer = await _send.resolve(ctx, req.chat)
3318
+ await _client(ctx)(
3319
+ fn.ToggleSuggestedPostApprovalRequest(
3320
+ peer=peer, msg_id=req.msg_id, schedule_date=_send.schedule_at(req.publish_at)
3321
+ )
3322
+ )
3323
+ price = None
3324
+ if req.price_stars is not None or req.price_ton is not None:
3325
+ price = {"stars": req.price_stars, "ton": req.price_ton}
3326
+ return SuggestedPostState(
3327
+ chat_id=_send.peer_id_of(peer),
3328
+ msg_id=req.msg_id,
3329
+ state="approved",
3330
+ publish_at=req.publish_at,
3331
+ price=price,
3332
+ )
3333
+
3334
+
3335
+ SPEC_SUGGESTED_APPROVE = OperationSpec(
3336
+ id="message.suggested.approve",
3337
+ request=SuggestedApproveReq,
3338
+ response=SuggestedPostState,
3339
+ impl=suggested_approve,
3340
+ summary="Approve a suggested post",
3341
+ description="Approving a paid suggestion spends the channel's Stars, so it needs --yes.",
3342
+ mutating=True,
3343
+ destructive=True,
3344
+ columns=("chat_id", "msg_id", "state"),
3345
+ example={"chat_id": -1001234, "msg_id": 42, "state": "approved"},
3346
+ example_args="message suggested approve @channel 42",
3347
+ covers=("messages-core.suggested-post-approve-decline",),
3348
+ )
3349
+
3350
+
3351
+ class SuggestedDenyReq(Request):
3352
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel.")]
3353
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Suggested post id.")]
3354
+ comment: Annotated[str | None, opt("--comment", help="Reason sent back to the author.")] = None
3355
+
3356
+
3357
+ async def suggested_deny(ctx: OpContext, req: SuggestedDenyReq) -> SuggestedPostState:
3358
+ """Decline a suggested post."""
3359
+ from telethon.tl.functions import messages as fn
3360
+
3361
+ peer = await _send.resolve(ctx, req.chat)
3362
+ await _client(ctx)(
3363
+ fn.ToggleSuggestedPostApprovalRequest(
3364
+ peer=peer, msg_id=req.msg_id, reject=True, reject_comment=req.comment
3365
+ )
3366
+ )
3367
+ return SuggestedPostState(chat_id=_send.peer_id_of(peer), msg_id=req.msg_id, state="declined")
3368
+
3369
+
3370
+ SPEC_SUGGESTED_DENY = OperationSpec(
3371
+ id="message.suggested.deny",
3372
+ request=SuggestedDenyReq,
3373
+ response=SuggestedPostState,
3374
+ impl=suggested_deny,
3375
+ summary="Decline a suggested post",
3376
+ aliases=("message.suggested.decline",),
3377
+ mutating=True,
3378
+ columns=("chat_id", "msg_id", "state"),
3379
+ example={"chat_id": -1001234, "msg_id": 42, "state": "declined"},
3380
+ example_args="message suggested deny @channel 42",
3381
+ covers_partial=("messages-core.suggested-post-approve-decline",),
3382
+ coverage_note="The decline half; `message suggested approve` covers the approve half.",
3383
+ )
3384
+
3385
+
3386
+ class SuggestedEditReq(Request):
3387
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel.")]
3388
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="My suggested post.")]
3389
+ text: Annotated[str | None, opt("--text", help="New text.")] = None
3390
+ price_stars: Annotated[
3391
+ int | None, opt("--price-stars", metavar="N", help="New asking price in Stars.")
3392
+ ] = None
3393
+ price_ton: Annotated[
3394
+ int | None, opt("--price-ton", metavar="NANO", help="New asking price in TON.")
3395
+ ] = None
3396
+ publish_at: Annotated[
3397
+ str | None,
3398
+ opt("--publish-at", metavar="TS", kind="datetime", help="New requested publication time."),
3399
+ ] = None
3400
+ add_offer: Annotated[
3401
+ bool, opt("--add-offer", help="Attach a new offer to an already-sent post.")
3402
+ ] = False
3403
+
3404
+
3405
+ async def suggested_edit(ctx: OpContext, req: SuggestedEditReq) -> SuggestedPostState:
3406
+ """Change my own pending suggested post: text, price or time."""
3407
+ from telethon.tl.functions import messages as fn
3408
+
3409
+ peer = await _send.resolve(ctx, req.chat)
3410
+ if req.text is not None:
3411
+ text, entities = _send.body(req.text)
3412
+ await _client(ctx)(
3413
+ fn.EditMessageRequest(
3414
+ peer=peer, id=req.msg_id, message=text, entities=_send.tl_entities(entities)
3415
+ )
3416
+ )
3417
+ if req.price_stars is not None or req.price_ton is not None or req.publish_at:
3418
+ await _client(ctx)(
3419
+ fn.ToggleSuggestedPostApprovalRequest(
3420
+ peer=peer, msg_id=req.msg_id, schedule_date=_send.schedule_at(req.publish_at)
3421
+ )
3422
+ )
3423
+ return SuggestedPostState(
3424
+ chat_id=_send.peer_id_of(peer),
3425
+ msg_id=req.msg_id,
3426
+ state="offer-updated",
3427
+ publish_at=req.publish_at,
3428
+ price={"stars": req.price_stars, "ton": req.price_ton},
3429
+ )
3430
+
3431
+
3432
+ SPEC_SUGGESTED_EDIT = OperationSpec(
3433
+ id="message.suggested.edit",
3434
+ request=SuggestedEditReq,
3435
+ response=SuggestedPostState,
3436
+ impl=suggested_edit,
3437
+ summary="Change your own pending suggested post",
3438
+ description="Price bounds come from appConfig's stars_suggested_post_amount_min/max.",
3439
+ mutating=True,
3440
+ columns=("chat_id", "msg_id", "state"),
3441
+ example={"chat_id": -1001234, "msg_id": 42, "state": "offer-updated"},
3442
+ example_args='message suggested edit @channel 42 --text "new copy"',
3443
+ covers=(
3444
+ "groups-channels-admin.suggested-post-counter-offer",
3445
+ "groups-channels-admin.suggested-post-send",
3446
+ "messages-core.suggested-post-edit-own-offer",
3447
+ ),
3448
+ )
3449
+
3450
+
3451
+ # ---------------------------------------------------------------------------
3452
+ # AI composition tones
3453
+ # ---------------------------------------------------------------------------
3454
+
3455
+
3456
+ def _tone(entry: Any) -> Tone:
3457
+ return Tone(
3458
+ slug=str(getattr(entry, "slug", "") or ""),
3459
+ title=str(getattr(entry, "title", "") or ""),
3460
+ prompt=getattr(entry, "prompt", None),
3461
+ emoji_id=getattr(entry, "emoji_id", None) or getattr(entry, "document_id", None),
3462
+ installed=bool(getattr(entry, "installed", False)),
3463
+ author=getattr(entry, "author", None),
3464
+ )
3465
+
3466
+
3467
+ class ToneListReq(Request):
3468
+ installed: Annotated[bool, opt("--installed", help="Only tones I installed.")] = False
3469
+ mine: Annotated[bool, opt("--mine", help="Only tones I authored.")] = False
3470
+ refresh: Annotated[bool, opt("--refresh", help="Ignore the cached hash.")] = False
3471
+
3472
+
3473
+ async def tone_list(ctx: OpContext, req: ToneListReq) -> Page[Tone]:
3474
+ """List the AI composition tones `--tone` accepts."""
3475
+ from telethon.tl.functions import aicompose
3476
+
3477
+ limit, _ = _window(ctx, "message.tone.list", PageKind.LOCAL, default=100)
3478
+ result = await _client(ctx)(aicompose.GetTonesRequest(hash=0))
3479
+ items = [_tone(entry) for entry in (getattr(result, "tones", None) or [])]
3480
+ if req.installed:
3481
+ items = [tone for tone in items if tone.installed]
3482
+ return Page(items=items[:limit], has_more=len(items) > limit, total=len(items))
3483
+
3484
+
3485
+ SPEC_TONE_LIST = OperationSpec(
3486
+ id="message.tone.list",
3487
+ request=ToneListReq,
3488
+ response=Page[Tone],
3489
+ impl=tone_list,
3490
+ summary="List AI composition tones",
3491
+ aliases=("ai.tone.list",),
3492
+ paginated=PageKind.LOCAL,
3493
+ columns=("slug", "title", "installed"),
3494
+ example={"items": [{"slug": "formal", "title": "Formal"}], "has_more": False},
3495
+ example_args="message tone list",
3496
+ covers=("ai.tones-list", "appearance.ai-compose-tones", "messages-core.ai-compose-tones"),
3497
+ )
3498
+
3499
+
3500
+ class ToneGetReq(Request):
3501
+ slug: Annotated[str, arg(0, metavar="SLUG", help="Tone slug.")]
3502
+ example: Annotated[
3503
+ int, opt("--example", metavar="N", help="Generate N example rewrites.", ge=0)
3504
+ ] = 1
3505
+
3506
+
3507
+ async def tone_get(ctx: OpContext, req: ToneGetReq) -> Tone:
3508
+ """Show one tone, and preview what it does."""
3509
+ from telethon.tl.functions import aicompose
3510
+
3511
+ client = _client(ctx)
3512
+ found = await client(aicompose.GetToneRequest(slug=req.slug))
3513
+ tone = _tone(getattr(found, "tone", None) or found)
3514
+ for _ in range(req.example):
3515
+ sample = await client(aicompose.GetToneExampleRequest(slug=req.slug))
3516
+ piece = getattr(sample, "text", None) or sample
3517
+ tone.examples.append(str(getattr(piece, "text", piece) or ""))
3518
+ return tone
3519
+
3520
+
3521
+ SPEC_TONE_GET = OperationSpec(
3522
+ id="message.tone.get",
3523
+ request=ToneGetReq,
3524
+ response=Tone,
3525
+ impl=tone_get,
3526
+ summary="Show one composition tone and preview it",
3527
+ description="Examples are generated server-side and spend the AI quota.",
3528
+ aliases=("ai.tone.get",),
3529
+ tags=frozenset({"mutating-checked"}),
3530
+ columns=("slug", "title", "prompt"),
3531
+ example={"slug": "formal", "title": "Formal", "prompt": "Rewrite formally"},
3532
+ example_args="message tone get formal",
3533
+ covers=("ai.tone-example",),
3534
+ )
3535
+
3536
+
3537
+ class ToneSetReq(Request):
3538
+ slug: Annotated[
3539
+ str, arg(0, metavar="SLUG", required=False, help="Existing tone; omit with --new.")
3540
+ ] = ""
3541
+ new: Annotated[bool, opt("--new", help="Create a new tone.")] = False
3542
+ title: Annotated[str | None, opt("--title", help="Tone title.")] = None
3543
+ prompt: Annotated[str | None, opt("--prompt", help="Tone prompt.")] = None
3544
+ emoji_id: Annotated[
3545
+ int | None, opt("--emoji-id", metavar="ID", help="Custom emoji icon (Premium).")
3546
+ ] = None
3547
+ credit_me: Annotated[bool, opt("--credit-me", help="Show me as the author.")] = False
3548
+ install: Annotated[bool, opt("--install", help="Install a shared tone.")] = False
3549
+ uninstall: Annotated[bool, opt("--uninstall", help="Uninstall it.")] = False
3550
+
3551
+
3552
+ async def tone_set(ctx: OpContext, req: ToneSetReq) -> Tone:
3553
+ """Create, edit, install or uninstall an AI composition tone."""
3554
+ from telethon.tl.functions import aicompose
3555
+
3556
+ client = _client(ctx)
3557
+ if req.install or req.uninstall:
3558
+ if not req.slug:
3559
+ raise UsageError("--install/--uninstall need a tone slug", field="slug")
3560
+ await client(aicompose.SaveToneRequest(slug=req.slug, unsave=req.uninstall or None))
3561
+ return Tone(slug=req.slug, installed=req.install)
3562
+
3563
+ if req.new:
3564
+ created = await client(
3565
+ aicompose.CreateToneRequest(
3566
+ title=req.title or "", prompt=req.prompt or "", emoji_id=req.emoji_id
3567
+ )
3568
+ )
3569
+ return _tone(getattr(created, "tone", None) or created)
3570
+
3571
+ if not req.slug:
3572
+ raise UsageError("give a tone slug, or --new", field="slug")
3573
+ updated = await client(
3574
+ aicompose.UpdateToneRequest(
3575
+ slug=req.slug, title=req.title, prompt=req.prompt, emoji_id=req.emoji_id
3576
+ )
3577
+ )
3578
+ return _tone(getattr(updated, "tone", None) or updated)
3579
+
3580
+
3581
+ SPEC_TONE_SET = OperationSpec(
3582
+ id="message.tone.set",
3583
+ request=ToneSetReq,
3584
+ response=Tone,
3585
+ impl=tone_set,
3586
+ summary="Create, edit or install a composition tone",
3587
+ aliases=("ai.tone.set",),
3588
+ mutating=True,
3589
+ columns=("slug", "title", "installed"),
3590
+ example={"slug": "formal", "title": "Formal"},
3591
+ example_args='message tone set --new --title Formal --prompt "Rewrite formally"',
3592
+ covers=("ai.tone-create", "ai.tone-edit", "ai.tone-install"),
3593
+ )
3594
+
3595
+
3596
+ class ToneDeleteReq(Request):
3597
+ slug: Annotated[str, arg(0, metavar="SLUG", help="Tone I authored.")]
3598
+
3599
+
3600
+ async def tone_delete(ctx: OpContext, req: ToneDeleteReq) -> Tone:
3601
+ """Delete a tone I authored. It disappears for everyone who installed it."""
3602
+ from telethon.tl.functions import aicompose
3603
+
3604
+ await _client(ctx)(aicompose.DeleteToneRequest(slug=req.slug))
3605
+ return Tone(slug=req.slug, installed=False)
3606
+
3607
+
3608
+ SPEC_TONE_DELETE = OperationSpec(
3609
+ id="message.tone.delete",
3610
+ request=ToneDeleteReq,
3611
+ response=Tone,
3612
+ impl=tone_delete,
3613
+ summary="Delete a composition tone you authored",
3614
+ aliases=("ai.tone.delete",),
3615
+ mutating=True,
3616
+ destructive=True,
3617
+ columns=("slug",),
3618
+ example={"slug": "formal"},
3619
+ example_args="message tone delete formal",
3620
+ covers_partial=("ai.tone-edit",),
3621
+ coverage_note="The delete half of `ai.tone-edit`; `message tone set` covers the edit half.",
3622
+ )
3623
+
3624
+
3625
+ # ---------------------------------------------------------------------------
3626
+ # games
3627
+ # ---------------------------------------------------------------------------
3628
+
3629
+
3630
+ class GameGetReq(Request):
3631
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
3632
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Game message id.")]
3633
+ user: Annotated[
3634
+ PeerRef | None, opt("--user", metavar="USER", kind="user", help="Only this player.")
3635
+ ] = None
3636
+ url: Annotated[bool, opt("--url", help="Print the HTML5 game URL.")] = False
3637
+
3638
+
3639
+ async def game_get(ctx: OpContext, req: GameGetReq) -> GameInfo:
3640
+ """A game's high-score table.
3641
+
3642
+ Control-only: a CLI can report the scoreboard but cannot render an HTML5
3643
+ game, and `messages.getGameUrl` is a bot method the pinned Telethon does
3644
+ not expose, so --url is refused rather than faked.
3645
+ """
3646
+ from telethon.tl import types
3647
+ from telethon.tl.functions import messages as fn
3648
+
3649
+ if req.url:
3650
+ _send.require_supported(
3651
+ "--url",
3652
+ "messages.getGameUrl is a bot-only method and is not in Telethon 1.44; "
3653
+ "a user account cannot mint a game URL",
3654
+ )
3655
+ peer = await _send.resolve(ctx, req.chat)
3656
+ client = _client(ctx)
3657
+ player = await _send.resolve(ctx, req.user) if req.user is not None else types.InputUserSelf()
3658
+ result = await client(fn.GetGameHighScoresRequest(peer=peer, id=req.msg_id, user_id=player))
3659
+ scores = [
3660
+ GameScore(
3661
+ position=getattr(entry, "pos", None),
3662
+ user_id=getattr(entry, "user_id", None),
3663
+ score=int(getattr(entry, "score", 0) or 0),
3664
+ )
3665
+ for entry in (getattr(result, "scores", None) or [])
3666
+ ]
3667
+ info = GameInfo(scores=scores)
3668
+ found = await _fetch(ctx, peer, chat_id=_send.peer_id_of(peer), limit=1, ids=[req.msg_id])
3669
+ if found and found[0].media is not None:
3670
+ info.title = found[0].media.title or ""
3671
+ return info
3672
+
3673
+
3674
+ SPEC_GAME_GET = OperationSpec(
3675
+ id="message.game.get",
3676
+ request=GameGetReq,
3677
+ response=GameInfo,
3678
+ impl=game_get,
3679
+ summary="A game's high-score table",
3680
+ columns=("title", "short_name"),
3681
+ example={"title": "Corsairs", "short_name": "corsairs", "scores": []},
3682
+ example_args="message game get @gamebot 42",
3683
+ covers=("game.high-scores",),
3684
+ covers_partial=("game.play",),
3685
+ coverage_note="A CLI cannot render an HTML5 game; --url is refused with NOT_SUPPORTED.",
3686
+ )
3687
+
3688
+
3689
+ class GameSendReq(Request):
3690
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
3691
+ bot: Annotated[
3692
+ PeerRef | None, opt("--bot", metavar="USER", kind="user", help="Bot that owns the game.")
3693
+ ] = None
3694
+ short_name: Annotated[str | None, opt("--short-name", help="Game short name.")] = None
3695
+ via_inline: Annotated[
3696
+ bool, opt(help="Relay the game through an inline query (the user-account path).")
3697
+ ] = True
3698
+ reply_to: Annotated[
3699
+ int | None, opt("--reply-to", metavar="ID", kind="msg_id", help="Reply to this id.")
3700
+ ] = None
3701
+ silent: Annotated[bool, opt("--silent", help="No notification.")] = False
3702
+
3703
+
3704
+ async def game_send(ctx: OpContext, req: GameSendReq) -> Message:
3705
+ """Send a bot game into a chat.
3706
+
3707
+ `inputMediaGame` with a short name is a bot path; a user account relays
3708
+ an inline result instead, which is what `--via-inline` does and why it is
3709
+ the default.
3710
+ """
3711
+ from telethon.tl import types
3712
+ from telethon.tl.functions import messages as fn
3713
+
3714
+ if req.bot is None or not req.short_name:
3715
+ raise UsageError("--bot and --short-name are both required", field="bot")
3716
+
3717
+ peer = await _send.resolve(ctx, req.chat)
3718
+ chat_id = _send.peer_id_of(peer)
3719
+ bot = await _send.resolve(ctx, req.bot)
3720
+ client = _client(ctx)
3721
+
3722
+ if req.via_inline:
3723
+ results = await client(
3724
+ fn.GetInlineBotResultsRequest(bot=bot, peer=peer, query=req.short_name, offset="")
3725
+ )
3726
+ found = list(getattr(results, "results", None) or [])
3727
+ if not found:
3728
+ raise NotFoundError(f"the bot returned no inline result for {req.short_name!r}")
3729
+ sent = await client(
3730
+ fn.SendInlineBotResultRequest(
3731
+ peer=peer,
3732
+ random_id=_random_id(),
3733
+ query_id=getattr(results, "query_id", 0),
3734
+ id=str(getattr(found[0], "id", "")),
3735
+ silent=req.silent or None,
3736
+ )
3737
+ )
3738
+ return _send.message_from_updates(sent, chat_id=chat_id)
3739
+
3740
+ sent = await client(
3741
+ fn.SendMediaRequest(
3742
+ peer=peer,
3743
+ media=types.InputMediaGame(
3744
+ id=types.InputGameShortName(bot_id=bot, short_name=req.short_name)
3745
+ ),
3746
+ message="",
3747
+ random_id=_random_id(),
3748
+ silent=req.silent or None,
3749
+ )
3750
+ )
3751
+ return _send.message_from_updates(sent, chat_id=chat_id)
3752
+
3753
+
3754
+ SPEC_GAME_SEND = OperationSpec(
3755
+ id="message.game.send",
3756
+ request=GameSendReq,
3757
+ response=Message,
3758
+ impl=game_send,
3759
+ summary="Send a bot game into a chat",
3760
+ mutating=True,
3761
+ rate_class="send",
3762
+ columns=("id", "chat_id"),
3763
+ example=_EXAMPLE_MESSAGE,
3764
+ example_args="message game send @group --bot @gamebot --short-name corsairs",
3765
+ covers=("game.send",),
3766
+ )
3767
+
3768
+
3769
+ __all__ = sorted(name for name in dir() if name.startswith("SPEC_"))