diff --git a/src/slack/monitor/media.ts b/src/slack/monitor/media.ts index 2674e2d50..1350831fc 100644 --- a/src/slack/monitor/media.ts +++ b/src/slack/monitor/media.ts @@ -37,16 +37,23 @@ export async function fetchWithSlackAuth(url: string, token: string): Promise { +}; + +/** + * Resolves all Slack media files from a message. + * Returns an array of successfully downloaded files. + */ +export async function resolveSlackMediaList(params: { + files?: SlackFile[]; + token: string; + maxBytes: number; +}): Promise { const files = params.files ?? []; + const out: SlackMediaInfo[] = []; for (const file of files) { const url = file.url_private_download ?? file.url_private; if (!url) continue; @@ -71,16 +78,58 @@ export async function resolveSlackMedia(params: { params.maxBytes, ); const label = fetched.fileName ?? file.name; - return { + out.push({ path: saved.path, contentType: saved.contentType, placeholder: label ? `[Slack file: ${label}]` : "[Slack file]", - }; + }); } catch { - // Ignore download failures and fall through to the next file. + // Ignore download failures and continue to the next file. } } - return null; + return out; +} + +/** + * Legacy function for backwards compatibility. + * @deprecated Use resolveSlackMediaList instead. + */ +export async function resolveSlackMedia(params: { + files?: SlackFile[]; + token: string; + maxBytes: number; +}): Promise { + const list = await resolveSlackMediaList(params); + return list[0] ?? null; +} + +/** + * Builds the media payload fields for the inbound context. + * Provides both singular (MediaPath) and plural (MediaPaths) fields for compatibility. + */ +export function buildSlackMediaPayload(mediaList: SlackMediaInfo[]): { + MediaPath?: string; + MediaType?: string; + MediaUrl?: string; + MediaPaths?: string[]; + MediaUrls?: string[]; + MediaTypes?: string[]; + placeholder?: string; +} { + if (mediaList.length === 0) return {}; + const first = mediaList[0]; + const mediaPaths = mediaList.map((media) => media.path); + const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[]; + const placeholders = mediaList.map((media) => media.placeholder); + return { + MediaPath: first?.path, + MediaType: first?.contentType, + MediaUrl: first?.path, + MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined, + MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined, + MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined, + placeholder: placeholders.length > 0 ? placeholders.join(" ") : undefined, + }; } export type SlackThreadStarter = { diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 8a2a9e111..99be742f6 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -44,7 +44,12 @@ import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "../allow-li import { resolveSlackEffectiveAllowFrom } from "../auth.js"; import { resolveSlackChannelConfig } from "../channel-config.js"; import { normalizeSlackChannelType, type SlackMonitorContext } from "../context.js"; -import { resolveSlackMedia, resolveSlackThreadStarter } from "../media.js"; +import { + buildSlackMediaPayload, + resolveSlackMediaList, + resolveSlackThreadStarter, + type SlackMediaInfo, +} from "../media.js"; import type { PreparedSlackMessage } from "./types.js"; @@ -331,12 +336,13 @@ export async function prepareSlackMessage(params: { return null; } - const media = await resolveSlackMedia({ + const mediaList = await resolveSlackMediaList({ files: message.files, token: ctx.botToken, maxBytes: ctx.mediaMaxBytes, }); - const rawBody = (message.text ?? "").trim() || media?.placeholder || ""; + const mediaPayload = buildSlackMediaPayload(mediaList); + const rawBody = (message.text ?? "").trim() || mediaPayload.placeholder || ""; if (!rawBody) return null; const ackReaction = resolveAckReaction(cfg, route.agentId); @@ -453,7 +459,7 @@ export async function prepareSlackMessage(params: { let threadStarterBody: string | undefined; let threadLabel: string | undefined; - let threadStarterMedia: Awaited> = null; + let threadStarterMediaList: SlackMediaInfo[] = []; if (isThreadReply && threadTs) { const starter = await resolveSlackThreadStarter({ channelId: message.channel, @@ -474,15 +480,15 @@ export async function prepareSlackMessage(params: { const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80); threadLabel = `Slack thread ${roomLabel}${snippet ? `: ${snippet}` : ""}`; // If current message has no files but thread starter does, fetch starter's files - if (!media && starter.files && starter.files.length > 0) { - threadStarterMedia = await resolveSlackMedia({ + if (mediaList.length === 0 && starter.files && starter.files.length > 0) { + threadStarterMediaList = await resolveSlackMediaList({ files: starter.files, token: ctx.botToken, maxBytes: ctx.mediaMaxBytes, }); - if (threadStarterMedia) { + if (threadStarterMediaList.length > 0) { logVerbose( - `slack: hydrated thread starter file ${threadStarterMedia.placeholder} from root message`, + `slack: hydrated ${threadStarterMediaList.length} thread starter file(s) from root message`, ); } } @@ -492,7 +498,9 @@ export async function prepareSlackMessage(params: { } // Use thread starter media if current message has none - const effectiveMedia = media ?? threadStarterMedia; + const effectiveMediaList = mediaList.length > 0 ? mediaList : threadStarterMediaList; + const effectiveMediaPayload = + mediaList.length > 0 ? mediaPayload : buildSlackMediaPayload(threadStarterMediaList); const ctxPayload = finalizeInboundContext({ Body: combinedBody, @@ -519,9 +527,12 @@ export async function prepareSlackMessage(params: { ThreadLabel: threadLabel, Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined, WasMentioned: isRoomish ? effectiveWasMentioned : undefined, - MediaPath: effectiveMedia?.path, - MediaType: effectiveMedia?.contentType, - MediaUrl: effectiveMedia?.path, + MediaPath: effectiveMediaPayload.MediaPath, + MediaType: effectiveMediaPayload.MediaType, + MediaUrl: effectiveMediaPayload.MediaUrl, + MediaPaths: effectiveMediaPayload.MediaPaths, + MediaUrls: effectiveMediaPayload.MediaUrls, + MediaTypes: effectiveMediaPayload.MediaTypes, CommandAuthorized: commandAuthorized, OriginatingChannel: "slack" as const, OriginatingTo: slackTo,