refactor(slack): share setup helpers
This commit is contained in:
parent
e88c6d8486
commit
1c0db5b8e4
@ -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,
|
||||
}),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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: [
|
||||
|
||||
152
extensions/slack/src/shared.ts
Normal file
152
extensions/slack/src/shared.ts
Normal 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"],
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user