Skip to content

Commit b3f3154

Browse files
committed
fix(telegram): preserve rich markdown line breaks
1 parent 5b460c4 commit b3f3154

7 files changed

Lines changed: 134 additions & 25 deletions

File tree

extensions/codex/src/command-formatters.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,6 @@ type CodexStatusProbes = {
2121
skills: SafeValue<JsonValue | undefined>;
2222
};
2323

24-
// Status rows are intentional layout, not GFM soft-wrapped prose.
25-
const CODEX_STATUS_LINE_BREAK = " \n";
26-
2724
/** Formats the combined `/codex status` probe result. */
2825
export function formatCodexStatus(probes: CodexStatusProbes): string {
2926
const connected =
@@ -69,7 +66,7 @@ export function formatCodexStatus(probes: CodexStatusProbes): string {
6966
: formatCodexDisplayText(probes.skills.error)
7067
}`,
7168
);
72-
return lines.flatMap((line) => line.split("\n")).join(CODEX_STATUS_LINE_BREAK);
69+
return lines.join("\n");
7370
}
7471

7572
/** Formats Codex model-list results for `/codex models`. */

extensions/codex/src/commands.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -878,7 +878,7 @@ describe("codex command", () => {
878878
"Rate limits: offline",
879879
"MCP servers: offline",
880880
"Skills: offline",
881-
].join(" \n"),
881+
].join("\n"),
882882
});
883883
expect(deps.readCodexStatusProbes).toHaveBeenCalledWith(undefined, config);
884884
});

extensions/telegram/src/rich-message.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,22 @@ function isSafeRichMarkdownBlockBreak(spans: readonly RichMarkdownFenceSpan[], i
239239
return !spans.some((span) => index > span.start && index < span.end);
240240
}
241241

242+
function isRichMarkdownFenceMarker(line: string): boolean {
243+
return /^( {0,3})(`{3,}|~{3,})/.test(line);
244+
}
245+
246+
function isRichMarkdownBlockLine(line: string, isTableLine: boolean): boolean {
247+
const trimmed = line.trimStart();
248+
return (
249+
isTableLine ||
250+
isRichMarkdownFenceMarker(line) ||
251+
/^#{1,6}\s+\S/.test(trimmed) ||
252+
trimmed.startsWith(">") ||
253+
/^(?:[-+*]|\d+[.)])\s+\S/.test(trimmed) ||
254+
/^[-*_][\s-*_-]{2,}$/.test(trimmed)
255+
);
256+
}
257+
242258
function splitMarkdownTableRow(row: string): string[] {
243259
const trimmed = row.trim();
244260
const body = trimmed.startsWith("|") && trimmed.endsWith("|") ? trimmed.slice(1, -1) : trimmed;
@@ -280,7 +296,73 @@ function markdownTableColumnCount(row: string): number {
280296
return splitMarkdownTableRow(row).length;
281297
}
282298

283-
function normalizeTelegramRichMarkdown(markdown: string): string {
299+
function findRichMarkdownTableLineIndexes(
300+
markdown: string,
301+
lines: readonly string[],
302+
fenceSpans: readonly RichMarkdownFenceSpan[],
303+
): Set<number> {
304+
const tableLineIndexes = new Set<number>();
305+
let offset = 0;
306+
for (let index = 0; index < lines.length; index += 1) {
307+
const line = lines[index] ?? "";
308+
const nextLine = lines[index + 1];
309+
if (
310+
nextLine !== undefined &&
311+
isSafeRichMarkdownBlockBreak(fenceSpans, offset) &&
312+
isMarkdownTableRow(line) &&
313+
isMarkdownTableSeparator(nextLine)
314+
) {
315+
tableLineIndexes.add(index);
316+
tableLineIndexes.add(index + 1);
317+
offset += line.length + 1 + nextLine.length + 1;
318+
index += 2;
319+
while (index < lines.length && isMarkdownTableRow(lines[index] ?? "")) {
320+
tableLineIndexes.add(index);
321+
offset += (lines[index] ?? "").length + 1;
322+
index += 1;
323+
}
324+
index -= 1;
325+
continue;
326+
}
327+
offset += line.length + 1;
328+
}
329+
return tableLineIndexes;
330+
}
331+
332+
function preserveTelegramRichMarkdownLineBreaks(markdown: string): string {
333+
if (!markdown.includes("\n")) {
334+
return markdown;
335+
}
336+
337+
const fenceSpans = parseRichMarkdownFenceSpans(markdown);
338+
const lines = markdown.split("\n");
339+
const tableLineIndexes = findRichMarkdownTableLineIndexes(markdown, lines, fenceSpans);
340+
const out: string[] = [];
341+
let offset = 0;
342+
for (let index = 0; index < lines.length; index += 1) {
343+
const line = lines[index] ?? "";
344+
const nextLine = lines[index + 1];
345+
if (nextLine === undefined) {
346+
out.push(line);
347+
break;
348+
}
349+
350+
const newlineIndex = offset + line.length;
351+
const shouldPreserveBreak =
352+
line.length > 0 &&
353+
nextLine.length > 0 &&
354+
!line.endsWith(" ") &&
355+
!line.endsWith("\\") &&
356+
!isRichMarkdownBlockLine(line, tableLineIndexes.has(index)) &&
357+
!isRichMarkdownBlockLine(nextLine, tableLineIndexes.has(index + 1)) &&
358+
isSafeRichMarkdownBlockBreak(fenceSpans, newlineIndex);
359+
out.push(`${line}${shouldPreserveBreak ? " " : ""}\n`);
360+
offset = newlineIndex + 1;
361+
}
362+
return out.join("");
363+
}
364+
365+
function normalizeTelegramRichMarkdownTables(markdown: string): string {
284366
if (!markdown.includes("|")) {
285367
return markdown;
286368
}
@@ -320,6 +402,10 @@ function normalizeTelegramRichMarkdown(markdown: string): string {
320402
return out.join("\n");
321403
}
322404

405+
function normalizeTelegramRichMarkdown(markdown: string): string {
406+
return preserveTelegramRichMarkdownLineBreaks(normalizeTelegramRichMarkdownTables(markdown));
407+
}
408+
323409
type RichMarkdownBlockBreak = {
324410
start: number;
325411
end: number;

extensions/telegram/src/send.test.ts

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -947,8 +947,42 @@ describe("sendMessageTelegram", () => {
947947
});
948948
});
949949

950-
it("keeps markdown media syntax on the text-only rich path", async () => {
950+
it("preserves rich markdown line breaks outside fenced code", async () => {
951951
botApi.sendMessage.mockResolvedValue({ message_id: 47, chat: { id: "123" } });
952+
const markdown = [
953+
"Status: ok | mode",
954+
"Models: ready",
955+
"",
956+
"```",
957+
"a",
958+
"b",
959+
"```",
960+
"Tail",
961+
].join("\n");
962+
const expectedMarkdown = [
963+
"Status: ok | mode ",
964+
"Models: ready",
965+
"",
966+
"```",
967+
"a",
968+
"b",
969+
"```",
970+
"Tail",
971+
].join("\n");
972+
973+
await sendMessageTelegram("123", markdown, {
974+
cfg: TELEGRAM_TEST_CFG,
975+
token: "tok",
976+
});
977+
978+
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
979+
chat_id: "123",
980+
rich_message: { markdown: expectedMarkdown },
981+
});
982+
});
983+
984+
it("keeps markdown media syntax on the text-only rich path", async () => {
985+
botApi.sendMessage.mockResolvedValue({ message_id: 48, chat: { id: "123" } });
952986

953987
await sendMessageTelegram("123", "See ![diagram](https://example.com/diagram.png)", {
954988
cfg: TELEGRAM_TEST_CFG,
@@ -962,7 +996,7 @@ describe("sendMessageTelegram", () => {
962996
});
963997

964998
it("escapes HTML media tags on the text-only rich path", async () => {
965-
botApi.sendMessage.mockResolvedValue({ message_id: 48, chat: { id: "123" } });
999+
botApi.sendMessage.mockResolvedValue({ message_id: 49, chat: { id: "123" } });
9661000

9671001
await sendMessageTelegram("123", '<b>See</b><img src="https://example.com/diagram.png">', {
9681002
cfg: TELEGRAM_TEST_CFG,
@@ -979,7 +1013,7 @@ describe("sendMessageTelegram", () => {
9791013
});
9801014

9811015
it("keeps native rich markdown tables within Telegram's column limit", async () => {
982-
botApi.sendMessage.mockResolvedValue({ message_id: 49, chat: { id: "123" } });
1016+
botApi.sendMessage.mockResolvedValue({ message_id: 50, chat: { id: "123" } });
9831017
const markdown = markdownTable(20);
9841018

9851019
await sendMessageTelegram("123", markdown, {
@@ -994,7 +1028,7 @@ describe("sendMessageTelegram", () => {
9941028
});
9951029

9961030
it("wraps wide rich markdown tables that exceed Telegram's column limit", async () => {
997-
botApi.sendMessage.mockResolvedValue({ message_id: 50, chat: { id: "123" } });
1031+
botApi.sendMessage.mockResolvedValue({ message_id: 51, chat: { id: "123" } });
9981032
const markdown = markdownTable(21);
9991033

10001034
await sendMessageTelegram("123", markdown, {
@@ -1009,7 +1043,7 @@ describe("sendMessageTelegram", () => {
10091043
});
10101044

10111045
it("leaves wide rich markdown tables alone inside fences", async () => {
1012-
botApi.sendMessage.mockResolvedValue({ message_id: 51, chat: { id: "123" } });
1046+
botApi.sendMessage.mockResolvedValue({ message_id: 52, chat: { id: "123" } });
10131047
const markdown = `~~~\n${markdownTable(25)}\n~~~`;
10141048

10151049
await sendMessageTelegram("123", markdown, {
@@ -1046,7 +1080,9 @@ describe("sendMessageTelegram", () => {
10461080

10471081
it("sends long rich markdown as one message", async () => {
10481082
botApi.sendMessage.mockResolvedValue({ message_id: 53, chat: { id: "123" } });
1049-
const markdown = `# Long\n\n${"**section** with _style_ and `code`\n".repeat(800)}`;
1083+
const line = "**section** with _style_ and `code`";
1084+
const markdown = `# Long\n\n${`${line}\n`.repeat(800)}`;
1085+
const expectedMarkdown = `# Long\n\n${`${line} \n`.repeat(799)}${line}\n`;
10501086

10511087
await sendMessageTelegram("123", markdown, {
10521088
cfg: TELEGRAM_TEST_CFG,
@@ -1056,7 +1092,7 @@ describe("sendMessageTelegram", () => {
10561092
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
10571093
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
10581094
chat_id: "123",
1059-
rich_message: { markdown },
1095+
rich_message: { markdown: expectedMarkdown },
10601096
});
10611097
});
10621098

extensions/telegram/src/telegram-outbound.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ describe("telegramPlugin outbound", () => {
1919
it("uses static outbound contract when Telegram runtime is uninitialized", () => {
2020
clearTelegramRuntime();
2121
const text = `${"hello\n".repeat(1200)}tail`;
22-
const expected = chunkMarkdownTextWithMode(text, 32_768, "length");
22+
const expected = chunkMarkdownTextWithMode(`${"hello \n".repeat(1200)}tail`, 32_768, "length");
2323

2424
expect(telegramOutbound.chunker?.(text, 32_768)).toEqual(expected);
2525
expect(telegramOutbound.deliveryMode).toBe("direct");

src/auto-reply/status.test.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,6 @@ describe("buildStatusMessage", () => {
130130
});
131131
const normalized = normalizeTestText(text);
132132

133-
expect(
134-
text
135-
.split("\n")
136-
.slice(0, -1)
137-
.every((line) => line.endsWith(" ")),
138-
).toBe(true);
139133
expect(normalized).toContain("OpenClaw");
140134
expect(normalized).toContain("Model: anthropic/test:opus");
141135
expect(normalized).toContain("api-key");

src/status/status-message.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,6 @@ type AgentConfig = Partial<AgentDefaults> & {
6969
model?: AgentDefaults["model"] | string;
7070
};
7171

72-
// Status rows are intentional layout, not GFM soft-wrapped prose.
73-
const STATUS_MARKDOWN_LINE_BREAK = " \n";
74-
7572
export const formatTokenCount = formatTokenCountShared;
7673

7774
type QueueStatus = {
@@ -1053,6 +1050,5 @@ export function buildStatusMessage(args: StatusArgs): string {
10531050
activationLine,
10541051
]
10551052
.filter((line): line is string => Boolean(line))
1056-
.flatMap((line) => line.split("\n"))
1057-
.join(STATUS_MARKDOWN_LINE_BREAK);
1053+
.join("\n");
10581054
}

0 commit comments

Comments
 (0)