// Talk Voice tests cover index plugin behavior. import type { OpenClawPluginCommandDefinition } from "openclaw/plugin-sdk/core"; import { describe, expect, it, vi } from "vitest"; import type { PluginRuntime } from "./api.js"; import register from "./index.js"; function createHarness(initialConfig: Record) { let config = initialConfig; let command: OpenClawPluginCommandDefinition | undefined; const runtime = { config: { current: vi.fn(() => config), loadConfig: vi.fn(() => config), mutateConfigFile: vi.fn( async ({ mutate }: { mutate: (draft: Record) => void }) => { const draft = structuredClone(config); mutate(draft); config = draft; return { path: "/tmp/openclaw.json", previousHash: null, persistedHash: null, snapshot: {}, nextConfig: config, afterWrite: { mode: "auto" }, followUp: { mode: "auto", requiresRestart: false }, result: undefined, }; }, ), replaceConfigFile: vi.fn(async ({ nextConfig }: { nextConfig: Record }) => { config = nextConfig; }), writeConfigFile: vi.fn().mockResolvedValue(undefined), }, tts: { listVoices: vi.fn(), }, } as unknown as PluginRuntime; const api = { runtime, registerCommand: vi.fn((definition: OpenClawPluginCommandDefinition) => { command = definition; }), }; register.register(api as never); if (!command) { throw new Error("talk-voice command not registered"); } return { command, runtime }; } function createCommandContext( args: string, channel = "discord", gatewayClientScopes?: string[], senderIsOwner?: boolean, ) { return { args, channel, channelId: channel, isAuthorizedSender: true, gatewayClientScopes, senderIsOwner, commandBody: args ? `/voice ${args}` : "/voice", config: {}, requestConversationBinding: vi.fn(), detachConversationBinding: vi.fn(), getCurrentConversationBinding: vi.fn(), }; } describe("talk-voice plugin", () => { function createElevenlabsVoiceSetHarness(channel = "webchat", scopes?: string[]) { const { command, runtime } = createHarness({ talk: { provider: "elevenlabs", providers: { elevenlabs: { apiKey: "sk-eleven", }, }, }, }); vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]); return { runtime, run: async () => await command.handler(createCommandContext("set Claudia", channel, scopes)), }; } it("reports active provider status", async () => { const { command } = createHarness({ talk: { provider: "microsoft", providers: { microsoft: { voiceId: "en-US-AvaNeural", apiKey: "secret-token", }, }, }, }); const result = await command.handler(createCommandContext("")); expect(result).toEqual({ text: "Talk voice status:\n" + "- provider: microsoft\n" + "- talk.providers.microsoft.voiceId: en-US-AvaNeural\n" + "- microsoft.apiKey: secret…", }); }); it("exposes owner status for mutating voice commands", () => { const { command } = createHarness({}); expect(command.exposeSenderIsOwner).toBe(true); }); it("lists voices from the active provider", async () => { const { command, runtime } = createHarness({ talk: { provider: "elevenlabs", providers: { elevenlabs: { apiKey: "sk-eleven", baseUrl: "https://voices.example.test", }, }, }, }); vi.mocked(runtime.tts.listVoices).mockResolvedValue([ { id: "voice-a", name: "Claudia", category: "general" }, { id: "voice-b", name: "Bert" }, ]); const result = await command.handler(createCommandContext("list +01")); expect(runtime.tts.listVoices).toHaveBeenCalledWith({ provider: "elevenlabs", cfg: { talk: { provider: "elevenlabs", providers: { elevenlabs: { apiKey: "sk-eleven", baseUrl: "https://voices.example.test", }, }, }, }, apiKey: "sk-eleven", baseUrl: "https://voices.example.test", }); expect(result).toEqual({ text: "ElevenLabs voices: 2\n\n" + "- Claudia · general\n" + " id: voice-a\n\n" + "(showing first 1)", }); }); it("does not coerce partial voice list limits", async () => { const { command, runtime } = createHarness({ talk: { provider: "elevenlabs", providers: { elevenlabs: { apiKey: "sk-eleven", }, }, }, }); vi.mocked(runtime.tts.listVoices).mockResolvedValue( Array.from({ length: 13 }, (_, index) => ({ id: `voice-${index}`, name: `Voice ${index}`, })), ); const result = await command.handler(createCommandContext("list 1x")); expect(result.text).toContain("(showing first 12)"); }); it("surfaces richer provider voice metadata when available", async () => { const { command, runtime } = createHarness({ talk: { provider: "microsoft", providers: { microsoft: {}, }, }, }); vi.mocked(runtime.tts.listVoices).mockResolvedValue([ { id: "en-US-AvaNeural", name: "Ava", category: "General", locale: "en-US", gender: "Female", personalities: ["Friendly", "Positive"], description: "Friendly, Positive", }, ]); const result = await command.handler(createCommandContext("list")); expect(result).toEqual({ text: "Microsoft voices: 1\n\n" + "- Ava · General\n" + " id: en-US-AvaNeural\n" + " meta: en-US · Female · Friendly, Positive\n" + " note: Friendly, Positive", }); }); it("writes canonical talk provider config and legacy elevenlabs voice id", async () => { const { command, runtime } = createHarness({ talk: { provider: "elevenlabs", providers: { elevenlabs: { apiKey: "sk-eleven", }, }, }, }); vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]); const result = await command.handler( createCommandContext("set Claudia", "webchat", ["operator.admin"]), ); expect(runtime.config.mutateConfigFile).toHaveBeenCalledWith({ afterWrite: { mode: "auto" }, mutate: expect.any(Function), }); expect(runtime.config.current()).toEqual({ talk: { provider: "elevenlabs", providers: { elevenlabs: { apiKey: "sk-eleven", voiceId: "voice-a", }, }, voiceId: "voice-a", }, }); expect(result).toEqual({ text: "✅ ElevenLabs Talk voice set to Claudia\nvoice-a", }); }); it("writes provider voice id without legacy top-level field for microsoft", async () => { const { command, runtime } = createHarness({ talk: { provider: "microsoft", providers: { microsoft: {}, }, }, }); vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "en-US-AvaNeural", name: "Ava" }]); await command.handler(createCommandContext("set Ava", "webchat", ["operator.admin"])); expect(runtime.config.mutateConfigFile).toHaveBeenCalledWith({ afterWrite: { mode: "auto" }, mutate: expect.any(Function), }); expect(runtime.config.current()).toEqual({ talk: { provider: "microsoft", providers: { microsoft: { voiceId: "en-US-AvaNeural", }, }, }, }); }); it("rejects /voice set from gateway client with only operator.write scope", async () => { const { runtime, run } = createElevenlabsVoiceSetHarness("webchat", ["operator.write"]); const result = await run(); expect(result.text).toContain("requires operator.admin"); expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled(); }); it("rejects /voice set from non-webchat gateway callers missing operator.admin", async () => { const { runtime, run } = createElevenlabsVoiceSetHarness("telegram", ["operator.write"]); const result = await run(); expect(result.text).toContain("requires operator.admin"); expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled(); }); it("allows /voice set from gateway client with operator.admin scope", async () => { const { runtime, run } = createElevenlabsVoiceSetHarness("webchat", ["operator.admin"]); const result = await run(); expect(runtime.config.mutateConfigFile).toHaveBeenCalled(); expect(result.text).toContain("voice-a"); }); it("rejects /voice set from webchat channel with no scopes (TUI/internal)", async () => { const { runtime, run } = createElevenlabsVoiceSetHarness(); const result = await run(); expect(result.text).toContain("requires operator.admin"); expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled(); }); it.each(["telegram", "discord"])( "rejects /voice set from %s channel without operator.admin", async (channel) => { const { runtime, run } = createElevenlabsVoiceSetHarness(channel); const result = await run(); expect(result.text).toContain("requires operator.admin"); expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled(); }, ); it("keeps read-only voice commands available without operator.admin", async () => { const { command, runtime } = createHarness({ talk: { provider: "elevenlabs", providers: { elevenlabs: { apiKey: "sk-eleven", }, }, }, }); vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]); const status = await command.handler(createCommandContext("status", "telegram")); const list = await command.handler(createCommandContext("list", "telegram")); expect(status.text).toContain("Talk voice status:"); expect(list.text).toContain("ElevenLabs voices: 1"); expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled(); }); it("allows /voice set when operator.admin is present on a non-webchat channel", async () => { const { runtime, run } = createElevenlabsVoiceSetHarness("telegram", ["operator.admin"]); const result = await run(); expect(runtime.config.mutateConfigFile).toHaveBeenCalled(); expect(result.text).toContain("voice-a"); }); it("allows /voice set from an owner non-gateway channel without scopes", async () => { const { command, runtime } = createHarness({ talk: { provider: "elevenlabs", providers: { elevenlabs: { apiKey: "sk-eleven", }, }, }, }); vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]); const result = await command.handler( createCommandContext("set Claudia", "telegram", undefined, true), ); expect(runtime.config.mutateConfigFile).toHaveBeenCalled(); expect(result.text).toContain("voice-a"); }); it("returns provider lookup errors cleanly", async () => { const { command, runtime } = createHarness({ talk: { provider: "microsoft", providers: { microsoft: {}, }, }, }); vi.mocked(runtime.tts.listVoices).mockRejectedValue( new Error("speech provider microsoft does not support voice listing"), ); const result = await command.handler(createCommandContext("list")); expect(result).toEqual({ text: "Microsoft voice list failed: speech provider microsoft does not support voice listing", }); }); });