feat: add slack multi-account routing
This commit is contained in:
@@ -20,6 +20,7 @@ export function createClawdbotTools(options?: {
|
||||
browserControlUrl?: string;
|
||||
agentSessionKey?: string;
|
||||
agentProvider?: string;
|
||||
agentAccountId?: string;
|
||||
agentDir?: string;
|
||||
sandboxed?: boolean;
|
||||
config?: ClawdbotConfig;
|
||||
@@ -34,7 +35,10 @@ export function createClawdbotTools(options?: {
|
||||
createNodesTool(),
|
||||
createCronTool(),
|
||||
createDiscordTool(),
|
||||
createSlackTool(),
|
||||
createSlackTool({
|
||||
agentAccountId: options?.agentAccountId,
|
||||
config: options?.config,
|
||||
}),
|
||||
createTelegramTool(),
|
||||
createWhatsAppTool(),
|
||||
createGatewayTool({ agentSessionKey: options?.agentSessionKey }),
|
||||
|
||||
@@ -276,6 +276,13 @@ type ApiKeyInfo = {
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type MessagingToolSend = {
|
||||
tool: string;
|
||||
provider: string;
|
||||
accountId?: string;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
export type EmbeddedPiRunResult = {
|
||||
payloads?: Array<{
|
||||
text?: string;
|
||||
@@ -290,6 +297,8 @@ export type EmbeddedPiRunResult = {
|
||||
didSendViaMessagingTool?: boolean;
|
||||
// Texts successfully sent via messaging tools during the run.
|
||||
messagingToolSentTexts?: string[];
|
||||
// Messaging tool targets that successfully sent a message during the run.
|
||||
messagingToolSentTargets?: MessagingToolSend[];
|
||||
};
|
||||
|
||||
export type EmbeddedPiCompactResult = {
|
||||
@@ -737,6 +746,7 @@ export async function compactEmbeddedPiSession(params: {
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
messageProvider?: string;
|
||||
agentAccountId?: string;
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
agentDir?: string;
|
||||
@@ -842,6 +852,7 @@ export async function compactEmbeddedPiSession(params: {
|
||||
},
|
||||
sandbox,
|
||||
messageProvider: params.messageProvider,
|
||||
agentAccountId: params.agentAccountId,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
agentDir,
|
||||
config: params.config,
|
||||
@@ -962,6 +973,7 @@ export async function runEmbeddedPiAgent(params: {
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
messageProvider?: string;
|
||||
agentAccountId?: string;
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
agentDir?: string;
|
||||
@@ -1153,6 +1165,7 @@ export async function runEmbeddedPiAgent(params: {
|
||||
},
|
||||
sandbox,
|
||||
messageProvider: params.messageProvider,
|
||||
agentAccountId: params.agentAccountId,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
agentDir,
|
||||
config: params.config,
|
||||
@@ -1283,6 +1296,7 @@ export async function runEmbeddedPiAgent(params: {
|
||||
unsubscribe,
|
||||
waitForCompactionRetry,
|
||||
getMessagingToolSentTexts,
|
||||
getMessagingToolSentTargets,
|
||||
didSendViaMessagingTool,
|
||||
} = subscription;
|
||||
|
||||
@@ -1567,6 +1581,7 @@ export async function runEmbeddedPiAgent(params: {
|
||||
},
|
||||
didSendViaMessagingTool: didSendViaMessagingTool(),
|
||||
messagingToolSentTexts: getMessagingToolSentTexts(),
|
||||
messagingToolSentTargets: getMessagingToolSentTargets(),
|
||||
};
|
||||
} finally {
|
||||
restoreSkillEnv?.();
|
||||
|
||||
@@ -26,6 +26,13 @@ const log = createSubsystemLogger("agent/embedded");
|
||||
|
||||
export type { BlockReplyChunking } from "./pi-embedded-block-chunker.js";
|
||||
|
||||
type MessagingToolSend = {
|
||||
tool: string;
|
||||
provider: string;
|
||||
accountId?: string;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
function truncateToolText(text: string): string {
|
||||
if (text.length <= TOOL_RESULT_MAX_CHARS) return text;
|
||||
return `${text.slice(0, TOOL_RESULT_MAX_CHARS)}\n…(truncated)…`;
|
||||
@@ -86,6 +93,127 @@ function stripUnpairedThinkingTags(text: string): string {
|
||||
return text;
|
||||
}
|
||||
|
||||
function normalizeSlackTarget(raw: string): string | undefined {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const mentionMatch = trimmed.match(/^<@([A-Z0-9]+)>$/i);
|
||||
if (mentionMatch) return `user:${mentionMatch[1]}`;
|
||||
if (trimmed.startsWith("user:")) {
|
||||
const id = trimmed.slice(5).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (trimmed.startsWith("channel:")) {
|
||||
const id = trimmed.slice(8).trim();
|
||||
return id ? `channel:${id}` : undefined;
|
||||
}
|
||||
if (trimmed.startsWith("slack:")) {
|
||||
const id = trimmed.slice(6).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (trimmed.startsWith("@")) {
|
||||
const id = trimmed.slice(1).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (trimmed.startsWith("#")) {
|
||||
const id = trimmed.slice(1).trim();
|
||||
return id ? `channel:${id}` : undefined;
|
||||
}
|
||||
return `channel:${trimmed}`;
|
||||
}
|
||||
|
||||
function normalizeDiscordTarget(raw: string): string | undefined {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const mentionMatch = trimmed.match(/^<@!?(\d+)>$/);
|
||||
if (mentionMatch) return `user:${mentionMatch[1]}`;
|
||||
if (trimmed.startsWith("user:")) {
|
||||
const id = trimmed.slice(5).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (trimmed.startsWith("channel:")) {
|
||||
const id = trimmed.slice(8).trim();
|
||||
return id ? `channel:${id}` : undefined;
|
||||
}
|
||||
if (trimmed.startsWith("discord:")) {
|
||||
const id = trimmed.slice(8).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (trimmed.startsWith("@")) {
|
||||
const id = trimmed.slice(1).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
return `channel:${trimmed}`;
|
||||
}
|
||||
|
||||
function normalizeTelegramTarget(raw: string): string | undefined {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
let normalized = trimmed;
|
||||
if (normalized.startsWith("telegram:")) {
|
||||
normalized = normalized.slice("telegram:".length).trim();
|
||||
} else if (normalized.startsWith("tg:")) {
|
||||
normalized = normalized.slice("tg:".length).trim();
|
||||
} else if (normalized.startsWith("group:")) {
|
||||
normalized = normalized.slice("group:".length).trim();
|
||||
}
|
||||
if (!normalized) return undefined;
|
||||
const tmeMatch =
|
||||
/^https?:\/\/t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized) ??
|
||||
/^t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized);
|
||||
if (tmeMatch?.[1]) normalized = `@${tmeMatch[1]}`;
|
||||
if (!normalized) return undefined;
|
||||
return `telegram:${normalized}`;
|
||||
}
|
||||
|
||||
function extractMessagingToolSend(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): MessagingToolSend | undefined {
|
||||
const action = typeof args.action === "string" ? args.action.trim() : "";
|
||||
const accountIdRaw =
|
||||
typeof args.accountId === "string" ? args.accountId.trim() : undefined;
|
||||
const accountId = accountIdRaw ? accountIdRaw : undefined;
|
||||
if (toolName === "slack") {
|
||||
if (action !== "sendMessage") return undefined;
|
||||
const toRaw = typeof args.to === "string" ? args.to : undefined;
|
||||
if (!toRaw) return undefined;
|
||||
const to = normalizeSlackTarget(toRaw);
|
||||
return to
|
||||
? { tool: toolName, provider: "slack", accountId, to }
|
||||
: undefined;
|
||||
}
|
||||
if (toolName === "discord") {
|
||||
if (action === "sendMessage") {
|
||||
const toRaw = typeof args.to === "string" ? args.to : undefined;
|
||||
if (!toRaw) return undefined;
|
||||
const to = normalizeDiscordTarget(toRaw);
|
||||
return to
|
||||
? { tool: toolName, provider: "discord", accountId, to }
|
||||
: undefined;
|
||||
}
|
||||
if (action === "threadReply") {
|
||||
const channelId =
|
||||
typeof args.channelId === "string" ? args.channelId.trim() : "";
|
||||
if (!channelId) return undefined;
|
||||
const to = normalizeDiscordTarget(`channel:${channelId}`);
|
||||
return to
|
||||
? { tool: toolName, provider: "discord", accountId, to }
|
||||
: undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (toolName === "telegram") {
|
||||
if (action !== "sendMessage") return undefined;
|
||||
const toRaw = typeof args.to === "string" ? args.to : undefined;
|
||||
if (!toRaw) return undefined;
|
||||
const to = normalizeTelegramTarget(toRaw);
|
||||
return to
|
||||
? { tool: toolName, provider: "telegram", accountId, to }
|
||||
: undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function subscribeEmbeddedPiSession(params: {
|
||||
session: AgentSession;
|
||||
runId: string;
|
||||
@@ -151,7 +279,9 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
"sessions_send",
|
||||
]);
|
||||
const messagingToolSentTexts: string[] = [];
|
||||
const messagingToolSentTargets: MessagingToolSend[] = [];
|
||||
const pendingMessagingTexts = new Map<string, string>();
|
||||
const pendingMessagingTargets = new Map<string, MessagingToolSend>();
|
||||
|
||||
const ensureCompactionPromise = () => {
|
||||
if (!compactionRetryPromise) {
|
||||
@@ -315,7 +445,9 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
toolMetaById.clear();
|
||||
toolSummaryById.clear();
|
||||
messagingToolSentTexts.length = 0;
|
||||
messagingToolSentTargets.length = 0;
|
||||
pendingMessagingTexts.clear();
|
||||
pendingMessagingTargets.clear();
|
||||
deltaBuffer = "";
|
||||
blockBuffer = "";
|
||||
blockChunker?.reset();
|
||||
@@ -398,6 +530,10 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
action === "threadReply" ||
|
||||
toolName === "sessions_send"
|
||||
) {
|
||||
const sendTarget = extractMessagingToolSend(toolName, argsRecord);
|
||||
if (sendTarget) {
|
||||
pendingMessagingTargets.set(toolCallId, sendTarget);
|
||||
}
|
||||
// Field names vary by tool: Discord/Slack use "content", sessions_send uses "message"
|
||||
const text =
|
||||
(argsRecord.content as string) ?? (argsRecord.message as string);
|
||||
@@ -460,6 +596,7 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
|
||||
// Commit messaging tool text on success, discard on error
|
||||
const pendingText = pendingMessagingTexts.get(toolCallId);
|
||||
const pendingTarget = pendingMessagingTargets.get(toolCallId);
|
||||
if (pendingText) {
|
||||
pendingMessagingTexts.delete(toolCallId);
|
||||
if (!isError) {
|
||||
@@ -469,6 +606,12 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (pendingTarget) {
|
||||
pendingMessagingTargets.delete(toolCallId);
|
||||
if (!isError) {
|
||||
messagingToolSentTargets.push(pendingTarget);
|
||||
}
|
||||
}
|
||||
|
||||
emitAgentEvent({
|
||||
runId: params.runId,
|
||||
@@ -779,6 +922,7 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
unsubscribe,
|
||||
isCompacting: () => compactionInFlight || pendingCompactionRetry > 0,
|
||||
getMessagingToolSentTexts: () => messagingToolSentTexts.slice(),
|
||||
getMessagingToolSentTargets: () => messagingToolSentTargets.slice(),
|
||||
// Returns true if any messaging tool successfully sent a message.
|
||||
// Used to suppress agent's confirmation text (e.g., "Respondi no Telegram!")
|
||||
// which is generated AFTER the tool sends the actual answer.
|
||||
|
||||
@@ -625,6 +625,7 @@ function shouldIncludeWhatsAppTool(messageProvider?: string): boolean {
|
||||
export function createClawdbotCodingTools(options?: {
|
||||
bash?: BashToolDefaults & ProcessToolDefaults;
|
||||
messageProvider?: string;
|
||||
agentAccountId?: string;
|
||||
sandbox?: SandboxContext | null;
|
||||
sessionKey?: string;
|
||||
agentDir?: string;
|
||||
@@ -695,6 +696,7 @@ export function createClawdbotCodingTools(options?: {
|
||||
browserControlUrl: sandbox?.browser?.controlUrl,
|
||||
agentSessionKey: options?.sessionKey,
|
||||
agentProvider: options?.messageProvider,
|
||||
agentAccountId: options?.agentAccountId,
|
||||
agentDir: options?.agentDir,
|
||||
sandboxed: !!sandbox,
|
||||
config: options?.config,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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()),
|
||||
}),
|
||||
]);
|
||||
|
||||
85
src/agents/tools/slack-tool.test.ts
Normal file
85
src/agents/tools/slack-tool.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user