tlgr-cli 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. tlgr/__init__.py +3 -0
  2. tlgr/__main__.py +6 -0
  3. tlgr/actions/__init__.py +45 -0
  4. tlgr/actions/forward.py +74 -0
  5. tlgr/actions/reply.py +32 -0
  6. tlgr/cli/__init__.py +259 -0
  7. tlgr/cli/confirm.py +55 -0
  8. tlgr/cli/errors.py +84 -0
  9. tlgr/cli/gen.py +690 -0
  10. tlgr/cli/globals.py +273 -0
  11. tlgr/cli/introspect.py +170 -0
  12. tlgr/cli/params.py +189 -0
  13. tlgr/cli/render.py +418 -0
  14. tlgr/core/__init__.py +0 -0
  15. tlgr/core/accounts.py +384 -0
  16. tlgr/core/config.py +358 -0
  17. tlgr/core/custom_tl.py +170 -0
  18. tlgr/core/errors.py +687 -0
  19. tlgr/core/eventtypes.py +1170 -0
  20. tlgr/core/identity.py +127 -0
  21. tlgr/core/launchd.py +122 -0
  22. tlgr/core/logging.py +194 -0
  23. tlgr/core/media.py +134 -0
  24. tlgr/core/output.py +251 -0
  25. tlgr/core/pagination.py +227 -0
  26. tlgr/core/paths.py +360 -0
  27. tlgr/core/peers.py +427 -0
  28. tlgr/core/process.py +138 -0
  29. tlgr/core/signing.py +38 -0
  30. tlgr/core/systemd.py +96 -0
  31. tlgr/core/telethon_compat.py +295 -0
  32. tlgr/core/text.py +211 -0
  33. tlgr/core/timefmt.py +199 -0
  34. tlgr/core/tl.py +98 -0
  35. tlgr/daemon/__init__.py +0 -0
  36. tlgr/daemon/app.py +869 -0
  37. tlgr/daemon/dispatch.py +446 -0
  38. tlgr/daemon/events.py +723 -0
  39. tlgr/daemon/files.py +431 -0
  40. tlgr/daemon/idle.py +119 -0
  41. tlgr/daemon/jobs.py +68 -0
  42. tlgr/daemon/main.py +161 -0
  43. tlgr/daemon/peercred.py +75 -0
  44. tlgr/daemon/policy.py +113 -0
  45. tlgr/daemon/preauth.py +366 -0
  46. tlgr/daemon/ratelimit.py +391 -0
  47. tlgr/daemon/server.py +24 -0
  48. tlgr/daemon/session.py +648 -0
  49. tlgr/daemon/sessions.py +274 -0
  50. tlgr/daemon/singleton.py +114 -0
  51. tlgr/daemon/stream.py +193 -0
  52. tlgr/daemon/transfers.py +219 -0
  53. tlgr/daemon/webhook.py +390 -0
  54. tlgr/data/catalog_index.json +1 -0
  55. tlgr/data/parity_waivers.toml +90 -0
  56. tlgr/filters/__init__.py +42 -0
  57. tlgr/filters/compose.py +121 -0
  58. tlgr/filters/content.py +85 -0
  59. tlgr/filters/context.py +114 -0
  60. tlgr/filters/message.py +161 -0
  61. tlgr/filters/temporal.py +87 -0
  62. tlgr/filters/user.py +36 -0
  63. tlgr/gateway/__init__.py +1 -0
  64. tlgr/gateway/config.py +161 -0
  65. tlgr/gateway/engine.py +215 -0
  66. tlgr/gateway/event.py +22 -0
  67. tlgr/jobs/__init__.py +0 -0
  68. tlgr/jobs/base.py +81 -0
  69. tlgr/jobs/client.py +37 -0
  70. tlgr/models/__init__.py +1220 -0
  71. tlgr/models/admin.py +744 -0
  72. tlgr/models/auth.py +510 -0
  73. tlgr/models/base.py +81 -0
  74. tlgr/models/bot.py +576 -0
  75. tlgr/models/business.py +265 -0
  76. tlgr/models/call.py +586 -0
  77. tlgr/models/config.py +101 -0
  78. tlgr/models/contact.py +481 -0
  79. tlgr/models/daemon.py +336 -0
  80. tlgr/models/dialog.py +626 -0
  81. tlgr/models/envelope.py +68 -0
  82. tlgr/models/error.py +30 -0
  83. tlgr/models/event.py +79 -0
  84. tlgr/models/export.py +66 -0
  85. tlgr/models/gift.py +275 -0
  86. tlgr/models/inline.py +84 -0
  87. tlgr/models/location.py +115 -0
  88. tlgr/models/media.py +507 -0
  89. tlgr/models/message.py +584 -0
  90. tlgr/models/net.py +232 -0
  91. tlgr/models/notify.py +105 -0
  92. tlgr/models/page.py +32 -0
  93. tlgr/models/payment.py +172 -0
  94. tlgr/models/peer.py +400 -0
  95. tlgr/models/poll.py +119 -0
  96. tlgr/models/premium.py +161 -0
  97. tlgr/models/privacy.py +93 -0
  98. tlgr/models/profile.py +217 -0
  99. tlgr/models/reaction.py +160 -0
  100. tlgr/models/resolve.py +175 -0
  101. tlgr/models/settings.py +103 -0
  102. tlgr/models/stars.py +101 -0
  103. tlgr/models/sticker.py +243 -0
  104. tlgr/models/story.py +467 -0
  105. tlgr/models/sync.py +105 -0
  106. tlgr/models/todo.py +36 -0
  107. tlgr/models/webapp.py +89 -0
  108. tlgr/ops/__init__.py +63 -0
  109. tlgr/ops/_admin.py +313 -0
  110. tlgr/ops/_auth.py +599 -0
  111. tlgr/ops/_bots.py +586 -0
  112. tlgr/ops/_calls.py +535 -0
  113. tlgr/ops/_common.py +160 -0
  114. tlgr/ops/_layer.py +46 -0
  115. tlgr/ops/_media.py +592 -0
  116. tlgr/ops/_params.py +212 -0
  117. tlgr/ops/_rights.py +402 -0
  118. tlgr/ops/_send.py +593 -0
  119. tlgr/ops/_serialize.py +667 -0
  120. tlgr/ops/_settings.py +306 -0
  121. tlgr/ops/_spec.py +167 -0
  122. tlgr/ops/_story.py +743 -0
  123. tlgr/ops/account.py +2604 -0
  124. tlgr/ops/agent.py +937 -0
  125. tlgr/ops/auth.py +1282 -0
  126. tlgr/ops/bot.py +4880 -0
  127. tlgr/ops/business.py +1520 -0
  128. tlgr/ops/call.py +1610 -0
  129. tlgr/ops/chat.py +4025 -0
  130. tlgr/ops/chat_admin.py +929 -0
  131. tlgr/ops/chat_extra.py +1061 -0
  132. tlgr/ops/chat_invite.py +716 -0
  133. tlgr/ops/chat_manage.py +1691 -0
  134. tlgr/ops/chat_member.py +1357 -0
  135. tlgr/ops/chat_stats.py +902 -0
  136. tlgr/ops/chat_topic.py +905 -0
  137. tlgr/ops/conference.py +791 -0
  138. tlgr/ops/config.py +1698 -0
  139. tlgr/ops/contact.py +2330 -0
  140. tlgr/ops/daemon.py +1397 -0
  141. tlgr/ops/draft.py +299 -0
  142. tlgr/ops/emoji.py +343 -0
  143. tlgr/ops/events.py +1327 -0
  144. tlgr/ops/export.py +596 -0
  145. tlgr/ops/folder.py +1322 -0
  146. tlgr/ops/gif.py +522 -0
  147. tlgr/ops/gift.py +1546 -0
  148. tlgr/ops/giveaway.py +541 -0
  149. tlgr/ops/inline.py +773 -0
  150. tlgr/ops/job.py +799 -0
  151. tlgr/ops/location.py +917 -0
  152. tlgr/ops/media.py +4495 -0
  153. tlgr/ops/message.py +3769 -0
  154. tlgr/ops/net.py +536 -0
  155. tlgr/ops/notify.py +840 -0
  156. tlgr/ops/passport.py +464 -0
  157. tlgr/ops/payment.py +907 -0
  158. tlgr/ops/poll.py +1078 -0
  159. tlgr/ops/premium.py +488 -0
  160. tlgr/ops/privacy.py +794 -0
  161. tlgr/ops/profile.py +1481 -0
  162. tlgr/ops/proxy.py +750 -0
  163. tlgr/ops/reaction.py +1475 -0
  164. tlgr/ops/resolve.py +1140 -0
  165. tlgr/ops/search.py +521 -0
  166. tlgr/ops/settings.py +1066 -0
  167. tlgr/ops/stars.py +594 -0
  168. tlgr/ops/sticker.py +1602 -0
  169. tlgr/ops/story.py +3216 -0
  170. tlgr/ops/sync.py +788 -0
  171. tlgr/ops/todo.py +514 -0
  172. tlgr/ops/user.py +1406 -0
  173. tlgr/ops/vc.py +2351 -0
  174. tlgr/ops/webapp.py +717 -0
  175. tlgr/ops/webhook.py +418 -0
  176. tlgr/parity.py +386 -0
  177. tlgr/processors/__init__.py +125 -0
  178. tlgr/processors/regex.py +26 -0
  179. tlgr/processors/text.py +56 -0
  180. tlgr/registry.py +519 -0
  181. tlgr/schema.py +173 -0
  182. tlgr/transport/__init__.py +30 -0
  183. tlgr/transport/autostart.py +293 -0
  184. tlgr/transport/client.py +805 -0
  185. tlgr/transport/ndjson.py +44 -0
  186. tlgr/version.py +31 -0
  187. tlgr_cli-2.0.1.dist-info/METADATA +957 -0
  188. tlgr_cli-2.0.1.dist-info/RECORD +192 -0
  189. tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
  190. tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
  191. tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
  192. tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/ops/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
+ )