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