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_member.py
ADDED
|
@@ -0,0 +1,1357 @@
|
|
|
1
|
+
"""`chat member *` and `chat request *`: who is in a chat, and on what terms.
|
|
2
|
+
|
|
3
|
+
Three shapes are deliberate, and each one is a v1 bug turned into a rule.
|
|
4
|
+
|
|
5
|
+
* **A member is a participant, not a user.** v1's `chat members` returned
|
|
6
|
+
`{id, first_name, username, is_bot}`; the `ChannelParticipant*` wrapper —
|
|
7
|
+
status, rank, join date, inviter, promoter, both rights masks — went in the
|
|
8
|
+
bin. `chat members` still works and still answers with those keys, but the
|
|
9
|
+
row around them is now a whole `Participant`.
|
|
10
|
+
* **Restricting is a read-modify-write.** `channels.editBanned` replaces the
|
|
11
|
+
entire mask, so sending only the flags a caller named would silently give
|
|
12
|
+
back every restriction they did not mention. The current mask is fetched,
|
|
13
|
+
patched, and sent complete.
|
|
14
|
+
* **Kick and ban are different operations.** A kick is `editBanned(view)`
|
|
15
|
+
followed by an empty mask, so the person may come back; a ban leaves the
|
|
16
|
+
mask in place. v1 had one command and it did the second thing while
|
|
17
|
+
reading like the first.
|
|
18
|
+
|
|
19
|
+
Telethon is imported inside functions, never at module scope (§2.2).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
from typing import Annotated, Any
|
|
26
|
+
|
|
27
|
+
from tlgr.core.errors import EXIT_EMPTY, NotFoundError, UsageError
|
|
28
|
+
from tlgr.core.pagination import PageKind, build_page
|
|
29
|
+
from tlgr.core.timefmt import fmt_dt, to_unix
|
|
30
|
+
from tlgr.models.admin import (
|
|
31
|
+
JoinRequest,
|
|
32
|
+
MemberResult,
|
|
33
|
+
MembersAdded,
|
|
34
|
+
MissingInvitee,
|
|
35
|
+
Participant,
|
|
36
|
+
RequestResult,
|
|
37
|
+
)
|
|
38
|
+
from tlgr.models.base import Request
|
|
39
|
+
from tlgr.models.page import Page
|
|
40
|
+
from tlgr.models.peer import PeerRef, Rights
|
|
41
|
+
from tlgr.ops import _admin, _rights, _send
|
|
42
|
+
from tlgr.ops._params import arg, choice, opt
|
|
43
|
+
from tlgr.ops._spec import OpContext, OperationSpec
|
|
44
|
+
|
|
45
|
+
__all__ = [name for name in dir() if name.startswith("SPEC_")]
|
|
46
|
+
|
|
47
|
+
_EXAMPLE_MEMBER: dict[str, Any] = {
|
|
48
|
+
"id": 4242,
|
|
49
|
+
"user_id": 4242,
|
|
50
|
+
"name": "Alice",
|
|
51
|
+
"username": "alice",
|
|
52
|
+
"status": "admin",
|
|
53
|
+
"rank": "moderator",
|
|
54
|
+
"date": "2026-02-01T10:00:00Z",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#: `channels.getParticipants` filters, keyed the way the CLI spells them.
|
|
58
|
+
_FILTERS = {
|
|
59
|
+
"recent": "ChannelParticipantsRecent",
|
|
60
|
+
"admins": "ChannelParticipantsAdmins",
|
|
61
|
+
"bots": "ChannelParticipantsBots",
|
|
62
|
+
"contacts": "ChannelParticipantsContacts",
|
|
63
|
+
"kicked": "ChannelParticipantsKicked",
|
|
64
|
+
"banned": "ChannelParticipantsBanned",
|
|
65
|
+
"restricted": "ChannelParticipantsBanned",
|
|
66
|
+
"mentions": "ChannelParticipantsMentions",
|
|
67
|
+
"search": "ChannelParticipantsSearch",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#: `messages.missingInvitee` flags → the reason string tlgr reports.
|
|
71
|
+
_MISSING_REASONS = (
|
|
72
|
+
("premium_would_allow_invite", "premium-would-allow-invite"),
|
|
73
|
+
("premium_required_for_pm", "premium-required-for-pm"),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _participants_filter(name: str, search: str, topic: int | None) -> Any:
|
|
78
|
+
"""The `ChannelParticipantsFilter` for a CLI `--filter` name."""
|
|
79
|
+
from telethon.tl import types
|
|
80
|
+
|
|
81
|
+
if search and name == "recent":
|
|
82
|
+
name = "search"
|
|
83
|
+
cls = getattr(types, _FILTERS[name])
|
|
84
|
+
if name in ("kicked", "banned", "restricted", "search", "mentions"):
|
|
85
|
+
if name == "mentions":
|
|
86
|
+
return cls(q=search or None, top_msg_id=topic)
|
|
87
|
+
return cls(q=search or "")
|
|
88
|
+
return cls()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _missing(raw: Any) -> list[MissingInvitee]:
|
|
92
|
+
"""`messages.invitedUsers.missing_invitees`, verbatim rather than dropped."""
|
|
93
|
+
out: list[MissingInvitee] = []
|
|
94
|
+
for item in getattr(raw, "missing_invitees", None) or []:
|
|
95
|
+
reason = "privacy-restricted"
|
|
96
|
+
for flag, label in _MISSING_REASONS:
|
|
97
|
+
if getattr(item, flag, False):
|
|
98
|
+
reason = label
|
|
99
|
+
break
|
|
100
|
+
out.append(MissingInvitee(user_id=int(getattr(item, "user_id", 0) or 0), reason=reason))
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def _default_banned(ctx: OpContext, peer: Any) -> Rights | None:
|
|
105
|
+
"""The chat-wide default mask, for the `effective_permissions` column."""
|
|
106
|
+
try:
|
|
107
|
+
_full, entity, _entities = await _admin.full_chat(ctx, peer)
|
|
108
|
+
except UsageError:
|
|
109
|
+
return None
|
|
110
|
+
return _rights.model_from_banned(getattr(entity, "default_banned_rights", None))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def _member_of(ctx: OpContext, peer: Any, user: Any) -> Any:
|
|
114
|
+
"""The raw participant object, or NOT_FOUND.
|
|
115
|
+
|
|
116
|
+
Fetched before every restrict/promote so the mask that goes back is the
|
|
117
|
+
caller's change applied to what is actually there.
|
|
118
|
+
"""
|
|
119
|
+
from telethon.tl.functions import channels as fn
|
|
120
|
+
|
|
121
|
+
if _admin.is_channel(peer):
|
|
122
|
+
try:
|
|
123
|
+
reply = await _admin.client(ctx)(
|
|
124
|
+
fn.GetParticipantRequest(channel=_admin.input_channel(peer), participant=user)
|
|
125
|
+
)
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
if type(exc).__name__ == "UserNotParticipantError":
|
|
128
|
+
raise NotFoundError("that user is not a member of this chat") from exc
|
|
129
|
+
raise
|
|
130
|
+
return reply
|
|
131
|
+
full, _entity, entities = await _admin.full_chat(ctx, peer)
|
|
132
|
+
wanted = _send.peer_id_of(user)
|
|
133
|
+
holder = getattr(full, "participants", None)
|
|
134
|
+
for row in getattr(holder, "participants", None) or []:
|
|
135
|
+
if int(getattr(row, "user_id", 0) or 0) == abs(wanted):
|
|
136
|
+
return type("_Small", (), {"participant": row, "users": list(entities.values())})()
|
|
137
|
+
raise NotFoundError("that user is not a member of this chat")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# chat member list
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class MemberListReq(Request):
|
|
146
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
|
|
147
|
+
filter: Annotated[
|
|
148
|
+
str,
|
|
149
|
+
choice(
|
|
150
|
+
"recent",
|
|
151
|
+
"admins",
|
|
152
|
+
"bots",
|
|
153
|
+
"contacts",
|
|
154
|
+
"kicked",
|
|
155
|
+
"banned",
|
|
156
|
+
"restricted",
|
|
157
|
+
"mentions",
|
|
158
|
+
help="Which participant list. kicked = removed, restricted = still in the chat.",
|
|
159
|
+
),
|
|
160
|
+
] = "recent"
|
|
161
|
+
search: Annotated[
|
|
162
|
+
str, opt("--search", "-s", metavar="TEXT", help="Server-side name query.")
|
|
163
|
+
] = ""
|
|
164
|
+
topic: Annotated[
|
|
165
|
+
int | None,
|
|
166
|
+
opt("--topic", metavar="ID", kind="msg_id", help="With --filter mentions: one topic."),
|
|
167
|
+
] = None
|
|
168
|
+
via_link: Annotated[
|
|
169
|
+
str | None,
|
|
170
|
+
opt("--via-link", metavar="LINK", help="Only people who joined through this invite link."),
|
|
171
|
+
] = None
|
|
172
|
+
subscription_expired: Annotated[
|
|
173
|
+
bool,
|
|
174
|
+
opt("--subscription-expired", help="With --via-link: lapsed paid subscribers."),
|
|
175
|
+
] = False
|
|
176
|
+
bots: Annotated[bool, opt("--bots", help="Shorthand for --filter bots.")] = False
|
|
177
|
+
admins: Annotated[bool, opt("--admins", help="Shorthand for --filter admins (v1).")] = False
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def _importers(
|
|
181
|
+
ctx: OpContext, req: MemberListReq, peer: Any, limit: int, state: dict[str, Any], op: str
|
|
182
|
+
) -> Page[Participant]:
|
|
183
|
+
"""`--via-link`: who came in through one invite (messages.getChatInviteImporters)."""
|
|
184
|
+
from datetime import datetime, timezone
|
|
185
|
+
|
|
186
|
+
from telethon.tl import types
|
|
187
|
+
from telethon.tl.functions import messages as fn
|
|
188
|
+
|
|
189
|
+
offset_date = state.get("date")
|
|
190
|
+
offset_user: Any = types.InputUserEmpty()
|
|
191
|
+
if state.get("user"):
|
|
192
|
+
offset_user = types.InputUser(
|
|
193
|
+
user_id=int(state["user"]), access_hash=int(state.get("hash", 0) or 0)
|
|
194
|
+
)
|
|
195
|
+
reply = await _admin.client(ctx)(
|
|
196
|
+
fn.GetChatInviteImportersRequest(
|
|
197
|
+
peer=peer,
|
|
198
|
+
offset_date=datetime.fromtimestamp(offset_date, tz=timezone.utc)
|
|
199
|
+
if offset_date
|
|
200
|
+
else None,
|
|
201
|
+
offset_user=offset_user,
|
|
202
|
+
limit=limit,
|
|
203
|
+
link=req.via_link,
|
|
204
|
+
q=req.search or None,
|
|
205
|
+
subscription_expired=req.subscription_expired or None,
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
entities = _admin.entity_map(reply)
|
|
209
|
+
chat_id = _send.peer_id_of(peer)
|
|
210
|
+
items: list[Participant] = []
|
|
211
|
+
last: Any = None
|
|
212
|
+
for row in getattr(reply, "importers", None) or []:
|
|
213
|
+
user_id = int(getattr(row, "user_id", 0) or 0)
|
|
214
|
+
entity = entities.get(user_id)
|
|
215
|
+
items.append(
|
|
216
|
+
Participant(
|
|
217
|
+
id=user_id,
|
|
218
|
+
user_id=user_id,
|
|
219
|
+
chat_id=chat_id,
|
|
220
|
+
peer=_admin.peer_row(entity) if entity is not None else None,
|
|
221
|
+
username=getattr(entity, "username", None),
|
|
222
|
+
name=_admin.display_name(entity),
|
|
223
|
+
is_bot=bool(getattr(entity, "bot", False)),
|
|
224
|
+
status="member",
|
|
225
|
+
date=fmt_dt(getattr(row, "date", None)),
|
|
226
|
+
date_unix=to_unix(getattr(row, "date", None)),
|
|
227
|
+
about=getattr(row, "about", None),
|
|
228
|
+
approved_by=getattr(row, "approved_by", None),
|
|
229
|
+
via_link=req.via_link,
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
last = row
|
|
233
|
+
next_state: dict[str, Any] = {}
|
|
234
|
+
if last is not None:
|
|
235
|
+
entity = entities.get(int(getattr(last, "user_id", 0) or 0))
|
|
236
|
+
next_state = {
|
|
237
|
+
"date": to_unix(getattr(last, "date", None)) or 0,
|
|
238
|
+
"user": int(getattr(last, "user_id", 0) or 0),
|
|
239
|
+
"hash": int(getattr(entity, "access_hash", 0) or 0),
|
|
240
|
+
}
|
|
241
|
+
return build_page(
|
|
242
|
+
items,
|
|
243
|
+
op=op,
|
|
244
|
+
kind=PageKind.PARTICIPANTS,
|
|
245
|
+
state=next_state,
|
|
246
|
+
account=ctx.account,
|
|
247
|
+
limit=limit,
|
|
248
|
+
total=int(getattr(reply, "count", 0) or 0),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
async def list_members(ctx: OpContext, req: MemberListReq) -> Page[Participant]:
|
|
253
|
+
"""One page of a chat's participants, wrapper and all."""
|
|
254
|
+
from telethon.tl.functions import channels as fn
|
|
255
|
+
|
|
256
|
+
limit, state = _admin.window(ctx, "chat.member.list", PageKind.PARTICIPANTS)
|
|
257
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
258
|
+
chat_id = _send.peer_id_of(peer)
|
|
259
|
+
|
|
260
|
+
if req.via_link:
|
|
261
|
+
return await _importers(ctx, req, peer, limit, state, "chat.member.list")
|
|
262
|
+
|
|
263
|
+
name = "bots" if req.bots else ("admins" if req.admins else req.filter)
|
|
264
|
+
if not _admin.is_channel(peer):
|
|
265
|
+
# A basic group has no participant paging at all: the whole list
|
|
266
|
+
# arrives inside chatFull, and slicing it here is the only offset.
|
|
267
|
+
full, entity, entities = await _admin.full_chat(ctx, peer)
|
|
268
|
+
holder = getattr(full, "participants", None)
|
|
269
|
+
rows = list(getattr(holder, "participants", None) or [])
|
|
270
|
+
if name == "admins":
|
|
271
|
+
rows = [r for r in rows if type(r).__name__ != "ChatParticipant"]
|
|
272
|
+
if name == "bots":
|
|
273
|
+
rows = [
|
|
274
|
+
r
|
|
275
|
+
for r in rows
|
|
276
|
+
if getattr(entities.get(int(getattr(r, "user_id", 0) or 0)), "bot", False)
|
|
277
|
+
]
|
|
278
|
+
default = _rights.model_from_banned(getattr(entity, "default_banned_rights", None))
|
|
279
|
+
offset = int(state.get("offset", 0) or 0)
|
|
280
|
+
window = rows[offset : offset + limit]
|
|
281
|
+
items = [
|
|
282
|
+
_admin.participant_model(
|
|
283
|
+
row, chat_id=chat_id, entities=entities, default_banned=default
|
|
284
|
+
)
|
|
285
|
+
for row in window
|
|
286
|
+
]
|
|
287
|
+
return build_page(
|
|
288
|
+
items,
|
|
289
|
+
op="chat.member.list",
|
|
290
|
+
kind=PageKind.PARTICIPANTS,
|
|
291
|
+
state={"offset": offset + len(window)},
|
|
292
|
+
account=ctx.account,
|
|
293
|
+
has_more=offset + len(window) < len(rows),
|
|
294
|
+
total=len(rows),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
offset = int(state.get("offset", 0) or 0)
|
|
298
|
+
reply = await _admin.client(ctx)(
|
|
299
|
+
fn.GetParticipantsRequest(
|
|
300
|
+
channel=_admin.input_channel(peer),
|
|
301
|
+
filter=_participants_filter(name, req.search, req.topic),
|
|
302
|
+
offset=offset,
|
|
303
|
+
limit=limit,
|
|
304
|
+
hash=0,
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
entities = _admin.entity_map(reply)
|
|
308
|
+
default = await _default_banned(ctx, peer) if name in ("banned", "restricted") else None
|
|
309
|
+
rows = list(getattr(reply, "participants", None) or [])
|
|
310
|
+
if name == "restricted":
|
|
311
|
+
rows = [r for r in rows if not getattr(r, "left", False)]
|
|
312
|
+
elif name == "banned":
|
|
313
|
+
rows = [r for r in rows if getattr(r, "left", False) or True]
|
|
314
|
+
items = [
|
|
315
|
+
_admin.participant_model(row, chat_id=chat_id, entities=entities, default_banned=default)
|
|
316
|
+
for row in rows
|
|
317
|
+
]
|
|
318
|
+
return build_page(
|
|
319
|
+
items,
|
|
320
|
+
op="chat.member.list",
|
|
321
|
+
kind=PageKind.PARTICIPANTS,
|
|
322
|
+
state={"offset": offset + len(rows)},
|
|
323
|
+
account=ctx.account,
|
|
324
|
+
limit=limit,
|
|
325
|
+
total=int(getattr(reply, "count", 0) or 0),
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
SPEC_MEMBER_LIST = OperationSpec(
|
|
330
|
+
id="chat.member.list",
|
|
331
|
+
request=MemberListReq,
|
|
332
|
+
response=Page[Participant],
|
|
333
|
+
impl=list_members,
|
|
334
|
+
summary="List members with the participant filters the API offers",
|
|
335
|
+
description=(
|
|
336
|
+
"Every row keeps its `ChannelParticipant*` wrapper: status, rank, "
|
|
337
|
+
"join date, inviter, promoter and both rights masks. `--filter "
|
|
338
|
+
"kicked` is people who were removed; `--filter restricted` is people "
|
|
339
|
+
"still in the chat with a mask on them. A chat with "
|
|
340
|
+
"`participants_hidden` answers PERMISSION_DENIED rather than an "
|
|
341
|
+
"empty page, because "
|
|
342
|
+
"“nobody is in this group” would be a lie."
|
|
343
|
+
),
|
|
344
|
+
aliases=("chat.members",),
|
|
345
|
+
legacy_paths=("chat members",),
|
|
346
|
+
paginated=PageKind.PARTICIPANTS,
|
|
347
|
+
columns=("id", "username", "name", "status", "rank"),
|
|
348
|
+
headers=("ID", "Username", "Name", "Status", "Rank"),
|
|
349
|
+
example={"items": [_EXAMPLE_MEMBER], "has_more": False, "total": 1},
|
|
350
|
+
example_args="chat member list @mygroup --filter admins",
|
|
351
|
+
covers=(
|
|
352
|
+
"groups-channels-admin.banned-list",
|
|
353
|
+
"groups-channels-admin.channel-subscriptions-admin",
|
|
354
|
+
"groups-channels-admin.invite-link-importers",
|
|
355
|
+
"groups-channels-admin.member-mention-autocomplete",
|
|
356
|
+
"groups-channels-admin.members-list",
|
|
357
|
+
),
|
|
358
|
+
covers_partial=("groups-channels-admin.admin-list",),
|
|
359
|
+
coverage_note="`--filter admins` lists them; `chat admin list` is the primary owner.",
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
# chat member get
|
|
365
|
+
# ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class MemberGetReq(Request):
|
|
369
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
|
|
370
|
+
user: Annotated[PeerRef, arg(1, metavar="USER", kind="user", help="The member.")]
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
async def get_member(ctx: OpContext, req: MemberGetReq) -> Participant:
|
|
374
|
+
"""One member's status, rights, rank, join date and inviter."""
|
|
375
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
376
|
+
user = await _send.resolve(ctx, req.user)
|
|
377
|
+
reply = await _member_of(ctx, peer, user)
|
|
378
|
+
entities = _admin.entity_map(reply)
|
|
379
|
+
return _admin.participant_model(
|
|
380
|
+
reply.participant,
|
|
381
|
+
chat_id=_send.peer_id_of(peer),
|
|
382
|
+
entities=entities,
|
|
383
|
+
default_banned=await _default_banned(ctx, peer),
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
SPEC_MEMBER_GET = OperationSpec(
|
|
388
|
+
id="chat.member.get",
|
|
389
|
+
request=MemberGetReq,
|
|
390
|
+
response=Participant,
|
|
391
|
+
impl=get_member,
|
|
392
|
+
summary="One member's status, rights, rank, join date and inviter",
|
|
393
|
+
description=(
|
|
394
|
+
"`effective_permissions` is the chat's defaults patched with this "
|
|
395
|
+
"member's own mask, in the same allow-polarity vocabulary "
|
|
396
|
+
"`chat permission get` prints — which is the answer to “what may "
|
|
397
|
+
"this person actually do” rather than “what did an admin type”."
|
|
398
|
+
),
|
|
399
|
+
columns=("id", "name", "status", "rank"),
|
|
400
|
+
example=_EXAMPLE_MEMBER,
|
|
401
|
+
example_args="chat member get @mygroup @alice",
|
|
402
|
+
empty_exit=EXIT_EMPTY,
|
|
403
|
+
covers=(
|
|
404
|
+
"groups-channels-admin.member-get",
|
|
405
|
+
"groups-channels-admin.member-invited-by",
|
|
406
|
+
"groups-channels-admin.member-permissions-view",
|
|
407
|
+
),
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# ---------------------------------------------------------------------------
|
|
412
|
+
# chat member add
|
|
413
|
+
# ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class MemberAddReq(Request):
|
|
417
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
|
|
418
|
+
user: Annotated[
|
|
419
|
+
list[PeerRef], arg(1, metavar="USER", kind="user", variadic=True, help="Who to add.")
|
|
420
|
+
] = []
|
|
421
|
+
forward_history: Annotated[
|
|
422
|
+
int,
|
|
423
|
+
opt("--forward-history", metavar="N", help="Basic groups: past messages the member sees."),
|
|
424
|
+
] = 0
|
|
425
|
+
invite_link_fallback: Annotated[
|
|
426
|
+
bool,
|
|
427
|
+
opt("--invite-link-fallback", help="DM the invite link to anyone who could not be added."),
|
|
428
|
+
] = False
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
async def add_members(ctx: OpContext, req: MemberAddReq) -> MembersAdded:
|
|
432
|
+
"""Add members, and say by name who could not be added and why.
|
|
433
|
+
|
|
434
|
+
`messages.invitedUsers.missing_invitees` is reported verbatim: "added 3
|
|
435
|
+
of 5" with no names is not something a script can act on, and privacy
|
|
436
|
+
settings make partial failure the normal case rather than the exception.
|
|
437
|
+
"""
|
|
438
|
+
from telethon.tl.functions import channels as chan_fn
|
|
439
|
+
from telethon.tl.functions import messages as msg_fn
|
|
440
|
+
|
|
441
|
+
if not req.user:
|
|
442
|
+
raise UsageError("name at least one user to add", field="user")
|
|
443
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
444
|
+
chat_id = _send.peer_id_of(peer)
|
|
445
|
+
users = [_admin.input_user(await _send.resolve(ctx, ref)) for ref in req.user]
|
|
446
|
+
handle = _admin.client(ctx)
|
|
447
|
+
|
|
448
|
+
missing: list[MissingInvitee] = []
|
|
449
|
+
if _admin.is_channel(peer):
|
|
450
|
+
reply = await handle(
|
|
451
|
+
chan_fn.InviteToChannelRequest(channel=_admin.input_channel(peer), users=users)
|
|
452
|
+
)
|
|
453
|
+
missing = _missing(reply)
|
|
454
|
+
else:
|
|
455
|
+
for user in users:
|
|
456
|
+
reply = await handle(
|
|
457
|
+
msg_fn.AddChatUserRequest(
|
|
458
|
+
chat_id=_admin.small_chat_id(peer),
|
|
459
|
+
user_id=user,
|
|
460
|
+
fwd_limit=req.forward_history,
|
|
461
|
+
)
|
|
462
|
+
)
|
|
463
|
+
missing.extend(_missing(reply))
|
|
464
|
+
|
|
465
|
+
refused = {item.user_id for item in missing}
|
|
466
|
+
added = [int(getattr(u, "user_id", 0) or 0) for u in users]
|
|
467
|
+
added = [uid for uid in added if uid not in refused]
|
|
468
|
+
invited: list[int] = []
|
|
469
|
+
if missing and req.invite_link_fallback:
|
|
470
|
+
invited = await _dm_the_link(ctx, peer, missing)
|
|
471
|
+
ctx.emit("chat_members_added", {"chat_id": chat_id, "added": added})
|
|
472
|
+
return MembersAdded(chat_id=chat_id, added=added, missing=missing, invited_by_link=invited)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
async def _dm_the_link(ctx: OpContext, peer: Any, missing: list[MissingInvitee]) -> list[int]:
|
|
476
|
+
"""`--invite-link-fallback`: send the link to whoever could not be added.
|
|
477
|
+
|
|
478
|
+
Opt-in because it *sends a message* on the owner's behalf to people who
|
|
479
|
+
have already expressed a privacy preference against being added.
|
|
480
|
+
"""
|
|
481
|
+
from telethon.tl.functions import messages as fn
|
|
482
|
+
|
|
483
|
+
handle = _admin.client(ctx)
|
|
484
|
+
invite = await handle(fn.ExportChatInviteRequest(peer=peer))
|
|
485
|
+
link = str(getattr(invite, "link", "") or "")
|
|
486
|
+
reached: list[int] = []
|
|
487
|
+
for item in missing:
|
|
488
|
+
try:
|
|
489
|
+
target = await _send.resolve(ctx, str(item.user_id))
|
|
490
|
+
await handle.send_message(target, link)
|
|
491
|
+
reached.append(item.user_id)
|
|
492
|
+
except Exception as exc:
|
|
493
|
+
ctx.warn(f"could not send the invite link to {item.user_id}: {exc}")
|
|
494
|
+
return reached
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
SPEC_MEMBER_ADD = OperationSpec(
|
|
498
|
+
id="chat.member.add",
|
|
499
|
+
request=MemberAddReq,
|
|
500
|
+
response=MembersAdded,
|
|
501
|
+
impl=add_members,
|
|
502
|
+
summary="Add members to a group or channel",
|
|
503
|
+
description=(
|
|
504
|
+
"`missing` carries `messages.invitedUsers.missing_invitees` verbatim "
|
|
505
|
+
"— one `{user_id, reason}` per refusal, with reason in "
|
|
506
|
+
"privacy-restricted, premium-would-allow-invite or "
|
|
507
|
+
"premium-required-for-pm. `--invite-link-fallback` DMs the link to "
|
|
508
|
+
"them, and is opt-in because it sends a message on your behalf."
|
|
509
|
+
),
|
|
510
|
+
mutating=True,
|
|
511
|
+
rate_class="bulk",
|
|
512
|
+
columns=("chat_id", "added"),
|
|
513
|
+
example={"chat_id": -1001500, "added": [4242], "missing": []},
|
|
514
|
+
example_args="chat member add @mygroup @alice @carol",
|
|
515
|
+
covers=(
|
|
516
|
+
"groups-channels-admin.add-members",
|
|
517
|
+
"groups-channels-admin.add-members-failure-report",
|
|
518
|
+
),
|
|
519
|
+
tags=frozenset({"visible-to-others"}),
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
# ---------------------------------------------------------------------------
|
|
524
|
+
# Moderation: remove / ban / unban
|
|
525
|
+
# ---------------------------------------------------------------------------
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
async def _purge(ctx: OpContext, peer: Any, user: Any, ids: list[int]) -> int:
|
|
529
|
+
"""`--purge` / `--messages`: everything that member wrote, or named ids."""
|
|
530
|
+
from telethon.tl.functions import channels as fn
|
|
531
|
+
|
|
532
|
+
channel = _admin.input_channel(peer)
|
|
533
|
+
removed = 0
|
|
534
|
+
if ids:
|
|
535
|
+
reply = await _admin.client(ctx)(fn.DeleteMessagesRequest(channel=channel, id=list(ids)))
|
|
536
|
+
removed += int(getattr(reply, "pts_count", 0) or 0)
|
|
537
|
+
return removed
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
async def _purge_all(ctx: OpContext, peer: Any, user: Any) -> int:
|
|
541
|
+
from telethon.tl.functions import channels as fn
|
|
542
|
+
|
|
543
|
+
channel = _admin.input_channel(peer)
|
|
544
|
+
return await _admin.affected_loop(
|
|
545
|
+
ctx,
|
|
546
|
+
lambda _offset: fn.DeleteParticipantHistoryRequest(channel=channel, participant=user),
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
async def _report_spam(ctx: OpContext, peer: Any, user: Any, ids: list[int]) -> bool:
|
|
551
|
+
from telethon.tl.functions import channels as fn
|
|
552
|
+
|
|
553
|
+
await _admin.client(ctx)(
|
|
554
|
+
fn.ReportSpamRequest(
|
|
555
|
+
channel=_admin.input_channel(peer), participant=user, id=list(ids) or []
|
|
556
|
+
)
|
|
557
|
+
)
|
|
558
|
+
return True
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
class MemberRemoveReq(Request):
|
|
562
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
|
|
563
|
+
user: Annotated[
|
|
564
|
+
list[PeerRef], arg(1, metavar="USER", kind="user", variadic=True, help="Who to remove.")
|
|
565
|
+
] = []
|
|
566
|
+
purge: Annotated[bool, opt("--purge", help="Delete everything they ever sent here.")] = False
|
|
567
|
+
messages: Annotated[
|
|
568
|
+
list[int], opt("--messages", metavar="ID", help="Also delete these message ids.")
|
|
569
|
+
] = []
|
|
570
|
+
report: Annotated[bool, opt("--report", help="Report the messages as spam too.")] = False
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
async def remove_member(ctx: OpContext, req: MemberRemoveReq) -> list[MemberResult]:
|
|
574
|
+
"""Kick: ban, then immediately lift the ban, so they may rejoin.
|
|
575
|
+
|
|
576
|
+
Telegram has no kick method. `editBanned(view_messages=True)` followed by
|
|
577
|
+
an empty mask is what every client calls a kick, and the second call is
|
|
578
|
+
the whole difference from `chat member ban`.
|
|
579
|
+
"""
|
|
580
|
+
from telethon.tl.functions import channels as chan_fn
|
|
581
|
+
from telethon.tl.functions import messages as msg_fn
|
|
582
|
+
|
|
583
|
+
if not req.user:
|
|
584
|
+
raise UsageError("name at least one user to remove", field="user")
|
|
585
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
586
|
+
chat_id = _send.peer_id_of(peer)
|
|
587
|
+
handle = _admin.client(ctx)
|
|
588
|
+
out: list[MemberResult] = []
|
|
589
|
+
for ref in req.user:
|
|
590
|
+
user = await _send.resolve(ctx, ref)
|
|
591
|
+
if _admin.is_channel(peer):
|
|
592
|
+
channel = _admin.input_channel(peer)
|
|
593
|
+
await handle(
|
|
594
|
+
chan_fn.EditBannedRequest(
|
|
595
|
+
channel=channel,
|
|
596
|
+
participant=user,
|
|
597
|
+
banned_rights=_rights.build_banned_rights([]),
|
|
598
|
+
)
|
|
599
|
+
)
|
|
600
|
+
await asyncio.sleep(0)
|
|
601
|
+
await handle(
|
|
602
|
+
chan_fn.EditBannedRequest(
|
|
603
|
+
channel=channel,
|
|
604
|
+
participant=user,
|
|
605
|
+
banned_rights=_rights.build_banned_rights(_rights.all_allowed()),
|
|
606
|
+
)
|
|
607
|
+
)
|
|
608
|
+
else:
|
|
609
|
+
await handle(
|
|
610
|
+
msg_fn.DeleteChatUserRequest(
|
|
611
|
+
chat_id=_admin.small_chat_id(peer), user_id=_admin.input_user(user)
|
|
612
|
+
)
|
|
613
|
+
)
|
|
614
|
+
purged = await _purge_all(ctx, peer, user) if req.purge else None
|
|
615
|
+
if req.messages:
|
|
616
|
+
purged = (purged or 0) + await _purge(ctx, peer, user, req.messages)
|
|
617
|
+
reported = (
|
|
618
|
+
await _report_spam(ctx, peer, user, req.messages)
|
|
619
|
+
if (req.report and _admin.is_channel(peer))
|
|
620
|
+
else None
|
|
621
|
+
)
|
|
622
|
+
out.append(
|
|
623
|
+
MemberResult(
|
|
624
|
+
chat_id=chat_id,
|
|
625
|
+
user_id=abs(_send.peer_id_of(user)),
|
|
626
|
+
removed=True,
|
|
627
|
+
purged_messages=purged,
|
|
628
|
+
reported=reported,
|
|
629
|
+
)
|
|
630
|
+
)
|
|
631
|
+
ctx.emit("chat_members_removed", {"chat_id": chat_id, "users": [r.user_id for r in out]})
|
|
632
|
+
return out
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
SPEC_MEMBER_REMOVE = OperationSpec(
|
|
636
|
+
id="chat.member.remove",
|
|
637
|
+
request=MemberRemoveReq,
|
|
638
|
+
response=list[MemberResult],
|
|
639
|
+
impl=remove_member,
|
|
640
|
+
summary="Remove (kick) a member; they can rejoin",
|
|
641
|
+
description=(
|
|
642
|
+
"A kick is `editBanned(view_messages)` followed by an empty mask, so "
|
|
643
|
+
"the person may come back. Use `chat member ban` to keep them out. "
|
|
644
|
+
"`--purge` drains `messages.affectedHistory` until the server stops "
|
|
645
|
+
"handing back an offset."
|
|
646
|
+
),
|
|
647
|
+
mutating=True,
|
|
648
|
+
destructive=True,
|
|
649
|
+
rate_class="bulk",
|
|
650
|
+
columns=("chat_id", "user_id", "removed"),
|
|
651
|
+
example=[{"chat_id": -1001500, "user_id": 4242, "removed": True}],
|
|
652
|
+
example_args="chat member remove @mygroup @spammer --purge --yes",
|
|
653
|
+
covers=("groups-channels-admin.remove-member",),
|
|
654
|
+
covers_partial=(
|
|
655
|
+
"groups-channels-admin.delete-member-history",
|
|
656
|
+
"groups-channels-admin.report-member",
|
|
657
|
+
),
|
|
658
|
+
coverage_note=(
|
|
659
|
+
"`--purge`/`--report` are the moderate box; the standalone commands "
|
|
660
|
+
"are `chat member delete-history` and `chat member report`."
|
|
661
|
+
),
|
|
662
|
+
tags=frozenset({"visible-to-others"}),
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
class MemberBanReq(Request):
|
|
667
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
|
|
668
|
+
user: Annotated[
|
|
669
|
+
list[PeerRef], arg(1, metavar="USER", kind="user", variadic=True, help="Who to ban.")
|
|
670
|
+
] = []
|
|
671
|
+
until: Annotated[
|
|
672
|
+
str | None,
|
|
673
|
+
opt("--until", metavar="WHEN", help="Ban expiry; 0, under 30s or over 366d = forever."),
|
|
674
|
+
] = None
|
|
675
|
+
purge: Annotated[bool, opt("--purge", help="Delete everything they ever sent here.")] = False
|
|
676
|
+
messages: Annotated[
|
|
677
|
+
list[int], opt("--messages", metavar="ID", help="Also delete these message ids.")
|
|
678
|
+
] = []
|
|
679
|
+
report: Annotated[bool, opt("--report", help="Report the messages as spam too.")] = False
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
async def ban_member(ctx: OpContext, req: MemberBanReq) -> list[MemberResult]:
|
|
683
|
+
"""The GUI's moderate box: ban, purge and report in one confirmation."""
|
|
684
|
+
from telethon.tl.functions import channels as chan_fn
|
|
685
|
+
from telethon.tl.functions import messages as msg_fn
|
|
686
|
+
|
|
687
|
+
if not req.user:
|
|
688
|
+
raise UsageError("name at least one user to ban", field="user")
|
|
689
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
690
|
+
chat_id = _send.peer_id_of(peer)
|
|
691
|
+
until = _rights.parse_until(req.until)
|
|
692
|
+
label, label_unix = _rights.until_label(until)
|
|
693
|
+
handle = _admin.client(ctx)
|
|
694
|
+
out: list[MemberResult] = []
|
|
695
|
+
for ref in req.user:
|
|
696
|
+
user = await _send.resolve(ctx, ref)
|
|
697
|
+
if _admin.is_channel(peer):
|
|
698
|
+
await handle(
|
|
699
|
+
chan_fn.EditBannedRequest(
|
|
700
|
+
channel=_admin.input_channel(peer),
|
|
701
|
+
participant=user,
|
|
702
|
+
banned_rights=_rights.build_banned_rights([], until=until),
|
|
703
|
+
)
|
|
704
|
+
)
|
|
705
|
+
else:
|
|
706
|
+
# A basic group has no ban at all: removal is the strongest thing
|
|
707
|
+
# the peer shape supports, and saying so beats pretending.
|
|
708
|
+
ctx.warn("a basic group cannot ban; the member was removed instead")
|
|
709
|
+
await handle(
|
|
710
|
+
msg_fn.DeleteChatUserRequest(
|
|
711
|
+
chat_id=_admin.small_chat_id(peer), user_id=_admin.input_user(user)
|
|
712
|
+
)
|
|
713
|
+
)
|
|
714
|
+
purged = await _purge_all(ctx, peer, user) if req.purge else None
|
|
715
|
+
if req.messages:
|
|
716
|
+
purged = (purged or 0) + await _purge(ctx, peer, user, req.messages)
|
|
717
|
+
reported = (
|
|
718
|
+
await _report_spam(ctx, peer, user, req.messages)
|
|
719
|
+
if (req.report and _admin.is_channel(peer))
|
|
720
|
+
else None
|
|
721
|
+
)
|
|
722
|
+
out.append(
|
|
723
|
+
MemberResult(
|
|
724
|
+
chat_id=chat_id,
|
|
725
|
+
user_id=abs(_send.peer_id_of(user)),
|
|
726
|
+
banned=True,
|
|
727
|
+
until=label,
|
|
728
|
+
until_unix=label_unix,
|
|
729
|
+
purged_messages=purged,
|
|
730
|
+
reported=reported,
|
|
731
|
+
)
|
|
732
|
+
)
|
|
733
|
+
ctx.emit("chat_members_banned", {"chat_id": chat_id, "users": [r.user_id for r in out]})
|
|
734
|
+
return out
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
SPEC_MEMBER_BAN = OperationSpec(
|
|
738
|
+
id="chat.member.ban",
|
|
739
|
+
request=MemberBanReq,
|
|
740
|
+
response=list[MemberResult],
|
|
741
|
+
impl=ban_member,
|
|
742
|
+
summary="Ban a member, optionally purging and reporting them in one step",
|
|
743
|
+
description=(
|
|
744
|
+
"`participant` is an `InputPeer`, so a channel posting in the group "
|
|
745
|
+
"— or an anonymous admin's channel — can be banned as well as a "
|
|
746
|
+
"user. `--until` follows Telegram's own rounding: 0, under 30 "
|
|
747
|
+
"seconds and over 366 days all mean forever."
|
|
748
|
+
),
|
|
749
|
+
aliases=("chat.ban",),
|
|
750
|
+
mutating=True,
|
|
751
|
+
destructive=True,
|
|
752
|
+
rate_class="bulk",
|
|
753
|
+
columns=("chat_id", "user_id", "banned", "until"),
|
|
754
|
+
example=[{"chat_id": -1001500, "user_id": 4242, "banned": True}],
|
|
755
|
+
example_args="chat member ban @mygroup @spammer --purge --report --yes",
|
|
756
|
+
covers=("groups-channels-admin.ban-member", "groups-channels-admin.moderate-member"),
|
|
757
|
+
covers_partial=(
|
|
758
|
+
"groups-channels-admin.delete-member-history",
|
|
759
|
+
"groups-channels-admin.report-member",
|
|
760
|
+
),
|
|
761
|
+
coverage_note=(
|
|
762
|
+
"The moderate box bundles them; `chat member delete-history` and "
|
|
763
|
+
"`chat member report` own the standalone ids."
|
|
764
|
+
),
|
|
765
|
+
tags=frozenset({"visible-to-others"}),
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
class MemberUnbanReq(Request):
|
|
770
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
|
|
771
|
+
user: Annotated[
|
|
772
|
+
list[PeerRef], arg(1, metavar="USER", kind="user", variadic=True, help="Who to unban.")
|
|
773
|
+
] = []
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
async def unban_member(ctx: OpContext, req: MemberUnbanReq) -> list[MemberResult]:
|
|
777
|
+
"""Send an all-clear mask. The user may return; they are not re-added."""
|
|
778
|
+
from telethon.tl.functions import channels as fn
|
|
779
|
+
|
|
780
|
+
if not req.user:
|
|
781
|
+
raise UsageError("name at least one user to unban", field="user")
|
|
782
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
783
|
+
channel = _admin.input_channel(peer)
|
|
784
|
+
chat_id = _send.peer_id_of(peer)
|
|
785
|
+
out: list[MemberResult] = []
|
|
786
|
+
for ref in req.user:
|
|
787
|
+
user = await _send.resolve(ctx, ref)
|
|
788
|
+
await _admin.client(ctx)(
|
|
789
|
+
fn.EditBannedRequest(
|
|
790
|
+
channel=channel,
|
|
791
|
+
participant=user,
|
|
792
|
+
banned_rights=_rights.build_banned_rights(_rights.all_allowed()),
|
|
793
|
+
)
|
|
794
|
+
)
|
|
795
|
+
out.append(MemberResult(chat_id=chat_id, user_id=abs(_send.peer_id_of(user)), banned=False))
|
|
796
|
+
return out
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
SPEC_MEMBER_UNBAN = OperationSpec(
|
|
800
|
+
id="chat.member.unban",
|
|
801
|
+
request=MemberUnbanReq,
|
|
802
|
+
response=list[MemberResult],
|
|
803
|
+
impl=unban_member,
|
|
804
|
+
summary="Lift a ban or a restriction",
|
|
805
|
+
description=(
|
|
806
|
+
"Sends an all-clear mask, which takes the user off the Removed and "
|
|
807
|
+
"Restricted lists. It does not put them back in the chat."
|
|
808
|
+
),
|
|
809
|
+
mutating=True,
|
|
810
|
+
columns=("chat_id", "user_id", "banned"),
|
|
811
|
+
example=[{"chat_id": -1001500, "user_id": 4242, "banned": False}],
|
|
812
|
+
example_args="chat member unban @mygroup @alice",
|
|
813
|
+
covers=("groups-channels-admin.unban-member",),
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
# ---------------------------------------------------------------------------
|
|
818
|
+
# chat member restrict
|
|
819
|
+
# ---------------------------------------------------------------------------
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
class MemberRestrictReq(Request):
|
|
823
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Supergroup.")]
|
|
824
|
+
user: Annotated[PeerRef, arg(1, metavar="USER", kind="user", help="The member.")]
|
|
825
|
+
deny: Annotated[
|
|
826
|
+
str | None, opt("--deny", metavar="RIGHTS", help="Rights to take away, comma-separated.")
|
|
827
|
+
] = None
|
|
828
|
+
allow: Annotated[
|
|
829
|
+
str | None, opt("--allow", metavar="RIGHTS", help="Rights to give back, comma-separated.")
|
|
830
|
+
] = None
|
|
831
|
+
none: Annotated[bool, opt("--none", help="Read-only: deny everything but view-messages.")] = (
|
|
832
|
+
False
|
|
833
|
+
)
|
|
834
|
+
everything: Annotated[bool, opt("--all", help="Allow everything.")] = False
|
|
835
|
+
clear: Annotated[bool, opt("--clear", help="Drop the mask; fall back to the chat default.")] = (
|
|
836
|
+
False
|
|
837
|
+
)
|
|
838
|
+
replace: Annotated[
|
|
839
|
+
bool, opt("--replace", help="Treat --deny/--allow as the whole mask, not a patch.")
|
|
840
|
+
] = False
|
|
841
|
+
until: Annotated[
|
|
842
|
+
str | None, opt("--until", metavar="WHEN", help="When the restriction lapses.")
|
|
843
|
+
] = None
|
|
844
|
+
purge: Annotated[bool, opt("--purge", help="Also delete everything they sent.")] = False
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
async def restrict_member(ctx: OpContext, req: MemberRestrictReq) -> MemberResult:
|
|
848
|
+
"""Read the member's current mask, patch it, and send it back complete.
|
|
849
|
+
|
|
850
|
+
`channels.editBanned` replaces the whole mask. Sending only the flags the
|
|
851
|
+
caller named would hand back every restriction they did not mention —
|
|
852
|
+
which is a moderation action nobody asked for.
|
|
853
|
+
"""
|
|
854
|
+
from telethon.tl.functions import channels as fn
|
|
855
|
+
|
|
856
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
857
|
+
if not _admin.is_channel(peer):
|
|
858
|
+
raise UsageError(
|
|
859
|
+
"a basic group has no per-member mask; use `chat permission set`, "
|
|
860
|
+
"or convert the group with `chat convert <chat> supergroup`",
|
|
861
|
+
field="chat",
|
|
862
|
+
)
|
|
863
|
+
user = await _send.resolve(ctx, req.user)
|
|
864
|
+
reply = await _member_of(ctx, peer, user)
|
|
865
|
+
current = _rights.model_from_banned(getattr(reply.participant, "banned_rights", None))
|
|
866
|
+
base = set(_rights.granted_names(current, mask="member")) if current else set()
|
|
867
|
+
if current is None:
|
|
868
|
+
base = set(_rights.all_allowed())
|
|
869
|
+
|
|
870
|
+
deny = _rights.parse_names(req.deny, mask="member", field="deny")
|
|
871
|
+
allow = _rights.parse_names(req.allow, mask="member", field="allow")
|
|
872
|
+
_rights.require_supported(deny + allow, mask="member")
|
|
873
|
+
|
|
874
|
+
if req.clear or req.everything:
|
|
875
|
+
allowed = set(_rights.all_allowed())
|
|
876
|
+
elif req.none:
|
|
877
|
+
allowed = set(_rights.read_only())
|
|
878
|
+
elif req.replace:
|
|
879
|
+
allowed = set(allow) if allow else set(_rights.all_allowed())
|
|
880
|
+
allowed -= set(deny)
|
|
881
|
+
else:
|
|
882
|
+
allowed = (base | set(allow)) - set(deny)
|
|
883
|
+
|
|
884
|
+
until = _rights.parse_until(req.until)
|
|
885
|
+
label, label_unix = _rights.until_label(until)
|
|
886
|
+
await _admin.client(ctx)(
|
|
887
|
+
fn.EditBannedRequest(
|
|
888
|
+
channel=_admin.input_channel(peer),
|
|
889
|
+
participant=user,
|
|
890
|
+
banned_rights=_rights.build_banned_rights(allowed, until=until),
|
|
891
|
+
)
|
|
892
|
+
)
|
|
893
|
+
purged = await _purge_all(ctx, peer, user) if req.purge else None
|
|
894
|
+
order = list(_rights.MEMBER_MASK)
|
|
895
|
+
return MemberResult(
|
|
896
|
+
chat_id=_send.peer_id_of(peer),
|
|
897
|
+
user_id=abs(_send.peer_id_of(user)),
|
|
898
|
+
allow=[n for n in order if n in allowed],
|
|
899
|
+
deny=[n for n in order if n not in allowed and n in _rights.all_allowed()],
|
|
900
|
+
until=label,
|
|
901
|
+
until_unix=label_unix,
|
|
902
|
+
purged_messages=purged,
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
SPEC_MEMBER_RESTRICT = OperationSpec(
|
|
907
|
+
id="chat.member.restrict",
|
|
908
|
+
request=MemberRestrictReq,
|
|
909
|
+
response=MemberResult,
|
|
910
|
+
impl=restrict_member,
|
|
911
|
+
summary="Restrict what one member may do, with an expiry",
|
|
912
|
+
description=(
|
|
913
|
+
"Read-modify-write of the member's current mask: `--deny` and "
|
|
914
|
+
"`--allow` patch it, `--replace` supplies it whole, `--none` is "
|
|
915
|
+
"read-only and `--clear` drops it back to the chat default. Names "
|
|
916
|
+
"come from `chat permission list --mask member`."
|
|
917
|
+
),
|
|
918
|
+
mutating=True,
|
|
919
|
+
columns=("chat_id", "user_id", "until"),
|
|
920
|
+
example={"chat_id": -1001500, "user_id": 4242, "deny": ["send-media"], "allow": []},
|
|
921
|
+
example_args="chat member restrict @mygroup @alice --deny send-media --until 7d",
|
|
922
|
+
covers=("groups-channels-admin.restrict-member",),
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
# ---------------------------------------------------------------------------
|
|
927
|
+
# chat member edit / delete-history / report
|
|
928
|
+
# ---------------------------------------------------------------------------
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
class MemberEditReq(Request):
|
|
932
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
|
|
933
|
+
user: Annotated[PeerRef, arg(1, metavar="USER", kind="user", help="The member.")]
|
|
934
|
+
rank: Annotated[
|
|
935
|
+
str | None,
|
|
936
|
+
opt("--rank", metavar="TITLE", help="Custom title, max 16 chars."),
|
|
937
|
+
] = None
|
|
938
|
+
free_messages: Annotated[
|
|
939
|
+
bool | None,
|
|
940
|
+
opt("--free-messages", help="Let this user message the channel without paying Stars."),
|
|
941
|
+
] = None
|
|
942
|
+
refund: Annotated[
|
|
943
|
+
bool, opt("--refund", help="With --free-messages: refund the Stars already paid.")
|
|
944
|
+
] = False
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
async def edit_member(ctx: OpContext, req: MemberEditReq) -> MemberResult:
|
|
948
|
+
"""A member's custom rank, and their paid-message exception."""
|
|
949
|
+
from telethon.tl.functions import account as acct_fn
|
|
950
|
+
from telethon.tl.functions import messages as fn
|
|
951
|
+
|
|
952
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
953
|
+
user = await _send.resolve(ctx, req.user)
|
|
954
|
+
handle = _admin.client(ctx)
|
|
955
|
+
if req.rank is None and req.free_messages is None:
|
|
956
|
+
raise UsageError("nothing to change: pass --rank or --free-messages", field="rank")
|
|
957
|
+
|
|
958
|
+
if req.rank is not None:
|
|
959
|
+
await handle(fn.EditChatParticipantRankRequest(peer=peer, participant=user, rank=req.rank))
|
|
960
|
+
if req.free_messages is not None:
|
|
961
|
+
await handle(
|
|
962
|
+
acct_fn.ToggleNoPaidMessagesExceptionRequest(
|
|
963
|
+
user_id=_admin.input_user(user),
|
|
964
|
+
parent_peer=peer,
|
|
965
|
+
refund_charged=req.refund or None,
|
|
966
|
+
require_payment=None if req.free_messages else True,
|
|
967
|
+
)
|
|
968
|
+
)
|
|
969
|
+
return MemberResult(
|
|
970
|
+
chat_id=_send.peer_id_of(peer),
|
|
971
|
+
user_id=abs(_send.peer_id_of(user)),
|
|
972
|
+
rank=req.rank,
|
|
973
|
+
free_messages=req.free_messages,
|
|
974
|
+
)
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
SPEC_MEMBER_EDIT = OperationSpec(
|
|
978
|
+
id="chat.member.edit",
|
|
979
|
+
request=MemberEditReq,
|
|
980
|
+
response=MemberResult,
|
|
981
|
+
impl=edit_member,
|
|
982
|
+
summary="Edit a member's custom rank and paid-message exception",
|
|
983
|
+
description=(
|
|
984
|
+
"The rank shows up as `message.from_rank` in supergroups and "
|
|
985
|
+
"`chatParticipant.rank` in basic groups; `chat admin promote --rank` "
|
|
986
|
+
"writes the same field for admins."
|
|
987
|
+
),
|
|
988
|
+
mutating=True,
|
|
989
|
+
columns=("chat_id", "user_id", "rank"),
|
|
990
|
+
example={"chat_id": -1001500, "user_id": 4242, "rank": "moderator"},
|
|
991
|
+
example_args="chat member edit @mygroup @alice --rank moderator",
|
|
992
|
+
covers=("groups-channels-admin.paid-messages-price",),
|
|
993
|
+
covers_partial=("groups-channels-admin.member-tag-rank",),
|
|
994
|
+
coverage_note="Ranks for plain members; `chat admin promote --rank` owns the admin half.",
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
class MemberPurgeReq(Request):
|
|
999
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Supergroup or channel.")]
|
|
1000
|
+
user: Annotated[PeerRef, arg(1, metavar="USER", kind="user", help="Whose messages to delete.")]
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
async def delete_member_history(ctx: OpContext, req: MemberPurgeReq) -> MemberResult:
|
|
1004
|
+
"""Delete every message one member ever sent, draining the offset loop."""
|
|
1005
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
1006
|
+
if not _admin.is_channel(peer):
|
|
1007
|
+
raise UsageError(
|
|
1008
|
+
"only supergroups and channels support deleting one member's history", field="chat"
|
|
1009
|
+
)
|
|
1010
|
+
user = await _send.resolve(ctx, req.user)
|
|
1011
|
+
deleted = await _purge_all(ctx, peer, user)
|
|
1012
|
+
return MemberResult(
|
|
1013
|
+
chat_id=_send.peer_id_of(peer),
|
|
1014
|
+
user_id=abs(_send.peer_id_of(user)),
|
|
1015
|
+
deleted=deleted,
|
|
1016
|
+
purged_messages=deleted,
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
SPEC_MEMBER_DELETE_HISTORY = OperationSpec(
|
|
1021
|
+
id="chat.member.delete-history",
|
|
1022
|
+
request=MemberPurgeReq,
|
|
1023
|
+
response=MemberResult,
|
|
1024
|
+
impl=delete_member_history,
|
|
1025
|
+
summary="Delete every message one member ever sent",
|
|
1026
|
+
description=(
|
|
1027
|
+
"`channels.deleteParticipantHistory` answers with "
|
|
1028
|
+
"`messages.affectedHistory` and an offset to resume from; the loop "
|
|
1029
|
+
"runs in the daemon until the offset is 0, because deleting the "
|
|
1030
|
+
"first hundred messages and reporting success is not deleting a "
|
|
1031
|
+
"history."
|
|
1032
|
+
),
|
|
1033
|
+
aliases=("chat.member.purge",),
|
|
1034
|
+
mutating=True,
|
|
1035
|
+
destructive=True,
|
|
1036
|
+
rate_class="bulk",
|
|
1037
|
+
timeout_s=300,
|
|
1038
|
+
columns=("chat_id", "user_id", "deleted"),
|
|
1039
|
+
example={"chat_id": -1001500, "user_id": 4242, "deleted": 42},
|
|
1040
|
+
example_args="chat member delete-history @mygroup @spammer --yes",
|
|
1041
|
+
covers=("groups-channels-admin.delete-member-history",),
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
class MemberReportReq(Request):
|
|
1046
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Supergroup or channel.")]
|
|
1047
|
+
user: Annotated[PeerRef, arg(1, metavar="USER", kind="user", help="Who to report.")]
|
|
1048
|
+
messages: Annotated[
|
|
1049
|
+
list[int], opt("--messages", metavar="ID", help="Message ids to attach to the report.")
|
|
1050
|
+
] = []
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
async def report_member(ctx: OpContext, req: MemberReportReq) -> MemberResult:
|
|
1054
|
+
"""Report a member as spam. Reporting the *chat* is `chat report`."""
|
|
1055
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
1056
|
+
user = await _send.resolve(ctx, req.user)
|
|
1057
|
+
await _report_spam(ctx, peer, user, req.messages)
|
|
1058
|
+
return MemberResult(
|
|
1059
|
+
chat_id=_send.peer_id_of(peer), user_id=abs(_send.peer_id_of(user)), reported=True
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
SPEC_MEMBER_REPORT = OperationSpec(
|
|
1064
|
+
id="chat.member.report",
|
|
1065
|
+
request=MemberReportReq,
|
|
1066
|
+
response=MemberResult,
|
|
1067
|
+
impl=report_member,
|
|
1068
|
+
summary="Report a member (and optionally their messages) as spam",
|
|
1069
|
+
mutating=True,
|
|
1070
|
+
columns=("chat_id", "user_id", "reported"),
|
|
1071
|
+
example={"chat_id": -1001500, "user_id": 4242, "reported": True},
|
|
1072
|
+
example_args="chat member report @mygroup @spammer --messages 918 --yes",
|
|
1073
|
+
covers=("groups-channels-admin.report-member",),
|
|
1074
|
+
tags=frozenset({"visible-to-others"}),
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
# ---------------------------------------------------------------------------
|
|
1079
|
+
# chat request list / approve / deny
|
|
1080
|
+
# ---------------------------------------------------------------------------
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
class RequestListReq(Request):
|
|
1084
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
|
|
1085
|
+
link: Annotated[
|
|
1086
|
+
str | None, opt("--link", metavar="LINK", help="Only requests from this invite link.")
|
|
1087
|
+
] = None
|
|
1088
|
+
search: Annotated[str, opt("--search", "-s", metavar="TEXT", help="Filter by name.")] = ""
|
|
1089
|
+
approve: Annotated[
|
|
1090
|
+
list[PeerRef], opt("--approve", metavar="USER", kind="user", help="Approve one requester.")
|
|
1091
|
+
] = []
|
|
1092
|
+
decline: Annotated[
|
|
1093
|
+
list[PeerRef], opt("--decline", metavar="USER", kind="user", help="Decline one requester.")
|
|
1094
|
+
] = []
|
|
1095
|
+
approve_all: Annotated[bool, opt("--approve-all", help="Approve every pending request.")] = (
|
|
1096
|
+
False
|
|
1097
|
+
)
|
|
1098
|
+
decline_all: Annotated[bool, opt("--decline-all", help="Decline every pending request.")] = (
|
|
1099
|
+
False
|
|
1100
|
+
)
|
|
1101
|
+
community: Annotated[
|
|
1102
|
+
bool, opt("--community", help="Target a layer-229 community's peer-link requests.")
|
|
1103
|
+
] = False
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
async def _hide_requests(
|
|
1107
|
+
ctx: OpContext,
|
|
1108
|
+
peer: Any,
|
|
1109
|
+
*,
|
|
1110
|
+
approve: list[PeerRef],
|
|
1111
|
+
decline: list[PeerRef],
|
|
1112
|
+
approve_all: bool,
|
|
1113
|
+
decline_all: bool,
|
|
1114
|
+
link: str | None,
|
|
1115
|
+
) -> RequestResult:
|
|
1116
|
+
"""Answer join requests, collecting per-user failures instead of aborting."""
|
|
1117
|
+
from telethon.tl.functions import messages as fn
|
|
1118
|
+
|
|
1119
|
+
handle = _admin.client(ctx)
|
|
1120
|
+
result = RequestResult(chat_id=_send.peer_id_of(peer))
|
|
1121
|
+
for refs, approved in ((approve, True), (decline, False)):
|
|
1122
|
+
for ref in refs:
|
|
1123
|
+
user = await _send.resolve(ctx, ref)
|
|
1124
|
+
user_id = abs(_send.peer_id_of(user))
|
|
1125
|
+
try:
|
|
1126
|
+
await handle(
|
|
1127
|
+
fn.HideChatJoinRequestRequest(
|
|
1128
|
+
peer=peer, user_id=_admin.input_user(user), approved=approved
|
|
1129
|
+
)
|
|
1130
|
+
)
|
|
1131
|
+
except Exception as exc:
|
|
1132
|
+
result.failed.append(MissingInvitee(user_id=user_id, reason=str(exc)))
|
|
1133
|
+
continue
|
|
1134
|
+
(result.approved if approved else result.denied).append(user_id)
|
|
1135
|
+
if approve_all or decline_all:
|
|
1136
|
+
await handle(fn.HideAllChatJoinRequestsRequest(peer=peer, approved=approve_all, link=link))
|
|
1137
|
+
result.all = True
|
|
1138
|
+
return result
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
async def list_requests(ctx: OpContext, req: RequestListReq) -> Page[JoinRequest]:
|
|
1142
|
+
"""Pending join requests, and — with the answer flags — the answer to them.
|
|
1143
|
+
|
|
1144
|
+
Listing is a read, so the op is not marked mutating and `--dry-run
|
|
1145
|
+
chat request list` keeps listing. The answer flags check `ctx.dry_run`
|
|
1146
|
+
themselves and warn instead of firing (the same rule `folder list --tags`
|
|
1147
|
+
follows).
|
|
1148
|
+
"""
|
|
1149
|
+
from datetime import datetime, timezone
|
|
1150
|
+
|
|
1151
|
+
from telethon.tl import types
|
|
1152
|
+
from telethon.tl.functions import messages as fn
|
|
1153
|
+
|
|
1154
|
+
if req.community:
|
|
1155
|
+
from tlgr.ops._layer import community_gap
|
|
1156
|
+
|
|
1157
|
+
community_gap("chat request list --community", "communities.getPeerLinkRequests")
|
|
1158
|
+
|
|
1159
|
+
limit, state = _admin.window(ctx, "chat.request.list", PageKind.PARTICIPANTS)
|
|
1160
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
1161
|
+
|
|
1162
|
+
answering = bool(req.approve or req.decline or req.approve_all or req.decline_all)
|
|
1163
|
+
if answering:
|
|
1164
|
+
if ctx.dry_run:
|
|
1165
|
+
ctx.warn("--dry-run: the join requests were listed, not answered")
|
|
1166
|
+
else:
|
|
1167
|
+
answered = await _hide_requests(
|
|
1168
|
+
ctx,
|
|
1169
|
+
peer,
|
|
1170
|
+
approve=req.approve,
|
|
1171
|
+
decline=req.decline,
|
|
1172
|
+
approve_all=req.approve_all,
|
|
1173
|
+
decline_all=req.decline_all,
|
|
1174
|
+
link=req.link,
|
|
1175
|
+
)
|
|
1176
|
+
ctx.emit(
|
|
1177
|
+
"chat_join_requests_answered",
|
|
1178
|
+
{
|
|
1179
|
+
"chat_id": answered.chat_id,
|
|
1180
|
+
"approved": answered.approved,
|
|
1181
|
+
"denied": answered.denied,
|
|
1182
|
+
},
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
offset_date = state.get("date")
|
|
1186
|
+
offset_user: Any = types.InputUserEmpty()
|
|
1187
|
+
if state.get("user"):
|
|
1188
|
+
offset_user = types.InputUser(
|
|
1189
|
+
user_id=int(state["user"]), access_hash=int(state.get("hash", 0) or 0)
|
|
1190
|
+
)
|
|
1191
|
+
reply = await _admin.client(ctx)(
|
|
1192
|
+
fn.GetChatInviteImportersRequest(
|
|
1193
|
+
peer=peer,
|
|
1194
|
+
offset_date=datetime.fromtimestamp(offset_date, tz=timezone.utc)
|
|
1195
|
+
if offset_date
|
|
1196
|
+
else None,
|
|
1197
|
+
offset_user=offset_user,
|
|
1198
|
+
limit=limit,
|
|
1199
|
+
requested=True,
|
|
1200
|
+
link=req.link,
|
|
1201
|
+
q=req.search or None,
|
|
1202
|
+
)
|
|
1203
|
+
)
|
|
1204
|
+
entities = _admin.entity_map(reply)
|
|
1205
|
+
items: list[JoinRequest] = []
|
|
1206
|
+
last: Any = None
|
|
1207
|
+
for row in getattr(reply, "importers", None) or []:
|
|
1208
|
+
user_id = int(getattr(row, "user_id", 0) or 0)
|
|
1209
|
+
entity = entities.get(user_id)
|
|
1210
|
+
items.append(
|
|
1211
|
+
JoinRequest(
|
|
1212
|
+
user_id=user_id,
|
|
1213
|
+
username=getattr(entity, "username", None),
|
|
1214
|
+
name=_admin.display_name(entity),
|
|
1215
|
+
date=fmt_dt(getattr(row, "date", None)),
|
|
1216
|
+
date_unix=to_unix(getattr(row, "date", None)),
|
|
1217
|
+
about=getattr(row, "about", None),
|
|
1218
|
+
via_link=req.link,
|
|
1219
|
+
approved_by=getattr(row, "approved_by", None),
|
|
1220
|
+
)
|
|
1221
|
+
)
|
|
1222
|
+
last = row
|
|
1223
|
+
next_state: dict[str, Any] = {}
|
|
1224
|
+
if last is not None:
|
|
1225
|
+
entity = entities.get(int(getattr(last, "user_id", 0) or 0))
|
|
1226
|
+
next_state = {
|
|
1227
|
+
"date": to_unix(getattr(last, "date", None)) or 0,
|
|
1228
|
+
"user": int(getattr(last, "user_id", 0) or 0),
|
|
1229
|
+
"hash": int(getattr(entity, "access_hash", 0) or 0),
|
|
1230
|
+
}
|
|
1231
|
+
return build_page(
|
|
1232
|
+
items,
|
|
1233
|
+
op="chat.request.list",
|
|
1234
|
+
kind=PageKind.PARTICIPANTS,
|
|
1235
|
+
state=next_state,
|
|
1236
|
+
account=ctx.account,
|
|
1237
|
+
limit=limit,
|
|
1238
|
+
total=int(getattr(reply, "count", 0) or 0),
|
|
1239
|
+
)
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
SPEC_REQUEST_LIST = OperationSpec(
|
|
1243
|
+
id="chat.request.list",
|
|
1244
|
+
request=RequestListReq,
|
|
1245
|
+
response=Page[JoinRequest],
|
|
1246
|
+
impl=list_requests,
|
|
1247
|
+
summary="List pending join requests",
|
|
1248
|
+
description=(
|
|
1249
|
+
"The answer flags (`--approve`, `--decline`, `--approve-all`, "
|
|
1250
|
+
"`--decline-all`) run before the listing and honour `--dry-run` "
|
|
1251
|
+
"themselves, so listing keeps working under a dry run instead of "
|
|
1252
|
+
"printing a stub."
|
|
1253
|
+
),
|
|
1254
|
+
aliases=("chat.join-requests",),
|
|
1255
|
+
paginated=PageKind.PARTICIPANTS,
|
|
1256
|
+
columns=("user_id", "username", "name", "date"),
|
|
1257
|
+
headers=("User", "Username", "Name", "Requested"),
|
|
1258
|
+
example={
|
|
1259
|
+
"items": [{"user_id": 4242, "username": "alice", "name": "Alice"}],
|
|
1260
|
+
"has_more": False,
|
|
1261
|
+
},
|
|
1262
|
+
example_args="chat request list @mygroup",
|
|
1263
|
+
covers=("dialogs.community-join-requests", "groups-channels-admin.join-request-list"),
|
|
1264
|
+
covers_partial=("dialogs.join-requests-badge",),
|
|
1265
|
+
coverage_note="The badge itself is `chat list --with-join-requests`; this is the queue.",
|
|
1266
|
+
tags=frozenset({"mutating-checked"}),
|
|
1267
|
+
)
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
class RequestAnswerReq(Request):
|
|
1271
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
|
|
1272
|
+
user: Annotated[
|
|
1273
|
+
list[PeerRef], arg(1, metavar="USER", kind="user", variadic=True, help="Which requesters.")
|
|
1274
|
+
] = []
|
|
1275
|
+
everyone: Annotated[bool, opt("--all", help="Answer every pending request.")] = False
|
|
1276
|
+
link: Annotated[
|
|
1277
|
+
str | None, opt("--link", metavar="LINK", help="With --all: only this invite link.")
|
|
1278
|
+
] = None
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
async def approve_requests(ctx: OpContext, req: RequestAnswerReq) -> RequestResult:
|
|
1282
|
+
"""Approve join requests, one by one or the whole queue."""
|
|
1283
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
1284
|
+
if not req.user and not req.everyone:
|
|
1285
|
+
raise UsageError("name a user, or pass --all", field="user")
|
|
1286
|
+
result = await _hide_requests(
|
|
1287
|
+
ctx,
|
|
1288
|
+
peer,
|
|
1289
|
+
approve=list(req.user),
|
|
1290
|
+
decline=[],
|
|
1291
|
+
approve_all=req.everyone,
|
|
1292
|
+
decline_all=False,
|
|
1293
|
+
link=req.link,
|
|
1294
|
+
)
|
|
1295
|
+
ctx.emit(
|
|
1296
|
+
"chat_join_requests_answered",
|
|
1297
|
+
{"chat_id": result.chat_id, "approved": result.approved, "all": result.all},
|
|
1298
|
+
)
|
|
1299
|
+
return result
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
SPEC_REQUEST_APPROVE = OperationSpec(
|
|
1303
|
+
id="chat.request.approve",
|
|
1304
|
+
request=RequestAnswerReq,
|
|
1305
|
+
response=RequestResult,
|
|
1306
|
+
impl=approve_requests,
|
|
1307
|
+
summary="Approve join requests",
|
|
1308
|
+
description="Per-user failures are collected in `failed` rather than aborting the batch.",
|
|
1309
|
+
mutating=True,
|
|
1310
|
+
rate_class="bulk",
|
|
1311
|
+
columns=("chat_id", "approved"),
|
|
1312
|
+
example={"chat_id": -1001500, "approved": [4242], "failed": []},
|
|
1313
|
+
example_args="chat request approve @mygroup @alice",
|
|
1314
|
+
covers=("groups-channels-admin.join-request-approve-all",),
|
|
1315
|
+
covers_partial=("groups-channels-admin.join-request-approve-one",),
|
|
1316
|
+
coverage_note="Approving one is here; declining one is `chat request deny`.",
|
|
1317
|
+
tags=frozenset({"visible-to-others"}),
|
|
1318
|
+
)
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
async def deny_requests(ctx: OpContext, req: RequestAnswerReq) -> RequestResult:
|
|
1322
|
+
"""Decline join requests. A declined user may ask again after a cooldown."""
|
|
1323
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
1324
|
+
if not req.user and not req.everyone:
|
|
1325
|
+
raise UsageError("name a user, or pass --all", field="user")
|
|
1326
|
+
result = await _hide_requests(
|
|
1327
|
+
ctx,
|
|
1328
|
+
peer,
|
|
1329
|
+
approve=[],
|
|
1330
|
+
decline=list(req.user),
|
|
1331
|
+
approve_all=False,
|
|
1332
|
+
decline_all=req.everyone,
|
|
1333
|
+
link=req.link,
|
|
1334
|
+
)
|
|
1335
|
+
ctx.emit(
|
|
1336
|
+
"chat_join_requests_answered",
|
|
1337
|
+
{"chat_id": result.chat_id, "denied": result.denied, "all": result.all},
|
|
1338
|
+
)
|
|
1339
|
+
return result
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
SPEC_REQUEST_DENY = OperationSpec(
|
|
1343
|
+
id="chat.request.deny",
|
|
1344
|
+
request=RequestAnswerReq,
|
|
1345
|
+
response=RequestResult,
|
|
1346
|
+
impl=deny_requests,
|
|
1347
|
+
summary="Decline join requests",
|
|
1348
|
+
mutating=True,
|
|
1349
|
+
destructive=True,
|
|
1350
|
+
rate_class="bulk",
|
|
1351
|
+
columns=("chat_id", "denied"),
|
|
1352
|
+
example={"chat_id": -1001500, "denied": [4242], "failed": []},
|
|
1353
|
+
example_args="chat request deny @mygroup --all --yes",
|
|
1354
|
+
covers=("groups-channels-admin.join-request-approve-one",),
|
|
1355
|
+
covers_partial=("groups-channels-admin.join-request-approve-all",),
|
|
1356
|
+
coverage_note="Declining the whole queue is here too; `chat request approve` owns the other id.",
|
|
1357
|
+
)
|