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_admin.py
ADDED
|
@@ -0,0 +1,929 @@
|
|
|
1
|
+
"""`chat admin *`, `chat permission *`, `chat admin-log *` and `chat transfer`.
|
|
2
|
+
|
|
3
|
+
The rights vocabulary is the spine of this module and it lives in
|
|
4
|
+
`ops/_rights.py`, so `chat admin promote --rights`, `chat member restrict
|
|
5
|
+
--deny` and `chat permission set --allow` cannot drift apart. Three rules
|
|
6
|
+
follow from the API rather than from taste.
|
|
7
|
+
|
|
8
|
+
* **Both masks are replaced, never patched, server-side.** Every writer here
|
|
9
|
+
therefore reads the current mask first and sends a complete one.
|
|
10
|
+
* **A right this Telethon has no field for is refused, not dropped.**
|
|
11
|
+
`manage-linked-peers` and `manage-welcome-messages` are layer-229 flags;
|
|
12
|
+
asking for one exits 13 with the reason instead of quietly granting less
|
|
13
|
+
than the caller asked for.
|
|
14
|
+
* **The admin log is normalised but never lossy.** Fifty-odd
|
|
15
|
+
`channelAdminLogEventAction*` constructors become `{action, prev, new}` so
|
|
16
|
+
a script can switch on one string, and `raw_type` keeps the TL name so
|
|
17
|
+
nothing that was in the reply is unavailable.
|
|
18
|
+
|
|
19
|
+
Telethon is imported inside functions, never at module scope (§2.2).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import base64
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from typing import Annotated, Any
|
|
27
|
+
|
|
28
|
+
from tlgr.core.errors import UsageError
|
|
29
|
+
from tlgr.core.pagination import PageKind, build_page
|
|
30
|
+
from tlgr.core.timefmt import fmt_dt, to_unix
|
|
31
|
+
from tlgr.models.admin import (
|
|
32
|
+
AdminLogEvent,
|
|
33
|
+
AdminResult,
|
|
34
|
+
AntiSpamReport,
|
|
35
|
+
Participant,
|
|
36
|
+
PermissionResult,
|
|
37
|
+
PermissionView,
|
|
38
|
+
RightInfo,
|
|
39
|
+
TransferResult,
|
|
40
|
+
)
|
|
41
|
+
from tlgr.models.base import Request
|
|
42
|
+
from tlgr.models.page import Page
|
|
43
|
+
from tlgr.models.peer import PeerRef
|
|
44
|
+
from tlgr.ops import _admin, _rights, _send
|
|
45
|
+
from tlgr.ops._params import arg, choice, opt
|
|
46
|
+
from tlgr.ops._spec import OpContext, OperationSpec
|
|
47
|
+
|
|
48
|
+
__all__ = [name for name in dir() if name.startswith("SPEC_")]
|
|
49
|
+
|
|
50
|
+
_EXAMPLE_ADMIN: dict[str, Any] = {
|
|
51
|
+
"id": 4242,
|
|
52
|
+
"user_id": 4242,
|
|
53
|
+
"name": "Alice",
|
|
54
|
+
"status": "admin",
|
|
55
|
+
"rank": "moderator",
|
|
56
|
+
"can_edit": True,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#: `--filter` names for the admin log → `ChannelAdminLogEventsFilter` flags.
|
|
60
|
+
#: Telethon's own `iter_admin_log` shifts four of them (restrict→ban,
|
|
61
|
+
#: unrestrict→unban, ban→kick, unban→unkick) and has no flag at all for
|
|
62
|
+
#: invites/send/forums/sub_extend/edit_rank, so tlgr builds the filter itself
|
|
63
|
+
#: and keeps the CLI names identical to the API's.
|
|
64
|
+
_LOG_FILTERS = {
|
|
65
|
+
"join": "join",
|
|
66
|
+
"leave": "leave",
|
|
67
|
+
"invite": "invite",
|
|
68
|
+
"ban": "ban",
|
|
69
|
+
"unban": "unban",
|
|
70
|
+
"kick": "kick",
|
|
71
|
+
"unkick": "unkick",
|
|
72
|
+
"promote": "promote",
|
|
73
|
+
"demote": "demote",
|
|
74
|
+
"info": "info",
|
|
75
|
+
"settings": "settings",
|
|
76
|
+
"pinned": "pinned",
|
|
77
|
+
"edit": "edit",
|
|
78
|
+
"delete": "delete",
|
|
79
|
+
"group-call": "group_call",
|
|
80
|
+
"invites": "invites",
|
|
81
|
+
"send": "send",
|
|
82
|
+
"forums": "forums",
|
|
83
|
+
"sub-extend": "sub_extend",
|
|
84
|
+
"edit-rank": "edit_rank",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
_ACTION_PREFIX = "ChannelAdminLogEventAction"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _kebab(name: str) -> str:
|
|
91
|
+
out: list[str] = []
|
|
92
|
+
for index, char in enumerate(name):
|
|
93
|
+
if char.isupper() and index:
|
|
94
|
+
out.append("-")
|
|
95
|
+
out.append(char.lower())
|
|
96
|
+
return "".join(out)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _plain(value: Any) -> Any:
|
|
100
|
+
"""A Telethon object as plain JSON-able data, losing nothing on the way.
|
|
101
|
+
|
|
102
|
+
`prev`/`new` in an admin-log row can be a whole message, a rights mask or
|
|
103
|
+
an invite link, and guessing which fields matter is how a log viewer ends
|
|
104
|
+
up hiding the one field somebody needed.
|
|
105
|
+
"""
|
|
106
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
107
|
+
return value
|
|
108
|
+
if isinstance(value, bytes):
|
|
109
|
+
return base64.b64encode(value).decode()
|
|
110
|
+
if isinstance(value, datetime):
|
|
111
|
+
return fmt_dt(value)
|
|
112
|
+
if isinstance(value, (list, tuple)):
|
|
113
|
+
return [_plain(item) for item in value]
|
|
114
|
+
if isinstance(value, dict):
|
|
115
|
+
return {key: _plain(item) for key, item in value.items() if item is not None}
|
|
116
|
+
as_dict = getattr(value, "to_dict", None)
|
|
117
|
+
if callable(as_dict):
|
|
118
|
+
return _plain(as_dict())
|
|
119
|
+
return str(value)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# chat admin list
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class AdminListReq(Request):
|
|
128
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
|
|
129
|
+
rights: Annotated[
|
|
130
|
+
bool, opt("--rights/--no-rights", help="Expand each admin's mask into right names.")
|
|
131
|
+
] = True
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def _antispam_bot(ctx: OpContext, peer: Any) -> Participant | None:
|
|
135
|
+
"""The anti-spam bot, which the server never lists but the GUI shows.
|
|
136
|
+
|
|
137
|
+
`channelFull.antispam` means Telegram's own bot is moderating; it holds
|
|
138
|
+
admin rights and deletes messages, so leaving it out of "who administers
|
|
139
|
+
this chat" would make the admin log's deletions look like they came from
|
|
140
|
+
nobody.
|
|
141
|
+
"""
|
|
142
|
+
from telethon.tl.functions import help as help_fn
|
|
143
|
+
|
|
144
|
+
full, _entity, _entities = await _admin.full_chat(ctx, peer)
|
|
145
|
+
if not getattr(full, "antispam", False):
|
|
146
|
+
return None
|
|
147
|
+
config = await _admin.client(ctx)(help_fn.GetAppConfigRequest(hash=0))
|
|
148
|
+
bot_id = 0
|
|
149
|
+
for item in getattr(getattr(config, "config", None), "value", None) or []:
|
|
150
|
+
if getattr(item, "key", "") == "telegram_antispam_user_id":
|
|
151
|
+
raw = getattr(getattr(item, "value", None), "value", 0)
|
|
152
|
+
bot_id = int(float(raw or 0))
|
|
153
|
+
if not bot_id:
|
|
154
|
+
return None
|
|
155
|
+
return Participant(
|
|
156
|
+
id=bot_id,
|
|
157
|
+
user_id=bot_id,
|
|
158
|
+
chat_id=_send.peer_id_of(peer),
|
|
159
|
+
name="Telegram Anti-Spam",
|
|
160
|
+
is_bot=True,
|
|
161
|
+
status="admin",
|
|
162
|
+
rank="anti-spam",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def list_admins(ctx: OpContext, req: AdminListReq) -> Page[Participant]:
|
|
167
|
+
"""Administrators with their rights, ranks and who promoted them."""
|
|
168
|
+
from telethon.tl import types
|
|
169
|
+
from telethon.tl.functions import channels as fn
|
|
170
|
+
|
|
171
|
+
limit, state = _admin.window(ctx, "chat.admin.list", PageKind.PARTICIPANTS)
|
|
172
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
173
|
+
chat_id = _send.peer_id_of(peer)
|
|
174
|
+
offset = int(state.get("offset", 0) or 0)
|
|
175
|
+
|
|
176
|
+
if not _admin.is_channel(peer):
|
|
177
|
+
full, _entity, entities = await _admin.full_chat(ctx, peer)
|
|
178
|
+
holder = getattr(full, "participants", None)
|
|
179
|
+
rows = [
|
|
180
|
+
row
|
|
181
|
+
for row in (getattr(holder, "participants", None) or [])
|
|
182
|
+
if type(row).__name__ != "ChatParticipant"
|
|
183
|
+
]
|
|
184
|
+
window = rows[offset : offset + limit]
|
|
185
|
+
items = [
|
|
186
|
+
_admin.participant_model(row, chat_id=chat_id, entities=entities) for row in window
|
|
187
|
+
]
|
|
188
|
+
return build_page(
|
|
189
|
+
items,
|
|
190
|
+
op="chat.admin.list",
|
|
191
|
+
kind=PageKind.PARTICIPANTS,
|
|
192
|
+
state={"offset": offset + len(window)},
|
|
193
|
+
account=ctx.account,
|
|
194
|
+
has_more=offset + len(window) < len(rows),
|
|
195
|
+
total=len(rows),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
reply = await _admin.client(ctx)(
|
|
199
|
+
fn.GetParticipantsRequest(
|
|
200
|
+
channel=_admin.input_channel(peer),
|
|
201
|
+
filter=types.ChannelParticipantsAdmins(),
|
|
202
|
+
offset=offset,
|
|
203
|
+
limit=limit,
|
|
204
|
+
hash=0,
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
entities = _admin.entity_map(reply)
|
|
208
|
+
items = [
|
|
209
|
+
_admin.participant_model(row, chat_id=chat_id, entities=entities)
|
|
210
|
+
for row in (getattr(reply, "participants", None) or [])
|
|
211
|
+
]
|
|
212
|
+
if not req.rights:
|
|
213
|
+
for item in items:
|
|
214
|
+
item.admin_rights = None
|
|
215
|
+
total = int(getattr(reply, "count", 0) or 0)
|
|
216
|
+
if not offset:
|
|
217
|
+
bot = await _antispam_bot(ctx, peer)
|
|
218
|
+
if bot is not None:
|
|
219
|
+
items.append(bot)
|
|
220
|
+
total += 1
|
|
221
|
+
return build_page(
|
|
222
|
+
items,
|
|
223
|
+
op="chat.admin.list",
|
|
224
|
+
kind=PageKind.PARTICIPANTS,
|
|
225
|
+
state={"offset": offset + len(items)},
|
|
226
|
+
account=ctx.account,
|
|
227
|
+
limit=limit,
|
|
228
|
+
total=total,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
SPEC_ADMIN_LIST = OperationSpec(
|
|
233
|
+
id="chat.admin.list",
|
|
234
|
+
request=AdminListReq,
|
|
235
|
+
response=Page[Participant],
|
|
236
|
+
impl=list_admins,
|
|
237
|
+
summary="List administrators with their rights and ranks",
|
|
238
|
+
description=(
|
|
239
|
+
"The creator is reported as `status: creator`. When the chat has "
|
|
240
|
+
"Telegram's aggressive anti-spam turned on, its bot is appended "
|
|
241
|
+
"locally exactly as the GUI does — the server never lists it, and "
|
|
242
|
+
"its deletions do show up in the admin log."
|
|
243
|
+
),
|
|
244
|
+
paginated=PageKind.PARTICIPANTS,
|
|
245
|
+
columns=("id", "name", "status", "rank", "can_edit"),
|
|
246
|
+
headers=("ID", "Name", "Status", "Rank", "Editable"),
|
|
247
|
+
example={"items": [_EXAMPLE_ADMIN], "has_more": False, "total": 1},
|
|
248
|
+
example_args="chat admin list @mygroup",
|
|
249
|
+
covers=("groups-channels-admin.admin-list", "groups-channels-admin.basic-group-admin"),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ---------------------------------------------------------------------------
|
|
254
|
+
# chat admin promote / demote
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class AdminPromoteReq(Request):
|
|
259
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
|
|
260
|
+
user: Annotated[PeerRef, arg(1, metavar="USER", kind="user", help="Who to promote.")]
|
|
261
|
+
rights: Annotated[
|
|
262
|
+
str | None, opt("--rights", metavar="RIGHTS", help="The whole mask, comma-separated.")
|
|
263
|
+
] = None
|
|
264
|
+
grant: Annotated[
|
|
265
|
+
str | None, opt("--grant", metavar="RIGHTS", help="Add these to the current mask.")
|
|
266
|
+
] = None
|
|
267
|
+
revoke: Annotated[
|
|
268
|
+
str | None, opt("--revoke", metavar="RIGHTS", help="Remove these from the current mask.")
|
|
269
|
+
] = None
|
|
270
|
+
everything: Annotated[bool, opt("--all", help="Grant every right you hold yourself.")] = False
|
|
271
|
+
none: Annotated[bool, opt("--none", help="Empty mask (the same as `chat admin demote`).")] = (
|
|
272
|
+
False
|
|
273
|
+
)
|
|
274
|
+
except_rights: Annotated[
|
|
275
|
+
str | None, opt("--except", metavar="RIGHTS", help="With --all: withhold these.")
|
|
276
|
+
] = None
|
|
277
|
+
rank: Annotated[
|
|
278
|
+
str | None,
|
|
279
|
+
opt("--rank", metavar="TITLE", help="Custom title, max 16 chars."),
|
|
280
|
+
] = None
|
|
281
|
+
anonymous: Annotated[
|
|
282
|
+
bool | None, opt("--anonymous", help="Shorthand for the `anonymous` right.")
|
|
283
|
+
] = None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def _my_admin_rights(ctx: OpContext, peer: Any) -> set[str]:
|
|
287
|
+
"""What the caller may hand out: you cannot grant a right you do not hold.
|
|
288
|
+
|
|
289
|
+
The owner holds everything, which is why the creator short-circuits; a
|
|
290
|
+
chat we cannot query answers with the full set so `--all` degrades to
|
|
291
|
+
"ask for everything and let the server refuse" rather than to nothing.
|
|
292
|
+
"""
|
|
293
|
+
from telethon.tl import types
|
|
294
|
+
from telethon.tl.functions import channels as fn
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
reply = await _admin.client(ctx)(
|
|
298
|
+
fn.GetParticipantRequest(
|
|
299
|
+
channel=_admin.input_channel(peer), participant=types.InputPeerSelf()
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
except Exception:
|
|
303
|
+
return set(_rights.all_allowed(mask="admin"))
|
|
304
|
+
participant = getattr(reply, "participant", None)
|
|
305
|
+
if type(participant).__name__ == "ChannelParticipantCreator":
|
|
306
|
+
return set(_rights.all_allowed(mask="admin"))
|
|
307
|
+
mask = _rights.model_from_admin(getattr(participant, "admin_rights", None))
|
|
308
|
+
return set(_rights.granted_names(mask, mask="admin"))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
async def promote_admin(ctx: OpContext, req: AdminPromoteReq) -> AdminResult:
|
|
312
|
+
"""Promote a member, or re-cut an existing admin's mask and rank."""
|
|
313
|
+
from telethon.tl.functions import channels as chan_fn
|
|
314
|
+
from telethon.tl.functions import messages as msg_fn
|
|
315
|
+
|
|
316
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
317
|
+
user = await _send.resolve(ctx, req.user)
|
|
318
|
+
chat_id = _send.peer_id_of(peer)
|
|
319
|
+
user_id = abs(_send.peer_id_of(user))
|
|
320
|
+
|
|
321
|
+
absolute = _rights.parse_names(req.rights, mask="admin", field="rights")
|
|
322
|
+
add = _rights.parse_names(req.grant, mask="admin", field="grant")
|
|
323
|
+
remove = _rights.parse_names(req.revoke, mask="admin", field="revoke")
|
|
324
|
+
excluded = _rights.parse_names(req.except_rights, mask="admin", field="except")
|
|
325
|
+
_rights.require_supported(absolute + add + remove, mask="admin")
|
|
326
|
+
|
|
327
|
+
if not _admin.is_channel(peer):
|
|
328
|
+
# A basic group has one bit, not a mask. Saying which rights were
|
|
329
|
+
# dropped beats pretending the whole mask was applied.
|
|
330
|
+
await _admin.client(ctx)(
|
|
331
|
+
msg_fn.EditChatAdminRequest(
|
|
332
|
+
chat_id=_admin.small_chat_id(peer),
|
|
333
|
+
user_id=_admin.input_user(user),
|
|
334
|
+
is_admin=not req.none,
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
dropped = sorted(set(absolute) | set(add))
|
|
338
|
+
if dropped:
|
|
339
|
+
ctx.warn(
|
|
340
|
+
"a basic group has no granular admin rights; "
|
|
341
|
+
"`chat convert <chat> supergroup` first, or accept the all-or-nothing bit"
|
|
342
|
+
)
|
|
343
|
+
return AdminResult(chat_id=chat_id, user_id=user_id, dropped=dropped, rank=req.rank)
|
|
344
|
+
|
|
345
|
+
current: set[str] = set()
|
|
346
|
+
try:
|
|
347
|
+
reply = await _admin.client(ctx)(
|
|
348
|
+
chan_fn.GetParticipantRequest(channel=_admin.input_channel(peer), participant=user)
|
|
349
|
+
)
|
|
350
|
+
current = set(
|
|
351
|
+
_rights.granted_names(
|
|
352
|
+
_rights.model_from_admin(getattr(reply.participant, "admin_rights", None)),
|
|
353
|
+
mask="admin",
|
|
354
|
+
)
|
|
355
|
+
)
|
|
356
|
+
except Exception as exc:
|
|
357
|
+
if type(exc).__name__ != "UserNotParticipantError":
|
|
358
|
+
raise
|
|
359
|
+
|
|
360
|
+
editor = _rights.MaskEdit(mask="admin", current=current)
|
|
361
|
+
wanted = editor.resolve(
|
|
362
|
+
absolute=absolute or None,
|
|
363
|
+
add=add,
|
|
364
|
+
remove=remove,
|
|
365
|
+
everything=req.everything,
|
|
366
|
+
nothing=req.none,
|
|
367
|
+
exclude=excluded,
|
|
368
|
+
ceiling=await _my_admin_rights(ctx, peer) if req.everything else None,
|
|
369
|
+
)
|
|
370
|
+
if req.anonymous is True:
|
|
371
|
+
wanted.add("anonymous")
|
|
372
|
+
elif req.anonymous is False:
|
|
373
|
+
wanted.discard("anonymous")
|
|
374
|
+
|
|
375
|
+
await _admin.client(ctx)(
|
|
376
|
+
chan_fn.EditAdminRequest(
|
|
377
|
+
channel=_admin.input_channel(peer),
|
|
378
|
+
user_id=_admin.input_user(user),
|
|
379
|
+
admin_rights=_rights.build_admin_rights(wanted),
|
|
380
|
+
rank=req.rank or "",
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
ctx.emit("chat_admin_changed", {"chat_id": chat_id, "user_id": user_id})
|
|
384
|
+
return AdminResult(
|
|
385
|
+
chat_id=chat_id,
|
|
386
|
+
user_id=user_id,
|
|
387
|
+
admin_rights=_rights.model_from_admin(_rights.build_admin_rights(wanted)),
|
|
388
|
+
rank=req.rank,
|
|
389
|
+
already=wanted == current and req.rank is None,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
SPEC_ADMIN_PROMOTE = OperationSpec(
|
|
394
|
+
id="chat.admin.promote",
|
|
395
|
+
request=AdminPromoteReq,
|
|
396
|
+
response=AdminResult,
|
|
397
|
+
impl=promote_admin,
|
|
398
|
+
summary="Promote a member to admin, or change an existing admin's rights and rank",
|
|
399
|
+
description=(
|
|
400
|
+
"`--rights` sets the mask absolutely; `--grant`/`--revoke` patch the "
|
|
401
|
+
"one the member already has, which is read first. `--all` grants "
|
|
402
|
+
"every right *you* hold, because the server refuses to let you give "
|
|
403
|
+
"away more. A basic group has no granular rights: the request "
|
|
404
|
+
"collapses to `messages.editChatAdmin` and the dropped names are "
|
|
405
|
+
"reported in `dropped` rather than silently lost."
|
|
406
|
+
),
|
|
407
|
+
aliases=("chat.admin.edit",),
|
|
408
|
+
mutating=True,
|
|
409
|
+
columns=("chat_id", "user_id", "rank"),
|
|
410
|
+
example={"chat_id": -1001500, "user_id": 4242, "rank": "moderator"},
|
|
411
|
+
example_args="chat admin promote @mygroup @alice --rights ban-users,delete-messages",
|
|
412
|
+
covers=(
|
|
413
|
+
"groupcall.admin-right-manage-call",
|
|
414
|
+
"groups-channels-admin.member-tag-rank",
|
|
415
|
+
"groups-channels-admin.promote-admin",
|
|
416
|
+
"stories.admin-rights",
|
|
417
|
+
),
|
|
418
|
+
covers_partial=("groups-channels-admin.basic-group-admin",),
|
|
419
|
+
coverage_note="Basic groups get the one all-or-nothing bit; `chat admin list` owns the id.",
|
|
420
|
+
tags=frozenset({"visible-to-others"}),
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
class AdminDemoteReq(Request):
|
|
425
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
|
|
426
|
+
user: Annotated[PeerRef, arg(1, metavar="USER", kind="user", help="Who to dismiss.")]
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
async def demote_admin(ctx: OpContext, req: AdminDemoteReq) -> AdminResult:
|
|
430
|
+
"""Send an empty mask. Membership survives; only the rights go."""
|
|
431
|
+
from telethon.tl.functions import channels as chan_fn
|
|
432
|
+
from telethon.tl.functions import messages as msg_fn
|
|
433
|
+
|
|
434
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
435
|
+
user = await _send.resolve(ctx, req.user)
|
|
436
|
+
if _admin.is_channel(peer):
|
|
437
|
+
await _admin.client(ctx)(
|
|
438
|
+
chan_fn.EditAdminRequest(
|
|
439
|
+
channel=_admin.input_channel(peer),
|
|
440
|
+
user_id=_admin.input_user(user),
|
|
441
|
+
admin_rights=_rights.build_admin_rights([]),
|
|
442
|
+
rank="",
|
|
443
|
+
)
|
|
444
|
+
)
|
|
445
|
+
else:
|
|
446
|
+
await _admin.client(ctx)(
|
|
447
|
+
msg_fn.EditChatAdminRequest(
|
|
448
|
+
chat_id=_admin.small_chat_id(peer),
|
|
449
|
+
user_id=_admin.input_user(user),
|
|
450
|
+
is_admin=False,
|
|
451
|
+
)
|
|
452
|
+
)
|
|
453
|
+
chat_id = _send.peer_id_of(peer)
|
|
454
|
+
user_id = abs(_send.peer_id_of(user))
|
|
455
|
+
ctx.emit("chat_admin_changed", {"chat_id": chat_id, "user_id": user_id, "demoted": True})
|
|
456
|
+
return AdminResult(
|
|
457
|
+
chat_id=chat_id,
|
|
458
|
+
user_id=user_id,
|
|
459
|
+
admin_rights=_rights.model_from_admin(_rights.build_admin_rights([])),
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
SPEC_ADMIN_DEMOTE = OperationSpec(
|
|
464
|
+
id="chat.admin.demote",
|
|
465
|
+
request=AdminDemoteReq,
|
|
466
|
+
response=AdminResult,
|
|
467
|
+
impl=demote_admin,
|
|
468
|
+
summary="Dismiss an administrator",
|
|
469
|
+
description=(
|
|
470
|
+
"Sends an empty `ChatAdminRights`; the person keeps their "
|
|
471
|
+
"membership. You need `add-admins` and — for somebody another admin "
|
|
472
|
+
"promoted — `channelParticipantAdmin.can_edit`."
|
|
473
|
+
),
|
|
474
|
+
mutating=True,
|
|
475
|
+
destructive=True,
|
|
476
|
+
columns=("chat_id", "user_id"),
|
|
477
|
+
example={"chat_id": -1001500, "user_id": 4242},
|
|
478
|
+
example_args="chat admin demote @mygroup @alice --yes",
|
|
479
|
+
covers=("groups-channels-admin.demote-admin",),
|
|
480
|
+
covers_partial=("groups-channels-admin.basic-group-admin",),
|
|
481
|
+
coverage_note="`is_admin=false` in a basic group; `chat admin list` owns the id.",
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
# ---------------------------------------------------------------------------
|
|
486
|
+
# chat permission get / list / set
|
|
487
|
+
# ---------------------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
class PermissionGetReq(Request):
|
|
491
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
async def get_permissions(ctx: OpContext, req: PermissionGetReq) -> PermissionView:
|
|
495
|
+
"""The chat-wide defaults, printed in the polarity `--allow` accepts."""
|
|
496
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
497
|
+
_full, entity, _entities = await _admin.full_chat(ctx, peer)
|
|
498
|
+
rights = _rights.model_from_banned(getattr(entity, "default_banned_rights", None))
|
|
499
|
+
if rights is None:
|
|
500
|
+
rights = _rights.model_from_banned(_rights.build_banned_rights(_rights.all_allowed()))
|
|
501
|
+
return PermissionView(
|
|
502
|
+
chat_id=_send.peer_id_of(peer),
|
|
503
|
+
allow=_rights.granted_names(rights, mask="member"),
|
|
504
|
+
deny=_rights.denied_names(rights, mask="member"),
|
|
505
|
+
rights=rights,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
SPEC_PERMISSION_GET = OperationSpec(
|
|
510
|
+
id="chat.permission.get",
|
|
511
|
+
request=PermissionGetReq,
|
|
512
|
+
response=PermissionView,
|
|
513
|
+
impl=get_permissions,
|
|
514
|
+
summary="Show the chat-wide default permissions (what every member may do)",
|
|
515
|
+
description=(
|
|
516
|
+
"Allow-polarity, using the same names `chat permission set "
|
|
517
|
+
"--allow/--deny` accepts, so the output round-trips back into the "
|
|
518
|
+
"input."
|
|
519
|
+
),
|
|
520
|
+
columns=("chat_id", "allow", "deny"),
|
|
521
|
+
example={"chat_id": -1001500, "allow": ["send-messages"], "deny": ["send-media"]},
|
|
522
|
+
example_args="chat permission get @mygroup",
|
|
523
|
+
covers_partial=("groups-channels-admin.default-permissions",),
|
|
524
|
+
coverage_note="Reading half; `chat permission set` writes them and owns the id.",
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
class PermissionListReq(Request):
|
|
529
|
+
mask: Annotated[str, choice("admin", "member", "all", help="Which vocabulary to print.")] = (
|
|
530
|
+
"all"
|
|
531
|
+
)
|
|
532
|
+
chat: Annotated[
|
|
533
|
+
PeerRef | None,
|
|
534
|
+
opt("--chat", metavar="CHAT", kind="peer", help="Also mark what you may grant here."),
|
|
535
|
+
] = None
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
async def list_rights(ctx: OpContext, req: PermissionListReq) -> list[RightInfo]:
|
|
539
|
+
"""The canonical right vocabulary — the single source of truth for names.
|
|
540
|
+
|
|
541
|
+
Without `--chat` this is a static table and touches no network; with one
|
|
542
|
+
it also marks which rights the caller may currently hand out, which needs
|
|
543
|
+
the caller's own participant row.
|
|
544
|
+
"""
|
|
545
|
+
grantable: dict[str, bool] = {}
|
|
546
|
+
if req.chat is not None:
|
|
547
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
548
|
+
mine = await _my_admin_rights(ctx, peer)
|
|
549
|
+
for name in _rights.ADMIN_MASK:
|
|
550
|
+
grantable[f"admin:{name}"] = name in mine
|
|
551
|
+
may_ban = "ban-users" in mine
|
|
552
|
+
for name in _rights.MEMBER_MASK:
|
|
553
|
+
grantable[f"member:{name}"] = may_ban
|
|
554
|
+
return _rights.catalog(mask=req.mask, grantable=grantable)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
SPEC_PERMISSION_LIST = OperationSpec(
|
|
558
|
+
id="chat.permission.list",
|
|
559
|
+
request=PermissionListReq,
|
|
560
|
+
response=list[RightInfo],
|
|
561
|
+
impl=list_rights,
|
|
562
|
+
summary="Print the canonical right vocabulary (admin mask, member mask, layer support)",
|
|
563
|
+
description=(
|
|
564
|
+
"The one place the names come from: `chat admin promote --rights`, "
|
|
565
|
+
"`chat member restrict --deny`, `chat permission set --allow` and "
|
|
566
|
+
"`bot default-rights set` all read this table. "
|
|
567
|
+
"`manage-linked-peers` and `manage-welcome-messages` are layer-229 "
|
|
568
|
+
"flags Telethon 1.44 cannot express and are marked "
|
|
569
|
+
"`supported: false` rather than omitted."
|
|
570
|
+
),
|
|
571
|
+
rate_class="local",
|
|
572
|
+
columns=("name", "mask", "tl_flag", "supported"),
|
|
573
|
+
headers=("Name", "Mask", "TL flag", "Supported"),
|
|
574
|
+
example=[
|
|
575
|
+
{
|
|
576
|
+
"name": "ban-users",
|
|
577
|
+
"mask": "admin",
|
|
578
|
+
"tl_flag": "ban_users",
|
|
579
|
+
"polarity": "allow",
|
|
580
|
+
"supported": True,
|
|
581
|
+
}
|
|
582
|
+
],
|
|
583
|
+
example_args="chat permission list --mask member",
|
|
584
|
+
covers_partial=(
|
|
585
|
+
"groups-channels-admin.default-permissions",
|
|
586
|
+
"groups-channels-admin.promote-admin",
|
|
587
|
+
"groups-channels-admin.restrict-member",
|
|
588
|
+
),
|
|
589
|
+
coverage_note=(
|
|
590
|
+
"The vocabulary the three writing commands share; each of them owns its own catalog id."
|
|
591
|
+
),
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
class PermissionSetReq(Request):
|
|
596
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
|
|
597
|
+
allow: Annotated[
|
|
598
|
+
str | None, opt("--allow", metavar="RIGHTS", help="Rights every member may use.")
|
|
599
|
+
] = None
|
|
600
|
+
deny: Annotated[
|
|
601
|
+
str | None, opt("--deny", metavar="RIGHTS", help="Rights no member may use.")
|
|
602
|
+
] = None
|
|
603
|
+
everything: Annotated[bool, opt("--all", help="Allow everything (empty banned mask).")] = False
|
|
604
|
+
none: Annotated[bool, opt("--none", help="Deny everything except view-messages.")] = False
|
|
605
|
+
replace: Annotated[
|
|
606
|
+
bool, opt("--replace", help="Treat --allow/--deny as the whole mask, not a patch.")
|
|
607
|
+
] = False
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
async def set_permissions(ctx: OpContext, req: PermissionSetReq) -> PermissionResult:
|
|
611
|
+
"""Read the chat's current default mask, patch it, and send it complete."""
|
|
612
|
+
from telethon.tl.functions import messages as fn
|
|
613
|
+
|
|
614
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
615
|
+
_full, entity, _entities = await _admin.full_chat(ctx, peer)
|
|
616
|
+
current = _rights.model_from_banned(getattr(entity, "default_banned_rights", None))
|
|
617
|
+
base = (
|
|
618
|
+
set(_rights.granted_names(current, mask="member"))
|
|
619
|
+
if current is not None
|
|
620
|
+
else set(_rights.all_allowed())
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
allow = _rights.parse_names(req.allow, mask="member", field="allow")
|
|
624
|
+
deny = _rights.parse_names(req.deny, mask="member", field="deny")
|
|
625
|
+
_rights.require_supported(allow + deny, mask="member")
|
|
626
|
+
if not (allow or deny or req.everything or req.none):
|
|
627
|
+
raise UsageError("nothing to change: pass --allow, --deny, --all or --none", field="allow")
|
|
628
|
+
|
|
629
|
+
editor = _rights.MaskEdit(mask="member", current=base)
|
|
630
|
+
wanted = editor.resolve(
|
|
631
|
+
absolute=(set(allow) - set(deny)) if (req.replace and allow) else None,
|
|
632
|
+
add=allow,
|
|
633
|
+
remove=deny,
|
|
634
|
+
everything=req.everything,
|
|
635
|
+
nothing=req.none,
|
|
636
|
+
)
|
|
637
|
+
# `view-messages` is not a chat-wide default: a chat nobody may read is a
|
|
638
|
+
# chat nobody is in, and the server refuses it.
|
|
639
|
+
wanted.add("view-messages")
|
|
640
|
+
|
|
641
|
+
if wanted == base:
|
|
642
|
+
_admin.already(ctx)
|
|
643
|
+
order = list(_rights.MEMBER_MASK)
|
|
644
|
+
return PermissionResult(
|
|
645
|
+
chat_id=_send.peer_id_of(peer),
|
|
646
|
+
allow=[n for n in order if n in wanted],
|
|
647
|
+
deny=[n for n in order if n not in wanted and n in _rights.all_allowed()],
|
|
648
|
+
changed=False,
|
|
649
|
+
already=True,
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
await _admin.client(ctx)(
|
|
653
|
+
fn.EditChatDefaultBannedRightsRequest(
|
|
654
|
+
peer=peer, banned_rights=_rights.build_banned_rights(wanted)
|
|
655
|
+
)
|
|
656
|
+
)
|
|
657
|
+
order = list(_rights.MEMBER_MASK)
|
|
658
|
+
ctx.emit("chat_permissions_changed", {"chat_id": _send.peer_id_of(peer)})
|
|
659
|
+
return PermissionResult(
|
|
660
|
+
chat_id=_send.peer_id_of(peer),
|
|
661
|
+
allow=[n for n in order if n in wanted],
|
|
662
|
+
deny=[n for n in order if n not in wanted and n in _rights.all_allowed()],
|
|
663
|
+
changed=True,
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
SPEC_PERMISSION_SET = OperationSpec(
|
|
668
|
+
id="chat.permission.set",
|
|
669
|
+
request=PermissionSetReq,
|
|
670
|
+
response=PermissionResult,
|
|
671
|
+
impl=set_permissions,
|
|
672
|
+
summary="Set the chat-wide default permissions",
|
|
673
|
+
description=(
|
|
674
|
+
"Read-modify-write: the current `default_banned_rights` are fetched "
|
|
675
|
+
"and patched, because a fresh mask resets every flag you did not "
|
|
676
|
+
"mention. `view-messages` is not settable here — a chat nobody may "
|
|
677
|
+
"read is `chat member ban`, not a permission — and `until_date` is "
|
|
678
|
+
"ignored. Works for basic groups too."
|
|
679
|
+
),
|
|
680
|
+
mutating=True,
|
|
681
|
+
columns=("chat_id", "allow", "deny", "changed"),
|
|
682
|
+
example={"chat_id": -1001500, "allow": ["send-messages"], "deny": [], "changed": True},
|
|
683
|
+
example_args="chat permission set @mygroup --deny send-media,send-stickers",
|
|
684
|
+
covers=("groups-channels-admin.default-permissions",),
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
# ---------------------------------------------------------------------------
|
|
689
|
+
# chat admin-log
|
|
690
|
+
# ---------------------------------------------------------------------------
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
class AdminLogReq(Request):
|
|
694
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
|
|
695
|
+
filter: Annotated[
|
|
696
|
+
str | None,
|
|
697
|
+
opt("--filter", metavar="CLASSES", help="Comma-separated event classes; default all."),
|
|
698
|
+
] = None
|
|
699
|
+
admin: Annotated[
|
|
700
|
+
list[PeerRef], opt("--admin", metavar="USER", kind="user", help="Only these admins.")
|
|
701
|
+
] = []
|
|
702
|
+
search: Annotated[str, opt("--search", "-s", metavar="TEXT", help="Free-text query.")] = ""
|
|
703
|
+
min_id: Annotated[int, opt("--min-id", metavar="ID", help="Stop at this event id.")] = 0
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _log_filter(value: str | None) -> Any:
|
|
707
|
+
if not value:
|
|
708
|
+
return None
|
|
709
|
+
from telethon.tl import types
|
|
710
|
+
|
|
711
|
+
flags: dict[str, bool] = {}
|
|
712
|
+
for raw in str(value).replace(",", " ").split():
|
|
713
|
+
name = raw.strip().lower()
|
|
714
|
+
if not name:
|
|
715
|
+
continue
|
|
716
|
+
if name == "all":
|
|
717
|
+
return None
|
|
718
|
+
if name not in _LOG_FILTERS:
|
|
719
|
+
raise UsageError(
|
|
720
|
+
f"{name!r} is not an admin-log event class; "
|
|
721
|
+
f"pick from {', '.join(sorted(_LOG_FILTERS))}",
|
|
722
|
+
field="filter",
|
|
723
|
+
)
|
|
724
|
+
flags[_LOG_FILTERS[name]] = True
|
|
725
|
+
return types.ChannelAdminLogEventsFilter(**flags)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _log_event(raw: Any, *, chat_id: int) -> AdminLogEvent:
|
|
729
|
+
action = getattr(raw, "action", None)
|
|
730
|
+
name = type(action).__name__
|
|
731
|
+
slug = _kebab(name[len(_ACTION_PREFIX) :]) if name.startswith(_ACTION_PREFIX) else _kebab(name)
|
|
732
|
+
body = _plain(action) if action is not None else {}
|
|
733
|
+
previous: Any = None
|
|
734
|
+
current: Any = None
|
|
735
|
+
if isinstance(body, dict):
|
|
736
|
+
for key, value in body.items():
|
|
737
|
+
if key.startswith("prev"):
|
|
738
|
+
previous = value
|
|
739
|
+
elif key.startswith("new"):
|
|
740
|
+
current = value
|
|
741
|
+
if previous is None and current is None:
|
|
742
|
+
current = {k: v for k, v in body.items() if k != "_"} or None
|
|
743
|
+
return AdminLogEvent(
|
|
744
|
+
id=int(getattr(raw, "id", 0) or 0),
|
|
745
|
+
date=fmt_dt(getattr(raw, "date", None)),
|
|
746
|
+
date_unix=to_unix(getattr(raw, "date", None)),
|
|
747
|
+
user_id=int(getattr(raw, "user_id", 0) or 0),
|
|
748
|
+
action=slug,
|
|
749
|
+
raw_type=name,
|
|
750
|
+
prev=previous,
|
|
751
|
+
new=current,
|
|
752
|
+
chat_id=chat_id,
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
async def list_admin_log(ctx: OpContext, req: AdminLogReq) -> Page[AdminLogEvent]:
|
|
757
|
+
"""Recent actions, newest first, with `max_id` as the cursor."""
|
|
758
|
+
from telethon.tl.functions import channels as fn
|
|
759
|
+
|
|
760
|
+
limit, state = _admin.window(ctx, "chat.admin-log.list", PageKind.PARTICIPANTS)
|
|
761
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
762
|
+
chat_id = _send.peer_id_of(peer)
|
|
763
|
+
admins = [_admin.input_user(await _send.resolve(ctx, ref)) for ref in req.admin]
|
|
764
|
+
reply = await _admin.client(ctx)(
|
|
765
|
+
fn.GetAdminLogRequest(
|
|
766
|
+
channel=_admin.input_channel(peer),
|
|
767
|
+
q=req.search or "",
|
|
768
|
+
max_id=int(state.get("max_id", 0) or 0),
|
|
769
|
+
min_id=req.min_id,
|
|
770
|
+
limit=limit,
|
|
771
|
+
events_filter=_log_filter(req.filter),
|
|
772
|
+
admins=admins or None,
|
|
773
|
+
)
|
|
774
|
+
)
|
|
775
|
+
events = list(getattr(reply, "events", None) or [])
|
|
776
|
+
items = [_log_event(row, chat_id=chat_id) for row in events]
|
|
777
|
+
lowest = min((item.id for item in items), default=0)
|
|
778
|
+
return build_page(
|
|
779
|
+
items,
|
|
780
|
+
op="chat.admin-log.list",
|
|
781
|
+
kind=PageKind.PARTICIPANTS,
|
|
782
|
+
state={"max_id": lowest},
|
|
783
|
+
account=ctx.account,
|
|
784
|
+
limit=limit,
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
SPEC_ADMIN_LOG_LIST = OperationSpec(
|
|
789
|
+
id="chat.admin-log.list",
|
|
790
|
+
request=AdminLogReq,
|
|
791
|
+
response=Page[AdminLogEvent],
|
|
792
|
+
impl=list_admin_log,
|
|
793
|
+
summary="Recent actions (the admin log)",
|
|
794
|
+
description=(
|
|
795
|
+
"The filter is built here rather than through Telethon's "
|
|
796
|
+
"`iter_admin_log`, which shifts four names (restrict→ban, "
|
|
797
|
+
"unrestrict→unban, ban→kick, unban→unkick) and has no flag at all "
|
|
798
|
+
"for invites, send, forums, sub-extend or edit-rank. tlgr's "
|
|
799
|
+
"`--filter` names are the API's. Retention is about 48 hours for "
|
|
800
|
+
"most classes, and the endpoint is aggressively flood-limited."
|
|
801
|
+
),
|
|
802
|
+
paginated=PageKind.PARTICIPANTS,
|
|
803
|
+
rate_class="bulk",
|
|
804
|
+
columns=("id", "date", "user_id", "action"),
|
|
805
|
+
headers=("Event", "When", "By", "Action"),
|
|
806
|
+
example={
|
|
807
|
+
"items": [
|
|
808
|
+
{
|
|
809
|
+
"id": 91,
|
|
810
|
+
"date": "2026-02-01T10:00:00Z",
|
|
811
|
+
"user_id": 777,
|
|
812
|
+
"action": "participant-toggle-ban",
|
|
813
|
+
"raw_type": "ChannelAdminLogEventActionParticipantToggleBan",
|
|
814
|
+
}
|
|
815
|
+
],
|
|
816
|
+
"has_more": False,
|
|
817
|
+
},
|
|
818
|
+
example_args="chat admin-log list @mygroup --filter ban,kick",
|
|
819
|
+
covers=("groupcall.admin-log", "groups-channels-admin.admin-log"),
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
class AntiSpamReportReq(Request):
|
|
824
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Supergroup.")]
|
|
825
|
+
msg_id: Annotated[
|
|
826
|
+
int, arg(1, metavar="MSG_ID", kind="msg_id", help="The wrongly deleted message.")
|
|
827
|
+
]
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
async def report_false_positive(ctx: OpContext, req: AntiSpamReportReq) -> AntiSpamReport:
|
|
831
|
+
"""Tell Telegram its anti-spam bot deleted something it should not have."""
|
|
832
|
+
from telethon.tl.functions import channels as fn
|
|
833
|
+
|
|
834
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
835
|
+
await _admin.client(ctx)(
|
|
836
|
+
fn.ReportAntiSpamFalsePositiveRequest(channel=_admin.input_channel(peer), msg_id=req.msg_id)
|
|
837
|
+
)
|
|
838
|
+
return AntiSpamReport(chat_id=_send.peer_id_of(peer), msg_id=req.msg_id, reported=True)
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
SPEC_ADMIN_LOG_REPORT = OperationSpec(
|
|
842
|
+
id="chat.admin-log.report",
|
|
843
|
+
request=AntiSpamReportReq,
|
|
844
|
+
response=AntiSpamReport,
|
|
845
|
+
impl=report_false_positive,
|
|
846
|
+
summary="Report an anti-spam deletion as a false positive",
|
|
847
|
+
description=(
|
|
848
|
+
"The candidate ids come from `chat admin-log list --filter delete`, "
|
|
849
|
+
"where the anti-spam bot's deletions appear."
|
|
850
|
+
),
|
|
851
|
+
mutating=True,
|
|
852
|
+
columns=("chat_id", "msg_id", "reported"),
|
|
853
|
+
example={"chat_id": -1001500, "msg_id": 918, "reported": True},
|
|
854
|
+
example_args="chat admin-log report @mygroup 918",
|
|
855
|
+
covers=("groups-channels-admin.antispam-false-positive",),
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
# ---------------------------------------------------------------------------
|
|
860
|
+
# chat transfer
|
|
861
|
+
# ---------------------------------------------------------------------------
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
class TransferReq(Request):
|
|
865
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
|
|
866
|
+
user: Annotated[PeerRef, arg(1, metavar="USER", kind="user", help="The new owner.")]
|
|
867
|
+
password: Annotated[
|
|
868
|
+
str | None,
|
|
869
|
+
opt(
|
|
870
|
+
secret=True,
|
|
871
|
+
envvar="TLGR_2FA_PASSWORD",
|
|
872
|
+
help="Two-factor password. Never taken on the command line.",
|
|
873
|
+
),
|
|
874
|
+
] = None
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
async def transfer_chat(ctx: OpContext, req: TransferReq) -> TransferResult:
|
|
878
|
+
"""Hand ownership over. Needs the 2FA password, and is irreversible.
|
|
879
|
+
|
|
880
|
+
The password is never a positional or a value-taking flag: argv is
|
|
881
|
+
world-readable through `ps` and lands in shell history (STYLE §3).
|
|
882
|
+
"""
|
|
883
|
+
from telethon.password import compute_check
|
|
884
|
+
from telethon.tl.functions import account as acct_fn
|
|
885
|
+
from telethon.tl.functions import messages as fn
|
|
886
|
+
|
|
887
|
+
if not req.password:
|
|
888
|
+
raise UsageError(
|
|
889
|
+
"the 2FA password is required; pass --password-env, --password-stdin "
|
|
890
|
+
"or --password-file (never as an argument)",
|
|
891
|
+
field="password",
|
|
892
|
+
)
|
|
893
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
894
|
+
user = await _send.resolve(ctx, req.user)
|
|
895
|
+
handle = _admin.client(ctx)
|
|
896
|
+
algo = await handle(acct_fn.GetPasswordRequest())
|
|
897
|
+
await handle(
|
|
898
|
+
fn.EditChatCreatorRequest(
|
|
899
|
+
peer=peer,
|
|
900
|
+
user_id=_admin.input_user(user),
|
|
901
|
+
password=compute_check(algo, req.password),
|
|
902
|
+
)
|
|
903
|
+
)
|
|
904
|
+
chat_id = _send.peer_id_of(peer)
|
|
905
|
+
new_owner = abs(_send.peer_id_of(user))
|
|
906
|
+
ctx.emit("chat_owner_changed", {"chat_id": chat_id, "new_owner_id": new_owner})
|
|
907
|
+
return TransferResult(chat_id=chat_id, new_owner_id=new_owner)
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
SPEC_TRANSFER = OperationSpec(
|
|
911
|
+
id="chat.transfer",
|
|
912
|
+
request=TransferReq,
|
|
913
|
+
response=TransferResult,
|
|
914
|
+
impl=transfer_chat,
|
|
915
|
+
summary="Transfer ownership of a group or channel (2FA)",
|
|
916
|
+
description=(
|
|
917
|
+
"The target must already be an admin. PASSWORD_HASH_INVALID exits 4; "
|
|
918
|
+
"PASSWORD_TOO_FRESH / SESSION_TOO_FRESH and CHANNELS_TOO_MUCH exit "
|
|
919
|
+
"6 with the wait reported."
|
|
920
|
+
),
|
|
921
|
+
aliases=("chat.admin.transfer",),
|
|
922
|
+
mutating=True,
|
|
923
|
+
destructive=True,
|
|
924
|
+
columns=("chat_id", "new_owner_id"),
|
|
925
|
+
example={"chat_id": -1001500, "new_owner_id": 4242},
|
|
926
|
+
example_args="chat transfer @mygroup @alice --password-stdin --yes",
|
|
927
|
+
covers=("groups-channels-admin.transfer-ownership",),
|
|
928
|
+
tags=frozenset({"visible-to-others"}),
|
|
929
|
+
)
|