diff --git a/src/channels/plugins/slack.actions.ts b/extensions/slack/src/channel-actions.ts similarity index 75% rename from src/channels/plugins/slack.actions.ts rename to extensions/slack/src/channel-actions.ts index 317b8a7d8db..b584358fe18 100644 --- a/src/channels/plugins/slack.actions.ts +++ b/extensions/slack/src/channel-actions.ts @@ -1,17 +1,15 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { - handleSlackAction, - type SlackActionContext, -} from "../../../extensions/slack/runtime-api.js"; -import { - extractSlackToolSend, - isSlackInteractiveRepliesEnabled, - listSlackMessageActions, - resolveSlackChannelId, - handleSlackMessageAction, -} from "../../plugin-sdk/slack.js"; -import { createSlackMessageToolBlocksSchema } from "./message-tool-schema.js"; -import type { ChannelMessageActionAdapter, ChannelMessageToolDiscovery } from "./types.js"; + createSlackMessageToolBlocksSchema, + type ChannelMessageActionAdapter, + type ChannelMessageToolDiscovery, +} from "openclaw/plugin-sdk/channel-runtime"; +import { isSlackInteractiveRepliesEnabled } from "openclaw/plugin-sdk/slack"; +import type { SlackActionContext } from "./action-runtime.js"; +import { handleSlackAction } from "./action-runtime.js"; +import { handleSlackMessageAction } from "./message-action-dispatch.js"; +import { extractSlackToolSend, listSlackMessageActions } from "./message-actions.js"; +import { resolveSlackChannelId } from "./targets.js"; type SlackActionInvoke = ( action: Record, diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts index 98fbddca77d..4f22cd91263 100644 --- a/extensions/slack/src/channel.test.ts +++ b/extensions/slack/src/channel.test.ts @@ -32,6 +32,28 @@ describe("slackPlugin actions", () => { expect(slackPlugin.meta.preferSessionLookupForAnnounceTarget).toBe(true); }); + it("owns unified message tool discovery", () => { + const discovery = slackPlugin.actions?.describeMessageTool({ + cfg: { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + capabilities: { interactiveReplies: true }, + }, + }, + }, + }); + + expect(discovery?.actions).toContain("send"); + expect(discovery?.capabilities).toEqual(expect.arrayContaining(["blocks", "interactive"])); + expect(discovery?.schema).toMatchObject({ + properties: { + blocks: expect.any(Object), + }, + }); + }); + it("forwards read threadId to Slack action handler", async () => { handleSlackActionMock.mockResolvedValueOnce({ messages: [], hasMore: false }); const handleAction = slackPlugin.actions?.handleAction; diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 3b346c07d48..b6f82f19afd 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -22,7 +22,6 @@ import { resolveConfiguredFromRequiredCredentialStatuses, resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, - createSlackActions, type ChannelPlugin, type OpenClawConfig, type SlackActionContext, @@ -35,6 +34,7 @@ import { type ResolvedSlackAccount, } from "./accounts.js"; import { parseSlackBlocksInput } from "./blocks-input.js"; +import { createSlackActions } from "./channel-actions.js"; import { createSlackWebClient } from "./client.js"; import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; import { normalizeAllowListLower } from "./monitor/allow-list.js"; diff --git a/src/plugin-sdk/slack-message-actions.test.ts b/extensions/slack/src/message-action-dispatch.test.ts similarity index 95% rename from src/plugin-sdk/slack-message-actions.test.ts rename to extensions/slack/src/message-action-dispatch.test.ts index 9c098bffe76..e44af2f4e71 100644 --- a/src/plugin-sdk/slack-message-actions.test.ts +++ b/extensions/slack/src/message-action-dispatch.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { handleSlackMessageAction } from "./slack-message-actions.js"; +import { handleSlackMessageAction } from "./message-action-dispatch.js"; function createInvokeSpy() { return vi.fn(async (action: Record) => ({ diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts index fc04c122ac7..d55c058105b 100644 --- a/extensions/slack/src/message-action-dispatch.ts +++ b/extensions/slack/src/message-action-dispatch.ts @@ -1,9 +1,205 @@ -import { handleSlackMessageAction as handleSlackMessageActionImpl } from "openclaw/plugin-sdk/slack"; +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { + normalizeInteractiveReply, + type ChannelMessageActionContext, +} from "openclaw/plugin-sdk/channel-runtime"; +import { readNumberParam, readStringParam } from "openclaw/plugin-sdk/slack-core"; +import { parseSlackBlocksInput } from "./blocks-input.js"; +import { buildSlackInteractiveBlocks } from "./blocks-render.js"; -type HandleSlackMessageAction = typeof import("openclaw/plugin-sdk/slack").handleSlackMessageAction; +type SlackActionInvoke = ( + action: Record, + cfg: ChannelMessageActionContext["cfg"], + toolContext?: ChannelMessageActionContext["toolContext"], +) => Promise>; -export async function handleSlackMessageAction( - ...args: Parameters -): ReturnType { - return await handleSlackMessageActionImpl(...args); +function readSlackBlocksParam(actionParams: Record) { + return parseSlackBlocksInput(actionParams.blocks) as Record[] | undefined; +} + +/** Translate generic channel action requests into Slack-specific tool invocations and payload shapes. */ +export async function handleSlackMessageAction(params: { + providerId: string; + ctx: ChannelMessageActionContext; + invoke: SlackActionInvoke; + normalizeChannelId?: (channelId: string) => string; + includeReadThreadId?: boolean; +}): Promise> { + const { providerId, ctx, invoke, normalizeChannelId, includeReadThreadId = false } = params; + const { action, cfg, params: actionParams } = ctx; + const accountId = ctx.accountId ?? undefined; + const resolveChannelId = () => { + const channelId = + readStringParam(actionParams, "channelId") ?? + readStringParam(actionParams, "to", { required: true }); + return normalizeChannelId ? normalizeChannelId(channelId) : channelId; + }; + + if (action === "send") { + const to = readStringParam(actionParams, "to", { required: true }); + const content = readStringParam(actionParams, "message", { + required: false, + allowEmpty: true, + }); + const mediaUrl = readStringParam(actionParams, "media", { trim: false }); + const interactive = normalizeInteractiveReply(actionParams.interactive); + const interactiveBlocks = interactive ? buildSlackInteractiveBlocks(interactive) : undefined; + const blocks = readSlackBlocksParam(actionParams) ?? interactiveBlocks; + if (!content && !mediaUrl && !blocks) { + throw new Error("Slack send requires message, blocks, or media."); + } + if (mediaUrl && blocks) { + throw new Error("Slack send does not support blocks with media."); + } + const threadId = readStringParam(actionParams, "threadId"); + const replyTo = readStringParam(actionParams, "replyTo"); + return await invoke( + { + action: "sendMessage", + to, + content: content ?? "", + mediaUrl: mediaUrl ?? undefined, + accountId, + threadTs: threadId ?? replyTo ?? undefined, + ...(blocks ? { blocks } : {}), + }, + cfg, + ctx.toolContext, + ); + } + + if (action === "react") { + const messageId = readStringParam(actionParams, "messageId", { + required: true, + }); + const emoji = readStringParam(actionParams, "emoji", { allowEmpty: true }); + const remove = typeof actionParams.remove === "boolean" ? actionParams.remove : undefined; + return await invoke( + { + action: "react", + channelId: resolveChannelId(), + messageId, + emoji, + remove, + accountId, + }, + cfg, + ); + } + + if (action === "reactions") { + const messageId = readStringParam(actionParams, "messageId", { + required: true, + }); + const limit = readNumberParam(actionParams, "limit", { integer: true }); + return await invoke( + { + action: "reactions", + channelId: resolveChannelId(), + messageId, + limit, + accountId, + }, + cfg, + ); + } + + if (action === "read") { + const limit = readNumberParam(actionParams, "limit", { integer: true }); + const readAction: Record = { + action: "readMessages", + channelId: resolveChannelId(), + limit, + before: readStringParam(actionParams, "before"), + after: readStringParam(actionParams, "after"), + accountId, + }; + if (includeReadThreadId) { + readAction.threadId = readStringParam(actionParams, "threadId"); + } + return await invoke(readAction, cfg); + } + + if (action === "edit") { + const messageId = readStringParam(actionParams, "messageId", { + required: true, + }); + const content = readStringParam(actionParams, "message", { allowEmpty: true }); + const blocks = readSlackBlocksParam(actionParams); + if (!content && !blocks) { + throw new Error("Slack edit requires message or blocks."); + } + return await invoke( + { + action: "editMessage", + channelId: resolveChannelId(), + messageId, + content: content ?? "", + blocks, + accountId, + }, + cfg, + ); + } + + if (action === "delete") { + const messageId = readStringParam(actionParams, "messageId", { + required: true, + }); + return await invoke( + { + action: "deleteMessage", + channelId: resolveChannelId(), + messageId, + accountId, + }, + cfg, + ); + } + + if (action === "pin" || action === "unpin" || action === "list-pins") { + const messageId = + action === "list-pins" + ? undefined + : readStringParam(actionParams, "messageId", { required: true }); + return await invoke( + { + action: action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins", + channelId: resolveChannelId(), + messageId, + accountId, + }, + cfg, + ); + } + + if (action === "member-info") { + const userId = readStringParam(actionParams, "userId", { required: true }); + return await invoke({ action: "memberInfo", userId, accountId }, cfg); + } + + if (action === "emoji-list") { + const limit = readNumberParam(actionParams, "limit", { integer: true }); + return await invoke({ action: "emojiList", limit, accountId }, cfg); + } + + if (action === "download-file") { + const fileId = readStringParam(actionParams, "fileId", { required: true }); + const channelId = + readStringParam(actionParams, "channelId") ?? readStringParam(actionParams, "to"); + const threadId = + readStringParam(actionParams, "threadId") ?? readStringParam(actionParams, "replyTo"); + return await invoke( + { + action: "downloadFile", + fileId, + channelId: channelId ?? undefined, + threadId: threadId ?? undefined, + accountId, + }, + cfg, + ); + } + + throw new Error(`Action ${action} is not supported for provider ${providerId}.`); } diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index 5442b2cf135..fca5b468066 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -21,7 +21,7 @@ vi.mock("../../../../extensions/signal/src/send-reactions.js", () => ({ removeReactionSignal, })); -vi.mock("../../../../extensions/slack/runtime-api.js", () => ({ +vi.mock("../../../../extensions/slack/src/action-runtime.js", () => ({ handleSlackAction, })); @@ -29,7 +29,7 @@ let discordMessageActions: typeof import("./discord.js").discordMessageActions; let handleDiscordMessageAction: typeof import("./discord/handle-action.js").handleDiscordMessageAction; let telegramMessageActions: typeof import("./telegram.js").telegramMessageActions; let signalMessageActions: typeof import("./signal.js").signalMessageActions; -let createSlackActions: typeof import("../slack.actions.js").createSlackActions; +let createSlackActions: typeof import("../../../../extensions/slack/src/channel-actions.js").createSlackActions; function getDescribedActions(params: { describeMessageTool?: ChannelMessageActionAdapter["describeMessageTool"]; @@ -205,7 +205,7 @@ beforeEach(async () => { ({ handleDiscordMessageAction } = await import("./discord/handle-action.js")); ({ telegramMessageActions } = await import("./telegram.js")); ({ signalMessageActions } = await import("./signal.js")); - ({ createSlackActions } = await import("../slack.actions.js")); + ({ createSlackActions } = await import("../../../../extensions/slack/src/channel-actions.js")); vi.clearAllMocks(); }); diff --git a/src/plugin-sdk/slack-message-actions.ts b/src/plugin-sdk/slack-message-actions.ts deleted file mode 100644 index 64863623503..00000000000 --- a/src/plugin-sdk/slack-message-actions.ts +++ /dev/null @@ -1,202 +0,0 @@ -import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { parseSlackBlocksInput, buildSlackInteractiveBlocks } from "../../extensions/slack/api.js"; -import { readNumberParam, readStringParam } from "../agents/tools/common.js"; -import type { ChannelMessageActionContext } from "../channels/plugins/types.js"; -import { normalizeInteractiveReply } from "../interactive/payload.js"; - -type SlackActionInvoke = ( - action: Record, - cfg: ChannelMessageActionContext["cfg"], - toolContext?: ChannelMessageActionContext["toolContext"], -) => Promise>; - -function readSlackBlocksParam(actionParams: Record) { - return parseSlackBlocksInput(actionParams.blocks) as Record[] | undefined; -} - -/** Translate generic channel action requests into Slack-specific tool invocations and payload shapes. */ -export async function handleSlackMessageAction(params: { - providerId: string; - ctx: ChannelMessageActionContext; - invoke: SlackActionInvoke; - normalizeChannelId?: (channelId: string) => string; - includeReadThreadId?: boolean; -}): Promise> { - const { providerId, ctx, invoke, normalizeChannelId, includeReadThreadId = false } = params; - const { action, cfg, params: actionParams } = ctx; - const accountId = ctx.accountId ?? undefined; - const resolveChannelId = () => { - const channelId = - readStringParam(actionParams, "channelId") ?? - readStringParam(actionParams, "to", { required: true }); - return normalizeChannelId ? normalizeChannelId(channelId) : channelId; - }; - - if (action === "send") { - const to = readStringParam(actionParams, "to", { required: true }); - const content = readStringParam(actionParams, "message", { - required: false, - allowEmpty: true, - }); - const mediaUrl = readStringParam(actionParams, "media", { trim: false }); - const interactive = normalizeInteractiveReply(actionParams.interactive); - const interactiveBlocks = interactive ? buildSlackInteractiveBlocks(interactive) : undefined; - const blocks = readSlackBlocksParam(actionParams) ?? interactiveBlocks; - if (!content && !mediaUrl && !blocks) { - throw new Error("Slack send requires message, blocks, or media."); - } - if (mediaUrl && blocks) { - throw new Error("Slack send does not support blocks with media."); - } - const threadId = readStringParam(actionParams, "threadId"); - const replyTo = readStringParam(actionParams, "replyTo"); - return await invoke( - { - action: "sendMessage", - to, - content: content ?? "", - mediaUrl: mediaUrl ?? undefined, - accountId, - threadTs: threadId ?? replyTo ?? undefined, - ...(blocks ? { blocks } : {}), - }, - cfg, - ctx.toolContext, - ); - } - - if (action === "react") { - const messageId = readStringParam(actionParams, "messageId", { - required: true, - }); - const emoji = readStringParam(actionParams, "emoji", { allowEmpty: true }); - const remove = typeof actionParams.remove === "boolean" ? actionParams.remove : undefined; - return await invoke( - { - action: "react", - channelId: resolveChannelId(), - messageId, - emoji, - remove, - accountId, - }, - cfg, - ); - } - - if (action === "reactions") { - const messageId = readStringParam(actionParams, "messageId", { - required: true, - }); - const limit = readNumberParam(actionParams, "limit", { integer: true }); - return await invoke( - { - action: "reactions", - channelId: resolveChannelId(), - messageId, - limit, - accountId, - }, - cfg, - ); - } - - if (action === "read") { - const limit = readNumberParam(actionParams, "limit", { integer: true }); - const readAction: Record = { - action: "readMessages", - channelId: resolveChannelId(), - limit, - before: readStringParam(actionParams, "before"), - after: readStringParam(actionParams, "after"), - accountId, - }; - if (includeReadThreadId) { - readAction.threadId = readStringParam(actionParams, "threadId"); - } - return await invoke(readAction, cfg); - } - - if (action === "edit") { - const messageId = readStringParam(actionParams, "messageId", { - required: true, - }); - const content = readStringParam(actionParams, "message", { allowEmpty: true }); - const blocks = readSlackBlocksParam(actionParams); - if (!content && !blocks) { - throw new Error("Slack edit requires message or blocks."); - } - return await invoke( - { - action: "editMessage", - channelId: resolveChannelId(), - messageId, - content: content ?? "", - blocks, - accountId, - }, - cfg, - ); - } - - if (action === "delete") { - const messageId = readStringParam(actionParams, "messageId", { - required: true, - }); - return await invoke( - { - action: "deleteMessage", - channelId: resolveChannelId(), - messageId, - accountId, - }, - cfg, - ); - } - - if (action === "pin" || action === "unpin" || action === "list-pins") { - const messageId = - action === "list-pins" - ? undefined - : readStringParam(actionParams, "messageId", { required: true }); - return await invoke( - { - action: action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins", - channelId: resolveChannelId(), - messageId, - accountId, - }, - cfg, - ); - } - - if (action === "member-info") { - const userId = readStringParam(actionParams, "userId", { required: true }); - return await invoke({ action: "memberInfo", userId, accountId }, cfg); - } - - if (action === "emoji-list") { - const limit = readNumberParam(actionParams, "limit", { integer: true }); - return await invoke({ action: "emojiList", limit, accountId }, cfg); - } - - if (action === "download-file") { - const fileId = readStringParam(actionParams, "fileId", { required: true }); - const channelId = - readStringParam(actionParams, "channelId") ?? readStringParam(actionParams, "to"); - const threadId = - readStringParam(actionParams, "threadId") ?? readStringParam(actionParams, "replyTo"); - return await invoke( - { - action: "downloadFile", - fileId, - channelId: channelId ?? undefined, - threadId: threadId ?? undefined, - accountId, - }, - cfg, - ); - } - - throw new Error(`Action ${action} is not supported for provider ${providerId}.`); -} diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index bb3dcfe7c59..31b857c0d2a 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -78,6 +78,4 @@ export { unpinSlackMessage, } from "../../extensions/slack/api.js"; export { recordSlackThreadParticipation } from "../../extensions/slack/api.js"; -export { handleSlackMessageAction } from "./slack-message-actions.js"; -export { createSlackActions } from "../channels/plugins/slack.actions.js"; export type { SlackActionContext } from "../../extensions/slack/runtime-api.js";