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
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:
11
extensions/slack/README.md
Normal file
11
extensions/slack/README.md
Normal 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.
|
||||
7
extensions/slack/account-inspect-api.ts
Normal file
7
extensions/slack/account-inspect-api.ts
Normal 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
116
extensions/slack/api.ts
Normal 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";
|
||||
2
extensions/slack/channel-config-api.ts
Normal file
2
extensions/slack/channel-config-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Slack API module exposes the plugin public contract.
|
||||
export { SlackChannelConfigSchema } from "./src/config-schema.js";
|
||||
21
extensions/slack/channel-entry.ts
Normal file
21
extensions/slack/channel-entry.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
3
extensions/slack/channel-plugin-api.ts
Normal file
3
extensions/slack/channel-plugin-api.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Keep bundled channel entry imports narrow so bootstrap/discovery paths do
|
||||
// not drag setup-only Slack surfaces into lightweight channel plugin loads.
|
||||
export { slackPlugin } from "./src/channel.js";
|
||||
5
extensions/slack/config-api.ts
Normal file
5
extensions/slack/config-api.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Slack API module exposes the plugin public contract.
|
||||
export {
|
||||
buildChannelConfigSchema,
|
||||
SlackConfigSchema,
|
||||
} from "openclaw/plugin-sdk/bundled-channel-config-schema";
|
||||
8
extensions/slack/configured-state.ts
Normal file
8
extensions/slack/configured-state.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
5
extensions/slack/contract-api.ts
Normal file
5
extensions/slack/contract-api.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Slack API module exposes the plugin public contract.
|
||||
export type {
|
||||
SlackInteractiveHandlerContext,
|
||||
SlackInteractiveHandlerRegistration,
|
||||
} from "./src/interactive-dispatch.js";
|
||||
5
extensions/slack/directory-contract-api.ts
Normal file
5
extensions/slack/directory-contract-api.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Slack API module exposes the plugin public contract.
|
||||
export {
|
||||
listSlackDirectoryGroupsFromConfig,
|
||||
listSlackDirectoryPeersFromConfig,
|
||||
} from "./src/directory-config.js";
|
||||
2
extensions/slack/doctor-contract-api.ts
Normal file
2
extensions/slack/doctor-contract-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Slack API module exposes the plugin public contract.
|
||||
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
|
||||
4
extensions/slack/http-routes-api.ts
Normal file
4
extensions/slack/http-routes-api.ts
Normal 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";
|
||||
3
extensions/slack/inbound-contract-test-api.ts
Normal file
3
extensions/slack/inbound-contract-test-api.ts
Normal 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";
|
||||
98
extensions/slack/index.test.ts
Normal file
98
extensions/slack/index.test.ts
Normal 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
27
extensions/slack/index.ts
Normal 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,
|
||||
});
|
||||
5
extensions/slack/interactive-replies-api.ts
Normal file
5
extensions/slack/interactive-replies-api.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Slack API module exposes the plugin public contract.
|
||||
export {
|
||||
compileSlackInteractiveReplies,
|
||||
isSlackInteractiveRepliesEnabled,
|
||||
} from "./src/interactive-replies.js";
|
||||
2
extensions/slack/message-tool-api.ts
Normal file
2
extensions/slack/message-tool-api.ts
Normal 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
1427
extensions/slack/npm-shrinkwrap.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
extensions/slack/openclaw.plugin.json
Normal file
28
extensions/slack/openclaw.plugin.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
2
extensions/slack/outbound-payload-test-api.ts
Normal file
2
extensions/slack/outbound-payload-test-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Slack API module exposes the plugin public contract.
|
||||
export { createSlackOutboundPayloadHarness } from "./src/outbound-payload.test-harness.js";
|
||||
82
extensions/slack/package.json
Normal file
82
extensions/slack/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
45
extensions/slack/runtime-api.ts
Normal file
45
extensions/slack/runtime-api.ts
Normal 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";
|
||||
3
extensions/slack/runtime-setter-api.ts
Normal file
3
extensions/slack/runtime-setter-api.ts
Normal 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";
|
||||
6
extensions/slack/secret-contract-api.ts
Normal file
6
extensions/slack/secret-contract-api.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Slack API module exposes the plugin public contract.
|
||||
export {
|
||||
channelSecrets,
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
2
extensions/slack/security-contract-api.ts
Normal file
2
extensions/slack/security-contract-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Slack API module exposes the plugin public contract.
|
||||
export { collectSlackSecurityAuditFindings } from "./src/security-audit.js";
|
||||
20
extensions/slack/setup-entry.ts
Normal file
20
extensions/slack/setup-entry.ts
Normal 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,
|
||||
});
|
||||
3
extensions/slack/setup-plugin-api.ts
Normal file
3
extensions/slack/setup-plugin-api.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Keep bundled setup entry imports narrow so setup loads do not pull the
|
||||
// broader Slack channel plugin surface.
|
||||
export { slackSetupPlugin } from "./src/channel.setup.js";
|
||||
78
extensions/slack/skills/slack/SKILL.md
Normal file
78
extensions/slack/skills/slack/SKILL.md
Normal 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.
|
||||
23
extensions/slack/src/account-configured.ts
Normal file
23
extensions/slack/src/account-configured.ts
Normal 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());
|
||||
}
|
||||
200
extensions/slack/src/account-inspect.ts
Normal file
200
extensions/slack/src/account-inspect.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
38
extensions/slack/src/account-reply-mode.ts
Normal file
38
extensions/slack/src/account-reply-mode.ts
Normal 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";
|
||||
}
|
||||
16
extensions/slack/src/account-surface-fields.ts
Normal file
16
extensions/slack/src/account-surface-fields.ts
Normal 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"];
|
||||
};
|
||||
2
extensions/slack/src/accounts.runtime.ts
Normal file
2
extensions/slack/src/accounts.runtime.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Slack plugin module implements accounts behavior.
|
||||
export { resolveSlackAccount } from "./accounts.js";
|
||||
607
extensions/slack/src/accounts.test.ts
Normal file
607
extensions/slack/src/accounts.test.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
282
extensions/slack/src/accounts.ts
Normal file
282
extensions/slack/src/accounts.ts
Normal 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);
|
||||
}
|
||||
2
extensions/slack/src/action-runtime.runtime.ts
Normal file
2
extensions/slack/src/action-runtime.runtime.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Slack plugin module implements action runtime behavior.
|
||||
export { handleSlackAction } from "./action-runtime.js";
|
||||
1431
extensions/slack/src/action-runtime.test.ts
Normal file
1431
extensions/slack/src/action-runtime.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
612
extensions/slack/src/action-runtime.ts
Normal file
612
extensions/slack/src/action-runtime.ts
Normal 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}`);
|
||||
}
|
||||
120
extensions/slack/src/action-threading.test.ts
Normal file
120
extensions/slack/src/action-threading.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
38
extensions/slack/src/action-threading.ts
Normal file
38
extensions/slack/src/action-threading.ts
Normal 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;
|
||||
}
|
||||
178
extensions/slack/src/actions.blocks.test.ts
Normal file
178
extensions/slack/src/actions.blocks.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
267
extensions/slack/src/actions.download-file.test.ts
Normal file
267
extensions/slack/src/actions.download-file.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
158
extensions/slack/src/actions.reactions.test.ts
Normal file
158
extensions/slack/src/actions.reactions.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
233
extensions/slack/src/actions.read.test.ts
Normal file
233
extensions/slack/src/actions.read.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
18
extensions/slack/src/actions.runtime.ts
Normal file
18
extensions/slack/src/actions.runtime.ts
Normal 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";
|
||||
565
extensions/slack/src/actions.ts
Normal file
565
extensions/slack/src/actions.ts
Normal 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;
|
||||
}
|
||||
125
extensions/slack/src/approval-auth.test.ts
Normal file
125
extensions/slack/src/approval-auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
43
extensions/slack/src/approval-auth.ts
Normal file
43
extensions/slack/src/approval-auth.ts
Normal 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),
|
||||
});
|
||||
605
extensions/slack/src/approval-handler.runtime.test.ts
Normal file
605
extensions/slack/src/approval-handler.runtime.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
527
extensions/slack/src/approval-handler.runtime.ts
Normal file
527
extensions/slack/src/approval-handler.runtime.ts
Normal 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)}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
436
extensions/slack/src/approval-native-gates.ts
Normal file
436
extensions/slack/src/approval-native-gates.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
1221
extensions/slack/src/approval-native.test.ts
Normal file
1221
extensions/slack/src/approval-native.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
233
extensions/slack/src/approval-native.ts
Normal file
233
extensions/slack/src/approval-native.ts
Normal 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,
|
||||
};
|
||||
96
extensions/slack/src/blocks-fallback.ts
Normal file
96
extensions/slack/src/blocks-fallback.ts
Normal 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";
|
||||
}
|
||||
46
extensions/slack/src/blocks-input.ts
Normal file
46
extensions/slack/src/blocks-input.ts
Normal 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);
|
||||
}
|
||||
421
extensions/slack/src/blocks-render.ts
Normal file
421
extensions/slack/src/blocks-render.ts
Normal 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;
|
||||
}
|
||||
55
extensions/slack/src/blocks.test-helpers.ts
Normal file
55
extensions/slack/src/blocks.test-helpers.ts
Normal 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;
|
||||
}
|
||||
150
extensions/slack/src/blocks.test.ts
Normal file
150
extensions/slack/src/blocks.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
73
extensions/slack/src/channel-actions.ts
Normal file
73
extensions/slack/src/channel-actions.ts
Normal 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));
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
28
extensions/slack/src/channel-api.ts
Normal file
28
extensions/slack/src/channel-api.ts
Normal 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;
|
||||
}
|
||||
119
extensions/slack/src/channel-migration.test.ts
Normal file
119
extensions/slack/src/channel-migration.test.ts
Normal 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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
104
extensions/slack/src/channel-migration.ts
Normal file
104
extensions/slack/src/channel-migration.ts
Normal 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 };
|
||||
}
|
||||
202
extensions/slack/src/channel-type.test.ts
Normal file
202
extensions/slack/src/channel-type.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
124
extensions/slack/src/channel-type.ts
Normal file
124
extensions/slack/src/channel-type.ts
Normal 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 };
|
||||
358
extensions/slack/src/channel.lazy-seams.test.ts
Normal file
358
extensions/slack/src/channel.lazy-seams.test.ts
Normal 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.
|
||||
293
extensions/slack/src/channel.message-adapter.test.ts
Normal file
293
extensions/slack/src/channel.message-adapter.test.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
86
extensions/slack/src/channel.setup.ts
Normal file
86
extensions/slack/src/channel.setup.ts
Normal 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,
|
||||
};
|
||||
1548
extensions/slack/src/channel.test.ts
Normal file
1548
extensions/slack/src/channel.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
915
extensions/slack/src/channel.ts
Normal file
915
extensions/slack/src/channel.ts
Normal 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,
|
||||
});
|
||||
73
extensions/slack/src/client-options.ts
Normal file
73
extensions/slack/src/client-options.ts
Normal 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;
|
||||
}
|
||||
441
extensions/slack/src/client.test.ts
Normal file
441
extensions/slack/src/client.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
60
extensions/slack/src/client.ts
Normal file
60
extensions/slack/src/client.ts
Normal 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();
|
||||
}
|
||||
147
extensions/slack/src/client.web-api.test.ts
Normal file
147
extensions/slack/src/client.web-api.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
282
extensions/slack/src/config-schema.test.ts
Normal file
282
extensions/slack/src/config-schema.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
7
extensions/slack/src/config-schema.ts
Normal file
7
extensions/slack/src/config-schema.ts
Normal 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,
|
||||
});
|
||||
225
extensions/slack/src/config-ui-hints.ts
Normal file
225
extensions/slack/src/config-ui-hints.ts
Normal 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>;
|
||||
66
extensions/slack/src/directory-config.ts
Normal file
66
extensions/slack/src/directory-config.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
162
extensions/slack/src/directory-contract.test.ts
Normal file
162
extensions/slack/src/directory-contract.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
226
extensions/slack/src/directory-live.ts
Normal file
226
extensions/slack/src/directory-live.ts
Normal 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;
|
||||
}
|
||||
173
extensions/slack/src/doctor-contract.ts
Normal file
173
extensions/slack/src/doctor-contract.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
406
extensions/slack/src/doctor.test.ts
Normal file
406
extensions/slack/src/doctor.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
192
extensions/slack/src/doctor.ts
Normal file
192
extensions/slack/src/doctor.ts
Normal 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 }),
|
||||
],
|
||||
};
|
||||
232
extensions/slack/src/draft-stream.test.ts
Normal file
232
extensions/slack/src/draft-stream.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
179
extensions/slack/src/draft-stream.ts
Normal file
179
extensions/slack/src/draft-stream.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
19
extensions/slack/src/edit-text.ts
Normal file
19
extensions/slack/src/edit-text.ts
Normal 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 " ";
|
||||
}
|
||||
78
extensions/slack/src/errors.test.ts
Normal file
78
extensions/slack/src/errors.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
156
extensions/slack/src/errors.ts
Normal file
156
extensions/slack/src/errors.ts
Normal 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;
|
||||
}
|
||||
303
extensions/slack/src/exec-approvals.test.ts
Normal file
303
extensions/slack/src/exec-approvals.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
88
extensions/slack/src/exec-approvals.ts
Normal file
88
extensions/slack/src/exec-approvals.ts
Normal 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;
|
||||
16
extensions/slack/src/file-reference.ts
Normal file
16
extensions/slack/src/file-reference.ts
Normal 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(", ");
|
||||
}
|
||||
96
extensions/slack/src/format.test.ts
Normal file
96
extensions/slack/src/format.test.ts
Normal 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 & b < c > d"],
|
||||
[
|
||||
"preserves Slack angle-bracket markup (mentions/links)",
|
||||
"hi <@U123> see <https://example.com|docs> and <!here>",
|
||||
"hi <@U123> see <https://example.com|docs> and <!here>",
|
||||
],
|
||||
["escapes raw HTML", "<b>nope</b>", "<b>nope</b>"],
|
||||
["renders paragraphs with blank lines", "first\n\nsecond", "first\n\nsecond"],
|
||||
["renders bullet lists", "- one\n- two", "• one\n• two"],
|
||||
["renders ordered lists with numbering", "2. two\n3. three", "2. two\n3. three"],
|
||||
["renders headings as bold text", "# Title", "*Title*"],
|
||||
["renders blockquotes", "> Quote", "> Quote"],
|
||||
] as const;
|
||||
for (const [name, input, expected] of cases) {
|
||||
expect(markdownToSlackMrkdwn(input), name).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles nested list items", () => {
|
||||
const res = markdownToSlackMrkdwn("- item\n - nested");
|
||||
// markdown-it correctly parses this as a nested list
|
||||
expect(res).toBe("• item\n • nested");
|
||||
});
|
||||
|
||||
it("handles complex message with multiple elements", () => {
|
||||
const res = markdownToSlackMrkdwn(
|
||||
"**Important:** Check the _docs_ at [link](https://example.com)\n\n- first\n- second",
|
||||
);
|
||||
expect(res).toBe(
|
||||
"*Important:* Check the _docs_ at <https://example.com|link>\n\n• first\n• second",
|
||||
);
|
||||
});
|
||||
|
||||
it("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 ", "<<"]);
|
||||
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\\_\\*\\`\\~<&>\\\\");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeSlackOutboundText", () => {
|
||||
it("normalizes markdown for outbound send/update paths", () => {
|
||||
expect(normalizeSlackOutboundText(" **bold** ")).toBe("*bold*");
|
||||
});
|
||||
});
|
||||
159
extensions/slack/src/format.ts
Normal file
159
extensions/slack/src/format.ts
Normal 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
const SLACK_ANGLE_TOKEN_RE = /<[^>\n]+>/g;
|
||||
|
||||
function isAllowedSlackAngleToken(token: string): boolean {
|
||||
if (!token.startsWith("<") || !token.endsWith(">")) {
|
||||
return false;
|
||||
}
|
||||
const inner = token.slice(1, -1);
|
||||
return (
|
||||
inner.startsWith("@") ||
|
||||
inner.startsWith("#") ||
|
||||
inner.startsWith("!") ||
|
||||
inner.startsWith("mailto:") ||
|
||||
inner.startsWith("tel:") ||
|
||||
inner.startsWith("http://") ||
|
||||
inner.startsWith("https://") ||
|
||||
inner.startsWith("slack://")
|
||||
);
|
||||
}
|
||||
|
||||
function escapeSlackMrkdwnContent(text: string): string {
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
if (!text.includes("&") && !text.includes("<") && !text.includes(">")) {
|
||||
return text;
|
||||
}
|
||||
|
||||
SLACK_ANGLE_TOKEN_RE.lastIndex = 0;
|
||||
const out: string[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
for (
|
||||
let match = SLACK_ANGLE_TOKEN_RE.exec(text);
|
||||
match;
|
||||
match = SLACK_ANGLE_TOKEN_RE.exec(text)
|
||||
) {
|
||||
const matchIndex = match.index ?? 0;
|
||||
out.push(escapeSlackMrkdwnSegment(text.slice(lastIndex, matchIndex)));
|
||||
const token = match[0] ?? "";
|
||||
out.push(isAllowedSlackAngleToken(token) ? token : escapeSlackMrkdwnSegment(token));
|
||||
lastIndex = matchIndex + token.length;
|
||||
}
|
||||
|
||||
out.push(escapeSlackMrkdwnSegment(text.slice(lastIndex)));
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
function escapeSlackMrkdwnText(text: string): string {
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
if (!text.includes("&") && !text.includes("<") && !text.includes(">")) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
if (line.startsWith("> ")) {
|
||||
return `> ${escapeSlackMrkdwnContent(line.slice(2))}`;
|
||||
}
|
||||
return escapeSlackMrkdwnContent(line);
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function buildSlackLink(link: MarkdownLinkSpan, text: string) {
|
||||
const href = link.href.trim();
|
||||
if (!href) {
|
||||
return null;
|
||||
}
|
||||
const label = text.slice(link.start, link.end);
|
||||
const trimmedLabel = label.trim();
|
||||
const comparableHref = href.startsWith("mailto:") ? href.slice("mailto:".length) : href;
|
||||
const useMarkup =
|
||||
trimmedLabel.length > 0 && trimmedLabel !== href && trimmedLabel !== comparableHref;
|
||||
if (!useMarkup) {
|
||||
return null;
|
||||
}
|
||||
const safeHref = escapeSlackMrkdwnSegment(href);
|
||||
return {
|
||||
start: link.start,
|
||||
end: link.end,
|
||||
open: `<${safeHref}|`,
|
||||
close: ">",
|
||||
};
|
||||
}
|
||||
|
||||
type SlackMarkdownOptions = {
|
||||
tableMode?: MarkdownTableMode;
|
||||
};
|
||||
|
||||
function buildSlackRenderOptions() {
|
||||
return {
|
||||
styleMarkers: {
|
||||
bold: { open: "*", close: "*" },
|
||||
italic: { open: "_", close: "_" },
|
||||
strikethrough: { open: "~", close: "~" },
|
||||
code: { open: "`", close: "`" },
|
||||
code_block: { open: "```\n", close: "```" },
|
||||
},
|
||||
escapeText: escapeSlackMrkdwnText,
|
||||
buildLink: buildSlackLink,
|
||||
};
|
||||
}
|
||||
|
||||
export function markdownToSlackMrkdwn(
|
||||
markdown: string,
|
||||
options: SlackMarkdownOptions = {},
|
||||
): string {
|
||||
const ir = markdownToIR(markdown ?? "", {
|
||||
linkify: false,
|
||||
autolink: false,
|
||||
headingStyle: "bold",
|
||||
blockquotePrefix: "> ",
|
||||
tableMode: options.tableMode,
|
||||
});
|
||||
return renderMarkdownWithMarkers(ir, buildSlackRenderOptions());
|
||||
}
|
||||
|
||||
export function normalizeSlackOutboundText(markdown: string): string {
|
||||
return markdownToSlackMrkdwn(markdown ?? "");
|
||||
}
|
||||
|
||||
export function markdownToSlackMrkdwnChunks(
|
||||
markdown: string,
|
||||
limit: number,
|
||||
options: SlackMarkdownOptions = {},
|
||||
): string[] {
|
||||
const ir = markdownToIR(markdown ?? "", {
|
||||
linkify: false,
|
||||
autolink: false,
|
||||
headingStyle: "bold",
|
||||
blockquotePrefix: "> ",
|
||||
tableMode: options.tableMode,
|
||||
});
|
||||
const renderOptions = buildSlackRenderOptions();
|
||||
return renderMarkdownIRChunksWithinLimit({
|
||||
ir,
|
||||
limit,
|
||||
renderChunk: (chunk) => renderMarkdownWithMarkers(chunk, renderOptions),
|
||||
measureRendered: (rendered) => rendered.length,
|
||||
}).map(({ rendered }) => rendered);
|
||||
}
|
||||
55
extensions/slack/src/group-policy.test.ts
Normal file
55
extensions/slack/src/group-policy.test.ts
Normal 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"] });
|
||||
});
|
||||
});
|
||||
78
extensions/slack/src/group-policy.ts
Normal file
78
extensions/slack/src/group-policy.ts
Normal 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);
|
||||
}
|
||||
2
extensions/slack/src/http/index.ts
Normal file
2
extensions/slack/src/http/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Slack plugin entrypoint registers its OpenClaw integration.
|
||||
export * from "./registry.js";
|
||||
8
extensions/slack/src/http/paths.ts
Normal file
8
extensions/slack/src/http/paths.ts
Normal 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}`;
|
||||
}
|
||||
97
extensions/slack/src/http/plugin-routes.test.ts
Normal file
97
extensions/slack/src/http/plugin-routes.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
31
extensions/slack/src/http/plugin-routes.ts
Normal file
31
extensions/slack/src/http/plugin-routes.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
128
extensions/slack/src/http/registry.test.ts
Normal file
128
extensions/slack/src/http/registry.test.ts
Normal 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
Reference in New Issue
Block a user