diff --git a/apps/server/src/routes/slack/handler.ts b/apps/server/src/routes/slack/handler.ts index 4420e584..9b52f879 100644 --- a/apps/server/src/routes/slack/handler.ts +++ b/apps/server/src/routes/slack/handler.ts @@ -14,6 +14,7 @@ import { import { findByThread, replace, store } from "./confirmation-store"; import type { PendingPayload } from "./confirmation-store"; import { publishHomeView } from "./home"; +import { toMrkdwn } from "./mrkdwn"; import { getComponentNames, getPageDashboardLink } from "./page-urls"; import { getRegistryTool, isSlackToolDraft } from "./registry-runner"; import { resolveWorkspace } from "./workspace-resolver"; @@ -349,7 +350,7 @@ async function processEvent(body: SlackEvent) { await slack.chat.update({ channel: event.channel, ts: thinkingTs, - text: result.text || "Done!", + text: result.text ? toMrkdwn(result.text) : "Done!", }); logger.info("slack response sent", { teamId, diff --git a/apps/server/src/routes/slack/mrkdwn.test.ts b/apps/server/src/routes/slack/mrkdwn.test.ts new file mode 100644 index 00000000..3e00a772 --- /dev/null +++ b/apps/server/src/routes/slack/mrkdwn.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "@openstatus/test-utils"; + +import { toMrkdwn } from "./mrkdwn"; + +describe("toMrkdwn", () => { + test("converts bold to single asterisks", () => { + expect( + toMrkdwn( + "Which component is affected — **test-slack**, **Test**, or both?", + ), + ).toBe("Which component is affected — *test-slack*, *Test*, or both?"); + }); + + test("converts underscore bold", () => { + expect(toMrkdwn("__Title:__ Server Outage")).toBe("*Title:* Server Outage"); + }); + + test("converts bold italic", () => { + expect(toMrkdwn("***urgent***")).toBe("*_urgent_*"); + }); + + test("leaves slack bold and italic untouched", () => { + expect(toMrkdwn("*bold* and _italic_")).toBe("*bold* and _italic_"); + }); + + test("converts headings to bold lines", () => { + expect(toMrkdwn("## Status Report\nbody")).toBe("*Status Report*\nbody"); + }); + + test("converts markdown links", () => { + expect(toMrkdwn("See [status page](https://x.com/p) for details")).toBe( + "See for details", + ); + }); + + test("escapes pipes in link labels", () => { + expect(toMrkdwn("[a|b](https://x.com)")).toBe(""); + }); + + test("drops empty link labels", () => { + expect(toMrkdwn("[](https://x.com)")).toBe(""); + }); + + test("converts bullets", () => { + expect(toMrkdwn("- one\n- two\n * nested")).toBe( + "• one\n• two\n • nested", + ); + }); + + test("converts strikethrough", () => { + expect(toMrkdwn("~~resolved~~")).toBe("~resolved~"); + }); + + test("leaves inline code untouched", () => { + expect(toMrkdwn("run `npm i --save **x**` now")).toBe( + "run `npm i --save **x**` now", + ); + }); + + test("leaves fenced code untouched", () => { + const input = "before **b**\n```\n# heading\n- item\n```\nafter **b**"; + expect(toMrkdwn(input)).toBe( + "before *b*\n```\n# heading\n- item\n```\nafter *b*", + ); + }); + + test("passes through empty text", () => { + expect(toMrkdwn("")).toBe(""); + }); + + test("leaves plain text unchanged", () => { + const text = "Got the page and components. Which one is affected?"; + expect(toMrkdwn(text)).toBe(text); + }); +}); diff --git a/apps/server/src/routes/slack/mrkdwn.ts b/apps/server/src/routes/slack/mrkdwn.ts new file mode 100644 index 00000000..eef9f43d --- /dev/null +++ b/apps/server/src/routes/slack/mrkdwn.ts @@ -0,0 +1,43 @@ +/** + * The model writes standard markdown (`**bold**`, `[text](url)`, `### head`) + * even when told to use Slack mrkdwn, and Slack renders those literally. + * Convert its free-text replies before posting. + */ + +const CODE_SPAN = /```[\s\S]*?```|`[^`\n]+`/g; +const LINK = /!?\[([^\]]*)\]\(\s*?(?:\s+"[^"]*")?\s*\)/g; +const BOLD_ITALIC = /\*\*\*(?=\S)([\s\S]*?\S)\*\*\*/g; +const BOLD = /(?:\*\*|__)(?=\S)([\s\S]*?\S)(?:\*\*|__)/g; +const STRIKETHROUGH = /~~(?=\S)([\s\S]*?\S)~~/g; +const HEADING = /^ {0,3}#{1,6}[ \t]+(.+?)[ \t]*#*$/gm; +const BULLET = /^([ \t]*)[-*+][ \t]+/gm; + +/** `|` would terminate a `` link, so swap it for a lookalike. */ +function linkLabel(text: string): string { + return text.replace(/\|/g, "❘"); +} + +function convert(text: string): string { + return text + .replace(LINK, (_match, label: string, url: string) => + label.trim() ? `<${url}|${linkLabel(label.trim())}>` : `<${url}>`, + ) + .replace(BOLD_ITALIC, "*_$1_*") + .replace(BOLD, "*$1*") + .replace(STRIKETHROUGH, "~$1~") + .replace(HEADING, "*$1*") + .replace(BULLET, "$1• "); +} + +/** Convert markdown in an agent reply to Slack mrkdwn, leaving code spans untouched. */ +export function toMrkdwn(text: string): string { + if (!text) return text; + + let out = ""; + let cursor = 0; + for (const match of text.matchAll(CODE_SPAN)) { + out += convert(text.slice(cursor, match.index)) + match[0]; + cursor = match.index + match[0].length; + } + return out + convert(text.slice(cursor)); +}