fix(slack): bound inbound media downloads

This commit is contained in:
Peter Steinberger
2026-04-28 11:35:20 +01:00
parent a722da3ed0
commit 5a1ff1347d
5 changed files with 190 additions and 13 deletions

View File

@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- 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

@@ -632,6 +632,8 @@ Notes:
<Accordion title="Inbound attachments">
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`.
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.
Runtime inbound size cap defaults to `20MB` unless overridden by `channels.slack.mediaMaxMb`.
</Accordion>

View File

@@ -390,6 +390,11 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
enabled: true,
botToken: "xoxb-...",
appToken: "xapp-...",
socketMode: {
clientPingTimeout: 15000,
serverPingTimeout: 30000,
pingPongLoggingEnabled: false,
},
dmPolicy: "pairing",
allowFrom: ["U123", "U456", "*"],
dm: { enabled: true, groupEnabled: false, groupChannels: ["G123"] },
@@ -448,6 +453,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
- **Socket mode** requires both `botToken` and `appToken` (`SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` for default account env fallback).
- **HTTP mode** requires `botToken` plus `signingSecret` (at root or per-account).
- `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.
- `botToken`, `appToken`, `signingSecret`, and `userToken` accept plaintext
strings or SecretRef objects.
- Slack account snapshots expose per-credential source/status fields such as

View File

@@ -6,6 +6,7 @@ import {
resolveSlackThreadHistory,
resolveSlackThreadStarter,
resetSlackThreadStarterCacheForTest,
SLACK_MEDIA_READ_IDLE_TIMEOUT_MS,
} from "./media.js";
import type { FetchLike, SavedMedia } from "./media.runtime.js";
import * as mediaRuntime from "./media.runtime.js";
@@ -19,7 +20,10 @@ const fetchRemoteMediaMock = vi.hoisted(() =>
url: string;
fetchImpl: FetchLike;
filePathHint?: string;
maxBytes?: number;
readIdleTimeoutMs?: number;
requestInit?: RequestInit;
ssrfPolicy?: unknown;
}) => {
let response = await params.fetchImpl(params.url, {
...params.requestInit,
@@ -355,6 +359,65 @@ describe("resolveSlackMedia", () => {
expect(result).toBeNull();
});
it("passes bounded media download timeouts while preserving Slack auth", async () => {
vi.spyOn(mediaRuntime, "saveMediaBuffer").mockResolvedValue(
createSavedMedia("/tmp/test.jpg", "image/jpeg"),
);
mockFetch.mockResolvedValueOnce(
new Response(Buffer.from("image data"), {
status: 200,
headers: { "content-type": "image/jpeg" },
}),
);
const result = await resolveSlackMedia({
files: [{ url_private: "https://files.slack.com/test.jpg", name: "test.jpg" }],
token: "xoxb-test-token",
maxBytes: 1024 * 1024,
});
expect(result).not.toBeNull();
const fetchOptions = fetchRemoteMediaMock.mock.calls[0]?.[0];
expect(fetchOptions?.readIdleTimeoutMs).toBe(SLACK_MEDIA_READ_IDLE_TIMEOUT_MS);
expect(fetchOptions?.requestInit?.signal).toBeInstanceOf(AbortSignal);
expect(new Headers(fetchOptions?.requestInit?.headers).get("Authorization")).toBe(
"Bearer xoxb-test-token",
);
});
it("returns null when a media download exceeds the total timeout", async () => {
vi.useFakeTimers();
try {
let abortSignal: AbortSignal | undefined;
fetchRemoteMediaMock.mockImplementationOnce(
(params) =>
new Promise<never>((_resolve, reject) => {
abortSignal = params.requestInit?.signal ?? undefined;
abortSignal?.addEventListener(
"abort",
() => {
reject(Object.assign(new Error("aborted"), { name: "AbortError" }));
},
{ once: true },
);
}),
);
const resultPromise = resolveSlackMedia({
files: [{ url_private: "https://files.slack.com/slow.jpg", name: "slow.jpg" }],
token: "xoxb-test-token",
maxBytes: 1024 * 1024,
totalTimeoutMs: 25,
});
await vi.advanceTimersByTimeAsync(25);
await expect(resultPromise).resolves.toBeNull();
expect(abortSignal?.aborted).toBe(true);
} finally {
vi.useRealTimers();
}
});
it("returns null when no files are provided", async () => {
const result = await resolveSlackMedia({
files: [],

View File

@@ -146,6 +146,92 @@ const SLACK_MEDIA_SSRF_POLICY = {
hostnameAllowlist: ["*.slack.com", "*.slack-edge.com", "*.slack-files.com"],
allowRfc2544BenchmarkRange: true,
};
export const SLACK_MEDIA_READ_IDLE_TIMEOUT_MS = 60_000;
export const SLACK_MEDIA_TOTAL_TIMEOUT_MS = 120_000;
type SlackFetchRemoteMediaOptions = Parameters<typeof fetchRemoteMedia>[0];
function mergeAbortSignals(signals: Array<AbortSignal | undefined>): AbortSignal | undefined {
const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal));
if (activeSignals.length === 0) {
return undefined;
}
if (activeSignals.length === 1) {
return activeSignals[0];
}
if (typeof AbortSignal.any === "function") {
return AbortSignal.any(activeSignals);
}
const controller = new AbortController();
for (const signal of activeSignals) {
if (signal.aborted) {
controller.abort();
return controller.signal;
}
}
const abort = () => {
controller.abort();
for (const signal of activeSignals) {
signal.removeEventListener("abort", abort);
}
};
for (const signal of activeSignals) {
signal.addEventListener("abort", abort, { once: true });
}
return controller.signal;
}
async function fetchSlackMedia(params: {
options: SlackFetchRemoteMediaOptions;
readIdleTimeoutMs?: number;
totalTimeoutMs?: number;
abortSignal?: AbortSignal;
}): ReturnType<typeof fetchRemoteMedia> {
const timeoutAbortController = params.totalTimeoutMs ? new AbortController() : undefined;
const signal = mergeAbortSignals([
params.abortSignal,
params.options.requestInit?.signal ?? undefined,
timeoutAbortController?.signal,
]);
let timedOut = false;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const fetchPromise = fetchRemoteMedia({
...params.options,
readIdleTimeoutMs: params.readIdleTimeoutMs ?? SLACK_MEDIA_READ_IDLE_TIMEOUT_MS,
...(signal
? {
requestInit: {
...params.options.requestInit,
signal,
},
}
: {}),
}).catch((error) => {
if (timedOut) {
return new Promise<never>(() => {});
}
throw error;
});
try {
if (!params.totalTimeoutMs) {
return await fetchPromise;
}
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
timedOut = true;
timeoutAbortController?.abort();
reject(new Error(`slack media download timed out after ${params.totalTimeoutMs}ms`));
}, params.totalTimeoutMs);
timeoutHandle.unref?.();
});
return await Promise.race([fetchPromise, timeoutPromise]);
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
}
/**
* Slack voice messages (audio clips, huddle recordings) carry a `subtype` of
@@ -229,6 +315,9 @@ export async function resolveSlackMedia(params: {
files?: SlackFile[];
token: string;
maxBytes: number;
readIdleTimeoutMs?: number;
totalTimeoutMs?: number;
abortSignal?: AbortSignal;
}): Promise<SlackMediaResult[] | null> {
const files = params.files ?? [];
const limitedFiles =
@@ -245,13 +334,18 @@ export async function resolveSlackMedia(params: {
try {
const { url: slackUrl, requestInit } = createSlackMediaRequest(url, params.token);
const fetchImpl = createSlackMediaFetch();
const fetched = await fetchRemoteMedia({
url: slackUrl,
fetchImpl,
requestInit,
filePathHint: file.name,
maxBytes: params.maxBytes,
ssrfPolicy: SLACK_MEDIA_SSRF_POLICY,
const fetched = await fetchSlackMedia({
options: {
url: slackUrl,
fetchImpl,
requestInit,
filePathHint: file.name,
maxBytes: params.maxBytes,
ssrfPolicy: SLACK_MEDIA_SSRF_POLICY,
},
readIdleTimeoutMs: params.readIdleTimeoutMs,
totalTimeoutMs: params.totalTimeoutMs ?? SLACK_MEDIA_TOTAL_TIMEOUT_MS,
abortSignal: params.abortSignal,
});
if (fetched.buffer.byteLength > params.maxBytes) {
return null;
@@ -299,6 +393,9 @@ export async function resolveSlackAttachmentContent(params: {
attachments?: SlackAttachment[];
token: string;
maxBytes: number;
readIdleTimeoutMs?: number;
totalTimeoutMs?: number;
abortSignal?: AbortSignal;
}): Promise<{ text: string; media: SlackMediaResult[] } | null> {
const attachments = params.attachments;
if (!attachments || attachments.length === 0) {
@@ -328,12 +425,17 @@ export async function resolveSlackAttachmentContent(params: {
try {
const { url: slackUrl, requestInit } = createSlackMediaRequest(imageUrl, params.token);
const fetchImpl = createSlackMediaFetch();
const fetched = await fetchRemoteMedia({
url: slackUrl,
fetchImpl,
requestInit,
maxBytes: params.maxBytes,
ssrfPolicy: SLACK_MEDIA_SSRF_POLICY,
const fetched = await fetchSlackMedia({
options: {
url: slackUrl,
fetchImpl,
requestInit,
maxBytes: params.maxBytes,
ssrfPolicy: SLACK_MEDIA_SSRF_POLICY,
},
readIdleTimeoutMs: params.readIdleTimeoutMs,
totalTimeoutMs: params.totalTimeoutMs ?? SLACK_MEDIA_TOTAL_TIMEOUT_MS,
abortSignal: params.abortSignal,
});
if (fetched.buffer.byteLength <= params.maxBytes) {
const saved = await saveMediaBuffer(
@@ -359,6 +461,9 @@ export async function resolveSlackAttachmentContent(params: {
files: att.files,
token: params.token,
maxBytes: params.maxBytes,
readIdleTimeoutMs: params.readIdleTimeoutMs,
totalTimeoutMs: params.totalTimeoutMs,
abortSignal: params.abortSignal,
});
if (fileMedia) {
allMedia.push(...fileMedia);