Files
openclaw/extensions/slack/src/action-runtime.ts
Sarah Fortune 00fc1bd123 fix(slack): scope enterprise channel and user policies by workspace (#122346)
* fix(slack): scope channel policies by workspace

* fix(slack): scope user policies by workspace

* fix(slack): preserve workspace policies at ingress

* test(slack): use canonical workspace ids

* chore(slack): remove stale allowlist import

* fix(slack): require workspace-scoped grid policies

* test(slack): scope enterprise policy fixtures

* style(slack): format workspace policy changes

* fix(slack): preserve workspace policy wildcards

* fix(slack): retain workspace system-event policies

* fix(slack): scope bot policy identities

* fix(slack): retain workspace in group policy

* fix(slack): preserve workspace DM allowlists

---------

Co-authored-by: Sarah Fortune <sarah.fortune@gmail.com>
2026-08-11 22:09:46 -07:00

1056 lines
38 KiB
TypeScript

// Slack plugin module implements action runtime behavior.
import { normalizeAccountId } from "openclaw/plugin-sdk/account-resolution";
import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference";
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { ResolvedSlackAccount } from "./accounts.js";
import { parseSlackBlocksInput } from "./blocks-input.js";
import type { SlackConversationInfo } from "./channel-type.js";
import { assertSlackDetachedTargetAllowed } from "./detached-target-admission.js";
import { buildSlackChannelIdCandidates } from "./group-policy.js";
import { getSlackInstallationKind } from "./installation-identity-state.js";
import { SLACK_TEXT_LIMIT } from "./limits.js";
import { resolveSlackChannelConfig } from "./monitor/channel-config.js";
import { isSlackChannelAllowedByPolicy } from "./monitor/policy.js";
import { hasSlackNativeDataBlock } from "./native-data-blocks.js";
import type { SlackReplyDeliveryMessage } from "./reply-blocks.js";
import {
createActionGate,
imageResultFromFile,
jsonResult,
readPositiveIntegerParam,
readReactionParams,
readStringParam,
type OpenClawConfig,
withNormalizedTimestamp,
} from "./runtime-api.js";
import { formatSlackTarget } from "./target-parsing.js";
import { parseSlackTarget, resolveSlackChannelId, slackContextTargetsMatch } from "./targets.js";
type ConversationReadInvocationOrigin = NonNullable<
ChannelMessageActionContext["conversationReadOrigin"]
>;
const messagingActions = new Set([
"sendMessage",
"uploadFile",
"editMessage",
"deleteMessage",
"readMessages",
"downloadFile",
]);
const reactionsActions = new Set(["react", "reactions"]);
const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]);
const SLACK_REACTION_USER_LIMIT = 100;
type SlackActionsRuntimeModule = typeof import("./actions.runtime.js");
const loadSlackActionsRuntime = createLazyRuntimeModule(() => import("./actions.runtime.js"));
const loadSlackAccountsRuntime = createLazyRuntimeModule(() => import("./accounts.runtime.js"));
const loadSlackChannelTypeRuntime = createLazyRuntimeModule(() => import("./channel-type.js"));
function createLazySlackAction<K extends keyof SlackActionsRuntimeModule>(
key: K,
): SlackActionsRuntimeModule[K] {
return (async (...args: unknown[]) => {
const runtime = await loadSlackActionsRuntime();
const action = runtime[key] as (...actionArgs: unknown[]) => unknown;
return action(...args);
}) as SlackActionsRuntimeModule[K];
}
export const slackActionRuntime = {
deleteSlackMessage: createLazySlackAction("deleteSlackMessage"),
downloadSlackFile: createLazySlackAction("downloadSlackFile"),
editSlackMessage: createLazySlackAction("editSlackMessage"),
getSlackMemberInfo: createLazySlackAction("getSlackMemberInfo"),
listSlackEmojis: createLazySlackAction("listSlackEmojis"),
listSlackPins: createLazySlackAction("listSlackPins"),
listSlackReactions: createLazySlackAction("listSlackReactions"),
parseSlackBlocksInput,
pinSlackMessage: createLazySlackAction("pinSlackMessage"),
reactSlackMessage: createLazySlackAction("reactSlackMessage"),
readSlackMessages: createLazySlackAction("readSlackMessages"),
removeOwnSlackReactions: createLazySlackAction("removeOwnSlackReactions"),
removeSlackReaction: createLazySlackAction("removeSlackReaction"),
resolveSlackConversationName: createLazySlackAction("resolveSlackConversationName"),
resolveSlackConversationInfo: async (params: {
cfg: OpenClawConfig;
accountId?: string | null;
channelId: string;
teamId?: string;
operation?: "read" | "write";
requireFreshName?: boolean;
}) => (await loadSlackChannelTypeRuntime()).resolveSlackConversationInfo(params),
resolveSlackChannelType: async (params: {
cfg: OpenClawConfig;
accountId?: string | null;
channelId: string;
}) => (await loadSlackChannelTypeRuntime()).resolveSlackChannelType(params),
sendSlackMessage: createLazySlackAction("sendSlackMessage"),
unpinSlackMessage: createLazySlackAction("unpinSlackMessage"),
};
export type SlackActionContext = {
conversationReadOrigin?: ConversationReadInvocationOrigin;
requesterAccountId?: string;
requesterSenderId?: string;
currentChannelProvider?: string;
/** Current channel ID for auto-threading. */
currentChannelId?: string;
/** Routable target for the current conversation when it differs from the channel ID. */
currentMessagingTarget?: string;
/** Current thread timestamp for auto-threading. */
currentThreadTs?: string;
/** Reply-to mode for auto-threading. */
replyToMode?: "off" | "first" | "all" | "batched";
/** Mutable ref to track if a reply was sent for single-use reply modes. */
hasRepliedRef?: { value: boolean };
/** True when same-channel root posting would leak a thread-originated reply. */
sameChannelThreadRequired?: boolean;
mediaAccess?: ChannelMessageActionContext["mediaAccess"];
/** Allowed local media directories for file uploads. */
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
/** Slack-private ordered delivery plan prepared after presentation normalization. */
preparedMessages?: readonly SlackReplyDeliveryMessage[];
};
/**
* Resolve threadTs for a Slack message based on context and replyToMode.
* - "all": always inject threadTs
* - "first"/"batched": inject only for the first eligible message (updates hasRepliedRef)
* - "off": never auto-inject
*/
function resolveThreadTsFromContext(
explicitThreadTs: string | undefined,
targetChannel: string,
context: SlackActionContext | undefined,
opts?: { suppressImplicitThread?: boolean },
): string | undefined {
// Agent explicitly provided threadTs - use it
if (explicitThreadTs) {
return explicitThreadTs;
}
if (opts?.suppressImplicitThread) {
return undefined;
}
if (!context?.currentChannelId && !context?.currentMessagingTarget) {
return undefined;
}
// Different channel - don't inject
if (!slackContextTargetsMatch(targetChannel, context)) {
return undefined;
}
if (!context.currentThreadTs) {
if (context.sameChannelThreadRequired) {
throw new Error(
"Slack thread context is required for same-channel replies from a threaded Slack turn. Set topLevel=true or threadId=null to post at the channel root.",
);
}
return undefined;
}
// Check replyToMode
if (context.replyToMode === "all") {
return context.currentThreadTs;
}
if (
isSingleUseReplyToMode(context.replyToMode ?? "off") &&
context.hasRepliedRef &&
!context.hasRepliedRef.value
) {
context.hasRepliedRef.value = true;
return context.currentThreadTs;
}
return undefined;
}
function readSlackBlocksParam(params: Record<string, unknown>) {
return slackActionRuntime.parseSlackBlocksInput(params.blocks);
}
function isImageContentType(value: string | undefined): boolean {
return value?.trim().toLowerCase().startsWith("image/") === true;
}
function hasPotentialSlackNamedPolicy(params: {
channels: ResolvedSlackAccount["config"]["channels"];
allowNameMatching?: boolean;
decision: "allow" | "deny";
}): boolean {
if (params.allowNameMatching !== true) {
return false;
}
return Object.entries(params.channels ?? {}).some(([key, entry]) => {
if (entry == null || key === "*") {
return false;
}
const named = !/^(?:channel:)?[CDG][A-Z0-9]+$/i.test(key);
const entryAllows = entry.enabled !== false;
return named && (params.decision === "allow" ? entryAllows : !entryAllows);
});
}
function resolveSlackDmReadAllowed(account: ResolvedSlackAccount): boolean {
const dmPolicy = account.config.dmPolicy ?? "pairing";
return account.config.dm?.enabled !== false && dmPolicy !== "disabled";
}
function normalizeConfiguredSlackDmUserId(value: unknown): string | undefined {
const target = parseSlackTarget(String(value), { defaultKind: "user" });
if (target?.kind !== "user") {
return undefined;
}
const userId = target.id.trim().toLowerCase();
return /^[uw][a-z0-9]+$/i.test(userId) ? userId : undefined;
}
async function isSlackDmTargetConfigured(params: {
account: ResolvedSlackAccount;
cfg: OpenClawConfig;
channelId: string;
userId?: string;
}): Promise<boolean> {
const defaultTo = params.account.config.defaultTo?.trim();
if (
defaultTo &&
slackContextTargetsMatch(params.channelId, {
currentChannelId: defaultTo,
})
) {
return true;
}
const userId = normalizeConfiguredSlackDmUserId(params.userId);
if (!userId) {
return false;
}
const { resolveSlackAccountAllowFrom } = await loadSlackAccountsRuntime();
const configuredUsers = [
...(resolveSlackAccountAllowFrom({
cfg: params.cfg,
accountId: params.account.accountId,
}) ?? []),
...Object.keys(params.account.config.dms ?? {}),
...(defaultTo ? [defaultTo] : []),
];
return configuredUsers.some((entry) => normalizeConfiguredSlackDmUserId(entry) === userId);
}
function isCurrentSlackReadTarget(params: {
account: ResolvedSlackAccount;
target: string;
context?: SlackActionContext;
}): boolean {
const requesterAccountId = params.context?.requesterAccountId?.trim();
return Boolean(
normalizeOptionalLowercaseString(params.context?.currentChannelProvider) === "slack" &&
requesterAccountId &&
normalizeAccountId(requesterAccountId) === normalizeAccountId(params.account.accountId) &&
params.context &&
slackContextTargetsMatch(params.target, params.context),
);
}
function assertSlackMemberInfoAllowed(params: {
account: ResolvedSlackAccount;
context?: SlackActionContext;
userId: string;
}) {
if (params.context?.conversationReadOrigin === "direct-operator") {
return;
}
const requesterAccountId = params.context?.requesterAccountId?.trim();
const requesterSenderId = normalizeOptionalLowercaseString(params.context?.requesterSenderId);
if (
normalizeOptionalLowercaseString(params.context?.currentChannelProvider) !== "slack" ||
!requesterAccountId ||
normalizeAccountId(requesterAccountId) !== normalizeAccountId(params.account.accountId) ||
!requesterSenderId ||
requesterSenderId !== normalizeOptionalLowercaseString(params.userId)
) {
throw new Error("Delegated Slack member info is limited to the current requester.");
}
}
function resolveSlackChannelReadPolicy(params: {
account: ResolvedSlackAccount;
cfg: OpenClawConfig;
channelId: string;
teamId?: string;
channelName?: string;
conversationReadOrigin?: ConversationReadInvocationOrigin;
metadataResolved?: boolean;
currentConversation?: boolean;
}) {
const channels = params.account.config.channels;
const channelKeys = Object.keys(channels ?? {});
const channelConfig = resolveSlackChannelConfig({
teamId: params.teamId,
allowUnscoped: getSlackInstallationKind(params.account.accountId) !== "enterprise",
channelId: params.channelId,
channelName: params.channelName,
channels,
channelKeys,
allowNameMatching: params.account.config.dangerouslyAllowNameMatching,
defaultRequireMention: params.account.config.requireMention,
});
const channelAllowed = channelConfig?.allowed !== false;
const channelExplicitlyDisabled = !channelAllowed && channelConfig?.matchSource === "direct";
const channelWildcardDisabled = !channelAllowed && channelConfig?.matchSource === "wildcard";
const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: params.cfg.channels?.slack !== undefined,
groupPolicy: params.account.config.groupPolicy,
defaultGroupPolicy: params.cfg.channels?.defaults?.groupPolicy,
});
const policyAllowed = isSlackChannelAllowedByPolicy({
groupPolicy,
channelAllowlistConfigured: channelKeys.length > 0,
channelAllowed,
});
const delegatedChannelAllowed =
policyAllowed && !(!channelAllowed && (groupPolicy !== "open" || channelConfig?.matchSource));
const directChannelAllowed =
groupPolicy !== "disabled" && !channelExplicitlyDisabled && !channelWildcardDisabled;
const baseChannelAllowed =
params.conversationReadOrigin === "direct-operator" || params.currentConversation
? directChannelAllowed
: delegatedChannelAllowed;
const allowNameMatching = params.account.config.dangerouslyAllowNameMatching;
const shouldResolveName =
!params.metadataResolved &&
!params.channelName &&
((baseChannelAllowed &&
channelConfig?.matchSource !== "direct" &&
hasPotentialSlackNamedPolicy({
channels,
allowNameMatching,
decision: "deny",
})) ||
(!baseChannelAllowed &&
groupPolicy !== "disabled" &&
!channelExplicitlyDisabled &&
hasPotentialSlackNamedPolicy({
channels,
allowNameMatching,
decision: "allow",
})));
return {
channelAllowed: baseChannelAllowed,
channelExplicitlyDisabled,
groupDmAllowed:
params.account.config.dm?.enabled !== false &&
params.account.config.dm?.groupEnabled === true &&
(params.currentConversation ||
isSlackGroupDmTargetConfigured(params.account, params.channelId, params.teamId)),
shouldResolveName,
};
}
async function assertSlackReadTargetAllowed(params: {
account: ResolvedSlackAccount;
cfg: OpenClawConfig;
channelId: string;
teamId?: string;
conversationReadOrigin?: ConversationReadInvocationOrigin;
context?: SlackActionContext;
}) {
const deny = () => {
throw new Error("Slack read target channel is not allowed.");
};
const currentConversation = isCurrentSlackReadTarget({
account: params.account,
target: formatSlackTarget({
teamId: params.teamId,
kind: "channel",
id: params.channelId,
}),
context: params.context,
});
const directOperator = params.conversationReadOrigin === "direct-operator";
if (/^D/i.test(params.channelId)) {
if (!resolveSlackDmReadAllowed(params.account)) {
deny();
}
if (directOperator || currentConversation) {
return;
}
const info = await slackActionRuntime.resolveSlackConversationInfo({
cfg: params.cfg,
accountId: params.account.accountId,
channelId: params.channelId,
teamId: params.teamId,
operation: "read",
});
if (
info.type !== "dm" ||
!(await isSlackDmTargetConfigured({
...params,
userId: info.user,
}))
) {
deny();
}
return;
}
const preliminary = resolveSlackChannelReadPolicy({
...params,
currentConversation,
});
if (preliminary.channelExplicitlyDisabled) {
deny();
}
const needsMetadata =
preliminary.shouldResolveName || preliminary.channelAllowed !== preliminary.groupDmAllowed;
if (!needsMetadata) {
if (!preliminary.channelAllowed) {
deny();
}
return;
}
const info: SlackConversationInfo = await slackActionRuntime.resolveSlackConversationInfo({
cfg: params.cfg,
accountId: params.account.accountId,
channelId: params.channelId,
teamId: params.teamId,
operation: "read",
...(preliminary.shouldResolveName ? { requireFreshName: true } : {}),
});
if (
preliminary.shouldResolveName &&
(info.type === "channel" || info.type === "unknown") &&
!info.name
) {
deny();
}
const resolved = resolveSlackChannelReadPolicy({
...params,
channelName: info.name,
metadataResolved: true,
currentConversation,
});
if (resolved.channelExplicitlyDisabled) {
deny();
}
if (info.type === "dm") {
if (
!resolveSlackDmReadAllowed(params.account) ||
(!directOperator &&
!currentConversation &&
!(await isSlackDmTargetConfigured({
...params,
userId: info.user,
})))
) {
deny();
}
return;
}
const allowed =
info.type === "channel"
? resolved.channelAllowed
: info.type === "group"
? resolved.groupDmAllowed
: resolved.channelAllowed && resolved.groupDmAllowed;
if (!allowed) {
deny();
}
}
function isSlackGroupDmTargetConfigured(
account: ResolvedSlackAccount,
channelId: string,
teamId?: string,
): boolean {
const entries = account.config.dm?.groupChannels ?? [];
if (entries.length === 0) {
return true;
}
const candidates = new Set(
buildSlackChannelIdCandidates(channelId, teamId, {
allowUnscoped: getSlackInstallationKind(account.accountId) !== "enterprise",
}).map((candidate) => candidate.toLowerCase()),
);
const target = channelId.trim().toLowerCase();
return entries.some((entry) => {
const candidate = String(entry).trim().toLowerCase();
return (
candidate === "*" ||
candidates.has(candidate) ||
candidate === target ||
candidate === `slack:${target}` ||
candidate === `channel:${target}` ||
candidate === `group:${target}` ||
candidate === `mpim:${target}`
);
});
}
type SlackActionChannelTarget = {
channelId: string;
teamId?: string;
};
function resolveTrustedCurrentSlackTeamId(params: {
account: ResolvedSlackAccount;
target?: NonNullable<ReturnType<typeof parseSlackTarget>>;
context?: SlackActionContext;
}): string | undefined {
const requesterAccountId = params.context?.requesterAccountId?.trim();
if (
normalizeOptionalLowercaseString(params.context?.currentChannelProvider) !== "slack" ||
!requesterAccountId ||
normalizeAccountId(requesterAccountId) !== normalizeAccountId(params.account.accountId)
) {
return undefined;
}
const matchingTeams = new Map<string, string>();
for (const raw of [params.context?.currentChannelId, params.context?.currentMessagingTarget]) {
if (!raw) {
continue;
}
const current = parseSlackTarget(raw);
if (
current?.teamId &&
(!params.target ||
(current.kind === params.target.kind &&
current.id.toLowerCase() === params.target.id.toLowerCase()))
) {
matchingTeams.set(current.teamId.toLowerCase(), current.teamId);
}
}
return matchingTeams.size === 1 ? matchingTeams.values().next().value : undefined;
}
function resolveSlackActionTarget(
account: ResolvedSlackAccount,
raw: string,
context?: SlackActionContext,
) {
const parsed = parseSlackTarget(raw, { defaultKind: "channel" });
if (!parsed) {
throw new Error("Slack target is required.");
}
const teamId =
parsed.teamId ?? resolveTrustedCurrentSlackTeamId({ account, target: parsed, context });
assertSlackDetachedTargetAllowed(account.accountId, teamId);
return {
routingTarget: teamId ? formatSlackTarget({ teamId, kind: parsed.kind, id: parsed.id }) : raw,
teamId,
};
}
function resolveSlackActionChannelTarget(
account: ResolvedSlackAccount,
raw: string,
context?: SlackActionContext,
): SlackActionChannelTarget {
const resolved = resolveSlackActionTarget(account, raw, context);
const channelId = resolveSlackChannelId(raw);
return {
channelId,
teamId: resolved.teamId,
};
}
export async function handleSlackAction(
params: Record<string, unknown>,
cfg: OpenClawConfig,
context?: SlackActionContext,
): Promise<AgentToolResult<unknown>> {
const action = readStringParam(params, "action", { required: true });
const accountId = readStringParam(params, "accountId");
const { resolveSlackAccount, resolveSlackOperationToken } = await loadSlackAccountsRuntime();
const account = resolveSlackAccount({ cfg, accountId });
const resolveChannelTarget = () =>
resolveSlackActionChannelTarget(
account,
readStringParam(params, "channelId", {
required: true,
}),
context,
);
const actionConfig = account.actions ?? cfg.channels?.slack?.actions;
const isActionEnabled = createActionGate(actionConfig);
const botToken = account.botToken?.trim();
const buildActionOpts = (operation: "read" | "write", teamId?: string) => {
const token = resolveSlackOperationToken(account, operation);
if (!token && account.identity === "user") {
throw new Error(`Slack operation token missing for account "${account.accountId}".`);
}
const tokenOverride = token && token !== botToken ? token : undefined;
return {
cfg,
...(accountId ? { accountId } : {}),
...(tokenOverride ? { token: tokenOverride } : {}),
teamId,
};
};
const assertReadTargetAllowed = async (target: SlackActionChannelTarget) => {
await assertSlackReadTargetAllowed({
account,
cfg,
channelId: target.channelId,
teamId: target.teamId,
conversationReadOrigin: context?.conversationReadOrigin,
context,
});
};
if (reactionsActions.has(action)) {
if (!isActionEnabled("reactions")) {
throw new Error("Slack reactions are disabled.");
}
const target = resolveChannelTarget();
const { channelId } = target;
const readOpts = buildActionOpts("read", target.teamId);
const writeOpts = buildActionOpts("write", target.teamId);
const messageId = readStringParam(params, "messageId", { required: true });
if (action === "react") {
const { emoji, remove, isEmpty } = readReactionParams(params, {
removeErrorMessage: "Emoji is required to remove a Slack reaction.",
});
await assertReadTargetAllowed(target);
if (remove) {
await slackActionRuntime.removeSlackReaction(channelId, messageId, emoji, writeOpts);
return jsonResult({ ok: true, removed: emoji });
}
if (isEmpty) {
const removed = await slackActionRuntime.removeOwnSlackReactions(
channelId,
messageId,
writeOpts,
);
return jsonResult({ ok: true, removed });
}
await slackActionRuntime.reactSlackMessage(channelId, messageId, emoji, writeOpts);
return jsonResult({ ok: true, added: emoji });
}
await assertReadTargetAllowed(target);
const limit = Math.min(
readPositiveIntegerParam(params, "limit", {
message: "limit must be a positive integer.",
}) ?? SLACK_REACTION_USER_LIMIT,
SLACK_REACTION_USER_LIMIT,
);
const reactions = await slackActionRuntime.listSlackReactions(channelId, messageId, readOpts);
return jsonResult({
ok: true,
reactions: reactions?.map((reaction) =>
reaction.users
? Object.assign({}, reaction, { users: reaction.users.slice(0, limit) })
: reaction,
),
});
}
if (messagingActions.has(action)) {
if (!isActionEnabled("messages")) {
throw new Error("Slack messages are disabled.");
}
switch (action) {
case "sendMessage": {
const to = readStringParam(params, "to", { required: true });
const target = resolveSlackActionTarget(account, to, context);
const destination = target.routingTarget;
const content = readStringParam(params, "content", {
allowEmpty: true,
});
const mediaUrl = readStringParam(params, "mediaUrl");
const blocks = readSlackBlocksParam(params);
const replyBroadcast = readBooleanParam(params, "replyBroadcast");
const textIsSlackMrkdwn = readBooleanParam(params, "textIsSlackMrkdwn");
const textIsSlackPlainText = readBooleanParam(params, "textIsSlackPlainText");
const preparedMessages = context?.preparedMessages;
const authoredTextPlacement = readStringParam(params, "authoredTextPlacement") as
| "none"
| "blocks"
| "outside-blocks"
| undefined;
if (
authoredTextPlacement &&
authoredTextPlacement !== "none" &&
authoredTextPlacement !== "blocks" &&
authoredTextPlacement !== "outside-blocks"
) {
throw new Error("Slack authoredTextPlacement is invalid.");
}
const nativeDataFallbackBaseText = readStringParam(params, "nativeDataFallbackBaseText", {
allowEmpty: true,
});
if (!content && !mediaUrl && !blocks && !preparedMessages?.length) {
throw new Error("Slack sendMessage requires content, blocks, or mediaUrl.");
}
if (replyBroadcast && mediaUrl) {
throw new Error(
"Slack replyBroadcast is only supported for text or block thread replies.",
);
}
const threadTs = resolveThreadTsFromContext(
readStringParam(params, "threadTs"),
destination,
context,
{
suppressImplicitThread: params.topLevel === true || params.threadTs === null,
},
);
const baseSendOpts = {
...buildActionOpts("write"),
mediaAccess: context?.mediaAccess,
mediaLocalRoots: context?.mediaLocalRoots,
mediaReadFile: context?.mediaReadFile,
threadTs: threadTs ?? undefined,
};
const sendOpts = {
...baseSendOpts,
...(replyBroadcast ? { replyBroadcast } : {}),
...(textIsSlackMrkdwn ? { textIsSlackMrkdwn: true } : {}),
...(textIsSlackPlainText ? { textIsSlackPlainText: true } : {}),
...(authoredTextPlacement ? { authoredTextPlacement } : {}),
...(nativeDataFallbackBaseText !== undefined ? { nativeDataFallbackBaseText } : {}),
};
const sendContentAndBlocks = async () => {
const shouldSplitLongContent =
content && content.length > SLACK_TEXT_LIMIT && !hasSlackNativeDataBlock(blocks);
if (content && shouldSplitLongContent) {
// Reuse the resolved thread for both sends. Invoking the action twice
// could consume replyToMode=first and move the full text off-thread.
const { replyBroadcast: _replyBroadcast, ...blockSendOpts } = sendOpts;
await slackActionRuntime.sendSlackMessage(destination, "", {
...blockSendOpts,
blocks,
});
return await slackActionRuntime.sendSlackMessage(destination, content, sendOpts);
}
return await slackActionRuntime.sendSlackMessage(destination, content ?? "", {
...sendOpts,
blocks,
});
};
const result = preparedMessages?.length
? await (async () => {
let lastResult:
| Awaited<ReturnType<typeof slackActionRuntime.sendSlackMessage>>
| undefined;
if (mediaUrl) {
lastResult = await slackActionRuntime.sendSlackMessage(destination, "", {
...baseSendOpts,
mediaUrl,
});
}
for (const [index, message] of preparedMessages.entries()) {
lastResult = await slackActionRuntime.sendSlackMessage(destination, message.text, {
...baseSendOpts,
...(index === 0 && replyBroadcast ? { replyBroadcast: true } : {}),
...(message.blocks ? { blocks: message.blocks } : {}),
...(message.authoredTextPlacement
? { authoredTextPlacement: message.authoredTextPlacement }
: {}),
...(Object.hasOwn(message, "nativeDataFallbackBaseText")
? { nativeDataFallbackBaseText: message.nativeDataFallbackBaseText }
: {}),
...(message.textIsSlackPlainText ? { textIsSlackPlainText: true } : {}),
});
}
if (!lastResult) {
throw new Error("Slack prepared message plan produced no delivery.");
}
return lastResult;
})()
: blocks
? await (async () => {
if (mediaUrl) {
await slackActionRuntime.sendSlackMessage(destination, "", {
...sendOpts,
mediaUrl,
});
}
return await sendContentAndBlocks();
})()
: await slackActionRuntime.sendSlackMessage(destination, content ?? "", {
...sendOpts,
mediaUrl: mediaUrl ?? undefined,
blocks,
});
// Keep "first" mode consistent even when the agent explicitly provided
// threadTs: once we send a message to the current channel, consider the
// first reply "used" so later tool calls don't auto-thread again.
if (context?.hasRepliedRef && slackContextTargetsMatch(destination, context)) {
context.hasRepliedRef.value = true;
}
return jsonResult({ ok: true, result });
}
case "uploadFile": {
const to = readStringParam(params, "to", { required: true });
const target = resolveSlackActionTarget(account, to, context);
const destination = target.routingTarget;
const filePath = readStringParam(params, "filePath", {
required: true,
trim: false,
});
const initialComment = readStringParam(params, "initialComment", {
allowEmpty: true,
});
const filename = readStringParam(params, "filename");
const title = readStringParam(params, "title");
const replyBroadcast = readBooleanParam(params, "replyBroadcast");
if (replyBroadcast) {
throw new Error(
"Slack replyBroadcast is only supported for text or block thread replies.",
);
}
const threadTs = resolveThreadTsFromContext(
readStringParam(params, "threadTs"),
destination,
context,
{
suppressImplicitThread: params.topLevel === true || params.threadTs === null,
},
);
const result = await slackActionRuntime.sendSlackMessage(
destination,
initialComment ?? "",
{
...buildActionOpts("write"),
mediaUrl: filePath,
mediaAccess: context?.mediaAccess,
mediaLocalRoots: context?.mediaLocalRoots,
mediaReadFile: context?.mediaReadFile,
threadTs: threadTs ?? undefined,
...(filename ? { uploadFileName: filename } : {}),
...(title ? { uploadTitle: title } : {}),
},
);
if (context?.hasRepliedRef && slackContextTargetsMatch(destination, context)) {
context.hasRepliedRef.value = true;
}
return jsonResult({ ok: true, result });
}
case "editMessage": {
const target = resolveChannelTarget();
const { channelId } = target;
const messageId = readStringParam(params, "messageId", {
required: true,
});
const content = readStringParam(params, "content", {
allowEmpty: true,
});
const blocks = readSlackBlocksParam(params);
if (!content && !blocks) {
throw new Error("Slack editMessage requires content or blocks.");
}
await assertReadTargetAllowed(target);
const writeOpts = buildActionOpts("write", target.teamId);
await slackActionRuntime.editSlackMessage(channelId, messageId, content ?? "", {
...writeOpts,
blocks,
});
return jsonResult({ ok: true });
}
case "deleteMessage": {
const target = resolveChannelTarget();
const { channelId } = target;
const messageId = readStringParam(params, "messageId", {
required: true,
});
await assertReadTargetAllowed(target);
const writeOpts = buildActionOpts("write", target.teamId);
await slackActionRuntime.deleteSlackMessage(channelId, messageId, writeOpts);
return jsonResult({ ok: true });
}
case "readMessages": {
const target = resolveChannelTarget();
const { channelId } = target;
await assertReadTargetAllowed(target);
const readOpts = buildActionOpts("read", target.teamId);
const limit = readPositiveIntegerParam(params, "limit", {
message: "limit must be a positive integer.",
});
const before = readStringParam(params, "before");
const after = readStringParam(params, "after");
const threadId = readStringParam(params, "threadId");
const messageId = readStringParam(params, "messageId");
const result = await slackActionRuntime.readSlackMessages(channelId, {
...readOpts,
limit,
before: before ?? undefined,
after: after ?? undefined,
threadId: threadId ?? undefined,
messageId: messageId ?? undefined,
});
const messages = result.messages.map((message) =>
withNormalizedTimestamp(
message as Record<string, unknown>,
(message as { ts?: unknown }).ts,
),
);
return jsonResult({
ok: true,
channelId,
...(threadId ? { threadId } : {}),
messages,
hasMore: result.hasMore,
});
}
case "downloadFile": {
const fileId = readStringParam(params, "fileId", { required: true });
const channelTarget =
readStringParam(params, "channelId") ??
readStringParam(params, "to") ??
context?.currentChannelId;
if (!channelTarget) {
throw new Error(
"Slack file download requires channelId or to so the read target can be authorized.",
);
}
const target = resolveSlackActionChannelTarget(account, channelTarget, context);
const { channelId } = target;
await assertReadTargetAllowed(target);
const threadId = readStringParam(params, "threadId") ?? readStringParam(params, "replyTo");
const maxBytes = account.config?.mediaMaxMb
? account.config.mediaMaxMb * 1024 * 1024
: 20 * 1024 * 1024;
const readToken = resolveSlackOperationToken(account, "read");
const readOpts = buildActionOpts("read", target.teamId);
const downloaded = await slackActionRuntime.downloadSlackFile(fileId, {
...readOpts,
...(readToken && !readOpts.token ? { token: readToken } : {}),
maxBytes,
channelId,
threadId: threadId ?? undefined,
});
if (!downloaded) {
return jsonResult({
ok: false,
error: "File could not be downloaded (not found, too large, or inaccessible).",
});
}
if (!isImageContentType(downloaded.contentType)) {
return jsonResult({
ok: true,
fileId,
path: downloaded.path,
contentType: downloaded.contentType,
placeholder: downloaded.placeholder,
media: {
mediaUrl: downloaded.path,
outbound: false,
...(downloaded.contentType ? { contentType: downloaded.contentType } : {}),
},
});
}
return await imageResultFromFile({
label: "slack-file",
path: downloaded.path,
extraText: downloaded.placeholder,
details: {
fileId,
path: downloaded.path,
...(downloaded.contentType ? { contentType: downloaded.contentType } : {}),
media: { outbound: false },
},
});
}
default:
break;
}
}
if (pinActions.has(action)) {
if (!isActionEnabled("pins")) {
throw new Error("Slack pins are disabled.");
}
const target = resolveChannelTarget();
const { channelId } = target;
const readOpts = buildActionOpts("read", target.teamId);
const writeOpts = buildActionOpts("write", target.teamId);
if (action === "pinMessage") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
await assertReadTargetAllowed(target);
await slackActionRuntime.pinSlackMessage(channelId, messageId, writeOpts);
return jsonResult({ ok: true });
}
if (action === "unpinMessage") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
await assertReadTargetAllowed(target);
await slackActionRuntime.unpinSlackMessage(channelId, messageId, writeOpts);
return jsonResult({ ok: true });
}
await assertReadTargetAllowed(target);
const pins = await slackActionRuntime.listSlackPins(channelId, readOpts);
const normalizedPins = pins.map((pin) => {
const message = pin.message
? withNormalizedTimestamp(
pin.message as Record<string, unknown>,
(pin.message as { ts?: unknown }).ts,
)
: pin.message;
return message ? Object.assign({}, pin, { message }) : pin;
});
return jsonResult({ ok: true, pins: normalizedPins });
}
if (action === "memberInfo") {
if (!isActionEnabled("memberInfo")) {
throw new Error("Slack member info is disabled.");
}
const userId = readStringParam(params, "userId", { required: true });
assertSlackMemberInfoAllowed({ account, context, userId });
const teamId = resolveTrustedCurrentSlackTeamId({ account, context });
assertSlackDetachedTargetAllowed(account.accountId, teamId);
const info = await slackActionRuntime.getSlackMemberInfo(
userId,
buildActionOpts("read", teamId),
);
return jsonResult({ ok: true, info });
}
if (action === "emojiList") {
if (!isActionEnabled("emojiList")) {
throw new Error("Slack emoji list is disabled.");
}
const limit = readPositiveIntegerParam(params, "limit", {
message: "limit must be a positive integer.",
});
const teamId = resolveTrustedCurrentSlackTeamId({ account, context });
assertSlackDetachedTargetAllowed(account.accountId, teamId);
const result = await slackActionRuntime.listSlackEmojis(buildActionOpts("read", teamId));
if (limit != null && limit > 0 && result.emoji != null) {
const entries = Object.entries(result.emoji).toSorted(([a], [b]) => a.localeCompare(b));
if (entries.length > limit) {
return jsonResult({
ok: true,
emojis: {
...result,
emoji: Object.fromEntries(entries.slice(0, limit)),
},
});
}
}
return jsonResult({ ok: true, emojis: result });
}
throw new Error(`Unknown action: ${action}`);
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */