import type { WebClient as SlackWebClient } from "@slack/web-api"; import type { FetchLike } from "../../media/fetch.js"; import { fetchRemoteMedia } from "../../media/fetch.js"; import { saveMediaBuffer } from "../../media/store.js"; import { logWarn } from "../../logger.js"; import type { SlackFile } from "../types.js"; /** * Detects if buffer content looks like HTML (login page / error page). * Slack sometimes returns HTML login pages when auth fails instead of binary media. */ function looksLikeHtml(buffer: Buffer): boolean { const head = buffer.subarray(0, 512).toString("utf-8").trim().toLowerCase(); return ( head.startsWith(" { // 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; // Guard: reject if we received HTML instead of expected media. // This happens when Slack auth fails and returns a login page. // Skip this check if the file metadata indicates it's genuinely an HTML file. const detectedMime = fetched.contentType?.split(";")[0]?.trim().toLowerCase(); const expectedMime = file.mimetype?.split(";")[0]?.trim().toLowerCase(); const isExpectedHtml = expectedMime === "text/html" || file.name?.toLowerCase().endsWith(".html"); if (!isExpectedHtml && (detectedMime === "text/html" || looksLikeHtml(fetched.buffer))) { const fileId = file.name ?? file.id ?? "unknown"; logWarn( `slack: received HTML instead of media for file ${fileId}; possible auth failure or expired URL`, ); 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; } }