test(qqbot): cover voice utility contracts

This commit is contained in:
Peter Steinberger
2026-04-25 12:57:09 +01:00
parent c307700db0
commit a48998d8c8
5 changed files with 452 additions and 0 deletions

View File

@@ -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<AudioConvertAdapter> = {}): 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]);
});
});

View File

@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from "vitest";
import { createMessageQueue, type QueuedMessage } from "./message-queue.js";
function makeMessage(overrides: Partial<QueuedMessage> = {}): QueuedMessage {
return {
type: "c2c",
senderId: "user-1",
content: "hello",
messageId: "msg-1",
timestamp: "2026-04-25T00:00:00.000Z",
...overrides,
};
}
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void;
const promise = new Promise<void>((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"));
});
});

View File

@@ -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> = {}): 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]",
);
});
});

View File

@@ -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",
);
});
});

View File

@@ -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),
}),
);
});
});