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/call.py
ADDED
|
@@ -0,0 +1,1610 @@
|
|
|
1
|
+
"""The `call` group: 1:1 voice and video calls, as signalling.
|
|
2
|
+
|
|
3
|
+
The honest summary of what this group is, stated once here and repeated in
|
|
4
|
+
every answer it gives: **tlgr can ring, answer, hang up, rate and observe a
|
|
5
|
+
call, and it cannot talk.** There is no tgcalls binding behind it, so a call
|
|
6
|
+
tlgr accepts is a silent one, `media` is always `"none"`, and the parts of a
|
|
7
|
+
call that are genuinely media — turning the camera on, the audio itself — are
|
|
8
|
+
reachable only as opaque signalling packets somebody else's engine produced
|
|
9
|
+
(`call signal`).
|
|
10
|
+
|
|
11
|
+
What is left is more useful than it sounds. The key exchange, the ringing
|
|
12
|
+
state machine, the call log, the quality rating and the incoming-call stream
|
|
13
|
+
are all pure control plane, which means a headless machine can answer "is my
|
|
14
|
+
phone ringing, and who is it" — and can do it in a script.
|
|
15
|
+
|
|
16
|
+
Telethon is imported inside functions, never at module scope: importing the
|
|
17
|
+
registry is what builds `tlgr --help`, and that must not pull in Telethon.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import contextlib
|
|
24
|
+
import hashlib
|
|
25
|
+
import secrets
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from typing import Annotated, Any
|
|
28
|
+
|
|
29
|
+
from tlgr.core.errors import (
|
|
30
|
+
IndeterminateError,
|
|
31
|
+
NotFoundError,
|
|
32
|
+
UsageError,
|
|
33
|
+
)
|
|
34
|
+
from tlgr.core.pagination import PageKind, build_page, decode_cursor
|
|
35
|
+
from tlgr.core.timefmt import fmt_dt, parse_dt, to_unix
|
|
36
|
+
from tlgr.models.base import Request
|
|
37
|
+
from tlgr.models.call import (
|
|
38
|
+
MEDIA_NONE,
|
|
39
|
+
Call,
|
|
40
|
+
CallConfig,
|
|
41
|
+
CallDebugUpload,
|
|
42
|
+
CallDeclined,
|
|
43
|
+
CallEnded,
|
|
44
|
+
CallEvent,
|
|
45
|
+
CallLogDeleted,
|
|
46
|
+
CallLogEntry,
|
|
47
|
+
CallRating,
|
|
48
|
+
CallSignal,
|
|
49
|
+
CallUpgrade,
|
|
50
|
+
)
|
|
51
|
+
from tlgr.models.page import Page
|
|
52
|
+
from tlgr.models.peer import Peer, PeerRef
|
|
53
|
+
from tlgr.ops import _calls, _send
|
|
54
|
+
from tlgr.ops._params import arg, choice, opt
|
|
55
|
+
from tlgr.ops._serialize import entity_to_peer
|
|
56
|
+
from tlgr.ops._spec import OpContext, OperationSpec
|
|
57
|
+
|
|
58
|
+
__all__ = [name for name in dir() if name.startswith("SPEC_")]
|
|
59
|
+
|
|
60
|
+
#: The nine problem ids the official clients offer. Validated locally and
|
|
61
|
+
#: appended to the rating comment as hashtags, which is the whole protocol.
|
|
62
|
+
RATING_PROBLEMS = (
|
|
63
|
+
"echo",
|
|
64
|
+
"noise",
|
|
65
|
+
"interruptions",
|
|
66
|
+
"distorted_speech",
|
|
67
|
+
"silent_local",
|
|
68
|
+
"silent_remote",
|
|
69
|
+
"dropped",
|
|
70
|
+
"distorted_video",
|
|
71
|
+
"pixelated_video",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
_EXAMPLE_CALL: dict[str, Any] = {
|
|
75
|
+
"call_id": 4815162342,
|
|
76
|
+
"state": "waiting",
|
|
77
|
+
"media": "none",
|
|
78
|
+
"video": False,
|
|
79
|
+
"out": True,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#: Background auto-discard timers, held so the event loop does not collect a
|
|
83
|
+
#: task nobody is awaiting.
|
|
84
|
+
_TIMERS: set[asyncio.Task[None]] = set()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _client(ctx: OpContext) -> Any:
|
|
88
|
+
client = getattr(ctx, "client", None)
|
|
89
|
+
if client is None: # pragma: no cover - the daemon always supplies one
|
|
90
|
+
raise UsageError("this operation needs a connected account")
|
|
91
|
+
return client
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _now() -> str:
|
|
95
|
+
return fmt_dt(datetime.now(timezone.utc)) or ""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _peer_of(ctx: OpContext, user_id: int | None) -> Peer | None:
|
|
99
|
+
"""The other side of a call, as far as the entity cache knows it."""
|
|
100
|
+
if not user_id:
|
|
101
|
+
return None
|
|
102
|
+
session = getattr(ctx, "session", None)
|
|
103
|
+
entity = getattr(session, "me", None)
|
|
104
|
+
if entity is not None and getattr(entity, "id", None) == user_id:
|
|
105
|
+
return entity_to_peer(entity)
|
|
106
|
+
return Peer(id=user_id, raw_id=user_id, kind="user")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _live_model(ctx: OpContext, live: _calls.LiveCall, *, fingerprint: bool = False) -> Call:
|
|
110
|
+
"""What the daemon remembers about a call, as the wire shape."""
|
|
111
|
+
model = Call(
|
|
112
|
+
call_id=live.id,
|
|
113
|
+
state=live.state,
|
|
114
|
+
media=MEDIA_NONE,
|
|
115
|
+
video=live.video,
|
|
116
|
+
out=live.out,
|
|
117
|
+
access_hash=live.access_hash,
|
|
118
|
+
admin_id=live.admin_id,
|
|
119
|
+
participant_id=live.participant_id,
|
|
120
|
+
peer=_peer_of(ctx, live.peer_id),
|
|
121
|
+
conference_supported=live.conference_supported,
|
|
122
|
+
connections=live.connections,
|
|
123
|
+
protocol=_calls.protocol_dict(),
|
|
124
|
+
need_rating=live.need_rating,
|
|
125
|
+
need_debug=live.need_debug,
|
|
126
|
+
reason=live.reason,
|
|
127
|
+
duration=live.duration,
|
|
128
|
+
)
|
|
129
|
+
if fingerprint and live.key and live.g_a:
|
|
130
|
+
indices = _calls.emoji_indices(live.key, live.g_a)
|
|
131
|
+
model.fingerprint = [f"#{index}" for index in indices]
|
|
132
|
+
return model
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _absorb(ctx: OpContext, phone_call: Any, *, out: bool = False) -> _calls.LiveCall:
|
|
136
|
+
"""Record a `phoneCall*` constructor into the live-call store."""
|
|
137
|
+
live = _calls.LiveCall(
|
|
138
|
+
id=int(getattr(phone_call, "id", 0) or 0),
|
|
139
|
+
access_hash=int(getattr(phone_call, "access_hash", 0) or 0),
|
|
140
|
+
state=_calls.state_name(phone_call),
|
|
141
|
+
video=bool(getattr(phone_call, "video", False)),
|
|
142
|
+
out=out,
|
|
143
|
+
admin_id=getattr(phone_call, "admin_id", None),
|
|
144
|
+
participant_id=getattr(phone_call, "participant_id", None),
|
|
145
|
+
conference_supported=getattr(phone_call, "conference_supported", None),
|
|
146
|
+
connections=len(getattr(phone_call, "connections", None) or []),
|
|
147
|
+
need_rating=getattr(phone_call, "need_rating", None),
|
|
148
|
+
need_debug=getattr(phone_call, "need_debug", None),
|
|
149
|
+
reason=_calls.reason_name(getattr(phone_call, "reason", None)),
|
|
150
|
+
duration=getattr(phone_call, "duration", None),
|
|
151
|
+
)
|
|
152
|
+
me = getattr(getattr(ctx, "session", None), "me", None)
|
|
153
|
+
my_id = int(getattr(me, "id", 0) or 0)
|
|
154
|
+
admin = live.admin_id or 0
|
|
155
|
+
participant = live.participant_id or 0
|
|
156
|
+
if my_id:
|
|
157
|
+
live.out = admin == my_id
|
|
158
|
+
live.peer_id = participant if admin == my_id else admin
|
|
159
|
+
elif participant:
|
|
160
|
+
live.peer_id = participant
|
|
161
|
+
return _calls.remember_call(ctx.account, live)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _group_call_of(result: Any) -> Any:
|
|
165
|
+
"""The `groupCall` an `Updates` carries in its `updateGroupCall`."""
|
|
166
|
+
direct = getattr(result, "call", None)
|
|
167
|
+
if direct is not None and type(direct).__name__.startswith("GroupCall"):
|
|
168
|
+
return direct
|
|
169
|
+
for update in getattr(result, "updates", None) or []:
|
|
170
|
+
call = getattr(update, "call", None)
|
|
171
|
+
if call is not None and type(call).__name__.startswith("GroupCall"):
|
|
172
|
+
return call
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _phone_call_of(result: Any) -> Any:
|
|
177
|
+
"""The `phoneCall*` inside a `phone.PhoneCall` or an `Updates`."""
|
|
178
|
+
direct = getattr(result, "phone_call", None)
|
|
179
|
+
if direct is not None:
|
|
180
|
+
return direct
|
|
181
|
+
for update in getattr(result, "updates", None) or []:
|
|
182
|
+
found = getattr(update, "phone_call", None)
|
|
183
|
+
if found is not None:
|
|
184
|
+
return found
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def _dh_parameters(ctx: OpContext) -> tuple[int, int, bytes]:
|
|
189
|
+
"""`(p, g, server random)` — validated, never trusted as sent."""
|
|
190
|
+
from telethon.tl.functions import messages as fn
|
|
191
|
+
|
|
192
|
+
config = await _client(ctx)(fn.GetDhConfigRequest(version=0, random_length=256))
|
|
193
|
+
p_bytes = bytes(getattr(config, "p", b"") or b"")
|
|
194
|
+
g = int(getattr(config, "g", 0) or 0)
|
|
195
|
+
if not p_bytes or not g:
|
|
196
|
+
raise IndeterminateError(
|
|
197
|
+
"the server did not send DH parameters, so no call key exchange could start"
|
|
198
|
+
)
|
|
199
|
+
checks = _calls.dh_verdict(p_bytes, g)
|
|
200
|
+
if not checks["ok"]:
|
|
201
|
+
raise IndeterminateError(
|
|
202
|
+
"the DH parameters the server sent did not validate "
|
|
203
|
+
f"({checks}); no call was placed, and this is not a refusal by the peer"
|
|
204
|
+
)
|
|
205
|
+
return int.from_bytes(p_bytes, "big"), g, bytes(getattr(config, "random", b"") or b"")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _secret(p: int, g: int, server_random: bytes) -> tuple[int, bytes]:
|
|
209
|
+
"""A fresh exponent and its `g^x mod p`, mixed with the server's entropy."""
|
|
210
|
+
local = secrets.token_bytes(256)
|
|
211
|
+
mixed = bytes(a ^ b for a, b in zip(local, server_random.ljust(256, b"\0"), strict=False))
|
|
212
|
+
x = int.from_bytes(mixed, "big") % (p - 2) + 1
|
|
213
|
+
return x, pow(g, x, p).to_bytes(256, "big")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# call start
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class StartReq(Request):
|
|
222
|
+
user: Annotated[
|
|
223
|
+
PeerRef | None,
|
|
224
|
+
arg(0, metavar="USER", required=False, kind="user", help="Who to ring."),
|
|
225
|
+
] = None
|
|
226
|
+
video: Annotated[bool, opt("--video", help="Ring as a video call.")] = False
|
|
227
|
+
from_message: Annotated[
|
|
228
|
+
str | None,
|
|
229
|
+
opt(
|
|
230
|
+
"--from-message",
|
|
231
|
+
metavar="CHAT:MSG_ID",
|
|
232
|
+
help="Redial the peer of a call log row, or a shared contact card.",
|
|
233
|
+
),
|
|
234
|
+
] = None
|
|
235
|
+
check: Annotated[
|
|
236
|
+
bool,
|
|
237
|
+
opt("--check", help="Only report whether the peer can be called. Nothing rings."),
|
|
238
|
+
] = False
|
|
239
|
+
wait: Annotated[bool, opt("--wait", help="Block until the call is answered or discarded.")] = (
|
|
240
|
+
False
|
|
241
|
+
)
|
|
242
|
+
wait_timeout: Annotated[
|
|
243
|
+
int, opt("--wait-timeout", metavar="DURATION", kind="duration", help="Cap on --wait.")
|
|
244
|
+
] = 60
|
|
245
|
+
auto_discard: Annotated[
|
|
246
|
+
int,
|
|
247
|
+
opt(
|
|
248
|
+
"--auto-discard",
|
|
249
|
+
metavar="DURATION",
|
|
250
|
+
kind="duration",
|
|
251
|
+
help="Hang up automatically after this long; 0 leaves the call ringing.",
|
|
252
|
+
),
|
|
253
|
+
] = 60
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
async def _redial_target(ctx: OpContext, reference: str) -> PeerRef:
|
|
257
|
+
"""The user behind a call log row or a shared contact card."""
|
|
258
|
+
from tlgr.models.peer import parse_message_link, parse_peer_ref
|
|
259
|
+
|
|
260
|
+
link = parse_message_link(reference)
|
|
261
|
+
if link is not None:
|
|
262
|
+
chat_ref, msg_id = link
|
|
263
|
+
else:
|
|
264
|
+
chat, _, tail = reference.rpartition(":")
|
|
265
|
+
if not chat or not tail.isdigit():
|
|
266
|
+
raise UsageError(
|
|
267
|
+
"--from-message wants CHAT:MSG_ID or a t.me message link", field="from_message"
|
|
268
|
+
)
|
|
269
|
+
chat_ref, msg_id = parse_peer_ref(chat), int(tail)
|
|
270
|
+
|
|
271
|
+
peer = await _send.resolve(ctx, chat_ref)
|
|
272
|
+
message = await _client(ctx).get_messages(peer, ids=msg_id)
|
|
273
|
+
if message is None:
|
|
274
|
+
raise NotFoundError(f"message {msg_id} is not there to redial")
|
|
275
|
+
|
|
276
|
+
media = getattr(message, "media", None)
|
|
277
|
+
phone = getattr(getattr(media, "user_id", None) and media, "phone_number", None)
|
|
278
|
+
if phone:
|
|
279
|
+
from telethon.tl.functions import contacts as contacts_fn
|
|
280
|
+
|
|
281
|
+
resolved = await _client(ctx)(contacts_fn.ResolvePhoneRequest(phone=str(phone)))
|
|
282
|
+
users = getattr(resolved, "users", None) or []
|
|
283
|
+
if users:
|
|
284
|
+
return parse_peer_ref(str(users[0].id))
|
|
285
|
+
sender = getattr(message, "from_id", None) or getattr(message, "peer_id", None)
|
|
286
|
+
user_id = getattr(sender, "user_id", None)
|
|
287
|
+
if not user_id:
|
|
288
|
+
raise UsageError("that message does not name a user to call", field="from_message")
|
|
289
|
+
return parse_peer_ref(str(user_id))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
async def _availability(ctx: OpContext, user: Any) -> Call:
|
|
293
|
+
"""`--check`: the three flags the GUI uses to grey out the call buttons."""
|
|
294
|
+
from telethon.tl.functions import users as users_fn
|
|
295
|
+
|
|
296
|
+
full = await _client(ctx)(users_fn.GetFullUserRequest(user))
|
|
297
|
+
inner = getattr(full, "full_user", None)
|
|
298
|
+
entity = (getattr(full, "users", None) or [None])[0]
|
|
299
|
+
return Call(
|
|
300
|
+
call_id=0,
|
|
301
|
+
state="checked",
|
|
302
|
+
media=MEDIA_NONE,
|
|
303
|
+
peer=entity_to_peer(entity) if entity is not None else None,
|
|
304
|
+
can_call=bool(getattr(inner, "phone_calls_available", False)),
|
|
305
|
+
can_video_call=bool(getattr(inner, "video_calls_available", False)),
|
|
306
|
+
private=bool(getattr(inner, "phone_calls_private", False)),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _arm_auto_discard(ctx: OpContext, call_id: int, seconds: int) -> None:
|
|
311
|
+
"""Hang up a call that nobody answered, so automation leaves nothing open."""
|
|
312
|
+
|
|
313
|
+
async def timer() -> None:
|
|
314
|
+
from telethon.tl import types
|
|
315
|
+
from telethon.tl.functions import phone as fn
|
|
316
|
+
|
|
317
|
+
await asyncio.sleep(seconds)
|
|
318
|
+
live = _calls.live_calls(ctx.account).get(call_id)
|
|
319
|
+
if live is None or live.state in ("active", "discarded") or live.discarded:
|
|
320
|
+
return
|
|
321
|
+
live.discarded = True
|
|
322
|
+
with contextlib.suppress(Exception):
|
|
323
|
+
await _client(ctx)(
|
|
324
|
+
fn.DiscardCallRequest(
|
|
325
|
+
peer=types.InputPhoneCall(id=live.id, access_hash=live.access_hash),
|
|
326
|
+
duration=0,
|
|
327
|
+
reason=types.PhoneCallDiscardReasonMissed(),
|
|
328
|
+
connection_id=0,
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
live.state = "discarded"
|
|
332
|
+
live.reason = "missed"
|
|
333
|
+
|
|
334
|
+
task = asyncio.get_event_loop().create_task(timer())
|
|
335
|
+
_TIMERS.add(task)
|
|
336
|
+
task.add_done_callback(_TIMERS.discard)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
async def _wait_for(ctx: OpContext, call_id: int, seconds: int) -> Any:
|
|
340
|
+
"""Wait for the next `updatePhoneCall` about *call_id*."""
|
|
341
|
+
from telethon import events
|
|
342
|
+
from telethon.tl import types
|
|
343
|
+
|
|
344
|
+
client = _client(ctx)
|
|
345
|
+
queue: asyncio.Queue[Any] = asyncio.Queue()
|
|
346
|
+
|
|
347
|
+
async def handler(update: Any) -> None:
|
|
348
|
+
if type(update).__name__ != "UpdatePhoneCall":
|
|
349
|
+
return
|
|
350
|
+
call = getattr(update, "phone_call", None)
|
|
351
|
+
if getattr(call, "id", None) == call_id:
|
|
352
|
+
await queue.put(call)
|
|
353
|
+
|
|
354
|
+
builder = events.Raw(types=[types.UpdatePhoneCall])
|
|
355
|
+
client.add_event_handler(handler, builder)
|
|
356
|
+
try:
|
|
357
|
+
return await asyncio.wait_for(queue.get(), timeout=max(1, seconds))
|
|
358
|
+
except (TimeoutError, asyncio.TimeoutError):
|
|
359
|
+
return None
|
|
360
|
+
finally:
|
|
361
|
+
client.remove_event_handler(handler, builder)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
async def start(ctx: OpContext, req: StartReq) -> Call:
|
|
365
|
+
"""Ring a user. Signalling only: the callee hears silence.
|
|
366
|
+
|
|
367
|
+
tlgr drives the whole documented handshake — a validated DH prime,
|
|
368
|
+
`g_a_hash` in `requestCall`, `confirmCall` with the real `g_a` once the
|
|
369
|
+
peer answers — because a client that skips it is not placing a call, it is
|
|
370
|
+
asking the server to place one for it. What it does not have is an audio
|
|
371
|
+
engine, which is why the answer says `media: none` rather than implying
|
|
372
|
+
otherwise.
|
|
373
|
+
"""
|
|
374
|
+
from telethon import utils
|
|
375
|
+
from telethon.tl.functions import phone as fn
|
|
376
|
+
|
|
377
|
+
reference = req.user
|
|
378
|
+
if req.from_message:
|
|
379
|
+
reference = await _redial_target(ctx, req.from_message)
|
|
380
|
+
if reference is None:
|
|
381
|
+
raise UsageError("give a user to call, or --from-message to redial", field="user")
|
|
382
|
+
|
|
383
|
+
peer = await _send.resolve(ctx, reference)
|
|
384
|
+
try:
|
|
385
|
+
user = utils.get_input_user(peer)
|
|
386
|
+
except (TypeError, ValueError) as exc:
|
|
387
|
+
raise UsageError("only a user can be called", field="user") from exc
|
|
388
|
+
|
|
389
|
+
if req.check:
|
|
390
|
+
return await _availability(ctx, user)
|
|
391
|
+
|
|
392
|
+
p, g, server_random = await _dh_parameters(ctx)
|
|
393
|
+
a, g_a = _secret(p, g, server_random)
|
|
394
|
+
|
|
395
|
+
result = await _client(ctx)(
|
|
396
|
+
fn.RequestCallRequest(
|
|
397
|
+
user_id=user,
|
|
398
|
+
g_a_hash=hashlib.sha256(g_a).digest(),
|
|
399
|
+
protocol=_calls.protocol(),
|
|
400
|
+
video=req.video or None,
|
|
401
|
+
random_id=secrets.randbits(31),
|
|
402
|
+
)
|
|
403
|
+
)
|
|
404
|
+
phone_call = _phone_call_of(result)
|
|
405
|
+
if phone_call is None:
|
|
406
|
+
raise IndeterminateError("the server accepted the call request without describing the call")
|
|
407
|
+
|
|
408
|
+
live = _absorb(ctx, phone_call, out=True)
|
|
409
|
+
live.a, live.p, live.g, live.g_a = a, p, g, g_a
|
|
410
|
+
ctx.emit("call_started", {"call_id": live.id, "video": live.video})
|
|
411
|
+
|
|
412
|
+
if req.wait:
|
|
413
|
+
answered = await _wait_for(ctx, live.id, req.wait_timeout)
|
|
414
|
+
if answered is not None:
|
|
415
|
+
live = _absorb(ctx, answered, out=True)
|
|
416
|
+
g_b = bytes(getattr(answered, "g_b", b"") or b"")
|
|
417
|
+
if g_b and live.state == "accepted":
|
|
418
|
+
key = pow(int.from_bytes(g_b, "big"), a, p).to_bytes(256, "big")
|
|
419
|
+
confirmed = await _client(ctx)(
|
|
420
|
+
fn.ConfirmCallRequest(
|
|
421
|
+
peer=_calls_input(live),
|
|
422
|
+
g_a=g_a,
|
|
423
|
+
key_fingerprint=_calls.key_fingerprint(key),
|
|
424
|
+
protocol=_calls.protocol(),
|
|
425
|
+
)
|
|
426
|
+
)
|
|
427
|
+
final = _phone_call_of(confirmed)
|
|
428
|
+
if final is not None:
|
|
429
|
+
live = _absorb(ctx, final, out=True)
|
|
430
|
+
live.key, live.a, live.p, live.g, live.g_a = key, a, p, g, g_a
|
|
431
|
+
elif req.auto_discard:
|
|
432
|
+
_arm_auto_discard(ctx, live.id, req.auto_discard)
|
|
433
|
+
|
|
434
|
+
model = _live_model(ctx, live)
|
|
435
|
+
if not req.wait and req.auto_discard:
|
|
436
|
+
ctx.warn(f"the call is discarded automatically in {req.auto_discard}s")
|
|
437
|
+
return model
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _calls_input(live: _calls.LiveCall) -> Any:
|
|
441
|
+
from telethon.tl import types
|
|
442
|
+
|
|
443
|
+
return types.InputPhoneCall(id=live.id, access_hash=live.access_hash)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
SPEC_START = OperationSpec(
|
|
447
|
+
id="call.start",
|
|
448
|
+
request=StartReq,
|
|
449
|
+
response=Call,
|
|
450
|
+
impl=start,
|
|
451
|
+
summary="Ring a user, voice or video — signalling only, no audio",
|
|
452
|
+
description=(
|
|
453
|
+
"tlgr runs the real key exchange and reports the relay list a media "
|
|
454
|
+
"engine would use, but it has no media engine: `media` is always "
|
|
455
|
+
"`none` and the callee hears silence. `--auto-discard` exists so "
|
|
456
|
+
"automation cannot leave a call ringing forever."
|
|
457
|
+
),
|
|
458
|
+
mutating=True,
|
|
459
|
+
rate_class="send",
|
|
460
|
+
timeout_s=180,
|
|
461
|
+
columns=("call_id", "state", "video", "media"),
|
|
462
|
+
example=_EXAMPLE_CALL,
|
|
463
|
+
example_args="call start @alice",
|
|
464
|
+
covers=(
|
|
465
|
+
"calls.availability-flags",
|
|
466
|
+
"calls.call-shared-contact",
|
|
467
|
+
"calls.confirm-handshake",
|
|
468
|
+
"calls.place-video-call",
|
|
469
|
+
"calls.place-voice-call",
|
|
470
|
+
"calls.redial",
|
|
471
|
+
),
|
|
472
|
+
tags=frozenset({"visible-to-others"}),
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
# ---------------------------------------------------------------------------
|
|
477
|
+
# call accept
|
|
478
|
+
# ---------------------------------------------------------------------------
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
class AcceptReq(Request):
|
|
482
|
+
call: Annotated[str, arg(0, metavar="CALL", help="Call id, or id:access_hash.")]
|
|
483
|
+
ack_only: Annotated[
|
|
484
|
+
bool,
|
|
485
|
+
opt("--ack-only", help="Only mark the call received (busy-lock), do not answer."),
|
|
486
|
+
] = False
|
|
487
|
+
no_ack: Annotated[
|
|
488
|
+
bool,
|
|
489
|
+
opt("--no-ack", help="Skip the implicit receivedCall so several calls may ring."),
|
|
490
|
+
] = False
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
async def accept(ctx: OpContext, req: AcceptReq) -> Call:
|
|
494
|
+
"""Answer an incoming call, or only mark it received.
|
|
495
|
+
|
|
496
|
+
The implicit `receivedCall` is the busy-lock every client sends first:
|
|
497
|
+
without it a second caller gets a ring instead of "busy". `--no-ack` is
|
|
498
|
+
for the case a bot deliberately wants several calls ringing at once,
|
|
499
|
+
which the API documents as legitimate.
|
|
500
|
+
"""
|
|
501
|
+
from telethon.tl.functions import phone as fn
|
|
502
|
+
|
|
503
|
+
peer, known = _calls.input_phone_call(ctx, req.call)
|
|
504
|
+
if not req.no_ack:
|
|
505
|
+
await _client(ctx)(fn.ReceivedCallRequest(peer=peer))
|
|
506
|
+
if req.ack_only:
|
|
507
|
+
if known is None: # pragma: no cover - input_phone_call refuses first
|
|
508
|
+
raise NotFoundError(f"call {peer.id} is not one this daemon has seen")
|
|
509
|
+
known.state = "requested"
|
|
510
|
+
ctx.emit("call_received", {"call_id": peer.id})
|
|
511
|
+
return _live_model(ctx, known)
|
|
512
|
+
|
|
513
|
+
p, g, server_random = await _dh_parameters(ctx)
|
|
514
|
+
b, g_b = _secret(p, g, server_random)
|
|
515
|
+
result = await _client(ctx)(
|
|
516
|
+
fn.AcceptCallRequest(peer=peer, g_b=g_b, protocol=_calls.protocol())
|
|
517
|
+
)
|
|
518
|
+
phone_call = _phone_call_of(result)
|
|
519
|
+
live = _absorb(ctx, phone_call) if phone_call is not None else known
|
|
520
|
+
if live is None: # pragma: no cover - the server always answers with a call
|
|
521
|
+
raise IndeterminateError("the server accepted the answer without describing the call")
|
|
522
|
+
live.a, live.p, live.g, live.g_a = b, p, g, g_b
|
|
523
|
+
ctx.emit("call_accepted", {"call_id": live.id})
|
|
524
|
+
ctx.warn("tlgr answered the signalling; there is no audio engine behind it")
|
|
525
|
+
return _live_model(ctx, live)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
SPEC_ACCEPT = OperationSpec(
|
|
529
|
+
id="call.accept",
|
|
530
|
+
request=AcceptReq,
|
|
531
|
+
response=Call,
|
|
532
|
+
impl=accept,
|
|
533
|
+
summary="Answer an incoming call (signalling only), or just mark it received",
|
|
534
|
+
description=(
|
|
535
|
+
"Accepting without an audio engine leaves a silent call — useful for "
|
|
536
|
+
"automation and testing, useless for talking."
|
|
537
|
+
),
|
|
538
|
+
mutating=True,
|
|
539
|
+
rate_class="send",
|
|
540
|
+
columns=("call_id", "state", "video", "media"),
|
|
541
|
+
example={**_EXAMPLE_CALL, "state": "accepted", "out": False},
|
|
542
|
+
example_args="call accept 4815162342",
|
|
543
|
+
covers=("calls.accept-call", "calls.mark-received-busy"),
|
|
544
|
+
tags=frozenset({"visible-to-others"}),
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
# ---------------------------------------------------------------------------
|
|
549
|
+
# call decline
|
|
550
|
+
# ---------------------------------------------------------------------------
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
class DeclineReq(Request):
|
|
554
|
+
call: Annotated[str, arg(0, metavar="CALL", help="Call id, or id:access_hash.")]
|
|
555
|
+
reason: Annotated[str, choice("missed", "busy", help="Discard reason sent to the caller.")] = (
|
|
556
|
+
"missed"
|
|
557
|
+
)
|
|
558
|
+
reply: Annotated[
|
|
559
|
+
str | None,
|
|
560
|
+
opt("--reply", metavar="TEXT", help="Also send this message to the caller."),
|
|
561
|
+
] = None
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
async def decline(ctx: OpContext, req: DeclineReq) -> CallDeclined:
|
|
565
|
+
"""Refuse a call, optionally with the text reply the GUI offers.
|
|
566
|
+
|
|
567
|
+
Two operations, exactly as in the official clients: the discard, and then
|
|
568
|
+
an ordinary message. There is no "decline with text" RPC.
|
|
569
|
+
"""
|
|
570
|
+
from telethon.tl.functions import phone as fn
|
|
571
|
+
|
|
572
|
+
peer, known = _calls.input_phone_call(ctx, req.call)
|
|
573
|
+
await _client(ctx)(
|
|
574
|
+
fn.DiscardCallRequest(
|
|
575
|
+
peer=peer,
|
|
576
|
+
duration=0,
|
|
577
|
+
reason=_calls.discard_reason(req.reason),
|
|
578
|
+
connection_id=0,
|
|
579
|
+
)
|
|
580
|
+
)
|
|
581
|
+
if known is not None:
|
|
582
|
+
known.state = "discarded"
|
|
583
|
+
known.reason = req.reason
|
|
584
|
+
|
|
585
|
+
reply_id: int | None = None
|
|
586
|
+
if req.reply:
|
|
587
|
+
caller = known.peer_id if known is not None else None
|
|
588
|
+
if not caller:
|
|
589
|
+
raise UsageError(
|
|
590
|
+
"--reply needs to know who called; run `tlgr call watch` so the "
|
|
591
|
+
"daemon holds the call",
|
|
592
|
+
field="reply",
|
|
593
|
+
)
|
|
594
|
+
sent = await _client(ctx).send_message(caller, req.reply)
|
|
595
|
+
reply_id = int(getattr(sent, "id", 0) or 0)
|
|
596
|
+
ctx.emit("call_declined", {"call_id": peer.id, "reason": req.reason})
|
|
597
|
+
return CallDeclined(call_id=peer.id, reason=req.reason, reply_message_id=reply_id)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
SPEC_DECLINE = OperationSpec(
|
|
601
|
+
id="call.decline",
|
|
602
|
+
request=DeclineReq,
|
|
603
|
+
response=CallDeclined,
|
|
604
|
+
impl=decline,
|
|
605
|
+
summary="Refuse an incoming call, optionally with a text reply",
|
|
606
|
+
mutating=True,
|
|
607
|
+
rate_class="send",
|
|
608
|
+
columns=("call_id", "reason"),
|
|
609
|
+
example={"call_id": 4815162342, "reason": "missed"},
|
|
610
|
+
example_args="call decline 4815162342",
|
|
611
|
+
covers=("calls.decline-call", "calls.respond-with-text"),
|
|
612
|
+
tags=frozenset({"visible-to-others"}),
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
# ---------------------------------------------------------------------------
|
|
617
|
+
# call end
|
|
618
|
+
# ---------------------------------------------------------------------------
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
class EndReq(Request):
|
|
622
|
+
call: Annotated[str, arg(0, metavar="CALL", help="Call id, or id:access_hash.")]
|
|
623
|
+
reason: Annotated[
|
|
624
|
+
str,
|
|
625
|
+
choice("hangup", "disconnect", "busy", "missed", help="Discard reason."),
|
|
626
|
+
] = "hangup"
|
|
627
|
+
duration: Annotated[
|
|
628
|
+
int, opt("--duration", metavar="N", help="Measured duration in seconds.", ge=0)
|
|
629
|
+
] = 0
|
|
630
|
+
connection_id: Annotated[
|
|
631
|
+
int, opt("--connection-id", metavar="N", help="tgcalls connection id, if bridged.", ge=0)
|
|
632
|
+
] = 0
|
|
633
|
+
video: Annotated[bool, opt("--video", help="Report that video was on at the end.")] = False
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
async def end(ctx: OpContext, req: EndReq) -> CallEnded:
|
|
637
|
+
"""Hang up. The answer says whether the server wants a rating or a debug blob.
|
|
638
|
+
|
|
639
|
+
`need_rating` and `need_debug` are the only reason `call rate` and `call
|
|
640
|
+
debug upload` are ever worth running, so they are reported rather than
|
|
641
|
+
dropped with the rest of the `Updates`.
|
|
642
|
+
"""
|
|
643
|
+
from telethon.tl.functions import phone as fn
|
|
644
|
+
|
|
645
|
+
peer, known = _calls.input_phone_call(ctx, req.call)
|
|
646
|
+
result = await _client(ctx)(
|
|
647
|
+
fn.DiscardCallRequest(
|
|
648
|
+
peer=peer,
|
|
649
|
+
duration=req.duration,
|
|
650
|
+
reason=_calls.discard_reason(req.reason),
|
|
651
|
+
connection_id=req.connection_id,
|
|
652
|
+
video=req.video or None,
|
|
653
|
+
)
|
|
654
|
+
)
|
|
655
|
+
discarded = _phone_call_of(result)
|
|
656
|
+
if known is not None:
|
|
657
|
+
known.state = "discarded"
|
|
658
|
+
known.reason = req.reason
|
|
659
|
+
known.duration = req.duration
|
|
660
|
+
known.need_rating = bool(getattr(discarded, "need_rating", False))
|
|
661
|
+
known.need_debug = bool(getattr(discarded, "need_debug", False))
|
|
662
|
+
ctx.emit("call_ended", {"call_id": peer.id, "reason": req.reason})
|
|
663
|
+
return CallEnded(
|
|
664
|
+
call_id=peer.id,
|
|
665
|
+
reason=req.reason,
|
|
666
|
+
duration=req.duration,
|
|
667
|
+
need_rating=bool(getattr(discarded, "need_rating", False)),
|
|
668
|
+
need_debug=bool(getattr(discarded, "need_debug", False)),
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
SPEC_END = OperationSpec(
|
|
673
|
+
id="call.end",
|
|
674
|
+
request=EndReq,
|
|
675
|
+
response=CallEnded,
|
|
676
|
+
impl=end,
|
|
677
|
+
summary="Hang up an active call",
|
|
678
|
+
mutating=True,
|
|
679
|
+
idempotent=True,
|
|
680
|
+
rate_class="send",
|
|
681
|
+
columns=("call_id", "reason", "duration", "need_rating"),
|
|
682
|
+
example={"call_id": 4815162342, "reason": "hangup", "duration": 42, "need_rating": False},
|
|
683
|
+
example_args="call end 4815162342",
|
|
684
|
+
covers=("calls.hangup",),
|
|
685
|
+
tags=frozenset({"visible-to-others"}),
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
# ---------------------------------------------------------------------------
|
|
690
|
+
# call get
|
|
691
|
+
# ---------------------------------------------------------------------------
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
class GetReq(Request):
|
|
695
|
+
call: Annotated[str, arg(0, metavar="CALL", help="Call id, or id:access_hash.")]
|
|
696
|
+
fingerprint: Annotated[
|
|
697
|
+
bool, opt("--fingerprint", help="Derive the key verification indices.")
|
|
698
|
+
] = True
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
async def get(ctx: OpContext, req: GetReq) -> Call:
|
|
702
|
+
"""The live state of a call this daemon is holding.
|
|
703
|
+
|
|
704
|
+
There is no `phone.getCall`: a call exists in the update stream and in the
|
|
705
|
+
client that ran the handshake, nowhere else. So this reads what the daemon
|
|
706
|
+
knows, and says `null` for the parts only the handshake could supply —
|
|
707
|
+
notably the key verification, which is derivable exactly when *this*
|
|
708
|
+
session did the exchange.
|
|
709
|
+
"""
|
|
710
|
+
call_id, _ = _calls.parse_call_id(req.call)
|
|
711
|
+
live = _calls.live_calls(ctx.account).get(call_id)
|
|
712
|
+
if live is None:
|
|
713
|
+
raise NotFoundError(
|
|
714
|
+
f"call {call_id} is not one this daemon has seen; "
|
|
715
|
+
"`tlgr call watch` picks up incoming calls, `tlgr call start` outgoing ones"
|
|
716
|
+
)
|
|
717
|
+
model = _live_model(ctx, live, fingerprint=req.fingerprint)
|
|
718
|
+
if req.fingerprint and model.fingerprint is None:
|
|
719
|
+
ctx.warn("no key verification: this session did not run the DH exchange for that call")
|
|
720
|
+
elif model.fingerprint is not None:
|
|
721
|
+
ctx.warn(
|
|
722
|
+
"the four values are indices into Telegram's 333-emoji table, which tlgr "
|
|
723
|
+
"does not bundle; compare the indices, not emoji"
|
|
724
|
+
)
|
|
725
|
+
return model
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
SPEC_GET = OperationSpec(
|
|
729
|
+
id="call.get",
|
|
730
|
+
request=GetReq,
|
|
731
|
+
response=Call,
|
|
732
|
+
impl=get,
|
|
733
|
+
summary="State of a call, including the key verification indices",
|
|
734
|
+
description=(
|
|
735
|
+
"`conference_supported` says whether `call invite` may be offered at "
|
|
736
|
+
"all; `fingerprint` is null unless this session ran the key exchange."
|
|
737
|
+
),
|
|
738
|
+
columns=("call_id", "state", "video", "media", "conference_supported"),
|
|
739
|
+
example={**_EXAMPLE_CALL, "state": "active", "conference_supported": True},
|
|
740
|
+
example_args="call get 4815162342",
|
|
741
|
+
covers=("calls.conference-supported-flag",),
|
|
742
|
+
covers_partial=("calls.emoji-fingerprint",),
|
|
743
|
+
coverage_note=(
|
|
744
|
+
"the four verification values are reported as indices into Telegram's "
|
|
745
|
+
"333-emoji table; tlgr does not bundle the table, and guessing it for a "
|
|
746
|
+
"security check would be worse than not printing it"
|
|
747
|
+
),
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
# ---------------------------------------------------------------------------
|
|
752
|
+
# call rate
|
|
753
|
+
# ---------------------------------------------------------------------------
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
class RateReq(Request):
|
|
757
|
+
call: Annotated[str, arg(0, metavar="CALL", help="Call id, or id:access_hash.")]
|
|
758
|
+
rating: Annotated[int, arg(1, metavar="RATING", help="1 to 5 stars.", ge=1, le=5)]
|
|
759
|
+
comment: Annotated[str, opt("--comment", help="Free-text comment.")] = ""
|
|
760
|
+
problem: Annotated[
|
|
761
|
+
list[str],
|
|
762
|
+
opt("--problem", metavar="NAME", help="Problem to report; repeat for several."),
|
|
763
|
+
] = []
|
|
764
|
+
user_initiative: Annotated[
|
|
765
|
+
bool, opt("--user-initiative", help="The user rated unprompted.")
|
|
766
|
+
] = False
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
async def rate(ctx: OpContext, req: RateReq) -> CallRating:
|
|
770
|
+
"""Rate a finished call, and name the problems.
|
|
771
|
+
|
|
772
|
+
The nine problem ids are a fixed list validated here rather than at the
|
|
773
|
+
server, and they travel as hashtags appended to the comment — that is not
|
|
774
|
+
a tlgr convention, it is the wire format the clients use.
|
|
775
|
+
"""
|
|
776
|
+
from telethon.tl.functions import phone as fn
|
|
777
|
+
|
|
778
|
+
peer, _ = _calls.input_phone_call(ctx, req.call)
|
|
779
|
+
unknown = [p for p in req.problem if p not in RATING_PROBLEMS]
|
|
780
|
+
if unknown:
|
|
781
|
+
raise UsageError(
|
|
782
|
+
f"unknown problem(s) {unknown}; expected any of {', '.join(RATING_PROBLEMS)}",
|
|
783
|
+
field="problem",
|
|
784
|
+
)
|
|
785
|
+
comment = " ".join([req.comment.strip(), *(f"#{p}" for p in req.problem)]).strip()
|
|
786
|
+
await _client(ctx)(
|
|
787
|
+
fn.SetCallRatingRequest(
|
|
788
|
+
peer=peer,
|
|
789
|
+
rating=req.rating,
|
|
790
|
+
comment=comment,
|
|
791
|
+
user_initiative=req.user_initiative or None,
|
|
792
|
+
)
|
|
793
|
+
)
|
|
794
|
+
return CallRating(call_id=peer.id, rating=req.rating, comment=comment)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
SPEC_RATE = OperationSpec(
|
|
798
|
+
id="call.rate",
|
|
799
|
+
request=RateReq,
|
|
800
|
+
response=CallRating,
|
|
801
|
+
impl=rate,
|
|
802
|
+
summary="Rate call quality and report specific problems",
|
|
803
|
+
mutating=True,
|
|
804
|
+
rate_class="send",
|
|
805
|
+
columns=("call_id", "rating", "comment"),
|
|
806
|
+
example={"call_id": 4815162342, "rating": 4, "comment": "#echo"},
|
|
807
|
+
example_args="call rate 4815162342 4",
|
|
808
|
+
covers=("calls.rate-call", "calls.rate-problems"),
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
# ---------------------------------------------------------------------------
|
|
813
|
+
# call signal
|
|
814
|
+
# ---------------------------------------------------------------------------
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
class SignalReq(Request):
|
|
818
|
+
call: Annotated[str, arg(0, metavar="CALL", help="Call id, or id:access_hash.")]
|
|
819
|
+
data: Annotated[
|
|
820
|
+
str | None, opt("--data", metavar="B64", help="Opaque packet to send, base64.")
|
|
821
|
+
] = None
|
|
822
|
+
file: Annotated[
|
|
823
|
+
str | None,
|
|
824
|
+
opt("--file", metavar="PATH", kind="path", help="Read the packet from a file."),
|
|
825
|
+
] = None
|
|
826
|
+
follow: Annotated[bool, opt("--follow", help="Keep streaming inbound signalling packets.")] = (
|
|
827
|
+
False
|
|
828
|
+
)
|
|
829
|
+
idle_timeout: Annotated[
|
|
830
|
+
int,
|
|
831
|
+
opt("--idle-timeout", metavar="DURATION", kind="duration", help="Give up after silence."),
|
|
832
|
+
] = 3600
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
async def signal(ctx: OpContext, req: SignalReq) -> Any:
|
|
836
|
+
"""Carry one tgcalls signalling packet, and optionally watch for replies.
|
|
837
|
+
|
|
838
|
+
Bridge plumbing, and the only route to some things MTProto has no verb
|
|
839
|
+
for: "switch this voice call to video" is a media-state packet, not an
|
|
840
|
+
RPC. tlgr can carry the bytes and can never produce them, which is why
|
|
841
|
+
they go in and come out as opaque base64.
|
|
842
|
+
"""
|
|
843
|
+
from telethon import events
|
|
844
|
+
from telethon.tl import types
|
|
845
|
+
from telethon.tl.functions import phone as fn
|
|
846
|
+
|
|
847
|
+
peer, _ = _calls.input_phone_call(ctx, req.call)
|
|
848
|
+
payload: bytes | None = None
|
|
849
|
+
if req.file:
|
|
850
|
+
import sys
|
|
851
|
+
from pathlib import Path
|
|
852
|
+
|
|
853
|
+
payload = sys.stdin.buffer.read() if req.file == "-" else Path(req.file).read_bytes()
|
|
854
|
+
elif req.data:
|
|
855
|
+
payload = _calls.unb64(req.data, field="data")
|
|
856
|
+
|
|
857
|
+
if payload is None and not req.follow:
|
|
858
|
+
raise UsageError("give --data or --file to send, or --follow to listen", field="data")
|
|
859
|
+
|
|
860
|
+
if payload is not None:
|
|
861
|
+
await _client(ctx)(fn.SendSignalingDataRequest(peer=peer, data=payload))
|
|
862
|
+
yield Page(
|
|
863
|
+
items=[
|
|
864
|
+
CallSignal(
|
|
865
|
+
direction="out",
|
|
866
|
+
call_id=peer.id,
|
|
867
|
+
data=_calls.b64(payload),
|
|
868
|
+
at=_now(),
|
|
869
|
+
)
|
|
870
|
+
],
|
|
871
|
+
has_more=req.follow,
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
if not req.follow:
|
|
875
|
+
return
|
|
876
|
+
|
|
877
|
+
client = _client(ctx)
|
|
878
|
+
queue: asyncio.Queue[Any] = asyncio.Queue()
|
|
879
|
+
|
|
880
|
+
async def handler(update: Any) -> None:
|
|
881
|
+
if type(update).__name__ != "UpdatePhoneCallSignalingData":
|
|
882
|
+
return
|
|
883
|
+
if int(getattr(update, "phone_call_id", 0) or 0) != peer.id:
|
|
884
|
+
return
|
|
885
|
+
await queue.put(bytes(getattr(update, "data", b"") or b""))
|
|
886
|
+
|
|
887
|
+
builder = events.Raw(types=[types.UpdatePhoneCallSignalingData])
|
|
888
|
+
client.add_event_handler(handler, builder)
|
|
889
|
+
try:
|
|
890
|
+
while True:
|
|
891
|
+
try:
|
|
892
|
+
data = await asyncio.wait_for(queue.get(), timeout=max(1, req.idle_timeout))
|
|
893
|
+
except (TimeoutError, asyncio.TimeoutError):
|
|
894
|
+
yield Page(items=[], has_more=False)
|
|
895
|
+
return
|
|
896
|
+
yield Page(
|
|
897
|
+
items=[
|
|
898
|
+
CallSignal(direction="in", call_id=peer.id, data=_calls.b64(data), at=_now())
|
|
899
|
+
],
|
|
900
|
+
has_more=True,
|
|
901
|
+
)
|
|
902
|
+
finally:
|
|
903
|
+
client.remove_event_handler(handler, builder)
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
SPEC_SIGNAL = OperationSpec(
|
|
907
|
+
id="call.signal",
|
|
908
|
+
request=SignalReq,
|
|
909
|
+
response=Page[CallSignal],
|
|
910
|
+
impl=signal,
|
|
911
|
+
summary="Relay a tgcalls signalling packet to the peer (bridge plumbing)",
|
|
912
|
+
description=(
|
|
913
|
+
"The packets are opaque to tlgr. This is also the only route for "
|
|
914
|
+
"turning a camera on mid-call: MTProto has no verb for it, only a "
|
|
915
|
+
"media-state packet a real engine produces."
|
|
916
|
+
),
|
|
917
|
+
mutating=True,
|
|
918
|
+
stream=True,
|
|
919
|
+
rate_class="send",
|
|
920
|
+
columns=("direction", "call_id", "at"),
|
|
921
|
+
example={"items": [{"direction": "out", "call_id": 4815162342, "data": "AA==", "at": "x"}]},
|
|
922
|
+
example_args="call signal 4815162342 --data AA==",
|
|
923
|
+
covers=("calls.signaling-data", "calls.switch-to-video"),
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
# ---------------------------------------------------------------------------
|
|
928
|
+
# call watch
|
|
929
|
+
# ---------------------------------------------------------------------------
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
class WatchReq(Request):
|
|
933
|
+
auto_ack: Annotated[
|
|
934
|
+
bool, opt("--auto-ack", help="Send receivedCall for each incoming call (busy-lock).")
|
|
935
|
+
] = False
|
|
936
|
+
conference: Annotated[bool, opt("--conference", help="Also emit conference invitations.")] = (
|
|
937
|
+
True
|
|
938
|
+
)
|
|
939
|
+
idle_timeout: Annotated[
|
|
940
|
+
int,
|
|
941
|
+
opt("--idle-timeout", metavar="DURATION", kind="duration", help="Give up after silence."),
|
|
942
|
+
] = 3600
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def _conference_event(ctx: OpContext, message: Any, *, should_ring: bool) -> CallEvent:
|
|
946
|
+
action = getattr(message, "action", None)
|
|
947
|
+
others = [
|
|
948
|
+
Peer(
|
|
949
|
+
id=int(getattr(peer, "user_id", 0) or 0),
|
|
950
|
+
raw_id=int(getattr(peer, "user_id", 0) or 0),
|
|
951
|
+
kind="user",
|
|
952
|
+
)
|
|
953
|
+
for peer in (getattr(action, "other_participants", None) or [])
|
|
954
|
+
]
|
|
955
|
+
missed = bool(getattr(action, "missed", False))
|
|
956
|
+
return CallEvent(
|
|
957
|
+
kind="conference.invite-cancelled" if missed else "conference.invite",
|
|
958
|
+
at=_now(),
|
|
959
|
+
call_id=int(getattr(action, "call_id", 0) or 0) or None,
|
|
960
|
+
video=bool(getattr(action, "video", False)),
|
|
961
|
+
msg_id=int(getattr(message, "id", 0) or 0),
|
|
962
|
+
other_participants=others,
|
|
963
|
+
should_ring=should_ring,
|
|
964
|
+
peer=_peer_of(ctx, getattr(getattr(message, "peer_id", None), "user_id", None)),
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
async def watch(ctx: OpContext, req: WatchReq) -> Any:
|
|
969
|
+
"""Stream incoming rings, state changes and conference invitations.
|
|
970
|
+
|
|
971
|
+
This is the honest answer to "can tlgr take calls": it can tell you the
|
|
972
|
+
phone is ringing and who is calling, and hand that to a notifier or a
|
|
973
|
+
script. Conference invitations do not arrive as `updatePhoneCall` at all —
|
|
974
|
+
they are service messages — so they are folded in here rather than left
|
|
975
|
+
for the caller to discover.
|
|
976
|
+
"""
|
|
977
|
+
from telethon import events
|
|
978
|
+
from telethon.tl import types
|
|
979
|
+
from telethon.tl.functions import phone as fn
|
|
980
|
+
|
|
981
|
+
client = _client(ctx)
|
|
982
|
+
queue: asyncio.Queue[CallEvent] = asyncio.Queue()
|
|
983
|
+
config = await _calls.app_config(ctx) if req.conference else {}
|
|
984
|
+
requests_disabled = bool(config.get("call_requests_disabled", False))
|
|
985
|
+
|
|
986
|
+
async def handler(update: Any) -> None:
|
|
987
|
+
name = type(update).__name__
|
|
988
|
+
if name == "UpdatePhoneCall":
|
|
989
|
+
phone_call = getattr(update, "phone_call", None)
|
|
990
|
+
live = _absorb(ctx, phone_call)
|
|
991
|
+
if req.auto_ack and live.state == "requested":
|
|
992
|
+
with contextlib.suppress(Exception):
|
|
993
|
+
await client(
|
|
994
|
+
fn.ReceivedCallRequest(
|
|
995
|
+
peer=types.InputPhoneCall(id=live.id, access_hash=live.access_hash)
|
|
996
|
+
)
|
|
997
|
+
)
|
|
998
|
+
await queue.put(
|
|
999
|
+
CallEvent(
|
|
1000
|
+
kind=f"call.{live.state}",
|
|
1001
|
+
at=_now(),
|
|
1002
|
+
call_id=live.id,
|
|
1003
|
+
peer=_peer_of(ctx, live.peer_id),
|
|
1004
|
+
video=live.video,
|
|
1005
|
+
state=live.state,
|
|
1006
|
+
should_ring=live.state == "requested" and not requests_disabled,
|
|
1007
|
+
)
|
|
1008
|
+
)
|
|
1009
|
+
elif name == "UpdatePhoneCallSignalingData":
|
|
1010
|
+
await queue.put(
|
|
1011
|
+
CallEvent(
|
|
1012
|
+
kind="call.signalling",
|
|
1013
|
+
at=_now(),
|
|
1014
|
+
call_id=int(getattr(update, "phone_call_id", 0) or 0),
|
|
1015
|
+
data=_calls.b64(bytes(getattr(update, "data", b"") or b"")),
|
|
1016
|
+
)
|
|
1017
|
+
)
|
|
1018
|
+
elif req.conference and name in ("UpdateNewMessage", "UpdateShortChatMessage"):
|
|
1019
|
+
message = getattr(update, "message", None)
|
|
1020
|
+
action = getattr(message, "action", None)
|
|
1021
|
+
if type(action).__name__ != "MessageActionConferenceCall":
|
|
1022
|
+
return
|
|
1023
|
+
should_ring = not requests_disabled and not (
|
|
1024
|
+
getattr(action, "missed", False) or getattr(action, "active", False)
|
|
1025
|
+
)
|
|
1026
|
+
await queue.put(_conference_event(ctx, message, should_ring=should_ring))
|
|
1027
|
+
|
|
1028
|
+
builder = events.Raw(
|
|
1029
|
+
types=[
|
|
1030
|
+
types.UpdatePhoneCall,
|
|
1031
|
+
types.UpdatePhoneCallSignalingData,
|
|
1032
|
+
types.UpdateNewMessage,
|
|
1033
|
+
]
|
|
1034
|
+
)
|
|
1035
|
+
client.add_event_handler(handler, builder)
|
|
1036
|
+
try:
|
|
1037
|
+
while True:
|
|
1038
|
+
try:
|
|
1039
|
+
event = await asyncio.wait_for(queue.get(), timeout=max(1, req.idle_timeout))
|
|
1040
|
+
except (TimeoutError, asyncio.TimeoutError):
|
|
1041
|
+
yield Page(items=[], has_more=False)
|
|
1042
|
+
return
|
|
1043
|
+
yield Page(items=[event], has_more=True)
|
|
1044
|
+
finally:
|
|
1045
|
+
client.remove_event_handler(handler, builder)
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
SPEC_WATCH = OperationSpec(
|
|
1049
|
+
id="call.watch",
|
|
1050
|
+
request=WatchReq,
|
|
1051
|
+
response=Page[CallEvent],
|
|
1052
|
+
impl=watch,
|
|
1053
|
+
summary="Stream call signalling: incoming rings, state changes, conference invitations",
|
|
1054
|
+
description=(
|
|
1055
|
+
"`should_ring` is resolved here from the app-config and the action "
|
|
1056
|
+
"flags, so every notifier does not have to get it right separately."
|
|
1057
|
+
),
|
|
1058
|
+
stream=True,
|
|
1059
|
+
columns=("kind", "call_id", "state", "should_ring"),
|
|
1060
|
+
example={"items": [{"kind": "call.requested", "at": "2026-09-03T09:14:07Z"}]},
|
|
1061
|
+
example_args="call watch",
|
|
1062
|
+
covers=("calls.incoming-signalling", "conference.incoming-ring"),
|
|
1063
|
+
)
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
# ---------------------------------------------------------------------------
|
|
1067
|
+
# call invite (upgrade to a conference)
|
|
1068
|
+
# ---------------------------------------------------------------------------
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
class InviteReq(Request):
|
|
1072
|
+
call: Annotated[str, arg(0, metavar="CALL", help="Call id, or id:access_hash.")]
|
|
1073
|
+
user: Annotated[
|
|
1074
|
+
list[PeerRef],
|
|
1075
|
+
arg(1, metavar="USER", required=False, variadic=True, kind="user", help="Who to add."),
|
|
1076
|
+
] = []
|
|
1077
|
+
params_json: Annotated[
|
|
1078
|
+
str | None,
|
|
1079
|
+
opt("--params-json", metavar="PATH", kind="path", help="tgcalls join payload."),
|
|
1080
|
+
] = None
|
|
1081
|
+
public_key: Annotated[
|
|
1082
|
+
str | None, opt("--public-key", metavar="HEX", help="Your int256 E2E public key.")
|
|
1083
|
+
] = None
|
|
1084
|
+
block: Annotated[
|
|
1085
|
+
str | None,
|
|
1086
|
+
opt("--block", metavar="PATH", kind="path", help="Pre-built e2e.chain block."),
|
|
1087
|
+
] = None
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def _read_bytes(path: str, *, field: str) -> bytes:
|
|
1091
|
+
from pathlib import Path
|
|
1092
|
+
|
|
1093
|
+
try:
|
|
1094
|
+
return Path(path).read_bytes()
|
|
1095
|
+
except OSError as exc:
|
|
1096
|
+
raise UsageError(f"--{field}: {exc.strerror or exc}", field=field) from exc
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def _read_text(path: str, *, field: str) -> str:
|
|
1100
|
+
from pathlib import Path
|
|
1101
|
+
|
|
1102
|
+
try:
|
|
1103
|
+
return Path(path).read_text(encoding="utf-8")
|
|
1104
|
+
except OSError as exc:
|
|
1105
|
+
raise UsageError(f"--{field}: {exc.strerror or exc}", field=field) from exc
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def _public_key(value: str) -> int:
|
|
1109
|
+
try:
|
|
1110
|
+
return int(value, 16)
|
|
1111
|
+
except ValueError as exc:
|
|
1112
|
+
raise UsageError("--public-key must be hex", field="public_key") from exc
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
async def invite(ctx: OpContext, req: InviteReq) -> CallUpgrade:
|
|
1116
|
+
"""Upgrade a 1:1 call into a conference and pull the other side in.
|
|
1117
|
+
|
|
1118
|
+
Half of this is free and half of it is cryptography tlgr does not do.
|
|
1119
|
+
Creating the conference with `join=true` needs a signed initial
|
|
1120
|
+
`e2e.chain` block; tlgr cannot build one, so without `--block` and
|
|
1121
|
+
`--public-key` the command stops here with a usage error naming the
|
|
1122
|
+
missing piece rather than sending a request that is going to fail at the
|
|
1123
|
+
server. Everything after the slug exists — discarding the 1:1 call with
|
|
1124
|
+
`migrateConferenceCall` so the peer auto-joins, and inviting further
|
|
1125
|
+
users — is fully supported.
|
|
1126
|
+
"""
|
|
1127
|
+
from telethon import utils
|
|
1128
|
+
from telethon.tl import types
|
|
1129
|
+
from telethon.tl.functions import phone as fn
|
|
1130
|
+
|
|
1131
|
+
peer, known = _calls.input_phone_call(ctx, req.call)
|
|
1132
|
+
if known is not None and known.conference_supported is False:
|
|
1133
|
+
raise UsageError("the other side's client does not support conference calls", field="call")
|
|
1134
|
+
if not req.block or not req.public_key:
|
|
1135
|
+
raise UsageError(
|
|
1136
|
+
"upgrading a call to a conference needs a signed initial e2e.chain block "
|
|
1137
|
+
"(ChangeSetGroupState + ChangeSetSharedKey) and your int256 public key; "
|
|
1138
|
+
"tlgr cannot build the block, so pass --block and --public-key from an "
|
|
1139
|
+
"external E2E implementation",
|
|
1140
|
+
field="block",
|
|
1141
|
+
)
|
|
1142
|
+
|
|
1143
|
+
created = await _client(ctx)(
|
|
1144
|
+
fn.CreateConferenceCallRequest(
|
|
1145
|
+
join=True,
|
|
1146
|
+
random_id=secrets.randbits(31),
|
|
1147
|
+
public_key=_public_key(req.public_key),
|
|
1148
|
+
block=_read_bytes(req.block, field="block"),
|
|
1149
|
+
params=types.DataJSON(data=_read_text(req.params_json, field="params-json"))
|
|
1150
|
+
if req.params_json
|
|
1151
|
+
else None,
|
|
1152
|
+
)
|
|
1153
|
+
)
|
|
1154
|
+
call = _group_call_of(created)
|
|
1155
|
+
if call is None:
|
|
1156
|
+
raise IndeterminateError(
|
|
1157
|
+
"the conference was created but the server did not name it; the 1:1 call "
|
|
1158
|
+
"was left alone rather than discarded into nothing"
|
|
1159
|
+
)
|
|
1160
|
+
ref = _calls.call_ref_of(call)
|
|
1161
|
+
link = getattr(call, "invite_link", None)
|
|
1162
|
+
slug = link.rsplit("/", 1)[-1] if link else None
|
|
1163
|
+
ref.slug = slug
|
|
1164
|
+
|
|
1165
|
+
await _client(ctx)(
|
|
1166
|
+
fn.DiscardCallRequest(
|
|
1167
|
+
peer=peer,
|
|
1168
|
+
duration=0,
|
|
1169
|
+
reason=types.PhoneCallDiscardReasonMigrateConferenceCall(slug=slug or ""),
|
|
1170
|
+
connection_id=0,
|
|
1171
|
+
)
|
|
1172
|
+
)
|
|
1173
|
+
|
|
1174
|
+
invited: list[Peer] = []
|
|
1175
|
+
group = types.InputGroupCall(id=ref.id, access_hash=ref.access_hash or 0)
|
|
1176
|
+
for reference in req.user:
|
|
1177
|
+
target = await _send.resolve(ctx, reference)
|
|
1178
|
+
await _client(ctx)(
|
|
1179
|
+
fn.InviteConferenceCallParticipantRequest(
|
|
1180
|
+
call=group, user_id=utils.get_input_user(target)
|
|
1181
|
+
)
|
|
1182
|
+
)
|
|
1183
|
+
invited.append(_peer_of(ctx, _send.peer_id_of(target)) or Peer(id=0, raw_id=0, kind="user"))
|
|
1184
|
+
|
|
1185
|
+
ctx.emit("call_migrated", {"call_id": peer.id, "slug": slug})
|
|
1186
|
+
return CallUpgrade(
|
|
1187
|
+
call_id=peer.id,
|
|
1188
|
+
conference=ref,
|
|
1189
|
+
slug=slug,
|
|
1190
|
+
invite_link=link,
|
|
1191
|
+
invited=invited,
|
|
1192
|
+
migrated=True,
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
SPEC_INVITE = OperationSpec(
|
|
1197
|
+
id="call.invite",
|
|
1198
|
+
request=InviteReq,
|
|
1199
|
+
response=CallUpgrade,
|
|
1200
|
+
impl=invite,
|
|
1201
|
+
aliases=("call.add-people",),
|
|
1202
|
+
summary="Upgrade a 1:1 call into a conference and pull the other side in",
|
|
1203
|
+
mutating=True,
|
|
1204
|
+
rate_class="send",
|
|
1205
|
+
columns=("call_id", "slug", "invite_link"),
|
|
1206
|
+
example={"call_id": 4815162342, "slug": "AbCdEf", "migrated": True},
|
|
1207
|
+
example_args="call invite 4815162342 @bobby --block /tmp/block.bin --public-key ff",
|
|
1208
|
+
covers_partial=("calls.migrate-to-conference",),
|
|
1209
|
+
coverage_note=(
|
|
1210
|
+
"the migration and the invitations are complete; creating the conference "
|
|
1211
|
+
"needs a signed e2e.chain block, which tlgr accepts (--block) but cannot "
|
|
1212
|
+
"build"
|
|
1213
|
+
),
|
|
1214
|
+
tags=frozenset({"visible-to-others"}),
|
|
1215
|
+
)
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
# ---------------------------------------------------------------------------
|
|
1219
|
+
# call config get
|
|
1220
|
+
# ---------------------------------------------------------------------------
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
class ConfigReq(Request):
|
|
1224
|
+
timeouts: Annotated[bool, opt("--timeouts", help="Include the ringing timeouts.")] = True
|
|
1225
|
+
dh: Annotated[bool, opt("--dh", help="Include the DH parameters and their verdict.")] = False
|
|
1226
|
+
raw: Annotated[bool, opt("--raw", help="Include the tgcalls blob verbatim.")] = False
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
async def config_get(ctx: OpContext, req: ConfigReq) -> CallConfig:
|
|
1230
|
+
"""Everything a client needs before it can ring, in one place.
|
|
1231
|
+
|
|
1232
|
+
The DH block is reported with its validation verdict beside it, never on
|
|
1233
|
+
its own: a prime the server chose and the client did not check is not a
|
|
1234
|
+
key exchange.
|
|
1235
|
+
"""
|
|
1236
|
+
import json
|
|
1237
|
+
|
|
1238
|
+
from telethon.tl.functions import help as help_fn
|
|
1239
|
+
from telethon.tl.functions import messages as messages_fn
|
|
1240
|
+
from telethon.tl.functions import phone as fn
|
|
1241
|
+
|
|
1242
|
+
client = _client(ctx)
|
|
1243
|
+
blob = await client(fn.GetCallConfigRequest())
|
|
1244
|
+
text = str(getattr(blob, "data", "") or "")
|
|
1245
|
+
try:
|
|
1246
|
+
parsed = json.loads(text) if text else {}
|
|
1247
|
+
except ValueError:
|
|
1248
|
+
parsed = {}
|
|
1249
|
+
tgcalls: dict[str, Any] = dict(parsed) if isinstance(parsed, dict) else {"value": parsed}
|
|
1250
|
+
if req.raw:
|
|
1251
|
+
tgcalls["raw"] = text
|
|
1252
|
+
|
|
1253
|
+
timeouts: dict[str, int] = {}
|
|
1254
|
+
if req.timeouts:
|
|
1255
|
+
server = await client(help_fn.GetConfigRequest())
|
|
1256
|
+
for name in (
|
|
1257
|
+
"call_receive_timeout_ms",
|
|
1258
|
+
"call_ring_timeout_ms",
|
|
1259
|
+
"call_connect_timeout_ms",
|
|
1260
|
+
"call_packet_timeout_ms",
|
|
1261
|
+
):
|
|
1262
|
+
value = getattr(server, name, None)
|
|
1263
|
+
if value is not None:
|
|
1264
|
+
timeouts[name] = int(value)
|
|
1265
|
+
|
|
1266
|
+
dh: dict[str, Any] | None = None
|
|
1267
|
+
if req.dh:
|
|
1268
|
+
config = await client(messages_fn.GetDhConfigRequest(version=0, random_length=0))
|
|
1269
|
+
p_bytes = bytes(getattr(config, "p", b"") or b"")
|
|
1270
|
+
g = int(getattr(config, "g", 0) or 0)
|
|
1271
|
+
dh = {"version": int(getattr(config, "version", 0) or 0), **_calls.dh_verdict(p_bytes, g)}
|
|
1272
|
+
|
|
1273
|
+
limits = await _calls.app_config(ctx)
|
|
1274
|
+
numeric = {k: int(v) for k, v in limits.items() if isinstance(v, (int, float))}
|
|
1275
|
+
return CallConfig(
|
|
1276
|
+
tgcalls_config=tgcalls,
|
|
1277
|
+
timeouts=timeouts,
|
|
1278
|
+
dh=dh,
|
|
1279
|
+
call_requests_disabled=bool(limits.get("call_requests_disabled", False)),
|
|
1280
|
+
conference_call_size_limit=numeric.get("conference_call_size_limit"),
|
|
1281
|
+
limits=numeric,
|
|
1282
|
+
)
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
SPEC_CONFIG_GET = OperationSpec(
|
|
1286
|
+
id="call.config.get",
|
|
1287
|
+
request=ConfigReq,
|
|
1288
|
+
response=CallConfig,
|
|
1289
|
+
impl=config_get,
|
|
1290
|
+
summary="VoIP configuration: tgcalls blob, ringing timeouts, DH parameters, call limits",
|
|
1291
|
+
columns=("call_requests_disabled", "conference_call_size_limit"),
|
|
1292
|
+
example={"timeouts": {"call_ring_timeout_ms": 90000}, "call_requests_disabled": False},
|
|
1293
|
+
example_args="call config get",
|
|
1294
|
+
covers=(
|
|
1295
|
+
"calls.client-call-requests-disabled",
|
|
1296
|
+
"calls.dh-config",
|
|
1297
|
+
"calls.get-call-config",
|
|
1298
|
+
"calls.timeout-config",
|
|
1299
|
+
),
|
|
1300
|
+
)
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
# ---------------------------------------------------------------------------
|
|
1304
|
+
# call debug upload
|
|
1305
|
+
# ---------------------------------------------------------------------------
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
class DebugReq(Request):
|
|
1309
|
+
call: Annotated[str, arg(0, metavar="CALL", help="Call id, or id:access_hash.")]
|
|
1310
|
+
json_file: Annotated[
|
|
1311
|
+
str | None,
|
|
1312
|
+
opt("--json-file", metavar="PATH", kind="path", help="tgcalls debug JSON to upload."),
|
|
1313
|
+
] = None
|
|
1314
|
+
log_file: Annotated[
|
|
1315
|
+
str | None,
|
|
1316
|
+
opt("--log-file", metavar="PATH", kind="path", help="Whole log file to upload."),
|
|
1317
|
+
] = None
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
async def debug_upload(ctx: OpContext, req: DebugReq) -> CallDebugUpload:
|
|
1321
|
+
"""Upload a debug blob or a log file the server asked for.
|
|
1322
|
+
|
|
1323
|
+
Only meaningful when a real media engine produced it — the server asks via
|
|
1324
|
+
`phoneCallDiscarded.need_debug`. tlgr never fabricates one, which is why
|
|
1325
|
+
both inputs are files and neither has a default.
|
|
1326
|
+
"""
|
|
1327
|
+
from telethon.tl import types
|
|
1328
|
+
from telethon.tl.functions import phone as fn
|
|
1329
|
+
|
|
1330
|
+
peer, _ = _calls.input_phone_call(ctx, req.call)
|
|
1331
|
+
if not req.json_file and not req.log_file:
|
|
1332
|
+
raise UsageError(
|
|
1333
|
+
"give --json-file or --log-file; tlgr has no media engine and will not "
|
|
1334
|
+
"invent a debug blob",
|
|
1335
|
+
field="json_file",
|
|
1336
|
+
)
|
|
1337
|
+
uploaded: list[str] = []
|
|
1338
|
+
if req.json_file:
|
|
1339
|
+
data = _read_text(req.json_file, field="json-file")
|
|
1340
|
+
await _client(ctx)(fn.SaveCallDebugRequest(peer=peer, debug=types.DataJSON(data=data)))
|
|
1341
|
+
uploaded.append("debug")
|
|
1342
|
+
if req.log_file:
|
|
1343
|
+
handle = await ctx.upload_file(req.log_file) # type: ignore[attr-defined]
|
|
1344
|
+
await _client(ctx)(fn.SaveCallLogRequest(peer=peer, file=handle))
|
|
1345
|
+
uploaded.append("log")
|
|
1346
|
+
return CallDebugUpload(call_id=peer.id, uploaded=uploaded)
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
SPEC_DEBUG_UPLOAD = OperationSpec(
|
|
1350
|
+
id="call.debug.upload",
|
|
1351
|
+
request=DebugReq,
|
|
1352
|
+
response=CallDebugUpload,
|
|
1353
|
+
impl=debug_upload,
|
|
1354
|
+
summary="Upload call debug information or a call log file",
|
|
1355
|
+
mutating=True,
|
|
1356
|
+
rate_class="file",
|
|
1357
|
+
columns=("call_id", "uploaded"),
|
|
1358
|
+
example={"call_id": 4815162342, "uploaded": ["debug"]},
|
|
1359
|
+
example_args="call debug upload 4815162342 --json-file /tmp/debug.json",
|
|
1360
|
+
covers=("calls.save-debug", "calls.save-log"),
|
|
1361
|
+
)
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
# ---------------------------------------------------------------------------
|
|
1365
|
+
# call log list
|
|
1366
|
+
# ---------------------------------------------------------------------------
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
class LogListReq(Request):
|
|
1370
|
+
missed: Annotated[bool, opt("--missed", help="Missed calls only (the server filter).")] = False
|
|
1371
|
+
with_peer: Annotated[
|
|
1372
|
+
PeerRef | None,
|
|
1373
|
+
opt("--with", metavar="USER", kind="user", help="Only calls with this peer."),
|
|
1374
|
+
] = None
|
|
1375
|
+
video: Annotated[bool, opt("--video", help="Video calls only (filtered locally).")] = False
|
|
1376
|
+
since: Annotated[
|
|
1377
|
+
str | None, opt("--since", metavar="WHEN", kind="datetime", help="Lower date bound.")
|
|
1378
|
+
] = None
|
|
1379
|
+
until: Annotated[
|
|
1380
|
+
str | None, opt("--until", metavar="WHEN", kind="datetime", help="Upper date bound.")
|
|
1381
|
+
] = None
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def _log_entry(ctx: OpContext, message: Any, chat_id: int) -> CallLogEntry | None:
|
|
1385
|
+
"""One call-log service message as a flat row, or None if it is not one."""
|
|
1386
|
+
action = getattr(message, "action", None)
|
|
1387
|
+
name = type(action).__name__
|
|
1388
|
+
if name not in ("MessageActionPhoneCall", "MessageActionConferenceCall"):
|
|
1389
|
+
return None
|
|
1390
|
+
out = bool(getattr(message, "out", False))
|
|
1391
|
+
reason = _calls.reason_name(getattr(action, "reason", None))
|
|
1392
|
+
conference = name == "MessageActionConferenceCall"
|
|
1393
|
+
missed = bool(getattr(action, "missed", False)) if conference else reason in ("missed", "busy")
|
|
1394
|
+
date = getattr(message, "date", None)
|
|
1395
|
+
others = [
|
|
1396
|
+
Peer(
|
|
1397
|
+
id=int(getattr(peer, "user_id", 0) or 0),
|
|
1398
|
+
raw_id=int(getattr(peer, "user_id", 0) or 0),
|
|
1399
|
+
kind="user",
|
|
1400
|
+
)
|
|
1401
|
+
for peer in (getattr(action, "other_participants", None) or [])
|
|
1402
|
+
]
|
|
1403
|
+
return CallLogEntry(
|
|
1404
|
+
msg_id=int(getattr(message, "id", 0) or 0),
|
|
1405
|
+
chat_id=chat_id,
|
|
1406
|
+
date=fmt_dt(date) or "",
|
|
1407
|
+
date_unix=to_unix(date) or 0,
|
|
1408
|
+
kind="conference" if conference else "call",
|
|
1409
|
+
direction="out" if out else "in",
|
|
1410
|
+
peer=_peer_of(ctx, abs(chat_id)) if chat_id > 0 else None,
|
|
1411
|
+
call_id=int(getattr(action, "call_id", 0) or 0) or None,
|
|
1412
|
+
video=bool(getattr(action, "video", False)),
|
|
1413
|
+
reason=reason,
|
|
1414
|
+
duration=int(getattr(action, "duration", 0) or 0),
|
|
1415
|
+
missed=missed,
|
|
1416
|
+
active=bool(getattr(action, "active", False)) if conference else None,
|
|
1417
|
+
other_participants=others,
|
|
1418
|
+
)
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
async def log_list(ctx: OpContext, req: LogListReq) -> Page[CallLogEntry]:
|
|
1422
|
+
"""The Calls tab: voice, video, group and conference calls in one list.
|
|
1423
|
+
|
|
1424
|
+
Not a method of its own — the call log is a global `messages.search` with
|
|
1425
|
+
`inputMessagesFilterPhoneCalls`, and the rows are service messages. This
|
|
1426
|
+
decodes both actions into one flat shape (Android shows the conference
|
|
1427
|
+
one as "Incoming/Outgoing/Missed Group Call") and keeps `chat_id` and
|
|
1428
|
+
`msg_id`, so `message get` jumps to the bubble and `call start
|
|
1429
|
+
--from-message` redials.
|
|
1430
|
+
"""
|
|
1431
|
+
from telethon import utils
|
|
1432
|
+
from telethon.tl import types
|
|
1433
|
+
from telethon.tl.functions import messages as fn
|
|
1434
|
+
|
|
1435
|
+
limit = min(int(getattr(ctx, "limit", None) or 30), 100)
|
|
1436
|
+
if limit < 1:
|
|
1437
|
+
raise UsageError("--limit must be at least 1", field="limit")
|
|
1438
|
+
token = getattr(ctx, "cursor", None)
|
|
1439
|
+
state = (
|
|
1440
|
+
decode_cursor(token, op="call.log.list", kind=PageKind.SEARCH, account=ctx.account)
|
|
1441
|
+
if token
|
|
1442
|
+
else {}
|
|
1443
|
+
)
|
|
1444
|
+
|
|
1445
|
+
peer: Any = types.InputPeerEmpty()
|
|
1446
|
+
if req.with_peer is not None:
|
|
1447
|
+
peer = await _send.resolve(ctx, req.with_peer)
|
|
1448
|
+
|
|
1449
|
+
result = await _client(ctx)(
|
|
1450
|
+
fn.SearchRequest(
|
|
1451
|
+
peer=peer,
|
|
1452
|
+
q="",
|
|
1453
|
+
filter=types.InputMessagesFilterPhoneCalls(missed=req.missed or None),
|
|
1454
|
+
min_date=parse_dt(req.since) if req.since else None,
|
|
1455
|
+
max_date=parse_dt(req.until) if req.until else None,
|
|
1456
|
+
offset_id=int(state.get("offset_id", 0) or 0),
|
|
1457
|
+
add_offset=0,
|
|
1458
|
+
limit=limit,
|
|
1459
|
+
max_id=0,
|
|
1460
|
+
min_id=0,
|
|
1461
|
+
hash=0,
|
|
1462
|
+
)
|
|
1463
|
+
)
|
|
1464
|
+
|
|
1465
|
+
items: list[CallLogEntry] = []
|
|
1466
|
+
last_id = 0
|
|
1467
|
+
for message in getattr(result, "messages", None) or []:
|
|
1468
|
+
last_id = int(getattr(message, "id", 0) or 0) or last_id
|
|
1469
|
+
try:
|
|
1470
|
+
chat_id = int(utils.get_peer_id(getattr(message, "peer_id", None)))
|
|
1471
|
+
except (TypeError, ValueError): # pragma: no cover - malformed row
|
|
1472
|
+
chat_id = 0
|
|
1473
|
+
entry = _log_entry(ctx, message, chat_id)
|
|
1474
|
+
if entry is None:
|
|
1475
|
+
continue
|
|
1476
|
+
if req.video and not entry.video:
|
|
1477
|
+
continue
|
|
1478
|
+
items.append(entry)
|
|
1479
|
+
|
|
1480
|
+
total = getattr(result, "count", None)
|
|
1481
|
+
return build_page(
|
|
1482
|
+
items,
|
|
1483
|
+
op="call.log.list",
|
|
1484
|
+
kind=PageKind.SEARCH,
|
|
1485
|
+
state={"offset_id": last_id},
|
|
1486
|
+
account=ctx.account,
|
|
1487
|
+
limit=limit,
|
|
1488
|
+
has_more=bool(last_id) and len(getattr(result, "messages", None) or []) >= limit,
|
|
1489
|
+
total=int(total) if isinstance(total, int) else None,
|
|
1490
|
+
)
|
|
1491
|
+
|
|
1492
|
+
|
|
1493
|
+
SPEC_LOG_LIST = OperationSpec(
|
|
1494
|
+
id="call.log.list",
|
|
1495
|
+
request=LogListReq,
|
|
1496
|
+
response=Page[CallLogEntry],
|
|
1497
|
+
impl=log_list,
|
|
1498
|
+
summary="The Calls tab: every voice, video, group and conference call",
|
|
1499
|
+
description=(
|
|
1500
|
+
"Rows are service messages, decoded here: `messageActionPhoneCall` and "
|
|
1501
|
+
"`messageActionConferenceCall` become one flat shape."
|
|
1502
|
+
),
|
|
1503
|
+
paginated=PageKind.SEARCH,
|
|
1504
|
+
columns=("date", "direction", "kind", "video", "duration", "reason", "chat_id", "msg_id"),
|
|
1505
|
+
headers=("Date", "Dir", "Kind", "Video", "Secs", "Reason", "Chat", "Msg"),
|
|
1506
|
+
example={
|
|
1507
|
+
"items": [
|
|
1508
|
+
{
|
|
1509
|
+
"msg_id": 900,
|
|
1510
|
+
"chat_id": 4242,
|
|
1511
|
+
"date": "2026-09-03T09:14:07Z",
|
|
1512
|
+
"date_unix": 1788340447,
|
|
1513
|
+
"kind": "call",
|
|
1514
|
+
"direction": "in",
|
|
1515
|
+
"duration": 42,
|
|
1516
|
+
}
|
|
1517
|
+
],
|
|
1518
|
+
"has_more": False,
|
|
1519
|
+
},
|
|
1520
|
+
example_args="call log list",
|
|
1521
|
+
covers=(
|
|
1522
|
+
"calls.history-list",
|
|
1523
|
+
"calls.history-missed-only",
|
|
1524
|
+
"calls.history-per-peer",
|
|
1525
|
+
"calls.history-service-message-parse",
|
|
1526
|
+
),
|
|
1527
|
+
)
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
# ---------------------------------------------------------------------------
|
|
1531
|
+
# call log delete
|
|
1532
|
+
# ---------------------------------------------------------------------------
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
class LogDeleteReq(Request):
|
|
1536
|
+
id: Annotated[
|
|
1537
|
+
list[str],
|
|
1538
|
+
arg(
|
|
1539
|
+
0,
|
|
1540
|
+
metavar="CHAT:MSG_ID",
|
|
1541
|
+
required=False,
|
|
1542
|
+
variadic=True,
|
|
1543
|
+
help="Rows as `call log list` prints them.",
|
|
1544
|
+
),
|
|
1545
|
+
] = []
|
|
1546
|
+
revoke: Annotated[bool, opt("--revoke", help="Delete for both sides.")] = False
|
|
1547
|
+
history: Annotated[
|
|
1548
|
+
bool, opt("--history", help="Delete the whole call log instead of named rows.")
|
|
1549
|
+
] = False
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
async def log_delete(ctx: OpContext, req: LogDeleteReq) -> CallLogDeleted:
|
|
1553
|
+
"""Delete call log rows, or the entire call log.
|
|
1554
|
+
|
|
1555
|
+
A row is the underlying service message, so named rows go down tlgr's
|
|
1556
|
+
ordinary delete path. `--history` is a different RPC that answers with an
|
|
1557
|
+
offset to resume from; calling it once and reporting success is how "clear
|
|
1558
|
+
my call history" ends up clearing the first page.
|
|
1559
|
+
"""
|
|
1560
|
+
from telethon.tl.functions import messages as fn
|
|
1561
|
+
|
|
1562
|
+
from tlgr.models.peer import parse_peer_ref
|
|
1563
|
+
|
|
1564
|
+
client = _client(ctx)
|
|
1565
|
+
if req.history:
|
|
1566
|
+
deleted = 0
|
|
1567
|
+
for _ in range(100):
|
|
1568
|
+
affected = await client(fn.DeletePhoneCallHistoryRequest(revoke=req.revoke or None))
|
|
1569
|
+
deleted += len(getattr(affected, "messages", None) or [])
|
|
1570
|
+
if not int(getattr(affected, "offset", 0) or 0):
|
|
1571
|
+
break
|
|
1572
|
+
limiter = getattr(ctx, "limiter", None)
|
|
1573
|
+
if limiter is not None:
|
|
1574
|
+
await limiter.acquire("bulk")
|
|
1575
|
+
ctx.emit("call_log_cleared", {"revoked": req.revoke})
|
|
1576
|
+
return CallLogDeleted(deleted=deleted, revoked=req.revoke)
|
|
1577
|
+
|
|
1578
|
+
if not req.id:
|
|
1579
|
+
raise UsageError("give CHAT:MSG_ID rows, or --history to clear the log", field="id")
|
|
1580
|
+
|
|
1581
|
+
grouped: dict[str, list[int]] = {}
|
|
1582
|
+
for token in req.id:
|
|
1583
|
+
chat, _, tail = str(token).rpartition(":")
|
|
1584
|
+
if not chat or not tail.lstrip("-").isdigit():
|
|
1585
|
+
raise UsageError(f"{token!r} is not CHAT:MSG_ID", field="id")
|
|
1586
|
+
grouped.setdefault(chat, []).append(int(tail))
|
|
1587
|
+
|
|
1588
|
+
deleted = 0
|
|
1589
|
+
for chat, ids in grouped.items():
|
|
1590
|
+
peer = await _send.resolve(ctx, parse_peer_ref(chat))
|
|
1591
|
+
await client.delete_messages(peer, ids, revoke=req.revoke)
|
|
1592
|
+
deleted += len(ids)
|
|
1593
|
+
ctx.emit("call_log_deleted", {"count": deleted})
|
|
1594
|
+
return CallLogDeleted(deleted=deleted, revoked=req.revoke)
|
|
1595
|
+
|
|
1596
|
+
|
|
1597
|
+
SPEC_LOG_DELETE = OperationSpec(
|
|
1598
|
+
id="call.log.delete",
|
|
1599
|
+
request=LogDeleteReq,
|
|
1600
|
+
response=CallLogDeleted,
|
|
1601
|
+
impl=log_delete,
|
|
1602
|
+
summary="Delete call log entries, or the whole call log",
|
|
1603
|
+
mutating=True,
|
|
1604
|
+
destructive=True,
|
|
1605
|
+
rate_class="bulk",
|
|
1606
|
+
columns=("deleted", "revoked"),
|
|
1607
|
+
example={"deleted": 3, "revoked": True},
|
|
1608
|
+
example_args="call log delete 4242:900",
|
|
1609
|
+
covers=("calls.history-delete-selected", "messages-core.delete-call-history"),
|
|
1610
|
+
)
|