feat: add slack multi-account routing

This commit is contained in:
Peter Steinberger
2026-01-08 08:49:16 +01:00
parent 00c1403f5c
commit 8930ec32cb
31 changed files with 878 additions and 93 deletions

View File

@@ -1,6 +1,7 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveSlackAccount } from "../../slack/accounts.js";
import {
deleteSlackMessage,
editSlackMessage,
@@ -38,7 +39,11 @@ export async function handleSlackAction(
cfg: ClawdbotConfig,
): Promise<AgentToolResult<unknown>> {
const action = readStringParam(params, "action", { required: true });
const isActionEnabled = createActionGate(cfg.slack?.actions);
const accountId = readStringParam(params, "accountId");
const accountOpts = accountId ? { accountId } : undefined;
const account = resolveSlackAccount({ cfg, accountId });
const actionConfig = account.actions ?? cfg.slack?.actions;
const isActionEnabled = createActionGate(actionConfig);
if (reactionsActions.has(action)) {
if (!isActionEnabled("reactions")) {
@@ -51,17 +56,29 @@ export async function handleSlackAction(
removeErrorMessage: "Emoji is required to remove a Slack reaction.",
});
if (remove) {
await removeSlackReaction(channelId, messageId, emoji);
if (accountOpts) {
await removeSlackReaction(channelId, messageId, emoji, accountOpts);
} else {
await removeSlackReaction(channelId, messageId, emoji);
}
return jsonResult({ ok: true, removed: emoji });
}
if (isEmpty) {
const removed = await removeOwnSlackReactions(channelId, messageId);
const removed = accountOpts
? await removeOwnSlackReactions(channelId, messageId, accountOpts)
: await removeOwnSlackReactions(channelId, messageId);
return jsonResult({ ok: true, removed });
}
await reactSlackMessage(channelId, messageId, emoji);
if (accountOpts) {
await reactSlackMessage(channelId, messageId, emoji, accountOpts);
} else {
await reactSlackMessage(channelId, messageId, emoji);
}
return jsonResult({ ok: true, added: emoji });
}
const reactions = await listSlackReactions(channelId, messageId);
const reactions = accountOpts
? await listSlackReactions(channelId, messageId, accountOpts)
: await listSlackReactions(channelId, messageId);
return jsonResult({ ok: true, reactions });
}
@@ -75,6 +92,7 @@ export async function handleSlackAction(
const content = readStringParam(params, "content", { required: true });
const mediaUrl = readStringParam(params, "mediaUrl");
const result = await sendSlackMessage(to, content, {
accountId: accountId ?? undefined,
mediaUrl: mediaUrl ?? undefined,
});
return jsonResult({ ok: true, result });
@@ -89,7 +107,11 @@ export async function handleSlackAction(
const content = readStringParam(params, "content", {
required: true,
});
await editSlackMessage(channelId, messageId, content);
if (accountOpts) {
await editSlackMessage(channelId, messageId, content, accountOpts);
} else {
await editSlackMessage(channelId, messageId, content);
}
return jsonResult({ ok: true });
}
case "deleteMessage": {
@@ -99,7 +121,11 @@ export async function handleSlackAction(
const messageId = readStringParam(params, "messageId", {
required: true,
});
await deleteSlackMessage(channelId, messageId);
if (accountOpts) {
await deleteSlackMessage(channelId, messageId, accountOpts);
} else {
await deleteSlackMessage(channelId, messageId);
}
return jsonResult({ ok: true });
}
case "readMessages": {
@@ -114,6 +140,7 @@ export async function handleSlackAction(
const before = readStringParam(params, "before");
const after = readStringParam(params, "after");
const result = await readSlackMessages(channelId, {
accountId: accountId ?? undefined,
limit,
before: before ?? undefined,
after: after ?? undefined,
@@ -134,17 +161,27 @@ export async function handleSlackAction(
const messageId = readStringParam(params, "messageId", {
required: true,
});
await pinSlackMessage(channelId, messageId);
if (accountOpts) {
await pinSlackMessage(channelId, messageId, accountOpts);
} else {
await pinSlackMessage(channelId, messageId);
}
return jsonResult({ ok: true });
}
if (action === "unpinMessage") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
await unpinSlackMessage(channelId, messageId);
if (accountOpts) {
await unpinSlackMessage(channelId, messageId, accountOpts);
} else {
await unpinSlackMessage(channelId, messageId);
}
return jsonResult({ ok: true });
}
const pins = await listSlackPins(channelId);
const pins = accountOpts
? await listSlackPins(channelId, accountOpts)
: await listSlackPins(channelId);
return jsonResult({ ok: true, pins });
}
@@ -153,7 +190,9 @@ export async function handleSlackAction(
throw new Error("Slack member info is disabled.");
}
const userId = readStringParam(params, "userId", { required: true });
const info = await getSlackMemberInfo(userId);
const info = accountOpts
? await getSlackMemberInfo(userId, accountOpts)
: await getSlackMemberInfo(userId);
return jsonResult({ ok: true, info });
}
@@ -161,7 +200,9 @@ export async function handleSlackAction(
if (!isActionEnabled("emojiList")) {
throw new Error("Slack emoji list is disabled.");
}
const emojis = await listSlackEmojis();
const emojis = accountOpts
? await listSlackEmojis(accountOpts)
: await listSlackEmojis();
return jsonResult({ ok: true, emojis });
}

View File

@@ -9,28 +9,35 @@ export const SlackToolSchema = Type.Union([
messageId: Type.String(),
},
includeRemove: true,
extras: {
accountId: Type.Optional(Type.String()),
},
}),
Type.Object({
action: Type.Literal("reactions"),
channelId: Type.String(),
messageId: Type.String(),
accountId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("sendMessage"),
to: Type.String(),
content: Type.String(),
mediaUrl: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("editMessage"),
channelId: Type.String(),
messageId: Type.String(),
content: Type.String(),
accountId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("deleteMessage"),
channelId: Type.String(),
messageId: Type.String(),
accountId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("readMessages"),
@@ -38,26 +45,32 @@ export const SlackToolSchema = Type.Union([
limit: Type.Optional(Type.Number()),
before: Type.Optional(Type.String()),
after: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("pinMessage"),
channelId: Type.String(),
messageId: Type.String(),
accountId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("unpinMessage"),
channelId: Type.String(),
messageId: Type.String(),
accountId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("listPins"),
channelId: Type.String(),
accountId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("memberInfo"),
userId: Type.String(),
accountId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("emojiList"),
accountId: Type.Optional(Type.String()),
}),
]);

View File

@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const handleSlackActionMock = vi.fn();
vi.mock("./slack-actions.js", () => ({
handleSlackAction: (params: unknown, cfg: unknown) =>
handleSlackActionMock(params, cfg),
}));
import { createSlackTool } from "./slack-tool.js";
describe("slack tool", () => {
beforeEach(() => {
handleSlackActionMock.mockReset();
handleSlackActionMock.mockResolvedValue({
content: [],
details: { ok: true },
});
});
it("injects agentAccountId when accountId is missing", async () => {
const tool = createSlackTool({
agentAccountId: " Kev ",
config: { slack: { accounts: { kev: {} } } },
});
await tool.execute("call-1", {
action: "sendMessage",
to: "channel:C1",
content: "hello",
});
expect(handleSlackActionMock).toHaveBeenCalledTimes(1);
const [params] = handleSlackActionMock.mock.calls[0] ?? [];
expect(params).toMatchObject({ accountId: "kev" });
});
it("keeps explicit accountId when provided", async () => {
const tool = createSlackTool({
agentAccountId: "kev",
config: {},
});
await tool.execute("call-2", {
action: "sendMessage",
to: "channel:C1",
content: "hello",
accountId: "rex",
});
expect(handleSlackActionMock).toHaveBeenCalledTimes(1);
const [params] = handleSlackActionMock.mock.calls[0] ?? [];
expect(params).toMatchObject({ accountId: "rex" });
});
it("does not inject accountId when agentAccountId is missing", async () => {
const tool = createSlackTool({ config: {} });
await tool.execute("call-3", {
action: "sendMessage",
to: "channel:C1",
content: "hello",
});
expect(handleSlackActionMock).toHaveBeenCalledTimes(1);
const [params] = handleSlackActionMock.mock.calls[0] ?? [];
expect(params).not.toHaveProperty("accountId");
});
it("does not inject unknown agentAccountId when not configured", async () => {
const tool = createSlackTool({
agentAccountId: "unknown",
config: { slack: { accounts: { kev: {} } } },
});
await tool.execute("call-4", {
action: "sendMessage",
to: "channel:C1",
content: "hello",
});
const [params] = handleSlackActionMock.mock.calls[0] ?? [];
expect(params).not.toHaveProperty("accountId");
});
});

View File

@@ -1,9 +1,44 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import { logVerbose } from "../../globals.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import type { AnyAgentTool } from "./common.js";
import { handleSlackAction } from "./slack-actions.js";
import { SlackToolSchema } from "./slack-schema.js";
export function createSlackTool(): AnyAgentTool {
type SlackToolOptions = {
agentAccountId?: string;
config?: ClawdbotConfig;
};
function resolveAgentAccountId(value?: string): string | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;
return normalizeAccountId(trimmed);
}
function resolveConfiguredAccountId(
cfg: ClawdbotConfig,
accountId: string,
): string | undefined {
if (accountId === "default") return accountId;
const accounts = cfg.slack?.accounts;
if (!accounts || typeof accounts !== "object") return undefined;
if (accountId in accounts) return accountId;
const match = Object.keys(accounts).find(
(key) => key.toLowerCase() === accountId.toLowerCase(),
);
return match;
}
function hasAccountId(params: Record<string, unknown>): boolean {
const raw = params.accountId;
if (typeof raw !== "string") return false;
return raw.trim().length > 0;
}
export function createSlackTool(options?: SlackToolOptions): AnyAgentTool {
const agentAccountId = resolveAgentAccountId(options?.agentAccountId);
return {
label: "Slack",
name: "slack",
@@ -11,8 +46,24 @@ export function createSlackTool(): AnyAgentTool {
parameters: SlackToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const cfg = loadConfig();
return await handleSlackAction(params, cfg);
const cfg = options?.config ?? loadConfig();
const resolvedAccountId = agentAccountId
? resolveConfiguredAccountId(cfg, agentAccountId)
: undefined;
const resolvedParams =
resolvedAccountId && !hasAccountId(params)
? { ...params, accountId: resolvedAccountId }
: params;
if (hasAccountId(resolvedParams)) {
const action =
typeof params.action === "string" ? params.action : "unknown";
logVerbose(
`slack tool: action=${action} accountId=${String(
resolvedParams.accountId,
).trim()}`,
);
}
return await handleSlackAction(resolvedParams, cfg);
},
};
}