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_extra.py
ADDED
|
@@ -0,0 +1,1061 @@
|
|
|
1
|
+
"""The rest of the group/channel surface: the parts nobody puts in one menu.
|
|
2
|
+
|
|
3
|
+
`chat similar`, `chat sponsored`, `chat suggestion`, `chat suggested-post`,
|
|
4
|
+
`chat verification`, `chat direct` (channel DMs), `chat affiliate` (Stars
|
|
5
|
+
referral bots), and the two layer-229 surfaces — `chat community` and
|
|
6
|
+
`chat welcome` — that are registered and refuse.
|
|
7
|
+
|
|
8
|
+
Two deliberate refusals live here.
|
|
9
|
+
|
|
10
|
+
* **tlgr never views or clicks a sponsored message.** `chat sponsored list`
|
|
11
|
+
reports them as data and stops there; `messages.viewSponsoredMessage` and
|
|
12
|
+
`clickSponsoredMessage` are impressions, and a headless CLI has no
|
|
13
|
+
viewport, so sending them would be reporting an impression that did not
|
|
14
|
+
happen. The owner-side switch is `chat setting set --ads off`.
|
|
15
|
+
* **Communities and welcome messages are layer 229.** Telethon 1.44 speaks
|
|
16
|
+
layer 227 and has no request class for `communities.*` or the
|
|
17
|
+
`ephemeral.*` welcome methods, so those seven commands are registered and
|
|
18
|
+
exit 13 with the reason (`ops/_layer.py`). Registering them is the point:
|
|
19
|
+
a command that refuses with an explanation teaches an agent what is
|
|
20
|
+
missing, and a command that is absent teaches it nothing.
|
|
21
|
+
|
|
22
|
+
Telethon is imported inside functions, never at module scope (§2.2).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import base64
|
|
28
|
+
import binascii
|
|
29
|
+
from typing import Annotated, Any
|
|
30
|
+
|
|
31
|
+
from tlgr.core.errors import EXIT_EMPTY, NotFoundError, UsageError
|
|
32
|
+
from tlgr.core.pagination import PageKind, build_page
|
|
33
|
+
from tlgr.core.timefmt import fmt_dt, parse_dt, to_unix
|
|
34
|
+
from tlgr.models.admin import (
|
|
35
|
+
AffiliateBot,
|
|
36
|
+
AffiliateResult,
|
|
37
|
+
CommunityResult,
|
|
38
|
+
CommunityRow,
|
|
39
|
+
DirectBanResult,
|
|
40
|
+
DirectDialog,
|
|
41
|
+
SimilarChat,
|
|
42
|
+
SponsoredReport,
|
|
43
|
+
SuggestedPostResult,
|
|
44
|
+
SuggestionResult,
|
|
45
|
+
VerificationResult,
|
|
46
|
+
WelcomeMessage,
|
|
47
|
+
WelcomeResult,
|
|
48
|
+
)
|
|
49
|
+
from tlgr.models.base import Request
|
|
50
|
+
from tlgr.models.message import SponsoredMessage
|
|
51
|
+
from tlgr.models.page import Page
|
|
52
|
+
from tlgr.models.peer import PeerRef
|
|
53
|
+
from tlgr.ops import _admin, _layer, _rights, _send
|
|
54
|
+
from tlgr.ops._params import arg, opt
|
|
55
|
+
from tlgr.ops._serialize import message_entities, peer_id_of
|
|
56
|
+
from tlgr.ops._spec import OpContext, OperationSpec
|
|
57
|
+
|
|
58
|
+
__all__ = [name for name in dir() if name.startswith("SPEC_")]
|
|
59
|
+
|
|
60
|
+
_EXAMPLE_SIMILAR: dict[str, Any] = {
|
|
61
|
+
"id": -1001600,
|
|
62
|
+
"title": "Telegram Tips",
|
|
63
|
+
"username": "telegramtips",
|
|
64
|
+
"participants_count": 1200000,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _blob(value: str, field: str) -> bytes:
|
|
69
|
+
"""A server-issued opaque id, given back the way it was printed."""
|
|
70
|
+
text = str(value).strip()
|
|
71
|
+
try:
|
|
72
|
+
return base64.b64decode(text + "=" * (-len(text) % 4))
|
|
73
|
+
except (binascii.Error, ValueError):
|
|
74
|
+
try:
|
|
75
|
+
return bytes.fromhex(text)
|
|
76
|
+
except ValueError as exc:
|
|
77
|
+
raise UsageError(
|
|
78
|
+
f"{field} is the opaque id `chat sponsored list` printed", field=field
|
|
79
|
+
) from exc
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# chat similar
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class SimilarReq(Request):
|
|
88
|
+
chat: Annotated[
|
|
89
|
+
PeerRef | None,
|
|
90
|
+
arg(0, metavar="CHAT", kind="peer", required=False, help="Channel, or omit for mine."),
|
|
91
|
+
] = None
|
|
92
|
+
bots: Annotated[bool, opt("--bots", help="Bots similar to this bot instead.")] = False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def list_similar(ctx: OpContext, req: SimilarReq) -> Page[SimilarChat]:
|
|
96
|
+
"""Channels similar to one channel, or the account's own recommendations."""
|
|
97
|
+
from telethon.tl.functions import bots as bot_fn
|
|
98
|
+
from telethon.tl.functions import channels as fn
|
|
99
|
+
|
|
100
|
+
limit, _state = _admin.window(ctx, "chat.similar.list", PageKind.PARTICIPANTS)
|
|
101
|
+
handle = _admin.client(ctx)
|
|
102
|
+
if req.bots:
|
|
103
|
+
if req.chat is None:
|
|
104
|
+
raise UsageError("--bots needs the bot to be similar to", field="chat")
|
|
105
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
106
|
+
reply = await handle(bot_fn.GetBotRecommendationsRequest(bot=_admin.input_user(peer)))
|
|
107
|
+
else:
|
|
108
|
+
channel = None
|
|
109
|
+
if req.chat is not None:
|
|
110
|
+
channel = _admin.input_channel(await _send.resolve(ctx, req.chat))
|
|
111
|
+
reply = await handle(fn.GetChannelRecommendationsRequest(channel=channel))
|
|
112
|
+
rows = [
|
|
113
|
+
SimilarChat(
|
|
114
|
+
id=_admin.entity_id(chat),
|
|
115
|
+
title=str(getattr(chat, "title", "") or ""),
|
|
116
|
+
username=getattr(chat, "username", None),
|
|
117
|
+
participants_count=getattr(chat, "participants_count", None),
|
|
118
|
+
)
|
|
119
|
+
for chat in (getattr(reply, "chats", None) or [])
|
|
120
|
+
]
|
|
121
|
+
# A `chatsSlice` means the server truncated the list for a non-Premium
|
|
122
|
+
# account; reporting `total` is how a script can see the cut instead of
|
|
123
|
+
# concluding there are only five similar channels.
|
|
124
|
+
total = int(getattr(reply, "count", len(rows)) or len(rows))
|
|
125
|
+
return build_page(
|
|
126
|
+
rows[:limit],
|
|
127
|
+
op="chat.similar.list",
|
|
128
|
+
kind=PageKind.PARTICIPANTS,
|
|
129
|
+
account=ctx.account,
|
|
130
|
+
has_more=len(rows) > limit,
|
|
131
|
+
total=total,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
SPEC_SIMILAR_LIST = OperationSpec(
|
|
136
|
+
id="chat.similar.list",
|
|
137
|
+
request=SimilarReq,
|
|
138
|
+
response=Page[SimilarChat],
|
|
139
|
+
impl=list_similar,
|
|
140
|
+
summary="Channels similar to this one (or global recommendations)",
|
|
141
|
+
description=(
|
|
142
|
+
"A non-Premium account gets a truncated `messages.chatsSlice`; "
|
|
143
|
+
"`total` reports the full count so the cut is visible. There is no "
|
|
144
|
+
"bare `chat similar` alias: Click cannot hold a command and a group "
|
|
145
|
+
"under one name, and `chat similar list` is the canonical path."
|
|
146
|
+
),
|
|
147
|
+
paginated=PageKind.PARTICIPANTS,
|
|
148
|
+
columns=("id", "title", "username", "participants_count"),
|
|
149
|
+
example={"items": [_EXAMPLE_SIMILAR], "has_more": False},
|
|
150
|
+
example_args="chat similar list @somechannel",
|
|
151
|
+
covers=(
|
|
152
|
+
"contacts-users.people-you-may-know",
|
|
153
|
+
"dialogs.recommended-channels",
|
|
154
|
+
"groups-channels-admin.similar-channels",
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# chat sponsored
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class SponsoredListReq(Request):
|
|
165
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel.")]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def list_sponsored(ctx: OpContext, req: SponsoredListReq) -> list[SponsoredMessage]:
|
|
169
|
+
"""Sponsored messages the server wants shown here — as data, nothing else.
|
|
170
|
+
|
|
171
|
+
tlgr never calls `messages.viewSponsoredMessage` or
|
|
172
|
+
`clickSponsoredMessage`: those report an impression, a headless CLI has
|
|
173
|
+
no viewport, and faking one would be dishonest to both the advertiser and
|
|
174
|
+
the channel owner.
|
|
175
|
+
"""
|
|
176
|
+
from telethon.tl.functions import messages as fn
|
|
177
|
+
|
|
178
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
179
|
+
reply = await _admin.client(ctx)(fn.GetSponsoredMessagesRequest(peer=peer))
|
|
180
|
+
out: list[SponsoredMessage] = []
|
|
181
|
+
for row in getattr(reply, "messages", None) or []:
|
|
182
|
+
random_id = getattr(row, "random_id", b"") or b""
|
|
183
|
+
out.append(
|
|
184
|
+
SponsoredMessage(
|
|
185
|
+
random_id=base64.b64encode(random_id).decode(),
|
|
186
|
+
title=getattr(row, "title", None),
|
|
187
|
+
message=str(getattr(row, "message", "") or ""),
|
|
188
|
+
entities=message_entities(row),
|
|
189
|
+
url=getattr(row, "url", None),
|
|
190
|
+
button_text=getattr(row, "button_text", None),
|
|
191
|
+
sponsor_info=getattr(row, "sponsor_info", None),
|
|
192
|
+
additional_info=getattr(row, "additional_info", None),
|
|
193
|
+
recommended=bool(getattr(row, "recommended", False)),
|
|
194
|
+
can_report=bool(getattr(row, "can_report", False)),
|
|
195
|
+
viewed=False,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
return out
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
SPEC_SPONSORED_LIST = OperationSpec(
|
|
202
|
+
id="chat.sponsored.list",
|
|
203
|
+
request=SponsoredListReq,
|
|
204
|
+
response=list[SponsoredMessage],
|
|
205
|
+
impl=list_sponsored,
|
|
206
|
+
summary="Sponsored messages the server wants shown in this channel",
|
|
207
|
+
description=(
|
|
208
|
+
"Exposed as data only; `viewed` is always false because tlgr never "
|
|
209
|
+
"reports an impression it did not make. Results are cached five "
|
|
210
|
+
"minutes server-side, which is the API's own contract. Turning them "
|
|
211
|
+
"off for your channel is `chat setting set --ads off`."
|
|
212
|
+
),
|
|
213
|
+
columns=("random_id", "title", "url"),
|
|
214
|
+
example=[{"random_id": "AQID", "title": "Sponsor", "message": "Try this", "url": "https://x"}],
|
|
215
|
+
example_args="chat sponsored list @somechannel",
|
|
216
|
+
empty_exit=EXIT_EMPTY,
|
|
217
|
+
covers_partial=("groups-channels-admin.channel-sponsored-messages",),
|
|
218
|
+
coverage_note="Reading them; `chat sponsored report` owns the id and reports one.",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class SponsoredReportReq(Request):
|
|
223
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel.")]
|
|
224
|
+
random_id: Annotated[
|
|
225
|
+
str, arg(1, metavar="RANDOM_ID", help="The opaque id from `chat sponsored list`.")
|
|
226
|
+
]
|
|
227
|
+
option: Annotated[
|
|
228
|
+
str | None,
|
|
229
|
+
opt("--option", metavar="BLOB", help="Server-provided option for the next step."),
|
|
230
|
+
] = None
|
|
231
|
+
comment: Annotated[str, opt("--comment", metavar="TEXT", help="Free-text comment.")] = ""
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
async def report_sponsored(ctx: OpContext, req: SponsoredReportReq) -> SponsoredReport:
|
|
235
|
+
"""Report a sponsored message. The reason menu is the server's, not ours."""
|
|
236
|
+
from telethon.tl.functions import messages as fn
|
|
237
|
+
|
|
238
|
+
await _send.resolve(ctx, req.chat)
|
|
239
|
+
reply = await _admin.client(ctx)(
|
|
240
|
+
fn.ReportSponsoredMessageRequest(
|
|
241
|
+
random_id=_blob(req.random_id, "random_id"),
|
|
242
|
+
option=_blob(req.option, "option") if req.option else b"",
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
kind = type(reply).__name__
|
|
246
|
+
if kind == "SponsoredMessageReportResultChooseOption":
|
|
247
|
+
return SponsoredReport(
|
|
248
|
+
result="choose-option",
|
|
249
|
+
title=str(getattr(reply, "title", "") or ""),
|
|
250
|
+
options=[
|
|
251
|
+
{
|
|
252
|
+
"text": str(getattr(item, "text", "") or ""),
|
|
253
|
+
"option": base64.b64encode(getattr(item, "option", b"") or b"").decode(),
|
|
254
|
+
}
|
|
255
|
+
for item in (getattr(reply, "options", None) or [])
|
|
256
|
+
],
|
|
257
|
+
)
|
|
258
|
+
if kind == "SponsoredMessageReportResultAdsHidden":
|
|
259
|
+
return SponsoredReport(result="ads-hidden")
|
|
260
|
+
return SponsoredReport(result="reported")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
SPEC_SPONSORED_REPORT = OperationSpec(
|
|
264
|
+
id="chat.sponsored.report",
|
|
265
|
+
request=SponsoredReportReq,
|
|
266
|
+
response=SponsoredReport,
|
|
267
|
+
impl=report_sponsored,
|
|
268
|
+
summary="Report a sponsored message",
|
|
269
|
+
description=(
|
|
270
|
+
"The reason menu is server-driven: with no `--option` the command "
|
|
271
|
+
"prints the option blobs and exits 0, so a script can walk the tree "
|
|
272
|
+
"one level at a time."
|
|
273
|
+
),
|
|
274
|
+
mutating=True,
|
|
275
|
+
columns=("result", "title"),
|
|
276
|
+
example={"result": "choose-option", "title": "What is wrong?", "options": []},
|
|
277
|
+
example_args="chat sponsored report @somechannel AQID",
|
|
278
|
+
covers=("groups-channels-admin.channel-sponsored-messages",),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# ---------------------------------------------------------------------------
|
|
283
|
+
# chat suggestion / suggested-post / verification
|
|
284
|
+
# ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class SuggestionDeleteReq(Request):
|
|
288
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
|
|
289
|
+
key: Annotated[str, arg(1, metavar="KEY", help="The suggestion key to dismiss.")]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
async def dismiss_suggestion(ctx: OpContext, req: SuggestionDeleteReq) -> SuggestionResult:
|
|
293
|
+
"""Dismiss one server-suggested admin action, and report what is left."""
|
|
294
|
+
from telethon.tl.functions import help as fn
|
|
295
|
+
|
|
296
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
297
|
+
await _admin.client(ctx)(fn.DismissSuggestionRequest(peer=peer, suggestion=req.key))
|
|
298
|
+
full, _entity, _entities = await _admin.full_chat(ctx, peer)
|
|
299
|
+
return SuggestionResult(
|
|
300
|
+
chat_id=peer_id_of(peer) or 0,
|
|
301
|
+
key=req.key,
|
|
302
|
+
pending_suggestions=list(getattr(full, "pending_suggestions", None) or []),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
SPEC_SUGGESTION_DELETE = OperationSpec(
|
|
307
|
+
id="chat.suggestion.delete",
|
|
308
|
+
request=SuggestionDeleteReq,
|
|
309
|
+
response=SuggestionResult,
|
|
310
|
+
impl=dismiss_suggestion,
|
|
311
|
+
summary="Dismiss a server-suggested admin action",
|
|
312
|
+
description="The pending keys come from `chat get --full` (`channelFull.pending_suggestions`).",
|
|
313
|
+
mutating=True,
|
|
314
|
+
columns=("chat_id", "key", "pending_suggestions"),
|
|
315
|
+
example={"chat_id": -1001500, "key": "CONVERT_GIGAGROUP", "pending_suggestions": []},
|
|
316
|
+
example_args="chat suggestion delete @mygroup CONVERT_GIGAGROUP",
|
|
317
|
+
covers=("groups-channels-admin.pending-suggestions",),
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class SuggestedPostApproveReq(Request):
|
|
322
|
+
channel: Annotated[PeerRef, arg(0, metavar="CHANNEL", kind="peer", help="The channel.")]
|
|
323
|
+
msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="The suggested post.")]
|
|
324
|
+
at: Annotated[
|
|
325
|
+
str | None, opt("--at", metavar="WHEN", help="Publish then instead of when proposed.")
|
|
326
|
+
] = None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
async def approve_suggested_post(
|
|
330
|
+
ctx: OpContext, req: SuggestedPostApproveReq
|
|
331
|
+
) -> SuggestedPostResult:
|
|
332
|
+
"""Approve a post suggested to a channel, optionally rescheduling it."""
|
|
333
|
+
from telethon.tl.functions import messages as fn
|
|
334
|
+
|
|
335
|
+
peer = await _send.resolve(ctx, req.channel)
|
|
336
|
+
when = parse_dt(req.at) if req.at else None
|
|
337
|
+
if req.at and when is None:
|
|
338
|
+
raise UsageError("--at takes a timestamp", field="at")
|
|
339
|
+
await _admin.client(ctx)(
|
|
340
|
+
fn.ToggleSuggestedPostApprovalRequest(peer=peer, msg_id=req.msg_id, schedule_date=when)
|
|
341
|
+
)
|
|
342
|
+
return SuggestedPostResult(
|
|
343
|
+
channel_id=peer_id_of(peer) or 0,
|
|
344
|
+
msg_id=req.msg_id,
|
|
345
|
+
approved=True,
|
|
346
|
+
schedule_date=fmt_dt(when),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
SPEC_SUGGESTED_POST_APPROVE = OperationSpec(
|
|
351
|
+
id="chat.suggested-post.approve",
|
|
352
|
+
request=SuggestedPostApproveReq,
|
|
353
|
+
response=SuggestedPostResult,
|
|
354
|
+
impl=approve_suggested_post,
|
|
355
|
+
summary="Approve a post suggested to a channel",
|
|
356
|
+
description=(
|
|
357
|
+
"`--at` doubles as the accept-and-reschedule counter-offer. "
|
|
358
|
+
"Accepting a priced post debits the payer, not you."
|
|
359
|
+
),
|
|
360
|
+
mutating=True,
|
|
361
|
+
columns=("channel_id", "msg_id", "approved"),
|
|
362
|
+
example={"channel_id": -1001600, "msg_id": 918, "approved": True},
|
|
363
|
+
example_args="chat suggested-post approve @mychannel 918",
|
|
364
|
+
covers_partial=("groups-channels-admin.suggested-post-approve",),
|
|
365
|
+
coverage_note="Approving; `chat suggested-post deny` rejects and owns the id.",
|
|
366
|
+
tags=frozenset({"visible-to-others"}),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class SuggestedPostDenyReq(Request):
|
|
371
|
+
channel: Annotated[PeerRef, arg(0, metavar="CHANNEL", kind="peer", help="The channel.")]
|
|
372
|
+
msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="The suggested post.")]
|
|
373
|
+
comment: Annotated[
|
|
374
|
+
str, opt("--comment", metavar="TEXT", help="Reason sent back to the author.")
|
|
375
|
+
] = ""
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
async def deny_suggested_post(ctx: OpContext, req: SuggestedPostDenyReq) -> SuggestedPostResult:
|
|
379
|
+
"""Reject a suggested post, with a reason the author sees."""
|
|
380
|
+
from telethon.tl.functions import messages as fn
|
|
381
|
+
|
|
382
|
+
peer = await _send.resolve(ctx, req.channel)
|
|
383
|
+
await _admin.client(ctx)(
|
|
384
|
+
fn.ToggleSuggestedPostApprovalRequest(
|
|
385
|
+
peer=peer, msg_id=req.msg_id, reject=True, reject_comment=req.comment or None
|
|
386
|
+
)
|
|
387
|
+
)
|
|
388
|
+
return SuggestedPostResult(channel_id=peer_id_of(peer) or 0, msg_id=req.msg_id, rejected=True)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
SPEC_SUGGESTED_POST_DENY = OperationSpec(
|
|
392
|
+
id="chat.suggested-post.deny",
|
|
393
|
+
request=SuggestedPostDenyReq,
|
|
394
|
+
response=SuggestedPostResult,
|
|
395
|
+
impl=deny_suggested_post,
|
|
396
|
+
summary="Reject a post suggested to a channel",
|
|
397
|
+
mutating=True,
|
|
398
|
+
destructive=True,
|
|
399
|
+
columns=("channel_id", "msg_id", "rejected"),
|
|
400
|
+
example={"channel_id": -1001600, "msg_id": 918, "rejected": True},
|
|
401
|
+
example_args="chat suggested-post deny @mychannel 918 --comment 'off topic' --yes",
|
|
402
|
+
covers=("groups-channels-admin.suggested-post-approve",),
|
|
403
|
+
tags=frozenset({"visible-to-others"}),
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class VerificationSetReq(Request):
|
|
408
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The peer to badge.")]
|
|
409
|
+
bot: Annotated[
|
|
410
|
+
PeerRef | None,
|
|
411
|
+
opt("--bot", metavar="BOT", kind="user", help="Your bot with verifier_settings."),
|
|
412
|
+
] = None
|
|
413
|
+
description: Annotated[
|
|
414
|
+
str, opt("--description", metavar="TEXT", help="Custom description for the badge.")
|
|
415
|
+
] = ""
|
|
416
|
+
off: Annotated[bool, opt("--off", help="Remove the badge instead.")] = False
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
async def set_verification(ctx: OpContext, req: VerificationSetReq) -> VerificationResult:
|
|
420
|
+
"""Attach or remove a third-party (bot) verification badge.
|
|
421
|
+
|
|
422
|
+
Not the blue Telegram check: `channel.verified` is server-assigned and
|
|
423
|
+
cannot be set through the API at all.
|
|
424
|
+
"""
|
|
425
|
+
from telethon.tl.functions import bots as fn
|
|
426
|
+
|
|
427
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
428
|
+
bot = _admin.input_user(await _send.resolve(ctx, req.bot)) if req.bot is not None else None
|
|
429
|
+
await _admin.client(ctx)(
|
|
430
|
+
fn.SetCustomVerificationRequest(
|
|
431
|
+
peer=peer,
|
|
432
|
+
bot=bot,
|
|
433
|
+
enabled=None if req.off else True,
|
|
434
|
+
custom_description=req.description or None,
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
return VerificationResult(
|
|
438
|
+
chat_id=peer_id_of(peer) or 0,
|
|
439
|
+
bot_id=abs(peer_id_of(await _send.resolve(ctx, req.bot)) or 0) if req.bot else None,
|
|
440
|
+
enabled=not req.off,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
SPEC_VERIFICATION_SET = OperationSpec(
|
|
445
|
+
id="chat.verification.set",
|
|
446
|
+
request=VerificationSetReq,
|
|
447
|
+
response=VerificationResult,
|
|
448
|
+
impl=set_verification,
|
|
449
|
+
summary="Attach or remove a third-party (bot) verification badge",
|
|
450
|
+
description=(
|
|
451
|
+
"This is a bot's badge, not Telegram's blue check — `channel.verified` "
|
|
452
|
+
"is server-assigned and has no API to set."
|
|
453
|
+
),
|
|
454
|
+
mutating=True,
|
|
455
|
+
columns=("chat_id", "bot_id", "enabled"),
|
|
456
|
+
example={"chat_id": -1001500, "bot_id": 8800, "enabled": True},
|
|
457
|
+
example_args="chat verification set @mygroup --bot @myverifierbot",
|
|
458
|
+
covers=("groups-channels-admin.verify-peer",),
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# ---------------------------------------------------------------------------
|
|
463
|
+
# chat direct (the channel direct-messages monoforum)
|
|
464
|
+
# ---------------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
async def _monoforum(ctx: OpContext, ref: PeerRef) -> tuple[Any, Any, int]:
|
|
468
|
+
"""`(channel peer, monoforum peer, monoforum id)` for a channel's DMs.
|
|
469
|
+
|
|
470
|
+
Every `chat direct` verb acts on `channel.linked_monoforum_id`, not on
|
|
471
|
+
the channel — banning somebody from a channel's DMs while banning them
|
|
472
|
+
from the channel are different things, and conflating them would be a
|
|
473
|
+
moderation action nobody asked for.
|
|
474
|
+
"""
|
|
475
|
+
peer = await _send.resolve(ctx, ref)
|
|
476
|
+
_full, entity, _entities = await _admin.full_chat(ctx, peer)
|
|
477
|
+
linked = getattr(entity, "linked_monoforum_id", None)
|
|
478
|
+
if not linked:
|
|
479
|
+
raise NotFoundError(
|
|
480
|
+
"this channel has no direct-messages conversation; turn it on with "
|
|
481
|
+
"`tlgr chat setting set <channel> --direct-messages on`"
|
|
482
|
+
)
|
|
483
|
+
from tlgr.ops._serialize import marked_id
|
|
484
|
+
|
|
485
|
+
monoforum = await _send.resolve(ctx, str(marked_id(int(linked), "channel")))
|
|
486
|
+
return peer, monoforum, peer_id_of(monoforum) or 0
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
class DirectListReq(Request):
|
|
490
|
+
channel: Annotated[PeerRef, arg(0, metavar="CHANNEL", kind="peer", help="The channel.")]
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
async def list_direct(ctx: OpContext, req: DirectListReq) -> Page[DirectDialog]:
|
|
494
|
+
"""Browse a channel's direct-message conversations."""
|
|
495
|
+
from datetime import datetime, timezone
|
|
496
|
+
|
|
497
|
+
from telethon.tl import types
|
|
498
|
+
from telethon.tl.functions import messages as fn
|
|
499
|
+
|
|
500
|
+
limit, state = _admin.window(ctx, "chat.direct.list", PageKind.PARTICIPANTS)
|
|
501
|
+
_peer, monoforum, _mono_id = await _monoforum(ctx, req.channel)
|
|
502
|
+
offset_date = state.get("date")
|
|
503
|
+
reply = await _admin.client(ctx)(
|
|
504
|
+
fn.GetSavedDialogsRequest(
|
|
505
|
+
parent_peer=monoforum,
|
|
506
|
+
offset_date=datetime.fromtimestamp(offset_date, tz=timezone.utc)
|
|
507
|
+
if offset_date
|
|
508
|
+
else None,
|
|
509
|
+
offset_id=int(state.get("id", 0) or 0),
|
|
510
|
+
offset_peer=types.InputPeerEmpty(),
|
|
511
|
+
limit=limit,
|
|
512
|
+
hash=0,
|
|
513
|
+
)
|
|
514
|
+
)
|
|
515
|
+
entities = _admin.entity_map(reply)
|
|
516
|
+
rows: list[DirectDialog] = []
|
|
517
|
+
for row in getattr(reply, "dialogs", None) or []:
|
|
518
|
+
saved = peer_id_of(getattr(row, "peer", None)) or 0
|
|
519
|
+
entity = entities.get(saved)
|
|
520
|
+
rows.append(
|
|
521
|
+
DirectDialog(
|
|
522
|
+
saved_peer_id=saved,
|
|
523
|
+
name=_admin.display_name(entity),
|
|
524
|
+
top_message=int(getattr(row, "top_message", 0) or 0) or None,
|
|
525
|
+
unread_count=int(getattr(row, "unread_count", 0) or 0),
|
|
526
|
+
unread_reactions_count=int(getattr(row, "unread_reactions_count", 0) or 0),
|
|
527
|
+
nopaid_messages_exception=getattr(row, "nopaid_messages_exception", None),
|
|
528
|
+
)
|
|
529
|
+
)
|
|
530
|
+
next_state = {"id": rows[-1].top_message or 0} if rows else {}
|
|
531
|
+
return build_page(
|
|
532
|
+
rows,
|
|
533
|
+
op="chat.direct.list",
|
|
534
|
+
kind=PageKind.PARTICIPANTS,
|
|
535
|
+
state=next_state,
|
|
536
|
+
account=ctx.account,
|
|
537
|
+
limit=limit,
|
|
538
|
+
total=int(getattr(reply, "count", len(rows)) or len(rows)),
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
SPEC_DIRECT_LIST = OperationSpec(
|
|
543
|
+
id="chat.direct.list",
|
|
544
|
+
request=DirectListReq,
|
|
545
|
+
response=Page[DirectDialog],
|
|
546
|
+
impl=list_direct,
|
|
547
|
+
summary="Browse a channel's direct-message conversations",
|
|
548
|
+
description=(
|
|
549
|
+
"Each conversation is a `monoForumDialog` keyed by `saved_peer_id` "
|
|
550
|
+
"(the user). Reading and replying inside one is "
|
|
551
|
+
"`message list/send --direct <user>`."
|
|
552
|
+
),
|
|
553
|
+
aliases=("chat.monoforum.list",),
|
|
554
|
+
paginated=PageKind.PARTICIPANTS,
|
|
555
|
+
columns=("saved_peer_id", "name", "unread_count"),
|
|
556
|
+
example={"items": [{"saved_peer_id": 4242, "name": "Alice", "unread_count": 1}]},
|
|
557
|
+
example_args="chat direct list @mychannel",
|
|
558
|
+
covers=("groups-channels-admin.monoforum-topic-list",),
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
class DirectBanReq(Request):
|
|
563
|
+
channel: Annotated[PeerRef, arg(0, metavar="CHANNEL", kind="peer", help="The channel.")]
|
|
564
|
+
user: Annotated[PeerRef, arg(1, metavar="USER", kind="user", help="Who to ban from the DMs.")]
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
async def ban_direct(ctx: OpContext, req: DirectBanReq) -> DirectBanResult:
|
|
568
|
+
"""Ban a user from a channel's direct messages, not from the channel."""
|
|
569
|
+
from telethon.tl.functions import channels as fn
|
|
570
|
+
|
|
571
|
+
peer, monoforum, mono_id = await _monoforum(ctx, req.channel)
|
|
572
|
+
user = await _send.resolve(ctx, req.user)
|
|
573
|
+
await _admin.client(ctx)(
|
|
574
|
+
fn.EditBannedRequest(
|
|
575
|
+
channel=_admin.input_channel(monoforum),
|
|
576
|
+
participant=user,
|
|
577
|
+
banned_rights=_rights.build_banned_rights([]),
|
|
578
|
+
)
|
|
579
|
+
)
|
|
580
|
+
return DirectBanResult(
|
|
581
|
+
channel_id=peer_id_of(peer) or 0,
|
|
582
|
+
monoforum_id=mono_id,
|
|
583
|
+
user_id=abs(peer_id_of(user) or 0),
|
|
584
|
+
banned=True,
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
SPEC_DIRECT_BAN = OperationSpec(
|
|
589
|
+
id="chat.direct.ban",
|
|
590
|
+
request=DirectBanReq,
|
|
591
|
+
response=DirectBanResult,
|
|
592
|
+
impl=ban_direct,
|
|
593
|
+
summary="Ban a user from a channel's direct messages",
|
|
594
|
+
description=(
|
|
595
|
+
"Acts on `channel.linked_monoforum_id`, not on the channel: banning "
|
|
596
|
+
"somebody from your DMs and banning them from your channel are "
|
|
597
|
+
"different decisions."
|
|
598
|
+
),
|
|
599
|
+
aliases=("chat.monoforum.ban",),
|
|
600
|
+
mutating=True,
|
|
601
|
+
destructive=True,
|
|
602
|
+
columns=("channel_id", "user_id", "banned"),
|
|
603
|
+
example={"channel_id": -1001600, "monoforum_id": -1001700, "user_id": 4242, "banned": True},
|
|
604
|
+
example_args="chat direct ban @mychannel @spammer --yes",
|
|
605
|
+
covers_partial=("groups-channels-admin.monoforum-ban",),
|
|
606
|
+
coverage_note="Banning; `chat direct unban` lifts it and owns the id.",
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
async def unban_direct(ctx: OpContext, req: DirectBanReq) -> DirectBanResult:
|
|
611
|
+
"""Lift a direct-messages ban."""
|
|
612
|
+
from telethon.tl.functions import channels as fn
|
|
613
|
+
|
|
614
|
+
peer, monoforum, mono_id = await _monoforum(ctx, req.channel)
|
|
615
|
+
user = await _send.resolve(ctx, req.user)
|
|
616
|
+
await _admin.client(ctx)(
|
|
617
|
+
fn.EditBannedRequest(
|
|
618
|
+
channel=_admin.input_channel(monoforum),
|
|
619
|
+
participant=user,
|
|
620
|
+
banned_rights=_rights.build_banned_rights(_rights.all_allowed()),
|
|
621
|
+
)
|
|
622
|
+
)
|
|
623
|
+
return DirectBanResult(
|
|
624
|
+
channel_id=peer_id_of(peer) or 0,
|
|
625
|
+
monoforum_id=mono_id,
|
|
626
|
+
user_id=abs(peer_id_of(user) or 0),
|
|
627
|
+
banned=False,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
SPEC_DIRECT_UNBAN = OperationSpec(
|
|
632
|
+
id="chat.direct.unban",
|
|
633
|
+
request=DirectBanReq,
|
|
634
|
+
response=DirectBanResult,
|
|
635
|
+
impl=unban_direct,
|
|
636
|
+
summary="Unban a user from a channel's direct messages",
|
|
637
|
+
description=(
|
|
638
|
+
"`chat member edit --free-messages on` waives the Stars price for "
|
|
639
|
+
"one user without touching the ban."
|
|
640
|
+
),
|
|
641
|
+
aliases=("chat.monoforum.unban",),
|
|
642
|
+
mutating=True,
|
|
643
|
+
columns=("channel_id", "user_id", "banned"),
|
|
644
|
+
example={"channel_id": -1001600, "monoforum_id": -1001700, "user_id": 4242, "banned": False},
|
|
645
|
+
example_args="chat direct unban @mychannel @alice",
|
|
646
|
+
covers=("groups-channels-admin.monoforum-ban",),
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
# ---------------------------------------------------------------------------
|
|
651
|
+
# chat affiliate
|
|
652
|
+
# ---------------------------------------------------------------------------
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
class AffiliateListReq(Request):
|
|
656
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The channel.")]
|
|
657
|
+
suggested: Annotated[
|
|
658
|
+
bool, opt("--suggested", help="Suggested affiliate bots instead of the connected ones.")
|
|
659
|
+
] = False
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
async def list_affiliates(ctx: OpContext, req: AffiliateListReq) -> Page[AffiliateBot]:
|
|
663
|
+
"""Affiliate (Stars referral) bots connected to a channel, or suggested."""
|
|
664
|
+
from datetime import datetime, timezone
|
|
665
|
+
|
|
666
|
+
from telethon.tl.functions import payments as fn
|
|
667
|
+
|
|
668
|
+
limit, state = _admin.window(ctx, "chat.affiliate.list", PageKind.PARTICIPANTS)
|
|
669
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
670
|
+
handle = _admin.client(ctx)
|
|
671
|
+
rows: list[AffiliateBot] = []
|
|
672
|
+
next_state: dict[str, Any] = {}
|
|
673
|
+
if req.suggested:
|
|
674
|
+
reply = await handle(
|
|
675
|
+
fn.GetSuggestedStarRefBotsRequest(
|
|
676
|
+
peer=peer, offset=str(state.get("offset", "") or ""), limit=limit
|
|
677
|
+
)
|
|
678
|
+
)
|
|
679
|
+
for row in getattr(reply, "suggested_bots", None) or []:
|
|
680
|
+
rows.append(
|
|
681
|
+
AffiliateBot(
|
|
682
|
+
bot_id=int(getattr(row, "bot_id", 0) or 0),
|
|
683
|
+
commission_permille=int(getattr(row, "commission_permille", 0) or 0),
|
|
684
|
+
duration_months=getattr(row, "duration_months", None),
|
|
685
|
+
)
|
|
686
|
+
)
|
|
687
|
+
next_state = {"offset": str(getattr(reply, "next_offset", "") or "")}
|
|
688
|
+
else:
|
|
689
|
+
offset_date = state.get("date")
|
|
690
|
+
reply = await handle(
|
|
691
|
+
fn.GetConnectedStarRefBotsRequest(
|
|
692
|
+
peer=peer,
|
|
693
|
+
limit=limit,
|
|
694
|
+
offset_date=datetime.fromtimestamp(offset_date, tz=timezone.utc)
|
|
695
|
+
if offset_date
|
|
696
|
+
else None,
|
|
697
|
+
offset_link=state.get("link") or None,
|
|
698
|
+
)
|
|
699
|
+
)
|
|
700
|
+
for row in getattr(reply, "connected_bots", None) or []:
|
|
701
|
+
date = getattr(row, "date", None)
|
|
702
|
+
rows.append(
|
|
703
|
+
AffiliateBot(
|
|
704
|
+
bot_id=int(getattr(row, "bot_id", 0) or 0),
|
|
705
|
+
url=str(getattr(row, "url", "") or ""),
|
|
706
|
+
commission_permille=int(getattr(row, "commission_permille", 0) or 0),
|
|
707
|
+
duration_months=getattr(row, "duration_months", None),
|
|
708
|
+
participants=getattr(row, "participants", None),
|
|
709
|
+
revenue=getattr(row, "revenue", None),
|
|
710
|
+
date=fmt_dt(date),
|
|
711
|
+
date_unix=to_unix(date),
|
|
712
|
+
)
|
|
713
|
+
)
|
|
714
|
+
if rows:
|
|
715
|
+
next_state = {"date": rows[-1].date_unix or 0, "link": rows[-1].url}
|
|
716
|
+
return build_page(
|
|
717
|
+
rows,
|
|
718
|
+
op="chat.affiliate.list",
|
|
719
|
+
kind=PageKind.PARTICIPANTS,
|
|
720
|
+
state=next_state,
|
|
721
|
+
account=ctx.account,
|
|
722
|
+
limit=limit,
|
|
723
|
+
total=int(getattr(reply, "count", len(rows)) or len(rows)),
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
SPEC_AFFILIATE_LIST = OperationSpec(
|
|
728
|
+
id="chat.affiliate.list",
|
|
729
|
+
request=AffiliateListReq,
|
|
730
|
+
response=Page[AffiliateBot],
|
|
731
|
+
impl=list_affiliates,
|
|
732
|
+
summary="Affiliate (Star referral) bots connected to a channel, plus suggestions",
|
|
733
|
+
paginated=PageKind.PARTICIPANTS,
|
|
734
|
+
columns=("bot_id", "commission_permille", "participants", "revenue"),
|
|
735
|
+
example={"items": [{"bot_id": 8800, "commission_permille": 200}], "has_more": False},
|
|
736
|
+
example_args="chat affiliate list @mychannel",
|
|
737
|
+
covers_partial=("groups-channels-admin.affiliate-program",),
|
|
738
|
+
coverage_note="Listing; `chat affiliate set` connects one and owns the id.",
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
class AffiliateSetReq(Request):
|
|
743
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The channel.")]
|
|
744
|
+
bot: Annotated[PeerRef, arg(1, metavar="BOT", kind="user", help="The affiliate bot.")]
|
|
745
|
+
off: Annotated[bool, opt("--off", help="Revoke the affiliate link instead.")] = False
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
async def set_affiliate(ctx: OpContext, req: AffiliateSetReq) -> AffiliateResult:
|
|
749
|
+
"""Connect or revoke an affiliate bot for a channel."""
|
|
750
|
+
from telethon.tl.functions import payments as fn
|
|
751
|
+
|
|
752
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
753
|
+
bot = await _send.resolve(ctx, req.bot)
|
|
754
|
+
handle = _admin.client(ctx)
|
|
755
|
+
if req.off:
|
|
756
|
+
connected = await list_affiliates(ctx, AffiliateListReq(chat=req.chat))
|
|
757
|
+
bot_id = abs(peer_id_of(bot) or 0)
|
|
758
|
+
link = next((row.url for row in connected.items if row.bot_id == bot_id), "")
|
|
759
|
+
if not link:
|
|
760
|
+
raise NotFoundError("that bot is not connected to this channel")
|
|
761
|
+
reply = await handle(fn.EditConnectedStarRefBotRequest(peer=peer, link=link, revoked=True))
|
|
762
|
+
else:
|
|
763
|
+
reply = await handle(fn.ConnectStarRefBotRequest(peer=peer, bot=_admin.input_user(bot)))
|
|
764
|
+
connected_bots = list(getattr(reply, "connected_bots", None) or [])
|
|
765
|
+
row = connected_bots[0] if connected_bots else None
|
|
766
|
+
return AffiliateResult(
|
|
767
|
+
chat_id=peer_id_of(peer) or 0,
|
|
768
|
+
bot_id=abs(peer_id_of(bot) or 0),
|
|
769
|
+
url=str(getattr(row, "url", "") or ""),
|
|
770
|
+
commission_permille=int(getattr(row, "commission_permille", 0) or 0),
|
|
771
|
+
revoked=req.off,
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
SPEC_AFFILIATE_SET = OperationSpec(
|
|
776
|
+
id="chat.affiliate.set",
|
|
777
|
+
request=AffiliateSetReq,
|
|
778
|
+
response=AffiliateResult,
|
|
779
|
+
impl=set_affiliate,
|
|
780
|
+
summary="Connect or disconnect an affiliate bot for a channel",
|
|
781
|
+
description="Connecting is free; the Stars commission comes out of the bot's revenue.",
|
|
782
|
+
mutating=True,
|
|
783
|
+
columns=("chat_id", "bot_id", "url", "revoked"),
|
|
784
|
+
example={"chat_id": -1001600, "bot_id": 8800, "url": "https://t.me/mybot?start=ref"},
|
|
785
|
+
example_args="chat affiliate set @mychannel @refbot",
|
|
786
|
+
covers=("groups-channels-admin.affiliate-program",),
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
# ---------------------------------------------------------------------------
|
|
791
|
+
# chat community — layer 229, registered and refusing
|
|
792
|
+
# ---------------------------------------------------------------------------
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
class CommunityCreateReq(Request):
|
|
796
|
+
title: Annotated[str, arg(0, metavar="TITLE", help="The community's title.")]
|
|
797
|
+
about: Annotated[str, opt("--about", metavar="TEXT", help="Description.")] = ""
|
|
798
|
+
hidden: Annotated[bool, opt("--hidden", help="Do not show the community publicly.")] = False
|
|
799
|
+
peer: Annotated[
|
|
800
|
+
list[PeerRef], opt("--peer", metavar="CHAT", kind="peer", help="Seed it with this chat.")
|
|
801
|
+
] = []
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
async def create_community(ctx: OpContext, req: CommunityCreateReq) -> CommunityResult:
|
|
805
|
+
"""Reserved: `communities.create` is layer 229 and Telethon speaks 227."""
|
|
806
|
+
_layer.community_gap("chat community create", "communities.create")
|
|
807
|
+
raise AssertionError # pragma: no cover - community_gap always raises
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
SPEC_COMMUNITY_CREATE = OperationSpec(
|
|
811
|
+
id="chat.community.create",
|
|
812
|
+
request=CommunityCreateReq,
|
|
813
|
+
response=CommunityResult,
|
|
814
|
+
impl=create_community,
|
|
815
|
+
summary="Create a Community (a hub grouping several chats)",
|
|
816
|
+
description=(
|
|
817
|
+
"Registered and refusing with NOT_SUPPORTED (exit 13): the whole "
|
|
818
|
+
"Community surface arrived in MTProto layer 229 and Telethon 1.44 "
|
|
819
|
+
"speaks 227, so there is no request class to send. The command shape "
|
|
820
|
+
"is settled and will start working with the layer uplift."
|
|
821
|
+
),
|
|
822
|
+
mutating=True,
|
|
823
|
+
columns=("community_id", "title"),
|
|
824
|
+
example={"community_id": 0, "title": "Release hub"},
|
|
825
|
+
example_args="chat community create 'Release hub'",
|
|
826
|
+
covers=("groups-channels-admin.community-create",),
|
|
827
|
+
tags=frozenset({"layer-gap"}),
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
class CommunityListReq(Request):
|
|
832
|
+
community: Annotated[
|
|
833
|
+
PeerRef | None,
|
|
834
|
+
arg(0, metavar="COMMUNITY", kind="peer", required=False, help="One community."),
|
|
835
|
+
] = None
|
|
836
|
+
requests: Annotated[bool, opt("--requests", help="Pending link requests instead of chats.")] = (
|
|
837
|
+
False
|
|
838
|
+
)
|
|
839
|
+
user: Annotated[
|
|
840
|
+
PeerRef | None,
|
|
841
|
+
opt("--user", metavar="USER", kind="user", help="Which of its chats this member joined."),
|
|
842
|
+
] = None
|
|
843
|
+
collapse: Annotated[
|
|
844
|
+
str | None, opt("--collapse", metavar="ID=ON|OFF", help="Collapse it in the chat list.")
|
|
845
|
+
] = None
|
|
846
|
+
mute: Annotated[
|
|
847
|
+
str | None, opt("--mute", metavar="ID=WHEN", help="Community-wide notify settings.")
|
|
848
|
+
] = None
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
async def list_communities(ctx: OpContext, req: CommunityListReq) -> Page[CommunityRow]:
|
|
852
|
+
"""Reserved: `communities.getJoinedCommunities` is layer 229."""
|
|
853
|
+
_layer.community_gap(
|
|
854
|
+
"chat community list",
|
|
855
|
+
"communities.getJoinedCommunities / getPeerLinkRequests / getParticipantJoinedChats",
|
|
856
|
+
)
|
|
857
|
+
raise AssertionError # pragma: no cover - community_gap always raises
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
SPEC_COMMUNITY_LIST = OperationSpec(
|
|
861
|
+
id="chat.community.list",
|
|
862
|
+
request=CommunityListReq,
|
|
863
|
+
response=Page[CommunityRow],
|
|
864
|
+
impl=list_communities,
|
|
865
|
+
summary="List my communities, a community's chats, or its pending link requests",
|
|
866
|
+
description="Registered and refusing with NOT_SUPPORTED (exit 13): layer 229.",
|
|
867
|
+
paginated=PageKind.PARTICIPANTS,
|
|
868
|
+
columns=("id", "title", "type"),
|
|
869
|
+
example={"items": [], "has_more": False},
|
|
870
|
+
example_args="chat community list",
|
|
871
|
+
covers=("dialogs.community-collapse", "dialogs.notify-community"),
|
|
872
|
+
covers_partial=(
|
|
873
|
+
"groups-channels-admin.community-link-requests",
|
|
874
|
+
"groups-channels-admin.community-manage-links",
|
|
875
|
+
),
|
|
876
|
+
coverage_note=(
|
|
877
|
+
"The whole Community surface is layer-229 and refuses with a reason; "
|
|
878
|
+
"`chat community set` and `chat community ban` own the two ids."
|
|
879
|
+
),
|
|
880
|
+
tags=frozenset({"layer-gap"}),
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
class CommunitySetReq(Request):
|
|
885
|
+
community: Annotated[PeerRef, arg(0, metavar="COMMUNITY", kind="peer", help="The community.")]
|
|
886
|
+
chat: Annotated[PeerRef, arg(1, metavar="CHAT", kind="peer", help="The chat to link.")]
|
|
887
|
+
state: Annotated[
|
|
888
|
+
str | None,
|
|
889
|
+
arg(2, metavar="STATE", required=False, help="visible | hidden | removed."),
|
|
890
|
+
] = None
|
|
891
|
+
approve: Annotated[bool, opt("--approve", help="Approve this chat's link request.")] = False
|
|
892
|
+
deny: Annotated[bool, opt("--deny", help="Reject this chat's link request.")] = False
|
|
893
|
+
everything: Annotated[bool, opt("--all", help="Answer every pending request.")] = False
|
|
894
|
+
collapsed: Annotated[
|
|
895
|
+
str | None, opt("--collapsed", metavar="ON|OFF", help="Collapse it in my dialog list.")
|
|
896
|
+
] = None
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
async def set_community(ctx: OpContext, req: CommunitySetReq) -> CommunityResult:
|
|
900
|
+
"""Reserved: `communities.togglePeerLink` is layer 229."""
|
|
901
|
+
_layer.community_gap(
|
|
902
|
+
"chat community set",
|
|
903
|
+
"communities.togglePeerLink / togglePeerLinkRequestApproval / "
|
|
904
|
+
"toggleAllPeerLinkRequestApproval / toggleCommunityCollapsedInDialogs",
|
|
905
|
+
)
|
|
906
|
+
raise AssertionError # pragma: no cover - community_gap always raises
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
SPEC_COMMUNITY_SET = OperationSpec(
|
|
910
|
+
id="chat.community.set",
|
|
911
|
+
request=CommunitySetReq,
|
|
912
|
+
response=CommunityResult,
|
|
913
|
+
impl=set_community,
|
|
914
|
+
summary="Add, hide, remove a chat in a community, or answer a link request",
|
|
915
|
+
description="Registered and refusing with NOT_SUPPORTED (exit 13): layer 229.",
|
|
916
|
+
mutating=True,
|
|
917
|
+
columns=("community_id", "chat_id", "state"),
|
|
918
|
+
example={"community_id": 0, "chat_id": -1001500, "state": "visible"},
|
|
919
|
+
example_args="chat community set @myhub @mygroup visible",
|
|
920
|
+
covers=("groups-channels-admin.community-manage-links",),
|
|
921
|
+
covers_partial=("groups-channels-admin.community-link-requests",),
|
|
922
|
+
coverage_note="Layer-229 surface; `chat community ban` owns the link-requests id.",
|
|
923
|
+
tags=frozenset({"layer-gap"}),
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
class CommunityBanReq(Request):
|
|
928
|
+
community: Annotated[PeerRef, arg(0, metavar="COMMUNITY", kind="peer", help="The community.")]
|
|
929
|
+
user: Annotated[PeerRef, arg(1, metavar="USER", kind="user", help="Who to ban.")]
|
|
930
|
+
off: Annotated[bool, opt("--off", help="Unban instead.")] = False
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
async def ban_community(ctx: OpContext, req: CommunityBanReq) -> CommunityResult:
|
|
934
|
+
"""Reserved: `communities.toggleParticipantBanned` is layer 229."""
|
|
935
|
+
_layer.community_gap("chat community ban", "communities.toggleParticipantBanned")
|
|
936
|
+
raise AssertionError # pragma: no cover - community_gap always raises
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
SPEC_COMMUNITY_BAN = OperationSpec(
|
|
940
|
+
id="chat.community.ban",
|
|
941
|
+
request=CommunityBanReq,
|
|
942
|
+
response=CommunityResult,
|
|
943
|
+
impl=ban_community,
|
|
944
|
+
summary="Ban a member from a whole community",
|
|
945
|
+
description="Registered and refusing with NOT_SUPPORTED (exit 13): layer 229.",
|
|
946
|
+
mutating=True,
|
|
947
|
+
destructive=True,
|
|
948
|
+
columns=("community_id", "user_id", "banned"),
|
|
949
|
+
example={"community_id": 0, "user_id": 4242, "banned": True},
|
|
950
|
+
example_args="chat community ban @myhub @spammer --yes",
|
|
951
|
+
covers=("groups-channels-admin.community-link-requests",),
|
|
952
|
+
tags=frozenset({"layer-gap"}),
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
# ---------------------------------------------------------------------------
|
|
957
|
+
# chat welcome — layer 229, registered and refusing
|
|
958
|
+
# ---------------------------------------------------------------------------
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
class WelcomeListReq(Request):
|
|
962
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
async def list_welcome(ctx: OpContext, req: WelcomeListReq) -> list[WelcomeMessage]:
|
|
966
|
+
"""Reserved: `ephemeral.getWelcomeMessages` is layer 229."""
|
|
967
|
+
_layer.welcome_gap("chat welcome list", "ephemeral.getWelcomeMessages")
|
|
968
|
+
raise AssertionError # pragma: no cover - welcome_gap always raises
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
SPEC_WELCOME_LIST = OperationSpec(
|
|
972
|
+
id="chat.welcome.list",
|
|
973
|
+
request=WelcomeListReq,
|
|
974
|
+
response=list[WelcomeMessage],
|
|
975
|
+
impl=list_welcome,
|
|
976
|
+
summary="List a group/channel's welcome messages",
|
|
977
|
+
description=(
|
|
978
|
+
"Registered and refusing with NOT_SUPPORTED (exit 13). "
|
|
979
|
+
"`chatFull.has_welcome_messages` advertises them, but the "
|
|
980
|
+
"`ephemeral.*` methods that read them are layer 229 and Telethon "
|
|
981
|
+
"1.44 speaks 227."
|
|
982
|
+
),
|
|
983
|
+
columns=("id", "text"),
|
|
984
|
+
example=[{"id": 1, "text": "Welcome!"}],
|
|
985
|
+
example_args="chat welcome list @mygroup",
|
|
986
|
+
covers=("messages-core.chat-welcome-messages",),
|
|
987
|
+
covers_partial=("groups-channels-admin.welcome-messages",),
|
|
988
|
+
coverage_note="Layer-229 surface; `chat welcome set` owns the writing id.",
|
|
989
|
+
tags=frozenset({"layer-gap"}),
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
class WelcomeSetReq(Request):
|
|
994
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
|
|
995
|
+
text: Annotated[str | None, arg(1, metavar="TEXT", required=False, help="The message.")] = None
|
|
996
|
+
id: Annotated[
|
|
997
|
+
int | None, opt("--id", metavar="ID", help="Edit this welcome message instead of adding.")
|
|
998
|
+
] = None
|
|
999
|
+
file: Annotated[
|
|
1000
|
+
list[str], opt("--file", metavar="PATH", kind="path", help="Attach media.")
|
|
1001
|
+
] = []
|
|
1002
|
+
parse: Annotated[
|
|
1003
|
+
str | None, opt("--parse", metavar="MODE", help="Markup of the text: md, html or none.")
|
|
1004
|
+
] = None
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
async def set_welcome(ctx: OpContext, req: WelcomeSetReq) -> WelcomeResult:
|
|
1008
|
+
"""Reserved: `ephemeral.sendMessage(welcome=true)` is layer 229."""
|
|
1009
|
+
_layer.welcome_gap("chat welcome set", "ephemeral.sendMessage / ephemeral.editMessage")
|
|
1010
|
+
raise AssertionError # pragma: no cover - welcome_gap always raises
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
SPEC_WELCOME_SET = OperationSpec(
|
|
1014
|
+
id="chat.welcome.set",
|
|
1015
|
+
request=WelcomeSetReq,
|
|
1016
|
+
response=WelcomeResult,
|
|
1017
|
+
impl=set_welcome,
|
|
1018
|
+
summary="Add or edit a welcome message",
|
|
1019
|
+
description="Registered and refusing with NOT_SUPPORTED (exit 13): layer 229.",
|
|
1020
|
+
mutating=True,
|
|
1021
|
+
columns=("chat_id", "id", "text"),
|
|
1022
|
+
example={"chat_id": -1001500, "id": 1, "text": "Welcome!"},
|
|
1023
|
+
example_args="chat welcome set @mygroup 'Welcome aboard'",
|
|
1024
|
+
covers=("groups-channels-admin.welcome-messages",),
|
|
1025
|
+
tags=frozenset({"layer-gap"}),
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
class WelcomeDeleteReq(Request):
|
|
1030
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Group or channel.")]
|
|
1031
|
+
id: Annotated[
|
|
1032
|
+
int | None, arg(1, metavar="ID", required=False, help="Which welcome message.")
|
|
1033
|
+
] = None
|
|
1034
|
+
everything: Annotated[bool, opt("--all", help="Delete every welcome message.")] = False
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
async def delete_welcome(ctx: OpContext, req: WelcomeDeleteReq) -> WelcomeResult:
|
|
1038
|
+
"""Reserved: `ephemeral.deleteWelcomeMessage` is layer 229."""
|
|
1039
|
+
_layer.welcome_gap(
|
|
1040
|
+
"chat welcome delete",
|
|
1041
|
+
"ephemeral.deleteWelcomeMessage / ephemeral.deleteAllWelcomeMessages",
|
|
1042
|
+
)
|
|
1043
|
+
raise AssertionError # pragma: no cover - welcome_gap always raises
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
SPEC_WELCOME_DELETE = OperationSpec(
|
|
1047
|
+
id="chat.welcome.delete",
|
|
1048
|
+
request=WelcomeDeleteReq,
|
|
1049
|
+
response=WelcomeResult,
|
|
1050
|
+
impl=delete_welcome,
|
|
1051
|
+
summary="Delete one or all welcome messages",
|
|
1052
|
+
description="Registered and refusing with NOT_SUPPORTED (exit 13): layer 229.",
|
|
1053
|
+
mutating=True,
|
|
1054
|
+
destructive=True,
|
|
1055
|
+
columns=("chat_id", "deleted"),
|
|
1056
|
+
example={"chat_id": -1001500, "deleted": 1},
|
|
1057
|
+
example_args="chat welcome delete @mygroup --all --yes",
|
|
1058
|
+
covers_partial=("groups-channels-admin.welcome-messages",),
|
|
1059
|
+
coverage_note="Layer-229 surface; `chat welcome set` owns the id.",
|
|
1060
|
+
tags=frozenset({"layer-gap"}),
|
|
1061
|
+
)
|