@llblab/pi-telegram 0.16.6 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +15 -9
- package/BACKLOG.md +8 -5
- package/CHANGELOG.md +12 -0
- package/README.md +7 -5
- package/docs/README.md +2 -1
- package/docs/architecture.md +16 -12
- package/docs/outbound.md +2 -2
- package/docs/public-api.md +3 -0
- package/docs/sections.md +4 -2
- package/docs/telegram-bot-api-rich-messages.md +890 -0
- package/docs/ui-style.md +1 -1
- package/index.ts +11 -2
- package/lib/commands.ts +2 -2
- package/lib/media.ts +92 -1
- package/lib/menu-model.ts +3 -3
- package/lib/menu-settings.ts +2 -2
- package/lib/menu.ts +4 -4
- package/lib/preview.ts +160 -195
- package/lib/prompts.ts +4 -4
- package/lib/rendering.ts +3 -349
- package/lib/replies.ts +227 -31
- package/lib/routing.ts +2 -2
- package/lib/sections.ts +10 -5
- package/lib/telegram-api.ts +79 -9
- package/package.json +1 -1
package/lib/rendering.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Telegram
|
|
2
|
+
* Telegram UI/compat rendering helpers
|
|
3
3
|
* Zones: telegram rendering, shared text utils
|
|
4
|
-
* Converts
|
|
4
|
+
* Converts bridge-owned UI/status/menu/interactive text into Telegram-safe plain text and HTML chunks with chunk-boundary handling
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
export const MAX_MESSAGE_LENGTH = 4096;
|
|
@@ -488,353 +488,7 @@ function splitLeadingMarkdownBlankLines(markdown: string): {
|
|
|
488
488
|
};
|
|
489
489
|
}
|
|
490
490
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
export interface TelegramPreviewSnapshotState {
|
|
494
|
-
pendingText: string;
|
|
495
|
-
lastSentText: string;
|
|
496
|
-
lastSentParseMode?: "HTML";
|
|
497
|
-
lastSentStrategy?: TelegramPreviewRenderStrategy;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
export interface TelegramPreviewSnapshot extends TelegramRenderedChunk {
|
|
501
|
-
sourceText: string;
|
|
502
|
-
strategy: TelegramPreviewRenderStrategy;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
export function buildTelegramPreviewFlushText(options: {
|
|
506
|
-
state: TelegramPreviewSnapshotState;
|
|
507
|
-
maxMessageLength: number;
|
|
508
|
-
renderPreviewText: (markdown: string) => string;
|
|
509
|
-
}): string | undefined {
|
|
510
|
-
const rawText = options.state.pendingText.trim();
|
|
511
|
-
const previewText = options.renderPreviewText(rawText).trim();
|
|
512
|
-
if (!previewText || previewText === options.state.lastSentText) {
|
|
513
|
-
return undefined;
|
|
514
|
-
}
|
|
515
|
-
return previewText.length > options.maxMessageLength
|
|
516
|
-
? previewText.slice(0, options.maxMessageLength)
|
|
517
|
-
: previewText;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
function buildTelegramPlainPreviewSnapshot(options: {
|
|
521
|
-
sourceText: string;
|
|
522
|
-
state: TelegramPreviewSnapshotState;
|
|
523
|
-
maxMessageLength: number;
|
|
524
|
-
renderPreviewText: (markdown: string) => string;
|
|
525
|
-
}): TelegramPreviewSnapshot | undefined {
|
|
526
|
-
const previewText = options.renderPreviewText(options.sourceText).trim();
|
|
527
|
-
if (!previewText) return undefined;
|
|
528
|
-
const truncatedPreviewText =
|
|
529
|
-
previewText.length > options.maxMessageLength
|
|
530
|
-
? previewText.slice(0, options.maxMessageLength)
|
|
531
|
-
: previewText;
|
|
532
|
-
if (
|
|
533
|
-
truncatedPreviewText === options.state.lastSentText &&
|
|
534
|
-
options.state.lastSentStrategy === "plain"
|
|
535
|
-
) {
|
|
536
|
-
return undefined;
|
|
537
|
-
}
|
|
538
|
-
return {
|
|
539
|
-
text: truncatedPreviewText,
|
|
540
|
-
sourceText: options.sourceText,
|
|
541
|
-
strategy: "plain",
|
|
542
|
-
};
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
interface TelegramStablePreviewSplit {
|
|
546
|
-
stableMarkdown: string;
|
|
547
|
-
unstableTail: string;
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
function buildTelegramStablePreviewSplit(
|
|
551
|
-
lines: string[],
|
|
552
|
-
stableEndIndex: number,
|
|
553
|
-
): TelegramStablePreviewSplit {
|
|
554
|
-
return {
|
|
555
|
-
stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
|
|
556
|
-
unstableTail: lines.slice(stableEndIndex).join("\n"),
|
|
557
|
-
};
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
function collectTelegramStablePreviewTextBlockLines(
|
|
561
|
-
lines: string[],
|
|
562
|
-
index: number,
|
|
563
|
-
): { nextIndex: number } {
|
|
564
|
-
let nextIndex = index;
|
|
565
|
-
while (nextIndex < lines.length) {
|
|
566
|
-
const current = lines[nextIndex] ?? "";
|
|
567
|
-
const following = lines[nextIndex + 1] ?? "";
|
|
568
|
-
if (current.trim().length === 0) break;
|
|
569
|
-
if (
|
|
570
|
-
nextIndex !== index &&
|
|
571
|
-
(isFencedCodeStart(current) ||
|
|
572
|
-
canStartIndentedCodeBlock(lines, nextIndex) ||
|
|
573
|
-
/^\s*>/.test(current) ||
|
|
574
|
-
(current.includes("|") && isMarkdownTableSeparator(following)))
|
|
575
|
-
) {
|
|
576
|
-
break;
|
|
577
|
-
}
|
|
578
|
-
nextIndex += 1;
|
|
579
|
-
}
|
|
580
|
-
return { nextIndex };
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
function splitTelegramStablePreviewMarkdown(
|
|
584
|
-
markdown: string,
|
|
585
|
-
): TelegramStablePreviewSplit {
|
|
586
|
-
const normalized = normalizeMarkdownDocument(markdown);
|
|
587
|
-
if (normalized.length === 0) return { stableMarkdown: "", unstableTail: "" };
|
|
588
|
-
const lines = normalized.split("\n");
|
|
589
|
-
let index = 0;
|
|
590
|
-
let stableEndIndex = 0;
|
|
591
|
-
while (index < lines.length) {
|
|
592
|
-
while (index < lines.length && (lines[index] ?? "").trim().length === 0) {
|
|
593
|
-
index += 1;
|
|
594
|
-
}
|
|
595
|
-
if (index >= lines.length) break;
|
|
596
|
-
const blockStart = index;
|
|
597
|
-
const line = lines[index] ?? "";
|
|
598
|
-
const nextLine = lines[index + 1] ?? "";
|
|
599
|
-
const fence = parseMarkdownFence(line);
|
|
600
|
-
if (fence) {
|
|
601
|
-
const block = collectFencedMarkdownCodeLines(lines, index, fence);
|
|
602
|
-
if (!block.closed) {
|
|
603
|
-
return buildTelegramStablePreviewSplit(lines, stableEndIndex);
|
|
604
|
-
}
|
|
605
|
-
index = block.nextIndex;
|
|
606
|
-
stableEndIndex = index;
|
|
607
|
-
continue;
|
|
608
|
-
}
|
|
609
|
-
if (line.includes("|") && isMarkdownTableSeparator(nextLine)) {
|
|
610
|
-
const block = collectMarkdownTableBlockLines(lines, index);
|
|
611
|
-
index = block.nextIndex;
|
|
612
|
-
if (index >= lines.length) {
|
|
613
|
-
return buildTelegramStablePreviewSplit(lines, stableEndIndex);
|
|
614
|
-
}
|
|
615
|
-
stableEndIndex = index;
|
|
616
|
-
continue;
|
|
617
|
-
}
|
|
618
|
-
if (canStartIndentedCodeBlock(lines, index)) {
|
|
619
|
-
const block = collectIndentedMarkdownCodeLines(lines, index);
|
|
620
|
-
index = block.nextIndex;
|
|
621
|
-
if (index >= lines.length) {
|
|
622
|
-
return buildTelegramStablePreviewSplit(lines, stableEndIndex);
|
|
623
|
-
}
|
|
624
|
-
stableEndIndex = index;
|
|
625
|
-
continue;
|
|
626
|
-
}
|
|
627
|
-
if (/^\s*>/.test(line)) {
|
|
628
|
-
const block = collectMarkdownQuoteBlockLines(lines, index);
|
|
629
|
-
index = block.nextIndex;
|
|
630
|
-
if (index >= lines.length) {
|
|
631
|
-
return buildTelegramStablePreviewSplit(lines, stableEndIndex);
|
|
632
|
-
}
|
|
633
|
-
stableEndIndex = index;
|
|
634
|
-
continue;
|
|
635
|
-
}
|
|
636
|
-
const block = collectTelegramStablePreviewTextBlockLines(lines, blockStart);
|
|
637
|
-
index = block.nextIndex;
|
|
638
|
-
if (index >= lines.length) {
|
|
639
|
-
return buildTelegramStablePreviewSplit(lines, stableEndIndex);
|
|
640
|
-
}
|
|
641
|
-
stableEndIndex = index;
|
|
642
|
-
}
|
|
643
|
-
return buildTelegramStablePreviewSplit(lines, stableEndIndex);
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
function renderTelegramStablePreviewChunk(options: {
|
|
647
|
-
stableMarkdown: string;
|
|
648
|
-
maxMessageLength: number;
|
|
649
|
-
renderTelegramMessage: (
|
|
650
|
-
text: string,
|
|
651
|
-
options?: { mode?: TelegramRenderMode },
|
|
652
|
-
) => TelegramRenderedChunk[];
|
|
653
|
-
}): TelegramRenderedChunk | undefined {
|
|
654
|
-
const stableChunk = options.renderTelegramMessage(options.stableMarkdown, {
|
|
655
|
-
mode: "markdown",
|
|
656
|
-
})[0];
|
|
657
|
-
if (!stableChunk || stableChunk.text.length === 0) return undefined;
|
|
658
|
-
if (stableChunk.text.length > options.maxMessageLength) return undefined;
|
|
659
|
-
return stableChunk;
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
function appendTelegramUnstablePreviewTail(options: {
|
|
663
|
-
previewText: string;
|
|
664
|
-
stableMarkdown: string;
|
|
665
|
-
unstableTail: string;
|
|
666
|
-
maxMessageLength: number;
|
|
667
|
-
}): string {
|
|
668
|
-
if (options.unstableTail.length === 0) return options.previewText;
|
|
669
|
-
const tail = splitLeadingMarkdownBlankLines(options.unstableTail);
|
|
670
|
-
const minimumBlankLinesBeforeTail = endsWithMarkdownHeadingLine(
|
|
671
|
-
options.stableMarkdown,
|
|
672
|
-
)
|
|
673
|
-
? 1
|
|
674
|
-
: 0;
|
|
675
|
-
const blankLinesBeforeTail = Math.max(
|
|
676
|
-
tail.blankLines,
|
|
677
|
-
minimumBlankLinesBeforeTail,
|
|
678
|
-
);
|
|
679
|
-
const separator =
|
|
680
|
-
tail.remainingText.length > 0 ? "\n".repeat(blankLinesBeforeTail + 1) : "";
|
|
681
|
-
const tailText = escapeHtml(tail.remainingText);
|
|
682
|
-
const candidate = `${options.previewText}${separator}${tailText}`;
|
|
683
|
-
return candidate.length <= options.maxMessageLength
|
|
684
|
-
? candidate
|
|
685
|
-
: options.previewText;
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
function isTelegramPreviewSnapshotUnchanged(options: {
|
|
689
|
-
text: string;
|
|
690
|
-
parseMode?: "HTML";
|
|
691
|
-
state: TelegramPreviewSnapshotState;
|
|
692
|
-
strategy: TelegramPreviewRenderStrategy;
|
|
693
|
-
}): boolean {
|
|
694
|
-
return (
|
|
695
|
-
options.text === options.state.lastSentText &&
|
|
696
|
-
options.parseMode === options.state.lastSentParseMode &&
|
|
697
|
-
options.strategy === options.state.lastSentStrategy
|
|
698
|
-
);
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
export function buildTelegramPreviewSnapshot(options: {
|
|
702
|
-
state: TelegramPreviewSnapshotState;
|
|
703
|
-
maxMessageLength: number;
|
|
704
|
-
renderPreviewText: (markdown: string) => string;
|
|
705
|
-
renderTelegramMessage: (
|
|
706
|
-
text: string,
|
|
707
|
-
options?: { mode?: TelegramRenderMode },
|
|
708
|
-
) => TelegramRenderedChunk[];
|
|
709
|
-
}): TelegramPreviewSnapshot | undefined {
|
|
710
|
-
const sourceText = options.state.pendingText.trim();
|
|
711
|
-
if (!sourceText) return undefined;
|
|
712
|
-
const split = splitTelegramStablePreviewMarkdown(sourceText);
|
|
713
|
-
if (split.stableMarkdown.length === 0) {
|
|
714
|
-
return buildTelegramPlainPreviewSnapshot({
|
|
715
|
-
sourceText,
|
|
716
|
-
state: options.state,
|
|
717
|
-
maxMessageLength: options.maxMessageLength,
|
|
718
|
-
renderPreviewText: options.renderPreviewText,
|
|
719
|
-
});
|
|
720
|
-
}
|
|
721
|
-
const stableChunk = renderTelegramStablePreviewChunk({
|
|
722
|
-
stableMarkdown: split.stableMarkdown,
|
|
723
|
-
maxMessageLength: options.maxMessageLength,
|
|
724
|
-
renderTelegramMessage: options.renderTelegramMessage,
|
|
725
|
-
});
|
|
726
|
-
if (!stableChunk) {
|
|
727
|
-
return buildTelegramPlainPreviewSnapshot({
|
|
728
|
-
sourceText,
|
|
729
|
-
state: options.state,
|
|
730
|
-
maxMessageLength: options.maxMessageLength,
|
|
731
|
-
renderPreviewText: options.renderPreviewText,
|
|
732
|
-
});
|
|
733
|
-
}
|
|
734
|
-
const previewText = appendTelegramUnstablePreviewTail({
|
|
735
|
-
previewText: stableChunk.text,
|
|
736
|
-
stableMarkdown: split.stableMarkdown,
|
|
737
|
-
unstableTail: split.unstableTail,
|
|
738
|
-
maxMessageLength: options.maxMessageLength,
|
|
739
|
-
});
|
|
740
|
-
if (
|
|
741
|
-
isTelegramPreviewSnapshotUnchanged({
|
|
742
|
-
text: previewText,
|
|
743
|
-
parseMode: stableChunk.parseMode,
|
|
744
|
-
state: options.state,
|
|
745
|
-
strategy: "rich-stable-blocks",
|
|
746
|
-
})
|
|
747
|
-
) {
|
|
748
|
-
return undefined;
|
|
749
|
-
}
|
|
750
|
-
return {
|
|
751
|
-
text: previewText,
|
|
752
|
-
parseMode: stableChunk.parseMode,
|
|
753
|
-
sourceText,
|
|
754
|
-
strategy: "rich-stable-blocks",
|
|
755
|
-
};
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
export function renderMarkdownPreviewText(markdown: string): string {
|
|
759
|
-
const normalized = normalizeMarkdownDocument(markdown);
|
|
760
|
-
if (normalized.length === 0) return "";
|
|
761
|
-
const output: string[] = [];
|
|
762
|
-
const lines = normalized.split("\n");
|
|
763
|
-
let activeFence: { marker: "`" | "~"; length: number } | undefined;
|
|
764
|
-
for (const rawLine of lines) {
|
|
765
|
-
const line = rawLine ?? "";
|
|
766
|
-
const fence = parseMarkdownFence(line);
|
|
767
|
-
if (activeFence) {
|
|
768
|
-
if (fence && isMatchingMarkdownFence(line, activeFence)) {
|
|
769
|
-
activeFence = undefined;
|
|
770
|
-
continue;
|
|
771
|
-
}
|
|
772
|
-
if (line.trim().length === 0) {
|
|
773
|
-
output.push("");
|
|
774
|
-
continue;
|
|
775
|
-
}
|
|
776
|
-
output.push(line);
|
|
777
|
-
continue;
|
|
778
|
-
}
|
|
779
|
-
if (fence) {
|
|
780
|
-
activeFence = { marker: fence.marker, length: fence.length };
|
|
781
|
-
continue;
|
|
782
|
-
}
|
|
783
|
-
if (line.trim().length === 0) {
|
|
784
|
-
output.push("");
|
|
785
|
-
continue;
|
|
786
|
-
}
|
|
787
|
-
if (isMarkdownTableSeparator(line)) {
|
|
788
|
-
continue;
|
|
789
|
-
}
|
|
790
|
-
const heading = matchMarkdownHeadingLine(line);
|
|
791
|
-
if (heading) {
|
|
792
|
-
output.push(stripInlineMarkdownToPlainText(heading[2] ?? ""));
|
|
793
|
-
continue;
|
|
794
|
-
}
|
|
795
|
-
const task = line.match(/^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.+)$/);
|
|
796
|
-
if (task) {
|
|
797
|
-
const indent = " ".repeat((task[1] ?? "").length);
|
|
798
|
-
const listMarker = task[2] ?? "-";
|
|
799
|
-
const checkboxMarker =
|
|
800
|
-
(task[3] ?? " ").toLowerCase() === "x" ? "[x]" : "[ ]";
|
|
801
|
-
const taskPrefix = isMarkdownNumberedListMarker(listMarker)
|
|
802
|
-
? `${listMarker} ${checkboxMarker}`
|
|
803
|
-
: checkboxMarker;
|
|
804
|
-
output.push(
|
|
805
|
-
`${indent}${taskPrefix} ${stripInlineMarkdownToPlainText(task[4] ?? "")}`,
|
|
806
|
-
);
|
|
807
|
-
continue;
|
|
808
|
-
}
|
|
809
|
-
const bullet = line.match(/^(\s*)[-*+]\s+(.+)$/);
|
|
810
|
-
if (bullet) {
|
|
811
|
-
output.push(
|
|
812
|
-
`${" ".repeat((bullet[1] ?? "").length)}- ${stripInlineMarkdownToPlainText(bullet[2] ?? "")}`,
|
|
813
|
-
);
|
|
814
|
-
continue;
|
|
815
|
-
}
|
|
816
|
-
const numbered = line.match(/^(\s*\d+\.)\s+(.+)$/);
|
|
817
|
-
if (numbered) {
|
|
818
|
-
output.push(
|
|
819
|
-
`${numbered[1]} ${stripInlineMarkdownToPlainText(numbered[2] ?? "")}`,
|
|
820
|
-
);
|
|
821
|
-
continue;
|
|
822
|
-
}
|
|
823
|
-
const quote = line.match(/^\s*>\s?(.+)$/);
|
|
824
|
-
if (quote) {
|
|
825
|
-
output.push(`> ${stripInlineMarkdownToPlainText(quote[1] ?? "")}`);
|
|
826
|
-
continue;
|
|
827
|
-
}
|
|
828
|
-
if (/^\s*([-*_]\s*){3,}\s*$/.test(line)) {
|
|
829
|
-
output.push("────────");
|
|
830
|
-
continue;
|
|
831
|
-
}
|
|
832
|
-
output.push(stripInlineMarkdownToPlainText(line));
|
|
833
|
-
}
|
|
834
|
-
return output.join("\n");
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
// --- Rich Markdown Rendering ---
|
|
491
|
+
// --- UI Markdown-to-Telegram-HTML Rendering ---
|
|
838
492
|
|
|
839
493
|
function renderDelimitedInlineStyle(
|
|
840
494
|
text: string,
|
package/lib/replies.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Telegram reply delivery helpers
|
|
3
|
-
* Zones: telegram outbound, rendering transport
|
|
4
|
-
* Owns rendered
|
|
3
|
+
* Zones: telegram outbound, native rich markdown, UI/compat rendering transport
|
|
4
|
+
* Owns native assistant replies, rendered UI delivery, reply transport wiring, and plain text replies
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { assertTelegramInlineKeyboardCallbackData } from "./keyboard.ts";
|
|
8
8
|
import type {
|
|
9
|
+
TelegramInputRichMessage,
|
|
9
10
|
TelegramReplyParameters,
|
|
11
|
+
TelegramSendRichMessageBody,
|
|
10
12
|
TelegramSentMessage,
|
|
11
13
|
} from "./telegram-api.ts";
|
|
12
14
|
import {
|
|
@@ -21,6 +23,9 @@ export {
|
|
|
21
23
|
type TelegramRenderMode,
|
|
22
24
|
};
|
|
23
25
|
|
|
26
|
+
export const TELEGRAM_RICH_MESSAGE_MAX_CHARS = 32768;
|
|
27
|
+
export const TELEGRAM_RICH_MESSAGE_MAX_BLOCKS = 500;
|
|
28
|
+
|
|
24
29
|
// --- Reply Dedup ---
|
|
25
30
|
|
|
26
31
|
/** Non-persistent reply deduplication for a single agent turn.
|
|
@@ -138,7 +143,8 @@ export interface TelegramReplyDeliveryDeps<TReplyMarkup> {
|
|
|
138
143
|
editMessage: (body: {
|
|
139
144
|
chat_id: number;
|
|
140
145
|
message_id: number;
|
|
141
|
-
text
|
|
146
|
+
text?: string;
|
|
147
|
+
rich_message?: TelegramInputRichMessage;
|
|
142
148
|
parse_mode?: "HTML";
|
|
143
149
|
reply_markup?: TReplyMarkup;
|
|
144
150
|
}) => Promise<unknown>;
|
|
@@ -251,24 +257,221 @@ export async function sendTelegramPlainReply(
|
|
|
251
257
|
return deps.sendRenderedChunks(chunks);
|
|
252
258
|
}
|
|
253
259
|
|
|
254
|
-
|
|
260
|
+
function normalizeTelegramNativeMarkdownLine(line: string): string {
|
|
261
|
+
let result = line.replace(/^( {0,3}>)[ \t]/, "$1");
|
|
262
|
+
const codeSpans: string[] = [];
|
|
263
|
+
result = result.replace(/`+[^`]*`+/g, (code) => {
|
|
264
|
+
const token = `\u0000${codeSpans.length}\u0000`;
|
|
265
|
+
codeSpans.push(code);
|
|
266
|
+
return token;
|
|
267
|
+
});
|
|
268
|
+
result = result.replace(
|
|
269
|
+
/(^|[^\\$])\$([A-Z][A-Z0-9]{1,})(?!\$)(?=\b|[.,;:)/-])/g,
|
|
270
|
+
(_match, prefix: string, ticker: string) => `${prefix}\\$${ticker}`,
|
|
271
|
+
);
|
|
272
|
+
return result.replace(/\u0000(\d+)\u0000/g, (_match, index) => codeSpans[Number(index)] ?? "");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function normalizeTelegramNativeMarkdown(markdown: string): string {
|
|
276
|
+
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
|
277
|
+
let fence: { marker: "`" | "~"; length: number } | undefined;
|
|
278
|
+
return lines
|
|
279
|
+
.map((line) => {
|
|
280
|
+
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
|
|
281
|
+
const inFence = fence !== undefined;
|
|
282
|
+
if (!inFence && fenceMatch) {
|
|
283
|
+
const markerText = fenceMatch[1] ?? "```";
|
|
284
|
+
fence = { marker: markerText[0] as "`" | "~", length: markerText.length };
|
|
285
|
+
return line;
|
|
286
|
+
}
|
|
287
|
+
if (
|
|
288
|
+
inFence &&
|
|
289
|
+
new RegExp(`^ {0,3}${fence?.marker}{${fence?.length},}\\s*$`).test(line)
|
|
290
|
+
) {
|
|
291
|
+
fence = undefined;
|
|
292
|
+
return line;
|
|
293
|
+
}
|
|
294
|
+
if (!inFence) return normalizeTelegramNativeMarkdownLine(line);
|
|
295
|
+
return line;
|
|
296
|
+
})
|
|
297
|
+
.join("\n");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function splitTelegramNativeMarkdown(markdown: string): string[] {
|
|
301
|
+
const normalizedMarkdown = normalizeTelegramNativeMarkdown(markdown);
|
|
302
|
+
if (
|
|
303
|
+
normalizedMarkdown.length <= TELEGRAM_RICH_MESSAGE_MAX_CHARS &&
|
|
304
|
+
countTelegramNativeMarkdownBlocks(normalizedMarkdown) <= TELEGRAM_RICH_MESSAGE_MAX_BLOCKS
|
|
305
|
+
) {
|
|
306
|
+
return [normalizedMarkdown];
|
|
307
|
+
}
|
|
308
|
+
const chunks: string[] = [];
|
|
309
|
+
let current = "";
|
|
310
|
+
let currentBlockCount = 0;
|
|
311
|
+
for (const rawBlock of splitTelegramNativeMarkdownBlocks(normalizedMarkdown)) {
|
|
312
|
+
for (const block of splitTelegramNativeMarkdownCountedBlocks(rawBlock)) {
|
|
313
|
+
const blockCount = countTelegramNativeMarkdownBlocks(block);
|
|
314
|
+
const candidate = current ? `${current}\n\n${block}` : block;
|
|
315
|
+
const exceedsChars = candidate.length > TELEGRAM_RICH_MESSAGE_MAX_CHARS;
|
|
316
|
+
const exceedsBlocks = currentBlockCount + blockCount > TELEGRAM_RICH_MESSAGE_MAX_BLOCKS;
|
|
317
|
+
if (!exceedsChars && !exceedsBlocks) {
|
|
318
|
+
current = candidate;
|
|
319
|
+
currentBlockCount += blockCount;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (current) chunks.push(current.trimEnd());
|
|
323
|
+
if (
|
|
324
|
+
block.length <= TELEGRAM_RICH_MESSAGE_MAX_CHARS &&
|
|
325
|
+
blockCount <= TELEGRAM_RICH_MESSAGE_MAX_BLOCKS
|
|
326
|
+
) {
|
|
327
|
+
current = block;
|
|
328
|
+
currentBlockCount = blockCount;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
chunks.push(...splitTelegramNativeMarkdownLongBlock(block));
|
|
332
|
+
current = "";
|
|
333
|
+
currentBlockCount = 0;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (current) chunks.push(current.trimEnd());
|
|
337
|
+
return chunks;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function splitTelegramNativeMarkdownBlocks(markdown: string): string[] {
|
|
341
|
+
const blocks: string[] = [];
|
|
342
|
+
const current: string[] = [];
|
|
343
|
+
let fence: { marker: "`" | "~"; length: number } | undefined;
|
|
344
|
+
const flush = (): void => {
|
|
345
|
+
if (current.length === 0) return;
|
|
346
|
+
blocks.push(current.join("\n"));
|
|
347
|
+
current.length = 0;
|
|
348
|
+
};
|
|
349
|
+
for (const line of markdown.split("\n")) {
|
|
350
|
+
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
|
|
351
|
+
if (!fence && line.trim().length === 0) {
|
|
352
|
+
flush();
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
current.push(line);
|
|
356
|
+
if (!fence && fenceMatch) {
|
|
357
|
+
const markerText = fenceMatch[1] ?? "```";
|
|
358
|
+
fence = { marker: markerText[0] as "`" | "~", length: markerText.length };
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (
|
|
362
|
+
fence &&
|
|
363
|
+
new RegExp(`^ {0,3}${fence.marker}{${fence.length},}\\s*$`).test(line)
|
|
364
|
+
) {
|
|
365
|
+
fence = undefined;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
flush();
|
|
369
|
+
return blocks;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function splitTelegramNativeMarkdownCountedBlocks(block: string): string[] {
|
|
373
|
+
if (countTelegramNativeMarkdownBlocks(block) <= TELEGRAM_RICH_MESSAGE_MAX_BLOCKS) {
|
|
374
|
+
return [block];
|
|
375
|
+
}
|
|
376
|
+
const chunks: string[] = [];
|
|
377
|
+
let current: string[] = [];
|
|
378
|
+
let fence: { marker: "`" | "~"; length: number } | undefined;
|
|
379
|
+
for (const line of block.split("\n")) {
|
|
380
|
+
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
|
|
381
|
+
if (!fence && current.length >= TELEGRAM_RICH_MESSAGE_MAX_BLOCKS) {
|
|
382
|
+
chunks.push(current.join("\n"));
|
|
383
|
+
current = [];
|
|
384
|
+
}
|
|
385
|
+
current.push(line);
|
|
386
|
+
if (!fence && fenceMatch) {
|
|
387
|
+
const markerText = fenceMatch[1] ?? "```";
|
|
388
|
+
fence = { marker: markerText[0] as "`" | "~", length: markerText.length };
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (
|
|
392
|
+
fence &&
|
|
393
|
+
new RegExp(`^ {0,3}${fence.marker}{${fence.length},}\\s*$`).test(line)
|
|
394
|
+
) {
|
|
395
|
+
fence = undefined;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (current.length > 0) chunks.push(current.join("\n"));
|
|
399
|
+
return chunks;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function countTelegramNativeMarkdownBlocks(block: string): number {
|
|
403
|
+
if (/^ {0,3}(`{3,}|~{3,})/.test(block)) return 1;
|
|
404
|
+
const lines = block.split("\n").filter((line) => line.trim().length > 0);
|
|
405
|
+
if (lines.some((line) => /^\s*([-*+] |\d+\. |>|\|)/.test(line))) {
|
|
406
|
+
return Math.max(1, lines.length);
|
|
407
|
+
}
|
|
408
|
+
return 1;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function splitTelegramNativeMarkdownLongBlock(block: string): string[] {
|
|
412
|
+
const chunks: string[] = [];
|
|
413
|
+
let remaining = block;
|
|
414
|
+
while (remaining.length > TELEGRAM_RICH_MESSAGE_MAX_CHARS) {
|
|
415
|
+
const window = remaining.slice(0, TELEGRAM_RICH_MESSAGE_MAX_CHARS + 1);
|
|
416
|
+
const splitIndex = findTelegramNativeMarkdownSplitIndex(window);
|
|
417
|
+
chunks.push(remaining.slice(0, splitIndex).trimEnd());
|
|
418
|
+
remaining = remaining.slice(splitIndex).trimStart();
|
|
419
|
+
}
|
|
420
|
+
if (remaining.length > 0) chunks.push(remaining);
|
|
421
|
+
return chunks;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function findTelegramNativeMarkdownSplitIndex(text: string): number {
|
|
425
|
+
const hardLimit = TELEGRAM_RICH_MESSAGE_MAX_CHARS;
|
|
426
|
+
const paragraphIndex = text.lastIndexOf("\n\n", hardLimit);
|
|
427
|
+
if (paragraphIndex > 0) return paragraphIndex + 2;
|
|
428
|
+
const lineIndex = text.lastIndexOf("\n", hardLimit);
|
|
429
|
+
if (lineIndex > 0) return lineIndex + 1;
|
|
430
|
+
const spaceIndex = text.lastIndexOf(" ", hardLimit);
|
|
431
|
+
if (spaceIndex > 0) return spaceIndex + 1;
|
|
432
|
+
return hardLimit;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export async function sendTelegramNativeMarkdownReply<TReplyMarkup = unknown>(
|
|
436
|
+
chatId: number,
|
|
437
|
+
replyToMessageId: number | undefined,
|
|
255
438
|
markdown: string,
|
|
256
|
-
deps:
|
|
439
|
+
deps: {
|
|
440
|
+
sendRichMessage: (
|
|
441
|
+
body: TelegramSendRichMessageBody,
|
|
442
|
+
) => Promise<TelegramSentMessage>;
|
|
443
|
+
},
|
|
257
444
|
options?: { replyMarkup?: TReplyMarkup },
|
|
258
445
|
): Promise<number | undefined> {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
446
|
+
assertTelegramInlineKeyboardCallbackData(options?.replyMarkup);
|
|
447
|
+
let lastMessageId: number | undefined;
|
|
448
|
+
const chunks = splitTelegramNativeMarkdown(markdown);
|
|
449
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
450
|
+
const replyParameters =
|
|
451
|
+
index === 0
|
|
452
|
+
? buildTelegramReplyParameters(chatId, replyToMessageId)
|
|
453
|
+
: undefined;
|
|
454
|
+
const sent = await deps.sendRichMessage({
|
|
455
|
+
chat_id: chatId,
|
|
456
|
+
rich_message: { markdown: chunk, skip_entity_detection: true },
|
|
457
|
+
reply_markup: index === chunks.length - 1 ? options?.replyMarkup : undefined,
|
|
458
|
+
...(replyParameters ? { reply_parameters: replyParameters } : {}),
|
|
459
|
+
});
|
|
460
|
+
lastMessageId = sent.message_id;
|
|
262
461
|
}
|
|
263
|
-
return
|
|
462
|
+
return lastMessageId;
|
|
264
463
|
}
|
|
265
464
|
|
|
465
|
+
// UI/compat regular-message runtime for bridge-owned text and interactive
|
|
466
|
+
// surfaces. Assistant and guest Markdown delivery bypass this path and use
|
|
467
|
+
// native Rich Message helpers above.
|
|
266
468
|
export interface TelegramRenderedMessageRuntimeDeps<TReplyMarkup> {
|
|
267
469
|
renderTelegramMessage: (
|
|
268
470
|
text: string,
|
|
269
471
|
options?: { mode?: TelegramRenderMode },
|
|
270
472
|
) => TelegramRenderedChunk[];
|
|
271
473
|
replyTransport: TelegramReplyTransport<TReplyMarkup>;
|
|
474
|
+
sendRichMessage: (body: TelegramSendRichMessageBody) => Promise<TelegramSentMessage>;
|
|
272
475
|
}
|
|
273
476
|
|
|
274
477
|
export interface TelegramRenderedMessageRuntime<TReplyMarkup> {
|
|
@@ -312,6 +515,7 @@ export interface TelegramRenderedMessageDeliveryRuntimeDeps<
|
|
|
312
515
|
text: string,
|
|
313
516
|
options?: { mode?: TelegramRenderMode },
|
|
314
517
|
) => TelegramRenderedChunk[];
|
|
518
|
+
sendRichMessage: (body: TelegramSendRichMessageBody) => Promise<TelegramSentMessage>;
|
|
315
519
|
}
|
|
316
520
|
|
|
317
521
|
export function createTelegramRenderedMessageDeliveryRuntime<TReplyMarkup>(
|
|
@@ -327,6 +531,7 @@ export function createTelegramRenderedMessageDeliveryRuntime<TReplyMarkup>(
|
|
|
327
531
|
renderTelegramMessage:
|
|
328
532
|
deps.renderTelegramMessage ?? renderTelegramMessage,
|
|
329
533
|
replyTransport,
|
|
534
|
+
sendRichMessage: deps.sendRichMessage,
|
|
330
535
|
}),
|
|
331
536
|
};
|
|
332
537
|
}
|
|
@@ -349,18 +554,11 @@ export function createTelegramRenderedMessageRuntime<TReplyMarkup>(
|
|
|
349
554
|
);
|
|
350
555
|
},
|
|
351
556
|
sendMarkdownReply: async (chatId, replyToMessageId, markdown, options) => {
|
|
352
|
-
return
|
|
557
|
+
return sendTelegramNativeMarkdownReply(
|
|
558
|
+
chatId,
|
|
559
|
+
replyToMessageId,
|
|
353
560
|
markdown,
|
|
354
|
-
{
|
|
355
|
-
renderTelegramMessage: deps.renderTelegramMessage,
|
|
356
|
-
sendRenderedChunks: (chunks, chunkOptions) =>
|
|
357
|
-
deps.replyTransport.sendRenderedChunks(chatId, chunks, {
|
|
358
|
-
replyToMessageId,
|
|
359
|
-
replyMarkup: chunkOptions?.replyMarkup as
|
|
360
|
-
| TReplyMarkup
|
|
361
|
-
| undefined,
|
|
362
|
-
}),
|
|
363
|
-
},
|
|
561
|
+
{ sendRichMessage: deps.sendRichMessage },
|
|
364
562
|
options,
|
|
365
563
|
);
|
|
366
564
|
},
|
|
@@ -438,23 +636,21 @@ export function dedupSendMarkdownReply<TReplyMarkup = unknown>(
|
|
|
438
636
|
}
|
|
439
637
|
|
|
440
638
|
/**
|
|
441
|
-
* Guest reply sender:
|
|
442
|
-
*
|
|
443
|
-
*
|
|
639
|
+
* Guest reply sender: answers guest queries with native Rich Markdown content.
|
|
640
|
+
* Guest queries use InlineQueryResult input_message_content rather than chat
|
|
641
|
+
* sendRichMessage, so this stays as a dedicated guest transport adapter.
|
|
642
|
+
*/
|
|
444
643
|
export function createGuestMarkdownReplySender(deps: {
|
|
445
|
-
renderTelegramMessage: (
|
|
446
|
-
text: string,
|
|
447
|
-
options?: { mode?: TelegramRenderMode },
|
|
448
|
-
) => TelegramRenderedChunk[];
|
|
449
644
|
answerGuestQuery: (
|
|
450
645
|
guestQueryId: string,
|
|
451
646
|
text?: string,
|
|
452
|
-
options?: { parseMode?: string },
|
|
647
|
+
options?: { parseMode?: string; richMessage?: TelegramInputRichMessage },
|
|
453
648
|
) => Promise<void>;
|
|
454
649
|
}) {
|
|
455
650
|
return async (guestQueryId: string, markdown: string) => {
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
-
|
|
651
|
+
const [richMarkdown = markdown] = splitTelegramNativeMarkdown(markdown);
|
|
652
|
+
await deps.answerGuestQuery(guestQueryId, undefined, {
|
|
653
|
+
richMessage: { markdown: richMarkdown, skip_entity_detection: true },
|
|
654
|
+
});
|
|
459
655
|
};
|
|
460
656
|
}
|