feat(slack): support multiple media files in a single message

Previously, when a user sent multiple images/files in a single Slack
message, only the first file was processed and passed to the agent.

Changes:
- Add resolveSlackMediaList() to process ALL files (not just the first)
- Add buildSlackMediaPayload() helper to build singular + plural fields
- Update prepareSlackMessage() to populate MediaPaths/MediaUrls/MediaTypes
- Keep backwards compatibility via deprecated resolveSlackMedia()

This mirrors the existing Discord implementation which already supports
multiple attachments per message.

Fixes #XXXX
This commit is contained in:
Bruno Santos 2026-01-28 08:10:02 +00:00
parent 9688454a30
commit c1b1c76d03
2 changed files with 82 additions and 22 deletions

View File

@ -37,16 +37,23 @@ export async function fetchWithSlackAuth(url: string, token: string): Promise<Re
return fetch(resolvedUrl, { redirect: "follow" }); return fetch(resolvedUrl, { redirect: "follow" });
} }
export async function resolveSlackMedia(params: { export type SlackMediaInfo = {
files?: SlackFile[];
token: string;
maxBytes: number;
}): Promise<{
path: string; path: string;
contentType?: string; contentType?: string;
placeholder: string; placeholder: string;
} | null> { };
/**
* 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<SlackMediaInfo[]> {
const files = params.files ?? []; const files = params.files ?? [];
const out: SlackMediaInfo[] = [];
for (const file of files) { for (const file of files) {
const url = file.url_private_download ?? file.url_private; const url = file.url_private_download ?? file.url_private;
if (!url) continue; if (!url) continue;
@ -71,16 +78,58 @@ export async function resolveSlackMedia(params: {
params.maxBytes, params.maxBytes,
); );
const label = fetched.fileName ?? file.name; const label = fetched.fileName ?? file.name;
return { out.push({
path: saved.path, path: saved.path,
contentType: saved.contentType, contentType: saved.contentType,
placeholder: label ? `[Slack file: ${label}]` : "[Slack file]", placeholder: label ? `[Slack file: ${label}]` : "[Slack file]",
}; });
} catch { } 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<SlackMediaInfo | null> {
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 = { export type SlackThreadStarter = {

View File

@ -44,7 +44,12 @@ import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "../allow-li
import { resolveSlackEffectiveAllowFrom } from "../auth.js"; import { resolveSlackEffectiveAllowFrom } from "../auth.js";
import { resolveSlackChannelConfig } from "../channel-config.js"; import { resolveSlackChannelConfig } from "../channel-config.js";
import { normalizeSlackChannelType, type SlackMonitorContext } from "../context.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"; import type { PreparedSlackMessage } from "./types.js";
@ -331,12 +336,13 @@ export async function prepareSlackMessage(params: {
return null; return null;
} }
const media = await resolveSlackMedia({ const mediaList = await resolveSlackMediaList({
files: message.files, files: message.files,
token: ctx.botToken, token: ctx.botToken,
maxBytes: ctx.mediaMaxBytes, 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; if (!rawBody) return null;
const ackReaction = resolveAckReaction(cfg, route.agentId); const ackReaction = resolveAckReaction(cfg, route.agentId);
@ -453,7 +459,7 @@ export async function prepareSlackMessage(params: {
let threadStarterBody: string | undefined; let threadStarterBody: string | undefined;
let threadLabel: string | undefined; let threadLabel: string | undefined;
let threadStarterMedia: Awaited<ReturnType<typeof resolveSlackMedia>> = null; let threadStarterMediaList: SlackMediaInfo[] = [];
if (isThreadReply && threadTs) { if (isThreadReply && threadTs) {
const starter = await resolveSlackThreadStarter({ const starter = await resolveSlackThreadStarter({
channelId: message.channel, channelId: message.channel,
@ -474,15 +480,15 @@ export async function prepareSlackMessage(params: {
const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80); const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80);
threadLabel = `Slack thread ${roomLabel}${snippet ? `: ${snippet}` : ""}`; threadLabel = `Slack thread ${roomLabel}${snippet ? `: ${snippet}` : ""}`;
// If current message has no files but thread starter does, fetch starter's files // If current message has no files but thread starter does, fetch starter's files
if (!media && starter.files && starter.files.length > 0) { if (mediaList.length === 0 && starter.files && starter.files.length > 0) {
threadStarterMedia = await resolveSlackMedia({ threadStarterMediaList = await resolveSlackMediaList({
files: starter.files, files: starter.files,
token: ctx.botToken, token: ctx.botToken,
maxBytes: ctx.mediaMaxBytes, maxBytes: ctx.mediaMaxBytes,
}); });
if (threadStarterMedia) { if (threadStarterMediaList.length > 0) {
logVerbose( 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 // 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({ const ctxPayload = finalizeInboundContext({
Body: combinedBody, Body: combinedBody,
@ -519,9 +527,12 @@ export async function prepareSlackMessage(params: {
ThreadLabel: threadLabel, ThreadLabel: threadLabel,
Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined, Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
WasMentioned: isRoomish ? effectiveWasMentioned : undefined, WasMentioned: isRoomish ? effectiveWasMentioned : undefined,
MediaPath: effectiveMedia?.path, MediaPath: effectiveMediaPayload.MediaPath,
MediaType: effectiveMedia?.contentType, MediaType: effectiveMediaPayload.MediaType,
MediaUrl: effectiveMedia?.path, MediaUrl: effectiveMediaPayload.MediaUrl,
MediaPaths: effectiveMediaPayload.MediaPaths,
MediaUrls: effectiveMediaPayload.MediaUrls,
MediaTypes: effectiveMediaPayload.MediaTypes,
CommandAuthorized: commandAuthorized, CommandAuthorized: commandAuthorized,
OriginatingChannel: "slack" as const, OriginatingChannel: "slack" as const,
OriginatingTo: slackTo, OriginatingTo: slackTo,