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