From 1c0db5b8e4314a9de3b69b409531859c1931d93a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Mar 2026 01:43:45 +0000 Subject: [PATCH] refactor(slack): share setup helpers --- extensions/slack/src/channel.setup.ts | 46 +-- extensions/slack/src/channel.ts | 41 +-- .../slack/src/message-action-dispatch.ts | 335 +----------------- extensions/slack/src/setup-core.ts | 118 +----- extensions/slack/src/setup-surface.ts | 118 +----- extensions/slack/src/shared.ts | 152 ++++++++ 6 files changed, 177 insertions(+), 633 deletions(-) create mode 100644 extensions/slack/src/shared.ts diff --git a/extensions/slack/src/channel.setup.ts b/extensions/slack/src/channel.setup.ts index b5723ea5130..c221cc9cebf 100644 --- a/extensions/slack/src/channel.setup.ts +++ b/extensions/slack/src/channel.setup.ts @@ -1,56 +1,18 @@ -import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; -import { - createScopedAccountConfigAccessors, - formatAllowFromLowercase, -} from "openclaw/plugin-sdk/compat"; import { buildChannelConfigSchema, getChatChannelMeta, SlackConfigSchema, type ChannelPlugin, } from "openclaw/plugin-sdk/slack"; -import { inspectSlackAccount } from "./account-inspect.js"; -import { - listSlackAccountIds, - resolveDefaultSlackAccountId, - resolveSlackAccount, - type ResolvedSlackAccount, -} from "./accounts.js"; +import { type ResolvedSlackAccount } from "./accounts.js"; import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js"; +import { isSlackPluginAccountConfigured, slackConfigAccessors, slackConfigBase } from "./shared.js"; async function loadSlackChannelRuntime() { return await import("./channel.runtime.js"); } -function isSlackAccountConfigured(account: ResolvedSlackAccount): boolean { - const mode = account.config.mode ?? "socket"; - const hasBotToken = Boolean(account.botToken?.trim()); - if (!hasBotToken) { - return false; - } - if (mode === "http") { - return Boolean(account.config.signingSecret?.trim()); - } - return Boolean(account.appToken?.trim()); -} - -const slackConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }), - resolveAllowFrom: (account: ResolvedSlackAccount) => account.dm?.allowFrom, - formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), - resolveDefaultTo: (account: ResolvedSlackAccount) => account.config.defaultTo, -}); - -const slackConfigBase = createScopedChannelConfigBase({ - sectionKey: "slack", - listAccountIds: listSlackAccountIds, - resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), - inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }), - defaultAccountId: resolveDefaultSlackAccountId, - clearBaseFields: ["botToken", "appToken", "name"], -}); - const slackSetupWizard = createSlackSetupWizardProxy(async () => ({ slackSetupWizard: (await loadSlackChannelRuntime()).slackSetupWizard, })); @@ -87,12 +49,12 @@ export const slackSetupPlugin: ChannelPlugin = { configSchema: buildChannelConfigSchema(SlackConfigSchema), config: { ...slackConfigBase, - isConfigured: (account) => isSlackAccountConfigured(account), + isConfigured: (account) => isSlackPluginAccountConfigured(account), describeAccount: (account) => ({ accountId: account.accountId, name: account.name, enabled: account.enabled, - configured: isSlackAccountConfigured(account), + configured: isSlackPluginAccountConfigured(account), botTokenSource: account.botTokenSource, appTokenSource: account.appTokenSource, }), diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index a07608d836a..4a43055c142 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -1,11 +1,8 @@ -import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; import { buildAccountScopedAllowlistConfigEditor, buildAccountScopedDmSecurityPolicy, collectOpenProviderGroupPolicyWarnings, collectOpenGroupPolicyConfiguredRouteWarnings, - createScopedAccountConfigAccessors, - formatAllowFromLowercase, } from "openclaw/plugin-sdk/compat"; import { buildAgentSessionKey, @@ -32,11 +29,8 @@ import { } from "openclaw/plugin-sdk/slack"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; -import { inspectSlackAccount } from "./account-inspect.js"; import { listEnabledSlackAccounts, - listSlackAccountIds, - resolveDefaultSlackAccountId, resolveSlackAccount, resolveSlackReplyToMode, type ResolvedSlackAccount, @@ -52,6 +46,7 @@ import { resolveSlackUserAllowlist } from "./resolve-users.js"; import { getSlackRuntime } from "./runtime.js"; import { fetchSlackScopes } from "./scopes.js"; import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js"; +import { isSlackPluginAccountConfigured, slackConfigAccessors, slackConfigBase } from "./shared.js"; import { parseSlackTarget } from "./targets.js"; import { buildSlackThreadingToolContext } from "./threading-tool-context.js"; @@ -79,18 +74,6 @@ function getTokenForOperation( return botToken ?? userToken; } -function isSlackAccountConfigured(account: ResolvedSlackAccount): boolean { - const mode = account.config.mode ?? "socket"; - const hasBotToken = Boolean(account.botToken?.trim()); - if (!hasBotToken) { - return false; - } - if (mode === "http") { - return Boolean(account.config.signingSecret?.trim()); - } - return Boolean(account.appToken?.trim()); -} - type SlackSendFn = ReturnType["channel"]["slack"]["sendMessageSlack"]; function resolveSlackSendContext(params: { @@ -345,22 +328,6 @@ async function resolveSlackAllowlistNames(params: { return await resolveSlackUserAllowlist({ token, entries: params.entries }); } -const slackConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }), - resolveAllowFrom: (account: ResolvedSlackAccount) => account.dm?.allowFrom, - formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), - resolveDefaultTo: (account: ResolvedSlackAccount) => account.config.defaultTo, -}); - -const slackConfigBase = createScopedChannelConfigBase({ - sectionKey: "slack", - listAccountIds: listSlackAccountIds, - resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), - inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }), - defaultAccountId: resolveDefaultSlackAccountId, - clearBaseFields: ["botToken", "appToken", "name"], -}); - const slackSetupWizard = createSlackSetupWizardProxy(async () => ({ slackSetupWizard: (await loadSlackChannelRuntime()).slackSetupWizard, })); @@ -425,12 +392,12 @@ export const slackPlugin: ChannelPlugin = { configSchema: buildChannelConfigSchema(SlackConfigSchema), config: { ...slackConfigBase, - isConfigured: (account) => isSlackAccountConfigured(account), + isConfigured: (account) => isSlackPluginAccountConfigured(account), describeAccount: (account) => ({ accountId: account.accountId, name: account.name, enabled: account.enabled, - configured: isSlackAccountConfigured(account), + configured: isSlackPluginAccountConfigured(account), botTokenSource: account.botTokenSource, appTokenSource: account.appTokenSource, }), @@ -722,7 +689,7 @@ export const slackPlugin: ChannelPlugin = { : resolveConfiguredFromRequiredCredentialStatuses(account, [ "botTokenStatus", "appTokenStatus", - ])) ?? isSlackAccountConfigured(account); + ])) ?? isSlackPluginAccountConfigured(account); const base = buildComputedAccountStatusSnapshot({ accountId: account.accountId, name: account.name, diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts index fc902f49558..b0883be083d 100644 --- a/extensions/slack/src/message-action-dispatch.ts +++ b/extensions/slack/src/message-action-dispatch.ts @@ -1,334 +1 @@ -import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/core"; -import { parseSlackBlocksInput } from "./blocks-input.js"; -import { buildSlackInteractiveBlocks } from "./blocks-render.js"; - -type SlackActionInvoke = ( - action: Record, - cfg: ChannelMessageActionContext["cfg"], - toolContext?: ChannelMessageActionContext["toolContext"], -) => Promise>; - -type InteractiveButtonStyle = "primary" | "secondary" | "success" | "danger"; - -type InteractiveReplyButton = { - label: string; - value: string; - style?: InteractiveButtonStyle; -}; - -type InteractiveReplyOption = { - label: string; - value: string; -}; - -type InteractiveReplyBlock = - | { type: "text"; text: string } - | { type: "buttons"; buttons: InteractiveReplyButton[] } - | { type: "select"; placeholder?: string; options: InteractiveReplyOption[] }; - -type InteractiveReply = { - blocks: InteractiveReplyBlock[]; -}; - -function readTrimmedString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed || undefined; -} - -function normalizeButtonStyle(value: unknown): InteractiveButtonStyle | undefined { - const style = readTrimmedString(value)?.toLowerCase(); - return style === "primary" || style === "secondary" || style === "success" || style === "danger" - ? style - : undefined; -} - -function normalizeInteractiveButton(raw: unknown): InteractiveReplyButton | undefined { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) { - return undefined; - } - const record = raw as Record; - const label = readTrimmedString(record.label) ?? readTrimmedString(record.text); - const value = - readTrimmedString(record.value) ?? - readTrimmedString(record.callbackData) ?? - readTrimmedString(record.callback_data); - if (!label || !value) { - return undefined; - } - return { label, value, style: normalizeButtonStyle(record.style) }; -} - -function normalizeInteractiveOption(raw: unknown): InteractiveReplyOption | undefined { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) { - return undefined; - } - const record = raw as Record; - const label = readTrimmedString(record.label) ?? readTrimmedString(record.text); - const value = readTrimmedString(record.value); - return label && value ? { label, value } : undefined; -} - -function normalizeInteractiveReply(raw: unknown): InteractiveReply | undefined { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) { - return undefined; - } - const record = raw as Record; - const blocks = Array.isArray(record.blocks) - ? record.blocks - .map((entry) => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { - return undefined; - } - const block = entry as Record; - const type = readTrimmedString(block.type)?.toLowerCase(); - if (type === "text") { - const text = readTrimmedString(block.text); - return text ? ({ type: "text", text } as const) : undefined; - } - if (type === "buttons") { - const buttons = Array.isArray(block.buttons) - ? block.buttons - .map((button) => normalizeInteractiveButton(button)) - .filter((button): button is InteractiveReplyButton => Boolean(button)) - : []; - return buttons.length > 0 ? ({ type: "buttons", buttons } as const) : undefined; - } - if (type === "select") { - const options = Array.isArray(block.options) - ? block.options - .map((option) => normalizeInteractiveOption(option)) - .filter((option): option is InteractiveReplyOption => Boolean(option)) - : []; - return options.length > 0 - ? ({ - type: "select", - placeholder: readTrimmedString(block.placeholder), - options, - } as const) - : undefined; - } - return undefined; - }) - .filter((entry): entry is InteractiveReplyBlock => Boolean(entry)) - : []; - return blocks.length > 0 ? { blocks } : undefined; -} - -function readStringParam( - params: Record, - key: string, - options: { required?: boolean; trim?: boolean; label?: string; allowEmpty?: boolean } = {}, -): string | undefined { - const { required = false, trim = true, label = key, allowEmpty = false } = options; - const raw = params[key]; - if (typeof raw !== "string") { - if (required) { - throw new Error(`${label} required`); - } - return undefined; - } - const value = trim ? raw.trim() : raw; - if (!value && !allowEmpty) { - if (required) { - throw new Error(`${label} required`); - } - return undefined; - } - return value; -} - -function readNumberParam( - params: Record, - key: string, - options: { required?: boolean; label?: string; integer?: boolean; strict?: boolean } = {}, -): number | undefined { - const { required = false, label = key, integer = false, strict = false } = options; - const raw = params[key]; - let value: number | undefined; - if (typeof raw === "number" && Number.isFinite(raw)) { - value = raw; - } else if (typeof raw === "string") { - const trimmed = raw.trim(); - if (trimmed) { - const parsed = strict ? Number(trimmed) : Number.parseFloat(trimmed); - if (Number.isFinite(parsed)) { - value = parsed; - } - } - } - if (value === undefined) { - if (required) { - throw new Error(`${label} required`); - } - return undefined; - } - return integer ? Math.trunc(value) : value; -} - -function readSlackBlocksParam(actionParams: Record) { - return parseSlackBlocksInput(actionParams.blocks) as Record[] | undefined; -} - -export async function handleSlackMessageAction(params: { - providerId: string; - ctx: ChannelMessageActionContext; - invoke: SlackActionInvoke; - normalizeChannelId?: (channelId: string) => string; - includeReadThreadId?: boolean; -}): Promise> { - const { providerId, ctx, invoke, normalizeChannelId, includeReadThreadId = false } = params; - const { action, cfg, params: actionParams } = ctx; - const accountId = ctx.accountId ?? undefined; - const resolveChannelId = () => { - const channelId = - readStringParam(actionParams, "channelId") ?? - readStringParam(actionParams, "to", { required: true }); - if (!channelId) { - throw new Error("channelId required"); - } - return normalizeChannelId ? normalizeChannelId(channelId) : channelId; - }; - - if (action === "send") { - const to = readStringParam(actionParams, "to", { required: true }); - const content = readStringParam(actionParams, "message", { allowEmpty: true }); - const mediaUrl = readStringParam(actionParams, "media", { trim: false }); - const interactive = normalizeInteractiveReply(actionParams.interactive); - const interactiveBlocks = interactive ? buildSlackInteractiveBlocks(interactive) : undefined; - const blocks = readSlackBlocksParam(actionParams) ?? interactiveBlocks; - if (!content && !mediaUrl && !blocks) { - throw new Error("Slack send requires message, blocks, or media."); - } - if (mediaUrl && blocks) { - throw new Error("Slack send does not support blocks with media."); - } - const threadId = readStringParam(actionParams, "threadId"); - const replyTo = readStringParam(actionParams, "replyTo"); - return await invoke( - { - action: "sendMessage", - to, - content: content ?? "", - mediaUrl: mediaUrl ?? undefined, - accountId, - threadTs: threadId ?? replyTo ?? undefined, - ...(blocks ? { blocks } : {}), - }, - cfg, - ctx.toolContext, - ); - } - - if (action === "react") { - const messageId = readStringParam(actionParams, "messageId", { required: true }); - const emoji = readStringParam(actionParams, "emoji", { allowEmpty: true }); - const remove = typeof actionParams.remove === "boolean" ? actionParams.remove : undefined; - return await invoke( - { action: "react", channelId: resolveChannelId(), messageId, emoji, remove, accountId }, - cfg, - ); - } - - if (action === "reactions") { - const messageId = readStringParam(actionParams, "messageId", { required: true }); - const limit = readNumberParam(actionParams, "limit", { integer: true }); - return await invoke( - { action: "reactions", channelId: resolveChannelId(), messageId, limit, accountId }, - cfg, - ); - } - - if (action === "read") { - const limit = readNumberParam(actionParams, "limit", { integer: true }); - const readAction: Record = { - action: "readMessages", - channelId: resolveChannelId(), - limit, - before: readStringParam(actionParams, "before"), - after: readStringParam(actionParams, "after"), - accountId, - }; - if (includeReadThreadId) { - readAction.threadId = readStringParam(actionParams, "threadId"); - } - return await invoke(readAction, cfg); - } - - if (action === "edit") { - const messageId = readStringParam(actionParams, "messageId", { required: true }); - const content = readStringParam(actionParams, "message", { allowEmpty: true }); - const blocks = readSlackBlocksParam(actionParams); - if (!content && !blocks) { - throw new Error("Slack edit requires message or blocks."); - } - return await invoke( - { - action: "editMessage", - channelId: resolveChannelId(), - messageId, - content: content ?? "", - blocks, - accountId, - }, - cfg, - ); - } - - if (action === "delete") { - const messageId = readStringParam(actionParams, "messageId", { required: true }); - return await invoke( - { action: "deleteMessage", channelId: resolveChannelId(), messageId, accountId }, - cfg, - ); - } - - if (action === "pin" || action === "unpin" || action === "list-pins") { - const messageId = - action === "list-pins" - ? undefined - : readStringParam(actionParams, "messageId", { required: true }); - return await invoke( - { - action: action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins", - channelId: resolveChannelId(), - messageId, - accountId, - }, - cfg, - ); - } - - if (action === "member-info") { - const userId = readStringParam(actionParams, "userId", { required: true }); - return await invoke({ action: "memberInfo", userId, accountId }, cfg); - } - - if (action === "emoji-list") { - const limit = readNumberParam(actionParams, "limit", { integer: true }); - return await invoke({ action: "emojiList", limit, accountId }, cfg); - } - - if (action === "download-file") { - const fileId = readStringParam(actionParams, "fileId", { required: true }); - const channelId = - readStringParam(actionParams, "channelId") ?? readStringParam(actionParams, "to"); - const threadId = - readStringParam(actionParams, "threadId") ?? readStringParam(actionParams, "replyTo"); - return await invoke( - { - action: "downloadFile", - fileId, - channelId: channelId ?? undefined, - threadId: threadId ?? undefined, - accountId, - }, - cfg, - ); - } - - throw new Error(`Action ${action} is not supported for provider ${providerId}.`); -} +export { handleSlackMessageAction } from "../../../src/plugin-sdk/slack-message-actions.js"; diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index 9b8ad30d240..a0f068b3e81 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -22,92 +22,12 @@ import { } from "../../../src/plugin-sdk-internal/setup.js"; import { inspectSlackAccount } from "./account-inspect.js"; import { listSlackAccountIds, resolveSlackAccount, type ResolvedSlackAccount } from "./accounts.js"; - -const channel = "slack" as const; - -function buildSlackManifest(botName: string) { - const safeName = botName.trim() || "OpenClaw"; - const manifest = { - display_information: { - name: safeName, - description: `${safeName} connector for OpenClaw`, - }, - features: { - bot_user: { - display_name: safeName, - always_online: false, - }, - app_home: { - messages_tab_enabled: true, - messages_tab_read_only_enabled: false, - }, - slash_commands: [ - { - command: "/openclaw", - description: "Send a message to OpenClaw", - should_escape: false, - }, - ], - }, - oauth_config: { - scopes: { - bot: [ - "chat:write", - "channels:history", - "channels:read", - "groups:history", - "im:history", - "mpim:history", - "users:read", - "app_mentions:read", - "reactions:read", - "reactions:write", - "pins:read", - "pins:write", - "emoji:read", - "commands", - "files:read", - "files:write", - ], - }, - }, - settings: { - socket_mode_enabled: true, - event_subscriptions: { - bot_events: [ - "app_mention", - "message.channels", - "message.groups", - "message.im", - "message.mpim", - "reaction_added", - "reaction_removed", - "member_joined_channel", - "member_left_channel", - "channel_rename", - "pin_added", - "pin_removed", - ], - }, - }, - }; - return JSON.stringify(manifest, null, 2); -} - -function buildSlackSetupLines(botName = "OpenClaw"): string[] { - return [ - "1) Slack API -> Create App -> From scratch or From manifest (with the JSON below)", - "2) Add Socket Mode + enable it to get the app-level token (xapp-...)", - "3) Install App to workspace to get the xoxb- bot token", - "4) Enable Event Subscriptions (socket) for message events", - "5) App Home -> enable the Messages tab for DMs", - "Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.", - `Docs: ${formatDocsLink("/slack", "slack")}`, - "", - "Manifest (JSON):", - buildSlackManifest(botName), - ]; -} +import { + buildSlackSetupLines, + isSlackSetupAccountConfigured, + setSlackChannelAllowlist, + SLACK_CHANNEL as channel, +} from "./shared.js"; function enableSlackAccount(cfg: OpenClawConfig, accountId: string): OpenClawConfig { return patchChannelConfigForAccount({ @@ -118,28 +38,6 @@ function enableSlackAccount(cfg: OpenClawConfig, accountId: string): OpenClawCon }); } -function setSlackChannelAllowlist( - cfg: OpenClawConfig, - accountId: string, - channelKeys: string[], -): OpenClawConfig { - const channels = Object.fromEntries(channelKeys.map((key) => [key, { allow: true }])); - return patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { channels }, - }); -} - -function isSlackAccountConfigured(account: ResolvedSlackAccount): boolean { - const hasConfiguredBotToken = - Boolean(account.botToken?.trim()) || hasConfiguredSecretInput(account.config.botToken); - const hasConfiguredAppToken = - Boolean(account.appToken?.trim()) || hasConfiguredSecretInput(account.config.appToken); - return hasConfiguredBotToken && hasConfiguredAppToken; -} - export const slackSetupAdapter: ChannelSetupAdapter = { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => @@ -256,7 +154,7 @@ export function createSlackSetupWizardProxy( title: "Slack socket mode tokens", lines: buildSlackSetupLines(), shouldShow: ({ cfg, accountId }) => - !isSlackAccountConfigured(resolveSlackAccount({ cfg, accountId })), + !isSlackSetupAccountConfigured(resolveSlackAccount({ cfg, accountId })), }, envShortcut: { prompt: "SLACK_BOT_TOKEN + SLACK_APP_TOKEN detected. Use env vars?", @@ -265,7 +163,7 @@ export function createSlackSetupWizardProxy( accountId === DEFAULT_ACCOUNT_ID && Boolean(process.env.SLACK_BOT_TOKEN?.trim()) && Boolean(process.env.SLACK_APP_TOKEN?.trim()) && - !isSlackAccountConfigured(resolveSlackAccount({ cfg, accountId })), + !isSlackSetupAccountConfigured(resolveSlackAccount({ cfg, accountId })), apply: ({ cfg, accountId }) => enableSlackAccount(cfg, accountId), }, credentials: [ diff --git a/extensions/slack/src/setup-surface.ts b/extensions/slack/src/setup-surface.ts index 6493a17ac79..de7dc06e40e 100644 --- a/extensions/slack/src/setup-surface.ts +++ b/extensions/slack/src/setup-surface.ts @@ -30,106 +30,12 @@ import { import { resolveSlackChannelAllowlist } from "./resolve-channels.js"; import { resolveSlackUserAllowlist } from "./resolve-users.js"; import { slackSetupAdapter } from "./setup-core.js"; - -const channel = "slack" as const; - -function buildSlackManifest(botName: string) { - const safeName = botName.trim() || "OpenClaw"; - const manifest = { - display_information: { - name: safeName, - description: `${safeName} connector for OpenClaw`, - }, - features: { - bot_user: { - display_name: safeName, - always_online: false, - }, - app_home: { - messages_tab_enabled: true, - messages_tab_read_only_enabled: false, - }, - slash_commands: [ - { - command: "/openclaw", - description: "Send a message to OpenClaw", - should_escape: false, - }, - ], - }, - oauth_config: { - scopes: { - bot: [ - "chat:write", - "channels:history", - "channels:read", - "groups:history", - "im:history", - "mpim:history", - "users:read", - "app_mentions:read", - "reactions:read", - "reactions:write", - "pins:read", - "pins:write", - "emoji:read", - "commands", - "files:read", - "files:write", - ], - }, - }, - settings: { - socket_mode_enabled: true, - event_subscriptions: { - bot_events: [ - "app_mention", - "message.channels", - "message.groups", - "message.im", - "message.mpim", - "reaction_added", - "reaction_removed", - "member_joined_channel", - "member_left_channel", - "channel_rename", - "pin_added", - "pin_removed", - ], - }, - }, - }; - return JSON.stringify(manifest, null, 2); -} - -function buildSlackSetupLines(botName = "OpenClaw"): string[] { - return [ - "1) Slack API -> Create App -> From scratch or From manifest (with the JSON below)", - "2) Add Socket Mode + enable it to get the app-level token (xapp-...)", - "3) Install App to workspace to get the xoxb- bot token", - "4) Enable Event Subscriptions (socket) for message events", - "5) App Home -> enable the Messages tab for DMs", - "Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.", - `Docs: ${formatDocsLink("/slack", "slack")}`, - "", - "Manifest (JSON):", - buildSlackManifest(botName), - ]; -} - -function setSlackChannelAllowlist( - cfg: OpenClawConfig, - accountId: string, - channelKeys: string[], -): OpenClawConfig { - const channels = Object.fromEntries(channelKeys.map((key) => [key, { allow: true }])); - return patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { channels }, - }); -} +import { + buildSlackSetupLines, + isSlackSetupAccountConfigured, + setSlackChannelAllowlist, + SLACK_CHANNEL as channel, +} from "./shared.js"; function enableSlackAccount(cfg: OpenClawConfig, accountId: string): OpenClawConfig { return patchChannelConfigForAccount({ @@ -227,14 +133,6 @@ const slackDmPolicy: ChannelSetupDmPolicy = { promptAllowFrom: promptSlackAllowFrom, }; -function isSlackAccountConfigured(account: ResolvedSlackAccount): boolean { - const hasConfiguredBotToken = - Boolean(account.botToken?.trim()) || hasConfiguredSecretInput(account.config.botToken); - const hasConfiguredAppToken = - Boolean(account.appToken?.trim()) || hasConfiguredSecretInput(account.config.appToken); - return hasConfiguredBotToken && hasConfiguredAppToken; -} - export const slackSetupWizard: ChannelSetupWizard = { channel, status: { @@ -254,7 +152,7 @@ export const slackSetupWizard: ChannelSetupWizard = { title: "Slack socket mode tokens", lines: buildSlackSetupLines(), shouldShow: ({ cfg, accountId }) => - !isSlackAccountConfigured(resolveSlackAccount({ cfg, accountId })), + !isSlackSetupAccountConfigured(resolveSlackAccount({ cfg, accountId })), }, envShortcut: { prompt: "SLACK_BOT_TOKEN + SLACK_APP_TOKEN detected. Use env vars?", @@ -263,7 +161,7 @@ export const slackSetupWizard: ChannelSetupWizard = { accountId === DEFAULT_ACCOUNT_ID && Boolean(process.env.SLACK_BOT_TOKEN?.trim()) && Boolean(process.env.SLACK_APP_TOKEN?.trim()) && - !isSlackAccountConfigured(resolveSlackAccount({ cfg, accountId })), + !isSlackSetupAccountConfigured(resolveSlackAccount({ cfg, accountId })), apply: ({ cfg, accountId }) => enableSlackAccount(cfg, accountId), }, credentials: [ diff --git a/extensions/slack/src/shared.ts b/extensions/slack/src/shared.ts new file mode 100644 index 00000000000..7345de3a22c --- /dev/null +++ b/extensions/slack/src/shared.ts @@ -0,0 +1,152 @@ +import { patchChannelConfigForAccount } from "../../../src/channels/plugins/setup-wizard-helpers.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; +import { formatAllowFromLowercase } from "../../../src/plugin-sdk/allow-from.js"; +import { + createScopedAccountConfigAccessors, + createScopedChannelConfigBase, +} from "../../../src/plugin-sdk/channel-config-helpers.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { inspectSlackAccount } from "./account-inspect.js"; +import { + listSlackAccountIds, + resolveDefaultSlackAccountId, + resolveSlackAccount, + type ResolvedSlackAccount, +} from "./accounts.js"; + +export const SLACK_CHANNEL = "slack" as const; + +function buildSlackManifest(botName: string) { + const safeName = botName.trim() || "OpenClaw"; + const manifest = { + display_information: { + name: safeName, + description: `${safeName} connector for OpenClaw`, + }, + features: { + bot_user: { + display_name: safeName, + always_online: false, + }, + app_home: { + messages_tab_enabled: true, + messages_tab_read_only_enabled: false, + }, + slash_commands: [ + { + command: "/openclaw", + description: "Send a message to OpenClaw", + should_escape: false, + }, + ], + }, + oauth_config: { + scopes: { + bot: [ + "chat:write", + "channels:history", + "channels:read", + "groups:history", + "im:history", + "mpim:history", + "users:read", + "app_mentions:read", + "reactions:read", + "reactions:write", + "pins:read", + "pins:write", + "emoji:read", + "commands", + "files:read", + "files:write", + ], + }, + }, + settings: { + socket_mode_enabled: true, + event_subscriptions: { + bot_events: [ + "app_mention", + "message.channels", + "message.groups", + "message.im", + "message.mpim", + "reaction_added", + "reaction_removed", + "member_joined_channel", + "member_left_channel", + "channel_rename", + "pin_added", + "pin_removed", + ], + }, + }, + }; + return JSON.stringify(manifest, null, 2); +} + +export function buildSlackSetupLines(botName = "OpenClaw"): string[] { + return [ + "1) Slack API -> Create App -> From scratch or From manifest (with the JSON below)", + "2) Add Socket Mode + enable it to get the app-level token (xapp-...)", + "3) Install App to workspace to get the xoxb- bot token", + "4) Enable Event Subscriptions (socket) for message events", + "5) App Home -> enable the Messages tab for DMs", + "Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.", + `Docs: ${formatDocsLink("/slack", "slack")}`, + "", + "Manifest (JSON):", + buildSlackManifest(botName), + ]; +} + +export function setSlackChannelAllowlist( + cfg: OpenClawConfig, + accountId: string, + channelKeys: string[], +): OpenClawConfig { + const channels = Object.fromEntries(channelKeys.map((key) => [key, { allow: true }])); + return patchChannelConfigForAccount({ + cfg, + channel: SLACK_CHANNEL, + accountId, + patch: { channels }, + }); +} + +export function isSlackPluginAccountConfigured(account: ResolvedSlackAccount): boolean { + const mode = account.config.mode ?? "socket"; + const hasBotToken = Boolean(account.botToken?.trim()); + if (!hasBotToken) { + return false; + } + if (mode === "http") { + return Boolean(account.config.signingSecret?.trim()); + } + return Boolean(account.appToken?.trim()); +} + +export function isSlackSetupAccountConfigured(account: ResolvedSlackAccount): boolean { + const hasConfiguredBotToken = + Boolean(account.botToken?.trim()) || hasConfiguredSecretInput(account.config.botToken); + const hasConfiguredAppToken = + Boolean(account.appToken?.trim()) || hasConfiguredSecretInput(account.config.appToken); + return hasConfiguredBotToken && hasConfiguredAppToken; +} + +export const slackConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedSlackAccount) => account.dm?.allowFrom, + formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), + resolveDefaultTo: (account: ResolvedSlackAccount) => account.config.defaultTo, +}); + +export const slackConfigBase = createScopedChannelConfigBase({ + sectionKey: SLACK_CHANNEL, + listAccountIds: listSlackAccountIds, + resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultSlackAccountId, + clearBaseFields: ["botToken", "appToken", "name"], +});