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/folder.py ADDED
@@ -0,0 +1,1322 @@
1
+ """The `folder` group: chat folders, and the links that share them.
2
+
3
+ A chat folder is a *filter*, not a container: Telegram stores one
4
+ `dialogFilter` per folder and evaluates it client-side against the dialog
5
+ list. Three consequences shape this module.
6
+
7
+ * **Every edit is a read-modify-write.** `messages.updateDialogFilter`
8
+ replaces the whole filter, so changing an emoji by sending only the emoji
9
+ would silently empty the folder. Everything here fetches the current
10
+ filter, applies the change and writes the result back — in one call, never
11
+ one per chat, because per-chat calls are the classic FLOOD_WAIT generator.
12
+ * **Peers go in batches, and the three lists must stay disjoint.** A peer in
13
+ both `pinned_peers` and `exclude_peers` is rejected by the server; a peer
14
+ removed from `include_peers` but still matching a type flag comes straight
15
+ back, which is why `folder remove --exclude` exists.
16
+ * **A shared folder is a different constructor.** `dialogFilterChatlist`
17
+ carries no type flags at all, so setting one on a shared folder is refused
18
+ here with a sentence instead of by the server with `FILTER_NOT_SUPPORTED`.
19
+
20
+ Telethon is imported inside functions, never at module scope (§2.2).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Annotated, Any
26
+
27
+ from tlgr.core.errors import NotFoundError, UsageError
28
+ from tlgr.core.pagination import PageKind
29
+ from tlgr.models.base import Request
30
+ from tlgr.models.dialog import (
31
+ ChatlistInvite,
32
+ ChatlistJoin,
33
+ ChatlistUpdates,
34
+ Folder,
35
+ FolderDeleted,
36
+ FolderList,
37
+ FolderOrder,
38
+ ShareDeleted,
39
+ SuggestedFolder,
40
+ )
41
+ from tlgr.models.page import Page
42
+ from tlgr.models.peer import PeerRef
43
+ from tlgr.ops import _send
44
+ from tlgr.ops._params import arg, choice, opt
45
+ from tlgr.ops._spec import OpContext, OperationSpec
46
+
47
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
48
+
49
+ #: Folder ids 0 and 1 are Telegram's peer-folders (main and archive); a chat
50
+ #: folder always gets an id of at least 2, and every official client picks
51
+ #: the smallest unused one.
52
+ FIRST_FILTER_ID = 2
53
+
54
+ _EXAMPLE_FOLDER: dict[str, Any] = {
55
+ "id": 2,
56
+ "title": "Work",
57
+ "emoticon": "💼",
58
+ "include_peers": [777123],
59
+ "groups": True,
60
+ }
61
+
62
+ _TYPE_FLAGS = (
63
+ "contacts",
64
+ "non_contacts",
65
+ "groups",
66
+ "broadcasts",
67
+ "bots",
68
+ "exclude_muted",
69
+ "exclude_read",
70
+ "exclude_archived",
71
+ )
72
+
73
+
74
+ def _client(ctx: OpContext) -> Any:
75
+ client = getattr(ctx, "client", None)
76
+ if client is None: # pragma: no cover - the daemon always supplies one
77
+ raise UsageError("this operation needs a connected account")
78
+ return client
79
+
80
+
81
+ def _already(ctx: OpContext) -> None:
82
+ mark = getattr(ctx, "mark_already", None)
83
+ if callable(mark):
84
+ mark()
85
+
86
+
87
+ def _title_text(value: Any) -> str:
88
+ """A folder title, which is `TextWithEntities` since layer 187."""
89
+ if value is None:
90
+ return ""
91
+ if isinstance(value, str):
92
+ return value
93
+ return str(getattr(value, "text", "") or "")
94
+
95
+
96
+ def _title_of(raw: Any) -> str:
97
+ return _title_text(getattr(raw, "title", None))
98
+
99
+
100
+ def _ids(peers: Any) -> list[int]:
101
+ """`[InputPeer]` → marked ids, so a folder reads like every other output."""
102
+ from telethon import utils
103
+
104
+ out: list[int] = []
105
+ for peer in peers or []:
106
+ try:
107
+ out.append(int(utils.get_peer_id(peer)))
108
+ except (TypeError, ValueError):
109
+ continue
110
+ return out
111
+
112
+
113
+ def folder_model(raw: Any) -> Folder:
114
+ """A `dialogFilter` / `dialogFilterChatlist` / `dialogFilterDefault`."""
115
+ name = type(raw).__name__
116
+ if name == "DialogFilterDefault":
117
+ return Folder(id=0, title="All chats", is_default=True)
118
+ return Folder(
119
+ id=int(getattr(raw, "id", 0) or 0),
120
+ title=_title_of(raw),
121
+ emoticon=getattr(raw, "emoticon", None),
122
+ color=getattr(raw, "color", None),
123
+ title_noanimate=bool(getattr(raw, "title_noanimate", False)),
124
+ include_peers=_ids(getattr(raw, "include_peers", None)),
125
+ exclude_peers=_ids(getattr(raw, "exclude_peers", None)),
126
+ pinned_peers=_ids(getattr(raw, "pinned_peers", None)),
127
+ contacts=bool(getattr(raw, "contacts", False)),
128
+ non_contacts=bool(getattr(raw, "non_contacts", False)),
129
+ groups=bool(getattr(raw, "groups", False)),
130
+ broadcasts=bool(getattr(raw, "broadcasts", False)),
131
+ bots=bool(getattr(raw, "bots", False)),
132
+ exclude_muted=bool(getattr(raw, "exclude_muted", False)),
133
+ exclude_read=bool(getattr(raw, "exclude_read", False)),
134
+ exclude_archived=bool(getattr(raw, "exclude_archived", False)),
135
+ is_chatlist=name == "DialogFilterChatlist",
136
+ has_my_invites=bool(getattr(raw, "has_my_invites", False)),
137
+ )
138
+
139
+
140
+ async def raw_filters(ctx: OpContext) -> tuple[list[Any], bool]:
141
+ """`(filters, tags_enabled)` straight from the server.
142
+
143
+ Exported because `chat list --folder`, `chat mute --folder` and
144
+ `chat pin --folder` all evaluate a folder client-side — there is no
145
+ `getDialogs(filter_id)` — and they must read the same filter this module
146
+ writes.
147
+ """
148
+ from telethon.tl.functions import messages as fn
149
+
150
+ result = await _client(ctx)(fn.GetDialogFiltersRequest())
151
+ filters = list(getattr(result, "filters", None) or [])
152
+ return filters, bool(getattr(result, "tags_enabled", False))
153
+
154
+
155
+ async def find_filter(ctx: OpContext, ref: str) -> Any:
156
+ """The raw filter named by an id or a title, or NOT_FOUND.
157
+
158
+ Accepting the title is not sugar: folder ids are invisible in every
159
+ official client, so a human who wants "Work" has no way to learn that it
160
+ is filter 4 except by listing them first.
161
+ """
162
+ wanted = (ref or "").strip()
163
+ if not wanted:
164
+ raise UsageError("a folder id or name is required", field="folder")
165
+ filters, _ = await raw_filters(ctx)
166
+ if wanted.lstrip("-").isdigit():
167
+ target = int(wanted)
168
+ for raw in filters:
169
+ if int(getattr(raw, "id", -1)) == target:
170
+ return raw
171
+ raise NotFoundError(f"no chat folder with id {target}")
172
+ lowered = wanted.casefold()
173
+ for raw in filters:
174
+ if _title_of(raw).casefold() == lowered:
175
+ return raw
176
+ known = ", ".join(_title_of(f) for f in filters if _title_of(f)) or "none"
177
+ raise NotFoundError(f"no chat folder called {wanted!r} (folders: {known})")
178
+
179
+
180
+ def _next_id(filters: list[Any]) -> int:
181
+ used = {int(getattr(raw, "id", 0) or 0) for raw in filters}
182
+ candidate = FIRST_FILTER_ID
183
+ while candidate in used:
184
+ candidate += 1
185
+ return candidate
186
+
187
+
188
+ async def _resolve_all(ctx: OpContext, refs: Any) -> list[Any]:
189
+ return [await _send.resolve(ctx, ref) for ref in refs or []]
190
+
191
+
192
+ def _same_peer(left: Any, right: Any) -> bool:
193
+ from telethon import utils
194
+
195
+ try:
196
+ return int(utils.get_peer_id(left)) == int(utils.get_peer_id(right))
197
+ except (TypeError, ValueError):
198
+ return False
199
+
200
+
201
+ def _without(peers: list[Any], drop: list[Any]) -> list[Any]:
202
+ return [p for p in peers if not any(_same_peer(p, d) for d in drop)]
203
+
204
+
205
+ def _append(peers: list[Any], add: list[Any]) -> list[Any]:
206
+ out = list(peers)
207
+ for peer in add:
208
+ if not any(_same_peer(peer, existing) for existing in out):
209
+ out.append(peer)
210
+ return out
211
+
212
+
213
+ def _rebuild(raw: Any, **changes: Any) -> Any:
214
+ """A copy of *raw* with *changes* applied.
215
+
216
+ `updateDialogFilter` replaces the filter wholesale, so the safe edit is
217
+ to reconstruct the same constructor with every field carried over.
218
+ """
219
+ from telethon.tl import types
220
+
221
+ common = {
222
+ "id": int(getattr(raw, "id", 0) or 0),
223
+ "title": getattr(raw, "title", None) or types.TextWithEntities(text="", entities=[]),
224
+ "pinned_peers": list(getattr(raw, "pinned_peers", None) or []),
225
+ "include_peers": list(getattr(raw, "include_peers", None) or []),
226
+ "emoticon": getattr(raw, "emoticon", None),
227
+ "color": getattr(raw, "color", None),
228
+ "title_noanimate": getattr(raw, "title_noanimate", None),
229
+ }
230
+ if type(raw).__name__ == "DialogFilterChatlist":
231
+ common.update({"has_my_invites": getattr(raw, "has_my_invites", None)})
232
+ common.update(changes)
233
+ common.pop("exclude_peers", None)
234
+ for flag in _TYPE_FLAGS:
235
+ common.pop(flag, None)
236
+ return types.DialogFilterChatlist(**common)
237
+ common["exclude_peers"] = list(getattr(raw, "exclude_peers", None) or [])
238
+ for flag in _TYPE_FLAGS:
239
+ common[flag] = getattr(raw, flag, None)
240
+ common.update(changes)
241
+ return types.DialogFilter(**common)
242
+
243
+
244
+ async def _write(ctx: OpContext, filter_id: int, raw: Any) -> None:
245
+ from telethon.tl.functions import messages as fn
246
+
247
+ await _client(ctx)(fn.UpdateDialogFilterRequest(id=filter_id, filter=raw))
248
+
249
+
250
+ async def strip_peers(ctx: OpContext, raw: Any, chat_ids: set[int]) -> None:
251
+ """Drop these chats from every list of one folder.
252
+
253
+ Exported for `chat leave --remove-from-folders`: a folder that still
254
+ names a chat you left shows an empty row in every client.
255
+ """
256
+ from telethon import utils
257
+
258
+ def keep(peers: Any) -> list[Any]:
259
+ out = []
260
+ for peer in peers or []:
261
+ try:
262
+ if int(utils.get_peer_id(peer)) in chat_ids:
263
+ continue
264
+ except (TypeError, ValueError): # pragma: no cover - defensive
265
+ pass
266
+ out.append(peer)
267
+ return out
268
+
269
+ changes: dict[str, Any] = {
270
+ "include_peers": keep(getattr(raw, "include_peers", None)),
271
+ "pinned_peers": keep(getattr(raw, "pinned_peers", None)),
272
+ }
273
+ if type(raw).__name__ != "DialogFilterChatlist":
274
+ changes["exclude_peers"] = keep(getattr(raw, "exclude_peers", None))
275
+ await _write(ctx, int(getattr(raw, "id", 0) or 0), _rebuild(raw, **changes))
276
+
277
+
278
+ async def folder_pinned_write(
279
+ ctx: OpContext,
280
+ raw: Any,
281
+ peers: list[Any],
282
+ *,
283
+ unpin: bool = False,
284
+ order: bool = False,
285
+ ) -> None:
286
+ """Pin, unpin or reorder chats *inside* a folder.
287
+
288
+ Exported for `chat pin --folder`: pinning in a folder is a filter edit,
289
+ not `toggleDialogPin`, and the pinned list must stay disjoint from the
290
+ excluded one or the whole filter is rejected.
291
+ """
292
+ filter_id = int(getattr(raw, "id", 0) or 0)
293
+ pinned = list(getattr(raw, "pinned_peers", None) or [])
294
+ include = list(getattr(raw, "include_peers", None) or [])
295
+
296
+ if order:
297
+ pinned = list(peers)
298
+ elif unpin:
299
+ pinned = _without(pinned, peers)
300
+ include = _append(include, peers)
301
+ else:
302
+ pinned = _append(pinned, peers)
303
+ include = _without(include, peers)
304
+
305
+ changes: dict[str, Any] = {"pinned_peers": pinned, "include_peers": include}
306
+ if type(raw).__name__ != "DialogFilterChatlist":
307
+ changes["exclude_peers"] = _without(list(getattr(raw, "exclude_peers", None) or []), pinned)
308
+ await _write(ctx, filter_id, _rebuild(raw, **changes))
309
+
310
+
311
+ def _refuse_flags_on_chatlist(raw: Any, requested: bool) -> None:
312
+ if requested and type(raw).__name__ == "DialogFilterChatlist":
313
+ raise UsageError(
314
+ "a shared folder has no type filters: Telegram stores only its "
315
+ "peer list, so --contacts/--groups/--exclude-muted and friends "
316
+ "cannot be set on one",
317
+ field="folder",
318
+ )
319
+
320
+
321
+ def _chatlist(filter_id: int) -> Any:
322
+ from telethon.tl import types
323
+
324
+ return types.InputChatlistDialogFilter(filter_id=filter_id)
325
+
326
+
327
+ def _slug(text: str) -> str:
328
+ """`t.me/addlist/AbC` → `AbC`; a bare slug passes through."""
329
+ value = (text or "").strip()
330
+ for marker in ("addlist/", "list/"):
331
+ if marker in value:
332
+ value = value.split(marker, 1)[1]
333
+ return value.strip("/").split("?", 1)[0]
334
+
335
+
336
+ def _invite_model(raw: Any, *, slug: str = "") -> ChatlistInvite:
337
+ from tlgr.ops._serialize import peer_id_of
338
+
339
+ url = str(getattr(raw, "url", "") or "")
340
+ return ChatlistInvite(
341
+ slug=slug or _slug(url),
342
+ url=url,
343
+ title=_title_text(getattr(raw, "title", None)),
344
+ peers=[pid for pid in (peer_id_of(p) for p in getattr(raw, "peers", None) or []) if pid],
345
+ )
346
+
347
+
348
+ def _peer_ids(peers: Any) -> list[int]:
349
+ from tlgr.ops._serialize import peer_id_of
350
+
351
+ return [pid for pid in (peer_id_of(p) for p in peers or []) if pid]
352
+
353
+
354
+ # ---------------------------------------------------------------------------
355
+ # folder list
356
+ # ---------------------------------------------------------------------------
357
+
358
+
359
+ class ListReq(Request):
360
+ with_counts: Annotated[
361
+ bool, opt("--with-counts", help="Evaluate each folder over the dialog list.")
362
+ ] = False
363
+ tags: Annotated[
364
+ str | None, choice("on", "off", help="Turn folder tags on or off account-wide.")
365
+ ] = None
366
+
367
+
368
+ async def list_folders(ctx: OpContext, req: ListReq) -> FolderList:
369
+ """Every chat folder, in display order, with the peers each one names.
370
+
371
+ `--with-counts` walks the dialog list **once** and applies each filter to
372
+ it, because there is no server-side count: asking Telegram per folder
373
+ would be one full dialog walk per folder.
374
+ """
375
+ from telethon.tl.functions import messages as fn
376
+
377
+ # `folder list` is a read, so it stays dry-runnable: the one write it can
378
+ # perform is guarded here rather than by declaring the whole listing
379
+ # mutating, which would make `--dry-run folder list` print a stub.
380
+ if req.tags is not None:
381
+ if ctx.dry_run:
382
+ ctx.warn(f"--dry-run: folder tags would be turned {req.tags}")
383
+ else:
384
+ await _client(ctx)(fn.ToggleDialogFilterTagsRequest(enabled=req.tags == "on"))
385
+
386
+ filters, tags_enabled = await raw_filters(ctx)
387
+ if req.tags is not None and not ctx.dry_run:
388
+ tags_enabled = req.tags == "on"
389
+ folders = [folder_model(raw) for raw in filters]
390
+
391
+ if req.with_counts:
392
+ from tlgr.ops.chat import folder_counts
393
+
394
+ await folder_counts(ctx, filters, folders)
395
+ return FolderList(tags_enabled=tags_enabled, folders=folders)
396
+
397
+
398
+ SPEC_LIST = OperationSpec(
399
+ id="folder.list",
400
+ request=ListReq,
401
+ response=FolderList,
402
+ impl=list_folders,
403
+ summary="List chat folders in display order",
404
+ description=(
405
+ "Shared folders (`is_chatlist`) carry no type flags, and the 'All "
406
+ "chats' placeholder appears as id 0 so `folder reorder` can position "
407
+ "it. `--tags on|off` is the one write here and it honours --dry-run "
408
+ "itself, so listing stays available under it."
409
+ ),
410
+ tags=frozenset({"mutating-checked"}),
411
+ example={"tags_enabled": False, "folders": [_EXAMPLE_FOLDER]},
412
+ example_args="folder list",
413
+ covers=("dialogs.folder-emoticon-title", "dialogs.folder-list", "dialogs.folder-tags"),
414
+ )
415
+
416
+
417
+ # ---------------------------------------------------------------------------
418
+ # folder create / edit
419
+ # ---------------------------------------------------------------------------
420
+
421
+
422
+ class CreateReq(Request):
423
+ title: Annotated[str, arg(0, metavar="TITLE", help="Folder title.")]
424
+ emoji: Annotated[str | None, opt("--emoji", metavar="EMOJI", help="Folder icon.")] = None
425
+ no_title_animation: Annotated[
426
+ bool, opt("--no-title-animation", help="Do not animate custom emoji in the title.")
427
+ ] = False
428
+ color: Annotated[
429
+ str | None, opt("--color", metavar="N", help="Tag colour 0-6, or 'none' (Premium).")
430
+ ] = None
431
+ contacts: Annotated[bool, opt("--contacts", help="Include every contact.")] = False
432
+ non_contacts: Annotated[bool, opt("--non-contacts", help="Include non-contacts.")] = False
433
+ groups: Annotated[bool, opt("--groups", help="Include groups.")] = False
434
+ channels: Annotated[bool, opt("--channels", help="Include channels.")] = False
435
+ bots: Annotated[bool, opt("--bots", help="Include bots.")] = False
436
+ exclude_muted: Annotated[bool, opt("--exclude-muted", help="Drop muted chats.")] = False
437
+ exclude_read: Annotated[bool, opt("--exclude-read", help="Drop fully read chats.")] = False
438
+ exclude_archived: Annotated[bool, opt("--exclude-archived", help="Drop archived chats.")] = (
439
+ False
440
+ )
441
+ include: Annotated[
442
+ list[PeerRef], opt("--include", metavar="CHAT", kind="peer", help="Always include.")
443
+ ] = []
444
+ exclude: Annotated[
445
+ list[PeerRef], opt("--exclude", metavar="CHAT", kind="peer", help="Always exclude.")
446
+ ] = []
447
+ pin: Annotated[
448
+ list[PeerRef], opt("--pin", metavar="CHAT", kind="peer", help="Pin inside the folder.")
449
+ ] = []
450
+
451
+
452
+ def _color(value: str | None) -> int | None:
453
+ if value is None:
454
+ return None
455
+ if value.strip().lower() in ("none", "off", ""):
456
+ return None
457
+ try:
458
+ number = int(value)
459
+ except ValueError as exc:
460
+ raise UsageError("--color takes 0-6 or 'none'", field="color") from exc
461
+ if not 0 <= number <= 6:
462
+ raise UsageError("--color takes 0-6 or 'none'", field="color")
463
+ return number
464
+
465
+
466
+ async def create(ctx: OpContext, req: CreateReq) -> Folder:
467
+ """Create a chat folder, picking the id the way every official client does.
468
+
469
+ The id is chosen client-side (smallest unused >= 2) because Telegram has
470
+ no "create" call: a folder exists as soon as `updateDialogFilter` is sent
471
+ with an id nothing else is using.
472
+ """
473
+ from telethon.tl import types
474
+
475
+ if not req.title.strip():
476
+ raise UsageError("a folder needs a title", field="title")
477
+
478
+ filters, _ = await raw_filters(ctx)
479
+ filter_id = _next_id(filters)
480
+ include = await _resolve_all(ctx, req.include)
481
+ pinned = await _resolve_all(ctx, req.pin)
482
+ exclude = await _resolve_all(ctx, req.exclude)
483
+ # A peer cannot be pinned and included twice, and cannot be both included
484
+ # and excluded; the server rejects the whole filter if it is.
485
+ include = _without(include, pinned)
486
+ exclude = _without(exclude, pinned + include)
487
+
488
+ raw = types.DialogFilter(
489
+ id=filter_id,
490
+ title=types.TextWithEntities(text=req.title, entities=[]),
491
+ pinned_peers=pinned,
492
+ include_peers=include,
493
+ exclude_peers=exclude,
494
+ contacts=req.contacts or None,
495
+ non_contacts=req.non_contacts or None,
496
+ groups=req.groups or None,
497
+ broadcasts=req.channels or None,
498
+ bots=req.bots or None,
499
+ exclude_muted=req.exclude_muted or None,
500
+ exclude_read=req.exclude_read or None,
501
+ exclude_archived=req.exclude_archived or None,
502
+ emoticon=req.emoji,
503
+ color=_color(req.color),
504
+ title_noanimate=req.no_title_animation or None,
505
+ )
506
+ if not (include or pinned or any(getattr(raw, flag, None) for flag in _TYPE_FLAGS[:5])):
507
+ raise UsageError(
508
+ "a folder needs something in it: pass --include/--pin or a type flag such as --groups",
509
+ field="include",
510
+ )
511
+ await _write(ctx, filter_id, raw)
512
+ ctx.emit("folder_create", {"id": filter_id, "title": req.title})
513
+ return folder_model(raw)
514
+
515
+
516
+ SPEC_CREATE = OperationSpec(
517
+ id="folder.create",
518
+ request=CreateReq,
519
+ response=Folder,
520
+ impl=create,
521
+ summary="Create a chat folder",
522
+ description=(
523
+ "Peers are batched into one `updateDialogFilter`; adding them one at "
524
+ "a time is what earns a FLOOD_WAIT on a folder with thirty chats."
525
+ ),
526
+ mutating=True,
527
+ rate_class="bulk",
528
+ columns=("id", "title", "emoticon"),
529
+ example=_EXAMPLE_FOLDER,
530
+ example_args='folder create "Work" --groups --emoji 💼',
531
+ covers=("dialogs.folder-create",),
532
+ )
533
+
534
+
535
+ class EditReq(Request):
536
+ folder: Annotated[str, arg(0, metavar="FOLDER", help="Folder id or name.")]
537
+ title: Annotated[str | None, opt("--title", metavar="TEXT", help="New title.")] = None
538
+ emoji: Annotated[str | None, opt("--emoji", metavar="EMOJI", help="Folder icon.")] = None
539
+ no_title_animation: Annotated[
540
+ bool | None, opt("--no-title-animation", help="Animate custom emoji in the title.")
541
+ ] = None
542
+ color: Annotated[
543
+ str | None, opt("--color", metavar="N", help="Tag colour 0-6, or 'none' (Premium).")
544
+ ] = None
545
+ contacts: Annotated[bool | None, opt("--contacts", help="Include every contact.")] = None
546
+ non_contacts: Annotated[bool | None, opt("--non-contacts", help="Include non-contacts.")] = None
547
+ groups: Annotated[bool | None, opt("--groups", help="Include groups.")] = None
548
+ channels: Annotated[bool | None, opt("--channels", help="Include channels.")] = None
549
+ bots: Annotated[bool | None, opt("--bots", help="Include bots.")] = None
550
+ exclude_muted: Annotated[bool | None, opt("--exclude-muted", help="Drop muted chats.")] = None
551
+ exclude_read: Annotated[bool | None, opt("--exclude-read", help="Drop read chats.")] = None
552
+ exclude_archived: Annotated[
553
+ bool | None, opt("--exclude-archived", help="Drop archived chats.")
554
+ ] = None
555
+ add: Annotated[
556
+ list[PeerRef], opt("--add", "--include", metavar="CHAT", kind="peer", help="Add a chat.")
557
+ ] = []
558
+ remove: Annotated[
559
+ list[PeerRef], opt("--remove", metavar="CHAT", kind="peer", help="Remove a chat.")
560
+ ] = []
561
+ exclude: Annotated[
562
+ list[PeerRef], opt("--exclude", metavar="CHAT", kind="peer", help="Always exclude.")
563
+ ] = []
564
+ pin: Annotated[
565
+ list[PeerRef], opt("--pin", metavar="CHAT", kind="peer", help="Pin inside the folder.")
566
+ ] = []
567
+ clear_include: Annotated[bool, opt("--clear-include", help="Empty the include list.")] = False
568
+ clear_exclude: Annotated[bool, opt("--clear-exclude", help="Empty the exclude list.")] = False
569
+
570
+
571
+ async def edit(ctx: OpContext, req: EditReq) -> Folder:
572
+ """Change one thing about a folder without losing the rest.
573
+
574
+ `updateDialogFilter` replaces the filter, so this reads the current one,
575
+ applies only what was asked for and writes the whole thing back.
576
+ """
577
+ from telethon.tl import types
578
+
579
+ raw = await find_filter(ctx, req.folder)
580
+ filter_id = int(getattr(raw, "id", 0) or 0)
581
+ flags = {
582
+ "contacts": req.contacts,
583
+ "non_contacts": req.non_contacts,
584
+ "groups": req.groups,
585
+ "broadcasts": req.channels,
586
+ "bots": req.bots,
587
+ "exclude_muted": req.exclude_muted,
588
+ "exclude_read": req.exclude_read,
589
+ "exclude_archived": req.exclude_archived,
590
+ }
591
+ _refuse_flags_on_chatlist(raw, any(value is not None for value in flags.values()))
592
+
593
+ changes: dict[str, Any] = {name: value for name, value in flags.items() if value is not None}
594
+ if req.title is not None:
595
+ changes["title"] = types.TextWithEntities(text=req.title, entities=[])
596
+ if req.emoji is not None:
597
+ changes["emoticon"] = req.emoji
598
+ if req.color is not None:
599
+ changes["color"] = _color(req.color)
600
+ if req.no_title_animation is not None:
601
+ changes["title_noanimate"] = req.no_title_animation or None
602
+
603
+ include = list(getattr(raw, "include_peers", None) or [])
604
+ exclude = list(getattr(raw, "exclude_peers", None) or [])
605
+ pinned = list(getattr(raw, "pinned_peers", None) or [])
606
+ if req.clear_include:
607
+ include = []
608
+ if req.clear_exclude:
609
+ exclude = []
610
+ added = await _resolve_all(ctx, req.add)
611
+ removed = await _resolve_all(ctx, req.remove)
612
+ pins = await _resolve_all(ctx, req.pin)
613
+ excluded = await _resolve_all(ctx, req.exclude)
614
+
615
+ include = _append(_without(include, removed + pins), added)
616
+ pinned = _append(_without(pinned, removed), pins)
617
+ exclude = _append(_without(exclude, added + pins), excluded)
618
+ include = _without(include, exclude)
619
+
620
+ changes["include_peers"] = include
621
+ changes["pinned_peers"] = pinned
622
+ if type(raw).__name__ != "DialogFilterChatlist":
623
+ changes["exclude_peers"] = exclude
624
+
625
+ updated = _rebuild(raw, **changes)
626
+ await _write(ctx, filter_id, updated)
627
+ ctx.emit("folder_edit", {"id": filter_id})
628
+ return folder_model(updated)
629
+
630
+
631
+ SPEC_EDIT = OperationSpec(
632
+ id="folder.edit",
633
+ request=EditReq,
634
+ response=Folder,
635
+ impl=edit,
636
+ summary="Edit a chat folder",
637
+ description=(
638
+ "Every flag is a paired switch (`--groups/--no-groups`) because "
639
+ "'leave it alone' and 'turn it off' are different requests, and the "
640
+ "whole filter is rewritten in one call."
641
+ ),
642
+ mutating=True,
643
+ rate_class="bulk",
644
+ columns=("id", "title", "emoticon"),
645
+ example=_EXAMPLE_FOLDER,
646
+ example_args='folder edit Work --title "Work & clients"',
647
+ covers=("dialogs.folder-edit",),
648
+ )
649
+
650
+
651
+ # ---------------------------------------------------------------------------
652
+ # folder add / remove
653
+ # ---------------------------------------------------------------------------
654
+
655
+
656
+ class AddReq(Request):
657
+ folder: Annotated[str, arg(0, metavar="FOLDER", help="Folder id or name.")]
658
+ chat: Annotated[
659
+ list[PeerRef], arg(1, metavar="CHAT", variadic=True, kind="peer", help="Chats to add.")
660
+ ] = []
661
+ pin: Annotated[bool, opt("--pin", help="Add to the pinned list instead.")] = False
662
+
663
+
664
+ async def add(ctx: OpContext, req: AddReq) -> Folder:
665
+ """Add chats to a folder, and stop excluding them.
666
+
667
+ Dropping the peer from `exclude_peers` is part of adding it: a chat that
668
+ is included *and* excluded is the state Telegram refuses, and it is the
669
+ state a naive append produces on a chat somebody removed yesterday.
670
+ """
671
+ if not req.chat:
672
+ raise UsageError("give at least one chat to add", field="chat")
673
+ raw = await find_filter(ctx, req.folder)
674
+ filter_id = int(getattr(raw, "id", 0) or 0)
675
+ peers = await _resolve_all(ctx, req.chat)
676
+
677
+ include = list(getattr(raw, "include_peers", None) or [])
678
+ pinned = list(getattr(raw, "pinned_peers", None) or [])
679
+ exclude = _without(list(getattr(raw, "exclude_peers", None) or []), peers)
680
+ if req.pin:
681
+ pinned = _append(_without(pinned, peers), peers)
682
+ include = _without(include, peers)
683
+ else:
684
+ include = _append(include, _without(peers, pinned))
685
+
686
+ changes: dict[str, Any] = {"include_peers": include, "pinned_peers": pinned}
687
+ if type(raw).__name__ != "DialogFilterChatlist":
688
+ changes["exclude_peers"] = exclude
689
+ updated = _rebuild(raw, **changes)
690
+ await _write(ctx, filter_id, updated)
691
+ ctx.emit("folder_add", {"id": filter_id, "chats": _ids(peers)})
692
+ return folder_model(updated)
693
+
694
+
695
+ SPEC_ADD = OperationSpec(
696
+ id="folder.add",
697
+ request=AddReq,
698
+ response=Folder,
699
+ impl=add,
700
+ summary="Add chats to a folder",
701
+ mutating=True,
702
+ idempotent=True,
703
+ rate_class="bulk",
704
+ columns=("id", "title", "include_peers"),
705
+ example=_EXAMPLE_FOLDER,
706
+ example_args="folder add Work @alice",
707
+ covers=("dialogs.folder-add-chat",),
708
+ )
709
+
710
+
711
+ class RemoveReq(Request):
712
+ folder: Annotated[str, arg(0, metavar="FOLDER", help="Folder id or name.")]
713
+ chat: Annotated[
714
+ list[PeerRef], arg(1, metavar="CHAT", variadic=True, kind="peer", help="Chats to remove.")
715
+ ] = []
716
+ exclude: Annotated[
717
+ bool, opt("--exclude", help="Also exclude, so a type flag cannot pull it back in.")
718
+ ] = False
719
+
720
+
721
+ async def remove(ctx: OpContext, req: RemoveReq) -> Folder:
722
+ """Remove chats from a folder.
723
+
724
+ Removing from `include_peers` is not enough when the chat still matches a
725
+ type flag — it reappears on the next sync. `--exclude` is what the GUI
726
+ does in that case, and the only way to make the removal stick.
727
+ """
728
+ if not req.chat:
729
+ raise UsageError("give at least one chat to remove", field="chat")
730
+ raw = await find_filter(ctx, req.folder)
731
+ filter_id = int(getattr(raw, "id", 0) or 0)
732
+ peers = await _resolve_all(ctx, req.chat)
733
+
734
+ changes: dict[str, Any] = {
735
+ "include_peers": _without(list(getattr(raw, "include_peers", None) or []), peers),
736
+ "pinned_peers": _without(list(getattr(raw, "pinned_peers", None) or []), peers),
737
+ }
738
+ if type(raw).__name__ != "DialogFilterChatlist":
739
+ exclude = list(getattr(raw, "exclude_peers", None) or [])
740
+ changes["exclude_peers"] = _append(exclude, peers) if req.exclude else exclude
741
+ elif req.exclude:
742
+ raise UsageError(
743
+ "a shared folder has no exclude list; remove the chat instead",
744
+ field="exclude",
745
+ )
746
+
747
+ updated = _rebuild(raw, **changes)
748
+ await _write(ctx, filter_id, updated)
749
+ ctx.emit("folder_remove", {"id": filter_id, "chats": _ids(peers)})
750
+ return folder_model(updated)
751
+
752
+
753
+ SPEC_REMOVE = OperationSpec(
754
+ id="folder.remove",
755
+ request=RemoveReq,
756
+ response=Folder,
757
+ impl=remove,
758
+ summary="Remove chats from a folder",
759
+ mutating=True,
760
+ idempotent=True,
761
+ rate_class="bulk",
762
+ columns=("id", "title", "include_peers"),
763
+ example=_EXAMPLE_FOLDER,
764
+ example_args="folder remove Work @alice --exclude",
765
+ covers=("dialogs.folder-remove-chat",),
766
+ )
767
+
768
+
769
+ # ---------------------------------------------------------------------------
770
+ # folder delete / reorder
771
+ # ---------------------------------------------------------------------------
772
+
773
+
774
+ class DeleteReq(Request):
775
+ folder: Annotated[str, arg(0, metavar="FOLDER", help="Folder id or name.")]
776
+ leave_chats: Annotated[
777
+ str,
778
+ opt(
779
+ "--leave-chats",
780
+ metavar="WHAT",
781
+ help="none (default) | suggested | all | a comma-separated chat list.",
782
+ ),
783
+ ] = "none"
784
+
785
+
786
+ async def delete(ctx: OpContext, req: DeleteReq) -> FolderDeleted:
787
+ """Delete a folder; for a shared one, optionally leave what came with it.
788
+
789
+ An *imported* folder is not deleted with `updateDialogFilter`: the chats
790
+ joined through it would stay, silently. `chatlists.leaveChatlist` is the
791
+ call that removes both, and the peers it should take come from
792
+ `getLeaveChatlistSuggestions` — printed before anything is left.
793
+ """
794
+ from telethon.tl.functions import chatlists as cfn
795
+ from telethon.tl.functions import messages as fn
796
+
797
+ raw = await find_filter(ctx, req.folder)
798
+ filter_id = int(getattr(raw, "id", 0) or 0)
799
+ shared = type(raw).__name__ == "DialogFilterChatlist"
800
+ choice_ = (req.leave_chats or "none").strip().lower()
801
+
802
+ suggested: list[Any] = []
803
+ if shared:
804
+ suggested = list(
805
+ await _client(ctx)(cfn.GetLeaveChatlistSuggestionsRequest(_chatlist(filter_id)))
806
+ )
807
+
808
+ leaving: list[Any] = []
809
+ if choice_ == "all":
810
+ leaving = list(getattr(raw, "include_peers", None) or []) + list(
811
+ getattr(raw, "pinned_peers", None) or []
812
+ )
813
+ elif choice_ == "suggested":
814
+ leaving = suggested
815
+ elif choice_ not in ("", "none"):
816
+ refs = [part.strip() for part in choice_.split(",") if part.strip()]
817
+ leaving = [await _send.resolve(ctx, ref) for ref in refs]
818
+
819
+ if shared:
820
+ await _client(ctx)(cfn.LeaveChatlistRequest(chatlist=_chatlist(filter_id), peers=leaving))
821
+ else:
822
+ if choice_ not in ("", "none"):
823
+ raise UsageError(
824
+ "--leave-chats only applies to a shared folder you joined; a "
825
+ "folder you made is just a filter, so deleting it leaves no chats",
826
+ field="leave-chats",
827
+ )
828
+ await _client(ctx)(fn.UpdateDialogFilterRequest(id=filter_id, filter=None))
829
+
830
+ ctx.emit("folder_delete", {"id": filter_id})
831
+ return FolderDeleted(
832
+ id=filter_id,
833
+ deleted=True,
834
+ left_chats=_ids(leaving),
835
+ suggested=_peer_ids(suggested),
836
+ )
837
+
838
+
839
+ SPEC_DELETE = OperationSpec(
840
+ id="folder.delete",
841
+ request=DeleteReq,
842
+ response=FolderDeleted,
843
+ impl=delete,
844
+ summary="Delete a chat folder",
845
+ description=(
846
+ "A folder you made is a filter: deleting it keeps every chat. A "
847
+ "shared folder you joined can take its chats with it, which is why "
848
+ "--leave-chats exists and defaults to keeping them."
849
+ ),
850
+ aliases=("folder.leave",),
851
+ mutating=True,
852
+ destructive=True,
853
+ rate_class="bulk",
854
+ columns=("id", "deleted"),
855
+ example={"id": 2, "deleted": True, "left_chats": []},
856
+ example_args="folder delete Work",
857
+ covers=("dialogs.chatlist-leave", "dialogs.folder-delete"),
858
+ )
859
+
860
+
861
+ class ReorderReq(Request):
862
+ folder: Annotated[
863
+ list[str],
864
+ arg(0, metavar="FOLDER", variadic=True, help="Folders in the order you want them."),
865
+ ] = []
866
+
867
+
868
+ async def reorder(ctx: OpContext, req: ReorderReq) -> FolderOrder:
869
+ """Set the tab order, including where "All chats" sits.
870
+
871
+ Id 0 is the main list; naming it anywhere but first is a Premium feature,
872
+ and the server says so rather than this doing the check.
873
+ """
874
+ from telethon.tl.functions import messages as fn
875
+
876
+ if not req.folder:
877
+ raise UsageError("give the folders in the order you want them", field="folder")
878
+ order: list[int] = []
879
+ for ref in req.folder:
880
+ text = ref.strip()
881
+ if text in ("0", "main", "all"):
882
+ order.append(0)
883
+ continue
884
+ order.append(int(getattr(await find_filter(ctx, text), "id", 0) or 0))
885
+ await _client(ctx)(fn.UpdateDialogFiltersOrderRequest(order=order))
886
+ ctx.emit("folder_reorder", {"order": order})
887
+ return FolderOrder(order=order)
888
+
889
+
890
+ SPEC_REORDER = OperationSpec(
891
+ id="folder.reorder",
892
+ request=ReorderReq,
893
+ response=FolderOrder,
894
+ impl=reorder,
895
+ summary="Set the display order of chat folders",
896
+ mutating=True,
897
+ idempotent=True,
898
+ columns=("order",),
899
+ example={"order": [0, 2, 3]},
900
+ example_args="folder reorder main Work Family",
901
+ covers=("dialogs.folder-reorder",),
902
+ )
903
+
904
+
905
+ # ---------------------------------------------------------------------------
906
+ # folder suggested / updates
907
+ # ---------------------------------------------------------------------------
908
+
909
+
910
+ class SuggestedReq(Request):
911
+ add: Annotated[
912
+ str | None,
913
+ opt("--add", metavar="TITLE", help="Add the suggested folder with this title."),
914
+ ] = None
915
+
916
+
917
+ async def suggested_list(ctx: OpContext, req: SuggestedReq) -> Page[SuggestedFolder]:
918
+ """Telegram's own folder suggestions, and adding one.
919
+
920
+ Adding is not a separate call: a suggestion is a ready-made filter, so it
921
+ is written with `updateDialogFilter` under a fresh id like any other.
922
+ """
923
+ from telethon.tl.functions import messages as fn
924
+
925
+ raw_suggestions = list(await _client(ctx)(fn.GetSuggestedDialogFiltersRequest()))
926
+ items: list[SuggestedFolder] = []
927
+ for suggestion in raw_suggestions:
928
+ inner = getattr(suggestion, "filter", None)
929
+ model = folder_model(inner)
930
+ items.append(
931
+ SuggestedFolder(
932
+ title=model.title,
933
+ description=str(getattr(suggestion, "description", "") or ""),
934
+ emoticon=model.emoticon,
935
+ contacts=model.contacts,
936
+ non_contacts=model.non_contacts,
937
+ groups=model.groups,
938
+ broadcasts=model.broadcasts,
939
+ bots=model.bots,
940
+ exclude_muted=model.exclude_muted,
941
+ exclude_read=model.exclude_read,
942
+ exclude_archived=model.exclude_archived,
943
+ )
944
+ )
945
+
946
+ if req.add:
947
+ wanted = req.add.strip().casefold()
948
+ chosen = next(
949
+ (
950
+ s
951
+ for s, model in zip(raw_suggestions, items, strict=True)
952
+ if model.title.casefold() == wanted
953
+ ),
954
+ None,
955
+ )
956
+ if chosen is None:
957
+ raise NotFoundError(f"Telegram is not suggesting a folder called {req.add!r}")
958
+ filters, _ = await raw_filters(ctx)
959
+ filter_id = _next_id(filters)
960
+ updated = _rebuild(getattr(chosen, "filter", None), id=filter_id)
961
+ await _write(ctx, filter_id, updated)
962
+ for suggestion_model in items:
963
+ if suggestion_model.title.casefold() == wanted:
964
+ suggestion_model.added_id = filter_id
965
+ ctx.emit("folder_create", {"id": filter_id, "title": req.add})
966
+
967
+ return Page(items=items, has_more=False, total=len(items))
968
+
969
+
970
+ SPEC_SUGGESTED_LIST = OperationSpec(
971
+ id="folder.suggested.list",
972
+ request=SuggestedReq,
973
+ response=Page[SuggestedFolder],
974
+ impl=suggested_list,
975
+ summary="List the chat folders Telegram suggests, and add one",
976
+ paginated=PageKind.LOCAL,
977
+ mutating=True,
978
+ idempotent=True,
979
+ columns=("title", "description"),
980
+ headers=("Folder", "What it collects"),
981
+ example={"items": [{"title": "Unread", "description": "Unread chats"}], "has_more": False},
982
+ example_args="folder suggested list",
983
+ covers=("dialogs.folder-suggested",),
984
+ )
985
+
986
+
987
+ class UpdateListReq(Request):
988
+ folder: Annotated[str, arg(0, metavar="FOLDER", help="Shared folder id or name.")]
989
+ join: Annotated[
990
+ str | None, opt("--join", metavar="WHAT", help="all | a comma-separated chat list.")
991
+ ] = None
992
+ dismiss: Annotated[bool, opt("--dismiss", help="Hide the update badge.")] = False
993
+
994
+
995
+ async def update_list(ctx: OpContext, req: UpdateListReq) -> ChatlistUpdates:
996
+ """New chats the folder's owner added since you joined.
997
+
998
+ Poll this no more often than the app-config `chatlist_update_period`; the
999
+ badge is a courtesy, not an event stream.
1000
+ """
1001
+ from telethon.tl.functions import chatlists as cfn
1002
+
1003
+ raw = await find_filter(ctx, req.folder)
1004
+ filter_id = int(getattr(raw, "id", 0) or 0)
1005
+ if type(raw).__name__ != "DialogFilterChatlist":
1006
+ raise UsageError(
1007
+ "only a shared folder receives updates; this one is your own filter",
1008
+ field="folder",
1009
+ )
1010
+ chatlist = _chatlist(filter_id)
1011
+ result = await _client(ctx)(cfn.GetChatlistUpdatesRequest(chatlist=chatlist))
1012
+ missing = list(getattr(result, "missing_peers", None) or [])
1013
+
1014
+ joined: list[int] = []
1015
+ if req.join:
1016
+ wanted = req.join.strip().lower()
1017
+ if wanted == "all":
1018
+ peers = await _peers_for(ctx, missing, result)
1019
+ else:
1020
+ peers = [
1021
+ await _send.resolve(ctx, part.strip())
1022
+ for part in req.join.split(",")
1023
+ if part.strip()
1024
+ ]
1025
+ if peers:
1026
+ await _client(ctx)(cfn.JoinChatlistUpdatesRequest(chatlist=chatlist, peers=peers))
1027
+ joined = _ids(peers)
1028
+
1029
+ dismissed = False
1030
+ if req.dismiss:
1031
+ await _client(ctx)(cfn.HideChatlistUpdatesRequest(chatlist=chatlist))
1032
+ dismissed = True
1033
+
1034
+ return ChatlistUpdates(
1035
+ id=filter_id,
1036
+ missing_peers=_peer_ids(missing),
1037
+ joined=joined,
1038
+ dismissed=dismissed,
1039
+ )
1040
+
1041
+
1042
+ async def _peers_for(ctx: OpContext, peers: list[Any], result: Any) -> list[Any]:
1043
+ """`Peer` → `InputPeer`, using the chats/users the same reply carried.
1044
+
1045
+ Resolving them one by one would be one `resolveUsername` per chat for
1046
+ peers the server just handed over in full.
1047
+ """
1048
+ from telethon import utils
1049
+
1050
+ known: dict[int, Any] = {}
1051
+ for entity in list(getattr(result, "chats", None) or []) + list(
1052
+ getattr(result, "users", None) or []
1053
+ ):
1054
+ try:
1055
+ known[int(utils.get_peer_id(entity))] = utils.get_input_peer(entity)
1056
+ except (TypeError, ValueError): # pragma: no cover - defensive
1057
+ continue
1058
+ out: list[Any] = []
1059
+ for peer in peers:
1060
+ try:
1061
+ marked = int(utils.get_peer_id(peer))
1062
+ except (TypeError, ValueError): # pragma: no cover - defensive
1063
+ continue
1064
+ found = known.get(marked)
1065
+ if found is None:
1066
+ found = await _send.resolve(ctx, str(marked))
1067
+ out.append(found)
1068
+ return out
1069
+
1070
+
1071
+ SPEC_UPDATE_LIST = OperationSpec(
1072
+ id="folder.update.list",
1073
+ request=UpdateListReq,
1074
+ response=ChatlistUpdates,
1075
+ impl=update_list,
1076
+ summary="New chats the owner added to a shared folder",
1077
+ aliases=("folder.updates",),
1078
+ mutating=True,
1079
+ idempotent=True,
1080
+ columns=("id", "missing_peers"),
1081
+ example={"id": 4, "missing_peers": [777123], "joined": []},
1082
+ example_args="folder update list Work",
1083
+ covers=("dialogs.chatlist-updates",),
1084
+ )
1085
+
1086
+
1087
+ # ---------------------------------------------------------------------------
1088
+ # folder join
1089
+ # ---------------------------------------------------------------------------
1090
+
1091
+
1092
+ class JoinReq(Request):
1093
+ link: Annotated[str, arg(0, metavar="LINK", help="t.me/addlist/<slug>, or a bare slug.")]
1094
+ chats: Annotated[
1095
+ list[PeerRef],
1096
+ opt("--chats", metavar="CHAT", kind="peer", help="Which offered chats to join."),
1097
+ ] = []
1098
+ join_all: Annotated[bool, opt("--all-chats", help="Join every offered chat.")] = False
1099
+
1100
+
1101
+ async def join(ctx: OpContext, req: JoinReq) -> ChatlistJoin:
1102
+ """Preview a shared-folder link, and join the chats you name.
1103
+
1104
+ Previewing is the default and joining is opt-in per chat: an `addlist`
1105
+ link can carry fifty channels, and "joined fifty channels" is not a thing
1106
+ a command should do because a link was pasted.
1107
+ """
1108
+ from telethon.tl.functions import chatlists as cfn
1109
+
1110
+ slug = _slug(req.link)
1111
+ if not slug:
1112
+ raise UsageError("that is not a folder link", field="link")
1113
+ preview = await _client(ctx)(cfn.CheckChatlistInviteRequest(slug=slug))
1114
+ already = type(preview).__name__ == "ChatlistInviteAlready"
1115
+
1116
+ offered = list(getattr(preview, "peers", None) or [])
1117
+ missing = list(getattr(preview, "missing_peers", None) or [])
1118
+ result = ChatlistJoin(
1119
+ slug=slug,
1120
+ title=_title_text(getattr(preview, "title", None)),
1121
+ emoticon=getattr(preview, "emoticon", None),
1122
+ already_member=already,
1123
+ filter_id=getattr(preview, "filter_id", None),
1124
+ peers=_peer_ids(offered),
1125
+ missing_peers=_peer_ids(missing),
1126
+ already_peers=_peer_ids(getattr(preview, "already_peers", None)),
1127
+ )
1128
+
1129
+ wanted: list[Any] = []
1130
+ if req.join_all:
1131
+ wanted = await _peers_for(ctx, missing or offered, preview)
1132
+ elif req.chats:
1133
+ wanted = await _resolve_all(ctx, req.chats)
1134
+ if not wanted:
1135
+ _already(ctx)
1136
+ return result
1137
+
1138
+ if already:
1139
+ await _client(ctx)(
1140
+ cfn.JoinChatlistUpdatesRequest(
1141
+ chatlist=_chatlist(int(result.filter_id or 0)), peers=wanted
1142
+ )
1143
+ )
1144
+ else:
1145
+ await _client(ctx)(cfn.JoinChatlistInviteRequest(slug=slug, peers=wanted))
1146
+ result.joined = _ids(wanted)
1147
+ ctx.emit("folder_join", {"slug": slug, "joined": result.joined})
1148
+ return result
1149
+
1150
+
1151
+ SPEC_JOIN = OperationSpec(
1152
+ id="folder.join",
1153
+ request=JoinReq,
1154
+ response=ChatlistJoin,
1155
+ impl=join,
1156
+ summary="Preview and join a shared folder link",
1157
+ description=(
1158
+ "Without --chats or --all this only previews: the reply lists what "
1159
+ "the link offers and joins nothing."
1160
+ ),
1161
+ mutating=True,
1162
+ rate_class="bulk",
1163
+ columns=("slug", "title", "peers"),
1164
+ example={"slug": "AbCdEf", "title": "Work", "peers": [777123], "joined": []},
1165
+ example_args="folder join t.me/addlist/AbCdEf",
1166
+ covers=(
1167
+ "contacts-users.resolve-folder-link",
1168
+ "dialogs.chatlist-check",
1169
+ "dialogs.chatlist-join",
1170
+ ),
1171
+ )
1172
+
1173
+
1174
+ # ---------------------------------------------------------------------------
1175
+ # folder share
1176
+ # ---------------------------------------------------------------------------
1177
+
1178
+
1179
+ class ShareListReq(Request):
1180
+ folder: Annotated[str, arg(0, metavar="FOLDER", help="Folder id or name.")]
1181
+
1182
+
1183
+ async def share_list(ctx: OpContext, req: ShareListReq) -> Page[ChatlistInvite]:
1184
+ """Every share link this folder has, with the chats each one carries."""
1185
+ from telethon.tl.functions import chatlists as cfn
1186
+
1187
+ raw = await find_filter(ctx, req.folder)
1188
+ filter_id = int(getattr(raw, "id", 0) or 0)
1189
+ result = await _client(ctx)(cfn.GetExportedInvitesRequest(chatlist=_chatlist(filter_id)))
1190
+ items = [_invite_model(invite) for invite in getattr(result, "invites", None) or []]
1191
+ return Page(items=items, has_more=False, total=len(items))
1192
+
1193
+
1194
+ SPEC_SHARE_LIST = OperationSpec(
1195
+ id="folder.share.list",
1196
+ request=ShareListReq,
1197
+ response=Page[ChatlistInvite],
1198
+ impl=share_list,
1199
+ summary="List a folder's shareable invite links",
1200
+ paginated=PageKind.LOCAL,
1201
+ columns=("slug", "title", "url"),
1202
+ headers=("Slug", "Title", "Link"),
1203
+ example={
1204
+ "items": [{"slug": "AbCdEf", "url": "https://t.me/addlist/AbCdEf", "title": "Work"}],
1205
+ "has_more": False,
1206
+ },
1207
+ example_args="folder share list Work",
1208
+ covers=("dialogs.chatlist-invite-list",),
1209
+ )
1210
+
1211
+
1212
+ class ShareSetReq(Request):
1213
+ folder: Annotated[str, arg(0, metavar="FOLDER", help="Folder id or name.")]
1214
+ slug: Annotated[
1215
+ str | None, opt("--slug", metavar="SLUG", help="Edit this link instead of creating one.")
1216
+ ] = None
1217
+ title: Annotated[str | None, opt("--title", metavar="TEXT", help="Link title.")] = None
1218
+ chats: Annotated[
1219
+ list[PeerRef], opt("--chats", metavar="CHAT", kind="peer", help="Chats the link carries.")
1220
+ ] = []
1221
+ all_eligible: Annotated[
1222
+ bool, opt("--all-eligible", help="Every chat of the folder that may be shared.")
1223
+ ] = False
1224
+
1225
+
1226
+ async def share_set(ctx: OpContext, req: ShareSetReq) -> ChatlistInvite:
1227
+ """Create a share link, or edit one that exists.
1228
+
1229
+ Only chats you can make an invite link for may be shared, so a folder of
1230
+ private groups produces a link with fewer chats than the folder has —
1231
+ the reply lists what actually went in rather than what was asked for.
1232
+ """
1233
+ from telethon.tl.functions import chatlists as cfn
1234
+
1235
+ raw = await find_filter(ctx, req.folder)
1236
+ filter_id = int(getattr(raw, "id", 0) or 0)
1237
+ chatlist = _chatlist(filter_id)
1238
+
1239
+ peers: list[Any] = []
1240
+ if req.all_eligible:
1241
+ peers = list(getattr(raw, "pinned_peers", None) or []) + list(
1242
+ getattr(raw, "include_peers", None) or []
1243
+ )
1244
+ elif req.chats:
1245
+ peers = await _resolve_all(ctx, req.chats)
1246
+
1247
+ if req.slug:
1248
+ result = await _client(ctx)(
1249
+ cfn.EditExportedInviteRequest(
1250
+ chatlist=chatlist,
1251
+ slug=req.slug,
1252
+ title=req.title,
1253
+ peers=peers or None,
1254
+ )
1255
+ )
1256
+ invite = getattr(result, "invite", result)
1257
+ ctx.emit("folder_share", {"id": filter_id, "slug": req.slug})
1258
+ return _invite_model(invite, slug=req.slug)
1259
+
1260
+ if not peers:
1261
+ raise UsageError(
1262
+ "a share link needs chats: pass --chats or --all-eligible",
1263
+ field="chats",
1264
+ )
1265
+ result = await _client(ctx)(
1266
+ cfn.ExportChatlistInviteRequest(
1267
+ chatlist=chatlist, title=req.title or _title_of(raw), peers=peers
1268
+ )
1269
+ )
1270
+ invite = getattr(result, "invite", result)
1271
+ model = _invite_model(invite)
1272
+ ctx.emit("folder_share", {"id": filter_id, "slug": model.slug})
1273
+ return model
1274
+
1275
+
1276
+ SPEC_SHARE_SET = OperationSpec(
1277
+ id="folder.share.set",
1278
+ request=ShareSetReq,
1279
+ response=ChatlistInvite,
1280
+ impl=share_set,
1281
+ summary="Create or edit a folder's shareable invite link",
1282
+ aliases=("folder.share.create", "folder.share.edit"),
1283
+ mutating=True,
1284
+ rate_class="bulk",
1285
+ columns=("slug", "url", "title"),
1286
+ example={"slug": "AbCdEf", "url": "https://t.me/addlist/AbCdEf", "title": "Work"},
1287
+ example_args="folder share set Work --all-eligible",
1288
+ covers=("dialogs.chatlist-invite-create", "dialogs.chatlist-invite-edit"),
1289
+ tags=frozenset({"visible-to-others"}),
1290
+ )
1291
+
1292
+
1293
+ class ShareDeleteReq(Request):
1294
+ folder: Annotated[str, arg(0, metavar="FOLDER", help="Folder id or name.")]
1295
+ slug: Annotated[str, arg(1, metavar="SLUG", help="The link to revoke.")]
1296
+
1297
+
1298
+ async def share_delete(ctx: OpContext, req: ShareDeleteReq) -> ShareDeleted:
1299
+ """Revoke one share link. Anyone holding it stops being able to use it."""
1300
+ from telethon.tl.functions import chatlists as cfn
1301
+
1302
+ raw = await find_filter(ctx, req.folder)
1303
+ filter_id = int(getattr(raw, "id", 0) or 0)
1304
+ slug = _slug(req.slug)
1305
+ await _client(ctx)(cfn.DeleteExportedInviteRequest(chatlist=_chatlist(filter_id), slug=slug))
1306
+ ctx.emit("folder_share_delete", {"id": filter_id, "slug": slug})
1307
+ return ShareDeleted(slug=slug, deleted=True)
1308
+
1309
+
1310
+ SPEC_SHARE_DELETE = OperationSpec(
1311
+ id="folder.share.delete",
1312
+ request=ShareDeleteReq,
1313
+ response=ShareDeleted,
1314
+ impl=share_delete,
1315
+ summary="Revoke a folder share link",
1316
+ mutating=True,
1317
+ destructive=True,
1318
+ columns=("slug", "deleted"),
1319
+ example={"slug": "AbCdEf", "deleted": True},
1320
+ example_args="folder share delete Work AbCdEf",
1321
+ covers=("dialogs.chatlist-invite-delete",),
1322
+ )