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.py ADDED
@@ -0,0 +1,4025 @@
1
+ """The `chat` group: the dialog list, and everything you can do to one dialog.
2
+
3
+ This is the surface an agent starts from, so three semantics are frozen and
4
+ must not drift (AGENT.md says so, and `tests/test_agentmd_compat.py` holds
5
+ the line):
6
+
7
+ * **`chat catchup` and `chat list` emit no read receipts.** They are how you
8
+ find out what happened; finding out must not tell anyone you looked.
9
+ * **`chat open` does emit one, deliberately.** It humanises the account and
10
+ clears the owner's own unread badge — which is also why `--no-read` exists
11
+ for chats a person is handling by hand.
12
+ * **`chat unread` sets Telegram's manual unread *flag*.** It restores the
13
+ owner's badge; it does not un-send the receipt the other side already got,
14
+ and no command can.
15
+
16
+ Two implementation notes that shape most of the module. Telegram has no
17
+ `getDialogs(filter_id)`, so `--folder <name>` is evaluated client-side
18
+ against `messages.getDialogFilters` — a folder is a filter, not a container.
19
+ And `peerNotifySettings` is sparse: an omitted field means "inherit the scope
20
+ default", so every notification change here is a read-modify-write.
21
+
22
+ Telethon is imported inside functions, never at module scope (§2.2).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ from datetime import datetime, timedelta, timezone
29
+ from typing import Annotated, Any
30
+
31
+ from tlgr.core.errors import (
32
+ EXIT_EMPTY,
33
+ NotSupportedError,
34
+ PermissionError_,
35
+ UsageError,
36
+ )
37
+ from tlgr.core.pagination import PageKind, build_page, decode_cursor
38
+ from tlgr.core.timefmt import fmt_dt, parse_dt, parse_duration, to_unix
39
+ from tlgr.models.base import Request
40
+ from tlgr.models.dialog import (
41
+ ActionBar,
42
+ ArchiveResult,
43
+ ArchiveSettings,
44
+ Badge,
45
+ Catchup,
46
+ CatchupChat,
47
+ ChatInfo,
48
+ ChatSwitches,
49
+ ChatTheme,
50
+ ChatWallpaper,
51
+ ClearResult,
52
+ DeleteChatResult,
53
+ Dialog,
54
+ Folder,
55
+ FolderBadge,
56
+ ImportState,
57
+ LeaveResult,
58
+ MuteResult,
59
+ NotifySettings,
60
+ NotifyView,
61
+ OpenResult,
62
+ PeerResult,
63
+ PinnedDialogs,
64
+ Poster,
65
+ PosterReport,
66
+ Promo,
67
+ ReadChats,
68
+ SavedDialog,
69
+ SecretChat,
70
+ ThemeResult,
71
+ TranslateResult,
72
+ TtlResult,
73
+ TypingResult,
74
+ UnreadResult,
75
+ WallpaperResult,
76
+ )
77
+ from tlgr.models.message import Message, ReportResult
78
+ from tlgr.models.page import Page
79
+ from tlgr.models.peer import Peer, PeerRef
80
+ from tlgr.ops import _send
81
+ from tlgr.ops._params import arg, choice, opt
82
+ from tlgr.ops._serialize import (
83
+ action_bar,
84
+ chat_theme,
85
+ entity_to_peer,
86
+ message_to_model,
87
+ notify_settings,
88
+ peer_id_of,
89
+ wallpaper,
90
+ )
91
+ from tlgr.ops._spec import OpContext, OperationSpec
92
+
93
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
94
+
95
+ #: `mute_until` for "forever". Telegram's own sentinel is 2^31-1.
96
+ MUTE_FOREVER = 2**31 - 1
97
+
98
+ #: Telegram's peer-folders. There are exactly two and there will not be a
99
+ #: third: `folder_id` is 0 (main) or 1 (archive), and everything a user calls
100
+ #: a "folder" is a dialog *filter* instead.
101
+ FOLDER_MAIN = 0
102
+ FOLDER_ARCHIVE = 1
103
+
104
+ _EXAMPLE_DIALOG: dict[str, Any] = {
105
+ "chat": {"id": 777123, "raw_id": 777123, "kind": "user", "title": "Alice"},
106
+ "unread_count": 3,
107
+ "top_message_id": 4210,
108
+ }
109
+
110
+ _ACTIONS = {
111
+ "typing": "SendMessageTypingAction",
112
+ "cancel": "SendMessageCancelAction",
113
+ "record-audio": "SendMessageRecordAudioAction",
114
+ "record-video": "SendMessageRecordVideoAction",
115
+ "record-round": "SendMessageRecordRoundAction",
116
+ "upload-photo": "SendMessageUploadPhotoAction",
117
+ "upload-video": "SendMessageUploadVideoAction",
118
+ "upload-document": "SendMessageUploadDocumentAction",
119
+ "upload-audio": "SendMessageUploadAudioAction",
120
+ "location": "SendMessageGeoLocationAction",
121
+ "contact": "SendMessageChooseContactAction",
122
+ "sticker": "SendMessageChooseStickerAction",
123
+ "game": "SendMessageGamePlayAction",
124
+ "history-import": "SendMessageHistoryImportAction",
125
+ "speaking": "SpeakingInGroupCallAction",
126
+ }
127
+
128
+ _REPORT_REASONS = {
129
+ "spam": "InputReportReasonSpam",
130
+ "violence": "InputReportReasonViolence",
131
+ "porn": "InputReportReasonPornography",
132
+ "child-abuse": "InputReportReasonChildAbuse",
133
+ "geo-irrelevant": "InputReportReasonGeoIrrelevant",
134
+ "fake": "InputReportReasonFake",
135
+ "copyright": "InputReportReasonCopyright",
136
+ "drugs": "InputReportReasonIllegalDrugs",
137
+ "personal-details": "InputReportReasonPersonalDetails",
138
+ "other": "InputReportReasonOther",
139
+ }
140
+
141
+ #: What every secret-chat command that needs key material answers with.
142
+ SECRET_UNSUPPORTED = (
143
+ "Telethon speaks no MTProto-2.0 end-to-end layer, so tlgr cannot hold a "
144
+ "secret chat's keys: DH validation, AES-IGE, in/out sequence numbers, "
145
+ "PFS re-keying and local key storage are a module tlgr does not have yet. "
146
+ "`chat secret discard` works, because discarding needs only the chat id"
147
+ )
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Shared plumbing
152
+ # ---------------------------------------------------------------------------
153
+
154
+
155
+ def _client(ctx: OpContext) -> Any:
156
+ client = getattr(ctx, "client", None)
157
+ if client is None: # pragma: no cover - the daemon always supplies one
158
+ raise UsageError("this operation needs a connected account")
159
+ return client
160
+
161
+
162
+ def _already(ctx: OpContext) -> None:
163
+ mark = getattr(ctx, "mark_already", None)
164
+ if callable(mark):
165
+ mark()
166
+
167
+
168
+ def _window(ctx: OpContext, op: str, kind: PageKind, default: int = 50) -> tuple[int, Any]:
169
+ """`(limit, cursor state)` — `--limit`/`--cursor` are transport-level (L5)."""
170
+ limit = int(getattr(ctx, "limit", None) or default)
171
+ if limit < 1:
172
+ raise UsageError("--limit must be at least 1", field="limit")
173
+ token = getattr(ctx, "cursor", None)
174
+ state: dict[str, Any] = {}
175
+ if token:
176
+ state = decode_cursor(token, op=op, kind=kind, account=ctx.account)
177
+ return min(limit, 1000), state
178
+
179
+
180
+ def _input_channel(peer: Any) -> Any:
181
+ """The `InputChannel` a `channels.*` request wants, or a usage error."""
182
+ from telethon import utils
183
+
184
+ try:
185
+ return utils.get_input_channel(peer)
186
+ except (TypeError, ValueError) as exc:
187
+ raise UsageError(
188
+ "this operation only works in a channel or supergroup", field="chat"
189
+ ) from exc
190
+
191
+
192
+ def _is_channel(peer: Any) -> bool:
193
+ return type(peer).__name__ in ("InputPeerChannel", "InputPeerChannelFromMessage")
194
+
195
+
196
+ async def _affected_loop(ctx: OpContext, make_request: Any) -> int:
197
+ """Drive an `AffectedHistory` call until `offset == 0`.
198
+
199
+ The server answers a big history with a partial result and an offset to
200
+ resume from. Calling once and reporting success is how "clear the whole
201
+ history" clears the first hundred messages.
202
+ """
203
+ client = _client(ctx)
204
+ total = 0
205
+ offset = 0
206
+ for _ in range(100):
207
+ result = await client(make_request(offset))
208
+ total += int(getattr(result, "pts_count", 0) or 0)
209
+ offset = int(getattr(result, "offset", 0) or 0)
210
+ if offset == 0:
211
+ break
212
+ limiter = getattr(ctx, "limiter", None)
213
+ if limiter is not None:
214
+ await limiter.acquire("bulk")
215
+ return total
216
+
217
+
218
+ def _peer_folder(name: str | None) -> int | None:
219
+ """`main`/`archive`/`all` → the peer-folder id, or None for "not one"."""
220
+ value = (name or "").strip().lower()
221
+ if value in ("", "main", "inbox", "0"):
222
+ return FOLDER_MAIN
223
+ if value in ("archive", "archived", "1"):
224
+ return FOLDER_ARCHIVE
225
+ if value == "all":
226
+ return None
227
+ return None
228
+
229
+
230
+ def _is_peer_folder(name: str | None) -> bool:
231
+ return (name or "").strip().lower() in ("", "main", "inbox", "0", "archive", "archived", "1")
232
+
233
+
234
+ async def _read_filter(ctx: OpContext, name: str | None) -> Any:
235
+ """The raw `dialogFilter` a `--folder <name|id>` names, or None."""
236
+ if name is None or _is_peer_folder(name) or name.strip().lower() == "all":
237
+ return None
238
+ from tlgr.ops.folder import find_filter
239
+
240
+ return await find_filter(ctx, name)
241
+
242
+
243
+ def _entity_map(result: Any) -> dict[int, Any]:
244
+ """`{marked id: entity}` for the chats and users a reply carried."""
245
+ from telethon import utils
246
+
247
+ out: dict[int, Any] = {}
248
+ for entity in list(getattr(result, "chats", None) or []) + list(
249
+ getattr(result, "users", None) or []
250
+ ):
251
+ try:
252
+ out[int(utils.get_peer_id(entity))] = entity
253
+ except (TypeError, ValueError): # pragma: no cover - defensive
254
+ continue
255
+ return out
256
+
257
+
258
+ def _dialog_model(raw: Any, entities: dict[int, Any], messages: dict[int, Any]) -> Dialog:
259
+ """One `dialog` row plus the entity and top message that came with it."""
260
+ from tlgr.ops.draft import draft_model
261
+
262
+ chat_id = peer_id_of(getattr(raw, "peer", None)) or 0
263
+ entity = entities.get(chat_id)
264
+ peer = (
265
+ entity_to_peer(entity)
266
+ if entity is not None
267
+ else Peer(id=chat_id, raw_id=abs(chat_id), kind="unknown")
268
+ )
269
+ top_id = int(getattr(raw, "top_message", 0) or 0)
270
+ top = messages.get(top_id) if top_id else None
271
+ draft_raw = getattr(raw, "draft", None)
272
+ draft = None
273
+ if draft_raw is not None and type(draft_raw).__name__ != "DraftMessageEmpty":
274
+ draft = draft_model(draft_raw, chat_id=chat_id)
275
+ folder_id = int(getattr(raw, "folder_id", 0) or 0)
276
+ return Dialog(
277
+ chat=peer,
278
+ unread_count=int(getattr(raw, "unread_count", 0) or 0),
279
+ unread_mentions_count=int(getattr(raw, "unread_mentions_count", 0) or 0),
280
+ unread_reactions_count=int(getattr(raw, "unread_reactions_count", 0) or 0),
281
+ unread_poll_votes_count=int(getattr(raw, "unread_poll_votes_count", 0) or 0),
282
+ unread_mark=bool(getattr(raw, "unread_mark", False)),
283
+ read_inbox_max_id=int(getattr(raw, "read_inbox_max_id", 0) or 0),
284
+ read_outbox_max_id=int(getattr(raw, "read_outbox_max_id", 0) or 0),
285
+ top_message_id=top_id or None,
286
+ pinned=bool(getattr(raw, "pinned", False)),
287
+ folder_id=folder_id,
288
+ archived=folder_id == FOLDER_ARCHIVE,
289
+ notify=notify_settings(getattr(raw, "notify_settings", None)),
290
+ draft=draft,
291
+ ttl_period=getattr(raw, "ttl_period", None),
292
+ view_forum_as_messages=getattr(raw, "view_forum_as_messages", None),
293
+ requests_pending=getattr(entity, "requests_pending", None),
294
+ restricted=bool(getattr(entity, "restricted", False)),
295
+ restriction_reason=[
296
+ str(getattr(reason, "text", "") or "")
297
+ for reason in (getattr(entity, "restriction_reason", None) or [])
298
+ ],
299
+ participants_count=getattr(entity, "participants_count", None),
300
+ last_message=message_to_model(top, chat_id=chat_id) if top is not None else None,
301
+ )
302
+
303
+
304
+ async def fetch_dialogs(
305
+ ctx: OpContext,
306
+ *,
307
+ folder_id: int | None,
308
+ limit: int,
309
+ offset_date: Any = None,
310
+ offset_id: int = 0,
311
+ offset_peer: Any = None,
312
+ ) -> tuple[list[Dialog], list[Any], dict[int, Any]]:
313
+ """One page of `messages.getDialogs`, as models, raw rows and entities.
314
+
315
+ Raw rows come back too because the cursor is built from the *last row's*
316
+ peer, and rebuilding an `InputPeer` from a marked id would be a second
317
+ resolution of something the server just sent. The entity map comes with
318
+ them because a dialog row's `peer` carries only ids, and the cursor needs
319
+ the peer's ACCESS HASH — see `_offset_peer`.
320
+ """
321
+ from telethon.tl import types
322
+ from telethon.tl.functions import messages as fn
323
+
324
+ result = await _client(ctx)(
325
+ fn.GetDialogsRequest(
326
+ offset_date=offset_date,
327
+ offset_id=offset_id,
328
+ offset_peer=offset_peer or types.InputPeerEmpty(),
329
+ limit=limit,
330
+ hash=0,
331
+ folder_id=folder_id if folder_id else None,
332
+ )
333
+ )
334
+ entities = _entity_map(result)
335
+ messages = {
336
+ int(getattr(message, "id", 0) or 0): message
337
+ for message in (getattr(result, "messages", None) or [])
338
+ }
339
+ rows = [row for row in (getattr(result, "dialogs", None) or []) if hasattr(row, "peer")]
340
+ # The cursor needs the last row's peer WITH its access hash, and the only
341
+ # place that hash exists is the entity list this same reply carried — so
342
+ # hand it back with the rows rather than letting the caller invent one.
343
+ return [_dialog_model(row, entities, messages) for row in rows], rows, entities
344
+
345
+
346
+ async def _all_dialogs(ctx: OpContext, *, folder_id: int | None, cap: int = 2000) -> list[Dialog]:
347
+ """Every dialog, walked inside the daemon.
348
+
349
+ A folder is evaluated client-side, so anything scoped by one needs the
350
+ whole list; walking it here is what keeps "the dialog list was fully
351
+ enumerated" true for the callers that depend on it.
352
+ """
353
+ out: list[Dialog] = []
354
+ offset_date: Any = None
355
+ offset_id = 0
356
+ offset_peer: Any = None
357
+ seen: set[int] = set()
358
+ while len(out) < cap:
359
+ page, rows, entities = await fetch_dialogs(
360
+ ctx,
361
+ folder_id=folder_id,
362
+ limit=100,
363
+ offset_date=offset_date,
364
+ offset_id=offset_id,
365
+ offset_peer=offset_peer,
366
+ )
367
+ fresh = [d for d in page if d.chat.id not in seen]
368
+ out.extend(fresh)
369
+ seen.update(d.chat.id for d in page)
370
+ if len(page) < 100 or not fresh:
371
+ break
372
+ last = page[-1]
373
+ offset_id = last.top_message_id or 0
374
+ offset_date = parse_dt(last.last_message.date) if last.last_message else None
375
+ offset_peer = _offset_peer(rows[-1], entities) if rows else None
376
+ return out
377
+
378
+
379
+ def _offset_peer(row: Any, entities: dict[int, Any] | None = None) -> Any:
380
+ """The `InputPeer` for a dialog row's peer, without a round trip.
381
+
382
+ The access hash is NOT optional here. `messages.getDialogs` resolves the
383
+ cursor against `offset_peer`, and a peer carrying `access_hash=0` does not
384
+ resolve: the server answers from the top instead of from the cursor, so
385
+ the next page repeats the first and the walk sees nothing new.
386
+
387
+ Measured on a live account (`chat.list`, 100 rows per page, following
388
+ `next_cursor`): 34 rows in one page before, 600+ across six pages after.
389
+ That is the paged path. The `fetch_all` walk in `_all_dialogs` has a
390
+ SEPARATE stall that this does not fix — it still stops at ~101 on a
391
+ ~936-dialog account — so do not read this as the whole enumeration bug.
392
+
393
+ The hash is already in hand: the same `getDialogs` reply carries the
394
+ entity for every peer it mentions, so `fetch_dialogs` passes that map in
395
+ beside the row rather than having anyone re-resolve it.
396
+ """
397
+ from telethon import utils
398
+ from telethon.tl import types
399
+
400
+ peer = getattr(row, "peer", None)
401
+ if peer is not None and entities:
402
+ try:
403
+ entity = entities.get(int(utils.get_peer_id(peer)))
404
+ except (TypeError, ValueError):
405
+ entity = None
406
+ if entity is not None:
407
+ try:
408
+ return utils.get_input_peer(entity)
409
+ except (TypeError, ValueError):
410
+ pass
411
+ # Fall back to the hashless form rather than failing the page outright: a
412
+ # stalled cursor loses dialogs, an exception loses the whole call.
413
+ user_id = getattr(peer, "user_id", None)
414
+ if user_id is not None:
415
+ return types.InputPeerUser(user_id=int(user_id), access_hash=0)
416
+ chat_id = getattr(peer, "chat_id", None)
417
+ if chat_id is not None:
418
+ return types.InputPeerChat(chat_id=int(chat_id))
419
+ channel_id = getattr(peer, "channel_id", None)
420
+ if channel_id is not None:
421
+ return types.InputPeerChannel(channel_id=int(channel_id), access_hash=0)
422
+ return types.InputPeerEmpty()
423
+
424
+
425
+ def _matches_filter(dialog: Dialog, raw: Any) -> bool:
426
+ """Does this dialog fall into that chat folder?
427
+
428
+ The same rules every official client applies: an explicit include or pin
429
+ always wins, an explicit exclude always loses, and the type flags decide
430
+ the rest.
431
+ """
432
+ chat_id = dialog.chat.id
433
+ from tlgr.ops.folder import folder_model
434
+
435
+ model = folder_model(raw)
436
+ if chat_id in model.exclude_peers:
437
+ return False
438
+ if chat_id in model.include_peers or chat_id in model.pinned_peers:
439
+ return True
440
+ if model.is_chatlist:
441
+ return False
442
+ kind = dialog.chat.kind
443
+ if model.exclude_muted and dialog.notify is not None and dialog.notify.muted:
444
+ return False
445
+ if model.exclude_read and not (dialog.unread_count or dialog.unread_mark):
446
+ return False
447
+ if model.exclude_archived and dialog.archived:
448
+ return False
449
+ if kind == "bot":
450
+ return model.bots
451
+ if kind in ("user", "saved"):
452
+ return model.contacts or model.non_contacts
453
+ if kind in ("group", "supergroup"):
454
+ return model.groups
455
+ if kind == "channel":
456
+ return model.broadcasts
457
+ return False
458
+
459
+
460
+ async def folder_counts(ctx: OpContext, filters: list[Any], folders: list[Folder]) -> None:
461
+ """Fill in `chats`/`unread_*` for each folder from one dialog walk.
462
+
463
+ Exported for `folder list --with-counts`: there is no server-side count,
464
+ and asking per folder would be one full dialog walk per folder.
465
+ """
466
+ dialogs = await _all_dialogs(ctx, folder_id=None)
467
+ dialogs += await _all_dialogs(ctx, folder_id=FOLDER_ARCHIVE)
468
+ for raw, model in zip(filters, folders, strict=True):
469
+ if model.is_default:
470
+ members = [d for d in dialogs if not d.archived]
471
+ else:
472
+ members = [d for d in dialogs if _matches_filter(d, raw)]
473
+ model.chats = len(members)
474
+ model.unread_chats = sum(1 for d in members if d.unread_count or d.unread_mark)
475
+ model.unread_messages = sum(d.unread_count for d in members)
476
+
477
+
478
+ async def _resolve_many(
479
+ ctx: OpContext, refs: Any, *, from_file: str | None = None
480
+ ) -> list[tuple[Any, int]]:
481
+ """`[(InputPeer, marked id)]` for a variadic peer list, plus `--from-file`."""
482
+ items = list(refs or [])
483
+ if from_file:
484
+ items.extend(_lines(from_file))
485
+ out: list[tuple[Any, int]] = []
486
+ for ref in items:
487
+ peer = await _send.resolve(ctx, ref)
488
+ out.append((peer, _send.peer_id_of(peer)))
489
+ return out
490
+
491
+
492
+ def _lines(path: str) -> list[str]:
493
+ """One peer per line from a file, or from stdin when the path is `-`."""
494
+ import sys
495
+ from pathlib import Path
496
+
497
+ if path == "-":
498
+ if sys.stdin is None or sys.stdin.isatty():
499
+ raise UsageError("--from-file - was given but stdin is a terminal", field="from-file")
500
+ text = sys.stdin.read()
501
+ else:
502
+ try:
503
+ text = Path(path).expanduser().read_text(encoding="utf-8")
504
+ except OSError as exc:
505
+ raise UsageError(f"--from-file: {exc.strerror or exc}", field="from-file") from exc
506
+ return [line.strip() for line in text.splitlines() if line.strip() and not line.startswith("#")]
507
+
508
+
509
+ async def _folder_members(ctx: OpContext, folder: str, kind: str | None = None) -> list[Dialog]:
510
+ """Every dialog of a folder — peer-folder or chat folder — optionally typed."""
511
+ raw = await _read_filter(ctx, folder)
512
+ if raw is None:
513
+ dialogs = await _all_dialogs(ctx, folder_id=_peer_folder(folder))
514
+ else:
515
+ dialogs = [
516
+ d
517
+ for d in await _all_dialogs(ctx, folder_id=None)
518
+ + await _all_dialogs(ctx, folder_id=FOLDER_ARCHIVE)
519
+ if _matches_filter(d, raw)
520
+ ]
521
+ return [d for d in dialogs if _kind_matches(d.chat.kind, kind)]
522
+
523
+
524
+ def _kind_matches(kind: str, wanted: str | None) -> bool:
525
+ if not wanted:
526
+ return True
527
+ if wanted == "group":
528
+ return kind in ("group", "supergroup")
529
+ if wanted == "user":
530
+ return kind in ("user", "saved")
531
+ return kind == wanted
532
+
533
+
534
+ def _error_text(exc: BaseException) -> str:
535
+ return f"{type(exc).__name__}: {exc}".strip()
536
+
537
+
538
+ # ---------------------------------------------------------------------------
539
+ # chat list
540
+ # ---------------------------------------------------------------------------
541
+
542
+
543
+ class ListReq(Request):
544
+ folder: Annotated[
545
+ str,
546
+ opt("--folder", metavar="FOLDER", help="main | archive | all | folder id | folder name."),
547
+ ] = "main"
548
+ type: Annotated[
549
+ str | None,
550
+ choice(
551
+ "user",
552
+ "bot",
553
+ "group",
554
+ "supergroup",
555
+ "channel",
556
+ "forum",
557
+ "saved",
558
+ "self",
559
+ help="Filter by peer kind.",
560
+ ),
561
+ ] = None
562
+ unread: Annotated[bool, opt("--unread", help="Only chats with unread messages or a mark.")] = (
563
+ False
564
+ )
565
+ unread_mark: Annotated[
566
+ bool, opt("--unread-mark", help="Only chats carrying the manual unread mark.")
567
+ ] = False
568
+ with_mentions: Annotated[
569
+ bool, opt("--with-mentions", help="Only chats with unread mentions.")
570
+ ] = False
571
+ with_reactions: Annotated[
572
+ bool, opt("--with-reactions", help="Only chats with unread reactions.")
573
+ ] = False
574
+ with_join_requests: Annotated[
575
+ bool, opt("--with-join-requests", help="Only chats with pending join requests.")
576
+ ] = False
577
+ with_drafts: Annotated[bool, opt("--with-drafts", help="Only chats with a saved draft.")] = (
578
+ False
579
+ )
580
+ pinned: Annotated[bool, opt("--pinned", help="Only pinned dialogs, in pinned order.")] = False
581
+ muted: Annotated[bool, opt("--muted", help="Only muted chats.")] = False
582
+ unmuted: Annotated[bool, opt("--unmuted", help="Only unmuted chats.")] = False
583
+ search: Annotated[
584
+ str | None, opt("--search", "-s", metavar="TEXT", help="Match title or username.")
585
+ ] = None
586
+ inactive: Annotated[
587
+ bool, opt("--inactive", help="The groups/channels you have not opened for longest.")
588
+ ] = False
589
+ scope: Annotated[
590
+ str,
591
+ choice(
592
+ "dialogs",
593
+ "admined-public",
594
+ "inactive",
595
+ "left",
596
+ help="What to list instead of the dialog list.",
597
+ ),
598
+ ] = "dialogs"
599
+ common_with: Annotated[
600
+ PeerRef | None,
601
+ opt("--common-with", metavar="USER", kind="user", help="Chats shared with this user."),
602
+ ] = None
603
+ by_location: Annotated[
604
+ bool, opt("--by-location", help="With --scope admined-public: geogroups.")
605
+ ] = False
606
+ check_limit: Annotated[
607
+ bool, opt("--check-limit", help="With --scope admined-public: report the limit instead.")
608
+ ] = False
609
+ for_personal: Annotated[
610
+ bool,
611
+ opt("--for-personal", help="With --scope admined-public: personal-channel candidates."),
612
+ ] = False
613
+ sort: Annotated[
614
+ str, choice("default", "date", "unread", "name", "pinned", help="Ordering.")
615
+ ] = "default"
616
+
617
+
618
+ async def list_chats(ctx: OpContext, req: ListReq) -> Page[Dialog]:
619
+ """The chat list, filtered the way the GUI's quick filters filter it.
620
+
621
+ A `--folder <name>` is applied client-side because Telegram has no
622
+ `getDialogs(filter_id)`; that also means such a listing walks every page
623
+ inside the daemon, which is what keeps "the dialog list was fully
624
+ enumerated" an honest statement for `user dialog-status` to rely on.
625
+ """
626
+ limit, state = _window(ctx, "chat.list", PageKind.DIALOGS)
627
+ scope = "inactive" if req.inactive else req.scope
628
+
629
+ if req.common_with is not None:
630
+ return await _common_chats(ctx, req, limit)
631
+ if scope != "dialogs":
632
+ return await _chat_scope(ctx, req, scope, limit)
633
+ if req.pinned:
634
+ return await _pinned_dialogs(ctx, req)
635
+
636
+ chat_filter = await _read_filter(ctx, req.folder)
637
+ folder_id = None if chat_filter is not None else _peer_folder(req.folder)
638
+ fetch_all = bool(getattr(ctx, "fetch_all", False))
639
+ walk_all = fetch_all or chat_filter is not None
640
+
641
+ rows: list[Any] = []
642
+ if walk_all:
643
+ items = await _all_dialogs(ctx, folder_id=folder_id)
644
+ if chat_filter is not None:
645
+ seen = {d.chat.id for d in items}
646
+ archived = await _all_dialogs(ctx, folder_id=FOLDER_ARCHIVE)
647
+ items += [d for d in archived if d.chat.id not in seen]
648
+ items = [d for d in items if _matches_filter(d, chat_filter)]
649
+ member_of = int(getattr(chat_filter, "id", 0) or 0)
650
+ for dialog in items:
651
+ dialog.folders = [member_of]
652
+ else:
653
+ items, rows, entities = await fetch_dialogs(
654
+ ctx,
655
+ folder_id=folder_id,
656
+ limit=limit,
657
+ offset_date=parse_dt(state.get("offset_date")) if state.get("offset_date") else None,
658
+ offset_id=int(state.get("offset_id", 0) or 0),
659
+ offset_peer=_state_peer(state),
660
+ )
661
+
662
+ items = _sorted(_apply_filters(items, req), req.sort)
663
+ if walk_all:
664
+ return Page(
665
+ items=items if fetch_all else items[:limit],
666
+ has_more=False,
667
+ total=len(items),
668
+ )
669
+
670
+ next_state: dict[str, Any] = {}
671
+ if rows and items:
672
+ next_state = {
673
+ "offset_id": int(getattr(rows[-1], "top_message", 0) or 0),
674
+ "peer": _peer_state(rows[-1], entities),
675
+ }
676
+ return build_page(
677
+ items,
678
+ op="chat.list",
679
+ kind=PageKind.DIALOGS,
680
+ state=next_state,
681
+ account=ctx.account,
682
+ limit=limit,
683
+ has_more=None if next_state else False,
684
+ )
685
+
686
+
687
+ def _peer_state(row: Any, entities: dict[int, Any] | None = None) -> dict[str, Any]:
688
+ """The cursor's peer, WITH its access hash.
689
+
690
+ Same reason as `_offset_peer`: `getDialogs` resolves its cursor against
691
+ this peer, and a hashless one silently restarts the walk from the top.
692
+ """
693
+ from telethon import utils
694
+
695
+ peer = getattr(row, "peer", None)
696
+ access_hash = 0
697
+ if peer is not None and entities:
698
+ try:
699
+ entity = entities.get(int(utils.get_peer_id(peer)))
700
+ except (TypeError, ValueError):
701
+ entity = None
702
+ access_hash = int(getattr(entity, "access_hash", 0) or 0)
703
+ for attribute in ("user_id", "chat_id", "channel_id"):
704
+ value = getattr(peer, attribute, None)
705
+ if value is not None:
706
+ return {"kind": attribute, "id": int(value), "access_hash": access_hash}
707
+ return {}
708
+
709
+
710
+ def _state_peer(state: dict[str, Any]) -> Any:
711
+ from telethon.tl import types
712
+
713
+ saved = state.get("peer") or {}
714
+ kind = saved.get("kind")
715
+ value = int(saved.get("id") or 0)
716
+ access_hash = int(saved.get("access_hash") or 0)
717
+ if kind == "user_id":
718
+ return types.InputPeerUser(user_id=value, access_hash=access_hash)
719
+ if kind == "chat_id":
720
+ return types.InputPeerChat(chat_id=value)
721
+ if kind == "channel_id":
722
+ return types.InputPeerChannel(channel_id=value, access_hash=access_hash)
723
+ return None
724
+
725
+
726
+ def _apply_filters(items: list[Dialog], req: ListReq) -> list[Dialog]:
727
+ out = items
728
+ if req.type:
729
+ if req.type == "forum":
730
+ out = [d for d in out if d.view_forum_as_messages is not None]
731
+ elif req.type == "self":
732
+ out = [d for d in out if d.chat.is_self or d.chat.kind == "saved"]
733
+ else:
734
+ out = [d for d in out if _kind_matches(d.chat.kind, req.type)]
735
+ if req.unread:
736
+ out = [d for d in out if d.unread_count or d.unread_mark]
737
+ if req.unread_mark:
738
+ out = [d for d in out if d.unread_mark]
739
+ if req.with_mentions:
740
+ out = [d for d in out if d.unread_mentions_count]
741
+ if req.with_reactions:
742
+ out = [d for d in out if d.unread_reactions_count]
743
+ if req.with_join_requests:
744
+ out = [d for d in out if (d.requests_pending or 0) > 0]
745
+ if req.with_drafts:
746
+ out = [d for d in out if d.draft is not None and not d.draft.empty]
747
+ if req.muted:
748
+ out = [d for d in out if d.notify is not None and d.notify.muted]
749
+ if req.unmuted:
750
+ out = [d for d in out if d.notify is None or not d.notify.muted]
751
+ if req.search:
752
+ needle = req.search.casefold()
753
+ out = [
754
+ d
755
+ for d in out
756
+ if needle in d.chat.title.casefold() or needle in (d.chat.username or "").casefold()
757
+ ]
758
+ return out
759
+
760
+
761
+ def _sorted(items: list[Dialog], how: str) -> list[Dialog]:
762
+ if how == "name":
763
+ return sorted(items, key=lambda d: d.chat.title.casefold())
764
+ if how == "unread":
765
+ return sorted(items, key=lambda d: d.unread_count, reverse=True)
766
+ if how == "pinned":
767
+ return sorted(items, key=lambda d: (not d.pinned, -(d.top_message_id or 0)))
768
+ if how == "date":
769
+ return sorted(items, key=lambda d: -(d.top_message_id or 0))
770
+ return items
771
+
772
+
773
+ async def _pinned_dialogs(ctx: OpContext, req: ListReq) -> Page[Dialog]:
774
+ """`messages.getPinnedDialogs` — the pinned rows, in their pinned order."""
775
+ from telethon.tl.functions import messages as fn
776
+
777
+ folder_id = _peer_folder(req.folder) or FOLDER_MAIN
778
+ result = await _client(ctx)(fn.GetPinnedDialogsRequest(folder_id=folder_id))
779
+ entities = _entity_map(result)
780
+ messages = {int(getattr(m, "id", 0) or 0): m for m in (getattr(result, "messages", None) or [])}
781
+ items = [
782
+ _dialog_model(row, entities, messages)
783
+ for row in (getattr(result, "dialogs", None) or [])
784
+ if hasattr(row, "peer")
785
+ ]
786
+ for order, dialog in enumerate(items):
787
+ dialog.pinned = True
788
+ dialog.pinned_order = order
789
+ return Page(items=_apply_filters(items, req), has_more=False, total=len(items))
790
+
791
+
792
+ async def _common_chats(ctx: OpContext, req: ListReq, limit: int) -> Page[Dialog]:
793
+ """`messages.getCommonChats` — what you and this user are both in."""
794
+ from telethon.tl.functions import messages as fn
795
+
796
+ user = await _send.resolve(ctx, req.common_with)
797
+ result = await _client(ctx)(fn.GetCommonChatsRequest(user_id=user, max_id=0, limit=limit))
798
+ items = [Dialog(chat=entity_to_peer(chat)) for chat in (getattr(result, "chats", None) or [])]
799
+ return Page(items=_apply_filters(items, req), has_more=False, total=len(items))
800
+
801
+
802
+ async def _chat_scope(ctx: OpContext, req: ListReq, scope: str, limit: int) -> Page[Dialog]:
803
+ """The three chat lists that are not the dialog list.
804
+
805
+ `inactive` is the CHANNELS_TOO_MUCH escape hatch (it names what to leave),
806
+ `admined-public` is the public-username budget, and `left` is what you
807
+ once joined. None of them is a dialog, so they arrive as chats with the
808
+ dialog fields absent rather than zeroed.
809
+ """
810
+ from telethon.tl.functions import channels as fn
811
+
812
+ items: list[Dialog] = []
813
+ if scope == "inactive":
814
+ result = await _client(ctx)(fn.GetInactiveChannelsRequest())
815
+ dates = list(getattr(result, "dates", None) or [])
816
+ for index, chat in enumerate(getattr(result, "chats", None) or []):
817
+ dialog = Dialog(chat=entity_to_peer(chat))
818
+ if index < len(dates):
819
+ dialog.inactive_since = fmt_dt(dates[index])
820
+ dialog.participants_count = getattr(chat, "participants_count", None)
821
+ items.append(dialog)
822
+ elif scope == "left":
823
+ result = await _client(ctx)(fn.GetLeftChannelsRequest(offset=0))
824
+ items = [Dialog(chat=entity_to_peer(c)) for c in (getattr(result, "chats", None) or [])]
825
+ else:
826
+ result = await _client(ctx)(
827
+ fn.GetAdminedPublicChannelsRequest(
828
+ by_location=req.by_location or None,
829
+ check_limit=req.check_limit or None,
830
+ for_personal=req.for_personal or None,
831
+ )
832
+ )
833
+ items = [Dialog(chat=entity_to_peer(c)) for c in (getattr(result, "chats", None) or [])]
834
+ items = _apply_filters(items, req)
835
+ return Page(items=items[:limit], has_more=len(items) > limit, total=len(items))
836
+
837
+
838
+ SPEC_LIST = OperationSpec(
839
+ id="chat.list",
840
+ request=ListReq,
841
+ response=Page[Dialog],
842
+ impl=list_chats,
843
+ summary="List dialogs with folder, type, unread, pinned and search filters",
844
+ description=(
845
+ "Never emits a read receipt. `--folder` takes a peer-folder "
846
+ "(main/archive/all) or a chat folder by id or name, evaluated "
847
+ "client-side because Telegram has no getDialogs(filter_id). "
848
+ "`--scope` swaps the dialog list for the admined-public, inactive or "
849
+ "left-channel lists, which are chats rather than dialogs."
850
+ ),
851
+ aliases=("chats", "inbox"),
852
+ legacy_paths=("chat list", "chats", "inbox"),
853
+ paginated=PageKind.DIALOGS,
854
+ columns=("chat.id", "chat.title", "chat.kind", "unread_count"),
855
+ headers=("ID", "Name", "Type", "Unread"),
856
+ example={"items": [_EXAMPLE_DIALOG], "has_more": True},
857
+ example_args="chat list --unread",
858
+ covers=(
859
+ "dialogs.folder-chat-count",
860
+ "dialogs.inactive-chats",
861
+ "dialogs.join-requests-badge",
862
+ "dialogs.list-archive",
863
+ "dialogs.list-folder",
864
+ "dialogs.list-main",
865
+ "dialogs.pinned-list",
866
+ "dialogs.restricted-peer",
867
+ "dialogs.saved-messages",
868
+ "dialogs.unread-marks-list",
869
+ "dialogs.unread-quick-filter",
870
+ "groups-channels-admin.admined-public-chats",
871
+ "groups-channels-admin.common-chats",
872
+ "groups-channels-admin.inactive-chats",
873
+ "groups-channels-admin.left-channels",
874
+ ),
875
+ covers_partial=("contacts-users.contacts-sort", "dialogs.search-peers"),
876
+ coverage_note=(
877
+ "Peer search here is a substring match over the dialog list; the "
878
+ "global one is `contact search`. `--sort` orders chats, not contacts."
879
+ ),
880
+ )
881
+
882
+
883
+ # ---------------------------------------------------------------------------
884
+ # chat open / catchup
885
+ # ---------------------------------------------------------------------------
886
+
887
+
888
+ class OpenReq(Request):
889
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat to open.")]
890
+ no_read: Annotated[
891
+ bool, opt("--no-read", help="Peek: fetch the history and emit no read receipt.")
892
+ ] = False
893
+ topic: Annotated[
894
+ int | None, opt("--topic", metavar="ID", kind="msg_id", help="Only this forum topic.")
895
+ ] = None
896
+ increment_views: Annotated[
897
+ bool, opt("--increment-views", help="Also count a view on channel posts.")
898
+ ] = False
899
+
900
+
901
+ async def open_chat(ctx: OpContext, req: OpenReq) -> OpenResult:
902
+ """Open a chat the way a human does: recent history *and* a read receipt.
903
+
904
+ The receipt is the point, and it has two effects: the other side sees you
905
+ read it, and the owner's own unread badge is cleared. The second one is
906
+ why `--no-read` exists — on a chat a person is handling by hand that badge
907
+ is their only reminder that they owe a reply.
908
+ """
909
+ limit = int(getattr(ctx, "limit", None) or 30)
910
+ peer = await _send.resolve(ctx, req.chat)
911
+ chat_id = _send.peer_id_of(peer)
912
+ client = _client(ctx)
913
+
914
+ kwargs: dict[str, Any] = {}
915
+ if req.topic is not None:
916
+ kwargs["reply_to"] = req.topic
917
+ raw = [m async for m in client.iter_messages(peer, limit=limit, **kwargs) if m is not None]
918
+ messages = [message_to_model(m, chat_id=chat_id) for m in raw]
919
+
920
+ if req.increment_views and raw:
921
+ from telethon.tl.functions import messages as fn
922
+
923
+ await client(
924
+ fn.GetMessagesViewsRequest(peer=peer, id=[m.id for m in messages], increment=True)
925
+ )
926
+
927
+ marked_read = False
928
+ if not req.no_read:
929
+ top = max((m.id for m in messages), default=0)
930
+ await client.send_read_acknowledge(peer, max_id=top)
931
+ marked_read = True
932
+ ctx.emit("chat_read", {"chat_id": chat_id, "max_id": top})
933
+ return OpenResult(chat_id=chat_id, marked_read=marked_read, messages=messages)
934
+
935
+
936
+ SPEC_OPEN = OperationSpec(
937
+ id="chat.open",
938
+ request=OpenReq,
939
+ response=OpenResult,
940
+ impl=open_chat,
941
+ summary="Open a chat like a human: recent history AND a read receipt",
942
+ description=(
943
+ "SEMANTICS ARE FROZEN. The read receipt is visible to the other side "
944
+ "and irreversible; `--no-read` is the silent peek, and so is "
945
+ "`message list`."
946
+ ),
947
+ legacy_paths=("chat open",),
948
+ mutating=True,
949
+ rate_class="read",
950
+ columns=("chat_id", "marked_read"),
951
+ example={"chat_id": 777123, "marked_read": True, "messages": []},
952
+ example_args="chat open @alice",
953
+ covers=("dialogs.open-chat", "dialogs.peek-chat"),
954
+ tags=frozenset({"visible-to-others"}),
955
+ )
956
+
957
+
958
+ class CatchupReq(Request):
959
+ type: Annotated[
960
+ str | None, choice("user", "bot", "group", "channel", help="Filter by peer kind.")
961
+ ] = None
962
+ folder: Annotated[
963
+ str, opt("--folder", metavar="FOLDER", help="Restrict to a folder / archive / main.")
964
+ ] = "main"
965
+ limit_chats: Annotated[
966
+ int, opt("--limit-chats", metavar="N", help="Max unread chats to include.", ge=1)
967
+ ] = 20
968
+ per_chat: Annotated[
969
+ int, opt("--per-chat", metavar="N", help="Max messages per chat.", ge=1)
970
+ ] = 10
971
+
972
+
973
+ async def catchup(ctx: OpContext, req: CatchupReq) -> Catchup:
974
+ """What did I miss: every unread chat with its recent messages, read-only.
975
+
976
+ Emits no read receipts, which is what makes it safe to run at the start
977
+ of every session. A chat carrying only the manual unread mark is included
978
+ even though its `unread_count` is 0 — that mark is somebody saying "come
979
+ back to this".
980
+ """
981
+ dialogs = await _folder_members(ctx, req.folder, req.type)
982
+ unread = [d for d in dialogs if d.unread_count or d.unread_mark][: req.limit_chats]
983
+
984
+ client = _client(ctx)
985
+ chats: list[CatchupChat] = []
986
+ for dialog in unread:
987
+ peer = await _send.resolve(ctx, str(dialog.chat.id))
988
+ raw = [m async for m in client.iter_messages(peer, limit=req.per_chat) if m is not None]
989
+ chats.append(
990
+ CatchupChat(
991
+ id=dialog.chat.id,
992
+ chat=dialog.chat,
993
+ name=dialog.chat.title,
994
+ unread_count=dialog.unread_count,
995
+ unread_mark=dialog.unread_mark,
996
+ messages=[message_to_model(m, chat_id=dialog.chat.id) for m in raw],
997
+ )
998
+ )
999
+ return Catchup(chats=chats)
1000
+
1001
+
1002
+ SPEC_CATCHUP = OperationSpec(
1003
+ id="chat.catchup",
1004
+ request=CatchupReq,
1005
+ response=Catchup,
1006
+ impl=catchup,
1007
+ summary="What did I miss: every unread chat with its recent messages",
1008
+ description=(
1009
+ "SEMANTICS ARE FROZEN: read-only, emits no read receipts, and "
1010
+ "includes chats that carry only the manual unread mark."
1011
+ ),
1012
+ aliases=("catchup",),
1013
+ legacy_paths=("chat catchup", "catchup"),
1014
+ timeout_s=300,
1015
+ columns=("chats.id", "chats.name", "chats.unread_count"),
1016
+ example={"chats": [{"id": 777123, "name": "Alice", "unread_count": 3, "messages": []}]},
1017
+ example_args="chat catchup",
1018
+ covers=("dialogs.sync-updates",),
1019
+ coverage_note="The CLI-visible half; the getDifference loop is the daemon's (PR-4).",
1020
+ )
1021
+
1022
+
1023
+ # ---------------------------------------------------------------------------
1024
+ # chat read / unread
1025
+ # ---------------------------------------------------------------------------
1026
+
1027
+
1028
+ class ReadReq(Request):
1029
+ chat: Annotated[
1030
+ list[PeerRef],
1031
+ arg(0, metavar="CHAT", required=False, variadic=True, kind="peer", help="Chats to read."),
1032
+ ] = []
1033
+ up_to: Annotated[
1034
+ int | None, opt("--up-to", metavar="ID", kind="msg_id", help="Read up to this message id.")
1035
+ ] = None
1036
+ mentions: Annotated[bool, opt("--mentions", help="Also clear unread mentions.")] = False
1037
+ reactions: Annotated[bool, opt("--reactions", help="Also clear unread reactions.")] = False
1038
+ polls: Annotated[bool, opt("--polls", help="Also clear unread poll votes.")] = False
1039
+ topic: Annotated[
1040
+ int | None,
1041
+ opt("--topic", metavar="ID", kind="msg_id", help="Advance a comment thread instead."),
1042
+ ] = None
1043
+ saved_peer: Annotated[
1044
+ PeerRef | None,
1045
+ opt("--saved-peer", metavar="CHAT", kind="peer", help="A Saved-Messages sublist."),
1046
+ ] = None
1047
+ folder: Annotated[
1048
+ str | None,
1049
+ opt("--folder", metavar="FOLDER", help="Read every chat of a folder instead."),
1050
+ ] = None
1051
+ type: Annotated[
1052
+ str | None, choice("user", "bot", "group", "channel", help="With --folder: peer kind.")
1053
+ ] = None
1054
+ from_file: Annotated[
1055
+ str | None,
1056
+ opt("--from-file", metavar="PATH", kind="path", help="Peers from a file, '-' for stdin."),
1057
+ ] = None
1058
+ continue_on_error: Annotated[
1059
+ bool, opt("--continue-on-error", help="Keep going and report per-peer results.")
1060
+ ] = True
1061
+
1062
+
1063
+ async def read(ctx: OpContext, req: ReadReq) -> ReadChats:
1064
+ """Send a read receipt — for one chat, several, or a whole folder.
1065
+
1066
+ A read receipt is irreversible for the other side: `chat unread` restores
1067
+ only the owner's own badge. `--folder` is the CLI form of the GUI's
1068
+ multi-select, so it runs one chat at a time behind the session's flood
1069
+ limiter and reports per-peer results rather than stopping at the first
1070
+ failure.
1071
+ """
1072
+ from telethon.tl.functions import messages as fn
1073
+
1074
+ client = _client(ctx)
1075
+ targets = await _resolve_many(ctx, req.chat, from_file=req.from_file)
1076
+ if req.folder is not None:
1077
+ for dialog in await _folder_members(ctx, req.folder, req.type):
1078
+ peer = await _send.resolve(ctx, str(dialog.chat.id))
1079
+ targets.append((peer, dialog.chat.id))
1080
+ if not targets:
1081
+ raise UsageError("give a chat, --folder or --from-file", field="chat")
1082
+
1083
+ if req.topic is not None and len(targets) == 1:
1084
+ peer, chat_id = targets[0]
1085
+ await client(
1086
+ fn.ReadDiscussionRequest(peer=peer, msg_id=req.topic, read_max_id=req.up_to or 0)
1087
+ )
1088
+ ctx.emit("chat_read", {"chat_id": chat_id, "topic": req.topic})
1089
+ return ReadChats(read=True, chat_id=chat_id, results=[PeerResult(chat_id=chat_id, ok=True)])
1090
+
1091
+ if req.saved_peer is not None and len(targets) == 1:
1092
+ peer, chat_id = targets[0]
1093
+ origin = await _send.resolve(ctx, req.saved_peer)
1094
+ await client(
1095
+ fn.ReadSavedHistoryRequest(parent_peer=peer, peer=origin, max_id=req.up_to or 0)
1096
+ )
1097
+ ctx.emit("chat_read", {"chat_id": chat_id, "saved_peer": _send.peer_id_of(origin)})
1098
+ return ReadChats(read=True, chat_id=chat_id, results=[PeerResult(chat_id=chat_id, ok=True)])
1099
+
1100
+ results: list[PeerResult] = []
1101
+ mentions_read = reactions_read = 0
1102
+ limiter = getattr(ctx, "limiter", None)
1103
+ for index, (peer, chat_id) in enumerate(targets):
1104
+ if index and limiter is not None:
1105
+ await limiter.acquire("bulk")
1106
+ try:
1107
+ await client.send_read_acknowledge(peer, max_id=req.up_to or 0)
1108
+ if req.mentions:
1109
+ mentions_read += await _affected_loop(
1110
+ ctx, lambda offset, p=peer: fn.ReadMentionsRequest(peer=p)
1111
+ )
1112
+ if req.reactions:
1113
+ reactions_read += await _affected_loop(
1114
+ ctx, lambda offset, p=peer: fn.ReadReactionsRequest(peer=p)
1115
+ )
1116
+ if req.polls:
1117
+ await client(fn.ReadPollVotesRequest(peer=peer))
1118
+ results.append(PeerResult(chat_id=chat_id, ok=True))
1119
+ ctx.emit("chat_read", {"chat_id": chat_id, "max_id": req.up_to or 0})
1120
+ except Exception as exc:
1121
+ if not req.continue_on_error:
1122
+ raise
1123
+ results.append(PeerResult(chat_id=chat_id, ok=False, error=_error_text(exc)))
1124
+
1125
+ return ReadChats(
1126
+ read=any(r.ok for r in results),
1127
+ chat_id=results[0].chat_id if len(results) == 1 else None,
1128
+ results=results,
1129
+ mentions_read=mentions_read if req.mentions else None,
1130
+ reactions_read=reactions_read if req.reactions else None,
1131
+ polls_read=True if req.polls else None,
1132
+ )
1133
+
1134
+
1135
+ SPEC_READ = OperationSpec(
1136
+ id="chat.read",
1137
+ request=ReadReq,
1138
+ response=ReadChats,
1139
+ impl=read,
1140
+ summary="Send a read receipt for chats, a thread, or a whole folder",
1141
+ description=(
1142
+ "Irreversible for the other side. `readHistory` is namespace-split "
1143
+ "(channels.* for supergroups and channels) and Telethon picks the "
1144
+ "right one; the mention and reaction sweeps are looped until the "
1145
+ "server stops returning an offset."
1146
+ ),
1147
+ aliases=("chat.read-all",),
1148
+ mutating=True,
1149
+ idempotent=True,
1150
+ rate_class="bulk",
1151
+ timeout_s=300,
1152
+ columns=("read", "results"),
1153
+ example={"read": True, "results": [{"chat_id": 777123, "ok": True}]},
1154
+ example_args="chat read @alice",
1155
+ covers=("dialogs.mark-read", "dialogs.mark-read-all", "dialogs.read-discussion"),
1156
+ covers_partial=(
1157
+ "dialogs.bulk-chat-actions",
1158
+ "dialogs.monoforum-topics",
1159
+ "dialogs.saved-sublists",
1160
+ ),
1161
+ coverage_note=(
1162
+ "The read half of the bulk and saved-sublist surfaces; archiving in "
1163
+ "bulk is `chat archive` and listing sublists is `chat saved list`."
1164
+ ),
1165
+ tags=frozenset({"visible-to-others"}),
1166
+ )
1167
+
1168
+
1169
+ class UnreadReq(Request):
1170
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat to mark unread.")]
1171
+ clear: Annotated[bool, opt("--clear", help="Clear the mark instead of setting it.")] = False
1172
+ saved_peer: Annotated[
1173
+ PeerRef | None,
1174
+ opt("--saved-peer", metavar="CHAT", kind="peer", help="A monoforum topic instead."),
1175
+ ] = None
1176
+
1177
+
1178
+ async def unread(ctx: OpContext, req: UnreadReq) -> UnreadResult:
1179
+ """Mark a chat unread again — the undo for an accidental read receipt.
1180
+
1181
+ SEMANTICS ARE FROZEN: this sets Telegram's manual unread *flag*, not a
1182
+ numeric count, and it does not un-send the receipt the other side already
1183
+ got. Chats flagged this way come back with `unread_mark: true` and do
1184
+ appear in `chat list --unread`, `inbox` and `catchup`.
1185
+ """
1186
+ from telethon.tl import types
1187
+ from telethon.tl.functions import messages as fn
1188
+
1189
+ peer = await _send.resolve(ctx, req.chat)
1190
+ chat_id = _send.peer_id_of(peer)
1191
+ parent = await _send.resolve(ctx, req.saved_peer) if req.saved_peer is not None else None
1192
+ await _client(ctx)(
1193
+ fn.MarkDialogUnreadRequest(
1194
+ peer=types.InputDialogPeer(peer),
1195
+ unread=not req.clear or None,
1196
+ parent_peer=parent,
1197
+ )
1198
+ )
1199
+ ctx.emit("chat_unread", {"chat_id": chat_id, "unread": not req.clear})
1200
+ return UnreadResult(chat_id=chat_id, unread=not req.clear)
1201
+
1202
+
1203
+ SPEC_UNREAD = OperationSpec(
1204
+ id="chat.unread",
1205
+ request=UnreadReq,
1206
+ response=UnreadResult,
1207
+ impl=unread,
1208
+ summary="Mark a chat unread again — the undo for an accidental read receipt",
1209
+ description=(
1210
+ "Restores the badge the account owner sees. It cannot un-send the "
1211
+ "read receipt the other side already got; nothing can."
1212
+ ),
1213
+ legacy_paths=("chat unread",),
1214
+ mutating=True,
1215
+ idempotent=True,
1216
+ columns=("chat_id", "unread"),
1217
+ example={"unread": True, "chat_id": 777123},
1218
+ example_args="chat unread @alice",
1219
+ covers=(
1220
+ "dialogs.mark-unread",
1221
+ "dialogs.mark-unread-clear",
1222
+ "messages-core.chat-mark-unread",
1223
+ ),
1224
+ )
1225
+
1226
+
1227
+ # ---------------------------------------------------------------------------
1228
+ # chat get
1229
+ # ---------------------------------------------------------------------------
1230
+
1231
+
1232
+ class GetReq(Request):
1233
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat to describe.")]
1234
+ full: Annotated[
1235
+ bool, opt("--full", help="Also fetch getFullUser / getFullChat / getFullChannel.")
1236
+ ] = False
1237
+ dialog: Annotated[bool, opt("--dialog", help="Include the dialog record.")] = True
1238
+ refresh: Annotated[bool, opt("--refresh", help="Bypass the 60 s server-side *Full cache.")] = (
1239
+ False
1240
+ )
1241
+ field: Annotated[
1242
+ str | None, opt("--field", metavar="NAME", help="Emit one field only (scripting).")
1243
+ ] = None
1244
+
1245
+
1246
+ async def get(ctx: OpContext, req: GetReq) -> ChatInfo:
1247
+ """Everything one chat is: the peer, its dialog row, and optionally *Full.
1248
+
1249
+ `--full` is opt-in because `getFullChannel` is a second round trip that a
1250
+ caller listing thirty chats does not want, and because the server caches
1251
+ it for about a minute anyway.
1252
+ """
1253
+ from telethon.tl import types
1254
+ from telethon.tl.functions import messages as fn
1255
+
1256
+ client = _client(ctx)
1257
+ peer = await _send.resolve(ctx, req.chat)
1258
+ chat_id = _send.peer_id_of(peer)
1259
+ entity = await client.get_entity(peer)
1260
+ shape = entity_to_peer(entity)
1261
+
1262
+ info = ChatInfo(
1263
+ id=shape.id,
1264
+ raw_id=shape.raw_id,
1265
+ type=shape.kind,
1266
+ title=shape.title,
1267
+ name=shape.title,
1268
+ username=shape.username,
1269
+ usernames=shape.usernames,
1270
+ restricted=bool(getattr(entity, "restricted", False)) or None,
1271
+ restriction_reason=[
1272
+ str(getattr(reason, "text", "") or "")
1273
+ for reason in (getattr(entity, "restriction_reason", None) or [])
1274
+ ],
1275
+ left=bool(getattr(entity, "left", False)) or None,
1276
+ creator=bool(getattr(entity, "creator", False)) or None,
1277
+ forum=bool(getattr(entity, "forum", False)) or None,
1278
+ gigagroup=bool(getattr(entity, "gigagroup", False)) or None,
1279
+ join_to_send=getattr(entity, "join_to_send", None),
1280
+ join_request=getattr(entity, "join_request", None),
1281
+ noforwards=getattr(entity, "noforwards", None),
1282
+ participants_count=getattr(entity, "participants_count", None),
1283
+ level=getattr(entity, "level", None),
1284
+ linked_monoforum_id=getattr(entity, "linked_monoforum_id", None),
1285
+ )
1286
+
1287
+ if req.dialog:
1288
+ result = await client(fn.GetPeerDialogsRequest(peers=[types.InputDialogPeer(peer)]))
1289
+ for row in getattr(result, "dialogs", None) or []:
1290
+ _fill_dialog(info, row)
1291
+
1292
+ if req.full:
1293
+ await _fill_full(ctx, info, peer, entity)
1294
+ if req.field:
1295
+ import msgspec
1296
+
1297
+ data = msgspec.to_builtins(info)
1298
+ if req.field not in data:
1299
+ raise UsageError(f"chat get has no field {req.field!r}", field="field")
1300
+ if chat_id and not info.id: # pragma: no cover - defensive
1301
+ info.id = chat_id
1302
+ return info
1303
+
1304
+
1305
+ def _fill_dialog(info: ChatInfo, row: Any) -> None:
1306
+ from tlgr.ops.draft import draft_model
1307
+
1308
+ info.folder_id = int(getattr(row, "folder_id", 0) or 0)
1309
+ info.pinned = bool(getattr(row, "pinned", False))
1310
+ info.unread = int(getattr(row, "unread_count", 0) or 0)
1311
+ info.unread_mentions = int(getattr(row, "unread_mentions_count", 0) or 0)
1312
+ info.unread_reactions = int(getattr(row, "unread_reactions_count", 0) or 0)
1313
+ info.unread_mark = bool(getattr(row, "unread_mark", False))
1314
+ info.read_inbox_max_id = int(getattr(row, "read_inbox_max_id", 0) or 0)
1315
+ info.read_outbox_max_id = int(getattr(row, "read_outbox_max_id", 0) or 0)
1316
+ info.top_message_id = int(getattr(row, "top_message", 0) or 0) or None
1317
+ info.notify_settings = notify_settings(getattr(row, "notify_settings", None))
1318
+ info.ttl_period = getattr(row, "ttl_period", None)
1319
+ info.view_forum_as_messages = getattr(row, "view_forum_as_messages", None)
1320
+ draft = getattr(row, "draft", None)
1321
+ if draft is not None and type(draft).__name__ != "DraftMessageEmpty":
1322
+ info.draft = draft_model(draft, chat_id=info.id)
1323
+
1324
+
1325
+ async def _fill_full(ctx: OpContext, info: ChatInfo, peer: Any, entity: Any) -> None:
1326
+ """The `*Full` half: three different calls, one flat answer."""
1327
+ from telethon.tl.functions import channels as cfn
1328
+ from telethon.tl.functions import messages as mfn
1329
+ from telethon.tl.functions import users as ufn
1330
+
1331
+ client = _client(ctx)
1332
+ kind = type(entity).__name__
1333
+ if kind == "User":
1334
+ result = await client(ufn.GetFullUserRequest(peer))
1335
+ full = getattr(result, "full_user", None)
1336
+ info.about = getattr(full, "about", None)
1337
+ info.blocked = getattr(full, "blocked", None)
1338
+ info.blocked_my_stories_from = getattr(full, "blocked_my_stories_from", None)
1339
+ info.common_chats_count = getattr(full, "common_chats_count", None)
1340
+ info.personal_channel_id = getattr(full, "personal_channel_id", None)
1341
+ info.ttl_period = getattr(full, "ttl_period", info.ttl_period)
1342
+ info.settings = action_bar(getattr(full, "settings", None), chat_id=info.id)
1343
+ info.theme = chat_theme(getattr(full, "theme_emoticon", None) and _emoji_theme(full))
1344
+ info.wallpaper = wallpaper(getattr(full, "wallpaper", None))
1345
+ info.translations_disabled = getattr(full, "translations_disabled", None)
1346
+ return
1347
+
1348
+ if kind == "Chat":
1349
+ result = await client(mfn.GetFullChatRequest(chat_id=getattr(entity, "id", 0)))
1350
+ else:
1351
+ result = await client(cfn.GetFullChannelRequest(channel=_input_channel(peer)))
1352
+ full = getattr(result, "full_chat", None)
1353
+ info.about = getattr(full, "about", None)
1354
+ info.participants_count = getattr(full, "participants_count", info.participants_count)
1355
+ info.admins_count = getattr(full, "admins_count", None)
1356
+ info.kicked_count = getattr(full, "kicked_count", None)
1357
+ info.banned_count = getattr(full, "banned_count", None)
1358
+ info.online_count = getattr(full, "online_count", None)
1359
+ info.requests_pending = getattr(full, "requests_pending", None)
1360
+ info.recent_requesters = list(getattr(full, "recent_requesters", None) or [])
1361
+ info.slowmode_seconds = getattr(full, "slowmode_seconds", None)
1362
+ info.hidden_prehistory = getattr(full, "hidden_prehistory", None)
1363
+ info.participants_hidden = getattr(full, "participants_hidden", None)
1364
+ info.antispam = getattr(full, "antispam", None)
1365
+ info.linked_chat_id = peer_id_of_channel(getattr(full, "linked_chat_id", None))
1366
+ info.stats_dc = getattr(full, "stats_dc", None)
1367
+ info.can_view_stats = getattr(full, "can_view_stats", None)
1368
+ info.can_view_participants = getattr(full, "can_view_participants", None)
1369
+ info.can_set_stickers = getattr(full, "can_set_stickers", None)
1370
+ info.can_set_location = getattr(full, "can_set_location", None)
1371
+ info.can_delete_channel = getattr(full, "can_delete_channel", None)
1372
+ info.can_view_revenue = getattr(full, "can_view_revenue", None)
1373
+ info.can_view_stars_revenue = getattr(full, "can_view_stars_revenue", None)
1374
+ info.paid_reactions_available = getattr(full, "paid_reactions_available", None)
1375
+ info.has_welcome_messages = getattr(full, "stargifts_available", None)
1376
+ info.boosts_applied = getattr(full, "boosts_applied", None)
1377
+ info.ttl_period = getattr(full, "ttl_period", info.ttl_period)
1378
+ info.translations_disabled = getattr(full, "translations_disabled", None)
1379
+ info.theme = chat_theme(_emoji_theme(full))
1380
+ info.wallpaper = wallpaper(getattr(full, "wallpaper", None))
1381
+ info.settings = action_bar(getattr(full, "settings", None), chat_id=info.id)
1382
+ info.default_send_as = peer_id_of(getattr(full, "default_send_as", None))
1383
+ sticker_set = getattr(full, "stickerset", None)
1384
+ info.sticker_set = getattr(sticker_set, "short_name", None)
1385
+ emoji_set = getattr(full, "emojiset", None)
1386
+ info.emoji_set = getattr(emoji_set, "short_name", None)
1387
+ reactions = getattr(full, "available_reactions", None)
1388
+ info.available_reactions = [
1389
+ str(getattr(r, "emoticon", "") or getattr(r, "document_id", ""))
1390
+ for r in (getattr(reactions, "reactions", None) or [])
1391
+ ] or None
1392
+ invite = getattr(full, "exported_invite", None)
1393
+ info.exported_invite = getattr(invite, "link", None)
1394
+ info.pending_suggestions = list(getattr(full, "pending_suggestions", None) or [])
1395
+ location = getattr(full, "location", None)
1396
+ info.location = getattr(location, "address", None)
1397
+
1398
+
1399
+ def _emoji_theme(full: Any) -> Any:
1400
+ """`theme_emoticon` as something `chat_theme()` can read."""
1401
+ emoticon = getattr(full, "theme_emoticon", None)
1402
+ if not emoticon:
1403
+ return None
1404
+ return type("_Theme", (), {"emoticon": emoticon, "title": emoticon})()
1405
+
1406
+
1407
+ def peer_id_of_channel(raw_id: Any) -> int | None:
1408
+ """A bare `linked_chat_id` as the marked id every other field uses."""
1409
+ from tlgr.ops._serialize import marked_id
1410
+
1411
+ if raw_id is None:
1412
+ return None
1413
+ return marked_id(int(raw_id), "supergroup")
1414
+
1415
+
1416
+ SPEC_GET = OperationSpec(
1417
+ id="chat.get",
1418
+ request=GetReq,
1419
+ response=ChatInfo,
1420
+ impl=get,
1421
+ summary="Full info for one chat: dialog record, settings, notify, ttl, theme",
1422
+ description=(
1423
+ "`--full` adds users.getFullUser / messages.getFullChat / "
1424
+ "channels.getFullChannel, which the server caches for about a minute. "
1425
+ "`read_outbox_max_id` is here; the per-member reader list is "
1426
+ "`message seen`."
1427
+ ),
1428
+ legacy_paths=("chat get",),
1429
+ columns=("id", "type", "title", "username"),
1430
+ example={"id": 777123, "type": "user", "title": "Alice", "name": "Alice", "unread": 3},
1431
+ example_args="chat get @alice --full",
1432
+ covers=(
1433
+ "dialogs.chat-full-settings",
1434
+ "dialogs.get-peer-dialog",
1435
+ "dialogs.online-count",
1436
+ "dialogs.read-receipts-outbox",
1437
+ "groups-channels-admin.bulk-resolve-chats",
1438
+ "groups-channels-admin.get-full-info",
1439
+ "media.content-protection",
1440
+ "updates.presence-group-online-count",
1441
+ ),
1442
+ covers_partial=("groups-channels-admin.pending-suggestions",),
1443
+ coverage_note="Pending suggestions are reported here; dismissing one is PR-7's.",
1444
+ )
1445
+
1446
+
1447
+ # ---------------------------------------------------------------------------
1448
+ # chat archive / autoarchive
1449
+ # ---------------------------------------------------------------------------
1450
+
1451
+
1452
+ class ArchiveReq(Request):
1453
+ chat: Annotated[
1454
+ list[PeerRef],
1455
+ arg(0, metavar="CHAT", variadic=True, kind="peer", help="Chats to archive."),
1456
+ ] = []
1457
+ undo: Annotated[bool, opt("--undo", help="Unarchive (folder 0) instead.")] = False
1458
+ dismiss_bar: Annotated[
1459
+ bool, opt("--dismiss-bar", help="Also hide the 'auto-archived' action bar.")
1460
+ ] = False
1461
+ from_file: Annotated[
1462
+ str | None,
1463
+ opt("--from-file", metavar="PATH", kind="path", help="Peers from a file, '-' for stdin."),
1464
+ ] = None
1465
+
1466
+
1467
+ async def archive(ctx: OpContext, req: ArchiveReq) -> ArchiveResult:
1468
+ """Move chats into the archive, or back out of it.
1469
+
1470
+ `folders.editPeerFolders` takes a *vector*, which makes this the one
1471
+ genuinely batched chat action Telegram offers: twenty peers cost one RPC.
1472
+ `folder_id` is only ever 0 or 1; there is no third peer-folder.
1473
+ """
1474
+ from telethon.tl import types
1475
+ from telethon.tl.functions import folders as fn
1476
+ from telethon.tl.functions import messages as mfn
1477
+
1478
+ targets = await _resolve_many(ctx, req.chat, from_file=req.from_file)
1479
+ if not targets:
1480
+ raise UsageError("give at least one chat", field="chat")
1481
+ folder_id = FOLDER_MAIN if req.undo else FOLDER_ARCHIVE
1482
+
1483
+ await _client(ctx)(
1484
+ fn.EditPeerFoldersRequest(
1485
+ folder_peers=[
1486
+ types.InputFolderPeer(peer=peer, folder_id=folder_id) for peer, _ in targets
1487
+ ]
1488
+ )
1489
+ )
1490
+ bar_hidden = False
1491
+ if req.dismiss_bar:
1492
+ for peer, _ in targets:
1493
+ await _client(ctx)(mfn.HidePeerSettingsBarRequest(peer=peer))
1494
+ bar_hidden = True
1495
+
1496
+ ids = [chat_id for _, chat_id in targets]
1497
+ ctx.emit("chat_archive", {"chat_ids": ids, "archived": not req.undo})
1498
+ return ArchiveResult(
1499
+ archived=not req.undo,
1500
+ chat_id=ids[0] if len(ids) == 1 else None,
1501
+ chat_ids=ids,
1502
+ bar_hidden=bar_hidden,
1503
+ )
1504
+
1505
+
1506
+ SPEC_ARCHIVE = OperationSpec(
1507
+ id="chat.archive",
1508
+ request=ArchiveReq,
1509
+ response=ArchiveResult,
1510
+ impl=archive,
1511
+ summary="Move chats to the archive, or back out of it",
1512
+ description=(
1513
+ "One RPC for any number of peers, because `folders.editPeerFolders` "
1514
+ "is the only batched chat action Telegram has. `--undo` is the half "
1515
+ "v1 never had."
1516
+ ),
1517
+ aliases=("chat.unarchive",),
1518
+ legacy_paths=("chat archive",),
1519
+ mutating=True,
1520
+ idempotent=True,
1521
+ rate_class="bulk",
1522
+ columns=("archived", "chat_ids"),
1523
+ example={"archived": True, "chat_id": 777123, "chat_ids": [777123]},
1524
+ example_args="chat archive @alice",
1525
+ covers=(
1526
+ "dialogs.archive",
1527
+ "dialogs.bulk-chat-actions",
1528
+ "dialogs.unarchive",
1529
+ "dialogs.unarchive-autoarchived",
1530
+ ),
1531
+ )
1532
+
1533
+
1534
+ class AutoArchiveReq(Request):
1535
+ auto: Annotated[
1536
+ str | None, choice("on", "off", help="Auto-archive and mute new non-contacts.")
1537
+ ] = None
1538
+ keep_unmuted: Annotated[
1539
+ str | None, choice("on", "off", help="Keep unmuted chats in the archive.")
1540
+ ] = None
1541
+ keep_folders: Annotated[
1542
+ str | None, choice("on", "off", help="Keep archived chats that belong to a folder.")
1543
+ ] = None
1544
+
1545
+
1546
+ async def autoarchive_set(ctx: OpContext, req: AutoArchiveReq) -> ArchiveSettings:
1547
+ """The chat-list archive rules, read-modify-written.
1548
+
1549
+ `setGlobalPrivacySettings` replaces the whole constructor, and the other
1550
+ flags in it belong to `privacy global`: sending only the archive fields
1551
+ would quietly reset somebody's read-marks and paid-message settings.
1552
+ """
1553
+ from telethon.tl.functions import account as fn
1554
+
1555
+ client = _client(ctx)
1556
+ current = await client(fn.GetGlobalPrivacySettingsRequest())
1557
+ wanted = {
1558
+ "archive_and_mute_new_noncontact_peers": getattr(
1559
+ current, "archive_and_mute_new_noncontact_peers", None
1560
+ ),
1561
+ "keep_archived_unmuted": getattr(current, "keep_archived_unmuted", None),
1562
+ "keep_archived_folders": getattr(current, "keep_archived_folders", None),
1563
+ }
1564
+ asked = {
1565
+ "archive_and_mute_new_noncontact_peers": req.auto,
1566
+ "keep_archived_unmuted": req.keep_unmuted,
1567
+ "keep_archived_folders": req.keep_folders,
1568
+ }
1569
+ changed = False
1570
+ for name, value in asked.items():
1571
+ if value is None:
1572
+ continue
1573
+ new = value == "on"
1574
+ if bool(wanted[name]) != new:
1575
+ changed = True
1576
+ wanted[name] = new or None
1577
+
1578
+ if not any(v is not None for v in asked.values()):
1579
+ return _archive_settings(current)
1580
+ if not changed:
1581
+ _already(ctx)
1582
+ return _archive_settings(current)
1583
+
1584
+ for name, value in wanted.items():
1585
+ setattr(current, name, value)
1586
+ await client(fn.SetGlobalPrivacySettingsRequest(settings=current))
1587
+ ctx.emit("chat_autoarchive", {k: bool(v) for k, v in wanted.items()})
1588
+ return _archive_settings(current)
1589
+
1590
+
1591
+ def _archive_settings(raw: Any) -> ArchiveSettings:
1592
+ return ArchiveSettings(
1593
+ archive_and_mute_new_noncontact_peers=bool(
1594
+ getattr(raw, "archive_and_mute_new_noncontact_peers", False)
1595
+ ),
1596
+ keep_archived_unmuted=bool(getattr(raw, "keep_archived_unmuted", False)),
1597
+ keep_archived_folders=bool(getattr(raw, "keep_archived_folders", False)),
1598
+ )
1599
+
1600
+
1601
+ SPEC_AUTOARCHIVE_SET = OperationSpec(
1602
+ id="chat.autoarchive.set",
1603
+ request=AutoArchiveReq,
1604
+ response=ArchiveSettings,
1605
+ impl=autoarchive_set,
1606
+ summary="Chat-list archive rules for new and archived chats",
1607
+ description=(
1608
+ "Auto-archiving new non-contacts needs Premium unless the app config "
1609
+ "says otherwise; the server refuses it otherwise."
1610
+ ),
1611
+ mutating=True,
1612
+ idempotent=True,
1613
+ columns=("archive_and_mute_new_noncontact_peers", "keep_archived_unmuted"),
1614
+ example={"archive_and_mute_new_noncontact_peers": True, "keep_archived_unmuted": False},
1615
+ example_args="chat autoarchive set --auto on",
1616
+ covers=("dialogs.archive-settings",),
1617
+ )
1618
+
1619
+
1620
+ # ---------------------------------------------------------------------------
1621
+ # chat mute
1622
+ # ---------------------------------------------------------------------------
1623
+
1624
+
1625
+ class MuteReq(Request):
1626
+ chat: Annotated[
1627
+ list[PeerRef],
1628
+ arg(0, metavar="CHAT", required=False, variadic=True, kind="peer", help="Chats to mute."),
1629
+ ] = []
1630
+ for_: Annotated[
1631
+ int | None,
1632
+ opt("--for", metavar="DURATION", kind="duration", help="Mute for 1h | 8h | 2d."),
1633
+ ] = None
1634
+ until: Annotated[
1635
+ str | None,
1636
+ opt("--until", metavar="TS", kind="datetime", help="Mute until an absolute time."),
1637
+ ] = None
1638
+ off: Annotated[bool, opt("--off", help="Unmute, keeping the other notify fields.")] = False
1639
+ stories: Annotated[bool, opt("--stories", help="Mute the peer's stories instead.")] = False
1640
+ folder: Annotated[
1641
+ str | None, opt("--folder", metavar="FOLDER", help="Apply to every chat of a folder.")
1642
+ ] = None
1643
+ topic: Annotated[
1644
+ int | None, opt("--topic", metavar="ID", kind="msg_id", help="A forum topic.")
1645
+ ] = None
1646
+
1647
+
1648
+ async def mute(ctx: OpContext, req: MuteReq) -> MuteResult:
1649
+ """Mute or unmute chats, for a duration, until a time, or forever.
1650
+
1651
+ `mute_until` is an **absolute unix timestamp**. v1 computed it from the
1652
+ event loop's monotonic clock, so every timed mute resolved to 1970 and
1653
+ did nothing at all (COR-01); it is computed from the wall clock here, and
1654
+ the test asserts the value that reaches the request.
1655
+ """
1656
+ from telethon.tl import types
1657
+ from telethon.tl.functions import account as fn
1658
+
1659
+ targets = await _resolve_many(ctx, req.chat)
1660
+ if req.folder is not None:
1661
+ for dialog in await _folder_members(ctx, req.folder):
1662
+ peer = await _send.resolve(ctx, str(dialog.chat.id))
1663
+ targets.append((peer, dialog.chat.id))
1664
+ if not targets:
1665
+ raise UsageError("give a chat, or --folder", field="chat")
1666
+
1667
+ until = _mute_until(req)
1668
+ settings_kwargs: dict[str, Any] = {}
1669
+ if req.stories:
1670
+ settings_kwargs["stories_muted"] = not req.off
1671
+ else:
1672
+ settings_kwargs["mute_until"] = until
1673
+
1674
+ client = _client(ctx)
1675
+ limiter = getattr(ctx, "limiter", None)
1676
+ results: list[PeerResult] = []
1677
+ for index, (peer, chat_id) in enumerate(targets):
1678
+ if index and limiter is not None:
1679
+ # One RPC per chat: Telegram has no batched updateNotifySettings.
1680
+ await limiter.acquire("bulk")
1681
+ notify_peer = (
1682
+ types.InputNotifyForumTopic(peer=peer, top_msg_id=req.topic)
1683
+ if req.topic is not None
1684
+ else types.InputNotifyPeer(peer=peer)
1685
+ )
1686
+ try:
1687
+ await client(
1688
+ fn.UpdateNotifySettingsRequest(
1689
+ peer=notify_peer,
1690
+ settings=types.InputPeerNotifySettings(**settings_kwargs),
1691
+ )
1692
+ )
1693
+ results.append(PeerResult(chat_id=chat_id, ok=True))
1694
+ except Exception as exc:
1695
+ if len(targets) == 1:
1696
+ raise
1697
+ results.append(PeerResult(chat_id=chat_id, ok=False, error=_error_text(exc)))
1698
+
1699
+ ids = [r.chat_id for r in results if r.ok]
1700
+ ctx.emit("chat_mute", {"chat_ids": ids, "muted": not req.off})
1701
+ return MuteResult(
1702
+ muted=not req.off,
1703
+ chat_id=ids[0] if len(ids) == 1 else None,
1704
+ chat_ids=ids,
1705
+ mute_until=fmt_dt(until) if until and not req.off else None,
1706
+ mute_until_unix=to_unix(until) if until and not req.off else None,
1707
+ stories=req.stories,
1708
+ results=results if len(results) > 1 else [],
1709
+ )
1710
+
1711
+
1712
+ def _mute_until(req: MuteReq) -> datetime | None:
1713
+ """The absolute moment the mute ends, from `--off`, `--for` or `--until`."""
1714
+ if req.off:
1715
+ return datetime.fromtimestamp(0, tz=timezone.utc)
1716
+ if req.until:
1717
+ parsed = parse_dt(req.until)
1718
+ if parsed is None:
1719
+ raise UsageError(f"--until: cannot read {req.until!r} as a time", field="until")
1720
+ return parsed
1721
+ if req.for_:
1722
+ # The CLI's `duration` type has already turned `8h` into seconds; a
1723
+ # caller talking to the daemon directly sends the seconds itself.
1724
+ return datetime.now(timezone.utc) + timedelta(seconds=int(req.for_))
1725
+ return datetime.fromtimestamp(MUTE_FOREVER, tz=timezone.utc)
1726
+
1727
+
1728
+ SPEC_MUTE = OperationSpec(
1729
+ id="chat.mute",
1730
+ request=MuteReq,
1731
+ response=MuteResult,
1732
+ impl=mute,
1733
+ summary="Mute or unmute chats, for a duration or forever",
1734
+ description=(
1735
+ "`mute_until` is absolute wall-clock time (COR-01). "
1736
+ "`inputPeerNotifySettings` is sparse — only the field being changed "
1737
+ "is sent, so the rest keeps inheriting the scope default. `--folder` "
1738
+ "costs one RPC per chat because Telegram has no batched form."
1739
+ ),
1740
+ aliases=("chat.unmute",),
1741
+ legacy_paths=("chat mute",),
1742
+ mutating=True,
1743
+ idempotent=True,
1744
+ rate_class="bulk",
1745
+ timeout_s=300,
1746
+ columns=("muted", "chat_ids", "mute_until"),
1747
+ example={"muted": True, "chat_id": 777123, "chat_ids": [777123]},
1748
+ example_args="chat mute @alice --for 8h",
1749
+ covers=(
1750
+ "dialogs.mute-folder",
1751
+ "dialogs.mute-for-duration",
1752
+ "dialogs.mute-forever",
1753
+ "dialogs.unmute",
1754
+ "groups-channels-admin.chat-notify-settings",
1755
+ ),
1756
+ )
1757
+
1758
+
1759
+ # ---------------------------------------------------------------------------
1760
+ # chat pin
1761
+ # ---------------------------------------------------------------------------
1762
+
1763
+
1764
+ class PinReq(Request):
1765
+ chat: Annotated[
1766
+ list[PeerRef], arg(0, metavar="CHAT", variadic=True, kind="peer", help="Chats to pin.")
1767
+ ] = []
1768
+ unpin: Annotated[bool, opt("--unpin", help="Remove the pin instead.")] = False
1769
+ folder: Annotated[
1770
+ str, opt("--folder", metavar="FOLDER", help="Pin inside a chat folder, or main|archive.")
1771
+ ] = "main"
1772
+ order: Annotated[
1773
+ bool, opt("--order", help="Treat the arguments as the complete pinned order.")
1774
+ ] = False
1775
+ saved_peer: Annotated[
1776
+ PeerRef | None,
1777
+ opt("--saved-peer", metavar="CHAT", kind="peer", help="Pin a Saved-Messages sublist."),
1778
+ ] = None
1779
+
1780
+
1781
+ async def pin(ctx: OpContext, req: PinReq) -> PinnedDialogs:
1782
+ """Pin or unpin dialogs, or rewrite the pinned order outright.
1783
+
1784
+ Pinning *inside a chat folder* is a different operation from pinning in
1785
+ the chat list: it edits the folder's `pinned_peers` rather than calling
1786
+ `toggleDialogPin`, and the folder's pinned and excluded lists must stay
1787
+ disjoint or the server rejects the filter.
1788
+ """
1789
+ from telethon.tl import types
1790
+ from telethon.tl.functions import messages as fn
1791
+
1792
+ client = _client(ctx)
1793
+ targets = await _resolve_many(ctx, req.chat)
1794
+ if not targets:
1795
+ raise UsageError("give at least one chat", field="chat")
1796
+
1797
+ if req.saved_peer is not None:
1798
+ origin = await _send.resolve(ctx, req.saved_peer)
1799
+ await client(
1800
+ fn.ToggleSavedDialogPinRequest(
1801
+ peer=types.InputDialogPeer(origin), pinned=not req.unpin or None
1802
+ )
1803
+ )
1804
+ chat_id = _send.peer_id_of(origin)
1805
+ return PinnedDialogs(pinned=not req.unpin, chat_id=chat_id, chat_ids=[chat_id])
1806
+
1807
+ chat_filter = await _read_filter(ctx, req.folder)
1808
+ if chat_filter is not None:
1809
+ return await _pin_in_folder(ctx, req, chat_filter, targets)
1810
+
1811
+ folder_id = _peer_folder(req.folder) or FOLDER_MAIN
1812
+ if req.order:
1813
+ await client(
1814
+ fn.ReorderPinnedDialogsRequest(
1815
+ folder_id=folder_id,
1816
+ order=[types.InputDialogPeer(peer) for peer, _ in targets],
1817
+ force=True,
1818
+ )
1819
+ )
1820
+ ids = [chat_id for _, chat_id in targets]
1821
+ ctx.emit("chat_pin", {"chat_ids": ids, "order": True})
1822
+ return PinnedDialogs(pinned=True, chat_ids=ids, folder=req.folder, order=ids)
1823
+
1824
+ for peer, _ in targets:
1825
+ await client(
1826
+ fn.ToggleDialogPinRequest(
1827
+ peer=types.InputDialogPeer(peer), pinned=not req.unpin or None
1828
+ )
1829
+ )
1830
+ ids = [chat_id for _, chat_id in targets]
1831
+ ctx.emit("chat_pin", {"chat_ids": ids, "pinned": not req.unpin})
1832
+ return PinnedDialogs(
1833
+ pinned=not req.unpin,
1834
+ chat_id=ids[0] if len(ids) == 1 else None,
1835
+ chat_ids=ids,
1836
+ folder=req.folder,
1837
+ )
1838
+
1839
+
1840
+ async def _pin_in_folder(
1841
+ ctx: OpContext, req: PinReq, chat_filter: Any, targets: list[tuple[Any, int]]
1842
+ ) -> PinnedDialogs:
1843
+ from tlgr.ops.folder import folder_pinned_write
1844
+
1845
+ ids = [chat_id for _, chat_id in targets]
1846
+ await folder_pinned_write(
1847
+ ctx, chat_filter, [peer for peer, _ in targets], unpin=req.unpin, order=req.order
1848
+ )
1849
+ ctx.emit("chat_pin", {"chat_ids": ids, "folder": req.folder})
1850
+ return PinnedDialogs(
1851
+ pinned=not req.unpin,
1852
+ chat_id=ids[0] if len(ids) == 1 else None,
1853
+ chat_ids=ids,
1854
+ folder=req.folder,
1855
+ order=ids if req.order else [],
1856
+ )
1857
+
1858
+
1859
+ SPEC_PIN = OperationSpec(
1860
+ id="chat.pin",
1861
+ request=PinReq,
1862
+ response=PinnedDialogs,
1863
+ impl=pin,
1864
+ summary="Pin or unpin dialogs, or rewrite the whole pinned order",
1865
+ description=(
1866
+ "`--folder <name>` pins inside a chat folder, which is a filter edit "
1867
+ "rather than `toggleDialogPin`. PINNED_DIALOGS_TOO_MUCH is the "
1868
+ "server's answer when the pinned limit is reached."
1869
+ ),
1870
+ aliases=("chat.unpin", "chat.pin-order"),
1871
+ mutating=True,
1872
+ idempotent=True,
1873
+ rate_class="bulk",
1874
+ columns=("pinned", "chat_ids", "folder"),
1875
+ example={"pinned": True, "chat_id": 777123, "chat_ids": [777123], "folder": "main"},
1876
+ example_args="chat pin @alice",
1877
+ covers=("dialogs.pin", "dialogs.pin-in-folder", "dialogs.pin-reorder", "dialogs.unpin"),
1878
+ )
1879
+
1880
+
1881
+ # ---------------------------------------------------------------------------
1882
+ # chat leave / delete / clear
1883
+ # ---------------------------------------------------------------------------
1884
+
1885
+
1886
+ class LeaveReq(Request):
1887
+ chat: Annotated[
1888
+ list[PeerRef],
1889
+ arg(0, metavar="CHAT", required=False, variadic=True, kind="peer", help="Chats to leave."),
1890
+ ] = []
1891
+ delete_history: Annotated[
1892
+ bool, opt("--delete-history", help="Also delete my copy of the history.")
1893
+ ] = False
1894
+ remove_from_folders: Annotated[
1895
+ bool, opt("--remove-from-folders", help="Strip the peer from every chat folder.")
1896
+ ] = False
1897
+ common_with: Annotated[
1898
+ PeerRef | None,
1899
+ opt("--common-with", metavar="USER", kind="user", help="Leave every group shared with."),
1900
+ ] = None
1901
+
1902
+
1903
+ async def leave(ctx: OpContext, req: LeaveReq) -> LeaveResult:
1904
+ """Leave groups and channels, optionally cleaning up after yourself.
1905
+
1906
+ A basic group's creator is asked about first: `getFutureChatCreatorAfterLeave`
1907
+ names who inherits it, and reporting that is the difference between
1908
+ leaving and abandoning.
1909
+ """
1910
+ from telethon.tl.functions import channels as cfn
1911
+ from telethon.tl.functions import messages as fn
1912
+
1913
+ client = _client(ctx)
1914
+ targets = await _resolve_many(ctx, req.chat)
1915
+ if req.common_with is not None:
1916
+ user = await _send.resolve(ctx, req.common_with)
1917
+ common = await client(fn.GetCommonChatsRequest(user_id=user, max_id=0, limit=100))
1918
+ for chat in getattr(common, "chats", None) or []:
1919
+ peer = await _send.resolve(ctx, str(_marked(chat)))
1920
+ targets.append((peer, _marked(chat)))
1921
+ if not targets:
1922
+ raise UsageError("give a chat, or --common-with", field="chat")
1923
+
1924
+ left: list[int] = []
1925
+ errors: list[PeerResult] = []
1926
+ successor: int | None = None
1927
+ for peer, chat_id in targets:
1928
+ try:
1929
+ if _is_channel(peer):
1930
+ await client(cfn.LeaveChannelRequest(channel=_input_channel(peer)))
1931
+ else:
1932
+ successor = await _basic_group_successor(ctx, peer) or successor
1933
+ await client(fn.DeleteChatUserRequest(chat_id=_raw_chat_id(peer), user_id="me"))
1934
+ if req.delete_history:
1935
+ await _affected_loop(
1936
+ ctx,
1937
+ lambda offset, p=peer: fn.DeleteHistoryRequest(peer=p, max_id=0, revoke=False),
1938
+ )
1939
+ left.append(chat_id)
1940
+ ctx.emit("chat_leave", {"chat_id": chat_id})
1941
+ except Exception as exc:
1942
+ errors.append(PeerResult(chat_id=chat_id, ok=False, error=_error_text(exc)))
1943
+
1944
+ if req.remove_from_folders and left:
1945
+ await _strip_from_folders(ctx, left)
1946
+
1947
+ return LeaveResult(
1948
+ left=bool(left),
1949
+ chat_id=left[0] if len(left) == 1 else None,
1950
+ chat_ids=left,
1951
+ errors=errors,
1952
+ successor=successor,
1953
+ )
1954
+
1955
+
1956
+ def _marked(entity: Any) -> int:
1957
+ from telethon import utils
1958
+
1959
+ return int(utils.get_peer_id(entity))
1960
+
1961
+
1962
+ def _raw_chat_id(peer: Any) -> int:
1963
+ value = getattr(peer, "chat_id", None)
1964
+ if value is None:
1965
+ raise UsageError("this is not a basic group", field="chat")
1966
+ return int(value)
1967
+
1968
+
1969
+ async def _basic_group_successor(ctx: OpContext, peer: Any) -> int | None:
1970
+ """Who inherits a basic group when its creator leaves, if anyone."""
1971
+ from telethon.tl.functions import messages as fn
1972
+
1973
+ try:
1974
+ result = await _client(ctx)(fn.GetFutureChatCreatorAfterLeaveRequest(peer=peer))
1975
+ except Exception:
1976
+ return None
1977
+ return peer_id_of(getattr(result, "user_id", None)) or getattr(result, "user_id", None)
1978
+
1979
+
1980
+ async def _strip_from_folders(ctx: OpContext, chat_ids: list[int]) -> None:
1981
+ """Drop the peers from every folder, so no folder points at a chat we left."""
1982
+ from tlgr.ops.folder import folder_model, raw_filters, strip_peers
1983
+
1984
+ filters, _ = await raw_filters(ctx)
1985
+ wanted = set(chat_ids)
1986
+ for raw in filters:
1987
+ model = folder_model(raw)
1988
+ touched = wanted & (
1989
+ set(model.include_peers) | set(model.pinned_peers) | set(model.exclude_peers)
1990
+ )
1991
+ if touched:
1992
+ await strip_peers(ctx, raw, touched)
1993
+
1994
+
1995
+ SPEC_LEAVE = OperationSpec(
1996
+ id="chat.leave",
1997
+ request=LeaveReq,
1998
+ response=LeaveResult,
1999
+ impl=leave,
2000
+ summary="Leave groups and channels",
2001
+ description=(
2002
+ "Bulk leaving is the CHANNELS_TOO_MUCH escape hatch — pair it with "
2003
+ "`chat list --scope inactive`, which names the chats you have not "
2004
+ "opened in the longest time."
2005
+ ),
2006
+ legacy_paths=("chat leave",),
2007
+ mutating=True,
2008
+ destructive=True,
2009
+ rate_class="bulk",
2010
+ timeout_s=300,
2011
+ columns=("left", "chat_ids"),
2012
+ example={"left": True, "chat_id": -1000000005150, "chat_ids": [-1000000005150]},
2013
+ example_args="chat leave @somegroup",
2014
+ covers=(
2015
+ "dialogs.delete-and-leave",
2016
+ "dialogs.leave-group",
2017
+ "groups-channels-admin.leave",
2018
+ "groups-channels-admin.owner-leave-successor",
2019
+ ),
2020
+ covers_partial=("contacts-users.user-leave-common-groups",),
2021
+ coverage_note="`--common-with` leaves the shared groups; listing them is `user chat list`.",
2022
+ )
2023
+
2024
+
2025
+ class DeleteReq(Request):
2026
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat to delete.")]
2027
+ for_both: Annotated[
2028
+ bool, opt("--for-both", help="Private chat / basic group: delete for the other side too.")
2029
+ ] = False
2030
+ for_everyone: Annotated[
2031
+ bool, opt("--for-everyone", help="Owner only: destroy the group or channel itself.")
2032
+ ] = False
2033
+ for_me: Annotated[
2034
+ bool, opt("--for-me", help="Leave it and wipe only my copy of the history.")
2035
+ ] = False
2036
+
2037
+
2038
+ async def delete(ctx: OpContext, req: DeleteReq) -> DeleteChatResult:
2039
+ """Delete a chat: from my list, for both sides, or for everyone.
2040
+
2041
+ `--for-everyone` destroys the group or channel and is owner-only and
2042
+ irreversible; the server gates it on `channelFull.can_delete_channel`,
2043
+ which is member-count limited, so a large channel refuses.
2044
+ """
2045
+ from telethon.tl.functions import channels as cfn
2046
+ from telethon.tl.functions import messages as fn
2047
+
2048
+ client = _client(ctx)
2049
+ peer = await _send.resolve(ctx, req.chat)
2050
+ chat_id = _send.peer_id_of(peer)
2051
+
2052
+ if req.for_everyone:
2053
+ try:
2054
+ if _is_channel(peer):
2055
+ await client(cfn.DeleteChannelRequest(channel=_input_channel(peer)))
2056
+ else:
2057
+ await client(fn.DeleteChatRequest(chat_id=_raw_chat_id(peer)))
2058
+ except Exception as exc:
2059
+ if "ADMIN" in str(exc).upper() or "RIGHTS" in str(exc).upper():
2060
+ raise PermissionError_(
2061
+ "deleting a group or channel for everyone is owner-only"
2062
+ ) from exc
2063
+ raise
2064
+ ctx.emit("chat_delete", {"chat_id": chat_id, "scope": "everyone"})
2065
+ return DeleteChatResult(chat_id=chat_id, deleted=True, scope="everyone")
2066
+
2067
+ left = False
2068
+ if _is_channel(peer):
2069
+ await client(cfn.LeaveChannelRequest(channel=_input_channel(peer)))
2070
+ left = True
2071
+ if req.for_me or req.for_both:
2072
+ await _affected_loop(
2073
+ ctx,
2074
+ lambda offset, p=peer: cfn.DeleteHistoryRequest(
2075
+ channel=_input_channel(p), max_id=0, for_everyone=False
2076
+ ),
2077
+ )
2078
+ else:
2079
+ await _affected_loop(
2080
+ ctx,
2081
+ lambda offset, p=peer: fn.DeleteHistoryRequest(
2082
+ peer=p, max_id=0, revoke=req.for_both or None
2083
+ ),
2084
+ )
2085
+ scope = "both" if req.for_both else "me"
2086
+ ctx.emit("chat_delete", {"chat_id": chat_id, "scope": scope})
2087
+ return DeleteChatResult(chat_id=chat_id, deleted=True, scope=scope, left=left)
2088
+
2089
+
2090
+ SPEC_DELETE = OperationSpec(
2091
+ id="chat.delete",
2092
+ request=DeleteReq,
2093
+ response=DeleteChatResult,
2094
+ impl=delete,
2095
+ summary="Delete a chat: for me, for both sides, or for everyone",
2096
+ description=(
2097
+ "`--for-everyone` is owner-only and destroys the chat itself. "
2098
+ "Leaving a channel does not delete the history for anyone else."
2099
+ ),
2100
+ mutating=True,
2101
+ destructive=True,
2102
+ rate_class="bulk",
2103
+ timeout_s=300,
2104
+ columns=("chat_id", "deleted", "scope"),
2105
+ example={"chat_id": 777123, "deleted": True, "scope": "me"},
2106
+ example_args="chat delete @alice --yes",
2107
+ covers=(
2108
+ "dialogs.delete-chat-private",
2109
+ "dialogs.delete-group-for-all",
2110
+ "groups-channels-admin.delete-basic-group",
2111
+ "groups-channels-admin.delete-channel",
2112
+ "groups-channels-admin.delete-for-all-members",
2113
+ "messages-core.history-delete-conversation",
2114
+ "messages-core.saved-dialog-delete",
2115
+ ),
2116
+ )
2117
+
2118
+
2119
+ class ClearReq(Request):
2120
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat to clear.")]
2121
+ for_both: Annotated[
2122
+ bool, opt("--for-both", help="Also delete for the other side (revoke).")
2123
+ ] = False
2124
+ max_id: Annotated[
2125
+ int | None, opt("--max-id", metavar="ID", kind="msg_id", help="Only up to this id.")
2126
+ ] = None
2127
+ since: Annotated[
2128
+ str | None,
2129
+ opt("--since", metavar="TS", kind="datetime", help="Range start (private chats only)."),
2130
+ ] = None
2131
+ until: Annotated[
2132
+ str | None,
2133
+ opt("--until", metavar="TS", kind="datetime", help="Range end (private chats only)."),
2134
+ ] = None
2135
+ saved_peer: Annotated[
2136
+ PeerRef | None,
2137
+ opt("--saved-peer", metavar="CHAT", kind="peer", help="Clear a Saved-Messages sublist."),
2138
+ ] = None
2139
+ topic: Annotated[
2140
+ int | None, opt("--topic", metavar="ID", kind="msg_id", help="Clear one forum topic.")
2141
+ ] = None
2142
+
2143
+
2144
+ async def clear(ctx: OpContext, req: ClearReq) -> ClearResult:
2145
+ """Clear a chat's history while keeping the chat itself.
2146
+
2147
+ `just_clear` is what separates this from `chat delete`. The server
2148
+ answers a long history with a partial result and an offset to resume
2149
+ from, so the call is looped until it reports nothing left — clearing the
2150
+ first hundred messages and calling it done was v1's bug.
2151
+ """
2152
+ from telethon.tl.functions import channels as cfn
2153
+ from telethon.tl.functions import messages as fn
2154
+
2155
+ peer = await _send.resolve(ctx, req.chat)
2156
+ chat_id = _send.peer_id_of(peer)
2157
+ max_id = req.max_id or 0
2158
+
2159
+ if req.saved_peer is not None:
2160
+ origin = await _send.resolve(ctx, req.saved_peer)
2161
+ affected = await _affected_loop(
2162
+ ctx,
2163
+ lambda offset, p=peer, o=origin: fn.DeleteSavedHistoryRequest(
2164
+ peer=o, max_id=max_id, parent_peer=p
2165
+ ),
2166
+ )
2167
+ elif req.topic is not None:
2168
+ affected = await _affected_loop(
2169
+ ctx,
2170
+ lambda offset, p=peer: fn.DeleteTopicHistoryRequest(peer=p, top_msg_id=req.topic),
2171
+ )
2172
+ elif _is_channel(peer):
2173
+ if req.since or req.until:
2174
+ raise UsageError(
2175
+ "a date range only works in private chats and basic groups "
2176
+ "(the server answers CHAT_REVOKE_DATE_UNSUPPORTED elsewhere)",
2177
+ field="since",
2178
+ )
2179
+ affected = await _affected_loop(
2180
+ ctx,
2181
+ lambda offset, p=peer: cfn.DeleteHistoryRequest(
2182
+ channel=_input_channel(p), max_id=max_id, for_everyone=req.for_both or None
2183
+ ),
2184
+ )
2185
+ else:
2186
+ affected = await _affected_loop(
2187
+ ctx,
2188
+ lambda offset, p=peer: fn.DeleteHistoryRequest(
2189
+ peer=p,
2190
+ max_id=max_id,
2191
+ just_clear=True,
2192
+ revoke=req.for_both or None,
2193
+ min_date=parse_dt(req.since) if req.since else None,
2194
+ max_date=parse_dt(req.until) if req.until else None,
2195
+ ),
2196
+ )
2197
+
2198
+ ctx.emit("chat_clear", {"chat_id": chat_id, "for_both": req.for_both})
2199
+ return ClearResult(chat_id=chat_id, cleared=True, messages_affected=affected)
2200
+
2201
+
2202
+ SPEC_CLEAR = OperationSpec(
2203
+ id="chat.clear",
2204
+ request=ClearReq,
2205
+ response=ClearResult,
2206
+ impl=clear,
2207
+ summary="Clear a chat's history — for me, for both sides, or a date range",
2208
+ description=(
2209
+ "Irreversible. Date ranges are private-chat and basic-group only; "
2210
+ "supergroups clear through `channels.deleteHistory`, where clearing "
2211
+ "for everyone needs admin rights."
2212
+ ),
2213
+ mutating=True,
2214
+ destructive=True,
2215
+ rate_class="bulk",
2216
+ timeout_s=600,
2217
+ columns=("chat_id", "cleared", "messages_affected"),
2218
+ example={"chat_id": 777123, "cleared": True, "messages_affected": 412},
2219
+ example_args="chat clear @alice --yes",
2220
+ covers=(
2221
+ "dialogs.clear-history-both",
2222
+ "dialogs.clear-history-by-date",
2223
+ "dialogs.clear-history-self",
2224
+ "groups-channels-admin.clear-history",
2225
+ "messages-core.history-clear",
2226
+ "messages-core.history-clear-date-range",
2227
+ "messages-core.history-clear-topic",
2228
+ ),
2229
+ )
2230
+
2231
+
2232
+ # ---------------------------------------------------------------------------
2233
+ # chat typing
2234
+ # ---------------------------------------------------------------------------
2235
+
2236
+
2237
+ class TypingReq(Request):
2238
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat to type in.")]
2239
+ action: Annotated[
2240
+ str,
2241
+ choice(*sorted(_ACTIONS), help="Which action to broadcast."),
2242
+ ] = "typing"
2243
+ cancel: Annotated[bool, opt("--cancel", help="Alias for --action cancel.")] = False
2244
+ duration: Annotated[
2245
+ float, opt("--duration", metavar="SECONDS", help="Keep it alive this long.", ge=0)
2246
+ ] = 5.0
2247
+ progress: Annotated[
2248
+ int | None,
2249
+ opt("--progress", metavar="PERCENT", help="Percent, for the upload actions."),
2250
+ ] = None
2251
+ topic: Annotated[
2252
+ int | None, opt("--topic", metavar="ID", kind="msg_id", help="A forum topic.")
2253
+ ] = None
2254
+
2255
+
2256
+ async def typing(ctx: OpContext, req: TypingReq) -> TypingResult:
2257
+ """Broadcast a chat action, and hold it for a while.
2258
+
2259
+ Telegram expires an action after about six seconds, so a `--duration`
2260
+ longer than that is re-sent every five: the alternative is an indicator
2261
+ that flickers off halfway through.
2262
+ """
2263
+ from telethon.tl import types
2264
+ from telethon.tl.functions import messages as fn
2265
+
2266
+ action_name = "cancel" if req.cancel else req.action
2267
+ class_name = _ACTIONS.get(action_name)
2268
+ if class_name is None:
2269
+ raise UsageError(f"--action {action_name!r} is not a chat action", field="action")
2270
+
2271
+ peer = await _send.resolve(ctx, req.chat)
2272
+ chat_id = _send.peer_id_of(peer)
2273
+ kwargs: dict[str, Any] = {}
2274
+ if req.progress is not None and "Upload" in class_name:
2275
+ kwargs["progress"] = req.progress
2276
+ action = getattr(types, class_name)(**kwargs)
2277
+
2278
+ client = _client(ctx)
2279
+ seconds = 0.0 if action_name == "cancel" else min(float(req.duration), 300.0)
2280
+ deadline = seconds
2281
+ while True:
2282
+ await client(fn.SetTypingRequest(peer=peer, action=action, top_msg_id=req.topic))
2283
+ if deadline <= 5.0:
2284
+ break
2285
+ await asyncio.sleep(5.0)
2286
+ deadline -= 5.0
2287
+ if 0 < deadline <= 5.0:
2288
+ await asyncio.sleep(deadline)
2289
+
2290
+ ctx.emit("chat_typing", {"chat_id": chat_id, "action": action_name})
2291
+ return TypingResult(
2292
+ chat_id=chat_id, action=action_name, duration=seconds, typing=action_name != "cancel"
2293
+ )
2294
+
2295
+
2296
+ SPEC_TYPING = OperationSpec(
2297
+ id="chat.typing",
2298
+ request=TypingReq,
2299
+ response=TypingResult,
2300
+ impl=typing,
2301
+ summary="Send or cancel a chat action (typing, recording, uploading)",
2302
+ description=(
2303
+ "Seeing who else is typing is `watch --events typing`; this is the outgoing half."
2304
+ ),
2305
+ legacy_paths=("chat typing",),
2306
+ mutating=True,
2307
+ rate_class="send",
2308
+ timeout_s=330,
2309
+ columns=("chat_id", "action", "duration"),
2310
+ example={"chat_id": 777123, "action": "typing", "duration": 5.0, "typing": True},
2311
+ example_args="chat typing @alice",
2312
+ covers=(
2313
+ "dialogs.typing-cancel",
2314
+ "dialogs.typing-send",
2315
+ "groupcall.speaking-indicator",
2316
+ "messages-core.typing-action",
2317
+ ),
2318
+ tags=frozenset({"visible-to-others"}),
2319
+ )
2320
+
2321
+
2322
+ # ---------------------------------------------------------------------------
2323
+ # chat mention list
2324
+ # ---------------------------------------------------------------------------
2325
+
2326
+
2327
+ class MentionListReq(Request):
2328
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat to look in.")]
2329
+ kind: Annotated[
2330
+ str, choice("mention", "reaction", "poll-vote", help="Which unread queue to list.")
2331
+ ] = "mention"
2332
+ topic: Annotated[
2333
+ int | None, opt("--topic", metavar="ID", kind="msg_id", help="Restrict to a topic.")
2334
+ ] = None
2335
+ saved_peer: Annotated[
2336
+ PeerRef | None,
2337
+ opt("--saved-peer", metavar="CHAT", kind="peer", help="Restrict to a saved sublist."),
2338
+ ] = None
2339
+ read: Annotated[bool, opt("--read", help="Mark the listed items read afterwards.")] = False
2340
+
2341
+
2342
+ async def mention_list(ctx: OpContext, req: MentionListReq) -> Page[Message]:
2343
+ """The three unread queues that sit next to `unread_count`.
2344
+
2345
+ Mentions, reactions and poll votes each have their own counter on the
2346
+ dialog and their own list call. `--read` clears the one you listed, which
2347
+ is the only way to make the badge agree with what you have seen.
2348
+ """
2349
+ from telethon.tl.functions import messages as fn
2350
+
2351
+ limit, state = _window(ctx, "chat.mention.list", PageKind.HISTORY, default=20)
2352
+ peer = await _send.resolve(ctx, req.chat)
2353
+ chat_id = _send.peer_id_of(peer)
2354
+ offset_id = int(state.get("offset_id", 0) or 0)
2355
+ client = _client(ctx)
2356
+
2357
+ if req.kind == "reaction":
2358
+ saved = await _send.resolve(ctx, req.saved_peer) if req.saved_peer is not None else None
2359
+ result = await client(
2360
+ fn.GetUnreadReactionsRequest(
2361
+ peer=peer,
2362
+ offset_id=offset_id,
2363
+ add_offset=0,
2364
+ limit=limit,
2365
+ max_id=0,
2366
+ min_id=0,
2367
+ top_msg_id=req.topic,
2368
+ saved_peer_id=saved,
2369
+ )
2370
+ )
2371
+ elif req.kind == "poll-vote":
2372
+ result = await client(
2373
+ fn.GetUnreadPollVotesRequest(
2374
+ peer=peer,
2375
+ offset_id=offset_id,
2376
+ add_offset=0,
2377
+ limit=limit,
2378
+ max_id=0,
2379
+ min_id=0,
2380
+ top_msg_id=req.topic,
2381
+ )
2382
+ )
2383
+ else:
2384
+ result = await client(
2385
+ fn.GetUnreadMentionsRequest(
2386
+ peer=peer,
2387
+ offset_id=offset_id,
2388
+ add_offset=0,
2389
+ limit=limit,
2390
+ max_id=0,
2391
+ min_id=0,
2392
+ top_msg_id=req.topic,
2393
+ )
2394
+ )
2395
+
2396
+ items = [
2397
+ message_to_model(message, chat_id=chat_id)
2398
+ for message in (getattr(result, "messages", None) or [])
2399
+ ]
2400
+ if req.read and items and ctx.dry_run:
2401
+ ctx.warn(f"--dry-run: {len(items)} {req.kind}(s) would be marked read")
2402
+ elif req.read and items:
2403
+ if req.kind == "reaction":
2404
+ await _affected_loop(ctx, lambda offset, p=peer: fn.ReadReactionsRequest(peer=p))
2405
+ elif req.kind == "poll-vote":
2406
+ await client(fn.ReadPollVotesRequest(peer=peer, top_msg_id=req.topic))
2407
+ else:
2408
+ await _affected_loop(ctx, lambda offset, p=peer: fn.ReadMentionsRequest(peer=p))
2409
+
2410
+ next_state = {"offset_id": items[-1].id} if items else {}
2411
+ return build_page(
2412
+ items,
2413
+ op="chat.mention.list",
2414
+ kind=PageKind.HISTORY,
2415
+ state=next_state,
2416
+ account=ctx.account,
2417
+ limit=limit,
2418
+ total=getattr(result, "count", None),
2419
+ )
2420
+
2421
+
2422
+ SPEC_MENTION_LIST = OperationSpec(
2423
+ id="chat.mention.list",
2424
+ request=MentionListReq,
2425
+ response=Page[Message],
2426
+ impl=mention_list,
2427
+ summary="Unread mentions, reactions or poll votes of a chat",
2428
+ description=(
2429
+ "`--read` clears the queue it just listed, and honours --dry-run "
2430
+ "itself so that listing stays available under it."
2431
+ ),
2432
+ aliases=("chat.mentions", "chat.reactions", "chat.poll-votes"),
2433
+ paginated=PageKind.HISTORY,
2434
+ tags=frozenset({"mutating-checked"}),
2435
+ columns=("id", "date", "text"),
2436
+ headers=("ID", "Date", "Text"),
2437
+ example={
2438
+ "items": [
2439
+ {
2440
+ "id": 12345,
2441
+ "chat_id": 777123,
2442
+ "date": "2026-09-03T09:14:07Z",
2443
+ "date_unix": 1788340447,
2444
+ "text": "@me look",
2445
+ }
2446
+ ],
2447
+ "has_more": False,
2448
+ },
2449
+ example_args="chat mention list @somegroup",
2450
+ covers=(
2451
+ "dialogs.unread-mentions",
2452
+ "dialogs.unread-poll-votes",
2453
+ "dialogs.unread-reactions",
2454
+ ),
2455
+ )
2456
+
2457
+
2458
+ # ---------------------------------------------------------------------------
2459
+ # chat poster list
2460
+ # ---------------------------------------------------------------------------
2461
+
2462
+
2463
+ class PosterListReq(Request):
2464
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat to harvest.")]
2465
+ since: Annotated[
2466
+ str | None, opt("--since", metavar="TS", kind="datetime", help="Scan window start.")
2467
+ ] = None
2468
+ until: Annotated[
2469
+ str | None, opt("--until", metavar="TS", kind="datetime", help="Scan window end.")
2470
+ ] = None
2471
+ min_messages: Annotated[
2472
+ int, opt("--min-messages", metavar="N", help="Drop senders below this count.", ge=1)
2473
+ ] = 1
2474
+ max_messages: Annotated[
2475
+ int,
2476
+ opt("--max-messages", metavar="N", help="How much history to walk.", ge=1, le=20000),
2477
+ ] = 2000
2478
+
2479
+
2480
+ async def poster_list(ctx: OpContext, req: PosterListReq) -> PosterReport:
2481
+ """Distinct senders in a chat's recent history, by message count.
2482
+
2483
+ The walk is internal: every agent that hand-rolled this loop got the
2484
+ offsets or the flood backoff wrong. A flood wait mid-scan returns the
2485
+ partial harvest with `partial: true` rather than an error, because half
2486
+ the senders is a useful answer and an exception is not.
2487
+ """
2488
+ peer = await _send.resolve(ctx, req.chat)
2489
+ chat_id = _send.peer_id_of(peer)
2490
+ client = _client(ctx)
2491
+ limit = int(getattr(ctx, "limit", None) or 0)
2492
+
2493
+ since = parse_dt(req.since) if req.since else None
2494
+ until = parse_dt(req.until) if req.until else None
2495
+
2496
+ counts: dict[int, Poster] = {}
2497
+ scanned = 0
2498
+ partial = False
2499
+ flood_wait: int | None = None
2500
+ try:
2501
+ async for message in client.iter_messages(peer, limit=req.max_messages, offset_date=until):
2502
+ if message is None:
2503
+ continue
2504
+ scanned += 1
2505
+ date = getattr(message, "date", None)
2506
+ if since is not None and date is not None and date < since:
2507
+ break
2508
+ sender_id = peer_id_of(getattr(message, "from_id", None))
2509
+ if sender_id is None:
2510
+ sender_id = peer_id_of(getattr(message, "peer_id", None))
2511
+ if sender_id is None:
2512
+ continue
2513
+ poster = counts.get(sender_id)
2514
+ if poster is None:
2515
+ poster = Poster(user_id=sender_id, id=sender_id)
2516
+ counts[sender_id] = poster
2517
+ poster.count += 1
2518
+ if poster.last_msg_id is None:
2519
+ poster.last_msg_id = int(getattr(message, "id", 0) or 0)
2520
+ poster.date = fmt_dt(date)
2521
+ poster.date_unix = to_unix(date)
2522
+ except Exception as exc:
2523
+ seconds = getattr(exc, "seconds", None)
2524
+ if seconds is None:
2525
+ raise
2526
+ partial = True
2527
+ flood_wait = int(seconds)
2528
+ ctx.warn(f"flood wait of {seconds}s cut the scan short; the harvest is partial")
2529
+
2530
+ await _name_posters(ctx, counts)
2531
+ posters = sorted(
2532
+ (p for p in counts.values() if p.count >= req.min_messages),
2533
+ key=lambda p: (-p.count, p.user_id),
2534
+ )
2535
+ if limit:
2536
+ posters = posters[:limit]
2537
+ ctx.emit("chat_posters", {"chat_id": chat_id, "scanned": scanned})
2538
+ return PosterReport(
2539
+ posters=posters,
2540
+ scanned_messages=scanned,
2541
+ distinct_posters=len(counts),
2542
+ partial=partial,
2543
+ flood_wait=flood_wait,
2544
+ )
2545
+
2546
+
2547
+ async def _name_posters(ctx: OpContext, counts: dict[int, Poster]) -> None:
2548
+ """Resolve each distinct sender once; a failure leaves the id, not a hole."""
2549
+ client = _client(ctx)
2550
+ for sender_id, poster in counts.items():
2551
+ try:
2552
+ entity = await client.get_entity(sender_id)
2553
+ except Exception:
2554
+ continue
2555
+ shape = entity_to_peer(entity)
2556
+ poster.username = shape.username
2557
+ poster.name = shape.title
2558
+ poster.is_bot = shape.kind == "bot"
2559
+ poster.is_deleted = bool(getattr(entity, "deleted", False))
2560
+
2561
+
2562
+ SPEC_POSTER_LIST = OperationSpec(
2563
+ id="chat.poster.list",
2564
+ request=PosterListReq,
2565
+ response=PosterReport,
2566
+ impl=poster_list,
2567
+ summary="Harvest the senders that posted in a chat over a message window",
2568
+ description=(
2569
+ "Pagination is internal — do not hand-roll the walk. Senders are not "
2570
+ "always users: an anonymous admin and a linked channel post under a "
2571
+ "negative channel id, so filter to positive ids when harvesting people."
2572
+ ),
2573
+ aliases=("chat.posters",),
2574
+ legacy_paths=("chat posters",),
2575
+ timeout_s=600,
2576
+ rate_class="bulk",
2577
+ empty_exit=EXIT_EMPTY,
2578
+ columns=("posters.user_id", "posters.name", "posters.count"),
2579
+ example={
2580
+ "posters": [{"user_id": 4242, "id": 4242, "name": "Alice", "count": 44}],
2581
+ "scanned_messages": 2400,
2582
+ "distinct_posters": 137,
2583
+ },
2584
+ example_args="chat poster list @somegroup",
2585
+ covers=(),
2586
+ covers_partial=(),
2587
+ tags=frozenset({"infrastructure"}),
2588
+ )
2589
+
2590
+
2591
+ # ---------------------------------------------------------------------------
2592
+ # chat notify
2593
+ # ---------------------------------------------------------------------------
2594
+
2595
+
2596
+ class NotifyGetReq(Request):
2597
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat to describe.")]
2598
+ topic: Annotated[
2599
+ int | None, opt("--topic", metavar="ID", kind="msg_id", help="A forum topic.")
2600
+ ] = None
2601
+ effective: Annotated[
2602
+ bool, opt("--effective", help="Merge the scope default under the exception.")
2603
+ ] = True
2604
+
2605
+
2606
+ async def notify_get(ctx: OpContext, req: NotifyGetReq) -> NotifyView:
2607
+ """A chat's notification exception, and what it resolves to.
2608
+
2609
+ `peerNotifySettings` fields are ternary: unset means "inherit the scope
2610
+ default". Both halves are reported because they answer different
2611
+ questions — the exception is what you must send back, the effective value
2612
+ is what the user is actually asking about.
2613
+ """
2614
+ from telethon.tl import types
2615
+ from telethon.tl.functions import account as fn
2616
+
2617
+ client = _client(ctx)
2618
+ peer = await _send.resolve(ctx, req.chat)
2619
+ chat_id = _send.peer_id_of(peer)
2620
+ notify_peer = (
2621
+ types.InputNotifyForumTopic(peer=peer, top_msg_id=req.topic)
2622
+ if req.topic is not None
2623
+ else types.InputNotifyPeer(peer=peer)
2624
+ )
2625
+ raw = await client(fn.GetNotifySettingsRequest(peer=notify_peer))
2626
+ settings = notify_settings(raw) or NotifySettings()
2627
+
2628
+ scope_name, scope_peer = _scope_for(peer)
2629
+ view = NotifyView(chat_id=chat_id, settings=settings, scope=scope_name)
2630
+ if req.effective:
2631
+ default_raw = await client(fn.GetNotifySettingsRequest(peer=scope_peer))
2632
+ default = notify_settings(default_raw) or NotifySettings()
2633
+ view.scope_default = default
2634
+ merged, inherited = _merge_notify(settings, default)
2635
+ view.effective = merged
2636
+ view.inherited = inherited
2637
+ return view
2638
+
2639
+
2640
+ def _scope_for(peer: Any) -> tuple[str, Any]:
2641
+ """Which notification scope a peer inherits from."""
2642
+ from telethon.tl import types
2643
+
2644
+ name = type(peer).__name__
2645
+ if name in ("InputPeerChannel", "InputPeerChannelFromMessage"):
2646
+ return "broadcasts", types.InputNotifyBroadcasts()
2647
+ if name == "InputPeerChat":
2648
+ return "chats", types.InputNotifyChats()
2649
+ return "users", types.InputNotifyUsers()
2650
+
2651
+
2652
+ def _merge_notify(
2653
+ exception: NotifySettings, default: NotifySettings
2654
+ ) -> tuple[NotifySettings, list[str]]:
2655
+ merged = NotifySettings(
2656
+ muted=exception.muted,
2657
+ mute_until=exception.mute_until,
2658
+ mute_until_unix=exception.mute_until_unix,
2659
+ )
2660
+ inherited: list[str] = []
2661
+ for field in ("silent", "show_previews", "sound", "stories_muted", "stories_hide_sender"):
2662
+ value = getattr(exception, field)
2663
+ if value is None:
2664
+ value = getattr(default, field)
2665
+ inherited.append(field)
2666
+ setattr(merged, field, value)
2667
+ if exception.mute_until_unix is None:
2668
+ merged.muted = default.muted
2669
+ merged.mute_until = default.mute_until
2670
+ merged.mute_until_unix = default.mute_until_unix
2671
+ inherited.append("mute_until")
2672
+ return merged, inherited
2673
+
2674
+
2675
+ SPEC_NOTIFY_GET = OperationSpec(
2676
+ id="chat.notify.get",
2677
+ request=NotifyGetReq,
2678
+ response=NotifyView,
2679
+ impl=notify_get,
2680
+ summary="Show a chat's notification settings, exception and effective value",
2681
+ columns=("chat_id", "settings.muted", "scope"),
2682
+ example={"chat_id": 777123, "settings": {"muted": True}, "scope": "users"},
2683
+ example_args="chat notify get @alice",
2684
+ covers=("dialogs.notify-get",),
2685
+ )
2686
+
2687
+
2688
+ class NotifySetReq(Request):
2689
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat to change.")]
2690
+ preview: Annotated[
2691
+ str | None, choice("on", "off", "default", help="Message preview in notifications.")
2692
+ ] = None
2693
+ silent: Annotated[
2694
+ str | None, choice("on", "off", "default", help="Deliver without a sound.")
2695
+ ] = None
2696
+ sound: Annotated[
2697
+ str | None,
2698
+ opt("--sound", metavar="SOUND", help="none | default | <ringtone id> | local:<name>."),
2699
+ ] = None
2700
+ stories_mute: Annotated[
2701
+ str | None, choice("on", "off", "default", help="Mute this peer's stories.")
2702
+ ] = None
2703
+ stories_hide_sender: Annotated[
2704
+ str | None, choice("on", "off", "default", help="Hide the sender on story alerts.")
2705
+ ] = None
2706
+ gifts: Annotated[
2707
+ str | None, choice("on", "off", help="Star-gift notifications for a channel you admin.")
2708
+ ] = None
2709
+ topic: Annotated[
2710
+ int | None, opt("--topic", metavar="ID", kind="msg_id", help="A forum topic.")
2711
+ ] = None
2712
+ reset: Annotated[
2713
+ bool, opt("--reset", help="Drop the exception and inherit the scope default.")
2714
+ ] = False
2715
+
2716
+
2717
+ async def notify_set(ctx: OpContext, req: NotifySetReq) -> NotifyView:
2718
+ """Set one chat's notification exception, one field at a time.
2719
+
2720
+ `inputPeerNotifySettings` only carries the fields you set, and `default`
2721
+ is expressed by *omitting* one — which is why every value here is a
2722
+ three-way choice rather than a boolean flag.
2723
+ """
2724
+ from telethon.tl import types
2725
+ from telethon.tl.functions import account as fn
2726
+ from telethon.tl.functions import payments as pfn
2727
+
2728
+ client = _client(ctx)
2729
+ peer = await _send.resolve(ctx, req.chat)
2730
+ chat_id = _send.peer_id_of(peer)
2731
+ notify_peer = (
2732
+ types.InputNotifyForumTopic(peer=peer, top_msg_id=req.topic)
2733
+ if req.topic is not None
2734
+ else types.InputNotifyPeer(peer=peer)
2735
+ )
2736
+
2737
+ kwargs: dict[str, Any] = {}
2738
+ if not req.reset:
2739
+ # Read-modify-write: the request replaces the whole exception, so the
2740
+ # fields nobody asked about have to be sent back as they were.
2741
+ current = await client(fn.GetNotifySettingsRequest(peer=notify_peer))
2742
+ for name in ("show_previews", "silent", "stories_muted", "stories_hide_sender"):
2743
+ value = getattr(current, name, None)
2744
+ if value is not None:
2745
+ kwargs[name] = value
2746
+ if getattr(current, "mute_until", None) is not None:
2747
+ kwargs["mute_until"] = current.mute_until
2748
+ _tri(kwargs, "show_previews", req.preview)
2749
+ _tri(kwargs, "silent", req.silent)
2750
+ _tri(kwargs, "stories_muted", req.stories_mute)
2751
+ _tri(kwargs, "stories_hide_sender", req.stories_hide_sender)
2752
+ if req.sound is not None:
2753
+ kwargs["sound"] = _sound_value(req.sound)
2754
+
2755
+ if req.gifts is not None:
2756
+ await client(
2757
+ pfn.ToggleChatStarGiftNotificationsRequest(peer=peer, enabled=req.gifts == "on")
2758
+ )
2759
+ if req.gifts is None or kwargs or req.reset:
2760
+ await client(
2761
+ fn.UpdateNotifySettingsRequest(
2762
+ peer=notify_peer, settings=types.InputPeerNotifySettings(**kwargs)
2763
+ )
2764
+ )
2765
+ ctx.emit("chat_notify", {"chat_id": chat_id})
2766
+ return await notify_get(ctx, NotifyGetReq(chat=req.chat, topic=req.topic, effective=True))
2767
+
2768
+
2769
+ def _tri(kwargs: dict[str, Any], name: str, value: str | None) -> None:
2770
+ """`on`/`off` set the field; `default` drops it back to inheriting."""
2771
+ if value is None:
2772
+ return
2773
+ if value == "default":
2774
+ kwargs.pop(name, None)
2775
+ return
2776
+ kwargs[name] = value == "on"
2777
+
2778
+
2779
+ def _sound_value(text: str) -> Any:
2780
+ from telethon.tl import types
2781
+
2782
+ value = text.strip()
2783
+ if value in ("none", "off", "silent"):
2784
+ return types.NotificationSoundNone()
2785
+ if value in ("default", ""):
2786
+ return types.NotificationSoundDefault()
2787
+ if value.startswith("local:"):
2788
+ title = value.split(":", 1)[1]
2789
+ return types.NotificationSoundLocal(title=title, data=title)
2790
+ try:
2791
+ return types.NotificationSoundRingtone(id=int(value))
2792
+ except ValueError as exc:
2793
+ raise UsageError(
2794
+ "--sound takes none, default, a ringtone id, or local:<name>", field="sound"
2795
+ ) from exc
2796
+
2797
+
2798
+ SPEC_NOTIFY_SET = OperationSpec(
2799
+ id="chat.notify.set",
2800
+ request=NotifySetReq,
2801
+ response=NotifyView,
2802
+ impl=notify_set,
2803
+ summary="Set one chat's notification exception",
2804
+ description=(
2805
+ "Every switch takes on|off|default, because `default` is a real third "
2806
+ "state: it removes the exception so the chat inherits the scope again."
2807
+ ),
2808
+ mutating=True,
2809
+ idempotent=True,
2810
+ columns=("chat_id", "settings.muted"),
2811
+ example={"chat_id": 777123, "settings": {"muted": False, "silent": True}},
2812
+ example_args="chat notify set @alice --silent on",
2813
+ covers=(
2814
+ "dialogs.gift-notifications",
2815
+ "dialogs.notify-preview",
2816
+ "dialogs.notify-silent",
2817
+ ),
2818
+ )
2819
+
2820
+
2821
+ # ---------------------------------------------------------------------------
2822
+ # chat ttl / theme / wallpaper / translate / set
2823
+ # ---------------------------------------------------------------------------
2824
+
2825
+
2826
+ class TtlSetReq(Request):
2827
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
2828
+ period: Annotated[
2829
+ str | None,
2830
+ arg(
2831
+ 1, metavar="PERIOD", required=False, help="1d | 1w | 1m | seconds | off; omit to show."
2832
+ ),
2833
+ ] = None
2834
+
2835
+
2836
+ async def ttl_set(ctx: OpContext, req: TtlSetReq) -> TtlResult:
2837
+ """Set — or, with no period, show — a chat's auto-delete timer.
2838
+
2839
+ Either side may set it in a private chat; a group or channel needs the
2840
+ change-info right. Only server-accepted values work, and the server says
2841
+ `TTL_PERIOD_INVALID` about the rest rather than rounding.
2842
+ """
2843
+ from telethon.tl import types
2844
+ from telethon.tl.functions import messages as fn
2845
+
2846
+ client = _client(ctx)
2847
+ peer = await _send.resolve(ctx, req.chat)
2848
+ chat_id = _send.peer_id_of(peer)
2849
+
2850
+ if req.period is None:
2851
+ result = await client(fn.GetPeerDialogsRequest(peers=[types.InputDialogPeer(peer)]))
2852
+ for row in getattr(result, "dialogs", None) or []:
2853
+ return TtlResult(chat_id=chat_id, ttl_period=getattr(row, "ttl_period", None))
2854
+ return TtlResult(chat_id=chat_id)
2855
+
2856
+ text = req.period.strip().lower()
2857
+ period = 0 if text in ("off", "0", "none") else (parse_duration(text) or 0)
2858
+ if text not in ("off", "0", "none") and not period:
2859
+ raise UsageError(f"cannot read {req.period!r} as a duration", field="period")
2860
+ await client(fn.SetHistoryTTLRequest(peer=peer, period=period))
2861
+ ctx.emit("chat_ttl", {"chat_id": chat_id, "ttl_period": period})
2862
+ return TtlResult(chat_id=chat_id, ttl_period=period or None, set=True)
2863
+
2864
+
2865
+ SPEC_TTL_SET = OperationSpec(
2866
+ id="chat.ttl.set",
2867
+ request=TtlSetReq,
2868
+ response=TtlResult,
2869
+ impl=ttl_set,
2870
+ summary="Set or show a chat's auto-delete timer",
2871
+ mutating=True,
2872
+ idempotent=True,
2873
+ columns=("chat_id", "ttl_period"),
2874
+ example={"chat_id": 777123, "ttl_period": 86400, "set": True},
2875
+ example_args="chat ttl set @alice 1d",
2876
+ covers=("dialogs.ttl-get", "dialogs.ttl-set"),
2877
+ )
2878
+
2879
+
2880
+ class ThemeListReq(Request):
2881
+ gifts: Annotated[
2882
+ bool, opt("--gifts", help="Collectible gift themes instead of emoji ones.")
2883
+ ] = False
2884
+
2885
+
2886
+ async def theme_list(ctx: OpContext, req: ThemeListReq) -> Page[ChatTheme]:
2887
+ """The chat themes this account may set."""
2888
+ from telethon.tl.functions import account as fn
2889
+
2890
+ limit, state = _window(ctx, "chat.theme.list", PageKind.RATE, default=50)
2891
+ client = _client(ctx)
2892
+ if req.gifts:
2893
+ result = await client(
2894
+ fn.GetUniqueGiftChatThemesRequest(
2895
+ offset=str(state.get("offset", "")), limit=limit, hash=0
2896
+ )
2897
+ )
2898
+ raw = list(getattr(result, "themes", None) or [])
2899
+ next_offset = getattr(result, "next_offset", None)
2900
+ items = [t for t in (chat_theme(theme) for theme in raw) if t is not None]
2901
+ return build_page(
2902
+ items,
2903
+ op="chat.theme.list",
2904
+ kind=PageKind.RATE,
2905
+ state={"offset": next_offset} if next_offset else {},
2906
+ account=ctx.account,
2907
+ has_more=bool(next_offset),
2908
+ )
2909
+
2910
+ result = await client(fn.GetChatThemesRequest(hash=0))
2911
+ raw = list(getattr(result, "themes", None) or [])
2912
+ items = [t for t in (chat_theme(theme) for theme in raw) if t is not None]
2913
+ return Page(items=items, has_more=False, total=len(items))
2914
+
2915
+
2916
+ SPEC_THEME_LIST = OperationSpec(
2917
+ id="chat.theme.list",
2918
+ request=ThemeListReq,
2919
+ response=Page[ChatTheme],
2920
+ impl=theme_list,
2921
+ summary="List the chat themes available",
2922
+ paginated=PageKind.RATE,
2923
+ columns=("emoticon", "title"),
2924
+ headers=("Emoji", "Theme"),
2925
+ example={"items": [{"emoticon": "🌷", "title": "🌷"}], "has_more": False},
2926
+ example_args="chat theme list",
2927
+ covers=("dialogs.chat-theme-list",),
2928
+ )
2929
+
2930
+
2931
+ class ThemeSetReq(Request):
2932
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
2933
+ emoji: Annotated[
2934
+ str | None, opt("--emoji", metavar="EMOJI", help="Emoji theme from `chat theme list`.")
2935
+ ] = None
2936
+ gift: Annotated[
2937
+ str | None, opt("--gift", metavar="SLUG", help="Collectible gift theme slug.")
2938
+ ] = None
2939
+ unset: Annotated[bool, opt("--unset", help="Remove the per-chat theme.")] = False
2940
+
2941
+
2942
+ async def theme_set(ctx: OpContext, req: ThemeSetReq) -> ThemeResult:
2943
+ """Set or clear one chat's theme. Both sides of the chat see it."""
2944
+ from telethon.tl import types
2945
+ from telethon.tl.functions import messages as fn
2946
+
2947
+ peer = await _send.resolve(ctx, req.chat)
2948
+ chat_id = _send.peer_id_of(peer)
2949
+
2950
+ if req.unset or not (req.emoji or req.gift):
2951
+ if not req.unset:
2952
+ raise UsageError("give --emoji, --gift, or --unset", field="emoji")
2953
+ theme: Any = types.InputChatThemeEmpty()
2954
+ model = None
2955
+ elif req.gift:
2956
+ theme = types.InputChatThemeUniqueGift(slug=req.gift)
2957
+ model = ChatTheme(gift_slug=req.gift, title=req.gift)
2958
+ else:
2959
+ theme = types.InputChatTheme(emoticon=req.emoji or "")
2960
+ model = ChatTheme(emoticon=req.emoji, title=req.emoji or "")
2961
+
2962
+ await _client(ctx)(fn.SetChatThemeRequest(peer=peer, theme=theme))
2963
+ ctx.emit("chat_theme", {"chat_id": chat_id, "theme": req.emoji or req.gift})
2964
+ return ThemeResult(chat_id=chat_id, theme=model)
2965
+
2966
+
2967
+ SPEC_THEME_SET = OperationSpec(
2968
+ id="chat.theme.set",
2969
+ request=ThemeSetReq,
2970
+ response=ThemeResult,
2971
+ impl=theme_set,
2972
+ summary="Set or remove the theme of one chat",
2973
+ aliases=("chat.theme.unset",),
2974
+ mutating=True,
2975
+ idempotent=True,
2976
+ columns=("chat_id", "theme.emoticon"),
2977
+ example={"chat_id": 777123, "theme": {"emoticon": "🌷", "title": "🌷"}},
2978
+ example_args="chat theme set @alice --emoji 🌷",
2979
+ covers=(
2980
+ "dialogs.chat-theme-reset",
2981
+ "gift.as-chat-theme",
2982
+ "gifts.set-as-chat-theme",
2983
+ "theme.set-chat-theme",
2984
+ ),
2985
+ tags=frozenset({"visible-to-others"}),
2986
+ )
2987
+
2988
+
2989
+ class WallpaperSetReq(Request):
2990
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
2991
+ slug: Annotated[
2992
+ str | None, opt("--slug", metavar="SLUG", help="Wallpaper slug from the gallery.")
2993
+ ] = None
2994
+ file: Annotated[
2995
+ str | None, opt("--file", metavar="PATH", kind="path", help="Upload a local image.")
2996
+ ] = None
2997
+ color: Annotated[
2998
+ str | None, opt("--color", metavar="HEX", help="Solid or gradient fill, '#rrggbb'.")
2999
+ ] = None
3000
+ blur: Annotated[bool, opt("--blur", help="Blur the background.")] = False
3001
+ intensity: Annotated[
3002
+ int | None, opt("--intensity", metavar="N", help="Pattern intensity, -100..100.")
3003
+ ] = None
3004
+ for_both: Annotated[bool, opt("--for-both", help="Apply on the other side too (Premium).")] = (
3005
+ False
3006
+ )
3007
+ from_message: Annotated[
3008
+ int | None,
3009
+ opt("--from-message", metavar="ID", kind="msg_id", help="Accept a suggested wallpaper."),
3010
+ ] = None
3011
+ revert: Annotated[bool, opt("--revert", help="Restore my previous wallpaper.")] = False
3012
+ unset: Annotated[bool, opt("--unset", help="Remove the chat wallpaper.")] = False
3013
+
3014
+
3015
+ async def wallpaper_set(ctx: OpContext, req: WallpaperSetReq) -> WallpaperResult:
3016
+ """Set, accept, revert or remove one chat's wallpaper.
3017
+
3018
+ `--from-message` is the "same" acknowledgement: it passes the service
3019
+ message id with no wallpaper of its own, which is how a client accepts
3020
+ the background the other side suggested.
3021
+ """
3022
+ from telethon.tl import types
3023
+ from telethon.tl.functions import account as afn
3024
+ from telethon.tl.functions import messages as fn
3025
+
3026
+ client = _client(ctx)
3027
+ peer = await _send.resolve(ctx, req.chat)
3028
+ chat_id = _send.peer_id_of(peer)
3029
+
3030
+ paper: Any = None
3031
+ settings: Any = None
3032
+ if req.file:
3033
+ upload = getattr(ctx, "upload_file", None)
3034
+ if upload is None: # pragma: no cover - the daemon always supplies one
3035
+ raise UsageError("this context cannot upload files")
3036
+ handle = await upload(req.file)
3037
+ settings = _wallpaper_settings(req)
3038
+ uploaded = await client(
3039
+ afn.UploadWallPaperRequest(file=handle, mime_type="image/jpeg", settings=settings)
3040
+ )
3041
+ paper = types.InputWallPaper(
3042
+ id=getattr(uploaded, "id", 0), access_hash=getattr(uploaded, "access_hash", 0)
3043
+ )
3044
+ elif req.slug:
3045
+ paper = types.InputWallPaperSlug(slug=req.slug)
3046
+ settings = _wallpaper_settings(req)
3047
+ elif req.color:
3048
+ paper = types.InputWallPaperNoFile(id=0)
3049
+ settings = _wallpaper_settings(req)
3050
+
3051
+ result = await client(
3052
+ fn.SetChatWallPaperRequest(
3053
+ peer=peer,
3054
+ for_both=req.for_both or None,
3055
+ revert=req.revert or None,
3056
+ wallpaper=paper,
3057
+ settings=settings,
3058
+ id=req.from_message,
3059
+ )
3060
+ )
3061
+ model = None
3062
+ for update in getattr(result, "updates", None) or []:
3063
+ found = getattr(update, "wallpaper", None)
3064
+ if found is not None:
3065
+ model = wallpaper(found)
3066
+ break
3067
+ if model is None and paper is not None:
3068
+ model = ChatWallpaper(slug=req.slug, blur=req.blur, intensity=req.intensity)
3069
+ ctx.emit("chat_wallpaper", {"chat_id": chat_id})
3070
+ return WallpaperResult(
3071
+ chat_id=chat_id,
3072
+ wallpaper=None if req.unset else model,
3073
+ for_both=req.for_both,
3074
+ overridden=req.revert,
3075
+ )
3076
+
3077
+
3078
+ def _wallpaper_settings(req: WallpaperSetReq) -> Any:
3079
+ from telethon.tl import types
3080
+
3081
+ colors = [_hex(part) for part in (req.color or "").split("-") if part.strip()]
3082
+ return types.WallPaperSettings(
3083
+ blur=req.blur or None,
3084
+ intensity=req.intensity,
3085
+ background_color=colors[0] if colors else None,
3086
+ second_background_color=colors[1] if len(colors) > 1 else None,
3087
+ )
3088
+
3089
+
3090
+ def _hex(value: str) -> int:
3091
+ try:
3092
+ return int(value.strip().lstrip("#"), 16)
3093
+ except ValueError as exc:
3094
+ raise UsageError(f"--color: {value!r} is not a hex colour", field="color") from exc
3095
+
3096
+
3097
+ SPEC_WALLPAPER_SET = OperationSpec(
3098
+ id="chat.wallpaper.set",
3099
+ request=WallpaperSetReq,
3100
+ response=WallpaperResult,
3101
+ impl=wallpaper_set,
3102
+ summary="Set, apply, revert or remove the wallpaper of one chat",
3103
+ aliases=("chat.wallpaper.unset",),
3104
+ mutating=True,
3105
+ idempotent=True,
3106
+ rate_class="file",
3107
+ timeout_s=300,
3108
+ columns=("chat_id", "wallpaper.slug"),
3109
+ example={"chat_id": 777123, "wallpaper": {"slug": "pattern"}, "for_both": False},
3110
+ example_args="chat wallpaper set @alice --slug pattern",
3111
+ covers=(
3112
+ "contacts-users.user-wallpaper",
3113
+ "dialogs.chat-wallpaper-apply-suggested",
3114
+ "dialogs.chat-wallpaper-revert",
3115
+ "stories.story-set-wallpaper",
3116
+ "wallpaper.set-for-channel-group",
3117
+ "wallpaper.set-for-chat",
3118
+ ),
3119
+ tags=frozenset({"visible-to-others"}),
3120
+ )
3121
+
3122
+
3123
+ class TranslateReq(Request):
3124
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
3125
+ state: Annotated[str, arg(1, metavar="STATE", help="on | off.")]
3126
+
3127
+
3128
+ async def translate(ctx: OpContext, req: TranslateReq) -> TranslateResult:
3129
+ """Turn Telegram's translation bar on or off for one chat.
3130
+
3131
+ `off` stores `translations_disabled=true` — the GUI's "Don't translate".
3132
+ Translating actual messages is `message translate`.
3133
+ """
3134
+ from telethon.tl.functions import messages as fn
3135
+
3136
+ state = req.state.strip().lower()
3137
+ if state not in ("on", "off"):
3138
+ raise UsageError("state must be 'on' or 'off'", field="state")
3139
+ peer = await _send.resolve(ctx, req.chat)
3140
+ chat_id = _send.peer_id_of(peer)
3141
+ disabled = state == "off"
3142
+ await _client(ctx)(fn.TogglePeerTranslationsRequest(peer=peer, disabled=disabled or None))
3143
+ ctx.emit("chat_translate", {"chat_id": chat_id, "disabled": disabled})
3144
+ return TranslateResult(chat_id=chat_id, translations_disabled=disabled)
3145
+
3146
+
3147
+ SPEC_TRANSLATE = OperationSpec(
3148
+ id="chat.translate",
3149
+ request=TranslateReq,
3150
+ response=TranslateResult,
3151
+ impl=translate,
3152
+ summary="Turn Telegram's translation bar on or off for a chat",
3153
+ mutating=True,
3154
+ idempotent=True,
3155
+ columns=("chat_id", "translations_disabled"),
3156
+ example={"chat_id": 777123, "translations_disabled": True},
3157
+ example_args="chat translate @alice off",
3158
+ covers=(
3159
+ "dialogs.translate-toggle",
3160
+ "lang.chat-autotranslate",
3161
+ "messages-core.translate-chat-toggle",
3162
+ ),
3163
+ )
3164
+
3165
+
3166
+ class SetReq(Request):
3167
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
3168
+ sharing: Annotated[
3169
+ str | None, choice("on", "off", help="Allow forwarding and saving from this chat.")
3170
+ ] = None
3171
+ request_msg: Annotated[
3172
+ int | None,
3173
+ opt("--request-msg", metavar="ID", kind="msg_id", help="The message that asked."),
3174
+ ] = None
3175
+ view_as: Annotated[
3176
+ str | None, choice("topics", "messages", help="Show a forum as topics or one list.")
3177
+ ] = None
3178
+ send_as: Annotated[
3179
+ PeerRef | None,
3180
+ opt("--send-as", metavar="PEER", kind="peer", help="Default identity to post as."),
3181
+ ] = None
3182
+ send_as_list: Annotated[
3183
+ bool, opt("--send-as-list", help="List the identities you may post as and stop.")
3184
+ ] = False
3185
+
3186
+
3187
+ async def set_chat(ctx: OpContext, req: SetReq) -> ChatSwitches:
3188
+ """The per-dialog switches: content sharing, forum view mode, send-as.
3189
+
3190
+ Send-as is a per-*dialog* setting rather than a per-message flag — it is
3191
+ mirrored in chatFull/channelFull — which is why it lives here and not in
3192
+ `message send`.
3193
+ """
3194
+ from telethon.tl.functions import channels as cfn
3195
+ from telethon.tl.functions import messages as fn
3196
+
3197
+ client = _client(ctx)
3198
+ peer = await _send.resolve(ctx, req.chat)
3199
+ chat_id = _send.peer_id_of(peer)
3200
+ out = ChatSwitches(chat_id=chat_id)
3201
+
3202
+ if req.send_as_list:
3203
+ result = await client(cfn.GetSendAsRequest(peer=peer))
3204
+ out.send_as_options = [
3205
+ entity_to_peer(entity) for entity in (getattr(result, "chats", None) or [])
3206
+ ] + [entity_to_peer(entity) for entity in (getattr(result, "users", None) or [])]
3207
+ return out
3208
+
3209
+ if req.sharing is not None:
3210
+ await client(
3211
+ fn.ToggleNoForwardsRequest(
3212
+ peer=peer, enabled=req.sharing == "off", request_msg_id=req.request_msg
3213
+ )
3214
+ )
3215
+ out.noforwards = req.sharing == "off"
3216
+ if req.view_as is not None:
3217
+ await client(
3218
+ cfn.ToggleViewForumAsMessagesRequest(
3219
+ channel=_input_channel(peer), enabled=req.view_as == "messages"
3220
+ )
3221
+ )
3222
+ out.view_forum_as_messages = req.view_as == "messages"
3223
+ if req.send_as is not None:
3224
+ identity = await _send.resolve(ctx, req.send_as)
3225
+ await client(fn.SaveDefaultSendAsRequest(peer=peer, send_as=identity))
3226
+ out.default_send_as = _send.peer_id_of(identity)
3227
+ if req.sharing is None and req.view_as is None and req.send_as is None:
3228
+ raise UsageError("give --sharing, --view-as, --send-as or --send-as-list", field="sharing")
3229
+ ctx.emit("chat_set", {"chat_id": chat_id})
3230
+ return out
3231
+
3232
+
3233
+ SPEC_SET = OperationSpec(
3234
+ id="chat.set",
3235
+ request=SetReq,
3236
+ response=ChatSwitches,
3237
+ impl=set_chat,
3238
+ summary="Per-dialog switches: content sharing, forum view mode, send-as",
3239
+ description=(
3240
+ "`--sharing off` is `noforwards`, which needs Premium in a private "
3241
+ "chat and owner rights elsewhere. `--send-as-list` reports the "
3242
+ "identities available and changes nothing."
3243
+ ),
3244
+ mutating=True,
3245
+ idempotent=True,
3246
+ tags=frozenset({"mutating-checked"}),
3247
+ columns=("chat_id", "noforwards", "default_send_as"),
3248
+ example={"chat_id": 777123, "noforwards": True},
3249
+ example_args="chat set @alice --sharing off",
3250
+ covers=(
3251
+ "dialogs.no-forwards-private",
3252
+ "dialogs.send-as-default",
3253
+ "dialogs.view-as-topics",
3254
+ ),
3255
+ )
3256
+
3257
+
3258
+ # ---------------------------------------------------------------------------
3259
+ # chat action-bar / badge / promo
3260
+ # ---------------------------------------------------------------------------
3261
+
3262
+
3263
+ class ActionBarGetReq(Request):
3264
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
3265
+ hide: Annotated[bool, opt("--hide", help="Dismiss the bar.")] = False
3266
+
3267
+
3268
+ async def action_bar_get(ctx: OpContext, req: ActionBarGetReq) -> ActionBar:
3269
+ """The anti-spam info box Telegram draws above a chat with a stranger.
3270
+
3271
+ `registration_month`, `phone_country`, `name_change_date` and
3272
+ `photo_change_date` are the strongest cold-outreach triage signals a
3273
+ client is ever given, and no other call reports them; `geo_distance`
3274
+ appears when the peer found you through People Nearby.
3275
+ """
3276
+ from telethon.tl.functions import messages as fn
3277
+
3278
+ client = _client(ctx)
3279
+ peer = await _send.resolve(ctx, req.chat)
3280
+ chat_id = _send.peer_id_of(peer)
3281
+ result = await client(fn.GetPeerSettingsRequest(peer=peer))
3282
+ settings = getattr(result, "settings", result)
3283
+ model = action_bar(settings, chat_id=chat_id)
3284
+
3285
+ if req.hide and ctx.dry_run:
3286
+ ctx.warn("--dry-run: the action bar would be dismissed")
3287
+ elif req.hide:
3288
+ await client(fn.HidePeerSettingsBarRequest(peer=peer))
3289
+ model.hidden = True
3290
+ ctx.emit("chat_action_bar", {"chat_id": chat_id, "hidden": True})
3291
+ return model
3292
+
3293
+
3294
+ SPEC_ACTION_BAR_GET = OperationSpec(
3295
+ id="chat.action-bar.get",
3296
+ request=ActionBarGetReq,
3297
+ response=ActionBar,
3298
+ impl=action_bar_get,
3299
+ summary="Read or dismiss the action bar of a chat (the anti-spam info box)",
3300
+ description=(
3301
+ "The bar's own buttons live elsewhere: `contact add`, "
3302
+ "`contact share-phone`, `user block`, `chat report --spam` and "
3303
+ "`chat archive --undo`. `--hide` dismisses the bar and honours "
3304
+ "--dry-run itself, so reading it stays available under one."
3305
+ ),
3306
+ tags=frozenset({"mutating-checked"}),
3307
+ columns=("chat_id", "report_spam", "add_contact", "phone_country"),
3308
+ example={"chat_id": 777123, "report_spam": True, "phone_country": "DE"},
3309
+ example_args="chat action-bar get @alice",
3310
+ covers=(
3311
+ "contacts-users.nearby-geo-distance",
3312
+ "contacts-users.user-action-bar",
3313
+ "contacts-users.user-action-bar-hide",
3314
+ "dialogs.actionbar-invite-members",
3315
+ "dialogs.peer-settings-get",
3316
+ ),
3317
+ )
3318
+
3319
+
3320
+ class BadgeGetReq(Request):
3321
+ folder: Annotated[
3322
+ str, opt("--folder", metavar="FOLDER", help="Scope the badge to a folder.")
3323
+ ] = "all"
3324
+ include_muted: Annotated[
3325
+ bool, opt("--include-muted", help="Count muted chats toward the badge.")
3326
+ ] = False
3327
+ count: Annotated[str, choice("chats", "messages", help="Count chats or messages.")] = "chats"
3328
+ limits: Annotated[bool, opt("--limits", help="Also report the chat-list limits.")] = False
3329
+
3330
+
3331
+ async def badge_get(ctx: OpContext, req: BadgeGetReq) -> Badge:
3332
+ """The unread badge, computed the way a client computes it.
3333
+
3334
+ There is no server-side total: every official client walks its own dialog
3335
+ list and adds up. This does the same, which is why the muted split is
3336
+ reported rather than folded in — "12 unread, 9 of them muted" is a
3337
+ different fact from "12 unread".
3338
+ """
3339
+ dialogs = await _all_dialogs(ctx, folder_id=None)
3340
+ archived = await _all_dialogs(ctx, folder_id=FOLDER_ARCHIVE)
3341
+ seen = {d.chat.id for d in dialogs}
3342
+ dialogs += [d for d in archived if d.chat.id not in seen]
3343
+
3344
+ chat_filter = await _read_filter(ctx, req.folder)
3345
+ if chat_filter is not None:
3346
+ dialogs = [d for d in dialogs if _matches_filter(d, chat_filter)]
3347
+ elif _is_peer_folder(req.folder):
3348
+ wanted = _peer_folder(req.folder)
3349
+ dialogs = [d for d in dialogs if d.folder_id == wanted]
3350
+
3351
+ badge = Badge()
3352
+ for dialog in dialogs:
3353
+ muted = dialog.notify is not None and dialog.notify.muted
3354
+ unread = dialog.unread_count or (1 if dialog.unread_mark else 0)
3355
+ if not unread:
3356
+ continue
3357
+ if muted:
3358
+ badge.muted_chats += 1
3359
+ badge.muted_messages += dialog.unread_count
3360
+ if not req.include_muted:
3361
+ continue
3362
+ badge.chats += 1
3363
+ badge.messages += dialog.unread_count
3364
+ badge.mentions += dialog.unread_mentions_count
3365
+ badge.reactions += dialog.unread_reactions_count
3366
+
3367
+ filters, _ = await _folder_filters(ctx)
3368
+ for raw in filters:
3369
+ from tlgr.ops.folder import folder_model
3370
+
3371
+ model = folder_model(raw)
3372
+ if model.is_default:
3373
+ continue
3374
+ members = [d for d in dialogs if _matches_filter(d, raw)]
3375
+ badge.folders.append(
3376
+ FolderBadge(
3377
+ id=model.id,
3378
+ title=model.title,
3379
+ chats=sum(1 for d in members if d.unread_count or d.unread_mark),
3380
+ messages=sum(d.unread_count for d in members),
3381
+ )
3382
+ )
3383
+
3384
+ if req.limits:
3385
+ badge.limits = await _chat_limits(ctx)
3386
+ return badge
3387
+
3388
+
3389
+ async def _folder_filters(ctx: OpContext) -> tuple[list[Any], bool]:
3390
+ from tlgr.ops.folder import raw_filters
3391
+
3392
+ return await raw_filters(ctx)
3393
+
3394
+
3395
+ _LIMIT_KEYS = (
3396
+ "dialog_filters_limit_default",
3397
+ "dialog_filters_limit_premium",
3398
+ "dialog_filters_chats_limit_default",
3399
+ "dialog_filters_chats_limit_premium",
3400
+ "dialogs_pinned_limit_default",
3401
+ "dialogs_pinned_limit_premium",
3402
+ "dialogs_folder_pinned_limit_default",
3403
+ "dialogs_folder_pinned_limit_premium",
3404
+ "chatlist_invites_limit_default",
3405
+ "chatlist_invites_limit_premium",
3406
+ "chatlist_joined_limit_default",
3407
+ "chatlist_joined_limit_premium",
3408
+ "channels_limit_default",
3409
+ "channels_limit_premium",
3410
+ "saved_dialogs_pinned_limit_default",
3411
+ "saved_dialogs_pinned_limit_premium",
3412
+ )
3413
+
3414
+
3415
+ async def _chat_limits(ctx: OpContext) -> dict[str, Any]:
3416
+ """The chat-list limits out of `help.getAppConfig`, named rather than dumped."""
3417
+ from telethon.tl.functions import help as fn
3418
+
3419
+ result = await _client(ctx)(fn.GetAppConfigRequest(hash=0))
3420
+ config = getattr(result, "config", result)
3421
+ values = _json_object(config)
3422
+ return {key: values[key] for key in _LIMIT_KEYS if key in values}
3423
+
3424
+
3425
+ def _json_object(node: Any) -> dict[str, Any]:
3426
+ """A TL `JSONObject` as a plain dict, one level deep."""
3427
+ out: dict[str, Any] = {}
3428
+ for entry in getattr(node, "value", None) or []:
3429
+ key = getattr(entry, "key", None)
3430
+ value = getattr(entry, "value", None)
3431
+ if key is None:
3432
+ continue
3433
+ out[str(key)] = getattr(value, "value", None)
3434
+ return out
3435
+
3436
+
3437
+ SPEC_BADGE_GET = OperationSpec(
3438
+ id="chat.badge.get",
3439
+ request=BadgeGetReq,
3440
+ response=Badge,
3441
+ impl=badge_get,
3442
+ summary="Aggregate unread badge and the chat-list limits behind it",
3443
+ description=(
3444
+ "Computed locally from the dialog list, because no server-side total "
3445
+ "exists. Muted chats are counted separately rather than silently."
3446
+ ),
3447
+ timeout_s=300,
3448
+ columns=("chats", "messages", "mentions", "reactions"),
3449
+ example={"chats": 4, "messages": 17, "mentions": 1, "reactions": 0},
3450
+ example_args="chat badge get",
3451
+ covers=("dialogs.badge-preferences", "dialogs.limits", "dialogs.unread-counters"),
3452
+ )
3453
+
3454
+
3455
+ class PromoListReq(Request):
3456
+ dismiss: Annotated[
3457
+ str | None, opt("--dismiss", metavar="KEY", help="Dismiss one suggestion key.")
3458
+ ] = None
3459
+ hide_promo: Annotated[bool, opt("--hide-promo", help="Hide the promoted / PSA dialog.")] = False
3460
+
3461
+
3462
+ async def promo_list(ctx: OpContext, req: PromoListReq) -> Promo:
3463
+ """The rows a client puts *above* the chat list.
3464
+
3465
+ `BIRTHDAY_CONTACTS_TODAY` is an inverted suggestion: its presence in the
3466
+ dismissed list means "do not show the bar", so it is reported in both
3467
+ lists rather than translated into a boolean that would read backwards.
3468
+ """
3469
+ from telethon.tl.functions import contacts as cfn
3470
+ from telethon.tl.functions import help as fn
3471
+
3472
+ client = _client(ctx)
3473
+ # `help.promoData` carries the suggestion lists *and* the promoted dialog,
3474
+ # so one call answers both halves of this command.
3475
+ data = await client(fn.GetPromoDataRequest())
3476
+ promo = Promo(
3477
+ pending_suggestions=[str(v) for v in (getattr(data, "pending_suggestions", None) or [])],
3478
+ dismissed_suggestions=[
3479
+ str(v) for v in (getattr(data, "dismissed_suggestions", None) or [])
3480
+ ],
3481
+ )
3482
+ peer = getattr(data, "peer", None)
3483
+ if peer is not None:
3484
+ promo.promo_peer = peer_id_of(peer)
3485
+ promo.psa_type = getattr(data, "psa_type", None)
3486
+ promo.psa_message = getattr(data, "psa_message", None)
3487
+
3488
+ try:
3489
+ birthdays = await client(cfn.GetBirthdaysRequest())
3490
+ promo.birthdays_today = [
3491
+ int(getattr(entry, "contact_id", 0) or 0)
3492
+ for entry in (getattr(birthdays, "contacts", None) or [])
3493
+ ]
3494
+ except Exception as exc: # pragma: no cover - optional surface
3495
+ ctx.warn(f"birthdays unavailable: {_error_text(exc)}")
3496
+
3497
+ if req.dismiss:
3498
+ from telethon.tl import types
3499
+
3500
+ await client(
3501
+ fn.DismissSuggestionRequest(peer=types.InputPeerEmpty(), suggestion=req.dismiss)
3502
+ )
3503
+ if req.dismiss not in promo.dismissed_suggestions:
3504
+ promo.dismissed_suggestions.append(req.dismiss)
3505
+ if req.hide_promo and promo.promo_peer is not None:
3506
+ await client(fn.HidePromoDataRequest(peer=await _send.resolve(ctx, str(promo.promo_peer))))
3507
+ promo.hidden = True
3508
+ return promo
3509
+
3510
+
3511
+ SPEC_PROMO_LIST = OperationSpec(
3512
+ id="chat.promo.list",
3513
+ request=PromoListReq,
3514
+ response=Promo,
3515
+ impl=promo_list,
3516
+ summary="Chat-list top rows: pending suggestions, birthday bar, PSA promo",
3517
+ aliases=("chat.suggestions",),
3518
+ mutating=True,
3519
+ idempotent=True,
3520
+ columns=("pending_suggestions", "promo_peer"),
3521
+ example={"pending_suggestions": ["VALIDATE_PHONE_NUMBER"], "dismissed_suggestions": []},
3522
+ example_args="chat promo list",
3523
+ covers=(
3524
+ "contacts-users.contacts-birthday-dismiss",
3525
+ "dialogs.birthday-bar",
3526
+ "dialogs.promo-psa",
3527
+ "dialogs.suggestions-dismiss",
3528
+ ),
3529
+ )
3530
+
3531
+
3532
+ # ---------------------------------------------------------------------------
3533
+ # chat saved list
3534
+ # ---------------------------------------------------------------------------
3535
+
3536
+
3537
+ class SavedListReq(Request):
3538
+ in_: Annotated[
3539
+ PeerRef | None,
3540
+ opt("--in", metavar="CHAT", kind="peer", help="me/saved, or a monoforum channel."),
3541
+ ] = None
3542
+ pinned: Annotated[bool, opt("--pinned", help="Only pinned sublists, in order.")] = False
3543
+ unread: Annotated[bool, opt("--unread", help="Only sublists with unread messages.")] = False
3544
+
3545
+
3546
+ async def saved_list(ctx: OpContext, req: SavedListReq) -> Page[SavedDialog]:
3547
+ """Saved-Messages sublists, or a channel's direct-message topics.
3548
+
3549
+ One call family answers both: a monoforum topic *is* a saved dialog whose
3550
+ `parent_peer` is the channel. Reading, clearing and pinning one are the
3551
+ `--saved-peer` flags on `chat read`, `chat clear` and `chat pin`.
3552
+ """
3553
+ from telethon.tl import types
3554
+ from telethon.tl.functions import messages as fn
3555
+
3556
+ limit, state = _window(ctx, "chat.saved.list", PageKind.DIALOGS, default=50)
3557
+ client = _client(ctx)
3558
+ parent = await _send.resolve(ctx, req.in_) if req.in_ is not None else None
3559
+ parent_id = _send.peer_id_of(parent) if parent is not None else None
3560
+
3561
+ if req.pinned:
3562
+ result = await client(fn.GetPinnedSavedDialogsRequest())
3563
+ else:
3564
+ result = await client(
3565
+ fn.GetSavedDialogsRequest(
3566
+ offset_date=None,
3567
+ offset_id=int(state.get("offset_id", 0) or 0),
3568
+ offset_peer=types.InputPeerEmpty(),
3569
+ limit=limit,
3570
+ hash=0,
3571
+ parent_peer=parent,
3572
+ )
3573
+ )
3574
+
3575
+ entities = _entity_map(result)
3576
+ messages = {int(getattr(m, "id", 0) or 0): m for m in (getattr(result, "messages", None) or [])}
3577
+ items: list[SavedDialog] = []
3578
+ for row in getattr(result, "dialogs", None) or []:
3579
+ origin_id = peer_id_of(getattr(row, "peer", None)) or 0
3580
+ entity = entities.get(origin_id)
3581
+ top_id = int(getattr(row, "top_message", 0) or 0)
3582
+ top = messages.get(top_id)
3583
+ items.append(
3584
+ SavedDialog(
3585
+ origin_peer=entity_to_peer(entity) if entity is not None else None,
3586
+ origin_id=origin_id,
3587
+ parent_peer=parent_id,
3588
+ top_message_id=top_id or None,
3589
+ pinned=bool(getattr(row, "pinned", False)) or req.pinned,
3590
+ unread_count=int(getattr(row, "unread_count", 0) or 0),
3591
+ unread_mark=bool(getattr(row, "unread_mark", False)),
3592
+ date=fmt_dt(getattr(top, "date", None)) if top is not None else None,
3593
+ date_unix=to_unix(getattr(top, "date", None)) if top is not None else None,
3594
+ )
3595
+ )
3596
+ if req.unread:
3597
+ items = [i for i in items if i.unread_count or i.unread_mark]
3598
+
3599
+ next_state = {"offset_id": items[-1].top_message_id or 0} if items and not req.pinned else {}
3600
+ return build_page(
3601
+ items,
3602
+ op="chat.saved.list",
3603
+ kind=PageKind.DIALOGS,
3604
+ state=next_state,
3605
+ account=ctx.account,
3606
+ limit=None if req.pinned else limit,
3607
+ has_more=False if req.pinned else None,
3608
+ total=getattr(result, "count", None),
3609
+ )
3610
+
3611
+
3612
+ SPEC_SAVED_LIST = OperationSpec(
3613
+ id="chat.saved.list",
3614
+ request=SavedListReq,
3615
+ response=Page[SavedDialog],
3616
+ impl=saved_list,
3617
+ summary="Saved-Messages sublists and channel direct-message topics",
3618
+ paginated=PageKind.DIALOGS,
3619
+ columns=("origin_id", "unread_count", "pinned"),
3620
+ headers=("Origin", "Unread", "Pinned"),
3621
+ example={"items": [{"origin_id": 4242, "unread_count": 0}], "has_more": False},
3622
+ example_args="chat saved list",
3623
+ covers=("dialogs.monoforum-topics", "dialogs.saved-sublists"),
3624
+ )
3625
+
3626
+
3627
+ # ---------------------------------------------------------------------------
3628
+ # chat report
3629
+ # ---------------------------------------------------------------------------
3630
+
3631
+
3632
+ class ReportReq(Request):
3633
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat to report.")]
3634
+ messages: Annotated[
3635
+ list[int], opt("--messages", metavar="ID", kind="msg_id", help="Message ids to report.")
3636
+ ] = []
3637
+ option: Annotated[
3638
+ str | None, opt("--option", metavar="HEX", help="Option bytes from the previous step.")
3639
+ ] = None
3640
+ comment: Annotated[
3641
+ str | None, opt("--comment", metavar="TEXT", help="Free text when the tree asks.")
3642
+ ] = None
3643
+ spam: Annotated[bool, opt("--spam", help="One-shot action-bar report instead of the tree.")] = (
3644
+ False
3645
+ )
3646
+ reason: Annotated[
3647
+ str | None, choice(*sorted(_REPORT_REASONS), help="Legacy account.reportPeer reason.")
3648
+ ] = None
3649
+ block: Annotated[bool, opt("--block", help="Block the peer afterwards.")] = False
3650
+ delete: Annotated[bool, opt("--delete", help="Delete the chat afterwards.")] = False
3651
+
3652
+
3653
+ async def report(ctx: OpContext, req: ReportReq) -> ReportResult:
3654
+ """Report a chat, a user or specific messages.
3655
+
3656
+ The modern flow is a server-driven state machine: the first call answers
3657
+ with a menu, each `--option` walks one level deeper, and the last step
3658
+ may ask for a comment. **Option bytes are opaque and session-specific** —
3659
+ they are echoed as hex for the next invocation and must never be stored.
3660
+ """
3661
+ from telethon.tl import types
3662
+ from telethon.tl.functions import account as afn
3663
+ from telethon.tl.functions import messages as fn
3664
+
3665
+ client = _client(ctx)
3666
+ peer = await _send.resolve(ctx, req.chat)
3667
+ chat_id = _send.peer_id_of(peer)
3668
+
3669
+ if req.spam:
3670
+ await client(fn.ReportSpamRequest(peer=peer))
3671
+ await client(fn.HidePeerSettingsBarRequest(peer=peer))
3672
+ result = ReportResult(ok=True)
3673
+ elif req.reason is not None:
3674
+ reason = getattr(types, _REPORT_REASONS[req.reason])()
3675
+ await client(afn.ReportPeerRequest(peer=peer, reason=reason, message=req.comment or ""))
3676
+ result = ReportResult(ok=True)
3677
+ else:
3678
+ result = await _report_tree(ctx, peer, req)
3679
+
3680
+ if result.ok and req.block:
3681
+ from telethon.tl.functions import contacts as cfn
3682
+
3683
+ await client(cfn.BlockRequest(id=peer))
3684
+ if result.ok and req.delete:
3685
+ await _affected_loop(
3686
+ ctx, lambda offset, p=peer: fn.DeleteHistoryRequest(peer=p, max_id=0, revoke=False)
3687
+ )
3688
+ if result.ok:
3689
+ ctx.emit("chat_report", {"chat_id": chat_id})
3690
+ return result
3691
+
3692
+
3693
+ async def _report_tree(ctx: OpContext, peer: Any, req: ReportReq) -> ReportResult:
3694
+ from telethon.tl.functions import messages as fn
3695
+
3696
+ option = b""
3697
+ if req.option:
3698
+ try:
3699
+ option = bytes.fromhex(req.option)
3700
+ except ValueError as exc:
3701
+ raise UsageError(
3702
+ "--option takes the hex bytes printed by the previous step", field="option"
3703
+ ) from exc
3704
+
3705
+ answer = await _client(ctx)(
3706
+ fn.ReportRequest(
3707
+ peer=peer,
3708
+ id=[int(i) for i in req.messages],
3709
+ option=option,
3710
+ message=req.comment or "",
3711
+ )
3712
+ )
3713
+ name = type(answer).__name__
3714
+ if name == "ReportResultChooseOption":
3715
+ return ReportResult(
3716
+ ok=False,
3717
+ title=str(getattr(answer, "title", "") or ""),
3718
+ options=[
3719
+ {
3720
+ "text": str(getattr(item, "text", "") or ""),
3721
+ "option": bytes(getattr(item, "option", b"") or b"").hex(),
3722
+ }
3723
+ for item in (getattr(answer, "options", None) or [])
3724
+ ],
3725
+ )
3726
+ if name == "ReportResultAddComment":
3727
+ return ReportResult(
3728
+ ok=False,
3729
+ comment_required=not bool(getattr(answer, "optional", False)),
3730
+ options=[{"option": bytes(getattr(answer, "option", b"") or b"").hex()}],
3731
+ )
3732
+ return ReportResult(ok=True)
3733
+
3734
+
3735
+ SPEC_REPORT = OperationSpec(
3736
+ id="chat.report",
3737
+ request=ReportReq,
3738
+ response=ReportResult,
3739
+ impl=report,
3740
+ summary="Report a chat, a user or specific messages",
3741
+ description=(
3742
+ "Without --spam or --reason this walks Telegram's own option tree: "
3743
+ "call it, read `options`, call again with `--option <hex>`. The bytes "
3744
+ "are opaque and path-specific — never persist them."
3745
+ ),
3746
+ aliases=("chat.report-spam",),
3747
+ mutating=True,
3748
+ destructive=True,
3749
+ columns=("ok", "title"),
3750
+ example={"ok": False, "title": "What is wrong?", "options": []},
3751
+ example_args="chat report @spammer --spam --yes",
3752
+ covers=(
3753
+ "contacts-users.user-report-messages",
3754
+ "contacts-users.user-report-spam",
3755
+ "dialogs.report-chat",
3756
+ "dialogs.report-spam-bar",
3757
+ "dialogs.report-spam-supergroup",
3758
+ "groups-channels-admin.report-chat",
3759
+ "groups-channels-admin.report-chat-photo",
3760
+ "privacy.report-profile-photo",
3761
+ ),
3762
+ tags=frozenset({"visible-to-others"}),
3763
+ )
3764
+
3765
+
3766
+ # ---------------------------------------------------------------------------
3767
+ # chat secret
3768
+ # ---------------------------------------------------------------------------
3769
+
3770
+
3771
+ class SecretListReq(Request):
3772
+ requests: Annotated[
3773
+ bool, opt("--requests", help="Only incoming, not-yet-accepted requests.")
3774
+ ] = False
3775
+ fingerprint: Annotated[bool, opt("--fingerprint", help="Include the key fingerprint.")] = False
3776
+
3777
+
3778
+ async def secret_list(ctx: OpContext, req: SecretListReq) -> Page[SecretChat]:
3779
+ """Secret chats this session holds — which tlgr cannot enumerate yet."""
3780
+ raise NotSupportedError(f"chat secret list is not supported: {SECRET_UNSUPPORTED}")
3781
+
3782
+
3783
+ SPEC_SECRET_LIST = OperationSpec(
3784
+ id="chat.secret.list",
3785
+ request=SecretListReq,
3786
+ response=Page[SecretChat],
3787
+ impl=secret_list,
3788
+ summary="Secret chats this session holds, with state and key fingerprint",
3789
+ description=(
3790
+ "NOT SUPPORTED. Secret chats never appear in `messages.getDialogs` "
3791
+ "and are bound to the one session that created them, so listing them "
3792
+ "needs the local key store the E2E module would own."
3793
+ ),
3794
+ columns=("id", "state"),
3795
+ example={"items": [], "has_more": False},
3796
+ example_args="chat secret list",
3797
+ covers=(
3798
+ "contacts-users.user-secret-chat",
3799
+ "dialogs.secret-fingerprint",
3800
+ "dialogs.secret-list",
3801
+ ),
3802
+ coverage_note="Registered and refused with NOT_SUPPORTED (exit 13) until the E2E module exists.",
3803
+ )
3804
+
3805
+
3806
+ class SecretStartReq(Request):
3807
+ user: Annotated[
3808
+ PeerRef | None,
3809
+ arg(0, metavar="USER", required=False, kind="user", help="Who to start it with."),
3810
+ ] = None
3811
+ accept: Annotated[
3812
+ int | None, opt("--accept", metavar="ID", help="Accept this incoming secret chat.")
3813
+ ] = None
3814
+
3815
+
3816
+ async def secret_start(ctx: OpContext, req: SecretStartReq) -> SecretChat:
3817
+ """Start or accept a secret chat — which needs the E2E module."""
3818
+ raise NotSupportedError(f"chat secret start is not supported: {SECRET_UNSUPPORTED}")
3819
+
3820
+
3821
+ SPEC_SECRET_START = OperationSpec(
3822
+ id="chat.secret.start",
3823
+ request=SecretStartReq,
3824
+ response=SecretChat,
3825
+ impl=secret_start,
3826
+ summary="Start a secret chat with a user, or accept an incoming request",
3827
+ description=(
3828
+ "NOT SUPPORTED. `requestEncryption`/`acceptEncryption` need a "
3829
+ "validated Diffie-Hellman exchange tlgr cannot perform yet."
3830
+ ),
3831
+ mutating=True,
3832
+ aliases=("chat.secret.accept",),
3833
+ columns=("id", "state"),
3834
+ example={"id": 0, "state": "unsupported"},
3835
+ example_args="chat secret start @alice",
3836
+ covers=("dialogs.secret-accept", "dialogs.secret-create"),
3837
+ coverage_note="Registered and refused with NOT_SUPPORTED (exit 13) until the E2E module exists.",
3838
+ )
3839
+
3840
+
3841
+ class SecretSendReq(Request):
3842
+ id: Annotated[int, arg(0, metavar="ID", help="Secret chat id.")]
3843
+ text: Annotated[str, arg(1, metavar="TEXT", required=False, help="What to send.")] = ""
3844
+ ttl: Annotated[
3845
+ int | None, opt("--ttl", metavar="DURATION", kind="duration", help="Self-destruct timer.")
3846
+ ] = None
3847
+ read: Annotated[bool, opt("--read", help="Acknowledge up to now.")] = False
3848
+ typing: Annotated[bool, opt("--typing", help="Show the typing indicator.")] = False
3849
+
3850
+
3851
+ async def secret_send(ctx: OpContext, req: SecretSendReq) -> SecretChat:
3852
+ """Send into a secret chat — which needs the E2E module."""
3853
+ raise NotSupportedError(f"chat secret send is not supported: {SECRET_UNSUPPORTED}")
3854
+
3855
+
3856
+ SPEC_SECRET_SEND = OperationSpec(
3857
+ id="chat.secret.send",
3858
+ request=SecretSendReq,
3859
+ response=SecretChat,
3860
+ impl=secret_send,
3861
+ summary="Send into a secret chat, set its timer, ack or show typing",
3862
+ description=(
3863
+ "NOT SUPPORTED. The payload of `messages.sendEncrypted` is a "
3864
+ "separately-serialised, AES-IGE-encrypted message with its own "
3865
+ "sequence numbers; Telethon builds none of it."
3866
+ ),
3867
+ mutating=True,
3868
+ columns=("id", "state"),
3869
+ example={"id": 0, "state": "unsupported"},
3870
+ example_args="chat secret send 12 hello",
3871
+ covers=("dialogs.secret-read-typing", "dialogs.secret-send", "dialogs.secret-ttl"),
3872
+ coverage_note="Registered and refused with NOT_SUPPORTED (exit 13) until the E2E module exists.",
3873
+ )
3874
+
3875
+
3876
+ class SecretDiscardReq(Request):
3877
+ id: Annotated[int, arg(0, metavar="ID", help="Secret chat id.")]
3878
+ delete_history: Annotated[bool, opt("--delete-history", help="Also delete the history.")] = (
3879
+ False
3880
+ )
3881
+ report_spam: Annotated[bool, opt("--report-spam", help="Report it as spam.")] = False
3882
+
3883
+
3884
+ async def secret_discard(ctx: OpContext, req: SecretDiscardReq) -> SecretChat:
3885
+ """Discard a secret chat — the one secret-chat operation that works today.
3886
+
3887
+ Discarding needs no key material: the chat id is enough, which is why
3888
+ this is implemented while its siblings are not.
3889
+ """
3890
+ from telethon.tl import types
3891
+ from telethon.tl.functions import messages as fn
3892
+
3893
+ client = _client(ctx)
3894
+ if req.report_spam:
3895
+ await client(
3896
+ fn.ReportEncryptedSpamRequest(
3897
+ peer=types.InputEncryptedChat(chat_id=req.id, access_hash=0)
3898
+ )
3899
+ )
3900
+ await client(
3901
+ fn.DiscardEncryptionRequest(chat_id=req.id, delete_history=req.delete_history or None)
3902
+ )
3903
+ ctx.emit("chat_secret_discard", {"id": req.id})
3904
+ return SecretChat(id=req.id, state="discarded", discarded=True)
3905
+
3906
+
3907
+ SPEC_SECRET_DISCARD = OperationSpec(
3908
+ id="chat.secret.discard",
3909
+ request=SecretDiscardReq,
3910
+ response=SecretChat,
3911
+ impl=secret_discard,
3912
+ summary="Discard a secret chat, optionally deleting it or reporting spam",
3913
+ mutating=True,
3914
+ destructive=True,
3915
+ columns=("id", "discarded"),
3916
+ example={"id": 12, "state": "discarded", "discarded": True},
3917
+ example_args="chat secret discard 12 --yes",
3918
+ covers=("dialogs.report-encrypted-spam", "dialogs.secret-discard"),
3919
+ )
3920
+
3921
+
3922
+ # ---------------------------------------------------------------------------
3923
+ # chat import
3924
+ # ---------------------------------------------------------------------------
3925
+
3926
+
3927
+ class ImportReq(Request):
3928
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Where to import into.")]
3929
+ export: Annotated[
3930
+ str, arg(1, metavar="EXPORT", kind="path", help="Exported .txt from the other app.")
3931
+ ]
3932
+ media_dir: Annotated[
3933
+ str | None,
3934
+ opt("--media-dir", metavar="PATH", kind="path", help="Attachments the export names."),
3935
+ ] = None
3936
+ check: Annotated[
3937
+ bool, opt("--check", help="Only report whether the import would be accepted.")
3938
+ ] = False
3939
+
3940
+
3941
+ async def import_history(ctx: OpContext, req: ImportReq) -> ImportState:
3942
+ """Import a chat history exported from another messenger.
3943
+
3944
+ Two checks come first and are worth running on their own (`--check`):
3945
+ the peer must accept imports at all, and the file's first lines must
3946
+ parse. `PREVIOUS_CHAT_IMPORT_ACTIVE_WAIT_XMIN` is a retry, not a failure.
3947
+ """
3948
+ from pathlib import Path
3949
+
3950
+ from telethon.tl.functions import messages as fn
3951
+
3952
+ client = _client(ctx)
3953
+ peer = await _send.resolve(ctx, req.chat)
3954
+ chat_id = _send.peer_id_of(peer)
3955
+
3956
+ path = Path(req.export).expanduser()
3957
+ if not path.exists():
3958
+ raise UsageError(f"{req.export} does not exist", field="export")
3959
+ head = path.read_text(encoding="utf-8", errors="replace")[:1024]
3960
+
3961
+ await client(fn.CheckHistoryImportPeerRequest(peer=peer))
3962
+ await client(fn.CheckHistoryImportRequest(import_head=head))
3963
+ media = sorted(p for p in Path(req.media_dir).expanduser().iterdir()) if req.media_dir else []
3964
+ if req.check or ctx.dry_run:
3965
+ return ImportState(chat_id=chat_id, media_count=len(media), state="checked")
3966
+
3967
+ upload = getattr(ctx, "upload_file", None)
3968
+ if upload is None: # pragma: no cover - the daemon always supplies one
3969
+ raise UsageError("this context cannot upload files")
3970
+ handle = await upload(path)
3971
+ started = await client(
3972
+ fn.InitHistoryImportRequest(peer=peer, file=handle, media_count=len(media))
3973
+ )
3974
+ import_id = int(getattr(started, "id", 0) or 0)
3975
+
3976
+ uploaded = 0
3977
+ for item in media:
3978
+ if not item.is_file():
3979
+ continue
3980
+ from telethon.tl import types
3981
+
3982
+ file_handle = await upload(item)
3983
+ await client(
3984
+ fn.UploadImportedMediaRequest(
3985
+ peer=peer,
3986
+ import_id=import_id,
3987
+ file_name=item.name,
3988
+ media=types.InputMediaUploadedDocument(
3989
+ file=file_handle, mime_type="application/octet-stream", attributes=[]
3990
+ ),
3991
+ )
3992
+ )
3993
+ uploaded += 1
3994
+
3995
+ await client(fn.StartHistoryImportRequest(peer=peer, import_id=import_id))
3996
+ ctx.emit("chat_import", {"chat_id": chat_id, "import_id": import_id})
3997
+ return ImportState(
3998
+ chat_id=chat_id,
3999
+ import_id=import_id,
4000
+ media_count=len(media),
4001
+ media=uploaded,
4002
+ started=True,
4003
+ state="started",
4004
+ )
4005
+
4006
+
4007
+ SPEC_IMPORT = OperationSpec(
4008
+ id="chat.import",
4009
+ request=ImportReq,
4010
+ response=ImportState,
4011
+ impl=import_history,
4012
+ summary="Import a chat history exported from another messenger",
4013
+ description=(
4014
+ "Only a private chat, or a group you created (or hold import rights "
4015
+ "in), accepts an import. `--check` and `--dry-run` stop after the "
4016
+ "two feasibility calls."
4017
+ ),
4018
+ mutating=True,
4019
+ rate_class="file",
4020
+ timeout_s=900,
4021
+ columns=("chat_id", "import_id", "state"),
4022
+ example={"chat_id": 777123, "import_id": 42, "media_count": 3, "state": "started"},
4023
+ example_args="chat import @alice ./whatsapp.txt --check",
4024
+ covers=("dialogs.history-import", "groups-channels-admin.import-chat-history"),
4025
+ )