tlgr-cli 2.0.1__py3-none-any.whl

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