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/vc.py ADDED
@@ -0,0 +1,2351 @@
1
+ """The `vc` group: video chats, livestreams, live stories and RTMP.
2
+
3
+ One MTProto surface (`phone.*groupCall*`) is four products in the GUI, and
4
+ which one you get depends on the peer and two flags: a video chat in a group,
5
+ a livestream in a channel, an RTMP stream when `rtmp_stream` is set, a live
6
+ story when it hangs off a story. tlgr keeps that as one noun with one set of
7
+ verbs and reports `kind`, rather than shipping four near-identical groups.
8
+
9
+ What is real here and what is not:
10
+
11
+ * **Real.** Creating, scheduling, starting, ending, titling, recording,
12
+ muting, moderating, inviting, links, RTMP credentials, the participant
13
+ list, the live update stream, and downloading a livestream to disk. None of
14
+ that needs a media engine.
15
+ * **Not real.** Anything that would put your microphone or camera on the
16
+ wire. `vc join` obtains *server-side presence*, `vc mute` flips a
17
+ server-side flag, `vc video set` announces a state — and every one of them
18
+ says `media: none`, because there is no audio behind it.
19
+
20
+ `vc download` is the one place where a headless CLI is strictly better than
21
+ the GUI: it cannot play a livestream, but it can record one.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import contextlib
28
+ import secrets
29
+ from datetime import datetime, timezone
30
+ from typing import Annotated, Any
31
+
32
+ from tlgr.core.errors import (
33
+ NotFoundError,
34
+ NotSupportedError,
35
+ PermissionError_,
36
+ UsageError,
37
+ )
38
+ from tlgr.core.pagination import PageKind, build_page, decode_cursor
39
+ from tlgr.core.timefmt import fmt_dt, parse_dt, to_unix
40
+ from tlgr.models.base import Request
41
+ from tlgr.models.call import (
42
+ MEDIA_NONE,
43
+ ActiveCall,
44
+ CallIdentity,
45
+ CallRef,
46
+ GroupCall,
47
+ GroupCallCreated,
48
+ GroupCallEnded,
49
+ GroupCallEvent,
50
+ GroupCallInvited,
51
+ GroupCallJoined,
52
+ GroupCallLeft,
53
+ GroupCallLink,
54
+ GroupCallParticipant,
55
+ GroupCallSettings,
56
+ GroupCallStarted,
57
+ InCallMessage,
58
+ InCallMessagesDeleted,
59
+ MuteState,
60
+ ParticipantRemoved,
61
+ RaisedHand,
62
+ RtmpInfo,
63
+ StreamChannel,
64
+ StreamDownload,
65
+ VideoState,
66
+ VolumeState,
67
+ )
68
+ from tlgr.models.page import Page
69
+ from tlgr.models.peer import Peer, PeerRef
70
+ from tlgr.ops import _calls, _send
71
+ from tlgr.ops._params import arg, choice, opt
72
+ from tlgr.ops._serialize import entity_to_peer
73
+ from tlgr.ops._spec import OpContext, OperationSpec
74
+
75
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
76
+
77
+ #: The default overlay lifetime and length cap, used when the app-config does
78
+ #: not carry them. Both are validated locally so a message that cannot land
79
+ #: fails here rather than at the server.
80
+ DEFAULT_MESSAGE_LENGTH = 128
81
+ DEFAULT_MESSAGE_TTL = 10
82
+
83
+ _EXAMPLE_REF: dict[str, Any] = {"id": 900100, "access_hash": 12345}
84
+ _EXAMPLE_CALL: dict[str, Any] = {
85
+ "call": _EXAMPLE_REF,
86
+ "kind": "video-chat",
87
+ "media": "none",
88
+ "title": "standup",
89
+ "participants_count": 4,
90
+ }
91
+
92
+
93
+ def _client(ctx: OpContext) -> Any:
94
+ client = getattr(ctx, "client", None)
95
+ if client is None: # pragma: no cover - the daemon always supplies one
96
+ raise UsageError("this operation needs a connected account")
97
+ return client
98
+
99
+
100
+ def _now() -> str:
101
+ return fmt_dt(datetime.now(timezone.utc)) or ""
102
+
103
+
104
+ def _entities(result: Any) -> dict[int, Any]:
105
+ """`{raw id: entity}` for everything a `phone.*` answer carried along."""
106
+ found: dict[int, Any] = {}
107
+ for entity in (getattr(result, "users", None) or []) + (getattr(result, "chats", None) or []):
108
+ found[int(getattr(entity, "id", 0) or 0)] = entity
109
+ return found
110
+
111
+
112
+ def _peer_model(peer: Any, entities: dict[int, Any]) -> Peer | None:
113
+ """A `Peer` constructor plus the entity table → the output shape."""
114
+ if peer is None:
115
+ return None
116
+ raw = int(
117
+ getattr(peer, "user_id", None)
118
+ or getattr(peer, "channel_id", None)
119
+ or getattr(peer, "chat_id", None)
120
+ or 0
121
+ )
122
+ entity = entities.get(raw)
123
+ if entity is not None:
124
+ return entity_to_peer(entity)
125
+ if getattr(peer, "channel_id", None):
126
+ return Peer(id=-1000000000000 - raw, raw_id=raw, kind="channel")
127
+ if getattr(peer, "chat_id", None):
128
+ return Peer(id=-raw, raw_id=raw, kind="group")
129
+ return Peer(id=raw, raw_id=raw, kind="user")
130
+
131
+
132
+ def _kind_of(call: Any, chat: Any = None) -> str:
133
+ """Which of the four products this call is."""
134
+ if getattr(call, "conference", False):
135
+ return "conference"
136
+ if getattr(call, "rtmp_stream", False):
137
+ return "rtmp"
138
+ if chat is not None and getattr(chat, "broadcast", False):
139
+ return "livestream"
140
+ return "video-chat"
141
+
142
+
143
+ def _group_model(
144
+ call: Any, *, chat: Any = None, entities: dict[int, Any] | None = None
145
+ ) -> GroupCall:
146
+ """A `groupCall` or `groupCallDiscarded` as the wire shape."""
147
+ if type(call).__name__ == "GroupCallDiscarded":
148
+ return GroupCall(
149
+ call=_calls.call_ref_of(call),
150
+ media=MEDIA_NONE,
151
+ discarded=True,
152
+ duration=int(getattr(call, "duration", 0) or 0),
153
+ )
154
+ link = getattr(call, "invite_link", None)
155
+ return GroupCall(
156
+ call=_calls.call_ref_of(call, slug=link.rsplit("/", 1)[-1] if link else None),
157
+ kind=_kind_of(call, chat),
158
+ media=MEDIA_NONE,
159
+ title=getattr(call, "title", None),
160
+ participants_count=int(getattr(call, "participants_count", 0) or 0),
161
+ join_muted=bool(getattr(call, "join_muted", False)),
162
+ can_change_join_muted=bool(getattr(call, "can_change_join_muted", False)),
163
+ messages_enabled=bool(getattr(call, "messages_enabled", False)),
164
+ can_change_messages_enabled=bool(getattr(call, "can_change_messages_enabled", False)),
165
+ record_start_date=fmt_dt(getattr(call, "record_start_date", None)),
166
+ record_video_active=bool(getattr(call, "record_video_active", False)),
167
+ rtmp_stream=bool(getattr(call, "rtmp_stream", False)),
168
+ listeners_hidden=bool(getattr(call, "listeners_hidden", False)),
169
+ conference=bool(getattr(call, "conference", False)),
170
+ creator=bool(getattr(call, "creator", False)),
171
+ schedule_date=fmt_dt(getattr(call, "schedule_date", None)),
172
+ schedule_start_subscribed=bool(getattr(call, "schedule_start_subscribed", False)),
173
+ stream_dc_id=getattr(call, "stream_dc_id", None),
174
+ invite_link=link,
175
+ send_paid_messages_stars=getattr(call, "send_paid_messages_stars", None),
176
+ default_send_as=_peer_model(getattr(call, "default_send_as", None), entities or {}),
177
+ unmuted_video_count=int(getattr(call, "unmuted_video_count", 0) or 0),
178
+ unmuted_video_limit=int(getattr(call, "unmuted_video_limit", 0) or 0),
179
+ version=int(getattr(call, "version", 0) or 0),
180
+ chat=entity_to_peer(chat) if chat is not None else None,
181
+ )
182
+
183
+
184
+ def _participant_model(raw: Any, entities: dict[int, Any]) -> GroupCallParticipant:
185
+ volume = getattr(raw, "volume", None)
186
+ return GroupCallParticipant(
187
+ peer=_peer_model(getattr(raw, "peer", None), entities),
188
+ source=int(getattr(raw, "source", 0) or 0),
189
+ muted=bool(getattr(raw, "muted", False)),
190
+ can_self_unmute=bool(getattr(raw, "can_self_unmute", False)),
191
+ muted_by_you=bool(getattr(raw, "muted_by_you", False)),
192
+ volume=int(volume) // 100 if volume else None,
193
+ volume_by_admin=bool(getattr(raw, "volume_by_admin", False)),
194
+ raise_hand=getattr(raw, "raise_hand_rating", None) is not None,
195
+ raise_hand_rating=getattr(raw, "raise_hand_rating", None),
196
+ video=getattr(raw, "video", None) is not None,
197
+ presentation=getattr(raw, "presentation", None) is not None,
198
+ video_joined=bool(getattr(raw, "video_joined", False)),
199
+ is_self=bool(getattr(raw, "is_self", False)),
200
+ left=bool(getattr(raw, "left", False)),
201
+ about=getattr(raw, "about", None),
202
+ joined_at=fmt_dt(getattr(raw, "date", None)),
203
+ last_active=fmt_dt(getattr(raw, "active_date", None)),
204
+ paid_stars_total=getattr(raw, "paid_stars_total", None),
205
+ )
206
+
207
+
208
+ async def _chat_peer(ctx: OpContext, ref: PeerRef | None) -> Any:
209
+ if ref is None:
210
+ raise UsageError("a chat is required", field="chat")
211
+ return await _send.resolve(ctx, ref)
212
+
213
+
214
+ def _call_from_updates(updates: Any) -> Any:
215
+ """The `groupCall` an `Updates` carries in its `updateGroupCall`."""
216
+ for update in getattr(updates, "updates", None) or []:
217
+ call = getattr(update, "call", None)
218
+ if call is not None:
219
+ return call
220
+ return None
221
+
222
+
223
+ async def _fetch_call(ctx: OpContext, handle: _calls.CallHandle) -> tuple[Any, dict[int, Any]]:
224
+ from telethon.tl.functions import phone as fn
225
+
226
+ result = await _client(ctx)(fn.GetGroupCallRequest(call=handle.input, limit=0))
227
+ call = getattr(result, "call", None)
228
+ if call is None:
229
+ raise NotFoundError("that call does not exist any more")
230
+ return call, _entities(result)
231
+
232
+
233
+ def _forbid_e2e(op: str) -> None:
234
+ raise UsageError(
235
+ f"{op} needs a signed e2e.chain block and an int256 public key. Conferences are "
236
+ "end-to-end encrypted and tlgr has no block builder, so pass --block and "
237
+ "--public-key from an external E2E implementation, or use a video chat instead",
238
+ field="block",
239
+ )
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # vc create
244
+ # ---------------------------------------------------------------------------
245
+
246
+
247
+ class CreateReq(Request):
248
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Where to start it.")]
249
+ title: Annotated[str | None, opt("--title", help="Call title; defaults to the chat name.")] = (
250
+ None
251
+ )
252
+ schedule: Annotated[
253
+ str | None,
254
+ opt("--schedule", metavar="WHEN", kind="datetime", help="Schedule it instead of starting."),
255
+ ] = None
256
+ rtmp: Annotated[
257
+ bool, opt("--rtmp", help="RTMP mode: one external encoder publishes all media.")
258
+ ] = False
259
+
260
+
261
+ async def create(ctx: OpContext, req: CreateReq) -> GroupCallCreated:
262
+ """Start or schedule a video chat, livestream or RTMP stream.
263
+
264
+ One RPC covers all three products and the name follows the peer. A chat
265
+ holds one call at a time, so creating a new one *terminates* the old one —
266
+ which is why this is a confirmed operation rather than a convenience.
267
+ """
268
+ from telethon.tl.functions import phone as fn
269
+
270
+ peer = await _chat_peer(ctx, req.chat)
271
+ result = await _client(ctx)(
272
+ fn.CreateGroupCallRequest(
273
+ peer=peer,
274
+ random_id=secrets.randbits(31),
275
+ title=req.title,
276
+ schedule_date=parse_dt(req.schedule) if req.schedule else None,
277
+ rtmp_stream=req.rtmp or None,
278
+ )
279
+ )
280
+ call = _call_from_updates(result)
281
+ if call is None:
282
+ raise NotSupportedError(
283
+ "the server created the call without telling us which one; nothing to address"
284
+ )
285
+ model = _group_model(call)
286
+ ctx.emit("vc_created", {"chat_id": _send.peer_id_of(peer), "call_id": model.call.id})
287
+ return GroupCallCreated(
288
+ call=model.call,
289
+ chat_id=_send.peer_id_of(peer),
290
+ kind="rtmp" if req.rtmp else model.kind,
291
+ title=model.title or req.title,
292
+ schedule_date=model.schedule_date,
293
+ rtmp_stream=req.rtmp,
294
+ )
295
+
296
+
297
+ SPEC_CREATE = OperationSpec(
298
+ id="vc.create",
299
+ request=CreateReq,
300
+ response=GroupCallCreated,
301
+ impl=create,
302
+ summary="Start or schedule a video chat, livestream or RTMP stream in a chat",
303
+ description=(
304
+ "Needs `manage_call`. A chat has one call at a time: creating a new "
305
+ "one ends the old one, which is why it asks."
306
+ ),
307
+ mutating=True,
308
+ destructive=True,
309
+ rate_class="send",
310
+ columns=("call.id", "chat_id", "title", "kind"),
311
+ example={"call": _EXAMPLE_REF, "chat_id": -1000000005150, "kind": "video-chat"},
312
+ example_args="vc create @newsroom",
313
+ covers=(
314
+ "groupcall.create-livestream",
315
+ "groupcall.create-rtmp-call",
316
+ "groupcall.create-video-chat",
317
+ "groupcall.schedule",
318
+ "groupcall.set-title-on-create",
319
+ ),
320
+ tags=frozenset({"visible-to-others"}),
321
+ )
322
+
323
+
324
+ # ---------------------------------------------------------------------------
325
+ # vc start
326
+ # ---------------------------------------------------------------------------
327
+
328
+
329
+ class StartReq(Request):
330
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat with the call.")]
331
+
332
+
333
+ async def start(ctx: OpContext, req: StartReq) -> GroupCallStarted:
334
+ """Start a scheduled video chat now; subscribers with a reminder are told."""
335
+ from telethon.tl.functions import phone as fn
336
+
337
+ handle = await _calls.resolve_call(ctx, req.chat.raw)
338
+ handle = await _calls.concrete_call(ctx, handle)
339
+ await _client(ctx)(fn.StartScheduledGroupCallRequest(call=handle.input))
340
+ chat_id = _send.peer_id_of(handle.chat) if handle.chat is not None else 0
341
+ ctx.emit("vc_started", {"call_id": handle.ref.id, "chat_id": chat_id})
342
+ return GroupCallStarted(call=handle.ref, chat_id=chat_id, started=True)
343
+
344
+
345
+ SPEC_START = OperationSpec(
346
+ id="vc.start",
347
+ request=StartReq,
348
+ response=GroupCallStarted,
349
+ impl=start,
350
+ summary="Start a scheduled video chat now",
351
+ mutating=True,
352
+ rate_class="send",
353
+ columns=("call.id", "chat_id", "started"),
354
+ example={"call": _EXAMPLE_REF, "chat_id": -1000000005150, "started": True},
355
+ example_args="vc start @newsroom",
356
+ covers=("groupcall.start-scheduled",),
357
+ tags=frozenset({"visible-to-others"}),
358
+ )
359
+
360
+
361
+ # ---------------------------------------------------------------------------
362
+ # vc get
363
+ # ---------------------------------------------------------------------------
364
+
365
+
366
+ class GetReq(Request):
367
+ call: Annotated[
368
+ str,
369
+ arg(0, metavar="CALL", help="A chat, id:access_hash, a call link, or msg:<id>."),
370
+ ]
371
+ limits: Annotated[bool, opt("--limits", help="Add the app-config caps.")] = False
372
+ stream_channels: Annotated[
373
+ bool, opt("--stream-channels", help="Add the live stream channels and the live edge.")
374
+ ] = False
375
+ check_sources: Annotated[
376
+ list[int],
377
+ opt("--check-sources", metavar="SSRC", help="Ask which of these sources are still joined."),
378
+ ] = []
379
+ donors: Annotated[bool, opt("--donors", help="Add Stars donated and top donors.")] = False
380
+
381
+
382
+ async def get(ctx: OpContext, req: GetReq) -> GroupCall:
383
+ """Everything about one group call.
384
+
385
+ `limit` on `getGroupCall` is unusual — at least three participants always
386
+ come back — so tlgr asks for 0 and leaves paging to `vc participant list`.
387
+ When `listeners_hidden` is set the participant list carries publishers
388
+ only: `participants_count` is the audience, and reporting the short list
389
+ as "the call" would be a lie about an empty room.
390
+ """
391
+ from telethon.tl.functions import phone as fn
392
+
393
+ handle = await _calls.resolve_call(ctx, req.call)
394
+ call, entities = await _fetch_call(ctx, handle)
395
+ chat = entities.get(int(getattr(getattr(handle, "chat", None), "channel_id", 0) or 0))
396
+ model = _group_model(call, chat=chat, entities=entities)
397
+ if handle.ref.slug:
398
+ model.call.slug = handle.ref.slug
399
+
400
+ if req.limits:
401
+ config = await _calls.app_config(ctx)
402
+ model.limits = {k: int(v) for k, v in config.items() if isinstance(v, (int, float))}
403
+
404
+ if req.stream_channels:
405
+ channels = await _client(ctx)(fn.GetGroupCallStreamChannelsRequest(call=handle.input))
406
+ rows = [
407
+ StreamChannel(
408
+ channel=int(getattr(item, "channel", 0) or 0),
409
+ scale=int(getattr(item, "scale", 0) or 0),
410
+ last_timestamp_ms=int(getattr(item, "last_timestamp_ms", 0) or 0),
411
+ )
412
+ for item in (getattr(channels, "channels", None) or [])
413
+ ]
414
+ model.stream_channels = rows
415
+ model.live_edge_ms = max((row.last_timestamp_ms for row in rows), default=0)
416
+ if not rows:
417
+ ctx.warn("no stream channels yet: the publisher is idle, try again in a second")
418
+
419
+ if req.check_sources:
420
+ checked = await _client(ctx)(
421
+ fn.CheckGroupCallRequest(call=handle.input, sources=list(req.check_sources))
422
+ )
423
+ joined = [int(s) for s in (checked or [])]
424
+ model.sources_joined = joined
425
+ model.sources_missing = [s for s in req.check_sources if s not in joined]
426
+
427
+ if req.donors:
428
+ stars = await _client(ctx)(fn.GetGroupCallStarsRequest(call=handle.input))
429
+ donors = _entities(stars)
430
+ model.donors = {
431
+ "total": int(getattr(stars, "total_stars", 0) or 0),
432
+ "top": [
433
+ {
434
+ "peer": getattr(peer, "id", None),
435
+ "stars": int(getattr(donor, "stars", 0) or 0),
436
+ }
437
+ for donor, peer in (
438
+ (item, _peer_model(getattr(item, "peer", None), donors))
439
+ for item in (getattr(stars, "top_donors", None) or [])
440
+ )
441
+ if peer is not None
442
+ ],
443
+ }
444
+ return model
445
+
446
+
447
+ SPEC_GET = OperationSpec(
448
+ id="vc.get",
449
+ request=GetReq,
450
+ response=GroupCall,
451
+ impl=get,
452
+ summary="Everything about a group call: state, recording, limits, stream channels, donors",
453
+ description=(
454
+ "`CALL` accepts a chat, `id:access_hash`, a t.me video-chat or "
455
+ "`t.me/call/<slug>` link, or `msg:<id>` for an invitation."
456
+ ),
457
+ columns=("call.id", "kind", "title", "participants_count", "rtmp_stream"),
458
+ example=_EXAMPLE_CALL,
459
+ example_args="vc get @newsroom",
460
+ covers=(
461
+ "groupcall.check-connection",
462
+ "groupcall.get",
463
+ "groupcall.limits-config",
464
+ "groupcall.listeners-hidden",
465
+ "groupcall.paid-comment-tiers",
466
+ "groupcall.participant-limits",
467
+ "groupcall.recording-status",
468
+ "groupcall.resolve-call-link",
469
+ "groupcall.stars-top-donors",
470
+ "groupcall.stream-channels",
471
+ ),
472
+ )
473
+
474
+
475
+ # ---------------------------------------------------------------------------
476
+ # vc list
477
+ # ---------------------------------------------------------------------------
478
+
479
+
480
+ class ListReq(Request):
481
+ scheduled: Annotated[
482
+ bool, opt("--scheduled", help="Include chats whose call is only scheduled.")
483
+ ] = True
484
+ empty: Annotated[bool, opt("--empty", help="Include active calls nobody is in.")] = True
485
+
486
+
487
+ async def list_calls(ctx: OpContext, req: ListReq) -> Page[ActiveCall]:
488
+ """Chats with a call running right now.
489
+
490
+ Not an RPC of its own: `call_active` and `call_not_empty` ride on the
491
+ `Chat`/`Channel` constructors the dialog list already returns, so this is
492
+ one pass over the cached dialogs plus one `getGroupCall` per hit — and the
493
+ per-hit call is what makes the page size worth respecting.
494
+ """
495
+ limit = min(int(getattr(ctx, "limit", None) or 30), 200)
496
+ token = getattr(ctx, "cursor", None)
497
+ state = (
498
+ decode_cursor(token, op="vc.list", kind=PageKind.LOCAL, account=ctx.account)
499
+ if token
500
+ else {}
501
+ )
502
+ offset = int(state.get("offset", 0) or 0)
503
+
504
+ hits: list[Any] = []
505
+ async for dialog in _client(ctx).iter_dialogs():
506
+ entity = getattr(dialog, "entity", None)
507
+ if entity is None:
508
+ continue
509
+ active = bool(getattr(entity, "call_active", False))
510
+ if not active:
511
+ continue
512
+ if not req.empty and not getattr(entity, "call_not_empty", False):
513
+ continue
514
+ hits.append(entity)
515
+
516
+ window = hits[offset : offset + limit]
517
+ items: list[ActiveCall] = []
518
+ for entity in window:
519
+ row = ActiveCall(
520
+ chat=entity_to_peer(entity),
521
+ chat_id=_send.peer_id_of(entity),
522
+ active=True,
523
+ not_empty=bool(getattr(entity, "call_not_empty", False)),
524
+ )
525
+ with contextlib.suppress(Exception):
526
+ handle = await _calls.resolve_call(ctx, str(row.chat_id))
527
+ call, entities = await _fetch_call(ctx, handle)
528
+ model = _group_model(call, chat=entity, entities=entities)
529
+ row.call = model.call
530
+ row.title = model.title
531
+ row.participants_count = model.participants_count
532
+ row.schedule_date = model.schedule_date
533
+ row.rtmp_stream = model.rtmp_stream
534
+ if row.schedule_date and not req.scheduled:
535
+ continue
536
+ items.append(row)
537
+
538
+ return build_page(
539
+ items,
540
+ op="vc.list",
541
+ kind=PageKind.LOCAL,
542
+ state={"offset": offset + len(window)},
543
+ account=ctx.account,
544
+ has_more=offset + len(window) < len(hits),
545
+ total=len(hits),
546
+ )
547
+
548
+
549
+ SPEC_LIST = OperationSpec(
550
+ id="vc.list",
551
+ request=ListReq,
552
+ response=Page[ActiveCall],
553
+ impl=list_calls,
554
+ summary="Chats with a video chat, livestream or scheduled call running right now",
555
+ paginated=PageKind.LOCAL,
556
+ columns=("chat_id", "title", "participants_count", "not_empty"),
557
+ headers=("Chat", "Title", "In call", "Live"),
558
+ example={"items": [{"chat_id": -1000000005150, "title": "standup"}], "has_more": False},
559
+ example_args="vc list",
560
+ covers=("groupcall.active-calls-list", "groupcall.detect-active-call"),
561
+ )
562
+
563
+
564
+ # ---------------------------------------------------------------------------
565
+ # vc set
566
+ # ---------------------------------------------------------------------------
567
+
568
+
569
+ class SetReq(Request):
570
+ call: Annotated[str, arg(0, metavar="CALL", help="A chat, id:access_hash or call link.")]
571
+ title: Annotated[
572
+ str | None, opt("--title", help="Rename the call; empty resets it to the chat name.")
573
+ ] = None
574
+ join_muted: Annotated[
575
+ str | None, choice("on", "off", help="New participants arrive muted.")
576
+ ] = None
577
+ messages: Annotated[
578
+ str | None, choice("on", "off", help="In-call message/comment overlay.")
579
+ ] = None
580
+ comment_price: Annotated[
581
+ int | None,
582
+ opt("--comment-price", metavar="STARS", help="Minimum Stars to comment; 0 is free."),
583
+ ] = None
584
+ reminder: Annotated[
585
+ str | None, choice("on", "off", help="Be notified when a scheduled call starts.")
586
+ ] = None
587
+ record: Annotated[str | None, choice("start", "stop", help="Server-side recording.")] = None
588
+ record_title: Annotated[str | None, opt("--record-title", help="Name for the recording.")] = (
589
+ None
590
+ )
591
+ record_video: Annotated[bool, opt("--record-video", help="Also record video.")] = False
592
+ record_portrait: Annotated[bool, opt("--record-portrait", help="Portrait video recording.")] = (
593
+ False
594
+ )
595
+
596
+
597
+ async def set_call(ctx: OpContext, req: SetReq) -> GroupCallSettings:
598
+ """The in-call settings sheet as one command.
599
+
600
+ Four RPCs sit behind it and each is sent only for the flags you passed,
601
+ so `vc set --title x` does not silently re-assert the recording state.
602
+ The result is read back from the server rather than echoed, because
603
+ `--join-muted` on an RTMP call is refused server-side and reporting what
604
+ we asked for would hide that.
605
+ """
606
+ from telethon.tl.functions import phone as fn
607
+
608
+ handle = await _calls.concrete_call(ctx, await _calls.resolve_call(ctx, req.call))
609
+ client = _client(ctx)
610
+ changed: list[str] = []
611
+
612
+ if req.title is not None:
613
+ await client(fn.EditGroupCallTitleRequest(call=handle.input, title=req.title))
614
+ changed.append("title")
615
+
616
+ settings: dict[str, Any] = {}
617
+ if req.join_muted is not None:
618
+ settings["join_muted"] = req.join_muted == "on"
619
+ if req.messages is not None:
620
+ settings["messages_enabled"] = req.messages == "on"
621
+ if req.comment_price is not None:
622
+ settings["send_paid_messages_stars"] = req.comment_price
623
+ if settings:
624
+ await client(fn.ToggleGroupCallSettingsRequest(call=handle.input, **settings))
625
+ changed.extend(sorted(settings))
626
+
627
+ if req.reminder is not None:
628
+ await client(
629
+ fn.ToggleGroupCallStartSubscriptionRequest(
630
+ call=handle.input, subscribed=req.reminder == "on"
631
+ )
632
+ )
633
+ changed.append("reminder")
634
+
635
+ if req.record is not None:
636
+ await client(
637
+ fn.ToggleGroupCallRecordRequest(
638
+ call=handle.input,
639
+ start=req.record == "start" or None,
640
+ video=req.record_video or None,
641
+ title=req.record_title,
642
+ video_portrait=req.record_portrait or None,
643
+ )
644
+ )
645
+ changed.append("record")
646
+ ctx.warn("the recording is delivered to the starting admin's Saved Messages when it stops")
647
+
648
+ if not changed:
649
+ raise UsageError("nothing to set; pass at least one flag", field="title")
650
+
651
+ call, entities = await _fetch_call(ctx, handle)
652
+ model = _group_model(call, entities=entities)
653
+ ctx.emit("vc_settings", {"call_id": model.call.id, "changed": changed})
654
+ return GroupCallSettings(
655
+ call=model.call,
656
+ title=model.title,
657
+ join_muted=model.join_muted,
658
+ messages_enabled=model.messages_enabled,
659
+ send_paid_messages_stars=model.send_paid_messages_stars,
660
+ schedule_start_subscribed=model.schedule_start_subscribed,
661
+ record_start_date=model.record_start_date,
662
+ record_video_active=model.record_video_active,
663
+ changed=changed,
664
+ )
665
+
666
+
667
+ SPEC_SET = OperationSpec(
668
+ id="vc.set",
669
+ request=SetReq,
670
+ response=GroupCallSettings,
671
+ impl=set_call,
672
+ aliases=("story.live.settings", "vc.record"),
673
+ summary="Call settings: title, mute-on-join, messages, comment price, reminder, recording",
674
+ description=(
675
+ "Everything except `--reminder` needs `manage_call`. Recording is "
676
+ "server-side and every participant sees the badge."
677
+ ),
678
+ mutating=True,
679
+ rate_class="send",
680
+ columns=("call.id", "title", "join_muted", "messages_enabled", "changed"),
681
+ example={"call": _EXAMPLE_REF, "title": "standup", "changed": ["title"]},
682
+ example_args="vc set @newsroom --title standup",
683
+ covers=(
684
+ "groupcall.edit-title",
685
+ "groupcall.record-start-audio",
686
+ "groupcall.record-start-video",
687
+ "groupcall.record-stop",
688
+ "groupcall.schedule-reminder",
689
+ "groupcall.set-comment-price",
690
+ "groupcall.toggle-join-muted",
691
+ "groupcall.toggle-messages-enabled",
692
+ "stories.live-settings",
693
+ ),
694
+ tags=frozenset({"visible-to-others"}),
695
+ )
696
+
697
+
698
+ # ---------------------------------------------------------------------------
699
+ # vc end
700
+ # ---------------------------------------------------------------------------
701
+
702
+
703
+ class EndReq(Request):
704
+ call: Annotated[str, arg(0, metavar="CALL", help="A chat, id:access_hash or call link.")]
705
+
706
+
707
+ async def end(ctx: OpContext, req: EndReq) -> GroupCallEnded:
708
+ """End a video chat, livestream, live story or conference for everyone."""
709
+ from telethon.tl.functions import phone as fn
710
+
711
+ handle = await _calls.concrete_call(ctx, await _calls.resolve_call(ctx, req.call))
712
+ result = await _client(ctx)(fn.DiscardGroupCallRequest(call=handle.input))
713
+ call = _call_from_updates(result)
714
+ duration = int(getattr(call, "duration", 0) or 0) if call is not None else None
715
+ ctx.emit("vc_ended", {"call_id": handle.ref.id})
716
+ return GroupCallEnded(call=handle.ref, ended=True, duration=duration)
717
+
718
+
719
+ SPEC_END = OperationSpec(
720
+ id="vc.end",
721
+ request=EndReq,
722
+ response=GroupCallEnded,
723
+ impl=end,
724
+ aliases=("story.live.end", "conference.end"),
725
+ summary="End a video chat, livestream, live story or conference for everyone",
726
+ description="Irreversible: the call becomes `groupCallDiscarded`.",
727
+ mutating=True,
728
+ destructive=True,
729
+ rate_class="send",
730
+ columns=("call.id", "ended", "duration"),
731
+ example={"call": _EXAMPLE_REF, "ended": True, "duration": 900},
732
+ example_args="vc end @newsroom",
733
+ covers=("conference.end", "groupcall.discard", "livestory.end", "stories.live-end"),
734
+ tags=frozenset({"visible-to-others"}),
735
+ )
736
+
737
+
738
+ # ---------------------------------------------------------------------------
739
+ # vc join / leave
740
+ # ---------------------------------------------------------------------------
741
+
742
+
743
+ class JoinReq(Request):
744
+ call: Annotated[str, arg(0, metavar="CALL", help="A chat, id:access_hash or call link.")]
745
+ send_as: Annotated[
746
+ PeerRef | None,
747
+ opt("--send-as", metavar="PEER", kind="peer", help="Appear as yourself or a channel."),
748
+ ] = None
749
+ remember: Annotated[bool, opt("--remember", help="Store --send-as as the chat's default.")] = (
750
+ False
751
+ )
752
+ muted: Annotated[bool, opt("--muted/--unmuted", help="Join muted.")] = True
753
+ video_stopped: Annotated[bool, opt("--video-stopped", help="Join with video off.")] = True
754
+ invite_hash: Annotated[
755
+ str | None, opt("--invite-hash", help="Speaker link hash: grants can_self_unmute.")
756
+ ] = None
757
+ params_json: Annotated[
758
+ str | None,
759
+ opt("--params-json", metavar="PATH", kind="path", help="tgcalls join payload."),
760
+ ] = None
761
+ listen_only: Annotated[
762
+ bool,
763
+ opt("--listen-only", help="Synthesize a listener payload. Experimental — see the docs."),
764
+ ] = False
765
+ public_key: Annotated[
766
+ str | None, opt("--public-key", metavar="HEX", help="int256 E2E key (conferences).")
767
+ ] = None
768
+ block: Annotated[
769
+ str | None, opt("--block", metavar="PATH", kind="path", help="E2E join block.")
770
+ ] = None
771
+
772
+
773
+ def _listener_params() -> str:
774
+ """A syntactically valid listener payload, with no media behind it.
775
+
776
+ An empty or `{}` `params` is not sanctioned anywhere in the documentation
777
+ and must be expected to fail, so `--listen-only` produces the shape a real
778
+ engine produces — a fresh SSRC, ICE credentials, a DTLS fingerprint —
779
+ purely to obtain server-side presence, which is a hard prerequisite for
780
+ `vc download`. It carries no audio and never will.
781
+ """
782
+ import json
783
+
784
+ fingerprint = ":".join(f"{byte:02X}" for byte in secrets.token_bytes(32))
785
+ return json.dumps(
786
+ {
787
+ "ufrag": secrets.token_hex(4),
788
+ "pwd": secrets.token_hex(12),
789
+ "fingerprints": [{"hash": "sha-256", "setup": "active", "fingerprint": fingerprint}],
790
+ "ssrc": secrets.randbits(31),
791
+ "ssrc-groups": [],
792
+ }
793
+ )
794
+
795
+
796
+ async def join(ctx: OpContext, req: JoinReq) -> GroupCallJoined:
797
+ """Join a group call as a participant — control plane only.
798
+
799
+ `params` is documented as a payload the local tgcalls engine produces.
800
+ tlgr has no engine, so there are exactly two honest options and both are
801
+ offered: bring your own payload with `--params-json`, or take
802
+ `--listen-only`, which synthesizes a valid-looking one to obtain presence
803
+ and says so in the answer. Neither carries audio. Remember `source`:
804
+ `vc leave` needs it.
805
+ """
806
+ import json
807
+
808
+ from telethon.tl import types
809
+ from telethon.tl.functions import phone as fn
810
+
811
+ handle = await _calls.concrete_call(ctx, await _calls.resolve_call(ctx, req.call))
812
+ call, entities = await _fetch_call(ctx, handle)
813
+ conference = bool(getattr(call, "conference", False))
814
+
815
+ if conference and (not req.block or not req.public_key):
816
+ _forbid_e2e("joining a conference")
817
+
818
+ if not req.params_json and not req.listen_only:
819
+ raise UsageError(
820
+ "joining needs a tgcalls join payload: pass --params-json from a real media "
821
+ "engine, or --listen-only to synthesize a listener payload for presence only",
822
+ field="params_json",
823
+ )
824
+ payload = (
825
+ _read_text(req.params_json, field="params-json") if req.params_json else _listener_params()
826
+ )
827
+
828
+ join_as: Any = types.InputPeerSelf()
829
+ if req.send_as is not None:
830
+ if conference:
831
+ raise UsageError(
832
+ "a conference is always joined as yourself; --send-as is for video chats",
833
+ field="send_as",
834
+ )
835
+ join_as = await _send.resolve(ctx, req.send_as)
836
+
837
+ result = await _client(ctx)(
838
+ fn.JoinGroupCallRequest(
839
+ call=handle.input,
840
+ join_as=join_as,
841
+ params=types.DataJSON(data=payload),
842
+ muted=req.muted or None,
843
+ video_stopped=req.video_stopped or None,
844
+ invite_hash=req.invite_hash,
845
+ public_key=int(req.public_key, 16) if req.public_key else None,
846
+ block=_read_bytes(req.block, field="block") if req.block else None,
847
+ )
848
+ )
849
+
850
+ if req.remember and req.send_as is not None and handle.chat is not None:
851
+ await _client(ctx)(fn.SaveDefaultGroupCallJoinAsRequest(peer=handle.chat, join_as=join_as))
852
+
853
+ mode = "webrtc"
854
+ connection: dict[str, Any] = {}
855
+ for update in getattr(result, "updates", None) or []:
856
+ params = getattr(update, "params", None)
857
+ if params is None:
858
+ continue
859
+ with contextlib.suppress(ValueError):
860
+ connection = json.loads(getattr(params, "data", "") or "{}")
861
+ if connection.get("rtmp"):
862
+ mode = "rtmp"
863
+ elif connection.get("stream"):
864
+ mode = "stream"
865
+
866
+ try:
867
+ source = int(json.loads(payload).get("ssrc", 0) or 0)
868
+ except ValueError: # pragma: no cover - a bring-your-own payload may differ
869
+ source = 0
870
+
871
+ ctx.emit("vc_joined", {"call_id": handle.ref.id, "source": source})
872
+ ctx.warn("tlgr has no media engine: this is server-side presence, not audio")
873
+ return GroupCallJoined(
874
+ call=handle.ref,
875
+ media=MEDIA_NONE,
876
+ joined=True,
877
+ source=source,
878
+ mode=mode,
879
+ join_as=_peer_model(getattr(call, "default_send_as", None), entities)
880
+ if req.send_as is None
881
+ else None,
882
+ can_self_unmute=bool(req.invite_hash),
883
+ params=connection or None,
884
+ experimental=req.listen_only,
885
+ )
886
+
887
+
888
+ SPEC_JOIN = OperationSpec(
889
+ id="vc.join",
890
+ request=JoinReq,
891
+ response=GroupCallJoined,
892
+ impl=join,
893
+ summary="Join a group call — control plane only, no audio is sent or received",
894
+ description=(
895
+ "`--listen-only` is experimental: the server's acceptance of a "
896
+ "synthesized payload is not documented. It exists because joining is "
897
+ "a hard prerequisite for `vc download`."
898
+ ),
899
+ mutating=True,
900
+ rate_class="send",
901
+ columns=("call.id", "source", "mode", "media"),
902
+ example={
903
+ "call": _EXAMPLE_REF,
904
+ "media": "none",
905
+ "joined": True,
906
+ "source": 1234567,
907
+ "mode": "stream",
908
+ },
909
+ example_args="vc join @newsroom --listen-only",
910
+ covers=(
911
+ "groupcall.detect-stream-mode",
912
+ "groupcall.join",
913
+ "groupcall.join-via-invite-hash",
914
+ "groupcall.save-default-join-as",
915
+ ),
916
+ tags=frozenset({"visible-to-others"}),
917
+ )
918
+
919
+
920
+ class LeaveReq(Request):
921
+ call: Annotated[str, arg(0, metavar="CALL", help="A chat, id:access_hash or call link.")]
922
+ source: Annotated[
923
+ int, opt("--source", metavar="SSRC", help="SSRC you joined with; 0 for a listener.")
924
+ ] = 0
925
+
926
+
927
+ async def leave(ctx: OpContext, req: LeaveReq) -> GroupCallLeft:
928
+ """Leave a call. It keeps running for everyone else."""
929
+ from telethon.tl.functions import phone as fn
930
+
931
+ handle = await _calls.concrete_call(ctx, await _calls.resolve_call(ctx, req.call))
932
+ await _client(ctx)(fn.LeaveGroupCallRequest(call=handle.input, source=req.source))
933
+ ctx.emit("vc_left", {"call_id": handle.ref.id, "source": req.source})
934
+ return GroupCallLeft(call=handle.ref, source=req.source, left=True)
935
+
936
+
937
+ SPEC_LEAVE = OperationSpec(
938
+ id="vc.leave",
939
+ request=LeaveReq,
940
+ response=GroupCallLeft,
941
+ impl=leave,
942
+ aliases=("conference.leave",),
943
+ summary="Leave a group call (it keeps running for everyone else)",
944
+ description=(
945
+ "After leaving a conference the remaining participants still have to "
946
+ "prune you from the E2E chain — `conference remove --left-only`."
947
+ ),
948
+ mutating=True,
949
+ idempotent=True,
950
+ rate_class="send",
951
+ columns=("call.id", "source", "left"),
952
+ example={"call": _EXAMPLE_REF, "source": 1234567, "left": True},
953
+ example_args="vc leave @newsroom",
954
+ covers=("conference.leave", "groupcall.leave"),
955
+ )
956
+
957
+
958
+ # ---------------------------------------------------------------------------
959
+ # vc invite / link / remove
960
+ # ---------------------------------------------------------------------------
961
+
962
+
963
+ class InviteReq(Request):
964
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat with the call.")]
965
+ user: Annotated[
966
+ list[PeerRef],
967
+ arg(1, metavar="USER", variadic=True, kind="user", help="Who to invite."),
968
+ ] = []
969
+ add_to_chat: Annotated[
970
+ bool, opt("--add-to-chat", help="Add non-members to the chat first.")
971
+ ] = False
972
+ link_fallback: Annotated[
973
+ bool, opt("--link-fallback", help="Report the link for users who cannot be invited.")
974
+ ] = True
975
+
976
+
977
+ async def invite(ctx: OpContext, req: InviteReq) -> GroupCallInvited:
978
+ """Invite people into a video chat, adding them to the chat if asked.
979
+
980
+ `phone.inviteToGroupCall` only works for chat members, so the per-user
981
+ outcome is classified the way the GUI toasts it rather than collapsed into
982
+ one error: invited, added and invited, already in the call, or refused by
983
+ the user's privacy settings.
984
+ """
985
+ from telethon import utils
986
+ from telethon.tl.functions import channels as channels_fn
987
+ from telethon.tl.functions import messages as messages_fn
988
+ from telethon.tl.functions import phone as fn
989
+
990
+ if not req.user:
991
+ raise UsageError("give at least one user to invite", field="user")
992
+ peer = await _chat_peer(ctx, req.chat)
993
+ handle = await _calls.concrete_call(ctx, await _calls.resolve_call(ctx, req.chat.raw))
994
+ client = _client(ctx)
995
+
996
+ invited: list[Peer] = []
997
+ added: list[Peer] = []
998
+ failed: list[dict[str, Any]] = []
999
+ for reference in req.user:
1000
+ target = await _send.resolve(ctx, reference)
1001
+ user = utils.get_input_user(target)
1002
+ model = Peer(id=_send.peer_id_of(target), raw_id=_send.peer_id_of(target), kind="user")
1003
+ try:
1004
+ await client(fn.InviteToGroupCallRequest(call=handle.input, users=[user]))
1005
+ invited.append(model)
1006
+ continue
1007
+ except Exception as exc:
1008
+ text = f"{type(exc).__name__} {exc}".upper().replace("_", "")
1009
+ if "ALREADYPARTICIPANT" in text:
1010
+ failed.append({"peer": model.id, "reason": "already-in-call"})
1011
+ continue
1012
+ if not req.add_to_chat or "NOTPARTICIPANT" not in text:
1013
+ failed.append({"peer": model.id, "reason": "privacy-restricted"})
1014
+ continue
1015
+ try:
1016
+ if type(peer).__name__ == "InputPeerChannel":
1017
+ await client(
1018
+ channels_fn.InviteToChannelRequest(
1019
+ channel=utils.get_input_channel(peer), users=[user]
1020
+ )
1021
+ )
1022
+ else:
1023
+ await client(
1024
+ messages_fn.AddChatUserRequest(chat_id=peer.chat_id, user_id=user, fwd_limit=0)
1025
+ )
1026
+ await client(fn.InviteToGroupCallRequest(call=handle.input, users=[user]))
1027
+ added.append(model)
1028
+ invited.append(model)
1029
+ except Exception:
1030
+ failed.append({"peer": model.id, "reason": "cannot-add"})
1031
+
1032
+ result = GroupCallInvited(invited=invited, added=added, failed=failed)
1033
+ if failed and req.link_fallback:
1034
+ with contextlib.suppress(Exception):
1035
+ exported = await client(fn.ExportGroupCallInviteRequest(call=handle.input))
1036
+ result.link = getattr(exported, "link", None)
1037
+ ctx.emit("vc_invited", {"call_id": handle.ref.id, "count": len(invited)})
1038
+ return result
1039
+
1040
+
1041
+ SPEC_INVITE = OperationSpec(
1042
+ id="vc.invite",
1043
+ request=InviteReq,
1044
+ response=GroupCallInvited,
1045
+ impl=invite,
1046
+ summary="Invite people into a video chat, adding them to the chat first if needed",
1047
+ mutating=True,
1048
+ rate_class="send",
1049
+ columns=("invited", "added", "failed"),
1050
+ example={"invited": [{"id": 4242, "raw_id": 4242, "kind": "user"}], "added": []},
1051
+ example_args="vc invite @newsroom @alice",
1052
+ covers=("groupcall.invite-members", "groupcall.invite-nonmember"),
1053
+ tags=frozenset({"visible-to-others"}),
1054
+ )
1055
+
1056
+
1057
+ class LinkReq(Request):
1058
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat with the call.")]
1059
+ speaker: Annotated[
1060
+ bool, opt("--speaker", help="Link that grants can_self_unmute at join time.")
1061
+ ] = False
1062
+ revoke: Annotated[
1063
+ bool, opt("--revoke", help="Invalidate every existing speaker and listener link.")
1064
+ ] = False
1065
+
1066
+
1067
+ async def link(ctx: OpContext, req: LinkReq) -> GroupCallLink:
1068
+ """Export or revoke the video chat's listener and speaker links.
1069
+
1070
+ `phone.exportGroupCallInvite` does not work for a call in a private group;
1071
+ the documented fallback is the chat's own invite link, which is fetched
1072
+ automatically and marked `fallback: true` so nobody mistakes one for the
1073
+ other.
1074
+ """
1075
+ from telethon.tl.functions import messages as messages_fn
1076
+ from telethon.tl.functions import phone as fn
1077
+
1078
+ peer = await _chat_peer(ctx, req.chat)
1079
+ handle = await _calls.concrete_call(ctx, await _calls.resolve_call(ctx, req.chat.raw))
1080
+ client = _client(ctx)
1081
+
1082
+ if req.revoke:
1083
+ await client(fn.ToggleGroupCallSettingsRequest(call=handle.input, reset_invite_hash=True))
1084
+ ctx.emit("vc_link_revoked", {"call_id": handle.ref.id})
1085
+ return GroupCallLink(kind="speaker" if req.speaker else "listener", revoked=True)
1086
+
1087
+ try:
1088
+ exported = await client(
1089
+ fn.ExportGroupCallInviteRequest(call=handle.input, can_self_unmute=req.speaker or None)
1090
+ )
1091
+ url = getattr(exported, "link", None)
1092
+ invite_hash = url.rsplit("=", 1)[-1] if url and "=" in url else None
1093
+ return GroupCallLink(
1094
+ kind="speaker" if req.speaker else "listener", link=url, invite_hash=invite_hash
1095
+ )
1096
+ except Exception as exc:
1097
+ text = f"{type(exc).__name__} {exc}".upper()
1098
+ if "FORBIDDEN" not in text and "PRIVATE" not in text and "INVALID" not in text:
1099
+ raise
1100
+ fallback = await client(messages_fn.ExportChatInviteRequest(peer=peer))
1101
+ ctx.warn("this call is in a private chat; falling back to the chat's own invite link")
1102
+ return GroupCallLink(kind="chat", link=getattr(fallback, "link", None), fallback=True)
1103
+
1104
+
1105
+ SPEC_LINK = OperationSpec(
1106
+ id="vc.link",
1107
+ request=LinkReq,
1108
+ response=GroupCallLink,
1109
+ impl=link,
1110
+ summary="Export or revoke the video chat's listener and speaker links",
1111
+ description="`--speaker` and `--revoke` need `manage_call`.",
1112
+ mutating=True,
1113
+ rate_class="send",
1114
+ columns=("kind", "link", "fallback"),
1115
+ example={"kind": "listener", "link": "https://t.me/c/5150?voicechat=abc"},
1116
+ example_args="vc link @newsroom",
1117
+ covers=(
1118
+ "groupcall.export-listener-link",
1119
+ "groupcall.export-speaker-link",
1120
+ "groupcall.private-chat-link-fallback",
1121
+ "groupcall.reset-invite-hash",
1122
+ ),
1123
+ )
1124
+
1125
+
1126
+ class RemoveReq(Request):
1127
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat with the call.")]
1128
+ peer: Annotated[PeerRef, arg(1, metavar="PEER", kind="peer", help="Who to remove.")]
1129
+ ban: Annotated[bool, opt("--ban", help="Ban them from the chat, not just the call.")] = False
1130
+
1131
+
1132
+ async def remove(ctx: OpContext, req: RemoveReq) -> ParticipantRemoved:
1133
+ """Remove a participant from a video chat.
1134
+
1135
+ There is no "kick from call" RPC: the clients restrict or ban the user in
1136
+ the chat, which drops them from the call. Conferences work differently —
1137
+ `conference remove`.
1138
+ """
1139
+ from telethon import utils
1140
+ from telethon.tl import types
1141
+ from telethon.tl.functions import channels as channels_fn
1142
+ from telethon.tl.functions import messages as messages_fn
1143
+
1144
+ chat = await _chat_peer(ctx, req.chat)
1145
+ target = await _send.resolve(ctx, req.peer)
1146
+ client = _client(ctx)
1147
+
1148
+ if type(chat).__name__ == "InputPeerChannel":
1149
+ rights = types.ChatBannedRights(
1150
+ until_date=None,
1151
+ view_messages=req.ban or None,
1152
+ send_messages=True,
1153
+ send_media=True,
1154
+ send_plain=True,
1155
+ )
1156
+ await client(
1157
+ channels_fn.EditBannedRequest(
1158
+ channel=utils.get_input_channel(chat), participant=target, banned_rights=rights
1159
+ )
1160
+ )
1161
+ else:
1162
+ await client(
1163
+ messages_fn.DeleteChatUserRequest(
1164
+ chat_id=chat.chat_id, user_id=utils.get_input_user(target)
1165
+ )
1166
+ )
1167
+ ctx.emit("vc_participant_removed", {"chat_id": _send.peer_id_of(chat)})
1168
+ return ParticipantRemoved(
1169
+ chat_id=_send.peer_id_of(chat),
1170
+ removed=True,
1171
+ peer=Peer(id=_send.peer_id_of(target), raw_id=_send.peer_id_of(target), kind="user"),
1172
+ banned=req.ban,
1173
+ )
1174
+
1175
+
1176
+ SPEC_REMOVE = OperationSpec(
1177
+ id="vc.remove",
1178
+ request=RemoveReq,
1179
+ response=ParticipantRemoved,
1180
+ impl=remove,
1181
+ summary="Remove a participant from a video chat",
1182
+ description="Needs `ban_users`; restricting them in the chat is what drops the call.",
1183
+ mutating=True,
1184
+ destructive=True,
1185
+ rate_class="send",
1186
+ columns=("chat_id", "banned", "removed"),
1187
+ example={"chat_id": -1000000005150, "banned": False, "removed": True},
1188
+ example_args="vc remove @newsroom @alice",
1189
+ covers=("groupcall.remove-participant",),
1190
+ tags=frozenset({"visible-to-others"}),
1191
+ )
1192
+
1193
+
1194
+ # ---------------------------------------------------------------------------
1195
+ # vc mute / unmute / raise-hand / volume / video
1196
+ # ---------------------------------------------------------------------------
1197
+
1198
+
1199
+ async def _edit_participant(
1200
+ ctx: OpContext, call_ref: str, peer_ref: PeerRef | None, **fields: Any
1201
+ ) -> tuple[CallRef, Peer | None, Any]:
1202
+ """`phone.editGroupCallParticipant` with the peer defaulting to yourself."""
1203
+ from telethon.tl import types
1204
+ from telethon.tl.functions import phone as fn
1205
+
1206
+ handle = await _calls.concrete_call(ctx, await _calls.resolve_call(ctx, call_ref))
1207
+ participant: Any = types.InputPeerSelf()
1208
+ model: Peer | None = None
1209
+ if peer_ref is not None:
1210
+ participant = await _send.resolve(ctx, peer_ref)
1211
+ marked = _send.peer_id_of(participant)
1212
+ model = Peer(id=marked, raw_id=abs(marked), kind="user")
1213
+ result = await _client(ctx)(
1214
+ fn.EditGroupCallParticipantRequest(call=handle.input, participant=participant, **fields)
1215
+ )
1216
+ return handle.ref, model, result
1217
+
1218
+
1219
+ class MuteReq(Request):
1220
+ call: Annotated[str, arg(0, metavar="CALL", help="A chat, id:access_hash or call link.")]
1221
+ peer: Annotated[
1222
+ PeerRef | None,
1223
+ arg(1, metavar="PEER", required=False, kind="peer", help="Whom; omit for yourself."),
1224
+ ] = None
1225
+ for_me: Annotated[
1226
+ bool, opt("--for-me", help="Mute them only in your own playback (muted_by_you).")
1227
+ ] = False
1228
+
1229
+
1230
+ async def mute(ctx: OpContext, req: MuteReq) -> MuteState:
1231
+ """Mute yourself, force-mute a participant, or silence someone for yourself.
1232
+
1233
+ The same RPC means two different things depending on your rights: with
1234
+ `manage_call` it is a force-mute that takes `can_self_unmute` away, and
1235
+ without it the server records `muted_by_you` instead. That is a real
1236
+ difference in what other people experience, so `--for-me` is explicit
1237
+ rather than inferred.
1238
+ """
1239
+ if req.peer is not None and not req.for_me:
1240
+ ctx.warn(
1241
+ "without manage_call this becomes a mute-for-me; pass --for-me if that is "
1242
+ "what you meant"
1243
+ )
1244
+ ref, peer, _ = await _edit_participant(ctx, req.call, req.peer, muted=True)
1245
+ ctx.emit("vc_muted", {"call_id": ref.id})
1246
+ return MuteState(
1247
+ call=ref,
1248
+ media=MEDIA_NONE,
1249
+ peer=peer,
1250
+ muted=True,
1251
+ can_self_unmute=False if req.peer is not None and not req.for_me else None,
1252
+ muted_by_you=bool(req.peer is not None and req.for_me),
1253
+ )
1254
+
1255
+
1256
+ SPEC_MUTE = OperationSpec(
1257
+ id="vc.mute",
1258
+ request=MuteReq,
1259
+ response=MuteState,
1260
+ impl=mute,
1261
+ summary="Mute yourself, force-mute a participant, or silence someone just for you",
1262
+ description=(
1263
+ "Self-mute flips a server-side flag; there is no microphone behind it, and `media` says so."
1264
+ ),
1265
+ mutating=True,
1266
+ idempotent=True,
1267
+ rate_class="send",
1268
+ columns=("call.id", "muted", "muted_by_you", "media"),
1269
+ example={"call": _EXAMPLE_REF, "muted": True, "media": "none"},
1270
+ example_args="vc mute @newsroom",
1271
+ covers=("groupcall.mute-for-me", "groupcall.mute-participant"),
1272
+ covers_partial=("groupcall.mute-self",),
1273
+ coverage_note="the unmute half lives in `vc unmute`",
1274
+ )
1275
+
1276
+
1277
+ class UnmuteReq(Request):
1278
+ call: Annotated[str, arg(0, metavar="CALL", help="A chat, id:access_hash or call link.")]
1279
+ peer: Annotated[
1280
+ PeerRef | None,
1281
+ arg(1, metavar="PEER", required=False, kind="peer", help="Whom; omit for yourself."),
1282
+ ] = None
1283
+ for_me: Annotated[bool, opt("--for-me", help="Undo a mute-for-me on this participant.")] = False
1284
+
1285
+
1286
+ async def unmute(ctx: OpContext, req: UnmuteReq) -> MuteState:
1287
+ """Unmute yourself, or allow a force-muted participant to speak.
1288
+
1289
+ Unmuting somebody else does not open their microphone: it restores
1290
+ `can_self_unmute`, which the GUI calls "Allow to speak". The alias says
1291
+ so, because the RPC's name does not.
1292
+ """
1293
+ ref, peer, _ = await _edit_participant(ctx, req.call, req.peer, muted=False)
1294
+ ctx.emit("vc_unmuted", {"call_id": ref.id})
1295
+ if req.peer is not None:
1296
+ ctx.warn("this restores can_self_unmute; it does not open their microphone")
1297
+ return MuteState(
1298
+ call=ref,
1299
+ media=MEDIA_NONE,
1300
+ peer=peer,
1301
+ muted=False,
1302
+ can_self_unmute=True,
1303
+ muted_by_you=False,
1304
+ )
1305
+
1306
+
1307
+ SPEC_UNMUTE = OperationSpec(
1308
+ id="vc.unmute",
1309
+ request=UnmuteReq,
1310
+ response=MuteState,
1311
+ impl=unmute,
1312
+ aliases=("vc.allow-speak",),
1313
+ summary="Unmute yourself, or allow a force-muted participant to speak",
1314
+ mutating=True,
1315
+ idempotent=True,
1316
+ rate_class="send",
1317
+ columns=("call.id", "muted", "can_self_unmute", "media"),
1318
+ example={"call": _EXAMPLE_REF, "muted": False, "can_self_unmute": True, "media": "none"},
1319
+ example_args="vc unmute @newsroom",
1320
+ covers=("groupcall.allow-to-speak", "groupcall.mute-self"),
1321
+ )
1322
+
1323
+
1324
+ class HandReq(Request):
1325
+ call: Annotated[str, arg(0, metavar="CALL", help="A chat, id:access_hash or call link.")]
1326
+ peer: Annotated[
1327
+ PeerRef | None,
1328
+ arg(1, metavar="PEER", required=False, kind="peer", help="Whom; omit for yourself."),
1329
+ ] = None
1330
+ lower: Annotated[bool, opt("--lower", help="Lower the hand instead of raising it.")] = False
1331
+
1332
+
1333
+ async def raise_hand(ctx: OpContext, req: HandReq) -> RaisedHand:
1334
+ """Ask to speak, or clear a raised hand.
1335
+
1336
+ Video chats and livestreams only — conferences have no raised hands, and
1337
+ `raise_hand_rating` is what orders the admin's request queue.
1338
+ """
1339
+ ref, peer, _ = await _edit_participant(ctx, req.call, req.peer, raise_hand=not req.lower)
1340
+ return RaisedHand(call=ref, peer=peer, raise_hand=not req.lower)
1341
+
1342
+
1343
+ SPEC_RAISE_HAND = OperationSpec(
1344
+ id="vc.raise-hand",
1345
+ request=HandReq,
1346
+ response=RaisedHand,
1347
+ impl=raise_hand,
1348
+ aliases=("vc.lower-hand",),
1349
+ summary="Ask to speak, or clear a raised hand",
1350
+ description="Lowering somebody else's hand needs `manage_call`.",
1351
+ mutating=True,
1352
+ idempotent=True,
1353
+ rate_class="send",
1354
+ columns=("call.id", "raise_hand", "raise_hand_rating"),
1355
+ example={"call": _EXAMPLE_REF, "raise_hand": True},
1356
+ example_args="vc raise-hand @newsroom",
1357
+ covers=("groupcall.lower-participant-hand", "groupcall.raise-hand"),
1358
+ )
1359
+
1360
+
1361
+ class VolumeReq(Request):
1362
+ call: Annotated[str, arg(0, metavar="CALL", help="A chat, id:access_hash or call link.")]
1363
+ peer: Annotated[PeerRef, arg(1, metavar="PEER", kind="peer", help="Whose volume.")]
1364
+ percent: Annotated[
1365
+ int, arg(2, metavar="PERCENT", help="0 to 200; 100 is normal.", ge=0, le=200)
1366
+ ]
1367
+
1368
+
1369
+ async def set_volume(ctx: OpContext, req: VolumeReq) -> VolumeState:
1370
+ """Set a participant's volume.
1371
+
1372
+ `PERCENT` is 0–200 and maps to the API's 1..20000. With moderation rights
1373
+ it becomes everyone's default (`volume_by_admin`) and lands in the admin
1374
+ log; without them it is your own playback only — which, with no media
1375
+ engine, means nothing audible happens here.
1376
+ """
1377
+ ref, peer, _ = await _edit_participant(
1378
+ ctx, req.call, req.peer, volume=max(1, req.percent * 100)
1379
+ )
1380
+ if req.percent == 0:
1381
+ ctx.warn("volume 0 is the same action as a mute-for-me")
1382
+ ctx.warn("tlgr plays no audio: this changes the server-side setting only")
1383
+ return VolumeState(call=ref, peer=peer, volume=req.percent)
1384
+
1385
+
1386
+ SPEC_VOLUME_SET = OperationSpec(
1387
+ id="vc.volume.set",
1388
+ request=VolumeReq,
1389
+ response=VolumeState,
1390
+ impl=set_volume,
1391
+ summary="Set a participant's volume",
1392
+ mutating=True,
1393
+ idempotent=True,
1394
+ rate_class="send",
1395
+ columns=("call.id", "volume", "volume_by_admin"),
1396
+ example={"call": _EXAMPLE_REF, "volume": 150},
1397
+ example_args="vc volume set @newsroom @alice 150",
1398
+ covers=("groupcall.set-volume",),
1399
+ )
1400
+
1401
+
1402
+ class VideoReq(Request):
1403
+ call: Annotated[str, arg(0, metavar="CALL", help="A chat, id:access_hash or call link.")]
1404
+ on: Annotated[bool, opt("--on", help="Announce your camera as running.")] = False
1405
+ off: Annotated[bool, opt("--off", help="Announce it as stopped.")] = False
1406
+ pause: Annotated[bool, opt("--pause", help="Mark the stream paused.")] = False
1407
+ resume: Annotated[bool, opt("--resume", help="Unpause it.")] = False
1408
+ screen: Annotated[
1409
+ bool, opt("--screen", help="Act on the screen-share connection, not the camera.")
1410
+ ] = False
1411
+ params_json: Annotated[
1412
+ str | None,
1413
+ opt("--params-json", metavar="PATH", kind="path", help="Payload for --screen --on."),
1414
+ ] = None
1415
+
1416
+
1417
+ async def set_video(ctx: OpContext, req: VideoReq) -> VideoState:
1418
+ """Camera and screen-share state in a group call.
1419
+
1420
+ Control-only, and here the gap is total: these calls tell the server what
1421
+ your video is doing and the frames go through tgcalls, which tlgr does not
1422
+ have. `--screen --on` registers a *second* connection and needs its own
1423
+ real payload, so it is plumbing for a bridge; `--screen --off` is a safe
1424
+ control call that drops the presentation and keeps the main connection.
1425
+ """
1426
+ from telethon.tl import types
1427
+ from telethon.tl.functions import phone as fn
1428
+
1429
+ if req.on and req.off:
1430
+ raise UsageError("--on and --off contradict each other", field="on")
1431
+ if req.pause and req.resume:
1432
+ raise UsageError("--pause and --resume contradict each other", field="pause")
1433
+
1434
+ handle = await _calls.concrete_call(ctx, await _calls.resolve_call(ctx, req.call))
1435
+ client = _client(ctx)
1436
+
1437
+ if req.screen:
1438
+ if req.on:
1439
+ if not req.params_json:
1440
+ raise UsageError(
1441
+ "sharing a screen registers a second tgcalls connection and needs its "
1442
+ "own join payload; pass --params-json from a real media engine",
1443
+ field="params_json",
1444
+ )
1445
+ await client(
1446
+ fn.JoinGroupCallPresentationRequest(
1447
+ call=handle.input,
1448
+ params=types.DataJSON(data=_read_text(req.params_json, field="params-json")),
1449
+ )
1450
+ )
1451
+ ctx.warn("tlgr presents nothing: the connection exists, the frames do not")
1452
+ return VideoState(call=handle.ref, media=MEDIA_NONE, presentation=True)
1453
+ if req.off:
1454
+ await client(fn.LeaveGroupCallPresentationRequest(call=handle.input))
1455
+ return VideoState(call=handle.ref, media=MEDIA_NONE, presentation=False)
1456
+ if req.pause or req.resume:
1457
+ ref, _, _ = await _edit_participant(ctx, req.call, None, presentation_paused=req.pause)
1458
+ return VideoState(call=ref, media=MEDIA_NONE, presentation_paused=req.pause)
1459
+ raise UsageError("--screen needs --on, --off, --pause or --resume", field="screen")
1460
+
1461
+ fields: dict[str, Any] = {}
1462
+ if req.on or req.off:
1463
+ fields["video_stopped"] = req.off
1464
+ if req.pause or req.resume:
1465
+ fields["video_paused"] = req.pause
1466
+ if not fields:
1467
+ raise UsageError("give --on, --off, --pause or --resume", field="on")
1468
+ ref, _, _ = await _edit_participant(ctx, req.call, None, **fields)
1469
+ ctx.warn("tlgr has no camera: this announces a state, it does not send video")
1470
+ return VideoState(
1471
+ call=ref,
1472
+ media=MEDIA_NONE,
1473
+ video_stopped=fields.get("video_stopped"),
1474
+ video_paused=fields.get("video_paused"),
1475
+ )
1476
+
1477
+
1478
+ SPEC_VIDEO_SET = OperationSpec(
1479
+ id="vc.video.set",
1480
+ request=VideoReq,
1481
+ response=VideoState,
1482
+ impl=set_video,
1483
+ summary="Camera and screen-share state in a group call",
1484
+ mutating=True,
1485
+ rate_class="send",
1486
+ columns=("call.id", "video_stopped", "presentation", "media"),
1487
+ example={"call": _EXAMPLE_REF, "video_stopped": False, "media": "none"},
1488
+ example_args="vc video set @newsroom --on",
1489
+ covers=(
1490
+ "groupcall.pause-my-video",
1491
+ "groupcall.pause-presentation",
1492
+ "groupcall.screen-share-start",
1493
+ "groupcall.screen-share-stop",
1494
+ "groupcall.toggle-my-video",
1495
+ ),
1496
+ )
1497
+
1498
+
1499
+ # ---------------------------------------------------------------------------
1500
+ # vc participant list / identity list
1501
+ # ---------------------------------------------------------------------------
1502
+
1503
+
1504
+ class ParticipantListReq(Request):
1505
+ call: Annotated[str, arg(0, metavar="CALL", help="A chat, id:access_hash or call link.")]
1506
+ user: Annotated[
1507
+ list[PeerRef],
1508
+ opt("--user", metavar="PEER", kind="peer", help="Only these participants."),
1509
+ ] = []
1510
+ source: Annotated[
1511
+ list[int], opt("--source", metavar="SSRC", help="Only the owners of these SSRCs.")
1512
+ ] = []
1513
+ raised_hands: Annotated[
1514
+ bool, opt("--raised-hands", help="Only participants asking to speak.")
1515
+ ] = False
1516
+ video: Annotated[bool, opt("--video", help="Only participants publishing video.")] = False
1517
+
1518
+
1519
+ async def participant_list(ctx: OpContext, req: ParticipantListReq) -> Page[GroupCallParticipant]:
1520
+ """List who is in a call.
1521
+
1522
+ String-cursor pagination, seeded from `getGroupCall`'s
1523
+ `participants_next_offset` and stopped when `next_offset` comes back
1524
+ empty — re-sending an empty offset loops forever, which is the bug this
1525
+ implementation exists to not have.
1526
+ """
1527
+ from telethon.tl.functions import phone as fn
1528
+
1529
+ limit = min(int(getattr(ctx, "limit", None) or 30), 200)
1530
+ token = getattr(ctx, "cursor", None)
1531
+ state = (
1532
+ decode_cursor(
1533
+ token, op="vc.participant.list", kind=PageKind.PARTICIPANTS, account=ctx.account
1534
+ )
1535
+ if token
1536
+ else {}
1537
+ )
1538
+
1539
+ handle = await _calls.concrete_call(ctx, await _calls.resolve_call(ctx, req.call))
1540
+ ids = [await _send.resolve(ctx, reference) for reference in req.user]
1541
+ # `phone.GroupParticipants` does not carry the call, and two of its flags
1542
+ # change what the page *means* — `listeners_hidden` (this is not the
1543
+ # audience) and `conference` (the states are different words). Asking for
1544
+ # the call costs one read and is the difference between a correct answer
1545
+ # and a confident wrong one.
1546
+ call, _ = await _fetch_call(ctx, handle)
1547
+ result = await _client(ctx)(
1548
+ fn.GetGroupParticipantsRequest(
1549
+ call=handle.input,
1550
+ ids=ids,
1551
+ sources=list(req.source),
1552
+ offset=str(state.get("offset", "") or ""),
1553
+ limit=limit,
1554
+ )
1555
+ )
1556
+ entities = _entities(result)
1557
+ items = [
1558
+ _participant_model(raw, entities) for raw in (getattr(result, "participants", None) or [])
1559
+ ]
1560
+ if req.raised_hands:
1561
+ items = sorted(
1562
+ (p for p in items if p.raise_hand),
1563
+ key=lambda p: p.raise_hand_rating or 0,
1564
+ reverse=True,
1565
+ )
1566
+ if req.video:
1567
+ items = [p for p in items if p.video or p.presentation]
1568
+
1569
+ if getattr(call, "listeners_hidden", False):
1570
+ ctx.warn(
1571
+ "listeners are hidden in this call: the page carries publishers only, and "
1572
+ "participants_count is the real audience"
1573
+ )
1574
+ if getattr(call, "conference", False):
1575
+ for participant in items:
1576
+ participant.state = "joined" if not participant.left else "invited"
1577
+
1578
+ next_offset = str(getattr(result, "next_offset", "") or "")
1579
+ return build_page(
1580
+ items,
1581
+ op="vc.participant.list",
1582
+ kind=PageKind.PARTICIPANTS,
1583
+ state={"offset": next_offset},
1584
+ account=ctx.account,
1585
+ has_more=bool(next_offset),
1586
+ total=getattr(result, "count", None),
1587
+ )
1588
+
1589
+
1590
+ SPEC_PARTICIPANT_LIST = OperationSpec(
1591
+ id="vc.participant.list",
1592
+ request=ParticipantListReq,
1593
+ response=Page[GroupCallParticipant],
1594
+ impl=participant_list,
1595
+ aliases=("conference.participants", "vc.participants"),
1596
+ summary="List the participants of a video chat, livestream, live story or conference",
1597
+ description=(
1598
+ "The fourth conference state — present in the E2E chain with no media "
1599
+ "— needs a block parser tlgr does not have and is reported as null."
1600
+ ),
1601
+ paginated=PageKind.PARTICIPANTS,
1602
+ columns=("peer.id", "muted", "video", "raise_hand", "source"),
1603
+ headers=("Peer", "Muted", "Video", "Hand", "SSRC"),
1604
+ example={"items": [{"source": 1234567, "muted": True}], "has_more": False},
1605
+ example_args="vc participant list @newsroom",
1606
+ covers=(
1607
+ "conference.participant-states",
1608
+ "conference.participants",
1609
+ "groupcall.list-participants",
1610
+ "groupcall.participants-by-id",
1611
+ "livestory.streamer-info",
1612
+ ),
1613
+ )
1614
+
1615
+
1616
+ class IdentityListReq(Request):
1617
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat with the call.")]
1618
+ comment: Annotated[
1619
+ bool, opt("--comment", help="The live-story comment-author list instead of join-as.")
1620
+ ] = False
1621
+
1622
+
1623
+ async def identity_list(ctx: OpContext, req: IdentityListReq) -> Page[CallIdentity]:
1624
+ """Peers you may appear as: "Display as" and "Comment as".
1625
+
1626
+ Two GUI pickers, two RPCs, one command. Live stories and conferences must
1627
+ be *joined* as yourself; only the comment author may differ, which is why
1628
+ the two lists are not interchangeable.
1629
+ """
1630
+ from telethon.tl.functions import channels as channels_fn
1631
+ from telethon.tl.functions import phone as fn
1632
+
1633
+ peer = await _chat_peer(ctx, req.chat)
1634
+ client = _client(ctx)
1635
+ if req.comment:
1636
+ result = await client(channels_fn.GetSendAsRequest(peer=peer, for_live_stories=True))
1637
+ kind = "send-as"
1638
+ rows = getattr(result, "peers", None) or []
1639
+ raw_peers = [getattr(row, "peer", row) for row in rows]
1640
+ default = None
1641
+ else:
1642
+ result = await client(fn.GetGroupCallJoinAsRequest(peer=peer))
1643
+ kind = "join-as"
1644
+ raw_peers = list(getattr(result, "peers", None) or [])
1645
+ default = getattr(result, "default_peer", None)
1646
+
1647
+ entities = _entities(result)
1648
+ default_id = getattr(default, "user_id", None) or getattr(default, "channel_id", None)
1649
+ items: list[CallIdentity] = []
1650
+ for raw in raw_peers:
1651
+ model = _peer_model(raw, entities)
1652
+ if model is None: # pragma: no cover - the server sends real peers
1653
+ continue
1654
+ items.append(
1655
+ CallIdentity(
1656
+ peer=model,
1657
+ kind=kind,
1658
+ default=bool(default_id and model.raw_id == default_id),
1659
+ is_self=model.is_self,
1660
+ )
1661
+ )
1662
+ return Page(items=items, has_more=False, total=len(items))
1663
+
1664
+
1665
+ SPEC_IDENTITY_LIST = OperationSpec(
1666
+ id="vc.identity.list",
1667
+ request=IdentityListReq,
1668
+ response=Page[CallIdentity],
1669
+ impl=identity_list,
1670
+ aliases=("vc.join-as.list", "vc.send-as.list"),
1671
+ summary="Peers you may appear as in a call ('Display as' / 'Comment as')",
1672
+ columns=("peer.id", "kind", "default"),
1673
+ headers=("Peer", "Kind", "Default"),
1674
+ example={"items": [{"kind": "join-as", "default": True}], "has_more": False},
1675
+ example_args="vc identity list @newsroom",
1676
+ covers=("groupcall.join-as-list", "groupcall.send-as-list"),
1677
+ )
1678
+
1679
+
1680
+ # ---------------------------------------------------------------------------
1681
+ # vc rtmp get
1682
+ # ---------------------------------------------------------------------------
1683
+
1684
+
1685
+ class RtmpReq(Request):
1686
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat or channel.")]
1687
+ live_story: Annotated[
1688
+ bool, opt("--live-story", help="Credentials for the peer's live story.")
1689
+ ] = False
1690
+ revoke: Annotated[
1691
+ bool, opt("--revoke", help="Rotate the key — this breaks any running encoder.")
1692
+ ] = False
1693
+ show_key: Annotated[bool, opt("--show-key", help="Print the key instead of masking it.")] = (
1694
+ False
1695
+ )
1696
+ key_file: Annotated[
1697
+ str | None,
1698
+ opt("--key-file", metavar="PATH", kind="path", help="Write the key to a 0600 file."),
1699
+ ] = None
1700
+
1701
+
1702
+ async def rtmp_get(ctx: OpContext, req: RtmpReq) -> RtmpInfo:
1703
+ """Get or rotate the RTMP ingest URL and stream key.
1704
+
1705
+ The key is a publishing credential and the obvious thing to do with CLI
1706
+ output is paste it into a bug report, so it is masked unless you asked for
1707
+ it in so many words. Fetch this *before* `vc create --rtmp`.
1708
+ """
1709
+ import os
1710
+
1711
+ from telethon.tl.functions import phone as fn
1712
+
1713
+ peer = await _chat_peer(ctx, req.chat)
1714
+ result = await _client(ctx)(
1715
+ fn.GetGroupCallStreamRtmpUrlRequest(
1716
+ peer=peer, revoke=bool(req.revoke), live_story=req.live_story or None
1717
+ )
1718
+ )
1719
+ key = str(getattr(result, "key", "") or "")
1720
+ info = RtmpInfo(
1721
+ url=str(getattr(result, "url", "") or ""),
1722
+ peer=Peer(id=_send.peer_id_of(peer), raw_id=abs(_send.peer_id_of(peer)), kind="channel"),
1723
+ live_story=req.live_story,
1724
+ revoked=req.revoke,
1725
+ )
1726
+ if req.key_file:
1727
+ handle = os.open(req.key_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
1728
+ try:
1729
+ os.write(handle, key.encode())
1730
+ finally:
1731
+ os.close(handle)
1732
+ info.key_file = req.key_file
1733
+ info.key = "<written to file>"
1734
+ elif req.show_key:
1735
+ info.key, info.key_shown = key, True
1736
+ else:
1737
+ info.key = f"{key[:4]}…{key[-2:]}" if len(key) > 8 else "<hidden>"
1738
+ ctx.warn("the stream key is masked; pass --show-key or --key-file to get it")
1739
+ if req.revoke:
1740
+ ctx.warn("the old key is dead: any encoder still publishing with it has stopped")
1741
+ return info
1742
+
1743
+
1744
+ SPEC_RTMP_GET = OperationSpec(
1745
+ id="vc.rtmp.get",
1746
+ request=RtmpReq,
1747
+ response=RtmpInfo,
1748
+ impl=rtmp_get,
1749
+ aliases=("story.live.rtmp",),
1750
+ summary="Get or rotate the RTMP ingest URL and stream key",
1751
+ description="Needs `manage_call`; revoking needs owner privileges.",
1752
+ mutating=True,
1753
+ rate_class="send",
1754
+ columns=("url", "key", "key_shown"),
1755
+ example={"url": "rtmps://dc4-1.rtmp.t.me/s/", "key": "abcd…yz", "key_shown": False},
1756
+ example_args="vc rtmp get @newsroom",
1757
+ covers=(
1758
+ "groupcall.rtmp-get-url",
1759
+ "groupcall.rtmp-revoke-key",
1760
+ "livestory.rtmp-revoke",
1761
+ "stories.live-rtmp-url",
1762
+ ),
1763
+ )
1764
+
1765
+
1766
+ # ---------------------------------------------------------------------------
1767
+ # vc send / message delete
1768
+ # ---------------------------------------------------------------------------
1769
+
1770
+
1771
+ class SendReq(Request):
1772
+ call: Annotated[str, arg(0, metavar="CALL", help="A chat, id:access_hash or call link.")]
1773
+ text: Annotated[str, arg(1, metavar="TEXT", help="What to say, or the reaction emoji.")]
1774
+ custom_emoji: Annotated[
1775
+ int | None,
1776
+ opt("--custom-emoji", metavar="ID", help="Send a custom emoji reaction (Premium)."),
1777
+ ] = None
1778
+ send_as: Annotated[
1779
+ PeerRef | None,
1780
+ opt("--send-as", metavar="PEER", kind="peer", help="Comment as a channel (live stories)."),
1781
+ ] = None
1782
+ remember: Annotated[bool, opt("--remember", help="Store --send-as as the default.")] = False
1783
+ parse: Annotated[str | None, choice("md", "html", "none", help="Text formatting.")] = "md"
1784
+ stars: Annotated[
1785
+ int | None, opt("--stars", metavar="N", help="Donate Stars to highlight it (at least 1).")
1786
+ ] = None
1787
+ confirm_stars: Annotated[
1788
+ bool, opt("--confirm-stars", help="Required with --stars: it spends real Stars.")
1789
+ ] = False
1790
+
1791
+
1792
+ async def send(ctx: OpContext, req: SendReq) -> InCallMessage:
1793
+ """Send an in-call message or emoji reaction.
1794
+
1795
+ Reactions are not a separate API: a standard one is a message whose text
1796
+ is the emoji, a custom one is fallback text plus a single custom-emoji
1797
+ entity — hence `vc react` as an alias rather than a second command. The
1798
+ overlay has no history and no fetch method at all, so `vc watch` is the
1799
+ only way to read anyone else's.
1800
+
1801
+ `--stars` spends the account's Star balance, so it is never implicit:
1802
+ an explicit amount *and* `--confirm-stars` are both required.
1803
+ """
1804
+ from telethon.tl import types
1805
+ from telethon.tl.functions import phone as fn
1806
+
1807
+ if req.stars and not req.confirm_stars:
1808
+ raise UsageError(
1809
+ f"--stars {req.stars} spends real Stars; add --confirm-stars to mean it",
1810
+ field="stars",
1811
+ )
1812
+
1813
+ handle = await _calls.concrete_call(ctx, await _calls.resolve_call(ctx, req.call))
1814
+ call, entities = await _fetch_call(ctx, handle)
1815
+ if not getattr(call, "messages_enabled", False):
1816
+ raise PermissionError_(
1817
+ "in-call messages are off for this call (`vc set CALL --messages on` turns "
1818
+ "them on, with manage_call)"
1819
+ )
1820
+
1821
+ config = await _calls.app_config(ctx)
1822
+ cap = int(config.get("group_call_message_length_limit", DEFAULT_MESSAGE_LENGTH) or 0)
1823
+ text, entity_models = _send.body(req.text, parse=req.parse)
1824
+ if cap and len(text.encode()) > cap:
1825
+ raise UsageError(f"an in-call message is at most {cap} characters", field="text")
1826
+
1827
+ tl_entities = _send.tl_entities(entity_models) or []
1828
+ if req.custom_emoji is not None:
1829
+ from tlgr.core.text import utf16_len
1830
+
1831
+ tl_entities = [
1832
+ types.MessageEntityCustomEmoji(
1833
+ offset=0, length=utf16_len(text), document_id=req.custom_emoji
1834
+ )
1835
+ ]
1836
+
1837
+ send_as: Any = None
1838
+ if req.send_as is not None:
1839
+ send_as = await _send.resolve(ctx, req.send_as)
1840
+
1841
+ result = await _client(ctx)(
1842
+ fn.SendGroupCallMessageRequest(
1843
+ call=handle.input,
1844
+ message=types.TextWithEntities(text=text, entities=tl_entities),
1845
+ random_id=secrets.randbits(63),
1846
+ allow_paid_stars=req.stars,
1847
+ send_as=send_as,
1848
+ )
1849
+ )
1850
+ if req.remember and send_as is not None:
1851
+ await _client(ctx)(fn.SaveDefaultSendAsRequest(call=handle.input, send_as=send_as))
1852
+
1853
+ msg_id = 0
1854
+ date: Any = None
1855
+ for update in getattr(result, "updates", None) or []:
1856
+ message = getattr(update, "message", None)
1857
+ if message is not None:
1858
+ msg_id = int(getattr(message, "id", 0) or 0) or msg_id
1859
+ date = getattr(message, "date", None) or date
1860
+ ttl = int(config.get("group_call_message_ttl", DEFAULT_MESSAGE_TTL) or DEFAULT_MESSAGE_TTL)
1861
+ ctx.emit("vc_message", {"call_id": handle.ref.id, "text": text})
1862
+ return InCallMessage(
1863
+ call=handle.ref,
1864
+ msg_id=msg_id,
1865
+ from_id=_peer_model(getattr(call, "default_send_as", None), entities),
1866
+ date=fmt_dt(date),
1867
+ date_unix=to_unix(date),
1868
+ text=text,
1869
+ paid_message_stars=req.stars,
1870
+ ttl=ttl,
1871
+ )
1872
+
1873
+
1874
+ SPEC_SEND = OperationSpec(
1875
+ id="vc.send",
1876
+ request=SendReq,
1877
+ response=InCallMessage,
1878
+ impl=send,
1879
+ aliases=("story.live.comment", "vc.react"),
1880
+ summary="Send an in-call message or emoji reaction (live-story comments included)",
1881
+ description=(
1882
+ "The overlay lives about ten seconds and has no history method; run "
1883
+ "`vc watch --messages` to read the other side."
1884
+ ),
1885
+ mutating=True,
1886
+ rate_class="send",
1887
+ columns=("call.id", "msg_id", "text", "ttl"),
1888
+ example={"call": _EXAMPLE_REF, "msg_id": 7, "text": "🔥", "ttl": 10},
1889
+ example_args='vc send @newsroom "hello"',
1890
+ covers=(
1891
+ "groupcall.save-default-send-as",
1892
+ "groupcall.send-message",
1893
+ "groupcall.send-reaction",
1894
+ "stories.live-comments",
1895
+ "stories.live-highlight-comment",
1896
+ "stories.live-message-sender",
1897
+ ),
1898
+ tags=frozenset({"visible-to-others"}),
1899
+ )
1900
+
1901
+
1902
+ class MessageDeleteReq(Request):
1903
+ call: Annotated[str, arg(0, metavar="CALL", help="A chat, id:access_hash or call link.")]
1904
+ id: Annotated[
1905
+ list[int],
1906
+ arg(1, metavar="ID", required=False, variadic=True, help="In-call message ids."),
1907
+ ] = []
1908
+ from_peer: Annotated[
1909
+ PeerRef | None,
1910
+ opt("--from", metavar="PEER", kind="peer", help="Delete everything this peer said."),
1911
+ ] = None
1912
+ report_spam: Annotated[
1913
+ bool, opt("--report-spam", help="Report the deleted messages as spam.")
1914
+ ] = False
1915
+
1916
+
1917
+ async def message_delete(ctx: OpContext, req: MessageDeleteReq) -> InCallMessagesDeleted:
1918
+ """Delete in-call messages, or every message from one participant.
1919
+
1920
+ Two RPCs, picked by whether `--from` is given; deletions reach everyone as
1921
+ `updateDeleteGroupCallMessages`. `--report-spam` only means anything when
1922
+ moderating somebody else's messages.
1923
+ """
1924
+ from telethon.tl.functions import phone as fn
1925
+
1926
+ handle = await _calls.concrete_call(ctx, await _calls.resolve_call(ctx, req.call))
1927
+ client = _client(ctx)
1928
+
1929
+ if req.from_peer is not None:
1930
+ participant = await _send.resolve(ctx, req.from_peer)
1931
+ await client(
1932
+ fn.DeleteGroupCallParticipantMessagesRequest(
1933
+ call=handle.input, participant=participant, report_spam=req.report_spam or None
1934
+ )
1935
+ )
1936
+ marked = _send.peer_id_of(participant)
1937
+ return InCallMessagesDeleted(
1938
+ call=handle.ref,
1939
+ participant=Peer(id=marked, raw_id=abs(marked), kind="user"),
1940
+ reported=req.report_spam,
1941
+ )
1942
+
1943
+ if not req.id:
1944
+ raise UsageError("give message ids, or --from to clear one participant", field="id")
1945
+ await client(
1946
+ fn.DeleteGroupCallMessagesRequest(
1947
+ call=handle.input, messages=list(req.id), report_spam=req.report_spam or None
1948
+ )
1949
+ )
1950
+ return InCallMessagesDeleted(call=handle.ref, deleted=list(req.id), reported=req.report_spam)
1951
+
1952
+
1953
+ SPEC_MESSAGE_DELETE = OperationSpec(
1954
+ id="vc.message.delete",
1955
+ request=MessageDeleteReq,
1956
+ response=InCallMessagesDeleted,
1957
+ impl=message_delete,
1958
+ aliases=("story.live.moderate",),
1959
+ summary="Delete in-call messages, or every message from one participant",
1960
+ description="Your own messages always; anyone else's needs call moderation rights.",
1961
+ mutating=True,
1962
+ destructive=True,
1963
+ rate_class="send",
1964
+ columns=("call.id", "deleted", "reported"),
1965
+ example={"call": _EXAMPLE_REF, "deleted": [7], "reported": False},
1966
+ example_args="vc message delete @newsroom 7",
1967
+ covers=(
1968
+ "groupcall.delete-own-message",
1969
+ "groupcall.delete-participant-messages",
1970
+ "groupcall.report-message-spam",
1971
+ ),
1972
+ )
1973
+
1974
+
1975
+ # ---------------------------------------------------------------------------
1976
+ # vc download
1977
+ # ---------------------------------------------------------------------------
1978
+
1979
+
1980
+ class DownloadReq(Request):
1981
+ call: Annotated[str, arg(0, metavar="CALL", help="A chat, id:access_hash or call link.")]
1982
+ out: Annotated[str, opt("--out", metavar="PATH", kind="path", help="Output file.")] = ""
1983
+ quality: Annotated[
1984
+ int, opt("--quality", metavar="0|1|2", help="Video quality: 0 lowest.", ge=0, le=2)
1985
+ ] = 1
1986
+ channel: Annotated[
1987
+ int | None, opt("--channel", metavar="N", help="Video channel; omit for audio only.")
1988
+ ] = None
1989
+ audio_only: Annotated[bool, opt("--audio-only", help="Fetch only the audio segments.")] = False
1990
+ since: Annotated[
1991
+ str, opt("--since", metavar="live|MS", help="Start at the live edge or a timestamp.")
1992
+ ] = "live"
1993
+ duration: Annotated[
1994
+ int,
1995
+ opt("--duration", metavar="DURATION", kind="duration", help="Stop after this much media."),
1996
+ ] = 30
1997
+ scale: Annotated[int, opt("--scale", metavar="N", help="Segment scale; 0 is 1000ms.", ge=0)] = 0
1998
+
1999
+
2000
+ async def _stream_call(client: Any, dc_id: int | None) -> tuple[Any, Any]:
2001
+ """`(send, release)` for the media DC a livestream is served from.
2002
+
2003
+ Telethon's exported-sender API is private, so it is used through `getattr`
2004
+ and falls back to the ordinary client: a fallback that fetches from the
2005
+ wrong DC fails loudly, which is better than a chunk loop that silently
2006
+ never starts.
2007
+ """
2008
+ borrow = getattr(client, "_borrow_exported_sender", None)
2009
+ call = getattr(client, "_call", None)
2010
+ if not dc_id or borrow is None or call is None:
2011
+
2012
+ async def direct(request: Any) -> Any:
2013
+ return await client(request)
2014
+
2015
+ async def noop() -> None:
2016
+ return None
2017
+
2018
+ return direct, noop
2019
+
2020
+ sender = await borrow(dc_id)
2021
+
2022
+ async def send(request: Any) -> Any:
2023
+ return await call(sender, request)
2024
+
2025
+ async def release() -> None:
2026
+ release_fn = getattr(client, "_return_exported_sender", None)
2027
+ if release_fn is not None:
2028
+ await release_fn(sender)
2029
+
2030
+ return send, release
2031
+
2032
+
2033
+ async def download(ctx: OpContext, req: DownloadReq) -> StreamDownload:
2034
+ """Archive a livestream to disk, one 1 MB chunk at a time.
2035
+
2036
+ tlgr cannot play a livestream and can record one. Chunks are
2037
+ `upload.getFile` against `inputGroupCallStream`, sent to the call's
2038
+ `stream_dc_id`; a `TIME_TOO_BIG` or a flood wait means the chunk is not
2039
+ ready yet, so the same one is retried rather than skipped — skipping is
2040
+ how a recording ends up with holes in it.
2041
+ """
2042
+ from pathlib import Path
2043
+
2044
+ from telethon.tl import types
2045
+ from telethon.tl.functions import phone as fn
2046
+ from telethon.tl.functions import upload as upload_fn
2047
+
2048
+ if req.out in ("", "-"):
2049
+ raise UsageError(
2050
+ "give --out PATH: the recording is written by the daemon, which has no "
2051
+ "access to your terminal's stdout",
2052
+ field="out",
2053
+ )
2054
+
2055
+ handle = await _calls.concrete_call(ctx, await _calls.resolve_call(ctx, req.call))
2056
+ call, _ = await _fetch_call(ctx, handle)
2057
+ dc_id = getattr(call, "stream_dc_id", None)
2058
+
2059
+ channels = await _client(ctx)(fn.GetGroupCallStreamChannelsRequest(call=handle.input))
2060
+ rows = list(getattr(channels, "channels", None) or [])
2061
+ if not rows:
2062
+ raise NotFoundError("this call publishes no stream channels yet; the publisher is idle")
2063
+ wanted = rows[0]
2064
+ for row in rows:
2065
+ if req.channel is not None and int(getattr(row, "channel", 0) or 0) == req.channel:
2066
+ wanted = row
2067
+ scale = int(getattr(wanted, "scale", req.scale) or req.scale)
2068
+ segment_ms = 1000 >> scale
2069
+
2070
+ if req.since == "live":
2071
+ time_ms = int(getattr(wanted, "last_timestamp_ms", 0) or 0)
2072
+ else:
2073
+ try:
2074
+ time_ms = int(req.since)
2075
+ except ValueError as exc:
2076
+ raise UsageError(
2077
+ "--since wants `live` or a chunk timestamp in ms", field="since"
2078
+ ) from exc
2079
+
2080
+ send, release = await _stream_call(_client(ctx), dc_id)
2081
+ written = 0
2082
+ chunks = 0
2083
+ first_ms = time_ms
2084
+ deadline = max(1, req.duration)
2085
+ target = Path(req.out)
2086
+ try:
2087
+ with target.open("wb") as handle_out:
2088
+ while chunks * segment_ms < deadline * 1000:
2089
+ location = types.InputGroupCallStream(
2090
+ call=handle.input,
2091
+ time_ms=time_ms,
2092
+ scale=scale,
2093
+ video_channel=None if req.audio_only else req.channel,
2094
+ video_quality=None if req.audio_only else req.quality,
2095
+ )
2096
+ try:
2097
+ result = await send(
2098
+ upload_fn.GetFileRequest(location=location, offset=0, limit=1048576)
2099
+ )
2100
+ except Exception as exc:
2101
+ text = f"{type(exc).__name__} {exc}".upper().replace("_", "")
2102
+ if "TIMETOOBIG" in text or "FLOODWAIT" in text:
2103
+ await asyncio.sleep(0.1)
2104
+ continue
2105
+ raise
2106
+ payload = bytes(getattr(result, "bytes", b"") or b"")
2107
+ if not payload:
2108
+ await asyncio.sleep(0.1)
2109
+ time_ms += segment_ms
2110
+ continue
2111
+ handle_out.write(payload)
2112
+ written += len(payload)
2113
+ chunks += 1
2114
+ time_ms += segment_ms
2115
+ finally:
2116
+ await release()
2117
+
2118
+ ctx.warn("recorded, not played: tlgr writes the segments and decodes nothing")
2119
+ return StreamDownload(
2120
+ call=handle.ref,
2121
+ media=MEDIA_NONE,
2122
+ out=str(target),
2123
+ bytes=written,
2124
+ chunks=chunks,
2125
+ mode="rtmp" if getattr(call, "rtmp_stream", False) else "stream",
2126
+ stream_dc_id=dc_id,
2127
+ first_time_ms=first_ms,
2128
+ last_time_ms=time_ms,
2129
+ )
2130
+
2131
+
2132
+ SPEC_DOWNLOAD = OperationSpec(
2133
+ id="vc.download",
2134
+ request=DownloadReq,
2135
+ response=StreamDownload,
2136
+ impl=download,
2137
+ summary="Archive a livestream or live story to disk (stream-mode chunks)",
2138
+ description=(
2139
+ "Join the call first (`vc join --listen-only`): chunk fetches fail "
2140
+ "with GROUPCALL_JOIN_MISSING otherwise."
2141
+ ),
2142
+ mutating=True,
2143
+ rate_class="file",
2144
+ timeout_s=900,
2145
+ columns=("call.id", "out", "bytes", "chunks", "mode"),
2146
+ example={
2147
+ "call": _EXAMPLE_REF,
2148
+ "media": "none",
2149
+ "out": "/tmp/stream.ogg",
2150
+ "bytes": 1048576,
2151
+ "chunks": 1,
2152
+ },
2153
+ example_args="vc download @newsroom --out /tmp/stream.ogg",
2154
+ covers=("groupcall.download-stream", "livestory.join-as-viewer"),
2155
+ covers_partial=("stories.live-join",),
2156
+ coverage_note="watching a live story as a viewer is owned by `story live get`",
2157
+ )
2158
+
2159
+
2160
+ # ---------------------------------------------------------------------------
2161
+ # vc watch
2162
+ # ---------------------------------------------------------------------------
2163
+
2164
+
2165
+ class WatchReq(Request):
2166
+ call: Annotated[str, arg(0, metavar="CALL", help="A chat, id:access_hash or call link.")]
2167
+ messages: Annotated[bool, opt("--messages", help="Include in-call messages.")] = True
2168
+ participants: Annotated[
2169
+ bool, opt("--participants", help="Include participant joins, leaves and mutes.")
2170
+ ] = True
2171
+ idle_timeout: Annotated[
2172
+ int,
2173
+ opt("--idle-timeout", metavar="DURATION", kind="duration", help="Give up after silence."),
2174
+ ] = 3600
2175
+
2176
+
2177
+ async def watch(ctx: OpContext, req: WatchReq) -> Any:
2178
+ """Stream live call state: participants, mutes, in-call messages, connection.
2179
+
2180
+ Implements the documented version-gap rule: `updateGroupCall` and
2181
+ `updateGroupCallParticipants` carry a monotonically increasing version and
2182
+ must be applied in order, so a gap emits a `resync` record instead of a
2183
+ silently reordered state. This is also the *only* way to read in-call
2184
+ messages — they are not part of the chat history and no method fetches
2185
+ them.
2186
+ """
2187
+ from telethon import events
2188
+ from telethon.tl import types
2189
+
2190
+ handle = await _calls.concrete_call(ctx, await _calls.resolve_call(ctx, req.call))
2191
+ client = _client(ctx)
2192
+ queue: asyncio.Queue[GroupCallEvent] = asyncio.Queue()
2193
+ seen = {"version": 0}
2194
+
2195
+ def _same_call(update: Any) -> bool:
2196
+ call = getattr(update, "call", None)
2197
+ return int(getattr(call, "id", 0) or 0) in (0, handle.ref.id)
2198
+
2199
+ async def handler(update: Any) -> None:
2200
+ name = type(update).__name__
2201
+ if not _same_call(update):
2202
+ return
2203
+ version = int(getattr(update, "version", 0) or 0)
2204
+ resync = bool(version and seen["version"] and version > seen["version"] + 1)
2205
+ if version:
2206
+ seen["version"] = max(seen["version"], version)
2207
+
2208
+ if name == "UpdateGroupCall":
2209
+ call = getattr(update, "call", None)
2210
+ await queue.put(
2211
+ GroupCallEvent(
2212
+ kind="call.state",
2213
+ at=_now(),
2214
+ call=_calls.call_ref_of(call),
2215
+ version=version or None,
2216
+ resync=resync,
2217
+ )
2218
+ )
2219
+ elif name == "UpdateGroupCallParticipants" and req.participants:
2220
+ entities = _entities(update)
2221
+ await queue.put(
2222
+ GroupCallEvent(
2223
+ kind="participants",
2224
+ at=_now(),
2225
+ call=handle.ref,
2226
+ version=version or None,
2227
+ participants=[
2228
+ _participant_model(raw, entities)
2229
+ for raw in (getattr(update, "participants", None) or [])
2230
+ ],
2231
+ resync=resync,
2232
+ )
2233
+ )
2234
+ elif name == "UpdateGroupCallConnection":
2235
+ import json
2236
+
2237
+ params = getattr(update, "params", None)
2238
+ payload: dict[str, Any] = {}
2239
+ with contextlib.suppress(ValueError):
2240
+ payload = json.loads(getattr(params, "data", "") or "{}")
2241
+ await queue.put(
2242
+ GroupCallEvent(
2243
+ kind="connection", at=_now(), call=handle.ref, connection=payload or None
2244
+ )
2245
+ )
2246
+ elif name == "UpdateGroupCallMessage" and req.messages:
2247
+ message = getattr(update, "message", None)
2248
+ await queue.put(
2249
+ GroupCallEvent(
2250
+ kind="message",
2251
+ at=_now(),
2252
+ call=handle.ref,
2253
+ message=InCallMessage(
2254
+ call=handle.ref,
2255
+ msg_id=int(getattr(message, "id", 0) or 0),
2256
+ text=str(getattr(getattr(message, "message", None), "text", "") or ""),
2257
+ date=fmt_dt(getattr(message, "date", None)),
2258
+ ),
2259
+ )
2260
+ )
2261
+ elif name == "UpdateDeleteGroupCallMessages" and req.messages:
2262
+ await queue.put(
2263
+ GroupCallEvent(
2264
+ kind="message-deleted",
2265
+ at=_now(),
2266
+ call=handle.ref,
2267
+ blocks=[str(i) for i in (getattr(update, "messages", None) or [])],
2268
+ )
2269
+ )
2270
+ elif name == "UpdateGroupCallEncryptedMessage" and req.messages:
2271
+ await queue.put(
2272
+ GroupCallEvent(
2273
+ kind="message-encrypted",
2274
+ at=_now(),
2275
+ call=handle.ref,
2276
+ encrypted=_calls.b64(bytes(getattr(update, "encrypted_message", b"") or b"")),
2277
+ )
2278
+ )
2279
+ elif name == "UpdateGroupCallChainBlocks":
2280
+ await queue.put(
2281
+ GroupCallEvent(
2282
+ kind="chain",
2283
+ at=_now(),
2284
+ call=handle.ref,
2285
+ blocks=[_calls.b64(b) for b in (getattr(update, "blocks", None) or [])],
2286
+ )
2287
+ )
2288
+
2289
+ wanted = [
2290
+ types.UpdateGroupCall,
2291
+ types.UpdateGroupCallParticipants,
2292
+ types.UpdateGroupCallConnection,
2293
+ types.UpdateGroupCallChainBlocks,
2294
+ ]
2295
+ for name in (
2296
+ "UpdateGroupCallMessage",
2297
+ "UpdateDeleteGroupCallMessages",
2298
+ "UpdateGroupCallEncryptedMessage",
2299
+ ):
2300
+ found = getattr(types, name, None)
2301
+ if found is not None:
2302
+ wanted.append(found)
2303
+
2304
+ builder = events.Raw(types=wanted)
2305
+ client.add_event_handler(handler, builder)
2306
+ try:
2307
+ while True:
2308
+ try:
2309
+ event = await asyncio.wait_for(queue.get(), timeout=max(1, req.idle_timeout))
2310
+ except (TimeoutError, asyncio.TimeoutError):
2311
+ yield Page(items=[], has_more=False)
2312
+ return
2313
+ yield Page(items=[event], has_more=True)
2314
+ finally:
2315
+ client.remove_event_handler(handler, builder)
2316
+
2317
+
2318
+ SPEC_WATCH = OperationSpec(
2319
+ id="vc.watch",
2320
+ request=WatchReq,
2321
+ response=Page[GroupCallEvent],
2322
+ impl=watch,
2323
+ summary="Stream live call state: participants, mutes, in-call messages, connection changes",
2324
+ description=(
2325
+ "Conference messages arrive encrypted and are emitted as opaque "
2326
+ "blobs: decrypting them needs the E2E key tlgr cannot derive."
2327
+ ),
2328
+ stream=True,
2329
+ columns=("kind", "call.id", "version"),
2330
+ example={"items": [{"kind": "participants", "at": "2026-09-03T09:14:07Z"}]},
2331
+ example_args="vc watch @newsroom",
2332
+ covers=("groupcall.watch-in-call-chat", "groupcall.watch-updates"),
2333
+ )
2334
+
2335
+
2336
+ def _read_bytes(path: str, *, field: str) -> bytes:
2337
+ from pathlib import Path
2338
+
2339
+ try:
2340
+ return Path(path).read_bytes()
2341
+ except OSError as exc:
2342
+ raise UsageError(f"--{field}: {exc.strerror or exc}", field=field) from exc
2343
+
2344
+
2345
+ def _read_text(path: str, *, field: str) -> str:
2346
+ from pathlib import Path
2347
+
2348
+ try:
2349
+ return Path(path).read_text(encoding="utf-8")
2350
+ except OSError as exc:
2351
+ raise UsageError(f"--{field}: {exc.strerror or exc}", field=field) from exc