tlgr-cli 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. tlgr/__init__.py +3 -0
  2. tlgr/__main__.py +6 -0
  3. tlgr/actions/__init__.py +45 -0
  4. tlgr/actions/forward.py +74 -0
  5. tlgr/actions/reply.py +32 -0
  6. tlgr/cli/__init__.py +259 -0
  7. tlgr/cli/confirm.py +55 -0
  8. tlgr/cli/errors.py +84 -0
  9. tlgr/cli/gen.py +690 -0
  10. tlgr/cli/globals.py +273 -0
  11. tlgr/cli/introspect.py +170 -0
  12. tlgr/cli/params.py +189 -0
  13. tlgr/cli/render.py +418 -0
  14. tlgr/core/__init__.py +0 -0
  15. tlgr/core/accounts.py +384 -0
  16. tlgr/core/config.py +358 -0
  17. tlgr/core/custom_tl.py +170 -0
  18. tlgr/core/errors.py +687 -0
  19. tlgr/core/eventtypes.py +1170 -0
  20. tlgr/core/identity.py +127 -0
  21. tlgr/core/launchd.py +122 -0
  22. tlgr/core/logging.py +194 -0
  23. tlgr/core/media.py +134 -0
  24. tlgr/core/output.py +251 -0
  25. tlgr/core/pagination.py +227 -0
  26. tlgr/core/paths.py +360 -0
  27. tlgr/core/peers.py +427 -0
  28. tlgr/core/process.py +138 -0
  29. tlgr/core/signing.py +38 -0
  30. tlgr/core/systemd.py +96 -0
  31. tlgr/core/telethon_compat.py +295 -0
  32. tlgr/core/text.py +211 -0
  33. tlgr/core/timefmt.py +199 -0
  34. tlgr/core/tl.py +98 -0
  35. tlgr/daemon/__init__.py +0 -0
  36. tlgr/daemon/app.py +869 -0
  37. tlgr/daemon/dispatch.py +446 -0
  38. tlgr/daemon/events.py +723 -0
  39. tlgr/daemon/files.py +431 -0
  40. tlgr/daemon/idle.py +119 -0
  41. tlgr/daemon/jobs.py +68 -0
  42. tlgr/daemon/main.py +161 -0
  43. tlgr/daemon/peercred.py +75 -0
  44. tlgr/daemon/policy.py +113 -0
  45. tlgr/daemon/preauth.py +366 -0
  46. tlgr/daemon/ratelimit.py +391 -0
  47. tlgr/daemon/server.py +24 -0
  48. tlgr/daemon/session.py +648 -0
  49. tlgr/daemon/sessions.py +274 -0
  50. tlgr/daemon/singleton.py +114 -0
  51. tlgr/daemon/stream.py +193 -0
  52. tlgr/daemon/transfers.py +219 -0
  53. tlgr/daemon/webhook.py +390 -0
  54. tlgr/data/catalog_index.json +1 -0
  55. tlgr/data/parity_waivers.toml +90 -0
  56. tlgr/filters/__init__.py +42 -0
  57. tlgr/filters/compose.py +121 -0
  58. tlgr/filters/content.py +85 -0
  59. tlgr/filters/context.py +114 -0
  60. tlgr/filters/message.py +161 -0
  61. tlgr/filters/temporal.py +87 -0
  62. tlgr/filters/user.py +36 -0
  63. tlgr/gateway/__init__.py +1 -0
  64. tlgr/gateway/config.py +161 -0
  65. tlgr/gateway/engine.py +215 -0
  66. tlgr/gateway/event.py +22 -0
  67. tlgr/jobs/__init__.py +0 -0
  68. tlgr/jobs/base.py +81 -0
  69. tlgr/jobs/client.py +37 -0
  70. tlgr/models/__init__.py +1220 -0
  71. tlgr/models/admin.py +744 -0
  72. tlgr/models/auth.py +510 -0
  73. tlgr/models/base.py +81 -0
  74. tlgr/models/bot.py +576 -0
  75. tlgr/models/business.py +265 -0
  76. tlgr/models/call.py +586 -0
  77. tlgr/models/config.py +101 -0
  78. tlgr/models/contact.py +481 -0
  79. tlgr/models/daemon.py +336 -0
  80. tlgr/models/dialog.py +626 -0
  81. tlgr/models/envelope.py +68 -0
  82. tlgr/models/error.py +30 -0
  83. tlgr/models/event.py +79 -0
  84. tlgr/models/export.py +66 -0
  85. tlgr/models/gift.py +275 -0
  86. tlgr/models/inline.py +84 -0
  87. tlgr/models/location.py +115 -0
  88. tlgr/models/media.py +507 -0
  89. tlgr/models/message.py +584 -0
  90. tlgr/models/net.py +232 -0
  91. tlgr/models/notify.py +105 -0
  92. tlgr/models/page.py +32 -0
  93. tlgr/models/payment.py +172 -0
  94. tlgr/models/peer.py +400 -0
  95. tlgr/models/poll.py +119 -0
  96. tlgr/models/premium.py +161 -0
  97. tlgr/models/privacy.py +93 -0
  98. tlgr/models/profile.py +217 -0
  99. tlgr/models/reaction.py +160 -0
  100. tlgr/models/resolve.py +175 -0
  101. tlgr/models/settings.py +103 -0
  102. tlgr/models/stars.py +101 -0
  103. tlgr/models/sticker.py +243 -0
  104. tlgr/models/story.py +467 -0
  105. tlgr/models/sync.py +105 -0
  106. tlgr/models/todo.py +36 -0
  107. tlgr/models/webapp.py +89 -0
  108. tlgr/ops/__init__.py +63 -0
  109. tlgr/ops/_admin.py +313 -0
  110. tlgr/ops/_auth.py +599 -0
  111. tlgr/ops/_bots.py +586 -0
  112. tlgr/ops/_calls.py +535 -0
  113. tlgr/ops/_common.py +160 -0
  114. tlgr/ops/_layer.py +46 -0
  115. tlgr/ops/_media.py +592 -0
  116. tlgr/ops/_params.py +212 -0
  117. tlgr/ops/_rights.py +402 -0
  118. tlgr/ops/_send.py +593 -0
  119. tlgr/ops/_serialize.py +667 -0
  120. tlgr/ops/_settings.py +306 -0
  121. tlgr/ops/_spec.py +167 -0
  122. tlgr/ops/_story.py +743 -0
  123. tlgr/ops/account.py +2604 -0
  124. tlgr/ops/agent.py +937 -0
  125. tlgr/ops/auth.py +1282 -0
  126. tlgr/ops/bot.py +4880 -0
  127. tlgr/ops/business.py +1520 -0
  128. tlgr/ops/call.py +1610 -0
  129. tlgr/ops/chat.py +4025 -0
  130. tlgr/ops/chat_admin.py +929 -0
  131. tlgr/ops/chat_extra.py +1061 -0
  132. tlgr/ops/chat_invite.py +716 -0
  133. tlgr/ops/chat_manage.py +1691 -0
  134. tlgr/ops/chat_member.py +1357 -0
  135. tlgr/ops/chat_stats.py +902 -0
  136. tlgr/ops/chat_topic.py +905 -0
  137. tlgr/ops/conference.py +791 -0
  138. tlgr/ops/config.py +1698 -0
  139. tlgr/ops/contact.py +2330 -0
  140. tlgr/ops/daemon.py +1397 -0
  141. tlgr/ops/draft.py +299 -0
  142. tlgr/ops/emoji.py +343 -0
  143. tlgr/ops/events.py +1327 -0
  144. tlgr/ops/export.py +596 -0
  145. tlgr/ops/folder.py +1322 -0
  146. tlgr/ops/gif.py +522 -0
  147. tlgr/ops/gift.py +1546 -0
  148. tlgr/ops/giveaway.py +541 -0
  149. tlgr/ops/inline.py +773 -0
  150. tlgr/ops/job.py +799 -0
  151. tlgr/ops/location.py +917 -0
  152. tlgr/ops/media.py +4495 -0
  153. tlgr/ops/message.py +3769 -0
  154. tlgr/ops/net.py +536 -0
  155. tlgr/ops/notify.py +840 -0
  156. tlgr/ops/passport.py +464 -0
  157. tlgr/ops/payment.py +907 -0
  158. tlgr/ops/poll.py +1078 -0
  159. tlgr/ops/premium.py +488 -0
  160. tlgr/ops/privacy.py +794 -0
  161. tlgr/ops/profile.py +1481 -0
  162. tlgr/ops/proxy.py +750 -0
  163. tlgr/ops/reaction.py +1475 -0
  164. tlgr/ops/resolve.py +1140 -0
  165. tlgr/ops/search.py +521 -0
  166. tlgr/ops/settings.py +1066 -0
  167. tlgr/ops/stars.py +594 -0
  168. tlgr/ops/sticker.py +1602 -0
  169. tlgr/ops/story.py +3216 -0
  170. tlgr/ops/sync.py +788 -0
  171. tlgr/ops/todo.py +514 -0
  172. tlgr/ops/user.py +1406 -0
  173. tlgr/ops/vc.py +2351 -0
  174. tlgr/ops/webapp.py +717 -0
  175. tlgr/ops/webhook.py +418 -0
  176. tlgr/parity.py +386 -0
  177. tlgr/processors/__init__.py +125 -0
  178. tlgr/processors/regex.py +26 -0
  179. tlgr/processors/text.py +56 -0
  180. tlgr/registry.py +519 -0
  181. tlgr/schema.py +173 -0
  182. tlgr/transport/__init__.py +30 -0
  183. tlgr/transport/autostart.py +293 -0
  184. tlgr/transport/client.py +805 -0
  185. tlgr/transport/ndjson.py +44 -0
  186. tlgr/version.py +31 -0
  187. tlgr_cli-2.0.1.dist-info/METADATA +957 -0
  188. tlgr_cli-2.0.1.dist-info/RECORD +192 -0
  189. tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
  190. tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
  191. tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
  192. tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/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