mirror of https://github.com/openclaw/openclaw.git
883 lines
30 KiB
TypeScript
883 lines
30 KiB
TypeScript
import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@slack/bolt";
|
|
import {
|
|
type ChatCommandDefinition,
|
|
type CommandArgs,
|
|
} from "../../auto-reply/commands-registry.js";
|
|
import type { ReplyPayload } from "../../auto-reply/types.js";
|
|
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
|
|
import { resolveNativeCommandSessionTargets } from "../../channels/native-command-session-targets.js";
|
|
import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../../config/commands.js";
|
|
import { danger, logVerbose } from "../../globals.js";
|
|
import { chunkItems } from "../../utils/chunk-items.js";
|
|
import type { ResolvedSlackAccount } from "../accounts.js";
|
|
import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "./allow-list.js";
|
|
import { resolveSlackEffectiveAllowFrom } from "./auth.js";
|
|
import { resolveSlackChannelConfig, type SlackChannelConfigResolved } from "./channel-config.js";
|
|
import { buildSlackSlashCommandMatcher, resolveSlackSlashCommandConfig } from "./commands.js";
|
|
import type { SlackMonitorContext } from "./context.js";
|
|
import { normalizeSlackChannelType } from "./context.js";
|
|
import { authorizeSlackDirectMessage } from "./dm-auth.js";
|
|
import {
|
|
createSlackExternalArgMenuStore,
|
|
SLACK_EXTERNAL_ARG_MENU_PREFIX,
|
|
type SlackExternalArgMenuChoice,
|
|
} from "./external-arg-menu-store.js";
|
|
import { escapeSlackMrkdwn } from "./mrkdwn.js";
|
|
import { isSlackChannelAllowedByPolicy } from "./policy.js";
|
|
import { resolveSlackRoomContextHints } from "./room-context.js";
|
|
|
|
type SlackBlock = { type: string; [key: string]: unknown };
|
|
|
|
const SLACK_COMMAND_ARG_ACTION_ID = "openclaw_cmdarg";
|
|
const SLACK_COMMAND_ARG_VALUE_PREFIX = "cmdarg";
|
|
const SLACK_COMMAND_ARG_BUTTON_ROW_SIZE = 5;
|
|
const SLACK_COMMAND_ARG_OVERFLOW_MIN = 3;
|
|
const SLACK_COMMAND_ARG_OVERFLOW_MAX = 5;
|
|
const SLACK_COMMAND_ARG_SELECT_OPTIONS_MAX = 100;
|
|
const SLACK_COMMAND_ARG_SELECT_OPTION_VALUE_MAX = 75;
|
|
const SLACK_HEADER_TEXT_MAX = 150;
|
|
let slashCommandsRuntimePromise: Promise<typeof import("./slash-commands.runtime.js")> | null =
|
|
null;
|
|
let slashDispatchRuntimePromise: Promise<typeof import("./slash-dispatch.runtime.js")> | null =
|
|
null;
|
|
let slashSkillCommandsRuntimePromise: Promise<
|
|
typeof import("./slash-skill-commands.runtime.js")
|
|
> | null = null;
|
|
|
|
function loadSlashCommandsRuntime() {
|
|
slashCommandsRuntimePromise ??= import("./slash-commands.runtime.js");
|
|
return slashCommandsRuntimePromise;
|
|
}
|
|
|
|
function loadSlashDispatchRuntime() {
|
|
slashDispatchRuntimePromise ??= import("./slash-dispatch.runtime.js");
|
|
return slashDispatchRuntimePromise;
|
|
}
|
|
|
|
function loadSlashSkillCommandsRuntime() {
|
|
slashSkillCommandsRuntimePromise ??= import("./slash-skill-commands.runtime.js");
|
|
return slashSkillCommandsRuntimePromise;
|
|
}
|
|
|
|
type EncodedMenuChoice = SlackExternalArgMenuChoice;
|
|
const slackExternalArgMenuStore = createSlackExternalArgMenuStore();
|
|
|
|
function truncatePlainText(value: string, max: number): string {
|
|
const trimmed = value.trim();
|
|
if (trimmed.length <= max) {
|
|
return trimmed;
|
|
}
|
|
if (max <= 1) {
|
|
return trimmed.slice(0, max);
|
|
}
|
|
return `${trimmed.slice(0, max - 1)}…`;
|
|
}
|
|
|
|
function buildSlackArgMenuConfirm(params: { command: string; arg: string }) {
|
|
const command = escapeSlackMrkdwn(params.command);
|
|
const arg = escapeSlackMrkdwn(params.arg);
|
|
return {
|
|
title: { type: "plain_text", text: "Confirm selection" },
|
|
text: {
|
|
type: "mrkdwn",
|
|
text: `Run */${command}* with *${arg}* set to this value?`,
|
|
},
|
|
confirm: { type: "plain_text", text: "Run command" },
|
|
deny: { type: "plain_text", text: "Cancel" },
|
|
};
|
|
}
|
|
|
|
function storeSlackExternalArgMenu(params: {
|
|
choices: EncodedMenuChoice[];
|
|
userId: string;
|
|
}): string {
|
|
return slackExternalArgMenuStore.create({
|
|
choices: params.choices,
|
|
userId: params.userId,
|
|
});
|
|
}
|
|
|
|
function readSlackExternalArgMenuToken(raw: unknown): string | undefined {
|
|
return slackExternalArgMenuStore.readToken(raw);
|
|
}
|
|
|
|
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 buildSlackArgMenuOptions(choices: EncodedMenuChoice[]) {
|
|
return choices.map((choice) => ({
|
|
text: { type: "plain_text", text: choice.label.slice(0, 75) },
|
|
value: choice.value,
|
|
}));
|
|
}
|
|
|
|
function buildSlackCommandArgMenuBlocks(params: {
|
|
title: string;
|
|
command: string;
|
|
arg: string;
|
|
choices: Array<{ value: string; label: string }>;
|
|
userId: string;
|
|
supportsExternalSelect: boolean;
|
|
createExternalMenuToken: (choices: EncodedMenuChoice[]) => string;
|
|
}) {
|
|
const encodedChoices = params.choices.map((choice) => ({
|
|
label: choice.label,
|
|
value: encodeSlackCommandArgValue({
|
|
command: params.command,
|
|
arg: params.arg,
|
|
value: choice.value,
|
|
userId: params.userId,
|
|
}),
|
|
}));
|
|
const canUseStaticSelect = encodedChoices.every(
|
|
(choice) => choice.value.length <= SLACK_COMMAND_ARG_SELECT_OPTION_VALUE_MAX,
|
|
);
|
|
const canUseOverflow =
|
|
canUseStaticSelect &&
|
|
encodedChoices.length >= SLACK_COMMAND_ARG_OVERFLOW_MIN &&
|
|
encodedChoices.length <= SLACK_COMMAND_ARG_OVERFLOW_MAX;
|
|
const canUseExternalSelect =
|
|
params.supportsExternalSelect &&
|
|
canUseStaticSelect &&
|
|
encodedChoices.length > SLACK_COMMAND_ARG_SELECT_OPTIONS_MAX;
|
|
const rows = canUseOverflow
|
|
? [
|
|
{
|
|
type: "actions",
|
|
elements: [
|
|
{
|
|
type: "overflow",
|
|
action_id: SLACK_COMMAND_ARG_ACTION_ID,
|
|
confirm: buildSlackArgMenuConfirm({ command: params.command, arg: params.arg }),
|
|
options: buildSlackArgMenuOptions(encodedChoices),
|
|
},
|
|
],
|
|
},
|
|
]
|
|
: canUseExternalSelect
|
|
? [
|
|
{
|
|
type: "actions",
|
|
block_id: `${SLACK_EXTERNAL_ARG_MENU_PREFIX}${params.createExternalMenuToken(
|
|
encodedChoices,
|
|
)}`,
|
|
elements: [
|
|
{
|
|
type: "external_select",
|
|
action_id: SLACK_COMMAND_ARG_ACTION_ID,
|
|
confirm: buildSlackArgMenuConfirm({ command: params.command, arg: params.arg }),
|
|
min_query_length: 0,
|
|
placeholder: {
|
|
type: "plain_text",
|
|
text: `Search ${params.arg}`,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
]
|
|
: encodedChoices.length <= SLACK_COMMAND_ARG_BUTTON_ROW_SIZE || !canUseStaticSelect
|
|
? chunkItems(encodedChoices, SLACK_COMMAND_ARG_BUTTON_ROW_SIZE).map((choices) => ({
|
|
type: "actions",
|
|
elements: choices.map((choice) => ({
|
|
type: "button",
|
|
action_id: SLACK_COMMAND_ARG_ACTION_ID,
|
|
text: { type: "plain_text", text: choice.label },
|
|
value: choice.value,
|
|
confirm: buildSlackArgMenuConfirm({ command: params.command, arg: params.arg }),
|
|
})),
|
|
}))
|
|
: chunkItems(encodedChoices, SLACK_COMMAND_ARG_SELECT_OPTIONS_MAX).map(
|
|
(choices, index) => ({
|
|
type: "actions",
|
|
elements: [
|
|
{
|
|
type: "static_select",
|
|
action_id: SLACK_COMMAND_ARG_ACTION_ID,
|
|
confirm: buildSlackArgMenuConfirm({ command: params.command, arg: params.arg }),
|
|
placeholder: {
|
|
type: "plain_text",
|
|
text:
|
|
index === 0 ? `Choose ${params.arg}` : `Choose ${params.arg} (${index + 1})`,
|
|
},
|
|
options: buildSlackArgMenuOptions(choices),
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const headerText = truncatePlainText(
|
|
`/${params.command}: choose ${params.arg}`,
|
|
SLACK_HEADER_TEXT_MAX,
|
|
);
|
|
const sectionText = truncatePlainText(params.title, 3000);
|
|
const contextText = truncatePlainText(
|
|
`Select one option to continue /${params.command} (${params.arg})`,
|
|
3000,
|
|
);
|
|
return [
|
|
{
|
|
type: "header",
|
|
text: { type: "plain_text", text: headerText },
|
|
},
|
|
{
|
|
type: "section",
|
|
text: { type: "mrkdwn", text: sectionText },
|
|
},
|
|
{
|
|
type: "context",
|
|
elements: [{ type: "mrkdwn", text: contextText }],
|
|
},
|
|
...rows,
|
|
];
|
|
}
|
|
|
|
export async function registerSlackMonitorSlashCommands(params: {
|
|
ctx: SlackMonitorContext;
|
|
account: ResolvedSlackAccount;
|
|
}): Promise<void> {
|
|
const { ctx, account } = params;
|
|
const cfg = ctx.cfg;
|
|
const runtime = ctx.runtime;
|
|
|
|
const supportsInteractiveArgMenus =
|
|
typeof (ctx.app as { action?: unknown }).action === "function";
|
|
let supportsExternalArgMenus = typeof (ctx.app as { options?: unknown }).options === "function";
|
|
|
|
const slashCommand = resolveSlackSlashCommandConfig(
|
|
ctx.slashCommand ?? account.config.slashCommand,
|
|
);
|
|
|
|
const handleSlashCommand = async (p: {
|
|
command: SlackCommandMiddlewareArgs["command"];
|
|
ack: SlackCommandMiddlewareArgs["ack"];
|
|
respond: SlackCommandMiddlewareArgs["respond"];
|
|
body?: unknown;
|
|
prompt: string;
|
|
commandArgs?: CommandArgs;
|
|
commandDefinition?: ChatCommandDefinition;
|
|
}) => {
|
|
const { command, ack, respond, body, prompt, commandArgs, commandDefinition } = p;
|
|
try {
|
|
if (ctx.shouldDropMismatchedSlackEvent?.(body)) {
|
|
await ack();
|
|
runtime.log?.(
|
|
`slack: drop slash command from user=${command.user_id ?? "unknown"} channel=${command.channel_id ?? "unknown"} (mismatched app/team)`,
|
|
);
|
|
return;
|
|
}
|
|
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 rawChannelType =
|
|
channelInfo?.type ?? (command.channel_name === "directmessage" ? "im" : undefined);
|
|
const channelType = normalizeSlackChannelType(rawChannelType, command.channel_id);
|
|
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 { allowFromLower: effectiveAllowFromLower } = await resolveSlackEffectiveAllowFrom(
|
|
ctx,
|
|
{
|
|
includePairingStore: isDirectMessage,
|
|
},
|
|
);
|
|
|
|
// Privileged command surface: compute CommandAuthorized, don't assume true.
|
|
// Keep this aligned with the Slack message path (message-handler/prepare.ts).
|
|
let commandAuthorized = false;
|
|
let channelConfig: SlackChannelConfigResolved | null = null;
|
|
if (isDirectMessage) {
|
|
const allowed = await authorizeSlackDirectMessage({
|
|
ctx,
|
|
accountId: ctx.accountId,
|
|
senderId: command.user_id,
|
|
allowFromLower: effectiveAllowFromLower,
|
|
resolveSenderName: ctx.resolveUserName,
|
|
sendPairingReply: async (text) => {
|
|
await respond({
|
|
text,
|
|
response_type: "ephemeral",
|
|
});
|
|
},
|
|
onDisabled: async () => {
|
|
await respond({
|
|
text: "Slack DMs are disabled.",
|
|
response_type: "ephemeral",
|
|
});
|
|
},
|
|
onUnauthorized: async ({ allowMatchMeta }) => {
|
|
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",
|
|
});
|
|
},
|
|
log: logVerbose,
|
|
});
|
|
if (!allowed) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (isRoom) {
|
|
channelConfig = resolveSlackChannelConfig({
|
|
channelId: command.channel_id,
|
|
channelName: channelInfo?.name,
|
|
channels: ctx.channelsConfig,
|
|
channelKeys: ctx.channelsConfigKeys,
|
|
defaultRequireMention: ctx.defaultRequireMention,
|
|
allowNameMatching: ctx.allowNameMatching,
|
|
});
|
|
if (ctx.useAccessGroups) {
|
|
const channelAllowlistConfigured = (ctx.channelsConfigKeys?.length ?? 0) > 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,
|
|
allowNameMatching: ctx.allowNameMatching,
|
|
})
|
|
: 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,
|
|
allowNameMatching: ctx.allowNameMatching,
|
|
}).allowed;
|
|
// DMs: allow chatting in dmPolicy=open, but keep privileged command gating intact by setting
|
|
// CommandAuthorized based on allowlists/access-groups (downstream decides which commands need it).
|
|
commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
|
useAccessGroups: ctx.useAccessGroups,
|
|
authorizers: [{ configured: effectiveAllowFromLower.length > 0, allowed: ownerAllowed }],
|
|
modeWhenAccessGroupsOff: "configured",
|
|
});
|
|
if (isRoomish) {
|
|
commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
|
useAccessGroups: ctx.useAccessGroups,
|
|
authorizers: [
|
|
{ configured: effectiveAllowFromLower.length > 0, allowed: ownerAllowed },
|
|
{ configured: channelUsersAllowlistConfigured, allowed: channelUserAllowed },
|
|
],
|
|
modeWhenAccessGroupsOff: "configured",
|
|
});
|
|
if (ctx.useAccessGroups && !commandAuthorized) {
|
|
await respond({
|
|
text: "You are not authorized to use this command.",
|
|
response_type: "ephemeral",
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (commandDefinition && supportsInteractiveArgMenus) {
|
|
const { resolveCommandArgMenu } = await loadSlashCommandsRuntime();
|
|
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,
|
|
supportsExternalSelect: supportsExternalArgMenus,
|
|
createExternalMenuToken: (choices) =>
|
|
storeSlackExternalArgMenu({ 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 {
|
|
createReplyPrefixOptions,
|
|
deliverSlackSlashReplies,
|
|
dispatchReplyWithDispatcher,
|
|
finalizeInboundContext,
|
|
recordInboundSessionMetaSafe,
|
|
resolveAgentRoute,
|
|
resolveChunkMode,
|
|
resolveConversationLabel,
|
|
resolveMarkdownTableMode,
|
|
} = await loadSlashDispatchRuntime();
|
|
|
|
const route = resolveAgentRoute({
|
|
cfg,
|
|
channel: "slack",
|
|
accountId: account.accountId,
|
|
teamId: ctx.teamId || undefined,
|
|
peer: {
|
|
kind: isDirectMessage ? "direct" : isRoom ? "channel" : "group",
|
|
id: isDirectMessage ? command.user_id : command.channel_id,
|
|
},
|
|
});
|
|
|
|
const { untrustedChannelMetadata, groupSystemPrompt } = resolveSlackRoomContextHints({
|
|
isRoomish,
|
|
channelInfo,
|
|
channelConfig,
|
|
});
|
|
|
|
const { sessionKey, commandTargetSessionKey } = resolveNativeCommandSessionTargets({
|
|
agentId: route.agentId,
|
|
sessionPrefix: slashCommand.sessionPrefix,
|
|
userId: command.user_id,
|
|
targetSessionKey: route.sessionKey,
|
|
lowercaseSessionKey: true,
|
|
});
|
|
const ctxPayload = finalizeInboundContext({
|
|
Body: prompt,
|
|
BodyForAgent: 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,
|
|
UntrustedContext: untrustedChannelMetadata ? [untrustedChannelMetadata] : 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: sessionKey,
|
|
CommandTargetSessionKey: commandTargetSessionKey,
|
|
AccountId: route.accountId,
|
|
CommandSource: "native" as const,
|
|
CommandAuthorized: commandAuthorized,
|
|
OriginatingChannel: "slack" as const,
|
|
OriginatingTo: `user:${command.user_id}`,
|
|
});
|
|
|
|
await recordInboundSessionMetaSafe({
|
|
cfg,
|
|
agentId: route.agentId,
|
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
ctx: ctxPayload,
|
|
onError: (err) =>
|
|
runtime.error?.(danger(`slack slash: failed updating session meta: ${String(err)}`)),
|
|
});
|
|
|
|
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
|
cfg,
|
|
agentId: route.agentId,
|
|
channel: "slack",
|
|
accountId: route.accountId,
|
|
});
|
|
|
|
const deliverSlashPayloads = async (replies: ReplyPayload[]) => {
|
|
await deliverSlackSlashReplies({
|
|
replies,
|
|
respond,
|
|
ephemeral: slashCommand.ephemeral,
|
|
textLimit: ctx.textLimit,
|
|
chunkMode: resolveChunkMode(cfg, "slack", route.accountId),
|
|
tableMode: resolveMarkdownTableMode({
|
|
cfg,
|
|
channel: "slack",
|
|
accountId: route.accountId,
|
|
}),
|
|
});
|
|
};
|
|
|
|
const { counts } = await dispatchReplyWithDispatcher({
|
|
ctx: ctxPayload,
|
|
cfg,
|
|
dispatcherOptions: {
|
|
...prefixOptions,
|
|
deliver: async (payload) => deliverSlashPayloads([payload]),
|
|
onError: (err, info) => {
|
|
runtime.error?.(danger(`slack slash ${info.kind} reply failed: ${String(err)}`));
|
|
},
|
|
},
|
|
replyOptions: {
|
|
skillFilter: channelConfig?.skills,
|
|
onModelSelected,
|
|
},
|
|
});
|
|
if (counts.final + counts.tool + counts.block === 0) {
|
|
await deliverSlashPayloads([]);
|
|
}
|
|
} 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,
|
|
});
|
|
|
|
let nativeCommands: Array<{ name: string }> = [];
|
|
let slashCommandsRuntime: typeof import("./slash-commands.runtime.js") | null = null;
|
|
if (nativeEnabled) {
|
|
slashCommandsRuntime = await loadSlashCommandsRuntime();
|
|
const skillCommands = nativeSkillsEnabled
|
|
? (await loadSlashSkillCommandsRuntime()).listSkillCommandsForAgents({ cfg })
|
|
: [];
|
|
nativeCommands = slashCommandsRuntime.listNativeCommandSpecsForConfig(cfg, {
|
|
skillCommands,
|
|
provider: "slack",
|
|
});
|
|
}
|
|
|
|
if (nativeCommands.length > 0) {
|
|
if (!slashCommandsRuntime) {
|
|
throw new Error("Missing commands runtime for native Slack commands.");
|
|
}
|
|
for (const command of nativeCommands) {
|
|
ctx.app.command(
|
|
`/${command.name}`,
|
|
async ({ command: cmd, ack, respond, body }: SlackCommandMiddlewareArgs) => {
|
|
const commandDefinition = slashCommandsRuntime.findCommandByNativeName(
|
|
command.name,
|
|
"slack",
|
|
);
|
|
const rawText = cmd.text?.trim() ?? "";
|
|
const commandArgs = commandDefinition
|
|
? slashCommandsRuntime.parseCommandArgs(commandDefinition, rawText)
|
|
: rawText
|
|
? ({ raw: rawText } satisfies CommandArgs)
|
|
: undefined;
|
|
const prompt = commandDefinition
|
|
? slashCommandsRuntime.buildCommandTextFromArgs(commandDefinition, commandArgs)
|
|
: rawText
|
|
? `/${command.name} ${rawText}`
|
|
: `/${command.name}`;
|
|
await handleSlashCommand({
|
|
command: cmd,
|
|
ack,
|
|
respond,
|
|
body,
|
|
prompt,
|
|
commandArgs,
|
|
commandDefinition: commandDefinition ?? undefined,
|
|
});
|
|
},
|
|
);
|
|
}
|
|
} else if (slashCommand.enabled) {
|
|
ctx.app.command(
|
|
buildSlackSlashCommandMatcher(slashCommand.name),
|
|
async ({ command, ack, respond, body }: SlackCommandMiddlewareArgs) => {
|
|
await handleSlashCommand({
|
|
command,
|
|
ack,
|
|
respond,
|
|
body,
|
|
prompt: command.text?.trim() ?? "",
|
|
});
|
|
},
|
|
);
|
|
} else {
|
|
logVerbose("slack: slash commands disabled");
|
|
}
|
|
|
|
if (nativeCommands.length === 0 || !supportsInteractiveArgMenus) {
|
|
return;
|
|
}
|
|
|
|
const registerArgOptions = () => {
|
|
const appWithOptions = ctx.app as unknown as {
|
|
options?: (
|
|
actionId: string,
|
|
handler: (args: {
|
|
ack: (payload: { options: unknown[] }) => Promise<void>;
|
|
body: unknown;
|
|
}) => Promise<void>,
|
|
) => void;
|
|
};
|
|
if (typeof appWithOptions.options !== "function") {
|
|
return;
|
|
}
|
|
appWithOptions.options(SLACK_COMMAND_ARG_ACTION_ID, async ({ ack, body }) => {
|
|
if (ctx.shouldDropMismatchedSlackEvent?.(body)) {
|
|
await ack({ options: [] });
|
|
runtime.log?.("slack: drop slash arg options payload (mismatched app/team)");
|
|
return;
|
|
}
|
|
const typedBody = body as {
|
|
value?: string;
|
|
user?: { id?: string };
|
|
actions?: Array<{ block_id?: string }>;
|
|
block_id?: string;
|
|
};
|
|
const blockId = typedBody.actions?.[0]?.block_id ?? typedBody.block_id;
|
|
const token = readSlackExternalArgMenuToken(blockId);
|
|
if (!token) {
|
|
await ack({ options: [] });
|
|
return;
|
|
}
|
|
const entry = slackExternalArgMenuStore.get(token);
|
|
if (!entry) {
|
|
await ack({ options: [] });
|
|
return;
|
|
}
|
|
const requesterUserId = typedBody.user?.id?.trim();
|
|
if (!requesterUserId || requesterUserId !== entry.userId) {
|
|
await ack({ options: [] });
|
|
return;
|
|
}
|
|
const query = typedBody.value?.trim().toLowerCase() ?? "";
|
|
const options = entry.choices
|
|
.filter((choice) => !query || choice.label.toLowerCase().includes(query))
|
|
.slice(0, SLACK_COMMAND_ARG_SELECT_OPTIONS_MAX)
|
|
.map((choice) => ({
|
|
text: { type: "plain_text", text: choice.label.slice(0, 75) },
|
|
value: choice.value,
|
|
}));
|
|
await ack({ options });
|
|
});
|
|
};
|
|
// Treat external arg-menu registration as best-effort: if Bolt's app.options()
|
|
// throws (e.g. from receiver init issues), disable external selects and fall back
|
|
// to static_select/button menus instead of crashing the entire provider startup.
|
|
try {
|
|
registerArgOptions();
|
|
} catch (err) {
|
|
supportsExternalArgMenus = false;
|
|
logVerbose(
|
|
`slack: external arg-menu registration failed, falling back to static menus: ${String(err)}`,
|
|
);
|
|
}
|
|
|
|
const registerArgAction = (actionId: string) => {
|
|
(
|
|
ctx.app as unknown as {
|
|
action: NonNullable<(typeof ctx.app & { action?: unknown })["action"]>;
|
|
}
|
|
).action(actionId, async (args: SlackActionMiddlewareArgs) => {
|
|
const { ack, body, respond } = args;
|
|
const action = args.action as { value?: string; selected_option?: { value?: string } };
|
|
await ack();
|
|
if (ctx.shouldDropMismatchedSlackEvent?.(body)) {
|
|
runtime.log?.("slack: drop slash arg action payload (mismatched app/team)");
|
|
return;
|
|
}
|
|
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 actionValue = action?.value ?? action?.selected_option?.value;
|
|
const parsed = parseSlackCommandArgValue(actionValue);
|
|
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 { buildCommandTextFromArgs, findCommandByNativeName } =
|
|
await loadSlashCommandsRuntime();
|
|
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,
|
|
} as SlackCommandMiddlewareArgs["command"];
|
|
await handleSlashCommand({
|
|
command: commandPayload,
|
|
ack: async () => {},
|
|
respond: respondFn,
|
|
body,
|
|
prompt,
|
|
commandArgs,
|
|
commandDefinition: commandDefinition ?? undefined,
|
|
});
|
|
});
|
|
};
|
|
registerArgAction(SLACK_COMMAND_ARG_ACTION_ID);
|
|
}
|