@llblab/pi-telegram 0.2.10 → 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 +30 -19
- package/docs/architecture.md +51 -28
- package/index.ts +388 -1881
- package/lib/api.ts +396 -60
- package/lib/attachments.ts +128 -16
- package/lib/commands.ts +648 -14
- package/lib/config.ts +157 -0
- package/lib/media.ts +147 -41
- package/lib/menu.ts +920 -338
- package/lib/model.ts +647 -0
- package/lib/pi.ts +80 -0
- package/lib/polling.ts +240 -14
- package/lib/preview.ts +420 -25
- package/lib/queue.ts +1134 -110
- package/lib/registration.ts +127 -28
- package/lib/rendering.ts +560 -366
- 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 +127 -23
- package/lib/updates.ts +340 -109
- package/package.json +18 -3
- package/AGENTS.md +0 -91
- package/BACKLOG.md +0 -5
- package/CHANGELOG.md +0 -34
- package/lib/model-switch.ts +0 -62
- package/lib/types.ts +0 -137
- package/tests/api.test.ts +0 -331
- package/tests/attachments.test.ts +0 -132
- package/tests/commands.test.ts +0 -85
- package/tests/config.test.ts +0 -80
- package/tests/media.test.ts +0 -166
- package/tests/menu.test.ts +0 -676
- package/tests/polling.test.ts +0 -202
- package/tests/preview.test.ts +0 -480
- package/tests/queue.test.ts +0 -3245
- package/tests/registration.test.ts +0 -268
- package/tests/rendering.test.ts +0 -526
- package/tests/replies.test.ts +0 -142
- package/tests/turns.test.ts +0 -247
- package/tests/updates.test.ts +0 -416
package/lib/rendering.ts
CHANGED
|
@@ -5,19 +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
|
|
|
17
|
-
function escapeHtmlAttribute(text: string): string {
|
|
31
|
+
export function escapeHtmlAttribute(text: string): string {
|
|
18
32
|
return escapeHtml(text).replace(/"/g, """).replace(/'/g, "'");
|
|
19
33
|
}
|
|
20
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
|
+
|
|
21
120
|
// --- Plain Preview Rendering ---
|
|
22
121
|
|
|
23
122
|
function splitPlainMarkdownLine(line: string, maxLength = 1500): string[] {
|
|
@@ -222,7 +321,7 @@ function parseMarkdownAutolinkAt(
|
|
|
222
321
|
return { startIndex: index, endIndex, destination };
|
|
223
322
|
}
|
|
224
323
|
|
|
225
|
-
function
|
|
324
|
+
function replaceMarkdownLink(
|
|
226
325
|
text: string,
|
|
227
326
|
options: {
|
|
228
327
|
renderInlineLink: (
|
|
@@ -256,7 +355,7 @@ function replaceMarkdownLinkLike(
|
|
|
256
355
|
}
|
|
257
356
|
|
|
258
357
|
function stripInlineMarkdownToPlainText(text: string): string {
|
|
259
|
-
let result =
|
|
358
|
+
let result = replaceMarkdownLink(text, {
|
|
260
359
|
renderInlineLink: (link, supported) => {
|
|
261
360
|
const plainLabel = stripInlineMarkdownToPlainText(link.label).trim();
|
|
262
361
|
if (plainLabel.length > 0) return plainLabel;
|
|
@@ -390,7 +489,7 @@ function splitLeadingMarkdownBlankLines(markdown: string): {
|
|
|
390
489
|
|
|
391
490
|
export type TelegramPreviewRenderStrategy = "plain" | "rich-stable-blocks";
|
|
392
491
|
|
|
393
|
-
export interface
|
|
492
|
+
export interface TelegramPreviewSnapshotState {
|
|
394
493
|
pendingText: string;
|
|
395
494
|
lastSentText: string;
|
|
396
495
|
lastSentParseMode?: "HTML";
|
|
@@ -403,7 +502,7 @@ export interface TelegramPreviewSnapshot extends TelegramRenderedChunk {
|
|
|
403
502
|
}
|
|
404
503
|
|
|
405
504
|
export function buildTelegramPreviewFlushText(options: {
|
|
406
|
-
state:
|
|
505
|
+
state: TelegramPreviewSnapshotState;
|
|
407
506
|
maxMessageLength: number;
|
|
408
507
|
renderPreviewText: (markdown: string) => string;
|
|
409
508
|
}): string | undefined {
|
|
@@ -419,7 +518,7 @@ export function buildTelegramPreviewFlushText(options: {
|
|
|
419
518
|
|
|
420
519
|
function buildTelegramPlainPreviewSnapshot(options: {
|
|
421
520
|
sourceText: string;
|
|
422
|
-
state:
|
|
521
|
+
state: TelegramPreviewSnapshotState;
|
|
423
522
|
maxMessageLength: number;
|
|
424
523
|
renderPreviewText: (markdown: string) => string;
|
|
425
524
|
}): TelegramPreviewSnapshot | undefined {
|
|
@@ -447,13 +546,44 @@ interface TelegramStablePreviewSplit {
|
|
|
447
546
|
unstableTail: string;
|
|
448
547
|
}
|
|
449
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
|
+
|
|
450
582
|
function splitTelegramStablePreviewMarkdown(
|
|
451
583
|
markdown: string,
|
|
452
584
|
): TelegramStablePreviewSplit {
|
|
453
585
|
const normalized = normalizeMarkdownDocument(markdown);
|
|
454
|
-
if (normalized.length === 0) {
|
|
455
|
-
return { stableMarkdown: "", unstableTail: "" };
|
|
456
|
-
}
|
|
586
|
+
if (normalized.length === 0) return { stableMarkdown: "", unstableTail: "" };
|
|
457
587
|
const lines = normalized.split("\n");
|
|
458
588
|
let index = 0;
|
|
459
589
|
let stableEndIndex = 0;
|
|
@@ -467,104 +597,108 @@ function splitTelegramStablePreviewMarkdown(
|
|
|
467
597
|
const nextLine = lines[index + 1] ?? "";
|
|
468
598
|
const fence = parseMarkdownFence(line);
|
|
469
599
|
if (fence) {
|
|
470
|
-
index
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
!isMatchingMarkdownFence(lines[index] ?? "", fence)
|
|
474
|
-
) {
|
|
475
|
-
index += 1;
|
|
600
|
+
const block = collectFencedMarkdownCodeLines(lines, index, fence);
|
|
601
|
+
if (!block.closed) {
|
|
602
|
+
return buildTelegramStablePreviewSplit(lines, stableEndIndex);
|
|
476
603
|
}
|
|
477
|
-
|
|
478
|
-
return {
|
|
479
|
-
stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
|
|
480
|
-
unstableTail: lines.slice(stableEndIndex).join("\n"),
|
|
481
|
-
};
|
|
482
|
-
}
|
|
483
|
-
index += 1;
|
|
604
|
+
index = block.nextIndex;
|
|
484
605
|
stableEndIndex = index;
|
|
485
606
|
continue;
|
|
486
607
|
}
|
|
487
608
|
if (line.includes("|") && isMarkdownTableSeparator(nextLine)) {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
const tableLine = lines[index] ?? "";
|
|
491
|
-
if (tableLine.trim().length === 0 || !tableLine.includes("|")) {
|
|
492
|
-
break;
|
|
493
|
-
}
|
|
494
|
-
index += 1;
|
|
495
|
-
}
|
|
609
|
+
const block = collectMarkdownTableBlockLines(lines, index);
|
|
610
|
+
index = block.nextIndex;
|
|
496
611
|
if (index >= lines.length) {
|
|
497
|
-
return
|
|
498
|
-
stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
|
|
499
|
-
unstableTail: lines.slice(stableEndIndex).join("\n"),
|
|
500
|
-
};
|
|
612
|
+
return buildTelegramStablePreviewSplit(lines, stableEndIndex);
|
|
501
613
|
}
|
|
502
614
|
stableEndIndex = index;
|
|
503
615
|
continue;
|
|
504
616
|
}
|
|
505
617
|
if (canStartIndentedCodeBlock(lines, index)) {
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
if (rawLine.trim().length === 0) {
|
|
509
|
-
index += 1;
|
|
510
|
-
continue;
|
|
511
|
-
}
|
|
512
|
-
if (!isIndentedCodeLine(rawLine)) break;
|
|
513
|
-
index += 1;
|
|
514
|
-
}
|
|
618
|
+
const block = collectIndentedMarkdownCodeLines(lines, index);
|
|
619
|
+
index = block.nextIndex;
|
|
515
620
|
if (index >= lines.length) {
|
|
516
|
-
return
|
|
517
|
-
stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
|
|
518
|
-
unstableTail: lines.slice(stableEndIndex).join("\n"),
|
|
519
|
-
};
|
|
621
|
+
return buildTelegramStablePreviewSplit(lines, stableEndIndex);
|
|
520
622
|
}
|
|
521
623
|
stableEndIndex = index;
|
|
522
624
|
continue;
|
|
523
625
|
}
|
|
524
626
|
if (/^\s*>/.test(line)) {
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
}
|
|
627
|
+
const block = collectMarkdownQuoteBlockLines(lines, index);
|
|
628
|
+
index = block.nextIndex;
|
|
528
629
|
if (index >= lines.length) {
|
|
529
|
-
return
|
|
530
|
-
stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
|
|
531
|
-
unstableTail: lines.slice(stableEndIndex).join("\n"),
|
|
532
|
-
};
|
|
630
|
+
return buildTelegramStablePreviewSplit(lines, stableEndIndex);
|
|
533
631
|
}
|
|
534
632
|
stableEndIndex = index;
|
|
535
633
|
continue;
|
|
536
634
|
}
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
}
|
|
635
|
+
const block = collectTelegramStablePreviewTextBlockLines(lines, blockStart);
|
|
636
|
+
index = block.nextIndex;
|
|
552
637
|
if (index >= lines.length) {
|
|
553
|
-
return
|
|
554
|
-
stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
|
|
555
|
-
unstableTail: lines.slice(stableEndIndex).join("\n"),
|
|
556
|
-
};
|
|
638
|
+
return buildTelegramStablePreviewSplit(lines, stableEndIndex);
|
|
557
639
|
}
|
|
558
640
|
stableEndIndex = index;
|
|
559
641
|
}
|
|
560
|
-
return
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
+
);
|
|
564
698
|
}
|
|
565
699
|
|
|
566
700
|
export function buildTelegramPreviewSnapshot(options: {
|
|
567
|
-
state:
|
|
701
|
+
state: TelegramPreviewSnapshotState;
|
|
568
702
|
maxMessageLength: number;
|
|
569
703
|
renderPreviewText: (markdown: string) => string;
|
|
570
704
|
renderTelegramMessage: (
|
|
@@ -583,14 +717,12 @@ export function buildTelegramPreviewSnapshot(options: {
|
|
|
583
717
|
renderPreviewText: options.renderPreviewText,
|
|
584
718
|
});
|
|
585
719
|
}
|
|
586
|
-
const stableChunk =
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
stableChunk.text.length > options.maxMessageLength
|
|
593
|
-
) {
|
|
720
|
+
const stableChunk = renderTelegramStablePreviewChunk({
|
|
721
|
+
stableMarkdown: split.stableMarkdown,
|
|
722
|
+
maxMessageLength: options.maxMessageLength,
|
|
723
|
+
renderTelegramMessage: options.renderTelegramMessage,
|
|
724
|
+
});
|
|
725
|
+
if (!stableChunk) {
|
|
594
726
|
return buildTelegramPlainPreviewSnapshot({
|
|
595
727
|
sourceText,
|
|
596
728
|
state: options.state,
|
|
@@ -598,32 +730,19 @@ export function buildTelegramPreviewSnapshot(options: {
|
|
|
598
730
|
renderPreviewText: options.renderPreviewText,
|
|
599
731
|
});
|
|
600
732
|
}
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
}
|
|
733
|
+
const previewText = appendTelegramUnstablePreviewTail({
|
|
734
|
+
previewText: stableChunk.text,
|
|
735
|
+
stableMarkdown: split.stableMarkdown,
|
|
736
|
+
unstableTail: split.unstableTail,
|
|
737
|
+
maxMessageLength: options.maxMessageLength,
|
|
738
|
+
});
|
|
623
739
|
if (
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
740
|
+
isTelegramPreviewSnapshotUnchanged({
|
|
741
|
+
text: previewText,
|
|
742
|
+
parseMode: stableChunk.parseMode,
|
|
743
|
+
state: options.state,
|
|
744
|
+
strategy: "rich-stable-blocks",
|
|
745
|
+
})
|
|
627
746
|
) {
|
|
628
747
|
return undefined;
|
|
629
748
|
}
|
|
@@ -734,36 +853,55 @@ function renderDelimitedInlineStyle(
|
|
|
734
853
|
);
|
|
735
854
|
}
|
|
736
855
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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, {
|
|
745
874
|
renderInlineLink: (link, supported) => {
|
|
746
875
|
const plainLabel = stripInlineMarkdownToPlainText(link.label).trim();
|
|
747
|
-
if (!supported)
|
|
876
|
+
if (!supported)
|
|
748
877
|
return plainLabel.length > 0 ? plainLabel : link.destination;
|
|
749
|
-
}
|
|
750
878
|
const renderedLabel =
|
|
751
879
|
plainLabel.length > 0 ? plainLabel : link.destination;
|
|
752
|
-
return
|
|
880
|
+
return makeInlineMarkdownToken(
|
|
881
|
+
state,
|
|
753
882
|
`<a href="${escapeHtmlAttribute(link.destination)}">${escapeHtml(renderedLabel)}</a>`,
|
|
754
883
|
);
|
|
755
884
|
},
|
|
756
885
|
renderAutolink: (link) => {
|
|
757
|
-
return
|
|
886
|
+
return makeInlineMarkdownToken(
|
|
887
|
+
state,
|
|
758
888
|
`<a href="${escapeHtmlAttribute(link.destination)}">${escapeHtml(link.destination)}</a>`,
|
|
759
889
|
);
|
|
760
890
|
},
|
|
761
891
|
});
|
|
762
|
-
|
|
763
|
-
|
|
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>`);
|
|
764
900
|
});
|
|
765
|
-
|
|
766
|
-
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function applyInlineMarkdownStyles(text: string): string {
|
|
904
|
+
let result = renderDelimitedInlineStyle(text, "***", (content) => {
|
|
767
905
|
return `<b><i>${content}</i></b>`;
|
|
768
906
|
});
|
|
769
907
|
result = renderDelimitedInlineStyle(result, "___", (content) => {
|
|
@@ -781,16 +919,31 @@ function renderInlineMarkdown(text: string): string {
|
|
|
781
919
|
result = renderDelimitedInlineStyle(result, "*", (content) => {
|
|
782
920
|
return `<i>${content}</i>`;
|
|
783
921
|
});
|
|
784
|
-
|
|
922
|
+
return renderDelimitedInlineStyle(result, "_", (content) => {
|
|
785
923
|
return `<i>${content}</i>`;
|
|
786
924
|
});
|
|
787
|
-
|
|
788
|
-
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function restoreInlineMarkdownTokens(
|
|
928
|
+
text: string,
|
|
929
|
+
state: InlineMarkdownTokenState,
|
|
930
|
+
): string {
|
|
931
|
+
return text.replace(
|
|
789
932
|
/\uE000(\d+)\uE001/g,
|
|
790
|
-
(_match, index: string) => tokens[Number(index)] ?? "",
|
|
933
|
+
(_match, index: string) => state.tokens[Number(index)] ?? "",
|
|
791
934
|
);
|
|
792
935
|
}
|
|
793
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
|
+
|
|
794
947
|
function buildListIndent(level: number): string {
|
|
795
948
|
return "\u00A0".repeat(Math.max(0, level) * 2);
|
|
796
949
|
}
|
|
@@ -815,67 +968,48 @@ function parseMarkdownQuoteLine(
|
|
|
815
968
|
};
|
|
816
969
|
}
|
|
817
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
|
+
|
|
818
1006
|
function renderMarkdownTextLines(block: string): string[] {
|
|
819
1007
|
const rendered: string[] = [];
|
|
820
1008
|
const lines = block.split("\n");
|
|
821
1009
|
for (const line of lines) {
|
|
822
1010
|
if (line.trim().length === 0) continue;
|
|
823
|
-
const
|
|
824
|
-
|
|
825
|
-
const heading = matchMarkdownHeadingLine(piece);
|
|
826
|
-
if (heading) {
|
|
827
|
-
rendered.push(
|
|
828
|
-
`${buildListIndent(Math.floor((heading[1] ?? "").length / 2))}<b>${renderInlineMarkdown(heading[2] ?? "")}</b>`,
|
|
829
|
-
);
|
|
830
|
-
continue;
|
|
831
|
-
}
|
|
832
|
-
const task = piece.match(/^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.+)$/);
|
|
833
|
-
if (task) {
|
|
834
|
-
const indent = buildListIndent(Math.floor((task[1] ?? "").length / 2));
|
|
835
|
-
const listMarker = task[2] ?? "-";
|
|
836
|
-
const checkboxMarker =
|
|
837
|
-
(task[3] ?? " ").toLowerCase() === "x" ? "[x]" : "[ ]";
|
|
838
|
-
const taskPrefix = isMarkdownNumberedListMarker(listMarker)
|
|
839
|
-
? `<code>${listMarker}</code> <code>${checkboxMarker}</code>`
|
|
840
|
-
: `<code>${checkboxMarker}</code>`;
|
|
841
|
-
rendered.push(
|
|
842
|
-
`${indent}${taskPrefix} ${renderInlineMarkdown(task[4] ?? "")}`,
|
|
843
|
-
);
|
|
844
|
-
continue;
|
|
845
|
-
}
|
|
846
|
-
const bullet = piece.match(/^(\s*)[-*+]\s+(.+)$/);
|
|
847
|
-
if (bullet) {
|
|
848
|
-
const indent = buildListIndent(
|
|
849
|
-
Math.floor((bullet[1] ?? "").length / 2),
|
|
850
|
-
);
|
|
851
|
-
rendered.push(
|
|
852
|
-
`${indent}<code>-</code> ${renderInlineMarkdown(bullet[2] ?? "")}`,
|
|
853
|
-
);
|
|
854
|
-
continue;
|
|
855
|
-
}
|
|
856
|
-
const numbered = piece.match(/^(\s*)(\d+)\.\s+(.+)$/);
|
|
857
|
-
if (numbered) {
|
|
858
|
-
const indent = buildListIndent(
|
|
859
|
-
Math.floor((numbered[1] ?? "").length / 2),
|
|
860
|
-
);
|
|
861
|
-
rendered.push(
|
|
862
|
-
`${indent}<code>${numbered[2]}.</code> ${renderInlineMarkdown(numbered[3] ?? "")}`,
|
|
863
|
-
);
|
|
864
|
-
continue;
|
|
865
|
-
}
|
|
866
|
-
const quote = piece.match(/^>\s?(.+)$/);
|
|
867
|
-
if (quote) {
|
|
868
|
-
rendered.push(
|
|
869
|
-
`<blockquote>${renderInlineMarkdown(quote[1] ?? "")}</blockquote>`,
|
|
870
|
-
);
|
|
871
|
-
continue;
|
|
872
|
-
}
|
|
873
|
-
const trimmed = piece.trim();
|
|
874
|
-
if (/^([-*_]\s*){3,}$/.test(trimmed)) {
|
|
875
|
-
rendered.push("────────────");
|
|
876
|
-
continue;
|
|
877
|
-
}
|
|
878
|
-
rendered.push(renderInlineMarkdown(piece));
|
|
1011
|
+
for (const piece of splitPlainMarkdownLine(line)) {
|
|
1012
|
+
rendered.push(renderMarkdownTextPiece(piece));
|
|
879
1013
|
}
|
|
880
1014
|
}
|
|
881
1015
|
return rendered;
|
|
@@ -925,6 +1059,64 @@ function renderMarkdownCodeBlock(code: string, language?: string): string[] {
|
|
|
925
1059
|
return chunks.length > 0 ? chunks : [`${open}${close}`];
|
|
926
1060
|
}
|
|
927
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
|
+
|
|
928
1120
|
function renderMarkdownTableBlock(lines: string[]): string[] {
|
|
929
1121
|
const rows = lines.map(parseMarkdownTableRow);
|
|
930
1122
|
const columnCount = Math.max(...rows.map((row) => row.length), 0);
|
|
@@ -938,12 +1130,16 @@ function renderMarkdownTableBlock(lines: string[]): string[] {
|
|
|
938
1130
|
const widths = Array.from({ length: columnCount }, (_, columnIndex) => {
|
|
939
1131
|
return Math.max(
|
|
940
1132
|
3,
|
|
941
|
-
...normalizedRows.map((row) =>
|
|
1133
|
+
...normalizedRows.map((row) =>
|
|
1134
|
+
getTelegramTableCellWidth(row[columnIndex] ?? ""),
|
|
1135
|
+
),
|
|
942
1136
|
);
|
|
943
1137
|
});
|
|
944
1138
|
const formatRow = (row: string[]): string => {
|
|
945
1139
|
return row
|
|
946
|
-
.map((cell, columnIndex) =>
|
|
1140
|
+
.map((cell, columnIndex) =>
|
|
1141
|
+
padTelegramTableCellEnd(cell ?? "", widths[columnIndex] ?? 3),
|
|
1142
|
+
)
|
|
947
1143
|
.join(" | ");
|
|
948
1144
|
};
|
|
949
1145
|
const separator = widths.map((width) => "-".repeat(width)).join(" | ");
|
|
@@ -1014,9 +1210,100 @@ interface TelegramRenderedBlockWithSpacing {
|
|
|
1014
1210
|
blankLinesBefore: number;
|
|
1015
1211
|
}
|
|
1016
1212
|
|
|
1017
|
-
function
|
|
1018
|
-
|
|
1019
|
-
|
|
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[] {
|
|
1020
1307
|
const renderedBlocks: TelegramRenderedBlockWithSpacing[] = [];
|
|
1021
1308
|
let minimumBlankLinesBeforeNextBlock = 0;
|
|
1022
1309
|
const pushRenderedBlocks = (
|
|
@@ -1035,7 +1322,7 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
|
1035
1322
|
}
|
|
1036
1323
|
minimumBlankLinesBeforeNextBlock = 0;
|
|
1037
1324
|
};
|
|
1038
|
-
const lines =
|
|
1325
|
+
const lines = normalizedMarkdown.split("\n");
|
|
1039
1326
|
let index = 0;
|
|
1040
1327
|
let pendingBlankLines = 0;
|
|
1041
1328
|
while (index < lines.length) {
|
|
@@ -1061,125 +1348,120 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
|
1061
1348
|
}
|
|
1062
1349
|
const fence = parseMarkdownFence(line);
|
|
1063
1350
|
if (fence) {
|
|
1064
|
-
index
|
|
1065
|
-
|
|
1066
|
-
while (
|
|
1067
|
-
index < lines.length &&
|
|
1068
|
-
!isMatchingMarkdownFence(lines[index] ?? "", fence)
|
|
1069
|
-
) {
|
|
1070
|
-
codeLines.push(lines[index] ?? "");
|
|
1071
|
-
index += 1;
|
|
1072
|
-
}
|
|
1073
|
-
if (index < lines.length) {
|
|
1074
|
-
index += 1;
|
|
1075
|
-
}
|
|
1351
|
+
const block = collectFencedMarkdownCodeLines(lines, index, fence);
|
|
1352
|
+
index = block.nextIndex;
|
|
1076
1353
|
pushRenderedBlocks(
|
|
1077
|
-
renderMarkdownCodeBlock(codeLines.join("\n"), fence.info),
|
|
1354
|
+
renderMarkdownCodeBlock(block.codeLines.join("\n"), fence.info),
|
|
1078
1355
|
pendingBlankLines,
|
|
1079
1356
|
);
|
|
1080
1357
|
pendingBlankLines = 0;
|
|
1081
1358
|
continue;
|
|
1082
1359
|
}
|
|
1083
1360
|
if (line.includes("|") && isMarkdownTableSeparator(nextLine)) {
|
|
1084
|
-
const
|
|
1085
|
-
index
|
|
1086
|
-
while (index < lines.length) {
|
|
1087
|
-
const tableLine = lines[index] ?? "";
|
|
1088
|
-
if (tableLine.trim().length === 0 || !tableLine.includes("|")) {
|
|
1089
|
-
break;
|
|
1090
|
-
}
|
|
1091
|
-
tableLines.push(tableLine);
|
|
1092
|
-
index += 1;
|
|
1093
|
-
}
|
|
1361
|
+
const block = collectMarkdownTableBlockLines(lines, index);
|
|
1362
|
+
index = block.nextIndex;
|
|
1094
1363
|
pushRenderedBlocks(
|
|
1095
|
-
renderMarkdownTableBlock(tableLines),
|
|
1364
|
+
renderMarkdownTableBlock(block.tableLines),
|
|
1096
1365
|
pendingBlankLines,
|
|
1097
1366
|
);
|
|
1098
1367
|
pendingBlankLines = 0;
|
|
1099
1368
|
continue;
|
|
1100
1369
|
}
|
|
1101
1370
|
if (canStartIndentedCodeBlock(lines, index)) {
|
|
1102
|
-
const
|
|
1103
|
-
|
|
1104
|
-
const rawLine = lines[index] ?? "";
|
|
1105
|
-
if (rawLine.trim().length === 0) {
|
|
1106
|
-
codeLines.push("");
|
|
1107
|
-
index += 1;
|
|
1108
|
-
continue;
|
|
1109
|
-
}
|
|
1110
|
-
if (!isIndentedCodeLine(rawLine)) break;
|
|
1111
|
-
codeLines.push(stripIndentedCodePrefix(rawLine));
|
|
1112
|
-
index += 1;
|
|
1113
|
-
}
|
|
1371
|
+
const block = collectIndentedMarkdownCodeLines(lines, index);
|
|
1372
|
+
index = block.nextIndex;
|
|
1114
1373
|
pushRenderedBlocks(
|
|
1115
|
-
renderMarkdownCodeBlock(codeLines.join("\n")),
|
|
1374
|
+
renderMarkdownCodeBlock(block.codeLines.join("\n")),
|
|
1116
1375
|
pendingBlankLines,
|
|
1117
1376
|
);
|
|
1118
1377
|
pendingBlankLines = 0;
|
|
1119
1378
|
continue;
|
|
1120
1379
|
}
|
|
1121
1380
|
if (/^\s*>/.test(line)) {
|
|
1122
|
-
const
|
|
1123
|
-
|
|
1124
|
-
quoteLines.push(lines[index] ?? "");
|
|
1125
|
-
index += 1;
|
|
1126
|
-
}
|
|
1381
|
+
const block = collectMarkdownQuoteBlockLines(lines, index);
|
|
1382
|
+
index = block.nextIndex;
|
|
1127
1383
|
pushRenderedBlocks(
|
|
1128
|
-
renderMarkdownQuoteBlock(quoteLines),
|
|
1384
|
+
renderMarkdownQuoteBlock(block.quoteLines),
|
|
1129
1385
|
pendingBlankLines,
|
|
1130
1386
|
);
|
|
1131
1387
|
pendingBlankLines = 0;
|
|
1132
1388
|
continue;
|
|
1133
1389
|
}
|
|
1134
|
-
const
|
|
1135
|
-
|
|
1136
|
-
const current = lines[index] ?? "";
|
|
1137
|
-
const following = lines[index + 1] ?? "";
|
|
1138
|
-
if (current.trim().length === 0) break;
|
|
1139
|
-
if (
|
|
1140
|
-
isFencedCodeStart(current) ||
|
|
1141
|
-
canStartIndentedCodeBlock(lines, index) ||
|
|
1142
|
-
/^\s*>/.test(current)
|
|
1143
|
-
)
|
|
1144
|
-
break;
|
|
1145
|
-
if (current.includes("|") && isMarkdownTableSeparator(following)) break;
|
|
1146
|
-
textLines.push(current);
|
|
1147
|
-
index += 1;
|
|
1148
|
-
}
|
|
1390
|
+
const block = collectMarkdownTextBlockLines(lines, index);
|
|
1391
|
+
index = block.nextIndex;
|
|
1149
1392
|
pushRenderedBlocks(
|
|
1150
|
-
renderMarkdownTextBlock(textLines.join("\n")),
|
|
1393
|
+
renderMarkdownTextBlock(block.textLines.join("\n")),
|
|
1151
1394
|
pendingBlankLines,
|
|
1152
1395
|
);
|
|
1153
1396
|
pendingBlankLines = 0;
|
|
1154
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[] {
|
|
1155
1415
|
const chunks: string[] = [];
|
|
1156
|
-
let
|
|
1157
|
-
|
|
1158
|
-
const separator = "\n".repeat(block.blankLinesBefore + 1);
|
|
1159
|
-
const candidate =
|
|
1160
|
-
current.length === 0 ? block.text : `${current}${separator}${block.text}`;
|
|
1161
|
-
if (candidate.length <= MAX_MESSAGE_LENGTH) {
|
|
1162
|
-
current = candidate;
|
|
1163
|
-
continue;
|
|
1164
|
-
}
|
|
1165
|
-
if (current.length > 0) {
|
|
1166
|
-
chunks.push(current);
|
|
1167
|
-
current = "";
|
|
1168
|
-
}
|
|
1169
|
-
if (block.text.length <= MAX_MESSAGE_LENGTH) {
|
|
1170
|
-
current = block.text;
|
|
1171
|
-
continue;
|
|
1172
|
-
}
|
|
1173
|
-
for (let i = 0; i < block.text.length; i += MAX_MESSAGE_LENGTH) {
|
|
1174
|
-
chunks.push(block.text.slice(i, i + MAX_MESSAGE_LENGTH));
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1177
|
-
if (current.length > 0) {
|
|
1178
|
-
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));
|
|
1179
1418
|
}
|
|
1180
1419
|
return chunks;
|
|
1181
1420
|
}
|
|
1182
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
|
+
|
|
1183
1465
|
// --- Unified Telegram Rendering ---
|
|
1184
1466
|
|
|
1185
1467
|
export type TelegramRenderMode = "plain" | "markdown" | "html";
|
|
@@ -1245,94 +1527,6 @@ function chunkParagraphs(text: string): string[] {
|
|
|
1245
1527
|
return chunks;
|
|
1246
1528
|
}
|
|
1247
1529
|
|
|
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
|
-
|
|
1336
1530
|
export function renderTelegramMessage(
|
|
1337
1531
|
text: string,
|
|
1338
1532
|
options?: { mode?: TelegramRenderMode },
|
|
@@ -1342,7 +1536,7 @@ export function renderTelegramMessage(
|
|
|
1342
1536
|
return chunkParagraphs(text).map((chunk) => ({ text: chunk }));
|
|
1343
1537
|
}
|
|
1344
1538
|
if (mode === "html") {
|
|
1345
|
-
return chunkHtmlPreservingTags(text).map((chunk) => ({
|
|
1539
|
+
return chunkHtmlPreservingTags(text, MAX_MESSAGE_LENGTH).map((chunk) => ({
|
|
1346
1540
|
text: chunk,
|
|
1347
1541
|
parseMode: "HTML",
|
|
1348
1542
|
}));
|