fix(slack): tune socket mode pong timeout

This commit is contained in:
Peter Steinberger
2026-04-28 11:12:45 +01:00
parent c9ead1b928
commit 6cc6996a1c
11 changed files with 202 additions and 9 deletions

View File

@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
- Tasks/media: infer agent ownership for session-scoped task records so `/tasks` agent-local fallback includes session-backed `video_generate` and other async media jobs even when the current chat session has no linked rows. Thanks @vincentkoc.
- Agents/media: keep long-running `video_generate` and `music_generate` tasks fresh while provider jobs are still pending, so task maintenance does not mark active Discord media renders lost before completion. Thanks @vincentkoc.
- CLI/status: treat scope-limited gateway probes as reachable-but-degraded in shared status scans, so `openclaw status --all` no longer reports a live gateway as unreachable after `missing scope: operator.read`. Fixes #49180; supersedes #47981. Thanks @openjay.
- Slack/Socket Mode: use a 15s Slack SDK pong timeout by default and add `channels.slack.socketMode.clientPingTimeout`, `serverPingTimeout`, and `pingPongLoggingEnabled` overrides so stale-websocket handling no longer depends on app-event health heuristics. Fixes #14248; refs #58519, #64009, and #63488. Thanks @shivasymbl and @freerk.
- Plugins/inspector: keep bundled plugin runtime capture quiet and config-tolerant for Codex, memory-lancedb, Feishu, Mattermost, QQBot, and Tlon so plugin-inspector JSON checks can validate the full bundled set. Thanks @vincentkoc.
- Slack/auto-reply: keep fully consumed text reset triggers such as `new session` out of `BodyForAgent` after directive cleanup, so configured Slack reset phrases do not leak into the fresh model turn. Fixes #73137. Thanks @neeravmakwana.
- Plugins/runtime deps: prune stale retained bundled runtime deps and keep doctor/secret channel contract scans on lightweight artifacts, so disabled bundled channels stop preserving old dependency trees or importing heavy plugin surfaces. Thanks @SymbolStar and @vincentkoc.

View File

@@ -1,4 +1,4 @@
b1d76b9451b21434325e64d5bb531b9b995ba3bbf8f7b1628c09cce18f24c8e2 config-baseline.json
78888d302b2263583430e41b9811277aab91937201d4de90cfbd5761e9b95727 config-baseline.json
58e98b59498060d301104b3772332de5600eb674687b06d0d32a202370709ee0 config-baseline.core.json
a9f058ee9616e189dab7fc223e1207a49ae52b8490b8028935c9d0a2b16f81b2 config-baseline.channel.json
323a9fd49a669951ca5b3442d95aad243bd1330083f9857e83a8dcfae2bbc9d0 config-baseline.channel.json
1f5592bfd141ba1e982ce31763a253c10afb080ab4ea2b6538299b114e29cee1 config-baseline.plugin.json

View File

@@ -117,6 +117,27 @@ openclaw gateway
</Tab>
</Tabs>
## Socket Mode transport tuning
OpenClaw sets the Slack SDK client pong timeout to 15 seconds by default for Socket Mode. Override the transport settings only when you need workspace- or host-specific tuning:
```json5
{
channels: {
slack: {
mode: "socket",
socketMode: {
clientPingTimeout: 20000,
serverPingTimeout: 30000,
pingPongLoggingEnabled: false,
},
},
},
}
```
Use this only for Socket Mode workspaces that log Slack websocket pong/server-ping timeouts or run on hosts with known event-loop starvation. `clientPingTimeout` is the pong wait after the SDK sends a client ping; `serverPingTimeout` is the wait for Slack server pings. App messages and events remain application state, not transport liveness signals.
## Manifest and scope checklist
The base Slack app manifest is the same for Socket Mode and HTTP Request URLs. Only the `settings` block (and the slash command `url`) differs.

View File

@@ -63,6 +63,35 @@ describe("slack config schema", () => {
});
});
it("accepts Socket Mode ping/pong transport tuning", () => {
expectSlackConfigValid({
mode: "socket",
socketMode: {
clientPingTimeout: 15_000,
serverPingTimeout: 45_000,
pingPongLoggingEnabled: true,
},
accounts: {
ops: {
socketMode: {
clientPingTimeout: 20_000,
},
},
},
});
});
it("rejects invalid Socket Mode ping/pong transport tuning", () => {
expectSlackConfigIssue(
{
socketMode: {
clientPingTimeout: 0,
},
},
"socketMode.clientPingTimeout",
);
});
it("accepts account-level user token config", () => {
expectSlackConfigValid({
accounts: {

View File

@@ -29,6 +29,22 @@ export const slackChannelConfigUiHints = {
label: "Slack Allow Bot Messages",
help: "Allow bot-authored messages to trigger Slack replies (default: false).",
},
socketMode: {
label: "Slack Socket Mode Transport",
help: "Slack Socket Mode transport tuning passed to the Slack SDK. Use only when investigating ping/pong timeout or stale websocket behavior.",
},
"socketMode.clientPingTimeout": {
label: "Slack Socket Mode Pong Timeout",
help: "Milliseconds the Slack SDK waits for a pong after its client ping before treating the websocket as stale (OpenClaw default: 15000). Increase on hosts with event-loop starvation or slow network scheduling.",
},
"socketMode.serverPingTimeout": {
label: "Slack Socket Mode Server Ping Timeout",
help: "Milliseconds the Slack SDK waits for Slack server pings before treating the websocket as stale.",
},
"socketMode.pingPongLoggingEnabled": {
label: "Slack Socket Mode Ping/Pong Logging",
help: "Enable Slack SDK ping/pong transport logs while debugging Socket Mode websocket health.",
},
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.",

View File

@@ -5,6 +5,13 @@ import { formatUnknownError, waitForSlackSocketDisconnect } from "./reconnect-po
type SlackAppConstructor = typeof import("@slack/bolt").App;
type SlackHttpReceiverConstructor = typeof import("@slack/bolt").HTTPReceiver;
type SlackSocketModeReceiverConstructor = typeof import("@slack/bolt").SocketModeReceiver;
type SlackSocketModeReceiverOptions = ConstructorParameters<SlackSocketModeReceiverConstructor>[0];
type SlackSocketModeConfig = Pick<
SlackSocketModeReceiverOptions,
"clientPingTimeout" | "serverPingTimeout" | "pingPongLoggingEnabled"
>;
export const OPENCLAW_SLACK_CLIENT_PING_TIMEOUT_MS = 15_000;
export type SlackBoltResolvedExports = {
App: SlackAppConstructor;
@@ -167,16 +174,27 @@ export function createSlackBoltApp(params: {
signingSecret?: string;
slackWebhookPath: string;
clientOptions: Record<string, unknown>;
socketMode?: SlackSocketModeConfig;
}) {
const socketModeReceiverOptions: SlackSocketModeReceiverOptions = {
appToken: params.appToken ?? "",
autoReconnectEnabled: false,
clientPingTimeout:
params.socketMode?.clientPingTimeout ?? OPENCLAW_SLACK_CLIENT_PING_TIMEOUT_MS,
installerOptions: {
clientOptions: params.clientOptions,
},
};
if (params.socketMode?.serverPingTimeout !== undefined) {
socketModeReceiverOptions.serverPingTimeout = params.socketMode.serverPingTimeout;
}
if (params.socketMode?.pingPongLoggingEnabled !== undefined) {
socketModeReceiverOptions.pingPongLoggingEnabled = params.socketMode.pingPongLoggingEnabled;
}
const receiver =
params.slackMode === "socket"
? new params.interop.SocketModeReceiver({
appToken: params.appToken ?? "",
autoReconnectEnabled: false,
installerOptions: {
clientOptions: params.clientOptions,
},
})
? new params.interop.SocketModeReceiver(socketModeReceiverOptions)
: new params.interop.HTTPReceiver({
signingSecret: params.signingSecret ?? "",
endpoints: params.slackWebhookPath,

View File

@@ -158,6 +158,7 @@ describe("createSlackBoltApp", () => {
expect((receiver as unknown as FakeSocketModeReceiver).args).toEqual({
appToken: "xapp-test",
autoReconnectEnabled: false,
clientPingTimeout: 15_000,
installerOptions: {
clientOptions,
},
@@ -173,6 +174,38 @@ describe("createSlackBoltApp", () => {
expect((app as unknown as FakeApp).middleware).toHaveLength(1);
});
it("passes Socket Mode ping/pong options through Slack's public receiver API", () => {
const clientOptions = { teamId: "T1" };
const { receiver } = createSlackBoltApp({
interop: {
App: FakeApp as never,
HTTPReceiver: FakeHTTPReceiver as never,
SocketModeReceiver: FakeSocketModeReceiver as never,
},
slackMode: "socket",
botToken: "xoxb-test",
appToken: "xapp-test",
slackWebhookPath: "/slack/events",
clientOptions,
socketMode: {
clientPingTimeout: 20_000,
serverPingTimeout: 45_000,
pingPongLoggingEnabled: true,
},
});
expect((receiver as unknown as FakeSocketModeReceiver).args).toEqual({
appToken: "xapp-test",
autoReconnectEnabled: false,
clientPingTimeout: 20_000,
serverPingTimeout: 45_000,
pingPongLoggingEnabled: true,
installerOptions: {
clientOptions,
},
});
});
it("uses HTTPReceiver for webhook mode", () => {
const clientOptions = { teamId: "T1" };
const { app, receiver } = createSlackBoltApp({

View File

@@ -192,6 +192,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
signingSecret: signingSecret ?? undefined,
slackWebhookPath,
clientOptions: clientOptions as Record<string, unknown>,
...(slackCfg.socketMode ? { socketMode: slackCfg.socketMode } : {}),
});
// Pre-set shuttingDown on the SocketModeClient before app.stop() to prevent

View File

@@ -11018,6 +11018,25 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
type: "string",
enum: ["socket", "http"],
},
socketMode: {
type: "object",
properties: {
clientPingTimeout: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
serverPingTimeout: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
pingPongLoggingEnabled: {
type: "boolean",
},
},
additionalProperties: false,
},
signingSecret: {
anyOf: [
{
@@ -11932,6 +11951,25 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
type: "string",
enum: ["socket", "http"],
},
socketMode: {
type: "object",
properties: {
clientPingTimeout: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
serverPingTimeout: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
pingPongLoggingEnabled: {
type: "boolean",
},
},
additionalProperties: false,
},
signingSecret: {
anyOf: [
{
@@ -12870,6 +12908,22 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
label: "Slack Allow Bot Messages",
help: "Allow bot-authored messages to trigger Slack replies (default: false).",
},
socketMode: {
label: "Slack Socket Mode Transport",
help: "Slack Socket Mode transport tuning passed to the Slack SDK. Use only when investigating ping/pong timeout or stale websocket behavior.",
},
"socketMode.clientPingTimeout": {
label: "Slack Socket Mode Pong Timeout",
help: "Milliseconds the Slack SDK waits for a pong after its client ping before treating the websocket as stale (OpenClaw default: 15000). Increase on hosts with event-loop starvation or slow network scheduling.",
},
"socketMode.serverPingTimeout": {
label: "Slack Socket Mode Server Ping Timeout",
help: "Milliseconds the Slack SDK waits for Slack server pings before treating the websocket as stale.",
},
"socketMode.pingPongLoggingEnabled": {
label: "Slack Socket Mode Ping/Pong Logging",
help: "Enable Slack SDK ping/pong transport logs while debugging Socket Mode websocket health.",
},
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.",

View File

@@ -106,11 +106,22 @@ export type SlackThreadConfig = {
requireExplicitMention?: boolean;
};
export type SlackSocketModeConfig = {
/** Slack SDK pong timeout in milliseconds. Socket Mode only. Default: 15000. */
clientPingTimeout?: number;
/** Slack SDK server ping timeout in milliseconds. Socket Mode only. */
serverPingTimeout?: number;
/** Enable Slack SDK ping/pong transport logging. Socket Mode only. */
pingPongLoggingEnabled?: boolean;
};
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 SDK Socket Mode transport options. Ignored in HTTP mode. */
socketMode?: SlackSocketModeConfig;
/** Slack signing secret (required for HTTP mode). */
signingSecret?: string;
/** Slack Events API webhook path (default: /slack/events). */

View File

@@ -895,10 +895,19 @@ const SlackReplyToModeByChatTypeSchema = z
})
.strict();
export const SlackSocketModeSchema = z
.object({
clientPingTimeout: z.number().int().positive().optional(),
serverPingTimeout: z.number().int().positive().optional(),
pingPongLoggingEnabled: z.boolean().optional(),
})
.strict();
export const SlackAccountSchema = z
.object({
name: z.string().optional(),
mode: z.enum(["socket", "http"]).optional(),
socketMode: SlackSocketModeSchema.optional(),
signingSecret: SecretInputSchema.optional().register(sensitive),
webhookPath: z.string().optional(),
capabilities: SlackCapabilitiesSchema.optional(),