tlgr-cli 2.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tlgr/__init__.py +3 -0
- tlgr/__main__.py +6 -0
- tlgr/actions/__init__.py +45 -0
- tlgr/actions/forward.py +74 -0
- tlgr/actions/reply.py +32 -0
- tlgr/cli/__init__.py +259 -0
- tlgr/cli/confirm.py +55 -0
- tlgr/cli/errors.py +84 -0
- tlgr/cli/gen.py +690 -0
- tlgr/cli/globals.py +273 -0
- tlgr/cli/introspect.py +170 -0
- tlgr/cli/params.py +189 -0
- tlgr/cli/render.py +418 -0
- tlgr/core/__init__.py +0 -0
- tlgr/core/accounts.py +384 -0
- tlgr/core/config.py +358 -0
- tlgr/core/custom_tl.py +170 -0
- tlgr/core/errors.py +687 -0
- tlgr/core/eventtypes.py +1170 -0
- tlgr/core/identity.py +127 -0
- tlgr/core/launchd.py +122 -0
- tlgr/core/logging.py +194 -0
- tlgr/core/media.py +134 -0
- tlgr/core/output.py +251 -0
- tlgr/core/pagination.py +227 -0
- tlgr/core/paths.py +360 -0
- tlgr/core/peers.py +427 -0
- tlgr/core/process.py +138 -0
- tlgr/core/signing.py +38 -0
- tlgr/core/systemd.py +96 -0
- tlgr/core/telethon_compat.py +295 -0
- tlgr/core/text.py +211 -0
- tlgr/core/timefmt.py +199 -0
- tlgr/core/tl.py +98 -0
- tlgr/daemon/__init__.py +0 -0
- tlgr/daemon/app.py +869 -0
- tlgr/daemon/dispatch.py +446 -0
- tlgr/daemon/events.py +723 -0
- tlgr/daemon/files.py +431 -0
- tlgr/daemon/idle.py +119 -0
- tlgr/daemon/jobs.py +68 -0
- tlgr/daemon/main.py +161 -0
- tlgr/daemon/peercred.py +75 -0
- tlgr/daemon/policy.py +113 -0
- tlgr/daemon/preauth.py +366 -0
- tlgr/daemon/ratelimit.py +391 -0
- tlgr/daemon/server.py +24 -0
- tlgr/daemon/session.py +648 -0
- tlgr/daemon/sessions.py +274 -0
- tlgr/daemon/singleton.py +114 -0
- tlgr/daemon/stream.py +193 -0
- tlgr/daemon/transfers.py +219 -0
- tlgr/daemon/webhook.py +390 -0
- tlgr/data/catalog_index.json +1 -0
- tlgr/data/parity_waivers.toml +90 -0
- tlgr/filters/__init__.py +42 -0
- tlgr/filters/compose.py +121 -0
- tlgr/filters/content.py +85 -0
- tlgr/filters/context.py +114 -0
- tlgr/filters/message.py +161 -0
- tlgr/filters/temporal.py +87 -0
- tlgr/filters/user.py +36 -0
- tlgr/gateway/__init__.py +1 -0
- tlgr/gateway/config.py +161 -0
- tlgr/gateway/engine.py +215 -0
- tlgr/gateway/event.py +22 -0
- tlgr/jobs/__init__.py +0 -0
- tlgr/jobs/base.py +81 -0
- tlgr/jobs/client.py +37 -0
- tlgr/models/__init__.py +1220 -0
- tlgr/models/admin.py +744 -0
- tlgr/models/auth.py +510 -0
- tlgr/models/base.py +81 -0
- tlgr/models/bot.py +576 -0
- tlgr/models/business.py +265 -0
- tlgr/models/call.py +586 -0
- tlgr/models/config.py +101 -0
- tlgr/models/contact.py +481 -0
- tlgr/models/daemon.py +336 -0
- tlgr/models/dialog.py +626 -0
- tlgr/models/envelope.py +68 -0
- tlgr/models/error.py +30 -0
- tlgr/models/event.py +79 -0
- tlgr/models/export.py +66 -0
- tlgr/models/gift.py +275 -0
- tlgr/models/inline.py +84 -0
- tlgr/models/location.py +115 -0
- tlgr/models/media.py +507 -0
- tlgr/models/message.py +584 -0
- tlgr/models/net.py +232 -0
- tlgr/models/notify.py +105 -0
- tlgr/models/page.py +32 -0
- tlgr/models/payment.py +172 -0
- tlgr/models/peer.py +400 -0
- tlgr/models/poll.py +119 -0
- tlgr/models/premium.py +161 -0
- tlgr/models/privacy.py +93 -0
- tlgr/models/profile.py +217 -0
- tlgr/models/reaction.py +160 -0
- tlgr/models/resolve.py +175 -0
- tlgr/models/settings.py +103 -0
- tlgr/models/stars.py +101 -0
- tlgr/models/sticker.py +243 -0
- tlgr/models/story.py +467 -0
- tlgr/models/sync.py +105 -0
- tlgr/models/todo.py +36 -0
- tlgr/models/webapp.py +89 -0
- tlgr/ops/__init__.py +63 -0
- tlgr/ops/_admin.py +313 -0
- tlgr/ops/_auth.py +599 -0
- tlgr/ops/_bots.py +586 -0
- tlgr/ops/_calls.py +535 -0
- tlgr/ops/_common.py +160 -0
- tlgr/ops/_layer.py +46 -0
- tlgr/ops/_media.py +592 -0
- tlgr/ops/_params.py +212 -0
- tlgr/ops/_rights.py +402 -0
- tlgr/ops/_send.py +593 -0
- tlgr/ops/_serialize.py +667 -0
- tlgr/ops/_settings.py +306 -0
- tlgr/ops/_spec.py +167 -0
- tlgr/ops/_story.py +743 -0
- tlgr/ops/account.py +2604 -0
- tlgr/ops/agent.py +937 -0
- tlgr/ops/auth.py +1282 -0
- tlgr/ops/bot.py +4880 -0
- tlgr/ops/business.py +1520 -0
- tlgr/ops/call.py +1610 -0
- tlgr/ops/chat.py +4025 -0
- tlgr/ops/chat_admin.py +929 -0
- tlgr/ops/chat_extra.py +1061 -0
- tlgr/ops/chat_invite.py +716 -0
- tlgr/ops/chat_manage.py +1691 -0
- tlgr/ops/chat_member.py +1357 -0
- tlgr/ops/chat_stats.py +902 -0
- tlgr/ops/chat_topic.py +905 -0
- tlgr/ops/conference.py +791 -0
- tlgr/ops/config.py +1698 -0
- tlgr/ops/contact.py +2330 -0
- tlgr/ops/daemon.py +1397 -0
- tlgr/ops/draft.py +299 -0
- tlgr/ops/emoji.py +343 -0
- tlgr/ops/events.py +1327 -0
- tlgr/ops/export.py +596 -0
- tlgr/ops/folder.py +1322 -0
- tlgr/ops/gif.py +522 -0
- tlgr/ops/gift.py +1546 -0
- tlgr/ops/giveaway.py +541 -0
- tlgr/ops/inline.py +773 -0
- tlgr/ops/job.py +799 -0
- tlgr/ops/location.py +917 -0
- tlgr/ops/media.py +4495 -0
- tlgr/ops/message.py +3769 -0
- tlgr/ops/net.py +536 -0
- tlgr/ops/notify.py +840 -0
- tlgr/ops/passport.py +464 -0
- tlgr/ops/payment.py +907 -0
- tlgr/ops/poll.py +1078 -0
- tlgr/ops/premium.py +488 -0
- tlgr/ops/privacy.py +794 -0
- tlgr/ops/profile.py +1481 -0
- tlgr/ops/proxy.py +750 -0
- tlgr/ops/reaction.py +1475 -0
- tlgr/ops/resolve.py +1140 -0
- tlgr/ops/search.py +521 -0
- tlgr/ops/settings.py +1066 -0
- tlgr/ops/stars.py +594 -0
- tlgr/ops/sticker.py +1602 -0
- tlgr/ops/story.py +3216 -0
- tlgr/ops/sync.py +788 -0
- tlgr/ops/todo.py +514 -0
- tlgr/ops/user.py +1406 -0
- tlgr/ops/vc.py +2351 -0
- tlgr/ops/webapp.py +717 -0
- tlgr/ops/webhook.py +418 -0
- tlgr/parity.py +386 -0
- tlgr/processors/__init__.py +125 -0
- tlgr/processors/regex.py +26 -0
- tlgr/processors/text.py +56 -0
- tlgr/registry.py +519 -0
- tlgr/schema.py +173 -0
- tlgr/transport/__init__.py +30 -0
- tlgr/transport/autostart.py +293 -0
- tlgr/transport/client.py +805 -0
- tlgr/transport/ndjson.py +44 -0
- tlgr/version.py +31 -0
- tlgr_cli-2.0.1.dist-info/METADATA +957 -0
- tlgr_cli-2.0.1.dist-info/RECORD +192 -0
- tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
- tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
- tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
- tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/ops/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
|
+
)
|