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/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
|
+
)
|