mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-13 07:36:06 +00:00
fix(telegram): preserve rich markdown line breaks
This commit is contained in:
@@ -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`. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ", {
|
||||
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 },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user