tlgr-cli 2.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tlgr/__init__.py +3 -0
- tlgr/__main__.py +6 -0
- tlgr/actions/__init__.py +45 -0
- tlgr/actions/forward.py +74 -0
- tlgr/actions/reply.py +32 -0
- tlgr/cli/__init__.py +259 -0
- tlgr/cli/confirm.py +55 -0
- tlgr/cli/errors.py +84 -0
- tlgr/cli/gen.py +690 -0
- tlgr/cli/globals.py +273 -0
- tlgr/cli/introspect.py +170 -0
- tlgr/cli/params.py +189 -0
- tlgr/cli/render.py +418 -0
- tlgr/core/__init__.py +0 -0
- tlgr/core/accounts.py +384 -0
- tlgr/core/config.py +358 -0
- tlgr/core/custom_tl.py +170 -0
- tlgr/core/errors.py +687 -0
- tlgr/core/eventtypes.py +1170 -0
- tlgr/core/identity.py +127 -0
- tlgr/core/launchd.py +122 -0
- tlgr/core/logging.py +194 -0
- tlgr/core/media.py +134 -0
- tlgr/core/output.py +251 -0
- tlgr/core/pagination.py +227 -0
- tlgr/core/paths.py +360 -0
- tlgr/core/peers.py +427 -0
- tlgr/core/process.py +138 -0
- tlgr/core/signing.py +38 -0
- tlgr/core/systemd.py +96 -0
- tlgr/core/telethon_compat.py +295 -0
- tlgr/core/text.py +211 -0
- tlgr/core/timefmt.py +199 -0
- tlgr/core/tl.py +98 -0
- tlgr/daemon/__init__.py +0 -0
- tlgr/daemon/app.py +869 -0
- tlgr/daemon/dispatch.py +446 -0
- tlgr/daemon/events.py +723 -0
- tlgr/daemon/files.py +431 -0
- tlgr/daemon/idle.py +119 -0
- tlgr/daemon/jobs.py +68 -0
- tlgr/daemon/main.py +161 -0
- tlgr/daemon/peercred.py +75 -0
- tlgr/daemon/policy.py +113 -0
- tlgr/daemon/preauth.py +366 -0
- tlgr/daemon/ratelimit.py +391 -0
- tlgr/daemon/server.py +24 -0
- tlgr/daemon/session.py +648 -0
- tlgr/daemon/sessions.py +274 -0
- tlgr/daemon/singleton.py +114 -0
- tlgr/daemon/stream.py +193 -0
- tlgr/daemon/transfers.py +219 -0
- tlgr/daemon/webhook.py +390 -0
- tlgr/data/catalog_index.json +1 -0
- tlgr/data/parity_waivers.toml +90 -0
- tlgr/filters/__init__.py +42 -0
- tlgr/filters/compose.py +121 -0
- tlgr/filters/content.py +85 -0
- tlgr/filters/context.py +114 -0
- tlgr/filters/message.py +161 -0
- tlgr/filters/temporal.py +87 -0
- tlgr/filters/user.py +36 -0
- tlgr/gateway/__init__.py +1 -0
- tlgr/gateway/config.py +161 -0
- tlgr/gateway/engine.py +215 -0
- tlgr/gateway/event.py +22 -0
- tlgr/jobs/__init__.py +0 -0
- tlgr/jobs/base.py +81 -0
- tlgr/jobs/client.py +37 -0
- tlgr/models/__init__.py +1220 -0
- tlgr/models/admin.py +744 -0
- tlgr/models/auth.py +510 -0
- tlgr/models/base.py +81 -0
- tlgr/models/bot.py +576 -0
- tlgr/models/business.py +265 -0
- tlgr/models/call.py +586 -0
- tlgr/models/config.py +101 -0
- tlgr/models/contact.py +481 -0
- tlgr/models/daemon.py +336 -0
- tlgr/models/dialog.py +626 -0
- tlgr/models/envelope.py +68 -0
- tlgr/models/error.py +30 -0
- tlgr/models/event.py +79 -0
- tlgr/models/export.py +66 -0
- tlgr/models/gift.py +275 -0
- tlgr/models/inline.py +84 -0
- tlgr/models/location.py +115 -0
- tlgr/models/media.py +507 -0
- tlgr/models/message.py +584 -0
- tlgr/models/net.py +232 -0
- tlgr/models/notify.py +105 -0
- tlgr/models/page.py +32 -0
- tlgr/models/payment.py +172 -0
- tlgr/models/peer.py +400 -0
- tlgr/models/poll.py +119 -0
- tlgr/models/premium.py +161 -0
- tlgr/models/privacy.py +93 -0
- tlgr/models/profile.py +217 -0
- tlgr/models/reaction.py +160 -0
- tlgr/models/resolve.py +175 -0
- tlgr/models/settings.py +103 -0
- tlgr/models/stars.py +101 -0
- tlgr/models/sticker.py +243 -0
- tlgr/models/story.py +467 -0
- tlgr/models/sync.py +105 -0
- tlgr/models/todo.py +36 -0
- tlgr/models/webapp.py +89 -0
- tlgr/ops/__init__.py +63 -0
- tlgr/ops/_admin.py +313 -0
- tlgr/ops/_auth.py +599 -0
- tlgr/ops/_bots.py +586 -0
- tlgr/ops/_calls.py +535 -0
- tlgr/ops/_common.py +160 -0
- tlgr/ops/_layer.py +46 -0
- tlgr/ops/_media.py +592 -0
- tlgr/ops/_params.py +212 -0
- tlgr/ops/_rights.py +402 -0
- tlgr/ops/_send.py +593 -0
- tlgr/ops/_serialize.py +667 -0
- tlgr/ops/_settings.py +306 -0
- tlgr/ops/_spec.py +167 -0
- tlgr/ops/_story.py +743 -0
- tlgr/ops/account.py +2604 -0
- tlgr/ops/agent.py +937 -0
- tlgr/ops/auth.py +1282 -0
- tlgr/ops/bot.py +4880 -0
- tlgr/ops/business.py +1520 -0
- tlgr/ops/call.py +1610 -0
- tlgr/ops/chat.py +4025 -0
- tlgr/ops/chat_admin.py +929 -0
- tlgr/ops/chat_extra.py +1061 -0
- tlgr/ops/chat_invite.py +716 -0
- tlgr/ops/chat_manage.py +1691 -0
- tlgr/ops/chat_member.py +1357 -0
- tlgr/ops/chat_stats.py +902 -0
- tlgr/ops/chat_topic.py +905 -0
- tlgr/ops/conference.py +791 -0
- tlgr/ops/config.py +1698 -0
- tlgr/ops/contact.py +2330 -0
- tlgr/ops/daemon.py +1397 -0
- tlgr/ops/draft.py +299 -0
- tlgr/ops/emoji.py +343 -0
- tlgr/ops/events.py +1327 -0
- tlgr/ops/export.py +596 -0
- tlgr/ops/folder.py +1322 -0
- tlgr/ops/gif.py +522 -0
- tlgr/ops/gift.py +1546 -0
- tlgr/ops/giveaway.py +541 -0
- tlgr/ops/inline.py +773 -0
- tlgr/ops/job.py +799 -0
- tlgr/ops/location.py +917 -0
- tlgr/ops/media.py +4495 -0
- tlgr/ops/message.py +3769 -0
- tlgr/ops/net.py +536 -0
- tlgr/ops/notify.py +840 -0
- tlgr/ops/passport.py +464 -0
- tlgr/ops/payment.py +907 -0
- tlgr/ops/poll.py +1078 -0
- tlgr/ops/premium.py +488 -0
- tlgr/ops/privacy.py +794 -0
- tlgr/ops/profile.py +1481 -0
- tlgr/ops/proxy.py +750 -0
- tlgr/ops/reaction.py +1475 -0
- tlgr/ops/resolve.py +1140 -0
- tlgr/ops/search.py +521 -0
- tlgr/ops/settings.py +1066 -0
- tlgr/ops/stars.py +594 -0
- tlgr/ops/sticker.py +1602 -0
- tlgr/ops/story.py +3216 -0
- tlgr/ops/sync.py +788 -0
- tlgr/ops/todo.py +514 -0
- tlgr/ops/user.py +1406 -0
- tlgr/ops/vc.py +2351 -0
- tlgr/ops/webapp.py +717 -0
- tlgr/ops/webhook.py +418 -0
- tlgr/parity.py +386 -0
- tlgr/processors/__init__.py +125 -0
- tlgr/processors/regex.py +26 -0
- tlgr/processors/text.py +56 -0
- tlgr/registry.py +519 -0
- tlgr/schema.py +173 -0
- tlgr/transport/__init__.py +30 -0
- tlgr/transport/autostart.py +293 -0
- tlgr/transport/client.py +805 -0
- tlgr/transport/ndjson.py +44 -0
- tlgr/version.py +31 -0
- tlgr_cli-2.0.1.dist-info/METADATA +957 -0
- tlgr_cli-2.0.1.dist-info/RECORD +192 -0
- tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
- tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
- tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
- tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/ops/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_")]
|