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/inline.py ADDED
@@ -0,0 +1,773 @@
1
+ """The `inline` group: `@bot query`, and the two halves of sending a result.
2
+
3
+ Inline mode looks like a search box and behaves like nothing else in the API.
4
+
5
+ * **Offsets are the bot's, not Telegram's.** `next_offset` is an opaque string
6
+ the bot invented; feeding it back is the only way to page, and an empty one
7
+ is the end. tlgr passes it through untouched rather than wrapping it in a
8
+ signed cursor that would imply an ordering nobody promised.
9
+ * **A result id is only valid with its query id, and only briefly.** They come
10
+ back paired for `cache_time` seconds. `inline send --pick` therefore re-runs
11
+ the query itself instead of accepting a pair from an earlier command, which
12
+ is the difference between a command that works and one that fails whenever
13
+ the user paused to think.
14
+ * **A silent bot is not an error.** `BOT_RESPONSE_TIMEOUT` means the bot is
15
+ offline. That is an empty page (exit 3), not a failure — an agent that reads
16
+ it as a failure retries something that will never answer.
17
+
18
+ Telethon is imported inside functions, never at module scope (§2.2).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Annotated, Any
24
+
25
+ from tlgr.core.errors import NotFoundError, UsageError
26
+ from tlgr.core.pagination import PageKind, build_page
27
+ from tlgr.models.base import Request
28
+ from tlgr.models.inline import (
29
+ InlineEdited,
30
+ InlineResult,
31
+ InlineSent,
32
+ PreparedMessage,
33
+ PreparedSaved,
34
+ )
35
+ from tlgr.models.page import Page
36
+ from tlgr.models.peer import PeerRef
37
+ from tlgr.ops import _bots, _send
38
+ from tlgr.ops._common import client, window
39
+ from tlgr.ops._params import arg, choice, opt
40
+ from tlgr.ops._spec import OpContext, OperationSpec
41
+
42
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
43
+
44
+ #: `botInlineMessage*` → the `send_message` kind reported on a result.
45
+ _MESSAGE_KINDS = {
46
+ "BotInlineMessageText": "text",
47
+ "BotInlineMessageMediaAuto": "media_auto",
48
+ "BotInlineMessageMediaGeo": "geo",
49
+ "BotInlineMessageMediaVenue": "venue",
50
+ "BotInlineMessageMediaContact": "contact",
51
+ "BotInlineMessageMediaInvoice": "invoice",
52
+ "BotInlineMessageMediaWebPage": "webpage",
53
+ "BotInlineMessageRichMessage": "rich",
54
+ "BotInlineMessageGame": "game",
55
+ }
56
+
57
+ _PEER_TYPES = {
58
+ "pm": "InlineQueryPeerTypePM",
59
+ "bot": "InlineQueryPeerTypeBotPM",
60
+ "group": "InlineQueryPeerTypeChat",
61
+ "megagroup": "InlineQueryPeerTypeMegagroup",
62
+ "channel": "InlineQueryPeerTypeBroadcast",
63
+ "broadcast": "InlineQueryPeerTypeBroadcast",
64
+ "same_bot": "InlineQueryPeerTypeSameBotPM",
65
+ }
66
+
67
+ _EXAMPLE_RESULT: dict[str, Any] = {
68
+ "n": 0,
69
+ "id": "BQADAgAD",
70
+ "type": "gif",
71
+ "title": "cat",
72
+ "query_id": "987654321",
73
+ }
74
+
75
+
76
+ def _result_model(entry: Any, index: int, query_id: int, results: Any = None) -> InlineResult:
77
+ """One `botInlineResult`/`botInlineMediaResult` as one row.
78
+
79
+ The two constructors differ in where the bytes live — a `WebDocument` the
80
+ client must fetch, or a `Photo`/`Document` Telegram already holds — and
81
+ `content` is what says which, so a caller that needs to know still can.
82
+ """
83
+ send = getattr(entry, "send_message", None)
84
+ document = getattr(entry, "document", None)
85
+ photo = getattr(entry, "photo", None)
86
+ thumb = getattr(entry, "thumb", None)
87
+ # The constructor, not the payload: a media result with neither a photo
88
+ # nor a document is still a media result, and saying "url" would send a
89
+ # caller looking for a URL that is not there.
90
+ media = type(entry).__name__ == "BotInlineMediaResult"
91
+ return InlineResult(
92
+ n=index,
93
+ id=str(getattr(entry, "id", "") or ""),
94
+ type=str(getattr(entry, "type", "") or ""),
95
+ title=getattr(entry, "title", None),
96
+ description=getattr(entry, "description", None),
97
+ url=getattr(entry, "url", None),
98
+ thumb=getattr(thumb, "url", None),
99
+ content="media" if media else "url",
100
+ send_message=_MESSAGE_KINDS.get(type(send).__name__),
101
+ query_id=str(query_id),
102
+ doc_id=int(getattr(document, "id", 0) or 0) or None,
103
+ photo_id=int(getattr(photo, "id", 0) or 0) or None,
104
+ gallery=bool(getattr(results, "gallery", False)) if results is not None else False,
105
+ cache_time=getattr(results, "cache_time", None) if results is not None else None,
106
+ )
107
+
108
+
109
+ def _switch(value: Any) -> dict[str, Any] | None:
110
+ if value is None:
111
+ return None
112
+ return {
113
+ "text": getattr(value, "text", None),
114
+ "start_param": getattr(value, "start_param", None),
115
+ "url": getattr(value, "url", None),
116
+ }
117
+
118
+
119
+ def _timed_out(exc: BaseException) -> bool:
120
+ """`BOT_RESPONSE_TIMEOUT` — the bot is offline, which is an answer."""
121
+ return "BOTRESPONSETIMEOUT" in f"{type(exc).__name__} {exc}".upper().replace("_", "")
122
+
123
+
124
+ async def _query(
125
+ ctx: OpContext,
126
+ bot: Any,
127
+ peer: Any,
128
+ text: str,
129
+ offset: str,
130
+ geo: Any = None,
131
+ ) -> Any:
132
+ from telethon.tl.functions import messages as fn
133
+
134
+ return await client(ctx)(
135
+ fn.GetInlineBotResultsRequest(bot=bot, peer=peer, query=text, offset=offset, geo_point=geo)
136
+ )
137
+
138
+
139
+ def _geo(lat: float | None, lon: float | None, accuracy: int | None) -> Any:
140
+ if lat is None or lon is None:
141
+ return None
142
+ from telethon.tl import types
143
+
144
+ return types.InputGeoPoint(lat=lat, long=lon, accuracy_radius=accuracy)
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # inline query
149
+ # ---------------------------------------------------------------------------
150
+
151
+
152
+ class QueryReq(Request):
153
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The inline bot.")]
154
+ query: Annotated[
155
+ str, arg(1, metavar="QUERY", required=False, help="Query text; empty is valid.")
156
+ ] = ""
157
+ chat: Annotated[
158
+ PeerRef | None,
159
+ opt("--chat", metavar="CHAT", kind="peer", help="Chat the query is made from."),
160
+ ] = None
161
+ offset: Annotated[
162
+ str | None, opt("--offset", metavar="TOKEN", help="Opaque next_offset from a page.")
163
+ ] = None
164
+ lat: Annotated[float | None, opt("--lat", metavar="DEG", help="Latitude for geo bots.")] = None
165
+ lon: Annotated[float | None, opt("--lon", metavar="DEG", help="Longitude for geo bots.")] = None
166
+ accuracy: Annotated[
167
+ int | None, opt("--accuracy", metavar="M", help="Location accuracy radius in metres.")
168
+ ] = None
169
+
170
+
171
+ async def query(ctx: OpContext, req: QueryReq) -> Page[InlineResult]:
172
+ """Query an inline bot and list what it answers with.
173
+
174
+ The chat matters: a bot is told which kind of chat the query came from and
175
+ routinely answers differently in a group than in a private chat, so
176
+ `--chat` is not cosmetic.
177
+ """
178
+ from telethon.tl import types
179
+
180
+ limit, state = window(ctx, "inline.query", PageKind.RATE, default=50)
181
+ bot = await _bots.input_user(ctx, req.bot)
182
+ peer = await _send.resolve(ctx, req.chat) if req.chat is not None else types.InputPeerSelf()
183
+ offset = req.offset if req.offset is not None else str(state.get("offset", "") or "")
184
+
185
+ try:
186
+ results = await _query(
187
+ ctx, bot, peer, req.query, offset, _geo(req.lat, req.lon, req.accuracy)
188
+ )
189
+ except Exception as exc:
190
+ if not _timed_out(exc):
191
+ raise
192
+ ctx.warn("the bot did not answer in time; it is probably offline")
193
+ return Page(items=[], has_more=False, total=0)
194
+
195
+ query_id = int(getattr(results, "query_id", 0) or 0)
196
+ entries = list(getattr(results, "results", None) or [])[:limit]
197
+ items = [_result_model(entry, index, query_id, results) for index, entry in enumerate(entries)]
198
+ next_offset = str(getattr(results, "next_offset", "") or "")
199
+ if items:
200
+ items[0].next_offset = next_offset or None
201
+ items[0].switch_pm = _switch(getattr(results, "switch_pm", None))
202
+ items[0].switch_webview = _switch(getattr(results, "switch_webview", None))
203
+ return build_page(
204
+ items,
205
+ op="inline.query",
206
+ kind=PageKind.RATE,
207
+ state={"offset": next_offset},
208
+ account=ctx.account,
209
+ has_more=bool(next_offset),
210
+ total=None,
211
+ )
212
+
213
+
214
+ SPEC_QUERY = OperationSpec(
215
+ id="inline.query",
216
+ request=QueryReq,
217
+ response=Page[InlineResult],
218
+ impl=query,
219
+ summary="Query an inline bot and list its results",
220
+ description=(
221
+ "Paging offsets are opaque strings the bot invented, not integers: "
222
+ "the `next_offset` on the first row is fed straight back, and an "
223
+ "empty one means the end. A bot that does not answer is an empty page "
224
+ "with a warning, not an error."
225
+ ),
226
+ paginated=PageKind.RATE,
227
+ empty_exit=3,
228
+ columns=("n", "id", "type", "title"),
229
+ headers=("#", "ID", "Type", "Title"),
230
+ example={"items": [_EXAMPLE_RESULT], "has_more": False},
231
+ example_args="inline query @gifbot cat",
232
+ covers=(
233
+ "bots.inline-query",
234
+ "bots.inline-query-paging",
235
+ "bots.inline-query-with-location",
236
+ "bots.inline-result-message-kinds",
237
+ "bots.inline-result-types",
238
+ "bots.inline-switch-webview",
239
+ "bots.switch-inline-button",
240
+ ),
241
+ covers_partial=("bots.inline-switch-pm", "bots.webapp-switch-inline-query"),
242
+ coverage_note=(
243
+ "A `switch_pm` button is completed with `bot start --param` and a "
244
+ "`switch_webview` one with `webapp open --from-switch-webview`."
245
+ ),
246
+ )
247
+
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # inline search
251
+ # ---------------------------------------------------------------------------
252
+
253
+
254
+ class SearchReq(Request):
255
+ kind: Annotated[str, arg(0, metavar="KIND", help="gif, venue or image.")]
256
+ query: Annotated[str, arg(1, metavar="QUERY", required=False, help="Search text.")] = ""
257
+ chat: Annotated[
258
+ PeerRef | None,
259
+ opt("--chat", metavar="CHAT", kind="peer", help="Chat the search is made from."),
260
+ ] = None
261
+ lat: Annotated[float | None, opt("--lat", metavar="DEG", help="Latitude (venue).")] = None
262
+ lon: Annotated[float | None, opt("--lon", metavar="DEG", help="Longitude (venue).")] = None
263
+ offset: Annotated[str | None, opt("--offset", metavar="TOKEN", help="Opaque next_offset.")] = (
264
+ None
265
+ )
266
+
267
+
268
+ _SEARCH_BOTS = {
269
+ "gif": ("gif_search_username", "gif"),
270
+ "venue": ("venue_search_username", "foursquare"),
271
+ "image": ("img_search_username", "pic"),
272
+ }
273
+
274
+
275
+ async def search(ctx: OpContext, req: SearchReq) -> Page[InlineResult]:
276
+ """Search the built-in inline bots for GIFs, venues or images.
277
+
278
+ The usernames come from `help.getConfig`, never from a constant here:
279
+ Telegram has moved them before, and a hardcoded one would keep querying an
280
+ account that no longer serves anything.
281
+ """
282
+ from telethon.tl import types
283
+ from telethon.tl.functions import help as help_fn
284
+
285
+ if req.kind not in _SEARCH_BOTS:
286
+ raise UsageError("kind must be gif, venue or image", field="kind")
287
+ if req.kind == "venue" and (req.lat is None or req.lon is None):
288
+ raise UsageError("a venue search needs --lat and --lon", field="lat")
289
+
290
+ key, fallback = _SEARCH_BOTS[req.kind]
291
+ username = fallback
292
+ try:
293
+ config = await client(ctx)(help_fn.GetConfigRequest())
294
+ username = str(getattr(config, key, "") or fallback)
295
+ except Exception: # an older server: fall back rather than fail the search
296
+ pass
297
+
298
+ limit, state = window(ctx, "inline.search", PageKind.RATE, default=50)
299
+ # Built rather than parsed: Telegram's own service accounts are shorter
300
+ # than the four characters a *user* may register, so the username parser
301
+ # rightly refuses them.
302
+ handle = username.lstrip("@")
303
+ bot = await _bots.input_user(
304
+ ctx, PeerRef(raw=f"@{handle}", kind="username", value=handle), field="kind"
305
+ )
306
+ peer = await _send.resolve(ctx, req.chat) if req.chat is not None else types.InputPeerEmpty()
307
+ offset = req.offset if req.offset is not None else str(state.get("offset", "") or "")
308
+
309
+ try:
310
+ results = await _query(ctx, bot, peer, req.query, offset, _geo(req.lat, req.lon, None))
311
+ except Exception as exc:
312
+ if not _timed_out(exc):
313
+ raise
314
+ ctx.warn(f"@{handle} did not answer in time")
315
+ return Page(items=[], has_more=False, total=0)
316
+
317
+ query_id = int(getattr(results, "query_id", 0) or 0)
318
+ entries = list(getattr(results, "results", None) or [])[:limit]
319
+ items = [_result_model(entry, index, query_id, results) for index, entry in enumerate(entries)]
320
+ next_offset = str(getattr(results, "next_offset", "") or "")
321
+ return build_page(
322
+ items,
323
+ op="inline.search",
324
+ kind=PageKind.RATE,
325
+ state={"offset": next_offset},
326
+ account=ctx.account,
327
+ has_more=bool(next_offset),
328
+ )
329
+
330
+
331
+ SPEC_SEARCH = OperationSpec(
332
+ id="inline.search",
333
+ request=SearchReq,
334
+ response=Page[InlineResult],
335
+ impl=search,
336
+ summary="Search the built-in inline bots for GIFs, venues or images",
337
+ paginated=PageKind.RATE,
338
+ empty_exit=3,
339
+ columns=("n", "id", "type", "title"),
340
+ headers=("#", "ID", "Type", "Title"),
341
+ example={"items": [_EXAMPLE_RESULT], "has_more": False},
342
+ example_args="inline search gif cat",
343
+ covers=("bots.gif-search-inline", "bots.img-search-inline", "bots.venue-search-inline"),
344
+ )
345
+
346
+
347
+ # ---------------------------------------------------------------------------
348
+ # inline send
349
+ # ---------------------------------------------------------------------------
350
+
351
+
352
+ class SendReq(Request):
353
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The inline bot.")]
354
+ query: Annotated[
355
+ str, arg(1, metavar="QUERY", required=False, help="Query to re-run for --pick.")
356
+ ] = ""
357
+ chat: Annotated[
358
+ PeerRef | None, opt("--chat", metavar="CHAT", kind="peer", help="Destination chat.")
359
+ ] = None
360
+ pick: Annotated[
361
+ str | None, opt("--pick", metavar="N|ID", help="Result to send: index or result id.")
362
+ ] = None
363
+ query_id: Annotated[
364
+ str | None, opt("--query-id", metavar="ID", help="query_id from a previous `inline query`.")
365
+ ] = None
366
+ result_id: Annotated[
367
+ str | None, opt("--result-id", metavar="ID", help="Result id belonging to --query-id.")
368
+ ] = None
369
+ hide_via: Annotated[bool, opt("--hide-via", help="Drop the 'via @bot' header.")] = False
370
+ clear_draft: Annotated[bool, opt("--clear-draft", help="Clear the chat draft.")] = False
371
+ background: Annotated[bool, opt("--background", help="Send in the background.")] = False
372
+ quick_reply: Annotated[
373
+ str | None,
374
+ opt("--quick-reply", metavar="SHORTCUT", help="Store it in a Business quick reply."),
375
+ ] = None
376
+ reply_to: Annotated[
377
+ int | None, opt("--reply-to", metavar="ID", kind="msg_id", help="Reply to this message.")
378
+ ] = None
379
+ quote: Annotated[str | None, opt("--quote", help="Quoted fragment of the reply target.")] = None
380
+ topic: Annotated[
381
+ int | None, opt("--topic", metavar="ID", kind="msg_id", help="Forum topic id.")
382
+ ] = None
383
+ silent: Annotated[bool, opt("--silent", help="Send without a notification.")] = False
384
+ schedule: Annotated[
385
+ str | None, opt("--schedule", metavar="TS|online", help="Schedule the send.")
386
+ ] = None
387
+ send_as: Annotated[
388
+ PeerRef | None, opt("--send-as", metavar="PEER", kind="peer", help="Send as this peer.")
389
+ ] = None
390
+ paid_stars: Annotated[
391
+ int | None,
392
+ opt("--paid-stars", metavar="N", help="Agree to pay N Stars for a paid-message peer."),
393
+ ] = None
394
+ business_connection: Annotated[
395
+ str | None,
396
+ opt(
397
+ "--business-connection", metavar="ID", help="Send as a business account (bot session)."
398
+ ),
399
+ ] = None
400
+
401
+
402
+ async def send(ctx: OpContext, req: SendReq) -> InlineSent:
403
+ """Send one chosen inline result into a chat.
404
+
405
+ `--pick` re-runs the query in this same command rather than taking a
406
+ `(query_id, result_id)` pair from an earlier one, because that pair
407
+ expires in about a minute: a two-command workflow would fail whenever the
408
+ human in the middle took a moment to choose.
409
+ """
410
+ from telethon.tl import types
411
+ from telethon.tl.functions import messages as fn
412
+
413
+ if req.chat is None:
414
+ raise UsageError("--chat is required", field="chat")
415
+ if req.paid_stars and req.paid_stars < 0:
416
+ raise UsageError("--paid-stars cannot be negative", field="paid_stars")
417
+
418
+ target = await _send.resolve(ctx, req.chat)
419
+ query_id, result_id = await _pair(ctx, req, target)
420
+
421
+ request = fn.SendInlineBotResultRequest(
422
+ peer=target,
423
+ query_id=query_id,
424
+ id=result_id,
425
+ random_id=_random_id(),
426
+ silent=req.silent or None,
427
+ background=req.background or None,
428
+ clear_draft=req.clear_draft or None,
429
+ hide_via=req.hide_via or None,
430
+ reply_to=await _send.reply_target(
431
+ ctx, reply_to=req.reply_to, quote=req.quote, topic=req.topic
432
+ ),
433
+ schedule_date=_send.schedule_at(req.schedule),
434
+ send_as=await _send.resolve(ctx, req.send_as) if req.send_as is not None else None,
435
+ quick_reply_shortcut=(
436
+ types.InputQuickReplyShortcut(shortcut=req.quick_reply) if req.quick_reply else None
437
+ ),
438
+ allow_paid_stars=req.paid_stars,
439
+ )
440
+ updates = await _invoke_as(ctx, req.business_connection, request)
441
+ message = _send.message_from_updates(updates, chat_id=_send.peer_id_of(target))
442
+ ctx.emit("inline_send", {"chat_id": message.chat_id, "result_id": result_id})
443
+ return InlineSent(
444
+ chat_id=message.chat_id,
445
+ msg_id=message.id,
446
+ result_id=result_id,
447
+ via_bot_id=message.via_bot_id,
448
+ quick_reply=req.quick_reply,
449
+ )
450
+
451
+
452
+ async def _pair(ctx: OpContext, req: SendReq, target: Any) -> tuple[int, str]:
453
+ """The `(query_id, result_id)` pair, freshly minted unless one was given."""
454
+ if req.query_id and req.result_id:
455
+ try:
456
+ return int(req.query_id), req.result_id
457
+ except ValueError as exc:
458
+ raise UsageError("--query-id must be numeric", field="query_id") from exc
459
+ if req.query_id or req.result_id:
460
+ raise UsageError("--query-id and --result-id are only valid together", field="query_id")
461
+ if req.pick is None:
462
+ raise UsageError("give --pick, or --query-id with --result-id", field="pick")
463
+
464
+ bot = await _bots.input_user(ctx, req.bot)
465
+ results = await _query(ctx, bot, target, req.query, "")
466
+ entries = list(getattr(results, "results", None) or [])
467
+ query_id = int(getattr(results, "query_id", 0) or 0)
468
+ spec = req.pick.strip()
469
+ if spec.isdigit() and int(spec) < len(entries):
470
+ return query_id, str(getattr(entries[int(spec)], "id", ""))
471
+ for entry in entries:
472
+ if str(getattr(entry, "id", "")) == spec:
473
+ return query_id, spec
474
+ raise NotFoundError(f"the bot returned no result {spec!r} for that query")
475
+
476
+
477
+ def _random_id() -> int:
478
+ from tlgr.ops._common import random_id
479
+
480
+ return random_id()
481
+
482
+
483
+ async def _invoke_as(ctx: OpContext, connection_id: str | None, request: Any) -> Any:
484
+ from tlgr.ops.bot import _invoke_as as wrap
485
+
486
+ return await wrap(ctx, connection_id, request)
487
+
488
+
489
+ SPEC_SEND = OperationSpec(
490
+ id="inline.send",
491
+ request=SendReq,
492
+ response=InlineSent,
493
+ impl=send,
494
+ summary="Send a chosen inline result to a chat",
495
+ tags=frozenset({"visible-to-others"}),
496
+ description=(
497
+ "`--paid-stars` agrees to a per-message Star fee. Naming the number "
498
+ "is the consent: `--yes` is a CLI-level gate an operation never sees, "
499
+ "so a flag that spends money spells out how much."
500
+ ),
501
+ mutating=True,
502
+ rate_class="send",
503
+ columns=("chat_id", "msg_id", "result_id"),
504
+ headers=("Chat", "Message", "Result"),
505
+ example={"chat_id": 4242, "msg_id": 12, "result_id": "BQADAgAD"},
506
+ example_args="inline send @gifbot cat --chat @alice --pick 0",
507
+ covers=(
508
+ "bots.inline-result-into-quick-reply",
509
+ "bots.send-inline-result",
510
+ "bots.webapp-switch-inline-query",
511
+ ),
512
+ covers_partial=("bots.gif-search-inline", "bots.venue-search-inline"),
513
+ coverage_note="Running the built-in searches themselves is `inline search`.",
514
+ )
515
+
516
+
517
+ # ---------------------------------------------------------------------------
518
+ # inline edit
519
+ # ---------------------------------------------------------------------------
520
+
521
+
522
+ class EditReq(Request):
523
+ inline_msg_id: Annotated[
524
+ str, arg(0, metavar="INLINE_MSG_ID", help="Inline message id as dc:id:access_hash.")
525
+ ]
526
+ text: Annotated[str | None, opt("--text", help="New text.")] = None
527
+ media: Annotated[str | None, opt("--media", metavar="PATH", kind="path", help="New media.")] = (
528
+ None
529
+ )
530
+ buttons: Annotated[
531
+ str | None, opt("--buttons", metavar="PATH", kind="path", help="New keyboard, as JSON.")
532
+ ] = None
533
+ parse: Annotated[str | None, choice("md", "html", "none", help="Text formatting.")] = None
534
+ no_preview: Annotated[bool, opt("--no-preview", help="Disable the link preview.")] = False
535
+
536
+
537
+ async def edit(ctx: OpContext, req: EditReq) -> InlineEdited:
538
+ """Edit a message that was sent through inline mode.
539
+
540
+ The request has to reach the DC named in the inline message id. Sending it
541
+ to the home DC fails with an error that says nothing about data centres,
542
+ which is why the id carries one at all.
543
+ """
544
+ from telethon.tl.functions import messages as fn
545
+
546
+ await _bots.require_bot_session(ctx, "editing an inline message")
547
+ identifier = _bots.inline_message_id(req.inline_msg_id, field="inline_msg_id")
548
+ text, entities = _send.body(req.text, parse=req.parse) if req.text is not None else ("", [])
549
+ await _bots.on_dc(
550
+ ctx,
551
+ int(getattr(identifier, "dc_id", 0) or 0),
552
+ fn.EditInlineBotMessageRequest(
553
+ id=identifier,
554
+ message=text if req.text is not None else None,
555
+ entities=_send.tl_entities(entities) if req.text is not None else None,
556
+ media=await _send.input_media(ctx, req.media) if req.media else None,
557
+ reply_markup=_bots.keyboard_tl(req.buttons, field="buttons"),
558
+ no_webpage=req.no_preview or None,
559
+ ),
560
+ )
561
+ return InlineEdited(inline_msg_id=req.inline_msg_id, edited=True)
562
+
563
+
564
+ SPEC_EDIT = OperationSpec(
565
+ id="inline.edit",
566
+ request=EditReq,
567
+ response=InlineEdited,
568
+ impl=edit,
569
+ summary="Edit a message sent through inline mode",
570
+ mutating=True,
571
+ columns=("inline_msg_id", "edited"),
572
+ headers=("Inline ID", "Edited"),
573
+ example={"inline_msg_id": "2:123:456", "edited": True},
574
+ example_args="inline edit 2:123:456 --text Updated",
575
+ covers=("bots.edit-inline-message",),
576
+ )
577
+
578
+
579
+ # ---------------------------------------------------------------------------
580
+ # inline prepared
581
+ # ---------------------------------------------------------------------------
582
+
583
+
584
+ class PreparedGetReq(Request):
585
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The mini app's bot.")]
586
+ id: Annotated[str, arg(1, metavar="ID", help="Prepared message id from the app.")]
587
+
588
+
589
+ def _peer_type_names(values: Any) -> list[str]:
590
+ names = {tl: name for name, tl in _PEER_TYPES.items()}
591
+ return [names.get(type(v).__name__, type(v).__name__) for v in (values or [])]
592
+
593
+
594
+ async def prepared_get(ctx: OpContext, req: PreparedGetReq) -> PreparedMessage:
595
+ """Inspect a prepared inline message shared from a mini app.
596
+
597
+ `peer_types` is not advisory: it restricts which chats the picker may
598
+ offer, and `inline prepared send` refuses a chat outside it rather than
599
+ letting the server reject the send after the fact.
600
+ """
601
+ from telethon.tl.functions import messages as fn
602
+
603
+ result = await client(ctx)(
604
+ fn.GetPreparedInlineMessageRequest(bot=await _bots.input_user(ctx, req.bot), id=req.id)
605
+ )
606
+ query_id = int(getattr(result, "query_id", 0) or 0)
607
+ entry = getattr(result, "result", None)
608
+ return PreparedMessage(
609
+ query_id=str(query_id),
610
+ result=_result_model(entry, 0, query_id) if entry is not None else None,
611
+ peer_types=_peer_type_names(getattr(result, "peer_types", None)),
612
+ cache_time=getattr(result, "cache_time", None),
613
+ )
614
+
615
+
616
+ SPEC_PREPARED_GET = OperationSpec(
617
+ id="inline.prepared.get",
618
+ request=PreparedGetReq,
619
+ response=PreparedMessage,
620
+ impl=prepared_get,
621
+ summary="Inspect a prepared inline message from a mini app",
622
+ columns=("query_id", "peer_types", "cache_time"),
623
+ headers=("Query", "Chat types", "Cache"),
624
+ example={"query_id": "987654321", "peer_types": ["pm"]},
625
+ example_args="inline prepared get @my_helper_bot abc123",
626
+ covers_partial=("bots.prepared-inline-message-send",),
627
+ coverage_note="Sending it is `inline prepared send`.",
628
+ )
629
+
630
+
631
+ class PreparedSaveReq(Request):
632
+ user: Annotated[
633
+ PeerRef | None,
634
+ opt("--user", metavar="USER", kind="user", help="Who will be able to share it."),
635
+ ] = None
636
+ result: Annotated[
637
+ str | None, opt("--result", metavar="PATH", kind="path", help="JSON inline result.")
638
+ ] = None
639
+ peer_types: Annotated[
640
+ list[str],
641
+ opt("--peer-types", metavar="KIND", help="Chat types the picker may offer (repeatable)."),
642
+ ] = []
643
+
644
+
645
+ async def prepared_save(ctx: OpContext, req: PreparedSaveReq) -> PreparedSaved:
646
+ """Save a prepared inline message for a user to share later."""
647
+ from telethon.tl import types
648
+ from telethon.tl.functions import messages as fn
649
+
650
+ await _bots.require_bot_session(ctx, "saving a prepared inline message")
651
+ if req.user is None or not req.result:
652
+ raise UsageError("--user and --result are both required", field="user")
653
+
654
+ from tlgr.ops.bot import _inline_results
655
+
656
+ peer_types = []
657
+ for name in req.peer_types:
658
+ klass = _PEER_TYPES.get(name)
659
+ if klass is None:
660
+ raise UsageError(
661
+ f"--peer-types: {name!r} is not a chat type ({', '.join(sorted(_PEER_TYPES))})",
662
+ field="peer_types",
663
+ )
664
+ peer_types.append(getattr(types, klass)())
665
+
666
+ result = await client(ctx)(
667
+ fn.SavePreparedInlineMessageRequest(
668
+ result=_inline_results(req.result)[0],
669
+ user_id=await _bots.input_user(ctx, req.user, field="user"),
670
+ peer_types=peer_types or None,
671
+ )
672
+ )
673
+ from tlgr.core.timefmt import fmt_dt
674
+
675
+ return PreparedSaved(
676
+ id=str(getattr(result, "id", "") or ""),
677
+ expires_at=fmt_dt(getattr(result, "expire_date", None)),
678
+ )
679
+
680
+
681
+ SPEC_PREPARED_SAVE = OperationSpec(
682
+ id="inline.prepared.save",
683
+ request=PreparedSaveReq,
684
+ response=PreparedSaved,
685
+ impl=prepared_save,
686
+ summary="Save a prepared inline message for a user",
687
+ mutating=True,
688
+ columns=("id", "expires_at"),
689
+ headers=("ID", "Expires"),
690
+ example={"id": "abc123"},
691
+ example_args="inline prepared save --user @alice --result ./result.json",
692
+ covers=("bots.prepared-inline-message-save",),
693
+ )
694
+
695
+
696
+ class PreparedSendReq(Request):
697
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The mini app's bot.")]
698
+ id: Annotated[str, arg(1, metavar="ID", help="Prepared message id from the app.")]
699
+ chat: Annotated[
700
+ PeerRef | None, opt("--chat", metavar="CHAT", kind="peer", help="Destination chat.")
701
+ ] = None
702
+ reply_to: Annotated[
703
+ int | None, opt("--reply-to", metavar="ID", kind="msg_id", help="Reply to this message.")
704
+ ] = None
705
+ silent: Annotated[bool, opt("--silent", help="Send without a notification.")] = False
706
+ hide_via: Annotated[bool, opt("--hide-via", help="Drop the 'via @bot' header.")] = False
707
+
708
+
709
+ _PEER_KINDS = {
710
+ "InputPeerUser": {"pm", "bot", "same_bot"},
711
+ "InputPeerChat": {"group"},
712
+ "InputPeerChannel": {"channel", "broadcast", "megagroup", "group"},
713
+ "InputPeerSelf": {"pm", "same_bot"},
714
+ }
715
+
716
+
717
+ async def prepared_send(ctx: OpContext, req: PreparedSendReq) -> InlineSent:
718
+ """Send a prepared inline message a mini app handed over.
719
+
720
+ The app said which chat types it allows; a destination outside them is a
721
+ usage error here rather than a server rejection, because the app's
722
+ restriction is the thing the user agreed to when they tapped share.
723
+ """
724
+ from telethon.tl.functions import messages as fn
725
+
726
+ if req.chat is None:
727
+ raise UsageError("--chat is required", field="chat")
728
+ prepared = await prepared_get(ctx, PreparedGetReq(bot=req.bot, id=req.id))
729
+ target = await _send.resolve(ctx, req.chat)
730
+ allowed = set(prepared.peer_types)
731
+ if allowed:
732
+ kinds = _PEER_KINDS.get(type(target).__name__, set())
733
+ if not (kinds & allowed):
734
+ raise UsageError(
735
+ f"this prepared message may only go to {', '.join(sorted(allowed))}",
736
+ field="chat",
737
+ )
738
+
739
+ updates = await client(ctx)(
740
+ fn.SendInlineBotResultRequest(
741
+ peer=target,
742
+ query_id=int(prepared.query_id or 0),
743
+ id=prepared.result.id if prepared.result is not None else "",
744
+ random_id=_random_id(),
745
+ silent=req.silent or None,
746
+ hide_via=req.hide_via or None,
747
+ reply_to=await _send.reply_target(ctx, reply_to=req.reply_to),
748
+ )
749
+ )
750
+ message = _send.message_from_updates(updates, chat_id=_send.peer_id_of(target))
751
+ return InlineSent(
752
+ chat_id=message.chat_id,
753
+ msg_id=message.id,
754
+ result_id=prepared.result.id if prepared.result is not None else "",
755
+ via_bot_id=message.via_bot_id,
756
+ )
757
+
758
+
759
+ SPEC_PREPARED_SEND = OperationSpec(
760
+ id="inline.prepared.send",
761
+ request=PreparedSendReq,
762
+ response=InlineSent,
763
+ impl=prepared_send,
764
+ summary="Send a prepared inline message shared from a mini app",
765
+ tags=frozenset({"visible-to-others"}),
766
+ mutating=True,
767
+ rate_class="send",
768
+ columns=("chat_id", "msg_id", "result_id"),
769
+ headers=("Chat", "Message", "Result"),
770
+ example={"chat_id": 4242, "msg_id": 12, "result_id": "BQADAgAD"},
771
+ example_args="inline prepared send @my_helper_bot abc123 --chat @alice",
772
+ covers=("bots.prepared-inline-message-send",),
773
+ )