From 1bdf5d39cbaf859180cce87ca54bb8449dbddec9 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:30:08 +0000 Subject: [PATCH 1/5] fix(slack): Convert canvas HTML to Markdown on read Slack serves canvas downloads as Quip HTML. slackCanvasEdit wrote that HTML back as Markdown, so Slack rendered the whole canvas as escaped HTML text. Convert the body to Markdown in readCanvas so reads and edits both work on Markdown. --- .../src/chat/slack/tool-support/canvas/api.ts | 11 +++--- .../chat/slack/tool-support/canvas/html.ts | 27 +++++++++++++ .../integration/slack-canvas-edit.test.ts | 39 +++++++++++++++++++ 3 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 packages/junior/src/chat/slack/tool-support/canvas/html.ts 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..526f0ee970 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, @@ -242,7 +243,7 @@ export async function readCanvas( permalink: file.permalink, mimetype: file.mimetype, filetype: file.filetype, - content: buffer.toString("utf-8"), + content: canvasBodyToMarkdown(buffer.toString("utf-8")), 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..2d3882299e --- /dev/null +++ b/packages/junior/src/chat/slack/tool-support/canvas/html.ts @@ -0,0 +1,27 @@ +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") ?? ""})`, + }), + }, +); + +/** + * 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): string { + if (!body.trimStart().startsWith("<")) { + return body; + } + return canvasHtmlConverter.translate(body).trim(); +} diff --git a/packages/junior/tests/integration/slack-canvas-edit.test.ts b/packages/junior/tests/integration/slack-canvas-edit.test.ts index bba36d70fe..9ee6ea7ce3 100644 --- a/packages/junior/tests/integration/slack-canvas-edit.test.ts +++ b/packages/junior/tests/integration/slack-canvas-edit.test.ts @@ -84,6 +84,45 @@ 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", + '

Rows

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

Old summary

', + ); + 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\nNew summary", + }, + }, + ], + }); + }); + it("applies multiple edits against the original canvas content in one write", async () => { queueCanvasRead( "F0PREVIOUS", From 1a211ffae816bb0e85e5f08b26be5a2c816d85dc Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:32:08 +0000 Subject: [PATCH 2/5] fix(slack): Drop the canvas title heading on read Slack keeps the title h1 across a body replace, so an edit that wrote it back would show the title twice. --- .../junior/src/chat/slack/tool-support/canvas/api.ts | 5 +++-- .../junior/src/chat/slack/tool-support/canvas/html.ts | 11 +++++++++-- .../tests/integration/slack-canvas-edit.test.ts | 3 ++- 3 files changed, 14 insertions(+), 5 deletions(-) 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 526f0ee970..703985309c 100644 --- a/packages/junior/src/chat/slack/tool-support/canvas/api.ts +++ b/packages/junior/src/chat/slack/tool-support/canvas/api.ts @@ -237,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: canvasBodyToMarkdown(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 index 2d3882299e..b437986736 100644 --- a/packages/junior/src/chat/slack/tool-support/canvas/html.ts +++ b/packages/junior/src/chat/slack/tool-support/canvas/html.ts @@ -19,9 +19,16 @@ const canvasHtmlConverter = new NodeHtmlMarkdown( * 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): string { +export function canvasBodyToMarkdown(body: string, title?: string): string { if (!body.trimStart().startsWith("<")) { return body; } - return canvasHtmlConverter.translate(body).trim(); + 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. + const titleHeading = title ? `# ${title}` : undefined; + if (titleHeading && markdown.split("\n", 1)[0] === titleHeading) { + return markdown.slice(titleHeading.length).trimStart(); + } + return markdown; } diff --git a/packages/junior/tests/integration/slack-canvas-edit.test.ts b/packages/junior/tests/integration/slack-canvas-edit.test.ts index 9ee6ea7ce3..18d25b6489 100644 --- a/packages/junior/tests/integration/slack-canvas-edit.test.ts +++ b/packages/junior/tests/integration/slack-canvas-edit.test.ts @@ -87,7 +87,8 @@ describe("Slack canvas file-like tools", () => { it("edits a canvas served as Quip HTML and writes Markdown back", async () => { queueCanvasRead( "F0PREVIOUS", - '

Rows

' + + '

Reference Canvas

' + + '

Rows

' + '
  • ' + 'NET-625' + " — Docs fix
" + From aed0bb28f6b03c6408fb6defe8f918fe191176c7 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:41:28 +0000 Subject: [PATCH 3/5] fix(slack): Match escaped canvas titles when dropping the title heading --- .../src/chat/slack/tool-support/canvas/html.ts | 12 +++++++++++- .../tests/integration/slack-canvas-edit.test.ts | 11 ++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/junior/src/chat/slack/tool-support/canvas/html.ts b/packages/junior/src/chat/slack/tool-support/canvas/html.ts index b437986736..2f953c6d4c 100644 --- a/packages/junior/src/chat/slack/tool-support/canvas/html.ts +++ b/packages/junior/src/chat/slack/tool-support/canvas/html.ts @@ -26,9 +26,19 @@ export function canvasBodyToMarkdown(body: string, title?: string): string { 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. - const titleHeading = title ? `# ${title}` : undefined; + // 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 18d25b6489..8ddc547661 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`, }), @@ -87,12 +91,13 @@ describe("Slack canvas file-like tools", () => { it("edits a canvas served as Quip HTML and writes Markdown back", async () => { queueCanvasRead( "F0PREVIOUS", - '

Reference Canvas

' + + '

Q4 [draft] notes_v1 & more

' + '

Rows

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

Old summary

', + "Q4 [draft] notes_v1 & more", ); queueSlackApiResponse("canvases.edit", { body: canvasesEditOk(), From 06a6efd1015279af53db2e48accfa1923a40ad84 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:29:58 +0000 Subject: [PATCH 4/5] fix(slack): Detect canvas HTML by a leading start tag --- packages/junior/src/chat/slack/tool-support/canvas/html.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/junior/src/chat/slack/tool-support/canvas/html.ts b/packages/junior/src/chat/slack/tool-support/canvas/html.ts index 2f953c6d4c..796df8d6cc 100644 --- a/packages/junior/src/chat/slack/tool-support/canvas/html.ts +++ b/packages/junior/src/chat/slack/tool-support/canvas/html.ts @@ -15,12 +15,15 @@ const canvasHtmlConverter = new NodeHtmlMarkdown( }, ); +// 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 (!body.trimStart().startsWith("<")) { + if (!leadingHtmlTag.test(body)) { return body; } const markdown = canvasHtmlConverter.translate(body).trim(); From e8020f29251232d6fa5b1e8feefae6184f7ca4ce Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:40:18 +0000 Subject: [PATCH 5/5] fix(slack): Keep canvas checklist state on read --- .../src/chat/slack/tool-support/canvas/html.ts | 16 ++++++++++++++++ .../tests/integration/slack-canvas-edit.test.ts | 5 ++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/junior/src/chat/slack/tool-support/canvas/html.ts b/packages/junior/src/chat/slack/tool-support/canvas/html.ts index 796df8d6cc..318be01810 100644 --- a/packages/junior/src/chat/slack/tool-support/canvas/html.ts +++ b/packages/junior/src/chat/slack/tool-support/canvas/html.ts @@ -15,6 +15,22 @@ const canvasHtmlConverter = new NodeHtmlMarkdown( }, ); +// 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; diff --git a/packages/junior/tests/integration/slack-canvas-edit.test.ts b/packages/junior/tests/integration/slack-canvas-edit.test.ts index 8ddc547661..956cd5371d 100644 --- a/packages/junior/tests/integration/slack-canvas-edit.test.ts +++ b/packages/junior/tests/integration/slack-canvas-edit.test.ts @@ -96,6 +96,9 @@ describe("Slack canvas file-like tools", () => { '
  • ' + 'NET-625' + " — Docs fix
" + + '
    ' + + '
  • Done
  • ' + + '
  • Open
' + '

Old summary

', "Q4 [draft] notes_v1 & more", ); @@ -122,7 +125,7 @@ describe("Slack canvas file-like tools", () => { { document_content: { markdown: - "## Rows\n\n- [NET-625](https://linear.app/getsentry/issue/NET-625) — `Docs` fix\n\nNew summary", + "## Rows\n\n- [NET-625](https://linear.app/getsentry/issue/NET-625) — `Docs` fix\n\n- [x] Done\n- [ ] Open\n\nNew summary", }, }, ],