tlgr-cli 2.0.1__py3-none-any.whl

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