@llblab/pi-telegram 0.2.8 → 0.2.10
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/AGENTS.md +2 -1
- package/CHANGELOG.md +15 -0
- package/README.md +16 -13
- package/docs/architecture.md +26 -16
- package/index.ts +199 -251
- package/lib/api.ts +277 -42
- package/lib/commands.ts +87 -0
- package/lib/media.ts +70 -1
- package/lib/polling.ts +25 -5
- package/lib/preview.ts +239 -0
- package/lib/rendering.ts +686 -49
- package/lib/replies.ts +2 -181
- package/lib/turns.ts +86 -0
- package/lib/types.ts +137 -0
- package/lib/updates.ts +64 -2
- package/package.json +1 -1
- package/tests/api.test.ts +243 -1
- package/tests/commands.test.ts +85 -0
- package/tests/media.test.ts +90 -1
- package/tests/menu.test.ts +46 -15
- package/tests/polling.test.ts +73 -0
- package/tests/preview.test.ts +480 -0
- package/tests/queue.test.ts +3 -0
- package/tests/rendering.test.ts +175 -2
- package/tests/replies.test.ts +2 -222
- package/tests/turns.test.ts +115 -0
- package/tests/updates.test.ts +72 -7
package/lib/rendering.ts
CHANGED
|
@@ -14,6 +14,10 @@ function escapeHtml(text: string): string {
|
|
|
14
14
|
.replace(/>/g, ">");
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
function escapeHtmlAttribute(text: string): string {
|
|
18
|
+
return escapeHtml(text).replace(/"/g, """).replace(/'/g, "'");
|
|
19
|
+
}
|
|
20
|
+
|
|
17
21
|
// --- Plain Preview Rendering ---
|
|
18
22
|
|
|
19
23
|
function splitPlainMarkdownLine(line: string, maxLength = 1500): string[] {
|
|
@@ -46,11 +50,220 @@ function splitPlainMarkdownLine(line: string, maxLength = 1500): string[] {
|
|
|
46
50
|
return parts.length > 0 ? parts : [line];
|
|
47
51
|
}
|
|
48
52
|
|
|
53
|
+
interface ParsedMarkdownInlineLink {
|
|
54
|
+
startIndex: number;
|
|
55
|
+
endIndex: number;
|
|
56
|
+
label: string;
|
|
57
|
+
destination: string;
|
|
58
|
+
isImage: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface ParsedMarkdownAutolink {
|
|
62
|
+
startIndex: number;
|
|
63
|
+
endIndex: number;
|
|
64
|
+
destination: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isEscapedMarkdownCharacter(text: string, index: number): boolean {
|
|
68
|
+
let backslashCount = 0;
|
|
69
|
+
for (let i = index - 1; i >= 0 && text[i] === "\\"; i--) {
|
|
70
|
+
backslashCount += 1;
|
|
71
|
+
}
|
|
72
|
+
return backslashCount % 2 === 1;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function findMarkdownClosingBracket(
|
|
76
|
+
text: string,
|
|
77
|
+
startIndex: number,
|
|
78
|
+
): number | undefined {
|
|
79
|
+
let depth = 0;
|
|
80
|
+
for (let index = startIndex; index < text.length; index += 1) {
|
|
81
|
+
if (isEscapedMarkdownCharacter(text, index)) continue;
|
|
82
|
+
const char = text[index] ?? "";
|
|
83
|
+
if (char === "[") {
|
|
84
|
+
depth += 1;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (char !== "]") continue;
|
|
88
|
+
depth -= 1;
|
|
89
|
+
if (depth === 0) return index;
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseMarkdownLinkTarget(
|
|
95
|
+
text: string,
|
|
96
|
+
openParenIndex: number,
|
|
97
|
+
): { destination: string; endIndex: number } | undefined {
|
|
98
|
+
let index = openParenIndex + 1;
|
|
99
|
+
while (index < text.length && /\s/.test(text[index] ?? "")) {
|
|
100
|
+
index += 1;
|
|
101
|
+
}
|
|
102
|
+
if (index >= text.length) return undefined;
|
|
103
|
+
let destination = "";
|
|
104
|
+
if (text[index] === "<") {
|
|
105
|
+
const destinationStart = index + 1;
|
|
106
|
+
index += 1;
|
|
107
|
+
while (index < text.length) {
|
|
108
|
+
if (!isEscapedMarkdownCharacter(text, index) && text[index] === ">") {
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
index += 1;
|
|
112
|
+
}
|
|
113
|
+
if (index >= text.length) return undefined;
|
|
114
|
+
destination = text.slice(destinationStart, index).trim();
|
|
115
|
+
index += 1;
|
|
116
|
+
} else {
|
|
117
|
+
const destinationStart = index;
|
|
118
|
+
let parenDepth = 0;
|
|
119
|
+
while (index < text.length) {
|
|
120
|
+
if (isEscapedMarkdownCharacter(text, index)) {
|
|
121
|
+
index += 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const char = text[index] ?? "";
|
|
125
|
+
if (/\s/.test(char) && parenDepth === 0) break;
|
|
126
|
+
if (char === "(") {
|
|
127
|
+
parenDepth += 1;
|
|
128
|
+
index += 1;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (char === ")") {
|
|
132
|
+
if (parenDepth === 0) break;
|
|
133
|
+
parenDepth -= 1;
|
|
134
|
+
index += 1;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
index += 1;
|
|
138
|
+
}
|
|
139
|
+
destination = text.slice(destinationStart, index).trim();
|
|
140
|
+
}
|
|
141
|
+
if (!destination) return undefined;
|
|
142
|
+
while (index < text.length && /\s/.test(text[index] ?? "")) {
|
|
143
|
+
index += 1;
|
|
144
|
+
}
|
|
145
|
+
if (
|
|
146
|
+
index < text.length &&
|
|
147
|
+
(text[index] === '"' || text[index] === "'" || text[index] === "(")
|
|
148
|
+
) {
|
|
149
|
+
const titleDelimiter = text[index] ?? '"';
|
|
150
|
+
const closingTitleDelimiter = titleDelimiter === "(" ? ")" : titleDelimiter;
|
|
151
|
+
index += 1;
|
|
152
|
+
while (index < text.length) {
|
|
153
|
+
if (
|
|
154
|
+
!isEscapedMarkdownCharacter(text, index) &&
|
|
155
|
+
text[index] === closingTitleDelimiter
|
|
156
|
+
) {
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
index += 1;
|
|
160
|
+
}
|
|
161
|
+
if (index >= text.length) return undefined;
|
|
162
|
+
index += 1;
|
|
163
|
+
while (index < text.length && /\s/.test(text[index] ?? "")) {
|
|
164
|
+
index += 1;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (text[index] !== ")") return undefined;
|
|
168
|
+
return { destination, endIndex: index };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isSupportedMarkdownLinkDestination(destination: string): boolean {
|
|
172
|
+
return /^(?:https?:\/\/|mailto:)/i.test(destination.trim());
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function parseMarkdownInlineLinkAt(
|
|
176
|
+
text: string,
|
|
177
|
+
index: number,
|
|
178
|
+
): ParsedMarkdownInlineLink | undefined {
|
|
179
|
+
const isImage = text[index] === "!" && text[index + 1] === "[";
|
|
180
|
+
const labelStartIndex = isImage ? index + 1 : index;
|
|
181
|
+
if (text[labelStartIndex] !== "[") return undefined;
|
|
182
|
+
if (
|
|
183
|
+
isEscapedMarkdownCharacter(text, labelStartIndex) ||
|
|
184
|
+
(isImage && isEscapedMarkdownCharacter(text, index))
|
|
185
|
+
) {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
const labelEndIndex = findMarkdownClosingBracket(text, labelStartIndex);
|
|
189
|
+
if (labelEndIndex === undefined || text[labelEndIndex + 1] !== "(") {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
const target = parseMarkdownLinkTarget(text, labelEndIndex + 1);
|
|
193
|
+
if (!target) return undefined;
|
|
194
|
+
return {
|
|
195
|
+
startIndex: index,
|
|
196
|
+
endIndex: target.endIndex,
|
|
197
|
+
label: text.slice(labelStartIndex + 1, labelEndIndex),
|
|
198
|
+
destination: target.destination,
|
|
199
|
+
isImage,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function parseMarkdownAutolinkAt(
|
|
204
|
+
text: string,
|
|
205
|
+
index: number,
|
|
206
|
+
): ParsedMarkdownAutolink | undefined {
|
|
207
|
+
if (text[index] !== "<" || isEscapedMarkdownCharacter(text, index)) {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
let endIndex = index + 1;
|
|
211
|
+
while (endIndex < text.length) {
|
|
212
|
+
if (!isEscapedMarkdownCharacter(text, endIndex) && text[endIndex] === ">") {
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
endIndex += 1;
|
|
216
|
+
}
|
|
217
|
+
if (endIndex >= text.length) return undefined;
|
|
218
|
+
const destination = text.slice(index + 1, endIndex).trim();
|
|
219
|
+
if (!isSupportedMarkdownLinkDestination(destination)) {
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
return { startIndex: index, endIndex, destination };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function replaceMarkdownLinkLike(
|
|
226
|
+
text: string,
|
|
227
|
+
options: {
|
|
228
|
+
renderInlineLink: (
|
|
229
|
+
link: ParsedMarkdownInlineLink,
|
|
230
|
+
supported: boolean,
|
|
231
|
+
) => string;
|
|
232
|
+
renderAutolink: (link: ParsedMarkdownAutolink) => string;
|
|
233
|
+
},
|
|
234
|
+
): string {
|
|
235
|
+
let result = "";
|
|
236
|
+
for (let index = 0; index < text.length; ) {
|
|
237
|
+
const inlineLink = parseMarkdownInlineLinkAt(text, index);
|
|
238
|
+
if (inlineLink) {
|
|
239
|
+
result += options.renderInlineLink(
|
|
240
|
+
inlineLink,
|
|
241
|
+
isSupportedMarkdownLinkDestination(inlineLink.destination),
|
|
242
|
+
);
|
|
243
|
+
index = inlineLink.endIndex + 1;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const autolink = parseMarkdownAutolinkAt(text, index);
|
|
247
|
+
if (autolink) {
|
|
248
|
+
result += options.renderAutolink(autolink);
|
|
249
|
+
index = autolink.endIndex + 1;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
result += text[index] ?? "";
|
|
253
|
+
index += 1;
|
|
254
|
+
}
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
|
|
49
258
|
function stripInlineMarkdownToPlainText(text: string): string {
|
|
50
|
-
let result = text
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
259
|
+
let result = replaceMarkdownLinkLike(text, {
|
|
260
|
+
renderInlineLink: (link, supported) => {
|
|
261
|
+
const plainLabel = stripInlineMarkdownToPlainText(link.label).trim();
|
|
262
|
+
if (plainLabel.length > 0) return plainLabel;
|
|
263
|
+
return supported ? link.destination : "";
|
|
264
|
+
},
|
|
265
|
+
renderAutolink: (link) => link.destination,
|
|
266
|
+
});
|
|
54
267
|
result = result.replace(/`([^`\n]+)`/g, "$1");
|
|
55
268
|
result = result.replace(/(\*\*\*|___)(.+?)\1/g, "$2");
|
|
56
269
|
result = result.replace(/(\*\*|__)(.+?)\1/g, "$2");
|
|
@@ -146,6 +359,282 @@ function isMarkdownNumberedListMarker(marker: string): boolean {
|
|
|
146
359
|
return /^\d+\.$/.test(marker);
|
|
147
360
|
}
|
|
148
361
|
|
|
362
|
+
function matchMarkdownHeadingLine(line: string): RegExpMatchArray | null {
|
|
363
|
+
return line.match(/^(\s*)#{1,6}\s+(.+)$/);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function endsWithMarkdownHeadingLine(markdown: string): boolean {
|
|
367
|
+
const lines = markdown.split("\n");
|
|
368
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
369
|
+
const line = lines[index] ?? "";
|
|
370
|
+
if (line.trim().length === 0) continue;
|
|
371
|
+
return matchMarkdownHeadingLine(line) !== null;
|
|
372
|
+
}
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function splitLeadingMarkdownBlankLines(markdown: string): {
|
|
377
|
+
blankLines: number;
|
|
378
|
+
remainingText: string;
|
|
379
|
+
} {
|
|
380
|
+
const lines = markdown.split("\n");
|
|
381
|
+
let start = 0;
|
|
382
|
+
while (start < lines.length && (lines[start] ?? "").trim().length === 0) {
|
|
383
|
+
start += 1;
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
blankLines: start,
|
|
387
|
+
remainingText: lines.slice(start).join("\n"),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export type TelegramPreviewRenderStrategy = "plain" | "rich-stable-blocks";
|
|
392
|
+
|
|
393
|
+
export interface TelegramPreviewSnapshotStateLike {
|
|
394
|
+
pendingText: string;
|
|
395
|
+
lastSentText: string;
|
|
396
|
+
lastSentParseMode?: "HTML";
|
|
397
|
+
lastSentStrategy?: TelegramPreviewRenderStrategy;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export interface TelegramPreviewSnapshot extends TelegramRenderedChunk {
|
|
401
|
+
sourceText: string;
|
|
402
|
+
strategy: TelegramPreviewRenderStrategy;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function buildTelegramPreviewFlushText(options: {
|
|
406
|
+
state: TelegramPreviewSnapshotStateLike;
|
|
407
|
+
maxMessageLength: number;
|
|
408
|
+
renderPreviewText: (markdown: string) => string;
|
|
409
|
+
}): string | undefined {
|
|
410
|
+
const rawText = options.state.pendingText.trim();
|
|
411
|
+
const previewText = options.renderPreviewText(rawText).trim();
|
|
412
|
+
if (!previewText || previewText === options.state.lastSentText) {
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
return previewText.length > options.maxMessageLength
|
|
416
|
+
? previewText.slice(0, options.maxMessageLength)
|
|
417
|
+
: previewText;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function buildTelegramPlainPreviewSnapshot(options: {
|
|
421
|
+
sourceText: string;
|
|
422
|
+
state: TelegramPreviewSnapshotStateLike;
|
|
423
|
+
maxMessageLength: number;
|
|
424
|
+
renderPreviewText: (markdown: string) => string;
|
|
425
|
+
}): TelegramPreviewSnapshot | undefined {
|
|
426
|
+
const previewText = options.renderPreviewText(options.sourceText).trim();
|
|
427
|
+
if (!previewText) return undefined;
|
|
428
|
+
const truncatedPreviewText =
|
|
429
|
+
previewText.length > options.maxMessageLength
|
|
430
|
+
? previewText.slice(0, options.maxMessageLength)
|
|
431
|
+
: previewText;
|
|
432
|
+
if (
|
|
433
|
+
truncatedPreviewText === options.state.lastSentText &&
|
|
434
|
+
options.state.lastSentStrategy === "plain"
|
|
435
|
+
) {
|
|
436
|
+
return undefined;
|
|
437
|
+
}
|
|
438
|
+
return {
|
|
439
|
+
text: truncatedPreviewText,
|
|
440
|
+
sourceText: options.sourceText,
|
|
441
|
+
strategy: "plain",
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
interface TelegramStablePreviewSplit {
|
|
446
|
+
stableMarkdown: string;
|
|
447
|
+
unstableTail: string;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function splitTelegramStablePreviewMarkdown(
|
|
451
|
+
markdown: string,
|
|
452
|
+
): TelegramStablePreviewSplit {
|
|
453
|
+
const normalized = normalizeMarkdownDocument(markdown);
|
|
454
|
+
if (normalized.length === 0) {
|
|
455
|
+
return { stableMarkdown: "", unstableTail: "" };
|
|
456
|
+
}
|
|
457
|
+
const lines = normalized.split("\n");
|
|
458
|
+
let index = 0;
|
|
459
|
+
let stableEndIndex = 0;
|
|
460
|
+
while (index < lines.length) {
|
|
461
|
+
while (index < lines.length && (lines[index] ?? "").trim().length === 0) {
|
|
462
|
+
index += 1;
|
|
463
|
+
}
|
|
464
|
+
if (index >= lines.length) break;
|
|
465
|
+
const blockStart = index;
|
|
466
|
+
const line = lines[index] ?? "";
|
|
467
|
+
const nextLine = lines[index + 1] ?? "";
|
|
468
|
+
const fence = parseMarkdownFence(line);
|
|
469
|
+
if (fence) {
|
|
470
|
+
index += 1;
|
|
471
|
+
while (
|
|
472
|
+
index < lines.length &&
|
|
473
|
+
!isMatchingMarkdownFence(lines[index] ?? "", fence)
|
|
474
|
+
) {
|
|
475
|
+
index += 1;
|
|
476
|
+
}
|
|
477
|
+
if (index >= lines.length) {
|
|
478
|
+
return {
|
|
479
|
+
stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
|
|
480
|
+
unstableTail: lines.slice(stableEndIndex).join("\n"),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
index += 1;
|
|
484
|
+
stableEndIndex = index;
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
if (line.includes("|") && isMarkdownTableSeparator(nextLine)) {
|
|
488
|
+
index += 2;
|
|
489
|
+
while (index < lines.length) {
|
|
490
|
+
const tableLine = lines[index] ?? "";
|
|
491
|
+
if (tableLine.trim().length === 0 || !tableLine.includes("|")) {
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
index += 1;
|
|
495
|
+
}
|
|
496
|
+
if (index >= lines.length) {
|
|
497
|
+
return {
|
|
498
|
+
stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
|
|
499
|
+
unstableTail: lines.slice(stableEndIndex).join("\n"),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
stableEndIndex = index;
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
if (canStartIndentedCodeBlock(lines, index)) {
|
|
506
|
+
while (index < lines.length) {
|
|
507
|
+
const rawLine = lines[index] ?? "";
|
|
508
|
+
if (rawLine.trim().length === 0) {
|
|
509
|
+
index += 1;
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
if (!isIndentedCodeLine(rawLine)) break;
|
|
513
|
+
index += 1;
|
|
514
|
+
}
|
|
515
|
+
if (index >= lines.length) {
|
|
516
|
+
return {
|
|
517
|
+
stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
|
|
518
|
+
unstableTail: lines.slice(stableEndIndex).join("\n"),
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
stableEndIndex = index;
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (/^\s*>/.test(line)) {
|
|
525
|
+
while (index < lines.length && /^\s*>/.test(lines[index] ?? "")) {
|
|
526
|
+
index += 1;
|
|
527
|
+
}
|
|
528
|
+
if (index >= lines.length) {
|
|
529
|
+
return {
|
|
530
|
+
stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
|
|
531
|
+
unstableTail: lines.slice(stableEndIndex).join("\n"),
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
stableEndIndex = index;
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
while (index < lines.length) {
|
|
538
|
+
const current = lines[index] ?? "";
|
|
539
|
+
const following = lines[index + 1] ?? "";
|
|
540
|
+
if (current.trim().length === 0) break;
|
|
541
|
+
if (
|
|
542
|
+
index !== blockStart &&
|
|
543
|
+
(isFencedCodeStart(current) ||
|
|
544
|
+
canStartIndentedCodeBlock(lines, index) ||
|
|
545
|
+
/^\s*>/.test(current) ||
|
|
546
|
+
(current.includes("|") && isMarkdownTableSeparator(following)))
|
|
547
|
+
) {
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
index += 1;
|
|
551
|
+
}
|
|
552
|
+
if (index >= lines.length) {
|
|
553
|
+
return {
|
|
554
|
+
stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
|
|
555
|
+
unstableTail: lines.slice(stableEndIndex).join("\n"),
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
stableEndIndex = index;
|
|
559
|
+
}
|
|
560
|
+
return {
|
|
561
|
+
stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
|
|
562
|
+
unstableTail: lines.slice(stableEndIndex).join("\n"),
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export function buildTelegramPreviewSnapshot(options: {
|
|
567
|
+
state: TelegramPreviewSnapshotStateLike;
|
|
568
|
+
maxMessageLength: number;
|
|
569
|
+
renderPreviewText: (markdown: string) => string;
|
|
570
|
+
renderTelegramMessage: (
|
|
571
|
+
text: string,
|
|
572
|
+
options?: { mode?: TelegramRenderMode },
|
|
573
|
+
) => TelegramRenderedChunk[];
|
|
574
|
+
}): TelegramPreviewSnapshot | undefined {
|
|
575
|
+
const sourceText = options.state.pendingText.trim();
|
|
576
|
+
if (!sourceText) return undefined;
|
|
577
|
+
const split = splitTelegramStablePreviewMarkdown(sourceText);
|
|
578
|
+
if (split.stableMarkdown.length === 0) {
|
|
579
|
+
return buildTelegramPlainPreviewSnapshot({
|
|
580
|
+
sourceText,
|
|
581
|
+
state: options.state,
|
|
582
|
+
maxMessageLength: options.maxMessageLength,
|
|
583
|
+
renderPreviewText: options.renderPreviewText,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
const stableChunk = options.renderTelegramMessage(split.stableMarkdown, {
|
|
587
|
+
mode: "markdown",
|
|
588
|
+
})[0];
|
|
589
|
+
if (
|
|
590
|
+
!stableChunk ||
|
|
591
|
+
stableChunk.text.length === 0 ||
|
|
592
|
+
stableChunk.text.length > options.maxMessageLength
|
|
593
|
+
) {
|
|
594
|
+
return buildTelegramPlainPreviewSnapshot({
|
|
595
|
+
sourceText,
|
|
596
|
+
state: options.state,
|
|
597
|
+
maxMessageLength: options.maxMessageLength,
|
|
598
|
+
renderPreviewText: options.renderPreviewText,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
let previewText = stableChunk.text;
|
|
602
|
+
if (split.unstableTail.length > 0) {
|
|
603
|
+
const tail = splitLeadingMarkdownBlankLines(split.unstableTail);
|
|
604
|
+
const minimumBlankLinesBeforeTail = endsWithMarkdownHeadingLine(
|
|
605
|
+
split.stableMarkdown,
|
|
606
|
+
)
|
|
607
|
+
? 1
|
|
608
|
+
: 0;
|
|
609
|
+
const blankLinesBeforeTail = Math.max(
|
|
610
|
+
tail.blankLines,
|
|
611
|
+
minimumBlankLinesBeforeTail,
|
|
612
|
+
);
|
|
613
|
+
const separator =
|
|
614
|
+
tail.remainingText.length > 0
|
|
615
|
+
? "\n".repeat(blankLinesBeforeTail + 1)
|
|
616
|
+
: "";
|
|
617
|
+
const tailText = escapeHtml(tail.remainingText);
|
|
618
|
+
const candidate = `${previewText}${separator}${tailText}`;
|
|
619
|
+
if (candidate.length <= options.maxMessageLength) {
|
|
620
|
+
previewText = candidate;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
if (
|
|
624
|
+
previewText === options.state.lastSentText &&
|
|
625
|
+
stableChunk.parseMode === options.state.lastSentParseMode &&
|
|
626
|
+
options.state.lastSentStrategy === "rich-stable-blocks"
|
|
627
|
+
) {
|
|
628
|
+
return undefined;
|
|
629
|
+
}
|
|
630
|
+
return {
|
|
631
|
+
text: previewText,
|
|
632
|
+
parseMode: stableChunk.parseMode,
|
|
633
|
+
sourceText,
|
|
634
|
+
strategy: "rich-stable-blocks",
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
149
638
|
export function renderMarkdownPreviewText(markdown: string): string {
|
|
150
639
|
const normalized = normalizeMarkdownDocument(markdown);
|
|
151
640
|
if (normalized.length === 0) return "";
|
|
@@ -161,7 +650,7 @@ export function renderMarkdownPreviewText(markdown: string): string {
|
|
|
161
650
|
continue;
|
|
162
651
|
}
|
|
163
652
|
if (line.trim().length === 0) {
|
|
164
|
-
|
|
653
|
+
output.push("");
|
|
165
654
|
continue;
|
|
166
655
|
}
|
|
167
656
|
output.push(line);
|
|
@@ -172,15 +661,15 @@ export function renderMarkdownPreviewText(markdown: string): string {
|
|
|
172
661
|
continue;
|
|
173
662
|
}
|
|
174
663
|
if (line.trim().length === 0) {
|
|
175
|
-
|
|
664
|
+
output.push("");
|
|
176
665
|
continue;
|
|
177
666
|
}
|
|
178
667
|
if (isMarkdownTableSeparator(line)) {
|
|
179
668
|
continue;
|
|
180
669
|
}
|
|
181
|
-
const heading = line
|
|
670
|
+
const heading = matchMarkdownHeadingLine(line);
|
|
182
671
|
if (heading) {
|
|
183
|
-
output.push(stripInlineMarkdownToPlainText(heading[
|
|
672
|
+
output.push(stripInlineMarkdownToPlainText(heading[2] ?? ""));
|
|
184
673
|
continue;
|
|
185
674
|
}
|
|
186
675
|
const task = line.match(/^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.+)$/);
|
|
@@ -252,26 +741,24 @@ function renderInlineMarkdown(text: string): string {
|
|
|
252
741
|
tokens.push(html);
|
|
253
742
|
return token;
|
|
254
743
|
};
|
|
255
|
-
let result = text
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
return makeToken(`<a href="${escapeHtml(url)}">${escapeHtml(label)}</a>`);
|
|
744
|
+
let result = replaceMarkdownLinkLike(text, {
|
|
745
|
+
renderInlineLink: (link, supported) => {
|
|
746
|
+
const plainLabel = stripInlineMarkdownToPlainText(link.label).trim();
|
|
747
|
+
if (!supported) {
|
|
748
|
+
return plainLabel.length > 0 ? plainLabel : link.destination;
|
|
749
|
+
}
|
|
750
|
+
const renderedLabel =
|
|
751
|
+
plainLabel.length > 0 ? plainLabel : link.destination;
|
|
752
|
+
return makeToken(
|
|
753
|
+
`<a href="${escapeHtmlAttribute(link.destination)}">${escapeHtml(renderedLabel)}</a>`,
|
|
754
|
+
);
|
|
267
755
|
},
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
return makeToken(`<a href="${escapeHtml(url)}">${escapeHtml(url)}</a>`);
|
|
756
|
+
renderAutolink: (link) => {
|
|
757
|
+
return makeToken(
|
|
758
|
+
`<a href="${escapeHtmlAttribute(link.destination)}">${escapeHtml(link.destination)}</a>`,
|
|
759
|
+
);
|
|
273
760
|
},
|
|
274
|
-
);
|
|
761
|
+
});
|
|
275
762
|
result = result.replace(/`([^`\n]+)`/g, (_match, code: string) => {
|
|
276
763
|
return makeToken(`<code>${escapeHtml(code)}</code>`);
|
|
277
764
|
});
|
|
@@ -335,7 +822,7 @@ function renderMarkdownTextLines(block: string): string[] {
|
|
|
335
822
|
if (line.trim().length === 0) continue;
|
|
336
823
|
const pieces = splitPlainMarkdownLine(line);
|
|
337
824
|
for (const piece of pieces) {
|
|
338
|
-
const heading = piece
|
|
825
|
+
const heading = matchMarkdownHeadingLine(piece);
|
|
339
826
|
if (heading) {
|
|
340
827
|
rendered.push(
|
|
341
828
|
`${buildListIndent(Math.floor((heading[1] ?? "").length / 2))}<b>${renderInlineMarkdown(heading[2] ?? "")}</b>`,
|
|
@@ -394,9 +881,14 @@ function renderMarkdownTextLines(block: string): string[] {
|
|
|
394
881
|
return rendered;
|
|
395
882
|
}
|
|
396
883
|
|
|
884
|
+
function sanitizeTelegramCodeLanguage(language: string): string {
|
|
885
|
+
return language.split(/\s+/)[0]?.replace(/[^A-Za-z0-9_+.-]/g, "") ?? "";
|
|
886
|
+
}
|
|
887
|
+
|
|
397
888
|
function renderMarkdownCodeBlock(code: string, language?: string): string[] {
|
|
398
|
-
const
|
|
399
|
-
|
|
889
|
+
const safeLanguage = language ? sanitizeTelegramCodeLanguage(language) : "";
|
|
890
|
+
const open = safeLanguage
|
|
891
|
+
? `<pre><code class="language-${escapeHtmlAttribute(safeLanguage)}">`
|
|
400
892
|
: "<pre><code>";
|
|
401
893
|
const close = "</code></pre>";
|
|
402
894
|
const maxContentLength = MAX_MESSAGE_LENGTH - open.length - close.length;
|
|
@@ -517,15 +1009,56 @@ function renderMarkdownQuoteBlock(lines: string[]): string[] {
|
|
|
517
1009
|
});
|
|
518
1010
|
}
|
|
519
1011
|
|
|
1012
|
+
interface TelegramRenderedBlockWithSpacing {
|
|
1013
|
+
text: string;
|
|
1014
|
+
blankLinesBefore: number;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
520
1017
|
function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
521
1018
|
const normalized = normalizeMarkdownDocument(markdown);
|
|
522
1019
|
if (normalized.length === 0) return [];
|
|
523
|
-
const renderedBlocks:
|
|
1020
|
+
const renderedBlocks: TelegramRenderedBlockWithSpacing[] = [];
|
|
1021
|
+
let minimumBlankLinesBeforeNextBlock = 0;
|
|
1022
|
+
const pushRenderedBlocks = (
|
|
1023
|
+
blocks: string[],
|
|
1024
|
+
blankLinesBefore: number,
|
|
1025
|
+
): void => {
|
|
1026
|
+
const effectiveBlankLinesBefore =
|
|
1027
|
+
renderedBlocks.length === 0
|
|
1028
|
+
? blankLinesBefore
|
|
1029
|
+
: Math.max(blankLinesBefore, minimumBlankLinesBeforeNextBlock);
|
|
1030
|
+
for (const [blockIndex, block] of blocks.entries()) {
|
|
1031
|
+
renderedBlocks.push({
|
|
1032
|
+
text: block,
|
|
1033
|
+
blankLinesBefore: blockIndex === 0 ? effectiveBlankLinesBefore : 0,
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
minimumBlankLinesBeforeNextBlock = 0;
|
|
1037
|
+
};
|
|
524
1038
|
const lines = normalized.split("\n");
|
|
525
1039
|
let index = 0;
|
|
1040
|
+
let pendingBlankLines = 0;
|
|
526
1041
|
while (index < lines.length) {
|
|
527
1042
|
const line = lines[index] ?? "";
|
|
528
1043
|
const nextLine = lines[index + 1] ?? "";
|
|
1044
|
+
if (line.trim().length === 0) {
|
|
1045
|
+
pendingBlankLines += 1;
|
|
1046
|
+
index += 1;
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
const heading = matchMarkdownHeadingLine(line);
|
|
1050
|
+
if (heading) {
|
|
1051
|
+
pushRenderedBlocks(
|
|
1052
|
+
renderMarkdownTextBlock(line),
|
|
1053
|
+
renderedBlocks.length === 0
|
|
1054
|
+
? pendingBlankLines
|
|
1055
|
+
: Math.max(pendingBlankLines, 1),
|
|
1056
|
+
);
|
|
1057
|
+
pendingBlankLines = 0;
|
|
1058
|
+
minimumBlankLinesBeforeNextBlock = 1;
|
|
1059
|
+
index += 1;
|
|
1060
|
+
continue;
|
|
1061
|
+
}
|
|
529
1062
|
const fence = parseMarkdownFence(line);
|
|
530
1063
|
if (fence) {
|
|
531
1064
|
index += 1;
|
|
@@ -540,16 +1073,11 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
|
540
1073
|
if (index < lines.length) {
|
|
541
1074
|
index += 1;
|
|
542
1075
|
}
|
|
543
|
-
|
|
544
|
-
|
|
1076
|
+
pushRenderedBlocks(
|
|
1077
|
+
renderMarkdownCodeBlock(codeLines.join("\n"), fence.info),
|
|
1078
|
+
pendingBlankLines,
|
|
545
1079
|
);
|
|
546
|
-
|
|
547
|
-
index += 1;
|
|
548
|
-
}
|
|
549
|
-
continue;
|
|
550
|
-
}
|
|
551
|
-
if (line.trim().length === 0) {
|
|
552
|
-
index += 1;
|
|
1080
|
+
pendingBlankLines = 0;
|
|
553
1081
|
continue;
|
|
554
1082
|
}
|
|
555
1083
|
if (line.includes("|") && isMarkdownTableSeparator(nextLine)) {
|
|
@@ -563,7 +1091,11 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
|
563
1091
|
tableLines.push(tableLine);
|
|
564
1092
|
index += 1;
|
|
565
1093
|
}
|
|
566
|
-
|
|
1094
|
+
pushRenderedBlocks(
|
|
1095
|
+
renderMarkdownTableBlock(tableLines),
|
|
1096
|
+
pendingBlankLines,
|
|
1097
|
+
);
|
|
1098
|
+
pendingBlankLines = 0;
|
|
567
1099
|
continue;
|
|
568
1100
|
}
|
|
569
1101
|
if (canStartIndentedCodeBlock(lines, index)) {
|
|
@@ -579,7 +1111,11 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
|
579
1111
|
codeLines.push(stripIndentedCodePrefix(rawLine));
|
|
580
1112
|
index += 1;
|
|
581
1113
|
}
|
|
582
|
-
|
|
1114
|
+
pushRenderedBlocks(
|
|
1115
|
+
renderMarkdownCodeBlock(codeLines.join("\n")),
|
|
1116
|
+
pendingBlankLines,
|
|
1117
|
+
);
|
|
1118
|
+
pendingBlankLines = 0;
|
|
583
1119
|
continue;
|
|
584
1120
|
}
|
|
585
1121
|
if (/^\s*>/.test(line)) {
|
|
@@ -588,7 +1124,11 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
|
588
1124
|
quoteLines.push(lines[index] ?? "");
|
|
589
1125
|
index += 1;
|
|
590
1126
|
}
|
|
591
|
-
|
|
1127
|
+
pushRenderedBlocks(
|
|
1128
|
+
renderMarkdownQuoteBlock(quoteLines),
|
|
1129
|
+
pendingBlankLines,
|
|
1130
|
+
);
|
|
1131
|
+
pendingBlankLines = 0;
|
|
592
1132
|
continue;
|
|
593
1133
|
}
|
|
594
1134
|
const textLines: string[] = [];
|
|
@@ -606,12 +1146,18 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
|
606
1146
|
textLines.push(current);
|
|
607
1147
|
index += 1;
|
|
608
1148
|
}
|
|
609
|
-
|
|
1149
|
+
pushRenderedBlocks(
|
|
1150
|
+
renderMarkdownTextBlock(textLines.join("\n")),
|
|
1151
|
+
pendingBlankLines,
|
|
1152
|
+
);
|
|
1153
|
+
pendingBlankLines = 0;
|
|
610
1154
|
}
|
|
611
1155
|
const chunks: string[] = [];
|
|
612
1156
|
let current = "";
|
|
613
1157
|
for (const block of renderedBlocks) {
|
|
614
|
-
const
|
|
1158
|
+
const separator = "\n".repeat(block.blankLinesBefore + 1);
|
|
1159
|
+
const candidate =
|
|
1160
|
+
current.length === 0 ? block.text : `${current}${separator}${block.text}`;
|
|
615
1161
|
if (candidate.length <= MAX_MESSAGE_LENGTH) {
|
|
616
1162
|
current = candidate;
|
|
617
1163
|
continue;
|
|
@@ -620,12 +1166,12 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
|
620
1166
|
chunks.push(current);
|
|
621
1167
|
current = "";
|
|
622
1168
|
}
|
|
623
|
-
if (block.length <= MAX_MESSAGE_LENGTH) {
|
|
624
|
-
current = block;
|
|
1169
|
+
if (block.text.length <= MAX_MESSAGE_LENGTH) {
|
|
1170
|
+
current = block.text;
|
|
625
1171
|
continue;
|
|
626
1172
|
}
|
|
627
|
-
for (let i = 0; i < block.length; i += MAX_MESSAGE_LENGTH) {
|
|
628
|
-
chunks.push(block.slice(i, i + MAX_MESSAGE_LENGTH));
|
|
1173
|
+
for (let i = 0; i < block.text.length; i += MAX_MESSAGE_LENGTH) {
|
|
1174
|
+
chunks.push(block.text.slice(i, i + MAX_MESSAGE_LENGTH));
|
|
629
1175
|
}
|
|
630
1176
|
}
|
|
631
1177
|
if (current.length > 0) {
|
|
@@ -699,6 +1245,94 @@ function chunkParagraphs(text: string): string[] {
|
|
|
699
1245
|
return chunks;
|
|
700
1246
|
}
|
|
701
1247
|
|
|
1248
|
+
interface OpenHtmlTag {
|
|
1249
|
+
name: string;
|
|
1250
|
+
openTag: string;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
const TELEGRAM_VOID_HTML_TAGS = new Set(["br", "hr"]);
|
|
1254
|
+
|
|
1255
|
+
function getHtmlTagName(tag: string): string | undefined {
|
|
1256
|
+
return tag.match(/^<\/?\s*([a-zA-Z][\w-]*)/)?.[1]?.toLowerCase();
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function isHtmlClosingTag(tag: string): boolean {
|
|
1260
|
+
return /^<\//.test(tag);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function isHtmlSelfClosingTag(tag: string): boolean {
|
|
1264
|
+
return /\/\s*>$/.test(tag);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function getHtmlClosingTags(openTags: OpenHtmlTag[]): string {
|
|
1268
|
+
return [...openTags]
|
|
1269
|
+
.reverse()
|
|
1270
|
+
.map((tag) => `</${tag.name}>`)
|
|
1271
|
+
.join("");
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function getHtmlOpeningTags(openTags: OpenHtmlTag[]): string {
|
|
1275
|
+
return openTags.map((tag) => tag.openTag).join("");
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function updateOpenHtmlTags(tag: string, openTags: OpenHtmlTag[]): void {
|
|
1279
|
+
const name = getHtmlTagName(tag);
|
|
1280
|
+
if (!name || TELEGRAM_VOID_HTML_TAGS.has(name)) return;
|
|
1281
|
+
if (isHtmlClosingTag(tag)) {
|
|
1282
|
+
const index = openTags.map((openTag) => openTag.name).lastIndexOf(name);
|
|
1283
|
+
if (index !== -1) openTags.splice(index, 1);
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
if (isHtmlSelfClosingTag(tag)) return;
|
|
1287
|
+
openTags.push({ name, openTag: tag });
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
function chunkHtmlPreservingTags(html: string): string[] {
|
|
1291
|
+
if (html.length <= MAX_MESSAGE_LENGTH) return [html];
|
|
1292
|
+
const chunks: string[] = [];
|
|
1293
|
+
const openTags: OpenHtmlTag[] = [];
|
|
1294
|
+
const tagPattern = /<\/?[a-zA-Z][^>]*>/g;
|
|
1295
|
+
let current = "";
|
|
1296
|
+
let index = 0;
|
|
1297
|
+
const flushCurrent = (): void => {
|
|
1298
|
+
if (current.length === 0) return;
|
|
1299
|
+
chunks.push(`${current}${getHtmlClosingTags(openTags)}`);
|
|
1300
|
+
current = getHtmlOpeningTags(openTags);
|
|
1301
|
+
};
|
|
1302
|
+
const appendText = (text: string): void => {
|
|
1303
|
+
let remaining = text;
|
|
1304
|
+
while (remaining.length > 0) {
|
|
1305
|
+
const closingTags = getHtmlClosingTags(openTags);
|
|
1306
|
+
const available =
|
|
1307
|
+
MAX_MESSAGE_LENGTH - current.length - closingTags.length;
|
|
1308
|
+
if (available <= 0) {
|
|
1309
|
+
flushCurrent();
|
|
1310
|
+
continue;
|
|
1311
|
+
}
|
|
1312
|
+
const slice = remaining.slice(0, available);
|
|
1313
|
+
current += slice;
|
|
1314
|
+
remaining = remaining.slice(slice.length);
|
|
1315
|
+
if (remaining.length > 0) flushCurrent();
|
|
1316
|
+
}
|
|
1317
|
+
};
|
|
1318
|
+
const appendTag = (tag: string): void => {
|
|
1319
|
+
const closingTags = getHtmlClosingTags(openTags);
|
|
1320
|
+
if (current.length + tag.length + closingTags.length > MAX_MESSAGE_LENGTH) {
|
|
1321
|
+
flushCurrent();
|
|
1322
|
+
}
|
|
1323
|
+
current += tag;
|
|
1324
|
+
updateOpenHtmlTags(tag, openTags);
|
|
1325
|
+
};
|
|
1326
|
+
for (const match of html.matchAll(tagPattern)) {
|
|
1327
|
+
appendText(html.slice(index, match.index));
|
|
1328
|
+
appendTag(match[0]);
|
|
1329
|
+
index = match.index + match[0].length;
|
|
1330
|
+
}
|
|
1331
|
+
appendText(html.slice(index));
|
|
1332
|
+
if (current.length > 0) chunks.push(current);
|
|
1333
|
+
return chunks;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
702
1336
|
export function renderTelegramMessage(
|
|
703
1337
|
text: string,
|
|
704
1338
|
options?: { mode?: TelegramRenderMode },
|
|
@@ -708,7 +1342,10 @@ export function renderTelegramMessage(
|
|
|
708
1342
|
return chunkParagraphs(text).map((chunk) => ({ text: chunk }));
|
|
709
1343
|
}
|
|
710
1344
|
if (mode === "html") {
|
|
711
|
-
return
|
|
1345
|
+
return chunkHtmlPreservingTags(text).map((chunk) => ({
|
|
1346
|
+
text: chunk,
|
|
1347
|
+
parseMode: "HTML",
|
|
1348
|
+
}));
|
|
712
1349
|
}
|
|
713
1350
|
return renderMarkdownToTelegramHtmlChunks(text).map((chunk) => ({
|
|
714
1351
|
text: chunk,
|