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/contact.py
ADDED
|
@@ -0,0 +1,2330 @@
|
|
|
1
|
+
"""The `contact` group: the address book, the blocklist and the phonebook.
|
|
2
|
+
|
|
3
|
+
Four things about Telegram's contact API shape this module.
|
|
4
|
+
|
|
5
|
+
* **Adding a contact is two different methods.** `contacts.addContact` takes
|
|
6
|
+
a user you can already address; `contacts.importContacts` takes a raw
|
|
7
|
+
phone number. They fail differently and they answer differently, and an
|
|
8
|
+
empty `imported` is *ambiguous* — the number may have no account, or its
|
|
9
|
+
owner may refuse phone lookups — so both lists come back rather than a
|
|
10
|
+
boolean.
|
|
11
|
+
* **Several "edit" calls are replacements.** `contacts.setBlocked` and
|
|
12
|
+
`contacts.editCloseFriends` overwrite the whole list, so everything here
|
|
13
|
+
reads the current state, prints the diff and writes the union — never a
|
|
14
|
+
bare append, which is how a client silently unblocks everyone.
|
|
15
|
+
* **A contact's name is only ever *our* view of it.** `contacts.addContact`
|
|
16
|
+
on someone who is already a contact rewrites the local name and touches
|
|
17
|
+
nothing on their profile. v1 leaned on that for tagging and so does this.
|
|
18
|
+
* **Phone numbers are privacy-bearing.** They appear only where the server
|
|
19
|
+
chose to send one, and `--redact` blanks them like any other secret.
|
|
20
|
+
|
|
21
|
+
Telethon is imported inside functions, never at module scope (§2.2).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import contextlib
|
|
27
|
+
import json
|
|
28
|
+
import re
|
|
29
|
+
import time
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Annotated, Any
|
|
33
|
+
|
|
34
|
+
from tlgr.core.errors import NotFoundError, UsageError
|
|
35
|
+
from tlgr.core.pagination import PageKind, build_page, decode_cursor
|
|
36
|
+
from tlgr.core.paths import write_private
|
|
37
|
+
from tlgr.core.timefmt import fmt_dt, parse_dt, to_unix
|
|
38
|
+
from tlgr.models.base import Request
|
|
39
|
+
from tlgr.models.contact import (
|
|
40
|
+
BlockedPeer,
|
|
41
|
+
BlockedSet,
|
|
42
|
+
CloseFriends,
|
|
43
|
+
Contact,
|
|
44
|
+
ContactAdded,
|
|
45
|
+
ContactImport,
|
|
46
|
+
ContactNote,
|
|
47
|
+
ContactRemoved,
|
|
48
|
+
ContactRenamed,
|
|
49
|
+
ContactShared,
|
|
50
|
+
ContactSync,
|
|
51
|
+
FoundPeer,
|
|
52
|
+
ImportedPhone,
|
|
53
|
+
PhoneShared,
|
|
54
|
+
SavedPhoneContact,
|
|
55
|
+
SignUp,
|
|
56
|
+
TopPeer,
|
|
57
|
+
TopPeerState,
|
|
58
|
+
UserStatus,
|
|
59
|
+
)
|
|
60
|
+
from tlgr.models.page import Page
|
|
61
|
+
from tlgr.models.peer import Peer, PeerRef
|
|
62
|
+
from tlgr.ops import _send
|
|
63
|
+
from tlgr.ops._params import arg, choice, opt
|
|
64
|
+
from tlgr.ops._serialize import entity_to_peer, peer_id_of
|
|
65
|
+
from tlgr.ops._spec import OpContext, OperationSpec
|
|
66
|
+
|
|
67
|
+
__all__ = [name for name in dir() if name.startswith("SPEC_")]
|
|
68
|
+
|
|
69
|
+
#: `contacts.importContacts` is one of the most flood-limited methods there
|
|
70
|
+
#: is; official clients send a few hundred per call and pause between them.
|
|
71
|
+
IMPORT_BATCH = 200
|
|
72
|
+
|
|
73
|
+
#: The rating categories `contacts.getTopPeers` splits its answer into, in
|
|
74
|
+
#: the CLI's spelling. The key is the request flag Telethon expects.
|
|
75
|
+
TOP_CATEGORIES: dict[str, str] = {
|
|
76
|
+
"correspondents": "correspondents",
|
|
77
|
+
"bots-pm": "bots_pm",
|
|
78
|
+
"bots-inline": "bots_inline",
|
|
79
|
+
"bots-app": "bots_app",
|
|
80
|
+
"bots-guestchat": "bots_guestchat",
|
|
81
|
+
"calls": "phone_calls",
|
|
82
|
+
"forward-users": "forward_users",
|
|
83
|
+
"forward-chats": "forward_chats",
|
|
84
|
+
"groups": "groups",
|
|
85
|
+
"channels": "channels",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#: category name → the `TopPeerCategory*` constructor `resetTopPeerRating`
|
|
89
|
+
#: and the reply both use.
|
|
90
|
+
_TOP_TYPES: dict[str, str] = {
|
|
91
|
+
"correspondents": "TopPeerCategoryCorrespondents",
|
|
92
|
+
"bots-pm": "TopPeerCategoryBotsPM",
|
|
93
|
+
"bots-inline": "TopPeerCategoryBotsInline",
|
|
94
|
+
"bots-app": "TopPeerCategoryBotsApp",
|
|
95
|
+
"bots-guestchat": "TopPeerCategoryBotsGuestChat",
|
|
96
|
+
"calls": "TopPeerCategoryPhoneCalls",
|
|
97
|
+
"forward-users": "TopPeerCategoryForwardUsers",
|
|
98
|
+
"forward-chats": "TopPeerCategoryForwardChats",
|
|
99
|
+
"groups": "TopPeerCategoryGroups",
|
|
100
|
+
"channels": "TopPeerCategoryChannels",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
_EXAMPLE_CONTACT: dict[str, Any] = {
|
|
104
|
+
"id": 777123,
|
|
105
|
+
"raw_id": 777123,
|
|
106
|
+
"name": "Alice",
|
|
107
|
+
"username": "alice",
|
|
108
|
+
"phone": "+15550001111",
|
|
109
|
+
"mutual": True,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
_PHONE_CHARS = re.compile(r"[^0-9+]")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Shared helpers — `user.py` and `resolve.py` import from here
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def client_of(ctx: OpContext) -> Any:
|
|
121
|
+
client = getattr(ctx, "client", None)
|
|
122
|
+
if client is None: # pragma: no cover - the daemon always supplies one
|
|
123
|
+
raise UsageError("this operation needs a connected account")
|
|
124
|
+
return client
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def mark_already(ctx: OpContext) -> None:
|
|
128
|
+
mark = getattr(ctx, "mark_already", None)
|
|
129
|
+
if callable(mark):
|
|
130
|
+
mark()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def e164(phone: str) -> str:
|
|
134
|
+
"""`(555) 000-1111` → `+15550001111`. Format only; nothing is looked up."""
|
|
135
|
+
cleaned = _PHONE_CHARS.sub("", (phone or "").strip())
|
|
136
|
+
digits = cleaned.lstrip("+")
|
|
137
|
+
return f"+{digits}" if digits else ""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def input_user(
|
|
141
|
+
ctx: OpContext,
|
|
142
|
+
ref: PeerRef | str,
|
|
143
|
+
*,
|
|
144
|
+
from_chat: PeerRef | None = None,
|
|
145
|
+
from_message: int | None = None,
|
|
146
|
+
) -> Any:
|
|
147
|
+
"""The `InputUser` for *ref*, including the `min` form Telethon never builds.
|
|
148
|
+
|
|
149
|
+
A user seen only inside a channel message carries no usable access hash.
|
|
150
|
+
`--from-chat/--from-message` is what turns that into
|
|
151
|
+
`inputUserFromMessage`, and it is the difference between `chat posters`
|
|
152
|
+
producing ids and producing something you can act on.
|
|
153
|
+
"""
|
|
154
|
+
from telethon import utils
|
|
155
|
+
from telethon.tl import types
|
|
156
|
+
|
|
157
|
+
if from_chat is not None and from_message is not None:
|
|
158
|
+
container = await _send.resolve(ctx, from_chat)
|
|
159
|
+
raw = str(getattr(ref, "value", ref)).lstrip("@")
|
|
160
|
+
if not raw.lstrip("-").isdigit():
|
|
161
|
+
raise UsageError(
|
|
162
|
+
"--from-chat/--from-message address a user by id; pass the numeric id",
|
|
163
|
+
field="user",
|
|
164
|
+
)
|
|
165
|
+
return types.InputUserFromMessage(
|
|
166
|
+
peer=container, msg_id=int(from_message), user_id=abs(int(raw))
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
peer = await _send.resolve(ctx, ref)
|
|
170
|
+
try:
|
|
171
|
+
return utils.get_input_user(peer)
|
|
172
|
+
except (TypeError, ValueError) as exc:
|
|
173
|
+
raise UsageError(
|
|
174
|
+
f"{getattr(ref, 'raw', ref)!r} is a chat, not a user", field="user"
|
|
175
|
+
) from exc
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def fetch_user(ctx: OpContext, target: Any) -> Any:
|
|
179
|
+
"""The full `User` object behind an `InputUser`.
|
|
180
|
+
|
|
181
|
+
Used wherever a command has to read the *current* state before writing —
|
|
182
|
+
the existing name for a rename, `stories_hidden` for the idempotent
|
|
183
|
+
hide — because writing a value the server already holds is an RPC and a
|
|
184
|
+
flood-budget entry for nothing.
|
|
185
|
+
"""
|
|
186
|
+
from telethon.tl.functions import users as ufn
|
|
187
|
+
|
|
188
|
+
found = await client_of(ctx)(ufn.GetUsersRequest(id=[target]))
|
|
189
|
+
for user in list(found or []):
|
|
190
|
+
if type(user).__name__ == "User":
|
|
191
|
+
return user
|
|
192
|
+
raise NotFoundError("that user could not be read back from the server")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def display_name(user: Any) -> str:
|
|
196
|
+
first = getattr(user, "first_name", "") or ""
|
|
197
|
+
last = getattr(user, "last_name", "") or ""
|
|
198
|
+
return f"{first} {last}".strip()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def status_model(user_id: int, status: Any) -> UserStatus:
|
|
202
|
+
"""`userStatus*` as a model, keeping `by_me` intact.
|
|
203
|
+
|
|
204
|
+
`userStatusRecently` and friends are coarse *because of a privacy
|
|
205
|
+
setting*, and `by_me` says the setting is ours. Dropping it is how a
|
|
206
|
+
client concludes "they hid from you" about someone who did nothing.
|
|
207
|
+
"""
|
|
208
|
+
name = type(status).__name__
|
|
209
|
+
kind = {
|
|
210
|
+
"UserStatusOnline": "online",
|
|
211
|
+
"UserStatusOffline": "offline",
|
|
212
|
+
"UserStatusRecently": "recently",
|
|
213
|
+
"UserStatusLastWeek": "last_week",
|
|
214
|
+
"UserStatusLastMonth": "last_month",
|
|
215
|
+
}.get(name, "empty")
|
|
216
|
+
expires = getattr(status, "expires", None)
|
|
217
|
+
was = getattr(status, "was_online", None)
|
|
218
|
+
return UserStatus(
|
|
219
|
+
user_id=user_id,
|
|
220
|
+
kind=kind, # type: ignore[arg-type]
|
|
221
|
+
expires=fmt_dt(expires),
|
|
222
|
+
expires_unix=to_unix(expires),
|
|
223
|
+
was_online=fmt_dt(was),
|
|
224
|
+
was_online_unix=to_unix(was),
|
|
225
|
+
by_me=bool(getattr(status, "by_me", False)),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def status_word(status: Any) -> str:
|
|
230
|
+
"""v1's short lowercase status string (`online`, `offline`, `recently`)."""
|
|
231
|
+
return type(status).__name__.replace("UserStatus", "").lower() if status else ""
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def birthday_text(birthday: Any) -> str | None:
|
|
235
|
+
"""`birthday` as `YYYY-MM-DD`, or `MM-DD` when the year is withheld."""
|
|
236
|
+
if birthday is None:
|
|
237
|
+
return None
|
|
238
|
+
day = int(getattr(birthday, "day", 0) or 0)
|
|
239
|
+
month = int(getattr(birthday, "month", 0) or 0)
|
|
240
|
+
if not day or not month:
|
|
241
|
+
return None
|
|
242
|
+
year = getattr(birthday, "year", None)
|
|
243
|
+
return f"{year:04d}-{month:02d}-{day:02d}" if year else f"{month:02d}-{day:02d}"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def birthday_age(birthday: Any, *, today: datetime | None = None) -> int | None:
|
|
247
|
+
year = getattr(birthday, "year", None) if birthday is not None else None
|
|
248
|
+
if not year:
|
|
249
|
+
return None
|
|
250
|
+
now = today or datetime.now(timezone.utc)
|
|
251
|
+
month = int(getattr(birthday, "month", 1) or 1)
|
|
252
|
+
day = int(getattr(birthday, "day", 1) or 1)
|
|
253
|
+
age = now.year - int(year) - ((now.month, now.day) < (month, day))
|
|
254
|
+
return age if age >= 0 else None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def contact_model(user: Any, *, mutual: bool | None = None) -> Contact:
|
|
258
|
+
"""A Telethon `User` as a contact row."""
|
|
259
|
+
raw_id = int(getattr(user, "id", 0) or 0)
|
|
260
|
+
status = getattr(user, "status", None)
|
|
261
|
+
return Contact(
|
|
262
|
+
id=raw_id,
|
|
263
|
+
raw_id=raw_id,
|
|
264
|
+
first_name=getattr(user, "first_name", None),
|
|
265
|
+
last_name=getattr(user, "last_name", None),
|
|
266
|
+
name=display_name(user),
|
|
267
|
+
username=getattr(user, "username", None),
|
|
268
|
+
usernames=[
|
|
269
|
+
u.username
|
|
270
|
+
for u in (getattr(user, "usernames", None) or [])
|
|
271
|
+
if getattr(u, "username", None)
|
|
272
|
+
],
|
|
273
|
+
# Telegram sends a bare number; tlgr emits E.164 everywhere.
|
|
274
|
+
phone=e164(getattr(user, "phone", "") or "") or None,
|
|
275
|
+
mutual=bool(getattr(user, "mutual_contact", False)) if mutual is None else bool(mutual),
|
|
276
|
+
close_friend=bool(getattr(user, "close_friend", False)),
|
|
277
|
+
premium=bool(getattr(user, "premium", False)),
|
|
278
|
+
bot=bool(getattr(user, "bot", False)),
|
|
279
|
+
deleted=bool(getattr(user, "deleted", False)),
|
|
280
|
+
verified=bool(getattr(user, "verified", False)),
|
|
281
|
+
scam=bool(getattr(user, "scam", False)),
|
|
282
|
+
fake=bool(getattr(user, "fake", False)),
|
|
283
|
+
stories_hidden=bool(getattr(user, "stories_hidden", False)),
|
|
284
|
+
status=status_model(raw_id, status) if status is not None else None,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def peers_by_id(*collections: Any) -> dict[int, Any]:
|
|
289
|
+
"""`{marked id: entity}` for the users/chats a reply carried with it."""
|
|
290
|
+
from telethon import utils
|
|
291
|
+
|
|
292
|
+
out: dict[int, Any] = {}
|
|
293
|
+
for collection in collections:
|
|
294
|
+
for entity in collection or []:
|
|
295
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
296
|
+
out[int(utils.get_peer_id(entity))] = entity
|
|
297
|
+
return out
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def peer_model(peer: Any, known: dict[int, Any]) -> Peer:
|
|
301
|
+
"""A bare `Peer` resolved against the entities the same reply carried."""
|
|
302
|
+
marked = peer_id_of(peer)
|
|
303
|
+
entity = known.get(marked or 0)
|
|
304
|
+
if entity is not None:
|
|
305
|
+
return entity_to_peer(entity)
|
|
306
|
+
return Peer(id=marked or 0, raw_id=abs(marked or 0), kind="unknown")
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
async def load_contacts(ctx: OpContext) -> tuple[list[Any], list[Any], int]:
|
|
310
|
+
"""`(contacts, users, saved_count)` from `contacts.getContacts`.
|
|
311
|
+
|
|
312
|
+
`hash=0` because Telethon has no store to diff against; the cheap drift
|
|
313
|
+
check is `contact list --ids-only`, which is `getContactIDs`.
|
|
314
|
+
"""
|
|
315
|
+
from telethon.tl.functions import contacts as fn
|
|
316
|
+
|
|
317
|
+
result = await client_of(ctx)(fn.GetContactsRequest(hash=0))
|
|
318
|
+
if type(result).__name__ == "ContactsNotModified": # pragma: no cover - hash is always 0
|
|
319
|
+
return [], [], 0
|
|
320
|
+
return (
|
|
321
|
+
list(getattr(result, "contacts", None) or []),
|
|
322
|
+
list(getattr(result, "users", None) or []),
|
|
323
|
+
int(getattr(result, "saved_count", 0) or 0),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _window(ctx: OpContext, op: str, kind: PageKind, default: int = 50) -> tuple[int, Any]:
|
|
328
|
+
"""`(limit, cursor state)` — `--limit`/`--cursor` are transport-level (L5)."""
|
|
329
|
+
limit = int(getattr(ctx, "limit", None) or default)
|
|
330
|
+
if limit < 1:
|
|
331
|
+
raise UsageError("--limit must be at least 1", field="limit")
|
|
332
|
+
token = getattr(ctx, "cursor", None)
|
|
333
|
+
state: dict[str, Any] = {}
|
|
334
|
+
if token:
|
|
335
|
+
state = decode_cursor(token, op=op, kind=kind, account=ctx.account)
|
|
336
|
+
return min(limit, 1000), state
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _slice(items: list[Any], ctx: OpContext, op: str, offset: int, limit: int) -> Page[Any]:
|
|
340
|
+
"""One page out of a list we already hold in full."""
|
|
341
|
+
window = items[offset : offset + limit]
|
|
342
|
+
return build_page(
|
|
343
|
+
window,
|
|
344
|
+
op=op,
|
|
345
|
+
kind=PageKind.LOCAL,
|
|
346
|
+
state={"offset": offset + len(window)},
|
|
347
|
+
account=ctx.account,
|
|
348
|
+
has_more=offset + len(window) < len(items),
|
|
349
|
+
total=len(items),
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# ---------------------------------------------------------------------------
|
|
354
|
+
# Phonebook files
|
|
355
|
+
# ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _read_file(source: str, field: str) -> str:
|
|
359
|
+
"""Read a phonebook the daemon can reach.
|
|
360
|
+
|
|
361
|
+
`-` is refused rather than silently read: the implementation runs inside
|
|
362
|
+
the daemon, so "stdin" there is the daemon's stdin, not the caller's.
|
|
363
|
+
"""
|
|
364
|
+
if source.strip() == "-":
|
|
365
|
+
raise UsageError(
|
|
366
|
+
"'-' reads the caller's stdin, and this operation runs in the daemon; "
|
|
367
|
+
"write the phonebook to a file and pass its path",
|
|
368
|
+
field=field,
|
|
369
|
+
)
|
|
370
|
+
path = Path(source).expanduser()
|
|
371
|
+
try:
|
|
372
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
373
|
+
except OSError as exc:
|
|
374
|
+
raise UsageError(f"{source}: {exc.strerror or exc}", field=field) from exc
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def parse_phonebook(text: str) -> list[ImportedPhone]:
|
|
378
|
+
"""vCard, CSV or one-number-per-line, into the same list.
|
|
379
|
+
|
|
380
|
+
Deliberately forgiving about the input and strict about the output: every
|
|
381
|
+
entry ends up with an E.164 number and a first name, because
|
|
382
|
+
`importContacts` rejects an empty name with `CONTACT_NAME_EMPTY` and a
|
|
383
|
+
whole batch fails for one bad row.
|
|
384
|
+
"""
|
|
385
|
+
body = text.strip()
|
|
386
|
+
if not body:
|
|
387
|
+
return []
|
|
388
|
+
if "BEGIN:VCARD" in body.upper():
|
|
389
|
+
return _parse_vcard(body)
|
|
390
|
+
return _parse_csv(body)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _parse_vcard(text: str) -> list[ImportedPhone]:
|
|
394
|
+
out: list[ImportedPhone] = []
|
|
395
|
+
first = last = ""
|
|
396
|
+
phone = ""
|
|
397
|
+
for raw_line in text.splitlines():
|
|
398
|
+
line = raw_line.strip()
|
|
399
|
+
upper = line.upper()
|
|
400
|
+
if upper.startswith("BEGIN:VCARD"):
|
|
401
|
+
first = last = phone = ""
|
|
402
|
+
elif upper.startswith("N:"):
|
|
403
|
+
parts = line.split(":", 1)[1].split(";")
|
|
404
|
+
last = parts[0].strip() if parts else ""
|
|
405
|
+
first = parts[1].strip() if len(parts) > 1 else ""
|
|
406
|
+
elif upper.startswith("FN:") and not first:
|
|
407
|
+
words = line.split(":", 1)[1].strip().split(maxsplit=1)
|
|
408
|
+
first = words[0] if words else ""
|
|
409
|
+
last = words[1] if len(words) > 1 else last
|
|
410
|
+
elif upper.startswith("TEL") and ":" in line:
|
|
411
|
+
phone = e164(line.split(":", 1)[1])
|
|
412
|
+
elif upper.startswith("END:VCARD") and phone:
|
|
413
|
+
out.append(ImportedPhone(phone=phone, first_name=first or phone, last_name=last))
|
|
414
|
+
return out
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _parse_csv(text: str) -> list[ImportedPhone]:
|
|
418
|
+
import csv
|
|
419
|
+
import io
|
|
420
|
+
|
|
421
|
+
out: list[ImportedPhone] = []
|
|
422
|
+
for row in csv.reader(io.StringIO(text)):
|
|
423
|
+
cells = [cell.strip() for cell in row if cell.strip()]
|
|
424
|
+
if not cells:
|
|
425
|
+
continue
|
|
426
|
+
phone = e164(cells[0])
|
|
427
|
+
if not phone or not phone.lstrip("+").isdigit():
|
|
428
|
+
# A header row, or a comment. Skipping beats importing "phone".
|
|
429
|
+
continue
|
|
430
|
+
out.append(
|
|
431
|
+
ImportedPhone(
|
|
432
|
+
phone=phone,
|
|
433
|
+
first_name=cells[1] if len(cells) > 1 else phone,
|
|
434
|
+
last_name=cells[2] if len(cells) > 2 else "",
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
return out
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def render_export(contacts: list[Contact], fmt: str) -> str:
|
|
441
|
+
"""The contact list as vCard, CSV or JSON — all local, no RPC."""
|
|
442
|
+
if fmt == "json":
|
|
443
|
+
import msgspec
|
|
444
|
+
|
|
445
|
+
return msgspec.json.format(msgspec.json.encode(contacts).decode(), indent=2)
|
|
446
|
+
if fmt == "csv":
|
|
447
|
+
lines = ["id,first_name,last_name,username,phone"]
|
|
448
|
+
for row in contacts:
|
|
449
|
+
lines.append(
|
|
450
|
+
",".join(
|
|
451
|
+
str(value or "")
|
|
452
|
+
for value in (
|
|
453
|
+
row.id,
|
|
454
|
+
row.first_name,
|
|
455
|
+
row.last_name,
|
|
456
|
+
row.username,
|
|
457
|
+
row.phone,
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
)
|
|
461
|
+
return "\n".join(lines) + "\n"
|
|
462
|
+
cards: list[str] = []
|
|
463
|
+
for row in contacts:
|
|
464
|
+
card = [
|
|
465
|
+
"BEGIN:VCARD",
|
|
466
|
+
"VERSION:3.0",
|
|
467
|
+
f"N:{row.last_name or ''};{row.first_name or ''};;;",
|
|
468
|
+
f"FN:{row.name or row.first_name or ''}",
|
|
469
|
+
]
|
|
470
|
+
if row.phone:
|
|
471
|
+
card.append(f"TEL;TYPE=CELL:{row.phone}")
|
|
472
|
+
if row.username:
|
|
473
|
+
card.append(f"X-TELEGRAM:{row.username}")
|
|
474
|
+
card.append("END:VCARD")
|
|
475
|
+
cards.append("\n".join(card))
|
|
476
|
+
return "\n".join(cards) + "\n"
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
# ---------------------------------------------------------------------------
|
|
480
|
+
# contact list
|
|
481
|
+
# ---------------------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
class ListReq(Request):
|
|
485
|
+
sort: Annotated[
|
|
486
|
+
str,
|
|
487
|
+
choice("name", "first-name", "last-name", "last-seen", "added", help="Ordering."),
|
|
488
|
+
] = "name"
|
|
489
|
+
with_status: Annotated[
|
|
490
|
+
bool, opt("--with-status", help="Merge contacts.getStatuses into every row.")
|
|
491
|
+
] = False
|
|
492
|
+
with_stories: Annotated[
|
|
493
|
+
bool, opt("--with-stories", help="Add has_unseen_stories per contact.")
|
|
494
|
+
] = False
|
|
495
|
+
mutual_only: Annotated[bool, opt("--mutual-only", help="Only mutual contacts.")] = False
|
|
496
|
+
close_friends_only: Annotated[bool, opt("--close-friends-only", help="Only close friends.")] = (
|
|
497
|
+
False
|
|
498
|
+
)
|
|
499
|
+
unregistered: Annotated[
|
|
500
|
+
bool, opt("--unregistered", help="Saved numbers with no Telegram account (takeout).")
|
|
501
|
+
] = False
|
|
502
|
+
ids_only: Annotated[
|
|
503
|
+
bool, opt("--ids-only", help="Cheap drift check: contacts.getContactIDs only.")
|
|
504
|
+
] = False
|
|
505
|
+
export: Annotated[
|
|
506
|
+
str | None, choice("vcard", "csv", "json", help="Write the list out instead.")
|
|
507
|
+
] = None
|
|
508
|
+
out: Annotated[
|
|
509
|
+
str | None, opt("--out", metavar="PATH", kind="path", help="Destination file for --export.")
|
|
510
|
+
] = None
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
async def _read_stories(ctx: OpContext, users: list[Any]) -> dict[int, bool]:
|
|
514
|
+
"""`{user id: has unseen stories}` from the read marks the server holds."""
|
|
515
|
+
from telethon.tl.functions import stories as sfn
|
|
516
|
+
|
|
517
|
+
read: dict[int, int] = {}
|
|
518
|
+
with contextlib.suppress(Exception):
|
|
519
|
+
result = await client_of(ctx)(sfn.GetAllReadPeerStoriesRequest())
|
|
520
|
+
for update in getattr(result, "updates", None) or []:
|
|
521
|
+
peer = getattr(update, "peer", None)
|
|
522
|
+
marked = peer_id_of(peer)
|
|
523
|
+
if marked is not None:
|
|
524
|
+
read[abs(marked)] = int(getattr(update, "max_id", 0) or 0)
|
|
525
|
+
out: dict[int, bool] = {}
|
|
526
|
+
for user in users:
|
|
527
|
+
recent = getattr(user, "stories_max_id", None)
|
|
528
|
+
max_id = int(getattr(recent, "max_id", 0) or 0)
|
|
529
|
+
out[int(user.id)] = bool(max_id and max_id > read.get(int(user.id), 0))
|
|
530
|
+
return out
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _sorted(rows: list[Contact], how: str) -> list[Contact]:
|
|
534
|
+
if how == "first-name":
|
|
535
|
+
return sorted(rows, key=lambda c: ((c.first_name or "").casefold(), c.id))
|
|
536
|
+
if how == "last-name":
|
|
537
|
+
return sorted(rows, key=lambda c: ((c.last_name or "").casefold(), c.id))
|
|
538
|
+
if how == "last-seen":
|
|
539
|
+
# Newest first: an unknown last-seen sorts last rather than as 1970.
|
|
540
|
+
return sorted(rows, key=lambda c: -((c.status.was_online_unix or 0) if c.status else 0))
|
|
541
|
+
if how == "added":
|
|
542
|
+
# Telegram sends the contact list in the order it was built up.
|
|
543
|
+
return rows
|
|
544
|
+
return sorted(rows, key=lambda c: ((c.name or "").casefold(), c.id))
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
async def list_contacts(ctx: OpContext, req: ListReq) -> Page[Contact]:
|
|
548
|
+
"""The contact list, sorted, decorated and optionally written to a file.
|
|
549
|
+
|
|
550
|
+
Sorting and the vCard/CSV rendering are entirely local: the server sends
|
|
551
|
+
one list and has no opinion about its order, so asking it per sort would
|
|
552
|
+
be a second full download for nothing.
|
|
553
|
+
"""
|
|
554
|
+
from telethon.tl.functions import contacts as fn
|
|
555
|
+
|
|
556
|
+
limit, state = _window(ctx, "contact.list", PageKind.LOCAL, default=200)
|
|
557
|
+
offset = int(state.get("offset", 0) or 0)
|
|
558
|
+
|
|
559
|
+
if req.ids_only:
|
|
560
|
+
ids = list(await client_of(ctx)(fn.GetContactIDsRequest(hash=0)))
|
|
561
|
+
rows = [Contact(id=int(i), raw_id=int(i)) for i in ids]
|
|
562
|
+
return _slice(rows, ctx, "contact.list", offset, limit)
|
|
563
|
+
|
|
564
|
+
if req.unregistered:
|
|
565
|
+
saved = await _saved_contacts(ctx)
|
|
566
|
+
_, users, _ = await load_contacts(ctx)
|
|
567
|
+
known = {e164(getattr(u, "phone", "") or "") for u in users}
|
|
568
|
+
rows = [
|
|
569
|
+
Contact(
|
|
570
|
+
id=0,
|
|
571
|
+
first_name=entry.first_name,
|
|
572
|
+
last_name=entry.last_name,
|
|
573
|
+
name=f"{entry.first_name} {entry.last_name}".strip(),
|
|
574
|
+
phone=entry.phone,
|
|
575
|
+
)
|
|
576
|
+
for entry in saved
|
|
577
|
+
if entry.phone not in known
|
|
578
|
+
]
|
|
579
|
+
return _slice(rows, ctx, "contact.list", offset, limit)
|
|
580
|
+
|
|
581
|
+
contacts, users, saved_count = await load_contacts(ctx)
|
|
582
|
+
mutual = {int(c.user_id): bool(getattr(c, "mutual", False)) for c in contacts}
|
|
583
|
+
rows = [contact_model(u, mutual=mutual.get(int(u.id))) for u in users]
|
|
584
|
+
|
|
585
|
+
if req.with_status:
|
|
586
|
+
statuses = {
|
|
587
|
+
int(item.user_id): status_model(int(item.user_id), item.status)
|
|
588
|
+
for item in (await client_of(ctx)(fn.GetStatusesRequest()) or [])
|
|
589
|
+
}
|
|
590
|
+
for row in rows:
|
|
591
|
+
row.status = statuses.get(row.id, row.status)
|
|
592
|
+
if req.with_stories:
|
|
593
|
+
unseen = await _read_stories(ctx, users)
|
|
594
|
+
for row in rows:
|
|
595
|
+
row.has_unseen_stories = unseen.get(row.id, False)
|
|
596
|
+
|
|
597
|
+
if req.mutual_only:
|
|
598
|
+
rows = [row for row in rows if row.mutual]
|
|
599
|
+
if req.close_friends_only:
|
|
600
|
+
rows = [row for row in rows if row.close_friend]
|
|
601
|
+
rows = _sorted(rows, req.sort)
|
|
602
|
+
if rows:
|
|
603
|
+
rows[0].saved_count = saved_count
|
|
604
|
+
|
|
605
|
+
if req.export:
|
|
606
|
+
if not req.out:
|
|
607
|
+
raise UsageError(
|
|
608
|
+
"--export writes a file and this operation runs in the daemon, so it "
|
|
609
|
+
"needs --out PATH; for machine-readable output on stdout use --json",
|
|
610
|
+
field="out",
|
|
611
|
+
)
|
|
612
|
+
text = render_export(rows, req.export)
|
|
613
|
+
# 0600: a phonebook is exactly the kind of file that should not be
|
|
614
|
+
# world-readable because a shell redirect was convenient.
|
|
615
|
+
write_private(Path(req.out).expanduser(), text)
|
|
616
|
+
ctx.warn(f"wrote {len(rows)} contacts as {req.export} to {req.out}")
|
|
617
|
+
|
|
618
|
+
return _slice(rows, ctx, "contact.list", offset, limit)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
SPEC_LIST = OperationSpec(
|
|
622
|
+
id="contact.list",
|
|
623
|
+
request=ListReq,
|
|
624
|
+
response=Page[Contact],
|
|
625
|
+
impl=list_contacts,
|
|
626
|
+
summary="The contact list, with sorting, status, story state and export formats",
|
|
627
|
+
description=(
|
|
628
|
+
"`contacts.getContacts` sends the whole list in one call, so sorting "
|
|
629
|
+
"and the vCard/CSV rendering happen locally. `--ids-only` is the "
|
|
630
|
+
"cheap drift check (`contacts.getContactIDs`); `--with-status` and "
|
|
631
|
+
"`--with-stories` each cost one extra call for the whole list, never "
|
|
632
|
+
"one per contact. A phone number appears only where privacy allows."
|
|
633
|
+
),
|
|
634
|
+
aliases=("contacts",),
|
|
635
|
+
legacy_paths=("contact list", "contacts"),
|
|
636
|
+
paginated=PageKind.LOCAL,
|
|
637
|
+
rate_class="read",
|
|
638
|
+
columns=("id", "name", "username", "phone"),
|
|
639
|
+
headers=("Id", "Name", "Username", "Phone"),
|
|
640
|
+
example={"items": [_EXAMPLE_CONTACT], "has_more": False, "total": 1},
|
|
641
|
+
example_args="contact list --with-status",
|
|
642
|
+
covers=(
|
|
643
|
+
"contacts-users.close-friends-list",
|
|
644
|
+
"contacts-users.contacts-export-vcard",
|
|
645
|
+
"contacts-users.contacts-ids",
|
|
646
|
+
"contacts-users.contacts-list",
|
|
647
|
+
"contacts-users.contacts-sort",
|
|
648
|
+
"contacts-users.contacts-story-state",
|
|
649
|
+
),
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
# ---------------------------------------------------------------------------
|
|
654
|
+
# contact add / rename / remove
|
|
655
|
+
# ---------------------------------------------------------------------------
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
class AddReq(Request):
|
|
659
|
+
user: Annotated[
|
|
660
|
+
PeerRef | None,
|
|
661
|
+
arg(0, metavar="USER", required=False, kind="user", help="@username, id or +phone."),
|
|
662
|
+
] = None
|
|
663
|
+
name: Annotated[
|
|
664
|
+
str | None,
|
|
665
|
+
arg(1, metavar="NAME", required=False, help="v1 spelling of --first-name [--last-name]."),
|
|
666
|
+
] = None
|
|
667
|
+
first_name: Annotated[
|
|
668
|
+
str | None, opt("--first-name", metavar="TEXT", help="Mandatory for a new contact.")
|
|
669
|
+
] = None
|
|
670
|
+
last_name: Annotated[str | None, opt("--last-name", metavar="TEXT")] = None
|
|
671
|
+
phone: Annotated[
|
|
672
|
+
str | None, opt("--phone", metavar="NUMBER", help="Attach a phone number.")
|
|
673
|
+
] = None
|
|
674
|
+
share_phone: Annotated[
|
|
675
|
+
bool, opt("--share-phone", help="Grant them a phone-number privacy exception.")
|
|
676
|
+
] = False
|
|
677
|
+
note: Annotated[
|
|
678
|
+
str | None, opt("--note", metavar="TEXT", help="Private annotation on the contact.")
|
|
679
|
+
] = None
|
|
680
|
+
from_message: Annotated[
|
|
681
|
+
str | None,
|
|
682
|
+
opt("--from-message", metavar="CHAT:ID", help="Take the contact card of that message."),
|
|
683
|
+
] = None
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _split_name(name: str | None) -> tuple[str, str]:
|
|
687
|
+
parts = (name or "").split(maxsplit=1)
|
|
688
|
+
return (parts[0] if parts else ""), (parts[1] if len(parts) > 1 else "")
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _note_of(text: str | None) -> Any:
|
|
692
|
+
from telethon.tl import types
|
|
693
|
+
|
|
694
|
+
if text is None:
|
|
695
|
+
return None
|
|
696
|
+
return types.TextWithEntities(text=text, entities=[])
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
async def _card_from_message(ctx: OpContext, ref: str) -> tuple[str, str, str, int]:
|
|
700
|
+
"""`(phone, first, last, user_id)` out of a `messageMediaContact`."""
|
|
701
|
+
chat_ref, _, raw_id = ref.rpartition(":")
|
|
702
|
+
if not chat_ref or not raw_id.strip().isdigit():
|
|
703
|
+
raise UsageError("--from-message takes <chat>:<msg-id>", field="from-message")
|
|
704
|
+
peer = await _send.resolve(ctx, chat_ref)
|
|
705
|
+
# `get_messages` picks channels.getMessages or messages.getMessages by
|
|
706
|
+
# peer kind; building the request by hand here would get that wrong for
|
|
707
|
+
# exactly the case (a channel) where a contact card is most often seen.
|
|
708
|
+
found = await client_of(ctx).get_messages(peer, ids=[int(raw_id)])
|
|
709
|
+
for message in found or []:
|
|
710
|
+
media = getattr(message, "media", None) if message is not None else None
|
|
711
|
+
if type(media).__name__ == "MessageMediaContact":
|
|
712
|
+
return (
|
|
713
|
+
e164(getattr(media, "phone_number", "") or ""),
|
|
714
|
+
getattr(media, "first_name", "") or "",
|
|
715
|
+
getattr(media, "last_name", "") or "",
|
|
716
|
+
int(getattr(media, "user_id", 0) or 0),
|
|
717
|
+
)
|
|
718
|
+
raise NotFoundError(f"message {raw_id} in {chat_ref} carries no contact card")
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
async def _import_phone(
|
|
722
|
+
ctx: OpContext, phone: str, first: str, last: str, note: str | None
|
|
723
|
+
) -> ContactAdded:
|
|
724
|
+
"""`contacts.importContacts` for one number, ambiguity intact."""
|
|
725
|
+
from telethon.tl import types
|
|
726
|
+
from telethon.tl.functions import contacts as fn
|
|
727
|
+
|
|
728
|
+
result = await client_of(ctx)(
|
|
729
|
+
fn.ImportContactsRequest(
|
|
730
|
+
[
|
|
731
|
+
types.InputPhoneContact(
|
|
732
|
+
client_id=int(time.time() * 1000) & 0x7FFFFFFF,
|
|
733
|
+
phone=phone,
|
|
734
|
+
first_name=first or phone,
|
|
735
|
+
last_name=last,
|
|
736
|
+
note=_note_of(note),
|
|
737
|
+
)
|
|
738
|
+
]
|
|
739
|
+
)
|
|
740
|
+
)
|
|
741
|
+
imported = [int(i.user_id) for i in getattr(result, "imported", None) or []]
|
|
742
|
+
popular = [int(getattr(p, "importers", 0) or 0) for p in getattr(result, "popular_invites", [])]
|
|
743
|
+
reason = None
|
|
744
|
+
if not imported:
|
|
745
|
+
reason = (
|
|
746
|
+
"the server imported nothing: the number has no Telegram account, OR its "
|
|
747
|
+
"owner refuses lookups by phone (inputPrivacyKeyAddedByPhone). These two "
|
|
748
|
+
"are not distinguishable from here."
|
|
749
|
+
)
|
|
750
|
+
return ContactAdded(
|
|
751
|
+
added=bool(imported),
|
|
752
|
+
user_id=imported[0] if imported else None,
|
|
753
|
+
first_name=first or phone,
|
|
754
|
+
last_name=last,
|
|
755
|
+
imported=imported,
|
|
756
|
+
retry=[int(i) for i in getattr(result, "retry_contacts", None) or []],
|
|
757
|
+
popular_importers=max(popular) if popular else None,
|
|
758
|
+
note=note,
|
|
759
|
+
reason=reason,
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
async def add(ctx: OpContext, req: AddReq) -> ContactAdded:
|
|
764
|
+
"""Add a contact — by user, by phone, or from a contact card in a message.
|
|
765
|
+
|
|
766
|
+
Which method runs is decided by what we can address: a user we can build
|
|
767
|
+
an `InputUser` for goes through `contacts.addContact` (no phone needed);
|
|
768
|
+
a bare number goes through `contacts.importContacts`, whose empty answer
|
|
769
|
+
is ambiguous and is reported as ambiguous rather than as "no such user".
|
|
770
|
+
"""
|
|
771
|
+
from telethon.tl.functions import contacts as fn
|
|
772
|
+
|
|
773
|
+
first, last = _split_name(req.name)
|
|
774
|
+
first = req.first_name if req.first_name is not None else first
|
|
775
|
+
last = req.last_name if req.last_name is not None else last
|
|
776
|
+
phone = e164(req.phone or "")
|
|
777
|
+
|
|
778
|
+
if req.from_message:
|
|
779
|
+
card_phone, card_first, card_last, user_id = await _card_from_message(ctx, req.from_message)
|
|
780
|
+
first = first or card_first
|
|
781
|
+
last = last or card_last
|
|
782
|
+
phone = phone or card_phone
|
|
783
|
+
if not user_id:
|
|
784
|
+
# A card with user_id 0 belongs to somebody with no account we can
|
|
785
|
+
# see; only importContacts can do anything with it.
|
|
786
|
+
if not phone:
|
|
787
|
+
raise NotFoundError("that contact card carries neither a user nor a phone number")
|
|
788
|
+
return await _import_phone(ctx, phone, first, last, req.note)
|
|
789
|
+
target = await input_user(ctx, str(user_id))
|
|
790
|
+
elif req.user is not None and req.user.kind == "phone":
|
|
791
|
+
return await _import_phone(ctx, str(req.user.value), first, last, req.note)
|
|
792
|
+
elif req.user is not None:
|
|
793
|
+
target = await input_user(ctx, req.user)
|
|
794
|
+
elif phone:
|
|
795
|
+
return await _import_phone(ctx, phone, first, last, req.note)
|
|
796
|
+
else:
|
|
797
|
+
raise UsageError("give a user, a +phone, or --from-message", field="user")
|
|
798
|
+
|
|
799
|
+
known = await fetch_user(ctx, target)
|
|
800
|
+
first = first or (getattr(known, "first_name", "") or "")
|
|
801
|
+
last = last or (getattr(known, "last_name", "") or "")
|
|
802
|
+
if not first:
|
|
803
|
+
# The server rejects an empty first name outright; v1 sent "." and
|
|
804
|
+
# everything downstream (including the user's own tagging scheme)
|
|
805
|
+
# depends on that still working.
|
|
806
|
+
first = "."
|
|
807
|
+
if req.share_phone and not getattr(ctx, "dry_run", False):
|
|
808
|
+
ctx.warn("--share-phone discloses your own number to them; it cannot be undone")
|
|
809
|
+
await client_of(ctx)(
|
|
810
|
+
fn.AddContactRequest(
|
|
811
|
+
id=target,
|
|
812
|
+
first_name=first,
|
|
813
|
+
last_name=last,
|
|
814
|
+
phone=phone or (getattr(known, "phone", None) or ""),
|
|
815
|
+
add_phone_privacy_exception=req.share_phone or None,
|
|
816
|
+
note=_note_of(req.note),
|
|
817
|
+
)
|
|
818
|
+
)
|
|
819
|
+
user_id = int(getattr(known, "id", 0) or 0)
|
|
820
|
+
ctx.emit("contact_add", {"user_id": user_id})
|
|
821
|
+
return ContactAdded(
|
|
822
|
+
added=True,
|
|
823
|
+
user_id=user_id,
|
|
824
|
+
first_name=first,
|
|
825
|
+
last_name=last,
|
|
826
|
+
imported=[user_id],
|
|
827
|
+
shared_phone=req.share_phone,
|
|
828
|
+
note=req.note,
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
SPEC_ADD = OperationSpec(
|
|
833
|
+
id="contact.add",
|
|
834
|
+
request=AddReq,
|
|
835
|
+
response=ContactAdded,
|
|
836
|
+
impl=add,
|
|
837
|
+
summary="Add a contact — by user, by phone, or from a contact card in a message",
|
|
838
|
+
description=(
|
|
839
|
+
"An empty `imported` with an empty `retry` is ambiguous: the number "
|
|
840
|
+
"has no account, or its owner hides it from phone lookups. `reason` "
|
|
841
|
+
"says so rather than the reply claiming 'no such user'. `--retry` "
|
|
842
|
+
"entries must be sent again later; they are not failures."
|
|
843
|
+
),
|
|
844
|
+
legacy_paths=("contact add",),
|
|
845
|
+
mutating=True,
|
|
846
|
+
rate_class="bulk",
|
|
847
|
+
columns=("added", "user_id", "first_name"),
|
|
848
|
+
example={"added": True, "user_id": 777123, "first_name": "Alice", "imported": [777123]},
|
|
849
|
+
example_args="contact add @alice --first-name Alice",
|
|
850
|
+
covers=(
|
|
851
|
+
"contact.receive-card",
|
|
852
|
+
"contacts-users.contact-add-by-phone",
|
|
853
|
+
"contacts-users.contact-add-by-user",
|
|
854
|
+
"contacts-users.contact-card-open",
|
|
855
|
+
"contacts-users.contact-phone-privacy-exception",
|
|
856
|
+
"dialogs.actionbar-add-contact",
|
|
857
|
+
),
|
|
858
|
+
tags=frozenset({"visible-to-others"}),
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
class RenameReq(Request):
|
|
863
|
+
user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Who to rename.")]
|
|
864
|
+
first_name: Annotated[str | None, opt("--first-name", metavar="TEXT")] = None
|
|
865
|
+
last_name: Annotated[str | None, opt("--last-name", metavar="TEXT")] = None
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
async def rename(ctx: OpContext, req: RenameReq) -> ContactRenamed:
|
|
869
|
+
"""Change the locally visible name of a contact.
|
|
870
|
+
|
|
871
|
+
Re-issuing `addContact` for someone who is already a contact rewrites
|
|
872
|
+
only *our* view of their name; their profile is untouched, and it works
|
|
873
|
+
on non-contacts too (it saves them). Omitted parts keep the current
|
|
874
|
+
profile name, and an empty first name becomes `"."` because the server
|
|
875
|
+
rejects an empty one — v1 did this and the user's tagging depends on it.
|
|
876
|
+
"""
|
|
877
|
+
from telethon.tl.functions import contacts as fn
|
|
878
|
+
|
|
879
|
+
target = await input_user(ctx, req.user)
|
|
880
|
+
known = await fetch_user(ctx, target)
|
|
881
|
+
first = req.first_name if req.first_name is not None else (known.first_name or "")
|
|
882
|
+
last = req.last_name if req.last_name is not None else (known.last_name or "")
|
|
883
|
+
if not first:
|
|
884
|
+
first = "."
|
|
885
|
+
await client_of(ctx)(
|
|
886
|
+
fn.AddContactRequest(
|
|
887
|
+
id=target,
|
|
888
|
+
first_name=first,
|
|
889
|
+
last_name=last,
|
|
890
|
+
phone=getattr(known, "phone", None) or "",
|
|
891
|
+
add_phone_privacy_exception=None,
|
|
892
|
+
)
|
|
893
|
+
)
|
|
894
|
+
user_id = int(getattr(known, "id", 0) or 0)
|
|
895
|
+
ctx.emit("contact_rename", {"user_id": user_id})
|
|
896
|
+
return ContactRenamed(saved=True, user_id=user_id, first_name=first, last_name=last)
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
SPEC_RENAME = OperationSpec(
|
|
900
|
+
id="contact.rename",
|
|
901
|
+
request=RenameReq,
|
|
902
|
+
response=ContactRenamed,
|
|
903
|
+
impl=rename,
|
|
904
|
+
summary="Change the locally visible name of a contact",
|
|
905
|
+
description=(
|
|
906
|
+
"Works on non-contacts too — it saves them — which is what makes it "
|
|
907
|
+
"usable for tagging users with a state marker in the last name."
|
|
908
|
+
),
|
|
909
|
+
legacy_paths=("contact rename",),
|
|
910
|
+
mutating=True,
|
|
911
|
+
idempotent=True,
|
|
912
|
+
columns=("saved", "user_id", "first_name", "last_name"),
|
|
913
|
+
example={"saved": True, "user_id": 777123, "first_name": "Alice", "last_name": "· lead"},
|
|
914
|
+
example_args="contact rename @alice --last-name '· lead'",
|
|
915
|
+
covers=("contacts-users.contact-edit-name",),
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
class RemoveReq(Request):
|
|
920
|
+
user: Annotated[
|
|
921
|
+
list[PeerRef],
|
|
922
|
+
arg(0, metavar="USER", variadic=True, kind="user", help="Contacts to delete."),
|
|
923
|
+
] = []
|
|
924
|
+
phone: Annotated[
|
|
925
|
+
list[str],
|
|
926
|
+
opt("--phone", metavar="NUMBER", help="Delete a phonebook entry by number."),
|
|
927
|
+
] = []
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
async def remove(ctx: OpContext, req: RemoveReq) -> ContactRemoved:
|
|
931
|
+
"""Delete contacts, by user or by phone number.
|
|
932
|
+
|
|
933
|
+
Deleting by phone reaches entries with no Telegram account at all, which
|
|
934
|
+
is the only way to clear them — and it is irreversible server-side, which
|
|
935
|
+
is why the whole op is destructive and needs `--yes`.
|
|
936
|
+
"""
|
|
937
|
+
from telethon.tl.functions import contacts as fn
|
|
938
|
+
|
|
939
|
+
if not req.user and not req.phone:
|
|
940
|
+
raise UsageError("give at least one user or --phone", field="user")
|
|
941
|
+
|
|
942
|
+
user_ids: list[int] = []
|
|
943
|
+
if req.user:
|
|
944
|
+
targets = [await input_user(ctx, ref) for ref in req.user]
|
|
945
|
+
result = await client_of(ctx)(fn.DeleteContactsRequest(id=targets))
|
|
946
|
+
user_ids = [
|
|
947
|
+
int(u.id) for u in (getattr(result, "users", None) or []) if hasattr(u, "id")
|
|
948
|
+
] or [int(getattr(t, "user_id", 0) or 0) for t in targets]
|
|
949
|
+
|
|
950
|
+
phones = [e164(p) for p in req.phone if e164(p)]
|
|
951
|
+
if phones:
|
|
952
|
+
await client_of(ctx)(fn.DeleteByPhonesRequest(phones=phones))
|
|
953
|
+
|
|
954
|
+
ctx.emit("contact_remove", {"user_ids": user_ids, "phones": phones})
|
|
955
|
+
return ContactRemoved(removed=True, user_ids=user_ids, phones=phones)
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
SPEC_REMOVE = OperationSpec(
|
|
959
|
+
id="contact.remove",
|
|
960
|
+
request=RemoveReq,
|
|
961
|
+
response=ContactRemoved,
|
|
962
|
+
impl=remove,
|
|
963
|
+
summary="Delete contacts, by user or by phone number",
|
|
964
|
+
legacy_paths=("contact remove",),
|
|
965
|
+
mutating=True,
|
|
966
|
+
destructive=True,
|
|
967
|
+
rate_class="bulk",
|
|
968
|
+
columns=("removed", "user_ids", "phones"),
|
|
969
|
+
example={"removed": True, "user_ids": [777123], "phones": []},
|
|
970
|
+
example_args="contact remove @alice",
|
|
971
|
+
covers=("contacts-users.contact-delete", "contacts-users.contact-delete-by-phone"),
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
# ---------------------------------------------------------------------------
|
|
976
|
+
# contact note set
|
|
977
|
+
# ---------------------------------------------------------------------------
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
class NoteSetReq(Request):
|
|
981
|
+
user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Which contact.")]
|
|
982
|
+
text: Annotated[str | None, arg(1, metavar="TEXT", required=False, help="The note.")] = None
|
|
983
|
+
clear: Annotated[bool, opt("--clear", help="Delete the note.")] = False
|
|
984
|
+
parse: Annotated[str | None, choice("md", "html", "none", help="Markup of the note.")] = None
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
async def note_set(ctx: OpContext, req: NoteSetReq) -> ContactNote:
|
|
988
|
+
"""Set or clear the private note attached to a contact.
|
|
989
|
+
|
|
990
|
+
The note is ours alone; read it back with `user get --full`. Only a
|
|
991
|
+
contact can carry one, so a non-contact fails with CONTACT_MISSING rather
|
|
992
|
+
than silently doing nothing.
|
|
993
|
+
"""
|
|
994
|
+
from telethon.tl import types
|
|
995
|
+
from telethon.tl.functions import contacts as fn
|
|
996
|
+
|
|
997
|
+
if req.clear and req.text:
|
|
998
|
+
raise UsageError("--clear and a note text contradict each other", field="clear")
|
|
999
|
+
text, entities = _send.body(req.text or "", parse=req.parse)
|
|
1000
|
+
if req.clear:
|
|
1001
|
+
text, entities = "", []
|
|
1002
|
+
|
|
1003
|
+
target = await input_user(ctx, req.user)
|
|
1004
|
+
await client_of(ctx)(
|
|
1005
|
+
fn.UpdateContactNoteRequest(
|
|
1006
|
+
id=target,
|
|
1007
|
+
note=types.TextWithEntities(text=text, entities=_send.tl_entities(entities) or []),
|
|
1008
|
+
)
|
|
1009
|
+
)
|
|
1010
|
+
user_id = int(getattr(target, "user_id", 0) or 0)
|
|
1011
|
+
ctx.emit("contact_note", {"user_id": user_id})
|
|
1012
|
+
return ContactNote(user_id=user_id, note=text or None, cleared=not text)
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
SPEC_NOTE_SET = OperationSpec(
|
|
1016
|
+
id="contact.note.set",
|
|
1017
|
+
request=NoteSetReq,
|
|
1018
|
+
response=ContactNote,
|
|
1019
|
+
impl=note_set,
|
|
1020
|
+
summary="Set or clear the private note attached to a contact",
|
|
1021
|
+
description="An empty `TextWithEntities` is how Telegram spells 'no note'.",
|
|
1022
|
+
mutating=True,
|
|
1023
|
+
idempotent=True,
|
|
1024
|
+
columns=("user_id", "note"),
|
|
1025
|
+
example={"user_id": 777123, "note": "met at the conference"},
|
|
1026
|
+
example_args='contact note set @alice "met at the conference"',
|
|
1027
|
+
covers=(
|
|
1028
|
+
"contact.note",
|
|
1029
|
+
"contacts-users.contact-note-delete",
|
|
1030
|
+
"contacts-users.contact-note-set",
|
|
1031
|
+
),
|
|
1032
|
+
)
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
# ---------------------------------------------------------------------------
|
|
1036
|
+
# contact search
|
|
1037
|
+
# ---------------------------------------------------------------------------
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
class SearchReq(Request):
|
|
1041
|
+
query: Annotated[str, arg(0, metavar="QUERY", help="What to look for.")] = ""
|
|
1042
|
+
mine_only: Annotated[bool, opt("--mine-only", help="Only contacts and known peers.")] = False
|
|
1043
|
+
global_only: Annotated[bool, opt("--global-only", help="Only public username matches.")] = False
|
|
1044
|
+
broadcasts: Annotated[bool, opt("--broadcasts", help="Restrict to channels.")] = False
|
|
1045
|
+
bots: Annotated[bool, opt("--bots", help="Restrict to bots.")] = False
|
|
1046
|
+
type: Annotated[str | None, choice("user", "bot", "group", "channel", help="Kind filter.")] = (
|
|
1047
|
+
None
|
|
1048
|
+
)
|
|
1049
|
+
with_sponsored: Annotated[
|
|
1050
|
+
bool, opt("--with-sponsored", help="Also request sponsored peers (off by default).")
|
|
1051
|
+
] = False
|
|
1052
|
+
recent: Annotated[bool, opt("--recent", help="List the recently searched peers instead.")] = (
|
|
1053
|
+
False
|
|
1054
|
+
)
|
|
1055
|
+
forget: Annotated[
|
|
1056
|
+
PeerRef | None,
|
|
1057
|
+
opt("--forget", metavar="PEER", kind="peer", help="Drop one entry and reset its rating."),
|
|
1058
|
+
] = None
|
|
1059
|
+
clear_recent: Annotated[bool, opt("--clear-recent", help="Forget the whole history.")] = False
|
|
1060
|
+
with_tme_urls: Annotated[
|
|
1061
|
+
bool, opt("--with-tme-urls", help="With --recent: include help.getRecentMeUrls.")
|
|
1062
|
+
] = False
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def _recent_path(ctx: OpContext) -> Path | None:
|
|
1066
|
+
paths = getattr(ctx, "paths", None)
|
|
1067
|
+
if paths is None or not ctx.account: # pragma: no cover - the daemon supplies both
|
|
1068
|
+
return None
|
|
1069
|
+
return Path(paths.account_dir(ctx.account)) / "recent_peers.json"
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def _recent_load(ctx: OpContext) -> list[dict[str, Any]]:
|
|
1073
|
+
path = _recent_path(ctx)
|
|
1074
|
+
if path is None or not path.exists():
|
|
1075
|
+
return []
|
|
1076
|
+
try:
|
|
1077
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
1078
|
+
except (OSError, json.JSONDecodeError): # pragma: no cover - a corrupt file is empty
|
|
1079
|
+
return []
|
|
1080
|
+
return list(raw.get("peers", [])) if isinstance(raw, dict) else []
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def _recent_save(ctx: OpContext, rows: list[dict[str, Any]]) -> None:
|
|
1084
|
+
path = _recent_path(ctx)
|
|
1085
|
+
if path is None: # pragma: no cover
|
|
1086
|
+
return
|
|
1087
|
+
with contextlib.suppress(OSError):
|
|
1088
|
+
write_private(path, json.dumps({"peers": rows[:50]}))
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
async def search(ctx: OpContext, req: SearchReq) -> Page[FoundPeer]:
|
|
1092
|
+
"""Search contacts, known peers and global public usernames.
|
|
1093
|
+
|
|
1094
|
+
`contacts.search` splits its answer in two and the split is the useful
|
|
1095
|
+
part: `my_results` is people this account already knows, `results` is
|
|
1096
|
+
everyone else's public username. They arrive labelled (`source`) rather
|
|
1097
|
+
than merged, and sponsored rows stay off unless asked for — a CLI has no
|
|
1098
|
+
reason to render an advert next to a contact.
|
|
1099
|
+
|
|
1100
|
+
The recent-search list is tlgr's own state: TDLib keeps it client-side
|
|
1101
|
+
and MTProto has no call for it. `--forget` is the one half that *is*
|
|
1102
|
+
server-side, because dropping a peer also resets its top-peer rating so
|
|
1103
|
+
the server stops suggesting it.
|
|
1104
|
+
"""
|
|
1105
|
+
from telethon.tl.functions import contacts as fn
|
|
1106
|
+
from telethon.tl.functions import help as hfn
|
|
1107
|
+
|
|
1108
|
+
limit, state = _window(ctx, "contact.search", PageKind.LOCAL, default=50)
|
|
1109
|
+
offset = int(state.get("offset", 0) or 0)
|
|
1110
|
+
|
|
1111
|
+
# Searching is a read, so the op stays dry-runnable; the two branches
|
|
1112
|
+
# that *do* write guard themselves rather than making the whole command
|
|
1113
|
+
# print a stub under --dry-run (the `folder list --tags` pattern).
|
|
1114
|
+
if req.clear_recent:
|
|
1115
|
+
if getattr(ctx, "dry_run", False):
|
|
1116
|
+
ctx.warn("--dry-run: the recent-search history would be cleared")
|
|
1117
|
+
else:
|
|
1118
|
+
_recent_save(ctx, [])
|
|
1119
|
+
ctx.emit("contact_search_clear", {})
|
|
1120
|
+
if req.forget is not None:
|
|
1121
|
+
from telethon.tl import types as tl
|
|
1122
|
+
|
|
1123
|
+
peer = await _send.resolve(ctx, req.forget)
|
|
1124
|
+
marked = _send.peer_id_of(peer)
|
|
1125
|
+
if getattr(ctx, "dry_run", False):
|
|
1126
|
+
ctx.warn(f"--dry-run: {marked} would be forgotten and its rating reset")
|
|
1127
|
+
else:
|
|
1128
|
+
_recent_save(ctx, [row for row in _recent_load(ctx) if int(row.get("id", 0)) != marked])
|
|
1129
|
+
await client_of(ctx)(
|
|
1130
|
+
fn.ResetTopPeerRatingRequest(category=tl.TopPeerCategoryCorrespondents(), peer=peer)
|
|
1131
|
+
)
|
|
1132
|
+
ctx.emit("contact_search_forget", {"peer_id": marked})
|
|
1133
|
+
|
|
1134
|
+
if req.recent:
|
|
1135
|
+
recent: list[FoundPeer] = [
|
|
1136
|
+
FoundPeer(
|
|
1137
|
+
peer=Peer(
|
|
1138
|
+
id=int(row.get("id", 0)),
|
|
1139
|
+
raw_id=abs(int(row.get("id", 0))),
|
|
1140
|
+
kind=row.get("kind", "unknown"),
|
|
1141
|
+
title=row.get("title", ""),
|
|
1142
|
+
username=row.get("username"),
|
|
1143
|
+
),
|
|
1144
|
+
source="recent",
|
|
1145
|
+
)
|
|
1146
|
+
for row in _recent_load(ctx)
|
|
1147
|
+
]
|
|
1148
|
+
if req.with_tme_urls:
|
|
1149
|
+
result = await client_of(ctx)(hfn.GetRecentMeUrlsRequest(referer=""))
|
|
1150
|
+
known = peers_by_id(getattr(result, "users", None), getattr(result, "chats", None))
|
|
1151
|
+
for url in getattr(result, "urls", None) or []:
|
|
1152
|
+
peer_ref = getattr(url, "peer", None) or getattr(url, "chat", None)
|
|
1153
|
+
if peer_ref is None:
|
|
1154
|
+
continue
|
|
1155
|
+
recent.append(
|
|
1156
|
+
FoundPeer(
|
|
1157
|
+
peer=peer_model(peer_ref, known),
|
|
1158
|
+
source="tme",
|
|
1159
|
+
url=str(getattr(url, "url", "") or ""),
|
|
1160
|
+
)
|
|
1161
|
+
)
|
|
1162
|
+
return _slice(recent, ctx, "contact.search", offset, limit)
|
|
1163
|
+
|
|
1164
|
+
if not req.query.strip():
|
|
1165
|
+
raise UsageError("a search query is required", field="query")
|
|
1166
|
+
|
|
1167
|
+
found = await client_of(ctx)(
|
|
1168
|
+
fn.SearchRequest(
|
|
1169
|
+
q=req.query,
|
|
1170
|
+
limit=min(limit + offset, 200),
|
|
1171
|
+
broadcasts=req.broadcasts or None,
|
|
1172
|
+
bots=req.bots or None,
|
|
1173
|
+
)
|
|
1174
|
+
)
|
|
1175
|
+
known = peers_by_id(getattr(found, "users", None), getattr(found, "chats", None))
|
|
1176
|
+
rows: list[FoundPeer] = []
|
|
1177
|
+
if not req.global_only:
|
|
1178
|
+
rows += [
|
|
1179
|
+
FoundPeer(peer=peer_model(p, known), source="mine")
|
|
1180
|
+
for p in getattr(found, "my_results", None) or []
|
|
1181
|
+
]
|
|
1182
|
+
if not req.mine_only:
|
|
1183
|
+
seen = {row.peer.id for row in rows}
|
|
1184
|
+
rows += [
|
|
1185
|
+
FoundPeer(peer=peer_model(p, known), source="global")
|
|
1186
|
+
for p in getattr(found, "results", None) or []
|
|
1187
|
+
if peer_id_of(p) not in seen
|
|
1188
|
+
]
|
|
1189
|
+
|
|
1190
|
+
if req.with_sponsored:
|
|
1191
|
+
sponsored = await client_of(ctx)(fn.GetSponsoredPeersRequest(q=req.query))
|
|
1192
|
+
ads = peers_by_id(getattr(sponsored, "users", None), getattr(sponsored, "chats", None))
|
|
1193
|
+
for item in getattr(sponsored, "peers", None) or []:
|
|
1194
|
+
random_id = getattr(item, "random_id", None)
|
|
1195
|
+
rows.append(
|
|
1196
|
+
FoundPeer(
|
|
1197
|
+
peer=peer_model(getattr(item, "peer", None), ads),
|
|
1198
|
+
source="sponsored",
|
|
1199
|
+
sponsored=True,
|
|
1200
|
+
random_id=random_id.hex() if isinstance(random_id, bytes) else None,
|
|
1201
|
+
)
|
|
1202
|
+
)
|
|
1203
|
+
|
|
1204
|
+
if req.type:
|
|
1205
|
+
wanted = {"user": {"user"}, "bot": {"bot"}, "group": {"group", "supergroup"}}.get(
|
|
1206
|
+
req.type, {"channel"}
|
|
1207
|
+
)
|
|
1208
|
+
rows = [row for row in rows if row.peer.kind in wanted]
|
|
1209
|
+
|
|
1210
|
+
# Remember what was found so `--recent` has something to show; this is
|
|
1211
|
+
# local state, and the only reason it exists is that MTProto has no call
|
|
1212
|
+
# for the recently-searched list every GUI client keeps.
|
|
1213
|
+
history = _recent_load(ctx)
|
|
1214
|
+
for row in rows[:10]:
|
|
1215
|
+
entry = {
|
|
1216
|
+
"id": row.peer.id,
|
|
1217
|
+
"kind": row.peer.kind,
|
|
1218
|
+
"title": row.peer.title,
|
|
1219
|
+
"username": row.peer.username,
|
|
1220
|
+
}
|
|
1221
|
+
history = [item for item in history if int(item.get("id", 0)) != row.peer.id]
|
|
1222
|
+
history.insert(0, entry)
|
|
1223
|
+
_recent_save(ctx, history)
|
|
1224
|
+
|
|
1225
|
+
return _slice(rows, ctx, "contact.search", offset, limit)
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
SPEC_SEARCH = OperationSpec(
|
|
1229
|
+
id="contact.search",
|
|
1230
|
+
request=SearchReq,
|
|
1231
|
+
response=Page[FoundPeer],
|
|
1232
|
+
impl=search,
|
|
1233
|
+
summary="Search contacts, known peers and global public usernames",
|
|
1234
|
+
description=(
|
|
1235
|
+
"`source` labels every row: `mine` is a contact or an already-known "
|
|
1236
|
+
"peer, `global` is a public username match, `recent` is tlgr's own "
|
|
1237
|
+
"search history and `sponsored` is an advert (off unless "
|
|
1238
|
+
"--with-sponsored). Local title matching over the dialog list is "
|
|
1239
|
+
"`chat list --search`."
|
|
1240
|
+
),
|
|
1241
|
+
aliases=("chat.search",),
|
|
1242
|
+
legacy_paths=("contact search",),
|
|
1243
|
+
paginated=PageKind.LOCAL,
|
|
1244
|
+
rate_class="resolve",
|
|
1245
|
+
tags=frozenset({"mutating-checked"}),
|
|
1246
|
+
columns=("peer.id", "peer.title", "peer.username", "source"),
|
|
1247
|
+
headers=("Id", "Title", "Username", "Source"),
|
|
1248
|
+
example={
|
|
1249
|
+
"items": [
|
|
1250
|
+
{
|
|
1251
|
+
"peer": {"id": 777123, "raw_id": 777123, "kind": "user", "title": "Alice"},
|
|
1252
|
+
"source": "mine",
|
|
1253
|
+
}
|
|
1254
|
+
],
|
|
1255
|
+
"has_more": False,
|
|
1256
|
+
},
|
|
1257
|
+
example_args="contact search alice",
|
|
1258
|
+
covers=(
|
|
1259
|
+
"contacts-users.contacts-search",
|
|
1260
|
+
"contacts-users.search-recent",
|
|
1261
|
+
"contacts-users.search-sponsored-peers",
|
|
1262
|
+
"dialogs.recent-searches",
|
|
1263
|
+
"dialogs.search-peers",
|
|
1264
|
+
"dialogs.sponsored-search-peers",
|
|
1265
|
+
),
|
|
1266
|
+
)
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
# ---------------------------------------------------------------------------
|
|
1270
|
+
# contact status list / birthday list / joined list
|
|
1271
|
+
# ---------------------------------------------------------------------------
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
class StatusListReq(Request):
|
|
1275
|
+
online_only: Annotated[bool, opt("--online-only", help="Only contacts online right now.")] = (
|
|
1276
|
+
False
|
|
1277
|
+
)
|
|
1278
|
+
since: Annotated[
|
|
1279
|
+
str | None,
|
|
1280
|
+
opt("--since", metavar="TS", kind="datetime", help="Only statuses newer than this."),
|
|
1281
|
+
] = None
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
async def status_list(ctx: OpContext, req: StatusListReq) -> Page[UserStatus]:
|
|
1285
|
+
"""Online / last-seen for every contact, in one call.
|
|
1286
|
+
|
|
1287
|
+
This is the cold-start snapshot; live changes arrive as `updateUserStatus`
|
|
1288
|
+
on the event bus. `by_me` on a coarse bucket means *our* last-seen privacy
|
|
1289
|
+
caused the coarseness — never report that as the peer hiding from us.
|
|
1290
|
+
"""
|
|
1291
|
+
from telethon.tl.functions import contacts as fn
|
|
1292
|
+
|
|
1293
|
+
rows = [
|
|
1294
|
+
status_model(int(item.user_id), item.status)
|
|
1295
|
+
for item in (await client_of(ctx)(fn.GetStatusesRequest()) or [])
|
|
1296
|
+
]
|
|
1297
|
+
if req.online_only:
|
|
1298
|
+
rows = [row for row in rows if row.kind == "online"]
|
|
1299
|
+
if req.since:
|
|
1300
|
+
floor = parse_dt(req.since)
|
|
1301
|
+
cutoff = int(floor.timestamp()) if floor else 0
|
|
1302
|
+
rows = [
|
|
1303
|
+
row for row in rows if max(row.was_online_unix or 0, row.expires_unix or 0) >= cutoff
|
|
1304
|
+
]
|
|
1305
|
+
return Page(items=rows, has_more=False, total=len(rows))
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
SPEC_STATUS_LIST = OperationSpec(
|
|
1309
|
+
id="contact.status.list",
|
|
1310
|
+
request=StatusListReq,
|
|
1311
|
+
response=Page[UserStatus],
|
|
1312
|
+
impl=status_list,
|
|
1313
|
+
summary="Online / last-seen status of every contact in one call",
|
|
1314
|
+
description=(
|
|
1315
|
+
"`userStatusRecently`/`LastWeek`/`LastMonth` carry `by_me`: the "
|
|
1316
|
+
"coarse bucket is caused by OUR OWN last-seen privacy, not by "
|
|
1317
|
+
"theirs. Never report it as the peer hiding from you."
|
|
1318
|
+
),
|
|
1319
|
+
aliases=("contact.statuses",),
|
|
1320
|
+
paginated=PageKind.LOCAL,
|
|
1321
|
+
columns=("user_id", "kind", "was_online"),
|
|
1322
|
+
headers=("User", "State", "Last seen"),
|
|
1323
|
+
example={"items": [{"user_id": 777123, "kind": "online"}], "has_more": False},
|
|
1324
|
+
example_args="contact status list --online-only",
|
|
1325
|
+
covers=("contacts-users.contacts-statuses", "dialogs.presence-watch"),
|
|
1326
|
+
)
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
class BirthdayListReq(Request):
|
|
1330
|
+
window: Annotated[
|
|
1331
|
+
int, opt("--window", metavar="DAYS", help="Days around today to include.", ge=0)
|
|
1332
|
+
] = 1
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
async def birthday_list(ctx: OpContext, req: BirthdayListReq) -> Page[Contact]:
|
|
1336
|
+
"""Contacts whose birthday is today or within a day.
|
|
1337
|
+
|
|
1338
|
+
Only the ones whose birthday privacy lets us see it. Official clients
|
|
1339
|
+
poll this every six to eight hours, which makes it a good `job` and a bad
|
|
1340
|
+
thing to call in a loop.
|
|
1341
|
+
"""
|
|
1342
|
+
from telethon.tl.functions import contacts as fn
|
|
1343
|
+
|
|
1344
|
+
result = await client_of(ctx)(fn.GetBirthdaysRequest())
|
|
1345
|
+
users = {int(u.id): u for u in getattr(result, "users", None) or []}
|
|
1346
|
+
today = datetime.now(timezone.utc)
|
|
1347
|
+
rows: list[Contact] = []
|
|
1348
|
+
for entry in getattr(result, "contacts", None) or []:
|
|
1349
|
+
user = users.get(int(entry.contact_id))
|
|
1350
|
+
row = contact_model(user) if user is not None else Contact(id=int(entry.contact_id))
|
|
1351
|
+
row.birthday = birthday_text(entry.birthday)
|
|
1352
|
+
row.age = birthday_age(entry.birthday, today=today)
|
|
1353
|
+
if req.window and not _within(entry.birthday, today, req.window):
|
|
1354
|
+
continue
|
|
1355
|
+
rows.append(row)
|
|
1356
|
+
limit, state = _window(ctx, "contact.birthday.list", PageKind.LOCAL, default=100)
|
|
1357
|
+
return _slice(rows, ctx, "contact.birthday.list", int(state.get("offset", 0) or 0), limit)
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
def _within(birthday: Any, today: datetime, window: int) -> bool:
|
|
1361
|
+
"""Is this birthday within `window` days of today, wrapping the year?"""
|
|
1362
|
+
month = int(getattr(birthday, "month", 0) or 0)
|
|
1363
|
+
day = int(getattr(birthday, "day", 0) or 0)
|
|
1364
|
+
if not month or not day:
|
|
1365
|
+
return False
|
|
1366
|
+
for offset in range(-window, window + 1):
|
|
1367
|
+
moment = today.fromordinal(today.toordinal() + offset)
|
|
1368
|
+
if (moment.month, moment.day) == (month, day):
|
|
1369
|
+
return True
|
|
1370
|
+
return False
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
SPEC_BIRTHDAY_LIST = OperationSpec(
|
|
1374
|
+
id="contact.birthday.list",
|
|
1375
|
+
request=BirthdayListReq,
|
|
1376
|
+
response=Page[Contact],
|
|
1377
|
+
impl=birthday_list,
|
|
1378
|
+
summary="Contacts whose birthday is today or within a day",
|
|
1379
|
+
description=(
|
|
1380
|
+
"Visible only per each contact's birthday privacy. Dismissing the "
|
|
1381
|
+
"chat-list bar is `chat promo list --dismiss BIRTHDAY_CONTACTS_TODAY`."
|
|
1382
|
+
),
|
|
1383
|
+
aliases=("contact.birthdays",),
|
|
1384
|
+
paginated=PageKind.LOCAL,
|
|
1385
|
+
columns=("id", "name", "birthday", "age"),
|
|
1386
|
+
headers=("Id", "Name", "Birthday", "Age"),
|
|
1387
|
+
example={
|
|
1388
|
+
"items": [{"id": 777123, "name": "Alice", "birthday": "1990-04-01", "age": 36}],
|
|
1389
|
+
"has_more": False,
|
|
1390
|
+
},
|
|
1391
|
+
example_args="contact birthday list",
|
|
1392
|
+
covers=("contact.birthdays", "contacts-users.contacts-birthdays"),
|
|
1393
|
+
)
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
class JoinedListReq(Request):
|
|
1397
|
+
since: Annotated[
|
|
1398
|
+
str | None,
|
|
1399
|
+
opt("--since", metavar="TS", kind="datetime", help="Only sign-ups after this date."),
|
|
1400
|
+
] = None
|
|
1401
|
+
notify: Annotated[
|
|
1402
|
+
str | None,
|
|
1403
|
+
choice("on", "off", help="Turn the 'contact joined' notification on or off."),
|
|
1404
|
+
] = None
|
|
1405
|
+
max_chats: Annotated[
|
|
1406
|
+
int, opt("--max-chats", metavar="N", help="Cap the dialog scan.", ge=1)
|
|
1407
|
+
] = 200
|
|
1408
|
+
|
|
1409
|
+
|
|
1410
|
+
async def joined_list(ctx: OpContext, req: JoinedListReq) -> Page[SignUp]:
|
|
1411
|
+
"""Contacts who joined Telegram, and the "X joined" notification switch.
|
|
1412
|
+
|
|
1413
|
+
There is no method that lists sign-ups: Telegram delivers each one as a
|
|
1414
|
+
`messageActionContactSignUp` service message in that person's chat, so
|
|
1415
|
+
this scans recent dialogs for them. The scan is capped and says so rather
|
|
1416
|
+
than pretending an empty answer is authoritative.
|
|
1417
|
+
"""
|
|
1418
|
+
from telethon.tl.functions import account as afn
|
|
1419
|
+
|
|
1420
|
+
notify: bool | None = None
|
|
1421
|
+
if req.notify is not None:
|
|
1422
|
+
if getattr(ctx, "dry_run", False):
|
|
1423
|
+
ctx.warn(f"--dry-run: the contact-joined notification would be turned {req.notify}")
|
|
1424
|
+
else:
|
|
1425
|
+
await client_of(ctx)(
|
|
1426
|
+
afn.SetContactSignUpNotificationRequest(silent=req.notify == "off")
|
|
1427
|
+
)
|
|
1428
|
+
notify = req.notify == "on"
|
|
1429
|
+
else:
|
|
1430
|
+
silent = await client_of(ctx)(afn.GetContactSignUpNotificationRequest())
|
|
1431
|
+
notify = not bool(silent)
|
|
1432
|
+
|
|
1433
|
+
floor = parse_dt(req.since) if req.since else None
|
|
1434
|
+
rows: list[SignUp] = []
|
|
1435
|
+
scanned = 0
|
|
1436
|
+
client = client_of(ctx)
|
|
1437
|
+
async for dialog in client.iter_dialogs(limit=req.max_chats):
|
|
1438
|
+
scanned += 1
|
|
1439
|
+
entity = getattr(dialog, "entity", None)
|
|
1440
|
+
if type(entity).__name__ != "User":
|
|
1441
|
+
continue
|
|
1442
|
+
async for message in client.iter_messages(entity, limit=20):
|
|
1443
|
+
if message is None:
|
|
1444
|
+
continue
|
|
1445
|
+
action = getattr(message, "action", None)
|
|
1446
|
+
if type(action).__name__ != "MessageActionContactSignUp":
|
|
1447
|
+
continue
|
|
1448
|
+
when = getattr(message, "date", None)
|
|
1449
|
+
if floor is not None and when is not None and when < floor:
|
|
1450
|
+
continue
|
|
1451
|
+
rows.append(
|
|
1452
|
+
SignUp(
|
|
1453
|
+
user_id=int(getattr(entity, "id", 0) or 0),
|
|
1454
|
+
name=display_name(entity),
|
|
1455
|
+
username=getattr(entity, "username", None),
|
|
1456
|
+
chat_id=int(getattr(entity, "id", 0) or 0),
|
|
1457
|
+
msg_id=int(getattr(message, "id", 0) or 0),
|
|
1458
|
+
date=fmt_dt(when),
|
|
1459
|
+
date_unix=to_unix(when),
|
|
1460
|
+
notify=notify,
|
|
1461
|
+
)
|
|
1462
|
+
)
|
|
1463
|
+
if scanned >= req.max_chats:
|
|
1464
|
+
ctx.warn(
|
|
1465
|
+
f"the scan stopped at {req.max_chats} chats; raise --max-chats to look further. "
|
|
1466
|
+
"An empty list here is not proof that nobody joined."
|
|
1467
|
+
)
|
|
1468
|
+
limit, state = _window(ctx, "contact.joined.list", PageKind.LOCAL, default=50)
|
|
1469
|
+
return _slice(rows, ctx, "contact.joined.list", int(state.get("offset", 0) or 0), limit)
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
SPEC_JOINED_LIST = OperationSpec(
|
|
1473
|
+
id="contact.joined.list",
|
|
1474
|
+
request=JoinedListReq,
|
|
1475
|
+
response=Page[SignUp],
|
|
1476
|
+
impl=joined_list,
|
|
1477
|
+
summary="Contacts who joined Telegram, and the 'X joined' notification switch",
|
|
1478
|
+
description=(
|
|
1479
|
+
"Telegram has no sign-up list: each one is a "
|
|
1480
|
+
"`messageActionContactSignUp` service message, so this scans recent "
|
|
1481
|
+
"chats for them and warns when the scan was capped."
|
|
1482
|
+
),
|
|
1483
|
+
paginated=PageKind.LOCAL,
|
|
1484
|
+
rate_class="bulk",
|
|
1485
|
+
timeout_s=300,
|
|
1486
|
+
tags=frozenset({"mutating-checked"}),
|
|
1487
|
+
columns=("user_id", "name", "date"),
|
|
1488
|
+
headers=("User", "Name", "Joined"),
|
|
1489
|
+
example={"items": [{"user_id": 777123, "name": "Alice", "notify": True}], "has_more": False},
|
|
1490
|
+
example_args="contact joined list",
|
|
1491
|
+
covers=("contacts-users.contacts-joined-notification", "dialogs.contact-signup-notify"),
|
|
1492
|
+
)
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
# ---------------------------------------------------------------------------
|
|
1496
|
+
# contact blocked list / set
|
|
1497
|
+
# ---------------------------------------------------------------------------
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
class BlockedListReq(Request):
|
|
1501
|
+
stories: Annotated[
|
|
1502
|
+
bool, opt("--stories", help="The story blocklist instead (my_stories_from).")
|
|
1503
|
+
] = False
|
|
1504
|
+
|
|
1505
|
+
|
|
1506
|
+
async def _blocked_page(ctx: OpContext, *, stories: bool, offset: int, limit: int) -> Any:
|
|
1507
|
+
from telethon.tl.functions import contacts as fn
|
|
1508
|
+
|
|
1509
|
+
return await client_of(ctx)(
|
|
1510
|
+
fn.GetBlockedRequest(offset=offset, limit=limit, my_stories_from=stories or None)
|
|
1511
|
+
)
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
async def blocked_list(ctx: OpContext, req: BlockedListReq) -> Page[BlockedPeer]:
|
|
1515
|
+
"""The blocklist, or the separate story blocklist.
|
|
1516
|
+
|
|
1517
|
+
The two lists are independent: someone on the story blocklist can still
|
|
1518
|
+
message you, and someone blocked outright is not automatically on it.
|
|
1519
|
+
"""
|
|
1520
|
+
limit, state = _window(ctx, "contact.blocked.list", PageKind.PARTICIPANTS, default=100)
|
|
1521
|
+
offset = int(state.get("offset", 0) or 0)
|
|
1522
|
+
kind = "stories" if req.stories else "main"
|
|
1523
|
+
|
|
1524
|
+
rows: list[BlockedPeer] = []
|
|
1525
|
+
total: int | None = None
|
|
1526
|
+
fetch_all = bool(getattr(ctx, "fetch_all", False))
|
|
1527
|
+
while True:
|
|
1528
|
+
result = await _blocked_page(
|
|
1529
|
+
ctx, stories=req.stories, offset=offset + len(rows), limit=limit
|
|
1530
|
+
)
|
|
1531
|
+
known = peers_by_id(getattr(result, "users", None), getattr(result, "chats", None))
|
|
1532
|
+
batch = list(getattr(result, "blocked", None) or [])
|
|
1533
|
+
total = getattr(result, "count", None)
|
|
1534
|
+
for item in batch:
|
|
1535
|
+
date = getattr(item, "date", None)
|
|
1536
|
+
rows.append(
|
|
1537
|
+
BlockedPeer(
|
|
1538
|
+
peer=peer_model(getattr(item, "peer_id", None), known),
|
|
1539
|
+
date=fmt_dt(date),
|
|
1540
|
+
date_unix=to_unix(date),
|
|
1541
|
+
kind=kind, # type: ignore[arg-type]
|
|
1542
|
+
)
|
|
1543
|
+
)
|
|
1544
|
+
if not fetch_all or not batch or (total is not None and offset + len(rows) >= total):
|
|
1545
|
+
break
|
|
1546
|
+
|
|
1547
|
+
has_more = total is not None and offset + len(rows) < int(total)
|
|
1548
|
+
return build_page(
|
|
1549
|
+
rows,
|
|
1550
|
+
op="contact.blocked.list",
|
|
1551
|
+
kind=PageKind.PARTICIPANTS,
|
|
1552
|
+
state={"offset": offset + len(rows)},
|
|
1553
|
+
account=ctx.account,
|
|
1554
|
+
has_more=has_more and not fetch_all,
|
|
1555
|
+
total=int(total) if total is not None else None,
|
|
1556
|
+
)
|
|
1557
|
+
|
|
1558
|
+
|
|
1559
|
+
SPEC_BLOCKED_LIST = OperationSpec(
|
|
1560
|
+
id="contact.blocked.list",
|
|
1561
|
+
request=BlockedListReq,
|
|
1562
|
+
response=Page[BlockedPeer],
|
|
1563
|
+
impl=blocked_list,
|
|
1564
|
+
summary="The blocklist, or the separate story blocklist",
|
|
1565
|
+
aliases=("user.blocked",),
|
|
1566
|
+
paginated=PageKind.PARTICIPANTS,
|
|
1567
|
+
columns=("peer.id", "peer.title", "date", "kind"),
|
|
1568
|
+
headers=("Id", "Peer", "Blocked", "List"),
|
|
1569
|
+
example={
|
|
1570
|
+
"items": [{"peer": {"id": 777123, "raw_id": 777123, "kind": "user"}, "kind": "main"}],
|
|
1571
|
+
"has_more": False,
|
|
1572
|
+
},
|
|
1573
|
+
example_args="contact blocked list",
|
|
1574
|
+
covers=("contacts-users.block-list", "contacts-users.block-stories-list"),
|
|
1575
|
+
)
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
class BlockedSetReq(Request):
|
|
1579
|
+
user: Annotated[
|
|
1580
|
+
list[PeerRef],
|
|
1581
|
+
arg(0, metavar="PEER", variadic=True, kind="peer", help="The complete new list."),
|
|
1582
|
+
] = []
|
|
1583
|
+
stories: Annotated[bool, opt("--stories", help="Operate on the story blocklist.")] = False
|
|
1584
|
+
from_file: Annotated[
|
|
1585
|
+
str | None,
|
|
1586
|
+
opt("--from-file", metavar="PATH", kind="path", help="Read the peer list from a file."),
|
|
1587
|
+
] = None
|
|
1588
|
+
|
|
1589
|
+
|
|
1590
|
+
async def blocked_set(ctx: OpContext, req: BlockedSetReq) -> BlockedSet:
|
|
1591
|
+
"""Replace the whole blocklist atomically.
|
|
1592
|
+
|
|
1593
|
+
DESTRUCTIVE in a way the method name hides: `contacts.setBlocked`
|
|
1594
|
+
*replaces* the list, so everyone not named is unblocked. The current list
|
|
1595
|
+
is read first and the diff is part of the answer, because "I unblocked
|
|
1596
|
+
forty people" should not be something you discover later.
|
|
1597
|
+
"""
|
|
1598
|
+
from telethon.tl.functions import contacts as fn
|
|
1599
|
+
|
|
1600
|
+
refs = list(req.user)
|
|
1601
|
+
if req.from_file:
|
|
1602
|
+
for line in _read_file(req.from_file, "from-file").splitlines():
|
|
1603
|
+
text = line.strip()
|
|
1604
|
+
if text and not text.startswith("#"):
|
|
1605
|
+
from tlgr.models.peer import parse_peer_ref
|
|
1606
|
+
|
|
1607
|
+
refs.append(parse_peer_ref(text))
|
|
1608
|
+
if not refs:
|
|
1609
|
+
raise UsageError(
|
|
1610
|
+
"give the complete new blocklist; setBlocked replaces it, so an empty "
|
|
1611
|
+
"list would unblock everyone",
|
|
1612
|
+
field="user",
|
|
1613
|
+
)
|
|
1614
|
+
|
|
1615
|
+
peers = [await _send.resolve(ctx, ref) for ref in refs]
|
|
1616
|
+
wanted = {_send.peer_id_of(peer) for peer in peers}
|
|
1617
|
+
|
|
1618
|
+
current = await _blocked_page(ctx, stories=req.stories, offset=0, limit=1000)
|
|
1619
|
+
known = peers_by_id(getattr(current, "users", None), getattr(current, "chats", None))
|
|
1620
|
+
before = {
|
|
1621
|
+
peer_model(getattr(item, "peer_id", None), known).id
|
|
1622
|
+
for item in getattr(current, "blocked", None) or []
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
await client_of(ctx)(
|
|
1626
|
+
fn.SetBlockedRequest(id=peers, limit=len(peers), my_stories_from=req.stories or None)
|
|
1627
|
+
)
|
|
1628
|
+
ctx.emit("blocked_set", {"count": len(peers)})
|
|
1629
|
+
return BlockedSet(
|
|
1630
|
+
count=len(peers),
|
|
1631
|
+
blocked=sorted(wanted - before),
|
|
1632
|
+
unblocked=sorted(before - wanted),
|
|
1633
|
+
kind="stories" if req.stories else "main",
|
|
1634
|
+
applied=True,
|
|
1635
|
+
)
|
|
1636
|
+
|
|
1637
|
+
|
|
1638
|
+
SPEC_BLOCKED_SET = OperationSpec(
|
|
1639
|
+
id="contact.blocked.set",
|
|
1640
|
+
request=BlockedSetReq,
|
|
1641
|
+
response=BlockedSet,
|
|
1642
|
+
impl=blocked_set,
|
|
1643
|
+
summary="Replace the whole blocklist atomically",
|
|
1644
|
+
description=(
|
|
1645
|
+
"`contacts.setBlocked` REPLACES the list: everyone not passed is "
|
|
1646
|
+
"unblocked. The reply is the diff against what was there before."
|
|
1647
|
+
),
|
|
1648
|
+
mutating=True,
|
|
1649
|
+
destructive=True,
|
|
1650
|
+
rate_class="bulk",
|
|
1651
|
+
columns=("count", "blocked", "unblocked"),
|
|
1652
|
+
example={"count": 1, "blocked": [777123], "unblocked": [], "applied": True},
|
|
1653
|
+
example_args="contact blocked set @spammer",
|
|
1654
|
+
covers=("dialogs.blocked-set-bulk",),
|
|
1655
|
+
)
|
|
1656
|
+
|
|
1657
|
+
|
|
1658
|
+
# ---------------------------------------------------------------------------
|
|
1659
|
+
# contact close-friends list / set
|
|
1660
|
+
# ---------------------------------------------------------------------------
|
|
1661
|
+
|
|
1662
|
+
|
|
1663
|
+
class CloseFriendsListReq(Request):
|
|
1664
|
+
pass
|
|
1665
|
+
|
|
1666
|
+
|
|
1667
|
+
async def close_friends_list(ctx: OpContext, req: CloseFriendsListReq) -> Page[Contact]:
|
|
1668
|
+
"""List close friends.
|
|
1669
|
+
|
|
1670
|
+
There is no getter: a close friend is a contact carrying `close_friend`,
|
|
1671
|
+
so the contact list is fetched and filtered.
|
|
1672
|
+
"""
|
|
1673
|
+
_, users, _ = await load_contacts(ctx)
|
|
1674
|
+
rows = [contact_model(u) for u in users if getattr(u, "close_friend", False)]
|
|
1675
|
+
limit, state = _window(ctx, "contact.close-friends.list", PageKind.LOCAL, default=30)
|
|
1676
|
+
return _slice(rows, ctx, "contact.close-friends.list", int(state.get("offset", 0) or 0), limit)
|
|
1677
|
+
|
|
1678
|
+
|
|
1679
|
+
SPEC_CLOSE_FRIENDS_LIST = OperationSpec(
|
|
1680
|
+
id="contact.close-friends.list",
|
|
1681
|
+
request=CloseFriendsListReq,
|
|
1682
|
+
response=Page[Contact],
|
|
1683
|
+
impl=close_friends_list,
|
|
1684
|
+
summary="List your close friends",
|
|
1685
|
+
description="No dedicated getter exists; `user.close_friend` on the contact list is it.",
|
|
1686
|
+
aliases=("story.close-friends.list",),
|
|
1687
|
+
paginated=PageKind.LOCAL,
|
|
1688
|
+
columns=("id", "name", "username"),
|
|
1689
|
+
headers=("Id", "Name", "Username"),
|
|
1690
|
+
example={"items": [dict(_EXAMPLE_CONTACT, close_friend=True)], "has_more": False},
|
|
1691
|
+
example_args="contact close-friends list",
|
|
1692
|
+
covers=("stories.close-friends-list",),
|
|
1693
|
+
)
|
|
1694
|
+
|
|
1695
|
+
|
|
1696
|
+
class CloseFriendsSetReq(Request):
|
|
1697
|
+
user: Annotated[
|
|
1698
|
+
list[PeerRef],
|
|
1699
|
+
arg(0, metavar="USER", variadic=True, kind="user", help="The complete new list."),
|
|
1700
|
+
] = []
|
|
1701
|
+
add: Annotated[
|
|
1702
|
+
list[PeerRef], opt("--add", metavar="USER", kind="user", help="Read-modify-write add.")
|
|
1703
|
+
] = []
|
|
1704
|
+
remove: Annotated[
|
|
1705
|
+
list[PeerRef],
|
|
1706
|
+
opt("--remove", metavar="USER", kind="user", help="Read-modify-write remove."),
|
|
1707
|
+
] = []
|
|
1708
|
+
|
|
1709
|
+
|
|
1710
|
+
async def close_friends_set(ctx: OpContext, req: CloseFriendsSetReq) -> CloseFriends:
|
|
1711
|
+
"""Read or edit the close-friends list.
|
|
1712
|
+
|
|
1713
|
+
`contacts.editCloseFriends` replaces the whole list, so `--add`/`--remove`
|
|
1714
|
+
read the current contact list first and send the union. Only contacts may
|
|
1715
|
+
be close friends; the server refuses anyone else.
|
|
1716
|
+
"""
|
|
1717
|
+
from telethon.tl.functions import contacts as fn
|
|
1718
|
+
|
|
1719
|
+
_, users, _ = await load_contacts(ctx)
|
|
1720
|
+
by_id = {int(u.id): u for u in users}
|
|
1721
|
+
current = [int(u.id) for u in users if getattr(u, "close_friend", False)]
|
|
1722
|
+
|
|
1723
|
+
if req.user and (req.add or req.remove):
|
|
1724
|
+
raise UsageError("give either a complete list or --add/--remove, not both", field="user")
|
|
1725
|
+
|
|
1726
|
+
if req.user:
|
|
1727
|
+
wanted = [int(getattr(await input_user(ctx, ref), "user_id", 0) or 0) for ref in req.user]
|
|
1728
|
+
else:
|
|
1729
|
+
wanted = list(current)
|
|
1730
|
+
for ref in req.add:
|
|
1731
|
+
uid = int(getattr(await input_user(ctx, ref), "user_id", 0) or 0)
|
|
1732
|
+
if uid and uid not in wanted:
|
|
1733
|
+
wanted.append(uid)
|
|
1734
|
+
for ref in req.remove:
|
|
1735
|
+
uid = int(getattr(await input_user(ctx, ref), "user_id", 0) or 0)
|
|
1736
|
+
wanted = [i for i in wanted if i != uid]
|
|
1737
|
+
|
|
1738
|
+
strangers = [uid for uid in wanted if uid not in by_id]
|
|
1739
|
+
if strangers:
|
|
1740
|
+
raise UsageError(
|
|
1741
|
+
f"only contacts can be close friends; {strangers} are not in the contact list",
|
|
1742
|
+
field="user",
|
|
1743
|
+
)
|
|
1744
|
+
if sorted(wanted) == sorted(current):
|
|
1745
|
+
mark_already(ctx)
|
|
1746
|
+
return CloseFriends(
|
|
1747
|
+
user_ids=current,
|
|
1748
|
+
count=len(current),
|
|
1749
|
+
contacts=[contact_model(by_id[i]) for i in current if i in by_id],
|
|
1750
|
+
)
|
|
1751
|
+
|
|
1752
|
+
await client_of(ctx)(fn.EditCloseFriendsRequest(id=wanted))
|
|
1753
|
+
ctx.emit("close_friends_set", {"count": len(wanted)})
|
|
1754
|
+
return CloseFriends(
|
|
1755
|
+
user_ids=wanted,
|
|
1756
|
+
count=len(wanted),
|
|
1757
|
+
contacts=[contact_model(by_id[i]) for i in wanted if i in by_id],
|
|
1758
|
+
)
|
|
1759
|
+
|
|
1760
|
+
|
|
1761
|
+
SPEC_CLOSE_FRIENDS_SET = OperationSpec(
|
|
1762
|
+
id="contact.close-friends.set",
|
|
1763
|
+
request=CloseFriendsSetReq,
|
|
1764
|
+
response=CloseFriends,
|
|
1765
|
+
impl=close_friends_set,
|
|
1766
|
+
summary="Read or edit the close-friends list",
|
|
1767
|
+
description=(
|
|
1768
|
+
"`contacts.editCloseFriends` replaces the list, so --add/--remove are "
|
|
1769
|
+
"a read-modify-write over the current contact list."
|
|
1770
|
+
),
|
|
1771
|
+
aliases=("privacy.close-friends.set", "story.close-friends.set"),
|
|
1772
|
+
mutating=True,
|
|
1773
|
+
idempotent=True,
|
|
1774
|
+
rate_class="bulk",
|
|
1775
|
+
columns=("count", "user_ids"),
|
|
1776
|
+
example={"user_ids": [777123], "count": 1},
|
|
1777
|
+
example_args="contact close-friends set @alice",
|
|
1778
|
+
covers=("contacts-users.close-friends-set", "stories.close-friends-set"),
|
|
1779
|
+
)
|
|
1780
|
+
|
|
1781
|
+
|
|
1782
|
+
# ---------------------------------------------------------------------------
|
|
1783
|
+
# contact top list / set
|
|
1784
|
+
# ---------------------------------------------------------------------------
|
|
1785
|
+
|
|
1786
|
+
|
|
1787
|
+
class TopListReq(Request):
|
|
1788
|
+
category: Annotated[
|
|
1789
|
+
list[str],
|
|
1790
|
+
opt(
|
|
1791
|
+
"--category",
|
|
1792
|
+
metavar="NAME",
|
|
1793
|
+
help=("Rating category; repeatable. " + ", ".join(TOP_CATEGORIES)),
|
|
1794
|
+
),
|
|
1795
|
+
] = []
|
|
1796
|
+
|
|
1797
|
+
|
|
1798
|
+
async def top_list(ctx: OpContext, req: TopListReq) -> Page[TopPeer]:
|
|
1799
|
+
"""Frequent contacts / top peers by category.
|
|
1800
|
+
|
|
1801
|
+
`topPeersDisabled` is a real answer, not an empty one: the user turned
|
|
1802
|
+
the feature off, and the ratings are gone server-side. Reporting it as
|
|
1803
|
+
"no frequent contacts" would suggest there is something to look at.
|
|
1804
|
+
"""
|
|
1805
|
+
from telethon.tl.functions import contacts as fn
|
|
1806
|
+
|
|
1807
|
+
wanted = list(req.category or ["correspondents"])
|
|
1808
|
+
unknown = [name for name in wanted if name not in TOP_CATEGORIES]
|
|
1809
|
+
if unknown:
|
|
1810
|
+
raise UsageError(
|
|
1811
|
+
f"unknown --category {unknown}; pick from {', '.join(TOP_CATEGORIES)}",
|
|
1812
|
+
field="category",
|
|
1813
|
+
)
|
|
1814
|
+
limit, state = _window(ctx, "contact.top.list", PageKind.PARTICIPANTS, default=50)
|
|
1815
|
+
offset = int(state.get("offset", 0) or 0)
|
|
1816
|
+
|
|
1817
|
+
flags = {TOP_CATEGORIES[name]: True for name in wanted}
|
|
1818
|
+
result = await client_of(ctx)(
|
|
1819
|
+
fn.GetTopPeersRequest(offset=offset, limit=limit, hash=0, **flags)
|
|
1820
|
+
)
|
|
1821
|
+
if type(result).__name__ == "TopPeersDisabled":
|
|
1822
|
+
reason = (
|
|
1823
|
+
"frequent-contact collection is turned off for this account, so there are "
|
|
1824
|
+
"no ratings to report; turn it back on with `tlgr contact top set on`"
|
|
1825
|
+
)
|
|
1826
|
+
ctx.warn(reason)
|
|
1827
|
+
mark = getattr(ctx, "mark_indeterminate", None)
|
|
1828
|
+
if callable(mark):
|
|
1829
|
+
mark(reason)
|
|
1830
|
+
return Page(items=[], has_more=False, total=0)
|
|
1831
|
+
known = peers_by_id(getattr(result, "users", None), getattr(result, "chats", None))
|
|
1832
|
+
names = {value: key for key, value in _TOP_TYPES.items()}
|
|
1833
|
+
rows: list[TopPeer] = []
|
|
1834
|
+
for group in getattr(result, "categories", None) or []:
|
|
1835
|
+
label = names.get(type(getattr(group, "category", None)).__name__, "correspondents")
|
|
1836
|
+
for entry in getattr(group, "peers", None) or []:
|
|
1837
|
+
rows.append(
|
|
1838
|
+
TopPeer(
|
|
1839
|
+
peer=peer_model(getattr(entry, "peer", None), known),
|
|
1840
|
+
category=label,
|
|
1841
|
+
rating=float(getattr(entry, "rating", 0.0) or 0.0),
|
|
1842
|
+
)
|
|
1843
|
+
)
|
|
1844
|
+
return build_page(
|
|
1845
|
+
rows,
|
|
1846
|
+
op="contact.top.list",
|
|
1847
|
+
kind=PageKind.PARTICIPANTS,
|
|
1848
|
+
state={"offset": offset + len(rows)},
|
|
1849
|
+
account=ctx.account,
|
|
1850
|
+
limit=limit,
|
|
1851
|
+
)
|
|
1852
|
+
|
|
1853
|
+
|
|
1854
|
+
SPEC_TOP_LIST = OperationSpec(
|
|
1855
|
+
id="contact.top.list",
|
|
1856
|
+
request=TopListReq,
|
|
1857
|
+
response=Page[TopPeer],
|
|
1858
|
+
impl=top_list,
|
|
1859
|
+
summary="Frequent contacts / top peers by category",
|
|
1860
|
+
description=(
|
|
1861
|
+
"Ratings decay with the server's `rating_e_decay`. A disabled "
|
|
1862
|
+
"feature answers exit 13, not an empty list: nothing was measured."
|
|
1863
|
+
),
|
|
1864
|
+
paginated=PageKind.PARTICIPANTS,
|
|
1865
|
+
columns=("category", "peer.title", "rating"),
|
|
1866
|
+
headers=("Category", "Peer", "Rating"),
|
|
1867
|
+
example={
|
|
1868
|
+
"items": [
|
|
1869
|
+
{
|
|
1870
|
+
"peer": {"id": 777123, "raw_id": 777123, "kind": "user", "title": "Alice"},
|
|
1871
|
+
"category": "correspondents",
|
|
1872
|
+
"rating": 12.5,
|
|
1873
|
+
}
|
|
1874
|
+
],
|
|
1875
|
+
"has_more": False,
|
|
1876
|
+
},
|
|
1877
|
+
example_args="contact top list --category correspondents",
|
|
1878
|
+
covers=("calls.top-callers", "contacts-users.top-peers-get"),
|
|
1879
|
+
)
|
|
1880
|
+
|
|
1881
|
+
|
|
1882
|
+
class TopSetReq(Request):
|
|
1883
|
+
state: Annotated[str | None, arg(0, metavar="STATE", required=False, help="on | off")] = None
|
|
1884
|
+
reset: Annotated[
|
|
1885
|
+
PeerRef | None,
|
|
1886
|
+
opt("--reset", metavar="PEER", kind="peer", help="Zero this peer's rating instead."),
|
|
1887
|
+
] = None
|
|
1888
|
+
category: Annotated[str, opt("--category", metavar="NAME", help="Category for --reset.")] = (
|
|
1889
|
+
"correspondents"
|
|
1890
|
+
)
|
|
1891
|
+
|
|
1892
|
+
|
|
1893
|
+
async def top_set(ctx: OpContext, req: TopSetReq) -> TopPeerState:
|
|
1894
|
+
"""Enable/disable frequent-contact collection, or reset one peer's rating.
|
|
1895
|
+
|
|
1896
|
+
Turning it off wipes the ratings server-side, so it is destructive even
|
|
1897
|
+
though it looks like a switch.
|
|
1898
|
+
"""
|
|
1899
|
+
from telethon.tl import types
|
|
1900
|
+
from telethon.tl.functions import contacts as fn
|
|
1901
|
+
|
|
1902
|
+
if req.reset is not None:
|
|
1903
|
+
constructor = _TOP_TYPES.get(req.category)
|
|
1904
|
+
if constructor is None:
|
|
1905
|
+
raise UsageError(
|
|
1906
|
+
f"unknown --category {req.category!r}; pick from {', '.join(_TOP_TYPES)}",
|
|
1907
|
+
field="category",
|
|
1908
|
+
)
|
|
1909
|
+
peer = await _send.resolve(ctx, req.reset)
|
|
1910
|
+
await client_of(ctx)(
|
|
1911
|
+
fn.ResetTopPeerRatingRequest(category=getattr(types, constructor)(), peer=peer)
|
|
1912
|
+
)
|
|
1913
|
+
marked = _send.peer_id_of(peer)
|
|
1914
|
+
ctx.emit("top_peer_reset", {"peer_id": marked})
|
|
1915
|
+
return TopPeerState(reset_peer=marked, category=req.category)
|
|
1916
|
+
|
|
1917
|
+
if req.state not in ("on", "off"):
|
|
1918
|
+
raise UsageError("say `on` or `off`, or pass --reset <peer>", field="state")
|
|
1919
|
+
enabled = req.state == "on"
|
|
1920
|
+
await client_of(ctx)(fn.ToggleTopPeersRequest(enabled=enabled))
|
|
1921
|
+
ctx.emit("top_peers_toggle", {"enabled": enabled})
|
|
1922
|
+
return TopPeerState(enabled=enabled, disabled_by_user=not enabled)
|
|
1923
|
+
|
|
1924
|
+
|
|
1925
|
+
SPEC_TOP_SET = OperationSpec(
|
|
1926
|
+
id="contact.top.set",
|
|
1927
|
+
request=TopSetReq,
|
|
1928
|
+
response=TopPeerState,
|
|
1929
|
+
impl=top_set,
|
|
1930
|
+
summary="Enable/disable frequent-contact collection, or reset one peer's rating",
|
|
1931
|
+
description="Turning it off also wipes the ratings server-side, which is why it needs --yes.",
|
|
1932
|
+
mutating=True,
|
|
1933
|
+
destructive=True,
|
|
1934
|
+
columns=("enabled", "reset_peer", "category"),
|
|
1935
|
+
example={"enabled": True},
|
|
1936
|
+
example_args="contact top set on",
|
|
1937
|
+
covers=(
|
|
1938
|
+
"calls.reset-top-caller",
|
|
1939
|
+
"contacts-users.top-peers-reset",
|
|
1940
|
+
"contacts-users.top-peers-toggle",
|
|
1941
|
+
"dialogs.top-peers-toggle",
|
|
1942
|
+
),
|
|
1943
|
+
)
|
|
1944
|
+
|
|
1945
|
+
|
|
1946
|
+
# ---------------------------------------------------------------------------
|
|
1947
|
+
# contact share / share-phone
|
|
1948
|
+
# ---------------------------------------------------------------------------
|
|
1949
|
+
|
|
1950
|
+
|
|
1951
|
+
class ShareReq(Request):
|
|
1952
|
+
user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Whose card to send.")]
|
|
1953
|
+
to: Annotated[
|
|
1954
|
+
PeerRef | None, opt("--to", metavar="CHAT", kind="peer", help="Destination chat.")
|
|
1955
|
+
] = None
|
|
1956
|
+
|
|
1957
|
+
|
|
1958
|
+
async def share(ctx: OpContext, req: ShareReq) -> ContactShared:
|
|
1959
|
+
"""Send someone's contact card into a chat.
|
|
1960
|
+
|
|
1961
|
+
The phone may be empty when their privacy hides it, which yields a card
|
|
1962
|
+
without a number rather than an error — that is what the GUI sends too.
|
|
1963
|
+
"""
|
|
1964
|
+
from telethon.helpers import generate_random_long
|
|
1965
|
+
from telethon.tl import types
|
|
1966
|
+
from telethon.tl.functions import messages as mfn
|
|
1967
|
+
|
|
1968
|
+
if req.to is None:
|
|
1969
|
+
raise UsageError("--to names the chat to send the card into", field="to")
|
|
1970
|
+
target = await input_user(ctx, req.user)
|
|
1971
|
+
known = await fetch_user(ctx, target)
|
|
1972
|
+
destination = await _send.resolve(ctx, req.to)
|
|
1973
|
+
|
|
1974
|
+
updates = await client_of(ctx)(
|
|
1975
|
+
mfn.SendMediaRequest(
|
|
1976
|
+
peer=destination,
|
|
1977
|
+
media=types.InputMediaContact(
|
|
1978
|
+
phone_number=getattr(known, "phone", None) or "",
|
|
1979
|
+
first_name=getattr(known, "first_name", None) or "",
|
|
1980
|
+
last_name=getattr(known, "last_name", None) or "",
|
|
1981
|
+
vcard="",
|
|
1982
|
+
),
|
|
1983
|
+
message="",
|
|
1984
|
+
random_id=generate_random_long(),
|
|
1985
|
+
)
|
|
1986
|
+
)
|
|
1987
|
+
chat_id = _send.peer_id_of(destination)
|
|
1988
|
+
message = _send.message_from_updates(updates, chat_id=chat_id)
|
|
1989
|
+
ctx.emit("contact_share", {"chat_id": chat_id, "user_id": int(known.id)})
|
|
1990
|
+
return ContactShared(chat_id=chat_id, msg_id=message.id, contact=contact_model(known))
|
|
1991
|
+
|
|
1992
|
+
|
|
1993
|
+
SPEC_SHARE = OperationSpec(
|
|
1994
|
+
id="contact.share",
|
|
1995
|
+
request=ShareReq,
|
|
1996
|
+
response=ContactShared,
|
|
1997
|
+
impl=share,
|
|
1998
|
+
summary="Send someone's contact card into a chat",
|
|
1999
|
+
mutating=True,
|
|
2000
|
+
rate_class="send",
|
|
2001
|
+
columns=("chat_id", "msg_id"),
|
|
2002
|
+
example={"chat_id": 777123, "msg_id": 4211},
|
|
2003
|
+
example_args="contact share @alice --to @bobby",
|
|
2004
|
+
covers=("contacts-users.user-share-contact-card",),
|
|
2005
|
+
tags=frozenset({"visible-to-others"}),
|
|
2006
|
+
)
|
|
2007
|
+
|
|
2008
|
+
|
|
2009
|
+
class SharePhoneReq(Request):
|
|
2010
|
+
user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Who to share it with.")]
|
|
2011
|
+
|
|
2012
|
+
|
|
2013
|
+
async def share_phone(ctx: OpContext, req: SharePhoneReq) -> PhoneShared:
|
|
2014
|
+
"""Share my phone number with someone who added me as a contact.
|
|
2015
|
+
|
|
2016
|
+
Only valid while `peerSettings.share_contact` is set — check
|
|
2017
|
+
`chat action-bar` first — and irreversible: a number cannot be un-shared.
|
|
2018
|
+
"""
|
|
2019
|
+
from telethon.tl.functions import contacts as fn
|
|
2020
|
+
|
|
2021
|
+
target = await input_user(ctx, req.user)
|
|
2022
|
+
await client_of(ctx)(fn.AcceptContactRequest(id=target))
|
|
2023
|
+
user_id = int(getattr(target, "user_id", 0) or 0)
|
|
2024
|
+
ctx.emit("contact_share_phone", {"user_id": user_id})
|
|
2025
|
+
return PhoneShared(user_id=user_id, shared=True)
|
|
2026
|
+
|
|
2027
|
+
|
|
2028
|
+
SPEC_SHARE_PHONE = OperationSpec(
|
|
2029
|
+
id="contact.share-phone",
|
|
2030
|
+
request=SharePhoneReq,
|
|
2031
|
+
response=PhoneShared,
|
|
2032
|
+
impl=share_phone,
|
|
2033
|
+
summary="Share my phone number with someone who added me as a contact",
|
|
2034
|
+
description="Irreversible: your number cannot be un-shared once they have it.",
|
|
2035
|
+
mutating=True,
|
|
2036
|
+
destructive=True,
|
|
2037
|
+
columns=("user_id", "shared"),
|
|
2038
|
+
example={"user_id": 777123, "shared": True},
|
|
2039
|
+
example_args="contact share-phone @alice",
|
|
2040
|
+
covers=("contacts-users.contact-accept-share-phone", "dialogs.actionbar-share-phone"),
|
|
2041
|
+
tags=frozenset({"visible-to-others"}),
|
|
2042
|
+
)
|
|
2043
|
+
|
|
2044
|
+
|
|
2045
|
+
# ---------------------------------------------------------------------------
|
|
2046
|
+
# contact saved list / import / sync
|
|
2047
|
+
# ---------------------------------------------------------------------------
|
|
2048
|
+
|
|
2049
|
+
|
|
2050
|
+
async def _saved_contacts(ctx: OpContext) -> list[SavedPhoneContact]:
|
|
2051
|
+
"""`contacts.getSaved`, inside a takeout session when the server insists.
|
|
2052
|
+
|
|
2053
|
+
TAKEOUT_REQUIRED is not a failure: it is Telegram saying "this is a data
|
|
2054
|
+
export, open one". `TAKEOUT_INIT_DELAY_X` is a wait, and it surfaces as
|
|
2055
|
+
a rate limit rather than an error.
|
|
2056
|
+
"""
|
|
2057
|
+
from telethon.tl.functions import InvokeWithTakeoutRequest
|
|
2058
|
+
from telethon.tl.functions import account as afn
|
|
2059
|
+
from telethon.tl.functions import contacts as fn
|
|
2060
|
+
|
|
2061
|
+
client = client_of(ctx)
|
|
2062
|
+
query = fn.GetSavedRequest()
|
|
2063
|
+
try:
|
|
2064
|
+
rows = await client(query)
|
|
2065
|
+
except Exception as exc:
|
|
2066
|
+
if "TAKEOUT" not in type(exc).__name__.upper() and "TAKEOUT" not in str(exc).upper():
|
|
2067
|
+
raise
|
|
2068
|
+
session = await client(afn.InitTakeoutSessionRequest(contacts=True))
|
|
2069
|
+
rows = await client(
|
|
2070
|
+
InvokeWithTakeoutRequest(takeout_id=int(getattr(session, "id", 0) or 0), query=query)
|
|
2071
|
+
)
|
|
2072
|
+
out: list[SavedPhoneContact] = []
|
|
2073
|
+
for entry in list(rows or []):
|
|
2074
|
+
date = getattr(entry, "date", None)
|
|
2075
|
+
out.append(
|
|
2076
|
+
SavedPhoneContact(
|
|
2077
|
+
phone=e164(getattr(entry, "phone", "") or ""),
|
|
2078
|
+
first_name=getattr(entry, "first_name", "") or "",
|
|
2079
|
+
last_name=getattr(entry, "last_name", "") or "",
|
|
2080
|
+
date=fmt_dt(date),
|
|
2081
|
+
date_unix=to_unix(date),
|
|
2082
|
+
)
|
|
2083
|
+
)
|
|
2084
|
+
return out
|
|
2085
|
+
|
|
2086
|
+
|
|
2087
|
+
class SavedListReq(Request):
|
|
2088
|
+
invite_text: Annotated[
|
|
2089
|
+
bool, opt("--invite-text", help="Also print the localized invite copy.")
|
|
2090
|
+
] = False
|
|
2091
|
+
|
|
2092
|
+
|
|
2093
|
+
async def saved_list(ctx: OpContext, req: SavedListReq) -> Page[SavedPhoneContact]:
|
|
2094
|
+
"""Every phone number this account ever uploaded, Telegram account or not.
|
|
2095
|
+
|
|
2096
|
+
A CLI cannot send an SMS, so `--invite-text` prints the copy Telegram
|
|
2097
|
+
would have used and leaves the sending to a human.
|
|
2098
|
+
"""
|
|
2099
|
+
from telethon.tl.functions import help as hfn
|
|
2100
|
+
|
|
2101
|
+
rows = await _saved_contacts(ctx)
|
|
2102
|
+
_, users, _ = await load_contacts(ctx)
|
|
2103
|
+
known = {e164(getattr(u, "phone", "") or "") for u in users if getattr(u, "phone", None)}
|
|
2104
|
+
for row in rows:
|
|
2105
|
+
row.has_account = row.phone in known if known else None
|
|
2106
|
+
if req.invite_text and rows:
|
|
2107
|
+
invite = await client_of(ctx)(hfn.GetInviteTextRequest())
|
|
2108
|
+
rows[0].invite_text = str(getattr(invite, "message", "") or "")
|
|
2109
|
+
|
|
2110
|
+
limit, state = _window(ctx, "contact.saved.list", PageKind.LOCAL, default=100)
|
|
2111
|
+
return _slice(rows, ctx, "contact.saved.list", int(state.get("offset", 0) or 0), limit)
|
|
2112
|
+
|
|
2113
|
+
|
|
2114
|
+
SPEC_SAVED_LIST = OperationSpec(
|
|
2115
|
+
id="contact.saved.list",
|
|
2116
|
+
request=SavedListReq,
|
|
2117
|
+
response=Page[SavedPhoneContact],
|
|
2118
|
+
impl=saved_list,
|
|
2119
|
+
summary="Every phone number this account ever uploaded, including non-Telegram ones",
|
|
2120
|
+
description=(
|
|
2121
|
+
"Needs a takeout session, which this opens automatically. "
|
|
2122
|
+
"`has_account` is computed against the contact list, so it is null "
|
|
2123
|
+
"when the contact list could not be read."
|
|
2124
|
+
),
|
|
2125
|
+
paginated=PageKind.LOCAL,
|
|
2126
|
+
rate_class="bulk",
|
|
2127
|
+
timeout_s=300,
|
|
2128
|
+
columns=("phone", "first_name", "last_name", "has_account"),
|
|
2129
|
+
headers=("Phone", "First", "Last", "On Telegram"),
|
|
2130
|
+
example={
|
|
2131
|
+
"items": [{"phone": "+15550001111", "first_name": "Alice", "has_account": True}],
|
|
2132
|
+
"has_more": False,
|
|
2133
|
+
},
|
|
2134
|
+
example_args="contact saved list",
|
|
2135
|
+
covers=("contacts-users.contacts-saved-phonebook", "contacts-users.user-invite-friends"),
|
|
2136
|
+
)
|
|
2137
|
+
|
|
2138
|
+
|
|
2139
|
+
class ImportReq(Request):
|
|
2140
|
+
file: Annotated[str, arg(0, metavar="FILE", kind="path", help="file.vcf | file.csv")]
|
|
2141
|
+
batch_size: Annotated[
|
|
2142
|
+
int, opt("--batch-size", metavar="N", help="Contacts per call.", ge=1, le=500)
|
|
2143
|
+
] = IMPORT_BATCH
|
|
2144
|
+
|
|
2145
|
+
|
|
2146
|
+
async def contact_import(ctx: OpContext, req: ImportReq) -> ContactImport:
|
|
2147
|
+
"""Bulk-import a phonebook from vCard or CSV.
|
|
2148
|
+
|
|
2149
|
+
`retry_contacts` is not an error list: the server is asking for those
|
|
2150
|
+
numbers again later, and a caller that drops them loses contacts
|
|
2151
|
+
silently. They come back in `retry` so a second pass can send them.
|
|
2152
|
+
"""
|
|
2153
|
+
from telethon.tl import types
|
|
2154
|
+
from telethon.tl.functions import contacts as fn
|
|
2155
|
+
|
|
2156
|
+
entries = parse_phonebook(_read_file(req.file, "file"))
|
|
2157
|
+
if not entries:
|
|
2158
|
+
raise UsageError(f"{req.file} has no usable phone numbers in it", field="file")
|
|
2159
|
+
|
|
2160
|
+
imported: list[ImportedPhone] = []
|
|
2161
|
+
retry: list[ImportedPhone] = []
|
|
2162
|
+
popular: list[ImportedPhone] = []
|
|
2163
|
+
batches = 0
|
|
2164
|
+
client = client_of(ctx)
|
|
2165
|
+
for start in range(0, len(entries), req.batch_size):
|
|
2166
|
+
chunk = entries[start : start + req.batch_size]
|
|
2167
|
+
batches += 1
|
|
2168
|
+
result = await client(
|
|
2169
|
+
fn.ImportContactsRequest(
|
|
2170
|
+
[
|
|
2171
|
+
types.InputPhoneContact(
|
|
2172
|
+
client_id=start + index,
|
|
2173
|
+
phone=entry.phone,
|
|
2174
|
+
first_name=entry.first_name or entry.phone,
|
|
2175
|
+
last_name=entry.last_name,
|
|
2176
|
+
)
|
|
2177
|
+
for index, entry in enumerate(chunk)
|
|
2178
|
+
]
|
|
2179
|
+
)
|
|
2180
|
+
)
|
|
2181
|
+
by_client = {start + index: entry for index, entry in enumerate(chunk)}
|
|
2182
|
+
for item in getattr(result, "imported", None) or []:
|
|
2183
|
+
entry = by_client.get(int(item.client_id))
|
|
2184
|
+
if entry is not None:
|
|
2185
|
+
imported.append(
|
|
2186
|
+
ImportedPhone(
|
|
2187
|
+
phone=entry.phone,
|
|
2188
|
+
first_name=entry.first_name,
|
|
2189
|
+
last_name=entry.last_name,
|
|
2190
|
+
user_id=int(item.user_id),
|
|
2191
|
+
)
|
|
2192
|
+
)
|
|
2193
|
+
for item in getattr(result, "popular_invites", None) or []:
|
|
2194
|
+
entry = by_client.get(int(item.client_id))
|
|
2195
|
+
if entry is not None:
|
|
2196
|
+
popular.append(
|
|
2197
|
+
ImportedPhone(
|
|
2198
|
+
phone=entry.phone,
|
|
2199
|
+
first_name=entry.first_name,
|
|
2200
|
+
last_name=entry.last_name,
|
|
2201
|
+
importers=int(getattr(item, "importers", 0) or 0),
|
|
2202
|
+
)
|
|
2203
|
+
)
|
|
2204
|
+
for client_id in getattr(result, "retry_contacts", None) or []:
|
|
2205
|
+
entry = by_client.get(int(client_id))
|
|
2206
|
+
if entry is not None:
|
|
2207
|
+
retry.append(
|
|
2208
|
+
ImportedPhone(
|
|
2209
|
+
phone=entry.phone,
|
|
2210
|
+
first_name=entry.first_name,
|
|
2211
|
+
last_name=entry.last_name,
|
|
2212
|
+
retry=True,
|
|
2213
|
+
)
|
|
2214
|
+
)
|
|
2215
|
+
|
|
2216
|
+
if retry:
|
|
2217
|
+
ctx.warn(
|
|
2218
|
+
f"{len(retry)} numbers came back in retry_contacts; the server wants them "
|
|
2219
|
+
"sent again later. Re-run with a file containing just those."
|
|
2220
|
+
)
|
|
2221
|
+
ctx.emit("contact_import", {"imported": len(imported), "retry": len(retry)})
|
|
2222
|
+
return ContactImport(
|
|
2223
|
+
parsed=len(entries),
|
|
2224
|
+
imported=imported,
|
|
2225
|
+
retry=retry,
|
|
2226
|
+
popular_invites=popular,
|
|
2227
|
+
batches=batches,
|
|
2228
|
+
flood_waits=int(getattr(ctx, "flood_wait_slept", 0) or 0),
|
|
2229
|
+
)
|
|
2230
|
+
|
|
2231
|
+
|
|
2232
|
+
SPEC_IMPORT = OperationSpec(
|
|
2233
|
+
id="contact.import",
|
|
2234
|
+
request=ImportReq,
|
|
2235
|
+
response=ContactImport,
|
|
2236
|
+
impl=contact_import,
|
|
2237
|
+
summary="Bulk-import a phonebook from vCard or CSV",
|
|
2238
|
+
description=(
|
|
2239
|
+
"Heavily flood-limited, so imports are chunked (`--batch-size`, 200 "
|
|
2240
|
+
"by default) and paced by the session limiter. `popular_invites` "
|
|
2241
|
+
"says how many other people already imported that number."
|
|
2242
|
+
),
|
|
2243
|
+
mutating=True,
|
|
2244
|
+
rate_class="bulk",
|
|
2245
|
+
timeout_s=600,
|
|
2246
|
+
columns=("parsed", "batches", "imported", "retry"),
|
|
2247
|
+
example={"parsed": 2, "batches": 1, "imported": [{"phone": "+15550001111"}], "retry": []},
|
|
2248
|
+
example_args="contact import phonebook.vcf",
|
|
2249
|
+
covers=("contacts-users.contacts-import-bulk",),
|
|
2250
|
+
)
|
|
2251
|
+
|
|
2252
|
+
|
|
2253
|
+
class SyncReq(Request):
|
|
2254
|
+
file: Annotated[str, arg(0, metavar="FILE", kind="path", help="The phonebook to sync from.")]
|
|
2255
|
+
delete_missing: Annotated[
|
|
2256
|
+
bool, opt("--delete-missing", help="Delete server contacts absent from the file.")
|
|
2257
|
+
] = False
|
|
2258
|
+
apply: Annotated[
|
|
2259
|
+
bool, opt("--apply", help="Actually apply the diff (the default is to print it).")
|
|
2260
|
+
] = False
|
|
2261
|
+
|
|
2262
|
+
|
|
2263
|
+
async def sync(ctx: OpContext, req: SyncReq) -> ContactSync:
|
|
2264
|
+
"""Two-way sync of a local phonebook file with the server contact list.
|
|
2265
|
+
|
|
2266
|
+
A headless CLI has no OS address book, so the "device phonebook" is the
|
|
2267
|
+
file you point at. Printing the diff is the default because deleting by
|
|
2268
|
+
phone is irreversible server-side; `--apply` is what actually writes.
|
|
2269
|
+
"""
|
|
2270
|
+
from telethon.tl import types
|
|
2271
|
+
from telethon.tl.functions import contacts as fn
|
|
2272
|
+
|
|
2273
|
+
entries = parse_phonebook(_read_file(req.file, "file"))
|
|
2274
|
+
_, users, _ = await load_contacts(ctx)
|
|
2275
|
+
server = {e164(getattr(u, "phone", "") or ""): u for u in users if getattr(u, "phone", None)}
|
|
2276
|
+
local = {entry.phone: entry for entry in entries}
|
|
2277
|
+
|
|
2278
|
+
to_import = [entry for phone, entry in local.items() if phone not in server]
|
|
2279
|
+
to_delete = [phone for phone in server if phone not in local] if req.delete_missing else []
|
|
2280
|
+
|
|
2281
|
+
if not req.apply:
|
|
2282
|
+
return ContactSync(to_import=to_import, to_delete=to_delete, applied=False)
|
|
2283
|
+
|
|
2284
|
+
imported = 0
|
|
2285
|
+
if to_import:
|
|
2286
|
+
result = await client_of(ctx)(
|
|
2287
|
+
fn.ImportContactsRequest(
|
|
2288
|
+
[
|
|
2289
|
+
types.InputPhoneContact(
|
|
2290
|
+
client_id=index,
|
|
2291
|
+
phone=entry.phone,
|
|
2292
|
+
first_name=entry.first_name or entry.phone,
|
|
2293
|
+
last_name=entry.last_name,
|
|
2294
|
+
)
|
|
2295
|
+
for index, entry in enumerate(to_import)
|
|
2296
|
+
]
|
|
2297
|
+
)
|
|
2298
|
+
)
|
|
2299
|
+
imported = len(getattr(result, "imported", None) or [])
|
|
2300
|
+
if to_delete:
|
|
2301
|
+
await client_of(ctx)(fn.DeleteByPhonesRequest(phones=to_delete))
|
|
2302
|
+
ctx.emit("contact_sync", {"imported": imported, "deleted": len(to_delete)})
|
|
2303
|
+
return ContactSync(
|
|
2304
|
+
to_import=to_import,
|
|
2305
|
+
to_delete=to_delete,
|
|
2306
|
+
applied=True,
|
|
2307
|
+
imported=imported,
|
|
2308
|
+
deleted=len(to_delete),
|
|
2309
|
+
)
|
|
2310
|
+
|
|
2311
|
+
|
|
2312
|
+
SPEC_SYNC = OperationSpec(
|
|
2313
|
+
id="contact.sync",
|
|
2314
|
+
request=SyncReq,
|
|
2315
|
+
response=ContactSync,
|
|
2316
|
+
impl=sync,
|
|
2317
|
+
summary="Two-way sync of a local phonebook file with the server contact list",
|
|
2318
|
+
description=(
|
|
2319
|
+
"Prints the diff and changes nothing unless `--apply` is given, "
|
|
2320
|
+
"because `contacts.deleteByPhones` is irreversible server-side."
|
|
2321
|
+
),
|
|
2322
|
+
mutating=True,
|
|
2323
|
+
destructive=True,
|
|
2324
|
+
rate_class="bulk",
|
|
2325
|
+
timeout_s=600,
|
|
2326
|
+
columns=("applied", "imported", "deleted"),
|
|
2327
|
+
example={"to_import": [{"phone": "+15550001111"}], "to_delete": [], "applied": False},
|
|
2328
|
+
example_args="contact sync phonebook.vcf",
|
|
2329
|
+
covers=("contacts-users.contacts-sync", "privacy.sync-contacts-delete"),
|
|
2330
|
+
)
|