tlgr-cli 2.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tlgr/__init__.py +3 -0
- tlgr/__main__.py +6 -0
- tlgr/actions/__init__.py +45 -0
- tlgr/actions/forward.py +74 -0
- tlgr/actions/reply.py +32 -0
- tlgr/cli/__init__.py +259 -0
- tlgr/cli/confirm.py +55 -0
- tlgr/cli/errors.py +84 -0
- tlgr/cli/gen.py +690 -0
- tlgr/cli/globals.py +273 -0
- tlgr/cli/introspect.py +170 -0
- tlgr/cli/params.py +189 -0
- tlgr/cli/render.py +418 -0
- tlgr/core/__init__.py +0 -0
- tlgr/core/accounts.py +384 -0
- tlgr/core/config.py +358 -0
- tlgr/core/custom_tl.py +170 -0
- tlgr/core/errors.py +687 -0
- tlgr/core/eventtypes.py +1170 -0
- tlgr/core/identity.py +127 -0
- tlgr/core/launchd.py +122 -0
- tlgr/core/logging.py +194 -0
- tlgr/core/media.py +134 -0
- tlgr/core/output.py +251 -0
- tlgr/core/pagination.py +227 -0
- tlgr/core/paths.py +360 -0
- tlgr/core/peers.py +427 -0
- tlgr/core/process.py +138 -0
- tlgr/core/signing.py +38 -0
- tlgr/core/systemd.py +96 -0
- tlgr/core/telethon_compat.py +295 -0
- tlgr/core/text.py +211 -0
- tlgr/core/timefmt.py +199 -0
- tlgr/core/tl.py +98 -0
- tlgr/daemon/__init__.py +0 -0
- tlgr/daemon/app.py +869 -0
- tlgr/daemon/dispatch.py +446 -0
- tlgr/daemon/events.py +723 -0
- tlgr/daemon/files.py +431 -0
- tlgr/daemon/idle.py +119 -0
- tlgr/daemon/jobs.py +68 -0
- tlgr/daemon/main.py +161 -0
- tlgr/daemon/peercred.py +75 -0
- tlgr/daemon/policy.py +113 -0
- tlgr/daemon/preauth.py +366 -0
- tlgr/daemon/ratelimit.py +391 -0
- tlgr/daemon/server.py +24 -0
- tlgr/daemon/session.py +648 -0
- tlgr/daemon/sessions.py +274 -0
- tlgr/daemon/singleton.py +114 -0
- tlgr/daemon/stream.py +193 -0
- tlgr/daemon/transfers.py +219 -0
- tlgr/daemon/webhook.py +390 -0
- tlgr/data/catalog_index.json +1 -0
- tlgr/data/parity_waivers.toml +90 -0
- tlgr/filters/__init__.py +42 -0
- tlgr/filters/compose.py +121 -0
- tlgr/filters/content.py +85 -0
- tlgr/filters/context.py +114 -0
- tlgr/filters/message.py +161 -0
- tlgr/filters/temporal.py +87 -0
- tlgr/filters/user.py +36 -0
- tlgr/gateway/__init__.py +1 -0
- tlgr/gateway/config.py +161 -0
- tlgr/gateway/engine.py +215 -0
- tlgr/gateway/event.py +22 -0
- tlgr/jobs/__init__.py +0 -0
- tlgr/jobs/base.py +81 -0
- tlgr/jobs/client.py +37 -0
- tlgr/models/__init__.py +1220 -0
- tlgr/models/admin.py +744 -0
- tlgr/models/auth.py +510 -0
- tlgr/models/base.py +81 -0
- tlgr/models/bot.py +576 -0
- tlgr/models/business.py +265 -0
- tlgr/models/call.py +586 -0
- tlgr/models/config.py +101 -0
- tlgr/models/contact.py +481 -0
- tlgr/models/daemon.py +336 -0
- tlgr/models/dialog.py +626 -0
- tlgr/models/envelope.py +68 -0
- tlgr/models/error.py +30 -0
- tlgr/models/event.py +79 -0
- tlgr/models/export.py +66 -0
- tlgr/models/gift.py +275 -0
- tlgr/models/inline.py +84 -0
- tlgr/models/location.py +115 -0
- tlgr/models/media.py +507 -0
- tlgr/models/message.py +584 -0
- tlgr/models/net.py +232 -0
- tlgr/models/notify.py +105 -0
- tlgr/models/page.py +32 -0
- tlgr/models/payment.py +172 -0
- tlgr/models/peer.py +400 -0
- tlgr/models/poll.py +119 -0
- tlgr/models/premium.py +161 -0
- tlgr/models/privacy.py +93 -0
- tlgr/models/profile.py +217 -0
- tlgr/models/reaction.py +160 -0
- tlgr/models/resolve.py +175 -0
- tlgr/models/settings.py +103 -0
- tlgr/models/stars.py +101 -0
- tlgr/models/sticker.py +243 -0
- tlgr/models/story.py +467 -0
- tlgr/models/sync.py +105 -0
- tlgr/models/todo.py +36 -0
- tlgr/models/webapp.py +89 -0
- tlgr/ops/__init__.py +63 -0
- tlgr/ops/_admin.py +313 -0
- tlgr/ops/_auth.py +599 -0
- tlgr/ops/_bots.py +586 -0
- tlgr/ops/_calls.py +535 -0
- tlgr/ops/_common.py +160 -0
- tlgr/ops/_layer.py +46 -0
- tlgr/ops/_media.py +592 -0
- tlgr/ops/_params.py +212 -0
- tlgr/ops/_rights.py +402 -0
- tlgr/ops/_send.py +593 -0
- tlgr/ops/_serialize.py +667 -0
- tlgr/ops/_settings.py +306 -0
- tlgr/ops/_spec.py +167 -0
- tlgr/ops/_story.py +743 -0
- tlgr/ops/account.py +2604 -0
- tlgr/ops/agent.py +937 -0
- tlgr/ops/auth.py +1282 -0
- tlgr/ops/bot.py +4880 -0
- tlgr/ops/business.py +1520 -0
- tlgr/ops/call.py +1610 -0
- tlgr/ops/chat.py +4025 -0
- tlgr/ops/chat_admin.py +929 -0
- tlgr/ops/chat_extra.py +1061 -0
- tlgr/ops/chat_invite.py +716 -0
- tlgr/ops/chat_manage.py +1691 -0
- tlgr/ops/chat_member.py +1357 -0
- tlgr/ops/chat_stats.py +902 -0
- tlgr/ops/chat_topic.py +905 -0
- tlgr/ops/conference.py +791 -0
- tlgr/ops/config.py +1698 -0
- tlgr/ops/contact.py +2330 -0
- tlgr/ops/daemon.py +1397 -0
- tlgr/ops/draft.py +299 -0
- tlgr/ops/emoji.py +343 -0
- tlgr/ops/events.py +1327 -0
- tlgr/ops/export.py +596 -0
- tlgr/ops/folder.py +1322 -0
- tlgr/ops/gif.py +522 -0
- tlgr/ops/gift.py +1546 -0
- tlgr/ops/giveaway.py +541 -0
- tlgr/ops/inline.py +773 -0
- tlgr/ops/job.py +799 -0
- tlgr/ops/location.py +917 -0
- tlgr/ops/media.py +4495 -0
- tlgr/ops/message.py +3769 -0
- tlgr/ops/net.py +536 -0
- tlgr/ops/notify.py +840 -0
- tlgr/ops/passport.py +464 -0
- tlgr/ops/payment.py +907 -0
- tlgr/ops/poll.py +1078 -0
- tlgr/ops/premium.py +488 -0
- tlgr/ops/privacy.py +794 -0
- tlgr/ops/profile.py +1481 -0
- tlgr/ops/proxy.py +750 -0
- tlgr/ops/reaction.py +1475 -0
- tlgr/ops/resolve.py +1140 -0
- tlgr/ops/search.py +521 -0
- tlgr/ops/settings.py +1066 -0
- tlgr/ops/stars.py +594 -0
- tlgr/ops/sticker.py +1602 -0
- tlgr/ops/story.py +3216 -0
- tlgr/ops/sync.py +788 -0
- tlgr/ops/todo.py +514 -0
- tlgr/ops/user.py +1406 -0
- tlgr/ops/vc.py +2351 -0
- tlgr/ops/webapp.py +717 -0
- tlgr/ops/webhook.py +418 -0
- tlgr/parity.py +386 -0
- tlgr/processors/__init__.py +125 -0
- tlgr/processors/regex.py +26 -0
- tlgr/processors/text.py +56 -0
- tlgr/registry.py +519 -0
- tlgr/schema.py +173 -0
- tlgr/transport/__init__.py +30 -0
- tlgr/transport/autostart.py +293 -0
- tlgr/transport/client.py +805 -0
- tlgr/transport/ndjson.py +44 -0
- tlgr/version.py +31 -0
- tlgr_cli-2.0.1.dist-info/METADATA +957 -0
- tlgr_cli-2.0.1.dist-info/RECORD +192 -0
- tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
- tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
- tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
- tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/ops/chat_manage.py
ADDED
|
@@ -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
|
+
)
|