diff --git a/CHANGELOG.md b/CHANGELOG.md index ae58c9dea..e314ad8e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ ### Fixes - Pairing: generate DM pairing codes with CSPRNG, expire pending codes after 1 hour, and avoid re-sending codes for already pending requests. - Pairing: lock + atomically write pairing stores with 0600 perms and stop logging pairing codes in provider logs. +- Discord: include all inbound attachments in `MediaPaths`/`MediaUrls` (back-compat `MediaPath`/`MediaUrl` still first). - Tools: add Telegram/WhatsApp reaction tools (with per-provider gating). Thanks @zats for PR #353. - Tools: unify reaction removal semantics across Discord/Slack/Telegram/WhatsApp and allow WhatsApp reaction routing across accounts. - Gateway/CLI: add daemon runtime selection (Node recommended; Bun optional) and document WhatsApp/Baileys Bun WebSocket instability on reconnect. diff --git a/src/discord/monitor.test.ts b/src/discord/monitor.test.ts index f0925e734..9caeb419f 100644 --- a/src/discord/monitor.test.ts +++ b/src/discord/monitor.test.ts @@ -2,6 +2,7 @@ import type { Guild } from "@buape/carbon"; import { describe, expect, it } from "vitest"; import { allowListMatches, + buildDiscordMediaPayload, type DiscordGuildEntryResolved, isDiscordGroupAllowedByPolicy, normalizeDiscordAllowList, @@ -346,3 +347,26 @@ describe("discord reaction notification gating", () => { ).toBe(true); }); }); + +describe("discord media payload", () => { + it("preserves attachment order for MediaPaths/MediaUrls", () => { + const payload = buildDiscordMediaPayload([ + { path: "/tmp/a.png", contentType: "image/png" }, + { path: "/tmp/b.png", contentType: "image/png" }, + { path: "/tmp/c.png", contentType: "image/png" }, + ]); + expect(payload.MediaPath).toBe("/tmp/a.png"); + expect(payload.MediaUrl).toBe("/tmp/a.png"); + expect(payload.MediaType).toBe("image/png"); + expect(payload.MediaPaths).toEqual([ + "/tmp/a.png", + "/tmp/b.png", + "/tmp/c.png", + ]); + expect(payload.MediaUrls).toEqual([ + "/tmp/a.png", + "/tmp/b.png", + "/tmp/c.png", + ]); + }); +}); diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index cb1bccc12..1f9743707 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -662,12 +662,8 @@ export function createDiscordMessageHandler(params: { return; } - const media = await resolveMedia(message, mediaMaxBytes); - const text = - message.content?.trim() || - media?.placeholder || - message.embeds?.[0]?.description || - ""; + const mediaList = await resolveMediaList(message, mediaMaxBytes); + const text = baseText; if (!text) { logVerbose(`discord: drop message ${message.id} (empty content)`); return; @@ -741,6 +737,7 @@ export function createDiscordMessageHandler(params: { combinedBody = `[Replied message - for context]\n${replyContext}\n\n${combinedBody}`; } + const mediaPayload = buildDiscordMediaPayload(mediaList); const discordTo = `channel:${message.channelId}`; const ctxPayload = { Body: combinedBody, @@ -766,9 +763,7 @@ export function createDiscordMessageHandler(params: { WasMentioned: wasMentioned, MessageSid: message.id, Timestamp: resolveTimestampMs(message.timestamp), - MediaPath: media?.path, - MediaType: media?.contentType, - MediaUrl: media?.path, + ...mediaPayload, CommandAuthorized: commandAuthorized, CommandSource: "text" as const, // Originating channel for reply routing. @@ -1356,30 +1351,41 @@ async function resolveDiscordChannelInfo( } } -async function resolveMedia( +async function resolveMediaList( message: Message, maxBytes: number, -): Promise { - const attachment = message.attachments?.[0]; - if (!attachment) return null; - const res = await fetch(attachment.url); - if (!res.ok) { - throw new Error( - `Failed to download discord attachment: HTTP ${res.status}`, - ); +): Promise { + const attachments = message.attachments ?? []; + if (attachments.length === 0) return []; + const out: DiscordMediaInfo[] = []; + for (const attachment of attachments) { + try { + const res = await fetch(attachment.url); + if (!res.ok) { + throw new Error( + `Failed to download discord attachment: HTTP ${res.status}`, + ); + } + const buffer = Buffer.from(await res.arrayBuffer()); + const mime = await detectMime({ + buffer, + headerMime: attachment.content_type ?? res.headers.get("content-type"), + filePath: attachment.filename ?? attachment.url, + }); + const saved = await saveMediaBuffer(buffer, mime, "inbound", maxBytes); + out.push({ + path: saved.path, + contentType: saved.contentType, + placeholder: inferPlaceholder(attachment), + }); + } catch (err) { + const id = attachment.id ?? attachment.url; + logVerbose( + `discord: failed to download attachment ${id}: ${String(err)}`, + ); + } } - const buffer = Buffer.from(await res.arrayBuffer()); - const mime = await detectMime({ - buffer, - headerMime: attachment.content_type ?? res.headers.get("content-type"), - filePath: attachment.filename ?? attachment.url, - }); - const saved = await saveMediaBuffer(buffer, mime, "inbound", maxBytes); - return { - path: saved.path, - contentType: saved.contentType, - placeholder: inferPlaceholder(attachment), - }; + return out; } function inferPlaceholder(attachment: APIAttachment): string { @@ -1390,20 +1396,64 @@ function inferPlaceholder(attachment: APIAttachment): string { return ""; } +function isImageAttachment(attachment: APIAttachment): boolean { + const mime = attachment.content_type ?? ""; + if (mime.startsWith("image/")) return true; + const name = attachment.filename?.toLowerCase() ?? ""; + if (!name) return false; + return /\.(avif|bmp|gif|heic|heif|jpe?g|png|tiff?|webp)$/.test(name); +} + +function buildDiscordAttachmentPlaceholder( + attachments?: APIAttachment[], +): string { + if (!attachments || attachments.length === 0) return ""; + const count = attachments.length; + const allImages = attachments.every(isImageAttachment); + const label = allImages ? "image" : "file"; + const suffix = count === 1 ? label : `${label}s`; + const tag = allImages ? "" : ""; + return `${tag} (${count} ${suffix})`; +} + function resolveDiscordMessageText( message: Message, fallbackText?: string, ): string { - const attachment = message.attachments?.[0]; return ( message.content?.trim() || - (attachment ? inferPlaceholder(attachment) : "") || + buildDiscordAttachmentPlaceholder(message.attachments) || message.embeds?.[0]?.description || fallbackText?.trim() || "" ); } +export function buildDiscordMediaPayload( + mediaList: Array<{ path: string; contentType?: string }>, +): { + MediaPath?: string; + MediaType?: string; + MediaUrl?: string; + MediaPaths?: string[]; + MediaUrls?: string[]; + MediaTypes?: string[]; +} { + const first = mediaList[0]; + const mediaPaths = mediaList.map((media) => media.path); + const mediaTypes = mediaList + .map((media) => media.contentType) + .filter(Boolean) as string[]; + 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, + }; +} + function resolveReplyContext(message: Message): string | null { const referenced = message.referencedMessage; if (!referenced?.author) return null;