Skip to content

Commit 6fa944e

Browse files
authored
[codex] Add Slack relay mode for incoming messages (#94707)
1 parent 4c453c9 commit 6fa944e

34 files changed

Lines changed: 1359 additions & 154 deletions

docs/channels/slack.md

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
---
2-
summary: "Slack setup and runtime behavior (Socket Mode + HTTP Request URLs)"
2+
summary: "Slack setup and runtime behavior (Socket Mode, HTTP Request URLs, and relay mode)"
33
read_when:
4-
- Setting up Slack or debugging Slack socket/HTTP mode
4+
- Setting up Slack or debugging Slack socket, HTTP, or relay mode
55
title: "Slack"
66
---
77

8-
Production-ready for DMs and channels via Slack app integrations. Default mode is Socket Mode; HTTP Request URLs are also supported.
8+
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.
99

1010
<CardGroup cols={3}>
1111
<Card title="Pairing" icon="link" href="/channels/pairing">
@@ -41,6 +41,37 @@ Both transports are production-ready and reach feature parity for messaging, sla
4141
**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.
4242
</Note>
4343

44+
### Relay mode
45+
46+
Relay mode separates Slack ingress from the OpenClaw gateway. A trusted router owns the
47+
single Slack Socket Mode connection, chooses a destination gateway, and forwards a typed
48+
event over an authenticated websocket. The gateway continues to use its bot token for
49+
outbound Slack Web API calls.
50+
51+
```json5
52+
{
53+
channels: {
54+
slack: {
55+
mode: "relay",
56+
botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" },
57+
relay: {
58+
url: "wss://router.example.com/gateway/ws",
59+
authToken: { source: "env", provider: "default", id: "SLACK_RELAY_AUTH_TOKEN" },
60+
gatewayId: "team-gateway",
61+
},
62+
},
63+
},
64+
}
65+
```
66+
67+
The relay URL must use `wss://` unless it targets localhost. Treat the bearer token and
68+
router route table as part of the Slack authorization boundary: routed events enter the
69+
normal Slack message handler as authorized activations. A router-provided `slack_identity`
70+
in the websocket `hello` frame can set the default outbound username and icon; an explicit
71+
identity supplied by the caller still wins. The relay connection reconnects with the same
72+
bounded backoff timing used by Socket Mode and clears the router-provided identity whenever
73+
it disconnects.
74+
4475
## Install
4576

4677
Install Slack before configuring the channel:
@@ -863,7 +894,8 @@ The default manifest enables the Slack App Home **Home** tab and subscribes to `
863894

864895
- `botToken` + `appToken` are required for Socket Mode.
865896
- HTTP mode requires `botToken` + `signingSecret`.
866-
- `botToken`, `appToken`, `signingSecret`, and `userToken` accept plaintext
897+
- Relay mode requires `botToken` plus `relay.url`, `relay.authToken`, and `relay.gatewayId`; it does not use an app token or signing secret.
898+
- `botToken`, `appToken`, `signingSecret`, `relay.authToken`, and `userToken` accept plaintext
867899
strings or SecretRef objects.
868900
- Config tokens override env fallback.
869901
- `SLACK_BOT_TOKEN` / `SLACK_APP_TOKEN` env fallback applies only to the default account.

docs/reference/secretref-credential-surface.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,12 @@ Scope intent:
7272
- `channels.telegram.accounts.*.webhookSecret`
7373
- `channels.slack.botToken`
7474
- `channels.slack.appToken`
75+
- `channels.slack.relay.authToken`
7576
- `channels.slack.userToken`
7677
- `channels.slack.signingSecret`
7778
- `channels.slack.accounts.*.botToken`
7879
- `channels.slack.accounts.*.appToken`
80+
- `channels.slack.accounts.*.relay.authToken`
7981
- `channels.slack.accounts.*.userToken`
8082
- `channels.slack.accounts.*.signingSecret`
8183
- `channels.sms.authToken`

docs/reference/secretref-user-supplied-credentials-matrix.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,13 @@
295295
"secretShape": "secret_input",
296296
"optIn": true
297297
},
298+
{
299+
"id": "channels.slack.accounts.*.relay.authToken",
300+
"configFile": "openclaw.json",
301+
"path": "channels.slack.accounts.*.relay.authToken",
302+
"secretShape": "secret_input",
303+
"optIn": true
304+
},
298305
{
299306
"id": "channels.slack.accounts.*.signingSecret",
300307
"configFile": "openclaw.json",
@@ -323,6 +330,13 @@
323330
"secretShape": "secret_input",
324331
"optIn": true
325332
},
333+
{
334+
"id": "channels.slack.relay.authToken",
335+
"configFile": "openclaw.json",
336+
"path": "channels.slack.relay.authToken",
337+
"secretShape": "secret_input",
338+
"optIn": true
339+
},
326340
{
327341
"id": "channels.slack.signingSecret",
328342
"configFile": "openclaw.json",

extensions/slack/npm-shrinkwrap.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

extensions/slack/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"@slack/types": "2.21.1",
1313
"@slack/web-api": "7.16.0",
1414
"typebox": "1.1.39",
15+
"ws": "8.21.0",
1516
"zod": "4.4.3"
1617
},
1718
"devDependencies": {

extensions/slack/src/account-configured.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,13 @@ export function isSlackPluginAccountConfigured(account: ResolvedSlackAccount): b
1111
if (mode === "http") {
1212
return hasConfiguredAccountValue(account.config.signingSecret);
1313
}
14+
if (mode === "relay") {
15+
const relay = account.config.relay;
16+
return (
17+
hasConfiguredAccountValue(relay?.url) &&
18+
hasConfiguredAccountValue(relay?.authToken) &&
19+
hasConfiguredAccountValue(relay?.gatewayId)
20+
);
21+
}
1422
return Boolean(account.appToken?.trim());
1523
}

extensions/slack/src/account-inspect.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export function inspectSlackAccount(params: {
8080
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
8181
const mode = merged.mode ?? "socket";
8282
const isHttpMode = mode === "http";
83+
const isRelayMode = mode === "relay";
8384

8485
const configBot = inspectSlackToken(merged.botToken);
8586
const configApp = inspectSlackToken(merged.appToken);
@@ -89,9 +90,10 @@ export function inspectSlackAccount(params: {
8990
const envBot = allowEnv
9091
? normalizeSecretInputString(params.envBotToken ?? process.env.SLACK_BOT_TOKEN)
9192
: undefined;
92-
const envApp = allowEnv
93-
? normalizeSecretInputString(params.envAppToken ?? process.env.SLACK_APP_TOKEN)
94-
: undefined;
93+
const envApp =
94+
allowEnv && !isRelayMode
95+
? normalizeSecretInputString(params.envAppToken ?? process.env.SLACK_APP_TOKEN)
96+
: undefined;
9597
const envUser = allowEnv
9698
? normalizeSecretInputString(params.envUserToken ?? process.env.SLACK_USER_TOKEN)
9799
: undefined;
@@ -100,6 +102,11 @@ export function inspectSlackAccount(params: {
100102
const appToken = configApp.token ?? envApp;
101103
const signingSecret = configSigningSecret.token;
102104
const userToken = configUser.token ?? envUser;
105+
const relayConfigured =
106+
isRelayMode &&
107+
Boolean(normalizeOptionalString(merged.relay?.url)) &&
108+
hasConfiguredSecretInput(merged.relay?.authToken) &&
109+
Boolean(normalizeOptionalString(merged.relay?.gatewayId));
103110
const botTokenSource: SlackTokenSource = configBot.token
104111
? "config"
105112
: configBot.status === "configured_unavailable"
@@ -173,8 +180,10 @@ export function inspectSlackAccount(params: {
173180
configured: isHttpMode
174181
? (configBot.status !== "missing" || Boolean(envBot)) &&
175182
configSigningSecret.status !== "missing"
176-
: (configBot.status !== "missing" || Boolean(envBot)) &&
177-
(configApp.status !== "missing" || Boolean(envApp)),
183+
: isRelayMode
184+
? (configBot.status !== "missing" || Boolean(envBot)) && relayConfigured
185+
: (configBot.status !== "missing" || Boolean(envBot)) &&
186+
(configApp.status !== "missing" || Boolean(envApp)),
178187
config: merged,
179188
groupPolicy: merged.groupPolicy,
180189
textChunkLimit: merged.textChunkLimit,

extensions/slack/src/accounts.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("sl
5454
if (slack?.mode === "http") {
5555
return hasConfiguredAccountValue(slack.signingSecret);
5656
}
57+
if (slack?.mode === "relay") {
58+
return (
59+
hasConfiguredAccountValue(slack.relay?.url) &&
60+
hasConfiguredAccountValue(slack.relay?.authToken) &&
61+
hasConfiguredAccountValue(slack.relay?.gatewayId)
62+
);
63+
}
5764
return (
5865
hasConfiguredAccountValue(slack?.appToken) ||
5966
hasConfiguredAccountValue(process.env.SLACK_APP_TOKEN)
@@ -137,7 +144,7 @@ export function mergeSlackAccountConfig(
137144
channelConfig: cfg.channels?.slack as SlackAccountConfig,
138145
accounts: cfg.channels?.slack?.accounts as Record<string, Partial<SlackAccountConfig>>,
139146
accountId,
140-
nestedObjectKeys: ["botLoopProtection"],
147+
nestedObjectKeys: ["botLoopProtection", "relay"],
141148
});
142149
const streaming = mergeSlackStreamingConfig(
143150
(cfg.channels?.slack as Record<string, unknown> | undefined)?.streaming,
@@ -207,7 +214,7 @@ export function resolveSlackAccount(params: {
207214
const mode = merged.mode ?? "socket";
208215
const baseAllowEnv = accountId === DEFAULT_ACCOUNT_ID;
209216
const botActive = enabled;
210-
const appActive = enabled && mode !== "http";
217+
const appActive = enabled && mode === "socket";
211218
const userActive = enabled;
212219
const envBot =
213220
botActive && baseAllowEnv ? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN) : undefined;

extensions/slack/src/channel.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -729,16 +729,19 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
729729
},
730730
resolveAccountSnapshot: ({ account }) => {
731731
const mode = account.config.mode ?? "socket";
732-
const configured =
733-
(mode === "http"
732+
const credentialConfigured =
733+
mode === "http"
734734
? resolveConfiguredFromRequiredCredentialStatuses(account, [
735735
"botTokenStatus",
736736
"signingSecretStatus",
737737
])
738-
: resolveConfiguredFromRequiredCredentialStatuses(account, [
739-
"botTokenStatus",
740-
"appTokenStatus",
741-
])) ?? isSlackPluginAccountConfigured(account);
738+
: mode === "socket"
739+
? resolveConfiguredFromRequiredCredentialStatuses(account, [
740+
"botTokenStatus",
741+
"appTokenStatus",
742+
])
743+
: undefined;
744+
const configured = credentialConfigured ?? isSlackPluginAccountConfigured(account);
742745
return {
743746
accountId: account.accountId,
744747
name: account.name,

extensions/slack/src/config-schema.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,36 @@ describe("slack config schema", () => {
111111
});
112112
});
113113

114+
it("accepts relay mode with a SecretInput auth token", () => {
115+
expectSlackConfigValid({
116+
mode: "relay",
117+
botToken: "xoxb-any",
118+
relay: {
119+
url: "wss://router.example.com/gateway/ws",
120+
authToken: { source: "env", provider: "default", id: "SLACK_RELAY_AUTH_TOKEN" },
121+
gatewayId: "team-gateway",
122+
},
123+
});
124+
});
125+
126+
it("requires every relay connection field", () => {
127+
expectSlackConfigIssue({ mode: "relay" }, "relay.url");
128+
expectSlackConfigIssue(
129+
{ mode: "relay", relay: { url: "wss://router.example.com/gateway/ws" } },
130+
"relay.authToken",
131+
);
132+
expectSlackConfigIssue(
133+
{
134+
mode: "relay",
135+
relay: {
136+
url: "wss://router.example.com/gateway/ws",
137+
authToken: "secret",
138+
},
139+
},
140+
"relay.gatewayId",
141+
);
142+
});
143+
114144
it("rejects invalid Socket Mode ping/pong transport tuning", () => {
115145
expectSlackConfigIssue(
116146
{

0 commit comments

Comments
 (0)