Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions packages/junior/src/chat/slack/tool-support/canvas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -190,10 +191,10 @@ function isCanvasFile(file: NonNullable<FilesInfoResponse["file"]>): 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,
Expand Down Expand Up @@ -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,
};
}
63 changes: 63 additions & 0 deletions packages/junior/src/chat/slack/tool-support/canvas/html.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { NodeHtmlMarkdown } from "node-html-markdown";

// Slack serves canvas downloads as Quip HTML, with links as `<lnk href>`.
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 `<https://...>`.
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(`<h1>${escapeHtml(title)}</h1>`).trim()
: undefined;
if (titleHeading && markdown.split("\n", 1)[0] === titleHeading) {
return markdown.slice(titleHeading.length).trimStart();
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}
return markdown;
Comment thread
sentry-junior[bot] marked this conversation as resolved.
Resolved
}

function escapeHtml(text: string): string {
return text
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
52 changes: 50 additions & 2 deletions packages/junior/tests/integration/slack-canvas-edit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
}),
Expand Down Expand Up @@ -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",
'<div class="quip-canvas-content"><h1 id="temp:C:T">Q4 [draft] notes_v1 &amp; more</h1>' +
'<h2 id="temp:C:A">Rows</h2>' +
'<div data-section-style="5"><ul id="temp:C:B"><li id="temp:C:C">' +
'<span><lnk href="https://linear.app/getsentry/issue/NET-625">NET-625</lnk>' +
" — <code>Docs</code> fix</span><br/></li></ul></div>" +
'<div data-section-style="7"><ul id="temp:C:E">' +
'<li id="temp:C:F" class="checked"><span>Done</span><br/></li>' +
'<li id="temp:C:G" class=""><span>Open</span><br/></li></ul></div>' +
'<p id="temp:C:D" class="line">Old summary</p></div>',
"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",
Expand Down
Loading