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(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, }); }); }