From 6cc6996a1c50030c93f01b2e741956da628caa66 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 11:12:45 +0100 Subject: [PATCH] fix(slack): tune socket mode pong timeout --- CHANGELOG.md | 1 + docs/.generated/config-baseline.sha256 | 4 +- docs/channels/slack.md | 21 ++++++++ extensions/slack/src/config-schema.test.ts | 29 ++++++++++ extensions/slack/src/config-ui-hints.ts | 16 ++++++ .../slack/src/monitor/provider-support.ts | 32 ++++++++--- .../src/monitor/provider.interop.test.ts | 33 ++++++++++++ extensions/slack/src/monitor/provider.ts | 1 + ...ndled-channel-config-metadata.generated.ts | 54 +++++++++++++++++++ src/config/types.slack.ts | 11 ++++ src/config/zod-schema.providers-core.ts | 9 ++++ 11 files changed, 202 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1174a24b93c..3752d85d72f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 7e9f4a75492..8e4668c95e5 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -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 diff --git a/docs/channels/slack.md b/docs/channels/slack.md index e3fa1f24eaf..0eaef6997fa 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -117,6 +117,27 @@ openclaw gateway +## 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. diff --git a/extensions/slack/src/config-schema.test.ts b/extensions/slack/src/config-schema.test.ts index 663f3980f7b..a18e4693ee0 100644 --- a/extensions/slack/src/config-schema.test.ts +++ b/extensions/slack/src/config-schema.test.ts @@ -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: { diff --git a/extensions/slack/src/config-ui-hints.ts b/extensions/slack/src/config-ui-hints.ts index ab131418210..bd7aab07ac1 100644 --- a/extensions/slack/src/config-ui-hints.ts +++ b/extensions/slack/src/config-ui-hints.ts @@ -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.", diff --git a/extensions/slack/src/monitor/provider-support.ts b/extensions/slack/src/monitor/provider-support.ts index 3f3eed65bdb..8d3669f50e1 100644 --- a/extensions/slack/src/monitor/provider-support.ts +++ b/extensions/slack/src/monitor/provider-support.ts @@ -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[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; + 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, diff --git a/extensions/slack/src/monitor/provider.interop.test.ts b/extensions/slack/src/monitor/provider.interop.test.ts index 78ed608eb7c..76815a96618 100644 --- a/extensions/slack/src/monitor/provider.interop.test.ts +++ b/extensions/slack/src/monitor/provider.interop.test.ts @@ -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({ diff --git a/extensions/slack/src/monitor/provider.ts b/extensions/slack/src/monitor/provider.ts index 3719cbdffc1..060da1a23a9 100644 --- a/extensions/slack/src/monitor/provider.ts +++ b/extensions/slack/src/monitor/provider.ts @@ -192,6 +192,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { signingSecret: signingSecret ?? undefined, slackWebhookPath, clientOptions: clientOptions as Record, + ...(slackCfg.socketMode ? { socketMode: slackCfg.socketMode } : {}), }); // Pre-set shuttingDown on the SocketModeClient before app.stop() to prevent diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index 129505c987b..eda626c8fd9 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -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.", diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index 4e5af109e51..ed13b881842 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -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). */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index d962e6a835d..228cfe4f05f 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -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(),