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:
parent
9688454a30
commit
c1b1c76d03
@ -37,16 +37,23 @@ export async function fetchWithSlackAuth(url: string, token: string): Promise<Re
|
||||
return fetch(resolvedUrl, { redirect: "follow" });
|
||||
}
|
||||
|
||||
export async function resolveSlackMedia(params: {
|
||||
files?: SlackFile[];
|
||||
token: string;
|
||||
maxBytes: number;
|
||||
}): Promise<{
|
||||
export type SlackMediaInfo = {
|
||||
path: string;
|
||||
contentType?: 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 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<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 = {
|
||||
|
||||
@ -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<ReturnType<typeof resolveSlackMedia>> = 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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user