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/_calls.py
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"""Shared plumbing for the three call groups: `call`, `vc` and `conference`.
|
|
2
|
+
|
|
3
|
+
Three things live here because all three groups need them and none of them
|
|
4
|
+
owns them.
|
|
5
|
+
|
|
6
|
+
* **How a call is addressed.** A 1:1 call is `(id, access_hash)`; a group call
|
|
7
|
+
is that, or a conference `slug`, or the invitation service message, or just
|
|
8
|
+
"the video chat in this chat". One resolver understands all of it, so `vc
|
|
9
|
+
get @team`, `vc get 123:456` and `vc get t.me/call/AbCd` are the same
|
|
10
|
+
command rather than three.
|
|
11
|
+
* **What tlgr remembers.** Telegram hands out a call's `access_hash` exactly
|
|
12
|
+
once, in the update that created the call. A CLI whose process ends between
|
|
13
|
+
"ring" and "hang up" would otherwise be unable to hang up, so the daemon
|
|
14
|
+
keeps the live calls it has seen and `call end 12345` works with the bare
|
|
15
|
+
id. The store is per account and in memory: it describes a connection, not
|
|
16
|
+
a fact worth persisting.
|
|
17
|
+
* **The honest boundary.** tlgr speaks the signalling half of MTProto and has
|
|
18
|
+
no media engine. `MEDIA_NONE` is stamped on every answer that would
|
|
19
|
+
otherwise be mistaken for "you are now in a call".
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import base64
|
|
25
|
+
import hashlib
|
|
26
|
+
import secrets
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from functools import lru_cache
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from tlgr.core.errors import NotFoundError, UsageError
|
|
32
|
+
from tlgr.models.call import CallRef
|
|
33
|
+
from tlgr.models.peer import parse_peer_ref
|
|
34
|
+
from tlgr.ops._spec import OpContext
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"CallHandle",
|
|
38
|
+
"LiveCall",
|
|
39
|
+
"app_config",
|
|
40
|
+
"call_ref_of",
|
|
41
|
+
"discard_reason",
|
|
42
|
+
"forget_call",
|
|
43
|
+
"input_phone_call",
|
|
44
|
+
"live_calls",
|
|
45
|
+
"protocol",
|
|
46
|
+
"remember_call",
|
|
47
|
+
"resolve_call",
|
|
48
|
+
"state_name",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
#: The frozen legacy constant set every official client still sends. These are
|
|
52
|
+
#: not tlgr's capabilities — tlgr has no media engine at all — they are what
|
|
53
|
+
#: the peer's client needs to see to consider the call negotiable.
|
|
54
|
+
PROTOCOL_MIN_LAYER = 65
|
|
55
|
+
PROTOCOL_MAX_LAYER = 92
|
|
56
|
+
LIBRARY_VERSIONS = ("2.4.4", "2.7.7", "5.0.0")
|
|
57
|
+
|
|
58
|
+
#: `phoneCall*` constructor → the word tlgr reports. The constructor name is
|
|
59
|
+
#: the only place the ringing state machine is written down.
|
|
60
|
+
CALL_STATES = {
|
|
61
|
+
"PhoneCallEmpty": "empty",
|
|
62
|
+
"PhoneCallWaiting": "waiting",
|
|
63
|
+
"PhoneCallRequested": "requested",
|
|
64
|
+
"PhoneCallAccepted": "accepted",
|
|
65
|
+
"PhoneCall": "active",
|
|
66
|
+
"PhoneCallDiscarded": "discarded",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
DISCARD_REASONS = ("missed", "busy", "hangup", "disconnect")
|
|
70
|
+
|
|
71
|
+
_REASON_CLASSES = {
|
|
72
|
+
"missed": "PhoneCallDiscardReasonMissed",
|
|
73
|
+
"busy": "PhoneCallDiscardReasonBusy",
|
|
74
|
+
"hangup": "PhoneCallDiscardReasonHangup",
|
|
75
|
+
"disconnect": "PhoneCallDiscardReasonDisconnect",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#: The app-config keys the call surface reads. Kept as one list so `call
|
|
79
|
+
#: config get` and `vc get --limits` cannot disagree about what a limit is
|
|
80
|
+
#: called.
|
|
81
|
+
APP_CONFIG_KEYS = (
|
|
82
|
+
"call_requests_disabled",
|
|
83
|
+
"conference_call_size_limit",
|
|
84
|
+
"group_call_message_length_limit",
|
|
85
|
+
"group_call_message_ttl",
|
|
86
|
+
"groupcall_video_participants_max",
|
|
87
|
+
"stars_groupcall_message_amount_max",
|
|
88
|
+
"stars_groupcall_message_limits",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# The live-call store
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class LiveCall:
|
|
99
|
+
"""A 1:1 call this daemon has seen, and the secrets it holds for it.
|
|
100
|
+
|
|
101
|
+
`a`, `g_a` and `p` exist only when *this* session ran the key exchange;
|
|
102
|
+
they never leave the process and are what makes an emoji fingerprint
|
|
103
|
+
derivable at all. Nothing here is written to disk.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
id: int
|
|
107
|
+
access_hash: int
|
|
108
|
+
state: str = "waiting"
|
|
109
|
+
video: bool = False
|
|
110
|
+
out: bool = False
|
|
111
|
+
peer_id: int | None = None
|
|
112
|
+
admin_id: int | None = None
|
|
113
|
+
participant_id: int | None = None
|
|
114
|
+
conference_supported: bool | None = None
|
|
115
|
+
connections: int = 0
|
|
116
|
+
need_rating: bool | None = None
|
|
117
|
+
need_debug: bool | None = None
|
|
118
|
+
reason: str | None = None
|
|
119
|
+
duration: int | None = None
|
|
120
|
+
a: int | None = None
|
|
121
|
+
p: int | None = None
|
|
122
|
+
g: int | None = None
|
|
123
|
+
g_a: bytes | None = None
|
|
124
|
+
key: bytes | None = None
|
|
125
|
+
discarded: bool = False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class _Store:
|
|
130
|
+
calls: dict[int, LiveCall] = field(default_factory=dict)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
_STORES: dict[str, _Store] = {}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def live_calls(account: str) -> dict[int, LiveCall]:
|
|
137
|
+
"""Every call this daemon currently knows about for *account*."""
|
|
138
|
+
return _STORES.setdefault(account, _Store()).calls
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def remember_call(account: str, call: LiveCall) -> LiveCall:
|
|
142
|
+
"""Record (or merge into) what we know about a call."""
|
|
143
|
+
known = live_calls(account).get(call.id)
|
|
144
|
+
if known is None:
|
|
145
|
+
live_calls(account)[call.id] = call
|
|
146
|
+
return call
|
|
147
|
+
for name, value in vars(call).items():
|
|
148
|
+
if value not in (None, 0, False, "") or getattr(known, name, None) is None:
|
|
149
|
+
setattr(known, name, value)
|
|
150
|
+
return known
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def forget_call(account: str, call_id: int) -> None:
|
|
154
|
+
live_calls(account).pop(call_id, None)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def reset_calls() -> None:
|
|
158
|
+
"""Drop every remembered call. For tests, and for a daemon restart."""
|
|
159
|
+
_STORES.clear()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# 1:1 call references
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def parse_call_id(text: str) -> tuple[int, int | None]:
|
|
168
|
+
"""`"12345"` or `"12345:678"` → `(id, access_hash | None)`."""
|
|
169
|
+
raw = str(text or "").strip()
|
|
170
|
+
if not raw:
|
|
171
|
+
raise UsageError("a call id is required", field="call")
|
|
172
|
+
head, _, tail = raw.partition(":")
|
|
173
|
+
try:
|
|
174
|
+
call_id = int(head)
|
|
175
|
+
except ValueError as exc:
|
|
176
|
+
raise UsageError(f"{raw!r} is not a call id", field="call") from exc
|
|
177
|
+
if not tail:
|
|
178
|
+
return call_id, None
|
|
179
|
+
try:
|
|
180
|
+
return call_id, int(tail)
|
|
181
|
+
except ValueError as exc:
|
|
182
|
+
raise UsageError(f"{raw!r} is not an id:access_hash pair", field="call") from exc
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def input_phone_call(ctx: OpContext, text: str) -> tuple[Any, LiveCall | None]:
|
|
186
|
+
"""The `InputPhoneCall` for a call reference, plus what we remember of it.
|
|
187
|
+
|
|
188
|
+
An id on its own is resolved through the store, which is why `call end
|
|
189
|
+
12345` works: the access hash arrived in an update, not from the user.
|
|
190
|
+
"""
|
|
191
|
+
from telethon.tl import types
|
|
192
|
+
|
|
193
|
+
call_id, access_hash = parse_call_id(text)
|
|
194
|
+
known = live_calls(ctx.account).get(call_id)
|
|
195
|
+
if access_hash is None:
|
|
196
|
+
if known is None:
|
|
197
|
+
raise NotFoundError(
|
|
198
|
+
f"call {call_id} is not one this daemon has seen; "
|
|
199
|
+
"give it as id:access_hash, or run `tlgr call watch` to pick it up"
|
|
200
|
+
)
|
|
201
|
+
access_hash = known.access_hash
|
|
202
|
+
elif known is None:
|
|
203
|
+
# A caller who typed the access hash knows something the daemon does
|
|
204
|
+
# not; remembering it is what makes the *next* command work with the
|
|
205
|
+
# bare id, which is the whole point of the store.
|
|
206
|
+
known = remember_call(
|
|
207
|
+
ctx.account, LiveCall(id=call_id, access_hash=access_hash, state="unknown")
|
|
208
|
+
)
|
|
209
|
+
return types.InputPhoneCall(id=call_id, access_hash=access_hash), known
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def protocol() -> Any:
|
|
213
|
+
"""The `phoneCallProtocol` every client sends, tlgr included."""
|
|
214
|
+
from telethon.tl import types
|
|
215
|
+
|
|
216
|
+
return types.PhoneCallProtocol(
|
|
217
|
+
min_layer=PROTOCOL_MIN_LAYER,
|
|
218
|
+
max_layer=PROTOCOL_MAX_LAYER,
|
|
219
|
+
library_versions=list(LIBRARY_VERSIONS),
|
|
220
|
+
udp_p2p=True,
|
|
221
|
+
udp_reflector=True,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def protocol_dict() -> dict[str, Any]:
|
|
226
|
+
return {
|
|
227
|
+
"min_layer": PROTOCOL_MIN_LAYER,
|
|
228
|
+
"max_layer": PROTOCOL_MAX_LAYER,
|
|
229
|
+
"udp_p2p": True,
|
|
230
|
+
"udp_reflector": True,
|
|
231
|
+
"library_versions": list(LIBRARY_VERSIONS),
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def discard_reason(name: str) -> Any:
|
|
236
|
+
"""`"hangup"` → `PhoneCallDiscardReasonHangup()`."""
|
|
237
|
+
from telethon.tl import types
|
|
238
|
+
|
|
239
|
+
class_name = _REASON_CLASSES.get(str(name or "").lower())
|
|
240
|
+
if class_name is None:
|
|
241
|
+
raise UsageError(
|
|
242
|
+
f"--reason {name!r} is not a discard reason; expected one of "
|
|
243
|
+
+ ", ".join(DISCARD_REASONS),
|
|
244
|
+
field="reason",
|
|
245
|
+
)
|
|
246
|
+
return getattr(types, class_name)()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def reason_name(reason: Any) -> str | None:
|
|
250
|
+
"""A `phoneCallDiscardReason*` back to its word."""
|
|
251
|
+
if reason is None:
|
|
252
|
+
return None
|
|
253
|
+
name = type(reason).__name__
|
|
254
|
+
for word, class_name in _REASON_CLASSES.items():
|
|
255
|
+
if class_name == name:
|
|
256
|
+
return word
|
|
257
|
+
if name == "PhoneCallDiscardReasonMigrateConferenceCall":
|
|
258
|
+
return "migrate-conference"
|
|
259
|
+
return name
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def state_name(call: Any) -> str:
|
|
263
|
+
return CALL_STATES.get(type(call).__name__, "unknown")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
# Group call references
|
|
268
|
+
# ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@dataclass
|
|
272
|
+
class CallHandle:
|
|
273
|
+
"""A resolved group call: what to send, and what to echo back."""
|
|
274
|
+
|
|
275
|
+
input: Any
|
|
276
|
+
ref: CallRef
|
|
277
|
+
chat: Any = None
|
|
278
|
+
call: Any = None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def call_ref_of(call: Any, *, slug: str | None = None, msg_id: int | None = None) -> CallRef:
|
|
282
|
+
"""A `groupCall`/`inputGroupCall` as the reference every response carries."""
|
|
283
|
+
return CallRef(
|
|
284
|
+
id=int(getattr(call, "id", 0) or 0),
|
|
285
|
+
access_hash=getattr(call, "access_hash", None),
|
|
286
|
+
slug=slug,
|
|
287
|
+
msg_id=msg_id,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _slug_from_link(text: str) -> str | None:
|
|
292
|
+
"""`t.me/call/AbCd` or `tg://call?slug=AbCd` → `AbCd`."""
|
|
293
|
+
lowered = text.lower()
|
|
294
|
+
if "t.me/call/" in lowered:
|
|
295
|
+
return text.split("/call/", 1)[1].split("?", 1)[0].strip("/")
|
|
296
|
+
if lowered.startswith("tg://call") and "slug=" in lowered:
|
|
297
|
+
return text.split("slug=", 1)[1].split("&", 1)[0]
|
|
298
|
+
if lowered.startswith("slug:"):
|
|
299
|
+
return text.split(":", 1)[1]
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
async def _chat_call(ctx: OpContext, ref: str) -> CallHandle:
|
|
304
|
+
"""The active (or scheduled) group call of a chat."""
|
|
305
|
+
from telethon.tl.functions import channels, messages
|
|
306
|
+
|
|
307
|
+
from tlgr.ops import _send
|
|
308
|
+
|
|
309
|
+
peer = await _send.resolve(ctx, parse_peer_ref(ref))
|
|
310
|
+
client = getattr(ctx, "client", None)
|
|
311
|
+
if client is None: # pragma: no cover - the daemon always supplies one
|
|
312
|
+
raise UsageError("this operation needs a connected account")
|
|
313
|
+
|
|
314
|
+
name = type(peer).__name__
|
|
315
|
+
if name == "InputPeerChannel":
|
|
316
|
+
from telethon import utils
|
|
317
|
+
|
|
318
|
+
full = await client(channels.GetFullChannelRequest(utils.get_input_channel(peer)))
|
|
319
|
+
elif name == "InputPeerChat":
|
|
320
|
+
full = await client(messages.GetFullChatRequest(chat_id=peer.chat_id))
|
|
321
|
+
else:
|
|
322
|
+
raise UsageError(
|
|
323
|
+
"a 1:1 chat has no video chat; give a group, a channel or a call link",
|
|
324
|
+
field="call",
|
|
325
|
+
)
|
|
326
|
+
call = getattr(getattr(full, "full_chat", None), "call", None)
|
|
327
|
+
if call is None:
|
|
328
|
+
raise NotFoundError("that chat has no video chat running or scheduled")
|
|
329
|
+
return CallHandle(input=call, ref=call_ref_of(call), chat=peer)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
async def resolve_call(ctx: OpContext, ref: str) -> CallHandle:
|
|
333
|
+
"""Any way a human names a group call → the `InputGroupCall` to send.
|
|
334
|
+
|
|
335
|
+
Accepted: `id:access_hash`, `msg:<id>` for an invitation service message,
|
|
336
|
+
a `t.me/call/<slug>` or `tg://call?slug=` link, `slug:<slug>`, or a chat
|
|
337
|
+
(its video chat). A bare word is tried as a chat first and as a slug
|
|
338
|
+
second, because usernames and conference slugs share a shape and the chat
|
|
339
|
+
is overwhelmingly the common case.
|
|
340
|
+
"""
|
|
341
|
+
from telethon.tl import types
|
|
342
|
+
|
|
343
|
+
raw = str(ref or "").strip()
|
|
344
|
+
if not raw:
|
|
345
|
+
raise UsageError("a call reference is required", field="call")
|
|
346
|
+
|
|
347
|
+
if raw.lower().startswith("msg:"):
|
|
348
|
+
try:
|
|
349
|
+
msg_id = int(raw.split(":", 1)[1])
|
|
350
|
+
except ValueError as exc:
|
|
351
|
+
raise UsageError(f"{raw!r} is not msg:<id>", field="call") from exc
|
|
352
|
+
return CallHandle(
|
|
353
|
+
input=types.InputGroupCallInviteMessage(msg_id=msg_id),
|
|
354
|
+
ref=CallRef(msg_id=msg_id),
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
slug = _slug_from_link(raw)
|
|
358
|
+
if slug:
|
|
359
|
+
return CallHandle(input=types.InputGroupCallSlug(slug=slug), ref=CallRef(slug=slug))
|
|
360
|
+
|
|
361
|
+
head, _, tail = raw.partition(":")
|
|
362
|
+
if tail and head.lstrip("-").isdigit() and tail.lstrip("-").isdigit():
|
|
363
|
+
call = types.InputGroupCall(id=int(head), access_hash=int(tail))
|
|
364
|
+
return CallHandle(input=call, ref=call_ref_of(call))
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
return await _chat_call(ctx, raw)
|
|
368
|
+
except (NotFoundError, UsageError, ValueError):
|
|
369
|
+
if raw.startswith("@") or raw.lstrip("-").isdigit() or "/" in raw:
|
|
370
|
+
raise
|
|
371
|
+
return CallHandle(input=types.InputGroupCallSlug(slug=raw), ref=CallRef(slug=raw))
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
async def concrete_call(ctx: OpContext, handle: CallHandle) -> CallHandle:
|
|
375
|
+
"""Turn a slug/invitation handle into the `(id, access_hash)` pair.
|
|
376
|
+
|
|
377
|
+
Several `phone.*` methods only accept the concrete constructor, and a slug
|
|
378
|
+
is one `getGroupCall` away from it — asking here is cheaper than every
|
|
379
|
+
caller getting the distinction right.
|
|
380
|
+
"""
|
|
381
|
+
from telethon.tl import types
|
|
382
|
+
from telethon.tl.functions import phone
|
|
383
|
+
|
|
384
|
+
if isinstance(handle.input, types.InputGroupCall):
|
|
385
|
+
return handle
|
|
386
|
+
client = getattr(ctx, "client", None)
|
|
387
|
+
if client is None: # pragma: no cover
|
|
388
|
+
raise UsageError("this operation needs a connected account")
|
|
389
|
+
result = await client(phone.GetGroupCallRequest(call=handle.input, limit=0))
|
|
390
|
+
call = getattr(result, "call", None)
|
|
391
|
+
if call is None or getattr(call, "access_hash", None) is None:
|
|
392
|
+
raise NotFoundError("that call link does not resolve to a call any more")
|
|
393
|
+
return CallHandle(
|
|
394
|
+
input=types.InputGroupCall(id=call.id, access_hash=call.access_hash),
|
|
395
|
+
ref=call_ref_of(call, slug=handle.ref.slug, msg_id=handle.ref.msg_id),
|
|
396
|
+
chat=handle.chat,
|
|
397
|
+
call=call,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ---------------------------------------------------------------------------
|
|
402
|
+
# App config
|
|
403
|
+
# ---------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _json_value(node: Any) -> Any:
|
|
407
|
+
"""A `JSONValue` tree as plain Python."""
|
|
408
|
+
name = type(node).__name__
|
|
409
|
+
if name == "JsonNull":
|
|
410
|
+
return None
|
|
411
|
+
if name in ("JsonBool", "JsonNumber", "JsonString"):
|
|
412
|
+
return node.value
|
|
413
|
+
if name == "JsonArray":
|
|
414
|
+
return [_json_value(item) for item in node.value]
|
|
415
|
+
if name == "JsonObject":
|
|
416
|
+
return {item.key: _json_value(item.value) for item in node.value}
|
|
417
|
+
return node
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
async def app_config(ctx: OpContext) -> dict[str, Any]:
|
|
421
|
+
"""The call-related keys of `help.getAppConfig`, as plain values.
|
|
422
|
+
|
|
423
|
+
Failures are swallowed into an empty dict on purpose: a missing limit must
|
|
424
|
+
degrade the answer, never fail the command that only wanted to mention it.
|
|
425
|
+
"""
|
|
426
|
+
from telethon.tl.functions import help as help_fn
|
|
427
|
+
|
|
428
|
+
client = getattr(ctx, "client", None)
|
|
429
|
+
if client is None: # pragma: no cover
|
|
430
|
+
return {}
|
|
431
|
+
try:
|
|
432
|
+
result = await client(help_fn.GetAppConfigRequest(hash=0))
|
|
433
|
+
except Exception: # pragma: no cover - a diagnostic must not fail the op
|
|
434
|
+
return {}
|
|
435
|
+
config = _json_value(getattr(result, "config", None))
|
|
436
|
+
if not isinstance(config, dict):
|
|
437
|
+
return {}
|
|
438
|
+
return {key: config[key] for key in APP_CONFIG_KEYS if key in config}
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# ---------------------------------------------------------------------------
|
|
442
|
+
# Diffie-Hellman
|
|
443
|
+
# ---------------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _is_probable_prime(n: int, rounds: int = 16) -> bool:
|
|
447
|
+
"""Miller-Rabin. Enough rounds that a composite slipping through is not
|
|
448
|
+
the reason a key exchange failed."""
|
|
449
|
+
if n < 2:
|
|
450
|
+
return False
|
|
451
|
+
for small in (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37):
|
|
452
|
+
if n % small == 0:
|
|
453
|
+
return n == small
|
|
454
|
+
d, r = n - 1, 0
|
|
455
|
+
while d % 2 == 0:
|
|
456
|
+
d //= 2
|
|
457
|
+
r += 1
|
|
458
|
+
for _ in range(rounds):
|
|
459
|
+
a = secrets.randbelow(n - 3) + 2
|
|
460
|
+
x = pow(a, d, n)
|
|
461
|
+
if x in (1, n - 1):
|
|
462
|
+
continue
|
|
463
|
+
for _ in range(r - 1):
|
|
464
|
+
x = x * x % n
|
|
465
|
+
if x == n - 1:
|
|
466
|
+
break
|
|
467
|
+
else:
|
|
468
|
+
return False
|
|
469
|
+
return True
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
@lru_cache(maxsize=8)
|
|
473
|
+
def validate_dh(p_bytes: bytes, g: int) -> dict[str, Any]:
|
|
474
|
+
"""Check the server's DH parameters instead of trusting them.
|
|
475
|
+
|
|
476
|
+
A client that accepts whatever prime the server sends has not performed a
|
|
477
|
+
key exchange; it has accepted a key the server chose. The checks are the
|
|
478
|
+
documented ones: 2048 bits, `p` and `(p-1)/2` prime, and the residue
|
|
479
|
+
condition for the generator.
|
|
480
|
+
|
|
481
|
+
Cached: the parameters change roughly never, and two Miller-Rabin runs
|
|
482
|
+
over a 2048-bit number cost about half a second — worth paying once per
|
|
483
|
+
prime rather than once per call.
|
|
484
|
+
"""
|
|
485
|
+
p = int.from_bytes(p_bytes, "big")
|
|
486
|
+
checks: dict[str, Any] = {
|
|
487
|
+
"bits": p.bit_length(),
|
|
488
|
+
"size_ok": p.bit_length() == 2048,
|
|
489
|
+
"generator": g,
|
|
490
|
+
}
|
|
491
|
+
residue = {
|
|
492
|
+
2: p % 8 == 7,
|
|
493
|
+
3: p % 3 == 2,
|
|
494
|
+
4: True,
|
|
495
|
+
5: p % 5 in (1, 4),
|
|
496
|
+
6: p % 24 in (19, 23),
|
|
497
|
+
7: p % 7 in (3, 5, 6),
|
|
498
|
+
}
|
|
499
|
+
checks["generator_ok"] = residue.get(g, False)
|
|
500
|
+
checks["prime"] = _is_probable_prime(p)
|
|
501
|
+
checks["safe_prime"] = checks["prime"] and _is_probable_prime((p - 1) // 2)
|
|
502
|
+
checks["ok"] = bool(checks["size_ok"] and checks["generator_ok"] and checks["safe_prime"])
|
|
503
|
+
return checks
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def dh_verdict(p_bytes: bytes, g: int) -> dict[str, Any]:
|
|
507
|
+
"""`validate_dh` as a fresh dict, so a caller may annotate it safely."""
|
|
508
|
+
return dict(validate_dh(p_bytes, g))
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def key_fingerprint(key: bytes) -> int:
|
|
512
|
+
"""The 64-bit fingerprint both sides compare: the tail of SHA1(key)."""
|
|
513
|
+
return int.from_bytes(hashlib.sha1(key).digest()[-8:], "little", signed=True)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def emoji_indices(key: bytes, g_a: bytes) -> list[int]:
|
|
517
|
+
"""The four indices into Telegram's 333-emoji verification table.
|
|
518
|
+
|
|
519
|
+
tlgr reports the indices, not the emoji: the table is a fixed list every
|
|
520
|
+
client ships, tlgr does not bundle it, and printing four *guessed* emoji
|
|
521
|
+
for a security check would be worse than printing none.
|
|
522
|
+
"""
|
|
523
|
+
digest = hashlib.sha256(key + g_a).digest()
|
|
524
|
+
return [int.from_bytes(digest[8 + i * 8 : 16 + i * 8], "big") % 333 for i in range(4)]
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def b64(data: bytes) -> str:
|
|
528
|
+
return base64.b64encode(data).decode("ascii")
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def unb64(text: str, *, field: str = "data") -> bytes:
|
|
532
|
+
try:
|
|
533
|
+
return base64.b64decode(text, validate=True)
|
|
534
|
+
except (ValueError, TypeError) as exc:
|
|
535
|
+
raise UsageError(f"--{field} is not valid base64", field=field) from exc
|
tlgr/ops/_common.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""The plumbing every operation module needs, in one place.
|
|
2
|
+
|
|
3
|
+
`message` grew these helpers first — the client accessor, the pagination
|
|
4
|
+
window, the affected-history loop, the id-range expander — and PR-9's five
|
|
5
|
+
new modules need all of them. Copying them five times is how "`--limit` means
|
|
6
|
+
at most 1000" becomes true in one module and false in the next, so they live
|
|
7
|
+
here and `ops/message.py` imports them like everybody else.
|
|
8
|
+
|
|
9
|
+
Telethon is imported inside functions, never at module scope: importing the
|
|
10
|
+
registry is what builds `tlgr --help`, and that must not pull in Telethon.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from tlgr.core.errors import UsageError
|
|
18
|
+
from tlgr.core.pagination import PageKind, decode_cursor
|
|
19
|
+
from tlgr.ops._spec import OpContext
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"affected_loop",
|
|
23
|
+
"already",
|
|
24
|
+
"client",
|
|
25
|
+
"ids",
|
|
26
|
+
"input_channel",
|
|
27
|
+
"is_not_modified",
|
|
28
|
+
"only",
|
|
29
|
+
"random_id",
|
|
30
|
+
"window",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def client(ctx: OpContext) -> Any:
|
|
35
|
+
"""The connected Telethon client, or a usage error."""
|
|
36
|
+
handle = getattr(ctx, "client", None)
|
|
37
|
+
if handle is None: # pragma: no cover - the daemon always supplies one
|
|
38
|
+
raise UsageError("this operation needs a connected account")
|
|
39
|
+
return handle
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def window(ctx: OpContext, op: str, kind: PageKind, default: int = 20) -> tuple[int, Any]:
|
|
43
|
+
"""`(limit, cursor state)` for a paginated op.
|
|
44
|
+
|
|
45
|
+
`--limit`/`--cursor` are transport-level and never request fields
|
|
46
|
+
(registry lint L5), so every paginated implementation reads them the same
|
|
47
|
+
way instead of redeclaring them.
|
|
48
|
+
"""
|
|
49
|
+
limit = int(getattr(ctx, "limit", None) or default)
|
|
50
|
+
if limit < 1:
|
|
51
|
+
raise UsageError("--limit must be at least 1", field="limit")
|
|
52
|
+
token = getattr(ctx, "cursor", None)
|
|
53
|
+
state: dict[str, Any] = {}
|
|
54
|
+
if token:
|
|
55
|
+
state = decode_cursor(token, op=op, kind=kind, account=ctx.account)
|
|
56
|
+
return min(limit, 1000), state
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def ids(values: tuple[int, ...] | tuple[str, ...] | list[str] | None) -> list[int]:
|
|
60
|
+
"""Expand `100-120` ranges alongside plain ids.
|
|
61
|
+
|
|
62
|
+
A range is what a human types when deleting a burst of messages, and
|
|
63
|
+
making them spell out twenty ids is how a wrong one gets in.
|
|
64
|
+
"""
|
|
65
|
+
out: list[int] = []
|
|
66
|
+
for value in values or ():
|
|
67
|
+
text = str(value)
|
|
68
|
+
if "-" in text[1:]:
|
|
69
|
+
head, _, tail = text.partition("-")
|
|
70
|
+
try:
|
|
71
|
+
start, end = int(head), int(tail)
|
|
72
|
+
except ValueError as exc:
|
|
73
|
+
raise UsageError(f"{text!r} is not an id or an id range", field="msg_id") from exc
|
|
74
|
+
if end < start or end - start > 10_000:
|
|
75
|
+
raise UsageError(f"{text!r} is not a usable id range", field="msg_id")
|
|
76
|
+
out.extend(range(start, end + 1))
|
|
77
|
+
else:
|
|
78
|
+
try:
|
|
79
|
+
out.append(int(text))
|
|
80
|
+
except ValueError as exc:
|
|
81
|
+
raise UsageError(f"{text!r} is not a message id", field="msg_id") from exc
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def is_not_modified(exc: BaseException) -> bool:
|
|
86
|
+
"""MESSAGE_NOT_MODIFIED is success: the world already looks like that.
|
|
87
|
+
|
|
88
|
+
Matched on the class name *and* the message with underscores stripped,
|
|
89
|
+
because Telethon spells it `MessageNotModifiedError` and the server
|
|
90
|
+
spells it `MESSAGE_NOT_MODIFIED`.
|
|
91
|
+
"""
|
|
92
|
+
text = f"{type(exc).__name__} {exc}".upper().replace("_", "")
|
|
93
|
+
return "NOTMODIFIED" in text
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def input_channel(peer: Any) -> Any:
|
|
97
|
+
"""The `InputChannel` a `channels.*` request wants, or a usage error.
|
|
98
|
+
|
|
99
|
+
`utils.get_input_channel` is arithmetic on the peer we already hold; going
|
|
100
|
+
back to `get_input_entity` would be a round trip for something that is
|
|
101
|
+
already known, and would hide the real problem when the peer is a user.
|
|
102
|
+
"""
|
|
103
|
+
from telethon import utils
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
return utils.get_input_channel(peer)
|
|
107
|
+
except (TypeError, ValueError) as exc:
|
|
108
|
+
raise UsageError(
|
|
109
|
+
"this operation only works in a channel or supergroup", field="chat"
|
|
110
|
+
) from exc
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def affected_loop(ctx: OpContext, make_request: Any) -> int:
|
|
114
|
+
"""Drive a `messages.AffectedHistory` call until `offset == 0`.
|
|
115
|
+
|
|
116
|
+
`readMentions`, `readReactions`, `readPollVotes`, `unpinAllMessages` and
|
|
117
|
+
`deleteParticipantHistory` all return a partial result with an offset to
|
|
118
|
+
resume from. v1 called them once and reported success, so "unpin
|
|
119
|
+
everything" unpinned the first hundred.
|
|
120
|
+
"""
|
|
121
|
+
handle = client(ctx)
|
|
122
|
+
total = 0
|
|
123
|
+
offset = 0
|
|
124
|
+
for _ in range(100):
|
|
125
|
+
result = await handle(make_request(offset))
|
|
126
|
+
total += int(getattr(result, "pts_count", 0) or 0)
|
|
127
|
+
offset = int(getattr(result, "offset", 0) or 0)
|
|
128
|
+
if offset == 0:
|
|
129
|
+
break
|
|
130
|
+
limiter = getattr(ctx, "limiter", None)
|
|
131
|
+
if limiter is not None:
|
|
132
|
+
await limiter.acquire("bulk")
|
|
133
|
+
return total
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def already(ctx: OpContext) -> None:
|
|
137
|
+
"""Record that the world already looked the way the caller asked for."""
|
|
138
|
+
mark = getattr(ctx, "mark_already", None)
|
|
139
|
+
if callable(mark):
|
|
140
|
+
mark()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def only(values: dict[str, Any], request: Any) -> dict[str, Any]:
|
|
144
|
+
"""Keep the keys *request* actually accepts.
|
|
145
|
+
|
|
146
|
+
The send requests share most of their flags and differ in a few; a
|
|
147
|
+
filtered dict is how one options mapping serves all of them without a
|
|
148
|
+
per-request copy that drifts.
|
|
149
|
+
"""
|
|
150
|
+
import inspect
|
|
151
|
+
|
|
152
|
+
allowed = set(inspect.signature(request.__init__).parameters)
|
|
153
|
+
return {k: v for k, v in values.items() if k in allowed and v is not None}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def random_id() -> int:
|
|
157
|
+
"""A 64-bit `random_id`, as every send request wants one."""
|
|
158
|
+
import os
|
|
159
|
+
|
|
160
|
+
return int.from_bytes(os.urandom(8), "big", signed=True)
|