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
@@ -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