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/user.py ADDED
@@ -0,0 +1,1406 @@
1
+ """The `user` group: one person's profile, and what this account may do to them.
2
+
3
+ One contract here is frozen by `AGENT.md` and must not drift; the tests in
4
+ `tests/test_ops_contacts.py` hold the line.
5
+
6
+ * **`user dialog-status` is three-valued.** `resolved=true, has_dialog=true`
7
+ is a dialog with an exact server-side message count;
8
+ `resolved=true, has_dialog=false` is a *definitive* negative, licensed only
9
+ by enumerating the account's complete dialog list; anything else is
10
+ `resolved=false, has_dialog=null` and exit 13. "Could not find the input
11
+ entity" is never evidence of absence — `get_input_entity` on a bare numeric
12
+ id only consults the local cache, and its network fallback returns
13
+ `UserEmpty` for any non-contact. Reading that as "no history" is the
14
+ cold-contact bug this command exists to remove.
15
+
16
+ v1's other frozen `user` contract, `user hide-stories`, now lives in the
17
+ story group: `story hide` owns the implementation and keeps `user
18
+ hide-stories` as a legacy path, so one toggle has one definition.
19
+
20
+ Access hashes are never printed. `access_hash_cached` says whether one is
21
+ held; the value is per-login-session state that is useless — and unsafe —
22
+ anywhere else.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import contextlib
28
+ from typing import Annotated, Any
29
+
30
+ from tlgr.core.errors import NotFoundError, UsageError
31
+ from tlgr.core.pagination import PageKind, build_page, decode_cursor
32
+ from tlgr.core.timefmt import fmt_dt, to_unix
33
+ from tlgr.models.base import Request
34
+ from tlgr.models.contact import (
35
+ BlockResult,
36
+ ContactRequirement,
37
+ DialogStatus,
38
+ MusicTrack,
39
+ PersonalChannel,
40
+ PhotoResult,
41
+ ProfilePhoto,
42
+ SuggestedBirthday,
43
+ UserLink,
44
+ UserProfile,
45
+ )
46
+ from tlgr.models.page import Page
47
+ from tlgr.models.peer import Chat, PeerRef
48
+ from tlgr.ops import _send
49
+ from tlgr.ops._params import arg, choice, opt
50
+ from tlgr.ops._serialize import action_bar, entity_to_peer, message_to_model, photo_summary
51
+ from tlgr.ops._spec import OpContext, OperationSpec
52
+ from tlgr.ops.contact import (
53
+ birthday_text,
54
+ client_of,
55
+ display_name,
56
+ e164,
57
+ fetch_user,
58
+ input_user,
59
+ mark_already,
60
+ status_model,
61
+ status_word,
62
+ )
63
+
64
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
65
+
66
+ #: The Replies pseudo-chat. `contacts.blockFromReplies` takes a message id
67
+ #: *inside* it, which is why `--from-replies` is a bare integer.
68
+ REPLIES_PEER = 1271266957
69
+
70
+ _EXAMPLE_USER: dict[str, Any] = {
71
+ "id": 777123,
72
+ "raw_id": 777123,
73
+ "first_name": "Alice",
74
+ "name": "Alice",
75
+ "username": "alice",
76
+ "bio": "somewhere warm",
77
+ "is_bot": False,
78
+ "status": "offline",
79
+ "stories_hidden": False,
80
+ }
81
+
82
+
83
+ def _window(ctx: OpContext, op: str, kind: PageKind, default: int = 50) -> tuple[int, Any]:
84
+ limit = int(getattr(ctx, "limit", None) or default)
85
+ if limit < 1:
86
+ raise UsageError("--limit must be at least 1", field="limit")
87
+ token = getattr(ctx, "cursor", None)
88
+ state: dict[str, Any] = {}
89
+ if token:
90
+ state = decode_cursor(token, op=op, kind=kind, account=ctx.account)
91
+ return min(limit, 1000), state
92
+
93
+
94
+ def _has_hash(target: Any) -> bool:
95
+ """Does this `InputUser` carry a real access hash?
96
+
97
+ `InputUserFromMessage` deliberately does not, which is the honest answer
98
+ for a `min` user: we can address them in that one context and nowhere
99
+ else.
100
+ """
101
+ return bool(int(getattr(target, "access_hash", 0) or 0))
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # user get
106
+ # ---------------------------------------------------------------------------
107
+
108
+
109
+ class GetReq(Request):
110
+ user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="@username, id or +phone.")]
111
+ full: Annotated[
112
+ bool, opt("--full", help="Add users.getFullUser (bio, note, birthday, business, blocked).")
113
+ ] = True
114
+ translate_bio: Annotated[
115
+ str | None, opt("--translate-bio", metavar="LANG", help="Translate the bio.")
116
+ ] = None
117
+ from_chat: Annotated[
118
+ PeerRef | None,
119
+ opt("--from-chat", metavar="CHAT", kind="peer", help="Context for a `min` user."),
120
+ ] = None
121
+ from_message: Annotated[
122
+ int | None,
123
+ opt("--from-message", metavar="ID", kind="msg_id", help="Message id in --from-chat."),
124
+ ] = None
125
+
126
+
127
+ def _colors(user: Any) -> dict[str, Any] | None:
128
+ color = getattr(user, "color", None)
129
+ profile = getattr(user, "profile_color", None)
130
+ if color is None and profile is None:
131
+ return None
132
+ out: dict[str, Any] = {}
133
+ if color is not None:
134
+ out["name_color"] = getattr(color, "color", None)
135
+ out["name_emoji_id"] = getattr(color, "background_emoji_id", None)
136
+ if profile is not None:
137
+ out["profile_color"] = getattr(profile, "color", None)
138
+ out["profile_emoji_id"] = getattr(profile, "background_emoji_id", None)
139
+ return out
140
+
141
+
142
+ def _business_hours(full: Any) -> dict[str, Any] | None:
143
+ hours = getattr(full, "business_work_hours", None)
144
+ if hours is None:
145
+ return None
146
+ return {
147
+ "timezone": getattr(hours, "timezone_id", None),
148
+ "open_now": getattr(hours, "open_now", None),
149
+ "periods": [
150
+ {"start": int(getattr(p, "start_minute", 0)), "end": int(getattr(p, "end_minute", 0))}
151
+ for p in getattr(hours, "weekly_open", None) or []
152
+ ],
153
+ }
154
+
155
+
156
+ def profile_model(user: Any, *, full: Any = None, has_hash: bool = False) -> UserProfile:
157
+ """A `User` (plus an optional `userFull`) as the profile shape.
158
+
159
+ v1's keys survive verbatim — `id`, `first_name`, `username`, `bio`,
160
+ `is_bot`, `status`, `stories_hidden`, `deleted`, `has_photo` — because
161
+ AGENT.md documents them and agents read them today.
162
+ """
163
+ raw_id = int(getattr(user, "id", 0) or 0)
164
+ status = getattr(user, "status", None)
165
+ photo = getattr(user, "photo", None)
166
+ model = UserProfile(
167
+ id=raw_id,
168
+ raw_id=raw_id,
169
+ kind="bot" if getattr(user, "bot", False) else "user",
170
+ first_name=getattr(user, "first_name", "") or "",
171
+ last_name=getattr(user, "last_name", "") or "",
172
+ name=display_name(user),
173
+ username=getattr(user, "username", None),
174
+ usernames=[
175
+ u.username
176
+ for u in (getattr(user, "usernames", None) or [])
177
+ if getattr(u, "username", None)
178
+ ],
179
+ phone=e164(getattr(user, "phone", "") or "") or None,
180
+ status=status_word(status),
181
+ status_detail=status_model(raw_id, status) if status is not None else None,
182
+ is_self=bool(getattr(user, "is_self", False)),
183
+ is_bot=bool(getattr(user, "bot", False)),
184
+ is_contact=bool(getattr(user, "contact", False)),
185
+ is_mutual_contact=bool(getattr(user, "mutual_contact", False)),
186
+ is_close_friend=bool(getattr(user, "close_friend", False)),
187
+ is_premium=bool(getattr(user, "premium", False)),
188
+ is_support=bool(getattr(user, "support", False)),
189
+ is_verified=bool(getattr(user, "verified", False)),
190
+ is_scam=bool(getattr(user, "scam", False)),
191
+ is_fake=bool(getattr(user, "fake", False)),
192
+ deleted=bool(getattr(user, "deleted", False)),
193
+ restricted=bool(getattr(user, "restricted", False)),
194
+ restriction_reason=[
195
+ str(getattr(r, "text", "") or "")
196
+ for r in getattr(user, "restriction_reason", None) or []
197
+ ],
198
+ # No photo together with an empty status is the classic signature of
199
+ # an account that blocked us — or of an abandoned one. Both signals
200
+ # are reported; the conclusion is not drawn here, because it cannot
201
+ # be drawn correctly.
202
+ has_photo=photo is not None and type(photo).__name__ != "UserProfilePhotoEmpty",
203
+ stories_hidden=bool(getattr(user, "stories_hidden", False)),
204
+ lang_code=getattr(user, "lang_code", None),
205
+ photo=photo_summary(photo),
206
+ emoji_status_id=getattr(getattr(user, "emoji_status", None), "document_id", None),
207
+ colors=_colors(user),
208
+ access_hash_cached=has_hash or bool(getattr(user, "access_hash", None)),
209
+ min=bool(getattr(user, "min", False)),
210
+ )
211
+ if full is None:
212
+ return model
213
+
214
+ model.full = True
215
+ model.bio = getattr(full, "about", None) or ""
216
+ note = getattr(full, "note", None)
217
+ model.note = getattr(note, "text", None) if note is not None else None
218
+ model.birthday = birthday_text(getattr(full, "birthday", None))
219
+ model.blocked = getattr(full, "blocked", None)
220
+ model.blocked_my_stories_from = getattr(full, "blocked_my_stories_from", None)
221
+ model.common_chats_count = getattr(full, "common_chats_count", None)
222
+ model.personal_channel_id = getattr(full, "personal_channel_id", None)
223
+ model.personal_channel_message_id = getattr(full, "personal_channel_message", None)
224
+ model.contact_require_premium = getattr(full, "contact_require_premium", None)
225
+ model.send_paid_messages_stars = getattr(full, "send_paid_messages_stars", None)
226
+ model.stargifts_count = getattr(full, "stargifts_count", None)
227
+ rating = getattr(full, "stars_rating", None)
228
+ model.stars_rating = getattr(rating, "level", None) if rating is not None else None
229
+ tab = getattr(full, "main_tab", None)
230
+ model.main_tab = type(tab).__name__.removeprefix("ProfileTab").lower() if tab else None
231
+ model.unofficial_security_risk = getattr(full, "unofficial_security_risk", None)
232
+ model.business_hours = _business_hours(full)
233
+ location = getattr(full, "business_location", None)
234
+ model.business_location = getattr(location, "address", None) if location else None
235
+ intro = getattr(full, "business_intro", None)
236
+ if intro is not None:
237
+ model.business_intro = {
238
+ "title": getattr(intro, "title", None),
239
+ "description": getattr(intro, "description", None),
240
+ }
241
+ model.personal_photo = photo_summary(getattr(full, "personal_photo", None))
242
+ model.fallback_photo = photo_summary(getattr(full, "fallback_photo", None))
243
+ paper = getattr(full, "wallpaper", None)
244
+ model.wallpaper = getattr(paper, "slug", None) if paper is not None else None
245
+ settings = getattr(full, "settings", None)
246
+ if settings is not None:
247
+ from tlgr.models.base import to_builtins
248
+
249
+ model.action_bar = to_builtins(action_bar(settings, chat_id=raw_id))
250
+ return model
251
+
252
+
253
+ async def get(ctx: OpContext, req: GetReq) -> UserProfile:
254
+ """Full profile of one user.
255
+
256
+ A bare numeric id resolves only from this account's own peer cache —
257
+ there is no MTProto call that turns an id into an access hash — so an
258
+ uncached one fails rather than guessing. For a `min` user (someone seen
259
+ only inside a channel message) pass `--from-chat/--from-message`;
260
+ Telethon builds `inputUserFromMessage` for nobody.
261
+ """
262
+ from telethon.tl.functions import messages as mfn
263
+ from telethon.tl.functions import users as ufn
264
+
265
+ target = await input_user(ctx, req.user, from_chat=req.from_chat, from_message=req.from_message)
266
+ user = await fetch_user(ctx, target)
267
+
268
+ full = None
269
+ if req.full:
270
+ try:
271
+ answer = await client_of(ctx)(ufn.GetFullUserRequest(id=target))
272
+ except Exception as exc:
273
+ # A profile we can see the shell of but not the inside of is a
274
+ # real state (privacy, a deleted account); half an answer beats
275
+ # an error that hides the half we do have.
276
+ ctx.warn(f"users.getFullUser failed, reporting the short profile only: {exc}")
277
+ answer = None
278
+ if answer is not None:
279
+ full = getattr(answer, "full_user", None)
280
+ for candidate in getattr(answer, "users", None) or []:
281
+ if int(getattr(candidate, "id", 0) or 0) == int(user.id):
282
+ user = candidate
283
+
284
+ model = profile_model(user, full=full, has_hash=_has_hash(target))
285
+
286
+ if req.translate_bio and model.bio:
287
+ from telethon.tl import types
288
+
289
+ try:
290
+ translated = await client_of(ctx)(
291
+ mfn.TranslateTextRequest(
292
+ to_lang=req.translate_bio,
293
+ text=[types.TextWithEntities(text=model.bio, entities=[])],
294
+ )
295
+ )
296
+ first = list(getattr(translated, "result", None) or [])
297
+ model.bio_translated = getattr(first[0], "text", None) if first else None
298
+ except Exception as exc: # pragma: no cover - server-side feature gate
299
+ ctx.warn(f"bio translation is unavailable: {exc}")
300
+
301
+ return model
302
+
303
+
304
+ SPEC_GET = OperationSpec(
305
+ id="user.get",
306
+ request=GetReq,
307
+ response=UserProfile,
308
+ impl=get,
309
+ summary="Full profile of a user",
310
+ description=(
311
+ "Never prints an access hash: `access_hash_cached` says whether one "
312
+ "is held. A bare numeric id resolves only from this account's peer "
313
+ "cache; for a `min` user pass --from-chat/--from-message so "
314
+ "`inputUserFromMessage` can be built. `userFull` is invalidated "
315
+ "server-side after 60 s and whenever our own last-seen privacy "
316
+ "changes. No photo plus an empty status is a signal, not a verdict: "
317
+ "this never claims 'they blocked you'. To pull out one field, use "
318
+ "the global `--select bio --results-only` rather than a per-command "
319
+ "projection flag."
320
+ ),
321
+ legacy_paths=("user get",),
322
+ columns=("id", "first_name", "username", "bio", "is_bot", "status", "stories_hidden"),
323
+ example=_EXAMPLE_USER,
324
+ example_args="user get @alice",
325
+ covers=(
326
+ "contacts-users.block-status",
327
+ "contacts-users.resolve-min-users",
328
+ "contacts-users.resolve-user-id",
329
+ "contacts-users.user-badges",
330
+ "contacts-users.user-bio",
331
+ "contacts-users.user-bio-translate",
332
+ "contacts-users.user-birthday-read",
333
+ "contacts-users.user-business-hours",
334
+ "contacts-users.user-business-intro",
335
+ "contacts-users.user-business-location",
336
+ "contacts-users.user-copy-fields",
337
+ "contacts-users.user-emoji-status",
338
+ "contacts-users.user-gifts-count",
339
+ "contacts-users.user-main-profile-tab",
340
+ "contacts-users.user-peer-colors",
341
+ "contacts-users.user-phone",
342
+ "contacts-users.user-profile-basic",
343
+ "contacts-users.user-profile-full",
344
+ "contacts-users.user-stars-rating",
345
+ "contacts-users.user-status",
346
+ "contacts-users.user-unofficial-warning",
347
+ "contacts-users.user-usernames",
348
+ "profile.security-risk-flag",
349
+ ),
350
+ )
351
+
352
+
353
+ # ---------------------------------------------------------------------------
354
+ # user block / unblock
355
+ # ---------------------------------------------------------------------------
356
+
357
+
358
+ class BlockReq(Request):
359
+ user: Annotated[
360
+ PeerRef | None,
361
+ arg(0, metavar="USER", required=False, kind="peer", help="User, bot or channel."),
362
+ ] = None
363
+ stories: Annotated[
364
+ bool, opt("--stories", help="Story blocklist only: they keep messaging you.")
365
+ ] = False
366
+ report_spam: Annotated[bool, opt("--report-spam", help="Report spam first.")] = False
367
+ delete_history: Annotated[
368
+ bool, opt("--delete-history", help="Also delete the chat for BOTH sides.")
369
+ ] = False
370
+ from_replies: Annotated[
371
+ int | None,
372
+ opt("--from-replies", metavar="ID", help="Block the author of this Replies message."),
373
+ ] = None
374
+ delete_message: Annotated[
375
+ bool, opt("--delete-message", help="With --from-replies: also delete that message.")
376
+ ] = False
377
+
378
+
379
+ async def block(ctx: OpContext, req: BlockReq) -> BlockResult:
380
+ """Block a user, bot or channel — optionally stories-only, with cleanup.
381
+
382
+ The main blocklist and the story blocklist are independent: `--stories`
383
+ stops them seeing our stories and nothing else. Stopping a bot *is*
384
+ `contacts.block(bot)`; restarting it is `user unblock` plus `bot start`.
385
+ """
386
+ from telethon.tl.functions import contacts as fn
387
+ from telethon.tl.functions import messages as mfn
388
+
389
+ if req.from_replies is not None:
390
+ await client_of(ctx)(
391
+ fn.BlockFromRepliesRequest(
392
+ msg_id=int(req.from_replies),
393
+ delete_message=req.delete_message or None,
394
+ delete_history=req.delete_history or None,
395
+ report_spam=req.report_spam or None,
396
+ )
397
+ )
398
+ ctx.emit("user_block", {"msg_id": int(req.from_replies), "source": "replies"})
399
+ return BlockResult(
400
+ peer_id=REPLIES_PEER,
401
+ blocked=True,
402
+ deleted=req.delete_history,
403
+ reported=req.report_spam,
404
+ )
405
+
406
+ if req.user is None:
407
+ raise UsageError("give a user to block, or --from-replies <msg-id>", field="user")
408
+ peer = await _send.resolve(ctx, req.user)
409
+ marked = _send.peer_id_of(peer)
410
+
411
+ reported = False
412
+ if req.report_spam:
413
+ # Reporting before blocking, because a blocked peer's chat is no
414
+ # longer somewhere a report can point at.
415
+ await client_of(ctx)(mfn.ReportSpamRequest(peer=peer))
416
+ reported = True
417
+
418
+ await client_of(ctx)(fn.BlockRequest(id=peer, my_stories_from=req.stories or None))
419
+
420
+ deleted = False
421
+ if req.delete_history:
422
+ await client_of(ctx)(mfn.DeleteHistoryRequest(peer=peer, max_id=0, revoke=True))
423
+ deleted = True
424
+
425
+ ctx.emit("user_block", {"peer_id": marked, "stories_only": req.stories})
426
+ return BlockResult(
427
+ peer_id=marked,
428
+ blocked=True,
429
+ stories_only=req.stories,
430
+ deleted=deleted,
431
+ reported=reported,
432
+ )
433
+
434
+
435
+ SPEC_BLOCK = OperationSpec(
436
+ id="user.block",
437
+ request=BlockReq,
438
+ response=BlockResult,
439
+ impl=block,
440
+ summary="Block a user, bot or channel — optionally stories-only, with report and cleanup",
441
+ description=(
442
+ "The main blocklist stops messages, calls, status, photo and "
443
+ "stories. `--stories` is the independent story blocklist and stops "
444
+ "only stories. `--delete-history` revokes for both sides, which is "
445
+ "why the whole command is destructive."
446
+ ),
447
+ aliases=("chat.block", "contact.blocked.add"),
448
+ mutating=True,
449
+ destructive=True,
450
+ columns=("peer_id", "blocked", "stories_only"),
451
+ example={"peer_id": 777123, "blocked": True, "stories_only": False},
452
+ example_args="user block @spammer",
453
+ covers=(
454
+ "contacts-users.block-delete-and-block",
455
+ "contacts-users.block-from-replies",
456
+ "dialogs.block-stories",
457
+ "dialogs.block-user",
458
+ "dialogs.bot-stop-restart",
459
+ ),
460
+ tags=frozenset({"visible-to-others"}),
461
+ )
462
+
463
+
464
+ class UnblockReq(Request):
465
+ user: Annotated[PeerRef, arg(0, metavar="USER", kind="peer", help="User, bot or channel.")]
466
+ stories: Annotated[bool, opt("--stories", help="Remove from the story blocklist instead.")] = (
467
+ False
468
+ )
469
+
470
+
471
+ async def unblock(ctx: OpContext, req: UnblockReq) -> BlockResult:
472
+ """Unblock a user, bot or channel. Idempotent."""
473
+ from telethon.tl.functions import contacts as fn
474
+
475
+ peer = await _send.resolve(ctx, req.user)
476
+ marked = _send.peer_id_of(peer)
477
+ changed = await client_of(ctx)(fn.UnblockRequest(id=peer, my_stories_from=req.stories or None))
478
+ already = changed is False
479
+ if already:
480
+ mark_already(ctx)
481
+ else:
482
+ ctx.emit("user_unblock", {"peer_id": marked, "stories_only": req.stories})
483
+ return BlockResult(peer_id=marked, blocked=False, stories_only=req.stories, already=already)
484
+
485
+
486
+ SPEC_UNBLOCK = OperationSpec(
487
+ id="user.unblock",
488
+ request=UnblockReq,
489
+ response=BlockResult,
490
+ impl=unblock,
491
+ summary="Unblock a user, bot or channel",
492
+ description="`already: true` means the peer was not on the list and no RPC changed anything.",
493
+ aliases=("chat.unblock", "contact.blocked.remove"),
494
+ mutating=True,
495
+ idempotent=True,
496
+ columns=("peer_id", "blocked", "already"),
497
+ example={"peer_id": 777123, "blocked": False, "already": False},
498
+ example_args="user unblock @alice",
499
+ covers=("contacts-users.block-unblock", "dialogs.unblock-user"),
500
+ )
501
+
502
+
503
+ # ---------------------------------------------------------------------------
504
+ # user dialog-status
505
+ # ---------------------------------------------------------------------------
506
+
507
+
508
+ class DialogStatusReq(Request):
509
+ user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Who to ask about.")]
510
+ max_dialogs: Annotated[
511
+ int,
512
+ opt(
513
+ "--max-dialogs",
514
+ metavar="N",
515
+ help="Cap the fallback dialog scan. Hitting it is indeterminate, never 'no'.",
516
+ ge=1,
517
+ ),
518
+ ] = 5000
519
+
520
+
521
+ async def dialog_status(ctx: OpContext, req: DialogStatusReq) -> DialogStatus:
522
+ """Does this account have prior history with this user? Three-valued.
523
+
524
+ SEMANTICS FROZEN (AGENT.md). The naive probe — list a few messages, read
525
+ the error — is unsound for a bare numeric id, because
526
+ `get_input_entity` only consults the local cache and its network fallback
527
+ returns `UserEmpty` for any non-contact. So:
528
+
529
+ 1. try to address the peer cheaply and, if that works, ask the server
530
+ directly with `messages.getPeerDialogs` plus an exact message total;
531
+ 2. if it cannot be addressed, enumerate the account's *complete* dialog
532
+ list. Finding the id is a positive; **exhausting** the list is the
533
+ only thing that licenses a negative;
534
+ 3. if neither completes — cap, flood, RPC failure — report
535
+ `resolved: false` and exit 13 so the caller fails closed.
536
+
537
+ It reports on the dialog list: a conversation this account itself deleted
538
+ is gone server-side too and correctly reads as no dialog.
539
+
540
+ `messages.getPeerDialogs` already hands back the whole dialog object, so
541
+ the peer's read state rides along rather than being thrown away:
542
+ `read_outbox_max_id` (the highest message of OURS they have read),
543
+ `unread_count` and `top_message`. All three are on EVERY return path,
544
+ including the indeterminate one (null) and the definitive negative (0) —
545
+ an absent key reads back as `null`, which is indistinguishable from "they
546
+ have not read it". There is deliberately no derived `they_read_it`
547
+ boolean: that comparison is only meaningful when the last message is
548
+ ours, which only the caller knows.
549
+ """
550
+ from telethon import utils
551
+ from telethon.tl import types
552
+ from telethon.tl.functions import messages as mfn
553
+
554
+ def unknown(reason: str) -> DialogStatus:
555
+ """The third answer: report it, and make the process fail closed.
556
+
557
+ The body is still returned — a caller needs `reason` and
558
+ `scanned_dialogs` to decide what to do — and `mark_indeterminate`
559
+ is what turns the exit status into 13, so "could not establish"
560
+ can never be read as "no history".
561
+ """
562
+ out.reason = reason
563
+ out.resolved = False
564
+ out.has_dialog = None
565
+ mark = getattr(ctx, "mark_indeterminate", None)
566
+ if callable(mark):
567
+ mark(reason)
568
+ return out
569
+
570
+ out = DialogStatus(ref=getattr(req.user, "raw", str(req.user)))
571
+ target_id: int | None = None
572
+ target_username: str | None = None
573
+ if req.user.kind == "id":
574
+ target_id = int(req.user.value)
575
+ elif req.user.kind == "username":
576
+ target_username = str(req.user.value).lstrip("@").lower()
577
+
578
+ client = client_of(ctx)
579
+ peer: Any = None
580
+ try:
581
+ peer = await _send.resolve(ctx, req.user)
582
+ except Exception as exc:
583
+ # NOT evidence of absence — a cold cache or an unknown handle.
584
+ out.reason = f"entity not resolvable directly: {exc}"
585
+
586
+ scanned = 0
587
+ if peer is None:
588
+ if target_id is None and target_username is None:
589
+ return unknown(f"unusable reference: {out.ref!r}")
590
+ try:
591
+ async for dialog in client.iter_dialogs(limit=req.max_dialogs):
592
+ scanned += 1
593
+ entity = getattr(dialog, "entity", None)
594
+ entity_id = getattr(entity, "id", None)
595
+ dialog_id = getattr(dialog, "id", None)
596
+ handle = (getattr(entity, "username", None) or "").lower()
597
+ if (target_id is not None and target_id in (entity_id, dialog_id)) or (
598
+ target_username is not None and handle == target_username
599
+ ):
600
+ peer = entity
601
+ out.source = "dialog_scan"
602
+ break
603
+ else:
604
+ if scanned >= req.max_dialogs:
605
+ out.scanned_dialogs = scanned
606
+ return unknown(
607
+ f"dialog scan hit the {req.max_dialogs}-dialog cap without a "
608
+ "match — indeterminate, NOT a negative"
609
+ )
610
+ except Exception as exc:
611
+ out.scanned_dialogs = scanned
612
+ return unknown(f"dialog scan did not complete: {exc}")
613
+
614
+ out.scanned_dialogs = scanned
615
+ if peer is None:
616
+ # The server handed over every dialog this account has and the
617
+ # peer was not among them. This is the definitive negative, and
618
+ # the only one.
619
+ out.resolved = True
620
+ out.has_dialog = False
621
+ out.message_count = 0
622
+ out.read_outbox_max_id = 0
623
+ out.unread_count = 0
624
+ out.top_message = 0
625
+ out.source = "dialog_scan"
626
+ out.reason = "absent from the account's complete dialog list"
627
+ out.id = target_id
628
+ out.username = target_username
629
+ return out
630
+
631
+ try:
632
+ input_peer = await client.get_input_entity(peer)
633
+ answer = await client(
634
+ mfn.GetPeerDialogsRequest(peers=[types.InputDialogPeer(peer=input_peer)])
635
+ )
636
+ dialogs = list(getattr(answer, "dialogs", None) or [])
637
+ # The peer's own dialog object — the one with the newest message when
638
+ # the server hands back more than one — carries the read state as
639
+ # well as the top message.
640
+ dlg = max(dialogs, key=lambda d: int(getattr(d, "top_message", 0) or 0), default=None)
641
+ top = int(getattr(dlg, "top_message", 0) or 0) if dlg is not None else 0
642
+ messages = await client.get_messages(input_peer, limit=1)
643
+ total = getattr(messages, "total", None)
644
+ total = int(total if total is not None else len(messages or []))
645
+ except Exception as exc:
646
+ return unknown(f"server dialog query failed: {exc}")
647
+
648
+ with contextlib.suppress(TypeError, ValueError):
649
+ out.id = int(utils.get_peer_id(peer))
650
+ if out.id is None:
651
+ out.id = target_id
652
+ out.username = getattr(peer, "username", None) or target_username
653
+ out.resolved = True
654
+ # A scan hit stays a positive even if both sides have since wiped the
655
+ # history: presence in the dialog list *is* the dialog.
656
+ out.has_dialog = out.source == "dialog_scan" or bool(top) or total > 0
657
+ out.message_count = total
658
+ if dlg is not None:
659
+ read = getattr(dlg, "read_outbox_max_id", None)
660
+ unread = getattr(dlg, "unread_count", None)
661
+ out.read_outbox_max_id = None if read is None else int(read)
662
+ out.unread_count = None if unread is None else int(unread)
663
+ out.top_message = top
664
+ if out.source != "dialog_scan":
665
+ out.source = "peer_dialogs"
666
+ return out
667
+
668
+
669
+ SPEC_DIALOG_STATUS = OperationSpec(
670
+ id="user.dialog-status",
671
+ request=DialogStatusReq,
672
+ response=DialogStatus,
673
+ impl=dialog_status,
674
+ summary="Does this account have prior history with this user? (three-valued, never guessed)",
675
+ description=(
676
+ "resolved=true/has_dialog=true — a dialog exists, message_count is "
677
+ "the server's exact total. resolved=true/has_dialog=false — "
678
+ "definitively none, because the COMPLETE dialog list was enumerated. "
679
+ "resolved=false/has_dialog=null — exit 13, and `reason` says why. "
680
+ "Exit 13 means UNKNOWN: a caller gating a cold first message must "
681
+ "treat it as a refusal, never as a green light. The peer's read "
682
+ "state — read_outbox_max_id (the highest message of OURS they have "
683
+ "read), unread_count, top_message — is on every return path, null "
684
+ "when nothing could be established. 'Did they see our last message?' "
685
+ "is read_outbox_max_id >= top_message AND the last message being "
686
+ "ours; this op does not guess at the second half, because only the "
687
+ "caller knows it."
688
+ ),
689
+ legacy_paths=("user dialog-status",),
690
+ rate_class="bulk",
691
+ timeout_s=600,
692
+ columns=(
693
+ "id",
694
+ "username",
695
+ "resolved",
696
+ "has_dialog",
697
+ "message_count",
698
+ "read_outbox_max_id",
699
+ "top_message",
700
+ "unread_count",
701
+ "source",
702
+ ),
703
+ example={
704
+ "ref": "@alice",
705
+ "id": 777123,
706
+ "username": "alice",
707
+ "resolved": True,
708
+ "has_dialog": True,
709
+ "message_count": 12,
710
+ "read_outbox_max_id": 893,
711
+ "unread_count": 0,
712
+ "top_message": 893,
713
+ "source": "peer_dialogs",
714
+ "reason": None,
715
+ },
716
+ example_args="user dialog-status @alice",
717
+ covers=("contacts-users.user-dialog-exists", "dialogs.dialog-exists"),
718
+ covers_partial=("dialogs.resolve-peer",),
719
+ coverage_note=(
720
+ "The reference-resolution half of `dialogs.resolve-peer` is `resolve peer`; "
721
+ "this op only answers the has-a-dialog question about a user."
722
+ ),
723
+ )
724
+
725
+
726
+ # ---------------------------------------------------------------------------
727
+ # user can-message
728
+ # ---------------------------------------------------------------------------
729
+
730
+
731
+ class CanMessageReq(Request):
732
+ user: Annotated[
733
+ list[PeerRef],
734
+ arg(0, metavar="USER", variadic=True, kind="user", help="Who to check."),
735
+ ] = []
736
+
737
+
738
+ async def can_message(ctx: OpContext, req: CanMessageReq) -> Page[ContactRequirement]:
739
+ """Can I message this user, and at what price?
740
+
741
+ Pairs with `user dialog-status` for cold-outreach gating: this answers
742
+ "am I allowed to", that one answers "have I already". Reading the Stars
743
+ price is fine; paying it is a payment a human initiates.
744
+ """
745
+ from telethon.tl.functions import users as ufn
746
+
747
+ if not req.user:
748
+ raise UsageError("give at least one user", field="user")
749
+ targets = [await input_user(ctx, ref) for ref in req.user]
750
+ answers = list(await client_of(ctx)(ufn.GetRequirementsToContactRequest(id=targets)) or [])
751
+
752
+ rows: list[ContactRequirement] = []
753
+ for target, answer in zip(targets, answers, strict=False):
754
+ name = type(answer).__name__
755
+ kind = {
756
+ "RequirementToContactEmpty": "free",
757
+ "RequirementToContactPremium": "premium",
758
+ "RequirementToContactPaidMessages": "paid",
759
+ }.get(name, "unknown")
760
+ rows.append(
761
+ ContactRequirement(
762
+ user_id=int(getattr(target, "user_id", 0) or 0),
763
+ result=kind, # type: ignore[arg-type]
764
+ stars_amount=getattr(answer, "stars_amount", None),
765
+ contact_require_premium=kind == "premium" or None,
766
+ )
767
+ )
768
+ limit, state = _window(ctx, "user.can-message", PageKind.LOCAL, default=100)
769
+ offset = int(state.get("offset", 0) or 0)
770
+ window = rows[offset : offset + limit]
771
+ return build_page(
772
+ window,
773
+ op="user.can-message",
774
+ kind=PageKind.LOCAL,
775
+ state={"offset": offset + len(window)},
776
+ account=ctx.account,
777
+ has_more=offset + len(window) < len(rows),
778
+ total=len(rows),
779
+ )
780
+
781
+
782
+ SPEC_CAN_MESSAGE = OperationSpec(
783
+ id="user.can-message",
784
+ request=CanMessageReq,
785
+ response=Page[ContactRequirement],
786
+ impl=can_message,
787
+ summary="Can I message this user, and at what price?",
788
+ description=(
789
+ "`free` | `premium` | `paid` (with `stars_amount`). The send-time "
790
+ "failure this predicts is PRIVACY_PREMIUM_REQUIRED (403)."
791
+ ),
792
+ paginated=PageKind.LOCAL,
793
+ columns=("user_id", "result", "stars_amount"),
794
+ headers=("User", "Requirement", "Stars"),
795
+ example={"items": [{"user_id": 777123, "result": "free"}], "has_more": False},
796
+ example_args="user can-message @alice",
797
+ covers=(
798
+ "contacts-users.user-paid-messages",
799
+ "contacts-users.user-requirements-to-contact",
800
+ "privacy.requirements-to-contact",
801
+ ),
802
+ )
803
+
804
+
805
+ # ---------------------------------------------------------------------------
806
+ # user chat list
807
+ # ---------------------------------------------------------------------------
808
+
809
+
810
+ class ChatListReq(Request):
811
+ user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Whose common chats.")]
812
+ leave_all: Annotated[bool, opt("--leave-all", help="Leave every listed chat.")] = False
813
+
814
+
815
+ async def chat_list(ctx: OpContext, req: ChatListReq) -> Page[Chat]:
816
+ """Groups and channels shared with a user, optionally leaving all of them.
817
+
818
+ `userFull.common_chats_count` is the count; this is the list. Leaving is
819
+ opt-in and destructive, and the chats are listed before anything is left.
820
+ """
821
+ from telethon.tl.functions import channels as cfn
822
+ from telethon.tl.functions import messages as mfn
823
+
824
+ limit, state = _window(ctx, "user.chat.list", PageKind.PARTICIPANTS, default=100)
825
+ max_id = int(state.get("max_id", 0) or 0)
826
+ target = await input_user(ctx, req.user)
827
+ result = await client_of(ctx)(
828
+ mfn.GetCommonChatsRequest(user_id=target, max_id=max_id, limit=limit)
829
+ )
830
+ chats = list(getattr(result, "chats", None) or [])
831
+
832
+ rows: list[Chat] = []
833
+ for entity in chats:
834
+ peer = entity_to_peer(entity)
835
+ rows.append(
836
+ Chat(
837
+ id=peer.id,
838
+ raw_id=peer.raw_id,
839
+ kind=peer.kind,
840
+ title=peer.title,
841
+ username=peer.username,
842
+ usernames=peer.usernames,
843
+ left=bool(getattr(entity, "left", False)),
844
+ )
845
+ )
846
+
847
+ if req.leave_all and rows:
848
+ # Listing is a read and stays dry-runnable, so this branch honours
849
+ # --dry-run itself rather than turning the whole command into a stub.
850
+ if getattr(ctx, "dry_run", False):
851
+ ctx.warn(f"--dry-run: would leave {len(rows)} shared chats")
852
+ else:
853
+ from telethon import utils
854
+ from telethon.tl import types
855
+
856
+ for entity in chats:
857
+ try:
858
+ if type(entity).__name__ == "Channel":
859
+ await client_of(ctx)(
860
+ cfn.LeaveChannelRequest(utils.get_input_channel(entity))
861
+ )
862
+ else:
863
+ await client_of(ctx)(
864
+ mfn.DeleteChatUserRequest(
865
+ chat_id=int(entity.id), user_id=types.InputUserSelf()
866
+ )
867
+ )
868
+ except Exception as exc:
869
+ ctx.warn(f"could not leave {getattr(entity, 'title', entity)}: {exc}")
870
+ continue
871
+ row = next((r for r in rows if r.raw_id == int(entity.id)), None)
872
+ if row is not None:
873
+ row.left = True
874
+ ctx.emit("user_common_chats_leave", {"count": len(rows)})
875
+
876
+ return build_page(
877
+ rows,
878
+ op="user.chat.list",
879
+ kind=PageKind.PARTICIPANTS,
880
+ state={"max_id": min((abs(row.raw_id) for row in rows), default=0)},
881
+ account=ctx.account,
882
+ limit=limit,
883
+ )
884
+
885
+
886
+ SPEC_CHAT_LIST = OperationSpec(
887
+ id="user.chat.list",
888
+ request=ChatListReq,
889
+ response=Page[Chat],
890
+ impl=chat_list,
891
+ summary="Groups and channels you share with a user",
892
+ description=(
893
+ "`--leave-all` leaves every listed chat immediately — run it under "
894
+ "--dry-run first, which prints what would go. `userFull."
895
+ "common_chats_count` is the count; this is the list."
896
+ ),
897
+ aliases=("user.common-chats",),
898
+ paginated=PageKind.PARTICIPANTS,
899
+ rate_class="bulk",
900
+ tags=frozenset({"mutating-checked"}),
901
+ columns=("id", "title", "kind", "left"),
902
+ headers=("Id", "Title", "Kind", "Left"),
903
+ example={
904
+ "items": [{"id": -1001234, "raw_id": 1234, "kind": "supergroup", "title": "News"}],
905
+ "has_more": False,
906
+ },
907
+ example_args="user chat list @alice",
908
+ covers=("contacts-users.user-leave-common-groups",),
909
+ )
910
+
911
+
912
+ # ---------------------------------------------------------------------------
913
+ # user link
914
+ # ---------------------------------------------------------------------------
915
+
916
+
917
+ class LinkReq(Request):
918
+ user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Use `me` with --token.")]
919
+ profile: Annotated[
920
+ bool, opt("--profile", help="Add ?profile so clients open the profile, not the chat.")
921
+ ] = False
922
+ text: Annotated[
923
+ str | None, opt("--text", metavar="TEXT", help="Pre-fill a draft (?text=).")
924
+ ] = None
925
+ scheme: Annotated[str, choice("tme", "tg", help="Link flavour.")] = "tme"
926
+ token: Annotated[
927
+ bool, opt("--token", help="For `me`: a t.me/contact/<token> link with no username.")
928
+ ] = False
929
+
930
+
931
+ async def link(ctx: OpContext, req: LinkReq) -> UserLink:
932
+ """Build a link to a user, or my own temporary contact-token link.
933
+
934
+ A contact token EXPIRES, so the expiry is reported next to the URL — a
935
+ link with no expiry printed is a link somebody will paste next month.
936
+ """
937
+ from urllib.parse import quote
938
+
939
+ from telethon.tl.functions import contacts as fn
940
+
941
+ if req.token:
942
+ if req.user.kind not in ("self", "saved"):
943
+ raise UsageError("--token builds a link to your own profile: use `me`", field="user")
944
+ exported = await client_of(ctx)(fn.ExportContactTokenRequest())
945
+ expires = getattr(exported, "expires", None)
946
+ return UserLink(
947
+ url=str(getattr(exported, "url", "") or ""),
948
+ kind="contact-token",
949
+ expires=fmt_dt(expires),
950
+ expires_unix=to_unix(expires),
951
+ )
952
+
953
+ target = await input_user(ctx, req.user)
954
+ user = await fetch_user(ctx, target)
955
+ handle = getattr(user, "username", None)
956
+ query: list[str] = []
957
+ if req.profile:
958
+ query.append("profile")
959
+ if req.text:
960
+ # A draft starting with '@' would be read as a username by the
961
+ # clients that honour ?text=, so it is prefixed with a space.
962
+ text = req.text if not req.text.startswith("@") else " " + req.text
963
+ query.append("text=" + quote(text[:4096], safe=""))
964
+
965
+ if req.scheme == "tg":
966
+ base = f"tg://user?id={int(user.id)}" if not handle else f"tg://resolve?domain={handle}"
967
+ joined = base + ("&" + "&".join(query) if query else "")
968
+ return UserLink(url=joined, kind="profile" if req.profile else "chat")
969
+
970
+ if not handle:
971
+ raise NotFoundError(
972
+ "that user has no public username, so no t.me link exists for them; "
973
+ "use --scheme tg, which addresses them by id"
974
+ )
975
+ joined = f"https://t.me/{handle}" + ("?" + "&".join(query) if query else "")
976
+ return UserLink(url=joined, kind="profile" if req.profile else "chat")
977
+
978
+
979
+ SPEC_LINK = OperationSpec(
980
+ id="user.link",
981
+ request=LinkReq,
982
+ response=UserLink,
983
+ impl=link,
984
+ summary="Build a link to a user (t.me / tg://), or my own temporary profile link",
985
+ description=(
986
+ "Mostly local string building. `--token` is the exception: "
987
+ "`contacts.exportContactToken` mints a t.me/contact/<token> link that "
988
+ "works without a username and EXPIRES, so `expires` is always "
989
+ "reported next to it."
990
+ ),
991
+ columns=("url", "kind", "expires"),
992
+ example={"url": "https://t.me/alice", "kind": "chat"},
993
+ example_args="user link @alice --profile",
994
+ covers=("contacts-users.contact-token-export", "contacts-users.user-link-build"),
995
+ )
996
+
997
+
998
+ # ---------------------------------------------------------------------------
999
+ # user photo list / set
1000
+ # ---------------------------------------------------------------------------
1001
+
1002
+
1003
+ class PhotoListReq(Request):
1004
+ user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Whose photos.")]
1005
+ download: Annotated[
1006
+ str | None,
1007
+ opt("--download", metavar="DIR", kind="path", help="Download into this directory."),
1008
+ ] = None
1009
+ big: Annotated[bool, opt("--big", help="Prefer the largest size when downloading.")] = False
1010
+
1011
+
1012
+ async def photo_list(ctx: OpContext, req: PhotoListReq) -> Page[ProfilePhoto]:
1013
+ """A user's profile-photo history.
1014
+
1015
+ Personal and fallback photos are NOT in here — they are `userFull` fields
1016
+ and `user get --full` reports them.
1017
+ """
1018
+ from telethon.tl.functions import photos as pfn
1019
+
1020
+ limit, state = _window(ctx, "user.photo.list", PageKind.PARTICIPANTS, default=50)
1021
+ offset = int(state.get("offset", 0) or 0)
1022
+ target = await input_user(ctx, req.user)
1023
+ result = await client_of(ctx)(
1024
+ pfn.GetUserPhotosRequest(user_id=target, offset=offset, max_id=0, limit=limit)
1025
+ )
1026
+ photos = list(getattr(result, "photos", None) or [])
1027
+ total = getattr(result, "count", None)
1028
+
1029
+ rows: list[ProfilePhoto] = []
1030
+ for photo in photos:
1031
+ date = getattr(photo, "date", None)
1032
+ rows.append(
1033
+ ProfilePhoto(
1034
+ id=int(getattr(photo, "id", 0) or 0),
1035
+ date=fmt_dt(date),
1036
+ date_unix=to_unix(date),
1037
+ sizes=[
1038
+ str(getattr(size, "type", ""))
1039
+ for size in getattr(photo, "sizes", None) or []
1040
+ if getattr(size, "type", None)
1041
+ ],
1042
+ video=bool(getattr(photo, "video_sizes", None)),
1043
+ dc_id=getattr(photo, "dc_id", None),
1044
+ )
1045
+ )
1046
+
1047
+ if req.download:
1048
+ from pathlib import Path
1049
+
1050
+ directory = Path(req.download).expanduser()
1051
+ directory.mkdir(parents=True, exist_ok=True)
1052
+ for photo, row in zip(photos, rows, strict=True):
1053
+ try:
1054
+ saved = await client_of(ctx).download_media(
1055
+ photo, file=str(directory / f"{row.id}.jpg")
1056
+ )
1057
+ except Exception as exc:
1058
+ ctx.warn(f"could not download photo {row.id}: {exc}")
1059
+ continue
1060
+ row.file = str(saved) if saved else None
1061
+
1062
+ return build_page(
1063
+ rows,
1064
+ op="user.photo.list",
1065
+ kind=PageKind.PARTICIPANTS,
1066
+ state={"offset": offset + len(rows)},
1067
+ account=ctx.account,
1068
+ limit=limit,
1069
+ total=int(total) if total is not None else None,
1070
+ )
1071
+
1072
+
1073
+ SPEC_PHOTO_LIST = OperationSpec(
1074
+ id="user.photo.list",
1075
+ request=PhotoListReq,
1076
+ response=Page[ProfilePhoto],
1077
+ impl=photo_list,
1078
+ summary="A user's profile-photo history",
1079
+ aliases=("user.photos",),
1080
+ paginated=PageKind.PARTICIPANTS,
1081
+ rate_class="file",
1082
+ timeout_s=300,
1083
+ columns=("id", "date", "video"),
1084
+ headers=("Photo", "Taken", "Video"),
1085
+ example={"items": [{"id": 55123, "video": False}], "has_more": False},
1086
+ example_args="user photo list @alice",
1087
+ covers=("contacts-users.user-profile-photos",),
1088
+ )
1089
+
1090
+
1091
+ class PhotoSetReq(Request):
1092
+ user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Whose card to change.")]
1093
+ file: Annotated[
1094
+ str | None, arg(1, metavar="FILE", required=False, kind="path", help="Image or video.")
1095
+ ] = None
1096
+ suggest: Annotated[
1097
+ bool, opt("--suggest", help="Send it as a suggestion instead of applying it locally.")
1098
+ ] = False
1099
+ video: Annotated[bool, opt("--video", help="Upload as a video avatar.")] = False
1100
+ reset: Annotated[bool, opt("--reset", help="Remove the personal photo.")] = False
1101
+
1102
+
1103
+ async def photo_set(ctx: OpContext, req: PhotoSetReq) -> PhotoResult:
1104
+ """Set, suggest or reset the personal photo you see for a contact.
1105
+
1106
+ One method, three modes: `save` applies a photo only we see, `suggest`
1107
+ posts `messageActionSuggestProfilePhoto` to them (visible, hence --yes),
1108
+ and neither with no file removes what is there.
1109
+ """
1110
+ from pathlib import Path
1111
+
1112
+ from telethon.tl.functions import photos as pfn
1113
+
1114
+ target = await input_user(ctx, req.user)
1115
+ user_id = int(getattr(target, "user_id", 0) or 0)
1116
+
1117
+ if req.reset or not req.file:
1118
+ await client_of(ctx)(pfn.UploadContactProfilePhotoRequest(user_id=target))
1119
+ ctx.emit("user_photo_reset", {"user_id": user_id})
1120
+ return PhotoResult(user_id=user_id, reset=True)
1121
+
1122
+ upload = getattr(ctx, "upload_file", None)
1123
+ if upload is None: # pragma: no cover - the daemon always supplies one
1124
+ raise UsageError("this context cannot upload files")
1125
+ path = Path(req.file).expanduser()
1126
+ if not path.exists():
1127
+ raise UsageError(f"{req.file} does not exist", field="file")
1128
+ handle = await upload(path)
1129
+
1130
+ result = await client_of(ctx)(
1131
+ pfn.UploadContactProfilePhotoRequest(
1132
+ user_id=target,
1133
+ suggest=req.suggest or None,
1134
+ save=None if req.suggest else True,
1135
+ file=None if req.video else handle,
1136
+ video=handle if req.video else None,
1137
+ )
1138
+ )
1139
+ photo = getattr(result, "photo", None)
1140
+ ctx.emit("user_photo_set", {"user_id": user_id, "suggested": req.suggest})
1141
+ return PhotoResult(
1142
+ user_id=user_id,
1143
+ photo_id=int(getattr(photo, "id", 0) or 0) or None,
1144
+ suggested=req.suggest,
1145
+ )
1146
+
1147
+
1148
+ SPEC_PHOTO_SET = OperationSpec(
1149
+ id="user.photo.set",
1150
+ request=PhotoSetReq,
1151
+ response=PhotoResult,
1152
+ impl=photo_set,
1153
+ summary="Set, suggest or reset the personal photo you see for a contact",
1154
+ description=(
1155
+ "`--suggest` posts a visible message to them; without it the photo "
1156
+ "is a private override only this account sees."
1157
+ ),
1158
+ aliases=("user.set-photo",),
1159
+ mutating=True,
1160
+ rate_class="file",
1161
+ timeout_s=300,
1162
+ columns=("user_id", "photo_id", "suggested"),
1163
+ example={"user_id": 777123, "photo_id": 55123, "suggested": False},
1164
+ example_args="user photo set @alice avatar.jpg",
1165
+ covers=(
1166
+ "contacts-users.user-personal-photo-reset",
1167
+ "contacts-users.user-suggest-photo",
1168
+ "profile.photo-personal-for-contact",
1169
+ "profile.photo-suggest-to-user",
1170
+ ),
1171
+ tags=frozenset({"visible-to-others"}),
1172
+ )
1173
+
1174
+
1175
+ # ---------------------------------------------------------------------------
1176
+ # user birthday set
1177
+ # ---------------------------------------------------------------------------
1178
+
1179
+
1180
+ class BirthdaySetReq(Request):
1181
+ user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Who to suggest it to.")]
1182
+ date: Annotated[str, arg(1, metavar="DATE", help="YYYY-MM-DD or MM-DD.")]
1183
+
1184
+
1185
+ def _birthday(value: str) -> Any:
1186
+ from telethon.tl import types
1187
+
1188
+ parts = [p for p in (value or "").replace("/", "-").split("-") if p]
1189
+ try:
1190
+ numbers = [int(p) for p in parts]
1191
+ except ValueError as exc:
1192
+ raise UsageError(f"{value!r} is not a date; use YYYY-MM-DD or MM-DD", field="date") from exc
1193
+ year: int | None
1194
+ if len(numbers) == 3:
1195
+ year, month, day = numbers
1196
+ elif len(numbers) == 2:
1197
+ year, month, day = None, numbers[0], numbers[1]
1198
+ else:
1199
+ raise UsageError(f"{value!r} is not a date; use YYYY-MM-DD or MM-DD", field="date")
1200
+ if not 1 <= month <= 12 or not 1 <= day <= 31:
1201
+ raise UsageError(f"{value!r} is not a real date", field="date")
1202
+ return types.Birthday(day=day, month=month, year=year)
1203
+
1204
+
1205
+ async def birthday_set(ctx: OpContext, req: BirthdaySetReq) -> SuggestedBirthday:
1206
+ """Suggest a birthday to a contact.
1207
+
1208
+ This sends `messageActionSuggestBirthday` on our behalf — a visible
1209
+ message — so it needs --yes. BIRTHDAY_ALREADY means they already have one
1210
+ we can see.
1211
+ """
1212
+ from telethon.tl.functions import users as ufn
1213
+
1214
+ target = await input_user(ctx, req.user)
1215
+ birthday = _birthday(req.date)
1216
+ await client_of(ctx)(ufn.SuggestBirthdayRequest(id=target, birthday=birthday))
1217
+ user_id = int(getattr(target, "user_id", 0) or 0)
1218
+ ctx.emit("user_birthday_suggest", {"user_id": user_id})
1219
+ return SuggestedBirthday(
1220
+ user_id=user_id, birthday=birthday_text(birthday) or req.date, sent=True
1221
+ )
1222
+
1223
+
1224
+ SPEC_BIRTHDAY_SET = OperationSpec(
1225
+ id="user.birthday.set",
1226
+ request=BirthdaySetReq,
1227
+ response=SuggestedBirthday,
1228
+ impl=birthday_set,
1229
+ summary="Suggest a birthday to a contact",
1230
+ aliases=("user.suggest-birthday",),
1231
+ mutating=True,
1232
+ rate_class="send",
1233
+ columns=("user_id", "birthday", "sent"),
1234
+ example={"user_id": 777123, "birthday": "1990-04-01", "sent": True},
1235
+ example_args="user birthday set @alice 1990-04-01",
1236
+ covers=(
1237
+ "contact.birthday-accept",
1238
+ "contact.suggest-birthday",
1239
+ "contacts-users.user-suggest-birthday",
1240
+ "profile.birthday-suggest",
1241
+ ),
1242
+ tags=frozenset({"visible-to-others"}),
1243
+ )
1244
+
1245
+
1246
+ # ---------------------------------------------------------------------------
1247
+ # user music list / personal-channel get
1248
+ # ---------------------------------------------------------------------------
1249
+
1250
+
1251
+ class MusicListReq(Request):
1252
+ user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Whose pinned music.")]
1253
+ download: Annotated[
1254
+ str | None,
1255
+ opt("--download", metavar="DIR", kind="path", help="Download into this directory."),
1256
+ ] = None
1257
+
1258
+
1259
+ async def music_list(ctx: OpContext, req: MusicListReq) -> Page[MusicTrack]:
1260
+ """Music a user pinned to their profile.
1261
+
1262
+ Visibility is governed by `inputPrivacyKeySavedMusic`, so an empty list
1263
+ can mean "none pinned" or "not shared with you"; it is not evidence
1264
+ either way.
1265
+ """
1266
+ from telethon.tl.functions import users as ufn
1267
+
1268
+ limit, state = _window(ctx, "user.music.list", PageKind.PARTICIPANTS, default=50)
1269
+ offset = int(state.get("offset", 0) or 0)
1270
+ target = await input_user(ctx, req.user)
1271
+ result = await client_of(ctx)(
1272
+ ufn.GetSavedMusicRequest(id=target, offset=offset, limit=limit, hash=0)
1273
+ )
1274
+ documents = list(getattr(result, "documents", None) or [])
1275
+
1276
+ rows: list[MusicTrack] = []
1277
+ for document in documents:
1278
+ title = performer = None
1279
+ duration = None
1280
+ for attribute in getattr(document, "attributes", None) or []:
1281
+ if type(attribute).__name__ == "DocumentAttributeAudio":
1282
+ title = getattr(attribute, "title", None)
1283
+ performer = getattr(attribute, "performer", None)
1284
+ duration = getattr(attribute, "duration", None)
1285
+ rows.append(
1286
+ MusicTrack(
1287
+ id=int(getattr(document, "id", 0) or 0),
1288
+ title=title,
1289
+ performer=performer,
1290
+ duration=duration,
1291
+ mime_type=getattr(document, "mime_type", None),
1292
+ size=getattr(document, "size", None),
1293
+ )
1294
+ )
1295
+
1296
+ if req.download:
1297
+ from pathlib import Path
1298
+
1299
+ directory = Path(req.download).expanduser()
1300
+ directory.mkdir(parents=True, exist_ok=True)
1301
+ for document, row in zip(documents, rows, strict=True):
1302
+ try:
1303
+ saved = await client_of(ctx).download_media(document, file=str(directory))
1304
+ except Exception as exc:
1305
+ ctx.warn(f"could not download track {row.id}: {exc}")
1306
+ continue
1307
+ row.file = str(saved) if saved else None
1308
+
1309
+ return build_page(
1310
+ rows,
1311
+ op="user.music.list",
1312
+ kind=PageKind.PARTICIPANTS,
1313
+ state={"offset": offset + len(rows)},
1314
+ account=ctx.account,
1315
+ limit=limit,
1316
+ total=getattr(result, "count", None),
1317
+ )
1318
+
1319
+
1320
+ SPEC_MUSIC_LIST = OperationSpec(
1321
+ id="user.music.list",
1322
+ request=MusicListReq,
1323
+ response=Page[MusicTrack],
1324
+ impl=music_list,
1325
+ summary="Music a user pinned to their profile",
1326
+ description=(
1327
+ "An empty list is not evidence: `inputPrivacyKeySavedMusic` may "
1328
+ "simply not include us. Managing your own is `profile music`."
1329
+ ),
1330
+ paginated=PageKind.PARTICIPANTS,
1331
+ rate_class="file",
1332
+ timeout_s=300,
1333
+ columns=("id", "title", "performer", "duration"),
1334
+ headers=("Id", "Title", "Performer", "Seconds"),
1335
+ example={"items": [{"id": 991, "title": "Nocturne", "performer": "Chopin"}], "has_more": False},
1336
+ example_args="user music list @alice",
1337
+ covers=("contacts-users.user-saved-music",),
1338
+ )
1339
+
1340
+
1341
+ class PersonalChannelReq(Request):
1342
+ user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Whose profile.")]
1343
+
1344
+
1345
+ async def personal_channel(ctx: OpContext, req: PersonalChannelReq) -> PersonalChannel:
1346
+ """The channel a user pinned to their profile, with its latest posts.
1347
+
1348
+ `messages.getPersonalChannelHistory` returns the posts without joining
1349
+ the channel and without resolving it separately, which is the only reason
1350
+ this is one command rather than three.
1351
+ """
1352
+ from telethon.tl.functions import messages as mfn
1353
+ from telethon.tl.functions import users as ufn
1354
+
1355
+ limit = int(getattr(ctx, "limit", None) or 5)
1356
+ target = await input_user(ctx, req.user)
1357
+ answer = await client_of(ctx)(ufn.GetFullUserRequest(id=target))
1358
+ full = getattr(answer, "full_user", None)
1359
+ channel_id = getattr(full, "personal_channel_id", None)
1360
+ user_id = int(getattr(target, "user_id", 0) or 0)
1361
+ if not channel_id:
1362
+ raise NotFoundError("that user has no personal channel pinned to their profile")
1363
+
1364
+ channel = None
1365
+ for entity in getattr(answer, "chats", None) or []:
1366
+ if int(getattr(entity, "id", 0) or 0) == int(channel_id):
1367
+ channel = entity_to_peer(entity)
1368
+
1369
+ history = await client_of(ctx)(
1370
+ mfn.GetPersonalChannelHistoryRequest(
1371
+ user_id=target, limit=limit, max_id=0, min_id=0, hash=0
1372
+ )
1373
+ )
1374
+ posts = [
1375
+ message_to_model(message, chat_id=channel.id if channel else None)
1376
+ for message in getattr(history, "messages", None) or []
1377
+ if getattr(message, "id", None) is not None
1378
+ ]
1379
+ return PersonalChannel(
1380
+ user_id=user_id,
1381
+ channel=channel,
1382
+ msg_id=getattr(full, "personal_channel_message", None),
1383
+ posts=posts,
1384
+ )
1385
+
1386
+
1387
+ SPEC_PERSONAL_CHANNEL_GET = OperationSpec(
1388
+ id="user.personal-channel.get",
1389
+ request=PersonalChannelReq,
1390
+ response=PersonalChannel,
1391
+ impl=personal_channel,
1392
+ summary="The channel a user pinned to their profile, with its latest posts",
1393
+ description="Setting your OWN personal channel is `profile update --personal-channel`.",
1394
+ columns=("channel.id", "channel.title", "msg_id"),
1395
+ example={
1396
+ "user_id": 777123,
1397
+ "channel": {"id": -1001234, "raw_id": 1234, "kind": "channel", "title": "Alice writes"},
1398
+ "posts": [],
1399
+ },
1400
+ example_args="user personal-channel get @alice",
1401
+ covers=(
1402
+ "contacts-users.user-personal-channel",
1403
+ "dialogs.personal-channel-preview",
1404
+ "groups-channels-admin.personal-channel",
1405
+ ),
1406
+ )