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/gif.py ADDED
@@ -0,0 +1,522 @@
1
+ """The `gif` group: the saved-GIF shelf, and the inline bot behind the search.
2
+
3
+ Two genuinely different sends hide behind one verb, and the difference is
4
+ visible to everyone in the chat:
5
+
6
+ * a **saved** GIF goes out as `messages.sendMedia(inputMediaDocument)` and
7
+ looks like any other message;
8
+ * a **search result** goes out as `messages.sendInlineBotResult` and is
9
+ attributed "via @gif" unless the bot allows `hide_via`.
10
+
11
+ `gif send --search` re-runs the query in the same command rather than
12
+ accepting a `query_id` from an earlier `gif search`, because a query id
13
+ expires in about a minute — taking a stale one would be an API that fails for
14
+ reasons the caller cannot see.
15
+
16
+ Telethon is imported inside functions, never at module scope (§2.2).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ from pathlib import Path
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.page import Page
29
+ from tlgr.models.peer import PeerRef
30
+ from tlgr.models.sticker import GifResult, GifSaved, GifSent, SavedGif
31
+ from tlgr.ops import _media, _send
32
+ from tlgr.ops._params import arg, opt
33
+ from tlgr.ops._spec import OpContext, OperationSpec
34
+
35
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
36
+
37
+ #: The fallback when `help.getConfig` names no GIF bot. Read from the config
38
+ #: first: hardcoding it is how a client keeps querying a bot Telegram moved.
39
+ DEFAULT_GIF_BOT = "gif"
40
+
41
+ _EXAMPLE_GIF: dict[str, Any] = {
42
+ "doc_id": 5312836234,
43
+ "index": 0,
44
+ "mime": "video/mp4",
45
+ "size": 184320,
46
+ "duration": 3,
47
+ "width": 320,
48
+ "height": 240,
49
+ }
50
+
51
+
52
+ def _client(ctx: OpContext) -> Any:
53
+ return _media.client(ctx)
54
+
55
+
56
+ async def _saved_documents(ctx: OpContext) -> list[Any]:
57
+ """The saved GIFs, freshly fetched so every reference is live."""
58
+ from telethon.tl.functions import messages as fn
59
+
60
+ result = await _client(ctx)(fn.GetSavedGifsRequest(hash=0))
61
+ return list(getattr(result, "gifs", None) or [])
62
+
63
+
64
+ async def _gif_bot(ctx: OpContext, override: PeerRef | None) -> Any:
65
+ """The inline bot to query: `config.gif_search_username`, not a constant."""
66
+ if override is not None:
67
+ return await _send.resolve(ctx, override)
68
+ from telethon.tl.functions import help as help_fn
69
+
70
+ username = DEFAULT_GIF_BOT
71
+ try:
72
+ config = await _client(ctx)(help_fn.GetConfigRequest())
73
+ username = str(getattr(config, "gif_search_username", "") or DEFAULT_GIF_BOT)
74
+ except Exception: # an old server: fall back rather than fail the send
75
+ pass
76
+ # Built rather than parsed: Telegram's own service accounts (`gif`, `vid`,
77
+ # `pic`, `wiki`) are shorter than the four characters a *user* may
78
+ # register, so the username parser rightly refuses them.
79
+ from tlgr.models.peer import PeerRef
80
+
81
+ handle = username.lstrip("@")
82
+ return await _send.resolve(ctx, PeerRef(raw=f"@{handle}", kind="username", value=handle))
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # gif list
87
+ # ---------------------------------------------------------------------------
88
+
89
+
90
+ class ListReq(Request):
91
+ download: Annotated[
92
+ str | None,
93
+ opt("--download", metavar="DIR", kind="path", help="Download every saved GIF into here."),
94
+ ] = None
95
+
96
+
97
+ async def list_gifs(ctx: OpContext, req: ListReq) -> Page[SavedGif]:
98
+ """Your saved GIFs.
99
+
100
+ A hash-cached list capped by `saved_gifs_limit`, with the oldest evicted
101
+ server-side. The index printed here is what `gif send <chat> <index>`
102
+ takes, which is why it is resolved against a freshly fetched list.
103
+ """
104
+ documents = await _saved_documents(ctx)
105
+ items = [_media.gif_model(document, index) for index, document in enumerate(documents)]
106
+ if req.download:
107
+ download = getattr(ctx, "download_file", None)
108
+ if download is None: # pragma: no cover - the daemon always supplies one
109
+ raise UsageError("this context cannot download files")
110
+ directory = Path(os.path.expanduser(req.download))
111
+ directory.mkdir(parents=True, exist_ok=True)
112
+ for index, document in enumerate(documents):
113
+ path = await download(
114
+ document,
115
+ directory / f"gif_{index}.mp4",
116
+ size=int(getattr(document, "size", 0) or 0),
117
+ dc_id=int(getattr(document, "dc_id", 0) or 0),
118
+ )
119
+ items[index].path = str(path)
120
+ return Page(items=items, has_more=False, total=len(items))
121
+
122
+
123
+ SPEC_LIST = OperationSpec(
124
+ id="gif.list",
125
+ request=ListReq,
126
+ response=Page[SavedGif],
127
+ impl=list_gifs,
128
+ summary="Your saved GIFs",
129
+ columns=("index", "doc_id", "duration", "size"),
130
+ headers=("#", "Doc", "Secs", "Bytes"),
131
+ example={"items": [_EXAMPLE_GIF], "has_more": False},
132
+ example_args="gif list",
133
+ covers=("gif.saved-list",),
134
+ )
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # gif add / remove
139
+ # ---------------------------------------------------------------------------
140
+
141
+
142
+ class AddReq(Request):
143
+ chat: Annotated[
144
+ PeerRef | None,
145
+ arg(0, metavar="CHAT", required=False, kind="peer", help="Chat holding the animation."),
146
+ ] = None
147
+ msg_id: Annotated[
148
+ list[int],
149
+ arg(1, metavar="MSG_ID", required=False, variadic=True, kind="msg_id", help="Message ids."),
150
+ ] = []
151
+ file_id: Annotated[
152
+ str | None, opt("--file-id", metavar="ID", help="Save media already on Telegram.")
153
+ ] = None
154
+ file: Annotated[
155
+ str | None,
156
+ opt("--file", metavar="PATH", kind="path", help="Upload an animation and save it."),
157
+ ] = None
158
+
159
+
160
+ async def add(ctx: OpContext, req: AddReq) -> GifSaved:
161
+ """Save GIFs.
162
+
163
+ `saveGif` needs a live `file_reference`, so the message is re-fetched
164
+ immediately before the call. Only animations are accepted — anything else
165
+ is `GIF_CONTENT_TYPE_INVALID`.
166
+ """
167
+ from telethon.tl.functions import messages as fn
168
+
169
+ documents = await _gif_documents(ctx, req)
170
+ before = {int(getattr(d, "id", 0)) for d in await _saved_documents(ctx)}
171
+ for document in documents:
172
+ await _client(ctx)(fn.SaveGifRequest(id=_media.input_document(document), unsave=False))
173
+ ids = [int(getattr(document, "id", 0)) for document in documents]
174
+ after = {int(getattr(d, "id", 0)) for d in await _saved_documents(ctx)}
175
+ evicted = sorted(before - after - set(ids))
176
+ if evicted:
177
+ ctx.warn("the saved-GIF limit was reached; the server evicted the oldest entries")
178
+ return GifSaved(
179
+ doc_ids=ids, saved=len(ids), already=bool(ids) and set(ids) <= before, evicted=evicted
180
+ )
181
+
182
+
183
+ async def _gif_documents(ctx: OpContext, req: AddReq) -> list[Any]:
184
+ if req.file_id:
185
+ from telethon import utils
186
+
187
+ resolved = utils.resolve_bot_file_id(req.file_id)
188
+ if resolved is None:
189
+ raise UsageError("--file-id: that is not a Telegram file id", field="file_id")
190
+ return [resolved]
191
+ if req.file:
192
+ # Uploading an animation saves it server-side already; sending it to
193
+ # Saved Messages is what makes a document exist to point at.
194
+ from telethon.tl import types
195
+ from telethon.tl.functions import messages as fn
196
+
197
+ upload = getattr(ctx, "upload_file", None)
198
+ if upload is None: # pragma: no cover
199
+ raise UsageError("this context cannot upload files")
200
+ path = Path(os.path.expanduser(req.file))
201
+ if not path.exists():
202
+ raise UsageError(f"--file: {path} does not exist", field="file")
203
+ handle = await upload(path)
204
+ uploaded = await _client(ctx)(
205
+ fn.UploadMediaRequest(
206
+ peer=types.InputPeerSelf(),
207
+ media=types.InputMediaUploadedDocument(
208
+ file=handle,
209
+ mime_type="video/mp4",
210
+ attributes=[types.DocumentAttributeAnimated()],
211
+ ),
212
+ )
213
+ )
214
+ document = _media.document_of(uploaded)
215
+ if document is None:
216
+ raise NotFoundError("the server did not accept that file as an animation")
217
+ return [document]
218
+
219
+ if req.chat is None or not req.msg_id:
220
+ raise UsageError("give a chat and message ids, or --file-id/--file", field="msg_id")
221
+ peer = await _send.resolve(ctx, req.chat)
222
+ out: list[Any] = []
223
+ for message_id in req.msg_id:
224
+ message = await _media.fetch_message(ctx, peer, int(message_id))
225
+ document = _media.document_of(getattr(message, "media", None))
226
+ if document is None:
227
+ raise NotFoundError(f"message {message_id} carries no animation")
228
+ out.append(document)
229
+ return out
230
+
231
+
232
+ SPEC_ADD = OperationSpec(
233
+ id="gif.add",
234
+ request=AddReq,
235
+ response=GifSaved,
236
+ impl=add,
237
+ summary="Save a GIF",
238
+ mutating=True,
239
+ idempotent=True,
240
+ columns=("doc_ids", "saved", "evicted"),
241
+ headers=("Docs", "Saved", "Evicted"),
242
+ example={"doc_ids": [5312836234], "saved": 1, "evicted": []},
243
+ example_args="gif add @alice 12345",
244
+ covers_partial=("gif.save-unsave",),
245
+ coverage_note="Removing is `gif remove`; the shelf itself is `gif list`.",
246
+ )
247
+
248
+
249
+ class RemoveReq(Request):
250
+ gif: Annotated[
251
+ list[str],
252
+ arg(0, metavar="GIF", variadic=True, help="Document id, or the index `gif list` printed."),
253
+ ] = []
254
+
255
+
256
+ async def remove(ctx: OpContext, req: RemoveReq) -> GifSaved:
257
+ """Remove GIFs from the saved list.
258
+
259
+ Indices are resolved against a freshly fetched list, so the reference is
260
+ valid *and* the index means what the user just saw.
261
+ """
262
+ from telethon.tl.functions import messages as fn
263
+
264
+ if not req.gif:
265
+ raise UsageError("name at least one GIF", field="gif")
266
+ documents = await _saved_documents(ctx)
267
+ by_id = {int(getattr(document, "id", 0)): document for document in documents}
268
+
269
+ picked: list[Any] = []
270
+ for value in req.gif:
271
+ text = str(value).strip()
272
+ if not text.lstrip("-").isdigit():
273
+ raise UsageError(f"{text!r} is not a document id or an index", field="gif")
274
+ number = int(text)
275
+ if number in by_id:
276
+ picked.append(by_id[number])
277
+ elif 0 <= number < len(documents):
278
+ picked.append(documents[number])
279
+ else:
280
+ raise NotFoundError(f"{text} is neither a saved document id nor an index")
281
+
282
+ for document in picked:
283
+ await _client(ctx)(fn.SaveGifRequest(id=_media.input_document(document), unsave=True))
284
+ return GifSaved(
285
+ doc_ids=[int(getattr(document, "id", 0)) for document in picked], removed=len(picked)
286
+ )
287
+
288
+
289
+ SPEC_REMOVE = OperationSpec(
290
+ id="gif.remove",
291
+ request=RemoveReq,
292
+ response=GifSaved,
293
+ impl=remove,
294
+ summary="Remove a GIF from the saved list",
295
+ mutating=True,
296
+ destructive=True,
297
+ columns=("doc_ids", "removed"),
298
+ headers=("Docs", "Removed"),
299
+ example={"doc_ids": [5312836234], "removed": 1},
300
+ example_args="gif remove 0",
301
+ covers=("gif.save-unsave",),
302
+ )
303
+
304
+
305
+ # ---------------------------------------------------------------------------
306
+ # gif search
307
+ # ---------------------------------------------------------------------------
308
+
309
+
310
+ class SearchReq(Request):
311
+ query: Annotated[str, arg(0, metavar="QUERY", help="What to search for.")]
312
+ bot: Annotated[
313
+ PeerRef | None,
314
+ opt("--bot", metavar="USER", kind="user", help="Inline bot to query."),
315
+ ] = None
316
+ chat: Annotated[
317
+ PeerRef | None,
318
+ opt("--chat", metavar="CHAT", kind="peer", help="Query in this chat's context."),
319
+ ] = None
320
+
321
+
322
+ def _inline_result(entry: Any, query_id: int) -> GifResult:
323
+ document = getattr(entry, "document", None)
324
+ facts = _media.attributes_of(document) if document is not None else {}
325
+ thumb = getattr(entry, "thumb", None)
326
+ return GifResult(
327
+ result_id=str(getattr(entry, "id", "") or ""),
328
+ query_id=query_id,
329
+ type=str(getattr(entry, "type", "gif") or "gif"),
330
+ title=getattr(entry, "title", None),
331
+ url=getattr(entry, "url", None) or getattr(getattr(entry, "content", None), "url", None),
332
+ thumb=getattr(thumb, "url", None),
333
+ duration=int(facts["duration"]) if facts.get("duration") is not None else None,
334
+ width=facts.get("width"),
335
+ height=facts.get("height"),
336
+ doc_id=getattr(document, "id", None),
337
+ )
338
+
339
+
340
+ async def search(ctx: OpContext, req: SearchReq) -> Page[GifResult]:
341
+ """Search GIFs through the inline GIF bot.
342
+
343
+ Results paginate on the bot's own opaque `next_offset` string, which the
344
+ cursor carries; there is no numeric offset to invent.
345
+ """
346
+ from telethon.tl import types
347
+ from telethon.tl.functions import messages as fn
348
+
349
+ limit, state = _media.window(ctx, "gif.search", PageKind.LOCAL)
350
+ bot = await _gif_bot(ctx, req.bot)
351
+ peer = await _send.resolve(ctx, req.chat) if req.chat is not None else types.InputPeerEmpty()
352
+ result = await _client(ctx)(
353
+ fn.GetInlineBotResultsRequest(
354
+ bot=bot, peer=peer, query=req.query, offset=str(state.get("offset", ""))
355
+ )
356
+ )
357
+ query_id = int(getattr(result, "query_id", 0) or 0)
358
+ items = [
359
+ _inline_result(entry, query_id)
360
+ for entry in (getattr(result, "results", None) or [])[:limit]
361
+ ]
362
+ next_offset = str(getattr(result, "next_offset", "") or "")
363
+ return build_page(
364
+ items,
365
+ op="gif.search",
366
+ kind=PageKind.LOCAL,
367
+ state={"offset": next_offset} if next_offset else None,
368
+ account=ctx.account,
369
+ has_more=bool(next_offset),
370
+ )
371
+
372
+
373
+ SPEC_SEARCH = OperationSpec(
374
+ id="gif.search",
375
+ request=SearchReq,
376
+ response=Page[GifResult],
377
+ impl=search,
378
+ summary="Search GIFs through the inline GIF bot",
379
+ paginated=PageKind.LOCAL,
380
+ columns=("result_id", "type", "title", "duration"),
381
+ headers=("Result", "Type", "Title", "Secs"),
382
+ example={
383
+ "items": [{"result_id": "1234", "query_id": 987654321, "type": "gif", "title": "cat"}],
384
+ "has_more": True,
385
+ },
386
+ example_args="gif search cats",
387
+ covers=("gif.search",),
388
+ )
389
+
390
+
391
+ # ---------------------------------------------------------------------------
392
+ # gif send
393
+ # ---------------------------------------------------------------------------
394
+
395
+
396
+ class SendReq(Request):
397
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Where to send it.")]
398
+ gif: Annotated[
399
+ str | None,
400
+ arg(1, metavar="GIF", required=False, help="Document id, or a saved-list index."),
401
+ ] = None
402
+ search: Annotated[
403
+ str | None, opt("--search", metavar="QUERY", help="Send from a fresh inline search.")
404
+ ] = None
405
+ pick: Annotated[
406
+ int, opt("--pick", metavar="N", help="Which search result to send (1-based).", ge=1)
407
+ ] = 1
408
+ bot: Annotated[
409
+ PeerRef | None, opt("--bot", metavar="USER", kind="user", help="Inline bot for --search.")
410
+ ] = None
411
+ hide_via: Annotated[
412
+ bool, opt("--hide-via", help="Hide the 'via @gif' attribution where allowed.")
413
+ ] = False
414
+ caption: Annotated[str | None, opt("--caption", metavar="TEXT", help="Caption text.")] = None
415
+ reply_to: Annotated[
416
+ int | None, opt("--reply-to", metavar="ID", kind="msg_id", help="Reply to this message.")
417
+ ] = None
418
+ topic: Annotated[
419
+ int | None, opt("--topic", metavar="ID", kind="msg_id", help="Forum topic id.")
420
+ ] = None
421
+ silent: Annotated[bool, opt("--silent", help="Send without a notification sound.")] = False
422
+ schedule: Annotated[
423
+ str | None, opt("--schedule", metavar="TS", kind="datetime", help="Send later.")
424
+ ] = None
425
+ save: Annotated[bool, opt("--save", help="Also add the sent GIF to the saved list.")] = False
426
+
427
+
428
+ async def send(ctx: OpContext, req: SendReq) -> GifSent:
429
+ """Send a saved GIF, or a fresh search result.
430
+
431
+ A search result goes out through `sendInlineBotResult` and carries the
432
+ bot's attribution; a saved GIF is an ordinary `sendMedia`. The two are
433
+ not interchangeable and the response says which happened.
434
+ """
435
+ from telethon.tl import types
436
+ from telethon.tl.functions import messages as fn
437
+
438
+ peer = await _send.resolve(ctx, req.chat)
439
+ chat_id = _send.peer_id_of(peer)
440
+ reply = await _send.reply_target(ctx, reply_to=req.reply_to, topic=req.topic)
441
+
442
+ if req.search:
443
+ bot = await _gif_bot(ctx, req.bot)
444
+ found = await _client(ctx)(
445
+ fn.GetInlineBotResultsRequest(bot=bot, peer=peer, query=req.search, offset="")
446
+ )
447
+ results = list(getattr(found, "results", None) or [])
448
+ if len(results) < req.pick:
449
+ raise NotFoundError(f"the search returned {len(results)} results")
450
+ chosen = results[req.pick - 1]
451
+ await _client(ctx)(
452
+ fn.SendInlineBotResultRequest(
453
+ peer=peer,
454
+ query_id=int(getattr(found, "query_id", 0) or 0),
455
+ id=str(getattr(chosen, "id", "")),
456
+ random_id=_random_id(),
457
+ silent=req.silent or None,
458
+ hide_via=req.hide_via or None,
459
+ reply_to=reply,
460
+ schedule_date=_send.schedule_at(req.schedule),
461
+ )
462
+ )
463
+ ctx.emit("media.sent", {"chat_id": chat_id, "kind": "gif", "via_bot": True})
464
+ return GifSent(
465
+ chat_id=chat_id,
466
+ doc_id=getattr(getattr(chosen, "document", None), "id", None),
467
+ via_bot=None if req.hide_via else "inline",
468
+ )
469
+
470
+ if req.gif is None:
471
+ raise UsageError("name a saved GIF by index or id, or use --search", field="gif")
472
+ documents = await _saved_documents(ctx)
473
+ by_id = {int(getattr(document, "id", 0)): document for document in documents}
474
+ number = int(req.gif) if str(req.gif).lstrip("-").isdigit() else -1
475
+ document = by_id.get(number) or (documents[number] if 0 <= number < len(documents) else None)
476
+ if document is None:
477
+ raise NotFoundError(f"{req.gif} is neither a saved document id nor an index")
478
+
479
+ result = await _client(ctx)(
480
+ fn.SendMediaRequest(
481
+ peer=peer,
482
+ media=types.InputMediaDocument(id=_media.input_document(document)),
483
+ message=req.caption or "",
484
+ random_id=_random_id(),
485
+ silent=req.silent or None,
486
+ reply_to=reply,
487
+ schedule_date=_send.schedule_at(req.schedule),
488
+ )
489
+ )
490
+ message = _send.message_from_updates(result, chat_id=chat_id, sent_text=req.caption or "")
491
+ if req.save:
492
+ await _client(ctx)(fn.SaveGifRequest(id=_media.input_document(document), unsave=False))
493
+ ctx.emit("media.sent", {"chat_id": chat_id, "msg_id": message.id, "kind": "gif"})
494
+ return GifSent(
495
+ chat_id=chat_id,
496
+ msg_id=message.id,
497
+ doc_id=int(getattr(document, "id", 0)),
498
+ saved=req.save,
499
+ )
500
+
501
+
502
+ def _random_id() -> int:
503
+ return int.from_bytes(os.urandom(8), "big", signed=True)
504
+
505
+
506
+ SPEC_SEND = OperationSpec(
507
+ id="gif.send",
508
+ request=SendReq,
509
+ response=GifSent,
510
+ impl=send,
511
+ summary="Send a saved GIF or a GIF search result",
512
+ mutating=True,
513
+ rate_class="send",
514
+ columns=("chat_id", "msg_id", "doc_id", "via_bot"),
515
+ headers=("Chat", "ID", "Doc", "Via"),
516
+ example={"chat_id": 777123, "msg_id": 12348, "doc_id": 5312836234},
517
+ example_args="gif send @alice 0",
518
+ covers=("gif.send-saved",),
519
+ covers_partial=("gif.search",),
520
+ coverage_note="`gif search` owns the listing; --search re-runs it because a query id expires.",
521
+ tags=frozenset({"visible-to-others"}),
522
+ )