Files
clawdbot/src/slack/monitor/slash.ts
2026-01-24 11:57:11 +00:00

593 lines
20 KiB
TypeScript

import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@slack/bolt";
import type { ChatCommandDefinition, CommandArgs } from "../../auto-reply/commands-registry.js";
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
import {
buildCommandTextFromArgs,
findCommandByNativeName,
listNativeCommandSpecsForConfig,
parseCommandArgs,
resolveCommandArgMenu,
} from "../../auto-reply/commands-registry.js";
import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js";
import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../../config/commands.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import { danger, logVerbose } from "../../globals.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js";
import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { resolveConversationLabel } from "../../channels/conversation-label.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js";
import type { ResolvedSlackAccount } from "../accounts.js";
import {
normalizeAllowList,
normalizeAllowListLower,
resolveSlackAllowListMatch,
resolveSlackUserAllowed,
} from "./allow-list.js";
import { resolveSlackChannelConfig, type SlackChannelConfigResolved } from "./channel-config.js";
import { buildSlackSlashCommandMatcher, resolveSlackSlashCommandConfig } from "./commands.js";
import type { SlackMonitorContext } from "./context.js";
import { isSlackChannelAllowedByPolicy } from "./policy.js";
import { deliverSlackSlashReplies } from "./replies.js";
type SlackBlock = { type: string; [key: string]: unknown };
const SLACK_COMMAND_ARG_ACTION_ID = "clawdbot_cmdarg";
const SLACK_COMMAND_ARG_VALUE_PREFIX = "cmdarg";
function chunkItems<T>(items: T[], size: number): T[][] {
if (size <= 0) return [items];
const rows: T[][] = [];
for (let i = 0; i < items.length; i += size) {
rows.push(items.slice(i, i + size));
}
return rows;
}
function encodeSlackCommandArgValue(parts: {
command: string;
arg: string;
value: string;
userId: string;
}) {
return [
SLACK_COMMAND_ARG_VALUE_PREFIX,
encodeURIComponent(parts.command),
encodeURIComponent(parts.arg),
encodeURIComponent(parts.value),
encodeURIComponent(parts.userId),
].join("|");
}
function parseSlackCommandArgValue(raw?: string | null): {
command: string;
arg: string;
value: string;
userId: string;
} | null {
if (!raw) return null;
const parts = raw.split("|");
if (parts.length !== 5 || parts[0] !== SLACK_COMMAND_ARG_VALUE_PREFIX) return null;
const [, command, arg, value, userId] = parts;
if (!command || !arg || !value || !userId) return null;
const decode = (text: string) => {
try {
return decodeURIComponent(text);
} catch {
return null;
}
};
const decodedCommand = decode(command);
const decodedArg = decode(arg);
const decodedValue = decode(value);
const decodedUserId = decode(userId);
if (!decodedCommand || !decodedArg || !decodedValue || !decodedUserId) return null;
return {
command: decodedCommand,
arg: decodedArg,
value: decodedValue,
userId: decodedUserId,
};
}
function buildSlackCommandArgMenuBlocks(params: {
title: string;
command: string;
arg: string;
choices: string[];
userId: string;
}) {
const rows = chunkItems(params.choices, 5).map((choices) => ({
type: "actions",
elements: choices.map((choice) => ({
type: "button",
action_id: SLACK_COMMAND_ARG_ACTION_ID,
text: { type: "plain_text", text: choice },
value: encodeSlackCommandArgValue({
command: params.command,
arg: params.arg,
value: choice,
userId: params.userId,
}),
})),
}));
return [
{
type: "section",
text: { type: "mrkdwn", text: params.title },
},
...rows,
];
}
export function registerSlackMonitorSlashCommands(params: {
ctx: SlackMonitorContext;
account: ResolvedSlackAccount;
}) {
const { ctx, account } = params;
const cfg = ctx.cfg;
const runtime = ctx.runtime;
const supportsInteractiveArgMenus =
typeof (ctx.app as { action?: unknown }).action === "function";
const slashCommand = resolveSlackSlashCommandConfig(
ctx.slashCommand ?? account.config.slashCommand,
);
const handleSlashCommand = async (p: {
command: SlackCommandMiddlewareArgs["command"];
ack: SlackCommandMiddlewareArgs["ack"];
respond: SlackCommandMiddlewareArgs["respond"];
prompt: string;
commandArgs?: CommandArgs;
commandDefinition?: ChatCommandDefinition;
}) => {
const { command, ack, respond, prompt, commandArgs, commandDefinition } = p;
try {
if (!prompt.trim()) {
await ack({
text: "Message required.",
response_type: "ephemeral",
});
return;
}
await ack();
if (ctx.botUserId && command.user_id === ctx.botUserId) return;
const channelInfo = await ctx.resolveChannelName(command.channel_id);
const channelType =
channelInfo?.type ?? (command.channel_name === "directmessage" ? "im" : undefined);
const isDirectMessage = channelType === "im";
const isGroupDm = channelType === "mpim";
const isRoom = channelType === "channel" || channelType === "group";
const isRoomish = isRoom || isGroupDm;
if (
!ctx.isChannelAllowed({
channelId: command.channel_id,
channelName: channelInfo?.name,
channelType,
})
) {
await respond({
text: "This channel is not allowed.",
response_type: "ephemeral",
});
return;
}
const storeAllowFrom = await readChannelAllowFromStore("slack").catch(() => []);
const effectiveAllowFrom = normalizeAllowList([...ctx.allowFrom, ...storeAllowFrom]);
const effectiveAllowFromLower = normalizeAllowListLower(effectiveAllowFrom);
let commandAuthorized = true;
let channelConfig: SlackChannelConfigResolved | null = null;
if (isDirectMessage) {
if (!ctx.dmEnabled || ctx.dmPolicy === "disabled") {
await respond({
text: "Slack DMs are disabled.",
response_type: "ephemeral",
});
return;
}
if (ctx.dmPolicy !== "open") {
const sender = await ctx.resolveUserName(command.user_id);
const senderName = sender?.name ?? undefined;
const allowMatch = resolveSlackAllowListMatch({
allowList: effectiveAllowFromLower,
id: command.user_id,
name: senderName,
});
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
if (!allowMatch.allowed) {
if (ctx.dmPolicy === "pairing") {
const { code, created } = await upsertChannelPairingRequest({
channel: "slack",
id: command.user_id,
meta: { name: senderName },
});
if (created) {
logVerbose(
`slack pairing request sender=${command.user_id} name=${
senderName ?? "unknown"
} (${allowMatchMeta})`,
);
await respond({
text: buildPairingReply({
channel: "slack",
idLine: `Your Slack user id: ${command.user_id}`,
code,
}),
response_type: "ephemeral",
});
}
} else {
logVerbose(
`slack: blocked slash sender ${command.user_id} (dmPolicy=${ctx.dmPolicy}, ${allowMatchMeta})`,
);
await respond({
text: "You are not authorized to use this command.",
response_type: "ephemeral",
});
}
return;
}
commandAuthorized = true;
}
}
if (isRoom) {
channelConfig = resolveSlackChannelConfig({
channelId: command.channel_id,
channelName: channelInfo?.name,
channels: ctx.channelsConfig,
defaultRequireMention: ctx.defaultRequireMention,
});
if (ctx.useAccessGroups) {
const channelAllowlistConfigured =
Boolean(ctx.channelsConfig) && Object.keys(ctx.channelsConfig ?? {}).length > 0;
const channelAllowed = channelConfig?.allowed !== false;
if (
!isSlackChannelAllowedByPolicy({
groupPolicy: ctx.groupPolicy,
channelAllowlistConfigured,
channelAllowed,
})
) {
await respond({
text: "This channel is not allowed.",
response_type: "ephemeral",
});
return;
}
// When groupPolicy is "open", only block channels that are EXPLICITLY denied
// (i.e., have a matching config entry with allow:false). Channels not in the
// config (matchSource undefined) should be allowed under open policy.
const hasExplicitConfig = Boolean(channelConfig?.matchSource);
if (!channelAllowed && (ctx.groupPolicy !== "open" || hasExplicitConfig)) {
await respond({
text: "This channel is not allowed.",
response_type: "ephemeral",
});
return;
}
}
}
const sender = await ctx.resolveUserName(command.user_id);
const senderName = sender?.name ?? command.user_name ?? command.user_id;
const channelUsersAllowlistConfigured =
isRoom && Array.isArray(channelConfig?.users) && channelConfig.users.length > 0;
const channelUserAllowed = channelUsersAllowlistConfigured
? resolveSlackUserAllowed({
allowList: channelConfig?.users,
userId: command.user_id,
userName: senderName,
})
: false;
if (channelUsersAllowlistConfigured && !channelUserAllowed) {
await respond({
text: "You are not authorized to use this command here.",
response_type: "ephemeral",
});
return;
}
const ownerAllowed = resolveSlackAllowListMatch({
allowList: effectiveAllowFromLower,
id: command.user_id,
name: senderName,
}).allowed;
if (isRoomish) {
commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
useAccessGroups: ctx.useAccessGroups,
authorizers: [
{ configured: effectiveAllowFromLower.length > 0, allowed: ownerAllowed },
{ configured: channelUsersAllowlistConfigured, allowed: channelUserAllowed },
],
});
if (ctx.useAccessGroups && !commandAuthorized) {
await respond({
text: "You are not authorized to use this command.",
response_type: "ephemeral",
});
return;
}
}
if (commandDefinition && supportsInteractiveArgMenus) {
const menu = resolveCommandArgMenu({
command: commandDefinition,
args: commandArgs,
cfg,
});
if (menu) {
const commandLabel = commandDefinition.nativeName ?? commandDefinition.key;
const title =
menu.title ?? `Choose ${menu.arg.description || menu.arg.name} for /${commandLabel}.`;
const blocks = buildSlackCommandArgMenuBlocks({
title,
command: commandLabel,
arg: menu.arg.name,
choices: menu.choices,
userId: command.user_id,
});
await respond({
text: title,
blocks,
response_type: "ephemeral",
});
return;
}
}
const channelName = channelInfo?.name;
const roomLabel = channelName ? `#${channelName}` : `#${command.channel_id}`;
const route = resolveAgentRoute({
cfg,
channel: "slack",
accountId: account.accountId,
teamId: ctx.teamId || undefined,
peer: {
kind: isDirectMessage ? "dm" : isRoom ? "channel" : "group",
id: isDirectMessage ? command.user_id : command.channel_id,
},
});
const channelDescription = [channelInfo?.topic, channelInfo?.purpose]
.map((entry) => entry?.trim())
.filter((entry): entry is string => Boolean(entry))
.filter((entry, index, list) => list.indexOf(entry) === index)
.join("\n");
const systemPromptParts = [
channelDescription ? `Channel description: ${channelDescription}` : null,
channelConfig?.systemPrompt?.trim() || null,
].filter((entry): entry is string => Boolean(entry));
const groupSystemPrompt =
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
const ctxPayload = finalizeInboundContext({
Body: prompt,
RawBody: prompt,
CommandBody: prompt,
CommandArgs: commandArgs,
From: isDirectMessage
? `slack:${command.user_id}`
: isRoom
? `slack:channel:${command.channel_id}`
: `slack:group:${command.channel_id}`,
To: `slash:${command.user_id}`,
ChatType: isDirectMessage ? "direct" : "channel",
ConversationLabel:
resolveConversationLabel({
ChatType: isDirectMessage ? "direct" : "channel",
SenderName: senderName,
GroupSubject: isRoomish ? roomLabel : undefined,
From: isDirectMessage
? `slack:${command.user_id}`
: isRoom
? `slack:channel:${command.channel_id}`
: `slack:group:${command.channel_id}`,
}) ?? (isDirectMessage ? senderName : roomLabel),
GroupSubject: isRoomish ? roomLabel : undefined,
GroupSystemPrompt: isRoomish ? groupSystemPrompt : undefined,
SenderName: senderName,
SenderId: command.user_id,
Provider: "slack" as const,
Surface: "slack" as const,
WasMentioned: true,
MessageSid: command.trigger_id,
Timestamp: Date.now(),
SessionKey:
`agent:${route.agentId}:${slashCommand.sessionPrefix}:${command.user_id}`.toLowerCase(),
CommandTargetSessionKey: route.sessionKey,
AccountId: route.accountId,
CommandSource: "native" as const,
CommandAuthorized: commandAuthorized,
OriginatingChannel: "slack" as const,
OriginatingTo: `user:${command.user_id}`,
});
const { counts } = await dispatchReplyWithDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
deliver: async (payload) => {
await deliverSlackSlashReplies({
replies: [payload],
respond,
ephemeral: slashCommand.ephemeral,
textLimit: ctx.textLimit,
tableMode: resolveMarkdownTableMode({
cfg,
channel: "slack",
accountId: route.accountId,
}),
});
},
onError: (err, info) => {
runtime.error?.(danger(`slack slash ${info.kind} reply failed: ${String(err)}`));
},
},
replyOptions: { skillFilter: channelConfig?.skills },
});
if (counts.final + counts.tool + counts.block === 0) {
await deliverSlackSlashReplies({
replies: [],
respond,
ephemeral: slashCommand.ephemeral,
textLimit: ctx.textLimit,
tableMode: resolveMarkdownTableMode({
cfg,
channel: "slack",
accountId: route.accountId,
}),
});
}
} catch (err) {
runtime.error?.(danger(`slack slash handler failed: ${String(err)}`));
await respond({
text: "Sorry, something went wrong handling that command.",
response_type: "ephemeral",
});
}
};
const nativeEnabled = resolveNativeCommandsEnabled({
providerId: "slack",
providerSetting: account.config.commands?.native,
globalSetting: cfg.commands?.native,
});
const nativeSkillsEnabled = resolveNativeSkillsEnabled({
providerId: "slack",
providerSetting: account.config.commands?.nativeSkills,
globalSetting: cfg.commands?.nativeSkills,
});
const skillCommands =
nativeEnabled && nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : [];
const nativeCommands = nativeEnabled
? listNativeCommandSpecsForConfig(cfg, { skillCommands, provider: "slack" })
: [];
if (nativeCommands.length > 0) {
for (const command of nativeCommands) {
ctx.app.command(
`/${command.name}`,
async ({ command: cmd, ack, respond }: SlackCommandMiddlewareArgs) => {
const commandDefinition = findCommandByNativeName(command.name, "slack");
const rawText = cmd.text?.trim() ?? "";
const commandArgs = commandDefinition
? parseCommandArgs(commandDefinition, rawText)
: rawText
? ({ raw: rawText } satisfies CommandArgs)
: undefined;
const prompt = commandDefinition
? buildCommandTextFromArgs(commandDefinition, commandArgs)
: rawText
? `/${command.name} ${rawText}`
: `/${command.name}`;
await handleSlashCommand({
command: cmd,
ack,
respond,
prompt,
commandArgs,
commandDefinition: commandDefinition ?? undefined,
});
},
);
}
} else if (slashCommand.enabled) {
ctx.app.command(
buildSlackSlashCommandMatcher(slashCommand.name),
async ({ command, ack, respond }: SlackCommandMiddlewareArgs) => {
await handleSlashCommand({
command,
ack,
respond,
prompt: command.text?.trim() ?? "",
});
},
);
} else {
logVerbose("slack: slash commands disabled");
}
if (nativeCommands.length === 0 || !supportsInteractiveArgMenus) return;
(
ctx.app as unknown as { action: NonNullable<(typeof ctx.app & { action?: unknown })["action"]> }
).action(SLACK_COMMAND_ARG_ACTION_ID, async (args: SlackActionMiddlewareArgs) => {
const { ack, body, respond } = args;
const action = args.action as { value?: string };
await ack();
const respondFn =
respond ??
(async (payload: { text: string; blocks?: SlackBlock[]; response_type?: string }) => {
if (!body.channel?.id || !body.user?.id) return;
await ctx.app.client.chat.postEphemeral({
token: ctx.botToken,
channel: body.channel.id,
user: body.user.id,
text: payload.text,
blocks: payload.blocks,
});
});
const parsed = parseSlackCommandArgValue(action?.value);
if (!parsed) {
await respondFn({
text: "Sorry, that button is no longer valid.",
response_type: "ephemeral",
});
return;
}
if (body.user?.id && parsed.userId !== body.user.id) {
await respondFn({
text: "That menu is for another user.",
response_type: "ephemeral",
});
return;
}
const commandDefinition = findCommandByNativeName(parsed.command, "slack");
const commandArgs: CommandArgs = {
values: { [parsed.arg]: parsed.value },
};
const prompt = commandDefinition
? buildCommandTextFromArgs(commandDefinition, commandArgs)
: `/${parsed.command} ${parsed.value}`;
const user = body.user;
const userName =
user && "name" in user && user.name
? user.name
: user && "username" in user && user.username
? user.username
: (user?.id ?? "");
const triggerId = "trigger_id" in body ? body.trigger_id : undefined;
const commandPayload = {
user_id: user?.id ?? "",
user_name: userName,
channel_id: body.channel?.id ?? "",
channel_name: body.channel?.name ?? body.channel?.id ?? "",
trigger_id: triggerId ?? String(Date.now()),
} as SlackCommandMiddlewareArgs["command"];
await handleSlashCommand({
command: commandPayload,
ack: async () => {},
respond: respondFn as SlackCommandMiddlewareArgs["respond"],
prompt,
commandArgs,
commandDefinition: commandDefinition ?? undefined,
});
});
}