mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
[codex] Add Slack relay mode for incoming messages (#94707)
This commit is contained in:
+36
-4
@@ -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",
|
||||
|
||||
Generated
+1
@@ -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": {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
@@ -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
@@ -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). */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user