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/bot.py ADDED
@@ -0,0 +1,4880 @@
1
+ """The `bot` group: talking to bots, and running the ones you own.
2
+
3
+ Two audiences share one noun, and the split matters because it decides which
4
+ account can run a command at all.
5
+
6
+ * **As a user.** `bot get`, `bot start`, `bot command send`, `bot press`,
7
+ `bot permission set`, `bot url-auth get` — everything a person does to a bot
8
+ from a Telegram client. These run on an ordinary account.
9
+ * **As the bot.** `bot answer`, `bot command set`, `bot menu set`,
10
+ `bot api send` — the Bot-API surface, which Telegram serves only to a
11
+ session created from a bot token. On a user session they exit 4 with a
12
+ sentence saying how to add one, rather than surfacing Telegram's own
13
+ `BOT_METHOD_INVALID`.
14
+
15
+ `bot press` is the centre of the group. Telegram has fourteen kinds of button
16
+ and one of them (`buy`) starts a payment, four of them disclose personal data,
17
+ and two need a layer this build does not speak. One dispatcher handles them
18
+ all: it names what it is about to send, refuses the ones that would leak
19
+ without their consent flag, refuses `buy` outright, and returns a typed answer
20
+ saying which kind actually came back.
21
+
22
+ Telethon is imported inside functions, never at module scope (§2.2).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import Annotated, Any
28
+
29
+ from tlgr.core.errors import (
30
+ NotFoundError,
31
+ PermissionError_,
32
+ UsageError,
33
+ )
34
+ from tlgr.core.pagination import PageKind, build_page
35
+ from tlgr.core.timefmt import fmt_dt, to_unix
36
+ from tlgr.models.base import Request
37
+ from tlgr.models.bot import (
38
+ AttachMenuBot,
39
+ BotAccess,
40
+ BotAnswer,
41
+ BotApiResult,
42
+ BotCommand,
43
+ BotCommandSet,
44
+ BotCreated,
45
+ BotEdited,
46
+ BotIds,
47
+ BotInfo,
48
+ BotPermission,
49
+ BotQuery,
50
+ BotRef,
51
+ BotStarted,
52
+ BotStopped,
53
+ BotToken,
54
+ BotUsernameCheck,
55
+ BotUsernames,
56
+ BotVerification,
57
+ BotVerified,
58
+ BotWelcomeMessage,
59
+ BusinessConnection,
60
+ CommandSent,
61
+ DefaultRights,
62
+ EmojiGame,
63
+ EphemeralDeleted,
64
+ EphemeralSent,
65
+ GameSent,
66
+ HighScore,
67
+ MenuButton,
68
+ Pressed,
69
+ PreviewChange,
70
+ PreviewMedia,
71
+ RecentBots,
72
+ ReportOutcome,
73
+ ScoreSet,
74
+ SponsoredRead,
75
+ StarRefProgram,
76
+ StreamProgress,
77
+ ToggledAttachMenu,
78
+ UrlAuth,
79
+ WelcomeDeleted,
80
+ WelcomeSet,
81
+ )
82
+ from tlgr.models.message import SponsoredMessage
83
+ from tlgr.models.page import Page
84
+ from tlgr.models.peer import PeerRef
85
+ from tlgr.ops import _bots, _media, _send
86
+ from tlgr.ops._common import client, window
87
+ from tlgr.ops._params import arg, choice, opt
88
+ from tlgr.ops._spec import OpContext, OperationSpec
89
+
90
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
91
+
92
+ #: Reported as the client platform on every mini-app request. Telegram uses
93
+ #: it to pick the app's own layout; there is no "cli" value it understands.
94
+ PLATFORM = "web"
95
+
96
+ _EXAMPLE_BOT: dict[str, Any] = {
97
+ "id": 93372553,
98
+ "username": "gif",
99
+ "first_name": "GIF",
100
+ "about": "Send GIFs inline",
101
+ "bot_can_edit": False,
102
+ }
103
+
104
+
105
+ async def _full(ctx: OpContext, peer: Any) -> tuple[Any, Any]:
106
+ """`(userFull, user)` for a bot, in one round trip."""
107
+ from telethon import utils
108
+ from telethon.tl.functions import users as fn
109
+
110
+ result = await client(ctx)(fn.GetFullUserRequest(id=utils.get_input_user(peer)))
111
+ full = getattr(result, "full_user", None)
112
+ users = {int(getattr(u, "id", 0)): u for u in (getattr(result, "users", None) or [])}
113
+ user = users.get(int(getattr(full, "id", 0) or 0)) if full is not None else None
114
+ return full, user
115
+
116
+
117
+ def _usernames(user: Any) -> list[str]:
118
+ names = [
119
+ str(getattr(entry, "username", "") or "")
120
+ for entry in (getattr(user, "usernames", None) or [])
121
+ if getattr(entry, "username", None)
122
+ ]
123
+ primary = getattr(user, "username", None)
124
+ if primary and primary not in names:
125
+ names.insert(0, str(primary))
126
+ return names
127
+
128
+
129
+ def _menu_button(button: Any, *, user_id: int | None = None) -> MenuButton | None:
130
+ """`botMenuButton*` as the model.
131
+
132
+ `botMenuButtonDefault` never reaches a user — the server substitutes the
133
+ commands list — so it is normalised to `commands` rather than leaking a
134
+ third state nobody can act on.
135
+ """
136
+ if button is None:
137
+ return None
138
+ name = type(button).__name__
139
+ if name == "BotMenuButton":
140
+ return MenuButton(
141
+ kind="webapp",
142
+ text=getattr(button, "text", None),
143
+ url=getattr(button, "url", None),
144
+ user_id=user_id,
145
+ )
146
+ return MenuButton(kind="commands", user_id=user_id)
147
+
148
+
149
+ def _commands(
150
+ raw: Any, *, bot_id: int, scope: str | None = None, lang: str | None = None
151
+ ) -> list[BotCommand]:
152
+ entries = list(
153
+ (getattr(raw, "commands", None) or []) if hasattr(raw, "commands") else (raw or [])
154
+ )
155
+ names = {str(getattr(c, "command", "") or "") for c in entries}
156
+ return [
157
+ BotCommand(
158
+ bot_id=bot_id,
159
+ command=str(getattr(entry, "command", "") or ""),
160
+ description=str(getattr(entry, "description", "") or ""),
161
+ ephemeral=bool(getattr(entry, "ephemeral", False)),
162
+ scope=scope,
163
+ lang=lang,
164
+ has_help="help" in names,
165
+ has_settings="settings" in names,
166
+ )
167
+ for entry in entries
168
+ ]
169
+
170
+
171
+ def _starref(program: Any) -> StarRefProgram | None:
172
+ if program is None:
173
+ return None
174
+ end = getattr(program, "end_date", None)
175
+ revenue = getattr(program, "daily_revenue_per_user", None)
176
+ return StarRefProgram(
177
+ bot_id=int(getattr(program, "bot_id", 0) or 0),
178
+ url=getattr(program, "url", None),
179
+ commission_permille=int(getattr(program, "commission_permille", 0) or 0),
180
+ duration_months=getattr(program, "duration_months", None),
181
+ end_date=fmt_dt(end),
182
+ end_date_unix=to_unix(end),
183
+ participants=getattr(program, "participants", None),
184
+ revenue=int(getattr(revenue, "amount", 0) or 0) if revenue is not None else None,
185
+ revoked=bool(getattr(program, "revoked", False)),
186
+ )
187
+
188
+
189
+ def _verification(full: Any, user: Any) -> BotVerification | None:
190
+ badge = getattr(full, "bot_verification", None)
191
+ verified = bool(getattr(user, "verified", False))
192
+ if badge is None and not verified:
193
+ return None
194
+ return BotVerification(
195
+ verified_by_bot=int(getattr(badge, "bot_id", 0) or 0) or None,
196
+ description=getattr(badge, "description", None),
197
+ icon=getattr(badge, "icon", None),
198
+ telegram_verified=verified,
199
+ )
200
+
201
+
202
+ def _access(settings: Any) -> BotAccess:
203
+ """`bots.accessSettings` as the model. The allow-list is `add_users`."""
204
+ return BotAccess(
205
+ restricted=bool(getattr(settings, "restricted", False)),
206
+ allowed_users=[
207
+ int(getattr(u, "id", 0) or getattr(u, "user_id", 0) or 0)
208
+ for u in (getattr(settings, "add_users", None) or [])
209
+ ],
210
+ allowed_chats=[
211
+ int(getattr(c, "id", 0) or 0) for c in (getattr(settings, "add_chats", None) or [])
212
+ ],
213
+ )
214
+
215
+
216
+ #: `businessBotRights` is its own flag set, not `chatAdminRights`; the fields
217
+ #: are listed rather than scanned so a new one cannot appear as a right the
218
+ #: bot silently already has.
219
+ BUSINESS_RIGHTS: tuple[str, ...] = (
220
+ "reply",
221
+ "read_messages",
222
+ "delete_sent_messages",
223
+ "delete_received_messages",
224
+ "edit_name",
225
+ "edit_bio",
226
+ "edit_profile_photo",
227
+ "edit_username",
228
+ "view_gifts",
229
+ "sell_gifts",
230
+ "change_gift_settings",
231
+ "transfer_and_upgrade_gifts",
232
+ "transfer_stars",
233
+ "manage_stories",
234
+ )
235
+
236
+
237
+ def _business_rights(rights: Any) -> list[str]:
238
+ if rights is None:
239
+ return []
240
+ return [name for name in BUSINESS_RIGHTS if bool(getattr(rights, name, False))]
241
+
242
+
243
+ # ---------------------------------------------------------------------------
244
+ # bot get
245
+ # ---------------------------------------------------------------------------
246
+
247
+
248
+ class GetReq(Request):
249
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="@username, id or t.me link.")]
250
+ lang: Annotated[
251
+ str | None, opt("--lang", metavar="CODE", help="Localized description (owner view).")
252
+ ] = None
253
+ access: Annotated[bool, opt("--access", help="Also fetch managed-bot access settings.")] = False
254
+ refresh: Annotated[
255
+ bool, opt("--refresh", help="Re-resolve the username instead of trusting the cache.")
256
+ ] = False
257
+
258
+
259
+ async def get(ctx: OpContext, req: GetReq) -> BotInfo:
260
+ """A bot's whole profile card.
261
+
262
+ `bot_info_version` is the only invalidation signal Telegram gives for the
263
+ commands and the description, so it is reported: a caller that caches this
264
+ card has no other way to know when to refetch it.
265
+ """
266
+ from telethon.tl.functions import bots as bots_fn
267
+
268
+ peer = await _resolve_bot(ctx, req.bot, refresh=req.refresh)
269
+ full, user = await _full(ctx, peer)
270
+ info = getattr(full, "bot_info", None)
271
+ bot_id = int(getattr(full, "id", 0) or 0)
272
+
273
+ about = getattr(full, "about", None)
274
+ description = getattr(info, "description", None)
275
+ if req.lang:
276
+ localized = await client(ctx)(
277
+ bots_fn.GetBotInfoRequest(lang_code=req.lang, bot=await _bots.input_user(ctx, req.bot))
278
+ )
279
+ about = getattr(localized, "about", about)
280
+ description = getattr(localized, "description", description)
281
+
282
+ settings = getattr(info, "app_settings", None)
283
+ verifier = getattr(info, "verifier_settings", None)
284
+ card = BotInfo(
285
+ id=bot_id,
286
+ username=getattr(user, "username", None),
287
+ usernames=_usernames(user),
288
+ first_name=getattr(user, "first_name", None),
289
+ about=about,
290
+ description=description,
291
+ description_photo=_id_of(getattr(info, "description_photo", None)),
292
+ description_document=_id_of(getattr(info, "description_document", None)),
293
+ privacy_policy_url=getattr(info, "privacy_policy_url", None),
294
+ commands=_commands(info, bot_id=bot_id, lang=req.lang),
295
+ menu_button=_menu_button(getattr(info, "menu_button", None)),
296
+ app_settings=_app_settings(settings),
297
+ verifier_settings=(
298
+ {
299
+ "icon": getattr(verifier, "icon", None),
300
+ "company": getattr(verifier, "company", None),
301
+ "can_modify_custom_description": bool(
302
+ getattr(verifier, "can_modify_custom_description", False)
303
+ ),
304
+ "custom_description": getattr(verifier, "custom_description", None),
305
+ }
306
+ if verifier is not None
307
+ else None
308
+ ),
309
+ bot_verification=_verification(full, user),
310
+ bot_info_version=getattr(user, "bot_info_version", None),
311
+ bot_active_users=getattr(user, "bot_active_users", None),
312
+ bot_can_edit=bool(getattr(user, "bot_can_edit", False)),
313
+ bot_has_main_app=bool(getattr(user, "bot_has_main_app", False)),
314
+ bot_nochats=bool(getattr(user, "bot_nochats", False)),
315
+ bot_business=bool(getattr(user, "bot_business", False)),
316
+ bot_attach_menu=bool(getattr(user, "bot_attach_menu", False)),
317
+ bot_inline_geo=bool(getattr(user, "bot_inline_geo", False)),
318
+ inline_placeholder=getattr(user, "bot_inline_placeholder", None),
319
+ bot_group_admin_rights=_bots.rights_keywords(getattr(full, "bot_group_admin_rights", None)),
320
+ bot_broadcast_admin_rights=_bots.rights_keywords(
321
+ getattr(full, "bot_broadcast_admin_rights", None)
322
+ ),
323
+ has_preview_medias=bool(getattr(info, "has_preview_medias", False)),
324
+ starref_program=_starref(getattr(full, "starref_program", None)),
325
+ blocked=bool(getattr(full, "blocked", False)),
326
+ lang=req.lang,
327
+ )
328
+ if req.access:
329
+ if not card.bot_can_edit:
330
+ raise PermissionError_("access settings are only readable on a bot you administer")
331
+ settings = await client(ctx)(
332
+ bots_fn.GetAccessSettingsRequest(bot=await _bots.input_user(ctx, req.bot))
333
+ )
334
+ card.access = _access(settings)
335
+ return card
336
+
337
+
338
+ def _id_of(value: Any) -> int | None:
339
+ identifier = getattr(value, "id", None)
340
+ return int(identifier) if isinstance(identifier, int) else None
341
+
342
+
343
+ def _app_settings(settings: Any) -> dict[str, Any] | None:
344
+ if settings is None:
345
+ return None
346
+ path = getattr(settings, "placeholder_path", None)
347
+ return {
348
+ "placeholder_path": len(path) if path else None,
349
+ "bg_color": getattr(settings, "background_color", None),
350
+ "bg_dark_color": getattr(settings, "background_dark_color", None),
351
+ "header_color": getattr(settings, "header_color", None),
352
+ "header_dark_color": getattr(settings, "header_dark_color", None),
353
+ }
354
+
355
+
356
+ async def _resolve_bot(ctx: OpContext, ref: PeerRef, *, refresh: bool = False) -> Any:
357
+ """The bot's `InputPeer`, optionally re-resolving the username first.
358
+
359
+ `contacts.resolveUsername` is what mints the access hash, and a cached one
360
+ can be stale after a bot changes hands; `--refresh` is the escape hatch
361
+ that does not require deleting the peer cache by hand.
362
+ """
363
+ if refresh and ref.kind == "username":
364
+ from telethon.tl.functions import contacts as fn
365
+
366
+ await client(ctx)(fn.ResolveUsernameRequest(username=str(ref.value)))
367
+ return await _send.resolve(ctx, ref)
368
+
369
+
370
+ SPEC_GET = OperationSpec(
371
+ id="bot.get",
372
+ request=GetReq,
373
+ response=BotInfo,
374
+ impl=get,
375
+ summary="Show a bot's profile card",
376
+ description=(
377
+ "Description, about text, commands, menu button, privacy policy, "
378
+ "capability flags, verification badge and mini-app settings, from the "
379
+ "one `users.getFullUser` that carries all of them."
380
+ ),
381
+ aliases=("bot.info",),
382
+ columns=("id", "username", "first_name", "bot_active_users"),
383
+ headers=("ID", "Username", "Name", "Users"),
384
+ example=_EXAMPLE_BOT,
385
+ example_args="bot get @gifbot",
386
+ covers=(
387
+ "bots.bot-info-card",
388
+ "bots.bot-privacy-policy",
389
+ "bots.bot-profile-flags",
390
+ "bots.resolve-bot",
391
+ ),
392
+ covers_partial=(
393
+ "bots.bot-verification-view",
394
+ "bots.menu-button-state",
395
+ "bots.suggested-admin-rights",
396
+ "bots.webapp-placeholder-and-close",
397
+ ),
398
+ coverage_note=(
399
+ "The card shows the menu button, the suggested admin rights, the "
400
+ "verification badge and the mini-app placeholder; setting them is "
401
+ "`bot menu set`, `bot default-rights set`, `bot verification set` and "
402
+ "`webapp get`."
403
+ ),
404
+ )
405
+
406
+
407
+ # ---------------------------------------------------------------------------
408
+ # bot list
409
+ # ---------------------------------------------------------------------------
410
+
411
+
412
+ class ListReq(Request):
413
+ owned: Annotated[bool, opt("--owned", help="Bots I own or administer (default).")] = True
414
+ similar_to: Annotated[
415
+ PeerRef | None,
416
+ opt("--similar-to", metavar="BOT", kind="user", help="Bots recommended next to this bot."),
417
+ ] = None
418
+ popular_apps: Annotated[bool, opt("--popular-apps", help="The Mini App store list.")] = False
419
+ recent: Annotated[bool, opt("--recent", help="Frequently-used bots (top peers).")] = False
420
+ kind: Annotated[
421
+ str, choice("pm", "inline", "app", "guest", help="Top-peer category for --recent.")
422
+ ] = "pm"
423
+
424
+
425
+ _TOP_PEER_FLAGS = {
426
+ "pm": "bots_pm",
427
+ "inline": "bots_inline",
428
+ "app": "bots_app",
429
+ "guest": "bots_guestchat",
430
+ }
431
+
432
+
433
+ async def list_bots(ctx: OpContext, req: ListReq) -> Page[BotRef]:
434
+ """Bots, from whichever of the four listings the flags name.
435
+
436
+ They share a command because they answer one question — "which bots?" —
437
+ and differ only in where the answer comes from.
438
+ """
439
+ from telethon.tl.functions import bots as fn
440
+ from telethon.tl.functions import contacts as contacts_fn
441
+
442
+ limit, state = window(ctx, "bot.list", PageKind.RATE, default=50)
443
+ handle = client(ctx)
444
+
445
+ if req.similar_to is not None:
446
+ result = await handle(
447
+ fn.GetBotRecommendationsRequest(bot=await _bots.input_user(ctx, req.similar_to))
448
+ )
449
+ truncated = getattr(result, "count", None)
450
+ items = [
451
+ _bot_ref(user, kind="similar", truncated=truncated)
452
+ for user in (getattr(result, "users", None) or [])
453
+ ]
454
+ return build_page(items[:limit], op="bot.list", kind=PageKind.RATE, has_more=False)
455
+
456
+ if req.popular_apps:
457
+ offset = str(state.get("offset", "") or "")
458
+ result = await handle(fn.GetPopularAppBotsRequest(offset=offset, limit=limit))
459
+ items = [_bot_ref(user, kind="app") for user in (getattr(result, "users", None) or [])]
460
+ next_offset = str(getattr(result, "next_offset", "") or "")
461
+ return build_page(
462
+ items,
463
+ op="bot.list",
464
+ kind=PageKind.RATE,
465
+ state={"offset": next_offset},
466
+ account=ctx.account,
467
+ has_more=bool(next_offset),
468
+ )
469
+
470
+ if req.recent:
471
+ flag = _TOP_PEER_FLAGS[req.kind]
472
+ result = await handle(
473
+ contacts_fn.GetTopPeersRequest(offset=0, limit=limit, hash=0, **{flag: True})
474
+ )
475
+ if type(result).__name__ == "TopPeersDisabled":
476
+ ctx.warn("frequently-used suggestions are switched off for this account")
477
+ return Page(items=[], has_more=False, total=0)
478
+ users = {int(getattr(u, "id", 0)): u for u in (getattr(result, "users", None) or [])}
479
+ items = []
480
+ for category in getattr(result, "categories", None) or []:
481
+ for entry in getattr(category, "peers", None) or []:
482
+ user = users.get(int(getattr(getattr(entry, "peer", None), "user_id", 0) or 0))
483
+ if user is not None:
484
+ items.append(
485
+ _bot_ref(user, kind=req.kind, rating=getattr(entry, "rating", None))
486
+ )
487
+ return build_page(items[:limit], op="bot.list", kind=PageKind.RATE, has_more=False)
488
+
489
+ result = await handle(fn.GetAdminedBotsRequest())
490
+ items = [_bot_ref(user, kind="owned") for user in (result or [])]
491
+ return build_page(items[:limit], op="bot.list", kind=PageKind.RATE, has_more=False)
492
+
493
+
494
+ def _bot_ref(
495
+ user: Any, *, kind: str, truncated: int | None = None, rating: float | None = None
496
+ ) -> BotRef:
497
+ first = str(getattr(user, "first_name", "") or "")
498
+ last = str(getattr(user, "last_name", "") or "")
499
+ return BotRef(
500
+ id=int(getattr(user, "id", 0) or 0),
501
+ username=getattr(user, "username", None),
502
+ title=f"{first} {last}".strip() or None,
503
+ kind=kind,
504
+ active_users=getattr(user, "bot_active_users", None),
505
+ truncated_count=truncated,
506
+ rating=float(rating) if rating is not None else None,
507
+ )
508
+
509
+
510
+ SPEC_LIST = OperationSpec(
511
+ id="bot.list",
512
+ request=ListReq,
513
+ response=Page[BotRef],
514
+ impl=list_bots,
515
+ summary="List bots I own, similar bots, popular mini apps or my recent bots",
516
+ description=(
517
+ "A non-Premium account gets a shortened `--similar-to` list plus the "
518
+ "real count, which is reported as `truncated_count` rather than "
519
+ "silently looking like the whole answer."
520
+ ),
521
+ aliases=("bot.mine",),
522
+ paginated=PageKind.RATE,
523
+ columns=("id", "username", "title", "kind"),
524
+ headers=("ID", "Username", "Title", "Kind"),
525
+ example={"items": [{"id": 93372553, "username": "gif", "kind": "owned"}], "has_more": False},
526
+ example_args="bot list --owned",
527
+ covers=(
528
+ "bots.guest-mode-invoke",
529
+ "bots.list-owned-bots",
530
+ "bots.popular-app-bots",
531
+ "bots.similar-bots",
532
+ ),
533
+ covers_partial=("bots.top-peers-bots",),
534
+ coverage_note="Turning the frequently-used list on or off is `bot recent set`.",
535
+ )
536
+
537
+
538
+ # ---------------------------------------------------------------------------
539
+ # bot id get
540
+ # ---------------------------------------------------------------------------
541
+
542
+
543
+ class IdReq(Request):
544
+ chat: Annotated[
545
+ PeerRef, arg(0, metavar="CHAT", kind="peer", help="@username, MTProto id or Bot-API id.")
546
+ ]
547
+
548
+
549
+ async def id_get(ctx: OpContext, req: IdReq) -> BotIds:
550
+ """Convert between MTProto peer ids and HTTP Bot-API chat ids.
551
+
552
+ Pure arithmetic when the ref is already an id: a Bot-API id marks channels
553
+ with `-100…` and basic groups with a plain negative number, and getting
554
+ that conversion wrong is how a script posts into the wrong chat.
555
+ """
556
+ from telethon import utils
557
+
558
+ if req.chat.kind == "id":
559
+ marked = int(req.chat.value)
560
+ _raw, kind = utils.resolve_id(marked)
561
+ return BotIds(
562
+ mtproto_id=marked,
563
+ bot_api_id=marked,
564
+ kind=_KIND_NAMES.get(kind.__name__, "user"),
565
+ has_access_hash=False,
566
+ )
567
+
568
+ peer = await _send.resolve(ctx, req.chat)
569
+ marked = int(utils.get_peer_id(peer))
570
+ _raw, kind = utils.resolve_id(marked)
571
+ return BotIds(
572
+ mtproto_id=marked,
573
+ bot_api_id=marked,
574
+ kind=_KIND_NAMES.get(kind.__name__, "user"),
575
+ has_access_hash=getattr(peer, "access_hash", None) is not None,
576
+ username=str(req.chat.value) if req.chat.kind == "username" else None,
577
+ )
578
+
579
+
580
+ _KIND_NAMES = {"PeerUser": "user", "PeerChat": "group", "PeerChannel": "channel"}
581
+
582
+
583
+ SPEC_ID_GET = OperationSpec(
584
+ id="bot.id.get",
585
+ request=IdReq,
586
+ response=BotIds,
587
+ impl=id_get,
588
+ summary="Convert between MTProto peer ids and Bot-API chat ids",
589
+ description=(
590
+ "tlgr prints marked ids everywhere (COR-10), which is the same "
591
+ "dialect the HTTP Bot API uses; this command says so out loud and "
592
+ "reports whether an access hash is cached for the peer."
593
+ ),
594
+ columns=("mtproto_id", "bot_api_id", "kind"),
595
+ headers=("MTProto", "Bot API", "Kind"),
596
+ example={"mtproto_id": -1001234567890, "bot_api_id": -1001234567890, "kind": "channel"},
597
+ example_args="bot id get @durov",
598
+ covers=("bots.bot-api-dialog-ids",),
599
+ )
600
+
601
+
602
+ # ---------------------------------------------------------------------------
603
+ # bot start / stop
604
+ # ---------------------------------------------------------------------------
605
+
606
+
607
+ class StartReq(Request):
608
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot to start.")]
609
+ param: Annotated[str | None, opt("--param", metavar="TEXT", help="Hidden start parameter.")] = (
610
+ None
611
+ )
612
+ referrer: Annotated[
613
+ str | None, opt("--referrer", metavar="TEXT", help="Referral/affiliate start parameter.")
614
+ ] = None
615
+ chat: Annotated[
616
+ PeerRef | None,
617
+ opt("--chat", metavar="CHAT", kind="peer", help="Start the bot inside this group."),
618
+ ] = None
619
+ channel: Annotated[
620
+ PeerRef | None,
621
+ opt("--channel", metavar="CHAT", kind="peer", help="Add the bot to this channel."),
622
+ ] = None
623
+ admin: Annotated[
624
+ str | None,
625
+ opt("--admin", metavar="RIGHTS", help="'+'-joined admin rights to grant."),
626
+ ] = None
627
+ add: Annotated[bool, opt("--add", help="Add the bot to the chat if it is not a member.")] = (
628
+ False
629
+ )
630
+ restart: Annotated[bool, opt("--restart", help="Unblock the bot before starting it.")] = False
631
+
632
+
633
+ async def start(ctx: OpContext, req: StartReq) -> BotStarted:
634
+ """Send `/start`, optionally with a hidden parameter and inside a chat.
635
+
636
+ `messages.startBot` is the only way to send a start parameter the user
637
+ never sees, which is what a deep link is; typing `/start payload` puts the
638
+ payload in the history for everyone in the chat to read.
639
+ """
640
+ from telethon.tl.functions import channels as channels_fn
641
+ from telethon.tl.functions import contacts as contacts_fn
642
+ from telethon.tl.functions import messages as fn
643
+
644
+ handle = client(ctx)
645
+ if req.referrer and req.bot.kind == "username":
646
+ await handle(
647
+ contacts_fn.ResolveUsernameRequest(username=str(req.bot.value), referer=req.referrer)
648
+ )
649
+ peer = await _send.resolve(ctx, req.bot)
650
+ bot = await _bots.input_user(ctx, req.bot)
651
+ full, user = await _full(ctx, peer)
652
+ bot_id = int(getattr(full, "id", 0) or 0)
653
+
654
+ unblocked = False
655
+ if req.restart and getattr(full, "blocked", False):
656
+ await handle(contacts_fn.UnblockRequest(id=peer))
657
+ unblocked = True
658
+
659
+ target: Any = peer
660
+ rights: list[str] = []
661
+ if req.chat is not None or req.channel is not None:
662
+ if bool(getattr(user, "bot_nochats", False)):
663
+ raise PermissionError_("this bot refuses to be added to groups (BOT_GROUPS_BLOCKED)")
664
+ where = req.chat if req.chat is not None else req.channel
665
+ target = await _send.resolve(ctx, where)
666
+ if req.add:
667
+ await _add_to_chat(ctx, target, bot)
668
+ granted = _bots.admin_rights(req.admin) or (
669
+ getattr(full, "bot_group_admin_rights", None)
670
+ if req.chat is not None
671
+ else getattr(full, "bot_broadcast_admin_rights", None)
672
+ )
673
+ if granted is not None and (req.admin or req.channel is not None):
674
+ await handle(
675
+ channels_fn.EditAdminRequest(
676
+ channel=_input_channel(target),
677
+ user_id=bot,
678
+ admin_rights=granted,
679
+ rank="",
680
+ )
681
+ )
682
+ rights = _bots.rights_keywords(granted)
683
+
684
+ updates = await handle(
685
+ fn.StartBotRequest(bot=bot, peer=target, start_param=req.param or req.referrer or "")
686
+ )
687
+ message = _send.message_from_updates(updates, chat_id=_send.peer_id_of(target))
688
+ ctx.emit("bot_start", {"bot_id": bot_id, "chat_id": message.chat_id})
689
+ return BotStarted(
690
+ bot_id=bot_id,
691
+ chat_id=message.chat_id,
692
+ msg_id=message.id,
693
+ start_param=req.param or req.referrer,
694
+ admin_rights=rights,
695
+ unblocked=unblocked,
696
+ )
697
+
698
+
699
+ def _input_channel(peer: Any) -> Any:
700
+ from tlgr.ops._common import input_channel
701
+
702
+ return input_channel(peer)
703
+
704
+
705
+ async def _add_to_chat(ctx: OpContext, peer: Any, bot: Any) -> None:
706
+ """Invite the bot, tolerating "already a member"."""
707
+ from telethon.tl.functions import channels as channels_fn
708
+ from telethon.tl.functions import messages as fn
709
+
710
+ handle = client(ctx)
711
+ try:
712
+ if type(peer).__name__ == "InputPeerChat":
713
+ await handle(
714
+ fn.AddChatUserRequest(chat_id=getattr(peer, "chat_id", 0), user_id=bot, fwd_limit=0)
715
+ )
716
+ else:
717
+ await handle(
718
+ channels_fn.InviteToChannelRequest(channel=_input_channel(peer), users=[bot])
719
+ )
720
+ except Exception as exc: # the server's own "already a member" is not a failure
721
+ if "ALREADY" not in f"{type(exc).__name__} {exc}".upper().replace("_", ""):
722
+ raise
723
+
724
+
725
+ SPEC_START = OperationSpec(
726
+ id="bot.start",
727
+ request=StartReq,
728
+ response=BotStarted,
729
+ impl=start,
730
+ summary="Start a bot, with a deep-link parameter or inside a group",
731
+ tags=frozenset({"visible-to-others"}),
732
+ description=(
733
+ "`--param` is the payload behind a `t.me/<bot>?start=…` link and is "
734
+ "never written into the chat, which is the whole point of a deep "
735
+ "link. `--referrer` additionally re-resolves the username with the "
736
+ "referral attached, because the attribution happens at resolve time."
737
+ ),
738
+ aliases=("bot.restart",),
739
+ mutating=True,
740
+ rate_class="send",
741
+ columns=("bot_id", "chat_id", "msg_id"),
742
+ headers=("Bot", "Chat", "Message"),
743
+ example={"bot_id": 93372553, "chat_id": 93372553, "msg_id": 12},
744
+ example_args="bot start @gifbot",
745
+ covers=(
746
+ "bots.inline-switch-pm",
747
+ "bots.referral-link-import",
748
+ "bots.restart-bot",
749
+ "bots.start-in-channel",
750
+ "bots.start-in-group",
751
+ "bots.start-in-group-as-admin",
752
+ "bots.start-private",
753
+ "bots.start-with-deeplink-param",
754
+ ),
755
+ )
756
+
757
+
758
+ class StopReq(Request):
759
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot to block.")]
760
+ delete_chat: Annotated[bool, opt("--delete-chat", help="Also delete the chat history.")] = False
761
+ report: Annotated[bool, opt("--report", help="Report the bot as spam while blocking.")] = False
762
+
763
+
764
+ async def stop(ctx: OpContext, req: StopReq) -> BotStopped:
765
+ """Block a bot, and optionally delete the whole conversation.
766
+
767
+ `deleteHistory` answers with an `AffectedHistory` carrying an offset to
768
+ resume from; calling it once and reporting success is how v1-shaped code
769
+ deletes the first hundred messages and leaves the rest.
770
+ """
771
+ from telethon.tl.functions import contacts as contacts_fn
772
+ from telethon.tl.functions import messages as fn
773
+
774
+ handle = client(ctx)
775
+ peer = await _send.resolve(ctx, req.bot)
776
+ bot_id = _send.peer_id_of(peer)
777
+ if req.report:
778
+ await handle(fn.ReportSpamRequest(peer=peer))
779
+ await handle(contacts_fn.BlockRequest(id=peer))
780
+
781
+ deleted = 0
782
+ if req.delete_chat:
783
+ from tlgr.ops._common import affected_loop
784
+
785
+ deleted = await affected_loop(
786
+ ctx,
787
+ lambda offset: fn.DeleteHistoryRequest(peer=peer, max_id=0, revoke=False),
788
+ )
789
+ ctx.emit("bot_stop", {"bot_id": bot_id})
790
+ return BotStopped(bot_id=bot_id, blocked=True, history_deleted=deleted)
791
+
792
+
793
+ SPEC_STOP = OperationSpec(
794
+ id="bot.stop",
795
+ request=StopReq,
796
+ response=BotStopped,
797
+ impl=stop,
798
+ summary="Stop and block a bot",
799
+ mutating=True,
800
+ destructive=True,
801
+ columns=("bot_id", "blocked", "history_deleted"),
802
+ headers=("Bot", "Blocked", "Deleted"),
803
+ example={"bot_id": 93372553, "blocked": True, "history_deleted": 0},
804
+ example_args="bot stop @gifbot",
805
+ covers=("bots.delete-bot-chat-and-block", "bots.stop-bot", "dialogs.bot-stop-restart"),
806
+ )
807
+
808
+
809
+ # ---------------------------------------------------------------------------
810
+ # bot command list / send / set
811
+ # ---------------------------------------------------------------------------
812
+
813
+
814
+ class CommandListReq(Request):
815
+ bot: Annotated[
816
+ PeerRef | None,
817
+ arg(0, metavar="BOT", required=False, kind="user", help="The bot."),
818
+ ] = None
819
+ chat: Annotated[
820
+ PeerRef | None,
821
+ opt("--chat", metavar="CHAT", kind="peer", help="Every bot's commands in this chat."),
822
+ ] = None
823
+ scope: Annotated[
824
+ str | None,
825
+ choice(
826
+ "default",
827
+ "users",
828
+ "chats",
829
+ "chat-admins",
830
+ "peer",
831
+ "peer-admins",
832
+ "peer-user",
833
+ help="Bot-side scope to read back (bot session).",
834
+ ),
835
+ ] = None
836
+ peer: Annotated[
837
+ PeerRef | None, opt("--peer", metavar="CHAT", kind="peer", help="Peer for a peer* scope.")
838
+ ] = None
839
+ user: Annotated[
840
+ PeerRef | None,
841
+ opt("--user", metavar="USER", kind="user", help="User for the peer-user scope."),
842
+ ] = None
843
+ lang: Annotated[str | None, opt("--lang", metavar="CODE", help="Language code.")] = None
844
+
845
+
846
+ async def command_list(ctx: OpContext, req: CommandListReq) -> Page[BotCommand]:
847
+ """A bot's slash commands, from whichever side is asking.
848
+
849
+ `has_help`/`has_settings` decide whether a GUI shows its "Bot Help" and
850
+ "Bot Settings" entries, so they are computed from the list rather than
851
+ assumed: a bot without `/help` must not get a menu item that does nothing.
852
+ """
853
+ from telethon.tl.functions import bots as fn
854
+
855
+ if req.scope is not None:
856
+ await _bots.require_bot_session(ctx, "reading back your own command list")
857
+ scope = await _bots.command_scope(ctx, req.scope, req.peer, req.user)
858
+ result = await client(ctx)(fn.GetBotCommandsRequest(scope=scope, lang_code=req.lang or ""))
859
+ me = await client(ctx).get_me()
860
+ items = _commands(
861
+ result, bot_id=int(getattr(me, "id", 0) or 0), scope=req.scope, lang=req.lang
862
+ )
863
+ return Page(items=items, has_more=False, total=len(items))
864
+
865
+ if req.chat is not None and req.bot is None:
866
+ return await _chat_commands(ctx, req.chat)
867
+
868
+ if req.bot is None:
869
+ raise UsageError("name a bot, or use --chat to list every bot in a chat", field="bot")
870
+ peer = await _send.resolve(ctx, req.bot)
871
+ full, _user = await _full(ctx, peer)
872
+ info = getattr(full, "bot_info", None)
873
+ items = _commands(info, bot_id=int(getattr(full, "id", 0) or 0), lang=req.lang)
874
+ return Page(items=items, has_more=False, total=len(items))
875
+
876
+
877
+ async def _chat_commands(ctx: OpContext, chat: PeerRef) -> Page[BotCommand]:
878
+ """Every bot's commands in one chat, out of the chat's full info."""
879
+ from telethon.tl.functions import channels as channels_fn
880
+ from telethon.tl.functions import messages as fn
881
+
882
+ peer = await _send.resolve(ctx, chat)
883
+ if type(peer).__name__ == "InputPeerChat":
884
+ result = await client(ctx)(fn.GetFullChatRequest(chat_id=getattr(peer, "chat_id", 0)))
885
+ else:
886
+ result = await client(ctx)(channels_fn.GetFullChannelRequest(channel=_input_channel(peer)))
887
+ full = getattr(result, "full_chat", None)
888
+ items: list[BotCommand] = []
889
+ for info in getattr(full, "bot_info", None) or []:
890
+ items += _commands(info, bot_id=int(getattr(info, "user_id", 0) or 0))
891
+ return Page(items=items, has_more=False, total=len(items))
892
+
893
+
894
+ SPEC_COMMAND_LIST = OperationSpec(
895
+ id="bot.command.list",
896
+ request=CommandListReq,
897
+ response=Page[BotCommand],
898
+ impl=command_list,
899
+ summary="List a bot's slash commands",
900
+ description=(
901
+ "A user reads them out of `botInfo`; a bot session reads its own back "
902
+ "per scope with `bots.getBotCommands`, which is the only way to see "
903
+ "what a scope actually holds."
904
+ ),
905
+ aliases=("bot.commands",),
906
+ columns=("bot_id", "command", "description"),
907
+ headers=("Bot", "Command", "Description"),
908
+ example={
909
+ "items": [{"bot_id": 93372553, "command": "start", "description": "Start the bot"}],
910
+ "has_more": False,
911
+ },
912
+ example_args="bot command list @gifbot",
913
+ covers=(
914
+ "bots.bot-help-settings-shortcuts",
915
+ "bots.get-my-bot-commands",
916
+ "bots.list-commands",
917
+ ),
918
+ )
919
+
920
+
921
+ class CommandSendReq(Request):
922
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot addressed.")]
923
+ command: Annotated[str, arg(1, metavar="COMMAND", help="The command, with or without '/'.")]
924
+ args: Annotated[
925
+ list[str],
926
+ arg(2, metavar="ARGS", required=False, variadic=True, help="Arguments appended after it."),
927
+ ] = []
928
+ chat: Annotated[
929
+ PeerRef | None,
930
+ opt("--chat", metavar="CHAT", kind="peer", help="Send it in this chat instead."),
931
+ ] = None
932
+ guest: Annotated[
933
+ bool, opt("--guest", help="Address the bot in guest mode by mentioning it.")
934
+ ] = False
935
+ topic: Annotated[
936
+ int | None, opt("--topic", metavar="ID", kind="msg_id", help="Forum topic id.")
937
+ ] = None
938
+ reply_to: Annotated[
939
+ int | None, opt("--reply-to", metavar="ID", kind="msg_id", help="Reply to this message.")
940
+ ] = None
941
+ silent: Annotated[bool, opt("--silent", help="Send without a notification.")] = False
942
+ business_connection: Annotated[
943
+ str | None,
944
+ opt(
945
+ "--business-connection", metavar="ID", help="Send as a business account (bot session)."
946
+ ),
947
+ ] = None
948
+
949
+
950
+ async def command_send(ctx: OpContext, req: CommandSendReq) -> CommandSent:
951
+ """Send `/command` to a bot, in its chat or in a group.
952
+
953
+ In a group with more than one bot the command MUST carry `@botusername`
954
+ or every bot ignores it, so tlgr appends it whenever the destination is
955
+ not the bot's own private chat. Guest mode has no method of its own: the
956
+ trigger is an ordinary message that mentions the bot.
957
+ """
958
+ from telethon.tl.functions import messages as fn
959
+
960
+ bot_peer = await _send.resolve(ctx, req.bot)
961
+ target = await _send.resolve(ctx, req.chat) if req.chat is not None else bot_peer
962
+ in_private = req.chat is None or _send.peer_id_of(target) == _send.peer_id_of(bot_peer)
963
+
964
+ username = str(req.bot.value) if req.bot.kind == "username" else ""
965
+ if not username and not in_private:
966
+ _full_user, user = await _full(ctx, bot_peer)
967
+ username = str(getattr(user, "username", "") or "")
968
+ name = req.command.lstrip("/")
969
+ if not in_private and username:
970
+ name = f"{name}@{username}"
971
+ text = " ".join([f"/{name}", *req.args]).strip()
972
+ if req.guest and username:
973
+ text = f"@{username} {text}"
974
+
975
+ reply_to = await _send.reply_target(ctx, reply_to=req.reply_to, topic=req.topic)
976
+ request = fn.SendMessageRequest(
977
+ peer=target,
978
+ message=text,
979
+ random_id=_random_id(),
980
+ reply_to=reply_to,
981
+ silent=req.silent or None,
982
+ )
983
+ updates = await _invoke_as(ctx, req.business_connection, request)
984
+ message = _send.message_from_updates(updates, chat_id=_send.peer_id_of(target), sent_text=text)
985
+ ctx.emit("bot_command", {"chat_id": message.chat_id, "text": text})
986
+ return CommandSent(
987
+ chat_id=message.chat_id, msg_id=message.id, text=text, via_bot=username or None
988
+ )
989
+
990
+
991
+ def _random_id() -> int:
992
+ from tlgr.ops._common import random_id
993
+
994
+ return random_id()
995
+
996
+
997
+ async def _invoke_as(ctx: OpContext, connection_id: str | None, request: Any) -> Any:
998
+ """Send *request*, wrapped in a business connection when one is named.
999
+
1000
+ The wrapper is not a header: the query has to reach the connection's own
1001
+ DC, which is why the connection is looked up first.
1002
+ """
1003
+ handle = client(ctx)
1004
+ if not connection_id:
1005
+ return await handle(request)
1006
+ await _bots.require_bot_session(ctx, "--business-connection")
1007
+ from telethon.tl.functions import InvokeWithBusinessConnectionRequest
1008
+ from telethon.tl.functions import account as account_fn
1009
+
1010
+ connection = await handle(
1011
+ account_fn.GetBotBusinessConnectionRequest(connection_id=connection_id)
1012
+ )
1013
+ dc_id = _connection_dc(connection)
1014
+ return await _bots.on_dc(
1015
+ ctx,
1016
+ dc_id,
1017
+ InvokeWithBusinessConnectionRequest(connection_id=connection_id, query=request),
1018
+ )
1019
+
1020
+
1021
+ def _connection_dc(result: Any) -> int:
1022
+ for update in getattr(result, "updates", None) or []:
1023
+ connection = getattr(update, "connection", None)
1024
+ if connection is not None:
1025
+ return int(getattr(connection, "dc_id", 0) or 0)
1026
+ return int(getattr(result, "dc_id", 0) or 0)
1027
+
1028
+
1029
+ SPEC_COMMAND_SEND = OperationSpec(
1030
+ id="bot.command.send",
1031
+ request=CommandSendReq,
1032
+ response=CommandSent,
1033
+ impl=command_send,
1034
+ summary="Send a slash command to a bot",
1035
+ tags=frozenset({"visible-to-others"}),
1036
+ description=(
1037
+ "Driving @BotFather's own conversation with this command and "
1038
+ "`bot press` is the only way to reach the toggles Telegram exposes "
1039
+ "nowhere else — group privacy mode and bot-to-bot mode."
1040
+ ),
1041
+ aliases=("bot.cmd",),
1042
+ mutating=True,
1043
+ rate_class="send",
1044
+ columns=("chat_id", "msg_id", "text"),
1045
+ headers=("Chat", "Message", "Text"),
1046
+ example={"chat_id": 93372553, "msg_id": 12, "text": "/start"},
1047
+ example_args="bot command send @gifbot start",
1048
+ covers=("bots.bot-privacy-mode", "bots.bot-to-bot-messaging", "bots.send-command"),
1049
+ covers_partial=("bots.bot-help-settings-shortcuts", "bots.guest-mode-invoke"),
1050
+ coverage_note=(
1051
+ "Whether a bot declares /help and /settings is reported by "
1052
+ "`bot command list`; the guest-mode bot listing is `bot list --recent "
1053
+ "--kind guest`."
1054
+ ),
1055
+ )
1056
+
1057
+
1058
+ class CommandSetReq(Request):
1059
+ commands: Annotated[
1060
+ str | None,
1061
+ arg(0, metavar="COMMANDS", required=False, help="'start:Start,help:Show help'."),
1062
+ ] = None
1063
+ scope: Annotated[
1064
+ str,
1065
+ choice(
1066
+ "default",
1067
+ "users",
1068
+ "chats",
1069
+ "chat-admins",
1070
+ "peer",
1071
+ "peer-admins",
1072
+ "peer-user",
1073
+ help="Command scope.",
1074
+ ),
1075
+ ] = "default"
1076
+ peer: Annotated[
1077
+ PeerRef | None, opt("--peer", metavar="CHAT", kind="peer", help="Peer for a peer* scope.")
1078
+ ] = None
1079
+ user: Annotated[
1080
+ PeerRef | None,
1081
+ opt("--user", metavar="USER", kind="user", help="User for the peer-user scope."),
1082
+ ] = None
1083
+ lang: Annotated[str, opt("--lang", metavar="CODE", help="Language code.")] = ""
1084
+ file: Annotated[
1085
+ str | None,
1086
+ opt("--file", metavar="PATH", kind="path", help="Read the list from a JSON file."),
1087
+ ] = None
1088
+ clear: Annotated[bool, opt("--clear", help="Reset the list for this scope.")] = False
1089
+
1090
+
1091
+ async def command_set(ctx: OpContext, req: CommandSetReq) -> BotCommandSet:
1092
+ """Publish (or clear) my bot's command list for one scope and language.
1093
+
1094
+ A human owner does this through @BotFather; the method itself is bot-only,
1095
+ which is why the session is checked before the request is built.
1096
+ """
1097
+ from telethon.tl import types
1098
+ from telethon.tl.functions import bots as fn
1099
+
1100
+ await _bots.require_bot_session(ctx, "setting your bot's command list")
1101
+ scope = await _bots.command_scope(ctx, req.scope, req.peer, req.user)
1102
+ handle = client(ctx)
1103
+
1104
+ if req.clear:
1105
+ await handle(fn.ResetBotCommandsRequest(scope=scope, lang_code=req.lang))
1106
+ return BotCommandSet(scope=req.scope, lang=req.lang, cleared=True)
1107
+
1108
+ pairs = _command_pairs(req)
1109
+ await handle(
1110
+ fn.SetBotCommandsRequest(
1111
+ scope=scope,
1112
+ lang_code=req.lang,
1113
+ commands=[
1114
+ types.BotCommand(command=name, description=description)
1115
+ for name, description in pairs
1116
+ ],
1117
+ )
1118
+ )
1119
+ return BotCommandSet(
1120
+ scope=req.scope,
1121
+ lang=req.lang,
1122
+ commands=[
1123
+ BotCommand(command=name, description=description, scope=req.scope, lang=req.lang)
1124
+ for name, description in pairs
1125
+ ],
1126
+ )
1127
+
1128
+
1129
+ def _command_pairs(req: CommandSetReq) -> list[tuple[str, str]]:
1130
+ if req.file:
1131
+ loaded = _bots.load_json(req.file, field="file")
1132
+ if not isinstance(loaded, list):
1133
+ raise UsageError("--file: expected a JSON list of commands", field="file")
1134
+ return [
1135
+ (str(entry.get("command", "")).lstrip("/"), str(entry.get("description", "")))
1136
+ for entry in loaded
1137
+ ]
1138
+ if not req.commands:
1139
+ raise UsageError("give a command list, --file or --clear", field="commands")
1140
+ pairs: list[tuple[str, str]] = []
1141
+ for chunk in req.commands.split(","):
1142
+ name, _, description = chunk.partition(":")
1143
+ if not name.strip():
1144
+ continue
1145
+ pairs.append((name.strip().lstrip("/"), description.strip()))
1146
+ return pairs
1147
+
1148
+
1149
+ SPEC_COMMAND_SET = OperationSpec(
1150
+ id="bot.command.set",
1151
+ request=CommandSetReq,
1152
+ response=BotCommandSet,
1153
+ impl=command_set,
1154
+ summary="Set or clear my bot's command list for one scope",
1155
+ mutating=True,
1156
+ columns=("scope", "lang", "cleared"),
1157
+ headers=("Scope", "Lang", "Cleared"),
1158
+ example={
1159
+ "scope": "default",
1160
+ "lang": "",
1161
+ "commands": [{"command": "start", "description": "Start the bot"}],
1162
+ },
1163
+ example_args='bot command set "start:Start the bot"',
1164
+ covers=("bots.reset-my-bot-commands", "bots.set-my-bot-commands"),
1165
+ )
1166
+
1167
+
1168
+ # ---------------------------------------------------------------------------
1169
+ # bot menu get / set
1170
+ # ---------------------------------------------------------------------------
1171
+
1172
+
1173
+ class MenuGetReq(Request):
1174
+ bot: Annotated[
1175
+ PeerRef | None, arg(0, metavar="BOT", required=False, kind="user", help="The bot.")
1176
+ ] = None
1177
+ user: Annotated[
1178
+ PeerRef | None,
1179
+ opt("--user", metavar="USER", kind="user", help="Per-user override (bot session)."),
1180
+ ] = None
1181
+
1182
+
1183
+ async def menu_get(ctx: OpContext, req: MenuGetReq) -> MenuButton:
1184
+ """The button left of the message input."""
1185
+ from telethon.tl.functions import bots as fn
1186
+
1187
+ if req.user is not None:
1188
+ await _bots.require_bot_session(ctx, "reading a per-user menu button")
1189
+ button = await client(ctx)(
1190
+ fn.GetBotMenuButtonRequest(user_id=await _bots.input_user(ctx, req.user, field="user"))
1191
+ )
1192
+ return _menu_button(
1193
+ button, user_id=_send.peer_id_of(await _send.resolve(ctx, req.user))
1194
+ ) or (MenuButton())
1195
+ if req.bot is None:
1196
+ raise UsageError("name a bot, or use --user on a bot session", field="bot")
1197
+ peer = await _send.resolve(ctx, req.bot)
1198
+ full, _user = await _full(ctx, peer)
1199
+ info = getattr(full, "bot_info", None)
1200
+ return _menu_button(getattr(info, "menu_button", None)) or MenuButton(kind="commands")
1201
+
1202
+
1203
+ SPEC_MENU_GET = OperationSpec(
1204
+ id="bot.menu.get",
1205
+ request=MenuGetReq,
1206
+ response=MenuButton,
1207
+ impl=menu_get,
1208
+ summary="Show a bot's menu button",
1209
+ description=(
1210
+ "`botMenuButtonDefault` is never what a user sees — the server shows "
1211
+ "the commands list instead — so it is normalised to `commands` rather "
1212
+ "than reported as a third state nobody can act on."
1213
+ ),
1214
+ columns=("kind", "text", "url"),
1215
+ headers=("Kind", "Text", "URL"),
1216
+ example={"kind": "commands"},
1217
+ example_args="bot menu get @gifbot",
1218
+ covers=("bots.menu-button-state",),
1219
+ )
1220
+
1221
+
1222
+ class MenuSetReq(Request):
1223
+ commands: Annotated[bool, opt("--commands", help="Show the commands list.")] = False
1224
+ webapp: Annotated[bool, opt("--webapp", help="Open a mini app.")] = False
1225
+ default: Annotated[bool, opt("--default", help="Reset to the default.")] = False
1226
+ text: Annotated[str | None, opt("--text", metavar="TEXT", help="Button label.")] = None
1227
+ url: Annotated[str | None, opt("--url", metavar="URL", help="Mini app URL.")] = None
1228
+ user: Annotated[
1229
+ PeerRef | None, opt("--user", metavar="USER", kind="user", help="Apply to this user only.")
1230
+ ] = None
1231
+
1232
+
1233
+ async def menu_set(ctx: OpContext, req: MenuSetReq) -> MenuButton:
1234
+ """Set my bot's menu button, globally or for one user."""
1235
+ from telethon.tl import types
1236
+ from telethon.tl.functions import bots as fn
1237
+
1238
+ await _bots.require_bot_session(ctx, "setting the menu button")
1239
+ chosen = [name for name in ("commands", "webapp", "default") if getattr(req, name)]
1240
+ if len(chosen) != 1:
1241
+ raise UsageError("give exactly one of --commands, --webapp or --default", field="commands")
1242
+
1243
+ if req.webapp:
1244
+ if not req.text or not req.url:
1245
+ raise UsageError("--webapp needs --text and --url", field="url")
1246
+ button: Any = types.BotMenuButton(text=req.text, url=req.url)
1247
+ elif req.commands:
1248
+ button = types.BotMenuButtonCommands()
1249
+ else:
1250
+ button = types.BotMenuButtonDefault()
1251
+
1252
+ user = (
1253
+ await _bots.input_user(ctx, req.user, field="user")
1254
+ if req.user is not None
1255
+ else types.InputUserEmpty()
1256
+ )
1257
+ await client(ctx)(fn.SetBotMenuButtonRequest(user_id=user, button=button))
1258
+ user_id = _send.peer_id_of(await _send.resolve(ctx, req.user)) if req.user else None
1259
+ return MenuButton(
1260
+ kind="webapp" if req.webapp else "commands" if req.commands else "default",
1261
+ text=req.text,
1262
+ url=req.url,
1263
+ user_id=user_id,
1264
+ )
1265
+
1266
+
1267
+ SPEC_MENU_SET = OperationSpec(
1268
+ id="bot.menu.set",
1269
+ request=MenuSetReq,
1270
+ response=MenuButton,
1271
+ impl=menu_set,
1272
+ summary="Set my bot's menu button",
1273
+ mutating=True,
1274
+ columns=("kind", "text", "url"),
1275
+ headers=("Kind", "Text", "URL"),
1276
+ example={"kind": "webapp", "text": "Open", "url": "https://example.org/app"},
1277
+ example_args="bot menu set --commands",
1278
+ covers=("bots.menu-button-set",),
1279
+ )
1280
+
1281
+
1282
+ # ---------------------------------------------------------------------------
1283
+ # bot permission get / set
1284
+ # ---------------------------------------------------------------------------
1285
+
1286
+
1287
+ class PermissionGetReq(Request):
1288
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot.")]
1289
+
1290
+
1291
+ async def permission_get(ctx: OpContext, req: PermissionGetReq) -> BotPermission:
1292
+ """What a bot is allowed to do to me."""
1293
+ from telethon.tl.functions import bots as fn
1294
+
1295
+ peer = await _send.resolve(ctx, req.bot)
1296
+ full, _user = await _full(ctx, peer)
1297
+ can_send = bool(
1298
+ await client(ctx)(fn.CanSendMessageRequest(bot=await _bots.input_user(ctx, req.bot)))
1299
+ )
1300
+ return BotPermission(
1301
+ bot_id=int(getattr(full, "id", 0) or 0),
1302
+ can_send_messages=can_send,
1303
+ emoji_status_allowed=bool(getattr(full, "bot_can_manage_emoji_status", False)),
1304
+ )
1305
+
1306
+
1307
+ SPEC_PERMISSION_GET = OperationSpec(
1308
+ id="bot.permission.get",
1309
+ request=PermissionGetReq,
1310
+ response=BotPermission,
1311
+ impl=permission_get,
1312
+ summary="Show what a bot may do to me",
1313
+ columns=("bot_id", "can_send_messages", "emoji_status_allowed"),
1314
+ headers=("Bot", "May message", "May set status"),
1315
+ example={"bot_id": 93372553, "can_send_messages": True, "emoji_status_allowed": False},
1316
+ example_args="bot permission get @gifbot",
1317
+ covers=("bots.bot-emoji-status-permission",),
1318
+ covers_partial=("bots.allow-send-messages",),
1319
+ coverage_note="Granting or revoking is `bot permission set`.",
1320
+ )
1321
+
1322
+
1323
+ class PermissionSetReq(Request):
1324
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot.")]
1325
+ key: Annotated[str, arg(1, metavar="KEY", help="message or emoji-status.")]
1326
+ state: Annotated[str, arg(2, metavar="STATE", help="on to grant, off to revoke.")]
1327
+
1328
+
1329
+ async def permission_set(ctx: OpContext, req: PermissionSetReq) -> BotPermission:
1330
+ """Grant or revoke one bot permission.
1331
+
1332
+ "May message me again" is also granted implicitly by `webapp open
1333
+ --allow-write` and `bot attach toggle --allow-write`, and by nothing else:
1334
+ a permission that can be granted as a side effect of an unrelated command
1335
+ is a permission the user did not give.
1336
+ """
1337
+ from telethon.tl.functions import bots as fn
1338
+
1339
+ if req.key not in ("message", "emoji-status"):
1340
+ raise UsageError("key must be 'message' or 'emoji-status'", field="key")
1341
+ if req.state not in ("on", "off"):
1342
+ raise UsageError("state must be 'on' or 'off'", field="state")
1343
+
1344
+ bot = await _bots.input_user(ctx, req.bot)
1345
+ peer = await _send.resolve(ctx, req.bot)
1346
+ bot_id = _send.peer_id_of(peer)
1347
+ handle = client(ctx)
1348
+ already = False
1349
+
1350
+ if req.key == "message":
1351
+ if req.state == "off":
1352
+ raise UsageError(
1353
+ "Telegram has no revoke for 'may message me'; block the bot with `bot stop`",
1354
+ field="state",
1355
+ )
1356
+ if bool(await handle(fn.CanSendMessageRequest(bot=bot))):
1357
+ already = True
1358
+ from tlgr.ops._common import already as mark_already
1359
+
1360
+ mark_already(ctx)
1361
+ else:
1362
+ await handle(fn.AllowSendMessageRequest(bot=bot))
1363
+ else:
1364
+ await handle(fn.ToggleUserEmojiStatusPermissionRequest(bot=bot, enabled=req.state == "on"))
1365
+
1366
+ return BotPermission(
1367
+ bot_id=bot_id,
1368
+ key=req.key,
1369
+ state=req.state,
1370
+ already=already,
1371
+ can_send_messages=req.key == "message" and req.state == "on",
1372
+ emoji_status_allowed=req.key == "emoji-status" and req.state == "on",
1373
+ )
1374
+
1375
+
1376
+ SPEC_PERMISSION_SET = OperationSpec(
1377
+ id="bot.permission.set",
1378
+ request=PermissionSetReq,
1379
+ response=BotPermission,
1380
+ impl=permission_set,
1381
+ summary="Allow or revoke a bot permission",
1382
+ mutating=True,
1383
+ idempotent=True,
1384
+ columns=("bot_id", "key", "state", "already"),
1385
+ headers=("Bot", "Key", "State", "Already"),
1386
+ example={"bot_id": 93372553, "key": "message", "state": "on", "already": False},
1387
+ example_args="bot permission set @gifbot message on",
1388
+ covers=("bots.allow-send-messages",),
1389
+ covers_partial=("bots.bot-emoji-status-permission",),
1390
+ coverage_note="Reading both permissions back is `bot permission get`.",
1391
+ )
1392
+
1393
+
1394
+ # ---------------------------------------------------------------------------
1395
+ # bot press
1396
+ # ---------------------------------------------------------------------------
1397
+
1398
+
1399
+ class PressReq(Request):
1400
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat holding the message.")]
1401
+ msg_id: Annotated[
1402
+ int | None,
1403
+ arg(1, metavar="MSG_ID", required=False, kind="msg_id", help="Message id."),
1404
+ ] = None
1405
+ button: Annotated[
1406
+ str | None,
1407
+ opt("--button", metavar="SPEC", help="'<row>,<col>', '<n>' or the button's exact text."),
1408
+ ] = None
1409
+ data: Annotated[
1410
+ str | None,
1411
+ opt("--data", metavar="PAYLOAD", help="Address a callback button by its payload."),
1412
+ ] = None
1413
+ rich_button: Annotated[
1414
+ int | None, opt("--rich-button", metavar="N", help="Button in a layer-229 rich message.")
1415
+ ] = None
1416
+ ephemeral: Annotated[
1417
+ int | None, opt("--ephemeral", metavar="ID", help="Button on an ephemeral bot message.")
1418
+ ] = None
1419
+ webapp_req: Annotated[
1420
+ str | None, opt("--webapp-req", metavar="ID", help="Answer a mini app's peer request.")
1421
+ ] = None
1422
+ password: Annotated[
1423
+ str | None,
1424
+ opt(secret=True, envvar="TLGR_2FA_PASSWORD", help="2FA password for a guarded button."),
1425
+ ] = None
1426
+ share_phone: Annotated[
1427
+ bool, opt("--share-phone", help="CONSENT: send my phone number to the bot.")
1428
+ ] = False
1429
+ share_geo: Annotated[
1430
+ str | None, opt("--share-geo", metavar="LAT,LON", help="CONSENT: send this location.")
1431
+ ] = None
1432
+ peers: Annotated[
1433
+ list[PeerRef],
1434
+ opt("--peers", metavar="PEER", kind="peer", help="CONSENT: peers to share (repeatable)."),
1435
+ ] = []
1436
+ create_bot: Annotated[
1437
+ bool, opt("--create-bot", help="Answer a create-bot request by creating one.")
1438
+ ] = False
1439
+ name: Annotated[str | None, opt("--name", help="Managed-bot name for --create-bot.")] = None
1440
+ username: Annotated[
1441
+ str | None, opt("--username", help="Managed-bot username for --create-bot.")
1442
+ ] = None
1443
+ poll: Annotated[
1444
+ str | None, opt("--poll", metavar="SPEC", help="CONSENT: poll 'Question?:A,B,C'.")
1445
+ ] = None
1446
+ quiz: Annotated[bool, opt("--quiz", help="Make the --poll a quiz.")] = False
1447
+ correct: Annotated[
1448
+ int | None, opt("--correct", metavar="N", help="0-based correct answer for a quiz.")
1449
+ ] = None
1450
+ switch_to: Annotated[
1451
+ PeerRef | None,
1452
+ opt(
1453
+ "--switch-to", metavar="CHAT", kind="peer", help="Chat to run a switch-inline query in."
1454
+ ),
1455
+ ] = None
1456
+ business_connection: Annotated[
1457
+ str | None,
1458
+ opt(
1459
+ "--business-connection", metavar="ID", help="Press as a business account (bot session)."
1460
+ ),
1461
+ ] = None
1462
+
1463
+
1464
+ async def press(ctx: OpContext, req: PressReq) -> Pressed:
1465
+ """Press a button, whatever kind it is, and report what came back.
1466
+
1467
+ The consent rule is the reason this is one command and not fourteen.
1468
+ Four button kinds hand the bot something the user owns — a phone number, a
1469
+ location, a chat, a poll — and Telegram's protocol makes them look like
1470
+ every other button. tlgr will not press one without the flag that names
1471
+ what is about to leave: without it, it prints what it *would* send and
1472
+ exits 2. `buy` is refused outright, because paying is not something an
1473
+ agent does on someone's behalf.
1474
+ """
1475
+ if req.rich_button is not None:
1476
+ _bots.unsupported("--rich-button")
1477
+ if req.ephemeral is not None:
1478
+ _bots.unsupported("--ephemeral (ephemeral.getCallbackAnswer)")
1479
+
1480
+ peer = await _send.resolve(ctx, req.chat)
1481
+ if req.webapp_req:
1482
+ return await _answer_webapp_request(ctx, req, peer)
1483
+ if req.msg_id is None:
1484
+ raise UsageError("give a message id, or --webapp-req", field="msg_id")
1485
+
1486
+ message = await _media.fetch_message(ctx, peer, req.msg_id)
1487
+ markup = getattr(message, "reply_markup", None)
1488
+ flat = _flatten(markup)
1489
+ if not flat:
1490
+ raise NotFoundError(f"message {req.msg_id} has no buttons")
1491
+ row, col, index, button = _pick(flat, req)
1492
+ kind = _bots.BUTTON_TYPES.get(type(button).__name__, "unsupported")
1493
+ answer = Pressed(kind=kind, row=row, col=col, n=index, text=getattr(button, "text", None))
1494
+ return await _dispatch(ctx, req, peer, message, button, answer)
1495
+
1496
+
1497
+ def _flatten(markup: Any) -> list[tuple[int, int, int, Any]]:
1498
+ out: list[tuple[int, int, int, Any]] = []
1499
+ index = 0
1500
+ for row_index, row in enumerate(getattr(markup, "rows", None) or []):
1501
+ for col_index, button in enumerate(getattr(row, "buttons", None) or []):
1502
+ out.append((row_index, col_index, index, button))
1503
+ index += 1
1504
+ return out
1505
+
1506
+
1507
+ def _pick(flat: list[tuple[int, int, int, Any]], req: PressReq) -> tuple[int, int, int, Any]:
1508
+ """The addressed button: by payload, by coordinates, by index, or by text."""
1509
+ if req.data is not None:
1510
+ wanted = _bots.payload_bytes(req.data, field="data")
1511
+ for row, col, index, button in flat:
1512
+ if getattr(button, "data", None) == wanted:
1513
+ return row, col, index, button
1514
+ raise NotFoundError("no callback button on that message carries that payload")
1515
+
1516
+ if req.button is None:
1517
+ if len(flat) == 1:
1518
+ return flat[0]
1519
+ raise UsageError(
1520
+ f"the message has {len(flat)} buttons; name one with --button or --data",
1521
+ field="button",
1522
+ )
1523
+
1524
+ spec = req.button.strip()
1525
+ if "," in spec:
1526
+ head, _, tail = spec.partition(",")
1527
+ try:
1528
+ want = (int(head), int(tail))
1529
+ except ValueError as exc:
1530
+ raise UsageError("--button: expected '<row>,<col>'", field="button") from exc
1531
+ for row, col, index, button in flat:
1532
+ if (row, col) == want:
1533
+ return row, col, index, button
1534
+ raise NotFoundError(f"there is no button at row {want[0]}, column {want[1]}")
1535
+ if spec.isdigit():
1536
+ number = int(spec)
1537
+ for row, col, index, button in flat:
1538
+ if index == number:
1539
+ return row, col, index, button
1540
+ raise NotFoundError(f"there is no button {number}; the message has {len(flat)}")
1541
+
1542
+ exact = [e for e in flat if str(getattr(e[3], "text", "")) == spec]
1543
+ if len(exact) == 1:
1544
+ return exact[0]
1545
+ lowered = spec.lower()
1546
+ partial = [e for e in flat if lowered in str(getattr(e[3], "text", "")).lower()]
1547
+ if len(partial) == 1:
1548
+ return partial[0]
1549
+ if not partial:
1550
+ raise NotFoundError(f"no button matches {spec!r}")
1551
+ raise UsageError(
1552
+ f"{spec!r} matches {len(partial)} buttons; use --button '<row>,<col>' instead",
1553
+ field="button",
1554
+ )
1555
+
1556
+
1557
+ def _refuse(what: str, flag: str) -> Any:
1558
+ raise UsageError(
1559
+ f"this button would {what}; pass {flag} to allow it. Nothing was sent.",
1560
+ field=flag.lstrip("-").replace("-", "_"),
1561
+ )
1562
+
1563
+
1564
+ async def _dispatch(
1565
+ ctx: OpContext, req: PressReq, peer: Any, message: Any, button: Any, answer: Pressed
1566
+ ) -> Pressed:
1567
+ from telethon.tl import types
1568
+ from telethon.tl.functions import messages as fn
1569
+
1570
+ handle = client(ctx)
1571
+ chat_id = _send.peer_id_of(peer)
1572
+ kind = answer.kind
1573
+
1574
+ if kind == "buy":
1575
+ raise PermissionError_(
1576
+ "a Pay button starts a payment, and tlgr never spends money on your behalf"
1577
+ )
1578
+ if kind == "unsupported":
1579
+ raise UsageError(f"tlgr cannot press a {type(button).__name__}", field="button")
1580
+
1581
+ if kind == "text":
1582
+ sent = await _invoke_as(
1583
+ ctx,
1584
+ req.business_connection,
1585
+ fn.SendMessageRequest(
1586
+ peer=peer, message=str(getattr(button, "text", "")), random_id=_random_id()
1587
+ ),
1588
+ )
1589
+ answer.sent_message_id = _send.message_from_updates(sent, chat_id=chat_id).id
1590
+ return answer
1591
+
1592
+ if kind in ("callback", "game"):
1593
+ return await _press_callback(ctx, req, peer, message, button, answer)
1594
+
1595
+ if kind == "url":
1596
+ answer.url = getattr(button, "url", None)
1597
+ return answer
1598
+
1599
+ if kind == "copy":
1600
+ answer.copy_text = getattr(button, "copy_text", None)
1601
+ return answer
1602
+
1603
+ if kind == "url_auth":
1604
+ result = await handle(
1605
+ fn.RequestUrlAuthRequest(
1606
+ peer=peer, msg_id=int(message.id), button_id=int(getattr(button, "button_id", 0))
1607
+ )
1608
+ )
1609
+ auth = _url_auth_model(result)
1610
+ answer.auth = _bots_to_dict(auth)
1611
+ answer.url = auth.url
1612
+ return answer
1613
+
1614
+ if kind == "user_profile":
1615
+ user_id = int(getattr(button, "user_id", 0) or 0)
1616
+ answer.user = {"id": user_id}
1617
+ return answer
1618
+
1619
+ if kind == "switch_inline":
1620
+ target = req.switch_to if req.switch_to is not None else req.chat
1621
+ where = await _send.resolve(ctx, target)
1622
+ bot = await _bot_of(ctx, message)
1623
+ if bot is None:
1624
+ raise NotFoundError("the message does not say which bot to query")
1625
+ results = await handle(
1626
+ fn.GetInlineBotResultsRequest(
1627
+ bot=bot, peer=where, query=str(getattr(button, "query", "") or ""), offset=""
1628
+ )
1629
+ )
1630
+ answer.query_id = str(getattr(results, "query_id", "") or "")
1631
+ answer.results = [
1632
+ {"id": str(getattr(entry, "id", "")), "type": str(getattr(entry, "type", ""))}
1633
+ for entry in (getattr(results, "results", None) or [])
1634
+ ]
1635
+ return answer
1636
+
1637
+ if kind in ("webview", "simple_webview"):
1638
+ bot = await _bot_of(ctx, message) or await _bots.input_user(ctx, req.chat)
1639
+ url = str(getattr(button, "url", "") or "")
1640
+ if kind == "webview":
1641
+ result = await handle(
1642
+ fn.RequestWebViewRequest(peer=peer, bot=bot, platform=PLATFORM, url=url)
1643
+ )
1644
+ else:
1645
+ result = await handle(
1646
+ fn.RequestSimpleWebViewRequest(bot=bot, platform=PLATFORM, url=url)
1647
+ )
1648
+ answer.url = str(getattr(result, "url", "") or "")
1649
+ query_id = getattr(result, "query_id", None)
1650
+ answer.query_id = str(query_id) if query_id else None
1651
+ return answer
1652
+
1653
+ if kind == "request_phone":
1654
+ if not req.share_phone:
1655
+ _refuse("send the bot your phone number", "--share-phone")
1656
+ me = await handle.get_me()
1657
+ sent = await handle(
1658
+ fn.SendMediaRequest(
1659
+ peer=peer,
1660
+ media=types.InputMediaContact(
1661
+ phone_number=str(getattr(me, "phone", "") or ""),
1662
+ first_name=str(getattr(me, "first_name", "") or ""),
1663
+ last_name=str(getattr(me, "last_name", "") or ""),
1664
+ vcard="",
1665
+ ),
1666
+ message="",
1667
+ random_id=_random_id(),
1668
+ )
1669
+ )
1670
+ answer.sent_message_id = _send.message_from_updates(sent, chat_id=chat_id).id
1671
+ return answer
1672
+
1673
+ if kind == "request_geo":
1674
+ if not req.share_geo:
1675
+ _refuse("send the bot your location", "--share-geo")
1676
+ lat, lon = _latlon(str(req.share_geo))
1677
+ sent = await handle(
1678
+ fn.SendMediaRequest(
1679
+ peer=peer,
1680
+ media=types.InputMediaGeoPoint(geo_point=types.InputGeoPoint(lat=lat, long=lon)),
1681
+ message="",
1682
+ random_id=_random_id(),
1683
+ )
1684
+ )
1685
+ answer.sent_message_id = _send.message_from_updates(sent, chat_id=chat_id).id
1686
+ return answer
1687
+
1688
+ if kind == "request_poll":
1689
+ if not req.poll:
1690
+ _refuse("create a poll in this chat", "--poll")
1691
+ sent = await handle(
1692
+ fn.SendMediaRequest(
1693
+ peer=peer, media=_poll_media(req), message="", random_id=_random_id()
1694
+ )
1695
+ )
1696
+ answer.sent_message_id = _send.message_from_updates(sent, chat_id=chat_id).id
1697
+ return answer
1698
+
1699
+ if kind == "request_peer":
1700
+ return await _press_request_peer(ctx, req, peer, message, button, answer)
1701
+
1702
+ raise UsageError(f"tlgr cannot press a {kind} button", field="button")
1703
+
1704
+
1705
+ def _bots_to_dict(model: Any) -> dict[str, Any]:
1706
+ from tlgr.models.base import to_builtins
1707
+
1708
+ value = to_builtins(model)
1709
+ return value if isinstance(value, dict) else {}
1710
+
1711
+
1712
+ async def _bot_of(ctx: OpContext, message: Any) -> Any:
1713
+ """The `InputUser` of the bot that owns a message's buttons.
1714
+
1715
+ Resolved through the account's own resolver rather than assembled from
1716
+ the message: an `InputUser` needs an access hash, and the one on a message
1717
+ object is frequently absent (`min` peers carry none at all).
1718
+ """
1719
+ for attribute in ("via_bot_id", "from_id", "peer_id"):
1720
+ value = getattr(message, attribute, None)
1721
+ user_id = value if isinstance(value, int) else getattr(value, "user_id", None)
1722
+ if user_id:
1723
+ return await _bots.input_user(ctx, _bots.peer_ref(str(int(user_id))), field="chat")
1724
+ return None
1725
+
1726
+
1727
+ def _latlon(value: str) -> tuple[float, float]:
1728
+ head, _, tail = str(value).partition(",")
1729
+ try:
1730
+ return float(head), float(tail)
1731
+ except ValueError as exc:
1732
+ raise UsageError("--share-geo: expected 'lat,lon'", field="share_geo") from exc
1733
+
1734
+
1735
+ def _poll_media(req: PressReq) -> Any:
1736
+ from telethon.tl import types
1737
+
1738
+ question, _, options = str(req.poll).partition(":")
1739
+ answers = [a.strip() for a in options.split(",") if a.strip()]
1740
+ if len(answers) < 2:
1741
+ raise UsageError("--poll: expected 'Question?:A,B,C'", field="poll")
1742
+ if req.quiz and req.correct is None:
1743
+ raise UsageError("--quiz needs --correct", field="correct")
1744
+ return types.InputMediaPoll(
1745
+ poll=types.Poll(
1746
+ id=0,
1747
+ hash=0,
1748
+ question=types.TextWithEntities(text=question.strip(), entities=[]),
1749
+ answers=[
1750
+ types.PollAnswer(
1751
+ text=types.TextWithEntities(text=text, entities=[]),
1752
+ option=bytes([index]),
1753
+ )
1754
+ for index, text in enumerate(answers)
1755
+ ],
1756
+ quiz=req.quiz or None,
1757
+ ),
1758
+ correct_answers=[bytes([int(req.correct)])] if req.correct is not None else None,
1759
+ )
1760
+
1761
+
1762
+ async def _press_callback(
1763
+ ctx: OpContext, req: PressReq, peer: Any, message: Any, button: Any, answer: Pressed
1764
+ ) -> Pressed:
1765
+ """A callback (or game) button, with the SRP dance when it needs one.
1766
+
1767
+ `BOT_RESPONSE_TIMEOUT` is not an error: it means the bot is not running.
1768
+ Reporting it as a failure would make "the bot is offline" indistinguishable
1769
+ from "the press was rejected", so the answer comes back with a null message.
1770
+ """
1771
+ from telethon.tl.functions import messages as fn
1772
+
1773
+ from tlgr.ops import _auth
1774
+
1775
+ handle = client(ctx)
1776
+ is_game = answer.kind == "game"
1777
+
1778
+ def build(check: Any) -> Any:
1779
+ return fn.GetBotCallbackAnswerRequest(
1780
+ peer=peer,
1781
+ msg_id=int(message.id),
1782
+ game=is_game or None,
1783
+ data=None if is_game else getattr(button, "data", None),
1784
+ password=check,
1785
+ )
1786
+
1787
+ try:
1788
+ if getattr(button, "requires_password", False):
1789
+ if req.password is None:
1790
+ raise UsageError(
1791
+ "this button is protected by your 2FA password; "
1792
+ "pass it with --password-env or --password-stdin",
1793
+ field="password",
1794
+ )
1795
+ result = await _auth.with_password(handle, build, req.password)
1796
+ else:
1797
+ result = await handle(build(None))
1798
+ except Exception as exc: # one specific server answer is not a failure
1799
+ if "BOTRESPONSETIMEOUT" not in f"{type(exc).__name__} {exc}".upper().replace("_", ""):
1800
+ raise
1801
+ ctx.warn("the bot did not answer in time; it is probably offline")
1802
+ return answer
1803
+
1804
+ answer.message = getattr(result, "message", None)
1805
+ answer.alert = bool(getattr(result, "alert", False))
1806
+ answer.url = getattr(result, "url", None)
1807
+ answer.native_ui = bool(getattr(result, "native_ui", False))
1808
+ answer.cache_time = getattr(result, "cache_time", None)
1809
+ return answer
1810
+
1811
+
1812
+ async def _press_request_peer(
1813
+ ctx: OpContext, req: PressReq, peer: Any, message: Any, button: Any, answer: Pressed
1814
+ ) -> Pressed:
1815
+ """Answer a request-peer button, or create the bot it asked for."""
1816
+ from telethon.tl.functions import bots as bots_fn
1817
+ from telethon.tl.functions import messages as fn
1818
+
1819
+ handle = client(ctx)
1820
+ peer_type = getattr(button, "peer_type", None)
1821
+ if type(peer_type).__name__ == "RequestPeerTypeCreateBot":
1822
+ if not req.create_bot:
1823
+ _refuse("create a bot owned by you", "--create-bot")
1824
+ if not req.name or not req.username:
1825
+ raise UsageError("--create-bot needs --name and --username", field="name")
1826
+ created = await handle(
1827
+ bots_fn.CreateBotRequest(
1828
+ name=req.name,
1829
+ username=req.username,
1830
+ manager_id=await _bot_of(ctx, message) or await _bots.input_user(ctx, req.chat),
1831
+ )
1832
+ )
1833
+ answer.peers = [
1834
+ int(getattr(u, "id", 0) or 0) for u in (getattr(created, "users", None) or [])
1835
+ ]
1836
+ return answer
1837
+
1838
+ if not req.peers:
1839
+ _refuse("disclose one of your chats to the bot", "--peers")
1840
+ resolved = [await _send.resolve(ctx, value) for value in req.peers]
1841
+ await handle(
1842
+ fn.SendBotRequestedPeerRequest(
1843
+ peer=peer,
1844
+ msg_id=int(message.id),
1845
+ button_id=int(getattr(button, "button_id", 0) or 0),
1846
+ requested_peers=resolved,
1847
+ )
1848
+ )
1849
+ answer.peers = [_send.peer_id_of(entry) for entry in resolved]
1850
+ return answer
1851
+
1852
+
1853
+ async def _answer_webapp_request(ctx: OpContext, req: PressReq, peer: Any) -> Pressed:
1854
+ """Answer a mini app's peer request, addressed by `webapp_req_id`."""
1855
+ from telethon.tl.functions import bots as bots_fn
1856
+ from telethon.tl.functions import messages as fn
1857
+
1858
+ handle = client(ctx)
1859
+ bot = await _bots.input_user(ctx, req.chat)
1860
+ button = await handle(
1861
+ bots_fn.GetRequestedWebViewButtonRequest(bot=bot, webapp_req_id=str(req.webapp_req))
1862
+ )
1863
+ answer = Pressed(kind="request_peer", text=getattr(button, "text", None))
1864
+ if not req.peers:
1865
+ _refuse("disclose one of your chats to the mini app", "--peers")
1866
+ resolved = [await _send.resolve(ctx, value) for value in req.peers]
1867
+ await handle(
1868
+ fn.SendBotRequestedPeerRequest(
1869
+ peer=peer,
1870
+ button_id=int(getattr(button, "button_id", 0) or 0),
1871
+ requested_peers=resolved,
1872
+ webapp_req_id=str(req.webapp_req),
1873
+ )
1874
+ )
1875
+ answer.peers = [_send.peer_id_of(entry) for entry in resolved]
1876
+ return answer
1877
+
1878
+
1879
+ SPEC_PRESS = OperationSpec(
1880
+ id="bot.press",
1881
+ request=PressReq,
1882
+ response=Pressed,
1883
+ impl=press,
1884
+ summary="Press a button on a message",
1885
+ tags=frozenset({"visible-to-others"}),
1886
+ description=(
1887
+ "One dispatcher for every button kind, returning a typed answer: a "
1888
+ "callback toast, a URL, a signed mini-app session, inline results, a "
1889
+ "peer prompt or copy text. A button that would disclose your phone "
1890
+ "number, your location, a chat or a new poll is not pressed without "
1891
+ "the flag that names it — tlgr prints what it would send and exits 2. "
1892
+ "A Pay button is refused outright (exit 6)."
1893
+ ),
1894
+ aliases=("bot.click", "bot.button.press"),
1895
+ mutating=True,
1896
+ rate_class="send",
1897
+ columns=("kind", "n", "message", "url"),
1898
+ headers=("Kind", "#", "Answer", "URL"),
1899
+ example={"kind": "callback", "n": 0, "message": "Saved", "alert": False},
1900
+ example_args="bot press @gifbot 12 --button 0",
1901
+ covers=(
1902
+ "bots.bot-ownership-transfer",
1903
+ "bots.button-request-location",
1904
+ "bots.button-request-peer",
1905
+ "bots.button-request-phone",
1906
+ "bots.button-request-poll",
1907
+ "bots.callback-button-press",
1908
+ "bots.callback-button-with-password",
1909
+ "bots.copy-text-button",
1910
+ "bots.managed-bot-request-button",
1911
+ "bots.play-game",
1912
+ "bots.reply-keyboard-press-text",
1913
+ "bots.url-button",
1914
+ "bots.user-profile-button",
1915
+ "bots.webapp-request-phone",
1916
+ ),
1917
+ covers_partial=(
1918
+ "bots.attach-webapp-open",
1919
+ "bots.bot-privacy-mode",
1920
+ "bots.button-request-peer-from-miniapp",
1921
+ "bots.login-url-button",
1922
+ "bots.switch-inline-button",
1923
+ "bots.webapp-switch-inline-query",
1924
+ ),
1925
+ coverage_note=(
1926
+ "Pressing surfaces each of these; completing them is `webapp open`, "
1927
+ "`bot url-auth accept`, `inline query` and `inline send`."
1928
+ ),
1929
+ )
1930
+
1931
+
1932
+ # ---------------------------------------------------------------------------
1933
+ # bot url-auth
1934
+ # ---------------------------------------------------------------------------
1935
+
1936
+
1937
+ def _url_auth_model(result: Any) -> UrlAuth:
1938
+ name = type(result).__name__
1939
+ if name == "UrlAuthResultAccepted":
1940
+ return UrlAuth(result="accepted", url=getattr(result, "url", None))
1941
+ if name == "UrlAuthResultDefault":
1942
+ return UrlAuth(result="default")
1943
+ bot = getattr(result, "bot", None)
1944
+ return UrlAuth(
1945
+ result="request",
1946
+ bot=str(getattr(bot, "username", "") or getattr(bot, "id", "") or "") or None,
1947
+ domain=getattr(result, "domain", None),
1948
+ verified_app_name=getattr(result, "verified_app_name", None),
1949
+ is_app=bool(getattr(result, "is_app", False)),
1950
+ browser=getattr(result, "browser", None),
1951
+ platform=getattr(result, "platform", None),
1952
+ ip=getattr(result, "ip", None),
1953
+ region=getattr(result, "region", None),
1954
+ request_write_access=bool(getattr(result, "request_write_access", False)),
1955
+ request_phone_number=bool(getattr(result, "request_phone_number", False)),
1956
+ match_codes=bool(getattr(result, "match_codes", False)),
1957
+ match_codes_first=bool(getattr(result, "match_codes_first", False)),
1958
+ user_id_hint=getattr(result, "user_id_hint", None),
1959
+ )
1960
+
1961
+
1962
+ class UrlAuthGetReq(Request):
1963
+ target: Annotated[
1964
+ str, arg(0, metavar="TARGET", help="Chat holding the button, or the OAuth deep link.")
1965
+ ]
1966
+ msg_id: Annotated[
1967
+ int | None, opt("--msg-id", metavar="ID", kind="msg_id", help="Message id for a button.")
1968
+ ] = None
1969
+ button_id: Annotated[
1970
+ int | None, opt("--button-id", metavar="N", help="Button id from the reply markup.")
1971
+ ] = None
1972
+ in_app_origin: Annotated[
1973
+ str | None, opt("--in-app-origin", metavar="ORIGIN", help="Origin of a mini-app request.")
1974
+ ] = None
1975
+ check_code: Annotated[
1976
+ str | None, opt("--check-code", metavar="CODE", help="Pre-validate this emoji match code.")
1977
+ ] = None
1978
+
1979
+
1980
+ async def _url_auth_request(
1981
+ ctx: OpContext, target: str, msg_id: int | None, button_id: int | None, origin: str | None
1982
+ ) -> Any:
1983
+ """The one `requestUrlAuth` with three addressing modes."""
1984
+ from telethon.tl.functions import messages as fn
1985
+
1986
+ if target.startswith(("http://", "https://", "tg://")):
1987
+ return await client(ctx)(fn.RequestUrlAuthRequest(url=target, in_app_origin=origin))
1988
+ if msg_id is None or button_id is None:
1989
+ raise UsageError("addressing a button needs --msg-id and --button-id", field="msg_id")
1990
+ peer = await _send.resolve(ctx, _bots.peer_ref(target))
1991
+ return await client(ctx)(
1992
+ fn.RequestUrlAuthRequest(peer=peer, msg_id=int(msg_id), button_id=int(button_id))
1993
+ )
1994
+
1995
+
1996
+ async def url_auth_get(ctx: OpContext, req: UrlAuthGetReq) -> UrlAuth:
1997
+ """Inspect a seamless-login request without accepting it.
1998
+
1999
+ Three addressing modes reach one method: a keyboard button, a
2000
+ `url_auth_domains` URL, and an OAuth deep link with the origin it came
2001
+ from. Whichever it was, nothing is granted here — this command exists so
2002
+ the domain, the browser and the IP can be *read* before the decision.
2003
+ """
2004
+ from telethon.tl.functions import messages as fn
2005
+
2006
+ result = await _url_auth_request(ctx, req.target, req.msg_id, req.button_id, req.in_app_origin)
2007
+ model = _url_auth_model(result)
2008
+ if req.check_code:
2009
+ if not (model.match_codes and model.match_codes_first):
2010
+ raise UsageError(
2011
+ "--check-code only applies when the request sets match_codes_first",
2012
+ field="check_code",
2013
+ )
2014
+ model.code_valid = bool(
2015
+ await client(ctx)(
2016
+ fn.CheckUrlAuthMatchCodeRequest(url=req.target, match_code=req.check_code)
2017
+ )
2018
+ )
2019
+ return model
2020
+
2021
+
2022
+ SPEC_URL_AUTH_GET = OperationSpec(
2023
+ id="bot.url-auth.get",
2024
+ request=UrlAuthGetReq,
2025
+ response=UrlAuth,
2026
+ impl=url_auth_get,
2027
+ summary="Inspect a seamless-login request without accepting it",
2028
+ description=(
2029
+ "Telegram Login hands a website your identity. What it is about to "
2030
+ "hand over — the domain (or the verified app name), the browser, the "
2031
+ "platform, the IP and the region — is printed here first, and "
2032
+ "accepting is a separate command."
2033
+ ),
2034
+ aliases=("bot.url_auth.get", "bot.login-url.get", "link.auth", "auth.url-login"),
2035
+ mutating=True,
2036
+ tags=frozenset({"mutating-checked"}),
2037
+ columns=("result", "domain", "bot", "request_write_access"),
2038
+ headers=("Result", "Domain", "Bot", "Wants write"),
2039
+ example={"result": "request", "domain": "example.org", "bot": "examplebot"},
2040
+ example_args="bot url-auth get @examplebot --msg-id 12 --button-id 0",
2041
+ covers=(
2042
+ "auth.oauth-deep-link",
2043
+ "auth.url-auth-bot-button",
2044
+ "contacts-users.url-auth-login",
2045
+ "messages-core.url-authorization",
2046
+ ),
2047
+ covers_partial=(
2048
+ "bots.login-url-button",
2049
+ "bots.oauth-deeplink-login",
2050
+ "bots.url-auth-match-code",
2051
+ "bots.webapp-oauth-request",
2052
+ ),
2053
+ coverage_note=(
2054
+ "Inspecting is this command; granting is `bot url-auth accept` and "
2055
+ "refusing is `bot url-auth decline`."
2056
+ ),
2057
+ )
2058
+
2059
+
2060
+ class UrlAuthAcceptReq(Request):
2061
+ target: Annotated[
2062
+ str, arg(0, metavar="TARGET", help="Chat holding the button, or the OAuth deep link.")
2063
+ ]
2064
+ msg_id: Annotated[
2065
+ int | None, opt("--msg-id", metavar="ID", kind="msg_id", help="Message id for a button.")
2066
+ ] = None
2067
+ button_id: Annotated[
2068
+ int | None, opt("--button-id", metavar="N", help="Button id from the reply markup.")
2069
+ ] = None
2070
+ write_allowed: Annotated[
2071
+ bool, opt("--write-allowed", help="CONSENT: let the linked bot message me.")
2072
+ ] = False
2073
+ share_phone: Annotated[
2074
+ bool, opt("--share-phone", help="CONSENT: give the site my phone number.")
2075
+ ] = False
2076
+ match_code: Annotated[
2077
+ str | None, opt("--match-code", metavar="CODE", help="The emoji shown on the login page.")
2078
+ ] = None
2079
+
2080
+
2081
+ async def url_auth_accept(ctx: OpContext, req: UrlAuthAcceptReq) -> UrlAuth:
2082
+ """Complete a seamless login and print the authorized URL.
2083
+
2084
+ The request is inspected first, always: a match code that the server marks
2085
+ `match_codes_first` has to be verified *before* accepting, and both
2086
+ consent flags default off and are never inferred from the request having
2087
+ asked for them.
2088
+ """
2089
+ from telethon.tl.functions import messages as fn
2090
+
2091
+ handle = client(ctx)
2092
+ inspected = _url_auth_model(
2093
+ await _url_auth_request(ctx, req.target, req.msg_id, req.button_id, None)
2094
+ )
2095
+ if inspected.match_codes and not req.match_code:
2096
+ raise UsageError(
2097
+ "this login shows an emoji match code; pass it with --match-code", field="match_code"
2098
+ )
2099
+ if inspected.match_codes_first and req.match_code:
2100
+ ok = bool(
2101
+ await handle(fn.CheckUrlAuthMatchCodeRequest(url=req.target, match_code=req.match_code))
2102
+ )
2103
+ if not ok:
2104
+ raise PermissionError_("the match code does not match; nothing was authorized")
2105
+
2106
+ kwargs: dict[str, Any] = {
2107
+ "write_allowed": req.write_allowed or None,
2108
+ "share_phone_number": req.share_phone or None,
2109
+ "match_code": req.match_code,
2110
+ }
2111
+ if req.target.startswith(("http://", "https://", "tg://")):
2112
+ kwargs["url"] = req.target
2113
+ else:
2114
+ kwargs["peer"] = await _send.resolve(ctx, _bots.peer_ref(req.target))
2115
+ kwargs["msg_id"] = req.msg_id
2116
+ kwargs["button_id"] = req.button_id
2117
+ result = await handle(fn.AcceptUrlAuthRequest(**kwargs))
2118
+ model = _url_auth_model(result)
2119
+ model.domain = model.domain or inspected.domain
2120
+ model.bot = model.bot or inspected.bot
2121
+ model.write_allowed = req.write_allowed
2122
+ model.phone_shared = req.share_phone
2123
+ ctx.emit("bot_url_auth", {"domain": model.domain})
2124
+ return model
2125
+
2126
+
2127
+ SPEC_URL_AUTH_ACCEPT = OperationSpec(
2128
+ id="bot.url-auth.accept",
2129
+ request=UrlAuthAcceptReq,
2130
+ response=UrlAuth,
2131
+ impl=url_auth_accept,
2132
+ summary="Complete a seamless login and print the authorized URL",
2133
+ description=(
2134
+ "Destructive in the sense that matters: it logs you into a "
2135
+ "third-party site under your Telegram identity, which cannot be taken "
2136
+ "back from here. `--write-allowed` and `--share-phone` default off."
2137
+ ),
2138
+ aliases=("bot.url_auth.accept",),
2139
+ mutating=True,
2140
+ destructive=True,
2141
+ columns=("result", "domain", "url"),
2142
+ headers=("Result", "Domain", "URL"),
2143
+ example={"result": "accepted", "url": "https://example.org/login?token=…"},
2144
+ example_args="bot url-auth accept @examplebot --msg-id 12 --button-id 0",
2145
+ covers=("bots.login-url-button", "bots.url-auth-match-code", "bots.webapp-oauth-request"),
2146
+ covers_partial=("bots.oauth-deeplink-login",),
2147
+ coverage_note="Refusing an OAuth deep link is `bot url-auth decline`.",
2148
+ )
2149
+
2150
+
2151
+ class UrlAuthDeclineReq(Request):
2152
+ url: Annotated[str, arg(0, metavar="URL", help="The OAuth deep link to decline.")]
2153
+
2154
+
2155
+ async def url_auth_decline(ctx: OpContext, req: UrlAuthDeclineReq) -> UrlAuth:
2156
+ """Refuse a seamless-login request."""
2157
+ from telethon.tl.functions import messages as fn
2158
+
2159
+ await client(ctx)(fn.DeclineUrlAuthRequest(url=req.url))
2160
+ return UrlAuth(result="declined", declined=True, url=req.url)
2161
+
2162
+
2163
+ SPEC_URL_AUTH_DECLINE = OperationSpec(
2164
+ id="bot.url-auth.decline",
2165
+ request=UrlAuthDeclineReq,
2166
+ response=UrlAuth,
2167
+ impl=url_auth_decline,
2168
+ summary="Refuse a seamless-login request",
2169
+ aliases=("bot.url_auth.decline",),
2170
+ mutating=True,
2171
+ columns=("result", "declined"),
2172
+ headers=("Result", "Declined"),
2173
+ example={"result": "declined", "declined": True},
2174
+ example_args="bot url-auth decline tg://oauth?domain=example.org",
2175
+ covers=("bots.oauth-deeplink-login", "bots.url-auth-decline"),
2176
+ )
2177
+
2178
+
2179
+ # ---------------------------------------------------------------------------
2180
+ # bot answer
2181
+ # ---------------------------------------------------------------------------
2182
+
2183
+
2184
+ class AnswerReq(Request):
2185
+ kind: Annotated[
2186
+ str,
2187
+ arg(
2188
+ 0,
2189
+ metavar="KIND",
2190
+ help="callback|inline|shipping|precheckout|guest|webapp|webhook.",
2191
+ ),
2192
+ ]
2193
+ query_id: Annotated[str, arg(1, metavar="QUERY_ID", help="Query id being answered.")]
2194
+ text: Annotated[str | None, opt("--text", help="callback: toast or alert text.")] = None
2195
+ alert: Annotated[bool, opt("--alert", help="callback: show a modal alert.")] = False
2196
+ url: Annotated[str | None, opt("--url", metavar="URL", help="callback: deep link.")] = None
2197
+ cache_time: Annotated[
2198
+ int | None, opt("--cache-time", metavar="SECONDS", help="Seconds clients may cache it.")
2199
+ ] = None
2200
+ results: Annotated[
2201
+ str | None,
2202
+ opt("--results", metavar="PATH", kind="path", help="inline|guest|webapp: JSON results."),
2203
+ ] = None
2204
+ next_offset: Annotated[
2205
+ str | None, opt("--next-offset", metavar="TOKEN", help="inline: offset for the next page.")
2206
+ ] = None
2207
+ gallery: Annotated[bool, opt("--gallery", help="inline: render results as a grid.")] = False
2208
+ private: Annotated[bool, opt("--private", help="inline: cache per user.")] = False
2209
+ switch_pm: Annotated[
2210
+ str | None, opt("--switch-pm", metavar="TEXT:PARAM", help="inline: a button above them.")
2211
+ ] = None
2212
+ switch_webview: Annotated[
2213
+ str | None, opt("--switch-webview", metavar="TEXT:URL", help="inline: mini-app button.")
2214
+ ] = None
2215
+ options: Annotated[
2216
+ str | None,
2217
+ opt("--options", metavar="PATH", kind="path", help="shipping: JSON shipping options."),
2218
+ ] = None
2219
+ ok: Annotated[bool, opt("--ok", help="shipping|precheckout: accept.")] = False
2220
+ error: Annotated[
2221
+ str | None, opt("--error", metavar="TEXT", help="shipping|precheckout: rejection.")
2222
+ ] = None
2223
+ data: Annotated[
2224
+ str | None, opt("--data", metavar="JSON", kind="json", help="webhook: JSON payload.")
2225
+ ] = None
2226
+
2227
+
2228
+ _ANSWER_FLAGS = {
2229
+ "callback": {"text", "alert", "url", "cache_time"},
2230
+ "inline": {
2231
+ "results",
2232
+ "next_offset",
2233
+ "gallery",
2234
+ "private",
2235
+ "switch_pm",
2236
+ "switch_webview",
2237
+ "cache_time",
2238
+ },
2239
+ "shipping": {"options", "ok", "error"},
2240
+ "precheckout": {"ok", "error"},
2241
+ "guest": {"results"},
2242
+ "webapp": {"results"},
2243
+ "webhook": {"data"},
2244
+ }
2245
+
2246
+
2247
+ async def answer(ctx: OpContext, req: AnswerReq) -> BotAnswer:
2248
+ """Answer one pending bot query.
2249
+
2250
+ Seven query kinds, seven methods, one command — because a caller reading
2251
+ `bot query list` has one loop to write, not seven. A flag that belongs to
2252
+ another kind is a usage error rather than a silently ignored argument.
2253
+
2254
+ Answering a pre-checkout query is not a payment: it approves or rejects one
2255
+ the buyer has already started, and refusing to do it would leave that buyer
2256
+ stuck.
2257
+ """
2258
+ from telethon.tl.functions import bots as bots_fn
2259
+ from telethon.tl.functions import messages as fn
2260
+
2261
+ await _bots.require_bot_session(ctx, "answering a bot query")
2262
+ allowed = _ANSWER_FLAGS.get(req.kind)
2263
+ if allowed is None:
2264
+ raise UsageError(f"{req.kind!r} is not a query kind", field="kind")
2265
+ supplied = {
2266
+ name
2267
+ for name in set().union(*_ANSWER_FLAGS.values())
2268
+ if getattr(req, name, None) not in (None, False)
2269
+ }
2270
+ stray = sorted(supplied - allowed)
2271
+ if stray:
2272
+ raise UsageError(
2273
+ f"{', '.join('--' + s.replace('_', '-') for s in stray)} "
2274
+ f"does not belong to a {req.kind} answer",
2275
+ field=stray[0],
2276
+ )
2277
+
2278
+ handle = client(ctx)
2279
+ query_id = _query_id(req.query_id)
2280
+ if req.kind == "callback":
2281
+ await handle(
2282
+ fn.SetBotCallbackAnswerRequest(
2283
+ query_id=query_id,
2284
+ cache_time=int(req.cache_time or 0),
2285
+ alert=req.alert or None,
2286
+ message=req.text,
2287
+ url=req.url,
2288
+ )
2289
+ )
2290
+ elif req.kind == "inline":
2291
+ await handle(
2292
+ fn.SetInlineBotResultsRequest(
2293
+ query_id=query_id,
2294
+ results=_inline_results(req.results),
2295
+ cache_time=int(req.cache_time or 0),
2296
+ gallery=req.gallery or None,
2297
+ private=req.private or None,
2298
+ next_offset=req.next_offset,
2299
+ switch_pm=_switch_pm(req.switch_pm),
2300
+ switch_webview=_switch_webview(req.switch_webview),
2301
+ )
2302
+ )
2303
+ elif req.kind == "shipping":
2304
+ await handle(
2305
+ fn.SetBotShippingResultsRequest(
2306
+ query_id=query_id,
2307
+ error=req.error,
2308
+ shipping_options=_shipping_options(req.options),
2309
+ )
2310
+ )
2311
+ elif req.kind == "precheckout":
2312
+ await handle(
2313
+ fn.SetBotPrecheckoutResultsRequest(
2314
+ query_id=query_id, success=req.ok or None, error=req.error
2315
+ )
2316
+ )
2317
+ elif req.kind == "guest":
2318
+ await handle(
2319
+ fn.SetBotGuestChatResultRequest(
2320
+ query_id=query_id, result=_inline_results(req.results)[0]
2321
+ )
2322
+ )
2323
+ elif req.kind == "webapp":
2324
+ await handle(
2325
+ fn.SendWebViewResultMessageRequest(
2326
+ bot_query_id=str(req.query_id), result=_inline_results(req.results)[0]
2327
+ )
2328
+ )
2329
+ else:
2330
+ payload = _bots.data_json(req.data, field="data")
2331
+ if payload is None:
2332
+ raise UsageError("a webhook answer needs --data", field="data")
2333
+ await handle(bots_fn.AnswerWebhookJSONQueryRequest(query_id=query_id, data=payload))
2334
+
2335
+ return BotAnswer(query_id=str(req.query_id), kind=req.kind, answered=True)
2336
+
2337
+
2338
+ def _query_id(value: str) -> int:
2339
+ try:
2340
+ return int(value)
2341
+ except ValueError as exc:
2342
+ raise UsageError(
2343
+ "query-id must be the numeric id from `bot query list`", field="query_id"
2344
+ ) from exc
2345
+
2346
+
2347
+ def _switch_pm(value: str | None) -> Any:
2348
+ if not value:
2349
+ return None
2350
+ from telethon.tl import types
2351
+
2352
+ text, _, param = value.partition(":")
2353
+ return types.InlineBotSwitchPM(text=text, start_param=param)
2354
+
2355
+
2356
+ def _switch_webview(value: str | None) -> Any:
2357
+ if not value:
2358
+ return None
2359
+ from telethon.tl import types
2360
+
2361
+ text, _, url = value.partition(":")
2362
+ return types.InlineBotWebView(text=text, url=url)
2363
+
2364
+
2365
+ def _shipping_options(path: str | None) -> Any:
2366
+ if not path:
2367
+ return None
2368
+ from telethon.tl import types
2369
+
2370
+ loaded = _bots.load_json(path, field="options")
2371
+ return [
2372
+ types.ShippingOption(
2373
+ id=str(entry.get("id", "")),
2374
+ title=str(entry.get("title", "")),
2375
+ prices=[
2376
+ types.LabeledPrice(label=str(p.get("label", "")), amount=int(p.get("amount", 0)))
2377
+ for p in entry.get("prices", [])
2378
+ ],
2379
+ )
2380
+ for entry in loaded or []
2381
+ ]
2382
+
2383
+
2384
+ def _inline_results(path: str | None) -> list[Any]:
2385
+ """The `--results` JSON file as `InputBotInlineResult` objects."""
2386
+ from telethon.tl import types
2387
+
2388
+ if not path:
2389
+ raise UsageError("this answer needs --results", field="results")
2390
+ loaded = _bots.load_json(path, field="results")
2391
+ if isinstance(loaded, dict):
2392
+ loaded = [loaded]
2393
+ if not loaded:
2394
+ raise UsageError("--results: the file holds no results", field="results")
2395
+ out: list[Any] = []
2396
+ for entry in loaded:
2397
+ message = entry.get("message") or {}
2398
+ out.append(
2399
+ types.InputBotInlineResult(
2400
+ id=str(entry.get("id", "")),
2401
+ type=str(entry.get("type", "article")),
2402
+ send_message=types.InputBotInlineMessageText(
2403
+ message=str(message.get("text", "")),
2404
+ no_webpage=bool(message.get("no_preview")) or None,
2405
+ reply_markup=_bots.keyboard_tl(message.get("reply_markup"), field="results"),
2406
+ ),
2407
+ title=entry.get("title"),
2408
+ description=entry.get("description"),
2409
+ url=entry.get("url"),
2410
+ )
2411
+ )
2412
+ return out
2413
+
2414
+
2415
+ SPEC_ANSWER = OperationSpec(
2416
+ id="bot.answer",
2417
+ request=AnswerReq,
2418
+ response=BotAnswer,
2419
+ impl=answer,
2420
+ summary="Answer a pending bot query",
2421
+ tags=frozenset({"visible-to-others"}),
2422
+ description=(
2423
+ "Callback, inline, shipping, pre-checkout, guest, mini-app and "
2424
+ "webhook queries, one flag set per kind. Answering a pre-checkout "
2425
+ "query approves or rejects a payment the buyer already started, which "
2426
+ "is why it is here and `payments.sendPaymentForm` is not."
2427
+ ),
2428
+ mutating=True,
2429
+ rate_class="send",
2430
+ columns=("query_id", "kind", "answered"),
2431
+ headers=("Query", "Kind", "Answered"),
2432
+ example={"query_id": "123456", "kind": "callback", "answered": True},
2433
+ example_args="bot answer callback 123456 --text Saved",
2434
+ covers=(
2435
+ "bots.answer-callback-query",
2436
+ "bots.answer-inline-query",
2437
+ "bots.answer-precheckout-query",
2438
+ "bots.answer-shipping-query",
2439
+ "bots.guest-mode-answer",
2440
+ "bots.send-webview-result-message",
2441
+ ),
2442
+ covers_partial=("bots.send-custom-request",),
2443
+ coverage_note="An arbitrary Bot-API method is `bot api send`.",
2444
+ )
2445
+
2446
+
2447
+ # ---------------------------------------------------------------------------
2448
+ # bot query list
2449
+ # ---------------------------------------------------------------------------
2450
+
2451
+
2452
+ class QueryListReq(Request):
2453
+ kind: Annotated[
2454
+ str | None,
2455
+ choice(
2456
+ "callback",
2457
+ "inline",
2458
+ "inline-send",
2459
+ "shipping",
2460
+ "precheckout",
2461
+ "guest",
2462
+ "webapp",
2463
+ "webhook",
2464
+ help="Filter by query kind.",
2465
+ ),
2466
+ ] = None
2467
+ since: Annotated[
2468
+ str | None, opt("--since", metavar="WHEN", kind="datetime", help="Only newer than this.")
2469
+ ] = None
2470
+ resolve_message: Annotated[
2471
+ bool, opt("--resolve-message", help="Also fetch a callback's source message.")
2472
+ ] = True
2473
+
2474
+
2475
+ async def query_list(ctx: OpContext, req: QueryListReq) -> Page[BotQuery]:
2476
+ """The bot queries the daemon is holding.
2477
+
2478
+ The buffer is filled by `watch --bot-updates`, which belongs to the
2479
+ updates group; until that is running this is an empty page rather than an
2480
+ error, because "no queries" and "nobody is listening" look the same from
2481
+ here and the honest answer is the empty one plus a warning.
2482
+ """
2483
+ await _bots.require_bot_session(ctx, "listing bot queries")
2484
+ limit, _state = window(ctx, "bot.query.list", PageKind.LOCAL, default=50)
2485
+ buffer = getattr(getattr(ctx, "daemon", None), "bot_queries", None)
2486
+ if buffer is None:
2487
+ ctx.warn(
2488
+ "the daemon is not buffering bot updates; start one with "
2489
+ "`tlgr watch --bot-updates` to fill this list"
2490
+ )
2491
+ return Page(items=[], has_more=False, total=0)
2492
+
2493
+ since = _parse_since(req.since)
2494
+ items: list[BotQuery] = []
2495
+ for entry in list(buffer)[:limit]:
2496
+ row = _query_row(entry)
2497
+ if req.kind and row.kind != req.kind:
2498
+ continue
2499
+ if since and (row.expires_at or "") < since:
2500
+ continue
2501
+ items.append(row)
2502
+ return Page(items=items, has_more=False, total=len(items))
2503
+
2504
+
2505
+ def _parse_since(value: str | None) -> str:
2506
+ if not value:
2507
+ return ""
2508
+ from tlgr.core.timefmt import parse_dt
2509
+
2510
+ parsed = parse_dt(value)
2511
+ return fmt_dt(parsed) or ""
2512
+
2513
+
2514
+ def _query_row(entry: Any) -> BotQuery:
2515
+ data = entry if isinstance(entry, dict) else {}
2516
+ return BotQuery(
2517
+ query_id=str(data.get("query_id", "")),
2518
+ kind=str(data.get("kind", "")),
2519
+ user_id=data.get("user_id"),
2520
+ peer_id=data.get("peer_id"),
2521
+ msg_id=data.get("msg_id"),
2522
+ inline_msg_id=data.get("inline_msg_id"),
2523
+ data=data.get("data"),
2524
+ query=data.get("query"),
2525
+ payload=data.get("payload"),
2526
+ answered=bool(data.get("answered")),
2527
+ expires_at=data.get("expires_at"),
2528
+ message=data.get("message"),
2529
+ )
2530
+
2531
+
2532
+ SPEC_QUERY_LIST = OperationSpec(
2533
+ id="bot.query.list",
2534
+ request=QueryListReq,
2535
+ response=Page[BotQuery],
2536
+ impl=query_list,
2537
+ summary="List the bot queries the daemon is holding",
2538
+ description=(
2539
+ "An inline callback carries an `InputBotInlineMessageID` rather than a "
2540
+ "message id and cannot be fetched at all, so its `message` is null "
2541
+ "rather than missing."
2542
+ ),
2543
+ paginated=PageKind.LOCAL,
2544
+ columns=("query_id", "kind", "user_id", "answered"),
2545
+ headers=("Query", "Kind", "User", "Answered"),
2546
+ example={"items": [{"query_id": "123456", "kind": "callback"}], "has_more": False},
2547
+ example_args="bot query list --kind callback",
2548
+ covers=("bots.callback-query-message-get",),
2549
+ )
2550
+
2551
+
2552
+ # ---------------------------------------------------------------------------
2553
+ # bot api send / connection
2554
+ # ---------------------------------------------------------------------------
2555
+
2556
+
2557
+ class ApiSendReq(Request):
2558
+ method: Annotated[str, arg(0, metavar="METHOD", help="Bot-API method name.")]
2559
+ params: Annotated[
2560
+ str, opt("--params", metavar="JSON", kind="json", help="JSON parameters.")
2561
+ ] = "{}"
2562
+
2563
+
2564
+ async def api_send(ctx: OpContext, req: ApiSendReq) -> BotApiResult:
2565
+ """Call an arbitrary HTTP Bot-API method over MTProto.
2566
+
2567
+ The escape hatch for the Bot-API surface tlgr has not modelled. The reply
2568
+ is an opaque `DataJSON` and is passed through verbatim: parsing it would
2569
+ be inventing a schema for a method tlgr does not know.
2570
+ """
2571
+ from telethon.tl.functions import bots as fn
2572
+
2573
+ await _bots.require_bot_session(ctx, "bot api send")
2574
+ payload = _bots.data_json(req.params, field="params")
2575
+ result = await client(ctx)(
2576
+ fn.SendCustomRequestRequest(custom_method=req.method, params=payload)
2577
+ )
2578
+ return BotApiResult(method=req.method, result=_data_json(result))
2579
+
2580
+
2581
+ def _data_json(result: Any) -> Any:
2582
+ import json
2583
+
2584
+ text = getattr(result, "data", None)
2585
+ if not text:
2586
+ return None
2587
+ try:
2588
+ return json.loads(text)
2589
+ except json.JSONDecodeError:
2590
+ return text
2591
+
2592
+
2593
+ SPEC_API_SEND = OperationSpec(
2594
+ id="bot.api.send",
2595
+ request=ApiSendReq,
2596
+ response=BotApiResult,
2597
+ impl=api_send,
2598
+ summary="Call an arbitrary Bot-API method through the MTProto session",
2599
+ mutating=True,
2600
+ columns=("method",),
2601
+ headers=("Method",),
2602
+ example={"method": "getMe", "result": {"id": 93372553}},
2603
+ example_args='bot api send getMe --params "{}"',
2604
+ covers=("bots.send-custom-request",),
2605
+ )
2606
+
2607
+
2608
+ class ConnectionGetReq(Request):
2609
+ connection_id: Annotated[str, arg(0, metavar="CONNECTION_ID", help="Business connection id.")]
2610
+
2611
+
2612
+ async def connection_get(ctx: OpContext, req: ConnectionGetReq) -> BusinessConnection:
2613
+ """A business connection my bot acts through.
2614
+
2615
+ The `dc_id` is not decoration: every wrapped call has to be sent *there*,
2616
+ which is why it is reported rather than hidden inside the wrapper.
2617
+ """
2618
+ from telethon.tl.functions import account as fn
2619
+
2620
+ await _bots.require_bot_session(ctx, "reading a business connection")
2621
+ result = await client(ctx)(fn.GetBotBusinessConnectionRequest(connection_id=req.connection_id))
2622
+ connection = None
2623
+ for update in getattr(result, "updates", None) or []:
2624
+ connection = getattr(update, "connection", None) or connection
2625
+ date = getattr(connection, "date", None)
2626
+ return BusinessConnection(
2627
+ connection_id=str(getattr(connection, "connection_id", req.connection_id)),
2628
+ user_id=getattr(connection, "user_id", None),
2629
+ dc_id=getattr(connection, "dc_id", None),
2630
+ date=fmt_dt(date),
2631
+ date_unix=to_unix(date),
2632
+ rights=_business_rights(getattr(connection, "rights", None)),
2633
+ disabled=bool(getattr(connection, "disabled", False)),
2634
+ )
2635
+
2636
+
2637
+ SPEC_CONNECTION_GET = OperationSpec(
2638
+ id="bot.connection.get",
2639
+ request=ConnectionGetReq,
2640
+ response=BusinessConnection,
2641
+ impl=connection_get,
2642
+ summary="Show a business connection my bot is acting through",
2643
+ columns=("connection_id", "user_id", "dc_id", "disabled"),
2644
+ headers=("Connection", "User", "DC", "Disabled"),
2645
+ example={"connection_id": "abc123", "user_id": 4242, "dc_id": 2},
2646
+ example_args="bot connection get abc123",
2647
+ covers=("bots.business-connection-info",),
2648
+ )
2649
+
2650
+
2651
+ class ConnectionInvokeReq(Request):
2652
+ connection_id: Annotated[str, arg(0, metavar="CONNECTION_ID", help="Business connection id.")]
2653
+ command: Annotated[
2654
+ list[str],
2655
+ arg(1, metavar="COMMAND", variadic=True, help="The tlgr command to wrap."),
2656
+ ] = []
2657
+
2658
+
2659
+ async def connection_invoke(ctx: OpContext, req: ConnectionInvokeReq) -> BusinessConnection:
2660
+ """Run another tlgr command on behalf of a business account.
2661
+
2662
+ Wrapping is not a flag on a request: the wrapped query must be sent to the
2663
+ connection's own DC through an exported sender, which is why the
2664
+ connection is fetched first and the DC reported back.
2665
+
2666
+ The wrapper is also available inline as `--business-connection` on
2667
+ `bot command send`, `bot press` and `inline send`; this command exists for
2668
+ the operations that do not carry the flag yet.
2669
+ """
2670
+ if not req.command:
2671
+ raise UsageError("give a tlgr command to wrap", field="command")
2672
+ raise _bots.unsupported(
2673
+ "bot connection invoke",
2674
+ "wrapping an arbitrary tlgr operation needs the daemon's own dispatcher, "
2675
+ "which `ops/` may not import (§2.2); use the --business-connection flag on "
2676
+ "bot command send, bot press or inline send instead",
2677
+ )
2678
+
2679
+
2680
+ SPEC_CONNECTION_INVOKE = OperationSpec(
2681
+ id="bot.connection.invoke",
2682
+ request=ConnectionInvokeReq,
2683
+ response=BusinessConnection,
2684
+ impl=connection_invoke,
2685
+ summary="Run another tlgr command on behalf of a business account",
2686
+ description=(
2687
+ "Registered and refused with exit 13 rather than left out: the "
2688
+ "wrapper itself works and is reachable as `--business-connection` on "
2689
+ "the commands that carry it, but re-entering the dispatcher from "
2690
+ "inside an operation would break the layering rule that keeps `ops/` "
2691
+ "importable without the daemon."
2692
+ ),
2693
+ mutating=True,
2694
+ columns=("connection_id",),
2695
+ headers=("Connection",),
2696
+ example={"connection_id": "abc123"},
2697
+ example_args="bot connection invoke abc123 message send @alice hi",
2698
+ covers_partial=("bots.business-invoke-with-connection", "updates.invoke-business-connection"),
2699
+ coverage_note=(
2700
+ "The wrapper is implemented on `bot command send`, `bot press` and "
2701
+ "`inline send`; wrapping an arbitrary command is refused with exit 13."
2702
+ ),
2703
+ )
2704
+
2705
+
2706
+ # ---------------------------------------------------------------------------
2707
+ # bot stream send
2708
+ # ---------------------------------------------------------------------------
2709
+
2710
+
2711
+ class StreamSendReq(Request):
2712
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Destination chat.")]
2713
+ draft_id: Annotated[int, opt("--draft-id", metavar="ID", help="Draft random_id.")] = 0
2714
+ text: Annotated[str | None, opt("--text", help="Next text chunk.")] = None
2715
+ rich_file: Annotated[
2716
+ str | None, opt("--rich-file", metavar="PATH", kind="path", help="Next chunk, rich.")
2717
+ ] = None
2718
+ file: Annotated[
2719
+ str | None,
2720
+ opt("--file", metavar="PATH", kind="path", help="Read chunks from a file, one per line."),
2721
+ ] = None
2722
+ topic: Annotated[
2723
+ int | None, opt("--topic", metavar="ID", kind="msg_id", help="Forum topic id.")
2724
+ ] = None
2725
+ can_stop: Annotated[bool, opt("--can-stop", help="Let the user stop the generation.")] = False
2726
+ keep_on_stop: Annotated[
2727
+ bool, opt("--keep-on-stop", help="Keep the partial answer if the user stops it.")
2728
+ ] = False
2729
+ stop: Annotated[bool, opt("--stop", help="End the stream.")] = False
2730
+
2731
+
2732
+ async def stream_send(ctx: OpContext, req: StreamSendReq) -> StreamProgress:
2733
+ """Stream a live draft — an answer being generated — into a chat.
2734
+
2735
+ The server allows 20 calls per 5 s and 40 per 30 s *per peer* and answers
2736
+ a burst with a one-to-three second FloodWait. Retrying that is the wrong
2737
+ shape: the chunks would arrive late and out of order. tlgr paces itself
2738
+ through the session limiter and coalesces what it cannot send in time.
2739
+ """
2740
+ from telethon.tl import types
2741
+ from telethon.tl.functions import messages as fn
2742
+
2743
+ await _bots.require_bot_session(ctx, "streaming a live draft")
2744
+ if req.can_stop or req.keep_on_stop or req.stop:
2745
+ _bots.unsupported("--can-stop/--keep-on-stop/--stop")
2746
+ if not req.draft_id:
2747
+ raise UsageError("--draft-id is required; it is what keys the stream", field="draft_id")
2748
+
2749
+ chunks = _stream_chunks(req)
2750
+ if not chunks:
2751
+ raise UsageError("give --text, --rich-file or --file", field="text")
2752
+
2753
+ peer = await _send.resolve(ctx, req.chat)
2754
+ handle = client(ctx)
2755
+ limiter = getattr(ctx, "limiter", None)
2756
+ for chunk in chunks:
2757
+ action = types.SendMessageTextDraftAction(
2758
+ text=types.TextWithEntities(text=chunk, entities=[]), random_id=req.draft_id
2759
+ )
2760
+ await handle(fn.SetTypingRequest(peer=peer, action=action, top_msg_id=req.topic))
2761
+ if limiter is not None:
2762
+ await limiter.acquire("send")
2763
+ return StreamProgress(
2764
+ chat_id=_send.peer_id_of(peer), draft_id=req.draft_id, chunks_sent=len(chunks)
2765
+ )
2766
+
2767
+
2768
+ def _stream_chunks(req: StreamSendReq) -> list[str]:
2769
+ import os
2770
+ from pathlib import Path
2771
+
2772
+ if req.text:
2773
+ return [req.text]
2774
+ source = req.rich_file or req.file
2775
+ if not source:
2776
+ return []
2777
+ path = Path(os.path.expanduser(source))
2778
+ try:
2779
+ body = path.read_text(encoding="utf-8")
2780
+ except OSError as exc:
2781
+ raise UsageError(f"--file: {exc.strerror or exc}", field="file") from exc
2782
+ if req.rich_file:
2783
+ return [body]
2784
+ return [line for line in body.splitlines() if line.strip()]
2785
+
2786
+
2787
+ SPEC_STREAM_SEND = OperationSpec(
2788
+ id="bot.stream.send",
2789
+ request=StreamSendReq,
2790
+ response=StreamProgress,
2791
+ impl=stream_send,
2792
+ summary="Stream a live draft into a chat",
2793
+ tags=frozenset({"visible-to-others"}),
2794
+ mutating=True,
2795
+ rate_class="send",
2796
+ columns=("chat_id", "draft_id", "chunks_sent"),
2797
+ headers=("Chat", "Draft", "Chunks"),
2798
+ example={"chat_id": 4242, "draft_id": 99, "chunks_sent": 3},
2799
+ example_args="bot stream send @alice --draft-id 99 --text Thinking…",
2800
+ covers=("bots.ai-live-draft-streaming", "bots.rich-message-draft-stream"),
2801
+ )
2802
+
2803
+
2804
+ # ---------------------------------------------------------------------------
2805
+ # bot create / edit / username / token
2806
+ # ---------------------------------------------------------------------------
2807
+
2808
+
2809
+ class CreateReq(Request):
2810
+ name: Annotated[str, opt("--name", help="Display name.")] = ""
2811
+ username: Annotated[str, opt("--username", help="Username (must end in 'bot').")] = ""
2812
+ manager: Annotated[
2813
+ PeerRef | None,
2814
+ opt("--manager", metavar="USER", kind="user", help="Manager bot that owns the token."),
2815
+ ] = None
2816
+ about: Annotated[str | None, opt("--about", help="Short about text.")] = None
2817
+ check_only: Annotated[
2818
+ bool, opt("--check-only", help="Only report whether the username is free.")
2819
+ ] = False
2820
+
2821
+
2822
+ async def create(ctx: OpContext, req: CreateReq) -> BotCreated:
2823
+ """Create a managed bot without going through @BotFather.
2824
+
2825
+ The username is checked first, always: `bots.createBot` consumes one of a
2826
+ small per-account quota, and burning one on a name that was never free is
2827
+ not recoverable.
2828
+ """
2829
+ from telethon.tl import types
2830
+ from telethon.tl.functions import bots as fn
2831
+
2832
+ if not req.name or not req.username:
2833
+ raise UsageError("--name and --username are both required", field="username")
2834
+
2835
+ handle = client(ctx)
2836
+ free = bool(await handle(fn.CheckUsernameRequest(username=req.username)))
2837
+ if not free:
2838
+ raise UsageError(f"@{req.username} is not available", field="username")
2839
+ if req.check_only:
2840
+ return BotCreated(username=req.username, token_available=False)
2841
+
2842
+ manager = (
2843
+ await _bots.input_user(ctx, req.manager, field="manager")
2844
+ if req.manager is not None
2845
+ else types.InputUserSelf()
2846
+ )
2847
+ result = await handle(
2848
+ fn.CreateBotRequest(name=req.name, username=req.username, manager_id=manager)
2849
+ )
2850
+ users = getattr(result, "users", None) or []
2851
+ bot_id = int(getattr(users[0], "id", 0) or 0) if users else 0
2852
+ if req.about:
2853
+ await handle(
2854
+ fn.SetBotInfoRequest(
2855
+ lang_code="",
2856
+ bot=types.InputUser(
2857
+ user_id=bot_id, access_hash=int(getattr(users[0], "access_hash", 0) or 0)
2858
+ ),
2859
+ about=req.about,
2860
+ )
2861
+ )
2862
+ ctx.emit("bot_create", {"bot_id": bot_id, "username": req.username})
2863
+ return BotCreated(
2864
+ bot_id=bot_id,
2865
+ username=req.username,
2866
+ manager=_send.peer_id_of(manager) if req.manager is not None else None,
2867
+ token_available=True,
2868
+ )
2869
+
2870
+
2871
+ SPEC_CREATE = OperationSpec(
2872
+ id="bot.create",
2873
+ request=CreateReq,
2874
+ response=BotCreated,
2875
+ impl=create,
2876
+ summary="Create a managed bot without BotFather",
2877
+ description=(
2878
+ "A managed bot's token is exported with `bot token export`, which is "
2879
+ "what makes this worth having: the whole lifecycle stays in one tool."
2880
+ ),
2881
+ mutating=True,
2882
+ columns=("bot_id", "username", "token_available"),
2883
+ headers=("Bot", "Username", "Token"),
2884
+ example={"bot_id": 5000001, "username": "my_helper_bot", "token_available": True},
2885
+ example_args="bot create --name Helper --username my_helper_bot",
2886
+ covers=("bots.create-managed-bot",),
2887
+ )
2888
+
2889
+
2890
+ class EditReq(Request):
2891
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot I own.")]
2892
+ name: Annotated[str | None, opt("--name", help="Display name.")] = None
2893
+ about: Annotated[str | None, opt("--about", help="Short about text (profile).")] = None
2894
+ description: Annotated[
2895
+ str | None, opt("--description", help="Long description shown in an empty chat.")
2896
+ ] = None
2897
+ lang: Annotated[str, opt("--lang", metavar="CODE", help="Language these values apply to.")] = ""
2898
+ photo: Annotated[
2899
+ str | None, opt("--photo", metavar="PATH", kind="path", help="Profile photo or video.")
2900
+ ] = None
2901
+ video: Annotated[bool, opt("--video", help="Treat --photo as a video.")] = False
2902
+ video_start: Annotated[
2903
+ float | None, opt("--video-start", metavar="SECONDS", help="Video cover timestamp.")
2904
+ ] = None
2905
+ remove_photo: Annotated[
2906
+ bool, opt("--remove-photo", help="Delete the current profile photo.")
2907
+ ] = False
2908
+
2909
+
2910
+ async def edit(ctx: OpContext, req: EditReq) -> BotEdited:
2911
+ """Edit my bot's name, about text, description and profile photo.
2912
+
2913
+ `bot=` is what makes this the *owner* side of `bots.setBotInfo`; omitting
2914
+ it would edit the calling account instead, which is a bug you only notice
2915
+ after your own profile changed.
2916
+ """
2917
+ from telethon.tl.functions import bots as fn
2918
+ from telethon.tl.functions import photos as photos_fn
2919
+
2920
+ bot = await _bots.input_user(ctx, req.bot)
2921
+ peer = await _send.resolve(ctx, req.bot)
2922
+ handle = client(ctx)
2923
+ bot_id = _send.peer_id_of(peer)
2924
+
2925
+ if req.name or req.about or req.description:
2926
+ await handle(
2927
+ fn.SetBotInfoRequest(
2928
+ lang_code=req.lang,
2929
+ bot=bot,
2930
+ name=req.name,
2931
+ about=req.about,
2932
+ description=req.description,
2933
+ )
2934
+ )
2935
+
2936
+ photo_id: int | None = None
2937
+ if req.remove_photo:
2938
+ full, _user = await _full(ctx, peer)
2939
+ current = getattr(full, "profile_photo", None)
2940
+ if current is not None:
2941
+ from telethon.tl import types
2942
+
2943
+ await handle(
2944
+ photos_fn.DeletePhotosRequest(
2945
+ id=[
2946
+ types.InputPhoto(
2947
+ id=int(getattr(current, "id", 0) or 0),
2948
+ access_hash=int(getattr(current, "access_hash", 0) or 0),
2949
+ file_reference=getattr(current, "file_reference", b"") or b"",
2950
+ )
2951
+ ]
2952
+ )
2953
+ )
2954
+ elif req.photo:
2955
+ import os
2956
+ from pathlib import Path
2957
+
2958
+ upload = getattr(ctx, "upload_file", None)
2959
+ if upload is None: # pragma: no cover - the daemon always supplies one
2960
+ raise UsageError("this context cannot upload files")
2961
+ path = Path(os.path.expanduser(req.photo))
2962
+ if not path.exists():
2963
+ raise UsageError(f"--photo: {path} does not exist", field="photo")
2964
+ handle_file = await upload(path)
2965
+ result = await handle(
2966
+ photos_fn.UploadProfilePhotoRequest(
2967
+ bot=bot,
2968
+ file=None if req.video else handle_file,
2969
+ video=handle_file if req.video else None,
2970
+ video_start_ts=req.video_start,
2971
+ )
2972
+ )
2973
+ photo_id = _id_of(getattr(result, "photo", None))
2974
+
2975
+ ctx.emit("bot_edit", {"bot_id": bot_id})
2976
+ return BotEdited(
2977
+ bot_id=bot_id,
2978
+ name=req.name,
2979
+ about=req.about,
2980
+ description=req.description,
2981
+ lang=req.lang or None,
2982
+ photo_id=photo_id,
2983
+ )
2984
+
2985
+
2986
+ SPEC_EDIT = OperationSpec(
2987
+ id="bot.edit",
2988
+ request=EditReq,
2989
+ response=BotEdited,
2990
+ impl=edit,
2991
+ summary="Edit my bot's name, about text, description and photo",
2992
+ mutating=True,
2993
+ rate_class="file",
2994
+ columns=("bot_id", "name", "lang"),
2995
+ headers=("Bot", "Name", "Lang"),
2996
+ example={"bot_id": 5000001, "name": "Helper", "lang": "en"},
2997
+ example_args="bot edit @my_helper_bot --name Helper",
2998
+ covers=(
2999
+ "bot.profile-photo-set",
3000
+ "bots.bot-forums",
3001
+ "bots.set-bot-info",
3002
+ "bots.set-bot-photo",
3003
+ ),
3004
+ )
3005
+
3006
+
3007
+ class UsernameCheckReq(Request):
3008
+ username: Annotated[str, arg(0, metavar="USERNAME", help="Candidate username.")]
3009
+
3010
+
3011
+ async def username_check(ctx: OpContext, req: UsernameCheckReq) -> BotUsernameCheck:
3012
+ """Is a bot username free?"""
3013
+ from telethon.tl.functions import bots as fn
3014
+
3015
+ try:
3016
+ free = bool(await client(ctx)(fn.CheckUsernameRequest(username=req.username)))
3017
+ except Exception as exc: # the server's reason IS the answer here
3018
+ name = type(exc).__name__.upper()
3019
+ if "USERNAME" not in name:
3020
+ raise
3021
+ return BotUsernameCheck(username=req.username, available=False, reason=type(exc).__name__)
3022
+ return BotUsernameCheck(username=req.username, available=free)
3023
+
3024
+
3025
+ SPEC_USERNAME_CHECK = OperationSpec(
3026
+ id="bot.username.check",
3027
+ request=UsernameCheckReq,
3028
+ response=BotUsernameCheck,
3029
+ impl=username_check,
3030
+ summary="Check whether a bot username is available",
3031
+ columns=("username", "available", "reason"),
3032
+ headers=("Username", "Free", "Reason"),
3033
+ example={"username": "my_helper_bot", "available": True},
3034
+ example_args="bot username check my_helper_bot",
3035
+ covers=("bots.check-bot-username",),
3036
+ )
3037
+
3038
+
3039
+ class UsernameSetReq(Request):
3040
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot I own.")]
3041
+ enable: Annotated[
3042
+ list[str], opt("--enable", metavar="NAME", help="Usernames to activate.")
3043
+ ] = []
3044
+ disable: Annotated[
3045
+ list[str], opt("--disable", metavar="NAME", help="Usernames to deactivate.")
3046
+ ] = []
3047
+ order: Annotated[str | None, opt("--order", metavar="A,B,C", help="New display order.")] = None
3048
+
3049
+
3050
+ async def username_set(ctx: OpContext, req: UsernameSetReq) -> BotUsernames:
3051
+ """Enable, disable and reorder my bot's public usernames."""
3052
+ from telethon.tl.functions import bots as fn
3053
+
3054
+ bot = await _bots.input_user(ctx, req.bot)
3055
+ handle = client(ctx)
3056
+ for name in req.enable:
3057
+ await handle(fn.ToggleUsernameRequest(bot=bot, username=name.lstrip("@"), active=True))
3058
+ for name in req.disable:
3059
+ await handle(fn.ToggleUsernameRequest(bot=bot, username=name.lstrip("@"), active=False))
3060
+ if req.order:
3061
+ order = [n.strip().lstrip("@") for n in req.order.split(",") if n.strip()]
3062
+ await handle(fn.ReorderUsernamesRequest(bot=bot, order=order))
3063
+
3064
+ peer = await _send.resolve(ctx, req.bot)
3065
+ _full_user, user = await _full(ctx, peer)
3066
+ return BotUsernames(bot_id=_send.peer_id_of(peer), usernames=_usernames(user))
3067
+
3068
+
3069
+ SPEC_USERNAME_SET = OperationSpec(
3070
+ id="bot.username.set",
3071
+ request=UsernameSetReq,
3072
+ response=BotUsernames,
3073
+ impl=username_set,
3074
+ summary="Enable, disable and reorder my bot's usernames",
3075
+ mutating=True,
3076
+ columns=("bot_id", "usernames"),
3077
+ headers=("Bot", "Usernames"),
3078
+ example={"bot_id": 5000001, "usernames": ["my_helper_bot"]},
3079
+ example_args="bot username set @my_helper_bot --enable my_helper_bot",
3080
+ covers=("bots.bot-usernames",),
3081
+ )
3082
+
3083
+
3084
+ class TokenExportReq(Request):
3085
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The managed bot.")]
3086
+ revoke: Annotated[
3087
+ bool, opt("--revoke", help="Invalidate the old token and issue a new one.")
3088
+ ] = False
3089
+ out: Annotated[
3090
+ str | None, opt("--out", metavar="PATH", kind="path", help="Write it here, mode 0600.")
3091
+ ] = None
3092
+ show: Annotated[bool, opt("--show", help="Print the token; it is redacted by default.")] = False
3093
+
3094
+
3095
+ async def token_export(ctx: OpContext, req: TokenExportReq) -> BotToken:
3096
+ """Export (or revoke and re-export) a managed bot's API token.
3097
+
3098
+ The returned string is a full credential: anyone holding it *is* the bot.
3099
+ It is therefore not printed unless `--show` or `--out` says so, and
3100
+ `--out` writes with mode 0600 rather than leaving it in shell history.
3101
+ """
3102
+ import os
3103
+ from pathlib import Path
3104
+
3105
+ from telethon.tl.functions import bots as fn
3106
+
3107
+ bot = await _bots.input_user(ctx, req.bot)
3108
+ peer = await _send.resolve(ctx, req.bot)
3109
+ exported = await client(ctx)(fn.ExportBotTokenRequest(bot=bot, revoke=req.revoke))
3110
+ token = str(getattr(exported, "token", "") or "")
3111
+ result = BotToken(bot_id=_send.peer_id_of(peer), revoked=req.revoke)
3112
+
3113
+ if req.out:
3114
+ path = Path(os.path.expanduser(req.out))
3115
+ path.parent.mkdir(parents=True, exist_ok=True)
3116
+ descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
3117
+ try:
3118
+ os.write(descriptor, token.encode())
3119
+ finally:
3120
+ os.close(descriptor)
3121
+ result.path = str(path)
3122
+ if req.show:
3123
+ result.token = token
3124
+ elif not req.out:
3125
+ ctx.warn("the token is redacted; pass --show to print it or --out to write it to a file")
3126
+ return result
3127
+
3128
+
3129
+ SPEC_TOKEN_EXPORT = OperationSpec(
3130
+ id="bot.token.export",
3131
+ request=TokenExportReq,
3132
+ response=BotToken,
3133
+ impl=token_export,
3134
+ summary="Export a managed bot's API token",
3135
+ description=(
3136
+ "`--revoke` breaks every deployment still using the old token, which "
3137
+ "is why it is confirmed like a deletion."
3138
+ ),
3139
+ mutating=True,
3140
+ destructive=True,
3141
+ columns=("bot_id", "revoked", "path"),
3142
+ headers=("Bot", "Revoked", "Path"),
3143
+ example={"bot_id": 5000001, "revoked": False},
3144
+ example_args="bot token export @my_helper_bot --out ./token",
3145
+ covers=("bots.managed-bot-token",),
3146
+ )
3147
+
3148
+
3149
+ # ---------------------------------------------------------------------------
3150
+ # bot access get / set
3151
+ # ---------------------------------------------------------------------------
3152
+
3153
+
3154
+ class AccessGetReq(Request):
3155
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The managed bot.")]
3156
+
3157
+
3158
+ async def access_get(ctx: OpContext, req: AccessGetReq) -> BotAccess:
3159
+ """Who is allowed to use a managed bot."""
3160
+ from telethon.tl.functions import bots as fn
3161
+
3162
+ settings = await client(ctx)(
3163
+ fn.GetAccessSettingsRequest(bot=await _bots.input_user(ctx, req.bot))
3164
+ )
3165
+ return _access(settings)
3166
+
3167
+
3168
+ SPEC_ACCESS_GET = OperationSpec(
3169
+ id="bot.access.get",
3170
+ request=AccessGetReq,
3171
+ response=BotAccess,
3172
+ impl=access_get,
3173
+ summary="Show who may use a managed bot",
3174
+ columns=("restricted", "allowed_users", "allowed_chats"),
3175
+ headers=("Restricted", "Users", "Chats"),
3176
+ example={"restricted": True, "allowed_users": [4242], "allowed_chats": []},
3177
+ example_args="bot access get @my_helper_bot",
3178
+ covers_partial=("bots.managed-bot-access-settings",),
3179
+ coverage_note="Changing the list is `bot access set`.",
3180
+ )
3181
+
3182
+
3183
+ class AccessSetReq(Request):
3184
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The managed bot.")]
3185
+ restricted: Annotated[
3186
+ bool, opt("--restricted", help="Only the listed peers may use the bot.")
3187
+ ] = False
3188
+ open_to_all: Annotated[bool, opt("--open", help="Anyone may use the bot.")] = False
3189
+ add: Annotated[
3190
+ list[PeerRef], opt("--add", metavar="USER", kind="user", help="Peers to allow.")
3191
+ ] = []
3192
+ remove: Annotated[
3193
+ list[PeerRef], opt("--remove", metavar="USER", kind="user", help="Peers to disallow.")
3194
+ ] = []
3195
+
3196
+
3197
+ async def access_set(ctx: OpContext, req: AccessSetReq) -> BotAccess:
3198
+ """Restrict or open who may use a managed bot.
3199
+
3200
+ `bots.editAccessSettings` takes the whole allow-list, not a delta, so
3201
+ `--add`/`--remove` are applied to the list the server currently holds
3202
+ rather than replacing it — otherwise adding one user would silently drop
3203
+ everybody else.
3204
+ """
3205
+ from telethon.tl.functions import bots as fn
3206
+
3207
+ if req.restricted and req.open_to_all:
3208
+ raise UsageError("--restricted and --open contradict each other", field="restricted")
3209
+
3210
+ bot = await _bots.input_user(ctx, req.bot)
3211
+ handle = client(ctx)
3212
+ current = _access(await handle(fn.GetAccessSettingsRequest(bot=bot)))
3213
+
3214
+ allowed = list(current.allowed_users)
3215
+ for ref in req.add:
3216
+ peer_id = _send.peer_id_of(await _send.resolve(ctx, ref))
3217
+ if peer_id not in allowed:
3218
+ allowed.append(peer_id)
3219
+ for ref in req.remove:
3220
+ peer_id = _send.peer_id_of(await _send.resolve(ctx, ref))
3221
+ allowed = [entry for entry in allowed if entry != peer_id]
3222
+
3223
+ users = [
3224
+ await _bots.input_user(ctx, _bots.peer_ref(str(peer_id)), field="add")
3225
+ for peer_id in allowed
3226
+ ]
3227
+ restricted = True if req.restricted else False if req.open_to_all else current.restricted
3228
+ await handle(
3229
+ fn.EditAccessSettingsRequest(
3230
+ bot=bot, restricted=restricted or None, add_users=users or None
3231
+ )
3232
+ )
3233
+ return BotAccess(
3234
+ restricted=restricted, allowed_users=allowed, allowed_chats=current.allowed_chats
3235
+ )
3236
+
3237
+
3238
+ SPEC_ACCESS_SET = OperationSpec(
3239
+ id="bot.access.set",
3240
+ request=AccessSetReq,
3241
+ response=BotAccess,
3242
+ impl=access_set,
3243
+ summary="Restrict or open who may use a managed bot",
3244
+ mutating=True,
3245
+ columns=("restricted", "allowed_users"),
3246
+ headers=("Restricted", "Users"),
3247
+ example={"restricted": True, "allowed_users": [4242]},
3248
+ example_args="bot access set @my_helper_bot --restricted --add @alice",
3249
+ covers=("bots.managed-bot-access-settings",),
3250
+ )
3251
+
3252
+
3253
+ # ---------------------------------------------------------------------------
3254
+ # bot default-rights set
3255
+ # ---------------------------------------------------------------------------
3256
+
3257
+
3258
+ class DefaultRightsReq(Request):
3259
+ group: Annotated[
3260
+ str | None, opt("--group", metavar="RIGHTS", help="'+'-joined rights for groups.")
3261
+ ] = None
3262
+ channel: Annotated[
3263
+ str | None, opt("--channel", metavar="RIGHTS", help="'+'-joined rights for channels.")
3264
+ ] = None
3265
+
3266
+
3267
+ async def default_rights_set(ctx: OpContext, req: DefaultRightsReq) -> DefaultRights:
3268
+ """The admin rights clients pre-tick when my bot is added somewhere.
3269
+
3270
+ A suggestion, not a grant: the person adding the bot still confirms it.
3271
+ Reading them back is `bot get`.
3272
+ """
3273
+ from telethon.tl.functions import bots as fn
3274
+
3275
+ await _bots.require_bot_session(ctx, "setting suggested admin rights")
3276
+ if not req.group and not req.channel:
3277
+ raise UsageError("give --group and/or --channel", field="group")
3278
+
3279
+ handle = client(ctx)
3280
+ result = DefaultRights()
3281
+ if req.group:
3282
+ rights = _bots.admin_rights(req.group, field="group")
3283
+ await handle(fn.SetBotGroupDefaultAdminRightsRequest(admin_rights=rights))
3284
+ result.group_rights = _bots.rights_keywords(rights)
3285
+ if req.channel:
3286
+ rights = _bots.admin_rights(req.channel, field="channel")
3287
+ await handle(fn.SetBotBroadcastDefaultAdminRightsRequest(admin_rights=rights))
3288
+ result.channel_rights = _bots.rights_keywords(rights)
3289
+ return result
3290
+
3291
+
3292
+ SPEC_DEFAULT_RIGHTS_SET = OperationSpec(
3293
+ id="bot.default-rights.set",
3294
+ request=DefaultRightsReq,
3295
+ response=DefaultRights,
3296
+ impl=default_rights_set,
3297
+ summary="Set the admin rights clients pre-tick for my bot",
3298
+ aliases=("bot.default_rights.set",),
3299
+ mutating=True,
3300
+ columns=("group_rights", "channel_rights"),
3301
+ headers=("Group", "Channel"),
3302
+ example={"group_rights": ["delete_messages"], "channel_rights": []},
3303
+ example_args="bot default-rights set --group delete_messages+invite_users",
3304
+ covers=("bots.suggested-admin-rights",),
3305
+ )
3306
+
3307
+
3308
+ # ---------------------------------------------------------------------------
3309
+ # bot verification get / set
3310
+ # ---------------------------------------------------------------------------
3311
+
3312
+
3313
+ class VerificationGetReq(Request):
3314
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="User, bot or channel.")]
3315
+
3316
+
3317
+ async def verification_get(ctx: OpContext, req: VerificationGetReq) -> BotVerification:
3318
+ """A peer's third-party verification badge.
3319
+
3320
+ Distinct from Telegram's own blue check: a bot-issued badge says a company
3321
+ vouches for this peer, which is a different claim, so both are reported.
3322
+ """
3323
+ from telethon.tl.functions import channels as channels_fn
3324
+
3325
+ peer = await _send.resolve(ctx, req.chat)
3326
+ if type(peer).__name__ == "InputPeerChannel":
3327
+ result = await client(ctx)(channels_fn.GetFullChannelRequest(channel=_input_channel(peer)))
3328
+ full = getattr(result, "full_chat", None)
3329
+ chats = {int(getattr(c, "id", 0)): c for c in (getattr(result, "chats", None) or [])}
3330
+ entity = chats.get(int(getattr(full, "id", 0) or 0))
3331
+ else:
3332
+ full, entity = await _full(ctx, peer)
3333
+ return _verification(full, entity) or BotVerification()
3334
+
3335
+
3336
+ SPEC_VERIFICATION_GET = OperationSpec(
3337
+ id="bot.verification.get",
3338
+ request=VerificationGetReq,
3339
+ response=BotVerification,
3340
+ impl=verification_get,
3341
+ summary="Show a peer's third-party verification badge",
3342
+ columns=("verified_by_bot", "description", "telegram_verified"),
3343
+ headers=("By bot", "Description", "Telegram"),
3344
+ example={"verified_by_bot": 5000001, "description": "Verified merchant"},
3345
+ example_args="bot verification get @alice",
3346
+ covers=("bots.bot-verification-view",),
3347
+ )
3348
+
3349
+
3350
+ class VerificationSetReq(Request):
3351
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Peer to verify.")]
3352
+ bot: Annotated[
3353
+ PeerRef | None, opt("--bot", metavar="BOT", kind="user", help="My verifier bot.")
3354
+ ] = None
3355
+ description: Annotated[str | None, opt("--description", help="Custom badge description.")] = (
3356
+ None
3357
+ )
3358
+ remove: Annotated[bool, opt("--remove", help="Remove the verification.")] = False
3359
+
3360
+
3361
+ async def verification_set(ctx: OpContext, req: VerificationSetReq) -> BotVerified:
3362
+ """Verify or unverify a peer with my verifier bot."""
3363
+ from telethon.tl.functions import bots as fn
3364
+
3365
+ if req.bot is None:
3366
+ raise UsageError("--bot names the verifier bot and is required", field="bot")
3367
+ peer = await _send.resolve(ctx, req.chat)
3368
+ await client(ctx)(
3369
+ fn.SetCustomVerificationRequest(
3370
+ peer=peer,
3371
+ enabled=None if req.remove else True,
3372
+ bot=await _bots.input_user(ctx, req.bot),
3373
+ custom_description=req.description,
3374
+ )
3375
+ )
3376
+ return BotVerified(
3377
+ peer_id=_send.peer_id_of(peer),
3378
+ verified=not req.remove,
3379
+ description=req.description,
3380
+ )
3381
+
3382
+
3383
+ SPEC_VERIFICATION_SET = OperationSpec(
3384
+ id="bot.verification.set",
3385
+ request=VerificationSetReq,
3386
+ response=BotVerified,
3387
+ impl=verification_set,
3388
+ summary="Verify or unverify a peer with my verifier bot",
3389
+ tags=frozenset({"visible-to-others"}),
3390
+ mutating=True,
3391
+ destructive=True,
3392
+ columns=("peer_id", "verified", "description"),
3393
+ headers=("Peer", "Verified", "Description"),
3394
+ example={"peer_id": 4242, "verified": True},
3395
+ example_args="bot verification set @alice --bot @my_verifier_bot",
3396
+ covers=("bots.bot-verification-set",),
3397
+ )
3398
+
3399
+
3400
+ # ---------------------------------------------------------------------------
3401
+ # bot preview list / add / edit / delete
3402
+ # ---------------------------------------------------------------------------
3403
+
3404
+
3405
+ class PreviewListReq(Request):
3406
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot.")]
3407
+ lang: Annotated[str, opt("--lang", metavar="CODE", help="Language code.")] = ""
3408
+ owner: Annotated[bool, opt("--owner", help="Owner view, including per-language sets.")] = False
3409
+
3410
+
3411
+ def _preview(media: Any, index: int, lang: str | None) -> PreviewMedia:
3412
+ inner = getattr(media, "media", media)
3413
+ date = getattr(media, "date", None)
3414
+ document = getattr(inner, "document", None)
3415
+ photo = getattr(inner, "photo", None)
3416
+ target = document if document is not None else photo
3417
+ return PreviewMedia(
3418
+ index=index,
3419
+ kind="video" if document is not None else "photo",
3420
+ date=fmt_dt(date),
3421
+ date_unix=to_unix(date),
3422
+ lang=lang or None,
3423
+ file_id=_id_of(target),
3424
+ size=getattr(target, "size", None),
3425
+ )
3426
+
3427
+
3428
+ async def preview_list(ctx: OpContext, req: PreviewListReq) -> Page[PreviewMedia]:
3429
+ """The mini-app preview gallery on a bot's profile.
3430
+
3431
+ `userFull.has_preview_medias` says whether this call is worth making at
3432
+ all, and `bot get` reports it — asking for a gallery that does not exist
3433
+ is a round trip for an empty list.
3434
+ """
3435
+ from telethon.tl.functions import bots as fn
3436
+
3437
+ bot = await _bots.input_user(ctx, req.bot)
3438
+ if req.owner:
3439
+ result = await client(ctx)(fn.GetPreviewInfoRequest(bot=bot, lang_code=req.lang))
3440
+ media = getattr(result, "media", None) or []
3441
+ else:
3442
+ media = await client(ctx)(fn.GetPreviewMediasRequest(bot=bot)) or []
3443
+ items = [_preview(entry, index, req.lang) for index, entry in enumerate(media)]
3444
+ return Page(items=items, has_more=False, total=len(items))
3445
+
3446
+
3447
+ SPEC_PREVIEW_LIST = OperationSpec(
3448
+ id="bot.preview.list",
3449
+ request=PreviewListReq,
3450
+ response=Page[PreviewMedia],
3451
+ impl=preview_list,
3452
+ summary="List a bot's mini-app preview media",
3453
+ columns=("index", "kind", "lang", "file_id"),
3454
+ headers=("#", "Kind", "Lang", "File"),
3455
+ example={"items": [{"index": 0, "kind": "photo"}], "has_more": False},
3456
+ example_args="bot preview list @my_helper_bot",
3457
+ covers=("bot.media-previews", "bots.preview-info-per-language", "bots.preview-medias-list"),
3458
+ )
3459
+
3460
+
3461
+ class PreviewAddReq(Request):
3462
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot I own.")]
3463
+ file: Annotated[str, arg(1, metavar="FILE", kind="path", help="Image or video.")]
3464
+ lang: Annotated[str, opt("--lang", metavar="CODE", help="Language code.")] = ""
3465
+
3466
+
3467
+ async def _uploaded_media(ctx: OpContext, source: str) -> Any:
3468
+ """A local file as the `InputMedia` a `bots.*PreviewMedia*` call wants.
3469
+
3470
+ `messages.uploadMedia` is the step that turns an uploaded file handle into
3471
+ a document the server already holds; handing the raw handle to
3472
+ `addPreviewMedia` would upload it again for every call.
3473
+ """
3474
+ from telethon.tl import types
3475
+ from telethon.tl.functions import messages as fn
3476
+
3477
+ media = await _send.input_media(ctx, source)
3478
+ stored = await client(ctx)(fn.UploadMediaRequest(peer=types.InputPeerSelf(), media=media))
3479
+ document = _media.document_of(stored)
3480
+ if document is not None:
3481
+ return types.InputMediaDocument(id=_media.input_document(document))
3482
+ photo = getattr(stored, "photo", None)
3483
+ if photo is None:
3484
+ raise NotFoundError("the server did not accept that file as preview media")
3485
+ return types.InputMediaPhoto(id=_media.input_photo(photo))
3486
+
3487
+
3488
+ async def preview_add(ctx: OpContext, req: PreviewAddReq) -> PreviewChange:
3489
+ """Add one preview media to my bot's mini-app gallery."""
3490
+ from telethon.tl.functions import bots as fn
3491
+
3492
+ bot = await _bots.input_user(ctx, req.bot)
3493
+ media = await _uploaded_media(ctx, req.file)
3494
+ result = await client(ctx)(fn.AddPreviewMediaRequest(bot=bot, lang_code=req.lang, media=media))
3495
+ document = getattr(getattr(result, "media", result), "document", None)
3496
+ return PreviewChange(
3497
+ index=0, kind="video" if document is not None else "photo", lang=req.lang or None
3498
+ )
3499
+
3500
+
3501
+ SPEC_PREVIEW_ADD = OperationSpec(
3502
+ id="bot.preview.add",
3503
+ request=PreviewAddReq,
3504
+ response=PreviewChange,
3505
+ impl=preview_add,
3506
+ summary="Add a preview media to my bot's mini-app gallery",
3507
+ mutating=True,
3508
+ rate_class="file",
3509
+ columns=("index", "kind", "lang"),
3510
+ headers=("#", "Kind", "Lang"),
3511
+ example={"index": 0, "kind": "photo"},
3512
+ example_args="bot preview add @my_helper_bot ./shot.png",
3513
+ covers=("bots.preview-media-add",),
3514
+ )
3515
+
3516
+
3517
+ class PreviewEditReq(Request):
3518
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot I own.")]
3519
+ index: Annotated[int | None, opt("--index", metavar="N", help="Position to replace.")] = None
3520
+ file: Annotated[
3521
+ str | None, opt("--file", metavar="PATH", kind="path", help="New media for --index.")
3522
+ ] = None
3523
+ order: Annotated[
3524
+ str | None, opt("--order", metavar="2,0,1", help="New order for the whole gallery.")
3525
+ ] = None
3526
+ lang: Annotated[str, opt("--lang", metavar="CODE", help="Language code.")] = ""
3527
+
3528
+
3529
+ async def _current_previews(ctx: OpContext, bot: Any) -> list[Any]:
3530
+ from telethon.tl.functions import bots as fn
3531
+
3532
+ return list(await client(ctx)(fn.GetPreviewMediasRequest(bot=bot)) or [])
3533
+
3534
+
3535
+ def _as_input_media(entry: Any) -> Any:
3536
+ from telethon.tl import types
3537
+
3538
+ inner = getattr(entry, "media", entry)
3539
+ document = getattr(inner, "document", None)
3540
+ if document is not None:
3541
+ return types.InputMediaDocument(id=_media.input_document(document))
3542
+ return types.InputMediaPhoto(id=_media.input_photo(getattr(inner, "photo", None)))
3543
+
3544
+
3545
+ async def preview_edit(ctx: OpContext, req: PreviewEditReq) -> PreviewChange:
3546
+ """Replace one preview media, or reorder the gallery.
3547
+
3548
+ Both take the *current* media as their handle, so the gallery is fetched
3549
+ first: an index alone means nothing to the server.
3550
+ """
3551
+ from telethon.tl.functions import bots as fn
3552
+
3553
+ if (req.index is None) == (req.order is None):
3554
+ raise UsageError("give either --index with --file, or --order", field="index")
3555
+ index = req.index if req.index is not None else -1
3556
+
3557
+ bot = await _bots.input_user(ctx, req.bot)
3558
+ current = await _current_previews(ctx, bot)
3559
+ handle = client(ctx)
3560
+
3561
+ if req.order is not None:
3562
+ try:
3563
+ positions = [int(p) for p in req.order.split(",") if p.strip()]
3564
+ except ValueError as exc:
3565
+ raise UsageError("--order: expected a comma-separated list", field="order") from exc
3566
+ if sorted(positions) != list(range(len(current))):
3567
+ raise UsageError(
3568
+ f"--order must name every position exactly once (0..{len(current) - 1})",
3569
+ field="order",
3570
+ )
3571
+ await handle(
3572
+ fn.ReorderPreviewMediasRequest(
3573
+ bot=bot,
3574
+ lang_code=req.lang,
3575
+ order=[_as_input_media(current[p]) for p in positions],
3576
+ )
3577
+ )
3578
+ return PreviewChange(order=positions, lang=req.lang or None)
3579
+
3580
+ if not req.file:
3581
+ raise UsageError("--index needs --file", field="file")
3582
+ if not 0 <= index < len(current):
3583
+ raise NotFoundError(f"there is no preview media at position {index}")
3584
+ await handle(
3585
+ fn.EditPreviewMediaRequest(
3586
+ bot=bot,
3587
+ lang_code=req.lang,
3588
+ media=_as_input_media(current[index]),
3589
+ new_media=await _uploaded_media(ctx, req.file),
3590
+ )
3591
+ )
3592
+ return PreviewChange(index=req.index, lang=req.lang or None)
3593
+
3594
+
3595
+ SPEC_PREVIEW_EDIT = OperationSpec(
3596
+ id="bot.preview.edit",
3597
+ request=PreviewEditReq,
3598
+ response=PreviewChange,
3599
+ impl=preview_edit,
3600
+ summary="Replace one preview media, or reorder the gallery",
3601
+ mutating=True,
3602
+ rate_class="file",
3603
+ columns=("index", "order", "lang"),
3604
+ headers=("#", "Order", "Lang"),
3605
+ example={"index": 0, "lang": "en"},
3606
+ example_args="bot preview edit @my_helper_bot --order 1,0",
3607
+ covers=("bots.preview-media-edit", "bots.preview-media-reorder"),
3608
+ )
3609
+
3610
+
3611
+ class PreviewDeleteReq(Request):
3612
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot I own.")]
3613
+ index: Annotated[list[int], opt("--index", metavar="N", help="Positions to delete.")] = []
3614
+ lang: Annotated[str, opt("--lang", metavar="CODE", help="Language code.")] = ""
3615
+
3616
+
3617
+ async def preview_delete(ctx: OpContext, req: PreviewDeleteReq) -> PreviewChange:
3618
+ """Delete preview media from my bot's gallery."""
3619
+ from telethon.tl.functions import bots as fn
3620
+
3621
+ if not req.index:
3622
+ raise UsageError("name at least one --index", field="index")
3623
+ bot = await _bots.input_user(ctx, req.bot)
3624
+ current = await _current_previews(ctx, bot)
3625
+ missing = [i for i in req.index if not 0 <= i < len(current)]
3626
+ if missing:
3627
+ raise NotFoundError(f"there is no preview media at position {missing[0]}")
3628
+ await client(ctx)(
3629
+ fn.DeletePreviewMediaRequest(
3630
+ bot=bot,
3631
+ lang_code=req.lang,
3632
+ media=[_as_input_media(current[i]) for i in req.index],
3633
+ )
3634
+ )
3635
+ return PreviewChange(
3636
+ deleted=len(req.index), remaining=len(current) - len(req.index), lang=req.lang or None
3637
+ )
3638
+
3639
+
3640
+ SPEC_PREVIEW_DELETE = OperationSpec(
3641
+ id="bot.preview.delete",
3642
+ request=PreviewDeleteReq,
3643
+ response=PreviewChange,
3644
+ impl=preview_delete,
3645
+ summary="Delete preview media from my bot's gallery",
3646
+ mutating=True,
3647
+ destructive=True,
3648
+ columns=("deleted", "remaining"),
3649
+ headers=("Deleted", "Remaining"),
3650
+ example={"deleted": 1, "remaining": 2},
3651
+ example_args="bot preview delete @my_helper_bot --index 0",
3652
+ covers=("bots.preview-media-delete",),
3653
+ )
3654
+
3655
+
3656
+ # ---------------------------------------------------------------------------
3657
+ # bot affiliate
3658
+ # ---------------------------------------------------------------------------
3659
+
3660
+
3661
+ class AffiliateSetReq(Request):
3662
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot I own.")]
3663
+ commission_permille: Annotated[
3664
+ int, opt("--commission-permille", metavar="N", help="Commission in permille.")
3665
+ ] = 0
3666
+ duration_months: Annotated[
3667
+ int | None,
3668
+ opt("--duration-months", metavar="N", help="Program duration; omit for unlimited."),
3669
+ ] = None
3670
+
3671
+
3672
+ async def affiliate_set(ctx: OpContext, req: AffiliateSetReq) -> StarRefProgram:
3673
+ """Create or raise my bot's affiliate (star-ref) program.
3674
+
3675
+ Commission and duration may only ever be *raised*: Telegram will not let a
3676
+ program get worse for the affiliates already in it. The bounds come from
3677
+ the server's own config keys rather than from constants here, because they
3678
+ change without a client release.
3679
+ """
3680
+ from telethon.tl.functions import bots as fn
3681
+
3682
+ config = await _media.app_config(ctx)
3683
+ if not bool(config.get("starref_program_allowed", True)):
3684
+ raise PermissionError_("affiliate programs are switched off for this account")
3685
+ low = _media.config_int(config, "starref_min_commission_permille", 1)
3686
+ high = _media.config_int(config, "starref_max_commission_permille", 800)
3687
+ if not low <= req.commission_permille <= high:
3688
+ raise UsageError(
3689
+ f"--commission-permille must be between {low} and {high}", field="commission_permille"
3690
+ )
3691
+
3692
+ result = await client(ctx)(
3693
+ fn.UpdateStarRefProgramRequest(
3694
+ bot=await _bots.input_user(ctx, req.bot),
3695
+ commission_permille=req.commission_permille,
3696
+ duration_months=req.duration_months,
3697
+ )
3698
+ )
3699
+ return _starref(result) or StarRefProgram(
3700
+ commission_permille=req.commission_permille, duration_months=req.duration_months
3701
+ )
3702
+
3703
+
3704
+ SPEC_AFFILIATE_SET = OperationSpec(
3705
+ id="bot.affiliate.set",
3706
+ request=AffiliateSetReq,
3707
+ response=StarRefProgram,
3708
+ impl=affiliate_set,
3709
+ summary="Create or raise my bot's affiliate program",
3710
+ mutating=True,
3711
+ columns=("bot_id", "commission_permille", "duration_months"),
3712
+ headers=("Bot", "Permille", "Months"),
3713
+ example={"bot_id": 5000001, "commission_permille": 200},
3714
+ example_args="bot affiliate set @my_helper_bot --commission-permille 200",
3715
+ covers=("bots.affiliate-program-set",),
3716
+ )
3717
+
3718
+
3719
+ class AffiliateUnsetReq(Request):
3720
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot I own.")]
3721
+
3722
+
3723
+ async def affiliate_unset(ctx: OpContext, req: AffiliateUnsetReq) -> StarRefProgram:
3724
+ """End my bot's affiliate program.
3725
+
3726
+ A commission of zero schedules termination roughly a day out, and no new
3727
+ program can be created before that date — which is why this is confirmed
3728
+ like a deletion even though nothing disappears immediately.
3729
+ """
3730
+ from telethon.tl.functions import bots as fn
3731
+
3732
+ result = await client(ctx)(
3733
+ fn.UpdateStarRefProgramRequest(
3734
+ bot=await _bots.input_user(ctx, req.bot), commission_permille=0
3735
+ )
3736
+ )
3737
+ return _starref(result) or StarRefProgram(commission_permille=0)
3738
+
3739
+
3740
+ SPEC_AFFILIATE_UNSET = OperationSpec(
3741
+ id="bot.affiliate.unset",
3742
+ request=AffiliateUnsetReq,
3743
+ response=StarRefProgram,
3744
+ impl=affiliate_unset,
3745
+ summary="End my bot's affiliate program",
3746
+ mutating=True,
3747
+ destructive=True,
3748
+ columns=("bot_id", "end_date"),
3749
+ headers=("Bot", "Ends"),
3750
+ example={"bot_id": 5000001, "commission_permille": 0},
3751
+ example_args="bot affiliate unset @my_helper_bot",
3752
+ covers=("bots.affiliate-program-end",),
3753
+ )
3754
+
3755
+
3756
+ class AffiliateJoinReq(Request):
3757
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot's program to join.")]
3758
+ send_as: Annotated[
3759
+ PeerRef | None,
3760
+ opt("--send-as", metavar="PEER", kind="peer", help="Join as me, a bot or a channel."),
3761
+ ] = None
3762
+
3763
+
3764
+ async def affiliate_join(ctx: OpContext, req: AffiliateJoinReq) -> StarRefProgram:
3765
+ """Join a bot's affiliate program and get my referral link."""
3766
+ from telethon.tl import types
3767
+ from telethon.tl.functions import payments as fn
3768
+
3769
+ config = await _media.app_config(ctx)
3770
+ if not bool(config.get("starref_connect_allowed", True)):
3771
+ raise PermissionError_("joining affiliate programs is switched off for this account")
3772
+ peer = (
3773
+ await _send.resolve(ctx, req.send_as) if req.send_as is not None else types.InputPeerSelf()
3774
+ )
3775
+ result = await client(ctx)(
3776
+ fn.ConnectStarRefBotRequest(peer=peer, bot=await _bots.input_user(ctx, req.bot))
3777
+ )
3778
+ connected = (getattr(result, "connected_bots", None) or [None])[0]
3779
+ return _connected_ref(connected) or StarRefProgram()
3780
+
3781
+
3782
+ def _connected_ref(entry: Any) -> StarRefProgram | None:
3783
+ if entry is None:
3784
+ return None
3785
+ date = getattr(entry, "date", None)
3786
+ return StarRefProgram(
3787
+ bot_id=int(getattr(entry, "bot_id", 0) or 0),
3788
+ url=getattr(entry, "url", None),
3789
+ commission_permille=int(getattr(entry, "commission_permille", 0) or 0),
3790
+ duration_months=getattr(entry, "duration_months", None),
3791
+ participants=getattr(entry, "participants", None),
3792
+ revenue=int(getattr(entry, "revenue", 0) or 0) or None,
3793
+ date=fmt_dt(date),
3794
+ date_unix=to_unix(date),
3795
+ revoked=bool(getattr(entry, "revoked", False)),
3796
+ )
3797
+
3798
+
3799
+ SPEC_AFFILIATE_JOIN = OperationSpec(
3800
+ id="bot.affiliate.join",
3801
+ request=AffiliateJoinReq,
3802
+ response=StarRefProgram,
3803
+ impl=affiliate_join,
3804
+ summary="Join a bot's affiliate program and get my referral link",
3805
+ mutating=True,
3806
+ columns=("bot_id", "url", "commission_permille"),
3807
+ headers=("Bot", "Link", "Permille"),
3808
+ example={
3809
+ "bot_id": 5000001,
3810
+ "url": "https://t.me/my_helper_bot?start=ref",
3811
+ "commission_permille": 200,
3812
+ },
3813
+ example_args="bot affiliate join @my_helper_bot",
3814
+ covers=("bots.affiliate-connect",),
3815
+ )
3816
+
3817
+
3818
+ class AffiliateListReq(Request):
3819
+ suggested: Annotated[
3820
+ bool, opt("--suggested", help="Browse mini apps with an open program.")
3821
+ ] = False
3822
+ send_as: Annotated[
3823
+ PeerRef | None,
3824
+ opt("--send-as", metavar="PEER", kind="peer", help="Act as me, a bot or a channel."),
3825
+ ] = None
3826
+ bot: Annotated[
3827
+ PeerRef | None,
3828
+ opt("--bot", metavar="BOT", kind="user", help="Only the program connected to this bot."),
3829
+ ] = None
3830
+ by: Annotated[str, choice("revenue", "date", help="Sort order for --suggested.")] = "revenue"
3831
+
3832
+
3833
+ async def affiliate_list(ctx: OpContext, req: AffiliateListReq) -> Page[StarRefProgram]:
3834
+ """Affiliate programs: mine, one of mine, or ones on offer.
3835
+
3836
+ Connected programs page by `(offset_date, offset_link)` *together* — two
3837
+ values, not one — so tlgr packs both into the single opaque cursor every
3838
+ other listing uses. A caller that had to carry two offsets by hand would
3839
+ be the only place in tlgr where pagination looks different.
3840
+ """
3841
+ from telethon.tl import types
3842
+ from telethon.tl.functions import payments as fn
3843
+
3844
+ limit, state = window(ctx, "bot.affiliate.list", PageKind.RATE, default=50)
3845
+ peer = (
3846
+ await _send.resolve(ctx, req.send_as) if req.send_as is not None else types.InputPeerSelf()
3847
+ )
3848
+ handle = client(ctx)
3849
+
3850
+ if req.suggested:
3851
+ result = await handle(
3852
+ fn.GetSuggestedStarRefBotsRequest(
3853
+ peer=peer,
3854
+ offset=str(state.get("offset", "") or ""),
3855
+ limit=limit,
3856
+ order_by_revenue=req.by == "revenue" or None,
3857
+ order_by_date=req.by == "date" or None,
3858
+ )
3859
+ )
3860
+ items = [
3861
+ _starref(entry) or StarRefProgram()
3862
+ for entry in (getattr(result, "suggested_bots", None) or [])
3863
+ ]
3864
+ next_offset = str(getattr(result, "next_offset", "") or "")
3865
+ return build_page(
3866
+ items,
3867
+ op="bot.affiliate.list",
3868
+ kind=PageKind.RATE,
3869
+ state={"offset": next_offset},
3870
+ account=ctx.account,
3871
+ has_more=bool(next_offset),
3872
+ )
3873
+
3874
+ if req.bot is not None:
3875
+ result = await handle(
3876
+ fn.GetConnectedStarRefBotRequest(peer=peer, bot=await _bots.input_user(ctx, req.bot))
3877
+ )
3878
+ entry = _connected_ref(getattr(result, "connected_bot", None))
3879
+ return Page(items=[entry] if entry else [], has_more=False, total=1 if entry else 0)
3880
+
3881
+ from tlgr.core.timefmt import parse_dt
3882
+
3883
+ offset_date = parse_dt(str(state["date"])) if state.get("date") else None
3884
+ result = await handle(
3885
+ fn.GetConnectedStarRefBotsRequest(
3886
+ peer=peer,
3887
+ limit=limit,
3888
+ offset_date=offset_date,
3889
+ offset_link=state.get("link") or None,
3890
+ )
3891
+ )
3892
+ entries = getattr(result, "connected_bots", None) or []
3893
+ items = [ref for ref in (_connected_ref(entry) for entry in entries) if ref is not None]
3894
+ last = items[-1] if items else None
3895
+ return build_page(
3896
+ items,
3897
+ op="bot.affiliate.list",
3898
+ kind=PageKind.RATE,
3899
+ state={"date": last.date if last else None, "link": last.url if last else None},
3900
+ account=ctx.account,
3901
+ limit=limit,
3902
+ total=getattr(result, "count", None),
3903
+ )
3904
+
3905
+
3906
+ SPEC_AFFILIATE_LIST = OperationSpec(
3907
+ id="bot.affiliate.list",
3908
+ request=AffiliateListReq,
3909
+ response=Page[StarRefProgram],
3910
+ impl=affiliate_list,
3911
+ summary="List affiliate programs I joined, or ones on offer",
3912
+ paginated=PageKind.RATE,
3913
+ columns=("bot_id", "url", "commission_permille", "revenue"),
3914
+ headers=("Bot", "Link", "Permille", "Revenue"),
3915
+ example={"items": [{"bot_id": 5000001, "commission_permille": 200}], "has_more": False},
3916
+ example_args="bot affiliate list",
3917
+ covers=("bots.affiliate-list-connected", "bots.affiliate-suggested"),
3918
+ )
3919
+
3920
+
3921
+ class AffiliateRevokeReq(Request):
3922
+ link: Annotated[str, arg(0, metavar="LINK", help="The referral link to revoke.")]
3923
+ send_as: Annotated[
3924
+ PeerRef | None,
3925
+ opt("--send-as", metavar="PEER", kind="peer", help="Peer the link belongs to."),
3926
+ ] = None
3927
+
3928
+
3929
+ async def affiliate_revoke(ctx: OpContext, req: AffiliateRevokeReq) -> StarRefProgram:
3930
+ """Revoke one of my affiliate links.
3931
+
3932
+ `STARREF_EXPIRED` means the link is already dead, which is the state the
3933
+ caller asked for — reported as `already` rather than as a failure.
3934
+ """
3935
+ from telethon.tl import types
3936
+ from telethon.tl.functions import payments as fn
3937
+
3938
+ peer = (
3939
+ await _send.resolve(ctx, req.send_as) if req.send_as is not None else types.InputPeerSelf()
3940
+ )
3941
+ try:
3942
+ result = await client(ctx)(
3943
+ fn.EditConnectedStarRefBotRequest(peer=peer, link=req.link, revoked=True)
3944
+ )
3945
+ except Exception as exc: # one server answer means "already done"
3946
+ if "STARREFEXPIRED" not in f"{type(exc).__name__} {exc}".upper().replace("_", ""):
3947
+ raise
3948
+ from tlgr.ops._common import already as mark_already
3949
+
3950
+ mark_already(ctx)
3951
+ return StarRefProgram(url=req.link, revoked=True)
3952
+ return _connected_ref(getattr(result, "connected_bot", None)) or StarRefProgram(
3953
+ url=req.link, revoked=True
3954
+ )
3955
+
3956
+
3957
+ SPEC_AFFILIATE_REVOKE = OperationSpec(
3958
+ id="bot.affiliate.revoke",
3959
+ request=AffiliateRevokeReq,
3960
+ response=StarRefProgram,
3961
+ impl=affiliate_revoke,
3962
+ summary="Revoke one of my affiliate links",
3963
+ mutating=True,
3964
+ destructive=True,
3965
+ idempotent=True,
3966
+ columns=("url", "revoked"),
3967
+ headers=("Link", "Revoked"),
3968
+ example={"url": "https://t.me/my_helper_bot?start=ref", "revoked": True},
3969
+ example_args="bot affiliate revoke https://t.me/my_helper_bot?start=ref",
3970
+ covers=("bots.affiliate-revoke",),
3971
+ )
3972
+
3973
+
3974
+ # ---------------------------------------------------------------------------
3975
+ # bot attach list / toggle, bot recent set
3976
+ # ---------------------------------------------------------------------------
3977
+
3978
+
3979
+ class AttachListReq(Request):
3980
+ bot: Annotated[
3981
+ PeerRef | None,
3982
+ opt("--bot", metavar="BOT", kind="user", help="Inspect one bot's entry."),
3983
+ ] = None
3984
+
3985
+
3986
+ def _attach_bot(entry: Any) -> AttachMenuBot:
3987
+ return AttachMenuBot(
3988
+ bot_id=int(getattr(entry, "bot_id", 0) or 0),
3989
+ short_name=getattr(entry, "short_name", None),
3990
+ peer_types=[
3991
+ _bots.BUTTON_TYPES.get(
3992
+ type(p).__name__, type(p).__name__.removeprefix("AttachMenuPeerType").lower()
3993
+ )
3994
+ for p in (getattr(entry, "peer_types", None) or [])
3995
+ ],
3996
+ inactive=bool(getattr(entry, "inactive", False)),
3997
+ request_write_access=bool(getattr(entry, "request_write_access", False)),
3998
+ show_in_attach_menu=bool(getattr(entry, "show_in_attach_menu", False)),
3999
+ show_in_side_menu=bool(getattr(entry, "show_in_side_menu", False)),
4000
+ side_menu_disclaimer_needed=bool(getattr(entry, "side_menu_disclaimer_needed", False)),
4001
+ )
4002
+
4003
+
4004
+ async def attach_list(ctx: OpContext, req: AttachListReq) -> Page[AttachMenuBot]:
4005
+ """The bots installed in my attachment and side menus."""
4006
+ from telethon.tl.functions import messages as fn
4007
+
4008
+ handle = client(ctx)
4009
+ if req.bot is not None:
4010
+ result = await handle(fn.GetAttachMenuBotRequest(bot=await _bots.input_user(ctx, req.bot)))
4011
+ entry = getattr(result, "bot", None)
4012
+ rows = [_attach_bot(entry)] if entry is not None else []
4013
+ _name_bots(rows, getattr(result, "users", None) or [])
4014
+ return Page(items=rows, has_more=False, total=len(rows))
4015
+
4016
+ result = await handle(fn.GetAttachMenuBotsRequest(hash=0))
4017
+ if type(result).__name__ == "AttachMenuBotsNotModified": # pragma: no cover - hash is 0
4018
+ return Page(items=[], has_more=False, total=0)
4019
+ rows = [_attach_bot(entry) for entry in (getattr(result, "bots", None) or [])]
4020
+ _name_bots(rows, getattr(result, "users", None) or [])
4021
+ return Page(items=rows, has_more=False, total=len(rows))
4022
+
4023
+
4024
+ def _name_bots(rows: list[AttachMenuBot], users: list[Any]) -> None:
4025
+ by_id = {int(getattr(u, "id", 0) or 0): u for u in users}
4026
+ for row in rows:
4027
+ user = by_id.get(row.bot_id)
4028
+ if user is not None:
4029
+ row.username = getattr(user, "username", None)
4030
+
4031
+
4032
+ SPEC_ATTACH_LIST = OperationSpec(
4033
+ id="bot.attach.list",
4034
+ request=AttachListReq,
4035
+ response=Page[AttachMenuBot],
4036
+ impl=attach_list,
4037
+ summary="List the bots in my attachment and side menus",
4038
+ columns=("bot_id", "username", "short_name", "show_in_attach_menu"),
4039
+ headers=("Bot", "Username", "Name", "Attach"),
4040
+ example={"items": [{"bot_id": 5000001, "short_name": "Helper"}], "has_more": False},
4041
+ example_args="bot attach list",
4042
+ covers=("attach.menu-bots", "bots.attach-menu-list"),
4043
+ )
4044
+
4045
+
4046
+ class AttachToggleReq(Request):
4047
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot.")]
4048
+ state: Annotated[str, arg(1, metavar="STATE", help="on = install, off = remove.")]
4049
+ allow_write: Annotated[
4050
+ bool, opt("--allow-write", help="CONSENT: also let the bot message me.")
4051
+ ] = False
4052
+ accept_tos: Annotated[
4053
+ bool, opt("--accept-tos", help="Required when the bot needs a side-menu disclaimer.")
4054
+ ] = False
4055
+
4056
+
4057
+ async def attach_toggle(ctx: OpContext, req: AttachToggleReq) -> ToggledAttachMenu:
4058
+ """Install or remove a bot from the attachment and side menu.
4059
+
4060
+ `write_allowed` is never set implicitly: installing a mini app and letting
4061
+ its bot message you are two different decisions, and Telegram's own API
4062
+ puts them in one call.
4063
+ """
4064
+ from telethon.tl.functions import messages as fn
4065
+
4066
+ if req.state not in ("on", "off"):
4067
+ raise UsageError("state must be 'on' or 'off'", field="state")
4068
+ bot = await _bots.input_user(ctx, req.bot)
4069
+ handle = client(ctx)
4070
+
4071
+ if req.state == "on":
4072
+ entry = getattr(await handle(fn.GetAttachMenuBotRequest(bot=bot)), "bot", None)
4073
+ if bool(getattr(entry, "side_menu_disclaimer_needed", False)) and not req.accept_tos:
4074
+ raise UsageError(
4075
+ "this bot requires you to accept its terms first; pass --accept-tos",
4076
+ field="accept_tos",
4077
+ )
4078
+ await handle(
4079
+ fn.ToggleBotInAttachMenuRequest(
4080
+ bot=bot, enabled=req.state == "on", write_allowed=req.allow_write or None
4081
+ )
4082
+ )
4083
+ peer = await _send.resolve(ctx, req.bot)
4084
+ return ToggledAttachMenu(
4085
+ bot_id=_send.peer_id_of(peer),
4086
+ installed=req.state == "on",
4087
+ write_allowed=req.allow_write,
4088
+ )
4089
+
4090
+
4091
+ SPEC_ATTACH_TOGGLE = OperationSpec(
4092
+ id="bot.attach.toggle",
4093
+ request=AttachToggleReq,
4094
+ response=ToggledAttachMenu,
4095
+ impl=attach_toggle,
4096
+ summary="Install or remove a bot from the attachment menu",
4097
+ mutating=True,
4098
+ destructive=True,
4099
+ columns=("bot_id", "installed", "write_allowed"),
4100
+ headers=("Bot", "Installed", "May message"),
4101
+ example={"bot_id": 5000001, "installed": True, "write_allowed": False},
4102
+ example_args="bot attach toggle @my_helper_bot on",
4103
+ covers=("bots.attach-menu-toggle", "bots.miniapp-panel-menu", "bots.webapp-write-access"),
4104
+ )
4105
+
4106
+
4107
+ class RecentSetReq(Request):
4108
+ state: Annotated[
4109
+ str | None, arg(0, metavar="STATE", required=False, help="on|off for the whole feature.")
4110
+ ] = None
4111
+ forget: Annotated[
4112
+ PeerRef | None,
4113
+ opt("--forget", metavar="BOT", kind="user", help="Reset the rating of one bot."),
4114
+ ] = None
4115
+ forget_all: Annotated[bool, opt("--forget-all", help="Reset the whole category.")] = False
4116
+ kind: Annotated[
4117
+ str, choice("pm", "inline", "app", "guest", help="Category the reset applies to.")
4118
+ ] = "pm"
4119
+
4120
+
4121
+ _TOP_PEER_CATEGORIES = {
4122
+ "pm": "TopPeerCategoryBotsPM",
4123
+ "inline": "TopPeerCategoryBotsInline",
4124
+ "app": "TopPeerCategoryBotsApp",
4125
+ "guest": "TopPeerCategoryBotsGuestChat",
4126
+ }
4127
+
4128
+
4129
+ async def recent_set(ctx: OpContext, req: RecentSetReq) -> RecentBots:
4130
+ """Turn frequently-used-bot suggestions on or off, or forget one bot."""
4131
+ from telethon.tl import types
4132
+ from telethon.tl.functions import contacts as fn
4133
+
4134
+ handle = client(ctx)
4135
+ forgotten: list[int] = []
4136
+ enabled = req.state != "off"
4137
+
4138
+ if req.state is not None:
4139
+ if req.state not in ("on", "off"):
4140
+ raise UsageError("state must be 'on' or 'off'", field="state")
4141
+ await handle(fn.ToggleTopPeersRequest(enabled=req.state == "on"))
4142
+
4143
+ category_name = _TOP_PEER_CATEGORIES[req.kind]
4144
+ category: Any = getattr(types, category_name, None)
4145
+ if category is None: # pragma: no cover - layer 227 has every category we name
4146
+ _bots.unsupported(f"--kind {req.kind}")
4147
+ if req.forget is not None:
4148
+ peer = await _send.resolve(ctx, req.forget)
4149
+ await handle(fn.ResetTopPeerRatingRequest(category=category(), peer=peer))
4150
+ forgotten = [_send.peer_id_of(peer)]
4151
+ elif req.forget_all:
4152
+ await handle(fn.ResetTopPeerRatingRequest(category=category(), peer=types.InputPeerEmpty()))
4153
+
4154
+ if req.state is None and req.forget is None and not req.forget_all:
4155
+ raise UsageError("give a state, --forget or --forget-all", field="state")
4156
+ return RecentBots(enabled=enabled, kind=req.kind, forgotten=forgotten)
4157
+
4158
+
4159
+ SPEC_RECENT_SET = OperationSpec(
4160
+ id="bot.recent.set",
4161
+ request=RecentSetReq,
4162
+ response=RecentBots,
4163
+ impl=recent_set,
4164
+ summary="Turn frequently-used-bot suggestions on or off",
4165
+ mutating=True,
4166
+ columns=("enabled", "kind", "forgotten"),
4167
+ headers=("Enabled", "Kind", "Forgotten"),
4168
+ example={"enabled": True, "kind": "pm", "forgotten": []},
4169
+ example_args="bot recent set off",
4170
+ covers=("bots.top-peers-bots",),
4171
+ )
4172
+
4173
+
4174
+ # ---------------------------------------------------------------------------
4175
+ # bot report, bot ad
4176
+ # ---------------------------------------------------------------------------
4177
+
4178
+
4179
+ class ReportReq(Request):
4180
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot or mini app owner.")]
4181
+ app: Annotated[str | None, opt("--app", help="Report a mini app by short name.")] = None
4182
+ message: Annotated[
4183
+ int | None, opt("--message", metavar="ID", kind="msg_id", help="Report one message.")
4184
+ ] = None
4185
+ ephemeral: Annotated[
4186
+ int | None, opt("--ephemeral", metavar="ID", help="Report an ephemeral message.")
4187
+ ] = None
4188
+ option: Annotated[
4189
+ str | None, opt("--option", metavar="BYTES", help="Option from the previous step.")
4190
+ ] = None
4191
+ comment: Annotated[str | None, opt("--comment", help="Free-text comment.")] = None
4192
+
4193
+
4194
+ async def report(ctx: OpContext, req: ReportReq) -> ReportOutcome:
4195
+ """Report a bot, a mini app or one of its messages.
4196
+
4197
+ Telegram's report flow is a state machine, not a form: the first call
4198
+ returns a list of options, each option leads to another list or to a
4199
+ comment box. One call per step is what lets a caller drive it without
4200
+ tlgr guessing which category they meant.
4201
+ """
4202
+ from telethon.tl.functions import messages as fn
4203
+
4204
+ if req.ephemeral is not None:
4205
+ _bots.unsupported("--ephemeral (ephemeral.reportMessage)")
4206
+
4207
+ peer = await _send.resolve(ctx, req.bot)
4208
+ ids = [int(req.message)] if req.message is not None else []
4209
+ result = await client(ctx)(
4210
+ fn.ReportRequest(
4211
+ peer=peer,
4212
+ id=ids,
4213
+ option=_bots.option_bytes(req.option),
4214
+ message=req.comment or "",
4215
+ )
4216
+ )
4217
+ outcome = _bots.report_outcome(result)
4218
+ if outcome.reported:
4219
+ ctx.emit("bot_report", {"bot_id": _send.peer_id_of(peer)})
4220
+ return outcome
4221
+
4222
+
4223
+ SPEC_REPORT = OperationSpec(
4224
+ id="bot.report",
4225
+ request=ReportReq,
4226
+ response=ReportOutcome,
4227
+ impl=report,
4228
+ summary="Report a bot or a mini app",
4229
+ mutating=True,
4230
+ columns=("result", "title", "reported"),
4231
+ headers=("Step", "Title", "Done"),
4232
+ example={"result": "choose_option", "title": "What is wrong?", "options": []},
4233
+ example_args="bot report @spam_bot",
4234
+ covers=("bots.report-bot-or-app",),
4235
+ covers_partial=("bots.miniapp-panel-menu",),
4236
+ coverage_note=(
4237
+ "Installing and removing a mini app is `bot attach toggle`; reporting "
4238
+ "an ephemeral message needs layer 229 and exits 13."
4239
+ ),
4240
+ )
4241
+
4242
+
4243
+ class AdListReq(Request):
4244
+ bot: Annotated[
4245
+ PeerRef | None,
4246
+ arg(0, metavar="BOT", required=False, kind="user", help="The bot chat."),
4247
+ ] = None
4248
+ search: Annotated[
4249
+ str | None,
4250
+ opt("--search", metavar="QUERY", help="Sponsored chats a search for QUERY would show."),
4251
+ ] = None
4252
+
4253
+
4254
+ async def ad_list(ctx: OpContext, req: AdListReq) -> Page[SponsoredMessage]:
4255
+ """The sponsored messages a bot chat — or a search — would show.
4256
+
4257
+ Opt-in, like `message sponsored list`: tlgr never mixes ads into a message
4258
+ listing or a search result, and never reports an impression that nobody
4259
+ saw — that is `bot ad read`. Both surfaces are here because both are ads,
4260
+ and splitting them would hide one of the two places they appear.
4261
+ """
4262
+ from telethon.tl.functions import contacts as contacts_fn
4263
+ from telethon.tl.functions import messages as fn
4264
+
4265
+ if req.search is not None:
4266
+ return await _sponsored_peers(ctx, req.search, contacts_fn)
4267
+ if req.bot is None:
4268
+ raise UsageError("name a bot chat, or use --search", field="bot")
4269
+
4270
+ peer = await _send.resolve(ctx, req.bot)
4271
+ result = await client(ctx)(fn.GetSponsoredMessagesRequest(peer=peer))
4272
+ items = [
4273
+ SponsoredMessage(
4274
+ random_id=_bots.key_text(getattr(entry, "random_id", b"")),
4275
+ title=getattr(entry, "title", None),
4276
+ message=str(getattr(entry, "message", "") or ""),
4277
+ url=getattr(entry, "url", None),
4278
+ button_text=getattr(entry, "button_text", None),
4279
+ sponsor_info=getattr(entry, "sponsor_info", None),
4280
+ additional_info=getattr(entry, "additional_info", None),
4281
+ recommended=bool(getattr(entry, "recommended", False)),
4282
+ can_report=bool(getattr(entry, "can_report", False)),
4283
+ )
4284
+ for entry in (getattr(result, "messages", None) or [])
4285
+ ]
4286
+ return Page(items=items, has_more=False, total=len(items))
4287
+
4288
+
4289
+ async def _sponsored_peers(ctx: OpContext, query: str, contacts_fn: Any) -> Page[SponsoredMessage]:
4290
+ """`contacts.getSponsoredPeers` as the same row a bot-chat ad produces.
4291
+
4292
+ A sponsored *peer* is an ad for a chat rather than a message in one, so it
4293
+ has a title and no body; reporting it in the same shape is what lets one
4294
+ `bot ad read` mark either kind as seen.
4295
+ """
4296
+ from tlgr.ops._serialize import entity_to_peer
4297
+
4298
+ result = await client(ctx)(contacts_fn.GetSponsoredPeersRequest(q=query))
4299
+ chats = {int(getattr(c, "id", 0)): c for c in (getattr(result, "chats", None) or [])}
4300
+ users = {int(getattr(u, "id", 0)): u for u in (getattr(result, "users", None) or [])}
4301
+ items: list[SponsoredMessage] = []
4302
+ for entry in getattr(result, "peers", None) or []:
4303
+ peer = getattr(entry, "peer", None)
4304
+ raw_id = int(
4305
+ getattr(peer, "channel_id", 0)
4306
+ or getattr(peer, "user_id", 0)
4307
+ or getattr(peer, "chat_id", 0)
4308
+ or 0
4309
+ )
4310
+ entity = chats.get(raw_id) or users.get(raw_id)
4311
+ items.append(
4312
+ SponsoredMessage(
4313
+ random_id=_bots.key_text(getattr(entry, "random_id", b"")),
4314
+ title=entity_to_peer(entity).title if entity is not None else None,
4315
+ message="",
4316
+ sponsor_info=getattr(entry, "sponsor_info", None),
4317
+ additional_info=getattr(entry, "additional_info", None),
4318
+ )
4319
+ )
4320
+ return Page(items=items, has_more=False, total=len(items))
4321
+
4322
+
4323
+ SPEC_AD_LIST = OperationSpec(
4324
+ id="bot.ad.list",
4325
+ request=AdListReq,
4326
+ response=Page[SponsoredMessage],
4327
+ impl=ad_list,
4328
+ summary="List the sponsored messages shown inside a bot chat",
4329
+ description=(
4330
+ "Telegram's API terms require a third-party client that shows bot or "
4331
+ "channel content to support sponsored messages; tlgr does so by "
4332
+ "making them a command of their own instead of hiding them in a feed."
4333
+ ),
4334
+ columns=("random_id", "title", "message"),
4335
+ headers=("ID", "Title", "Text"),
4336
+ example={"items": [{"random_id": "abc", "message": "An ad"}], "has_more": False},
4337
+ example_args="bot ad list @my_helper_bot",
4338
+ covers=("bots.bot-ads-account", "dialogs.sponsored-search-peers"),
4339
+ covers_partial=("bots.sponsored-message-in-bot-chat",),
4340
+ coverage_note="Reporting an impression or a click is `bot ad read`.",
4341
+ )
4342
+
4343
+
4344
+ class AdReadReq(Request):
4345
+ random_id: Annotated[str, arg(0, metavar="RANDOM_ID", help="random_id from `bot ad list`.")]
4346
+ click: Annotated[bool, opt("--click", help="Also record a click.")] = False
4347
+ media: Annotated[bool, opt("--media", help="The click was on the ad's media.")] = False
4348
+ fullscreen: Annotated[bool, opt("--fullscreen", help="The click was in fullscreen.")] = False
4349
+
4350
+
4351
+ async def ad_read(ctx: OpContext, req: AdReadReq) -> SponsoredRead:
4352
+ """Report that a sponsored message was seen, or clicked."""
4353
+ from telethon.tl.functions import messages as fn
4354
+
4355
+ handle = client(ctx)
4356
+ raw = _bots.option_bytes(req.random_id, field="random_id")
4357
+ await handle(fn.ViewSponsoredMessageRequest(random_id=raw))
4358
+ if req.click:
4359
+ await handle(
4360
+ fn.ClickSponsoredMessageRequest(
4361
+ random_id=raw, media=req.media or None, fullscreen=req.fullscreen or None
4362
+ )
4363
+ )
4364
+ return SponsoredRead(random_id=req.random_id, viewed=True, clicked=req.click)
4365
+
4366
+
4367
+ SPEC_AD_READ = OperationSpec(
4368
+ id="bot.ad.read",
4369
+ request=AdReadReq,
4370
+ response=SponsoredRead,
4371
+ impl=ad_read,
4372
+ summary="Mark a sponsored message as seen, or as clicked",
4373
+ aliases=("bot.ad.view",),
4374
+ mutating=True,
4375
+ columns=("random_id", "viewed", "clicked"),
4376
+ headers=("ID", "Viewed", "Clicked"),
4377
+ example={"random_id": "abc", "viewed": True, "clicked": False},
4378
+ example_args="bot ad read abc",
4379
+ covers=("bots.sponsored-message-in-bot-chat",),
4380
+ )
4381
+
4382
+
4383
+ class AdReportReq(Request):
4384
+ random_id: Annotated[str, arg(0, metavar="RANDOM_ID", help="random_id from `bot ad list`.")]
4385
+ option: Annotated[
4386
+ str | None, opt("--option", metavar="BYTES", help="Option from the previous step.")
4387
+ ] = None
4388
+ comment: Annotated[str | None, opt("--comment", help="Free-text comment.")] = None
4389
+
4390
+
4391
+ async def ad_report(ctx: OpContext, req: AdReportReq) -> ReportOutcome:
4392
+ """Report a sponsored message, walking the same option tree as `bot report`."""
4393
+ from telethon.tl.functions import messages as fn
4394
+
4395
+ result = await client(ctx)(
4396
+ fn.ReportSponsoredMessageRequest(
4397
+ random_id=_bots.option_bytes(req.random_id, field="random_id"),
4398
+ option=_bots.option_bytes(req.option),
4399
+ )
4400
+ )
4401
+ return _bots.report_outcome(result)
4402
+
4403
+
4404
+ SPEC_AD_REPORT = OperationSpec(
4405
+ id="bot.ad.report",
4406
+ request=AdReportReq,
4407
+ response=ReportOutcome,
4408
+ impl=ad_report,
4409
+ summary="Report a sponsored message in a bot chat",
4410
+ mutating=True,
4411
+ columns=("result", "title", "reported"),
4412
+ headers=("Step", "Title", "Done"),
4413
+ example={"result": "reported", "reported": True},
4414
+ example_args="bot ad report abc",
4415
+ covers_partial=("bots.sponsored-message-in-bot-chat",),
4416
+ coverage_note="Listing and viewing the ads themselves is `bot ad list`/`bot ad read`.",
4417
+ )
4418
+
4419
+
4420
+ # ---------------------------------------------------------------------------
4421
+ # bot game get / send, bot score list / set
4422
+ # ---------------------------------------------------------------------------
4423
+
4424
+
4425
+ class GameGetReq(Request):
4426
+ emoji: Annotated[
4427
+ str | None, opt("--emoji", metavar="EMOJI", help="Dice emoji this report is about.")
4428
+ ] = None
4429
+
4430
+
4431
+ async def game_get(ctx: OpContext, req: GameGetReq) -> EmojiGame:
4432
+ """Emoji-dice game parameters. Inspect only.
4433
+
4434
+ Staking TON on an emoji game moves money, so tlgr reads the parameters and
4435
+ stops there. `messages.getEmojiGameInfo` takes no arguments — `--emoji` is
4436
+ recorded on the answer so a caller can tell which game they asked about.
4437
+ """
4438
+ from telethon.tl.functions import messages as fn
4439
+
4440
+ result = await client(ctx)(fn.GetEmojiGameInfoRequest())
4441
+ if type(result).__name__ == "EmojiGameUnavailable":
4442
+ return EmojiGame(emoticon=req.emoji or "", available=False)
4443
+ return EmojiGame(
4444
+ emoticon=req.emoji or "",
4445
+ available=True,
4446
+ game_hash=getattr(result, "game_hash", None),
4447
+ prev_stake=getattr(result, "prev_stake", None),
4448
+ current_streak=getattr(result, "current_streak", None),
4449
+ params=[int(p) for p in (getattr(result, "params", None) or [])],
4450
+ plays_left=getattr(result, "plays_left", None),
4451
+ )
4452
+
4453
+
4454
+ SPEC_GAME_GET = OperationSpec(
4455
+ id="bot.game.get",
4456
+ request=GameGetReq,
4457
+ response=EmojiGame,
4458
+ impl=game_get,
4459
+ summary="Show emoji-dice game parameters",
4460
+ columns=("emoticon", "available", "current_streak"),
4461
+ headers=("Emoji", "Available", "Streak"),
4462
+ example={"emoticon": "🎲", "available": True, "current_streak": 0},
4463
+ example_args="bot game get --emoji 🎲",
4464
+ covers=("bots.emoji-games",),
4465
+ )
4466
+
4467
+
4468
+ class GameSendReq(Request):
4469
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot that owns the game.")]
4470
+ short_name: Annotated[str, arg(1, metavar="SHORT_NAME", help="Game short name.")]
4471
+ chat: Annotated[
4472
+ PeerRef | None, opt("--chat", metavar="CHAT", kind="peer", help="Destination chat.")
4473
+ ] = None
4474
+ reply_to: Annotated[
4475
+ int | None, opt("--reply-to", metavar="ID", kind="msg_id", help="Reply to this message.")
4476
+ ] = None
4477
+ silent: Annotated[bool, opt("--silent", help="Send without a notification.")] = False
4478
+ schedule: Annotated[str | None, opt("--schedule", metavar="TS", help="Schedule the send.")] = (
4479
+ None
4480
+ )
4481
+
4482
+
4483
+ async def game_send(ctx: OpContext, req: GameSendReq) -> GameSent:
4484
+ """Send an HTML5 game to a chat.
4485
+
4486
+ Only the owning bot may send by short name; a user can forward an existing
4487
+ game message but cannot mint one, which is why this is a bot-session
4488
+ command rather than a refusal from the server three steps later.
4489
+ """
4490
+ from telethon.tl import types
4491
+ from telethon.tl.functions import messages as fn
4492
+
4493
+ await _bots.require_bot_session(ctx, "sending a game by short name")
4494
+ if req.chat is None:
4495
+ raise UsageError("--chat is required", field="chat")
4496
+ target = await _send.resolve(ctx, req.chat)
4497
+ updates = await client(ctx)(
4498
+ fn.SendMediaRequest(
4499
+ peer=target,
4500
+ media=types.InputMediaGame(
4501
+ id=types.InputGameShortName(
4502
+ bot_id=await _bots.input_user(ctx, req.bot), short_name=req.short_name
4503
+ )
4504
+ ),
4505
+ message="",
4506
+ random_id=_random_id(),
4507
+ reply_to=await _send.reply_target(ctx, reply_to=req.reply_to),
4508
+ silent=req.silent or None,
4509
+ schedule_date=_send.schedule_at(req.schedule),
4510
+ )
4511
+ )
4512
+ message = _send.message_from_updates(updates, chat_id=_send.peer_id_of(target))
4513
+ game = getattr(getattr(message, "media", None), "game", None)
4514
+ return GameSent(
4515
+ chat_id=message.chat_id,
4516
+ msg_id=message.id,
4517
+ game_id=_id_of(game),
4518
+ short_name=req.short_name,
4519
+ )
4520
+
4521
+
4522
+ SPEC_GAME_SEND = OperationSpec(
4523
+ id="bot.game.send",
4524
+ request=GameSendReq,
4525
+ response=GameSent,
4526
+ impl=game_send,
4527
+ summary="Send an HTML5 game to a chat",
4528
+ tags=frozenset({"visible-to-others"}),
4529
+ mutating=True,
4530
+ rate_class="send",
4531
+ columns=("chat_id", "msg_id", "short_name"),
4532
+ headers=("Chat", "Message", "Game"),
4533
+ example={"chat_id": 4242, "msg_id": 12, "short_name": "tetris"},
4534
+ example_args="bot game send @my_helper_bot tetris --chat @alice",
4535
+ covers=("bots.send-game",),
4536
+ )
4537
+
4538
+
4539
+ class ScoreListReq(Request):
4540
+ chat: Annotated[
4541
+ PeerRef | None,
4542
+ arg(0, metavar="CHAT", required=False, kind="peer", help="Chat holding the game."),
4543
+ ] = None
4544
+ msg_id: Annotated[
4545
+ int | None, arg(1, metavar="MSG_ID", required=False, kind="msg_id", help="Game message.")
4546
+ ] = None
4547
+ inline_id: Annotated[
4548
+ str | None, opt("--inline-id", metavar="DC:ID:HASH", help="Inline message id.")
4549
+ ] = None
4550
+ user: Annotated[
4551
+ PeerRef | None,
4552
+ opt("--user", metavar="USER", kind="user", help="Centre the table on this user."),
4553
+ ] = None
4554
+
4555
+
4556
+ async def score_list(ctx: OpContext, req: ScoreListReq) -> Page[HighScore]:
4557
+ """A game's high-score table.
4558
+
4559
+ The inline variant has to be sent to the DC the inline message lives on;
4560
+ sending it home answers with an error that never mentions data centres.
4561
+ """
4562
+ from telethon.tl import types
4563
+ from telethon.tl.functions import messages as fn
4564
+
4565
+ user = (
4566
+ await _bots.input_user(ctx, req.user, field="user")
4567
+ if req.user is not None
4568
+ else types.InputUserSelf()
4569
+ )
4570
+ if req.inline_id:
4571
+ identifier = _bots.inline_message_id(req.inline_id)
4572
+ result = await _bots.on_dc(
4573
+ ctx,
4574
+ int(getattr(identifier, "dc_id", 0) or 0),
4575
+ fn.GetInlineGameHighScoresRequest(id=identifier, user_id=user),
4576
+ )
4577
+ else:
4578
+ if req.chat is None or req.msg_id is None:
4579
+ raise UsageError("give a chat and a message id, or --inline-id", field="msg_id")
4580
+ result = await client(ctx)(
4581
+ fn.GetGameHighScoresRequest(
4582
+ peer=await _send.resolve(ctx, req.chat), id=int(req.msg_id), user_id=user
4583
+ )
4584
+ )
4585
+ items = [
4586
+ HighScore(
4587
+ position=int(getattr(score, "pos", 0) or 0),
4588
+ user_id=int(getattr(score, "user_id", 0) or 0),
4589
+ score=int(getattr(score, "score", 0) or 0),
4590
+ )
4591
+ for score in (getattr(result, "scores", None) or [])
4592
+ ]
4593
+ return Page(items=items, has_more=False, total=len(items))
4594
+
4595
+
4596
+ SPEC_SCORE_LIST = OperationSpec(
4597
+ id="bot.score.list",
4598
+ request=ScoreListReq,
4599
+ response=Page[HighScore],
4600
+ impl=score_list,
4601
+ summary="Show a game's high-score table",
4602
+ columns=("position", "user_id", "score"),
4603
+ headers=("#", "User", "Score"),
4604
+ example={"items": [{"position": 1, "user_id": 4242, "score": 900}], "has_more": False},
4605
+ example_args="bot score list @alice 12",
4606
+ covers=("bots.game-high-scores", "bots.inline-game-high-scores"),
4607
+ )
4608
+
4609
+
4610
+ class ScoreSetReq(Request):
4611
+ chat: Annotated[
4612
+ PeerRef | None,
4613
+ arg(0, metavar="CHAT", required=False, kind="peer", help="Chat holding the game."),
4614
+ ] = None
4615
+ msg_id: Annotated[
4616
+ int | None, arg(1, metavar="MSG_ID", required=False, kind="msg_id", help="Game message.")
4617
+ ] = None
4618
+ inline_id: Annotated[
4619
+ str | None, opt("--inline-id", metavar="DC:ID:HASH", help="Inline message id.")
4620
+ ] = None
4621
+ user: Annotated[
4622
+ PeerRef | None, opt("--user", metavar="USER", kind="user", help="The player.")
4623
+ ] = None
4624
+ score: Annotated[int, opt("--score", metavar="N", help="New score.")] = 0
4625
+ edit_message: Annotated[bool, opt("--edit-message", help="Also update the game message.")] = (
4626
+ False
4627
+ )
4628
+ allow_lower: Annotated[
4629
+ bool, opt("--allow-lower", help="Allow the score to decrease (force).")
4630
+ ] = False
4631
+
4632
+
4633
+ async def score_set(ctx: OpContext, req: ScoreSetReq) -> ScoreSet:
4634
+ """Report a game score for a user.
4635
+
4636
+ `--allow-lower` is Telegram's `force`: without it the server keeps the
4637
+ player's best score, which is almost always what a leaderboard wants.
4638
+ It is spelled out rather than borrowed from the global `--yes`, which an
4639
+ operation never sees.
4640
+ """
4641
+ from telethon.tl.functions import messages as fn
4642
+
4643
+ await _bots.require_bot_session(ctx, "setting a game score")
4644
+ if req.user is None:
4645
+ raise UsageError("--user names the player and is required", field="user")
4646
+ user = await _bots.input_user(ctx, req.user, field="user")
4647
+
4648
+ if req.inline_id:
4649
+ identifier = _bots.inline_message_id(req.inline_id)
4650
+ await _bots.on_dc(
4651
+ ctx,
4652
+ int(getattr(identifier, "dc_id", 0) or 0),
4653
+ fn.SetInlineGameScoreRequest(
4654
+ id=identifier,
4655
+ user_id=user,
4656
+ score=req.score,
4657
+ edit_message=req.edit_message or None,
4658
+ force=req.allow_lower or None,
4659
+ ),
4660
+ )
4661
+ else:
4662
+ if req.chat is None or req.msg_id is None:
4663
+ raise UsageError("give a chat and a message id, or --inline-id", field="msg_id")
4664
+ await client(ctx)(
4665
+ fn.SetGameScoreRequest(
4666
+ peer=await _send.resolve(ctx, req.chat),
4667
+ id=int(req.msg_id),
4668
+ user_id=user,
4669
+ score=req.score,
4670
+ edit_message=req.edit_message or None,
4671
+ force=req.allow_lower or None,
4672
+ )
4673
+ )
4674
+ return ScoreSet(user_id=_send.peer_id_of(await _send.resolve(ctx, req.user)), score=req.score)
4675
+
4676
+
4677
+ SPEC_SCORE_SET = OperationSpec(
4678
+ id="bot.score.set",
4679
+ request=ScoreSetReq,
4680
+ response=ScoreSet,
4681
+ impl=score_set,
4682
+ summary="Report a game score for a user",
4683
+ tags=frozenset({"visible-to-others"}),
4684
+ mutating=True,
4685
+ columns=("user_id", "score", "position"),
4686
+ headers=("User", "Score", "#"),
4687
+ example={"user_id": 4242, "score": 900},
4688
+ example_args="bot score set @alice 12 --user @alice --score 900",
4689
+ covers=("bots.set-game-score",),
4690
+ )
4691
+
4692
+
4693
+ # ---------------------------------------------------------------------------
4694
+ # Layer 229: ephemeral messages and bot welcome messages
4695
+ # ---------------------------------------------------------------------------
4696
+
4697
+
4698
+ class EphemeralSendReq(Request):
4699
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat it lives in.")]
4700
+ text: Annotated[str, arg(1, metavar="TEXT", help="Message text, or a /command.")]
4701
+ bot: Annotated[
4702
+ PeerRef | None,
4703
+ opt("--bot", metavar="BOT", kind="user", help="Bot the conversation belongs to."),
4704
+ ] = None
4705
+ receiver: Annotated[
4706
+ PeerRef | None,
4707
+ opt("--receiver", metavar="USER", kind="user", help="Who alone will see it (bot side)."),
4708
+ ] = None
4709
+ reply_to: Annotated[
4710
+ int | None, opt("--reply-to", metavar="ID", help="Ephemeral message being replied to.")
4711
+ ] = None
4712
+ query_id: Annotated[
4713
+ str | None, opt("--query-id", metavar="ID", help="Guest/callback query this answers.")
4714
+ ] = None
4715
+ keyboard: Annotated[
4716
+ str | None, opt("--keyboard", metavar="PATH", kind="path", help="JSON keyboard.")
4717
+ ] = None
4718
+ rich_file: Annotated[
4719
+ str | None, opt("--rich-file", metavar="PATH", kind="path", help="Send a rich body.")
4720
+ ] = None
4721
+ anchor: Annotated[bool, opt("--anchor", help="Pin it to the triggering message.")] = False
4722
+ welcome: Annotated[bool, opt("--welcome", help="Store it as a welcome template.")] = False
4723
+ edit: Annotated[
4724
+ int | None, opt("--edit", metavar="ID", help="Edit this ephemeral message instead.")
4725
+ ] = None
4726
+ parse: Annotated[str | None, choice("md", "html", "none", help="Text formatting.")] = None
4727
+
4728
+
4729
+ async def ephemeral_send(ctx: OpContext, req: EphemeralSendReq) -> EphemeralSent:
4730
+ """Send an "only you can see this" bot message.
4731
+
4732
+ `ephemeral.sendMessage#ba8d5f35` and `ephemeral.editMessage#cf9c725b` are
4733
+ layer-229 constructors and the pinned Telethon speaks 227. The operation
4734
+ is registered rather than omitted so that `tlgr agent capabilities` can
4735
+ say the surface exists and is unavailable — which is a different answer
4736
+ from "no such command", and the one an agent can act on.
4737
+ """
4738
+ _bots.unsupported("bot ephemeral send")
4739
+ raise AssertionError # pragma: no cover - unreachable; keeps mypy happy
4740
+
4741
+
4742
+ SPEC_EPHEMERAL_SEND = OperationSpec(
4743
+ id="bot.ephemeral.send",
4744
+ request=EphemeralSendReq,
4745
+ response=EphemeralSent,
4746
+ impl=ephemeral_send,
4747
+ summary="Send an ephemeral ('only you can see this') bot message",
4748
+ description=(
4749
+ "Layer 229. Exits 13 (NOT_SUPPORTED) until the pinned Telethon speaks "
4750
+ "it: hand-rolling the request would mean guessing at constructor ids "
4751
+ "for parameters nobody has published."
4752
+ ),
4753
+ mutating=True,
4754
+ rate_class="send",
4755
+ columns=("chat_id", "ephemeral_id"),
4756
+ headers=("Chat", "Ephemeral"),
4757
+ example={"chat_id": 4242, "ephemeral_id": 0},
4758
+ example_args="bot ephemeral send @alice Hello",
4759
+ tags=frozenset({"not-supported"}),
4760
+ )
4761
+
4762
+
4763
+ class EphemeralDeleteReq(Request):
4764
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat it lives in.")]
4765
+ id: Annotated[
4766
+ list[int], arg(1, metavar="ID", variadic=True, help="Ephemeral message ids.")
4767
+ ] = []
4768
+ receiver: Annotated[
4769
+ PeerRef | None,
4770
+ opt("--receiver", metavar="USER", kind="user", help="Whose copy is deleted (bot side)."),
4771
+ ] = None
4772
+ dismiss: Annotated[bool, opt("--dismiss", help="Only clear it locally.")] = False
4773
+
4774
+
4775
+ async def ephemeral_delete(ctx: OpContext, req: EphemeralDeleteReq) -> EphemeralDeleted:
4776
+ """Delete or dismiss an ephemeral bot message. Layer 229; exits 13."""
4777
+ _bots.unsupported("bot ephemeral delete")
4778
+ raise AssertionError # pragma: no cover - unreachable; keeps mypy happy
4779
+
4780
+
4781
+ SPEC_EPHEMERAL_DELETE = OperationSpec(
4782
+ id="bot.ephemeral.delete",
4783
+ request=EphemeralDeleteReq,
4784
+ response=EphemeralDeleted,
4785
+ impl=ephemeral_delete,
4786
+ summary="Delete or dismiss an ephemeral bot message",
4787
+ mutating=True,
4788
+ destructive=True,
4789
+ columns=("chat_id", "deleted"),
4790
+ headers=("Chat", "Deleted"),
4791
+ example={"chat_id": 4242, "deleted": 0},
4792
+ example_args="bot ephemeral delete @alice 12",
4793
+ tags=frozenset({"not-supported"}),
4794
+ )
4795
+
4796
+
4797
+ class WelcomeListReq(Request):
4798
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The chat.")]
4799
+
4800
+
4801
+ async def welcome_list(ctx: OpContext, req: WelcomeListReq) -> Page[BotWelcomeMessage]:
4802
+ """A chat's bot welcome-message templates. Layer 229; exits 13."""
4803
+ _bots.unsupported("bot welcome list")
4804
+ raise AssertionError # pragma: no cover - unreachable; keeps mypy happy
4805
+
4806
+
4807
+ SPEC_WELCOME_LIST = OperationSpec(
4808
+ id="bot.welcome.list",
4809
+ request=WelcomeListReq,
4810
+ response=Page[BotWelcomeMessage],
4811
+ impl=welcome_list,
4812
+ summary="List a chat's bot welcome-message templates",
4813
+ paginated=PageKind.LOCAL,
4814
+ columns=("id", "text"),
4815
+ headers=("ID", "Text"),
4816
+ example={"items": [], "has_more": False},
4817
+ example_args="bot welcome list @mygroup",
4818
+ tags=frozenset({"not-supported"}),
4819
+ )
4820
+
4821
+
4822
+ class WelcomeSetReq(Request):
4823
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The chat.")]
4824
+ text: Annotated[str, arg(1, metavar="TEXT", help="Welcome text.")]
4825
+ id: Annotated[int | None, opt("--id", metavar="ID", help="Edit this one instead.")] = None
4826
+ keyboard: Annotated[
4827
+ str | None, opt("--keyboard", metavar="PATH", kind="path", help="JSON keyboard.")
4828
+ ] = None
4829
+ parse: Annotated[str | None, choice("md", "html", "none", help="Text formatting.")] = None
4830
+
4831
+
4832
+ async def welcome_set(ctx: OpContext, req: WelcomeSetReq) -> WelcomeSet:
4833
+ """Add or edit a chat's bot welcome message. Layer 229; exits 13."""
4834
+ _bots.unsupported("bot welcome set")
4835
+ raise AssertionError # pragma: no cover - unreachable; keeps mypy happy
4836
+
4837
+
4838
+ SPEC_WELCOME_SET = OperationSpec(
4839
+ id="bot.welcome.set",
4840
+ request=WelcomeSetReq,
4841
+ response=WelcomeSet,
4842
+ impl=welcome_set,
4843
+ summary="Add or edit a chat's bot welcome message",
4844
+ mutating=True,
4845
+ columns=("chat_id", "id", "text"),
4846
+ headers=("Chat", "ID", "Text"),
4847
+ example={"chat_id": -1001, "id": 0, "text": "Welcome!"},
4848
+ example_args="bot welcome set @mygroup Welcome!",
4849
+ tags=frozenset({"not-supported"}),
4850
+ )
4851
+
4852
+
4853
+ class WelcomeDeleteReq(Request):
4854
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The chat.")]
4855
+ id: Annotated[
4856
+ list[int], arg(1, metavar="ID", required=False, variadic=True, help="Welcome message ids.")
4857
+ ] = []
4858
+ delete_all: Annotated[bool, opt("--all", help="Delete every welcome message.")] = False
4859
+
4860
+
4861
+ async def welcome_delete(ctx: OpContext, req: WelcomeDeleteReq) -> WelcomeDeleted:
4862
+ """Delete a chat's bot welcome messages. Layer 229; exits 13."""
4863
+ _bots.unsupported("bot welcome delete")
4864
+ raise AssertionError # pragma: no cover - unreachable; keeps mypy happy
4865
+
4866
+
4867
+ SPEC_WELCOME_DELETE = OperationSpec(
4868
+ id="bot.welcome.delete",
4869
+ request=WelcomeDeleteReq,
4870
+ response=WelcomeDeleted,
4871
+ impl=welcome_delete,
4872
+ summary="Delete one or all of a chat's bot welcome messages",
4873
+ mutating=True,
4874
+ destructive=True,
4875
+ columns=("chat_id", "deleted"),
4876
+ headers=("Chat", "Deleted"),
4877
+ example={"chat_id": -1001, "deleted": 0},
4878
+ example_args="bot welcome delete @mygroup 1",
4879
+ tags=frozenset({"not-supported"}),
4880
+ )