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/profile.py ADDED
@@ -0,0 +1,1481 @@
1
+ """The `profile` group: Settings ▸ Edit Profile, and everything on it.
2
+
3
+ Seven sub-nouns, one subject — *how I appear to other people*: the name and
4
+ bio, the usernames (including the Fragment collectibles), the avatar history,
5
+ the accent colours, the emoji status, the presence switch and the public link.
6
+
7
+ Three things here are corrections rather than features.
8
+
9
+ * **`profile get` fetches `users.getFullUser`.** v1 called `get_me()` and
10
+ hard-coded `bio` to `""`, so every agent that read a bio read a lie. The
11
+ full user is where the bio, the birthday, the personal channel, the gift
12
+ counters and the Star rating live, and one command answers with all of it.
13
+ * **`profile photo set` uploads the file itself.** v1 called
14
+ `client.upload_profile_photo()`, which does not exist in Telethon 1.44, so
15
+ the command could never have worked at all. The real path is `upload_file`
16
+ followed by raw `photos.uploadProfilePhoto`.
17
+ * **`profile update` is one command over three RPCs.** The GUI shows one Edit
18
+ Profile screen; `account.updateProfile`, `account.updateBirthday` and
19
+ `account.updatePersonalChannel` are the server's decomposition, not a
20
+ vocabulary an agent should have to learn.
21
+
22
+ Telethon is imported inside functions, never at module scope (§2.2).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ from pathlib import Path
29
+ from typing import Annotated, Any
30
+
31
+ from tlgr.core.errors import NotFoundError, UsageError
32
+ from tlgr.core.pagination import PageKind, build_page
33
+ from tlgr.core.timefmt import fmt_dt, parse_dt, to_unix
34
+ from tlgr.models.base import Request
35
+ from tlgr.models.contact import MusicTrack, ProfilePhoto
36
+ from tlgr.models.media import WallpaperInstalled
37
+ from tlgr.models.page import Page
38
+ from tlgr.models.peer import PeerRef
39
+ from tlgr.models.profile import (
40
+ AdminedChannel,
41
+ ColorPalette,
42
+ ColorSet,
43
+ EmojiStatusItem,
44
+ EmojiStatusSet,
45
+ PhotosDeleted,
46
+ PresenceSet,
47
+ ProfileFull,
48
+ ProfileLink,
49
+ ProfilePhotoSet,
50
+ ProfileUpdated,
51
+ ProfileUsername,
52
+ UsernameSet,
53
+ )
54
+ from tlgr.ops import _settings
55
+ from tlgr.ops._common import client, window
56
+ from tlgr.ops._params import arg, opt
57
+ from tlgr.ops._spec import OpContext, OperationSpec
58
+
59
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
60
+
61
+ #: Palette ids 0-6 carry no colours of their own: every official client draws
62
+ #: red, orange, violet, green, cyan, blue and pink for them. Saying so is the
63
+ #: difference between "no colours" and "the built-in seven".
64
+ _BUILTIN_PALETTES = ("red", "orange", "violet", "green", "cyan", "blue", "pink")
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # profile get
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ def _usernames(user: Any) -> list[ProfileUsername]:
73
+ """`user.usernames`, or the single `username` when the vector is absent.
74
+
75
+ The main handle is the first *active* entry, which is the rule links
76
+ resolve by; `editable` marks the basic (non-collectible) name, and the
77
+ two are different facts that a flattened string loses.
78
+ """
79
+ rows: list[ProfileUsername] = []
80
+ for entry in getattr(user, "usernames", None) or []:
81
+ rows.append(
82
+ ProfileUsername(
83
+ username=str(getattr(entry, "username", "") or ""),
84
+ active=bool(getattr(entry, "active", False)),
85
+ editable=bool(getattr(entry, "editable", False)),
86
+ )
87
+ )
88
+ if not rows and getattr(user, "username", None):
89
+ rows.append(ProfileUsername(username=str(user.username), active=True, editable=True))
90
+ for row in rows:
91
+ if row.active:
92
+ row.main = True
93
+ break
94
+ return rows
95
+
96
+
97
+ def _birthday_text(raw: Any) -> str | None:
98
+ """`birthday` as `DD-MM` or `DD-MM-YYYY`, the same spelling `--birthday` takes."""
99
+ if raw is None:
100
+ return None
101
+ day = int(getattr(raw, "day", 0) or 0)
102
+ month = int(getattr(raw, "month", 0) or 0)
103
+ year = getattr(raw, "year", None)
104
+ return f"{day:02d}-{month:02d}" + (f"-{int(year)}" if year else "")
105
+
106
+
107
+ def _birthday_tl(text: str | None) -> Any:
108
+ """The inverse. `none` clears it, which the API spells "send no birthday"."""
109
+ from telethon.tl import types
110
+
111
+ if text is None or text.strip().lower() in ("none", "clear", ""):
112
+ return None
113
+ parts = [part for part in text.replace("/", "-").replace(".", "-").split("-") if part]
114
+ if len(parts) not in (2, 3) or not all(part.isdigit() for part in parts):
115
+ raise UsageError("--birthday takes DD-MM, DD-MM-YYYY or 'none'", field="birthday")
116
+ day, month = int(parts[0]), int(parts[1])
117
+ if not (1 <= day <= 31 and 1 <= month <= 12):
118
+ raise UsageError(f"{text!r} is not a date", field="birthday")
119
+ return types.Birthday(day=day, month=month, year=int(parts[2]) if len(parts) == 3 else None)
120
+
121
+
122
+ def _disallowed_gifts(raw: Any) -> list[str]:
123
+ """`disallowedGiftsSettings` as the keyword list `privacy global set` takes."""
124
+ names = {
125
+ "disallow_unlimited_stargifts": "unlimited",
126
+ "disallow_limited_stargifts": "limited",
127
+ "disallow_unique_stargifts": "unique",
128
+ "disallow_premium_gifts": "premium",
129
+ "disallow_stargifts_from_channels": "from-channels",
130
+ }
131
+ if raw is None:
132
+ return []
133
+ return sorted(word for field, word in names.items() if getattr(raw, field, False))
134
+
135
+
136
+ def _emoji_status(raw: Any, into: ProfileFull) -> None:
137
+ name = type(raw).__name__
138
+ if name == "EmojiStatus":
139
+ into.emoji_status = int(getattr(raw, "document_id", 0) or 0)
140
+ elif name == "EmojiStatusCollectible":
141
+ into.emoji_status = int(getattr(raw, "document_id", 0) or 0)
142
+ into.emoji_status_collectible_id = int(getattr(raw, "collectible_id", 0) or 0)
143
+ else:
144
+ return
145
+ into.emoji_status_until = fmt_dt(getattr(raw, "until", None))
146
+
147
+
148
+ def _peer_color(raw: Any) -> tuple[int | None, int | None, int | None]:
149
+ """`(palette id, collectible id, background emoji id)` from a `PeerColor`."""
150
+ if raw is None:
151
+ return None, None, None
152
+ if type(raw).__name__ == "PeerColorCollectible":
153
+ return (
154
+ None,
155
+ int(getattr(raw, "collectible_id", 0) or 0),
156
+ getattr(raw, "background_emoji_id", None),
157
+ )
158
+ return getattr(raw, "color", None), None, getattr(raw, "background_emoji_id", None)
159
+
160
+
161
+ class GetReq(Request):
162
+ full: Annotated[
163
+ bool,
164
+ opt("--full/--no-full", help="Also fetch users.getFullUser (bio, birthday, counters)."),
165
+ ] = True
166
+ refresh: Annotated[
167
+ bool, opt("--refresh", help="Force the userFull fetch even with --no-full.")
168
+ ] = False
169
+
170
+
171
+ async def get(ctx: OpContext, req: GetReq) -> ProfileFull:
172
+ """My own profile, including the fields only `userFull` carries.
173
+
174
+ `--no-full` exists for the hot path — a script that only needs the id and
175
+ the name should not pay for a second round trip — but the default is
176
+ `--full`, because v1's default was a `bio` that was always `""`.
177
+ """
178
+ from telethon.tl import types
179
+ from telethon.tl.functions import users as fn
180
+
181
+ handle = client(ctx)
182
+ me = await handle.get_me()
183
+ profile = ProfileFull(
184
+ id=int(getattr(me, "id", 0) or 0),
185
+ first_name=str(getattr(me, "first_name", "") or ""),
186
+ last_name=str(getattr(me, "last_name", "") or ""),
187
+ username=getattr(me, "username", None),
188
+ usernames=_usernames(me),
189
+ phone=getattr(me, "phone", None),
190
+ premium=bool(getattr(me, "premium", False)),
191
+ bot=bool(getattr(me, "bot", False)),
192
+ photo_id=getattr(getattr(me, "photo", None), "photo_id", None),
193
+ )
194
+ _emoji_status(getattr(me, "emoji_status", None), profile)
195
+ profile.color, profile.color_collectible_id, profile.background_emoji_id = _peer_color(
196
+ getattr(me, "color", None)
197
+ )
198
+ profile.profile_color, _, profile.profile_background_emoji_id = _peer_color(
199
+ getattr(me, "profile_color", None)
200
+ )
201
+ if not req.full and not req.refresh:
202
+ return profile
203
+
204
+ # tlgr holds no `userFull` cache of its own — the daemon caches peers,
205
+ # not profiles — so the fetch below *is* the refresh. `--refresh` exists
206
+ # so a caller that assumed a cache gets the fresh answer it wanted rather
207
+ # than a flag that silently means nothing.
208
+ answer = await handle(fn.GetFullUserRequest(id=types.InputUserSelf()))
209
+ full = getattr(answer, "full_user", None)
210
+ if full is None: # pragma: no cover - the server always sends one
211
+ return profile
212
+ profile.bio = str(getattr(full, "about", "") or "")
213
+ profile.birthday = _birthday_text(getattr(full, "birthday", None))
214
+ profile.personal_channel_id = getattr(full, "personal_channel_id", None)
215
+ if profile.personal_channel_id is not None:
216
+ found = _settings.entity_map(answer).get(int(profile.personal_channel_id))
217
+ profile.personal_channel = _settings.peer_model(found)
218
+ profile.ttl_period = getattr(full, "ttl_period", None)
219
+ profile.sponsored_enabled = getattr(full, "sponsored_enabled", None)
220
+ profile.stargifts_count = getattr(full, "stargifts_count", None)
221
+ rating = getattr(full, "stars_rating", None)
222
+ profile.stars_rating = getattr(rating, "level", None) if rating is not None else None
223
+ profile.disallowed_gifts = _disallowed_gifts(getattr(full, "disallowed_gifts", None))
224
+ tab = getattr(full, "main_tab", None)
225
+ if tab is not None:
226
+ profile.main_tab = type(tab).__name__.removeprefix("ProfileTab").lower() or None
227
+ fallback = getattr(full, "fallback_photo", None)
228
+ profile.fallback_photo_id = getattr(fallback, "id", None)
229
+ profile.common_chats_count = getattr(full, "common_chats_count", None)
230
+ return profile
231
+
232
+
233
+ SPEC_GET = OperationSpec(
234
+ id="profile.get",
235
+ request=GetReq,
236
+ response=ProfileFull,
237
+ impl=get,
238
+ summary="Show my own profile (bio, birthday, business, gifts and colours included)",
239
+ description=(
240
+ 'v1 answered from `get_me()` alone and reported `bio: ""` for every '
241
+ "account, whether or not one was set. This fetches `users.getFullUser` "
242
+ "as well, so an absent bio and an empty bio are different answers."
243
+ ),
244
+ legacy_paths=("profile get",),
245
+ idempotent=True,
246
+ columns=("id", "first_name", "last_name", "username", "phone", "premium"),
247
+ headers=("ID", "First", "Last", "Username", "Phone", "Premium"),
248
+ example={
249
+ "id": 4242,
250
+ "first_name": "Ada",
251
+ "last_name": "Lovelace",
252
+ "username": "ada",
253
+ "phone": "+989123456789",
254
+ "premium": True,
255
+ "bio": "counting on it",
256
+ "birthday": "10-12",
257
+ },
258
+ example_args="profile get",
259
+ covers=("profile.main-tab", "profile.set-bio", "profile.view-own"),
260
+ covers_partial=("profile.usernames-list",),
261
+ coverage_note="The per-username detail (active, collectible) is `profile username list`.",
262
+ tags=frozenset({"agent-safe"}),
263
+ )
264
+
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # profile update
268
+ # ---------------------------------------------------------------------------
269
+
270
+
271
+ class UpdateReq(Request):
272
+ first_name: Annotated[str | None, opt("--first-name", metavar="TEXT", help="First name.")] = (
273
+ None
274
+ )
275
+ last_name: Annotated[
276
+ str | None, opt("--last-name", metavar="TEXT", help="Last name ('' clears it).")
277
+ ] = None
278
+ bio: Annotated[str | None, opt("--bio", metavar="TEXT", help="About text.")] = None
279
+ birthday: Annotated[
280
+ str | None, opt("--birthday", metavar="DATE", help="DD-MM, DD-MM-YYYY, or 'none'.")
281
+ ] = None
282
+ channel: Annotated[
283
+ str | None,
284
+ opt("--channel", metavar="CHAT", help="Personal channel to show; 'none' unlinks."),
285
+ ] = None
286
+ photo: Annotated[
287
+ str | None, opt("--photo", metavar="PATH", kind="path", help="Shortcut for photo set.")
288
+ ] = None
289
+
290
+
291
+ async def update(ctx: OpContext, req: UpdateReq) -> ProfileUpdated:
292
+ """Edit my profile: names, bio, birthday, personal channel, photo.
293
+
294
+ One command, up to four RPCs, and only the ones the caller's flags need.
295
+ An empty string is a real value — `--last-name ""` clears the surname,
296
+ which is why the fields are `str | None` and not truthiness tests.
297
+ """
298
+ from telethon.tl import types
299
+ from telethon.tl.functions import account as fn
300
+
301
+ handle = client(ctx)
302
+ result = ProfileUpdated()
303
+
304
+ if req.first_name is not None or req.last_name is not None or req.bio is not None:
305
+ await handle(
306
+ fn.UpdateProfileRequest(
307
+ first_name=req.first_name, last_name=req.last_name, about=req.bio
308
+ )
309
+ )
310
+ for name, value in (
311
+ ("first_name", req.first_name),
312
+ ("last_name", req.last_name),
313
+ ("bio", req.bio),
314
+ ):
315
+ if value is not None:
316
+ setattr(result, name, value)
317
+ result.changed.append(name)
318
+
319
+ if req.birthday is not None:
320
+ await handle(fn.UpdateBirthdayRequest(birthday=_birthday_tl(req.birthday)))
321
+ result.birthday = _birthday_text(_birthday_tl(req.birthday))
322
+ result.changed.append("birthday")
323
+
324
+ if req.channel is not None:
325
+ if req.channel.strip().lower() in ("none", "clear", ""):
326
+ channel: Any = types.InputChannelEmpty()
327
+ result.personal_channel_id = None
328
+ else:
329
+ from tlgr.ops._common import input_channel
330
+
331
+ peer = await _settings.resolve(ctx, req.channel)
332
+ channel = input_channel(peer)
333
+ result.personal_channel_id = _settings.peer_of(peer)
334
+ await handle(fn.UpdatePersonalChannelRequest(channel=channel))
335
+ result.changed.append("personal_channel")
336
+
337
+ if req.photo is not None:
338
+ photo = await photo_set(ctx, PhotoSetReq(file=req.photo))
339
+ result.photo_id = photo.photo_id
340
+ result.changed.append("photo")
341
+
342
+ if not result.changed:
343
+ raise UsageError(
344
+ "nothing to change: give --first-name, --last-name, --bio, "
345
+ "--birthday, --channel or --photo",
346
+ field="first_name",
347
+ )
348
+ ctx.emit("profile_updated", {"changed": result.changed})
349
+ return result
350
+
351
+
352
+ SPEC_UPDATE = OperationSpec(
353
+ id="profile.update",
354
+ request=UpdateReq,
355
+ response=ProfileUpdated,
356
+ impl=update,
357
+ summary="Edit my profile: names, bio, birthday, personal channel, photo",
358
+ description=(
359
+ "`changed` names exactly the fields that were written, because the "
360
+ "command spans four RPCs and a report that lists what you did not ask "
361
+ "for is one nobody can act on."
362
+ ),
363
+ aliases=("profile.set",),
364
+ legacy_paths=("profile update",),
365
+ mutating=True,
366
+ columns=("first_name", "last_name", "bio", "changed"),
367
+ headers=("First", "Last", "Bio", "Changed"),
368
+ example={"first_name": "Ada", "bio": "counting on it", "changed": ["first_name", "bio"]},
369
+ example_args='profile update --bio "counting on it"',
370
+ covers=("profile.birthday-set", "profile.set-name"),
371
+ covers_partial=("profile.personal-channel", "profile.photo-upload", "profile.set-bio"),
372
+ coverage_note=(
373
+ "Reading these back is `profile get`; the avatar's own flags live on "
374
+ "`profile photo set`, and the eligible channels on `profile channel list`."
375
+ ),
376
+ tags=frozenset({"visible-to-others"}),
377
+ )
378
+
379
+
380
+ # ---------------------------------------------------------------------------
381
+ # profile username list / set
382
+ # ---------------------------------------------------------------------------
383
+
384
+
385
+ class UsernameListReq(Request):
386
+ pass
387
+
388
+
389
+ async def username_list(ctx: OpContext, req: UsernameListReq) -> Page[ProfileUsername]:
390
+ """Every username on this account, collectibles included."""
391
+ rows = _usernames(await client(ctx).get_me())
392
+ return Page(items=rows, has_more=False, total=len(rows))
393
+
394
+
395
+ SPEC_USERNAME_LIST = OperationSpec(
396
+ id="profile.username.list",
397
+ request=UsernameListReq,
398
+ response=Page[ProfileUsername],
399
+ impl=username_list,
400
+ summary="List my usernames, including Fragment collectibles",
401
+ paginated=PageKind.LOCAL,
402
+ idempotent=True,
403
+ columns=("username", "active", "editable", "main"),
404
+ headers=("Username", "Active", "Editable", "Main"),
405
+ example={
406
+ "items": [
407
+ {"username": "ada", "active": True, "editable": True, "main": True},
408
+ {"username": "lovelace", "active": False},
409
+ ]
410
+ },
411
+ example_args="profile username list",
412
+ covers=("profile.usernames-list",),
413
+ tags=frozenset({"agent-safe"}),
414
+ )
415
+
416
+
417
+ class UsernameSetReq(Request):
418
+ name: Annotated[
419
+ str | None, arg(0, metavar="NAME", required=False, help="The username to act on.")
420
+ ] = None
421
+ check: Annotated[bool, opt("--check", help="Only test availability.")] = False
422
+ clear: Annotated[bool, opt("--clear", help="Remove the main username.")] = False
423
+ on: Annotated[bool, opt("--on", help="Activate a collectible username.")] = False
424
+ off: Annotated[bool, opt("--off", help="Deactivate a collectible username.")] = False
425
+ order: Annotated[
426
+ str | None,
427
+ opt("--order", metavar="LIST", help="Comma-separated: every active username, in order."),
428
+ ] = None
429
+
430
+
431
+ async def username_set(ctx: OpContext, req: UsernameSetReq) -> UsernameSet:
432
+ """Set, check, clear, activate/deactivate or reorder usernames.
433
+
434
+ `USERNAME_PURCHASE_AVAILABLE` is the interesting failure: the name is not
435
+ taken, it simply only exists for sale on Fragment. Reporting that as
436
+ `purchasable: true` rather than as an opaque error is the difference
437
+ between "pick another name" and "you can have this one, for money".
438
+ """
439
+ from telethon.tl.functions import account as fn
440
+
441
+ handle = client(ctx)
442
+
443
+ if req.order is not None:
444
+ order = [part.strip().lstrip("@") for part in req.order.split(",") if part.strip()]
445
+ if not order:
446
+ raise UsageError("--order wants every active username, in order", field="order")
447
+ await handle(fn.ReorderUsernamesRequest(order=order))
448
+ return UsernameSet(usernames=_usernames(await handle.get_me()))
449
+
450
+ if req.on or req.off:
451
+ if not req.name:
452
+ raise UsageError("--on/--off need the username to toggle", field="name")
453
+ await handle(fn.ToggleUsernameRequest(username=req.name.lstrip("@"), active=bool(req.on)))
454
+ return UsernameSet(
455
+ username=req.name.lstrip("@"),
456
+ active=bool(req.on),
457
+ usernames=_usernames(await handle.get_me()),
458
+ )
459
+
460
+ if req.clear:
461
+ await handle(fn.UpdateUsernameRequest(username=""))
462
+ return UsernameSet(username=None, active=False, usernames=_usernames(await handle.get_me()))
463
+
464
+ if not req.name:
465
+ raise UsageError("give a username, or --clear / --order", field="name")
466
+ wanted = req.name.lstrip("@")
467
+
468
+ if req.check:
469
+ try:
470
+ free = bool(await handle(fn.CheckUsernameRequest(username=wanted)))
471
+ except Exception as exc:
472
+ if "USERNAME_PURCHASE_AVAILABLE" in f"{type(exc).__name__} {exc}".upper():
473
+ return UsernameSet(username=wanted, available=False, purchasable=True)
474
+ raise
475
+ return UsernameSet(username=wanted, available=free)
476
+
477
+ await handle(fn.UpdateUsernameRequest(username=wanted))
478
+ ctx.emit("profile_username", {"username": wanted})
479
+ return UsernameSet(username=wanted, active=True, usernames=_usernames(await handle.get_me()))
480
+
481
+
482
+ SPEC_USERNAME_SET = OperationSpec(
483
+ id="profile.username.set",
484
+ request=UsernameSetReq,
485
+ response=UsernameSet,
486
+ impl=username_set,
487
+ summary="Set, check, clear, activate/deactivate or reorder my usernames",
488
+ description=(
489
+ "`--check` writes nothing. A name the server answers "
490
+ "`USERNAME_PURCHASE_AVAILABLE` for is reported as `purchasable`, not "
491
+ "as taken: it exists only on Fragment."
492
+ ),
493
+ mutating=True,
494
+ idempotent=True,
495
+ rate_class="send",
496
+ columns=("username", "active", "available", "purchasable"),
497
+ headers=("Username", "Active", "Available", "On Fragment"),
498
+ example={"username": "ada", "active": True},
499
+ example_args="profile username set ada",
500
+ covers=("profile.set-username", "profile.username-reorder", "profile.username-toggle"),
501
+ tags=frozenset({"visible-to-others"}),
502
+ )
503
+
504
+
505
+ # ---------------------------------------------------------------------------
506
+ # profile photo list / set / delete
507
+ # ---------------------------------------------------------------------------
508
+
509
+
510
+ class PhotoListReq(Request):
511
+ download: Annotated[
512
+ str | None,
513
+ opt("--download", metavar="DIR", kind="path", help="Download the listed photos here."),
514
+ ] = None
515
+
516
+
517
+ async def photo_list(ctx: OpContext, req: PhotoListReq) -> Page[ProfilePhoto]:
518
+ """My profile-photo history, newest first.
519
+
520
+ Also the source of the ids `profile photo set --photo-id` and `profile
521
+ photo delete` take: a photo id alone is useless without the access hash
522
+ and file reference this listing put in the session's cache, which is why
523
+ reusing an id from a previous run fails with `FILE_REFERENCE_EXPIRED`.
524
+ """
525
+ from telethon.tl import types
526
+ from telethon.tl.functions import photos as fn
527
+
528
+ limit, state = window(ctx, "profile.photo.list", PageKind.PARTICIPANTS, default=20)
529
+ offset = int(state.get("offset", 0) or 0)
530
+ handle = client(ctx)
531
+ result = await handle(
532
+ fn.GetUserPhotosRequest(user_id=types.InputUserSelf(), offset=offset, max_id=0, limit=limit)
533
+ )
534
+ photos = list(getattr(result, "photos", None) or [])
535
+ me = await handle.get_me()
536
+ current = getattr(getattr(me, "photo", None), "photo_id", None)
537
+
538
+ rows = [
539
+ ProfilePhoto(
540
+ id=int(getattr(photo, "id", 0) or 0),
541
+ date=fmt_dt(getattr(photo, "date", None)),
542
+ date_unix=to_unix(getattr(photo, "date", None)),
543
+ sizes=[
544
+ str(getattr(size, "type", ""))
545
+ for size in getattr(photo, "sizes", None) or []
546
+ if getattr(size, "type", None)
547
+ ],
548
+ video=bool(getattr(photo, "video_sizes", None)),
549
+ dc_id=getattr(photo, "dc_id", None),
550
+ current=current is not None and int(getattr(photo, "id", 0) or 0) == int(current),
551
+ )
552
+ for photo in photos
553
+ ]
554
+
555
+ if req.download:
556
+ directory = Path(os.path.expanduser(req.download))
557
+ directory.mkdir(parents=True, exist_ok=True)
558
+ for photo, row in zip(photos, rows, strict=True):
559
+ try:
560
+ saved = await handle.download_media(photo, file=str(directory / f"{row.id}.jpg"))
561
+ except Exception as exc:
562
+ ctx.warn(f"could not download photo {row.id}: {exc}")
563
+ continue
564
+ row.file = str(saved) if saved else None
565
+
566
+ return build_page(
567
+ rows,
568
+ op="profile.photo.list",
569
+ kind=PageKind.PARTICIPANTS,
570
+ state={"offset": offset + len(rows)},
571
+ account=ctx.account,
572
+ limit=limit,
573
+ total=getattr(result, "count", None),
574
+ )
575
+
576
+
577
+ SPEC_PHOTO_LIST = OperationSpec(
578
+ id="profile.photo.list",
579
+ request=PhotoListReq,
580
+ response=Page[ProfilePhoto],
581
+ impl=photo_list,
582
+ summary="List (and optionally download) my profile photos",
583
+ paginated=PageKind.PARTICIPANTS,
584
+ idempotent=True,
585
+ rate_class="file",
586
+ timeout_s=300,
587
+ columns=("id", "date", "video", "current"),
588
+ headers=("Photo", "Taken", "Video", "Current"),
589
+ example={
590
+ "items": [{"id": 55123, "date": "2026-08-01T10:00:00Z", "video": False, "current": True}],
591
+ "has_more": False,
592
+ },
593
+ example_args="profile photo list",
594
+ covers=("profile.photo-list", "profile.photos-list-history"),
595
+ tags=frozenset({"agent-safe"}),
596
+ )
597
+
598
+
599
+ class PhotoSetReq(Request):
600
+ file: Annotated[
601
+ str | None,
602
+ arg(0, metavar="FILE", required=False, kind="path", help="Image or video to upload."),
603
+ ] = None
604
+ video: Annotated[bool, opt("--video", help="Treat the file as an animated avatar.")] = False
605
+ start_ts: Annotated[
606
+ float | None, opt("--start-ts", metavar="SECONDS", help="Cover frame of a video avatar.")
607
+ ] = None
608
+ photo_id: Annotated[
609
+ str | None, opt("--photo-id", metavar="ID", help="Re-use a photo from `photo list`.")
610
+ ] = None
611
+ emoji: Annotated[
612
+ str | None, opt("--emoji", metavar="ID", help="Build the avatar from a custom emoji.")
613
+ ] = None
614
+ colors: Annotated[
615
+ str | None, opt("--colors", metavar="LIST", help="Background gradient for --emoji.")
616
+ ] = None
617
+ sticker_set: Annotated[
618
+ str | None,
619
+ opt("--sticker-set", metavar="SET:ID", help="Sticker markup instead of a custom emoji."),
620
+ ] = None
621
+ fallback: Annotated[
622
+ bool, opt("--fallback", help="Set the public fallback photo instead of the main one.")
623
+ ] = False
624
+
625
+
626
+ async def photo_set(ctx: OpContext, req: PhotoSetReq) -> ProfilePhotoSet:
627
+ """Set my avatar from a file, a video, an older photo or a custom emoji.
628
+
629
+ The fallback photo is what people who may *not* see the real avatar get,
630
+ so it only means anything next to a restrictive `privacy set
631
+ profile-photo` rule — setting one without that rule changes nothing
632
+ anybody will ever see.
633
+ """
634
+ from telethon.tl import types
635
+ from telethon.tl.functions import photos as fn
636
+
637
+ handle = client(ctx)
638
+
639
+ if req.photo_id:
640
+ if not req.photo_id.strip().lstrip("-").isdigit():
641
+ raise UsageError("--photo-id wants a photo id from `profile photo list`", field="photo")
642
+ photo = await _input_photo(ctx, int(req.photo_id))
643
+ result = await handle(fn.UpdateProfilePhotoRequest(id=photo, fallback=req.fallback or None))
644
+ return ProfilePhotoSet(photo_id=_photo_id_of(result), fallback=req.fallback, is_video=False)
645
+
646
+ kwargs: dict[str, Any] = {"fallback": req.fallback or None}
647
+ markup: Any = None
648
+ colours = [
649
+ _settings.color_int(value, field="colors")
650
+ for value in (req.colors or "").split(",")
651
+ if value.strip()
652
+ ]
653
+ if req.emoji:
654
+ if not req.emoji.strip().isdigit():
655
+ raise UsageError("--emoji wants a custom-emoji document id", field="emoji")
656
+ markup = types.VideoSizeEmojiMarkup(
657
+ emoji_id=int(req.emoji), background_colors=[c for c in colours if c is not None]
658
+ )
659
+ elif req.sticker_set:
660
+ from tlgr.ops import _media
661
+
662
+ short, _, sticker_id = req.sticker_set.rpartition(":")
663
+ if not short or not sticker_id.isdigit():
664
+ raise UsageError("--sticker-set wants '<set>:<sticker id>'", field="sticker_set")
665
+ markup = types.VideoSizeStickerMarkup(
666
+ stickerset=_media.sticker_set_ref(short, field="sticker_set"),
667
+ sticker_id=int(sticker_id),
668
+ background_colors=[c for c in colours if c is not None],
669
+ )
670
+ if markup is not None:
671
+ kwargs["video_emoji_markup"] = markup
672
+ else:
673
+ if not req.file:
674
+ raise UsageError("give a FILE, or --photo-id / --emoji / --sticker-set", field="file")
675
+ path = Path(os.path.expanduser(req.file))
676
+ if not path.exists():
677
+ raise UsageError(f"{req.file} does not exist", field="file")
678
+ upload = getattr(ctx, "upload_file", None)
679
+ if upload is None: # pragma: no cover - the daemon always supplies one
680
+ raise UsageError("this context cannot upload files")
681
+ handle_file = await upload(path)
682
+ if req.video:
683
+ kwargs["video"] = handle_file
684
+ kwargs["video_start_ts"] = req.start_ts
685
+ else:
686
+ kwargs["file"] = handle_file
687
+
688
+ result = await handle(fn.UploadProfilePhotoRequest(**kwargs))
689
+ ctx.emit("profile_photo", {"fallback": req.fallback})
690
+ return ProfilePhotoSet(
691
+ photo_id=_photo_id_of(result),
692
+ is_video=bool(req.video),
693
+ fallback=req.fallback,
694
+ emoji_markup=markup is not None,
695
+ )
696
+
697
+
698
+ def _photo_id_of(result: Any) -> int | None:
699
+ photo = getattr(result, "photo", None)
700
+ value = getattr(photo, "id", None)
701
+ return int(value) if value is not None else None
702
+
703
+
704
+ async def _input_photo(ctx: OpContext, photo_id: int) -> Any:
705
+ """An `InputPhoto` for one of my own photos, with its live file reference.
706
+
707
+ The access hash and file reference are only valid for the session that
708
+ fetched them, so the photo is looked up again rather than reconstructed
709
+ from an id a caller kept from yesterday.
710
+ """
711
+ from telethon.tl import types
712
+ from telethon.tl.functions import photos as fn
713
+
714
+ result = await client(ctx)(
715
+ fn.GetUserPhotosRequest(user_id=types.InputUserSelf(), offset=0, max_id=0, limit=100)
716
+ )
717
+ for photo in getattr(result, "photos", None) or []:
718
+ if int(getattr(photo, "id", 0) or 0) == photo_id:
719
+ return types.InputPhoto(
720
+ id=photo.id,
721
+ access_hash=photo.access_hash,
722
+ file_reference=getattr(photo, "file_reference", b"") or b"",
723
+ )
724
+ raise NotFoundError(f"photo {photo_id} is not in my photo history")
725
+
726
+
727
+ SPEC_PHOTO_SET = OperationSpec(
728
+ id="profile.photo.set",
729
+ request=PhotoSetReq,
730
+ response=ProfilePhotoSet,
731
+ impl=photo_set,
732
+ summary="Set my profile photo from a file, a video, an older photo or a custom emoji",
733
+ description=(
734
+ "v1's implementation called `client.upload_profile_photo()`, which "
735
+ "Telethon 1.44 does not have; this uploads the file and sends raw "
736
+ "`photos.uploadProfilePhoto`."
737
+ ),
738
+ mutating=True,
739
+ rate_class="file",
740
+ timeout_s=300,
741
+ columns=("photo_id", "is_video", "fallback"),
742
+ headers=("Photo", "Video", "Fallback"),
743
+ example={"photo_id": 55123, "is_video": False, "fallback": False},
744
+ example_args="profile photo set avatar.jpg",
745
+ covers=(
746
+ "profile.contact-personal-photo",
747
+ "profile.photo-emoji-markup",
748
+ "profile.photo-fallback",
749
+ "profile.photo-fallback-public",
750
+ "profile.photo-set",
751
+ "profile.photo-set-as-main",
752
+ "profile.photo-set-emoji-sticker",
753
+ "profile.photo-set-existing",
754
+ "profile.photo-set-video",
755
+ "profile.photo-upload",
756
+ "profile.photo-upload-video",
757
+ ),
758
+ tags=frozenset({"visible-to-others"}),
759
+ )
760
+
761
+
762
+ class PhotoDeleteReq(Request):
763
+ photo_id: Annotated[
764
+ tuple[str, ...],
765
+ arg(0, metavar="PHOTO_ID", required=False, variadic=True, help="Photos to delete."),
766
+ ] = ()
767
+ current: Annotated[
768
+ bool, opt("--current", help="Delete the current photo; the previous one is promoted.")
769
+ ] = False
770
+ every: Annotated[bool, opt("--every", help="Delete every profile photo.")] = False
771
+
772
+
773
+ async def photo_delete(ctx: OpContext, req: PhotoDeleteReq) -> PhotosDeleted:
774
+ """Delete profile photos.
775
+
776
+ Deleting the current one promotes the previous one, which is Telegram's
777
+ behaviour and not tlgr's: there is no "no avatar, but keep the history"
778
+ state short of `--every`.
779
+ """
780
+ from telethon.tl import types
781
+ from telethon.tl.functions import photos as fn
782
+
783
+ handle = client(ctx)
784
+ wanted: list[int] = []
785
+ if req.every or req.current:
786
+ result = await handle(
787
+ fn.GetUserPhotosRequest(user_id=types.InputUserSelf(), offset=0, max_id=0, limit=100)
788
+ )
789
+ photos = list(getattr(result, "photos", None) or [])
790
+ if req.current:
791
+ me = await handle.get_me()
792
+ current = getattr(getattr(me, "photo", None), "photo_id", None)
793
+ photos = [p for p in photos if current and int(p.id) == int(current)]
794
+ wanted = [int(photo.id) for photo in photos]
795
+ for value in req.photo_id:
796
+ if not str(value).lstrip("-").isdigit():
797
+ raise UsageError(f"{value!r} is not a photo id", field="photo_id")
798
+ wanted.append(int(value))
799
+ if not wanted:
800
+ if req.every or req.current:
801
+ return PhotosDeleted(deleted=0, already=True)
802
+ raise UsageError("give one or more photo ids, or --current / --every", field="photo_id")
803
+
804
+ inputs = [await _input_photo(ctx, photo_id) for photo_id in wanted]
805
+ deleted = await handle(fn.DeletePhotosRequest(id=inputs))
806
+ ctx.emit("profile_photo_deleted", {"photo_ids": wanted})
807
+ return PhotosDeleted(deleted=len(list(deleted or wanted)), photo_ids=wanted)
808
+
809
+
810
+ SPEC_PHOTO_DELETE = OperationSpec(
811
+ id="profile.photo.delete",
812
+ request=PhotoDeleteReq,
813
+ response=PhotosDeleted,
814
+ impl=photo_delete,
815
+ summary="Delete profile photos",
816
+ mutating=True,
817
+ destructive=True,
818
+ rate_class="send",
819
+ columns=("deleted", "photo_ids"),
820
+ headers=("Deleted", "Photos"),
821
+ example={"deleted": 1, "photo_ids": [55123]},
822
+ example_args="profile photo delete 55123",
823
+ covers=("profile.photo-delete",),
824
+ tags=frozenset({"visible-to-others"}),
825
+ )
826
+
827
+
828
+ # ---------------------------------------------------------------------------
829
+ # profile presence set
830
+ # ---------------------------------------------------------------------------
831
+
832
+
833
+ class PresenceSetReq(Request):
834
+ state: Annotated[str, arg(0, metavar="STATE", help="online or offline.")]
835
+
836
+
837
+ async def presence_set(ctx: OpContext, req: PresenceSetReq) -> PresenceSet:
838
+ """Go online or offline.
839
+
840
+ A daemon needs a *policy* here, not a default. Always reporting online
841
+ advertises that something is running around the clock; reading history
842
+ while reporting offline is the classic bot tell. tlgr therefore never
843
+ reports presence on its own — this command, and the `presence` config
844
+ key, are the only two things that do.
845
+ """
846
+ from telethon.tl.functions import account as fn
847
+
848
+ wanted = req.state.strip().lower()
849
+ if wanted not in ("online", "offline"):
850
+ raise UsageError("STATE is `online` or `offline`", field="state")
851
+ online = wanted == "online"
852
+ await client(ctx)(fn.UpdateStatusRequest(offline=not online))
853
+ ctx.emit("profile_presence", {"online": online})
854
+ return PresenceSet(online=online)
855
+
856
+
857
+ SPEC_PRESENCE_SET = OperationSpec(
858
+ id="profile.presence.set",
859
+ request=PresenceSetReq,
860
+ response=PresenceSet,
861
+ impl=presence_set,
862
+ summary="Go online or offline (account.updateStatus)",
863
+ aliases=("profile.online", "profile.offline"),
864
+ mutating=True,
865
+ idempotent=True,
866
+ rate_class="send",
867
+ columns=("online",),
868
+ headers=("Online",),
869
+ example={"online": True},
870
+ example_args="profile presence set online",
871
+ covers=("profile.online-status",),
872
+ tags=frozenset({"visible-to-others"}),
873
+ )
874
+
875
+
876
+ # ---------------------------------------------------------------------------
877
+ # profile status list / set
878
+ # ---------------------------------------------------------------------------
879
+
880
+
881
+ class StatusListReq(Request):
882
+ recent: Annotated[bool, opt("--recent", help="Recently used statuses.")] = False
883
+ default: Annotated[bool, opt("--default", help="Telegram's default set.")] = False
884
+ collectible: Annotated[
885
+ bool, opt("--collectible", help="Collectible gift statuses you may wear.")
886
+ ] = False
887
+ groups: Annotated[bool, opt("--groups", help="The category chips.")] = False
888
+ clear_recent: Annotated[bool, opt("--clear-recent", help="Clear the recent list.")] = False
889
+
890
+
891
+ async def status_list(ctx: OpContext, req: StatusListReq) -> Page[EmojiStatusItem]:
892
+ """Browse the emoji statuses this account may wear.
893
+
894
+ Four server lists behind one command, because the GUI shows them as four
895
+ tabs of one picker. The op is a read, so `--dry-run` does not
896
+ short-circuit it centrally — which is why `--clear-recent`, the single
897
+ write in here, checks `ctx.dry_run` itself rather than being exempt.
898
+ """
899
+ from telethon.tl.functions import account as fn
900
+ from telethon.tl.functions import messages as mfn
901
+
902
+ handle = client(ctx)
903
+ rows: list[EmojiStatusItem] = []
904
+
905
+ if req.clear_recent:
906
+ if getattr(ctx, "dry_run", False):
907
+ ctx.warn("--dry-run: the recent emoji-status list was left alone")
908
+ return Page(items=[], has_more=False, total=0)
909
+ await handle(fn.ClearRecentEmojiStatusesRequest())
910
+ return Page(items=[], has_more=False, total=0)
911
+
912
+ if req.groups:
913
+ result = await handle(mfn.GetEmojiStatusGroupsRequest(hash=0))
914
+ for group in getattr(result, "groups", None) or []:
915
+ title = str(getattr(group, "title", "") or "")
916
+ for document_id in getattr(group, "document_id", None) or []:
917
+ rows.append(EmojiStatusItem(document_id=int(document_id), group=title))
918
+ return Page(items=rows, has_more=False, total=len(rows))
919
+
920
+ wanted = [
921
+ ("recent", req.recent, fn.GetRecentEmojiStatusesRequest),
922
+ ("default", req.default, fn.GetDefaultEmojiStatusesRequest),
923
+ ("collectible", req.collectible, fn.GetCollectibleEmojiStatusesRequest),
924
+ ]
925
+ if not any(flag for _, flag, _ in wanted):
926
+ wanted = [(name, True, request) for name, _, request in wanted]
927
+
928
+ for name, flag, request in wanted:
929
+ if not flag:
930
+ continue
931
+ result = await handle(request(hash=0))
932
+ for status in getattr(result, "statuses", None) or []:
933
+ rows.append(
934
+ EmojiStatusItem(
935
+ document_id=int(getattr(status, "document_id", 0) or 0),
936
+ collectible_id=getattr(status, "collectible_id", None),
937
+ title=getattr(status, "title", None),
938
+ slug=getattr(status, "slug", None),
939
+ group=name,
940
+ until=fmt_dt(getattr(status, "until", None)),
941
+ )
942
+ )
943
+ return Page(items=rows, has_more=False, total=len(rows))
944
+
945
+
946
+ SPEC_STATUS_LIST = OperationSpec(
947
+ id="profile.status.list",
948
+ request=StatusListReq,
949
+ response=Page[EmojiStatusItem],
950
+ impl=status_list,
951
+ summary="Browse emoji-status suggestions (recent, default, themed groups, collectibles)",
952
+ description=(
953
+ "A read, with one exception: `--clear-recent` empties the recent "
954
+ "list. It honours `--dry-run` on its own rather than making the "
955
+ "whole listing a mutating operation."
956
+ ),
957
+ paginated=PageKind.LOCAL,
958
+ columns=("document_id", "collectible_id", "title", "group"),
959
+ headers=("Emoji", "Collectible", "Title", "List"),
960
+ example={"items": [{"document_id": 5301, "group": "recent"}], "has_more": False},
961
+ example_args="profile status list --recent",
962
+ covers=(
963
+ "emoji.status-lists",
964
+ "profile.emoji-status-collectible",
965
+ "profile.emoji-status-suggestions",
966
+ ),
967
+ )
968
+
969
+
970
+ class StatusSetReq(Request):
971
+ emoji: Annotated[
972
+ str | None,
973
+ arg(0, metavar="EMOJI", required=False, help="Document id, collectible:<id>, or 'none'."),
974
+ ] = None
975
+ until: Annotated[
976
+ str | None, opt("--until", metavar="WHEN", kind="datetime", help="Expire the status.")
977
+ ] = None
978
+ clear: Annotated[bool, opt("--clear", help="Remove the status.")] = False
979
+
980
+
981
+ async def status_set(ctx: OpContext, req: StatusSetReq) -> EmojiStatusSet:
982
+ """Set or clear my emoji status, including a collectible gift.
983
+
984
+ A collectible status and a collectible profile palette are mutually
985
+ exclusive on the server: setting one silently clears the other. tlgr says
986
+ so in the docs rather than pretending both can be worn.
987
+ """
988
+ from telethon.tl import types
989
+ from telethon.tl.functions import account as fn
990
+
991
+ until = parse_dt(req.until) if req.until else None
992
+ if req.clear or (req.emoji or "").strip().lower() in ("none", "clear"):
993
+ await client(ctx)(fn.UpdateEmojiStatusRequest(emoji_status=types.EmojiStatusEmpty()))
994
+ return EmojiStatusSet(cleared=True)
995
+ if not req.emoji:
996
+ raise UsageError("give an emoji document id, collectible:<id>, or --clear", field="emoji")
997
+
998
+ text = req.emoji.strip()
999
+ if text.lower().startswith("collectible:"):
1000
+ value = text.split(":", 1)[1]
1001
+ if not value.isdigit():
1002
+ raise UsageError("collectible:<id> wants a collectible id", field="emoji")
1003
+ status: Any = types.InputEmojiStatusCollectible(collectible_id=int(value), until=until)
1004
+ result = EmojiStatusSet(collectible_id=int(value))
1005
+ else:
1006
+ if not text.isdigit():
1007
+ raise UsageError("give a custom-emoji document id, or collectible:<id>", field="emoji")
1008
+ status = types.EmojiStatus(document_id=int(text), until=until)
1009
+ result = EmojiStatusSet(document_id=int(text))
1010
+ await client(ctx)(fn.UpdateEmojiStatusRequest(emoji_status=status))
1011
+ result.until = fmt_dt(until)
1012
+ result.until_unix = to_unix(until)
1013
+ ctx.emit("profile_status", {"document_id": result.document_id})
1014
+ return result
1015
+
1016
+
1017
+ SPEC_STATUS_SET = OperationSpec(
1018
+ id="profile.status.set",
1019
+ request=StatusSetReq,
1020
+ response=EmojiStatusSet,
1021
+ impl=status_set,
1022
+ summary="Set or clear my emoji status (including a collectible gift)",
1023
+ description=(
1024
+ "Premium only. A collectible status and a collectible message palette "
1025
+ "cannot both be worn: the server clears one when you set the other."
1026
+ ),
1027
+ mutating=True,
1028
+ idempotent=True,
1029
+ rate_class="send",
1030
+ columns=("document_id", "collectible_id", "until", "cleared"),
1031
+ headers=("Emoji", "Collectible", "Until", "Cleared"),
1032
+ example={"document_id": 5301, "until": "2026-09-10T00:00:00Z"},
1033
+ example_args="profile status set 5301 --until +7d",
1034
+ covers=("emoji.status-set", "profile.emoji-status"),
1035
+ covers_partial=("profile.emoji-status-collectible",),
1036
+ coverage_note="Browsing the wearable collectibles is `profile status list --collectible`.",
1037
+ tags=frozenset({"visible-to-others"}),
1038
+ )
1039
+
1040
+
1041
+ # ---------------------------------------------------------------------------
1042
+ # profile color list / set
1043
+ # ---------------------------------------------------------------------------
1044
+
1045
+
1046
+ class ColorListReq(Request):
1047
+ profile: Annotated[
1048
+ bool, opt("--profile", help="Profile-page palettes instead of name/message palettes.")
1049
+ ] = False
1050
+ emojis: Annotated[bool, opt("--emojis", help="Also list the default background emojis.")] = (
1051
+ False
1052
+ )
1053
+
1054
+
1055
+ async def color_list(ctx: OpContext, req: ColorListReq) -> Page[ColorPalette]:
1056
+ """The accent palettes this account may wear.
1057
+
1058
+ Ids 0-6 come back with no colours at all, because every client draws them
1059
+ from a built-in table. Reporting them as empty would read as "no colours
1060
+ available"; `builtin` plus the name is the honest answer.
1061
+ """
1062
+ from telethon.tl.functions import help as fn
1063
+
1064
+ handle = client(ctx)
1065
+ request = fn.GetPeerProfileColorsRequest if req.profile else fn.GetPeerColorsRequest
1066
+ result = await handle(request(hash=0))
1067
+ rows: list[ColorPalette] = []
1068
+ for option in getattr(result, "colors", None) or []:
1069
+ color_id = int(getattr(option, "color_id", 0) or 0)
1070
+ colors = getattr(option, "colors", None)
1071
+ dark = getattr(option, "dark_colors", None)
1072
+ rows.append(
1073
+ ColorPalette(
1074
+ color_id=color_id,
1075
+ colors=[_settings.color_text(v) for v in getattr(colors, "colors", None) or []],
1076
+ dark_colors=[_settings.color_text(v) for v in getattr(dark, "colors", None) or []],
1077
+ min_level=int(getattr(option, "channel_min_level", 0) or 0),
1078
+ channel_min_level=getattr(option, "channel_min_level", None),
1079
+ group_min_level=getattr(option, "group_min_level", None),
1080
+ hidden=bool(getattr(option, "hidden", False)),
1081
+ builtin=color_id < len(_BUILTIN_PALETTES),
1082
+ )
1083
+ )
1084
+ if req.emojis:
1085
+ from telethon.tl.functions import account as afn
1086
+
1087
+ emojis = await handle(afn.GetDefaultBackgroundEmojisRequest(hash=0))
1088
+ for document in getattr(emojis, "documents", None) or []:
1089
+ rows.append(ColorPalette(color_id=-1, colors=[str(getattr(document, "id", 0) or 0)]))
1090
+ return Page(items=rows, has_more=False, total=len(rows))
1091
+
1092
+
1093
+ SPEC_COLOR_LIST = OperationSpec(
1094
+ id="profile.color.list",
1095
+ request=ColorListReq,
1096
+ response=Page[ColorPalette],
1097
+ impl=color_list,
1098
+ summary="List the name and profile colour palettes",
1099
+ paginated=PageKind.LOCAL,
1100
+ idempotent=True,
1101
+ columns=("color_id", "colors", "dark_colors", "min_level", "hidden"),
1102
+ headers=("Id", "Light", "Dark", "Min level", "Hidden"),
1103
+ example={"items": [{"color_id": 5, "colors": ["#3FA3E8"], "min_level": 0}], "has_more": False},
1104
+ example_args="profile color list",
1105
+ covers=("profile.name-color", "profile.profile-color"),
1106
+ tags=frozenset({"agent-safe"}),
1107
+ )
1108
+
1109
+
1110
+ class ColorSetReq(Request):
1111
+ color: Annotated[
1112
+ str, arg(0, metavar="COLOR", help="Palette id, collectible:<slug|id>, or 'none'.")
1113
+ ]
1114
+ profile: Annotated[
1115
+ bool, opt("--profile", help="Change the profile-page colour, not the message colour.")
1116
+ ] = False
1117
+ emoji: Annotated[
1118
+ str | None, opt("--emoji", metavar="ID", help="Background custom-emoji document id.")
1119
+ ] = None
1120
+
1121
+
1122
+ async def color_set(ctx: OpContext, req: ColorSetReq) -> ColorSet:
1123
+ """Set my message accent colour, profile colour or collectible palette.
1124
+
1125
+ A collectible palette is a gift you own, and the server only accepts it
1126
+ for the *message* colour — `--profile` with one is refused here rather
1127
+ than sent and rejected with an error that names neither flag.
1128
+ """
1129
+ from telethon.tl import types
1130
+ from telethon.tl.functions import account as fn
1131
+
1132
+ text = req.color.strip()
1133
+ emoji = int(req.emoji) if req.emoji and req.emoji.isdigit() else None
1134
+ if text.lower() in ("none", "clear", "default"):
1135
+ await client(ctx)(fn.UpdateColorRequest(for_profile=req.profile or None, color=None))
1136
+ return ColorSet(for_profile=req.profile)
1137
+
1138
+ if text.lower().startswith("collectible:"):
1139
+ if req.profile:
1140
+ raise UsageError(
1141
+ "a collectible palette is only accepted for the message colour, not for --profile",
1142
+ field="color",
1143
+ )
1144
+ value = text.split(":", 1)[1]
1145
+ gift_id = int(value) if value.isdigit() else await _collectible_id(ctx, value)
1146
+ await client(ctx)(
1147
+ fn.UpdateColorRequest(color=types.InputPeerColorCollectible(collectible_id=gift_id))
1148
+ )
1149
+ return ColorSet(collectible_id=gift_id)
1150
+
1151
+ if not text.lstrip("-").isdigit():
1152
+ raise UsageError("COLOR is a palette id, collectible:<slug|id>, or 'none'", field="color")
1153
+ await client(ctx)(
1154
+ fn.UpdateColorRequest(
1155
+ for_profile=req.profile or None,
1156
+ color=types.PeerColor(color=int(text), background_emoji_id=emoji),
1157
+ )
1158
+ )
1159
+ ctx.emit("profile_color", {"color": int(text), "for_profile": req.profile})
1160
+ return ColorSet(color=int(text), background_emoji_id=emoji, for_profile=req.profile)
1161
+
1162
+
1163
+ async def _collectible_id(ctx: OpContext, slug: str) -> int:
1164
+ """The collectible id behind a gift slug, so `collectible:<slug>` works."""
1165
+ from telethon.tl.functions import payments as fn
1166
+
1167
+ result = await client(ctx)(fn.GetUniqueStarGiftRequest(slug=_settings.slug_of(slug)))
1168
+ gift = getattr(result, "gift", None)
1169
+ value = getattr(gift, "id", None)
1170
+ if value is None:
1171
+ raise NotFoundError(f"no collectible named {slug!r}")
1172
+ return int(value)
1173
+
1174
+
1175
+ SPEC_COLOR_SET = OperationSpec(
1176
+ id="profile.color.set",
1177
+ request=ColorSetReq,
1178
+ response=ColorSet,
1179
+ impl=color_set,
1180
+ summary="Set my message accent colour, profile colour, or a collectible palette",
1181
+ mutating=True,
1182
+ idempotent=True,
1183
+ rate_class="send",
1184
+ columns=("color", "collectible_id", "background_emoji_id", "for_profile"),
1185
+ headers=("Palette", "Collectible", "Emoji", "Profile"),
1186
+ example={"color": 5, "for_profile": False},
1187
+ example_args="profile color set 5",
1188
+ covers=("gift.as-peer-color", "profile.collectible-message-palette"),
1189
+ covers_partial=("profile.name-color", "profile.profile-color"),
1190
+ coverage_note="Listing the palettes is `profile color list`.",
1191
+ tags=frozenset({"visible-to-others"}),
1192
+ )
1193
+
1194
+
1195
+ # ---------------------------------------------------------------------------
1196
+ # profile channel list / music list / wallpaper set / link
1197
+ # ---------------------------------------------------------------------------
1198
+
1199
+
1200
+ class ChannelListReq(Request):
1201
+ pass
1202
+
1203
+
1204
+ async def channel_list(ctx: OpContext, req: ChannelListReq) -> Page[AdminedChannel]:
1205
+ """Public channels I administer that may be shown on my profile."""
1206
+ from telethon.tl import types
1207
+ from telethon.tl.functions import channels as fn
1208
+ from telethon.tl.functions import users as ufn
1209
+
1210
+ handle = client(ctx)
1211
+ result = await handle(fn.GetAdminedPublicChannelsRequest(for_personal=True))
1212
+ answer = await handle(ufn.GetFullUserRequest(id=types.InputUserSelf()))
1213
+ current = getattr(getattr(answer, "full_user", None), "personal_channel_id", None)
1214
+ rows = [
1215
+ AdminedChannel(
1216
+ id=int(getattr(chat, "id", 0) or 0),
1217
+ title=str(getattr(chat, "title", "") or ""),
1218
+ username=getattr(chat, "username", None),
1219
+ participants_count=getattr(chat, "participants_count", None),
1220
+ current=current is not None and int(getattr(chat, "id", 0) or 0) == int(current),
1221
+ )
1222
+ for chat in getattr(result, "chats", None) or []
1223
+ ]
1224
+ return Page(items=rows, has_more=False, total=len(rows))
1225
+
1226
+
1227
+ SPEC_CHANNEL_LIST = OperationSpec(
1228
+ id="profile.channel.list",
1229
+ request=ChannelListReq,
1230
+ response=Page[AdminedChannel],
1231
+ impl=channel_list,
1232
+ summary="List public channels I administer that can be shown on my profile",
1233
+ description="Pick one with `profile update --channel <chat>`; `none` unlinks it.",
1234
+ paginated=PageKind.LOCAL,
1235
+ idempotent=True,
1236
+ columns=("id", "title", "username", "participants_count", "current"),
1237
+ headers=("ID", "Title", "Username", "Members", "Shown"),
1238
+ example={"items": [{"id": 777, "title": "Notes", "username": "ada_notes"}], "has_more": False},
1239
+ example_args="profile channel list",
1240
+ covers=("profile.personal-channel",),
1241
+ tags=frozenset({"agent-safe"}),
1242
+ )
1243
+
1244
+
1245
+ class MusicListReq(Request):
1246
+ user: Annotated[
1247
+ PeerRef | None,
1248
+ opt("--user", metavar="USER", kind="user", help="Whose profile music (default: me)."),
1249
+ ] = None
1250
+
1251
+
1252
+ async def music_list(ctx: OpContext, req: MusicListReq) -> Page[MusicTrack]:
1253
+ """The music pinned to a profile — mine unless `--user` names another.
1254
+
1255
+ Somebody else's list obeys `inputPrivacyKeySavedMusic`, so an empty
1256
+ answer is not evidence that they pinned nothing.
1257
+ """
1258
+ from tlgr.models.peer import parse_peer_ref
1259
+ from tlgr.ops.user import MusicListReq as UserMusicListReq
1260
+ from tlgr.ops.user import music_list as user_music_list
1261
+
1262
+ target = req.user or parse_peer_ref("me")
1263
+ return await user_music_list(ctx, UserMusicListReq(user=target))
1264
+
1265
+
1266
+ SPEC_MUSIC_LIST = OperationSpec(
1267
+ id="profile.music.list",
1268
+ request=MusicListReq,
1269
+ response=Page[MusicTrack],
1270
+ impl=music_list,
1271
+ summary="List the music shown on a profile",
1272
+ paginated=PageKind.PARTICIPANTS,
1273
+ idempotent=True,
1274
+ rate_class="file",
1275
+ timeout_s=300,
1276
+ columns=("id", "title", "performer", "duration"),
1277
+ headers=("Document", "Title", "Performer", "Seconds"),
1278
+ example={"items": [{"id": 991, "title": "Nocturne", "performer": "Chopin"}], "has_more": False},
1279
+ example_args="profile music list",
1280
+ covers=("profile.saved-music", "profile.saved-music-list", "stories.story-music-save"),
1281
+ tags=frozenset({"agent-safe"}),
1282
+ )
1283
+
1284
+
1285
+ class WallpaperSetReq(Request):
1286
+ source: Annotated[
1287
+ str | None,
1288
+ arg(0, metavar="SOURCE", required=False, help="Image file, or a wallpaper slug."),
1289
+ ] = None
1290
+ blur: Annotated[bool, opt("--blur", help="wallPaperSettings.blur.")] = False
1291
+ motion: Annotated[bool, opt("--motion", help="wallPaperSettings.motion.")] = False
1292
+ intensity: Annotated[int | None, opt("--intensity", metavar="N", help="Pattern intensity.")] = (
1293
+ None
1294
+ )
1295
+ colors: Annotated[
1296
+ str | None, opt("--colors", metavar="LIST", help="Up to four gradient colours.")
1297
+ ] = None
1298
+ for_chat: Annotated[
1299
+ bool, opt("--for-chat", help="Upload it for use as a per-chat wallpaper.")
1300
+ ] = False
1301
+ save: Annotated[
1302
+ bool, opt("--save", help="Only add it to the saved list, do not install it.")
1303
+ ] = False
1304
+ reset: Annotated[bool, opt("--reset", help="Wipe the saved wallpaper list.")] = False
1305
+
1306
+
1307
+ async def wallpaper_set(ctx: OpContext, req: WallpaperSetReq) -> WallpaperInstalled:
1308
+ """Upload or install my chat wallpaper, or reset the saved list.
1309
+
1310
+ The catalogue itself is `media wallpaper list`; setting one chat's
1311
+ wallpaper is `chat wallpaper set`, which is a different server call with
1312
+ a "for both sides" flag. This is the account-wide write path, and it is
1313
+ here rather than in `media` because the GUI reaches it from Settings ▸
1314
+ Chat Settings and not from a file picker.
1315
+ """
1316
+ from tlgr.ops.media import WallpaperSetReq as MediaSetReq
1317
+ from tlgr.ops.media import WallpaperUploadReq, wallpaper_upload
1318
+ from tlgr.ops.media import wallpaper_set as media_wallpaper_set
1319
+
1320
+ colours = [value.strip() for value in (req.colors or "").split(",") if value.strip()]
1321
+ if req.reset:
1322
+ return await media_wallpaper_set(ctx, MediaSetReq(reset=True))
1323
+
1324
+ if not req.source:
1325
+ raise UsageError("give an image file or a wallpaper slug, or --reset", field="source")
1326
+
1327
+ path = Path(os.path.expanduser(req.source))
1328
+ slug = req.source
1329
+ if path.exists():
1330
+ uploaded = await wallpaper_upload(
1331
+ ctx,
1332
+ WallpaperUploadReq(
1333
+ path=str(path),
1334
+ colors=colours,
1335
+ blur=req.blur,
1336
+ motion=req.motion,
1337
+ intensity=req.intensity if req.intensity is not None else 50,
1338
+ pattern=req.intensity is not None,
1339
+ for_chat=bool(req.for_chat),
1340
+ ),
1341
+ )
1342
+ slug = uploaded.slug or ""
1343
+ if req.save:
1344
+ return WallpaperInstalled(slug=slug, saved=True, settings=uploaded.settings)
1345
+
1346
+ return await media_wallpaper_set(
1347
+ ctx,
1348
+ MediaSetReq(
1349
+ wallpaper=slug,
1350
+ blur=req.blur,
1351
+ motion=req.motion,
1352
+ intensity=req.intensity,
1353
+ colors=colours,
1354
+ save_only=req.save,
1355
+ ),
1356
+ )
1357
+
1358
+
1359
+ SPEC_WALLPAPER_SET = OperationSpec(
1360
+ id="profile.wallpaper.set",
1361
+ request=WallpaperSetReq,
1362
+ response=WallpaperInstalled,
1363
+ impl=wallpaper_set,
1364
+ summary="Upload/install my chat wallpaper, or reset the saved wallpaper list",
1365
+ mutating=True,
1366
+ rate_class="file",
1367
+ timeout_s=300,
1368
+ columns=("slug", "installed", "saved", "reset"),
1369
+ headers=("Slug", "Installed", "Saved", "Reset"),
1370
+ example={"slug": "Ycb0FfC6", "installed": True, "saved": True},
1371
+ example_args="profile wallpaper set Ycb0FfC6",
1372
+ covers=("wallpaper.save-install-reset", "wallpaper.upload"),
1373
+ )
1374
+
1375
+
1376
+ class LinkReq(Request):
1377
+ target: Annotated[
1378
+ str | None,
1379
+ arg(0, metavar="TARGET", required=False, help="@username or +888…; default me."),
1380
+ ] = None
1381
+ qr: Annotated[bool, opt("--qr", help="Render a unicode-block QR of the link.")] = False
1382
+ out: Annotated[
1383
+ str | None, opt("--out", metavar="PATH", kind="path", help="Write a PNG QR instead.")
1384
+ ] = None
1385
+ collectible: Annotated[
1386
+ bool, opt("--collectible", help="Fetch Fragment purchase date and price.")
1387
+ ] = False
1388
+
1389
+
1390
+ async def link(ctx: OpContext, req: LinkReq) -> ProfileLink:
1391
+ """My public link and QR code, and Fragment's record of a collectible.
1392
+
1393
+ An account with no username has only the `tg://user?id=` form, and that
1394
+ only opens for peers who already know it — which is why the answer says
1395
+ `resolvable_by_strangers: false` instead of handing back a link that
1396
+ quietly does nothing.
1397
+ """
1398
+ from telethon.tl import types
1399
+ from telethon.tl.functions import fragment as fn
1400
+
1401
+ handle = client(ctx)
1402
+ target = (req.target or "").strip()
1403
+ result = ProfileLink(resolvable_by_strangers=True)
1404
+
1405
+ if not target or target.lower() in ("me", "self"):
1406
+ me = await handle.get_me()
1407
+ result.user_id = int(getattr(me, "id", 0) or 0)
1408
+ result.username = getattr(me, "username", None)
1409
+ elif target.startswith("+"):
1410
+ result.username = target
1411
+ else:
1412
+ result.username = target.lstrip("@")
1413
+
1414
+ if result.username:
1415
+ result.link = f"https://t.me/{str(result.username).lstrip('+')}"
1416
+ else:
1417
+ result.link = f"tg://user?id={result.user_id}"
1418
+ result.resolvable_by_strangers = False
1419
+
1420
+ if req.collectible and result.username:
1421
+ name = str(result.username)
1422
+ collectible = (
1423
+ types.InputCollectiblePhone(phone=name)
1424
+ if name.startswith("+")
1425
+ else types.InputCollectibleUsername(username=name)
1426
+ )
1427
+ try:
1428
+ info = await handle(fn.GetCollectibleInfoRequest(collectible=collectible))
1429
+ except Exception as exc:
1430
+ ctx.warn(f"no Fragment record for {name}: {exc}")
1431
+ else:
1432
+ result.collectible = {
1433
+ "purchase_date": fmt_dt(getattr(info, "purchase_date", None)),
1434
+ "currency": getattr(info, "currency", None),
1435
+ "amount": getattr(info, "amount", None),
1436
+ "crypto_currency": getattr(info, "crypto_currency", None),
1437
+ "crypto_amount": getattr(info, "crypto_amount", None),
1438
+ "url": getattr(info, "url", None),
1439
+ }
1440
+
1441
+ if req.qr or req.out:
1442
+ result.qr, result.qr_path = _qr(ctx, result.link, req.out)
1443
+ return result
1444
+
1445
+
1446
+ def _qr(ctx: OpContext, text: str, out: str | None) -> tuple[str | None, str | None]:
1447
+ """The link as a QR, reusing the encoder `chat invite link` already uses.
1448
+
1449
+ A QR carries nothing the link does not, so it is pure local rendering —
1450
+ and a second implementation of it would be a second thing to get wrong.
1451
+ """
1452
+ from tlgr.ops.chat_invite import _render_qr
1453
+
1454
+ return _render_qr(ctx, text, out)
1455
+
1456
+
1457
+ SPEC_LINK = OperationSpec(
1458
+ id="profile.link",
1459
+ request=LinkReq,
1460
+ response=ProfileLink,
1461
+ impl=link,
1462
+ summary="My public link and QR code, and Fragment details for a username or phone",
1463
+ description=(
1464
+ "The GUI's styled QR *image* is a rendering choice a terminal has no "
1465
+ "use for; the link, a block QR and an optional PNG are the parts that "
1466
+ "carry information."
1467
+ ),
1468
+ idempotent=True,
1469
+ columns=("link", "username", "resolvable_by_strangers"),
1470
+ headers=("Link", "Username", "Public"),
1471
+ example={
1472
+ "link": "https://t.me/ada",
1473
+ "username": "ada",
1474
+ "resolvable_by_strangers": True,
1475
+ },
1476
+ example_args="profile link --qr",
1477
+ covers=("profile.collectible-info", "profile.qr-code"),
1478
+ tags=frozenset({"agent-safe"}),
1479
+ )
1480
+
1481
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]