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/bot.py
ADDED
|
@@ -0,0 +1,4880 @@
|
|
|
1
|
+
"""The `bot` group: talking to bots, and running the ones you own.
|
|
2
|
+
|
|
3
|
+
Two audiences share one noun, and the split matters because it decides which
|
|
4
|
+
account can run a command at all.
|
|
5
|
+
|
|
6
|
+
* **As a user.** `bot get`, `bot start`, `bot command send`, `bot press`,
|
|
7
|
+
`bot permission set`, `bot url-auth get` — everything a person does to a bot
|
|
8
|
+
from a Telegram client. These run on an ordinary account.
|
|
9
|
+
* **As the bot.** `bot answer`, `bot command set`, `bot menu set`,
|
|
10
|
+
`bot api send` — the Bot-API surface, which Telegram serves only to a
|
|
11
|
+
session created from a bot token. On a user session they exit 4 with a
|
|
12
|
+
sentence saying how to add one, rather than surfacing Telegram's own
|
|
13
|
+
`BOT_METHOD_INVALID`.
|
|
14
|
+
|
|
15
|
+
`bot press` is the centre of the group. Telegram has fourteen kinds of button
|
|
16
|
+
and one of them (`buy`) starts a payment, four of them disclose personal data,
|
|
17
|
+
and two need a layer this build does not speak. One dispatcher handles them
|
|
18
|
+
all: it names what it is about to send, refuses the ones that would leak
|
|
19
|
+
without their consent flag, refuses `buy` outright, and returns a typed answer
|
|
20
|
+
saying which kind actually came back.
|
|
21
|
+
|
|
22
|
+
Telethon is imported inside functions, never at module scope (§2.2).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import Annotated, Any
|
|
28
|
+
|
|
29
|
+
from tlgr.core.errors import (
|
|
30
|
+
NotFoundError,
|
|
31
|
+
PermissionError_,
|
|
32
|
+
UsageError,
|
|
33
|
+
)
|
|
34
|
+
from tlgr.core.pagination import PageKind, build_page
|
|
35
|
+
from tlgr.core.timefmt import fmt_dt, to_unix
|
|
36
|
+
from tlgr.models.base import Request
|
|
37
|
+
from tlgr.models.bot import (
|
|
38
|
+
AttachMenuBot,
|
|
39
|
+
BotAccess,
|
|
40
|
+
BotAnswer,
|
|
41
|
+
BotApiResult,
|
|
42
|
+
BotCommand,
|
|
43
|
+
BotCommandSet,
|
|
44
|
+
BotCreated,
|
|
45
|
+
BotEdited,
|
|
46
|
+
BotIds,
|
|
47
|
+
BotInfo,
|
|
48
|
+
BotPermission,
|
|
49
|
+
BotQuery,
|
|
50
|
+
BotRef,
|
|
51
|
+
BotStarted,
|
|
52
|
+
BotStopped,
|
|
53
|
+
BotToken,
|
|
54
|
+
BotUsernameCheck,
|
|
55
|
+
BotUsernames,
|
|
56
|
+
BotVerification,
|
|
57
|
+
BotVerified,
|
|
58
|
+
BotWelcomeMessage,
|
|
59
|
+
BusinessConnection,
|
|
60
|
+
CommandSent,
|
|
61
|
+
DefaultRights,
|
|
62
|
+
EmojiGame,
|
|
63
|
+
EphemeralDeleted,
|
|
64
|
+
EphemeralSent,
|
|
65
|
+
GameSent,
|
|
66
|
+
HighScore,
|
|
67
|
+
MenuButton,
|
|
68
|
+
Pressed,
|
|
69
|
+
PreviewChange,
|
|
70
|
+
PreviewMedia,
|
|
71
|
+
RecentBots,
|
|
72
|
+
ReportOutcome,
|
|
73
|
+
ScoreSet,
|
|
74
|
+
SponsoredRead,
|
|
75
|
+
StarRefProgram,
|
|
76
|
+
StreamProgress,
|
|
77
|
+
ToggledAttachMenu,
|
|
78
|
+
UrlAuth,
|
|
79
|
+
WelcomeDeleted,
|
|
80
|
+
WelcomeSet,
|
|
81
|
+
)
|
|
82
|
+
from tlgr.models.message import SponsoredMessage
|
|
83
|
+
from tlgr.models.page import Page
|
|
84
|
+
from tlgr.models.peer import PeerRef
|
|
85
|
+
from tlgr.ops import _bots, _media, _send
|
|
86
|
+
from tlgr.ops._common import client, window
|
|
87
|
+
from tlgr.ops._params import arg, choice, opt
|
|
88
|
+
from tlgr.ops._spec import OpContext, OperationSpec
|
|
89
|
+
|
|
90
|
+
__all__ = [name for name in dir() if name.startswith("SPEC_")]
|
|
91
|
+
|
|
92
|
+
#: Reported as the client platform on every mini-app request. Telegram uses
|
|
93
|
+
#: it to pick the app's own layout; there is no "cli" value it understands.
|
|
94
|
+
PLATFORM = "web"
|
|
95
|
+
|
|
96
|
+
_EXAMPLE_BOT: dict[str, Any] = {
|
|
97
|
+
"id": 93372553,
|
|
98
|
+
"username": "gif",
|
|
99
|
+
"first_name": "GIF",
|
|
100
|
+
"about": "Send GIFs inline",
|
|
101
|
+
"bot_can_edit": False,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def _full(ctx: OpContext, peer: Any) -> tuple[Any, Any]:
|
|
106
|
+
"""`(userFull, user)` for a bot, in one round trip."""
|
|
107
|
+
from telethon import utils
|
|
108
|
+
from telethon.tl.functions import users as fn
|
|
109
|
+
|
|
110
|
+
result = await client(ctx)(fn.GetFullUserRequest(id=utils.get_input_user(peer)))
|
|
111
|
+
full = getattr(result, "full_user", None)
|
|
112
|
+
users = {int(getattr(u, "id", 0)): u for u in (getattr(result, "users", None) or [])}
|
|
113
|
+
user = users.get(int(getattr(full, "id", 0) or 0)) if full is not None else None
|
|
114
|
+
return full, user
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _usernames(user: Any) -> list[str]:
|
|
118
|
+
names = [
|
|
119
|
+
str(getattr(entry, "username", "") or "")
|
|
120
|
+
for entry in (getattr(user, "usernames", None) or [])
|
|
121
|
+
if getattr(entry, "username", None)
|
|
122
|
+
]
|
|
123
|
+
primary = getattr(user, "username", None)
|
|
124
|
+
if primary and primary not in names:
|
|
125
|
+
names.insert(0, str(primary))
|
|
126
|
+
return names
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _menu_button(button: Any, *, user_id: int | None = None) -> MenuButton | None:
|
|
130
|
+
"""`botMenuButton*` as the model.
|
|
131
|
+
|
|
132
|
+
`botMenuButtonDefault` never reaches a user — the server substitutes the
|
|
133
|
+
commands list — so it is normalised to `commands` rather than leaking a
|
|
134
|
+
third state nobody can act on.
|
|
135
|
+
"""
|
|
136
|
+
if button is None:
|
|
137
|
+
return None
|
|
138
|
+
name = type(button).__name__
|
|
139
|
+
if name == "BotMenuButton":
|
|
140
|
+
return MenuButton(
|
|
141
|
+
kind="webapp",
|
|
142
|
+
text=getattr(button, "text", None),
|
|
143
|
+
url=getattr(button, "url", None),
|
|
144
|
+
user_id=user_id,
|
|
145
|
+
)
|
|
146
|
+
return MenuButton(kind="commands", user_id=user_id)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _commands(
|
|
150
|
+
raw: Any, *, bot_id: int, scope: str | None = None, lang: str | None = None
|
|
151
|
+
) -> list[BotCommand]:
|
|
152
|
+
entries = list(
|
|
153
|
+
(getattr(raw, "commands", None) or []) if hasattr(raw, "commands") else (raw or [])
|
|
154
|
+
)
|
|
155
|
+
names = {str(getattr(c, "command", "") or "") for c in entries}
|
|
156
|
+
return [
|
|
157
|
+
BotCommand(
|
|
158
|
+
bot_id=bot_id,
|
|
159
|
+
command=str(getattr(entry, "command", "") or ""),
|
|
160
|
+
description=str(getattr(entry, "description", "") or ""),
|
|
161
|
+
ephemeral=bool(getattr(entry, "ephemeral", False)),
|
|
162
|
+
scope=scope,
|
|
163
|
+
lang=lang,
|
|
164
|
+
has_help="help" in names,
|
|
165
|
+
has_settings="settings" in names,
|
|
166
|
+
)
|
|
167
|
+
for entry in entries
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _starref(program: Any) -> StarRefProgram | None:
|
|
172
|
+
if program is None:
|
|
173
|
+
return None
|
|
174
|
+
end = getattr(program, "end_date", None)
|
|
175
|
+
revenue = getattr(program, "daily_revenue_per_user", None)
|
|
176
|
+
return StarRefProgram(
|
|
177
|
+
bot_id=int(getattr(program, "bot_id", 0) or 0),
|
|
178
|
+
url=getattr(program, "url", None),
|
|
179
|
+
commission_permille=int(getattr(program, "commission_permille", 0) or 0),
|
|
180
|
+
duration_months=getattr(program, "duration_months", None),
|
|
181
|
+
end_date=fmt_dt(end),
|
|
182
|
+
end_date_unix=to_unix(end),
|
|
183
|
+
participants=getattr(program, "participants", None),
|
|
184
|
+
revenue=int(getattr(revenue, "amount", 0) or 0) if revenue is not None else None,
|
|
185
|
+
revoked=bool(getattr(program, "revoked", False)),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _verification(full: Any, user: Any) -> BotVerification | None:
|
|
190
|
+
badge = getattr(full, "bot_verification", None)
|
|
191
|
+
verified = bool(getattr(user, "verified", False))
|
|
192
|
+
if badge is None and not verified:
|
|
193
|
+
return None
|
|
194
|
+
return BotVerification(
|
|
195
|
+
verified_by_bot=int(getattr(badge, "bot_id", 0) or 0) or None,
|
|
196
|
+
description=getattr(badge, "description", None),
|
|
197
|
+
icon=getattr(badge, "icon", None),
|
|
198
|
+
telegram_verified=verified,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _access(settings: Any) -> BotAccess:
|
|
203
|
+
"""`bots.accessSettings` as the model. The allow-list is `add_users`."""
|
|
204
|
+
return BotAccess(
|
|
205
|
+
restricted=bool(getattr(settings, "restricted", False)),
|
|
206
|
+
allowed_users=[
|
|
207
|
+
int(getattr(u, "id", 0) or getattr(u, "user_id", 0) or 0)
|
|
208
|
+
for u in (getattr(settings, "add_users", None) or [])
|
|
209
|
+
],
|
|
210
|
+
allowed_chats=[
|
|
211
|
+
int(getattr(c, "id", 0) or 0) for c in (getattr(settings, "add_chats", None) or [])
|
|
212
|
+
],
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
#: `businessBotRights` is its own flag set, not `chatAdminRights`; the fields
|
|
217
|
+
#: are listed rather than scanned so a new one cannot appear as a right the
|
|
218
|
+
#: bot silently already has.
|
|
219
|
+
BUSINESS_RIGHTS: tuple[str, ...] = (
|
|
220
|
+
"reply",
|
|
221
|
+
"read_messages",
|
|
222
|
+
"delete_sent_messages",
|
|
223
|
+
"delete_received_messages",
|
|
224
|
+
"edit_name",
|
|
225
|
+
"edit_bio",
|
|
226
|
+
"edit_profile_photo",
|
|
227
|
+
"edit_username",
|
|
228
|
+
"view_gifts",
|
|
229
|
+
"sell_gifts",
|
|
230
|
+
"change_gift_settings",
|
|
231
|
+
"transfer_and_upgrade_gifts",
|
|
232
|
+
"transfer_stars",
|
|
233
|
+
"manage_stories",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _business_rights(rights: Any) -> list[str]:
|
|
238
|
+
if rights is None:
|
|
239
|
+
return []
|
|
240
|
+
return [name for name in BUSINESS_RIGHTS if bool(getattr(rights, name, False))]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
# bot get
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class GetReq(Request):
|
|
249
|
+
bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="@username, id or t.me link.")]
|
|
250
|
+
lang: Annotated[
|
|
251
|
+
str | None, opt("--lang", metavar="CODE", help="Localized description (owner view).")
|
|
252
|
+
] = None
|
|
253
|
+
access: Annotated[bool, opt("--access", help="Also fetch managed-bot access settings.")] = False
|
|
254
|
+
refresh: Annotated[
|
|
255
|
+
bool, opt("--refresh", help="Re-resolve the username instead of trusting the cache.")
|
|
256
|
+
] = False
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
async def get(ctx: OpContext, req: GetReq) -> BotInfo:
|
|
260
|
+
"""A bot's whole profile card.
|
|
261
|
+
|
|
262
|
+
`bot_info_version` is the only invalidation signal Telegram gives for the
|
|
263
|
+
commands and the description, so it is reported: a caller that caches this
|
|
264
|
+
card has no other way to know when to refetch it.
|
|
265
|
+
"""
|
|
266
|
+
from telethon.tl.functions import bots as bots_fn
|
|
267
|
+
|
|
268
|
+
peer = await _resolve_bot(ctx, req.bot, refresh=req.refresh)
|
|
269
|
+
full, user = await _full(ctx, peer)
|
|
270
|
+
info = getattr(full, "bot_info", None)
|
|
271
|
+
bot_id = int(getattr(full, "id", 0) or 0)
|
|
272
|
+
|
|
273
|
+
about = getattr(full, "about", None)
|
|
274
|
+
description = getattr(info, "description", None)
|
|
275
|
+
if req.lang:
|
|
276
|
+
localized = await client(ctx)(
|
|
277
|
+
bots_fn.GetBotInfoRequest(lang_code=req.lang, bot=await _bots.input_user(ctx, req.bot))
|
|
278
|
+
)
|
|
279
|
+
about = getattr(localized, "about", about)
|
|
280
|
+
description = getattr(localized, "description", description)
|
|
281
|
+
|
|
282
|
+
settings = getattr(info, "app_settings", None)
|
|
283
|
+
verifier = getattr(info, "verifier_settings", None)
|
|
284
|
+
card = BotInfo(
|
|
285
|
+
id=bot_id,
|
|
286
|
+
username=getattr(user, "username", None),
|
|
287
|
+
usernames=_usernames(user),
|
|
288
|
+
first_name=getattr(user, "first_name", None),
|
|
289
|
+
about=about,
|
|
290
|
+
description=description,
|
|
291
|
+
description_photo=_id_of(getattr(info, "description_photo", None)),
|
|
292
|
+
description_document=_id_of(getattr(info, "description_document", None)),
|
|
293
|
+
privacy_policy_url=getattr(info, "privacy_policy_url", None),
|
|
294
|
+
commands=_commands(info, bot_id=bot_id, lang=req.lang),
|
|
295
|
+
menu_button=_menu_button(getattr(info, "menu_button", None)),
|
|
296
|
+
app_settings=_app_settings(settings),
|
|
297
|
+
verifier_settings=(
|
|
298
|
+
{
|
|
299
|
+
"icon": getattr(verifier, "icon", None),
|
|
300
|
+
"company": getattr(verifier, "company", None),
|
|
301
|
+
"can_modify_custom_description": bool(
|
|
302
|
+
getattr(verifier, "can_modify_custom_description", False)
|
|
303
|
+
),
|
|
304
|
+
"custom_description": getattr(verifier, "custom_description", None),
|
|
305
|
+
}
|
|
306
|
+
if verifier is not None
|
|
307
|
+
else None
|
|
308
|
+
),
|
|
309
|
+
bot_verification=_verification(full, user),
|
|
310
|
+
bot_info_version=getattr(user, "bot_info_version", None),
|
|
311
|
+
bot_active_users=getattr(user, "bot_active_users", None),
|
|
312
|
+
bot_can_edit=bool(getattr(user, "bot_can_edit", False)),
|
|
313
|
+
bot_has_main_app=bool(getattr(user, "bot_has_main_app", False)),
|
|
314
|
+
bot_nochats=bool(getattr(user, "bot_nochats", False)),
|
|
315
|
+
bot_business=bool(getattr(user, "bot_business", False)),
|
|
316
|
+
bot_attach_menu=bool(getattr(user, "bot_attach_menu", False)),
|
|
317
|
+
bot_inline_geo=bool(getattr(user, "bot_inline_geo", False)),
|
|
318
|
+
inline_placeholder=getattr(user, "bot_inline_placeholder", None),
|
|
319
|
+
bot_group_admin_rights=_bots.rights_keywords(getattr(full, "bot_group_admin_rights", None)),
|
|
320
|
+
bot_broadcast_admin_rights=_bots.rights_keywords(
|
|
321
|
+
getattr(full, "bot_broadcast_admin_rights", None)
|
|
322
|
+
),
|
|
323
|
+
has_preview_medias=bool(getattr(info, "has_preview_medias", False)),
|
|
324
|
+
starref_program=_starref(getattr(full, "starref_program", None)),
|
|
325
|
+
blocked=bool(getattr(full, "blocked", False)),
|
|
326
|
+
lang=req.lang,
|
|
327
|
+
)
|
|
328
|
+
if req.access:
|
|
329
|
+
if not card.bot_can_edit:
|
|
330
|
+
raise PermissionError_("access settings are only readable on a bot you administer")
|
|
331
|
+
settings = await client(ctx)(
|
|
332
|
+
bots_fn.GetAccessSettingsRequest(bot=await _bots.input_user(ctx, req.bot))
|
|
333
|
+
)
|
|
334
|
+
card.access = _access(settings)
|
|
335
|
+
return card
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _id_of(value: Any) -> int | None:
|
|
339
|
+
identifier = getattr(value, "id", None)
|
|
340
|
+
return int(identifier) if isinstance(identifier, int) else None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _app_settings(settings: Any) -> dict[str, Any] | None:
|
|
344
|
+
if settings is None:
|
|
345
|
+
return None
|
|
346
|
+
path = getattr(settings, "placeholder_path", None)
|
|
347
|
+
return {
|
|
348
|
+
"placeholder_path": len(path) if path else None,
|
|
349
|
+
"bg_color": getattr(settings, "background_color", None),
|
|
350
|
+
"bg_dark_color": getattr(settings, "background_dark_color", None),
|
|
351
|
+
"header_color": getattr(settings, "header_color", None),
|
|
352
|
+
"header_dark_color": getattr(settings, "header_dark_color", None),
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
async def _resolve_bot(ctx: OpContext, ref: PeerRef, *, refresh: bool = False) -> Any:
|
|
357
|
+
"""The bot's `InputPeer`, optionally re-resolving the username first.
|
|
358
|
+
|
|
359
|
+
`contacts.resolveUsername` is what mints the access hash, and a cached one
|
|
360
|
+
can be stale after a bot changes hands; `--refresh` is the escape hatch
|
|
361
|
+
that does not require deleting the peer cache by hand.
|
|
362
|
+
"""
|
|
363
|
+
if refresh and ref.kind == "username":
|
|
364
|
+
from telethon.tl.functions import contacts as fn
|
|
365
|
+
|
|
366
|
+
await client(ctx)(fn.ResolveUsernameRequest(username=str(ref.value)))
|
|
367
|
+
return await _send.resolve(ctx, ref)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
SPEC_GET = OperationSpec(
|
|
371
|
+
id="bot.get",
|
|
372
|
+
request=GetReq,
|
|
373
|
+
response=BotInfo,
|
|
374
|
+
impl=get,
|
|
375
|
+
summary="Show a bot's profile card",
|
|
376
|
+
description=(
|
|
377
|
+
"Description, about text, commands, menu button, privacy policy, "
|
|
378
|
+
"capability flags, verification badge and mini-app settings, from the "
|
|
379
|
+
"one `users.getFullUser` that carries all of them."
|
|
380
|
+
),
|
|
381
|
+
aliases=("bot.info",),
|
|
382
|
+
columns=("id", "username", "first_name", "bot_active_users"),
|
|
383
|
+
headers=("ID", "Username", "Name", "Users"),
|
|
384
|
+
example=_EXAMPLE_BOT,
|
|
385
|
+
example_args="bot get @gifbot",
|
|
386
|
+
covers=(
|
|
387
|
+
"bots.bot-info-card",
|
|
388
|
+
"bots.bot-privacy-policy",
|
|
389
|
+
"bots.bot-profile-flags",
|
|
390
|
+
"bots.resolve-bot",
|
|
391
|
+
),
|
|
392
|
+
covers_partial=(
|
|
393
|
+
"bots.bot-verification-view",
|
|
394
|
+
"bots.menu-button-state",
|
|
395
|
+
"bots.suggested-admin-rights",
|
|
396
|
+
"bots.webapp-placeholder-and-close",
|
|
397
|
+
),
|
|
398
|
+
coverage_note=(
|
|
399
|
+
"The card shows the menu button, the suggested admin rights, the "
|
|
400
|
+
"verification badge and the mini-app placeholder; setting them is "
|
|
401
|
+
"`bot menu set`, `bot default-rights set`, `bot verification set` and "
|
|
402
|
+
"`webapp get`."
|
|
403
|
+
),
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# ---------------------------------------------------------------------------
|
|
408
|
+
# bot list
|
|
409
|
+
# ---------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class ListReq(Request):
|
|
413
|
+
owned: Annotated[bool, opt("--owned", help="Bots I own or administer (default).")] = True
|
|
414
|
+
similar_to: Annotated[
|
|
415
|
+
PeerRef | None,
|
|
416
|
+
opt("--similar-to", metavar="BOT", kind="user", help="Bots recommended next to this bot."),
|
|
417
|
+
] = None
|
|
418
|
+
popular_apps: Annotated[bool, opt("--popular-apps", help="The Mini App store list.")] = False
|
|
419
|
+
recent: Annotated[bool, opt("--recent", help="Frequently-used bots (top peers).")] = False
|
|
420
|
+
kind: Annotated[
|
|
421
|
+
str, choice("pm", "inline", "app", "guest", help="Top-peer category for --recent.")
|
|
422
|
+
] = "pm"
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
_TOP_PEER_FLAGS = {
|
|
426
|
+
"pm": "bots_pm",
|
|
427
|
+
"inline": "bots_inline",
|
|
428
|
+
"app": "bots_app",
|
|
429
|
+
"guest": "bots_guestchat",
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
async def list_bots(ctx: OpContext, req: ListReq) -> Page[BotRef]:
|
|
434
|
+
"""Bots, from whichever of the four listings the flags name.
|
|
435
|
+
|
|
436
|
+
They share a command because they answer one question — "which bots?" —
|
|
437
|
+
and differ only in where the answer comes from.
|
|
438
|
+
"""
|
|
439
|
+
from telethon.tl.functions import bots as fn
|
|
440
|
+
from telethon.tl.functions import contacts as contacts_fn
|
|
441
|
+
|
|
442
|
+
limit, state = window(ctx, "bot.list", PageKind.RATE, default=50)
|
|
443
|
+
handle = client(ctx)
|
|
444
|
+
|
|
445
|
+
if req.similar_to is not None:
|
|
446
|
+
result = await handle(
|
|
447
|
+
fn.GetBotRecommendationsRequest(bot=await _bots.input_user(ctx, req.similar_to))
|
|
448
|
+
)
|
|
449
|
+
truncated = getattr(result, "count", None)
|
|
450
|
+
items = [
|
|
451
|
+
_bot_ref(user, kind="similar", truncated=truncated)
|
|
452
|
+
for user in (getattr(result, "users", None) or [])
|
|
453
|
+
]
|
|
454
|
+
return build_page(items[:limit], op="bot.list", kind=PageKind.RATE, has_more=False)
|
|
455
|
+
|
|
456
|
+
if req.popular_apps:
|
|
457
|
+
offset = str(state.get("offset", "") or "")
|
|
458
|
+
result = await handle(fn.GetPopularAppBotsRequest(offset=offset, limit=limit))
|
|
459
|
+
items = [_bot_ref(user, kind="app") for user in (getattr(result, "users", None) or [])]
|
|
460
|
+
next_offset = str(getattr(result, "next_offset", "") or "")
|
|
461
|
+
return build_page(
|
|
462
|
+
items,
|
|
463
|
+
op="bot.list",
|
|
464
|
+
kind=PageKind.RATE,
|
|
465
|
+
state={"offset": next_offset},
|
|
466
|
+
account=ctx.account,
|
|
467
|
+
has_more=bool(next_offset),
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
if req.recent:
|
|
471
|
+
flag = _TOP_PEER_FLAGS[req.kind]
|
|
472
|
+
result = await handle(
|
|
473
|
+
contacts_fn.GetTopPeersRequest(offset=0, limit=limit, hash=0, **{flag: True})
|
|
474
|
+
)
|
|
475
|
+
if type(result).__name__ == "TopPeersDisabled":
|
|
476
|
+
ctx.warn("frequently-used suggestions are switched off for this account")
|
|
477
|
+
return Page(items=[], has_more=False, total=0)
|
|
478
|
+
users = {int(getattr(u, "id", 0)): u for u in (getattr(result, "users", None) or [])}
|
|
479
|
+
items = []
|
|
480
|
+
for category in getattr(result, "categories", None) or []:
|
|
481
|
+
for entry in getattr(category, "peers", None) or []:
|
|
482
|
+
user = users.get(int(getattr(getattr(entry, "peer", None), "user_id", 0) or 0))
|
|
483
|
+
if user is not None:
|
|
484
|
+
items.append(
|
|
485
|
+
_bot_ref(user, kind=req.kind, rating=getattr(entry, "rating", None))
|
|
486
|
+
)
|
|
487
|
+
return build_page(items[:limit], op="bot.list", kind=PageKind.RATE, has_more=False)
|
|
488
|
+
|
|
489
|
+
result = await handle(fn.GetAdminedBotsRequest())
|
|
490
|
+
items = [_bot_ref(user, kind="owned") for user in (result or [])]
|
|
491
|
+
return build_page(items[:limit], op="bot.list", kind=PageKind.RATE, has_more=False)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _bot_ref(
|
|
495
|
+
user: Any, *, kind: str, truncated: int | None = None, rating: float | None = None
|
|
496
|
+
) -> BotRef:
|
|
497
|
+
first = str(getattr(user, "first_name", "") or "")
|
|
498
|
+
last = str(getattr(user, "last_name", "") or "")
|
|
499
|
+
return BotRef(
|
|
500
|
+
id=int(getattr(user, "id", 0) or 0),
|
|
501
|
+
username=getattr(user, "username", None),
|
|
502
|
+
title=f"{first} {last}".strip() or None,
|
|
503
|
+
kind=kind,
|
|
504
|
+
active_users=getattr(user, "bot_active_users", None),
|
|
505
|
+
truncated_count=truncated,
|
|
506
|
+
rating=float(rating) if rating is not None else None,
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
SPEC_LIST = OperationSpec(
|
|
511
|
+
id="bot.list",
|
|
512
|
+
request=ListReq,
|
|
513
|
+
response=Page[BotRef],
|
|
514
|
+
impl=list_bots,
|
|
515
|
+
summary="List bots I own, similar bots, popular mini apps or my recent bots",
|
|
516
|
+
description=(
|
|
517
|
+
"A non-Premium account gets a shortened `--similar-to` list plus the "
|
|
518
|
+
"real count, which is reported as `truncated_count` rather than "
|
|
519
|
+
"silently looking like the whole answer."
|
|
520
|
+
),
|
|
521
|
+
aliases=("bot.mine",),
|
|
522
|
+
paginated=PageKind.RATE,
|
|
523
|
+
columns=("id", "username", "title", "kind"),
|
|
524
|
+
headers=("ID", "Username", "Title", "Kind"),
|
|
525
|
+
example={"items": [{"id": 93372553, "username": "gif", "kind": "owned"}], "has_more": False},
|
|
526
|
+
example_args="bot list --owned",
|
|
527
|
+
covers=(
|
|
528
|
+
"bots.guest-mode-invoke",
|
|
529
|
+
"bots.list-owned-bots",
|
|
530
|
+
"bots.popular-app-bots",
|
|
531
|
+
"bots.similar-bots",
|
|
532
|
+
),
|
|
533
|
+
covers_partial=("bots.top-peers-bots",),
|
|
534
|
+
coverage_note="Turning the frequently-used list on or off is `bot recent set`.",
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
# ---------------------------------------------------------------------------
|
|
539
|
+
# bot id get
|
|
540
|
+
# ---------------------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
class IdReq(Request):
|
|
544
|
+
chat: Annotated[
|
|
545
|
+
PeerRef, arg(0, metavar="CHAT", kind="peer", help="@username, MTProto id or Bot-API id.")
|
|
546
|
+
]
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
async def id_get(ctx: OpContext, req: IdReq) -> BotIds:
|
|
550
|
+
"""Convert between MTProto peer ids and HTTP Bot-API chat ids.
|
|
551
|
+
|
|
552
|
+
Pure arithmetic when the ref is already an id: a Bot-API id marks channels
|
|
553
|
+
with `-100…` and basic groups with a plain negative number, and getting
|
|
554
|
+
that conversion wrong is how a script posts into the wrong chat.
|
|
555
|
+
"""
|
|
556
|
+
from telethon import utils
|
|
557
|
+
|
|
558
|
+
if req.chat.kind == "id":
|
|
559
|
+
marked = int(req.chat.value)
|
|
560
|
+
_raw, kind = utils.resolve_id(marked)
|
|
561
|
+
return BotIds(
|
|
562
|
+
mtproto_id=marked,
|
|
563
|
+
bot_api_id=marked,
|
|
564
|
+
kind=_KIND_NAMES.get(kind.__name__, "user"),
|
|
565
|
+
has_access_hash=False,
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
569
|
+
marked = int(utils.get_peer_id(peer))
|
|
570
|
+
_raw, kind = utils.resolve_id(marked)
|
|
571
|
+
return BotIds(
|
|
572
|
+
mtproto_id=marked,
|
|
573
|
+
bot_api_id=marked,
|
|
574
|
+
kind=_KIND_NAMES.get(kind.__name__, "user"),
|
|
575
|
+
has_access_hash=getattr(peer, "access_hash", None) is not None,
|
|
576
|
+
username=str(req.chat.value) if req.chat.kind == "username" else None,
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
_KIND_NAMES = {"PeerUser": "user", "PeerChat": "group", "PeerChannel": "channel"}
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
SPEC_ID_GET = OperationSpec(
|
|
584
|
+
id="bot.id.get",
|
|
585
|
+
request=IdReq,
|
|
586
|
+
response=BotIds,
|
|
587
|
+
impl=id_get,
|
|
588
|
+
summary="Convert between MTProto peer ids and Bot-API chat ids",
|
|
589
|
+
description=(
|
|
590
|
+
"tlgr prints marked ids everywhere (COR-10), which is the same "
|
|
591
|
+
"dialect the HTTP Bot API uses; this command says so out loud and "
|
|
592
|
+
"reports whether an access hash is cached for the peer."
|
|
593
|
+
),
|
|
594
|
+
columns=("mtproto_id", "bot_api_id", "kind"),
|
|
595
|
+
headers=("MTProto", "Bot API", "Kind"),
|
|
596
|
+
example={"mtproto_id": -1001234567890, "bot_api_id": -1001234567890, "kind": "channel"},
|
|
597
|
+
example_args="bot id get @durov",
|
|
598
|
+
covers=("bots.bot-api-dialog-ids",),
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
# ---------------------------------------------------------------------------
|
|
603
|
+
# bot start / stop
|
|
604
|
+
# ---------------------------------------------------------------------------
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
class StartReq(Request):
|
|
608
|
+
bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot to start.")]
|
|
609
|
+
param: Annotated[str | None, opt("--param", metavar="TEXT", help="Hidden start parameter.")] = (
|
|
610
|
+
None
|
|
611
|
+
)
|
|
612
|
+
referrer: Annotated[
|
|
613
|
+
str | None, opt("--referrer", metavar="TEXT", help="Referral/affiliate start parameter.")
|
|
614
|
+
] = None
|
|
615
|
+
chat: Annotated[
|
|
616
|
+
PeerRef | None,
|
|
617
|
+
opt("--chat", metavar="CHAT", kind="peer", help="Start the bot inside this group."),
|
|
618
|
+
] = None
|
|
619
|
+
channel: Annotated[
|
|
620
|
+
PeerRef | None,
|
|
621
|
+
opt("--channel", metavar="CHAT", kind="peer", help="Add the bot to this channel."),
|
|
622
|
+
] = None
|
|
623
|
+
admin: Annotated[
|
|
624
|
+
str | None,
|
|
625
|
+
opt("--admin", metavar="RIGHTS", help="'+'-joined admin rights to grant."),
|
|
626
|
+
] = None
|
|
627
|
+
add: Annotated[bool, opt("--add", help="Add the bot to the chat if it is not a member.")] = (
|
|
628
|
+
False
|
|
629
|
+
)
|
|
630
|
+
restart: Annotated[bool, opt("--restart", help="Unblock the bot before starting it.")] = False
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
async def start(ctx: OpContext, req: StartReq) -> BotStarted:
|
|
634
|
+
"""Send `/start`, optionally with a hidden parameter and inside a chat.
|
|
635
|
+
|
|
636
|
+
`messages.startBot` is the only way to send a start parameter the user
|
|
637
|
+
never sees, which is what a deep link is; typing `/start payload` puts the
|
|
638
|
+
payload in the history for everyone in the chat to read.
|
|
639
|
+
"""
|
|
640
|
+
from telethon.tl.functions import channels as channels_fn
|
|
641
|
+
from telethon.tl.functions import contacts as contacts_fn
|
|
642
|
+
from telethon.tl.functions import messages as fn
|
|
643
|
+
|
|
644
|
+
handle = client(ctx)
|
|
645
|
+
if req.referrer and req.bot.kind == "username":
|
|
646
|
+
await handle(
|
|
647
|
+
contacts_fn.ResolveUsernameRequest(username=str(req.bot.value), referer=req.referrer)
|
|
648
|
+
)
|
|
649
|
+
peer = await _send.resolve(ctx, req.bot)
|
|
650
|
+
bot = await _bots.input_user(ctx, req.bot)
|
|
651
|
+
full, user = await _full(ctx, peer)
|
|
652
|
+
bot_id = int(getattr(full, "id", 0) or 0)
|
|
653
|
+
|
|
654
|
+
unblocked = False
|
|
655
|
+
if req.restart and getattr(full, "blocked", False):
|
|
656
|
+
await handle(contacts_fn.UnblockRequest(id=peer))
|
|
657
|
+
unblocked = True
|
|
658
|
+
|
|
659
|
+
target: Any = peer
|
|
660
|
+
rights: list[str] = []
|
|
661
|
+
if req.chat is not None or req.channel is not None:
|
|
662
|
+
if bool(getattr(user, "bot_nochats", False)):
|
|
663
|
+
raise PermissionError_("this bot refuses to be added to groups (BOT_GROUPS_BLOCKED)")
|
|
664
|
+
where = req.chat if req.chat is not None else req.channel
|
|
665
|
+
target = await _send.resolve(ctx, where)
|
|
666
|
+
if req.add:
|
|
667
|
+
await _add_to_chat(ctx, target, bot)
|
|
668
|
+
granted = _bots.admin_rights(req.admin) or (
|
|
669
|
+
getattr(full, "bot_group_admin_rights", None)
|
|
670
|
+
if req.chat is not None
|
|
671
|
+
else getattr(full, "bot_broadcast_admin_rights", None)
|
|
672
|
+
)
|
|
673
|
+
if granted is not None and (req.admin or req.channel is not None):
|
|
674
|
+
await handle(
|
|
675
|
+
channels_fn.EditAdminRequest(
|
|
676
|
+
channel=_input_channel(target),
|
|
677
|
+
user_id=bot,
|
|
678
|
+
admin_rights=granted,
|
|
679
|
+
rank="",
|
|
680
|
+
)
|
|
681
|
+
)
|
|
682
|
+
rights = _bots.rights_keywords(granted)
|
|
683
|
+
|
|
684
|
+
updates = await handle(
|
|
685
|
+
fn.StartBotRequest(bot=bot, peer=target, start_param=req.param or req.referrer or "")
|
|
686
|
+
)
|
|
687
|
+
message = _send.message_from_updates(updates, chat_id=_send.peer_id_of(target))
|
|
688
|
+
ctx.emit("bot_start", {"bot_id": bot_id, "chat_id": message.chat_id})
|
|
689
|
+
return BotStarted(
|
|
690
|
+
bot_id=bot_id,
|
|
691
|
+
chat_id=message.chat_id,
|
|
692
|
+
msg_id=message.id,
|
|
693
|
+
start_param=req.param or req.referrer,
|
|
694
|
+
admin_rights=rights,
|
|
695
|
+
unblocked=unblocked,
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _input_channel(peer: Any) -> Any:
|
|
700
|
+
from tlgr.ops._common import input_channel
|
|
701
|
+
|
|
702
|
+
return input_channel(peer)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
async def _add_to_chat(ctx: OpContext, peer: Any, bot: Any) -> None:
|
|
706
|
+
"""Invite the bot, tolerating "already a member"."""
|
|
707
|
+
from telethon.tl.functions import channels as channels_fn
|
|
708
|
+
from telethon.tl.functions import messages as fn
|
|
709
|
+
|
|
710
|
+
handle = client(ctx)
|
|
711
|
+
try:
|
|
712
|
+
if type(peer).__name__ == "InputPeerChat":
|
|
713
|
+
await handle(
|
|
714
|
+
fn.AddChatUserRequest(chat_id=getattr(peer, "chat_id", 0), user_id=bot, fwd_limit=0)
|
|
715
|
+
)
|
|
716
|
+
else:
|
|
717
|
+
await handle(
|
|
718
|
+
channels_fn.InviteToChannelRequest(channel=_input_channel(peer), users=[bot])
|
|
719
|
+
)
|
|
720
|
+
except Exception as exc: # the server's own "already a member" is not a failure
|
|
721
|
+
if "ALREADY" not in f"{type(exc).__name__} {exc}".upper().replace("_", ""):
|
|
722
|
+
raise
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
SPEC_START = OperationSpec(
|
|
726
|
+
id="bot.start",
|
|
727
|
+
request=StartReq,
|
|
728
|
+
response=BotStarted,
|
|
729
|
+
impl=start,
|
|
730
|
+
summary="Start a bot, with a deep-link parameter or inside a group",
|
|
731
|
+
tags=frozenset({"visible-to-others"}),
|
|
732
|
+
description=(
|
|
733
|
+
"`--param` is the payload behind a `t.me/<bot>?start=…` link and is "
|
|
734
|
+
"never written into the chat, which is the whole point of a deep "
|
|
735
|
+
"link. `--referrer` additionally re-resolves the username with the "
|
|
736
|
+
"referral attached, because the attribution happens at resolve time."
|
|
737
|
+
),
|
|
738
|
+
aliases=("bot.restart",),
|
|
739
|
+
mutating=True,
|
|
740
|
+
rate_class="send",
|
|
741
|
+
columns=("bot_id", "chat_id", "msg_id"),
|
|
742
|
+
headers=("Bot", "Chat", "Message"),
|
|
743
|
+
example={"bot_id": 93372553, "chat_id": 93372553, "msg_id": 12},
|
|
744
|
+
example_args="bot start @gifbot",
|
|
745
|
+
covers=(
|
|
746
|
+
"bots.inline-switch-pm",
|
|
747
|
+
"bots.referral-link-import",
|
|
748
|
+
"bots.restart-bot",
|
|
749
|
+
"bots.start-in-channel",
|
|
750
|
+
"bots.start-in-group",
|
|
751
|
+
"bots.start-in-group-as-admin",
|
|
752
|
+
"bots.start-private",
|
|
753
|
+
"bots.start-with-deeplink-param",
|
|
754
|
+
),
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
class StopReq(Request):
|
|
759
|
+
bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot to block.")]
|
|
760
|
+
delete_chat: Annotated[bool, opt("--delete-chat", help="Also delete the chat history.")] = False
|
|
761
|
+
report: Annotated[bool, opt("--report", help="Report the bot as spam while blocking.")] = False
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
async def stop(ctx: OpContext, req: StopReq) -> BotStopped:
|
|
765
|
+
"""Block a bot, and optionally delete the whole conversation.
|
|
766
|
+
|
|
767
|
+
`deleteHistory` answers with an `AffectedHistory` carrying an offset to
|
|
768
|
+
resume from; calling it once and reporting success is how v1-shaped code
|
|
769
|
+
deletes the first hundred messages and leaves the rest.
|
|
770
|
+
"""
|
|
771
|
+
from telethon.tl.functions import contacts as contacts_fn
|
|
772
|
+
from telethon.tl.functions import messages as fn
|
|
773
|
+
|
|
774
|
+
handle = client(ctx)
|
|
775
|
+
peer = await _send.resolve(ctx, req.bot)
|
|
776
|
+
bot_id = _send.peer_id_of(peer)
|
|
777
|
+
if req.report:
|
|
778
|
+
await handle(fn.ReportSpamRequest(peer=peer))
|
|
779
|
+
await handle(contacts_fn.BlockRequest(id=peer))
|
|
780
|
+
|
|
781
|
+
deleted = 0
|
|
782
|
+
if req.delete_chat:
|
|
783
|
+
from tlgr.ops._common import affected_loop
|
|
784
|
+
|
|
785
|
+
deleted = await affected_loop(
|
|
786
|
+
ctx,
|
|
787
|
+
lambda offset: fn.DeleteHistoryRequest(peer=peer, max_id=0, revoke=False),
|
|
788
|
+
)
|
|
789
|
+
ctx.emit("bot_stop", {"bot_id": bot_id})
|
|
790
|
+
return BotStopped(bot_id=bot_id, blocked=True, history_deleted=deleted)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
SPEC_STOP = OperationSpec(
|
|
794
|
+
id="bot.stop",
|
|
795
|
+
request=StopReq,
|
|
796
|
+
response=BotStopped,
|
|
797
|
+
impl=stop,
|
|
798
|
+
summary="Stop and block a bot",
|
|
799
|
+
mutating=True,
|
|
800
|
+
destructive=True,
|
|
801
|
+
columns=("bot_id", "blocked", "history_deleted"),
|
|
802
|
+
headers=("Bot", "Blocked", "Deleted"),
|
|
803
|
+
example={"bot_id": 93372553, "blocked": True, "history_deleted": 0},
|
|
804
|
+
example_args="bot stop @gifbot",
|
|
805
|
+
covers=("bots.delete-bot-chat-and-block", "bots.stop-bot", "dialogs.bot-stop-restart"),
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
# ---------------------------------------------------------------------------
|
|
810
|
+
# bot command list / send / set
|
|
811
|
+
# ---------------------------------------------------------------------------
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
class CommandListReq(Request):
|
|
815
|
+
bot: Annotated[
|
|
816
|
+
PeerRef | None,
|
|
817
|
+
arg(0, metavar="BOT", required=False, kind="user", help="The bot."),
|
|
818
|
+
] = None
|
|
819
|
+
chat: Annotated[
|
|
820
|
+
PeerRef | None,
|
|
821
|
+
opt("--chat", metavar="CHAT", kind="peer", help="Every bot's commands in this chat."),
|
|
822
|
+
] = None
|
|
823
|
+
scope: Annotated[
|
|
824
|
+
str | None,
|
|
825
|
+
choice(
|
|
826
|
+
"default",
|
|
827
|
+
"users",
|
|
828
|
+
"chats",
|
|
829
|
+
"chat-admins",
|
|
830
|
+
"peer",
|
|
831
|
+
"peer-admins",
|
|
832
|
+
"peer-user",
|
|
833
|
+
help="Bot-side scope to read back (bot session).",
|
|
834
|
+
),
|
|
835
|
+
] = None
|
|
836
|
+
peer: Annotated[
|
|
837
|
+
PeerRef | None, opt("--peer", metavar="CHAT", kind="peer", help="Peer for a peer* scope.")
|
|
838
|
+
] = None
|
|
839
|
+
user: Annotated[
|
|
840
|
+
PeerRef | None,
|
|
841
|
+
opt("--user", metavar="USER", kind="user", help="User for the peer-user scope."),
|
|
842
|
+
] = None
|
|
843
|
+
lang: Annotated[str | None, opt("--lang", metavar="CODE", help="Language code.")] = None
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
async def command_list(ctx: OpContext, req: CommandListReq) -> Page[BotCommand]:
|
|
847
|
+
"""A bot's slash commands, from whichever side is asking.
|
|
848
|
+
|
|
849
|
+
`has_help`/`has_settings` decide whether a GUI shows its "Bot Help" and
|
|
850
|
+
"Bot Settings" entries, so they are computed from the list rather than
|
|
851
|
+
assumed: a bot without `/help` must not get a menu item that does nothing.
|
|
852
|
+
"""
|
|
853
|
+
from telethon.tl.functions import bots as fn
|
|
854
|
+
|
|
855
|
+
if req.scope is not None:
|
|
856
|
+
await _bots.require_bot_session(ctx, "reading back your own command list")
|
|
857
|
+
scope = await _bots.command_scope(ctx, req.scope, req.peer, req.user)
|
|
858
|
+
result = await client(ctx)(fn.GetBotCommandsRequest(scope=scope, lang_code=req.lang or ""))
|
|
859
|
+
me = await client(ctx).get_me()
|
|
860
|
+
items = _commands(
|
|
861
|
+
result, bot_id=int(getattr(me, "id", 0) or 0), scope=req.scope, lang=req.lang
|
|
862
|
+
)
|
|
863
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
864
|
+
|
|
865
|
+
if req.chat is not None and req.bot is None:
|
|
866
|
+
return await _chat_commands(ctx, req.chat)
|
|
867
|
+
|
|
868
|
+
if req.bot is None:
|
|
869
|
+
raise UsageError("name a bot, or use --chat to list every bot in a chat", field="bot")
|
|
870
|
+
peer = await _send.resolve(ctx, req.bot)
|
|
871
|
+
full, _user = await _full(ctx, peer)
|
|
872
|
+
info = getattr(full, "bot_info", None)
|
|
873
|
+
items = _commands(info, bot_id=int(getattr(full, "id", 0) or 0), lang=req.lang)
|
|
874
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
async def _chat_commands(ctx: OpContext, chat: PeerRef) -> Page[BotCommand]:
|
|
878
|
+
"""Every bot's commands in one chat, out of the chat's full info."""
|
|
879
|
+
from telethon.tl.functions import channels as channels_fn
|
|
880
|
+
from telethon.tl.functions import messages as fn
|
|
881
|
+
|
|
882
|
+
peer = await _send.resolve(ctx, chat)
|
|
883
|
+
if type(peer).__name__ == "InputPeerChat":
|
|
884
|
+
result = await client(ctx)(fn.GetFullChatRequest(chat_id=getattr(peer, "chat_id", 0)))
|
|
885
|
+
else:
|
|
886
|
+
result = await client(ctx)(channels_fn.GetFullChannelRequest(channel=_input_channel(peer)))
|
|
887
|
+
full = getattr(result, "full_chat", None)
|
|
888
|
+
items: list[BotCommand] = []
|
|
889
|
+
for info in getattr(full, "bot_info", None) or []:
|
|
890
|
+
items += _commands(info, bot_id=int(getattr(info, "user_id", 0) or 0))
|
|
891
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
SPEC_COMMAND_LIST = OperationSpec(
|
|
895
|
+
id="bot.command.list",
|
|
896
|
+
request=CommandListReq,
|
|
897
|
+
response=Page[BotCommand],
|
|
898
|
+
impl=command_list,
|
|
899
|
+
summary="List a bot's slash commands",
|
|
900
|
+
description=(
|
|
901
|
+
"A user reads them out of `botInfo`; a bot session reads its own back "
|
|
902
|
+
"per scope with `bots.getBotCommands`, which is the only way to see "
|
|
903
|
+
"what a scope actually holds."
|
|
904
|
+
),
|
|
905
|
+
aliases=("bot.commands",),
|
|
906
|
+
columns=("bot_id", "command", "description"),
|
|
907
|
+
headers=("Bot", "Command", "Description"),
|
|
908
|
+
example={
|
|
909
|
+
"items": [{"bot_id": 93372553, "command": "start", "description": "Start the bot"}],
|
|
910
|
+
"has_more": False,
|
|
911
|
+
},
|
|
912
|
+
example_args="bot command list @gifbot",
|
|
913
|
+
covers=(
|
|
914
|
+
"bots.bot-help-settings-shortcuts",
|
|
915
|
+
"bots.get-my-bot-commands",
|
|
916
|
+
"bots.list-commands",
|
|
917
|
+
),
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
class CommandSendReq(Request):
|
|
922
|
+
bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot addressed.")]
|
|
923
|
+
command: Annotated[str, arg(1, metavar="COMMAND", help="The command, with or without '/'.")]
|
|
924
|
+
args: Annotated[
|
|
925
|
+
list[str],
|
|
926
|
+
arg(2, metavar="ARGS", required=False, variadic=True, help="Arguments appended after it."),
|
|
927
|
+
] = []
|
|
928
|
+
chat: Annotated[
|
|
929
|
+
PeerRef | None,
|
|
930
|
+
opt("--chat", metavar="CHAT", kind="peer", help="Send it in this chat instead."),
|
|
931
|
+
] = None
|
|
932
|
+
guest: Annotated[
|
|
933
|
+
bool, opt("--guest", help="Address the bot in guest mode by mentioning it.")
|
|
934
|
+
] = False
|
|
935
|
+
topic: Annotated[
|
|
936
|
+
int | None, opt("--topic", metavar="ID", kind="msg_id", help="Forum topic id.")
|
|
937
|
+
] = None
|
|
938
|
+
reply_to: Annotated[
|
|
939
|
+
int | None, opt("--reply-to", metavar="ID", kind="msg_id", help="Reply to this message.")
|
|
940
|
+
] = None
|
|
941
|
+
silent: Annotated[bool, opt("--silent", help="Send without a notification.")] = False
|
|
942
|
+
business_connection: Annotated[
|
|
943
|
+
str | None,
|
|
944
|
+
opt(
|
|
945
|
+
"--business-connection", metavar="ID", help="Send as a business account (bot session)."
|
|
946
|
+
),
|
|
947
|
+
] = None
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
async def command_send(ctx: OpContext, req: CommandSendReq) -> CommandSent:
|
|
951
|
+
"""Send `/command` to a bot, in its chat or in a group.
|
|
952
|
+
|
|
953
|
+
In a group with more than one bot the command MUST carry `@botusername`
|
|
954
|
+
or every bot ignores it, so tlgr appends it whenever the destination is
|
|
955
|
+
not the bot's own private chat. Guest mode has no method of its own: the
|
|
956
|
+
trigger is an ordinary message that mentions the bot.
|
|
957
|
+
"""
|
|
958
|
+
from telethon.tl.functions import messages as fn
|
|
959
|
+
|
|
960
|
+
bot_peer = await _send.resolve(ctx, req.bot)
|
|
961
|
+
target = await _send.resolve(ctx, req.chat) if req.chat is not None else bot_peer
|
|
962
|
+
in_private = req.chat is None or _send.peer_id_of(target) == _send.peer_id_of(bot_peer)
|
|
963
|
+
|
|
964
|
+
username = str(req.bot.value) if req.bot.kind == "username" else ""
|
|
965
|
+
if not username and not in_private:
|
|
966
|
+
_full_user, user = await _full(ctx, bot_peer)
|
|
967
|
+
username = str(getattr(user, "username", "") or "")
|
|
968
|
+
name = req.command.lstrip("/")
|
|
969
|
+
if not in_private and username:
|
|
970
|
+
name = f"{name}@{username}"
|
|
971
|
+
text = " ".join([f"/{name}", *req.args]).strip()
|
|
972
|
+
if req.guest and username:
|
|
973
|
+
text = f"@{username} {text}"
|
|
974
|
+
|
|
975
|
+
reply_to = await _send.reply_target(ctx, reply_to=req.reply_to, topic=req.topic)
|
|
976
|
+
request = fn.SendMessageRequest(
|
|
977
|
+
peer=target,
|
|
978
|
+
message=text,
|
|
979
|
+
random_id=_random_id(),
|
|
980
|
+
reply_to=reply_to,
|
|
981
|
+
silent=req.silent or None,
|
|
982
|
+
)
|
|
983
|
+
updates = await _invoke_as(ctx, req.business_connection, request)
|
|
984
|
+
message = _send.message_from_updates(updates, chat_id=_send.peer_id_of(target), sent_text=text)
|
|
985
|
+
ctx.emit("bot_command", {"chat_id": message.chat_id, "text": text})
|
|
986
|
+
return CommandSent(
|
|
987
|
+
chat_id=message.chat_id, msg_id=message.id, text=text, via_bot=username or None
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def _random_id() -> int:
|
|
992
|
+
from tlgr.ops._common import random_id
|
|
993
|
+
|
|
994
|
+
return random_id()
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
async def _invoke_as(ctx: OpContext, connection_id: str | None, request: Any) -> Any:
|
|
998
|
+
"""Send *request*, wrapped in a business connection when one is named.
|
|
999
|
+
|
|
1000
|
+
The wrapper is not a header: the query has to reach the connection's own
|
|
1001
|
+
DC, which is why the connection is looked up first.
|
|
1002
|
+
"""
|
|
1003
|
+
handle = client(ctx)
|
|
1004
|
+
if not connection_id:
|
|
1005
|
+
return await handle(request)
|
|
1006
|
+
await _bots.require_bot_session(ctx, "--business-connection")
|
|
1007
|
+
from telethon.tl.functions import InvokeWithBusinessConnectionRequest
|
|
1008
|
+
from telethon.tl.functions import account as account_fn
|
|
1009
|
+
|
|
1010
|
+
connection = await handle(
|
|
1011
|
+
account_fn.GetBotBusinessConnectionRequest(connection_id=connection_id)
|
|
1012
|
+
)
|
|
1013
|
+
dc_id = _connection_dc(connection)
|
|
1014
|
+
return await _bots.on_dc(
|
|
1015
|
+
ctx,
|
|
1016
|
+
dc_id,
|
|
1017
|
+
InvokeWithBusinessConnectionRequest(connection_id=connection_id, query=request),
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def _connection_dc(result: Any) -> int:
|
|
1022
|
+
for update in getattr(result, "updates", None) or []:
|
|
1023
|
+
connection = getattr(update, "connection", None)
|
|
1024
|
+
if connection is not None:
|
|
1025
|
+
return int(getattr(connection, "dc_id", 0) or 0)
|
|
1026
|
+
return int(getattr(result, "dc_id", 0) or 0)
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
SPEC_COMMAND_SEND = OperationSpec(
|
|
1030
|
+
id="bot.command.send",
|
|
1031
|
+
request=CommandSendReq,
|
|
1032
|
+
response=CommandSent,
|
|
1033
|
+
impl=command_send,
|
|
1034
|
+
summary="Send a slash command to a bot",
|
|
1035
|
+
tags=frozenset({"visible-to-others"}),
|
|
1036
|
+
description=(
|
|
1037
|
+
"Driving @BotFather's own conversation with this command and "
|
|
1038
|
+
"`bot press` is the only way to reach the toggles Telegram exposes "
|
|
1039
|
+
"nowhere else — group privacy mode and bot-to-bot mode."
|
|
1040
|
+
),
|
|
1041
|
+
aliases=("bot.cmd",),
|
|
1042
|
+
mutating=True,
|
|
1043
|
+
rate_class="send",
|
|
1044
|
+
columns=("chat_id", "msg_id", "text"),
|
|
1045
|
+
headers=("Chat", "Message", "Text"),
|
|
1046
|
+
example={"chat_id": 93372553, "msg_id": 12, "text": "/start"},
|
|
1047
|
+
example_args="bot command send @gifbot start",
|
|
1048
|
+
covers=("bots.bot-privacy-mode", "bots.bot-to-bot-messaging", "bots.send-command"),
|
|
1049
|
+
covers_partial=("bots.bot-help-settings-shortcuts", "bots.guest-mode-invoke"),
|
|
1050
|
+
coverage_note=(
|
|
1051
|
+
"Whether a bot declares /help and /settings is reported by "
|
|
1052
|
+
"`bot command list`; the guest-mode bot listing is `bot list --recent "
|
|
1053
|
+
"--kind guest`."
|
|
1054
|
+
),
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
class CommandSetReq(Request):
|
|
1059
|
+
commands: Annotated[
|
|
1060
|
+
str | None,
|
|
1061
|
+
arg(0, metavar="COMMANDS", required=False, help="'start:Start,help:Show help'."),
|
|
1062
|
+
] = None
|
|
1063
|
+
scope: Annotated[
|
|
1064
|
+
str,
|
|
1065
|
+
choice(
|
|
1066
|
+
"default",
|
|
1067
|
+
"users",
|
|
1068
|
+
"chats",
|
|
1069
|
+
"chat-admins",
|
|
1070
|
+
"peer",
|
|
1071
|
+
"peer-admins",
|
|
1072
|
+
"peer-user",
|
|
1073
|
+
help="Command scope.",
|
|
1074
|
+
),
|
|
1075
|
+
] = "default"
|
|
1076
|
+
peer: Annotated[
|
|
1077
|
+
PeerRef | None, opt("--peer", metavar="CHAT", kind="peer", help="Peer for a peer* scope.")
|
|
1078
|
+
] = None
|
|
1079
|
+
user: Annotated[
|
|
1080
|
+
PeerRef | None,
|
|
1081
|
+
opt("--user", metavar="USER", kind="user", help="User for the peer-user scope."),
|
|
1082
|
+
] = None
|
|
1083
|
+
lang: Annotated[str, opt("--lang", metavar="CODE", help="Language code.")] = ""
|
|
1084
|
+
file: Annotated[
|
|
1085
|
+
str | None,
|
|
1086
|
+
opt("--file", metavar="PATH", kind="path", help="Read the list from a JSON file."),
|
|
1087
|
+
] = None
|
|
1088
|
+
clear: Annotated[bool, opt("--clear", help="Reset the list for this scope.")] = False
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
async def command_set(ctx: OpContext, req: CommandSetReq) -> BotCommandSet:
|
|
1092
|
+
"""Publish (or clear) my bot's command list for one scope and language.
|
|
1093
|
+
|
|
1094
|
+
A human owner does this through @BotFather; the method itself is bot-only,
|
|
1095
|
+
which is why the session is checked before the request is built.
|
|
1096
|
+
"""
|
|
1097
|
+
from telethon.tl import types
|
|
1098
|
+
from telethon.tl.functions import bots as fn
|
|
1099
|
+
|
|
1100
|
+
await _bots.require_bot_session(ctx, "setting your bot's command list")
|
|
1101
|
+
scope = await _bots.command_scope(ctx, req.scope, req.peer, req.user)
|
|
1102
|
+
handle = client(ctx)
|
|
1103
|
+
|
|
1104
|
+
if req.clear:
|
|
1105
|
+
await handle(fn.ResetBotCommandsRequest(scope=scope, lang_code=req.lang))
|
|
1106
|
+
return BotCommandSet(scope=req.scope, lang=req.lang, cleared=True)
|
|
1107
|
+
|
|
1108
|
+
pairs = _command_pairs(req)
|
|
1109
|
+
await handle(
|
|
1110
|
+
fn.SetBotCommandsRequest(
|
|
1111
|
+
scope=scope,
|
|
1112
|
+
lang_code=req.lang,
|
|
1113
|
+
commands=[
|
|
1114
|
+
types.BotCommand(command=name, description=description)
|
|
1115
|
+
for name, description in pairs
|
|
1116
|
+
],
|
|
1117
|
+
)
|
|
1118
|
+
)
|
|
1119
|
+
return BotCommandSet(
|
|
1120
|
+
scope=req.scope,
|
|
1121
|
+
lang=req.lang,
|
|
1122
|
+
commands=[
|
|
1123
|
+
BotCommand(command=name, description=description, scope=req.scope, lang=req.lang)
|
|
1124
|
+
for name, description in pairs
|
|
1125
|
+
],
|
|
1126
|
+
)
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def _command_pairs(req: CommandSetReq) -> list[tuple[str, str]]:
|
|
1130
|
+
if req.file:
|
|
1131
|
+
loaded = _bots.load_json(req.file, field="file")
|
|
1132
|
+
if not isinstance(loaded, list):
|
|
1133
|
+
raise UsageError("--file: expected a JSON list of commands", field="file")
|
|
1134
|
+
return [
|
|
1135
|
+
(str(entry.get("command", "")).lstrip("/"), str(entry.get("description", "")))
|
|
1136
|
+
for entry in loaded
|
|
1137
|
+
]
|
|
1138
|
+
if not req.commands:
|
|
1139
|
+
raise UsageError("give a command list, --file or --clear", field="commands")
|
|
1140
|
+
pairs: list[tuple[str, str]] = []
|
|
1141
|
+
for chunk in req.commands.split(","):
|
|
1142
|
+
name, _, description = chunk.partition(":")
|
|
1143
|
+
if not name.strip():
|
|
1144
|
+
continue
|
|
1145
|
+
pairs.append((name.strip().lstrip("/"), description.strip()))
|
|
1146
|
+
return pairs
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
SPEC_COMMAND_SET = OperationSpec(
|
|
1150
|
+
id="bot.command.set",
|
|
1151
|
+
request=CommandSetReq,
|
|
1152
|
+
response=BotCommandSet,
|
|
1153
|
+
impl=command_set,
|
|
1154
|
+
summary="Set or clear my bot's command list for one scope",
|
|
1155
|
+
mutating=True,
|
|
1156
|
+
columns=("scope", "lang", "cleared"),
|
|
1157
|
+
headers=("Scope", "Lang", "Cleared"),
|
|
1158
|
+
example={
|
|
1159
|
+
"scope": "default",
|
|
1160
|
+
"lang": "",
|
|
1161
|
+
"commands": [{"command": "start", "description": "Start the bot"}],
|
|
1162
|
+
},
|
|
1163
|
+
example_args='bot command set "start:Start the bot"',
|
|
1164
|
+
covers=("bots.reset-my-bot-commands", "bots.set-my-bot-commands"),
|
|
1165
|
+
)
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
# ---------------------------------------------------------------------------
|
|
1169
|
+
# bot menu get / set
|
|
1170
|
+
# ---------------------------------------------------------------------------
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
class MenuGetReq(Request):
|
|
1174
|
+
bot: Annotated[
|
|
1175
|
+
PeerRef | None, arg(0, metavar="BOT", required=False, kind="user", help="The bot.")
|
|
1176
|
+
] = None
|
|
1177
|
+
user: Annotated[
|
|
1178
|
+
PeerRef | None,
|
|
1179
|
+
opt("--user", metavar="USER", kind="user", help="Per-user override (bot session)."),
|
|
1180
|
+
] = None
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
async def menu_get(ctx: OpContext, req: MenuGetReq) -> MenuButton:
|
|
1184
|
+
"""The button left of the message input."""
|
|
1185
|
+
from telethon.tl.functions import bots as fn
|
|
1186
|
+
|
|
1187
|
+
if req.user is not None:
|
|
1188
|
+
await _bots.require_bot_session(ctx, "reading a per-user menu button")
|
|
1189
|
+
button = await client(ctx)(
|
|
1190
|
+
fn.GetBotMenuButtonRequest(user_id=await _bots.input_user(ctx, req.user, field="user"))
|
|
1191
|
+
)
|
|
1192
|
+
return _menu_button(
|
|
1193
|
+
button, user_id=_send.peer_id_of(await _send.resolve(ctx, req.user))
|
|
1194
|
+
) or (MenuButton())
|
|
1195
|
+
if req.bot is None:
|
|
1196
|
+
raise UsageError("name a bot, or use --user on a bot session", field="bot")
|
|
1197
|
+
peer = await _send.resolve(ctx, req.bot)
|
|
1198
|
+
full, _user = await _full(ctx, peer)
|
|
1199
|
+
info = getattr(full, "bot_info", None)
|
|
1200
|
+
return _menu_button(getattr(info, "menu_button", None)) or MenuButton(kind="commands")
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
SPEC_MENU_GET = OperationSpec(
|
|
1204
|
+
id="bot.menu.get",
|
|
1205
|
+
request=MenuGetReq,
|
|
1206
|
+
response=MenuButton,
|
|
1207
|
+
impl=menu_get,
|
|
1208
|
+
summary="Show a bot's menu button",
|
|
1209
|
+
description=(
|
|
1210
|
+
"`botMenuButtonDefault` is never what a user sees — the server shows "
|
|
1211
|
+
"the commands list instead — so it is normalised to `commands` rather "
|
|
1212
|
+
"than reported as a third state nobody can act on."
|
|
1213
|
+
),
|
|
1214
|
+
columns=("kind", "text", "url"),
|
|
1215
|
+
headers=("Kind", "Text", "URL"),
|
|
1216
|
+
example={"kind": "commands"},
|
|
1217
|
+
example_args="bot menu get @gifbot",
|
|
1218
|
+
covers=("bots.menu-button-state",),
|
|
1219
|
+
)
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
class MenuSetReq(Request):
|
|
1223
|
+
commands: Annotated[bool, opt("--commands", help="Show the commands list.")] = False
|
|
1224
|
+
webapp: Annotated[bool, opt("--webapp", help="Open a mini app.")] = False
|
|
1225
|
+
default: Annotated[bool, opt("--default", help="Reset to the default.")] = False
|
|
1226
|
+
text: Annotated[str | None, opt("--text", metavar="TEXT", help="Button label.")] = None
|
|
1227
|
+
url: Annotated[str | None, opt("--url", metavar="URL", help="Mini app URL.")] = None
|
|
1228
|
+
user: Annotated[
|
|
1229
|
+
PeerRef | None, opt("--user", metavar="USER", kind="user", help="Apply to this user only.")
|
|
1230
|
+
] = None
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
async def menu_set(ctx: OpContext, req: MenuSetReq) -> MenuButton:
|
|
1234
|
+
"""Set my bot's menu button, globally or for one user."""
|
|
1235
|
+
from telethon.tl import types
|
|
1236
|
+
from telethon.tl.functions import bots as fn
|
|
1237
|
+
|
|
1238
|
+
await _bots.require_bot_session(ctx, "setting the menu button")
|
|
1239
|
+
chosen = [name for name in ("commands", "webapp", "default") if getattr(req, name)]
|
|
1240
|
+
if len(chosen) != 1:
|
|
1241
|
+
raise UsageError("give exactly one of --commands, --webapp or --default", field="commands")
|
|
1242
|
+
|
|
1243
|
+
if req.webapp:
|
|
1244
|
+
if not req.text or not req.url:
|
|
1245
|
+
raise UsageError("--webapp needs --text and --url", field="url")
|
|
1246
|
+
button: Any = types.BotMenuButton(text=req.text, url=req.url)
|
|
1247
|
+
elif req.commands:
|
|
1248
|
+
button = types.BotMenuButtonCommands()
|
|
1249
|
+
else:
|
|
1250
|
+
button = types.BotMenuButtonDefault()
|
|
1251
|
+
|
|
1252
|
+
user = (
|
|
1253
|
+
await _bots.input_user(ctx, req.user, field="user")
|
|
1254
|
+
if req.user is not None
|
|
1255
|
+
else types.InputUserEmpty()
|
|
1256
|
+
)
|
|
1257
|
+
await client(ctx)(fn.SetBotMenuButtonRequest(user_id=user, button=button))
|
|
1258
|
+
user_id = _send.peer_id_of(await _send.resolve(ctx, req.user)) if req.user else None
|
|
1259
|
+
return MenuButton(
|
|
1260
|
+
kind="webapp" if req.webapp else "commands" if req.commands else "default",
|
|
1261
|
+
text=req.text,
|
|
1262
|
+
url=req.url,
|
|
1263
|
+
user_id=user_id,
|
|
1264
|
+
)
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
SPEC_MENU_SET = OperationSpec(
|
|
1268
|
+
id="bot.menu.set",
|
|
1269
|
+
request=MenuSetReq,
|
|
1270
|
+
response=MenuButton,
|
|
1271
|
+
impl=menu_set,
|
|
1272
|
+
summary="Set my bot's menu button",
|
|
1273
|
+
mutating=True,
|
|
1274
|
+
columns=("kind", "text", "url"),
|
|
1275
|
+
headers=("Kind", "Text", "URL"),
|
|
1276
|
+
example={"kind": "webapp", "text": "Open", "url": "https://example.org/app"},
|
|
1277
|
+
example_args="bot menu set --commands",
|
|
1278
|
+
covers=("bots.menu-button-set",),
|
|
1279
|
+
)
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
# ---------------------------------------------------------------------------
|
|
1283
|
+
# bot permission get / set
|
|
1284
|
+
# ---------------------------------------------------------------------------
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
class PermissionGetReq(Request):
|
|
1288
|
+
bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot.")]
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
async def permission_get(ctx: OpContext, req: PermissionGetReq) -> BotPermission:
|
|
1292
|
+
"""What a bot is allowed to do to me."""
|
|
1293
|
+
from telethon.tl.functions import bots as fn
|
|
1294
|
+
|
|
1295
|
+
peer = await _send.resolve(ctx, req.bot)
|
|
1296
|
+
full, _user = await _full(ctx, peer)
|
|
1297
|
+
can_send = bool(
|
|
1298
|
+
await client(ctx)(fn.CanSendMessageRequest(bot=await _bots.input_user(ctx, req.bot)))
|
|
1299
|
+
)
|
|
1300
|
+
return BotPermission(
|
|
1301
|
+
bot_id=int(getattr(full, "id", 0) or 0),
|
|
1302
|
+
can_send_messages=can_send,
|
|
1303
|
+
emoji_status_allowed=bool(getattr(full, "bot_can_manage_emoji_status", False)),
|
|
1304
|
+
)
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
SPEC_PERMISSION_GET = OperationSpec(
|
|
1308
|
+
id="bot.permission.get",
|
|
1309
|
+
request=PermissionGetReq,
|
|
1310
|
+
response=BotPermission,
|
|
1311
|
+
impl=permission_get,
|
|
1312
|
+
summary="Show what a bot may do to me",
|
|
1313
|
+
columns=("bot_id", "can_send_messages", "emoji_status_allowed"),
|
|
1314
|
+
headers=("Bot", "May message", "May set status"),
|
|
1315
|
+
example={"bot_id": 93372553, "can_send_messages": True, "emoji_status_allowed": False},
|
|
1316
|
+
example_args="bot permission get @gifbot",
|
|
1317
|
+
covers=("bots.bot-emoji-status-permission",),
|
|
1318
|
+
covers_partial=("bots.allow-send-messages",),
|
|
1319
|
+
coverage_note="Granting or revoking is `bot permission set`.",
|
|
1320
|
+
)
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
class PermissionSetReq(Request):
|
|
1324
|
+
bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot.")]
|
|
1325
|
+
key: Annotated[str, arg(1, metavar="KEY", help="message or emoji-status.")]
|
|
1326
|
+
state: Annotated[str, arg(2, metavar="STATE", help="on to grant, off to revoke.")]
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
async def permission_set(ctx: OpContext, req: PermissionSetReq) -> BotPermission:
|
|
1330
|
+
"""Grant or revoke one bot permission.
|
|
1331
|
+
|
|
1332
|
+
"May message me again" is also granted implicitly by `webapp open
|
|
1333
|
+
--allow-write` and `bot attach toggle --allow-write`, and by nothing else:
|
|
1334
|
+
a permission that can be granted as a side effect of an unrelated command
|
|
1335
|
+
is a permission the user did not give.
|
|
1336
|
+
"""
|
|
1337
|
+
from telethon.tl.functions import bots as fn
|
|
1338
|
+
|
|
1339
|
+
if req.key not in ("message", "emoji-status"):
|
|
1340
|
+
raise UsageError("key must be 'message' or 'emoji-status'", field="key")
|
|
1341
|
+
if req.state not in ("on", "off"):
|
|
1342
|
+
raise UsageError("state must be 'on' or 'off'", field="state")
|
|
1343
|
+
|
|
1344
|
+
bot = await _bots.input_user(ctx, req.bot)
|
|
1345
|
+
peer = await _send.resolve(ctx, req.bot)
|
|
1346
|
+
bot_id = _send.peer_id_of(peer)
|
|
1347
|
+
handle = client(ctx)
|
|
1348
|
+
already = False
|
|
1349
|
+
|
|
1350
|
+
if req.key == "message":
|
|
1351
|
+
if req.state == "off":
|
|
1352
|
+
raise UsageError(
|
|
1353
|
+
"Telegram has no revoke for 'may message me'; block the bot with `bot stop`",
|
|
1354
|
+
field="state",
|
|
1355
|
+
)
|
|
1356
|
+
if bool(await handle(fn.CanSendMessageRequest(bot=bot))):
|
|
1357
|
+
already = True
|
|
1358
|
+
from tlgr.ops._common import already as mark_already
|
|
1359
|
+
|
|
1360
|
+
mark_already(ctx)
|
|
1361
|
+
else:
|
|
1362
|
+
await handle(fn.AllowSendMessageRequest(bot=bot))
|
|
1363
|
+
else:
|
|
1364
|
+
await handle(fn.ToggleUserEmojiStatusPermissionRequest(bot=bot, enabled=req.state == "on"))
|
|
1365
|
+
|
|
1366
|
+
return BotPermission(
|
|
1367
|
+
bot_id=bot_id,
|
|
1368
|
+
key=req.key,
|
|
1369
|
+
state=req.state,
|
|
1370
|
+
already=already,
|
|
1371
|
+
can_send_messages=req.key == "message" and req.state == "on",
|
|
1372
|
+
emoji_status_allowed=req.key == "emoji-status" and req.state == "on",
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
SPEC_PERMISSION_SET = OperationSpec(
|
|
1377
|
+
id="bot.permission.set",
|
|
1378
|
+
request=PermissionSetReq,
|
|
1379
|
+
response=BotPermission,
|
|
1380
|
+
impl=permission_set,
|
|
1381
|
+
summary="Allow or revoke a bot permission",
|
|
1382
|
+
mutating=True,
|
|
1383
|
+
idempotent=True,
|
|
1384
|
+
columns=("bot_id", "key", "state", "already"),
|
|
1385
|
+
headers=("Bot", "Key", "State", "Already"),
|
|
1386
|
+
example={"bot_id": 93372553, "key": "message", "state": "on", "already": False},
|
|
1387
|
+
example_args="bot permission set @gifbot message on",
|
|
1388
|
+
covers=("bots.allow-send-messages",),
|
|
1389
|
+
covers_partial=("bots.bot-emoji-status-permission",),
|
|
1390
|
+
coverage_note="Reading both permissions back is `bot permission get`.",
|
|
1391
|
+
)
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
# ---------------------------------------------------------------------------
|
|
1395
|
+
# bot press
|
|
1396
|
+
# ---------------------------------------------------------------------------
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
class PressReq(Request):
|
|
1400
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat holding the message.")]
|
|
1401
|
+
msg_id: Annotated[
|
|
1402
|
+
int | None,
|
|
1403
|
+
arg(1, metavar="MSG_ID", required=False, kind="msg_id", help="Message id."),
|
|
1404
|
+
] = None
|
|
1405
|
+
button: Annotated[
|
|
1406
|
+
str | None,
|
|
1407
|
+
opt("--button", metavar="SPEC", help="'<row>,<col>', '<n>' or the button's exact text."),
|
|
1408
|
+
] = None
|
|
1409
|
+
data: Annotated[
|
|
1410
|
+
str | None,
|
|
1411
|
+
opt("--data", metavar="PAYLOAD", help="Address a callback button by its payload."),
|
|
1412
|
+
] = None
|
|
1413
|
+
rich_button: Annotated[
|
|
1414
|
+
int | None, opt("--rich-button", metavar="N", help="Button in a layer-229 rich message.")
|
|
1415
|
+
] = None
|
|
1416
|
+
ephemeral: Annotated[
|
|
1417
|
+
int | None, opt("--ephemeral", metavar="ID", help="Button on an ephemeral bot message.")
|
|
1418
|
+
] = None
|
|
1419
|
+
webapp_req: Annotated[
|
|
1420
|
+
str | None, opt("--webapp-req", metavar="ID", help="Answer a mini app's peer request.")
|
|
1421
|
+
] = None
|
|
1422
|
+
password: Annotated[
|
|
1423
|
+
str | None,
|
|
1424
|
+
opt(secret=True, envvar="TLGR_2FA_PASSWORD", help="2FA password for a guarded button."),
|
|
1425
|
+
] = None
|
|
1426
|
+
share_phone: Annotated[
|
|
1427
|
+
bool, opt("--share-phone", help="CONSENT: send my phone number to the bot.")
|
|
1428
|
+
] = False
|
|
1429
|
+
share_geo: Annotated[
|
|
1430
|
+
str | None, opt("--share-geo", metavar="LAT,LON", help="CONSENT: send this location.")
|
|
1431
|
+
] = None
|
|
1432
|
+
peers: Annotated[
|
|
1433
|
+
list[PeerRef],
|
|
1434
|
+
opt("--peers", metavar="PEER", kind="peer", help="CONSENT: peers to share (repeatable)."),
|
|
1435
|
+
] = []
|
|
1436
|
+
create_bot: Annotated[
|
|
1437
|
+
bool, opt("--create-bot", help="Answer a create-bot request by creating one.")
|
|
1438
|
+
] = False
|
|
1439
|
+
name: Annotated[str | None, opt("--name", help="Managed-bot name for --create-bot.")] = None
|
|
1440
|
+
username: Annotated[
|
|
1441
|
+
str | None, opt("--username", help="Managed-bot username for --create-bot.")
|
|
1442
|
+
] = None
|
|
1443
|
+
poll: Annotated[
|
|
1444
|
+
str | None, opt("--poll", metavar="SPEC", help="CONSENT: poll 'Question?:A,B,C'.")
|
|
1445
|
+
] = None
|
|
1446
|
+
quiz: Annotated[bool, opt("--quiz", help="Make the --poll a quiz.")] = False
|
|
1447
|
+
correct: Annotated[
|
|
1448
|
+
int | None, opt("--correct", metavar="N", help="0-based correct answer for a quiz.")
|
|
1449
|
+
] = None
|
|
1450
|
+
switch_to: Annotated[
|
|
1451
|
+
PeerRef | None,
|
|
1452
|
+
opt(
|
|
1453
|
+
"--switch-to", metavar="CHAT", kind="peer", help="Chat to run a switch-inline query in."
|
|
1454
|
+
),
|
|
1455
|
+
] = None
|
|
1456
|
+
business_connection: Annotated[
|
|
1457
|
+
str | None,
|
|
1458
|
+
opt(
|
|
1459
|
+
"--business-connection", metavar="ID", help="Press as a business account (bot session)."
|
|
1460
|
+
),
|
|
1461
|
+
] = None
|
|
1462
|
+
|
|
1463
|
+
|
|
1464
|
+
async def press(ctx: OpContext, req: PressReq) -> Pressed:
|
|
1465
|
+
"""Press a button, whatever kind it is, and report what came back.
|
|
1466
|
+
|
|
1467
|
+
The consent rule is the reason this is one command and not fourteen.
|
|
1468
|
+
Four button kinds hand the bot something the user owns — a phone number, a
|
|
1469
|
+
location, a chat, a poll — and Telegram's protocol makes them look like
|
|
1470
|
+
every other button. tlgr will not press one without the flag that names
|
|
1471
|
+
what is about to leave: without it, it prints what it *would* send and
|
|
1472
|
+
exits 2. `buy` is refused outright, because paying is not something an
|
|
1473
|
+
agent does on someone's behalf.
|
|
1474
|
+
"""
|
|
1475
|
+
if req.rich_button is not None:
|
|
1476
|
+
_bots.unsupported("--rich-button")
|
|
1477
|
+
if req.ephemeral is not None:
|
|
1478
|
+
_bots.unsupported("--ephemeral (ephemeral.getCallbackAnswer)")
|
|
1479
|
+
|
|
1480
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
1481
|
+
if req.webapp_req:
|
|
1482
|
+
return await _answer_webapp_request(ctx, req, peer)
|
|
1483
|
+
if req.msg_id is None:
|
|
1484
|
+
raise UsageError("give a message id, or --webapp-req", field="msg_id")
|
|
1485
|
+
|
|
1486
|
+
message = await _media.fetch_message(ctx, peer, req.msg_id)
|
|
1487
|
+
markup = getattr(message, "reply_markup", None)
|
|
1488
|
+
flat = _flatten(markup)
|
|
1489
|
+
if not flat:
|
|
1490
|
+
raise NotFoundError(f"message {req.msg_id} has no buttons")
|
|
1491
|
+
row, col, index, button = _pick(flat, req)
|
|
1492
|
+
kind = _bots.BUTTON_TYPES.get(type(button).__name__, "unsupported")
|
|
1493
|
+
answer = Pressed(kind=kind, row=row, col=col, n=index, text=getattr(button, "text", None))
|
|
1494
|
+
return await _dispatch(ctx, req, peer, message, button, answer)
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
def _flatten(markup: Any) -> list[tuple[int, int, int, Any]]:
|
|
1498
|
+
out: list[tuple[int, int, int, Any]] = []
|
|
1499
|
+
index = 0
|
|
1500
|
+
for row_index, row in enumerate(getattr(markup, "rows", None) or []):
|
|
1501
|
+
for col_index, button in enumerate(getattr(row, "buttons", None) or []):
|
|
1502
|
+
out.append((row_index, col_index, index, button))
|
|
1503
|
+
index += 1
|
|
1504
|
+
return out
|
|
1505
|
+
|
|
1506
|
+
|
|
1507
|
+
def _pick(flat: list[tuple[int, int, int, Any]], req: PressReq) -> tuple[int, int, int, Any]:
|
|
1508
|
+
"""The addressed button: by payload, by coordinates, by index, or by text."""
|
|
1509
|
+
if req.data is not None:
|
|
1510
|
+
wanted = _bots.payload_bytes(req.data, field="data")
|
|
1511
|
+
for row, col, index, button in flat:
|
|
1512
|
+
if getattr(button, "data", None) == wanted:
|
|
1513
|
+
return row, col, index, button
|
|
1514
|
+
raise NotFoundError("no callback button on that message carries that payload")
|
|
1515
|
+
|
|
1516
|
+
if req.button is None:
|
|
1517
|
+
if len(flat) == 1:
|
|
1518
|
+
return flat[0]
|
|
1519
|
+
raise UsageError(
|
|
1520
|
+
f"the message has {len(flat)} buttons; name one with --button or --data",
|
|
1521
|
+
field="button",
|
|
1522
|
+
)
|
|
1523
|
+
|
|
1524
|
+
spec = req.button.strip()
|
|
1525
|
+
if "," in spec:
|
|
1526
|
+
head, _, tail = spec.partition(",")
|
|
1527
|
+
try:
|
|
1528
|
+
want = (int(head), int(tail))
|
|
1529
|
+
except ValueError as exc:
|
|
1530
|
+
raise UsageError("--button: expected '<row>,<col>'", field="button") from exc
|
|
1531
|
+
for row, col, index, button in flat:
|
|
1532
|
+
if (row, col) == want:
|
|
1533
|
+
return row, col, index, button
|
|
1534
|
+
raise NotFoundError(f"there is no button at row {want[0]}, column {want[1]}")
|
|
1535
|
+
if spec.isdigit():
|
|
1536
|
+
number = int(spec)
|
|
1537
|
+
for row, col, index, button in flat:
|
|
1538
|
+
if index == number:
|
|
1539
|
+
return row, col, index, button
|
|
1540
|
+
raise NotFoundError(f"there is no button {number}; the message has {len(flat)}")
|
|
1541
|
+
|
|
1542
|
+
exact = [e for e in flat if str(getattr(e[3], "text", "")) == spec]
|
|
1543
|
+
if len(exact) == 1:
|
|
1544
|
+
return exact[0]
|
|
1545
|
+
lowered = spec.lower()
|
|
1546
|
+
partial = [e for e in flat if lowered in str(getattr(e[3], "text", "")).lower()]
|
|
1547
|
+
if len(partial) == 1:
|
|
1548
|
+
return partial[0]
|
|
1549
|
+
if not partial:
|
|
1550
|
+
raise NotFoundError(f"no button matches {spec!r}")
|
|
1551
|
+
raise UsageError(
|
|
1552
|
+
f"{spec!r} matches {len(partial)} buttons; use --button '<row>,<col>' instead",
|
|
1553
|
+
field="button",
|
|
1554
|
+
)
|
|
1555
|
+
|
|
1556
|
+
|
|
1557
|
+
def _refuse(what: str, flag: str) -> Any:
|
|
1558
|
+
raise UsageError(
|
|
1559
|
+
f"this button would {what}; pass {flag} to allow it. Nothing was sent.",
|
|
1560
|
+
field=flag.lstrip("-").replace("-", "_"),
|
|
1561
|
+
)
|
|
1562
|
+
|
|
1563
|
+
|
|
1564
|
+
async def _dispatch(
|
|
1565
|
+
ctx: OpContext, req: PressReq, peer: Any, message: Any, button: Any, answer: Pressed
|
|
1566
|
+
) -> Pressed:
|
|
1567
|
+
from telethon.tl import types
|
|
1568
|
+
from telethon.tl.functions import messages as fn
|
|
1569
|
+
|
|
1570
|
+
handle = client(ctx)
|
|
1571
|
+
chat_id = _send.peer_id_of(peer)
|
|
1572
|
+
kind = answer.kind
|
|
1573
|
+
|
|
1574
|
+
if kind == "buy":
|
|
1575
|
+
raise PermissionError_(
|
|
1576
|
+
"a Pay button starts a payment, and tlgr never spends money on your behalf"
|
|
1577
|
+
)
|
|
1578
|
+
if kind == "unsupported":
|
|
1579
|
+
raise UsageError(f"tlgr cannot press a {type(button).__name__}", field="button")
|
|
1580
|
+
|
|
1581
|
+
if kind == "text":
|
|
1582
|
+
sent = await _invoke_as(
|
|
1583
|
+
ctx,
|
|
1584
|
+
req.business_connection,
|
|
1585
|
+
fn.SendMessageRequest(
|
|
1586
|
+
peer=peer, message=str(getattr(button, "text", "")), random_id=_random_id()
|
|
1587
|
+
),
|
|
1588
|
+
)
|
|
1589
|
+
answer.sent_message_id = _send.message_from_updates(sent, chat_id=chat_id).id
|
|
1590
|
+
return answer
|
|
1591
|
+
|
|
1592
|
+
if kind in ("callback", "game"):
|
|
1593
|
+
return await _press_callback(ctx, req, peer, message, button, answer)
|
|
1594
|
+
|
|
1595
|
+
if kind == "url":
|
|
1596
|
+
answer.url = getattr(button, "url", None)
|
|
1597
|
+
return answer
|
|
1598
|
+
|
|
1599
|
+
if kind == "copy":
|
|
1600
|
+
answer.copy_text = getattr(button, "copy_text", None)
|
|
1601
|
+
return answer
|
|
1602
|
+
|
|
1603
|
+
if kind == "url_auth":
|
|
1604
|
+
result = await handle(
|
|
1605
|
+
fn.RequestUrlAuthRequest(
|
|
1606
|
+
peer=peer, msg_id=int(message.id), button_id=int(getattr(button, "button_id", 0))
|
|
1607
|
+
)
|
|
1608
|
+
)
|
|
1609
|
+
auth = _url_auth_model(result)
|
|
1610
|
+
answer.auth = _bots_to_dict(auth)
|
|
1611
|
+
answer.url = auth.url
|
|
1612
|
+
return answer
|
|
1613
|
+
|
|
1614
|
+
if kind == "user_profile":
|
|
1615
|
+
user_id = int(getattr(button, "user_id", 0) or 0)
|
|
1616
|
+
answer.user = {"id": user_id}
|
|
1617
|
+
return answer
|
|
1618
|
+
|
|
1619
|
+
if kind == "switch_inline":
|
|
1620
|
+
target = req.switch_to if req.switch_to is not None else req.chat
|
|
1621
|
+
where = await _send.resolve(ctx, target)
|
|
1622
|
+
bot = await _bot_of(ctx, message)
|
|
1623
|
+
if bot is None:
|
|
1624
|
+
raise NotFoundError("the message does not say which bot to query")
|
|
1625
|
+
results = await handle(
|
|
1626
|
+
fn.GetInlineBotResultsRequest(
|
|
1627
|
+
bot=bot, peer=where, query=str(getattr(button, "query", "") or ""), offset=""
|
|
1628
|
+
)
|
|
1629
|
+
)
|
|
1630
|
+
answer.query_id = str(getattr(results, "query_id", "") or "")
|
|
1631
|
+
answer.results = [
|
|
1632
|
+
{"id": str(getattr(entry, "id", "")), "type": str(getattr(entry, "type", ""))}
|
|
1633
|
+
for entry in (getattr(results, "results", None) or [])
|
|
1634
|
+
]
|
|
1635
|
+
return answer
|
|
1636
|
+
|
|
1637
|
+
if kind in ("webview", "simple_webview"):
|
|
1638
|
+
bot = await _bot_of(ctx, message) or await _bots.input_user(ctx, req.chat)
|
|
1639
|
+
url = str(getattr(button, "url", "") or "")
|
|
1640
|
+
if kind == "webview":
|
|
1641
|
+
result = await handle(
|
|
1642
|
+
fn.RequestWebViewRequest(peer=peer, bot=bot, platform=PLATFORM, url=url)
|
|
1643
|
+
)
|
|
1644
|
+
else:
|
|
1645
|
+
result = await handle(
|
|
1646
|
+
fn.RequestSimpleWebViewRequest(bot=bot, platform=PLATFORM, url=url)
|
|
1647
|
+
)
|
|
1648
|
+
answer.url = str(getattr(result, "url", "") or "")
|
|
1649
|
+
query_id = getattr(result, "query_id", None)
|
|
1650
|
+
answer.query_id = str(query_id) if query_id else None
|
|
1651
|
+
return answer
|
|
1652
|
+
|
|
1653
|
+
if kind == "request_phone":
|
|
1654
|
+
if not req.share_phone:
|
|
1655
|
+
_refuse("send the bot your phone number", "--share-phone")
|
|
1656
|
+
me = await handle.get_me()
|
|
1657
|
+
sent = await handle(
|
|
1658
|
+
fn.SendMediaRequest(
|
|
1659
|
+
peer=peer,
|
|
1660
|
+
media=types.InputMediaContact(
|
|
1661
|
+
phone_number=str(getattr(me, "phone", "") or ""),
|
|
1662
|
+
first_name=str(getattr(me, "first_name", "") or ""),
|
|
1663
|
+
last_name=str(getattr(me, "last_name", "") or ""),
|
|
1664
|
+
vcard="",
|
|
1665
|
+
),
|
|
1666
|
+
message="",
|
|
1667
|
+
random_id=_random_id(),
|
|
1668
|
+
)
|
|
1669
|
+
)
|
|
1670
|
+
answer.sent_message_id = _send.message_from_updates(sent, chat_id=chat_id).id
|
|
1671
|
+
return answer
|
|
1672
|
+
|
|
1673
|
+
if kind == "request_geo":
|
|
1674
|
+
if not req.share_geo:
|
|
1675
|
+
_refuse("send the bot your location", "--share-geo")
|
|
1676
|
+
lat, lon = _latlon(str(req.share_geo))
|
|
1677
|
+
sent = await handle(
|
|
1678
|
+
fn.SendMediaRequest(
|
|
1679
|
+
peer=peer,
|
|
1680
|
+
media=types.InputMediaGeoPoint(geo_point=types.InputGeoPoint(lat=lat, long=lon)),
|
|
1681
|
+
message="",
|
|
1682
|
+
random_id=_random_id(),
|
|
1683
|
+
)
|
|
1684
|
+
)
|
|
1685
|
+
answer.sent_message_id = _send.message_from_updates(sent, chat_id=chat_id).id
|
|
1686
|
+
return answer
|
|
1687
|
+
|
|
1688
|
+
if kind == "request_poll":
|
|
1689
|
+
if not req.poll:
|
|
1690
|
+
_refuse("create a poll in this chat", "--poll")
|
|
1691
|
+
sent = await handle(
|
|
1692
|
+
fn.SendMediaRequest(
|
|
1693
|
+
peer=peer, media=_poll_media(req), message="", random_id=_random_id()
|
|
1694
|
+
)
|
|
1695
|
+
)
|
|
1696
|
+
answer.sent_message_id = _send.message_from_updates(sent, chat_id=chat_id).id
|
|
1697
|
+
return answer
|
|
1698
|
+
|
|
1699
|
+
if kind == "request_peer":
|
|
1700
|
+
return await _press_request_peer(ctx, req, peer, message, button, answer)
|
|
1701
|
+
|
|
1702
|
+
raise UsageError(f"tlgr cannot press a {kind} button", field="button")
|
|
1703
|
+
|
|
1704
|
+
|
|
1705
|
+
def _bots_to_dict(model: Any) -> dict[str, Any]:
|
|
1706
|
+
from tlgr.models.base import to_builtins
|
|
1707
|
+
|
|
1708
|
+
value = to_builtins(model)
|
|
1709
|
+
return value if isinstance(value, dict) else {}
|
|
1710
|
+
|
|
1711
|
+
|
|
1712
|
+
async def _bot_of(ctx: OpContext, message: Any) -> Any:
|
|
1713
|
+
"""The `InputUser` of the bot that owns a message's buttons.
|
|
1714
|
+
|
|
1715
|
+
Resolved through the account's own resolver rather than assembled from
|
|
1716
|
+
the message: an `InputUser` needs an access hash, and the one on a message
|
|
1717
|
+
object is frequently absent (`min` peers carry none at all).
|
|
1718
|
+
"""
|
|
1719
|
+
for attribute in ("via_bot_id", "from_id", "peer_id"):
|
|
1720
|
+
value = getattr(message, attribute, None)
|
|
1721
|
+
user_id = value if isinstance(value, int) else getattr(value, "user_id", None)
|
|
1722
|
+
if user_id:
|
|
1723
|
+
return await _bots.input_user(ctx, _bots.peer_ref(str(int(user_id))), field="chat")
|
|
1724
|
+
return None
|
|
1725
|
+
|
|
1726
|
+
|
|
1727
|
+
def _latlon(value: str) -> tuple[float, float]:
|
|
1728
|
+
head, _, tail = str(value).partition(",")
|
|
1729
|
+
try:
|
|
1730
|
+
return float(head), float(tail)
|
|
1731
|
+
except ValueError as exc:
|
|
1732
|
+
raise UsageError("--share-geo: expected 'lat,lon'", field="share_geo") from exc
|
|
1733
|
+
|
|
1734
|
+
|
|
1735
|
+
def _poll_media(req: PressReq) -> Any:
|
|
1736
|
+
from telethon.tl import types
|
|
1737
|
+
|
|
1738
|
+
question, _, options = str(req.poll).partition(":")
|
|
1739
|
+
answers = [a.strip() for a in options.split(",") if a.strip()]
|
|
1740
|
+
if len(answers) < 2:
|
|
1741
|
+
raise UsageError("--poll: expected 'Question?:A,B,C'", field="poll")
|
|
1742
|
+
if req.quiz and req.correct is None:
|
|
1743
|
+
raise UsageError("--quiz needs --correct", field="correct")
|
|
1744
|
+
return types.InputMediaPoll(
|
|
1745
|
+
poll=types.Poll(
|
|
1746
|
+
id=0,
|
|
1747
|
+
hash=0,
|
|
1748
|
+
question=types.TextWithEntities(text=question.strip(), entities=[]),
|
|
1749
|
+
answers=[
|
|
1750
|
+
types.PollAnswer(
|
|
1751
|
+
text=types.TextWithEntities(text=text, entities=[]),
|
|
1752
|
+
option=bytes([index]),
|
|
1753
|
+
)
|
|
1754
|
+
for index, text in enumerate(answers)
|
|
1755
|
+
],
|
|
1756
|
+
quiz=req.quiz or None,
|
|
1757
|
+
),
|
|
1758
|
+
correct_answers=[bytes([int(req.correct)])] if req.correct is not None else None,
|
|
1759
|
+
)
|
|
1760
|
+
|
|
1761
|
+
|
|
1762
|
+
async def _press_callback(
|
|
1763
|
+
ctx: OpContext, req: PressReq, peer: Any, message: Any, button: Any, answer: Pressed
|
|
1764
|
+
) -> Pressed:
|
|
1765
|
+
"""A callback (or game) button, with the SRP dance when it needs one.
|
|
1766
|
+
|
|
1767
|
+
`BOT_RESPONSE_TIMEOUT` is not an error: it means the bot is not running.
|
|
1768
|
+
Reporting it as a failure would make "the bot is offline" indistinguishable
|
|
1769
|
+
from "the press was rejected", so the answer comes back with a null message.
|
|
1770
|
+
"""
|
|
1771
|
+
from telethon.tl.functions import messages as fn
|
|
1772
|
+
|
|
1773
|
+
from tlgr.ops import _auth
|
|
1774
|
+
|
|
1775
|
+
handle = client(ctx)
|
|
1776
|
+
is_game = answer.kind == "game"
|
|
1777
|
+
|
|
1778
|
+
def build(check: Any) -> Any:
|
|
1779
|
+
return fn.GetBotCallbackAnswerRequest(
|
|
1780
|
+
peer=peer,
|
|
1781
|
+
msg_id=int(message.id),
|
|
1782
|
+
game=is_game or None,
|
|
1783
|
+
data=None if is_game else getattr(button, "data", None),
|
|
1784
|
+
password=check,
|
|
1785
|
+
)
|
|
1786
|
+
|
|
1787
|
+
try:
|
|
1788
|
+
if getattr(button, "requires_password", False):
|
|
1789
|
+
if req.password is None:
|
|
1790
|
+
raise UsageError(
|
|
1791
|
+
"this button is protected by your 2FA password; "
|
|
1792
|
+
"pass it with --password-env or --password-stdin",
|
|
1793
|
+
field="password",
|
|
1794
|
+
)
|
|
1795
|
+
result = await _auth.with_password(handle, build, req.password)
|
|
1796
|
+
else:
|
|
1797
|
+
result = await handle(build(None))
|
|
1798
|
+
except Exception as exc: # one specific server answer is not a failure
|
|
1799
|
+
if "BOTRESPONSETIMEOUT" not in f"{type(exc).__name__} {exc}".upper().replace("_", ""):
|
|
1800
|
+
raise
|
|
1801
|
+
ctx.warn("the bot did not answer in time; it is probably offline")
|
|
1802
|
+
return answer
|
|
1803
|
+
|
|
1804
|
+
answer.message = getattr(result, "message", None)
|
|
1805
|
+
answer.alert = bool(getattr(result, "alert", False))
|
|
1806
|
+
answer.url = getattr(result, "url", None)
|
|
1807
|
+
answer.native_ui = bool(getattr(result, "native_ui", False))
|
|
1808
|
+
answer.cache_time = getattr(result, "cache_time", None)
|
|
1809
|
+
return answer
|
|
1810
|
+
|
|
1811
|
+
|
|
1812
|
+
async def _press_request_peer(
|
|
1813
|
+
ctx: OpContext, req: PressReq, peer: Any, message: Any, button: Any, answer: Pressed
|
|
1814
|
+
) -> Pressed:
|
|
1815
|
+
"""Answer a request-peer button, or create the bot it asked for."""
|
|
1816
|
+
from telethon.tl.functions import bots as bots_fn
|
|
1817
|
+
from telethon.tl.functions import messages as fn
|
|
1818
|
+
|
|
1819
|
+
handle = client(ctx)
|
|
1820
|
+
peer_type = getattr(button, "peer_type", None)
|
|
1821
|
+
if type(peer_type).__name__ == "RequestPeerTypeCreateBot":
|
|
1822
|
+
if not req.create_bot:
|
|
1823
|
+
_refuse("create a bot owned by you", "--create-bot")
|
|
1824
|
+
if not req.name or not req.username:
|
|
1825
|
+
raise UsageError("--create-bot needs --name and --username", field="name")
|
|
1826
|
+
created = await handle(
|
|
1827
|
+
bots_fn.CreateBotRequest(
|
|
1828
|
+
name=req.name,
|
|
1829
|
+
username=req.username,
|
|
1830
|
+
manager_id=await _bot_of(ctx, message) or await _bots.input_user(ctx, req.chat),
|
|
1831
|
+
)
|
|
1832
|
+
)
|
|
1833
|
+
answer.peers = [
|
|
1834
|
+
int(getattr(u, "id", 0) or 0) for u in (getattr(created, "users", None) or [])
|
|
1835
|
+
]
|
|
1836
|
+
return answer
|
|
1837
|
+
|
|
1838
|
+
if not req.peers:
|
|
1839
|
+
_refuse("disclose one of your chats to the bot", "--peers")
|
|
1840
|
+
resolved = [await _send.resolve(ctx, value) for value in req.peers]
|
|
1841
|
+
await handle(
|
|
1842
|
+
fn.SendBotRequestedPeerRequest(
|
|
1843
|
+
peer=peer,
|
|
1844
|
+
msg_id=int(message.id),
|
|
1845
|
+
button_id=int(getattr(button, "button_id", 0) or 0),
|
|
1846
|
+
requested_peers=resolved,
|
|
1847
|
+
)
|
|
1848
|
+
)
|
|
1849
|
+
answer.peers = [_send.peer_id_of(entry) for entry in resolved]
|
|
1850
|
+
return answer
|
|
1851
|
+
|
|
1852
|
+
|
|
1853
|
+
async def _answer_webapp_request(ctx: OpContext, req: PressReq, peer: Any) -> Pressed:
|
|
1854
|
+
"""Answer a mini app's peer request, addressed by `webapp_req_id`."""
|
|
1855
|
+
from telethon.tl.functions import bots as bots_fn
|
|
1856
|
+
from telethon.tl.functions import messages as fn
|
|
1857
|
+
|
|
1858
|
+
handle = client(ctx)
|
|
1859
|
+
bot = await _bots.input_user(ctx, req.chat)
|
|
1860
|
+
button = await handle(
|
|
1861
|
+
bots_fn.GetRequestedWebViewButtonRequest(bot=bot, webapp_req_id=str(req.webapp_req))
|
|
1862
|
+
)
|
|
1863
|
+
answer = Pressed(kind="request_peer", text=getattr(button, "text", None))
|
|
1864
|
+
if not req.peers:
|
|
1865
|
+
_refuse("disclose one of your chats to the mini app", "--peers")
|
|
1866
|
+
resolved = [await _send.resolve(ctx, value) for value in req.peers]
|
|
1867
|
+
await handle(
|
|
1868
|
+
fn.SendBotRequestedPeerRequest(
|
|
1869
|
+
peer=peer,
|
|
1870
|
+
button_id=int(getattr(button, "button_id", 0) or 0),
|
|
1871
|
+
requested_peers=resolved,
|
|
1872
|
+
webapp_req_id=str(req.webapp_req),
|
|
1873
|
+
)
|
|
1874
|
+
)
|
|
1875
|
+
answer.peers = [_send.peer_id_of(entry) for entry in resolved]
|
|
1876
|
+
return answer
|
|
1877
|
+
|
|
1878
|
+
|
|
1879
|
+
SPEC_PRESS = OperationSpec(
|
|
1880
|
+
id="bot.press",
|
|
1881
|
+
request=PressReq,
|
|
1882
|
+
response=Pressed,
|
|
1883
|
+
impl=press,
|
|
1884
|
+
summary="Press a button on a message",
|
|
1885
|
+
tags=frozenset({"visible-to-others"}),
|
|
1886
|
+
description=(
|
|
1887
|
+
"One dispatcher for every button kind, returning a typed answer: a "
|
|
1888
|
+
"callback toast, a URL, a signed mini-app session, inline results, a "
|
|
1889
|
+
"peer prompt or copy text. A button that would disclose your phone "
|
|
1890
|
+
"number, your location, a chat or a new poll is not pressed without "
|
|
1891
|
+
"the flag that names it — tlgr prints what it would send and exits 2. "
|
|
1892
|
+
"A Pay button is refused outright (exit 6)."
|
|
1893
|
+
),
|
|
1894
|
+
aliases=("bot.click", "bot.button.press"),
|
|
1895
|
+
mutating=True,
|
|
1896
|
+
rate_class="send",
|
|
1897
|
+
columns=("kind", "n", "message", "url"),
|
|
1898
|
+
headers=("Kind", "#", "Answer", "URL"),
|
|
1899
|
+
example={"kind": "callback", "n": 0, "message": "Saved", "alert": False},
|
|
1900
|
+
example_args="bot press @gifbot 12 --button 0",
|
|
1901
|
+
covers=(
|
|
1902
|
+
"bots.bot-ownership-transfer",
|
|
1903
|
+
"bots.button-request-location",
|
|
1904
|
+
"bots.button-request-peer",
|
|
1905
|
+
"bots.button-request-phone",
|
|
1906
|
+
"bots.button-request-poll",
|
|
1907
|
+
"bots.callback-button-press",
|
|
1908
|
+
"bots.callback-button-with-password",
|
|
1909
|
+
"bots.copy-text-button",
|
|
1910
|
+
"bots.managed-bot-request-button",
|
|
1911
|
+
"bots.play-game",
|
|
1912
|
+
"bots.reply-keyboard-press-text",
|
|
1913
|
+
"bots.url-button",
|
|
1914
|
+
"bots.user-profile-button",
|
|
1915
|
+
"bots.webapp-request-phone",
|
|
1916
|
+
),
|
|
1917
|
+
covers_partial=(
|
|
1918
|
+
"bots.attach-webapp-open",
|
|
1919
|
+
"bots.bot-privacy-mode",
|
|
1920
|
+
"bots.button-request-peer-from-miniapp",
|
|
1921
|
+
"bots.login-url-button",
|
|
1922
|
+
"bots.switch-inline-button",
|
|
1923
|
+
"bots.webapp-switch-inline-query",
|
|
1924
|
+
),
|
|
1925
|
+
coverage_note=(
|
|
1926
|
+
"Pressing surfaces each of these; completing them is `webapp open`, "
|
|
1927
|
+
"`bot url-auth accept`, `inline query` and `inline send`."
|
|
1928
|
+
),
|
|
1929
|
+
)
|
|
1930
|
+
|
|
1931
|
+
|
|
1932
|
+
# ---------------------------------------------------------------------------
|
|
1933
|
+
# bot url-auth
|
|
1934
|
+
# ---------------------------------------------------------------------------
|
|
1935
|
+
|
|
1936
|
+
|
|
1937
|
+
def _url_auth_model(result: Any) -> UrlAuth:
|
|
1938
|
+
name = type(result).__name__
|
|
1939
|
+
if name == "UrlAuthResultAccepted":
|
|
1940
|
+
return UrlAuth(result="accepted", url=getattr(result, "url", None))
|
|
1941
|
+
if name == "UrlAuthResultDefault":
|
|
1942
|
+
return UrlAuth(result="default")
|
|
1943
|
+
bot = getattr(result, "bot", None)
|
|
1944
|
+
return UrlAuth(
|
|
1945
|
+
result="request",
|
|
1946
|
+
bot=str(getattr(bot, "username", "") or getattr(bot, "id", "") or "") or None,
|
|
1947
|
+
domain=getattr(result, "domain", None),
|
|
1948
|
+
verified_app_name=getattr(result, "verified_app_name", None),
|
|
1949
|
+
is_app=bool(getattr(result, "is_app", False)),
|
|
1950
|
+
browser=getattr(result, "browser", None),
|
|
1951
|
+
platform=getattr(result, "platform", None),
|
|
1952
|
+
ip=getattr(result, "ip", None),
|
|
1953
|
+
region=getattr(result, "region", None),
|
|
1954
|
+
request_write_access=bool(getattr(result, "request_write_access", False)),
|
|
1955
|
+
request_phone_number=bool(getattr(result, "request_phone_number", False)),
|
|
1956
|
+
match_codes=bool(getattr(result, "match_codes", False)),
|
|
1957
|
+
match_codes_first=bool(getattr(result, "match_codes_first", False)),
|
|
1958
|
+
user_id_hint=getattr(result, "user_id_hint", None),
|
|
1959
|
+
)
|
|
1960
|
+
|
|
1961
|
+
|
|
1962
|
+
class UrlAuthGetReq(Request):
|
|
1963
|
+
target: Annotated[
|
|
1964
|
+
str, arg(0, metavar="TARGET", help="Chat holding the button, or the OAuth deep link.")
|
|
1965
|
+
]
|
|
1966
|
+
msg_id: Annotated[
|
|
1967
|
+
int | None, opt("--msg-id", metavar="ID", kind="msg_id", help="Message id for a button.")
|
|
1968
|
+
] = None
|
|
1969
|
+
button_id: Annotated[
|
|
1970
|
+
int | None, opt("--button-id", metavar="N", help="Button id from the reply markup.")
|
|
1971
|
+
] = None
|
|
1972
|
+
in_app_origin: Annotated[
|
|
1973
|
+
str | None, opt("--in-app-origin", metavar="ORIGIN", help="Origin of a mini-app request.")
|
|
1974
|
+
] = None
|
|
1975
|
+
check_code: Annotated[
|
|
1976
|
+
str | None, opt("--check-code", metavar="CODE", help="Pre-validate this emoji match code.")
|
|
1977
|
+
] = None
|
|
1978
|
+
|
|
1979
|
+
|
|
1980
|
+
async def _url_auth_request(
|
|
1981
|
+
ctx: OpContext, target: str, msg_id: int | None, button_id: int | None, origin: str | None
|
|
1982
|
+
) -> Any:
|
|
1983
|
+
"""The one `requestUrlAuth` with three addressing modes."""
|
|
1984
|
+
from telethon.tl.functions import messages as fn
|
|
1985
|
+
|
|
1986
|
+
if target.startswith(("http://", "https://", "tg://")):
|
|
1987
|
+
return await client(ctx)(fn.RequestUrlAuthRequest(url=target, in_app_origin=origin))
|
|
1988
|
+
if msg_id is None or button_id is None:
|
|
1989
|
+
raise UsageError("addressing a button needs --msg-id and --button-id", field="msg_id")
|
|
1990
|
+
peer = await _send.resolve(ctx, _bots.peer_ref(target))
|
|
1991
|
+
return await client(ctx)(
|
|
1992
|
+
fn.RequestUrlAuthRequest(peer=peer, msg_id=int(msg_id), button_id=int(button_id))
|
|
1993
|
+
)
|
|
1994
|
+
|
|
1995
|
+
|
|
1996
|
+
async def url_auth_get(ctx: OpContext, req: UrlAuthGetReq) -> UrlAuth:
|
|
1997
|
+
"""Inspect a seamless-login request without accepting it.
|
|
1998
|
+
|
|
1999
|
+
Three addressing modes reach one method: a keyboard button, a
|
|
2000
|
+
`url_auth_domains` URL, and an OAuth deep link with the origin it came
|
|
2001
|
+
from. Whichever it was, nothing is granted here — this command exists so
|
|
2002
|
+
the domain, the browser and the IP can be *read* before the decision.
|
|
2003
|
+
"""
|
|
2004
|
+
from telethon.tl.functions import messages as fn
|
|
2005
|
+
|
|
2006
|
+
result = await _url_auth_request(ctx, req.target, req.msg_id, req.button_id, req.in_app_origin)
|
|
2007
|
+
model = _url_auth_model(result)
|
|
2008
|
+
if req.check_code:
|
|
2009
|
+
if not (model.match_codes and model.match_codes_first):
|
|
2010
|
+
raise UsageError(
|
|
2011
|
+
"--check-code only applies when the request sets match_codes_first",
|
|
2012
|
+
field="check_code",
|
|
2013
|
+
)
|
|
2014
|
+
model.code_valid = bool(
|
|
2015
|
+
await client(ctx)(
|
|
2016
|
+
fn.CheckUrlAuthMatchCodeRequest(url=req.target, match_code=req.check_code)
|
|
2017
|
+
)
|
|
2018
|
+
)
|
|
2019
|
+
return model
|
|
2020
|
+
|
|
2021
|
+
|
|
2022
|
+
SPEC_URL_AUTH_GET = OperationSpec(
|
|
2023
|
+
id="bot.url-auth.get",
|
|
2024
|
+
request=UrlAuthGetReq,
|
|
2025
|
+
response=UrlAuth,
|
|
2026
|
+
impl=url_auth_get,
|
|
2027
|
+
summary="Inspect a seamless-login request without accepting it",
|
|
2028
|
+
description=(
|
|
2029
|
+
"Telegram Login hands a website your identity. What it is about to "
|
|
2030
|
+
"hand over — the domain (or the verified app name), the browser, the "
|
|
2031
|
+
"platform, the IP and the region — is printed here first, and "
|
|
2032
|
+
"accepting is a separate command."
|
|
2033
|
+
),
|
|
2034
|
+
aliases=("bot.url_auth.get", "bot.login-url.get", "link.auth", "auth.url-login"),
|
|
2035
|
+
mutating=True,
|
|
2036
|
+
tags=frozenset({"mutating-checked"}),
|
|
2037
|
+
columns=("result", "domain", "bot", "request_write_access"),
|
|
2038
|
+
headers=("Result", "Domain", "Bot", "Wants write"),
|
|
2039
|
+
example={"result": "request", "domain": "example.org", "bot": "examplebot"},
|
|
2040
|
+
example_args="bot url-auth get @examplebot --msg-id 12 --button-id 0",
|
|
2041
|
+
covers=(
|
|
2042
|
+
"auth.oauth-deep-link",
|
|
2043
|
+
"auth.url-auth-bot-button",
|
|
2044
|
+
"contacts-users.url-auth-login",
|
|
2045
|
+
"messages-core.url-authorization",
|
|
2046
|
+
),
|
|
2047
|
+
covers_partial=(
|
|
2048
|
+
"bots.login-url-button",
|
|
2049
|
+
"bots.oauth-deeplink-login",
|
|
2050
|
+
"bots.url-auth-match-code",
|
|
2051
|
+
"bots.webapp-oauth-request",
|
|
2052
|
+
),
|
|
2053
|
+
coverage_note=(
|
|
2054
|
+
"Inspecting is this command; granting is `bot url-auth accept` and "
|
|
2055
|
+
"refusing is `bot url-auth decline`."
|
|
2056
|
+
),
|
|
2057
|
+
)
|
|
2058
|
+
|
|
2059
|
+
|
|
2060
|
+
class UrlAuthAcceptReq(Request):
|
|
2061
|
+
target: Annotated[
|
|
2062
|
+
str, arg(0, metavar="TARGET", help="Chat holding the button, or the OAuth deep link.")
|
|
2063
|
+
]
|
|
2064
|
+
msg_id: Annotated[
|
|
2065
|
+
int | None, opt("--msg-id", metavar="ID", kind="msg_id", help="Message id for a button.")
|
|
2066
|
+
] = None
|
|
2067
|
+
button_id: Annotated[
|
|
2068
|
+
int | None, opt("--button-id", metavar="N", help="Button id from the reply markup.")
|
|
2069
|
+
] = None
|
|
2070
|
+
write_allowed: Annotated[
|
|
2071
|
+
bool, opt("--write-allowed", help="CONSENT: let the linked bot message me.")
|
|
2072
|
+
] = False
|
|
2073
|
+
share_phone: Annotated[
|
|
2074
|
+
bool, opt("--share-phone", help="CONSENT: give the site my phone number.")
|
|
2075
|
+
] = False
|
|
2076
|
+
match_code: Annotated[
|
|
2077
|
+
str | None, opt("--match-code", metavar="CODE", help="The emoji shown on the login page.")
|
|
2078
|
+
] = None
|
|
2079
|
+
|
|
2080
|
+
|
|
2081
|
+
async def url_auth_accept(ctx: OpContext, req: UrlAuthAcceptReq) -> UrlAuth:
|
|
2082
|
+
"""Complete a seamless login and print the authorized URL.
|
|
2083
|
+
|
|
2084
|
+
The request is inspected first, always: a match code that the server marks
|
|
2085
|
+
`match_codes_first` has to be verified *before* accepting, and both
|
|
2086
|
+
consent flags default off and are never inferred from the request having
|
|
2087
|
+
asked for them.
|
|
2088
|
+
"""
|
|
2089
|
+
from telethon.tl.functions import messages as fn
|
|
2090
|
+
|
|
2091
|
+
handle = client(ctx)
|
|
2092
|
+
inspected = _url_auth_model(
|
|
2093
|
+
await _url_auth_request(ctx, req.target, req.msg_id, req.button_id, None)
|
|
2094
|
+
)
|
|
2095
|
+
if inspected.match_codes and not req.match_code:
|
|
2096
|
+
raise UsageError(
|
|
2097
|
+
"this login shows an emoji match code; pass it with --match-code", field="match_code"
|
|
2098
|
+
)
|
|
2099
|
+
if inspected.match_codes_first and req.match_code:
|
|
2100
|
+
ok = bool(
|
|
2101
|
+
await handle(fn.CheckUrlAuthMatchCodeRequest(url=req.target, match_code=req.match_code))
|
|
2102
|
+
)
|
|
2103
|
+
if not ok:
|
|
2104
|
+
raise PermissionError_("the match code does not match; nothing was authorized")
|
|
2105
|
+
|
|
2106
|
+
kwargs: dict[str, Any] = {
|
|
2107
|
+
"write_allowed": req.write_allowed or None,
|
|
2108
|
+
"share_phone_number": req.share_phone or None,
|
|
2109
|
+
"match_code": req.match_code,
|
|
2110
|
+
}
|
|
2111
|
+
if req.target.startswith(("http://", "https://", "tg://")):
|
|
2112
|
+
kwargs["url"] = req.target
|
|
2113
|
+
else:
|
|
2114
|
+
kwargs["peer"] = await _send.resolve(ctx, _bots.peer_ref(req.target))
|
|
2115
|
+
kwargs["msg_id"] = req.msg_id
|
|
2116
|
+
kwargs["button_id"] = req.button_id
|
|
2117
|
+
result = await handle(fn.AcceptUrlAuthRequest(**kwargs))
|
|
2118
|
+
model = _url_auth_model(result)
|
|
2119
|
+
model.domain = model.domain or inspected.domain
|
|
2120
|
+
model.bot = model.bot or inspected.bot
|
|
2121
|
+
model.write_allowed = req.write_allowed
|
|
2122
|
+
model.phone_shared = req.share_phone
|
|
2123
|
+
ctx.emit("bot_url_auth", {"domain": model.domain})
|
|
2124
|
+
return model
|
|
2125
|
+
|
|
2126
|
+
|
|
2127
|
+
SPEC_URL_AUTH_ACCEPT = OperationSpec(
|
|
2128
|
+
id="bot.url-auth.accept",
|
|
2129
|
+
request=UrlAuthAcceptReq,
|
|
2130
|
+
response=UrlAuth,
|
|
2131
|
+
impl=url_auth_accept,
|
|
2132
|
+
summary="Complete a seamless login and print the authorized URL",
|
|
2133
|
+
description=(
|
|
2134
|
+
"Destructive in the sense that matters: it logs you into a "
|
|
2135
|
+
"third-party site under your Telegram identity, which cannot be taken "
|
|
2136
|
+
"back from here. `--write-allowed` and `--share-phone` default off."
|
|
2137
|
+
),
|
|
2138
|
+
aliases=("bot.url_auth.accept",),
|
|
2139
|
+
mutating=True,
|
|
2140
|
+
destructive=True,
|
|
2141
|
+
columns=("result", "domain", "url"),
|
|
2142
|
+
headers=("Result", "Domain", "URL"),
|
|
2143
|
+
example={"result": "accepted", "url": "https://example.org/login?token=…"},
|
|
2144
|
+
example_args="bot url-auth accept @examplebot --msg-id 12 --button-id 0",
|
|
2145
|
+
covers=("bots.login-url-button", "bots.url-auth-match-code", "bots.webapp-oauth-request"),
|
|
2146
|
+
covers_partial=("bots.oauth-deeplink-login",),
|
|
2147
|
+
coverage_note="Refusing an OAuth deep link is `bot url-auth decline`.",
|
|
2148
|
+
)
|
|
2149
|
+
|
|
2150
|
+
|
|
2151
|
+
class UrlAuthDeclineReq(Request):
|
|
2152
|
+
url: Annotated[str, arg(0, metavar="URL", help="The OAuth deep link to decline.")]
|
|
2153
|
+
|
|
2154
|
+
|
|
2155
|
+
async def url_auth_decline(ctx: OpContext, req: UrlAuthDeclineReq) -> UrlAuth:
|
|
2156
|
+
"""Refuse a seamless-login request."""
|
|
2157
|
+
from telethon.tl.functions import messages as fn
|
|
2158
|
+
|
|
2159
|
+
await client(ctx)(fn.DeclineUrlAuthRequest(url=req.url))
|
|
2160
|
+
return UrlAuth(result="declined", declined=True, url=req.url)
|
|
2161
|
+
|
|
2162
|
+
|
|
2163
|
+
SPEC_URL_AUTH_DECLINE = OperationSpec(
|
|
2164
|
+
id="bot.url-auth.decline",
|
|
2165
|
+
request=UrlAuthDeclineReq,
|
|
2166
|
+
response=UrlAuth,
|
|
2167
|
+
impl=url_auth_decline,
|
|
2168
|
+
summary="Refuse a seamless-login request",
|
|
2169
|
+
aliases=("bot.url_auth.decline",),
|
|
2170
|
+
mutating=True,
|
|
2171
|
+
columns=("result", "declined"),
|
|
2172
|
+
headers=("Result", "Declined"),
|
|
2173
|
+
example={"result": "declined", "declined": True},
|
|
2174
|
+
example_args="bot url-auth decline tg://oauth?domain=example.org",
|
|
2175
|
+
covers=("bots.oauth-deeplink-login", "bots.url-auth-decline"),
|
|
2176
|
+
)
|
|
2177
|
+
|
|
2178
|
+
|
|
2179
|
+
# ---------------------------------------------------------------------------
|
|
2180
|
+
# bot answer
|
|
2181
|
+
# ---------------------------------------------------------------------------
|
|
2182
|
+
|
|
2183
|
+
|
|
2184
|
+
class AnswerReq(Request):
|
|
2185
|
+
kind: Annotated[
|
|
2186
|
+
str,
|
|
2187
|
+
arg(
|
|
2188
|
+
0,
|
|
2189
|
+
metavar="KIND",
|
|
2190
|
+
help="callback|inline|shipping|precheckout|guest|webapp|webhook.",
|
|
2191
|
+
),
|
|
2192
|
+
]
|
|
2193
|
+
query_id: Annotated[str, arg(1, metavar="QUERY_ID", help="Query id being answered.")]
|
|
2194
|
+
text: Annotated[str | None, opt("--text", help="callback: toast or alert text.")] = None
|
|
2195
|
+
alert: Annotated[bool, opt("--alert", help="callback: show a modal alert.")] = False
|
|
2196
|
+
url: Annotated[str | None, opt("--url", metavar="URL", help="callback: deep link.")] = None
|
|
2197
|
+
cache_time: Annotated[
|
|
2198
|
+
int | None, opt("--cache-time", metavar="SECONDS", help="Seconds clients may cache it.")
|
|
2199
|
+
] = None
|
|
2200
|
+
results: Annotated[
|
|
2201
|
+
str | None,
|
|
2202
|
+
opt("--results", metavar="PATH", kind="path", help="inline|guest|webapp: JSON results."),
|
|
2203
|
+
] = None
|
|
2204
|
+
next_offset: Annotated[
|
|
2205
|
+
str | None, opt("--next-offset", metavar="TOKEN", help="inline: offset for the next page.")
|
|
2206
|
+
] = None
|
|
2207
|
+
gallery: Annotated[bool, opt("--gallery", help="inline: render results as a grid.")] = False
|
|
2208
|
+
private: Annotated[bool, opt("--private", help="inline: cache per user.")] = False
|
|
2209
|
+
switch_pm: Annotated[
|
|
2210
|
+
str | None, opt("--switch-pm", metavar="TEXT:PARAM", help="inline: a button above them.")
|
|
2211
|
+
] = None
|
|
2212
|
+
switch_webview: Annotated[
|
|
2213
|
+
str | None, opt("--switch-webview", metavar="TEXT:URL", help="inline: mini-app button.")
|
|
2214
|
+
] = None
|
|
2215
|
+
options: Annotated[
|
|
2216
|
+
str | None,
|
|
2217
|
+
opt("--options", metavar="PATH", kind="path", help="shipping: JSON shipping options."),
|
|
2218
|
+
] = None
|
|
2219
|
+
ok: Annotated[bool, opt("--ok", help="shipping|precheckout: accept.")] = False
|
|
2220
|
+
error: Annotated[
|
|
2221
|
+
str | None, opt("--error", metavar="TEXT", help="shipping|precheckout: rejection.")
|
|
2222
|
+
] = None
|
|
2223
|
+
data: Annotated[
|
|
2224
|
+
str | None, opt("--data", metavar="JSON", kind="json", help="webhook: JSON payload.")
|
|
2225
|
+
] = None
|
|
2226
|
+
|
|
2227
|
+
|
|
2228
|
+
_ANSWER_FLAGS = {
|
|
2229
|
+
"callback": {"text", "alert", "url", "cache_time"},
|
|
2230
|
+
"inline": {
|
|
2231
|
+
"results",
|
|
2232
|
+
"next_offset",
|
|
2233
|
+
"gallery",
|
|
2234
|
+
"private",
|
|
2235
|
+
"switch_pm",
|
|
2236
|
+
"switch_webview",
|
|
2237
|
+
"cache_time",
|
|
2238
|
+
},
|
|
2239
|
+
"shipping": {"options", "ok", "error"},
|
|
2240
|
+
"precheckout": {"ok", "error"},
|
|
2241
|
+
"guest": {"results"},
|
|
2242
|
+
"webapp": {"results"},
|
|
2243
|
+
"webhook": {"data"},
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
|
|
2247
|
+
async def answer(ctx: OpContext, req: AnswerReq) -> BotAnswer:
|
|
2248
|
+
"""Answer one pending bot query.
|
|
2249
|
+
|
|
2250
|
+
Seven query kinds, seven methods, one command — because a caller reading
|
|
2251
|
+
`bot query list` has one loop to write, not seven. A flag that belongs to
|
|
2252
|
+
another kind is a usage error rather than a silently ignored argument.
|
|
2253
|
+
|
|
2254
|
+
Answering a pre-checkout query is not a payment: it approves or rejects one
|
|
2255
|
+
the buyer has already started, and refusing to do it would leave that buyer
|
|
2256
|
+
stuck.
|
|
2257
|
+
"""
|
|
2258
|
+
from telethon.tl.functions import bots as bots_fn
|
|
2259
|
+
from telethon.tl.functions import messages as fn
|
|
2260
|
+
|
|
2261
|
+
await _bots.require_bot_session(ctx, "answering a bot query")
|
|
2262
|
+
allowed = _ANSWER_FLAGS.get(req.kind)
|
|
2263
|
+
if allowed is None:
|
|
2264
|
+
raise UsageError(f"{req.kind!r} is not a query kind", field="kind")
|
|
2265
|
+
supplied = {
|
|
2266
|
+
name
|
|
2267
|
+
for name in set().union(*_ANSWER_FLAGS.values())
|
|
2268
|
+
if getattr(req, name, None) not in (None, False)
|
|
2269
|
+
}
|
|
2270
|
+
stray = sorted(supplied - allowed)
|
|
2271
|
+
if stray:
|
|
2272
|
+
raise UsageError(
|
|
2273
|
+
f"{', '.join('--' + s.replace('_', '-') for s in stray)} "
|
|
2274
|
+
f"does not belong to a {req.kind} answer",
|
|
2275
|
+
field=stray[0],
|
|
2276
|
+
)
|
|
2277
|
+
|
|
2278
|
+
handle = client(ctx)
|
|
2279
|
+
query_id = _query_id(req.query_id)
|
|
2280
|
+
if req.kind == "callback":
|
|
2281
|
+
await handle(
|
|
2282
|
+
fn.SetBotCallbackAnswerRequest(
|
|
2283
|
+
query_id=query_id,
|
|
2284
|
+
cache_time=int(req.cache_time or 0),
|
|
2285
|
+
alert=req.alert or None,
|
|
2286
|
+
message=req.text,
|
|
2287
|
+
url=req.url,
|
|
2288
|
+
)
|
|
2289
|
+
)
|
|
2290
|
+
elif req.kind == "inline":
|
|
2291
|
+
await handle(
|
|
2292
|
+
fn.SetInlineBotResultsRequest(
|
|
2293
|
+
query_id=query_id,
|
|
2294
|
+
results=_inline_results(req.results),
|
|
2295
|
+
cache_time=int(req.cache_time or 0),
|
|
2296
|
+
gallery=req.gallery or None,
|
|
2297
|
+
private=req.private or None,
|
|
2298
|
+
next_offset=req.next_offset,
|
|
2299
|
+
switch_pm=_switch_pm(req.switch_pm),
|
|
2300
|
+
switch_webview=_switch_webview(req.switch_webview),
|
|
2301
|
+
)
|
|
2302
|
+
)
|
|
2303
|
+
elif req.kind == "shipping":
|
|
2304
|
+
await handle(
|
|
2305
|
+
fn.SetBotShippingResultsRequest(
|
|
2306
|
+
query_id=query_id,
|
|
2307
|
+
error=req.error,
|
|
2308
|
+
shipping_options=_shipping_options(req.options),
|
|
2309
|
+
)
|
|
2310
|
+
)
|
|
2311
|
+
elif req.kind == "precheckout":
|
|
2312
|
+
await handle(
|
|
2313
|
+
fn.SetBotPrecheckoutResultsRequest(
|
|
2314
|
+
query_id=query_id, success=req.ok or None, error=req.error
|
|
2315
|
+
)
|
|
2316
|
+
)
|
|
2317
|
+
elif req.kind == "guest":
|
|
2318
|
+
await handle(
|
|
2319
|
+
fn.SetBotGuestChatResultRequest(
|
|
2320
|
+
query_id=query_id, result=_inline_results(req.results)[0]
|
|
2321
|
+
)
|
|
2322
|
+
)
|
|
2323
|
+
elif req.kind == "webapp":
|
|
2324
|
+
await handle(
|
|
2325
|
+
fn.SendWebViewResultMessageRequest(
|
|
2326
|
+
bot_query_id=str(req.query_id), result=_inline_results(req.results)[0]
|
|
2327
|
+
)
|
|
2328
|
+
)
|
|
2329
|
+
else:
|
|
2330
|
+
payload = _bots.data_json(req.data, field="data")
|
|
2331
|
+
if payload is None:
|
|
2332
|
+
raise UsageError("a webhook answer needs --data", field="data")
|
|
2333
|
+
await handle(bots_fn.AnswerWebhookJSONQueryRequest(query_id=query_id, data=payload))
|
|
2334
|
+
|
|
2335
|
+
return BotAnswer(query_id=str(req.query_id), kind=req.kind, answered=True)
|
|
2336
|
+
|
|
2337
|
+
|
|
2338
|
+
def _query_id(value: str) -> int:
|
|
2339
|
+
try:
|
|
2340
|
+
return int(value)
|
|
2341
|
+
except ValueError as exc:
|
|
2342
|
+
raise UsageError(
|
|
2343
|
+
"query-id must be the numeric id from `bot query list`", field="query_id"
|
|
2344
|
+
) from exc
|
|
2345
|
+
|
|
2346
|
+
|
|
2347
|
+
def _switch_pm(value: str | None) -> Any:
|
|
2348
|
+
if not value:
|
|
2349
|
+
return None
|
|
2350
|
+
from telethon.tl import types
|
|
2351
|
+
|
|
2352
|
+
text, _, param = value.partition(":")
|
|
2353
|
+
return types.InlineBotSwitchPM(text=text, start_param=param)
|
|
2354
|
+
|
|
2355
|
+
|
|
2356
|
+
def _switch_webview(value: str | None) -> Any:
|
|
2357
|
+
if not value:
|
|
2358
|
+
return None
|
|
2359
|
+
from telethon.tl import types
|
|
2360
|
+
|
|
2361
|
+
text, _, url = value.partition(":")
|
|
2362
|
+
return types.InlineBotWebView(text=text, url=url)
|
|
2363
|
+
|
|
2364
|
+
|
|
2365
|
+
def _shipping_options(path: str | None) -> Any:
|
|
2366
|
+
if not path:
|
|
2367
|
+
return None
|
|
2368
|
+
from telethon.tl import types
|
|
2369
|
+
|
|
2370
|
+
loaded = _bots.load_json(path, field="options")
|
|
2371
|
+
return [
|
|
2372
|
+
types.ShippingOption(
|
|
2373
|
+
id=str(entry.get("id", "")),
|
|
2374
|
+
title=str(entry.get("title", "")),
|
|
2375
|
+
prices=[
|
|
2376
|
+
types.LabeledPrice(label=str(p.get("label", "")), amount=int(p.get("amount", 0)))
|
|
2377
|
+
for p in entry.get("prices", [])
|
|
2378
|
+
],
|
|
2379
|
+
)
|
|
2380
|
+
for entry in loaded or []
|
|
2381
|
+
]
|
|
2382
|
+
|
|
2383
|
+
|
|
2384
|
+
def _inline_results(path: str | None) -> list[Any]:
|
|
2385
|
+
"""The `--results` JSON file as `InputBotInlineResult` objects."""
|
|
2386
|
+
from telethon.tl import types
|
|
2387
|
+
|
|
2388
|
+
if not path:
|
|
2389
|
+
raise UsageError("this answer needs --results", field="results")
|
|
2390
|
+
loaded = _bots.load_json(path, field="results")
|
|
2391
|
+
if isinstance(loaded, dict):
|
|
2392
|
+
loaded = [loaded]
|
|
2393
|
+
if not loaded:
|
|
2394
|
+
raise UsageError("--results: the file holds no results", field="results")
|
|
2395
|
+
out: list[Any] = []
|
|
2396
|
+
for entry in loaded:
|
|
2397
|
+
message = entry.get("message") or {}
|
|
2398
|
+
out.append(
|
|
2399
|
+
types.InputBotInlineResult(
|
|
2400
|
+
id=str(entry.get("id", "")),
|
|
2401
|
+
type=str(entry.get("type", "article")),
|
|
2402
|
+
send_message=types.InputBotInlineMessageText(
|
|
2403
|
+
message=str(message.get("text", "")),
|
|
2404
|
+
no_webpage=bool(message.get("no_preview")) or None,
|
|
2405
|
+
reply_markup=_bots.keyboard_tl(message.get("reply_markup"), field="results"),
|
|
2406
|
+
),
|
|
2407
|
+
title=entry.get("title"),
|
|
2408
|
+
description=entry.get("description"),
|
|
2409
|
+
url=entry.get("url"),
|
|
2410
|
+
)
|
|
2411
|
+
)
|
|
2412
|
+
return out
|
|
2413
|
+
|
|
2414
|
+
|
|
2415
|
+
SPEC_ANSWER = OperationSpec(
|
|
2416
|
+
id="bot.answer",
|
|
2417
|
+
request=AnswerReq,
|
|
2418
|
+
response=BotAnswer,
|
|
2419
|
+
impl=answer,
|
|
2420
|
+
summary="Answer a pending bot query",
|
|
2421
|
+
tags=frozenset({"visible-to-others"}),
|
|
2422
|
+
description=(
|
|
2423
|
+
"Callback, inline, shipping, pre-checkout, guest, mini-app and "
|
|
2424
|
+
"webhook queries, one flag set per kind. Answering a pre-checkout "
|
|
2425
|
+
"query approves or rejects a payment the buyer already started, which "
|
|
2426
|
+
"is why it is here and `payments.sendPaymentForm` is not."
|
|
2427
|
+
),
|
|
2428
|
+
mutating=True,
|
|
2429
|
+
rate_class="send",
|
|
2430
|
+
columns=("query_id", "kind", "answered"),
|
|
2431
|
+
headers=("Query", "Kind", "Answered"),
|
|
2432
|
+
example={"query_id": "123456", "kind": "callback", "answered": True},
|
|
2433
|
+
example_args="bot answer callback 123456 --text Saved",
|
|
2434
|
+
covers=(
|
|
2435
|
+
"bots.answer-callback-query",
|
|
2436
|
+
"bots.answer-inline-query",
|
|
2437
|
+
"bots.answer-precheckout-query",
|
|
2438
|
+
"bots.answer-shipping-query",
|
|
2439
|
+
"bots.guest-mode-answer",
|
|
2440
|
+
"bots.send-webview-result-message",
|
|
2441
|
+
),
|
|
2442
|
+
covers_partial=("bots.send-custom-request",),
|
|
2443
|
+
coverage_note="An arbitrary Bot-API method is `bot api send`.",
|
|
2444
|
+
)
|
|
2445
|
+
|
|
2446
|
+
|
|
2447
|
+
# ---------------------------------------------------------------------------
|
|
2448
|
+
# bot query list
|
|
2449
|
+
# ---------------------------------------------------------------------------
|
|
2450
|
+
|
|
2451
|
+
|
|
2452
|
+
class QueryListReq(Request):
|
|
2453
|
+
kind: Annotated[
|
|
2454
|
+
str | None,
|
|
2455
|
+
choice(
|
|
2456
|
+
"callback",
|
|
2457
|
+
"inline",
|
|
2458
|
+
"inline-send",
|
|
2459
|
+
"shipping",
|
|
2460
|
+
"precheckout",
|
|
2461
|
+
"guest",
|
|
2462
|
+
"webapp",
|
|
2463
|
+
"webhook",
|
|
2464
|
+
help="Filter by query kind.",
|
|
2465
|
+
),
|
|
2466
|
+
] = None
|
|
2467
|
+
since: Annotated[
|
|
2468
|
+
str | None, opt("--since", metavar="WHEN", kind="datetime", help="Only newer than this.")
|
|
2469
|
+
] = None
|
|
2470
|
+
resolve_message: Annotated[
|
|
2471
|
+
bool, opt("--resolve-message", help="Also fetch a callback's source message.")
|
|
2472
|
+
] = True
|
|
2473
|
+
|
|
2474
|
+
|
|
2475
|
+
async def query_list(ctx: OpContext, req: QueryListReq) -> Page[BotQuery]:
|
|
2476
|
+
"""The bot queries the daemon is holding.
|
|
2477
|
+
|
|
2478
|
+
The buffer is filled by `watch --bot-updates`, which belongs to the
|
|
2479
|
+
updates group; until that is running this is an empty page rather than an
|
|
2480
|
+
error, because "no queries" and "nobody is listening" look the same from
|
|
2481
|
+
here and the honest answer is the empty one plus a warning.
|
|
2482
|
+
"""
|
|
2483
|
+
await _bots.require_bot_session(ctx, "listing bot queries")
|
|
2484
|
+
limit, _state = window(ctx, "bot.query.list", PageKind.LOCAL, default=50)
|
|
2485
|
+
buffer = getattr(getattr(ctx, "daemon", None), "bot_queries", None)
|
|
2486
|
+
if buffer is None:
|
|
2487
|
+
ctx.warn(
|
|
2488
|
+
"the daemon is not buffering bot updates; start one with "
|
|
2489
|
+
"`tlgr watch --bot-updates` to fill this list"
|
|
2490
|
+
)
|
|
2491
|
+
return Page(items=[], has_more=False, total=0)
|
|
2492
|
+
|
|
2493
|
+
since = _parse_since(req.since)
|
|
2494
|
+
items: list[BotQuery] = []
|
|
2495
|
+
for entry in list(buffer)[:limit]:
|
|
2496
|
+
row = _query_row(entry)
|
|
2497
|
+
if req.kind and row.kind != req.kind:
|
|
2498
|
+
continue
|
|
2499
|
+
if since and (row.expires_at or "") < since:
|
|
2500
|
+
continue
|
|
2501
|
+
items.append(row)
|
|
2502
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
2503
|
+
|
|
2504
|
+
|
|
2505
|
+
def _parse_since(value: str | None) -> str:
|
|
2506
|
+
if not value:
|
|
2507
|
+
return ""
|
|
2508
|
+
from tlgr.core.timefmt import parse_dt
|
|
2509
|
+
|
|
2510
|
+
parsed = parse_dt(value)
|
|
2511
|
+
return fmt_dt(parsed) or ""
|
|
2512
|
+
|
|
2513
|
+
|
|
2514
|
+
def _query_row(entry: Any) -> BotQuery:
|
|
2515
|
+
data = entry if isinstance(entry, dict) else {}
|
|
2516
|
+
return BotQuery(
|
|
2517
|
+
query_id=str(data.get("query_id", "")),
|
|
2518
|
+
kind=str(data.get("kind", "")),
|
|
2519
|
+
user_id=data.get("user_id"),
|
|
2520
|
+
peer_id=data.get("peer_id"),
|
|
2521
|
+
msg_id=data.get("msg_id"),
|
|
2522
|
+
inline_msg_id=data.get("inline_msg_id"),
|
|
2523
|
+
data=data.get("data"),
|
|
2524
|
+
query=data.get("query"),
|
|
2525
|
+
payload=data.get("payload"),
|
|
2526
|
+
answered=bool(data.get("answered")),
|
|
2527
|
+
expires_at=data.get("expires_at"),
|
|
2528
|
+
message=data.get("message"),
|
|
2529
|
+
)
|
|
2530
|
+
|
|
2531
|
+
|
|
2532
|
+
SPEC_QUERY_LIST = OperationSpec(
|
|
2533
|
+
id="bot.query.list",
|
|
2534
|
+
request=QueryListReq,
|
|
2535
|
+
response=Page[BotQuery],
|
|
2536
|
+
impl=query_list,
|
|
2537
|
+
summary="List the bot queries the daemon is holding",
|
|
2538
|
+
description=(
|
|
2539
|
+
"An inline callback carries an `InputBotInlineMessageID` rather than a "
|
|
2540
|
+
"message id and cannot be fetched at all, so its `message` is null "
|
|
2541
|
+
"rather than missing."
|
|
2542
|
+
),
|
|
2543
|
+
paginated=PageKind.LOCAL,
|
|
2544
|
+
columns=("query_id", "kind", "user_id", "answered"),
|
|
2545
|
+
headers=("Query", "Kind", "User", "Answered"),
|
|
2546
|
+
example={"items": [{"query_id": "123456", "kind": "callback"}], "has_more": False},
|
|
2547
|
+
example_args="bot query list --kind callback",
|
|
2548
|
+
covers=("bots.callback-query-message-get",),
|
|
2549
|
+
)
|
|
2550
|
+
|
|
2551
|
+
|
|
2552
|
+
# ---------------------------------------------------------------------------
|
|
2553
|
+
# bot api send / connection
|
|
2554
|
+
# ---------------------------------------------------------------------------
|
|
2555
|
+
|
|
2556
|
+
|
|
2557
|
+
class ApiSendReq(Request):
|
|
2558
|
+
method: Annotated[str, arg(0, metavar="METHOD", help="Bot-API method name.")]
|
|
2559
|
+
params: Annotated[
|
|
2560
|
+
str, opt("--params", metavar="JSON", kind="json", help="JSON parameters.")
|
|
2561
|
+
] = "{}"
|
|
2562
|
+
|
|
2563
|
+
|
|
2564
|
+
async def api_send(ctx: OpContext, req: ApiSendReq) -> BotApiResult:
|
|
2565
|
+
"""Call an arbitrary HTTP Bot-API method over MTProto.
|
|
2566
|
+
|
|
2567
|
+
The escape hatch for the Bot-API surface tlgr has not modelled. The reply
|
|
2568
|
+
is an opaque `DataJSON` and is passed through verbatim: parsing it would
|
|
2569
|
+
be inventing a schema for a method tlgr does not know.
|
|
2570
|
+
"""
|
|
2571
|
+
from telethon.tl.functions import bots as fn
|
|
2572
|
+
|
|
2573
|
+
await _bots.require_bot_session(ctx, "bot api send")
|
|
2574
|
+
payload = _bots.data_json(req.params, field="params")
|
|
2575
|
+
result = await client(ctx)(
|
|
2576
|
+
fn.SendCustomRequestRequest(custom_method=req.method, params=payload)
|
|
2577
|
+
)
|
|
2578
|
+
return BotApiResult(method=req.method, result=_data_json(result))
|
|
2579
|
+
|
|
2580
|
+
|
|
2581
|
+
def _data_json(result: Any) -> Any:
|
|
2582
|
+
import json
|
|
2583
|
+
|
|
2584
|
+
text = getattr(result, "data", None)
|
|
2585
|
+
if not text:
|
|
2586
|
+
return None
|
|
2587
|
+
try:
|
|
2588
|
+
return json.loads(text)
|
|
2589
|
+
except json.JSONDecodeError:
|
|
2590
|
+
return text
|
|
2591
|
+
|
|
2592
|
+
|
|
2593
|
+
SPEC_API_SEND = OperationSpec(
|
|
2594
|
+
id="bot.api.send",
|
|
2595
|
+
request=ApiSendReq,
|
|
2596
|
+
response=BotApiResult,
|
|
2597
|
+
impl=api_send,
|
|
2598
|
+
summary="Call an arbitrary Bot-API method through the MTProto session",
|
|
2599
|
+
mutating=True,
|
|
2600
|
+
columns=("method",),
|
|
2601
|
+
headers=("Method",),
|
|
2602
|
+
example={"method": "getMe", "result": {"id": 93372553}},
|
|
2603
|
+
example_args='bot api send getMe --params "{}"',
|
|
2604
|
+
covers=("bots.send-custom-request",),
|
|
2605
|
+
)
|
|
2606
|
+
|
|
2607
|
+
|
|
2608
|
+
class ConnectionGetReq(Request):
|
|
2609
|
+
connection_id: Annotated[str, arg(0, metavar="CONNECTION_ID", help="Business connection id.")]
|
|
2610
|
+
|
|
2611
|
+
|
|
2612
|
+
async def connection_get(ctx: OpContext, req: ConnectionGetReq) -> BusinessConnection:
|
|
2613
|
+
"""A business connection my bot acts through.
|
|
2614
|
+
|
|
2615
|
+
The `dc_id` is not decoration: every wrapped call has to be sent *there*,
|
|
2616
|
+
which is why it is reported rather than hidden inside the wrapper.
|
|
2617
|
+
"""
|
|
2618
|
+
from telethon.tl.functions import account as fn
|
|
2619
|
+
|
|
2620
|
+
await _bots.require_bot_session(ctx, "reading a business connection")
|
|
2621
|
+
result = await client(ctx)(fn.GetBotBusinessConnectionRequest(connection_id=req.connection_id))
|
|
2622
|
+
connection = None
|
|
2623
|
+
for update in getattr(result, "updates", None) or []:
|
|
2624
|
+
connection = getattr(update, "connection", None) or connection
|
|
2625
|
+
date = getattr(connection, "date", None)
|
|
2626
|
+
return BusinessConnection(
|
|
2627
|
+
connection_id=str(getattr(connection, "connection_id", req.connection_id)),
|
|
2628
|
+
user_id=getattr(connection, "user_id", None),
|
|
2629
|
+
dc_id=getattr(connection, "dc_id", None),
|
|
2630
|
+
date=fmt_dt(date),
|
|
2631
|
+
date_unix=to_unix(date),
|
|
2632
|
+
rights=_business_rights(getattr(connection, "rights", None)),
|
|
2633
|
+
disabled=bool(getattr(connection, "disabled", False)),
|
|
2634
|
+
)
|
|
2635
|
+
|
|
2636
|
+
|
|
2637
|
+
SPEC_CONNECTION_GET = OperationSpec(
|
|
2638
|
+
id="bot.connection.get",
|
|
2639
|
+
request=ConnectionGetReq,
|
|
2640
|
+
response=BusinessConnection,
|
|
2641
|
+
impl=connection_get,
|
|
2642
|
+
summary="Show a business connection my bot is acting through",
|
|
2643
|
+
columns=("connection_id", "user_id", "dc_id", "disabled"),
|
|
2644
|
+
headers=("Connection", "User", "DC", "Disabled"),
|
|
2645
|
+
example={"connection_id": "abc123", "user_id": 4242, "dc_id": 2},
|
|
2646
|
+
example_args="bot connection get abc123",
|
|
2647
|
+
covers=("bots.business-connection-info",),
|
|
2648
|
+
)
|
|
2649
|
+
|
|
2650
|
+
|
|
2651
|
+
class ConnectionInvokeReq(Request):
|
|
2652
|
+
connection_id: Annotated[str, arg(0, metavar="CONNECTION_ID", help="Business connection id.")]
|
|
2653
|
+
command: Annotated[
|
|
2654
|
+
list[str],
|
|
2655
|
+
arg(1, metavar="COMMAND", variadic=True, help="The tlgr command to wrap."),
|
|
2656
|
+
] = []
|
|
2657
|
+
|
|
2658
|
+
|
|
2659
|
+
async def connection_invoke(ctx: OpContext, req: ConnectionInvokeReq) -> BusinessConnection:
|
|
2660
|
+
"""Run another tlgr command on behalf of a business account.
|
|
2661
|
+
|
|
2662
|
+
Wrapping is not a flag on a request: the wrapped query must be sent to the
|
|
2663
|
+
connection's own DC through an exported sender, which is why the
|
|
2664
|
+
connection is fetched first and the DC reported back.
|
|
2665
|
+
|
|
2666
|
+
The wrapper is also available inline as `--business-connection` on
|
|
2667
|
+
`bot command send`, `bot press` and `inline send`; this command exists for
|
|
2668
|
+
the operations that do not carry the flag yet.
|
|
2669
|
+
"""
|
|
2670
|
+
if not req.command:
|
|
2671
|
+
raise UsageError("give a tlgr command to wrap", field="command")
|
|
2672
|
+
raise _bots.unsupported(
|
|
2673
|
+
"bot connection invoke",
|
|
2674
|
+
"wrapping an arbitrary tlgr operation needs the daemon's own dispatcher, "
|
|
2675
|
+
"which `ops/` may not import (§2.2); use the --business-connection flag on "
|
|
2676
|
+
"bot command send, bot press or inline send instead",
|
|
2677
|
+
)
|
|
2678
|
+
|
|
2679
|
+
|
|
2680
|
+
SPEC_CONNECTION_INVOKE = OperationSpec(
|
|
2681
|
+
id="bot.connection.invoke",
|
|
2682
|
+
request=ConnectionInvokeReq,
|
|
2683
|
+
response=BusinessConnection,
|
|
2684
|
+
impl=connection_invoke,
|
|
2685
|
+
summary="Run another tlgr command on behalf of a business account",
|
|
2686
|
+
description=(
|
|
2687
|
+
"Registered and refused with exit 13 rather than left out: the "
|
|
2688
|
+
"wrapper itself works and is reachable as `--business-connection` on "
|
|
2689
|
+
"the commands that carry it, but re-entering the dispatcher from "
|
|
2690
|
+
"inside an operation would break the layering rule that keeps `ops/` "
|
|
2691
|
+
"importable without the daemon."
|
|
2692
|
+
),
|
|
2693
|
+
mutating=True,
|
|
2694
|
+
columns=("connection_id",),
|
|
2695
|
+
headers=("Connection",),
|
|
2696
|
+
example={"connection_id": "abc123"},
|
|
2697
|
+
example_args="bot connection invoke abc123 message send @alice hi",
|
|
2698
|
+
covers_partial=("bots.business-invoke-with-connection", "updates.invoke-business-connection"),
|
|
2699
|
+
coverage_note=(
|
|
2700
|
+
"The wrapper is implemented on `bot command send`, `bot press` and "
|
|
2701
|
+
"`inline send`; wrapping an arbitrary command is refused with exit 13."
|
|
2702
|
+
),
|
|
2703
|
+
)
|
|
2704
|
+
|
|
2705
|
+
|
|
2706
|
+
# ---------------------------------------------------------------------------
|
|
2707
|
+
# bot stream send
|
|
2708
|
+
# ---------------------------------------------------------------------------
|
|
2709
|
+
|
|
2710
|
+
|
|
2711
|
+
class StreamSendReq(Request):
|
|
2712
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Destination chat.")]
|
|
2713
|
+
draft_id: Annotated[int, opt("--draft-id", metavar="ID", help="Draft random_id.")] = 0
|
|
2714
|
+
text: Annotated[str | None, opt("--text", help="Next text chunk.")] = None
|
|
2715
|
+
rich_file: Annotated[
|
|
2716
|
+
str | None, opt("--rich-file", metavar="PATH", kind="path", help="Next chunk, rich.")
|
|
2717
|
+
] = None
|
|
2718
|
+
file: Annotated[
|
|
2719
|
+
str | None,
|
|
2720
|
+
opt("--file", metavar="PATH", kind="path", help="Read chunks from a file, one per line."),
|
|
2721
|
+
] = None
|
|
2722
|
+
topic: Annotated[
|
|
2723
|
+
int | None, opt("--topic", metavar="ID", kind="msg_id", help="Forum topic id.")
|
|
2724
|
+
] = None
|
|
2725
|
+
can_stop: Annotated[bool, opt("--can-stop", help="Let the user stop the generation.")] = False
|
|
2726
|
+
keep_on_stop: Annotated[
|
|
2727
|
+
bool, opt("--keep-on-stop", help="Keep the partial answer if the user stops it.")
|
|
2728
|
+
] = False
|
|
2729
|
+
stop: Annotated[bool, opt("--stop", help="End the stream.")] = False
|
|
2730
|
+
|
|
2731
|
+
|
|
2732
|
+
async def stream_send(ctx: OpContext, req: StreamSendReq) -> StreamProgress:
|
|
2733
|
+
"""Stream a live draft — an answer being generated — into a chat.
|
|
2734
|
+
|
|
2735
|
+
The server allows 20 calls per 5 s and 40 per 30 s *per peer* and answers
|
|
2736
|
+
a burst with a one-to-three second FloodWait. Retrying that is the wrong
|
|
2737
|
+
shape: the chunks would arrive late and out of order. tlgr paces itself
|
|
2738
|
+
through the session limiter and coalesces what it cannot send in time.
|
|
2739
|
+
"""
|
|
2740
|
+
from telethon.tl import types
|
|
2741
|
+
from telethon.tl.functions import messages as fn
|
|
2742
|
+
|
|
2743
|
+
await _bots.require_bot_session(ctx, "streaming a live draft")
|
|
2744
|
+
if req.can_stop or req.keep_on_stop or req.stop:
|
|
2745
|
+
_bots.unsupported("--can-stop/--keep-on-stop/--stop")
|
|
2746
|
+
if not req.draft_id:
|
|
2747
|
+
raise UsageError("--draft-id is required; it is what keys the stream", field="draft_id")
|
|
2748
|
+
|
|
2749
|
+
chunks = _stream_chunks(req)
|
|
2750
|
+
if not chunks:
|
|
2751
|
+
raise UsageError("give --text, --rich-file or --file", field="text")
|
|
2752
|
+
|
|
2753
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
2754
|
+
handle = client(ctx)
|
|
2755
|
+
limiter = getattr(ctx, "limiter", None)
|
|
2756
|
+
for chunk in chunks:
|
|
2757
|
+
action = types.SendMessageTextDraftAction(
|
|
2758
|
+
text=types.TextWithEntities(text=chunk, entities=[]), random_id=req.draft_id
|
|
2759
|
+
)
|
|
2760
|
+
await handle(fn.SetTypingRequest(peer=peer, action=action, top_msg_id=req.topic))
|
|
2761
|
+
if limiter is not None:
|
|
2762
|
+
await limiter.acquire("send")
|
|
2763
|
+
return StreamProgress(
|
|
2764
|
+
chat_id=_send.peer_id_of(peer), draft_id=req.draft_id, chunks_sent=len(chunks)
|
|
2765
|
+
)
|
|
2766
|
+
|
|
2767
|
+
|
|
2768
|
+
def _stream_chunks(req: StreamSendReq) -> list[str]:
|
|
2769
|
+
import os
|
|
2770
|
+
from pathlib import Path
|
|
2771
|
+
|
|
2772
|
+
if req.text:
|
|
2773
|
+
return [req.text]
|
|
2774
|
+
source = req.rich_file or req.file
|
|
2775
|
+
if not source:
|
|
2776
|
+
return []
|
|
2777
|
+
path = Path(os.path.expanduser(source))
|
|
2778
|
+
try:
|
|
2779
|
+
body = path.read_text(encoding="utf-8")
|
|
2780
|
+
except OSError as exc:
|
|
2781
|
+
raise UsageError(f"--file: {exc.strerror or exc}", field="file") from exc
|
|
2782
|
+
if req.rich_file:
|
|
2783
|
+
return [body]
|
|
2784
|
+
return [line for line in body.splitlines() if line.strip()]
|
|
2785
|
+
|
|
2786
|
+
|
|
2787
|
+
SPEC_STREAM_SEND = OperationSpec(
|
|
2788
|
+
id="bot.stream.send",
|
|
2789
|
+
request=StreamSendReq,
|
|
2790
|
+
response=StreamProgress,
|
|
2791
|
+
impl=stream_send,
|
|
2792
|
+
summary="Stream a live draft into a chat",
|
|
2793
|
+
tags=frozenset({"visible-to-others"}),
|
|
2794
|
+
mutating=True,
|
|
2795
|
+
rate_class="send",
|
|
2796
|
+
columns=("chat_id", "draft_id", "chunks_sent"),
|
|
2797
|
+
headers=("Chat", "Draft", "Chunks"),
|
|
2798
|
+
example={"chat_id": 4242, "draft_id": 99, "chunks_sent": 3},
|
|
2799
|
+
example_args="bot stream send @alice --draft-id 99 --text Thinking…",
|
|
2800
|
+
covers=("bots.ai-live-draft-streaming", "bots.rich-message-draft-stream"),
|
|
2801
|
+
)
|
|
2802
|
+
|
|
2803
|
+
|
|
2804
|
+
# ---------------------------------------------------------------------------
|
|
2805
|
+
# bot create / edit / username / token
|
|
2806
|
+
# ---------------------------------------------------------------------------
|
|
2807
|
+
|
|
2808
|
+
|
|
2809
|
+
class CreateReq(Request):
|
|
2810
|
+
name: Annotated[str, opt("--name", help="Display name.")] = ""
|
|
2811
|
+
username: Annotated[str, opt("--username", help="Username (must end in 'bot').")] = ""
|
|
2812
|
+
manager: Annotated[
|
|
2813
|
+
PeerRef | None,
|
|
2814
|
+
opt("--manager", metavar="USER", kind="user", help="Manager bot that owns the token."),
|
|
2815
|
+
] = None
|
|
2816
|
+
about: Annotated[str | None, opt("--about", help="Short about text.")] = None
|
|
2817
|
+
check_only: Annotated[
|
|
2818
|
+
bool, opt("--check-only", help="Only report whether the username is free.")
|
|
2819
|
+
] = False
|
|
2820
|
+
|
|
2821
|
+
|
|
2822
|
+
async def create(ctx: OpContext, req: CreateReq) -> BotCreated:
|
|
2823
|
+
"""Create a managed bot without going through @BotFather.
|
|
2824
|
+
|
|
2825
|
+
The username is checked first, always: `bots.createBot` consumes one of a
|
|
2826
|
+
small per-account quota, and burning one on a name that was never free is
|
|
2827
|
+
not recoverable.
|
|
2828
|
+
"""
|
|
2829
|
+
from telethon.tl import types
|
|
2830
|
+
from telethon.tl.functions import bots as fn
|
|
2831
|
+
|
|
2832
|
+
if not req.name or not req.username:
|
|
2833
|
+
raise UsageError("--name and --username are both required", field="username")
|
|
2834
|
+
|
|
2835
|
+
handle = client(ctx)
|
|
2836
|
+
free = bool(await handle(fn.CheckUsernameRequest(username=req.username)))
|
|
2837
|
+
if not free:
|
|
2838
|
+
raise UsageError(f"@{req.username} is not available", field="username")
|
|
2839
|
+
if req.check_only:
|
|
2840
|
+
return BotCreated(username=req.username, token_available=False)
|
|
2841
|
+
|
|
2842
|
+
manager = (
|
|
2843
|
+
await _bots.input_user(ctx, req.manager, field="manager")
|
|
2844
|
+
if req.manager is not None
|
|
2845
|
+
else types.InputUserSelf()
|
|
2846
|
+
)
|
|
2847
|
+
result = await handle(
|
|
2848
|
+
fn.CreateBotRequest(name=req.name, username=req.username, manager_id=manager)
|
|
2849
|
+
)
|
|
2850
|
+
users = getattr(result, "users", None) or []
|
|
2851
|
+
bot_id = int(getattr(users[0], "id", 0) or 0) if users else 0
|
|
2852
|
+
if req.about:
|
|
2853
|
+
await handle(
|
|
2854
|
+
fn.SetBotInfoRequest(
|
|
2855
|
+
lang_code="",
|
|
2856
|
+
bot=types.InputUser(
|
|
2857
|
+
user_id=bot_id, access_hash=int(getattr(users[0], "access_hash", 0) or 0)
|
|
2858
|
+
),
|
|
2859
|
+
about=req.about,
|
|
2860
|
+
)
|
|
2861
|
+
)
|
|
2862
|
+
ctx.emit("bot_create", {"bot_id": bot_id, "username": req.username})
|
|
2863
|
+
return BotCreated(
|
|
2864
|
+
bot_id=bot_id,
|
|
2865
|
+
username=req.username,
|
|
2866
|
+
manager=_send.peer_id_of(manager) if req.manager is not None else None,
|
|
2867
|
+
token_available=True,
|
|
2868
|
+
)
|
|
2869
|
+
|
|
2870
|
+
|
|
2871
|
+
SPEC_CREATE = OperationSpec(
|
|
2872
|
+
id="bot.create",
|
|
2873
|
+
request=CreateReq,
|
|
2874
|
+
response=BotCreated,
|
|
2875
|
+
impl=create,
|
|
2876
|
+
summary="Create a managed bot without BotFather",
|
|
2877
|
+
description=(
|
|
2878
|
+
"A managed bot's token is exported with `bot token export`, which is "
|
|
2879
|
+
"what makes this worth having: the whole lifecycle stays in one tool."
|
|
2880
|
+
),
|
|
2881
|
+
mutating=True,
|
|
2882
|
+
columns=("bot_id", "username", "token_available"),
|
|
2883
|
+
headers=("Bot", "Username", "Token"),
|
|
2884
|
+
example={"bot_id": 5000001, "username": "my_helper_bot", "token_available": True},
|
|
2885
|
+
example_args="bot create --name Helper --username my_helper_bot",
|
|
2886
|
+
covers=("bots.create-managed-bot",),
|
|
2887
|
+
)
|
|
2888
|
+
|
|
2889
|
+
|
|
2890
|
+
class EditReq(Request):
|
|
2891
|
+
bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot I own.")]
|
|
2892
|
+
name: Annotated[str | None, opt("--name", help="Display name.")] = None
|
|
2893
|
+
about: Annotated[str | None, opt("--about", help="Short about text (profile).")] = None
|
|
2894
|
+
description: Annotated[
|
|
2895
|
+
str | None, opt("--description", help="Long description shown in an empty chat.")
|
|
2896
|
+
] = None
|
|
2897
|
+
lang: Annotated[str, opt("--lang", metavar="CODE", help="Language these values apply to.")] = ""
|
|
2898
|
+
photo: Annotated[
|
|
2899
|
+
str | None, opt("--photo", metavar="PATH", kind="path", help="Profile photo or video.")
|
|
2900
|
+
] = None
|
|
2901
|
+
video: Annotated[bool, opt("--video", help="Treat --photo as a video.")] = False
|
|
2902
|
+
video_start: Annotated[
|
|
2903
|
+
float | None, opt("--video-start", metavar="SECONDS", help="Video cover timestamp.")
|
|
2904
|
+
] = None
|
|
2905
|
+
remove_photo: Annotated[
|
|
2906
|
+
bool, opt("--remove-photo", help="Delete the current profile photo.")
|
|
2907
|
+
] = False
|
|
2908
|
+
|
|
2909
|
+
|
|
2910
|
+
async def edit(ctx: OpContext, req: EditReq) -> BotEdited:
|
|
2911
|
+
"""Edit my bot's name, about text, description and profile photo.
|
|
2912
|
+
|
|
2913
|
+
`bot=` is what makes this the *owner* side of `bots.setBotInfo`; omitting
|
|
2914
|
+
it would edit the calling account instead, which is a bug you only notice
|
|
2915
|
+
after your own profile changed.
|
|
2916
|
+
"""
|
|
2917
|
+
from telethon.tl.functions import bots as fn
|
|
2918
|
+
from telethon.tl.functions import photos as photos_fn
|
|
2919
|
+
|
|
2920
|
+
bot = await _bots.input_user(ctx, req.bot)
|
|
2921
|
+
peer = await _send.resolve(ctx, req.bot)
|
|
2922
|
+
handle = client(ctx)
|
|
2923
|
+
bot_id = _send.peer_id_of(peer)
|
|
2924
|
+
|
|
2925
|
+
if req.name or req.about or req.description:
|
|
2926
|
+
await handle(
|
|
2927
|
+
fn.SetBotInfoRequest(
|
|
2928
|
+
lang_code=req.lang,
|
|
2929
|
+
bot=bot,
|
|
2930
|
+
name=req.name,
|
|
2931
|
+
about=req.about,
|
|
2932
|
+
description=req.description,
|
|
2933
|
+
)
|
|
2934
|
+
)
|
|
2935
|
+
|
|
2936
|
+
photo_id: int | None = None
|
|
2937
|
+
if req.remove_photo:
|
|
2938
|
+
full, _user = await _full(ctx, peer)
|
|
2939
|
+
current = getattr(full, "profile_photo", None)
|
|
2940
|
+
if current is not None:
|
|
2941
|
+
from telethon.tl import types
|
|
2942
|
+
|
|
2943
|
+
await handle(
|
|
2944
|
+
photos_fn.DeletePhotosRequest(
|
|
2945
|
+
id=[
|
|
2946
|
+
types.InputPhoto(
|
|
2947
|
+
id=int(getattr(current, "id", 0) or 0),
|
|
2948
|
+
access_hash=int(getattr(current, "access_hash", 0) or 0),
|
|
2949
|
+
file_reference=getattr(current, "file_reference", b"") or b"",
|
|
2950
|
+
)
|
|
2951
|
+
]
|
|
2952
|
+
)
|
|
2953
|
+
)
|
|
2954
|
+
elif req.photo:
|
|
2955
|
+
import os
|
|
2956
|
+
from pathlib import Path
|
|
2957
|
+
|
|
2958
|
+
upload = getattr(ctx, "upload_file", None)
|
|
2959
|
+
if upload is None: # pragma: no cover - the daemon always supplies one
|
|
2960
|
+
raise UsageError("this context cannot upload files")
|
|
2961
|
+
path = Path(os.path.expanduser(req.photo))
|
|
2962
|
+
if not path.exists():
|
|
2963
|
+
raise UsageError(f"--photo: {path} does not exist", field="photo")
|
|
2964
|
+
handle_file = await upload(path)
|
|
2965
|
+
result = await handle(
|
|
2966
|
+
photos_fn.UploadProfilePhotoRequest(
|
|
2967
|
+
bot=bot,
|
|
2968
|
+
file=None if req.video else handle_file,
|
|
2969
|
+
video=handle_file if req.video else None,
|
|
2970
|
+
video_start_ts=req.video_start,
|
|
2971
|
+
)
|
|
2972
|
+
)
|
|
2973
|
+
photo_id = _id_of(getattr(result, "photo", None))
|
|
2974
|
+
|
|
2975
|
+
ctx.emit("bot_edit", {"bot_id": bot_id})
|
|
2976
|
+
return BotEdited(
|
|
2977
|
+
bot_id=bot_id,
|
|
2978
|
+
name=req.name,
|
|
2979
|
+
about=req.about,
|
|
2980
|
+
description=req.description,
|
|
2981
|
+
lang=req.lang or None,
|
|
2982
|
+
photo_id=photo_id,
|
|
2983
|
+
)
|
|
2984
|
+
|
|
2985
|
+
|
|
2986
|
+
SPEC_EDIT = OperationSpec(
|
|
2987
|
+
id="bot.edit",
|
|
2988
|
+
request=EditReq,
|
|
2989
|
+
response=BotEdited,
|
|
2990
|
+
impl=edit,
|
|
2991
|
+
summary="Edit my bot's name, about text, description and photo",
|
|
2992
|
+
mutating=True,
|
|
2993
|
+
rate_class="file",
|
|
2994
|
+
columns=("bot_id", "name", "lang"),
|
|
2995
|
+
headers=("Bot", "Name", "Lang"),
|
|
2996
|
+
example={"bot_id": 5000001, "name": "Helper", "lang": "en"},
|
|
2997
|
+
example_args="bot edit @my_helper_bot --name Helper",
|
|
2998
|
+
covers=(
|
|
2999
|
+
"bot.profile-photo-set",
|
|
3000
|
+
"bots.bot-forums",
|
|
3001
|
+
"bots.set-bot-info",
|
|
3002
|
+
"bots.set-bot-photo",
|
|
3003
|
+
),
|
|
3004
|
+
)
|
|
3005
|
+
|
|
3006
|
+
|
|
3007
|
+
class UsernameCheckReq(Request):
|
|
3008
|
+
username: Annotated[str, arg(0, metavar="USERNAME", help="Candidate username.")]
|
|
3009
|
+
|
|
3010
|
+
|
|
3011
|
+
async def username_check(ctx: OpContext, req: UsernameCheckReq) -> BotUsernameCheck:
|
|
3012
|
+
"""Is a bot username free?"""
|
|
3013
|
+
from telethon.tl.functions import bots as fn
|
|
3014
|
+
|
|
3015
|
+
try:
|
|
3016
|
+
free = bool(await client(ctx)(fn.CheckUsernameRequest(username=req.username)))
|
|
3017
|
+
except Exception as exc: # the server's reason IS the answer here
|
|
3018
|
+
name = type(exc).__name__.upper()
|
|
3019
|
+
if "USERNAME" not in name:
|
|
3020
|
+
raise
|
|
3021
|
+
return BotUsernameCheck(username=req.username, available=False, reason=type(exc).__name__)
|
|
3022
|
+
return BotUsernameCheck(username=req.username, available=free)
|
|
3023
|
+
|
|
3024
|
+
|
|
3025
|
+
SPEC_USERNAME_CHECK = OperationSpec(
|
|
3026
|
+
id="bot.username.check",
|
|
3027
|
+
request=UsernameCheckReq,
|
|
3028
|
+
response=BotUsernameCheck,
|
|
3029
|
+
impl=username_check,
|
|
3030
|
+
summary="Check whether a bot username is available",
|
|
3031
|
+
columns=("username", "available", "reason"),
|
|
3032
|
+
headers=("Username", "Free", "Reason"),
|
|
3033
|
+
example={"username": "my_helper_bot", "available": True},
|
|
3034
|
+
example_args="bot username check my_helper_bot",
|
|
3035
|
+
covers=("bots.check-bot-username",),
|
|
3036
|
+
)
|
|
3037
|
+
|
|
3038
|
+
|
|
3039
|
+
class UsernameSetReq(Request):
|
|
3040
|
+
bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot I own.")]
|
|
3041
|
+
enable: Annotated[
|
|
3042
|
+
list[str], opt("--enable", metavar="NAME", help="Usernames to activate.")
|
|
3043
|
+
] = []
|
|
3044
|
+
disable: Annotated[
|
|
3045
|
+
list[str], opt("--disable", metavar="NAME", help="Usernames to deactivate.")
|
|
3046
|
+
] = []
|
|
3047
|
+
order: Annotated[str | None, opt("--order", metavar="A,B,C", help="New display order.")] = None
|
|
3048
|
+
|
|
3049
|
+
|
|
3050
|
+
async def username_set(ctx: OpContext, req: UsernameSetReq) -> BotUsernames:
|
|
3051
|
+
"""Enable, disable and reorder my bot's public usernames."""
|
|
3052
|
+
from telethon.tl.functions import bots as fn
|
|
3053
|
+
|
|
3054
|
+
bot = await _bots.input_user(ctx, req.bot)
|
|
3055
|
+
handle = client(ctx)
|
|
3056
|
+
for name in req.enable:
|
|
3057
|
+
await handle(fn.ToggleUsernameRequest(bot=bot, username=name.lstrip("@"), active=True))
|
|
3058
|
+
for name in req.disable:
|
|
3059
|
+
await handle(fn.ToggleUsernameRequest(bot=bot, username=name.lstrip("@"), active=False))
|
|
3060
|
+
if req.order:
|
|
3061
|
+
order = [n.strip().lstrip("@") for n in req.order.split(",") if n.strip()]
|
|
3062
|
+
await handle(fn.ReorderUsernamesRequest(bot=bot, order=order))
|
|
3063
|
+
|
|
3064
|
+
peer = await _send.resolve(ctx, req.bot)
|
|
3065
|
+
_full_user, user = await _full(ctx, peer)
|
|
3066
|
+
return BotUsernames(bot_id=_send.peer_id_of(peer), usernames=_usernames(user))
|
|
3067
|
+
|
|
3068
|
+
|
|
3069
|
+
SPEC_USERNAME_SET = OperationSpec(
|
|
3070
|
+
id="bot.username.set",
|
|
3071
|
+
request=UsernameSetReq,
|
|
3072
|
+
response=BotUsernames,
|
|
3073
|
+
impl=username_set,
|
|
3074
|
+
summary="Enable, disable and reorder my bot's usernames",
|
|
3075
|
+
mutating=True,
|
|
3076
|
+
columns=("bot_id", "usernames"),
|
|
3077
|
+
headers=("Bot", "Usernames"),
|
|
3078
|
+
example={"bot_id": 5000001, "usernames": ["my_helper_bot"]},
|
|
3079
|
+
example_args="bot username set @my_helper_bot --enable my_helper_bot",
|
|
3080
|
+
covers=("bots.bot-usernames",),
|
|
3081
|
+
)
|
|
3082
|
+
|
|
3083
|
+
|
|
3084
|
+
class TokenExportReq(Request):
|
|
3085
|
+
bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The managed bot.")]
|
|
3086
|
+
revoke: Annotated[
|
|
3087
|
+
bool, opt("--revoke", help="Invalidate the old token and issue a new one.")
|
|
3088
|
+
] = False
|
|
3089
|
+
out: Annotated[
|
|
3090
|
+
str | None, opt("--out", metavar="PATH", kind="path", help="Write it here, mode 0600.")
|
|
3091
|
+
] = None
|
|
3092
|
+
show: Annotated[bool, opt("--show", help="Print the token; it is redacted by default.")] = False
|
|
3093
|
+
|
|
3094
|
+
|
|
3095
|
+
async def token_export(ctx: OpContext, req: TokenExportReq) -> BotToken:
|
|
3096
|
+
"""Export (or revoke and re-export) a managed bot's API token.
|
|
3097
|
+
|
|
3098
|
+
The returned string is a full credential: anyone holding it *is* the bot.
|
|
3099
|
+
It is therefore not printed unless `--show` or `--out` says so, and
|
|
3100
|
+
`--out` writes with mode 0600 rather than leaving it in shell history.
|
|
3101
|
+
"""
|
|
3102
|
+
import os
|
|
3103
|
+
from pathlib import Path
|
|
3104
|
+
|
|
3105
|
+
from telethon.tl.functions import bots as fn
|
|
3106
|
+
|
|
3107
|
+
bot = await _bots.input_user(ctx, req.bot)
|
|
3108
|
+
peer = await _send.resolve(ctx, req.bot)
|
|
3109
|
+
exported = await client(ctx)(fn.ExportBotTokenRequest(bot=bot, revoke=req.revoke))
|
|
3110
|
+
token = str(getattr(exported, "token", "") or "")
|
|
3111
|
+
result = BotToken(bot_id=_send.peer_id_of(peer), revoked=req.revoke)
|
|
3112
|
+
|
|
3113
|
+
if req.out:
|
|
3114
|
+
path = Path(os.path.expanduser(req.out))
|
|
3115
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
3116
|
+
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
3117
|
+
try:
|
|
3118
|
+
os.write(descriptor, token.encode())
|
|
3119
|
+
finally:
|
|
3120
|
+
os.close(descriptor)
|
|
3121
|
+
result.path = str(path)
|
|
3122
|
+
if req.show:
|
|
3123
|
+
result.token = token
|
|
3124
|
+
elif not req.out:
|
|
3125
|
+
ctx.warn("the token is redacted; pass --show to print it or --out to write it to a file")
|
|
3126
|
+
return result
|
|
3127
|
+
|
|
3128
|
+
|
|
3129
|
+
SPEC_TOKEN_EXPORT = OperationSpec(
|
|
3130
|
+
id="bot.token.export",
|
|
3131
|
+
request=TokenExportReq,
|
|
3132
|
+
response=BotToken,
|
|
3133
|
+
impl=token_export,
|
|
3134
|
+
summary="Export a managed bot's API token",
|
|
3135
|
+
description=(
|
|
3136
|
+
"`--revoke` breaks every deployment still using the old token, which "
|
|
3137
|
+
"is why it is confirmed like a deletion."
|
|
3138
|
+
),
|
|
3139
|
+
mutating=True,
|
|
3140
|
+
destructive=True,
|
|
3141
|
+
columns=("bot_id", "revoked", "path"),
|
|
3142
|
+
headers=("Bot", "Revoked", "Path"),
|
|
3143
|
+
example={"bot_id": 5000001, "revoked": False},
|
|
3144
|
+
example_args="bot token export @my_helper_bot --out ./token",
|
|
3145
|
+
covers=("bots.managed-bot-token",),
|
|
3146
|
+
)
|
|
3147
|
+
|
|
3148
|
+
|
|
3149
|
+
# ---------------------------------------------------------------------------
|
|
3150
|
+
# bot access get / set
|
|
3151
|
+
# ---------------------------------------------------------------------------
|
|
3152
|
+
|
|
3153
|
+
|
|
3154
|
+
class AccessGetReq(Request):
|
|
3155
|
+
bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The managed bot.")]
|
|
3156
|
+
|
|
3157
|
+
|
|
3158
|
+
async def access_get(ctx: OpContext, req: AccessGetReq) -> BotAccess:
|
|
3159
|
+
"""Who is allowed to use a managed bot."""
|
|
3160
|
+
from telethon.tl.functions import bots as fn
|
|
3161
|
+
|
|
3162
|
+
settings = await client(ctx)(
|
|
3163
|
+
fn.GetAccessSettingsRequest(bot=await _bots.input_user(ctx, req.bot))
|
|
3164
|
+
)
|
|
3165
|
+
return _access(settings)
|
|
3166
|
+
|
|
3167
|
+
|
|
3168
|
+
SPEC_ACCESS_GET = OperationSpec(
|
|
3169
|
+
id="bot.access.get",
|
|
3170
|
+
request=AccessGetReq,
|
|
3171
|
+
response=BotAccess,
|
|
3172
|
+
impl=access_get,
|
|
3173
|
+
summary="Show who may use a managed bot",
|
|
3174
|
+
columns=("restricted", "allowed_users", "allowed_chats"),
|
|
3175
|
+
headers=("Restricted", "Users", "Chats"),
|
|
3176
|
+
example={"restricted": True, "allowed_users": [4242], "allowed_chats": []},
|
|
3177
|
+
example_args="bot access get @my_helper_bot",
|
|
3178
|
+
covers_partial=("bots.managed-bot-access-settings",),
|
|
3179
|
+
coverage_note="Changing the list is `bot access set`.",
|
|
3180
|
+
)
|
|
3181
|
+
|
|
3182
|
+
|
|
3183
|
+
class AccessSetReq(Request):
|
|
3184
|
+
bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The managed bot.")]
|
|
3185
|
+
restricted: Annotated[
|
|
3186
|
+
bool, opt("--restricted", help="Only the listed peers may use the bot.")
|
|
3187
|
+
] = False
|
|
3188
|
+
open_to_all: Annotated[bool, opt("--open", help="Anyone may use the bot.")] = False
|
|
3189
|
+
add: Annotated[
|
|
3190
|
+
list[PeerRef], opt("--add", metavar="USER", kind="user", help="Peers to allow.")
|
|
3191
|
+
] = []
|
|
3192
|
+
remove: Annotated[
|
|
3193
|
+
list[PeerRef], opt("--remove", metavar="USER", kind="user", help="Peers to disallow.")
|
|
3194
|
+
] = []
|
|
3195
|
+
|
|
3196
|
+
|
|
3197
|
+
async def access_set(ctx: OpContext, req: AccessSetReq) -> BotAccess:
|
|
3198
|
+
"""Restrict or open who may use a managed bot.
|
|
3199
|
+
|
|
3200
|
+
`bots.editAccessSettings` takes the whole allow-list, not a delta, so
|
|
3201
|
+
`--add`/`--remove` are applied to the list the server currently holds
|
|
3202
|
+
rather than replacing it — otherwise adding one user would silently drop
|
|
3203
|
+
everybody else.
|
|
3204
|
+
"""
|
|
3205
|
+
from telethon.tl.functions import bots as fn
|
|
3206
|
+
|
|
3207
|
+
if req.restricted and req.open_to_all:
|
|
3208
|
+
raise UsageError("--restricted and --open contradict each other", field="restricted")
|
|
3209
|
+
|
|
3210
|
+
bot = await _bots.input_user(ctx, req.bot)
|
|
3211
|
+
handle = client(ctx)
|
|
3212
|
+
current = _access(await handle(fn.GetAccessSettingsRequest(bot=bot)))
|
|
3213
|
+
|
|
3214
|
+
allowed = list(current.allowed_users)
|
|
3215
|
+
for ref in req.add:
|
|
3216
|
+
peer_id = _send.peer_id_of(await _send.resolve(ctx, ref))
|
|
3217
|
+
if peer_id not in allowed:
|
|
3218
|
+
allowed.append(peer_id)
|
|
3219
|
+
for ref in req.remove:
|
|
3220
|
+
peer_id = _send.peer_id_of(await _send.resolve(ctx, ref))
|
|
3221
|
+
allowed = [entry for entry in allowed if entry != peer_id]
|
|
3222
|
+
|
|
3223
|
+
users = [
|
|
3224
|
+
await _bots.input_user(ctx, _bots.peer_ref(str(peer_id)), field="add")
|
|
3225
|
+
for peer_id in allowed
|
|
3226
|
+
]
|
|
3227
|
+
restricted = True if req.restricted else False if req.open_to_all else current.restricted
|
|
3228
|
+
await handle(
|
|
3229
|
+
fn.EditAccessSettingsRequest(
|
|
3230
|
+
bot=bot, restricted=restricted or None, add_users=users or None
|
|
3231
|
+
)
|
|
3232
|
+
)
|
|
3233
|
+
return BotAccess(
|
|
3234
|
+
restricted=restricted, allowed_users=allowed, allowed_chats=current.allowed_chats
|
|
3235
|
+
)
|
|
3236
|
+
|
|
3237
|
+
|
|
3238
|
+
SPEC_ACCESS_SET = OperationSpec(
|
|
3239
|
+
id="bot.access.set",
|
|
3240
|
+
request=AccessSetReq,
|
|
3241
|
+
response=BotAccess,
|
|
3242
|
+
impl=access_set,
|
|
3243
|
+
summary="Restrict or open who may use a managed bot",
|
|
3244
|
+
mutating=True,
|
|
3245
|
+
columns=("restricted", "allowed_users"),
|
|
3246
|
+
headers=("Restricted", "Users"),
|
|
3247
|
+
example={"restricted": True, "allowed_users": [4242]},
|
|
3248
|
+
example_args="bot access set @my_helper_bot --restricted --add @alice",
|
|
3249
|
+
covers=("bots.managed-bot-access-settings",),
|
|
3250
|
+
)
|
|
3251
|
+
|
|
3252
|
+
|
|
3253
|
+
# ---------------------------------------------------------------------------
|
|
3254
|
+
# bot default-rights set
|
|
3255
|
+
# ---------------------------------------------------------------------------
|
|
3256
|
+
|
|
3257
|
+
|
|
3258
|
+
class DefaultRightsReq(Request):
|
|
3259
|
+
group: Annotated[
|
|
3260
|
+
str | None, opt("--group", metavar="RIGHTS", help="'+'-joined rights for groups.")
|
|
3261
|
+
] = None
|
|
3262
|
+
channel: Annotated[
|
|
3263
|
+
str | None, opt("--channel", metavar="RIGHTS", help="'+'-joined rights for channels.")
|
|
3264
|
+
] = None
|
|
3265
|
+
|
|
3266
|
+
|
|
3267
|
+
async def default_rights_set(ctx: OpContext, req: DefaultRightsReq) -> DefaultRights:
|
|
3268
|
+
"""The admin rights clients pre-tick when my bot is added somewhere.
|
|
3269
|
+
|
|
3270
|
+
A suggestion, not a grant: the person adding the bot still confirms it.
|
|
3271
|
+
Reading them back is `bot get`.
|
|
3272
|
+
"""
|
|
3273
|
+
from telethon.tl.functions import bots as fn
|
|
3274
|
+
|
|
3275
|
+
await _bots.require_bot_session(ctx, "setting suggested admin rights")
|
|
3276
|
+
if not req.group and not req.channel:
|
|
3277
|
+
raise UsageError("give --group and/or --channel", field="group")
|
|
3278
|
+
|
|
3279
|
+
handle = client(ctx)
|
|
3280
|
+
result = DefaultRights()
|
|
3281
|
+
if req.group:
|
|
3282
|
+
rights = _bots.admin_rights(req.group, field="group")
|
|
3283
|
+
await handle(fn.SetBotGroupDefaultAdminRightsRequest(admin_rights=rights))
|
|
3284
|
+
result.group_rights = _bots.rights_keywords(rights)
|
|
3285
|
+
if req.channel:
|
|
3286
|
+
rights = _bots.admin_rights(req.channel, field="channel")
|
|
3287
|
+
await handle(fn.SetBotBroadcastDefaultAdminRightsRequest(admin_rights=rights))
|
|
3288
|
+
result.channel_rights = _bots.rights_keywords(rights)
|
|
3289
|
+
return result
|
|
3290
|
+
|
|
3291
|
+
|
|
3292
|
+
SPEC_DEFAULT_RIGHTS_SET = OperationSpec(
|
|
3293
|
+
id="bot.default-rights.set",
|
|
3294
|
+
request=DefaultRightsReq,
|
|
3295
|
+
response=DefaultRights,
|
|
3296
|
+
impl=default_rights_set,
|
|
3297
|
+
summary="Set the admin rights clients pre-tick for my bot",
|
|
3298
|
+
aliases=("bot.default_rights.set",),
|
|
3299
|
+
mutating=True,
|
|
3300
|
+
columns=("group_rights", "channel_rights"),
|
|
3301
|
+
headers=("Group", "Channel"),
|
|
3302
|
+
example={"group_rights": ["delete_messages"], "channel_rights": []},
|
|
3303
|
+
example_args="bot default-rights set --group delete_messages+invite_users",
|
|
3304
|
+
covers=("bots.suggested-admin-rights",),
|
|
3305
|
+
)
|
|
3306
|
+
|
|
3307
|
+
|
|
3308
|
+
# ---------------------------------------------------------------------------
|
|
3309
|
+
# bot verification get / set
|
|
3310
|
+
# ---------------------------------------------------------------------------
|
|
3311
|
+
|
|
3312
|
+
|
|
3313
|
+
class VerificationGetReq(Request):
|
|
3314
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="User, bot or channel.")]
|
|
3315
|
+
|
|
3316
|
+
|
|
3317
|
+
async def verification_get(ctx: OpContext, req: VerificationGetReq) -> BotVerification:
|
|
3318
|
+
"""A peer's third-party verification badge.
|
|
3319
|
+
|
|
3320
|
+
Distinct from Telegram's own blue check: a bot-issued badge says a company
|
|
3321
|
+
vouches for this peer, which is a different claim, so both are reported.
|
|
3322
|
+
"""
|
|
3323
|
+
from telethon.tl.functions import channels as channels_fn
|
|
3324
|
+
|
|
3325
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
3326
|
+
if type(peer).__name__ == "InputPeerChannel":
|
|
3327
|
+
result = await client(ctx)(channels_fn.GetFullChannelRequest(channel=_input_channel(peer)))
|
|
3328
|
+
full = getattr(result, "full_chat", None)
|
|
3329
|
+
chats = {int(getattr(c, "id", 0)): c for c in (getattr(result, "chats", None) or [])}
|
|
3330
|
+
entity = chats.get(int(getattr(full, "id", 0) or 0))
|
|
3331
|
+
else:
|
|
3332
|
+
full, entity = await _full(ctx, peer)
|
|
3333
|
+
return _verification(full, entity) or BotVerification()
|
|
3334
|
+
|
|
3335
|
+
|
|
3336
|
+
SPEC_VERIFICATION_GET = OperationSpec(
|
|
3337
|
+
id="bot.verification.get",
|
|
3338
|
+
request=VerificationGetReq,
|
|
3339
|
+
response=BotVerification,
|
|
3340
|
+
impl=verification_get,
|
|
3341
|
+
summary="Show a peer's third-party verification badge",
|
|
3342
|
+
columns=("verified_by_bot", "description", "telegram_verified"),
|
|
3343
|
+
headers=("By bot", "Description", "Telegram"),
|
|
3344
|
+
example={"verified_by_bot": 5000001, "description": "Verified merchant"},
|
|
3345
|
+
example_args="bot verification get @alice",
|
|
3346
|
+
covers=("bots.bot-verification-view",),
|
|
3347
|
+
)
|
|
3348
|
+
|
|
3349
|
+
|
|
3350
|
+
class VerificationSetReq(Request):
|
|
3351
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Peer to verify.")]
|
|
3352
|
+
bot: Annotated[
|
|
3353
|
+
PeerRef | None, opt("--bot", metavar="BOT", kind="user", help="My verifier bot.")
|
|
3354
|
+
] = None
|
|
3355
|
+
description: Annotated[str | None, opt("--description", help="Custom badge description.")] = (
|
|
3356
|
+
None
|
|
3357
|
+
)
|
|
3358
|
+
remove: Annotated[bool, opt("--remove", help="Remove the verification.")] = False
|
|
3359
|
+
|
|
3360
|
+
|
|
3361
|
+
async def verification_set(ctx: OpContext, req: VerificationSetReq) -> BotVerified:
|
|
3362
|
+
"""Verify or unverify a peer with my verifier bot."""
|
|
3363
|
+
from telethon.tl.functions import bots as fn
|
|
3364
|
+
|
|
3365
|
+
if req.bot is None:
|
|
3366
|
+
raise UsageError("--bot names the verifier bot and is required", field="bot")
|
|
3367
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
3368
|
+
await client(ctx)(
|
|
3369
|
+
fn.SetCustomVerificationRequest(
|
|
3370
|
+
peer=peer,
|
|
3371
|
+
enabled=None if req.remove else True,
|
|
3372
|
+
bot=await _bots.input_user(ctx, req.bot),
|
|
3373
|
+
custom_description=req.description,
|
|
3374
|
+
)
|
|
3375
|
+
)
|
|
3376
|
+
return BotVerified(
|
|
3377
|
+
peer_id=_send.peer_id_of(peer),
|
|
3378
|
+
verified=not req.remove,
|
|
3379
|
+
description=req.description,
|
|
3380
|
+
)
|
|
3381
|
+
|
|
3382
|
+
|
|
3383
|
+
SPEC_VERIFICATION_SET = OperationSpec(
|
|
3384
|
+
id="bot.verification.set",
|
|
3385
|
+
request=VerificationSetReq,
|
|
3386
|
+
response=BotVerified,
|
|
3387
|
+
impl=verification_set,
|
|
3388
|
+
summary="Verify or unverify a peer with my verifier bot",
|
|
3389
|
+
tags=frozenset({"visible-to-others"}),
|
|
3390
|
+
mutating=True,
|
|
3391
|
+
destructive=True,
|
|
3392
|
+
columns=("peer_id", "verified", "description"),
|
|
3393
|
+
headers=("Peer", "Verified", "Description"),
|
|
3394
|
+
example={"peer_id": 4242, "verified": True},
|
|
3395
|
+
example_args="bot verification set @alice --bot @my_verifier_bot",
|
|
3396
|
+
covers=("bots.bot-verification-set",),
|
|
3397
|
+
)
|
|
3398
|
+
|
|
3399
|
+
|
|
3400
|
+
# ---------------------------------------------------------------------------
|
|
3401
|
+
# bot preview list / add / edit / delete
|
|
3402
|
+
# ---------------------------------------------------------------------------
|
|
3403
|
+
|
|
3404
|
+
|
|
3405
|
+
class PreviewListReq(Request):
|
|
3406
|
+
bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot.")]
|
|
3407
|
+
lang: Annotated[str, opt("--lang", metavar="CODE", help="Language code.")] = ""
|
|
3408
|
+
owner: Annotated[bool, opt("--owner", help="Owner view, including per-language sets.")] = False
|
|
3409
|
+
|
|
3410
|
+
|
|
3411
|
+
def _preview(media: Any, index: int, lang: str | None) -> PreviewMedia:
|
|
3412
|
+
inner = getattr(media, "media", media)
|
|
3413
|
+
date = getattr(media, "date", None)
|
|
3414
|
+
document = getattr(inner, "document", None)
|
|
3415
|
+
photo = getattr(inner, "photo", None)
|
|
3416
|
+
target = document if document is not None else photo
|
|
3417
|
+
return PreviewMedia(
|
|
3418
|
+
index=index,
|
|
3419
|
+
kind="video" if document is not None else "photo",
|
|
3420
|
+
date=fmt_dt(date),
|
|
3421
|
+
date_unix=to_unix(date),
|
|
3422
|
+
lang=lang or None,
|
|
3423
|
+
file_id=_id_of(target),
|
|
3424
|
+
size=getattr(target, "size", None),
|
|
3425
|
+
)
|
|
3426
|
+
|
|
3427
|
+
|
|
3428
|
+
async def preview_list(ctx: OpContext, req: PreviewListReq) -> Page[PreviewMedia]:
|
|
3429
|
+
"""The mini-app preview gallery on a bot's profile.
|
|
3430
|
+
|
|
3431
|
+
`userFull.has_preview_medias` says whether this call is worth making at
|
|
3432
|
+
all, and `bot get` reports it — asking for a gallery that does not exist
|
|
3433
|
+
is a round trip for an empty list.
|
|
3434
|
+
"""
|
|
3435
|
+
from telethon.tl.functions import bots as fn
|
|
3436
|
+
|
|
3437
|
+
bot = await _bots.input_user(ctx, req.bot)
|
|
3438
|
+
if req.owner:
|
|
3439
|
+
result = await client(ctx)(fn.GetPreviewInfoRequest(bot=bot, lang_code=req.lang))
|
|
3440
|
+
media = getattr(result, "media", None) or []
|
|
3441
|
+
else:
|
|
3442
|
+
media = await client(ctx)(fn.GetPreviewMediasRequest(bot=bot)) or []
|
|
3443
|
+
items = [_preview(entry, index, req.lang) for index, entry in enumerate(media)]
|
|
3444
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
3445
|
+
|
|
3446
|
+
|
|
3447
|
+
SPEC_PREVIEW_LIST = OperationSpec(
|
|
3448
|
+
id="bot.preview.list",
|
|
3449
|
+
request=PreviewListReq,
|
|
3450
|
+
response=Page[PreviewMedia],
|
|
3451
|
+
impl=preview_list,
|
|
3452
|
+
summary="List a bot's mini-app preview media",
|
|
3453
|
+
columns=("index", "kind", "lang", "file_id"),
|
|
3454
|
+
headers=("#", "Kind", "Lang", "File"),
|
|
3455
|
+
example={"items": [{"index": 0, "kind": "photo"}], "has_more": False},
|
|
3456
|
+
example_args="bot preview list @my_helper_bot",
|
|
3457
|
+
covers=("bot.media-previews", "bots.preview-info-per-language", "bots.preview-medias-list"),
|
|
3458
|
+
)
|
|
3459
|
+
|
|
3460
|
+
|
|
3461
|
+
class PreviewAddReq(Request):
|
|
3462
|
+
bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot I own.")]
|
|
3463
|
+
file: Annotated[str, arg(1, metavar="FILE", kind="path", help="Image or video.")]
|
|
3464
|
+
lang: Annotated[str, opt("--lang", metavar="CODE", help="Language code.")] = ""
|
|
3465
|
+
|
|
3466
|
+
|
|
3467
|
+
async def _uploaded_media(ctx: OpContext, source: str) -> Any:
|
|
3468
|
+
"""A local file as the `InputMedia` a `bots.*PreviewMedia*` call wants.
|
|
3469
|
+
|
|
3470
|
+
`messages.uploadMedia` is the step that turns an uploaded file handle into
|
|
3471
|
+
a document the server already holds; handing the raw handle to
|
|
3472
|
+
`addPreviewMedia` would upload it again for every call.
|
|
3473
|
+
"""
|
|
3474
|
+
from telethon.tl import types
|
|
3475
|
+
from telethon.tl.functions import messages as fn
|
|
3476
|
+
|
|
3477
|
+
media = await _send.input_media(ctx, source)
|
|
3478
|
+
stored = await client(ctx)(fn.UploadMediaRequest(peer=types.InputPeerSelf(), media=media))
|
|
3479
|
+
document = _media.document_of(stored)
|
|
3480
|
+
if document is not None:
|
|
3481
|
+
return types.InputMediaDocument(id=_media.input_document(document))
|
|
3482
|
+
photo = getattr(stored, "photo", None)
|
|
3483
|
+
if photo is None:
|
|
3484
|
+
raise NotFoundError("the server did not accept that file as preview media")
|
|
3485
|
+
return types.InputMediaPhoto(id=_media.input_photo(photo))
|
|
3486
|
+
|
|
3487
|
+
|
|
3488
|
+
async def preview_add(ctx: OpContext, req: PreviewAddReq) -> PreviewChange:
|
|
3489
|
+
"""Add one preview media to my bot's mini-app gallery."""
|
|
3490
|
+
from telethon.tl.functions import bots as fn
|
|
3491
|
+
|
|
3492
|
+
bot = await _bots.input_user(ctx, req.bot)
|
|
3493
|
+
media = await _uploaded_media(ctx, req.file)
|
|
3494
|
+
result = await client(ctx)(fn.AddPreviewMediaRequest(bot=bot, lang_code=req.lang, media=media))
|
|
3495
|
+
document = getattr(getattr(result, "media", result), "document", None)
|
|
3496
|
+
return PreviewChange(
|
|
3497
|
+
index=0, kind="video" if document is not None else "photo", lang=req.lang or None
|
|
3498
|
+
)
|
|
3499
|
+
|
|
3500
|
+
|
|
3501
|
+
SPEC_PREVIEW_ADD = OperationSpec(
|
|
3502
|
+
id="bot.preview.add",
|
|
3503
|
+
request=PreviewAddReq,
|
|
3504
|
+
response=PreviewChange,
|
|
3505
|
+
impl=preview_add,
|
|
3506
|
+
summary="Add a preview media to my bot's mini-app gallery",
|
|
3507
|
+
mutating=True,
|
|
3508
|
+
rate_class="file",
|
|
3509
|
+
columns=("index", "kind", "lang"),
|
|
3510
|
+
headers=("#", "Kind", "Lang"),
|
|
3511
|
+
example={"index": 0, "kind": "photo"},
|
|
3512
|
+
example_args="bot preview add @my_helper_bot ./shot.png",
|
|
3513
|
+
covers=("bots.preview-media-add",),
|
|
3514
|
+
)
|
|
3515
|
+
|
|
3516
|
+
|
|
3517
|
+
class PreviewEditReq(Request):
|
|
3518
|
+
bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot I own.")]
|
|
3519
|
+
index: Annotated[int | None, opt("--index", metavar="N", help="Position to replace.")] = None
|
|
3520
|
+
file: Annotated[
|
|
3521
|
+
str | None, opt("--file", metavar="PATH", kind="path", help="New media for --index.")
|
|
3522
|
+
] = None
|
|
3523
|
+
order: Annotated[
|
|
3524
|
+
str | None, opt("--order", metavar="2,0,1", help="New order for the whole gallery.")
|
|
3525
|
+
] = None
|
|
3526
|
+
lang: Annotated[str, opt("--lang", metavar="CODE", help="Language code.")] = ""
|
|
3527
|
+
|
|
3528
|
+
|
|
3529
|
+
async def _current_previews(ctx: OpContext, bot: Any) -> list[Any]:
|
|
3530
|
+
from telethon.tl.functions import bots as fn
|
|
3531
|
+
|
|
3532
|
+
return list(await client(ctx)(fn.GetPreviewMediasRequest(bot=bot)) or [])
|
|
3533
|
+
|
|
3534
|
+
|
|
3535
|
+
def _as_input_media(entry: Any) -> Any:
|
|
3536
|
+
from telethon.tl import types
|
|
3537
|
+
|
|
3538
|
+
inner = getattr(entry, "media", entry)
|
|
3539
|
+
document = getattr(inner, "document", None)
|
|
3540
|
+
if document is not None:
|
|
3541
|
+
return types.InputMediaDocument(id=_media.input_document(document))
|
|
3542
|
+
return types.InputMediaPhoto(id=_media.input_photo(getattr(inner, "photo", None)))
|
|
3543
|
+
|
|
3544
|
+
|
|
3545
|
+
async def preview_edit(ctx: OpContext, req: PreviewEditReq) -> PreviewChange:
|
|
3546
|
+
"""Replace one preview media, or reorder the gallery.
|
|
3547
|
+
|
|
3548
|
+
Both take the *current* media as their handle, so the gallery is fetched
|
|
3549
|
+
first: an index alone means nothing to the server.
|
|
3550
|
+
"""
|
|
3551
|
+
from telethon.tl.functions import bots as fn
|
|
3552
|
+
|
|
3553
|
+
if (req.index is None) == (req.order is None):
|
|
3554
|
+
raise UsageError("give either --index with --file, or --order", field="index")
|
|
3555
|
+
index = req.index if req.index is not None else -1
|
|
3556
|
+
|
|
3557
|
+
bot = await _bots.input_user(ctx, req.bot)
|
|
3558
|
+
current = await _current_previews(ctx, bot)
|
|
3559
|
+
handle = client(ctx)
|
|
3560
|
+
|
|
3561
|
+
if req.order is not None:
|
|
3562
|
+
try:
|
|
3563
|
+
positions = [int(p) for p in req.order.split(",") if p.strip()]
|
|
3564
|
+
except ValueError as exc:
|
|
3565
|
+
raise UsageError("--order: expected a comma-separated list", field="order") from exc
|
|
3566
|
+
if sorted(positions) != list(range(len(current))):
|
|
3567
|
+
raise UsageError(
|
|
3568
|
+
f"--order must name every position exactly once (0..{len(current) - 1})",
|
|
3569
|
+
field="order",
|
|
3570
|
+
)
|
|
3571
|
+
await handle(
|
|
3572
|
+
fn.ReorderPreviewMediasRequest(
|
|
3573
|
+
bot=bot,
|
|
3574
|
+
lang_code=req.lang,
|
|
3575
|
+
order=[_as_input_media(current[p]) for p in positions],
|
|
3576
|
+
)
|
|
3577
|
+
)
|
|
3578
|
+
return PreviewChange(order=positions, lang=req.lang or None)
|
|
3579
|
+
|
|
3580
|
+
if not req.file:
|
|
3581
|
+
raise UsageError("--index needs --file", field="file")
|
|
3582
|
+
if not 0 <= index < len(current):
|
|
3583
|
+
raise NotFoundError(f"there is no preview media at position {index}")
|
|
3584
|
+
await handle(
|
|
3585
|
+
fn.EditPreviewMediaRequest(
|
|
3586
|
+
bot=bot,
|
|
3587
|
+
lang_code=req.lang,
|
|
3588
|
+
media=_as_input_media(current[index]),
|
|
3589
|
+
new_media=await _uploaded_media(ctx, req.file),
|
|
3590
|
+
)
|
|
3591
|
+
)
|
|
3592
|
+
return PreviewChange(index=req.index, lang=req.lang or None)
|
|
3593
|
+
|
|
3594
|
+
|
|
3595
|
+
SPEC_PREVIEW_EDIT = OperationSpec(
|
|
3596
|
+
id="bot.preview.edit",
|
|
3597
|
+
request=PreviewEditReq,
|
|
3598
|
+
response=PreviewChange,
|
|
3599
|
+
impl=preview_edit,
|
|
3600
|
+
summary="Replace one preview media, or reorder the gallery",
|
|
3601
|
+
mutating=True,
|
|
3602
|
+
rate_class="file",
|
|
3603
|
+
columns=("index", "order", "lang"),
|
|
3604
|
+
headers=("#", "Order", "Lang"),
|
|
3605
|
+
example={"index": 0, "lang": "en"},
|
|
3606
|
+
example_args="bot preview edit @my_helper_bot --order 1,0",
|
|
3607
|
+
covers=("bots.preview-media-edit", "bots.preview-media-reorder"),
|
|
3608
|
+
)
|
|
3609
|
+
|
|
3610
|
+
|
|
3611
|
+
class PreviewDeleteReq(Request):
|
|
3612
|
+
bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot I own.")]
|
|
3613
|
+
index: Annotated[list[int], opt("--index", metavar="N", help="Positions to delete.")] = []
|
|
3614
|
+
lang: Annotated[str, opt("--lang", metavar="CODE", help="Language code.")] = ""
|
|
3615
|
+
|
|
3616
|
+
|
|
3617
|
+
async def preview_delete(ctx: OpContext, req: PreviewDeleteReq) -> PreviewChange:
|
|
3618
|
+
"""Delete preview media from my bot's gallery."""
|
|
3619
|
+
from telethon.tl.functions import bots as fn
|
|
3620
|
+
|
|
3621
|
+
if not req.index:
|
|
3622
|
+
raise UsageError("name at least one --index", field="index")
|
|
3623
|
+
bot = await _bots.input_user(ctx, req.bot)
|
|
3624
|
+
current = await _current_previews(ctx, bot)
|
|
3625
|
+
missing = [i for i in req.index if not 0 <= i < len(current)]
|
|
3626
|
+
if missing:
|
|
3627
|
+
raise NotFoundError(f"there is no preview media at position {missing[0]}")
|
|
3628
|
+
await client(ctx)(
|
|
3629
|
+
fn.DeletePreviewMediaRequest(
|
|
3630
|
+
bot=bot,
|
|
3631
|
+
lang_code=req.lang,
|
|
3632
|
+
media=[_as_input_media(current[i]) for i in req.index],
|
|
3633
|
+
)
|
|
3634
|
+
)
|
|
3635
|
+
return PreviewChange(
|
|
3636
|
+
deleted=len(req.index), remaining=len(current) - len(req.index), lang=req.lang or None
|
|
3637
|
+
)
|
|
3638
|
+
|
|
3639
|
+
|
|
3640
|
+
SPEC_PREVIEW_DELETE = OperationSpec(
|
|
3641
|
+
id="bot.preview.delete",
|
|
3642
|
+
request=PreviewDeleteReq,
|
|
3643
|
+
response=PreviewChange,
|
|
3644
|
+
impl=preview_delete,
|
|
3645
|
+
summary="Delete preview media from my bot's gallery",
|
|
3646
|
+
mutating=True,
|
|
3647
|
+
destructive=True,
|
|
3648
|
+
columns=("deleted", "remaining"),
|
|
3649
|
+
headers=("Deleted", "Remaining"),
|
|
3650
|
+
example={"deleted": 1, "remaining": 2},
|
|
3651
|
+
example_args="bot preview delete @my_helper_bot --index 0",
|
|
3652
|
+
covers=("bots.preview-media-delete",),
|
|
3653
|
+
)
|
|
3654
|
+
|
|
3655
|
+
|
|
3656
|
+
# ---------------------------------------------------------------------------
|
|
3657
|
+
# bot affiliate
|
|
3658
|
+
# ---------------------------------------------------------------------------
|
|
3659
|
+
|
|
3660
|
+
|
|
3661
|
+
class AffiliateSetReq(Request):
|
|
3662
|
+
bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot I own.")]
|
|
3663
|
+
commission_permille: Annotated[
|
|
3664
|
+
int, opt("--commission-permille", metavar="N", help="Commission in permille.")
|
|
3665
|
+
] = 0
|
|
3666
|
+
duration_months: Annotated[
|
|
3667
|
+
int | None,
|
|
3668
|
+
opt("--duration-months", metavar="N", help="Program duration; omit for unlimited."),
|
|
3669
|
+
] = None
|
|
3670
|
+
|
|
3671
|
+
|
|
3672
|
+
async def affiliate_set(ctx: OpContext, req: AffiliateSetReq) -> StarRefProgram:
|
|
3673
|
+
"""Create or raise my bot's affiliate (star-ref) program.
|
|
3674
|
+
|
|
3675
|
+
Commission and duration may only ever be *raised*: Telegram will not let a
|
|
3676
|
+
program get worse for the affiliates already in it. The bounds come from
|
|
3677
|
+
the server's own config keys rather than from constants here, because they
|
|
3678
|
+
change without a client release.
|
|
3679
|
+
"""
|
|
3680
|
+
from telethon.tl.functions import bots as fn
|
|
3681
|
+
|
|
3682
|
+
config = await _media.app_config(ctx)
|
|
3683
|
+
if not bool(config.get("starref_program_allowed", True)):
|
|
3684
|
+
raise PermissionError_("affiliate programs are switched off for this account")
|
|
3685
|
+
low = _media.config_int(config, "starref_min_commission_permille", 1)
|
|
3686
|
+
high = _media.config_int(config, "starref_max_commission_permille", 800)
|
|
3687
|
+
if not low <= req.commission_permille <= high:
|
|
3688
|
+
raise UsageError(
|
|
3689
|
+
f"--commission-permille must be between {low} and {high}", field="commission_permille"
|
|
3690
|
+
)
|
|
3691
|
+
|
|
3692
|
+
result = await client(ctx)(
|
|
3693
|
+
fn.UpdateStarRefProgramRequest(
|
|
3694
|
+
bot=await _bots.input_user(ctx, req.bot),
|
|
3695
|
+
commission_permille=req.commission_permille,
|
|
3696
|
+
duration_months=req.duration_months,
|
|
3697
|
+
)
|
|
3698
|
+
)
|
|
3699
|
+
return _starref(result) or StarRefProgram(
|
|
3700
|
+
commission_permille=req.commission_permille, duration_months=req.duration_months
|
|
3701
|
+
)
|
|
3702
|
+
|
|
3703
|
+
|
|
3704
|
+
SPEC_AFFILIATE_SET = OperationSpec(
|
|
3705
|
+
id="bot.affiliate.set",
|
|
3706
|
+
request=AffiliateSetReq,
|
|
3707
|
+
response=StarRefProgram,
|
|
3708
|
+
impl=affiliate_set,
|
|
3709
|
+
summary="Create or raise my bot's affiliate program",
|
|
3710
|
+
mutating=True,
|
|
3711
|
+
columns=("bot_id", "commission_permille", "duration_months"),
|
|
3712
|
+
headers=("Bot", "Permille", "Months"),
|
|
3713
|
+
example={"bot_id": 5000001, "commission_permille": 200},
|
|
3714
|
+
example_args="bot affiliate set @my_helper_bot --commission-permille 200",
|
|
3715
|
+
covers=("bots.affiliate-program-set",),
|
|
3716
|
+
)
|
|
3717
|
+
|
|
3718
|
+
|
|
3719
|
+
class AffiliateUnsetReq(Request):
|
|
3720
|
+
bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot I own.")]
|
|
3721
|
+
|
|
3722
|
+
|
|
3723
|
+
async def affiliate_unset(ctx: OpContext, req: AffiliateUnsetReq) -> StarRefProgram:
|
|
3724
|
+
"""End my bot's affiliate program.
|
|
3725
|
+
|
|
3726
|
+
A commission of zero schedules termination roughly a day out, and no new
|
|
3727
|
+
program can be created before that date — which is why this is confirmed
|
|
3728
|
+
like a deletion even though nothing disappears immediately.
|
|
3729
|
+
"""
|
|
3730
|
+
from telethon.tl.functions import bots as fn
|
|
3731
|
+
|
|
3732
|
+
result = await client(ctx)(
|
|
3733
|
+
fn.UpdateStarRefProgramRequest(
|
|
3734
|
+
bot=await _bots.input_user(ctx, req.bot), commission_permille=0
|
|
3735
|
+
)
|
|
3736
|
+
)
|
|
3737
|
+
return _starref(result) or StarRefProgram(commission_permille=0)
|
|
3738
|
+
|
|
3739
|
+
|
|
3740
|
+
SPEC_AFFILIATE_UNSET = OperationSpec(
|
|
3741
|
+
id="bot.affiliate.unset",
|
|
3742
|
+
request=AffiliateUnsetReq,
|
|
3743
|
+
response=StarRefProgram,
|
|
3744
|
+
impl=affiliate_unset,
|
|
3745
|
+
summary="End my bot's affiliate program",
|
|
3746
|
+
mutating=True,
|
|
3747
|
+
destructive=True,
|
|
3748
|
+
columns=("bot_id", "end_date"),
|
|
3749
|
+
headers=("Bot", "Ends"),
|
|
3750
|
+
example={"bot_id": 5000001, "commission_permille": 0},
|
|
3751
|
+
example_args="bot affiliate unset @my_helper_bot",
|
|
3752
|
+
covers=("bots.affiliate-program-end",),
|
|
3753
|
+
)
|
|
3754
|
+
|
|
3755
|
+
|
|
3756
|
+
class AffiliateJoinReq(Request):
|
|
3757
|
+
bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot's program to join.")]
|
|
3758
|
+
send_as: Annotated[
|
|
3759
|
+
PeerRef | None,
|
|
3760
|
+
opt("--send-as", metavar="PEER", kind="peer", help="Join as me, a bot or a channel."),
|
|
3761
|
+
] = None
|
|
3762
|
+
|
|
3763
|
+
|
|
3764
|
+
async def affiliate_join(ctx: OpContext, req: AffiliateJoinReq) -> StarRefProgram:
|
|
3765
|
+
"""Join a bot's affiliate program and get my referral link."""
|
|
3766
|
+
from telethon.tl import types
|
|
3767
|
+
from telethon.tl.functions import payments as fn
|
|
3768
|
+
|
|
3769
|
+
config = await _media.app_config(ctx)
|
|
3770
|
+
if not bool(config.get("starref_connect_allowed", True)):
|
|
3771
|
+
raise PermissionError_("joining affiliate programs is switched off for this account")
|
|
3772
|
+
peer = (
|
|
3773
|
+
await _send.resolve(ctx, req.send_as) if req.send_as is not None else types.InputPeerSelf()
|
|
3774
|
+
)
|
|
3775
|
+
result = await client(ctx)(
|
|
3776
|
+
fn.ConnectStarRefBotRequest(peer=peer, bot=await _bots.input_user(ctx, req.bot))
|
|
3777
|
+
)
|
|
3778
|
+
connected = (getattr(result, "connected_bots", None) or [None])[0]
|
|
3779
|
+
return _connected_ref(connected) or StarRefProgram()
|
|
3780
|
+
|
|
3781
|
+
|
|
3782
|
+
def _connected_ref(entry: Any) -> StarRefProgram | None:
|
|
3783
|
+
if entry is None:
|
|
3784
|
+
return None
|
|
3785
|
+
date = getattr(entry, "date", None)
|
|
3786
|
+
return StarRefProgram(
|
|
3787
|
+
bot_id=int(getattr(entry, "bot_id", 0) or 0),
|
|
3788
|
+
url=getattr(entry, "url", None),
|
|
3789
|
+
commission_permille=int(getattr(entry, "commission_permille", 0) or 0),
|
|
3790
|
+
duration_months=getattr(entry, "duration_months", None),
|
|
3791
|
+
participants=getattr(entry, "participants", None),
|
|
3792
|
+
revenue=int(getattr(entry, "revenue", 0) or 0) or None,
|
|
3793
|
+
date=fmt_dt(date),
|
|
3794
|
+
date_unix=to_unix(date),
|
|
3795
|
+
revoked=bool(getattr(entry, "revoked", False)),
|
|
3796
|
+
)
|
|
3797
|
+
|
|
3798
|
+
|
|
3799
|
+
SPEC_AFFILIATE_JOIN = OperationSpec(
|
|
3800
|
+
id="bot.affiliate.join",
|
|
3801
|
+
request=AffiliateJoinReq,
|
|
3802
|
+
response=StarRefProgram,
|
|
3803
|
+
impl=affiliate_join,
|
|
3804
|
+
summary="Join a bot's affiliate program and get my referral link",
|
|
3805
|
+
mutating=True,
|
|
3806
|
+
columns=("bot_id", "url", "commission_permille"),
|
|
3807
|
+
headers=("Bot", "Link", "Permille"),
|
|
3808
|
+
example={
|
|
3809
|
+
"bot_id": 5000001,
|
|
3810
|
+
"url": "https://t.me/my_helper_bot?start=ref",
|
|
3811
|
+
"commission_permille": 200,
|
|
3812
|
+
},
|
|
3813
|
+
example_args="bot affiliate join @my_helper_bot",
|
|
3814
|
+
covers=("bots.affiliate-connect",),
|
|
3815
|
+
)
|
|
3816
|
+
|
|
3817
|
+
|
|
3818
|
+
class AffiliateListReq(Request):
|
|
3819
|
+
suggested: Annotated[
|
|
3820
|
+
bool, opt("--suggested", help="Browse mini apps with an open program.")
|
|
3821
|
+
] = False
|
|
3822
|
+
send_as: Annotated[
|
|
3823
|
+
PeerRef | None,
|
|
3824
|
+
opt("--send-as", metavar="PEER", kind="peer", help="Act as me, a bot or a channel."),
|
|
3825
|
+
] = None
|
|
3826
|
+
bot: Annotated[
|
|
3827
|
+
PeerRef | None,
|
|
3828
|
+
opt("--bot", metavar="BOT", kind="user", help="Only the program connected to this bot."),
|
|
3829
|
+
] = None
|
|
3830
|
+
by: Annotated[str, choice("revenue", "date", help="Sort order for --suggested.")] = "revenue"
|
|
3831
|
+
|
|
3832
|
+
|
|
3833
|
+
async def affiliate_list(ctx: OpContext, req: AffiliateListReq) -> Page[StarRefProgram]:
|
|
3834
|
+
"""Affiliate programs: mine, one of mine, or ones on offer.
|
|
3835
|
+
|
|
3836
|
+
Connected programs page by `(offset_date, offset_link)` *together* — two
|
|
3837
|
+
values, not one — so tlgr packs both into the single opaque cursor every
|
|
3838
|
+
other listing uses. A caller that had to carry two offsets by hand would
|
|
3839
|
+
be the only place in tlgr where pagination looks different.
|
|
3840
|
+
"""
|
|
3841
|
+
from telethon.tl import types
|
|
3842
|
+
from telethon.tl.functions import payments as fn
|
|
3843
|
+
|
|
3844
|
+
limit, state = window(ctx, "bot.affiliate.list", PageKind.RATE, default=50)
|
|
3845
|
+
peer = (
|
|
3846
|
+
await _send.resolve(ctx, req.send_as) if req.send_as is not None else types.InputPeerSelf()
|
|
3847
|
+
)
|
|
3848
|
+
handle = client(ctx)
|
|
3849
|
+
|
|
3850
|
+
if req.suggested:
|
|
3851
|
+
result = await handle(
|
|
3852
|
+
fn.GetSuggestedStarRefBotsRequest(
|
|
3853
|
+
peer=peer,
|
|
3854
|
+
offset=str(state.get("offset", "") or ""),
|
|
3855
|
+
limit=limit,
|
|
3856
|
+
order_by_revenue=req.by == "revenue" or None,
|
|
3857
|
+
order_by_date=req.by == "date" or None,
|
|
3858
|
+
)
|
|
3859
|
+
)
|
|
3860
|
+
items = [
|
|
3861
|
+
_starref(entry) or StarRefProgram()
|
|
3862
|
+
for entry in (getattr(result, "suggested_bots", None) or [])
|
|
3863
|
+
]
|
|
3864
|
+
next_offset = str(getattr(result, "next_offset", "") or "")
|
|
3865
|
+
return build_page(
|
|
3866
|
+
items,
|
|
3867
|
+
op="bot.affiliate.list",
|
|
3868
|
+
kind=PageKind.RATE,
|
|
3869
|
+
state={"offset": next_offset},
|
|
3870
|
+
account=ctx.account,
|
|
3871
|
+
has_more=bool(next_offset),
|
|
3872
|
+
)
|
|
3873
|
+
|
|
3874
|
+
if req.bot is not None:
|
|
3875
|
+
result = await handle(
|
|
3876
|
+
fn.GetConnectedStarRefBotRequest(peer=peer, bot=await _bots.input_user(ctx, req.bot))
|
|
3877
|
+
)
|
|
3878
|
+
entry = _connected_ref(getattr(result, "connected_bot", None))
|
|
3879
|
+
return Page(items=[entry] if entry else [], has_more=False, total=1 if entry else 0)
|
|
3880
|
+
|
|
3881
|
+
from tlgr.core.timefmt import parse_dt
|
|
3882
|
+
|
|
3883
|
+
offset_date = parse_dt(str(state["date"])) if state.get("date") else None
|
|
3884
|
+
result = await handle(
|
|
3885
|
+
fn.GetConnectedStarRefBotsRequest(
|
|
3886
|
+
peer=peer,
|
|
3887
|
+
limit=limit,
|
|
3888
|
+
offset_date=offset_date,
|
|
3889
|
+
offset_link=state.get("link") or None,
|
|
3890
|
+
)
|
|
3891
|
+
)
|
|
3892
|
+
entries = getattr(result, "connected_bots", None) or []
|
|
3893
|
+
items = [ref for ref in (_connected_ref(entry) for entry in entries) if ref is not None]
|
|
3894
|
+
last = items[-1] if items else None
|
|
3895
|
+
return build_page(
|
|
3896
|
+
items,
|
|
3897
|
+
op="bot.affiliate.list",
|
|
3898
|
+
kind=PageKind.RATE,
|
|
3899
|
+
state={"date": last.date if last else None, "link": last.url if last else None},
|
|
3900
|
+
account=ctx.account,
|
|
3901
|
+
limit=limit,
|
|
3902
|
+
total=getattr(result, "count", None),
|
|
3903
|
+
)
|
|
3904
|
+
|
|
3905
|
+
|
|
3906
|
+
SPEC_AFFILIATE_LIST = OperationSpec(
|
|
3907
|
+
id="bot.affiliate.list",
|
|
3908
|
+
request=AffiliateListReq,
|
|
3909
|
+
response=Page[StarRefProgram],
|
|
3910
|
+
impl=affiliate_list,
|
|
3911
|
+
summary="List affiliate programs I joined, or ones on offer",
|
|
3912
|
+
paginated=PageKind.RATE,
|
|
3913
|
+
columns=("bot_id", "url", "commission_permille", "revenue"),
|
|
3914
|
+
headers=("Bot", "Link", "Permille", "Revenue"),
|
|
3915
|
+
example={"items": [{"bot_id": 5000001, "commission_permille": 200}], "has_more": False},
|
|
3916
|
+
example_args="bot affiliate list",
|
|
3917
|
+
covers=("bots.affiliate-list-connected", "bots.affiliate-suggested"),
|
|
3918
|
+
)
|
|
3919
|
+
|
|
3920
|
+
|
|
3921
|
+
class AffiliateRevokeReq(Request):
|
|
3922
|
+
link: Annotated[str, arg(0, metavar="LINK", help="The referral link to revoke.")]
|
|
3923
|
+
send_as: Annotated[
|
|
3924
|
+
PeerRef | None,
|
|
3925
|
+
opt("--send-as", metavar="PEER", kind="peer", help="Peer the link belongs to."),
|
|
3926
|
+
] = None
|
|
3927
|
+
|
|
3928
|
+
|
|
3929
|
+
async def affiliate_revoke(ctx: OpContext, req: AffiliateRevokeReq) -> StarRefProgram:
|
|
3930
|
+
"""Revoke one of my affiliate links.
|
|
3931
|
+
|
|
3932
|
+
`STARREF_EXPIRED` means the link is already dead, which is the state the
|
|
3933
|
+
caller asked for — reported as `already` rather than as a failure.
|
|
3934
|
+
"""
|
|
3935
|
+
from telethon.tl import types
|
|
3936
|
+
from telethon.tl.functions import payments as fn
|
|
3937
|
+
|
|
3938
|
+
peer = (
|
|
3939
|
+
await _send.resolve(ctx, req.send_as) if req.send_as is not None else types.InputPeerSelf()
|
|
3940
|
+
)
|
|
3941
|
+
try:
|
|
3942
|
+
result = await client(ctx)(
|
|
3943
|
+
fn.EditConnectedStarRefBotRequest(peer=peer, link=req.link, revoked=True)
|
|
3944
|
+
)
|
|
3945
|
+
except Exception as exc: # one server answer means "already done"
|
|
3946
|
+
if "STARREFEXPIRED" not in f"{type(exc).__name__} {exc}".upper().replace("_", ""):
|
|
3947
|
+
raise
|
|
3948
|
+
from tlgr.ops._common import already as mark_already
|
|
3949
|
+
|
|
3950
|
+
mark_already(ctx)
|
|
3951
|
+
return StarRefProgram(url=req.link, revoked=True)
|
|
3952
|
+
return _connected_ref(getattr(result, "connected_bot", None)) or StarRefProgram(
|
|
3953
|
+
url=req.link, revoked=True
|
|
3954
|
+
)
|
|
3955
|
+
|
|
3956
|
+
|
|
3957
|
+
SPEC_AFFILIATE_REVOKE = OperationSpec(
|
|
3958
|
+
id="bot.affiliate.revoke",
|
|
3959
|
+
request=AffiliateRevokeReq,
|
|
3960
|
+
response=StarRefProgram,
|
|
3961
|
+
impl=affiliate_revoke,
|
|
3962
|
+
summary="Revoke one of my affiliate links",
|
|
3963
|
+
mutating=True,
|
|
3964
|
+
destructive=True,
|
|
3965
|
+
idempotent=True,
|
|
3966
|
+
columns=("url", "revoked"),
|
|
3967
|
+
headers=("Link", "Revoked"),
|
|
3968
|
+
example={"url": "https://t.me/my_helper_bot?start=ref", "revoked": True},
|
|
3969
|
+
example_args="bot affiliate revoke https://t.me/my_helper_bot?start=ref",
|
|
3970
|
+
covers=("bots.affiliate-revoke",),
|
|
3971
|
+
)
|
|
3972
|
+
|
|
3973
|
+
|
|
3974
|
+
# ---------------------------------------------------------------------------
|
|
3975
|
+
# bot attach list / toggle, bot recent set
|
|
3976
|
+
# ---------------------------------------------------------------------------
|
|
3977
|
+
|
|
3978
|
+
|
|
3979
|
+
class AttachListReq(Request):
|
|
3980
|
+
bot: Annotated[
|
|
3981
|
+
PeerRef | None,
|
|
3982
|
+
opt("--bot", metavar="BOT", kind="user", help="Inspect one bot's entry."),
|
|
3983
|
+
] = None
|
|
3984
|
+
|
|
3985
|
+
|
|
3986
|
+
def _attach_bot(entry: Any) -> AttachMenuBot:
|
|
3987
|
+
return AttachMenuBot(
|
|
3988
|
+
bot_id=int(getattr(entry, "bot_id", 0) or 0),
|
|
3989
|
+
short_name=getattr(entry, "short_name", None),
|
|
3990
|
+
peer_types=[
|
|
3991
|
+
_bots.BUTTON_TYPES.get(
|
|
3992
|
+
type(p).__name__, type(p).__name__.removeprefix("AttachMenuPeerType").lower()
|
|
3993
|
+
)
|
|
3994
|
+
for p in (getattr(entry, "peer_types", None) or [])
|
|
3995
|
+
],
|
|
3996
|
+
inactive=bool(getattr(entry, "inactive", False)),
|
|
3997
|
+
request_write_access=bool(getattr(entry, "request_write_access", False)),
|
|
3998
|
+
show_in_attach_menu=bool(getattr(entry, "show_in_attach_menu", False)),
|
|
3999
|
+
show_in_side_menu=bool(getattr(entry, "show_in_side_menu", False)),
|
|
4000
|
+
side_menu_disclaimer_needed=bool(getattr(entry, "side_menu_disclaimer_needed", False)),
|
|
4001
|
+
)
|
|
4002
|
+
|
|
4003
|
+
|
|
4004
|
+
async def attach_list(ctx: OpContext, req: AttachListReq) -> Page[AttachMenuBot]:
|
|
4005
|
+
"""The bots installed in my attachment and side menus."""
|
|
4006
|
+
from telethon.tl.functions import messages as fn
|
|
4007
|
+
|
|
4008
|
+
handle = client(ctx)
|
|
4009
|
+
if req.bot is not None:
|
|
4010
|
+
result = await handle(fn.GetAttachMenuBotRequest(bot=await _bots.input_user(ctx, req.bot)))
|
|
4011
|
+
entry = getattr(result, "bot", None)
|
|
4012
|
+
rows = [_attach_bot(entry)] if entry is not None else []
|
|
4013
|
+
_name_bots(rows, getattr(result, "users", None) or [])
|
|
4014
|
+
return Page(items=rows, has_more=False, total=len(rows))
|
|
4015
|
+
|
|
4016
|
+
result = await handle(fn.GetAttachMenuBotsRequest(hash=0))
|
|
4017
|
+
if type(result).__name__ == "AttachMenuBotsNotModified": # pragma: no cover - hash is 0
|
|
4018
|
+
return Page(items=[], has_more=False, total=0)
|
|
4019
|
+
rows = [_attach_bot(entry) for entry in (getattr(result, "bots", None) or [])]
|
|
4020
|
+
_name_bots(rows, getattr(result, "users", None) or [])
|
|
4021
|
+
return Page(items=rows, has_more=False, total=len(rows))
|
|
4022
|
+
|
|
4023
|
+
|
|
4024
|
+
def _name_bots(rows: list[AttachMenuBot], users: list[Any]) -> None:
|
|
4025
|
+
by_id = {int(getattr(u, "id", 0) or 0): u for u in users}
|
|
4026
|
+
for row in rows:
|
|
4027
|
+
user = by_id.get(row.bot_id)
|
|
4028
|
+
if user is not None:
|
|
4029
|
+
row.username = getattr(user, "username", None)
|
|
4030
|
+
|
|
4031
|
+
|
|
4032
|
+
SPEC_ATTACH_LIST = OperationSpec(
|
|
4033
|
+
id="bot.attach.list",
|
|
4034
|
+
request=AttachListReq,
|
|
4035
|
+
response=Page[AttachMenuBot],
|
|
4036
|
+
impl=attach_list,
|
|
4037
|
+
summary="List the bots in my attachment and side menus",
|
|
4038
|
+
columns=("bot_id", "username", "short_name", "show_in_attach_menu"),
|
|
4039
|
+
headers=("Bot", "Username", "Name", "Attach"),
|
|
4040
|
+
example={"items": [{"bot_id": 5000001, "short_name": "Helper"}], "has_more": False},
|
|
4041
|
+
example_args="bot attach list",
|
|
4042
|
+
covers=("attach.menu-bots", "bots.attach-menu-list"),
|
|
4043
|
+
)
|
|
4044
|
+
|
|
4045
|
+
|
|
4046
|
+
class AttachToggleReq(Request):
|
|
4047
|
+
bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot.")]
|
|
4048
|
+
state: Annotated[str, arg(1, metavar="STATE", help="on = install, off = remove.")]
|
|
4049
|
+
allow_write: Annotated[
|
|
4050
|
+
bool, opt("--allow-write", help="CONSENT: also let the bot message me.")
|
|
4051
|
+
] = False
|
|
4052
|
+
accept_tos: Annotated[
|
|
4053
|
+
bool, opt("--accept-tos", help="Required when the bot needs a side-menu disclaimer.")
|
|
4054
|
+
] = False
|
|
4055
|
+
|
|
4056
|
+
|
|
4057
|
+
async def attach_toggle(ctx: OpContext, req: AttachToggleReq) -> ToggledAttachMenu:
|
|
4058
|
+
"""Install or remove a bot from the attachment and side menu.
|
|
4059
|
+
|
|
4060
|
+
`write_allowed` is never set implicitly: installing a mini app and letting
|
|
4061
|
+
its bot message you are two different decisions, and Telegram's own API
|
|
4062
|
+
puts them in one call.
|
|
4063
|
+
"""
|
|
4064
|
+
from telethon.tl.functions import messages as fn
|
|
4065
|
+
|
|
4066
|
+
if req.state not in ("on", "off"):
|
|
4067
|
+
raise UsageError("state must be 'on' or 'off'", field="state")
|
|
4068
|
+
bot = await _bots.input_user(ctx, req.bot)
|
|
4069
|
+
handle = client(ctx)
|
|
4070
|
+
|
|
4071
|
+
if req.state == "on":
|
|
4072
|
+
entry = getattr(await handle(fn.GetAttachMenuBotRequest(bot=bot)), "bot", None)
|
|
4073
|
+
if bool(getattr(entry, "side_menu_disclaimer_needed", False)) and not req.accept_tos:
|
|
4074
|
+
raise UsageError(
|
|
4075
|
+
"this bot requires you to accept its terms first; pass --accept-tos",
|
|
4076
|
+
field="accept_tos",
|
|
4077
|
+
)
|
|
4078
|
+
await handle(
|
|
4079
|
+
fn.ToggleBotInAttachMenuRequest(
|
|
4080
|
+
bot=bot, enabled=req.state == "on", write_allowed=req.allow_write or None
|
|
4081
|
+
)
|
|
4082
|
+
)
|
|
4083
|
+
peer = await _send.resolve(ctx, req.bot)
|
|
4084
|
+
return ToggledAttachMenu(
|
|
4085
|
+
bot_id=_send.peer_id_of(peer),
|
|
4086
|
+
installed=req.state == "on",
|
|
4087
|
+
write_allowed=req.allow_write,
|
|
4088
|
+
)
|
|
4089
|
+
|
|
4090
|
+
|
|
4091
|
+
SPEC_ATTACH_TOGGLE = OperationSpec(
|
|
4092
|
+
id="bot.attach.toggle",
|
|
4093
|
+
request=AttachToggleReq,
|
|
4094
|
+
response=ToggledAttachMenu,
|
|
4095
|
+
impl=attach_toggle,
|
|
4096
|
+
summary="Install or remove a bot from the attachment menu",
|
|
4097
|
+
mutating=True,
|
|
4098
|
+
destructive=True,
|
|
4099
|
+
columns=("bot_id", "installed", "write_allowed"),
|
|
4100
|
+
headers=("Bot", "Installed", "May message"),
|
|
4101
|
+
example={"bot_id": 5000001, "installed": True, "write_allowed": False},
|
|
4102
|
+
example_args="bot attach toggle @my_helper_bot on",
|
|
4103
|
+
covers=("bots.attach-menu-toggle", "bots.miniapp-panel-menu", "bots.webapp-write-access"),
|
|
4104
|
+
)
|
|
4105
|
+
|
|
4106
|
+
|
|
4107
|
+
class RecentSetReq(Request):
|
|
4108
|
+
state: Annotated[
|
|
4109
|
+
str | None, arg(0, metavar="STATE", required=False, help="on|off for the whole feature.")
|
|
4110
|
+
] = None
|
|
4111
|
+
forget: Annotated[
|
|
4112
|
+
PeerRef | None,
|
|
4113
|
+
opt("--forget", metavar="BOT", kind="user", help="Reset the rating of one bot."),
|
|
4114
|
+
] = None
|
|
4115
|
+
forget_all: Annotated[bool, opt("--forget-all", help="Reset the whole category.")] = False
|
|
4116
|
+
kind: Annotated[
|
|
4117
|
+
str, choice("pm", "inline", "app", "guest", help="Category the reset applies to.")
|
|
4118
|
+
] = "pm"
|
|
4119
|
+
|
|
4120
|
+
|
|
4121
|
+
_TOP_PEER_CATEGORIES = {
|
|
4122
|
+
"pm": "TopPeerCategoryBotsPM",
|
|
4123
|
+
"inline": "TopPeerCategoryBotsInline",
|
|
4124
|
+
"app": "TopPeerCategoryBotsApp",
|
|
4125
|
+
"guest": "TopPeerCategoryBotsGuestChat",
|
|
4126
|
+
}
|
|
4127
|
+
|
|
4128
|
+
|
|
4129
|
+
async def recent_set(ctx: OpContext, req: RecentSetReq) -> RecentBots:
|
|
4130
|
+
"""Turn frequently-used-bot suggestions on or off, or forget one bot."""
|
|
4131
|
+
from telethon.tl import types
|
|
4132
|
+
from telethon.tl.functions import contacts as fn
|
|
4133
|
+
|
|
4134
|
+
handle = client(ctx)
|
|
4135
|
+
forgotten: list[int] = []
|
|
4136
|
+
enabled = req.state != "off"
|
|
4137
|
+
|
|
4138
|
+
if req.state is not None:
|
|
4139
|
+
if req.state not in ("on", "off"):
|
|
4140
|
+
raise UsageError("state must be 'on' or 'off'", field="state")
|
|
4141
|
+
await handle(fn.ToggleTopPeersRequest(enabled=req.state == "on"))
|
|
4142
|
+
|
|
4143
|
+
category_name = _TOP_PEER_CATEGORIES[req.kind]
|
|
4144
|
+
category: Any = getattr(types, category_name, None)
|
|
4145
|
+
if category is None: # pragma: no cover - layer 227 has every category we name
|
|
4146
|
+
_bots.unsupported(f"--kind {req.kind}")
|
|
4147
|
+
if req.forget is not None:
|
|
4148
|
+
peer = await _send.resolve(ctx, req.forget)
|
|
4149
|
+
await handle(fn.ResetTopPeerRatingRequest(category=category(), peer=peer))
|
|
4150
|
+
forgotten = [_send.peer_id_of(peer)]
|
|
4151
|
+
elif req.forget_all:
|
|
4152
|
+
await handle(fn.ResetTopPeerRatingRequest(category=category(), peer=types.InputPeerEmpty()))
|
|
4153
|
+
|
|
4154
|
+
if req.state is None and req.forget is None and not req.forget_all:
|
|
4155
|
+
raise UsageError("give a state, --forget or --forget-all", field="state")
|
|
4156
|
+
return RecentBots(enabled=enabled, kind=req.kind, forgotten=forgotten)
|
|
4157
|
+
|
|
4158
|
+
|
|
4159
|
+
SPEC_RECENT_SET = OperationSpec(
|
|
4160
|
+
id="bot.recent.set",
|
|
4161
|
+
request=RecentSetReq,
|
|
4162
|
+
response=RecentBots,
|
|
4163
|
+
impl=recent_set,
|
|
4164
|
+
summary="Turn frequently-used-bot suggestions on or off",
|
|
4165
|
+
mutating=True,
|
|
4166
|
+
columns=("enabled", "kind", "forgotten"),
|
|
4167
|
+
headers=("Enabled", "Kind", "Forgotten"),
|
|
4168
|
+
example={"enabled": True, "kind": "pm", "forgotten": []},
|
|
4169
|
+
example_args="bot recent set off",
|
|
4170
|
+
covers=("bots.top-peers-bots",),
|
|
4171
|
+
)
|
|
4172
|
+
|
|
4173
|
+
|
|
4174
|
+
# ---------------------------------------------------------------------------
|
|
4175
|
+
# bot report, bot ad
|
|
4176
|
+
# ---------------------------------------------------------------------------
|
|
4177
|
+
|
|
4178
|
+
|
|
4179
|
+
class ReportReq(Request):
|
|
4180
|
+
bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot or mini app owner.")]
|
|
4181
|
+
app: Annotated[str | None, opt("--app", help="Report a mini app by short name.")] = None
|
|
4182
|
+
message: Annotated[
|
|
4183
|
+
int | None, opt("--message", metavar="ID", kind="msg_id", help="Report one message.")
|
|
4184
|
+
] = None
|
|
4185
|
+
ephemeral: Annotated[
|
|
4186
|
+
int | None, opt("--ephemeral", metavar="ID", help="Report an ephemeral message.")
|
|
4187
|
+
] = None
|
|
4188
|
+
option: Annotated[
|
|
4189
|
+
str | None, opt("--option", metavar="BYTES", help="Option from the previous step.")
|
|
4190
|
+
] = None
|
|
4191
|
+
comment: Annotated[str | None, opt("--comment", help="Free-text comment.")] = None
|
|
4192
|
+
|
|
4193
|
+
|
|
4194
|
+
async def report(ctx: OpContext, req: ReportReq) -> ReportOutcome:
|
|
4195
|
+
"""Report a bot, a mini app or one of its messages.
|
|
4196
|
+
|
|
4197
|
+
Telegram's report flow is a state machine, not a form: the first call
|
|
4198
|
+
returns a list of options, each option leads to another list or to a
|
|
4199
|
+
comment box. One call per step is what lets a caller drive it without
|
|
4200
|
+
tlgr guessing which category they meant.
|
|
4201
|
+
"""
|
|
4202
|
+
from telethon.tl.functions import messages as fn
|
|
4203
|
+
|
|
4204
|
+
if req.ephemeral is not None:
|
|
4205
|
+
_bots.unsupported("--ephemeral (ephemeral.reportMessage)")
|
|
4206
|
+
|
|
4207
|
+
peer = await _send.resolve(ctx, req.bot)
|
|
4208
|
+
ids = [int(req.message)] if req.message is not None else []
|
|
4209
|
+
result = await client(ctx)(
|
|
4210
|
+
fn.ReportRequest(
|
|
4211
|
+
peer=peer,
|
|
4212
|
+
id=ids,
|
|
4213
|
+
option=_bots.option_bytes(req.option),
|
|
4214
|
+
message=req.comment or "",
|
|
4215
|
+
)
|
|
4216
|
+
)
|
|
4217
|
+
outcome = _bots.report_outcome(result)
|
|
4218
|
+
if outcome.reported:
|
|
4219
|
+
ctx.emit("bot_report", {"bot_id": _send.peer_id_of(peer)})
|
|
4220
|
+
return outcome
|
|
4221
|
+
|
|
4222
|
+
|
|
4223
|
+
SPEC_REPORT = OperationSpec(
|
|
4224
|
+
id="bot.report",
|
|
4225
|
+
request=ReportReq,
|
|
4226
|
+
response=ReportOutcome,
|
|
4227
|
+
impl=report,
|
|
4228
|
+
summary="Report a bot or a mini app",
|
|
4229
|
+
mutating=True,
|
|
4230
|
+
columns=("result", "title", "reported"),
|
|
4231
|
+
headers=("Step", "Title", "Done"),
|
|
4232
|
+
example={"result": "choose_option", "title": "What is wrong?", "options": []},
|
|
4233
|
+
example_args="bot report @spam_bot",
|
|
4234
|
+
covers=("bots.report-bot-or-app",),
|
|
4235
|
+
covers_partial=("bots.miniapp-panel-menu",),
|
|
4236
|
+
coverage_note=(
|
|
4237
|
+
"Installing and removing a mini app is `bot attach toggle`; reporting "
|
|
4238
|
+
"an ephemeral message needs layer 229 and exits 13."
|
|
4239
|
+
),
|
|
4240
|
+
)
|
|
4241
|
+
|
|
4242
|
+
|
|
4243
|
+
class AdListReq(Request):
|
|
4244
|
+
bot: Annotated[
|
|
4245
|
+
PeerRef | None,
|
|
4246
|
+
arg(0, metavar="BOT", required=False, kind="user", help="The bot chat."),
|
|
4247
|
+
] = None
|
|
4248
|
+
search: Annotated[
|
|
4249
|
+
str | None,
|
|
4250
|
+
opt("--search", metavar="QUERY", help="Sponsored chats a search for QUERY would show."),
|
|
4251
|
+
] = None
|
|
4252
|
+
|
|
4253
|
+
|
|
4254
|
+
async def ad_list(ctx: OpContext, req: AdListReq) -> Page[SponsoredMessage]:
|
|
4255
|
+
"""The sponsored messages a bot chat — or a search — would show.
|
|
4256
|
+
|
|
4257
|
+
Opt-in, like `message sponsored list`: tlgr never mixes ads into a message
|
|
4258
|
+
listing or a search result, and never reports an impression that nobody
|
|
4259
|
+
saw — that is `bot ad read`. Both surfaces are here because both are ads,
|
|
4260
|
+
and splitting them would hide one of the two places they appear.
|
|
4261
|
+
"""
|
|
4262
|
+
from telethon.tl.functions import contacts as contacts_fn
|
|
4263
|
+
from telethon.tl.functions import messages as fn
|
|
4264
|
+
|
|
4265
|
+
if req.search is not None:
|
|
4266
|
+
return await _sponsored_peers(ctx, req.search, contacts_fn)
|
|
4267
|
+
if req.bot is None:
|
|
4268
|
+
raise UsageError("name a bot chat, or use --search", field="bot")
|
|
4269
|
+
|
|
4270
|
+
peer = await _send.resolve(ctx, req.bot)
|
|
4271
|
+
result = await client(ctx)(fn.GetSponsoredMessagesRequest(peer=peer))
|
|
4272
|
+
items = [
|
|
4273
|
+
SponsoredMessage(
|
|
4274
|
+
random_id=_bots.key_text(getattr(entry, "random_id", b"")),
|
|
4275
|
+
title=getattr(entry, "title", None),
|
|
4276
|
+
message=str(getattr(entry, "message", "") or ""),
|
|
4277
|
+
url=getattr(entry, "url", None),
|
|
4278
|
+
button_text=getattr(entry, "button_text", None),
|
|
4279
|
+
sponsor_info=getattr(entry, "sponsor_info", None),
|
|
4280
|
+
additional_info=getattr(entry, "additional_info", None),
|
|
4281
|
+
recommended=bool(getattr(entry, "recommended", False)),
|
|
4282
|
+
can_report=bool(getattr(entry, "can_report", False)),
|
|
4283
|
+
)
|
|
4284
|
+
for entry in (getattr(result, "messages", None) or [])
|
|
4285
|
+
]
|
|
4286
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
4287
|
+
|
|
4288
|
+
|
|
4289
|
+
async def _sponsored_peers(ctx: OpContext, query: str, contacts_fn: Any) -> Page[SponsoredMessage]:
|
|
4290
|
+
"""`contacts.getSponsoredPeers` as the same row a bot-chat ad produces.
|
|
4291
|
+
|
|
4292
|
+
A sponsored *peer* is an ad for a chat rather than a message in one, so it
|
|
4293
|
+
has a title and no body; reporting it in the same shape is what lets one
|
|
4294
|
+
`bot ad read` mark either kind as seen.
|
|
4295
|
+
"""
|
|
4296
|
+
from tlgr.ops._serialize import entity_to_peer
|
|
4297
|
+
|
|
4298
|
+
result = await client(ctx)(contacts_fn.GetSponsoredPeersRequest(q=query))
|
|
4299
|
+
chats = {int(getattr(c, "id", 0)): c for c in (getattr(result, "chats", None) or [])}
|
|
4300
|
+
users = {int(getattr(u, "id", 0)): u for u in (getattr(result, "users", None) or [])}
|
|
4301
|
+
items: list[SponsoredMessage] = []
|
|
4302
|
+
for entry in getattr(result, "peers", None) or []:
|
|
4303
|
+
peer = getattr(entry, "peer", None)
|
|
4304
|
+
raw_id = int(
|
|
4305
|
+
getattr(peer, "channel_id", 0)
|
|
4306
|
+
or getattr(peer, "user_id", 0)
|
|
4307
|
+
or getattr(peer, "chat_id", 0)
|
|
4308
|
+
or 0
|
|
4309
|
+
)
|
|
4310
|
+
entity = chats.get(raw_id) or users.get(raw_id)
|
|
4311
|
+
items.append(
|
|
4312
|
+
SponsoredMessage(
|
|
4313
|
+
random_id=_bots.key_text(getattr(entry, "random_id", b"")),
|
|
4314
|
+
title=entity_to_peer(entity).title if entity is not None else None,
|
|
4315
|
+
message="",
|
|
4316
|
+
sponsor_info=getattr(entry, "sponsor_info", None),
|
|
4317
|
+
additional_info=getattr(entry, "additional_info", None),
|
|
4318
|
+
)
|
|
4319
|
+
)
|
|
4320
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
4321
|
+
|
|
4322
|
+
|
|
4323
|
+
SPEC_AD_LIST = OperationSpec(
|
|
4324
|
+
id="bot.ad.list",
|
|
4325
|
+
request=AdListReq,
|
|
4326
|
+
response=Page[SponsoredMessage],
|
|
4327
|
+
impl=ad_list,
|
|
4328
|
+
summary="List the sponsored messages shown inside a bot chat",
|
|
4329
|
+
description=(
|
|
4330
|
+
"Telegram's API terms require a third-party client that shows bot or "
|
|
4331
|
+
"channel content to support sponsored messages; tlgr does so by "
|
|
4332
|
+
"making them a command of their own instead of hiding them in a feed."
|
|
4333
|
+
),
|
|
4334
|
+
columns=("random_id", "title", "message"),
|
|
4335
|
+
headers=("ID", "Title", "Text"),
|
|
4336
|
+
example={"items": [{"random_id": "abc", "message": "An ad"}], "has_more": False},
|
|
4337
|
+
example_args="bot ad list @my_helper_bot",
|
|
4338
|
+
covers=("bots.bot-ads-account", "dialogs.sponsored-search-peers"),
|
|
4339
|
+
covers_partial=("bots.sponsored-message-in-bot-chat",),
|
|
4340
|
+
coverage_note="Reporting an impression or a click is `bot ad read`.",
|
|
4341
|
+
)
|
|
4342
|
+
|
|
4343
|
+
|
|
4344
|
+
class AdReadReq(Request):
|
|
4345
|
+
random_id: Annotated[str, arg(0, metavar="RANDOM_ID", help="random_id from `bot ad list`.")]
|
|
4346
|
+
click: Annotated[bool, opt("--click", help="Also record a click.")] = False
|
|
4347
|
+
media: Annotated[bool, opt("--media", help="The click was on the ad's media.")] = False
|
|
4348
|
+
fullscreen: Annotated[bool, opt("--fullscreen", help="The click was in fullscreen.")] = False
|
|
4349
|
+
|
|
4350
|
+
|
|
4351
|
+
async def ad_read(ctx: OpContext, req: AdReadReq) -> SponsoredRead:
|
|
4352
|
+
"""Report that a sponsored message was seen, or clicked."""
|
|
4353
|
+
from telethon.tl.functions import messages as fn
|
|
4354
|
+
|
|
4355
|
+
handle = client(ctx)
|
|
4356
|
+
raw = _bots.option_bytes(req.random_id, field="random_id")
|
|
4357
|
+
await handle(fn.ViewSponsoredMessageRequest(random_id=raw))
|
|
4358
|
+
if req.click:
|
|
4359
|
+
await handle(
|
|
4360
|
+
fn.ClickSponsoredMessageRequest(
|
|
4361
|
+
random_id=raw, media=req.media or None, fullscreen=req.fullscreen or None
|
|
4362
|
+
)
|
|
4363
|
+
)
|
|
4364
|
+
return SponsoredRead(random_id=req.random_id, viewed=True, clicked=req.click)
|
|
4365
|
+
|
|
4366
|
+
|
|
4367
|
+
SPEC_AD_READ = OperationSpec(
|
|
4368
|
+
id="bot.ad.read",
|
|
4369
|
+
request=AdReadReq,
|
|
4370
|
+
response=SponsoredRead,
|
|
4371
|
+
impl=ad_read,
|
|
4372
|
+
summary="Mark a sponsored message as seen, or as clicked",
|
|
4373
|
+
aliases=("bot.ad.view",),
|
|
4374
|
+
mutating=True,
|
|
4375
|
+
columns=("random_id", "viewed", "clicked"),
|
|
4376
|
+
headers=("ID", "Viewed", "Clicked"),
|
|
4377
|
+
example={"random_id": "abc", "viewed": True, "clicked": False},
|
|
4378
|
+
example_args="bot ad read abc",
|
|
4379
|
+
covers=("bots.sponsored-message-in-bot-chat",),
|
|
4380
|
+
)
|
|
4381
|
+
|
|
4382
|
+
|
|
4383
|
+
class AdReportReq(Request):
|
|
4384
|
+
random_id: Annotated[str, arg(0, metavar="RANDOM_ID", help="random_id from `bot ad list`.")]
|
|
4385
|
+
option: Annotated[
|
|
4386
|
+
str | None, opt("--option", metavar="BYTES", help="Option from the previous step.")
|
|
4387
|
+
] = None
|
|
4388
|
+
comment: Annotated[str | None, opt("--comment", help="Free-text comment.")] = None
|
|
4389
|
+
|
|
4390
|
+
|
|
4391
|
+
async def ad_report(ctx: OpContext, req: AdReportReq) -> ReportOutcome:
|
|
4392
|
+
"""Report a sponsored message, walking the same option tree as `bot report`."""
|
|
4393
|
+
from telethon.tl.functions import messages as fn
|
|
4394
|
+
|
|
4395
|
+
result = await client(ctx)(
|
|
4396
|
+
fn.ReportSponsoredMessageRequest(
|
|
4397
|
+
random_id=_bots.option_bytes(req.random_id, field="random_id"),
|
|
4398
|
+
option=_bots.option_bytes(req.option),
|
|
4399
|
+
)
|
|
4400
|
+
)
|
|
4401
|
+
return _bots.report_outcome(result)
|
|
4402
|
+
|
|
4403
|
+
|
|
4404
|
+
SPEC_AD_REPORT = OperationSpec(
|
|
4405
|
+
id="bot.ad.report",
|
|
4406
|
+
request=AdReportReq,
|
|
4407
|
+
response=ReportOutcome,
|
|
4408
|
+
impl=ad_report,
|
|
4409
|
+
summary="Report a sponsored message in a bot chat",
|
|
4410
|
+
mutating=True,
|
|
4411
|
+
columns=("result", "title", "reported"),
|
|
4412
|
+
headers=("Step", "Title", "Done"),
|
|
4413
|
+
example={"result": "reported", "reported": True},
|
|
4414
|
+
example_args="bot ad report abc",
|
|
4415
|
+
covers_partial=("bots.sponsored-message-in-bot-chat",),
|
|
4416
|
+
coverage_note="Listing and viewing the ads themselves is `bot ad list`/`bot ad read`.",
|
|
4417
|
+
)
|
|
4418
|
+
|
|
4419
|
+
|
|
4420
|
+
# ---------------------------------------------------------------------------
|
|
4421
|
+
# bot game get / send, bot score list / set
|
|
4422
|
+
# ---------------------------------------------------------------------------
|
|
4423
|
+
|
|
4424
|
+
|
|
4425
|
+
class GameGetReq(Request):
|
|
4426
|
+
emoji: Annotated[
|
|
4427
|
+
str | None, opt("--emoji", metavar="EMOJI", help="Dice emoji this report is about.")
|
|
4428
|
+
] = None
|
|
4429
|
+
|
|
4430
|
+
|
|
4431
|
+
async def game_get(ctx: OpContext, req: GameGetReq) -> EmojiGame:
|
|
4432
|
+
"""Emoji-dice game parameters. Inspect only.
|
|
4433
|
+
|
|
4434
|
+
Staking TON on an emoji game moves money, so tlgr reads the parameters and
|
|
4435
|
+
stops there. `messages.getEmojiGameInfo` takes no arguments — `--emoji` is
|
|
4436
|
+
recorded on the answer so a caller can tell which game they asked about.
|
|
4437
|
+
"""
|
|
4438
|
+
from telethon.tl.functions import messages as fn
|
|
4439
|
+
|
|
4440
|
+
result = await client(ctx)(fn.GetEmojiGameInfoRequest())
|
|
4441
|
+
if type(result).__name__ == "EmojiGameUnavailable":
|
|
4442
|
+
return EmojiGame(emoticon=req.emoji or "", available=False)
|
|
4443
|
+
return EmojiGame(
|
|
4444
|
+
emoticon=req.emoji or "",
|
|
4445
|
+
available=True,
|
|
4446
|
+
game_hash=getattr(result, "game_hash", None),
|
|
4447
|
+
prev_stake=getattr(result, "prev_stake", None),
|
|
4448
|
+
current_streak=getattr(result, "current_streak", None),
|
|
4449
|
+
params=[int(p) for p in (getattr(result, "params", None) or [])],
|
|
4450
|
+
plays_left=getattr(result, "plays_left", None),
|
|
4451
|
+
)
|
|
4452
|
+
|
|
4453
|
+
|
|
4454
|
+
SPEC_GAME_GET = OperationSpec(
|
|
4455
|
+
id="bot.game.get",
|
|
4456
|
+
request=GameGetReq,
|
|
4457
|
+
response=EmojiGame,
|
|
4458
|
+
impl=game_get,
|
|
4459
|
+
summary="Show emoji-dice game parameters",
|
|
4460
|
+
columns=("emoticon", "available", "current_streak"),
|
|
4461
|
+
headers=("Emoji", "Available", "Streak"),
|
|
4462
|
+
example={"emoticon": "🎲", "available": True, "current_streak": 0},
|
|
4463
|
+
example_args="bot game get --emoji 🎲",
|
|
4464
|
+
covers=("bots.emoji-games",),
|
|
4465
|
+
)
|
|
4466
|
+
|
|
4467
|
+
|
|
4468
|
+
class GameSendReq(Request):
|
|
4469
|
+
bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="user", help="The bot that owns the game.")]
|
|
4470
|
+
short_name: Annotated[str, arg(1, metavar="SHORT_NAME", help="Game short name.")]
|
|
4471
|
+
chat: Annotated[
|
|
4472
|
+
PeerRef | None, opt("--chat", metavar="CHAT", kind="peer", help="Destination chat.")
|
|
4473
|
+
] = None
|
|
4474
|
+
reply_to: Annotated[
|
|
4475
|
+
int | None, opt("--reply-to", metavar="ID", kind="msg_id", help="Reply to this message.")
|
|
4476
|
+
] = None
|
|
4477
|
+
silent: Annotated[bool, opt("--silent", help="Send without a notification.")] = False
|
|
4478
|
+
schedule: Annotated[str | None, opt("--schedule", metavar="TS", help="Schedule the send.")] = (
|
|
4479
|
+
None
|
|
4480
|
+
)
|
|
4481
|
+
|
|
4482
|
+
|
|
4483
|
+
async def game_send(ctx: OpContext, req: GameSendReq) -> GameSent:
|
|
4484
|
+
"""Send an HTML5 game to a chat.
|
|
4485
|
+
|
|
4486
|
+
Only the owning bot may send by short name; a user can forward an existing
|
|
4487
|
+
game message but cannot mint one, which is why this is a bot-session
|
|
4488
|
+
command rather than a refusal from the server three steps later.
|
|
4489
|
+
"""
|
|
4490
|
+
from telethon.tl import types
|
|
4491
|
+
from telethon.tl.functions import messages as fn
|
|
4492
|
+
|
|
4493
|
+
await _bots.require_bot_session(ctx, "sending a game by short name")
|
|
4494
|
+
if req.chat is None:
|
|
4495
|
+
raise UsageError("--chat is required", field="chat")
|
|
4496
|
+
target = await _send.resolve(ctx, req.chat)
|
|
4497
|
+
updates = await client(ctx)(
|
|
4498
|
+
fn.SendMediaRequest(
|
|
4499
|
+
peer=target,
|
|
4500
|
+
media=types.InputMediaGame(
|
|
4501
|
+
id=types.InputGameShortName(
|
|
4502
|
+
bot_id=await _bots.input_user(ctx, req.bot), short_name=req.short_name
|
|
4503
|
+
)
|
|
4504
|
+
),
|
|
4505
|
+
message="",
|
|
4506
|
+
random_id=_random_id(),
|
|
4507
|
+
reply_to=await _send.reply_target(ctx, reply_to=req.reply_to),
|
|
4508
|
+
silent=req.silent or None,
|
|
4509
|
+
schedule_date=_send.schedule_at(req.schedule),
|
|
4510
|
+
)
|
|
4511
|
+
)
|
|
4512
|
+
message = _send.message_from_updates(updates, chat_id=_send.peer_id_of(target))
|
|
4513
|
+
game = getattr(getattr(message, "media", None), "game", None)
|
|
4514
|
+
return GameSent(
|
|
4515
|
+
chat_id=message.chat_id,
|
|
4516
|
+
msg_id=message.id,
|
|
4517
|
+
game_id=_id_of(game),
|
|
4518
|
+
short_name=req.short_name,
|
|
4519
|
+
)
|
|
4520
|
+
|
|
4521
|
+
|
|
4522
|
+
SPEC_GAME_SEND = OperationSpec(
|
|
4523
|
+
id="bot.game.send",
|
|
4524
|
+
request=GameSendReq,
|
|
4525
|
+
response=GameSent,
|
|
4526
|
+
impl=game_send,
|
|
4527
|
+
summary="Send an HTML5 game to a chat",
|
|
4528
|
+
tags=frozenset({"visible-to-others"}),
|
|
4529
|
+
mutating=True,
|
|
4530
|
+
rate_class="send",
|
|
4531
|
+
columns=("chat_id", "msg_id", "short_name"),
|
|
4532
|
+
headers=("Chat", "Message", "Game"),
|
|
4533
|
+
example={"chat_id": 4242, "msg_id": 12, "short_name": "tetris"},
|
|
4534
|
+
example_args="bot game send @my_helper_bot tetris --chat @alice",
|
|
4535
|
+
covers=("bots.send-game",),
|
|
4536
|
+
)
|
|
4537
|
+
|
|
4538
|
+
|
|
4539
|
+
class ScoreListReq(Request):
|
|
4540
|
+
chat: Annotated[
|
|
4541
|
+
PeerRef | None,
|
|
4542
|
+
arg(0, metavar="CHAT", required=False, kind="peer", help="Chat holding the game."),
|
|
4543
|
+
] = None
|
|
4544
|
+
msg_id: Annotated[
|
|
4545
|
+
int | None, arg(1, metavar="MSG_ID", required=False, kind="msg_id", help="Game message.")
|
|
4546
|
+
] = None
|
|
4547
|
+
inline_id: Annotated[
|
|
4548
|
+
str | None, opt("--inline-id", metavar="DC:ID:HASH", help="Inline message id.")
|
|
4549
|
+
] = None
|
|
4550
|
+
user: Annotated[
|
|
4551
|
+
PeerRef | None,
|
|
4552
|
+
opt("--user", metavar="USER", kind="user", help="Centre the table on this user."),
|
|
4553
|
+
] = None
|
|
4554
|
+
|
|
4555
|
+
|
|
4556
|
+
async def score_list(ctx: OpContext, req: ScoreListReq) -> Page[HighScore]:
|
|
4557
|
+
"""A game's high-score table.
|
|
4558
|
+
|
|
4559
|
+
The inline variant has to be sent to the DC the inline message lives on;
|
|
4560
|
+
sending it home answers with an error that never mentions data centres.
|
|
4561
|
+
"""
|
|
4562
|
+
from telethon.tl import types
|
|
4563
|
+
from telethon.tl.functions import messages as fn
|
|
4564
|
+
|
|
4565
|
+
user = (
|
|
4566
|
+
await _bots.input_user(ctx, req.user, field="user")
|
|
4567
|
+
if req.user is not None
|
|
4568
|
+
else types.InputUserSelf()
|
|
4569
|
+
)
|
|
4570
|
+
if req.inline_id:
|
|
4571
|
+
identifier = _bots.inline_message_id(req.inline_id)
|
|
4572
|
+
result = await _bots.on_dc(
|
|
4573
|
+
ctx,
|
|
4574
|
+
int(getattr(identifier, "dc_id", 0) or 0),
|
|
4575
|
+
fn.GetInlineGameHighScoresRequest(id=identifier, user_id=user),
|
|
4576
|
+
)
|
|
4577
|
+
else:
|
|
4578
|
+
if req.chat is None or req.msg_id is None:
|
|
4579
|
+
raise UsageError("give a chat and a message id, or --inline-id", field="msg_id")
|
|
4580
|
+
result = await client(ctx)(
|
|
4581
|
+
fn.GetGameHighScoresRequest(
|
|
4582
|
+
peer=await _send.resolve(ctx, req.chat), id=int(req.msg_id), user_id=user
|
|
4583
|
+
)
|
|
4584
|
+
)
|
|
4585
|
+
items = [
|
|
4586
|
+
HighScore(
|
|
4587
|
+
position=int(getattr(score, "pos", 0) or 0),
|
|
4588
|
+
user_id=int(getattr(score, "user_id", 0) or 0),
|
|
4589
|
+
score=int(getattr(score, "score", 0) or 0),
|
|
4590
|
+
)
|
|
4591
|
+
for score in (getattr(result, "scores", None) or [])
|
|
4592
|
+
]
|
|
4593
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
4594
|
+
|
|
4595
|
+
|
|
4596
|
+
SPEC_SCORE_LIST = OperationSpec(
|
|
4597
|
+
id="bot.score.list",
|
|
4598
|
+
request=ScoreListReq,
|
|
4599
|
+
response=Page[HighScore],
|
|
4600
|
+
impl=score_list,
|
|
4601
|
+
summary="Show a game's high-score table",
|
|
4602
|
+
columns=("position", "user_id", "score"),
|
|
4603
|
+
headers=("#", "User", "Score"),
|
|
4604
|
+
example={"items": [{"position": 1, "user_id": 4242, "score": 900}], "has_more": False},
|
|
4605
|
+
example_args="bot score list @alice 12",
|
|
4606
|
+
covers=("bots.game-high-scores", "bots.inline-game-high-scores"),
|
|
4607
|
+
)
|
|
4608
|
+
|
|
4609
|
+
|
|
4610
|
+
class ScoreSetReq(Request):
|
|
4611
|
+
chat: Annotated[
|
|
4612
|
+
PeerRef | None,
|
|
4613
|
+
arg(0, metavar="CHAT", required=False, kind="peer", help="Chat holding the game."),
|
|
4614
|
+
] = None
|
|
4615
|
+
msg_id: Annotated[
|
|
4616
|
+
int | None, arg(1, metavar="MSG_ID", required=False, kind="msg_id", help="Game message.")
|
|
4617
|
+
] = None
|
|
4618
|
+
inline_id: Annotated[
|
|
4619
|
+
str | None, opt("--inline-id", metavar="DC:ID:HASH", help="Inline message id.")
|
|
4620
|
+
] = None
|
|
4621
|
+
user: Annotated[
|
|
4622
|
+
PeerRef | None, opt("--user", metavar="USER", kind="user", help="The player.")
|
|
4623
|
+
] = None
|
|
4624
|
+
score: Annotated[int, opt("--score", metavar="N", help="New score.")] = 0
|
|
4625
|
+
edit_message: Annotated[bool, opt("--edit-message", help="Also update the game message.")] = (
|
|
4626
|
+
False
|
|
4627
|
+
)
|
|
4628
|
+
allow_lower: Annotated[
|
|
4629
|
+
bool, opt("--allow-lower", help="Allow the score to decrease (force).")
|
|
4630
|
+
] = False
|
|
4631
|
+
|
|
4632
|
+
|
|
4633
|
+
async def score_set(ctx: OpContext, req: ScoreSetReq) -> ScoreSet:
|
|
4634
|
+
"""Report a game score for a user.
|
|
4635
|
+
|
|
4636
|
+
`--allow-lower` is Telegram's `force`: without it the server keeps the
|
|
4637
|
+
player's best score, which is almost always what a leaderboard wants.
|
|
4638
|
+
It is spelled out rather than borrowed from the global `--yes`, which an
|
|
4639
|
+
operation never sees.
|
|
4640
|
+
"""
|
|
4641
|
+
from telethon.tl.functions import messages as fn
|
|
4642
|
+
|
|
4643
|
+
await _bots.require_bot_session(ctx, "setting a game score")
|
|
4644
|
+
if req.user is None:
|
|
4645
|
+
raise UsageError("--user names the player and is required", field="user")
|
|
4646
|
+
user = await _bots.input_user(ctx, req.user, field="user")
|
|
4647
|
+
|
|
4648
|
+
if req.inline_id:
|
|
4649
|
+
identifier = _bots.inline_message_id(req.inline_id)
|
|
4650
|
+
await _bots.on_dc(
|
|
4651
|
+
ctx,
|
|
4652
|
+
int(getattr(identifier, "dc_id", 0) or 0),
|
|
4653
|
+
fn.SetInlineGameScoreRequest(
|
|
4654
|
+
id=identifier,
|
|
4655
|
+
user_id=user,
|
|
4656
|
+
score=req.score,
|
|
4657
|
+
edit_message=req.edit_message or None,
|
|
4658
|
+
force=req.allow_lower or None,
|
|
4659
|
+
),
|
|
4660
|
+
)
|
|
4661
|
+
else:
|
|
4662
|
+
if req.chat is None or req.msg_id is None:
|
|
4663
|
+
raise UsageError("give a chat and a message id, or --inline-id", field="msg_id")
|
|
4664
|
+
await client(ctx)(
|
|
4665
|
+
fn.SetGameScoreRequest(
|
|
4666
|
+
peer=await _send.resolve(ctx, req.chat),
|
|
4667
|
+
id=int(req.msg_id),
|
|
4668
|
+
user_id=user,
|
|
4669
|
+
score=req.score,
|
|
4670
|
+
edit_message=req.edit_message or None,
|
|
4671
|
+
force=req.allow_lower or None,
|
|
4672
|
+
)
|
|
4673
|
+
)
|
|
4674
|
+
return ScoreSet(user_id=_send.peer_id_of(await _send.resolve(ctx, req.user)), score=req.score)
|
|
4675
|
+
|
|
4676
|
+
|
|
4677
|
+
SPEC_SCORE_SET = OperationSpec(
|
|
4678
|
+
id="bot.score.set",
|
|
4679
|
+
request=ScoreSetReq,
|
|
4680
|
+
response=ScoreSet,
|
|
4681
|
+
impl=score_set,
|
|
4682
|
+
summary="Report a game score for a user",
|
|
4683
|
+
tags=frozenset({"visible-to-others"}),
|
|
4684
|
+
mutating=True,
|
|
4685
|
+
columns=("user_id", "score", "position"),
|
|
4686
|
+
headers=("User", "Score", "#"),
|
|
4687
|
+
example={"user_id": 4242, "score": 900},
|
|
4688
|
+
example_args="bot score set @alice 12 --user @alice --score 900",
|
|
4689
|
+
covers=("bots.set-game-score",),
|
|
4690
|
+
)
|
|
4691
|
+
|
|
4692
|
+
|
|
4693
|
+
# ---------------------------------------------------------------------------
|
|
4694
|
+
# Layer 229: ephemeral messages and bot welcome messages
|
|
4695
|
+
# ---------------------------------------------------------------------------
|
|
4696
|
+
|
|
4697
|
+
|
|
4698
|
+
class EphemeralSendReq(Request):
|
|
4699
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat it lives in.")]
|
|
4700
|
+
text: Annotated[str, arg(1, metavar="TEXT", help="Message text, or a /command.")]
|
|
4701
|
+
bot: Annotated[
|
|
4702
|
+
PeerRef | None,
|
|
4703
|
+
opt("--bot", metavar="BOT", kind="user", help="Bot the conversation belongs to."),
|
|
4704
|
+
] = None
|
|
4705
|
+
receiver: Annotated[
|
|
4706
|
+
PeerRef | None,
|
|
4707
|
+
opt("--receiver", metavar="USER", kind="user", help="Who alone will see it (bot side)."),
|
|
4708
|
+
] = None
|
|
4709
|
+
reply_to: Annotated[
|
|
4710
|
+
int | None, opt("--reply-to", metavar="ID", help="Ephemeral message being replied to.")
|
|
4711
|
+
] = None
|
|
4712
|
+
query_id: Annotated[
|
|
4713
|
+
str | None, opt("--query-id", metavar="ID", help="Guest/callback query this answers.")
|
|
4714
|
+
] = None
|
|
4715
|
+
keyboard: Annotated[
|
|
4716
|
+
str | None, opt("--keyboard", metavar="PATH", kind="path", help="JSON keyboard.")
|
|
4717
|
+
] = None
|
|
4718
|
+
rich_file: Annotated[
|
|
4719
|
+
str | None, opt("--rich-file", metavar="PATH", kind="path", help="Send a rich body.")
|
|
4720
|
+
] = None
|
|
4721
|
+
anchor: Annotated[bool, opt("--anchor", help="Pin it to the triggering message.")] = False
|
|
4722
|
+
welcome: Annotated[bool, opt("--welcome", help="Store it as a welcome template.")] = False
|
|
4723
|
+
edit: Annotated[
|
|
4724
|
+
int | None, opt("--edit", metavar="ID", help="Edit this ephemeral message instead.")
|
|
4725
|
+
] = None
|
|
4726
|
+
parse: Annotated[str | None, choice("md", "html", "none", help="Text formatting.")] = None
|
|
4727
|
+
|
|
4728
|
+
|
|
4729
|
+
async def ephemeral_send(ctx: OpContext, req: EphemeralSendReq) -> EphemeralSent:
|
|
4730
|
+
"""Send an "only you can see this" bot message.
|
|
4731
|
+
|
|
4732
|
+
`ephemeral.sendMessage#ba8d5f35` and `ephemeral.editMessage#cf9c725b` are
|
|
4733
|
+
layer-229 constructors and the pinned Telethon speaks 227. The operation
|
|
4734
|
+
is registered rather than omitted so that `tlgr agent capabilities` can
|
|
4735
|
+
say the surface exists and is unavailable — which is a different answer
|
|
4736
|
+
from "no such command", and the one an agent can act on.
|
|
4737
|
+
"""
|
|
4738
|
+
_bots.unsupported("bot ephemeral send")
|
|
4739
|
+
raise AssertionError # pragma: no cover - unreachable; keeps mypy happy
|
|
4740
|
+
|
|
4741
|
+
|
|
4742
|
+
SPEC_EPHEMERAL_SEND = OperationSpec(
|
|
4743
|
+
id="bot.ephemeral.send",
|
|
4744
|
+
request=EphemeralSendReq,
|
|
4745
|
+
response=EphemeralSent,
|
|
4746
|
+
impl=ephemeral_send,
|
|
4747
|
+
summary="Send an ephemeral ('only you can see this') bot message",
|
|
4748
|
+
description=(
|
|
4749
|
+
"Layer 229. Exits 13 (NOT_SUPPORTED) until the pinned Telethon speaks "
|
|
4750
|
+
"it: hand-rolling the request would mean guessing at constructor ids "
|
|
4751
|
+
"for parameters nobody has published."
|
|
4752
|
+
),
|
|
4753
|
+
mutating=True,
|
|
4754
|
+
rate_class="send",
|
|
4755
|
+
columns=("chat_id", "ephemeral_id"),
|
|
4756
|
+
headers=("Chat", "Ephemeral"),
|
|
4757
|
+
example={"chat_id": 4242, "ephemeral_id": 0},
|
|
4758
|
+
example_args="bot ephemeral send @alice Hello",
|
|
4759
|
+
tags=frozenset({"not-supported"}),
|
|
4760
|
+
)
|
|
4761
|
+
|
|
4762
|
+
|
|
4763
|
+
class EphemeralDeleteReq(Request):
|
|
4764
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat it lives in.")]
|
|
4765
|
+
id: Annotated[
|
|
4766
|
+
list[int], arg(1, metavar="ID", variadic=True, help="Ephemeral message ids.")
|
|
4767
|
+
] = []
|
|
4768
|
+
receiver: Annotated[
|
|
4769
|
+
PeerRef | None,
|
|
4770
|
+
opt("--receiver", metavar="USER", kind="user", help="Whose copy is deleted (bot side)."),
|
|
4771
|
+
] = None
|
|
4772
|
+
dismiss: Annotated[bool, opt("--dismiss", help="Only clear it locally.")] = False
|
|
4773
|
+
|
|
4774
|
+
|
|
4775
|
+
async def ephemeral_delete(ctx: OpContext, req: EphemeralDeleteReq) -> EphemeralDeleted:
|
|
4776
|
+
"""Delete or dismiss an ephemeral bot message. Layer 229; exits 13."""
|
|
4777
|
+
_bots.unsupported("bot ephemeral delete")
|
|
4778
|
+
raise AssertionError # pragma: no cover - unreachable; keeps mypy happy
|
|
4779
|
+
|
|
4780
|
+
|
|
4781
|
+
SPEC_EPHEMERAL_DELETE = OperationSpec(
|
|
4782
|
+
id="bot.ephemeral.delete",
|
|
4783
|
+
request=EphemeralDeleteReq,
|
|
4784
|
+
response=EphemeralDeleted,
|
|
4785
|
+
impl=ephemeral_delete,
|
|
4786
|
+
summary="Delete or dismiss an ephemeral bot message",
|
|
4787
|
+
mutating=True,
|
|
4788
|
+
destructive=True,
|
|
4789
|
+
columns=("chat_id", "deleted"),
|
|
4790
|
+
headers=("Chat", "Deleted"),
|
|
4791
|
+
example={"chat_id": 4242, "deleted": 0},
|
|
4792
|
+
example_args="bot ephemeral delete @alice 12",
|
|
4793
|
+
tags=frozenset({"not-supported"}),
|
|
4794
|
+
)
|
|
4795
|
+
|
|
4796
|
+
|
|
4797
|
+
class WelcomeListReq(Request):
|
|
4798
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The chat.")]
|
|
4799
|
+
|
|
4800
|
+
|
|
4801
|
+
async def welcome_list(ctx: OpContext, req: WelcomeListReq) -> Page[BotWelcomeMessage]:
|
|
4802
|
+
"""A chat's bot welcome-message templates. Layer 229; exits 13."""
|
|
4803
|
+
_bots.unsupported("bot welcome list")
|
|
4804
|
+
raise AssertionError # pragma: no cover - unreachable; keeps mypy happy
|
|
4805
|
+
|
|
4806
|
+
|
|
4807
|
+
SPEC_WELCOME_LIST = OperationSpec(
|
|
4808
|
+
id="bot.welcome.list",
|
|
4809
|
+
request=WelcomeListReq,
|
|
4810
|
+
response=Page[BotWelcomeMessage],
|
|
4811
|
+
impl=welcome_list,
|
|
4812
|
+
summary="List a chat's bot welcome-message templates",
|
|
4813
|
+
paginated=PageKind.LOCAL,
|
|
4814
|
+
columns=("id", "text"),
|
|
4815
|
+
headers=("ID", "Text"),
|
|
4816
|
+
example={"items": [], "has_more": False},
|
|
4817
|
+
example_args="bot welcome list @mygroup",
|
|
4818
|
+
tags=frozenset({"not-supported"}),
|
|
4819
|
+
)
|
|
4820
|
+
|
|
4821
|
+
|
|
4822
|
+
class WelcomeSetReq(Request):
|
|
4823
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The chat.")]
|
|
4824
|
+
text: Annotated[str, arg(1, metavar="TEXT", help="Welcome text.")]
|
|
4825
|
+
id: Annotated[int | None, opt("--id", metavar="ID", help="Edit this one instead.")] = None
|
|
4826
|
+
keyboard: Annotated[
|
|
4827
|
+
str | None, opt("--keyboard", metavar="PATH", kind="path", help="JSON keyboard.")
|
|
4828
|
+
] = None
|
|
4829
|
+
parse: Annotated[str | None, choice("md", "html", "none", help="Text formatting.")] = None
|
|
4830
|
+
|
|
4831
|
+
|
|
4832
|
+
async def welcome_set(ctx: OpContext, req: WelcomeSetReq) -> WelcomeSet:
|
|
4833
|
+
"""Add or edit a chat's bot welcome message. Layer 229; exits 13."""
|
|
4834
|
+
_bots.unsupported("bot welcome set")
|
|
4835
|
+
raise AssertionError # pragma: no cover - unreachable; keeps mypy happy
|
|
4836
|
+
|
|
4837
|
+
|
|
4838
|
+
SPEC_WELCOME_SET = OperationSpec(
|
|
4839
|
+
id="bot.welcome.set",
|
|
4840
|
+
request=WelcomeSetReq,
|
|
4841
|
+
response=WelcomeSet,
|
|
4842
|
+
impl=welcome_set,
|
|
4843
|
+
summary="Add or edit a chat's bot welcome message",
|
|
4844
|
+
mutating=True,
|
|
4845
|
+
columns=("chat_id", "id", "text"),
|
|
4846
|
+
headers=("Chat", "ID", "Text"),
|
|
4847
|
+
example={"chat_id": -1001, "id": 0, "text": "Welcome!"},
|
|
4848
|
+
example_args="bot welcome set @mygroup Welcome!",
|
|
4849
|
+
tags=frozenset({"not-supported"}),
|
|
4850
|
+
)
|
|
4851
|
+
|
|
4852
|
+
|
|
4853
|
+
class WelcomeDeleteReq(Request):
|
|
4854
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The chat.")]
|
|
4855
|
+
id: Annotated[
|
|
4856
|
+
list[int], arg(1, metavar="ID", required=False, variadic=True, help="Welcome message ids.")
|
|
4857
|
+
] = []
|
|
4858
|
+
delete_all: Annotated[bool, opt("--all", help="Delete every welcome message.")] = False
|
|
4859
|
+
|
|
4860
|
+
|
|
4861
|
+
async def welcome_delete(ctx: OpContext, req: WelcomeDeleteReq) -> WelcomeDeleted:
|
|
4862
|
+
"""Delete a chat's bot welcome messages. Layer 229; exits 13."""
|
|
4863
|
+
_bots.unsupported("bot welcome delete")
|
|
4864
|
+
raise AssertionError # pragma: no cover - unreachable; keeps mypy happy
|
|
4865
|
+
|
|
4866
|
+
|
|
4867
|
+
SPEC_WELCOME_DELETE = OperationSpec(
|
|
4868
|
+
id="bot.welcome.delete",
|
|
4869
|
+
request=WelcomeDeleteReq,
|
|
4870
|
+
response=WelcomeDeleted,
|
|
4871
|
+
impl=welcome_delete,
|
|
4872
|
+
summary="Delete one or all of a chat's bot welcome messages",
|
|
4873
|
+
mutating=True,
|
|
4874
|
+
destructive=True,
|
|
4875
|
+
columns=("chat_id", "deleted"),
|
|
4876
|
+
headers=("Chat", "Deleted"),
|
|
4877
|
+
example={"chat_id": -1001, "deleted": 0},
|
|
4878
|
+
example_args="bot welcome delete @mygroup 1",
|
|
4879
|
+
tags=frozenset({"not-supported"}),
|
|
4880
|
+
)
|