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
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Every private Telethon API tlgr touches, behind a feature probe.
|
|
2
|
+
|
|
3
|
+
tlgr needs four things Telethon 1.44 does not expose publicly: persisting
|
|
4
|
+
update state without disconnecting, learning that a reconnect happened,
|
|
5
|
+
learning that the server said "your gap is too long", and reading a request's
|
|
6
|
+
flood-wait memory. Reaching into `client._save_states_and_entities` from five
|
|
7
|
+
call sites would mean five tracebacks the day Telethon renames it.
|
|
8
|
+
|
|
9
|
+
The rule here: **probe once, warn once, degrade**. Each adapter checks for the
|
|
10
|
+
attribute it needs, logs a single warning naming the Telethon version if it is
|
|
11
|
+
gone, and returns a value that lets the caller carry on. Losing the periodic
|
|
12
|
+
state save costs at most one `catch_up()`; losing the `*TooLong` hook costs a
|
|
13
|
+
resync the daemon would have done anyway on its 15-minute backstop. Neither is
|
|
14
|
+
worth a crash, and both are worth a log line that names the cause.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import contextlib
|
|
20
|
+
import logging
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger("tlgr.compat")
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"TOO_LONG_CHANNEL",
|
|
28
|
+
"TOO_LONG_GLOBAL",
|
|
29
|
+
"entity_count",
|
|
30
|
+
"install_reconnect_hook",
|
|
31
|
+
"install_too_long_hook",
|
|
32
|
+
"probe",
|
|
33
|
+
"save_state",
|
|
34
|
+
"session_state",
|
|
35
|
+
"set_session_state",
|
|
36
|
+
"telethon_version",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
TOO_LONG_GLOBAL = "global"
|
|
40
|
+
TOO_LONG_CHANNEL = "channel"
|
|
41
|
+
|
|
42
|
+
_warned: set[str] = set()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def telethon_version() -> str:
|
|
46
|
+
try:
|
|
47
|
+
import telethon
|
|
48
|
+
|
|
49
|
+
return str(telethon.__version__)
|
|
50
|
+
except Exception: # pragma: no cover - telethon is a hard dependency
|
|
51
|
+
return "unknown"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _warn_once(feature: str, detail: str = "") -> None:
|
|
55
|
+
if feature in _warned:
|
|
56
|
+
return
|
|
57
|
+
_warned.add(feature)
|
|
58
|
+
log.warning(
|
|
59
|
+
"telethon %s does not expose %s%s; tlgr degrades that behaviour",
|
|
60
|
+
telethon_version(),
|
|
61
|
+
feature,
|
|
62
|
+
f" ({detail})" if detail else "",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def probe(client: Any, attribute: str) -> bool:
|
|
67
|
+
"""Is *attribute* present on this client? Warns once when it is not."""
|
|
68
|
+
if hasattr(client, attribute):
|
|
69
|
+
return True
|
|
70
|
+
_warn_once(attribute)
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def save_state(client: Any) -> bool:
|
|
75
|
+
"""Persist update state and the entity cache without disconnecting.
|
|
76
|
+
|
|
77
|
+
Telethon writes `pts`/`qts`/`date` only on `disconnect()`. A daemon that is
|
|
78
|
+
SIGKILLed, or a laptop that loses power, therefore replays from whatever
|
|
79
|
+
the session file last held — in v1, from the last clean shutdown, which
|
|
80
|
+
could be days. Called every `[daemon] state_save_interval` seconds and on
|
|
81
|
+
every shutdown path.
|
|
82
|
+
"""
|
|
83
|
+
ok = True
|
|
84
|
+
saver = getattr(client, "_save_states_and_entities", None)
|
|
85
|
+
if callable(saver):
|
|
86
|
+
try:
|
|
87
|
+
result = saver()
|
|
88
|
+
if hasattr(result, "__await__"):
|
|
89
|
+
await result
|
|
90
|
+
except Exception as exc: # pragma: no cover - depends on session backend
|
|
91
|
+
log.debug("state save failed: %s", exc)
|
|
92
|
+
ok = False
|
|
93
|
+
else:
|
|
94
|
+
_warn_once("_save_states_and_entities")
|
|
95
|
+
ok = False
|
|
96
|
+
|
|
97
|
+
session = getattr(client, "session", None)
|
|
98
|
+
save = getattr(session, "save", None)
|
|
99
|
+
if callable(save):
|
|
100
|
+
try:
|
|
101
|
+
save()
|
|
102
|
+
except Exception as exc: # pragma: no cover
|
|
103
|
+
log.debug("session save failed: %s", exc)
|
|
104
|
+
ok = False
|
|
105
|
+
return ok
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def install_reconnect_hook(client: Any, callback: Callable[[], Any]) -> bool:
|
|
109
|
+
"""Call *callback* after Telethon finishes an automatic reconnect.
|
|
110
|
+
|
|
111
|
+
Telethon's `_handle_auto_reconnect` re-runs `get_me()` and nothing else, so
|
|
112
|
+
an account that dropped for ten minutes comes back with a stale `pts` and
|
|
113
|
+
silently misses everything that happened (checklist 1/2). Wrapping it is
|
|
114
|
+
the only hook there is; when it is gone the supervisor's own reconnect path
|
|
115
|
+
still calls `catch_up()`, so this is an optimisation, not a requirement.
|
|
116
|
+
"""
|
|
117
|
+
original = getattr(client, "_handle_auto_reconnect", None)
|
|
118
|
+
if not callable(original):
|
|
119
|
+
_warn_once("_handle_auto_reconnect")
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
async def wrapper() -> Any:
|
|
123
|
+
result = original()
|
|
124
|
+
if hasattr(result, "__await__"):
|
|
125
|
+
result = await result
|
|
126
|
+
try:
|
|
127
|
+
outcome = callback()
|
|
128
|
+
if hasattr(outcome, "__await__"):
|
|
129
|
+
await outcome
|
|
130
|
+
except Exception as exc: # pragma: no cover - the callback logs its own
|
|
131
|
+
log.debug("reconnect hook failed: %s", exc)
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
client._handle_auto_reconnect = wrapper
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def install_too_long_hook(client: Any, callback: Callable[[str, int | None], Any]) -> bool:
|
|
139
|
+
"""Report `differenceTooLong` / `channelDifferenceTooLong` to *callback*.
|
|
140
|
+
|
|
141
|
+
Telethon consumes both inside `MessageBox` and delivers nothing to
|
|
142
|
+
handlers, so a client that was offline long enough to blow the server's
|
|
143
|
+
difference window silently skips history (checklist 9). The hook wraps
|
|
144
|
+
`MessageBox.apply_difference` / `apply_channel_difference` **on this
|
|
145
|
+
client's own box instance**, never on the class, so one account's hook
|
|
146
|
+
cannot fire for another's.
|
|
147
|
+
|
|
148
|
+
`callback(scope, channel_id)` is called with `TOO_LONG_GLOBAL`/`None` or
|
|
149
|
+
`TOO_LONG_CHANNEL`/`<id>`.
|
|
150
|
+
"""
|
|
151
|
+
box = getattr(client, "_message_box", None)
|
|
152
|
+
if box is None:
|
|
153
|
+
_warn_once("_message_box")
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
installed = False
|
|
157
|
+
|
|
158
|
+
original_global = getattr(box, "apply_difference", None)
|
|
159
|
+
if callable(original_global):
|
|
160
|
+
|
|
161
|
+
def apply_difference(diff: Any, chat_hashes: Any, _orig: Any = original_global) -> Any:
|
|
162
|
+
if type(diff).__name__ == "DifferenceTooLong":
|
|
163
|
+
_safe(callback, TOO_LONG_GLOBAL, None)
|
|
164
|
+
return _orig(diff, chat_hashes)
|
|
165
|
+
|
|
166
|
+
box.apply_difference = apply_difference
|
|
167
|
+
installed = True
|
|
168
|
+
else: # pragma: no cover - present in 1.44
|
|
169
|
+
_warn_once("MessageBox.apply_difference")
|
|
170
|
+
|
|
171
|
+
original_channel = getattr(box, "apply_channel_difference", None)
|
|
172
|
+
if callable(original_channel):
|
|
173
|
+
|
|
174
|
+
def apply_channel_difference(
|
|
175
|
+
request: Any, diff: Any, chat_hashes: Any, _orig: Any = original_channel
|
|
176
|
+
) -> Any:
|
|
177
|
+
if type(diff).__name__ == "ChannelDifferenceTooLong":
|
|
178
|
+
channel = getattr(request, "channel", None)
|
|
179
|
+
_safe(callback, TOO_LONG_CHANNEL, getattr(channel, "channel_id", None))
|
|
180
|
+
return _orig(request, diff, chat_hashes)
|
|
181
|
+
|
|
182
|
+
box.apply_channel_difference = apply_channel_difference
|
|
183
|
+
installed = True
|
|
184
|
+
else: # pragma: no cover - present in 1.44
|
|
185
|
+
_warn_once("MessageBox.apply_channel_difference")
|
|
186
|
+
|
|
187
|
+
return installed
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _safe(callback: Callable[..., Any], *args: Any) -> None:
|
|
191
|
+
try:
|
|
192
|
+
callback(*args)
|
|
193
|
+
except Exception as exc: # pragma: no cover
|
|
194
|
+
log.debug("too-long hook failed: %s", exc)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def clear_config_cache(client: Any) -> bool:
|
|
198
|
+
"""Drop Telethon's cached `help.getConfig` so the next call refetches.
|
|
199
|
+
|
|
200
|
+
`UpdateConfig`/`UpdateDcOptions` mean the DC list or the limits changed
|
|
201
|
+
(checklist 12). Telethon caches the config for an hour and does not listen
|
|
202
|
+
for the update, so a client that saw a DC migration keeps using the old
|
|
203
|
+
address list until the cache ages out.
|
|
204
|
+
"""
|
|
205
|
+
if hasattr(client, "_config"):
|
|
206
|
+
client._config = None
|
|
207
|
+
return True
|
|
208
|
+
_warn_once("_config")
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def session_state(client: Any) -> tuple[dict[str, Any], dict[int, int]]:
|
|
213
|
+
"""`({pts, qts, seq, date}, {channel_id: pts})` from the session.
|
|
214
|
+
|
|
215
|
+
Telethon 1.44 has no public accessor for its update state: the common box
|
|
216
|
+
lives in the session's `update_state` table under entity id 0 and the
|
|
217
|
+
per-channel boxes under their channel ids. Reading it here — once, behind
|
|
218
|
+
a name — is what lets `sync status` answer "how far behind is this
|
|
219
|
+
account" without every caller reaching into a private table.
|
|
220
|
+
"""
|
|
221
|
+
common: dict[str, Any] = {}
|
|
222
|
+
channels: dict[int, int] = {}
|
|
223
|
+
session = getattr(client, "session", None)
|
|
224
|
+
getter = getattr(session, "get_update_states", None)
|
|
225
|
+
if not callable(getter):
|
|
226
|
+
_warn_once("session.get_update_states")
|
|
227
|
+
return common, channels
|
|
228
|
+
try:
|
|
229
|
+
rows = list(getter())
|
|
230
|
+
except Exception as exc: # pragma: no cover - depends on session backend
|
|
231
|
+
log.debug("update state read failed: %s", exc)
|
|
232
|
+
return common, channels
|
|
233
|
+
for entity_id, state in rows:
|
|
234
|
+
if int(entity_id) == 0:
|
|
235
|
+
date = getattr(state, "date", None)
|
|
236
|
+
common = {
|
|
237
|
+
"pts": getattr(state, "pts", None),
|
|
238
|
+
"qts": getattr(state, "qts", None),
|
|
239
|
+
"seq": getattr(state, "seq", None),
|
|
240
|
+
"date": date.strftime("%Y-%m-%dT%H:%M:%SZ") if date is not None else None,
|
|
241
|
+
"date_unix": int(date.timestamp()) if date is not None else None,
|
|
242
|
+
"unread_count": getattr(state, "unread_count", None),
|
|
243
|
+
}
|
|
244
|
+
else:
|
|
245
|
+
channels[int(entity_id)] = int(getattr(state, "pts", 0) or 0)
|
|
246
|
+
return common, channels
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def set_session_state(client: Any, state: Any, entity_id: int = 0) -> bool:
|
|
250
|
+
"""Write one update-state row. The `sync reset` half of the pair above."""
|
|
251
|
+
session = getattr(client, "session", None)
|
|
252
|
+
setter = getattr(session, "set_update_state", None)
|
|
253
|
+
if not callable(setter):
|
|
254
|
+
_warn_once("session.set_update_state")
|
|
255
|
+
return False
|
|
256
|
+
try:
|
|
257
|
+
setter(entity_id, state)
|
|
258
|
+
except Exception as exc: # pragma: no cover - depends on session backend
|
|
259
|
+
log.debug("update state write failed: %s", exc)
|
|
260
|
+
return False
|
|
261
|
+
return True
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def entity_count(client: Any) -> int:
|
|
265
|
+
"""How many peers the session has an access hash for.
|
|
266
|
+
|
|
267
|
+
Not a statistic: an entity missing from here is a channel `catch_up()`
|
|
268
|
+
will silently skip, because `getChannelDifference` needs the access hash
|
|
269
|
+
and Telethon will not ask for one it does not have.
|
|
270
|
+
"""
|
|
271
|
+
session = getattr(client, "session", None)
|
|
272
|
+
cursor = getattr(session, "_cursor", None)
|
|
273
|
+
if callable(cursor):
|
|
274
|
+
try:
|
|
275
|
+
row = cursor().execute("select count(*) from entities").fetchone()
|
|
276
|
+
return int(row[0]) if row else 0
|
|
277
|
+
except Exception as exc: # pragma: no cover - depends on session backend
|
|
278
|
+
log.debug("entity count failed: %s", exc)
|
|
279
|
+
cache = getattr(client, "_entity_cache", None)
|
|
280
|
+
with contextlib.suppress(TypeError):
|
|
281
|
+
return len(cache) if cache is not None else 0
|
|
282
|
+
return 0
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def flood_waited_requests(client: Any) -> dict[int, float]:
|
|
286
|
+
"""Telethon's in-process flood memory, `{constructor_id: until_unix}`.
|
|
287
|
+
|
|
288
|
+
Read-only, and lost on restart — which is why tlgr keeps its own persisted
|
|
289
|
+
copy (§6.4). Exposed here so the daemon can report both in `/v1/status`.
|
|
290
|
+
"""
|
|
291
|
+
waited = getattr(client, "_flood_waited_requests", None)
|
|
292
|
+
if isinstance(waited, dict):
|
|
293
|
+
return dict(waited)
|
|
294
|
+
_warn_once("_flood_waited_requests")
|
|
295
|
+
return {}
|
tlgr/core/text.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Message text: parse modes, spoilers, and entity JSON.
|
|
2
|
+
|
|
3
|
+
Two rules shape this module.
|
|
4
|
+
|
|
5
|
+
*Parsing is input-only.* Telethon's markdown/HTML parsers are used to turn
|
|
6
|
+
what a user typed into `(text, entities)`, and never to turn a received
|
|
7
|
+
message back into markup: `unparse` is lossy — it cannot express overlapping
|
|
8
|
+
runs, custom emoji or a URL containing the delimiter it would have to escape —
|
|
9
|
+
so tlgr sends the raw `text` plus `entities` and lets the consumer decide.
|
|
10
|
+
|
|
11
|
+
*Spoilers are ours.* Telethon 1.44 drops `||x||` and `<tg-spoiler>` silently:
|
|
12
|
+
the markers survive as literal text in markdown and vanish without an entity
|
|
13
|
+
in HTML. Either way the user's intent is lost with no error, so the markers
|
|
14
|
+
are lifted out here, before and after the parser runs.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from tlgr.core.errors import UsageError
|
|
23
|
+
from tlgr.models.message import MessageEntity
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"PARSE_MODES",
|
|
27
|
+
"default_parse_mode",
|
|
28
|
+
"entities_from_json",
|
|
29
|
+
"entities_to_json",
|
|
30
|
+
"parse_text",
|
|
31
|
+
"utf16_len",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
PARSE_MODES = ("md", "html", "none")
|
|
35
|
+
|
|
36
|
+
#: Private-use sentinels: the parsers pass them through untouched, so a
|
|
37
|
+
#: spoiler's boundaries survive markdown/HTML parsing and can be converted to
|
|
38
|
+
#: real offsets afterwards. They are stripped from user input first.
|
|
39
|
+
_SPOILER_OPEN = ""
|
|
40
|
+
_SPOILER_CLOSE = ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def default_parse_mode() -> str:
|
|
44
|
+
"""`[defaults] parse_mode`, itself defaulting to `none` (COR-21)."""
|
|
45
|
+
try:
|
|
46
|
+
from tlgr.core.config import load_app_config
|
|
47
|
+
|
|
48
|
+
mode = str(getattr(load_app_config().defaults, "parse_mode", "none") or "none")
|
|
49
|
+
except Exception:
|
|
50
|
+
mode = "none"
|
|
51
|
+
return mode if mode in PARSE_MODES else "none"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def utf16_len(text: str) -> int:
|
|
55
|
+
"""Length in UTF-16 code units — the unit Telegram measures offsets in.
|
|
56
|
+
|
|
57
|
+
An emoji is one Python character and two UTF-16 units, which is why
|
|
58
|
+
counting characters puts every entity after the first emoji in the wrong
|
|
59
|
+
place.
|
|
60
|
+
"""
|
|
61
|
+
return len(text.encode("utf-16-le")) // 2
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _strip_sentinels(text: str) -> str:
|
|
65
|
+
return text.replace(_SPOILER_OPEN, "").replace(_SPOILER_CLOSE, "")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _mark_spoilers(text: str, mode: str) -> str:
|
|
69
|
+
"""Replace the mode's spoiler markup with sentinels, before parsing."""
|
|
70
|
+
if mode == "md":
|
|
71
|
+
parts = text.split("||")
|
|
72
|
+
if len(parts) < 3:
|
|
73
|
+
return text
|
|
74
|
+
out: list[str] = [parts[0]]
|
|
75
|
+
for index, part in enumerate(parts[1:], start=1):
|
|
76
|
+
# Odd boundaries open, even boundaries close; a trailing unmatched
|
|
77
|
+
# `||` is left as typed rather than guessed at.
|
|
78
|
+
out.append(_SPOILER_OPEN if index % 2 else _SPOILER_CLOSE)
|
|
79
|
+
out.append(part)
|
|
80
|
+
if len(parts) % 2 == 0:
|
|
81
|
+
out[-2] = "||"
|
|
82
|
+
return "".join(out)
|
|
83
|
+
if mode == "html":
|
|
84
|
+
for tag in ("tg-spoiler", "spoiler"):
|
|
85
|
+
text = text.replace(f"<{tag}>", _SPOILER_OPEN).replace(f"</{tag}>", _SPOILER_CLOSE)
|
|
86
|
+
return text
|
|
87
|
+
return text
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _extract_spoilers(text: str, entities: list[MessageEntity]) -> tuple[str, list[MessageEntity]]:
|
|
91
|
+
"""Remove sentinels from parsed text, shifting offsets, emitting spoilers."""
|
|
92
|
+
if _SPOILER_OPEN not in text and _SPOILER_CLOSE not in text:
|
|
93
|
+
return text, entities
|
|
94
|
+
|
|
95
|
+
out: list[str] = []
|
|
96
|
+
removed = 0 # UTF-16 units removed so far
|
|
97
|
+
shifts: list[tuple[int, int]] = [] # (original utf-16 offset, units removed before it)
|
|
98
|
+
open_at: int | None = None
|
|
99
|
+
spoilers: list[tuple[int, int]] = [] # (start, length) in *final* offsets
|
|
100
|
+
position = 0
|
|
101
|
+
|
|
102
|
+
for char in text:
|
|
103
|
+
if char in (_SPOILER_OPEN, _SPOILER_CLOSE):
|
|
104
|
+
removed += 1
|
|
105
|
+
shifts.append((position, removed))
|
|
106
|
+
if char == _SPOILER_OPEN:
|
|
107
|
+
open_at = position - removed + 1
|
|
108
|
+
elif open_at is not None:
|
|
109
|
+
spoilers.append((open_at, (position - removed + 1) - open_at))
|
|
110
|
+
open_at = None
|
|
111
|
+
else:
|
|
112
|
+
out.append(char)
|
|
113
|
+
position += utf16_len(char)
|
|
114
|
+
|
|
115
|
+
def shift(offset: int) -> int:
|
|
116
|
+
total = 0
|
|
117
|
+
for at, cumulative in shifts:
|
|
118
|
+
if at < offset:
|
|
119
|
+
total = cumulative
|
|
120
|
+
return offset - total
|
|
121
|
+
|
|
122
|
+
moved = [
|
|
123
|
+
MessageEntity(
|
|
124
|
+
type=entity.type,
|
|
125
|
+
offset=shift(entity.offset),
|
|
126
|
+
length=shift(entity.offset + entity.length) - shift(entity.offset),
|
|
127
|
+
url=entity.url,
|
|
128
|
+
user_id=entity.user_id,
|
|
129
|
+
language=entity.language,
|
|
130
|
+
document_id=entity.document_id,
|
|
131
|
+
collapsed=entity.collapsed,
|
|
132
|
+
)
|
|
133
|
+
for entity in entities
|
|
134
|
+
]
|
|
135
|
+
moved.extend(
|
|
136
|
+
MessageEntity(type="spoiler", offset=start, length=length)
|
|
137
|
+
for start, length in spoilers
|
|
138
|
+
if length > 0
|
|
139
|
+
)
|
|
140
|
+
moved.sort(key=lambda e: (e.offset, e.length))
|
|
141
|
+
return "".join(out), moved
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _tl_entity_type(obj: Any) -> str:
|
|
145
|
+
"""`MessageEntityTextUrl` → `text_url`."""
|
|
146
|
+
name = type(obj).__name__.removeprefix("MessageEntity")
|
|
147
|
+
result: list[str] = []
|
|
148
|
+
for index, char in enumerate(name):
|
|
149
|
+
if char.isupper() and index:
|
|
150
|
+
result.append("_")
|
|
151
|
+
result.append(char.lower())
|
|
152
|
+
return "".join(result)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _from_tl(obj: Any) -> MessageEntity:
|
|
156
|
+
return MessageEntity(
|
|
157
|
+
type=_tl_entity_type(obj),
|
|
158
|
+
offset=int(getattr(obj, "offset", 0)),
|
|
159
|
+
length=int(getattr(obj, "length", 0)),
|
|
160
|
+
url=getattr(obj, "url", None),
|
|
161
|
+
user_id=getattr(getattr(obj, "user_id", None), "user_id", getattr(obj, "user_id", None)),
|
|
162
|
+
language=getattr(obj, "language", None),
|
|
163
|
+
document_id=getattr(obj, "document_id", None),
|
|
164
|
+
collapsed=getattr(obj, "collapsed", None),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def parse_text(text: str, mode: str = "none") -> tuple[str, list[MessageEntity]]:
|
|
169
|
+
"""Turn user input into `(plain text, entities)`.
|
|
170
|
+
|
|
171
|
+
Telethon is imported lazily so that `import tlgr.core.text` — which the
|
|
172
|
+
CLI does to validate `--parse` — stays free of it (§2.2).
|
|
173
|
+
"""
|
|
174
|
+
if mode not in PARSE_MODES:
|
|
175
|
+
raise UsageError(f"unknown parse mode {mode!r}: expected md, html or none", field="parse")
|
|
176
|
+
|
|
177
|
+
clean = _strip_sentinels(text)
|
|
178
|
+
if mode == "none":
|
|
179
|
+
return clean, []
|
|
180
|
+
|
|
181
|
+
marked = _mark_spoilers(clean, mode)
|
|
182
|
+
from telethon.extensions import html as tl_html
|
|
183
|
+
from telethon.extensions import markdown as tl_markdown
|
|
184
|
+
|
|
185
|
+
parser = tl_markdown if mode == "md" else tl_html
|
|
186
|
+
parsed, raw_entities = parser.parse(marked)
|
|
187
|
+
entities = [_from_tl(entity) for entity in raw_entities or []]
|
|
188
|
+
return _extract_spoilers(parsed, entities)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def entities_to_json(entities: list[MessageEntity]) -> str:
|
|
192
|
+
"""Entities as the JSON `--entities` accepts back."""
|
|
193
|
+
import msgspec
|
|
194
|
+
|
|
195
|
+
return msgspec.json.encode(entities).decode()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def entities_from_json(raw: str) -> list[MessageEntity]:
|
|
199
|
+
"""Decode `--entities JSON`, rejecting anything malformed as USAGE."""
|
|
200
|
+
import msgspec
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
loaded = json.loads(raw)
|
|
204
|
+
except ValueError as exc:
|
|
205
|
+
raise UsageError(f"--entities is not valid JSON: {exc}", field="entities") from exc
|
|
206
|
+
if not isinstance(loaded, list):
|
|
207
|
+
raise UsageError("--entities must be a JSON array of entity objects", field="entities")
|
|
208
|
+
try:
|
|
209
|
+
return msgspec.convert(loaded, type=list[MessageEntity])
|
|
210
|
+
except msgspec.ValidationError as exc:
|
|
211
|
+
raise UsageError(f"--entities: {exc}", field="entities") from exc
|