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.
- tlgr/__init__.py +3 -0
- tlgr/__main__.py +6 -0
- tlgr/actions/__init__.py +45 -0
- tlgr/actions/forward.py +74 -0
- tlgr/actions/reply.py +32 -0
- tlgr/cli/__init__.py +259 -0
- tlgr/cli/confirm.py +55 -0
- tlgr/cli/errors.py +84 -0
- tlgr/cli/gen.py +690 -0
- tlgr/cli/globals.py +273 -0
- tlgr/cli/introspect.py +170 -0
- tlgr/cli/params.py +189 -0
- tlgr/cli/render.py +418 -0
- tlgr/core/__init__.py +0 -0
- tlgr/core/accounts.py +384 -0
- tlgr/core/config.py +358 -0
- tlgr/core/custom_tl.py +170 -0
- tlgr/core/errors.py +687 -0
- tlgr/core/eventtypes.py +1170 -0
- tlgr/core/identity.py +127 -0
- tlgr/core/launchd.py +122 -0
- tlgr/core/logging.py +194 -0
- tlgr/core/media.py +134 -0
- tlgr/core/output.py +251 -0
- tlgr/core/pagination.py +227 -0
- tlgr/core/paths.py +360 -0
- tlgr/core/peers.py +427 -0
- tlgr/core/process.py +138 -0
- tlgr/core/signing.py +38 -0
- tlgr/core/systemd.py +96 -0
- tlgr/core/telethon_compat.py +295 -0
- tlgr/core/text.py +211 -0
- tlgr/core/timefmt.py +199 -0
- tlgr/core/tl.py +98 -0
- tlgr/daemon/__init__.py +0 -0
- tlgr/daemon/app.py +869 -0
- tlgr/daemon/dispatch.py +446 -0
- tlgr/daemon/events.py +723 -0
- tlgr/daemon/files.py +431 -0
- tlgr/daemon/idle.py +119 -0
- tlgr/daemon/jobs.py +68 -0
- tlgr/daemon/main.py +161 -0
- tlgr/daemon/peercred.py +75 -0
- tlgr/daemon/policy.py +113 -0
- tlgr/daemon/preauth.py +366 -0
- tlgr/daemon/ratelimit.py +391 -0
- tlgr/daemon/server.py +24 -0
- tlgr/daemon/session.py +648 -0
- tlgr/daemon/sessions.py +274 -0
- tlgr/daemon/singleton.py +114 -0
- tlgr/daemon/stream.py +193 -0
- tlgr/daemon/transfers.py +219 -0
- tlgr/daemon/webhook.py +390 -0
- tlgr/data/catalog_index.json +1 -0
- tlgr/data/parity_waivers.toml +90 -0
- tlgr/filters/__init__.py +42 -0
- tlgr/filters/compose.py +121 -0
- tlgr/filters/content.py +85 -0
- tlgr/filters/context.py +114 -0
- tlgr/filters/message.py +161 -0
- tlgr/filters/temporal.py +87 -0
- tlgr/filters/user.py +36 -0
- tlgr/gateway/__init__.py +1 -0
- tlgr/gateway/config.py +161 -0
- tlgr/gateway/engine.py +215 -0
- tlgr/gateway/event.py +22 -0
- tlgr/jobs/__init__.py +0 -0
- tlgr/jobs/base.py +81 -0
- tlgr/jobs/client.py +37 -0
- tlgr/models/__init__.py +1220 -0
- tlgr/models/admin.py +744 -0
- tlgr/models/auth.py +510 -0
- tlgr/models/base.py +81 -0
- tlgr/models/bot.py +576 -0
- tlgr/models/business.py +265 -0
- tlgr/models/call.py +586 -0
- tlgr/models/config.py +101 -0
- tlgr/models/contact.py +481 -0
- tlgr/models/daemon.py +336 -0
- tlgr/models/dialog.py +626 -0
- tlgr/models/envelope.py +68 -0
- tlgr/models/error.py +30 -0
- tlgr/models/event.py +79 -0
- tlgr/models/export.py +66 -0
- tlgr/models/gift.py +275 -0
- tlgr/models/inline.py +84 -0
- tlgr/models/location.py +115 -0
- tlgr/models/media.py +507 -0
- tlgr/models/message.py +584 -0
- tlgr/models/net.py +232 -0
- tlgr/models/notify.py +105 -0
- tlgr/models/page.py +32 -0
- tlgr/models/payment.py +172 -0
- tlgr/models/peer.py +400 -0
- tlgr/models/poll.py +119 -0
- tlgr/models/premium.py +161 -0
- tlgr/models/privacy.py +93 -0
- tlgr/models/profile.py +217 -0
- tlgr/models/reaction.py +160 -0
- tlgr/models/resolve.py +175 -0
- tlgr/models/settings.py +103 -0
- tlgr/models/stars.py +101 -0
- tlgr/models/sticker.py +243 -0
- tlgr/models/story.py +467 -0
- tlgr/models/sync.py +105 -0
- tlgr/models/todo.py +36 -0
- tlgr/models/webapp.py +89 -0
- tlgr/ops/__init__.py +63 -0
- tlgr/ops/_admin.py +313 -0
- tlgr/ops/_auth.py +599 -0
- tlgr/ops/_bots.py +586 -0
- tlgr/ops/_calls.py +535 -0
- tlgr/ops/_common.py +160 -0
- tlgr/ops/_layer.py +46 -0
- tlgr/ops/_media.py +592 -0
- tlgr/ops/_params.py +212 -0
- tlgr/ops/_rights.py +402 -0
- tlgr/ops/_send.py +593 -0
- tlgr/ops/_serialize.py +667 -0
- tlgr/ops/_settings.py +306 -0
- tlgr/ops/_spec.py +167 -0
- tlgr/ops/_story.py +743 -0
- tlgr/ops/account.py +2604 -0
- tlgr/ops/agent.py +937 -0
- tlgr/ops/auth.py +1282 -0
- tlgr/ops/bot.py +4880 -0
- tlgr/ops/business.py +1520 -0
- tlgr/ops/call.py +1610 -0
- tlgr/ops/chat.py +4025 -0
- tlgr/ops/chat_admin.py +929 -0
- tlgr/ops/chat_extra.py +1061 -0
- tlgr/ops/chat_invite.py +716 -0
- tlgr/ops/chat_manage.py +1691 -0
- tlgr/ops/chat_member.py +1357 -0
- tlgr/ops/chat_stats.py +902 -0
- tlgr/ops/chat_topic.py +905 -0
- tlgr/ops/conference.py +791 -0
- tlgr/ops/config.py +1698 -0
- tlgr/ops/contact.py +2330 -0
- tlgr/ops/daemon.py +1397 -0
- tlgr/ops/draft.py +299 -0
- tlgr/ops/emoji.py +343 -0
- tlgr/ops/events.py +1327 -0
- tlgr/ops/export.py +596 -0
- tlgr/ops/folder.py +1322 -0
- tlgr/ops/gif.py +522 -0
- tlgr/ops/gift.py +1546 -0
- tlgr/ops/giveaway.py +541 -0
- tlgr/ops/inline.py +773 -0
- tlgr/ops/job.py +799 -0
- tlgr/ops/location.py +917 -0
- tlgr/ops/media.py +4495 -0
- tlgr/ops/message.py +3769 -0
- tlgr/ops/net.py +536 -0
- tlgr/ops/notify.py +840 -0
- tlgr/ops/passport.py +464 -0
- tlgr/ops/payment.py +907 -0
- tlgr/ops/poll.py +1078 -0
- tlgr/ops/premium.py +488 -0
- tlgr/ops/privacy.py +794 -0
- tlgr/ops/profile.py +1481 -0
- tlgr/ops/proxy.py +750 -0
- tlgr/ops/reaction.py +1475 -0
- tlgr/ops/resolve.py +1140 -0
- tlgr/ops/search.py +521 -0
- tlgr/ops/settings.py +1066 -0
- tlgr/ops/stars.py +594 -0
- tlgr/ops/sticker.py +1602 -0
- tlgr/ops/story.py +3216 -0
- tlgr/ops/sync.py +788 -0
- tlgr/ops/todo.py +514 -0
- tlgr/ops/user.py +1406 -0
- tlgr/ops/vc.py +2351 -0
- tlgr/ops/webapp.py +717 -0
- tlgr/ops/webhook.py +418 -0
- tlgr/parity.py +386 -0
- tlgr/processors/__init__.py +125 -0
- tlgr/processors/regex.py +26 -0
- tlgr/processors/text.py +56 -0
- tlgr/registry.py +519 -0
- tlgr/schema.py +173 -0
- tlgr/transport/__init__.py +30 -0
- tlgr/transport/autostart.py +293 -0
- tlgr/transport/client.py +805 -0
- tlgr/transport/ndjson.py +44 -0
- tlgr/version.py +31 -0
- tlgr_cli-2.0.1.dist-info/METADATA +957 -0
- tlgr_cli-2.0.1.dist-info/RECORD +192 -0
- tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
- tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
- tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|