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.
Files changed (192) hide show
  1. tlgr/__init__.py +3 -0
  2. tlgr/__main__.py +6 -0
  3. tlgr/actions/__init__.py +45 -0
  4. tlgr/actions/forward.py +74 -0
  5. tlgr/actions/reply.py +32 -0
  6. tlgr/cli/__init__.py +259 -0
  7. tlgr/cli/confirm.py +55 -0
  8. tlgr/cli/errors.py +84 -0
  9. tlgr/cli/gen.py +690 -0
  10. tlgr/cli/globals.py +273 -0
  11. tlgr/cli/introspect.py +170 -0
  12. tlgr/cli/params.py +189 -0
  13. tlgr/cli/render.py +418 -0
  14. tlgr/core/__init__.py +0 -0
  15. tlgr/core/accounts.py +384 -0
  16. tlgr/core/config.py +358 -0
  17. tlgr/core/custom_tl.py +170 -0
  18. tlgr/core/errors.py +687 -0
  19. tlgr/core/eventtypes.py +1170 -0
  20. tlgr/core/identity.py +127 -0
  21. tlgr/core/launchd.py +122 -0
  22. tlgr/core/logging.py +194 -0
  23. tlgr/core/media.py +134 -0
  24. tlgr/core/output.py +251 -0
  25. tlgr/core/pagination.py +227 -0
  26. tlgr/core/paths.py +360 -0
  27. tlgr/core/peers.py +427 -0
  28. tlgr/core/process.py +138 -0
  29. tlgr/core/signing.py +38 -0
  30. tlgr/core/systemd.py +96 -0
  31. tlgr/core/telethon_compat.py +295 -0
  32. tlgr/core/text.py +211 -0
  33. tlgr/core/timefmt.py +199 -0
  34. tlgr/core/tl.py +98 -0
  35. tlgr/daemon/__init__.py +0 -0
  36. tlgr/daemon/app.py +869 -0
  37. tlgr/daemon/dispatch.py +446 -0
  38. tlgr/daemon/events.py +723 -0
  39. tlgr/daemon/files.py +431 -0
  40. tlgr/daemon/idle.py +119 -0
  41. tlgr/daemon/jobs.py +68 -0
  42. tlgr/daemon/main.py +161 -0
  43. tlgr/daemon/peercred.py +75 -0
  44. tlgr/daemon/policy.py +113 -0
  45. tlgr/daemon/preauth.py +366 -0
  46. tlgr/daemon/ratelimit.py +391 -0
  47. tlgr/daemon/server.py +24 -0
  48. tlgr/daemon/session.py +648 -0
  49. tlgr/daemon/sessions.py +274 -0
  50. tlgr/daemon/singleton.py +114 -0
  51. tlgr/daemon/stream.py +193 -0
  52. tlgr/daemon/transfers.py +219 -0
  53. tlgr/daemon/webhook.py +390 -0
  54. tlgr/data/catalog_index.json +1 -0
  55. tlgr/data/parity_waivers.toml +90 -0
  56. tlgr/filters/__init__.py +42 -0
  57. tlgr/filters/compose.py +121 -0
  58. tlgr/filters/content.py +85 -0
  59. tlgr/filters/context.py +114 -0
  60. tlgr/filters/message.py +161 -0
  61. tlgr/filters/temporal.py +87 -0
  62. tlgr/filters/user.py +36 -0
  63. tlgr/gateway/__init__.py +1 -0
  64. tlgr/gateway/config.py +161 -0
  65. tlgr/gateway/engine.py +215 -0
  66. tlgr/gateway/event.py +22 -0
  67. tlgr/jobs/__init__.py +0 -0
  68. tlgr/jobs/base.py +81 -0
  69. tlgr/jobs/client.py +37 -0
  70. tlgr/models/__init__.py +1220 -0
  71. tlgr/models/admin.py +744 -0
  72. tlgr/models/auth.py +510 -0
  73. tlgr/models/base.py +81 -0
  74. tlgr/models/bot.py +576 -0
  75. tlgr/models/business.py +265 -0
  76. tlgr/models/call.py +586 -0
  77. tlgr/models/config.py +101 -0
  78. tlgr/models/contact.py +481 -0
  79. tlgr/models/daemon.py +336 -0
  80. tlgr/models/dialog.py +626 -0
  81. tlgr/models/envelope.py +68 -0
  82. tlgr/models/error.py +30 -0
  83. tlgr/models/event.py +79 -0
  84. tlgr/models/export.py +66 -0
  85. tlgr/models/gift.py +275 -0
  86. tlgr/models/inline.py +84 -0
  87. tlgr/models/location.py +115 -0
  88. tlgr/models/media.py +507 -0
  89. tlgr/models/message.py +584 -0
  90. tlgr/models/net.py +232 -0
  91. tlgr/models/notify.py +105 -0
  92. tlgr/models/page.py +32 -0
  93. tlgr/models/payment.py +172 -0
  94. tlgr/models/peer.py +400 -0
  95. tlgr/models/poll.py +119 -0
  96. tlgr/models/premium.py +161 -0
  97. tlgr/models/privacy.py +93 -0
  98. tlgr/models/profile.py +217 -0
  99. tlgr/models/reaction.py +160 -0
  100. tlgr/models/resolve.py +175 -0
  101. tlgr/models/settings.py +103 -0
  102. tlgr/models/stars.py +101 -0
  103. tlgr/models/sticker.py +243 -0
  104. tlgr/models/story.py +467 -0
  105. tlgr/models/sync.py +105 -0
  106. tlgr/models/todo.py +36 -0
  107. tlgr/models/webapp.py +89 -0
  108. tlgr/ops/__init__.py +63 -0
  109. tlgr/ops/_admin.py +313 -0
  110. tlgr/ops/_auth.py +599 -0
  111. tlgr/ops/_bots.py +586 -0
  112. tlgr/ops/_calls.py +535 -0
  113. tlgr/ops/_common.py +160 -0
  114. tlgr/ops/_layer.py +46 -0
  115. tlgr/ops/_media.py +592 -0
  116. tlgr/ops/_params.py +212 -0
  117. tlgr/ops/_rights.py +402 -0
  118. tlgr/ops/_send.py +593 -0
  119. tlgr/ops/_serialize.py +667 -0
  120. tlgr/ops/_settings.py +306 -0
  121. tlgr/ops/_spec.py +167 -0
  122. tlgr/ops/_story.py +743 -0
  123. tlgr/ops/account.py +2604 -0
  124. tlgr/ops/agent.py +937 -0
  125. tlgr/ops/auth.py +1282 -0
  126. tlgr/ops/bot.py +4880 -0
  127. tlgr/ops/business.py +1520 -0
  128. tlgr/ops/call.py +1610 -0
  129. tlgr/ops/chat.py +4025 -0
  130. tlgr/ops/chat_admin.py +929 -0
  131. tlgr/ops/chat_extra.py +1061 -0
  132. tlgr/ops/chat_invite.py +716 -0
  133. tlgr/ops/chat_manage.py +1691 -0
  134. tlgr/ops/chat_member.py +1357 -0
  135. tlgr/ops/chat_stats.py +902 -0
  136. tlgr/ops/chat_topic.py +905 -0
  137. tlgr/ops/conference.py +791 -0
  138. tlgr/ops/config.py +1698 -0
  139. tlgr/ops/contact.py +2330 -0
  140. tlgr/ops/daemon.py +1397 -0
  141. tlgr/ops/draft.py +299 -0
  142. tlgr/ops/emoji.py +343 -0
  143. tlgr/ops/events.py +1327 -0
  144. tlgr/ops/export.py +596 -0
  145. tlgr/ops/folder.py +1322 -0
  146. tlgr/ops/gif.py +522 -0
  147. tlgr/ops/gift.py +1546 -0
  148. tlgr/ops/giveaway.py +541 -0
  149. tlgr/ops/inline.py +773 -0
  150. tlgr/ops/job.py +799 -0
  151. tlgr/ops/location.py +917 -0
  152. tlgr/ops/media.py +4495 -0
  153. tlgr/ops/message.py +3769 -0
  154. tlgr/ops/net.py +536 -0
  155. tlgr/ops/notify.py +840 -0
  156. tlgr/ops/passport.py +464 -0
  157. tlgr/ops/payment.py +907 -0
  158. tlgr/ops/poll.py +1078 -0
  159. tlgr/ops/premium.py +488 -0
  160. tlgr/ops/privacy.py +794 -0
  161. tlgr/ops/profile.py +1481 -0
  162. tlgr/ops/proxy.py +750 -0
  163. tlgr/ops/reaction.py +1475 -0
  164. tlgr/ops/resolve.py +1140 -0
  165. tlgr/ops/search.py +521 -0
  166. tlgr/ops/settings.py +1066 -0
  167. tlgr/ops/stars.py +594 -0
  168. tlgr/ops/sticker.py +1602 -0
  169. tlgr/ops/story.py +3216 -0
  170. tlgr/ops/sync.py +788 -0
  171. tlgr/ops/todo.py +514 -0
  172. tlgr/ops/user.py +1406 -0
  173. tlgr/ops/vc.py +2351 -0
  174. tlgr/ops/webapp.py +717 -0
  175. tlgr/ops/webhook.py +418 -0
  176. tlgr/parity.py +386 -0
  177. tlgr/processors/__init__.py +125 -0
  178. tlgr/processors/regex.py +26 -0
  179. tlgr/processors/text.py +56 -0
  180. tlgr/registry.py +519 -0
  181. tlgr/schema.py +173 -0
  182. tlgr/transport/__init__.py +30 -0
  183. tlgr/transport/autostart.py +293 -0
  184. tlgr/transport/client.py +805 -0
  185. tlgr/transport/ndjson.py +44 -0
  186. tlgr/version.py +31 -0
  187. tlgr_cli-2.0.1.dist-info/METADATA +957 -0
  188. tlgr_cli-2.0.1.dist-info/RECORD +192 -0
  189. tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
  190. tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
  191. tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
  192. 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
+ )