fix(discord): handle multi-attachment inbound media
This commit is contained in:
@@ -19,6 +19,7 @@
|
|||||||
### Fixes
|
### Fixes
|
||||||
- Pairing: generate DM pairing codes with CSPRNG, expire pending codes after 1 hour, and avoid re-sending codes for already pending requests.
|
- 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.
|
- 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: 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.
|
- 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.
|
- Gateway/CLI: add daemon runtime selection (Node recommended; Bun optional) and document WhatsApp/Baileys Bun WebSocket instability on reconnect.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Guild } from "@buape/carbon";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
allowListMatches,
|
allowListMatches,
|
||||||
|
buildDiscordMediaPayload,
|
||||||
type DiscordGuildEntryResolved,
|
type DiscordGuildEntryResolved,
|
||||||
isDiscordGroupAllowedByPolicy,
|
isDiscordGroupAllowedByPolicy,
|
||||||
normalizeDiscordAllowList,
|
normalizeDiscordAllowList,
|
||||||
@@ -346,3 +347,26 @@ describe("discord reaction notification gating", () => {
|
|||||||
).toBe(true);
|
).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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -662,12 +662,8 @@ export function createDiscordMessageHandler(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const media = await resolveMedia(message, mediaMaxBytes);
|
const mediaList = await resolveMediaList(message, mediaMaxBytes);
|
||||||
const text =
|
const text = baseText;
|
||||||
message.content?.trim() ||
|
|
||||||
media?.placeholder ||
|
|
||||||
message.embeds?.[0]?.description ||
|
|
||||||
"";
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
logVerbose(`discord: drop message ${message.id} (empty content)`);
|
logVerbose(`discord: drop message ${message.id} (empty content)`);
|
||||||
return;
|
return;
|
||||||
@@ -741,6 +737,7 @@ export function createDiscordMessageHandler(params: {
|
|||||||
combinedBody = `[Replied message - for context]\n${replyContext}\n\n${combinedBody}`;
|
combinedBody = `[Replied message - for context]\n${replyContext}\n\n${combinedBody}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mediaPayload = buildDiscordMediaPayload(mediaList);
|
||||||
const discordTo = `channel:${message.channelId}`;
|
const discordTo = `channel:${message.channelId}`;
|
||||||
const ctxPayload = {
|
const ctxPayload = {
|
||||||
Body: combinedBody,
|
Body: combinedBody,
|
||||||
@@ -766,9 +763,7 @@ export function createDiscordMessageHandler(params: {
|
|||||||
WasMentioned: wasMentioned,
|
WasMentioned: wasMentioned,
|
||||||
MessageSid: message.id,
|
MessageSid: message.id,
|
||||||
Timestamp: resolveTimestampMs(message.timestamp),
|
Timestamp: resolveTimestampMs(message.timestamp),
|
||||||
MediaPath: media?.path,
|
...mediaPayload,
|
||||||
MediaType: media?.contentType,
|
|
||||||
MediaUrl: media?.path,
|
|
||||||
CommandAuthorized: commandAuthorized,
|
CommandAuthorized: commandAuthorized,
|
||||||
CommandSource: "text" as const,
|
CommandSource: "text" as const,
|
||||||
// Originating channel for reply routing.
|
// Originating channel for reply routing.
|
||||||
@@ -1356,30 +1351,41 @@ async function resolveDiscordChannelInfo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveMedia(
|
async function resolveMediaList(
|
||||||
message: Message,
|
message: Message,
|
||||||
maxBytes: number,
|
maxBytes: number,
|
||||||
): Promise<DiscordMediaInfo | null> {
|
): Promise<DiscordMediaInfo[]> {
|
||||||
const attachment = message.attachments?.[0];
|
const attachments = message.attachments ?? [];
|
||||||
if (!attachment) return null;
|
if (attachments.length === 0) return [];
|
||||||
const res = await fetch(attachment.url);
|
const out: DiscordMediaInfo[] = [];
|
||||||
if (!res.ok) {
|
for (const attachment of attachments) {
|
||||||
throw new Error(
|
try {
|
||||||
`Failed to download discord attachment: HTTP ${res.status}`,
|
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());
|
return out;
|
||||||
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),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferPlaceholder(attachment: APIAttachment): string {
|
function inferPlaceholder(attachment: APIAttachment): string {
|
||||||
@@ -1390,20 +1396,64 @@ function inferPlaceholder(attachment: APIAttachment): string {
|
|||||||
return "<media:document>";
|
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(
|
function resolveDiscordMessageText(
|
||||||
message: Message,
|
message: Message,
|
||||||
fallbackText?: string,
|
fallbackText?: string,
|
||||||
): string {
|
): string {
|
||||||
const attachment = message.attachments?.[0];
|
|
||||||
return (
|
return (
|
||||||
message.content?.trim() ||
|
message.content?.trim() ||
|
||||||
(attachment ? inferPlaceholder(attachment) : "") ||
|
buildDiscordAttachmentPlaceholder(message.attachments) ||
|
||||||
message.embeds?.[0]?.description ||
|
message.embeds?.[0]?.description ||
|
||||||
fallbackText?.trim() ||
|
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 {
|
function resolveReplyContext(message: Message): string | null {
|
||||||
const referenced = message.referencedMessage;
|
const referenced = message.referencedMessage;
|
||||||
if (!referenced?.author) return null;
|
if (!referenced?.author) return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user