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", "uploadFile", "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) { return slackActionRuntime.parseSlackBlocksInput(params.blocks); } export async function handleSlackAction( params: Record, cfg: OpenClawConfig, context?: SlackActionContext, ): Promise> { 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 "uploadFile": { const to = readStringParam(params, "to", { required: true }); 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 threadTs = resolveThreadTsFromContext( readStringParam(params, "threadTs"), to, context, ); const result = await slackActionRuntime.sendSlackMessage(to, initialComment ?? "", { ...writeOpts, mediaUrl: filePath, mediaLocalRoots: context?.mediaLocalRoots, threadTs: threadTs ?? undefined, ...(filename ? { uploadFileName: filename } : {}), ...(title ? { uploadTitle: title } : {}), }); if (threadTs && result.channelId && account.accountId) { slackActionRuntime.recordSlackThreadParticipation( account.accountId, result.channelId, threadTs, ); } 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, (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, (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}`); }