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/chat_invite.py
ADDED
|
@@ -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
|
+
)
|