openclaw/extensions/slack/src/action-runtime.ts

427 lines
14 KiB
TypeScript

import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { resolveSlackAccount } from "./accounts.js";
import {
deleteSlackMessage,
downloadSlackFile,
editSlackMessage,
getSlackMemberInfo,
listSlackEmojis,
listSlackPins,
listSlackReactions,
pinSlackMessage,
reactSlackMessage,
readSlackMessages,
removeOwnSlackReactions,
removeSlackReaction,
sendSlackMessage,
unpinSlackMessage,
} from "./actions.js";
import { parseSlackBlocksInput } from "./blocks-input.js";
import {
createActionGate,
imageResultFromFile,
jsonResult,
readNumberParam,
readReactionParams,
readStringParam,
type OpenClawConfig,
withNormalizedTimestamp,
} from "./runtime-api.js";
import { recordSlackThreadParticipation } from "./sent-thread-cache.js";
import { parseSlackTarget, resolveSlackChannelId } from "./targets.js";
const messagingActions = new Set([
"sendMessage",
"editMessage",
"deleteMessage",
"readMessages",
"downloadFile",
]);
const reactionsActions = new Set(["react", "reactions"]);
const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]);
export const slackActionRuntime = {
deleteSlackMessage,
downloadSlackFile,
editSlackMessage,
getSlackMemberInfo,
listSlackEmojis,
listSlackPins,
listSlackReactions,
parseSlackBlocksInput,
pinSlackMessage,
reactSlackMessage,
readSlackMessages,
recordSlackThreadParticipation,
removeOwnSlackReactions,
removeSlackReaction,
sendSlackMessage,
unpinSlackMessage,
};
export type SlackActionContext = {
/** Current channel ID for auto-threading. */
currentChannelId?: string;
/** Current thread timestamp for auto-threading. */
currentThreadTs?: string;
/** Reply-to mode for auto-threading. */
replyToMode?: "off" | "first" | "all";
/** Mutable ref to track if a reply was sent (for "first" mode). */
hasRepliedRef?: { value: boolean };
/** Allowed local media directories for file uploads. */
mediaLocalRoots?: readonly string[];
};
/**
* Resolve threadTs for a Slack message based on context and replyToMode.
* - "all": always inject threadTs
* - "first": inject only for first message (updates hasRepliedRef)
* - "off": never auto-inject
*/
function resolveThreadTsFromContext(
explicitThreadTs: string | undefined,
targetChannel: string,
context: SlackActionContext | undefined,
): string | undefined {
// Agent explicitly provided threadTs - use it
if (explicitThreadTs) {
return explicitThreadTs;
}
// No context or missing required fields
if (!context?.currentThreadTs || !context?.currentChannelId) {
return undefined;
}
const parsedTarget = parseSlackTarget(targetChannel, {
defaultKind: "channel",
});
if (!parsedTarget || parsedTarget.kind !== "channel") {
return undefined;
}
const normalizedTarget = parsedTarget.id;
// Different channel - don't inject
if (normalizedTarget !== context.currentChannelId) {
return undefined;
}
// Check replyToMode
if (context.replyToMode === "all") {
return context.currentThreadTs;
}
if (context.replyToMode === "first" && 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);
}
export async function handleSlackAction(
params: Record<string, unknown>,
cfg: OpenClawConfig,
context?: SlackActionContext,
): Promise<AgentToolResult<unknown>> {
const resolveChannelId = () =>
resolveSlackChannelId(
readStringParam(params, "channelId", {
required: true,
}),
);
const action = readStringParam(params, "action", { required: true });
const accountId = readStringParam(params, "accountId");
const account = resolveSlackAccount({ cfg, accountId });
const actionConfig = account.actions ?? cfg.channels?.slack?.actions;
const isActionEnabled = createActionGate(actionConfig);
const userToken = account.userToken;
const botToken = account.botToken?.trim();
const allowUserWrites = account.config.userTokenReadOnly === false;
// Choose the most appropriate token for Slack read/write operations.
const getTokenForOperation = (operation: "read" | "write") => {
if (operation === "read") {
return userToken ?? botToken;
}
if (!allowUserWrites) {
return botToken;
}
return botToken ?? userToken;
};
const buildActionOpts = (operation: "read" | "write") => {
const token = getTokenForOperation(operation);
const tokenOverride = token && token !== botToken ? token : undefined;
if (!accountId && !tokenOverride) {
return undefined;
}
return {
...(accountId ? { accountId } : {}),
...(tokenOverride ? { token: tokenOverride } : {}),
};
};
const readOpts = buildActionOpts("read");
const writeOpts = buildActionOpts("write");
if (reactionsActions.has(action)) {
if (!isActionEnabled("reactions")) {
throw new Error("Slack reactions are disabled.");
}
const channelId = resolveChannelId();
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.",
});
if (remove) {
if (writeOpts) {
await slackActionRuntime.removeSlackReaction(channelId, messageId, emoji, writeOpts);
} else {
await slackActionRuntime.removeSlackReaction(channelId, messageId, emoji);
}
return jsonResult({ ok: true, removed: emoji });
}
if (isEmpty) {
const removed = writeOpts
? await slackActionRuntime.removeOwnSlackReactions(channelId, messageId, writeOpts)
: await slackActionRuntime.removeOwnSlackReactions(channelId, messageId);
return jsonResult({ ok: true, removed });
}
if (writeOpts) {
await slackActionRuntime.reactSlackMessage(channelId, messageId, emoji, writeOpts);
} else {
await slackActionRuntime.reactSlackMessage(channelId, messageId, emoji);
}
return jsonResult({ ok: true, added: emoji });
}
const reactions = readOpts
? await slackActionRuntime.listSlackReactions(channelId, messageId, readOpts)
: await slackActionRuntime.listSlackReactions(channelId, messageId);
return jsonResult({ ok: true, reactions });
}
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 content = readStringParam(params, "content", {
allowEmpty: true,
});
const mediaUrl = readStringParam(params, "mediaUrl");
const blocks = readSlackBlocksParam(params);
if (!content && !mediaUrl && !blocks) {
throw new Error("Slack sendMessage requires content, blocks, or mediaUrl.");
}
if (mediaUrl && blocks) {
throw new Error("Slack sendMessage does not support blocks with mediaUrl.");
}
const threadTs = resolveThreadTsFromContext(
readStringParam(params, "threadTs"),
to,
context,
);
const result = await slackActionRuntime.sendSlackMessage(to, content ?? "", {
...writeOpts,
mediaUrl: mediaUrl ?? undefined,
mediaLocalRoots: context?.mediaLocalRoots,
threadTs: threadTs ?? undefined,
blocks,
});
if (threadTs && result.channelId && account.accountId) {
slackActionRuntime.recordSlackThreadParticipation(
account.accountId,
result.channelId,
threadTs,
);
}
// 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 && context.currentChannelId) {
const parsedTarget = parseSlackTarget(to, { defaultKind: "channel" });
if (parsedTarget?.kind === "channel" && parsedTarget.id === context.currentChannelId) {
context.hasRepliedRef.value = true;
}
}
return jsonResult({ ok: true, result });
}
case "editMessage": {
const channelId = resolveChannelId();
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.");
}
if (writeOpts) {
await slackActionRuntime.editSlackMessage(channelId, messageId, content ?? "", {
...writeOpts,
blocks,
});
} else {
await slackActionRuntime.editSlackMessage(channelId, messageId, content ?? "", {
blocks,
});
}
return jsonResult({ ok: true });
}
case "deleteMessage": {
const channelId = resolveChannelId();
const messageId = readStringParam(params, "messageId", {
required: true,
});
if (writeOpts) {
await slackActionRuntime.deleteSlackMessage(channelId, messageId, writeOpts);
} else {
await slackActionRuntime.deleteSlackMessage(channelId, messageId);
}
return jsonResult({ ok: true });
}
case "readMessages": {
const channelId = resolveChannelId();
const limitRaw = params.limit;
const limit =
typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined;
const before = readStringParam(params, "before");
const after = readStringParam(params, "after");
const threadId = readStringParam(params, "threadId");
const result = await slackActionRuntime.readSlackMessages(channelId, {
...readOpts,
limit,
before: before ?? undefined,
after: after ?? undefined,
threadId: threadId ?? undefined,
});
const messages = result.messages.map((message) =>
withNormalizedTimestamp(
message as Record<string, unknown>,
(message as { ts?: unknown }).ts,
),
);
return jsonResult({ ok: true, messages, hasMore: result.hasMore });
}
case "downloadFile": {
const fileId = readStringParam(params, "fileId", { required: true });
const channelTarget = readStringParam(params, "channelId") ?? readStringParam(params, "to");
const channelId = channelTarget ? resolveSlackChannelId(channelTarget) : undefined;
const threadId = readStringParam(params, "threadId") ?? readStringParam(params, "replyTo");
const maxBytes = account.config?.mediaMaxMb
? account.config.mediaMaxMb * 1024 * 1024
: 20 * 1024 * 1024;
const downloaded = await slackActionRuntime.downloadSlackFile(fileId, {
...readOpts,
maxBytes,
channelId,
threadId: threadId ?? undefined,
});
if (!downloaded) {
return jsonResult({
ok: false,
error: "File could not be downloaded (not found, too large, or inaccessible).",
});
}
return await imageResultFromFile({
label: "slack-file",
path: downloaded.path,
extraText: downloaded.placeholder,
details: { fileId, path: downloaded.path },
});
}
default:
break;
}
}
if (pinActions.has(action)) {
if (!isActionEnabled("pins")) {
throw new Error("Slack pins are disabled.");
}
const channelId = resolveChannelId();
if (action === "pinMessage") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
if (writeOpts) {
await slackActionRuntime.pinSlackMessage(channelId, messageId, writeOpts);
} else {
await slackActionRuntime.pinSlackMessage(channelId, messageId);
}
return jsonResult({ ok: true });
}
if (action === "unpinMessage") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
if (writeOpts) {
await slackActionRuntime.unpinSlackMessage(channelId, messageId, writeOpts);
} else {
await slackActionRuntime.unpinSlackMessage(channelId, messageId);
}
return jsonResult({ ok: true });
}
const pins = writeOpts
? await slackActionRuntime.listSlackPins(channelId, readOpts)
: await slackActionRuntime.listSlackPins(channelId);
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 ? { ...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 });
const info = writeOpts
? await slackActionRuntime.getSlackMemberInfo(userId, readOpts)
: await slackActionRuntime.getSlackMemberInfo(userId);
return jsonResult({ ok: true, info });
}
if (action === "emojiList") {
if (!isActionEnabled("emojiList")) {
throw new Error("Slack emoji list is disabled.");
}
const result = readOpts
? await slackActionRuntime.listSlackEmojis(readOpts)
: await slackActionRuntime.listSlackEmojis();
const limit = readNumberParam(params, "limit", { integer: true });
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}`);
}