tlgr-cli 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. tlgr/__init__.py +3 -0
  2. tlgr/__main__.py +6 -0
  3. tlgr/actions/__init__.py +45 -0
  4. tlgr/actions/forward.py +74 -0
  5. tlgr/actions/reply.py +32 -0
  6. tlgr/cli/__init__.py +259 -0
  7. tlgr/cli/confirm.py +55 -0
  8. tlgr/cli/errors.py +84 -0
  9. tlgr/cli/gen.py +690 -0
  10. tlgr/cli/globals.py +273 -0
  11. tlgr/cli/introspect.py +170 -0
  12. tlgr/cli/params.py +189 -0
  13. tlgr/cli/render.py +418 -0
  14. tlgr/core/__init__.py +0 -0
  15. tlgr/core/accounts.py +384 -0
  16. tlgr/core/config.py +358 -0
  17. tlgr/core/custom_tl.py +170 -0
  18. tlgr/core/errors.py +687 -0
  19. tlgr/core/eventtypes.py +1170 -0
  20. tlgr/core/identity.py +127 -0
  21. tlgr/core/launchd.py +122 -0
  22. tlgr/core/logging.py +194 -0
  23. tlgr/core/media.py +134 -0
  24. tlgr/core/output.py +251 -0
  25. tlgr/core/pagination.py +227 -0
  26. tlgr/core/paths.py +360 -0
  27. tlgr/core/peers.py +427 -0
  28. tlgr/core/process.py +138 -0
  29. tlgr/core/signing.py +38 -0
  30. tlgr/core/systemd.py +96 -0
  31. tlgr/core/telethon_compat.py +295 -0
  32. tlgr/core/text.py +211 -0
  33. tlgr/core/timefmt.py +199 -0
  34. tlgr/core/tl.py +98 -0
  35. tlgr/daemon/__init__.py +0 -0
  36. tlgr/daemon/app.py +869 -0
  37. tlgr/daemon/dispatch.py +446 -0
  38. tlgr/daemon/events.py +723 -0
  39. tlgr/daemon/files.py +431 -0
  40. tlgr/daemon/idle.py +119 -0
  41. tlgr/daemon/jobs.py +68 -0
  42. tlgr/daemon/main.py +161 -0
  43. tlgr/daemon/peercred.py +75 -0
  44. tlgr/daemon/policy.py +113 -0
  45. tlgr/daemon/preauth.py +366 -0
  46. tlgr/daemon/ratelimit.py +391 -0
  47. tlgr/daemon/server.py +24 -0
  48. tlgr/daemon/session.py +648 -0
  49. tlgr/daemon/sessions.py +274 -0
  50. tlgr/daemon/singleton.py +114 -0
  51. tlgr/daemon/stream.py +193 -0
  52. tlgr/daemon/transfers.py +219 -0
  53. tlgr/daemon/webhook.py +390 -0
  54. tlgr/data/catalog_index.json +1 -0
  55. tlgr/data/parity_waivers.toml +90 -0
  56. tlgr/filters/__init__.py +42 -0
  57. tlgr/filters/compose.py +121 -0
  58. tlgr/filters/content.py +85 -0
  59. tlgr/filters/context.py +114 -0
  60. tlgr/filters/message.py +161 -0
  61. tlgr/filters/temporal.py +87 -0
  62. tlgr/filters/user.py +36 -0
  63. tlgr/gateway/__init__.py +1 -0
  64. tlgr/gateway/config.py +161 -0
  65. tlgr/gateway/engine.py +215 -0
  66. tlgr/gateway/event.py +22 -0
  67. tlgr/jobs/__init__.py +0 -0
  68. tlgr/jobs/base.py +81 -0
  69. tlgr/jobs/client.py +37 -0
  70. tlgr/models/__init__.py +1220 -0
  71. tlgr/models/admin.py +744 -0
  72. tlgr/models/auth.py +510 -0
  73. tlgr/models/base.py +81 -0
  74. tlgr/models/bot.py +576 -0
  75. tlgr/models/business.py +265 -0
  76. tlgr/models/call.py +586 -0
  77. tlgr/models/config.py +101 -0
  78. tlgr/models/contact.py +481 -0
  79. tlgr/models/daemon.py +336 -0
  80. tlgr/models/dialog.py +626 -0
  81. tlgr/models/envelope.py +68 -0
  82. tlgr/models/error.py +30 -0
  83. tlgr/models/event.py +79 -0
  84. tlgr/models/export.py +66 -0
  85. tlgr/models/gift.py +275 -0
  86. tlgr/models/inline.py +84 -0
  87. tlgr/models/location.py +115 -0
  88. tlgr/models/media.py +507 -0
  89. tlgr/models/message.py +584 -0
  90. tlgr/models/net.py +232 -0
  91. tlgr/models/notify.py +105 -0
  92. tlgr/models/page.py +32 -0
  93. tlgr/models/payment.py +172 -0
  94. tlgr/models/peer.py +400 -0
  95. tlgr/models/poll.py +119 -0
  96. tlgr/models/premium.py +161 -0
  97. tlgr/models/privacy.py +93 -0
  98. tlgr/models/profile.py +217 -0
  99. tlgr/models/reaction.py +160 -0
  100. tlgr/models/resolve.py +175 -0
  101. tlgr/models/settings.py +103 -0
  102. tlgr/models/stars.py +101 -0
  103. tlgr/models/sticker.py +243 -0
  104. tlgr/models/story.py +467 -0
  105. tlgr/models/sync.py +105 -0
  106. tlgr/models/todo.py +36 -0
  107. tlgr/models/webapp.py +89 -0
  108. tlgr/ops/__init__.py +63 -0
  109. tlgr/ops/_admin.py +313 -0
  110. tlgr/ops/_auth.py +599 -0
  111. tlgr/ops/_bots.py +586 -0
  112. tlgr/ops/_calls.py +535 -0
  113. tlgr/ops/_common.py +160 -0
  114. tlgr/ops/_layer.py +46 -0
  115. tlgr/ops/_media.py +592 -0
  116. tlgr/ops/_params.py +212 -0
  117. tlgr/ops/_rights.py +402 -0
  118. tlgr/ops/_send.py +593 -0
  119. tlgr/ops/_serialize.py +667 -0
  120. tlgr/ops/_settings.py +306 -0
  121. tlgr/ops/_spec.py +167 -0
  122. tlgr/ops/_story.py +743 -0
  123. tlgr/ops/account.py +2604 -0
  124. tlgr/ops/agent.py +937 -0
  125. tlgr/ops/auth.py +1282 -0
  126. tlgr/ops/bot.py +4880 -0
  127. tlgr/ops/business.py +1520 -0
  128. tlgr/ops/call.py +1610 -0
  129. tlgr/ops/chat.py +4025 -0
  130. tlgr/ops/chat_admin.py +929 -0
  131. tlgr/ops/chat_extra.py +1061 -0
  132. tlgr/ops/chat_invite.py +716 -0
  133. tlgr/ops/chat_manage.py +1691 -0
  134. tlgr/ops/chat_member.py +1357 -0
  135. tlgr/ops/chat_stats.py +902 -0
  136. tlgr/ops/chat_topic.py +905 -0
  137. tlgr/ops/conference.py +791 -0
  138. tlgr/ops/config.py +1698 -0
  139. tlgr/ops/contact.py +2330 -0
  140. tlgr/ops/daemon.py +1397 -0
  141. tlgr/ops/draft.py +299 -0
  142. tlgr/ops/emoji.py +343 -0
  143. tlgr/ops/events.py +1327 -0
  144. tlgr/ops/export.py +596 -0
  145. tlgr/ops/folder.py +1322 -0
  146. tlgr/ops/gif.py +522 -0
  147. tlgr/ops/gift.py +1546 -0
  148. tlgr/ops/giveaway.py +541 -0
  149. tlgr/ops/inline.py +773 -0
  150. tlgr/ops/job.py +799 -0
  151. tlgr/ops/location.py +917 -0
  152. tlgr/ops/media.py +4495 -0
  153. tlgr/ops/message.py +3769 -0
  154. tlgr/ops/net.py +536 -0
  155. tlgr/ops/notify.py +840 -0
  156. tlgr/ops/passport.py +464 -0
  157. tlgr/ops/payment.py +907 -0
  158. tlgr/ops/poll.py +1078 -0
  159. tlgr/ops/premium.py +488 -0
  160. tlgr/ops/privacy.py +794 -0
  161. tlgr/ops/profile.py +1481 -0
  162. tlgr/ops/proxy.py +750 -0
  163. tlgr/ops/reaction.py +1475 -0
  164. tlgr/ops/resolve.py +1140 -0
  165. tlgr/ops/search.py +521 -0
  166. tlgr/ops/settings.py +1066 -0
  167. tlgr/ops/stars.py +594 -0
  168. tlgr/ops/sticker.py +1602 -0
  169. tlgr/ops/story.py +3216 -0
  170. tlgr/ops/sync.py +788 -0
  171. tlgr/ops/todo.py +514 -0
  172. tlgr/ops/user.py +1406 -0
  173. tlgr/ops/vc.py +2351 -0
  174. tlgr/ops/webapp.py +717 -0
  175. tlgr/ops/webhook.py +418 -0
  176. tlgr/parity.py +386 -0
  177. tlgr/processors/__init__.py +125 -0
  178. tlgr/processors/regex.py +26 -0
  179. tlgr/processors/text.py +56 -0
  180. tlgr/registry.py +519 -0
  181. tlgr/schema.py +173 -0
  182. tlgr/transport/__init__.py +30 -0
  183. tlgr/transport/autostart.py +293 -0
  184. tlgr/transport/client.py +805 -0
  185. tlgr/transport/ndjson.py +44 -0
  186. tlgr/version.py +31 -0
  187. tlgr_cli-2.0.1.dist-info/METADATA +957 -0
  188. tlgr_cli-2.0.1.dist-info/RECORD +192 -0
  189. tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
  190. tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
  191. tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
  192. tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/ops/_layer.py ADDED
@@ -0,0 +1,46 @@
1
+ """What this build genuinely cannot do, said once and said the same way.
2
+
3
+ Telethon 1.44 speaks MTProto layer 227. Two server features tlgr's command
4
+ surface names — Communities and the `ephemeral.*` welcome messages — are
5
+ layer 229 and have no TL classes to call. The commands are registered anyway
6
+ and refuse with `NOT_SUPPORTED` (exit 13 — "tlgr cannot do this", not "the
7
+ operation failed", §7.3), because a command that is absent teaches an agent
8
+ nothing while a command that refuses with a reason teaches it exactly what is
9
+ missing and when it will arrive.
10
+
11
+ `ARCHITECTURE §6.14` describes the escape hatch (`core/custom_tl.py`) that
12
+ would close these: hand-written `TLRequest` subclasses invoked with an
13
+ explicit layer. Nothing on the P0/P1 path needs it, so the gap is declared
14
+ rather than hand-rolled.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from tlgr.core.errors import NotSupportedError
20
+
21
+ __all__ = ["LAYER", "NEEDED_LAYER", "community_gap", "welcome_gap"]
22
+
23
+ #: The layer the installed Telethon speaks.
24
+ LAYER = 227
25
+
26
+ #: The layer these features arrived in.
27
+ NEEDED_LAYER = 229
28
+
29
+
30
+ def _refuse(command: str, methods: str, feature: str) -> None:
31
+ raise NotSupportedError(
32
+ f"{command} needs {methods}, which arrived in MTProto layer {NEEDED_LAYER}; "
33
+ f"Telethon 1.44 speaks layer {LAYER}, so tlgr has no request class to send. "
34
+ f"The {feature} surface is reserved: the command shape is settled and will "
35
+ "start working with the layer uplift, without a flag or a path changing"
36
+ )
37
+
38
+
39
+ def community_gap(command: str, methods: str) -> None:
40
+ """Refuse a `communities.*` command (layer 229)."""
41
+ _refuse(command, methods, "Community")
42
+
43
+
44
+ def welcome_gap(command: str, methods: str) -> None:
45
+ """Refuse an `ephemeral.*` welcome-message command (layer 229)."""
46
+ _refuse(command, methods, "welcome-message")
tlgr/ops/_media.py ADDED
@@ -0,0 +1,592 @@
1
+ """What the four media modules share: references, documents and set names.
2
+
3
+ Three things are hard about media and all three are reference handling:
4
+
5
+ * **a `file_reference` expires.** Every `InputDocument`/`InputPhoto` tlgr
6
+ builds is derived from an object fetched *now* — a message re-read, a
7
+ sticker set re-fetched, the saved-GIF list re-listed — because a stale
8
+ reference fails with `FILE_REFERENCE_EXPIRED` and the fix is never a retry,
9
+ it is a re-fetch (§api file-references).
10
+ * **a document does not say what it is.** `MessageMediaDocument` is the same
11
+ class for a thumbs-up sticker, a voice note, a GIF and a PDF; the answer is
12
+ in the attributes, and only after *all* of them are collected.
13
+ * **a sticker set has four spellings** — short name, t.me link, numeric id and
14
+ the system sets the clients use internally — and every command in the group
15
+ accepts all four.
16
+
17
+ Telethon is imported inside functions, never at module scope: importing the
18
+ registry builds `tlgr --help`, and that must not pull in Telethon (§2.2).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import base64
24
+ from typing import Any
25
+
26
+ from tlgr.core.errors import NotFoundError, UsageError
27
+ from tlgr.core.pagination import PageKind, decode_cursor
28
+ from tlgr.core.timefmt import fmt_dt, to_unix
29
+ from tlgr.models.media import MediaFile, MediaSize
30
+ from tlgr.models.sticker import SavedGif, Sticker, StickerSet
31
+ from tlgr.ops._spec import OpContext
32
+
33
+ __all__ = [
34
+ "MEDIA_FILTERS",
35
+ "SYSTEM_SETS",
36
+ "app_config",
37
+ "b64",
38
+ "client",
39
+ "document_of",
40
+ "file_id_of",
41
+ "gif_model",
42
+ "input_document",
43
+ "input_photo",
44
+ "media_file",
45
+ "media_filter",
46
+ "set_link",
47
+ "sticker_model",
48
+ "sticker_set_model",
49
+ "sticker_set_ref",
50
+ "window",
51
+ ]
52
+
53
+ #: `--type` → the `inputMessagesFilter*` that implements a shared-media tab.
54
+ #: `chat-photo` is the group/channel avatar history: chats have no
55
+ #: `photos.getUserPhotos` equivalent, so the filter is the only way to it.
56
+ MEDIA_FILTERS: dict[str, str] = {
57
+ "photo": "InputMessagesFilterPhotos",
58
+ "video": "InputMessagesFilterVideo",
59
+ "media": "InputMessagesFilterPhotoVideo",
60
+ "file": "InputMessagesFilterDocument",
61
+ "link": "InputMessagesFilterUrl",
62
+ "music": "InputMessagesFilterMusic",
63
+ "voice": "InputMessagesFilterVoice",
64
+ "gif": "InputMessagesFilterGif",
65
+ "round": "InputMessagesFilterRoundVideo",
66
+ "chat-photo": "InputMessagesFilterChatPhotos",
67
+ "all": "InputMessagesFilterEmpty",
68
+ }
69
+
70
+ #: The sets a client holds without ever installing them: dice faces, the
71
+ #: animated-emoji assets, the default status and topic-icon packs.
72
+ SYSTEM_SETS: dict[str, str] = {
73
+ "animated-emoji": "InputStickerSetAnimatedEmoji",
74
+ "emoji-animations": "InputStickerSetAnimatedEmojiAnimations",
75
+ "premium-gifts": "InputStickerSetPremiumGifts",
76
+ "generic-animations": "InputStickerSetEmojiGenericAnimations",
77
+ "default-statuses": "InputStickerSetEmojiDefaultStatuses",
78
+ "channel-statuses": "InputStickerSetEmojiChannelDefaultStatuses",
79
+ "topic-icons": "InputStickerSetEmojiDefaultTopicIcons",
80
+ }
81
+
82
+
83
+ def client(ctx: OpContext) -> Any:
84
+ handle = getattr(ctx, "client", None)
85
+ if handle is None: # pragma: no cover - the daemon always supplies one
86
+ raise UsageError("this operation needs a connected account")
87
+ return handle
88
+
89
+
90
+ def window(ctx: OpContext, op: str, kind: PageKind, default: int = 50) -> tuple[int, Any]:
91
+ """`(limit, cursor state)` — `--limit`/`--cursor` are transport-level (L5)."""
92
+ limit = int(getattr(ctx, "limit", None) or default)
93
+ if limit < 1:
94
+ raise UsageError("--limit must be at least 1", field="limit")
95
+ token = getattr(ctx, "cursor", None)
96
+ state: dict[str, Any] = {}
97
+ if token:
98
+ state = decode_cursor(token, op=op, kind=kind, account=ctx.account)
99
+ return min(limit, 1000), state
100
+
101
+
102
+ def b64(data: Any) -> str | None:
103
+ """Bytes as base64, because JSON has none and a caller round-trips these."""
104
+ if not isinstance(data, (bytes, bytearray)):
105
+ return None
106
+ return base64.b64encode(bytes(data)).decode()
107
+
108
+
109
+ def unb64(text: str | None) -> bytes:
110
+ if not text:
111
+ return b""
112
+ try:
113
+ return base64.b64decode(text)
114
+ except (ValueError, TypeError) as exc:
115
+ raise UsageError(f"{text!r} is not base64", field="file_reference") from exc
116
+
117
+
118
+ def media_filter(name: str | None) -> Any:
119
+ """`--type photo` → `InputMessagesFilterPhotos()`."""
120
+ class_name = MEDIA_FILTERS.get(name or "media")
121
+ if class_name is None:
122
+ raise UsageError(
123
+ f"--type {name!r} is not a media tab; expected one of {', '.join(MEDIA_FILTERS)}",
124
+ field="type",
125
+ )
126
+ from telethon.tl import types
127
+
128
+ return getattr(types, class_name)()
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Documents
133
+ # ---------------------------------------------------------------------------
134
+
135
+
136
+ def document_of(media: Any) -> Any:
137
+ """The `Document` behind whatever wrapper is holding it."""
138
+ if media is None:
139
+ return None
140
+ if type(media).__name__ == "Document":
141
+ return media
142
+ return getattr(media, "document", None)
143
+
144
+
145
+ def photo_of(media: Any) -> Any:
146
+ if media is None:
147
+ return None
148
+ if type(media).__name__ == "Photo":
149
+ return media
150
+ return getattr(media, "photo", None)
151
+
152
+
153
+ def input_document(document: Any) -> Any:
154
+ """`InputDocument` from a live `Document`, reference and all."""
155
+ from telethon.tl import types
156
+
157
+ if document is None:
158
+ raise NotFoundError("this media carries no document")
159
+ if type(document).__name__ == "InputDocument":
160
+ return document
161
+ return types.InputDocument(
162
+ id=int(getattr(document, "id", 0)),
163
+ access_hash=int(getattr(document, "access_hash", 0) or 0),
164
+ file_reference=getattr(document, "file_reference", b"") or b"",
165
+ )
166
+
167
+
168
+ def input_photo(photo: Any) -> Any:
169
+ from telethon.tl import types
170
+
171
+ if photo is None:
172
+ raise NotFoundError("this media carries no photo")
173
+ if type(photo).__name__ == "InputPhoto":
174
+ return photo
175
+ return types.InputPhoto(
176
+ id=int(getattr(photo, "id", 0)),
177
+ access_hash=int(getattr(photo, "access_hash", 0) or 0),
178
+ file_reference=getattr(photo, "file_reference", b"") or b"",
179
+ )
180
+
181
+
182
+ def file_id_of(media: Any) -> str | None:
183
+ """`telethon.utils.pack_bot_file_id` — a convenience handle, not an identity.
184
+
185
+ The Bot-API-shaped string carries **no** `file_reference`, so it is only
186
+ usable while tlgr can refresh one for it (`media file-id get`). Returning
187
+ None rather than raising: a media type that has no packed form is normal.
188
+ """
189
+ if media is None:
190
+ return None
191
+ try:
192
+ from telethon import utils
193
+
194
+ return utils.pack_bot_file_id(media) or None
195
+ except Exception:
196
+ return None
197
+
198
+
199
+ def attributes_of(document: Any) -> dict[str, Any]:
200
+ """Every `DocumentAttribute*` collapsed into one dict.
201
+
202
+ Collected before anything is decided: a GIF carries Video **and**
203
+ Animated and a video sticker carries Video **and** Sticker, so
204
+ "first attribute wins" gets both of them wrong (v1's bug, kept fixed).
205
+ """
206
+ facts: dict[str, Any] = {}
207
+ for attribute in getattr(document, "attributes", None) or []:
208
+ name = type(attribute).__name__
209
+ if name == "DocumentAttributeSticker":
210
+ facts["sticker"] = getattr(attribute, "alt", "") or ""
211
+ facts["mask"] = bool(getattr(attribute, "mask", False))
212
+ facts["stickerset"] = getattr(attribute, "stickerset", None)
213
+ elif name == "DocumentAttributeCustomEmoji":
214
+ facts["custom_emoji"] = getattr(attribute, "alt", "") or ""
215
+ facts["free"] = bool(getattr(attribute, "free", False))
216
+ facts["text_color"] = bool(getattr(attribute, "text_color", False))
217
+ facts["stickerset"] = getattr(attribute, "stickerset", None)
218
+ elif name == "DocumentAttributeAudio":
219
+ facts["voice"] = bool(getattr(attribute, "voice", False))
220
+ facts["duration"] = getattr(attribute, "duration", None)
221
+ facts["title"] = getattr(attribute, "title", None)
222
+ facts["performer"] = getattr(attribute, "performer", None)
223
+ facts["waveform"] = getattr(attribute, "waveform", None) is not None
224
+ elif name == "DocumentAttributeVideo":
225
+ facts["round"] = bool(getattr(attribute, "round_message", False))
226
+ facts["video"] = True
227
+ facts["duration"] = getattr(attribute, "duration", None)
228
+ facts["width"] = getattr(attribute, "w", None)
229
+ facts["height"] = getattr(attribute, "h", None)
230
+ facts["supports_streaming"] = bool(getattr(attribute, "supports_streaming", False))
231
+ facts["nosound"] = bool(getattr(attribute, "nosound", False))
232
+ elif name == "DocumentAttributeAnimated":
233
+ facts["animated"] = True
234
+ elif name == "DocumentAttributeImageSize":
235
+ facts["width"] = getattr(attribute, "w", None)
236
+ facts["height"] = getattr(attribute, "h", None)
237
+ elif name == "DocumentAttributeFilename":
238
+ facts["file_name"] = getattr(attribute, "file_name", None)
239
+ return facts
240
+
241
+
242
+ def kind_of(document: Any, facts: dict[str, Any] | None = None) -> str:
243
+ """`sticker`/`voice`/`video_note`/`gif`/`video`/`audio`/`file`."""
244
+ facts = attributes_of(document) if facts is None else facts
245
+ mime = getattr(document, "mime_type", None)
246
+ if "custom_emoji" in facts:
247
+ return "sticker"
248
+ if "sticker" in facts:
249
+ return "sticker"
250
+ if facts.get("voice"):
251
+ return "voice"
252
+ if facts.get("round"):
253
+ return "video_note"
254
+ if facts.get("animated") or mime == "image/gif":
255
+ return "gif"
256
+ if facts.get("video"):
257
+ return "video"
258
+ if "duration" in facts and not facts.get("video"):
259
+ return "audio"
260
+ return "file"
261
+
262
+
263
+ def photo_sizes(photo: Any) -> list[MediaSize]:
264
+ """Every `PhotoSize` variant, with the inline bytes where there are any."""
265
+ out: list[MediaSize] = []
266
+ for size in getattr(photo, "sizes", None) or []:
267
+ name = type(size).__name__
268
+ out.append(
269
+ MediaSize(
270
+ type=str(getattr(size, "type", "") or ""),
271
+ width=getattr(size, "w", None),
272
+ height=getattr(size, "h", None),
273
+ size=getattr(size, "size", None),
274
+ bytes_b64=b64(getattr(size, "bytes", None))
275
+ if name in ("PhotoStrippedSize", "PhotoPathSize")
276
+ else None,
277
+ )
278
+ )
279
+ for size in getattr(photo, "video_sizes", None) or []:
280
+ out.append(
281
+ MediaSize(
282
+ type=str(getattr(size, "type", "") or ""),
283
+ width=getattr(size, "w", None),
284
+ height=getattr(size, "h", None),
285
+ size=getattr(size, "size", None),
286
+ video=True,
287
+ )
288
+ )
289
+ return out
290
+
291
+
292
+ def media_file(document: Any) -> MediaFile | None:
293
+ if document is None:
294
+ return None
295
+ return MediaFile(
296
+ doc_id=int(getattr(document, "id", 0) or 0),
297
+ access_hash=getattr(document, "access_hash", None),
298
+ file_reference_b64=b64(getattr(document, "file_reference", None)),
299
+ dc_id=getattr(document, "dc_id", None),
300
+ mime=getattr(document, "mime_type", None),
301
+ size=getattr(document, "size", None),
302
+ file_id=file_id_of(document),
303
+ )
304
+
305
+
306
+ def sticker_model(
307
+ document: Any,
308
+ *,
309
+ index: int | None = None,
310
+ set_short_name: str | None = None,
311
+ set_title: str | None = None,
312
+ keywords: list[str] | None = None,
313
+ premium: bool = False,
314
+ ) -> Sticker:
315
+ """One sticker or custom-emoji document as the row every list prints."""
316
+ facts = attributes_of(document)
317
+ stickerset = facts.get("stickerset")
318
+ return Sticker(
319
+ doc_id=int(getattr(document, "id", 0) or 0),
320
+ access_hash=getattr(document, "access_hash", None),
321
+ file_reference_b64=b64(getattr(document, "file_reference", None)),
322
+ emoji=facts.get("custom_emoji") or facts.get("sticker") or None,
323
+ keywords=list(keywords or []),
324
+ index=index,
325
+ set_id=getattr(stickerset, "id", None),
326
+ set_short_name=set_short_name or getattr(stickerset, "short_name", None),
327
+ set=set_title,
328
+ mime=getattr(document, "mime_type", None),
329
+ size=getattr(document, "size", None),
330
+ width=facts.get("width"),
331
+ height=facts.get("height"),
332
+ dc_id=getattr(document, "dc_id", None),
333
+ premium=premium,
334
+ mask=bool(facts.get("mask")),
335
+ custom_emoji="custom_emoji" in facts,
336
+ free=facts.get("free"),
337
+ text_color=facts.get("text_color"),
338
+ file_id=file_id_of(document),
339
+ )
340
+
341
+
342
+ def gif_model(document: Any, index: int | None = None) -> SavedGif:
343
+ facts = attributes_of(document)
344
+ return SavedGif(
345
+ doc_id=int(getattr(document, "id", 0) or 0),
346
+ access_hash=getattr(document, "access_hash", None),
347
+ file_reference_b64=b64(getattr(document, "file_reference", None)),
348
+ index=index,
349
+ mime=getattr(document, "mime_type", None),
350
+ size=getattr(document, "size", None),
351
+ duration=int(facts["duration"]) if facts.get("duration") is not None else None,
352
+ width=facts.get("width"),
353
+ height=facts.get("height"),
354
+ file_id=file_id_of(document),
355
+ )
356
+
357
+
358
+ # ---------------------------------------------------------------------------
359
+ # Sticker sets
360
+ # ---------------------------------------------------------------------------
361
+
362
+
363
+ def set_link(short_name: str, kind: str = "sticker") -> str | None:
364
+ """`t.me/addstickers/<name>`, or `addemoji` for an emoji set.
365
+
366
+ String formatting, not a request: the share link has never needed one.
367
+ """
368
+ if not short_name:
369
+ return None
370
+ path = "addemoji" if kind == "emoji" else "addstickers"
371
+ return f"https://t.me/{path}/{short_name}"
372
+
373
+
374
+ def _short_name_from_link(text: str) -> str | None:
375
+ lowered = text.lower()
376
+ for marker in ("addstickers/", "addemoji/", "stickerset/"):
377
+ if marker in lowered:
378
+ tail = text[lowered.index(marker) + len(marker) :]
379
+ return tail.split("?")[0].split("/")[0].strip()
380
+ return None
381
+
382
+
383
+ def sticker_set_ref(text: str, *, field: str = "set") -> Any:
384
+ """Any of the four spellings of a set as one `InputStickerSet`.
385
+
386
+ A bare numeric id is deliberately **not** accepted: `InputStickerSetID`
387
+ needs an access hash the id alone does not carry, and guessing one
388
+ produces `STICKERSET_INVALID` rather than a useful message. `id:hash`
389
+ is accepted for a caller that has both (that is what `sticker set list`
390
+ prints), and everything else must be a short name or a link.
391
+ """
392
+ from telethon.tl import types
393
+
394
+ value = (text or "").strip()
395
+ if not value:
396
+ raise UsageError("a sticker set is required", field=field)
397
+
398
+ lowered = value.lower()
399
+ if lowered.startswith("dice:"):
400
+ emoticon = value.split(":", 1)[1].strip()
401
+ if not emoticon:
402
+ raise UsageError("dice: needs an emoji, e.g. dice:🎲", field=field)
403
+ return types.InputStickerSetDice(emoticon=emoticon)
404
+ system = SYSTEM_SETS.get(lowered)
405
+ if system is not None:
406
+ return getattr(types, system)()
407
+
408
+ link = _short_name_from_link(value)
409
+ if link:
410
+ return types.InputStickerSetShortName(short_name=link)
411
+
412
+ if ":" in value:
413
+ head, _, tail = value.partition(":")
414
+ if head.lstrip("-").isdigit() and tail.lstrip("-").isdigit():
415
+ return types.InputStickerSetID(id=int(head), access_hash=int(tail))
416
+ if value.lstrip("-").isdigit():
417
+ raise UsageError(
418
+ f"{value!r} is a set id without its access hash; name the set by its short "
419
+ "name or its t.me link, or pass <id>:<access_hash>",
420
+ field=field,
421
+ )
422
+ return types.InputStickerSetShortName(short_name=value.lstrip("@"))
423
+
424
+
425
+ def set_kind(set_obj: Any) -> str:
426
+ if getattr(set_obj, "emojis", False):
427
+ return "emoji"
428
+ if getattr(set_obj, "masks", False):
429
+ return "mask"
430
+ return "sticker"
431
+
432
+
433
+ def sticker_set_model(set_obj: Any, *, covers: list[Any] | None = None) -> StickerSet:
434
+ """A `StickerSet` TL object as the metadata row every listing prints."""
435
+ kind = set_kind(set_obj)
436
+ short_name = str(getattr(set_obj, "short_name", "") or "")
437
+ thumbs = getattr(set_obj, "thumbs", None) or []
438
+ thumb = None
439
+ if thumbs:
440
+ first = thumbs[0]
441
+ thumb = MediaFile(
442
+ doc_id=int(getattr(set_obj, "thumb_document_id", 0) or 0),
443
+ dc_id=getattr(set_obj, "thumb_dc_id", None),
444
+ size=getattr(first, "size", None),
445
+ )
446
+ return StickerSet(
447
+ id=int(getattr(set_obj, "id", 0) or 0),
448
+ access_hash=getattr(set_obj, "access_hash", None),
449
+ short_name=short_name,
450
+ title=str(getattr(set_obj, "title", "") or ""),
451
+ count=int(getattr(set_obj, "count", 0) or 0),
452
+ type=kind,
453
+ installed=getattr(set_obj, "installed_date", None) is not None,
454
+ archived=bool(getattr(set_obj, "archived", False)),
455
+ official=bool(getattr(set_obj, "official", False)),
456
+ creator=bool(getattr(set_obj, "creator", False)),
457
+ text_color=bool(getattr(set_obj, "text_color", False)),
458
+ installed_date=fmt_dt(getattr(set_obj, "installed_date", None)),
459
+ thumb=thumb,
460
+ link=set_link(short_name, kind),
461
+ covers=[sticker_model(doc) for doc in (covers or [])],
462
+ )
463
+
464
+
465
+ def covered_set(covered: Any) -> StickerSet:
466
+ """`stickerSetCovered` / `stickerSetMultiCovered` → one `StickerSet`."""
467
+ inner = getattr(covered, "set", covered)
468
+ covers = list(getattr(covered, "covers", None) or [])
469
+ single = getattr(covered, "cover", None)
470
+ if single is not None:
471
+ covers = [single]
472
+ return sticker_set_model(inner, covers=covers)
473
+
474
+
475
+ async def fetch_set(ctx: OpContext, ref: Any, *, field: str = "set") -> Any:
476
+ """`messages.getStickerSet` for any spelling, with a NOT_FOUND on a miss."""
477
+ from telethon.tl.functions import messages as fn
478
+
479
+ stickerset = ref if not isinstance(ref, str) else sticker_set_ref(ref, field=field)
480
+ try:
481
+ return await client(ctx)(fn.GetStickerSetRequest(stickerset=stickerset, hash=0))
482
+ except Exception as exc:
483
+ if "STICKERSET_INVALID" in str(exc).upper():
484
+ raise NotFoundError(f"no sticker set matches {ref!r}") from exc
485
+ raise
486
+
487
+
488
+ async def resolve_stickers(
489
+ ctx: OpContext,
490
+ refs: list[str],
491
+ *,
492
+ from_message: tuple[Any, int] | None = None,
493
+ ) -> list[Any]:
494
+ """`<set>/<index>`, `<set>/<emoji>` or a document id → live `Document`s.
495
+
496
+ Always through a fresh `messages.getStickerSet`: an `InputDocument` built
497
+ from a cached id carries a dead `file_reference`, and every
498
+ `faveSticker`/`saveRecentSticker`/`removeStickerFromSet` call would fail
499
+ with `FILE_REFERENCE_EXPIRED` a few hours after the id was printed.
500
+ """
501
+ out: list[Any] = []
502
+ if from_message is not None:
503
+ peer, message_id = from_message
504
+ message = await fetch_message(ctx, peer, message_id)
505
+ document = document_of(getattr(message, "media", None))
506
+ if document is None:
507
+ raise NotFoundError(f"message {message_id} carries no sticker")
508
+ out.append(document)
509
+
510
+ cache: dict[str, Any] = {}
511
+ for ref in refs:
512
+ value = str(ref).strip()
513
+ if "/" in value:
514
+ set_name, _, selector = value.rpartition("/")
515
+ if set_name not in cache:
516
+ cache[set_name] = await fetch_set(ctx, set_name, field="sticker")
517
+ documents = list(getattr(cache[set_name], "documents", None) or [])
518
+ out.append(_pick_sticker(documents, selector, value))
519
+ continue
520
+ raise UsageError(
521
+ f"{value!r} is not a sticker; use <set>/<index>, <set>/<emoji> or --from-message",
522
+ field="sticker",
523
+ )
524
+ if not out:
525
+ raise UsageError("no sticker was given", field="sticker")
526
+ return out
527
+
528
+
529
+ def _pick_sticker(documents: list[Any], selector: str, spelling: str) -> Any:
530
+ if selector.lstrip("-").isdigit():
531
+ index = int(selector)
532
+ if not 0 <= index < len(documents):
533
+ raise NotFoundError(f"{spelling}: the set has {len(documents)} stickers")
534
+ return documents[index]
535
+ for document in documents:
536
+ if attributes_of(document).get("sticker") == selector:
537
+ return document
538
+ if attributes_of(document).get("custom_emoji") == selector:
539
+ return document
540
+ raise NotFoundError(f"{spelling}: no sticker in the set carries that emoji")
541
+
542
+
543
+ # ---------------------------------------------------------------------------
544
+ # Messages
545
+ # ---------------------------------------------------------------------------
546
+
547
+
548
+ async def fetch_message(ctx: OpContext, peer: Any, message_id: int) -> Any:
549
+ """Re-read one message so its `file_reference` is current.
550
+
551
+ Every byte-touching path starts here. Telethon refreshes a reference
552
+ mid-download only when it was handed a `Message`; nothing refreshes one
553
+ for a send, so tlgr fetches the message itself and builds the input media
554
+ from what came back.
555
+ """
556
+ found = await client(ctx).get_messages(peer, ids=[int(message_id)])
557
+ message = (found or [None])[0]
558
+ if message is None or not getattr(message, "id", 0):
559
+ raise NotFoundError(f"message {message_id} does not exist here")
560
+ return message
561
+
562
+
563
+ def message_dates(message: Any) -> tuple[str, int]:
564
+ date = getattr(message, "date", None)
565
+ return fmt_dt(date) or "", to_unix(date) or 0
566
+
567
+
568
+ async def app_config(ctx: OpContext) -> dict[str, Any]:
569
+ """`help.getAppConfig` as plain Python. Never hardcode a server limit."""
570
+ from telethon.tl.functions import help as help_fn
571
+
572
+ config = await client(ctx)(help_fn.GetAppConfigRequest(hash=0))
573
+
574
+ def unwrap(node: Any) -> Any:
575
+ name = type(node).__name__
576
+ if name == "JsonObject":
577
+ return {str(v.key): unwrap(v.value) for v in (getattr(node, "value", None) or [])}
578
+ if name == "JsonArray":
579
+ return [unwrap(v) for v in (getattr(node, "value", None) or [])]
580
+ if name == "JsonNull":
581
+ return None
582
+ return getattr(node, "value", node)
583
+
584
+ return unwrap(getattr(config, "config", config)) or {}
585
+
586
+
587
+ def config_int(values: dict[str, Any], name: str, default: int = 0) -> int:
588
+ raw = values.get(name, default)
589
+ try:
590
+ return int(raw)
591
+ except (TypeError, ValueError):
592
+ return default