[codex] Add Slack relay mode for incoming messages (#94707)

This commit is contained in:
Sarah Fortune
2026-06-21 11:28:33 -07:00
committed by GitHub
parent 4c453c931f
commit 6fa944e80f
34 changed files with 1359 additions and 154 deletions
+36 -4
View File
@@ -1,11 +1,11 @@
---
summary: "Slack setup and runtime behavior (Socket Mode + HTTP Request URLs)"
summary: "Slack setup and runtime behavior (Socket Mode, HTTP Request URLs, and relay mode)"
read_when:
- Setting up Slack or debugging Slack socket/HTTP mode
- Setting up Slack or debugging Slack socket, HTTP, or relay mode
title: "Slack"
---
Production-ready for DMs and channels via Slack app integrations. Default mode is Socket Mode; HTTP Request URLs are also supported.
Production-ready for DMs and channels via Slack app integrations. Default mode is Socket Mode; HTTP Request URLs are also supported. Relay mode is intended for managed deployments where a trusted router owns Slack ingress.
<CardGroup cols={3}>
<Card title="Pairing" icon="link" href="/channels/pairing">
@@ -41,6 +41,37 @@ Both transports are production-ready and reach feature parity for messaging, sla
**Pick HTTP Request URLs** when running multiple Gateway replicas behind a load balancer, when outbound WSS is blocked but inbound HTTPS is allowed, or when you already terminate Slack webhooks at a reverse proxy.
</Note>
### Relay mode
Relay mode separates Slack ingress from the OpenClaw gateway. A trusted router owns the
single Slack Socket Mode connection, chooses a destination gateway, and forwards a typed
event over an authenticated websocket. The gateway continues to use its bot token for
outbound Slack Web API calls.
```json5
{
channels: {
slack: {
mode: "relay",
botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" },
relay: {
url: "wss://router.example.com/gateway/ws",
authToken: { source: "env", provider: "default", id: "SLACK_RELAY_AUTH_TOKEN" },
gatewayId: "team-gateway",
},
},
},
}
```
The relay URL must use `wss://` unless it targets localhost. Treat the bearer token and
router route table as part of the Slack authorization boundary: routed events enter the
normal Slack message handler as authorized activations. A router-provided `slack_identity`
in the websocket `hello` frame can set the default outbound username and icon; an explicit
identity supplied by the caller still wins. The relay connection reconnects with the same
bounded backoff timing used by Socket Mode and clears the router-provided identity whenever
it disconnects.
## Install
Install Slack before configuring the channel:
@@ -863,7 +894,8 @@ The default manifest enables the Slack App Home **Home** tab and subscribes to `
- `botToken` + `appToken` are required for Socket Mode.
- HTTP mode requires `botToken` + `signingSecret`.
- `botToken`, `appToken`, `signingSecret`, and `userToken` accept plaintext
- Relay mode requires `botToken` plus `relay.url`, `relay.authToken`, and `relay.gatewayId`; it does not use an app token or signing secret.
- `botToken`, `appToken`, `signingSecret`, `relay.authToken`, and `userToken` accept plaintext
strings or SecretRef objects.
- Config tokens override env fallback.
- `SLACK_BOT_TOKEN` / `SLACK_APP_TOKEN` env fallback applies only to the default account.
@@ -72,10 +72,12 @@ Scope intent:
- `channels.telegram.accounts.*.webhookSecret`
- `channels.slack.botToken`
- `channels.slack.appToken`
- `channels.slack.relay.authToken`
- `channels.slack.userToken`
- `channels.slack.signingSecret`
- `channels.slack.accounts.*.botToken`
- `channels.slack.accounts.*.appToken`
- `channels.slack.accounts.*.relay.authToken`
- `channels.slack.accounts.*.userToken`
- `channels.slack.accounts.*.signingSecret`
- `channels.sms.authToken`
@@ -295,6 +295,13 @@
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.slack.accounts.*.relay.authToken",
"configFile": "openclaw.json",
"path": "channels.slack.accounts.*.relay.authToken",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.slack.accounts.*.signingSecret",
"configFile": "openclaw.json",
@@ -323,6 +330,13 @@
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.slack.relay.authToken",
"configFile": "openclaw.json",
"path": "channels.slack.relay.authToken",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "channels.slack.signingSecret",
"configFile": "openclaw.json",
+1
View File
@@ -12,6 +12,7 @@
"@slack/types": "2.21.1",
"@slack/web-api": "7.16.0",
"typebox": "1.1.39",
"ws": "8.21.0",
"zod": "4.4.3"
},
"peerDependencies": {
+1
View File
@@ -12,6 +12,7 @@
"@slack/types": "2.21.1",
"@slack/web-api": "7.16.0",
"typebox": "1.1.39",
"ws": "8.21.0",
"zod": "4.4.3"
},
"devDependencies": {
@@ -11,5 +11,13 @@ export function isSlackPluginAccountConfigured(account: ResolvedSlackAccount): b
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());
}
+14 -5
View File
@@ -80,6 +80,7 @@ export function inspectSlackAccount(params: {
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);
@@ -89,9 +90,10 @@ export function inspectSlackAccount(params: {
const envBot = allowEnv
? normalizeSecretInputString(params.envBotToken ?? process.env.SLACK_BOT_TOKEN)
: undefined;
const envApp = allowEnv
? normalizeSecretInputString(params.envAppToken ?? process.env.SLACK_APP_TOKEN)
: undefined;
const envApp =
allowEnv && !isRelayMode
? normalizeSecretInputString(params.envAppToken ?? process.env.SLACK_APP_TOKEN)
: undefined;
const envUser = allowEnv
? normalizeSecretInputString(params.envUserToken ?? process.env.SLACK_USER_TOKEN)
: undefined;
@@ -100,6 +102,11 @@ export function inspectSlackAccount(params: {
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"
@@ -173,8 +180,10 @@ export function inspectSlackAccount(params: {
configured: isHttpMode
? (configBot.status !== "missing" || Boolean(envBot)) &&
configSigningSecret.status !== "missing"
: (configBot.status !== "missing" || Boolean(envBot)) &&
(configApp.status !== "missing" || Boolean(envApp)),
: 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,
+9 -2
View File
@@ -54,6 +54,13 @@ const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("sl
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)
@@ -137,7 +144,7 @@ export function mergeSlackAccountConfig(
channelConfig: cfg.channels?.slack as SlackAccountConfig,
accounts: cfg.channels?.slack?.accounts as Record<string, Partial<SlackAccountConfig>>,
accountId,
nestedObjectKeys: ["botLoopProtection"],
nestedObjectKeys: ["botLoopProtection", "relay"],
});
const streaming = mergeSlackStreamingConfig(
(cfg.channels?.slack as Record<string, unknown> | undefined)?.streaming,
@@ -207,7 +214,7 @@ export function resolveSlackAccount(params: {
const mode = merged.mode ?? "socket";
const baseAllowEnv = accountId === DEFAULT_ACCOUNT_ID;
const botActive = enabled;
const appActive = enabled && mode !== "http";
const appActive = enabled && mode === "socket";
const userActive = enabled;
const envBot =
botActive && baseAllowEnv ? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN) : undefined;
+9 -6
View File
@@ -729,16 +729,19 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
},
resolveAccountSnapshot: ({ account }) => {
const mode = account.config.mode ?? "socket";
const configured =
(mode === "http"
const credentialConfigured =
mode === "http"
? resolveConfiguredFromRequiredCredentialStatuses(account, [
"botTokenStatus",
"signingSecretStatus",
])
: resolveConfiguredFromRequiredCredentialStatuses(account, [
"botTokenStatus",
"appTokenStatus",
])) ?? isSlackPluginAccountConfigured(account);
: mode === "socket"
? resolveConfiguredFromRequiredCredentialStatuses(account, [
"botTokenStatus",
"appTokenStatus",
])
: undefined;
const configured = credentialConfigured ?? isSlackPluginAccountConfigured(account);
return {
accountId: account.accountId,
name: account.name,
@@ -111,6 +111,36 @@ describe("slack config schema", () => {
});
});
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(
{
+16
View File
@@ -82,6 +82,22 @@ export const slackChannelConfigUiHints = {
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.",
@@ -3,6 +3,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const enqueueMock = vi.fn(async (_entry: unknown) => {});
const flushKeyMock = vi.fn(async (_key: string) => {});
const onFlushCallbacks: Array<(entries: Array<Record<string, unknown>>) => Promise<void>> = [];
const prepareSlackMessageMock = vi.fn(async () => ({ ctxPayload: {} }));
const dispatchPreparedSlackMessageMock = vi.fn(async () => {});
const resolveThreadTsMock = vi.fn(async ({ message }: { message: Record<string, unknown> }) => ({
...message,
}));
@@ -14,13 +17,18 @@ vi.mock("openclaw/plugin-sdk/channel-inbound", async () => {
);
return {
...actual,
createChannelInboundDebouncer: () => ({
debounceMs: 10,
debouncer: {
enqueue: (entry: unknown) => enqueueMock(entry),
flushKey: (key: string) => flushKeyMock(key),
},
}),
createChannelInboundDebouncer: (params: {
onFlush: (entries: Array<Record<string, unknown>>) => Promise<void>;
}) => {
onFlushCallbacks.push(params.onFlush);
return {
debounceMs: 10,
debouncer: {
enqueue: (entry: unknown) => enqueueMock(entry),
flushKey: (key: string) => flushKeyMock(key),
},
};
},
shouldDebounceTextInbound: ({ hasMedia }: { hasMedia?: boolean }) => !hasMedia,
};
});
@@ -31,6 +39,16 @@ vi.mock("./thread-resolution.js", () => ({
}),
}));
vi.mock("./message-handler/pipeline.runtime.js", () => ({
prepareSlackMessage: prepareSlackMessageMock,
dispatchPreparedSlackMessage: dispatchPreparedSlackMessageMock,
}));
vi.mock("./inbound-delivery-state.js", () => ({
hasSlackInboundMessageDelivery: vi.fn(async () => false),
recordSlackInboundMessageDeliveries: vi.fn(async () => {}),
}));
function createContext(overrides?: {
markMessageSeen?: (channel: string | undefined, ts: string | undefined) => boolean;
releaseSeenMessage?: (channel: string | undefined, ts: string | undefined) => void;
@@ -80,6 +98,9 @@ describe("createSlackMessageHandler", () => {
beforeEach(() => {
enqueueMock.mockClear();
flushKeyMock.mockClear();
onFlushCallbacks.length = 0;
prepareSlackMessageMock.mockClear();
dispatchPreparedSlackMessageMock.mockClear();
resolveThreadTsMock.mockClear();
});
@@ -201,4 +222,52 @@ describe("createSlackMessageHandler", () => {
expect(flushKeyMock).toHaveBeenCalledWith("slack:default:C111:1709000000.000100:U111");
});
it("waits for debounced dispatch completion when requested by relay delivery", async () => {
const { handler } = createHandlerWithTracker();
const handled = handler(
{
type: "message",
channel: "C111",
user: "U111",
ts: "1709000000.000500",
text: "relay message",
} as never,
{ source: "message", awaitDispatch: true },
);
await vi.waitFor(() => expect(enqueueMock).toHaveBeenCalledTimes(1));
const entry = enqueueMock.mock.calls[0]?.[0] as Record<string, unknown>;
let settled = false;
void handled.then(() => {
settled = true;
});
await Promise.resolve();
expect(settled).toBe(false);
await onFlushCallbacks[0]?.([entry]);
await expect(handled).resolves.toBeUndefined();
expect(dispatchPreparedSlackMessageMock).toHaveBeenCalledTimes(1);
});
it("propagates debounced dispatch failures to relay delivery", async () => {
dispatchPreparedSlackMessageMock.mockRejectedValueOnce(new Error("dispatch failed"));
const { handler } = createHandlerWithTracker();
const handled = handler(
{
type: "message",
channel: "C111",
user: "U111",
ts: "1709000000.000600",
text: "relay message",
} as never,
{ source: "message", awaitDispatch: true },
);
await vi.waitFor(() => expect(enqueueMock).toHaveBeenCalledTimes(1));
const entry = enqueueMock.mock.calls[0]?.[0] as Record<string, unknown>;
const handledFailure = expect(handled).rejects.toThrow("dispatch failed");
const flushFailure = expect(onFlushCallbacks[0]?.([entry])).rejects.toThrow("dispatch failed");
await Promise.all([handledFailure, flushFailure]);
});
});
+147 -85
View File
@@ -9,6 +9,7 @@ import {
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import type { ResolvedSlackAccount } from "../accounts.js";
import type { SlackSendIdentity } from "../send.js";
import type { SlackMessageEvent } from "../types.js";
import { stripSlackMentionsForCommandDetection } from "./commands.js";
import type { SlackMonitorContext } from "./context.js";
@@ -33,9 +34,35 @@ function loadSlackMessagePipeline(): Promise<SlackMessagePipeline> {
export type SlackMessageHandler = (
message: SlackMessageEvent,
opts: { source: "message" | "app_mention"; wasMentioned?: boolean },
opts: {
source: "message" | "app_mention";
wasMentioned?: boolean;
relayIdentity?: SlackSendIdentity;
/** Wait until any inbound debounce flush and dispatch has completed. */
awaitDispatch?: boolean;
},
) => Promise<void>;
type SlackDispatchCompletion = {
promise: Promise<void>;
resolve: () => void;
reject: (error: unknown) => void;
};
type QueuedSlackMessageOptions = Parameters<SlackMessageHandler>[1] & {
dispatchCompletion?: Omit<SlackDispatchCompletion, "promise">;
};
function createSlackDispatchCompletion(): SlackDispatchCompletion {
let resolve!: () => void;
let reject!: (error: unknown) => void;
const promise = new Promise<void>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return { promise, resolve, reject };
}
const APP_MENTION_RETRY_TTL_MS = 60_000;
export class SlackRetryableInboundError extends Error {
@@ -71,103 +98,123 @@ export function createSlackMessageHandler(params: {
const { ctx, account, trackEvent } = params;
const { debounceMs, debouncer } = createChannelInboundDebouncer<{
message: SlackMessageEvent;
opts: { source: "message" | "app_mention"; wasMentioned?: boolean };
opts: QueuedSlackMessageOptions;
}>({
cfg: ctx.cfg,
channel: "slack",
buildKey: (entry) => buildSlackDebounceKey(entry.message, ctx.accountId),
shouldDebounce: (entry) => shouldDebounceSlackMessage(entry.message, ctx.cfg),
onFlush: async (entries) => {
const last = entries.at(-1);
if (!last) {
return;
}
const flushedKey = buildSlackDebounceKey(last.message, ctx.accountId);
const topLevelConversationKey = buildTopLevelSlackConversationKey(
last.message,
ctx.accountId,
);
if (flushedKey && topLevelConversationKey) {
const pendingKeys = pendingTopLevelDebounceKeys.get(topLevelConversationKey);
if (pendingKeys) {
pendingKeys.delete(flushedKey);
if (pendingKeys.size === 0) {
pendingTopLevelDebounceKeys.delete(topLevelConversationKey);
}
}
}
const combinedText =
entries.length === 1
? (last.message.text ?? "")
: entries
.map((entry) => entry.message.text ?? "")
.filter(Boolean)
.join("\n");
const combinedMentioned = entries.some((entry) => Boolean(entry.opts.wasMentioned));
const syntheticMessage: SlackMessageEvent = {
...last.message,
text: combinedText,
};
const seenMessageKey = buildSeenMessageKey(last.message.channel, last.message.ts);
const completions = entries
.map((entry) => entry.opts.dispatchCompletion)
.filter((completion) => completion !== undefined);
try {
const { prepareSlackMessage, dispatchPreparedSlackMessage } =
await loadSlackMessagePipeline();
const prepared = await prepareSlackMessage({
ctx,
account,
message: syntheticMessage,
opts: {
...last.opts,
wasMentioned: combinedMentioned || last.opts.wasMentioned,
},
});
if (!prepared) {
return;
}
if (seenMessageKey) {
pruneAppMentionRetryKeys(Date.now());
if (last.opts.source === "app_mention") {
// If app_mention wins the race and dispatches first, drop the later message dispatch.
rememberExpiringAppMentionKey(appMentionDispatchedKeys, seenMessageKey);
} else if (
last.opts.source === "message" &&
appMentionDispatchedKeys.has(seenMessageKey)
) {
appMentionDispatchedKeys.delete(seenMessageKey);
appMentionRetryKeys.delete(seenMessageKey);
await (async () => {
const last = entries.at(-1);
if (!last) {
return;
}
appMentionRetryKeys.delete(seenMessageKey);
}
if (entries.length > 1) {
const ids = entries.map((entry) => entry.message.ts).filter(Boolean) as string[];
if (ids.length > 0) {
prepared.ctxPayload.MessageSids = ids;
prepared.ctxPayload.MessageSidFirst = ids[0];
prepared.ctxPayload.MessageSidLast = ids[ids.length - 1];
const flushedKey = buildSlackDebounceKey(last.message, ctx.accountId);
const topLevelConversationKey = buildTopLevelSlackConversationKey(
last.message,
ctx.accountId,
);
if (flushedKey && topLevelConversationKey) {
const pendingKeys = pendingTopLevelDebounceKeys.get(topLevelConversationKey);
if (pendingKeys) {
pendingKeys.delete(flushedKey);
if (pendingKeys.size === 0) {
pendingTopLevelDebounceKeys.delete(topLevelConversationKey);
}
}
}
}
try {
await dispatchPreparedSlackMessage(prepared);
await recordSlackInboundMessageDeliveries({
accountId: ctx.accountId,
messages: entries.map((entry) => entry.message),
});
} catch (error) {
if (!(error instanceof SlackRetryableInboundError)) {
await recordSlackInboundMessageDeliveries({
accountId: ctx.accountId,
messages: entries.map((entry) => entry.message),
const combinedText =
entries.length === 1
? (last.message.text ?? "")
: entries
.map((entry) => entry.message.text ?? "")
.filter(Boolean)
.join("\n");
const combinedMentioned = entries.some((entry) => Boolean(entry.opts.wasMentioned));
const syntheticMessage: SlackMessageEvent = {
...last.message,
text: combinedText,
};
const seenMessageKey = buildSeenMessageKey(last.message.channel, last.message.ts);
try {
const { prepareSlackMessage, dispatchPreparedSlackMessage } =
await loadSlackMessagePipeline();
const {
dispatchCompletion: _completion,
awaitDispatch: _awaitDispatch,
...lastOpts
} = last.opts;
const prepared = await prepareSlackMessage({
ctx,
account,
message: syntheticMessage,
opts: {
...lastOpts,
wasMentioned: combinedMentioned || last.opts.wasMentioned,
},
});
if (!prepared) {
return;
}
if (seenMessageKey) {
pruneAppMentionRetryKeys(Date.now());
if (last.opts.source === "app_mention") {
// If app_mention wins the race and dispatches first, drop the later message dispatch.
rememberExpiringAppMentionKey(appMentionDispatchedKeys, seenMessageKey);
} else if (
last.opts.source === "message" &&
appMentionDispatchedKeys.has(seenMessageKey)
) {
appMentionDispatchedKeys.delete(seenMessageKey);
appMentionRetryKeys.delete(seenMessageKey);
return;
}
appMentionRetryKeys.delete(seenMessageKey);
}
if (entries.length > 1) {
const ids = entries.map((entry) => entry.message.ts).filter(Boolean) as string[];
if (ids.length > 0) {
prepared.ctxPayload.MessageSids = ids;
prepared.ctxPayload.MessageSidFirst = ids[0];
prepared.ctxPayload.MessageSidLast = ids[ids.length - 1];
}
}
try {
await dispatchPreparedSlackMessage(prepared);
await recordSlackInboundMessageDeliveries({
accountId: ctx.accountId,
messages: entries.map((entry) => entry.message),
});
} catch (error) {
if (!(error instanceof SlackRetryableInboundError)) {
await recordSlackInboundMessageDeliveries({
accountId: ctx.accountId,
messages: entries.map((entry) => entry.message),
});
}
throw error;
}
} catch (error) {
if (error instanceof SlackRetryableInboundError) {
if (seenMessageKey) {
appMentionDispatchedKeys.delete(seenMessageKey);
}
ctx.releaseSeenMessage(last.message.channel, last.message.ts);
}
throw error;
}
throw error;
})();
for (const completion of completions) {
completion.resolve();
}
} catch (error) {
if (error instanceof SlackRetryableInboundError) {
if (seenMessageKey) {
appMentionDispatchedKeys.delete(seenMessageKey);
}
ctx.releaseSeenMessage(last.message.channel, last.message.ts);
for (const completion of completions) {
completion.reject(error);
}
throw error;
}
@@ -284,6 +331,21 @@ export function createSlackMessageHandler(params: {
pendingKeys.add(debounceKey);
pendingTopLevelDebounceKeys.set(conversationKey, pendingKeys);
}
await debouncer.enqueue({ message: resolvedMessage, opts });
const dispatchCompletion = opts.awaitDispatch ? createSlackDispatchCompletion() : undefined;
await debouncer.enqueue({
message: resolvedMessage,
opts: {
...opts,
...(dispatchCompletion
? {
dispatchCompletion: {
resolve: dispatchCompletion.resolve,
reject: dispatchCompletion.reject,
},
}
: {}),
},
});
await dispatchCompletion?.promise;
};
}
@@ -339,6 +339,7 @@ function createPreparedSlackMessage(params?: {
typingReaction?: string;
ackReactionMessageTs?: string;
ackReactionPromise?: Promise<boolean> | null;
relayIdentity?: { username?: string; iconUrl?: string; iconEmoji?: string };
}) {
const routeSessionKey = params?.route?.sessionKey ?? "agent:agent-1:slack:C123";
const mainSessionKey = params?.route?.mainSessionKey ?? "main";
@@ -373,6 +374,7 @@ function createPreparedSlackMessage(params?: {
accountId: "default",
config: params?.accountConfig ?? {},
},
relayIdentity: params?.relayIdentity,
message,
route: {
agentId: "agent-1",
@@ -1279,6 +1281,27 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
expectDeliverReplyCall(0, FINAL_REPLY_TEXT);
});
it("uses the relay identity when the agent has no explicit Slack identity", async () => {
const relayIdentity = { username: "Nik Team Claw" };
await dispatchPreparedSlackMessage(createPreparedSlackMessage({ relayIdentity }));
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
expectDeliverReplyCall(0, FINAL_REPLY_TEXT, { identity: relayIdentity });
});
it("does not use native Slack streaming when a custom identity is active", async () => {
mockedNativeStreaming = true;
const relayIdentity = { username: "Nik Team Claw" };
await dispatchPreparedSlackMessage(createPreparedSlackMessage({ relayIdentity }));
expect(startSlackStreamMock).not.toHaveBeenCalled();
expect(createSlackDraftStreamMock).toHaveBeenCalledTimes(1);
expect(deliverRepliesMock).toHaveBeenCalledTimes(1);
expectDeliverReplyCall(0, FINAL_REPLY_TEXT, { identity: relayIdentity });
});
it("does not create a Slack thread for top-level messages when replyToMode is off", async () => {
mockedSlackStreamingMode = "off";
mockedSlackIsThreadReply = false;
@@ -466,7 +466,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
iconUrl: outboundIdentity.avatarUrl,
iconEmoji: outboundIdentity.emoji,
}
: undefined;
: prepared.relayIdentity;
if (prepared.isDirectMessage) {
const sessionCfg = cfg.session;
@@ -688,7 +688,11 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
shouldEnableSlackPreviewStreaming({
mode: slackStreaming.mode,
});
// Slack's native streaming APIs do not accept chat:write.customize identity
// fields. Keep custom-identity replies on the draft/standard postMessage
// path so the configured username and icon are not silently discarded.
const streamingEnabled =
!slackIdentity &&
!sourceRepliesAreToolOnly &&
isSlackStreamingEnabled({
mode: slackStreaming.mode,
@@ -40,6 +40,7 @@ import type { ResolvedSlackAccount } from "../../accounts.js";
import { reactSlackMessage } from "../../actions.js";
import { formatSlackError } from "../../errors.js";
import { formatSlackFileReference } from "../../file-reference.js";
import type { SlackSendIdentity } from "../../send.js";
import { hasSlackThreadParticipationWithPersistence } from "../../sent-thread-cache.js";
import type { SlackAttachment, SlackFile, SlackMessageEvent } from "../../types.js";
import { normalizeAllowListLower, normalizeSlackAllowOwnerEntry } from "../allow-list.js";
@@ -619,7 +620,11 @@ export async function prepareSlackMessage(params: {
ctx: SlackMonitorContext;
account: ResolvedSlackAccount;
message: SlackMessageEvent;
opts: { source: "message" | "app_mention"; wasMentioned?: boolean };
opts: {
source: "message" | "app_mention";
wasMentioned?: boolean;
relayIdentity?: SlackSendIdentity;
};
}): Promise<PreparedSlackMessage | null> {
const { ctx, account, message, opts } = params;
const cfg = ctx.cfg;
@@ -1390,6 +1395,7 @@ export async function prepareSlackMessage(params: {
ctx,
account,
message,
...(opts.relayIdentity ? { relayIdentity: opts.relayIdentity } : {}),
route,
channelConfig,
replyTarget,
@@ -4,6 +4,7 @@ import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime";
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
import type { ResolvedSlackAccount } from "../../accounts.js";
import type { SlackSendIdentity } from "../../send.js";
import type { SlackMessageEvent } from "../../types.js";
import type { SlackChannelConfigResolved } from "../channel-config.js";
import type { SlackMonitorContext } from "../context.js";
@@ -12,6 +13,7 @@ export type PreparedSlackMessage = {
ctx: SlackMonitorContext;
account: ResolvedSlackAccount;
message: SlackMessageEvent;
relayIdentity?: SlackSendIdentity;
route: ResolvedAgentRoute;
channelConfig: SlackChannelConfigResolved | null;
replyTarget: string;
@@ -6,6 +6,7 @@ import { formatUnknownError, waitForSlackSocketDisconnect } from "./reconnect-po
type SlackAppConstructor = typeof import("@slack/bolt").App;
type SlackHttpReceiverConstructor = typeof import("@slack/bolt").HTTPReceiver;
type SlackReceiver = import("@slack/bolt").Receiver;
type SlackSocketModeReceiverConstructor = typeof import("@slack/bolt").SocketModeReceiver;
type SlackSocketModeReceiverOptions = ConstructorParameters<SlackSocketModeReceiverConstructor>[0];
type SlackSocketModeConfig = Pick<
@@ -113,6 +114,14 @@ function installSlackNativeReconnectFailureObserver(receiver: unknown) {
);
}
function createSlackRelayReceiver(): SlackReceiver {
return {
init() {},
start: () => Promise.resolve(undefined),
stop: () => Promise.resolve(undefined),
};
}
function resolveSlackBoltModule(value: unknown): SlackBoltResolvedExports | null {
if (!value || typeof value !== "object") {
return null;
@@ -296,7 +305,7 @@ export function shouldSkipOpenClawSlackSelfEvent(args: SlackSelfFilterArgs): boo
export function createSlackBoltApp(params: {
interop: SlackBoltResolvedExports;
slackMode: "socket" | "http";
slackMode: "socket" | "http" | "relay";
botToken: string;
appToken?: string;
signingSecret?: string;
@@ -322,25 +331,31 @@ export function createSlackBoltApp(params: {
socketModeReceiverOptions.pingPongLoggingEnabled = params.socketMode.pingPongLoggingEnabled;
}
const receiver =
params.slackMode === "socket"
? new params.interop.SocketModeReceiver(socketModeReceiverOptions)
: new params.interop.HTTPReceiver({
signingSecret: params.signingSecret ?? "",
endpoints: params.slackWebhookPath,
});
let receiver:
| InstanceType<SlackSocketModeReceiverConstructor>
| InstanceType<SlackHttpReceiverConstructor>
| SlackReceiver
| undefined;
if (params.slackMode === "socket") {
receiver = new params.interop.SocketModeReceiver(socketModeReceiverOptions);
installSlackNativeReconnectFailureObserver(receiver);
} else if (params.slackMode === "http") {
receiver = new params.interop.HTTPReceiver({
signingSecret: params.signingSecret ?? "",
endpoints: params.slackWebhookPath,
});
} else {
receiver = createSlackRelayReceiver();
}
const app = new params.interop.App({
token: params.botToken,
receiver,
clientOptions: params.clientOptions,
ignoreSelf: false,
// Bolt eagerly starts an auth.test promise in the constructor when token
// verification is enabled. Invalid tokens can reject before any listener
// consumes that promise, tripping OpenClaw's fatal unhandled-rejection path.
tokenVerificationEnabled: false,
...(receiver ? { receiver } : {}),
});
app.use(async (args) => {
if (shouldSkipOpenClawSlackSelfEvent(args)) {
+71 -6
View File
@@ -21,7 +21,10 @@ import {
type RuntimeEnv,
} from "openclaw/plugin-sdk/runtime-env";
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
normalizeOptionalString,
normalizeStringEntries,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { installRequestBodyLimitGuard } from "openclaw/plugin-sdk/webhook-request-guards";
import {
resolveSlackAccount,
@@ -67,10 +70,13 @@ import {
SLACK_SOCKET_RECONNECT_POLICY,
waitForSlackSocketDisconnect,
} from "./reconnect-policy.js";
import { setSlackDefaultSendIdentity } from "./send.runtime.js";
import { registerSlackMonitorSlashCommands } from "./slash.js";
import type { MonitorSlackOpts } from "./types.js";
let slackBoltInterop: SlackBoltResolvedExports | undefined;
type SlackRelaySourceModule = typeof import("./relay-source.js");
let slackRelaySourcePromise: Promise<SlackRelaySourceModule> | undefined;
async function getSlackBoltInterop(): Promise<SlackBoltResolvedExports> {
if (!slackBoltInterop) {
@@ -83,6 +89,11 @@ async function getSlackBoltInterop(): Promise<SlackBoltResolvedExports> {
return slackBoltInterop;
}
function loadSlackRelaySource(): Promise<SlackRelaySourceModule> {
slackRelaySourcePromise ??= import("./relay-source.js");
return slackRelaySourcePromise;
}
const SLACK_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
const SLACK_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
@@ -146,6 +157,33 @@ function parseApiAppIdFromAppToken(raw?: string) {
return match?.[1]?.toUpperCase();
}
function resolveSlackRelayConfig(params: { relay: unknown; accountId: string }): {
url: string;
authToken: string;
gatewayId: string;
} {
const relay =
params.relay && typeof params.relay === "object" && !Array.isArray(params.relay)
? (params.relay as Record<string, unknown>)
: {};
const url = normalizeOptionalString(relay.url);
const authToken = normalizeResolvedSecretInputString({
value: relay.authToken,
path: `channels.slack.accounts.${params.accountId}.relay.authToken`,
});
const gatewayId = normalizeOptionalString(relay.gatewayId);
if (!url || !authToken || !gatewayId) {
throw new Error(
`Slack relay mode requires relay.url, relay.authToken, and relay.gatewayId for account "${params.accountId}".`,
);
}
return {
url,
authToken,
gatewayId,
};
}
export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const cfg = opts.config ?? getRuntimeConfig();
const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime();
@@ -188,11 +226,20 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
});
const botToken = resolveSlackBotToken(opts.botToken ?? account.botToken);
const appToken = resolveSlackAppToken(opts.appToken ?? account.appToken);
if (!botToken || (slackMode !== "http" && !appToken)) {
const relayConfig =
slackMode === "relay"
? resolveSlackRelayConfig({
relay: account.config.relay,
accountId: account.accountId,
})
: undefined;
if (!botToken || (slackMode === "socket" && !appToken)) {
const missing =
slackMode === "http"
? `Slack bot token missing for account "${account.accountId}" (set channels.slack.accounts.${account.accountId}.botToken or SLACK_BOT_TOKEN for default).`
: `Slack bot + app tokens missing for account "${account.accountId}" (set channels.slack.accounts.${account.accountId}.botToken/appToken or SLACK_BOT_TOKEN/SLACK_APP_TOKEN for default).`;
: slackMode === "relay"
? `Slack bot token missing for account "${account.accountId}" (set channels.slack.accounts.${account.accountId}.botToken or SLACK_BOT_TOKEN for default).`
: `Slack bot + app tokens missing for account "${account.accountId}" (set channels.slack.accounts.${account.accountId}.botToken/appToken or SLACK_BOT_TOKEN/SLACK_APP_TOKEN for default).`;
throw new Error(missing);
}
if (slackMode === "http" && !signingSecret) {
@@ -246,8 +293,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
interop: await getSlackBoltInterop(),
slackMode,
botToken,
appToken: appToken ?? undefined,
signingSecret: signingSecret ?? undefined,
appToken: slackMode === "socket" ? (appToken ?? undefined) : undefined,
signingSecret: slackMode === "http" ? (signingSecret ?? undefined) : undefined,
slackWebhookPath,
clientOptions: clientOptions as Record<string, unknown>,
...(slackCfg.socketMode ? { socketMode: slackCfg.socketMode } : {}),
@@ -292,7 +339,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
let botId = "";
let teamId = "";
let apiAppId = "";
const expectedApiAppIdFromAppToken = parseApiAppIdFromAppToken(appToken);
const expectedApiAppIdFromAppToken =
slackMode === "socket" ? parseApiAppIdFromAppToken(appToken) : undefined;
let authTestFailed = false;
let authTestError: string | undefined;
try {
@@ -617,6 +665,20 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
continue;
}
}
} else if (slackMode === "relay" && relayConfig) {
runtime.log?.(
`slack relay mode connecting to ${relayConfig.url} gateway_id:${relayConfig.gatewayId}`,
);
await (
await loadSlackRelaySource()
).monitorSlackRelaySource({
config: relayConfig,
handleSlackMessage,
runtime,
abortSignal: opts.abortSignal,
setStatus: opts.setStatus,
setIdentity: (identity) => setSlackDefaultSendIdentity(account.accountId, identity),
});
} else {
runtime.log?.(`slack http mode listening at ${slackWebhookPath}`);
if (!opts.abortSignal?.aborted) {
@@ -628,6 +690,9 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
}
}
} finally {
if (slackMode === "relay") {
setSlackDefaultSendIdentity(account.accountId, undefined);
}
opts.abortSignal?.removeEventListener("abort", stopOnAbort);
unregisterHttpHandler?.();
await gracefulStop();
@@ -0,0 +1,201 @@
import type { AddressInfo } from "node:net";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { describe, expect, it, vi } from "vitest";
import { WebSocketServer } from "ws";
import {
buildRelayWebSocketOptions,
buildRelayWebSocketUrl,
monitorSlackRelaySource,
SLACK_RELAY_MAX_PAYLOAD_BYTES,
type SlackRelayIdentity,
} from "./relay-source.js";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return { promise, reject, resolve };
}
describe("Slack relay source", () => {
it("builds authenticated relay websocket URLs safely", () => {
expect(
buildRelayWebSocketUrl({
url: "https://router.example.com/gateway/ws?existing=1",
authToken: "secret",
gatewayId: "pash",
}),
).toBe("wss://router.example.com/gateway/ws?existing=1&gateway_id=pash");
expect(() =>
buildRelayWebSocketUrl({
url: "ws://router.example.com/gateway/ws",
authToken: "secret",
gatewayId: "pash",
}),
).toThrow("plaintext ws:// for non-local host");
expect(() =>
buildRelayWebSocketUrl({
url: "https://router.example.com",
authToken: "secret",
gatewayId: "pash",
}),
).toThrow("must include its websocket path");
expect(buildRelayWebSocketOptions("secret")).toMatchObject({
headers: { Authorization: "Bearer secret" },
maxPayload: SLACK_RELAY_MAX_PAYLOAD_BYTES,
perMessageDeflate: false,
});
});
it("applies hello identity, dispatches a routed event, and acknowledges its delivery", async () => {
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
await new Promise<void>((resolve) => {
server.once("listening", resolve);
});
const port = (server.address() as AddressInfo).port;
const ack = deferred<Record<string, unknown>>();
const dispatchStarted = deferred<void>();
const dispatchDone = deferred<void>();
const receivedAcks: Array<Record<string, unknown>> = [];
const requestHeaders = deferred<{ authorization?: string; url?: string }>();
server.once("connection", (socket, request) => {
requestHeaders.resolve({
authorization: request.headers.authorization,
url: request.url,
});
socket.on("message", (data) => {
const messageText = Array.isArray(data)
? Buffer.concat(data).toString("utf8")
: data instanceof ArrayBuffer
? Buffer.from(new Uint8Array(data)).toString("utf8")
: Buffer.from(data).toString("utf8");
const frame = JSON.parse(messageText) as Record<string, unknown>;
receivedAcks.push(frame);
ack.resolve(frame);
});
socket.send(
JSON.stringify({
type: "hello",
gateway_id: "pash",
slack_identity: {
username: "Nik Team Claw",
icon_url: "https://example.com/nik.png",
},
}),
);
socket.send("not-json");
socket.send(
JSON.stringify({
type: "slack_event",
delivery_id: "delivery-failed",
route: { kind: "user_group", key: "T1:S1" },
payload: {
event: {
type: "message",
channel: "C1",
user: "U1",
text: "fail-handler",
ts: "1.000000",
},
},
}),
);
socket.send(
JSON.stringify({
type: "slack_event",
delivery_id: "delivery-1",
route: { kind: "channel_default", key: "T1:C1" },
payload: {
team_id: "T1",
event_id: "Ev1",
event: {
type: "message",
channel: "C1",
user: "U1",
text: "hello",
ts: "1.000001",
},
},
}),
);
});
const abortController = new AbortController();
const handleSlackMessage = vi.fn(async (event: { text?: string }) => {
if (event.text === "fail-handler") {
throw new Error("handler failed");
}
dispatchStarted.resolve();
await dispatchDone.promise;
});
const runtimeError = vi.fn();
const identities: Array<SlackRelayIdentity | undefined> = [];
const statuses: Array<Record<string, unknown>> = [];
const monitor = monitorSlackRelaySource({
config: {
url: `ws://127.0.0.1:${port}/gateway/ws`,
authToken: "relay-secret",
gatewayId: "pash",
},
handleSlackMessage,
runtime: { error: runtimeError, log: vi.fn() } as unknown as RuntimeEnv,
abortSignal: abortController.signal,
setIdentity: (identity) => identities.push(identity),
setStatus: (status) => statuses.push(status),
});
await expect(requestHeaders.promise).resolves.toEqual({
authorization: "Bearer relay-secret",
url: "/gateway/ws?gateway_id=pash",
});
await dispatchStarted.promise;
expect(receivedAcks).toEqual([]);
dispatchDone.resolve();
await expect(ack.promise).resolves.toEqual({
type: "ack",
delivery_id: "delivery-1",
});
expect(receivedAcks).toEqual([{ type: "ack", delivery_id: "delivery-1" }]);
expect(runtimeError).toHaveBeenCalledTimes(2);
expect(handleSlackMessage).toHaveBeenCalledWith(
expect.objectContaining({ channel: "C1", text: "hello" }),
{
source: "message",
wasMentioned: true,
awaitDispatch: true,
relayIdentity: {
username: "Nik Team Claw",
iconUrl: "https://example.com/nik.png",
},
},
);
expect(identities).toContainEqual({
username: "Nik Team Claw",
iconUrl: "https://example.com/nik.png",
});
expect(statuses).toContainEqual({
relayRoute: { kind: "channel_default", key: "T1:C1" },
});
abortController.abort();
await monitor;
expect(identities.at(-1)).toBeUndefined();
for (const client of server.clients) {
client.terminate();
}
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
});
});
@@ -0,0 +1,411 @@
// Slack plugin module implements relay-backed inbound event transport.
import { Buffer } from "node:buffer";
import { isIP } from "node:net";
import {
computeBackoff,
sleepWithAbort,
warn,
type RuntimeEnv,
} from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import WebSocket, { type ClientOptions, type RawData } from "ws";
import type { SlackSendIdentity } from "../send.js";
import type { SlackMessageEvent } from "../types.js";
import type { SlackMessageHandler } from "./message-handler.js";
import { formatUnknownError, SLACK_SOCKET_RECONNECT_POLICY } from "./reconnect-policy.js";
export type SlackRelaySourceConfig = {
url: string;
authToken: string;
gatewayId: string;
};
export type SlackRelayIdentity = SlackSendIdentity;
type OpenRelayWebSocket = {
ws: WebSocket;
bufferedMessages: RawData[];
detachBuffer: () => void;
};
type RelayConnectionState = {
identity?: SlackRelayIdentity;
};
const SLACK_RELAY_ROUTE_KINDS = new Set(["user_group", "thread_affinity", "channel_default"]);
export const SLACK_RELAY_MAX_PAYLOAD_BYTES = 1024 * 1024;
export type SlackRelayRoute = {
kind: "user_group" | "thread_affinity" | "channel_default";
key: string;
};
export async function monitorSlackRelaySource(params: {
config: SlackRelaySourceConfig;
handleSlackMessage: SlackMessageHandler;
runtime: RuntimeEnv;
abortSignal?: AbortSignal;
setStatus?: (next: Record<string, unknown>) => void;
setIdentity?: (identity: SlackRelayIdentity | undefined) => void;
}): Promise<void> {
let reconnectAttempts = 0;
while (!params.abortSignal?.aborted) {
let connection: OpenRelayWebSocket | undefined;
try {
connection = await openRelayWebSocket(params.config, params.abortSignal);
reconnectAttempts = 0;
params.setStatus?.({
connected: true,
lastConnectedAt: Date.now(),
healthState: "healthy",
lastError: null,
});
params.runtime.log?.(`slack relay mode connected gateway_id:${params.config.gatewayId}`);
await runRelayWebSocket({
connection,
handleSlackMessage: params.handleSlackMessage,
runtime: params.runtime,
abortSignal: params.abortSignal,
setStatus: params.setStatus,
setIdentity: params.setIdentity,
});
} catch (err) {
if (params.abortSignal?.aborted) {
break;
}
reconnectAttempts += 1;
const delayMs = computeBackoff(SLACK_SOCKET_RECONNECT_POLICY, reconnectAttempts);
params.setStatus?.({
connected: false,
healthState: "disconnected",
lastDisconnect: { at: Date.now(), error: formatUnknownError(err) },
lastError: formatUnknownError(err),
});
params.runtime.log?.(
warn(
`slack relay mode disconnected; reconnecting in ${Math.round(delayMs / 1000)}s ` +
`(attempt ${reconnectAttempts}) ` +
`reason="${formatUnknownError(err)}"`,
),
);
await sleepWithAbort(delayMs, params.abortSignal);
} finally {
closeRelayWebSocket(connection?.ws);
params.setIdentity?.(undefined);
}
}
}
function openRelayWebSocket(
config: SlackRelaySourceConfig,
abortSignal?: AbortSignal,
): Promise<OpenRelayWebSocket> {
if (abortSignal?.aborted) {
return Promise.reject(new Error("Slack relay websocket aborted before connect"));
}
return new Promise((resolve, reject) => {
const url = buildRelayWebSocketUrl(config);
const ws = new WebSocket(url, buildRelayWebSocketOptions(config.authToken));
const bufferedMessages: RawData[] = [];
const onEarlyMessage = (data: RawData) => bufferedMessages.push(data);
const detachBuffer = () => ws.off("message", onEarlyMessage);
ws.on("message", onEarlyMessage);
const cleanup = () => {
ws.off("open", onOpen);
ws.off("error", onError);
ws.off("close", onClose);
abortSignal?.removeEventListener("abort", onAbort);
};
const onOpen = () => {
cleanup();
resolve({ ws, bufferedMessages, detachBuffer });
};
const onError = (error: Error) => {
cleanup();
detachBuffer();
reject(error);
};
const onClose = (code: number, reason: Buffer) => {
cleanup();
detachBuffer();
reject(new Error(formatRelayClose(code, reason)));
};
const onAbort = () => {
cleanup();
detachBuffer();
closeRelayWebSocket(ws);
reject(new Error("Slack relay websocket aborted during connect"));
};
ws.once("open", onOpen);
ws.once("error", onError);
ws.once("close", onClose);
abortSignal?.addEventListener("abort", onAbort, { once: true });
});
}
function runRelayWebSocket(params: {
connection: OpenRelayWebSocket;
handleSlackMessage: SlackMessageHandler;
runtime: RuntimeEnv;
abortSignal?: AbortSignal;
setStatus?: (next: Record<string, unknown>) => void;
setIdentity?: (identity: SlackRelayIdentity | undefined) => void;
}): Promise<void> {
const { ws } = params.connection;
const relayState: RelayConnectionState = {};
let pending = Promise.resolve();
return new Promise((resolve, reject) => {
const cleanup = () => {
ws.off("message", onMessage);
ws.off("error", onError);
ws.off("close", onClose);
params.abortSignal?.removeEventListener("abort", onAbort);
};
const settleResolve = () => {
cleanup();
pending.then(resolve, reject);
};
const settleReject = (error: Error) => {
cleanup();
pending.then(() => reject(error), reject);
};
const onMessage = (data: RawData) => {
pending = pending
.then(() =>
handleRelayFrame({
ws,
data,
handleSlackMessage: params.handleSlackMessage,
relayState,
setStatus: params.setStatus,
setIdentity: params.setIdentity,
}),
)
.catch((err: unknown) => {
params.runtime.error?.(`slack relay frame failed: ${formatUnknownError(err)}`);
});
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
const onClose = (code: number, reason: Buffer) => {
const closeReason = formatRelayClose(code, reason);
params.setStatus?.({
connected: false,
healthState: "disconnected",
lastDisconnect: { at: Date.now(), error: closeReason },
});
settleReject(new Error(closeReason));
};
const onAbort = () => {
closeRelayWebSocket(ws);
settleResolve();
};
params.connection.detachBuffer();
ws.on("message", onMessage);
ws.once("error", onError);
ws.once("close", onClose);
params.abortSignal?.addEventListener("abort", onAbort, { once: true });
for (const message of params.connection.bufferedMessages) {
onMessage(message);
}
});
}
async function handleRelayFrame(params: {
ws: WebSocket;
data: RawData;
handleSlackMessage: SlackMessageHandler;
relayState: RelayConnectionState;
setStatus?: (next: Record<string, unknown>) => void;
setIdentity?: (identity: SlackRelayIdentity | undefined) => void;
}): Promise<void> {
const frame = parseRelayFrame(params.data);
const hello = extractRelayHello(frame);
if (hello) {
params.relayState.identity = hello.identity;
params.setIdentity?.(hello.identity);
params.setStatus?.({ relayIdentity: hello.identity ?? null });
return;
}
const event = extractRelaySlackMessageEvent(frame);
if (!event) {
return;
}
const now = Date.now();
params.setStatus?.({ lastEventAt: now, lastInboundAt: now });
params.setStatus?.({ relayRoute: event.route });
// Relay delivery is already authorized by the router's selected route.
await params.handleSlackMessage(event.message, {
source: "message",
wasMentioned: true,
awaitDispatch: true,
...(params.relayState.identity ? { relayIdentity: params.relayState.identity } : {}),
});
sendRelayAck(params.ws, event.deliveryId);
}
export function buildRelayWebSocketOptions(authToken: string): ClientOptions {
return {
headers: {
Authorization: `Bearer ${authToken}`,
},
handshakeTimeout: 30_000,
maxPayload: SLACK_RELAY_MAX_PAYLOAD_BYTES,
perMessageDeflate: false,
};
}
export function buildRelayWebSocketUrl(config: SlackRelaySourceConfig): string {
const url = new URL(config.url);
if (url.protocol === "http:") {
url.protocol = "ws:";
} else if (url.protocol === "https:") {
url.protocol = "wss:";
}
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
throw new Error(`Slack relay URL must use http(s) or ws(s): ${config.url}`);
}
if (url.protocol === "ws:" && !isLocalRelayHost(url.hostname)) {
throw new Error(
`Slack relay URL uses plaintext ws:// for non-local host "${url.host}". ` +
"Use wss:// for remote relay URLs; ws:// is only allowed for localhost, 127.0.0.1, or [::1].",
);
}
if (!url.pathname || url.pathname === "/") {
throw new Error(`Slack relay URL must include its websocket path: ${config.url}`);
}
url.searchParams.set("gateway_id", config.gatewayId);
return url.toString();
}
function isLocalRelayHost(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase();
const host =
normalized.startsWith("[") && normalized.endsWith("]") ? normalized.slice(1, -1) : normalized;
if (host === "localhost" || host === "::1") {
return true;
}
return isIP(host) === 4 && host.startsWith("127.");
}
function parseRelayFrame(data: RawData): unknown {
const text = rawDataToString(data);
return JSON.parse(text) as unknown;
}
function rawDataToString(data: RawData): string {
if (typeof data === "string") {
return data;
}
if (Buffer.isBuffer(data)) {
return data.toString("utf8");
}
if (Array.isArray(data)) {
return Buffer.concat(data).toString("utf8");
}
return Buffer.from(data).toString("utf8");
}
function extractRelaySlackMessageEvent(
frame: unknown,
): { deliveryId: string; message: SlackMessageEvent; route: SlackRelayRoute } | undefined {
const record = asRecord(frame);
if (!record || record.type !== "slack_event") {
return undefined;
}
const deliveryId = stringValue(record.delivery_id);
const routeRecord = asRecord(record.route);
const routeKind = stringValue(routeRecord?.kind);
const routeKey = stringValue(routeRecord?.key);
const payload = asRecord(record.payload);
const event = asRecord(payload?.event);
if (event?.type !== "message" || typeof event.channel !== "string") {
return undefined;
}
if (!deliveryId || !routeKind || !SLACK_RELAY_ROUTE_KINDS.has(routeKind) || !routeKey) {
return undefined;
}
return {
deliveryId,
message: event as SlackMessageEvent,
route: {
kind: routeKind as SlackRelayRoute["kind"],
key: routeKey,
},
};
}
function extractRelayHello(
frame: unknown,
): { identity: SlackRelayIdentity | undefined } | undefined {
const record = asRecord(frame);
if (!record || record.type !== "hello") {
return undefined;
}
return {
identity: extractRelayIdentity(record),
};
}
function extractRelayIdentity(record: Record<string, unknown>): SlackRelayIdentity | undefined {
const identityRecord = asRecord(record.slack_identity) ?? asRecord(record.slackIdentity);
if (!identityRecord) {
return undefined;
}
const username = normalizeOptionalString(identityRecord.username);
const iconUrl =
normalizeOptionalString(identityRecord.icon_url) ??
normalizeOptionalString(identityRecord.iconUrl);
const iconEmoji =
normalizeOptionalString(identityRecord.icon_emoji) ??
normalizeOptionalString(identityRecord.iconEmoji);
if (!username && !iconUrl && !iconEmoji) {
return undefined;
}
return {
...(username ? { username } : {}),
...(iconUrl ? { iconUrl } : {}),
...(iconEmoji ? { iconEmoji } : {}),
};
}
function sendRelayAck(ws: WebSocket, deliveryId: string): void {
if (ws.readyState !== WebSocket.OPEN) {
return;
}
ws.send(
JSON.stringify({
type: "ack",
delivery_id: deliveryId,
}),
);
}
function closeRelayWebSocket(ws: WebSocket | undefined): void {
if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
return;
}
ws.close();
}
function formatRelayClose(code: number, reason: Buffer): string {
const text = reason.toString("utf8");
return text
? `Slack relay websocket closed (${code} ${text})`
: `Slack relay websocket closed (${code})`;
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function stringValue(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
@@ -1,6 +1,7 @@
// Slack plugin module implements send behavior.
export {
sendMessageSlack,
setSlackDefaultSendIdentity,
type SlackSendIdentity,
type SlackSendResult,
} from "../send.js";
+1 -1
View File
@@ -8,7 +8,7 @@ export type MonitorSlackOpts = {
botToken?: string;
appToken?: string;
accountId?: string;
mode?: "socket" | "http";
mode?: "socket" | "http" | "relay";
config?: OpenClawConfig;
runtime?: RuntimeEnv;
channelRuntime?: ChannelRuntimeSurface;
+61 -5
View File
@@ -1,6 +1,7 @@
// Slack plugin module implements secret contract behavior.
import {
collectConditionalChannelFieldAssignments,
collectNestedChannelFieldAssignments,
collectSimpleChannelFieldAssignments,
getChannelSurface,
hasOwnProperty,
@@ -21,6 +22,17 @@ export const secretTargetRegistryEntries: import("openclaw/plugin-sdk/channel-se
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.slack.accounts.*.relay.authToken",
targetType: "channels.slack.accounts.*.relay.authToken",
configFile: "openclaw.json",
pathPattern: "channels.slack.accounts.*.relay.authToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.slack.accounts.*.botToken",
targetType: "channels.slack.accounts.*.botToken",
@@ -76,6 +88,17 @@ export const secretTargetRegistryEntries: import("openclaw/plugin-sdk/channel-se
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.slack.relay.authToken",
targetType: "channels.slack.relay.authToken",
configFile: "openclaw.json",
pathPattern: "channels.slack.relay.authToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.slack.signingSecret",
targetType: "channels.slack.signingSecret",
@@ -110,7 +133,9 @@ export function collectRuntimeConfigAssignments(params: {
return;
}
const { channel: slack, surface } = resolved;
const baseMode = slack.mode === "http" || slack.mode === "socket" ? slack.mode : "socket";
const resolveMode = (value: unknown) =>
value === "http" || value === "socket" || value === "relay" ? value : undefined;
const baseMode = resolveMode(slack.mode) ?? "socket";
const fields = ["botToken", "userToken"] as const;
for (const field of fields) {
collectSimpleChannelFieldAssignments({
@@ -125,7 +150,16 @@ export function collectRuntimeConfigAssignments(params: {
});
}
const resolveAccountMode = (account: Record<string, unknown>) =>
account.mode === "http" || account.mode === "socket" ? account.mode : baseMode;
resolveMode(account.mode) ?? baseMode;
const hasNestedAuthTokenOverride = (account: Record<string, unknown>) => {
const relay = account.relay;
return (
relay !== null &&
typeof relay === "object" &&
!Array.isArray(relay) &&
hasOwnProperty(relay as Record<string, unknown>, "authToken")
);
};
collectConditionalChannelFieldAssignments({
channelKey: "slack",
field: "appToken",
@@ -133,10 +167,10 @@ export function collectRuntimeConfigAssignments(params: {
surface,
defaults: params.defaults,
context: params.context,
topLevelActiveWithoutAccounts: baseMode !== "http",
topLevelActiveWithoutAccounts: baseMode === "socket",
topLevelInheritedAccountActive: ({ account, enabled }) =>
enabled && !hasOwnProperty(account, "appToken") && resolveAccountMode(account) !== "http",
accountActive: ({ account, enabled }) => enabled && resolveAccountMode(account) !== "http",
enabled && !hasOwnProperty(account, "appToken") && resolveAccountMode(account) === "socket",
accountActive: ({ account, enabled }) => enabled && resolveAccountMode(account) === "socket",
topInactiveReason: "no enabled Slack socket-mode surface inherits this top-level appToken.",
accountInactiveReason: "Slack account is disabled or not running in socket mode.",
});
@@ -156,6 +190,28 @@ export function collectRuntimeConfigAssignments(params: {
topInactiveReason: "no enabled Slack HTTP-mode surface inherits this top-level signingSecret.",
accountInactiveReason: "Slack account is disabled or not running in HTTP mode.",
});
collectNestedChannelFieldAssignments({
channelKey: "slack",
nestedKey: "relay",
field: "authToken",
channel: slack,
surface,
defaults: params.defaults,
context: params.context,
topLevelActive:
surface.channelEnabled &&
((!surface.hasExplicitAccounts && baseMode === "relay") ||
surface.accounts.some(
({ account, enabled }) =>
enabled &&
resolveAccountMode(account) === "relay" &&
!hasNestedAuthTokenOverride(account),
)),
topInactiveReason:
"no enabled Slack relay-mode surface inherits this top-level relay authToken.",
accountActive: ({ account, enabled }) => enabled && resolveAccountMode(account) === "relay",
accountInactiveReason: "Slack account is disabled or not running in relay mode.",
});
}
export const channelSecrets = {
@@ -9,7 +9,8 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
shouldLogVerbose: () => false,
}));
const { sendMessageSlack } = await import("./send.js");
const { clearSlackDefaultSendIdentitiesForTest, sendMessageSlack, setSlackDefaultSendIdentity } =
await import("./send.js");
const SLACK_TEST_CFG = { channels: { slack: { botToken: "xoxb-test" } } };
type SlackMissingScopeError = Error & {
@@ -59,6 +60,51 @@ function readPostMessagePayload(
describe("sendMessageSlack customize-scope fallback", () => {
beforeEach(() => {
vi.mocked(logVerbose).mockClear();
clearSlackDefaultSendIdentitiesForTest();
});
it("uses the relay-provided default identity", async () => {
const client = createSlackSendTestClient();
vi.mocked(client.chat.postMessage).mockResolvedValueOnce({ ts: "171234.567" });
setSlackDefaultSendIdentity("default", {
username: "Nik Team Claw",
iconUrl: "https://example.com/nik.png",
});
await sendMessageSlack("channel:C123", "hello", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
});
expect(readPostMessagePayload(client, 0)).toEqual({
channel: "C123",
text: "hello",
username: "Nik Team Claw",
icon_url: "https://example.com/nik.png",
unfurl_links: false,
});
});
it("prefers an explicit send identity over the relay default", async () => {
const client = createSlackSendTestClient();
vi.mocked(client.chat.postMessage).mockResolvedValueOnce({ ts: "171234.567" });
setSlackDefaultSendIdentity("default", { username: "Nik Team Claw" });
await sendMessageSlack("channel:C123", "hello", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
identity: { username: "Explicit Bot", iconEmoji: ":robot_face:" },
});
expect(readPostMessagePayload(client, 0)).toEqual({
channel: "C123",
text: "hello",
username: "Explicit Bot",
icon_emoji: ":robot_face:",
unfurl_links: false,
});
});
it("retries without identity when needed contains chat:write.customize", async () => {
+52 -3
View File
@@ -65,6 +65,8 @@ export type SlackSendIdentity = {
iconEmoji?: string;
};
const slackDefaultSendIdentities = new Map<string, SlackSendIdentity>();
type SlackUnfurlOptions = {
unfurlLinks?: boolean;
unfurlMedia?: boolean;
@@ -131,6 +133,45 @@ function hasCustomIdentity(identity?: SlackSendIdentity): boolean {
return Boolean(identity?.username || identity?.iconUrl || identity?.iconEmoji);
}
function normalizeSlackSendIdentity(identity?: SlackSendIdentity): SlackSendIdentity | undefined {
const username = normalizeOptionalString(identity?.username);
const iconUrl = normalizeOptionalString(identity?.iconUrl);
const iconEmoji = normalizeOptionalString(identity?.iconEmoji);
const normalized = {
...(username ? { username } : {}),
...(iconUrl ? { iconUrl } : {}),
...(iconEmoji ? { iconEmoji } : {}),
};
return hasCustomIdentity(normalized) ? normalized : undefined;
}
export function setSlackDefaultSendIdentity(accountId: string, identity?: SlackSendIdentity): void {
const normalizedAccountId = normalizeOptionalString(accountId);
if (!normalizedAccountId) {
return;
}
const normalizedIdentity = normalizeSlackSendIdentity(identity);
if (normalizedIdentity) {
slackDefaultSendIdentities.set(normalizedAccountId, normalizedIdentity);
} else {
slackDefaultSendIdentities.delete(normalizedAccountId);
}
}
export function getSlackDefaultSendIdentity(accountId: string): SlackSendIdentity | undefined {
const normalizedAccountId = normalizeOptionalString(accountId);
return normalizedAccountId ? slackDefaultSendIdentities.get(normalizedAccountId) : undefined;
}
function resolveSlackSendIdentity(params: {
accountId: string;
explicit?: SlackSendIdentity;
}): SlackSendIdentity | undefined {
return (
normalizeSlackSendIdentity(params.explicit) ?? getSlackDefaultSendIdentity(params.accountId)
);
}
function buildSlackUnfurlPayload(options?: SlackUnfurlOptions) {
return {
// Default unfurl_links to false so bot messages don't expand inline
@@ -549,6 +590,10 @@ export function clearSlackSendQueuesForTest(): void {
slackSendQueues.clear();
}
export function clearSlackDefaultSendIdentitiesForTest(): void {
slackDefaultSendIdentities.clear();
}
async function uploadSlackFile(params: {
client: WebClient;
channelId: string;
@@ -706,6 +751,10 @@ async function sendMessageSlackQueuedInner(params: {
}): Promise<SlackSendResult> {
const { opts, cfg, account, token, recipient, blocks, trimmedMessage } = params;
const client = opts.client ?? getSlackWriteClient(token);
const identity = resolveSlackSendIdentity({
accountId: account.accountId,
explicit: opts.identity,
});
if (opts.replyBroadcast && opts.mediaUrl) {
throw new Error("Slack replyBroadcast is only supported for text or block thread replies.");
}
@@ -738,7 +787,7 @@ async function sendMessageSlackQueuedInner(params: {
text: fallbackText,
threadTs: opts.threadTs,
replyBroadcast: opts.replyBroadcast,
identity: opts.identity,
identity,
blocks,
metadata: opts.metadata,
unfurl,
@@ -809,7 +858,7 @@ async function sendMessageSlackQueuedInner(params: {
text: chunk,
threadTs: opts.threadTs,
replyBroadcast: sentMessageIds.length === 0 ? opts.replyBroadcast : undefined,
identity: opts.identity,
identity,
metadata: sentMessageIds.length === 0 ? opts.metadata : undefined,
unfurl,
});
@@ -828,7 +877,7 @@ async function sendMessageSlackQueuedInner(params: {
text: chunk,
threadTs: opts.threadTs,
replyBroadcast: sentMessageIds.length === 0 ? opts.replyBroadcast : undefined,
identity: opts.identity,
identity,
metadata: sentMessageIds.length === 0 ? opts.metadata : undefined,
unfurl,
});
+4
View File
@@ -3,6 +3,7 @@ import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
import { patchChannelConfigForAccount } from "openclaw/plugin-sdk/setup-runtime";
import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
import { isSlackPluginAccountConfigured } from "./account-configured.js";
import type { ResolvedSlackAccount } from "./accounts.js";
import type { OpenClawConfig } from "./channel-api.js";
@@ -133,6 +134,9 @@ export function setSlackChannelAllowlist(
}
export function isSlackSetupAccountConfigured(account: ResolvedSlackAccount): boolean {
if (account.config.mode === "relay") {
return isSlackPluginAccountConfigured(account);
}
const hasConfiguredBotToken =
Boolean(account.botToken?.trim()) || hasConfiguredSecretInput(account.config.botToken);
const hasConfiguredAppToken =
+3
View File
@@ -1465,6 +1465,9 @@ importers:
typebox:
specifier: 1.1.39
version: 1.1.39
ws:
specifier: 8.21.0
version: 8.21.0
zod:
specifier: 4.4.3
version: 4.4.3
+3 -3
View File
@@ -77,7 +77,7 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
"approval-reply-runtime": 1,
"config-runtime": 123,
"config-contracts": 1,
"config-types": 415,
"config-types": 416,
"config-schema": 3,
"reply-dedupe": 1,
"inbound-reply-dispatch": 33,
@@ -163,11 +163,11 @@ let publicDeprecatedExportsByEntrypointBudget;
try {
budgets = {
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 321),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10337),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10338),
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5187),
publicDeprecatedExports: readBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
3245,
3246,
),
publicWildcardReexports: readBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_WILDCARD_REEXPORTS",
File diff suppressed because one or more lines are too long
+13 -2
View File
@@ -139,13 +139,24 @@ export type SlackSocketModeConfig = {
pingPongLoggingEnabled?: boolean;
};
export type SlackRelayConfig = {
/** Full relay websocket URL, including the route path. */
url?: string;
/** Bearer token used to authenticate the gateway websocket to the Slack relay. */
authToken?: string;
/** Gateway destination id registered with openclaw-slack-router. */
gatewayId?: string;
};
export type SlackAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** Slack connection mode (socket|http). Default: socket. */
mode?: "socket" | "http";
/** Slack connection mode (socket|http|relay). Default: socket. */
mode?: "socket" | "http" | "relay";
/** Slack SDK Socket Mode transport options. Ignored in HTTP mode. */
socketMode?: SlackSocketModeConfig;
/** Relay-delivered Slack event source. Used when mode is "relay". */
relay?: SlackRelayConfig;
/** Slack signing secret (required for HTTP mode). */
signingSecret?: string;
/** Slack Events API webhook path (default: /slack/events). */
+49 -2
View File
@@ -8,6 +8,7 @@ import {
normalizeSlashCommandName,
resolveCustomCommands,
} from "../shared/custom-command-config.js";
import { hasConfiguredSecretInput } from "./types.secrets.js";
import { ToolPolicySchema } from "./zod-schema.agent-runtime.js";
import { NativeExecApprovalEnableModeSchema } from "./zod-schema.approvals.js";
import {
@@ -954,11 +955,20 @@ export const SlackSocketModeSchema = z
})
.strict();
export const SlackRelaySchema = z
.object({
url: z.string().optional(),
authToken: SecretInputSchema.optional().register(sensitive),
gatewayId: z.string().optional(),
})
.strict();
export const SlackAccountSchema = z
.object({
name: z.string().optional(),
mode: z.enum(["socket", "http"]).optional(),
mode: z.enum(["socket", "http", "relay"]).optional(),
socketMode: SlackSocketModeSchema.optional(),
relay: SlackRelaySchema.optional(),
signingSecret: SecretInputSchema.optional().register(sensitive),
webhookPath: z.string().optional(),
capabilities: SlackCapabilitiesSchema.optional(),
@@ -1042,7 +1052,7 @@ export const SlackAccountSchema = z
});
export const SlackConfigSchema = SlackAccountSchema.safeExtend({
mode: z.enum(["socket", "http"]).optional().default("socket"),
mode: z.enum(["socket", "http", "relay"]).optional().default("socket"),
signingSecret: SecretInputSchema.optional().register(sensitive),
webhookPath: z.string().optional().default("/slack/events"),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
@@ -1072,8 +1082,38 @@ export const SlackConfigSchema = SlackAccountSchema.safeExtend({
'channels.slack.dmPolicy="allowlist" requires channels.slack.allowFrom (or channels.slack.dm.allowFrom) to contain at least one sender ID',
});
const requireRelayConfig = (
relay: { url?: unknown; authToken?: unknown; gatewayId?: unknown } | undefined,
path: (string | number)[],
) => {
if (typeof relay?.url !== "string" || !relay.url.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'channels.slack.mode="relay" requires relay.url',
path: [...path, "url"],
});
}
if (!hasConfiguredSecretInput(relay?.authToken)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'channels.slack.mode="relay" requires relay.authToken',
path: [...path, "authToken"],
});
}
if (typeof relay?.gatewayId !== "string" || !relay.gatewayId.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'channels.slack.mode="relay" requires relay.gatewayId',
path: [...path, "gatewayId"],
});
}
};
const baseMode = value.mode ?? "socket";
if (!value.accounts) {
if (baseMode === "relay") {
requireRelayConfig(value.relay, ["relay"]);
}
validateSlackSigningSecretRequirements(value, ctx);
return;
}
@@ -1085,6 +1125,10 @@ export const SlackConfigSchema = SlackAccountSchema.safeExtend({
continue;
}
const accountMode = account.mode ?? baseMode;
const effectiveRelay = {
...value.relay,
...account.relay,
};
const effectivePolicy =
account.dmPolicy ?? account.dm?.policy ?? value.dmPolicy ?? value.dm?.policy ?? "pairing";
const effectiveAllowFrom =
@@ -1106,6 +1150,9 @@ export const SlackConfigSchema = SlackAccountSchema.safeExtend({
'channels.slack.accounts.*.dmPolicy="allowlist" requires channels.slack.accounts.*.allowFrom (or channels.slack.allowFrom) to contain at least one sender ID',
});
if (accountMode !== "http") {
if (accountMode === "relay") {
requireRelayConfig(effectiveRelay, ["accounts", accountId, "relay"]);
}
continue;
}
}
@@ -78,7 +78,9 @@ export function validateSlackSigningSecretRequirements(
value: SlackConfigLike,
ctx: z.RefinementCtx,
): void {
const baseMode = value.mode === "http" || value.mode === "socket" ? value.mode : "socket";
const resolveMode = (mode: unknown) =>
mode === "http" || mode === "socket" || mode === "relay" ? mode : undefined;
const baseMode = resolveMode(value.mode) ?? "socket";
if (baseMode === "http" && !hasConfiguredSecretInput(value.signingSecret)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -87,8 +89,7 @@ export function validateSlackSigningSecretRequirements(
});
}
forEachEnabledAccount(value.accounts, (accountId, account) => {
const accountMode =
account.mode === "http" || account.mode === "socket" ? account.mode : baseMode;
const accountMode = resolveMode(account.mode) ?? baseMode;
if (accountMode !== "http") {
return;
}
+6
View File
@@ -557,6 +557,12 @@ function applyConfigForOpenClawTarget(
if (entry.id === "channels.slack.accounts.*.signingSecret") {
setPathCreateStrict(config, ["channels", "slack", "accounts", wildcardToken, "mode"], "http");
}
if (entry.id === "channels.slack.relay.authToken") {
setPathCreateStrict(config, ["channels", "slack", "mode"], "relay");
}
if (entry.id === "channels.slack.accounts.*.relay.authToken") {
setPathCreateStrict(config, ["channels", "slack", "accounts", wildcardToken, "mode"], "relay");
}
if (entry.id === "channels.zalo.webhookSecret") {
setPathCreateStrict(config, ["channels", "zalo", "webhookUrl"], "https://example.com/hook");
}