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:
MythEclipse
2026-05-21 23:44:18 +07:00
parent d76549f94a
commit d0e906763e
3 changed files with 118 additions and 7 deletions
+11 -3
View File
@@ -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) {
+106 -4
View File
@@ -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 [
{
+1
View File
@@ -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,