diff --git a/extensions/voice-call/index.test.ts b/extensions/voice-call/index.test.ts index 054d5aa0b51..2bef4003b90 100644 --- a/extensions/voice-call/index.test.ts +++ b/extensions/voice-call/index.test.ts @@ -178,6 +178,22 @@ function expectWarningIncludes(text: string): void { expect(noopLogger.warn.mock.calls.map(([message]) => String(message)).join("\n")).toContain(text); } +function expectRedactedVoiceCallStatus(value: unknown): void { + expect(value).toEqual({ + callId: "call-1", + provider: "mock", + direction: "outbound", + state: "active", + startedAt: Date.UTC(2026, 4, 2, 9, 0, 0), + }); + expect(value).not.toHaveProperty("from"); + expect(value).not.toHaveProperty("to"); + expect(value).not.toHaveProperty("sessionKey"); + expect(value).not.toHaveProperty("transcript"); + expect(value).not.toHaveProperty("processedEventIds"); + expect(value).not.toHaveProperty("metadata"); +} + async function registerVoiceCallCli( program: Command, pluginConfig: Record = { provider: "mock" }, @@ -475,7 +491,21 @@ describe("voice-call plugin", () => { expect(firstRespondCall(respond)[0]).toBe(true); }); - it("returns call status", async () => { + it("returns redacted call status", async () => { + const call = createCallRecord({ + metadata: { requesterSessionKey: "agent:main:discord:channel:general" }, + processedEventIds: ["evt-1"], + sessionKey: "agent:main:voice:call-1", + transcript: [ + { + timestamp: Date.UTC(2026, 4, 2, 9, 1, 0), + speaker: "user", + text: "private call transcript", + isFinal: true, + }, + ], + }); + runtimeStub.manager.getCall = vi.fn(() => call); const { methods } = setup({ provider: "mock" }); const handler = methods.get("voicecall.status") as | ((ctx: { @@ -488,6 +518,41 @@ describe("voice-call plugin", () => { const [ok, payload] = firstRespondCall(respond); expect(ok).toBe(true); expect(payload?.found).toBe(true); + expectRedactedVoiceCallStatus(payload?.call); + }); + + it("returns redacted active call status list", async () => { + const call = createCallRecord({ + metadata: { requesterSessionKey: "agent:main:discord:channel:general" }, + processedEventIds: ["evt-1"], + sessionKey: "agent:main:voice:call-1", + transcript: [ + { + timestamp: Date.UTC(2026, 4, 2, 9, 1, 0), + speaker: "user", + text: "private call transcript", + isFinal: true, + }, + ], + }); + runtimeStub.manager.getActiveCalls = vi.fn(() => [call]); + const { methods } = setup({ provider: "mock" }); + const handler = methods.get("voicecall.status") as + | ((ctx: { + params: Record; + respond: ReturnType; + }) => Promise) + | undefined; + const respond = vi.fn(); + + await handler?.({ params: {}, respond }); + + const [ok, payload] = firstRespondCall(respond); + expect(ok).toBe(true); + expect(payload?.found).toBe(true); + const calls = (payload?.calls as unknown[] | undefined) ?? []; + expect(calls).toHaveLength(1); + expectRedactedVoiceCallStatus(calls[0]); }); it("sends DTMF via voicecall.dtmf", async () => { @@ -650,6 +715,20 @@ describe("voice-call plugin", () => { }); it("tool get_status returns json payload", async () => { + const call = createCallRecord({ + metadata: { requesterSessionKey: "agent:main:discord:channel:general" }, + processedEventIds: ["evt-1"], + sessionKey: "agent:main:voice:call-1", + transcript: [ + { + timestamp: Date.UTC(2026, 4, 2, 9, 1, 0), + speaker: "user", + text: "private call transcript", + isFinal: true, + }, + ], + }); + runtimeStub.manager.getCall = vi.fn(() => call); const { tools } = setup({ provider: "mock" }); const tool = tools[0] as { execute: (id: string, params: unknown) => Promise; @@ -657,8 +736,9 @@ describe("voice-call plugin", () => { const result = (await tool.execute("id", { action: "get_status", callId: "call-1", - })) as { details: { found?: boolean } }; + })) as { details: { found?: boolean; call?: unknown } }; expect(result.details.found).toBe(true); + expectRedactedVoiceCallStatus(result.details.call); }); it("tool send_dtmf returns json payload", async () => { diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts index a675fff1b7f..c0fc23da4d9 100644 --- a/extensions/voice-call/index.ts +++ b/extensions/voice-call/index.ts @@ -23,6 +23,7 @@ import { } from "./src/config.js"; import type { CoreConfig } from "./src/core-bridge.js"; import { createVoiceCallContinueOperationStore } from "./src/gateway-continue-operation.js"; +import type { CallRecord } from "./src/types.js"; const VOICE_CALL_WRITE_METHOD_SCOPE = { scope: "operator.write" as const }; const VOICE_CALL_READ_METHOD_SCOPE = { scope: "operator.read" as const }; @@ -232,6 +233,33 @@ function isCliOnlyProcess(): boolean { return process.env.OPENCLAW_CLI === "1" && !process.argv.slice(2).includes("gateway"); } +type VoiceCallStatus = Pick< + CallRecord, + | "callId" + | "providerCallId" + | "provider" + | "direction" + | "state" + | "startedAt" + | "answeredAt" + | "endedAt" + | "endReason" +>; + +function toVoiceCallStatus(call: CallRecord): VoiceCallStatus { + return { + callId: call.callId, + ...(call.providerCallId !== undefined ? { providerCallId: call.providerCallId } : {}), + provider: call.provider, + direction: call.direction, + state: call.state, + startedAt: call.startedAt, + ...(call.answeredAt !== undefined ? { answeredAt: call.answeredAt } : {}), + ...(call.endedAt !== undefined ? { endedAt: call.endedAt } : {}), + ...(call.endReason !== undefined ? { endReason: call.endReason } : {}), + }; +} + const VOICE_CALL_RUNTIME_KEY = Symbol.for("openclaw.voice-call.runtime"); const VOICE_CALL_RUNTIME_PROMISE_KEY = Symbol.for("openclaw.voice-call.runtimePromise"); const VOICE_CALL_RUNTIME_STOP_PROMISE_KEY = Symbol.for("openclaw.voice-call.runtimeStopPromise"); @@ -623,7 +651,10 @@ export default definePluginEntry({ normalizeOptionalString(params?.callId) ?? normalizeOptionalString(params?.sid) ?? ""; const rt = await ensureRuntime(); if (!raw) { - respond(true, { found: true, calls: rt.manager.getActiveCalls() }); + respond(true, { + found: true, + calls: rt.manager.getActiveCalls().map(toVoiceCallStatus), + }); return; } const call = rt.manager.getCall(raw) || rt.manager.getCallByProviderCallId(raw); @@ -631,7 +662,7 @@ export default definePluginEntry({ respond(true, { found: false }); return; } - respond(true, { found: true, call }); + respond(true, { found: true, call: toVoiceCallStatus(call) }); } catch (err) { sendError(respond, err); } @@ -765,7 +796,9 @@ export default definePluginEntry({ } const call = rt.manager.getCall(callId) || rt.manager.getCallByProviderCallId(callId); - return json(call ? { found: true, call } : { found: false }); + return json( + call ? { found: true, call: toVoiceCallStatus(call) } : { found: false }, + ); } } } @@ -777,7 +810,7 @@ export default definePluginEntry({ throw new Error("sid required for status"); } const call = rt.manager.getCall(sid) || rt.manager.getCallByProviderCallId(sid); - return json(call ? { found: true, call } : { found: false }); + return json(call ? { found: true, call: toVoiceCallStatus(call) } : { found: false }); } const to = normalizeOptionalString(rawParams.to) ?? rt.config.toNumber;