fix(discord): handle multi-attachment inbound media

This commit is contained in:
Peter Steinberger
2026-01-07 09:01:46 +01:00
parent 7d2dde6ea6
commit a50ffa69b0
3 changed files with 107 additions and 32 deletions

View File

@@ -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.

View File

@@ -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",
]);
});
});

View File

@@ -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<DiscordMediaInfo | null> {
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<DiscordMediaInfo[]> {
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 "<media:document>";
}
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 ? "<media:image>" : "<media:document>";
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;