fix(moderation): fix image attachment pipeline causing PIL BadRequestError on NVIDIA inference
Three-layer defect chain causing 'cannot identify image file <_io.BytesIO object>': 1. attachmentUploader: hardcoded 'application/octet-stream' on Tele CDN upload regardless of actual file MIME type — CDN stored images under wrong type. 2. messageCapture: processAttachmentUpload call site never forwarded attachment.contentType into the options bag, so the fix in (1) would have received undefined and fallen back to octet-stream anyway. 3. llmModerationClient: blindly trusted att.type from the DB record (Discord-provided MIME) when constructing data: URLs, but validated neither the HTTP status of the CDN re-fetch nor the actual byte content. Stale/expired CDN URLs returning HTML error pages were base64-encoded and sent to the model as 'image/jpeg', causing PIL to reject the stream. Fixes: - uploadAttachmentToTele now accepts contentType param (defaults to application/octet-stream for non-image files) - processAttachmentUpload options bag gains optional contentType field - messageCapture forwards attachment.contentType at the call site - Added sniffImageMimeType() using magic-byte probes for JPEG, PNG, GIF, WebP, AVIF/HEIF — runs on every downloaded attachment buffer before base64 encoding; skips the attachment (logs headerHex for diagnosis) if bytes don't match a known image format - data: URL now uses the sniffed MIME type, not the DB record
This commit is contained in:
@@ -35,12 +35,13 @@ function shouldRefreshDiscordUrl(error: unknown): boolean {
|
||||
export async function uploadAttachmentToTele(
|
||||
fileBuffer: Buffer,
|
||||
filename: string,
|
||||
contentType = "application/octet-stream",
|
||||
): Promise<string> {
|
||||
try {
|
||||
const result = await uploadToTele({
|
||||
buffer: fileBuffer,
|
||||
filename,
|
||||
contentType: "application/octet-stream",
|
||||
contentType,
|
||||
uploadUrl: config.TELE_UPLOAD_URL,
|
||||
timeoutMs: config.ATTACHMENT_UPLOAD_TIMEOUT_MS,
|
||||
retries: config.ATTACHMENT_RETRY_ATTEMPTS,
|
||||
@@ -88,7 +89,10 @@ export async function processAttachmentUpload(
|
||||
attachmentId: string,
|
||||
discordUrl: string,
|
||||
filename: string,
|
||||
options: { refreshDiscordUrl?: RefreshDiscordAttachmentUrl } = {},
|
||||
options: {
|
||||
refreshDiscordUrl?: RefreshDiscordAttachmentUrl;
|
||||
contentType?: string;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
try {
|
||||
let currentDiscordUrl = discordUrl;
|
||||
@@ -114,7 +118,11 @@ export async function processAttachmentUpload(
|
||||
);
|
||||
}
|
||||
|
||||
const uploadedUrl = await uploadAttachmentToTele(buffer, filename);
|
||||
const uploadedUrl = await uploadAttachmentToTele(
|
||||
buffer,
|
||||
filename,
|
||||
options.contentType,
|
||||
);
|
||||
|
||||
await updateAttachmentAsUploaded(attachmentId, uploadedUrl, Date.now());
|
||||
} catch (error) {
|
||||
|
||||
@@ -414,6 +414,88 @@ interface ModerationOutput {
|
||||
raw: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sniff the first bytes of a buffer to determine if it is a supported image
|
||||
* format. Returns the canonical MIME type string on success, or null if the
|
||||
* bytes are not a recognizable image.
|
||||
*
|
||||
* Supported probes (in order):
|
||||
* - JPEG: FF D8 FF
|
||||
* - PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||
* - GIF: 47 49 46 38 (GIF8)
|
||||
* - WebP: 52 49 46 46 ?? ?? ?? ?? 57 45 42 50 (RIFF....WEBP)
|
||||
* - AVIF / HEIF: 4-byte big-endian size + 66 74 79 70 (ftyp ISO base-media box)
|
||||
*/
|
||||
function sniffImageMimeType(buf: Buffer): string | null {
|
||||
if (buf.length < 12) return null;
|
||||
|
||||
// JPEG
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
// PNG
|
||||
if (
|
||||
buf[0] === 0x89 &&
|
||||
buf[1] === 0x50 &&
|
||||
buf[2] === 0x4e &&
|
||||
buf[3] === 0x47 &&
|
||||
buf[4] === 0x0d &&
|
||||
buf[5] === 0x0a &&
|
||||
buf[6] === 0x1a &&
|
||||
buf[7] === 0x0a
|
||||
) {
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
// GIF
|
||||
if (
|
||||
buf[0] === 0x47 &&
|
||||
buf[1] === 0x49 &&
|
||||
buf[2] === 0x46 &&
|
||||
buf[3] === 0x38
|
||||
) {
|
||||
return "image/gif";
|
||||
}
|
||||
|
||||
// WebP: RIFF????WEBP
|
||||
if (
|
||||
buf[0] === 0x52 &&
|
||||
buf[1] === 0x49 &&
|
||||
buf[2] === 0x46 &&
|
||||
buf[3] === 0x46 &&
|
||||
buf[8] === 0x57 &&
|
||||
buf[9] === 0x45 &&
|
||||
buf[10] === 0x42 &&
|
||||
buf[11] === 0x50
|
||||
) {
|
||||
return "image/webp";
|
||||
}
|
||||
|
||||
// AVIF / HEIF: ISO base media file format — ftyp box at offset 4
|
||||
if (
|
||||
buf.length >= 12 &&
|
||||
buf[4] === 0x66 &&
|
||||
buf[5] === 0x74 &&
|
||||
buf[6] === 0x79 &&
|
||||
buf[7] === 0x70
|
||||
) {
|
||||
const brand = buf.subarray(8, 12).toString("ascii");
|
||||
if (brand.startsWith("avif") || brand.startsWith("avis")) {
|
||||
return "image/avif";
|
||||
}
|
||||
if (
|
||||
brand.startsWith("mif1") ||
|
||||
brand.startsWith("heic") ||
|
||||
brand.startsWith("heis")
|
||||
) {
|
||||
return "image/heic";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs LLM-based moderation analysis on messages.
|
||||
* POSTs to AI_LLM_BASE_URL with auth bearer token.
|
||||
@@ -497,15 +579,35 @@ Return ONLY valid JSON, no other text.`;
|
||||
const res = await fetch(urlToUse);
|
||||
if (!res.ok) {
|
||||
log.warn(
|
||||
{ attachmentId: att.id, status: res.status },
|
||||
"Failed to fetch attachment image",
|
||||
{ attachmentId: att.id, status: res.status, url: urlToUse },
|
||||
"Failed to fetch attachment image — non-2xx status",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const buffer = await res.arrayBuffer();
|
||||
const base64Str = Buffer.from(buffer).toString("base64");
|
||||
const dataUrl = `data:${att.type};base64,${base64Str}`;
|
||||
const imageBytes = Buffer.from(buffer);
|
||||
|
||||
// Guard against HTML error pages, redirects, or octet streams
|
||||
// that the CDN might serve under a stale URL.
|
||||
const sniffedMime = sniffImageMimeType(imageBytes);
|
||||
if (!sniffedMime) {
|
||||
log.warn(
|
||||
{
|
||||
attachmentId: att.id,
|
||||
url: urlToUse,
|
||||
dbType: att.type,
|
||||
bytesLength: imageBytes.length,
|
||||
// First 16 bytes as hex to aid diagnosis
|
||||
headerHex: imageBytes.subarray(0, 16).toString("hex"),
|
||||
},
|
||||
"Skipping attachment: downloaded bytes are not a recognised image format",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const base64Str = imageBytes.toString("base64");
|
||||
const dataUrl = `data:${sniffedMime};base64,${base64Str}`;
|
||||
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -144,6 +144,7 @@ export async function captureMessage(
|
||||
attachment.url,
|
||||
attachment.name || "unknown",
|
||||
{
|
||||
contentType: attachment.contentType ?? undefined,
|
||||
refreshDiscordUrl: async () => {
|
||||
const freshMessage = await message.channel.messages.fetch(
|
||||
message.id,
|
||||
|
||||
Reference in New Issue
Block a user