diff --git a/extensions/qqbot/src/engine/gateway/inbound-attachments.test.ts b/extensions/qqbot/src/engine/gateway/inbound-attachments.test.ts index 970ea535886..e9eb0545eca 100644 --- a/extensions/qqbot/src/engine/gateway/inbound-attachments.test.ts +++ b/extensions/qqbot/src/engine/gateway/inbound-attachments.test.ts @@ -73,6 +73,58 @@ describe("engine/gateway/inbound-attachments", () => { expect(result.attachmentLocalPaths).toEqual([null]); }); + it("classifies image content types case-insensitively when download succeeds", async () => { + downloadFileMock.mockResolvedValueOnce("/tmp/openclaw-qqbot-downloads/a.png"); + downloadFileMock.mockResolvedValueOnce("/tmp/openclaw-qqbot-downloads/b.png"); + + const result = await processAttachments( + [ + { content_type: "image/png", url: "https://cdn.example.test/a.png", filename: "a.png" }, + { content_type: "Image/PNG", url: "https://cdn.example.test/b.png", filename: "b.png" }, + ], + { accountId: "qq", cfg: {}, audioConvert }, + ); + + expect(result.imageUrls).toEqual([ + "/tmp/openclaw-qqbot-downloads/a.png", + "/tmp/openclaw-qqbot-downloads/b.png", + ]); + // The raw content_type passes through unchanged. + expect(result.imageMediaTypes).toEqual(["image/png", "Image/PNG"]); + expect(result.attachmentInfo).toBe(""); + }); + + it("uses the remote image URL for a mixed-case image content type when download fails", async () => { + downloadFileMock.mockResolvedValue(null); + + const result = await processAttachments( + [{ content_type: "Image/PNG", url: "//cdn.example.test/a.png", filename: "a.png" }], + { accountId: "qq", cfg: {}, audioConvert }, + ); + + expect(result.imageUrls).toEqual(["https://cdn.example.test/a.png"]); + expect(result.imageMediaTypes).toEqual(["Image/PNG"]); + expect(result.attachmentLocalPaths).toEqual([null]); + }); + + it("does not classify a mixed-case non-image content type as an image", async () => { + downloadFileMock.mockResolvedValue("/tmp/openclaw-qqbot-downloads/doc.pdf"); + + const result = await processAttachments( + [ + { + content_type: "Application/PDF", + url: "https://cdn.example.test/doc.pdf", + filename: "doc.pdf", + }, + ], + { accountId: "qq", cfg: {}, audioConvert }, + ); + + expect(result.imageUrls).toEqual([]); + expect(result.attachmentInfo).toBe("\n[Attachment: /tmp/openclaw-qqbot-downloads/doc.pdf]"); + }); + it("prefers voice_wav_url for voice downloads and transcribes with configured STT", async () => { downloadFileMock.mockResolvedValue("/tmp/openclaw-qqbot-downloads/voice.wav"); resolveSTTConfigMock.mockReturnValue({ diff --git a/extensions/qqbot/src/engine/gateway/inbound-attachments.ts b/extensions/qqbot/src/engine/gateway/inbound-attachments.ts index 3a634588518..20f57d65910 100644 --- a/extensions/qqbot/src/engine/gateway/inbound-attachments.ts +++ b/extensions/qqbot/src/engine/gateway/inbound-attachments.ts @@ -3,7 +3,10 @@ import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { AudioConvertPort } from "../adapter/audio.port.js"; import { downloadFile } from "../utils/file-utils.js"; import { getQQBotMediaDir } from "../utils/platform.js"; -import { normalizeOptionalString } from "../utils/string-normalize.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../utils/string-normalize.js"; import { transcribeAudio, resolveSTTConfig } from "../utils/stt.js"; // Re-export the port type for convenience. @@ -115,6 +118,10 @@ export async function processAttachments( const processTasks = downloadResults.map( async ({ att, attUrl, isVoice, localPath, audioPath }) => { const asrReferText = normalizeOptionalString(att.asr_refer_text) ?? ""; + // MIME types are case-insensitive (RFC 2045) and relays may emit + // mixed-case values; compare lowercased but keep the raw content_type + // in returned fields. + const normalizedContentType = normalizeLowercaseStringOrEmpty(att.content_type); const wavUrl = isVoice && att.voice_wav_url ? att.voice_wav_url.startsWith("//") @@ -129,7 +136,7 @@ export async function processAttachments( }; if (localPath) { - if (att.content_type?.startsWith("image/")) { + if (normalizedContentType.startsWith("image/")) { log?.debug?.(`Downloaded attachment to: ${localPath}`); return { localPath, type: "image" as const, contentType: att.content_type, meta }; } @@ -150,7 +157,7 @@ export async function processAttachments( return { localPath, type: "other" as const, filename: att.filename, meta }; } log?.error(`Failed to download: ${attUrl}`); - if (att.content_type?.startsWith("image/")) { + if (normalizedContentType.startsWith("image/")) { return { localPath: null, type: "image-fallback" as const, diff --git a/extensions/qqbot/src/engine/utils/audio.test.ts b/extensions/qqbot/src/engine/utils/audio.test.ts index a3010f67fb0..86cacbb5251 100644 --- a/extensions/qqbot/src/engine/utils/audio.test.ts +++ b/extensions/qqbot/src/engine/utils/audio.test.ts @@ -97,6 +97,12 @@ describe("engine/utils/audio", () => { expect(isVoiceAttachment({ filename: "msg.slac" })).toBe(true); }); + it("treats content_type case-insensitively", () => { + expect(isVoiceAttachment({ content_type: "Voice" })).toBe(true); + expect(isVoiceAttachment({ content_type: "Audio/Silk" })).toBe(true); + expect(isVoiceAttachment({ content_type: "Image/PNG" })).toBe(false); + }); + it("rejects non-voice attachments", () => { expect(isVoiceAttachment({ content_type: "image/png" })).toBe(false); expect(isVoiceAttachment({ filename: "photo.jpg" })).toBe(false); diff --git a/extensions/qqbot/src/engine/utils/audio.ts b/extensions/qqbot/src/engine/utils/audio.ts index 04cea695c98..1718d20a1f1 100644 --- a/extensions/qqbot/src/engine/utils/audio.ts +++ b/extensions/qqbot/src/engine/utils/audio.ts @@ -118,7 +118,11 @@ export async function convertSilkToWav( /** Check whether an attachment is a voice file (by MIME type or extension). */ export function isVoiceAttachment(att: { content_type?: string; filename?: string }): boolean { - if (att.content_type === "voice" || att.content_type?.startsWith("audio/")) { + // MIME types are case-insensitive (RFC 2045) and relays may emit mixed-case + // values; the bare "voice" platform sentinel gets the same treatment. + // Compare lowercased like the extension check below. + const contentType = normalizeLowercase(att.content_type); + if (contentType === "voice" || contentType.startsWith("audio/")) { return true; } const ext = att.filename ? normalizeLowercase(path.extname(att.filename)) : "";