Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
# OpenClaw Slack
Official OpenClaw channel plugin for Slack channels, DMs, commands, and app events.
Install from OpenClaw:
```bash
openclaw plugin add @openclaw/slack
```
Configure the Slack app credentials and allowed workspaces/channels in OpenClaw. The plugin lets agents receive Slack events and reply through the configured Slack app.

View File

@@ -0,0 +1,7 @@
// Slack API module exposes the plugin public contract.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { inspectSlackAccount } from "./src/account-inspect.js";
export function inspectSlackReadOnlyAccount(cfg: OpenClawConfig, accountId?: string | null) {
return inspectSlackAccount({ cfg, accountId });
}

116
extensions/slack/api.ts Normal file
View File

@@ -0,0 +1,116 @@
// Slack API module exposes the plugin public contract.
export { slackPlugin } from "./src/channel.js";
export { slackSetupPlugin } from "./src/channel.setup.js";
export {
type InspectedSlackAccount,
inspectSlackAccount,
type SlackCredentialStatus,
} from "./src/account-inspect.js";
export {
listEnabledSlackAccounts,
listSlackAccountIds,
mergeSlackAccountConfig,
resolveDefaultSlackAccountId,
type ResolvedSlackAccount,
resolveSlackAccount,
resolveSlackReplyToMode,
type SlackTokenSource,
} from "./src/accounts.js";
export { resolveSlackAutoThreadId } from "./src/action-threading.js";
export {
deleteSlackMessage,
downloadSlackFile,
editSlackMessage,
getSlackMemberInfo,
listSlackEmojis,
listSlackPins,
listSlackReactions,
pinSlackMessage,
reactSlackMessage,
readSlackMessages,
removeOwnSlackReactions,
removeSlackReaction,
sendSlackMessage,
type SlackActionClientOpts,
type SlackMessageSummary,
type SlackPin,
unpinSlackMessage,
} from "./src/actions.js";
export {
parseSlackBlocksInput,
SLACK_MAX_BLOCKS,
validateSlackBlocksArray,
} from "./src/blocks-input.js";
export {
buildSlackInteractiveBlocks,
buildSlackPresentationBlocks,
type SlackBlock,
} from "./src/blocks-render.js";
export {
resetSlackChannelTypeCacheForTest as __resetSlackChannelTypeCacheForTest,
resetSlackChannelTypeCacheForTest,
resolveSlackChannelType,
} from "./src/channel-type.js";
export {
clearSlackWriteClientCacheForTest,
createSlackTokenCacheKey,
createSlackWebClient,
createSlackWriteClient,
getSlackWriteClient,
resolveSlackWebClientOptions,
resolveSlackWriteClientOptions,
SLACK_DEFAULT_RETRY_OPTIONS,
SLACK_WRITE_RETRY_OPTIONS,
} from "./src/client.js";
export {
listSlackDirectoryGroupsFromConfig,
listSlackDirectoryPeersFromConfig,
} from "./src/directory-config.js";
export {
handleSlackHttpRequest,
normalizeSlackWebhookPath,
registerSlackHttpHandler,
type SlackHttpRequestHandler,
} from "./src/http/index.js";
export type {
SlackInteractiveHandlerContext,
SlackInteractiveHandlerRegistration,
} from "./src/interactive-dispatch.js";
export {
compileSlackInteractiveReplies,
isSlackInteractiveRepliesEnabled,
parseSlackOptionsLine,
} from "./src/interactive-replies.js";
export { extractSlackToolSend, listSlackMessageActions } from "./src/message-actions.js";
export {
resolveSlackGroupRequireMention,
resolveSlackGroupToolPolicy,
} from "./src/group-policy.js";
export {
allowListMatches,
normalizeAllowList,
normalizeAllowListLower,
normalizeSlackAllowOwnerEntry,
normalizeSlackSlug,
resolveSlackAllowListMatch,
resolveSlackUserAllowed,
type SlackAllowListMatch,
} from "./src/monitor/allow-list.js";
export { probeSlack, type SlackProbe } from "./src/probe.js";
export { collectSlackSecurityAuditFindings } from "./src/security-audit.js";
export {
clearSlackThreadParticipationCache,
hasSlackThreadParticipation,
recordSlackThreadParticipation,
} from "./src/sent-thread-cache.js";
export {
looksLikeSlackTargetId,
normalizeSlackMessagingTarget,
parseSlackTarget,
resolveSlackChannelId,
type SlackTarget,
type SlackTargetKind,
type SlackTargetParseOptions,
} from "./src/targets.js";
export { buildSlackThreadingToolContext } from "./src/threading-tool-context.js";
export { resolveSlackRuntimeGroupPolicy } from "./src/monitor/provider.js";

View File

@@ -0,0 +1,2 @@
// Slack API module exposes the plugin public contract.
export { SlackChannelConfigSchema } from "./src/config-schema.js";

View File

@@ -0,0 +1,21 @@
// Slack plugin module implements channel entry behavior.
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-setter-api.js",
exportName: "setSlackRuntime",
},
});

View File

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

View File

@@ -0,0 +1,5 @@
// Slack API module exposes the plugin public contract.
export {
buildChannelConfigSchema,
SlackConfigSchema,
} from "openclaw/plugin-sdk/bundled-channel-config-schema";

View File

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

View File

@@ -0,0 +1,5 @@
// Slack API module exposes the plugin public contract.
export type {
SlackInteractiveHandlerContext,
SlackInteractiveHandlerRegistration,
} from "./src/interactive-dispatch.js";

View File

@@ -0,0 +1,5 @@
// Slack API module exposes the plugin public contract.
export {
listSlackDirectoryGroupsFromConfig,
listSlackDirectoryPeersFromConfig,
} from "./src/directory-config.js";

View File

@@ -0,0 +1,2 @@
// Slack API module exposes the plugin public contract.
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";

View File

@@ -0,0 +1,4 @@
// Narrow entry point for registerSlackPluginHttpRoutes — avoids pulling in
// the full runtime-api barrel (~284KB, 13 chunks) during plugin register().
// Mirrors the runtime-setter-api.ts split.
export { registerSlackPluginHttpRoutes } from "./src/http/plugin-routes.js";

View File

@@ -0,0 +1,3 @@
// Slack API module exposes the plugin public contract.
export { prepareSlackMessage } from "./src/monitor/message-handler/prepare.js";
export { createInboundSlackTestContext } from "./src/monitor/message-handler/prepare.test-helpers.js";

View File

@@ -0,0 +1,98 @@
// Slack tests cover index plugin behavior.
import { assertBundledChannelEntries } from "openclaw/plugin-sdk/channel-test-helpers";
import { describe, expect, it, vi } from "vitest";
import entry from "./index.js";
import setupEntry from "./setup-entry.js";
const httpRegistryMocks = vi.hoisted(() => ({
handleSlackHttpRequest: vi.fn(async () => true),
}));
vi.mock("./src/http/registry.js", () => ({
handleSlackHttpRequest: httpRegistryMocks.handleSlackHttpRequest,
}));
describe("slack bundled entries", () => {
assertBundledChannelEntries({
entry,
expectedId: "slack",
expectedName: "Slack",
setupEntry,
});
it("registers webhook routes through the full channel entry", async () => {
const registerHttpRoute = vi.fn();
entry.register({
registrationMode: "tool-discovery",
config: {
channels: {
slack: {
webhookPath: "/slack/root",
accounts: {
default: { webhookPath: "/slack/default" },
ops: { webhookPath: "hooks/ops" },
},
},
},
},
registerHttpRoute,
} as never);
expect(registerHttpRoute.mock.calls.map((call) => call[0].path)).toEqual([
"/hooks/ops",
"/slack/default",
]);
expect(httpRegistryMocks.handleSlackHttpRequest).not.toHaveBeenCalled();
const handler = registerHttpRoute.mock.calls[0]?.[0].handler;
await handler?.({ url: "/hooks/ops" }, {});
expect(httpRegistryMocks.handleSlackHttpRequest).toHaveBeenCalledOnce();
});
it("uses the root Slack webhook path when the default account does not override it", () => {
const registerHttpRoute = vi.fn();
entry.register({
registrationMode: "tool-discovery",
config: {
channels: {
slack: {
webhookPath: "/slack/root",
accounts: {
ops: { webhookPath: "hooks/ops" },
},
},
},
},
registerHttpRoute,
} as never);
expect(registerHttpRoute.mock.calls.map((call) => call[0].path)).toEqual([
"/hooks/ops",
"/slack/root",
]);
});
it("registers webhook routes through the setup-runtime entry", () => {
const registerHttpRoute = vi.fn();
setupEntry.registerSetupRuntime?.({
registrationMode: "setup-runtime",
config: {
channels: {
slack: {
webhookPath: "/slack/root",
accounts: {
default: { webhookPath: "/slack/default" },
ops: { webhookPath: "hooks/ops" },
},
},
},
},
registerHttpRoute,
} as never);
expect(registerHttpRoute.mock.calls.map((call) => call[0].path)).toEqual([
"/hooks/ops",
"/slack/default",
]);
});
});

27
extensions/slack/index.ts Normal file
View File

@@ -0,0 +1,27 @@
// Slack plugin entrypoint registers its OpenClaw integration.
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
import { registerSlackPluginHttpRoutes } from "./http-routes-api.js";
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-setter-api.js",
exportName: "setSlackRuntime",
},
accountInspect: {
specifier: "./account-inspect-api.js",
exportName: "inspectSlackReadOnlyAccount",
},
registerFull: registerSlackPluginHttpRoutes,
});

View File

@@ -0,0 +1,5 @@
// Slack API module exposes the plugin public contract.
export {
compileSlackInteractiveReplies,
isSlackInteractiveRepliesEnabled,
} from "./src/interactive-replies.js";

View File

@@ -0,0 +1,2 @@
// Slack API module exposes the plugin public contract.
export { describeSlackMessageTool as describeMessageTool } from "./src/message-tool-api.js";

1427
extensions/slack/npm-shrinkwrap.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
{
"id": "slack",
"name": "Slack",
"description": "OpenClaw Slack channel plugin for channels, DMs, commands, and app events.",
"skills": ["./skills"],
"activation": {
"onStartup": false
},
"channels": ["slack"],
"channelConfigs": {
"slack": {
"label": "Slack",
"description": "Slack channel, DM, command, and app event integration.",
"schema": {
"type": "object",
"additionalProperties": true
}
}
},
"channelEnvVars": {
"slack": ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_USER_TOKEN"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,2 @@
// Slack API module exposes the plugin public contract.
export { createSlackOutboundPayloadHarness } from "./src/outbound-payload.test-harness.js";

View File

@@ -0,0 +1,82 @@
{
"name": "@openclaw/slack",
"version": "2026.6.11",
"description": "OpenClaw Slack channel plugin for channels, DMs, commands, and app events.",
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
},
"type": "module",
"dependencies": {
"@slack/bolt": "4.7.3",
"@slack/types": "2.21.1",
"@slack/web-api": "7.18.0",
"typebox": "1.3.3",
"ws": "8.21.0",
"zod": "4.4.3"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.6.11"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"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,
"commands": {
"nativeCommandsAutoEnabled": false,
"nativeSkillsAutoEnabled": false
},
"configuredState": {
"env": {
"anyOf": [
"SLACK_APP_TOKEN",
"SLACK_BOT_TOKEN",
"SLACK_USER_TOKEN"
]
},
"specifier": "./configured-state",
"exportName": "hasSlackConfiguredState"
}
},
"install": {
"npmSpec": "@openclaw/slack",
"defaultChoice": "npm",
"minHostVersion": ">=2026.5.28",
"allowInvalidConfigRecovery": true
},
"compat": {
"pluginApi": ">=2026.6.11"
},
"startup": {
"deferConfiguredChannelFullLoadUntilAfterListen": true
},
"build": {
"openclawVersion": "2026.6.11",
"bundledDist": false
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}
}

View File

@@ -0,0 +1,45 @@
// Slack API module exposes the plugin public contract.
export {
handleSlackAction,
slackActionRuntime,
type SlackActionContext,
} from "./src/action-runtime.js";
export { listSlackDirectoryGroupsLive, listSlackDirectoryPeersLive } from "./src/directory-live.js";
export {
deleteSlackMessage,
editSlackMessage,
getSlackMemberInfo,
listEnabledSlackAccounts,
listSlackAccountIds,
listSlackEmojis,
listSlackPins,
listSlackReactions,
monitorSlackProvider,
pinSlackMessage,
probeSlack,
reactSlackMessage,
readSlackMessages,
removeOwnSlackReactions,
removeSlackReaction,
resolveDefaultSlackAccountId,
resolveSlackAccount,
resolveSlackAppToken,
resolveSlackBotToken,
resolveSlackGroupRequireMention,
resolveSlackGroupToolPolicy,
sendMessageSlack,
sendSlackMessage,
unpinSlackMessage,
} from "./src/index.js";
export {
resolveSlackChannelAllowlist,
type SlackChannelLookup,
type SlackChannelResolution,
} from "./src/resolve-channels.js";
export {
resolveSlackUserAllowlist,
type SlackUserLookup,
type SlackUserResolution,
} from "./src/resolve-users.js";
export { registerSlackPluginHttpRoutes } from "./src/http/plugin-routes.js";
export { setSlackRuntime } from "./src/runtime.js";

View File

@@ -0,0 +1,3 @@
// Narrow entry point for setSlackRuntime — avoids pulling in the full
// runtime-api barrel (284KB, 29 chunks) during plugin register().
export { setSlackRuntime } from "./src/runtime.js";

View File

@@ -0,0 +1,6 @@
// Slack API module exposes the plugin public contract.
export {
channelSecrets,
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -0,0 +1,2 @@
// Slack API module exposes the plugin public contract.
export { collectSlackSecurityAuditFindings } from "./src/security-audit.js";

View File

@@ -0,0 +1,20 @@
// Slack plugin module implements setup entry behavior.
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
import { registerSlackPluginHttpRoutes } from "./http-routes-api.js";
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./setup-plugin-api.js",
exportName: "slackSetupPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
runtime: {
specifier: "./runtime-setter-api.js",
exportName: "setSlackRuntime",
},
registerSetupRuntime: registerSlackPluginHttpRoutes,
});

View File

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

View File

@@ -0,0 +1,78 @@
---
name: slack
description: "Slack tool actions: send/read/edit/delete messages, react, pin/unpin, list pins/reactions/emoji, member info."
metadata: { "openclaw": { "emoji": "💬", "requires": { "config": ["channels.slack"] } } }
---
# Slack
Use the `slack` tool. Reuse `channelId` and Slack timestamp message IDs from context when present.
## Inputs
- `channelId`: Slack channel ID.
- `messageId`: Slack timestamp, e.g. `1712023032.1234`.
- `to`: `channel:<id>` or `user:<id>` for sends.
- `emoji`: Unicode or `:name:` for reactions.
## Actions
```json
{ "action": "sendMessage", "to": "channel:C123", "content": "Hello" }
```
```json
{ "action": "readMessages", "channelId": "C123", "limit": 20 }
```
```json
{
"action": "react",
"channelId": "C123",
"messageId": "1712023032.1234",
"emoji": ":white_check_mark:"
}
```
```json
{ "action": "reactions", "channelId": "C123", "messageId": "1712023032.1234" }
```
```json
{
"action": "editMessage",
"channelId": "C123",
"messageId": "1712023032.1234",
"content": "Updated text"
}
```
```json
{ "action": "deleteMessage", "channelId": "C123", "messageId": "1712023032.1234" }
```
```json
{ "action": "pinMessage", "channelId": "C123", "messageId": "1712023032.1234" }
```
```json
{ "action": "unpinMessage", "channelId": "C123", "messageId": "1712023032.1234" }
```
```json
{ "action": "listPins", "channelId": "C123" }
```
```json
{ "action": "memberInfo", "userId": "U123" }
```
```json
{ "action": "emojiList" }
```
## Safety
- Confirm destructive deletes when context is unclear.
- Keep outbound messages short; avoid Markdown tables.
- Prefer thread/message IDs over fuzzy channel names.

View File

@@ -0,0 +1,23 @@
// Slack helper module supports account configured behavior.
import { hasConfiguredAccountValue } from "openclaw/plugin-sdk/account-resolution";
import type { ResolvedSlackAccount } from "./accounts.js";
export function isSlackPluginAccountConfigured(account: ResolvedSlackAccount): boolean {
const mode = account.config.mode ?? "socket";
const hasBotToken = Boolean(account.botToken?.trim());
if (!hasBotToken) {
return false;
}
if (mode === "http") {
return hasConfiguredAccountValue(account.config.signingSecret);
}
if (mode === "relay") {
const relay = account.config.relay;
return (
hasConfiguredAccountValue(relay?.url) &&
hasConfiguredAccountValue(relay?.authToken) &&
hasConfiguredAccountValue(relay?.gatewayId)
);
}
return Boolean(account.appToken?.trim());
}

View File

@@ -0,0 +1,200 @@
// Slack plugin module implements account inspect behavior.
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/string-coerce-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 isRelayMode = mode === "relay";
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 && !isRelayMode
? 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 relayConfigured =
isRelayMode &&
Boolean(normalizeOptionalString(merged.relay?.url)) &&
hasConfiguredSecretInput(merged.relay?.authToken) &&
Boolean(normalizeOptionalString(merged.relay?.gatewayId));
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"
: isRelayMode
? (configBot.status !== "missing" || Boolean(envBot)) && relayConfigured
: (configBot.status !== "missing" || Boolean(envBot)) &&
(configApp.status !== "missing" || Boolean(envApp)),
config: merged,
groupPolicy: merged.groupPolicy,
textChunkLimit: merged.textChunkLimit,
mediaMaxMb: merged.mediaMaxMb,
reactionNotifications: merged.reactionNotifications,
reactionAllowlist: merged.reactionAllowlist,
replyToMode: merged.replyToMode,
replyToModeByChatType: merged.replyToModeByChatType,
actions: merged.actions,
slashCommand: merged.slashCommand,
dm: merged.dm,
channels: merged.channels,
};
}

View File

@@ -0,0 +1,38 @@
// Slack plugin module implements account reply mode behavior.
import type { SlackAccountConfig } from "./runtime-api.js";
type SlackReplyToMode = "off" | "first" | "all" | "batched";
type SlackReplyToModeAccount = {
replyToMode?: SlackReplyToMode;
replyToModeByChatType?: SlackAccountConfig["replyToModeByChatType"];
dm?: { replyToMode?: SlackReplyToMode };
};
function normalizeSlackChatType(raw?: string): "direct" | "group" | "channel" | undefined {
const value = raw?.trim().toLowerCase();
if (!value) {
return undefined;
}
if (value === "direct" || value === "dm") {
return "direct";
}
if (value === "group" || value === "channel") {
return value;
}
return undefined;
}
export function resolveSlackReplyToMode(
account: SlackReplyToModeAccount,
chatType?: string | null,
): SlackReplyToMode {
const normalized = normalizeSlackChatType(chatType ?? undefined);
if (normalized && account.replyToModeByChatType?.[normalized] !== undefined) {
return account.replyToModeByChatType[normalized] ?? "off";
}
if (normalized === "direct" && account.dm?.replyToMode !== undefined) {
return account.dm.replyToMode;
}
return account.replyToMode ?? "off";
}

View File

@@ -0,0 +1,16 @@
// Slack plugin module implements account surface fields behavior.
import type { SlackAccountConfig } from "openclaw/plugin-sdk/config-contracts";
export type SlackAccountSurfaceFields = {
groupPolicy?: SlackAccountConfig["groupPolicy"];
textChunkLimit?: SlackAccountConfig["textChunkLimit"];
mediaMaxMb?: SlackAccountConfig["mediaMaxMb"];
reactionNotifications?: SlackAccountConfig["reactionNotifications"];
reactionAllowlist?: SlackAccountConfig["reactionAllowlist"];
replyToMode?: SlackAccountConfig["replyToMode"];
replyToModeByChatType?: SlackAccountConfig["replyToModeByChatType"];
actions?: SlackAccountConfig["actions"];
slashCommand?: SlackAccountConfig["slashCommand"];
dm?: SlackAccountConfig["dm"];
channels?: SlackAccountConfig["channels"];
};

View File

@@ -0,0 +1,2 @@
// Slack plugin module implements accounts behavior.
export { resolveSlackAccount } from "./accounts.js";

View File

@@ -0,0 +1,607 @@
// Slack tests cover accounts plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import {
listEnabledSlackAccounts,
listSlackAccountIds,
resolveDefaultSlackAccountId,
resolveSlackAccount,
resolveSlackAccountAllowFrom,
resolveSlackAccountDmPolicy,
resolveSlackOperationToken,
} from "./accounts.js";
describe("resolveSlackOperationToken", () => {
it.each([
{
name: "prefers the user token for reads",
userTokenReadOnly: true,
operation: "read" as const,
expected: "xoxp-user",
},
{
name: "prefers the bot token for writes when user writes are enabled",
userTokenReadOnly: false,
operation: "write" as const,
expected: "xoxb-bot",
},
{
name: "uses the user token for writes only when explicitly enabled",
userTokenReadOnly: false,
operation: "write" as const,
hasBotToken: false,
expected: "xoxp-user",
},
{
name: "does not use the user token for writes by default",
userTokenReadOnly: true,
operation: "write" as const,
hasBotToken: false,
expected: undefined,
},
])("$name", ({ userTokenReadOnly, operation, hasBotToken = true, expected }) => {
const account = resolveSlackAccount({
cfg: {
channels: {
slack: {
accounts: {
work: {
...(hasBotToken ? { botToken: "xoxb-bot" } : {}),
userToken: "xoxp-user",
userTokenReadOnly,
},
},
},
},
} as OpenClawConfig,
accountId: "work",
});
expect(resolveSlackOperationToken(account, operation)).toBe(expected);
});
});
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("keeps the implicit default account when named accounts are added to top-level credentials", () => {
const cfg = {
channels: {
slack: {
botToken: "xoxb-default",
appToken: "xapp-default",
accounts: {
work: {
enabled: false,
botToken: "xoxb-work",
appToken: "xapp-work",
},
},
},
},
} as OpenClawConfig;
expect(listSlackAccountIds(cfg)).toEqual(["default", "work"]);
expect(resolveDefaultSlackAccountId(cfg)).toBe("default");
expect(listEnabledSlackAccounts(cfg).map((account) => account.accountId)).toEqual(["default"]);
});
it("does not synthesize a default account from only shared optional tokens", () => {
const cfg = {
channels: {
slack: {
appToken: "xapp-shared",
userToken: "xoxp-shared",
accounts: {
work: {
botToken: "xoxb-work",
appToken: "xapp-work",
},
},
},
},
} as OpenClawConfig;
expect(listSlackAccountIds(cfg)).toEqual(["work"]);
expect(resolveDefaultSlackAccountId(cfg)).toBe("work");
expect(listEnabledSlackAccounts(cfg).map((account) => account.accountId)).toEqual(["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("merges top-level unfurl controls into named accounts", () => {
const resolved = resolveSlackAccount({
cfg: {
channels: {
slack: {
unfurlLinks: false,
unfurlMedia: true,
accounts: {
work: { botToken: "xoxb-work", appToken: "xapp-work" },
},
},
},
},
accountId: "work",
});
expect(resolved.config.unfurlLinks).toBe(false);
expect(resolved.config.unfurlMedia).toBe(true);
});
it("prefers account-level unfurl controls over top-level defaults", () => {
const resolved = resolveSlackAccount({
cfg: {
channels: {
slack: {
unfurlLinks: false,
unfurlMedia: true,
accounts: {
work: {
botToken: "xoxb-work",
appToken: "xapp-work",
unfurlLinks: true,
unfurlMedia: false,
},
},
},
},
},
accountId: "work",
});
expect(resolved.config.unfurlLinks).toBe(true);
expect(resolved.config.unfurlMedia).toBe(false);
});
it("merges account bot loop protection over top-level defaults field-by-field", () => {
const resolved = resolveSlackAccount({
cfg: {
channels: {
slack: {
botLoopProtection: {
maxEventsPerWindow: 8,
windowSeconds: 120,
cooldownSeconds: 240,
},
accounts: {
work: {
botToken: "xoxb-work",
appToken: "xapp-work",
botLoopProtection: {
maxEventsPerWindow: 3,
},
},
},
},
},
},
accountId: "work",
});
expect(resolved.config.botLoopProtection).toEqual({
maxEventsPerWindow: 3,
windowSeconds: 120,
cooldownSeconds: 240,
});
});
it("merges canonical account streaming over top-level defaults field-by-field", () => {
const resolved = resolveSlackAccount({
cfg: {
channels: {
slack: {
streaming: {
mode: "progress",
nativeTransport: true,
preview: { toolProgress: true, commandText: "raw" },
progress: { label: "Shelling", commandText: "status" },
block: { enabled: true, coalesce: { minChars: 40, maxChars: 80, idleMs: 250 } },
},
accounts: {
work: {
botToken: "xoxb-work",
appToken: "xapp-work",
streaming: {
progress: { nativeTaskCards: true },
block: { coalesce: { idleMs: 500 } },
},
},
},
},
},
},
accountId: "work",
});
expect(resolved.config.streaming).toEqual({
mode: "progress",
nativeTransport: true,
preview: { toolProgress: true, commandText: "raw" },
progress: { label: "Shelling", commandText: "status", nativeTaskCards: true },
block: { enabled: true, coalesce: { minChars: 40, maxChars: 80, idleMs: 500 } },
});
});
it("preserves account legacy scalar streaming overrides", () => {
const resolved = resolveSlackAccount({
cfg: {
channels: {
slack: {
streaming: { mode: "progress", progress: { label: "Shelling" } },
accounts: {
work: {
botToken: "xoxb-work",
appToken: "xapp-work",
streaming: "off",
},
},
},
},
} as unknown as OpenClawConfig,
accountId: "work",
});
expect(resolved.config.streaming).toBe("off");
});
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"]);
});
it("resolves account legacy dm.allowFrom before inherited root allowFrom", () => {
const cfg = {
channels: {
slack: {
allowFrom: ["root"],
accounts: {
work: {
botToken: "xoxb-work",
appToken: "xapp-work",
dm: { allowFrom: ["account-legacy"] },
},
},
},
},
} satisfies OpenClawConfig;
expect(resolveSlackAccountAllowFrom({ cfg, accountId: "work" })).toEqual(["account-legacy"]);
});
it("coerces numeric allowFrom entries at the config boundary", () => {
const cfg = {
channels: {
slack: {
accounts: {
work: {
botToken: "xoxb-work",
appToken: "xapp-work",
allowFrom: [12345],
},
},
},
},
} as unknown as OpenClawConfig;
expect(resolveSlackAccountAllowFrom({ cfg, accountId: "work" })).toEqual(["12345"]);
});
it("resolves account legacy dm policy before inherited root policy", () => {
const cfg = {
channels: {
slack: {
dmPolicy: "open",
accounts: {
work: {
botToken: "xoxb-work",
appToken: "xapp-work",
dm: { policy: "allowlist" },
},
},
},
},
} satisfies OpenClawConfig;
expect(resolveSlackAccountDmPolicy({ cfg, accountId: "work" })).toBe("allowlist");
});
it("resolves mixed-case account keys for DM access settings", () => {
const cfg = {
channels: {
slack: {
dmPolicy: "open",
allowFrom: ["root"],
accounts: {
Work: {
botToken: "xoxb-work",
appToken: "xapp-work",
dm: { policy: "allowlist" },
allowFrom: ["U123"],
},
},
},
},
} satisfies OpenClawConfig;
expect(resolveSlackAccountDmPolicy({ cfg, accountId: "work" })).toBe("allowlist");
expect(resolveSlackAccountAllowFrom({ cfg, accountId: "work" })).toEqual(["U123"]);
});
});
describe("resolveSlackAccount active secret surfaces", () => {
const secretRef = { source: "exec", provider: "default", id: "slack_token" } as const;
const cfgWithUnresolvedBotTokenRef = {
channels: {
slack: {
accounts: {
default: {
botToken: secretRef,
allowFrom: ["U999"],
},
},
},
},
} as unknown as OpenClawConfig;
it("throws when an enabled account still has an unresolved active bot token SecretRef", () => {
expect(() =>
resolveSlackAccount({
cfg: cfgWithUnresolvedBotTokenRef,
accountId: "default",
}),
).toThrowError(/channels\.slack\.accounts\.default\.botToken/);
});
it("does not read credentials for disabled accounts", () => {
const resolved = resolveSlackAccount({
cfg: {
channels: {
slack: {
accounts: {
default: {
enabled: false,
botToken: secretRef,
appToken: secretRef,
userToken: secretRef,
allowFrom: ["U999"],
},
},
},
},
} as unknown as OpenClawConfig,
accountId: "default",
});
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");
expect(resolved.accountId).toBe("default");
expect(resolved.config.allowFrom).toEqual(["U999"]);
});
it("does not read socket-only app token for HTTP mode accounts", () => {
const resolved = resolveSlackAccount({
cfg: {
channels: {
slack: {
accounts: {
default: {
mode: "http",
botToken: "xoxb-resolved",
appToken: secretRef,
signingSecret: "signing-secret",
},
},
},
},
} as unknown as OpenClawConfig,
accountId: "default",
});
expect(resolved.botToken).toBe("xoxb-resolved");
expect(resolved.botTokenSource).toBe("config");
expect(resolved.appToken).toBeUndefined();
expect(resolved.appTokenSource).toBe("none");
});
it("throws when a socket-mode account still has an unresolved active app token SecretRef", () => {
expect(() =>
resolveSlackAccount({
cfg: {
channels: {
slack: {
accounts: {
default: {
mode: "socket",
botToken: "xoxb-resolved",
appToken: secretRef,
},
},
},
},
} as unknown as OpenClawConfig,
accountId: "default",
}),
).toThrowError(/channels\.slack\.accounts\.default\.appToken/);
});
it("preserves env fallback when no active config token is set", () => {
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",
});
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("does not use env fallback for inactive credentials", () => {
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 {
const resolved = resolveSlackAccount({
cfg: {
channels: {
slack: {
accounts: {
default: {
enabled: false,
},
},
},
},
},
accountId: "default",
});
expect(resolved.botToken).toBeUndefined();
expect(resolved.botTokenSource).toBe("none");
expect(resolved.appToken).toBeUndefined();
expect(resolved.appTokenSource).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;
}
}
});
});

View File

@@ -0,0 +1,282 @@
// Slack plugin module implements accounts behavior.
import {
createAccountListHelpers,
DEFAULT_ACCOUNT_ID,
hasConfiguredAccountValue,
normalizeAccountId,
resolveMergedAccountConfig,
type OpenClawConfig,
} from "openclaw/plugin-sdk/account-resolution";
import {
mapAllowFromEntries,
normalizeChannelDmPolicy,
resolveChannelDmAllowFrom,
resolveChannelDmPolicy,
type ChannelDmPolicy,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { resolveAccountEntry } from "openclaw/plugin-sdk/routing";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { SlackAccountSurfaceFields } from "./account-surface-fields.js";
import type { SlackAccountConfig } from "./runtime-api.js";
import { resolveSlackAppToken, resolveSlackBotToken, resolveSlackUserToken } from "./token.js";
export { resolveSlackReplyToMode } from "./account-reply-mode.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;
export type SlackConfigAccessorAccount = {
allowFrom: string[] | undefined;
defaultTo: string | undefined;
};
export function resolveSlackOperationToken(
account: ResolvedSlackAccount,
operation: "read" | "write",
): string | undefined {
const userToken = normalizeOptionalString(account.userToken);
const botToken = normalizeOptionalString(account.botToken);
if (operation === "read") {
return userToken ?? botToken;
}
return account.config.userTokenReadOnly === false ? (botToken ?? userToken) : botToken;
}
const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("slack", {
hasImplicitDefaultAccount: (cfg) => {
const slack = cfg.channels?.slack;
const hasBotToken =
hasConfiguredAccountValue(slack?.botToken) ||
hasConfiguredAccountValue(process.env.SLACK_BOT_TOKEN);
if (!hasBotToken) {
return false;
}
if (slack?.mode === "http") {
return hasConfiguredAccountValue(slack.signingSecret);
}
if (slack?.mode === "relay") {
return (
hasConfiguredAccountValue(slack.relay?.url) &&
hasConfiguredAccountValue(slack.relay?.authToken) &&
hasConfiguredAccountValue(slack.relay?.gatewayId)
);
}
return (
hasConfiguredAccountValue(slack?.appToken) ||
hasConfiguredAccountValue(process.env.SLACK_APP_TOKEN)
);
},
});
export const listSlackAccountIds = listAccountIds;
export const resolveDefaultSlackAccountId = resolveDefaultAccountId;
function resolveSlackAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): SlackAccountConfig | undefined {
return resolveAccountEntry(cfg.channels?.slack?.accounts, accountId);
}
type SlackStreamingConfig = NonNullable<SlackAccountConfig["streaming"]>;
type SlackStreamingConfigValue = SlackStreamingConfig | boolean | string;
function asStreamingConfigObject(value: unknown): SlackStreamingConfig | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as SlackStreamingConfig)
: undefined;
}
function asLegacyStreamingScalar(value: unknown): boolean | string | undefined {
return typeof value === "boolean" || typeof value === "string" ? value : undefined;
}
function mergeSlackStreamingConfig(
base: unknown,
account: unknown,
): SlackStreamingConfigValue | undefined {
const accountObject = asStreamingConfigObject(account);
if (account !== undefined && !accountObject) {
return asLegacyStreamingScalar(account);
}
const baseObject = asStreamingConfigObject(base);
if (base !== undefined && !baseObject) {
return accountObject ?? asLegacyStreamingScalar(base);
}
const baseConfig = baseObject;
const accountConfig = accountObject;
if (!baseConfig || !accountConfig) {
return accountConfig ?? baseConfig;
}
return {
...baseConfig,
...accountConfig,
...(baseConfig.preview || accountConfig.preview
? { preview: { ...baseConfig.preview, ...accountConfig.preview } }
: {}),
...(baseConfig.progress || accountConfig.progress
? { progress: { ...baseConfig.progress, ...accountConfig.progress } }
: {}),
...(baseConfig.block || accountConfig.block
? {
block: {
...baseConfig.block,
...accountConfig.block,
...(baseConfig.block?.coalesce || accountConfig.block?.coalesce
? {
coalesce: {
...baseConfig.block?.coalesce,
...accountConfig.block?.coalesce,
},
}
: {}),
},
}
: {}),
};
}
export function mergeSlackAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): SlackAccountConfig {
const accountConfig = resolveSlackAccountConfig(cfg, accountId);
const merged = resolveMergedAccountConfig<SlackAccountConfig>({
channelConfig: cfg.channels?.slack as SlackAccountConfig,
accounts: cfg.channels?.slack?.accounts as Record<string, Partial<SlackAccountConfig>>,
accountId,
nestedObjectKeys: ["botLoopProtection", "relay"],
});
const streaming = mergeSlackStreamingConfig(
(cfg.channels?.slack as Record<string, unknown> | undefined)?.streaming,
(accountConfig as Record<string, unknown> | undefined)?.streaming,
);
return streaming !== undefined ? ({ ...merged, streaming } as SlackAccountConfig) : merged;
}
export function resolveSlackAccountAllowFrom(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] | undefined {
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultSlackAccountId(params.cfg),
);
const accountConfig = resolveSlackAccountConfig(params.cfg, accountId);
const rootConfig = params.cfg.channels?.slack as SlackAccountConfig | undefined;
const allowFrom = resolveChannelDmAllowFrom({
account: accountConfig as Record<string, unknown> | undefined,
parent: rootConfig as Record<string, unknown> | undefined,
});
return allowFrom ? mapAllowFromEntries(allowFrom) : undefined;
}
export function resolveSlackConfigAccessorAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): SlackConfigAccessorAccount {
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultSlackAccountId(params.cfg),
);
const config = mergeSlackAccountConfig(params.cfg, accountId);
return {
allowFrom: resolveSlackAccountAllowFrom({ cfg: params.cfg, accountId }),
defaultTo: config.defaultTo,
};
}
export function resolveSlackAccountDmPolicy(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ChannelDmPolicy | undefined {
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultSlackAccountId(params.cfg),
);
const accountConfig = resolveSlackAccountConfig(params.cfg, accountId);
const rootConfig = params.cfg.channels?.slack as SlackAccountConfig | undefined;
const policy = resolveChannelDmPolicy({
account: accountConfig as Record<string, unknown> | undefined,
parent: rootConfig as Record<string, unknown> | undefined,
defaultPolicy: "pairing",
});
return normalizeChannelDmPolicy(policy);
}
export function resolveSlackAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): 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;
const mode = merged.mode ?? "socket";
const baseAllowEnv = accountId === DEFAULT_ACCOUNT_ID;
const botActive = enabled;
const appActive = enabled && mode === "socket";
const userActive = enabled;
const envBot =
botActive && baseAllowEnv ? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN) : undefined;
const envApp =
appActive && baseAllowEnv ? resolveSlackAppToken(process.env.SLACK_APP_TOKEN) : undefined;
const envUser =
userActive && baseAllowEnv ? resolveSlackUserToken(process.env.SLACK_USER_TOKEN) : undefined;
const configBot = botActive
? resolveSlackBotToken(merged.botToken, `channels.slack.accounts.${accountId}.botToken`)
: undefined;
const configApp = appActive
? resolveSlackAppToken(merged.appToken, `channels.slack.accounts.${accountId}.appToken`)
: undefined;
const configUser = userActive
? resolveSlackUserToken(merged.userToken, `channels.slack.accounts.${accountId}.userToken`)
: undefined;
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);
}

View File

@@ -0,0 +1,2 @@
// Slack plugin module implements action runtime behavior.
export { handleSlackAction } from "./action-runtime.js";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,612 @@
// Slack plugin module implements action runtime behavior.
import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference";
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
import type { ResolvedSlackAccount } from "./accounts.js";
import { parseSlackBlocksInput } from "./blocks-input.js";
import { resolveSlackChannelConfig } from "./monitor/channel-config.js";
import { isSlackChannelAllowedByPolicy } from "./monitor/policy.js";
import {
createActionGate,
imageResultFromFile,
jsonResult,
readPositiveIntegerParam,
readReactionParams,
readStringParam,
type OpenClawConfig,
withNormalizedTimestamp,
} from "./runtime-api.js";
import { resolveSlackChannelId, slackContextTargetsMatch } 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");
const loadSlackActionsRuntime = createLazyRuntimeModule(() => import("./actions.runtime.js"));
const loadSlackAccountsRuntime = createLazyRuntimeModule(() => import("./accounts.runtime.js"));
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"),
removeOwnSlackReactions: createLazySlackAction("removeOwnSlackReactions"),
removeSlackReaction: createLazySlackAction("removeSlackReaction"),
resolveSlackConversationName: createLazySlackAction("resolveSlackConversationName"),
sendSlackMessage: createLazySlackAction("sendSlackMessage"),
unpinSlackMessage: createLazySlackAction("unpinSlackMessage"),
};
export type SlackActionContext = {
/** Current channel ID for auto-threading. */
currentChannelId?: string;
/** Routable target for the current conversation when it differs from the channel ID. */
currentMessagingTarget?: 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 };
/** True when same-channel root posting would leak a thread-originated reply. */
sameChannelThreadRequired?: 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,
opts?: { suppressImplicitThread?: boolean },
): string | undefined {
// Agent explicitly provided threadTs - use it
if (explicitThreadTs) {
return explicitThreadTs;
}
if (opts?.suppressImplicitThread) {
return undefined;
}
if (!context?.currentChannelId && !context?.currentMessagingTarget) {
return undefined;
}
// Different channel - don't inject
if (!slackContextTargetsMatch(targetChannel, context)) {
return undefined;
}
if (!context.currentThreadTs) {
if (context.sameChannelThreadRequired) {
throw new Error(
"Slack thread context is required for same-channel replies from a threaded Slack turn. Set topLevel=true or threadId=null to post at the channel root.",
);
}
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);
}
function isImageContentType(value: string | undefined): boolean {
return value?.trim().toLowerCase().startsWith("image/") === true;
}
type SlackReadTargetDecision = "allow" | "deny" | "resolve-name";
function resolveSlackReadTargetDecision(params: {
account: ResolvedSlackAccount;
cfg: OpenClawConfig;
channelId: string;
channelName?: string;
}): SlackReadTargetDecision {
const channels = params.account.config.channels;
const channelKeys = Object.keys(channels ?? {});
const channelConfig = resolveSlackChannelConfig({
channelId: params.channelId,
channelName: params.channelName,
channels,
channelKeys,
allowNameMatching: params.account.config.dangerouslyAllowNameMatching,
defaultRequireMention: params.account.config.requireMention,
});
const channelAllowed = channelConfig?.allowed !== false;
const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: params.cfg.channels?.slack !== undefined,
groupPolicy: params.account.config.groupPolicy,
defaultGroupPolicy: params.cfg.channels?.defaults?.groupPolicy,
});
const policyAllowed = isSlackChannelAllowedByPolicy({
groupPolicy,
channelAllowlistConfigured: channelKeys.length > 0,
channelAllowed,
});
if (policyAllowed) {
return !channelAllowed && (groupPolicy !== "open" || channelConfig?.matchSource)
? "deny"
: "allow";
}
const canResolveName =
groupPolicy === "allowlist" &&
channelKeys.length > 0 &&
params.account.config.dangerouslyAllowNameMatching === true &&
!params.channelName &&
(channelConfig?.matchSource === undefined || channelConfig.matchSource === "wildcard");
return canResolveName ? "resolve-name" : "deny";
}
async function assertSlackReadTargetAllowed(params: {
account: ResolvedSlackAccount;
cfg: OpenClawConfig;
channelId: string;
resolveChannelName: () => Promise<string | undefined>;
}) {
const direct = resolveSlackReadTargetDecision(params);
if (direct === "allow") {
return;
}
if (direct === "resolve-name") {
const channelName = await params.resolveChannelName();
if (channelName && resolveSlackReadTargetDecision({ ...params, channelName }) === "allow") {
return;
}
}
throw new Error("Slack read target channel is not allowed.");
}
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;
return {
cfg,
...(accountId ? { accountId } : {}),
...(tokenOverride ? { token: tokenOverride } : {}),
};
};
const readOpts = buildActionOpts("read");
const writeOpts = buildActionOpts("write");
const assertReadTargetAllowed = async (channelId: string) =>
await assertSlackReadTargetAllowed({
account,
cfg,
channelId,
// Use the same credential that will perform the authorized read. Slack
// exposes conversation metadata according to the presented token's access.
resolveChannelName: async () =>
await slackActionRuntime.resolveSlackConversationName(channelId, readOpts),
});
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 });
}
await assertReadTargetAllowed(channelId);
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);
const replyBroadcast = readBooleanParam(params, "replyBroadcast");
if (!content && !mediaUrl && !blocks) {
throw new Error("Slack sendMessage requires content, blocks, or mediaUrl.");
}
if (replyBroadcast && mediaUrl) {
throw new Error(
"Slack replyBroadcast is only supported for text or block thread replies.",
);
}
const threadTs = resolveThreadTsFromContext(
readStringParam(params, "threadTs"),
to,
context,
{
suppressImplicitThread: params.topLevel === true || params.threadTs === null,
},
);
const sendOpts = {
...writeOpts,
mediaLocalRoots: context?.mediaLocalRoots,
mediaReadFile: context?.mediaReadFile,
threadTs: threadTs ?? undefined,
...(replyBroadcast ? { replyBroadcast } : {}),
};
const result =
mediaUrl && blocks
? await (async () => {
await slackActionRuntime.sendSlackMessage(to, "", {
...sendOpts,
mediaUrl,
});
return await slackActionRuntime.sendSlackMessage(to, content ?? "", {
...sendOpts,
blocks,
});
})()
: await slackActionRuntime.sendSlackMessage(to, content ?? "", {
...sendOpts,
mediaUrl: mediaUrl ?? undefined,
blocks,
});
// 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 && slackContextTargetsMatch(to, context)) {
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 replyBroadcast = readBooleanParam(params, "replyBroadcast");
if (replyBroadcast) {
throw new Error(
"Slack replyBroadcast is only supported for text or block thread replies.",
);
}
const threadTs = resolveThreadTsFromContext(
readStringParam(params, "threadTs"),
to,
context,
{
suppressImplicitThread: params.topLevel === true || params.threadTs === null,
},
);
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 (context?.hasRepliedRef && slackContextTargetsMatch(to, context)) {
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();
await assertReadTargetAllowed(channelId);
const limit = readPositiveIntegerParam(params, "limit", {
message: "limit must be a positive integer.",
});
const before = readStringParam(params, "before");
const after = readStringParam(params, "after");
const threadId = readStringParam(params, "threadId");
const messageId = readStringParam(params, "messageId");
const result = await slackActionRuntime.readSlackMessages(channelId, {
...readOpts,
limit,
before: before ?? undefined,
after: after ?? undefined,
threadId: threadId ?? undefined,
messageId: messageId ?? 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") ??
context?.currentChannelId;
if (!channelTarget) {
throw new Error(
"Slack file download requires channelId or to so the read target can be authorized.",
);
}
const channelId = resolveSlackChannelId(channelTarget);
await assertReadTargetAllowed(channelId);
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).",
});
}
if (!isImageContentType(downloaded.contentType)) {
return jsonResult({
ok: true,
fileId,
path: downloaded.path,
contentType: downloaded.contentType,
placeholder: downloaded.placeholder,
media: {
mediaUrl: downloaded.path,
outbound: false,
...(downloaded.contentType ? { contentType: downloaded.contentType } : {}),
},
});
}
return await imageResultFromFile({
label: "slack-file",
path: downloaded.path,
extraText: downloaded.placeholder,
details: {
fileId,
path: downloaded.path,
...(downloaded.contentType ? { contentType: downloaded.contentType } : {}),
media: { outbound: false },
},
});
}
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 });
}
await assertReadTargetAllowed(channelId);
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 limit = readPositiveIntegerParam(params, "limit", {
message: "limit must be a positive integer.",
});
const result = readOpts
? await slackActionRuntime.listSlackEmojis(readOpts)
: await slackActionRuntime.listSlackEmojis();
if (limit != null && limit > 0 && result.emoji != null) {
const entries = Object.entries(result.emoji).toSorted(([a], [b]) => a.localeCompare(b));
if (entries.length > limit) {
return jsonResult({
ok: true,
emojis: {
...result,
emoji: Object.fromEntries(entries.slice(0, limit)),
},
});
}
}
return jsonResult({ ok: true, emojis: result });
}
throw new Error(`Unknown action: ${action}`);
}

View File

@@ -0,0 +1,120 @@
// Slack tests cover action threading plugin behavior.
import { describe, expect, it } from "vitest";
import { resolveSlackAutoThreadId } from "./action-threading.js";
type SlackThreadingToolContext = {
currentChannelId?: string;
currentMessagingTarget?: string;
currentThreadTs?: string;
replyToMode?: "off" | "first" | "all" | "batched";
hasRepliedRef?: { value: boolean };
sameChannelThreadRequired?: 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("threads first matching prefixed channel target with bare current channel", () => {
const hasRepliedRef = { value: false };
expect(
resolveSlackAutoThreadId({
to: "channel:C123",
toolContext: createToolContext({
replyToMode: "first",
hasRepliedRef,
}),
}),
).toBe("thread-1");
expect(hasRepliedRef.value).toBe(false);
});
it("uses the active thread for matching user targets", () => {
expect(
resolveSlackAutoThreadId({
to: "user:U123",
toolContext: createToolContext({
currentChannelId: "slack:U123",
}),
}),
).toBe("thread-1");
});
it("matches either native or routable DM targets", () => {
const context = createToolContext({
currentChannelId: "D123",
currentMessagingTarget: "user:U123",
});
expect(resolveSlackAutoThreadId({ to: "user:U123", toolContext: context })).toBe("thread-1");
expect(resolveSlackAutoThreadId({ to: "U123", toolContext: context })).toBe("thread-1");
expect(resolveSlackAutoThreadId({ to: "D123", toolContext: context })).toBe("thread-1");
expect(resolveSlackAutoThreadId({ to: "user:U999", toolContext: context })).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();
});
it("fails closed for same-channel threaded replies when the thread timestamp is missing", () => {
expect(() =>
resolveSlackAutoThreadId({
to: "C123",
toolContext: createToolContext({
currentThreadTs: undefined,
sameChannelThreadRequired: true,
}),
}),
).toThrow("Slack thread context is required");
});
});

View File

@@ -0,0 +1,38 @@
// Slack plugin module implements action threading behavior.
import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference";
import { slackContextTargetsMatch } from "./targets.js";
export function resolveSlackAutoThreadId(params: {
to: string;
toolContext?: {
currentChannelId?: string;
currentMessagingTarget?: string;
currentThreadTs?: string;
replyToMode?: "off" | "first" | "all" | "batched";
hasRepliedRef?: { value: boolean };
sameChannelThreadRequired?: boolean;
};
}): string | undefined {
const context = params.toolContext;
if (!context?.currentChannelId && !context?.currentMessagingTarget) {
return undefined;
}
if (!slackContextTargetsMatch(params.to, context)) {
return undefined;
}
if (!context.currentThreadTs) {
if (context.sameChannelThreadRequired) {
throw new Error(
"Slack thread context is required for same-channel replies from a threaded Slack turn. Set topLevel=true or threadId=null to post at the channel root.",
);
}
return undefined;
}
if (context.replyToMode !== "all" && !isSingleUseReplyToMode(context.replyToMode ?? "off")) {
return undefined;
}
if (isSingleUseReplyToMode(context.replyToMode ?? "off") && context.hasRepliedRef?.value) {
return undefined;
}
return context.currentThreadTs;
}

View File

@@ -0,0 +1,178 @@
// Slack tests cover actions.blocks plugin behavior.
import { describe, expect, it } from "vitest";
import { createSlackEditTestClient } from "./blocks.test-helpers.js";
const { editSlackMessage } = await import("./actions.js");
const SLACK_TEXT_LIMIT = 8000;
function readFirstChatUpdatePayload(client: ReturnType<typeof createSlackEditTestClient>): {
text?: string;
} {
const [call] = client.chat.update.mock.calls;
if (!call) {
throw new Error("expected Slack chat.update call");
}
const [payload] = call;
if (!payload || typeof payload !== "object") {
throw new Error("expected Slack chat.update payload");
}
return payload as { text?: string };
}
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({
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({
channel: "C123",
ts: "171234.567",
text: "Chart",
blocks: [{ type: "image", image_url: "https://example.com/a.png", alt_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({
channel: "C123",
ts: "171234.567",
text: "Walkthrough",
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",
},
],
});
});
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({
channel: "C123",
ts: "171234.567",
text: "Shared a file",
blocks: [{ type: "file", source: "remote", external_id: "F123" }],
});
});
it("caps long block fallback text while preserving edit blocks", async () => {
const client = createSlackEditTestClient();
const longContextText = "a".repeat(3000);
const blocks = [
{
type: "context",
elements: [
{ type: "mrkdwn", text: longContextText },
{ type: "mrkdwn", text: longContextText },
{ type: "mrkdwn", text: longContextText },
],
},
];
await editSlackMessage("C123", "171234.567", "", {
token: "xoxb-test",
client,
blocks,
});
expect(client.chat.update).toHaveBeenCalledWith({
channel: "C123",
ts: "171234.567",
text: `${longContextText} ${longContextText} ${"a".repeat(SLACK_TEXT_LIMIT - longContextText.length * 2 - 3)}`,
blocks,
});
expect(readFirstChatUpdatePayload(client).text).toHaveLength(SLACK_TEXT_LIMIT);
});
it("rejects empty blocks arrays", async () => {
const client = createSlackEditTestClient();
await expect(
editSlackMessage("C123", "171234.567", "updated", {
token: "xoxb-test",
client,
blocks: [],
}),
).rejects.toThrow(/must contain at least one block/i);
expect(client.chat.update).not.toHaveBeenCalled();
});
it("rejects blocks missing a type", async () => {
const client = createSlackEditTestClient();
await expect(
editSlackMessage("C123", "171234.567", "updated", {
token: "xoxb-test",
client,
blocks: [{} as { type: string }],
}),
).rejects.toThrow(/non-empty string type/i);
expect(client.chat.update).not.toHaveBeenCalled();
});
it("rejects blocks arrays above Slack max count", async () => {
const client = createSlackEditTestClient();
const blocks = Array.from({ length: 51 }, () => ({ type: "divider" }));
await expect(
editSlackMessage("C123", "171234.567", "updated", {
token: "xoxb-test",
client,
blocks,
}),
).rejects.toThrow(/cannot exceed 50 items/i);
expect(client.chat.update).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,267 @@
// Slack tests cover actionsownload file plugin behavior.
import type { WebClient } from "@slack/web-api";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const resolveSlackMedia = vi.fn();
const createSlackWebClientMock = vi.hoisted(() => vi.fn());
vi.mock("./monitor/media.js", () => ({
resolveSlackMedia: (...args: Parameters<typeof resolveSlackMedia>) => resolveSlackMedia(...args),
}));
vi.mock("./client.js", () => ({
createSlackWebClient: createSlackWebClientMock,
createSlackWriteClient: createSlackWebClientMock,
getSlackWriteClient: createSlackWebClientMock,
}));
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(overrides?: Record<string, unknown>) {
return {
path: "/tmp/image.png",
contentType: "image/png",
placeholder: "[Slack file: image.png]",
...overrides,
};
}
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();
createSlackWebClientMock.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("preserves non-image download metadata", async () => {
const client = createClient();
client.files.info.mockResolvedValueOnce({
file: makeSlackFileInfo({
name: "report.pdf",
mimetype: "application/pdf",
url_private_download: "https://files.slack.com/files-pri/T1-F123/report.pdf",
}),
});
resolveSlackMedia.mockResolvedValueOnce([
makeResolvedSlackMedia({
path: "/tmp/report.pdf",
contentType: "application/pdf",
placeholder: "[Slack file: report.pdf (fileId: F123)]",
}),
]);
const result = await downloadSlackFile("F123", {
client,
token: "xoxb-test",
maxBytes: 1024,
});
expect(resolveSlackMedia).toHaveBeenCalledWith({
files: [
{
id: "F123",
name: "report.pdf",
mimetype: "application/pdf",
url_private: undefined,
url_private_download: "https://files.slack.com/files-pri/T1-F123/report.pdf",
},
],
token: "xoxb-test",
maxBytes: 1024,
});
expect(result).toEqual(
makeResolvedSlackMedia({
path: "/tmp/report.pdf",
contentType: "application/pdf",
placeholder: "[Slack file: report.pdf (fileId: F123)]",
}),
);
});
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();
});
it("resolves the bot token from cfg when no explicit token or client is provided", async () => {
// Regression guard for the 95331e5cc5 migration: downloadSlackFile must
// thread opts.cfg into resolveToken so the cfg-only resolution branch works
// from any caller (not only action-runtime.ts which always injects token).
const client = createClient();
mockSuccessfulMediaDownload(client);
createSlackWebClientMock.mockReturnValueOnce(client);
const cfg = {
channels: {
slack: {
accounts: {
default: {
botToken: "xoxb-from-cfg",
},
},
},
},
} as unknown as OpenClawConfig;
const result = await downloadSlackFile("F123", {
cfg,
accountId: "default",
maxBytes: 1024,
});
expect(createSlackWebClientMock).toHaveBeenCalledWith("xoxb-from-cfg");
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-from-cfg",
maxBytes: 1024,
});
expect(result).toEqual(makeResolvedSlackMedia());
});
});

View File

@@ -0,0 +1,158 @@
// Slack tests cover actions.reactions plugin behavior.
import type { WebClient } from "@slack/web-api";
import { describe, expect, it, vi } from "vitest";
import { reactSlackMessage, removeOwnSlackReactions, removeSlackReaction } from "./actions.js";
function createClient() {
return {
auth: {
test: vi.fn(async () => ({ user_id: "UBOT" })),
},
reactions: {
add: vi.fn(async () => ({})),
get: vi.fn(async () => ({
message: {
reactions: [],
},
})),
remove: vi.fn(async () => ({})),
},
} as unknown as WebClient & {
auth: {
test: ReturnType<typeof vi.fn>;
};
reactions: {
add: ReturnType<typeof vi.fn>;
get: ReturnType<typeof vi.fn>;
remove: ReturnType<typeof vi.fn>;
};
};
}
function slackPlatformError(error: string) {
return Object.assign(new Error(`An API error occurred: ${error}`), {
data: {
ok: false,
error,
},
});
}
describe("reactSlackMessage", () => {
it("treats already_reacted as idempotent success", async () => {
const client = createClient();
client.reactions.add.mockRejectedValueOnce(slackPlatformError("already_reacted"));
await expect(
reactSlackMessage("C1", "123.456", ":white_check_mark:", {
client,
token: "xoxb-test",
}),
).resolves.toBeUndefined();
expect(client.reactions.add).toHaveBeenCalledWith({
channel: "C1",
timestamp: "123.456",
name: "white_check_mark",
});
});
it("propagates unrelated reaction add errors", async () => {
const client = createClient();
client.reactions.add.mockRejectedValueOnce(slackPlatformError("invalid_name"));
let error: unknown;
try {
await reactSlackMessage("C1", "123.456", "not-an-emoji", {
client,
token: "xoxb-test",
});
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe("An API error occurred: invalid_name");
expect((error as { data?: unknown }).data).toEqual({
ok: false,
error: "invalid_name",
});
});
});
describe("removeSlackReaction", () => {
it("treats no_reaction as idempotent success", async () => {
const client = createClient();
client.reactions.remove.mockRejectedValueOnce(slackPlatformError("no_reaction"));
await expect(
removeSlackReaction("C1", "123.456", ":white_check_mark:", {
client,
token: "xoxb-test",
}),
).resolves.toBeUndefined();
expect(client.reactions.remove).toHaveBeenCalledWith({
channel: "C1",
timestamp: "123.456",
name: "white_check_mark",
});
});
it("propagates unrelated reaction remove errors", async () => {
const client = createClient();
client.reactions.remove.mockRejectedValueOnce(slackPlatformError("invalid_name"));
let error: unknown;
try {
await removeSlackReaction("C1", "123.456", "not-an-emoji", {
client,
token: "xoxb-test",
});
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe("An API error occurred: invalid_name");
expect((error as { data?: unknown }).data).toEqual({
ok: false,
error: "invalid_name",
});
});
});
describe("removeOwnSlackReactions", () => {
it("removes own reactions through the idempotent remove helper", async () => {
const client = createClient();
client.reactions.get.mockResolvedValueOnce({
message: {
reactions: [
{ name: "thumbsup", users: ["UBOT", "U1"] },
{ name: "eyes", users: ["U2", "UBOT"] },
{ name: "wave", users: ["U2"] },
],
},
});
client.reactions.remove
.mockRejectedValueOnce(slackPlatformError("no_reaction"))
.mockResolvedValueOnce({});
await expect(
removeOwnSlackReactions("C1", "123.456", {
client,
token: "xoxb-test",
}),
).resolves.toEqual(["thumbsup", "eyes"]);
expect(client.reactions.remove).toHaveBeenCalledTimes(2);
expect(client.reactions.remove).toHaveBeenNthCalledWith(1, {
channel: "C1",
timestamp: "123.456",
name: "thumbsup",
});
expect(client.reactions.remove).toHaveBeenNthCalledWith(2, {
channel: "C1",
timestamp: "123.456",
name: "eyes",
});
});
});

View File

@@ -0,0 +1,233 @@
// Slack tests cover actions.read plugin behavior.
import type { WebClient } from "@slack/web-api";
import { describe, expect, it, vi } from "vitest";
import { readSlackMessages, resolveSlackConversationName } from "./actions.js";
function createClient() {
return {
conversations: {
info: vi.fn(async () => ({ channel: { name: "general" } })),
replies: vi.fn(async () => ({ messages: [], has_more: false })),
history: vi.fn(async () => ({ messages: [], has_more: false })),
},
} as unknown as WebClient & {
conversations: {
info: ReturnType<typeof vi.fn>;
replies: ReturnType<typeof vi.fn>;
history: ReturnType<typeof vi.fn>;
};
};
}
describe("Slack read actions", () => {
it("resolves the current Slack conversation name without caching failures", async () => {
const client = createClient();
client.conversations.info
.mockRejectedValueOnce(new Error("temporary_failure"))
.mockResolvedValueOnce({ channel: { name: " allowed-channel " } });
await expect(
resolveSlackConversationName("C1", { client, token: "xoxp-reader" }),
).rejects.toThrow("temporary_failure");
await expect(
resolveSlackConversationName("C1", { client, token: "xoxp-reader" }),
).resolves.toBe("allowed-channel");
expect(client.conversations.info).toHaveBeenNthCalledWith(1, { channel: "C1" });
expect(client.conversations.info).toHaveBeenNthCalledWith(2, { channel: "C1" });
});
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("filters a specific thread reply by messageId", async () => {
const client = createClient();
client.conversations.replies.mockResolvedValueOnce({
messages: [{ ts: "171234.567" }, { ts: "171234.890", text: "reply" }],
has_more: true,
});
const result = await readSlackMessages("C1", {
client,
threadId: "171234.567",
messageId: "171234.890",
limit: 20,
token: "xoxb-test",
});
expect(client.conversations.replies).toHaveBeenCalledWith({
channel: "C1",
ts: "171234.567",
limit: 1,
inclusive: true,
latest: "171234.890",
oldest: undefined,
});
expect(result).toEqual({
messages: [{ ts: "171234.890", text: "reply" }],
hasMore: false,
});
});
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"]);
});
it("filters a specific channel message by messageId", async () => {
const client = createClient();
client.conversations.history.mockResolvedValueOnce({
messages: [{ ts: "171234.890", text: "exact" }, { ts: "171234.891" }],
has_more: true,
});
const result = await readSlackMessages("C1", {
client,
messageId: "171234.890",
token: "xoxb-test",
});
expect(client.conversations.history).toHaveBeenCalledWith({
channel: "C1",
limit: 1,
inclusive: true,
latest: "171234.890",
oldest: undefined,
});
expect(result).toEqual({
messages: [{ ts: "171234.890", text: "exact" }],
hasMore: false,
});
});
it("passes Slack timestamp strings through to history bounds", async () => {
const client = createClient();
await readSlackMessages("C1", {
client,
before: "1712345678.654321",
after: "1712340000.000001",
token: "xoxb-test",
});
expect(client.conversations.history).toHaveBeenCalledWith({
channel: "C1",
limit: undefined,
latest: "1712345678.654321",
oldest: "1712340000.000001",
});
});
it("converts ISO date strings to epoch seconds for history bounds", async () => {
const client = createClient();
await readSlackMessages("C1", {
client,
before: "2024-04-05T12:34:56.000Z",
after: "2024-04-05T00:00:00.000Z",
token: "xoxb-test",
});
expect(client.conversations.history).toHaveBeenCalledWith({
channel: "C1",
limit: undefined,
latest: "1712320496",
oldest: "1712275200",
});
});
it("converts ISO date strings with offsets to epoch seconds for history bounds", async () => {
const client = createClient();
await readSlackMessages("C1", {
client,
before: "2024-04-05T12:34:56+03:00",
after: "2024-04-05T12:34:56.789+03:00",
token: "xoxb-test",
});
expect(client.conversations.history).toHaveBeenCalledWith({
channel: "C1",
limit: undefined,
latest: "1712309696",
oldest: "1712309696.789",
});
});
it.each(["not-a-timestamp", "2024-02-30T00:00:00.000Z", "04/05/2024", "2024-04-05T12:34:56"])(
"rejects invalid history bound %s with a clear timestamp error",
async (before) => {
const client = createClient();
await expect(
readSlackMessages("C1", {
client,
before,
token: "xoxb-test",
}),
).rejects.toThrow(
`Invalid Slack read before timestamp "${before}": expected a Slack timestamp or ISO-8601 date string`,
);
expect(client.conversations.history).not.toHaveBeenCalled();
},
);
it("normalizes ISO date strings and Slack timestamp strings for thread reply bounds", async () => {
const client = createClient();
await readSlackMessages("C1", {
client,
threadId: "1712345678.000001",
before: "2024-04-05T12:34:56.000Z",
after: "1712340000.000001",
token: "xoxb-test",
});
expect(client.conversations.replies).toHaveBeenCalledWith({
channel: "C1",
ts: "1712345678.000001",
limit: undefined,
latest: "1712320496",
oldest: "1712340000.000001",
});
expect(client.conversations.history).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,18 @@
// Slack plugin module implements actions behavior.
export {
deleteSlackMessage,
downloadSlackFile,
editSlackMessage,
getSlackMemberInfo,
listSlackEmojis,
listSlackPins,
listSlackReactions,
pinSlackMessage,
reactSlackMessage,
readSlackMessages,
removeOwnSlackReactions,
removeSlackReaction,
resolveSlackConversationName,
sendSlackMessage,
unpinSlackMessage,
} from "./actions.js";

View File

@@ -0,0 +1,565 @@
// Slack plugin module implements actions behavior.
import type { Block, KnownBlock, WebClient } from "@slack/web-api";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { z } from "zod";
import { resolveSlackAccount } from "./accounts.js";
import { validateSlackBlocksArray } from "./blocks-input.js";
import { createSlackWebClient, getSlackWriteClient } from "./client.js";
import { buildSlackEditTextPayload } from "./edit-text.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 = {
cfg?: OpenClawConfig;
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, cfg?: OpenClawConfig): string {
if (explicit?.trim()) {
const token = resolveSlackBotToken(explicit);
if (token) {
return token;
}
}
if (!cfg) {
throw new Error(
"Slack actions requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
);
}
const resolvedCfg = requireRuntimeConfig(cfg, "Slack actions");
const account = resolveSlackAccount({ cfg: resolvedCfg, accountId });
const token = resolveSlackBotToken(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, "");
}
const SLACK_TIMESTAMP_RE = /^\d+(?:\.\d+)?$/;
const ISO_8601_TIMESTAMP_SCHEMA = z.iso.datetime({ offset: true });
function formatEpochSeconds(milliseconds: number): string {
const seconds = milliseconds / 1000;
if (Number.isInteger(seconds)) {
return String(seconds);
}
return seconds.toFixed(3).replace(/0+$/, "").replace(/\.$/, "");
}
function normalizeSlackReadTimestamp(
raw: string | undefined,
field: "before" | "after",
): string | undefined {
const trimmed = raw?.trim();
if (!trimmed) {
return undefined;
}
if (SLACK_TIMESTAMP_RE.test(trimmed)) {
return trimmed;
}
if (!ISO_8601_TIMESTAMP_SCHEMA.safeParse(trimmed).success) {
throw new Error(
`Invalid Slack read ${field} timestamp "${trimmed}": expected a Slack timestamp or ISO-8601 date string`,
);
}
const parsed = Date.parse(trimmed);
if (!Number.isFinite(parsed)) {
throw new Error(
`Invalid Slack read ${field} timestamp "${trimmed}": expected a Slack timestamp or ISO-8601 date string`,
);
}
return formatEpochSeconds(parsed);
}
function hasSlackPlatformError(err: unknown, code: string): boolean {
if (!err || typeof err !== "object") {
return false;
}
const data = (err as { data?: unknown }).data;
if (!data || typeof data !== "object") {
return false;
}
return (data as { error?: unknown }).error === code;
}
async function getClient(opts: SlackActionClientOpts = {}, mode: "read" | "write" = "read") {
if (opts.client) {
return opts.client;
}
const token = resolveToken(opts.token, opts.accountId, opts.cfg);
return mode === "write" ? getSlackWriteClient(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");
try {
await client.reactions.add({
channel: channelId,
timestamp: messageId,
name: normalizeEmoji(emoji),
});
} catch (err) {
if (hasSlackPlatformError(err, "already_reacted")) {
return;
}
throw err;
}
}
export async function removeSlackReaction(
channelId: string,
messageId: string,
emoji: string,
opts: SlackActionClientOpts = {},
) {
const client = await getClient(opts, "write");
try {
await client.reactions.remove({
channel: channelId,
timestamp: messageId,
name: normalizeEmoji(emoji),
});
} catch (err) {
if (hasSlackPlatformError(err, "no_reaction")) {
return;
}
throw err;
}
}
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) =>
removeSlackReaction(channelId, messageId, name, {
...opts,
client,
}),
),
);
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: Omit<SlackActionClientOpts, "cfg"> & {
cfg: OpenClawConfig;
mediaUrl?: string;
mediaAccess?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
};
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
threadTs?: string;
replyBroadcast?: boolean;
uploadFileName?: string;
uploadTitle?: string;
blocks?: (Block | KnownBlock)[];
},
) {
return await sendMessageSlack(to, content, {
accountId: opts.accountId,
cfg: opts.cfg,
token: opts.token,
mediaUrl: opts.mediaUrl,
mediaAccess: opts.mediaAccess,
mediaLocalRoots: opts.mediaLocalRoots,
mediaReadFile: opts.mediaReadFile,
client: opts.client,
threadTs: opts.threadTs,
replyBroadcast: opts.replyBroadcast,
...(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);
await client.chat.update({
channel: channelId,
ts: messageId,
text: buildSlackEditTextPayload(content, 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 resolveSlackConversationName(
channelId: string,
opts: SlackActionClientOpts = {},
): Promise<string | undefined> {
const client = await getClient(opts, "read");
const info = await client.conversations.info({ channel: channelId });
return info.channel?.name?.trim() || undefined;
}
export async function readSlackMessages(
channelId: string,
opts: SlackActionClientOpts & {
limit?: number;
before?: string;
after?: string;
threadId?: string;
messageId?: string;
} = {},
): Promise<{ messages: SlackMessageSummary[]; hasMore: boolean }> {
const exactMessageId = opts.messageId?.trim();
const readLimit = exactMessageId ? 1 : opts.limit;
const exactBounds = exactMessageId
? {
inclusive: true,
latest: exactMessageId,
oldest: undefined,
}
: {
latest: normalizeSlackReadTimestamp(opts.before, "before"),
oldest: normalizeSlackReadTimestamp(opts.after, "after"),
};
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: readLimit,
...exactBounds,
});
const messages = ((result.messages ?? []) as SlackMessageSummary[]).filter((message) => {
if (exactMessageId) {
return message.ts === exactMessageId;
}
// conversations.replies includes the parent message; drop it for replies-only reads.
return message.ts !== opts.threadId;
});
return {
messages,
hasMore: exactMessageId ? false : Boolean(result.has_more),
};
}
const result = await client.conversations.history({
channel: channelId,
limit: readLimit,
...exactBounds,
});
const messages = ((result.messages ?? []) as SlackMessageSummary[]).filter(
(message) => !exactMessageId || message.ts === exactMessageId,
);
return {
messages,
hasMore: exactMessageId ? false : 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, opts.cfg);
const client = await getClient(opts);
// Fetch fresh file metadata (includes a current url_private_download).
const info = await client.files.info({ file: fileId });
const file = info.file as SlackFileInfoSummary | undefined;
if (!file?.url_private_download && !file?.url_private) {
return null;
}
if (hasSlackScopeMismatch({ file, channelId: opts.channelId, threadId: opts.threadId })) {
return null;
}
const results = await resolveSlackMedia({
files: [
{
id: file.id,
name: file.name,
mimetype: file.mimetype,
url_private: file.url_private,
url_private_download: file.url_private_download,
},
],
token,
maxBytes: opts.maxBytes,
});
return results?.[0] ?? null;
}

View File

@@ -0,0 +1,125 @@
// Slack tests cover approval auth plugin behavior.
import { describe, expect, it } from "vitest";
import { isSlackApprovalAuthorizedSender, 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: "u123owner",
action: "approve",
approvalKind: "plugin",
}),
).toEqual({ authorized: true });
expect(
slackApprovalAuth.authorizeActorAction({
cfg,
senderId: "U345DEFAULT",
action: "approve",
approvalKind: "plugin",
}),
).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.",
});
});
it("canonicalizes configured plugin approver ids before matching uppercase senders", () => {
const cfg = {
channels: {
slack: {
allowFrom: ["slack:u123owner"],
defaultTo: "user:u345default",
},
},
};
for (const senderId of ["U123OWNER", "U345DEFAULT"]) {
expect(
slackApprovalAuth.authorizeActorAction({
cfg,
senderId,
action: "approve",
approvalKind: "plugin",
}),
).toEqual({ authorized: true });
}
});
it("allows same-chat plugin approval when no concrete Slack approvers are configured", () => {
const cfg = {
channels: {
slack: {
allowFrom: ["*"],
},
},
};
expect(
slackApprovalAuth.authorizeActorAction({
cfg,
senderId: "U123OWNER",
action: "approve",
approvalKind: "plugin",
}),
).toEqual({ authorized: true });
expect(
isSlackApprovalAuthorizedSender({
cfg,
senderId: "U123OWNER",
}),
).toBe(true);
});
});

View File

@@ -0,0 +1,43 @@
// Slack plugin module implements approval auth behavior.
import {
createResolvedApproverActionAuthAdapter,
resolveApprovalApprovers,
} from "openclaw/plugin-sdk/approval-auth-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveSlackAccount, resolveSlackAccountAllowFrom } 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: resolveSlackAccountAllowFrom(params),
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;
}
const approvers = getSlackApprovalApprovers(params);
if (approvers.length > 0) {
return approvers.includes(senderId);
}
return (resolveSlackAccountAllowFrom(params) ?? []).some((entry) => entry.trim() === "*");
}
export const slackApprovalAuth = createResolvedApproverActionAuthAdapter({
channelLabel: "Slack",
resolveApprovers: ({ cfg, accountId }) => getSlackApprovalApprovers({ cfg, accountId }),
normalizeSenderId: (value) => normalizeSlackApproverId(value),
});

View File

@@ -0,0 +1,605 @@
// Slack tests cover approval handler plugin behavior.
import { describe, expect, it, vi } from "vitest";
import { slackApprovalNativeRuntime } from "./approval-handler.runtime.js";
type SlackPayload = {
text: string;
blocks?: unknown;
};
type ChatUpdatePayload = {
channel?: string;
ts?: string;
text?: string;
blocks?: unknown;
};
const SLACK_CHAT_UPDATE_TEXT_LIMIT = 4000;
function findSlackActionsBlock(blocks: Array<{ type?: string; elements?: unknown[] }>) {
return blocks.find((block) => block.type === "actions");
}
function readChatUpdatePayload(
chatUpdate: { mock: { calls: unknown[][] } },
index: number,
): ChatUpdatePayload {
const call = chatUpdate.mock.calls[index];
if (!call) {
throw new Error(`Expected Slack chat.update call #${index + 1}`);
}
const [payload] = call;
if (!payload || typeof payload !== "object") {
throw new Error(`Expected Slack chat.update payload #${index + 1}`);
}
return payload as ChatUpdatePayload;
}
const UNPAIRED_SURROGATE_RE =
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
function readMrkdwnTexts(blocks: unknown): string[] {
if (!Array.isArray(blocks)) {
return [];
}
const texts: string[] = [];
for (const block of blocks) {
if (!block || typeof block !== "object") {
continue;
}
const text = (block as { text?: unknown }).text;
if (
text &&
typeof text === "object" &&
(text as { type?: unknown }).type === "mrkdwn" &&
typeof (text as { text?: unknown }).text === "string"
) {
texts.push((text as { text: string }).text);
}
const elements = (block as { elements?: unknown }).elements;
if (!Array.isArray(elements)) {
continue;
}
for (const element of elements) {
if (
element &&
typeof element === "object" &&
(element as { type?: unknown }).type === "mrkdwn" &&
typeof (element as { text?: unknown }).text === "string"
) {
texts.push((element as { text: string }).text);
}
}
}
return texts;
}
function findApprovalMrkdwn(payload: SlackPayload, prefix: string): string {
const text = readMrkdwnTexts(payload.blocks).find((entry) => entry.startsWith(prefix));
if (!text) {
throw new Error(`Expected Slack mrkdwn block starting with ${prefix}`);
}
return text;
}
describe("slackApprovalNativeRuntime", () => {
it("subscribes to plugin approval events", () => {
expect(slackApprovalNativeRuntime.eventKinds).toEqual(["exec", "plugin"]);
});
it("does not leave dangling surrogates when truncating exec approval command mrkdwn", async () => {
const commandText = `${"a".repeat(2598)}😀tail`;
const payload = (await slackApprovalNativeRuntime.presentation.buildPendingPayload({
cfg: {} as never,
accountId: "default",
context: {
app: {} as never,
config: {} as never,
},
request: {
id: "req-surrogate",
request: {
command: commandText,
},
createdAtMs: 0,
expiresAtMs: 60_000,
},
approvalKind: "exec",
nowMs: 0,
view: {
approvalKind: "exec",
approvalId: "req-surrogate",
commandText,
metadata: [],
actions: [
{
decision: "allow-once",
label: "Allow Once",
command: "/approve req-surrogate allow-once",
style: "success",
},
],
} as never,
})) as SlackPayload;
const commandMrkdwn = findApprovalMrkdwn(payload, "*Command*");
expect(commandMrkdwn).toMatch(/…\n```$/);
expect(UNPAIRED_SURROGATE_RE.test(commandMrkdwn)).toBe(false);
});
it("does not leave dangling surrogates when truncating plugin approval request mrkdwn", async () => {
const title = `${"a".repeat(2598)}😀tail`;
const payload = (await slackApprovalNativeRuntime.presentation.buildPendingPayload({
cfg: {} as never,
accountId: "default",
context: {
app: {} as never,
config: {} as never,
},
request: {
id: "plugin:req-surrogate",
request: {
title,
description: "Needs approval.",
},
createdAtMs: 0,
expiresAtMs: 60_000,
},
approvalKind: "plugin",
nowMs: 0,
view: {
approvalKind: "plugin",
phase: "pending",
approvalId: "plugin:req-surrogate",
title,
description: "Needs approval.",
severity: "warning",
pluginId: "test-plugin",
toolName: "test-tool",
metadata: [],
actions: [
{
decision: "deny",
label: "Deny",
command: "/approve plugin:req-surrogate deny",
style: "danger",
},
],
expiresAtMs: 60_000,
} as never,
})) as SlackPayload;
const requestMrkdwn = findApprovalMrkdwn(payload, "*Request*");
expect(requestMrkdwn).toMatch(/…$/);
expect(UNPAIRED_SURROGATE_RE.test(requestMrkdwn)).toBe(false);
});
it("still truncates plain BMP approval mrkdwn at the Slack approval preview limit", async () => {
const commandText = "b".repeat(2700);
const payload = (await slackApprovalNativeRuntime.presentation.buildPendingPayload({
cfg: {} as never,
accountId: "default",
context: {
app: {} as never,
config: {} as never,
},
request: {
id: "req-bmp",
request: {
command: commandText,
},
createdAtMs: 0,
expiresAtMs: 60_000,
},
approvalKind: "exec",
nowMs: 0,
view: {
approvalKind: "exec",
approvalId: "req-bmp",
commandText,
metadata: [],
actions: [
{
decision: "allow-once",
label: "Allow Once",
command: "/approve req-bmp allow-once",
style: "success",
},
],
} as never,
})) as SlackPayload;
const commandMrkdwn = findApprovalMrkdwn(payload, "*Command*");
expect(commandMrkdwn).toMatch(/…\n```$/);
expect(commandMrkdwn).toContain(`${"b".repeat(2599)}`);
expect(UNPAIRED_SURROGATE_RE.test(commandMrkdwn)).toBe(false);
});
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 plugin pending approvals with plugin approval actions", async () => {
const payload = (await slackApprovalNativeRuntime.presentation.buildPendingPayload({
cfg: {} as never,
accountId: "default",
context: {
app: {} as never,
config: {} as never,
},
request: {
id: "plugin:req-1",
request: {
title: "Share screen with Computer Use",
description: "Computer Use wants to inspect the desktop.",
},
createdAtMs: 0,
expiresAtMs: 60_000,
},
approvalKind: "plugin",
nowMs: 0,
view: {
approvalKind: "plugin",
phase: "pending",
approvalId: "plugin:req-1",
title: "Share screen with Computer Use",
description: "Computer Use wants to inspect the desktop.",
severity: "warning",
pluginId: "computer-use",
toolName: "screenshot",
metadata: [
{ label: "Severity", value: "Warning" },
{ label: "Plugin", value: "computer-use" },
],
actions: [
{
decision: "allow-once",
label: "Allow Once",
command: "/approve plugin:req-1 allow-once",
style: "success",
},
{
decision: "allow-always",
label: "Allow Always",
command: "/approve plugin:req-1 allow-always",
style: "success",
},
{
decision: "deny",
label: "Deny",
command: "/approve plugin:req-1 deny",
style: "danger",
},
],
expiresAtMs: 60_000,
},
})) as SlackPayload;
expect(payload.text).toContain("*Plugin approval required*");
expect(payload.text).toContain("Share screen with Computer Use");
expect(payload.text).toContain("*Approval ID:* plugin:req-1");
expect(payload.text).not.toContain("*Command*");
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", "Allow Always", "Deny"]);
expect(JSON.stringify(payload.blocks)).toContain("plugin:req-1");
});
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);
});
it("renders plugin resolved and expired updates without command text", async () => {
const resolved = await slackApprovalNativeRuntime.presentation.buildResolvedResult({
cfg: {} as never,
accountId: "default",
context: {
app: {} as never,
config: {} as never,
},
request: {
id: "plugin:req-1",
request: {
title: "Share screen with Computer Use",
description: "Computer Use wants to inspect the desktop.",
},
createdAtMs: 0,
expiresAtMs: 60_000,
},
resolved: {
id: "plugin:req-1",
decision: "allow-once",
resolvedBy: "U123APPROVER",
ts: 0,
} as never,
view: {
approvalKind: "plugin",
phase: "resolved",
approvalId: "plugin:req-1",
title: "Share screen with Computer Use",
description: "Computer Use wants to inspect the desktop.",
severity: "warning",
pluginId: "computer-use",
toolName: "screenshot",
metadata: [{ label: "Plugin", value: "computer-use" }],
decision: "allow-once",
resolvedBy: "U123APPROVER",
},
entry: {
channelId: "D123APPROVER",
messageTs: "1712345678.999999",
},
});
const expired = await slackApprovalNativeRuntime.presentation.buildExpiredResult({
cfg: {} as never,
accountId: "default",
context: {
app: {} as never,
config: {} as never,
},
request: {
id: "plugin:req-1",
request: {
title: "Share screen with Computer Use",
description: "Computer Use wants to inspect the desktop.",
},
createdAtMs: 0,
expiresAtMs: 60_000,
},
view: {
approvalKind: "plugin",
phase: "expired",
approvalId: "plugin:req-1",
title: "Share screen with Computer Use",
description: "Computer Use wants to inspect the desktop.",
severity: "warning",
pluginId: "computer-use",
toolName: "screenshot",
metadata: [{ label: "Plugin", value: "computer-use" }],
},
entry: {
channelId: "D123APPROVER",
messageTs: "1712345678.999999",
},
});
expect(resolved.kind).toBe("update");
expect(expired.kind).toBe("update");
if (resolved.kind !== "update" || expired.kind !== "update") {
throw new Error("expected Slack update payloads");
}
const resolvedPayload = resolved.payload as SlackPayload;
const expiredPayload = expired.payload as SlackPayload;
expect(resolvedPayload.text).toContain("*Plugin approval: Allowed once*");
expect(resolvedPayload.text).toContain("Resolved by <@U123APPROVER>.");
expect(resolvedPayload.text).toContain("Share screen with Computer Use");
expect(resolvedPayload.text).not.toContain("*Command*");
expect(expiredPayload.text).toContain("*Plugin approval expired*");
expect(expiredPayload.text).toContain("Share screen with Computer Use");
expect(expiredPayload.text).not.toContain("*Command*");
expect(
(resolvedPayload.blocks as Array<{ type?: string }>).some(
(block) => block.type === "actions",
),
).toBe(false);
});
it("caps resolved update fallback text to Slack chat.update limits while preserving blocks", async () => {
const blocks = [
{
type: "section",
text: {
type: "mrkdwn",
text: "*Command*\n```short preview```",
},
},
];
const chatUpdate = vi.fn(async (_payload: { text: string; blocks: typeof blocks }) => ({}));
const context = {
app: {
client: {
chat: {
update: chatUpdate,
},
},
},
config: {},
} as never;
await slackApprovalNativeRuntime.transport.updateEntry?.({
cfg: {} as never,
accountId: "default",
context,
entry: {
channelId: "C123",
messageTs: "1712345678.999999",
},
payload: {
text: "a".repeat(SLACK_CHAT_UPDATE_TEXT_LIMIT),
blocks,
},
phase: "resolved",
});
await slackApprovalNativeRuntime.transport.updateEntry?.({
cfg: {} as never,
accountId: "default",
context,
entry: {
channelId: "C123",
messageTs: "1712345678.999999",
},
payload: {
text: "a".repeat(5000),
blocks,
},
phase: "resolved",
});
const firstUpdate = readChatUpdatePayload(chatUpdate, 0);
const secondUpdate = readChatUpdatePayload(chatUpdate, 1);
expect(firstUpdate.channel).toBe("C123");
expect(firstUpdate.ts).toBe("1712345678.999999");
expect(firstUpdate.text).toBe("a".repeat(SLACK_CHAT_UPDATE_TEXT_LIMIT));
expect(firstUpdate.blocks).toBe(blocks);
expect(secondUpdate.channel).toBe("C123");
expect(secondUpdate.ts).toBe("1712345678.999999");
expect(secondUpdate.text).toMatch(/…$/);
expect(secondUpdate.blocks).toBe(blocks);
expect(secondUpdate.text).toHaveLength(SLACK_CHAT_UPDATE_TEXT_LIMIT);
});
it("keeps pending metadata context within Slack Block Kit limits", 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: Array.from({ length: 12 }, (_entry, index) => ({
label: `Metadata ${index + 1}`,
value: index === 0 ? "x".repeat(3100) : `value-${index + 1}`,
})),
actions: [
{
decision: "allow-once",
label: "Allow Once",
command: "/approve req-1 allow-once",
style: "success",
},
],
} as never,
})) as SlackPayload;
const contextBlock = (payload.blocks as Array<{ type?: string; elements?: unknown[] }>).find(
(block) => block.type === "context",
);
const elements = contextBlock?.elements as Array<{ text?: string }> | undefined;
expect(elements).toHaveLength(10);
expect(elements?.[0]?.text).toHaveLength(3000);
expect(elements?.[0]?.text?.endsWith("…")).toBe(true);
expect(elements?.at(-1)?.text).toBe("…+3 more");
});
});

View File

@@ -0,0 +1,527 @@
// Slack plugin module implements approval handler behavior.
import type { App } from "@slack/bolt";
import type { Block, KnownBlock } from "@slack/web-api";
import type {
ChannelApprovalCapabilityHandlerContext,
ExecApprovalExpiredView,
ExecApprovalPendingView,
ExecApprovalResolvedView,
ExpiredApprovalView,
PendingApprovalView,
PluginApprovalExpiredView,
PluginApprovalPendingView,
PluginApprovalResolvedView,
ResolvedApprovalView,
} 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 { buildApprovalPresentationFromActionDescriptors } from "openclaw/plugin-sdk/approval-reply-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { logError } from "openclaw/plugin-sdk/logging-core";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import {
isSlackAnyNativeApprovalClientEnabled,
resolveSlackApprovalKind,
shouldHandleSlackNativeApprovalRequest,
} from "./approval-native-gates.js";
import { normalizeSlackApproverId } from "./exec-approvals.js";
import { resolveSlackReplyBlocks } from "./reply-blocks.js";
import { sendMessageSlack } from "./send.js";
import { truncateSlackText } from "./truncate.js";
type SlackBlock = Block | KnownBlock;
type SlackPendingApproval = {
channelId: string;
messageTs: string;
};
type SlackPendingDelivery = {
text: string;
blocks: SlackBlock[];
};
type SlackMetadataItem = {
label: string;
value: string;
};
type SlackPluginApprovalView =
| PluginApprovalPendingView
| PluginApprovalResolvedView
| PluginApprovalExpiredView;
const SLACK_CONTEXT_ELEMENTS_MAX = 10;
const SLACK_CHAT_UPDATE_TEXT_LIMIT = 4000;
const SLACK_TEXT_OBJECT_MAX = 3000;
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 {
const limit = Math.max(0, Math.floor(maxChars));
if (text.length <= limit) {
return text;
}
if (limit <= 1) {
return truncateUtf16Safe(text, limit);
}
return `${truncateUtf16Safe(text, limit - 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 SlackMetadataItem[]): string[] {
const lines: string[] = [];
for (const item of metadata) {
lines.push(formatSlackMetadataLine(item.label, item.value));
}
return lines;
}
function buildSlackMetadataContextElements(metadata: readonly SlackMetadataItem[]) {
const lines = buildSlackMetadataLines(metadata);
const visibleLineCount =
lines.length > SLACK_CONTEXT_ELEMENTS_MAX ? SLACK_CONTEXT_ELEMENTS_MAX - 1 : lines.length;
const elements: Array<{ type: "mrkdwn"; text: string }> = [];
for (let index = 0; index < visibleLineCount; index += 1) {
const line = lines[index];
if (line === undefined) {
continue;
}
elements.push({
type: "mrkdwn",
text: truncateSlackMrkdwn(line, SLACK_TEXT_OBJECT_MAX),
});
}
if (lines.length > SLACK_CONTEXT_ELEMENTS_MAX) {
elements.push({
type: "mrkdwn",
text: `…+${lines.length - visibleLineCount} more`,
});
}
return elements;
}
function buildSlackMetadataContextBlocks(metadata: readonly SlackMetadataItem[]): SlackBlock[] {
const metadataElements = buildSlackMetadataContextElements(metadata);
return metadataElements.length > 0
? [
{
type: "context",
elements: metadataElements,
} satisfies SlackBlock,
]
: [];
}
function resolveSlackApprovalDecisionLabel(
decision: "allow-once" | "allow-always" | "deny",
): string {
return decision === "allow-once"
? "Allowed once"
: decision === "allow-always"
? "Allowed always"
: "Denied";
}
function buildSlackPluginMetadata(view: SlackPluginApprovalView): SlackMetadataItem[] {
return [{ label: "Approval ID", value: view.approvalId }, ...view.metadata];
}
function resolveSlackPluginDescription(view: SlackPluginApprovalView): string {
return normalizeOptionalString(view.description) ?? "A plugin action needs your approval.";
}
function buildSlackPluginRequestBlocks(view: SlackPluginApprovalView): SlackBlock[] {
return [
{
type: "section",
text: {
type: "mrkdwn",
text: `*Request*\n${truncateSlackMrkdwn(view.title, 2600)}`,
},
},
...buildSlackMetadataContextBlocks(buildSlackPluginMetadata(view)),
];
}
function buildSlackExecPendingApprovalText(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.join("\n");
}
function buildSlackPluginPendingApprovalText(view: PluginApprovalPendingView): string {
const metadataLines = buildSlackMetadataLines(buildSlackPluginMetadata(view));
const lines = [
"*Plugin approval required*",
resolveSlackPluginDescription(view),
"",
"*Request*",
view.title,
...metadataLines,
];
return lines.join("\n");
}
function buildSlackPendingApprovalText(view: PendingApprovalView): string {
return view.approvalKind === "plugin"
? buildSlackPluginPendingApprovalText(view)
: buildSlackExecPendingApprovalText(view);
}
function buildSlackExecPendingApprovalBlocks(view: ExecApprovalPendingView): SlackBlock[] {
const interactiveBlocks =
resolveSlackReplyBlocks({
text: "",
presentation: buildApprovalPresentationFromActionDescriptors(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))}`,
},
},
...buildSlackMetadataContextBlocks(view.metadata),
...interactiveBlocks,
];
}
function buildSlackPluginPendingApprovalBlocks(view: PluginApprovalPendingView): SlackBlock[] {
const interactiveBlocks =
resolveSlackReplyBlocks({
text: "",
presentation: buildApprovalPresentationFromActionDescriptors(view.actions),
}) ?? [];
return [
{
type: "section",
text: {
type: "mrkdwn",
text: `*Plugin approval required*\n${truncateSlackMrkdwn(
resolveSlackPluginDescription(view),
2600,
)}`,
},
},
...buildSlackPluginRequestBlocks(view),
...interactiveBlocks,
];
}
function buildSlackPendingApprovalBlocks(view: PendingApprovalView): SlackBlock[] {
return view.approvalKind === "plugin"
? buildSlackPluginPendingApprovalBlocks(view)
: buildSlackExecPendingApprovalBlocks(view);
}
function buildSlackExecResolvedText(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 buildSlackPluginResolvedText(view: PluginApprovalResolvedView): string {
const resolvedBy = formatSlackApprover(view.resolvedBy);
const metadataLines = buildSlackMetadataLines(buildSlackPluginMetadata(view));
const lines = [
`*Plugin approval: ${resolveSlackApprovalDecisionLabel(view.decision)}*`,
resolvedBy ? `Resolved by ${resolvedBy}.` : "Resolved.",
"",
"*Request*",
view.title,
...metadataLines,
];
return lines.join("\n");
}
function buildSlackResolvedText(view: ResolvedApprovalView): string {
return view.approvalKind === "plugin"
? buildSlackPluginResolvedText(view)
: buildSlackExecResolvedText(view);
}
function buildSlackExecResolvedBlocks(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 buildSlackPluginResolvedBlocks(view: PluginApprovalResolvedView): SlackBlock[] {
const resolvedBy = formatSlackApprover(view.resolvedBy);
return [
{
type: "section",
text: {
type: "mrkdwn",
text: `*Plugin approval: ${resolveSlackApprovalDecisionLabel(view.decision)}*\n${
resolvedBy ? `Resolved by ${resolvedBy}.` : "Resolved."
}`,
},
},
...buildSlackPluginRequestBlocks(view),
];
}
function buildSlackResolvedBlocks(view: ResolvedApprovalView): SlackBlock[] {
return view.approvalKind === "plugin"
? buildSlackPluginResolvedBlocks(view)
: buildSlackExecResolvedBlocks(view);
}
function buildSlackExecExpiredText(view: ExecApprovalExpiredView): string {
return [
"*Exec approval expired*",
"This approval request expired before it was resolved.",
"",
"*Command*",
buildSlackCodeBlock(view.commandText),
].join("\n");
}
function buildSlackPluginExpiredText(view: PluginApprovalExpiredView): string {
const metadataLines = buildSlackMetadataLines(buildSlackPluginMetadata(view));
return [
"*Plugin approval expired*",
"This approval request expired before it was resolved.",
"",
"*Request*",
view.title,
...metadataLines,
].join("\n");
}
function buildSlackExpiredText(view: ExpiredApprovalView): string {
return view.approvalKind === "plugin"
? buildSlackPluginExpiredText(view)
: buildSlackExecExpiredText(view);
}
function buildSlackExecExpiredBlocks(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))}`,
},
},
];
}
function buildSlackPluginExpiredBlocks(view: PluginApprovalExpiredView): SlackBlock[] {
return [
{
type: "section",
text: {
type: "mrkdwn",
text: "*Plugin approval expired*\nThis approval request expired before it was resolved.",
},
},
...buildSlackPluginRequestBlocks(view),
];
}
function buildSlackExpiredBlocks(view: ExpiredApprovalView): SlackBlock[] {
return view.approvalKind === "plugin"
? buildSlackPluginExpiredBlocks(view)
: buildSlackExecExpiredBlocks(view);
}
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: truncateSlackText(params.text, SLACK_CHAT_UPDATE_TEXT_LIMIT),
blocks: params.blocks,
});
} catch (err) {
logError(`slack approvals: failed to update message: ${String(err)}`);
}
}
export const slackApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
SlackPendingDelivery,
{ to: string; threadTs?: string },
SlackPendingApproval,
never,
SlackPendingDelivery
>({
eventKinds: ["exec", "plugin"],
availability: {
isConfigured: (params) => {
const resolved = resolveHandlerContext(params);
return resolved
? isSlackAnyNativeApprovalClientEnabled({
cfg: params.cfg,
accountId: resolved.accountId,
})
: false;
},
shouldHandle: (params) => {
const resolved = resolveHandlerContext(params);
if (!resolved) {
return false;
}
return shouldHandleSlackNativeApprovalRequest({
cfg: params.cfg,
accountId: resolved.accountId,
approvalKind: resolveSlackApprovalKind(params.request),
request: params.request,
});
},
},
presentation: {
buildPendingPayload: ({ view }) => ({
text: buildSlackPendingApprovalText(view),
blocks: buildSlackPendingApprovalBlocks(view),
}),
buildResolvedResult: ({ view }) => ({
kind: "update",
payload: {
text: buildSlackResolvedText(view),
blocks: buildSlackResolvedBlocks(view),
},
}),
buildExpiredResult: ({ view }) => ({
kind: "update",
payload: {
text: buildSlackExpiredText(view),
blocks: buildSlackExpiredBlocks(view),
},
}),
},
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 approvals: failed to deliver approval ${request.id}: ${String(error)}`);
},
},
});

View File

@@ -0,0 +1,436 @@
// Slack plugin module implements approval native gates behavior.
import {
isChannelExecApprovalClientEnabledFromConfig,
matchesApprovalRequestFilters,
} from "openclaw/plugin-sdk/approval-client-runtime";
import {
createNativeApprovalChannelRouteGates,
doesApprovalRequestMatchChannelAccount,
resolveApprovalRequestSessionConversation,
} from "openclaw/plugin-sdk/approval-native-runtime";
import type {
ExecApprovalRequest,
PluginApprovalRequest,
} from "openclaw/plugin-sdk/approval-runtime";
import type { ChannelApprovalCapability } from "openclaw/plugin-sdk/channel-contract";
import {
channelRouteTargetsMatchExact,
stringifyRouteThreadId,
} from "openclaw/plugin-sdk/channel-route";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { normalizeMessageChannel } from "openclaw/plugin-sdk/routing";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { isSlackPluginAccountConfigured } from "./account-configured.js";
import {
listSlackAccountIds,
resolveDefaultSlackAccountId,
resolveSlackAccount,
} from "./accounts.js";
import { getSlackApprovalApprovers } from "./approval-auth.js";
import {
getSlackExecApprovalApprovers,
isSlackExecApprovalClientEnabled,
} from "./exec-approvals.js";
import { parseSlackTarget } from "./targets.js";
export type SlackApprovalKind = "exec" | "plugin";
export type SlackNativeApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
export type SlackOriginTarget = {
to: string;
accountId?: string | null;
threadId?: string | number | null;
};
type ApprovalForwardingConfig = NonNullable<NonNullable<OpenClawConfig["approvals"]>["plugin"]>;
type ApprovalForwardingMode = NonNullable<ApprovalForwardingConfig["mode"]>;
type SlackForwardTarget = Parameters<
NonNullable<
NonNullable<ChannelApprovalCapability["delivery"]>["shouldSuppressForwardingFallback"]
>
>[0]["target"];
const DEFAULT_APPROVAL_FORWARDING_MODE: ApprovalForwardingMode = "session";
const SLACK_DM_CHANNEL_ID_RE = /^D[A-Z0-9]{8,}$/i;
const SLACK_USER_ID_RE = /^[UW][A-Z0-9]{8,}$/i;
export function resolveSlackApprovalKind(request: SlackNativeApprovalRequest): SlackApprovalKind {
return request.id.startsWith("plugin:") ? "plugin" : "exec";
}
function isSlackApprovalTransportEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
return isSlackPluginAccountConfigured(resolveSlackAccount(params));
}
function resolveSlackNativeApprovalConfig(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}) {
return resolveSlackAccount(params).config.execApprovals;
}
function resolvePluginApprovalForwardingConfig(cfg: OpenClawConfig) {
return cfg.approvals?.plugin;
}
function normalizeSlackThreadMatchKey(threadId?: string | number | null): string {
return threadId == null ? "" : String(threadId).trim();
}
function normalizeComparableTarget(value: string): string {
return normalizeLowercaseStringOrEmpty(value);
}
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 resolveSlackTurnSourceDefaultKind(params: {
turnSourceTo: string;
sessionKind: "direct" | "channel" | "group" | null;
}): "user" | "channel" {
// Slack app conversations arrive as the concrete D-channel plus the app
// thread root, so keep that live target instead of rewriting it to a user id.
if (SLACK_DM_CHANNEL_ID_RE.test(params.turnSourceTo)) {
return "channel";
}
return params.sessionKind === "direct" ? "user" : "channel";
}
export function resolveTurnSourceSlackOriginTarget(
request: SlackNativeApprovalRequest,
): 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: resolveSlackTurnSourceDefaultKind({ turnSourceTo, sessionKind }),
});
if (!parsed) {
return null;
}
return {
to: `${parsed.kind}:${parsed.id}`,
threadId: stringifyRouteThreadId(request.request.turnSourceThreadId),
};
}
export function resolveSessionSlackOriginTarget(sessionTarget: {
to: string;
threadId?: string | number | null;
}): SlackOriginTarget {
return {
to: sessionTarget.to,
threadId: stringifyRouteThreadId(sessionTarget.threadId),
};
}
export function resolveSlackFallbackOriginTarget(
request: SlackNativeApprovalRequest,
): 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,
};
}
export function normalizeSlackOriginTarget(target: SlackOriginTarget): SlackOriginTarget {
return {
...target,
to: normalizeComparableTarget(target.to),
};
}
function parseComparableSlackTarget(target: SlackOriginTarget) {
return parseSlackTarget(target.to, { defaultKind: "channel" });
}
function isSlackDmChannelToUserRoutePair(a: SlackOriginTarget, b: SlackOriginTarget): boolean {
const left = parseComparableSlackTarget(a);
const right = parseComparableSlackTarget(b);
if (!left || !right) {
return false;
}
return (
(left.kind === "channel" && SLACK_DM_CHANNEL_ID_RE.test(left.id) && right.kind === "user") ||
(right.kind === "channel" && SLACK_DM_CHANNEL_ID_RE.test(right.id) && left.kind === "user")
);
}
export function slackTargetsMatch(a: SlackOriginTarget, b: SlackOriginTarget): boolean {
const threadKey = normalizeSlackThreadMatchKey(a.threadId);
if (threadKey !== normalizeSlackThreadMatchKey(b.threadId)) {
return false;
}
if (
channelRouteTargetsMatchExact({
left: {
channel: "slack",
to: a.to,
},
right: {
channel: "slack",
to: b.to,
},
})
) {
return true;
}
return Boolean(threadKey && isSlackDmChannelToUserRoutePair(a, b));
}
export function normalizeSlackForwardTarget(
target: Pick<SlackForwardTarget, "channel" | "to" | "accountId" | "threadId">,
): SlackOriginTarget | null {
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
if (channel !== "slack") {
return null;
}
const to = normalizeOptionalString(target.to);
if (!to) {
return null;
}
const parsed = parseSlackTarget(to, {
defaultKind: SLACK_USER_ID_RE.test(to) ? "user" : "channel",
});
if (!parsed) {
return null;
}
return {
to: `${parsed.kind}:${parsed.id}`,
accountId: normalizeOptionalString(target.accountId),
threadId: stringifyRouteThreadId(target.threadId),
};
}
const slackApprovalRouteGates = createNativeApprovalChannelRouteGates({
channel: "slack",
defaultForwardingMode: DEFAULT_APPROVAL_FORWARDING_MODE,
isTransportEnabled: isSlackApprovalTransportEnabled,
listAccountIds: listSlackAccountIds,
resolveDefaultAccountId: resolveDefaultSlackAccountId,
normalizeForwardTarget: normalizeSlackForwardTarget,
resolveTurnSourceTarget: resolveTurnSourceSlackOriginTarget,
targetsMatch: slackTargetsMatch,
});
const {
canApprovalPotentiallyRouteToChannel: canApprovalPotentiallyRouteToSlack,
isSessionApprovalEligible: isForwardedSlackSessionApprovalEligible,
isExplicitTargetEligible: isForwardedSlackExplicitTargetEligible,
} = slackApprovalRouteGates;
export function hasSlackPluginApprovers(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
return getSlackApprovalApprovers(params).length > 0;
}
function isSlackPluginNativeApprovalClientConfigEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
const slackNativeConfig = resolveSlackNativeApprovalConfig(params);
return isChannelExecApprovalClientEnabledFromConfig({
enabled: slackNativeConfig?.enabled,
approverCount: getSlackApprovalApprovers(params).length,
});
}
function isSlackPluginForwardingRoutePotentiallyEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
return canApprovalPotentiallyRouteToSlack({
...params,
approvalKind: "plugin",
});
}
function isSlackPluginNativeApprovalClientEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
return (
isSlackPluginNativeApprovalClientConfigEnabled(params) ||
isSlackPluginForwardingRoutePotentiallyEnabled(params)
);
}
function shouldHandleSlackPluginViaNativeClientConfig(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: SlackNativeApprovalRequest;
}): boolean {
if (
!doesApprovalRequestMatchChannelAccount({
cfg: params.cfg,
request: params.request,
channel: "slack",
accountId: params.accountId,
})
) {
return false;
}
const config = resolveSlackNativeApprovalConfig(params);
if (
!isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: getSlackApprovalApprovers(params).length,
})
) {
return false;
}
return matchesSlackNativeApprovalFilters({
request: params.request,
agentFilter: config?.agentFilter,
sessionFilter: config?.sessionFilter,
});
}
function matchesSlackNativeApprovalFilters(params: {
request: SlackNativeApprovalRequest;
agentFilter?: string[];
sessionFilter?: string[];
}): boolean {
return matchesApprovalRequestFilters({
request: params.request.request,
agentFilter: params.agentFilter,
sessionFilter: params.sessionFilter,
});
}
function isAnyForwardedSlackExplicitTargetEligible(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: SlackNativeApprovalRequest;
}): boolean {
const targets = resolvePluginApprovalForwardingConfig(params.cfg)?.targets ?? [];
return targets.some((target) =>
isForwardedSlackExplicitTargetEligible({
...params,
approvalKind: "plugin",
target,
}),
);
}
function shouldHandleSlackPluginViaForwarding(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: SlackNativeApprovalRequest;
}): boolean {
return (
isForwardedSlackSessionApprovalEligible({
...params,
approvalKind: "plugin",
}) || isAnyForwardedSlackExplicitTargetEligible(params)
);
}
export function shouldHandleSlackPluginViaForwardingSession(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: SlackNativeApprovalRequest;
}): boolean {
return isForwardedSlackSessionApprovalEligible({
...params,
approvalKind: "plugin",
});
}
export function isSlackNativeApprovalClientEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind: SlackApprovalKind;
}): boolean {
if (params.approvalKind === "exec") {
return isSlackExecApprovalClientEnabled(params);
}
return isSlackPluginNativeApprovalClientEnabled(params);
}
export function isSlackAnyNativeApprovalClientEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
return (
isSlackNativeApprovalClientEnabled({
...params,
approvalKind: "exec",
}) ||
isSlackNativeApprovalClientEnabled({
...params,
approvalKind: "plugin",
})
);
}
export function shouldHandleSlackNativeApprovalRequest(params: {
cfg: OpenClawConfig;
accountId?: string | null;
approvalKind?: SlackApprovalKind;
request: SlackNativeApprovalRequest;
}): boolean {
const approvalKind = params.approvalKind ?? resolveSlackApprovalKind(params.request);
if (approvalKind === "plugin") {
return (
shouldHandleSlackPluginViaNativeClientConfig(params) ||
shouldHandleSlackPluginViaForwarding(params)
);
}
if (
!doesApprovalRequestMatchChannelAccount({
cfg: params.cfg,
request: params.request,
channel: "slack",
accountId: params.accountId,
})
) {
return false;
}
const config = resolveSlackNativeApprovalConfig(params);
if (
!isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: getSlackExecApprovalApprovers(params).length,
})
) {
return false;
}
return matchesSlackNativeApprovalFilters({
request: params.request,
agentFilter: config?.agentFilter,
sessionFilter: config?.sessionFilter,
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,233 @@
// Slack plugin module implements approval native behavior.
import { createApproverRestrictedNativeApprovalCapability } 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 {
createChannelNativeOriginTargetResolver,
createNativeApprovalForwardingFallbackSuppressor,
} from "openclaw/plugin-sdk/approval-native-runtime";
import type { ChannelApprovalCapability } from "openclaw/plugin-sdk/channel-contract";
import { normalizeMessageChannel } from "openclaw/plugin-sdk/routing";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { listSlackAccountIds } from "./accounts.js";
import { getSlackApprovalApprovers, isSlackApprovalAuthorizedSender } from "./approval-auth.js";
import {
hasSlackPluginApprovers,
isSlackAnyNativeApprovalClientEnabled,
normalizeSlackForwardTarget,
normalizeSlackOriginTarget,
resolveSessionSlackOriginTarget,
resolveSlackApprovalKind,
resolveSlackFallbackOriginTarget,
resolveTurnSourceSlackOriginTarget,
shouldHandleSlackNativeApprovalRequest,
shouldHandleSlackPluginViaForwardingSession,
slackTargetsMatch,
type SlackApprovalKind,
type SlackNativeApprovalRequest,
type SlackOriginTarget,
} from "./approval-native-gates.js";
import {
getSlackExecApprovalApprovers,
isSlackExecApprovalAuthorizedSender,
isSlackExecApprovalClientEnabled,
resolveSlackExecApprovalTarget,
} from "./exec-approvals.js";
type ApprovalRequest = SlackNativeApprovalRequest;
type ApprovalKind = SlackApprovalKind;
type SlackSuppressionAccountInput = {
target: { channel: string; accountId?: string | null };
request: {
request: {
turnSourceChannel?: string | null;
turnSourceAccountId?: string | null;
};
};
};
function resolveSlackNativeSuppressionAccountId({
target,
request,
}: SlackSuppressionAccountInput): string | undefined {
return (
normalizeOptionalString(target.accountId) ??
normalizeOptionalString(request.request.turnSourceAccountId)
);
}
function shouldConsiderSlackNativeForwardingSuppression(
input: SlackSuppressionAccountInput & { approvalKind: ApprovalKind },
): boolean {
const channel = normalizeMessageChannel(input.target.channel) ?? input.target.channel;
if (channel !== "slack") {
return false;
}
if (input.approvalKind === "plugin") {
return true;
}
const turnSourceChannel = normalizeMessageChannel(input.request.request.turnSourceChannel);
return turnSourceChannel === "slack";
}
const resolveSlackOriginTarget = createChannelNativeOriginTargetResolver({
channel: "slack",
shouldHandleRequest: ({ cfg, accountId, request }) =>
shouldHandleSlackNativeApprovalRequest({
cfg,
accountId,
request,
}),
resolveTurnSourceTarget: resolveTurnSourceSlackOriginTarget,
resolveSessionTarget: resolveSessionSlackOriginTarget,
normalizeTargetForMatch: normalizeSlackOriginTarget,
targetsMatch: slackTargetsMatch,
resolveFallbackTarget: resolveSlackFallbackOriginTarget,
});
function resolveSlackApproverDmTargets(params: {
cfg: Parameters<typeof shouldHandleSlackNativeApprovalRequest>[0]["cfg"];
accountId?: string | null;
approvalKind: ApprovalKind;
request: ApprovalRequest;
}): SlackOriginTarget[] {
if (
!shouldHandleSlackNativeApprovalRequest({
cfg: params.cfg,
accountId: params.accountId,
approvalKind: params.approvalKind,
request: params.request,
})
) {
return [];
}
const approvers =
params.approvalKind === "plugin"
? getSlackApprovalApprovers(params)
: getSlackExecApprovalApprovers(params);
return approvers.map((approver) => ({ to: `user:${approver}` }));
}
const shouldSuppressSlackForwardingFallback =
createNativeApprovalForwardingFallbackSuppressor<SlackOriginTarget>({
channel: "slack",
normalizeForwardTarget: normalizeSlackForwardTarget,
resolveAccountId: ({ target, request }) =>
resolveSlackNativeSuppressionAccountId({ target, request }),
isSessionRouteEligible: shouldHandleSlackNativeApprovalRequest,
isExplicitTargetEligible: shouldHandleSlackNativeApprovalRequest,
resolveOriginTarget: resolveSlackOriginTarget,
resolveApproverDmTargets: resolveSlackApproverDmTargets,
targetsMatch: slackTargetsMatch,
});
const baseSlackApprovalCapability = 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: resolveSlackNativeSuppressionAccountId,
resolveOriginTarget: resolveSlackOriginTarget,
resolveApproverDmTargets: resolveSlackApproverDmTargets,
notifyOriginWhenDmOnly: true,
nativeRuntime: createLazyChannelApprovalNativeRuntimeAdapter({
eventKinds: ["exec", "plugin"],
isConfigured: ({ cfg, accountId }) =>
isSlackAnyNativeApprovalClientEnabled({
cfg,
accountId,
}),
shouldHandle: ({ cfg, accountId, request }) =>
shouldHandleSlackNativeApprovalRequest({
cfg,
accountId,
approvalKind: resolveSlackApprovalKind(request),
request,
}),
load: async () =>
(await import("./approval-handler.runtime.js"))
.slackApprovalNativeRuntime as unknown as ChannelApprovalNativeRuntimeAdapter,
}),
});
const baseSlackNativeAdapter = baseSlackApprovalCapability.native;
export const slackApprovalCapability: ChannelApprovalCapability = {
...baseSlackApprovalCapability,
delivery: {
...baseSlackApprovalCapability.delivery,
shouldSuppressForwardingFallback: (input) => {
if (!shouldConsiderSlackNativeForwardingSuppression(input)) {
return false;
}
const canHandleNative = shouldHandleSlackNativeApprovalRequest({
cfg: input.cfg,
accountId: resolveSlackNativeSuppressionAccountId(input),
approvalKind: input.approvalKind,
request: input.request,
});
if (!canHandleNative || input.approvalKind !== "plugin") {
return canHandleNative;
}
return shouldSuppressSlackForwardingFallback(input);
},
},
native: baseSlackNativeAdapter
? {
...baseSlackNativeAdapter,
describeDeliveryCapabilities: (params) => {
const capabilities = baseSlackNativeAdapter.describeDeliveryCapabilities(params);
const request = params.request as ApprovalRequest;
const approvalKind = params.approvalKind;
return {
...capabilities,
enabled: shouldHandleSlackNativeApprovalRequest({
cfg: params.cfg,
accountId: params.accountId,
approvalKind,
request,
}),
...(approvalKind === "plugin" &&
shouldHandleSlackPluginViaForwardingSession({
cfg: params.cfg,
accountId: params.accountId,
request,
})
? {
preferredSurface: "origin" as const,
supportsApproverDmSurface: hasSlackPluginApprovers({
cfg: params.cfg,
accountId: params.accountId,
}),
}
: {}),
};
},
}
: undefined,
};
export const testing = {
resolveSessionSlackOriginTarget,
resolveTurnSourceSlackOriginTarget,
slackTargetsMatch,
};

View File

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

View File

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

View File

@@ -0,0 +1,421 @@
// Slack plugin module implements blocks render behavior.
import type { Block, KnownBlock } from "@slack/web-api";
import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime";
import {
reduceInteractiveReply,
resolveMessagePresentationControlValue,
} from "openclaw/plugin-sdk/interactive-runtime";
import type {
InteractiveReply,
MessagePresentation,
MessagePresentationButtonsBlock,
MessagePresentationSelectBlock,
} from "openclaw/plugin-sdk/interactive-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
SLACK_ACTION_BLOCK_ELEMENTS_MAX,
SLACK_ACTION_LABEL_MAX,
SLACK_BUTTON_VALUE_MAX,
SLACK_HEADER_TEXT_MAX,
SLACK_OPTION_VALUE_MAX,
SLACK_SECTION_TEXT_MAX,
SLACK_STATIC_SELECT_OPTIONS_MAX,
} from "./presentation.js";
import {
SLACK_REPLY_BUTTON_ACTION_ID,
SLACK_REPLY_LINK_ACTION_ID,
SLACK_REPLY_SELECT_ACTION_ID,
} from "./reply-action-ids.js";
import { truncateSlackText } from "./truncate.js";
const SLACK_BUTTON_URL_MAX = 3000;
export type SlackBlock = Block | KnownBlock;
type SlackInteractiveBlockRenderOptions = {
buttonIndexOffset?: number;
selectIndexOffset?: number;
};
function buildSlackReplyButtonActionId(buttonIndex: number, choiceIndex: number): string {
return `${SLACK_REPLY_BUTTON_ACTION_ID}:${String(buttonIndex)}:${String(choiceIndex + 1)}`;
}
function buildSlackReplyLinkActionId(buttonIndex: number, choiceIndex: number): string {
return `${SLACK_REPLY_LINK_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;
}
function resolveSlackControlValue(control: {
action?: { type: "command"; command: string } | { type: "callback"; value: string };
value?: string;
}): string | undefined {
if (control.action?.type === "command") {
const command = normalizeOptionalString(control.action.command);
if (command && parseExecApprovalCommandText(command)) {
return command;
}
const legacyValue = normalizeOptionalString(control.value);
return legacyValue && parseExecApprovalCommandText(legacyValue) ? legacyValue : undefined;
}
return resolveMessagePresentationControlValue(control);
}
function isWithinSlackLimit(value: string, maxLength: number): boolean {
return value.length <= maxLength;
}
function isRenderableSlackOption(option: {
label: string;
value: string | undefined;
}): option is { label: string; value: string } {
return option.value !== undefined && isWithinSlackLimit(option.value, SLACK_OPTION_VALUE_MAX);
}
function readSlackBlockId(block: SlackBlock): string | undefined {
const value = (block as { block_id?: unknown }).block_id;
return typeof value === "string" ? value : undefined;
}
function readSlackOpenClawBlockIndex(blockId: string, prefix: string): number | undefined {
if (!blockId.startsWith(prefix)) {
return undefined;
}
const value = Number.parseInt(blockId.slice(prefix.length), 10);
return Number.isSafeInteger(value) && value > 0 ? value : undefined;
}
/** Resolve existing OpenClaw Block Kit indexes so appended controls keep stable unique IDs. */
export function resolveSlackInteractiveBlockOffsets(
blocks?: readonly SlackBlock[],
): SlackInteractiveBlockRenderOptions {
let buttonIndexOffset = 0;
let selectIndexOffset = 0;
for (const block of blocks ?? []) {
const blockId = readSlackBlockId(block);
if (!blockId) {
continue;
}
buttonIndexOffset = Math.max(
buttonIndexOffset,
readSlackOpenClawBlockIndex(blockId, "openclaw_reply_buttons_") ?? 0,
);
selectIndexOffset = Math.max(
selectIndexOffset,
readSlackOpenClawBlockIndex(blockId, "openclaw_reply_select_") ?? 0,
);
}
return { buttonIndexOffset, selectIndexOffset };
}
/**
* @deprecated Use buildSlackPresentationBlocks with MessagePresentation.
*/
export function buildSlackInteractiveBlocks(
interactive?: InteractiveReply,
options: SlackInteractiveBlockRenderOptions = {},
): SlackBlock[] {
const initialState = {
blocks: [] as SlackBlock[],
buttonIndex: options.buttonIndexOffset ?? 0,
selectIndex: options.selectIndexOffset ?? 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") {
const elements = block.buttons
.flatMap((button, choiceIndex) => {
const callbackData = resolveSlackControlValue(button);
const value =
callbackData && isWithinSlackLimit(callbackData, SLACK_BUTTON_VALUE_MAX)
? callbackData
: undefined;
const url =
button.url && isWithinSlackLimit(button.url, SLACK_BUTTON_URL_MAX)
? button.url
: undefined;
if (!value && !url) {
return [];
}
const target = url ? { url } : { value };
const style = resolveSlackButtonStyle(button.style);
return [
{
type: "button" as const,
// Slack emits block_actions even for URL buttons; link-only actions must be ignored.
action_id: url
? buildSlackReplyLinkActionId(state.buttonIndex + 1, choiceIndex)
: buildSlackReplyButtonActionId(state.buttonIndex + 1, choiceIndex),
text: {
type: "plain_text" as const,
text: truncateSlackText(button.label, SLACK_ACTION_LABEL_MAX),
emoji: true,
},
...target,
...(style ? { style } : {}),
},
];
})
.slice(0, SLACK_ACTION_BLOCK_ELEMENTS_MAX);
if (elements.length === 0) {
return state;
}
state.blocks.push({
type: "actions",
block_id: `openclaw_reply_buttons_${++state.buttonIndex}`,
elements,
});
return state;
}
const optionsLocal = block.options
.map((option) => ({
label: option.label,
value: resolveSlackControlValue(option),
}))
.filter(isRenderableSlackOption)
.slice(0, SLACK_STATIC_SELECT_OPTIONS_MAX);
if (optionsLocal.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_ACTION_LABEL_MAX,
),
emoji: true,
},
options: optionsLocal.map((option, _choiceIndex) => ({
text: {
type: "plain_text",
text: truncateSlackText(option.label, SLACK_ACTION_LABEL_MAX),
emoji: true,
},
value: option.value,
})),
},
],
});
return state;
}).blocks;
}
/** Render portable presentation blocks as Slack Block Kit blocks. */
export function buildSlackPresentationBlocks(
presentation?: MessagePresentation,
options: SlackInteractiveBlockRenderOptions = {},
): SlackBlock[] {
if (!presentation) {
return [];
}
const blocks: SlackBlock[] = [];
if (presentation.title) {
blocks.push({
type: "header",
text: {
type: "plain_text",
text: truncateSlackText(presentation.title, SLACK_HEADER_TEXT_MAX),
emoji: true,
},
});
}
let buttonIndex = options.buttonIndexOffset ?? 0;
let selectIndex = options.selectIndexOffset ?? 0;
for (const block of presentation.blocks) {
if (block.type === "text" || block.type === "context") {
const text = block.text.trim();
if (!text) {
continue;
}
if (block.type === "context") {
blocks.push({
type: "context",
elements: [{ type: "mrkdwn", text: truncateSlackText(text, SLACK_SECTION_TEXT_MAX) }],
});
} else {
blocks.push({
type: "section",
text: { type: "mrkdwn", text: truncateSlackText(text, SLACK_SECTION_TEXT_MAX) },
});
}
continue;
}
if (block.type === "divider") {
blocks.push({ type: "divider" });
continue;
}
if (block.type === "buttons") {
const rendered = buildSlackPresentationButtonBlock(block, buttonIndex + 1);
if (rendered) {
buttonIndex += 1;
blocks.push(rendered);
}
continue;
}
if (block.type === "select") {
const rendered = buildSlackPresentationSelectBlock(block, selectIndex + 1);
if (rendered) {
selectIndex += 1;
blocks.push(rendered);
}
}
}
return blocks;
}
function buildSlackPresentationButtonBlock(
block: MessagePresentationButtonsBlock,
buttonIndex: number,
): SlackBlock | undefined {
const elements = block.buttons
.flatMap((button, choiceIndex) => {
const target = resolveSlackPresentationButtonTarget(button);
if (!target) {
return [];
}
const style = resolveSlackButtonStyle(button.style);
return [
{
type: "button" as const,
// Slack emits block_actions even for URL buttons; link-only actions must be ignored.
action_id: target.url
? buildSlackReplyLinkActionId(buttonIndex, choiceIndex)
: buildSlackReplyButtonActionId(buttonIndex, choiceIndex),
text: {
type: "plain_text" as const,
text: truncateSlackText(button.label, SLACK_ACTION_LABEL_MAX),
emoji: true,
},
...target,
...(style ? { style } : {}),
},
];
})
.slice(0, SLACK_ACTION_BLOCK_ELEMENTS_MAX);
return elements.length > 0
? {
type: "actions",
block_id: `openclaw_reply_buttons_${buttonIndex}`,
elements,
}
: undefined;
}
function resolveSlackPresentationButtonTarget(
button: MessagePresentationButtonsBlock["buttons"][number],
): { value?: string; url?: string } | undefined {
const callbackData = resolveSlackControlValue(button);
const value =
callbackData && isWithinSlackLimit(callbackData, SLACK_BUTTON_VALUE_MAX)
? callbackData
: undefined;
const rawUrl = button.url ?? button.webApp?.url ?? button.web_app?.url;
const url = rawUrl && isWithinSlackLimit(rawUrl, SLACK_BUTTON_URL_MAX) ? rawUrl : undefined;
return url ? { url } : value ? { value } : undefined;
}
/** True when native Slack rendering preserves every portable control. */
export function canRenderSlackPresentation(presentation: MessagePresentation): boolean {
if (presentation.title && !isWithinSlackLimit(presentation.title.trim(), SLACK_HEADER_TEXT_MAX)) {
return false;
}
return presentation.blocks.every((block) => {
if (block.type === "text" || block.type === "context") {
return isWithinSlackLimit(block.text.trim(), SLACK_SECTION_TEXT_MAX);
}
if (block.type === "buttons") {
return (
block.buttons.length <= SLACK_ACTION_BLOCK_ELEMENTS_MAX &&
block.buttons.every((button) => resolveSlackPresentationButtonTarget(button) !== undefined)
);
}
if (block.type === "select") {
return (
block.options.length <= SLACK_STATIC_SELECT_OPTIONS_MAX &&
block.options.every((option) =>
isRenderableSlackOption({
label: option.label,
value: resolveSlackControlValue(option),
}),
)
);
}
return true;
});
}
function buildSlackPresentationSelectBlock(
block: MessagePresentationSelectBlock,
selectIndex: number,
): SlackBlock | undefined {
const options = block.options
.map((option) => ({
label: option.label,
value: resolveSlackControlValue(option),
}))
.filter(isRenderableSlackOption)
.slice(0, SLACK_STATIC_SELECT_OPTIONS_MAX);
return options.length > 0
? {
type: "actions",
block_id: `openclaw_reply_select_${selectIndex}`,
elements: [
{
type: "static_select",
action_id: buildSlackReplySelectActionId(selectIndex),
placeholder: {
type: "plain_text",
text: truncateSlackText(
normalizeOptionalString(block.placeholder) ?? "Choose an option",
SLACK_ACTION_LABEL_MAX,
),
emoji: true,
},
options: options.map((option) => ({
text: {
type: "plain_text",
text: truncateSlackText(option.label, SLACK_ACTION_LABEL_MAX),
emoji: true,
},
value: option.value,
})),
},
],
}
: undefined;
}

View File

@@ -0,0 +1,55 @@
// Slack helper module supports blocks helpers behavior.
import type { WebClient } from "@slack/web-api";
import { vi } from "vitest";
type SlackEditTestClient = WebClient & {
chat: {
update: ReturnType<typeof vi.fn>;
};
};
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("./accounts.js", async () => {
const actual = await vi.importActual<typeof import("./accounts.js")>("./accounts.js");
return {
...actual,
resolveSlackAccount: () => slackBlockTestState.account,
};
});
export function createSlackEditTestClient(): SlackEditTestClient {
return {
chat: {
update: vi.fn(async () => ({ ok: true })),
},
} as unknown as SlackEditTestClient;
}
export function createSlackSendTestClient(): SlackSendTestClient {
return {
conversations: {
open: vi.fn(async () => ({ channel: { id: "D123" } })),
},
chat: {
postMessage: vi.fn(async () => ({ ts: "171234.567" })),
},
} as unknown as SlackSendTestClient;
}

View File

@@ -0,0 +1,150 @@
// Slack tests cover blocks plugin behavior.
import { describe, expect, it } from "vitest";
import { buildSlackBlocksFallbackText } from "./blocks-fallback.js";
import { parseSlackBlocksInput } from "./blocks-input.js";
import {
encodeSlackModalPrivateMetadata,
parseSlackModalPrivateMetadata,
} from "./modal-metadata.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",
);
});
});
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,
);
}
});
});
describe("parseSlackModalPrivateMetadata", () => {
it("returns empty object for missing or invalid values", () => {
expect(parseSlackModalPrivateMetadata(undefined)).toStrictEqual({});
expect(parseSlackModalPrivateMetadata("")).toStrictEqual({});
expect(parseSlackModalPrivateMetadata("{bad-json")).toStrictEqual({});
});
it("parses known metadata fields", () => {
expect(
parseSlackModalPrivateMetadata(
JSON.stringify({
sessionKey: "agent:main:slack:channel:C1",
channelId: "D123",
channelType: "im",
userId: "U123",
pluginInteractiveData: "dean.contract:confirm",
ignored: "x",
}),
),
).toEqual({
sessionKey: "agent:main:slack:channel:C1",
channelId: "D123",
channelType: "im",
userId: "U123",
pluginInteractiveData: "dean.contract:confirm",
});
});
});
describe("encodeSlackModalPrivateMetadata", () => {
it("encodes only known non-empty fields", () => {
expect(
JSON.parse(
encodeSlackModalPrivateMetadata({
sessionKey: "agent:main:slack:channel:C1",
channelId: "",
channelType: "im",
userId: "U123",
pluginInteractiveData: "dean.contract:confirm",
}),
),
).toEqual({
sessionKey: "agent:main:slack:channel:C1",
channelType: "im",
userId: "U123",
pluginInteractiveData: "dean.contract:confirm",
});
});
it("throws when encoded payload exceeds Slack metadata limit", () => {
expect(() =>
encodeSlackModalPrivateMetadata({
sessionKey: `agent:main:${"x".repeat(4000)}`,
}),
).toThrow(/cannot exceed 3000 chars/i);
});
});

View File

@@ -0,0 +1,138 @@
// Slack tests cover channel actions setup status.contract plugin behavior.
import {
installChannelActionsContractSuite,
installChannelSetupContractSuite,
installChannelStatusContractSuite,
} from "openclaw/plugin-sdk/channel-test-helpers";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect } from "vitest";
import { slackPlugin } from "../api.js";
import { slackSetupPlugin } from "../setup-plugin-api.js";
const slackDefaultActions = [
"send",
"react",
"reactions",
"read",
"edit",
"delete",
"download-file",
"upload-file",
"pin",
"unpin",
"list-pins",
"member-info",
"emoji-list",
] as const;
describe("slack actions contract", () => {
installChannelActionsContractSuite({
plugin: slackPlugin,
unsupportedAction: "poll",
cases: [
{
name: "configured account exposes default Slack actions",
cfg: {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
},
} as OpenClawConfig,
expectedActions: slackDefaultActions,
expectedCapabilities: ["presentation"],
},
{
name: "interactive replies keep the shared presentation capability",
cfg: {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
capabilities: {
interactiveReplies: true,
},
},
},
} as OpenClawConfig,
expectedActions: slackDefaultActions,
expectedCapabilities: ["presentation"],
},
{
name: "missing tokens disables the actions surface",
cfg: {
channels: {
slack: {
enabled: true,
},
},
} as OpenClawConfig,
expectedActions: [],
expectedCapabilities: [],
},
],
});
});
describe("slack setup contract", () => {
installChannelSetupContractSuite({
plugin: slackSetupPlugin,
cases: [
{
name: "default account stores tokens and enables the channel",
cfg: {} as OpenClawConfig,
input: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
expectedAccountId: "default",
assertPatchedConfig: (cfg) => {
expect(cfg.channels?.slack?.enabled).toBe(true);
expect(cfg.channels?.slack?.botToken).toBe("xoxb-test");
expect(cfg.channels?.slack?.appToken).toBe("xapp-test");
},
},
{
name: "non-default env setup is rejected",
cfg: {} as OpenClawConfig,
accountId: "ops",
input: {
useEnv: true,
},
expectedAccountId: "ops",
expectedValidation: "Slack env tokens can only be used for the default account.",
},
],
});
});
describe("slack status contract", () => {
installChannelStatusContractSuite({
plugin: slackPlugin,
cases: [
{
name: "configured account produces a configured status snapshot",
cfg: {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
},
} as OpenClawConfig,
runtime: {
accountId: "default",
connected: true,
running: true,
},
probe: { ok: true },
assertSnapshot: (snapshot) => {
expect(snapshot.accountId).toBe("default");
expect(snapshot.enabled).toBe(true);
expect(snapshot.configured).toBe(true);
},
},
],
});
});

View File

@@ -0,0 +1,73 @@
// Slack plugin module implements channel actions behavior.
import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-contract";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
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>>;
const SLACK_TOOL_DELIVERY_ACTIONS = new Set([
"deleteMessage",
"editMessage",
"pinMessage",
"react",
"sendMessage",
"unpinMessage",
"uploadFile",
]);
const loadSlackActionRuntime = createLazyRuntimeModule(() => import("./action-runtime.runtime.js"));
function resolveSlackActionContext(params: {
toolContext: unknown;
mediaLocalRoots: readonly string[] | undefined;
mediaReadFile: ((filePath: string) => Promise<Buffer>) | undefined;
}): SlackActionContext | undefined {
if (!params.toolContext && !params.mediaLocalRoots && !params.mediaReadFile) {
return undefined;
}
return {
...(params.toolContext as SlackActionContext | undefined),
...(params.mediaLocalRoots ? { mediaLocalRoots: params.mediaLocalRoots } : {}),
...(params.mediaReadFile ? { mediaReadFile: params.mediaReadFile } : {}),
};
}
export function createSlackActions(
providerId: string,
options?: { invoke?: SlackActionInvoke },
): ChannelMessageActionAdapter {
return {
describeMessageTool: describeSlackMessageTool,
extractToolSend: ({ args }) => extractSlackToolSend(args),
isToolDeliveryAction: ({ args }) =>
typeof args.action === "string" && SLACK_TOOL_DELIVERY_ACTIONS.has(args.action),
prepareSendPayload: ({ ctx, payload }) => (ctx.action === "send" ? payload : null),
handleAction: async (ctx) => {
return await handleSlackMessageAction({
providerId,
ctx,
normalizeChannelId: resolveSlackChannelId,
includeReadThreadId: true,
invoke: async (action, cfg, toolContext) => {
const actionContext = resolveSlackActionContext({
toolContext,
mediaLocalRoots: ctx.mediaLocalRoots,
mediaReadFile: ctx.mediaReadFile,
});
return await (options?.invoke
? options.invoke(action, cfg, actionContext)
: (await loadSlackActionRuntime()).handleSlackAction(action, cfg, actionContext));
},
});
},
};
}

View File

@@ -0,0 +1,28 @@
// Slack API module exposes the plugin public contract.
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-contracts";
export { looksLikeSlackTargetId, normalizeSlackMessagingTarget } from "./target-parsing.js";
const SLACK_CHANNEL_META = {
id: "slack",
label: "Slack",
selectionLabel: "Slack",
docsPath: "/channels/slack",
docsLabel: "slack",
blurb: "supports bot + app tokens, channels, threads, and interactive replies.",
systemImage: "number.square",
markdownCapable: true,
} as const;
export function getChatChannelMeta(id: string) {
if (id !== SLACK_CHANNEL_META.id) {
throw new Error(`Unsupported Slack channel meta lookup: ${id}`);
}
return SLACK_CHANNEL_META;
}

View File

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

View File

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

View File

@@ -0,0 +1,202 @@
// Slack tests cover channel type plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
resetSlackChannelTypeCacheForTest,
resolveSlackChannelType,
resolveSlackConversationInfo,
} from "./channel-type.js";
const conversationsInfoMock = vi.fn();
const conversationsOpenMock = vi.fn();
vi.mock("./client.js", () => ({
createSlackWebClient: vi.fn(() => ({
conversations: {
info: conversationsInfoMock,
open: conversationsOpenMock,
},
})),
}));
describe("resolveSlackChannelType", () => {
beforeEach(() => {
conversationsInfoMock.mockReset();
conversationsOpenMock.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();
});
it("returns Slack IM peer user metadata from conversations.open", async () => {
conversationsOpenMock.mockResolvedValueOnce({
channel: {
id: "D0AEWSDHAQH",
is_im: true,
user: "U09G2DJ0275",
},
});
await expect(
resolveSlackConversationInfo({
cfg: {
channels: {
slack: {
botToken: "xoxb-test",
},
},
} as never,
channelId: "D0AEWSDHAQH",
}),
).resolves.toEqual({
type: "dm",
user: "U09G2DJ0275",
});
expect(conversationsOpenMock).toHaveBeenCalledWith({
channel: "D0AEWSDHAQH",
prevent_creation: true,
return_im: true,
});
expect(conversationsInfoMock).not.toHaveBeenCalled();
});
it("keeps D-prefixed channels typed as dm when Slack lookup fails", async () => {
conversationsOpenMock.mockRejectedValueOnce(new Error("missing_scope"));
await expect(
resolveSlackConversationInfo({
cfg: {
channels: {
slack: {
botToken: "xoxb-test",
},
},
} as never,
channelId: "D0AEWSDHAQH",
}),
).resolves.toEqual({
type: "dm",
});
});
it("does not cache incomplete native IM channel lookups", async () => {
conversationsOpenMock
.mockRejectedValueOnce(new Error("temporary_failure"))
.mockResolvedValueOnce({
channel: {
id: "D0AEWSDHAQH",
is_im: true,
user: "U09G2DJ0275",
},
});
const cfg = {
channels: {
slack: {
botToken: "xoxb-test",
},
},
} as never;
await expect(
resolveSlackConversationInfo({
cfg,
channelId: "D0AEWSDHAQH",
}),
).resolves.toEqual({
type: "dm",
});
await expect(
resolveSlackConversationInfo({
cfg,
channelId: "D0AEWSDHAQH",
}),
).resolves.toEqual({
type: "dm",
user: "U09G2DJ0275",
});
expect(conversationsOpenMock).toHaveBeenCalledTimes(2);
});
it("does not let group-channel overrides reclassify native IM channel ids", async () => {
await expect(
resolveSlackConversationInfo({
cfg: {
channels: {
slack: {
dm: {
groupChannels: ["D0AEWSDHAQH"],
},
},
},
} as never,
channelId: "D0AEWSDHAQH",
}),
).resolves.toEqual({
type: "dm",
});
expect(conversationsOpenMock).not.toHaveBeenCalled();
expect(conversationsInfoMock).not.toHaveBeenCalled();
});
it("preserves the channel-type wrapper contract", async () => {
conversationsInfoMock.mockResolvedValueOnce({
channel: {
id: "G123",
is_mpim: true,
},
});
await expect(
resolveSlackChannelType({
cfg: {
channels: {
slack: {
botToken: "xoxb-test",
},
},
} as never,
channelId: "G123",
}),
).resolves.toBe("group");
});
});

View File

@@ -0,0 +1,124 @@
// Slack plugin module implements channel type behavior.
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-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";
export type SlackConversationInfo = {
type: "channel" | "group" | "dm" | "unknown";
user?: string;
};
const SLACK_CONVERSATION_INFO_CACHE = new Map<string, SlackConversationInfo>();
export async function resolveSlackConversationInfo(params: {
cfg: OpenClawConfig;
accountId?: string | null;
channelId: string;
}): Promise<SlackConversationInfo> {
const channelId = params.channelId.trim();
if (!channelId) {
return { type: "unknown" };
}
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
const cacheKey = `${account.accountId}:${channelId}`;
const cached = SLACK_CONVERSATION_INFO_CACHE.get(cacheKey);
if (cached) {
return cached;
}
const isNativeImChannel = /^D/i.test(channelId);
const groupChannels = normalizeAllowListLower(account.dm?.groupChannels);
const channelIdLower = normalizeLowercaseStringOrEmpty(channelId);
if (
!isNativeImChannel &&
(groupChannels.includes(channelIdLower) ||
groupChannels.includes(`slack:${channelIdLower}`) ||
groupChannels.includes(`channel:${channelIdLower}`) ||
groupChannels.includes(`group:${channelIdLower}`) ||
groupChannels.includes(`mpim:${channelIdLower}`))
) {
const result = { type: "group" } as const;
SLACK_CONVERSATION_INFO_CACHE.set(cacheKey, result);
return result;
}
const channelKeys = Object.keys(account.channels ?? {});
if (
!isNativeImChannel &&
channelKeys.some((key) => {
const normalized = normalizeLowercaseStringOrEmpty(key);
return (
normalized === channelIdLower ||
normalized === `channel:${channelIdLower}` ||
normalized.replace(/^#/, "") === channelIdLower
);
})
) {
const result = { type: "channel" } as const;
SLACK_CONVERSATION_INFO_CACHE.set(cacheKey, result);
return result;
}
const token =
normalizeOptionalString(account.botToken) ??
normalizeOptionalString(account.config.userToken) ??
"";
if (!token) {
const result = { type: isNativeImChannel ? "dm" : "unknown" } as const;
if (!isNativeImChannel) {
SLACK_CONVERSATION_INFO_CACHE.set(cacheKey, result);
}
return result;
}
try {
const client = createSlackWebClient(token);
if (isNativeImChannel) {
const opened = await client.conversations.open({
channel: channelId,
prevent_creation: true,
return_im: true,
});
const user =
typeof opened.channel?.user === "string" && opened.channel.user.trim()
? opened.channel.user.trim()
: undefined;
const result: SlackConversationInfo = user ? { type: "dm", user } : { type: "dm" };
if (user) {
SLACK_CONVERSATION_INFO_CACHE.set(cacheKey, result);
}
return result;
}
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";
const result = { type } as const;
SLACK_CONVERSATION_INFO_CACHE.set(cacheKey, result);
return result;
} catch {
const result = { type: isNativeImChannel ? "dm" : "unknown" } as const;
if (!isNativeImChannel) {
SLACK_CONVERSATION_INFO_CACHE.set(cacheKey, result);
}
return result;
}
}
export async function resolveSlackChannelType(params: {
cfg: OpenClawConfig;
accountId?: string | null;
channelId: string;
}): Promise<"channel" | "group" | "dm" | "unknown"> {
return (await resolveSlackConversationInfo(params)).type;
}
export function resetSlackChannelTypeCacheForTest(): void {
SLACK_CONVERSATION_INFO_CACHE.clear();
}
/** @deprecated Use `resetSlackChannelTypeCacheForTest`. */
export { resetSlackChannelTypeCacheForTest as __resetSlackChannelTypeCacheForTest };

View File

@@ -0,0 +1,358 @@
// Regression tests for the lazy-loading boundaries introduced for Slack
// startup-perf work (see PR #69317). Each test asserts both:
// - that the lazy module is reached (call mocks fire), and
// - that the inputs forwarded into the lazy module are correct, and
// - that the lazy module's return value is propagated back through the
// plugin surface unchanged.
//
// Together these guard against:
// - dynamic-import path/specifier drift on cold paths,
// - silent contract drift between the channel and its lazy modules,
// - and accidental loss of the perf intent (re-introducing eager imports
// without updating the seam).
import { beforeEach, describe, expect, it, vi } from "vitest";
import { slackPlugin } from "./channel.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { setSlackRuntime } from "./runtime.js";
// --- Hoisted mocks for lazy seams ------------------------------------------------
const collectAuditFindingsMock = vi.hoisted(() => vi.fn());
const fetchSlackScopesMock = vi.hoisted(() => vi.fn());
const resolveTargetsWithOptionalTokenMock = vi.hoisted(() => vi.fn());
const buildPassiveProbedChannelStatusSummaryMock = vi.hoisted(() => vi.fn());
vi.mock("./security-audit.js", () => ({
collectSlackSecurityAuditFindings: collectAuditFindingsMock,
}));
vi.mock("./scopes.js", () => ({
fetchSlackScopes: fetchSlackScopesMock,
}));
vi.mock("openclaw/plugin-sdk/target-resolver-runtime", async (orig) => {
// Preserve any sibling exports so importers that touch unrelated helpers
// do not break; only override the function the channel actually calls.
const original = (await orig()) as Record<string, unknown>;
return {
...original,
resolveTargetsWithOptionalToken: resolveTargetsWithOptionalTokenMock,
};
});
vi.mock("openclaw/plugin-sdk/extension-shared", async (orig) => {
const original = (await orig()) as Record<string, unknown>;
return {
...original,
buildPassiveProbedChannelStatusSummary: buildPassiveProbedChannelStatusSummaryMock,
};
});
// --- Test setup -----------------------------------------------------------------
beforeEach(() => {
collectAuditFindingsMock.mockReset();
fetchSlackScopesMock.mockReset();
resolveTargetsWithOptionalTokenMock.mockReset();
buildPassiveProbedChannelStatusSummaryMock.mockReset();
setSlackRuntime({ channel: { slack: {} } } as never);
});
function makeMinimalSlackConfig(
opts: { botToken?: string; userToken?: string } = {},
): OpenClawConfig {
const slack: Record<string, unknown> = {};
if (opts.botToken !== undefined) {
slack.botToken = opts.botToken;
}
if (opts.userToken !== undefined) {
slack.userToken = opts.userToken;
}
return { channels: { slack } } as OpenClawConfig;
}
type MockWithCalls = {
mock: { calls: unknown[][] };
};
function mockCallAt(mock: MockWithCalls, index: number): unknown[] {
const call = mock.mock.calls[index];
if (!call) {
throw new Error(`expected mock call ${index}`);
}
return call;
}
function mockRecordArgAt(mock: MockWithCalls, callIndex: number, argIndex: number) {
const value = mockCallAt(mock, callIndex)[argIndex];
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`expected mock call ${callIndex} argument ${argIndex} to be an object`);
}
return value as Record<string, unknown>;
}
// --- Status: buildChannelSummary -------------------------------------------------
describe("slackPlugin.status.buildChannelSummary lazy SDK forwarding", () => {
it("calls the lazy extension-shared SDK helper with the snapshot and token sources, and returns its output unchanged", async () => {
const buildChannelSummary = slackPlugin.status?.buildChannelSummary;
if (!buildChannelSummary) {
throw new Error("slackPlugin.status.buildChannelSummary should be exposed");
}
const sentinelSummary = { sentinel: "passive-summary" };
buildPassiveProbedChannelStatusSummaryMock.mockReturnValue(sentinelSummary);
const snapshot = {
accountId: "default",
configured: true,
enabled: true,
botTokenSource: "config" as const,
appTokenSource: "config" as const,
extra: { custom: 1 },
};
const result = await buildChannelSummary({
account: { accountId: "default" } as never,
snapshot,
cfg: makeMinimalSlackConfig({ botToken: "xoxb-test" }),
runtime: undefined,
} as never);
expect(buildPassiveProbedChannelStatusSummaryMock).toHaveBeenCalledTimes(1);
const [forwardedSnapshot, forwardedExtras] = mockCallAt(
buildPassiveProbedChannelStatusSummaryMock,
0,
);
// Snapshot must be forwarded by reference / structurally intact.
expect(forwardedSnapshot).toBe(snapshot);
// The channel must forward the (possibly fallback'd) token sources.
expect(forwardedExtras).toEqual({ botTokenSource: "config", appTokenSource: "config" });
// The SDK return value must be propagated through unchanged.
expect(result).toBe(sentinelSummary);
});
it("falls back to 'none' for missing token sources before forwarding to the SDK helper", async () => {
const buildChannelSummary = slackPlugin.status?.buildChannelSummary;
if (!buildChannelSummary) {
throw new Error("slackPlugin.status.buildChannelSummary should be exposed");
}
buildPassiveProbedChannelStatusSummaryMock.mockReturnValue({ sentinel: true });
await buildChannelSummary({
account: { accountId: "default" } as never,
snapshot: { accountId: "default", configured: false, enabled: true } as never,
cfg: makeMinimalSlackConfig(),
runtime: undefined,
} as never);
const [, forwardedExtras] = mockCallAt(buildPassiveProbedChannelStatusSummaryMock, 0);
expect(forwardedExtras).toEqual({ botTokenSource: "none", appTokenSource: "none" });
});
});
// --- Status: buildCapabilitiesDiagnostics ---------------------------------------
describe("slackPlugin.status.buildCapabilitiesDiagnostics lazy scopes loader", () => {
it("invokes fetchSlackScopes once when only a bot token is present", async () => {
const buildDiagnostics = slackPlugin.status?.buildCapabilitiesDiagnostics;
if (!buildDiagnostics) {
throw new Error("slackPlugin.status.buildCapabilitiesDiagnostics should be exposed");
}
fetchSlackScopesMock.mockResolvedValue({ ok: true, scopes: ["chat:write"] });
const cfg = makeMinimalSlackConfig({ botToken: "xoxb-bot" });
const account = slackPlugin.config.resolveAccount(cfg, "default");
const result = await buildDiagnostics({ account, timeoutMs: 1234, cfg } as never);
expect(fetchSlackScopesMock).toHaveBeenCalledTimes(1);
expect(fetchSlackScopesMock).toHaveBeenCalledWith("xoxb-bot", 1234);
expect(result?.details).toEqual({ botScopes: { ok: true, scopes: ["chat:write"] } });
expect(result?.lines?.length ?? 0).toBeGreaterThan(0);
});
it("invokes fetchSlackScopes twice (bot and user) when both tokens are present", async () => {
const buildDiagnostics = slackPlugin.status?.buildCapabilitiesDiagnostics;
if (!buildDiagnostics) {
throw new Error("slackPlugin.status.buildCapabilitiesDiagnostics should be exposed");
}
fetchSlackScopesMock
.mockResolvedValueOnce({ ok: true, scopes: ["chat:write"] })
.mockResolvedValueOnce({ ok: true, scopes: ["users:read"] });
const cfg = makeMinimalSlackConfig({ botToken: "xoxb-bot", userToken: "xoxp-user" });
const account = slackPlugin.config.resolveAccount(cfg, "default");
const result = await buildDiagnostics({ account, timeoutMs: 5000, cfg } as never);
expect(fetchSlackScopesMock).toHaveBeenCalledTimes(2);
expect(mockCallAt(fetchSlackScopesMock, 0)).toEqual(["xoxb-bot", 5000]);
expect(mockCallAt(fetchSlackScopesMock, 1)).toEqual(["xoxp-user", 5000]);
expect(result?.details).toEqual({
botScopes: { ok: true, scopes: ["chat:write"] },
userScopes: { ok: true, scopes: ["users:read"] },
});
});
it("does not invoke fetchSlackScopes when no bot token is present and reports a missing-token diagnostic", async () => {
const buildDiagnostics = slackPlugin.status?.buildCapabilitiesDiagnostics;
if (!buildDiagnostics) {
throw new Error("slackPlugin.status.buildCapabilitiesDiagnostics should be exposed");
}
const cfg = makeMinimalSlackConfig();
const account = slackPlugin.config.resolveAccount(cfg, "default");
const result = await buildDiagnostics({ account, timeoutMs: 1000, cfg } as never);
expect(fetchSlackScopesMock).not.toHaveBeenCalled();
expect(result?.details).toEqual({
botScopes: { ok: false, error: "Slack bot token missing." },
});
});
});
// --- Security: collectAuditFindings ---------------------------------------------
describe("slackPlugin.security.collectAuditFindings lazy module forwarding", () => {
it("delegates to the lazy security-audit module with the original params and returns its output", async () => {
const collectAuditFindings = slackPlugin.security?.collectAuditFindings;
if (!collectAuditFindings) {
throw new Error("slackPlugin.security.collectAuditFindings should be exposed");
}
const sentinel = [
{
checkId: "test-check",
severity: "info" as const,
title: "t",
detail: "d",
},
];
collectAuditFindingsMock.mockResolvedValue(sentinel);
const cfg = makeMinimalSlackConfig({ botToken: "xoxb-bot" });
const account = slackPlugin.config.resolveAccount(cfg, "default");
const result = await collectAuditFindings({ cfg, accountId: "default", account } as never);
expect(collectAuditFindingsMock).toHaveBeenCalledTimes(1);
expect(mockCallAt(collectAuditFindingsMock, 0)[0]).toEqual({
cfg,
accountId: "default",
account,
});
expect(result).toBe(sentinel);
});
it("propagates an empty findings array unchanged", async () => {
const collectAuditFindings = slackPlugin.security?.collectAuditFindings;
if (!collectAuditFindings) {
throw new Error("slackPlugin.security.collectAuditFindings should be exposed");
}
collectAuditFindingsMock.mockResolvedValue([]);
const cfg = makeMinimalSlackConfig();
const account = slackPlugin.config.resolveAccount(cfg, "default");
const result = await collectAuditFindings({ cfg, account } as never);
expect(result).toStrictEqual([]);
});
});
// --- Resolver: resolveTargets ---------------------------------------------------
describe("slackPlugin.resolver.resolveTargets lazy SDK forwarding", () => {
it("forwards user inputs and the configured token to the lazy SDK helper and returns its output", async () => {
const resolveTargets = slackPlugin.resolver?.resolveTargets;
if (!resolveTargets) {
throw new Error("slackPlugin.resolver.resolveTargets should be exposed");
}
const sentinelOutput = [{ input: "U123", resolved: true, id: "U123", note: undefined }];
resolveTargetsWithOptionalTokenMock.mockResolvedValue(sentinelOutput);
const cfg = makeMinimalSlackConfig({ botToken: "xoxb-bot" });
const result = await resolveTargets({
cfg,
accountId: "default",
inputs: ["U123"],
kind: "user",
} as never);
expect(resolveTargetsWithOptionalTokenMock).toHaveBeenCalledTimes(1);
const params = mockRecordArgAt(resolveTargetsWithOptionalTokenMock, 0, 0);
expect(params.token).toBe("xoxb-bot");
expect(params.inputs).toEqual(["U123"]);
expect(params.missingTokenNote).toBe("missing Slack token");
const resolveWithToken = params.resolveWithToken;
if (typeof resolveWithToken !== "function") {
throw new Error("expected Slack target resolver callback");
}
const mapResolved = params.mapResolved;
if (typeof mapResolved !== "function") {
throw new Error("expected Slack target mapper callback");
}
expect(
mapResolved({
input: "U123",
resolved: true,
id: "U123",
name: "Ada",
note: "workspace match",
}),
).toEqual({
input: "U123",
resolved: true,
id: "U123",
name: "Ada",
note: "workspace match",
});
expect(result).toBe(sentinelOutput);
});
it("prefers the user token over the bot token when both are configured", async () => {
const resolveTargets = slackPlugin.resolver?.resolveTargets;
if (!resolveTargets) {
throw new Error("slackPlugin.resolver.resolveTargets should be exposed");
}
resolveTargetsWithOptionalTokenMock.mockResolvedValue([]);
await resolveTargets({
cfg: makeMinimalSlackConfig({ botToken: "xoxb-bot", userToken: "xoxp-user" }),
accountId: "default",
inputs: ["U1"],
kind: "user",
} as never);
const params = mockRecordArgAt(resolveTargetsWithOptionalTokenMock, 0, 0);
expect(params.token).toBe("xoxp-user");
});
it("uses the same lazy SDK helper for kind='group'", async () => {
const resolveTargets = slackPlugin.resolver?.resolveTargets;
if (!resolveTargets) {
throw new Error("slackPlugin.resolver.resolveTargets should be exposed");
}
resolveTargetsWithOptionalTokenMock.mockResolvedValue([]);
await resolveTargets({
cfg: makeMinimalSlackConfig({ botToken: "xoxb-bot" }),
accountId: "default",
inputs: ["C1"],
kind: "group",
} as never);
expect(resolveTargetsWithOptionalTokenMock).toHaveBeenCalledTimes(1);
const params = mockRecordArgAt(resolveTargetsWithOptionalTokenMock, 0, 0);
expect(params.token).toBe("xoxb-bot");
expect(params.inputs).toEqual(["C1"]);
});
});
// Setup-wizard proxy delegation is unit-tested directly in
// setup-core.lazy-proxy.test.ts so it can be type-safe against the wider
// ChannelSetupWizard contract returned by createSlackSetupWizardProxy.

View File

@@ -0,0 +1,293 @@
// Slack tests cover channel.message adapter plugin behavior.
import {
verifyChannelMessageAdapterCapabilityProofs,
verifyChannelMessageLiveCapabilityAdapterProofs,
verifyChannelMessageLiveFinalizerProofs,
} from "openclaw/plugin-sdk/channel-outbound";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { slackPlugin } from "./channel.js";
import { SLACK_PRESENTATION_CAPABILITIES } from "./presentation.js";
import type { OpenClawConfig } from "./runtime-api.js";
const cfg = {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
},
} as OpenClawConfig;
type SlackMessageAdapter = NonNullable<typeof slackPlugin.message>;
type SlackMessageSender = NonNullable<SlackMessageAdapter["send"]>;
function requireSlackMessageAdapter(): SlackMessageAdapter {
const adapter = slackPlugin.message;
if (!adapter) {
throw new Error("Expected slack channel message adapter");
}
return adapter;
}
function requireTextSender(adapter: SlackMessageAdapter): NonNullable<SlackMessageSender["text"]> {
const text = adapter.send?.text;
if (!text) {
throw new Error("Expected slack message adapter text sender");
}
return text;
}
function requireMediaSender(
adapter: SlackMessageAdapter,
): NonNullable<SlackMessageSender["media"]> {
const media = adapter.send?.media;
if (!media) {
throw new Error("Expected slack message adapter media sender");
}
return media;
}
function requirePayloadSender(
adapter: SlackMessageAdapter,
): NonNullable<SlackMessageSender["payload"]> {
const payload = adapter.send?.payload;
if (!payload) {
throw new Error("Expected slack message adapter payload sender");
}
return payload;
}
describe("slack channel message adapter", () => {
const sendSlack = vi.fn();
function expectLastSendSlackCall(): [string, string, Record<string, unknown>] {
const call = sendSlack.mock.calls.at(-1) as unknown as
| [string, string, Record<string, unknown>]
| undefined;
if (!call) {
throw new Error("Expected sendSlack to be called");
}
return call;
}
beforeEach(() => {
sendSlack.mockReset();
sendSlack.mockResolvedValue({ messageId: "msg-1", channelId: "C123" });
});
it("backs declared durable-final capabilities with outbound send proofs", async () => {
const adapter = requireSlackMessageAdapter();
const sendText = requireTextSender(adapter);
const sendMedia = requireMediaSender(adapter);
const sendPayload = requirePayloadSender(adapter);
expect(adapter.durableFinal?.reconcileUnknownSendKinds).toEqual({ text: true });
const proveText = async () => {
sendSlack.mockClear();
const onPlatformSendDispatch = vi.fn();
const result = await sendText({
cfg,
to: "C123",
text: "hello",
accountId: "default",
deliveryQueueId: "queue-1",
onPlatformSendDispatch,
deps: { sendSlack },
});
const [to, text, options] = expectLastSendSlackCall();
expect(to).toBe("C123");
expect(text).toBe("hello");
expect(options.accountId).toBe("default");
expect(options.deliveryQueueId).toBe("queue-1");
expect(options.onPlatformSendDispatch).toBe(onPlatformSendDispatch);
expect(result.receipt.platformMessageIds).toEqual(["msg-1"]);
expect(result.receipt.parts[0]?.kind).toBe("text");
};
const proveMedia = async () => {
sendSlack.mockClear();
const result = await sendMedia({
cfg,
to: "C123",
text: "caption",
mediaUrl: "https://example.com/a.png",
mediaLocalRoots: ["/tmp/media"],
accountId: "default",
deliveryQueueId: "queue-1",
deps: { sendSlack },
});
const [to, text, options] = expectLastSendSlackCall();
expect(to).toBe("C123");
expect(text).toBe("caption");
expect(options.accountId).toBe("default");
expect(options.mediaUrl).toBe("https://example.com/a.png");
expect(options.mediaLocalRoots).toEqual(["/tmp/media"]);
expect(options.deliveryQueueId).toBeUndefined();
expect(result.receipt.parts[0]?.kind).toBe("media");
};
const provePayload = async () => {
sendSlack.mockClear();
const result = await sendPayload({
cfg,
to: "C123",
text: "payload",
payload: { text: "payload" },
accountId: "default",
deliveryQueueId: "queue-1",
deps: { sendSlack },
});
const [to, text, options] = expectLastSendSlackCall();
expect(to).toBe("C123");
expect(text).toBe("payload");
expect(options.accountId).toBe("default");
expect(options.deliveryQueueId).toBeUndefined();
expect(result.receipt.platformMessageIds).toEqual(["msg-1"]);
};
const proveReplyThread = async () => {
sendSlack.mockClear();
const result = await sendText({
cfg,
to: "C123",
text: "threaded",
accountId: "default",
replyToId: "1712000000.000001",
threadId: "1712345678.123456",
deps: { sendSlack },
});
const [to, text, options] = expectLastSendSlackCall();
expect(to).toBe("C123");
expect(text).toBe("threaded");
expect(options.accountId).toBe("default");
expect(options.threadTs).toBe("1712000000.000001");
expect(result.receipt.replyToId).toBe("1712000000.000001");
};
const proveThreadFallback = async () => {
sendSlack.mockClear();
const result = await sendText({
cfg,
to: "C123",
text: "threaded",
accountId: "default",
threadId: "1712345678.123456",
deps: { sendSlack },
});
const [to, text, options] = expectLastSendSlackCall();
expect(to).toBe("C123");
expect(text).toBe("threaded");
expect(options.accountId).toBe("default");
expect(options.threadTs).toBe("1712345678.123456");
expect(result.receipt.threadId).toBe("1712345678.123456");
};
await verifyChannelMessageAdapterCapabilityProofs({
adapterName: "slackMessageAdapter",
adapter,
proofs: {
text: proveText,
media: proveMedia,
payload: provePayload,
replyTo: proveReplyThread,
thread: proveThreadFallback,
messageSendingHooks: () => {
expect(sendText).toBeTypeOf("function");
},
reconcileUnknownSend: () => {
expect(adapter.durableFinal?.reconcileUnknownSend).toBeTypeOf("function");
},
},
});
});
it("renders portable presentations through the facade as card receipts (#95440)", async () => {
const outbound = slackPlugin.outbound;
const renderPresentation = outbound?.renderPresentation;
if (!renderPresentation) {
throw new Error("Expected Slack presentation renderer");
}
expect(outbound.presentationCapabilities).toBe(SLACK_PRESENTATION_CAPABILITIES);
const presentation = {
title: "Status",
blocks: [{ type: "divider" as const }],
};
const payload = { text: "Fallback", presentation };
const rendered = await renderPresentation({
payload,
presentation,
ctx: { cfg, to: "C123", text: payload.text, payload },
});
if (!rendered) {
throw new Error("Expected rendered Slack presentation payload");
}
// Core consumes the portable presentation before handing the native payload to the adapter.
const { presentation: _presentation, ...deliveryPayload } = rendered;
const result = await requirePayloadSender(requireSlackMessageAdapter())({
cfg,
to: "C123",
text: deliveryPayload.text ?? "",
payload: deliveryPayload,
accountId: "default",
deps: { sendSlack },
});
const [to, text, options] = expectLastSendSlackCall();
expect(to).toBe("C123");
expect(text).toBe("Fallback");
expect(options.blocks).toEqual([
{
type: "section",
text: { type: "mrkdwn", text: "Fallback" },
},
{
type: "header",
text: { type: "plain_text", text: "Status", emoji: true },
},
{ type: "divider" },
]);
expect(result.receipt.parts[0]?.kind).toBe("card");
});
it("backs declared live preview finalizer capabilities with adapter proofs", async () => {
const adapter = requireSlackMessageAdapter();
const sendText = requireTextSender(adapter);
await verifyChannelMessageLiveCapabilityAdapterProofs({
adapterName: "slackMessageAdapter",
adapter,
proofs: {
draftPreview: () => {
expect(adapter.live?.finalizer?.capabilities?.discardPending).toBe(true);
},
previewFinalization: () => {
expect(adapter.live?.finalizer?.capabilities?.finalEdit).toBe(true);
},
progressUpdates: () => {
expect(adapter.live?.capabilities?.draftPreview).toBe(true);
},
nativeStreaming: () => {
expect(adapter.live?.capabilities?.previewFinalization).toBe(true);
},
},
});
await verifyChannelMessageLiveFinalizerProofs({
adapterName: "slackMessageAdapter",
adapter,
proofs: {
finalEdit: () => {
expect(adapter.live?.capabilities?.previewFinalization).toBe(true);
},
normalFallback: () => {
expect(sendText).toBeTypeOf("function");
},
discardPending: () => {
expect(adapter.live?.capabilities?.draftPreview).toBe(true);
},
},
});
});
});

View File

@@ -0,0 +1,86 @@
// Slack plugin module implements channel.setup behavior.
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
import {
adaptScopedAccountAccessor,
createScopedChannelConfigAdapter,
} from "openclaw/plugin-sdk/channel-config-helpers";
import type { ResolvedSlackAccount } from "./accounts.js";
import {
listSlackAccountIds,
resolveSlackConfigAccessorAccount,
resolveDefaultSlackAccountId,
resolveSlackAccount,
type SlackConfigAccessorAccount,
} from "./accounts.js";
import type { ChannelPlugin } from "./channel-api.js";
import { SlackChannelConfigSchema } from "./config-schema.js";
import { slackSetupAdapter, createSlackSetupWizardProxy } from "./setup-core.js";
import {
describeSlackSetupAccount,
isSlackSetupAccountConfigured,
SLACK_CHANNEL,
} from "./setup-shared.js";
const slackSetupWizard = createSlackSetupWizardProxy(async () => ({
slackSetupWizard: (await import("./setup-surface.js")).slackSetupWizard,
}));
const slackSetupConfigAdapter = createScopedChannelConfigAdapter<
ResolvedSlackAccount,
SlackConfigAccessorAccount
>({
sectionKey: SLACK_CHANNEL,
listAccountIds: listSlackAccountIds,
resolveAccount: adaptScopedAccountAccessor(resolveSlackAccount),
resolveAccessorAccount: resolveSlackConfigAccessorAccount,
defaultAccountId: resolveDefaultSlackAccountId,
clearBaseFields: ["botToken", "appToken", "name"],
resolveAllowFrom: (account) => account.allowFrom,
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
resolveDefaultTo: (account) => account.defaultTo,
});
export const slackSetupPlugin: ChannelPlugin<ResolvedSlackAccount> = {
id: SLACK_CHANNEL,
meta: {
id: SLACK_CHANNEL,
label: "Slack",
selectionLabel: "Slack (Socket Mode)",
detailLabel: "Slack Bot",
docsPath: "/channels/slack",
docsLabel: "slack",
blurb: "supported (Socket Mode).",
systemImage: "number",
markdownCapable: true,
preferSessionLookupForAnnounceTarget: true,
},
setupWizard: slackSetupWizard,
capabilities: {
chatTypes: ["direct", "channel", "thread"],
reactions: true,
threads: true,
media: true,
nativeCommands: true,
},
commands: {
nativeCommandsAutoEnabled: false,
nativeSkillsAutoEnabled: false,
resolveNativeCommandName: ({ commandKey, defaultName }) =>
commandKey === "status" ? "agentstatus" : defaultName,
},
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.slack"] },
configSchema: SlackChannelConfigSchema,
config: {
...slackSetupConfigAdapter,
hasConfiguredState: ({ env }) =>
["SLACK_APP_TOKEN", "SLACK_BOT_TOKEN", "SLACK_USER_TOKEN"].some(
(key) => typeof env?.[key] === "string" && env[key]?.trim().length > 0,
),
isConfigured: (account) => isSlackSetupAccountConfigured(account),
describeAccount: (account) => describeSlackSetupAccount(account),
},
setup: slackSetupAdapter,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,915 @@
// Slack plugin module implements channel behavior.
import {
buildLegacyDmAccountAllowlistAdapter,
createAccountScopedAllowlistNameResolver,
createFlatAllowlistOverrideResolver,
} from "openclaw/plugin-sdk/allowlist-config-edit";
import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers";
import {
buildThreadAwareOutboundSessionRoute,
createChatChannelPlugin,
} from "openclaw/plugin-sdk/channel-core";
import {
createChannelMessageAdapterFromOutbound,
createRuntimeOutboundDelegates,
resolveOutboundSendDep,
} from "openclaw/plugin-sdk/channel-outbound";
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-result";
import {
createChannelDirectoryAdapter,
createRuntimeDirectoryLiveAdapter,
} from "openclaw/plugin-sdk/directory-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { buildOutboundBaseSessionKey, type RoutePeer } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
import {
resolveDefaultSlackAccountId,
resolveSlackAccount,
resolveSlackAccountAllowFrom,
resolveSlackOperationToken,
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, resolveSlackConversationInfo } from "./channel-type.js";
import { createSlackWebClient } from "./client.js";
import { formatSlackError } from "./errors.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 { SLACK_PRESENTATION_CAPABILITIES } from "./presentation.js";
import type { SlackProbe } from "./probe.js";
import { resolveSlackReplyBlocks } from "./reply-blocks.js";
import { getOptionalSlackRuntime } from "./runtime.js";
import { slackSecurityAdapter } from "./security.js";
import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js";
import {
createSlackPluginBase,
isSlackPluginAccountConfigured,
SLACK_CHANNEL,
slackConfigAdapter,
} from "./shared.js";
import { parseSlackTarget } from "./target-parsing.js";
import { slackContextTargetsMatch } from "./targets.js";
import { normalizeSlackThreadTsCandidate, resolveSlackThreadTsValue } from "./thread-ts.js";
import { buildSlackThreadingToolContext } from "./threading-tool-context.js";
// Lazy SDK loaders. The dynamic import is hidden behind a string-literal
// module id and typed by a hand-written structural alias so TypeScript does
// not have to crawl the SDK module's type graph just to type the loader.
//
// `openclaw/plugin-sdk/channel-policy` is intentionally NOT lazy here —
// `./group-policy.js` already imports it eagerly, so deferring it from
// `channel.ts` would not change the load graph.
type ExtensionSharedSurface = {
buildPassiveProbedChannelStatusSummary: <TExtra extends object>(
snapshot: {
configured?: boolean;
running?: boolean;
lastStartAt?: number | null;
lastStopAt?: number | null;
lastError?: string | null;
probe?: unknown;
lastProbeAt?: number | null;
},
extra?: TExtra,
) => {
configured: boolean;
running: boolean;
lastStartAt: number | null;
lastStopAt: number | null;
lastError: string | null;
probe: unknown;
lastProbeAt: number | null;
} & TExtra;
};
type TargetResolverRuntimeSurface = {
resolveTargetsWithOptionalToken: <TResult>(params: {
token?: string | null;
inputs: string[];
missingTokenNote: string;
resolveWithToken: (params: { token: string; inputs: string[] }) => Promise<TResult[]>;
mapResolved: (entry: TResult) => {
input: string;
resolved: boolean;
id?: string;
name?: string;
note?: string;
};
}) => Promise<
Array<{ input: string; resolved: boolean; id?: string; name?: string; note?: string }>
>;
};
const EXTENSION_SHARED_MODULE_ID = "openclaw/plugin-sdk/extension-shared";
const TARGET_RESOLVER_RUNTIME_MODULE_ID = "openclaw/plugin-sdk/target-resolver-runtime";
const loadExtensionSharedSdk = createLazyRuntimeModule(
() => import(EXTENSION_SHARED_MODULE_ID) as Promise<ExtensionSharedSurface>,
);
const loadTargetResolverRuntimeSdk = createLazyRuntimeModule(
() => import(TARGET_RESOLVER_RUNTIME_MODULE_ID) as Promise<TargetResolverRuntimeSurface>,
);
const loadSlackSetupSurfaceModule = createLazyRuntimeModule(() => import("./setup-surface.js"));
const loadSlackScopesModule = createLazyRuntimeModule(() => import("./scopes.js"));
const loadSlackOutboundAdapterModule = createLazyRuntimeModule(
() => import("./outbound-adapter.js"),
);
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
);
}
type SlackSendFn = typeof import("./send.runtime.js").sendMessageSlack;
const loadSlackDirectoryConfigModule = createLazyRuntimeModule(
() => import("./directory-config.js"),
);
const loadSlackResolveChannelsModule = createLazyRuntimeModule(
() => import("./resolve-channels.js"),
);
const loadSlackResolveUsersModule = createLazyRuntimeModule(() => import("./resolve-users.js"));
const loadSlackActionRuntime = createLazyRuntimeModule(() => import("./action-runtime.runtime.js"));
const loadSlackSendRuntime = createLazyRuntimeModule(() => import("./send.runtime.js"));
const loadSlackProbeModule = createLazyRuntimeModule(() => import("./probe.js"));
const loadSlackMonitorModule = createLazyRuntimeModule(() => import("./monitor.js"));
const loadSlackDirectoryLiveModule = createLazyRuntimeModule(() => import("./directory-live.js"));
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 from this snapshot. Strict mode
// is intentional so boot-time misconfigurations surface loudly. See #68237.
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
const token = resolveSlackOperationToken(account, "write");
const botToken = account.botToken?.trim();
const tokenOverride = token && token !== botToken ? token : undefined;
const threadTsValue = resolveSlackThreadTsValue(params);
return { send, threadTsValue, tokenOverride };
}
async function setSlackHeartbeatThreadStatus(params: {
cfg: OpenClawConfig;
to: string;
accountId?: string | null;
threadId?: string | number | null;
status: string;
}) {
const threadTs = resolveSlackThreadTsValue({ threadId: params.threadId });
const target = parseSlackTarget(params.to, { defaultKind: "channel" });
if (!threadTs || !target) {
return;
}
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
const botToken = normalizeOptionalString(account.botToken);
if (!botToken) {
return;
}
try {
const client = createSlackWebClient(botToken);
const channelId =
target.kind === "channel"
? target.id
: await (
await loadSlackSendRuntime()
).resolveSlackDmChannelId({
client,
userId: target.id,
accountId: account.accountId,
token: botToken,
});
await client.assistant.threads.setStatus({
token: botToken,
channel_id: channelId,
thread_ts: threadTs,
status: params.status,
});
} catch (error) {
logVerbose(`slack heartbeat status update failed: ${formatSlackError(error)}`);
}
}
function withSlackSendOverride(params: {
deps?: { [channelId: string]: unknown } | null;
send: SlackSendFn;
tokenOverride?: string;
deliveryQueueId?: string;
onPlatformSendDispatch?: () => Promise<void>;
}) {
return {
...params.deps,
slack: async (
to: Parameters<SlackSendFn>[0],
text: Parameters<SlackSendFn>[1],
opts: Parameters<SlackSendFn>[2],
) =>
await params.send(to, text, {
...opts,
...(params.tokenOverride ? { token: params.tokenOverride } : {}),
...(params.deliveryQueueId ? { deliveryQueueId: params.deliveryQueueId } : {}),
...(params.onPlatformSendDispatch
? { onPlatformSendDispatch: params.onPlatformSendDispatch }
: {}),
}),
};
}
function resolveSlackRouteTarget(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 normalizeSlackAcpConversationId(raw: string | undefined | null) {
const trimmed = normalizeOptionalString(raw);
if (!trimmed) {
return null;
}
const parsed = parseSlackTarget(trimmed, { defaultKind: "channel" });
const conversationId = normalizeLowercaseStringOrEmpty(
parsed?.id ?? trimmed.replace(/^slack:/i, "").replace(/^(?:channel|group|direct|user):/i, ""),
);
return conversationId ? { conversationId } : null;
}
function matchSlackAcpConversation(params: {
bindingConversationId: string;
conversationId: string;
parentConversationId?: string;
}) {
const bindingConversationId = normalizeSlackAcpConversationId(
params.bindingConversationId,
)?.conversationId;
const conversationId = normalizeSlackAcpConversationId(params.conversationId)?.conversationId;
const parentConversationId = normalizeSlackAcpConversationId(
params.parentConversationId,
)?.conversationId;
if (!bindingConversationId || !conversationId) {
return null;
}
if (bindingConversationId === conversationId) {
return { conversationId, matchPriority: 2 };
}
if (
parentConversationId &&
parentConversationId !== conversationId &&
bindingConversationId === parentConversationId
) {
return { conversationId: parentConversationId, matchPriority: 1 };
}
return null;
}
function buildSlackBaseSessionKey(params: {
cfg: OpenClawConfig;
agentId: string;
accountId?: string | null;
peer: RoutePeer;
}) {
return buildOutboundBaseSessionKey({ ...params, channel: "slack" });
}
function shouldRecoverSlackThreadFromCurrentSession(params: {
cfg: OpenClawConfig;
peerKind: RoutePeer["kind"];
}): boolean {
// Shared DM sessions (dmScope="main") do not encode the DM peer in the base key,
// so inheriting a prior thread can bleed across unrelated direct-message targets.
if (params.peerKind === "direct" && (params.cfg.session?.dmScope ?? "main") === "main") {
return false;
}
return true;
}
async function resolveSlackOutboundSessionRoute(params: {
cfg: OpenClawConfig;
agentId: string;
accountId?: string | null;
target: string;
replyToId?: string | null;
threadId?: string | number | null;
currentSessionKey?: string | 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";
let peerId = parsed.id;
if (!isDm && /^D/i.test(parsed.id)) {
const conversation = await resolveSlackConversationInfo({
cfg: params.cfg,
accountId: params.accountId,
channelId: parsed.id,
});
if (conversation.type !== "dm" || !conversation.user) {
return null;
}
peerKind = "direct";
peerId = conversation.user;
} else 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: peerId,
};
const baseSessionKey = buildSlackBaseSessionKey({
cfg: params.cfg,
agentId: params.agentId,
accountId: params.accountId,
peer,
});
return buildThreadAwareOutboundSessionRoute({
route: {
sessionKey: baseSessionKey,
baseSessionKey,
peer,
chatType: peerKind === "direct" ? ("direct" as const) : ("channel" as const),
from:
peerKind === "direct"
? `slack:${peerId}`
: peerKind === "group"
? `slack:group:${peerId}`
: `slack:channel:${peerId}`,
to: peerKind === "direct" ? `user:${peerId}` : `channel:${peerId}`,
},
replyToId: params.replyToId,
threadId: params.threadId,
currentSessionKey: params.currentSessionKey,
canRecoverCurrentThread: () =>
shouldRecoverSlackThreadFromCurrentSession({
cfg: params.cfg,
peerKind,
}),
});
}
// Mirrors `SlackScopesResult` in ./scopes.ts so the type does not pull the
// scopes module back in at module-load time. Keep the two in sync.
type SlackScopesResultShape = {
ok: boolean;
scopes?: string[];
source?: string;
error?: string;
};
function formatSlackScopeDiagnostic(params: {
tokenType: "bot" | "user";
result: SlackScopesResultShape;
}) {
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.userToken) ?? normalizeOptionalString(account.botToken),
resolveNames: async ({ token, entries }) =>
(await loadSlackResolveUsersModule()).resolveSlackUserAllowlist({ token, entries }),
});
const slackChannelOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: null,
textChunkLimit: SLACK_TEXT_LIMIT,
sanitizeText: ({ text }) => sanitizeAssistantVisibleText(text),
normalizePayload: ({ payload, cfg, accountId }) =>
isSlackInteractiveRepliesEnabled({ cfg, accountId })
? compileSlackInteractiveReplies(payload)
: payload,
deliveryCapabilities: {
durableFinal: {
text: true,
media: true,
payload: true,
replyTo: true,
thread: true,
messageSendingHooks: true,
},
},
shouldTreatDeliveredTextAsVisible: shouldTreatSlackDeliveredTextAsVisible,
shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload }) =>
shouldSuppressLocalSlackExecApprovalPrompt({
cfg,
accountId,
payload,
}),
presentationCapabilities: SLACK_PRESENTATION_CAPABILITIES,
...createRuntimeOutboundDelegates({
getRuntime: loadSlackOutboundAdapterModule,
renderPresentation: {
resolve: ({ slackOutbound }) => slackOutbound.renderPresentation,
unavailableMessage: "Slack outbound presentation rendering is unavailable",
},
}),
sendPayload: async (ctx) => {
const { send, threadTsValue, tokenOverride } = await resolveSlackSendContext({
cfg: ctx.cfg,
accountId: ctx.accountId ?? undefined,
deps: ctx.deps,
replyToId: ctx.replyToId,
threadId: ctx.threadId,
});
const { slackOutbound } = await loadSlackOutboundAdapterModule();
return await slackOutbound.sendPayload!({
...ctx,
replyToId: threadTsValue,
threadId: null,
deliveryQueueId: undefined,
onPlatformSendDispatch: undefined,
deps: withSlackSendOverride({
deps: ctx.deps,
send,
tokenOverride,
}),
});
},
sendText: async (ctx) => {
const { send, threadTsValue, tokenOverride } = await resolveSlackSendContext({
cfg: ctx.cfg,
accountId: ctx.accountId ?? undefined,
deps: ctx.deps,
replyToId: ctx.replyToId,
threadId: ctx.threadId,
});
const { slackOutbound } = await loadSlackOutboundAdapterModule();
return await slackOutbound.sendText!({
...ctx,
replyToId: threadTsValue,
threadId: null,
deliveryQueueId: undefined,
onPlatformSendDispatch: undefined,
deps: withSlackSendOverride({
deps: ctx.deps,
send,
tokenOverride,
deliveryQueueId: ctx.deliveryQueueId,
onPlatformSendDispatch: ctx.onPlatformSendDispatch,
}),
});
},
sendMedia: async (ctx) => {
const { send, threadTsValue, tokenOverride } = await resolveSlackSendContext({
cfg: ctx.cfg,
accountId: ctx.accountId ?? undefined,
deps: ctx.deps,
replyToId: ctx.replyToId,
threadId: ctx.threadId,
});
const { slackOutbound } = await loadSlackOutboundAdapterModule();
return await slackOutbound.sendMedia!({
...ctx,
replyToId: threadTsValue,
threadId: null,
deliveryQueueId: undefined,
onPlatformSendDispatch: undefined,
deps: withSlackSendOverride({
deps: ctx.deps,
send,
tokenOverride,
}),
});
},
};
const slackMessageAdapterBase = createChannelMessageAdapterFromOutbound({
id: "slack",
outbound: slackChannelOutbound,
live: {
capabilities: {
draftPreview: true,
previewFinalization: true,
progressUpdates: true,
nativeStreaming: true,
},
finalizer: {
capabilities: {
finalEdit: true,
normalFallback: true,
discardPending: true,
},
},
},
});
const slackMessageAdapter = {
...slackMessageAdapterBase,
durableFinal: {
capabilities: {
...slackMessageAdapterBase.durableFinal?.capabilities,
reconcileUnknownSend: true,
},
reconcileUnknownSendKinds: { text: true },
reconcileUnknownSend: async (ctx) =>
await (await loadSlackSendRuntime()).reconcileSlackUnknownSend(ctx),
},
} satisfies typeof slackMessageAdapterBase;
export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = createChatChannelPlugin<
ResolvedSlackAccount,
SlackProbe
>({
base: {
...createSlackPluginBase({
setupWizard: createSlackSetupWizardProxy(loadSlackSetupSurfaceModule),
setup: slackSetupAdapter,
}),
allowlist: {
...buildLegacyDmAccountAllowlistAdapter({
channelId: "slack",
resolveAccount: resolveSlackAccount,
normalize: ({ cfg, accountId, values }) =>
slackConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
resolveDmAllowFrom: (account, { cfg }) =>
resolveSlackAccountAllowFrom({ cfg, accountId: account.accountId }),
resolveGroupPolicy: (account) => account.groupPolicy,
resolveGroupOverrides: resolveSlackAllowlistGroupOverrides,
}),
resolveNames: resolveSlackAllowlistNames,
},
approvalCapability: slackApprovalCapability,
groups: {
resolveRequireMention: resolveSlackGroupRequireMention,
resolveToolPolicy: resolveSlackGroupToolPolicy,
},
bindings: {
compileConfiguredBinding: ({ conversationId }) =>
normalizeSlackAcpConversationId(conversationId),
matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) =>
matchSlackAcpConversation({
bindingConversationId: compiledBinding.conversationId,
conversationId,
parentConversationId,
}),
},
messaging: {
targetPrefixes: ["slack"],
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}`),
inferTargetChatType: ({ to }) => resolveSlackRouteTarget(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 = resolveSlackRouteTarget(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,
self: (runtime) => runtime.getSlackDirectorySelfLive,
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 });
const { resolveTargetsWithOptionalToken } = await loadTargetResolverRuntimeSdk();
if (kind === "group") {
return resolveTargetsWithOptionalToken({
token:
normalizeOptionalString(account.userToken) ??
normalizeOptionalString(account.botToken),
inputs,
missingTokenNote: "missing Slack token",
resolveWithToken: async ({ token, inputs: inputsValue }) =>
(await loadSlackResolveChannelsModule()).resolveSlackChannelAllowlist({
token,
entries: inputsValue,
}),
mapResolved: (entry) =>
toResolvedTarget(entry, entry.archived ? "archived" : undefined),
});
}
return resolveTargetsWithOptionalToken({
token:
normalizeOptionalString(account.userToken) ?? normalizeOptionalString(account.botToken),
inputs,
missingTokenNote: "missing Slack token",
resolveWithToken: async ({ token, inputs: inputsLocal }) =>
(await loadSlackResolveUsersModule()).resolveSlackUserAllowlist({
token,
entries: inputsLocal,
}),
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),
}),
message: slackMessageAdapter,
heartbeat: {
sendTyping: async ({ cfg, to, accountId, threadId }) => {
await setSlackHeartbeatThreadStatus({
cfg,
to,
accountId,
threadId,
status: "is typing...",
});
},
clearTyping: async ({ cfg, to, accountId, threadId }) => {
await setSlackHeartbeatThreadStatus({
cfg,
to,
accountId,
threadId,
status: "",
});
},
},
status: createComputedAccountStatusAdapter<ResolvedSlackAccount, SlackProbe>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
buildChannelSummary: async ({ snapshot }) => {
const { buildPassiveProbedChannelStatusSummary } = await loadExtensionSharedSdk();
return 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, {
accountId: account.accountId,
});
},
formatCapabilitiesProbe: ({ probe }) => {
const slackProbe = probe as SlackProbe | undefined;
const lines = [];
if (slackProbe?.warning) {
lines.push({ text: `Warning: ${slackProbe.warning}`, tone: "warn" } as const);
}
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.userToken?.trim();
const { fetchSlackScopes } = await loadSlackScopesModule();
const botScopes: SlackScopesResultShape = 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 credentialConfigured =
mode === "http"
? resolveConfiguredFromRequiredCredentialStatuses(account, [
"botTokenStatus",
"signingSecretStatus",
])
: mode === "socket"
? resolveConfiguredFromRequiredCredentialStatuses(account, [
"botTokenStatus",
"appTokenStatus",
])
: undefined;
const configured = credentialConfigured ?? 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 ({ cfg, id, message }) => {
const account = resolveSlackAccount({
cfg,
accountId: resolveDefaultSlackAccountId(cfg),
});
const { sendMessageSlack } = await loadSlackSendRuntime();
const token = resolveSlackOperationToken(account, "write");
await sendMessageSlack(`user:${id}`, message, {
cfg,
accountId: account.accountId,
...(token ? { token } : {}),
});
},
},
},
security: slackSecurityAdapter,
threading: {
matchesToolContextTarget: ({ target, toolContext }) =>
slackContextTargetsMatch(target, toolContext),
scopedAccountReplyToMode: {
resolveAccount: adaptScopedAccountAccessor(resolveSlackAccount),
resolveReplyToMode: (account, chatType) => resolveSlackReplyToMode(account, chatType),
},
allowExplicitReplyTagsWhenOff: false,
buildToolContext: (params) => buildSlackThreadingToolContext(params),
resolveAutoThreadId: ({ to, toolContext, replyToId }) =>
normalizeSlackThreadTsCandidate(replyToId)
? undefined
: normalizeSlackThreadTsCandidate(
resolveSlackAutoThreadId({
to,
toolContext,
}),
),
resolveReplyTransport: ({ threadId, replyToId, replyToIsExplicit, replyDelivery }) => {
const allowedReplyToId = replyDelivery?.replyToMode === "off" ? undefined : replyToId;
// Slack's thread_ts identifies the root. Only known inherited replies may let
// that root replace a child timestamp; explicit and unknown callers stay reply-first.
const preferThreadId = replyToIsExplicit === false;
const resolvedReplyToId = resolveSlackThreadTsValue({
replyToId: preferThreadId ? threadId : allowedReplyToId,
threadId: preferThreadId ? allowedReplyToId : threadId,
});
return {
replyToId:
replyDelivery?.replyToMode === "off" && !resolvedReplyToId ? null : resolvedReplyToId,
threadId: null,
};
},
},
outbound: slackChannelOutbound,
});

View File

@@ -0,0 +1,73 @@
// Slack plugin module implements client options behavior.
import type { Agent } from "node:http";
import type { RetryOptions, WebClientOptions } from "@slack/web-api";
import { createNodeProxyAgent } from "openclaw/plugin-sdk/fetch-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,
};
/**
* 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(targetUrl: string): Agent | undefined {
try {
return createNodeProxyAgent({
mode: "env",
targetUrl,
});
} catch {
// Malformed proxy URL; degrade gracefully to direct connection.
return undefined;
}
}
function resolveSlackApiUrlFromEnv(): string | undefined {
return process.env.SLACK_API_URL?.trim() || undefined;
}
function applySlackApiUrlAndProxyOptions(options: WebClientOptions): void {
const slackApiUrl = options.slackApiUrl ?? resolveSlackApiUrlFromEnv();
const proxyTargetUrl = slackApiUrl ?? "https://slack.com/";
options.agent ??= resolveSlackProxyAgent(proxyTargetUrl);
if (slackApiUrl !== undefined) {
options.slackApiUrl = slackApiUrl;
} else {
delete options.slackApiUrl;
}
}
export function resolveSlackWebClientOptions(options: WebClientOptions = {}): WebClientOptions {
const resolved: WebClientOptions = Object.assign({}, options);
applySlackApiUrlAndProxyOptions(resolved);
resolved.retryConfig ??= SLACK_DEFAULT_RETRY_OPTIONS;
return resolved;
}
export function resolveSlackWriteClientOptions(options: WebClientOptions = {}): WebClientOptions {
const resolved: WebClientOptions = Object.assign({}, options);
applySlackApiUrlAndProxyOptions(resolved);
resolved.retryConfig ??= SLACK_WRITE_RETRY_OPTIONS;
resolved.maxRequestConcurrency ??= 1;
return resolved;
}

View File

@@ -0,0 +1,441 @@
// Slack tests cover client plugin behavior.
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import type { WebClientOptions } from "@slack/web-api";
import { afterEach, beforeAll, beforeEach, describe, expect, expectTypeOf, 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 createSlackTokenCacheKey: typeof import("./client.js").createSlackTokenCacheKey;
let getSlackWriteClient: typeof import("./client.js").getSlackWriteClient;
let clearSlackWriteClientCacheForTest: typeof import("./client.js").clearSlackWriteClientCacheForTest;
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>;
const SLACK_API_URL_KEYS = ["SLACK_API_URL", "OPENCLAW_SLACK_API_URL"] as const;
const PROXY_KEYS = [
"HTTPS_PROXY",
"HTTP_PROXY",
"https_proxy",
"http_proxy",
"NO_PROXY",
"no_proxy",
"OPENCLAW_PROXY_ACTIVE",
"OPENCLAW_PROXY_CA_FILE",
] as const;
const originalEnv = { ...process.env };
const tempDirs: string[] = [];
function clearProxyEnvForTest() {
for (const key of PROXY_KEYS) {
delete process.env[key];
}
}
function restoreProxyEnvForTest() {
for (const key of PROXY_KEYS) {
if (originalEnv[key] !== undefined) {
process.env[key] = originalEnv[key];
} else {
delete process.env[key];
}
}
}
function clearSlackApiUrlEnvForTest() {
for (const key of SLACK_API_URL_KEYS) {
delete process.env[key];
}
}
function restoreSlackApiUrlEnvForTest() {
for (const key of SLACK_API_URL_KEYS) {
if (originalEnv[key] !== undefined) {
process.env[key] = originalEnv[key];
} else {
delete process.env[key];
}
}
}
function requireAgent<T extends { agent?: unknown }>(options: T): NonNullable<T["agent"]> {
if (!options.agent) {
throw new Error("expected proxy agent");
}
return options.agent as NonNullable<T["agent"]>;
}
function writeTempCa(contents: string): string {
const dir = mkdtempSync(path.join(os.tmpdir(), "openclaw-slack-proxy-ca-"));
tempDirs.push(dir);
const caFile = path.join(dir, "proxy-ca.pem");
writeFileSync(caFile, contents, "utf8");
return caFile;
}
beforeAll(async () => {
const slackWebApi = await import("@slack/web-api");
({
createSlackWebClient,
createSlackWriteClient,
createSlackTokenCacheKey,
getSlackWriteClient,
clearSlackWriteClientCacheForTest,
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();
clearSlackWriteClientCacheForTest();
clearSlackApiUrlEnvForTest();
});
afterEach(() => {
restoreSlackApiUrlEnvForTest();
});
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("uses SLACK_API_URL as the default Slack Web API root", () => {
process.env.SLACK_API_URL = " http://127.0.0.1:49152/api/ ";
expect(resolveSlackWebClientOptions().slackApiUrl).toBe("http://127.0.0.1:49152/api/");
expect(resolveSlackWriteClientOptions().slackApiUrl).toBe("http://127.0.0.1:49152/api/");
});
it("does not read OPENCLAW_SLACK_API_URL as a default Slack Web API root", () => {
process.env.OPENCLAW_SLACK_API_URL = "http://127.0.0.1:49152/api/";
expect(resolveSlackWebClientOptions().slackApiUrl).toBeUndefined();
expect(resolveSlackWriteClientOptions().slackApiUrl).toBeUndefined();
});
it("preserves Slack API URL client options over SLACK_API_URL", () => {
process.env.SLACK_API_URL = "http://127.0.0.1:49152/api/";
const explicitApiUrlOption = {
slackApiUrl: "http://127.0.0.1:49153/api/",
timeout: 1000,
};
expect(resolveSlackWebClientOptions(explicitApiUrlOption).slackApiUrl).toBe(
"http://127.0.0.1:49153/api/",
);
expect(resolveSlackWriteClientOptions(explicitApiUrlOption).slackApiUrl).toBe(
"http://127.0.0.1:49153/api/",
);
});
it("preserves Slack API URL client options when SLACK_API_URL is unset", () => {
const explicitApiUrlOption = {
slackApiUrl: "http://127.0.0.1:49153/api/",
timeout: 1000,
};
expect(resolveSlackWebClientOptions(explicitApiUrlOption).slackApiUrl).toBe(
"http://127.0.0.1:49153/api/",
);
expect(resolveSlackWriteClientOptions(explicitApiUrlOption).slackApiUrl).toBe(
"http://127.0.0.1:49153/api/",
);
});
it("passes merged options into WebClient", () => {
const customAgent = {} as never;
createSlackWebClient("xoxb-test", { timeout: 1234, agent: customAgent });
expect(WebClient).toHaveBeenCalledWith("xoxb-test", {
agent: customAgent,
retryConfig: SLACK_DEFAULT_RETRY_OPTIONS,
timeout: 1234,
});
});
it("applies the default retry config when constructing a client without proxy env", () => {
clearProxyEnvForTest();
try {
createSlackWebClient("xoxb-test", { timeout: 1234 });
expect(WebClient).toHaveBeenCalledWith("xoxb-test", {
agent: undefined,
retryConfig: SLACK_DEFAULT_RETRY_OPTIONS,
timeout: 1234,
});
} finally {
restoreProxyEnvForTest();
}
});
it("applies the write retry config when none is provided", () => {
const options = resolveSlackWriteClientOptions();
expect(options.retryConfig).toEqual(SLACK_WRITE_RETRY_OPTIONS);
});
it("serializes write client requests by default", () => {
const options = resolveSlackWriteClientOptions();
expect(options.maxRequestConcurrency).toBe(1);
});
it("respects explicit write client concurrency overrides", () => {
const options = resolveSlackWriteClientOptions({ maxRequestConcurrency: 5 });
expect(options.maxRequestConcurrency).toBe(5);
});
it("passes no-retry config into the write client by default", () => {
const customAgent = {} as never;
createSlackWriteClient("xoxb-test", { timeout: 4321, agent: customAgent });
expect(WebClient).toHaveBeenCalledWith("xoxb-test", {
agent: customAgent,
maxRequestConcurrency: 1,
retryConfig: SLACK_WRITE_RETRY_OPTIONS,
timeout: 4321,
});
});
it("reuses default write clients per token", () => {
clearProxyEnvForTest();
try {
const first = getSlackWriteClient("xoxb-test");
const second = getSlackWriteClient("xoxb-test");
expect(second).toBe(first);
expect(WebClient).toHaveBeenCalledTimes(1);
expect(WebClient).toHaveBeenCalledWith("xoxb-test", {
agent: undefined,
maxRequestConcurrency: 1,
retryConfig: SLACK_WRITE_RETRY_OPTIONS,
});
} finally {
restoreProxyEnvForTest();
}
});
it("keeps default write clients separated by token", () => {
const first = getSlackWriteClient("xoxb-one");
const second = getSlackWriteClient("xoxb-two");
expect(second).not.toBe(first);
expect(WebClient).toHaveBeenCalledTimes(2);
});
it("only exposes API-root options on cached write clients", () => {
expectTypeOf<NonNullable<Parameters<typeof getSlackWriteClient>[1]>>().toEqualTypeOf<
Pick<WebClientOptions, "slackApiUrl">
>();
});
it("keeps write clients separated by Slack API URL client options", () => {
clearProxyEnvForTest();
try {
const firstOptions = {
slackApiUrl: "http://127.0.0.1:49152/api/",
};
const secondOptions = {
slackApiUrl: "http://127.0.0.1:49153/api/",
};
const first = getSlackWriteClient("xoxb-test", firstOptions);
const second = getSlackWriteClient("xoxb-test", secondOptions);
expect(second).not.toBe(first);
expect(WebClient).toHaveBeenCalledTimes(2);
} finally {
restoreProxyEnvForTest();
}
});
it("keeps write clients separated by SLACK_API_URL", () => {
clearProxyEnvForTest();
try {
process.env.SLACK_API_URL = "http://127.0.0.1:49152/api/";
const first = getSlackWriteClient("xoxb-test");
process.env.SLACK_API_URL = "http://127.0.0.1:49153/api/";
const second = getSlackWriteClient("xoxb-test");
expect(second).not.toBe(first);
expect(WebClient).toHaveBeenCalledTimes(2);
} finally {
restoreProxyEnvForTest();
}
});
it("builds stable non-secret token cache keys", () => {
const token = "xoxb-sensitive-token";
const first = createSlackTokenCacheKey(token);
const second = createSlackTokenCacheKey(token);
expect(first).toBe(second);
expect(first).toMatch(/^sha256:/);
expect(first).not.toContain(token);
expect(createSlackTokenCacheKey("xoxb-other-token")).not.toBe(first);
});
});
describe("slack proxy agent", () => {
beforeEach(() => {
clearProxyEnvForTest();
});
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
restoreProxyEnvForTest();
});
it("sets agent from HTTPS_PROXY env var", () => {
process.env.HTTPS_PROXY = "http://proxy.example.com:3128";
const options = resolveSlackWebClientOptions();
const agent = requireAgent(options);
expect(agent.constructor.name).toBe("ProxylineNodeProxyAgent");
});
it("creates Slack env proxy agents while managed proxy CA trust is active", () => {
const caFile = writeTempCa("slack-managed-proxy-ca");
process.env.HTTPS_PROXY = "https://proxy.example.com:8443";
process.env.OPENCLAW_PROXY_ACTIVE = "1";
process.env.OPENCLAW_PROXY_CA_FILE = caFile;
const options = resolveSlackWebClientOptions();
const agent = requireAgent(options);
expect(agent.constructor.name).toBe("ProxylineNodeProxyAgent");
});
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(requireAgent(options).constructor.name).toBe("ProxylineNodeProxyAgent");
});
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();
const agent = requireAgent(options);
// Proxyline stores the effective proxy URL in its resolver.
expect(
(agent as unknown as { getProxyForUrl: (url: string) => string }).getProxyForUrl(
"https://slack.com/",
),
).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();
const agent = requireAgent(options);
expect(agent.constructor.name).toBe("ProxylineNodeProxyAgent");
});
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(requireAgent(options).constructor.name).toBe("ProxylineNodeProxyAgent");
});
it("degrades gracefully on malformed proxy URL", () => {
process.env.HTTPS_PROXY = "not-a-valid-url://:::bad";
const options = resolveSlackWebClientOptions();
// Should not throw; falls back to no agent
expect(options.agent).toBeUndefined();
});
});

View File

@@ -0,0 +1,60 @@
// Slack plugin module implements client behavior.
import { createHash } from "node:crypto";
import { type WebClientOptions, WebClient } from "@slack/web-api";
import { resolveSlackWebClientOptions, resolveSlackWriteClientOptions } from "./client-options.js";
const SLACK_WRITE_CLIENT_CACHE_MAX = 32;
const slackWriteClientCache = new Map<string, WebClient>();
type SlackWriteClientCacheOptions = Pick<WebClientOptions, "slackApiUrl">;
export {
resolveSlackWebClientOptions,
resolveSlackWriteClientOptions,
SLACK_DEFAULT_RETRY_OPTIONS,
SLACK_WRITE_RETRY_OPTIONS,
} from "./client-options.js";
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));
}
export function createSlackTokenCacheKey(token: string): string {
return `sha256:${createHash("sha256").update(token).digest("base64url")}`;
}
function slackWriteClientCacheKey(token: string, options: SlackWriteClientCacheOptions): string {
const tokenKey = createSlackTokenCacheKey(token);
return options.slackApiUrl ? `${tokenKey}:api:${options.slackApiUrl}` : tokenKey;
}
export function getSlackWriteClient(
token: string,
options: SlackWriteClientCacheOptions = {},
): WebClient {
const resolvedOptions = resolveSlackWriteClientOptions(options);
const tokenKey = slackWriteClientCacheKey(token, resolvedOptions);
const cached = slackWriteClientCache.get(tokenKey);
if (cached) {
slackWriteClientCache.delete(tokenKey);
slackWriteClientCache.set(tokenKey, cached);
return cached;
}
const client = new WebClient(token, resolvedOptions);
if (slackWriteClientCache.size >= SLACK_WRITE_CLIENT_CACHE_MAX) {
const oldestTokenKey = slackWriteClientCache.keys().next().value;
if (oldestTokenKey) {
slackWriteClientCache.delete(oldestTokenKey);
}
}
slackWriteClientCache.set(tokenKey, client);
return client;
}
export function clearSlackWriteClientCacheForTest(): void {
slackWriteClientCache.clear();
}

View File

@@ -0,0 +1,147 @@
// Slack tests cover real Web API routing behavior.
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it } from "vitest";
import { createSlackWebClient } from "./client.js";
const SLACK_API_URL_KEYS = ["SLACK_API_URL"] as const;
const PROXY_KEYS = [
"HTTPS_PROXY",
"HTTP_PROXY",
"https_proxy",
"http_proxy",
"NO_PROXY",
"no_proxy",
"OPENCLAW_PROXY_ACTIVE",
"OPENCLAW_PROXY_CA_FILE",
] as const;
const TEST_ENV_KEYS = [...SLACK_API_URL_KEYS, ...PROXY_KEYS] as const;
const originalEnv = { ...process.env };
type SlackApiRequest = {
authorization?: string;
method?: string;
url?: string;
};
function restoreTestEnv() {
for (const key of TEST_ENV_KEYS) {
if (originalEnv[key] !== undefined) {
process.env[key] = originalEnv[key];
} else {
delete process.env[key];
}
}
}
async function closeServer(server: Server): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
async function startSlackApiServer(requests: SlackApiRequest[]): Promise<{
baseUrl: string;
close(): Promise<void>;
}> {
const server = createServer((request, response) => {
requests.push({
authorization: request.headers.authorization,
method: request.method,
url: request.url,
});
request.resume();
response.writeHead(200, { "content-type": "application/json" });
response.end(
`${JSON.stringify({
ok: true,
team: "Mock Slack",
team_id: "TMOCK",
url: "https://mock.slack.test/",
user: "mock-bot",
user_id: "UMOCK",
})}\n`,
);
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address() as AddressInfo;
return {
baseUrl: `http://127.0.0.1:${address.port}`,
close: () => closeServer(server),
};
}
afterEach(() => {
restoreTestEnv();
});
describe("Slack Web API routing", () => {
it("routes real WebClient requests to the SLACK_API_URL root", async () => {
for (const key of TEST_ENV_KEYS) {
delete process.env[key];
}
const requests: SlackApiRequest[] = [];
const server = await startSlackApiServer(requests);
try {
process.env.SLACK_API_URL = `${server.baseUrl}/api/`;
const client = createSlackWebClient("xoxb-route-proof", {
retryConfig: { retries: 0 },
timeout: 1000,
});
const result = await client.auth.test();
expect(result.ok).toBe(true);
expect(requests).toEqual([
{
authorization: "Bearer xoxb-route-proof",
method: "POST",
url: "/api/auth.test",
},
]);
} finally {
await server.close();
}
});
it("routes real WebClient requests to explicit Slack API URL options before SLACK_API_URL", async () => {
for (const key of TEST_ENV_KEYS) {
delete process.env[key];
}
const envRequests: SlackApiRequest[] = [];
const explicitRequests: SlackApiRequest[] = [];
const envServer = await startSlackApiServer(envRequests);
const explicitServer = await startSlackApiServer(explicitRequests);
try {
process.env.SLACK_API_URL = `${envServer.baseUrl}/api/`;
const client = createSlackWebClient("xoxb-route-proof", {
retryConfig: { retries: 0 },
slackApiUrl: `${explicitServer.baseUrl}/api/`,
timeout: 1000,
});
const result = await client.auth.test();
expect(result.ok).toBe(true);
expect(envRequests).toEqual([]);
expect(explicitRequests).toEqual([
{
authorization: "Bearer xoxb-route-proof",
method: "POST",
url: "/api/auth.test",
},
]);
} finally {
await explicitServer.close();
await envServer.close();
}
});
});

View File

@@ -0,0 +1,282 @@
// Slack tests cover config schema plugin behavior.
import { describe, expect, it } from "vitest";
import { SlackConfigSchema } from "../config-api.js";
function expectSlackConfigValid(config: unknown) {
const res = SlackConfigSchema.safeParse(config);
expect(res.success).toBe(true);
}
function expectSlackConfigIssue(config: unknown, path: string) {
const res = SlackConfigSchema.safeParse(config);
expect(res.success).toBe(false);
if (!res.success) {
expect(res.error.issues.map((issue) => issue.path.join("."))).toContain(path);
}
}
describe("slack config schema", () => {
it("defaults groupPolicy to allowlist", () => {
const res = SlackConfigSchema.safeParse({});
expect(res.success).toBe(true);
if (res.success) {
expect(res.data.groupPolicy).toBe("allowlist");
}
});
it("accepts historyLimit overrides per account", () => {
const res = SlackConfigSchema.safeParse({
historyLimit: 7,
accounts: { ops: { historyLimit: 2 } },
});
expect(res.success).toBe(true);
if (res.success) {
expect(res.data.historyLimit).toBe(7);
expect(res.data.accounts?.ops?.historyLimit).toBe(2);
}
});
it("rejects Slack Web API URL config overrides", () => {
const res = SlackConfigSchema.safeParse({
apiUrl: "http://127.0.0.1:49152/api/",
accounts: { ops: { apiUrl: "http://127.0.0.1:49153/api/" } },
});
expect(res.success).toBe(false);
if (!res.success) {
expect(
res.error.issues.some(
(issue) => issue.code === "unrecognized_keys" && issue.keys.includes("apiUrl"),
),
).toBe(true);
}
});
it("accepts unfurl controls at root and account level", () => {
const res = SlackConfigSchema.safeParse({
unfurlLinks: false,
unfurlMedia: false,
accounts: {
ops: {
unfurlLinks: true,
unfurlMedia: false,
},
},
});
expect(res.success).toBe(true);
if (res.success) {
expect(res.data.unfurlLinks).toBe(false);
expect(res.data.unfurlMedia).toBe(false);
expect(res.data.accounts?.ops?.unfurlLinks).toBe(true);
expect(res.data.accounts?.ops?.unfurlMedia).toBe(false);
}
});
it("rejects invalid unfurl control types", () => {
expectSlackConfigIssue({ unfurlLinks: "false" }, "unfurlLinks");
expectSlackConfigIssue(
{ accounts: { ops: { unfurlMedia: "false" } } },
"accounts.ops.unfurlMedia",
);
});
it('rejects dmPolicy="open" without allowFrom "*"', () => {
expectSlackConfigIssue(
{
dmPolicy: "open",
allowFrom: ["U123"],
},
"allowFrom",
);
});
it('accepts legacy dm.policy="open" with top-level allowFrom alias', () => {
expectSlackConfigValid({
dm: { policy: "open", allowFrom: ["U123"] },
allowFrom: ["*"],
});
});
it("accepts user token config fields", () => {
expectSlackConfigValid({
botToken: "xoxb-any",
appToken: "xapp-any",
userToken: "xoxp-any",
userTokenReadOnly: false,
});
});
it("accepts Socket Mode ping/pong transport tuning", () => {
expectSlackConfigValid({
mode: "socket",
socketMode: {
clientPingTimeout: 15_000,
serverPingTimeout: 45_000,
pingPongLoggingEnabled: true,
},
accounts: {
ops: {
socketMode: {
clientPingTimeout: 20_000,
},
},
},
});
});
it("accepts relay mode with a SecretInput auth token", () => {
expectSlackConfigValid({
mode: "relay",
botToken: "xoxb-any",
relay: {
url: "wss://router.example.com/gateway/ws",
authToken: { source: "env", provider: "default", id: "SLACK_RELAY_AUTH_TOKEN" },
gatewayId: "team-gateway",
},
});
});
it("requires every relay connection field", () => {
expectSlackConfigIssue({ mode: "relay" }, "relay.url");
expectSlackConfigIssue(
{ mode: "relay", relay: { url: "wss://router.example.com/gateway/ws" } },
"relay.authToken",
);
expectSlackConfigIssue(
{
mode: "relay",
relay: {
url: "wss://router.example.com/gateway/ws",
authToken: "secret",
},
},
"relay.gatewayId",
);
});
it("rejects invalid Socket Mode ping/pong transport tuning", () => {
expectSlackConfigIssue(
{
socketMode: {
clientPingTimeout: 0,
},
},
"socketMode.clientPingTimeout",
);
});
it("accepts per-channel replyToMode", () => {
expectSlackConfigValid({
channels: {
C123: { requireMention: false, replyToMode: "off" },
},
});
});
it("rejects invalid per-channel replyToMode", () => {
expectSlackConfigIssue(
{
channels: {
C123: { replyToMode: "sometimes" },
},
},
"channels.C123.replyToMode",
);
});
it("accepts account-level user token config", () => {
expectSlackConfigValid({
accounts: {
work: {
botToken: "xoxb-any",
appToken: "xapp-any",
userToken: "xoxp-any",
userTokenReadOnly: true,
},
},
});
});
it("rejects invalid userTokenReadOnly types", () => {
expectSlackConfigIssue(
{
botToken: "xoxb-any",
appToken: "xapp-any",
userToken: "xoxp-any",
userTokenReadOnly: "no",
},
"userTokenReadOnly",
);
});
it("rejects invalid userToken types", () => {
expectSlackConfigIssue(
{
botToken: "xoxb-any",
appToken: "xapp-any",
userToken: 123,
},
"userToken",
);
});
it("accepts HTTP mode when signing secret is configured", () => {
expectSlackConfigValid({
mode: "http",
signingSecret: "secret",
});
});
it("accepts HTTP mode when signing secret is configured as SecretRef", () => {
expectSlackConfigValid({
mode: "http",
signingSecret: { source: "env", provider: "default", id: "SLACK_SIGNING_SECRET" },
});
});
it("rejects HTTP mode without signing secret", () => {
expectSlackConfigIssue({ mode: "http" }, "signingSecret");
});
it("accepts account HTTP mode when base signing secret is set", () => {
expectSlackConfigValid({
signingSecret: "secret",
accounts: {
ops: {
mode: "http",
},
},
});
});
it("accepts account HTTP mode when account signing secret is set as SecretRef", () => {
expectSlackConfigValid({
accounts: {
ops: {
mode: "http",
signingSecret: {
source: "env",
provider: "default",
id: "SLACK_OPS_SIGNING_SECRET",
},
},
},
});
});
it("rejects account HTTP mode without signing secret", () => {
expectSlackConfigIssue(
{
accounts: {
ops: {
mode: "http",
},
},
},
"accounts.ops.signingSecret",
);
});
});

View File

@@ -0,0 +1,7 @@
// Slack helper module supports config schema behavior.
import { buildChannelConfigSchema, SlackConfigSchema } from "../config-api.js";
import { slackChannelConfigUiHints } from "./config-ui-hints.js";
export const SlackChannelConfigSchema = buildChannelConfigSchema(SlackConfigSchema, {
uiHints: slackChannelConfigUiHints,
});

View File

@@ -0,0 +1,225 @@
// Slack helper module supports config ui hints behavior.
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).",
},
mentionPatterns: {
label: "Slack Mention Pattern Policy",
help: "Scopes configured groupChat mentionPatterns to selected Slack channel IDs. Native Slack @mentions still trigger even when regex patterns are denied.",
},
"mentionPatterns.mode": {
label: "Slack Mention Pattern Mode",
help: '"allow" enables configured regex mention patterns unless denyIn matches; "deny" disables them unless allowIn matches.',
},
"mentionPatterns.allowIn": {
label: "Slack Mention Pattern Allowlist",
help: "Slack channel IDs where configured regex mention patterns are enabled when mode is deny.",
},
"mentionPatterns.denyIn": {
label: "Slack Mention Pattern Denylist",
help: "Slack channel IDs where configured regex mention patterns are disabled. Native @mentions still trigger.",
},
"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).",
},
botLoopProtection: {
label: "Slack Bot Loop Protection",
help: "Sliding-window guard for Slack bot-to-bot loops. Default is enabled whenever allowBots lets bot-authored messages reach dispatch.",
},
"botLoopProtection.enabled": {
label: "Slack Bot Loop Protection Enabled",
help: 'Enable the bot-pair loop guard. Defaults to true when allowBots is true or "mentions", and false when bot messages are ignored.',
},
"botLoopProtection.maxEventsPerWindow": {
label: "Slack Bot Loop Events per Window",
help: "Maximum accepted bot-pair messages within the sliding window before suppression starts. Default: 20.",
},
"botLoopProtection.windowSeconds": {
label: "Slack Bot Loop Window Seconds",
help: "Sliding window length for counting bot-pair messages. Default: 60.",
},
"botLoopProtection.cooldownSeconds": {
label: "Slack Bot Loop Cooldown Seconds",
help: "How long to suppress the bot pair after it exceeds the budget. Default: 60.",
},
socketMode: {
label: "Slack Socket Mode Transport",
help: "Slack Socket Mode transport tuning passed to the Slack SDK. Use only when investigating ping/pong timeout or stale websocket behavior.",
},
"socketMode.clientPingTimeout": {
label: "Slack Socket Mode Pong Timeout",
help: "Milliseconds the Slack SDK waits for a pong after its client ping before treating the websocket as stale (OpenClaw default: 15000). Increase on hosts with event-loop starvation or slow network scheduling.",
},
"socketMode.serverPingTimeout": {
label: "Slack Socket Mode Server Ping Timeout",
help: "Milliseconds the Slack SDK waits for Slack server pings before treating the websocket as stale.",
},
"socketMode.pingPongLoggingEnabled": {
label: "Slack Socket Mode Ping/Pong Logging",
help: "Enable Slack SDK ping/pong transport logs while debugging Socket Mode websocket health.",
},
relay: {
label: "Slack Relay Mode",
help: 'Relay-delivered Slack events. Use with mode="relay" when openclaw-slack-router owns the Slack Socket Mode connection.',
},
"relay.url": {
label: "Slack Relay URL",
help: "Full websocket URL for openclaw-slack-router. Include the route path, for example ws://127.0.0.1:8081/gateway/ws.",
},
"relay.authToken": {
label: "Slack Relay Auth Token",
help: "Bearer token used by this gateway to authenticate its reverse websocket connection to openclaw-slack-router.",
},
"relay.gatewayId": {
label: "Slack Relay Gateway ID",
help: "Destination id that openclaw-slack-router uses when routing user-group mentions to this gateway.",
},
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). Native streaming and Slack assistant thread status require a reply thread target; top-level DMs can still use draft post-and-edit preview streaming.",
},
"streaming.preview.toolProgress": {
label: "Slack Draft Tool Progress",
help: "Show tool/progress activity in the live draft preview message (default: true). Set false to hide interim tool updates while the draft preview stays active.",
},
"streaming.preview.commandText": {
label: "Slack Draft Command Text",
help: 'Command/exec detail in preview tool-progress lines: "raw" preserves released behavior; "status" shows only the tool label.',
},
"streaming.progress.label": {
label: "Slack Progress Label",
help: 'Initial progress draft title. Use "auto" for built-in single-word labels, a custom string, or false to hide the title.',
},
"streaming.progress.labels": {
label: "Slack Progress Label Pool",
help: 'Candidate labels for streaming.progress.label="auto". Leave unset to use OpenClaw built-in progress labels.',
},
"streaming.progress.maxLines": {
label: "Slack Progress Max Lines",
help: "Maximum number of compact progress lines to keep below the draft label (default: 8).",
},
"streaming.progress.maxLineChars": {
label: "Slack Progress Max Line Chars",
help: "Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes.",
},
"streaming.progress.render": {
label: "Slack Progress Renderer",
help: 'Progress draft renderer: "text" uses one portable text body; "rich" renders structured Slack Block Kit fields with the same text fallback.',
},
"streaming.progress.nativeTaskCards": {
label: "Slack Native Progress Task Cards",
help: 'Opt in to Slack native task-card progress updates when channels.slack.streaming.mode="progress" and streaming.nativeTransport is enabled. Default: false.',
},
"streaming.progress.toolProgress": {
label: "Slack Progress Tool Lines",
help: "Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery.",
},
"streaming.progress.commandText": {
label: "Slack Progress Command Text",
help: 'Command/exec detail in progress draft lines: "raw" preserves released behavior; "status" shows only the tool label.',
},
"thread.historyScope": {
label: "Slack Thread History Scope",
help: 'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).',
},
"thread.inheritParent": {
label: "Slack Thread Parent Inheritance",
help: "If true, Slack thread sessions inherit the parent channel transcript (default: false).",
},
"thread.initialHistoryLimit": {
label: "Slack Thread Initial History Limit",
help: "Maximum number of existing Slack thread messages to fetch when starting a new thread session (default: 20, set to 0 to disable).",
},
"thread.requireExplicitMention": {
label: "Slack Thread Require Explicit Mention",
help: "If true, require an explicit @mention even inside threads where the bot has participated. Suppresses implicit thread mention behavior so the bot only responds to explicit @bot mentions in threads (default: false).",
},
} satisfies Record<string, ChannelConfigUiHint>;

View File

@@ -0,0 +1,66 @@
// Slack helper module supports directory config behavior.
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/string-coerce-runtime";
import {
mergeSlackAccountConfig,
resolveDefaultSlackAccountId,
resolveSlackAccountAllowFrom,
} 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,
allowFrom: resolveSlackAccountAllowFrom({ cfg, accountId: resolvedAccountId }) ?? [],
};
}
export const listSlackDirectoryPeersFromConfig = createResolvedDirectoryEntriesLister<
ReturnType<typeof resolveSlackDirectoryConfigAccount>
>({
kind: "user",
resolveAccount: (cfg, accountId) => resolveSlackDirectoryConfigAccount(cfg, accountId),
resolveSources: (account) => {
const channelUsers = Object.values(account.config.channels ?? {}).flatMap(
(channel) => channel.users ?? [],
);
return [account.allowFrom, Object.keys(account.config.dms ?? {}), channelUsers];
},
normalizeId: (raw) => {
const mention = raw.match(/^<@([A-Z0-9]+)>$/i);
const normalizedUserId = (mention?.[1] ?? raw).replace(/^(slack|user):/i, "").trim();
if (!normalizedUserId) {
return null;
}
const target = `user:${normalizedUserId}`;
const normalized = parseSlackTarget(target, { defaultKind: "user" });
return normalized?.kind === "user"
? `user:${normalizeLowercaseStringOrEmpty(normalized.id)}`
: null;
},
});
export const listSlackDirectoryGroupsFromConfig = createResolvedDirectoryEntriesLister<
ReturnType<typeof resolveSlackDirectoryConfigAccount>
>({
kind: "group",
resolveAccount: (cfg, accountId) => resolveSlackDirectoryConfigAccount(cfg, accountId),
resolveSources: (account) => [Object.keys(account.config.channels ?? {})],
normalizeId: (raw) => {
const normalized = parseSlackTarget(raw, { defaultKind: "channel" });
return normalized?.kind === "channel"
? `channel:${normalizeLowercaseStringOrEmpty(normalized.id)}`
: null;
},
});

View File

@@ -0,0 +1,162 @@
// Slack tests cover directory contract plugin behavior.
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
import { expectDirectoryIds } from "openclaw/plugin-sdk/channel-test-helpers";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
import {
listSlackDirectoryGroupsFromConfig,
listSlackDirectoryPeersFromConfig,
} from "../directory-contract-api.js";
import { getSlackDirectorySelfLive } from "./directory-live.js";
import type { SlackProbe } from "./probe.js";
const slackClientMocks = vi.hoisted(() => ({
authTest: vi.fn(),
usersInfo: vi.fn(),
}));
vi.mock("./client.js", () => ({
createSlackWebClient: () => ({
auth: { test: slackClientMocks.authTest },
users: { info: slackClientMocks.usersInfo },
}),
}));
describe("Slack directory contract", () => {
beforeEach(() => {
slackClientMocks.authTest.mockReset();
slackClientMocks.usersInfo.mockReset();
});
it("keeps public probe aligned with base contract", () => {
expectTypeOf<SlackProbe>().toMatchTypeOf<BaseProbeResult>();
});
it("lists peers/groups from config", async () => {
const cfg = {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
dm: { allowFrom: ["U123", "user:U999"] },
dms: { U234: {} },
channels: { C111: { users: ["U777"] } },
},
},
} as unknown as OpenClawConfig;
await expectDirectoryIds(
listSlackDirectoryPeersFromConfig,
cfg,
["user:u123", "user:u234", "user:u777", "user:u999"],
{ sorted: true },
);
await expectDirectoryIds(listSlackDirectoryGroupsFromConfig, cfg, ["channel:c111"]);
});
it("keeps directories readable when tokens are unresolved SecretRefs", async () => {
const envSecret = {
source: "env",
provider: "default",
id: "MISSING_TEST_SECRET",
} as const;
const cfg = {
channels: {
slack: {
botToken: envSecret,
appToken: envSecret,
dm: { allowFrom: ["U123"] },
channels: { C111: {} },
},
},
} as unknown as OpenClawConfig;
await expectDirectoryIds(listSlackDirectoryPeersFromConfig, cfg, ["user:u123"]);
await expectDirectoryIds(listSlackDirectoryGroupsFromConfig, cfg, ["channel:c111"]);
});
it("applies query and limit filtering for config-backed directories", async () => {
const cfg = {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
dm: { allowFrom: ["U100", "U200"] },
dms: { U300: {} },
},
},
} as unknown as OpenClawConfig;
const peers = await listSlackDirectoryPeersFromConfig({
cfg,
accountId: "default",
query: "user:u",
limit: 2,
});
expect(peers).toHaveLength(2);
expect(peers.every((entry) => entry.id.startsWith("user:u"))).toBe(true);
});
it("resolves current Slack account identity from live auth", async () => {
slackClientMocks.authTest.mockResolvedValue({
ok: true,
user_id: "USELF",
user: "ada",
team_id: "T1",
team: "Test Team",
});
slackClientMocks.usersInfo.mockResolvedValue({
user: {
id: "USELF",
name: "ada",
profile: {
display_name: "Ada",
real_name: "Ada Lovelace",
},
},
});
const cfg = {
channels: {
slack: {
userToken: "xoxp-test",
},
},
} as unknown as OpenClawConfig;
const self = await getSlackDirectorySelfLive({ cfg, accountId: "default" });
if (!self) {
throw new Error("expected Slack self directory entry");
}
expect(self.kind).toBe("user");
expect(self.id).toBe("user:USELF");
expect(self.name).toBe("Ada");
expect(self.handle).toBe("@ada");
expect(slackClientMocks.authTest).toHaveBeenCalled();
expect(slackClientMocks.usersInfo).toHaveBeenCalledWith({ user: "USELF" });
});
it("falls back to auth identity when live user profile lookup fails", async () => {
slackClientMocks.authTest.mockResolvedValue({
ok: true,
user_id: "USELF",
user: "ada",
});
slackClientMocks.usersInfo.mockRejectedValue(new Error("missing_scope"));
const cfg = {
channels: {
slack: {
userToken: "xoxp-test",
},
},
} as unknown as OpenClawConfig;
const self = await getSlackDirectorySelfLive({ cfg, accountId: "default" });
if (!self) {
throw new Error("expected Slack self directory entry");
}
expect(self.kind).toBe("user");
expect(self.id).toBe("user:USELF");
expect(self.name).toBe("ada");
expect(self.handle).toBe("@ada");
});
});

View File

@@ -0,0 +1,226 @@
// Slack plugin module implements directory live behavior.
import type {
ChannelDirectoryEntry,
DirectoryConfigParams,
} from "openclaw/plugin-sdk/directory-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
normalizeOptionalLowercaseString,
} from "openclaw/plugin-sdk/string-coerce-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 };
};
type SlackAuthTestResponse = {
ok?: boolean;
user_id?: string;
user?: string;
team_id?: string;
team?: string;
};
function createSlackDirectoryClient(params: DirectoryConfigParams) {
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
const token = account.userToken ?? account.botToken?.trim();
return token ? createSlackWebClient(token) : null;
}
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;
}
function slackUserToDirectoryEntry(
user: SlackUser,
fallback?: { id?: string; name?: string },
): ChannelDirectoryEntry | null {
const id = normalizeOptionalString(user.id) ?? normalizeOptionalString(fallback?.id);
if (!id) {
return null;
}
const handle = normalizeOptionalString(user.name) ?? normalizeOptionalString(fallback?.name);
const display =
normalizeOptionalString(user.profile?.display_name) ||
normalizeOptionalString(user.profile?.real_name) ||
normalizeOptionalString(user.real_name) ||
handle;
return {
kind: "user",
id: `user:${id}`,
name: display || undefined,
handle: handle ? `@${handle}` : undefined,
rank: buildUserRank(user),
raw: user,
};
}
export async function getSlackDirectorySelfLive(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry | null> {
const client = createSlackDirectoryClient(params);
if (!client) {
return null;
}
const auth = (await client.auth.test()) as SlackAuthTestResponse;
const userId = normalizeOptionalString(auth.user_id);
if (!userId) {
return null;
}
try {
const info = (await client.users.info({ user: userId })) as { user?: SlackUser };
return slackUserToDirectoryEntry(info.user ?? {}, { id: userId, name: auth.user });
} catch {
return slackUserToDirectoryEntry(
{ id: userId, name: auth.user },
{ id: userId, name: auth.user },
);
}
}
export async function listSlackDirectoryPeersLive(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const client = createSlackDirectoryClient(params);
if (!client) {
return [];
}
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) => slackUserToDirectoryEntry(member))
.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 client = createSlackDirectoryClient(params);
if (!client) {
return [];
}
const query = normalizeQuery(params.query);
const channels: SlackChannel[] = [];
let cursor: string | undefined;
do {
const res = (await client.conversations.list({
types: "public_channel,private_channel",
exclude_archived: false,
limit: 1000,
cursor,
})) as SlackListChannelsResponse;
if (Array.isArray(res.channels)) {
channels.push(...res.channels);
}
const next = res.response_metadata?.next_cursor?.trim();
cursor = next ? next : undefined;
} while (cursor);
const filtered = channels.filter((channel) => {
const name = normalizeOptionalLowercaseString(channel.name);
if (!query) {
return true;
}
return Boolean(name && name.includes(query));
});
const rows = filtered
.map((channel) => {
const id = channel.id?.trim();
const name = channel.name?.trim();
if (!id || !name) {
return null;
}
return {
kind: "group",
id: `channel:${id}`,
name,
handle: `#${name}`,
rank: buildChannelRank(channel),
raw: channel,
} satisfies ChannelDirectoryEntry;
})
.filter(Boolean) as ChannelDirectoryEntry[];
if (typeof params.limit === "number" && params.limit > 0) {
return rows.slice(0, params.limit);
}
return rows;
}

View File

@@ -0,0 +1,173 @@
// Slack plugin module implements doctor contract behavior.
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
asObjectRecord,
hasLegacyAccountStreamingAliases,
hasLegacyStreamingAliases,
normalizeLegacyChannelAliases,
} from "openclaw/plugin-sdk/runtime-doctor";
import { resolveSlackNativeStreaming, resolveSlackStreamingMode } from "./streaming-compat.js";
function hasLegacySlackStreamingAliases(value: unknown): boolean {
return hasLegacyStreamingAliases(value, { includeNativeTransport: true });
}
function hasLegacySlackChannelAllowAlias(value: unknown): boolean {
const channels = asObjectRecord(asObjectRecord(value)?.channels);
if (!channels) {
return false;
}
return Object.values(channels).some((channel) =>
Object.hasOwn(asObjectRecord(channel) ?? {}, "allow"),
);
}
function normalizeSlackChannelAllowAliases(params: {
channels: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { channels: Record<string, unknown>; changed: boolean } {
let changed = false;
const nextChannels = { ...params.channels };
for (const [channelId, channelValue] of Object.entries(params.channels)) {
const channel = asObjectRecord(channelValue);
if (!channel || !Object.hasOwn(channel, "allow")) {
continue;
}
const nextChannel = { ...channel };
if (nextChannel.enabled === undefined) {
nextChannel.enabled = channel.allow;
params.changes.push(
`Moved ${params.pathPrefix}.${channelId}.allow → ${params.pathPrefix}.${channelId}.enabled.`,
);
} else {
params.changes.push(
`Removed ${params.pathPrefix}.${channelId}.allow (${params.pathPrefix}.${channelId}.enabled already set).`,
);
}
delete nextChannel.allow;
nextChannels[channelId] = nextChannel;
changed = true;
}
return { channels: nextChannels, changed };
}
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),
},
{
path: ["channels", "slack"],
message:
'channels.slack.channels.<id>.allow is legacy; use channels.slack.channels.<id>.enabled instead. Run "openclaw doctor --fix".',
match: hasLegacySlackChannelAllowAlias,
},
{
path: ["channels", "slack", "accounts"],
message:
'channels.slack.accounts.<id>.channels.<id>.allow is legacy; use channels.slack.accounts.<id>.channels.<id>.enabled instead. Run "openclaw doctor --fix".',
match: (value) => {
const accounts = asObjectRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) => hasLegacySlackChannelAllowAlias(account));
},
},
];
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;
let changed;
const aliases = normalizeLegacyChannelAliases({
entry: rawEntry,
pathPrefix: "channels.slack",
changes,
normalizeDm: true,
normalizeAccountDm: true,
resolveStreamingOptions: (entry) => ({
resolvedMode: resolveSlackStreamingMode(entry),
resolvedNativeTransport: resolveSlackNativeStreaming(entry),
}),
});
updated = aliases.entry;
changed = aliases.changed;
const channels = asObjectRecord(updated.channels);
if (channels) {
const normalized = normalizeSlackChannelAllowAliases({
channels,
pathPrefix: "channels.slack.channels",
changes,
});
if (normalized.changed) {
updated = { ...updated, channels: normalized.channels };
changed = true;
}
}
const accounts = asObjectRecord(updated.accounts);
if (accounts) {
let accountsChanged = false;
const nextAccounts = { ...accounts };
for (const [accountId, accountValue] of Object.entries(accounts)) {
const account = asObjectRecord(accountValue);
const channelEntries = asObjectRecord(account?.channels);
if (!account || !channelEntries) {
continue;
}
const normalized = normalizeSlackChannelAllowAliases({
channels: channelEntries,
pathPrefix: `channels.slack.accounts.${accountId}.channels`,
changes,
});
if (!normalized.changed) {
continue;
}
nextAccounts[accountId] = { ...account, channels: normalized.channels };
accountsChanged = true;
}
if (accountsChanged) {
updated = { ...updated, accounts: nextAccounts };
changed = true;
}
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
slack: updated as unknown as NonNullable<OpenClawConfig["channels"]>["slack"],
} as OpenClawConfig["channels"],
},
changes,
};
}

View File

@@ -0,0 +1,406 @@
// Slack tests cover doctor plugin behavior.
import { describe, expect, it } from "vitest";
import { slackDoctor } from "./doctor.js";
async function collectSlackWarnings(
slack: Record<string, unknown>,
defaults?: Record<string, unknown>,
) {
return (
(await Promise.resolve(
slackDoctor.collectMutableAllowlistWarnings?.({
cfg: { channels: { ...(defaults ? { defaults } : {}), slack } } as never,
}),
)) ?? []
);
}
function getSlackCompatibilityNormalizer(): NonNullable<
typeof slackDoctor.normalizeCompatibilityConfig
> {
const normalize = slackDoctor.normalizeCompatibilityConfig;
if (!normalize) {
throw new Error("Expected slack doctor to expose normalizeCompatibilityConfig");
}
return normalize;
}
describe("slack doctor", () => {
it("warns when mutable allowlist entries rely on disabled name matching", async () => {
const warnings = await Promise.resolve(
slackDoctor.collectMutableAllowlistWarnings?.({
cfg: {
channels: {
slack: {
allowFrom: ["alice"],
accounts: {
work: {
dm: {
allowFrom: ["U12345678"],
},
channels: {
general: {
users: ["bob"],
},
},
},
},
},
},
} as never,
}),
);
expect(
warnings?.some((warning) => warning.includes("mutable allowlist entries across slack")),
).toBe(true);
expect(warnings?.some((warning) => warning.includes("channels.slack.allowFrom: alice"))).toBe(
true,
);
expect(
warnings?.some((warning) =>
warning.includes("channels.slack.accounts.work.channels.general.users: bob"),
),
).toBe(true);
});
it("warns for name-keyed allowlist channels but accepts routed ID forms (#81665)", async () => {
const warnings = await collectSlackWarnings({
channels: {
"example-channel": {},
community: {},
C0AL2GDUA7J: {},
c0al2gdua7k: {},
"channel:C0AL2GDUA7L": {},
"channel:c0al2gdua7m": {},
D0AL2GDUA7Q: {},
"channel:d0al2gdua7r": {},
"channel:dabcdefgh": {},
"channel:customers": {},
"CHANNEL:C0AL2GDUA7N": {},
"channel:C0al2gdua7p": {},
"*": {},
},
});
const nameKeyWarnings = warnings.filter((warning) =>
warning.includes("Re-key it with the channel's"),
);
expect(nameKeyWarnings).toHaveLength(5);
expect(nameKeyWarnings[0]).toContain('channels.slack.channels."example-channel"');
expect(nameKeyWarnings[0]).toContain('channels.slack.channels."*" applies instead');
expect(nameKeyWarnings[1]).toContain('channels.slack.channels."community" is ambiguous');
expect(nameKeyWarnings[2]).toContain(
'channels.slack.channels."channel:customers" is ambiguous',
);
expect(nameKeyWarnings[3]).toContain('channels.slack.channels."CHANNEL:C0AL2GDUA7N"');
expect(nameKeyWarnings[4]).toContain('channels.slack.channels."channel:C0al2gdua7p"');
const dmWarnings = warnings.filter((warning) =>
warning.includes("is a Slack DM conversation ID"),
);
expect(dmWarnings).toHaveLength(3);
expect(dmWarnings[0]).toContain('channels.slack.channels."D0AL2GDUA7Q"');
expect(dmWarnings[1]).toContain('channels.slack.channels."channel:d0al2gdua7r"');
expect(dmWarnings[2]).toContain('channels.slack.channels."channel:dabcdefgh"');
expect(dmWarnings[0]).toContain("channels.slack.dmPolicy");
});
it("uses account policy and name-matching overrides for name-keyed channels (#81665)", async () => {
const overlongName = "a".repeat(81);
const warnings = await collectSlackWarnings({
groupPolicy: "open",
channels: { "root-room": {} },
accounts: {
inheritedOpen: {
channels: { general: {} },
},
inheritedAllowlist: {
groupPolicy: "allowlist",
},
explicitAllowlist: {
groupPolicy: "allowlist",
channels: { engineering: {} },
},
nameMatching: {
groupPolicy: "allowlist",
dangerouslyAllowNameMatching: true,
channels: {
support: {},
"#help": {},
"crème-brûlée": {},
d0customers: {},
dabcdefgh: {},
"channel:customers": {},
"<#C0AL2GDUA7J>": {},
"slack:C0AL2GDUA7K": {},
"@help": {},
"##help": {},
"help+": {},
Support: {},
"-": {},
___: {},
"#--": {},
[overlongName]: {},
},
},
},
});
const nameKeyWarnings = warnings.filter((warning) =>
warning.includes("Re-key it with the channel's"),
);
expect(nameKeyWarnings).toHaveLength(13);
const rootWarning = nameKeyWarnings.find((warning) =>
warning.includes('channels.slack.channels."root-room"'),
);
expect(rootWarning).toContain("messages from the channel are dropped");
expect(
nameKeyWarnings.some((warning) =>
warning.includes('channels.slack.accounts.explicitAllowlist.channels."engineering"'),
),
).toBe(true);
expect(
nameKeyWarnings.some((warning) =>
warning.includes(
'channels.slack.accounts.nameMatching.channels."channel:customers" is ambiguous',
),
),
).toBe(true);
expect(
nameKeyWarnings.some((warning) =>
warning.includes('channels.slack.accounts.nameMatching.channels."<#C0AL2GDUA7J>"'),
),
).toBe(true);
expect(
nameKeyWarnings.some((warning) =>
warning.includes('channels.slack.accounts.nameMatching.channels."slack:C0AL2GDUA7K"'),
),
).toBe(true);
for (const invalidName of [
"@help",
"##help",
"help+",
"Support",
"-",
"___",
"#--",
overlongName,
]) {
expect(
nameKeyWarnings.some((warning) =>
warning.includes(`channels.slack.accounts.nameMatching.channels."${invalidName}"`),
),
).toBe(true);
}
const sharedOpenWarnings = await collectSlackWarnings(
{ channels: { "shared-room": {} } },
{ groupPolicy: "open" },
);
expect(
sharedOpenWarnings.some((warning) => warning.includes("not a routable Slack channel ID")),
).toBe(true);
});
it("warns when an open-policy override is keyed by channel name (#81665)", async () => {
const warnings = await collectSlackWarnings({
groupPolicy: "open",
channels: {
"private-room": { enabled: false },
},
});
expect(warnings).toEqual([expect.stringContaining('channels.slack.channels."private-room"')]);
expect(warnings[0]).toContain("the channel remains allowed");
});
it("warns for DM IDs regardless of room policy and uses account-scoped remediation", async () => {
const openWarnings = await collectSlackWarnings({
groupPolicy: "open",
channels: {
D0AL2GDUA7S: {},
},
});
expect(openWarnings).toEqual([
expect.stringContaining('channels.slack.channels."D0AL2GDUA7S"'),
]);
const disabledAccountWarnings = await collectSlackWarnings({
accounts: {
work: {
groupPolicy: "disabled",
channels: {
"channel:d0al2gdua7t": {},
},
},
},
});
expect(disabledAccountWarnings).toEqual([
expect.stringContaining('channels.slack.accounts.work.channels."channel:d0al2gdua7t"'),
]);
expect(disabledAccountWarnings[0]).toContain("channels.slack.accounts.work.dmPolicy");
expect(disabledAccountWarnings[0]).toContain("channels.slack.accounts.work.allowFrom");
const inheritedChannelWarnings = await collectSlackWarnings({
channels: {
D0AL2GDUA7U: {},
},
accounts: {
work: {
groupPolicy: "disabled",
dmPolicy: "allowlist",
allowFrom: ["U0AL2GDUA7U"],
},
},
});
expect(inheritedChannelWarnings).toEqual([
expect.stringContaining('channels.slack.channels."D0AL2GDUA7U"'),
]);
expect(inheritedChannelWarnings[0]).toContain("channels.slack.accounts.work.dmPolicy");
});
it("treats bare lowercase D forms as ambiguous without name matching", async () => {
const warnings = await collectSlackWarnings({
channels: {
d0customers: {},
dabcdefgh: {},
},
});
expect(warnings).toHaveLength(2);
expect(warnings[0]).toContain(
'channels.slack.channels."d0customers" is ambiguous: it may be a lowercase Slack DM conversation ID or a channel name',
);
expect(warnings[1]).toContain(
'channels.slack.channels."dabcdefgh" is ambiguous: it may be a lowercase Slack DM conversation ID or a channel name',
);
expect(warnings[0]).toContain("stable C/G ID");
});
it("does not audit provider defaults as a standalone named account (#81665)", async () => {
const warnings = await collectSlackWarnings({
channels: {
"provider-room": { enabled: false },
},
accounts: {
work: {
channels: {
C0AL2GDUA7J: {},
},
},
},
});
expect(warnings.some((warning) => warning.includes("provider-room"))).toBe(false);
});
it("normalizes legacy slack streaming aliases into the nested streaming shape", () => {
const normalize = getSlackCompatibilityNormalizer();
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,
});
for (const expectedChange of [
"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.",
]) {
expect(result.changes).toContain(expectedChange);
}
});
it("does not duplicate streaming.mode change messages when streamMode wins over boolean streaming", () => {
const normalize = getSlackCompatibilityNormalizer();
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)."]);
});
it("moves legacy channel allow toggles into enabled", () => {
const normalize = getSlackCompatibilityNormalizer();
const result = normalize({
cfg: {
channels: {
slack: {
channels: {
ops: {
allow: false,
},
},
accounts: {
work: {
channels: {
general: {
allow: true,
},
},
},
},
},
},
} as never,
});
expect(result.changes).toEqual([
"Moved channels.slack.channels.ops.allow → channels.slack.channels.ops.enabled.",
"Moved channels.slack.accounts.work.channels.general.allow → channels.slack.accounts.work.channels.general.enabled.",
]);
expect(result.config.channels?.slack?.channels?.ops).toEqual({
enabled: false,
});
expect(result.config.channels?.slack?.accounts?.work?.channels?.general).toEqual({
enabled: true,
});
});
});

View File

@@ -0,0 +1,192 @@
// Slack plugin module implements doctor behavior.
import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import type { GroupPolicy, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { listSlackAccountIds, mergeSlackAccountConfig } from "./accounts.js";
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;
}
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;
},
});
const SLACK_CANONICAL_CHANNEL_ID_RE = /^[CG][A-Z0-9]{8,}$/;
const SLACK_LOWERCASE_CHANNEL_ID_RE = /^[cg][0-9][a-z0-9]{7,}$/;
const SLACK_PREFIXED_CANONICAL_CHANNEL_ID_RE = /^channel:[CG][A-Z0-9]{8,}$/;
const SLACK_PREFIXED_LOWERCASE_CHANNEL_ID_RE = /^channel:[cg][0-9][a-z0-9]{7,}$/;
const SLACK_CANONICAL_DM_ID_RE = /^(?:channel:)?D[A-Z0-9]{8,}$/;
const SLACK_PREFIXED_LOWERCASE_DM_ID_RE = /^channel:d[a-z0-9]{8,}$/;
const SLACK_AMBIGUOUS_LOWERCASE_DM_ID_RE = /^d[a-z0-9]{8,}$/;
// Letter-leading lowercase forms may be valid IDs or human names. Warn conditionally instead of
// claiming they are unroutable.
const SLACK_AMBIGUOUS_LOWERCASE_CHANNEL_ID_RE = /^(?:channel:)?[cgd][a-z][a-z0-9]{7,}$/;
// Slack supports international channel names, and runtime name matching preserves exact names.
// Keep Unicode letters/marks/numbers while enforcing lowercase, length, and punctuation rules.
const SLACK_CHANNEL_NAME_RE = /^[\p{L}\p{M}\p{N}_-]{1,80}$/u;
const SLACK_CHANNEL_NAME_ALPHANUMERIC_RE = /[\p{L}\p{N}]/u;
function looksLikeSlackChannelId(channelKey: string): boolean {
return (
SLACK_CANONICAL_CHANNEL_ID_RE.test(channelKey) ||
SLACK_LOWERCASE_CHANNEL_ID_RE.test(channelKey) ||
SLACK_PREFIXED_CANONICAL_CHANNEL_ID_RE.test(channelKey) ||
SLACK_PREFIXED_LOWERCASE_CHANNEL_ID_RE.test(channelKey)
);
}
function looksLikeSlackDmId(channelKey: string): boolean {
return (
SLACK_CANONICAL_DM_ID_RE.test(channelKey) || SLACK_PREFIXED_LOWERCASE_DM_ID_RE.test(channelKey)
);
}
function looksLikeSlackChannelNameKey(channelKey: string): boolean {
const name = channelKey.startsWith("#") ? channelKey.slice(1) : channelKey;
return (
name === name.toLowerCase() &&
SLACK_CHANNEL_NAME_RE.test(name) &&
SLACK_CHANNEL_NAME_ALPHANUMERIC_RE.test(name)
);
}
// Startup resolution updates ctx.channelsConfig, but inbound authorization captures the authored
// channels map and key list when createSlackMonitorContext runs. Diagnose those authored keys.
function collectSlackNameKeyedChannelWarnings({ cfg }: { cfg: OpenClawConfig }): string[] {
const warnings = new Set<string>();
const slackCfg = asObjectRecord(asObjectRecord(cfg.channels)?.slack);
const providerChannels = asObjectRecord(slackCfg?.channels);
const accounts = asObjectRecord(slackCfg?.accounts);
for (const accountId of listSlackAccountIds(cfg)) {
const account = asObjectRecord(mergeSlackAccountConfig(cfg, accountId));
if (!account || slackCfg?.enabled === false || account.enabled === false) {
continue;
}
const scopedGroupPolicy =
typeof account.groupPolicy === "string" ? (account.groupPolicy as GroupPolicy) : undefined;
// Slack's schema materializes this provider default before runtime account merging.
const effectiveGroupPolicy = scopedGroupPolicy ?? "allowlist";
const rawAccount = asObjectRecord(accounts?.[accountId]);
const accountPrefix = rawAccount ? `channels.slack.accounts.${accountId}` : "channels.slack";
const accountChannels = asObjectRecord(rawAccount?.channels);
const channels = accountChannels ?? providerChannels;
if (!channels) {
continue;
}
const channelsPrefix = accountChannels
? `channels.slack.accounts.${accountId}`
: "channels.slack";
const fallbackDescription = Object.hasOwn(channels, "*")
? `${channelsPrefix}.channels."*" applies instead and this entry's overrides are ignored`
: effectiveGroupPolicy === "open"
? 'this entry\'s overrides are ignored and the channel remains allowed by groupPolicy: "open"'
: "messages from the channel are dropped";
for (const channelKey of Object.keys(channels)) {
if (channelKey === "*") {
continue;
}
if (looksLikeSlackDmId(channelKey)) {
warnings.add(
`${channelsPrefix}.channels."${channelKey}" is a Slack DM conversation ID, but ${channelsPrefix}.channels only configures channel and group rooms. ` +
`Configure DM access with ${accountPrefix}.dmPolicy and ${accountPrefix}.allowFrom instead.`,
);
continue;
}
if (SLACK_AMBIGUOUS_LOWERCASE_DM_ID_RE.test(channelKey)) {
if (
account.dangerouslyAllowNameMatching === true &&
looksLikeSlackChannelNameKey(channelKey)
) {
continue;
}
warnings.add(
`${channelsPrefix}.channels."${channelKey}" is ambiguous: it may be a lowercase Slack DM conversation ID or a channel name. ` +
`Configure DMs with ${accountPrefix}.dmPolicy and ${accountPrefix}.allowFrom; otherwise re-key the room with its stable C/G ID.`,
);
continue;
}
if (effectiveGroupPolicy === "disabled") {
continue;
}
const channelConfig = asObjectRecord(channels[channelKey]);
if (effectiveGroupPolicy === "open" && Object.keys(channelConfig ?? {}).length === 0) {
continue;
}
if (looksLikeSlackChannelId(channelKey)) {
continue;
}
if (
account.dangerouslyAllowNameMatching === true &&
looksLikeSlackChannelNameKey(channelKey)
) {
continue;
}
if (SLACK_AMBIGUOUS_LOWERCASE_CHANNEL_ID_RE.test(channelKey)) {
warnings.add(
`${channelsPrefix}.channels."${channelKey}" is ambiguous: it may be a lowercase Slack channel ID or a channel name. ` +
`If it is a channel name, inbound routing will not match it and ${fallbackDescription}. ` +
`Re-key it with the channel's stable ID (e.g. C0123ABCD, from the channel's About details or conversations.info).`,
);
continue;
}
warnings.add(
`${channelsPrefix}.channels."${channelKey}" is keyed by a channel name or non-canonical ID form, not a routable Slack channel ID; ` +
`under groupPolicy: "${effectiveGroupPolicy}" inbound routing does not match this entry, so ${fallbackDescription}. ` +
`Re-key it with the channel's ID (e.g. C0123ABCD, from the channel's About details or conversations.info).`,
);
}
}
return [...warnings];
}
export const slackDoctor: ChannelDoctorAdapter = {
dmAllowFromMode: "topOnly",
groupModel: "route",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,
legacyConfigRules: SLACK_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: normalizeSlackCompatibilityConfig,
collectMutableAllowlistWarnings: ({ cfg }) => [
...collectSlackMutableAllowlistWarnings({ cfg }),
...collectSlackNameKeyedChannelWarnings({ cfg }),
],
};

View File

@@ -0,0 +1,232 @@
// Slack tests cover draft stream plugin behavior.
import { createMessageReceiptFromOutboundResults } from "openclaw/plugin-sdk/channel-outbound";
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"]>;
type MockCalls<TArgs extends readonly unknown[]> = { mock: { calls: TArgs[] } };
const TEST_CFG = {};
function mockCalls<TArgs extends readonly unknown[]>(fn: unknown): TArgs[] {
return (fn as MockCalls<TArgs>).mock.calls;
}
function slackDraftSendResult(messageId: string, channelId = "C123") {
return {
channelId,
messageId,
receipt: createMessageReceiptFromOutboundResults({
results: [{ channel: "slack", messageId, channelId }],
kind: "preview",
}),
};
}
function createDraftStreamHarness(
params: {
maxChars?: number;
send?: DraftSendFn;
edit?: DraftEditFn;
remove?: DraftRemoveFn;
warn?: DraftWarnFn;
} = {},
) {
const send = params.send ?? vi.fn<DraftSendFn>(async () => slackDraftSendResult("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",
cfg: TEST_CFG,
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", {
cfg: TEST_CFG,
token: "xoxb-test",
accountId: undefined,
});
});
it("sends and edits rich draft blocks with text fallback", async () => {
const { stream, send, edit } = createDraftStreamHarness();
const blocks = [{ type: "divider" }] as const;
stream.update({ text: "fallback", blocks: [...blocks] });
await stream.flush();
stream.update({ text: "updated fallback", blocks: [...blocks] });
await stream.flush();
const sendCall = mockCalls<Parameters<DraftSendFn>>(send)[0];
expect(sendCall?.[0]).toBe("channel:C123");
expect(sendCall?.[1]).toBe("fallback");
expect((sendCall?.[2] as { blocks?: unknown } | undefined)?.blocks).toEqual([...blocks]);
const editCall = mockCalls<Parameters<DraftEditFn>>(edit)[0];
expect(editCall?.[0]).toBe("C123");
expect(editCall?.[1]).toBe("111.222");
expect(editCall?.[2]).toBe("updated fallback");
expect((editCall?.[3] as { blocks?: unknown } | undefined)?.blocks).toEqual([...blocks]);
});
it("forwards identity to the initial send call", async () => {
const identity = { username: "test-agent", iconEmoji: ":robot_face:" };
const send = vi.fn<DraftSendFn>(async () => slackDraftSendResult("111.222"));
const stream = createSlackDraftStream({
target: "channel:C123",
cfg: TEST_CFG,
token: "xoxb-test",
throttleMs: 250,
identity,
send,
edit: vi.fn<DraftEditFn>(async () => {}),
remove: vi.fn<DraftRemoveFn>(async () => {}),
});
stream.update("hello");
await stream.flush();
const sendCall = mockCalls<Parameters<DraftSendFn>>(send)[0];
expect(sendCall?.[0]).toBe("channel:C123");
expect(sendCall?.[1]).toBe("hello");
expect((sendCall?.[2] as { identity?: unknown } | undefined)?.identity).toEqual(identity);
});
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(slackDraftSendResult("111.222"))
.mockResolvedValueOnce(slackDraftSendResult("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);
const sendCall = mockCalls<Parameters<DraftSendFn>>(send)[0];
expect(sendCall?.[0]).toBe("channel:C123");
expect(sendCall?.[1]).toBe(text);
expect((sendCall?.[2] as { token?: string } | undefined)?.token).toBe("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("discardPending stops late updates without deleting the visible preview", async () => {
const { stream, send, edit, remove } = createDraftStreamHarness();
stream.update("hello");
await stream.flush();
await stream.discardPending();
stream.update("late");
await stream.flush();
expect(send).toHaveBeenCalledTimes(1);
expect(edit).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
expect(stream.messageId()).toBe("111.222");
expect(stream.channelId()).toBe("C123");
});
it("clear is a no-op when no preview message exists", async () => {
const { stream, remove } = createDraftStreamHarness();
await stream.clear();
expect(remove).not.toHaveBeenCalled();
});
it("clear warns when cleanup fails", async () => {
const remove = vi.fn<DraftRemoveFn>(async () => {
throw new Error("cleanup failed");
});
const warn = vi.fn<DraftWarnFn>();
const { stream } = createDraftStreamHarness({ remove, warn });
stream.update("hello");
await stream.flush();
await stream.clear();
expect(warn).toHaveBeenCalledWith("slack stream preview cleanup failed: cleanup failed");
expect(stream.messageId()).toBeUndefined();
expect(stream.channelId()).toBeUndefined();
});
});

View File

@@ -0,0 +1,179 @@
// Slack plugin module implements draft stream behavior.
import type { MessageMetadata } from "@slack/types";
import type { Block, KnownBlock } from "@slack/web-api";
import { createDraftStreamLoop } from "openclaw/plugin-sdk/channel-outbound";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { deleteSlackMessage, editSlackMessage } from "./actions.js";
import { formatSlackError } from "./errors.js";
import { SLACK_TEXT_LIMIT } from "./limits.js";
import type { SlackSendIdentity } from "./send.js";
import { sendMessageSlack } from "./send.js";
const DEFAULT_THROTTLE_MS = 1000;
type SlackDraftStream = {
update: (update: SlackDraftStreamUpdate) => void;
flush: () => Promise<void>;
clear: () => Promise<void>;
discardPending: () => Promise<void>;
seal: () => Promise<void>;
stop: () => void;
forceNewMessage: () => void;
messageId: () => string | undefined;
channelId: () => string | undefined;
};
export type SlackDraftStreamUpdate =
| string
| {
text: string;
blocks?: (Block | KnownBlock)[];
};
export function createSlackDraftStream(params: {
target: string;
cfg: OpenClawConfig;
token: string;
accountId?: string;
identity?: SlackSendIdentity;
maxChars?: number;
throttleMs?: number;
resolveThreadTs?: () => string | undefined;
metadata?: MessageMetadata;
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 lastSentKey = "";
let pendingUpdate: SlackDraftStreamUpdate | undefined;
let stopped = false;
const normalizeUpdate = (update: SlackDraftStreamUpdate) =>
typeof update === "string" ? { text: update } : update;
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;
}
const update = normalizeUpdate(pendingUpdate ?? text);
const blocks = update.text === text ? update.blocks : undefined;
const sentKey = `${trimmed}\n${blocks ? JSON.stringify(blocks) : ""}`;
if (sentKey === lastSentKey) {
return;
}
lastSentKey = sentKey;
try {
if (streamChannelId && streamMessageId) {
await edit(streamChannelId, streamMessageId, trimmed, {
cfg: params.cfg,
token: params.token,
accountId: params.accountId,
...(blocks ? { blocks } : {}),
});
return;
}
const sent = await send(params.target, trimmed, {
cfg: params.cfg,
token: params.token,
accountId: params.accountId,
threadTs: params.resolveThreadTs?.(),
identity: params.identity,
...(params.metadata ? { metadata: params.metadata } : {}),
...(blocks ? { blocks } : {}),
});
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: ${formatSlackError(err)}`);
}
};
const loop = createDraftStreamLoop({
throttleMs,
isStopped: () => stopped,
sendOrEditStreamMessage,
});
const stop = () => {
stopped = true;
loop.stop();
};
const discardPending = async () => {
stop();
await loop.waitForInFlight();
};
const clear = async () => {
await discardPending();
const channelId = streamChannelId;
const messageId = streamMessageId;
streamChannelId = undefined;
streamMessageId = undefined;
lastSentKey = "";
pendingUpdate = undefined;
if (!channelId || !messageId) {
return;
}
try {
await remove(channelId, messageId, {
token: params.token,
accountId: params.accountId,
});
} catch (err) {
params.warn?.(`slack stream preview cleanup failed: ${formatSlackError(err)}`);
}
};
const forceNewMessage = () => {
streamMessageId = undefined;
streamChannelId = undefined;
lastSentKey = "";
pendingUpdate = undefined;
loop.resetPending();
};
params.log?.(`slack stream preview ready (maxChars=${maxChars}, throttleMs=${throttleMs})`);
return {
update: (update: SlackDraftStreamUpdate) => {
const normalized = normalizeUpdate(update);
pendingUpdate = update;
loop.update(normalized.text);
},
flush: loop.flush,
clear,
discardPending,
seal: discardPending,
stop,
forceNewMessage,
messageId: () => streamMessageId,
channelId: () => streamChannelId,
};
}

View File

@@ -0,0 +1,19 @@
// Slack plugin module implements edit text behavior.
import type { Block, KnownBlock } from "@slack/web-api";
import { buildSlackBlocksFallbackText } from "./blocks-fallback.js";
import { SLACK_TEXT_LIMIT } from "./limits.js";
import { truncateSlackText } from "./truncate.js";
export function buildSlackEditTextPayload(
content: string,
blocks?: (Block | KnownBlock)[],
): string {
const trimmedContent = content.trim();
if (trimmedContent) {
return trimmedContent;
}
if (blocks?.length) {
return truncateSlackText(buildSlackBlocksFallbackText(blocks), SLACK_TEXT_LIMIT);
}
return " ";
}

View File

@@ -0,0 +1,78 @@
// Slack tests cover errors plugin behavior.
import { describe, expect, it } from "vitest";
import { formatSlackError } from "./errors.js";
describe("formatSlackError", () => {
it("formats missing and unserializable values with fallback text", () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
expect(formatSlackError(undefined)).toBe("no error detail");
expect(formatSlackError(null)).toBe("no error detail");
expect(formatSlackError("")).toBe("no error detail");
expect(formatSlackError(new Error(""))).toBe("Error");
expect(formatSlackError(circular)).toBe('{"self":"[Circular]"}');
});
it("includes Slack platform error fields and response metadata", () => {
const err = Object.assign(new Error("An API error occurred: missing_scope"), {
code: "slack_webapi_platform_error",
data: {
error: "missing_scope",
needed: "channels:write",
provided: "chat:write,app_mentions:read",
response_metadata: {
scopes: ["chat:write", "app_mentions:read"],
acceptedScopes: ["channels:write", "groups:write"],
messages: ["[ERROR] missing required scope"],
},
},
});
expect(formatSlackError(err)).toBe(
"An API error occurred: missing_scope; code: slack_webapi_platform_error; slack error: missing_scope; needed: channels:write; provided: chat:write,app_mentions:read; scopes: chat:write, app_mentions:read; accepted: channels:write, groups:write; slack message: [ERROR] missing required scope",
);
});
it("uses the Slack SDK top-level retryAfter field for rate limit errors", () => {
const err = Object.assign(new Error("rate limited"), {
code: "slack_webapi_rate_limited_error",
retryAfter: 30,
});
expect(formatSlackError(err)).toBe(
"rate limited; code: slack_webapi_rate_limited_error; retryAfter: 30",
);
});
it("includes HTTP status details", () => {
const err = Object.assign(new Error("http failed"), {
code: "slack_webapi_http_error",
statusCode: 429,
statusMessage: "Too Many Requests",
body: "slow down",
});
expect(formatSlackError(err)).toBe(
"http failed; code: slack_webapi_http_error; statusCode: 429; statusMessage: Too Many Requests; body: slow down",
);
});
it("redacts token-shaped values before returning", () => {
const token = "xoxb-1234567890abcdef";
const err = Object.assign(new Error(`Authorization: Bearer ${token}`), {
code: "slack_webapi_platform_error",
data: {
error: "missing_scope",
response_metadata: {
messages: [`token ${token} lacked scope`],
},
},
});
const formatted = formatSlackError(err);
expect(formatted).not.toContain(token);
expect(formatted).toContain("Authorization: Bearer xoxb-1…cdef");
expect(formatted).toContain("token xoxb-1…cdef lacked scope");
});
});

View File

@@ -0,0 +1,156 @@
// Slack plugin module implements errors behavior.
import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
const NO_ERROR_DETAIL = "no error detail";
function redact(value: string): string {
return redactSensitiveText(value);
}
function addStringDetail(details: string[], label: string, value: unknown) {
if (typeof value !== "string") {
return;
}
const trimmed = redact(value.trim());
if (trimmed) {
details.push(label ? `${label}: ${trimmed}` : trimmed);
}
}
function addScalarDetail(details: string[], label: string, value: unknown) {
if (typeof value === "string") {
addStringDetail(details, label, value);
return;
}
if (typeof value === "number" || typeof value === "boolean") {
details.push(`${label}: ${String(value)}`);
}
}
function addStringListDetail(details: string[], label: string, value: unknown) {
if (!Array.isArray(value)) {
return;
}
const entries = value.flatMap((entry) => {
if (typeof entry !== "string") {
return [];
}
const trimmed = redact(entry.trim());
return trimmed ? [trimmed] : [];
});
if (entries.length) {
details.push(`${label}: ${entries.join(", ")}`);
}
}
function safeStringify(value: unknown): string | undefined {
const seen = new WeakSet<object>();
try {
const result = JSON.stringify(value, (_key, nested) => {
if (typeof nested !== "object" || nested === null) {
return nested;
}
if (seen.has(nested)) {
return "[Circular]";
}
seen.add(nested);
return nested;
});
return result ? redact(result) : undefined;
} catch {
return undefined;
}
}
function addSlackResponseMetadata(details: string[], value: unknown) {
if (!isRecord(value)) {
return;
}
addStringListDetail(details, "scopes", value.scopes);
addStringListDetail(details, "accepted", value.acceptedScopes);
const messages = value.messages;
if (Array.isArray(messages)) {
for (const message of messages) {
addStringDetail(details, "slack message", message);
}
}
const warnings = value.warnings;
if (Array.isArray(warnings)) {
for (const warning of warnings) {
addStringDetail(details, "slack warning", warning);
}
}
}
function addSlackDataDetails(details: string[], value: unknown) {
if (!isRecord(value)) {
return;
}
addScalarDetail(details, "slack error", value.error);
addScalarDetail(details, "needed", value.needed);
addScalarDetail(details, "provided", value.provided);
addSlackResponseMetadata(details, value.response_metadata);
}
function addRecordDetails(details: string[], value: Record<string, unknown>) {
addScalarDetail(details, "code", value.code);
addScalarDetail(details, "status", value.status);
addScalarDetail(details, "statusCode", value.statusCode);
addScalarDetail(details, "statusMessage", value.statusMessage);
addScalarDetail(details, "retryAfter", value.retryAfter);
addScalarDetail(details, "errno", value.errno);
addScalarDetail(details, "syscall", value.syscall);
addScalarDetail(details, "hostname", value.hostname);
addScalarDetail(details, "type", value.type);
addStringDetail(details, "statusText", value.statusText);
addStringDetail(details, "body", value.body);
addSlackDataDetails(details, value.data);
if (isRecord(value.response)) {
addScalarDetail(details, "response status", value.response.status);
addStringDetail(details, "response statusText", value.response.statusText);
addSlackDataDetails(details, value.response.data);
}
}
function collectSlackErrorDetails(error: unknown): string[] {
const details: string[] = [];
if (error === undefined || error === null) {
return details;
}
if (typeof error === "string") {
addStringDetail(details, "", error);
return details;
}
if (error instanceof Error) {
addStringDetail(details, "", error.message || error.name);
if (error.cause !== undefined) {
const cause = formatSlackError(error.cause, "");
if (cause) {
details.push(`cause: ${cause}`);
}
}
}
if (isRecord(error)) {
addRecordDetails(details, error);
const fallback = safeStringify(error);
if (details.length === 0 && fallback && fallback !== "{}") {
details.push(fallback);
}
}
return details;
}
export function formatSlackError(error: unknown, fallback = NO_ERROR_DETAIL): string {
const details = collectSlackErrorDetails(error);
if (details.length > 0) {
return details.join("; ");
}
if (error === undefined || error === null) {
return fallback;
}
if (typeof error === "string" && !error.trim()) {
return fallback;
}
return safeStringify(error) ?? fallback;
}

View File

@@ -0,0 +1,303 @@
// Slack tests cover exec approvals plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
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("requires explicit enablement even when owner approvers resolve", () => {
expect(isSlackExecApprovalClientEnabled({ cfg: buildConfig() })).toBe(false);
expect(
isSlackExecApprovalClientEnabled({
cfg: buildConfig({ enabled: true }),
}),
).toBe(false);
expect(
isSlackExecApprovalClientEnabled({
cfg: buildConfig({ approvers: ["U123"] }),
}),
).toBe(false);
expect(
isSlackExecApprovalClientEnabled({
cfg: {
...buildConfig(),
commands: { ownerAllowFrom: ["slack:U123OWNER"] },
} as OpenClawConfig,
}),
).toBe(false);
expect(
isSlackExecApprovalClientEnabled({
cfg: buildConfig({ enabled: "auto", approvers: ["U123"] }),
}),
).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: "u456" })).toBe(true);
expect(isSlackExecApprovalApprover({ cfg, senderId: "U123" })).toBe(false);
});
it("canonicalizes configured exec approver ids before matching uppercase senders", () => {
const explicitCfg = buildConfig({ approvers: ["u456"] });
expect(getSlackExecApprovalApprovers({ cfg: explicitCfg })).toEqual(["U456"]);
expect(isSlackExecApprovalApprover({ cfg: explicitCfg, senderId: "U456" })).toBe(true);
const ownerFallbackCfg = {
...buildConfig({ enabled: true }),
commands: { ownerAllowFrom: ["slack:u123owner"] },
} as OpenClawConfig;
expect(getSlackExecApprovalApprovers({ cfg: ownerFallbackCfg })).toEqual(["U123OWNER"]);
expect(isSlackExecApprovalApprover({ cfg: ownerFallbackCfg, senderId: "U123OWNER" })).toBe(
true,
);
});
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 })).toStrictEqual([]);
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: "u123target" })).toBe(true);
expect(isSlackExecApprovalTargetRecipient({ cfg, senderId: "U999OTHER" })).toBe(false);
expect(isSlackExecApprovalAuthorizedSender({ cfg, senderId: "U123TARGET" })).toBe(true);
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("user:u123owner")).toBe("U123OWNER");
expect(normalizeSlackApproverId("slack:u123owner")).toBe("U123OWNER");
expect(normalizeSlackApproverId("<@U123OWNER>")).toBe("U123OWNER");
expect(normalizeSlackApproverId("<@u123owner>")).toBe("U123OWNER");
expect(normalizeSlackApproverId("u123owner")).toBe("U123OWNER");
expect(normalizeSlackApproverId("C123CHANNEL")).toBeUndefined();
expect(normalizeSlackApproverId("slack:C123CHANNEL")).toBeUndefined();
expect(normalizeSlackApproverId("user:C123CHANNEL")).toBeUndefined();
expect(normalizeSlackApproverId("<@C123CHANNEL>")).toBeUndefined();
});
it("applies agent and session filters to request handling", () => {
const cfg = buildConfig({
enabled: true,
approvers: ["U123"],
agentFilter: ["ops-agent"],
sessionFilter: ["slack:direct:", "tail$"],
});
expect(
shouldHandleSlackExecApprovalRequest({
cfg,
request: {
id: "req-1",
request: {
command: "echo hi",
agentId: "ops-agent",
sessionKey: "agent:ops-agent:slack:direct:U123:tail",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
}),
).toBe(true);
expect(
shouldHandleSlackExecApprovalRequest({
cfg,
request: {
id: "req-2",
request: {
command: "echo hi",
agentId: "other-agent",
sessionKey: "agent:other-agent:slack:direct:U123:tail",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
}),
).toBe(false);
expect(
shouldHandleSlackExecApprovalRequest({
cfg,
request: {
id: "req-3",
request: {
command: "echo hi",
agentId: "ops-agent",
sessionKey: "agent:ops-agent:discord:channel:123",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
}),
).toBe(false);
});
it("rejects requests bound to another channel or Slack account", () => {
const cfg = buildConfig({
enabled: true,
approvers: ["U123"],
});
expect(
shouldHandleSlackExecApprovalRequest({
cfg,
accountId: "work",
request: {
id: "req-1",
request: {
command: "echo hi",
turnSourceChannel: "discord",
turnSourceAccountId: "work",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
}),
).toBe(false);
expect(
shouldHandleSlackExecApprovalRequest({
cfg,
accountId: "work",
request: {
id: "req-2",
request: {
command: "echo hi",
turnSourceChannel: "slack",
turnSourceAccountId: "other",
sessionKey: "agent:ops-agent:missing",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
}),
).toBe(false);
expect(
shouldHandleSlackExecApprovalRequest({
cfg,
accountId: "work",
request: {
id: "req-3",
request: {
command: "echo hi",
turnSourceChannel: "slack",
turnSourceAccountId: "work",
sessionKey: "agent:ops-agent:missing",
},
createdAtMs: 0,
expiresAtMs: 1000,
},
}),
).toBe(true);
});
});

View File

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

View File

@@ -0,0 +1,16 @@
// Slack plugin module implements file reference behavior.
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { SlackFile } from "./types.js";
export function formatSlackFileReference(file: SlackFile | undefined): string {
const name = normalizeOptionalString(file?.name) ?? "file";
const fileId = normalizeOptionalString(file?.id);
return fileId ? `${name} (fileId: ${fileId})` : name;
}
export function formatSlackFileReferenceList(files: readonly SlackFile[] | undefined): string {
if (!files?.length) {
return "file";
}
return files.map((file) => formatSlackFileReference(file)).join(", ");
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
// Slack plugin entrypoint registers its OpenClaw integration.
export * from "./registry.js";

View File

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

View File

@@ -0,0 +1,97 @@
// Slack tests cover plugin routes plugin behavior.
import type { IncomingMessage, ServerResponse } from "node:http";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, OpenClawPluginApi } from "../runtime-api.js";
import { registerSlackPluginHttpRoutes } from "./plugin-routes.js";
import { registerSlackHttpHandler } from "./registry.js";
function createApi(config: OpenClawConfig, registerHttpRoute = vi.fn()): OpenClawPluginApi {
return createTestPluginApi({
id: "slack",
config,
registerHttpRoute,
});
}
function registeredRouteAt(registerHttpRoute: ReturnType<typeof vi.fn>, index: number) {
const call = registerHttpRoute.mock.calls[index];
if (!call) {
throw new Error(`expected registered HTTP route ${index}`);
}
return call[0] as {
handler: (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
};
}
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);
registerSlackPluginHttpRoutes(api);
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"]);
});
it("dispatches through the shared Slack HTTP handler registry", async () => {
const routeHandler = vi.fn();
const unregister = registerSlackHttpHandler({
path: "/slack/events",
handler: routeHandler,
});
const registerHttpRoute = vi.fn();
try {
registerSlackPluginHttpRoutes(createApi({}, registerHttpRoute));
const route = registeredRouteAt(registerHttpRoute, 0);
const req = { url: "/slack/events" } as IncomingMessage;
const res = {} as ServerResponse;
await expect(route.handler(req, res)).resolves.toBe(true);
expect(routeHandler).toHaveBeenCalledWith(req, res);
} finally {
unregister();
}
});
});

View File

@@ -0,0 +1,31 @@
// Slack plugin module implements plugin routes behavior.
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-plugin-common";
import { normalizeSlackWebhookPath } from "./paths.js";
import { handleSlackHttpRequest } from "./registry.js";
type SlackWebhookConfig = {
webhookPath?: unknown;
accounts?: Record<string, { webhookPath?: unknown } | undefined>;
};
function resolveSlackWebhookPaths(config: OpenClawPluginApi["config"]): string[] {
const slack = config.channels?.slack as SlackWebhookConfig | undefined;
const accountConfigs = slack?.accounts ?? {};
const paths = new Set<string>();
for (const accountId of new Set([DEFAULT_ACCOUNT_ID, ...Object.keys(accountConfigs)])) {
const path = accountConfigs[accountId]?.webhookPath ?? slack?.webhookPath;
paths.add(normalizeSlackWebhookPath(typeof path === "string" ? path : undefined));
}
return [...paths].toSorted((left, right) => left.localeCompare(right));
}
export function registerSlackPluginHttpRoutes(api: OpenClawPluginApi): void {
for (const path of resolveSlackWebhookPaths(api.config)) {
api.registerHttpRoute({
path,
auth: "plugin",
handler: async (req, res) => await handleSlackHttpRequest(req, res),
});
}
}

View File

@@ -0,0 +1,128 @@
// Slack tests cover registry plugin behavior.
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"',
);
});
it("preserves registered handlers across module reloads", async () => {
const handler = vi.fn();
unregisters.push(
registerSlackHttpHandler({
path: "/slack/events/reload",
handler,
}),
);
vi.resetModules();
const reloadedRegistry = await import("./registry.js");
const req = { url: "/slack/events/reload" } as IncomingMessage;
const res = {} as ServerResponse;
const handled = await reloadedRegistry.handleSlackHttpRequest(req, res);
expect(handled).toBe(true);
expect(handler).toHaveBeenCalledWith(req, res);
});
it("recreates the shared registry if the global slot is corrupted", async () => {
const globalStore = globalThis as Record<PropertyKey, unknown>;
globalStore[Symbol.for("openclaw.slack.httpRoutes.v1")] = {};
const handler = vi.fn();
unregisters.push(
registerSlackHttpHandler({
path: "/slack/events/recovered",
handler,
}),
);
const req = { url: "/slack/events/recovered" } as IncomingMessage;
const res = {} as ServerResponse;
const handled = await handleSlackHttpRequest(req, res);
expect(handled).toBe(true);
expect(handler).toHaveBeenCalledWith(req, res);
});
});

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