重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { inspectSlackAccount } from "./src/account-inspect.js";
export function inspectSlackReadOnlyAccount(cfg: OpenClawConfig, accountId?: string | null) {
return inspectSlackAccount({ cfg, accountId });
}

View file

@ -0,0 +1,26 @@
export { slackPlugin } from "./src/channel.js";
export { slackSetupPlugin } from "./src/channel.setup.js";
export * from "./src/account-inspect.js";
export * from "./src/accounts.js";
export * from "./src/action-threading.js";
export * from "./src/actions.js";
export * from "./src/blocks-input.js";
export * from "./src/blocks-render.js";
export * from "./src/channel-type.js";
export * from "./src/client.js";
export * from "./src/directory-config.js";
export * from "./src/http/index.js";
export type {
SlackInteractiveHandlerContext,
SlackInteractiveHandlerRegistration,
} from "./src/interactive-dispatch.js";
export * from "./src/interactive-replies.js";
export * from "./src/message-actions.js";
export * from "./src/group-policy.js";
export * from "./src/monitor/allow-list.js";
export * from "./src/probe.js";
export * from "./src/security-audit.js";
export * from "./src/sent-thread-cache.js";
export * from "./src/targets.js";
export * from "./src/threading-tool-context.js";
export { resolveSlackRuntimeGroupPolicy } from "./src/monitor/provider.js";

View file

@ -0,0 +1 @@
export { SlackChannelConfigSchema } from "./src/config-schema.js";

View file

@ -0,0 +1,20 @@
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelEntry({
id: "slack",
name: "Slack",
description: "Slack channel plugin",
importMetaUrl: import.meta.url,
plugin: {
specifier: "./api.js",
exportName: "slackPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
runtime: {
specifier: "./runtime-api.js",
exportName: "setSlackRuntime",
},
});

View file

@ -0,0 +1,3 @@
// Keep bundled channel entry imports narrow so bootstrap/discovery paths do
// not drag setup-only Slack surfaces into lightweight channel plugin loads.
export { slackPlugin } from "./src/channel.js";

View file

@ -0,0 +1,4 @@
export {
buildChannelConfigSchema,
SlackConfigSchema,
} from "openclaw/plugin-sdk/channel-config-schema";

View file

@ -0,0 +1,7 @@
const SLACK_CONFIGURED_ENV_KEYS = ["SLACK_APP_TOKEN", "SLACK_BOT_TOKEN", "SLACK_USER_TOKEN"];
export function hasSlackConfiguredState(params: { env?: NodeJS.ProcessEnv }): boolean {
return SLACK_CONFIGURED_ENV_KEYS.some(
(key) => typeof params.env?.[key] === "string" && params.env[key]?.trim().length > 0,
);
}

View file

@ -0,0 +1,14 @@
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
export {
listSlackDirectoryGroupsFromConfig,
listSlackDirectoryPeersFromConfig,
} from "./src/directory-config.js";
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export type {
SlackInteractiveHandlerContext,
SlackInteractiveHandlerRegistration,
} from "./src/interactive-dispatch.js";
export { collectSlackSecurityAuditFindings } from "./src/security-audit.js";

View file

@ -0,0 +1,4 @@
export {
listSlackDirectoryGroupsFromConfig,
listSlackDirectoryPeersFromConfig,
} from "./src/directory-config.js";

View file

@ -0,0 +1 @@
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";

View file

@ -0,0 +1,2 @@
export { prepareSlackMessage } from "./src/monitor/message-handler/prepare.js";
export { createInboundSlackTestContext } from "./src/monitor/message-handler/prepare.test-helpers.js";

View file

@ -0,0 +1,13 @@
import { describe } from "vitest";
import { assertBundledChannelEntries } from "../../test/helpers/bundled-channel-entry.ts";
import entry from "./index.js";
import setupEntry from "./setup-entry.js";
describe("slack bundled entries", () => {
assertBundledChannelEntries({
entry,
expectedId: "slack",
expectedName: "Slack",
setupEntry,
});
});

View file

@ -0,0 +1,37 @@
import {
defineBundledChannelEntry,
loadBundledEntryExportSync,
} from "openclaw/plugin-sdk/channel-entry-contract";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-entry-contract";
function registerSlackPluginHttpRoutes(api: OpenClawPluginApi): void {
const register = loadBundledEntryExportSync<(api: OpenClawPluginApi) => void>(import.meta.url, {
specifier: "./runtime-api.js",
exportName: "registerSlackPluginHttpRoutes",
});
register(api);
}
export default defineBundledChannelEntry({
id: "slack",
name: "Slack",
description: "Slack channel plugin",
importMetaUrl: import.meta.url,
plugin: {
specifier: "./channel-plugin-api.js",
exportName: "slackPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
runtime: {
specifier: "./runtime-api.js",
exportName: "setSlackRuntime",
},
accountInspect: {
specifier: "./account-inspect-api.js",
exportName: "inspectSlackReadOnlyAccount",
},
registerFull: registerSlackPluginHttpRoutes,
});

View file

@ -0,0 +1,4 @@
export {
compileSlackInteractiveReplies,
isSlackInteractiveRepliesEnabled,
} from "./src/interactive-replies.js";

View file

@ -0,0 +1 @@
export { describeSlackMessageTool as describeMessageTool } from "./src/message-tool-api.js";

View file

@ -0,0 +1,12 @@
{
"id": "slack",
"channels": ["slack"],
"channelEnvVars": {
"slack": ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_USER_TOKEN"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View file

@ -0,0 +1 @@
export { createSlackOutboundPayloadHarness } from "./src/outbound-payload.test-harness.js";

View file

@ -0,0 +1,39 @@
{
"name": "@openclaw/slack",
"version": "2026.4.20",
"private": true,
"description": "OpenClaw Slack channel plugin",
"type": "module",
"dependencies": {
"@slack/bolt": "^4.7.0",
"@slack/web-api": "^7.15.1",
"https-proxy-agent": "^9.0.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "slack",
"label": "Slack",
"selectionLabel": "Slack (Socket Mode)",
"detailLabel": "Slack Bot",
"docsPath": "/channels/slack",
"docsLabel": "slack",
"blurb": "supported (Socket Mode).",
"systemImage": "number",
"markdownCapable": true,
"configuredState": {
"specifier": "./configured-state",
"exportName": "hasSlackConfiguredState"
}
},
"bundle": {
"stageRuntimeDependencies": true
}
}
}

View file

@ -0,0 +1,7 @@
export * from "./src/action-runtime.js";
export * from "./src/directory-live.js";
export * from "./src/index.js";
export * from "./src/resolve-channels.js";
export * from "./src/resolve-users.js";
export { registerSlackPluginHttpRoutes } from "./src/http/plugin-routes.js";
export { setSlackRuntime } from "./src/runtime.js";

View file

@ -0,0 +1,5 @@
export {
channelSecrets,
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View file

@ -0,0 +1 @@
export { collectSlackSecurityAuditFindings } from "./src/security-audit.js";

View file

@ -0,0 +1,13 @@
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./setup-plugin-api.js",
exportName: "slackSetupPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
});

View file

@ -0,0 +1,3 @@
// Keep bundled setup entry imports narrow so setup loads do not pull the
// broader Slack channel plugin surface.
export { slackSetupPlugin } from "./src/channel.setup.js";

View file

@ -0,0 +1,190 @@
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
type OpenClawConfig,
} from "openclaw/plugin-sdk/account-resolution";
import {
hasConfiguredSecretInput,
normalizeSecretInputString,
} from "openclaw/plugin-sdk/secret-input";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { SlackAccountSurfaceFields } from "./account-surface-fields.js";
import {
mergeSlackAccountConfig,
resolveDefaultSlackAccountId,
type SlackTokenSource,
} from "./accounts.js";
import type { SlackAccountConfig } from "./runtime-api.js";
export type SlackCredentialStatus = "available" | "configured_unavailable" | "missing";
export type InspectedSlackAccount = {
accountId: string;
enabled: boolean;
name?: string;
mode?: SlackAccountConfig["mode"];
botToken?: string;
appToken?: string;
signingSecret?: string;
userToken?: string;
botTokenSource: SlackTokenSource;
appTokenSource: SlackTokenSource;
signingSecretSource?: SlackTokenSource;
userTokenSource: SlackTokenSource;
botTokenStatus: SlackCredentialStatus;
appTokenStatus: SlackCredentialStatus;
signingSecretStatus?: SlackCredentialStatus;
userTokenStatus: SlackCredentialStatus;
configured: boolean;
config: SlackAccountConfig;
} & SlackAccountSurfaceFields;
function inspectSlackToken(value: unknown): {
token?: string;
source: Exclude<SlackTokenSource, "env">;
status: SlackCredentialStatus;
} {
const token = normalizeSecretInputString(value);
if (token) {
return {
token,
source: "config",
status: "available",
};
}
if (hasConfiguredSecretInput(value)) {
return {
source: "config",
status: "configured_unavailable",
};
}
return {
source: "none",
status: "missing",
};
}
export function inspectSlackAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
envBotToken?: string | null;
envAppToken?: string | null;
envUserToken?: string | null;
}): InspectedSlackAccount {
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultSlackAccountId(params.cfg),
);
const merged = mergeSlackAccountConfig(params.cfg, accountId);
const enabled = params.cfg.channels?.slack?.enabled !== false && merged.enabled !== false;
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
const mode = merged.mode ?? "socket";
const isHttpMode = mode === "http";
const configBot = inspectSlackToken(merged.botToken);
const configApp = inspectSlackToken(merged.appToken);
const configSigningSecret = inspectSlackToken(merged.signingSecret);
const configUser = inspectSlackToken(merged.userToken);
const envBot = allowEnv
? normalizeSecretInputString(params.envBotToken ?? process.env.SLACK_BOT_TOKEN)
: undefined;
const envApp = allowEnv
? normalizeSecretInputString(params.envAppToken ?? process.env.SLACK_APP_TOKEN)
: undefined;
const envUser = allowEnv
? normalizeSecretInputString(params.envUserToken ?? process.env.SLACK_USER_TOKEN)
: undefined;
const botToken = configBot.token ?? envBot;
const appToken = configApp.token ?? envApp;
const signingSecret = configSigningSecret.token;
const userToken = configUser.token ?? envUser;
const botTokenSource: SlackTokenSource = configBot.token
? "config"
: configBot.status === "configured_unavailable"
? "config"
: envBot
? "env"
: "none";
const appTokenSource: SlackTokenSource = configApp.token
? "config"
: configApp.status === "configured_unavailable"
? "config"
: envApp
? "env"
: "none";
const signingSecretSource: SlackTokenSource = configSigningSecret.token
? "config"
: configSigningSecret.status === "configured_unavailable"
? "config"
: "none";
const userTokenSource: SlackTokenSource = configUser.token
? "config"
: configUser.status === "configured_unavailable"
? "config"
: envUser
? "env"
: "none";
return {
accountId,
enabled,
name: normalizeOptionalString(merged.name),
mode,
botToken,
appToken,
...(isHttpMode ? { signingSecret } : {}),
userToken,
botTokenSource,
appTokenSource,
...(isHttpMode ? { signingSecretSource } : {}),
userTokenSource,
botTokenStatus: configBot.token
? "available"
: configBot.status === "configured_unavailable"
? "configured_unavailable"
: envBot
? "available"
: "missing",
appTokenStatus: configApp.token
? "available"
: configApp.status === "configured_unavailable"
? "configured_unavailable"
: envApp
? "available"
: "missing",
...(isHttpMode
? {
signingSecretStatus: configSigningSecret.token
? "available"
: configSigningSecret.status === "configured_unavailable"
? "configured_unavailable"
: "missing",
}
: {}),
userTokenStatus: configUser.token
? "available"
: configUser.status === "configured_unavailable"
? "configured_unavailable"
: envUser
? "available"
: "missing",
configured: isHttpMode
? (configBot.status !== "missing" || Boolean(envBot)) &&
configSigningSecret.status !== "missing"
: (configBot.status !== "missing" || Boolean(envBot)) &&
(configApp.status !== "missing" || Boolean(envApp)),
config: merged,
groupPolicy: merged.groupPolicy,
textChunkLimit: merged.textChunkLimit,
mediaMaxMb: merged.mediaMaxMb,
reactionNotifications: merged.reactionNotifications,
reactionAllowlist: merged.reactionAllowlist,
replyToMode: merged.replyToMode,
replyToModeByChatType: merged.replyToModeByChatType,
actions: merged.actions,
slashCommand: merged.slashCommand,
dm: merged.dm,
channels: merged.channels,
};
}

View file

@ -0,0 +1,15 @@
import type { SlackAccountConfig } from "openclaw/plugin-sdk/config-runtime";
export type SlackAccountSurfaceFields = {
groupPolicy?: SlackAccountConfig["groupPolicy"];
textChunkLimit?: SlackAccountConfig["textChunkLimit"];
mediaMaxMb?: SlackAccountConfig["mediaMaxMb"];
reactionNotifications?: SlackAccountConfig["reactionNotifications"];
reactionAllowlist?: SlackAccountConfig["reactionAllowlist"];
replyToMode?: SlackAccountConfig["replyToMode"];
replyToModeByChatType?: SlackAccountConfig["replyToModeByChatType"];
actions?: SlackAccountConfig["actions"];
slashCommand?: SlackAccountConfig["slashCommand"];
dm?: SlackAccountConfig["dm"];
channels?: SlackAccountConfig["channels"];
};

View file

@ -0,0 +1 @@
export { resolveSlackAccount } from "./accounts.js";

View file

@ -0,0 +1,319 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it } from "vitest";
import { resolveSlackAccount } from "./accounts.js";
describe("resolveSlackAccount allowFrom precedence", () => {
it("uses configured defaultAccount when accountId is omitted", () => {
const resolved = resolveSlackAccount({
cfg: {
channels: {
slack: {
defaultAccount: "work",
accounts: {
work: {
name: "Work",
botToken: "xoxb-work",
appToken: "xapp-work",
},
},
},
},
},
});
expect(resolved.accountId).toBe("work");
expect(resolved.name).toBe("Work");
expect(resolved.botToken).toBe("xoxb-work");
expect(resolved.appToken).toBe("xapp-work");
});
it("prefers accounts.default.allowFrom over top-level for default account", () => {
const resolved = resolveSlackAccount({
cfg: {
channels: {
slack: {
allowFrom: ["top"],
accounts: {
default: {
botToken: "xoxb-default",
appToken: "xapp-default",
allowFrom: ["default"],
},
},
},
},
},
accountId: "default",
});
expect(resolved.config.allowFrom).toEqual(["default"]);
});
it("falls back to top-level allowFrom for named account without override", () => {
const resolved = resolveSlackAccount({
cfg: {
channels: {
slack: {
allowFrom: ["top"],
accounts: {
work: { botToken: "xoxb-work", appToken: "xapp-work" },
},
},
},
},
accountId: "work",
});
expect(resolved.config.allowFrom).toEqual(["top"]);
});
it("does not inherit default account allowFrom for named account when top-level is absent", () => {
const resolved = resolveSlackAccount({
cfg: {
channels: {
slack: {
accounts: {
default: {
botToken: "xoxb-default",
appToken: "xapp-default",
allowFrom: ["default"],
},
work: { botToken: "xoxb-work", appToken: "xapp-work" },
},
},
},
},
accountId: "work",
});
expect(resolved.config.allowFrom).toBeUndefined();
});
it("falls back to top-level dm.allowFrom when allowFrom alias is unset", () => {
const resolved = resolveSlackAccount({
cfg: {
channels: {
slack: {
dm: { allowFrom: ["U123"] },
accounts: {
work: { botToken: "xoxb-work", appToken: "xapp-work" },
},
},
},
},
accountId: "work",
});
expect(resolved.config.allowFrom).toBeUndefined();
expect(resolved.config.dm?.allowFrom).toEqual(["U123"]);
});
});
describe("resolveSlackAccount tolerateUnresolvedSecrets", () => {
// The static `SlackAccountConfig.botToken` type is `string` because it
// models the post-resolution shape, but the runtime cfg snapshot can still
// hold an unresolved `SecretRef` object for inactive channel targets (per
// the inspect/strict separation in #66818). Cast via `unknown` so the test
// can construct that runtime-only shape without weakening the production
// type. See #68237.
const cfgWithUnresolvedBotTokenRef = {
channels: {
slack: {
accounts: {
default: {
botToken: { source: "exec", provider: "default", id: "slack_bot_token" },
allowFrom: ["U999"],
},
},
},
},
} as unknown as OpenClawConfig;
it("throws by default when the snapshot still holds an unresolved SecretRef botToken", () => {
expect(() =>
resolveSlackAccount({
cfg: cfgWithUnresolvedBotTokenRef,
accountId: "default",
}),
).toThrowError(/channels\.slack\.accounts\.default\.botToken/);
});
it("returns undefined credentials without throwing when tolerateUnresolvedSecrets is set", () => {
const resolved = resolveSlackAccount({
cfg: cfgWithUnresolvedBotTokenRef,
accountId: "default",
tolerateUnresolvedSecrets: true,
});
expect(resolved.botToken).toBeUndefined();
expect(resolved.botTokenSource).toBe("none");
// Surrounding account info still resolves so callers with an explicit
// override (for example sendMessageSlack receiving opts.token) can keep
// operating.
expect(resolved.accountId).toBe("default");
expect(resolved.config.allowFrom).toEqual(["U999"]);
});
it("still returns resolved string credentials in tolerant mode", () => {
const resolved = resolveSlackAccount({
cfg: {
channels: {
slack: {
accounts: {
default: { botToken: "xoxb-resolved", appToken: "xapp-resolved" },
},
},
},
},
accountId: "default",
tolerateUnresolvedSecrets: true,
});
expect(resolved.botToken).toBe("xoxb-resolved");
expect(resolved.botTokenSource).toBe("config");
expect(resolved.appToken).toBe("xapp-resolved");
expect(resolved.appTokenSource).toBe("config");
});
it("does not silently fall back to SLACK_*_TOKEN env vars in tolerant mode when all credentials are configured as SecretRef (credential confusion guard)", () => {
// Each credential is configured as a SecretRef. In tolerant mode none of
// them resolves, so per-credential env gating must block all three env
// vars; otherwise a stray `SLACK_*_TOKEN` would silently impersonate the
// operator-configured account (CWE-287 credential confusion).
const cfgAllSecretRefs = {
channels: {
slack: {
accounts: {
default: {
botToken: { source: "exec", provider: "default", id: "slack_bot_token" },
appToken: { source: "exec", provider: "default", id: "slack_app_token" },
userToken: { source: "exec", provider: "default", id: "slack_user_token" },
},
},
},
},
} as unknown as OpenClawConfig;
const previousBotToken = process.env.SLACK_BOT_TOKEN;
const previousAppToken = process.env.SLACK_APP_TOKEN;
const previousUserToken = process.env.SLACK_USER_TOKEN;
process.env.SLACK_BOT_TOKEN = "xoxb-env-fallback";
process.env.SLACK_APP_TOKEN = "xapp-env-fallback";
process.env.SLACK_USER_TOKEN = "xoxp-env-fallback";
try {
const resolved = resolveSlackAccount({
cfg: cfgAllSecretRefs,
accountId: "default",
tolerateUnresolvedSecrets: true,
});
expect(resolved.botToken).toBeUndefined();
expect(resolved.botTokenSource).toBe("none");
expect(resolved.appToken).toBeUndefined();
expect(resolved.appTokenSource).toBe("none");
expect(resolved.userToken).toBeUndefined();
expect(resolved.userTokenSource).toBe("none");
} finally {
if (previousBotToken === undefined) {
delete process.env.SLACK_BOT_TOKEN;
} else {
process.env.SLACK_BOT_TOKEN = previousBotToken;
}
if (previousAppToken === undefined) {
delete process.env.SLACK_APP_TOKEN;
} else {
process.env.SLACK_APP_TOKEN = previousAppToken;
}
if (previousUserToken === undefined) {
delete process.env.SLACK_USER_TOKEN;
} else {
process.env.SLACK_USER_TOKEN = previousUserToken;
}
}
});
it("preserves SLACK_BOT_TOKEN env fallback in tolerant mode when no config token is set (env-only setups)", () => {
const previousBotToken = process.env.SLACK_BOT_TOKEN;
const previousAppToken = process.env.SLACK_APP_TOKEN;
process.env.SLACK_BOT_TOKEN = "xoxb-env-only";
process.env.SLACK_APP_TOKEN = "xapp-env-only";
try {
// No SecretRef and no string token configured for the default account:
// env fallback must still fire so env-only deployments (relying solely
// on SLACK_BOT_TOKEN / SLACK_APP_TOKEN) keep working when callers like
// `channel.ts` invoke sendMessageSlack without an explicit override.
const resolved = resolveSlackAccount({
cfg: {
channels: {
slack: {
accounts: {
default: { allowFrom: ["U001"] },
},
},
},
},
accountId: "default",
tolerateUnresolvedSecrets: true,
});
expect(resolved.botToken).toBe("xoxb-env-only");
expect(resolved.botTokenSource).toBe("env");
expect(resolved.appToken).toBe("xapp-env-only");
expect(resolved.appTokenSource).toBe("env");
} finally {
if (previousBotToken === undefined) {
delete process.env.SLACK_BOT_TOKEN;
} else {
process.env.SLACK_BOT_TOKEN = previousBotToken;
}
if (previousAppToken === undefined) {
delete process.env.SLACK_APP_TOKEN;
} else {
process.env.SLACK_APP_TOKEN = previousAppToken;
}
}
});
it("blocks env fallback per-credential: unresolved SecretRef on botToken does not leak SLACK_APP_TOKEN", () => {
const previousBotToken = process.env.SLACK_BOT_TOKEN;
const previousAppToken = process.env.SLACK_APP_TOKEN;
process.env.SLACK_BOT_TOKEN = "xoxb-env-bot";
process.env.SLACK_APP_TOKEN = "xapp-env-app";
try {
// botToken has an unresolved SecretRef (env fallback should be
// blocked), but appToken is unset (env fallback should still fire).
// This proves the gating is per-credential, not whole-account.
const resolved = resolveSlackAccount({
cfg: {
channels: {
slack: {
accounts: {
default: {
botToken: { source: "exec", provider: "default", id: "slack_bot_token" },
},
},
},
},
} as unknown as OpenClawConfig,
accountId: "default",
tolerateUnresolvedSecrets: true,
});
expect(resolved.botToken).toBeUndefined();
expect(resolved.botTokenSource).toBe("none");
// appToken was never configured → env fallback still fires.
expect(resolved.appToken).toBe("xapp-env-app");
expect(resolved.appTokenSource).toBe("env");
} finally {
if (previousBotToken === undefined) {
delete process.env.SLACK_BOT_TOKEN;
} else {
process.env.SLACK_BOT_TOKEN = previousBotToken;
}
if (previousAppToken === undefined) {
delete process.env.SLACK_APP_TOKEN;
} else {
process.env.SLACK_APP_TOKEN = previousAppToken;
}
}
});
});

View file

@ -0,0 +1,158 @@
import {
createAccountListHelpers,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeChatType,
resolveMergedAccountConfig,
type OpenClawConfig,
} from "openclaw/plugin-sdk/account-resolution";
import { isSecretRef, normalizeSecretInputString } from "openclaw/plugin-sdk/secret-input";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { SlackAccountSurfaceFields } from "./account-surface-fields.js";
import type { SlackAccountConfig } from "./runtime-api.js";
import { resolveSlackAppToken, resolveSlackBotToken, resolveSlackUserToken } from "./token.js";
export type SlackTokenSource = "env" | "config" | "none";
export type ResolvedSlackAccount = {
accountId: string;
enabled: boolean;
name?: string;
botToken?: string;
appToken?: string;
userToken?: string;
botTokenSource: SlackTokenSource;
appTokenSource: SlackTokenSource;
userTokenSource: SlackTokenSource;
config: SlackAccountConfig;
} & SlackAccountSurfaceFields;
const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("slack");
export const listSlackAccountIds = listAccountIds;
export const resolveDefaultSlackAccountId = resolveDefaultAccountId;
export function mergeSlackAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): SlackAccountConfig {
return resolveMergedAccountConfig<SlackAccountConfig>({
channelConfig: cfg.channels?.slack as SlackAccountConfig,
accounts: cfg.channels?.slack?.accounts as Record<string, Partial<SlackAccountConfig>>,
accountId,
});
}
export function resolveSlackAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
/**
* When true, account-level credential reads (`botToken`, `appToken`,
* `userToken`) silently become `undefined` for unresolved `SecretRef`
* inputs instead of throwing. Default is false to preserve the strict
* behavior expected by boot-time provider initialization, which must
* surface unresolved channel SecretRefs loudly.
*
* Pass `true` from call sites that already have a separately-resolved
* credential override (for example `sendMessageSlack` receives an explicit
* `opts.token` derived from the boot-time monitor context) and only need
* the rest of the account info (account id, dm policy, channel settings,
* etc.). The downstream consumer's existing `if (!token)` guard still
* surfaces a clean "missing token" error when no explicit override is
* supplied either.
*
* Without this opt-in, an inactive `channels.slack.accounts.*.botToken`
* SecretRef left in the runtime snapshot (per the inspect/strict
* separation introduced in #66818) blows up the strict resolver path even
* though the actual send already has a valid boot-resolved token. See
* #68237.
*/
tolerateUnresolvedSecrets?: boolean;
}): ResolvedSlackAccount {
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultSlackAccountId(params.cfg),
);
const baseEnabled = params.cfg.channels?.slack?.enabled !== false;
const merged = mergeSlackAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
// Per-credential env-var fallback gating: in tolerant mode, only block
// the `SLACK_*_TOKEN` env fallback for credentials whose configured value
// is an unresolved `SecretRef` object. Otherwise (config field is a
// resolved string, or unset entirely) keep the original env fallback so
// legitimate env-only setups (no per-account config token, just
// `SLACK_BOT_TOKEN` in the process env) keep working. This avoids
// credential confusion (CWE-287) on misconfigured deployments where an
// unresolved SecretRef would otherwise be silently overridden by a stray
// env var, while preserving the env-only contract that callers like
// `extensions/slack/src/channel.ts` rely on when omitting `opts.token`.
const baseAllowEnv = accountId === DEFAULT_ACCOUNT_ID;
const tolerantMode = params.tolerateUnresolvedSecrets === true;
const blockBotEnv = tolerantMode && isSecretRef(merged.botToken);
const blockAppEnv = tolerantMode && isSecretRef(merged.appToken);
const blockUserEnv = tolerantMode && isSecretRef(merged.userToken);
const envBot =
baseAllowEnv && !blockBotEnv ? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN) : undefined;
const envApp =
baseAllowEnv && !blockAppEnv ? resolveSlackAppToken(process.env.SLACK_APP_TOKEN) : undefined;
const envUser =
baseAllowEnv && !blockUserEnv ? resolveSlackUserToken(process.env.SLACK_USER_TOKEN) : undefined;
const configBot = tolerantMode
? normalizeSecretInputString(merged.botToken)
: resolveSlackBotToken(merged.botToken, `channels.slack.accounts.${accountId}.botToken`);
const configApp = tolerantMode
? normalizeSecretInputString(merged.appToken)
: resolveSlackAppToken(merged.appToken, `channels.slack.accounts.${accountId}.appToken`);
const configUser = tolerantMode
? normalizeSecretInputString(merged.userToken)
: resolveSlackUserToken(merged.userToken, `channels.slack.accounts.${accountId}.userToken`);
const botToken = configBot ?? envBot;
const appToken = configApp ?? envApp;
const userToken = configUser ?? envUser;
const botTokenSource: SlackTokenSource = configBot ? "config" : envBot ? "env" : "none";
const appTokenSource: SlackTokenSource = configApp ? "config" : envApp ? "env" : "none";
const userTokenSource: SlackTokenSource = configUser ? "config" : envUser ? "env" : "none";
return {
accountId,
enabled,
name: normalizeOptionalString(merged.name),
botToken,
appToken,
userToken,
botTokenSource,
appTokenSource,
userTokenSource,
config: merged,
groupPolicy: merged.groupPolicy,
textChunkLimit: merged.textChunkLimit,
mediaMaxMb: merged.mediaMaxMb,
reactionNotifications: merged.reactionNotifications,
reactionAllowlist: merged.reactionAllowlist,
replyToMode: merged.replyToMode,
replyToModeByChatType: merged.replyToModeByChatType,
actions: merged.actions,
slashCommand: merged.slashCommand,
dm: merged.dm,
channels: merged.channels,
};
}
export function listEnabledSlackAccounts(cfg: OpenClawConfig): ResolvedSlackAccount[] {
return listSlackAccountIds(cfg)
.map((accountId) => resolveSlackAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}
export function resolveSlackReplyToMode(
account: ResolvedSlackAccount,
chatType?: string | null,
): "off" | "first" | "all" | "batched" {
const normalized = normalizeChatType(chatType ?? undefined);
if (normalized && account.replyToModeByChatType?.[normalized] !== undefined) {
return account.replyToModeByChatType[normalized] ?? "off";
}
if (normalized === "direct" && account.dm?.replyToMode !== undefined) {
return account.dm.replyToMode;
}
return account.replyToMode ?? "off";
}

View file

@ -0,0 +1 @@
export { handleSlackAction } from "./action-runtime.js";

View file

@ -0,0 +1,699 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { handleSlackAction, slackActionRuntime } from "./action-runtime.js";
import { parseSlackBlocksInput } from "./blocks-input.js";
const originalSlackActionRuntime = { ...slackActionRuntime };
const deleteSlackMessage = vi.fn(async (..._args: unknown[]) => ({}));
const downloadSlackFile = vi.fn(async (..._args: unknown[]) => null);
const editSlackMessage = vi.fn(async (..._args: unknown[]) => ({}));
const getSlackMemberInfo = vi.fn(async (..._args: unknown[]) => ({}));
const listSlackEmojis = vi.fn(async (..._args: unknown[]) => ({}));
const listSlackPins = vi.fn(async (..._args: unknown[]) => ({}));
const listSlackReactions = vi.fn(async (..._args: unknown[]) => ({}));
const pinSlackMessage = vi.fn(async (..._args: unknown[]) => ({}));
const reactSlackMessage = vi.fn(async (..._args: unknown[]) => ({}));
const readSlackMessages = vi.fn(async (..._args: unknown[]) => ({}));
const removeOwnSlackReactions = vi.fn(async (..._args: unknown[]) => ["thumbsup"]);
const removeSlackReaction = vi.fn(async (..._args: unknown[]) => ({}));
const recordSlackThreadParticipation = vi.fn();
const sendSlackMessage = vi.fn(async (..._args: unknown[]) => ({ channelId: "C123" }));
const unpinSlackMessage = vi.fn(async (..._args: unknown[]) => ({}));
describe("handleSlackAction", () => {
function slackConfig(overrides?: Record<string, unknown>): OpenClawConfig {
return {
channels: {
slack: {
botToken: "tok",
...overrides,
},
},
} as OpenClawConfig;
}
function createReplyToFirstContext(hasRepliedRef: { value: boolean }) {
return {
currentChannelId: "C123",
currentThreadTs: "1111111111.111111",
replyToMode: "first" as const,
hasRepliedRef,
};
}
function createReplyToFirstScenario() {
const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig;
sendSlackMessage.mockClear();
const hasRepliedRef = { value: false };
const context = createReplyToFirstContext(hasRepliedRef);
return { cfg, context, hasRepliedRef };
}
function expectLastSlackSend(content: string, threadTs?: string) {
expect(sendSlackMessage).toHaveBeenLastCalledWith("channel:C123", content, {
mediaUrl: undefined,
threadTs,
blocks: undefined,
});
}
async function sendSecondMessageAndExpectNoThread(params: {
cfg: OpenClawConfig;
context: ReturnType<typeof createReplyToFirstContext>;
}) {
await handleSlackAction(
{ action: "sendMessage", to: "channel:C123", content: "Second" },
params.cfg,
params.context,
);
expectLastSlackSend("Second");
}
async function resolveReadToken(cfg: OpenClawConfig): Promise<string | undefined> {
readSlackMessages.mockClear();
readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false });
await handleSlackAction({ action: "readMessages", channelId: "C1" }, cfg);
const opts = readSlackMessages.mock.calls[0]?.[1] as { token?: string } | undefined;
return opts?.token;
}
async function resolveSendToken(cfg: OpenClawConfig): Promise<string | undefined> {
sendSlackMessage.mockClear();
await handleSlackAction({ action: "sendMessage", to: "channel:C1", content: "Hello" }, cfg);
const opts = sendSlackMessage.mock.calls[0]?.[2] as { token?: string } | undefined;
return opts?.token;
}
beforeEach(() => {
vi.clearAllMocks();
Object.assign(slackActionRuntime, originalSlackActionRuntime, {
deleteSlackMessage,
downloadSlackFile,
editSlackMessage,
getSlackMemberInfo,
listSlackEmojis,
listSlackPins,
listSlackReactions,
parseSlackBlocksInput,
pinSlackMessage,
reactSlackMessage,
readSlackMessages,
recordSlackThreadParticipation,
removeOwnSlackReactions,
removeSlackReaction,
sendSlackMessage,
unpinSlackMessage,
});
});
it.each([
{ name: "raw channel id", channelId: "C1" },
{ name: "channel: prefixed id", channelId: "channel:C1" },
])("adds reactions for $name", async ({ channelId }) => {
await handleSlackAction(
{
action: "react",
channelId,
messageId: "123.456",
emoji: "✅",
},
slackConfig(),
);
expect(reactSlackMessage).toHaveBeenCalledWith("C1", "123.456", "✅");
});
it("removes reactions on empty emoji", async () => {
await handleSlackAction(
{
action: "react",
channelId: "C1",
messageId: "123.456",
emoji: "",
},
slackConfig(),
);
expect(removeOwnSlackReactions).toHaveBeenCalledWith("C1", "123.456");
});
it("removes reactions when remove flag set", async () => {
await handleSlackAction(
{
action: "react",
channelId: "C1",
messageId: "123.456",
emoji: "✅",
remove: true,
},
slackConfig(),
);
expect(removeSlackReaction).toHaveBeenCalledWith("C1", "123.456", "✅");
});
it("rejects removes without emoji", async () => {
await expect(
handleSlackAction(
{
action: "react",
channelId: "C1",
messageId: "123.456",
emoji: "",
remove: true,
},
slackConfig(),
),
).rejects.toThrow(/Emoji is required/);
});
it("respects reaction gating", async () => {
await expect(
handleSlackAction(
{
action: "react",
channelId: "C1",
messageId: "123.456",
emoji: "✅",
},
slackConfig({ actions: { reactions: false } }),
),
).rejects.toThrow(/Slack reactions are disabled/);
});
it("passes threadTs to sendSlackMessage for thread replies", async () => {
await handleSlackAction(
{
action: "sendMessage",
to: "channel:C123",
content: "Hello thread",
threadTs: "1234567890.123456",
},
slackConfig(),
);
expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Hello thread", {
mediaUrl: undefined,
threadTs: "1234567890.123456",
blocks: undefined,
});
});
it("returns a friendly error when downloadFile cannot fetch the attachment", async () => {
downloadSlackFile.mockResolvedValueOnce(null);
const result = await handleSlackAction(
{
action: "downloadFile",
fileId: "F123",
},
slackConfig(),
);
expect(downloadSlackFile).toHaveBeenCalledWith(
"F123",
expect.objectContaining({ maxBytes: 20 * 1024 * 1024 }),
);
expect(result).toEqual(
expect.objectContaining({
details: expect.objectContaining({ ok: false }),
}),
);
});
it("passes download scope (channel/thread) to downloadSlackFile", async () => {
downloadSlackFile.mockResolvedValueOnce(null);
const result = await handleSlackAction(
{
action: "downloadFile",
fileId: "F123",
to: "channel:C1",
replyTo: "123.456",
},
slackConfig(),
);
expect(downloadSlackFile).toHaveBeenCalledWith(
"F123",
expect.objectContaining({
channelId: "C1",
threadId: "123.456",
}),
);
expect(result).toEqual(
expect.objectContaining({
details: expect.objectContaining({ ok: false }),
}),
);
});
it("forwards resolved botToken to action functions instead of relying on config re-read", async () => {
downloadSlackFile.mockResolvedValueOnce(null);
await handleSlackAction({ action: "downloadFile", fileId: "F123" }, slackConfig());
const opts = downloadSlackFile.mock.calls[0]?.[1] as { token?: string } | undefined;
expect(opts?.token).toBe("tok");
});
it("keeps resolved userToken for downloadFile reads when configured", async () => {
downloadSlackFile.mockResolvedValueOnce(null);
await handleSlackAction(
{ action: "downloadFile", fileId: "F123" },
slackConfig({
accounts: {
default: {
botToken: "xoxb-bot",
userToken: "xoxp-user",
},
},
}),
);
const opts = downloadSlackFile.mock.calls[0]?.[1] as { token?: string } | undefined;
expect(opts?.token).toBe("xoxp-user");
});
it.each([
{
name: "JSON blocks",
blocks: JSON.stringify([
{ type: "section", text: { type: "mrkdwn", text: "*Deploy* status" } },
]),
expectedBlocks: [{ type: "section", text: { type: "mrkdwn", text: "*Deploy* status" } }],
},
{
name: "array blocks",
blocks: [{ type: "divider" }],
expectedBlocks: [{ type: "divider" }],
},
])("accepts $name and allows empty content", async ({ blocks, expectedBlocks }) => {
await handleSlackAction(
{
action: "sendMessage",
to: "channel:C123",
content: "",
blocks,
},
slackConfig(),
);
expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "", {
mediaUrl: undefined,
threadTs: undefined,
blocks: expectedBlocks,
});
});
it.each([
{
name: "invalid blocks JSON",
blocks: "{not json",
expectedError: /blocks must be valid JSON/i,
},
{ name: "empty blocks arrays", blocks: "[]", expectedError: /at least one block/i },
])("rejects $name", async ({ blocks, expectedError }) => {
await expect(
handleSlackAction(
{
action: "sendMessage",
to: "channel:C123",
content: "",
blocks,
},
slackConfig(),
),
).rejects.toThrow(expectedError);
});
it("requires at least one of content, blocks, or mediaUrl", async () => {
await expect(
handleSlackAction(
{
action: "sendMessage",
to: "channel:C123",
content: "",
},
slackConfig(),
),
).rejects.toThrow(/requires content, blocks, or mediaUrl/i);
});
it("routes uploadFile through sendSlackMessage with upload metadata", async () => {
await handleSlackAction(
{
action: "uploadFile",
to: "user:U123",
filePath: "/tmp/report.png",
initialComment: "fresh report",
filename: "report-final.png",
title: "Report Final",
threadTs: "111.222",
},
slackConfig(),
);
expect(sendSlackMessage).toHaveBeenCalledWith("user:U123", "fresh report", {
mediaUrl: "/tmp/report.png",
threadTs: "111.222",
uploadFileName: "report-final.png",
uploadTitle: "Report Final",
});
});
it("rejects blocks combined with mediaUrl", async () => {
await expect(
handleSlackAction(
{
action: "sendMessage",
to: "channel:C123",
content: "hello",
mediaUrl: "https://example.com/file.png",
blocks: JSON.stringify([{ type: "divider" }]),
},
slackConfig(),
),
).rejects.toThrow(/does not support blocks with mediaUrl/i);
});
it.each([
{
name: "JSON blocks",
blocks: JSON.stringify([{ type: "divider" }]),
expectedBlocks: [{ type: "divider" }],
},
{
name: "array blocks",
blocks: [{ type: "section", text: { type: "mrkdwn", text: "updated" } }],
expectedBlocks: [{ type: "section", text: { type: "mrkdwn", text: "updated" } }],
},
])("passes $name to editSlackMessage", async ({ blocks, expectedBlocks }) => {
await handleSlackAction(
{
action: "editMessage",
channelId: "C123",
messageId: "123.456",
content: "",
blocks,
},
slackConfig(),
);
expect(editSlackMessage).toHaveBeenCalledWith("C123", "123.456", "", {
blocks: expectedBlocks,
});
});
it("requires content or blocks for editMessage", async () => {
await expect(
handleSlackAction(
{
action: "editMessage",
channelId: "C123",
messageId: "123.456",
content: "",
},
slackConfig(),
),
).rejects.toThrow(/requires content or blocks/i);
});
it("auto-injects threadTs from context when replyToMode=all", async () => {
await handleSlackAction(
{
action: "sendMessage",
to: "channel:C123",
content: "Threaded reply",
},
slackConfig(),
{
currentChannelId: "C123",
currentThreadTs: "1111111111.111111",
replyToMode: "all",
},
);
expectLastSlackSend("Threaded reply", "1111111111.111111");
});
it("replyToMode=first threads first message then stops", async () => {
const { cfg, context } = createReplyToFirstScenario();
await handleSlackAction(
{ action: "sendMessage", to: "channel:C123", content: "First" },
cfg,
context,
);
expectLastSlackSend("First", "1111111111.111111");
await sendSecondMessageAndExpectNoThread({ cfg, context });
});
it("replyToMode=first marks hasRepliedRef even when threadTs is explicit", async () => {
const { cfg, context, hasRepliedRef } = createReplyToFirstScenario();
await handleSlackAction(
{
action: "sendMessage",
to: "channel:C123",
content: "Explicit",
threadTs: "9999999999.999999",
},
cfg,
context,
);
expectLastSlackSend("Explicit", "9999999999.999999");
expect(hasRepliedRef.value).toBe(true);
await sendSecondMessageAndExpectNoThread({ cfg, context });
});
it("replyToMode=first without hasRepliedRef does not thread", async () => {
await handleSlackAction(
{ action: "sendMessage", to: "channel:C123", content: "No ref" },
slackConfig(),
{
currentChannelId: "C123",
currentThreadTs: "1111111111.111111",
replyToMode: "first",
},
);
expectLastSlackSend("No ref");
});
it("does not auto-inject threadTs when replyToMode=off", async () => {
await handleSlackAction(
{ action: "sendMessage", to: "channel:C123", content: "No thread" },
slackConfig(),
{
currentChannelId: "C123",
currentThreadTs: "1111111111.111111",
replyToMode: "off",
},
);
expectLastSlackSend("No thread");
});
it("does not auto-inject threadTs when sending to different channel", async () => {
await handleSlackAction(
{ action: "sendMessage", to: "channel:C999", content: "Other channel" },
slackConfig(),
{
currentChannelId: "C123",
currentThreadTs: "1111111111.111111",
replyToMode: "all",
},
);
expect(sendSlackMessage).toHaveBeenCalledWith("channel:C999", "Other channel", {
mediaUrl: undefined,
threadTs: undefined,
blocks: undefined,
});
});
it("explicit threadTs overrides context threadTs", async () => {
await handleSlackAction(
{
action: "sendMessage",
to: "channel:C123",
content: "Explicit wins",
threadTs: "9999999999.999999",
},
slackConfig(),
{
currentChannelId: "C123",
currentThreadTs: "1111111111.111111",
replyToMode: "all",
},
);
expectLastSlackSend("Explicit wins", "9999999999.999999");
});
it("handles channel target without prefix when replyToMode=all", async () => {
await handleSlackAction(
{ action: "sendMessage", to: "C123", content: "Bare target" },
slackConfig(),
{
currentChannelId: "C123",
currentThreadTs: "1111111111.111111",
replyToMode: "all",
},
);
expect(sendSlackMessage).toHaveBeenCalledWith("C123", "Bare target", {
mediaUrl: undefined,
threadTs: "1111111111.111111",
blocks: undefined,
});
});
it("adds normalized timestamps to readMessages payloads", async () => {
readSlackMessages.mockResolvedValueOnce({
messages: [{ ts: "1712345678.123456", text: "hi" }],
hasMore: false,
});
const result = await handleSlackAction(
{ action: "readMessages", channelId: "C1" },
slackConfig(),
);
expect(result).toMatchObject({
details: {
ok: true,
hasMore: false,
messages: [
expect.objectContaining({
ts: "1712345678.123456",
timestampMs: 1712345678123,
}),
],
},
});
});
it("passes threadId through to readSlackMessages", async () => {
readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false });
await handleSlackAction(
{ action: "readMessages", channelId: "C1", threadId: "1712345678.123456" },
slackConfig(),
);
expect(readSlackMessages).toHaveBeenCalledWith("C1", {
threadId: "1712345678.123456",
limit: undefined,
before: undefined,
after: undefined,
});
});
it("adds normalized timestamps to pin payloads", async () => {
listSlackPins.mockResolvedValueOnce([{ message: { ts: "1712345678.123456", text: "pin" } }]);
const result = await handleSlackAction({ action: "listPins", channelId: "C1" }, slackConfig());
expect(result).toMatchObject({
details: {
ok: true,
pins: [
{
message: expect.objectContaining({
ts: "1712345678.123456",
timestampMs: 1712345678123,
}),
},
],
},
});
});
it("uses user token for reads when available", async () => {
const token = await resolveReadToken(
slackConfig({
accounts: {
default: {
botToken: "xoxb-bot",
userToken: "xoxp-user",
},
},
}),
);
expect(token).toBe("xoxp-user");
});
it("falls back to bot token for reads when user token missing", async () => {
const token = await resolveReadToken(
slackConfig({
accounts: {
default: {
botToken: "xoxb-bot",
},
},
}),
);
expect(token).toBeUndefined();
});
it("uses bot token for writes when userTokenReadOnly is true", async () => {
const token = await resolveSendToken(
slackConfig({
accounts: {
default: {
botToken: "xoxb-bot",
userToken: "xoxp-user",
userTokenReadOnly: true,
},
},
}),
);
expect(token).toBeUndefined();
});
it("allows user token writes when bot token is missing", async () => {
const token = await resolveSendToken({
channels: {
slack: {
accounts: {
default: {
userToken: "xoxp-user",
userTokenReadOnly: false,
},
},
},
},
} as OpenClawConfig);
expect(token).toBe("xoxp-user");
});
it("returns all emojis when no limit is provided", async () => {
listSlackEmojis.mockResolvedValueOnce({
ok: true,
emoji: { party: "https://example.com/party.png", wave: "https://example.com/wave.png" },
});
const result = await handleSlackAction({ action: "emojiList" }, slackConfig());
expect(result).toMatchObject({
details: {
ok: true,
emojis: {
emoji: { party: "https://example.com/party.png", wave: "https://example.com/wave.png" },
},
},
});
});
it("applies limit to emoji-list results", async () => {
listSlackEmojis.mockResolvedValueOnce({
ok: true,
emoji: {
wave: "https://example.com/wave.png",
party: "https://example.com/party.png",
tada: "https://example.com/tada.png",
},
});
const result = await handleSlackAction({ action: "emojiList", limit: 2 }, slackConfig());
expect(result).toMatchObject({
details: {
ok: true,
emojis: {
emoji: {
party: "https://example.com/party.png",
tada: "https://example.com/tada.png",
},
},
},
});
});
});

View file

@ -0,0 +1,489 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference";
import { parseSlackBlocksInput } from "./blocks-input.js";
import {
createActionGate,
imageResultFromFile,
jsonResult,
readNumberParam,
readReactionParams,
readStringParam,
type OpenClawConfig,
withNormalizedTimestamp,
} from "./runtime-api.js";
import { recordSlackThreadParticipation } from "./sent-thread-cache.js";
import { parseSlackTarget, resolveSlackChannelId } from "./targets.js";
const messagingActions = new Set([
"sendMessage",
"uploadFile",
"editMessage",
"deleteMessage",
"readMessages",
"downloadFile",
]);
const reactionsActions = new Set(["react", "reactions"]);
const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]);
type SlackActionsRuntimeModule = typeof import("./actions.runtime.js");
type SlackAccountsRuntimeModule = typeof import("./accounts.runtime.js");
let slackActionsRuntimePromise: Promise<SlackActionsRuntimeModule> | undefined;
let slackAccountsRuntimePromise: Promise<SlackAccountsRuntimeModule> | undefined;
function loadSlackActionsRuntime(): Promise<SlackActionsRuntimeModule> {
slackActionsRuntimePromise ??= import("./actions.runtime.js");
return slackActionsRuntimePromise;
}
function loadSlackAccountsRuntime(): Promise<SlackAccountsRuntimeModule> {
slackAccountsRuntimePromise ??= import("./accounts.runtime.js");
return slackAccountsRuntimePromise;
}
function createLazySlackAction<K extends keyof SlackActionsRuntimeModule>(
key: K,
): SlackActionsRuntimeModule[K] {
return (async (...args: unknown[]) => {
const runtime = await loadSlackActionsRuntime();
const action = runtime[key] as (...actionArgs: unknown[]) => unknown;
return action(...args);
}) as SlackActionsRuntimeModule[K];
}
export const slackActionRuntime = {
deleteSlackMessage: createLazySlackAction("deleteSlackMessage"),
downloadSlackFile: createLazySlackAction("downloadSlackFile"),
editSlackMessage: createLazySlackAction("editSlackMessage"),
getSlackMemberInfo: createLazySlackAction("getSlackMemberInfo"),
listSlackEmojis: createLazySlackAction("listSlackEmojis"),
listSlackPins: createLazySlackAction("listSlackPins"),
listSlackReactions: createLazySlackAction("listSlackReactions"),
parseSlackBlocksInput,
pinSlackMessage: createLazySlackAction("pinSlackMessage"),
reactSlackMessage: createLazySlackAction("reactSlackMessage"),
readSlackMessages: createLazySlackAction("readSlackMessages"),
recordSlackThreadParticipation,
removeOwnSlackReactions: createLazySlackAction("removeOwnSlackReactions"),
removeSlackReaction: createLazySlackAction("removeSlackReaction"),
sendSlackMessage: createLazySlackAction("sendSlackMessage"),
unpinSlackMessage: createLazySlackAction("unpinSlackMessage"),
};
export type SlackActionContext = {
/** Current channel ID for auto-threading. */
currentChannelId?: string;
/** Current thread timestamp for auto-threading. */
currentThreadTs?: string;
/** Reply-to mode for auto-threading. */
replyToMode?: "off" | "first" | "all" | "batched";
/** Mutable ref to track if a reply was sent for single-use reply modes. */
hasRepliedRef?: { value: boolean };
/** Allowed local media directories for file uploads. */
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
};
/**
* Resolve threadTs for a Slack message based on context and replyToMode.
* - "all": always inject threadTs
* - "first"/"batched": inject only for the first eligible message (updates hasRepliedRef)
* - "off": never auto-inject
*/
function resolveThreadTsFromContext(
explicitThreadTs: string | undefined,
targetChannel: string,
context: SlackActionContext | undefined,
): string | undefined {
// Agent explicitly provided threadTs - use it
if (explicitThreadTs) {
return explicitThreadTs;
}
// No context or missing required fields
if (!context?.currentThreadTs || !context?.currentChannelId) {
return undefined;
}
const parsedTarget = parseSlackTarget(targetChannel, {
defaultKind: "channel",
});
if (!parsedTarget || parsedTarget.kind !== "channel") {
return undefined;
}
const normalizedTarget = parsedTarget.id;
// Different channel - don't inject
if (normalizedTarget !== context.currentChannelId) {
return undefined;
}
// Check replyToMode
if (context.replyToMode === "all") {
return context.currentThreadTs;
}
if (
isSingleUseReplyToMode(context.replyToMode ?? "off") &&
context.hasRepliedRef &&
!context.hasRepliedRef.value
) {
context.hasRepliedRef.value = true;
return context.currentThreadTs;
}
return undefined;
}
function readSlackBlocksParam(params: Record<string, unknown>) {
return slackActionRuntime.parseSlackBlocksInput(params.blocks);
}
export async function handleSlackAction(
params: Record<string, unknown>,
cfg: OpenClawConfig,
context?: SlackActionContext,
): Promise<AgentToolResult<unknown>> {
const resolveChannelId = () =>
resolveSlackChannelId(
readStringParam(params, "channelId", {
required: true,
}),
);
const action = readStringParam(params, "action", { required: true });
const accountId = readStringParam(params, "accountId");
const { resolveSlackAccount } = await loadSlackAccountsRuntime();
const account = resolveSlackAccount({ cfg, accountId });
const actionConfig = account.actions ?? cfg.channels?.slack?.actions;
const isActionEnabled = createActionGate(actionConfig);
const userToken = account.userToken;
const botToken = account.botToken?.trim();
const allowUserWrites = account.config.userTokenReadOnly === false;
// Choose the most appropriate token for Slack read/write operations.
const getTokenForOperation = (operation: "read" | "write") => {
if (operation === "read") {
return userToken ?? botToken;
}
if (!allowUserWrites) {
return botToken;
}
return botToken ?? userToken;
};
const buildActionOpts = (operation: "read" | "write") => {
const token = getTokenForOperation(operation);
const tokenOverride = token && token !== botToken ? token : undefined;
if (!accountId && !tokenOverride) {
return undefined;
}
return {
...(accountId ? { accountId } : {}),
...(tokenOverride ? { token: tokenOverride } : {}),
};
};
const readOpts = buildActionOpts("read");
const writeOpts = buildActionOpts("write");
if (reactionsActions.has(action)) {
if (!isActionEnabled("reactions")) {
throw new Error("Slack reactions are disabled.");
}
const channelId = resolveChannelId();
const messageId = readStringParam(params, "messageId", { required: true });
if (action === "react") {
const { emoji, remove, isEmpty } = readReactionParams(params, {
removeErrorMessage: "Emoji is required to remove a Slack reaction.",
});
if (remove) {
if (writeOpts) {
await slackActionRuntime.removeSlackReaction(channelId, messageId, emoji, writeOpts);
} else {
await slackActionRuntime.removeSlackReaction(channelId, messageId, emoji);
}
return jsonResult({ ok: true, removed: emoji });
}
if (isEmpty) {
const removed = writeOpts
? await slackActionRuntime.removeOwnSlackReactions(channelId, messageId, writeOpts)
: await slackActionRuntime.removeOwnSlackReactions(channelId, messageId);
return jsonResult({ ok: true, removed });
}
if (writeOpts) {
await slackActionRuntime.reactSlackMessage(channelId, messageId, emoji, writeOpts);
} else {
await slackActionRuntime.reactSlackMessage(channelId, messageId, emoji);
}
return jsonResult({ ok: true, added: emoji });
}
const reactions = readOpts
? await slackActionRuntime.listSlackReactions(channelId, messageId, readOpts)
: await slackActionRuntime.listSlackReactions(channelId, messageId);
return jsonResult({ ok: true, reactions });
}
if (messagingActions.has(action)) {
if (!isActionEnabled("messages")) {
throw new Error("Slack messages are disabled.");
}
switch (action) {
case "sendMessage": {
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "content", {
allowEmpty: true,
});
const mediaUrl = readStringParam(params, "mediaUrl");
const blocks = readSlackBlocksParam(params);
if (!content && !mediaUrl && !blocks) {
throw new Error("Slack sendMessage requires content, blocks, or mediaUrl.");
}
if (mediaUrl && blocks) {
throw new Error("Slack sendMessage does not support blocks with mediaUrl.");
}
const threadTs = resolveThreadTsFromContext(
readStringParam(params, "threadTs"),
to,
context,
);
const result = await slackActionRuntime.sendSlackMessage(to, content ?? "", {
...writeOpts,
mediaUrl: mediaUrl ?? undefined,
mediaLocalRoots: context?.mediaLocalRoots,
mediaReadFile: context?.mediaReadFile,
threadTs: threadTs ?? undefined,
blocks,
});
if (threadTs && result.channelId && account.accountId) {
slackActionRuntime.recordSlackThreadParticipation(
account.accountId,
result.channelId,
threadTs,
);
}
// Keep "first" mode consistent even when the agent explicitly provided
// threadTs: once we send a message to the current channel, consider the
// first reply "used" so later tool calls don't auto-thread again.
if (context?.hasRepliedRef && context.currentChannelId) {
const parsedTarget = parseSlackTarget(to, { defaultKind: "channel" });
if (parsedTarget?.kind === "channel" && parsedTarget.id === context.currentChannelId) {
context.hasRepliedRef.value = true;
}
}
return jsonResult({ ok: true, result });
}
case "uploadFile": {
const to = readStringParam(params, "to", { required: true });
const filePath = readStringParam(params, "filePath", {
required: true,
trim: false,
});
const initialComment = readStringParam(params, "initialComment", {
allowEmpty: true,
});
const filename = readStringParam(params, "filename");
const title = readStringParam(params, "title");
const threadTs = resolveThreadTsFromContext(
readStringParam(params, "threadTs"),
to,
context,
);
const result = await slackActionRuntime.sendSlackMessage(to, initialComment ?? "", {
...writeOpts,
mediaUrl: filePath,
mediaLocalRoots: context?.mediaLocalRoots,
mediaReadFile: context?.mediaReadFile,
threadTs: threadTs ?? undefined,
...(filename ? { uploadFileName: filename } : {}),
...(title ? { uploadTitle: title } : {}),
});
if (threadTs && result.channelId && account.accountId) {
slackActionRuntime.recordSlackThreadParticipation(
account.accountId,
result.channelId,
threadTs,
);
}
if (context?.hasRepliedRef && context.currentChannelId) {
const parsedTarget = parseSlackTarget(to, { defaultKind: "channel" });
if (parsedTarget?.kind === "channel" && parsedTarget.id === context.currentChannelId) {
context.hasRepliedRef.value = true;
}
}
return jsonResult({ ok: true, result });
}
case "editMessage": {
const channelId = resolveChannelId();
const messageId = readStringParam(params, "messageId", {
required: true,
});
const content = readStringParam(params, "content", {
allowEmpty: true,
});
const blocks = readSlackBlocksParam(params);
if (!content && !blocks) {
throw new Error("Slack editMessage requires content or blocks.");
}
if (writeOpts) {
await slackActionRuntime.editSlackMessage(channelId, messageId, content ?? "", {
...writeOpts,
blocks,
});
} else {
await slackActionRuntime.editSlackMessage(channelId, messageId, content ?? "", {
blocks,
});
}
return jsonResult({ ok: true });
}
case "deleteMessage": {
const channelId = resolveChannelId();
const messageId = readStringParam(params, "messageId", {
required: true,
});
if (writeOpts) {
await slackActionRuntime.deleteSlackMessage(channelId, messageId, writeOpts);
} else {
await slackActionRuntime.deleteSlackMessage(channelId, messageId);
}
return jsonResult({ ok: true });
}
case "readMessages": {
const channelId = resolveChannelId();
const limitRaw = params.limit;
const limit =
typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined;
const before = readStringParam(params, "before");
const after = readStringParam(params, "after");
const threadId = readStringParam(params, "threadId");
const result = await slackActionRuntime.readSlackMessages(channelId, {
...readOpts,
limit,
before: before ?? undefined,
after: after ?? undefined,
threadId: threadId ?? undefined,
});
const messages = result.messages.map((message) =>
withNormalizedTimestamp(
message as Record<string, unknown>,
(message as { ts?: unknown }).ts,
),
);
return jsonResult({ ok: true, messages, hasMore: result.hasMore });
}
case "downloadFile": {
const fileId = readStringParam(params, "fileId", { required: true });
const channelTarget = readStringParam(params, "channelId") ?? readStringParam(params, "to");
const channelId = channelTarget ? resolveSlackChannelId(channelTarget) : undefined;
const threadId = readStringParam(params, "threadId") ?? readStringParam(params, "replyTo");
const maxBytes = account.config?.mediaMaxMb
? account.config.mediaMaxMb * 1024 * 1024
: 20 * 1024 * 1024;
const readToken = getTokenForOperation("read");
const downloaded = await slackActionRuntime.downloadSlackFile(fileId, {
...readOpts,
...(readToken && !readOpts?.token ? { token: readToken } : {}),
maxBytes,
channelId,
threadId: threadId ?? undefined,
});
if (!downloaded) {
return jsonResult({
ok: false,
error: "File could not be downloaded (not found, too large, or inaccessible).",
});
}
return await imageResultFromFile({
label: "slack-file",
path: downloaded.path,
extraText: downloaded.placeholder,
details: { fileId, path: downloaded.path },
});
}
default:
break;
}
}
if (pinActions.has(action)) {
if (!isActionEnabled("pins")) {
throw new Error("Slack pins are disabled.");
}
const channelId = resolveChannelId();
if (action === "pinMessage") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
if (writeOpts) {
await slackActionRuntime.pinSlackMessage(channelId, messageId, writeOpts);
} else {
await slackActionRuntime.pinSlackMessage(channelId, messageId);
}
return jsonResult({ ok: true });
}
if (action === "unpinMessage") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
if (writeOpts) {
await slackActionRuntime.unpinSlackMessage(channelId, messageId, writeOpts);
} else {
await slackActionRuntime.unpinSlackMessage(channelId, messageId);
}
return jsonResult({ ok: true });
}
const pins = writeOpts
? await slackActionRuntime.listSlackPins(channelId, readOpts)
: await slackActionRuntime.listSlackPins(channelId);
const normalizedPins = pins.map((pin) => {
const message = pin.message
? withNormalizedTimestamp(
pin.message as Record<string, unknown>,
(pin.message as { ts?: unknown }).ts,
)
: pin.message;
return message ? Object.assign({}, pin, { message }) : pin;
});
return jsonResult({ ok: true, pins: normalizedPins });
}
if (action === "memberInfo") {
if (!isActionEnabled("memberInfo")) {
throw new Error("Slack member info is disabled.");
}
const userId = readStringParam(params, "userId", { required: true });
const info = writeOpts
? await slackActionRuntime.getSlackMemberInfo(userId, readOpts)
: await slackActionRuntime.getSlackMemberInfo(userId);
return jsonResult({ ok: true, info });
}
if (action === "emojiList") {
if (!isActionEnabled("emojiList")) {
throw new Error("Slack emoji list is disabled.");
}
const result = readOpts
? await slackActionRuntime.listSlackEmojis(readOpts)
: await slackActionRuntime.listSlackEmojis();
const limit = readNumberParam(params, "limit", { integer: true });
if (limit != null && limit > 0 && result.emoji != null) {
const entries = Object.entries(result.emoji).toSorted(([a], [b]) => a.localeCompare(b));
if (entries.length > limit) {
return jsonResult({
ok: true,
emojis: {
...result,
emoji: Object.fromEntries(entries.slice(0, limit)),
},
});
}
}
return jsonResult({ ok: true, emojis: result });
}
throw new Error(`Unknown action: ${action}`);
}

View file

@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { resolveSlackAutoThreadId } from "./action-threading.js";
type SlackThreadingToolContext = {
currentChannelId?: string;
currentThreadTs?: string;
replyToMode?: "off" | "first" | "all" | "batched";
hasRepliedRef?: { value: boolean };
};
function createToolContext(
overrides: Partial<SlackThreadingToolContext> = {},
): SlackThreadingToolContext {
return {
currentChannelId: "C123",
currentThreadTs: "thread-1",
replyToMode: "all",
...overrides,
};
}
describe("resolveSlackAutoThreadId", () => {
it("uses the active thread only for matching channel targets", () => {
expect(
resolveSlackAutoThreadId({
to: "#c123",
toolContext: createToolContext(),
}),
).toBe("thread-1");
expect(
resolveSlackAutoThreadId({
to: "channel:C999",
toolContext: createToolContext(),
}),
).toBeUndefined();
expect(
resolveSlackAutoThreadId({
to: "user:U123",
toolContext: createToolContext(),
}),
).toBeUndefined();
});
it("skips auto-threading when reply mode or thread context blocks it", () => {
expect(
resolveSlackAutoThreadId({
to: "C123",
toolContext: createToolContext({
replyToMode: "first",
hasRepliedRef: { value: true },
}),
}),
).toBeUndefined();
expect(
resolveSlackAutoThreadId({
to: "C123",
toolContext: createToolContext({ replyToMode: "off" }),
}),
).toBeUndefined();
expect(
resolveSlackAutoThreadId({
to: "C123",
toolContext: createToolContext({ currentThreadTs: undefined }),
}),
).toBeUndefined();
});
});

View file

@ -0,0 +1,35 @@
import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { parseSlackTarget } from "./targets.js";
export function resolveSlackAutoThreadId(params: {
to: string;
toolContext?: {
currentChannelId?: string;
currentThreadTs?: string;
replyToMode?: "off" | "first" | "all" | "batched";
hasRepliedRef?: { value: boolean };
};
}): string | undefined {
const context = params.toolContext;
if (!context?.currentThreadTs || !context.currentChannelId) {
return undefined;
}
if (context.replyToMode !== "all" && !isSingleUseReplyToMode(context.replyToMode ?? "off")) {
return undefined;
}
const parsedTarget = parseSlackTarget(params.to, { defaultKind: "channel" });
if (!parsedTarget || parsedTarget.kind !== "channel") {
return undefined;
}
if (
normalizeLowercaseStringOrEmpty(parsedTarget.id) !==
normalizeLowercaseStringOrEmpty(context.currentChannelId)
) {
return undefined;
}
if (isSingleUseReplyToMode(context.replyToMode ?? "off") && context.hasRepliedRef?.value) {
return undefined;
}
return context.currentThreadTs;
}

View file

@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import { createSlackEditTestClient, installSlackBlockTestMocks } from "./blocks.test-helpers.js";
installSlackBlockTestMocks();
const { editSlackMessage } = await import("./actions.js");
describe("editSlackMessage blocks", () => {
it("updates with valid blocks", async () => {
const client = createSlackEditTestClient();
await editSlackMessage("C123", "171234.567", "", {
token: "xoxb-test",
client,
blocks: [{ type: "divider" }],
});
expect(client.chat.update).toHaveBeenCalledWith(
expect.objectContaining({
channel: "C123",
ts: "171234.567",
text: "Shared a Block Kit message",
blocks: [{ type: "divider" }],
}),
);
});
it("uses image block text as edit fallback", async () => {
const client = createSlackEditTestClient();
await editSlackMessage("C123", "171234.567", "", {
token: "xoxb-test",
client,
blocks: [{ type: "image", image_url: "https://example.com/a.png", alt_text: "Chart" }],
});
expect(client.chat.update).toHaveBeenCalledWith(
expect.objectContaining({
text: "Chart",
}),
);
});
it("uses video block title as edit fallback", async () => {
const client = createSlackEditTestClient();
await editSlackMessage("C123", "171234.567", "", {
token: "xoxb-test",
client,
blocks: [
{
type: "video",
title: { type: "plain_text", text: "Walkthrough" },
video_url: "https://example.com/demo.mp4",
thumbnail_url: "https://example.com/thumb.jpg",
alt_text: "demo",
},
],
});
expect(client.chat.update).toHaveBeenCalledWith(
expect.objectContaining({
text: "Walkthrough",
}),
);
});
it("uses generic file fallback text for file blocks", async () => {
const client = createSlackEditTestClient();
await editSlackMessage("C123", "171234.567", "", {
token: "xoxb-test",
client,
blocks: [{ type: "file", source: "remote", external_id: "F123" }],
});
expect(client.chat.update).toHaveBeenCalledWith(
expect.objectContaining({
text: "Shared a file",
}),
);
});
it("rejects empty blocks arrays", async () => {
const client = createSlackEditTestClient();
await expect(
editSlackMessage("C123", "171234.567", "updated", {
token: "xoxb-test",
client,
blocks: [],
}),
).rejects.toThrow(/must contain at least one block/i);
expect(client.chat.update).not.toHaveBeenCalled();
});
it("rejects blocks missing a type", async () => {
const client = createSlackEditTestClient();
await expect(
editSlackMessage("C123", "171234.567", "updated", {
token: "xoxb-test",
client,
blocks: [{} as { type: string }],
}),
).rejects.toThrow(/non-empty string type/i);
expect(client.chat.update).not.toHaveBeenCalled();
});
it("rejects blocks arrays above Slack max count", async () => {
const client = createSlackEditTestClient();
const blocks = Array.from({ length: 51 }, () => ({ type: "divider" }));
await expect(
editSlackMessage("C123", "171234.567", "updated", {
token: "xoxb-test",
client,
blocks,
}),
).rejects.toThrow(/cannot exceed 50 items/i);
expect(client.chat.update).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,168 @@
import type { WebClient } from "@slack/web-api";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const resolveSlackMedia = vi.fn();
vi.mock("./monitor/media.js", () => ({
resolveSlackMedia: (...args: Parameters<typeof resolveSlackMedia>) => resolveSlackMedia(...args),
}));
let downloadSlackFile: typeof import("./actions.js").downloadSlackFile;
function createClient() {
return {
files: {
info: vi.fn(async () => ({ file: {} })),
},
} as unknown as WebClient & {
files: {
info: ReturnType<typeof vi.fn>;
};
};
}
function makeSlackFileInfo(overrides?: Record<string, unknown>) {
return {
id: "F123",
name: "image.png",
mimetype: "image/png",
url_private_download: "https://files.slack.com/files-pri/T1-F123/image.png",
...overrides,
};
}
function makeResolvedSlackMedia() {
return {
path: "/tmp/image.png",
contentType: "image/png",
placeholder: "[Slack file: image.png]",
};
}
function expectNoMediaDownload(result: Awaited<ReturnType<typeof downloadSlackFile>>) {
expect(result).toBeNull();
expect(resolveSlackMedia).not.toHaveBeenCalled();
}
function expectResolveSlackMediaCalledWithDefaults() {
expect(resolveSlackMedia).toHaveBeenCalledWith({
files: [
{
id: "F123",
name: "image.png",
mimetype: "image/png",
url_private: undefined,
url_private_download: "https://files.slack.com/files-pri/T1-F123/image.png",
},
],
token: "xoxb-test",
maxBytes: 1024,
});
}
function mockSuccessfulMediaDownload(client: ReturnType<typeof createClient>) {
client.files.info.mockResolvedValueOnce({
file: makeSlackFileInfo(),
});
resolveSlackMedia.mockResolvedValueOnce([makeResolvedSlackMedia()]);
}
describe("downloadSlackFile", () => {
beforeAll(async () => {
({ downloadSlackFile } = await import("./actions.js"));
});
beforeEach(() => {
resolveSlackMedia.mockReset();
});
it("returns null when files.info has no private download URL", async () => {
const client = createClient();
client.files.info.mockResolvedValueOnce({
file: {
id: "F123",
name: "image.png",
},
});
const result = await downloadSlackFile("F123", {
client,
token: "xoxb-test",
maxBytes: 1024,
});
expect(result).toBeNull();
expect(resolveSlackMedia).not.toHaveBeenCalled();
});
it("downloads via resolveSlackMedia using fresh files.info metadata", async () => {
const client = createClient();
mockSuccessfulMediaDownload(client);
const result = await downloadSlackFile("F123", {
client,
token: "xoxb-test",
maxBytes: 1024,
});
expect(client.files.info).toHaveBeenCalledWith({ file: "F123" });
expectResolveSlackMediaCalledWithDefaults();
expect(result).toEqual(makeResolvedSlackMedia());
});
it("returns null when channel scope definitely mismatches file shares", async () => {
const client = createClient();
client.files.info.mockResolvedValueOnce({
file: makeSlackFileInfo({ channels: ["C999"] }),
});
const result = await downloadSlackFile("F123", {
client,
token: "xoxb-test",
maxBytes: 1024,
channelId: "C123",
});
expectNoMediaDownload(result);
});
it("returns null when thread scope definitely mismatches file share thread", async () => {
const client = createClient();
client.files.info.mockResolvedValueOnce({
file: makeSlackFileInfo({
shares: {
private: {
C123: [{ ts: "111.111", thread_ts: "111.111" }],
},
},
}),
});
const result = await downloadSlackFile("F123", {
client,
token: "xoxb-test",
maxBytes: 1024,
channelId: "C123",
threadId: "222.222",
});
expectNoMediaDownload(result);
});
it("keeps legacy behavior when file metadata does not expose channel/thread shares", async () => {
const client = createClient();
mockSuccessfulMediaDownload(client);
const result = await downloadSlackFile("F123", {
client,
token: "xoxb-test",
maxBytes: 1024,
channelId: "C123",
threadId: "222.222",
});
expect(result).toEqual(makeResolvedSlackMedia());
expect(resolveSlackMedia).toHaveBeenCalledTimes(1);
expectResolveSlackMediaCalledWithDefaults();
});
});

View file

@ -0,0 +1,66 @@
import type { WebClient } from "@slack/web-api";
import { describe, expect, it, vi } from "vitest";
import { readSlackMessages } from "./actions.js";
function createClient() {
return {
conversations: {
replies: vi.fn(async () => ({ messages: [], has_more: false })),
history: vi.fn(async () => ({ messages: [], has_more: false })),
},
} as unknown as WebClient & {
conversations: {
replies: ReturnType<typeof vi.fn>;
history: ReturnType<typeof vi.fn>;
};
};
}
describe("readSlackMessages", () => {
it("uses conversations.replies and drops the parent message", async () => {
const client = createClient();
client.conversations.replies.mockResolvedValueOnce({
messages: [{ ts: "171234.567" }, { ts: "171234.890" }, { ts: "171235.000" }],
has_more: true,
});
const result = await readSlackMessages("C1", {
client,
threadId: "171234.567",
token: "xoxb-test",
});
expect(client.conversations.replies).toHaveBeenCalledWith({
channel: "C1",
ts: "171234.567",
limit: undefined,
latest: undefined,
oldest: undefined,
});
expect(client.conversations.history).not.toHaveBeenCalled();
expect(result.messages.map((message) => message.ts)).toEqual(["171234.890", "171235.000"]);
});
it("uses conversations.history when threadId is missing", async () => {
const client = createClient();
client.conversations.history.mockResolvedValueOnce({
messages: [{ ts: "1" }],
has_more: false,
});
const result = await readSlackMessages("C1", {
client,
limit: 20,
token: "xoxb-test",
});
expect(client.conversations.history).toHaveBeenCalledWith({
channel: "C1",
limit: 20,
latest: undefined,
oldest: undefined,
});
expect(client.conversations.replies).not.toHaveBeenCalled();
expect(result.messages.map((message) => message.ts)).toEqual(["1"]);
});
});

View file

@ -0,0 +1,16 @@
export {
deleteSlackMessage,
downloadSlackFile,
editSlackMessage,
getSlackMemberInfo,
listSlackEmojis,
listSlackPins,
listSlackReactions,
pinSlackMessage,
reactSlackMessage,
readSlackMessages,
removeOwnSlackReactions,
removeSlackReaction,
sendSlackMessage,
unpinSlackMessage,
} from "./actions.js";

View file

@ -0,0 +1,460 @@
import type { Block, KnownBlock, WebClient } from "@slack/web-api";
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveSlackAccount } from "./accounts.js";
import { buildSlackBlocksFallbackText } from "./blocks-fallback.js";
import { validateSlackBlocksArray } from "./blocks-input.js";
import { createSlackWebClient, createSlackWriteClient } from "./client.js";
import { resolveSlackMedia } from "./monitor/media.js";
import type { SlackMediaResult } from "./monitor/media.js";
import { sendMessageSlack } from "./send.js";
import { resolveSlackBotToken } from "./token.js";
export type SlackActionClientOpts = {
accountId?: string;
token?: string;
client?: WebClient;
};
export type SlackMessageSummary = {
ts?: string;
text?: string;
user?: string;
thread_ts?: string;
reply_count?: number;
reactions?: Array<{
name?: string;
count?: number;
users?: string[];
}>;
/** File attachments on this message. Present when the message has files. */
files?: Array<{
id?: string;
name?: string;
mimetype?: string;
}>;
};
export type SlackPin = {
type?: string;
message?: { ts?: string; text?: string };
file?: { id?: string; name?: string };
};
function resolveToken(explicit?: string, accountId?: string) {
const cfg = loadConfig();
const account = resolveSlackAccount({ cfg, accountId });
const token = resolveSlackBotToken(explicit ?? account.botToken ?? undefined);
if (!token) {
logVerbose(
`slack actions: missing bot token for account=${account.accountId} explicit=${Boolean(
explicit,
)} source=${account.botTokenSource ?? "unknown"}`,
);
throw new Error("SLACK_BOT_TOKEN or channels.slack.botToken is required for Slack actions");
}
return token;
}
function normalizeEmoji(raw: string) {
const trimmed = raw.trim();
if (!trimmed) {
throw new Error("Emoji is required for Slack reactions");
}
return trimmed.replace(/^:+|:+$/g, "");
}
async function getClient(opts: SlackActionClientOpts = {}, mode: "read" | "write" = "read") {
if (opts.client) {
return opts.client;
}
const token = resolveToken(opts.token, opts.accountId);
return mode === "write" ? createSlackWriteClient(token) : createSlackWebClient(token);
}
async function resolveBotUserId(client: WebClient) {
const auth = await client.auth.test();
if (!auth?.user_id) {
throw new Error("Failed to resolve Slack bot user id");
}
return auth.user_id;
}
export async function reactSlackMessage(
channelId: string,
messageId: string,
emoji: string,
opts: SlackActionClientOpts = {},
) {
const client = await getClient(opts, "write");
await client.reactions.add({
channel: channelId,
timestamp: messageId,
name: normalizeEmoji(emoji),
});
}
export async function removeSlackReaction(
channelId: string,
messageId: string,
emoji: string,
opts: SlackActionClientOpts = {},
) {
const client = await getClient(opts, "write");
await client.reactions.remove({
channel: channelId,
timestamp: messageId,
name: normalizeEmoji(emoji),
});
}
export async function removeOwnSlackReactions(
channelId: string,
messageId: string,
opts: SlackActionClientOpts = {},
): Promise<string[]> {
const client = await getClient(opts, "write");
const userId = await resolveBotUserId(client);
const reactions = await listSlackReactions(channelId, messageId, { client });
const toRemove = new Set<string>();
for (const reaction of reactions ?? []) {
const name = reaction?.name;
if (!name) {
continue;
}
const users = reaction?.users ?? [];
if (users.includes(userId)) {
toRemove.add(name);
}
}
if (toRemove.size === 0) {
return [];
}
await Promise.all(
Array.from(toRemove, (name) =>
client.reactions.remove({
channel: channelId,
timestamp: messageId,
name,
}),
),
);
return Array.from(toRemove);
}
export async function listSlackReactions(
channelId: string,
messageId: string,
opts: SlackActionClientOpts = {},
): Promise<SlackMessageSummary["reactions"]> {
const client = await getClient(opts);
const result = await client.reactions.get({
channel: channelId,
timestamp: messageId,
full: true,
});
const message = result.message as SlackMessageSummary | undefined;
return message?.reactions ?? [];
}
export async function sendSlackMessage(
to: string,
content: string,
opts: SlackActionClientOpts & {
mediaUrl?: string;
mediaAccess?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
};
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
threadTs?: string;
uploadFileName?: string;
uploadTitle?: string;
blocks?: (Block | KnownBlock)[];
} = {},
) {
return await sendMessageSlack(to, content, {
accountId: opts.accountId,
token: opts.token,
mediaUrl: opts.mediaUrl,
mediaAccess: opts.mediaAccess,
mediaLocalRoots: opts.mediaLocalRoots,
mediaReadFile: opts.mediaReadFile,
client: opts.client,
threadTs: opts.threadTs,
...(opts.uploadFileName ? { uploadFileName: opts.uploadFileName } : {}),
...(opts.uploadTitle ? { uploadTitle: opts.uploadTitle } : {}),
blocks: opts.blocks,
});
}
export async function editSlackMessage(
channelId: string,
messageId: string,
content: string,
opts: SlackActionClientOpts & { blocks?: (Block | KnownBlock)[] } = {},
) {
const client = await getClient(opts, "write");
const blocks = opts.blocks == null ? undefined : validateSlackBlocksArray(opts.blocks);
const trimmedContent = content.trim();
await client.chat.update({
channel: channelId,
ts: messageId,
text: trimmedContent || (blocks ? buildSlackBlocksFallbackText(blocks) : " "),
...(blocks ? { blocks } : {}),
});
}
export async function deleteSlackMessage(
channelId: string,
messageId: string,
opts: SlackActionClientOpts = {},
) {
const client = await getClient(opts, "write");
await client.chat.delete({
channel: channelId,
ts: messageId,
});
}
export async function readSlackMessages(
channelId: string,
opts: SlackActionClientOpts & {
limit?: number;
before?: string;
after?: string;
threadId?: string;
} = {},
): Promise<{ messages: SlackMessageSummary[]; hasMore: boolean }> {
const client = await getClient(opts);
// Use conversations.replies for thread messages, conversations.history for channel messages.
if (opts.threadId) {
const result = await client.conversations.replies({
channel: channelId,
ts: opts.threadId,
limit: opts.limit,
latest: opts.before,
oldest: opts.after,
});
return {
// conversations.replies includes the parent message; drop it for replies-only reads.
messages: (result.messages ?? []).filter(
(message) => (message as SlackMessageSummary)?.ts !== opts.threadId,
) as SlackMessageSummary[],
hasMore: Boolean(result.has_more),
};
}
const result = await client.conversations.history({
channel: channelId,
limit: opts.limit,
latest: opts.before,
oldest: opts.after,
});
return {
messages: (result.messages ?? []) as SlackMessageSummary[],
hasMore: Boolean(result.has_more),
};
}
export async function getSlackMemberInfo(userId: string, opts: SlackActionClientOpts = {}) {
const client = await getClient(opts);
return await client.users.info({ user: userId });
}
export async function listSlackEmojis(opts: SlackActionClientOpts = {}) {
const client = await getClient(opts);
return await client.emoji.list();
}
export async function pinSlackMessage(
channelId: string,
messageId: string,
opts: SlackActionClientOpts = {},
) {
const client = await getClient(opts, "write");
await client.pins.add({ channel: channelId, timestamp: messageId });
}
export async function unpinSlackMessage(
channelId: string,
messageId: string,
opts: SlackActionClientOpts = {},
) {
const client = await getClient(opts, "write");
await client.pins.remove({ channel: channelId, timestamp: messageId });
}
export async function listSlackPins(
channelId: string,
opts: SlackActionClientOpts = {},
): Promise<SlackPin[]> {
const client = await getClient(opts);
const result = await client.pins.list({ channel: channelId });
return (result.items ?? []) as SlackPin[];
}
type SlackFileInfoSummary = {
id?: string;
name?: string;
mimetype?: string;
url_private?: string;
url_private_download?: string;
channels?: unknown;
groups?: unknown;
ims?: unknown;
shares?: unknown;
};
type SlackFileThreadShare = {
channelId: string;
ts?: string;
threadTs?: string;
};
function normalizeSlackScopeValue(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function collectSlackDirectShareChannelIds(file: SlackFileInfoSummary): Set<string> {
const ids = new Set<string>();
for (const group of [file.channels, file.groups, file.ims]) {
if (!Array.isArray(group)) {
continue;
}
for (const entry of group) {
if (typeof entry !== "string") {
continue;
}
const normalized = normalizeSlackScopeValue(entry);
if (normalized) {
ids.add(normalized);
}
}
}
return ids;
}
function collectSlackShareMaps(file: SlackFileInfoSummary): Array<Record<string, unknown>> {
if (!file.shares || typeof file.shares !== "object" || Array.isArray(file.shares)) {
return [];
}
const shares = file.shares as Record<string, unknown>;
return [shares.public, shares.private].filter(
(value): value is Record<string, unknown> =>
Boolean(value) && typeof value === "object" && !Array.isArray(value),
);
}
function collectSlackSharedChannelIds(file: SlackFileInfoSummary): Set<string> {
const ids = new Set<string>();
for (const shareMap of collectSlackShareMaps(file)) {
for (const channelId of Object.keys(shareMap)) {
const normalized = normalizeSlackScopeValue(channelId);
if (normalized) {
ids.add(normalized);
}
}
}
return ids;
}
function collectSlackThreadShares(
file: SlackFileInfoSummary,
channelId: string,
): SlackFileThreadShare[] {
const matches: SlackFileThreadShare[] = [];
for (const shareMap of collectSlackShareMaps(file)) {
const rawEntries = shareMap[channelId];
if (!Array.isArray(rawEntries)) {
continue;
}
for (const rawEntry of rawEntries) {
if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) {
continue;
}
const entry = rawEntry as Record<string, unknown>;
const ts = typeof entry.ts === "string" ? normalizeSlackScopeValue(entry.ts) : undefined;
const threadTs =
typeof entry.thread_ts === "string" ? normalizeSlackScopeValue(entry.thread_ts) : undefined;
matches.push({ channelId, ts, threadTs });
}
}
return matches;
}
function hasSlackScopeMismatch(params: {
file: SlackFileInfoSummary;
channelId?: string;
threadId?: string;
}): boolean {
const channelId = normalizeSlackScopeValue(params.channelId);
if (!channelId) {
return false;
}
const threadId = normalizeSlackScopeValue(params.threadId);
const directIds = collectSlackDirectShareChannelIds(params.file);
const sharedIds = collectSlackSharedChannelIds(params.file);
const hasChannelEvidence = directIds.size > 0 || sharedIds.size > 0;
const inChannel = directIds.has(channelId) || sharedIds.has(channelId);
if (hasChannelEvidence && !inChannel) {
return true;
}
if (!threadId) {
return false;
}
const threadShares = collectSlackThreadShares(params.file, channelId);
if (threadShares.length === 0) {
return false;
}
const threadEvidence = threadShares.filter((entry) => entry.threadTs || entry.ts);
if (threadEvidence.length === 0) {
return false;
}
return !threadEvidence.some((entry) => entry.threadTs === threadId || entry.ts === threadId);
}
/**
* Downloads a Slack file by ID and saves it to the local media store.
* Fetches a fresh download URL via files.info to avoid using stale private URLs.
* Returns null when the file cannot be found or downloaded.
*/
export async function downloadSlackFile(
fileId: string,
opts: SlackActionClientOpts & { maxBytes: number; channelId?: string; threadId?: string },
): Promise<SlackMediaResult | null> {
const token = resolveToken(opts.token, opts.accountId);
const client = await getClient(opts);
// Fetch fresh file metadata (includes a current url_private_download).
const info = await client.files.info({ file: fileId });
const file = info.file as SlackFileInfoSummary | undefined;
if (!file?.url_private_download && !file?.url_private) {
return null;
}
if (hasSlackScopeMismatch({ file, channelId: opts.channelId, threadId: opts.threadId })) {
return null;
}
const results = await resolveSlackMedia({
files: [
{
id: file.id,
name: file.name,
mimetype: file.mimetype,
url_private: file.url_private,
url_private_download: file.url_private_download,
},
],
token,
maxBytes: opts.maxBytes,
});
return results?.[0] ?? null;
}

View file

@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { slackApprovalAuth } from "./approval-auth.js";
describe("slackApprovalAuth", () => {
it("authorizes general Slack approvers from allowFrom and defaultTo", () => {
const cfg = {
channels: {
slack: {
allowFrom: ["slack:U123OWNER"],
dm: { allowFrom: ["<@U234DM>"] },
defaultTo: "user:U345DEFAULT",
execApprovals: { enabled: true, approvers: ["user:U999EXEC"] },
},
},
};
expect(
slackApprovalAuth.authorizeActorAction({
cfg,
senderId: "U123OWNER",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ authorized: true });
expect(
slackApprovalAuth.authorizeActorAction({
cfg,
senderId: "U345DEFAULT",
action: "approve",
approvalKind: "plugin",
}),
).toEqual({ authorized: true });
expect(
slackApprovalAuth.authorizeActorAction({
cfg,
senderId: "U999EXEC",
action: "approve",
approvalKind: "plugin",
}),
).toEqual({
authorized: false,
reason: "❌ You are not authorized to approve plugin requests on Slack.",
});
expect(
slackApprovalAuth.authorizeActorAction({
cfg,
senderId: "U999ATTACKER",
action: "approve",
approvalKind: "exec",
}),
).toEqual({
authorized: false,
reason: "❌ You are not authorized to approve exec requests on Slack.",
});
});
});

View file

@ -0,0 +1,39 @@
import {
createResolvedApproverActionAuthAdapter,
resolveApprovalApprovers,
} from "openclaw/plugin-sdk/approval-auth-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveSlackAccount } from "./accounts.js";
import { normalizeSlackApproverId } from "./exec-approvals.js";
export function getSlackApprovalApprovers(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] {
const account = resolveSlackAccount(params).config;
return resolveApprovalApprovers({
allowFrom: account.allowFrom,
extraAllowFrom: account.dm?.allowFrom,
defaultTo: account.defaultTo,
normalizeApprover: normalizeSlackApproverId,
normalizeDefaultTo: normalizeSlackApproverId,
});
}
export function isSlackApprovalAuthorizedSender(params: {
cfg: OpenClawConfig;
accountId?: string | null;
senderId?: string | null;
}): boolean {
const senderId = params.senderId ? normalizeSlackApproverId(params.senderId) : undefined;
if (!senderId) {
return false;
}
return getSlackApprovalApprovers(params).includes(senderId);
}
export const slackApprovalAuth = createResolvedApproverActionAuthAdapter({
channelLabel: "Slack",
resolveApprovers: ({ cfg, accountId }) => getSlackApprovalApprovers({ cfg, accountId }),
normalizeSenderId: (value) => normalizeSlackApproverId(value),
});

View file

@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import { slackApprovalNativeRuntime } from "./approval-handler.runtime.js";
type SlackPayload = {
text: string;
blocks?: unknown;
};
function findSlackActionsBlock(blocks: Array<{ type?: string; elements?: unknown[] }>) {
return blocks.find((block) => block.type === "actions");
}
describe("slackApprovalNativeRuntime", () => {
it("renders only the allowed pending actions", async () => {
const payload = (await slackApprovalNativeRuntime.presentation.buildPendingPayload({
cfg: {} as never,
accountId: "default",
context: {
app: {} as never,
config: {} as never,
},
request: {
id: "req-1",
request: {
command: "echo hi",
},
createdAtMs: 0,
expiresAtMs: 60_000,
},
approvalKind: "exec",
nowMs: 0,
view: {
approvalKind: "exec",
approvalId: "req-1",
commandText: "echo hi",
metadata: [],
actions: [
{
decision: "allow-once",
label: "Allow Once",
command: "/approve req-1 allow-once",
style: "success",
},
{
decision: "deny",
label: "Deny",
command: "/approve req-1 deny",
style: "danger",
},
],
} as never,
})) as SlackPayload;
expect(payload.text).toContain("*Exec approval required*");
const actionsBlock = findSlackActionsBlock(
payload.blocks as Array<{ type?: string; elements?: unknown[] }>,
);
const labels = (actionsBlock?.elements ?? []).map((element) =>
typeof element === "object" &&
element &&
typeof (element as { text?: { text?: unknown } }).text?.text === "string"
? (element as { text: { text: string } }).text.text
: "",
);
expect(labels).toEqual(["Allow Once", "Deny"]);
expect(JSON.stringify(payload.blocks)).not.toContain("Allow Always");
});
it("renders resolved updates without interactive blocks", async () => {
const result = await slackApprovalNativeRuntime.presentation.buildResolvedResult({
cfg: {} as never,
accountId: "default",
context: {
app: {} as never,
config: {} as never,
},
request: {
id: "req-1",
request: {
command: "echo hi",
},
createdAtMs: 0,
expiresAtMs: 60_000,
},
resolved: {
id: "req-1",
decision: "allow-once",
resolvedBy: "U123APPROVER",
ts: 0,
} as never,
view: {
approvalKind: "exec",
approvalId: "req-1",
decision: "allow-once",
commandText: "echo hi",
resolvedBy: "U123APPROVER",
} as never,
entry: {
channelId: "D123APPROVER",
messageTs: "1712345678.999999",
},
});
expect(result.kind).toBe("update");
if (result.kind !== "update") {
throw new Error("expected Slack resolved update payload");
}
const payload = result.payload as SlackPayload;
expect(payload.text).toContain("*Exec approval: Allowed once*");
expect(payload.text).toContain("Resolved by <@U123APPROVER>.");
expect(
(payload.blocks as Array<{ type?: string }>).some((block) => block.type === "actions"),
).toBe(false);
});
});

View file

@ -0,0 +1,323 @@
import type { App } from "@slack/bolt";
import type { Block, KnownBlock } from "@slack/web-api";
import type {
ChannelApprovalCapabilityHandlerContext,
ExecApprovalExpiredView,
ExecApprovalPendingView,
ExecApprovalResolvedView,
} from "openclaw/plugin-sdk/approval-handler-runtime";
import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
buildApprovalInteractiveReplyFromActionDescriptors,
type ExecApprovalRequest,
} from "openclaw/plugin-sdk/infra-runtime";
import { logError, normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
isSlackExecApprovalClientEnabled,
shouldHandleSlackExecApprovalRequest,
normalizeSlackApproverId,
} from "./exec-approvals.js";
import { resolveSlackReplyBlocks } from "./reply-blocks.js";
import { sendMessageSlack } from "./send.js";
type SlackBlock = Block | KnownBlock;
type SlackPendingApproval = {
channelId: string;
messageTs: string;
};
type SlackPendingDelivery = {
text: string;
blocks: SlackBlock[];
};
type SlackExecApprovalConfig = NonNullable<
NonNullable<NonNullable<OpenClawConfig["channels"]>["slack"]>["execApprovals"]
>;
export type SlackApprovalHandlerContext = {
app: App;
config: SlackExecApprovalConfig;
};
function resolveHandlerContext(params: ChannelApprovalCapabilityHandlerContext): {
accountId: string;
context: SlackApprovalHandlerContext;
} | null {
const context = params.context as SlackApprovalHandlerContext | undefined;
const accountId = normalizeOptionalString(params.accountId) ?? "";
if (!context?.app || !accountId) {
return null;
}
return { accountId, context };
}
function truncateSlackMrkdwn(text: string, maxChars: number): string {
return text.length <= maxChars ? text : `${text.slice(0, maxChars - 1)}…`;
}
function buildSlackCodeBlock(text: string): string {
let fence = "```";
while (text.includes(fence)) {
fence += "`";
}
return `${fence}\n${text}\n${fence}`;
}
function formatSlackApprover(resolvedBy?: string | null): string | null {
const normalized = resolvedBy ? normalizeSlackApproverId(resolvedBy) : undefined;
if (normalized) {
return `<@${normalized}>`;
}
const trimmed = normalizeOptionalString(resolvedBy);
return trimmed ? trimmed : null;
}
function formatSlackMetadataLine(label: string, value: string): string {
return `*${label}:* ${value}`;
}
function buildSlackMetadataLines(metadata: readonly { label: string; value: string }[]): string[] {
return metadata.map((item) => formatSlackMetadataLine(item.label, item.value));
}
function resolveSlackApprovalDecisionLabel(
decision: "allow-once" | "allow-always" | "deny",
): string {
return decision === "allow-once"
? "Allowed once"
: decision === "allow-always"
? "Allowed always"
: "Denied";
}
function buildSlackPendingApprovalText(view: ExecApprovalPendingView): string {
const metadataLines = buildSlackMetadataLines(view.metadata);
const lines = [
"*Exec approval required*",
"A command needs your approval.",
"",
"*Command*",
buildSlackCodeBlock(view.commandText),
...metadataLines,
];
return lines.filter(Boolean).join("\n");
}
function buildSlackPendingApprovalBlocks(view: ExecApprovalPendingView): SlackBlock[] {
const metadataLines = buildSlackMetadataLines(view.metadata);
const interactiveBlocks =
resolveSlackReplyBlocks({
text: "",
interactive: buildApprovalInteractiveReplyFromActionDescriptors(view.actions),
}) ?? [];
return [
{
type: "section",
text: {
type: "mrkdwn",
text: "*Exec approval required*\nA command needs your approval.",
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: `*Command*\n${buildSlackCodeBlock(truncateSlackMrkdwn(view.commandText, 2600))}`,
},
},
...(metadataLines.length > 0
? [
{
type: "context",
elements: metadataLines.map((line) => ({
type: "mrkdwn" as const,
text: line,
})),
} satisfies SlackBlock,
]
: []),
...interactiveBlocks,
];
}
function buildSlackResolvedText(view: ExecApprovalResolvedView): string {
const resolvedBy = formatSlackApprover(view.resolvedBy);
const lines = [
`*Exec approval: ${resolveSlackApprovalDecisionLabel(view.decision)}*`,
resolvedBy ? `Resolved by ${resolvedBy}.` : "Resolved.",
"",
"*Command*",
buildSlackCodeBlock(view.commandText),
];
return lines.join("\n");
}
function buildSlackResolvedBlocks(view: ExecApprovalResolvedView): SlackBlock[] {
const resolvedBy = formatSlackApprover(view.resolvedBy);
return [
{
type: "section",
text: {
type: "mrkdwn",
text: `*Exec approval: ${resolveSlackApprovalDecisionLabel(view.decision)}*\n${
resolvedBy ? `Resolved by ${resolvedBy}.` : "Resolved."
}`,
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: `*Command*\n${buildSlackCodeBlock(truncateSlackMrkdwn(view.commandText, 2600))}`,
},
},
];
}
function buildSlackExpiredText(view: ExecApprovalExpiredView): string {
return [
"*Exec approval expired*",
"This approval request expired before it was resolved.",
"",
"*Command*",
buildSlackCodeBlock(view.commandText),
].join("\n");
}
function buildSlackExpiredBlocks(view: ExecApprovalExpiredView): SlackBlock[] {
return [
{
type: "section",
text: {
type: "mrkdwn",
text: "*Exec approval expired*\nThis approval request expired before it was resolved.",
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: `*Command*\n${buildSlackCodeBlock(truncateSlackMrkdwn(view.commandText, 2600))}`,
},
},
];
}
async function updateMessage(params: {
app: App;
channelId: string;
messageTs: string;
text: string;
blocks: SlackBlock[];
}): Promise<void> {
try {
await params.app.client.chat.update({
channel: params.channelId,
ts: params.messageTs,
text: params.text,
blocks: params.blocks,
});
} catch (err) {
logError(`slack exec approvals: failed to update message: ${String(err)}`);
}
}
export const slackApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
SlackPendingDelivery,
{ to: string; threadTs?: string },
SlackPendingApproval,
never,
SlackPendingDelivery
>({
eventKinds: ["exec"],
availability: {
isConfigured: (params) => {
const resolved = resolveHandlerContext(params);
return resolved
? isSlackExecApprovalClientEnabled({
cfg: params.cfg,
accountId: resolved.accountId,
})
: false;
},
shouldHandle: (params) => {
const resolved = resolveHandlerContext(params);
if (!resolved) {
return false;
}
return shouldHandleSlackExecApprovalRequest({
cfg: params.cfg,
accountId: resolved.accountId,
request: params.request as ExecApprovalRequest,
});
},
},
presentation: {
buildPendingPayload: ({ view }) => ({
text: buildSlackPendingApprovalText(view as ExecApprovalPendingView),
blocks: buildSlackPendingApprovalBlocks(view as ExecApprovalPendingView),
}),
buildResolvedResult: ({ view }) => ({
kind: "update",
payload: {
text: buildSlackResolvedText(view as ExecApprovalResolvedView),
blocks: buildSlackResolvedBlocks(view as ExecApprovalResolvedView),
},
}),
buildExpiredResult: ({ view }) => ({
kind: "update",
payload: {
text: buildSlackExpiredText(view as ExecApprovalExpiredView),
blocks: buildSlackExpiredBlocks(view as ExecApprovalExpiredView),
},
}),
},
transport: {
prepareTarget: ({ plannedTarget }) => ({
dedupeKey: buildChannelApprovalNativeTargetKey(plannedTarget.target),
target: {
to: plannedTarget.target.to,
threadTs:
plannedTarget.target.threadId != null ? String(plannedTarget.target.threadId) : undefined,
},
}),
deliverPending: async ({ cfg, accountId, context, preparedTarget, pendingPayload }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return null;
}
const message = await sendMessageSlack(preparedTarget.to, pendingPayload.text, {
cfg,
accountId: resolved.accountId,
threadTs: preparedTarget.threadTs,
blocks: pendingPayload.blocks,
client: resolved.context.app.client,
});
return {
channelId: message.channelId,
messageTs: message.messageId,
};
},
updateEntry: async ({ cfg, accountId, context, entry, payload }) => {
const resolved = resolveHandlerContext({ cfg, accountId, context });
if (!resolved) {
return;
}
const nextPayload = payload;
await updateMessage({
app: resolved.context.app,
channelId: entry.channelId,
messageTs: entry.messageTs,
text: nextPayload.text,
blocks: nextPayload.blocks,
});
},
},
observe: {
onDeliveryError: ({ error, request }) => {
logError(`slack exec approvals: failed to deliver approval ${request.id}: ${String(error)}`);
},
},
});

View file

@ -0,0 +1,438 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it } from "vitest";
import { clearSessionStoreCacheForTest } from "../../../src/config/sessions/store.js";
import { slackApprovalCapability, slackNativeApprovalAdapter } from "./approval-native.js";
function buildConfig(
overrides?: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["slack"]>>,
): OpenClawConfig {
return {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
execApprovals: {
enabled: true,
approvers: ["U123APPROVER"],
target: "both",
},
...overrides,
},
},
} as OpenClawConfig;
}
const STORE_PATH = path.join(os.tmpdir(), "openclaw-slack-approval-native-test.json");
function writeStore(store: Record<string, unknown>) {
fs.writeFileSync(STORE_PATH, `${JSON.stringify(store, null, 2)}\n`, "utf8");
clearSessionStoreCacheForTest();
}
describe("slack native approval adapter", () => {
it("keeps approval availability enabled when approvers exist but native delivery is off", () => {
const cfg = buildConfig({
execApprovals: {
enabled: false,
approvers: ["U123APPROVER"],
target: "channel",
},
});
expect(
slackNativeApprovalAdapter.auth?.getActionAvailabilityState?.({
cfg,
accountId: "default",
action: "approve",
}),
).toEqual({ kind: "enabled" });
expect(
slackNativeApprovalAdapter.native?.describeDeliveryCapabilities({
cfg,
accountId: "default",
approvalKind: "exec",
request: {
id: "req-disabled-1",
request: {
command: "echo hi",
turnSourceChannel: "slack",
turnSourceTo: "channel:C123",
turnSourceAccountId: "default",
sessionKey: "agent:main:slack:channel:c123",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
}),
).toEqual({
enabled: false,
preferredSurface: "origin",
supportsOriginSurface: true,
supportsApproverDmSurface: true,
notifyOriginWhenDmOnly: true,
});
});
it("describes native slack approval delivery capabilities", () => {
const capabilities = slackNativeApprovalAdapter.native?.describeDeliveryCapabilities({
cfg: buildConfig(),
accountId: "default",
approvalKind: "exec",
request: {
id: "req-1",
request: {
command: "echo hi",
turnSourceChannel: "slack",
turnSourceTo: "channel:C123",
turnSourceAccountId: "default",
sessionKey: "agent:main:slack:channel:c123",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(capabilities).toEqual({
enabled: true,
preferredSurface: "both",
supportsOriginSurface: true,
supportsApproverDmSurface: true,
notifyOriginWhenDmOnly: true,
});
});
it("describes the correct Slack exec-approval setup path", () => {
const text = slackApprovalCapability.describeExecApprovalSetup?.({
channel: "slack",
channelLabel: "Slack",
});
expect(text).toContain("`channels.slack.execApprovals.approvers`");
expect(text).toContain("`commands.ownerAllowFrom`");
expect(text).not.toContain("`channels.slack.dm.allowFrom`");
});
it("describes the named-account Slack exec-approval setup path", () => {
const text = slackApprovalCapability.describeExecApprovalSetup?.({
channel: "slack",
channelLabel: "Slack",
accountId: "work",
});
expect(text).toContain("`channels.slack.accounts.work.execApprovals.approvers`");
expect(text).toContain("`commands.ownerAllowFrom`");
expect(text).not.toContain("`channels.slack.execApprovals.approvers`");
});
it("resolves origin targets from slack turn source", async () => {
const target = await slackNativeApprovalAdapter.native?.resolveOriginTarget?.({
cfg: buildConfig(),
accountId: "default",
approvalKind: "exec",
request: {
id: "req-1",
request: {
command: "echo hi",
turnSourceChannel: "slack",
turnSourceTo: "channel:C123",
turnSourceAccountId: "default",
turnSourceThreadId: "1712345678.123456",
sessionKey: "agent:main:slack:channel:c123:thread:1712345678.123456",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(target).toEqual({
to: "channel:C123",
threadId: "1712345678.123456",
});
});
it("keeps origin delivery when session and turn source thread ids differ only by Slack timestamp precision", async () => {
const target = await slackNativeApprovalAdapter.native?.resolveOriginTarget?.({
cfg: buildConfig(),
accountId: "default",
approvalKind: "exec",
request: {
id: "req-1",
request: {
command: "echo hi",
turnSourceChannel: "slack",
turnSourceTo: "channel:C123",
turnSourceAccountId: "default",
turnSourceThreadId: "1712345678.123456",
sessionKey: "agent:main:slack:channel:c123:thread:1712345678.123456",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(target).toEqual({
to: "channel:C123",
threadId: "1712345678.123456",
});
});
it("resolves approver dm targets", async () => {
const targets = await slackNativeApprovalAdapter.native?.resolveApproverDmTargets?.({
cfg: buildConfig(),
accountId: "default",
approvalKind: "exec",
request: {
id: "req-1",
request: {
command: "echo hi",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(targets).toEqual([{ to: "user:U123APPROVER" }]);
});
it("falls back to the session-bound origin target for plugin approvals", async () => {
writeStore({
"agent:main:slack:channel:c123": {
sessionId: "sess",
updatedAt: Date.now(),
deliveryContext: {
channel: "slack",
to: "channel:C123",
accountId: "default",
threadId: "1712345678.123456",
},
},
});
const target = await slackNativeApprovalAdapter.native?.resolveOriginTarget?.({
cfg: {
...buildConfig(),
session: { store: STORE_PATH },
},
accountId: "default",
approvalKind: "plugin",
request: {
id: "plugin:req-1",
request: {
title: "Plugin approval",
description: "Allow access",
sessionKey: "agent:main:slack:channel:c123",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(target).toEqual({
to: "channel:C123",
threadId: "1712345678.123456",
});
});
it("falls back to the session-key origin target for plugin approvals when the store is missing", async () => {
const target = await slackNativeApprovalAdapter.native?.resolveOriginTarget?.({
cfg: {
...buildConfig(),
session: { store: STORE_PATH },
},
accountId: "default",
approvalKind: "plugin",
request: {
id: "plugin:req-1",
request: {
title: "Plugin approval",
description: "Allow access",
sessionKey: "agent:main:slack:channel:c123:thread:1712345678.123456",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(target).toEqual({
to: "channel:C123",
threadId: "1712345678.123456",
});
});
it("skips native delivery when agent filters do not match", async () => {
const cfg = buildConfig({
execApprovals: {
enabled: true,
approvers: ["U123APPROVER"],
target: "both",
agentFilter: ["ops-agent"],
},
});
const originTarget = await slackNativeApprovalAdapter.native?.resolveOriginTarget?.({
cfg,
accountId: "default",
approvalKind: "exec",
request: {
id: "req-1",
request: {
command: "echo hi",
agentId: "other-agent",
turnSourceChannel: "slack",
turnSourceTo: "channel:C123",
turnSourceAccountId: "default",
sessionKey: "agent:other-agent:slack:channel:c123",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
const dmTargets = await slackNativeApprovalAdapter.native?.resolveApproverDmTargets?.({
cfg,
accountId: "default",
approvalKind: "exec",
request: {
id: "req-1",
request: {
command: "echo hi",
agentId: "other-agent",
sessionKey: "agent:other-agent:slack:channel:c123",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(originTarget).toBeNull();
expect(dmTargets).toEqual([]);
});
it("skips native delivery when the request is bound to another Slack account", async () => {
const originTarget = await slackNativeApprovalAdapter.native?.resolveOriginTarget?.({
cfg: buildConfig(),
accountId: "default",
approvalKind: "exec",
request: {
id: "req-1",
request: {
command: "echo hi",
turnSourceChannel: "slack",
turnSourceTo: "channel:C123",
turnSourceAccountId: "other",
sessionKey: "agent:main:missing",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
const dmTargets = await slackNativeApprovalAdapter.native?.resolveApproverDmTargets?.({
cfg: buildConfig(),
accountId: "default",
approvalKind: "exec",
request: {
id: "req-1",
request: {
command: "echo hi",
turnSourceChannel: "slack",
turnSourceAccountId: "other",
sessionKey: "agent:main:missing",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
});
expect(originTarget).toBeNull();
expect(dmTargets).toEqual([]);
});
it("suppresses generic slack fallback only for slack-originated approvals", () => {
const shouldSuppress = slackNativeApprovalAdapter.delivery?.shouldSuppressForwardingFallback;
if (!shouldSuppress) {
throw new Error("slack native delivery suppression unavailable");
}
expect(
shouldSuppress({
cfg: buildConfig(),
approvalKind: "exec",
target: { channel: "slack", to: "channel:C123ROOM", accountId: "default" },
request: {
id: "approval-1",
request: {
command: "echo hi",
turnSourceChannel: "slack",
turnSourceAccountId: "default",
},
createdAtMs: 0,
expiresAtMs: 1_000,
},
}),
).toBe(true);
expect(
shouldSuppress({
cfg: buildConfig(),
approvalKind: "exec",
target: { channel: "slack", to: "channel:C123ROOM", accountId: "default" },
request: {
id: "approval-1",
request: {
command: "echo hi",
turnSourceChannel: "discord",
turnSourceAccountId: "default",
},
createdAtMs: 0,
expiresAtMs: 1_000,
},
}),
).toBe(false);
});
it("keeps plugin approval auth independent from exec approvers", () => {
const cfg = buildConfig({
allowFrom: ["U123OWNER"],
execApprovals: {
enabled: true,
approvers: ["U999EXEC"],
target: "both",
},
});
expect(
slackNativeApprovalAdapter.auth.authorizeActorAction?.({
cfg,
accountId: "default",
senderId: "U123OWNER",
action: "approve",
approvalKind: "plugin",
}),
).toEqual({ authorized: true });
expect(
slackNativeApprovalAdapter.auth.authorizeActorAction?.({
cfg,
accountId: "default",
senderId: "U999EXEC",
action: "approve",
approvalKind: "plugin",
}),
).toEqual({
authorized: false,
reason: "❌ You are not authorized to approve plugin requests on Slack.",
});
expect(
slackNativeApprovalAdapter.auth.authorizeActorAction?.({
cfg,
accountId: "default",
senderId: "U999EXEC",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ authorized: true });
});
});

View file

@ -0,0 +1,198 @@
import {
createApproverRestrictedNativeApprovalCapability,
splitChannelApprovalCapability,
} from "openclaw/plugin-sdk/approval-delivery-runtime";
import { createLazyChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
import type { ChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
import {
createChannelApproverDmTargetResolver,
createChannelNativeOriginTargetResolver,
resolveApprovalRequestSessionConversation,
} from "openclaw/plugin-sdk/approval-native-runtime";
import type { ChannelApprovalCapability } from "openclaw/plugin-sdk/channel-contract";
import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
import { listSlackAccountIds } from "./accounts.js";
import { isSlackApprovalAuthorizedSender } from "./approval-auth.js";
import {
getSlackExecApprovalApprovers,
isSlackExecApprovalAuthorizedSender,
isSlackExecApprovalClientEnabled,
resolveSlackExecApprovalTarget,
shouldHandleSlackExecApprovalRequest,
} from "./exec-approvals.js";
import { parseSlackTarget } from "./targets.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
type SlackOriginTarget = { to: string; threadId?: string };
function extractSlackSessionKind(
sessionKey?: string | null,
): "direct" | "channel" | "group" | null {
if (!sessionKey) {
return null;
}
const match = sessionKey.match(/slack:(direct|channel|group):/i);
const kind = normalizeLowercaseStringOrEmpty(match?.[1]);
return kind ? (kind as "direct" | "channel" | "group") : null;
}
function normalizeComparableTarget(value: string): string {
return normalizeLowercaseStringOrEmpty(value);
}
function normalizeSlackThreadMatchKey(threadId?: string): string {
const trimmed = threadId?.trim();
if (!trimmed) {
return "";
}
const leadingEpoch = trimmed.match(/^\d+/)?.[0];
return leadingEpoch ?? trimmed;
}
function resolveTurnSourceSlackOriginTarget(request: ApprovalRequest): SlackOriginTarget | null {
const turnSourceChannel = normalizeLowercaseStringOrEmpty(request.request.turnSourceChannel);
const turnSourceTo = normalizeOptionalString(request.request.turnSourceTo) ?? "";
if (turnSourceChannel !== "slack" || !turnSourceTo) {
return null;
}
const sessionKind = extractSlackSessionKind(request.request.sessionKey ?? undefined);
const parsed = parseSlackTarget(turnSourceTo, {
defaultKind: sessionKind === "direct" ? "user" : "channel",
});
if (!parsed) {
return null;
}
const threadId =
typeof request.request.turnSourceThreadId === "string"
? normalizeOptionalString(request.request.turnSourceThreadId)
: typeof request.request.turnSourceThreadId === "number"
? String(request.request.turnSourceThreadId)
: undefined;
return {
to: `${parsed.kind}:${parsed.id}`,
threadId,
};
}
function resolveSessionSlackOriginTarget(sessionTarget: {
to: string;
threadId?: string | number | null;
}): SlackOriginTarget {
return {
to: sessionTarget.to,
threadId:
typeof sessionTarget.threadId === "string"
? normalizeOptionalString(sessionTarget.threadId)
: typeof sessionTarget.threadId === "number"
? String(sessionTarget.threadId)
: undefined,
};
}
function resolveSlackFallbackOriginTarget(request: ApprovalRequest): SlackOriginTarget | null {
const sessionTarget = resolveApprovalRequestSessionConversation({
request,
channel: "slack",
bundledFallback: false,
});
if (!sessionTarget) {
return null;
}
const parsed = parseSlackTarget(sessionTarget.id.toUpperCase(), {
defaultKind: "channel",
});
if (!parsed) {
return null;
}
return {
to: `${parsed.kind}:${parsed.id}`,
threadId: sessionTarget.threadId,
};
}
function slackTargetsMatch(a: SlackOriginTarget, b: SlackOriginTarget): boolean {
return (
normalizeComparableTarget(a.to) === normalizeComparableTarget(b.to) &&
normalizeSlackThreadMatchKey(a.threadId) === normalizeSlackThreadMatchKey(b.threadId)
);
}
const resolveSlackOriginTarget = createChannelNativeOriginTargetResolver({
channel: "slack",
shouldHandleRequest: ({ cfg, accountId, request }) =>
shouldHandleSlackExecApprovalRequest({
cfg,
accountId,
request,
}),
resolveTurnSourceTarget: resolveTurnSourceSlackOriginTarget,
resolveSessionTarget: resolveSessionSlackOriginTarget,
targetsMatch: slackTargetsMatch,
resolveFallbackTarget: resolveSlackFallbackOriginTarget,
});
const resolveSlackApproverDmTargets = createChannelApproverDmTargetResolver({
shouldHandleRequest: ({ cfg, accountId, request }) =>
shouldHandleSlackExecApprovalRequest({
cfg,
accountId,
request,
}),
resolveApprovers: getSlackExecApprovalApprovers,
mapApprover: (approver) => ({ to: `user:${approver}` }),
});
export const slackApprovalCapability = createApproverRestrictedNativeApprovalCapability({
channel: "slack",
channelLabel: "Slack",
describeExecApprovalSetup: ({
accountId,
}: Parameters<NonNullable<ChannelApprovalCapability["describeExecApprovalSetup"]>>[0]) => {
const prefix =
accountId && accountId !== "default"
? `channels.slack.accounts.${accountId}`
: "channels.slack";
return `Approve it from the Web UI or terminal UI for now. Slack supports native exec approvals for this account. Configure \`${prefix}.execApprovals.approvers\` or \`commands.ownerAllowFrom\`; leave \`${prefix}.execApprovals.enabled\` unset/\`auto\` or set it to \`true\`.`;
},
listAccountIds: listSlackAccountIds,
hasApprovers: ({ cfg, accountId }) =>
getSlackExecApprovalApprovers({ cfg, accountId }).length > 0,
isExecAuthorizedSender: ({ cfg, accountId, senderId }) =>
isSlackExecApprovalAuthorizedSender({ cfg, accountId, senderId }),
isPluginAuthorizedSender: ({ cfg, accountId, senderId }) =>
isSlackApprovalAuthorizedSender({ cfg, accountId, senderId }),
isNativeDeliveryEnabled: ({ cfg, accountId }) =>
isSlackExecApprovalClientEnabled({ cfg, accountId }),
resolveNativeDeliveryMode: ({ cfg, accountId }) =>
resolveSlackExecApprovalTarget({ cfg, accountId }),
requireMatchingTurnSourceChannel: true,
resolveSuppressionAccountId: ({ target, request }) =>
normalizeOptionalString(target.accountId) ??
normalizeOptionalString(request.request.turnSourceAccountId),
resolveOriginTarget: resolveSlackOriginTarget,
resolveApproverDmTargets: resolveSlackApproverDmTargets,
notifyOriginWhenDmOnly: true,
nativeRuntime: createLazyChannelApprovalNativeRuntimeAdapter({
eventKinds: ["exec"],
isConfigured: ({ cfg, accountId }) =>
isSlackExecApprovalClientEnabled({
cfg,
accountId,
}),
shouldHandle: ({ cfg, accountId, request }) =>
shouldHandleSlackExecApprovalRequest({
cfg,
accountId,
request,
}),
load: async () =>
(await import("./approval-handler.runtime.js"))
.slackApprovalNativeRuntime as unknown as ChannelApprovalNativeRuntimeAdapter,
}),
});
export const slackNativeApprovalAdapter = splitChannelApprovalCapability(slackApprovalCapability);

View file

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { buildSlackBlocksFallbackText } from "./blocks-fallback.js";
describe("buildSlackBlocksFallbackText", () => {
it("prefers header text", () => {
expect(
buildSlackBlocksFallbackText([
{ type: "header", text: { type: "plain_text", text: "Deploy status" } },
] as never),
).toBe("Deploy status");
});
it("uses image alt text", () => {
expect(
buildSlackBlocksFallbackText([
{ type: "image", image_url: "https://example.com/image.png", alt_text: "Latency chart" },
] as never),
).toBe("Latency chart");
});
it("uses generic defaults for file and unknown blocks", () => {
expect(
buildSlackBlocksFallbackText([
{ type: "file", source: "remote", external_id: "F123" },
] as never),
).toBe("Shared a file");
expect(buildSlackBlocksFallbackText([{ type: "divider" }] as never)).toBe(
"Shared a Block Kit message",
);
});
});

View file

@ -0,0 +1,95 @@
import type { Block, KnownBlock } from "@slack/web-api";
type PlainTextObject = { text?: string };
type SlackBlockWithFields = {
type?: string;
text?: PlainTextObject & { type?: string };
title?: PlainTextObject;
alt_text?: string;
elements?: Array<{ text?: string; type?: string }>;
};
function cleanCandidate(value: string | undefined): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const normalized = value.replace(/\s+/g, " ").trim();
return normalized.length > 0 ? normalized : undefined;
}
function readSectionText(block: SlackBlockWithFields): string | undefined {
return cleanCandidate(block.text?.text);
}
function readHeaderText(block: SlackBlockWithFields): string | undefined {
return cleanCandidate(block.text?.text);
}
function readImageText(block: SlackBlockWithFields): string | undefined {
return cleanCandidate(block.alt_text) ?? cleanCandidate(block.title?.text);
}
function readVideoText(block: SlackBlockWithFields): string | undefined {
return cleanCandidate(block.title?.text) ?? cleanCandidate(block.alt_text);
}
function readContextText(block: SlackBlockWithFields): string | undefined {
if (!Array.isArray(block.elements)) {
return undefined;
}
const textParts = block.elements
.map((element) => cleanCandidate(element.text))
.filter((value): value is string => Boolean(value));
return textParts.length > 0 ? textParts.join(" ") : undefined;
}
export function buildSlackBlocksFallbackText(blocks: (Block | KnownBlock)[]): string {
for (const raw of blocks) {
const block = raw as SlackBlockWithFields;
switch (block.type) {
case "header": {
const text = readHeaderText(block);
if (text) {
return text;
}
break;
}
case "section": {
const text = readSectionText(block);
if (text) {
return text;
}
break;
}
case "image": {
const text = readImageText(block);
if (text) {
return text;
}
return "Shared an image";
}
case "video": {
const text = readVideoText(block);
if (text) {
return text;
}
return "Shared a video";
}
case "file": {
return "Shared a file";
}
case "context": {
const text = readContextText(block);
if (text) {
return text;
}
break;
}
default:
break;
}
}
return "Shared a Block Kit message";
}

View file

@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { parseSlackBlocksInput } from "./blocks-input.js";
describe("parseSlackBlocksInput", () => {
it("returns undefined when blocks are missing", () => {
expect(parseSlackBlocksInput(undefined)).toBeUndefined();
expect(parseSlackBlocksInput(null)).toBeUndefined();
});
it("accepts blocks arrays", () => {
const parsed = parseSlackBlocksInput([{ type: "divider" }]);
expect(parsed).toEqual([{ type: "divider" }]);
});
it("accepts JSON blocks strings", () => {
const parsed = parseSlackBlocksInput(
'[{"type":"section","text":{"type":"mrkdwn","text":"hi"}}]',
);
expect(parsed).toEqual([{ type: "section", text: { type: "mrkdwn", text: "hi" } }]);
});
it("rejects invalid block payloads", () => {
const cases = [
{
name: "invalid JSON",
input: "{bad-json",
expectedMessage: /valid JSON/i,
},
{
name: "non-array payload",
input: { type: "divider" },
expectedMessage: /must be an array/i,
},
{
name: "empty array",
input: [],
expectedMessage: /at least one block/i,
},
{
name: "non-object block",
input: ["not-a-block"],
expectedMessage: /must be an object/i,
},
{
name: "missing block type",
input: [{}],
expectedMessage: /non-empty string type/i,
},
] as const;
for (const testCase of cases) {
expect(() => parseSlackBlocksInput(testCase.input), testCase.name).toThrow(
testCase.expectedMessage,
);
}
});
});

View file

@ -0,0 +1,45 @@
import type { Block, KnownBlock } from "@slack/web-api";
export const SLACK_MAX_BLOCKS = 50;
function parseBlocksJson(raw: string) {
try {
return JSON.parse(raw);
} catch {
throw new Error("blocks must be valid JSON");
}
}
function assertBlocksArray(raw: unknown) {
if (!Array.isArray(raw)) {
throw new Error("blocks must be an array");
}
if (raw.length === 0) {
throw new Error("blocks must contain at least one block");
}
if (raw.length > SLACK_MAX_BLOCKS) {
throw new Error(`blocks cannot exceed ${SLACK_MAX_BLOCKS} items`);
}
for (const block of raw) {
if (!block || typeof block !== "object" || Array.isArray(block)) {
throw new Error("each block must be an object");
}
const type = (block as { type?: unknown }).type;
if (typeof type !== "string" || type.trim().length === 0) {
throw new Error("each block must include a non-empty string type");
}
}
}
export function validateSlackBlocksArray(raw: unknown): (Block | KnownBlock)[] {
assertBlocksArray(raw);
return raw as (Block | KnownBlock)[];
}
export function parseSlackBlocksInput(raw: unknown): (Block | KnownBlock)[] | undefined {
if (raw == null) {
return undefined;
}
const parsed = typeof raw === "string" ? parseBlocksJson(raw) : raw;
return validateSlackBlocksArray(parsed);
}

View file

@ -0,0 +1,110 @@
import type { Block, KnownBlock } from "@slack/web-api";
import { reduceInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
import type { InteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { truncateSlackText } from "./truncate.js";
export const SLACK_REPLY_BUTTON_ACTION_ID = "openclaw:reply_button";
export const SLACK_REPLY_SELECT_ACTION_ID = "openclaw:reply_select";
const SLACK_SECTION_TEXT_MAX = 3000;
const SLACK_PLAIN_TEXT_MAX = 75;
export type SlackBlock = Block | KnownBlock;
function buildSlackReplyButtonActionId(buttonIndex: number, choiceIndex: number): string {
return `${SLACK_REPLY_BUTTON_ACTION_ID}:${String(buttonIndex)}:${String(choiceIndex + 1)}`;
}
function buildSlackReplySelectActionId(selectIndex: number): string {
return `${SLACK_REPLY_SELECT_ACTION_ID}:${String(selectIndex)}`;
}
function resolveSlackButtonStyle(
style: "primary" | "secondary" | "success" | "danger" | undefined,
) {
if (style === "primary" || style === "danger") {
return style;
}
if (style === "success") {
return "primary";
}
return undefined;
}
export function buildSlackInteractiveBlocks(interactive?: InteractiveReply): SlackBlock[] {
const initialState = {
blocks: [] as SlackBlock[],
buttonIndex: 0,
selectIndex: 0,
};
return reduceInteractiveReply(interactive, initialState, (state, block) => {
if (block.type === "text") {
const trimmed = block.text.trim();
if (!trimmed) {
return state;
}
state.blocks.push({
type: "section",
text: {
type: "mrkdwn",
text: truncateSlackText(trimmed, SLACK_SECTION_TEXT_MAX),
},
});
return state;
}
if (block.type === "buttons") {
if (block.buttons.length === 0) {
return state;
}
state.blocks.push({
type: "actions",
block_id: `openclaw_reply_buttons_${++state.buttonIndex}`,
elements: block.buttons.map((button, choiceIndex) => {
const style = resolveSlackButtonStyle(button.style);
return {
type: "button",
action_id: buildSlackReplyButtonActionId(state.buttonIndex, choiceIndex),
text: {
type: "plain_text",
text: truncateSlackText(button.label, SLACK_PLAIN_TEXT_MAX),
emoji: true,
},
value: button.value,
...(style ? { style } : {}),
};
}),
});
return state;
}
if (block.options.length === 0) {
return state;
}
state.blocks.push({
type: "actions",
block_id: `openclaw_reply_select_${++state.selectIndex}`,
elements: [
{
type: "static_select",
action_id: buildSlackReplySelectActionId(state.selectIndex),
placeholder: {
type: "plain_text",
text: truncateSlackText(
normalizeOptionalString(block.placeholder) ?? "Choose an option",
SLACK_PLAIN_TEXT_MAX,
),
emoji: true,
},
options: block.options.map((option, _choiceIndex) => ({
text: {
type: "plain_text",
text: truncateSlackText(option.label, SLACK_PLAIN_TEXT_MAX),
emoji: true,
},
value: option.value,
})),
},
],
});
return state;
}).blocks;
}

View file

@ -0,0 +1,69 @@
import type { WebClient } from "@slack/web-api";
import { vi } from "vitest";
export type SlackEditTestClient = WebClient & {
chat: {
update: ReturnType<typeof vi.fn>;
};
};
export type SlackSendTestClient = WebClient & {
conversations: {
open: ReturnType<typeof vi.fn>;
};
chat: {
postMessage: ReturnType<typeof vi.fn>;
};
};
const slackBlockTestState = vi.hoisted(() => ({
account: {
accountId: "default",
botToken: "xoxb-test",
botTokenSource: "config",
config: {},
},
config: {},
}));
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
"openclaw/plugin-sdk/config-runtime",
);
return {
...actual,
loadConfig: () => slackBlockTestState.config,
};
});
vi.mock("./accounts.js", async () => {
const actual = await vi.importActual<typeof import("./accounts.js")>("./accounts.js");
return {
...actual,
resolveSlackAccount: () => slackBlockTestState.account,
};
});
// Kept for compatibility with existing tests; mocks install at module evaluation.
export function installSlackBlockTestMocks() {
return;
}
export function createSlackEditTestClient(): SlackEditTestClient {
return {
chat: {
update: vi.fn(async () => ({ ok: true })),
},
} as unknown as SlackEditTestClient;
}
export function createSlackSendTestClient(): SlackSendTestClient {
return {
conversations: {
open: vi.fn(async () => ({ channel: { id: "D123" } })),
},
chat: {
postMessage: vi.fn(async () => ({ ts: "171234.567" })),
},
} as unknown as SlackSendTestClient;
}

View file

@ -0,0 +1,46 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-contract";
import type { SlackActionContext } from "./action-runtime.js";
import { handleSlackMessageAction } from "./message-action-dispatch.js";
import { extractSlackToolSend } from "./message-actions.js";
import { describeSlackMessageTool } from "./message-tool-api.js";
import { resolveSlackChannelId } from "./targets.js";
type SlackActionInvoke = (
action: Record<string, unknown>,
cfg: unknown,
toolContext: unknown,
) => Promise<AgentToolResult<unknown>>;
let slackActionRuntimePromise: Promise<typeof import("./action-runtime.runtime.js")> | undefined;
async function loadSlackActionRuntime() {
slackActionRuntimePromise ??= import("./action-runtime.runtime.js");
return await slackActionRuntimePromise;
}
export function createSlackActions(
providerId: string,
options?: { invoke?: SlackActionInvoke },
): ChannelMessageActionAdapter {
return {
describeMessageTool: describeSlackMessageTool,
extractToolSend: ({ args }) => extractSlackToolSend(args),
handleAction: async (ctx) => {
return await handleSlackMessageAction({
providerId,
ctx,
normalizeChannelId: resolveSlackChannelId,
includeReadThreadId: true,
invoke: async (action, cfg, toolContext) =>
await (options?.invoke
? options.invoke(action, cfg, toolContext)
: (await loadSlackActionRuntime()).handleSlackAction(action, cfg, {
...(toolContext as SlackActionContext | undefined),
mediaLocalRoots: ctx.mediaLocalRoots,
mediaReadFile: ctx.mediaReadFile,
})),
});
},
};
}

View file

@ -0,0 +1,27 @@
export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
export {
PAIRING_APPROVED_MESSAGE,
projectCredentialSnapshotFields,
resolveConfiguredFromRequiredCredentialStatuses,
} from "openclaw/plugin-sdk/channel-status";
export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
export { looksLikeSlackTargetId, normalizeSlackMessagingTarget } from "./target-parsing.js";
const SLACK_CHANNEL_META = {
id: "slack",
label: "Slack",
selectionLabel: "Slack",
docsPath: "/channels/slack",
docsLabel: "slack",
blurb: "supports bot + app tokens, channels, threads, and interactive replies.",
systemImage: "number.square",
markdownCapable: true,
} as const;
export function getChatChannelMeta(id: string) {
if (id !== SLACK_CHANNEL_META.id) {
throw new Error(`Unsupported Slack channel meta lookup: ${id}`);
}
return SLACK_CHANNEL_META;
}

View file

@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import { migrateSlackChannelConfig, migrateSlackChannelsInPlace } from "./channel-migration.js";
function createSlackGlobalChannelConfig(channels: Record<string, Record<string, unknown>>) {
return {
channels: {
slack: {
channels,
},
},
};
}
function createSlackAccountChannelConfig(
accountId: string,
channels: Record<string, Record<string, unknown>>,
) {
return {
channels: {
slack: {
accounts: {
[accountId]: {
channels,
},
},
},
},
};
}
describe("migrateSlackChannelConfig", () => {
it("migrates global channel ids", () => {
const cfg = createSlackGlobalChannelConfig({
C123: { requireMention: false },
});
const result = migrateSlackChannelConfig({
cfg,
accountId: "default",
oldChannelId: "C123",
newChannelId: "C999",
});
expect(result.migrated).toBe(true);
expect(cfg.channels.slack.channels).toEqual({
C999: { requireMention: false },
});
});
it("migrates account-scoped channels", () => {
const cfg = createSlackAccountChannelConfig("primary", {
C123: { requireMention: true },
});
const result = migrateSlackChannelConfig({
cfg,
accountId: "primary",
oldChannelId: "C123",
newChannelId: "C999",
});
expect(result.migrated).toBe(true);
expect(result.scopes).toEqual(["account"]);
expect(cfg.channels.slack.accounts.primary.channels).toEqual({
C999: { requireMention: true },
});
});
it("matches account ids case-insensitively", () => {
const cfg = createSlackAccountChannelConfig("Primary", {
C123: {},
});
const result = migrateSlackChannelConfig({
cfg,
accountId: "primary",
oldChannelId: "C123",
newChannelId: "C999",
});
expect(result.migrated).toBe(true);
expect(cfg.channels.slack.accounts.Primary.channels).toEqual({
C999: {},
});
});
it("skips migration when new id already exists", () => {
const cfg = createSlackGlobalChannelConfig({
C123: { requireMention: true },
C999: { requireMention: false },
});
const result = migrateSlackChannelConfig({
cfg,
accountId: "default",
oldChannelId: "C123",
newChannelId: "C999",
});
expect(result.migrated).toBe(false);
expect(result.skippedExisting).toBe(true);
expect(cfg.channels.slack.channels).toEqual({
C123: { requireMention: true },
C999: { requireMention: false },
});
});
it("no-ops when old and new channel ids are the same", () => {
const channels = {
C123: { requireMention: true },
};
const result = migrateSlackChannelsInPlace(channels, "C123", "C123");
expect(result).toEqual({ migrated: false, skippedExisting: false });
expect(channels).toEqual({
C123: { requireMention: true },
});
});
});

View file

@ -0,0 +1,103 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { SlackChannelConfig } from "openclaw/plugin-sdk/config-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
type SlackChannels = Record<string, SlackChannelConfig>;
type MigrationScope = "account" | "global";
export type SlackChannelMigrationResult = {
migrated: boolean;
skippedExisting: boolean;
scopes: MigrationScope[];
};
function resolveAccountChannels(
cfg: OpenClawConfig,
accountId?: string | null,
): { channels?: SlackChannels } {
if (!accountId) {
return {};
}
const normalized = normalizeAccountId(accountId);
const accounts = cfg.channels?.slack?.accounts;
if (!accounts || typeof accounts !== "object") {
return {};
}
const exact = accounts[normalized];
if (exact?.channels) {
return { channels: exact.channels };
}
const matchKey = Object.keys(accounts).find(
(key) => normalizeLowercaseStringOrEmpty(key) === normalizeLowercaseStringOrEmpty(normalized),
);
return { channels: matchKey ? accounts[matchKey]?.channels : undefined };
}
export function migrateSlackChannelsInPlace(
channels: SlackChannels | undefined,
oldChannelId: string,
newChannelId: string,
): { migrated: boolean; skippedExisting: boolean } {
if (!channels) {
return { migrated: false, skippedExisting: false };
}
if (oldChannelId === newChannelId) {
return { migrated: false, skippedExisting: false };
}
if (!Object.hasOwn(channels, oldChannelId)) {
return { migrated: false, skippedExisting: false };
}
if (Object.hasOwn(channels, newChannelId)) {
return { migrated: false, skippedExisting: true };
}
channels[newChannelId] = channels[oldChannelId];
delete channels[oldChannelId];
return { migrated: true, skippedExisting: false };
}
export function migrateSlackChannelConfig(params: {
cfg: OpenClawConfig;
accountId?: string | null;
oldChannelId: string;
newChannelId: string;
}): SlackChannelMigrationResult {
const scopes: MigrationScope[] = [];
let migrated = false;
let skippedExisting = false;
const accountChannels = resolveAccountChannels(params.cfg, params.accountId).channels;
if (accountChannels) {
const result = migrateSlackChannelsInPlace(
accountChannels,
params.oldChannelId,
params.newChannelId,
);
if (result.migrated) {
migrated = true;
scopes.push("account");
}
if (result.skippedExisting) {
skippedExisting = true;
}
}
const globalChannels = params.cfg.channels?.slack?.channels;
if (globalChannels) {
const result = migrateSlackChannelsInPlace(
globalChannels,
params.oldChannelId,
params.newChannelId,
);
if (result.migrated) {
migrated = true;
scopes.push("global");
}
if (result.skippedExisting) {
skippedExisting = true;
}
}
return { migrated, skippedExisting, scopes };
}

View file

@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { __resetSlackChannelTypeCacheForTest, resolveSlackChannelType } from "./channel-type.js";
const conversationsInfoMock = vi.fn();
vi.mock("./client.js", () => ({
createSlackWebClient: vi.fn(() => ({
conversations: {
info: conversationsInfoMock,
},
})),
}));
describe("resolveSlackChannelType", () => {
beforeEach(() => {
conversationsInfoMock.mockReset();
__resetSlackChannelTypeCacheForTest();
});
it("uses configured defaultAccount for omitted-account cache keys", async () => {
const channelId = "C123";
await expect(
resolveSlackChannelType({
cfg: {
channels: {
slack: {
enabled: true,
},
},
} as never,
channelId,
}),
).resolves.toBe("unknown");
await expect(
resolveSlackChannelType({
cfg: {
channels: {
slack: {
enabled: true,
defaultAccount: "work",
accounts: {
work: {
botToken: "xoxb-work",
appToken: "xapp-work",
dm: {
groupChannels: [channelId],
},
},
},
},
},
} as never,
channelId,
}),
).resolves.toBe("group");
expect(conversationsInfoMock).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,79 @@
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
import { resolveSlackAccount } from "./accounts.js";
import { createSlackWebClient } from "./client.js";
import { normalizeAllowListLower } from "./monitor/allow-list.js";
import type { OpenClawConfig } from "./runtime-api.js";
const SLACK_CHANNEL_TYPE_CACHE = new Map<string, "channel" | "group" | "dm" | "unknown">();
export async function resolveSlackChannelType(params: {
cfg: OpenClawConfig;
accountId?: string | null;
channelId: string;
}): Promise<"channel" | "group" | "dm" | "unknown"> {
const channelId = params.channelId.trim();
if (!channelId) {
return "unknown";
}
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
const cacheKey = `${account.accountId}:${channelId}`;
const cached = SLACK_CHANNEL_TYPE_CACHE.get(cacheKey);
if (cached) {
return cached;
}
const groupChannels = normalizeAllowListLower(account.dm?.groupChannels);
const channelIdLower = normalizeLowercaseStringOrEmpty(channelId);
if (
groupChannels.includes(channelIdLower) ||
groupChannels.includes(`slack:${channelIdLower}`) ||
groupChannels.includes(`channel:${channelIdLower}`) ||
groupChannels.includes(`group:${channelIdLower}`) ||
groupChannels.includes(`mpim:${channelIdLower}`)
) {
SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, "group");
return "group";
}
const channelKeys = Object.keys(account.channels ?? {});
if (
channelKeys.some((key) => {
const normalized = normalizeLowercaseStringOrEmpty(key);
return (
normalized === channelIdLower ||
normalized === `channel:${channelIdLower}` ||
normalized.replace(/^#/, "") === channelIdLower
);
})
) {
SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, "channel");
return "channel";
}
const token =
normalizeOptionalString(account.botToken) ??
normalizeOptionalString(account.config.userToken) ??
"";
if (!token) {
SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, "unknown");
return "unknown";
}
try {
const client = createSlackWebClient(token);
const info = await client.conversations.info({ channel: channelId });
const channel = info.channel as { is_im?: boolean; is_mpim?: boolean } | undefined;
const type = channel?.is_im ? "dm" : channel?.is_mpim ? "group" : "channel";
SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, type);
return type;
} catch {
SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, "unknown");
return "unknown";
}
}
export function __resetSlackChannelTypeCacheForTest(): void {
SLACK_CHANNEL_TYPE_CACHE.clear();
}

View file

@ -0,0 +1,12 @@
import { type ResolvedSlackAccount } from "./accounts.js";
import { type ChannelPlugin } from "./channel-api.js";
import { slackSetupAdapter } from "./setup-core.js";
import { slackSetupWizard } from "./setup-surface.js";
import { createSlackPluginBase } from "./shared.js";
export const slackSetupPlugin: ChannelPlugin<ResolvedSlackAccount> = {
...createSlackPluginBase({
setupWizard: slackSetupWizard,
setup: slackSetupAdapter,
}),
};

View file

@ -0,0 +1,835 @@
import { Type } from "@sinclair/typebox";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createRuntimeEnv } from "../../../test/helpers/plugins/runtime-env.js";
import { slackPlugin } from "./channel.js";
import { slackOutbound } from "./outbound-adapter.js";
import * as probeModule from "./probe.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { clearSlackRuntime, setSlackRuntime } from "./runtime.js";
const { handleSlackActionMock } = vi.hoisted(() => ({
handleSlackActionMock: vi.fn(),
}));
const { sendMessageSlackMock } = vi.hoisted(() => ({
sendMessageSlackMock: vi.fn(),
}));
vi.mock("./action-runtime.js", async () => {
const actual = await vi.importActual<typeof import("./action-runtime.js")>("./action-runtime.js");
return {
...actual,
handleSlackAction: handleSlackActionMock,
};
});
vi.mock("./send.runtime.js", () => ({
sendMessageSlack: sendMessageSlackMock,
}));
beforeEach(async () => {
handleSlackActionMock.mockReset();
sendMessageSlackMock.mockReset();
sendMessageSlackMock.mockResolvedValue({ messageId: "msg-1", channelId: "D123" });
setSlackRuntime({
channel: {
slack: {
handleSlackAction: handleSlackActionMock,
},
},
} as never);
});
async function getSlackConfiguredState(cfg: OpenClawConfig) {
const account = slackPlugin.config.resolveAccount(cfg, "default");
return {
configured: slackPlugin.config.isConfigured?.(account, cfg),
snapshot: await slackPlugin.status?.buildAccountSnapshot?.({
account,
cfg,
runtime: undefined,
}),
};
}
function requireSlackHandleAction() {
const handleAction = slackPlugin.actions?.handleAction;
if (!handleAction) {
throw new Error("slack actions.handleAction unavailable");
}
return handleAction;
}
function requireSlackSendText() {
const sendText = slackPlugin.outbound?.sendText;
if (!sendText) {
throw new Error("slack outbound.sendText unavailable");
}
return sendText;
}
function requireSlackSendMedia() {
const sendMedia = slackPlugin.outbound?.sendMedia;
if (!sendMedia) {
throw new Error("slack outbound.sendMedia unavailable");
}
return sendMedia;
}
function requireSlackSendPayload() {
const sendPayload = slackPlugin.outbound?.sendPayload ?? slackOutbound.sendPayload;
if (!sendPayload) {
throw new Error("slack outbound.sendPayload unavailable");
}
return sendPayload;
}
function requireSlackListPeers() {
const listPeers = slackPlugin.directory?.listPeers;
if (!listPeers) {
throw new Error("slack directory.listPeers unavailable");
}
return listPeers;
}
describe("slackPlugin actions", () => {
it("prefers session lookup for announce target routing", () => {
expect(slackPlugin.meta.preferSessionLookupForAnnounceTarget).toBe(true);
});
it("owns unified message tool discovery", () => {
const discovery = slackPlugin.actions?.describeMessageTool({
cfg: {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
capabilities: { interactiveReplies: true },
},
},
},
});
expect(discovery?.actions).toContain("send");
expect(discovery?.capabilities).toEqual(expect.arrayContaining(["blocks", "interactive"]));
expect(discovery?.schema).toMatchObject({
properties: {
blocks: expect.any(Object),
},
});
});
it("honors the selected Slack account during message tool discovery", () => {
const cfg: OpenClawConfig = {
channels: {
slack: {
botToken: "xoxb-root",
appToken: "xapp-root",
actions: {
reactions: false,
messages: false,
pins: false,
memberInfo: false,
emojiList: false,
},
capabilities: {
interactiveReplies: false,
},
accounts: {
default: {
botToken: "xoxb-default",
appToken: "xapp-default",
actions: {
reactions: false,
messages: false,
pins: false,
memberInfo: false,
emojiList: false,
},
capabilities: {
interactiveReplies: false,
},
},
work: {
botToken: "xoxb-work",
appToken: "xapp-work",
actions: {
reactions: true,
messages: true,
pins: false,
memberInfo: false,
emojiList: false,
},
capabilities: {
interactiveReplies: true,
},
},
},
},
},
};
expect(slackPlugin.actions?.describeMessageTool?.({ cfg, accountId: "default" })).toMatchObject(
{
actions: ["send"],
capabilities: ["blocks"],
},
);
expect(slackPlugin.actions?.describeMessageTool?.({ cfg, accountId: "work" })).toMatchObject({
actions: [
"send",
"react",
"reactions",
"read",
"edit",
"delete",
"download-file",
"upload-file",
],
capabilities: expect.arrayContaining(["blocks", "interactive"]),
});
});
it("uses configured defaultAccount for pairing approval notifications", async () => {
const cfg = {
channels: {
slack: {
defaultAccount: "work",
accounts: {
work: {
botToken: "xoxb-work",
},
},
},
},
} as OpenClawConfig;
setSlackRuntime({
config: {
loadConfig: () => cfg,
},
} as never);
const notify = slackPlugin.pairing?.notifyApproval;
if (!notify) {
throw new Error("slack pairing notify unavailable");
}
await notify({
cfg,
id: "U12345678",
});
expect(sendMessageSlackMock).toHaveBeenCalledWith(
"user:U12345678",
expect.stringContaining("approved"),
);
});
it("keeps blocks optional in the message tool schema", () => {
const discovery = slackPlugin.actions?.describeMessageTool({
cfg: {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
},
} as OpenClawConfig,
});
const schema = discovery?.schema;
if (!schema || Array.isArray(schema)) {
throw new Error("expected slack message-tool schema");
}
expect(Type.Object(schema.properties).required).toBeUndefined();
});
it("treats interactive reply payloads as structured Slack payloads", () => {
const hasStructuredReplyPayload = slackPlugin.messaging?.hasStructuredReplyPayload;
if (!hasStructuredReplyPayload) {
throw new Error("slack messaging.hasStructuredReplyPayload unavailable");
}
expect(
hasStructuredReplyPayload({
payload: {
text: "Choose",
interactive: {
blocks: [{ type: "buttons", buttons: [{ label: "Retry", value: "retry" }] }],
},
},
}),
).toBe(true);
});
it("forwards read threadId to Slack action handler", async () => {
handleSlackActionMock.mockResolvedValueOnce({ messages: [], hasMore: false });
const handleAction = requireSlackHandleAction();
await handleAction({
action: "read",
channel: "slack",
accountId: "default",
cfg: {},
params: {
channelId: "C123",
threadId: "1712345678.123456",
},
});
expect(handleSlackActionMock).toHaveBeenCalledWith(
expect.objectContaining({
action: "readMessages",
channelId: "C123",
threadId: "1712345678.123456",
}),
{},
undefined,
);
});
});
describe("slackPlugin status", () => {
it("uses the direct Slack probe helper when runtime is not initialized", async () => {
const probeSpy = vi.spyOn(probeModule, "probeSlack").mockResolvedValueOnce({
ok: true,
status: 200,
bot: { id: "B1", name: "openclaw-bot" },
team: { id: "T1", name: "OpenClaw" },
});
clearSlackRuntime();
const cfg = {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
},
} as OpenClawConfig;
const account = slackPlugin.config.resolveAccount(cfg, "default");
const result = await slackPlugin.status!.probeAccount!({
account,
timeoutMs: 2500,
cfg,
});
expect(probeSpy).toHaveBeenCalledWith("xoxb-test", 2500);
expect(result).toEqual({
ok: true,
status: 200,
bot: { id: "B1", name: "openclaw-bot" },
team: { id: "T1", name: "OpenClaw" },
});
});
});
describe("slackPlugin security", () => {
it("normalizes dm allowlist entries with trimmed prefixes", () => {
const resolveDmPolicy = slackPlugin.security?.resolveDmPolicy;
if (!resolveDmPolicy) {
throw new Error("resolveDmPolicy unavailable");
}
const result = resolveDmPolicy({
cfg: {
channels: {
slack: {
dm: { policy: "allowlist", allowFrom: [" slack:U123 "] },
},
},
} as OpenClawConfig,
account: slackPlugin.config.resolveAccount(
{
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
dm: { policy: "allowlist", allowFrom: [" slack:U123 "] },
},
},
} as OpenClawConfig,
"default",
),
});
if (!result) {
throw new Error("slack resolveDmPolicy returned null");
}
expect(result.policy).toBe("allowlist");
expect(result.allowFrom).toEqual([" slack:U123 "]);
expect(result.normalizeEntry?.(" slack:U123 ")).toBe("U123");
expect(result.normalizeEntry?.(" user:U999 ")).toBe("U999");
});
});
describe("slackPlugin outbound", () => {
const cfg = {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
},
};
it("treats ACP block text as visible delivered output", () => {
expect(
slackPlugin.outbound?.shouldTreatDeliveredTextAsVisible?.({
kind: "block",
text: "hello",
}),
).toBe(true);
expect(
slackPlugin.outbound?.shouldTreatDeliveredTextAsVisible?.({
kind: "tool",
text: "hello",
}),
).toBe(false);
});
it("advertises the 8000-character Slack default chunk limit", () => {
expect(slackOutbound.textChunkLimit).toBe(8000);
expect(slackPlugin.outbound?.textChunkLimit).toBe(8000);
});
it("uses threadId as threadTs fallback for sendText", async () => {
const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-text" });
const sendText = requireSlackSendText();
const result = await sendText({
cfg,
to: "C123",
text: "hello",
accountId: "default",
threadId: "1712345678.123456",
deps: { sendSlack },
});
expect(sendSlack).toHaveBeenCalledWith(
"C123",
"hello",
expect.objectContaining({
threadTs: "1712345678.123456",
}),
);
expect(result).toEqual({ channel: "slack", messageId: "m-text" });
});
it("prefers replyToId over threadId for sendMedia", async () => {
const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-media" });
const sendMedia = requireSlackSendMedia();
const result = await sendMedia({
cfg,
to: "C999",
text: "caption",
mediaUrl: "https://example.com/image.png",
accountId: "default",
replyToId: "1712000000.000001",
threadId: "1712345678.123456",
deps: { sendSlack },
});
expect(sendSlack).toHaveBeenCalledWith(
"C999",
"caption",
expect.objectContaining({
mediaUrl: "https://example.com/image.png",
threadTs: "1712000000.000001",
}),
);
expect(result).toEqual({ channel: "slack", messageId: "m-media" });
});
it("forwards mediaLocalRoots for sendMedia", async () => {
const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-media-local" });
const sendMedia = requireSlackSendMedia();
const mediaLocalRoots = ["/tmp/workspace"];
const result = await sendMedia({
cfg,
to: "C999",
text: "caption",
mediaUrl: "/tmp/workspace/image.png",
mediaLocalRoots,
accountId: "default",
deps: { sendSlack },
});
expect(sendSlack).toHaveBeenCalledWith(
"C999",
"caption",
expect.objectContaining({
mediaUrl: "/tmp/workspace/image.png",
mediaLocalRoots,
}),
);
expect(result).toEqual({ channel: "slack", messageId: "m-media-local" });
});
it("sends block payload media first, then the final block message", async () => {
const sendSlack = vi
.fn()
.mockResolvedValueOnce({ messageId: "m-media-1" })
.mockResolvedValueOnce({ messageId: "m-media-2" })
.mockResolvedValueOnce({ messageId: "m-final" });
const sendPayload = requireSlackSendPayload();
const result = await sendPayload({
cfg,
to: "C999",
text: "",
payload: {
text: "hello",
mediaUrls: ["https://example.com/1.png", "https://example.com/2.png"],
channelData: {
slack: {
blocks: [
{
type: "section",
text: {
type: "plain_text",
text: "Block body",
},
},
],
},
},
},
accountId: "default",
deps: { sendSlack },
mediaLocalRoots: ["/tmp/media"],
});
expect(sendSlack).toHaveBeenCalledTimes(3);
expect(sendSlack).toHaveBeenNthCalledWith(
1,
"C999",
"",
expect.objectContaining({
mediaUrl: "https://example.com/1.png",
mediaLocalRoots: ["/tmp/media"],
}),
);
expect(sendSlack).toHaveBeenNthCalledWith(
2,
"C999",
"",
expect.objectContaining({
mediaUrl: "https://example.com/2.png",
mediaLocalRoots: ["/tmp/media"],
}),
);
expect(sendSlack).toHaveBeenNthCalledWith(
3,
"C999",
"hello",
expect.objectContaining({
blocks: [
{
type: "section",
text: {
type: "plain_text",
text: "Block body",
},
},
],
}),
);
expect(result).toEqual({ channel: "slack", messageId: "m-final" });
});
it("renders shared interactive payloads into Slack Block Kit via plugin outbound", async () => {
const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-interactive" });
const sendPayload = requireSlackSendPayload();
const result = await sendPayload({
cfg,
to: "user:U123",
text: "",
payload: {
text: "Slack interactive smoke.",
interactive: {
blocks: [
{
type: "text",
text: "Slack interactive smoke.",
},
{
type: "buttons",
buttons: [
{ label: "Approve", value: "approve" },
{ label: "Reject", value: "reject" },
],
},
{
type: "select",
placeholder: "Choose a target",
options: [
{ label: "Canary", value: "canary" },
{ label: "Production", value: "production" },
],
},
],
},
},
accountId: "default",
deps: { sendSlack },
});
expect(sendSlack).toHaveBeenCalledWith(
"user:U123",
"Slack interactive smoke.",
expect.objectContaining({
blocks: [
expect.objectContaining({
type: "section",
}),
expect.objectContaining({
type: "actions",
elements: [
expect.objectContaining({ type: "button", value: "approve" }),
expect.objectContaining({ type: "button", value: "reject" }),
],
}),
expect.objectContaining({
type: "actions",
elements: [
expect.objectContaining({
type: "static_select",
options: [
expect.objectContaining({ value: "canary" }),
expect.objectContaining({ value: "production" }),
],
}),
],
}),
],
}),
);
expect(result).toEqual({ channel: "slack", messageId: "m-interactive" });
});
});
describe("slackPlugin directory", () => {
it("lists configured peers without throwing a ReferenceError", async () => {
const listPeers = requireSlackListPeers();
await expect(
listPeers({
cfg: {
channels: {
slack: {
dms: {
U123: {},
},
},
},
},
runtime: createRuntimeEnv(),
}),
).resolves.toEqual([{ id: "user:u123", kind: "user" }]);
});
});
describe("slackPlugin agentPrompt", () => {
it("tells agents interactive replies are disabled by default", () => {
const hints = slackPlugin.agentPrompt?.messageToolHints?.({
cfg: {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
},
},
});
expect(hints).toEqual([
"- Slack interactive replies are disabled. If needed, ask to set `channels.slack.capabilities.interactiveReplies=true` (or the same under `channels.slack.accounts.<account>.capabilities`).",
]);
});
it("shows Slack interactive reply directives when enabled", () => {
const hints = slackPlugin.agentPrompt?.messageToolHints?.({
cfg: {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
capabilities: { interactiveReplies: true },
},
},
},
});
expect(hints).toContain(
"- Prefer Slack buttons/selects for 2-5 discrete choices or parameter picks instead of asking the user to type one.",
);
expect(hints).toContain(
"- Slack interactive replies: use `[[slack_buttons: Label:value, Other:other]]` to add action buttons that route clicks back as Slack interaction system events.",
);
expect(hints).toContain(
"- Slack selects: use `[[slack_select: Placeholder | Label:value, Other:other]]` to add a static select menu that routes the chosen value back as a Slack interaction system event.",
);
});
});
describe("slackPlugin outbound new targets", () => {
const cfg = {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
},
};
it("sends to a new user target via DM without erroring", async () => {
const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-new-user", channelId: "D999" });
const sendText = requireSlackSendText();
const result = await sendText({
cfg,
to: "user:U99NEW",
text: "hello new user",
accountId: "default",
deps: { sendSlack },
});
expect(sendSlack).toHaveBeenCalledWith(
"user:U99NEW",
"hello new user",
expect.objectContaining({ cfg }),
);
expect(result).toEqual({ channel: "slack", messageId: "m-new-user", channelId: "D999" });
});
it("sends to a new channel target without erroring", async () => {
const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-new-chan", channelId: "C555" });
const sendText = requireSlackSendText();
const result = await sendText({
cfg,
to: "channel:C555NEW",
text: "hello channel",
accountId: "default",
deps: { sendSlack },
});
expect(sendSlack).toHaveBeenCalledWith(
"channel:C555NEW",
"hello channel",
expect.objectContaining({ cfg }),
);
expect(result).toEqual({ channel: "slack", messageId: "m-new-chan", channelId: "C555" });
});
it("sends media to a new user target without erroring", async () => {
const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-new-media", channelId: "D888" });
const sendMedia = requireSlackSendMedia();
const result = await sendMedia({
cfg,
to: "user:U88NEW",
text: "here is a file",
mediaUrl: "https://example.com/file.png",
accountId: "default",
deps: { sendSlack },
});
expect(sendSlack).toHaveBeenCalledWith(
"user:U88NEW",
"here is a file",
expect.objectContaining({
cfg,
mediaUrl: "https://example.com/file.png",
}),
);
expect(result).toEqual({ channel: "slack", messageId: "m-new-media", channelId: "D888" });
});
});
describe("slackPlugin config", () => {
it("treats HTTP mode accounts with bot token + signing secret as configured", async () => {
const cfg: OpenClawConfig = {
channels: {
slack: {
mode: "http",
botToken: "xoxb-http",
signingSecret: "secret-http", // pragma: allowlist secret
},
},
};
const { configured, snapshot } = await getSlackConfiguredState(cfg);
expect(configured).toBe(true);
expect(snapshot?.configured).toBe(true);
});
it("keeps socket mode requiring app token", async () => {
const cfg: OpenClawConfig = {
channels: {
slack: {
mode: "socket",
botToken: "xoxb-socket",
},
},
};
const { configured, snapshot } = await getSlackConfiguredState(cfg);
expect(configured).toBe(false);
expect(snapshot?.configured).toBe(false);
});
it("does not mark partial configured-unavailable token status as configured", async () => {
const snapshot = await slackPlugin.status?.buildAccountSnapshot?.({
account: {
accountId: "default",
name: "Default",
enabled: true,
configured: false,
botTokenStatus: "configured_unavailable",
appTokenStatus: "missing",
botTokenSource: "config",
appTokenSource: "none",
config: {},
} as never,
cfg: {} as OpenClawConfig,
runtime: undefined,
});
expect(snapshot?.configured).toBe(false);
expect(snapshot?.botTokenStatus).toBe("configured_unavailable");
expect(snapshot?.appTokenStatus).toBe("missing");
});
it("keeps HTTP mode signing-secret unavailable accounts configured in snapshots", async () => {
const snapshot = await slackPlugin.status?.buildAccountSnapshot?.({
account: {
accountId: "default",
name: "Default",
enabled: true,
configured: true,
mode: "http",
botTokenStatus: "available",
signingSecretStatus: "configured_unavailable", // pragma: allowlist secret
botTokenSource: "config",
signingSecretSource: "config", // pragma: allowlist secret
config: {
mode: "http",
botToken: "xoxb-http",
signingSecret: { source: "env", provider: "default", id: "SLACK_SIGNING_SECRET" },
},
} as never,
cfg: {} as OpenClawConfig,
runtime: undefined,
});
expect(snapshot?.configured).toBe(true);
expect(snapshot?.botTokenStatus).toBe("available");
expect(snapshot?.signingSecretStatus).toBe("configured_unavailable");
});
});

View file

@ -0,0 +1,664 @@
import {
buildLegacyDmAccountAllowlistAdapter,
createAccountScopedAllowlistNameResolver,
createFlatAllowlistOverrideResolver,
} from "openclaw/plugin-sdk/allowlist-config-edit";
import {
adaptScopedAccountAccessor,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import {
createChannelDirectoryAdapter,
createRuntimeDirectoryLiveAdapter,
} from "openclaw/plugin-sdk/directory-runtime";
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/outbound-runtime";
import {
buildOutboundBaseSessionKey,
normalizeOutboundThreadId,
resolveThreadSessionKeys,
type RoutePeer,
} from "openclaw/plugin-sdk/routing";
import {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import { resolveTargetsWithOptionalToken } from "openclaw/plugin-sdk/target-resolver-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
resolveDefaultSlackAccountId,
resolveSlackAccount,
resolveSlackReplyToMode,
type ResolvedSlackAccount,
} from "./accounts.js";
import type { SlackActionContext } from "./action-runtime.js";
import { resolveSlackAutoThreadId } from "./action-threading.js";
import { slackApprovalCapability } from "./approval-native.js";
import { createSlackActions } from "./channel-actions.js";
import {
DEFAULT_ACCOUNT_ID,
looksLikeSlackTargetId,
normalizeSlackMessagingTarget,
PAIRING_APPROVED_MESSAGE,
projectCredentialSnapshotFields,
resolveConfiguredFromRequiredCredentialStatuses,
type ChannelPlugin,
type OpenClawConfig,
} from "./channel-api.js";
import { resolveSlackChannelType } from "./channel-type.js";
import { shouldSuppressLocalSlackExecApprovalPrompt } from "./exec-approvals.js";
import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js";
import {
compileSlackInteractiveReplies,
isSlackInteractiveRepliesEnabled,
} from "./interactive-replies.js";
import { SLACK_TEXT_LIMIT } from "./limits.js";
import { slackOutbound } from "./outbound-adapter.js";
import type { SlackProbe } from "./probe.js";
import { resolveSlackReplyBlocks } from "./reply-blocks.js";
import { getOptionalSlackRuntime, getSlackRuntime } from "./runtime.js";
import { fetchSlackScopes } from "./scopes.js";
import { collectSlackSecurityAuditFindings } from "./security-audit.js";
import { slackSetupAdapter } from "./setup-core.js";
import { slackSetupWizard } from "./setup-surface.js";
import {
createSlackPluginBase,
isSlackPluginAccountConfigured,
SLACK_CHANNEL,
slackConfigAdapter,
} from "./shared.js";
import { parseSlackTarget } from "./target-parsing.js";
import { buildSlackThreadingToolContext } from "./threading-tool-context.js";
const resolveSlackDmPolicy = createScopedDmSecurityResolver<ResolvedSlackAccount>({
channelKey: "slack",
resolvePolicy: (account) => account.dm?.policy,
resolveAllowFrom: (account) => account.dm?.allowFrom,
allowFromPathSuffix: "dm.",
normalizeEntry: (raw) =>
raw
.trim()
.replace(/^(slack|user):/i, "")
.trim(),
});
async function resolveSlackHandleAction() {
return (
getOptionalSlackRuntime()?.channel?.slack?.handleSlackAction ??
(await loadSlackActionRuntime()).handleSlackAction
);
}
function shouldTreatSlackDeliveredTextAsVisible(params: {
kind: "tool" | "block" | "final";
text?: string;
}): boolean {
return (
params.kind === "block" && typeof params.text === "string" && params.text.trim().length > 0
);
}
// Select the appropriate Slack token for read/write operations.
function getTokenForOperation(
account: ResolvedSlackAccount,
operation: "read" | "write",
): string | undefined {
const userToken = normalizeOptionalString(account.config.userToken);
const botToken = normalizeOptionalString(account.botToken);
const allowUserWrites = account.config.userTokenReadOnly === false;
if (operation === "read") {
return userToken ?? botToken;
}
if (!allowUserWrites) {
return botToken;
}
return botToken ?? userToken;
}
type SlackSendFn = typeof import("./send.runtime.js").sendMessageSlack;
let slackActionRuntimePromise: Promise<typeof import("./action-runtime.runtime.js")> | undefined;
let slackSendRuntimePromise: Promise<typeof import("./send.runtime.js")> | undefined;
let slackProbeModulePromise: Promise<typeof import("./probe.js")> | undefined;
let slackMonitorModulePromise: Promise<typeof import("./monitor.js")> | undefined;
let slackDirectoryLiveModulePromise: Promise<typeof import("./directory-live.js")> | undefined;
const loadSlackDirectoryConfigModule = createLazyRuntimeModule(
() => import("./directory-config.js"),
);
const loadSlackResolveChannelsModule = createLazyRuntimeModule(
() => import("./resolve-channels.js"),
);
const loadSlackResolveUsersModule = createLazyRuntimeModule(() => import("./resolve-users.js"));
async function loadSlackActionRuntime() {
slackActionRuntimePromise ??= import("./action-runtime.runtime.js");
return await slackActionRuntimePromise;
}
async function loadSlackSendRuntime() {
slackSendRuntimePromise ??= import("./send.runtime.js");
return await slackSendRuntimePromise;
}
async function loadSlackProbeModule() {
slackProbeModulePromise ??= import("./probe.js");
return await slackProbeModulePromise;
}
async function loadSlackMonitorModule() {
slackMonitorModulePromise ??= import("./monitor.js");
return await slackMonitorModulePromise;
}
async function loadSlackDirectoryLiveModule() {
slackDirectoryLiveModulePromise ??= import("./directory-live.js");
return await slackDirectoryLiveModulePromise;
}
async function resolveSlackSendContext(params: {
cfg: Parameters<typeof resolveSlackAccount>[0]["cfg"];
accountId?: string;
deps?: { [channelId: string]: unknown };
replyToId?: string | number | null;
threadId?: string | number | null;
}) {
const send =
resolveOutboundSendDep<SlackSendFn>(params.deps, "slack") ??
(await loadSlackSendRuntime()).sendMessageSlack;
// params.cfg is the scoped channel-dispatch config; channel credentials are
// expected to be resolved here (not a raw loadConfig() snapshot). Strict mode
// is intentional so boot-time misconfigurations surface loudly. See #68237
// for the companion tolerant-mode path in sendMessageSlack itself.
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
const token = getTokenForOperation(account, "write");
const botToken = account.botToken?.trim();
const tokenOverride = token && token !== botToken ? token : undefined;
const threadTsValue = params.replyToId ?? params.threadId;
return { send, threadTsValue, tokenOverride };
}
function parseSlackExplicitTarget(raw: string) {
const target = parseSlackTarget(raw, { defaultKind: "channel" });
if (!target) {
return null;
}
return {
to: target.id,
chatType: target.kind === "user" ? ("direct" as const) : ("channel" as const),
};
}
function buildSlackBaseSessionKey(params: {
cfg: OpenClawConfig;
agentId: string;
accountId?: string | null;
peer: RoutePeer;
}) {
return buildOutboundBaseSessionKey({ ...params, channel: "slack" });
}
async function resolveSlackOutboundSessionRoute(params: {
cfg: OpenClawConfig;
agentId: string;
accountId?: string | null;
target: string;
replyToId?: string | null;
threadId?: string | number | null;
}) {
const parsed = parseSlackTarget(params.target, { defaultKind: "channel" });
if (!parsed) {
return null;
}
const isDm = parsed.kind === "user";
let peerKind: "direct" | "channel" | "group" = isDm ? "direct" : "channel";
if (!isDm && /^G/i.test(parsed.id)) {
const channelType = await resolveSlackChannelType({
cfg: params.cfg,
accountId: params.accountId,
channelId: parsed.id,
});
if (channelType === "group") {
peerKind = "group";
}
if (channelType === "dm") {
peerKind = "direct";
}
}
const peer: RoutePeer = {
kind: peerKind,
id: parsed.id,
};
const baseSessionKey = buildSlackBaseSessionKey({
cfg: params.cfg,
agentId: params.agentId,
accountId: params.accountId,
peer,
});
const threadId = normalizeOutboundThreadId(params.threadId ?? params.replyToId);
const threadKeys = resolveThreadSessionKeys({
baseSessionKey,
threadId,
});
return {
sessionKey: threadKeys.sessionKey,
baseSessionKey,
peer,
chatType: peerKind === "direct" ? ("direct" as const) : ("channel" as const),
from:
peerKind === "direct"
? `slack:${parsed.id}`
: peerKind === "group"
? `slack:group:${parsed.id}`
: `slack:channel:${parsed.id}`,
to: peerKind === "direct" ? `user:${parsed.id}` : `channel:${parsed.id}`,
threadId,
};
}
function formatSlackScopeDiagnostic(params: {
tokenType: "bot" | "user";
result: Awaited<ReturnType<typeof fetchSlackScopes>>;
}) {
const source = params.result.source ? ` (${params.result.source})` : "";
const label = params.tokenType === "user" ? "User scopes" : "Bot scopes";
if (params.result.ok && params.result.scopes?.length) {
return { text: `${label}${source}: ${params.result.scopes.join(", ")}` } as const;
}
return {
text: `${label}: ${params.result.error ?? "scope lookup failed"}`,
tone: "error",
} as const;
}
const resolveSlackAllowlistGroupOverrides = createFlatAllowlistOverrideResolver({
resolveRecord: (account: ResolvedSlackAccount) => account.channels,
label: (key) => key,
resolveEntries: (value) => value?.users,
});
const resolveSlackAllowlistNames = createAccountScopedAllowlistNameResolver({
resolveAccount: resolveSlackAccount,
resolveToken: (account: ResolvedSlackAccount) =>
normalizeOptionalString(account.config.userToken) ?? normalizeOptionalString(account.botToken),
resolveNames: async ({ token, entries }) =>
(await loadSlackResolveUsersModule()).resolveSlackUserAllowlist({ token, entries }),
});
const collectSlackSecurityWarnings =
createOpenProviderConfiguredRouteWarningCollector<ResolvedSlackAccount>({
providerConfigPresent: (cfg) => cfg.channels?.slack !== undefined,
resolveGroupPolicy: (account) => account.config.groupPolicy,
resolveRouteAllowlistConfigured: (account) =>
Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0,
configureRouteAllowlist: {
surface: "Slack channels",
openScope: "any channel not explicitly denied",
groupPolicyPath: "channels.slack.groupPolicy",
routeAllowlistPath: "channels.slack.channels",
},
missingRouteAllowlist: {
surface: "Slack channels",
openBehavior: "with no channel allowlist; any channel can trigger (mention-gated)",
remediation:
'Set channels.slack.groupPolicy="allowlist" and configure channels.slack.channels',
},
});
export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = createChatChannelPlugin<
ResolvedSlackAccount,
SlackProbe
>({
base: {
...createSlackPluginBase({
setupWizard: slackSetupWizard,
setup: slackSetupAdapter,
}),
allowlist: {
...buildLegacyDmAccountAllowlistAdapter({
channelId: "slack",
resolveAccount: resolveSlackAccount,
normalize: ({ cfg, accountId, values }) =>
slackConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
resolveDmAllowFrom: (account) => account.config.allowFrom ?? account.config.dm?.allowFrom,
resolveGroupPolicy: (account) => account.groupPolicy,
resolveGroupOverrides: resolveSlackAllowlistGroupOverrides,
}),
resolveNames: resolveSlackAllowlistNames,
},
approvalCapability: slackApprovalCapability,
groups: {
resolveRequireMention: resolveSlackGroupRequireMention,
resolveToolPolicy: resolveSlackGroupToolPolicy,
},
messaging: {
normalizeTarget: normalizeSlackMessagingTarget,
resolveDeliveryTarget: ({ conversationId, parentConversationId }) => {
const parent = parentConversationId?.trim();
const child = conversationId.trim();
return parent && parent !== child
? { to: `channel:${parent}`, threadId: child }
: { to: normalizeSlackMessagingTarget(`channel:${child}`) };
},
resolveSessionTarget: ({ id }) => normalizeSlackMessagingTarget(`channel:${id}`),
parseExplicitTarget: ({ raw }) => parseSlackExplicitTarget(raw),
inferTargetChatType: ({ to }) => parseSlackExplicitTarget(to)?.chatType,
resolveOutboundSessionRoute: async (params) => await resolveSlackOutboundSessionRoute(params),
transformReplyPayload: ({ payload, cfg, accountId }) =>
isSlackInteractiveRepliesEnabled({ cfg, accountId })
? compileSlackInteractiveReplies(payload)
: payload,
enableInteractiveReplies: ({ cfg, accountId }) =>
isSlackInteractiveRepliesEnabled({ cfg, accountId }),
hasStructuredReplyPayload: ({ payload }) => {
try {
return Boolean(resolveSlackReplyBlocks(payload)?.length);
} catch {
return false;
}
},
targetResolver: {
looksLikeId: looksLikeSlackTargetId,
hint: "<channelId|user:ID|channel:ID>",
resolveTarget: async ({ input }) => {
const parsed = parseSlackExplicitTarget(input);
if (!parsed) {
return null;
}
return {
to: parsed.to,
kind: parsed.chatType === "direct" ? "user" : "group",
source: "normalized",
};
},
},
},
directory: createChannelDirectoryAdapter({
listPeers: async (params) =>
(await loadSlackDirectoryConfigModule()).listSlackDirectoryPeersFromConfig(params),
listGroups: async (params) =>
(await loadSlackDirectoryConfigModule()).listSlackDirectoryGroupsFromConfig(params),
...createRuntimeDirectoryLiveAdapter({
getRuntime: loadSlackDirectoryLiveModule,
listPeersLive: (runtime) => runtime.listSlackDirectoryPeersLive,
listGroupsLive: (runtime) => runtime.listSlackDirectoryGroupsLive,
}),
}),
resolver: {
resolveTargets: async ({ cfg, accountId, inputs, kind }) => {
const toResolvedTarget = (
entry: { input: string; resolved: boolean; id?: string; name?: string },
note?: string,
) => ({
input: entry.input,
resolved: entry.resolved,
id: entry.id,
name: entry.name,
note,
});
const account = resolveSlackAccount({ cfg, accountId });
if (kind === "group") {
return resolveTargetsWithOptionalToken({
token:
normalizeOptionalString(account.config.userToken) ??
normalizeOptionalString(account.botToken),
inputs,
missingTokenNote: "missing Slack token",
resolveWithToken: async ({ token, inputs }) =>
(await loadSlackResolveChannelsModule()).resolveSlackChannelAllowlist({
token,
entries: inputs,
}),
mapResolved: (entry) =>
toResolvedTarget(entry, entry.archived ? "archived" : undefined),
});
}
return resolveTargetsWithOptionalToken({
token:
normalizeOptionalString(account.config.userToken) ??
normalizeOptionalString(account.botToken),
inputs,
missingTokenNote: "missing Slack token",
resolveWithToken: async ({ token, inputs }) =>
(await loadSlackResolveUsersModule()).resolveSlackUserAllowlist({
token,
entries: inputs,
}),
mapResolved: (entry) => toResolvedTarget(entry, entry.note),
});
},
},
actions: createSlackActions(SLACK_CHANNEL, {
invoke: async (action, cfg, toolContext) =>
await (
await resolveSlackHandleAction()
)(action, cfg as OpenClawConfig, toolContext as SlackActionContext | undefined),
}),
status: createComputedAccountStatusAdapter<ResolvedSlackAccount, SlackProbe>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
buildChannelSummary: ({ snapshot }) =>
buildPassiveProbedChannelStatusSummary(snapshot, {
botTokenSource: snapshot.botTokenSource ?? "none",
appTokenSource: snapshot.appTokenSource ?? "none",
}),
probeAccount: async ({ account, timeoutMs }) => {
const token = account.botToken?.trim();
if (!token) {
return { ok: false, error: "missing token" };
}
return await (await loadSlackProbeModule()).probeSlack(token, timeoutMs);
},
formatCapabilitiesProbe: ({ probe }) => {
const slackProbe = probe as SlackProbe | undefined;
const lines = [];
if (slackProbe?.bot?.name) {
lines.push({ text: `Bot: @${slackProbe.bot.name}` });
}
if (slackProbe?.team?.name || slackProbe?.team?.id) {
const id = slackProbe.team?.id ? ` (${slackProbe.team.id})` : "";
lines.push({ text: `Team: ${slackProbe.team?.name ?? "unknown"}${id}` });
}
return lines;
},
buildCapabilitiesDiagnostics: async ({ account, timeoutMs }) => {
const lines = [];
const details: Record<string, unknown> = {};
const botToken = account.botToken?.trim();
const userToken = account.config.userToken?.trim();
const botScopes = botToken
? await fetchSlackScopes(botToken, timeoutMs)
: { ok: false, error: "Slack bot token missing." };
lines.push(formatSlackScopeDiagnostic({ tokenType: "bot", result: botScopes }));
details.botScopes = botScopes;
if (userToken) {
const userScopes = await fetchSlackScopes(userToken, timeoutMs);
lines.push(formatSlackScopeDiagnostic({ tokenType: "user", result: userScopes }));
details.userScopes = userScopes;
}
return { lines, details };
},
resolveAccountSnapshot: ({ account }) => {
const mode = account.config.mode ?? "socket";
const configured =
(mode === "http"
? resolveConfiguredFromRequiredCredentialStatuses(account, [
"botTokenStatus",
"signingSecretStatus",
])
: resolveConfiguredFromRequiredCredentialStatuses(account, [
"botTokenStatus",
"appTokenStatus",
])) ?? isSlackPluginAccountConfigured(account);
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
extra: {
...projectCredentialSnapshotFields(account),
},
};
},
}),
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
const botToken = account.botToken?.trim();
const appToken = account.appToken?.trim();
ctx.log?.info(`[${account.accountId}] starting provider`);
return (await loadSlackMonitorModule()).monitorSlackProvider({
botToken: botToken ?? "",
appToken: appToken ?? "",
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
channelRuntime: ctx.channelRuntime,
abortSignal: ctx.abortSignal,
mediaMaxMb: account.config.mediaMaxMb,
slashCommand: account.config.slashCommand,
setStatus: ctx.setStatus as (next: Record<string, unknown>) => void,
getStatus: ctx.getStatus as () => Record<string, unknown>,
});
},
},
mentions: {
stripPatterns: () => ["<@[^>\\s]+>"],
},
},
pairing: {
text: {
idLabel: "slackUserId",
message: PAIRING_APPROVED_MESSAGE,
normalizeAllowEntry: createPairingPrefixStripper(/^(slack|user):/i),
notify: async ({ id, message }) => {
const cfg = getSlackRuntime().config.loadConfig();
const account = resolveSlackAccount({
cfg,
accountId: resolveDefaultSlackAccountId(cfg),
});
const { sendMessageSlack } = await loadSlackSendRuntime();
const token = getTokenForOperation(account, "write");
const botToken = account.botToken?.trim();
const tokenOverride = token && token !== botToken ? token : undefined;
if (tokenOverride) {
await sendMessageSlack(`user:${id}`, message, {
token: tokenOverride,
});
} else {
await sendMessageSlack(`user:${id}`, message);
}
},
},
},
security: {
resolveDmPolicy: resolveSlackDmPolicy,
collectWarnings: collectSlackSecurityWarnings,
collectAuditFindings: collectSlackSecurityAuditFindings,
},
threading: {
scopedAccountReplyToMode: {
resolveAccount: adaptScopedAccountAccessor(resolveSlackAccount),
resolveReplyToMode: (account, chatType) => resolveSlackReplyToMode(account, chatType),
},
allowExplicitReplyTagsWhenOff: false,
buildToolContext: (params) => buildSlackThreadingToolContext(params),
resolveAutoThreadId: ({ to, toolContext, replyToId }) =>
replyToId
? undefined
: resolveSlackAutoThreadId({
to,
toolContext,
}),
resolveReplyTransport: ({ threadId, replyToId }) => ({
replyToId: replyToId ?? (threadId != null && threadId !== "" ? String(threadId) : undefined),
threadId: null,
}),
},
outbound: {
base: {
deliveryMode: "direct",
chunker: null,
textChunkLimit: SLACK_TEXT_LIMIT,
shouldTreatDeliveredTextAsVisible: shouldTreatSlackDeliveredTextAsVisible,
shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload }) =>
shouldSuppressLocalSlackExecApprovalPrompt({
cfg,
accountId,
payload,
}),
sendPayload: async (ctx) => {
const { send, tokenOverride } = await resolveSlackSendContext({
cfg: ctx.cfg,
accountId: ctx.accountId ?? undefined,
deps: ctx.deps,
replyToId: ctx.replyToId,
threadId: ctx.threadId,
});
return await slackOutbound.sendPayload!({
...ctx,
deps: {
...ctx.deps,
slack: async (
to: Parameters<SlackSendFn>[0],
text: Parameters<SlackSendFn>[1],
opts: Parameters<SlackSendFn>[2],
) =>
await send(to, text, {
...opts,
...(tokenOverride ? { token: tokenOverride } : {}),
}),
},
});
},
},
attachedResults: {
channel: "slack",
sendText: async ({ to, text, accountId, deps, replyToId, threadId, cfg }) => {
const { send, threadTsValue, tokenOverride } = await resolveSlackSendContext({
cfg,
accountId: accountId ?? undefined,
deps,
replyToId,
threadId,
});
return await send(to, text, {
cfg,
threadTs: threadTsValue != null ? String(threadTsValue) : undefined,
accountId: accountId ?? undefined,
...(tokenOverride ? { token: tokenOverride } : {}),
});
},
sendMedia: async ({
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
deps,
replyToId,
threadId,
cfg,
}) => {
const { send, threadTsValue, tokenOverride } = await resolveSlackSendContext({
cfg,
accountId: accountId ?? undefined,
deps,
replyToId,
threadId,
});
return await send(to, text, {
cfg,
mediaUrl,
mediaLocalRoots,
threadTs: threadTsValue != null ? String(threadTsValue) : undefined,
accountId: accountId ?? undefined,
...(tokenOverride ? { token: tokenOverride } : {}),
});
},
},
},
});

View file

@ -0,0 +1,217 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@slack/web-api", () => {
const WebClient = vi.fn(function WebClientMock(
this: Record<string, unknown>,
token: string,
options?: Record<string, unknown>,
) {
this.token = token;
this.options = options;
});
return { WebClient };
});
let createSlackWebClient: typeof import("./client.js").createSlackWebClient;
let createSlackWriteClient: typeof import("./client.js").createSlackWriteClient;
let resolveSlackWebClientOptions: typeof import("./client.js").resolveSlackWebClientOptions;
let resolveSlackWriteClientOptions: typeof import("./client.js").resolveSlackWriteClientOptions;
let SLACK_DEFAULT_RETRY_OPTIONS: typeof import("./client.js").SLACK_DEFAULT_RETRY_OPTIONS;
let SLACK_WRITE_RETRY_OPTIONS: typeof import("./client.js").SLACK_WRITE_RETRY_OPTIONS;
let WebClient: ReturnType<typeof vi.fn>;
beforeAll(async () => {
const slackWebApi = await import("@slack/web-api");
({
createSlackWebClient,
createSlackWriteClient,
resolveSlackWebClientOptions,
resolveSlackWriteClientOptions,
SLACK_DEFAULT_RETRY_OPTIONS,
SLACK_WRITE_RETRY_OPTIONS,
} = await import("./client.js"));
WebClient = slackWebApi.WebClient as unknown as ReturnType<typeof vi.fn>;
});
beforeEach(() => {
WebClient.mockClear();
});
describe("slack web client config", () => {
it("applies the default retry config when none is provided", () => {
const options = resolveSlackWebClientOptions();
expect(options.retryConfig).toEqual(SLACK_DEFAULT_RETRY_OPTIONS);
});
it("respects explicit retry config overrides", () => {
const customRetry = { retries: 0 };
const options = resolveSlackWebClientOptions({ retryConfig: customRetry });
expect(options.retryConfig).toBe(customRetry);
});
it("passes merged options into WebClient", () => {
createSlackWebClient("xoxb-test", { timeout: 1234 });
expect(WebClient).toHaveBeenCalledWith(
"xoxb-test",
expect.objectContaining({
timeout: 1234,
retryConfig: SLACK_DEFAULT_RETRY_OPTIONS,
}),
);
});
it("applies the write retry config when none is provided", () => {
const options = resolveSlackWriteClientOptions();
expect(options.retryConfig).toEqual(SLACK_WRITE_RETRY_OPTIONS);
});
it("passes no-retry config into the write client by default", () => {
createSlackWriteClient("xoxb-test", { timeout: 4321 });
expect(WebClient).toHaveBeenCalledWith(
"xoxb-test",
expect.objectContaining({
timeout: 4321,
retryConfig: SLACK_WRITE_RETRY_OPTIONS,
}),
);
});
});
describe("slack proxy agent", () => {
const originalEnv = { ...process.env };
const PROXY_KEYS = [
"HTTPS_PROXY",
"HTTP_PROXY",
"https_proxy",
"http_proxy",
"NO_PROXY",
"no_proxy",
];
beforeEach(() => {
for (const key of PROXY_KEYS) {
delete process.env[key];
}
});
afterEach(() => {
for (const key of PROXY_KEYS) {
if (originalEnv[key] !== undefined) {
process.env[key] = originalEnv[key];
} else {
delete process.env[key];
}
}
});
it("sets agent from HTTPS_PROXY env var", () => {
process.env.HTTPS_PROXY = "http://proxy.example.com:3128";
const options = resolveSlackWebClientOptions();
expect(options.agent).toBeDefined();
expect(options.agent!.constructor.name).toBe("HttpsProxyAgent");
});
it("falls back to HTTP_PROXY when HTTPS_PROXY is not set", () => {
process.env.HTTP_PROXY = "http://proxy.example.com:3128";
const options = resolveSlackWebClientOptions();
expect(options.agent).toBeDefined();
});
it("does not set agent when no proxy env var is configured", () => {
const options = resolveSlackWebClientOptions();
expect(options.agent).toBeUndefined();
});
it("does not override an explicitly provided agent", () => {
process.env.HTTPS_PROXY = "http://proxy.example.com:3128";
const customAgent = {} as never;
const options = resolveSlackWebClientOptions({ agent: customAgent });
expect(options.agent).toBe(customAgent);
});
it("prefers lowercase https_proxy over uppercase", () => {
process.env.https_proxy = "http://lower.example.com:3128";
process.env.HTTPS_PROXY = "http://upper.example.com:3128";
const options = resolveSlackWebClientOptions();
expect(options.agent).toBeDefined();
// HttpsProxyAgent stores the proxy URL — verify it picked the lower-case one
expect((options.agent as unknown as { proxy: { href: string } }).proxy.href).toContain(
"lower.example.com",
);
});
it("treats empty lowercase https_proxy as authoritative over uppercase", () => {
process.env.https_proxy = "";
process.env.HTTPS_PROXY = "http://upper.example.com:3128";
const options = resolveSlackWebClientOptions();
expect(options.agent).toBeUndefined();
});
it("also applies proxy agent to write client options", () => {
process.env.HTTPS_PROXY = "http://proxy.example.com:3128";
const options = resolveSlackWriteClientOptions();
expect(options.agent).toBeDefined();
expect(options.agent!.constructor.name).toBe("HttpsProxyAgent");
});
it("respects NO_PROXY excluding slack.com", () => {
process.env.HTTPS_PROXY = "http://proxy.example.com:3128";
process.env.NO_PROXY = "localhost,slack.com,.internal.corp";
const options = resolveSlackWebClientOptions();
expect(options.agent).toBeUndefined();
});
it("respects no_proxy (lowercase) excluding .slack.com", () => {
process.env.HTTPS_PROXY = "http://proxy.example.com:3128";
process.env.no_proxy = ".slack.com";
const options = resolveSlackWebClientOptions();
expect(options.agent).toBeUndefined();
});
it("respects space-separated no_proxy entries", () => {
process.env.HTTPS_PROXY = "http://proxy.example.com:3128";
process.env.no_proxy = "localhost *.slack.com";
const options = resolveSlackWebClientOptions();
expect(options.agent).toBeUndefined();
});
it("respects NO_PROXY wildcard", () => {
process.env.HTTPS_PROXY = "http://proxy.example.com:3128";
process.env.NO_PROXY = "*";
const options = resolveSlackWebClientOptions();
expect(options.agent).toBeUndefined();
});
it("does not skip proxy when NO_PROXY excludes unrelated hosts", () => {
process.env.HTTPS_PROXY = "http://proxy.example.com:3128";
process.env.NO_PROXY = "localhost,.internal.corp";
const options = resolveSlackWebClientOptions();
expect(options.agent).toBeDefined();
});
it("degrades gracefully on malformed proxy URL", () => {
process.env.HTTPS_PROXY = "not-a-valid-url://:::bad";
const options = resolveSlackWebClientOptions();
// Should not throw; falls back to no agent
expect(options.agent).toBeUndefined();
});
});

View file

@ -0,0 +1,103 @@
import { type RetryOptions, type WebClientOptions, WebClient } from "@slack/web-api";
import { HttpsProxyAgent } from "https-proxy-agent";
import { resolveEnvHttpProxyUrl } from "openclaw/plugin-sdk/infra-runtime";
export const SLACK_DEFAULT_RETRY_OPTIONS: RetryOptions = {
retries: 2,
factor: 2,
minTimeout: 500,
maxTimeout: 3000,
randomize: true,
};
export const SLACK_WRITE_RETRY_OPTIONS: RetryOptions = {
retries: 0,
};
/**
* Check whether a hostname is excluded from proxying by `NO_PROXY` / `no_proxy`.
* Supports comma-separated entries with optional leading dots (e.g. `.slack.com`).
*/
function isHostExcludedByNoProxy(hostname: string, env: NodeJS.ProcessEnv = process.env): boolean {
const raw = env.no_proxy ?? env.NO_PROXY;
if (!raw) {
return false;
}
const entries = raw
.split(/[,\s]+/)
.map((e) => e.trim().toLowerCase())
.filter(Boolean);
const lower = hostname.toLowerCase();
for (const entry of entries) {
if (entry === "*") {
return true;
}
// Strip optional wildcard/leading dot so `*.slack.com` and `.slack.com`
// match both `slack.com` (apex) and Slack subdomains.
const bare = entry.startsWith("*.")
? entry.slice(2)
: entry.startsWith(".")
? entry.slice(1)
: entry;
if (lower === bare || lower.endsWith(`.${bare}`)) {
return true;
}
}
return false;
}
/**
* Build an HTTPS proxy agent from env vars (HTTPS_PROXY, HTTP_PROXY, etc.)
* for use as the `agent` option in Slack WebClient and Socket Mode connections.
*
* When set, this agent is forwarded through @slack/bolt → @slack/socket-mode →
* SlackWebSocket as the `httpAgent`, which the `ws` library uses to tunnel the
* WebSocket upgrade request through the proxy. This fixes Socket Mode in
* environments where outbound traffic must go through an HTTP CONNECT proxy.
*
* Respects `NO_PROXY` / `no_proxy` — if `*.slack.com` (or a matching pattern)
* appears in the exclusion list, returns `undefined` so the connection is direct.
*
* Returns `undefined` when no proxy env var is configured or when Slack hosts
* are excluded by `NO_PROXY`.
*/
function resolveSlackProxyAgent(): HttpsProxyAgent<string> | undefined {
const proxyUrl = resolveEnvHttpProxyUrl("https");
if (!proxyUrl) {
return undefined;
}
// Slack Socket Mode connects to these hosts; skip proxy if excluded.
if (isHostExcludedByNoProxy("slack.com")) {
return undefined;
}
try {
return new HttpsProxyAgent(proxyUrl);
} catch {
// Malformed proxy URL — degrade gracefully to direct connection.
return undefined;
}
}
export function resolveSlackWebClientOptions(options: WebClientOptions = {}): WebClientOptions {
return {
...options,
agent: options.agent ?? resolveSlackProxyAgent(),
retryConfig: options.retryConfig ?? SLACK_DEFAULT_RETRY_OPTIONS,
};
}
export function resolveSlackWriteClientOptions(options: WebClientOptions = {}): WebClientOptions {
return {
...options,
agent: options.agent ?? resolveSlackProxyAgent(),
retryConfig: options.retryConfig ?? SLACK_WRITE_RETRY_OPTIONS,
};
}
export function createSlackWebClient(token: string, options: WebClientOptions = {}) {
return new WebClient(token, resolveSlackWebClientOptions(options));
}
export function createSlackWriteClient(token: string, options: WebClientOptions = {}) {
return new WebClient(token, resolveSlackWriteClientOptions(options));
}

View file

@ -0,0 +1,6 @@
import { buildChannelConfigSchema, SlackConfigSchema } from "../config-api.js";
import { slackChannelConfigUiHints } from "./config-ui-hints.js";
export const SlackChannelConfigSchema = buildChannelConfigSchema(SlackConfigSchema, {
uiHints: slackChannelConfigUiHints,
});

View file

@ -0,0 +1,116 @@
import type { ChannelConfigUiHint } from "openclaw/plugin-sdk/channel-core";
export const slackChannelConfigUiHints = {
"": {
label: "Slack",
help: "Slack channel provider configuration for bot/app tokens, streaming behavior, and DM policy controls. Keep token handling and thread behavior explicit to avoid noisy workspace interactions.",
},
"dm.policy": {
label: "Slack DM Policy",
help: 'Direct message access control ("pairing" recommended). "open" requires channels.slack.allowFrom=["*"] (legacy: channels.slack.dm.allowFrom).',
},
dmPolicy: {
label: "Slack DM Policy",
help: 'Direct message access control ("pairing" recommended). "open" requires channels.slack.allowFrom=["*"].',
},
configWrites: {
label: "Slack Config Writes",
help: "Allow Slack to write config in response to channel events/commands (default: true).",
},
"commands.native": {
label: "Slack Native Commands",
help: 'Override native commands for Slack (bool or "auto").',
},
"commands.nativeSkills": {
label: "Slack Native Skill Commands",
help: 'Override native skill commands for Slack (bool or "auto").',
},
allowBots: {
label: "Slack Allow Bot Messages",
help: "Allow bot-authored messages to trigger Slack replies (default: false).",
},
botToken: {
label: "Slack Bot Token",
help: "Slack bot token used for standard chat actions in the configured workspace. Keep this credential scoped and rotate if workspace app permissions change.",
},
appToken: {
label: "Slack App Token",
help: "Slack app-level token used for Socket Mode connections and event transport when enabled. Use least-privilege app scopes and store this token as a secret.",
},
userToken: {
label: "Slack User Token",
help: "Optional Slack user token for workflows requiring user-context API access beyond bot permissions. Use sparingly and audit scopes because this token can carry broader authority.",
},
userTokenReadOnly: {
label: "Slack User Token Read Only",
help: "When true, treat configured Slack user token usage as read-only helper behavior where possible. Keep enabled if you only need supplemental reads without user-context writes.",
},
"capabilities.interactiveReplies": {
label: "Slack Interactive Replies",
help: "Enable agent-authored Slack interactive reply directives (`[[slack_buttons: ...]]`, `[[slack_select: ...]]`). Default: false.",
},
execApprovals: {
label: "Slack Exec Approvals",
help: "Slack-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for this workspace account.",
},
"execApprovals.enabled": {
label: "Slack Exec Approvals Enabled",
help: 'Controls Slack native exec approvals for this account: unset or "auto" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them.',
},
"execApprovals.approvers": {
label: "Slack Exec Approval Approvers",
help: "Slack user IDs allowed to approve exec requests for this workspace account. Use Slack user IDs or user targets such as `U123`, `user:U123`, or `<@U123>`. If you leave this unset, OpenClaw falls back to commands.ownerAllowFrom when possible.",
},
"execApprovals.agentFilter": {
label: "Slack Exec Approval Agent Filter",
help: 'Optional allowlist of agent IDs eligible for Slack exec approvals, for example `["main", "ops-agent"]`. Use this to keep approval prompts scoped to the agents you actually operate from Slack.',
},
"execApprovals.sessionFilter": {
label: "Slack Exec Approval Session Filter",
help: "Optional session-key filters matched as substring or regex-style patterns before Slack approval routing is used. Use narrow patterns so Slack approvals only appear for intended sessions.",
},
"execApprovals.target": {
label: "Slack Exec Approval Target",
help: 'Controls where Slack approval prompts are sent: "dm" sends to approver DMs (default), "channel" sends to the originating Slack chat/thread, and "both" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted channels.',
},
streaming: {
label: "Slack Streaming Mode",
help: 'Unified Slack stream preview mode: "off" | "partial" | "block" | "progress". Legacy boolean/streamMode keys are auto-mapped.',
},
"streaming.mode": {
label: "Slack Streaming Mode",
help: 'Canonical Slack preview mode: "off" | "partial" | "block" | "progress".',
},
"streaming.chunkMode": {
label: "Slack Chunk Mode",
help: 'Chunking mode for outbound Slack text delivery: "length" (default) or "newline".',
},
"streaming.block.enabled": {
label: "Slack Block Streaming Enabled",
help: 'Enable chunked block-style Slack preview delivery when channels.slack.streaming.mode="block".',
},
"streaming.block.coalesce": {
label: "Slack Block Streaming Coalesce",
help: "Merge streamed Slack block replies before final delivery.",
},
"streaming.nativeTransport": {
label: "Slack Native Streaming",
help: "Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming.mode is partial (default: true). Requires a reply thread target; top-level DMs stay on the non-thread fallback path.",
},
"thread.historyScope": {
label: "Slack Thread History Scope",
help: 'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).',
},
"thread.inheritParent": {
label: "Slack Thread Parent Inheritance",
help: "If true, Slack thread sessions inherit the parent channel transcript (default: false).",
},
"thread.initialHistoryLimit": {
label: "Slack Thread Initial History Limit",
help: "Maximum number of existing Slack thread messages to fetch when starting a new thread session (default: 20, set to 0 to disable).",
},
"thread.requireExplicitMention": {
label: "Slack Thread Require Explicit Mention",
help: "If true, require an explicit @mention even inside threads where the bot has participated. Suppresses implicit thread mention behavior so the bot only responds to explicit @bot mentions in threads (default: false).",
},
} satisfies Record<string, ChannelConfigUiHint>;

View file

@ -0,0 +1,61 @@
import { normalizeAccountId } from "openclaw/plugin-sdk/account-resolution";
import {
createResolvedDirectoryEntriesLister,
type DirectoryConfigParams,
} from "openclaw/plugin-sdk/directory-config-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { mergeSlackAccountConfig, resolveDefaultSlackAccountId } from "./accounts.js";
import { parseSlackTarget } from "./targets.js";
function resolveSlackDirectoryConfigAccount(
cfg: DirectoryConfigParams["cfg"],
accountId?: string | null,
) {
const resolvedAccountId = normalizeAccountId(accountId ?? resolveDefaultSlackAccountId(cfg));
const config = mergeSlackAccountConfig(cfg, resolvedAccountId);
return {
accountId: resolvedAccountId,
config,
dm: config.dm,
};
}
export const listSlackDirectoryPeersFromConfig = createResolvedDirectoryEntriesLister<
ReturnType<typeof resolveSlackDirectoryConfigAccount>
>({
kind: "user",
resolveAccount: (cfg, accountId) => resolveSlackDirectoryConfigAccount(cfg, accountId),
resolveSources: (account) => {
const allowFrom = account.config.allowFrom ?? account.dm?.allowFrom ?? [];
const channelUsers = Object.values(account.config.channels ?? {}).flatMap(
(channel) => channel.users ?? [],
);
return [allowFrom, Object.keys(account.config.dms ?? {}), channelUsers];
},
normalizeId: (raw) => {
const mention = raw.match(/^<@([A-Z0-9]+)>$/i);
const normalizedUserId = (mention?.[1] ?? raw).replace(/^(slack|user):/i, "").trim();
if (!normalizedUserId) {
return null;
}
const target = `user:${normalizedUserId}`;
const normalized = parseSlackTarget(target, { defaultKind: "user" });
return normalized?.kind === "user"
? `user:${normalizeLowercaseStringOrEmpty(normalized.id)}`
: null;
},
});
export const listSlackDirectoryGroupsFromConfig = createResolvedDirectoryEntriesLister<
ReturnType<typeof resolveSlackDirectoryConfigAccount>
>({
kind: "group",
resolveAccount: (cfg, accountId) => resolveSlackDirectoryConfigAccount(cfg, accountId),
resolveSources: (account) => [Object.keys(account.config.channels ?? {})],
normalizeId: (raw) => {
const normalized = parseSlackTarget(raw, { defaultKind: "channel" });
return normalized?.kind === "channel"
? `channel:${normalizeLowercaseStringOrEmpty(normalized.id)}`
: null;
},
});

View file

@ -0,0 +1,190 @@
import type {
ChannelDirectoryEntry,
DirectoryConfigParams,
} from "openclaw/plugin-sdk/directory-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
normalizeOptionalLowercaseString,
} from "openclaw/plugin-sdk/text-runtime";
import { resolveSlackAccount } from "./accounts.js";
import { createSlackWebClient } from "./client.js";
type SlackUser = {
id?: string;
name?: string;
real_name?: string;
is_bot?: boolean;
is_app_user?: boolean;
deleted?: boolean;
profile?: {
display_name?: string;
real_name?: string;
email?: string;
};
};
type SlackChannel = {
id?: string;
name?: string;
is_archived?: boolean;
is_private?: boolean;
};
type SlackListUsersResponse = {
members?: SlackUser[];
response_metadata?: { next_cursor?: string };
};
type SlackListChannelsResponse = {
channels?: SlackChannel[];
response_metadata?: { next_cursor?: string };
};
function resolveReadToken(params: DirectoryConfigParams): string | undefined {
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
return account.userToken ?? account.botToken?.trim();
}
function normalizeQuery(value?: string | null): string {
return normalizeLowercaseStringOrEmpty(value);
}
function buildUserRank(user: SlackUser): number {
let rank = 0;
if (!user.deleted) {
rank += 2;
}
if (!user.is_bot && !user.is_app_user) {
rank += 1;
}
return rank;
}
function buildChannelRank(channel: SlackChannel): number {
return channel.is_archived ? 0 : 1;
}
export async function listSlackDirectoryPeersLive(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const token = resolveReadToken(params);
if (!token) {
return [];
}
const client = createSlackWebClient(token);
const query = normalizeQuery(params.query);
const members: SlackUser[] = [];
let cursor: string | undefined;
do {
const res = (await client.users.list({
limit: 200,
cursor,
})) as SlackListUsersResponse;
if (Array.isArray(res.members)) {
members.push(...res.members);
}
const next = res.response_metadata?.next_cursor?.trim();
cursor = next ? next : undefined;
} while (cursor);
const filtered = members.filter((member) => {
const name = member.profile?.display_name || member.profile?.real_name || member.real_name;
const handle = member.name;
const email = member.profile?.email;
const candidates = [name, handle, email]
.map((item) => normalizeOptionalLowercaseString(item))
.filter(Boolean);
if (!query) {
return true;
}
return candidates.some((candidate) => candidate?.includes(query));
});
const rows = filtered
.map((member) => {
const id = member.id?.trim();
if (!id) {
return null;
}
const handle = normalizeOptionalString(member.name);
const display =
normalizeOptionalString(member.profile?.display_name) ||
normalizeOptionalString(member.profile?.real_name) ||
normalizeOptionalString(member.real_name) ||
handle;
return {
kind: "user",
id: `user:${id}`,
name: display || undefined,
handle: handle ? `@${handle}` : undefined,
rank: buildUserRank(member),
raw: member,
} satisfies ChannelDirectoryEntry;
})
.filter(Boolean) as ChannelDirectoryEntry[];
if (typeof params.limit === "number" && params.limit > 0) {
return rows.slice(0, params.limit);
}
return rows;
}
export async function listSlackDirectoryGroupsLive(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const token = resolveReadToken(params);
if (!token) {
return [];
}
const client = createSlackWebClient(token);
const query = normalizeQuery(params.query);
const channels: SlackChannel[] = [];
let cursor: string | undefined;
do {
const res = (await client.conversations.list({
types: "public_channel,private_channel",
exclude_archived: false,
limit: 1000,
cursor,
})) as SlackListChannelsResponse;
if (Array.isArray(res.channels)) {
channels.push(...res.channels);
}
const next = res.response_metadata?.next_cursor?.trim();
cursor = next ? next : undefined;
} while (cursor);
const filtered = channels.filter((channel) => {
const name = normalizeOptionalLowercaseString(channel.name);
if (!query) {
return true;
}
return Boolean(name && name.includes(query));
});
const rows = filtered
.map((channel) => {
const id = channel.id?.trim();
const name = channel.name?.trim();
if (!id || !name) {
return null;
}
return {
kind: "group",
id: `channel:${id}`,
name,
handle: `#${name}`,
rank: buildChannelRank(channel),
raw: channel,
} satisfies ChannelDirectoryEntry;
})
.filter(Boolean) as ChannelDirectoryEntry[];
if (typeof params.limit === "number" && params.limit > 0) {
return rows.slice(0, params.limit);
}
return rows;
}

View file

@ -0,0 +1,122 @@
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
hasLegacyAccountStreamingAliases,
hasLegacyStreamingAliases,
normalizeLegacyDmAliases,
normalizeLegacyStreamingAliases,
} from "openclaw/plugin-sdk/runtime-doctor";
import { resolveSlackNativeStreaming, resolveSlackStreamingMode } from "./streaming-compat.js";
function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function hasLegacySlackStreamingAliases(value: unknown): boolean {
return hasLegacyStreamingAliases(value, { includeNativeTransport: true });
}
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "slack"],
message:
"channels.slack.streamMode, channels.slack.streaming (scalar), chunkMode, blockStreaming, blockStreamingCoalesce, and nativeStreaming are legacy; use channels.slack.streaming.{mode,chunkMode,block.enabled,block.coalesce,nativeTransport}.",
match: hasLegacySlackStreamingAliases,
},
{
path: ["channels", "slack", "accounts"],
message:
"channels.slack.accounts.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, blockStreamingCoalesce, and nativeStreaming are legacy; use channels.slack.accounts.<id>.streaming.{mode,chunkMode,block.enabled,block.coalesce,nativeTransport}.",
match: (value) => hasLegacyAccountStreamingAliases(value, hasLegacySlackStreamingAliases),
},
];
export function normalizeCompatibilityConfig({
cfg,
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
const rawEntry = asObjectRecord((cfg.channels as Record<string, unknown> | undefined)?.slack);
if (!rawEntry) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updated = rawEntry;
let changed = false;
const dm = normalizeLegacyDmAliases({
entry: updated,
pathPrefix: "channels.slack",
changes,
});
updated = dm.entry;
changed = changed || dm.changed;
const streaming = normalizeLegacyStreamingAliases({
entry: updated,
pathPrefix: "channels.slack",
changes,
resolvedMode: resolveSlackStreamingMode(updated),
resolvedNativeTransport: resolveSlackNativeStreaming(updated),
});
updated = streaming.entry;
changed = changed || streaming.changed;
const rawAccounts = asObjectRecord(updated.accounts);
if (rawAccounts) {
let accountsChanged = false;
const accounts = { ...rawAccounts };
for (const [accountId, rawAccount] of Object.entries(rawAccounts)) {
const account = asObjectRecord(rawAccount);
if (!account) {
continue;
}
let accountEntry = account;
let accountChanged = false;
const accountDm = normalizeLegacyDmAliases({
entry: accountEntry,
pathPrefix: `channels.slack.accounts.${accountId}`,
changes,
});
accountEntry = accountDm.entry;
accountChanged = accountDm.changed;
const accountStreaming = normalizeLegacyStreamingAliases({
entry: accountEntry,
pathPrefix: `channels.slack.accounts.${accountId}`,
changes,
resolvedMode: resolveSlackStreamingMode(accountEntry),
resolvedNativeTransport: resolveSlackNativeStreaming(accountEntry),
});
accountEntry = accountStreaming.entry;
accountChanged = accountChanged || accountStreaming.changed;
if (accountChanged) {
accounts[accountId] = accountEntry;
accountsChanged = true;
}
}
if (accountsChanged) {
updated = { ...updated, accounts };
changed = true;
}
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
slack: updated as unknown as NonNullable<OpenClawConfig["channels"]>["slack"],
} as OpenClawConfig["channels"],
},
changes,
};
}

View file

@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { slackDoctor } from "./doctor.js";
describe("slack doctor", () => {
it("warns when mutable allowlist entries rely on disabled name matching", () => {
expect(
slackDoctor.collectMutableAllowlistWarnings?.({
cfg: {
channels: {
slack: {
allowFrom: ["alice"],
accounts: {
work: {
dm: {
allowFrom: ["U12345678"],
},
channels: {
general: {
users: ["bob"],
},
},
},
},
},
},
} as never,
}),
).toEqual(
expect.arrayContaining([
expect.stringContaining("mutable allowlist entries across slack"),
expect.stringContaining("channels.slack.allowFrom: alice"),
expect.stringContaining("channels.slack.accounts.work.channels.general.users: bob"),
]),
);
});
it("normalizes legacy slack streaming aliases into the nested streaming shape", () => {
const normalize = slackDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();
if (!normalize) {
return;
}
const result = normalize({
cfg: {
channels: {
slack: {
streamMode: "status_final",
chunkMode: "newline",
blockStreaming: true,
blockStreamingCoalesce: {
idleMs: 250,
},
accounts: {
work: {
streaming: false,
nativeStreaming: false,
},
},
},
},
} as never,
});
expect(result.config.channels?.slack?.streaming).toEqual({
mode: "progress",
chunkMode: "newline",
block: {
enabled: true,
coalesce: {
idleMs: 250,
},
},
});
expect(result.config.channels?.slack?.accounts?.work?.streaming).toEqual({
mode: "off",
nativeTransport: false,
});
expect(result.changes).toEqual(
expect.arrayContaining([
"Moved channels.slack.streamMode → channels.slack.streaming.mode (progress).",
"Moved channels.slack.chunkMode → channels.slack.streaming.chunkMode.",
"Moved channels.slack.blockStreaming → channels.slack.streaming.block.enabled.",
"Moved channels.slack.blockStreamingCoalesce → channels.slack.streaming.block.coalesce.",
"Moved channels.slack.accounts.work.streaming (boolean) → channels.slack.accounts.work.streaming.mode (off).",
"Moved channels.slack.accounts.work.nativeStreaming → channels.slack.accounts.work.streaming.nativeTransport.",
]),
);
});
it("does not duplicate streaming.mode change messages when streamMode wins over boolean streaming", () => {
const normalize = slackDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();
if (!normalize) {
return;
}
const result = normalize({
cfg: {
channels: {
slack: {
streamMode: "status_final",
streaming: false,
},
},
} as never,
});
expect(result.config.channels?.slack?.streaming).toEqual({
mode: "progress",
nativeTransport: false,
});
expect(
result.changes.filter((change) => change.includes("channels.slack.streaming.mode")),
).toEqual(["Moved channels.slack.streamMode → channels.slack.streaming.mode (progress)."]);
});
});

View file

@ -0,0 +1,58 @@
import { type ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import {
legacyConfigRules as SLACK_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig as normalizeSlackCompatibilityConfig,
} from "./doctor-contract.js";
import { isSlackMutableAllowEntry } from "./security-doctor.js";
function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
export const collectSlackMutableAllowlistWarnings =
createDangerousNameMatchingMutableAllowlistWarningCollector({
channel: "slack",
detector: isSlackMutableAllowEntry,
collectLists: (scope) => {
const lists = [
{
pathLabel: `${scope.prefix}.allowFrom`,
list: scope.account.allowFrom,
},
];
const dm = asObjectRecord(scope.account.dm);
if (dm) {
lists.push({
pathLabel: `${scope.prefix}.dm.allowFrom`,
list: dm.allowFrom,
});
}
const channels = asObjectRecord(scope.account.channels);
if (channels) {
for (const [channelKey, channelRaw] of Object.entries(channels)) {
const channel = asObjectRecord(channelRaw);
if (!channel) {
continue;
}
lists.push({
pathLabel: `${scope.prefix}.channels.${channelKey}.users`,
list: channel.users,
});
}
}
return lists;
},
});
export const slackDoctor: ChannelDoctorAdapter = {
dmAllowFromMode: "topOrNested",
groupModel: "route",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,
legacyConfigRules: SLACK_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: normalizeSlackCompatibilityConfig,
collectMutableAllowlistWarnings: collectSlackMutableAllowlistWarnings,
};

View file

@ -0,0 +1,158 @@
import { describe, expect, it, vi } from "vitest";
import { createSlackDraftStream } from "./draft-stream.js";
type DraftStreamParams = Parameters<typeof createSlackDraftStream>[0];
type DraftSendFn = NonNullable<DraftStreamParams["send"]>;
type DraftEditFn = NonNullable<DraftStreamParams["edit"]>;
type DraftRemoveFn = NonNullable<DraftStreamParams["remove"]>;
type DraftWarnFn = NonNullable<DraftStreamParams["warn"]>;
function createDraftStreamHarness(
params: {
maxChars?: number;
send?: DraftSendFn;
edit?: DraftEditFn;
remove?: DraftRemoveFn;
warn?: DraftWarnFn;
} = {},
) {
const send =
params.send ??
vi.fn<DraftSendFn>(async () => ({
channelId: "C123",
messageId: "111.222",
}));
const edit = params.edit ?? vi.fn<DraftEditFn>(async () => {});
const remove = params.remove ?? vi.fn<DraftRemoveFn>(async () => {});
const warn = params.warn ?? vi.fn<DraftWarnFn>();
const stream = createSlackDraftStream({
target: "channel:C123",
token: "xoxb-test",
throttleMs: 250,
maxChars: params.maxChars,
send,
edit,
remove,
warn,
});
return { stream, send, edit, remove, warn };
}
describe("createSlackDraftStream", () => {
it("sends the first update and edits subsequent updates", async () => {
const { stream, send, edit } = createDraftStreamHarness();
stream.update("hello");
await stream.flush();
stream.update("hello world");
await stream.flush();
expect(send).toHaveBeenCalledTimes(1);
expect(edit).toHaveBeenCalledTimes(1);
expect(edit).toHaveBeenCalledWith("C123", "111.222", "hello world", {
token: "xoxb-test",
accountId: undefined,
});
});
it("does not send duplicate text", async () => {
const { stream, send, edit } = createDraftStreamHarness();
stream.update("same");
await stream.flush();
stream.update("same");
await stream.flush();
expect(send).toHaveBeenCalledTimes(1);
expect(edit).toHaveBeenCalledTimes(0);
});
it("supports forceNewMessage for subsequent assistant messages", async () => {
const send = vi
.fn<DraftSendFn>()
.mockResolvedValueOnce({ channelId: "C123", messageId: "111.222" })
.mockResolvedValueOnce({ channelId: "C123", messageId: "333.444" });
const { stream, edit } = createDraftStreamHarness({ send });
stream.update("first");
await stream.flush();
stream.forceNewMessage();
stream.update("second");
await stream.flush();
expect(send).toHaveBeenCalledTimes(2);
expect(edit).toHaveBeenCalledTimes(0);
expect(stream.messageId()).toBe("333.444");
});
it("stops when text exceeds max chars", async () => {
const { stream, send, edit, warn } = createDraftStreamHarness({ maxChars: 5 });
stream.update("123456");
await stream.flush();
stream.update("ok");
await stream.flush();
expect(send).not.toHaveBeenCalled();
expect(edit).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledTimes(1);
});
it("allows a 4205-character preview with the default max chars", async () => {
const { stream, send, warn } = createDraftStreamHarness();
const text = "a".repeat(4205);
stream.update(text);
await stream.flush();
expect(send).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledWith(
"channel:C123",
text,
expect.objectContaining({
token: "xoxb-test",
}),
);
expect(warn).not.toHaveBeenCalled();
});
it("clear removes preview message when one exists", async () => {
const { stream, remove } = createDraftStreamHarness();
stream.update("hello");
await stream.flush();
await stream.clear();
expect(remove).toHaveBeenCalledTimes(1);
expect(remove).toHaveBeenCalledWith("C123", "111.222", {
token: "xoxb-test",
accountId: undefined,
});
expect(stream.messageId()).toBeUndefined();
expect(stream.channelId()).toBeUndefined();
});
it("clear is a no-op when no preview message exists", async () => {
const { stream, remove } = createDraftStreamHarness();
await stream.clear();
expect(remove).not.toHaveBeenCalled();
});
it("clear warns when cleanup fails", async () => {
const remove = vi.fn<DraftRemoveFn>(async () => {
throw new Error("cleanup failed");
});
const warn = vi.fn<DraftWarnFn>();
const { stream } = createDraftStreamHarness({ remove, warn });
stream.update("hello");
await stream.flush();
await stream.clear();
expect(warn).toHaveBeenCalledWith("slack stream preview cleanup failed: cleanup failed");
expect(stream.messageId()).toBeUndefined();
expect(stream.channelId()).toBeUndefined();
});
});

View file

@ -0,0 +1,137 @@
import { createDraftStreamLoop } from "openclaw/plugin-sdk/channel-lifecycle";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { deleteSlackMessage, editSlackMessage } from "./actions.js";
import { SLACK_TEXT_LIMIT } from "./limits.js";
import { sendMessageSlack } from "./send.js";
const DEFAULT_THROTTLE_MS = 1000;
export type SlackDraftStream = {
update: (text: string) => void;
flush: () => Promise<void>;
clear: () => Promise<void>;
stop: () => void;
forceNewMessage: () => void;
messageId: () => string | undefined;
channelId: () => string | undefined;
};
export function createSlackDraftStream(params: {
target: string;
token: string;
accountId?: string;
maxChars?: number;
throttleMs?: number;
resolveThreadTs?: () => string | undefined;
onMessageSent?: () => void;
log?: (message: string) => void;
warn?: (message: string) => void;
send?: typeof sendMessageSlack;
edit?: typeof editSlackMessage;
remove?: typeof deleteSlackMessage;
}): SlackDraftStream {
const maxChars = Math.min(params.maxChars ?? SLACK_TEXT_LIMIT, SLACK_TEXT_LIMIT);
const throttleMs = Math.max(250, params.throttleMs ?? DEFAULT_THROTTLE_MS);
const send = params.send ?? sendMessageSlack;
const edit = params.edit ?? editSlackMessage;
const remove = params.remove ?? deleteSlackMessage;
let streamMessageId: string | undefined;
let streamChannelId: string | undefined;
let lastSentText = "";
let stopped = false;
const sendOrEditStreamMessage = async (text: string) => {
if (stopped) {
return;
}
const trimmed = text.trimEnd();
if (!trimmed) {
return;
}
if (trimmed.length > maxChars) {
stopped = true;
params.warn?.(`slack stream preview stopped (text length ${trimmed.length} > ${maxChars})`);
return;
}
if (trimmed === lastSentText) {
return;
}
lastSentText = trimmed;
try {
if (streamChannelId && streamMessageId) {
await edit(streamChannelId, streamMessageId, trimmed, {
token: params.token,
accountId: params.accountId,
});
return;
}
const sent = await send(params.target, trimmed, {
token: params.token,
accountId: params.accountId,
threadTs: params.resolveThreadTs?.(),
});
streamChannelId = sent.channelId || streamChannelId;
streamMessageId = sent.messageId || streamMessageId;
if (!streamChannelId || !streamMessageId) {
stopped = true;
params.warn?.("slack stream preview stopped (missing identifiers from sendMessage)");
return;
}
params.onMessageSent?.();
} catch (err) {
stopped = true;
params.warn?.(`slack stream preview failed: ${formatErrorMessage(err)}`);
}
};
const loop = createDraftStreamLoop({
throttleMs,
isStopped: () => stopped,
sendOrEditStreamMessage,
});
const stop = () => {
stopped = true;
loop.stop();
};
const clear = async () => {
stop();
await loop.waitForInFlight();
const channelId = streamChannelId;
const messageId = streamMessageId;
streamChannelId = undefined;
streamMessageId = undefined;
lastSentText = "";
if (!channelId || !messageId) {
return;
}
try {
await remove(channelId, messageId, {
token: params.token,
accountId: params.accountId,
});
} catch (err) {
params.warn?.(`slack stream preview cleanup failed: ${formatErrorMessage(err)}`);
}
};
const forceNewMessage = () => {
streamMessageId = undefined;
streamChannelId = undefined;
lastSentText = "";
loop.resetPending();
};
params.log?.(`slack stream preview ready (maxChars=${maxChars}, throttleMs=${throttleMs})`);
return {
update: loop.update,
flush: loop.flush,
clear,
stop,
forceNewMessage,
messageId: () => streamMessageId,
channelId: () => streamChannelId,
};
}

View file

@ -0,0 +1,271 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it } from "vitest";
import {
getSlackExecApprovalApprovers,
isSlackExecApprovalApprover,
isSlackExecApprovalAuthorizedSender,
isSlackExecApprovalClientEnabled,
isSlackExecApprovalTargetRecipient,
normalizeSlackApproverId,
resolveSlackExecApprovalTarget,
shouldHandleSlackExecApprovalRequest,
shouldSuppressLocalSlackExecApprovalPrompt,
} from "./exec-approvals.js";
function buildConfig(
execApprovals?: NonNullable<NonNullable<OpenClawConfig["channels"]>["slack"]>["execApprovals"],
channelOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["slack"]>>,
): OpenClawConfig {
return {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
...channelOverrides,
execApprovals,
},
},
} as OpenClawConfig;
}
describe("slack exec approvals", () => {
it("auto-enables when owner approvers resolve and disables only when forced off", () => {
expect(isSlackExecApprovalClientEnabled({ cfg: buildConfig() })).toBe(false);
expect(
isSlackExecApprovalClientEnabled({
cfg: buildConfig({ enabled: true }),
}),
).toBe(false);
expect(
isSlackExecApprovalClientEnabled({
cfg: buildConfig({ approvers: ["U123"] }),
}),
).toBe(true);
expect(
isSlackExecApprovalClientEnabled({
cfg: {
...buildConfig(),
commands: { ownerAllowFrom: ["slack:U123OWNER"] },
} as OpenClawConfig,
}),
).toBe(true);
expect(
isSlackExecApprovalClientEnabled({
cfg: buildConfig({ enabled: false, approvers: ["U123"] }),
}),
).toBe(false);
});
it("prefers explicit approvers when configured", () => {
const cfg = buildConfig(
{ approvers: ["U456"] },
{ allowFrom: ["U123"], defaultTo: "user:U789" },
);
expect(getSlackExecApprovalApprovers({ cfg })).toEqual(["U456"]);
expect(isSlackExecApprovalApprover({ cfg, senderId: "U456" })).toBe(true);
expect(isSlackExecApprovalApprover({ cfg, senderId: "U123" })).toBe(false);
});
it("does not infer approvers from allowFrom or DM default routes", () => {
const cfg = buildConfig(
{ enabled: true },
{
allowFrom: ["slack:U123"],
dm: { allowFrom: ["<@U456>"] },
defaultTo: "user:U789",
},
);
expect(getSlackExecApprovalApprovers({ cfg })).toEqual([]);
expect(isSlackExecApprovalApprover({ cfg, senderId: "U789" })).toBe(false);
});
it("falls back to commands.ownerAllowFrom for exec approvers", () => {
const cfg = {
...buildConfig({ enabled: true }),
commands: { ownerAllowFrom: ["slack:U123", "user:U456", "<@U789>"] },
} as OpenClawConfig;
expect(getSlackExecApprovalApprovers({ cfg })).toEqual(["U123", "U456", "U789"]);
expect(isSlackExecApprovalApprover({ cfg, senderId: "U456" })).toBe(true);
});
it("defaults target to dm", () => {
expect(
resolveSlackExecApprovalTarget({ cfg: buildConfig({ enabled: true, approvers: ["U1"] }) }),
).toBe("dm");
});
it("matches slack target recipients from generic approval forwarding targets", () => {
const cfg = {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
},
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [
{ channel: "slack", to: "user:U123TARGET" },
{ channel: "slack", to: "channel:C123" },
],
},
},
} as OpenClawConfig;
expect(isSlackExecApprovalTargetRecipient({ cfg, senderId: "U123TARGET" })).toBe(true);
expect(isSlackExecApprovalTargetRecipient({ cfg, senderId: "U999OTHER" })).toBe(false);
expect(isSlackExecApprovalAuthorizedSender({ cfg, senderId: "U123TARGET" })).toBe(true);
});
it("keeps the local Slack approval prompt path active", () => {
const payload = {
channelData: {
execApproval: {
approvalId: "req-1",
approvalSlug: "req-1",
},
},
};
expect(
shouldSuppressLocalSlackExecApprovalPrompt({
cfg: buildConfig({ enabled: true, approvers: ["U123"] }),
payload,
}),
).toBe(true);
expect(
shouldSuppressLocalSlackExecApprovalPrompt({
cfg: buildConfig(),
payload,
}),
).toBe(false);
});
it("normalizes wrapped sender ids", () => {
expect(normalizeSlackApproverId("user:U123OWNER")).toBe("U123OWNER");
expect(normalizeSlackApproverId("<@U123OWNER>")).toBe("U123OWNER");
});
it("applies agent and session filters to request handling", () => {
const cfg = buildConfig({
enabled: true,
approvers: ["U123"],
agentFilter: ["ops-agent"],
sessionFilter: ["slack:direct:", "tail$"],
});
expect(
shouldHandleSlackExecApprovalRequest({
cfg,
request: {
id: "req-1",
request: {
command: "echo hi",
agentId: "ops-agent",
sessionKey: "agent:ops-agent:slack:direct:U123:tail",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
}),
).toBe(true);
expect(
shouldHandleSlackExecApprovalRequest({
cfg,
request: {
id: "req-2",
request: {
command: "echo hi",
agentId: "other-agent",
sessionKey: "agent:other-agent:slack:direct:U123:tail",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
}),
).toBe(false);
expect(
shouldHandleSlackExecApprovalRequest({
cfg,
request: {
id: "req-3",
request: {
command: "echo hi",
agentId: "ops-agent",
sessionKey: "agent:ops-agent:discord:channel:123",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
}),
).toBe(false);
});
it("rejects requests bound to another channel or Slack account", () => {
const cfg = buildConfig({
enabled: true,
approvers: ["U123"],
});
expect(
shouldHandleSlackExecApprovalRequest({
cfg,
accountId: "work",
request: {
id: "req-1",
request: {
command: "echo hi",
turnSourceChannel: "discord",
turnSourceAccountId: "work",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
}),
).toBe(false);
expect(
shouldHandleSlackExecApprovalRequest({
cfg,
accountId: "work",
request: {
id: "req-2",
request: {
command: "echo hi",
turnSourceChannel: "slack",
turnSourceAccountId: "other",
sessionKey: "agent:ops-agent:missing",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
}),
).toBe(false);
expect(
shouldHandleSlackExecApprovalRequest({
cfg,
accountId: "work",
request: {
id: "req-3",
request: {
command: "echo hi",
turnSourceChannel: "slack",
turnSourceAccountId: "work",
sessionKey: "agent:ops-agent:missing",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
}),
).toBe(true);
});
});

View file

@ -0,0 +1,82 @@
import { resolveApprovalApprovers } from "openclaw/plugin-sdk/approval-auth-runtime";
import {
createChannelExecApprovalProfile,
isChannelExecApprovalTargetRecipient,
} from "openclaw/plugin-sdk/approval-client-runtime";
import { doesApprovalRequestMatchChannelAccount } from "openclaw/plugin-sdk/approval-native-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { normalizeStringifiedOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { resolveSlackAccount } from "./accounts.js";
export function normalizeSlackApproverId(value: string | number): string | undefined {
const trimmed = normalizeStringifiedOptionalString(value);
if (!trimmed) {
return undefined;
}
const prefixed = trimmed.match(/^(?:slack|user):([A-Z0-9]+)$/i);
if (prefixed?.[1]) {
return prefixed[1];
}
const mention = trimmed.match(/^<@([A-Z0-9]+)>$/i);
if (mention?.[1]) {
return mention[1];
}
return /^[UW][A-Z0-9]+$/i.test(trimmed) ? trimmed : undefined;
}
function resolveSlackOwnerApprovers(cfg: OpenClawConfig): string[] {
const ownerAllowFrom = cfg.commands?.ownerAllowFrom;
if (!Array.isArray(ownerAllowFrom) || ownerAllowFrom.length === 0) {
return [];
}
return resolveApprovalApprovers({
explicit: ownerAllowFrom,
normalizeApprover: normalizeSlackApproverId,
});
}
export function getSlackExecApprovalApprovers(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] {
const account = resolveSlackAccount(params).config;
return resolveApprovalApprovers({
explicit: account.execApprovals?.approvers ?? resolveSlackOwnerApprovers(params.cfg),
normalizeApprover: normalizeSlackApproverId,
});
}
export function isSlackExecApprovalTargetRecipient(params: {
cfg: OpenClawConfig;
senderId?: string | null;
accountId?: string | null;
}): boolean {
return isChannelExecApprovalTargetRecipient({
...params,
channel: "slack",
normalizeSenderId: normalizeSlackApproverId,
matchTarget: ({ target, normalizedSenderId }) =>
normalizeSlackApproverId(target.to) === normalizedSenderId,
});
}
const slackExecApprovalProfile = createChannelExecApprovalProfile({
resolveConfig: (params) => resolveSlackAccount(params).config.execApprovals,
resolveApprovers: getSlackExecApprovalApprovers,
normalizeSenderId: normalizeSlackApproverId,
isTargetRecipient: isSlackExecApprovalTargetRecipient,
matchesRequestAccount: (params) =>
doesApprovalRequestMatchChannelAccount({
cfg: params.cfg,
request: params.request,
channel: "slack",
accountId: params.accountId,
}),
});
export const isSlackExecApprovalClientEnabled = slackExecApprovalProfile.isClientEnabled;
export const isSlackExecApprovalApprover = slackExecApprovalProfile.isApprover;
export const isSlackExecApprovalAuthorizedSender = slackExecApprovalProfile.isAuthorizedSender;
export const resolveSlackExecApprovalTarget = slackExecApprovalProfile.resolveTarget;
export const shouldHandleSlackExecApprovalRequest = slackExecApprovalProfile.shouldHandleRequest;
export const shouldSuppressLocalSlackExecApprovalPrompt =
slackExecApprovalProfile.shouldSuppressLocalPrompt;

View file

@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import {
markdownToSlackMrkdwn,
markdownToSlackMrkdwnChunks,
normalizeSlackOutboundText,
} from "./format.js";
import { escapeSlackMrkdwn } from "./monitor/mrkdwn.js";
describe("markdownToSlackMrkdwn", () => {
it("handles core markdown formatting conversions", () => {
const cases = [
["converts bold from double asterisks to single", "**bold text**", "*bold text*"],
["preserves italic underscore format", "_italic text_", "_italic text_"],
[
"converts strikethrough from double tilde to single",
"~~strikethrough~~",
"~strikethrough~",
],
[
"renders basic inline formatting together",
"hi _there_ **boss** `code`",
"hi _there_ *boss* `code`",
],
["renders inline code", "use `npm install`", "use `npm install`"],
["renders fenced code blocks", "```js\nconst x = 1;\n```", "```\nconst x = 1;\n```"],
[
"renders links with Slack mrkdwn syntax",
"see [docs](https://example.com)",
"see <https://example.com|docs>",
],
["does not duplicate bare URLs", "see https://example.com", "see https://example.com"],
["escapes unsafe characters", "a & b < c > d", "a &amp; b &lt; c &gt; d"],
[
"preserves Slack angle-bracket markup (mentions/links)",
"hi <@U123> see <https://example.com|docs> and <!here>",
"hi <@U123> see <https://example.com|docs> and <!here>",
],
["escapes raw HTML", "<b>nope</b>", "&lt;b&gt;nope&lt;/b&gt;"],
["renders paragraphs with blank lines", "first\n\nsecond", "first\n\nsecond"],
["renders bullet lists", "- one\n- two", "• one\n• two"],
["renders ordered lists with numbering", "2. two\n3. three", "2. two\n3. three"],
["renders headings as bold text", "# Title", "*Title*"],
["renders blockquotes", "> Quote", "> Quote"],
] as const;
for (const [name, input, expected] of cases) {
expect(markdownToSlackMrkdwn(input), name).toBe(expected);
}
});
it("handles nested list items", () => {
const res = markdownToSlackMrkdwn("- item\n - nested");
// markdown-it correctly parses this as a nested list
expect(res).toBe("• item\n • nested");
});
it("handles complex message with multiple elements", () => {
const res = markdownToSlackMrkdwn(
"**Important:** Check the _docs_ at [link](https://example.com)\n\n- first\n- second",
);
expect(res).toBe(
"*Important:* Check the _docs_ at <https://example.com|link>\n\n• first\n• second",
);
});
it("does not throw when input is undefined at runtime", () => {
expect(markdownToSlackMrkdwn(undefined as unknown as string)).toBe("");
});
it("re-chunks on rendered length and still prefers word boundaries", () => {
const chunks = markdownToSlackMrkdwnChunks("alpha <<", 8);
expect(chunks).toEqual(["alpha ", "&lt;&lt;"]);
expect(chunks.every((chunk) => chunk.length <= 8)).toBe(true);
});
});
describe("escapeSlackMrkdwn", () => {
it("returns plain text unchanged", () => {
expect(escapeSlackMrkdwn("heartbeat status ok")).toBe("heartbeat status ok");
});
it("escapes slack and mrkdwn control characters", () => {
expect(escapeSlackMrkdwn("mode_*`~<&>\\")).toBe("mode\\_\\*\\`\\~&lt;&amp;&gt;\\\\");
});
});
describe("normalizeSlackOutboundText", () => {
it("normalizes markdown for outbound send/update paths", () => {
expect(normalizeSlackOutboundText(" **bold** ")).toBe("*bold*");
});
});

View file

@ -0,0 +1,158 @@
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import {
markdownToIR,
type MarkdownLinkSpan,
renderMarkdownIRChunksWithinLimit,
} from "openclaw/plugin-sdk/text-runtime";
import { renderMarkdownWithMarkers } from "openclaw/plugin-sdk/text-runtime";
// Escape special characters for Slack mrkdwn format.
// Preserve Slack's angle-bracket tokens so mentions and links stay intact.
function escapeSlackMrkdwnSegment(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
const SLACK_ANGLE_TOKEN_RE = /<[^>\n]+>/g;
function isAllowedSlackAngleToken(token: string): boolean {
if (!token.startsWith("<") || !token.endsWith(">")) {
return false;
}
const inner = token.slice(1, -1);
return (
inner.startsWith("@") ||
inner.startsWith("#") ||
inner.startsWith("!") ||
inner.startsWith("mailto:") ||
inner.startsWith("tel:") ||
inner.startsWith("http://") ||
inner.startsWith("https://") ||
inner.startsWith("slack://")
);
}
function escapeSlackMrkdwnContent(text: string): string {
if (!text) {
return "";
}
if (!text.includes("&") && !text.includes("<") && !text.includes(">")) {
return text;
}
SLACK_ANGLE_TOKEN_RE.lastIndex = 0;
const out: string[] = [];
let lastIndex = 0;
for (
let match = SLACK_ANGLE_TOKEN_RE.exec(text);
match;
match = SLACK_ANGLE_TOKEN_RE.exec(text)
) {
const matchIndex = match.index ?? 0;
out.push(escapeSlackMrkdwnSegment(text.slice(lastIndex, matchIndex)));
const token = match[0] ?? "";
out.push(isAllowedSlackAngleToken(token) ? token : escapeSlackMrkdwnSegment(token));
lastIndex = matchIndex + token.length;
}
out.push(escapeSlackMrkdwnSegment(text.slice(lastIndex)));
return out.join("");
}
function escapeSlackMrkdwnText(text: string): string {
if (!text) {
return "";
}
if (!text.includes("&") && !text.includes("<") && !text.includes(">")) {
return text;
}
return text
.split("\n")
.map((line) => {
if (line.startsWith("> ")) {
return `> ${escapeSlackMrkdwnContent(line.slice(2))}`;
}
return escapeSlackMrkdwnContent(line);
})
.join("\n");
}
function buildSlackLink(link: MarkdownLinkSpan, text: string) {
const href = link.href.trim();
if (!href) {
return null;
}
const label = text.slice(link.start, link.end);
const trimmedLabel = label.trim();
const comparableHref = href.startsWith("mailto:") ? href.slice("mailto:".length) : href;
const useMarkup =
trimmedLabel.length > 0 && trimmedLabel !== href && trimmedLabel !== comparableHref;
if (!useMarkup) {
return null;
}
const safeHref = escapeSlackMrkdwnSegment(href);
return {
start: link.start,
end: link.end,
open: `<${safeHref}|`,
close: ">",
};
}
type SlackMarkdownOptions = {
tableMode?: MarkdownTableMode;
};
function buildSlackRenderOptions() {
return {
styleMarkers: {
bold: { open: "*", close: "*" },
italic: { open: "_", close: "_" },
strikethrough: { open: "~", close: "~" },
code: { open: "`", close: "`" },
code_block: { open: "```\n", close: "```" },
},
escapeText: escapeSlackMrkdwnText,
buildLink: buildSlackLink,
};
}
export function markdownToSlackMrkdwn(
markdown: string,
options: SlackMarkdownOptions = {},
): string {
const ir = markdownToIR(markdown ?? "", {
linkify: false,
autolink: false,
headingStyle: "bold",
blockquotePrefix: "> ",
tableMode: options.tableMode,
});
return renderMarkdownWithMarkers(ir, buildSlackRenderOptions());
}
export function normalizeSlackOutboundText(markdown: string): string {
return markdownToSlackMrkdwn(markdown ?? "");
}
export function markdownToSlackMrkdwnChunks(
markdown: string,
limit: number,
options: SlackMarkdownOptions = {},
): string[] {
const ir = markdownToIR(markdown ?? "", {
linkify: false,
autolink: false,
headingStyle: "bold",
blockquotePrefix: "> ",
tableMode: options.tableMode,
});
const renderOptions = buildSlackRenderOptions();
return renderMarkdownIRChunksWithinLimit({
ir,
limit,
renderChunk: (chunk) => renderMarkdownWithMarkers(chunk, renderOptions),
measureRendered: (rendered) => rendered.length,
}).map(({ rendered }) => rendered);
}

View file

@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js";
const cfg = {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
channels: {
alerts: {
requireMention: false,
tools: { allow: ["message.send"] },
toolsBySender: {
"id:user:alice": { allow: ["sessions.list"] },
},
},
"*": {
requireMention: true,
tools: { deny: ["exec"] },
},
},
},
},
} as any;
describe("slack group policy", () => {
it("uses matched channel requireMention and wildcard fallback", () => {
expect(resolveSlackGroupRequireMention({ cfg, groupChannel: "#alerts" })).toBe(false);
expect(resolveSlackGroupRequireMention({ cfg, groupChannel: "#missing" })).toBe(true);
});
it("resolves sender override, then channel tools, then wildcard tools", () => {
const senderOverride = resolveSlackGroupToolPolicy({
cfg,
groupChannel: "#alerts",
senderId: "user:alice",
});
expect(senderOverride).toEqual({ allow: ["sessions.list"] });
const channelTools = resolveSlackGroupToolPolicy({
cfg,
groupChannel: "#alerts",
senderId: "user:bob",
});
expect(channelTools).toEqual({ allow: ["message.send"] });
const wildcardTools = resolveSlackGroupToolPolicy({
cfg,
groupChannel: "#missing",
senderId: "user:bob",
});
expect(wildcardTools).toEqual({ deny: ["exec"] });
});
});

View file

@ -0,0 +1,77 @@
import { normalizeAccountId } from "openclaw/plugin-sdk/account-resolution";
import type { ChannelGroupContext } from "openclaw/plugin-sdk/channel-contract";
import {
resolveToolsBySender,
type GroupToolPolicyBySenderConfig,
type GroupToolPolicyConfig,
} from "openclaw/plugin-sdk/channel-policy";
import { normalizeHyphenSlug } from "openclaw/plugin-sdk/string-normalization-runtime";
import { mergeSlackAccountConfig, resolveDefaultSlackAccountId } from "./accounts.js";
type SlackChannelPolicyEntry = {
requireMention?: boolean;
tools?: GroupToolPolicyConfig;
toolsBySender?: GroupToolPolicyBySenderConfig;
};
function resolveSlackChannelPolicyEntry(
params: ChannelGroupContext,
): SlackChannelPolicyEntry | undefined {
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultSlackAccountId(params.cfg),
);
const channels = mergeSlackAccountConfig(params.cfg, accountId).channels as
| Record<string, SlackChannelPolicyEntry>
| undefined;
const channelMap = channels ?? {};
if (Object.keys(channelMap).length === 0) {
return undefined;
}
const channelId = params.groupId?.trim();
const groupChannel = params.groupChannel;
const channelName = groupChannel?.replace(/^#/, "");
const normalizedName = normalizeHyphenSlug(channelName);
const candidates = [
channelId ?? "",
channelName ? `#${channelName}` : "",
channelName ?? "",
normalizedName,
].filter(Boolean);
for (const candidate of candidates) {
if (candidate && channelMap[candidate]) {
return channelMap[candidate];
}
}
return channelMap["*"];
}
function resolveSenderToolsEntry(
entry: SlackChannelPolicyEntry | undefined,
params: ChannelGroupContext,
): GroupToolPolicyConfig | undefined {
if (!entry) {
return undefined;
}
const senderPolicy = resolveToolsBySender({
toolsBySender: entry.toolsBySender,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
});
return senderPolicy ?? entry.tools;
}
export function resolveSlackGroupRequireMention(params: ChannelGroupContext): boolean {
const resolved = resolveSlackChannelPolicyEntry(params);
if (typeof resolved?.requireMention === "boolean") {
return resolved.requireMention;
}
return true;
}
export function resolveSlackGroupToolPolicy(
params: ChannelGroupContext,
): GroupToolPolicyConfig | undefined {
return resolveSenderToolsEntry(resolveSlackChannelPolicyEntry(params), params);
}

View file

@ -0,0 +1 @@
export { handleSlackHttpRequest } from "./registry.js";

View file

@ -0,0 +1 @@
export * from "./registry.js";

View file

@ -0,0 +1,7 @@
export function normalizeSlackWebhookPath(path?: string | null): string {
const trimmed = path?.trim();
if (!trimmed) {
return "/slack/events";
}
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
}

View file

@ -0,0 +1,62 @@
import { describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../../../test/helpers/plugins/plugin-api.js";
import type { OpenClawConfig, OpenClawPluginApi } from "../runtime-api.js";
import { registerSlackPluginHttpRoutes } from "./plugin-routes.js";
function createApi(config: OpenClawConfig, registerHttpRoute = vi.fn()): OpenClawPluginApi {
return createTestPluginApi({
id: "slack",
config,
registerHttpRoute,
}) as OpenClawPluginApi;
}
describe("registerSlackPluginHttpRoutes", () => {
it("registers account webhook paths without resolving unresolved token refs", () => {
const registerHttpRoute = vi.fn();
const cfg: OpenClawConfig = {
channels: {
slack: {
accounts: {
default: {
webhookPath: "/hooks/default",
botToken: {
source: "env",
provider: "default",
id: "SLACK_BOT_TOKEN",
} as unknown as string,
},
ops: {
webhookPath: "hooks/ops",
botToken: {
source: "env",
provider: "default",
id: "SLACK_OPS_BOT_TOKEN",
} as unknown as string,
},
},
},
},
};
const api = createApi(cfg, registerHttpRoute);
expect(() => registerSlackPluginHttpRoutes(api)).not.toThrow();
const paths = registerHttpRoute.mock.calls
.map((call) => (call[0] as { path: string }).path)
.toSorted();
expect(paths).toEqual(["/hooks/default", "/hooks/ops"]);
});
it("falls back to the default slack webhook path", () => {
const registerHttpRoute = vi.fn();
const api = createApi({}, registerHttpRoute);
registerSlackPluginHttpRoutes(api);
const paths = registerHttpRoute.mock.calls
.map((call) => (call[0] as { path: string }).path)
.toSorted();
expect(paths).toEqual(["/slack/events"]);
});
});

View file

@ -0,0 +1,32 @@
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-plugin-common";
import { listSlackAccountIds, mergeSlackAccountConfig } from "../accounts.js";
import { normalizeSlackWebhookPath } from "./paths.js";
let slackHttpHandlerRuntimePromise: Promise<typeof import("./handler.runtime.js")> | null = null;
async function loadSlackHttpHandlerRuntime() {
slackHttpHandlerRuntimePromise ??= import("./handler.runtime.js");
return await slackHttpHandlerRuntimePromise;
}
export function registerSlackPluginHttpRoutes(api: OpenClawPluginApi): void {
const accountIds = new Set<string>([DEFAULT_ACCOUNT_ID, ...listSlackAccountIds(api.config)]);
const registeredPaths = new Set<string>();
for (const accountId of accountIds) {
// Route registration must remain config-only and should not resolve tokens.
const accountConfig = mergeSlackAccountConfig(api.config, accountId);
registeredPaths.add(normalizeSlackWebhookPath(accountConfig.webhookPath));
}
if (registeredPaths.size === 0) {
registeredPaths.add(normalizeSlackWebhookPath());
}
for (const path of registeredPaths) {
api.registerHttpRoute({
path,
auth: "plugin",
handler: async (req, res) =>
await (await loadSlackHttpHandlerRuntime()).handleSlackHttpRequest(req, res),
});
}
}

View file

@ -0,0 +1,88 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
handleSlackHttpRequest,
normalizeSlackWebhookPath,
registerSlackHttpHandler,
} from "./registry.js";
describe("normalizeSlackWebhookPath", () => {
it("returns the default path when input is empty", () => {
expect(normalizeSlackWebhookPath()).toBe("/slack/events");
expect(normalizeSlackWebhookPath(" ")).toBe("/slack/events");
});
it("ensures a leading slash", () => {
expect(normalizeSlackWebhookPath("slack/events")).toBe("/slack/events");
expect(normalizeSlackWebhookPath("/hooks/slack")).toBe("/hooks/slack");
});
});
describe("registerSlackHttpHandler", () => {
const unregisters: Array<() => void> = [];
afterEach(() => {
for (const unregister of unregisters.splice(0)) {
unregister();
}
});
it("routes requests to a registered handler", async () => {
const handler = vi.fn();
unregisters.push(
registerSlackHttpHandler({
path: "/slack/events",
handler,
}),
);
const req = { url: "/slack/events?foo=bar" } as IncomingMessage;
const res = {} as ServerResponse;
const handled = await handleSlackHttpRequest(req, res);
expect(handled).toBe(true);
expect(handler).toHaveBeenCalledWith(req, res);
});
it("returns false when no handler matches", async () => {
const req = { url: "/slack/other" } as IncomingMessage;
const res = {} as ServerResponse;
const handled = await handleSlackHttpRequest(req, res);
expect(handled).toBe(false);
});
it("logs and ignores duplicate registrations", async () => {
const handler = vi.fn();
const log = vi.fn();
unregisters.push(
registerSlackHttpHandler({
path: "/slack/events",
handler,
log,
accountId: "primary",
}),
);
unregisters.push(
registerSlackHttpHandler({
path: "/slack/events",
handler: vi.fn(),
log,
accountId: "duplicate",
}),
);
const req = { url: "/slack/events" } as IncomingMessage;
const res = {} as ServerResponse;
const handled = await handleSlackHttpRequest(req, res);
expect(handled).toBe(true);
expect(handler).toHaveBeenCalledWith(req, res);
expect(log).toHaveBeenCalledWith(
'slack: webhook path /slack/events already registered for account "duplicate"',
);
});
});

View file

@ -0,0 +1,44 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { normalizeSlackWebhookPath } from "./paths.js";
export { normalizeSlackWebhookPath } from "./paths.js";
export type SlackHttpRequestHandler = (
req: IncomingMessage,
res: ServerResponse,
) => Promise<void> | void;
type RegisterSlackHttpHandlerArgs = {
path?: string | null;
handler: SlackHttpRequestHandler;
log?: (message: string) => void;
accountId?: string;
};
const slackHttpRoutes = new Map<string, SlackHttpRequestHandler>();
export function registerSlackHttpHandler(params: RegisterSlackHttpHandlerArgs): () => void {
const normalizedPath = normalizeSlackWebhookPath(params.path);
if (slackHttpRoutes.has(normalizedPath)) {
const suffix = params.accountId ? ` for account "${params.accountId}"` : "";
params.log?.(`slack: webhook path ${normalizedPath} already registered${suffix}`);
return () => {};
}
slackHttpRoutes.set(normalizedPath, params.handler);
return () => {
slackHttpRoutes.delete(normalizedPath);
};
}
export async function handleSlackHttpRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
const url = new URL(req.url ?? "/", "http://localhost");
const handler = slackHttpRoutes.get(url.pathname);
if (!handler) {
return false;
}
await handler(req, res);
return true;
}

View file

@ -0,0 +1,26 @@
export {
listEnabledSlackAccounts,
listSlackAccountIds,
resolveDefaultSlackAccountId,
resolveSlackAccount,
} from "./accounts.js";
export {
deleteSlackMessage,
editSlackMessage,
getSlackMemberInfo,
listSlackEmojis,
listSlackPins,
listSlackReactions,
pinSlackMessage,
reactSlackMessage,
readSlackMessages,
removeOwnSlackReactions,
removeSlackReaction,
sendSlackMessage,
unpinSlackMessage,
} from "./actions.js";
export { monitorSlackProvider } from "./monitor.js";
export { probeSlack } from "./probe.js";
export { sendMessageSlack } from "./send.js";
export { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js";
export { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";

View file

@ -0,0 +1,109 @@
import {
createInteractiveConversationBindingHelpers,
dispatchPluginInteractiveHandler,
type PluginConversationBinding,
type PluginConversationBindingRequestParams,
type PluginConversationBindingRequestResult,
type PluginInteractiveRegistration,
} from "openclaw/plugin-sdk/plugin-runtime";
export type SlackInteractiveHandlerContext = {
channel: "slack";
accountId: string;
interactionId: string;
conversationId: string;
parentConversationId?: string;
senderId?: string;
senderUsername?: string;
threadId?: string;
auth: {
isAuthorizedSender: boolean;
};
interaction: {
kind: "button" | "select";
data: string;
namespace: string;
payload: string;
actionId: string;
blockId?: string;
messageTs?: string;
threadTs?: string;
value?: string;
selectedValues?: string[];
selectedLabels?: string[];
triggerId?: string;
responseUrl?: string;
};
respond: {
acknowledge: () => Promise<void>;
reply: (params: { text: string; responseType?: "ephemeral" | "in_channel" }) => Promise<void>;
followUp: (params: {
text: string;
responseType?: "ephemeral" | "in_channel";
}) => Promise<void>;
editMessage: (params: { text?: string; blocks?: unknown[] }) => Promise<void>;
};
requestConversationBinding: (
params?: PluginConversationBindingRequestParams,
) => Promise<PluginConversationBindingRequestResult>;
detachConversationBinding: () => Promise<{ removed: boolean }>;
getCurrentConversationBinding: () => Promise<PluginConversationBinding | null>;
};
export type SlackInteractiveHandlerRegistration = PluginInteractiveRegistration<
SlackInteractiveHandlerContext,
"slack"
>;
export type SlackInteractiveDispatchContext = Omit<
SlackInteractiveHandlerContext,
| "interaction"
| "respond"
| "channel"
| "requestConversationBinding"
| "detachConversationBinding"
| "getCurrentConversationBinding"
> & {
interaction: Omit<
SlackInteractiveHandlerContext["interaction"],
"data" | "namespace" | "payload"
>;
};
export async function dispatchSlackPluginInteractiveHandler(params: {
data: string;
interactionId: string;
ctx: SlackInteractiveDispatchContext;
respond: SlackInteractiveHandlerContext["respond"];
onMatched?: () => Promise<void> | void;
}) {
return await dispatchPluginInteractiveHandler<SlackInteractiveHandlerRegistration>({
channel: "slack",
data: params.data,
dedupeId: params.interactionId,
onMatched: params.onMatched,
invoke: ({ registration, namespace, payload }) =>
registration.handler({
...params.ctx,
channel: "slack",
interaction: {
...params.ctx.interaction,
data: params.data,
namespace,
payload,
},
respond: params.respond,
...createInteractiveConversationBindingHelpers({
registration,
senderId: params.ctx.senderId,
conversation: {
channel: "slack",
accountId: params.ctx.accountId,
conversationId: params.ctx.conversationId,
parentConversationId: params.ctx.parentConversationId,
threadId: params.ctx.threadId,
},
}),
}),
});
}

View file

@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import { compileSlackInteractiveReplies } from "./interactive-replies.js";
describe("compileSlackInteractiveReplies", () => {
it("compiles inline Slack button directives into shared interactive blocks", () => {
const result = compileSlackInteractiveReplies({
text: "[bot] hello [[slack_buttons: Retry:retry, Ignore:ignore]]",
});
expect(result.text).toBe("[bot] hello");
expect(result.interactive).toEqual({
blocks: [
{
type: "text",
text: "[bot] hello",
},
{
type: "buttons",
buttons: [
{
label: "Retry",
value: "retry",
},
{
label: "Ignore",
value: "ignore",
},
],
},
],
});
});
it("compiles simple trailing Options lines into Slack buttons", () => {
const result = compileSlackInteractiveReplies({
text: "Current verbose level: off.\nOptions: on, full, off.",
});
expect(result.text).toBe("Current verbose level: off.");
expect(result.interactive).toEqual({
blocks: [
{
type: "text",
text: "Current verbose level: off.",
},
{
type: "buttons",
buttons: [
{ label: "on", value: "on" },
{ label: "full", value: "full" },
{ label: "off", value: "off" },
],
},
],
});
});
it("uses a Slack select when Options lines exceed button capacity", () => {
const result = compileSlackInteractiveReplies({
text: "Choose a reasoning level.\nOptions: off, minimal, low, medium, high, adaptive.",
});
expect(result.text).toBe("Choose a reasoning level.");
expect(result.interactive).toEqual({
blocks: [
{
type: "text",
text: "Choose a reasoning level.",
},
{
type: "select",
placeholder: "Choose an option",
options: [
{ label: "off", value: "off" },
{ label: "minimal", value: "minimal" },
{ label: "low", value: "low" },
{ label: "medium", value: "medium" },
{ label: "high", value: "high" },
{ label: "adaptive", value: "adaptive" },
],
},
],
});
});
it("leaves complex Options lines as plain text", () => {
const result = compileSlackInteractiveReplies({
text: "ACP runtime choices.\nOptions: host=auto|sandbox|gateway|node, security=deny|allowlist|full.",
});
expect(result.text).toBe(
"ACP runtime choices.\nOptions: host=auto|sandbox|gateway|node, security=deny|allowlist|full.",
);
expect(result.interactive).toBeUndefined();
});
});

View file

@ -0,0 +1,285 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { resolveDefaultSlackAccountId, resolveSlackAccount } from "./accounts.js";
const SLACK_BUTTON_MAX_ITEMS = 5;
const SLACK_SELECT_MAX_ITEMS = 100;
const SLACK_DIRECTIVE_RE = /\[\[(slack_buttons|slack_select):\s*([^\]]+)\]\]/gi;
const SLACK_OPTIONS_LINE_RE = /^\s*Options:\s*(.+?)\s*\.?\s*$/i;
const SLACK_AUTO_SELECT_MAX_ITEMS = 12;
const SLACK_SIMPLE_OPTION_RE = /^[a-z0-9][a-z0-9 _+/-]{0,31}$/i;
type SlackChoice = {
label: string;
value: string;
style?: "primary" | "secondary" | "success" | "danger";
};
function parseChoice(raw: string, options?: { allowStyle?: boolean }): SlackChoice | null {
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
const delimiter = trimmed.indexOf(":");
if (delimiter === -1) {
return {
label: trimmed,
value: trimmed,
};
}
const label = trimmed.slice(0, delimiter).trim();
let value = trimmed.slice(delimiter + 1).trim();
if (!label || !value) {
return null;
}
let style: SlackChoice["style"];
if (options?.allowStyle) {
const styleDelimiter = value.lastIndexOf(":");
if (styleDelimiter !== -1) {
const maybeStyle = normalizeLowercaseStringOrEmpty(value.slice(styleDelimiter + 1));
if (
maybeStyle === "primary" ||
maybeStyle === "secondary" ||
maybeStyle === "success" ||
maybeStyle === "danger"
) {
const unstyledValue = value.slice(0, styleDelimiter).trim();
if (unstyledValue) {
value = unstyledValue;
style = maybeStyle;
}
}
}
}
return style ? { label, value, style } : { label, value };
}
function parseChoices(
raw: string,
maxItems: number,
options?: { allowStyle?: boolean },
): SlackChoice[] {
return raw
.split(",")
.map((entry) => parseChoice(entry, options))
.filter((entry): entry is SlackChoice => Boolean(entry))
.slice(0, maxItems);
}
function buildTextBlock(
text: string,
): NonNullable<ReplyPayload["interactive"]>["blocks"][number] | null {
const trimmed = text.trim();
if (!trimmed) {
return null;
}
return { type: "text", text: trimmed };
}
function buildButtonsBlock(
raw: string,
): NonNullable<ReplyPayload["interactive"]>["blocks"][number] | null {
const choices = parseChoices(raw, SLACK_BUTTON_MAX_ITEMS, { allowStyle: true });
if (choices.length === 0) {
return null;
}
return {
type: "buttons",
buttons: choices.map((choice) =>
Object.assign(
{ label: choice.label, value: choice.value },
choice.style ? { style: choice.style } : {},
),
),
};
}
function buildSelectBlock(
raw: string,
): NonNullable<ReplyPayload["interactive"]>["blocks"][number] | null {
const parts = raw
.split("|")
.map((entry) => entry.trim())
.filter(Boolean);
if (parts.length === 0) {
return null;
}
const [first, second] = parts;
const placeholder = parts.length >= 2 ? first : "Choose an option";
const choices = parseChoices(parts.length >= 2 ? second : first, SLACK_SELECT_MAX_ITEMS);
if (choices.length === 0) {
return null;
}
return {
type: "select",
placeholder,
options: choices,
};
}
function hasSlackBlocks(payload: ReplyPayload): boolean {
const blocks = (payload.channelData?.slack as { blocks?: unknown } | undefined)?.blocks;
if (typeof blocks === "string") {
return blocks.trim().length > 0;
}
return Array.isArray(blocks) && blocks.length > 0;
}
function parseSimpleSlackOptions(raw: string): SlackChoice[] | null {
const entries = raw
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
if (entries.length < 2 || entries.length > SLACK_AUTO_SELECT_MAX_ITEMS) {
return null;
}
if (!entries.every((entry) => SLACK_SIMPLE_OPTION_RE.test(entry))) {
return null;
}
const deduped = new Set(entries.map((entry) => normalizeLowercaseStringOrEmpty(entry)));
if (deduped.size !== entries.length) {
return null;
}
return entries.map((entry) => ({
label: entry,
value: entry,
}));
}
function resolveInteractiveRepliesFromCapabilities(capabilities: unknown): boolean {
if (!capabilities) {
return false;
}
if (Array.isArray(capabilities)) {
return capabilities.some(
(entry) => normalizeLowercaseStringOrEmpty(String(entry)) === "interactivereplies",
);
}
if (typeof capabilities === "object") {
return (capabilities as { interactiveReplies?: unknown }).interactiveReplies === true;
}
return false;
}
export function isSlackInteractiveRepliesEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
const account = resolveSlackAccount({
cfg: params.cfg,
accountId: params.accountId ?? resolveDefaultSlackAccountId(params.cfg),
});
return resolveInteractiveRepliesFromCapabilities(account.config.capabilities);
}
export function compileSlackInteractiveReplies(payload: ReplyPayload): ReplyPayload {
const text = payload.text;
if (!text) {
return payload;
}
const generatedBlocks: NonNullable<ReplyPayload["interactive"]>["blocks"] = [];
const visibleTextParts: string[] = [];
let cursor = 0;
let matchedDirective = false;
let generatedInteractiveBlock = false;
SLACK_DIRECTIVE_RE.lastIndex = 0;
for (const match of text.matchAll(SLACK_DIRECTIVE_RE)) {
matchedDirective = true;
const matchText = match[0];
const directiveType = match[1];
const body = match[2];
const index = match.index ?? 0;
const precedingText = text.slice(cursor, index);
visibleTextParts.push(precedingText);
const section = buildTextBlock(precedingText);
if (section) {
generatedBlocks.push(section);
}
const block =
normalizeLowercaseStringOrEmpty(directiveType) === "slack_buttons"
? buildButtonsBlock(body)
: buildSelectBlock(body);
if (block) {
generatedInteractiveBlock = true;
generatedBlocks.push(block);
}
cursor = index + matchText.length;
}
const trailingText = text.slice(cursor);
visibleTextParts.push(trailingText);
const trailingSection = buildTextBlock(trailingText);
if (trailingSection) {
generatedBlocks.push(trailingSection);
}
const cleanedText = visibleTextParts.join("");
if (!matchedDirective || !generatedInteractiveBlock) {
return parseSlackOptionsLine(payload);
}
return {
...payload,
text: cleanedText.trim() || undefined,
interactive: {
blocks: [...(payload.interactive?.blocks ?? []), ...generatedBlocks],
},
};
}
export function parseSlackOptionsLine(payload: ReplyPayload): ReplyPayload {
const text = payload.text;
if (!text || payload.interactive?.blocks?.length || hasSlackBlocks(payload)) {
return payload;
}
const lines = text.split("\n");
const lastNonEmptyIndex = [...lines.keys()].toReversed().find((index) => lines[index]?.trim());
if (lastNonEmptyIndex == null) {
return payload;
}
const optionsLine = lines[lastNonEmptyIndex] ?? "";
const match = optionsLine.match(SLACK_OPTIONS_LINE_RE);
if (!match) {
return payload;
}
const choices = parseSimpleSlackOptions(match[1] ?? "");
if (!choices) {
return payload;
}
const bodyText = lines
.filter((_, index) => index !== lastNonEmptyIndex)
.join("\n")
.trim();
const generatedBlocks: NonNullable<ReplyPayload["interactive"]>["blocks"] = [];
const bodyBlock = buildTextBlock(bodyText);
if (bodyBlock) {
generatedBlocks.push(bodyBlock);
}
generatedBlocks.push(
choices.length <= SLACK_BUTTON_MAX_ITEMS
? {
type: "buttons",
buttons: choices,
}
: {
type: "select",
placeholder: "Choose an option",
options: choices,
},
);
return {
...payload,
text: bodyText || undefined,
interactive: {
blocks: [...(payload.interactive?.blocks ?? []), ...generatedBlocks],
},
};
}

View file

@ -0,0 +1 @@
export const SLACK_TEXT_LIMIT = 8000;

View file

@ -0,0 +1,178 @@
import { describe, expect, it, vi } from "vitest";
import { handleSlackMessageAction } from "./message-action-dispatch.js";
function createInvokeSpy() {
return vi.fn(async (action: Record<string, unknown>) => ({
ok: true,
content: action,
}));
}
describe("handleSlackMessageAction", () => {
it("maps upload-file to the internal uploadFile action", async () => {
const invoke = createInvokeSpy();
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "upload-file",
cfg: {},
params: {
to: "user:U1",
filePath: "/tmp/report.png",
initialComment: "fresh build",
filename: "build.png",
title: "Build Screenshot",
threadId: "111.222",
},
} as never,
invoke: invoke as never,
});
expect(invoke).toHaveBeenCalledWith(
expect.objectContaining({
action: "uploadFile",
to: "user:U1",
filePath: "/tmp/report.png",
initialComment: "fresh build",
filename: "build.png",
title: "Build Screenshot",
threadTs: "111.222",
}),
expect.any(Object),
undefined,
);
});
it("maps upload-file aliases to upload params", async () => {
const invoke = createInvokeSpy();
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "upload-file",
cfg: {},
params: {
channelId: "C1",
media: "/tmp/chart.png",
message: "chart attached",
replyTo: "333.444",
},
} as never,
invoke: invoke as never,
});
expect(invoke).toHaveBeenCalledWith(
expect.objectContaining({
action: "uploadFile",
to: "C1",
filePath: "/tmp/chart.png",
initialComment: "chart attached",
threadTs: "333.444",
}),
expect.any(Object),
undefined,
);
});
it("maps upload-file path alias to filePath", async () => {
const invoke = createInvokeSpy();
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "upload-file",
cfg: {},
params: {
to: "channel:C1",
path: "/tmp/report.txt",
initialComment: "path alias",
},
} as never,
invoke: invoke as never,
});
expect(invoke).toHaveBeenCalledWith(
expect.objectContaining({
action: "uploadFile",
to: "channel:C1",
filePath: "/tmp/report.txt",
initialComment: "path alias",
}),
expect.any(Object),
undefined,
);
});
it("requires filePath, path, or media for upload-file", async () => {
await expect(
handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "upload-file",
cfg: {},
params: {
to: "channel:C1",
},
} as never,
invoke: createInvokeSpy() as never,
}),
).rejects.toThrow(/upload-file requires filePath, path, or media/i);
});
it("maps download-file to the internal downloadFile action", async () => {
const invoke = createInvokeSpy();
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "download-file",
cfg: {},
params: {
channelId: "C1",
fileId: "F123",
threadId: "111.222",
},
} as never,
invoke: invoke as never,
});
expect(invoke).toHaveBeenCalledWith(
expect.objectContaining({
action: "downloadFile",
fileId: "F123",
channelId: "C1",
threadId: "111.222",
}),
expect.any(Object),
);
});
it("maps download-file target aliases to scope fields", async () => {
const invoke = createInvokeSpy();
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "download-file",
cfg: {},
params: {
to: "channel:C2",
fileId: "F999",
replyTo: "333.444",
},
} as never,
invoke: invoke as never,
});
expect(invoke).toHaveBeenCalledWith(
expect.objectContaining({
action: "downloadFile",
fileId: "F999",
channelId: "channel:C2",
threadId: "333.444",
}),
expect.any(Object),
);
});
});

View file

@ -0,0 +1,233 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
import { normalizeInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
import { readNumberParam, readStringParam } from "openclaw/plugin-sdk/param-readers";
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>>;
function readSlackBlocksParam(actionParams: Record<string, unknown>) {
return parseSlackBlocksInput(actionParams.blocks) as Record<string, unknown>[] | undefined;
}
/** Translate generic channel action requests into Slack-specific tool invocations and payload shapes. */
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 });
return normalizeChannelId ? normalizeChannelId(channelId) : channelId;
};
if (action === "send") {
const to = readStringParam(actionParams, "to", { required: true });
const content = readStringParam(actionParams, "message", {
required: false,
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,
);
}
if (action === "upload-file") {
const to = readStringParam(actionParams, "to") ?? resolveChannelId();
const filePath =
readStringParam(actionParams, "filePath", { trim: false }) ??
readStringParam(actionParams, "path", { trim: false }) ??
readStringParam(actionParams, "media", { trim: false });
if (!filePath) {
throw new Error("upload-file requires filePath, path, or media");
}
const threadId =
readStringParam(actionParams, "threadId") ?? readStringParam(actionParams, "replyTo");
return await invoke(
{
action: "uploadFile",
to,
filePath,
initialComment:
readStringParam(actionParams, "initialComment", { allowEmpty: true }) ??
readStringParam(actionParams, "message", { allowEmpty: true }) ??
"",
filename: readStringParam(actionParams, "filename"),
title: readStringParam(actionParams, "title"),
threadTs: threadId ?? undefined,
accountId,
},
cfg,
ctx.toolContext,
);
}
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
}

View file

@ -0,0 +1,73 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it } from "vitest";
import { listSlackMessageActions } from "./message-actions.js";
describe("listSlackMessageActions", () => {
it("includes file actions when message actions are enabled", () => {
const cfg = {
channels: {
slack: {
botToken: "xoxb-test",
actions: {
messages: true,
},
},
},
} as OpenClawConfig;
expect(listSlackMessageActions(cfg)).toEqual(
expect.arrayContaining(["read", "edit", "delete", "download-file", "upload-file"]),
);
});
it("honors the selected Slack account during discovery", () => {
const cfg = {
channels: {
slack: {
botToken: "xoxb-root",
actions: {
reactions: false,
messages: false,
pins: false,
memberInfo: false,
emojiList: false,
},
accounts: {
default: {
botToken: "xoxb-default",
actions: {
reactions: false,
messages: false,
pins: false,
memberInfo: false,
emojiList: false,
},
},
work: {
botToken: "xoxb-work",
actions: {
reactions: true,
messages: true,
pins: false,
memberInfo: false,
emojiList: false,
},
},
},
},
},
} as OpenClawConfig;
expect(listSlackMessageActions(cfg, "default")).toEqual(["send"]);
expect(listSlackMessageActions(cfg, "work")).toEqual([
"send",
"react",
"reactions",
"read",
"edit",
"delete",
"download-file",
"upload-file",
]);
});
});

View file

@ -0,0 +1,58 @@
import { createActionGate } from "openclaw/plugin-sdk/channel-actions";
import type { ChannelMessageActionName } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { extractToolSend, type ChannelToolSend } from "openclaw/plugin-sdk/tool-send";
import { listEnabledSlackAccounts, resolveSlackAccount } from "./accounts.js";
export function listSlackMessageActions(
cfg: OpenClawConfig,
accountId?: string | null,
): ChannelMessageActionName[] {
const accounts = (
accountId ? [resolveSlackAccount({ cfg, accountId })] : listEnabledSlackAccounts(cfg)
).filter((account) => account.enabled && account.botTokenSource !== "none");
if (accounts.length === 0) {
return [];
}
const isActionEnabled = (key: string, defaultValue = true) => {
for (const account of accounts) {
const gate = createActionGate(
(account.actions ?? cfg.channels?.slack?.actions) as Record<string, boolean | undefined>,
);
if (gate(key, defaultValue)) {
return true;
}
}
return false;
};
const actions = new Set<ChannelMessageActionName>(["send"]);
if (isActionEnabled("reactions")) {
actions.add("react");
actions.add("reactions");
}
if (isActionEnabled("messages")) {
actions.add("read");
actions.add("edit");
actions.add("delete");
actions.add("download-file");
actions.add("upload-file");
}
if (isActionEnabled("pins")) {
actions.add("pin");
actions.add("unpin");
actions.add("list-pins");
}
if (isActionEnabled("memberInfo")) {
actions.add("member-info");
}
if (isActionEnabled("emojiList")) {
actions.add("emoji-list");
}
return Array.from(actions);
}
export function extractSlackToolSend(args: Record<string, unknown>): ChannelToolSend | null {
return extractToolSend(args, "sendMessage");
}

View file

@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { describeSlackMessageTool } from "./message-tool-api.js";
describe("Slack message tool public API", () => {
it("describes configured Slack message actions without loading channel runtime", () => {
expect(
describeSlackMessageTool({
cfg: {
channels: {
slack: {
botToken: "xoxb-test",
},
},
},
}),
).toMatchObject({
actions: expect.arrayContaining(["send", "upload-file", "read"]),
capabilities: expect.arrayContaining(["blocks"]),
});
});
it("honors account-scoped action gates", () => {
expect(
describeSlackMessageTool({
cfg: {
channels: {
slack: {
botToken: "xoxb-default",
accounts: {
ops: {
botToken: "xoxb-ops",
actions: {
messages: false,
},
},
},
},
},
},
accountId: "ops",
}).actions,
).not.toContain("upload-file");
});
});

View file

@ -0,0 +1,30 @@
import { Type } from "@sinclair/typebox";
import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-contract";
import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js";
import { listSlackMessageActions } from "./message-actions.js";
import { createSlackMessageToolBlocksSchema } from "./message-tool-schema.js";
export function describeSlackMessageTool({
cfg,
accountId,
}: Parameters<NonNullable<ChannelMessageActionAdapter["describeMessageTool"]>>[0]) {
const actions = listSlackMessageActions(cfg, accountId);
const capabilities = new Set<"blocks" | "interactive">();
if (actions.includes("send")) {
capabilities.add("blocks");
}
if (isSlackInteractiveRepliesEnabled({ cfg, accountId })) {
capabilities.add("interactive");
}
return {
actions,
capabilities: Array.from(capabilities),
schema: actions.includes("send")
? {
properties: {
blocks: Type.Optional(createSlackMessageToolBlocksSchema()),
},
}
: null,
};
}

View file

@ -0,0 +1,13 @@
import { Type } from "@sinclair/typebox";
export function createSlackMessageToolBlocksSchema() {
return Type.Array(
Type.Object(
{},
{
additionalProperties: true,
description: "Slack Block Kit payload blocks (Slack only).",
},
),
);
}

View file

@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import {
encodeSlackModalPrivateMetadata,
parseSlackModalPrivateMetadata,
} from "./modal-metadata.js";
describe("parseSlackModalPrivateMetadata", () => {
it("returns empty object for missing or invalid values", () => {
expect(parseSlackModalPrivateMetadata(undefined)).toEqual({});
expect(parseSlackModalPrivateMetadata("")).toEqual({});
expect(parseSlackModalPrivateMetadata("{bad-json")).toEqual({});
});
it("parses known metadata fields", () => {
expect(
parseSlackModalPrivateMetadata(
JSON.stringify({
sessionKey: "agent:main:slack:channel:C1",
channelId: "D123",
channelType: "im",
userId: "U123",
ignored: "x",
}),
),
).toEqual({
sessionKey: "agent:main:slack:channel:C1",
channelId: "D123",
channelType: "im",
userId: "U123",
});
});
});
describe("encodeSlackModalPrivateMetadata", () => {
it("encodes only known non-empty fields", () => {
expect(
JSON.parse(
encodeSlackModalPrivateMetadata({
sessionKey: "agent:main:slack:channel:C1",
channelId: "",
channelType: "im",
userId: "U123",
}),
),
).toEqual({
sessionKey: "agent:main:slack:channel:C1",
channelType: "im",
userId: "U123",
});
});
it("throws when encoded payload exceeds Slack metadata limit", () => {
expect(() =>
encodeSlackModalPrivateMetadata({
sessionKey: `agent:main:${"x".repeat(4000)}`,
}),
).toThrow(/cannot exceed 3000 chars/i);
});
});

View file

@ -0,0 +1,43 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
export type SlackModalPrivateMetadata = {
sessionKey?: string;
channelId?: string;
channelType?: string;
userId?: string;
};
const SLACK_PRIVATE_METADATA_MAX = 3000;
export function parseSlackModalPrivateMetadata(raw: unknown): SlackModalPrivateMetadata {
if (typeof raw !== "string" || raw.trim().length === 0) {
return {};
}
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
return {
sessionKey: normalizeOptionalString(parsed.sessionKey),
channelId: normalizeOptionalString(parsed.channelId),
channelType: normalizeOptionalString(parsed.channelType),
userId: normalizeOptionalString(parsed.userId),
};
} catch {
return {};
}
}
export function encodeSlackModalPrivateMetadata(input: SlackModalPrivateMetadata): string {
const payload: SlackModalPrivateMetadata = {
...(input.sessionKey ? { sessionKey: input.sessionKey } : {}),
...(input.channelId ? { channelId: input.channelId } : {}),
...(input.channelType ? { channelType: input.channelType } : {}),
...(input.userId ? { userId: input.userId } : {}),
};
const encoded = JSON.stringify(payload);
if (encoded.length > SLACK_PRIVATE_METADATA_MAX) {
throw new Error(
`Slack modal private_metadata cannot exceed ${SLACK_PRIVATE_METADATA_MAX} chars`,
);
}
return encoded;
}

View file

@ -0,0 +1,270 @@
import { Mock, vi } from "vitest";
type SlackHandler = (args: unknown) => Promise<void>;
type SlackProviderMonitor = (params: {
botToken: string;
appToken: string;
abortSignal: AbortSignal;
config?: Record<string, unknown>;
}) => Promise<unknown>;
type SlackTestState = {
config: Record<string, unknown>;
sendMock: Mock<(...args: unknown[]) => Promise<unknown>>;
replyMock: Mock<(...args: unknown[]) => unknown>;
updateLastRouteMock: Mock<(...args: unknown[]) => unknown>;
reactMock: Mock<(...args: unknown[]) => unknown>;
readAllowFromStoreMock: Mock<(...args: unknown[]) => Promise<unknown>>;
upsertPairingRequestMock: Mock<(...args: unknown[]) => Promise<unknown>>;
};
const slackTestState: SlackTestState = vi.hoisted(() => ({
config: {} as Record<string, unknown>,
sendMock: vi.fn(),
replyMock: vi.fn(),
updateLastRouteMock: vi.fn(),
reactMock: vi.fn(),
readAllowFromStoreMock: vi.fn(),
upsertPairingRequestMock: vi.fn(),
}));
export const getSlackTestState = (): SlackTestState => slackTestState;
type SlackClient = {
auth: { test: Mock<(...args: unknown[]) => Promise<Record<string, unknown>>> };
conversations: {
info: Mock<(...args: unknown[]) => Promise<Record<string, unknown>>>;
replies: Mock<(...args: unknown[]) => Promise<Record<string, unknown>>>;
history: Mock<(...args: unknown[]) => Promise<Record<string, unknown>>>;
};
users: {
info: Mock<(...args: unknown[]) => Promise<{ user: { profile: { display_name: string } } }>>;
};
assistant: {
threads: {
setStatus: Mock<(...args: unknown[]) => Promise<{ ok: boolean }>>;
};
};
reactions: {
add: (...args: unknown[]) => unknown;
remove: (...args: unknown[]) => unknown;
};
};
export const getSlackHandlers = () => ensureSlackTestRuntime().handlers;
export const getSlackClient = () => ensureSlackTestRuntime().client;
function ensureSlackTestRuntime(): {
handlers: Map<string, SlackHandler>;
client: SlackClient;
} {
const globalState = globalThis as {
__slackHandlers?: Map<string, SlackHandler>;
__slackClient?: SlackClient;
};
if (!globalState.__slackHandlers) {
globalState.__slackHandlers = new Map<string, SlackHandler>();
}
if (!globalState.__slackClient) {
globalState.__slackClient = {
auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) },
conversations: {
info: vi.fn().mockResolvedValue({
channel: { name: "dm", is_im: true },
}),
replies: vi.fn().mockResolvedValue({ messages: [] }),
history: vi.fn().mockResolvedValue({ messages: [] }),
},
users: {
info: vi.fn().mockResolvedValue({
user: { profile: { display_name: "Ada" } },
}),
},
assistant: {
threads: {
setStatus: vi.fn().mockResolvedValue({ ok: true }),
},
},
reactions: {
add: (...args: unknown[]) => slackTestState.reactMock(...args),
remove: (...args: unknown[]) => slackTestState.reactMock(...args),
},
};
}
return {
handlers: globalState.__slackHandlers,
client: globalState.__slackClient,
};
}
export const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
export async function waitForSlackEvent(name: string) {
for (let i = 0; i < 10; i += 1) {
if (getSlackHandlers()?.has(name)) {
return;
}
await flush();
}
}
export function startSlackMonitor(
monitorSlackProvider: SlackProviderMonitor,
opts?: { botToken?: string; appToken?: string },
) {
const controller = new AbortController();
const run = monitorSlackProvider({
botToken: opts?.botToken ?? "bot-token",
appToken: opts?.appToken ?? "app-token",
abortSignal: controller.signal,
config: slackTestState.config,
});
return { controller, run };
}
export async function getSlackHandlerOrThrow(name: string) {
await waitForSlackEvent(name);
const handler = getSlackHandlers()?.get(name);
if (!handler) {
throw new Error(`Slack ${name} handler not registered`);
}
return handler;
}
export async function stopSlackMonitor(params: {
controller: AbortController;
run: Promise<unknown>;
}) {
await flush();
params.controller.abort();
await params.run;
}
export async function runSlackEventOnce(
monitorSlackProvider: SlackProviderMonitor,
name: string,
args: unknown,
opts?: { botToken?: string; appToken?: string },
) {
const { controller, run } = startSlackMonitor(monitorSlackProvider, opts);
const handler = await getSlackHandlerOrThrow(name);
await handler(args);
await stopSlackMonitor({ controller, run });
}
export async function runSlackMessageOnce(
monitorSlackProvider: SlackProviderMonitor,
args: unknown,
opts?: { botToken?: string; appToken?: string },
) {
await runSlackEventOnce(monitorSlackProvider, "message", args, opts);
}
export const defaultSlackTestConfig = () => ({
messages: {
responsePrefix: "PFX",
ackReaction: "👀",
ackReactionScope: "group-mentions",
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
groupPolicy: "open",
},
},
});
export function resetSlackTestState(config: Record<string, unknown> = defaultSlackTestConfig()) {
slackTestState.config = config;
slackTestState.sendMock.mockReset().mockResolvedValue(undefined);
slackTestState.replyMock.mockReset();
slackTestState.updateLastRouteMock.mockReset();
slackTestState.reactMock.mockReset();
slackTestState.readAllowFromStoreMock.mockReset().mockResolvedValue([]);
slackTestState.upsertPairingRequestMock.mockReset().mockResolvedValue({
code: "PAIRCODE",
created: true,
});
getSlackHandlers()?.clear();
}
vi.mock("./monitor/config.runtime.js", async () => {
const actual = await vi.importActual<typeof import("./monitor/config.runtime.js")>(
"./monitor/config.runtime.js",
);
return {
...actual,
loadConfig: () => slackTestState.config,
readSessionUpdatedAt: vi.fn(() => undefined),
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"),
updateLastRoute: (...args: unknown[]) => slackTestState.updateLastRouteMock(...args),
};
});
vi.mock("./monitor/reply.runtime.js", async () => {
const actual = await vi.importActual<typeof import("./monitor/reply.runtime.js")>(
"./monitor/reply.runtime.js",
);
const replyResolver: typeof actual.getReplyFromConfig = (...args) =>
slackTestState.replyMock(...args) as ReturnType<typeof actual.getReplyFromConfig>;
return {
...actual,
dispatchInboundMessage: (params: Parameters<typeof actual.dispatchInboundMessage>[0]) =>
actual.dispatchInboundMessage({
...params,
replyResolver,
}),
getReplyFromConfig: replyResolver,
};
});
vi.mock("./resolve-channels.js", () => ({
resolveSlackChannelAllowlist: async ({ entries }: { entries: string[] }) =>
entries.map((input) => ({ input, resolved: false })),
}));
vi.mock("./resolve-users.js", () => ({
resolveSlackUserAllowlist: async ({ entries }: { entries: string[] }) =>
entries.map((input) => ({ input, resolved: false })),
}));
vi.mock("./monitor/send.runtime.js", () => {
return {
sendMessageSlack: (...args: unknown[]) => slackTestState.sendMock(...args),
};
});
vi.mock("./monitor/conversation.runtime.js", async () => {
const actual = await vi.importActual<typeof import("./monitor/conversation.runtime.js")>(
"./monitor/conversation.runtime.js",
);
return {
...actual,
readChannelAllowFromStore: (...args: unknown[]) =>
slackTestState.readAllowFromStoreMock(...args),
recordInboundSession: vi.fn().mockResolvedValue(undefined),
upsertChannelPairingRequest: (...args: unknown[]) =>
slackTestState.upsertPairingRequestMock(...args),
};
});
vi.mock("@slack/bolt", () => {
const { handlers, client: slackClient } = ensureSlackTestRuntime();
class App {
client = slackClient;
event(name: string, handler: SlackHandler) {
handlers.set(name, handler);
}
command() {
/* no-op */
}
start = vi.fn().mockResolvedValue(undefined);
stop = vi.fn().mockResolvedValue(undefined);
}
class HTTPReceiver {
requestListener = vi.fn();
}
return { App, HTTPReceiver, default: { App, HTTPReceiver } };
});

View file

@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import { buildSlackSlashCommandMatcher } from "./monitor/commands.js";
import { isSlackChannelAllowedByPolicy } from "./monitor/policy.js";
import { resolveSlackThreadTs } from "./monitor/replies.js";
describe("slack groupPolicy gating", () => {
it("allows when policy is open", () => {
expect(
isSlackChannelAllowedByPolicy({
groupPolicy: "open",
channelAllowlistConfigured: false,
channelAllowed: false,
}),
).toBe(true);
});
it("blocks when policy is disabled", () => {
expect(
isSlackChannelAllowedByPolicy({
groupPolicy: "disabled",
channelAllowlistConfigured: true,
channelAllowed: true,
}),
).toBe(false);
});
it("blocks allowlist when no channel allowlist configured", () => {
expect(
isSlackChannelAllowedByPolicy({
groupPolicy: "allowlist",
channelAllowlistConfigured: false,
channelAllowed: true,
}),
).toBe(false);
});
it("allows allowlist when channel is allowed", () => {
expect(
isSlackChannelAllowedByPolicy({
groupPolicy: "allowlist",
channelAllowlistConfigured: true,
channelAllowed: true,
}),
).toBe(true);
});
it("blocks allowlist when channel is not allowed", () => {
expect(
isSlackChannelAllowedByPolicy({
groupPolicy: "allowlist",
channelAllowlistConfigured: true,
channelAllowed: false,
}),
).toBe(false);
});
});
describe("resolveSlackThreadTs", () => {
const threadTs = "1234567890.123456";
const messageTs = "9999999999.999999";
it("stays in incoming threads for all replyToMode values", () => {
for (const replyToMode of ["off", "first", "all", "batched"] as const) {
for (const hasReplied of [false, true]) {
expect(
resolveSlackThreadTs({
replyToMode,
incomingThreadTs: threadTs,
messageTs,
hasReplied,
isThreadReply: true,
}),
).toBe(threadTs);
}
}
});
describe("replyToMode=off", () => {
it("returns undefined when not in a thread", () => {
expect(
resolveSlackThreadTs({
replyToMode: "off",
incomingThreadTs: undefined,
messageTs,
hasReplied: false,
}),
).toBeUndefined();
});
});
describe("replyToMode=first", () => {
it("returns messageTs for first reply when not in a thread", () => {
expect(
resolveSlackThreadTs({
replyToMode: "first",
incomingThreadTs: undefined,
messageTs,
hasReplied: false,
}),
).toBe(messageTs);
});
it("returns undefined for subsequent replies when not in a thread (goes to main channel)", () => {
expect(
resolveSlackThreadTs({
replyToMode: "first",
incomingThreadTs: undefined,
messageTs,
hasReplied: true,
}),
).toBeUndefined();
});
});
describe("replyToMode=all", () => {
it("returns messageTs when not in a thread (starts thread)", () => {
expect(
resolveSlackThreadTs({
replyToMode: "all",
incomingThreadTs: undefined,
messageTs,
hasReplied: true,
}),
).toBe(messageTs);
});
});
});
describe("buildSlackSlashCommandMatcher", () => {
it("matches with or without a leading slash", () => {
const matcher = buildSlackSlashCommandMatcher("openclaw");
expect(matcher.test("openclaw")).toBe(true);
expect(matcher.test("/openclaw")).toBe(true);
});
it("does not match similar names", () => {
const matcher = buildSlackSlashCommandMatcher("openclaw");
expect(matcher.test("/openclaw-bot")).toBe(false);
expect(matcher.test("openclaw-bot")).toBe(false);
});
});

View file

@ -0,0 +1,61 @@
import { describe, expect, it, vi } from "vitest";
import { createSlackThreadTsResolver } from "./monitor/thread-resolution.js";
import type { SlackMessageEvent } from "./types.js";
function makeThreadReplyMessage(): SlackMessageEvent {
return {
type: "message",
user: "U1",
text: "hello",
ts: "456",
parent_user_id: "U2",
channel: "C1",
channel_type: "channel",
};
}
async function runMissingThreadScenario(params: {
historyResponse?: { messages: Array<{ ts?: string; thread_ts?: string }> };
historyError?: Error;
}) {
const history = vi.fn();
if (params.historyError) {
history.mockRejectedValueOnce(params.historyError);
} else {
history.mockResolvedValueOnce(params.historyResponse ?? { messages: [{ ts: "456" }] });
}
const resolver = createSlackThreadTsResolver({
client: { conversations: { history } } as never,
cacheTtlMs: 60_000,
maxSize: 5,
});
return await resolver.resolve({
message: makeThreadReplyMessage(),
source: "message",
});
}
describe("Slack missing thread_ts recovery", () => {
it("recovers missing thread_ts when parent_user_id is present", async () => {
const message = await runMissingThreadScenario({
historyResponse: { messages: [{ ts: "456", thread_ts: "111.222" }] },
});
expect(message).toMatchObject({ thread_ts: "111.222" });
});
it("continues without thread_ts when history lookup returns no thread result", async () => {
const message = await runMissingThreadScenario({
historyResponse: { messages: [{ ts: "456" }] },
});
expect(message.thread_ts).toBeUndefined();
});
it("continues without thread_ts when history lookup throws", async () => {
const message = await runMissingThreadScenario({
historyError: new Error("history failed"),
});
expect(message.thread_ts).toBeUndefined();
});
});

Some files were not shown because too many files have changed in this diff Show more