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/_send.py ADDED
@@ -0,0 +1,593 @@
1
+ """The send path, in one place: options, entities, reply targets, media.
2
+
3
+ `message send`, `message forward`, `message edit` and `draft set` all build the
4
+ same three things — a resolved peer, a `(text, entities)` pair and a reply
5
+ target — and v1 built each of them differently in each command. Everything
6
+ here is shared so that `--parse`, `--quote`, `--topic` and `--schedule` mean
7
+ exactly one thing across the surface.
8
+
9
+ **Telethon is imported inside functions, never at module scope.** `tlgr --help`
10
+ imports the registry, which imports every op module; a module-level
11
+ `import telethon` would put a 200 ms import and a hard dependency in front of
12
+ `tlgr --version` on a machine that has never connected to Telegram (§2.2).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import os
19
+ import sys
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Annotated, Any
23
+
24
+ from tlgr.core.errors import NotSupportedError, UsageError
25
+ from tlgr.core.text import default_parse_mode, entities_from_json, parse_text, utf16_len
26
+ from tlgr.core.timefmt import parse_dt
27
+ from tlgr.models.base import Request
28
+ from tlgr.models.message import Message, MessageEntity
29
+ from tlgr.models.peer import PeerRef
30
+ from tlgr.ops._params import choice, opt
31
+ from tlgr.ops._serialize import marked_id, message_to_model
32
+ from tlgr.ops._spec import OpContext
33
+
34
+ __all__ = [
35
+ "MAX_TEXT_UTF16",
36
+ "SCHEDULE_ONLINE",
37
+ "SendOptions",
38
+ "body",
39
+ "input_media",
40
+ "message_from_updates",
41
+ "messages_from_updates",
42
+ "peer_id_of",
43
+ "reply_target",
44
+ "resolve",
45
+ "schedule_at",
46
+ "split_text",
47
+ "tl_entities",
48
+ "typing_seconds",
49
+ ]
50
+
51
+ #: `schedule_date` sentinel meaning "when the recipient is next online".
52
+ SCHEDULE_ONLINE = 0x7FFFFFFE
53
+
54
+ #: Telegram's own limit, counted in UTF-16 units like every other offset.
55
+ MAX_TEXT_UTF16 = 4096
56
+
57
+ _REPEAT_PERIODS = {
58
+ "daily": 86400,
59
+ "weekly": 604800,
60
+ "biweekly": 1209600,
61
+ "monthly": 2592000,
62
+ "quarterly": 7776000,
63
+ "halfyearly": 15552000,
64
+ "yearly": 31536000,
65
+ }
66
+
67
+
68
+ class SendOptions(Request, kw_only=True):
69
+ """The send-time options STYLE §3 gives every "send something" command.
70
+
71
+ A base class rather than a nested struct: the CLI generator maps one
72
+ request field to one flag, so nesting would have produced `--options-silent`.
73
+ """
74
+
75
+ silent: Annotated[bool, opt("--silent", help="Send without a notification sound.")] = False
76
+ schedule: Annotated[
77
+ str | None,
78
+ opt(
79
+ "--schedule",
80
+ metavar="TS|online",
81
+ help="Send later; 'online' means when the recipient is next online.",
82
+ ),
83
+ ] = None
84
+ repeat: Annotated[
85
+ str | None,
86
+ choice(*_REPEAT_PERIODS, help="Repeating schedule (Premium)."),
87
+ ] = None
88
+ topic: Annotated[
89
+ int | None,
90
+ opt("--topic", metavar="ID", kind="msg_id", help="Send inside a forum topic."),
91
+ ] = None
92
+ send_as: Annotated[
93
+ PeerRef | None,
94
+ opt("--send-as", metavar="PEER", kind="peer", help="Post as a channel or anonymously."),
95
+ ] = None
96
+ effect: Annotated[
97
+ str | None,
98
+ opt("--effect", metavar="ID", help="Animated message effect (private chats)."),
99
+ ] = None
100
+ protect: Annotated[
101
+ bool,
102
+ opt("--protect", "--noforwards", help="noforwards: block forwarding and saving."),
103
+ ] = False
104
+ paid_stars: Annotated[
105
+ int | None,
106
+ opt("--paid-stars", metavar="N", help="Agree to pay N Stars per message."),
107
+ ] = None
108
+ quick_reply: Annotated[
109
+ str | None,
110
+ opt("--quick-reply", metavar="SHORTCUT", help="Send through a business quick reply."),
111
+ ] = None
112
+ typing: Annotated[
113
+ float,
114
+ opt("--typing", metavar="SECONDS", help="Show a typing action first.", ge=0),
115
+ ] = 0.0
116
+ typing_auto: Annotated[
117
+ bool,
118
+ opt("--typing-auto", help="Type for a duration estimated from the text length."),
119
+ ] = False
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Peers
124
+ # ---------------------------------------------------------------------------
125
+
126
+
127
+ async def resolve(ctx: OpContext, ref: PeerRef | str | None) -> Any:
128
+ """The `InputPeer` for *ref* through the account's own resolver (§6.6).
129
+
130
+ Never `client.get_input_entity` directly: the resolver is what makes the
131
+ NOT_FOUND / INDETERMINATE distinction, and it is per account because an
132
+ access hash minted for one account is meaningless to another.
133
+ """
134
+ if ref is None:
135
+ raise UsageError("a chat is required", field="chat")
136
+ resolver = getattr(ctx, "resolver", None)
137
+ if resolver is None: # pragma: no cover - the daemon always supplies one
138
+ raise UsageError("no peer resolver is available in this context")
139
+ return await resolver.resolve(ref)
140
+
141
+
142
+ def peer_id_of(peer: Any) -> int:
143
+ """The marked id of an `InputPeer`, or 0 when it cannot be determined."""
144
+ from telethon import utils
145
+
146
+ try:
147
+ return int(utils.get_peer_id(peer))
148
+ except (TypeError, ValueError):
149
+ return 0
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Text
154
+ # ---------------------------------------------------------------------------
155
+
156
+
157
+ def _stdin_text() -> str:
158
+ if sys.stdin is None or sys.stdin.isatty():
159
+ raise UsageError("'-' was given for the text but stdin is a terminal", field="text")
160
+ return sys.stdin.read()
161
+
162
+
163
+ def body(
164
+ text: str | None,
165
+ *,
166
+ parse: str | None = None,
167
+ entities: str | None = None,
168
+ stdin: bool = False,
169
+ ) -> tuple[str, list[MessageEntity]]:
170
+ """`(plain text, entities)` from what the caller typed.
171
+
172
+ `--entities` wins over `--parse` for the runs it names and is *merged*
173
+ with them otherwise, because the common case is markdown plus one custom
174
+ emoji that markdown cannot express.
175
+ """
176
+ raw = text or ""
177
+ if stdin or raw == "-":
178
+ raw = _stdin_text()
179
+ mode = parse if parse is not None else default_parse_mode()
180
+ plain, parsed = parse_text(raw, mode)
181
+ if entities:
182
+ explicit = entities_from_json(entities)
183
+ kinds = {e.type for e in explicit}
184
+ parsed = [e for e in parsed if e.type not in kinds] + explicit
185
+ parsed.sort(key=lambda e: (e.offset, e.length))
186
+ return plain, parsed
187
+
188
+
189
+ def tl_entities(entities: list[MessageEntity]) -> list[Any] | None:
190
+ """Model entities as the Telethon types the request wants.
191
+
192
+ Unknown types are dropped rather than guessed at: sending an entity
193
+ Telegram does not recognise fails the whole message, and a caller who
194
+ typed a typo would lose the send rather than the formatting.
195
+ """
196
+ from telethon.tl import types
197
+
198
+ out: list[Any] = []
199
+ for entity in entities:
200
+ name = "MessageEntity" + "".join(part.title() for part in entity.type.split("_"))
201
+ klass = getattr(types, name, None)
202
+ if klass is None:
203
+ continue
204
+ kwargs: dict[str, Any] = {"offset": entity.offset, "length": entity.length}
205
+ if entity.url is not None:
206
+ kwargs["url"] = entity.url
207
+ if entity.language is not None:
208
+ kwargs["language"] = entity.language
209
+ if entity.document_id is not None:
210
+ kwargs["document_id"] = entity.document_id
211
+ if entity.collapsed is not None and name == "MessageEntityBlockquote":
212
+ kwargs["collapsed"] = entity.collapsed
213
+ if entity.user_id is not None and name == "MessageEntityMentionName":
214
+ kwargs["user_id"] = entity.user_id
215
+ try:
216
+ out.append(klass(**kwargs))
217
+ except TypeError:
218
+ continue
219
+ return out or None
220
+
221
+
222
+ def split_text(
223
+ text: str, entities: list[MessageEntity], limit: int = MAX_TEXT_UTF16
224
+ ) -> list[tuple[str, list[MessageEntity]]]:
225
+ """Cut over-long text on word boundaries, re-slicing the entities.
226
+
227
+ Telegram counts the 4096 in UTF-16 units, so a message of emoji hits the
228
+ limit at half the characters. Splitting without moving the entities would
229
+ put every bold run in the second part at the wrong offset.
230
+ """
231
+ if utf16_len(text) <= limit:
232
+ return [(text, entities)]
233
+
234
+ parts: list[tuple[str, list[MessageEntity]]] = []
235
+ start = 0 # in UTF-16 units
236
+ remaining = text
237
+ while remaining:
238
+ if utf16_len(remaining) <= limit:
239
+ chunk = remaining
240
+ else:
241
+ # Walk characters until the next one would cross the limit.
242
+ taken, used = [], 0
243
+ for char in remaining:
244
+ width = utf16_len(char)
245
+ if used + width > limit:
246
+ break
247
+ taken.append(char)
248
+ used += width
249
+ chunk = "".join(taken)
250
+ cut = max(chunk.rfind(" "), chunk.rfind("\n"))
251
+ if cut > limit // 4:
252
+ chunk = chunk[: cut + 1]
253
+ width = utf16_len(chunk)
254
+ end = start + width
255
+ sliced = [
256
+ MessageEntity(
257
+ type=e.type,
258
+ offset=max(0, e.offset - start),
259
+ length=min(e.offset + e.length, end) - max(e.offset, start),
260
+ url=e.url,
261
+ user_id=e.user_id,
262
+ language=e.language,
263
+ document_id=e.document_id,
264
+ collapsed=e.collapsed,
265
+ )
266
+ for e in entities
267
+ if e.offset < end and e.offset + e.length > start
268
+ ]
269
+ parts.append((chunk, [e for e in sliced if e.length > 0]))
270
+ remaining = remaining[len(chunk) :]
271
+ start = end
272
+ return parts
273
+
274
+
275
+ def typing_seconds(text: str, *, requested: float = 0.0, auto: bool = False) -> float:
276
+ """How long to show the typing action. Capped at 60 s, as v1 capped it."""
277
+ if requested:
278
+ return min(requested, 60.0)
279
+ if not auto:
280
+ return 0.0
281
+ words = len(text.split())
282
+ return max(2.0, min(30.0, words * 0.35 + 1.5))
283
+
284
+
285
+ async def show_typing(ctx: OpContext, peer: Any, seconds: float) -> None:
286
+ """Hold a typing action, and still wait if the action call fails.
287
+
288
+ The sleep is the humanising part; losing it because the `setTyping` call
289
+ was rejected would make `--typing` silently do nothing.
290
+ """
291
+ if seconds <= 0:
292
+ return
293
+ client = getattr(ctx, "client", None)
294
+ if client is None: # pragma: no cover
295
+ return
296
+ try:
297
+ async with client.action(peer, "typing"):
298
+ await asyncio.sleep(seconds)
299
+ except Exception:
300
+ await asyncio.sleep(seconds)
301
+
302
+
303
+ # ---------------------------------------------------------------------------
304
+ # Reply targets and scheduling
305
+ # ---------------------------------------------------------------------------
306
+
307
+
308
+ async def reply_target(
309
+ ctx: OpContext,
310
+ *,
311
+ reply_to: int | None = None,
312
+ reply_in: PeerRef | None = None,
313
+ quote: str | None = None,
314
+ quote_offset: int | None = None,
315
+ quote_parse: str | None = None,
316
+ topic: int | None = None,
317
+ reply_task: int | None = None,
318
+ reply_poll_option: int | None = None,
319
+ reply_to_story: int | None = None,
320
+ story_peer: PeerRef | None = None,
321
+ direct_to: PeerRef | None = None,
322
+ ) -> Any:
323
+ """The single `InputReplyTo` union every reply variant collapses into.
324
+
325
+ Telegram has one field for "reply to a message", "reply inside a topic",
326
+ "reply to a story", "reply to a checklist task" and "reply to a poll
327
+ option"; expressing them as five independent flags and then building one
328
+ union here is what keeps the combinations from contradicting each other.
329
+ """
330
+ from telethon.tl import types
331
+
332
+ if reply_to_story is not None:
333
+ peer = await resolve(ctx, story_peer) if story_peer is not None else None
334
+ if peer is None:
335
+ raise UsageError("--reply-to-story needs a chat to read the story from", field="chat")
336
+ return types.InputReplyToStory(peer=peer, story_id=reply_to_story)
337
+
338
+ if reply_to is None and topic is None and direct_to is None:
339
+ return None
340
+
341
+ if reply_to is None and direct_to is not None:
342
+ return types.InputReplyToMonoForum(monoforum_peer_id=await resolve(ctx, direct_to))
343
+
344
+ kwargs: dict[str, Any] = {"reply_to_msg_id": reply_to or topic or 0}
345
+ if topic is not None and reply_to is not None:
346
+ kwargs["top_msg_id"] = topic
347
+ if reply_in is not None:
348
+ kwargs["reply_to_peer_id"] = await resolve(ctx, reply_in)
349
+ if quote:
350
+ text, entities = body(quote, parse=quote_parse)
351
+ kwargs["quote_text"] = text
352
+ if entities:
353
+ kwargs["quote_entities"] = tl_entities(entities)
354
+ if quote_offset is not None:
355
+ kwargs["quote_offset"] = quote_offset
356
+ if reply_task is not None:
357
+ kwargs["todo_item_id"] = reply_task
358
+ if reply_poll_option is not None:
359
+ kwargs["poll_option"] = str(reply_poll_option).encode()
360
+ if direct_to is not None:
361
+ kwargs["monoforum_peer_id"] = await resolve(ctx, direct_to)
362
+ return types.InputReplyToMessage(**kwargs)
363
+
364
+
365
+ def schedule_at(value: str | None) -> datetime | None:
366
+ """`--schedule` as the `schedule_date` Telegram wants.
367
+
368
+ `online` is not a time: it is the sentinel 0x7FFFFFFE, which the server
369
+ reads as "deliver when the recipient next comes online". Passing it as a
370
+ datetime is the only way Telethon will serialise it.
371
+ """
372
+ if not value:
373
+ return None
374
+ if value.strip().lower() == "online":
375
+ return datetime.fromtimestamp(SCHEDULE_ONLINE, tz=timezone.utc)
376
+ parsed = parse_dt(value)
377
+ if parsed is None:
378
+ raise UsageError(f"--schedule: cannot read {value!r} as a time", field="schedule")
379
+ return parsed
380
+
381
+
382
+ def repeat_period(value: str | None) -> int | None:
383
+ if not value:
384
+ return None
385
+ period = _REPEAT_PERIODS.get(value)
386
+ if period is None:
387
+ raise UsageError(f"--repeat: expected one of {', '.join(_REPEAT_PERIODS)}", field="repeat")
388
+ return period
389
+
390
+
391
+ def effect_id(value: str | None) -> int | None:
392
+ """`--effect` as the numeric id, accepting the id itself.
393
+
394
+ An emoji is accepted too and resolved by `message effect list`; doing the
395
+ lookup here would put a network call inside every send.
396
+ """
397
+ if not value:
398
+ return None
399
+ try:
400
+ return int(value)
401
+ except ValueError as exc:
402
+ raise UsageError(
403
+ f"--effect: {value!r} is not an effect id; run 'tlgr message effect list' "
404
+ "to find the id for an emoji",
405
+ field="effect",
406
+ ) from exc
407
+
408
+
409
+ # ---------------------------------------------------------------------------
410
+ # Media
411
+ # ---------------------------------------------------------------------------
412
+
413
+
414
+ async def input_media(
415
+ ctx: OpContext,
416
+ source: str,
417
+ *,
418
+ spoiler: bool = False,
419
+ ttl: int | None = None,
420
+ voice: bool = False,
421
+ video_note: bool = False,
422
+ force_file: bool = False,
423
+ file_name: str = "",
424
+ ) -> Any:
425
+ """Turn `--file` into an `InputMedia`, uploading when it names a local file.
426
+
427
+ A URL is handed to Telegram as `InputMediaUploadedDocument`'s URL cousin
428
+ rather than downloaded first: the server fetches it, which is both faster
429
+ and the only way a 2 GB link works from a laptop.
430
+ """
431
+ from telethon.tl import types
432
+
433
+ if source.startswith(("http://", "https://")):
434
+ return types.InputMediaDocumentExternal(url=source, ttl_seconds=ttl, spoiler=spoiler)
435
+
436
+ path = Path(os.path.expanduser(source))
437
+ if not path.exists():
438
+ raise UsageError(f"{source} does not exist", field="file")
439
+
440
+ upload = getattr(ctx, "upload_file", None)
441
+ if upload is None: # pragma: no cover - the daemon always supplies one
442
+ raise UsageError("this context cannot upload files")
443
+ handle = await upload(path)
444
+
445
+ suffix = path.suffix.lower()
446
+ is_photo = suffix in (".jpg", ".jpeg", ".png", ".webp", ".bmp") and not force_file
447
+ if is_photo and not voice and not video_note:
448
+ return types.InputMediaUploadedPhoto(file=handle, ttl_seconds=ttl, spoiler=spoiler)
449
+
450
+ attributes, mime = _attributes(path, voice=voice, video_note=video_note, name=file_name)
451
+ for warning in _warnings(path, voice=voice, video_note=video_note):
452
+ ctx.warn(warning)
453
+ return types.InputMediaUploadedDocument(
454
+ file=handle,
455
+ mime_type=mime,
456
+ attributes=attributes,
457
+ ttl_seconds=ttl,
458
+ spoiler=spoiler,
459
+ force_file=force_file,
460
+ )
461
+
462
+
463
+ def _attributes(
464
+ path: Path, *, voice: bool, video_note: bool, name: str = ""
465
+ ) -> tuple[list[Any], str]:
466
+ """Document attributes plus a mime type, from whatever can read the file."""
467
+ import mimetypes
468
+
469
+ from telethon.tl import types
470
+
471
+ facts = _probe(path)
472
+ mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
473
+ attributes: list[Any] = [types.DocumentAttributeFilename(file_name=name or path.name)]
474
+ duration = int(facts.get("duration") or 0)
475
+ if voice:
476
+ attributes.append(types.DocumentAttributeAudio(duration=duration, voice=True))
477
+ mime = "audio/ogg"
478
+ elif video_note:
479
+ attributes.append(
480
+ types.DocumentAttributeVideo(
481
+ duration=duration,
482
+ w=int(facts.get("width") or 384),
483
+ h=int(facts.get("height") or 384),
484
+ round_message=True,
485
+ )
486
+ )
487
+ mime = "video/mp4"
488
+ elif mime.startswith("video/"):
489
+ attributes.append(
490
+ types.DocumentAttributeVideo(
491
+ duration=duration,
492
+ w=int(facts.get("width") or 0),
493
+ h=int(facts.get("height") or 0),
494
+ supports_streaming=True,
495
+ )
496
+ )
497
+ elif mime.startswith("audio/"):
498
+ attributes.append(types.DocumentAttributeAudio(duration=duration))
499
+ return attributes, mime
500
+
501
+
502
+ def _probe(path: Path) -> dict[str, Any]:
503
+ """Media facts, or nothing. Imported through the context to respect §2.2."""
504
+ from tlgr.core.media import probe
505
+
506
+ return probe(path)
507
+
508
+
509
+ def _warnings(path: Path, *, voice: bool, video_note: bool) -> list[str]:
510
+ from tlgr.core.media import probe_warnings
511
+
512
+ return probe_warnings(path, voice=voice, video_note=video_note)
513
+
514
+
515
+ # ---------------------------------------------------------------------------
516
+ # Reading the reply
517
+ # ---------------------------------------------------------------------------
518
+
519
+
520
+ def messages_from_updates(updates: Any, *, chat_id: int = 0) -> list[Message]:
521
+ """Every message an `Updates` reply carries, as models.
522
+
523
+ Telegram answers a send with the whole update batch rather than the
524
+ message; picking the message out of it is the step v1 skipped, which is
525
+ why `message send` used to report `{"id": …}` and nothing else.
526
+ """
527
+ out: list[Message] = []
528
+ if updates is None:
529
+ return out
530
+ if hasattr(updates, "id") and hasattr(updates, "date") and not hasattr(updates, "updates"):
531
+ return [message_to_model(updates, chat_id=chat_id or None)]
532
+ chats = {c.id: c for c in (getattr(updates, "chats", None) or [])}
533
+ users = {u.id: u for u in (getattr(updates, "users", None) or [])}
534
+ for update in getattr(updates, "updates", None) or []:
535
+ message = getattr(update, "message", None)
536
+ if message is None or not hasattr(message, "id"):
537
+ continue
538
+ resolved = chat_id or _chat_of(message, chats, users)
539
+ out.append(message_to_model(message, chat_id=resolved or None))
540
+ return out
541
+
542
+
543
+ def _chat_of(message: Any, chats: dict[int, Any], users: dict[int, Any]) -> int:
544
+ peer = getattr(message, "peer_id", None)
545
+ for attribute, kind in (("channel_id", "channel"), ("chat_id", "group"), ("user_id", "user")):
546
+ value = getattr(peer, attribute, None)
547
+ if value is None:
548
+ continue
549
+ entity = chats.get(int(value)) or users.get(int(value))
550
+ if kind == "channel" and entity is not None and getattr(entity, "megagroup", False):
551
+ kind = "supergroup"
552
+ return marked_id(int(value), kind)
553
+ return 0
554
+
555
+
556
+ def message_from_updates(updates: Any, *, chat_id: int = 0, sent_text: str = "") -> Message:
557
+ """The one message a send produced.
558
+
559
+ When the update batch carries no message at all — which happens for a
560
+ scheduled send, where the server acknowledges without delivering — a
561
+ stub carrying the id from `UpdateMessageID` is returned rather than an
562
+ error, because the send *did* happen.
563
+ """
564
+ found = messages_from_updates(updates, chat_id=chat_id)
565
+ if found:
566
+ if sent_text and not found[0].text:
567
+ found[0].text = sent_text
568
+ return found[0]
569
+ message_id = 0
570
+ for update in getattr(updates, "updates", None) or []:
571
+ if type(update).__name__ == "UpdateMessageID":
572
+ message_id = int(getattr(update, "id", 0) or 0)
573
+ break
574
+ date = getattr(updates, "date", None)
575
+ from tlgr.core.timefmt import fmt_dt, to_unix
576
+
577
+ return Message(
578
+ id=message_id,
579
+ chat_id=chat_id,
580
+ date=fmt_dt(date) or "",
581
+ date_unix=to_unix(date) or 0,
582
+ text=sent_text,
583
+ out=True,
584
+ )
585
+
586
+
587
+ def require_supported(feature: str, reason: str) -> None:
588
+ """Refuse a feature this build genuinely cannot perform.
589
+
590
+ Exit 13 rather than 1: "tlgr cannot do this" is not "the operation
591
+ failed", and an agent must be able to tell them apart (§7.3).
592
+ """
593
+ raise NotSupportedError(f"{feature} is not supported: {reason}")