fix(qqbot): treat inbound attachment content types case-insensitively (#102753)

* fix(qqbot): treat inbound attachment content types case-insensitively

* test(qqbot): use distinct download paths per image; note voice sentinel in comment
This commit is contained in:
Masato Hoshino
2026-07-09 05:05:34 -07:00
committed by GitHub
parent 813a5da839
commit a49567cdfd
4 changed files with 73 additions and 4 deletions
@@ -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({
@@ -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,
@@ -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);
+5 -1
View File
@@ -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)) : "";