pi-braid-telegram 0.0.0-stage → 0.1.4

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.
@@ -0,0 +1,809 @@
1
+ /**
2
+ * Rich-format / split-to-fit helpers.
3
+ *
4
+ * Telegram caps a text message at 4096 chars. Rich (possibly long) responses
5
+ * need to fit that limit or be split into several sequential messages. This
6
+ * module is pure + unit-testable (no network): `splitLongText` breaks long
7
+ * text on paragraph → line → word boundaries (never mid-word), `splitHtmlText`
8
+ * does the same for HTML bodies while keeping tags whole (a chunk closes the
9
+ * tags it opens and the next chunk reopens them), and `sendMessageChunks`
10
+ * delivers N sequential messages each with a `(i/N)` continuity marker.
11
+ * `isBalancedMarkdown` is a pragmatic guard: Telegram's `Markdown` parse_mode
12
+ * rejects unbalanced emphasis/links, so callers drop parse_mode (fall back to
13
+ * plain text) when the delimiters are imbalanced.
14
+ *
15
+ * Dialect note: Telegram's `Markdown` parse_mode is the LEGACY dialect —
16
+ * `*bold*`, `_italic_`, `` `code` ``, `[t](u)`. The MarkdownV2 delimiter
17
+ * pairs (`**x**` bold, `__x__` underline, `~x~` strikethrough, `||x||`
18
+ * spoiler) have no legacy meaning; left as-is they reach the wire literally.
19
+ * `convertMarkdownV2ToHtml` rewrites the clean single-line shapes to their
20
+ * HTML equivalents (`<b>`, `<u>`, `<s>`, `<tg-spoiler>`) so
21
+ * `resolveDeliveryFormat` can ship them rendered instead of raw.
22
+ */
23
+
24
+ import type {
25
+ TelegramClient,
26
+ TelegramReplyMarkup,
27
+ TelegramSendResult,
28
+ ParseMode,
29
+ } from "./telegram-client.js";
30
+ import { checkTelegramHtml, parseTelegramTagAt } from "./html-check.js";
31
+
32
+ /** Telegram hard cap for a single text message (https://core.telegram.org/bots/api#sendmessage). */
33
+ export const TELEGRAM_MAX_TEXT_LENGTH = 4096;
34
+
35
+ /**
36
+ * Split `text` into chunks each <= `maxLen - reserved` chars, preferring
37
+ * paragraph boundaries (blank-line runs), then line boundaries, then word
38
+ * boundaries, then a hard character cut. Never splits mid-word unless a single
39
+ * word itself exceeds the limit. Empty input → []. `reserved` headroom lets
40
+ * callers append a continuity marker without exceeding the Telegram cap.
41
+ */
42
+ export function splitLongText(
43
+ text: string,
44
+ maxLen: number = TELEGRAM_MAX_TEXT_LENGTH,
45
+ reserved: number = 0,
46
+ ): string[] {
47
+ const limit = maxLen - reserved;
48
+ if (!(limit > 0))
49
+ throw new Error("splitLongText: maxLen must exceed reserved");
50
+ const trimmed = text.trim();
51
+ if (!trimmed) return [];
52
+ if (trimmed.length <= limit) return [trimmed];
53
+
54
+ const chunks: string[] = [];
55
+ let buf = "";
56
+ const flush = (): void => {
57
+ if (buf) {
58
+ chunks.push(buf);
59
+ buf = "";
60
+ }
61
+ };
62
+ for (const para of trimmed.split(/\n\s*\n/)) {
63
+ if (para.length > limit) {
64
+ // A single paragraph exceeds the limit — break it further on its own.
65
+ flush();
66
+ for (const part of hardChunk(para, limit)) chunks.push(part);
67
+ continue;
68
+ }
69
+ if (buf && buf.length + 2 + para.length > limit) flush();
70
+ buf = buf ? `${buf}\n\n${para}` : para;
71
+ }
72
+ flush();
73
+ return chunks;
74
+ }
75
+
76
+ /** Break one unit (still > limit) on line → word → char boundaries. */
77
+ function hardChunk(s: string, limit: number): string[] {
78
+ const out: string[] = [];
79
+ let rest = s;
80
+ while (rest.length > 0) {
81
+ if (rest.length <= limit) {
82
+ out.push(rest);
83
+ break;
84
+ }
85
+ // Last newline within the limit keeps line sections whole.
86
+ const nl = rest.lastIndexOf("\n", limit);
87
+ if (nl > 0) {
88
+ out.push(rest.slice(0, nl));
89
+ rest = rest.slice(nl + 1);
90
+ continue;
91
+ }
92
+ const sp = rest.lastIndexOf(" ", limit);
93
+ if (sp > 0) {
94
+ out.push(rest.slice(0, sp));
95
+ rest = rest.slice(sp + 1);
96
+ continue;
97
+ }
98
+ // One word longer than the limit — hard cut (last resort).
99
+ out.push(rest.slice(0, limit));
100
+ rest = rest.slice(limit);
101
+ }
102
+ return out.map((c) => c.trim()).filter((c) => c.length > 0);
103
+ }
104
+
105
+ /**
106
+ * Pragmatic guard for Telegram's `Markdown` parse_mode, which rejects messages
107
+ * with unbalanced emphasis/backtick/link delimiters. Returns false when the
108
+ * paired-delimiter counts are odd — callers then deliver plain text instead of
109
+ * surfacing a Telegram API error. Heuristic, not a full parser; balanced
110
+ * counts are the common case (bold/code are even-counted anyway).
111
+ *
112
+ * NOTE: this predicate answers only "would Telegram accept this as Markdown?"
113
+ * — it intentionally says `true` for a raw-HTML body (no Markdown markers to
114
+ * be unbalanced). Mode selection for delivery MUST combine it with
115
+ * `hasHtmlTags` (see `resolveDeliveryFormat`) so an HTML body is shipped as
116
+ * HTML (or its tags neutralized) rather than leaking raw tags under Markdown.
117
+ */
118
+ export function isBalancedMarkdown(text: string): boolean {
119
+ if (!text) return true;
120
+ const count = (ch: string): number => text.split(ch).length - 1;
121
+ // **bold** openers/closers must pair: even count of "**" tokens. A lone
122
+ // "**" (e.g. "**bold" missing its close) IS unbalanced in Telegram.
123
+ const boldOpeners = text.split("**").length - 1;
124
+ if (boldOpeners % 2 !== 0) return false;
125
+ // After removing `**` pairs, any leftover single `*` is unbalanced (Telegram
126
+ // also accepts *single-star* emphasis — two stars, i.e. even, is fine).
127
+ const singleStars = text.replace(/\*\*/g, "").split("*").length - 1;
128
+ return (
129
+ singleStars % 2 === 0 &&
130
+ count("_") % 2 === 0 &&
131
+ count("`") % 2 === 0 &&
132
+ count("[") === count("]")
133
+ );
134
+ }
135
+
136
+ // `parse_mode` only picks the TRANSPORT format — it does not transform the
137
+ // text. A body carrying the OTHER mode's markup reaches the wire raw under the
138
+ // wrong parse_mode (`<b>` shown literally in Markdown mode, `**bold**` shown
139
+ // literally in HTML mode). These helpers neutralize the other mode's tokens so
140
+ // the intended content renders cleanly; the delivery decision point is
141
+ // `resolveDeliveryFormat`.
142
+
143
+ /** Raw HTML tag anywhere in the text (`<b>`, `</i>`, `<a href=…>`, case-insensitive). */
144
+ // Tag recognition is quote-aware (`parseTelegramTagAt`): a `>` inside a quoted
145
+ // attribute value is part of the tag, so `<a href="a>b">` is one token and is
146
+ // removed whole rather than truncated at the first `>`.
147
+
148
+ /** True when the text contains raw HTML tags — a signal it was authored as HTML. */
149
+ export function hasHtmlTags(text: string): boolean {
150
+ return findTagToken(text) !== null;
151
+ }
152
+
153
+ /** First tag-shaped token in `text`, or null. */
154
+ function findTagToken(text: string): { start: number; end: number } | null {
155
+ let i = text.indexOf("<");
156
+ while (i !== -1) {
157
+ const tag = parseTelegramTagAt(text, i);
158
+ if (tag) return { start: i, end: tag.end };
159
+ i = text.indexOf("<", i + 1);
160
+ }
161
+ return null;
162
+ }
163
+
164
+ /**
165
+ * Neutralize raw HTML tags from text about to be delivered under a NON-HTML
166
+ * (Markdown/plain) parse_mode, so no `<b>`/`</i>` etc. leaks to the wire.
167
+ * `<br>` becomes a newline; the inner content of every other tag is kept
168
+ * (tags are removed, never escaped — Telegram's Markdown/plain modes do not
169
+ * decode HTML entities, so escaping would leak the raw markup anyway).
170
+ */
171
+ export function stripHtmlTags(text: string): string {
172
+ const withBreaks = text.replace(/<br\s*\/?>/gi, "\n");
173
+ const out: string[] = [];
174
+ let copied = 0;
175
+ let i = withBreaks.indexOf("<");
176
+ while (i !== -1) {
177
+ const tag = parseTelegramTagAt(withBreaks, i);
178
+ if (tag) {
179
+ out.push(withBreaks.slice(copied, i));
180
+ copied = tag.end;
181
+ i = withBreaks.indexOf("<", tag.end);
182
+ } else {
183
+ i = withBreaks.indexOf("<", i + 1);
184
+ }
185
+ }
186
+ out.push(withBreaks.slice(copied));
187
+ return out.join("");
188
+ }
189
+
190
+ /**
191
+ * Neutralize PAIRED Telegram-Markdown markers from text about to be delivered
192
+ * under HTML (or plain) parse_mode: `**bold**`→`bold`, `_italic_`→`italic`,
193
+ * `` `code` ``→`code`, `[label](url)`→`label`. Only balanced pairs are
194
+ * stripped; a lone `*`/`_`/backtick (math, globs, `snake_case` identifiers) is
195
+ * left untouched — the function never invents structure the author did not
196
+ * write. Telegram Markdown v1 has no heading/list syntax, so `#` and `-` are
197
+ * literal in every mode and need no handling here.
198
+ *
199
+ * Code and pre spans are exempt: their content is literal on the wire, so a
200
+ * `**x**` example inside `<code>` is content, not emphasis (this runs after
201
+ * the conversion, whose code spans are already `<code>`-wrapped).
202
+ */
203
+ export function neutralizeMarkdownMarkers(text: string): string {
204
+ const spans = text.split(
205
+ /((?:<code(?:\s[^>]*)?>[\s\S]*?<\/code>|<pre(?:\s[^>]*)?>[\s\S]*?<\/pre>))/gi,
206
+ );
207
+ return spans
208
+ .map((part, i) => (i % 2 === 1 ? part : neutralizeOutsideSpans(part)))
209
+ .join("");
210
+ }
211
+
212
+ /** The marker-stripping rules, applied to a stretch without code/pre spans. */
213
+ function neutralizeOutsideSpans(text: string): string {
214
+ return text
215
+ .replace(/`([^`\n]+)`/g, "$1")
216
+ .replace(/\*\*([^*\n]{1,200})\*\*/g, "$1")
217
+ .replace(/(^|[\s([{*`>])_([^_\n]{1,60}?)_(?=[\s.,;:!?)\]}<]|$)/g, "$1$2")
218
+ .replace(/\[([^\]\n]+)\]\(([^()\s]{1,200})\)/g, "$1");
219
+ }
220
+
221
+ /** Per-marker-kind counts produced by `convertMarkdownV2ToHtml`. */
222
+ export interface ConversionCounts {
223
+ /** `**x**` → `<b>x</b>` rewrites. */
224
+ bold: number;
225
+ /** `__x__` → `<u>x</u>` rewrites. */
226
+ underline: number;
227
+ /** `~x~` → `<s>x</s>` rewrites. */
228
+ strike: number;
229
+ /** `||x||` → `<tg-spoiler>x</tg-spoiler>` rewrites. */
230
+ spoiler: number;
231
+ /** `` `x` `` → `<code>x</code>` rewrites. */
232
+ code: number;
233
+ }
234
+
235
+ /** Result of `convertMarkdownV2ToHtml`: the rewritten text plus its counts. */
236
+ export interface MarkdownV2Conversion {
237
+ text: string;
238
+ converted: ConversionCounts;
239
+ }
240
+
241
+ /** HTML entity reference (named, decimal, hex) — already wire-ready text. */
242
+ const HTML_ENTITY_AT_RE = /&(?:[a-zA-Z][a-zA-Z0-9]*|#\d+|#x[0-9a-fA-F]+);/y;
243
+
244
+ /** Escape the three characters Telegram's HTML parser reserves. */
245
+ function escapeHtmlText(text: string): string {
246
+ return text
247
+ .replace(/&/g, "&amp;")
248
+ .replace(/</g, "&lt;")
249
+ .replace(/>/g, "&gt;");
250
+ }
251
+
252
+ /** Zero counts: the conversion was refused, nothing was rewritten. */
253
+ function zeroCounts(): ConversionCounts {
254
+ return { bold: 0, underline: 0, strike: 0, spoiler: 0, code: 0 };
255
+ }
256
+
257
+ /**
258
+ * Replace every tag-shaped token and HTML entity of `text` with a placeholder
259
+ * produced by `protect`, so the escaping and emphasis passes leave them alone.
260
+ * Tag-shaped means `parseTelegramTagAt` found the closing `>`: an unclosed or
261
+ * unrecognizable `<` stays literal text and is escaped with the rest.
262
+ */
263
+ function protectWireTokens(
264
+ text: string,
265
+ slot: string,
266
+ protect: (raw: string) => string,
267
+ ): string {
268
+ const out: string[] = [];
269
+ let copied = 0;
270
+ let i = 0;
271
+ while (i < text.length) {
272
+ const ch = text[i];
273
+ if (ch === "<") {
274
+ const tag = parseTelegramTagAt(text, i);
275
+ if (tag) {
276
+ out.push(text.slice(copied, i), protect(text.slice(i, tag.end)));
277
+ i = tag.end;
278
+ copied = i;
279
+ continue;
280
+ }
281
+ i++;
282
+ continue;
283
+ }
284
+ if (ch === "&") {
285
+ HTML_ENTITY_AT_RE.lastIndex = i;
286
+ const match = HTML_ENTITY_AT_RE.exec(text);
287
+ if (match) {
288
+ out.push(text.slice(copied, i), protect(match[0]));
289
+ i += match[0].length;
290
+ copied = i;
291
+ continue;
292
+ }
293
+ }
294
+ i++;
295
+ }
296
+ out.push(text.slice(copied));
297
+ return out.join("");
298
+ }
299
+
300
+ /**
301
+ * Pick a character absent from `text` to delimit internal placeholders. The
302
+ * slot references are then unforgeable: a U+E000..U+E003 sequence written by
303
+ * the author stays their characters instead of being read as a slot.
304
+ */
305
+ function pickPlaceholderChar(text: string): string {
306
+ const used = new Set(text);
307
+ for (let code = 0xe000; code <= 0xf8ff; code++) {
308
+ const ch = String.fromCharCode(code);
309
+ if (!used.has(ch)) return ch;
310
+ }
311
+ return "\uE000";
312
+ }
313
+
314
+ /**
315
+ * Rewrite MarkdownV2-style emphasis that Telegram's legacy `Markdown`
316
+ * parse_mode does not understand into its HTML equivalent: `**x**` →
317
+ * `<b>x</b>`, `__x__` → `<u>x</u>`, `~x~` → `<s>x</s>` and `||x||` →
318
+ * `<tg-spoiler>x</tg-spoiler>`, plus paired backticks → `<code>x</code>`.
319
+ * Left as-is those shapes reach the wire literally under legacy Markdown and
320
+ * plain text.
321
+ *
322
+ * Only clean single-line pairs convert: delimiter runs (`~~x~~`), spaced/
323
+ * operator forms (`cd ~ && ls ~`, `x || y || z`), word-adjacent `__`
324
+ * (`snake__case__name`) and pairs whose inner text spans a newline or a code
325
+ * span stay literal. Pairs may wrap arbitrarily long text: a length cap on the
326
+ * inner text would leave a long bolded paragraph shipping raw `**`.
327
+ *
328
+ * The result is validated against Telegram's HTML parser before it is
329
+ * returned. A conversion whose output would not parse — crossing delimiter
330
+ * pairs (`__a ~b__ c~` would become `<u>a <s>b</u> c</s>`), an unbalanced or
331
+ * attribute-broken author tag, an interleaved tag pair — is refused: the
332
+ * original text comes back with zero counts, the caller's unconverted path
333
+ * stays in charge and the body ships as authored. `renderedCap` refuses a
334
+ * conversion whose result would cross a caller-imposed wire cap (the plain
335
+ * send path passes the configured `sendMaxChars`), so converted bodies never
336
+ * silently ship over the owner's cap.
337
+ *
338
+ * Inside an accepted conversion the output is safe for Telegram's HTML
339
+ * parser: text outside tags, entities and code spans has `&`, `<` and `>`
340
+ * escaped (a stray `<` is fatal to Telegram), authors' tags and entities stay
341
+ * wire-ready, and code-span content is escaped inside `<code>`. The caller
342
+ * decides when to deliver the result: see `resolveDeliveryFormat`.
343
+ */
344
+ export function convertMarkdownV2ToHtml(
345
+ text: string,
346
+ opts: { renderedCap?: number } = {},
347
+ ): MarkdownV2Conversion {
348
+ const slot = pickPlaceholderChar(text);
349
+ const codeSpans: string[] = [];
350
+ // Protect code spans first: their content must stay literal for the
351
+ // emphasis passes below (a `**x**` example inside `code` is not bold).
352
+ let out = text.replace(/`([^`\n]+)`/g, (_m, inner: string) => {
353
+ codeSpans.push(inner);
354
+ return `${slot}c${codeSpans.length - 1}${slot}`;
355
+ });
356
+ // Protect author HTML (every tag-shaped token, Telegram's tag set or not)
357
+ // and entities so the escaping pass leaves them wire-ready. A tag the
358
+ // validation below refuses takes the whole conversion down with it.
359
+ const protectedParts: string[] = [];
360
+ out = protectWireTokens(out, slot, (raw: string) => {
361
+ protectedParts.push(raw);
362
+ return `${slot}p${protectedParts.length - 1}${slot}`;
363
+ });
364
+ out = escapeHtmlText(out);
365
+ let bold = 0;
366
+ out = out.replace(/\*\*([^*\n]+)\*\*/g, (_m, inner: string) => {
367
+ bold++;
368
+ return `<b>${inner}</b>`;
369
+ });
370
+ let underline = 0;
371
+ // Boundary guards mirror `neutralizeMarkdownMarkers`: a word-adjacent
372
+ // `__` (snake__case__name) is an identifier, not emphasis.
373
+ out = out.replace(
374
+ /(^|[\s([{*`>])__([^_\n]+?)__(?=[\s.,;:!?)\]}<]|$)/g,
375
+ (_m, pre: string, inner: string) => {
376
+ underline++;
377
+ return `${pre}<u>${inner}</u>`;
378
+ },
379
+ );
380
+ let strike = 0;
381
+ // MarkdownV2 strikethrough is a SINGLE-tilde pair, and a `~` adjacent to
382
+ // another `~` (GFM `~~x~~`) is no MarkdownV2 delimiter: the guards exclude
383
+ // tilde runs, so `~~x~~` stays whole instead of being half-rewritten into
384
+ // `~<s>x</s>~`. Whitespace at an inner edge marks prose (`cd ~ && ls ~`),
385
+ // not formatting, and stays literal.
386
+ out = out.replace(
387
+ /(^|[\s([{*`>])~(?!~)([^\s~](?:[^~\n]*[^\s~])?)~(?!~)(?=[\s.,;:!?)\]}<]|$)/g,
388
+ (_m, pre: string, inner: string) => {
389
+ strike++;
390
+ return `${pre}<s>${inner}</s>`;
391
+ },
392
+ );
393
+ let spoiler = 0;
394
+ // `||` is MarkdownV2's spoiler delimiter and also the logical-OR operator in
395
+ // prose and shell; the inner-edge rule keeps `x || y || z` literal.
396
+ out = out.replace(
397
+ /(^|[\s([{*`>])\|\|([^\s|](?:[^|\n]*[^\s|])?)\|\|(?=[\s.,;:!?)\]}<]|$)/g,
398
+ (_m, pre: string, inner: string) => {
399
+ spoiler++;
400
+ return `${pre}<tg-spoiler>${inner}</tg-spoiler>`;
401
+ },
402
+ );
403
+ // Restore in ONE pass: a restored value is never rescanned, so a protected
404
+ // attribute value cannot be re-read as a code-slot reference, an author's
405
+ // slot-shaped characters stay their characters, and an unknown slot is
406
+ // returned verbatim instead of being deleted.
407
+ const slotRe = new RegExp(`${slot}([cp])(\\d+)${slot}`, "g");
408
+ out = out.replace(slotRe, (_m, kind: string, index: string) => {
409
+ const i = Number(index);
410
+ if (kind === "c") {
411
+ const inner = codeSpans[i];
412
+ return inner !== undefined ? `<code>${escapeHtmlText(inner)}</code>` : _m;
413
+ }
414
+ const part = protectedParts[i];
415
+ return part !== undefined ? part : _m;
416
+ });
417
+ const counts: ConversionCounts = {
418
+ bold,
419
+ underline,
420
+ strike,
421
+ spoiler,
422
+ code: codeSpans.length,
423
+ };
424
+ // Refuse instead of emitting wire text Telegram rejects: the caller's
425
+ // unconverted path then delivers the body as authored (Markdown/plain) or
426
+ // strips the author's tags for a Markdown request.
427
+ if (opts.renderedCap !== undefined && out.length > opts.renderedCap) {
428
+ return { text, converted: zeroCounts() };
429
+ }
430
+ if (!checkTelegramHtml(out).ok) {
431
+ return { text, converted: zeroCounts() };
432
+ }
433
+ return { text: out, converted: counts };
434
+ }
435
+
436
+ /** Result of `resolveDeliveryFormat`: which parse_mode ships + the body for it. */
437
+ export interface DeliveryFormat {
438
+ /** parse_mode to ship (undefined = plain text). */
439
+ parseMode: ParseMode | undefined;
440
+ /** Body transformed (neutralized) so it renders cleanly in `parseMode`. */
441
+ text: string;
442
+ /** True when an explicit Markdown request falls back to plain text (unbalanced). */
443
+ downgraded?: boolean;
444
+ /**
445
+ * Per-kind counts when MarkdownV2-style markers were rewritten to HTML and
446
+ * the message ships as HTML (so the caller can report what happened).
447
+ */
448
+ converted?: ConversionCounts;
449
+ }
450
+
451
+ /**
452
+ * Single decision point for "which parse_mode ships + how the body is
453
+ * sanitized to it". The caller's explicit mode wins; with no request,
454
+ * a body containing raw HTML tags auto-selects HTML (so `<b>` renders instead
455
+ * of leaking); otherwise the body ships as-is plain — preserving the legacy
456
+ * default. Cross-mode tokens are neutralized so no markup meant for the other
457
+ * mode reaches the wire raw:
458
+ * - MarkdownV2 delimiter pairs present (`**x**` bold, `__x__` underline,
459
+ * `~x~` strikethrough, `||x||` spoiler) in a clean single-line shape:
460
+ * converted to HTML and shipped as HTML whatever was requested — under
461
+ * legacy Markdown or plain those shapes would show literally. Runs
462
+ * (`~~x~~`), spaced/operator forms (`x ~ y ~ z`), word-adjacent `__` and
463
+ * pairs whose conversion would cross or unbalance tags stay literal, and
464
+ * the conversion reports zero counts (see `convertMarkdownV2ToHtml`).
465
+ * - HTML requested/auto-selected: Markdown markers stripped, HTML kept live.
466
+ * - Markdown requested + raw HTML present: tags stripped to inner text, then
467
+ * the Markdown balance guard still applies (unbalanced → plain + downgraded).
468
+ * - Markdown requested + no HTML: balance guard only (legacy safeParseMode).
469
+ *
470
+ * `detectMarkdown` (the live-update crafted-summary path) opts a NO-REQUEST
471
+ * body into active Markdown detection:
472
+ * raw HTML → HTML (markers neutralized); balanced Markdown → Markdown;
473
+ * anything else → plain. Without it the no-request default stays plain — the
474
+ * two behaviors differ only on a marker-free/balanced no-request body.
475
+ *
476
+ * `renderedCap` is the wire cap the caller's mode accepts: a conversion whose
477
+ * result would exceed it is refused (the body ships as authored) so tag
478
+ * overhead never pushes a body past a configured `sendMaxChars`.
479
+ */
480
+ export function resolveDeliveryFormat(
481
+ requested: ParseMode | undefined,
482
+ text: string,
483
+ detectMarkdown: boolean = false,
484
+ renderedCap?: number,
485
+ ): DeliveryFormat {
486
+ // MarkdownV2 delimiter pairs (`**x**` bold, `__x__` underline, `~x~`
487
+ // strikethrough, `||x||` spoiler) are not legacy Markdown: left as-is they
488
+ // would reach the wire literally under BOTH Markdown (legacy) and plain.
489
+ // Rewrite them to HTML and ship HTML, whatever was requested, so the body
490
+ // renders the way its author meant; any other legacy markers are then
491
+ // neutralized and raw tags the author already wrote stay live, so a mixed
492
+ // body ends up fully HTML with no raw Markdown left. A conversion that would
493
+ // not parse (crossing pairs, unbalanced author tags) or that would cross the
494
+ // caller's cap reports zero counts and is skipped.
495
+ const conv = convertMarkdownV2ToHtml(
496
+ text,
497
+ renderedCap !== undefined ? { renderedCap } : {},
498
+ );
499
+ const counts = conv.converted;
500
+ const converted =
501
+ counts.bold + counts.underline + counts.strike + counts.spoiler > 0
502
+ ? counts
503
+ : undefined;
504
+ if (converted) {
505
+ return {
506
+ parseMode: "HTML",
507
+ text: neutralizeMarkdownMarkers(conv.text),
508
+ converted,
509
+ };
510
+ }
511
+
512
+ const html = hasHtmlTags(text);
513
+ if (requested === "HTML" || (requested === undefined && html)) {
514
+ return { parseMode: "HTML", text: neutralizeMarkdownMarkers(text) };
515
+ }
516
+ if (requested === "Markdown") {
517
+ if (html) {
518
+ const stripped = stripHtmlTags(text);
519
+ if (isBalancedMarkdown(stripped)) {
520
+ return { parseMode: "Markdown", text: stripped };
521
+ }
522
+ return { parseMode: undefined, text: stripped, downgraded: true };
523
+ }
524
+ if (!isBalancedMarkdown(text)) {
525
+ return { parseMode: undefined, text, downgraded: true };
526
+ }
527
+ return { parseMode: "Markdown", text };
528
+ }
529
+ // No explicit request and no HTML. Legacy no-request default is plain;
530
+ // when `detectMarkdown` is set, actively pick up balanced Markdown so a
531
+ // crafted body never leaks raw markup under the wrong transport.
532
+ if (detectMarkdown && isBalancedMarkdown(text)) {
533
+ return { parseMode: "Markdown", text };
534
+ }
535
+ return { parseMode: undefined, text };
536
+ }
537
+
538
+ export interface SendChunksOptions {
539
+ token: string;
540
+ chatId: number | string;
541
+ text: string;
542
+ /** parse_mode applied to every chunk (HTML | Markdown). Optional. */
543
+ parseMode?: ParseMode;
544
+ /** Hard cap per chunk (default Telegram's 4096). */
545
+ maxLen?: number;
546
+ /**
547
+ * Inline keyboard attached to the LAST chunk only (decision buttons). A
548
+ * keyboard on an intermediate chunk would sit above the text that still
549
+ * follows it, i.e. on the wrong message.
550
+ */
551
+ replyMarkup?: TelegramReplyMarkup;
552
+ }
553
+
554
+ export interface SendChunksResult {
555
+ ok: boolean;
556
+ /** Number of messages actually delivered. */
557
+ sent: number;
558
+ /** Number of messages planned (1 when the text fits in one message). */
559
+ total: number;
560
+ messageIds: number[];
561
+ error?: string;
562
+ }
563
+
564
+ /**
565
+ * Split HTML text so every chunk parses on its own, for `parse_mode: "HTML"`.
566
+ * Unlike `splitLongText`, a cut never lands inside a tag or entity: the tags
567
+ * open at a cut are closed at the end of the chunk and reopened at the start
568
+ * of the next one, so a long `<b>…</b>` paragraph stays bold on every message
569
+ * instead of breaking the parse with `<b>…</b` / `>` fragments. Cuts inside
570
+ * text prefer word boundaries and fall back to a hard character cut; a single
571
+ * token longer than the limit (a giant word or tag) is emitted whole — an atom
572
+ * is never split, and `sendMessageChunks` refuses an over-cap HTML chunk
573
+ * instead of trimming inside it.
574
+ */
575
+ export function splitHtmlText(
576
+ text: string,
577
+ maxLen: number = TELEGRAM_MAX_TEXT_LENGTH,
578
+ reserved: number = 0,
579
+ ): string[] {
580
+ const limit = maxLen - reserved;
581
+ if (!(limit > 0))
582
+ throw new Error("splitHtmlText: maxLen must exceed reserved");
583
+ const trimmed = text.trim();
584
+ if (!trimmed) return [];
585
+ if (trimmed.length <= limit) return [trimmed];
586
+
587
+ const chunks: string[] = [];
588
+ const open: string[] = [];
589
+ let buf = "";
590
+
591
+ const closersLen = (stack: string[]): number =>
592
+ stack.reduce((n, name) => n + name.length + 3, 0); // "</" + name + ">"
593
+ const closerTags = (stack: string[]): string =>
594
+ stack
595
+ .slice()
596
+ .reverse()
597
+ .map((name) => `</${name}>`)
598
+ .join("");
599
+ const reopenTags = (stack: string[]): string =>
600
+ stack.map((name) => `<${name}>`).join("");
601
+ const hasContent = (): boolean => buf.length > reopenTags(open).length;
602
+ const flush = (): void => {
603
+ chunks.push(buf + closerTags(open));
604
+ buf = reopenTags(open);
605
+ };
606
+
607
+ let i = 0;
608
+ while (i < trimmed.length) {
609
+ if (trimmed[i] === "<") {
610
+ const tag = parseTelegramTagAt(trimmed, i);
611
+ if (tag) {
612
+ const raw = trimmed.slice(i, tag.end);
613
+ const nextStack = tag.closing ? open.slice(0, -1) : [...open, tag.name];
614
+ if (
615
+ buf.length + raw.length + closersLen(nextStack) > limit &&
616
+ hasContent()
617
+ ) {
618
+ flush();
619
+ }
620
+ buf += raw;
621
+ if (tag.closing) open.pop();
622
+ else open.push(tag.name);
623
+ i = tag.end;
624
+ continue;
625
+ }
626
+ }
627
+ // Text/entity run up to the next "<".
628
+ const nextLt = trimmed.indexOf("<", i);
629
+ const runEnd = nextLt === -1 ? trimmed.length : nextLt;
630
+ let rest = trimmed.slice(i, runEnd);
631
+ i = runEnd;
632
+ while (rest.length > 0) {
633
+ const room = limit - buf.length - closersLen(open);
634
+ if (room <= 0) {
635
+ if (!hasContent()) {
636
+ // The open-tag overhead alone eats the limit (pathological): emit
637
+ // the rest whole rather than loop.
638
+ buf += rest;
639
+ break;
640
+ }
641
+ flush();
642
+ continue;
643
+ }
644
+ if (rest.length <= room) {
645
+ buf += rest;
646
+ break;
647
+ }
648
+ // Prefer a word boundary, then back off an entity that would be cut.
649
+ let cut = room;
650
+ const space = rest.lastIndexOf(" ", room - 1);
651
+ if (space > 0) cut = space;
652
+ const guarded = backOffEntity(rest, cut);
653
+ if (guarded <= 0) {
654
+ if (hasContent()) {
655
+ flush();
656
+ continue;
657
+ }
658
+ buf += rest;
659
+ break;
660
+ }
661
+ cut = guarded;
662
+ buf += rest.slice(0, cut).trimEnd();
663
+ rest = rest.slice(cut).replace(/^\s+/, "");
664
+ flush();
665
+ }
666
+ }
667
+ chunks.push(buf + closerTags(open));
668
+ return chunks.filter((chunk) => chunk.trim().length > 0);
669
+ }
670
+
671
+ /** HTML entity (named, decimal, hex) at the start of `text`. */
672
+ const HTML_ENTITY_AT_START_RE =
673
+ /&(?:[a-zA-Z][a-zA-Z0-9]*|#\d+|#x[0-9a-fA-F]+);/y;
674
+
675
+ /** If the cut falls inside an entity, return the entity's start index. */
676
+ function backOffEntity(text: string, cut: number): number {
677
+ const amp = text.lastIndexOf("&", cut);
678
+ if (amp === -1 || amp >= cut) return cut;
679
+ HTML_ENTITY_AT_START_RE.lastIndex = amp;
680
+ const match = HTML_ENTITY_AT_START_RE.exec(text);
681
+ if (match && amp + match[0].length > cut) return amp;
682
+ return cut;
683
+ }
684
+
685
+ /**
686
+ * Pick the splitter for a wire body: HTML bodies go through `splitHtmlText`
687
+ * (tag-aware, with the `(i/N)` marker's length reserved), everything else
688
+ * keeps `splitLongText`'s paragraph→line→word boundaries. The marker length
689
+ * depends on the chunk count, so the reserved headroom is iterated to a fixed
690
+ * point (at most a couple of rounds; counts stay in the low single digits).
691
+ */
692
+ function splitForWire(
693
+ text: string,
694
+ maxLen: number,
695
+ parseMode: ParseMode | undefined,
696
+ ): string[] {
697
+ if (parseMode !== "HTML") return splitLongText(text, maxLen);
698
+ const trimmed = text.trim();
699
+ if (!trimmed) return [];
700
+ let reserved = "\n\n(1/9)".length;
701
+ let chunks = splitHtmlText(trimmed, maxLen, reserved);
702
+ for (let guard = 0; guard < 4; guard++) {
703
+ const needed = `\n\n(1/${Math.max(chunks.length, 2)})`.length;
704
+ if (needed <= reserved) break;
705
+ reserved = needed;
706
+ chunks = splitHtmlText(trimmed, maxLen, reserved);
707
+ }
708
+ return chunks;
709
+ }
710
+
711
+ /**
712
+ * Send a (possibly very long) text as N sequential messages with a `(i/N)`
713
+ * continuity marker on every chunk when N > 1 (single chunk → no marker).
714
+ * Stops on the first delivery failure. Never throws — failures surface as
715
+ * `{ ok:false, error, sent }`.
716
+ */
717
+ export async function sendMessageChunks(
718
+ client: Pick<TelegramClient, "sendMessage">,
719
+ opts: SendChunksOptions,
720
+ ): Promise<SendChunksResult> {
721
+ const maxLen = opts.maxLen ?? TELEGRAM_MAX_TEXT_LENGTH;
722
+ const chunks = splitForWire(opts.text, maxLen, opts.parseMode);
723
+ if (chunks.length === 0) {
724
+ return { ok: true, sent: 0, total: 0, messageIds: [] };
725
+ }
726
+ // A single token longer than the cap (a giant tag) is emitted whole by the
727
+ // splitter; the safety-net trim below would cut it mid-attribute and ship a
728
+ // body Telegram rejects. Refuse the body before anything is sent instead.
729
+ if (opts.parseMode === "HTML") {
730
+ const over = chunks.findIndex(
731
+ (chunk, index) =>
732
+ chunk.length +
733
+ (chunks.length > 1
734
+ ? `\n\n(${index + 1}/${chunks.length})`.length
735
+ : 0) >
736
+ maxLen,
737
+ );
738
+ if (over !== -1) {
739
+ return {
740
+ ok: false,
741
+ sent: 0,
742
+ total: chunks.length,
743
+ messageIds: [],
744
+ error:
745
+ `HTML chunk ${over + 1}/${chunks.length} is ${chunks[over].length} chars; the cap is ${maxLen} ` +
746
+ `and a tag longer than the cap cannot be split without breaking it. ` +
747
+ `NOTHING was sent — shorten the tag or the body.`,
748
+ };
749
+ }
750
+ }
751
+ if (chunks.length === 1) {
752
+ const res = await client.sendMessage(opts.token, opts.chatId, chunks[0], {
753
+ ...(opts.parseMode ? { parseMode: opts.parseMode } : {}),
754
+ ...(opts.replyMarkup ? { replyMarkup: opts.replyMarkup } : {}),
755
+ });
756
+ if (!res.ok) {
757
+ return { ok: false, sent: 0, total: 1, messageIds: [], error: res.error };
758
+ }
759
+ return {
760
+ ok: true,
761
+ sent: 1,
762
+ total: 1,
763
+ messageIds: res.messageId != null ? [res.messageId] : [],
764
+ };
765
+ }
766
+
767
+ const total = chunks.length;
768
+ const messageIds: number[] = [];
769
+ let lastError: string | undefined;
770
+ const sendOne = (
771
+ body: string,
772
+ attachMarkup: boolean,
773
+ ): Promise<TelegramSendResult> =>
774
+ client.sendMessage(opts.token, opts.chatId, body, {
775
+ ...(opts.parseMode ? { parseMode: opts.parseMode } : {}),
776
+ ...(attachMarkup && opts.replyMarkup
777
+ ? { replyMarkup: opts.replyMarkup }
778
+ : {}),
779
+ });
780
+
781
+ for (let i = 0; i < total; i++) {
782
+ const marker = `\n\n(${i + 1}/${total})`;
783
+ let body = chunks[i] + marker;
784
+ if (body.length > maxLen) {
785
+ // Safety net for the legacy boundary splitter. HTML bodies never reach
786
+ // it: `sendMessageChunks` refused an over-cap HTML chunk above, and
787
+ // `splitHtmlText` reserves the marker, so a trim here would only cut a
788
+ // closing tag.
789
+ body =
790
+ chunks[i].slice(0, Math.max(0, maxLen - marker.length - 1)).trimEnd() +
791
+ "…" +
792
+ marker;
793
+ }
794
+ const res = await sendOne(body, i === total - 1);
795
+ if (res.ok) {
796
+ if (res.messageId != null) messageIds.push(res.messageId);
797
+ } else {
798
+ lastError = res.error;
799
+ break;
800
+ }
801
+ }
802
+ return {
803
+ ok: lastError === undefined,
804
+ sent: messageIds.length,
805
+ total,
806
+ messageIds,
807
+ ...(lastError !== undefined ? { error: lastError } : {}),
808
+ };
809
+ }