Skip to content

Commit 28b888c

Browse files
committed
Slack: move message actions behind plugin boundary
1 parent cd5c2f4 commit 28b888c

File tree

8 files changed

+239
-227
lines changed

8 files changed

+239
-227
lines changed
Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
11
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
22
import {
3-
handleSlackAction,
4-
type SlackActionContext,
5-
} from "../../../extensions/slack/runtime-api.js";
6-
import {
7-
extractSlackToolSend,
8-
isSlackInteractiveRepliesEnabled,
9-
listSlackMessageActions,
10-
resolveSlackChannelId,
11-
handleSlackMessageAction,
12-
} from "../../plugin-sdk/slack.js";
13-
import { createSlackMessageToolBlocksSchema } from "./message-tool-schema.js";
14-
import type { ChannelMessageActionAdapter, ChannelMessageToolDiscovery } from "./types.js";
3+
createSlackMessageToolBlocksSchema,
4+
type ChannelMessageActionAdapter,
5+
type ChannelMessageToolDiscovery,
6+
} from "openclaw/plugin-sdk/channel-runtime";
7+
import { isSlackInteractiveRepliesEnabled } from "openclaw/plugin-sdk/slack";
8+
import type { SlackActionContext } from "./action-runtime.js";
9+
import { handleSlackAction } from "./action-runtime.js";
10+
import { handleSlackMessageAction } from "./message-action-dispatch.js";
11+
import { extractSlackToolSend, listSlackMessageActions } from "./message-actions.js";
12+
import { resolveSlackChannelId } from "./targets.js";
1513

1614
type SlackActionInvoke = (
1715
action: Record<string, unknown>,

extensions/slack/src/channel.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,28 @@ describe("slackPlugin actions", () => {
3232
expect(slackPlugin.meta.preferSessionLookupForAnnounceTarget).toBe(true);
3333
});
3434

35+
it("owns unified message tool discovery", () => {
36+
const discovery = slackPlugin.actions?.describeMessageTool({
37+
cfg: {
38+
channels: {
39+
slack: {
40+
botToken: "xoxb-test",
41+
appToken: "xapp-test",
42+
capabilities: { interactiveReplies: true },
43+
},
44+
},
45+
},
46+
});
47+
48+
expect(discovery?.actions).toContain("send");
49+
expect(discovery?.capabilities).toEqual(expect.arrayContaining(["blocks", "interactive"]));
50+
expect(discovery?.schema).toMatchObject({
51+
properties: {
52+
blocks: expect.any(Object),
53+
},
54+
});
55+
});
56+
3557
it("forwards read threadId to Slack action handler", async () => {
3658
handleSlackActionMock.mockResolvedValueOnce({ messages: [], hasMore: false });
3759
const handleAction = slackPlugin.actions?.handleAction;

extensions/slack/src/channel.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import {
2222
resolveConfiguredFromRequiredCredentialStatuses,
2323
resolveSlackGroupRequireMention,
2424
resolveSlackGroupToolPolicy,
25-
createSlackActions,
2625
type ChannelPlugin,
2726
type OpenClawConfig,
2827
type SlackActionContext,
@@ -35,6 +34,7 @@ import {
3534
type ResolvedSlackAccount,
3635
} from "./accounts.js";
3736
import { parseSlackBlocksInput } from "./blocks-input.js";
37+
import { createSlackActions } from "./channel-actions.js";
3838
import { createSlackWebClient } from "./client.js";
3939
import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js";
4040
import { normalizeAllowListLower } from "./monitor/allow-list.js";

src/plugin-sdk/slack-message-actions.test.ts renamed to extensions/slack/src/message-action-dispatch.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it, vi } from "vitest";
2-
import { handleSlackMessageAction } from "./slack-message-actions.js";
2+
import { handleSlackMessageAction } from "./message-action-dispatch.js";
33

44
function createInvokeSpy() {
55
return vi.fn(async (action: Record<string, unknown>) => ({
Lines changed: 202 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,205 @@
1-
import { handleSlackMessageAction as handleSlackMessageActionImpl } from "openclaw/plugin-sdk/slack";
1+
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
2+
import {
3+
normalizeInteractiveReply,
4+
type ChannelMessageActionContext,
5+
} from "openclaw/plugin-sdk/channel-runtime";
6+
import { readNumberParam, readStringParam } from "openclaw/plugin-sdk/slack-core";
7+
import { parseSlackBlocksInput } from "./blocks-input.js";
8+
import { buildSlackInteractiveBlocks } from "./blocks-render.js";
29

3-
type HandleSlackMessageAction = typeof import("openclaw/plugin-sdk/slack").handleSlackMessageAction;
10+
type SlackActionInvoke = (
11+
action: Record<string, unknown>,
12+
cfg: ChannelMessageActionContext["cfg"],
13+
toolContext?: ChannelMessageActionContext["toolContext"],
14+
) => Promise<AgentToolResult<unknown>>;
415

5-
export async function handleSlackMessageAction(
6-
...args: Parameters<HandleSlackMessageAction>
7-
): ReturnType<HandleSlackMessageAction> {
8-
return await handleSlackMessageActionImpl(...args);
16+
function readSlackBlocksParam(actionParams: Record<string, unknown>) {
17+
return parseSlackBlocksInput(actionParams.blocks) as Record<string, unknown>[] | undefined;
18+
}
19+
20+
/** Translate generic channel action requests into Slack-specific tool invocations and payload shapes. */
21+
export async function handleSlackMessageAction(params: {
22+
providerId: string;
23+
ctx: ChannelMessageActionContext;
24+
invoke: SlackActionInvoke;
25+
normalizeChannelId?: (channelId: string) => string;
26+
includeReadThreadId?: boolean;
27+
}): Promise<AgentToolResult<unknown>> {
28+
const { providerId, ctx, invoke, normalizeChannelId, includeReadThreadId = false } = params;
29+
const { action, cfg, params: actionParams } = ctx;
30+
const accountId = ctx.accountId ?? undefined;
31+
const resolveChannelId = () => {
32+
const channelId =
33+
readStringParam(actionParams, "channelId") ??
34+
readStringParam(actionParams, "to", { required: true });
35+
return normalizeChannelId ? normalizeChannelId(channelId) : channelId;
36+
};
37+
38+
if (action === "send") {
39+
const to = readStringParam(actionParams, "to", { required: true });
40+
const content = readStringParam(actionParams, "message", {
41+
required: false,
42+
allowEmpty: true,
43+
});
44+
const mediaUrl = readStringParam(actionParams, "media", { trim: false });
45+
const interactive = normalizeInteractiveReply(actionParams.interactive);
46+
const interactiveBlocks = interactive ? buildSlackInteractiveBlocks(interactive) : undefined;
47+
const blocks = readSlackBlocksParam(actionParams) ?? interactiveBlocks;
48+
if (!content && !mediaUrl && !blocks) {
49+
throw new Error("Slack send requires message, blocks, or media.");
50+
}
51+
if (mediaUrl && blocks) {
52+
throw new Error("Slack send does not support blocks with media.");
53+
}
54+
const threadId = readStringParam(actionParams, "threadId");
55+
const replyTo = readStringParam(actionParams, "replyTo");
56+
return await invoke(
57+
{
58+
action: "sendMessage",
59+
to,
60+
content: content ?? "",
61+
mediaUrl: mediaUrl ?? undefined,
62+
accountId,
63+
threadTs: threadId ?? replyTo ?? undefined,
64+
...(blocks ? { blocks } : {}),
65+
},
66+
cfg,
67+
ctx.toolContext,
68+
);
69+
}
70+
71+
if (action === "react") {
72+
const messageId = readStringParam(actionParams, "messageId", {
73+
required: true,
74+
});
75+
const emoji = readStringParam(actionParams, "emoji", { allowEmpty: true });
76+
const remove = typeof actionParams.remove === "boolean" ? actionParams.remove : undefined;
77+
return await invoke(
78+
{
79+
action: "react",
80+
channelId: resolveChannelId(),
81+
messageId,
82+
emoji,
83+
remove,
84+
accountId,
85+
},
86+
cfg,
87+
);
88+
}
89+
90+
if (action === "reactions") {
91+
const messageId = readStringParam(actionParams, "messageId", {
92+
required: true,
93+
});
94+
const limit = readNumberParam(actionParams, "limit", { integer: true });
95+
return await invoke(
96+
{
97+
action: "reactions",
98+
channelId: resolveChannelId(),
99+
messageId,
100+
limit,
101+
accountId,
102+
},
103+
cfg,
104+
);
105+
}
106+
107+
if (action === "read") {
108+
const limit = readNumberParam(actionParams, "limit", { integer: true });
109+
const readAction: Record<string, unknown> = {
110+
action: "readMessages",
111+
channelId: resolveChannelId(),
112+
limit,
113+
before: readStringParam(actionParams, "before"),
114+
after: readStringParam(actionParams, "after"),
115+
accountId,
116+
};
117+
if (includeReadThreadId) {
118+
readAction.threadId = readStringParam(actionParams, "threadId");
119+
}
120+
return await invoke(readAction, cfg);
121+
}
122+
123+
if (action === "edit") {
124+
const messageId = readStringParam(actionParams, "messageId", {
125+
required: true,
126+
});
127+
const content = readStringParam(actionParams, "message", { allowEmpty: true });
128+
const blocks = readSlackBlocksParam(actionParams);
129+
if (!content && !blocks) {
130+
throw new Error("Slack edit requires message or blocks.");
131+
}
132+
return await invoke(
133+
{
134+
action: "editMessage",
135+
channelId: resolveChannelId(),
136+
messageId,
137+
content: content ?? "",
138+
blocks,
139+
accountId,
140+
},
141+
cfg,
142+
);
143+
}
144+
145+
if (action === "delete") {
146+
const messageId = readStringParam(actionParams, "messageId", {
147+
required: true,
148+
});
149+
return await invoke(
150+
{
151+
action: "deleteMessage",
152+
channelId: resolveChannelId(),
153+
messageId,
154+
accountId,
155+
},
156+
cfg,
157+
);
158+
}
159+
160+
if (action === "pin" || action === "unpin" || action === "list-pins") {
161+
const messageId =
162+
action === "list-pins"
163+
? undefined
164+
: readStringParam(actionParams, "messageId", { required: true });
165+
return await invoke(
166+
{
167+
action: action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
168+
channelId: resolveChannelId(),
169+
messageId,
170+
accountId,
171+
},
172+
cfg,
173+
);
174+
}
175+
176+
if (action === "member-info") {
177+
const userId = readStringParam(actionParams, "userId", { required: true });
178+
return await invoke({ action: "memberInfo", userId, accountId }, cfg);
179+
}
180+
181+
if (action === "emoji-list") {
182+
const limit = readNumberParam(actionParams, "limit", { integer: true });
183+
return await invoke({ action: "emojiList", limit, accountId }, cfg);
184+
}
185+
186+
if (action === "download-file") {
187+
const fileId = readStringParam(actionParams, "fileId", { required: true });
188+
const channelId =
189+
readStringParam(actionParams, "channelId") ?? readStringParam(actionParams, "to");
190+
const threadId =
191+
readStringParam(actionParams, "threadId") ?? readStringParam(actionParams, "replyTo");
192+
return await invoke(
193+
{
194+
action: "downloadFile",
195+
fileId,
196+
channelId: channelId ?? undefined,
197+
threadId: threadId ?? undefined,
198+
accountId,
199+
},
200+
cfg,
201+
);
202+
}
203+
204+
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
9205
}

src/channels/plugins/actions/actions.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,15 @@ vi.mock("../../../../extensions/signal/src/send-reactions.js", () => ({
2121
removeReactionSignal,
2222
}));
2323

24-
vi.mock("../../../../extensions/slack/runtime-api.js", () => ({
24+
vi.mock("../../../../extensions/slack/src/action-runtime.js", () => ({
2525
handleSlackAction,
2626
}));
2727

2828
let discordMessageActions: typeof import("./discord.js").discordMessageActions;
2929
let handleDiscordMessageAction: typeof import("./discord/handle-action.js").handleDiscordMessageAction;
3030
let telegramMessageActions: typeof import("./telegram.js").telegramMessageActions;
3131
let signalMessageActions: typeof import("./signal.js").signalMessageActions;
32-
let createSlackActions: typeof import("../slack.actions.js").createSlackActions;
32+
let createSlackActions: typeof import("../../../../extensions/slack/src/channel-actions.js").createSlackActions;
3333

3434
function getDescribedActions(params: {
3535
describeMessageTool?: ChannelMessageActionAdapter["describeMessageTool"];
@@ -205,7 +205,7 @@ beforeEach(async () => {
205205
({ handleDiscordMessageAction } = await import("./discord/handle-action.js"));
206206
({ telegramMessageActions } = await import("./telegram.js"));
207207
({ signalMessageActions } = await import("./signal.js"));
208-
({ createSlackActions } = await import("../slack.actions.js"));
208+
({ createSlackActions } = await import("../../../../extensions/slack/src/channel-actions.js"));
209209
vi.clearAllMocks();
210210
});
211211

0 commit comments

Comments
 (0)