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/models/peer.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Peers: how one is addressed on input (`PeerRef`) and identified on output.
|
|
2
|
+
|
|
3
|
+
Parsing lives here rather than in the CLI because the daemon has to accept the
|
|
4
|
+
same vocabulary over the wire, and because "is this a channel id?" must have
|
|
5
|
+
exactly one answer in the codebase. Nothing here touches the network: a
|
|
6
|
+
`PeerRef` is a *syntactic* classification, resolution happens later against a
|
|
7
|
+
connected account.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
from tlgr.models.base import Model
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Chat",
|
|
19
|
+
"Peer",
|
|
20
|
+
"PeerKind",
|
|
21
|
+
"PeerRef",
|
|
22
|
+
"PeerRefKind",
|
|
23
|
+
"Photo",
|
|
24
|
+
"Rights",
|
|
25
|
+
"User",
|
|
26
|
+
"UserRef",
|
|
27
|
+
"parse_message_link",
|
|
28
|
+
"parse_peer_ref",
|
|
29
|
+
"parse_user_ref",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
PeerKind = Literal["user", "bot", "saved", "group", "supergroup", "channel", "unknown"]
|
|
33
|
+
PeerRefKind = Literal["id", "username", "phone", "link", "invite", "self", "saved"]
|
|
34
|
+
|
|
35
|
+
# Telegram's own marking scheme, reproduced here so models/ stays Telethon-free.
|
|
36
|
+
_CHANNEL_MARK = -1000000000000
|
|
37
|
+
_MAX_USER_ID = 0x7FFFFFFFFFFFFFFF
|
|
38
|
+
|
|
39
|
+
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_]{4,32}$")
|
|
40
|
+
_PHONE_RE = re.compile(r"^\+?[0-9][0-9 \-()]{4,24}$")
|
|
41
|
+
_INT_RE = re.compile(r"^-?\d+$")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PeerRefError(ValueError):
|
|
45
|
+
"""A peer reference that cannot be parsed at all.
|
|
46
|
+
|
|
47
|
+
Raised as a plain ValueError subclass so that `models/` keeps importing
|
|
48
|
+
nothing from tlgr; `core.errors.classify` maps it to USAGE.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PeerRef(Model):
|
|
53
|
+
"""How a peer is *addressed* on input.
|
|
54
|
+
|
|
55
|
+
`raw` is exactly what the user typed, kept so that an error message can
|
|
56
|
+
quote it back; `kind` and `value` are the normalised form the resolver
|
|
57
|
+
actually uses.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
raw: str
|
|
61
|
+
kind: PeerRefKind
|
|
62
|
+
value: str | int
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
UserRef = PeerRef
|
|
66
|
+
"""Same shape, narrower parser (`parse_user_ref`): channel ids are refused."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Photo(Model):
|
|
70
|
+
id: int
|
|
71
|
+
has_video: bool = False
|
|
72
|
+
stripped_thumb_b64: str | None = None
|
|
73
|
+
dc_id: int | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Peer(Model):
|
|
77
|
+
"""How a peer is *identified* on output.
|
|
78
|
+
|
|
79
|
+
`id` is always the marked id and `raw_id`/`kind` sit next to it, so no
|
|
80
|
+
consumer ever has to parse the sign to learn what it is holding (COR-10).
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
id: int
|
|
84
|
+
raw_id: int
|
|
85
|
+
kind: PeerKind
|
|
86
|
+
title: str = ""
|
|
87
|
+
username: str | None = None
|
|
88
|
+
usernames: list[str] = []
|
|
89
|
+
is_self: bool = False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class User(Model):
|
|
93
|
+
id: int
|
|
94
|
+
raw_id: int
|
|
95
|
+
kind: Literal["user", "bot"] = "user"
|
|
96
|
+
first_name: str | None = None
|
|
97
|
+
last_name: str | None = None
|
|
98
|
+
title: str = ""
|
|
99
|
+
username: str | None = None
|
|
100
|
+
usernames: list[str] = []
|
|
101
|
+
phone: str | None = None
|
|
102
|
+
is_self: bool = False
|
|
103
|
+
is_contact: bool = False
|
|
104
|
+
is_mutual_contact: bool = False
|
|
105
|
+
is_deleted: bool = False
|
|
106
|
+
is_bot: bool = False
|
|
107
|
+
is_verified: bool = False
|
|
108
|
+
is_scam: bool = False
|
|
109
|
+
is_fake: bool = False
|
|
110
|
+
is_premium: bool = False
|
|
111
|
+
is_support: bool = False
|
|
112
|
+
is_blocked: bool | None = None
|
|
113
|
+
restricted: bool = False
|
|
114
|
+
restriction_reason: list[str] = []
|
|
115
|
+
lang_code: str | None = None
|
|
116
|
+
status: str | None = None
|
|
117
|
+
status_expires: str | None = None
|
|
118
|
+
last_seen: str | None = None
|
|
119
|
+
stories_hidden: bool = False
|
|
120
|
+
emoji_status_id: int | None = None
|
|
121
|
+
photo: Photo | None = None
|
|
122
|
+
# Full-profile fields: only populated when the op fetched users.getFullUser.
|
|
123
|
+
bio: str | None = None
|
|
124
|
+
birthday: str | None = None
|
|
125
|
+
common_chats_count: int | None = None
|
|
126
|
+
personal_channel_id: int | None = None
|
|
127
|
+
business_hours: dict[str, Any] | None = None
|
|
128
|
+
min: bool = False
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Rights(Model):
|
|
132
|
+
"""Flattened ChatAdminRights / ChatBannedRights.
|
|
133
|
+
|
|
134
|
+
`True` means *allowed*, everywhere, including for banned rights. Telegram
|
|
135
|
+
stores banned rights inverted (`send_messages=True` means "cannot send");
|
|
136
|
+
normalising once here is the difference between one confusing field and a
|
|
137
|
+
confusing field per caller.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
change_info: bool | None = None
|
|
141
|
+
post_messages: bool | None = None
|
|
142
|
+
edit_messages: bool | None = None
|
|
143
|
+
delete_messages: bool | None = None
|
|
144
|
+
ban_users: bool | None = None
|
|
145
|
+
invite_users: bool | None = None
|
|
146
|
+
pin_messages: bool | None = None
|
|
147
|
+
add_admins: bool | None = None
|
|
148
|
+
manage_call: bool | None = None
|
|
149
|
+
manage_topics: bool | None = None
|
|
150
|
+
post_stories: bool | None = None
|
|
151
|
+
edit_stories: bool | None = None
|
|
152
|
+
delete_stories: bool | None = None
|
|
153
|
+
manage_direct_messages: bool | None = None
|
|
154
|
+
manage_ranks: bool | None = None
|
|
155
|
+
anonymous: bool | None = None
|
|
156
|
+
other: bool | None = None
|
|
157
|
+
view_messages: bool | None = None
|
|
158
|
+
send_messages: bool | None = None
|
|
159
|
+
send_media: bool | None = None
|
|
160
|
+
send_photos: bool | None = None
|
|
161
|
+
send_videos: bool | None = None
|
|
162
|
+
send_audios: bool | None = None
|
|
163
|
+
send_voices: bool | None = None
|
|
164
|
+
send_roundvideos: bool | None = None
|
|
165
|
+
send_docs: bool | None = None
|
|
166
|
+
send_stickers: bool | None = None
|
|
167
|
+
send_gifs: bool | None = None
|
|
168
|
+
send_games: bool | None = None
|
|
169
|
+
send_inline: bool | None = None
|
|
170
|
+
send_polls: bool | None = None
|
|
171
|
+
send_plain: bool | None = None
|
|
172
|
+
send_reactions: bool | None = None
|
|
173
|
+
embed_links: bool | None = None
|
|
174
|
+
edit_rank: bool | None = None
|
|
175
|
+
until: str | None = None
|
|
176
|
+
until_unix: int | None = None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class Chat(Model):
|
|
180
|
+
id: int
|
|
181
|
+
raw_id: int
|
|
182
|
+
kind: PeerKind
|
|
183
|
+
title: str = ""
|
|
184
|
+
username: str | None = None
|
|
185
|
+
usernames: list[str] = []
|
|
186
|
+
is_creator: bool = False
|
|
187
|
+
is_admin: bool = False
|
|
188
|
+
is_broadcast: bool = False
|
|
189
|
+
is_forum: bool = False
|
|
190
|
+
is_gigagroup: bool = False
|
|
191
|
+
is_verified: bool = False
|
|
192
|
+
is_scam: bool = False
|
|
193
|
+
is_fake: bool = False
|
|
194
|
+
is_restricted: bool = False
|
|
195
|
+
noforwards: bool = False
|
|
196
|
+
join_to_send: bool | None = None
|
|
197
|
+
join_request: bool | None = None
|
|
198
|
+
signatures: bool | None = None
|
|
199
|
+
slowmode_seconds: int | None = None
|
|
200
|
+
slowmode_next_send: str | None = None
|
|
201
|
+
participants_count: int | None = None
|
|
202
|
+
online_count: int | None = None
|
|
203
|
+
photo: Photo | None = None
|
|
204
|
+
date: str | None = None
|
|
205
|
+
left: bool = False
|
|
206
|
+
# Full-chat fields: only from channels.getFullChannel / messages.getFullChat.
|
|
207
|
+
about: str | None = None
|
|
208
|
+
pinned_message_id: int | None = None
|
|
209
|
+
linked_chat_id: int | None = None
|
|
210
|
+
migrated_from_chat_id: int | None = None
|
|
211
|
+
available_reactions: list[str] | None = None
|
|
212
|
+
default_rights: Rights | None = None
|
|
213
|
+
my_rights: Rights | None = None
|
|
214
|
+
ttl_period: int | None = None
|
|
215
|
+
stats_dc: int | None = None
|
|
216
|
+
can_view_participants: bool | None = None
|
|
217
|
+
hidden_prehistory: bool | None = None
|
|
218
|
+
antispam: bool | None = None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
# Parsing
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _strip_scheme(text: str) -> str | None:
|
|
227
|
+
"""Return the path/query part of a t.me or tg:// reference, else None."""
|
|
228
|
+
low = text.lower()
|
|
229
|
+
for prefix in ("https://", "http://"):
|
|
230
|
+
if low.startswith(prefix):
|
|
231
|
+
text = text[len(prefix) :]
|
|
232
|
+
low = text.lower()
|
|
233
|
+
if low.startswith("tg://"):
|
|
234
|
+
return text[len("tg://") :]
|
|
235
|
+
for host in ("t.me/", "telegram.me/", "telegram.dog/"):
|
|
236
|
+
if low.startswith(host):
|
|
237
|
+
return text[len(host) :]
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _query_value(query: str, key: str) -> str | None:
|
|
242
|
+
for part in query.split("&"):
|
|
243
|
+
name, _, value = part.partition("=")
|
|
244
|
+
if name.lower() == key:
|
|
245
|
+
return value
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _parse_link(rest: str, raw: str) -> PeerRef:
|
|
250
|
+
"""Classify the part of a t.me/tg:// reference after the host."""
|
|
251
|
+
head, _, query = rest.partition("?")
|
|
252
|
+
head = head.strip("/")
|
|
253
|
+
|
|
254
|
+
# tg://resolve?domain=x / tg://join?invite=hash
|
|
255
|
+
if query and head.lower() in ("resolve", "join", "privatepost", "openmessage"):
|
|
256
|
+
verb = head.lower()
|
|
257
|
+
if verb == "resolve":
|
|
258
|
+
domain = _query_value(query, "domain")
|
|
259
|
+
if domain:
|
|
260
|
+
return PeerRef(raw=raw, kind="username", value=domain.lower())
|
|
261
|
+
elif verb == "join":
|
|
262
|
+
invite = _query_value(query, "invite")
|
|
263
|
+
if invite:
|
|
264
|
+
return PeerRef(raw=raw, kind="invite", value=invite)
|
|
265
|
+
elif verb == "privatepost":
|
|
266
|
+
channel = _query_value(query, "channel")
|
|
267
|
+
if channel and _INT_RE.match(channel):
|
|
268
|
+
return PeerRef(raw=raw, kind="id", value=_mark_channel(int(channel)))
|
|
269
|
+
elif verb == "openmessage":
|
|
270
|
+
user = _query_value(query, "user_id")
|
|
271
|
+
if user and _INT_RE.match(user):
|
|
272
|
+
return PeerRef(raw=raw, kind="id", value=int(user))
|
|
273
|
+
return PeerRef(raw=raw, kind="link", value=rest)
|
|
274
|
+
|
|
275
|
+
segments = [s for s in head.split("/") if s]
|
|
276
|
+
if not segments:
|
|
277
|
+
raise PeerRefError(f"{raw!r} is not a usable Telegram reference")
|
|
278
|
+
|
|
279
|
+
first = segments[0]
|
|
280
|
+
if first.startswith("+") or first.lower() == "joinchat":
|
|
281
|
+
# t.me/+AbCdEf and the older t.me/joinchat/AbCdEf
|
|
282
|
+
invite = first[1:] if first.startswith("+") else (segments[1] if len(segments) > 1 else "")
|
|
283
|
+
if not invite:
|
|
284
|
+
raise PeerRefError(f"{raw!r} has no invite hash")
|
|
285
|
+
# A `+` followed by digits only is a phone link, not an invite hash.
|
|
286
|
+
if first.startswith("+") and invite.isdigit():
|
|
287
|
+
return PeerRef(raw=raw, kind="phone", value="+" + invite)
|
|
288
|
+
return PeerRef(raw=raw, kind="invite", value=invite)
|
|
289
|
+
|
|
290
|
+
if first.lower() == "c" and len(segments) > 1 and _INT_RE.match(segments[1]):
|
|
291
|
+
# t.me/c/<internal channel id>/<message id>
|
|
292
|
+
return PeerRef(raw=raw, kind="id", value=_mark_channel(int(segments[1])))
|
|
293
|
+
|
|
294
|
+
if first.lower() == "s" and len(segments) > 1:
|
|
295
|
+
first = segments[1] # t.me/s/<username> is the preview view of a channel
|
|
296
|
+
|
|
297
|
+
if _USERNAME_RE.match(first):
|
|
298
|
+
return PeerRef(raw=raw, kind="username", value=first.lower())
|
|
299
|
+
|
|
300
|
+
return PeerRef(raw=raw, kind="link", value=rest)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _mark_channel(raw_id: int) -> int:
|
|
304
|
+
"""Turn an unmarked channel id into the marked form Telegram links use."""
|
|
305
|
+
return _CHANNEL_MARK - abs(raw_id) if raw_id > 0 else raw_id
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def parse_peer_ref(text: str) -> PeerRef:
|
|
309
|
+
"""Classify a chat/user reference. Syntax only — nothing is resolved here.
|
|
310
|
+
|
|
311
|
+
Accepts `@username`, a marked or raw id (including the `-100…` channel
|
|
312
|
+
form, and negative ids that need a `--` separator on the command line),
|
|
313
|
+
`+phone`, `me`/`saved`, and t.me / tg:// links and invites.
|
|
314
|
+
"""
|
|
315
|
+
raw = text.strip()
|
|
316
|
+
if not raw:
|
|
317
|
+
raise PeerRefError("empty peer reference")
|
|
318
|
+
|
|
319
|
+
low = raw.lower()
|
|
320
|
+
if low in ("me", "self"):
|
|
321
|
+
return PeerRef(raw=raw, kind="self", value="me")
|
|
322
|
+
if low in ("saved", "savedmessages", "saved_messages"):
|
|
323
|
+
return PeerRef(raw=raw, kind="saved", value="saved")
|
|
324
|
+
|
|
325
|
+
link_rest = _strip_scheme(raw)
|
|
326
|
+
if link_rest is not None:
|
|
327
|
+
return _parse_link(link_rest, raw)
|
|
328
|
+
|
|
329
|
+
if raw.startswith("@"):
|
|
330
|
+
name = raw[1:]
|
|
331
|
+
if not _USERNAME_RE.match(name):
|
|
332
|
+
raise PeerRefError(f"{raw!r} is not a valid username")
|
|
333
|
+
return PeerRef(raw=raw, kind="username", value=name.lower())
|
|
334
|
+
|
|
335
|
+
if _INT_RE.match(raw):
|
|
336
|
+
value = int(raw)
|
|
337
|
+
if value == 0:
|
|
338
|
+
raise PeerRefError("0 is not a peer id")
|
|
339
|
+
return PeerRef(raw=raw, kind="id", value=value)
|
|
340
|
+
|
|
341
|
+
if raw.startswith("+") and _PHONE_RE.match(raw):
|
|
342
|
+
digits = re.sub(r"[^0-9]", "", raw)
|
|
343
|
+
if not digits:
|
|
344
|
+
raise PeerRefError(f"{raw!r} is not a valid phone number")
|
|
345
|
+
return PeerRef(raw=raw, kind="phone", value="+" + digits)
|
|
346
|
+
|
|
347
|
+
if _USERNAME_RE.match(raw):
|
|
348
|
+
# A bare username is accepted because half the world types it that way.
|
|
349
|
+
return PeerRef(raw=raw, kind="username", value=raw.lower())
|
|
350
|
+
|
|
351
|
+
raise PeerRefError(f"{raw!r} is not a chat, user, id, phone or link")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def parse_user_ref(text: str) -> PeerRef:
|
|
355
|
+
"""`parse_peer_ref` restricted to things that can name a user.
|
|
356
|
+
|
|
357
|
+
A channel or group id here is almost always a copy/paste mistake, and
|
|
358
|
+
saying so is more useful than letting the resolver fail with
|
|
359
|
+
PEER_ID_INVALID three layers later.
|
|
360
|
+
"""
|
|
361
|
+
ref = parse_peer_ref(text)
|
|
362
|
+
if ref.kind == "id" and isinstance(ref.value, int) and ref.value < 0:
|
|
363
|
+
raise PeerRefError(
|
|
364
|
+
f"{ref.raw!r} is a group or channel id; this argument wants a user "
|
|
365
|
+
"(@username, numeric user id, +phone or 'me')"
|
|
366
|
+
)
|
|
367
|
+
if ref.kind == "invite":
|
|
368
|
+
raise PeerRefError(f"{ref.raw!r} is a chat invite link; this argument wants a user")
|
|
369
|
+
return ref
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def parse_message_link(text: str) -> tuple[PeerRef, int] | None:
|
|
373
|
+
"""Split a t.me message link into its chat and message id, or None.
|
|
374
|
+
|
|
375
|
+
This is what lets a `<chat> <msg-id>` pair be given as a single pasted
|
|
376
|
+
link anywhere in the CLI (STYLE §2).
|
|
377
|
+
"""
|
|
378
|
+
rest = _strip_scheme(text.strip())
|
|
379
|
+
if rest is None:
|
|
380
|
+
return None
|
|
381
|
+
head, _, query = rest.partition("?")
|
|
382
|
+
segments = [s for s in head.strip("/").split("/") if s]
|
|
383
|
+
|
|
384
|
+
if head.lower().strip("/") == "privatepost" and query:
|
|
385
|
+
channel = _query_value(query, "channel")
|
|
386
|
+
post = _query_value(query, "post")
|
|
387
|
+
if channel and post and _INT_RE.match(channel) and _INT_RE.match(post):
|
|
388
|
+
return PeerRef(raw=text, kind="id", value=_mark_channel(int(channel))), int(post)
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
if len(segments) >= 3 and segments[0].lower() == "c" and _INT_RE.match(segments[1]):
|
|
392
|
+
if _INT_RE.match(segments[2]):
|
|
393
|
+
ref = PeerRef(raw=text, kind="id", value=_mark_channel(int(segments[1])))
|
|
394
|
+
return ref, int(segments[2])
|
|
395
|
+
return None
|
|
396
|
+
|
|
397
|
+
if len(segments) >= 2 and _USERNAME_RE.match(segments[0]) and _INT_RE.match(segments[1]):
|
|
398
|
+
return PeerRef(raw=text, kind="username", value=segments[0].lower()), int(segments[1])
|
|
399
|
+
|
|
400
|
+
return None
|
tlgr/models/poll.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Polls and quizzes.
|
|
2
|
+
|
|
3
|
+
Telegram addresses a poll answer by an **opaque byte string**, not by its
|
|
4
|
+
index: `poll.answers[2].option` is whatever the server assigned, and with
|
|
5
|
+
`shuffle_answers` the order a client renders is not the order the server
|
|
6
|
+
stores. Every option therefore carries both — `index` for a human to type and
|
|
7
|
+
`option_b64` for a machine to round-trip — so `poll vote 2` can be resolved
|
|
8
|
+
against the server's copy rather than against whatever the caller last saw.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
from tlgr.models.base import Model
|
|
16
|
+
from tlgr.models.message import MediaSummary, MessageEntity
|
|
17
|
+
|
|
18
|
+
__all__ = ["Poll", "PollOption", "PollStats", "PollVoter"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PollOption(Model):
|
|
22
|
+
"""One answer, with the two names it answers to.
|
|
23
|
+
|
|
24
|
+
`voters`/`percent` are absent rather than zero until results are visible:
|
|
25
|
+
a poll that hides its results until it closes reports nothing, and zero
|
|
26
|
+
would be a wrong answer rather than a missing one.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
index: int
|
|
30
|
+
text: str = ""
|
|
31
|
+
entities: list[MessageEntity] = []
|
|
32
|
+
#: The server's opaque option identifier, base64. Everything after
|
|
33
|
+
#: creation — voting, deleting an answer, listing voters — addresses the
|
|
34
|
+
#: option by these bytes.
|
|
35
|
+
option_b64: str = ""
|
|
36
|
+
voters: int | None = None
|
|
37
|
+
percent: float | None = None
|
|
38
|
+
chosen: bool = False
|
|
39
|
+
correct: bool | None = None
|
|
40
|
+
added_by: int | None = None
|
|
41
|
+
added_date: str | None = None
|
|
42
|
+
media: MediaSummary | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Poll(Model):
|
|
46
|
+
"""A poll or quiz and everything currently known about its results.
|
|
47
|
+
|
|
48
|
+
`can_vote` and `restriction` are computed here rather than left to the
|
|
49
|
+
caller: eligibility is a client-side derivation over `closed`,
|
|
50
|
+
`subscribers_only` and `countries`, and an agent that cannot see it ends
|
|
51
|
+
up learning the answer by sending a vote and reading the RPC error.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
#: First and required: `omit_defaults` would drop `"poll"` and `true`,
|
|
55
|
+
#: and an agent reading a missing `can_vote` as False would be wrong.
|
|
56
|
+
type: Literal["poll", "quiz"]
|
|
57
|
+
can_vote: bool
|
|
58
|
+
chat_id: int = 0
|
|
59
|
+
msg_id: int = 0
|
|
60
|
+
poll_id: int | None = None
|
|
61
|
+
question: str = ""
|
|
62
|
+
entities: list[MessageEntity] = []
|
|
63
|
+
description: str | None = None
|
|
64
|
+
closed: bool = False
|
|
65
|
+
public_voters: bool = False
|
|
66
|
+
multiple: bool = False
|
|
67
|
+
open_answers: bool = False
|
|
68
|
+
revoting_disabled: bool = False
|
|
69
|
+
shuffle: bool = False
|
|
70
|
+
hide_results_until_close: bool = False
|
|
71
|
+
subscribers_only: bool = False
|
|
72
|
+
countries: list[str] = []
|
|
73
|
+
close_period: int | None = None
|
|
74
|
+
close_date: str | None = None
|
|
75
|
+
close_date_unix: int | None = None
|
|
76
|
+
total_voters: int = 0
|
|
77
|
+
restriction: str | None = None
|
|
78
|
+
my_votes: list[int] = []
|
|
79
|
+
options: list[PollOption] = []
|
|
80
|
+
recent_voters: list[int] = []
|
|
81
|
+
solution: str | None = None
|
|
82
|
+
solution_entities: list[MessageEntity] = []
|
|
83
|
+
#: `pollResults.min` — the payload is partial and a `poll get` refreshes it.
|
|
84
|
+
min: bool = False
|
|
85
|
+
has_unread_votes: bool = False
|
|
86
|
+
can_view_stats: bool = False
|
|
87
|
+
already: bool = False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class PollVoter(Model):
|
|
91
|
+
"""One row of `messages.getPollVotes`.
|
|
92
|
+
|
|
93
|
+
`options` rather than `option` because a multiple-choice poll answers with
|
|
94
|
+
`messagePeerVoteMultiple`; the singular is kept as the first entry so a
|
|
95
|
+
table stays readable.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
user_id: int
|
|
99
|
+
option: int | None = None
|
|
100
|
+
options: list[int] = []
|
|
101
|
+
date: str | None = None
|
|
102
|
+
date_unix: int | None = None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class PollStats(Model):
|
|
106
|
+
"""The vote-statistics graph, as the server hands it over.
|
|
107
|
+
|
|
108
|
+
Telegram answers with either an inline zipped JSON payload or an
|
|
109
|
+
asynchronous token to be loaded later; both are reported rather than one
|
|
110
|
+
being silently resolved, because loading a token costs another request on
|
|
111
|
+
the stats DC.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
chat_id: int = 0
|
|
115
|
+
msg_id: int = 0
|
|
116
|
+
graph: str | None = None
|
|
117
|
+
token: str | None = None
|
|
118
|
+
dark: bool = False
|
|
119
|
+
dc_id: int | None = None
|
tlgr/models/premium.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Telegram Premium, boosts, giveaways and gift codes.
|
|
2
|
+
|
|
3
|
+
The limit table is the part a CLI actually uses: caption length, upload size,
|
|
4
|
+
folder count, pinned chats, public usernames all double with Premium, and a
|
|
5
|
+
script that guesses them writes a message the server then refuses. It has no
|
|
6
|
+
MTProto method of its own — it is assembled from `help.getAppConfig` — which
|
|
7
|
+
is why `PremiumLimit` carries `source`.
|
|
8
|
+
|
|
9
|
+
Buying is absent throughout, as in `ops/payment.py`: `PremiumGiftQuote`
|
|
10
|
+
reports the price and says, in `reason`, that tlgr does not sign the form.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from tlgr.models.base import Model
|
|
18
|
+
from tlgr.models.peer import Peer
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"GiftCode",
|
|
22
|
+
"GiftCodeApplied",
|
|
23
|
+
"GiveawayInfo",
|
|
24
|
+
"GiveawayLaunched",
|
|
25
|
+
"GiveawayWinner",
|
|
26
|
+
"PremiumFeatures",
|
|
27
|
+
"PremiumGiftOption",
|
|
28
|
+
"PremiumGiftQuote",
|
|
29
|
+
"PremiumLimit",
|
|
30
|
+
"PremiumStatus",
|
|
31
|
+
"PrepaidGiveaway",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PremiumStatus(Model):
|
|
36
|
+
premium: bool = False
|
|
37
|
+
premium_until: str | None = None
|
|
38
|
+
#: appConfig `premium_purchase_blocked`: the store path is closed here.
|
|
39
|
+
premium_purchase_blocked: bool = True
|
|
40
|
+
invoice_link: str | None = None
|
|
41
|
+
premium_bot: str | None = None
|
|
42
|
+
reason: str = ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PremiumLimit(Model):
|
|
46
|
+
"""One `*_limit_default` / `*_limit_premium` pair."""
|
|
47
|
+
|
|
48
|
+
name: str
|
|
49
|
+
default: int = 0
|
|
50
|
+
premium: int = 0
|
|
51
|
+
source: str = "app-config"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PremiumFeatures(Model):
|
|
55
|
+
status_text: str = ""
|
|
56
|
+
period_options: list[dict[str, Any]] = []
|
|
57
|
+
video_sections: list[str] = []
|
|
58
|
+
limits: list[PremiumLimit] = []
|
|
59
|
+
#: Assembled from `channel_*_level_min` / `group_*_level_min`.
|
|
60
|
+
boost_levels: list[dict[str, Any]] = []
|
|
61
|
+
channel_level: int | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class PremiumGiftOption(Model):
|
|
65
|
+
users: int
|
|
66
|
+
months: int = 0
|
|
67
|
+
currency: str = ""
|
|
68
|
+
amount: int = 0
|
|
69
|
+
store_product: str | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class PremiumGiftQuote(Model):
|
|
73
|
+
"""The price of gifting Premium, and the refusal to pay it."""
|
|
74
|
+
|
|
75
|
+
user_id: int
|
|
76
|
+
#: No default: a refusal that can be mistaken for an absent field is a
|
|
77
|
+
#: refusal a caller may act on as a success.
|
|
78
|
+
ok: bool
|
|
79
|
+
months: int = 0
|
|
80
|
+
stars: int = 0
|
|
81
|
+
currency: str = "XTR"
|
|
82
|
+
reason: str = ""
|
|
83
|
+
form_id: int | None = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class GiftCode(Model):
|
|
87
|
+
"""`payments.checkGiftCode`, plus the link it came from."""
|
|
88
|
+
|
|
89
|
+
slug: str = ""
|
|
90
|
+
link: str = ""
|
|
91
|
+
from_id: int | None = None
|
|
92
|
+
to_id: int | None = None
|
|
93
|
+
date: str | None = None
|
|
94
|
+
date_unix: int | None = None
|
|
95
|
+
months: int | None = None
|
|
96
|
+
days: int | None = None
|
|
97
|
+
used_date: str | None = None
|
|
98
|
+
via_giveaway: bool = False
|
|
99
|
+
giveaway_msg_id: int | None = None
|
|
100
|
+
used: bool = False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class GiftCodeApplied(Model):
|
|
104
|
+
slug: str
|
|
105
|
+
applied: bool
|
|
106
|
+
months: int | None = None
|
|
107
|
+
until_date: str | None = None
|
|
108
|
+
already: bool = False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class GiveawayWinner(Model):
|
|
112
|
+
user_id: int
|
|
113
|
+
user: Peer | None = None
|
|
114
|
+
slug: str | None = None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class GiveawayInfo(Model):
|
|
118
|
+
"""`payments.getGiveawayInfo` — the personal "did I win?" answer."""
|
|
119
|
+
|
|
120
|
+
#: ongoing | finished
|
|
121
|
+
state: str
|
|
122
|
+
chat_id: int = 0
|
|
123
|
+
msg_id: int = 0
|
|
124
|
+
start_date: str | None = None
|
|
125
|
+
until_date: str | None = None
|
|
126
|
+
winners_count: int | None = None
|
|
127
|
+
months: int | None = None
|
|
128
|
+
stars: int | None = None
|
|
129
|
+
only_new_subscribers: bool = False
|
|
130
|
+
countries: list[str] = []
|
|
131
|
+
joined: bool = False
|
|
132
|
+
#: participating | already-participating | disallowed-country | admin |
|
|
133
|
+
#: joined-too-early — why this account cannot take part.
|
|
134
|
+
disallowed_reason: str | None = None
|
|
135
|
+
winner: bool = False
|
|
136
|
+
refunded: bool = False
|
|
137
|
+
gift_code_slug: str | None = None
|
|
138
|
+
activated_count: int | None = None
|
|
139
|
+
winners: list[GiveawayWinner] = []
|
|
140
|
+
prize_description: str | None = None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class PrepaidGiveaway(Model):
|
|
144
|
+
id: int
|
|
145
|
+
quantity: int = 0
|
|
146
|
+
months: int | None = None
|
|
147
|
+
stars: int | None = None
|
|
148
|
+
boosts: int | None = None
|
|
149
|
+
date: str | None = None
|
|
150
|
+
date_unix: int | None = None
|
|
151
|
+
slug: str | None = None
|
|
152
|
+
used: bool = False
|
|
153
|
+
from_chat: int | None = None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class GiveawayLaunched(Model):
|
|
157
|
+
chat_id: int
|
|
158
|
+
prepaid_id: int = 0
|
|
159
|
+
msg_id: int | None = None
|
|
160
|
+
winners_count: int = 0
|
|
161
|
+
until_date: str | None = None
|