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.
- package/LICENSE +21 -0
- package/README.md +204 -2
- package/docs/live-update.md +190 -0
- package/docs/pause-control.md +151 -0
- package/docs/turn-complete-notify.md +110 -0
- package/package.json +60 -4
- package/src/index.ts +1853 -0
- package/src/lib/audio-normalize.ts +91 -0
- package/src/lib/decision-buttons.ts +574 -0
- package/src/lib/document.ts +203 -0
- package/src/lib/format.ts +809 -0
- package/src/lib/html-check.ts +280 -0
- package/src/lib/inbound-poller.ts +1248 -0
- package/src/lib/inbound-queue.ts +157 -0
- package/src/lib/local-time.ts +181 -0
- package/src/lib/notify/agent-delivery.ts +29 -0
- package/src/lib/notify/craft.ts +157 -0
- package/src/lib/notify/live-update.ts +716 -0
- package/src/lib/notify/model.ts +197 -0
- package/src/lib/notify/rate-limit.ts +44 -0
- package/src/lib/notify/turn-notify.ts +138 -0
- package/src/lib/pause-control.ts +425 -0
- package/src/lib/photo.ts +182 -0
- package/src/lib/poller-lock.ts +127 -0
- package/src/lib/session-role.ts +32 -0
- package/src/lib/state-paths.ts +164 -0
- package/src/lib/stt-keys.ts +211 -0
- package/src/lib/telegram-client.ts +699 -0
- package/src/lib/telegram-inbox.ts +203 -0
- package/src/lib/telegram-settings.ts +92 -0
- package/src/lib/tool-logic.ts +604 -0
- package/src/lib/transcribe.ts +574 -0
- package/src/lib/tts.ts +451 -0
- package/src/lib/venice-catalog-base-url.ts +45 -0
- package/src/lib/voice.ts +192 -0
- package/src/lib/watchdog-tunnel.ts +102 -0
|
@@ -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, "&")
|
|
248
|
+
.replace(/</g, "<")
|
|
249
|
+
.replace(/>/g, ">");
|
|
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
|
+
}
|