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/_bots.py
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
"""The plumbing the bot, inline, mini-app and payment groups all need.
|
|
2
|
+
|
|
3
|
+
Four things live here rather than in one of the four modules, because all four
|
|
4
|
+
reach for them and a second copy is how they start to disagree:
|
|
5
|
+
|
|
6
|
+
* **the keyboard schema**, read *and* write. One vocabulary of button `type`
|
|
7
|
+
names serves `message get --json`, `bot press --button <text>` and
|
|
8
|
+
`--keyboard FILE`, so a button that can be read back can be pressed and
|
|
9
|
+
re-sent.
|
|
10
|
+
* **the bot-session gate.** Half of this surface is bot-only, and Telegram
|
|
11
|
+
answers a user session with a bare `403 BOT_METHOD_INVALID`. Asking the
|
|
12
|
+
session what it is first turns that into exit 4 with a sentence saying how
|
|
13
|
+
to add a bot account.
|
|
14
|
+
* **DC routing.** An inline message id names the DC it lives on, and sending
|
|
15
|
+
`editInlineBotMessage` to the home DC fails with an error that says nothing
|
|
16
|
+
about DCs. One helper borrows the exported sender for every caller.
|
|
17
|
+
* **the report option tree.** `messages.report`, `messages.reportSponsoredMessage`
|
|
18
|
+
and the mini-app report all walk the same
|
|
19
|
+
chooseOption → addComment → reported state machine.
|
|
20
|
+
|
|
21
|
+
Telethon is imported inside functions, never at module scope (§2.2).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import base64
|
|
27
|
+
import binascii
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from tlgr.core.errors import AuthenticationError, NotSupportedError, UsageError
|
|
34
|
+
from tlgr.models.bot import Keyboard, KeyboardButton, ReportOutcome
|
|
35
|
+
from tlgr.models.peer import PeerRef
|
|
36
|
+
from tlgr.ops import _send
|
|
37
|
+
from tlgr.ops._common import client
|
|
38
|
+
from tlgr.ops._spec import OpContext
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"ADMIN_RIGHTS",
|
|
42
|
+
"BUTTON_TYPES",
|
|
43
|
+
"LAYER_229",
|
|
44
|
+
"admin_rights",
|
|
45
|
+
"bot_peer",
|
|
46
|
+
"client",
|
|
47
|
+
"command_scope",
|
|
48
|
+
"data_json",
|
|
49
|
+
"inline_message_id",
|
|
50
|
+
"input_user",
|
|
51
|
+
"keyboard_model",
|
|
52
|
+
"keyboard_tl",
|
|
53
|
+
"load_json",
|
|
54
|
+
"on_dc",
|
|
55
|
+
"option_bytes",
|
|
56
|
+
"payload_bytes",
|
|
57
|
+
"peer_ref",
|
|
58
|
+
"report_outcome",
|
|
59
|
+
"require_bot_session",
|
|
60
|
+
"rights_keywords",
|
|
61
|
+
"unsupported",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
#: The one sentence every layer-gap refusal ends with. Written once so that
|
|
65
|
+
#: `tlgr agent capabilities` and the docs cannot describe the gap differently.
|
|
66
|
+
LAYER_229 = (
|
|
67
|
+
"it is a layer-229 method and the pinned Telethon speaks layer 227; "
|
|
68
|
+
"tlgr refuses rather than guessing at a constructor id"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def unsupported(feature: str, reason: str = LAYER_229) -> Any:
|
|
73
|
+
"""Refuse a feature this build genuinely cannot perform (exit 13)."""
|
|
74
|
+
raise NotSupportedError(f"{feature} is not supported: {reason}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Peers and sessions
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def peer_ref(value: str) -> PeerRef:
|
|
83
|
+
"""A `@username`/id string as a `PeerRef`, for a bot named in config."""
|
|
84
|
+
from tlgr.models.peer import parse_peer_ref
|
|
85
|
+
|
|
86
|
+
return parse_peer_ref(value)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def bot_peer(ctx: OpContext, ref: PeerRef | str | None) -> Any:
|
|
90
|
+
"""The `InputPeer` of a bot, through the account's own resolver (§6.6)."""
|
|
91
|
+
if isinstance(ref, str):
|
|
92
|
+
ref = peer_ref(ref)
|
|
93
|
+
return await _send.resolve(ctx, ref)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def input_user(ctx: OpContext, ref: PeerRef | str | None, *, field: str = "bot") -> Any:
|
|
97
|
+
"""The `InputUser` a `bots.*` request wants.
|
|
98
|
+
|
|
99
|
+
`utils.get_input_user` is arithmetic on a peer we already resolved; going
|
|
100
|
+
back to the network would hide the real problem when the ref names a chat.
|
|
101
|
+
"""
|
|
102
|
+
from telethon import utils
|
|
103
|
+
|
|
104
|
+
peer = await bot_peer(ctx, ref)
|
|
105
|
+
try:
|
|
106
|
+
return utils.get_input_user(peer)
|
|
107
|
+
except (TypeError, ValueError) as exc:
|
|
108
|
+
raise UsageError(f"{field} must name a user or a bot", field=field) from exc
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def require_bot_session(ctx: OpContext, what: str) -> Any:
|
|
112
|
+
"""Refuse a bot-only operation on a user session, with exit 4.
|
|
113
|
+
|
|
114
|
+
Telegram answers `BOT_METHOD_INVALID`, which reads like a bug in the
|
|
115
|
+
request rather than like "this account is a person".
|
|
116
|
+
"""
|
|
117
|
+
account = await client(ctx).get_me()
|
|
118
|
+
if not bool(getattr(account, "bot", False)):
|
|
119
|
+
raise AuthenticationError(
|
|
120
|
+
f"{what} needs a bot session; add one with "
|
|
121
|
+
"`tlgr account add --bot-token <token>` and pass it with -a"
|
|
122
|
+
)
|
|
123
|
+
return account
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# Admin rights
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
#: The keyword vocabulary `--admin`/`--group`/`--channel` accept, in the
|
|
131
|
+
#: spelling the groups-and-channels group uses.
|
|
132
|
+
ADMIN_RIGHTS: tuple[str, ...] = (
|
|
133
|
+
"change_info",
|
|
134
|
+
"post_messages",
|
|
135
|
+
"edit_messages",
|
|
136
|
+
"delete_messages",
|
|
137
|
+
"ban_users",
|
|
138
|
+
"invite_users",
|
|
139
|
+
"pin_messages",
|
|
140
|
+
"add_admins",
|
|
141
|
+
"anonymous",
|
|
142
|
+
"manage_call",
|
|
143
|
+
"other",
|
|
144
|
+
"manage_topics",
|
|
145
|
+
"post_stories",
|
|
146
|
+
"edit_stories",
|
|
147
|
+
"delete_stories",
|
|
148
|
+
"manage_direct_messages",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
#: The names the t.me deep links use, mapped onto the TL field they set.
|
|
152
|
+
_RIGHT_ALIASES = {
|
|
153
|
+
"manage_chat": "other",
|
|
154
|
+
"restrict_members": "ban_users",
|
|
155
|
+
"promote_members": "add_admins",
|
|
156
|
+
"manage_video_chats": "manage_call",
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def admin_rights(text: str | None, *, field: str = "admin") -> Any:
|
|
161
|
+
"""`change_info+invite_users` as a `ChatAdminRights`, or None."""
|
|
162
|
+
if not text:
|
|
163
|
+
return None
|
|
164
|
+
from telethon.tl import types
|
|
165
|
+
|
|
166
|
+
flags: dict[str, bool] = {}
|
|
167
|
+
for raw in str(text).replace(",", "+").split("+"):
|
|
168
|
+
name = raw.strip().lower()
|
|
169
|
+
if not name:
|
|
170
|
+
continue
|
|
171
|
+
name = _RIGHT_ALIASES.get(name, name)
|
|
172
|
+
if name not in ADMIN_RIGHTS:
|
|
173
|
+
raise UsageError(
|
|
174
|
+
f"--{field}: {raw!r} is not an admin right; choose from {', '.join(ADMIN_RIGHTS)}",
|
|
175
|
+
field=field,
|
|
176
|
+
)
|
|
177
|
+
flags[name] = True
|
|
178
|
+
return types.ChatAdminRights(**flags)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def rights_keywords(rights: Any) -> list[str]:
|
|
182
|
+
"""The keywords a `ChatAdminRights` has set, in the documented order."""
|
|
183
|
+
if rights is None:
|
|
184
|
+
return []
|
|
185
|
+
return [name for name in ADMIN_RIGHTS if bool(getattr(rights, name, False))]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
# Payloads
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def load_json(value: str | None, *, field: str) -> Any:
|
|
194
|
+
"""Inline JSON, `@path`, or a bare path — whichever the caller typed.
|
|
195
|
+
|
|
196
|
+
A JSON document large enough to be worth writing is large enough to be
|
|
197
|
+
worth keeping in a file, and a document small enough to type belongs on
|
|
198
|
+
the command line; supporting only one of the two is what makes a caller
|
|
199
|
+
write `--params "$(cat f.json)"`.
|
|
200
|
+
"""
|
|
201
|
+
if value is None:
|
|
202
|
+
return None
|
|
203
|
+
text = value.strip()
|
|
204
|
+
if text.startswith("@"):
|
|
205
|
+
text = _read(text[1:], field=field)
|
|
206
|
+
elif not text.startswith(("{", "[", '"')) and not text.lstrip("-").isdigit():
|
|
207
|
+
text = _read(text, field=field)
|
|
208
|
+
try:
|
|
209
|
+
return json.loads(text)
|
|
210
|
+
except json.JSONDecodeError as exc:
|
|
211
|
+
raise UsageError(f"--{field}: {exc}", field=field) from exc
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _read(path: str, *, field: str) -> str:
|
|
215
|
+
handle = Path(os.path.expanduser(path))
|
|
216
|
+
try:
|
|
217
|
+
return handle.read_text(encoding="utf-8")
|
|
218
|
+
except OSError as exc:
|
|
219
|
+
raise UsageError(f"--{field}: {exc.strerror or exc}", field=field) from exc
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def data_json(value: str | None, *, field: str) -> Any:
|
|
223
|
+
"""A `DataJSON` built from whatever `load_json` accepts."""
|
|
224
|
+
from telethon.tl import types
|
|
225
|
+
|
|
226
|
+
payload = load_json(value, field=field)
|
|
227
|
+
if payload is None:
|
|
228
|
+
return None
|
|
229
|
+
return types.DataJSON(data=json.dumps(payload, separators=(",", ":")))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def payload_bytes(value: str | None, *, field: str) -> bytes | None:
|
|
233
|
+
"""Callback payload bytes from `hex:…`, `str:…`, `@file`, or bare hex/text.
|
|
234
|
+
|
|
235
|
+
Callback data is *bytes*, and most of it is not text; a flag that only
|
|
236
|
+
accepted text would make half the buttons on Telegram unpressable, and one
|
|
237
|
+
that only accepted hex would make the other half unreadable.
|
|
238
|
+
"""
|
|
239
|
+
if value is None:
|
|
240
|
+
return None
|
|
241
|
+
text = str(value)
|
|
242
|
+
if text.startswith("hex:"):
|
|
243
|
+
return _unhex(text[4:], field=field)
|
|
244
|
+
if text.startswith("str:"):
|
|
245
|
+
return text[4:].encode()
|
|
246
|
+
if text.startswith("b64:"):
|
|
247
|
+
return _unb64(text[4:], field=field)
|
|
248
|
+
if text.startswith("@"):
|
|
249
|
+
try:
|
|
250
|
+
return Path(os.path.expanduser(text[1:])).read_bytes()
|
|
251
|
+
except OSError as exc:
|
|
252
|
+
raise UsageError(f"--{field}: {exc.strerror or exc}", field=field) from exc
|
|
253
|
+
stripped = text.strip()
|
|
254
|
+
if stripped and len(stripped) % 2 == 0 and all(c in "0123456789abcdefABCDEF" for c in stripped):
|
|
255
|
+
return _unhex(stripped, field=field)
|
|
256
|
+
return text.encode()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def option_bytes(value: str | None, *, field: str = "option") -> bytes:
|
|
260
|
+
"""Report-option bytes, as `bot ad list`/a previous step printed them."""
|
|
261
|
+
if not value:
|
|
262
|
+
return b""
|
|
263
|
+
return payload_bytes(value, field=field) or b""
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _unhex(text: str, *, field: str) -> bytes:
|
|
267
|
+
try:
|
|
268
|
+
return binascii.unhexlify(text.strip())
|
|
269
|
+
except (binascii.Error, ValueError) as exc:
|
|
270
|
+
raise UsageError(f"--{field}: {text!r} is not hexadecimal", field=field) from exc
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _unb64(text: str, *, field: str) -> bytes:
|
|
274
|
+
try:
|
|
275
|
+
return base64.b64decode(text + "=" * (-len(text) % 4))
|
|
276
|
+
except (binascii.Error, ValueError) as exc:
|
|
277
|
+
raise UsageError(f"--{field}: {text!r} is not base64", field=field) from exc
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def key_text(raw: bytes | None) -> str:
|
|
281
|
+
"""Opaque bytes as something a shell can round-trip: text, else base64."""
|
|
282
|
+
if not raw:
|
|
283
|
+
return ""
|
|
284
|
+
try:
|
|
285
|
+
return raw.decode()
|
|
286
|
+
except UnicodeDecodeError:
|
|
287
|
+
return base64.b64encode(raw).decode()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
# Keyboards — the schema, both ways
|
|
292
|
+
# ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
#: TL class suffix → the `type` name the JSON schema uses.
|
|
295
|
+
BUTTON_TYPES: dict[str, str] = {
|
|
296
|
+
"KeyboardButton": "text",
|
|
297
|
+
"KeyboardButtonCallback": "callback",
|
|
298
|
+
"KeyboardButtonUrl": "url",
|
|
299
|
+
"KeyboardButtonUrlAuth": "url_auth",
|
|
300
|
+
"InputKeyboardButtonUrlAuth": "url_auth",
|
|
301
|
+
"KeyboardButtonSwitchInline": "switch_inline",
|
|
302
|
+
"KeyboardButtonWebView": "webview",
|
|
303
|
+
"KeyboardButtonSimpleWebView": "simple_webview",
|
|
304
|
+
"KeyboardButtonGame": "game",
|
|
305
|
+
"KeyboardButtonBuy": "buy",
|
|
306
|
+
"KeyboardButtonRequestPhone": "request_phone",
|
|
307
|
+
"KeyboardButtonRequestGeoLocation": "request_geo",
|
|
308
|
+
"KeyboardButtonRequestPoll": "request_poll",
|
|
309
|
+
"KeyboardButtonRequestPeer": "request_peer",
|
|
310
|
+
"KeyboardButtonUserProfile": "user_profile",
|
|
311
|
+
"InputKeyboardButtonUserProfile": "user_profile",
|
|
312
|
+
"KeyboardButtonCopy": "copy",
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
_MARKUP_KINDS = {
|
|
316
|
+
"ReplyInlineMarkup": "inline",
|
|
317
|
+
"ReplyKeyboardMarkup": "keyboard",
|
|
318
|
+
"ReplyKeyboardHide": "hide",
|
|
319
|
+
"ReplyKeyboardForceReply": "force_reply",
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def button_model(button: Any) -> KeyboardButton:
|
|
324
|
+
"""One TL button as the schema row `--keyboard` would write."""
|
|
325
|
+
kind = BUTTON_TYPES.get(type(button).__name__, "unsupported")
|
|
326
|
+
data = getattr(button, "data", None)
|
|
327
|
+
return KeyboardButton(
|
|
328
|
+
text=str(getattr(button, "text", "") or ""),
|
|
329
|
+
type=kind,
|
|
330
|
+
data=key_text(data) if data else None,
|
|
331
|
+
url=getattr(button, "url", None),
|
|
332
|
+
query=getattr(button, "query", None),
|
|
333
|
+
user_id=_user_id(getattr(button, "user_id", None)),
|
|
334
|
+
requires_password=bool(getattr(button, "requires_password", False)),
|
|
335
|
+
same_peer=bool(getattr(button, "same_peer", False)),
|
|
336
|
+
copy_text=getattr(button, "copy_text", None),
|
|
337
|
+
button_id=getattr(button, "button_id", None),
|
|
338
|
+
fwd_text=getattr(button, "fwd_text", None),
|
|
339
|
+
request_write_access=bool(getattr(button, "request_write_access", False)),
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _user_id(value: Any) -> int | None:
|
|
344
|
+
if value is None:
|
|
345
|
+
return None
|
|
346
|
+
if isinstance(value, int):
|
|
347
|
+
return value
|
|
348
|
+
return int(getattr(value, "user_id", 0) or 0) or None
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def keyboard_model(markup: Any) -> Keyboard | None:
|
|
352
|
+
"""A TL reply markup as the `Keyboard` schema, or None."""
|
|
353
|
+
if markup is None:
|
|
354
|
+
return None
|
|
355
|
+
kind = _MARKUP_KINDS.get(type(markup).__name__)
|
|
356
|
+
if kind is None:
|
|
357
|
+
return None
|
|
358
|
+
rows = [
|
|
359
|
+
[button_model(button) for button in (getattr(row, "buttons", None) or [])]
|
|
360
|
+
for row in (getattr(markup, "rows", None) or [])
|
|
361
|
+
]
|
|
362
|
+
return Keyboard(
|
|
363
|
+
kind=kind,
|
|
364
|
+
rows=rows,
|
|
365
|
+
resize=bool(getattr(markup, "resize", False)),
|
|
366
|
+
single_use=bool(getattr(markup, "single_use", False)),
|
|
367
|
+
selective=bool(getattr(markup, "selective", False)),
|
|
368
|
+
persistent=bool(getattr(markup, "persistent", False)),
|
|
369
|
+
placeholder=getattr(markup, "placeholder", None),
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def keyboard_tl(spec: Any, *, field: str = "keyboard") -> Any:
|
|
374
|
+
"""The `Keyboard` schema as a TL reply markup.
|
|
375
|
+
|
|
376
|
+
Only the button kinds a *client* can legitimately author are built. A
|
|
377
|
+
`url_auth` button carries an `InputUser` the caller has not resolved and a
|
|
378
|
+
`buy` button starts a payment, so both are refused here rather than half
|
|
379
|
+
built.
|
|
380
|
+
"""
|
|
381
|
+
from telethon.tl import types
|
|
382
|
+
|
|
383
|
+
if spec is None:
|
|
384
|
+
return None
|
|
385
|
+
if isinstance(spec, str):
|
|
386
|
+
spec = load_json(spec, field=field)
|
|
387
|
+
if not isinstance(spec, dict):
|
|
388
|
+
raise UsageError(f"--{field}: expected a keyboard object", field=field)
|
|
389
|
+
|
|
390
|
+
kind = str(spec.get("kind") or "inline")
|
|
391
|
+
rows: list[Any] = []
|
|
392
|
+
for row in spec.get("rows") or []:
|
|
393
|
+
buttons = [_button_tl(entry, field=field) for entry in row]
|
|
394
|
+
rows.append(types.KeyboardButtonRow(buttons=buttons))
|
|
395
|
+
if kind == "inline":
|
|
396
|
+
return types.ReplyInlineMarkup(rows=rows)
|
|
397
|
+
if kind == "keyboard":
|
|
398
|
+
return types.ReplyKeyboardMarkup(
|
|
399
|
+
rows=rows,
|
|
400
|
+
resize=bool(spec.get("resize")) or None,
|
|
401
|
+
single_use=bool(spec.get("single_use")) or None,
|
|
402
|
+
selective=bool(spec.get("selective")) or None,
|
|
403
|
+
persistent=bool(spec.get("persistent")) or None,
|
|
404
|
+
placeholder=spec.get("placeholder"),
|
|
405
|
+
)
|
|
406
|
+
if kind == "hide":
|
|
407
|
+
return types.ReplyKeyboardHide(selective=bool(spec.get("selective")) or None)
|
|
408
|
+
if kind == "force_reply":
|
|
409
|
+
return types.ReplyKeyboardForceReply(
|
|
410
|
+
single_use=bool(spec.get("single_use")) or None,
|
|
411
|
+
selective=bool(spec.get("selective")) or None,
|
|
412
|
+
placeholder=spec.get("placeholder"),
|
|
413
|
+
)
|
|
414
|
+
raise UsageError(f"--{field}: kind must be inline, keyboard, hide or force_reply", field=field)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _button_tl(entry: Any, *, field: str) -> Any:
|
|
418
|
+
from telethon.tl import types
|
|
419
|
+
|
|
420
|
+
if not isinstance(entry, dict):
|
|
421
|
+
raise UsageError(f"--{field}: every button must be an object", field=field)
|
|
422
|
+
text = str(entry.get("text") or "")
|
|
423
|
+
kind = str(entry.get("type") or "text")
|
|
424
|
+
if kind == "text":
|
|
425
|
+
return types.KeyboardButton(text=text)
|
|
426
|
+
if kind == "callback":
|
|
427
|
+
return types.KeyboardButtonCallback(
|
|
428
|
+
text=text,
|
|
429
|
+
data=payload_bytes(entry.get("data"), field=field) or b"",
|
|
430
|
+
requires_password=bool(entry.get("requires_password")) or None,
|
|
431
|
+
)
|
|
432
|
+
if kind == "url":
|
|
433
|
+
return types.KeyboardButtonUrl(text=text, url=str(entry.get("url") or ""))
|
|
434
|
+
if kind == "switch_inline":
|
|
435
|
+
return types.KeyboardButtonSwitchInline(
|
|
436
|
+
text=text,
|
|
437
|
+
query=str(entry.get("query") or ""),
|
|
438
|
+
same_peer=bool(entry.get("same_peer")) or None,
|
|
439
|
+
)
|
|
440
|
+
if kind == "webview":
|
|
441
|
+
return types.KeyboardButtonWebView(text=text, url=str(entry.get("url") or ""))
|
|
442
|
+
if kind == "simple_webview":
|
|
443
|
+
return types.KeyboardButtonSimpleWebView(text=text, url=str(entry.get("url") or ""))
|
|
444
|
+
if kind == "copy":
|
|
445
|
+
return types.KeyboardButtonCopy(text=text, copy_text=str(entry.get("copy_text") or ""))
|
|
446
|
+
if kind == "game":
|
|
447
|
+
return types.KeyboardButtonGame(text=text)
|
|
448
|
+
if kind == "request_phone":
|
|
449
|
+
return types.KeyboardButtonRequestPhone(text=text)
|
|
450
|
+
if kind == "request_geo":
|
|
451
|
+
return types.KeyboardButtonRequestGeoLocation(text=text)
|
|
452
|
+
if kind == "request_poll":
|
|
453
|
+
return types.KeyboardButtonRequestPoll(text=text, quiz=entry.get("quiz"))
|
|
454
|
+
raise UsageError(
|
|
455
|
+
f"--{field}: {kind!r} is not a button kind tlgr can author "
|
|
456
|
+
"(text, callback, url, switch_inline, webview, simple_webview, copy, game, "
|
|
457
|
+
"request_phone, request_geo, request_poll)",
|
|
458
|
+
field=field,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# ---------------------------------------------------------------------------
|
|
463
|
+
# Inline message ids and DC routing
|
|
464
|
+
# ---------------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def inline_message_id(text: str, *, field: str = "inline_id") -> Any:
|
|
468
|
+
"""`dc:id:hash` (or `dc:owner:id:hash`) as an `InputBotInlineMessageID*`.
|
|
469
|
+
|
|
470
|
+
The id names the DC the message lives on, and every request that takes one
|
|
471
|
+
must be sent *there*; see `on_dc`.
|
|
472
|
+
"""
|
|
473
|
+
from telethon.tl import types
|
|
474
|
+
|
|
475
|
+
parts = [p for p in str(text).replace("-", ":").split(":") if p != ""]
|
|
476
|
+
try:
|
|
477
|
+
numbers = [int(p) for p in parts]
|
|
478
|
+
except ValueError as exc:
|
|
479
|
+
raise UsageError(
|
|
480
|
+
f"--{field}: expected 'dc:id:access_hash' (or 'dc:owner:id:access_hash')",
|
|
481
|
+
field=field,
|
|
482
|
+
) from exc
|
|
483
|
+
if len(numbers) == 3:
|
|
484
|
+
return types.InputBotInlineMessageID(
|
|
485
|
+
dc_id=numbers[0], id=numbers[1], access_hash=numbers[2]
|
|
486
|
+
)
|
|
487
|
+
if len(numbers) == 4:
|
|
488
|
+
return types.InputBotInlineMessageID64(
|
|
489
|
+
dc_id=numbers[0], owner_id=numbers[1], id=numbers[2], access_hash=numbers[3]
|
|
490
|
+
)
|
|
491
|
+
raise UsageError(
|
|
492
|
+
f"--{field}: expected 'dc:id:access_hash' (or 'dc:owner:id:access_hash')", field=field
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def inline_id_text(value: Any) -> str:
|
|
497
|
+
"""The round-trip spelling of an `InputBotInlineMessageID*`."""
|
|
498
|
+
owner = getattr(value, "owner_id", None)
|
|
499
|
+
parts = [getattr(value, "dc_id", 0)]
|
|
500
|
+
if owner is not None:
|
|
501
|
+
parts.append(owner)
|
|
502
|
+
parts += [getattr(value, "id", 0), getattr(value, "access_hash", 0)]
|
|
503
|
+
return ":".join(str(int(p or 0)) for p in parts)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
async def on_dc(ctx: OpContext, dc_id: int, request: Any) -> Any:
|
|
507
|
+
"""Send *request* to *dc_id* through an exported sender.
|
|
508
|
+
|
|
509
|
+
Inline message ids and web files live on a DC that is not necessarily the
|
|
510
|
+
home one, and sending there anyway fails with an error that says nothing
|
|
511
|
+
about data centres.
|
|
512
|
+
"""
|
|
513
|
+
handle = client(ctx)
|
|
514
|
+
if not dc_id:
|
|
515
|
+
return await handle(request)
|
|
516
|
+
sender = await handle._borrow_exported_sender(dc_id)
|
|
517
|
+
try:
|
|
518
|
+
return await sender.send(request)
|
|
519
|
+
finally:
|
|
520
|
+
await handle._return_exported_sender(sender)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
# ---------------------------------------------------------------------------
|
|
524
|
+
# Bot command scopes
|
|
525
|
+
# ---------------------------------------------------------------------------
|
|
526
|
+
|
|
527
|
+
_SCOPES = {
|
|
528
|
+
"default": "BotCommandScopeDefault",
|
|
529
|
+
"users": "BotCommandScopeUsers",
|
|
530
|
+
"chats": "BotCommandScopeChats",
|
|
531
|
+
"chat-admins": "BotCommandScopeChatAdmins",
|
|
532
|
+
"peer": "BotCommandScopePeer",
|
|
533
|
+
"peer-admins": "BotCommandScopePeerAdmins",
|
|
534
|
+
"peer-user": "BotCommandScopePeerUser",
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
async def command_scope(
|
|
539
|
+
ctx: OpContext, name: str, chat: PeerRef | None, user: PeerRef | None
|
|
540
|
+
) -> Any:
|
|
541
|
+
"""One of the seven `botCommandScope*` constructors."""
|
|
542
|
+
from telethon.tl import types
|
|
543
|
+
|
|
544
|
+
klass_name = _SCOPES.get(name)
|
|
545
|
+
if klass_name is None:
|
|
546
|
+
raise UsageError(f"--scope: {name!r} is not a command scope", field="scope")
|
|
547
|
+
klass = getattr(types, klass_name)
|
|
548
|
+
if name in ("peer", "peer-admins", "peer-user"):
|
|
549
|
+
if chat is None:
|
|
550
|
+
raise UsageError(f"--scope {name} needs --peer", field="peer")
|
|
551
|
+
peer = await _send.resolve(ctx, chat)
|
|
552
|
+
if name == "peer-user":
|
|
553
|
+
if user is None:
|
|
554
|
+
raise UsageError("--scope peer-user needs --user", field="user")
|
|
555
|
+
return klass(peer=peer, user_id=await input_user(ctx, user, field="user"))
|
|
556
|
+
return klass(peer=peer)
|
|
557
|
+
return klass()
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
# ---------------------------------------------------------------------------
|
|
561
|
+
# The report option tree
|
|
562
|
+
# ---------------------------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def report_outcome(result: Any) -> ReportOutcome:
|
|
566
|
+
"""One step of `reportResultChooseOption → addComment → reported`."""
|
|
567
|
+
name = type(result).__name__
|
|
568
|
+
if name == "ReportResultChooseOption":
|
|
569
|
+
return ReportOutcome(
|
|
570
|
+
result="choose_option",
|
|
571
|
+
title=str(getattr(result, "title", "") or ""),
|
|
572
|
+
options=[
|
|
573
|
+
{
|
|
574
|
+
"text": str(getattr(option, "text", "") or ""),
|
|
575
|
+
"option": key_text(getattr(option, "option", b"")),
|
|
576
|
+
}
|
|
577
|
+
for option in (getattr(result, "options", None) or [])
|
|
578
|
+
],
|
|
579
|
+
)
|
|
580
|
+
if name == "ReportResultAddComment":
|
|
581
|
+
return ReportOutcome(
|
|
582
|
+
result="add_comment",
|
|
583
|
+
options=[{"option": key_text(getattr(result, "option", b""))}],
|
|
584
|
+
title="optional" if getattr(result, "optional", False) else "required",
|
|
585
|
+
)
|
|
586
|
+
return ReportOutcome(result="reported", reported=True)
|