@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/lib/rendering.ts CHANGED
@@ -5,19 +5,118 @@
5
5
 
6
6
  export const MAX_MESSAGE_LENGTH = 4096;
7
7
 
8
- // --- Escaping ---
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
- function escapeHtml(text: string): string {
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, "&lt;")
14
28
  .replace(/>/g, "&gt;");
15
29
  }
16
30
 
17
- function escapeHtmlAttribute(text: string): string {
31
+ export function escapeHtmlAttribute(text: string): string {
18
32
  return escapeHtml(text).replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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 replaceMarkdownLinkLike(
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 = replaceMarkdownLinkLike(text, {
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 TelegramPreviewSnapshotStateLike {
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: TelegramPreviewSnapshotStateLike;
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: TelegramPreviewSnapshotStateLike;
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 += 1;
471
- while (
472
- index < lines.length &&
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
- if (index >= lines.length) {
478
- return {
479
- stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
480
- unstableTail: lines.slice(stableEndIndex).join("\n"),
481
- };
482
- }
483
- index += 1;
604
+ index = block.nextIndex;
484
605
  stableEndIndex = index;
485
606
  continue;
486
607
  }
487
608
  if (line.includes("|") && isMarkdownTableSeparator(nextLine)) {
488
- index += 2;
489
- while (index < lines.length) {
490
- const tableLine = lines[index] ?? "";
491
- if (tableLine.trim().length === 0 || !tableLine.includes("|")) {
492
- break;
493
- }
494
- index += 1;
495
- }
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
- while (index < lines.length) {
507
- const rawLine = lines[index] ?? "";
508
- if (rawLine.trim().length === 0) {
509
- index += 1;
510
- continue;
511
- }
512
- if (!isIndentedCodeLine(rawLine)) break;
513
- index += 1;
514
- }
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
- while (index < lines.length && /^\s*>/.test(lines[index] ?? "")) {
526
- index += 1;
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
- while (index < lines.length) {
538
- const current = lines[index] ?? "";
539
- const following = lines[index + 1] ?? "";
540
- if (current.trim().length === 0) break;
541
- if (
542
- index !== blockStart &&
543
- (isFencedCodeStart(current) ||
544
- canStartIndentedCodeBlock(lines, index) ||
545
- /^\s*>/.test(current) ||
546
- (current.includes("|") && isMarkdownTableSeparator(following)))
547
- ) {
548
- break;
549
- }
550
- index += 1;
551
- }
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
- stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
562
- unstableTail: lines.slice(stableEndIndex).join("\n"),
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: TelegramPreviewSnapshotStateLike;
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 = options.renderTelegramMessage(split.stableMarkdown, {
587
- mode: "markdown",
588
- })[0];
589
- if (
590
- !stableChunk ||
591
- stableChunk.text.length === 0 ||
592
- stableChunk.text.length > options.maxMessageLength
593
- ) {
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
- let previewText = stableChunk.text;
602
- if (split.unstableTail.length > 0) {
603
- const tail = splitLeadingMarkdownBlankLines(split.unstableTail);
604
- const minimumBlankLinesBeforeTail = endsWithMarkdownHeadingLine(
605
- split.stableMarkdown,
606
- )
607
- ? 1
608
- : 0;
609
- const blankLinesBeforeTail = Math.max(
610
- tail.blankLines,
611
- minimumBlankLinesBeforeTail,
612
- );
613
- const separator =
614
- tail.remainingText.length > 0
615
- ? "\n".repeat(blankLinesBeforeTail + 1)
616
- : "";
617
- const tailText = escapeHtml(tail.remainingText);
618
- const candidate = `${previewText}${separator}${tailText}`;
619
- if (candidate.length <= options.maxMessageLength) {
620
- previewText = candidate;
621
- }
622
- }
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
- previewText === options.state.lastSentText &&
625
- stableChunk.parseMode === options.state.lastSentParseMode &&
626
- options.state.lastSentStrategy === "rich-stable-blocks"
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
- function renderInlineMarkdown(text: string): string {
738
- const tokens: string[] = [];
739
- const makeToken = (html: string): string => {
740
- const token = `\uE000${tokens.length}\uE001`;
741
- tokens.push(html);
742
- return token;
743
- };
744
- let result = replaceMarkdownLinkLike(text, {
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 makeToken(
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 makeToken(
886
+ return makeInlineMarkdownToken(
887
+ state,
758
888
  `<a href="${escapeHtmlAttribute(link.destination)}">${escapeHtml(link.destination)}</a>`,
759
889
  );
760
890
  },
761
891
  });
762
- result = result.replace(/`([^`\n]+)`/g, (_match, code: string) => {
763
- return makeToken(`<code>${escapeHtml(code)}</code>`);
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
- result = escapeHtml(result);
766
- result = renderDelimitedInlineStyle(result, "***", (content) => {
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
- result = renderDelimitedInlineStyle(result, "_", (content) => {
922
+ return renderDelimitedInlineStyle(result, "_", (content) => {
785
923
  return `<i>${content}</i>`;
786
924
  });
787
- result = result.replace(/\\([\\`*_{}\[\]()#+\-.!>~|])/g, "$1");
788
- return result.replace(
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 pieces = splitPlainMarkdownLine(line);
824
- for (const piece of pieces) {
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) => (row[columnIndex] ?? "").length),
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) => (cell ?? "").padEnd(widths[columnIndex] ?? 3))
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 renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
1018
- const normalized = normalizeMarkdownDocument(markdown);
1019
- if (normalized.length === 0) return [];
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 = normalized.split("\n");
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 += 1;
1065
- const codeLines: string[] = [];
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 tableLines: string[] = [line];
1085
- index += 2;
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 codeLines: string[] = [];
1103
- while (index < lines.length) {
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 quoteLines: string[] = [];
1123
- while (index < lines.length && /^\s*>/.test(lines[index] ?? "")) {
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 textLines: string[] = [];
1135
- while (index < lines.length) {
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 current = "";
1157
- for (const block of renderedBlocks) {
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
  }));