openclaw/src/infra/outbound/message-action-spec.ts
msvechla 2c5b898eea
feat(slack): add download-file action for on-demand file attachment access (#24723)
* feat(slack): add download-file action for on-demand file attachment access

Adds a new `download-file` message tool action that allows the agent to
download Slack file attachments by file ID on demand. This is a prerequisite
for accessing images posted in thread history, where file attachments are
not automatically resolved.

Changes:
- Add `files` field to `SlackMessageSummary` type so file IDs are
  visible in message read results
- Add `downloadSlackFile()` to fetch a file by ID via `files.info`
  and resolve it through the existing `resolveSlackMedia()` pipeline
- Register `download-file` in `CHANNEL_MESSAGE_ACTION_NAMES`,
  `MESSAGE_ACTION_TARGET_MODE`, and `listSlackMessageActions`
- Add `downloadFile` dispatch case in `handleSlackAction`
- Wire agent-facing `download-file` → internal `downloadFile` in
  `handleSlackMessageAction`

Closes #24681

* style: fix formatting in slack-actions and actions

* test(slack): cover download-file action path

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-01 11:45:05 -06:00

103 lines
2.8 KiB
TypeScript

import type { ChannelMessageActionName } from "../../channels/plugins/types.js";
export type MessageActionTargetMode = "to" | "channelId" | "none";
export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, MessageActionTargetMode> =
{
send: "to",
broadcast: "none",
poll: "to",
react: "to",
reactions: "to",
read: "to",
edit: "to",
unsend: "to",
reply: "to",
sendWithEffect: "to",
renameGroup: "to",
setGroupIcon: "to",
addParticipant: "to",
removeParticipant: "to",
leaveGroup: "to",
sendAttachment: "to",
delete: "to",
pin: "to",
unpin: "to",
"list-pins": "to",
permissions: "to",
"thread-create": "to",
"thread-list": "none",
"thread-reply": "to",
search: "none",
sticker: "to",
"sticker-search": "none",
"member-info": "none",
"role-info": "none",
"emoji-list": "none",
"emoji-upload": "none",
"sticker-upload": "none",
"role-add": "none",
"role-remove": "none",
"channel-info": "channelId",
"channel-list": "none",
"channel-create": "none",
"channel-edit": "channelId",
"channel-delete": "channelId",
"channel-move": "channelId",
"category-create": "none",
"category-edit": "none",
"category-delete": "none",
"topic-create": "to",
"voice-status": "none",
"event-list": "none",
"event-create": "none",
timeout: "none",
kick: "none",
ban: "none",
"set-presence": "none",
"download-file": "none",
};
const ACTION_TARGET_ALIASES: Partial<Record<ChannelMessageActionName, string[]>> = {
unsend: ["messageId"],
edit: ["messageId"],
react: ["chatGuid", "chatIdentifier", "chatId"],
renameGroup: ["chatGuid", "chatIdentifier", "chatId"],
setGroupIcon: ["chatGuid", "chatIdentifier", "chatId"],
addParticipant: ["chatGuid", "chatIdentifier", "chatId"],
removeParticipant: ["chatGuid", "chatIdentifier", "chatId"],
leaveGroup: ["chatGuid", "chatIdentifier", "chatId"],
};
export function actionRequiresTarget(action: ChannelMessageActionName): boolean {
return MESSAGE_ACTION_TARGET_MODE[action] !== "none";
}
export function actionHasTarget(
action: ChannelMessageActionName,
params: Record<string, unknown>,
): boolean {
const to = typeof params.to === "string" ? params.to.trim() : "";
if (to) {
return true;
}
const channelId = typeof params.channelId === "string" ? params.channelId.trim() : "";
if (channelId) {
return true;
}
const aliases = ACTION_TARGET_ALIASES[action];
if (!aliases) {
return false;
}
return aliases.some((alias) => {
const value = params[alias];
if (typeof value === "string") {
return value.trim().length > 0;
}
if (typeof value === "number") {
return Number.isFinite(value);
}
return false;
});
}