tlgr-cli 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. tlgr/__init__.py +3 -0
  2. tlgr/__main__.py +6 -0
  3. tlgr/actions/__init__.py +45 -0
  4. tlgr/actions/forward.py +74 -0
  5. tlgr/actions/reply.py +32 -0
  6. tlgr/cli/__init__.py +259 -0
  7. tlgr/cli/confirm.py +55 -0
  8. tlgr/cli/errors.py +84 -0
  9. tlgr/cli/gen.py +690 -0
  10. tlgr/cli/globals.py +273 -0
  11. tlgr/cli/introspect.py +170 -0
  12. tlgr/cli/params.py +189 -0
  13. tlgr/cli/render.py +418 -0
  14. tlgr/core/__init__.py +0 -0
  15. tlgr/core/accounts.py +384 -0
  16. tlgr/core/config.py +358 -0
  17. tlgr/core/custom_tl.py +170 -0
  18. tlgr/core/errors.py +687 -0
  19. tlgr/core/eventtypes.py +1170 -0
  20. tlgr/core/identity.py +127 -0
  21. tlgr/core/launchd.py +122 -0
  22. tlgr/core/logging.py +194 -0
  23. tlgr/core/media.py +134 -0
  24. tlgr/core/output.py +251 -0
  25. tlgr/core/pagination.py +227 -0
  26. tlgr/core/paths.py +360 -0
  27. tlgr/core/peers.py +427 -0
  28. tlgr/core/process.py +138 -0
  29. tlgr/core/signing.py +38 -0
  30. tlgr/core/systemd.py +96 -0
  31. tlgr/core/telethon_compat.py +295 -0
  32. tlgr/core/text.py +211 -0
  33. tlgr/core/timefmt.py +199 -0
  34. tlgr/core/tl.py +98 -0
  35. tlgr/daemon/__init__.py +0 -0
  36. tlgr/daemon/app.py +869 -0
  37. tlgr/daemon/dispatch.py +446 -0
  38. tlgr/daemon/events.py +723 -0
  39. tlgr/daemon/files.py +431 -0
  40. tlgr/daemon/idle.py +119 -0
  41. tlgr/daemon/jobs.py +68 -0
  42. tlgr/daemon/main.py +161 -0
  43. tlgr/daemon/peercred.py +75 -0
  44. tlgr/daemon/policy.py +113 -0
  45. tlgr/daemon/preauth.py +366 -0
  46. tlgr/daemon/ratelimit.py +391 -0
  47. tlgr/daemon/server.py +24 -0
  48. tlgr/daemon/session.py +648 -0
  49. tlgr/daemon/sessions.py +274 -0
  50. tlgr/daemon/singleton.py +114 -0
  51. tlgr/daemon/stream.py +193 -0
  52. tlgr/daemon/transfers.py +219 -0
  53. tlgr/daemon/webhook.py +390 -0
  54. tlgr/data/catalog_index.json +1 -0
  55. tlgr/data/parity_waivers.toml +90 -0
  56. tlgr/filters/__init__.py +42 -0
  57. tlgr/filters/compose.py +121 -0
  58. tlgr/filters/content.py +85 -0
  59. tlgr/filters/context.py +114 -0
  60. tlgr/filters/message.py +161 -0
  61. tlgr/filters/temporal.py +87 -0
  62. tlgr/filters/user.py +36 -0
  63. tlgr/gateway/__init__.py +1 -0
  64. tlgr/gateway/config.py +161 -0
  65. tlgr/gateway/engine.py +215 -0
  66. tlgr/gateway/event.py +22 -0
  67. tlgr/jobs/__init__.py +0 -0
  68. tlgr/jobs/base.py +81 -0
  69. tlgr/jobs/client.py +37 -0
  70. tlgr/models/__init__.py +1220 -0
  71. tlgr/models/admin.py +744 -0
  72. tlgr/models/auth.py +510 -0
  73. tlgr/models/base.py +81 -0
  74. tlgr/models/bot.py +576 -0
  75. tlgr/models/business.py +265 -0
  76. tlgr/models/call.py +586 -0
  77. tlgr/models/config.py +101 -0
  78. tlgr/models/contact.py +481 -0
  79. tlgr/models/daemon.py +336 -0
  80. tlgr/models/dialog.py +626 -0
  81. tlgr/models/envelope.py +68 -0
  82. tlgr/models/error.py +30 -0
  83. tlgr/models/event.py +79 -0
  84. tlgr/models/export.py +66 -0
  85. tlgr/models/gift.py +275 -0
  86. tlgr/models/inline.py +84 -0
  87. tlgr/models/location.py +115 -0
  88. tlgr/models/media.py +507 -0
  89. tlgr/models/message.py +584 -0
  90. tlgr/models/net.py +232 -0
  91. tlgr/models/notify.py +105 -0
  92. tlgr/models/page.py +32 -0
  93. tlgr/models/payment.py +172 -0
  94. tlgr/models/peer.py +400 -0
  95. tlgr/models/poll.py +119 -0
  96. tlgr/models/premium.py +161 -0
  97. tlgr/models/privacy.py +93 -0
  98. tlgr/models/profile.py +217 -0
  99. tlgr/models/reaction.py +160 -0
  100. tlgr/models/resolve.py +175 -0
  101. tlgr/models/settings.py +103 -0
  102. tlgr/models/stars.py +101 -0
  103. tlgr/models/sticker.py +243 -0
  104. tlgr/models/story.py +467 -0
  105. tlgr/models/sync.py +105 -0
  106. tlgr/models/todo.py +36 -0
  107. tlgr/models/webapp.py +89 -0
  108. tlgr/ops/__init__.py +63 -0
  109. tlgr/ops/_admin.py +313 -0
  110. tlgr/ops/_auth.py +599 -0
  111. tlgr/ops/_bots.py +586 -0
  112. tlgr/ops/_calls.py +535 -0
  113. tlgr/ops/_common.py +160 -0
  114. tlgr/ops/_layer.py +46 -0
  115. tlgr/ops/_media.py +592 -0
  116. tlgr/ops/_params.py +212 -0
  117. tlgr/ops/_rights.py +402 -0
  118. tlgr/ops/_send.py +593 -0
  119. tlgr/ops/_serialize.py +667 -0
  120. tlgr/ops/_settings.py +306 -0
  121. tlgr/ops/_spec.py +167 -0
  122. tlgr/ops/_story.py +743 -0
  123. tlgr/ops/account.py +2604 -0
  124. tlgr/ops/agent.py +937 -0
  125. tlgr/ops/auth.py +1282 -0
  126. tlgr/ops/bot.py +4880 -0
  127. tlgr/ops/business.py +1520 -0
  128. tlgr/ops/call.py +1610 -0
  129. tlgr/ops/chat.py +4025 -0
  130. tlgr/ops/chat_admin.py +929 -0
  131. tlgr/ops/chat_extra.py +1061 -0
  132. tlgr/ops/chat_invite.py +716 -0
  133. tlgr/ops/chat_manage.py +1691 -0
  134. tlgr/ops/chat_member.py +1357 -0
  135. tlgr/ops/chat_stats.py +902 -0
  136. tlgr/ops/chat_topic.py +905 -0
  137. tlgr/ops/conference.py +791 -0
  138. tlgr/ops/config.py +1698 -0
  139. tlgr/ops/contact.py +2330 -0
  140. tlgr/ops/daemon.py +1397 -0
  141. tlgr/ops/draft.py +299 -0
  142. tlgr/ops/emoji.py +343 -0
  143. tlgr/ops/events.py +1327 -0
  144. tlgr/ops/export.py +596 -0
  145. tlgr/ops/folder.py +1322 -0
  146. tlgr/ops/gif.py +522 -0
  147. tlgr/ops/gift.py +1546 -0
  148. tlgr/ops/giveaway.py +541 -0
  149. tlgr/ops/inline.py +773 -0
  150. tlgr/ops/job.py +799 -0
  151. tlgr/ops/location.py +917 -0
  152. tlgr/ops/media.py +4495 -0
  153. tlgr/ops/message.py +3769 -0
  154. tlgr/ops/net.py +536 -0
  155. tlgr/ops/notify.py +840 -0
  156. tlgr/ops/passport.py +464 -0
  157. tlgr/ops/payment.py +907 -0
  158. tlgr/ops/poll.py +1078 -0
  159. tlgr/ops/premium.py +488 -0
  160. tlgr/ops/privacy.py +794 -0
  161. tlgr/ops/profile.py +1481 -0
  162. tlgr/ops/proxy.py +750 -0
  163. tlgr/ops/reaction.py +1475 -0
  164. tlgr/ops/resolve.py +1140 -0
  165. tlgr/ops/search.py +521 -0
  166. tlgr/ops/settings.py +1066 -0
  167. tlgr/ops/stars.py +594 -0
  168. tlgr/ops/sticker.py +1602 -0
  169. tlgr/ops/story.py +3216 -0
  170. tlgr/ops/sync.py +788 -0
  171. tlgr/ops/todo.py +514 -0
  172. tlgr/ops/user.py +1406 -0
  173. tlgr/ops/vc.py +2351 -0
  174. tlgr/ops/webapp.py +717 -0
  175. tlgr/ops/webhook.py +418 -0
  176. tlgr/parity.py +386 -0
  177. tlgr/processors/__init__.py +125 -0
  178. tlgr/processors/regex.py +26 -0
  179. tlgr/processors/text.py +56 -0
  180. tlgr/registry.py +519 -0
  181. tlgr/schema.py +173 -0
  182. tlgr/transport/__init__.py +30 -0
  183. tlgr/transport/autostart.py +293 -0
  184. tlgr/transport/client.py +805 -0
  185. tlgr/transport/ndjson.py +44 -0
  186. tlgr/version.py +31 -0
  187. tlgr_cli-2.0.1.dist-info/METADATA +957 -0
  188. tlgr_cli-2.0.1.dist-info/RECORD +192 -0
  189. tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
  190. tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
  191. tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
  192. tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1691 @@
1
+ """Making a chat, and everything on its Manage screen.
2
+
3
+ `chat create`, `chat edit`, `chat convert`, `chat setting get/set`,
4
+ `chat username *`, `chat photo *`, `chat send-as *` and `chat discussion *`.
5
+
6
+ The Manage screen is one screen in the GUI and roughly twenty separate
7
+ MTProto methods underneath, which is why `chat setting set` is a *batch*:
8
+ flags are applied in a fixed order, a toggle already in the requested state is
9
+ reported as `already` and not sent, and each failure is reported per key so
10
+ one refusal — usually a boost level or a capability flag — does not hide the
11
+ nine changes that did land. `chat setting get` prints the same key names
12
+ without the leading dashes, so its output round-trips back into its input.
13
+
14
+ Two peer-shape rules run through the module. A basic group accepts only a
15
+ title, an about and a photo; everything else needs a supergroup, and the
16
+ commands say so and point at `chat convert` rather than migrating a group out
17
+ from under its owner. And a colour, an emoji status or an emoji pack is
18
+ boost-gated: the server refuses below the level, and the refusal is reported
19
+ per key with the level it wanted.
20
+
21
+ Telethon is imported inside functions, never at module scope (§2.2).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Annotated, Any
27
+
28
+ from tlgr.core.errors import UsageError
29
+ from tlgr.core.timefmt import fmt_dt, parse_dt, parse_duration
30
+ from tlgr.models.admin import (
31
+ ChatEditResult,
32
+ ChatPhotoResult,
33
+ CreatedChat,
34
+ DiscussionCandidate,
35
+ DiscussionResult,
36
+ MigrateResult,
37
+ MissingInvitee,
38
+ SendAsPeer,
39
+ SendAsResult,
40
+ SettingResult,
41
+ SettingsView,
42
+ UsernameCheck,
43
+ UsernameResult,
44
+ )
45
+ from tlgr.models.base import Request
46
+ from tlgr.models.peer import PeerRef
47
+ from tlgr.ops import _admin, _send
48
+ from tlgr.ops._params import arg, choice, opt
49
+ from tlgr.ops._serialize import peer_id_of
50
+ from tlgr.ops._spec import OpContext, OperationSpec
51
+
52
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
53
+
54
+ _EXAMPLE_CHAT: dict[str, Any] = {
55
+ "id": -1001500,
56
+ "type": "supergroup",
57
+ "title": "News",
58
+ "username": "mynews",
59
+ }
60
+
61
+ #: `--main-tab` values → the `ProfileTab*` constructor they name.
62
+ _PROFILE_TABS = {
63
+ "posts": "ProfileTabPosts",
64
+ "gifts": "ProfileTabGifts",
65
+ "media": "ProfileTabMedia",
66
+ "files": "ProfileTabFiles",
67
+ "music": "ProfileTabMusic",
68
+ "voice": "ProfileTabVoice",
69
+ "links": "ProfileTabLinks",
70
+ "gifs": "ProfileTabGifs",
71
+ }
72
+
73
+ #: The slow-mode ladder the server accepts. Anything else is rounded up to
74
+ #: the next rung and the rounding is reported, because silently accepting
75
+ #: `--slow-mode 45s` and applying 60 is a lie about what the chat now does.
76
+ _SLOW_MODE_LADDER = (0, 10, 30, 60, 300, 900, 3600)
77
+
78
+
79
+ def _tri(value: str | None) -> bool | None:
80
+ """`on`/`off` → a bool; anything else is a usage error."""
81
+ if value is None:
82
+ return None
83
+ text = str(value).strip().lower()
84
+ if text in ("on", "yes", "true", "1", "enable", "enabled"):
85
+ return True
86
+ if text in ("off", "no", "false", "0", "disable", "disabled"):
87
+ return False
88
+ raise UsageError(f"{value!r} is not on or off")
89
+
90
+
91
+ def _geo(value: str | None) -> Any:
92
+ """`--geo 51.5,-0.12` → an `InputGeoPoint`; `off` → `InputGeoPointEmpty`."""
93
+ from telethon.tl import types
94
+
95
+ if value is None:
96
+ return None
97
+ text = str(value).strip().lower()
98
+ if text in ("off", "none", "clear"):
99
+ return types.InputGeoPointEmpty()
100
+ parts = text.replace(";", ",").split(",")
101
+ if len(parts) != 2:
102
+ raise UsageError("--geo takes 'lat,lon'", field="geo")
103
+ try:
104
+ return types.InputGeoPoint(lat=float(parts[0]), long=float(parts[1]))
105
+ except ValueError as exc:
106
+ raise UsageError("--geo takes 'lat,lon' as two numbers", field="geo") from exc
107
+
108
+
109
+ def _int_or_off(value: str | None, field: str) -> int | None:
110
+ if value is None:
111
+ return None
112
+ text = str(value).strip().lower()
113
+ if text in ("off", "none", "clear", ""):
114
+ return 0
115
+ try:
116
+ return int(text)
117
+ except ValueError as exc:
118
+ raise UsageError(
119
+ f"--{field.replace('_', '-')} takes a number or 'off'", field=field
120
+ ) from exc
121
+
122
+
123
+ def _slow_mode(value: str | None, ctx: OpContext) -> int | None:
124
+ if value is None:
125
+ return None
126
+ text = str(value).strip().lower()
127
+ if text in ("off", "none", "0"):
128
+ return 0
129
+ seconds = parse_duration(text)
130
+ if seconds is None:
131
+ raise UsageError("--slow-mode takes a duration or 'off'", field="slow_mode")
132
+ wanted = int(seconds)
133
+ for rung in _SLOW_MODE_LADDER:
134
+ if wanted <= rung:
135
+ if rung != wanted:
136
+ ctx.warn(f"slow mode has a fixed ladder; {wanted}s was rounded up to {rung}s")
137
+ return rung
138
+ ctx.warn("slow mode caps at 1h; the request was clamped")
139
+ return _SLOW_MODE_LADDER[-1]
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # chat create
144
+ # ---------------------------------------------------------------------------
145
+
146
+
147
+ class CreateReq(Request):
148
+ title: Annotated[str, arg(0, metavar="TITLE", help="The chat's title.")]
149
+ type: Annotated[
150
+ str,
151
+ choice(
152
+ "group",
153
+ "supergroup",
154
+ "channel",
155
+ "forum",
156
+ help="Peer shape. `group` is the legacy basic group.",
157
+ ),
158
+ ] = "supergroup"
159
+ about: Annotated[str, opt("--about", metavar="TEXT", help="Description.")] = ""
160
+ members: Annotated[
161
+ list[PeerRef], opt("--members", metavar="USER", kind="user", help="Seed members.")
162
+ ] = []
163
+ photo: Annotated[
164
+ str | None, opt("--photo", metavar="PATH", kind="path", help="Set the photo afterwards.")
165
+ ] = None
166
+ username: Annotated[
167
+ str | None, opt("--username", metavar="NAME", help="Claim a public username afterwards.")
168
+ ] = None
169
+ ttl: Annotated[
170
+ str | None, opt("--ttl", metavar="DURATION", help="Auto-delete timer at creation.")
171
+ ] = None
172
+ geo: Annotated[
173
+ str | None, opt("--geo", metavar="LAT,LON", help="Create a location-based group.")
174
+ ] = None
175
+ address: Annotated[
176
+ str, opt("--address", metavar="TEXT", help="Street address that goes with --geo.")
177
+ ] = ""
178
+ tabs: Annotated[bool, opt("--tabs", help="With --type forum: the tabbed topic UI.")] = False
179
+ for_import: Annotated[
180
+ bool, opt("--for-import", help="Destination for a history import (see `chat import`).")
181
+ ] = False
182
+ forward_history: Annotated[
183
+ int, opt("--forward-history", metavar="N", help="Basic groups: history new members see.")
184
+ ] = 0
185
+
186
+
187
+ async def create_chat(ctx: OpContext, req: CreateReq) -> CreatedChat:
188
+ """Create a basic group, supergroup, broadcast channel or forum.
189
+
190
+ `missing` carries `messages.invitedUsers.missing_invitees` verbatim: a
191
+ creation that quietly added three of five seed members and reported
192
+ success is a creation nobody can trust.
193
+ """
194
+ from telethon.tl.functions import channels as chan_fn
195
+ from telethon.tl.functions import messages as msg_fn
196
+
197
+ from tlgr.ops.chat_member import _missing
198
+
199
+ handle = _admin.client(ctx)
200
+ ttl = int(parse_duration(req.ttl) or 0) if req.ttl else None
201
+ if req.type == "group" and (req.username or req.geo):
202
+ raise UsageError(
203
+ "a basic group cannot have a username or a location; "
204
+ "use --type supergroup, or `chat convert <chat> supergroup` later",
205
+ field="type",
206
+ )
207
+
208
+ missing: list[MissingInvitee] = []
209
+ if req.type == "group":
210
+ users = [_admin.input_user(await _send.resolve(ctx, ref)) for ref in req.members]
211
+ reply = await handle(msg_fn.CreateChatRequest(users=users, title=req.title, ttl_period=ttl))
212
+ missing = _missing(reply)
213
+ updates = getattr(reply, "updates", reply)
214
+ else:
215
+ updates = await handle(
216
+ chan_fn.CreateChannelRequest(
217
+ title=req.title,
218
+ about=req.about,
219
+ megagroup=req.type in ("supergroup", "forum") or None,
220
+ broadcast=req.type == "channel" or None,
221
+ forum=req.type == "forum" or None,
222
+ for_import=req.for_import or None,
223
+ geo_point=_geo(req.geo),
224
+ address=req.address or None,
225
+ ttl_period=ttl,
226
+ )
227
+ )
228
+ chats = list(getattr(updates, "chats", None) or [])
229
+ entity = chats[0] if chats else None
230
+ chat_id = _admin.entity_id(entity)
231
+ result = CreatedChat(
232
+ id=chat_id or 0,
233
+ type=req.type,
234
+ title=req.title,
235
+ added=[],
236
+ missing=missing,
237
+ )
238
+
239
+ if req.type != "group" and req.members:
240
+ peer = await _send.resolve(ctx, str(result.id))
241
+ users = [_admin.input_user(await _send.resolve(ctx, ref)) for ref in req.members]
242
+ invited = await handle(
243
+ chan_fn.InviteToChannelRequest(channel=_admin.input_channel(peer), users=users)
244
+ )
245
+ result.missing = _missing(invited)
246
+ refused = {item.user_id for item in result.missing}
247
+ result.added = [
248
+ abs(peer_id_of(await _send.resolve(ctx, ref)) or 0)
249
+ for ref in req.members
250
+ if abs(peer_id_of(await _send.resolve(ctx, ref)) or 0) not in refused
251
+ ]
252
+
253
+ if req.username and result.id:
254
+ peer = await _send.resolve(ctx, str(result.id))
255
+ await handle(
256
+ chan_fn.UpdateUsernameRequest(channel=_admin.input_channel(peer), username=req.username)
257
+ )
258
+ result.username = req.username
259
+ if req.photo and result.id:
260
+ peer = await _send.resolve(ctx, str(result.id))
261
+ await _set_photo(ctx, peer, file=req.photo)
262
+ ctx.emit("chat_created", {"chat_id": result.id, "type": req.type})
263
+ return result
264
+
265
+
266
+ SPEC_CREATE = OperationSpec(
267
+ id="chat.create",
268
+ request=CreateReq,
269
+ response=CreatedChat,
270
+ impl=create_chat,
271
+ summary="Create a basic group, supergroup, broadcast channel or forum",
272
+ description=(
273
+ "`--type supergroup` is what the GUI creates today; `group` is the "
274
+ "legacy basic group, which cannot have a username or a location. "
275
+ "`missing` carries `messages.invitedUsers.missing_invitees` "
276
+ "verbatim, and the command exits 1 when any seed member was refused."
277
+ ),
278
+ legacy_paths=("chat create",),
279
+ mutating=True,
280
+ columns=("id", "type", "title", "username"),
281
+ example=_EXAMPLE_CHAT,
282
+ example_args="chat create 'Release team' --type supergroup --members @alice",
283
+ covers=(
284
+ "groups-channels-admin.create-basic-group",
285
+ "groups-channels-admin.create-channel",
286
+ "groups-channels-admin.create-forum",
287
+ "groups-channels-admin.create-geo-group",
288
+ "groups-channels-admin.create-supergroup",
289
+ "groups-channels-admin.create-with-autodelete",
290
+ "location.geogroup-create",
291
+ ),
292
+ covers_partial=(
293
+ "groups-channels-admin.add-members",
294
+ "groups-channels-admin.add-members-failure-report",
295
+ ),
296
+ coverage_note="Seed members go in at creation; `chat member add` owns adding them later.",
297
+ tags=frozenset({"visible-to-others"}),
298
+ )
299
+
300
+
301
+ # ---------------------------------------------------------------------------
302
+ # chat edit
303
+ # ---------------------------------------------------------------------------
304
+
305
+
306
+ class EditReq(Request):
307
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
308
+ title: Annotated[str | None, opt("--title", metavar="TEXT", help="New title.")] = None
309
+ about: Annotated[
310
+ str | None, opt("--about", metavar="TEXT", help="New description, max 255 chars.")
311
+ ] = None
312
+ geo: Annotated[
313
+ str | None, opt("--geo", metavar="LAT,LON", help="Geogroup location; `off` clears it.")
314
+ ] = None
315
+ address: Annotated[str, opt("--address", metavar="TEXT", help="Street address.")] = ""
316
+ color: Annotated[
317
+ str | None, opt("--color", metavar="ID", help="Message accent palette id, or `off`.")
318
+ ] = None
319
+ color_emoji: Annotated[
320
+ str | None, opt("--color-emoji", metavar="ID", help="Background emoji for --color.")
321
+ ] = None
322
+ profile_color: Annotated[
323
+ str | None, opt("--profile-color", metavar="ID", help="Profile palette id, or `off`.")
324
+ ] = None
325
+ profile_color_emoji: Annotated[
326
+ str | None,
327
+ opt("--profile-color-emoji", metavar="ID", help="Background emoji for --profile-color."),
328
+ ] = None
329
+ emoji_status: Annotated[
330
+ str | None, opt("--emoji-status", metavar="ID", help="Emoji status, or `off`.")
331
+ ] = None
332
+ emoji_status_until: Annotated[
333
+ str | None, opt("--emoji-status-until", metavar="WHEN", help="Expiry for --emoji-status.")
334
+ ] = None
335
+ main_tab: Annotated[
336
+ str | None,
337
+ choice(*_PROFILE_TABS, help="Default profile tab (channels.setMainProfileTab)."),
338
+ ] = None
339
+ palettes: Annotated[
340
+ bool, opt("--palettes", help="Do not edit: print the palettes and the level each needs.")
341
+ ] = False
342
+
343
+
344
+ async def edit_chat(ctx: OpContext, req: EditReq) -> ChatEditResult:
345
+ """One request per changed field, in a fixed order, reported in `changed`."""
346
+ from telethon.tl import types
347
+ from telethon.tl.functions import channels as chan_fn
348
+ from telethon.tl.functions import help as help_fn
349
+ from telethon.tl.functions import messages as msg_fn
350
+
351
+ handle = _admin.client(ctx)
352
+ if req.palettes:
353
+ colors = await handle(help_fn.GetPeerColorsRequest(hash=0))
354
+ profile = await handle(help_fn.GetPeerProfileColorsRequest(hash=0))
355
+ rows: list[dict[str, Any]] = []
356
+ for kind, reply in (("message", colors), ("profile", profile)):
357
+ for option in getattr(reply, "colors", None) or []:
358
+ rows.append(
359
+ {
360
+ "kind": kind,
361
+ "id": int(getattr(option, "color_id", 0) or 0),
362
+ "channel_min_level": int(getattr(option, "channel_min_level", 0) or 0),
363
+ "group_min_level": int(getattr(option, "group_min_level", 0) or 0),
364
+ "hidden": bool(getattr(option, "hidden", False)),
365
+ }
366
+ )
367
+ return ChatEditResult(id=0, palettes=rows)
368
+
369
+ peer = await _send.resolve(ctx, req.chat)
370
+ chat_id = peer_id_of(peer) or 0
371
+ channel = _admin.input_channel(peer) if _admin.is_channel(peer) else None
372
+ changed: list[str] = []
373
+
374
+ if req.title is not None:
375
+ if channel is not None:
376
+ await handle(chan_fn.EditTitleRequest(channel=channel, title=req.title))
377
+ else:
378
+ await handle(
379
+ msg_fn.EditChatTitleRequest(chat_id=_admin.small_chat_id(peer), title=req.title)
380
+ )
381
+ changed.append("title")
382
+ if req.about is not None:
383
+ # `messages.editChatAbout` works for every peer shape, which is why
384
+ # there is no channel/basic-group split here.
385
+ await handle(msg_fn.EditChatAboutRequest(peer=peer, about=req.about))
386
+ changed.append("about")
387
+ if req.geo is not None:
388
+ if channel is None:
389
+ raise UsageError("only a supergroup can have a location", field="geo")
390
+ await handle(
391
+ chan_fn.EditLocationRequest(
392
+ channel=channel, geo_point=_geo(req.geo), address=req.address
393
+ )
394
+ )
395
+ changed.append("geo")
396
+ if req.color is not None or req.color_emoji is not None:
397
+ if channel is None:
398
+ raise UsageError("colours are a channel/supergroup feature", field="color")
399
+ await handle(
400
+ chan_fn.UpdateColorRequest(
401
+ channel=channel,
402
+ color=_int_or_off(req.color, "color"),
403
+ background_emoji_id=_int_or_off(req.color_emoji, "color_emoji"),
404
+ )
405
+ )
406
+ changed.append("color")
407
+ if req.profile_color is not None or req.profile_color_emoji is not None:
408
+ if channel is None:
409
+ raise UsageError("colours are a channel/supergroup feature", field="profile_color")
410
+ await handle(
411
+ chan_fn.UpdateColorRequest(
412
+ channel=channel,
413
+ for_profile=True,
414
+ color=_int_or_off(req.profile_color, "profile_color"),
415
+ background_emoji_id=_int_or_off(req.profile_color_emoji, "profile_color_emoji"),
416
+ )
417
+ )
418
+ changed.append("profile_color")
419
+ if req.emoji_status is not None:
420
+ if channel is None:
421
+ raise UsageError(
422
+ "an emoji status is a channel/supergroup feature", field="emoji_status"
423
+ )
424
+ document = _int_or_off(req.emoji_status, "emoji_status") or 0
425
+ until = None
426
+ if req.emoji_status_until:
427
+ seconds = parse_duration(req.emoji_status_until)
428
+ if seconds is not None:
429
+ from datetime import datetime, timedelta, timezone
430
+
431
+ until = datetime.now(timezone.utc) + timedelta(seconds=seconds)
432
+ else:
433
+ until = parse_dt(req.emoji_status_until)
434
+ status: Any = (
435
+ types.EmojiStatusEmpty()
436
+ if not document
437
+ else types.EmojiStatus(document_id=document, until=until)
438
+ )
439
+ await handle(chan_fn.UpdateEmojiStatusRequest(channel=channel, emoji_status=status))
440
+ changed.append("emoji_status")
441
+ ctx.warn(
442
+ "a collectible emoji status and a custom profile palette are mutually "
443
+ "exclusive; setting one clears the other"
444
+ )
445
+ if req.main_tab is not None:
446
+ if channel is None:
447
+ raise UsageError("the profile tab is a channel/supergroup feature", field="main_tab")
448
+ await handle(
449
+ chan_fn.SetMainProfileTabRequest(
450
+ channel=channel, tab=getattr(types, _PROFILE_TABS[req.main_tab])()
451
+ )
452
+ )
453
+ changed.append("main_tab")
454
+
455
+ if not changed:
456
+ raise UsageError("nothing to change; --palettes prints the colour options", field="title")
457
+ ctx.emit("chat_edited", {"chat_id": chat_id, "changed": changed})
458
+ return ChatEditResult(id=chat_id, changed=changed)
459
+
460
+
461
+ SPEC_EDIT = OperationSpec(
462
+ id="chat.edit",
463
+ request=EditReq,
464
+ response=ChatEditResult,
465
+ impl=edit_chat,
466
+ summary="Edit a group/channel profile: title, about, location, colors, emoji status, tab",
467
+ description=(
468
+ "One request per changed field, applied in a fixed order and "
469
+ "reported in `changed`. Colours, emoji statuses and emoji packs are "
470
+ "boost-gated: `--palettes` prints every option with the level it "
471
+ "needs, without editing anything. A basic group accepts only "
472
+ "`--title` and `--about`."
473
+ ),
474
+ mutating=True,
475
+ columns=("id", "changed"),
476
+ example={"id": -1001500, "changed": ["title", "about"]},
477
+ example_args="chat edit @mygroup --title 'Release team' --about 'Ship it'",
478
+ covers=(
479
+ "groups-channels-admin.channel-emoji-status",
480
+ "groups-channels-admin.edit-about",
481
+ "groups-channels-admin.edit-title",
482
+ "groups-channels-admin.main-profile-tab",
483
+ "groups-channels-admin.peer-color-message",
484
+ "groups-channels-admin.peer-color-profile",
485
+ "groups-channels-admin.set-location",
486
+ "location.channel-geo",
487
+ ),
488
+ )
489
+
490
+
491
+ # ---------------------------------------------------------------------------
492
+ # chat convert
493
+ # ---------------------------------------------------------------------------
494
+
495
+
496
+ class ConvertReq(Request):
497
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The chat to convert.")]
498
+ target: Annotated[
499
+ str,
500
+ arg(1, metavar="TARGET", help="supergroup (from a basic group) or gigagroup."),
501
+ ] = "supergroup"
502
+
503
+
504
+ async def convert_chat(ctx: OpContext, req: ConvertReq) -> MigrateResult:
505
+ """Basic group → supergroup, or supergroup → gigagroup. One way, both."""
506
+ from telethon.tl.functions import channels as chan_fn
507
+ from telethon.tl.functions import messages as msg_fn
508
+
509
+ target = req.target.strip().lower()
510
+ if target not in ("supergroup", "gigagroup"):
511
+ raise UsageError("target is 'supergroup' or 'gigagroup'", field="target")
512
+ peer = await _send.resolve(ctx, req.chat)
513
+ old_id = peer_id_of(peer) or 0
514
+ handle = _admin.client(ctx)
515
+ if target == "gigagroup":
516
+ updates = await handle(
517
+ chan_fn.ConvertToGigagroupRequest(channel=_admin.input_channel(peer))
518
+ )
519
+ chats = list(getattr(updates, "chats", None) or [])
520
+ new_id = _admin.entity_id(chats[0]) if chats else old_id
521
+ ctx.emit("chat_converted", {"chat_id": new_id, "type": "gigagroup"})
522
+ return MigrateResult(old_chat_id=old_id, chat_id=new_id or old_id, type="gigagroup")
523
+
524
+ if _admin.is_channel(peer):
525
+ raise UsageError(
526
+ "this is already a supergroup or channel; only a basic group migrates",
527
+ field="chat",
528
+ )
529
+ updates = await handle(msg_fn.MigrateChatRequest(chat_id=_admin.small_chat_id(peer)))
530
+ new_id = old_id
531
+ for chat in getattr(updates, "chats", None) or []:
532
+ if type(chat).__name__ == "Channel":
533
+ new_id = _admin.entity_id(chat) or old_id
534
+ ctx.emit("chat_converted", {"chat_id": new_id, "type": "supergroup"})
535
+ return MigrateResult(old_chat_id=old_id, chat_id=new_id, type="supergroup")
536
+
537
+
538
+ SPEC_CONVERT = OperationSpec(
539
+ id="chat.convert",
540
+ request=ConvertReq,
541
+ response=MigrateResult,
542
+ impl=convert_chat,
543
+ summary="Convert a basic group to a supergroup, or a supergroup to a gigagroup",
544
+ description=(
545
+ "Both conversions are one-way. Both ids are reported, because the "
546
+ "history stays in the old peer (`chat.migrated_to` points at the new "
547
+ "one). Supergroup-only commands offer `--upgrade` rather than "
548
+ "migrating a chat out from under its owner."
549
+ ),
550
+ mutating=True,
551
+ destructive=True,
552
+ columns=("old_chat_id", "chat_id", "type"),
553
+ example={"old_chat_id": -1500, "chat_id": -1001500, "type": "supergroup"},
554
+ example_args="chat convert @mygroup supergroup --yes",
555
+ covers=(
556
+ "groups-channels-admin.convert-to-gigagroup",
557
+ "groups-channels-admin.upgrade-basic-to-supergroup",
558
+ ),
559
+ )
560
+
561
+
562
+ # ---------------------------------------------------------------------------
563
+ # chat setting get / set
564
+ # ---------------------------------------------------------------------------
565
+
566
+
567
+ class SettingGetReq(Request):
568
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
569
+
570
+
571
+ def _reactions_view(raw: Any) -> tuple[str | None, list[str]]:
572
+ kind = type(raw).__name__
573
+ if kind == "ChatReactionsAll":
574
+ return "all", []
575
+ if kind == "ChatReactionsNone":
576
+ return "none", []
577
+ if kind == "ChatReactionsSome":
578
+ names = [
579
+ str(getattr(item, "emoticon", None) or getattr(item, "document_id", ""))
580
+ for item in (getattr(raw, "reactions", None) or [])
581
+ ]
582
+ return "some", names
583
+ return None, []
584
+
585
+
586
+ async def get_settings(ctx: OpContext, req: SettingGetReq) -> SettingsView:
587
+ """Every administrable toggle, keyed the way `chat setting set` spells it."""
588
+ peer = await _send.resolve(ctx, req.chat)
589
+ full, entity, _entities = await _admin.full_chat(ctx, peer)
590
+ rights = _admin_rights_of(entity)
591
+ reactions, reaction_names = _reactions_view(getattr(full, "available_reactions", None))
592
+ sticker = getattr(full, "stickerset", None)
593
+ emojiset = getattr(full, "emojiset", None)
594
+ tab = getattr(full, "main_tab", None)
595
+ view = SettingsView(
596
+ chat_id=peer_id_of(peer) or 0,
597
+ slow_mode=getattr(full, "slowmode_seconds", None),
598
+ prehistory=(
599
+ None
600
+ if getattr(full, "hidden_prehistory", None) is None
601
+ else ("hidden" if full.hidden_prehistory else "visible")
602
+ ),
603
+ join_to_send=getattr(entity, "join_to_send", None),
604
+ join_request=getattr(entity, "join_request", None),
605
+ guard_bot=getattr(full, "guard_bot_id", None),
606
+ noforwards=getattr(entity, "noforwards", None),
607
+ antispam=getattr(full, "antispam", None),
608
+ hidden_members=getattr(full, "participants_hidden", None),
609
+ signatures=getattr(entity, "signatures", None),
610
+ signature_profiles=getattr(entity, "signature_profiles", None),
611
+ forum=getattr(entity, "forum", None),
612
+ forum_tabs=(
613
+ None
614
+ if getattr(entity, "forum", None) is None
615
+ else ("tabs" if getattr(entity, "forum_tabs", False) else "list")
616
+ ),
617
+ view_as=(
618
+ None
619
+ if getattr(full, "view_forum_as_messages", None) is None
620
+ else ("messages" if full.view_forum_as_messages else "topics")
621
+ ),
622
+ autotranslate=getattr(entity, "autotranslation", None),
623
+ ads=(
624
+ None
625
+ if getattr(full, "restricted_sponsored", None) is None
626
+ else not full.restricted_sponsored
627
+ ),
628
+ reactions=reactions,
629
+ reactions_list=reaction_names,
630
+ reactions_limit=getattr(full, "reactions_limit", None),
631
+ paid_reactions=getattr(full, "paid_reactions_available", None),
632
+ sticker_set=getattr(sticker, "short_name", None),
633
+ emoji_set=getattr(emojiset, "short_name", None),
634
+ paid_messages_stars=getattr(full, "send_paid_messages_stars", None),
635
+ direct_messages=getattr(entity, "broadcast_messages_allowed", None),
636
+ )
637
+ if tab is not None:
638
+ view.gated_by["main_tab"] = type(tab).__name__
639
+ change_info = rights.get("change_info", False) or rights.get("creator", False)
640
+ for key in (
641
+ "slow_mode",
642
+ "prehistory",
643
+ "join_to_send",
644
+ "join_request",
645
+ "antispam",
646
+ "hidden_members",
647
+ "signatures",
648
+ "signature_profiles",
649
+ "reactions",
650
+ "reactions_limit",
651
+ "sticker_set",
652
+ "emoji_set",
653
+ ):
654
+ view.available[key] = bool(change_info)
655
+ for key in ("forum", "noforwards", "ads", "direct_messages", "autotranslate"):
656
+ view.available[key] = bool(rights.get("creator", False))
657
+ for key, level in (
658
+ ("autotranslate", "channel_autotranslation_level_min"),
659
+ ("emoji_set", "group_emoji_stickers_level_min"),
660
+ ("reactions_limit", "channel_custom_reactions_level_min"),
661
+ ("ads", "channel_restrict_sponsored_level_min"),
662
+ ):
663
+ view.gated_by[key] = f"boost level: {level}"
664
+ if not getattr(full, "can_set_stickers", False):
665
+ view.gated_by["sticker_set"] = "needs can_set_stickers"
666
+ return view
667
+
668
+
669
+ def _admin_rights_of(entity: Any) -> dict[str, bool]:
670
+ rights = getattr(entity, "admin_rights", None)
671
+ out = {"creator": bool(getattr(entity, "creator", False))}
672
+ for name in ("change_info", "ban_users", "invite_users", "post_messages"):
673
+ out[name] = bool(getattr(rights, name, False)) or out["creator"]
674
+ return out
675
+
676
+
677
+ SPEC_SETTING_GET = OperationSpec(
678
+ id="chat.setting.get",
679
+ request=SettingGetReq,
680
+ response=SettingsView,
681
+ impl=get_settings,
682
+ summary="Print every administrable policy toggle with its current value",
683
+ description=(
684
+ "The key names are exactly `chat setting set`'s flag names without "
685
+ "the leading dashes, so the output round-trips into the input. "
686
+ "`available` says whether *you* may change each key, and `gated_by` "
687
+ "names the capability flag or boost level that blocks it."
688
+ ),
689
+ aliases=("chat.settings",),
690
+ columns=("chat_id", "slow_mode", "prehistory", "forum"),
691
+ example={"chat_id": -1001500, "slow_mode": 30, "prehistory": "visible", "forum": False},
692
+ example_args="chat setting get @mygroup",
693
+ covers=(
694
+ "groups-channels-admin.antispam",
695
+ "groups-channels-admin.autotranslation",
696
+ "groups-channels-admin.content-protection",
697
+ "groups-channels-admin.forum-toggle",
698
+ "groups-channels-admin.gift-notifications",
699
+ "groups-channels-admin.group-emoji-set",
700
+ "groups-channels-admin.hidden-members",
701
+ "groups-channels-admin.prehistory-visibility",
702
+ "groups-channels-admin.reactions-settings",
703
+ "groups-channels-admin.signatures",
704
+ "groups-channels-admin.slow-mode",
705
+ "groups-channels-admin.toggle-join-to-send",
706
+ ),
707
+ covers_partial=(
708
+ "groups-channels-admin.channel-direct-messages",
709
+ "groups-channels-admin.group-sticker-set",
710
+ "groups-channels-admin.paid-messages-price",
711
+ "groups-channels-admin.restrict-sponsored",
712
+ "groups-channels-admin.toggle-join-request",
713
+ "groups-channels-admin.view-forum-as-messages",
714
+ ),
715
+ coverage_note="Reading half of the Manage screen; `chat setting set` writes each key.",
716
+ )
717
+
718
+
719
+ class SettingSetReq(Request):
720
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
721
+ slow_mode: Annotated[
722
+ str | None, opt("--slow-mode", metavar="DURATION", help="Seconds between messages, or off.")
723
+ ] = None
724
+ prehistory: Annotated[
725
+ str | None, choice("visible", "hidden", help="History visibility for new members.")
726
+ ] = None
727
+ join_to_send: Annotated[
728
+ str | None, opt("--join-to-send", metavar="ON|OFF", help="Require joining before sending.")
729
+ ] = None
730
+ join_request: Annotated[
731
+ str | None, opt("--join-request", metavar="ON|OFF", help="Require approval to join.")
732
+ ] = None
733
+ guard_bot: Annotated[
734
+ PeerRef | None,
735
+ opt("--guard-bot", metavar="BOT", kind="user", help="Bot that handles the queue."),
736
+ ] = None
737
+ apply_to_links: Annotated[
738
+ bool, opt("--apply-to-links", help="Apply --join-request to existing invite links too.")
739
+ ] = False
740
+ protect: Annotated[
741
+ str | None,
742
+ opt("--protect", "--noforwards", metavar="ON|OFF", help="Restrict saving and forwarding."),
743
+ ] = None
744
+ antispam: Annotated[
745
+ str | None, opt("--antispam", metavar="ON|OFF", help="Aggressive anti-spam.")
746
+ ] = None
747
+ hidden_members: Annotated[
748
+ str | None,
749
+ opt("--hidden-members", metavar="ON|OFF", help="Hide the member list from non-admins."),
750
+ ] = None
751
+ signatures: Annotated[
752
+ str | None, opt("--signatures", metavar="ON|OFF", help="Sign channel posts.")
753
+ ] = None
754
+ signature_profiles: Annotated[
755
+ str | None,
756
+ opt("--signature-profiles", metavar="ON|OFF", help="Link signatures to profiles."),
757
+ ] = None
758
+ forum: Annotated[
759
+ str | None, opt("--forum", metavar="ON|OFF", help="Enable or disable Topics.")
760
+ ] = None
761
+ forum_tabs: Annotated[str | None, choice("tabs", "list", help="Tabbed vs list topic UI.")] = (
762
+ None
763
+ )
764
+ view_as: Annotated[
765
+ str | None, choice("messages", "topics", help="My own view preference for this forum.")
766
+ ] = None
767
+ autotranslate: Annotated[
768
+ str | None, opt("--autotranslate", metavar="ON|OFF", help="Channel auto-translation.")
769
+ ] = None
770
+ ads: Annotated[
771
+ str | None, opt("--ads", metavar="ON|OFF", help="`off` disables sponsored messages.")
772
+ ] = None
773
+ reactions: Annotated[
774
+ str | None,
775
+ opt("--reactions", metavar="ALL|NONE|LIST", help="Allowed reactions."),
776
+ ] = None
777
+ allow_custom_reactions: Annotated[
778
+ str | None,
779
+ opt("--allow-custom-reactions", metavar="ON|OFF", help="With --reactions all."),
780
+ ] = None
781
+ reactions_limit: Annotated[
782
+ int | None,
783
+ opt("--reactions-limit", metavar="N", help="Max distinct reactions per message."),
784
+ ] = None
785
+ paid_reactions: Annotated[
786
+ str | None, opt("--paid-reactions", metavar="ON|OFF", help="Enable Stars reactions.")
787
+ ] = None
788
+ sticker_set: Annotated[
789
+ str | None, opt("--sticker-set", metavar="NAME", help="Group sticker set short name.")
790
+ ] = None
791
+ emoji_set: Annotated[
792
+ str | None, opt("--emoji-set", metavar="NAME", help="Group custom-emoji pack short name.")
793
+ ] = None
794
+ paid_messages: Annotated[
795
+ str | None, opt("--paid-messages", metavar="N|OFF", help="Stars per incoming message.")
796
+ ] = None
797
+ direct_messages: Annotated[
798
+ str | None,
799
+ opt("--direct-messages", metavar="ON|OFF", help="Enable the channel's direct messages."),
800
+ ] = None
801
+ gift_notifications: Annotated[
802
+ str | None,
803
+ opt("--gift-notifications", metavar="ON|OFF", help="Star-gift notices in the channel."),
804
+ ] = None
805
+
806
+
807
+ def _stickerset(value: str) -> Any:
808
+ from telethon.tl import types
809
+
810
+ if value.strip().lower() in ("off", "none", "clear", ""):
811
+ return types.InputStickerSetEmpty()
812
+ return types.InputStickerSetShortName(short_name=value.strip().lstrip("@"))
813
+
814
+
815
+ def _reactions(value: str, allow_custom: bool | None) -> Any:
816
+ from telethon.tl import types
817
+
818
+ text = value.strip().lower()
819
+ if text == "all":
820
+ return types.ChatReactionsAll(allow_custom=allow_custom or None)
821
+ if text in ("none", "off"):
822
+ return types.ChatReactionsNone()
823
+ reactions: list[Any] = []
824
+ for item in value.replace(",", " ").split():
825
+ token = item.strip()
826
+ if not token:
827
+ continue
828
+ if token.isdigit():
829
+ reactions.append(types.ReactionCustomEmoji(document_id=int(token)))
830
+ else:
831
+ reactions.append(types.ReactionEmoji(emoticon=token))
832
+ return types.ChatReactionsSome(reactions=reactions)
833
+
834
+
835
+ async def set_settings(ctx: OpContext, req: SettingSetReq) -> SettingResult:
836
+ """Apply the Manage screen's toggles, one request per key, in a fixed order.
837
+
838
+ A toggle already in the requested state is not sent and is reported in
839
+ `already`; a key the server refuses is reported in `failed` and the rest
840
+ still run. Aborting on the first refusal would make a boost-gated colour
841
+ hide nine successful changes.
842
+ """
843
+ from telethon.tl.functions import channels as chan_fn
844
+ from telethon.tl.functions import messages as msg_fn
845
+ from telethon.tl.functions import payments as pay_fn
846
+
847
+ peer = await _send.resolve(ctx, req.chat)
848
+ current = await get_settings(ctx, SettingGetReq(chat=req.chat))
849
+ handle = _admin.client(ctx)
850
+ channel = _admin.input_channel(peer) if _admin.is_channel(peer) else None
851
+ result = SettingResult(chat_id=peer_id_of(peer) or 0)
852
+
853
+ async def apply(key: str, wanted: Any, is_current: Any, build: Any) -> None:
854
+ if wanted is None:
855
+ return
856
+ if wanted == is_current:
857
+ result.already.append(key)
858
+ return
859
+ try:
860
+ await handle(build())
861
+ except Exception as exc: # one refusal must not hide the other keys
862
+ result.failed[key] = f"{type(exc).__name__}: {exc}"
863
+ return
864
+ result.changed.append(key)
865
+
866
+ if channel is None and any(
867
+ value is not None
868
+ for value in (
869
+ req.slow_mode,
870
+ req.prehistory,
871
+ req.join_to_send,
872
+ req.join_request,
873
+ req.antispam,
874
+ req.hidden_members,
875
+ req.signatures,
876
+ req.forum,
877
+ )
878
+ ):
879
+ raise UsageError(
880
+ "these settings need a supergroup or channel; `chat convert <chat> supergroup` first",
881
+ field="chat",
882
+ )
883
+
884
+ seconds = _slow_mode(req.slow_mode, ctx)
885
+ await apply(
886
+ "slow_mode",
887
+ seconds,
888
+ current.slow_mode,
889
+ lambda: chan_fn.ToggleSlowModeRequest(channel=channel, seconds=seconds or 0),
890
+ )
891
+ prehistory = None if req.prehistory is None else (req.prehistory == "hidden")
892
+ await apply(
893
+ "prehistory",
894
+ prehistory,
895
+ None if current.prehistory is None else current.prehistory == "hidden",
896
+ lambda: chan_fn.TogglePreHistoryHiddenRequest(channel=channel, enabled=bool(prehistory)),
897
+ )
898
+ join_to_send = _tri(req.join_to_send)
899
+ await apply(
900
+ "join_to_send",
901
+ join_to_send,
902
+ current.join_to_send,
903
+ lambda: chan_fn.ToggleJoinToSendRequest(channel=channel, enabled=bool(join_to_send)),
904
+ )
905
+ join_request = _tri(req.join_request)
906
+ guard = (
907
+ _admin.input_user(await _send.resolve(ctx, req.guard_bot))
908
+ if req.guard_bot is not None
909
+ else None
910
+ )
911
+ await apply(
912
+ "join_request",
913
+ join_request,
914
+ current.join_request,
915
+ lambda: chan_fn.ToggleJoinRequestRequest(
916
+ channel=channel,
917
+ enabled=bool(join_request),
918
+ apply_to_invites=req.apply_to_links or None,
919
+ guard_bot=guard,
920
+ ),
921
+ )
922
+ protect = _tri(req.protect)
923
+ await apply(
924
+ "protect",
925
+ protect,
926
+ current.noforwards,
927
+ lambda: msg_fn.ToggleNoForwardsRequest(peer=peer, enabled=bool(protect)),
928
+ )
929
+ antispam = _tri(req.antispam)
930
+ await apply(
931
+ "antispam",
932
+ antispam,
933
+ current.antispam,
934
+ lambda: chan_fn.ToggleAntiSpamRequest(channel=channel, enabled=bool(antispam)),
935
+ )
936
+ hidden = _tri(req.hidden_members)
937
+ await apply(
938
+ "hidden_members",
939
+ hidden,
940
+ current.hidden_members,
941
+ lambda: chan_fn.ToggleParticipantsHiddenRequest(channel=channel, enabled=bool(hidden)),
942
+ )
943
+ signatures = _tri(req.signatures)
944
+ profiles = _tri(req.signature_profiles)
945
+ if signatures is not None or profiles is not None:
946
+ await apply(
947
+ "signatures",
948
+ (signatures if signatures is not None else True, profiles),
949
+ (current.signatures, current.signature_profiles),
950
+ lambda: chan_fn.ToggleSignaturesRequest(
951
+ channel=channel,
952
+ signatures_enabled=(signatures if signatures is not None else True) or None,
953
+ profiles_enabled=profiles or None,
954
+ ),
955
+ )
956
+ forum = _tri(req.forum)
957
+ tabs = None if req.forum_tabs is None else req.forum_tabs == "tabs"
958
+ if forum is not None or tabs is not None:
959
+ await apply(
960
+ "forum",
961
+ (forum if forum is not None else current.forum, tabs),
962
+ (current.forum, None if current.forum_tabs is None else current.forum_tabs == "tabs"),
963
+ lambda: chan_fn.ToggleForumRequest(
964
+ channel=channel,
965
+ enabled=bool(forum if forum is not None else current.forum),
966
+ tabs=bool(tabs),
967
+ ),
968
+ )
969
+ view_as = None if req.view_as is None else req.view_as == "messages"
970
+ await apply(
971
+ "view_as",
972
+ view_as,
973
+ None if current.view_as is None else current.view_as == "messages",
974
+ lambda: chan_fn.ToggleViewForumAsMessagesRequest(channel=channel, enabled=bool(view_as)),
975
+ )
976
+ autotranslate = _tri(req.autotranslate)
977
+ await apply(
978
+ "autotranslate",
979
+ autotranslate,
980
+ current.autotranslate,
981
+ lambda: chan_fn.ToggleAutotranslationRequest(channel=channel, enabled=bool(autotranslate)),
982
+ )
983
+ ads = _tri(req.ads)
984
+ await apply(
985
+ "ads",
986
+ ads,
987
+ current.ads,
988
+ lambda: chan_fn.RestrictSponsoredMessagesRequest(channel=channel, restricted=not ads),
989
+ )
990
+ if req.reactions is not None or req.reactions_limit is not None or req.paid_reactions:
991
+ allow_custom = _tri(req.allow_custom_reactions)
992
+ available = _reactions(req.reactions or "all", allow_custom)
993
+ await apply(
994
+ "reactions",
995
+ (req.reactions, req.reactions_limit, _tri(req.paid_reactions)),
996
+ (None, None, None),
997
+ lambda: msg_fn.SetChatAvailableReactionsRequest(
998
+ peer=peer,
999
+ available_reactions=available,
1000
+ reactions_limit=req.reactions_limit,
1001
+ paid_enabled=_tri(req.paid_reactions),
1002
+ ),
1003
+ )
1004
+ if req.sticker_set is not None:
1005
+ await apply(
1006
+ "sticker_set",
1007
+ req.sticker_set,
1008
+ current.sticker_set,
1009
+ lambda: chan_fn.SetStickersRequest(
1010
+ channel=channel, stickerset=_stickerset(req.sticker_set or "")
1011
+ ),
1012
+ )
1013
+ if req.emoji_set is not None:
1014
+ await apply(
1015
+ "emoji_set",
1016
+ req.emoji_set,
1017
+ current.emoji_set,
1018
+ lambda: chan_fn.SetEmojiStickersRequest(
1019
+ channel=channel, stickerset=_stickerset(req.emoji_set or "")
1020
+ ),
1021
+ )
1022
+ if req.paid_messages is not None or req.direct_messages is not None:
1023
+ stars = _int_or_off(req.paid_messages, "paid_messages")
1024
+ allowed = _tri(req.direct_messages)
1025
+ await apply(
1026
+ "paid_messages",
1027
+ (stars, allowed),
1028
+ (current.paid_messages_stars, current.direct_messages),
1029
+ lambda: chan_fn.UpdatePaidMessagesPriceRequest(
1030
+ channel=channel,
1031
+ send_paid_messages_stars=stars or 0,
1032
+ broadcast_messages_allowed=allowed,
1033
+ ),
1034
+ )
1035
+ gifts = _tri(req.gift_notifications)
1036
+ await apply(
1037
+ "gift_notifications",
1038
+ gifts,
1039
+ current.gift_notifications,
1040
+ lambda: pay_fn.ToggleChatStarGiftNotificationsRequest(peer=peer, enabled=gifts),
1041
+ )
1042
+
1043
+ if not (result.changed or result.already or result.failed):
1044
+ raise UsageError("nothing to change; `chat setting get` lists the keys", field="chat")
1045
+ if result.already and not result.changed:
1046
+ _admin.already(ctx)
1047
+ if result.changed:
1048
+ ctx.emit("chat_settings_changed", {"chat_id": result.chat_id, "changed": result.changed})
1049
+ return result
1050
+
1051
+
1052
+ SPEC_SETTING_SET = OperationSpec(
1053
+ id="chat.setting.set",
1054
+ request=SettingSetReq,
1055
+ response=SettingResult,
1056
+ impl=set_settings,
1057
+ summary="Change group/channel policy toggles (the Manage screen)",
1058
+ description=(
1059
+ "Idempotent: a toggle already in the requested state is reported in "
1060
+ "`already` and never sent. Failures are reported per key in `failed` "
1061
+ "and do not stop the rest, because a boost-gated refusal must not "
1062
+ "hide the changes that did land. `--direct-messages` and "
1063
+ "`--paid-messages` are the same call."
1064
+ ),
1065
+ mutating=True,
1066
+ columns=("chat_id", "changed", "already"),
1067
+ example={"chat_id": -1001500, "changed": ["slow_mode"], "already": [], "failed": {}},
1068
+ example_args="chat setting set @mygroup --slow-mode 30s --hidden-members on",
1069
+ covers=(
1070
+ "dialogs.channel-autotranslation",
1071
+ "dialogs.forum-tabs-mode",
1072
+ "emoji.status-channel",
1073
+ "groups-channels-admin.channel-direct-messages",
1074
+ "groups-channels-admin.group-sticker-set",
1075
+ "groups-channels-admin.restrict-sponsored",
1076
+ "groups-channels-admin.toggle-join-request",
1077
+ "groups-channels-admin.view-forum-as-messages",
1078
+ "messages-core.paid-messages-group-price",
1079
+ "messages-core.translate-channel-autotranslation",
1080
+ "sticker.group-sticker-set",
1081
+ ),
1082
+ covers_partial=(
1083
+ "groups-channels-admin.antispam",
1084
+ "groups-channels-admin.autotranslation",
1085
+ "groups-channels-admin.channel-sponsored-messages",
1086
+ "groups-channels-admin.content-protection",
1087
+ "groups-channels-admin.forum-toggle",
1088
+ "groups-channels-admin.gift-notifications",
1089
+ "groups-channels-admin.group-emoji-set",
1090
+ "groups-channels-admin.hidden-members",
1091
+ "groups-channels-admin.paid-messages-price",
1092
+ "groups-channels-admin.prehistory-visibility",
1093
+ "groups-channels-admin.reactions-settings",
1094
+ "groups-channels-admin.signatures",
1095
+ "groups-channels-admin.slow-mode",
1096
+ "groups-channels-admin.toggle-join-to-send",
1097
+ ),
1098
+ coverage_note=(
1099
+ "The writing half of the Manage screen; `chat setting get` reads the "
1100
+ "same keys and owns most of these ids."
1101
+ ),
1102
+ )
1103
+
1104
+
1105
+ # ---------------------------------------------------------------------------
1106
+ # chat username *
1107
+ # ---------------------------------------------------------------------------
1108
+
1109
+
1110
+ class UsernameGetReq(Request):
1111
+ username: Annotated[str, arg(0, metavar="USERNAME", help="The name to check.")]
1112
+ chat: Annotated[
1113
+ PeerRef | None,
1114
+ opt("--chat", metavar="CHAT", kind="peer", help="Check it for this existing peer."),
1115
+ ] = None
1116
+
1117
+
1118
+ async def check_username(ctx: OpContext, req: UsernameGetReq) -> UsernameCheck:
1119
+ """Availability, and — when Fragment sells it — how to buy it."""
1120
+ from telethon.tl import types
1121
+ from telethon.tl.functions import channels as fn
1122
+ from telethon.tl.functions import fragment as frag_fn
1123
+
1124
+ handle = _admin.client(ctx)
1125
+ name = req.username.lstrip("@")
1126
+ channel: Any = types.InputChannelEmpty()
1127
+ if req.chat is not None:
1128
+ channel = _admin.input_channel(await _send.resolve(ctx, req.chat))
1129
+ try:
1130
+ available = bool(await handle(fn.CheckUsernameRequest(channel=channel, username=name)))
1131
+ status = "available" if available else "occupied"
1132
+ except Exception as exc:
1133
+ message = str(exc)
1134
+ if "USERNAME_PURCHASE_AVAILABLE" in message:
1135
+ status, available = "purchasable", False
1136
+ elif "USERNAME_INVALID" in message:
1137
+ status, available = "invalid", False
1138
+ elif "USERNAME_OCCUPIED" in message:
1139
+ status, available = "occupied", False
1140
+ else:
1141
+ raise
1142
+ collectible = None
1143
+ if status == "purchasable":
1144
+ try:
1145
+ info = await handle(
1146
+ frag_fn.GetCollectibleInfoRequest(
1147
+ collectible=types.InputCollectibleUsername(username=name)
1148
+ )
1149
+ )
1150
+ collectible = {
1151
+ "purchase_date": fmt_dt(getattr(info, "purchase_date", None)),
1152
+ "currency": str(getattr(info, "currency", "") or ""),
1153
+ "amount": int(getattr(info, "amount", 0) or 0),
1154
+ "crypto_currency": str(getattr(info, "crypto_currency", "") or ""),
1155
+ "crypto_amount": int(getattr(info, "crypto_amount", 0) or 0),
1156
+ "url": str(getattr(info, "url", "") or ""),
1157
+ }
1158
+ except Exception as exc: # pragma: no cover - Fragment is optional
1159
+ ctx.warn(f"Fragment did not answer for @{name}: {exc}")
1160
+ return UsernameCheck(username=name, status=status, available=available, collectible=collectible)
1161
+
1162
+
1163
+ SPEC_USERNAME_GET = OperationSpec(
1164
+ id="chat.username.get",
1165
+ request=UsernameGetReq,
1166
+ response=UsernameCheck,
1167
+ impl=check_username,
1168
+ summary="Check a username: availability, owner, and Fragment collectible info",
1169
+ description=(
1170
+ "`status` is available, occupied, invalid or purchasable. On "
1171
+ "USERNAME_PURCHASE_AVAILABLE the Fragment purchase date, currency, "
1172
+ "amount and URL are attached — buying happens on fragment.com, not "
1173
+ "through the API."
1174
+ ),
1175
+ columns=("username", "status", "available"),
1176
+ example={"username": "mynews", "status": "available", "available": True},
1177
+ example_args="chat username get mynews",
1178
+ covers=(
1179
+ "groups-channels-admin.check-username",
1180
+ "groups-channels-admin.collectible-username-info",
1181
+ ),
1182
+ )
1183
+
1184
+
1185
+ class UsernameSetReq(Request):
1186
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
1187
+ username: Annotated[
1188
+ str | None, arg(1, metavar="USERNAME", required=False, help="The public link to claim.")
1189
+ ] = None
1190
+ order: Annotated[
1191
+ str | None, opt("--order", metavar="LIST", help="Set the display order of the usernames.")
1192
+ ] = None
1193
+ upgrade: Annotated[
1194
+ bool, opt("--upgrade", help="Migrate a basic group to a supergroup first, if needed.")
1195
+ ] = False
1196
+
1197
+
1198
+ async def set_username(ctx: OpContext, req: UsernameSetReq) -> UsernameResult:
1199
+ """Claim the public link, or reorder the additional usernames."""
1200
+ from telethon.tl.functions import channels as fn
1201
+
1202
+ if bool(req.username) == bool(req.order):
1203
+ raise UsageError("give exactly one of <username> and --order", field="username")
1204
+ peer = await _send.resolve(ctx, req.chat)
1205
+ if not _admin.is_channel(peer):
1206
+ if not req.upgrade:
1207
+ raise UsageError(
1208
+ "a basic group cannot have a username; pass --upgrade to migrate it "
1209
+ "to a supergroup first (this is one-way)",
1210
+ field="chat",
1211
+ )
1212
+ migrated = await convert_chat(ctx, ConvertReq(chat=req.chat, target="supergroup"))
1213
+ peer = await _send.resolve(ctx, str(migrated.chat_id))
1214
+ channel = _admin.input_channel(peer)
1215
+ handle = _admin.client(ctx)
1216
+ if req.order:
1217
+ order = [item.strip().lstrip("@") for item in req.order.replace(",", " ").split() if item]
1218
+ await handle(fn.ReorderUsernamesRequest(channel=channel, order=order))
1219
+ return UsernameResult(chat_id=peer_id_of(peer) or 0, usernames=order)
1220
+ name = (req.username or "").lstrip("@")
1221
+ await handle(fn.UpdateUsernameRequest(channel=channel, username=name))
1222
+ ctx.emit("chat_username_changed", {"chat_id": peer_id_of(peer) or 0, "username": name})
1223
+ return UsernameResult(chat_id=peer_id_of(peer) or 0, username=name, link=f"https://t.me/{name}")
1224
+
1225
+
1226
+ SPEC_USERNAME_SET = OperationSpec(
1227
+ id="chat.username.set",
1228
+ request=UsernameSetReq,
1229
+ response=UsernameResult,
1230
+ impl=set_username,
1231
+ summary="Set the public link, or the display order of the additional usernames",
1232
+ description=(
1233
+ "Exactly one of `<username>` and `--order` is required. A basic "
1234
+ "group has no username at all: `--upgrade` migrates it first, and "
1235
+ "never silently. Public groups are forced to a visible prehistory by "
1236
+ "the server, and the public-peer count is capped per account."
1237
+ ),
1238
+ mutating=True,
1239
+ columns=("chat_id", "username", "link"),
1240
+ example={"chat_id": -1001500, "username": "mynews", "link": "https://t.me/mynews"},
1241
+ example_args="chat username set @mygroup mynews",
1242
+ covers=(
1243
+ "groups-channels-admin.public-private-toggle",
1244
+ "groups-channels-admin.set-username",
1245
+ "groups-channels-admin.username-reorder",
1246
+ ),
1247
+ tags=frozenset({"visible-to-others"}),
1248
+ )
1249
+
1250
+
1251
+ class UsernameToggleReq(Request):
1252
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
1253
+ username: Annotated[str, arg(1, metavar="USERNAME", help="The additional username.")]
1254
+ state: Annotated[str, arg(2, metavar="ON|OFF", help="Activate or deactivate it.")]
1255
+
1256
+
1257
+ async def toggle_username(ctx: OpContext, req: UsernameToggleReq) -> UsernameResult:
1258
+ """Activate or deactivate one additional (collectible) username."""
1259
+ from telethon.tl.functions import channels as fn
1260
+
1261
+ peer = await _send.resolve(ctx, req.chat)
1262
+ active = _tri(req.state)
1263
+ name = req.username.lstrip("@")
1264
+ await _admin.client(ctx)(
1265
+ fn.ToggleUsernameRequest(
1266
+ channel=_admin.input_channel(peer), username=name, active=bool(active)
1267
+ )
1268
+ )
1269
+ return UsernameResult(chat_id=peer_id_of(peer) or 0, usernames=[name] if active else [])
1270
+
1271
+
1272
+ SPEC_USERNAME_TOGGLE = OperationSpec(
1273
+ id="chat.username.toggle",
1274
+ request=UsernameToggleReq,
1275
+ response=UsernameResult,
1276
+ impl=toggle_username,
1277
+ summary="Activate or deactivate one additional username",
1278
+ mutating=True,
1279
+ columns=("chat_id", "usernames"),
1280
+ example={"chat_id": -1001500, "usernames": ["mynews"]},
1281
+ example_args="chat username toggle @mygroup mynews off",
1282
+ covers=("groups-channels-admin.username-toggle",),
1283
+ )
1284
+
1285
+
1286
+ class UsernameUnsetReq(Request):
1287
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
1288
+ everything: Annotated[
1289
+ bool, opt("--all", help="Deactivate every username; collectibles stay reserved.")
1290
+ ] = False
1291
+
1292
+
1293
+ async def unset_username(ctx: OpContext, req: UsernameUnsetReq) -> UsernameResult:
1294
+ """Make a chat private, and print the invite link that keeps it reachable."""
1295
+ from telethon.tl.functions import channels as fn
1296
+ from telethon.tl.functions import messages as msg_fn
1297
+
1298
+ peer = await _send.resolve(ctx, req.chat)
1299
+ channel = _admin.input_channel(peer)
1300
+ handle = _admin.client(ctx)
1301
+ if req.everything:
1302
+ await handle(fn.DeactivateAllUsernamesRequest(channel=channel))
1303
+ else:
1304
+ await handle(fn.UpdateUsernameRequest(channel=channel, username=""))
1305
+ invite = await handle(msg_fn.ExportChatInviteRequest(peer=peer))
1306
+ return UsernameResult(
1307
+ chat_id=peer_id_of(peer) or 0,
1308
+ usernames=[],
1309
+ invite_link=str(getattr(invite, "link", "") or ""),
1310
+ )
1311
+
1312
+
1313
+ SPEC_USERNAME_UNSET = OperationSpec(
1314
+ id="chat.username.unset",
1315
+ request=UsernameUnsetReq,
1316
+ response=UsernameResult,
1317
+ impl=unset_username,
1318
+ summary="Make a group/channel private by clearing its username(s)",
1319
+ description=(
1320
+ "Prints the private invite link afterwards, so the chat stays "
1321
+ "reachable rather than becoming unfindable in one command."
1322
+ ),
1323
+ mutating=True,
1324
+ columns=("chat_id", "invite_link"),
1325
+ example={"chat_id": -1001500, "usernames": [], "invite_link": "https://t.me/+AbCdEf"},
1326
+ example_args="chat username unset @mygroup",
1327
+ covers=("groups-channels-admin.username-deactivate-all",),
1328
+ covers_partial=("groups-channels-admin.public-private-toggle",),
1329
+ coverage_note="Going private is here; `chat username set` goes public and owns the id.",
1330
+ )
1331
+
1332
+
1333
+ # ---------------------------------------------------------------------------
1334
+ # chat photo set / delete
1335
+ # ---------------------------------------------------------------------------
1336
+
1337
+
1338
+ async def _set_photo(
1339
+ ctx: OpContext,
1340
+ peer: Any,
1341
+ *,
1342
+ file: str | None = None,
1343
+ video: str | None = None,
1344
+ video_start: float | None = None,
1345
+ emoji_markup: int | None = None,
1346
+ emoji_bg: str = "",
1347
+ ) -> int | None:
1348
+ from telethon.tl import types
1349
+ from telethon.tl.functions import channels as chan_fn
1350
+ from telethon.tl.functions import messages as msg_fn
1351
+
1352
+ handle = _admin.client(ctx)
1353
+ markup = None
1354
+ if emoji_markup:
1355
+ colors = [int(c) for c in emoji_bg.replace(",", " ").split() if c.strip().isdigit()]
1356
+ markup = types.VideoSizeEmojiMarkup(
1357
+ emoji_id=emoji_markup, background_colors=colors or [0xFFFFFF]
1358
+ )
1359
+ # The file pipeline is a service on the context, not an import: `ops/`
1360
+ # may not reach into `daemon/` (§2.2).
1361
+ upload: Any = getattr(ctx, "upload_file", None)
1362
+ if (file or video) and upload is None: # pragma: no cover - the daemon supplies it
1363
+ raise UsageError("this context cannot upload files")
1364
+ uploaded_file = await upload(file) if file else None
1365
+ uploaded_video = await upload(video) if video else None
1366
+ photo: Any = types.InputChatUploadedPhoto(
1367
+ file=uploaded_file,
1368
+ video=uploaded_video,
1369
+ video_start_ts=video_start,
1370
+ video_emoji_markup=markup,
1371
+ )
1372
+ if _admin.is_channel(peer):
1373
+ updates = await handle(
1374
+ chan_fn.EditPhotoRequest(channel=_admin.input_channel(peer), photo=photo)
1375
+ )
1376
+ else:
1377
+ updates = await handle(
1378
+ msg_fn.EditChatPhotoRequest(chat_id=_admin.small_chat_id(peer), photo=photo)
1379
+ )
1380
+ for chat in getattr(updates, "chats", None) or []:
1381
+ candidate = getattr(getattr(chat, "photo", None), "photo_id", None)
1382
+ if candidate:
1383
+ return int(candidate)
1384
+ return None
1385
+
1386
+
1387
+ class PhotoSetReq(Request):
1388
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
1389
+ file: Annotated[
1390
+ str | None, arg(1, metavar="FILE", kind="path", required=False, help="The still image.")
1391
+ ] = None
1392
+ video: Annotated[
1393
+ str | None, opt("--video", metavar="PATH", kind="path", help="Animated avatar.")
1394
+ ] = None
1395
+ video_start: Annotated[
1396
+ float | None, opt("--video-start", metavar="SECONDS", help="Still frame from --video.")
1397
+ ] = None
1398
+ emoji_markup: Annotated[
1399
+ int | None, opt("--emoji-markup", metavar="ID", help="Build the avatar from an emoji.")
1400
+ ] = None
1401
+ emoji_bg: Annotated[
1402
+ str, opt("--emoji-bg", metavar="COLORS", help="Background palette for --emoji-markup.")
1403
+ ] = ""
1404
+
1405
+
1406
+ async def set_photo(ctx: OpContext, req: PhotoSetReq) -> ChatPhotoResult:
1407
+ """Set the chat photo: a still, a video avatar, or a custom emoji."""
1408
+ given = [bool(req.file), bool(req.video), bool(req.emoji_markup)]
1409
+ if sum(given) != 1:
1410
+ raise UsageError("give exactly one of <file>, --video and --emoji-markup", field="file")
1411
+ peer = await _send.resolve(ctx, req.chat)
1412
+ photo_id = await _set_photo(
1413
+ ctx,
1414
+ peer,
1415
+ file=req.file,
1416
+ video=req.video,
1417
+ video_start=req.video_start,
1418
+ emoji_markup=req.emoji_markup,
1419
+ emoji_bg=req.emoji_bg,
1420
+ )
1421
+ return ChatPhotoResult(chat_id=peer_id_of(peer) or 0, photo_id=photo_id)
1422
+
1423
+
1424
+ SPEC_PHOTO_SET = OperationSpec(
1425
+ id="chat.photo.set",
1426
+ request=PhotoSetReq,
1427
+ response=ChatPhotoResult,
1428
+ impl=set_photo,
1429
+ summary="Set the group/channel photo (still, video or emoji avatar)",
1430
+ description="Exactly one of `<file>`, `--video` and `--emoji-markup` is required.",
1431
+ mutating=True,
1432
+ rate_class="file",
1433
+ columns=("chat_id", "photo_id"),
1434
+ example={"chat_id": -1001500, "photo_id": 5522},
1435
+ example_args="chat photo set @mygroup ./logo.png",
1436
+ covers=("chat.photo-set", "groups-channels-admin.edit-photo"),
1437
+ tags=frozenset({"visible-to-others"}),
1438
+ )
1439
+
1440
+
1441
+ class PhotoDeleteReq(Request):
1442
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
1443
+
1444
+
1445
+ async def delete_photo(ctx: OpContext, req: PhotoDeleteReq) -> ChatPhotoResult:
1446
+ """Remove the chat photo."""
1447
+ from telethon.tl import types
1448
+ from telethon.tl.functions import channels as chan_fn
1449
+ from telethon.tl.functions import messages as msg_fn
1450
+
1451
+ peer = await _send.resolve(ctx, req.chat)
1452
+ empty = types.InputChatPhotoEmpty()
1453
+ if _admin.is_channel(peer):
1454
+ await _admin.client(ctx)(
1455
+ chan_fn.EditPhotoRequest(channel=_admin.input_channel(peer), photo=empty)
1456
+ )
1457
+ else:
1458
+ await _admin.client(ctx)(
1459
+ msg_fn.EditChatPhotoRequest(chat_id=_admin.small_chat_id(peer), photo=empty)
1460
+ )
1461
+ return ChatPhotoResult(chat_id=peer_id_of(peer) or 0, ok=True)
1462
+
1463
+
1464
+ SPEC_PHOTO_DELETE = OperationSpec(
1465
+ id="chat.photo.delete",
1466
+ request=PhotoDeleteReq,
1467
+ response=ChatPhotoResult,
1468
+ impl=delete_photo,
1469
+ summary="Remove the group/channel photo",
1470
+ mutating=True,
1471
+ columns=("chat_id", "ok"),
1472
+ example={"chat_id": -1001500, "ok": True},
1473
+ example_args="chat photo delete @mygroup",
1474
+ covers=("groups-channels-admin.remove-photo",),
1475
+ )
1476
+
1477
+
1478
+ # ---------------------------------------------------------------------------
1479
+ # chat send-as list / set
1480
+ # ---------------------------------------------------------------------------
1481
+
1482
+
1483
+ class SendAsListReq(Request):
1484
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
1485
+
1486
+
1487
+ async def list_send_as(ctx: OpContext, req: SendAsListReq) -> list[SendAsPeer]:
1488
+ """The peers you may post as here, with the current default marked."""
1489
+ from telethon.tl.functions import channels as fn
1490
+
1491
+ peer = await _send.resolve(ctx, req.chat)
1492
+ reply = await _admin.client(ctx)(fn.GetSendAsRequest(peer=peer))
1493
+ entities = _admin.entity_map(reply)
1494
+ full, _entity, _entities = await _admin.full_chat(ctx, peer)
1495
+ default_raw = getattr(full, "default_send_as", None)
1496
+ default_id = peer_id_of(default_raw) if default_raw is not None else None
1497
+ out: list[SendAsPeer] = []
1498
+ for row in getattr(reply, "peers", None) or []:
1499
+ marked = peer_id_of(getattr(row, "peer", None)) or 0
1500
+ entity = entities.get(marked)
1501
+ out.append(
1502
+ SendAsPeer(
1503
+ id=marked,
1504
+ type=("user" if marked > 0 else "channel"),
1505
+ title=_admin.display_name(entity),
1506
+ premium_required=bool(getattr(row, "premium_required", False)),
1507
+ default=marked == default_id,
1508
+ )
1509
+ )
1510
+ return out
1511
+
1512
+
1513
+ SPEC_SEND_AS_LIST = OperationSpec(
1514
+ id="chat.send-as.list",
1515
+ request=SendAsListReq,
1516
+ response=list[SendAsPeer],
1517
+ impl=list_send_as,
1518
+ summary="Peers I may post as in this chat",
1519
+ columns=("id", "type", "title", "default"),
1520
+ example=[{"id": -1001500, "type": "channel", "title": "News", "default": True}],
1521
+ example_args="chat send-as list @mygroup",
1522
+ covers_partial=("groups-channels-admin.send-as",),
1523
+ coverage_note="Listing half; `chat send-as set` writes the default and owns the id.",
1524
+ )
1525
+
1526
+
1527
+ class SendAsSetReq(Request):
1528
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
1529
+ peer: Annotated[PeerRef, arg(1, metavar="PEER", kind="peer", help="Who to post as.")]
1530
+
1531
+
1532
+ async def set_send_as(ctx: OpContext, req: SendAsSetReq) -> SendAsResult:
1533
+ """Set the default identity for this chat. Reactions follow it too."""
1534
+ from telethon.tl.functions import messages as fn
1535
+
1536
+ peer = await _send.resolve(ctx, req.chat)
1537
+ identity = await _send.resolve(ctx, req.peer)
1538
+ await _admin.client(ctx)(fn.SaveDefaultSendAsRequest(peer=peer, send_as=identity))
1539
+ return SendAsResult(chat_id=peer_id_of(peer) or 0, send_as=peer_id_of(identity) or 0)
1540
+
1541
+
1542
+ SPEC_SEND_AS_SET = OperationSpec(
1543
+ id="chat.send-as.set",
1544
+ request=SendAsSetReq,
1545
+ response=SendAsResult,
1546
+ impl=set_send_as,
1547
+ summary="Set the default peer I post as in this chat",
1548
+ description=(
1549
+ "Applies to reactions as well as messages. Every send command still "
1550
+ "takes a one-off `--send-as`. The `anonymous` admin right forces the "
1551
+ "group itself."
1552
+ ),
1553
+ mutating=True,
1554
+ columns=("chat_id", "send_as"),
1555
+ example={"chat_id": -1001500, "send_as": -1001600},
1556
+ example_args="chat send-as set @mygroup @mychannel",
1557
+ covers=("groups-channels-admin.send-as",),
1558
+ )
1559
+
1560
+
1561
+ # ---------------------------------------------------------------------------
1562
+ # chat discussion list / set / unset
1563
+ # ---------------------------------------------------------------------------
1564
+
1565
+
1566
+ class DiscussionListReq(Request):
1567
+ pass
1568
+
1569
+
1570
+ async def list_discussion_candidates(
1571
+ ctx: OpContext, req: DiscussionListReq
1572
+ ) -> list[DiscussionCandidate]:
1573
+ """Groups that could become a channel's discussion group."""
1574
+ from telethon.tl.functions import channels as fn
1575
+
1576
+ reply = await _admin.client(ctx)(fn.GetGroupsForDiscussionRequest())
1577
+ out: list[DiscussionCandidate] = []
1578
+ for chat in getattr(reply, "chats", None) or []:
1579
+ is_basic = type(chat).__name__ == "Chat"
1580
+ out.append(
1581
+ DiscussionCandidate(
1582
+ id=_admin.entity_id(chat),
1583
+ title=str(getattr(chat, "title", "") or ""),
1584
+ type="group" if is_basic else "supergroup",
1585
+ needs_migration=is_basic,
1586
+ )
1587
+ )
1588
+ return out
1589
+
1590
+
1591
+ SPEC_DISCUSSION_LIST = OperationSpec(
1592
+ id="chat.discussion.list",
1593
+ request=DiscussionListReq,
1594
+ response=list[DiscussionCandidate],
1595
+ impl=list_discussion_candidates,
1596
+ summary="Groups eligible to become a channel's discussion group",
1597
+ description=(
1598
+ "A basic group in this list must be converted first "
1599
+ "(`chat convert <chat> supergroup`); `needs_migration` says which."
1600
+ ),
1601
+ columns=("id", "title", "type", "needs_migration"),
1602
+ example=[{"id": -1001500, "title": "News chat", "type": "supergroup"}],
1603
+ example_args="chat discussion list",
1604
+ covers=("groups-channels-admin.discussion-candidates",),
1605
+ )
1606
+
1607
+
1608
+ class DiscussionSetReq(Request):
1609
+ channel: Annotated[PeerRef, arg(0, metavar="CHANNEL", kind="peer", help="The broadcast.")]
1610
+ group: Annotated[PeerRef, arg(1, metavar="GROUP", kind="peer", help="The discussion group.")]
1611
+ unhide_prehistory: Annotated[
1612
+ bool, opt("--unhide-prehistory", help="Make the group's prehistory visible first.")
1613
+ ] = False
1614
+
1615
+
1616
+ async def set_discussion(ctx: OpContext, req: DiscussionSetReq) -> DiscussionResult:
1617
+ """Link a discussion group to a channel."""
1618
+ from telethon.tl.functions import channels as fn
1619
+
1620
+ channel = _admin.input_channel(await _send.resolve(ctx, req.channel))
1621
+ group_peer = await _send.resolve(ctx, req.group)
1622
+ group = _admin.input_channel(group_peer)
1623
+ handle = _admin.client(ctx)
1624
+ if req.unhide_prehistory:
1625
+ await handle(fn.TogglePreHistoryHiddenRequest(channel=group, enabled=False))
1626
+ try:
1627
+ await handle(fn.SetDiscussionGroupRequest(broadcast=channel, group=group))
1628
+ except Exception as exc:
1629
+ if "LINK_NOT_MODIFIED" not in str(exc):
1630
+ raise
1631
+ _admin.already(ctx)
1632
+ return DiscussionResult(
1633
+ channel_id=peer_id_of(await _send.resolve(ctx, req.channel)) or 0,
1634
+ linked_chat_id=peer_id_of(group_peer),
1635
+ already=True,
1636
+ )
1637
+ return DiscussionResult(
1638
+ channel_id=peer_id_of(await _send.resolve(ctx, req.channel)) or 0,
1639
+ linked_chat_id=peer_id_of(group_peer),
1640
+ )
1641
+
1642
+
1643
+ SPEC_DISCUSSION_SET = OperationSpec(
1644
+ id="chat.discussion.set",
1645
+ request=DiscussionSetReq,
1646
+ response=DiscussionResult,
1647
+ impl=set_discussion,
1648
+ summary="Link a discussion group to a channel",
1649
+ description=(
1650
+ "The server refuses a group whose prehistory is hidden; "
1651
+ "`--unhide-prehistory` makes it visible first, and says so rather "
1652
+ "than doing it silently. LINK_NOT_MODIFIED reports `already: true`."
1653
+ ),
1654
+ mutating=True,
1655
+ columns=("channel_id", "linked_chat_id"),
1656
+ example={"channel_id": -1001600, "linked_chat_id": -1001500},
1657
+ example_args="chat discussion set @mychannel @mygroup",
1658
+ covers=("groups-channels-admin.discussion-link",),
1659
+ )
1660
+
1661
+
1662
+ class DiscussionUnsetReq(Request):
1663
+ channel: Annotated[PeerRef, arg(0, metavar="CHANNEL", kind="peer", help="The broadcast.")]
1664
+
1665
+
1666
+ async def unset_discussion(ctx: OpContext, req: DiscussionUnsetReq) -> DiscussionResult:
1667
+ """Unlink a channel's discussion group."""
1668
+ from telethon.tl import types
1669
+ from telethon.tl.functions import channels as fn
1670
+
1671
+ peer = await _send.resolve(ctx, req.channel)
1672
+ await _admin.client(ctx)(
1673
+ fn.SetDiscussionGroupRequest(
1674
+ broadcast=_admin.input_channel(peer), group=types.InputChannelEmpty()
1675
+ )
1676
+ )
1677
+ return DiscussionResult(channel_id=peer_id_of(peer) or 0, linked_chat_id=None)
1678
+
1679
+
1680
+ SPEC_DISCUSSION_UNSET = OperationSpec(
1681
+ id="chat.discussion.unset",
1682
+ request=DiscussionUnsetReq,
1683
+ response=DiscussionResult,
1684
+ impl=unset_discussion,
1685
+ summary="Unlink a channel's discussion group",
1686
+ mutating=True,
1687
+ columns=("channel_id", "linked_chat_id"),
1688
+ example={"channel_id": -1001600},
1689
+ example_args="chat discussion unset @mychannel",
1690
+ covers=("groups-channels-admin.discussion-unlink",),
1691
+ )