tlgr-cli 2.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tlgr/__init__.py +3 -0
- tlgr/__main__.py +6 -0
- tlgr/actions/__init__.py +45 -0
- tlgr/actions/forward.py +74 -0
- tlgr/actions/reply.py +32 -0
- tlgr/cli/__init__.py +259 -0
- tlgr/cli/confirm.py +55 -0
- tlgr/cli/errors.py +84 -0
- tlgr/cli/gen.py +690 -0
- tlgr/cli/globals.py +273 -0
- tlgr/cli/introspect.py +170 -0
- tlgr/cli/params.py +189 -0
- tlgr/cli/render.py +418 -0
- tlgr/core/__init__.py +0 -0
- tlgr/core/accounts.py +384 -0
- tlgr/core/config.py +358 -0
- tlgr/core/custom_tl.py +170 -0
- tlgr/core/errors.py +687 -0
- tlgr/core/eventtypes.py +1170 -0
- tlgr/core/identity.py +127 -0
- tlgr/core/launchd.py +122 -0
- tlgr/core/logging.py +194 -0
- tlgr/core/media.py +134 -0
- tlgr/core/output.py +251 -0
- tlgr/core/pagination.py +227 -0
- tlgr/core/paths.py +360 -0
- tlgr/core/peers.py +427 -0
- tlgr/core/process.py +138 -0
- tlgr/core/signing.py +38 -0
- tlgr/core/systemd.py +96 -0
- tlgr/core/telethon_compat.py +295 -0
- tlgr/core/text.py +211 -0
- tlgr/core/timefmt.py +199 -0
- tlgr/core/tl.py +98 -0
- tlgr/daemon/__init__.py +0 -0
- tlgr/daemon/app.py +869 -0
- tlgr/daemon/dispatch.py +446 -0
- tlgr/daemon/events.py +723 -0
- tlgr/daemon/files.py +431 -0
- tlgr/daemon/idle.py +119 -0
- tlgr/daemon/jobs.py +68 -0
- tlgr/daemon/main.py +161 -0
- tlgr/daemon/peercred.py +75 -0
- tlgr/daemon/policy.py +113 -0
- tlgr/daemon/preauth.py +366 -0
- tlgr/daemon/ratelimit.py +391 -0
- tlgr/daemon/server.py +24 -0
- tlgr/daemon/session.py +648 -0
- tlgr/daemon/sessions.py +274 -0
- tlgr/daemon/singleton.py +114 -0
- tlgr/daemon/stream.py +193 -0
- tlgr/daemon/transfers.py +219 -0
- tlgr/daemon/webhook.py +390 -0
- tlgr/data/catalog_index.json +1 -0
- tlgr/data/parity_waivers.toml +90 -0
- tlgr/filters/__init__.py +42 -0
- tlgr/filters/compose.py +121 -0
- tlgr/filters/content.py +85 -0
- tlgr/filters/context.py +114 -0
- tlgr/filters/message.py +161 -0
- tlgr/filters/temporal.py +87 -0
- tlgr/filters/user.py +36 -0
- tlgr/gateway/__init__.py +1 -0
- tlgr/gateway/config.py +161 -0
- tlgr/gateway/engine.py +215 -0
- tlgr/gateway/event.py +22 -0
- tlgr/jobs/__init__.py +0 -0
- tlgr/jobs/base.py +81 -0
- tlgr/jobs/client.py +37 -0
- tlgr/models/__init__.py +1220 -0
- tlgr/models/admin.py +744 -0
- tlgr/models/auth.py +510 -0
- tlgr/models/base.py +81 -0
- tlgr/models/bot.py +576 -0
- tlgr/models/business.py +265 -0
- tlgr/models/call.py +586 -0
- tlgr/models/config.py +101 -0
- tlgr/models/contact.py +481 -0
- tlgr/models/daemon.py +336 -0
- tlgr/models/dialog.py +626 -0
- tlgr/models/envelope.py +68 -0
- tlgr/models/error.py +30 -0
- tlgr/models/event.py +79 -0
- tlgr/models/export.py +66 -0
- tlgr/models/gift.py +275 -0
- tlgr/models/inline.py +84 -0
- tlgr/models/location.py +115 -0
- tlgr/models/media.py +507 -0
- tlgr/models/message.py +584 -0
- tlgr/models/net.py +232 -0
- tlgr/models/notify.py +105 -0
- tlgr/models/page.py +32 -0
- tlgr/models/payment.py +172 -0
- tlgr/models/peer.py +400 -0
- tlgr/models/poll.py +119 -0
- tlgr/models/premium.py +161 -0
- tlgr/models/privacy.py +93 -0
- tlgr/models/profile.py +217 -0
- tlgr/models/reaction.py +160 -0
- tlgr/models/resolve.py +175 -0
- tlgr/models/settings.py +103 -0
- tlgr/models/stars.py +101 -0
- tlgr/models/sticker.py +243 -0
- tlgr/models/story.py +467 -0
- tlgr/models/sync.py +105 -0
- tlgr/models/todo.py +36 -0
- tlgr/models/webapp.py +89 -0
- tlgr/ops/__init__.py +63 -0
- tlgr/ops/_admin.py +313 -0
- tlgr/ops/_auth.py +599 -0
- tlgr/ops/_bots.py +586 -0
- tlgr/ops/_calls.py +535 -0
- tlgr/ops/_common.py +160 -0
- tlgr/ops/_layer.py +46 -0
- tlgr/ops/_media.py +592 -0
- tlgr/ops/_params.py +212 -0
- tlgr/ops/_rights.py +402 -0
- tlgr/ops/_send.py +593 -0
- tlgr/ops/_serialize.py +667 -0
- tlgr/ops/_settings.py +306 -0
- tlgr/ops/_spec.py +167 -0
- tlgr/ops/_story.py +743 -0
- tlgr/ops/account.py +2604 -0
- tlgr/ops/agent.py +937 -0
- tlgr/ops/auth.py +1282 -0
- tlgr/ops/bot.py +4880 -0
- tlgr/ops/business.py +1520 -0
- tlgr/ops/call.py +1610 -0
- tlgr/ops/chat.py +4025 -0
- tlgr/ops/chat_admin.py +929 -0
- tlgr/ops/chat_extra.py +1061 -0
- tlgr/ops/chat_invite.py +716 -0
- tlgr/ops/chat_manage.py +1691 -0
- tlgr/ops/chat_member.py +1357 -0
- tlgr/ops/chat_stats.py +902 -0
- tlgr/ops/chat_topic.py +905 -0
- tlgr/ops/conference.py +791 -0
- tlgr/ops/config.py +1698 -0
- tlgr/ops/contact.py +2330 -0
- tlgr/ops/daemon.py +1397 -0
- tlgr/ops/draft.py +299 -0
- tlgr/ops/emoji.py +343 -0
- tlgr/ops/events.py +1327 -0
- tlgr/ops/export.py +596 -0
- tlgr/ops/folder.py +1322 -0
- tlgr/ops/gif.py +522 -0
- tlgr/ops/gift.py +1546 -0
- tlgr/ops/giveaway.py +541 -0
- tlgr/ops/inline.py +773 -0
- tlgr/ops/job.py +799 -0
- tlgr/ops/location.py +917 -0
- tlgr/ops/media.py +4495 -0
- tlgr/ops/message.py +3769 -0
- tlgr/ops/net.py +536 -0
- tlgr/ops/notify.py +840 -0
- tlgr/ops/passport.py +464 -0
- tlgr/ops/payment.py +907 -0
- tlgr/ops/poll.py +1078 -0
- tlgr/ops/premium.py +488 -0
- tlgr/ops/privacy.py +794 -0
- tlgr/ops/profile.py +1481 -0
- tlgr/ops/proxy.py +750 -0
- tlgr/ops/reaction.py +1475 -0
- tlgr/ops/resolve.py +1140 -0
- tlgr/ops/search.py +521 -0
- tlgr/ops/settings.py +1066 -0
- tlgr/ops/stars.py +594 -0
- tlgr/ops/sticker.py +1602 -0
- tlgr/ops/story.py +3216 -0
- tlgr/ops/sync.py +788 -0
- tlgr/ops/todo.py +514 -0
- tlgr/ops/user.py +1406 -0
- tlgr/ops/vc.py +2351 -0
- tlgr/ops/webapp.py +717 -0
- tlgr/ops/webhook.py +418 -0
- tlgr/parity.py +386 -0
- tlgr/processors/__init__.py +125 -0
- tlgr/processors/regex.py +26 -0
- tlgr/processors/text.py +56 -0
- tlgr/registry.py +519 -0
- tlgr/schema.py +173 -0
- tlgr/transport/__init__.py +30 -0
- tlgr/transport/autostart.py +293 -0
- tlgr/transport/client.py +805 -0
- tlgr/transport/ndjson.py +44 -0
- tlgr/version.py +31 -0
- tlgr_cli-2.0.1.dist-info/METADATA +957 -0
- tlgr_cli-2.0.1.dist-info/RECORD +192 -0
- tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
- tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
- tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
- tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/ops/_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}")
|