refactor(slack): share setup helpers

This commit is contained in:
Peter Steinberger 2026-03-17 01:43:45 +00:00
parent e88c6d8486
commit 1c0db5b8e4
6 changed files with 177 additions and 633 deletions

View File

@ -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<ResolvedSlackAccount> = {
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,
}),

View File

@ -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<typeof getSlackRuntime>["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<ResolvedSlackAccount> = {
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<ResolvedSlackAccount> = {
: resolveConfiguredFromRequiredCredentialStatuses(account, [
"botTokenStatus",
"appTokenStatus",
])) ?? isSlackAccountConfigured(account);
])) ?? isSlackPluginAccountConfigured(account);
const base = buildComputedAccountStatusSnapshot({
accountId: account.accountId,
name: account.name,

View File

@ -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<string, unknown>,
cfg: ChannelMessageActionContext["cfg"],
toolContext?: ChannelMessageActionContext["toolContext"],
) => Promise<AgentToolResult<unknown>>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>,
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<string, unknown>,
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<string, unknown>) {
return parseSlackBlocksInput(actionParams.blocks) as Record<string, unknown>[] | undefined;
}
export async function handleSlackMessageAction(params: {
providerId: string;
ctx: ChannelMessageActionContext;
invoke: SlackActionInvoke;
normalizeChannelId?: (channelId: string) => string;
includeReadThreadId?: boolean;
}): Promise<AgentToolResult<unknown>> {
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<string, unknown> = {
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";

View File

@ -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: [

View File

@ -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: [

View File

@ -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"],
});