mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-12 21:20:49 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
6
openclaw/extensions/slack/account-inspect-api.ts
Normal file
6
openclaw/extensions/slack/account-inspect-api.ts
Normal 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 });
|
||||
}
|
||||
26
openclaw/extensions/slack/api.ts
Normal file
26
openclaw/extensions/slack/api.ts
Normal 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";
|
||||
1
openclaw/extensions/slack/channel-config-api.ts
Normal file
1
openclaw/extensions/slack/channel-config-api.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { SlackChannelConfigSchema } from "./src/config-schema.js";
|
||||
20
openclaw/extensions/slack/channel-entry.ts
Normal file
20
openclaw/extensions/slack/channel-entry.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
3
openclaw/extensions/slack/channel-plugin-api.ts
Normal file
3
openclaw/extensions/slack/channel-plugin-api.ts
Normal 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";
|
||||
4
openclaw/extensions/slack/config-api.ts
Normal file
4
openclaw/extensions/slack/config-api.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export {
|
||||
buildChannelConfigSchema,
|
||||
SlackConfigSchema,
|
||||
} from "openclaw/plugin-sdk/channel-config-schema";
|
||||
7
openclaw/extensions/slack/configured-state.ts
Normal file
7
openclaw/extensions/slack/configured-state.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
14
openclaw/extensions/slack/contract-api.ts
Normal file
14
openclaw/extensions/slack/contract-api.ts
Normal 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";
|
||||
4
openclaw/extensions/slack/directory-contract-api.ts
Normal file
4
openclaw/extensions/slack/directory-contract-api.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export {
|
||||
listSlackDirectoryGroupsFromConfig,
|
||||
listSlackDirectoryPeersFromConfig,
|
||||
} from "./src/directory-config.js";
|
||||
1
openclaw/extensions/slack/doctor-contract-api.ts
Normal file
1
openclaw/extensions/slack/doctor-contract-api.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
|
||||
2
openclaw/extensions/slack/inbound-contract-test-api.ts
Normal file
2
openclaw/extensions/slack/inbound-contract-test-api.ts
Normal 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";
|
||||
13
openclaw/extensions/slack/index.test.ts
Normal file
13
openclaw/extensions/slack/index.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
37
openclaw/extensions/slack/index.ts
Normal file
37
openclaw/extensions/slack/index.ts
Normal 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,
|
||||
});
|
||||
4
openclaw/extensions/slack/interactive-replies-api.ts
Normal file
4
openclaw/extensions/slack/interactive-replies-api.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export {
|
||||
compileSlackInteractiveReplies,
|
||||
isSlackInteractiveRepliesEnabled,
|
||||
} from "./src/interactive-replies.js";
|
||||
1
openclaw/extensions/slack/message-tool-api.ts
Normal file
1
openclaw/extensions/slack/message-tool-api.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { describeSlackMessageTool as describeMessageTool } from "./src/message-tool-api.js";
|
||||
12
openclaw/extensions/slack/openclaw.plugin.json
Normal file
12
openclaw/extensions/slack/openclaw.plugin.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
1
openclaw/extensions/slack/outbound-payload-test-api.ts
Normal file
1
openclaw/extensions/slack/outbound-payload-test-api.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { createSlackOutboundPayloadHarness } from "./src/outbound-payload.test-harness.js";
|
||||
39
openclaw/extensions/slack/package.json
Normal file
39
openclaw/extensions/slack/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
7
openclaw/extensions/slack/runtime-api.ts
Normal file
7
openclaw/extensions/slack/runtime-api.ts
Normal 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";
|
||||
5
openclaw/extensions/slack/secret-contract-api.ts
Normal file
5
openclaw/extensions/slack/secret-contract-api.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export {
|
||||
channelSecrets,
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
1
openclaw/extensions/slack/security-contract-api.ts
Normal file
1
openclaw/extensions/slack/security-contract-api.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { collectSlackSecurityAuditFindings } from "./src/security-audit.js";
|
||||
13
openclaw/extensions/slack/setup-entry.ts
Normal file
13
openclaw/extensions/slack/setup-entry.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
3
openclaw/extensions/slack/setup-plugin-api.ts
Normal file
3
openclaw/extensions/slack/setup-plugin-api.ts
Normal 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";
|
||||
190
openclaw/extensions/slack/src/account-inspect.ts
Normal file
190
openclaw/extensions/slack/src/account-inspect.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
15
openclaw/extensions/slack/src/account-surface-fields.ts
Normal file
15
openclaw/extensions/slack/src/account-surface-fields.ts
Normal 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"];
|
||||
};
|
||||
1
openclaw/extensions/slack/src/accounts.runtime.ts
Normal file
1
openclaw/extensions/slack/src/accounts.runtime.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { resolveSlackAccount } from "./accounts.js";
|
||||
319
openclaw/extensions/slack/src/accounts.test.ts
Normal file
319
openclaw/extensions/slack/src/accounts.test.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
158
openclaw/extensions/slack/src/accounts.ts
Normal file
158
openclaw/extensions/slack/src/accounts.ts
Normal 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";
|
||||
}
|
||||
1
openclaw/extensions/slack/src/action-runtime.runtime.ts
Normal file
1
openclaw/extensions/slack/src/action-runtime.runtime.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { handleSlackAction } from "./action-runtime.js";
|
||||
699
openclaw/extensions/slack/src/action-runtime.test.ts
Normal file
699
openclaw/extensions/slack/src/action-runtime.test.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
489
openclaw/extensions/slack/src/action-runtime.ts
Normal file
489
openclaw/extensions/slack/src/action-runtime.ts
Normal 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}`);
|
||||
}
|
||||
67
openclaw/extensions/slack/src/action-threading.test.ts
Normal file
67
openclaw/extensions/slack/src/action-threading.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
35
openclaw/extensions/slack/src/action-threading.ts
Normal file
35
openclaw/extensions/slack/src/action-threading.ts
Normal 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;
|
||||
}
|
||||
125
openclaw/extensions/slack/src/actions.blocks.test.ts
Normal file
125
openclaw/extensions/slack/src/actions.blocks.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
168
openclaw/extensions/slack/src/actions.download-file.test.ts
Normal file
168
openclaw/extensions/slack/src/actions.download-file.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
66
openclaw/extensions/slack/src/actions.read.test.ts
Normal file
66
openclaw/extensions/slack/src/actions.read.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
16
openclaw/extensions/slack/src/actions.runtime.ts
Normal file
16
openclaw/extensions/slack/src/actions.runtime.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export {
|
||||
deleteSlackMessage,
|
||||
downloadSlackFile,
|
||||
editSlackMessage,
|
||||
getSlackMemberInfo,
|
||||
listSlackEmojis,
|
||||
listSlackPins,
|
||||
listSlackReactions,
|
||||
pinSlackMessage,
|
||||
reactSlackMessage,
|
||||
readSlackMessages,
|
||||
removeOwnSlackReactions,
|
||||
removeSlackReaction,
|
||||
sendSlackMessage,
|
||||
unpinSlackMessage,
|
||||
} from "./actions.js";
|
||||
460
openclaw/extensions/slack/src/actions.ts
Normal file
460
openclaw/extensions/slack/src/actions.ts
Normal 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;
|
||||
}
|
||||
59
openclaw/extensions/slack/src/approval-auth.test.ts
Normal file
59
openclaw/extensions/slack/src/approval-auth.test.ts
Normal 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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
39
openclaw/extensions/slack/src/approval-auth.ts
Normal file
39
openclaw/extensions/slack/src/approval-auth.ts
Normal 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),
|
||||
});
|
||||
116
openclaw/extensions/slack/src/approval-handler.runtime.test.ts
Normal file
116
openclaw/extensions/slack/src/approval-handler.runtime.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
323
openclaw/extensions/slack/src/approval-handler.runtime.ts
Normal file
323
openclaw/extensions/slack/src/approval-handler.runtime.ts
Normal 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)}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
438
openclaw/extensions/slack/src/approval-native.test.ts
Normal file
438
openclaw/extensions/slack/src/approval-native.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
198
openclaw/extensions/slack/src/approval-native.ts
Normal file
198
openclaw/extensions/slack/src/approval-native.ts
Normal 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);
|
||||
31
openclaw/extensions/slack/src/blocks-fallback.test.ts
Normal file
31
openclaw/extensions/slack/src/blocks-fallback.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
95
openclaw/extensions/slack/src/blocks-fallback.ts
Normal file
95
openclaw/extensions/slack/src/blocks-fallback.ts
Normal 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";
|
||||
}
|
||||
57
openclaw/extensions/slack/src/blocks-input.test.ts
Normal file
57
openclaw/extensions/slack/src/blocks-input.test.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
45
openclaw/extensions/slack/src/blocks-input.ts
Normal file
45
openclaw/extensions/slack/src/blocks-input.ts
Normal 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);
|
||||
}
|
||||
110
openclaw/extensions/slack/src/blocks-render.ts
Normal file
110
openclaw/extensions/slack/src/blocks-render.ts
Normal 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;
|
||||
}
|
||||
69
openclaw/extensions/slack/src/blocks.test-helpers.ts
Normal file
69
openclaw/extensions/slack/src/blocks.test-helpers.ts
Normal 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;
|
||||
}
|
||||
46
openclaw/extensions/slack/src/channel-actions.ts
Normal file
46
openclaw/extensions/slack/src/channel-actions.ts
Normal 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,
|
||||
})),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
27
openclaw/extensions/slack/src/channel-api.ts
Normal file
27
openclaw/extensions/slack/src/channel-api.ts
Normal 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;
|
||||
}
|
||||
118
openclaw/extensions/slack/src/channel-migration.test.ts
Normal file
118
openclaw/extensions/slack/src/channel-migration.test.ts
Normal 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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
103
openclaw/extensions/slack/src/channel-migration.ts
Normal file
103
openclaw/extensions/slack/src/channel-migration.ts
Normal 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 };
|
||||
}
|
||||
61
openclaw/extensions/slack/src/channel-type.test.ts
Normal file
61
openclaw/extensions/slack/src/channel-type.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
79
openclaw/extensions/slack/src/channel-type.ts
Normal file
79
openclaw/extensions/slack/src/channel-type.ts
Normal 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();
|
||||
}
|
||||
12
openclaw/extensions/slack/src/channel.setup.ts
Normal file
12
openclaw/extensions/slack/src/channel.setup.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
835
openclaw/extensions/slack/src/channel.test.ts
Normal file
835
openclaw/extensions/slack/src/channel.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
664
openclaw/extensions/slack/src/channel.ts
Normal file
664
openclaw/extensions/slack/src/channel.ts
Normal 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 } : {}),
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
217
openclaw/extensions/slack/src/client.test.ts
Normal file
217
openclaw/extensions/slack/src/client.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
103
openclaw/extensions/slack/src/client.ts
Normal file
103
openclaw/extensions/slack/src/client.ts
Normal 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));
|
||||
}
|
||||
6
openclaw/extensions/slack/src/config-schema.ts
Normal file
6
openclaw/extensions/slack/src/config-schema.ts
Normal 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,
|
||||
});
|
||||
116
openclaw/extensions/slack/src/config-ui-hints.ts
Normal file
116
openclaw/extensions/slack/src/config-ui-hints.ts
Normal 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>;
|
||||
61
openclaw/extensions/slack/src/directory-config.ts
Normal file
61
openclaw/extensions/slack/src/directory-config.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
190
openclaw/extensions/slack/src/directory-live.ts
Normal file
190
openclaw/extensions/slack/src/directory-live.ts
Normal 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;
|
||||
}
|
||||
122
openclaw/extensions/slack/src/doctor-contract.ts
Normal file
122
openclaw/extensions/slack/src/doctor-contract.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
117
openclaw/extensions/slack/src/doctor.test.ts
Normal file
117
openclaw/extensions/slack/src/doctor.test.ts
Normal 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)."]);
|
||||
});
|
||||
});
|
||||
58
openclaw/extensions/slack/src/doctor.ts
Normal file
58
openclaw/extensions/slack/src/doctor.ts
Normal 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,
|
||||
};
|
||||
158
openclaw/extensions/slack/src/draft-stream.test.ts
Normal file
158
openclaw/extensions/slack/src/draft-stream.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
137
openclaw/extensions/slack/src/draft-stream.ts
Normal file
137
openclaw/extensions/slack/src/draft-stream.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
271
openclaw/extensions/slack/src/exec-approvals.test.ts
Normal file
271
openclaw/extensions/slack/src/exec-approvals.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
82
openclaw/extensions/slack/src/exec-approvals.ts
Normal file
82
openclaw/extensions/slack/src/exec-approvals.ts
Normal 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;
|
||||
91
openclaw/extensions/slack/src/format.test.ts
Normal file
91
openclaw/extensions/slack/src/format.test.ts
Normal 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 & b < c > 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>", "<b>nope</b>"],
|
||||
["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 ", "<<"]);
|
||||
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\\_\\*\\`\\~<&>\\\\");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeSlackOutboundText", () => {
|
||||
it("normalizes markdown for outbound send/update paths", () => {
|
||||
expect(normalizeSlackOutboundText(" **bold** ")).toBe("*bold*");
|
||||
});
|
||||
});
|
||||
158
openclaw/extensions/slack/src/format.ts
Normal file
158
openclaw/extensions/slack/src/format.ts
Normal 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
54
openclaw/extensions/slack/src/group-policy.test.ts
Normal file
54
openclaw/extensions/slack/src/group-policy.test.ts
Normal 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"] });
|
||||
});
|
||||
});
|
||||
77
openclaw/extensions/slack/src/group-policy.ts
Normal file
77
openclaw/extensions/slack/src/group-policy.ts
Normal 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);
|
||||
}
|
||||
1
openclaw/extensions/slack/src/http/handler.runtime.ts
Normal file
1
openclaw/extensions/slack/src/http/handler.runtime.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { handleSlackHttpRequest } from "./registry.js";
|
||||
1
openclaw/extensions/slack/src/http/index.ts
Normal file
1
openclaw/extensions/slack/src/http/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./registry.js";
|
||||
7
openclaw/extensions/slack/src/http/paths.ts
Normal file
7
openclaw/extensions/slack/src/http/paths.ts
Normal 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}`;
|
||||
}
|
||||
62
openclaw/extensions/slack/src/http/plugin-routes.test.ts
Normal file
62
openclaw/extensions/slack/src/http/plugin-routes.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
32
openclaw/extensions/slack/src/http/plugin-routes.ts
Normal file
32
openclaw/extensions/slack/src/http/plugin-routes.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
88
openclaw/extensions/slack/src/http/registry.test.ts
Normal file
88
openclaw/extensions/slack/src/http/registry.test.ts
Normal 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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
44
openclaw/extensions/slack/src/http/registry.ts
Normal file
44
openclaw/extensions/slack/src/http/registry.ts
Normal 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;
|
||||
}
|
||||
26
openclaw/extensions/slack/src/index.ts
Normal file
26
openclaw/extensions/slack/src/index.ts
Normal 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";
|
||||
109
openclaw/extensions/slack/src/interactive-dispatch.ts
Normal file
109
openclaw/extensions/slack/src/interactive-dispatch.ts
Normal 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,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
96
openclaw/extensions/slack/src/interactive-replies.test.ts
Normal file
96
openclaw/extensions/slack/src/interactive-replies.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
285
openclaw/extensions/slack/src/interactive-replies.ts
Normal file
285
openclaw/extensions/slack/src/interactive-replies.ts
Normal 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],
|
||||
},
|
||||
};
|
||||
}
|
||||
1
openclaw/extensions/slack/src/limits.ts
Normal file
1
openclaw/extensions/slack/src/limits.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const SLACK_TEXT_LIMIT = 8000;
|
||||
178
openclaw/extensions/slack/src/message-action-dispatch.test.ts
Normal file
178
openclaw/extensions/slack/src/message-action-dispatch.test.ts
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
233
openclaw/extensions/slack/src/message-action-dispatch.ts
Normal file
233
openclaw/extensions/slack/src/message-action-dispatch.ts
Normal 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}.`);
|
||||
}
|
||||
73
openclaw/extensions/slack/src/message-actions.test.ts
Normal file
73
openclaw/extensions/slack/src/message-actions.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
58
openclaw/extensions/slack/src/message-actions.ts
Normal file
58
openclaw/extensions/slack/src/message-actions.ts
Normal 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");
|
||||
}
|
||||
44
openclaw/extensions/slack/src/message-tool-api.test.ts
Normal file
44
openclaw/extensions/slack/src/message-tool-api.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
30
openclaw/extensions/slack/src/message-tool-api.ts
Normal file
30
openclaw/extensions/slack/src/message-tool-api.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
13
openclaw/extensions/slack/src/message-tool-schema.ts
Normal file
13
openclaw/extensions/slack/src/message-tool-schema.ts
Normal 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).",
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
59
openclaw/extensions/slack/src/modal-metadata.test.ts
Normal file
59
openclaw/extensions/slack/src/modal-metadata.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
43
openclaw/extensions/slack/src/modal-metadata.ts
Normal file
43
openclaw/extensions/slack/src/modal-metadata.ts
Normal 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;
|
||||
}
|
||||
270
openclaw/extensions/slack/src/monitor.test-helpers.ts
Normal file
270
openclaw/extensions/slack/src/monitor.test-helpers.ts
Normal 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 } };
|
||||
});
|
||||
143
openclaw/extensions/slack/src/monitor.test.ts
Normal file
143
openclaw/extensions/slack/src/monitor.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue