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_topic.py
ADDED
|
@@ -0,0 +1,905 @@
|
|
|
1
|
+
"""`chat topic *`: forum topics, which are message threads with a UI.
|
|
2
|
+
|
|
3
|
+
A topic is not a chat. Its id is the id of the `messageActionTopicCreate`
|
|
4
|
+
service message that started it, which is also what every `--topic` flag on
|
|
5
|
+
`message send/list` takes — so the id this module returns is directly usable
|
|
6
|
+
in the messages group and nothing has to be translated.
|
|
7
|
+
|
|
8
|
+
Three server rules shape the module and are worth stating once.
|
|
9
|
+
|
|
10
|
+
* **General is id 1.** It always exists, cannot be deleted, and is the one
|
|
11
|
+
topic whose `top_msg_id` must be *omitted* rather than sent as 1. It is
|
|
12
|
+
also the only topic that may be hidden.
|
|
13
|
+
* **Deleting a topic is a history drain.** `messages.deleteTopicHistory`
|
|
14
|
+
answers with `affectedHistory` and an offset to resume from, and the server
|
|
15
|
+
emits no dedicated update — other clients learn about it from the deleted
|
|
16
|
+
root message.
|
|
17
|
+
* **Muting a topic is a notification exception, not a topic property.** It
|
|
18
|
+
goes through `account.updateNotifySettings` with an
|
|
19
|
+
`inputNotifyForumTopic`, and lives in your account rather than in the chat.
|
|
20
|
+
|
|
21
|
+
Telethon is imported inside functions, never at module scope (§2.2).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import random
|
|
27
|
+
from typing import Annotated, Any
|
|
28
|
+
|
|
29
|
+
from tlgr.core.errors import EXIT_EMPTY, NotFoundError, UsageError
|
|
30
|
+
from tlgr.core.pagination import PageKind, build_page
|
|
31
|
+
from tlgr.core.timefmt import fmt_dt, parse_duration, to_unix
|
|
32
|
+
from tlgr.models.admin import Topic, TopicPinResult, TopicReadResult, TopicResult
|
|
33
|
+
from tlgr.models.base import Request
|
|
34
|
+
from tlgr.models.page import Page
|
|
35
|
+
from tlgr.models.peer import PeerRef
|
|
36
|
+
from tlgr.ops import _admin, _send
|
|
37
|
+
from tlgr.ops._params import arg, opt
|
|
38
|
+
from tlgr.ops._serialize import message_to_model
|
|
39
|
+
from tlgr.ops._spec import OpContext, OperationSpec
|
|
40
|
+
|
|
41
|
+
__all__ = [name for name in dir() if name.startswith("SPEC_")]
|
|
42
|
+
|
|
43
|
+
_EXAMPLE_TOPIC: dict[str, Any] = {
|
|
44
|
+
"id": 314,
|
|
45
|
+
"title": "Releases",
|
|
46
|
+
"closed": False,
|
|
47
|
+
"pinned": True,
|
|
48
|
+
"unread_count": 2,
|
|
49
|
+
"top_message": 918,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#: Telegram's own sentinel for "muted forever".
|
|
53
|
+
MUTE_FOREVER = 2**31 - 1
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _topic_model(raw: Any, *, chat_id: int) -> Topic:
|
|
57
|
+
"""`forumTopic` (or `forumTopicDeleted`) → `Topic`."""
|
|
58
|
+
if type(raw).__name__ == "ForumTopicDeleted":
|
|
59
|
+
return Topic(id=int(getattr(raw, "id", 0) or 0), chat_id=chat_id, deleted=True)
|
|
60
|
+
notify = getattr(raw, "notify_settings", None)
|
|
61
|
+
mute_until = getattr(notify, "mute_until", None)
|
|
62
|
+
from_id = getattr(raw, "from_id", None)
|
|
63
|
+
return Topic(
|
|
64
|
+
id=int(getattr(raw, "id", 0) or 0),
|
|
65
|
+
chat_id=chat_id,
|
|
66
|
+
title=str(getattr(raw, "title", "") or ""),
|
|
67
|
+
icon_emoji_id=getattr(raw, "icon_emoji_id", None),
|
|
68
|
+
icon_color=getattr(raw, "icon_color", None),
|
|
69
|
+
closed=bool(getattr(raw, "closed", False)),
|
|
70
|
+
pinned=bool(getattr(raw, "pinned", False)),
|
|
71
|
+
hidden=bool(getattr(raw, "hidden", False)),
|
|
72
|
+
my=bool(getattr(raw, "my", False)),
|
|
73
|
+
top_message=int(getattr(raw, "top_message", 0) or 0) or None,
|
|
74
|
+
unread_count=int(getattr(raw, "unread_count", 0) or 0),
|
|
75
|
+
unread_mentions_count=int(getattr(raw, "unread_mentions_count", 0) or 0),
|
|
76
|
+
unread_reactions_count=int(getattr(raw, "unread_reactions_count", 0) or 0),
|
|
77
|
+
from_id=abs(_send.peer_id_of(from_id)) if from_id is not None else None,
|
|
78
|
+
muted=bool(mute_until) if mute_until is not None else None,
|
|
79
|
+
date=fmt_dt(getattr(raw, "date", None)),
|
|
80
|
+
date_unix=to_unix(getattr(raw, "date", None)),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def _forum_peer(ctx: OpContext, ref: PeerRef) -> Any:
|
|
85
|
+
"""The peer, refusing a chat that is not a forum with a readable reason."""
|
|
86
|
+
peer = await _send.resolve(ctx, ref)
|
|
87
|
+
if not _admin.is_channel(peer):
|
|
88
|
+
raise UsageError(
|
|
89
|
+
"topics only exist in forum supergroups; turn them on with "
|
|
90
|
+
"`tlgr chat setting set <chat> --forum on`",
|
|
91
|
+
field="chat",
|
|
92
|
+
)
|
|
93
|
+
return peer
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _emoji_id(value: str | None) -> int | None:
|
|
97
|
+
if value is None:
|
|
98
|
+
return None
|
|
99
|
+
text = str(value).strip()
|
|
100
|
+
if text in ("", "off", "none", "0"):
|
|
101
|
+
return 0
|
|
102
|
+
try:
|
|
103
|
+
return int(text)
|
|
104
|
+
except ValueError as exc:
|
|
105
|
+
raise UsageError(
|
|
106
|
+
"--icon-emoji takes a custom-emoji document id", field="icon_emoji"
|
|
107
|
+
) from exc
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def _topic_link(ctx: OpContext, peer: Any, topic_id: int) -> str | None:
|
|
111
|
+
"""`t.me/<username>/<topic_id>` for a public forum, else None."""
|
|
112
|
+
from telethon.tl.functions import channels as fn
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
reply = await _admin.client(ctx)(
|
|
116
|
+
fn.ExportMessageLinkRequest(
|
|
117
|
+
channel=_admin.input_channel(peer), id=topic_id, thread=True
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
except Exception:
|
|
121
|
+
return None
|
|
122
|
+
return str(getattr(reply, "link", "") or "") or None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# chat topic list / get
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TopicListReq(Request):
|
|
131
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Forum supergroup.")]
|
|
132
|
+
search: Annotated[str, opt("--search", "-s", metavar="TEXT", help="Title query.")] = ""
|
|
133
|
+
closed: Annotated[bool, opt("--closed", help="Only closed topics.")] = False
|
|
134
|
+
hidden: Annotated[bool, opt("--hidden", help="Only hidden topics.")] = False
|
|
135
|
+
pinned: Annotated[bool, opt("--pinned", help="Only pinned topics.")] = False
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def list_topics(ctx: OpContext, req: TopicListReq) -> Page[Topic]:
|
|
139
|
+
"""A forum's topics, newest activity first. General (id 1) is always there."""
|
|
140
|
+
from datetime import datetime, timezone
|
|
141
|
+
|
|
142
|
+
from telethon.tl.functions import messages as fn
|
|
143
|
+
|
|
144
|
+
limit, state = _admin.window(ctx, "chat.topic.list", PageKind.PARTICIPANTS)
|
|
145
|
+
peer = await _forum_peer(ctx, req.chat)
|
|
146
|
+
chat_id = _send.peer_id_of(peer)
|
|
147
|
+
offset_date = state.get("date")
|
|
148
|
+
reply = await _admin.client(ctx)(
|
|
149
|
+
fn.GetForumTopicsRequest(
|
|
150
|
+
peer=peer,
|
|
151
|
+
q=req.search or None,
|
|
152
|
+
offset_date=datetime.fromtimestamp(offset_date, tz=timezone.utc)
|
|
153
|
+
if offset_date
|
|
154
|
+
else None,
|
|
155
|
+
offset_id=int(state.get("id", 0) or 0),
|
|
156
|
+
offset_topic=int(state.get("topic", 0) or 0),
|
|
157
|
+
limit=limit,
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
rows = [_topic_model(row, chat_id=chat_id) for row in (getattr(reply, "topics", None) or [])]
|
|
161
|
+
# The three filters are client-side over the page: the API has no flag
|
|
162
|
+
# for any of them. The cursor is built from the last row the *server*
|
|
163
|
+
# sent, not the last row that survived the filter — otherwise a page
|
|
164
|
+
# whose tail was filtered out would resume in the wrong place.
|
|
165
|
+
items = list(rows)
|
|
166
|
+
if req.closed:
|
|
167
|
+
items = [t for t in items if t.closed]
|
|
168
|
+
if req.hidden:
|
|
169
|
+
items = [t for t in items if t.hidden]
|
|
170
|
+
if req.pinned:
|
|
171
|
+
items = [t for t in items if t.pinned]
|
|
172
|
+
next_state: dict[str, Any] = {}
|
|
173
|
+
if rows:
|
|
174
|
+
last = rows[-1]
|
|
175
|
+
next_state = {
|
|
176
|
+
"date": last.date_unix or 0,
|
|
177
|
+
"id": last.top_message or 0,
|
|
178
|
+
"topic": last.id,
|
|
179
|
+
}
|
|
180
|
+
return build_page(
|
|
181
|
+
items,
|
|
182
|
+
op="chat.topic.list",
|
|
183
|
+
kind=PageKind.PARTICIPANTS,
|
|
184
|
+
state=next_state,
|
|
185
|
+
account=ctx.account,
|
|
186
|
+
has_more=len(rows) >= limit,
|
|
187
|
+
total=int(getattr(reply, "count", 0) or 0),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
SPEC_TOPIC_LIST = OperationSpec(
|
|
192
|
+
id="chat.topic.list",
|
|
193
|
+
request=TopicListReq,
|
|
194
|
+
response=Page[Topic],
|
|
195
|
+
impl=list_topics,
|
|
196
|
+
summary="List or search a forum's topics",
|
|
197
|
+
description=(
|
|
198
|
+
"The cursor packs the `(offset_date, offset_id, offset_topic)` "
|
|
199
|
+
"triple of the last row. `--closed`, `--hidden` and `--pinned` are "
|
|
200
|
+
"client-side filters over the page, because the API offers no flag "
|
|
201
|
+
"for any of them."
|
|
202
|
+
),
|
|
203
|
+
paginated=PageKind.PARTICIPANTS,
|
|
204
|
+
columns=("id", "title", "unread_count", "closed", "pinned"),
|
|
205
|
+
headers=("ID", "Title", "Unread", "Closed", "Pinned"),
|
|
206
|
+
example={"items": [_EXAMPLE_TOPIC], "has_more": False, "total": 1},
|
|
207
|
+
example_args="chat topic list @myforum",
|
|
208
|
+
covers=("groups-channels-admin.topic-list",),
|
|
209
|
+
covers_partial=("groups-channels-admin.topic-unread-counters",),
|
|
210
|
+
coverage_note="The counters are on every row; `chat topic read` clears them and owns the id.",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class TopicGetReq(Request):
|
|
215
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Forum supergroup.")]
|
|
216
|
+
topic: Annotated[
|
|
217
|
+
list[int], arg(1, metavar="TOPIC", kind="msg_id", variadic=True, help="Topic ids.")
|
|
218
|
+
] = []
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def get_topics(ctx: OpContext, req: TopicGetReq) -> list[Topic]:
|
|
222
|
+
"""One or more topics by id, with their public link when there is one.
|
|
223
|
+
|
|
224
|
+
A `forumTopicDeleted` row comes back as `{id, deleted: true}` — that is
|
|
225
|
+
the only signal the API gives that a topic was removed, so dropping it
|
|
226
|
+
would turn "deleted" into "never existed".
|
|
227
|
+
"""
|
|
228
|
+
from telethon.tl.functions import messages as fn
|
|
229
|
+
|
|
230
|
+
if not req.topic:
|
|
231
|
+
raise UsageError("name at least one topic id", field="topic")
|
|
232
|
+
peer = await _forum_peer(ctx, req.chat)
|
|
233
|
+
chat_id = _send.peer_id_of(peer)
|
|
234
|
+
reply = await _admin.client(ctx)(
|
|
235
|
+
fn.GetForumTopicsByIDRequest(peer=peer, topics=[int(t) for t in req.topic])
|
|
236
|
+
)
|
|
237
|
+
items = [_topic_model(row, chat_id=chat_id) for row in (getattr(reply, "topics", None) or [])]
|
|
238
|
+
if not items:
|
|
239
|
+
raise NotFoundError("no such topic in this forum")
|
|
240
|
+
for item in items:
|
|
241
|
+
if not item.deleted:
|
|
242
|
+
item.link = await _topic_link(ctx, peer, item.id)
|
|
243
|
+
return items
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
SPEC_TOPIC_GET = OperationSpec(
|
|
247
|
+
id="chat.topic.get",
|
|
248
|
+
request=TopicGetReq,
|
|
249
|
+
response=list[Topic],
|
|
250
|
+
impl=get_topics,
|
|
251
|
+
summary="Get one or more topics by id",
|
|
252
|
+
description=(
|
|
253
|
+
"`forumTopicDeleted` rows are reported as `{id, deleted: true}`, "
|
|
254
|
+
"which is the only signal the API gives that a topic was removed."
|
|
255
|
+
),
|
|
256
|
+
columns=("id", "title", "closed", "link"),
|
|
257
|
+
example=[_EXAMPLE_TOPIC],
|
|
258
|
+
example_args="chat topic get @myforum 314",
|
|
259
|
+
empty_exit=EXIT_EMPTY,
|
|
260
|
+
covers=("groups-channels-admin.topic-get", "groups-channels-admin.topic-link"),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
# chat topic create / edit / close / reopen / hide / unhide / delete
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class TopicCreateReq(Request):
|
|
270
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Forum supergroup.")]
|
|
271
|
+
title: Annotated[str, arg(1, metavar="TITLE", help="Topic title.")]
|
|
272
|
+
icon_emoji: Annotated[
|
|
273
|
+
str | None, opt("--icon-emoji", metavar="ID", help="Custom-emoji icon (document id).")
|
|
274
|
+
] = None
|
|
275
|
+
icon_color: Annotated[
|
|
276
|
+
int | None, opt("--icon-color", metavar="RGB", help="Icon colour; immutable afterwards.")
|
|
277
|
+
] = None
|
|
278
|
+
send_as: Annotated[
|
|
279
|
+
PeerRef | None,
|
|
280
|
+
opt(
|
|
281
|
+
"--send-as", metavar="PEER", kind="peer", help="Post the creation notice as this peer."
|
|
282
|
+
),
|
|
283
|
+
] = None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def create_topic(ctx: OpContext, req: TopicCreateReq) -> TopicResult:
|
|
287
|
+
"""Create a topic. The id you get back is the one `--topic` takes."""
|
|
288
|
+
from telethon.tl.functions import messages as fn
|
|
289
|
+
|
|
290
|
+
peer = await _forum_peer(ctx, req.chat)
|
|
291
|
+
send_as = await _send.resolve(ctx, req.send_as) if req.send_as is not None else None
|
|
292
|
+
updates = await _admin.client(ctx)(
|
|
293
|
+
fn.CreateForumTopicRequest(
|
|
294
|
+
peer=peer,
|
|
295
|
+
title=req.title,
|
|
296
|
+
icon_color=req.icon_color,
|
|
297
|
+
icon_emoji_id=_emoji_id(req.icon_emoji) or None,
|
|
298
|
+
random_id=random.getrandbits(63),
|
|
299
|
+
send_as=send_as,
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
topic_id = 0
|
|
303
|
+
for update in getattr(updates, "updates", None) or []:
|
|
304
|
+
message = getattr(update, "message", None)
|
|
305
|
+
if message is not None and getattr(message, "id", None):
|
|
306
|
+
topic_id = int(message.id)
|
|
307
|
+
break
|
|
308
|
+
chat_id = _send.peer_id_of(peer)
|
|
309
|
+
ctx.emit("chat_topic_created", {"chat_id": chat_id, "topic_id": topic_id})
|
|
310
|
+
return TopicResult(chat_id=chat_id, topic_id=topic_id, title=req.title)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
SPEC_TOPIC_CREATE = OperationSpec(
|
|
314
|
+
id="chat.topic.create",
|
|
315
|
+
request=TopicCreateReq,
|
|
316
|
+
response=TopicResult,
|
|
317
|
+
impl=create_topic,
|
|
318
|
+
summary="Create a topic",
|
|
319
|
+
description=(
|
|
320
|
+
"The returned id is the id of the `messageActionTopicCreate` service "
|
|
321
|
+
"message, which is exactly what every `--topic` flag takes. "
|
|
322
|
+
"Non-Premium accounts may only use icons from "
|
|
323
|
+
"`inputStickerSetEmojiDefaultTopicIcons`."
|
|
324
|
+
),
|
|
325
|
+
mutating=True,
|
|
326
|
+
columns=("chat_id", "topic_id", "title"),
|
|
327
|
+
example={"chat_id": -1001500, "topic_id": 314, "title": "Releases"},
|
|
328
|
+
example_args="chat topic create @myforum Releases",
|
|
329
|
+
covers=("groups-channels-admin.topic-create",),
|
|
330
|
+
tags=frozenset({"visible-to-others"}),
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class TopicEditReq(Request):
|
|
335
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Forum supergroup.")]
|
|
336
|
+
topic: Annotated[int, arg(1, metavar="TOPIC", kind="msg_id", help="Topic id.")]
|
|
337
|
+
title: Annotated[str | None, opt("--title", metavar="TEXT", help="New title.")] = None
|
|
338
|
+
icon_emoji: Annotated[
|
|
339
|
+
str | None, opt("--icon-emoji", metavar="ID", help="New custom-emoji icon.")
|
|
340
|
+
] = None
|
|
341
|
+
no_icon: Annotated[bool, opt("--no-icon", help="Drop the custom emoji icon.")] = False
|
|
342
|
+
closed: Annotated[bool | None, opt("--closed", help="Close or reopen in the same call.")] = None
|
|
343
|
+
hidden: Annotated[bool | None, opt("--hidden", help="Hide or show (General only).")] = None
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
async def _edit_topic(
|
|
347
|
+
ctx: OpContext,
|
|
348
|
+
peer: Any,
|
|
349
|
+
topic_id: int,
|
|
350
|
+
*,
|
|
351
|
+
title: str | None = None,
|
|
352
|
+
icon_emoji_id: int | None = None,
|
|
353
|
+
closed: bool | None = None,
|
|
354
|
+
hidden: bool | None = None,
|
|
355
|
+
) -> TopicResult:
|
|
356
|
+
from telethon.tl.functions import messages as fn
|
|
357
|
+
|
|
358
|
+
await _admin.client(ctx)(
|
|
359
|
+
fn.EditForumTopicRequest(
|
|
360
|
+
peer=peer,
|
|
361
|
+
topic_id=topic_id,
|
|
362
|
+
title=title,
|
|
363
|
+
icon_emoji_id=icon_emoji_id,
|
|
364
|
+
closed=closed,
|
|
365
|
+
hidden=hidden,
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
changed = [
|
|
369
|
+
name
|
|
370
|
+
for name, value in (
|
|
371
|
+
("title", title),
|
|
372
|
+
("icon_emoji", icon_emoji_id),
|
|
373
|
+
("closed", closed),
|
|
374
|
+
("hidden", hidden),
|
|
375
|
+
)
|
|
376
|
+
if value is not None
|
|
377
|
+
]
|
|
378
|
+
return TopicResult(
|
|
379
|
+
chat_id=_send.peer_id_of(peer),
|
|
380
|
+
topic_id=topic_id,
|
|
381
|
+
title=title,
|
|
382
|
+
icon_emoji_id=icon_emoji_id,
|
|
383
|
+
closed=closed,
|
|
384
|
+
hidden=hidden,
|
|
385
|
+
changed=changed,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
async def edit_topic(ctx: OpContext, req: TopicEditReq) -> TopicResult:
|
|
390
|
+
"""Rename a topic, re-icon it, or close/hide it in the same call."""
|
|
391
|
+
peer = await _forum_peer(ctx, req.chat)
|
|
392
|
+
if (
|
|
393
|
+
req.title is None
|
|
394
|
+
and req.icon_emoji is None
|
|
395
|
+
and not req.no_icon
|
|
396
|
+
and req.closed is None
|
|
397
|
+
and req.hidden is None
|
|
398
|
+
):
|
|
399
|
+
raise UsageError("nothing to change", field="title")
|
|
400
|
+
if req.topic == _admin.GENERAL_TOPIC and (req.icon_emoji or req.no_icon):
|
|
401
|
+
raise UsageError("the General topic has no icon of its own", field="icon_emoji")
|
|
402
|
+
icon = 0 if req.no_icon else _emoji_id(req.icon_emoji)
|
|
403
|
+
return await _edit_topic(
|
|
404
|
+
ctx,
|
|
405
|
+
peer,
|
|
406
|
+
req.topic,
|
|
407
|
+
title=req.title,
|
|
408
|
+
icon_emoji_id=icon,
|
|
409
|
+
closed=req.closed,
|
|
410
|
+
hidden=req.hidden,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
SPEC_TOPIC_EDIT = OperationSpec(
|
|
415
|
+
id="chat.topic.edit",
|
|
416
|
+
request=TopicEditReq,
|
|
417
|
+
response=TopicResult,
|
|
418
|
+
impl=edit_topic,
|
|
419
|
+
summary="Rename a topic or change its icon",
|
|
420
|
+
description=(
|
|
421
|
+
"`icon_color` cannot be changed after creation — the API has no "
|
|
422
|
+
"field for it — and the General topic accepts only `--title` and "
|
|
423
|
+
"`--hidden`."
|
|
424
|
+
),
|
|
425
|
+
mutating=True,
|
|
426
|
+
columns=("chat_id", "topic_id", "title"),
|
|
427
|
+
example={"chat_id": -1001500, "topic_id": 314, "title": "Releases"},
|
|
428
|
+
example_args="chat topic edit @myforum 314 --title 'Release notes'",
|
|
429
|
+
covers=("groups-channels-admin.topic-edit",),
|
|
430
|
+
covers_partial=(
|
|
431
|
+
"groups-channels-admin.topic-close-reopen",
|
|
432
|
+
"groups-channels-admin.topic-hide-general",
|
|
433
|
+
),
|
|
434
|
+
coverage_note=(
|
|
435
|
+
"`--closed`/`--hidden` do it in one call; `chat topic reopen` and "
|
|
436
|
+
"`chat topic unhide` own the ids."
|
|
437
|
+
),
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
class TopicOneReq(Request):
|
|
442
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Forum supergroup.")]
|
|
443
|
+
topic: Annotated[int, arg(1, metavar="TOPIC", kind="msg_id", help="Topic id.")]
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
async def close_topic(ctx: OpContext, req: TopicOneReq) -> TopicResult:
|
|
447
|
+
"""Close a topic: only admins may post in it afterwards."""
|
|
448
|
+
peer = await _forum_peer(ctx, req.chat)
|
|
449
|
+
return await _edit_topic(ctx, peer, req.topic, closed=True)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
SPEC_TOPIC_CLOSE = OperationSpec(
|
|
453
|
+
id="chat.topic.close",
|
|
454
|
+
request=TopicOneReq,
|
|
455
|
+
response=TopicResult,
|
|
456
|
+
impl=close_topic,
|
|
457
|
+
summary="Close a topic",
|
|
458
|
+
mutating=True,
|
|
459
|
+
columns=("chat_id", "topic_id", "closed"),
|
|
460
|
+
example={"chat_id": -1001500, "topic_id": 314, "closed": True},
|
|
461
|
+
example_args="chat topic close @myforum 314",
|
|
462
|
+
covers_partial=("groups-channels-admin.topic-close-reopen",),
|
|
463
|
+
coverage_note="The closing half; `chat topic reopen` owns the id.",
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
async def reopen_topic(ctx: OpContext, req: TopicOneReq) -> TopicResult:
|
|
468
|
+
"""Reopen a closed topic."""
|
|
469
|
+
peer = await _forum_peer(ctx, req.chat)
|
|
470
|
+
return await _edit_topic(ctx, peer, req.topic, closed=False)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
SPEC_TOPIC_REOPEN = OperationSpec(
|
|
474
|
+
id="chat.topic.reopen",
|
|
475
|
+
request=TopicOneReq,
|
|
476
|
+
response=TopicResult,
|
|
477
|
+
impl=reopen_topic,
|
|
478
|
+
summary="Reopen a closed topic",
|
|
479
|
+
mutating=True,
|
|
480
|
+
columns=("chat_id", "topic_id", "closed"),
|
|
481
|
+
example={"chat_id": -1001500, "topic_id": 314, "closed": False},
|
|
482
|
+
example_args="chat topic reopen @myforum 314",
|
|
483
|
+
covers=("groups-channels-admin.topic-close-reopen",),
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
class TopicGeneralReq(Request):
|
|
488
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Forum supergroup.")]
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
async def hide_topic(ctx: OpContext, req: TopicGeneralReq) -> TopicResult:
|
|
492
|
+
"""Hide the General topic. The server refuses any other id."""
|
|
493
|
+
peer = await _forum_peer(ctx, req.chat)
|
|
494
|
+
return await _edit_topic(ctx, peer, _admin.GENERAL_TOPIC, hidden=True)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
SPEC_TOPIC_HIDE = OperationSpec(
|
|
498
|
+
id="chat.topic.hide",
|
|
499
|
+
request=TopicGeneralReq,
|
|
500
|
+
response=TopicResult,
|
|
501
|
+
impl=hide_topic,
|
|
502
|
+
summary="Hide the General topic",
|
|
503
|
+
description="Only General (id 1) may be hidden; the server refuses any other id.",
|
|
504
|
+
mutating=True,
|
|
505
|
+
columns=("chat_id", "topic_id", "hidden"),
|
|
506
|
+
example={"chat_id": -1001500, "topic_id": 1, "hidden": True},
|
|
507
|
+
example_args="chat topic hide @myforum",
|
|
508
|
+
covers_partial=("groups-channels-admin.topic-hide-general",),
|
|
509
|
+
coverage_note="The hiding half; `chat topic unhide` owns the id.",
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
async def unhide_topic(ctx: OpContext, req: TopicGeneralReq) -> TopicResult:
|
|
514
|
+
"""Show the General topic again."""
|
|
515
|
+
peer = await _forum_peer(ctx, req.chat)
|
|
516
|
+
return await _edit_topic(ctx, peer, _admin.GENERAL_TOPIC, hidden=False)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
SPEC_TOPIC_UNHIDE = OperationSpec(
|
|
520
|
+
id="chat.topic.unhide",
|
|
521
|
+
request=TopicGeneralReq,
|
|
522
|
+
response=TopicResult,
|
|
523
|
+
impl=unhide_topic,
|
|
524
|
+
summary="Show the General topic again",
|
|
525
|
+
mutating=True,
|
|
526
|
+
columns=("chat_id", "topic_id", "hidden"),
|
|
527
|
+
example={"chat_id": -1001500, "topic_id": 1, "hidden": False},
|
|
528
|
+
example_args="chat topic unhide @myforum",
|
|
529
|
+
covers=("groups-channels-admin.topic-hide-general",),
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
async def delete_topic(ctx: OpContext, req: TopicOneReq) -> TopicResult:
|
|
534
|
+
"""Delete a topic and every message in it, draining the offset loop."""
|
|
535
|
+
from telethon.tl.functions import messages as fn
|
|
536
|
+
|
|
537
|
+
peer = await _forum_peer(ctx, req.chat)
|
|
538
|
+
if req.topic == _admin.GENERAL_TOPIC:
|
|
539
|
+
raise UsageError(
|
|
540
|
+
"the General topic cannot be deleted; `chat topic hide` removes it from view",
|
|
541
|
+
field="topic",
|
|
542
|
+
)
|
|
543
|
+
deleted = await _admin.affected_loop(
|
|
544
|
+
ctx, lambda _offset: fn.DeleteTopicHistoryRequest(peer=peer, top_msg_id=req.topic)
|
|
545
|
+
)
|
|
546
|
+
chat_id = _send.peer_id_of(peer)
|
|
547
|
+
ctx.emit("chat_topic_deleted", {"chat_id": chat_id, "topic_id": req.topic})
|
|
548
|
+
return TopicResult(
|
|
549
|
+
chat_id=chat_id, topic_id=req.topic, deleted=True, changed=[f"messages:{deleted}"]
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
SPEC_TOPIC_DELETE = OperationSpec(
|
|
554
|
+
id="chat.topic.delete",
|
|
555
|
+
request=TopicOneReq,
|
|
556
|
+
response=TopicResult,
|
|
557
|
+
impl=delete_topic,
|
|
558
|
+
summary="Delete a topic and all its messages",
|
|
559
|
+
description=(
|
|
560
|
+
"Drains `messages.affectedHistory` until the offset is 0. No "
|
|
561
|
+
"dedicated update is emitted; other clients learn about it from the "
|
|
562
|
+
"deleted root message."
|
|
563
|
+
),
|
|
564
|
+
mutating=True,
|
|
565
|
+
destructive=True,
|
|
566
|
+
rate_class="bulk",
|
|
567
|
+
timeout_s=300,
|
|
568
|
+
columns=("chat_id", "topic_id", "deleted"),
|
|
569
|
+
example={"chat_id": -1001500, "topic_id": 314, "deleted": True},
|
|
570
|
+
example_args="chat topic delete @myforum 314 --yes",
|
|
571
|
+
covers=("groups-channels-admin.topic-delete",),
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
# ---------------------------------------------------------------------------
|
|
576
|
+
# chat topic pin / unpin
|
|
577
|
+
# ---------------------------------------------------------------------------
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
class TopicPinReq(Request):
|
|
581
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Forum supergroup.")]
|
|
582
|
+
topic: Annotated[
|
|
583
|
+
list[int], arg(1, metavar="TOPIC", kind="msg_id", variadic=True, help="Topic ids.")
|
|
584
|
+
] = []
|
|
585
|
+
reorder: Annotated[
|
|
586
|
+
bool, opt("--reorder", help="Treat the ids as the complete pinned order.")
|
|
587
|
+
] = False
|
|
588
|
+
force: Annotated[
|
|
589
|
+
bool, opt("--force", help="With --reorder: unpin topics missing from the list.")
|
|
590
|
+
] = False
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
async def pin_topics(ctx: OpContext, req: TopicPinReq) -> TopicPinResult:
|
|
594
|
+
"""Pin topics, or (with `--reorder`) declare the whole pinned order."""
|
|
595
|
+
from telethon.tl.functions import messages as fn
|
|
596
|
+
|
|
597
|
+
peer = await _forum_peer(ctx, req.chat)
|
|
598
|
+
if not req.topic:
|
|
599
|
+
raise UsageError("name at least one topic id", field="topic")
|
|
600
|
+
handle = _admin.client(ctx)
|
|
601
|
+
ids = [int(t) for t in req.topic]
|
|
602
|
+
if req.reorder:
|
|
603
|
+
await handle(
|
|
604
|
+
fn.ReorderPinnedForumTopicsRequest(peer=peer, order=ids, force=req.force or None)
|
|
605
|
+
)
|
|
606
|
+
else:
|
|
607
|
+
for topic_id in ids:
|
|
608
|
+
await handle(
|
|
609
|
+
fn.UpdatePinnedForumTopicRequest(peer=peer, topic_id=topic_id, pinned=True)
|
|
610
|
+
)
|
|
611
|
+
return TopicPinResult(chat_id=_send.peer_id_of(peer), pinned=ids)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
SPEC_TOPIC_PIN = OperationSpec(
|
|
615
|
+
id="chat.topic.pin",
|
|
616
|
+
request=TopicPinReq,
|
|
617
|
+
response=TopicPinResult,
|
|
618
|
+
impl=pin_topics,
|
|
619
|
+
summary="Pin topics (pass several ids to set the pinned order)",
|
|
620
|
+
description=(
|
|
621
|
+
"`--reorder` sends the ids as the complete order; `--force` also "
|
|
622
|
+
"unpins anything missing from the list. At most `topics_pinned_limit` "
|
|
623
|
+
"topics can be pinned."
|
|
624
|
+
),
|
|
625
|
+
mutating=True,
|
|
626
|
+
columns=("chat_id", "pinned"),
|
|
627
|
+
example={"chat_id": -1001500, "pinned": [314]},
|
|
628
|
+
example_args="chat topic pin @myforum 314",
|
|
629
|
+
covers=("groups-channels-admin.topic-reorder-pinned",),
|
|
630
|
+
covers_partial=("groups-channels-admin.topic-pin",),
|
|
631
|
+
coverage_note="Pinning is here; `chat topic unpin` owns the id.",
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
class TopicUnpinReq(Request):
|
|
636
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Forum supergroup.")]
|
|
637
|
+
topic: Annotated[
|
|
638
|
+
list[int],
|
|
639
|
+
arg(1, metavar="TOPIC", kind="msg_id", variadic=True, required=False, help="Topic ids."),
|
|
640
|
+
] = []
|
|
641
|
+
everything: Annotated[bool, opt("--all", help="Unpin every pinned topic.")] = False
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
async def unpin_topics(ctx: OpContext, req: TopicUnpinReq) -> TopicPinResult:
|
|
645
|
+
"""Unpin topics, or clear the pinned order entirely."""
|
|
646
|
+
from telethon.tl.functions import messages as fn
|
|
647
|
+
|
|
648
|
+
peer = await _forum_peer(ctx, req.chat)
|
|
649
|
+
handle = _admin.client(ctx)
|
|
650
|
+
if req.everything:
|
|
651
|
+
await handle(fn.ReorderPinnedForumTopicsRequest(peer=peer, order=[], force=True))
|
|
652
|
+
return TopicPinResult(chat_id=_send.peer_id_of(peer), unpinned=[], pinned=[])
|
|
653
|
+
if not req.topic:
|
|
654
|
+
raise UsageError("name a topic id, or pass --all", field="topic")
|
|
655
|
+
ids = [int(t) for t in req.topic]
|
|
656
|
+
for topic_id in ids:
|
|
657
|
+
await handle(fn.UpdatePinnedForumTopicRequest(peer=peer, topic_id=topic_id, pinned=False))
|
|
658
|
+
return TopicPinResult(chat_id=_send.peer_id_of(peer), unpinned=ids)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
SPEC_TOPIC_UNPIN = OperationSpec(
|
|
662
|
+
id="chat.topic.unpin",
|
|
663
|
+
request=TopicUnpinReq,
|
|
664
|
+
response=TopicPinResult,
|
|
665
|
+
impl=unpin_topics,
|
|
666
|
+
summary="Unpin a topic",
|
|
667
|
+
mutating=True,
|
|
668
|
+
columns=("chat_id", "unpinned"),
|
|
669
|
+
example={"chat_id": -1001500, "unpinned": [314]},
|
|
670
|
+
example_args="chat topic unpin @myforum 314",
|
|
671
|
+
covers=("groups-channels-admin.topic-pin",),
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
# ---------------------------------------------------------------------------
|
|
676
|
+
# chat topic mute / unmute
|
|
677
|
+
# ---------------------------------------------------------------------------
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
class TopicMuteReq(Request):
|
|
681
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Forum supergroup.")]
|
|
682
|
+
topic: Annotated[int, arg(1, metavar="TOPIC", kind="msg_id", help="Topic id.")]
|
|
683
|
+
duration: Annotated[
|
|
684
|
+
str | None,
|
|
685
|
+
arg(2, metavar="DURATION", required=False, help="How long; omit to mute forever."),
|
|
686
|
+
] = None
|
|
687
|
+
silent: Annotated[
|
|
688
|
+
bool | None, opt("--silent", help="Deliver without a sound instead of muting.")
|
|
689
|
+
] = None
|
|
690
|
+
previews: Annotated[
|
|
691
|
+
bool | None, opt("--previews", help="Show message text in notifications.")
|
|
692
|
+
] = None
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
async def _notify_topic(
|
|
696
|
+
ctx: OpContext,
|
|
697
|
+
peer: Any,
|
|
698
|
+
topic_id: int,
|
|
699
|
+
*,
|
|
700
|
+
mute_until: int | None,
|
|
701
|
+
silent: bool | None = None,
|
|
702
|
+
previews: bool | None = None,
|
|
703
|
+
) -> TopicResult:
|
|
704
|
+
"""One `inputNotifyForumTopic` exception, written absolutely.
|
|
705
|
+
|
|
706
|
+
`mute_until` is an absolute wall-clock timestamp, computed here. v1
|
|
707
|
+
computed a timed mute from the event loop's clock and every one of them
|
|
708
|
+
resolved to 1970 (COR-01); a topic mute must not repeat that.
|
|
709
|
+
"""
|
|
710
|
+
from datetime import datetime, timezone
|
|
711
|
+
|
|
712
|
+
from telethon.tl import types
|
|
713
|
+
from telethon.tl.functions import account as fn
|
|
714
|
+
|
|
715
|
+
stamp = (
|
|
716
|
+
datetime.fromtimestamp(mute_until, tz=timezone.utc) if mute_until not in (None, 0) else None
|
|
717
|
+
)
|
|
718
|
+
await _admin.client(ctx)(
|
|
719
|
+
fn.UpdateNotifySettingsRequest(
|
|
720
|
+
peer=types.InputNotifyForumTopic(peer=peer, top_msg_id=topic_id),
|
|
721
|
+
settings=types.InputPeerNotifySettings(
|
|
722
|
+
mute_until=stamp,
|
|
723
|
+
silent=silent,
|
|
724
|
+
show_previews=previews,
|
|
725
|
+
),
|
|
726
|
+
)
|
|
727
|
+
)
|
|
728
|
+
return TopicResult(
|
|
729
|
+
chat_id=_send.peer_id_of(peer),
|
|
730
|
+
topic_id=topic_id,
|
|
731
|
+
mute_until=fmt_dt(stamp),
|
|
732
|
+
silent=silent,
|
|
733
|
+
previews=previews,
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
async def mute_topic(ctx: OpContext, req: TopicMuteReq) -> TopicResult:
|
|
738
|
+
"""Mute one topic. Omitting the duration means forever."""
|
|
739
|
+
import time
|
|
740
|
+
|
|
741
|
+
peer = await _forum_peer(ctx, req.chat)
|
|
742
|
+
until: int | None = MUTE_FOREVER
|
|
743
|
+
if req.duration:
|
|
744
|
+
seconds = parse_duration(req.duration)
|
|
745
|
+
if seconds is None:
|
|
746
|
+
raise UsageError(f"{req.duration!r} is not a duration", field="duration")
|
|
747
|
+
until = int(time.time()) + int(seconds)
|
|
748
|
+
if req.silent is not None and req.duration is None:
|
|
749
|
+
until = None
|
|
750
|
+
return await _notify_topic(
|
|
751
|
+
ctx, peer, req.topic, mute_until=until, silent=req.silent, previews=req.previews
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
SPEC_TOPIC_MUTE = OperationSpec(
|
|
756
|
+
id="chat.topic.mute",
|
|
757
|
+
request=TopicMuteReq,
|
|
758
|
+
response=TopicResult,
|
|
759
|
+
impl=mute_topic,
|
|
760
|
+
summary="Mute a topic",
|
|
761
|
+
description=(
|
|
762
|
+
"`mute_until` is an absolute timestamp computed from the wall clock. "
|
|
763
|
+
"`--silent on` without a duration switches to silent delivery "
|
|
764
|
+
"instead of muting."
|
|
765
|
+
),
|
|
766
|
+
mutating=True,
|
|
767
|
+
columns=("chat_id", "topic_id", "mute_until"),
|
|
768
|
+
example={"chat_id": -1001500, "topic_id": 314, "mute_until": "2038-01-19T03:14:07Z"},
|
|
769
|
+
example_args="chat topic mute @myforum 314 8h",
|
|
770
|
+
covers_partial=("groups-channels-admin.topic-notify-settings",),
|
|
771
|
+
coverage_note="Muting half; `chat topic unmute` owns the id.",
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
async def unmute_topic(ctx: OpContext, req: TopicOneReq) -> TopicResult:
|
|
776
|
+
"""Unmute a topic (mute_until = 0)."""
|
|
777
|
+
peer = await _forum_peer(ctx, req.chat)
|
|
778
|
+
return await _notify_topic(ctx, peer, req.topic, mute_until=0)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
SPEC_TOPIC_UNMUTE = OperationSpec(
|
|
782
|
+
id="chat.topic.unmute",
|
|
783
|
+
request=TopicOneReq,
|
|
784
|
+
response=TopicResult,
|
|
785
|
+
impl=unmute_topic,
|
|
786
|
+
summary="Unmute a topic",
|
|
787
|
+
mutating=True,
|
|
788
|
+
columns=("chat_id", "topic_id", "mute_until"),
|
|
789
|
+
example={"chat_id": -1001500, "topic_id": 314},
|
|
790
|
+
example_args="chat topic unmute @myforum 314",
|
|
791
|
+
covers=("groups-channels-admin.topic-notify-settings",),
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
# ---------------------------------------------------------------------------
|
|
796
|
+
# chat topic read
|
|
797
|
+
# ---------------------------------------------------------------------------
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
class TopicReadReq(Request):
|
|
801
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Forum supergroup.")]
|
|
802
|
+
topic: Annotated[int, arg(1, metavar="TOPIC", kind="msg_id", help="Topic id.")]
|
|
803
|
+
max_id: Annotated[
|
|
804
|
+
int, opt("--max-id", metavar="ID", help="Read up to this message id; 0 means everything.")
|
|
805
|
+
] = 0
|
|
806
|
+
mentions: Annotated[bool, opt("--mentions", help="Also clear the unread-mentions badge.")] = (
|
|
807
|
+
False
|
|
808
|
+
)
|
|
809
|
+
reactions: Annotated[
|
|
810
|
+
bool, opt("--reactions", help="Also clear the unread-reactions badge.")
|
|
811
|
+
] = False
|
|
812
|
+
list_only: Annotated[
|
|
813
|
+
bool, opt("--list", help="Do not read: list the unread mentions/reactions instead.")
|
|
814
|
+
] = False
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
async def read_topic(ctx: OpContext, req: TopicReadReq) -> TopicReadResult:
|
|
818
|
+
"""Mark a topic read, or list what is unread in it.
|
|
819
|
+
|
|
820
|
+
Reading a topic does not read the rest of the forum, and `top_msg_id`
|
|
821
|
+
must be omitted for General — sending 1 there is how a client ends up
|
|
822
|
+
reading nothing.
|
|
823
|
+
"""
|
|
824
|
+
from telethon.tl.functions import messages as fn
|
|
825
|
+
|
|
826
|
+
peer = await _forum_peer(ctx, req.chat)
|
|
827
|
+
chat_id = _send.peer_id_of(peer)
|
|
828
|
+
handle = _admin.client(ctx)
|
|
829
|
+
top = None if req.topic == _admin.GENERAL_TOPIC else req.topic
|
|
830
|
+
|
|
831
|
+
if req.list_only:
|
|
832
|
+
items = []
|
|
833
|
+
if req.mentions or not req.reactions:
|
|
834
|
+
reply = await handle(
|
|
835
|
+
fn.GetUnreadMentionsRequest(
|
|
836
|
+
peer=peer,
|
|
837
|
+
top_msg_id=top,
|
|
838
|
+
offset_id=0,
|
|
839
|
+
add_offset=0,
|
|
840
|
+
limit=int(getattr(ctx, "limit", None) or 50),
|
|
841
|
+
max_id=0,
|
|
842
|
+
min_id=0,
|
|
843
|
+
)
|
|
844
|
+
)
|
|
845
|
+
items += [
|
|
846
|
+
message_to_model(m, chat_id=chat_id)
|
|
847
|
+
for m in (getattr(reply, "messages", None) or [])
|
|
848
|
+
]
|
|
849
|
+
if req.reactions:
|
|
850
|
+
reply = await handle(
|
|
851
|
+
fn.GetUnreadReactionsRequest(
|
|
852
|
+
peer=peer,
|
|
853
|
+
top_msg_id=top,
|
|
854
|
+
offset_id=0,
|
|
855
|
+
add_offset=0,
|
|
856
|
+
limit=int(getattr(ctx, "limit", None) or 50),
|
|
857
|
+
max_id=0,
|
|
858
|
+
min_id=0,
|
|
859
|
+
)
|
|
860
|
+
)
|
|
861
|
+
items += [
|
|
862
|
+
message_to_model(m, chat_id=chat_id)
|
|
863
|
+
for m in (getattr(reply, "messages", None) or [])
|
|
864
|
+
]
|
|
865
|
+
return TopicReadResult(chat_id=chat_id, topic_id=req.topic, items=items)
|
|
866
|
+
|
|
867
|
+
await handle(
|
|
868
|
+
fn.ReadDiscussionRequest(peer=peer, msg_id=req.topic, read_max_id=req.max_id or 0x7FFFFFFF)
|
|
869
|
+
)
|
|
870
|
+
if req.mentions:
|
|
871
|
+
await handle(fn.ReadMentionsRequest(peer=peer, top_msg_id=top))
|
|
872
|
+
if req.reactions:
|
|
873
|
+
await handle(fn.ReadReactionsRequest(peer=peer, top_msg_id=top))
|
|
874
|
+
ctx.emit("chat_topic_read", {"chat_id": chat_id, "topic_id": req.topic})
|
|
875
|
+
return TopicReadResult(
|
|
876
|
+
chat_id=chat_id,
|
|
877
|
+
topic_id=req.topic,
|
|
878
|
+
max_id=req.max_id or None,
|
|
879
|
+
unread_count=0,
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
SPEC_TOPIC_READ = OperationSpec(
|
|
884
|
+
id="chat.topic.read",
|
|
885
|
+
request=TopicReadReq,
|
|
886
|
+
response=TopicReadResult,
|
|
887
|
+
impl=read_topic,
|
|
888
|
+
summary="Mark a topic read, including its mentions and reactions",
|
|
889
|
+
description=(
|
|
890
|
+
"SEMANTICS: this emits a read receipt inside the topic, exactly like "
|
|
891
|
+
"`chat open` does for a chat. `--list` is the silent half. "
|
|
892
|
+
"`top_msg_id` is omitted for General (id 1), which is what the API "
|
|
893
|
+
"requires. Sending and listing messages inside a topic is "
|
|
894
|
+
"`message send/list --topic`."
|
|
895
|
+
),
|
|
896
|
+
mutating=True,
|
|
897
|
+
columns=("chat_id", "topic_id", "unread_count"),
|
|
898
|
+
example={"chat_id": -1001500, "topic_id": 314, "unread_count": 0},
|
|
899
|
+
example_args="chat topic read @myforum 314 --mentions",
|
|
900
|
+
covers=(
|
|
901
|
+
"groups-channels-admin.topic-messages",
|
|
902
|
+
"groups-channels-admin.topic-unread-counters",
|
|
903
|
+
),
|
|
904
|
+
tags=frozenset({"visible-to-others"}),
|
|
905
|
+
)
|