diff --git a/src/slack/monitor/media.ts b/src/slack/monitor/media.ts index 8dcf6ecd05..927d90d3c5 100644 --- a/src/slack/monitor/media.ts +++ b/src/slack/monitor/media.ts @@ -1,6 +1,6 @@ import type { WebClient as SlackWebClient } from "@slack/web-api"; import type { FetchLike } from "../../media/fetch.js"; -import type { SlackFile } from "../types.js"; +import type { SlackAttachment, SlackFile } from "../types.js"; import { normalizeHostname } from "../../infra/net/hostname.js"; import { fetchRemoteMedia } from "../../media/fetch.js"; import { saveMediaBuffer } from "../../media/store.js"; @@ -219,6 +219,73 @@ export async function resolveSlackMedia(params: { return results.length > 0 ? results : null; } +/** Extracts text and media from forwarded-message attachments. Returns null when empty. */ +export async function resolveSlackAttachmentContent(params: { + attachments?: SlackAttachment[]; + token: string; + maxBytes: number; +}): Promise<{ text: string; media: SlackMediaResult[] } | null> { + const attachments = params.attachments; + if (!attachments || attachments.length === 0) { + return null; + } + + const textBlocks: string[] = []; + const allMedia: SlackMediaResult[] = []; + + for (const att of attachments) { + const text = att.text?.trim() || att.fallback?.trim(); + if (text) { + const author = att.author_name; + const heading = author ? `[Forwarded message from ${author}]` : "[Forwarded message]"; + textBlocks.push(`${heading}\n${text}`); + } + + const imageUrl = att.image_url; + if (imageUrl) { + try { + const fetched = await fetchRemoteMedia({ + url: imageUrl, + maxBytes: params.maxBytes, + }); + if (fetched.buffer.byteLength <= params.maxBytes) { + const saved = await saveMediaBuffer( + fetched.buffer, + fetched.contentType, + "inbound", + params.maxBytes, + ); + const label = fetched.fileName ?? "forwarded image"; + allMedia.push({ + path: saved.path, + contentType: fetched.contentType ?? saved.contentType, + placeholder: `[Forwarded image: ${label}]`, + }); + } + } catch { + // Skip images that fail to download + } + } + + if (att.files && att.files.length > 0) { + const fileMedia = await resolveSlackMedia({ + files: att.files, + token: params.token, + maxBytes: params.maxBytes, + }); + if (fileMedia) { + allMedia.push(...fileMedia); + } + } + } + + const combinedText = textBlocks.join("\n\n"); + if (!combinedText && allMedia.length === 0) { + return null; + } + return { text: combinedText, media: allMedia }; +} + export type SlackThreadStarter = { text: string; userId?: string; diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index d7296646d5..dedc0739bf 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -44,6 +44,7 @@ import { resolveSlackChannelConfig } from "../channel-config.js"; import { stripSlackMentionsForCommandDetection } from "../commands.js"; import { normalizeSlackChannelType, type SlackMonitorContext } from "../context.js"; import { + resolveSlackAttachmentContent, resolveSlackMedia, resolveSlackThreadHistory, resolveSlackThreadStarter, @@ -342,8 +343,25 @@ export async function prepareSlackMessage(params: { token: ctx.botToken, maxBytes: ctx.mediaMaxBytes, }); - const mediaPlaceholder = media ? media.map((m) => m.placeholder).join(" ") : undefined; - const rawBody = (message.text ?? "").trim() || mediaPlaceholder || ""; + + // Resolve forwarded message content (text + media) from Slack attachments + const attachmentContent = await resolveSlackAttachmentContent({ + attachments: message.attachments, + token: ctx.botToken, + maxBytes: ctx.mediaMaxBytes, + }); + + // Merge forwarded media into the message's media array + const mergedMedia = [...(media ?? []), ...(attachmentContent?.media ?? [])]; + const effectiveDirectMedia = mergedMedia.length > 0 ? mergedMedia : null; + + const mediaPlaceholder = effectiveDirectMedia + ? effectiveDirectMedia.map((m) => m.placeholder).join(" ") + : undefined; + const rawBody = + [(message.text ?? "").trim(), attachmentContent?.text, mediaPlaceholder] + .filter(Boolean) + .join("\n") || ""; if (!rawBody) { return null; } @@ -478,7 +496,7 @@ 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) { + if (!effectiveDirectMedia && starter.files && starter.files.length > 0) { threadStarterMedia = await resolveSlackMedia({ files: starter.files, token: ctx.botToken, @@ -554,8 +572,8 @@ export async function prepareSlackMessage(params: { } } - // Use thread starter media if current message has none - const effectiveMedia = media ?? threadStarterMedia; + // Use direct media (including forwarded attachment media) if available, else thread starter media + const effectiveMedia = effectiveDirectMedia ?? threadStarterMedia; const firstMedia = effectiveMedia?.[0]; const inboundHistory = diff --git a/src/slack/types.ts b/src/slack/types.ts index 39a8d04ae1..6de9fcb5a2 100644 --- a/src/slack/types.ts +++ b/src/slack/types.ts @@ -8,6 +8,26 @@ export type SlackFile = { url_private_download?: string; }; +export type SlackAttachment = { + fallback?: string; + text?: string; + pretext?: string; + author_name?: string; + author_id?: string; + from_url?: string; + ts?: string; + channel_name?: string; + channel_id?: string; + is_msg_unfurl?: boolean; + is_share?: boolean; + image_url?: string; + image_width?: number; + image_height?: number; + thumb_url?: string; + files?: SlackFile[]; + message_blocks?: unknown[]; +}; + export type SlackMessageEvent = { type: "message"; user?: string; @@ -22,6 +42,7 @@ export type SlackMessageEvent = { channel: string; channel_type?: "im" | "mpim" | "channel" | "group"; files?: SlackFile[]; + attachments?: SlackAttachment[]; }; export type SlackAppMentionEvent = { @@ -36,4 +57,5 @@ export type SlackAppMentionEvent = { parent_user_id?: string; channel: string; channel_type?: "im" | "mpim" | "channel" | "group"; + attachments?: SlackAttachment[]; };