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
@@ -0,0 +1,716 @@
1
+ """`chat invite *` and `chat join`: links in, and the queue behind them.
2
+
3
+ An invite link is two different objects depending on which side of it you
4
+ stand on, and conflating them is how a CLI ends up with one command that
5
+ sometimes needs admin rights and sometimes does not. Here the split is
6
+ explicit: `chat invite get <chat> <link>` inspects a link *you* own
7
+ (`messages.getExportedChatInvite`, needs `invite-users`), while `chat invite
8
+ get <link>` previews a link somebody handed you
9
+ (`messages.checkChatInvite`, needs nothing).
10
+
11
+ Two behaviours are worth knowing before scripting against this module.
12
+
13
+ * **Editing the permanent link replaces it.** `messages.editExportedChatInvite`
14
+ may answer with `exportedChatInviteReplaced`, and both links are reported
15
+ (`link` plus `replaced_link`) rather than only the new one.
16
+ * **Joining has three successful outcomes.** Joined, already a member
17
+ (`already: true`), and request-sent (`pending_approval: true`). All three
18
+ exit 0; only an expired or invalid hash is an error.
19
+
20
+ Telethon is imported inside functions, never at module scope (§2.2).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Annotated, Any
26
+
27
+ from tlgr.core.errors import EXIT_EMPTY, NotFoundError, PermissionError_, UsageError
28
+ from tlgr.core.pagination import PageKind, build_page
29
+ from tlgr.core.timefmt import fmt_dt, parse_dt, parse_duration, to_unix
30
+ from tlgr.models.admin import (
31
+ Invite,
32
+ InviteDeleted,
33
+ InviteInfo,
34
+ InvitePeek,
35
+ InviteRevoked,
36
+ JoinResult,
37
+ )
38
+ from tlgr.models.base import Request
39
+ from tlgr.models.page import Page
40
+ from tlgr.models.peer import PeerRef
41
+ from tlgr.ops import _admin, _send
42
+ from tlgr.ops._params import arg, opt
43
+ from tlgr.ops._serialize import entity_to_peer, message_to_model
44
+ from tlgr.ops._spec import OpContext, OperationSpec
45
+
46
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
47
+
48
+ _EXAMPLE_INVITE: dict[str, Any] = {
49
+ "link": "https://t.me/+AbCdEf",
50
+ "title": "Launch week",
51
+ "permanent": False,
52
+ "usage_limit": 25,
53
+ "usage": 3,
54
+ }
55
+
56
+ #: A Stars subscription link is billed per 30-day period, always.
57
+ _SUBSCRIPTION_PERIOD = 30 * 86400
58
+
59
+
60
+ def _expiry(value: str | None) -> Any:
61
+ """`--expires 7d` or `--expires 2026-03-01T00:00Z` → a datetime, or None."""
62
+ if value is None:
63
+ return None
64
+ text = str(value).strip().lower()
65
+ if text in ("", "off", "never", "none", "0"):
66
+ return None
67
+ seconds = parse_duration(text)
68
+ if seconds is not None:
69
+ from datetime import datetime, timedelta, timezone
70
+
71
+ return datetime.now(timezone.utc) + timedelta(seconds=seconds)
72
+ moment = parse_dt(text)
73
+ if moment is None:
74
+ raise UsageError(f"{value!r} is neither a duration nor a timestamp", field="expires")
75
+ return moment
76
+
77
+
78
+ def _invite_model(raw: Any) -> Invite:
79
+ """`chatInviteExported` → `Invite`, with the Stars pricing spelled out."""
80
+ pricing = getattr(raw, "subscription_pricing", None)
81
+ return Invite(
82
+ link=str(getattr(raw, "link", "") or ""),
83
+ title=getattr(raw, "title", None),
84
+ permanent=bool(getattr(raw, "permanent", False)),
85
+ revoked=bool(getattr(raw, "revoked", False)),
86
+ request_needed=bool(getattr(raw, "request_needed", False)),
87
+ admin_id=getattr(raw, "admin_id", None),
88
+ date=fmt_dt(getattr(raw, "date", None)),
89
+ date_unix=to_unix(getattr(raw, "date", None)),
90
+ start_date=fmt_dt(getattr(raw, "start_date", None)),
91
+ expire_date=fmt_dt(getattr(raw, "expire_date", None)),
92
+ usage_limit=getattr(raw, "usage_limit", None),
93
+ usage=getattr(raw, "usage", None),
94
+ requested=getattr(raw, "requested", None),
95
+ subscription_expired=getattr(raw, "subscription_expired", None),
96
+ subscription_pricing=(
97
+ {
98
+ "period": int(getattr(pricing, "period", 0) or 0),
99
+ "amount": int(getattr(pricing, "amount", 0) or 0),
100
+ }
101
+ if pricing is not None
102
+ else None
103
+ ),
104
+ )
105
+
106
+
107
+ def _exported(reply: Any) -> tuple[Any, str | None]:
108
+ """`(the invite, the link it replaced)` — the replacement is never hidden."""
109
+ if type(reply).__name__ == "ExportedChatInviteReplaced":
110
+ return getattr(reply, "new_invite", None), str(
111
+ getattr(getattr(reply, "invite", None), "link", "") or ""
112
+ )
113
+ invite = getattr(reply, "invite", None)
114
+ return (invite if invite is not None else reply), None
115
+
116
+
117
+ def _render_qr(ctx: OpContext, link: str, png: str | None) -> tuple[str | None, str | None]:
118
+ """An ASCII QR, and optionally a PNG, when a QR library is installed.
119
+
120
+ The API contributes nothing to a QR code beyond the link, so this is
121
+ purely local rendering — and a pure-Python QR encoder is not something
122
+ tlgr should carry when `pip install tlgr[qr]` says it in one line.
123
+ """
124
+ try:
125
+ import segno
126
+ except ImportError:
127
+ ctx.warn(
128
+ "no QR encoder is installed; `pip install 'tlgr[qr]'` (segno) to render "
129
+ "the link as a QR code. The link itself is in `link`"
130
+ )
131
+ return None, None
132
+ code = segno.make(link, error="m")
133
+ if png:
134
+ code.save(png, scale=6)
135
+ import io
136
+
137
+ buffer = io.StringIO()
138
+ code.terminal(out=buffer, compact=True)
139
+ return buffer.getvalue(), png
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # chat invite create / edit / revoke / delete
144
+ # ---------------------------------------------------------------------------
145
+
146
+
147
+ class InviteCreateReq(Request):
148
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
149
+ title: Annotated[str | None, opt("--title", metavar="TEXT", help="Label shown to admins.")] = (
150
+ None
151
+ )
152
+ expires: Annotated[
153
+ str | None, opt("--expires", metavar="WHEN", help="Expiry, as a duration or a timestamp.")
154
+ ] = None
155
+ usage_limit: Annotated[
156
+ int | None,
157
+ opt("--limit", metavar="N", help="Maximum joins; excludes --request-approval."),
158
+ ] = None
159
+ request_approval: Annotated[
160
+ bool, opt("--request-approval", help="Joins land in the approval queue instead.")
161
+ ] = False
162
+ subscription_stars: Annotated[
163
+ int | None,
164
+ opt("--subscription-stars", metavar="N", help="Paid link: Stars per 30-day period."),
165
+ ] = None
166
+ replace_primary: Annotated[
167
+ bool, opt("--replace-primary", help="Revoke and replace the permanent link.")
168
+ ] = False
169
+
170
+
171
+ async def create_invite(ctx: OpContext, req: InviteCreateReq) -> Invite:
172
+ """Mint an invite link: expiring, join-limited, approval-gated or paid."""
173
+ from telethon.tl import types
174
+ from telethon.tl.functions import messages as fn
175
+
176
+ if req.usage_limit is not None and req.request_approval:
177
+ raise UsageError(
178
+ "--limit and --request-approval are mutually exclusive: a link that "
179
+ "queues joins has no join count to cap",
180
+ field="limit",
181
+ )
182
+ peer = await _send.resolve(ctx, req.chat)
183
+ pricing = (
184
+ types.StarsSubscriptionPricing(
185
+ period=_SUBSCRIPTION_PERIOD, amount=int(req.subscription_stars)
186
+ )
187
+ if req.subscription_stars
188
+ else None
189
+ )
190
+ reply = await _admin.client(ctx)(
191
+ fn.ExportChatInviteRequest(
192
+ peer=peer,
193
+ title=req.title,
194
+ expire_date=_expiry(req.expires),
195
+ usage_limit=req.usage_limit,
196
+ request_needed=req.request_approval or None,
197
+ subscription_pricing=pricing,
198
+ legacy_revoke_permanent=req.replace_primary or None,
199
+ )
200
+ )
201
+ invite, replaced = _exported(reply)
202
+ model = _invite_model(invite)
203
+ model.replaced_link = replaced
204
+ ctx.emit("chat_invite_created", {"chat_id": _send.peer_id_of(peer), "link": model.link})
205
+ return model
206
+
207
+
208
+ SPEC_INVITE_CREATE = OperationSpec(
209
+ id="chat.invite.create",
210
+ request=InviteCreateReq,
211
+ response=Invite,
212
+ impl=create_invite,
213
+ summary="Create an invite link (expiring, limited, approval-gated or paid)",
214
+ description=(
215
+ "`--limit` and `--request-approval` are mutually exclusive, which is "
216
+ "the server's rule and not ours. The subscription period is fixed at "
217
+ "30 days; creating a paid link costs nothing, only joining does. "
218
+ "`--replace-primary` invalidates the old permanent link for everyone "
219
+ "who holds it."
220
+ ),
221
+ mutating=True,
222
+ columns=("link", "title", "expire_date", "usage_limit"),
223
+ headers=("Link", "Title", "Expires", "Limit"),
224
+ example=_EXAMPLE_INVITE,
225
+ example_args="chat invite create @mygroup --title 'Launch week' --limit 25 --expires 7d",
226
+ covers=(
227
+ "groups-channels-admin.invite-link-create",
228
+ "groups-channels-admin.invite-link-primary",
229
+ "groups-channels-admin.invite-link-subscription",
230
+ ),
231
+ )
232
+
233
+
234
+ class InviteEditReq(Request):
235
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
236
+ link: Annotated[str, arg(1, metavar="LINK", help="The invite link to edit.")]
237
+ title: Annotated[str | None, opt("--title", metavar="TEXT", help="New label.")] = None
238
+ expires: Annotated[
239
+ str | None, opt("--expires", metavar="WHEN", help="New expiry; `off` clears it.")
240
+ ] = None
241
+ usage_limit: Annotated[
242
+ int | None, opt("--limit", metavar="N", help="New usage limit; 0 clears it.")
243
+ ] = None
244
+ request_approval: Annotated[
245
+ bool | None, opt("--request-approval", help="Turn the approval queue on or off.")
246
+ ] = None
247
+
248
+
249
+ async def edit_invite(ctx: OpContext, req: InviteEditReq) -> Invite:
250
+ """Change a link's label, expiry, cap or approval gate."""
251
+ from telethon.tl.functions import messages as fn
252
+
253
+ peer = await _send.resolve(ctx, req.chat)
254
+ if (
255
+ req.title is None
256
+ and req.expires is None
257
+ and req.usage_limit is None
258
+ and req.request_approval is None
259
+ ):
260
+ raise UsageError("nothing to change", field="title")
261
+ reply = await _admin.client(ctx)(
262
+ fn.EditExportedChatInviteRequest(
263
+ peer=peer,
264
+ link=req.link,
265
+ title=req.title,
266
+ expire_date=_expiry(req.expires),
267
+ usage_limit=req.usage_limit,
268
+ request_needed=req.request_approval,
269
+ )
270
+ )
271
+ invite, replaced = _exported(reply)
272
+ model = _invite_model(invite)
273
+ model.replaced_link = replaced
274
+ return model
275
+
276
+
277
+ SPEC_INVITE_EDIT = OperationSpec(
278
+ id="chat.invite.edit",
279
+ request=InviteEditReq,
280
+ response=Invite,
281
+ impl=edit_invite,
282
+ summary="Edit an invite link",
283
+ description=(
284
+ "May answer with `messages.exportedChatInviteReplaced`; both links "
285
+ "are reported, the new one in `link` and the old one in "
286
+ "`replaced_link`. A paid subscription link accepts only `--title`."
287
+ ),
288
+ mutating=True,
289
+ columns=("link", "title", "expire_date", "usage_limit"),
290
+ example=_EXAMPLE_INVITE,
291
+ example_args="chat invite edit @mygroup https://t.me/+AbCdEf --limit 50",
292
+ covers=("groups-channels-admin.invite-link-edit",),
293
+ )
294
+
295
+
296
+ class InviteRevokeReq(Request):
297
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
298
+ link: Annotated[str, arg(1, metavar="LINK", help="The invite link to revoke.")]
299
+
300
+
301
+ async def revoke_invite(ctx: OpContext, req: InviteRevokeReq) -> InviteRevoked:
302
+ """Revoke a link. Revoking the permanent one mints a replacement."""
303
+ from telethon.tl.functions import messages as fn
304
+
305
+ peer = await _send.resolve(ctx, req.chat)
306
+ reply = await _admin.client(ctx)(
307
+ fn.EditExportedChatInviteRequest(peer=peer, link=req.link, revoked=True)
308
+ )
309
+ invite, replaced = _exported(reply)
310
+ link = str(getattr(invite, "link", "") or req.link)
311
+ ctx.emit("chat_invite_revoked", {"chat_id": _send.peer_id_of(peer), "link": req.link})
312
+ return InviteRevoked(link=link, revoked=True, replaced_link=replaced)
313
+
314
+
315
+ SPEC_INVITE_REVOKE = OperationSpec(
316
+ id="chat.invite.revoke",
317
+ request=InviteRevokeReq,
318
+ response=InviteRevoked,
319
+ impl=revoke_invite,
320
+ summary="Revoke an invite link",
321
+ description=(
322
+ "Revoked links stay listable with `chat invite list --revoked` until "
323
+ "`chat invite delete` removes them."
324
+ ),
325
+ mutating=True,
326
+ destructive=True,
327
+ columns=("link", "revoked"),
328
+ example={"link": "https://t.me/+AbCdEf", "revoked": True},
329
+ example_args="chat invite revoke @mygroup https://t.me/+AbCdEf --yes",
330
+ covers=("groups-channels-admin.invite-link-revoke",),
331
+ )
332
+
333
+
334
+ class InviteDeleteReq(Request):
335
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
336
+ link: Annotated[
337
+ str | None, arg(1, metavar="LINK", required=False, help="The revoked link to delete.")
338
+ ] = None
339
+ revoked: Annotated[bool, opt("--revoked", help="Delete every revoked link instead.")] = False
340
+ admin: Annotated[
341
+ PeerRef | None,
342
+ opt("--admin", metavar="USER", kind="user", help="With --revoked: whose links to purge."),
343
+ ] = None
344
+
345
+
346
+ async def delete_invite(ctx: OpContext, req: InviteDeleteReq) -> InviteDeleted:
347
+ """Delete one revoked link, or every revoked link of one admin."""
348
+ from telethon.tl import types
349
+ from telethon.tl.functions import messages as fn
350
+
351
+ peer = await _send.resolve(ctx, req.chat)
352
+ handle = _admin.client(ctx)
353
+ if req.revoked:
354
+ admin: Any = types.InputUserSelf()
355
+ if req.admin is not None:
356
+ admin = _admin.input_user(await _send.resolve(ctx, req.admin))
357
+ await handle(fn.DeleteRevokedExportedChatInvitesRequest(peer=peer, admin_id=admin))
358
+ return InviteDeleted(chat_id=_send.peer_id_of(peer), deleted=-1)
359
+ if not req.link:
360
+ raise UsageError("name a link, or pass --revoked to purge them all", field="link")
361
+ await handle(fn.DeleteExportedChatInviteRequest(peer=peer, link=req.link))
362
+ return InviteDeleted(chat_id=_send.peer_id_of(peer), deleted=1)
363
+
364
+
365
+ SPEC_INVITE_DELETE = OperationSpec(
366
+ id="chat.invite.delete",
367
+ request=InviteDeleteReq,
368
+ response=InviteDeleted,
369
+ impl=delete_invite,
370
+ summary="Delete a revoked invite link, or every revoked link of an admin",
371
+ description=(
372
+ "Only revoked links can be deleted; revoke an active one first. "
373
+ "`--revoked` reports `deleted: -1`, because "
374
+ "`messages.deleteRevokedExportedChatInvites` answers with a bare "
375
+ "`true` and inventing a count would be inventing data."
376
+ ),
377
+ mutating=True,
378
+ destructive=True,
379
+ columns=("chat_id", "deleted"),
380
+ example={"chat_id": -1001500, "deleted": 1},
381
+ example_args="chat invite delete @mygroup --revoked --yes",
382
+ covers=(
383
+ "groups-channels-admin.invite-link-delete",
384
+ "groups-channels-admin.invite-link-delete-all-revoked",
385
+ ),
386
+ )
387
+
388
+
389
+ # ---------------------------------------------------------------------------
390
+ # chat invite get / list / open
391
+ # ---------------------------------------------------------------------------
392
+
393
+
394
+ class InviteGetReq(Request):
395
+ target: Annotated[
396
+ PeerRef, arg(0, metavar="CHAT|LINK", kind="peer", help="A chat, or an invite link.")
397
+ ]
398
+ link: Annotated[
399
+ str | None, arg(1, metavar="LINK", required=False, help="With a chat: which link.")
400
+ ] = None
401
+ qr: Annotated[bool, opt("--qr", help="Also render the link as an ASCII QR code.")] = False
402
+ png: Annotated[
403
+ str | None, opt("--png", metavar="PATH", kind="path", help="Write the QR code to a PNG.")
404
+ ] = None
405
+
406
+
407
+ async def get_invite(ctx: OpContext, req: InviteGetReq) -> InviteInfo:
408
+ """One of your links, your primary link, or a preview of somebody else's."""
409
+ from telethon.tl.functions import messages as fn
410
+
411
+ handle = _admin.client(ctx)
412
+
413
+ if req.target.kind == "invite" and req.link is None:
414
+ reply = await handle(fn.CheckChatInviteRequest(hash=str(req.target.value)))
415
+ kind = type(reply).__name__
416
+ chat = getattr(reply, "chat", None)
417
+ info = InviteInfo(link=req.target.raw)
418
+ if kind == "ChatInviteAlready":
419
+ info.already_member = True
420
+ info.chat = entity_to_peer(chat) if chat is not None else None
421
+ info.chat_title = _admin.display_name(chat)
422
+ elif kind == "ChatInvitePeek":
423
+ info.already_member = False
424
+ info.chat = entity_to_peer(chat) if chat is not None else None
425
+ info.chat_title = _admin.display_name(chat)
426
+ info.peek_expires = fmt_dt(getattr(reply, "expires", None))
427
+ else:
428
+ info.already_member = False
429
+ info.chat_title = str(getattr(reply, "title", "") or "")
430
+ info.members_count = getattr(reply, "participants_count", None)
431
+ info.about = getattr(reply, "about", None)
432
+ info.public = bool(getattr(reply, "public", False))
433
+ info.request_needed = bool(getattr(reply, "request_needed", False))
434
+ if req.qr or req.png:
435
+ info.qr, info.png = _render_qr(ctx, info.link, req.png)
436
+ return info
437
+
438
+ peer = await _send.resolve(ctx, req.target)
439
+ if req.link:
440
+ reply = await handle(fn.GetExportedChatInviteRequest(peer=peer, link=req.link))
441
+ invite, _replaced = _exported(reply)
442
+ else:
443
+ full, _entity, _entities = await _admin.full_chat(ctx, peer)
444
+ invite = getattr(full, "exported_invite", None)
445
+ if invite is None:
446
+ raise NotFoundError(
447
+ "this chat has no primary invite link visible to you; "
448
+ "`chat invite create` mints one"
449
+ )
450
+ base = _invite_model(invite)
451
+ info = InviteInfo(**{key: getattr(base, key) for key in base.__struct_fields__})
452
+ if req.qr or req.png:
453
+ info.qr, info.png = _render_qr(ctx, info.link, req.png)
454
+ return info
455
+
456
+
457
+ SPEC_INVITE_GET = OperationSpec(
458
+ id="chat.invite.get",
459
+ request=InviteGetReq,
460
+ response=InviteInfo,
461
+ impl=get_invite,
462
+ summary="Inspect one invite link, the primary link, or preview a link you were given",
463
+ description=(
464
+ "One argument that is a `t.me/+…` or `joinchat` link previews it "
465
+ "with `messages.checkChatInvite` and needs no rights; a chat plus a "
466
+ "link inspects your own with `messages.getExportedChatInvite` and "
467
+ "needs `invite-users`. A peek answers with `peek_expires`, which is "
468
+ "the window `chat invite open` reads inside."
469
+ ),
470
+ columns=("link", "chat_title", "members_count", "already_member"),
471
+ example={**_EXAMPLE_INVITE, "chat_title": "News", "members_count": 120},
472
+ example_args="chat invite get https://t.me/+AbCdEf",
473
+ empty_exit=EXIT_EMPTY,
474
+ covers=(
475
+ "groups-channels-admin.check-invite",
476
+ "groups-channels-admin.invite-link-get",
477
+ "groups-channels-admin.invite-link-qr",
478
+ ),
479
+ covers_partial=("groups-channels-admin.invite-link-primary",),
480
+ coverage_note=(
481
+ "The QR is rendered locally and needs the optional `tlgr[qr]` extra; "
482
+ "without it the link is still reported and a warning says so. "
483
+ "Minting the primary link is `chat invite create`."
484
+ ),
485
+ )
486
+
487
+
488
+ class InviteListReq(Request):
489
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
490
+ admin: Annotated[
491
+ PeerRef | None,
492
+ opt("--admin", metavar="USER", kind="user", help="Whose links to list; defaults to me."),
493
+ ] = None
494
+ revoked: Annotated[bool, opt("--revoked", help="Revoked links instead of active ones.")] = False
495
+ by_admin: Annotated[
496
+ bool, opt("--by-admin", help="One row per admin with their link counts instead.")
497
+ ] = False
498
+
499
+
500
+ async def list_invites(ctx: OpContext, req: InviteListReq) -> Page[Invite]:
501
+ """A chat's invite links, or (with `--by-admin`) who made how many."""
502
+ from datetime import datetime, timezone
503
+
504
+ from telethon.tl import types
505
+ from telethon.tl.functions import messages as fn
506
+
507
+ limit, state = _admin.window(ctx, "chat.invite.list", PageKind.PARTICIPANTS)
508
+ peer = await _send.resolve(ctx, req.chat)
509
+ handle = _admin.client(ctx)
510
+
511
+ if req.by_admin:
512
+ reply = await handle(fn.GetAdminsWithInvitesRequest(peer=peer))
513
+ rows = [
514
+ Invite(
515
+ link="",
516
+ admin_id=int(getattr(row, "admin_id", 0) or 0),
517
+ invites_count=int(getattr(row, "invites_count", 0) or 0),
518
+ revoked_invites_count=int(getattr(row, "revoked_invites_count", 0) or 0),
519
+ )
520
+ for row in (getattr(reply, "admins", None) or [])
521
+ ]
522
+ return build_page(
523
+ rows,
524
+ op="chat.invite.list",
525
+ kind=PageKind.PARTICIPANTS,
526
+ account=ctx.account,
527
+ has_more=False,
528
+ total=len(rows),
529
+ )
530
+
531
+ admin: Any = types.InputUserSelf()
532
+ if req.admin is not None:
533
+ admin = _admin.input_user(await _send.resolve(ctx, req.admin))
534
+ offset_date = state.get("date")
535
+ reply = await handle(
536
+ fn.GetExportedChatInvitesRequest(
537
+ peer=peer,
538
+ admin_id=admin,
539
+ limit=limit,
540
+ revoked=req.revoked or None,
541
+ offset_date=datetime.fromtimestamp(offset_date, tz=timezone.utc)
542
+ if offset_date
543
+ else None,
544
+ offset_link=state.get("link") or None,
545
+ )
546
+ )
547
+ items = [_invite_model(row) for row in (getattr(reply, "invites", None) or [])]
548
+ next_state: dict[str, Any] = {}
549
+ if items:
550
+ last = items[-1]
551
+ next_state = {"date": last.date_unix or 0, "link": last.link}
552
+ return build_page(
553
+ items,
554
+ op="chat.invite.list",
555
+ kind=PageKind.PARTICIPANTS,
556
+ state=next_state,
557
+ account=ctx.account,
558
+ limit=limit,
559
+ total=int(getattr(reply, "count", 0) or 0),
560
+ )
561
+
562
+
563
+ SPEC_INVITE_LIST = OperationSpec(
564
+ id="chat.invite.list",
565
+ request=InviteListReq,
566
+ response=Page[Invite],
567
+ impl=list_invites,
568
+ summary="List a chat's invite links (active, revoked, or grouped by admin)",
569
+ description=(
570
+ "`admin_id` is mandatory in the request, so it defaults to you; only "
571
+ "the owner may name somebody else. The cursor packs the "
572
+ "`(date, link)` pair of the last row. `--by-admin` swaps the rows "
573
+ "for one per admin, with their active and revoked counts in `usage` "
574
+ "and `requested`."
575
+ ),
576
+ paginated=PageKind.PARTICIPANTS,
577
+ columns=("link", "title", "usage", "expire_date"),
578
+ headers=("Link", "Title", "Used", "Expires"),
579
+ example={"items": [_EXAMPLE_INVITE], "has_more": False},
580
+ example_args="chat invite list @mygroup --revoked",
581
+ covers=(
582
+ "groups-channels-admin.invite-link-admins",
583
+ "groups-channels-admin.invite-link-list",
584
+ ),
585
+ )
586
+
587
+
588
+ class InviteOpenReq(Request):
589
+ link: Annotated[PeerRef, arg(0, metavar="LINK", kind="peer", help="The invite link.")]
590
+
591
+
592
+ async def open_invite(ctx: OpContext, req: InviteOpenReq) -> InvitePeek:
593
+ """Read a private channel through an invite peek, without joining.
594
+
595
+ Only works while the server answers `chatInvitePeek`. `peek_expires` is
596
+ reported so a script knows the window; after it the peer is dropped and
597
+ reading answers CHANNEL_PRIVATE.
598
+ """
599
+ from telethon.tl.functions import messages as fn
600
+
601
+ if req.link.kind != "invite":
602
+ raise UsageError("this takes an invite link (t.me/+… or joinchat/…)", field="link")
603
+ handle = _admin.client(ctx)
604
+ reply = await handle(fn.CheckChatInviteRequest(hash=str(req.link.value)))
605
+ if type(reply).__name__ != "ChatInvitePeek":
606
+ raise PermissionError_(
607
+ "the server did not offer a peek for this link; "
608
+ "`chat invite get` shows what it did say, and `chat join` joins"
609
+ )
610
+ chat = getattr(reply, "chat", None)
611
+ limit = int(getattr(ctx, "limit", None) or 20)
612
+ raw = [m async for m in handle.iter_messages(chat, limit=limit) if m is not None]
613
+ chat_id = _admin.entity_id(chat)
614
+ return InvitePeek(
615
+ chat=entity_to_peer(chat) if chat is not None else None,
616
+ chat_title=_admin.display_name(chat),
617
+ peek_expires=fmt_dt(getattr(reply, "expires", None)),
618
+ messages=[message_to_model(m, chat_id=chat_id) for m in raw],
619
+ )
620
+
621
+
622
+ SPEC_INVITE_OPEN = OperationSpec(
623
+ id="chat.invite.open",
624
+ request=InviteOpenReq,
625
+ response=InvitePeek,
626
+ impl=open_invite,
627
+ summary="Read a private channel through an invite peek, without joining",
628
+ description=(
629
+ "A peek is the server's offer, not ours: when it answers anything "
630
+ "other than `chatInvitePeek` this exits 6 rather than joining on "
631
+ "your behalf."
632
+ ),
633
+ columns=("chat_title", "peek_expires"),
634
+ example={"chat_title": "News", "peek_expires": "2026-02-01T11:00:00Z", "messages": []},
635
+ example_args="chat invite open https://t.me/+AbCdEf",
636
+ covers=("groups-channels-admin.invite-peek",),
637
+ )
638
+
639
+
640
+ # ---------------------------------------------------------------------------
641
+ # chat join
642
+ # ---------------------------------------------------------------------------
643
+
644
+
645
+ class JoinReq(Request):
646
+ target: Annotated[
647
+ PeerRef, arg(0, metavar="CHAT|LINK", kind="peer", help="A public chat, or an invite link.")
648
+ ]
649
+
650
+
651
+ async def join_chat(ctx: OpContext, req: JoinReq) -> JoinResult:
652
+ """Join a public chat by username, or a private one by invite link.
653
+
654
+ Three outcomes are all success: joined, already a member, and
655
+ request-sent. Only an expired or invalid hash is an error, because a
656
+ script that treats "you are already in this group" as a failure will
657
+ retry forever.
658
+ """
659
+ from telethon.tl.functions import channels as chan_fn
660
+ from telethon.tl.functions import messages as msg_fn
661
+
662
+ handle = _admin.client(ctx)
663
+ if req.target.kind == "invite":
664
+ try:
665
+ updates = await handle(msg_fn.ImportChatInviteRequest(hash=str(req.target.value)))
666
+ except Exception as exc:
667
+ name = type(exc).__name__
668
+ if name == "UserAlreadyParticipantError":
669
+ _admin.already(ctx)
670
+ return JoinResult(chat_id=0, joined=True, already=True)
671
+ if name == "InviteRequestSentError":
672
+ return JoinResult(chat_id=0, joined=False, pending_approval=True)
673
+ raise
674
+ chats = list(getattr(updates, "chats", None) or [])
675
+ chat = chats[0] if chats else None
676
+ peer = entity_to_peer(chat) if chat is not None else None
677
+ result = JoinResult(
678
+ chat_id=peer.id if peer is not None else 0,
679
+ title=peer.title if peer is not None else "",
680
+ joined=True,
681
+ )
682
+ if type(updates).__name__ == "ChatInviteJoinResultNeedsWebView": # pragma: no cover
683
+ result.joined = False
684
+ result.needs_web_view = "the server wants a web-view confirmation tlgr cannot open"
685
+ ctx.emit("chat_joined", {"chat_id": result.chat_id})
686
+ return result
687
+
688
+ peer = await _send.resolve(ctx, req.target)
689
+ updates = await handle(chan_fn.JoinChannelRequest(channel=_admin.input_channel(peer)))
690
+ chats = list(getattr(updates, "chats", None) or [])
691
+ entity = chats[0] if chats else None
692
+ chat_id = _send.peer_id_of(peer)
693
+ ctx.emit("chat_joined", {"chat_id": chat_id})
694
+ return JoinResult(chat_id=chat_id, title=_admin.display_name(entity), joined=True)
695
+
696
+
697
+ SPEC_JOIN = OperationSpec(
698
+ id="chat.join",
699
+ request=JoinReq,
700
+ response=JoinResult,
701
+ impl=join_chat,
702
+ summary="Join a public group/channel, or a private one by invite link",
703
+ description=(
704
+ "`INVITE_REQUEST_SENT` is success-with-pending (exit 0, "
705
+ "`pending_approval: true`) and `USER_ALREADY_PARTICIPANT` is "
706
+ "`already: true` (exit 0); an expired hash exits 5. A layer-229 join "
707
+ "that needs a web view is reported in `needs_web_view` rather than "
708
+ "claimed as a join."
709
+ ),
710
+ mutating=True,
711
+ columns=("chat_id", "title", "joined", "pending_approval"),
712
+ example={"chat_id": -1001500, "title": "News", "joined": True},
713
+ example_args="chat join @somechannel",
714
+ covers=("groups-channels-admin.join-by-invite", "groups-channels-admin.join-by-username"),
715
+ tags=frozenset({"visible-to-others"}),
716
+ )