Skip to content

Commit 5a1ff13

Browse files
committed
fix(slack): bound inbound media downloads
1 parent a722da3 commit 5a1ff13

5 files changed

Lines changed: 190 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
2121
- 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.
2222
- 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.
2323
- 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.
24+
- Slack/media: bound private file and forwarded attachment downloads with idle and total timeouts while preserving placeholder fallback, so stalled Slack `file_share` media no longer wedges inbound message handling. Fixes #61850. Thanks @bassboy2k.
2425
- 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.
2526
- 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.
2627
- 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.

docs/channels/slack.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,8 @@ Notes:
632632
<Accordion title="Inbound attachments">
633633
Slack file attachments are downloaded from Slack-hosted private URLs (token-authenticated request flow) and written to the media store when fetch succeeds and size limits permit. File placeholders include the Slack `fileId` so agents can fetch the original file with `download-file`.
634634

635+
Downloads use bounded idle and total timeouts. If Slack file retrieval stalls or fails, OpenClaw keeps processing the message and falls back to the file placeholder.
636+
635637
Runtime inbound size cap defaults to `20MB` unless overridden by `channels.slack.mediaMaxMb`.
636638

637639
</Accordion>

docs/gateway/config-channels.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,11 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
390390
enabled: true,
391391
botToken: "xoxb-...",
392392
appToken: "xapp-...",
393+
socketMode: {
394+
clientPingTimeout: 15000,
395+
serverPingTimeout: 30000,
396+
pingPongLoggingEnabled: false,
397+
},
393398
dmPolicy: "pairing",
394399
allowFrom: ["U123", "U456", "*"],
395400
dm: { enabled: true, groupEnabled: false, groupChannels: ["G123"] },
@@ -448,6 +453,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
448453

449454
- **Socket mode** requires both `botToken` and `appToken` (`SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` for default account env fallback).
450455
- **HTTP mode** requires `botToken` plus `signingSecret` (at root or per-account).
456+
- `socketMode` passes Slack SDK Socket Mode transport tuning through to the public Bolt receiver API. Use it only when investigating ping/pong timeout or stale websocket behavior.
451457
- `botToken`, `appToken`, `signingSecret`, and `userToken` accept plaintext
452458
strings or SecretRef objects.
453459
- Slack account snapshots expose per-credential source/status fields such as

extensions/slack/src/monitor/media.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
resolveSlackThreadHistory,
77
resolveSlackThreadStarter,
88
resetSlackThreadStarterCacheForTest,
9+
SLACK_MEDIA_READ_IDLE_TIMEOUT_MS,
910
} from "./media.js";
1011
import type { FetchLike, SavedMedia } from "./media.runtime.js";
1112
import * as mediaRuntime from "./media.runtime.js";
@@ -19,7 +20,10 @@ const fetchRemoteMediaMock = vi.hoisted(() =>
1920
url: string;
2021
fetchImpl: FetchLike;
2122
filePathHint?: string;
23+
maxBytes?: number;
24+
readIdleTimeoutMs?: number;
2225
requestInit?: RequestInit;
26+
ssrfPolicy?: unknown;
2327
}) => {
2428
let response = await params.fetchImpl(params.url, {
2529
...params.requestInit,
@@ -355,6 +359,65 @@ describe("resolveSlackMedia", () => {
355359
expect(result).toBeNull();
356360
});
357361

362+
it("passes bounded media download timeouts while preserving Slack auth", async () => {
363+
vi.spyOn(mediaRuntime, "saveMediaBuffer").mockResolvedValue(
364+
createSavedMedia("/tmp/test.jpg", "image/jpeg"),
365+
);
366+
mockFetch.mockResolvedValueOnce(
367+
new Response(Buffer.from("image data"), {
368+
status: 200,
369+
headers: { "content-type": "image/jpeg" },
370+
}),
371+
);
372+
373+
const result = await resolveSlackMedia({
374+
files: [{ url_private: "https://files.slack.com/test.jpg", name: "test.jpg" }],
375+
token: "xoxb-test-token",
376+
maxBytes: 1024 * 1024,
377+
});
378+
379+
expect(result).not.toBeNull();
380+
const fetchOptions = fetchRemoteMediaMock.mock.calls[0]?.[0];
381+
expect(fetchOptions?.readIdleTimeoutMs).toBe(SLACK_MEDIA_READ_IDLE_TIMEOUT_MS);
382+
expect(fetchOptions?.requestInit?.signal).toBeInstanceOf(AbortSignal);
383+
expect(new Headers(fetchOptions?.requestInit?.headers).get("Authorization")).toBe(
384+
"Bearer xoxb-test-token",
385+
);
386+
});
387+
388+
it("returns null when a media download exceeds the total timeout", async () => {
389+
vi.useFakeTimers();
390+
try {
391+
let abortSignal: AbortSignal | undefined;
392+
fetchRemoteMediaMock.mockImplementationOnce(
393+
(params) =>
394+
new Promise<never>((_resolve, reject) => {
395+
abortSignal = params.requestInit?.signal ?? undefined;
396+
abortSignal?.addEventListener(
397+
"abort",
398+
() => {
399+
reject(Object.assign(new Error("aborted"), { name: "AbortError" }));
400+
},
401+
{ once: true },
402+
);
403+
}),
404+
);
405+
406+
const resultPromise = resolveSlackMedia({
407+
files: [{ url_private: "https://files.slack.com/slow.jpg", name: "slow.jpg" }],
408+
token: "xoxb-test-token",
409+
maxBytes: 1024 * 1024,
410+
totalTimeoutMs: 25,
411+
});
412+
413+
await vi.advanceTimersByTimeAsync(25);
414+
await expect(resultPromise).resolves.toBeNull();
415+
expect(abortSignal?.aborted).toBe(true);
416+
} finally {
417+
vi.useRealTimers();
418+
}
419+
});
420+
358421
it("returns null when no files are provided", async () => {
359422
const result = await resolveSlackMedia({
360423
files: [],

extensions/slack/src/monitor/media.ts

Lines changed: 118 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,92 @@ const SLACK_MEDIA_SSRF_POLICY = {
146146
hostnameAllowlist: ["*.slack.com", "*.slack-edge.com", "*.slack-files.com"],
147147
allowRfc2544BenchmarkRange: true,
148148
};
149+
export const SLACK_MEDIA_READ_IDLE_TIMEOUT_MS = 60_000;
150+
export const SLACK_MEDIA_TOTAL_TIMEOUT_MS = 120_000;
151+
type SlackFetchRemoteMediaOptions = Parameters<typeof fetchRemoteMedia>[0];
152+
153+
function mergeAbortSignals(signals: Array<AbortSignal | undefined>): AbortSignal | undefined {
154+
const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal));
155+
if (activeSignals.length === 0) {
156+
return undefined;
157+
}
158+
if (activeSignals.length === 1) {
159+
return activeSignals[0];
160+
}
161+
if (typeof AbortSignal.any === "function") {
162+
return AbortSignal.any(activeSignals);
163+
}
164+
const controller = new AbortController();
165+
for (const signal of activeSignals) {
166+
if (signal.aborted) {
167+
controller.abort();
168+
return controller.signal;
169+
}
170+
}
171+
const abort = () => {
172+
controller.abort();
173+
for (const signal of activeSignals) {
174+
signal.removeEventListener("abort", abort);
175+
}
176+
};
177+
for (const signal of activeSignals) {
178+
signal.addEventListener("abort", abort, { once: true });
179+
}
180+
return controller.signal;
181+
}
182+
183+
async function fetchSlackMedia(params: {
184+
options: SlackFetchRemoteMediaOptions;
185+
readIdleTimeoutMs?: number;
186+
totalTimeoutMs?: number;
187+
abortSignal?: AbortSignal;
188+
}): ReturnType<typeof fetchRemoteMedia> {
189+
const timeoutAbortController = params.totalTimeoutMs ? new AbortController() : undefined;
190+
const signal = mergeAbortSignals([
191+
params.abortSignal,
192+
params.options.requestInit?.signal ?? undefined,
193+
timeoutAbortController?.signal,
194+
]);
195+
let timedOut = false;
196+
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
197+
198+
const fetchPromise = fetchRemoteMedia({
199+
...params.options,
200+
readIdleTimeoutMs: params.readIdleTimeoutMs ?? SLACK_MEDIA_READ_IDLE_TIMEOUT_MS,
201+
...(signal
202+
? {
203+
requestInit: {
204+
...params.options.requestInit,
205+
signal,
206+
},
207+
}
208+
: {}),
209+
}).catch((error) => {
210+
if (timedOut) {
211+
return new Promise<never>(() => {});
212+
}
213+
throw error;
214+
});
215+
216+
try {
217+
if (!params.totalTimeoutMs) {
218+
return await fetchPromise;
219+
}
220+
const timeoutPromise = new Promise<never>((_, reject) => {
221+
timeoutHandle = setTimeout(() => {
222+
timedOut = true;
223+
timeoutAbortController?.abort();
224+
reject(new Error(`slack media download timed out after ${params.totalTimeoutMs}ms`));
225+
}, params.totalTimeoutMs);
226+
timeoutHandle.unref?.();
227+
});
228+
return await Promise.race([fetchPromise, timeoutPromise]);
229+
} finally {
230+
if (timeoutHandle) {
231+
clearTimeout(timeoutHandle);
232+
}
233+
}
234+
}
149235

150236
/**
151237
* Slack voice messages (audio clips, huddle recordings) carry a `subtype` of
@@ -229,6 +315,9 @@ export async function resolveSlackMedia(params: {
229315
files?: SlackFile[];
230316
token: string;
231317
maxBytes: number;
318+
readIdleTimeoutMs?: number;
319+
totalTimeoutMs?: number;
320+
abortSignal?: AbortSignal;
232321
}): Promise<SlackMediaResult[] | null> {
233322
const files = params.files ?? [];
234323
const limitedFiles =
@@ -245,13 +334,18 @@ export async function resolveSlackMedia(params: {
245334
try {
246335
const { url: slackUrl, requestInit } = createSlackMediaRequest(url, params.token);
247336
const fetchImpl = createSlackMediaFetch();
248-
const fetched = await fetchRemoteMedia({
249-
url: slackUrl,
250-
fetchImpl,
251-
requestInit,
252-
filePathHint: file.name,
253-
maxBytes: params.maxBytes,
254-
ssrfPolicy: SLACK_MEDIA_SSRF_POLICY,
337+
const fetched = await fetchSlackMedia({
338+
options: {
339+
url: slackUrl,
340+
fetchImpl,
341+
requestInit,
342+
filePathHint: file.name,
343+
maxBytes: params.maxBytes,
344+
ssrfPolicy: SLACK_MEDIA_SSRF_POLICY,
345+
},
346+
readIdleTimeoutMs: params.readIdleTimeoutMs,
347+
totalTimeoutMs: params.totalTimeoutMs ?? SLACK_MEDIA_TOTAL_TIMEOUT_MS,
348+
abortSignal: params.abortSignal,
255349
});
256350
if (fetched.buffer.byteLength > params.maxBytes) {
257351
return null;
@@ -299,6 +393,9 @@ export async function resolveSlackAttachmentContent(params: {
299393
attachments?: SlackAttachment[];
300394
token: string;
301395
maxBytes: number;
396+
readIdleTimeoutMs?: number;
397+
totalTimeoutMs?: number;
398+
abortSignal?: AbortSignal;
302399
}): Promise<{ text: string; media: SlackMediaResult[] } | null> {
303400
const attachments = params.attachments;
304401
if (!attachments || attachments.length === 0) {
@@ -328,12 +425,17 @@ export async function resolveSlackAttachmentContent(params: {
328425
try {
329426
const { url: slackUrl, requestInit } = createSlackMediaRequest(imageUrl, params.token);
330427
const fetchImpl = createSlackMediaFetch();
331-
const fetched = await fetchRemoteMedia({
332-
url: slackUrl,
333-
fetchImpl,
334-
requestInit,
335-
maxBytes: params.maxBytes,
336-
ssrfPolicy: SLACK_MEDIA_SSRF_POLICY,
428+
const fetched = await fetchSlackMedia({
429+
options: {
430+
url: slackUrl,
431+
fetchImpl,
432+
requestInit,
433+
maxBytes: params.maxBytes,
434+
ssrfPolicy: SLACK_MEDIA_SSRF_POLICY,
435+
},
436+
readIdleTimeoutMs: params.readIdleTimeoutMs,
437+
totalTimeoutMs: params.totalTimeoutMs ?? SLACK_MEDIA_TOTAL_TIMEOUT_MS,
438+
abortSignal: params.abortSignal,
337439
});
338440
if (fetched.buffer.byteLength <= params.maxBytes) {
339441
const saved = await saveMediaBuffer(
@@ -359,6 +461,9 @@ export async function resolveSlackAttachmentContent(params: {
359461
files: att.files,
360462
token: params.token,
361463
maxBytes: params.maxBytes,
464+
readIdleTimeoutMs: params.readIdleTimeoutMs,
465+
totalTimeoutMs: params.totalTimeoutMs,
466+
abortSignal: params.abortSignal,
362467
});
363468
if (fileMedia) {
364469
allMedia.push(...fileMedia);

0 commit comments

Comments
 (0)