





















@@ -1,3 +1,5 @@
1+import type { WebClient as SlackWebClient } from "@slack/web-api";
2+import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
13import { normalizeHostname } from "openclaw/plugin-sdk/host-runtime";
24import { resolveRequestUrl } from "openclaw/plugin-sdk/request-url";
35import {
@@ -14,6 +16,7 @@ import {
1416fetchWithRuntimeDispatcher,
1517saveMediaBuffer,
1618} from "./media.runtime.js";
19+import { logVerbose } from "./thread.runtime.js";
1720export {
1821resetSlackThreadStarterCacheForTest,
1922resolveSlackThreadHistory,
@@ -255,6 +258,83 @@ function looksLikeHtmlBuffer(buffer: Buffer): boolean {
255258const MAX_SLACK_MEDIA_CONCURRENCY = 3;
256259const MAX_SLACK_FORWARDED_ATTACHMENTS = 8;
257260261+async function fetchFreshSlackFileUrl(params: {
262+file: SlackFile;
263+client?: SlackWebClient;
264+}): Promise<string | null> {
265+if (!params.file.id || !params.client) {
266+return null;
267+}
268+try {
269+const info = await params.client.files.info({ file: params.file.id });
270+const freshFile = info.file as SlackFile | undefined;
271+const freshUrl = freshFile?.url_private_download ?? freshFile?.url_private;
272+if (freshUrl) {
273+logVerbose(`slack: refreshed file URL via files.info for file id=${params.file.id}`);
274+return freshUrl;
275+}
276+logVerbose(`slack: files.info returned no private URL for file id=${params.file.id}`);
277+return null;
278+} catch (error) {
279+logVerbose(
280+`slack: files.info failed for file id=${params.file.id}: ${formatErrorMessage(error)}`,
281+);
282+return null;
283+}
284+}
285+286+async function downloadSlackMediaFile(params: {
287+file: SlackFile;
288+url: string;
289+token: string;
290+maxBytes: number;
291+readIdleTimeoutMs?: number;
292+totalTimeoutMs?: number;
293+abortSignal?: AbortSignal;
294+}): Promise<SlackMediaResult | null> {
295+const { url: slackUrl, requestInit } = createSlackMediaRequest(params.url, params.token);
296+const fetchImpl = createSlackMediaFetch();
297+const fetched = await fetchSlackMedia({
298+options: {
299+url: slackUrl,
300+ fetchImpl,
301+ requestInit,
302+filePathHint: params.file.name,
303+maxBytes: params.maxBytes,
304+ssrfPolicy: SLACK_MEDIA_SSRF_POLICY,
305+},
306+readIdleTimeoutMs: params.readIdleTimeoutMs,
307+totalTimeoutMs: params.totalTimeoutMs ?? SLACK_MEDIA_TOTAL_TIMEOUT_MS,
308+abortSignal: params.abortSignal,
309+});
310+if (fetched.buffer.byteLength > params.maxBytes) {
311+return null;
312+}
313+314+// Guard against auth/login HTML pages returned instead of binary media.
315+// Allow user-provided HTML files through.
316+const fileMime = normalizeOptionalLowercaseString(params.file.mimetype);
317+const fileName = normalizeLowercaseStringOrEmpty(params.file.name);
318+const isExpectedHtml =
319+fileMime === "text/html" || fileName.endsWith(".html") || fileName.endsWith(".htm");
320+if (!isExpectedHtml) {
321+const detectedMime = normalizeOptionalLowercaseString(fetched.contentType?.split(";")[0]);
322+if (detectedMime === "text/html" || looksLikeHtmlBuffer(fetched.buffer)) {
323+return null;
324+}
325+}
326+327+const effectiveMime = resolveSlackMediaMimetype(params.file, fetched.contentType);
328+const saved = await saveMediaBuffer(fetched.buffer, effectiveMime, "inbound", params.maxBytes);
329+const label = fetched.fileName ?? params.file.name;
330+const contentType = effectiveMime ?? saved.contentType;
331+return {
332+path: saved.path,
333+ ...(contentType ? { contentType } : {}),
334+placeholder: `[Slack file: ${formatSlackFileReference({ ...params.file, name: label })}]`,
335+};
336+}
337+258338function isForwardedSlackAttachment(attachment: SlackAttachment): boolean {
259339// Narrow this parser to Slack's explicit "shared/forwarded" attachment payloads.
260340return attachment.is_share === true;
@@ -308,6 +388,7 @@ async function mapLimit<T, R>(
308388 */
309389export async function resolveSlackMedia(params: {
310390files?: SlackFile[];
391+client?: SlackWebClient;
311392token: string;
312393maxBytes: number;
313394readIdleTimeoutMs?: number;
@@ -322,60 +403,37 @@ export async function resolveSlackMedia(params: {
322403limitedFiles,
323404MAX_SLACK_MEDIA_CONCURRENCY,
324405async (file) => {
325-const url = file.url_private_download ?? file.url_private;
406+const eventUrl = file.url_private_download ?? file.url_private;
407+const url = eventUrl ?? (await fetchFreshSlackFileUrl({ file, client: params.client }));
326408if (!url) {
327409return null;
328410}
329-try {
330-const { url: slackUrl, requestInit } = createSlackMediaRequest(url, params.token);
331-const fetchImpl = createSlackMediaFetch();
332-const fetched = await fetchSlackMedia({
333-options: {
334-url: slackUrl,
335- fetchImpl,
336- requestInit,
337-filePathHint: file.name,
338-maxBytes: params.maxBytes,
339-ssrfPolicy: SLACK_MEDIA_SSRF_POLICY,
340-},
341-readIdleTimeoutMs: params.readIdleTimeoutMs,
342-totalTimeoutMs: params.totalTimeoutMs ?? SLACK_MEDIA_TOTAL_TIMEOUT_MS,
343-abortSignal: params.abortSignal,
344-});
345-if (fetched.buffer.byteLength > params.maxBytes) {
346-return null;
347-}
348-349-// Guard against auth/login HTML pages returned instead of binary media.
350-// Allow user-provided HTML files through.
351-const fileMime = normalizeOptionalLowercaseString(file.mimetype);
352-const fileName = normalizeLowercaseStringOrEmpty(file.name);
353-const isExpectedHtml =
354-fileMime === "text/html" || fileName.endsWith(".html") || fileName.endsWith(".htm");
355-if (!isExpectedHtml) {
356-const detectedMime = normalizeOptionalLowercaseString(fetched.contentType?.split(";")[0]);
357-if (detectedMime === "text/html" || looksLikeHtmlBuffer(fetched.buffer)) {
358-return null;
359-}
360-}
411+const result = await downloadSlackMediaFile({
412+ file,
413+ url,
414+token: params.token,
415+maxBytes: params.maxBytes,
416+readIdleTimeoutMs: params.readIdleTimeoutMs,
417+totalTimeoutMs: params.totalTimeoutMs,
418+abortSignal: params.abortSignal,
419+}).catch(() => null);
420+if (result || !eventUrl) {
421+return result;
422+}
361423362-const effectiveMime = resolveSlackMediaMimetype(file, fetched.contentType);
363-const saved = await saveMediaBuffer(
364-fetched.buffer,
365-effectiveMime,
366-"inbound",
367-params.maxBytes,
368-);
369-const label = fetched.fileName ?? file.name;
370-const contentType = effectiveMime ?? saved.contentType;
371-return {
372-path: saved.path,
373- ...(contentType ? { contentType } : {}),
374-placeholder: `[Slack file: ${formatSlackFileReference({ ...file, name: label })}]`,
375-};
376-} catch {
424+const freshUrl = await fetchFreshSlackFileUrl({ file, client: params.client });
425+if (!freshUrl) {
377426return null;
378427}
428+return await downloadSlackMediaFile({
429+ file,
430+url: freshUrl,
431+token: params.token,
432+maxBytes: params.maxBytes,
433+readIdleTimeoutMs: params.readIdleTimeoutMs,
434+totalTimeoutMs: params.totalTimeoutMs,
435+abortSignal: params.abortSignal,
436+}).catch(() => null);
379437},
380438);
381439@@ -386,6 +444,7 @@ export async function resolveSlackMedia(params: {
386444/** Extracts text and media from forwarded-message attachments. Returns null when empty. */
387445export async function resolveSlackAttachmentContent(params: {
388446attachments?: SlackAttachment[];
447+client?: SlackWebClient;
389448token: string;
390449maxBytes: number;
391450readIdleTimeoutMs?: number;
@@ -454,6 +513,7 @@ export async function resolveSlackAttachmentContent(params: {
454513if (att.files && att.files.length > 0) {
455514const fileMedia = await resolveSlackMedia({
456515files: att.files,
516+client: params.client,
457517token: params.token,
458518maxBytes: params.maxBytes,
459519readIdleTimeoutMs: params.readIdleTimeoutMs,
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。