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/conference.py
ADDED
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
"""The `conference` group: call links, and the E2E calls behind them.
|
|
2
|
+
|
|
3
|
+
A conference is a group call that belongs to no chat: you create it, you get a
|
|
4
|
+
`t.me/call/<slug>` link, and anyone with the link can join. Half of that is
|
|
5
|
+
the easiest surface in this PR and half of it is the hardest, and the split is
|
|
6
|
+
worth stating plainly because it decides what every command here can do.
|
|
7
|
+
|
|
8
|
+
* **No crypto needed:** creating a link, reading a conference by link or by
|
|
9
|
+
invitation message, ringing people into it, declining an invitation,
|
|
10
|
+
revoking the link, listing participants, ending it. All fully implemented.
|
|
11
|
+
* **Crypto needed:** *joining*, *removing* somebody, and sending anything
|
|
12
|
+
inside. Conferences are end-to-end encrypted: the group state and the shared
|
|
13
|
+
key live in an `e2e.chain` blockchain, and every one of those operations
|
|
14
|
+
requires a signed block built on the current tip. Writing that block builder
|
|
15
|
+
is a project on the scale of the rest of tlgr. So tlgr **carries** blocks
|
|
16
|
+
(`--block`, `--encrypted-blob`, `--broadcast-block`) and **reads** the chain
|
|
17
|
+
(`conference chain list`), and refuses, by name and up front, to pretend it
|
|
18
|
+
can build one.
|
|
19
|
+
|
|
20
|
+
The refusal is a usage error rather than a server round trip on purpose: a
|
|
21
|
+
request that is going to fail should fail where the missing piece is, and the
|
|
22
|
+
message should name it.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import contextlib
|
|
28
|
+
import secrets
|
|
29
|
+
from typing import Annotated, Any, NoReturn
|
|
30
|
+
|
|
31
|
+
from tlgr.core.errors import NotFoundError, UsageError
|
|
32
|
+
from tlgr.core.pagination import PageKind, build_page, decode_cursor
|
|
33
|
+
from tlgr.models.base import Request
|
|
34
|
+
from tlgr.models.call import (
|
|
35
|
+
MEDIA_NONE,
|
|
36
|
+
ChainBlock,
|
|
37
|
+
ConferenceCreated,
|
|
38
|
+
ConferenceDeclined,
|
|
39
|
+
ConferenceInfo,
|
|
40
|
+
ConferenceInvited,
|
|
41
|
+
ConferenceRelayed,
|
|
42
|
+
ConferenceRemoved,
|
|
43
|
+
ConferenceRevoked,
|
|
44
|
+
)
|
|
45
|
+
from tlgr.models.page import Page
|
|
46
|
+
from tlgr.models.peer import Peer, PeerRef
|
|
47
|
+
from tlgr.ops import _calls, _send
|
|
48
|
+
from tlgr.ops._params import arg, opt
|
|
49
|
+
from tlgr.ops._spec import OpContext, OperationSpec
|
|
50
|
+
|
|
51
|
+
__all__ = [name for name in dir() if name.startswith("SPEC_")]
|
|
52
|
+
|
|
53
|
+
_EXAMPLE_REF: dict[str, Any] = {"id": 900100, "access_hash": 12345, "slug": "AbCdEf"}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _client(ctx: OpContext) -> Any:
|
|
57
|
+
client = getattr(ctx, "client", None)
|
|
58
|
+
if client is None: # pragma: no cover - the daemon always supplies one
|
|
59
|
+
raise UsageError("this operation needs a connected account")
|
|
60
|
+
return client
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _read_bytes(path: str, *, field: str) -> bytes:
|
|
64
|
+
from pathlib import Path
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
return Path(path).read_bytes()
|
|
68
|
+
except OSError as exc:
|
|
69
|
+
raise UsageError(f"--{field}: {exc.strerror or exc}", field=field) from exc
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _read_text(path: str, *, field: str) -> str:
|
|
73
|
+
from pathlib import Path
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
return Path(path).read_text(encoding="utf-8")
|
|
77
|
+
except OSError as exc:
|
|
78
|
+
raise UsageError(f"--{field}: {exc.strerror or exc}", field=field) from exc
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _public_key(value: str) -> int:
|
|
82
|
+
try:
|
|
83
|
+
return int(value, 16)
|
|
84
|
+
except ValueError as exc:
|
|
85
|
+
raise UsageError("--public-key must be hex", field="public_key") from exc
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _needs_block(what: str, field: str = "block") -> NoReturn:
|
|
89
|
+
"""The one refusal every E2E-gated command makes, worded the same way."""
|
|
90
|
+
raise UsageError(
|
|
91
|
+
f"{what} needs a signed e2e.chain block built on the current tip of subchain 0, "
|
|
92
|
+
"plus your int256 public key. Conferences are end-to-end encrypted and tlgr has "
|
|
93
|
+
"no block builder, so pass --block and --public-key from an external E2E "
|
|
94
|
+
"implementation; `conference chain list --tip` gives you the tip to build on",
|
|
95
|
+
field=field,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _call_of(result: Any) -> Any:
|
|
100
|
+
"""The `groupCall` in a `phone.GroupCall` or in an `Updates`."""
|
|
101
|
+
direct = getattr(result, "call", None)
|
|
102
|
+
if direct is not None and type(direct).__name__ in ("GroupCall", "GroupCallDiscarded"):
|
|
103
|
+
return direct
|
|
104
|
+
for update in getattr(result, "updates", None) or []:
|
|
105
|
+
call = getattr(update, "call", None)
|
|
106
|
+
if call is not None:
|
|
107
|
+
return call
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _slug_of(call: Any) -> str | None:
|
|
112
|
+
link = getattr(call, "invite_link", None)
|
|
113
|
+
return link.rsplit("/", 1)[-1] if link else None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# conference create
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class CreateReq(Request):
|
|
122
|
+
join: Annotated[bool, opt("--join", help="Also join it — needs the E2E material.")] = False
|
|
123
|
+
public_key: Annotated[
|
|
124
|
+
str | None, opt("--public-key", metavar="HEX", help="Your int256 E2E public key.")
|
|
125
|
+
] = None
|
|
126
|
+
block: Annotated[
|
|
127
|
+
str | None,
|
|
128
|
+
opt("--block", metavar="PATH", kind="path", help="Initial e2e.chain block for subchain 0."),
|
|
129
|
+
] = None
|
|
130
|
+
params_json: Annotated[
|
|
131
|
+
str | None,
|
|
132
|
+
opt("--params-json", metavar="PATH", kind="path", help="tgcalls join payload."),
|
|
133
|
+
] = None
|
|
134
|
+
muted: Annotated[bool, opt("--muted/--unmuted", help="Join muted.")] = True
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def create(ctx: OpContext, req: CreateReq) -> ConferenceCreated:
|
|
138
|
+
"""Create a call link: a conference call tied to no chat.
|
|
139
|
+
|
|
140
|
+
Without `--join` this is the easiest conference operation there is — one
|
|
141
|
+
RPC, no crypto — and it hands back `groupCall.invite_link`, the shareable
|
|
142
|
+
link. With `--join` the API additionally wants a fresh public key, a valid
|
|
143
|
+
initial chain block and a tgcalls payload, none of which tlgr can build.
|
|
144
|
+
"""
|
|
145
|
+
from telethon.tl import types
|
|
146
|
+
from telethon.tl.functions import phone as fn
|
|
147
|
+
|
|
148
|
+
if req.join and (not req.block or not req.public_key):
|
|
149
|
+
_needs_block("creating and joining a conference in one step")
|
|
150
|
+
|
|
151
|
+
result = await _client(ctx)(
|
|
152
|
+
fn.CreateConferenceCallRequest(
|
|
153
|
+
random_id=secrets.randbits(31),
|
|
154
|
+
join=req.join or None,
|
|
155
|
+
muted=req.muted or None,
|
|
156
|
+
public_key=_public_key(req.public_key) if req.public_key else None,
|
|
157
|
+
block=_read_bytes(req.block, field="block") if req.block else None,
|
|
158
|
+
params=types.DataJSON(data=_read_text(req.params_json, field="params-json"))
|
|
159
|
+
if req.params_json
|
|
160
|
+
else None,
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
call = _call_of(result)
|
|
164
|
+
if call is None:
|
|
165
|
+
raise NotFoundError("the server created a conference without naming it")
|
|
166
|
+
slug = _slug_of(call)
|
|
167
|
+
ref = _calls.call_ref_of(call, slug=slug)
|
|
168
|
+
ctx.emit("conference_created", {"call_id": ref.id, "slug": slug})
|
|
169
|
+
limits = await _calls.app_config(ctx)
|
|
170
|
+
cap = limits.get("conference_call_size_limit")
|
|
171
|
+
if cap:
|
|
172
|
+
ctx.warn(f"a conference holds at most {cap} participants")
|
|
173
|
+
return ConferenceCreated(
|
|
174
|
+
call=ref,
|
|
175
|
+
slug=slug,
|
|
176
|
+
invite_link=getattr(call, "invite_link", None),
|
|
177
|
+
creator=True,
|
|
178
|
+
joined=req.join,
|
|
179
|
+
media=MEDIA_NONE,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
SPEC_CREATE = OperationSpec(
|
|
184
|
+
id="conference.create",
|
|
185
|
+
request=CreateReq,
|
|
186
|
+
response=ConferenceCreated,
|
|
187
|
+
impl=create,
|
|
188
|
+
aliases=("conf.create",),
|
|
189
|
+
summary="Create a call link (a conference call not tied to any chat)",
|
|
190
|
+
mutating=True,
|
|
191
|
+
rate_class="send",
|
|
192
|
+
columns=("call.id", "slug", "invite_link"),
|
|
193
|
+
example={
|
|
194
|
+
"call": _EXAMPLE_REF,
|
|
195
|
+
"media": "none",
|
|
196
|
+
"joined": False,
|
|
197
|
+
"slug": "AbCdEf",
|
|
198
|
+
"invite_link": "https://t.me/call/AbCdEf",
|
|
199
|
+
},
|
|
200
|
+
example_args="conference create",
|
|
201
|
+
covers=("conference.create-link",),
|
|
202
|
+
covers_partial=("conference.create-and-join",),
|
|
203
|
+
coverage_note=(
|
|
204
|
+
"creating the link is complete; joining at creation needs a signed "
|
|
205
|
+
"e2e.chain block, which tlgr accepts (--block) but cannot build"
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
# conference get
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class GetReq(Request):
|
|
216
|
+
call: Annotated[
|
|
217
|
+
str,
|
|
218
|
+
arg(0, metavar="CALL", help="A call link, a slug, id:access_hash, or msg:<id>."),
|
|
219
|
+
]
|
|
220
|
+
qr: Annotated[bool, opt("--qr", help="Also return the link as QR payload text.")] = False
|
|
221
|
+
limits: Annotated[
|
|
222
|
+
bool, opt("--limits", help="Include the conference size and message caps.")
|
|
223
|
+
] = False
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def get(ctx: OpContext, req: GetReq) -> ConferenceInfo:
|
|
227
|
+
"""Inspect a conference by link, slug, id or invitation message.
|
|
228
|
+
|
|
229
|
+
The honest half of conference support: reading a call link needs no E2E
|
|
230
|
+
state at all. There is no `exportConferenceLink` method — the link *is*
|
|
231
|
+
`groupCall.invite_link`.
|
|
232
|
+
"""
|
|
233
|
+
from telethon.tl.functions import phone as fn
|
|
234
|
+
|
|
235
|
+
handle = await _calls.resolve_call(ctx, req.call)
|
|
236
|
+
result = await _client(ctx)(fn.GetGroupCallRequest(call=handle.input, limit=0))
|
|
237
|
+
call = getattr(result, "call", None)
|
|
238
|
+
if call is None:
|
|
239
|
+
raise NotFoundError("that call link does not resolve to a call")
|
|
240
|
+
slug = _slug_of(call) or handle.ref.slug
|
|
241
|
+
info = ConferenceInfo(
|
|
242
|
+
call=_calls.call_ref_of(call, slug=slug, msg_id=handle.ref.msg_id),
|
|
243
|
+
slug=slug,
|
|
244
|
+
invite_link=getattr(call, "invite_link", None),
|
|
245
|
+
participants_count=int(getattr(call, "participants_count", 0) or 0),
|
|
246
|
+
creator=bool(getattr(call, "creator", False)),
|
|
247
|
+
messages_enabled=bool(getattr(call, "messages_enabled", False)),
|
|
248
|
+
conference=bool(getattr(call, "conference", False)),
|
|
249
|
+
title=getattr(call, "title", None),
|
|
250
|
+
)
|
|
251
|
+
if req.limits:
|
|
252
|
+
config = await _calls.app_config(ctx)
|
|
253
|
+
info.limits = {k: int(v) for k, v in config.items() if isinstance(v, (int, float))}
|
|
254
|
+
if req.qr:
|
|
255
|
+
info.qr = info.invite_link
|
|
256
|
+
ctx.warn(
|
|
257
|
+
"tlgr does not bundle a QR encoder: `qr` is the exact text to encode, "
|
|
258
|
+
"pipe it into qrencode or any QR renderer"
|
|
259
|
+
)
|
|
260
|
+
return info
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
SPEC_GET = OperationSpec(
|
|
264
|
+
id="conference.get",
|
|
265
|
+
request=GetReq,
|
|
266
|
+
response=ConferenceInfo,
|
|
267
|
+
impl=get,
|
|
268
|
+
aliases=("conf.get", "conference.info"),
|
|
269
|
+
summary="Inspect a conference by link, slug, id or invitation message",
|
|
270
|
+
columns=("call.id", "slug", "participants_count", "messages_enabled"),
|
|
271
|
+
example={
|
|
272
|
+
"call": _EXAMPLE_REF,
|
|
273
|
+
"conference": True,
|
|
274
|
+
"slug": "AbCdEf",
|
|
275
|
+
"participants_count": 3,
|
|
276
|
+
},
|
|
277
|
+
example_args="conference get https://t.me/call/AbCdEf",
|
|
278
|
+
covers=("conference.get-link", "conference.join-by-slug", "conference.size-limit"),
|
|
279
|
+
covers_partial=("conference.link-qr",),
|
|
280
|
+
coverage_note=(
|
|
281
|
+
"`--qr` returns the exact text to encode; drawing the code needs a QR "
|
|
282
|
+
"encoder tlgr does not bundle"
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ---------------------------------------------------------------------------
|
|
288
|
+
# conference invite / decline
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class InviteReq(Request):
|
|
293
|
+
call: Annotated[str, arg(0, metavar="CALL", help="A call link, slug or id:access_hash.")]
|
|
294
|
+
user: Annotated[
|
|
295
|
+
list[PeerRef],
|
|
296
|
+
arg(1, metavar="USER", variadic=True, kind="user", help="Who to ring."),
|
|
297
|
+
] = []
|
|
298
|
+
video: Annotated[bool, opt("--video", help="Ring them as a video call.")] = False
|
|
299
|
+
fallback_link: Annotated[
|
|
300
|
+
bool, opt("--fallback-link", help="Report the link for users who cannot be invited.")
|
|
301
|
+
] = True
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
async def invite(ctx: OpContext, req: InviteReq) -> ConferenceInvited:
|
|
305
|
+
"""Ring people into a conference, falling back to the link.
|
|
306
|
+
|
|
307
|
+
The invitation is a `messageActionConferenceCall` service message and the
|
|
308
|
+
receiving client rings on it. Per-user outcomes are classified the way the
|
|
309
|
+
GUI does rather than collapsed into one failure, because "already in the
|
|
310
|
+
call" and "their privacy settings refuse you" call for different actions.
|
|
311
|
+
"""
|
|
312
|
+
from telethon import utils
|
|
313
|
+
from telethon.tl.functions import phone as fn
|
|
314
|
+
|
|
315
|
+
if not req.user:
|
|
316
|
+
raise UsageError("give at least one user to invite", field="user")
|
|
317
|
+
handle = await _calls.concrete_call(ctx, await _calls.resolve_call(ctx, req.call))
|
|
318
|
+
client = _client(ctx)
|
|
319
|
+
|
|
320
|
+
invited: list[Peer] = []
|
|
321
|
+
failed: list[dict[str, Any]] = []
|
|
322
|
+
for reference in req.user:
|
|
323
|
+
target = await _send.resolve(ctx, reference)
|
|
324
|
+
marked = _send.peer_id_of(target)
|
|
325
|
+
model = Peer(id=marked, raw_id=abs(marked), kind="user")
|
|
326
|
+
try:
|
|
327
|
+
await client(
|
|
328
|
+
fn.InviteConferenceCallParticipantRequest(
|
|
329
|
+
call=handle.input,
|
|
330
|
+
user_id=utils.get_input_user(target),
|
|
331
|
+
video=req.video or None,
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
invited.append(model)
|
|
335
|
+
except Exception as exc:
|
|
336
|
+
text = f"{type(exc).__name__} {exc}".upper().replace("_", "")
|
|
337
|
+
if "ALREADYPARTICIPANT" in text:
|
|
338
|
+
reason = "already-in-call"
|
|
339
|
+
elif "PRIVACY" in text:
|
|
340
|
+
reason = "privacy-restricted"
|
|
341
|
+
elif "KICKED" in text or "BANNED" in text:
|
|
342
|
+
reason = "kicked"
|
|
343
|
+
else:
|
|
344
|
+
raise
|
|
345
|
+
failed.append({"peer": model.id, "reason": reason})
|
|
346
|
+
|
|
347
|
+
result = ConferenceInvited(invited=invited, failed=failed)
|
|
348
|
+
if failed and req.fallback_link:
|
|
349
|
+
with contextlib.suppress(Exception):
|
|
350
|
+
info = await client(fn.GetGroupCallRequest(call=handle.input, limit=0))
|
|
351
|
+
result.link = getattr(getattr(info, "call", None), "invite_link", None)
|
|
352
|
+
if result.link:
|
|
353
|
+
ctx.warn(
|
|
354
|
+
"some users could not be rung; send them the link yourself with "
|
|
355
|
+
"`message send`, which asks before it writes on your behalf"
|
|
356
|
+
)
|
|
357
|
+
ctx.emit("conference_invited", {"call_id": handle.ref.id, "count": len(invited)})
|
|
358
|
+
return result
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
SPEC_INVITE = OperationSpec(
|
|
362
|
+
id="conference.invite",
|
|
363
|
+
request=InviteReq,
|
|
364
|
+
response=ConferenceInvited,
|
|
365
|
+
impl=invite,
|
|
366
|
+
aliases=("conf.invite",),
|
|
367
|
+
summary="Ring somebody into a conference, falling back to sending them the link",
|
|
368
|
+
mutating=True,
|
|
369
|
+
rate_class="send",
|
|
370
|
+
columns=("invited", "failed", "link"),
|
|
371
|
+
example={"invited": [{"id": 4242, "raw_id": 4242, "kind": "user"}], "failed": []},
|
|
372
|
+
example_args="conference invite AbCdEf @alice",
|
|
373
|
+
covers=("conference.invite-link-fallback", "conference.invite-user"),
|
|
374
|
+
tags=frozenset({"visible-to-others"}),
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class DeclineReq(Request):
|
|
379
|
+
msg_id: Annotated[int, arg(0, metavar="MSG_ID", help="The invitation service message id.")]
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
async def decline(ctx: OpContext, req: DeclineReq) -> ConferenceDeclined:
|
|
383
|
+
"""Decline a conference invitation, or stop ringing somebody you invited.
|
|
384
|
+
|
|
385
|
+
One RPC for both directions. No chat is needed because
|
|
386
|
+
`messageActionConferenceCall` only occurs in private chats, which share
|
|
387
|
+
one id sequence.
|
|
388
|
+
"""
|
|
389
|
+
from telethon.tl.functions import phone as fn
|
|
390
|
+
|
|
391
|
+
await _client(ctx)(fn.DeclineConferenceCallInviteRequest(msg_id=req.msg_id))
|
|
392
|
+
ctx.emit("conference_declined", {"msg_id": req.msg_id})
|
|
393
|
+
return ConferenceDeclined(msg_id=req.msg_id, declined=True)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
SPEC_DECLINE = OperationSpec(
|
|
397
|
+
id="conference.decline",
|
|
398
|
+
request=DeclineReq,
|
|
399
|
+
response=ConferenceDeclined,
|
|
400
|
+
impl=decline,
|
|
401
|
+
aliases=("conf.decline",),
|
|
402
|
+
summary="Decline a conference invitation, or stop ringing somebody you invited",
|
|
403
|
+
mutating=True,
|
|
404
|
+
idempotent=True,
|
|
405
|
+
rate_class="send",
|
|
406
|
+
columns=("msg_id", "declined"),
|
|
407
|
+
example={"msg_id": 900, "declined": True},
|
|
408
|
+
example_args="conference decline 900",
|
|
409
|
+
covers=("conference.decline-invite", "groupcall.stop-ringing"),
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# ---------------------------------------------------------------------------
|
|
414
|
+
# conference join
|
|
415
|
+
# ---------------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class JoinReq(Request):
|
|
419
|
+
call: Annotated[str, arg(0, metavar="CALL", help="A call link, slug, id or msg:<id>.")]
|
|
420
|
+
public_key: Annotated[
|
|
421
|
+
str | None, opt("--public-key", metavar="HEX", help="Your int256 E2E public key.")
|
|
422
|
+
] = None
|
|
423
|
+
block: Annotated[
|
|
424
|
+
str | None,
|
|
425
|
+
opt("--block", metavar="PATH", kind="path", help="Join block built on the current tip."),
|
|
426
|
+
] = None
|
|
427
|
+
params_json: Annotated[
|
|
428
|
+
str | None,
|
|
429
|
+
opt("--params-json", metavar="PATH", kind="path", help="tgcalls join payload."),
|
|
430
|
+
] = None
|
|
431
|
+
muted: Annotated[bool, opt("--muted/--unmuted", help="Join muted.")] = True
|
|
432
|
+
video_stopped: Annotated[bool, opt("--video-stopped", help="Join with video off.")] = True
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
async def join(ctx: OpContext, req: JoinReq) -> ConferenceCreated:
|
|
436
|
+
"""Join a conference — which needs E2E material tlgr cannot generate.
|
|
437
|
+
|
|
438
|
+
Unlike `vc join`, this is not usable with a synthetic payload: joining a
|
|
439
|
+
conference means fetching the tip of subchain 0, building a signed join
|
|
440
|
+
block on top of it, and retrying whenever the server answers
|
|
441
|
+
`CONF_WRITE_CHAIN_INVALID`. The command exists so a bridge, or a user with
|
|
442
|
+
an external block builder, has a place to plug in; without `--block` and
|
|
443
|
+
`--public-key` it stops with an explanation rather than an RPC failure.
|
|
444
|
+
`join_as` is always yourself for a conference.
|
|
445
|
+
"""
|
|
446
|
+
from telethon.tl import types
|
|
447
|
+
from telethon.tl.functions import phone as fn
|
|
448
|
+
|
|
449
|
+
if not req.block or not req.public_key:
|
|
450
|
+
_needs_block("joining a conference")
|
|
451
|
+
if not req.params_json:
|
|
452
|
+
raise UsageError(
|
|
453
|
+
"joining also needs a tgcalls join payload (--params-json) from a real "
|
|
454
|
+
"media engine; tlgr has none",
|
|
455
|
+
field="params_json",
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
handle = await _calls.concrete_call(ctx, await _calls.resolve_call(ctx, req.call))
|
|
459
|
+
result = await _client(ctx)(
|
|
460
|
+
fn.JoinGroupCallRequest(
|
|
461
|
+
call=handle.input,
|
|
462
|
+
join_as=types.InputPeerSelf(),
|
|
463
|
+
params=types.DataJSON(data=_read_text(req.params_json, field="params-json")),
|
|
464
|
+
muted=req.muted or None,
|
|
465
|
+
video_stopped=req.video_stopped or None,
|
|
466
|
+
public_key=_public_key(req.public_key),
|
|
467
|
+
block=_read_bytes(req.block, field="block"),
|
|
468
|
+
)
|
|
469
|
+
)
|
|
470
|
+
call = _call_of(result)
|
|
471
|
+
ref = _calls.call_ref_of(call) if call is not None else handle.ref
|
|
472
|
+
ctx.emit("conference_joined", {"call_id": ref.id})
|
|
473
|
+
ctx.warn("tlgr carries no media: this is server-side presence in an E2E call")
|
|
474
|
+
return ConferenceCreated(
|
|
475
|
+
call=ref,
|
|
476
|
+
slug=handle.ref.slug,
|
|
477
|
+
invite_link=getattr(call, "invite_link", None),
|
|
478
|
+
creator=False,
|
|
479
|
+
joined=True,
|
|
480
|
+
media=MEDIA_NONE,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
SPEC_JOIN = OperationSpec(
|
|
485
|
+
id="conference.join",
|
|
486
|
+
request=JoinReq,
|
|
487
|
+
response=ConferenceCreated,
|
|
488
|
+
impl=join,
|
|
489
|
+
aliases=("conf.join",),
|
|
490
|
+
summary="Join a conference call (requires E2E material tlgr cannot generate)",
|
|
491
|
+
mutating=True,
|
|
492
|
+
rate_class="send",
|
|
493
|
+
columns=("call.id", "joined", "media"),
|
|
494
|
+
example={"call": _EXAMPLE_REF, "joined": True, "media": "none"},
|
|
495
|
+
example_args=(
|
|
496
|
+
"conference join AbCdEf --block /tmp/b.bin --public-key ff --params-json /tmp/p.json"
|
|
497
|
+
),
|
|
498
|
+
covers_partial=("conference.join-by-invite-message",),
|
|
499
|
+
coverage_note=(
|
|
500
|
+
"the request is built and sent; the signed join block it needs is an "
|
|
501
|
+
"e2e.chain builder tlgr does not have and accepts from outside instead"
|
|
502
|
+
),
|
|
503
|
+
tags=frozenset({"visible-to-others"}),
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
# ---------------------------------------------------------------------------
|
|
508
|
+
# conference remove / revoke
|
|
509
|
+
# ---------------------------------------------------------------------------
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
class RemoveReq(Request):
|
|
513
|
+
call: Annotated[str, arg(0, metavar="CALL", help="A call link, slug or id:access_hash.")]
|
|
514
|
+
user: Annotated[
|
|
515
|
+
list[PeerRef],
|
|
516
|
+
arg(1, metavar="USER", required=False, variadic=True, kind="user", help="Who to remove."),
|
|
517
|
+
] = []
|
|
518
|
+
left_only: Annotated[
|
|
519
|
+
bool, opt("--left-only", help="Prune participants that already dropped off.")
|
|
520
|
+
] = False
|
|
521
|
+
block: Annotated[
|
|
522
|
+
str | None,
|
|
523
|
+
opt("--block", metavar="PATH", kind="path", help="Removal block that rotates the key."),
|
|
524
|
+
] = None
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
async def remove(ctx: OpContext, req: RemoveReq) -> ConferenceRemoved:
|
|
528
|
+
"""Remove conference participants, or prune the ones that already left.
|
|
529
|
+
|
|
530
|
+
`phone.deleteConferenceCallParticipants` always needs a valid block that
|
|
531
|
+
takes the users out of the group state *and rotates the shared key* —
|
|
532
|
+
otherwise the people you removed could still decrypt. tlgr will not send
|
|
533
|
+
that request without one. `--left-only` is the housekeeping pass real
|
|
534
|
+
clients run by themselves. You cannot remove yourself this way: use
|
|
535
|
+
`vc leave`.
|
|
536
|
+
"""
|
|
537
|
+
from telethon import utils
|
|
538
|
+
from telethon.tl.functions import phone as fn
|
|
539
|
+
|
|
540
|
+
if not req.block:
|
|
541
|
+
_needs_block("removing a conference participant")
|
|
542
|
+
if not req.user and not req.left_only:
|
|
543
|
+
raise UsageError("give users to remove, or --left-only to prune", field="user")
|
|
544
|
+
|
|
545
|
+
handle = await _calls.concrete_call(ctx, await _calls.resolve_call(ctx, req.call))
|
|
546
|
+
removed: list[Peer] = []
|
|
547
|
+
ids: list[int] = []
|
|
548
|
+
for reference in req.user:
|
|
549
|
+
target = await _send.resolve(ctx, reference)
|
|
550
|
+
ids.append(int(utils.get_input_user(target).user_id))
|
|
551
|
+
marked = _send.peer_id_of(target)
|
|
552
|
+
removed.append(Peer(id=marked, raw_id=abs(marked), kind="user"))
|
|
553
|
+
|
|
554
|
+
await _client(ctx)(
|
|
555
|
+
fn.DeleteConferenceCallParticipantsRequest(
|
|
556
|
+
call=handle.input,
|
|
557
|
+
ids=ids,
|
|
558
|
+
block=_read_bytes(req.block, field="block"),
|
|
559
|
+
only_left=req.left_only or None,
|
|
560
|
+
kick=bool(req.user) or None,
|
|
561
|
+
)
|
|
562
|
+
)
|
|
563
|
+
ctx.emit("conference_removed", {"call_id": handle.ref.id, "count": len(ids)})
|
|
564
|
+
return ConferenceRemoved(
|
|
565
|
+
call=handle.ref, removed=removed, only_left=req.left_only, kicked=bool(req.user)
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
SPEC_REMOVE = OperationSpec(
|
|
570
|
+
id="conference.remove",
|
|
571
|
+
request=RemoveReq,
|
|
572
|
+
response=ConferenceRemoved,
|
|
573
|
+
impl=remove,
|
|
574
|
+
aliases=("conf.remove", "conference.kick"),
|
|
575
|
+
summary="Remove conference participants, or prune the ones that already left",
|
|
576
|
+
description="Needs the `remove_users` chain permission and a key-rotating block.",
|
|
577
|
+
mutating=True,
|
|
578
|
+
destructive=True,
|
|
579
|
+
rate_class="send",
|
|
580
|
+
columns=("call.id", "removed", "only_left"),
|
|
581
|
+
example={"call": _EXAMPLE_REF, "only_left": True},
|
|
582
|
+
example_args="conference remove AbCdEf --left-only --block /tmp/b.bin",
|
|
583
|
+
covers_partial=("conference.kick-participant", "conference.prune-left"),
|
|
584
|
+
coverage_note=(
|
|
585
|
+
"the request is built and sent; the removal block that rotates the shared "
|
|
586
|
+
"key is an e2e.chain builder tlgr does not have and accepts from outside"
|
|
587
|
+
),
|
|
588
|
+
tags=frozenset({"visible-to-others"}),
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
class RevokeReq(Request):
|
|
593
|
+
call: Annotated[str, arg(0, metavar="CALL", help="A call link, slug or id:access_hash.")]
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
async def revoke(ctx: OpContext, req: RevokeReq) -> ConferenceRevoked:
|
|
597
|
+
"""Revoke a conference's call link. People already in the call stay."""
|
|
598
|
+
from telethon.tl.functions import phone as fn
|
|
599
|
+
|
|
600
|
+
handle = await _calls.concrete_call(ctx, await _calls.resolve_call(ctx, req.call))
|
|
601
|
+
await _client(ctx)(fn.ToggleGroupCallSettingsRequest(call=handle.input, reset_invite_hash=True))
|
|
602
|
+
ctx.emit("conference_revoked", {"call_id": handle.ref.id})
|
|
603
|
+
return ConferenceRevoked(call=handle.ref, revoked=True)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
SPEC_REVOKE = OperationSpec(
|
|
607
|
+
id="conference.revoke",
|
|
608
|
+
request=RevokeReq,
|
|
609
|
+
response=ConferenceRevoked,
|
|
610
|
+
impl=revoke,
|
|
611
|
+
aliases=("conf.revoke", "conference.link.revoke"),
|
|
612
|
+
summary="Revoke a conference's call link",
|
|
613
|
+
description="Creator only. Nobody new can join with the old link.",
|
|
614
|
+
mutating=True,
|
|
615
|
+
destructive=True,
|
|
616
|
+
rate_class="send",
|
|
617
|
+
columns=("call.id", "revoked"),
|
|
618
|
+
example={"call": _EXAMPLE_REF, "revoked": True},
|
|
619
|
+
example_args="conference revoke AbCdEf",
|
|
620
|
+
covers=("conference.revoke-link",),
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
# ---------------------------------------------------------------------------
|
|
625
|
+
# conference send
|
|
626
|
+
# ---------------------------------------------------------------------------
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
class SendReq(Request):
|
|
630
|
+
call: Annotated[str, arg(0, metavar="CALL", help="A call link, slug or id:access_hash.")]
|
|
631
|
+
encrypted_blob: Annotated[
|
|
632
|
+
str | None,
|
|
633
|
+
opt("--encrypted-blob", metavar="PATH", kind="path", help="Pre-encrypted payload."),
|
|
634
|
+
] = None
|
|
635
|
+
broadcast_block: Annotated[
|
|
636
|
+
str | None,
|
|
637
|
+
opt(
|
|
638
|
+
"--broadcast-block",
|
|
639
|
+
metavar="PATH",
|
|
640
|
+
kind="path",
|
|
641
|
+
help="Serialized e2e.chain broadcast block for subchain 1.",
|
|
642
|
+
),
|
|
643
|
+
] = None
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
async def send(ctx: OpContext, req: SendReq) -> ConferenceRelayed:
|
|
647
|
+
"""Relay an E2E-encrypted in-call message, or a verification broadcast.
|
|
648
|
+
|
|
649
|
+
A relay, not a composer. Conference chat is end-to-end encrypted, so tlgr
|
|
650
|
+
can carry a payload and cannot produce one: it has no shared key (that
|
|
651
|
+
comes from the chain) and no block builder. The same is true of the emoji
|
|
652
|
+
verification, which is a commit-reveal broadcast on subchain 1. Incoming
|
|
653
|
+
messages surface as opaque blobs in `vc watch`.
|
|
654
|
+
"""
|
|
655
|
+
from telethon.tl.functions import phone as fn
|
|
656
|
+
|
|
657
|
+
handle = await _calls.concrete_call(ctx, await _calls.resolve_call(ctx, req.call))
|
|
658
|
+
if not req.encrypted_blob and not req.broadcast_block:
|
|
659
|
+
raise UsageError(
|
|
660
|
+
"give --encrypted-blob or --broadcast-block: conference messages are "
|
|
661
|
+
"end-to-end encrypted and tlgr has no shared key to encrypt with",
|
|
662
|
+
field="encrypted_blob",
|
|
663
|
+
)
|
|
664
|
+
kind = "encrypted-message"
|
|
665
|
+
if req.encrypted_blob:
|
|
666
|
+
await _client(ctx)(
|
|
667
|
+
fn.SendGroupCallEncryptedMessageRequest(
|
|
668
|
+
call=handle.input,
|
|
669
|
+
encrypted_message=_read_bytes(req.encrypted_blob, field="encrypted-blob"),
|
|
670
|
+
)
|
|
671
|
+
)
|
|
672
|
+
if req.broadcast_block:
|
|
673
|
+
await _client(ctx)(
|
|
674
|
+
fn.SendConferenceCallBroadcastRequest(
|
|
675
|
+
call=handle.input, block=_read_bytes(req.broadcast_block, field="broadcast-block")
|
|
676
|
+
)
|
|
677
|
+
)
|
|
678
|
+
kind = "broadcast" if not req.encrypted_blob else "both"
|
|
679
|
+
return ConferenceRelayed(call=handle.ref, kind=kind, sent=True)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
SPEC_SEND = OperationSpec(
|
|
683
|
+
id="conference.send",
|
|
684
|
+
request=SendReq,
|
|
685
|
+
response=ConferenceRelayed,
|
|
686
|
+
impl=send,
|
|
687
|
+
aliases=("conf.send",),
|
|
688
|
+
summary="Send an E2E-encrypted in-call message, or a key-verification broadcast",
|
|
689
|
+
mutating=True,
|
|
690
|
+
rate_class="send",
|
|
691
|
+
columns=("call.id", "kind", "sent"),
|
|
692
|
+
example={"call": _EXAMPLE_REF, "kind": "encrypted-message", "sent": True},
|
|
693
|
+
example_args="conference send AbCdEf --encrypted-blob /tmp/msg.bin",
|
|
694
|
+
covers_partial=("conference.broadcast-nonce", "conference.encrypted-message"),
|
|
695
|
+
coverage_note=(
|
|
696
|
+
"tlgr is the transport: it carries a payload an external E2E "
|
|
697
|
+
"implementation produced, and cannot encrypt or sign one itself"
|
|
698
|
+
),
|
|
699
|
+
tags=frozenset({"visible-to-others"}),
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
# ---------------------------------------------------------------------------
|
|
704
|
+
# conference chain list
|
|
705
|
+
# ---------------------------------------------------------------------------
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
class ChainListReq(Request):
|
|
709
|
+
call: Annotated[str, arg(0, metavar="CALL", help="A call link, slug or id:access_hash.")]
|
|
710
|
+
subchain: Annotated[
|
|
711
|
+
int, opt("--subchain", metavar="0|1", help="0 = group state, 1 = broadcasts.", ge=0, le=1)
|
|
712
|
+
] = 0
|
|
713
|
+
tip: Annotated[bool, opt("--tip", help="Only the latest block.")] = False
|
|
714
|
+
offset: Annotated[
|
|
715
|
+
int, opt("--offset", metavar="N", help="Start at this height; -1 is the tip.")
|
|
716
|
+
] = 0
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
async def chain_list(ctx: OpContext, req: ChainListReq) -> Page[ChainBlock]:
|
|
720
|
+
"""Read the conference's E2E blockchain.
|
|
721
|
+
|
|
722
|
+
Fetching and dumping blocks is easy; validating or creating them is not,
|
|
723
|
+
so the blocks come out as base64 with their heights and nothing else. That
|
|
724
|
+
is exactly what an external E2E implementation needs from a transport, and
|
|
725
|
+
it is what `conference join --block` is built on top of.
|
|
726
|
+
"""
|
|
727
|
+
from telethon.tl.functions import phone as fn
|
|
728
|
+
|
|
729
|
+
limit = min(int(getattr(ctx, "limit", None) or 30), 100)
|
|
730
|
+
token = getattr(ctx, "cursor", None)
|
|
731
|
+
state = (
|
|
732
|
+
decode_cursor(token, op="conference.chain.list", kind=PageKind.LOCAL, account=ctx.account)
|
|
733
|
+
if token
|
|
734
|
+
else {}
|
|
735
|
+
)
|
|
736
|
+
offset = int(state.get("offset", req.offset) or 0)
|
|
737
|
+
if req.tip:
|
|
738
|
+
offset, limit = -1, 1
|
|
739
|
+
|
|
740
|
+
handle = await _calls.concrete_call(ctx, await _calls.resolve_call(ctx, req.call))
|
|
741
|
+
result = await _client(ctx)(
|
|
742
|
+
fn.GetGroupCallChainBlocksRequest(
|
|
743
|
+
call=handle.input, sub_chain_id=req.subchain, offset=offset, limit=limit
|
|
744
|
+
)
|
|
745
|
+
)
|
|
746
|
+
raw_blocks: list[Any] = []
|
|
747
|
+
for update in getattr(result, "updates", None) or []:
|
|
748
|
+
raw_blocks.extend(getattr(update, "blocks", None) or [])
|
|
749
|
+
next_offset = int(getattr(update, "next_offset", 0) or 0)
|
|
750
|
+
break
|
|
751
|
+
else:
|
|
752
|
+
next_offset = offset + len(raw_blocks)
|
|
753
|
+
|
|
754
|
+
start = max(0, next_offset - len(raw_blocks))
|
|
755
|
+
items = [
|
|
756
|
+
ChainBlock(
|
|
757
|
+
sub_chain_id=req.subchain,
|
|
758
|
+
height=start + index,
|
|
759
|
+
block=_calls.b64(bytes(block)),
|
|
760
|
+
next_offset=next_offset,
|
|
761
|
+
)
|
|
762
|
+
for index, block in enumerate(raw_blocks)
|
|
763
|
+
]
|
|
764
|
+
return build_page(
|
|
765
|
+
items,
|
|
766
|
+
op="conference.chain.list",
|
|
767
|
+
kind=PageKind.LOCAL,
|
|
768
|
+
state={"offset": next_offset},
|
|
769
|
+
account=ctx.account,
|
|
770
|
+
has_more=bool(items) and not req.tip and len(items) >= limit,
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
SPEC_CHAIN_LIST = OperationSpec(
|
|
775
|
+
id="conference.chain.list",
|
|
776
|
+
request=ChainListReq,
|
|
777
|
+
response=Page[ChainBlock],
|
|
778
|
+
impl=chain_list,
|
|
779
|
+
aliases=("conf.chain.list",),
|
|
780
|
+
summary="Read the conference's E2E blockchain",
|
|
781
|
+
description=(
|
|
782
|
+
"Blocks are base64 and unvalidated: tlgr is a transport for an "
|
|
783
|
+
"external E2E implementation, not a participant in the protocol."
|
|
784
|
+
),
|
|
785
|
+
paginated=PageKind.LOCAL,
|
|
786
|
+
columns=("sub_chain_id", "height", "next_offset"),
|
|
787
|
+
headers=("Chain", "Height", "Next"),
|
|
788
|
+
example={"items": [{"sub_chain_id": 0, "height": 12, "block": "AA=="}], "has_more": False},
|
|
789
|
+
example_args="conference chain list AbCdEf --tip",
|
|
790
|
+
covers=("conference.chain-blocks",),
|
|
791
|
+
)
|