Merge pull request #4325: fix(voice-call) verify stale calls with provider

This commit is contained in:
Peter Steinberger
2026-03-03 00:14:37 +00:00
12 changed files with 437 additions and 26 deletions

View File

@@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai
- Voice-call/Twilio signature verification: retry signature validation across deterministic URL port variants (with/without port) to handle mixed Twilio signing behavior behind reverse proxies and non-standard ports. (#25140) Thanks @drvoss.
- Voice-call/Twilio external outbound: auto-register webhook-first `outbound-api` calls (initiated outside OpenClaw) so media streams are accepted and call direction metadata stays accurate. (#31181) Thanks @scoootscooob.
- Voice-call/Twilio inbound greeting: run answered-call initial notify greeting for Twilio instead of skipping the manager speak path, with regression coverage for both Twilio and Plivo notify flows. (#29121) Thanks @xinhuagu.
- Voice-call/stale call hydration: verify active calls with the provider before loading persisted in-progress calls so stale locally persisted records do not block or misroute new call handling after restarts. (#4325) Thanks @garnetlyx.
- Feishu/topic session routing: use `thread_id` as topic session scope fallback when `root_id` is absent, keep first-turn topic keys stable across thread creation, and force thread replies when inbound events already carry topic/thread context. (#29788) Thanks @songyaolun.
- Feishu/topic root replies: prefer `root_id` as outbound `replyTargetMessageId` when present, and parse millisecond `message_create_time` values correctly so topic replies anchor to the root message in grouped thread flows. (#29968) Thanks @bmendonca3.
- Feishu/DM pairing reply target: send pairing challenge replies to `chat:<chat_id>` instead of `user:<sender_open_id>` so Lark/Feishu private chats with user-id-only sender payloads receive pairing messages reliably. (#31403) Thanks @stakeswky.

View File

@@ -1,3 +1,4 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
@@ -5,6 +6,8 @@ import { VoiceCallConfigSchema } from "./config.js";
import { CallManager } from "./manager.js";
import type { VoiceCallProvider } from "./providers/base.js";
import type {
GetCallStatusInput,
GetCallStatusResult,
HangupCallInput,
InitiateCallInput,
InitiateCallResult,
@@ -22,6 +25,7 @@ class FakeProvider implements VoiceCallProvider {
readonly hangupCalls: HangupCallInput[] = [];
readonly startListeningCalls: StartListeningInput[] = [];
readonly stopListeningCalls: StopListeningInput[] = [];
getCallStatusResult: GetCallStatusResult = { status: "in-progress", isTerminal: false };
constructor(name: "plivo" | "twilio" = "plivo") {
this.name = name;
@@ -48,6 +52,9 @@ class FakeProvider implements VoiceCallProvider {
async stopListening(input: StopListeningInput): Promise<void> {
this.stopListeningCalls.push(input);
}
async getCallStatus(_input: GetCallStatusInput): Promise<GetCallStatusResult> {
return this.getCallStatusResult;
}
}
let storeSeq = 0;
@@ -57,13 +64,13 @@ function createTestStorePath(): string {
return path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}-${storeSeq}`);
}
function createManagerHarness(
async function createManagerHarness(
configOverrides: Record<string, unknown> = {},
provider = new FakeProvider(),
): {
): Promise<{
manager: CallManager;
provider: FakeProvider;
} {
}> {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
@@ -71,7 +78,7 @@ function createManagerHarness(
...configOverrides,
});
const manager = new CallManager(config, createTestStorePath());
manager.initialize(provider, "https://example.com/voice/webhook");
await manager.initialize(provider, "https://example.com/voice/webhook");
return { manager, provider };
}
@@ -87,7 +94,7 @@ function markCallAnswered(manager: CallManager, callId: string, eventId: string)
describe("CallManager", () => {
it("upgrades providerCallId mapping when provider ID changes", async () => {
const { manager } = createManagerHarness();
const { manager } = await createManagerHarness();
const { callId, success, error } = await manager.initiateCall("+15550000001");
expect(success).toBe(true);
@@ -114,7 +121,7 @@ describe("CallManager", () => {
it.each(["plivo", "twilio"] as const)(
"speaks initial message on answered for notify mode (%s)",
async (providerName) => {
const { manager, provider } = createManagerHarness({}, new FakeProvider(providerName));
const { manager, provider } = await createManagerHarness({}, new FakeProvider(providerName));
const { callId, success } = await manager.initiateCall("+15550000002", undefined, {
message: "Hello there",
@@ -137,8 +144,8 @@ describe("CallManager", () => {
},
);
it("rejects inbound calls with missing caller ID when allowlist enabled", () => {
const { manager, provider } = createManagerHarness({
it("rejects inbound calls with missing caller ID when allowlist enabled", async () => {
const { manager, provider } = await createManagerHarness({
inboundPolicy: "allowlist",
allowFrom: ["+15550001234"],
});
@@ -158,8 +165,8 @@ describe("CallManager", () => {
expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-missing");
});
it("rejects inbound calls with anonymous caller ID when allowlist enabled", () => {
const { manager, provider } = createManagerHarness({
it("rejects inbound calls with anonymous caller ID when allowlist enabled", async () => {
const { manager, provider } = await createManagerHarness({
inboundPolicy: "allowlist",
allowFrom: ["+15550001234"],
});
@@ -180,8 +187,8 @@ describe("CallManager", () => {
expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-anon");
});
it("rejects inbound calls that only match allowlist suffixes", () => {
const { manager, provider } = createManagerHarness({
it("rejects inbound calls that only match allowlist suffixes", async () => {
const { manager, provider } = await createManagerHarness({
inboundPolicy: "allowlist",
allowFrom: ["+15550001234"],
});
@@ -202,8 +209,8 @@ describe("CallManager", () => {
expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-suffix");
});
it("rejects duplicate inbound events with a single hangup call", () => {
const { manager, provider } = createManagerHarness({
it("rejects duplicate inbound events with a single hangup call", async () => {
const { manager, provider } = await createManagerHarness({
inboundPolicy: "disabled",
});
@@ -234,8 +241,8 @@ describe("CallManager", () => {
expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-dup");
});
it("accepts inbound calls that exactly match the allowlist", () => {
const { manager } = createManagerHarness({
it("accepts inbound calls that exactly match the allowlist", async () => {
const { manager } = await createManagerHarness({
inboundPolicy: "allowlist",
allowFrom: ["+15550001234"],
});
@@ -255,7 +262,7 @@ describe("CallManager", () => {
});
it("completes a closed-loop turn without live audio", async () => {
const { manager, provider } = createManagerHarness({
const { manager, provider } = await createManagerHarness({
transcriptTimeoutMs: 5000,
});
@@ -295,7 +302,7 @@ describe("CallManager", () => {
});
it("rejects overlapping continueCall requests for the same call", async () => {
const { manager, provider } = createManagerHarness({
const { manager, provider } = await createManagerHarness({
transcriptTimeoutMs: 5000,
});
@@ -327,7 +334,7 @@ describe("CallManager", () => {
});
it("ignores speech events with mismatched turnToken while waiting for transcript", async () => {
const { manager, provider } = createManagerHarness(
const { manager, provider } = await createManagerHarness(
{
transcriptTimeoutMs: 5000,
},
@@ -382,7 +389,7 @@ describe("CallManager", () => {
});
it("tracks latency metadata across multiple closed-loop turns", async () => {
const { manager, provider } = createManagerHarness({
const { manager, provider } = await createManagerHarness({
transcriptTimeoutMs: 5000,
});
@@ -435,7 +442,7 @@ describe("CallManager", () => {
});
it("handles repeated closed-loop turns without waiter churn", async () => {
const { manager, provider } = createManagerHarness({
const { manager, provider } = await createManagerHarness({
transcriptTimeoutMs: 5000,
});
@@ -468,3 +475,152 @@ describe("CallManager", () => {
expect(provider.stopListeningCalls).toHaveLength(5);
});
});
// ---------------------------------------------------------------------------
// Call verification on restore
// ---------------------------------------------------------------------------
function writeCallsToStore(storePath: string, calls: Record<string, unknown>[]): void {
fs.mkdirSync(storePath, { recursive: true });
const logPath = path.join(storePath, "calls.jsonl");
const lines = calls.map((c) => JSON.stringify(c)).join("\n") + "\n";
fs.writeFileSync(logPath, lines);
}
function makePersistedCall(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
callId: `call-${Date.now()}-${Math.random().toString(36).slice(2)}`,
providerCallId: `prov-${Date.now()}-${Math.random().toString(36).slice(2)}`,
provider: "plivo",
direction: "outbound",
state: "answered",
from: "+15550000000",
to: "+15550000001",
startedAt: Date.now() - 30_000,
answeredAt: Date.now() - 25_000,
transcript: [],
processedEventIds: [],
...overrides,
};
}
describe("CallManager verification on restore", () => {
it("skips stale calls reported terminal by provider", async () => {
const storePath = createTestStorePath();
const call = makePersistedCall();
writeCallsToStore(storePath, [call]);
const provider = new FakeProvider();
provider.getCallStatusResult = { status: "completed", isTerminal: true };
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
});
const manager = new CallManager(config, storePath);
await manager.initialize(provider, "https://example.com/voice/webhook");
expect(manager.getActiveCalls()).toHaveLength(0);
});
it("keeps calls reported active by provider", async () => {
const storePath = createTestStorePath();
const call = makePersistedCall();
writeCallsToStore(storePath, [call]);
const provider = new FakeProvider();
provider.getCallStatusResult = { status: "in-progress", isTerminal: false };
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
});
const manager = new CallManager(config, storePath);
await manager.initialize(provider, "https://example.com/voice/webhook");
expect(manager.getActiveCalls()).toHaveLength(1);
expect(manager.getActiveCalls()[0]?.callId).toBe(call.callId);
});
it("keeps calls when provider returns unknown (transient error)", async () => {
const storePath = createTestStorePath();
const call = makePersistedCall();
writeCallsToStore(storePath, [call]);
const provider = new FakeProvider();
provider.getCallStatusResult = { status: "error", isTerminal: false, isUnknown: true };
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
});
const manager = new CallManager(config, storePath);
await manager.initialize(provider, "https://example.com/voice/webhook");
expect(manager.getActiveCalls()).toHaveLength(1);
});
it("skips calls older than maxDurationSeconds", async () => {
const storePath = createTestStorePath();
const call = makePersistedCall({
startedAt: Date.now() - 600_000, // 10 minutes ago
answeredAt: Date.now() - 590_000,
});
writeCallsToStore(storePath, [call]);
const provider = new FakeProvider();
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
maxDurationSeconds: 300, // 5 minutes
});
const manager = new CallManager(config, storePath);
await manager.initialize(provider, "https://example.com/voice/webhook");
expect(manager.getActiveCalls()).toHaveLength(0);
});
it("skips calls without providerCallId", async () => {
const storePath = createTestStorePath();
const call = makePersistedCall({ providerCallId: undefined, state: "initiated" });
writeCallsToStore(storePath, [call]);
const provider = new FakeProvider();
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
});
const manager = new CallManager(config, storePath);
await manager.initialize(provider, "https://example.com/voice/webhook");
expect(manager.getActiveCalls()).toHaveLength(0);
});
it("keeps call when getCallStatus throws (verification failure)", async () => {
const storePath = createTestStorePath();
const call = makePersistedCall();
writeCallsToStore(storePath, [call]);
const provider = new FakeProvider();
provider.getCallStatus = async () => {
throw new Error("network failure");
};
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
});
const manager = new CallManager(config, storePath);
await manager.initialize(provider, "https://example.com/voice/webhook");
expect(manager.getActiveCalls()).toHaveLength(1);
});
});

View File

@@ -13,8 +13,15 @@ import {
speakInitialMessage as speakInitialMessageWithContext,
} from "./manager/outbound.js";
import { getCallHistoryFromStore, loadActiveCallsFromStore } from "./manager/store.js";
import { startMaxDurationTimer } from "./manager/timers.js";
import type { VoiceCallProvider } from "./providers/base.js";
import type { CallId, CallRecord, NormalizedEvent, OutboundCallOptions } from "./types.js";
import {
TerminalStates,
type CallId,
type CallRecord,
type NormalizedEvent,
type OutboundCallOptions,
} from "./types.js";
import { resolveUserPath } from "./utils.js";
function resolveDefaultStoreBase(config: VoiceCallConfig, storePath?: string): string {
@@ -65,18 +72,126 @@ export class CallManager {
/**
* Initialize the call manager with a provider.
* Verifies persisted calls with the provider and restarts timers.
*/
initialize(provider: VoiceCallProvider, webhookUrl: string): void {
async initialize(provider: VoiceCallProvider, webhookUrl: string): Promise<void> {
this.provider = provider;
this.webhookUrl = webhookUrl;
fs.mkdirSync(this.storePath, { recursive: true });
const persisted = loadActiveCallsFromStore(this.storePath);
this.activeCalls = persisted.activeCalls;
this.providerCallIdMap = persisted.providerCallIdMap;
this.processedEventIds = persisted.processedEventIds;
this.rejectedProviderCallIds = persisted.rejectedProviderCallIds;
const verified = await this.verifyRestoredCalls(provider, persisted.activeCalls);
this.activeCalls = verified;
// Rebuild providerCallIdMap from verified calls only
this.providerCallIdMap = new Map();
for (const [callId, call] of verified) {
if (call.providerCallId) {
this.providerCallIdMap.set(call.providerCallId, callId);
}
}
// Restart max-duration timers for restored calls that are past the answered state
for (const [callId, call] of verified) {
if (call.answeredAt && !TerminalStates.has(call.state)) {
const elapsed = Date.now() - call.answeredAt;
const maxDurationMs = this.config.maxDurationSeconds * 1000;
if (elapsed >= maxDurationMs) {
// Already expired — remove instead of keeping
verified.delete(callId);
if (call.providerCallId) {
this.providerCallIdMap.delete(call.providerCallId);
}
console.log(
`[voice-call] Skipping restored call ${callId} (max duration already elapsed)`,
);
continue;
}
startMaxDurationTimer({
ctx: this.getContext(),
callId,
onTimeout: async (id) => {
await endCallWithContext(this.getContext(), id);
},
});
console.log(`[voice-call] Restarted max-duration timer for restored call ${callId}`);
}
}
if (verified.size > 0) {
console.log(`[voice-call] Restored ${verified.size} active call(s) from store`);
}
}
/**
* Verify persisted calls with the provider before restoring.
* Calls without providerCallId or older than maxDurationSeconds are skipped.
* Transient provider errors keep the call (rely on timer fallback).
*/
private async verifyRestoredCalls(
provider: VoiceCallProvider,
candidates: Map<CallId, CallRecord>,
): Promise<Map<CallId, CallRecord>> {
if (candidates.size === 0) {
return new Map();
}
const maxAgeMs = this.config.maxDurationSeconds * 1000;
const now = Date.now();
const verified = new Map<CallId, CallRecord>();
const verifyTasks: Array<{ callId: CallId; call: CallRecord; promise: Promise<void> }> = [];
for (const [callId, call] of candidates) {
// Skip calls without a provider ID — can't verify
if (!call.providerCallId) {
console.log(`[voice-call] Skipping restored call ${callId} (no providerCallId)`);
continue;
}
// Skip calls older than maxDurationSeconds (time-based fallback)
if (now - call.startedAt > maxAgeMs) {
console.log(
`[voice-call] Skipping restored call ${callId} (older than maxDurationSeconds)`,
);
continue;
}
const task = {
callId,
call,
promise: provider
.getCallStatus({ providerCallId: call.providerCallId })
.then((result) => {
if (result.isTerminal) {
console.log(
`[voice-call] Skipping restored call ${callId} (provider status: ${result.status})`,
);
} else if (result.isUnknown) {
console.log(
`[voice-call] Keeping restored call ${callId} (provider status unknown, relying on timer)`,
);
verified.set(callId, call);
} else {
verified.set(callId, call);
}
})
.catch(() => {
// Verification failed entirely — keep the call, rely on timer
console.log(
`[voice-call] Keeping restored call ${callId} (verification failed, relying on timer)`,
);
verified.set(callId, call);
}),
};
verifyTasks.push(task);
}
await Promise.allSettled(verifyTasks.map((t) => t.promise));
return verified;
}
/**

View File

@@ -41,6 +41,7 @@ function createProvider(overrides: Partial<VoiceCallProvider> = {}): VoiceCallPr
playTts: async () => {},
startListening: async () => {},
stopListening: async () => {},
getCallStatus: async () => ({ status: "in-progress", isTerminal: false }),
...overrides,
};
}

View File

@@ -1,4 +1,6 @@
import type {
GetCallStatusInput,
GetCallStatusResult,
HangupCallInput,
InitiateCallInput,
InitiateCallResult,
@@ -65,4 +67,12 @@ export interface VoiceCallProvider {
* Stop listening for user speech (deactivate STT).
*/
stopListening(input: StopListeningInput): Promise<void>;
/**
* Query provider for current call status.
* Used to verify persisted calls are still active on restart.
* Must return `isUnknown: true` for transient errors (network, 5xx)
* so the caller can keep the call and rely on timer-based fallback.
*/
getCallStatus(input: GetCallStatusInput): Promise<GetCallStatusResult>;
}

View File

@@ -1,6 +1,8 @@
import crypto from "node:crypto";
import type {
EndReason,
GetCallStatusInput,
GetCallStatusResult,
HangupCallInput,
InitiateCallInput,
InitiateCallResult,
@@ -166,4 +168,12 @@ export class MockProvider implements VoiceCallProvider {
async stopListening(_input: StopListeningInput): Promise<void> {
// No-op for mock
}
async getCallStatus(input: GetCallStatusInput): Promise<GetCallStatusResult> {
const id = input.providerCallId.toLowerCase();
if (id.includes("stale") || id.includes("ended") || id.includes("completed")) {
return { status: "completed", isTerminal: true };
}
return { status: "in-progress", isTerminal: false };
}
}

View File

@@ -2,6 +2,8 @@ import crypto from "node:crypto";
import type { PlivoConfig, WebhookSecurityConfig } from "../config.js";
import { getHeader } from "../http-headers.js";
import type {
GetCallStatusInput,
GetCallStatusResult,
HangupCallInput,
InitiateCallInput,
InitiateCallResult,
@@ -441,6 +443,41 @@ export class PlivoProvider implements VoiceCallProvider {
// GetInput ends automatically when speech ends.
}
async getCallStatus(input: GetCallStatusInput): Promise<GetCallStatusResult> {
const terminalStatuses = new Set([
"completed",
"busy",
"failed",
"timeout",
"no-answer",
"cancel",
"machine",
"hangup",
]);
try {
const data = await guardedJsonApiRequest<{ call_status?: string }>({
url: `${this.baseUrl}/Call/${input.providerCallId}/`,
method: "GET",
headers: {
Authorization: `Basic ${Buffer.from(`${this.authId}:${this.authToken}`).toString("base64")}`,
},
allowNotFound: true,
allowedHostnames: [this.apiHost],
auditContext: "plivo-get-call-status",
errorPrefix: "Plivo get call status error",
});
if (!data) {
return { status: "not-found", isTerminal: true };
}
const status = data.call_status ?? "unknown";
return { status, isTerminal: terminalStatuses.has(status) };
} catch {
return { status: "error", isTerminal: false, isUnknown: true };
}
}
private static normalizeNumber(numberOrSip: string): string {
const trimmed = numberOrSip.trim();
if (trimmed.toLowerCase().startsWith("sip:")) {

View File

@@ -2,6 +2,8 @@ import crypto from "node:crypto";
import type { TelnyxConfig } from "../config.js";
import type {
EndReason,
GetCallStatusInput,
GetCallStatusResult,
HangupCallInput,
InitiateCallInput,
InitiateCallResult,
@@ -291,6 +293,37 @@ export class TelnyxProvider implements VoiceCallProvider {
{ allowNotFound: true },
);
}
async getCallStatus(input: GetCallStatusInput): Promise<GetCallStatusResult> {
try {
const data = await guardedJsonApiRequest<{ data?: { state?: string; is_alive?: boolean } }>({
url: `${this.baseUrl}/calls/${input.providerCallId}`,
method: "GET",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
},
allowNotFound: true,
allowedHostnames: [this.apiHost],
auditContext: "telnyx-get-call-status",
errorPrefix: "Telnyx get call status error",
});
if (!data) {
return { status: "not-found", isTerminal: true };
}
const state = data.data?.state ?? "unknown";
const isAlive = data.data?.is_alive;
// If is_alive is missing, treat as unknown rather than terminal (P1 fix)
if (isAlive === undefined) {
return { status: state, isTerminal: false, isUnknown: true };
}
return { status: state, isTerminal: !isAlive };
} catch {
return { status: "error", isTerminal: false, isUnknown: true };
}
}
}
// -----------------------------------------------------------------------------

View File

@@ -5,6 +5,8 @@ import type { MediaStreamHandler } from "../media-stream.js";
import { chunkAudio } from "../telephony-audio.js";
import type { TelephonyTtsProvider } from "../telephony-tts.js";
import type {
GetCallStatusInput,
GetCallStatusResult,
HangupCallInput,
InitiateCallInput,
InitiateCallResult,
@@ -19,6 +21,7 @@ import type {
} from "../types.js";
import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js";
import type { VoiceCallProvider } from "./base.js";
import { guardedJsonApiRequest } from "./shared/guarded-json-api.js";
import { twilioApiRequest } from "./twilio/api.js";
import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
@@ -671,6 +674,33 @@ export class TwilioProvider implements VoiceCallProvider {
// Twilio's <Gather> automatically stops on speech end
// No explicit action needed
}
async getCallStatus(input: GetCallStatusInput): Promise<GetCallStatusResult> {
const terminalStatuses = new Set(["completed", "failed", "busy", "no-answer", "canceled"]);
try {
const data = await guardedJsonApiRequest<{ status?: string }>({
url: `${this.baseUrl}/Calls/${input.providerCallId}.json`,
method: "GET",
headers: {
Authorization: `Basic ${Buffer.from(`${this.accountSid}:${this.authToken}`).toString("base64")}`,
},
allowNotFound: true,
allowedHostnames: ["api.twilio.com"],
auditContext: "twilio-get-call-status",
errorPrefix: "Twilio get call status error",
});
if (!data) {
return { status: "not-found", isTerminal: true };
}
const status = data.status ?? "unknown";
return { status, isTerminal: terminalStatuses.has(status) };
} catch {
// Transient error — keep the call and rely on timer fallback
return { status: "error", isTerminal: false, isUnknown: true };
}
}
}
// -----------------------------------------------------------------------------

View File

@@ -189,7 +189,7 @@ export async function createVoiceCallRuntime(params: {
}
}
manager.initialize(provider, webhookUrl);
await manager.initialize(provider, webhookUrl);
const stop = async () => {
if (tunnelResult) {

View File

@@ -248,6 +248,23 @@ export type StopListeningInput = {
providerCallId: ProviderCallId;
};
// -----------------------------------------------------------------------------
// Call Status Verification (used on restart to verify persisted calls)
// -----------------------------------------------------------------------------
export type GetCallStatusInput = {
providerCallId: ProviderCallId;
};
export type GetCallStatusResult = {
/** Provider-specific status string (e.g. "completed", "in-progress") */
status: string;
/** True when the provider confirms the call has ended */
isTerminal: boolean;
/** True when the status could not be determined (transient error) */
isUnknown?: boolean;
};
// -----------------------------------------------------------------------------
// Outbound Call Options
// -----------------------------------------------------------------------------

View File

@@ -14,6 +14,7 @@ const provider: VoiceCallProvider = {
playTts: async () => {},
startListening: async () => {},
stopListening: async () => {},
getCallStatus: async () => ({ status: "in-progress", isTerminal: false }),
};
const createConfig = (overrides: Partial<VoiceCallConfig> = {}): VoiceCallConfig => {