tlgr-cli 2.0.1__py3-none-any.whl

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