diff --git a/src/agents/tools/slack-actions.test.ts b/src/agents/tools/slack-actions.test.ts index e3a6cb59042..f4ba99488df 100644 --- a/src/agents/tools/slack-actions.test.ts +++ b/src/agents/tools/slack-actions.test.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import { handleSlackAction } from "./slack-actions.js"; const deleteSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); +const downloadSlackFile = vi.fn(async (..._args: unknown[]) => null); const editSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); const getSlackMemberInfo = vi.fn(async (..._args: unknown[]) => ({})); const listSlackEmojis = vi.fn(async (..._args: unknown[]) => ({})); @@ -19,6 +20,7 @@ const unpinSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); vi.mock("../../slack/actions.js", () => ({ deleteSlackMessage: (...args: Parameters) => deleteSlackMessage(...args), + downloadSlackFile: (...args: Parameters) => downloadSlackFile(...args), editSlackMessage: (...args: Parameters) => editSlackMessage(...args), getSlackMemberInfo: (...args: Parameters) => getSlackMemberInfo(...args), @@ -194,6 +196,26 @@ describe("handleSlackAction", () => { }); }); + it("returns a friendly error when downloadFile cannot fetch the attachment", async () => { + downloadSlackFile.mockResolvedValueOnce(null); + const result = await handleSlackAction( + { + action: "downloadFile", + fileId: "F123", + }, + slackConfig(), + ); + expect(downloadSlackFile).toHaveBeenCalledWith( + "F123", + expect.objectContaining({ maxBytes: 20 * 1024 * 1024 }), + ); + expect(result).toEqual( + expect.objectContaining({ + details: expect.objectContaining({ ok: false }), + }), + ); + }); + it.each([ { name: "JSON blocks", diff --git a/src/agents/tools/slack-actions.ts b/src/agents/tools/slack-actions.ts index 54adea00afd..a56eb2b3686 100644 --- a/src/agents/tools/slack-actions.ts +++ b/src/agents/tools/slack-actions.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import { resolveSlackAccount } from "../../slack/accounts.js"; import { deleteSlackMessage, + downloadSlackFile, editSlackMessage, getSlackMemberInfo, listSlackEmojis, @@ -22,13 +23,20 @@ import { parseSlackTarget, resolveSlackChannelId } from "../../slack/targets.js" import { withNormalizedTimestamp } from "../date-time.js"; import { createActionGate, + imageResultFromFile, jsonResult, readNumberParam, readReactionParams, readStringParam, } from "./common.js"; -const messagingActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]); +const messagingActions = new Set([ + "sendMessage", + "editMessage", + "deleteMessage", + "readMessages", + "downloadFile", +]); const reactionsActions = new Set(["react", "reactions"]); const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]); @@ -280,6 +288,28 @@ export async function handleSlackAction( ); return jsonResult({ ok: true, messages, hasMore: result.hasMore }); } + case "downloadFile": { + const fileId = readStringParam(params, "fileId", { required: true }); + const maxBytes = account.config?.mediaMaxMb + ? account.config.mediaMaxMb * 1024 * 1024 + : 20 * 1024 * 1024; + const downloaded = await downloadSlackFile(fileId, { + ...readOpts, + maxBytes, + }); + if (!downloaded) { + return jsonResult({ + ok: false, + error: "File could not be downloaded (not found, too large, or inaccessible).", + }); + } + return await imageResultFromFile({ + label: "slack-file", + path: downloaded.path, + extraText: downloaded.placeholder, + details: { fileId, path: downloaded.path }, + }); + } default: break; } diff --git a/src/channels/plugins/message-action-names.ts b/src/channels/plugins/message-action-names.ts index ded4e9a5b7e..649bb6ce89f 100644 --- a/src/channels/plugins/message-action-names.ts +++ b/src/channels/plugins/message-action-names.ts @@ -50,6 +50,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [ "kick", "ban", "set-presence", + "download-file", ] as const; export type ChannelMessageActionName = (typeof CHANNEL_MESSAGE_ACTION_NAMES)[number]; diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index d5b6300d1f7..641cc362077 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -55,6 +55,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record> = { diff --git a/src/plugin-sdk/slack-message-actions.test.ts b/src/plugin-sdk/slack-message-actions.test.ts new file mode 100644 index 00000000000..14f584f9ca2 --- /dev/null +++ b/src/plugin-sdk/slack-message-actions.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from "vitest"; +import { handleSlackMessageAction } from "./slack-message-actions.js"; + +describe("handleSlackMessageAction", () => { + it("maps download-file to the internal downloadFile action", async () => { + const invoke = vi.fn(async (action: Record) => ({ + ok: true, + content: action, + })); + + await handleSlackMessageAction({ + providerId: "slack", + ctx: { + action: "download-file", + cfg: {}, + params: { + channelId: "C1", + fileId: "F123", + }, + } as never, + invoke: invoke as never, + }); + + expect(invoke).toHaveBeenCalledWith( + expect.objectContaining({ + action: "downloadFile", + fileId: "F123", + }), + expect.any(Object), + ); + }); +}); diff --git a/src/plugin-sdk/slack-message-actions.ts b/src/plugin-sdk/slack-message-actions.ts index 77b24b95860..16abb4f9b4f 100644 --- a/src/plugin-sdk/slack-message-actions.ts +++ b/src/plugin-sdk/slack-message-actions.ts @@ -176,5 +176,10 @@ export async function handleSlackMessageAction(params: { return await invoke({ action: "emojiList", limit, accountId }, cfg); } + if (action === "download-file") { + const fileId = readStringParam(actionParams, "fileId", { required: true }); + return await invoke({ action: "downloadFile", fileId, accountId }, cfg); + } + throw new Error(`Action ${action} is not supported for provider ${providerId}.`); } diff --git a/src/slack/actions.download-file.test.ts b/src/slack/actions.download-file.test.ts new file mode 100644 index 00000000000..3bc7ae76cab --- /dev/null +++ b/src/slack/actions.download-file.test.ts @@ -0,0 +1,88 @@ +import type { WebClient } from "@slack/web-api"; +import { describe, expect, it, vi } from "vitest"; + +const resolveSlackMedia = vi.fn(); + +vi.mock("./monitor/media.js", () => ({ + resolveSlackMedia: (...args: Parameters) => resolveSlackMedia(...args), +})); + +const { downloadSlackFile } = await import("./actions.js"); + +function createClient() { + return { + files: { + info: vi.fn(async () => ({ file: {} })), + }, + } as unknown as WebClient & { + files: { + info: ReturnType; + }; + }; +} + +describe("downloadSlackFile", () => { + it("returns null when files.info has no private download URL", async () => { + const client = createClient(); + client.files.info.mockResolvedValueOnce({ + file: { + id: "F123", + name: "image.png", + }, + }); + + const result = await downloadSlackFile("F123", { + client, + token: "xoxb-test", + maxBytes: 1024, + }); + + expect(result).toBeNull(); + expect(resolveSlackMedia).not.toHaveBeenCalled(); + }); + + it("downloads via resolveSlackMedia using fresh files.info metadata", async () => { + const client = createClient(); + client.files.info.mockResolvedValueOnce({ + file: { + id: "F123", + name: "image.png", + mimetype: "image/png", + url_private_download: "https://files.slack.com/files-pri/T1-F123/image.png", + }, + }); + resolveSlackMedia.mockResolvedValueOnce([ + { + path: "/tmp/image.png", + contentType: "image/png", + placeholder: "[Slack file: image.png]", + }, + ]); + + const result = await downloadSlackFile("F123", { + client, + token: "xoxb-test", + maxBytes: 1024, + }); + + expect(client.files.info).toHaveBeenCalledWith({ file: "F123" }); + expect(resolveSlackMedia).toHaveBeenCalledWith({ + files: [ + { + id: "F123", + name: "image.png", + mimetype: "image/png", + url_private: undefined, + url_private_download: "https://files.slack.com/files-pri/T1-F123/image.png", + }, + ], + token: "xoxb-test", + maxBytes: 1024, + }); + expect(result).toEqual({ + path: "/tmp/image.png", + contentType: "image/png", + placeholder: "[Slack file: image.png]", + }); + }); +}); diff --git a/src/slack/actions.ts b/src/slack/actions.ts index d72fe51a423..3feedcc5314 100644 --- a/src/slack/actions.ts +++ b/src/slack/actions.ts @@ -5,6 +5,8 @@ import { resolveSlackAccount } from "./accounts.js"; import { buildSlackBlocksFallbackText } from "./blocks-fallback.js"; import { validateSlackBlocksArray } from "./blocks-input.js"; import { createSlackWebClient } from "./client.js"; +import { resolveSlackMedia } from "./monitor/media.js"; +import type { SlackMediaResult } from "./monitor/media.js"; import { sendMessageSlack } from "./send.js"; import { resolveSlackBotToken } from "./token.js"; @@ -25,6 +27,12 @@ export type SlackMessageSummary = { count?: number; users?: string[]; }>; + /** File attachments on this message. Present when the message has files. */ + files?: Array<{ + id?: string; + name?: string; + mimetype?: string; + }>; }; export type SlackPin = { @@ -271,3 +279,48 @@ export async function listSlackPins( const result = await client.pins.list({ channel: channelId }); return (result.items ?? []) as SlackPin[]; } + +/** + * Downloads a Slack file by ID and saves it to the local media store. + * Fetches a fresh download URL via files.info to avoid using stale private URLs. + * Returns null when the file cannot be found or downloaded. + */ +export async function downloadSlackFile( + fileId: string, + opts: SlackActionClientOpts & { maxBytes: number }, +): Promise { + const token = resolveToken(opts.token, opts.accountId); + const client = await getClient(opts); + + // Fetch fresh file metadata (includes a current url_private_download). + const info = await client.files.info({ file: fileId }); + const file = info.file as + | { + id?: string; + name?: string; + mimetype?: string; + url_private?: string; + url_private_download?: string; + } + | undefined; + + if (!file?.url_private_download && !file?.url_private) { + return null; + } + + const results = await resolveSlackMedia({ + files: [ + { + id: file.id, + name: file.name, + mimetype: file.mimetype, + url_private: file.url_private, + url_private_download: file.url_private_download, + }, + ], + token, + maxBytes: opts.maxBytes, + }); + + return results?.[0] ?? null; +} diff --git a/src/slack/message-actions.test.ts b/src/slack/message-actions.test.ts new file mode 100644 index 00000000000..71d8e72ebbc --- /dev/null +++ b/src/slack/message-actions.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { listSlackMessageActions } from "./message-actions.js"; + +describe("listSlackMessageActions", () => { + it("includes download-file when message actions are enabled", () => { + const cfg = { + channels: { + slack: { + botToken: "xoxb-test", + actions: { + messages: true, + }, + }, + }, + } as OpenClawConfig; + + expect(listSlackMessageActions(cfg)).toEqual( + expect.arrayContaining(["read", "edit", "delete", "download-file"]), + ); + }); +}); diff --git a/src/slack/message-actions.ts b/src/slack/message-actions.ts index 21665f74ea7..5c5a4ba928e 100644 --- a/src/slack/message-actions.ts +++ b/src/slack/message-actions.ts @@ -32,6 +32,7 @@ export function listSlackMessageActions(cfg: OpenClawConfig): ChannelMessageActi actions.add("read"); actions.add("edit"); actions.add("delete"); + actions.add("download-file"); } if (isActionEnabled("pins")) { actions.add("pin");