From d2649e041067f4b2d19e68d6896cf6a24d901e32 Mon Sep 17 00:00:00 2001 From: evgyur Date: Mon, 4 May 2026 02:31:29 +0300 Subject: [PATCH] fix(telegram): preserve spacing before numbered sections --- extensions/telegram/src/format.test.ts | 27 ++++++++++++++++ extensions/telegram/src/format.ts | 43 ++++++++++++++++++++++++-- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/extensions/telegram/src/format.test.ts b/extensions/telegram/src/format.test.ts index 2fcd06663e0..70f09cab856 100644 --- a/extensions/telegram/src/format.test.ts +++ b/extensions/telegram/src/format.test.ts @@ -95,6 +95,33 @@ describe("markdownToTelegramHtml", () => { expect(res).toBe("secret text"); }); + it("preserves spacing between Telegram bullet blocks and following numbered sections", () => { + const input = [ + "2. Main invariants:", + "", + " • Raw Log is source of truth.", + " • Autonomy starts only with report/draft.", + "3. Cognee is a candidate:", + "", + " • bake-off first;", + " • decide keep/adopt/hybrid later.", + "4. Project Flow slices:", + ].join("\n"); + + const res = markdownToTelegramHtml(input, { wrapFileRefs: false }); + + expect(res).toContain("report/draft.\n\n3. Cognee"); + expect(res).toContain("keep/adopt/hybrid later.\n\n4. Project"); + }); + + it("does not insert Telegram list boundary spacing inside fenced code", () => { + const input = ["```", " • literal bullet", "3. literal number", "```"].join("\n"); + + const res = markdownToTelegramHtml(input, { wrapFileRefs: false }); + + expect(res).toBe("
  • literal bullet\n3. literal number\n
"); + }); + it("does not treat single pipe as spoiler", () => { const res = markdownToTelegramHtml("( ̄_ ̄|) face"); expect(res).not.toContain("tg-spoiler"); diff --git a/extensions/telegram/src/format.ts b/extensions/telegram/src/format.ts index 080af504b15..c959903c475 100644 --- a/extensions/telegram/src/format.ts +++ b/extensions/telegram/src/format.ts @@ -72,11 +72,50 @@ function renderTelegramHtml(ir: MarkdownIR): string { }); } +function leadingWhitespaceLength(line: string): number { + return line.match(/^[ \t]*/)?.[0]?.length ?? 0; +} + +function isTelegramBulletLine(line: string): boolean { + return /^[ \t]*(?:[•*+-])[ \t]+\S/.test(line); +} + +function isTelegramListBoundaryLine(line: string): boolean { + return /^[ \t]*(?:\d+\.|#{1,6})[ \t]+\S/.test(line); +} + +function preserveTelegramListBoundarySpacing(markdown: string): string { + const lines = markdown.split("\n"); + const out: string[] = []; + let inFence = false; + + for (const line of lines) { + const normalizedLine = line.replace(/\r$/, ""); + const isFenceLine = /^[ \t]*(?:```|~~~)/.test(normalizedLine); + if (!inFence && out.length > 0) { + const previous = out[out.length - 1] ?? ""; + if ( + isTelegramBulletLine(previous) && + isTelegramListBoundaryLine(normalizedLine) && + leadingWhitespaceLength(normalizedLine) <= leadingWhitespaceLength(previous) + ) { + out.push(""); + } + } + out.push(line); + if (isFenceLine) { + inFence = !inFence; + } + } + + return out.join("\n"); +} + export function markdownToTelegramHtml( markdown: string, options: { tableMode?: MarkdownTableMode; wrapFileRefs?: boolean } = {}, ): string { - const ir = markdownToIR(markdown ?? "", { + const ir = markdownToIR(preserveTelegramListBoundarySpacing(markdown ?? ""), { linkify: true, enableSpoilers: true, headingStyle: "none", @@ -458,7 +497,7 @@ export function markdownToTelegramChunks( limit: number, options: { tableMode?: MarkdownTableMode } = {}, ): TelegramFormattedChunk[] { - const ir = markdownToIR(markdown ?? "", { + const ir = markdownToIR(preserveTelegramListBoundarySpacing(markdown ?? ""), { linkify: true, enableSpoilers: true, headingStyle: "none",