@llblab/pi-telegram 0.2.9 → 0.3.0
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/README.md +40 -26
- package/docs/architecture.md +62 -35
- package/index.ts +388 -1936
- package/lib/api.ts +647 -76
- package/lib/attachments.ts +128 -16
- package/lib/commands.ts +721 -0
- package/lib/config.ts +157 -0
- package/lib/media.ts +211 -36
- package/lib/menu.ts +920 -338
- package/lib/model.ts +647 -0
- package/lib/pi.ts +80 -0
- package/lib/polling.ts +264 -18
- package/lib/preview.ts +451 -29
- package/lib/queue.ts +1134 -110
- package/lib/registration.ts +127 -28
- package/lib/rendering.ts +575 -281
- package/lib/replies.ts +198 -8
- package/lib/runtime.ts +475 -0
- package/lib/setup.ts +129 -1
- package/lib/status.ts +428 -13
- package/lib/turns.ts +207 -17
- package/lib/updates.ts +392 -99
- package/package.json +18 -3
- package/AGENTS.md +0 -91
- package/BACKLOG.md +0 -5
- package/CHANGELOG.md +0 -23
- package/lib/model-switch.ts +0 -62
- package/tests/api.test.ts +0 -89
- package/tests/attachments.test.ts +0 -132
- package/tests/config.test.ts +0 -80
- package/tests/media.test.ts +0 -77
- package/tests/menu.test.ts +0 -676
- package/tests/polling.test.ts +0 -129
- package/tests/preview.test.ts +0 -441
- package/tests/queue.test.ts +0 -3245
- package/tests/registration.test.ts +0 -268
- package/tests/rendering.test.ts +0 -475
- package/tests/replies.test.ts +0 -142
- package/tests/turns.test.ts +0 -132
- package/tests/updates.test.ts +0 -357
package/lib/rendering.ts
CHANGED
|
@@ -5,15 +5,118 @@
|
|
|
5
5
|
|
|
6
6
|
export const MAX_MESSAGE_LENGTH = 4096;
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
const TELEGRAM_TABLE_GRAPHEME_SEGMENTER =
|
|
9
|
+
typeof Intl.Segmenter === "function"
|
|
10
|
+
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
11
|
+
: undefined;
|
|
12
|
+
const TELEGRAM_TABLE_EMOJI_GRAPHEME_PATTERN =
|
|
13
|
+
/\p{Extended_Pictographic}|\p{Emoji_Presentation}|\p{Regional_Indicator}/u;
|
|
9
14
|
|
|
10
|
-
|
|
15
|
+
// --- HTML Helpers ---
|
|
16
|
+
|
|
17
|
+
interface OpenHtmlTag {
|
|
18
|
+
name: string;
|
|
19
|
+
openTag: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const TELEGRAM_VOID_HTML_TAGS = new Set(["br", "hr"]);
|
|
23
|
+
|
|
24
|
+
export function escapeHtml(text: string): string {
|
|
11
25
|
return text
|
|
12
26
|
.replace(/&/g, "&")
|
|
13
27
|
.replace(/</g, "<")
|
|
14
28
|
.replace(/>/g, ">");
|
|
15
29
|
}
|
|
16
30
|
|
|
31
|
+
export function escapeHtmlAttribute(text: string): string {
|
|
32
|
+
return escapeHtml(text).replace(/"/g, """).replace(/'/g, "'");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getHtmlTagName(tag: string): string | undefined {
|
|
36
|
+
return tag.match(/^<\/?\s*([a-zA-Z][\w-]*)/)?.[1]?.toLowerCase();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isHtmlClosingTag(tag: string): boolean {
|
|
40
|
+
return /^<\//.test(tag);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isHtmlSelfClosingTag(tag: string): boolean {
|
|
44
|
+
return /\/\s*>$/.test(tag);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getHtmlClosingTags(openTags: OpenHtmlTag[]): string {
|
|
48
|
+
return [...openTags]
|
|
49
|
+
.reverse()
|
|
50
|
+
.map((tag) => `</${tag.name}>`)
|
|
51
|
+
.join("");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getHtmlOpeningTags(openTags: OpenHtmlTag[]): string {
|
|
55
|
+
return openTags.map((tag) => tag.openTag).join("");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function updateOpenHtmlTags(tag: string, openTags: OpenHtmlTag[]): void {
|
|
59
|
+
const name = getHtmlTagName(tag);
|
|
60
|
+
if (!name || TELEGRAM_VOID_HTML_TAGS.has(name)) return;
|
|
61
|
+
if (isHtmlClosingTag(tag)) {
|
|
62
|
+
const index = openTags.map((openTag) => openTag.name).lastIndexOf(name);
|
|
63
|
+
if (index !== -1) openTags.splice(index, 1);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (isHtmlSelfClosingTag(tag)) return;
|
|
67
|
+
openTags.push({ name, openTag: tag });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function chunkHtmlPreservingTags(
|
|
71
|
+
html: string,
|
|
72
|
+
maxLength: number,
|
|
73
|
+
): string[] {
|
|
74
|
+
if (html.length <= maxLength) return [html];
|
|
75
|
+
const chunks: string[] = [];
|
|
76
|
+
const openTags: OpenHtmlTag[] = [];
|
|
77
|
+
const tagPattern = /<\/?[a-zA-Z][^>]*>/g;
|
|
78
|
+
let current = "";
|
|
79
|
+
let index = 0;
|
|
80
|
+
const flushCurrent = (): void => {
|
|
81
|
+
if (current.length === 0) return;
|
|
82
|
+
chunks.push(`${current}${getHtmlClosingTags(openTags)}`);
|
|
83
|
+
current = getHtmlOpeningTags(openTags);
|
|
84
|
+
};
|
|
85
|
+
const appendText = (text: string): void => {
|
|
86
|
+
let remaining = text;
|
|
87
|
+
while (remaining.length > 0) {
|
|
88
|
+
const closingTags = getHtmlClosingTags(openTags);
|
|
89
|
+
const available = maxLength - current.length - closingTags.length;
|
|
90
|
+
if (available <= 0) {
|
|
91
|
+
flushCurrent();
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const slice = remaining.slice(0, available);
|
|
95
|
+
current += slice;
|
|
96
|
+
remaining = remaining.slice(slice.length);
|
|
97
|
+
if (remaining.length > 0) flushCurrent();
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
const appendTag = (tag: string): void => {
|
|
101
|
+
const closingTags = isHtmlClosingTag(tag)
|
|
102
|
+
? ""
|
|
103
|
+
: getHtmlClosingTags(openTags);
|
|
104
|
+
if (current.length + tag.length + closingTags.length > maxLength) {
|
|
105
|
+
flushCurrent();
|
|
106
|
+
}
|
|
107
|
+
current += tag;
|
|
108
|
+
updateOpenHtmlTags(tag, openTags);
|
|
109
|
+
};
|
|
110
|
+
for (const match of html.matchAll(tagPattern)) {
|
|
111
|
+
appendText(html.slice(index, match.index));
|
|
112
|
+
appendTag(match[0]);
|
|
113
|
+
index = match.index + match[0].length;
|
|
114
|
+
}
|
|
115
|
+
appendText(html.slice(index));
|
|
116
|
+
if (current.length > 0) chunks.push(current);
|
|
117
|
+
return chunks;
|
|
118
|
+
}
|
|
119
|
+
|
|
17
120
|
// --- Plain Preview Rendering ---
|
|
18
121
|
|
|
19
122
|
function splitPlainMarkdownLine(line: string, maxLength = 1500): string[] {
|
|
@@ -218,7 +321,7 @@ function parseMarkdownAutolinkAt(
|
|
|
218
321
|
return { startIndex: index, endIndex, destination };
|
|
219
322
|
}
|
|
220
323
|
|
|
221
|
-
function
|
|
324
|
+
function replaceMarkdownLink(
|
|
222
325
|
text: string,
|
|
223
326
|
options: {
|
|
224
327
|
renderInlineLink: (
|
|
@@ -252,7 +355,7 @@ function replaceMarkdownLinkLike(
|
|
|
252
355
|
}
|
|
253
356
|
|
|
254
357
|
function stripInlineMarkdownToPlainText(text: string): string {
|
|
255
|
-
let result =
|
|
358
|
+
let result = replaceMarkdownLink(text, {
|
|
256
359
|
renderInlineLink: (link, supported) => {
|
|
257
360
|
const plainLabel = stripInlineMarkdownToPlainText(link.label).trim();
|
|
258
361
|
if (plainLabel.length > 0) return plainLabel;
|
|
@@ -386,7 +489,7 @@ function splitLeadingMarkdownBlankLines(markdown: string): {
|
|
|
386
489
|
|
|
387
490
|
export type TelegramPreviewRenderStrategy = "plain" | "rich-stable-blocks";
|
|
388
491
|
|
|
389
|
-
export interface
|
|
492
|
+
export interface TelegramPreviewSnapshotState {
|
|
390
493
|
pendingText: string;
|
|
391
494
|
lastSentText: string;
|
|
392
495
|
lastSentParseMode?: "HTML";
|
|
@@ -399,7 +502,7 @@ export interface TelegramPreviewSnapshot extends TelegramRenderedChunk {
|
|
|
399
502
|
}
|
|
400
503
|
|
|
401
504
|
export function buildTelegramPreviewFlushText(options: {
|
|
402
|
-
state:
|
|
505
|
+
state: TelegramPreviewSnapshotState;
|
|
403
506
|
maxMessageLength: number;
|
|
404
507
|
renderPreviewText: (markdown: string) => string;
|
|
405
508
|
}): string | undefined {
|
|
@@ -415,7 +518,7 @@ export function buildTelegramPreviewFlushText(options: {
|
|
|
415
518
|
|
|
416
519
|
function buildTelegramPlainPreviewSnapshot(options: {
|
|
417
520
|
sourceText: string;
|
|
418
|
-
state:
|
|
521
|
+
state: TelegramPreviewSnapshotState;
|
|
419
522
|
maxMessageLength: number;
|
|
420
523
|
renderPreviewText: (markdown: string) => string;
|
|
421
524
|
}): TelegramPreviewSnapshot | undefined {
|
|
@@ -443,13 +546,44 @@ interface TelegramStablePreviewSplit {
|
|
|
443
546
|
unstableTail: string;
|
|
444
547
|
}
|
|
445
548
|
|
|
549
|
+
function buildTelegramStablePreviewSplit(
|
|
550
|
+
lines: string[],
|
|
551
|
+
stableEndIndex: number,
|
|
552
|
+
): TelegramStablePreviewSplit {
|
|
553
|
+
return {
|
|
554
|
+
stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
|
|
555
|
+
unstableTail: lines.slice(stableEndIndex).join("\n"),
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function collectTelegramStablePreviewTextBlockLines(
|
|
560
|
+
lines: string[],
|
|
561
|
+
index: number,
|
|
562
|
+
): { nextIndex: number } {
|
|
563
|
+
let nextIndex = index;
|
|
564
|
+
while (nextIndex < lines.length) {
|
|
565
|
+
const current = lines[nextIndex] ?? "";
|
|
566
|
+
const following = lines[nextIndex + 1] ?? "";
|
|
567
|
+
if (current.trim().length === 0) break;
|
|
568
|
+
if (
|
|
569
|
+
nextIndex !== index &&
|
|
570
|
+
(isFencedCodeStart(current) ||
|
|
571
|
+
canStartIndentedCodeBlock(lines, nextIndex) ||
|
|
572
|
+
/^\s*>/.test(current) ||
|
|
573
|
+
(current.includes("|") && isMarkdownTableSeparator(following)))
|
|
574
|
+
) {
|
|
575
|
+
break;
|
|
576
|
+
}
|
|
577
|
+
nextIndex += 1;
|
|
578
|
+
}
|
|
579
|
+
return { nextIndex };
|
|
580
|
+
}
|
|
581
|
+
|
|
446
582
|
function splitTelegramStablePreviewMarkdown(
|
|
447
583
|
markdown: string,
|
|
448
584
|
): TelegramStablePreviewSplit {
|
|
449
585
|
const normalized = normalizeMarkdownDocument(markdown);
|
|
450
|
-
if (normalized.length === 0) {
|
|
451
|
-
return { stableMarkdown: "", unstableTail: "" };
|
|
452
|
-
}
|
|
586
|
+
if (normalized.length === 0) return { stableMarkdown: "", unstableTail: "" };
|
|
453
587
|
const lines = normalized.split("\n");
|
|
454
588
|
let index = 0;
|
|
455
589
|
let stableEndIndex = 0;
|
|
@@ -463,104 +597,108 @@ function splitTelegramStablePreviewMarkdown(
|
|
|
463
597
|
const nextLine = lines[index + 1] ?? "";
|
|
464
598
|
const fence = parseMarkdownFence(line);
|
|
465
599
|
if (fence) {
|
|
466
|
-
index
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
!isMatchingMarkdownFence(lines[index] ?? "", fence)
|
|
470
|
-
) {
|
|
471
|
-
index += 1;
|
|
472
|
-
}
|
|
473
|
-
if (index >= lines.length) {
|
|
474
|
-
return {
|
|
475
|
-
stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
|
|
476
|
-
unstableTail: lines.slice(stableEndIndex).join("\n"),
|
|
477
|
-
};
|
|
600
|
+
const block = collectFencedMarkdownCodeLines(lines, index, fence);
|
|
601
|
+
if (!block.closed) {
|
|
602
|
+
return buildTelegramStablePreviewSplit(lines, stableEndIndex);
|
|
478
603
|
}
|
|
479
|
-
index
|
|
604
|
+
index = block.nextIndex;
|
|
480
605
|
stableEndIndex = index;
|
|
481
606
|
continue;
|
|
482
607
|
}
|
|
483
608
|
if (line.includes("|") && isMarkdownTableSeparator(nextLine)) {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
const tableLine = lines[index] ?? "";
|
|
487
|
-
if (tableLine.trim().length === 0 || !tableLine.includes("|")) {
|
|
488
|
-
break;
|
|
489
|
-
}
|
|
490
|
-
index += 1;
|
|
491
|
-
}
|
|
609
|
+
const block = collectMarkdownTableBlockLines(lines, index);
|
|
610
|
+
index = block.nextIndex;
|
|
492
611
|
if (index >= lines.length) {
|
|
493
|
-
return
|
|
494
|
-
stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
|
|
495
|
-
unstableTail: lines.slice(stableEndIndex).join("\n"),
|
|
496
|
-
};
|
|
612
|
+
return buildTelegramStablePreviewSplit(lines, stableEndIndex);
|
|
497
613
|
}
|
|
498
614
|
stableEndIndex = index;
|
|
499
615
|
continue;
|
|
500
616
|
}
|
|
501
617
|
if (canStartIndentedCodeBlock(lines, index)) {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
if (rawLine.trim().length === 0) {
|
|
505
|
-
index += 1;
|
|
506
|
-
continue;
|
|
507
|
-
}
|
|
508
|
-
if (!isIndentedCodeLine(rawLine)) break;
|
|
509
|
-
index += 1;
|
|
510
|
-
}
|
|
618
|
+
const block = collectIndentedMarkdownCodeLines(lines, index);
|
|
619
|
+
index = block.nextIndex;
|
|
511
620
|
if (index >= lines.length) {
|
|
512
|
-
return
|
|
513
|
-
stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
|
|
514
|
-
unstableTail: lines.slice(stableEndIndex).join("\n"),
|
|
515
|
-
};
|
|
621
|
+
return buildTelegramStablePreviewSplit(lines, stableEndIndex);
|
|
516
622
|
}
|
|
517
623
|
stableEndIndex = index;
|
|
518
624
|
continue;
|
|
519
625
|
}
|
|
520
626
|
if (/^\s*>/.test(line)) {
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
}
|
|
627
|
+
const block = collectMarkdownQuoteBlockLines(lines, index);
|
|
628
|
+
index = block.nextIndex;
|
|
524
629
|
if (index >= lines.length) {
|
|
525
|
-
return
|
|
526
|
-
stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
|
|
527
|
-
unstableTail: lines.slice(stableEndIndex).join("\n"),
|
|
528
|
-
};
|
|
630
|
+
return buildTelegramStablePreviewSplit(lines, stableEndIndex);
|
|
529
631
|
}
|
|
530
632
|
stableEndIndex = index;
|
|
531
633
|
continue;
|
|
532
634
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
const following = lines[index + 1] ?? "";
|
|
536
|
-
if (current.trim().length === 0) break;
|
|
537
|
-
if (
|
|
538
|
-
index !== blockStart &&
|
|
539
|
-
(isFencedCodeStart(current) ||
|
|
540
|
-
canStartIndentedCodeBlock(lines, index) ||
|
|
541
|
-
/^\s*>/.test(current) ||
|
|
542
|
-
(current.includes("|") && isMarkdownTableSeparator(following)))
|
|
543
|
-
) {
|
|
544
|
-
break;
|
|
545
|
-
}
|
|
546
|
-
index += 1;
|
|
547
|
-
}
|
|
635
|
+
const block = collectTelegramStablePreviewTextBlockLines(lines, blockStart);
|
|
636
|
+
index = block.nextIndex;
|
|
548
637
|
if (index >= lines.length) {
|
|
549
|
-
return
|
|
550
|
-
stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
|
|
551
|
-
unstableTail: lines.slice(stableEndIndex).join("\n"),
|
|
552
|
-
};
|
|
638
|
+
return buildTelegramStablePreviewSplit(lines, stableEndIndex);
|
|
553
639
|
}
|
|
554
640
|
stableEndIndex = index;
|
|
555
641
|
}
|
|
556
|
-
return
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
642
|
+
return buildTelegramStablePreviewSplit(lines, stableEndIndex);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function renderTelegramStablePreviewChunk(options: {
|
|
646
|
+
stableMarkdown: string;
|
|
647
|
+
maxMessageLength: number;
|
|
648
|
+
renderTelegramMessage: (
|
|
649
|
+
text: string,
|
|
650
|
+
options?: { mode?: TelegramRenderMode },
|
|
651
|
+
) => TelegramRenderedChunk[];
|
|
652
|
+
}): TelegramRenderedChunk | undefined {
|
|
653
|
+
const stableChunk = options.renderTelegramMessage(options.stableMarkdown, {
|
|
654
|
+
mode: "markdown",
|
|
655
|
+
})[0];
|
|
656
|
+
if (!stableChunk || stableChunk.text.length === 0) return undefined;
|
|
657
|
+
if (stableChunk.text.length > options.maxMessageLength) return undefined;
|
|
658
|
+
return stableChunk;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function appendTelegramUnstablePreviewTail(options: {
|
|
662
|
+
previewText: string;
|
|
663
|
+
stableMarkdown: string;
|
|
664
|
+
unstableTail: string;
|
|
665
|
+
maxMessageLength: number;
|
|
666
|
+
}): string {
|
|
667
|
+
if (options.unstableTail.length === 0) return options.previewText;
|
|
668
|
+
const tail = splitLeadingMarkdownBlankLines(options.unstableTail);
|
|
669
|
+
const minimumBlankLinesBeforeTail = endsWithMarkdownHeadingLine(
|
|
670
|
+
options.stableMarkdown,
|
|
671
|
+
)
|
|
672
|
+
? 1
|
|
673
|
+
: 0;
|
|
674
|
+
const blankLinesBeforeTail = Math.max(
|
|
675
|
+
tail.blankLines,
|
|
676
|
+
minimumBlankLinesBeforeTail,
|
|
677
|
+
);
|
|
678
|
+
const separator =
|
|
679
|
+
tail.remainingText.length > 0 ? "\n".repeat(blankLinesBeforeTail + 1) : "";
|
|
680
|
+
const tailText = escapeHtml(tail.remainingText);
|
|
681
|
+
const candidate = `${options.previewText}${separator}${tailText}`;
|
|
682
|
+
return candidate.length <= options.maxMessageLength
|
|
683
|
+
? candidate
|
|
684
|
+
: options.previewText;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function isTelegramPreviewSnapshotUnchanged(options: {
|
|
688
|
+
text: string;
|
|
689
|
+
parseMode?: "HTML";
|
|
690
|
+
state: TelegramPreviewSnapshotState;
|
|
691
|
+
strategy: TelegramPreviewRenderStrategy;
|
|
692
|
+
}): boolean {
|
|
693
|
+
return (
|
|
694
|
+
options.text === options.state.lastSentText &&
|
|
695
|
+
options.parseMode === options.state.lastSentParseMode &&
|
|
696
|
+
options.strategy === options.state.lastSentStrategy
|
|
697
|
+
);
|
|
560
698
|
}
|
|
561
699
|
|
|
562
700
|
export function buildTelegramPreviewSnapshot(options: {
|
|
563
|
-
state:
|
|
701
|
+
state: TelegramPreviewSnapshotState;
|
|
564
702
|
maxMessageLength: number;
|
|
565
703
|
renderPreviewText: (markdown: string) => string;
|
|
566
704
|
renderTelegramMessage: (
|
|
@@ -579,14 +717,12 @@ export function buildTelegramPreviewSnapshot(options: {
|
|
|
579
717
|
renderPreviewText: options.renderPreviewText,
|
|
580
718
|
});
|
|
581
719
|
}
|
|
582
|
-
const stableChunk =
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
stableChunk.text.length > options.maxMessageLength
|
|
589
|
-
) {
|
|
720
|
+
const stableChunk = renderTelegramStablePreviewChunk({
|
|
721
|
+
stableMarkdown: split.stableMarkdown,
|
|
722
|
+
maxMessageLength: options.maxMessageLength,
|
|
723
|
+
renderTelegramMessage: options.renderTelegramMessage,
|
|
724
|
+
});
|
|
725
|
+
if (!stableChunk) {
|
|
590
726
|
return buildTelegramPlainPreviewSnapshot({
|
|
591
727
|
sourceText,
|
|
592
728
|
state: options.state,
|
|
@@ -594,32 +730,19 @@ export function buildTelegramPreviewSnapshot(options: {
|
|
|
594
730
|
renderPreviewText: options.renderPreviewText,
|
|
595
731
|
});
|
|
596
732
|
}
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
? 1
|
|
604
|
-
: 0;
|
|
605
|
-
const blankLinesBeforeTail = Math.max(
|
|
606
|
-
tail.blankLines,
|
|
607
|
-
minimumBlankLinesBeforeTail,
|
|
608
|
-
);
|
|
609
|
-
const separator =
|
|
610
|
-
tail.remainingText.length > 0
|
|
611
|
-
? "\n".repeat(blankLinesBeforeTail + 1)
|
|
612
|
-
: "";
|
|
613
|
-
const tailText = escapeHtml(tail.remainingText);
|
|
614
|
-
const candidate = `${previewText}${separator}${tailText}`;
|
|
615
|
-
if (candidate.length <= options.maxMessageLength) {
|
|
616
|
-
previewText = candidate;
|
|
617
|
-
}
|
|
618
|
-
}
|
|
733
|
+
const previewText = appendTelegramUnstablePreviewTail({
|
|
734
|
+
previewText: stableChunk.text,
|
|
735
|
+
stableMarkdown: split.stableMarkdown,
|
|
736
|
+
unstableTail: split.unstableTail,
|
|
737
|
+
maxMessageLength: options.maxMessageLength,
|
|
738
|
+
});
|
|
619
739
|
if (
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
740
|
+
isTelegramPreviewSnapshotUnchanged({
|
|
741
|
+
text: previewText,
|
|
742
|
+
parseMode: stableChunk.parseMode,
|
|
743
|
+
state: options.state,
|
|
744
|
+
strategy: "rich-stable-blocks",
|
|
745
|
+
})
|
|
623
746
|
) {
|
|
624
747
|
return undefined;
|
|
625
748
|
}
|
|
@@ -730,36 +853,55 @@ function renderDelimitedInlineStyle(
|
|
|
730
853
|
);
|
|
731
854
|
}
|
|
732
855
|
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
856
|
+
interface InlineMarkdownTokenState {
|
|
857
|
+
tokens: string[];
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function makeInlineMarkdownToken(
|
|
861
|
+
state: InlineMarkdownTokenState,
|
|
862
|
+
html: string,
|
|
863
|
+
): string {
|
|
864
|
+
const token = `\uE000${state.tokens.length}\uE001`;
|
|
865
|
+
state.tokens.push(html);
|
|
866
|
+
return token;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function stashInlineMarkdownLinks(
|
|
870
|
+
text: string,
|
|
871
|
+
state: InlineMarkdownTokenState,
|
|
872
|
+
): string {
|
|
873
|
+
return replaceMarkdownLink(text, {
|
|
741
874
|
renderInlineLink: (link, supported) => {
|
|
742
875
|
const plainLabel = stripInlineMarkdownToPlainText(link.label).trim();
|
|
743
|
-
if (!supported)
|
|
876
|
+
if (!supported)
|
|
744
877
|
return plainLabel.length > 0 ? plainLabel : link.destination;
|
|
745
|
-
}
|
|
746
878
|
const renderedLabel =
|
|
747
879
|
plainLabel.length > 0 ? plainLabel : link.destination;
|
|
748
|
-
return
|
|
749
|
-
|
|
880
|
+
return makeInlineMarkdownToken(
|
|
881
|
+
state,
|
|
882
|
+
`<a href="${escapeHtmlAttribute(link.destination)}">${escapeHtml(renderedLabel)}</a>`,
|
|
750
883
|
);
|
|
751
884
|
},
|
|
752
885
|
renderAutolink: (link) => {
|
|
753
|
-
return
|
|
754
|
-
|
|
886
|
+
return makeInlineMarkdownToken(
|
|
887
|
+
state,
|
|
888
|
+
`<a href="${escapeHtmlAttribute(link.destination)}">${escapeHtml(link.destination)}</a>`,
|
|
755
889
|
);
|
|
756
890
|
},
|
|
757
891
|
});
|
|
758
|
-
|
|
759
|
-
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function stashInlineMarkdownCodeSpans(
|
|
895
|
+
text: string,
|
|
896
|
+
state: InlineMarkdownTokenState,
|
|
897
|
+
): string {
|
|
898
|
+
return text.replace(/`([^`\n]+)`/g, (_match, code: string) => {
|
|
899
|
+
return makeInlineMarkdownToken(state, `<code>${escapeHtml(code)}</code>`);
|
|
760
900
|
});
|
|
761
|
-
|
|
762
|
-
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function applyInlineMarkdownStyles(text: string): string {
|
|
904
|
+
let result = renderDelimitedInlineStyle(text, "***", (content) => {
|
|
763
905
|
return `<b><i>${content}</i></b>`;
|
|
764
906
|
});
|
|
765
907
|
result = renderDelimitedInlineStyle(result, "___", (content) => {
|
|
@@ -777,16 +919,31 @@ function renderInlineMarkdown(text: string): string {
|
|
|
777
919
|
result = renderDelimitedInlineStyle(result, "*", (content) => {
|
|
778
920
|
return `<i>${content}</i>`;
|
|
779
921
|
});
|
|
780
|
-
|
|
922
|
+
return renderDelimitedInlineStyle(result, "_", (content) => {
|
|
781
923
|
return `<i>${content}</i>`;
|
|
782
924
|
});
|
|
783
|
-
|
|
784
|
-
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function restoreInlineMarkdownTokens(
|
|
928
|
+
text: string,
|
|
929
|
+
state: InlineMarkdownTokenState,
|
|
930
|
+
): string {
|
|
931
|
+
return text.replace(
|
|
785
932
|
/\uE000(\d+)\uE001/g,
|
|
786
|
-
(_match, index: string) => tokens[Number(index)] ?? "",
|
|
933
|
+
(_match, index: string) => state.tokens[Number(index)] ?? "",
|
|
787
934
|
);
|
|
788
935
|
}
|
|
789
936
|
|
|
937
|
+
function renderInlineMarkdown(text: string): string {
|
|
938
|
+
const tokenState: InlineMarkdownTokenState = { tokens: [] };
|
|
939
|
+
let result = stashInlineMarkdownLinks(text, tokenState);
|
|
940
|
+
result = stashInlineMarkdownCodeSpans(result, tokenState);
|
|
941
|
+
result = escapeHtml(result);
|
|
942
|
+
result = applyInlineMarkdownStyles(result);
|
|
943
|
+
result = result.replace(/\\([\\`*_{}\[\]()#+\-.!>~|])/g, "$1");
|
|
944
|
+
return restoreInlineMarkdownTokens(result, tokenState);
|
|
945
|
+
}
|
|
946
|
+
|
|
790
947
|
function buildListIndent(level: number): string {
|
|
791
948
|
return "\u00A0".repeat(Math.max(0, level) * 2);
|
|
792
949
|
}
|
|
@@ -811,75 +968,61 @@ function parseMarkdownQuoteLine(
|
|
|
811
968
|
};
|
|
812
969
|
}
|
|
813
970
|
|
|
971
|
+
function renderMarkdownTextPiece(piece: string): string {
|
|
972
|
+
const heading = matchMarkdownHeadingLine(piece);
|
|
973
|
+
if (heading) {
|
|
974
|
+
const indent = buildListIndent(Math.floor((heading[1] ?? "").length / 2));
|
|
975
|
+
return `${indent}<b>${renderInlineMarkdown(heading[2] ?? "")}</b>`;
|
|
976
|
+
}
|
|
977
|
+
const task = piece.match(/^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.+)$/);
|
|
978
|
+
if (task) {
|
|
979
|
+
const indent = buildListIndent(Math.floor((task[1] ?? "").length / 2));
|
|
980
|
+
const listMarker = task[2] ?? "-";
|
|
981
|
+
const checkboxMarker =
|
|
982
|
+
(task[3] ?? " ").toLowerCase() === "x" ? "[x]" : "[ ]";
|
|
983
|
+
const taskPrefix = isMarkdownNumberedListMarker(listMarker)
|
|
984
|
+
? `<code>${listMarker}</code> <code>${checkboxMarker}</code>`
|
|
985
|
+
: `<code>${checkboxMarker}</code>`;
|
|
986
|
+
return `${indent}${taskPrefix} ${renderInlineMarkdown(task[4] ?? "")}`;
|
|
987
|
+
}
|
|
988
|
+
const bullet = piece.match(/^(\s*)[-*+]\s+(.+)$/);
|
|
989
|
+
if (bullet) {
|
|
990
|
+
const indent = buildListIndent(Math.floor((bullet[1] ?? "").length / 2));
|
|
991
|
+
return `${indent}<code>-</code> ${renderInlineMarkdown(bullet[2] ?? "")}`;
|
|
992
|
+
}
|
|
993
|
+
const numbered = piece.match(/^(\s*)(\d+)\.\s+(.+)$/);
|
|
994
|
+
if (numbered) {
|
|
995
|
+
const indent = buildListIndent(Math.floor((numbered[1] ?? "").length / 2));
|
|
996
|
+
return `${indent}<code>${numbered[2]}.</code> ${renderInlineMarkdown(numbered[3] ?? "")}`;
|
|
997
|
+
}
|
|
998
|
+
const quote = piece.match(/^>\s?(.+)$/);
|
|
999
|
+
if (quote) {
|
|
1000
|
+
return `<blockquote>${renderInlineMarkdown(quote[1] ?? "")}</blockquote>`;
|
|
1001
|
+
}
|
|
1002
|
+
if (/^([-*_]\s*){3,}$/.test(piece.trim())) return "────────────";
|
|
1003
|
+
return renderInlineMarkdown(piece);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
814
1006
|
function renderMarkdownTextLines(block: string): string[] {
|
|
815
1007
|
const rendered: string[] = [];
|
|
816
1008
|
const lines = block.split("\n");
|
|
817
1009
|
for (const line of lines) {
|
|
818
1010
|
if (line.trim().length === 0) continue;
|
|
819
|
-
const
|
|
820
|
-
|
|
821
|
-
const heading = matchMarkdownHeadingLine(piece);
|
|
822
|
-
if (heading) {
|
|
823
|
-
rendered.push(
|
|
824
|
-
`${buildListIndent(Math.floor((heading[1] ?? "").length / 2))}<b>${renderInlineMarkdown(heading[2] ?? "")}</b>`,
|
|
825
|
-
);
|
|
826
|
-
continue;
|
|
827
|
-
}
|
|
828
|
-
const task = piece.match(/^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.+)$/);
|
|
829
|
-
if (task) {
|
|
830
|
-
const indent = buildListIndent(Math.floor((task[1] ?? "").length / 2));
|
|
831
|
-
const listMarker = task[2] ?? "-";
|
|
832
|
-
const checkboxMarker =
|
|
833
|
-
(task[3] ?? " ").toLowerCase() === "x" ? "[x]" : "[ ]";
|
|
834
|
-
const taskPrefix = isMarkdownNumberedListMarker(listMarker)
|
|
835
|
-
? `<code>${listMarker}</code> <code>${checkboxMarker}</code>`
|
|
836
|
-
: `<code>${checkboxMarker}</code>`;
|
|
837
|
-
rendered.push(
|
|
838
|
-
`${indent}${taskPrefix} ${renderInlineMarkdown(task[4] ?? "")}`,
|
|
839
|
-
);
|
|
840
|
-
continue;
|
|
841
|
-
}
|
|
842
|
-
const bullet = piece.match(/^(\s*)[-*+]\s+(.+)$/);
|
|
843
|
-
if (bullet) {
|
|
844
|
-
const indent = buildListIndent(
|
|
845
|
-
Math.floor((bullet[1] ?? "").length / 2),
|
|
846
|
-
);
|
|
847
|
-
rendered.push(
|
|
848
|
-
`${indent}<code>-</code> ${renderInlineMarkdown(bullet[2] ?? "")}`,
|
|
849
|
-
);
|
|
850
|
-
continue;
|
|
851
|
-
}
|
|
852
|
-
const numbered = piece.match(/^(\s*)(\d+)\.\s+(.+)$/);
|
|
853
|
-
if (numbered) {
|
|
854
|
-
const indent = buildListIndent(
|
|
855
|
-
Math.floor((numbered[1] ?? "").length / 2),
|
|
856
|
-
);
|
|
857
|
-
rendered.push(
|
|
858
|
-
`${indent}<code>${numbered[2]}.</code> ${renderInlineMarkdown(numbered[3] ?? "")}`,
|
|
859
|
-
);
|
|
860
|
-
continue;
|
|
861
|
-
}
|
|
862
|
-
const quote = piece.match(/^>\s?(.+)$/);
|
|
863
|
-
if (quote) {
|
|
864
|
-
rendered.push(
|
|
865
|
-
`<blockquote>${renderInlineMarkdown(quote[1] ?? "")}</blockquote>`,
|
|
866
|
-
);
|
|
867
|
-
continue;
|
|
868
|
-
}
|
|
869
|
-
const trimmed = piece.trim();
|
|
870
|
-
if (/^([-*_]\s*){3,}$/.test(trimmed)) {
|
|
871
|
-
rendered.push("────────────");
|
|
872
|
-
continue;
|
|
873
|
-
}
|
|
874
|
-
rendered.push(renderInlineMarkdown(piece));
|
|
1011
|
+
for (const piece of splitPlainMarkdownLine(line)) {
|
|
1012
|
+
rendered.push(renderMarkdownTextPiece(piece));
|
|
875
1013
|
}
|
|
876
1014
|
}
|
|
877
1015
|
return rendered;
|
|
878
1016
|
}
|
|
879
1017
|
|
|
1018
|
+
function sanitizeTelegramCodeLanguage(language: string): string {
|
|
1019
|
+
return language.split(/\s+/)[0]?.replace(/[^A-Za-z0-9_+.-]/g, "") ?? "";
|
|
1020
|
+
}
|
|
1021
|
+
|
|
880
1022
|
function renderMarkdownCodeBlock(code: string, language?: string): string[] {
|
|
881
|
-
const
|
|
882
|
-
|
|
1023
|
+
const safeLanguage = language ? sanitizeTelegramCodeLanguage(language) : "";
|
|
1024
|
+
const open = safeLanguage
|
|
1025
|
+
? `<pre><code class="language-${escapeHtmlAttribute(safeLanguage)}">`
|
|
883
1026
|
: "<pre><code>";
|
|
884
1027
|
const close = "</code></pre>";
|
|
885
1028
|
const maxContentLength = MAX_MESSAGE_LENGTH - open.length - close.length;
|
|
@@ -916,6 +1059,64 @@ function renderMarkdownCodeBlock(code: string, language?: string): string[] {
|
|
|
916
1059
|
return chunks.length > 0 ? chunks : [`${open}${close}`];
|
|
917
1060
|
}
|
|
918
1061
|
|
|
1062
|
+
function isTelegramTableEmojiGrapheme(grapheme: string): boolean {
|
|
1063
|
+
return (
|
|
1064
|
+
TELEGRAM_TABLE_EMOJI_GRAPHEME_PATTERN.test(grapheme) ||
|
|
1065
|
+
grapheme.includes("\u20e3")
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function getTelegramTableCodePointWidth(char: string): number {
|
|
1070
|
+
const codePoint = char.codePointAt(0) ?? 0;
|
|
1071
|
+
if (codePoint === 0 || codePoint < 32) return 0;
|
|
1072
|
+
if (/\p{Mark}/u.test(char)) return 0;
|
|
1073
|
+
if ((codePoint >= 0xfe00 && codePoint <= 0xfe0f) || codePoint === 0x200d)
|
|
1074
|
+
return 0;
|
|
1075
|
+
if (
|
|
1076
|
+
(codePoint >= 0x1100 && codePoint <= 0x115f) ||
|
|
1077
|
+
codePoint === 0x2329 ||
|
|
1078
|
+
codePoint === 0x232a ||
|
|
1079
|
+
(codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
|
|
1080
|
+
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
|
1081
|
+
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
|
1082
|
+
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
|
|
1083
|
+
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
|
|
1084
|
+
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
|
|
1085
|
+
(codePoint >= 0xffe0 && codePoint <= 0xffe6)
|
|
1086
|
+
) {
|
|
1087
|
+
return 2;
|
|
1088
|
+
}
|
|
1089
|
+
return 1;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function getTelegramTableGraphemes(text: string): string[] {
|
|
1093
|
+
if (TELEGRAM_TABLE_GRAPHEME_SEGMENTER) {
|
|
1094
|
+
return Array.from(
|
|
1095
|
+
TELEGRAM_TABLE_GRAPHEME_SEGMENTER.segment(text),
|
|
1096
|
+
(segment) => segment.segment,
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
return Array.from(text);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function getTelegramTableCellWidth(text: string): number {
|
|
1103
|
+
return getTelegramTableGraphemes(text).reduce((width, grapheme) => {
|
|
1104
|
+
if (isTelegramTableEmojiGrapheme(grapheme)) return width + 2;
|
|
1105
|
+
return (
|
|
1106
|
+
width +
|
|
1107
|
+
Array.from(grapheme).reduce(
|
|
1108
|
+
(sum, char) => sum + getTelegramTableCodePointWidth(char),
|
|
1109
|
+
0,
|
|
1110
|
+
)
|
|
1111
|
+
);
|
|
1112
|
+
}, 0);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function padTelegramTableCellEnd(cell: string, width: number): string {
|
|
1116
|
+
const padding = width - getTelegramTableCellWidth(cell);
|
|
1117
|
+
return padding > 0 ? `${cell}${" ".repeat(padding)}` : cell;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
919
1120
|
function renderMarkdownTableBlock(lines: string[]): string[] {
|
|
920
1121
|
const rows = lines.map(parseMarkdownTableRow);
|
|
921
1122
|
const columnCount = Math.max(...rows.map((row) => row.length), 0);
|
|
@@ -929,12 +1130,16 @@ function renderMarkdownTableBlock(lines: string[]): string[] {
|
|
|
929
1130
|
const widths = Array.from({ length: columnCount }, (_, columnIndex) => {
|
|
930
1131
|
return Math.max(
|
|
931
1132
|
3,
|
|
932
|
-
...normalizedRows.map((row) =>
|
|
1133
|
+
...normalizedRows.map((row) =>
|
|
1134
|
+
getTelegramTableCellWidth(row[columnIndex] ?? ""),
|
|
1135
|
+
),
|
|
933
1136
|
);
|
|
934
1137
|
});
|
|
935
1138
|
const formatRow = (row: string[]): string => {
|
|
936
1139
|
return row
|
|
937
|
-
.map((cell, columnIndex) =>
|
|
1140
|
+
.map((cell, columnIndex) =>
|
|
1141
|
+
padTelegramTableCellEnd(cell ?? "", widths[columnIndex] ?? 3),
|
|
1142
|
+
)
|
|
938
1143
|
.join(" | ");
|
|
939
1144
|
};
|
|
940
1145
|
const separator = widths.map((width) => "-".repeat(width)).join(" | ");
|
|
@@ -1005,9 +1210,100 @@ interface TelegramRenderedBlockWithSpacing {
|
|
|
1005
1210
|
blankLinesBefore: number;
|
|
1006
1211
|
}
|
|
1007
1212
|
|
|
1008
|
-
function
|
|
1009
|
-
|
|
1010
|
-
|
|
1213
|
+
function collectFencedMarkdownCodeLines(
|
|
1214
|
+
lines: string[],
|
|
1215
|
+
index: number,
|
|
1216
|
+
fence: { marker: "`" | "~"; length: number },
|
|
1217
|
+
): { codeLines: string[]; nextIndex: number; closed: boolean } {
|
|
1218
|
+
const codeLines: string[] = [];
|
|
1219
|
+
let nextIndex = index + 1;
|
|
1220
|
+
while (
|
|
1221
|
+
nextIndex < lines.length &&
|
|
1222
|
+
!isMatchingMarkdownFence(lines[nextIndex] ?? "", fence)
|
|
1223
|
+
) {
|
|
1224
|
+
codeLines.push(lines[nextIndex] ?? "");
|
|
1225
|
+
nextIndex += 1;
|
|
1226
|
+
}
|
|
1227
|
+
const closed = nextIndex < lines.length;
|
|
1228
|
+
if (closed) nextIndex += 1;
|
|
1229
|
+
return { codeLines, nextIndex, closed };
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function collectMarkdownTableBlockLines(
|
|
1233
|
+
lines: string[],
|
|
1234
|
+
index: number,
|
|
1235
|
+
): { tableLines: string[]; nextIndex: number } {
|
|
1236
|
+
const tableLines = [lines[index] ?? ""];
|
|
1237
|
+
let nextIndex = index + 2;
|
|
1238
|
+
while (nextIndex < lines.length) {
|
|
1239
|
+
const tableLine = lines[nextIndex] ?? "";
|
|
1240
|
+
if (tableLine.trim().length === 0 || !tableLine.includes("|")) break;
|
|
1241
|
+
tableLines.push(tableLine);
|
|
1242
|
+
nextIndex += 1;
|
|
1243
|
+
}
|
|
1244
|
+
return { tableLines, nextIndex };
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function collectIndentedMarkdownCodeLines(
|
|
1248
|
+
lines: string[],
|
|
1249
|
+
index: number,
|
|
1250
|
+
): { codeLines: string[]; nextIndex: number } {
|
|
1251
|
+
const codeLines: string[] = [];
|
|
1252
|
+
let nextIndex = index;
|
|
1253
|
+
while (nextIndex < lines.length) {
|
|
1254
|
+
const rawLine = lines[nextIndex] ?? "";
|
|
1255
|
+
if (rawLine.trim().length === 0) {
|
|
1256
|
+
codeLines.push("");
|
|
1257
|
+
nextIndex += 1;
|
|
1258
|
+
continue;
|
|
1259
|
+
}
|
|
1260
|
+
if (!isIndentedCodeLine(rawLine)) break;
|
|
1261
|
+
codeLines.push(stripIndentedCodePrefix(rawLine));
|
|
1262
|
+
nextIndex += 1;
|
|
1263
|
+
}
|
|
1264
|
+
return { codeLines, nextIndex };
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function collectMarkdownQuoteBlockLines(
|
|
1268
|
+
lines: string[],
|
|
1269
|
+
index: number,
|
|
1270
|
+
): { quoteLines: string[]; nextIndex: number } {
|
|
1271
|
+
const quoteLines: string[] = [];
|
|
1272
|
+
let nextIndex = index;
|
|
1273
|
+
while (nextIndex < lines.length && /^\s*>/.test(lines[nextIndex] ?? "")) {
|
|
1274
|
+
quoteLines.push(lines[nextIndex] ?? "");
|
|
1275
|
+
nextIndex += 1;
|
|
1276
|
+
}
|
|
1277
|
+
return { quoteLines, nextIndex };
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function isMarkdownTextBlockBoundary(lines: string[], index: number): boolean {
|
|
1281
|
+
const current = lines[index] ?? "";
|
|
1282
|
+
const following = lines[index + 1] ?? "";
|
|
1283
|
+
if (current.trim().length === 0) return true;
|
|
1284
|
+
if (isFencedCodeStart(current)) return true;
|
|
1285
|
+
if (canStartIndentedCodeBlock(lines, index)) return true;
|
|
1286
|
+
if (/^\s*>/.test(current)) return true;
|
|
1287
|
+
return current.includes("|") && isMarkdownTableSeparator(following);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
function collectMarkdownTextBlockLines(
|
|
1291
|
+
lines: string[],
|
|
1292
|
+
index: number,
|
|
1293
|
+
): { textLines: string[]; nextIndex: number } {
|
|
1294
|
+
const textLines: string[] = [];
|
|
1295
|
+
let nextIndex = index;
|
|
1296
|
+
while (nextIndex < lines.length) {
|
|
1297
|
+
if (isMarkdownTextBlockBoundary(lines, nextIndex)) break;
|
|
1298
|
+
textLines.push(lines[nextIndex] ?? "");
|
|
1299
|
+
nextIndex += 1;
|
|
1300
|
+
}
|
|
1301
|
+
return { textLines, nextIndex };
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function renderMarkdownDocumentBlocks(
|
|
1305
|
+
normalizedMarkdown: string,
|
|
1306
|
+
): TelegramRenderedBlockWithSpacing[] {
|
|
1011
1307
|
const renderedBlocks: TelegramRenderedBlockWithSpacing[] = [];
|
|
1012
1308
|
let minimumBlankLinesBeforeNextBlock = 0;
|
|
1013
1309
|
const pushRenderedBlocks = (
|
|
@@ -1026,7 +1322,7 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
|
1026
1322
|
}
|
|
1027
1323
|
minimumBlankLinesBeforeNextBlock = 0;
|
|
1028
1324
|
};
|
|
1029
|
-
const lines =
|
|
1325
|
+
const lines = normalizedMarkdown.split("\n");
|
|
1030
1326
|
let index = 0;
|
|
1031
1327
|
let pendingBlankLines = 0;
|
|
1032
1328
|
while (index < lines.length) {
|
|
@@ -1052,125 +1348,120 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
|
1052
1348
|
}
|
|
1053
1349
|
const fence = parseMarkdownFence(line);
|
|
1054
1350
|
if (fence) {
|
|
1055
|
-
index
|
|
1056
|
-
|
|
1057
|
-
while (
|
|
1058
|
-
index < lines.length &&
|
|
1059
|
-
!isMatchingMarkdownFence(lines[index] ?? "", fence)
|
|
1060
|
-
) {
|
|
1061
|
-
codeLines.push(lines[index] ?? "");
|
|
1062
|
-
index += 1;
|
|
1063
|
-
}
|
|
1064
|
-
if (index < lines.length) {
|
|
1065
|
-
index += 1;
|
|
1066
|
-
}
|
|
1351
|
+
const block = collectFencedMarkdownCodeLines(lines, index, fence);
|
|
1352
|
+
index = block.nextIndex;
|
|
1067
1353
|
pushRenderedBlocks(
|
|
1068
|
-
renderMarkdownCodeBlock(codeLines.join("\n"), fence.info),
|
|
1354
|
+
renderMarkdownCodeBlock(block.codeLines.join("\n"), fence.info),
|
|
1069
1355
|
pendingBlankLines,
|
|
1070
1356
|
);
|
|
1071
1357
|
pendingBlankLines = 0;
|
|
1072
1358
|
continue;
|
|
1073
1359
|
}
|
|
1074
1360
|
if (line.includes("|") && isMarkdownTableSeparator(nextLine)) {
|
|
1075
|
-
const
|
|
1076
|
-
index
|
|
1077
|
-
while (index < lines.length) {
|
|
1078
|
-
const tableLine = lines[index] ?? "";
|
|
1079
|
-
if (tableLine.trim().length === 0 || !tableLine.includes("|")) {
|
|
1080
|
-
break;
|
|
1081
|
-
}
|
|
1082
|
-
tableLines.push(tableLine);
|
|
1083
|
-
index += 1;
|
|
1084
|
-
}
|
|
1361
|
+
const block = collectMarkdownTableBlockLines(lines, index);
|
|
1362
|
+
index = block.nextIndex;
|
|
1085
1363
|
pushRenderedBlocks(
|
|
1086
|
-
renderMarkdownTableBlock(tableLines),
|
|
1364
|
+
renderMarkdownTableBlock(block.tableLines),
|
|
1087
1365
|
pendingBlankLines,
|
|
1088
1366
|
);
|
|
1089
1367
|
pendingBlankLines = 0;
|
|
1090
1368
|
continue;
|
|
1091
1369
|
}
|
|
1092
1370
|
if (canStartIndentedCodeBlock(lines, index)) {
|
|
1093
|
-
const
|
|
1094
|
-
|
|
1095
|
-
const rawLine = lines[index] ?? "";
|
|
1096
|
-
if (rawLine.trim().length === 0) {
|
|
1097
|
-
codeLines.push("");
|
|
1098
|
-
index += 1;
|
|
1099
|
-
continue;
|
|
1100
|
-
}
|
|
1101
|
-
if (!isIndentedCodeLine(rawLine)) break;
|
|
1102
|
-
codeLines.push(stripIndentedCodePrefix(rawLine));
|
|
1103
|
-
index += 1;
|
|
1104
|
-
}
|
|
1371
|
+
const block = collectIndentedMarkdownCodeLines(lines, index);
|
|
1372
|
+
index = block.nextIndex;
|
|
1105
1373
|
pushRenderedBlocks(
|
|
1106
|
-
renderMarkdownCodeBlock(codeLines.join("\n")),
|
|
1374
|
+
renderMarkdownCodeBlock(block.codeLines.join("\n")),
|
|
1107
1375
|
pendingBlankLines,
|
|
1108
1376
|
);
|
|
1109
1377
|
pendingBlankLines = 0;
|
|
1110
1378
|
continue;
|
|
1111
1379
|
}
|
|
1112
1380
|
if (/^\s*>/.test(line)) {
|
|
1113
|
-
const
|
|
1114
|
-
|
|
1115
|
-
quoteLines.push(lines[index] ?? "");
|
|
1116
|
-
index += 1;
|
|
1117
|
-
}
|
|
1381
|
+
const block = collectMarkdownQuoteBlockLines(lines, index);
|
|
1382
|
+
index = block.nextIndex;
|
|
1118
1383
|
pushRenderedBlocks(
|
|
1119
|
-
renderMarkdownQuoteBlock(quoteLines),
|
|
1384
|
+
renderMarkdownQuoteBlock(block.quoteLines),
|
|
1120
1385
|
pendingBlankLines,
|
|
1121
1386
|
);
|
|
1122
1387
|
pendingBlankLines = 0;
|
|
1123
1388
|
continue;
|
|
1124
1389
|
}
|
|
1125
|
-
const
|
|
1126
|
-
|
|
1127
|
-
const current = lines[index] ?? "";
|
|
1128
|
-
const following = lines[index + 1] ?? "";
|
|
1129
|
-
if (current.trim().length === 0) break;
|
|
1130
|
-
if (
|
|
1131
|
-
isFencedCodeStart(current) ||
|
|
1132
|
-
canStartIndentedCodeBlock(lines, index) ||
|
|
1133
|
-
/^\s*>/.test(current)
|
|
1134
|
-
)
|
|
1135
|
-
break;
|
|
1136
|
-
if (current.includes("|") && isMarkdownTableSeparator(following)) break;
|
|
1137
|
-
textLines.push(current);
|
|
1138
|
-
index += 1;
|
|
1139
|
-
}
|
|
1390
|
+
const block = collectMarkdownTextBlockLines(lines, index);
|
|
1391
|
+
index = block.nextIndex;
|
|
1140
1392
|
pushRenderedBlocks(
|
|
1141
|
-
renderMarkdownTextBlock(textLines.join("\n")),
|
|
1393
|
+
renderMarkdownTextBlock(block.textLines.join("\n")),
|
|
1142
1394
|
pendingBlankLines,
|
|
1143
1395
|
);
|
|
1144
1396
|
pendingBlankLines = 0;
|
|
1145
1397
|
}
|
|
1398
|
+
return renderedBlocks;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
interface TelegramMarkdownChunkAccumulator {
|
|
1402
|
+
chunks: string[];
|
|
1403
|
+
current: string;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
function flushTelegramMarkdownChunkAccumulator(
|
|
1407
|
+
accumulator: TelegramMarkdownChunkAccumulator,
|
|
1408
|
+
): void {
|
|
1409
|
+
if (accumulator.current.length === 0) return;
|
|
1410
|
+
accumulator.chunks.push(accumulator.current);
|
|
1411
|
+
accumulator.current = "";
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function splitOversizedTelegramMarkdownBlock(text: string): string[] {
|
|
1146
1415
|
const chunks: string[] = [];
|
|
1147
|
-
let
|
|
1148
|
-
|
|
1149
|
-
const separator = "\n".repeat(block.blankLinesBefore + 1);
|
|
1150
|
-
const candidate =
|
|
1151
|
-
current.length === 0 ? block.text : `${current}${separator}${block.text}`;
|
|
1152
|
-
if (candidate.length <= MAX_MESSAGE_LENGTH) {
|
|
1153
|
-
current = candidate;
|
|
1154
|
-
continue;
|
|
1155
|
-
}
|
|
1156
|
-
if (current.length > 0) {
|
|
1157
|
-
chunks.push(current);
|
|
1158
|
-
current = "";
|
|
1159
|
-
}
|
|
1160
|
-
if (block.text.length <= MAX_MESSAGE_LENGTH) {
|
|
1161
|
-
current = block.text;
|
|
1162
|
-
continue;
|
|
1163
|
-
}
|
|
1164
|
-
for (let i = 0; i < block.text.length; i += MAX_MESSAGE_LENGTH) {
|
|
1165
|
-
chunks.push(block.text.slice(i, i + MAX_MESSAGE_LENGTH));
|
|
1166
|
-
}
|
|
1167
|
-
}
|
|
1168
|
-
if (current.length > 0) {
|
|
1169
|
-
chunks.push(current);
|
|
1416
|
+
for (let i = 0; i < text.length; i += MAX_MESSAGE_LENGTH) {
|
|
1417
|
+
chunks.push(text.slice(i, i + MAX_MESSAGE_LENGTH));
|
|
1170
1418
|
}
|
|
1171
1419
|
return chunks;
|
|
1172
1420
|
}
|
|
1173
1421
|
|
|
1422
|
+
function appendTelegramRenderedMarkdownBlock(
|
|
1423
|
+
accumulator: TelegramMarkdownChunkAccumulator,
|
|
1424
|
+
block: TelegramRenderedBlockWithSpacing,
|
|
1425
|
+
): void {
|
|
1426
|
+
const separator = "\n".repeat(block.blankLinesBefore + 1);
|
|
1427
|
+
const candidate =
|
|
1428
|
+
accumulator.current.length === 0
|
|
1429
|
+
? block.text
|
|
1430
|
+
: `${accumulator.current}${separator}${block.text}`;
|
|
1431
|
+
if (candidate.length <= MAX_MESSAGE_LENGTH) {
|
|
1432
|
+
accumulator.current = candidate;
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
flushTelegramMarkdownChunkAccumulator(accumulator);
|
|
1436
|
+
if (block.text.length <= MAX_MESSAGE_LENGTH) {
|
|
1437
|
+
accumulator.current = block.text;
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
accumulator.chunks.push(...splitOversizedTelegramMarkdownBlock(block.text));
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
function chunkTelegramRenderedMarkdownBlocks(
|
|
1444
|
+
renderedBlocks: TelegramRenderedBlockWithSpacing[],
|
|
1445
|
+
): string[] {
|
|
1446
|
+
const accumulator: TelegramMarkdownChunkAccumulator = {
|
|
1447
|
+
chunks: [],
|
|
1448
|
+
current: "",
|
|
1449
|
+
};
|
|
1450
|
+
for (const block of renderedBlocks) {
|
|
1451
|
+
appendTelegramRenderedMarkdownBlock(accumulator, block);
|
|
1452
|
+
}
|
|
1453
|
+
flushTelegramMarkdownChunkAccumulator(accumulator);
|
|
1454
|
+
return accumulator.chunks;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
1458
|
+
const normalized = normalizeMarkdownDocument(markdown);
|
|
1459
|
+
if (normalized.length === 0) return [];
|
|
1460
|
+
return chunkTelegramRenderedMarkdownBlocks(
|
|
1461
|
+
renderMarkdownDocumentBlocks(normalized),
|
|
1462
|
+
);
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1174
1465
|
// --- Unified Telegram Rendering ---
|
|
1175
1466
|
|
|
1176
1467
|
export type TelegramRenderMode = "plain" | "markdown" | "html";
|
|
@@ -1245,7 +1536,10 @@ export function renderTelegramMessage(
|
|
|
1245
1536
|
return chunkParagraphs(text).map((chunk) => ({ text: chunk }));
|
|
1246
1537
|
}
|
|
1247
1538
|
if (mode === "html") {
|
|
1248
|
-
return
|
|
1539
|
+
return chunkHtmlPreservingTags(text, MAX_MESSAGE_LENGTH).map((chunk) => ({
|
|
1540
|
+
text: chunk,
|
|
1541
|
+
parseMode: "HTML",
|
|
1542
|
+
}));
|
|
1249
1543
|
}
|
|
1250
1544
|
return renderMarkdownToTelegramHtmlChunks(text).map((chunk) => ({
|
|
1251
1545
|
text: chunk,
|