mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
test(qqbot): cover voice utility contracts
This commit is contained in:
128
extensions/qqbot/src/engine/gateway/inbound-attachments.test.ts
Normal file
128
extensions/qqbot/src/engine/gateway/inbound-attachments.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
92
extensions/qqbot/src/engine/gateway/message-queue.test.ts
Normal file
92
extensions/qqbot/src/engine/gateway/message-queue.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
60
extensions/qqbot/src/engine/ref/format-ref-entry.test.ts
Normal file
60
extensions/qqbot/src/engine/ref/format-ref-entry.test.ts
Normal 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]",
|
||||
);
|
||||
});
|
||||
});
|
||||
68
extensions/qqbot/src/engine/utils/payload.test.ts
Normal file
68
extensions/qqbot/src/engine/utils/payload.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
104
extensions/qqbot/src/engine/utils/stt.test.ts
Normal file
104
extensions/qqbot/src/engine/utils/stt.test.ts
Normal 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),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user