diff --git a/packages/junior/src/chat/slack/tool-support/canvas/api.ts b/packages/junior/src/chat/slack/tool-support/canvas/api.ts index f5357a3cb8..703985309c 100644 --- a/packages/junior/src/chat/slack/tool-support/canvas/api.ts +++ b/packages/junior/src/chat/slack/tool-support/canvas/api.ts @@ -9,6 +9,7 @@ import { withSlackRetries, } from "@/chat/slack/client"; import { extractCanvasId } from "@/chat/slack/canvas-references"; +import { canvasBodyToMarkdown } from "@/chat/slack/tool-support/canvas/html"; import { normalizeCanvasMarkdown, type CanvasMarkdownNormalization, @@ -190,10 +191,10 @@ function isCanvasFile(file: NonNullable): boolean { } /** - * Read a Slack canvas the bot has access to and return its raw downloadable - * content. Slack does not expose a structured canvas-read API, so we fetch - * file metadata via `files.info` and download the canvas body via the private - * file URL with the bot token. + * Read a Slack canvas the bot has access to and return its body as Markdown. + * Slack does not expose a structured canvas-read API, so we fetch file + * metadata via `files.info` and download the canvas body via the private file + * URL with the bot token. */ export async function readCanvas( canvasIdOrUrl: string, @@ -236,13 +237,14 @@ export async function readCanvas( } const buffer = await downloadPrivateSlackFile(downloadUrl); + const title = file.title ?? file.name; return { canvasId, - title: file.title ?? file.name, + title, permalink: file.permalink, mimetype: file.mimetype, filetype: file.filetype, - content: buffer.toString("utf-8"), + content: canvasBodyToMarkdown(buffer.toString("utf-8"), title), byteLength: buffer.byteLength, }; } diff --git a/packages/junior/src/chat/slack/tool-support/canvas/html.ts b/packages/junior/src/chat/slack/tool-support/canvas/html.ts new file mode 100644 index 0000000000..318be01810 --- /dev/null +++ b/packages/junior/src/chat/slack/tool-support/canvas/html.ts @@ -0,0 +1,63 @@ +import { NodeHtmlMarkdown } from "node-html-markdown"; + +// Slack serves canvas downloads as Quip HTML, with links as ``. +const canvasHtmlConverter = new NodeHtmlMarkdown( + { + bulletMarker: "-", + codeBlockStyle: "fenced", + maxConsecutiveNewlines: 2, + }, + { + lnk: ({ node }) => ({ + prefix: "[", + postfix: `](${node.getAttribute("href") ?? ""})`, + }), + }, +); + +// Quip marks a checklist as `data-section-style="7"` and a done item with the +// `checked` class. Canvas writes take `- [ ]` and `- [x]`. +const listItemTranslator = canvasHtmlConverter.translators.get("li"); +canvasHtmlConverter.translators.set("li", (context) => { + const config = + typeof listItemTranslator === "function" + ? listItemTranslator(context) + : listItemTranslator; + const section = context.node.closest("[data-section-style]"); + if (section?.getAttribute("data-section-style") !== "7") { + return config; + } + const checkbox = context.node.classList.contains("checked") ? "[x] " : "[ ] "; + return { ...config, prefix: `${config?.prefix ?? ""}${checkbox}` }; +}); + +// A leading HTML start tag, not a Markdown autolink such as ``. +const leadingHtmlTag = /^\s*<[a-z][a-z0-9-]*[\s/>]/i; + +/** + * Convert a downloaded canvas body to Markdown. Canvas writes take Markdown, + * so an HTML body passed back unchanged renders as escaped HTML text. + */ +export function canvasBodyToMarkdown(body: string, title?: string): string { + if (!leadingHtmlTag.test(body)) { + return body; + } + const markdown = canvasHtmlConverter.translate(body).trim(); + // The export starts with the canvas title as an `h1`. Slack keeps that + // heading across a body replace, so writing it back would show it twice. + // Convert the title the same way so Markdown escapes match. + const titleHeading = title + ? canvasHtmlConverter.translate(`

${escapeHtml(title)}

`).trim() + : undefined; + if (titleHeading && markdown.split("\n", 1)[0] === titleHeading) { + return markdown.slice(titleHeading.length).trimStart(); + } + return markdown; +} + +function escapeHtml(text: string): string { + return text + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} diff --git a/packages/junior/tests/integration/slack-canvas-edit.test.ts b/packages/junior/tests/integration/slack-canvas-edit.test.ts index bba36d70fe..956cd5371d 100644 --- a/packages/junior/tests/integration/slack-canvas-edit.test.ts +++ b/packages/junior/tests/integration/slack-canvas-edit.test.ts @@ -21,11 +21,15 @@ function createState(): ToolState { }; } -function queueCanvasRead(canvasId: string, body: string): void { +function queueCanvasRead( + canvasId: string, + body: string, + title = "Reference Canvas", +): void { queueSlackApiResponse("files.info", { body: filesInfoOk({ fileId: canvasId, - title: "Reference Canvas", + title, permalink: `https://sentry.slack.com/docs/T000/${canvasId}`, urlPrivate: `https://files.slack.com/files-pri/T000-${canvasId}/canvas.md`, }), @@ -84,6 +88,50 @@ describe("Slack canvas file-like tools", () => { expect((result as { diff: string }).diff).toContain("+3 New summary"); }); + it("edits a canvas served as Quip HTML and writes Markdown back", async () => { + queueCanvasRead( + "F0PREVIOUS", + '

Q4 [draft] notes_v1 & more

' + + '

Rows

' + + '
  • ' + + 'NET-625' + + " — Docs fix
" + + '
    ' + + '
  • Done
  • ' + + '
  • Open
' + + '

Old summary

', + "Q4 [draft] notes_v1 & more", + ); + queueSlackApiResponse("canvases.edit", { + body: canvasesEditOk(), + }); + const tool = createSlackCanvasEditTool(createState()); + + if (typeof tool.execute !== "function") { + throw new Error("slackCanvasEdit execute function missing"); + } + + await tool.execute( + { + canvas: "F0PREVIOUS", + edits: [{ old_text: "Old summary", new_text: "New summary" }], + }, + {} as never, + ); + + const editCalls = getCapturedSlackApiCalls("canvases.edit"); + expect(editCalls[0]?.params).toMatchObject({ + changes: [ + { + document_content: { + markdown: + "## Rows\n\n- [NET-625](https://linear.app/getsentry/issue/NET-625) — `Docs` fix\n\n- [x] Done\n- [ ] Open\n\nNew summary", + }, + }, + ], + }); + }); + it("applies multiple edits against the original canvas content in one write", async () => { queueCanvasRead( "F0PREVIOUS",