From a48998d8c8e0127a645abc88cd048bc1130631cd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 12:57:09 +0100 Subject: [PATCH] test(qqbot): cover voice utility contracts --- .../gateway/inbound-attachments.test.ts | 128 ++++++++++++++++++ .../src/engine/gateway/message-queue.test.ts | 92 +++++++++++++ .../src/engine/ref/format-ref-entry.test.ts | 60 ++++++++ .../qqbot/src/engine/utils/payload.test.ts | 68 ++++++++++ extensions/qqbot/src/engine/utils/stt.test.ts | 104 ++++++++++++++ 5 files changed, 452 insertions(+) create mode 100644 extensions/qqbot/src/engine/gateway/inbound-attachments.test.ts create mode 100644 extensions/qqbot/src/engine/gateway/message-queue.test.ts create mode 100644 extensions/qqbot/src/engine/ref/format-ref-entry.test.ts create mode 100644 extensions/qqbot/src/engine/utils/payload.test.ts create mode 100644 extensions/qqbot/src/engine/utils/stt.test.ts diff --git a/extensions/qqbot/src/engine/gateway/inbound-attachments.test.ts b/extensions/qqbot/src/engine/gateway/inbound-attachments.test.ts new file mode 100644 index 00000000000..6e1d7e69ab2 --- /dev/null +++ b/extensions/qqbot/src/engine/gateway/inbound-attachments.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + processAttachments, + registerAudioConvertAdapter, + type AudioConvertAdapter, +} from "./inbound-attachments.js"; + +const downloadFileMock = vi.hoisted(() => vi.fn()); +const resolveSTTConfigMock = vi.hoisted(() => vi.fn()); +const transcribeAudioMock = vi.hoisted(() => vi.fn()); + +vi.mock("../utils/file-utils.js", () => ({ + downloadFile: downloadFileMock, +})); + +vi.mock("../utils/platform.js", () => ({ + getQQBotMediaDir: () => "/tmp/openclaw-qqbot-downloads", +})); + +vi.mock("../utils/stt.js", () => ({ + resolveSTTConfig: resolveSTTConfigMock, + transcribeAudio: transcribeAudioMock, +})); + +function registerAdapter(overrides: Partial = {}): void { + registerAudioConvertAdapter({ + convertSilkToWav: vi.fn(async () => null), + formatDuration: (seconds) => `${seconds}s`, + isVoiceAttachment: (att) => + att.content_type === "voice" || att.content_type.startsWith("audio/"), + ...overrides, + }); +} + +describe("engine/gateway/inbound-attachments", () => { + beforeEach(() => { + vi.clearAllMocks(); + resolveSTTConfigMock.mockReturnValue(null); + transcribeAudioMock.mockResolvedValue(null); + registerAdapter(); + }); + + it("returns an empty result when no attachments are present", async () => { + await expect( + processAttachments(undefined, { accountId: "qq", cfg: {} }), + ).resolves.toMatchObject({ + attachmentInfo: "", + imageUrls: [], + voiceAttachmentPaths: [], + attachmentLocalPaths: [], + }); + }); + + it("uses remote image URL when image 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: {} }, + ); + + expect(downloadFileMock).toHaveBeenCalledWith( + "https://cdn.example.test/a.png", + "/tmp/openclaw-qqbot-downloads", + "a.png", + ); + expect(result.imageUrls).toEqual(["https://cdn.example.test/a.png"]); + expect(result.imageMediaTypes).toEqual(["image/png"]); + expect(result.attachmentLocalPaths).toEqual([null]); + }); + + it("prefers voice_wav_url for voice downloads and transcribes with configured STT", async () => { + downloadFileMock.mockResolvedValue("/tmp/openclaw-qqbot-downloads/voice.wav"); + resolveSTTConfigMock.mockReturnValue({ + baseUrl: "https://stt.example.test", + apiKey: "key", + model: "whisper-1", + }); + transcribeAudioMock.mockResolvedValue("transcribed voice"); + + const result = await processAttachments( + [ + { + content_type: "voice", + url: "https://cdn.example.test/voice.silk", + filename: "voice.silk", + voice_wav_url: "//cdn.example.test/voice.wav", + asr_refer_text: "platform text", + }, + ], + { accountId: "qq", cfg: { channels: { qqbot: { stt: {} } } } }, + ); + + expect(downloadFileMock).toHaveBeenCalledWith( + "https://cdn.example.test/voice.wav", + "/tmp/openclaw-qqbot-downloads", + ); + expect(transcribeAudioMock).toHaveBeenCalledWith("/tmp/openclaw-qqbot-downloads/voice.wav", { + channels: { qqbot: { stt: {} } }, + }); + expect(result.voiceAttachmentPaths).toEqual(["/tmp/openclaw-qqbot-downloads/voice.wav"]); + expect(result.voiceAttachmentUrls).toEqual(["https://cdn.example.test/voice.wav"]); + expect(result.voiceAsrReferTexts).toEqual(["platform text"]); + expect(result.voiceTranscripts).toEqual(["transcribed voice"]); + expect(result.voiceTranscriptSources).toEqual(["stt"]); + }); + + it("falls back to platform ASR text when voice download fails", async () => { + downloadFileMock.mockResolvedValue(null); + + const result = await processAttachments( + [ + { + content_type: "voice", + url: "https://cdn.example.test/voice.silk", + filename: "voice.silk", + asr_refer_text: "platform text", + }, + ], + { accountId: "qq", cfg: {} }, + ); + + expect(result.voiceAttachmentUrls).toEqual(["https://cdn.example.test/voice.silk"]); + expect(result.voiceTranscripts).toEqual(["platform text"]); + expect(result.voiceTranscriptSources).toEqual(["asr"]); + expect(result.attachmentLocalPaths).toEqual([null]); + }); +}); diff --git a/extensions/qqbot/src/engine/gateway/message-queue.test.ts b/extensions/qqbot/src/engine/gateway/message-queue.test.ts new file mode 100644 index 00000000000..83ee86bf592 --- /dev/null +++ b/extensions/qqbot/src/engine/gateway/message-queue.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; +import { createMessageQueue, type QueuedMessage } from "./message-queue.js"; + +function makeMessage(overrides: Partial = {}): QueuedMessage { + return { + type: "c2c", + senderId: "user-1", + content: "hello", + messageId: "msg-1", + timestamp: "2026-04-25T00:00:00.000Z", + ...overrides, + }; +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +describe("engine/gateway/message-queue", () => { + it("derives peer ids by message surface", () => { + const q = createMessageQueue({ accountId: "qq", isAborted: () => false }); + + expect(q.getMessagePeerId(makeMessage({ type: "c2c", senderId: "alice" }))).toBe("dm:alice"); + expect(q.getMessagePeerId(makeMessage({ type: "dm", senderId: "alice" }))).toBe("dm:alice"); + expect(q.getMessagePeerId(makeMessage({ type: "guild", channelId: "chan" }))).toBe( + "guild:chan", + ); + expect(q.getMessagePeerId(makeMessage({ type: "group", groupOpenid: "group" }))).toBe( + "group:group", + ); + }); + + it("serializes messages for the same peer and reports cleared pending messages", async () => { + const first = deferred(); + const handled: string[] = []; + const q = createMessageQueue({ accountId: "qq", isAborted: () => false }); + q.startProcessor( + vi.fn(async (msg) => { + handled.push(msg.messageId); + if (msg.messageId === "msg-1") { + await first.promise; + } + }), + ); + + q.enqueue(makeMessage({ messageId: "msg-1" })); + q.enqueue(makeMessage({ messageId: "msg-2" })); + q.enqueue(makeMessage({ messageId: "msg-3" })); + + expect(q.getSnapshot("dm:user-1")).toMatchObject({ + totalPending: 2, + activeUsers: 1, + senderPending: 2, + }); + expect(q.clearUserQueue("dm:user-1")).toBe(2); + expect(q.getSnapshot("dm:user-1")).toMatchObject({ + totalPending: 0, + activeUsers: 1, + senderPending: 0, + }); + + first.resolve(); + await Promise.resolve(); + expect(handled).toEqual(["msg-1"]); + }); + + it("logs processor errors and continues draining the peer queue", async () => { + const log = { error: vi.fn(), info: vi.fn(), debug: vi.fn() }; + const handled: string[] = []; + const q = createMessageQueue({ accountId: "qq", log, isAborted: () => false }); + q.startProcessor( + vi.fn(async (msg) => { + handled.push(msg.messageId); + if (msg.messageId === "bad") { + throw new Error("boom"); + } + }), + ); + + q.enqueue(makeMessage({ messageId: "bad" })); + q.enqueue(makeMessage({ messageId: "next" })); + await Promise.resolve(); + await Promise.resolve(); + + expect(handled).toEqual(["bad", "next"]); + expect(log.error).toHaveBeenCalledWith(expect.stringContaining("Message processor error")); + }); +}); diff --git a/extensions/qqbot/src/engine/ref/format-ref-entry.test.ts b/extensions/qqbot/src/engine/ref/format-ref-entry.test.ts new file mode 100644 index 00000000000..99cb1a77010 --- /dev/null +++ b/extensions/qqbot/src/engine/ref/format-ref-entry.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { formatRefEntryForAgent } from "./format-ref-entry.js"; +import type { RefIndexEntry } from "./types.js"; + +function makeEntry(overrides: Partial = {}): RefIndexEntry { + return { + content: "hello", + senderId: "user-1", + timestamp: 1, + ...overrides, + }; +} + +describe("engine/ref/format-ref-entry", () => { + it("formats text and attachment hints for model context", () => { + const formatted = formatRefEntryForAgent( + makeEntry({ + content: "see these", + attachments: [ + { + type: "image", + filename: "photo.png", + localPath: "/tmp/photo.png", + }, + { + type: "voice", + transcript: "spoken words", + transcriptSource: "asr", + url: "https://example.test/voice.amr", + }, + { + type: "file", + filename: "notes.txt", + }, + ], + }), + ); + + expect(formatted).toBe( + 'see these [image: photo.png (/tmp/photo.png)] [voice message (content: "spoken words" - platform ASR) (https://example.test/voice.amr)] [file: notes.txt]', + ); + }); + + it("keeps voice attachments visible when no transcript exists", () => { + expect( + formatRefEntryForAgent( + makeEntry({ + content: "", + attachments: [{ type: "voice", localPath: "/tmp/voice.wav" }], + }), + ), + ).toBe("[voice message (/tmp/voice.wav)]"); + }); + + it("returns an explicit empty marker for blank entries", () => { + expect(formatRefEntryForAgent(makeEntry({ content: " ", attachments: [] }))).toBe( + "[empty message]", + ); + }); +}); diff --git a/extensions/qqbot/src/engine/utils/payload.test.ts b/extensions/qqbot/src/engine/utils/payload.test.ts new file mode 100644 index 00000000000..16dc651ad64 --- /dev/null +++ b/extensions/qqbot/src/engine/utils/payload.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { + decodeCronPayload, + encodePayloadForCron, + isCronReminderPayload, + isMediaPayload, + parseQQBotPayload, + type CronReminderPayload, +} from "./payload.js"; + +describe("engine/utils/payload", () => { + it("returns original text for non-payload replies", () => { + const result = parseQQBotPayload(" plain reply "); + + expect(result).toEqual({ isPayload: false, text: " plain reply " }); + }); + + it("parses a media payload", () => { + const result = parseQQBotPayload( + 'QQBOT_PAYLOAD: {"type":"media","mediaType":"image","source":"url","path":"https://example.test/a.png","caption":"cap"}', + ); + + expect(result.isPayload).toBe(true); + expect(result.payload).toEqual({ + type: "media", + mediaType: "image", + source: "url", + path: "https://example.test/a.png", + caption: "cap", + }); + expect(result.payload && isMediaPayload(result.payload)).toBe(true); + }); + + it("rejects malformed or incomplete payloads", () => { + expect(parseQQBotPayload("QQBOT_PAYLOAD:").error).toBe("Payload body is empty"); + expect(parseQQBotPayload("QQBOT_PAYLOAD: {bad json").error).toContain("Failed to parse JSON"); + expect(parseQQBotPayload('QQBOT_PAYLOAD: {"type":"media","mediaType":"image"}').error).toBe( + "media payload is missing required fields (mediaType, source, path)", + ); + }); + + it("round-trips cron reminder payloads through the stored format", () => { + const payload: CronReminderPayload = { + type: "cron_reminder", + content: "standup", + targetType: "group", + targetAddress: "group-openid", + originalMessageId: "msg-1", + }; + + const encoded = encodePayloadForCron(payload); + expect(encoded).toMatch(/^QQBOT_CRON:/); + + const decoded = decodeCronPayload(encoded); + expect(decoded).toEqual({ isCronPayload: true, payload }); + expect(decoded.payload && isCronReminderPayload(decoded.payload)).toBe(true); + }); + + it("reports cron decode errors without throwing", () => { + expect(decodeCronPayload("plain")).toEqual({ isCronPayload: false }); + expect(decodeCronPayload("QQBOT_CRON:").error).toBe("Cron payload body is empty"); + + const wrongType = Buffer.from('{"type":"media"}', "utf-8").toString("base64"); + expect(decodeCronPayload(`QQBOT_CRON:${wrongType}`).error).toBe( + "Expected type cron_reminder but got media", + ); + }); +}); diff --git a/extensions/qqbot/src/engine/utils/stt.test.ts b/extensions/qqbot/src/engine/utils/stt.test.ts new file mode 100644 index 00000000000..5e179c69203 --- /dev/null +++ b/extensions/qqbot/src/engine/utils/stt.test.ts @@ -0,0 +1,104 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveSTTConfig, transcribeAudio } from "./stt.js"; + +describe("engine/utils/stt", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("resolves plugin STT config and falls back to provider credentials", () => { + const cfg = { + channels: { + qqbot: { + stt: { + provider: "openai", + baseUrl: "https://api.example.test/v1///", + model: "whisper-large", + }, + }, + }, + models: { + providers: { + openai: { + apiKey: "provider-key", + }, + }, + }, + }; + + expect(resolveSTTConfig(cfg)).toEqual({ + baseUrl: "https://api.example.test/v1", + apiKey: "provider-key", + model: "whisper-large", + }); + }); + + it("falls back to framework audio model config when plugin STT is disabled", () => { + const cfg = { + channels: { qqbot: { stt: { enabled: false, apiKey: "ignored" } } }, + tools: { + media: { + audio: { + models: [{ provider: "local", baseUrl: "https://stt.example.test/", model: "sense" }], + }, + }, + }, + models: { + providers: { + local: { apiKey: "local-key" }, + }, + }, + }; + + expect(resolveSTTConfig(cfg)).toEqual({ + baseUrl: "https://stt.example.test", + apiKey: "local-key", + model: "sense", + }); + }); + + it("returns null when no usable STT credentials are configured", () => { + expect(resolveSTTConfig({ channels: { qqbot: { stt: { baseUrl: "https://x.test" } } } })).toBe( + null, + ); + expect(resolveSTTConfig({})).toBe(null); + }); + + it("posts audio to OpenAI-compatible transcription endpoint", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-qqbot-stt-")); + const audioPath = path.join(tmpDir, "voice.wav"); + fs.writeFileSync(audioPath, Buffer.from([1, 2, 3, 4])); + + const fetchMock = vi.fn(async () => + Response.json({ + text: "hello from audio", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const transcript = await transcribeAudio(audioPath, { + channels: { + qqbot: { + stt: { + baseUrl: "https://api.example.test/v1/", + apiKey: "secret", + model: "whisper-1", + }, + }, + }, + }); + + expect(transcript).toBe("hello from audio"); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.test/v1/audio/transcriptions", + expect.objectContaining({ + method: "POST", + headers: { Authorization: "Bearer secret" }, + body: expect.any(FormData), + }), + ); + }); +});