fix(telegram): preserve rich markdown line breaks

This commit is contained in:
Ayaan Zaidi
2026-06-15 10:44:04 +05:30
parent 5b460c4669
commit b3f315461b
7 changed files with 134 additions and 25 deletions

View File

@@ -21,9 +21,6 @@ type CodexStatusProbes = {
skills: SafeValue<JsonValue | undefined>;
};
// Status rows are intentional layout, not GFM soft-wrapped prose.
const CODEX_STATUS_LINE_BREAK = " \n";
/** Formats the combined `/codex status` probe result. */
export function formatCodexStatus(probes: CodexStatusProbes): string {
const connected =
@@ -69,7 +66,7 @@ export function formatCodexStatus(probes: CodexStatusProbes): string {
: formatCodexDisplayText(probes.skills.error)
}`,
);
return lines.flatMap((line) => line.split("\n")).join(CODEX_STATUS_LINE_BREAK);
return lines.join("\n");
}
/** Formats Codex model-list results for `/codex models`. */

View File

@@ -878,7 +878,7 @@ describe("codex command", () => {
"Rate limits: offline",
"MCP servers: offline",
"Skills: offline",
].join(" \n"),
].join("\n"),
});
expect(deps.readCodexStatusProbes).toHaveBeenCalledWith(undefined, config);
});

View File

@@ -239,6 +239,22 @@ function isSafeRichMarkdownBlockBreak(spans: readonly RichMarkdownFenceSpan[], i
return !spans.some((span) => index > span.start && index < span.end);
}
function isRichMarkdownFenceMarker(line: string): boolean {
return /^( {0,3})(`{3,}|~{3,})/.test(line);
}
function isRichMarkdownBlockLine(line: string, isTableLine: boolean): boolean {
const trimmed = line.trimStart();
return (
isTableLine ||
isRichMarkdownFenceMarker(line) ||
/^#{1,6}\s+\S/.test(trimmed) ||
trimmed.startsWith(">") ||
/^(?:[-+*]|\d+[.)])\s+\S/.test(trimmed) ||
/^[-*_][\s-*_-]{2,}$/.test(trimmed)
);
}
function splitMarkdownTableRow(row: string): string[] {
const trimmed = row.trim();
const body = trimmed.startsWith("|") && trimmed.endsWith("|") ? trimmed.slice(1, -1) : trimmed;
@@ -280,7 +296,73 @@ function markdownTableColumnCount(row: string): number {
return splitMarkdownTableRow(row).length;
}
function normalizeTelegramRichMarkdown(markdown: string): string {
function findRichMarkdownTableLineIndexes(
markdown: string,
lines: readonly string[],
fenceSpans: readonly RichMarkdownFenceSpan[],
): Set<number> {
const tableLineIndexes = new Set<number>();
let offset = 0;
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? "";
const nextLine = lines[index + 1];
if (
nextLine !== undefined &&
isSafeRichMarkdownBlockBreak(fenceSpans, offset) &&
isMarkdownTableRow(line) &&
isMarkdownTableSeparator(nextLine)
) {
tableLineIndexes.add(index);
tableLineIndexes.add(index + 1);
offset += line.length + 1 + nextLine.length + 1;
index += 2;
while (index < lines.length && isMarkdownTableRow(lines[index] ?? "")) {
tableLineIndexes.add(index);
offset += (lines[index] ?? "").length + 1;
index += 1;
}
index -= 1;
continue;
}
offset += line.length + 1;
}
return tableLineIndexes;
}
function preserveTelegramRichMarkdownLineBreaks(markdown: string): string {
if (!markdown.includes("\n")) {
return markdown;
}
const fenceSpans = parseRichMarkdownFenceSpans(markdown);
const lines = markdown.split("\n");
const tableLineIndexes = findRichMarkdownTableLineIndexes(markdown, lines, fenceSpans);
const out: string[] = [];
let offset = 0;
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? "";
const nextLine = lines[index + 1];
if (nextLine === undefined) {
out.push(line);
break;
}
const newlineIndex = offset + line.length;
const shouldPreserveBreak =
line.length > 0 &&
nextLine.length > 0 &&
!line.endsWith(" ") &&
!line.endsWith("\\") &&
!isRichMarkdownBlockLine(line, tableLineIndexes.has(index)) &&
!isRichMarkdownBlockLine(nextLine, tableLineIndexes.has(index + 1)) &&
isSafeRichMarkdownBlockBreak(fenceSpans, newlineIndex);
out.push(`${line}${shouldPreserveBreak ? " " : ""}\n`);
offset = newlineIndex + 1;
}
return out.join("");
}
function normalizeTelegramRichMarkdownTables(markdown: string): string {
if (!markdown.includes("|")) {
return markdown;
}
@@ -320,6 +402,10 @@ function normalizeTelegramRichMarkdown(markdown: string): string {
return out.join("\n");
}
function normalizeTelegramRichMarkdown(markdown: string): string {
return preserveTelegramRichMarkdownLineBreaks(normalizeTelegramRichMarkdownTables(markdown));
}
type RichMarkdownBlockBreak = {
start: number;
end: number;

View File

@@ -947,8 +947,42 @@ describe("sendMessageTelegram", () => {
});
});
it("keeps markdown media syntax on the text-only rich path", async () => {
it("preserves rich markdown line breaks outside fenced code", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 47, chat: { id: "123" } });
const markdown = [
"Status: ok | mode",
"Models: ready",
"",
"```",
"a",
"b",
"```",
"Tail",
].join("\n");
const expectedMarkdown = [
"Status: ok | mode ",
"Models: ready",
"",
"```",
"a",
"b",
"```",
"Tail",
].join("\n");
await sendMessageTelegram("123", markdown, {
cfg: TELEGRAM_TEST_CFG,
token: "tok",
});
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
chat_id: "123",
rich_message: { markdown: expectedMarkdown },
});
});
it("keeps markdown media syntax on the text-only rich path", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 48, chat: { id: "123" } });
await sendMessageTelegram("123", "See ![diagram](https://example.com/diagram.png)", {
cfg: TELEGRAM_TEST_CFG,
@@ -962,7 +996,7 @@ describe("sendMessageTelegram", () => {
});
it("escapes HTML media tags on the text-only rich path", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 48, chat: { id: "123" } });
botApi.sendMessage.mockResolvedValue({ message_id: 49, chat: { id: "123" } });
await sendMessageTelegram("123", '<b>See</b><img src="https://example.com/diagram.png">', {
cfg: TELEGRAM_TEST_CFG,
@@ -979,7 +1013,7 @@ describe("sendMessageTelegram", () => {
});
it("keeps native rich markdown tables within Telegram's column limit", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 49, chat: { id: "123" } });
botApi.sendMessage.mockResolvedValue({ message_id: 50, chat: { id: "123" } });
const markdown = markdownTable(20);
await sendMessageTelegram("123", markdown, {
@@ -994,7 +1028,7 @@ describe("sendMessageTelegram", () => {
});
it("wraps wide rich markdown tables that exceed Telegram's column limit", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 50, chat: { id: "123" } });
botApi.sendMessage.mockResolvedValue({ message_id: 51, chat: { id: "123" } });
const markdown = markdownTable(21);
await sendMessageTelegram("123", markdown, {
@@ -1009,7 +1043,7 @@ describe("sendMessageTelegram", () => {
});
it("leaves wide rich markdown tables alone inside fences", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 51, chat: { id: "123" } });
botApi.sendMessage.mockResolvedValue({ message_id: 52, chat: { id: "123" } });
const markdown = `~~~\n${markdownTable(25)}\n~~~`;
await sendMessageTelegram("123", markdown, {
@@ -1046,7 +1080,9 @@ describe("sendMessageTelegram", () => {
it("sends long rich markdown as one message", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 53, chat: { id: "123" } });
const markdown = `# Long\n\n${"**section** with _style_ and `code`\n".repeat(800)}`;
const line = "**section** with _style_ and `code`";
const markdown = `# Long\n\n${`${line}\n`.repeat(800)}`;
const expectedMarkdown = `# Long\n\n${`${line} \n`.repeat(799)}${line}\n`;
await sendMessageTelegram("123", markdown, {
cfg: TELEGRAM_TEST_CFG,
@@ -1056,7 +1092,7 @@ describe("sendMessageTelegram", () => {
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
chat_id: "123",
rich_message: { markdown },
rich_message: { markdown: expectedMarkdown },
});
});

View File

@@ -19,7 +19,7 @@ describe("telegramPlugin outbound", () => {
it("uses static outbound contract when Telegram runtime is uninitialized", () => {
clearTelegramRuntime();
const text = `${"hello\n".repeat(1200)}tail`;
const expected = chunkMarkdownTextWithMode(text, 32_768, "length");
const expected = chunkMarkdownTextWithMode(`${"hello \n".repeat(1200)}tail`, 32_768, "length");
expect(telegramOutbound.chunker?.(text, 32_768)).toEqual(expected);
expect(telegramOutbound.deliveryMode).toBe("direct");

View File

@@ -130,12 +130,6 @@ describe("buildStatusMessage", () => {
});
const normalized = normalizeTestText(text);
expect(
text
.split("\n")
.slice(0, -1)
.every((line) => line.endsWith(" ")),
).toBe(true);
expect(normalized).toContain("OpenClaw");
expect(normalized).toContain("Model: anthropic/test:opus");
expect(normalized).toContain("api-key");

View File

@@ -69,9 +69,6 @@ type AgentConfig = Partial<AgentDefaults> & {
model?: AgentDefaults["model"] | string;
};
// Status rows are intentional layout, not GFM soft-wrapped prose.
const STATUS_MARKDOWN_LINE_BREAK = " \n";
export const formatTokenCount = formatTokenCountShared;
type QueueStatus = {
@@ -1053,6 +1050,5 @@ export function buildStatusMessage(args: StatusArgs): string {
activationLine,
]
.filter((line): line is string => Boolean(line))
.flatMap((line) => line.split("\n"))
.join(STATUS_MARKDOWN_LINE_BREAK);
.join("\n");
}