diff --git a/docs/channels/slack.md b/docs/channels/slack.md index cc11d9395..f14255e4d 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -199,7 +199,9 @@ user scopes if you plan to configure a user token. "emoji:read", "commands", "files:read", - "files:write" + "files:write", + "canvases:read", + "canvases:write" ], "user": [ "channels:history", @@ -268,10 +270,14 @@ https://docs.slack.dev/apis/web-api/using-the-conversations-api/ for the overvie https://docs.slack.dev/reference/scopes/emoji.read - `files:write` (uploads via `files.uploadV2`) https://docs.slack.dev/messaging/working-with-files/#upload +- `files:read` (download canvas file content via `url_private_download`) +- `canvases:read`, `canvases:write` (create/update canvases) ### User token scopes (optional, read-only by default) Add these under **User Token Scopes** if you configure `channels.slack.userToken`. +**Canvas ingestion notes:** When a message includes a Slack Canvas, Clawdbot attempts to download the canvas file via `files.info` + `url_private_download`. This requires `files:read` and the bot must have access to the canvas file. Missing scopes will show a short diagnostic in the prompt context. + - `channels:history`, `groups:history`, `im:history`, `mpim:history` - `channels:read`, `groups:read`, `im:read`, `mpim:read` - `users:read` @@ -325,7 +331,8 @@ Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens: "messages": true, "pins": true, "memberInfo": true, - "emojiList": true + "emojiList": true, + "canvases": false }, "slashCommand": { "enabled": true, @@ -334,7 +341,9 @@ Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens: "ephemeral": true }, "textChunkLimit": 4000, - "mediaMaxMb": 20 + "mediaMaxMb": 20, + "canvasMaxMb": 2, + "canvasTextMaxChars": 20000 } } ``` @@ -351,6 +360,7 @@ ack reaction after the bot replies. - Outbound text is chunked to `channels.slack.textChunkLimit` (default 4000). - Optional newline chunking: set `channels.slack.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking. - Media uploads are capped by `channels.slack.mediaMaxMb` (default 20). +- Canvas downloads are capped by `channels.slack.canvasMaxMb` (default 2) and truncated to `channels.slack.canvasTextMaxChars` characters (default 20000). ## Reply threading By default, OpenClaw replies in the main channel. Use `channels.slack.replyToMode` to control automatic threading: diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 5a00ea9cd..88c9bd7c1 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1251,6 +1251,7 @@ Slack action groups (gate `slack` tool actions): | pins | enabled | Pin/unpin/list | | memberInfo | enabled | Member info | | emojiList | enabled | Custom emoji list | +| canvases | disabled | Create/update canvases | ### `channels.mattermost` (bot token) diff --git a/src/agents/tools/slack-actions.test.ts b/src/agents/tools/slack-actions.test.ts index a8ac103d1..1bc38140c 100644 --- a/src/agents/tools/slack-actions.test.ts +++ b/src/agents/tools/slack-actions.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { handleSlackAction } from "./slack-actions.js"; +const createSlackCanvas = vi.fn(async () => ({})); const deleteSlackMessage = vi.fn(async () => ({})); const editSlackMessage = vi.fn(async () => ({})); const getSlackMemberInfo = vi.fn(async () => ({})); @@ -16,8 +17,10 @@ const removeOwnSlackReactions = vi.fn(async () => ["thumbsup"]); const removeSlackReaction = vi.fn(async () => ({})); const sendSlackMessage = vi.fn(async () => ({})); const unpinSlackMessage = vi.fn(async () => ({})); +const updateSlackCanvas = vi.fn(async () => ({})); vi.mock("../../slack/actions.js", () => ({ + createSlackCanvas: (...args: unknown[]) => createSlackCanvas(...args), deleteSlackMessage: (...args: unknown[]) => deleteSlackMessage(...args), editSlackMessage: (...args: unknown[]) => editSlackMessage(...args), getSlackMemberInfo: (...args: unknown[]) => getSlackMemberInfo(...args), @@ -31,6 +34,7 @@ vi.mock("../../slack/actions.js", () => ({ removeSlackReaction: (...args: unknown[]) => removeSlackReaction(...args), sendSlackMessage: (...args: unknown[]) => sendSlackMessage(...args), unpinSlackMessage: (...args: unknown[]) => unpinSlackMessage(...args), + updateSlackCanvas: (...args: unknown[]) => updateSlackCanvas(...args), })); describe("handleSlackAction", () => { @@ -124,6 +128,35 @@ describe("handleSlackAction", () => { ).rejects.toThrow(/Slack reactions are disabled/); }); + it("requires canvases to be enabled", async () => { + const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig; + await expect( + handleSlackAction({ action: "createCanvas", title: "Test", content: "hello" }, cfg), + ).rejects.toThrow(/Slack canvases are disabled/); + }); + + it("creates a canvas when enabled", async () => { + const cfg = { + channels: { slack: { botToken: "tok", actions: { canvases: true } } }, + } as ClawdbotConfig; + await handleSlackAction({ action: "createCanvas", title: "Test", content: "hello" }, cfg); + expect(createSlackCanvas).toHaveBeenCalledWith( + { title: "Test", content: "hello", channelId: undefined }, + undefined, + ); + }); + + it("updates a canvas by id", async () => { + const cfg = { + channels: { slack: { botToken: "tok", actions: { canvases: true } } }, + } as ClawdbotConfig; + await handleSlackAction({ action: "updateCanvas", canvasId: "C123", content: "update" }, cfg); + expect(updateSlackCanvas).toHaveBeenCalledWith( + { canvasId: "C123", channelId: undefined, content: "update", updateMode: undefined }, + undefined, + ); + }); + it("passes threadTs to sendSlackMessage for thread replies", async () => { const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; await handleSlackAction( diff --git a/src/agents/tools/slack-actions.ts b/src/agents/tools/slack-actions.ts index 3bb8ea717..800411cf7 100644 --- a/src/agents/tools/slack-actions.ts +++ b/src/agents/tools/slack-actions.ts @@ -3,6 +3,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { OpenClawConfig } from "../../config/config.js"; import { resolveSlackAccount } from "../../slack/accounts.js"; import { + createSlackCanvas, deleteSlackMessage, editSlackMessage, getSlackMemberInfo, @@ -16,6 +17,7 @@ import { removeSlackReaction, sendSlackMessage, unpinSlackMessage, + updateSlackCanvas, } from "../../slack/actions.js"; import { parseSlackTarget, resolveSlackChannelId } from "../../slack/targets.js"; import { withNormalizedTimestamp } from "../date-time.js"; @@ -25,6 +27,7 @@ const messagingActions = new Set(["sendMessage", "editMessage", "deleteMessage", const reactionsActions = new Set(["react", "reactions"]); const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]); +const canvasActions = new Set(["createCanvas", "updateCanvas"]); export type SlackActionContext = { /** Current channel ID for auto-threading. */ @@ -71,6 +74,29 @@ function resolveThreadTsFromContext( return undefined; } +function formatSlackCanvasError(err: unknown): string { + const data = err as { data?: { error?: string; needed?: string | string[] } } | undefined; + const error = data?.data?.error ?? (err as { error?: string } | undefined)?.error; + if (error === "missing_scope") { + const needed = data?.data?.needed; + const scopes = Array.isArray(needed) ? needed.join(", ") : needed; + return scopes + ? `Slack canvas action failed (missing_scope: ${scopes}). Add scopes and reinstall the Slack app.` + : "Slack canvas action failed (missing_scope). Add scopes and reinstall the Slack app."; + } + if (error === "not_allowed_token_type") { + return "Slack canvas action failed (not_allowed_token_type). Use a bot token with canvases:write."; + } + if (error === "not_in_channel") { + return "Slack canvas action failed (not_in_channel). Invite the bot to the channel and retry."; + } + if (error === "channel_not_found") { + return "Slack canvas action failed (channel_not_found)."; + } + if (error && typeof error === "string") return `Slack canvas action failed (${error}).`; + return `Slack canvas action failed (${String(err)}).`; +} + export async function handleSlackAction( params: Record, cfg: OpenClawConfig, @@ -277,6 +303,49 @@ export async function handleSlackAction( return jsonResult({ ok: true, pins: normalizedPins }); } + if (canvasActions.has(action)) { + if (!isActionEnabled("canvases", false)) { + throw new Error("Slack canvases are disabled."); + } + if (action === "createCanvas") { + const title = readStringParam(params, "title", { required: true }); + const content = readStringParam(params, "content", { required: true, allowEmpty: true }); + const channelIdRaw = readStringParam(params, "channelId"); + const channelId = channelIdRaw ? resolveSlackChannelId(channelIdRaw) : undefined; + try { + const result = await createSlackCanvas( + { title, content, channelId }, + writeOpts ?? undefined, + ); + return jsonResult({ ok: true, result }); + } catch (err) { + throw new Error(formatSlackCanvasError(err)); + } + } + const content = readStringParam(params, "content", { required: true, allowEmpty: true }); + const canvasId = readStringParam(params, "canvasId"); + const channelIdRaw = readStringParam(params, "channelId"); + const channelId = channelIdRaw ? resolveSlackChannelId(channelIdRaw) : undefined; + if (!canvasId && !channelId) { + throw new Error("canvasId or channelId required for Slack canvas updates"); + } + const updateMode = readStringParam(params, "updateMode"); + try { + const result = await updateSlackCanvas( + { + canvasId: canvasId ?? undefined, + channelId, + content, + updateMode: updateMode ?? undefined, + }, + writeOpts ?? undefined, + ); + return jsonResult({ ok: true, result }); + } catch (err) { + throw new Error(formatSlackCanvasError(err)); + } + } + if (action === "memberInfo") { if (!isActionEnabled("memberInfo")) { throw new Error("Slack member info is disabled."); diff --git a/src/channels/plugins/message-action-names.ts b/src/channels/plugins/message-action-names.ts index 1884cacb0..8f3815e33 100644 --- a/src/channels/plugins/message-action-names.ts +++ b/src/channels/plugins/message-action-names.ts @@ -48,6 +48,8 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [ "timeout", "kick", "ban", + "canvas-create", + "canvas-update", ] as const; export type ChannelMessageActionName = (typeof CHANNEL_MESSAGE_ACTION_NAMES)[number]; diff --git a/src/channels/plugins/slack.actions.ts b/src/channels/plugins/slack.actions.ts index ca8aa6fb8..4df25e634 100644 --- a/src/channels/plugins/slack.actions.ts +++ b/src/channels/plugins/slack.actions.ts @@ -46,6 +46,10 @@ export function createSlackActions(providerId: string): ChannelMessageActionAdap } if (isActionEnabled("memberInfo")) actions.add("member-info"); if (isActionEnabled("emojiList")) actions.add("emoji-list"); + if (isActionEnabled("canvases", false)) { + actions.add("canvas-create"); + actions.add("canvas-update"); + } return Array.from(actions); }, extractToolSend: ({ args }): ChannelToolSend | null => { @@ -204,6 +208,40 @@ export function createSlackActions(providerId: string): ChannelMessageActionAdap ); } + if (action === "canvas-create") { + const title = readStringParam(params, "title", { required: true }); + const content = readStringParam(params, "content", { required: true, allowEmpty: true }); + const channelId = readStringParam(params, "channelId"); + return await handleSlackAction( + { + action: "createCanvas", + title, + content, + channelId: channelId ?? undefined, + accountId: accountId ?? undefined, + }, + cfg, + ); + } + + if (action === "canvas-update") { + const content = readStringParam(params, "content", { required: true, allowEmpty: true }); + const canvasId = readStringParam(params, "canvasId"); + const channelId = readStringParam(params, "channelId"); + const updateMode = readStringParam(params, "updateMode"); + return await handleSlackAction( + { + action: "updateCanvas", + canvasId: canvasId ?? undefined, + channelId: channelId ?? undefined, + updateMode: updateMode ?? undefined, + content, + accountId: accountId ?? undefined, + }, + cfg, + ); + } + throw new Error(`Action ${action} is not supported for provider ${providerId}.`); }, }; diff --git a/src/config/schema.ts b/src/config/schema.ts index 1401b0574..a684e6ef3 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -469,6 +469,10 @@ const FIELD_HELP: Record = { 'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).', "channels.slack.thread.inheritParent": "If true, Slack thread sessions inherit the parent channel transcript (default: false).", + "channels.slack.canvasMaxMb": "Max MB to download for Slack canvases (default: 2).", + "channels.slack.canvasTextMaxChars": + "Max characters of Slack canvas content injected into prompts (default: 20000).", + "channels.slack.actions.canvases": "Enable Slack canvas actions (default: false).", "channels.mattermost.botToken": "Bot token from Mattermost System Console -> Integrations -> Bot Accounts.", "channels.mattermost.baseUrl": diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index cbf912e80..352c49809 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -55,6 +55,7 @@ export type SlackActionConfig = { memberInfo?: boolean; channelInfo?: boolean; emojiList?: boolean; + canvases?: boolean; }; export type SlackSlashCommandConfig = { @@ -123,6 +124,10 @@ export type SlackAccountConfig = { /** Merge streamed block replies before sending. */ blockStreamingCoalesce?: BlockStreamingCoalesceConfig; mediaMaxMb?: number; + /** Max size for downloaded Slack canvas content (MB). Default: 2. */ + canvasMaxMb?: number; + /** Max characters of Slack canvas text injected into prompt (default: 20000). */ + canvasTextMaxChars?: number; /** Reaction notification mode (off|own|all|allowlist). Default: own. */ reactionNotifications?: SlackReactionNotificationMode; /** Allowlist for reaction notifications when mode is allowlist. */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index ed7dda22a..97224d891 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -429,6 +429,8 @@ export const SlackAccountSchema = z blockStreaming: z.boolean().optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), mediaMaxMb: z.number().positive().optional(), + canvasMaxMb: z.number().positive().optional(), + canvasTextMaxChars: z.number().int().positive().optional(), reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(), reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(), replyToMode: ReplyToModeSchema.optional(), @@ -444,6 +446,7 @@ export const SlackAccountSchema = z memberInfo: z.boolean().optional(), channelInfo: z.boolean().optional(), emojiList: z.boolean().optional(), + canvases: z.boolean().optional(), }) .strict() .optional(), diff --git a/src/slack/actions.ts b/src/slack/actions.ts index 4ce6b37ca..3428e80a4 100644 --- a/src/slack/actions.ts +++ b/src/slack/actions.ts @@ -258,3 +258,49 @@ export async function listSlackPins( const result = await client.pins.list({ channel: channelId }); return (result.items ?? []) as SlackPin[]; } + +type SlackCanvasCreatePayload = { + title: string; + document_content: string; + channel_id?: string; +}; + +type SlackCanvasEditPayload = { + canvas_id?: string; + channel_id?: string; + document_content: string; + mode?: string; +}; + +export async function createSlackCanvas( + params: { title: string; content: string; channelId?: string }, + opts: SlackActionClientOpts = {}, +) { + const client = await getClient(opts); + const payload: SlackCanvasCreatePayload = { + title: params.title, + document_content: params.content, + ...(params.channelId ? { channel_id: params.channelId } : {}), + }; + if (params.channelId) { + return await client.apiCall("conversations.canvases.create", payload); + } + return await client.apiCall("canvases.create", payload); +} + +export async function updateSlackCanvas( + params: { canvasId?: string; channelId?: string; content: string; updateMode?: string }, + opts: SlackActionClientOpts = {}, +) { + const client = await getClient(opts); + const payload: SlackCanvasEditPayload = { + ...(params.canvasId ? { canvas_id: params.canvasId } : {}), + ...(params.channelId ? { channel_id: params.channelId } : {}), + document_content: params.content, + ...(params.updateMode ? { mode: params.updateMode } : {}), + }; + if (params.channelId && !params.canvasId) { + return await client.apiCall("conversations.canvases.edit", payload); + } + return await client.apiCall("canvases.edit", payload); +} diff --git a/src/slack/canvases.test.ts b/src/slack/canvases.test.ts new file mode 100644 index 000000000..50e657f07 --- /dev/null +++ b/src/slack/canvases.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { WebClient } from "@slack/web-api"; + +import { + extractCanvasRefsFromEvent, + fetchSlackCanvasContent, + isSlackCanvasFile, +} from "./canvases.js"; +import type { SlackMessageEvent } from "./types.js"; + +function createClient() { + return { + files: { + info: vi.fn(async () => ({ + file: { + id: "F123", + title: "Canvas Title", + url_private_download: "https://files.slack.com/canvas.json", + }, + })), + }, + } as unknown as WebClient; +} + +describe("isSlackCanvasFile", () => { + it("detects canvas mimetype", () => { + expect(isSlackCanvasFile({ mimetype: "application/vnd.slack-docs" })).toBe(true); + }); + + it("detects canvas pretty_type", () => { + expect(isSlackCanvasFile({ pretty_type: "Canvas" })).toBe(true); + }); + + it("detects quip filetype", () => { + expect(isSlackCanvasFile({ filetype: "quip" })).toBe(true); + }); +}); + +describe("extractCanvasRefsFromEvent", () => { + it("finds canvas files and URLs", () => { + const event: SlackMessageEvent = { + type: "message", + channel: "C1", + ts: "1.2", + text: "See https://acme.slack.com/docs/T123/F12345678", + files: [{ id: "F999", mimetype: "application/vnd.slack-docs" }], + } as SlackMessageEvent; + + const refs = extractCanvasRefsFromEvent(event); + const ids = refs.map((ref) => ref.fileId).filter(Boolean); + expect(ids).toContain("F999"); + expect(ids).toContain("F12345678"); + }); +}); + +describe("fetchSlackCanvasContent", () => { + it("downloads and parses JSON canvas content", async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ text: "hello canvas" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const client = createClient(); + const result = await fetchSlackCanvasContent({ + ref: { fileId: "F123" }, + client, + token: "xoxb-test", + maxBytes: 1024, + maxChars: 1000, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.extractedText).toContain("hello canvas"); + expect(result.rawFormat).toBe("json"); + } + vi.unstubAllGlobals(); + }); + + it("returns missing_scope errors", async () => { + const client = { + files: { + info: vi.fn(async () => { + const err = new Error("missing_scope"); + (err as any).data = { error: "missing_scope", needed: "files:read" }; + throw err; + }), + }, + } as unknown as WebClient; + + const result = await fetchSlackCanvasContent({ + ref: { fileId: "F123" }, + client, + token: "xoxb-test", + maxBytes: 1024, + maxChars: 1000, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("missing_scope"); + } + }); + + it("handles download HTTP failures", async () => { + const fetchMock = vi.fn(async () => new Response("forbidden", { status: 403 })); + vi.stubGlobal("fetch", fetchMock); + + const client = createClient(); + const result = await fetchSlackCanvasContent({ + ref: { fileId: "F123" }, + client, + token: "xoxb-test", + maxBytes: 1024, + maxChars: 1000, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("download_http_403"); + } + vi.unstubAllGlobals(); + }); + + it("truncates large payloads", async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ text: "1234567890" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const client = createClient(); + const result = await fetchSlackCanvasContent({ + ref: { fileId: "F123" }, + client, + token: "xoxb-test", + maxBytes: 1024, + maxChars: 5, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.truncated).toBe(true); + expect(result.extractedText.length).toBeLessThanOrEqual(5); + } + vi.unstubAllGlobals(); + }); +}); diff --git a/src/slack/canvases.ts b/src/slack/canvases.ts new file mode 100644 index 000000000..1e81f0013 --- /dev/null +++ b/src/slack/canvases.ts @@ -0,0 +1,284 @@ +import type { WebClient as SlackWebClient } from "@slack/web-api"; + +import { logVerbose } from "../globals.js"; +import type { SlackFile, SlackMessageEvent } from "./types.js"; + +type CanvasUrlMatch = { + url: string; + fileId?: string; +}; + +export type SlackCanvasRef = { + fileId?: string; + canvasUrl?: string; + channelId?: string; + messageTs?: string; +}; + +export type SlackCanvasContent = { + title?: string; + extractedText: string; + rawFormat: "json" | "text" | "unknown"; + truncated: boolean; +}; + +export type SlackCanvasFetchResult = + | ({ ok: true } & SlackCanvasContent) + | { ok: false; error: string }; + +const SLACK_CANVAS_MIME = "application/vnd.slack-docs"; +const CANVAS_FILETYPE_HINTS = new Set(["quip"]); +const CANVAS_PRETTY_TYPE = new Set(["canvas"]); + +const CANVAS_URL_RE = /https?:\/\/[^\s<>"]*slack\.com\/(docs|canvas|doc|files)\/[^\s<>"]+/gi; +const SLACK_FILE_ID_RE = /\bF[A-Z0-9]{8,}\b/g; + +export function isSlackCanvasFile(file?: SlackFile | null): boolean { + if (!file) return false; + const mimetype = file.mimetype?.toLowerCase(); + const prettyType = file.pretty_type?.toLowerCase(); + const filetype = file.filetype?.toLowerCase(); + if (mimetype && mimetype === SLACK_CANVAS_MIME) return true; + if (prettyType && CANVAS_PRETTY_TYPE.has(prettyType)) return true; + if (filetype && CANVAS_FILETYPE_HINTS.has(filetype)) return true; + return false; +} + +function extractFileIdFromUrl(url: string): string | undefined { + const matches = url.match(SLACK_FILE_ID_RE); + if (!matches || matches.length === 0) return undefined; + return matches[0]; +} + +function collectUrlsFromText(text: string): CanvasUrlMatch[] { + const matches = text.match(CANVAS_URL_RE) ?? []; + return matches.map((url) => ({ url, fileId: extractFileIdFromUrl(url) })); +} + +function collectUrlsFromObject(value: unknown, into: CanvasUrlMatch[], seen = new Set()) { + if (!value) return; + if (typeof value === "string") { + for (const entry of collectUrlsFromText(value)) { + if (seen.has(entry.url)) continue; + seen.add(entry.url); + into.push(entry); + } + return; + } + if (Array.isArray(value)) { + for (const entry of value) collectUrlsFromObject(entry, into, seen); + return; + } + if (typeof value !== "object") return; + for (const entry of Object.values(value as Record)) { + collectUrlsFromObject(entry, into, seen); + } +} + +export function extractCanvasRefsFromEvent(event: SlackMessageEvent): SlackCanvasRef[] { + const refs: SlackCanvasRef[] = []; + const seen = new Set(); + const channelId = event.channel; + const messageTs = event.ts ?? event.event_ts; + + for (const file of event.files ?? []) { + if (!isSlackCanvasFile(file)) continue; + const fileId = file.id; + if (fileId && seen.has(fileId)) continue; + if (fileId) seen.add(fileId); + refs.push({ fileId, channelId, messageTs }); + } + + const urlMatches: CanvasUrlMatch[] = []; + if (event.text) collectUrlsFromObject(event.text, urlMatches); + collectUrlsFromObject(event.blocks, urlMatches); + collectUrlsFromObject(event.attachments, urlMatches); + + for (const match of urlMatches) { + const dedupeKey = match.fileId ?? match.url; + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + refs.push({ + fileId: match.fileId, + canvasUrl: match.url, + channelId, + messageTs, + }); + } + + return refs; +} + +function buildErrorLabel(err: unknown): string { + const data = err as { data?: { error?: string; needed?: string | string[] } } | undefined; + const apiError = data?.data?.error ?? (err as { error?: string } | undefined)?.error; + if (apiError === "missing_scope") { + const needed = data?.data?.needed; + const scopeList = Array.isArray(needed) ? needed.join(", ") : needed; + return scopeList ? `missing_scope: ${scopeList}` : "missing_scope"; + } + if (apiError === "not_allowed_token_type") return "not_allowed_token_type"; + if (apiError === "not_in_channel") return "not_in_channel"; + if (apiError === "channel_not_found") return "channel_not_found"; + if (apiError && typeof apiError === "string") return apiError; + return err instanceof Error ? err.message : String(err); +} + +function coerceToText(input: string, maxChars: number): { text: string; truncated: boolean } { + if (input.length <= maxChars) return { text: input, truncated: false }; + return { text: input.slice(0, maxChars), truncated: true }; +} + +function collectJsonText( + value: unknown, + params: { + maxChars: number; + results: string[]; + seen: Set; + depth: number; + }, +) { + if (params.results.join("\n").length >= params.maxChars) return; + if (params.depth > 12) return; + if (value == null) return; + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return; + if (!params.seen.has(trimmed)) { + params.seen.add(trimmed); + params.results.push(trimmed); + } + return; + } + if (Array.isArray(value)) { + for (const entry of value) { + collectJsonText(entry, { ...params, depth: params.depth + 1 }); + if (params.results.join("\n").length >= params.maxChars) return; + } + return; + } + if (typeof value !== "object") return; + + const record = value as Record; + const preferredKeys = ["text", "plain_text", "title", "value", "name"]; + for (const key of preferredKeys) { + const entry = record[key]; + if (typeof entry === "string") { + collectJsonText(entry, { ...params, depth: params.depth + 1 }); + } + } + for (const entry of Object.values(record)) { + collectJsonText(entry, { ...params, depth: params.depth + 1 }); + if (params.results.join("\n").length >= params.maxChars) return; + } +} + +function extractTextFromJson( + payload: unknown, + maxChars: number, +): { text: string; truncated: boolean } { + const results: string[] = []; + const seen = new Set(); + collectJsonText(payload, { maxChars, results, seen, depth: 0 }); + const joined = results.join("\n"); + if (!joined) { + return coerceToText(JSON.stringify(payload), maxChars); + } + return coerceToText(joined, maxChars); +} + +export async function fetchSlackCanvasContent(params: { + ref: SlackCanvasRef; + client: SlackWebClient; + token: string; + maxBytes: number; + maxChars: number; +}): Promise { + const { ref, client, token, maxBytes, maxChars } = params; + const fileId = ref.fileId ?? (ref.canvasUrl ? extractFileIdFromUrl(ref.canvasUrl) : undefined); + if (!fileId) { + return { ok: false, error: "no_file_id" }; + } + + let fileInfo: { + file?: SlackFile & { + title?: string; + name?: string; + url_private?: string; + url_private_download?: string; + }; + }; + try { + fileInfo = (await client.files.info({ file: fileId })) as { + file?: SlackFile & { + title?: string; + name?: string; + url_private?: string; + url_private_download?: string; + }; + }; + } catch (err) { + const label = buildErrorLabel(err); + logVerbose(`slack canvases: files.info failed fileId=${fileId} error=${label}`); + return { ok: false, error: label }; + } + + const file = fileInfo.file; + if (!file) { + return { ok: false, error: "file_not_found" }; + } + + const url = file.url_private_download ?? file.url_private; + if (!url) { + return { ok: false, error: "missing_download_url" }; + } + + let response: Response; + try { + response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + } catch (err) { + return { ok: false, error: `download_failed: ${String(err)}` }; + } + + if (!response.ok) { + return { ok: false, error: `download_http_${response.status}` }; + } + + const contentType = response.headers.get("content-type") ?? ""; + const buffer = await response.arrayBuffer(); + const truncatedByBytes = buffer.byteLength > maxBytes; + const sliced = truncatedByBytes ? buffer.slice(0, maxBytes) : buffer; + const text = new TextDecoder().decode(sliced); + + let extracted: { text: string; truncated: boolean }; + let rawFormat: SlackCanvasContent["rawFormat"] = "unknown"; + + if ( + contentType.includes("application/json") || + text.trim().startsWith("{") || + text.trim().startsWith("[") + ) { + rawFormat = "json"; + try { + const payload = JSON.parse(text); + extracted = extractTextFromJson(payload, maxChars); + } catch { + extracted = coerceToText(text, maxChars); + } + } else { + rawFormat = "text"; + extracted = coerceToText(text, maxChars); + } + + return { + ok: true, + title: file.title ?? file.name ?? fileId, + extractedText: extracted.text, + rawFormat, + truncated: truncatedByBytes || extracted.truncated, + }; +} diff --git a/src/slack/monitor/context.test.ts b/src/slack/monitor/context.test.ts index 87b1fe425..a711f1025 100644 --- a/src/slack/monitor/context.test.ts +++ b/src/slack/monitor/context.test.ts @@ -28,6 +28,8 @@ const baseParams = () => ({ reactionMode: "off" as const, reactionAllowlist: [], replyToMode: "off" as const, + threadHistoryScope: "thread" as const, + threadInheritParent: false, slashCommand: { enabled: false, name: "openclaw", @@ -37,6 +39,8 @@ const baseParams = () => ({ textLimit: 4000, ackReactionScope: "group-mentions", mediaMaxBytes: 1, + canvasMaxBytes: 1, + canvasTextMaxChars: 1000, removeAckAfterReply: false, }); diff --git a/src/slack/monitor/context.ts b/src/slack/monitor/context.ts index f74592a10..0899035e2 100644 --- a/src/slack/monitor/context.ts +++ b/src/slack/monitor/context.ts @@ -86,6 +86,8 @@ export type SlackMonitorContext = { textLimit: number; ackReactionScope: string; mediaMaxBytes: number; + canvasMaxBytes: number; + canvasTextMaxChars: number; removeAckAfterReply: boolean; logger: ReturnType; @@ -147,6 +149,8 @@ export function createSlackMonitorContext(params: { textLimit: number; ackReactionScope: string; mediaMaxBytes: number; + canvasMaxBytes: number; + canvasTextMaxChars: number; removeAckAfterReply: boolean; }): SlackMonitorContext { const channelHistories = new Map(); @@ -391,6 +395,8 @@ export function createSlackMonitorContext(params: { textLimit: params.textLimit, ackReactionScope: params.ackReactionScope, mediaMaxBytes: params.mediaMaxBytes, + canvasMaxBytes: params.canvasMaxBytes, + canvasTextMaxChars: params.canvasTextMaxChars, removeAckAfterReply: params.removeAckAfterReply, logger, markMessageSeen, diff --git a/src/slack/monitor/message-handler/prepare.inbound-contract.test.ts b/src/slack/monitor/message-handler/prepare.inbound-contract.test.ts index 1a614d6c5..b07aa519f 100644 --- a/src/slack/monitor/message-handler/prepare.inbound-contract.test.ts +++ b/src/slack/monitor/message-handler/prepare.inbound-contract.test.ts @@ -1,5 +1,5 @@ import type { App } from "@slack/bolt"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../config/config.js"; import type { RuntimeEnv } from "../../../runtime.js"; @@ -47,6 +47,8 @@ describe("slack prepareSlackMessage inbound contract", () => { textLimit: 4000, ackReactionScope: "group-mentions", mediaMaxBytes: 1024, + canvasMaxBytes: 1024, + canvasTextMaxChars: 2000, removeAckAfterReply: false, }); slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any; @@ -115,6 +117,8 @@ describe("slack prepareSlackMessage inbound contract", () => { textLimit: 4000, ackReactionScope: "group-mentions", mediaMaxBytes: 1024, + canvasMaxBytes: 1024, + canvasTextMaxChars: 2000, removeAckAfterReply: false, }); slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any; @@ -145,4 +149,94 @@ describe("slack prepareSlackMessage inbound contract", () => { expect(prepared).toBeTruthy(); expect(prepared!.ctxPayload.MessageThreadId).toBe("1.000"); }); + + it("appends canvas content to the inbound body", async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ title: "Canvas", text: "hello canvas" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const filesInfo = vi.fn(async () => ({ + file: { + id: "F12345678", + title: "Project Canvas", + url_private_download: "https://files.slack.com/canvas.json", + }, + })); + + const slackCtx = createSlackMonitorContext({ + cfg: { + channels: { slack: { enabled: true } }, + } as ClawdbotConfig, + accountId: "default", + botToken: "token", + app: { client: { files: { info: filesInfo } } } as App, + runtime: {} as RuntimeEnv, + botUserId: "B1", + teamId: "T1", + apiAppId: "A1", + historyLimit: 0, + sessionScope: "per-sender", + mainKey: "main", + dmEnabled: true, + dmPolicy: "open", + allowFrom: [], + groupDmEnabled: true, + groupDmChannels: [], + defaultRequireMention: true, + groupPolicy: "open", + useAccessGroups: false, + reactionMode: "off", + reactionAllowlist: [], + replyToMode: "off", + threadHistoryScope: "thread", + threadInheritParent: false, + slashCommand: { + enabled: false, + name: "clawd", + sessionPrefix: "slack:slash", + ephemeral: true, + }, + textLimit: 4000, + ackReactionScope: "group-mentions", + mediaMaxBytes: 1024, + canvasMaxBytes: 1024, + canvasTextMaxChars: 2000, + removeAckAfterReply: false, + }); + slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any; + + const account: ResolvedSlackAccount = { + accountId: "default", + enabled: true, + botTokenSource: "config", + appTokenSource: "config", + config: {}, + }; + + const message: SlackMessageEvent = { + channel: "D123", + channel_type: "im", + user: "U1", + text: "see canvas", + ts: "1.000", + files: [{ id: "F12345678", mimetype: "application/vnd.slack-docs" }], + } as SlackMessageEvent; + + const prepared = await prepareSlackMessage({ + ctx: slackCtx, + account, + message, + opts: { source: "message" }, + }); + + expect(prepared).toBeTruthy(); + expect(prepared!.ctxPayload.Body).toContain("[Slack Canvas: Project Canvas]"); + expect(prepared!.ctxPayload.Body).toContain("hello canvas"); + vi.unstubAllGlobals(); + }); }); diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 8a2a9e111..51ccd6992 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -44,6 +44,7 @@ import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "../allow-li import { resolveSlackEffectiveAllowFrom } from "../auth.js"; import { resolveSlackChannelConfig } from "../channel-config.js"; import { normalizeSlackChannelType, type SlackMonitorContext } from "../context.js"; +import { extractCanvasRefsFromEvent, fetchSlackCanvasContent } from "../../canvases.js"; import { resolveSlackMedia, resolveSlackThreadStarter } from "../media.js"; import type { PreparedSlackMessage } from "./types.js"; @@ -336,8 +337,45 @@ export async function prepareSlackMessage(params: { token: ctx.botToken, maxBytes: ctx.mediaMaxBytes, }); - const rawBody = (message.text ?? "").trim() || media?.placeholder || ""; - if (!rawBody) return null; + + const canvasRefs = extractCanvasRefsFromEvent(message); + const canvasBlocks: string[] = []; + for (const ref of canvasRefs) { + const label = ref.fileId ?? ref.canvasUrl ?? "unknown"; + const result = await fetchSlackCanvasContent({ + ref, + client: ctx.app.client, + token: ctx.botToken, + maxBytes: ctx.canvasMaxBytes, + maxChars: ctx.canvasTextMaxChars, + }); + if (result.ok) { + const title = result.title ?? label; + const content = result.truncated + ? `${result.extractedText}\n[truncated]` + : result.extractedText; + canvasBlocks.push(`[Slack Canvas: ${title}]\n${content}`); + } else { + ctx.logger.warn( + { + fileId: ref.fileId, + url: ref.canvasUrl, + channelId: ref.channelId, + messageTs: ref.messageTs, + error: result.error, + }, + "slack canvas fetch failed", + ); + canvasBlocks.push(`[Slack Canvas: ${label}] Unable to fetch content (${result.error})`); + } + } + const canvasContext = canvasBlocks.length > 0 ? canvasBlocks.join("\n\n") : ""; + + const baseBody = + (message.text ?? "").trim() || media?.placeholder || (canvasContext ? "[Slack canvas]" : ""); + if (!baseBody) return null; + const rawBody = baseBody; + const rawBodyWithCanvas = canvasContext ? `${rawBody}\n\n${canvasContext}` : rawBody; const ackReaction = resolveAckReaction(cfg, route.agentId); const ackReactionValue = ackReaction ?? ""; @@ -395,7 +433,7 @@ export async function prepareSlackMessage(params: { GroupSubject: isRoomish ? roomLabel : undefined, From: slackFrom, }) ?? (isDirectMessage ? senderName : roomLabel); - const textWithId = `${rawBody}\n[slack message id: ${message.ts} channel: ${message.channel}]`; + const textWithId = `${rawBodyWithCanvas}\n[slack message id: ${message.ts} channel: ${message.channel}]`; const storePath = resolveStorePath(ctx.cfg.session?.store, { agentId: route.agentId, }); diff --git a/src/slack/monitor/provider.ts b/src/slack/monitor/provider.ts index 366a32a34..c11417df2 100644 --- a/src/slack/monitor/provider.ts +++ b/src/slack/monitor/provider.ts @@ -122,6 +122,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId); const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024; + const canvasMaxBytes = (slackCfg.canvasMaxMb ?? 2) * 1024 * 1024; + const canvasTextMaxChars = slackCfg.canvasTextMaxChars ?? 20000; const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; const receiver = @@ -203,6 +205,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { textLimit, ackReactionScope, mediaMaxBytes, + canvasMaxBytes, + canvasTextMaxChars, removeAckAfterReply, }); diff --git a/src/slack/types.ts b/src/slack/types.ts index b87bdd739..db7bae514 100644 --- a/src/slack/types.ts +++ b/src/slack/types.ts @@ -1,7 +1,10 @@ export type SlackFile = { id?: string; name?: string; + title?: string; mimetype?: string; + filetype?: string; + pretty_type?: string; size?: number; url_private?: string; url_private_download?: string; @@ -21,6 +24,8 @@ export type SlackMessageEvent = { channel: string; channel_type?: "im" | "mpim" | "channel" | "group"; files?: SlackFile[]; + blocks?: unknown[]; + attachments?: unknown[]; }; export type SlackAppMentionEvent = {