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/_bots.py ADDED
@@ -0,0 +1,586 @@
1
+ """The plumbing the bot, inline, mini-app and payment groups all need.
2
+
3
+ Four things live here rather than in one of the four modules, because all four
4
+ reach for them and a second copy is how they start to disagree:
5
+
6
+ * **the keyboard schema**, read *and* write. One vocabulary of button `type`
7
+ names serves `message get --json`, `bot press --button <text>` and
8
+ `--keyboard FILE`, so a button that can be read back can be pressed and
9
+ re-sent.
10
+ * **the bot-session gate.** Half of this surface is bot-only, and Telegram
11
+ answers a user session with a bare `403 BOT_METHOD_INVALID`. Asking the
12
+ session what it is first turns that into exit 4 with a sentence saying how
13
+ to add a bot account.
14
+ * **DC routing.** An inline message id names the DC it lives on, and sending
15
+ `editInlineBotMessage` to the home DC fails with an error that says nothing
16
+ about DCs. One helper borrows the exported sender for every caller.
17
+ * **the report option tree.** `messages.report`, `messages.reportSponsoredMessage`
18
+ and the mini-app report all walk the same
19
+ chooseOption → addComment → reported state machine.
20
+
21
+ Telethon is imported inside functions, never at module scope (§2.2).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import base64
27
+ import binascii
28
+ import json
29
+ import os
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ from tlgr.core.errors import AuthenticationError, NotSupportedError, UsageError
34
+ from tlgr.models.bot import Keyboard, KeyboardButton, ReportOutcome
35
+ from tlgr.models.peer import PeerRef
36
+ from tlgr.ops import _send
37
+ from tlgr.ops._common import client
38
+ from tlgr.ops._spec import OpContext
39
+
40
+ __all__ = [
41
+ "ADMIN_RIGHTS",
42
+ "BUTTON_TYPES",
43
+ "LAYER_229",
44
+ "admin_rights",
45
+ "bot_peer",
46
+ "client",
47
+ "command_scope",
48
+ "data_json",
49
+ "inline_message_id",
50
+ "input_user",
51
+ "keyboard_model",
52
+ "keyboard_tl",
53
+ "load_json",
54
+ "on_dc",
55
+ "option_bytes",
56
+ "payload_bytes",
57
+ "peer_ref",
58
+ "report_outcome",
59
+ "require_bot_session",
60
+ "rights_keywords",
61
+ "unsupported",
62
+ ]
63
+
64
+ #: The one sentence every layer-gap refusal ends with. Written once so that
65
+ #: `tlgr agent capabilities` and the docs cannot describe the gap differently.
66
+ LAYER_229 = (
67
+ "it is a layer-229 method and the pinned Telethon speaks layer 227; "
68
+ "tlgr refuses rather than guessing at a constructor id"
69
+ )
70
+
71
+
72
+ def unsupported(feature: str, reason: str = LAYER_229) -> Any:
73
+ """Refuse a feature this build genuinely cannot perform (exit 13)."""
74
+ raise NotSupportedError(f"{feature} is not supported: {reason}")
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Peers and sessions
79
+ # ---------------------------------------------------------------------------
80
+
81
+
82
+ def peer_ref(value: str) -> PeerRef:
83
+ """A `@username`/id string as a `PeerRef`, for a bot named in config."""
84
+ from tlgr.models.peer import parse_peer_ref
85
+
86
+ return parse_peer_ref(value)
87
+
88
+
89
+ async def bot_peer(ctx: OpContext, ref: PeerRef | str | None) -> Any:
90
+ """The `InputPeer` of a bot, through the account's own resolver (§6.6)."""
91
+ if isinstance(ref, str):
92
+ ref = peer_ref(ref)
93
+ return await _send.resolve(ctx, ref)
94
+
95
+
96
+ async def input_user(ctx: OpContext, ref: PeerRef | str | None, *, field: str = "bot") -> Any:
97
+ """The `InputUser` a `bots.*` request wants.
98
+
99
+ `utils.get_input_user` is arithmetic on a peer we already resolved; going
100
+ back to the network would hide the real problem when the ref names a chat.
101
+ """
102
+ from telethon import utils
103
+
104
+ peer = await bot_peer(ctx, ref)
105
+ try:
106
+ return utils.get_input_user(peer)
107
+ except (TypeError, ValueError) as exc:
108
+ raise UsageError(f"{field} must name a user or a bot", field=field) from exc
109
+
110
+
111
+ async def require_bot_session(ctx: OpContext, what: str) -> Any:
112
+ """Refuse a bot-only operation on a user session, with exit 4.
113
+
114
+ Telegram answers `BOT_METHOD_INVALID`, which reads like a bug in the
115
+ request rather than like "this account is a person".
116
+ """
117
+ account = await client(ctx).get_me()
118
+ if not bool(getattr(account, "bot", False)):
119
+ raise AuthenticationError(
120
+ f"{what} needs a bot session; add one with "
121
+ "`tlgr account add --bot-token <token>` and pass it with -a"
122
+ )
123
+ return account
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Admin rights
128
+ # ---------------------------------------------------------------------------
129
+
130
+ #: The keyword vocabulary `--admin`/`--group`/`--channel` accept, in the
131
+ #: spelling the groups-and-channels group uses.
132
+ ADMIN_RIGHTS: tuple[str, ...] = (
133
+ "change_info",
134
+ "post_messages",
135
+ "edit_messages",
136
+ "delete_messages",
137
+ "ban_users",
138
+ "invite_users",
139
+ "pin_messages",
140
+ "add_admins",
141
+ "anonymous",
142
+ "manage_call",
143
+ "other",
144
+ "manage_topics",
145
+ "post_stories",
146
+ "edit_stories",
147
+ "delete_stories",
148
+ "manage_direct_messages",
149
+ )
150
+
151
+ #: The names the t.me deep links use, mapped onto the TL field they set.
152
+ _RIGHT_ALIASES = {
153
+ "manage_chat": "other",
154
+ "restrict_members": "ban_users",
155
+ "promote_members": "add_admins",
156
+ "manage_video_chats": "manage_call",
157
+ }
158
+
159
+
160
+ def admin_rights(text: str | None, *, field: str = "admin") -> Any:
161
+ """`change_info+invite_users` as a `ChatAdminRights`, or None."""
162
+ if not text:
163
+ return None
164
+ from telethon.tl import types
165
+
166
+ flags: dict[str, bool] = {}
167
+ for raw in str(text).replace(",", "+").split("+"):
168
+ name = raw.strip().lower()
169
+ if not name:
170
+ continue
171
+ name = _RIGHT_ALIASES.get(name, name)
172
+ if name not in ADMIN_RIGHTS:
173
+ raise UsageError(
174
+ f"--{field}: {raw!r} is not an admin right; choose from {', '.join(ADMIN_RIGHTS)}",
175
+ field=field,
176
+ )
177
+ flags[name] = True
178
+ return types.ChatAdminRights(**flags)
179
+
180
+
181
+ def rights_keywords(rights: Any) -> list[str]:
182
+ """The keywords a `ChatAdminRights` has set, in the documented order."""
183
+ if rights is None:
184
+ return []
185
+ return [name for name in ADMIN_RIGHTS if bool(getattr(rights, name, False))]
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # Payloads
190
+ # ---------------------------------------------------------------------------
191
+
192
+
193
+ def load_json(value: str | None, *, field: str) -> Any:
194
+ """Inline JSON, `@path`, or a bare path — whichever the caller typed.
195
+
196
+ A JSON document large enough to be worth writing is large enough to be
197
+ worth keeping in a file, and a document small enough to type belongs on
198
+ the command line; supporting only one of the two is what makes a caller
199
+ write `--params "$(cat f.json)"`.
200
+ """
201
+ if value is None:
202
+ return None
203
+ text = value.strip()
204
+ if text.startswith("@"):
205
+ text = _read(text[1:], field=field)
206
+ elif not text.startswith(("{", "[", '"')) and not text.lstrip("-").isdigit():
207
+ text = _read(text, field=field)
208
+ try:
209
+ return json.loads(text)
210
+ except json.JSONDecodeError as exc:
211
+ raise UsageError(f"--{field}: {exc}", field=field) from exc
212
+
213
+
214
+ def _read(path: str, *, field: str) -> str:
215
+ handle = Path(os.path.expanduser(path))
216
+ try:
217
+ return handle.read_text(encoding="utf-8")
218
+ except OSError as exc:
219
+ raise UsageError(f"--{field}: {exc.strerror or exc}", field=field) from exc
220
+
221
+
222
+ def data_json(value: str | None, *, field: str) -> Any:
223
+ """A `DataJSON` built from whatever `load_json` accepts."""
224
+ from telethon.tl import types
225
+
226
+ payload = load_json(value, field=field)
227
+ if payload is None:
228
+ return None
229
+ return types.DataJSON(data=json.dumps(payload, separators=(",", ":")))
230
+
231
+
232
+ def payload_bytes(value: str | None, *, field: str) -> bytes | None:
233
+ """Callback payload bytes from `hex:…`, `str:…`, `@file`, or bare hex/text.
234
+
235
+ Callback data is *bytes*, and most of it is not text; a flag that only
236
+ accepted text would make half the buttons on Telegram unpressable, and one
237
+ that only accepted hex would make the other half unreadable.
238
+ """
239
+ if value is None:
240
+ return None
241
+ text = str(value)
242
+ if text.startswith("hex:"):
243
+ return _unhex(text[4:], field=field)
244
+ if text.startswith("str:"):
245
+ return text[4:].encode()
246
+ if text.startswith("b64:"):
247
+ return _unb64(text[4:], field=field)
248
+ if text.startswith("@"):
249
+ try:
250
+ return Path(os.path.expanduser(text[1:])).read_bytes()
251
+ except OSError as exc:
252
+ raise UsageError(f"--{field}: {exc.strerror or exc}", field=field) from exc
253
+ stripped = text.strip()
254
+ if stripped and len(stripped) % 2 == 0 and all(c in "0123456789abcdefABCDEF" for c in stripped):
255
+ return _unhex(stripped, field=field)
256
+ return text.encode()
257
+
258
+
259
+ def option_bytes(value: str | None, *, field: str = "option") -> bytes:
260
+ """Report-option bytes, as `bot ad list`/a previous step printed them."""
261
+ if not value:
262
+ return b""
263
+ return payload_bytes(value, field=field) or b""
264
+
265
+
266
+ def _unhex(text: str, *, field: str) -> bytes:
267
+ try:
268
+ return binascii.unhexlify(text.strip())
269
+ except (binascii.Error, ValueError) as exc:
270
+ raise UsageError(f"--{field}: {text!r} is not hexadecimal", field=field) from exc
271
+
272
+
273
+ def _unb64(text: str, *, field: str) -> bytes:
274
+ try:
275
+ return base64.b64decode(text + "=" * (-len(text) % 4))
276
+ except (binascii.Error, ValueError) as exc:
277
+ raise UsageError(f"--{field}: {text!r} is not base64", field=field) from exc
278
+
279
+
280
+ def key_text(raw: bytes | None) -> str:
281
+ """Opaque bytes as something a shell can round-trip: text, else base64."""
282
+ if not raw:
283
+ return ""
284
+ try:
285
+ return raw.decode()
286
+ except UnicodeDecodeError:
287
+ return base64.b64encode(raw).decode()
288
+
289
+
290
+ # ---------------------------------------------------------------------------
291
+ # Keyboards — the schema, both ways
292
+ # ---------------------------------------------------------------------------
293
+
294
+ #: TL class suffix → the `type` name the JSON schema uses.
295
+ BUTTON_TYPES: dict[str, str] = {
296
+ "KeyboardButton": "text",
297
+ "KeyboardButtonCallback": "callback",
298
+ "KeyboardButtonUrl": "url",
299
+ "KeyboardButtonUrlAuth": "url_auth",
300
+ "InputKeyboardButtonUrlAuth": "url_auth",
301
+ "KeyboardButtonSwitchInline": "switch_inline",
302
+ "KeyboardButtonWebView": "webview",
303
+ "KeyboardButtonSimpleWebView": "simple_webview",
304
+ "KeyboardButtonGame": "game",
305
+ "KeyboardButtonBuy": "buy",
306
+ "KeyboardButtonRequestPhone": "request_phone",
307
+ "KeyboardButtonRequestGeoLocation": "request_geo",
308
+ "KeyboardButtonRequestPoll": "request_poll",
309
+ "KeyboardButtonRequestPeer": "request_peer",
310
+ "KeyboardButtonUserProfile": "user_profile",
311
+ "InputKeyboardButtonUserProfile": "user_profile",
312
+ "KeyboardButtonCopy": "copy",
313
+ }
314
+
315
+ _MARKUP_KINDS = {
316
+ "ReplyInlineMarkup": "inline",
317
+ "ReplyKeyboardMarkup": "keyboard",
318
+ "ReplyKeyboardHide": "hide",
319
+ "ReplyKeyboardForceReply": "force_reply",
320
+ }
321
+
322
+
323
+ def button_model(button: Any) -> KeyboardButton:
324
+ """One TL button as the schema row `--keyboard` would write."""
325
+ kind = BUTTON_TYPES.get(type(button).__name__, "unsupported")
326
+ data = getattr(button, "data", None)
327
+ return KeyboardButton(
328
+ text=str(getattr(button, "text", "") or ""),
329
+ type=kind,
330
+ data=key_text(data) if data else None,
331
+ url=getattr(button, "url", None),
332
+ query=getattr(button, "query", None),
333
+ user_id=_user_id(getattr(button, "user_id", None)),
334
+ requires_password=bool(getattr(button, "requires_password", False)),
335
+ same_peer=bool(getattr(button, "same_peer", False)),
336
+ copy_text=getattr(button, "copy_text", None),
337
+ button_id=getattr(button, "button_id", None),
338
+ fwd_text=getattr(button, "fwd_text", None),
339
+ request_write_access=bool(getattr(button, "request_write_access", False)),
340
+ )
341
+
342
+
343
+ def _user_id(value: Any) -> int | None:
344
+ if value is None:
345
+ return None
346
+ if isinstance(value, int):
347
+ return value
348
+ return int(getattr(value, "user_id", 0) or 0) or None
349
+
350
+
351
+ def keyboard_model(markup: Any) -> Keyboard | None:
352
+ """A TL reply markup as the `Keyboard` schema, or None."""
353
+ if markup is None:
354
+ return None
355
+ kind = _MARKUP_KINDS.get(type(markup).__name__)
356
+ if kind is None:
357
+ return None
358
+ rows = [
359
+ [button_model(button) for button in (getattr(row, "buttons", None) or [])]
360
+ for row in (getattr(markup, "rows", None) or [])
361
+ ]
362
+ return Keyboard(
363
+ kind=kind,
364
+ rows=rows,
365
+ resize=bool(getattr(markup, "resize", False)),
366
+ single_use=bool(getattr(markup, "single_use", False)),
367
+ selective=bool(getattr(markup, "selective", False)),
368
+ persistent=bool(getattr(markup, "persistent", False)),
369
+ placeholder=getattr(markup, "placeholder", None),
370
+ )
371
+
372
+
373
+ def keyboard_tl(spec: Any, *, field: str = "keyboard") -> Any:
374
+ """The `Keyboard` schema as a TL reply markup.
375
+
376
+ Only the button kinds a *client* can legitimately author are built. A
377
+ `url_auth` button carries an `InputUser` the caller has not resolved and a
378
+ `buy` button starts a payment, so both are refused here rather than half
379
+ built.
380
+ """
381
+ from telethon.tl import types
382
+
383
+ if spec is None:
384
+ return None
385
+ if isinstance(spec, str):
386
+ spec = load_json(spec, field=field)
387
+ if not isinstance(spec, dict):
388
+ raise UsageError(f"--{field}: expected a keyboard object", field=field)
389
+
390
+ kind = str(spec.get("kind") or "inline")
391
+ rows: list[Any] = []
392
+ for row in spec.get("rows") or []:
393
+ buttons = [_button_tl(entry, field=field) for entry in row]
394
+ rows.append(types.KeyboardButtonRow(buttons=buttons))
395
+ if kind == "inline":
396
+ return types.ReplyInlineMarkup(rows=rows)
397
+ if kind == "keyboard":
398
+ return types.ReplyKeyboardMarkup(
399
+ rows=rows,
400
+ resize=bool(spec.get("resize")) or None,
401
+ single_use=bool(spec.get("single_use")) or None,
402
+ selective=bool(spec.get("selective")) or None,
403
+ persistent=bool(spec.get("persistent")) or None,
404
+ placeholder=spec.get("placeholder"),
405
+ )
406
+ if kind == "hide":
407
+ return types.ReplyKeyboardHide(selective=bool(spec.get("selective")) or None)
408
+ if kind == "force_reply":
409
+ return types.ReplyKeyboardForceReply(
410
+ single_use=bool(spec.get("single_use")) or None,
411
+ selective=bool(spec.get("selective")) or None,
412
+ placeholder=spec.get("placeholder"),
413
+ )
414
+ raise UsageError(f"--{field}: kind must be inline, keyboard, hide or force_reply", field=field)
415
+
416
+
417
+ def _button_tl(entry: Any, *, field: str) -> Any:
418
+ from telethon.tl import types
419
+
420
+ if not isinstance(entry, dict):
421
+ raise UsageError(f"--{field}: every button must be an object", field=field)
422
+ text = str(entry.get("text") or "")
423
+ kind = str(entry.get("type") or "text")
424
+ if kind == "text":
425
+ return types.KeyboardButton(text=text)
426
+ if kind == "callback":
427
+ return types.KeyboardButtonCallback(
428
+ text=text,
429
+ data=payload_bytes(entry.get("data"), field=field) or b"",
430
+ requires_password=bool(entry.get("requires_password")) or None,
431
+ )
432
+ if kind == "url":
433
+ return types.KeyboardButtonUrl(text=text, url=str(entry.get("url") or ""))
434
+ if kind == "switch_inline":
435
+ return types.KeyboardButtonSwitchInline(
436
+ text=text,
437
+ query=str(entry.get("query") or ""),
438
+ same_peer=bool(entry.get("same_peer")) or None,
439
+ )
440
+ if kind == "webview":
441
+ return types.KeyboardButtonWebView(text=text, url=str(entry.get("url") or ""))
442
+ if kind == "simple_webview":
443
+ return types.KeyboardButtonSimpleWebView(text=text, url=str(entry.get("url") or ""))
444
+ if kind == "copy":
445
+ return types.KeyboardButtonCopy(text=text, copy_text=str(entry.get("copy_text") or ""))
446
+ if kind == "game":
447
+ return types.KeyboardButtonGame(text=text)
448
+ if kind == "request_phone":
449
+ return types.KeyboardButtonRequestPhone(text=text)
450
+ if kind == "request_geo":
451
+ return types.KeyboardButtonRequestGeoLocation(text=text)
452
+ if kind == "request_poll":
453
+ return types.KeyboardButtonRequestPoll(text=text, quiz=entry.get("quiz"))
454
+ raise UsageError(
455
+ f"--{field}: {kind!r} is not a button kind tlgr can author "
456
+ "(text, callback, url, switch_inline, webview, simple_webview, copy, game, "
457
+ "request_phone, request_geo, request_poll)",
458
+ field=field,
459
+ )
460
+
461
+
462
+ # ---------------------------------------------------------------------------
463
+ # Inline message ids and DC routing
464
+ # ---------------------------------------------------------------------------
465
+
466
+
467
+ def inline_message_id(text: str, *, field: str = "inline_id") -> Any:
468
+ """`dc:id:hash` (or `dc:owner:id:hash`) as an `InputBotInlineMessageID*`.
469
+
470
+ The id names the DC the message lives on, and every request that takes one
471
+ must be sent *there*; see `on_dc`.
472
+ """
473
+ from telethon.tl import types
474
+
475
+ parts = [p for p in str(text).replace("-", ":").split(":") if p != ""]
476
+ try:
477
+ numbers = [int(p) for p in parts]
478
+ except ValueError as exc:
479
+ raise UsageError(
480
+ f"--{field}: expected 'dc:id:access_hash' (or 'dc:owner:id:access_hash')",
481
+ field=field,
482
+ ) from exc
483
+ if len(numbers) == 3:
484
+ return types.InputBotInlineMessageID(
485
+ dc_id=numbers[0], id=numbers[1], access_hash=numbers[2]
486
+ )
487
+ if len(numbers) == 4:
488
+ return types.InputBotInlineMessageID64(
489
+ dc_id=numbers[0], owner_id=numbers[1], id=numbers[2], access_hash=numbers[3]
490
+ )
491
+ raise UsageError(
492
+ f"--{field}: expected 'dc:id:access_hash' (or 'dc:owner:id:access_hash')", field=field
493
+ )
494
+
495
+
496
+ def inline_id_text(value: Any) -> str:
497
+ """The round-trip spelling of an `InputBotInlineMessageID*`."""
498
+ owner = getattr(value, "owner_id", None)
499
+ parts = [getattr(value, "dc_id", 0)]
500
+ if owner is not None:
501
+ parts.append(owner)
502
+ parts += [getattr(value, "id", 0), getattr(value, "access_hash", 0)]
503
+ return ":".join(str(int(p or 0)) for p in parts)
504
+
505
+
506
+ async def on_dc(ctx: OpContext, dc_id: int, request: Any) -> Any:
507
+ """Send *request* to *dc_id* through an exported sender.
508
+
509
+ Inline message ids and web files live on a DC that is not necessarily the
510
+ home one, and sending there anyway fails with an error that says nothing
511
+ about data centres.
512
+ """
513
+ handle = client(ctx)
514
+ if not dc_id:
515
+ return await handle(request)
516
+ sender = await handle._borrow_exported_sender(dc_id)
517
+ try:
518
+ return await sender.send(request)
519
+ finally:
520
+ await handle._return_exported_sender(sender)
521
+
522
+
523
+ # ---------------------------------------------------------------------------
524
+ # Bot command scopes
525
+ # ---------------------------------------------------------------------------
526
+
527
+ _SCOPES = {
528
+ "default": "BotCommandScopeDefault",
529
+ "users": "BotCommandScopeUsers",
530
+ "chats": "BotCommandScopeChats",
531
+ "chat-admins": "BotCommandScopeChatAdmins",
532
+ "peer": "BotCommandScopePeer",
533
+ "peer-admins": "BotCommandScopePeerAdmins",
534
+ "peer-user": "BotCommandScopePeerUser",
535
+ }
536
+
537
+
538
+ async def command_scope(
539
+ ctx: OpContext, name: str, chat: PeerRef | None, user: PeerRef | None
540
+ ) -> Any:
541
+ """One of the seven `botCommandScope*` constructors."""
542
+ from telethon.tl import types
543
+
544
+ klass_name = _SCOPES.get(name)
545
+ if klass_name is None:
546
+ raise UsageError(f"--scope: {name!r} is not a command scope", field="scope")
547
+ klass = getattr(types, klass_name)
548
+ if name in ("peer", "peer-admins", "peer-user"):
549
+ if chat is None:
550
+ raise UsageError(f"--scope {name} needs --peer", field="peer")
551
+ peer = await _send.resolve(ctx, chat)
552
+ if name == "peer-user":
553
+ if user is None:
554
+ raise UsageError("--scope peer-user needs --user", field="user")
555
+ return klass(peer=peer, user_id=await input_user(ctx, user, field="user"))
556
+ return klass(peer=peer)
557
+ return klass()
558
+
559
+
560
+ # ---------------------------------------------------------------------------
561
+ # The report option tree
562
+ # ---------------------------------------------------------------------------
563
+
564
+
565
+ def report_outcome(result: Any) -> ReportOutcome:
566
+ """One step of `reportResultChooseOption → addComment → reported`."""
567
+ name = type(result).__name__
568
+ if name == "ReportResultChooseOption":
569
+ return ReportOutcome(
570
+ result="choose_option",
571
+ title=str(getattr(result, "title", "") or ""),
572
+ options=[
573
+ {
574
+ "text": str(getattr(option, "text", "") or ""),
575
+ "option": key_text(getattr(option, "option", b"")),
576
+ }
577
+ for option in (getattr(result, "options", None) or [])
578
+ ],
579
+ )
580
+ if name == "ReportResultAddComment":
581
+ return ReportOutcome(
582
+ result="add_comment",
583
+ options=[{"option": key_text(getattr(result, "option", b""))}],
584
+ title="optional" if getattr(result, "optional", False) else "required",
585
+ )
586
+ return ReportOutcome(result="reported", reported=True)