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" });
|
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 = {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user