tlgr-cli 2.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tlgr/__init__.py +3 -0
- tlgr/__main__.py +6 -0
- tlgr/actions/__init__.py +45 -0
- tlgr/actions/forward.py +74 -0
- tlgr/actions/reply.py +32 -0
- tlgr/cli/__init__.py +259 -0
- tlgr/cli/confirm.py +55 -0
- tlgr/cli/errors.py +84 -0
- tlgr/cli/gen.py +690 -0
- tlgr/cli/globals.py +273 -0
- tlgr/cli/introspect.py +170 -0
- tlgr/cli/params.py +189 -0
- tlgr/cli/render.py +418 -0
- tlgr/core/__init__.py +0 -0
- tlgr/core/accounts.py +384 -0
- tlgr/core/config.py +358 -0
- tlgr/core/custom_tl.py +170 -0
- tlgr/core/errors.py +687 -0
- tlgr/core/eventtypes.py +1170 -0
- tlgr/core/identity.py +127 -0
- tlgr/core/launchd.py +122 -0
- tlgr/core/logging.py +194 -0
- tlgr/core/media.py +134 -0
- tlgr/core/output.py +251 -0
- tlgr/core/pagination.py +227 -0
- tlgr/core/paths.py +360 -0
- tlgr/core/peers.py +427 -0
- tlgr/core/process.py +138 -0
- tlgr/core/signing.py +38 -0
- tlgr/core/systemd.py +96 -0
- tlgr/core/telethon_compat.py +295 -0
- tlgr/core/text.py +211 -0
- tlgr/core/timefmt.py +199 -0
- tlgr/core/tl.py +98 -0
- tlgr/daemon/__init__.py +0 -0
- tlgr/daemon/app.py +869 -0
- tlgr/daemon/dispatch.py +446 -0
- tlgr/daemon/events.py +723 -0
- tlgr/daemon/files.py +431 -0
- tlgr/daemon/idle.py +119 -0
- tlgr/daemon/jobs.py +68 -0
- tlgr/daemon/main.py +161 -0
- tlgr/daemon/peercred.py +75 -0
- tlgr/daemon/policy.py +113 -0
- tlgr/daemon/preauth.py +366 -0
- tlgr/daemon/ratelimit.py +391 -0
- tlgr/daemon/server.py +24 -0
- tlgr/daemon/session.py +648 -0
- tlgr/daemon/sessions.py +274 -0
- tlgr/daemon/singleton.py +114 -0
- tlgr/daemon/stream.py +193 -0
- tlgr/daemon/transfers.py +219 -0
- tlgr/daemon/webhook.py +390 -0
- tlgr/data/catalog_index.json +1 -0
- tlgr/data/parity_waivers.toml +90 -0
- tlgr/filters/__init__.py +42 -0
- tlgr/filters/compose.py +121 -0
- tlgr/filters/content.py +85 -0
- tlgr/filters/context.py +114 -0
- tlgr/filters/message.py +161 -0
- tlgr/filters/temporal.py +87 -0
- tlgr/filters/user.py +36 -0
- tlgr/gateway/__init__.py +1 -0
- tlgr/gateway/config.py +161 -0
- tlgr/gateway/engine.py +215 -0
- tlgr/gateway/event.py +22 -0
- tlgr/jobs/__init__.py +0 -0
- tlgr/jobs/base.py +81 -0
- tlgr/jobs/client.py +37 -0
- tlgr/models/__init__.py +1220 -0
- tlgr/models/admin.py +744 -0
- tlgr/models/auth.py +510 -0
- tlgr/models/base.py +81 -0
- tlgr/models/bot.py +576 -0
- tlgr/models/business.py +265 -0
- tlgr/models/call.py +586 -0
- tlgr/models/config.py +101 -0
- tlgr/models/contact.py +481 -0
- tlgr/models/daemon.py +336 -0
- tlgr/models/dialog.py +626 -0
- tlgr/models/envelope.py +68 -0
- tlgr/models/error.py +30 -0
- tlgr/models/event.py +79 -0
- tlgr/models/export.py +66 -0
- tlgr/models/gift.py +275 -0
- tlgr/models/inline.py +84 -0
- tlgr/models/location.py +115 -0
- tlgr/models/media.py +507 -0
- tlgr/models/message.py +584 -0
- tlgr/models/net.py +232 -0
- tlgr/models/notify.py +105 -0
- tlgr/models/page.py +32 -0
- tlgr/models/payment.py +172 -0
- tlgr/models/peer.py +400 -0
- tlgr/models/poll.py +119 -0
- tlgr/models/premium.py +161 -0
- tlgr/models/privacy.py +93 -0
- tlgr/models/profile.py +217 -0
- tlgr/models/reaction.py +160 -0
- tlgr/models/resolve.py +175 -0
- tlgr/models/settings.py +103 -0
- tlgr/models/stars.py +101 -0
- tlgr/models/sticker.py +243 -0
- tlgr/models/story.py +467 -0
- tlgr/models/sync.py +105 -0
- tlgr/models/todo.py +36 -0
- tlgr/models/webapp.py +89 -0
- tlgr/ops/__init__.py +63 -0
- tlgr/ops/_admin.py +313 -0
- tlgr/ops/_auth.py +599 -0
- tlgr/ops/_bots.py +586 -0
- tlgr/ops/_calls.py +535 -0
- tlgr/ops/_common.py +160 -0
- tlgr/ops/_layer.py +46 -0
- tlgr/ops/_media.py +592 -0
- tlgr/ops/_params.py +212 -0
- tlgr/ops/_rights.py +402 -0
- tlgr/ops/_send.py +593 -0
- tlgr/ops/_serialize.py +667 -0
- tlgr/ops/_settings.py +306 -0
- tlgr/ops/_spec.py +167 -0
- tlgr/ops/_story.py +743 -0
- tlgr/ops/account.py +2604 -0
- tlgr/ops/agent.py +937 -0
- tlgr/ops/auth.py +1282 -0
- tlgr/ops/bot.py +4880 -0
- tlgr/ops/business.py +1520 -0
- tlgr/ops/call.py +1610 -0
- tlgr/ops/chat.py +4025 -0
- tlgr/ops/chat_admin.py +929 -0
- tlgr/ops/chat_extra.py +1061 -0
- tlgr/ops/chat_invite.py +716 -0
- tlgr/ops/chat_manage.py +1691 -0
- tlgr/ops/chat_member.py +1357 -0
- tlgr/ops/chat_stats.py +902 -0
- tlgr/ops/chat_topic.py +905 -0
- tlgr/ops/conference.py +791 -0
- tlgr/ops/config.py +1698 -0
- tlgr/ops/contact.py +2330 -0
- tlgr/ops/daemon.py +1397 -0
- tlgr/ops/draft.py +299 -0
- tlgr/ops/emoji.py +343 -0
- tlgr/ops/events.py +1327 -0
- tlgr/ops/export.py +596 -0
- tlgr/ops/folder.py +1322 -0
- tlgr/ops/gif.py +522 -0
- tlgr/ops/gift.py +1546 -0
- tlgr/ops/giveaway.py +541 -0
- tlgr/ops/inline.py +773 -0
- tlgr/ops/job.py +799 -0
- tlgr/ops/location.py +917 -0
- tlgr/ops/media.py +4495 -0
- tlgr/ops/message.py +3769 -0
- tlgr/ops/net.py +536 -0
- tlgr/ops/notify.py +840 -0
- tlgr/ops/passport.py +464 -0
- tlgr/ops/payment.py +907 -0
- tlgr/ops/poll.py +1078 -0
- tlgr/ops/premium.py +488 -0
- tlgr/ops/privacy.py +794 -0
- tlgr/ops/profile.py +1481 -0
- tlgr/ops/proxy.py +750 -0
- tlgr/ops/reaction.py +1475 -0
- tlgr/ops/resolve.py +1140 -0
- tlgr/ops/search.py +521 -0
- tlgr/ops/settings.py +1066 -0
- tlgr/ops/stars.py +594 -0
- tlgr/ops/sticker.py +1602 -0
- tlgr/ops/story.py +3216 -0
- tlgr/ops/sync.py +788 -0
- tlgr/ops/todo.py +514 -0
- tlgr/ops/user.py +1406 -0
- tlgr/ops/vc.py +2351 -0
- tlgr/ops/webapp.py +717 -0
- tlgr/ops/webhook.py +418 -0
- tlgr/parity.py +386 -0
- tlgr/processors/__init__.py +125 -0
- tlgr/processors/regex.py +26 -0
- tlgr/processors/text.py +56 -0
- tlgr/registry.py +519 -0
- tlgr/schema.py +173 -0
- tlgr/transport/__init__.py +30 -0
- tlgr/transport/autostart.py +293 -0
- tlgr/transport/client.py +805 -0
- tlgr/transport/ndjson.py +44 -0
- tlgr/version.py +31 -0
- tlgr_cli-2.0.1.dist-info/METADATA +957 -0
- tlgr_cli-2.0.1.dist-info/RECORD +192 -0
- tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
- tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
- tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
- tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/ops/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
|