import type { WebClient as SlackWebClient } from "@slack/web-api"; import type { FetchLike } from "../../media/fetch.js"; import type { SlackFile } from "../types.js"; import { fetchRemoteMedia } from "../../media/fetch.js"; import { saveMediaBuffer } from "../../media/store.js"; /** * Fetches a URL with Authorization header, handling cross-origin redirects. * Node.js fetch strips Authorization headers on cross-origin redirects for security. * Slack's files.slack.com URLs redirect to CDN domains with pre-signed URLs that * don't need the Authorization header, so we handle the initial auth request manually. */ export async function fetchWithSlackAuth(url: string, token: string): Promise { // Initial request with auth and manual redirect handling const initialRes = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, redirect: "manual", }); // If not a redirect, return the response directly if (initialRes.status < 300 || initialRes.status >= 400) { return initialRes; } // Handle redirect - the redirected URL should be pre-signed and not need auth const redirectUrl = initialRes.headers.get("location"); if (!redirectUrl) { return initialRes; } // Resolve relative URLs against the original const resolvedUrl = new URL(redirectUrl, url).toString(); // Follow the redirect without the Authorization header // (Slack's CDN URLs are pre-signed and don't need it) return fetch(resolvedUrl, { redirect: "follow" }); } export async function resolveSlackMedia(params: { files?: SlackFile[]; token: string; maxBytes: number; }): Promise<{ path: string; contentType?: string; placeholder: string; } | null> { const files = params.files ?? []; for (const file of files) { const url = file.url_private_download ?? file.url_private; if (!url) { continue; } try { // Note: We ignore init options because fetchWithSlackAuth handles // redirect behavior specially. fetchRemoteMedia only passes the URL. const fetchImpl: FetchLike = (input) => { const inputUrl = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; return fetchWithSlackAuth(inputUrl, params.token); }; const fetched = await fetchRemoteMedia({ url, fetchImpl, filePathHint: file.name, }); if (fetched.buffer.byteLength > params.maxBytes) { continue; } const saved = await saveMediaBuffer( fetched.buffer, fetched.contentType ?? file.mimetype, "inbound", params.maxBytes, ); const label = fetched.fileName ?? file.name; return { path: saved.path, contentType: saved.contentType, placeholder: label ? `[Slack file: ${label}]` : "[Slack file]", }; } catch { // Ignore download failures and fall through to the next file. } } return null; } export type SlackThreadStarter = { text: string; userId?: string; ts?: string; files?: SlackFile[]; }; const THREAD_STARTER_CACHE = new Map(); export async function resolveSlackThreadStarter(params: { channelId: string; threadTs: string; client: SlackWebClient; }): Promise { const cacheKey = `${params.channelId}:${params.threadTs}`; const cached = THREAD_STARTER_CACHE.get(cacheKey); if (cached) { return cached; } try { const response = (await params.client.conversations.replies({ channel: params.channelId, ts: params.threadTs, limit: 1, inclusive: true, })) as { messages?: Array<{ text?: string; user?: string; ts?: string; files?: SlackFile[] }> }; const message = response?.messages?.[0]; const text = (message?.text ?? "").trim(); if (!message || !text) { return null; } const starter: SlackThreadStarter = { text, userId: message.user, ts: message.ts, files: message.files, }; THREAD_STARTER_CACHE.set(cacheKey, starter); return starter; } catch { return null; } }