Include full contents of all nested repositories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
41
openclaw/extensions/voice-call/src/manager/context.ts
Normal file
41
openclaw/extensions/voice-call/src/manager/context.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { VoiceCallConfig } from "../config.js";
|
||||
import type { VoiceCallProvider } from "../providers/base.js";
|
||||
import type { CallId, CallRecord } from "../types.js";
|
||||
|
||||
export type TranscriptWaiter = {
|
||||
resolve: (text: string) => void;
|
||||
reject: (err: Error) => void;
|
||||
timeout: NodeJS.Timeout;
|
||||
turnToken?: string;
|
||||
};
|
||||
|
||||
export type CallManagerRuntimeState = {
|
||||
activeCalls: Map<CallId, CallRecord>;
|
||||
providerCallIdMap: Map<string, CallId>;
|
||||
processedEventIds: Set<string>;
|
||||
/** Provider call IDs we already sent a reject hangup for; avoids duplicate hangup calls. */
|
||||
rejectedProviderCallIds: Set<string>;
|
||||
};
|
||||
|
||||
export type CallManagerRuntimeDeps = {
|
||||
provider: VoiceCallProvider | null;
|
||||
config: VoiceCallConfig;
|
||||
storePath: string;
|
||||
webhookUrl: string | null;
|
||||
};
|
||||
|
||||
export type CallManagerTransientState = {
|
||||
activeTurnCalls: Set<CallId>;
|
||||
transcriptWaiters: Map<CallId, TranscriptWaiter>;
|
||||
maxDurationTimers: Map<CallId, NodeJS.Timeout>;
|
||||
};
|
||||
|
||||
export type CallManagerHooks = {
|
||||
/** Optional runtime hook invoked after an event transitions a call into answered state. */
|
||||
onCallAnswered?: (call: CallRecord) => void;
|
||||
};
|
||||
|
||||
export type CallManagerContext = CallManagerRuntimeState &
|
||||
CallManagerRuntimeDeps &
|
||||
CallManagerTransientState &
|
||||
CallManagerHooks;
|
||||
282
openclaw/extensions/voice-call/src/manager/events.test.ts
Normal file
282
openclaw/extensions/voice-call/src/manager/events.test.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { VoiceCallConfigSchema } from "../config.js";
|
||||
import type { VoiceCallProvider } from "../providers/base.js";
|
||||
import type { HangupCallInput, NormalizedEvent } from "../types.js";
|
||||
import type { CallManagerContext } from "./context.js";
|
||||
import { processEvent } from "./events.js";
|
||||
|
||||
function createContext(overrides: Partial<CallManagerContext> = {}): CallManagerContext {
|
||||
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-events-test-${Date.now()}`);
|
||||
fs.mkdirSync(storePath, { recursive: true });
|
||||
return {
|
||||
activeCalls: new Map(),
|
||||
providerCallIdMap: new Map(),
|
||||
processedEventIds: new Set(),
|
||||
rejectedProviderCallIds: new Set(),
|
||||
provider: null,
|
||||
config: VoiceCallConfigSchema.parse({
|
||||
enabled: true,
|
||||
provider: "plivo",
|
||||
fromNumber: "+15550000000",
|
||||
}),
|
||||
storePath,
|
||||
webhookUrl: null,
|
||||
activeTurnCalls: new Set(),
|
||||
transcriptWaiters: new Map(),
|
||||
maxDurationTimers: new Map(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createProvider(overrides: Partial<VoiceCallProvider> = {}): VoiceCallProvider {
|
||||
return {
|
||||
name: "plivo",
|
||||
verifyWebhook: () => ({ ok: true }),
|
||||
parseWebhookEvent: () => ({ events: [] }),
|
||||
initiateCall: async () => ({ providerCallId: "provider-call-id", status: "initiated" }),
|
||||
hangupCall: async () => {},
|
||||
playTts: async () => {},
|
||||
startListening: async () => {},
|
||||
stopListening: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createInboundDisabledConfig() {
|
||||
return VoiceCallConfigSchema.parse({
|
||||
enabled: true,
|
||||
provider: "plivo",
|
||||
fromNumber: "+15550000000",
|
||||
inboundPolicy: "disabled",
|
||||
});
|
||||
}
|
||||
|
||||
function createInboundInitiatedEvent(params: {
|
||||
id: string;
|
||||
providerCallId: string;
|
||||
from: string;
|
||||
}): NormalizedEvent {
|
||||
return {
|
||||
id: params.id,
|
||||
type: "call.initiated",
|
||||
callId: params.providerCallId,
|
||||
providerCallId: params.providerCallId,
|
||||
timestamp: Date.now(),
|
||||
direction: "inbound",
|
||||
from: params.from,
|
||||
to: "+15550000000",
|
||||
};
|
||||
}
|
||||
|
||||
function createRejectingInboundContext(): {
|
||||
ctx: CallManagerContext;
|
||||
hangupCalls: HangupCallInput[];
|
||||
} {
|
||||
const hangupCalls: HangupCallInput[] = [];
|
||||
const provider = createProvider({
|
||||
hangupCall: async (input: HangupCallInput): Promise<void> => {
|
||||
hangupCalls.push(input);
|
||||
},
|
||||
});
|
||||
const ctx = createContext({
|
||||
config: createInboundDisabledConfig(),
|
||||
provider,
|
||||
});
|
||||
return { ctx, hangupCalls };
|
||||
}
|
||||
|
||||
describe("processEvent (functional)", () => {
|
||||
it("calls provider hangup when rejecting inbound call", () => {
|
||||
const { ctx, hangupCalls } = createRejectingInboundContext();
|
||||
const event = createInboundInitiatedEvent({
|
||||
id: "evt-1",
|
||||
providerCallId: "prov-1",
|
||||
from: "+15559999999",
|
||||
});
|
||||
|
||||
processEvent(ctx, event);
|
||||
|
||||
expect(ctx.activeCalls.size).toBe(0);
|
||||
expect(hangupCalls).toHaveLength(1);
|
||||
expect(hangupCalls[0]).toEqual({
|
||||
callId: "prov-1",
|
||||
providerCallId: "prov-1",
|
||||
reason: "hangup-bot",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call hangup when provider is null", () => {
|
||||
const ctx = createContext({
|
||||
config: createInboundDisabledConfig(),
|
||||
provider: null,
|
||||
});
|
||||
const event = createInboundInitiatedEvent({
|
||||
id: "evt-2",
|
||||
providerCallId: "prov-2",
|
||||
from: "+15551111111",
|
||||
});
|
||||
|
||||
processEvent(ctx, event);
|
||||
|
||||
expect(ctx.activeCalls.size).toBe(0);
|
||||
});
|
||||
|
||||
it("calls hangup only once for duplicate events for same rejected call", () => {
|
||||
const { ctx, hangupCalls } = createRejectingInboundContext();
|
||||
const event1 = createInboundInitiatedEvent({
|
||||
id: "evt-init",
|
||||
providerCallId: "prov-dup",
|
||||
from: "+15552222222",
|
||||
});
|
||||
const event2: NormalizedEvent = {
|
||||
id: "evt-ring",
|
||||
type: "call.ringing",
|
||||
callId: "prov-dup",
|
||||
providerCallId: "prov-dup",
|
||||
timestamp: Date.now(),
|
||||
direction: "inbound",
|
||||
from: "+15552222222",
|
||||
to: "+15550000000",
|
||||
};
|
||||
|
||||
processEvent(ctx, event1);
|
||||
processEvent(ctx, event2);
|
||||
|
||||
expect(ctx.activeCalls.size).toBe(0);
|
||||
expect(hangupCalls).toHaveLength(1);
|
||||
expect(hangupCalls[0]?.providerCallId).toBe("prov-dup");
|
||||
});
|
||||
|
||||
it("updates providerCallId map when provider ID changes", () => {
|
||||
const now = Date.now();
|
||||
const ctx = createContext();
|
||||
ctx.activeCalls.set("call-1", {
|
||||
callId: "call-1",
|
||||
providerCallId: "request-uuid",
|
||||
provider: "plivo",
|
||||
direction: "outbound",
|
||||
state: "initiated",
|
||||
from: "+15550000000",
|
||||
to: "+15550000001",
|
||||
startedAt: now,
|
||||
transcript: [],
|
||||
processedEventIds: [],
|
||||
metadata: {},
|
||||
});
|
||||
ctx.providerCallIdMap.set("request-uuid", "call-1");
|
||||
|
||||
processEvent(ctx, {
|
||||
id: "evt-provider-id-change",
|
||||
type: "call.answered",
|
||||
callId: "call-1",
|
||||
providerCallId: "call-uuid",
|
||||
timestamp: now + 1,
|
||||
});
|
||||
|
||||
expect(ctx.activeCalls.get("call-1")?.providerCallId).toBe("call-uuid");
|
||||
expect(ctx.providerCallIdMap.get("call-uuid")).toBe("call-1");
|
||||
expect(ctx.providerCallIdMap.has("request-uuid")).toBe(false);
|
||||
});
|
||||
|
||||
it("invokes onCallAnswered hook for answered events", () => {
|
||||
const now = Date.now();
|
||||
let answeredCallId: string | null = null;
|
||||
const ctx = createContext({
|
||||
onCallAnswered: (call) => {
|
||||
answeredCallId = call.callId;
|
||||
},
|
||||
});
|
||||
ctx.activeCalls.set("call-2", {
|
||||
callId: "call-2",
|
||||
providerCallId: "call-2-provider",
|
||||
provider: "plivo",
|
||||
direction: "inbound",
|
||||
state: "ringing",
|
||||
from: "+15550000002",
|
||||
to: "+15550000000",
|
||||
startedAt: now,
|
||||
transcript: [],
|
||||
processedEventIds: [],
|
||||
metadata: {},
|
||||
});
|
||||
ctx.providerCallIdMap.set("call-2-provider", "call-2");
|
||||
|
||||
processEvent(ctx, {
|
||||
id: "evt-answered-hook",
|
||||
type: "call.answered",
|
||||
callId: "call-2",
|
||||
providerCallId: "call-2-provider",
|
||||
timestamp: now + 1,
|
||||
});
|
||||
|
||||
expect(answeredCallId).toBe("call-2");
|
||||
});
|
||||
|
||||
it("when hangup throws, logs and does not throw", () => {
|
||||
const provider = createProvider({
|
||||
hangupCall: async (): Promise<void> => {
|
||||
throw new Error("provider down");
|
||||
},
|
||||
});
|
||||
const ctx = createContext({
|
||||
config: createInboundDisabledConfig(),
|
||||
provider,
|
||||
});
|
||||
const event = createInboundInitiatedEvent({
|
||||
id: "evt-fail",
|
||||
providerCallId: "prov-fail",
|
||||
from: "+15553333333",
|
||||
});
|
||||
|
||||
expect(() => processEvent(ctx, event)).not.toThrow();
|
||||
expect(ctx.activeCalls.size).toBe(0);
|
||||
});
|
||||
|
||||
it("deduplicates by dedupeKey even when event IDs differ", () => {
|
||||
const now = Date.now();
|
||||
const ctx = createContext();
|
||||
ctx.activeCalls.set("call-dedupe", {
|
||||
callId: "call-dedupe",
|
||||
providerCallId: "provider-dedupe",
|
||||
provider: "plivo",
|
||||
direction: "outbound",
|
||||
state: "answered",
|
||||
from: "+15550000000",
|
||||
to: "+15550000001",
|
||||
startedAt: now,
|
||||
transcript: [],
|
||||
processedEventIds: [],
|
||||
metadata: {},
|
||||
});
|
||||
ctx.providerCallIdMap.set("provider-dedupe", "call-dedupe");
|
||||
|
||||
processEvent(ctx, {
|
||||
id: "evt-1",
|
||||
dedupeKey: "stable-key-1",
|
||||
type: "call.speech",
|
||||
callId: "call-dedupe",
|
||||
providerCallId: "provider-dedupe",
|
||||
timestamp: now + 1,
|
||||
transcript: "hello",
|
||||
isFinal: true,
|
||||
});
|
||||
|
||||
processEvent(ctx, {
|
||||
id: "evt-2",
|
||||
dedupeKey: "stable-key-1",
|
||||
type: "call.speech",
|
||||
callId: "call-dedupe",
|
||||
providerCallId: "provider-dedupe",
|
||||
timestamp: now + 2,
|
||||
transcript: "hello",
|
||||
isFinal: true,
|
||||
});
|
||||
|
||||
const call = ctx.activeCalls.get("call-dedupe");
|
||||
expect(call?.transcript).toHaveLength(1);
|
||||
expect(Array.from(ctx.processedEventIds)).toEqual(["stable-key-1"]);
|
||||
});
|
||||
});
|
||||
242
openclaw/extensions/voice-call/src/manager/events.ts
Normal file
242
openclaw/extensions/voice-call/src/manager/events.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import crypto from "node:crypto";
|
||||
import { isAllowlistedCaller, normalizePhoneNumber } from "../allowlist.js";
|
||||
import type { CallRecord, CallState, NormalizedEvent } from "../types.js";
|
||||
import type { CallManagerContext } from "./context.js";
|
||||
import { findCall } from "./lookup.js";
|
||||
import { endCall } from "./outbound.js";
|
||||
import { addTranscriptEntry, transitionState } from "./state.js";
|
||||
import { persistCallRecord } from "./store.js";
|
||||
import {
|
||||
clearMaxDurationTimer,
|
||||
rejectTranscriptWaiter,
|
||||
resolveTranscriptWaiter,
|
||||
startMaxDurationTimer,
|
||||
} from "./timers.js";
|
||||
|
||||
type EventContext = Pick<
|
||||
CallManagerContext,
|
||||
| "activeCalls"
|
||||
| "providerCallIdMap"
|
||||
| "processedEventIds"
|
||||
| "rejectedProviderCallIds"
|
||||
| "provider"
|
||||
| "config"
|
||||
| "storePath"
|
||||
| "transcriptWaiters"
|
||||
| "maxDurationTimers"
|
||||
| "onCallAnswered"
|
||||
>;
|
||||
|
||||
function shouldAcceptInbound(config: EventContext["config"], from: string | undefined): boolean {
|
||||
const { inboundPolicy: policy, allowFrom } = config;
|
||||
|
||||
switch (policy) {
|
||||
case "disabled":
|
||||
console.log("[voice-call] Inbound call rejected: policy is disabled");
|
||||
return false;
|
||||
|
||||
case "open":
|
||||
console.log("[voice-call] Inbound call accepted: policy is open");
|
||||
return true;
|
||||
|
||||
case "allowlist":
|
||||
case "pairing": {
|
||||
const normalized = normalizePhoneNumber(from);
|
||||
if (!normalized) {
|
||||
console.log("[voice-call] Inbound call rejected: missing caller ID");
|
||||
return false;
|
||||
}
|
||||
const allowed = isAllowlistedCaller(normalized, allowFrom);
|
||||
const status = allowed ? "accepted" : "rejected";
|
||||
console.log(
|
||||
`[voice-call] Inbound call ${status}: ${from} ${allowed ? "is in" : "not in"} allowlist`,
|
||||
);
|
||||
return allowed;
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createInboundCall(params: {
|
||||
ctx: EventContext;
|
||||
providerCallId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}): CallRecord {
|
||||
const callId = crypto.randomUUID();
|
||||
|
||||
const callRecord: CallRecord = {
|
||||
callId,
|
||||
providerCallId: params.providerCallId,
|
||||
provider: params.ctx.provider?.name || "twilio",
|
||||
direction: "inbound",
|
||||
state: "ringing",
|
||||
from: params.from,
|
||||
to: params.to,
|
||||
startedAt: Date.now(),
|
||||
transcript: [],
|
||||
processedEventIds: [],
|
||||
metadata: {
|
||||
initialMessage: params.ctx.config.inboundGreeting || "Hello! How can I help you today?",
|
||||
},
|
||||
};
|
||||
|
||||
params.ctx.activeCalls.set(callId, callRecord);
|
||||
params.ctx.providerCallIdMap.set(params.providerCallId, callId);
|
||||
persistCallRecord(params.ctx.storePath, callRecord);
|
||||
|
||||
console.log(`[voice-call] Created inbound call record: ${callId} from ${params.from}`);
|
||||
return callRecord;
|
||||
}
|
||||
|
||||
export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
|
||||
const dedupeKey = event.dedupeKey || event.id;
|
||||
if (ctx.processedEventIds.has(dedupeKey)) {
|
||||
return;
|
||||
}
|
||||
ctx.processedEventIds.add(dedupeKey);
|
||||
|
||||
let call = findCall({
|
||||
activeCalls: ctx.activeCalls,
|
||||
providerCallIdMap: ctx.providerCallIdMap,
|
||||
callIdOrProviderCallId: event.callId,
|
||||
});
|
||||
|
||||
if (!call && event.direction === "inbound" && event.providerCallId) {
|
||||
if (!shouldAcceptInbound(ctx.config, event.from)) {
|
||||
const pid = event.providerCallId;
|
||||
if (!ctx.provider) {
|
||||
console.warn(
|
||||
`[voice-call] Inbound call rejected by policy but no provider to hang up (providerCallId: ${pid}, from: ${event.from}); call will time out on provider side.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (ctx.rejectedProviderCallIds.has(pid)) {
|
||||
return;
|
||||
}
|
||||
ctx.rejectedProviderCallIds.add(pid);
|
||||
const callId = event.callId ?? pid;
|
||||
console.log(`[voice-call] Rejecting inbound call by policy: ${pid}`);
|
||||
void ctx.provider
|
||||
.hangupCall({
|
||||
callId,
|
||||
providerCallId: pid,
|
||||
reason: "hangup-bot",
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`[voice-call] Failed to reject inbound call ${pid}:`, message);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
call = createInboundCall({
|
||||
ctx,
|
||||
providerCallId: event.providerCallId,
|
||||
from: event.from || "unknown",
|
||||
to: event.to || ctx.config.fromNumber || "unknown",
|
||||
});
|
||||
|
||||
// Normalize event to internal ID for downstream consumers.
|
||||
event.callId = call.callId;
|
||||
}
|
||||
|
||||
if (!call) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.providerCallId && event.providerCallId !== call.providerCallId) {
|
||||
const previousProviderCallId = call.providerCallId;
|
||||
call.providerCallId = event.providerCallId;
|
||||
ctx.providerCallIdMap.set(event.providerCallId, call.callId);
|
||||
if (previousProviderCallId) {
|
||||
const mapped = ctx.providerCallIdMap.get(previousProviderCallId);
|
||||
if (mapped === call.callId) {
|
||||
ctx.providerCallIdMap.delete(previousProviderCallId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
call.processedEventIds.push(dedupeKey);
|
||||
|
||||
switch (event.type) {
|
||||
case "call.initiated":
|
||||
transitionState(call, "initiated");
|
||||
break;
|
||||
|
||||
case "call.ringing":
|
||||
transitionState(call, "ringing");
|
||||
break;
|
||||
|
||||
case "call.answered":
|
||||
call.answeredAt = event.timestamp;
|
||||
transitionState(call, "answered");
|
||||
startMaxDurationTimer({
|
||||
ctx,
|
||||
callId: call.callId,
|
||||
onTimeout: async (callId) => {
|
||||
await endCall(ctx, callId);
|
||||
},
|
||||
});
|
||||
ctx.onCallAnswered?.(call);
|
||||
break;
|
||||
|
||||
case "call.active":
|
||||
transitionState(call, "active");
|
||||
break;
|
||||
|
||||
case "call.speaking":
|
||||
transitionState(call, "speaking");
|
||||
break;
|
||||
|
||||
case "call.speech":
|
||||
if (event.isFinal) {
|
||||
const hadWaiter = ctx.transcriptWaiters.has(call.callId);
|
||||
const resolved = resolveTranscriptWaiter(
|
||||
ctx,
|
||||
call.callId,
|
||||
event.transcript,
|
||||
event.turnToken,
|
||||
);
|
||||
if (hadWaiter && !resolved) {
|
||||
console.warn(
|
||||
`[voice-call] Ignoring speech event with mismatched turn token for ${call.callId}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
addTranscriptEntry(call, "user", event.transcript);
|
||||
}
|
||||
transitionState(call, "listening");
|
||||
break;
|
||||
|
||||
case "call.ended":
|
||||
call.endedAt = event.timestamp;
|
||||
call.endReason = event.reason;
|
||||
transitionState(call, event.reason as CallState);
|
||||
clearMaxDurationTimer(ctx, call.callId);
|
||||
rejectTranscriptWaiter(ctx, call.callId, `Call ended: ${event.reason}`);
|
||||
ctx.activeCalls.delete(call.callId);
|
||||
if (call.providerCallId) {
|
||||
ctx.providerCallIdMap.delete(call.providerCallId);
|
||||
}
|
||||
break;
|
||||
|
||||
case "call.error":
|
||||
if (!event.retryable) {
|
||||
call.endedAt = event.timestamp;
|
||||
call.endReason = "error";
|
||||
transitionState(call, "error");
|
||||
clearMaxDurationTimer(ctx, call.callId);
|
||||
rejectTranscriptWaiter(ctx, call.callId, `Call error: ${event.error}`);
|
||||
ctx.activeCalls.delete(call.callId);
|
||||
if (call.providerCallId) {
|
||||
ctx.providerCallIdMap.delete(call.providerCallId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
persistCallRecord(ctx.storePath, call);
|
||||
}
|
||||
35
openclaw/extensions/voice-call/src/manager/lookup.ts
Normal file
35
openclaw/extensions/voice-call/src/manager/lookup.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { CallId, CallRecord } from "../types.js";
|
||||
|
||||
export function getCallByProviderCallId(params: {
|
||||
activeCalls: Map<CallId, CallRecord>;
|
||||
providerCallIdMap: Map<string, CallId>;
|
||||
providerCallId: string;
|
||||
}): CallRecord | undefined {
|
||||
const callId = params.providerCallIdMap.get(params.providerCallId);
|
||||
if (callId) {
|
||||
return params.activeCalls.get(callId);
|
||||
}
|
||||
|
||||
for (const call of params.activeCalls.values()) {
|
||||
if (call.providerCallId === params.providerCallId) {
|
||||
return call;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function findCall(params: {
|
||||
activeCalls: Map<CallId, CallRecord>;
|
||||
providerCallIdMap: Map<string, CallId>;
|
||||
callIdOrProviderCallId: string;
|
||||
}): CallRecord | undefined {
|
||||
const directCall = params.activeCalls.get(params.callIdOrProviderCallId);
|
||||
if (directCall) {
|
||||
return directCall;
|
||||
}
|
||||
return getCallByProviderCallId({
|
||||
activeCalls: params.activeCalls,
|
||||
providerCallIdMap: params.providerCallIdMap,
|
||||
providerCallId: params.callIdOrProviderCallId,
|
||||
});
|
||||
}
|
||||
380
openclaw/extensions/voice-call/src/manager/outbound.ts
Normal file
380
openclaw/extensions/voice-call/src/manager/outbound.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { CallMode } from "../config.js";
|
||||
import {
|
||||
TerminalStates,
|
||||
type CallId,
|
||||
type CallRecord,
|
||||
type OutboundCallOptions,
|
||||
} from "../types.js";
|
||||
import { mapVoiceToPolly } from "../voice-mapping.js";
|
||||
import type { CallManagerContext } from "./context.js";
|
||||
import { getCallByProviderCallId } from "./lookup.js";
|
||||
import { addTranscriptEntry, transitionState } from "./state.js";
|
||||
import { persistCallRecord } from "./store.js";
|
||||
import {
|
||||
clearMaxDurationTimer,
|
||||
clearTranscriptWaiter,
|
||||
rejectTranscriptWaiter,
|
||||
waitForFinalTranscript,
|
||||
} from "./timers.js";
|
||||
import { generateNotifyTwiml } from "./twiml.js";
|
||||
|
||||
type InitiateContext = Pick<
|
||||
CallManagerContext,
|
||||
"activeCalls" | "providerCallIdMap" | "provider" | "config" | "storePath" | "webhookUrl"
|
||||
>;
|
||||
|
||||
type SpeakContext = Pick<
|
||||
CallManagerContext,
|
||||
"activeCalls" | "providerCallIdMap" | "provider" | "config" | "storePath"
|
||||
>;
|
||||
|
||||
type ConversationContext = Pick<
|
||||
CallManagerContext,
|
||||
| "activeCalls"
|
||||
| "providerCallIdMap"
|
||||
| "provider"
|
||||
| "config"
|
||||
| "storePath"
|
||||
| "activeTurnCalls"
|
||||
| "transcriptWaiters"
|
||||
| "maxDurationTimers"
|
||||
>;
|
||||
|
||||
type EndCallContext = Pick<
|
||||
CallManagerContext,
|
||||
| "activeCalls"
|
||||
| "providerCallIdMap"
|
||||
| "provider"
|
||||
| "storePath"
|
||||
| "transcriptWaiters"
|
||||
| "maxDurationTimers"
|
||||
>;
|
||||
|
||||
type ConnectedCallContext = Pick<CallManagerContext, "activeCalls" | "provider">;
|
||||
|
||||
type ConnectedCallLookup =
|
||||
| { kind: "error"; error: string }
|
||||
| { kind: "ended"; call: CallRecord }
|
||||
| {
|
||||
kind: "ok";
|
||||
call: CallRecord;
|
||||
providerCallId: string;
|
||||
provider: NonNullable<ConnectedCallContext["provider"]>;
|
||||
};
|
||||
|
||||
type ConnectedCallResolution =
|
||||
| { ok: false; error: string }
|
||||
| {
|
||||
ok: true;
|
||||
call: CallRecord;
|
||||
providerCallId: string;
|
||||
provider: NonNullable<ConnectedCallContext["provider"]>;
|
||||
};
|
||||
|
||||
function lookupConnectedCall(ctx: ConnectedCallContext, callId: CallId): ConnectedCallLookup {
|
||||
const call = ctx.activeCalls.get(callId);
|
||||
if (!call) {
|
||||
return { kind: "error", error: "Call not found" };
|
||||
}
|
||||
if (!ctx.provider || !call.providerCallId) {
|
||||
return { kind: "error", error: "Call not connected" };
|
||||
}
|
||||
if (TerminalStates.has(call.state)) {
|
||||
return { kind: "ended", call };
|
||||
}
|
||||
return { kind: "ok", call, providerCallId: call.providerCallId, provider: ctx.provider };
|
||||
}
|
||||
|
||||
function requireConnectedCall(ctx: ConnectedCallContext, callId: CallId): ConnectedCallResolution {
|
||||
const lookup = lookupConnectedCall(ctx, callId);
|
||||
if (lookup.kind === "error") {
|
||||
return { ok: false, error: lookup.error };
|
||||
}
|
||||
if (lookup.kind === "ended") {
|
||||
return { ok: false, error: "Call has ended" };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
call: lookup.call,
|
||||
providerCallId: lookup.providerCallId,
|
||||
provider: lookup.provider,
|
||||
};
|
||||
}
|
||||
|
||||
export async function initiateCall(
|
||||
ctx: InitiateContext,
|
||||
to: string,
|
||||
sessionKey?: string,
|
||||
options?: OutboundCallOptions | string,
|
||||
): Promise<{ callId: CallId; success: boolean; error?: string }> {
|
||||
const opts: OutboundCallOptions =
|
||||
typeof options === "string" ? { message: options } : (options ?? {});
|
||||
const initialMessage = opts.message;
|
||||
const mode = opts.mode ?? ctx.config.outbound.defaultMode;
|
||||
|
||||
if (!ctx.provider) {
|
||||
return { callId: "", success: false, error: "Provider not initialized" };
|
||||
}
|
||||
if (!ctx.webhookUrl) {
|
||||
return { callId: "", success: false, error: "Webhook URL not configured" };
|
||||
}
|
||||
|
||||
if (ctx.activeCalls.size >= ctx.config.maxConcurrentCalls) {
|
||||
return {
|
||||
callId: "",
|
||||
success: false,
|
||||
error: `Maximum concurrent calls (${ctx.config.maxConcurrentCalls}) reached`,
|
||||
};
|
||||
}
|
||||
|
||||
const callId = crypto.randomUUID();
|
||||
const from =
|
||||
ctx.config.fromNumber || (ctx.provider?.name === "mock" ? "+15550000000" : undefined);
|
||||
if (!from) {
|
||||
return { callId: "", success: false, error: "fromNumber not configured" };
|
||||
}
|
||||
|
||||
const callRecord: CallRecord = {
|
||||
callId,
|
||||
provider: ctx.provider.name,
|
||||
direction: "outbound",
|
||||
state: "initiated",
|
||||
from,
|
||||
to,
|
||||
sessionKey,
|
||||
startedAt: Date.now(),
|
||||
transcript: [],
|
||||
processedEventIds: [],
|
||||
metadata: {
|
||||
...(initialMessage && { initialMessage }),
|
||||
mode,
|
||||
},
|
||||
};
|
||||
|
||||
ctx.activeCalls.set(callId, callRecord);
|
||||
persistCallRecord(ctx.storePath, callRecord);
|
||||
|
||||
try {
|
||||
// For notify mode with a message, use inline TwiML with <Say>.
|
||||
let inlineTwiml: string | undefined;
|
||||
if (mode === "notify" && initialMessage) {
|
||||
const pollyVoice = mapVoiceToPolly(ctx.config.tts?.openai?.voice);
|
||||
inlineTwiml = generateNotifyTwiml(initialMessage, pollyVoice);
|
||||
console.log(`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`);
|
||||
}
|
||||
|
||||
const result = await ctx.provider.initiateCall({
|
||||
callId,
|
||||
from,
|
||||
to,
|
||||
webhookUrl: ctx.webhookUrl,
|
||||
inlineTwiml,
|
||||
});
|
||||
|
||||
callRecord.providerCallId = result.providerCallId;
|
||||
ctx.providerCallIdMap.set(result.providerCallId, callId);
|
||||
persistCallRecord(ctx.storePath, callRecord);
|
||||
|
||||
return { callId, success: true };
|
||||
} catch (err) {
|
||||
callRecord.state = "failed";
|
||||
callRecord.endedAt = Date.now();
|
||||
callRecord.endReason = "failed";
|
||||
persistCallRecord(ctx.storePath, callRecord);
|
||||
ctx.activeCalls.delete(callId);
|
||||
if (callRecord.providerCallId) {
|
||||
ctx.providerCallIdMap.delete(callRecord.providerCallId);
|
||||
}
|
||||
|
||||
return {
|
||||
callId,
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function speak(
|
||||
ctx: SpeakContext,
|
||||
callId: CallId,
|
||||
text: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const connected = requireConnectedCall(ctx, callId);
|
||||
if (!connected.ok) {
|
||||
return { success: false, error: connected.error };
|
||||
}
|
||||
const { call, providerCallId, provider } = connected;
|
||||
|
||||
try {
|
||||
transitionState(call, "speaking");
|
||||
persistCallRecord(ctx.storePath, call);
|
||||
|
||||
addTranscriptEntry(call, "bot", text);
|
||||
|
||||
const voice = provider.name === "twilio" ? ctx.config.tts?.openai?.voice : undefined;
|
||||
await provider.playTts({
|
||||
callId,
|
||||
providerCallId,
|
||||
text,
|
||||
voice,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function speakInitialMessage(
|
||||
ctx: ConversationContext,
|
||||
providerCallId: string,
|
||||
): Promise<void> {
|
||||
const call = getCallByProviderCallId({
|
||||
activeCalls: ctx.activeCalls,
|
||||
providerCallIdMap: ctx.providerCallIdMap,
|
||||
providerCallId,
|
||||
});
|
||||
if (!call) {
|
||||
console.warn(`[voice-call] speakInitialMessage: no call found for ${providerCallId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const initialMessage = call.metadata?.initialMessage as string | undefined;
|
||||
const mode = (call.metadata?.mode as CallMode) ?? "conversation";
|
||||
|
||||
if (!initialMessage) {
|
||||
console.log(`[voice-call] speakInitialMessage: no initial message for ${call.callId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear so we don't speak it again if the provider reconnects.
|
||||
if (call.metadata) {
|
||||
delete call.metadata.initialMessage;
|
||||
persistCallRecord(ctx.storePath, call);
|
||||
}
|
||||
|
||||
console.log(`[voice-call] Speaking initial message for call ${call.callId} (mode: ${mode})`);
|
||||
const result = await speak(ctx, call.callId, initialMessage);
|
||||
if (!result.success) {
|
||||
console.warn(`[voice-call] Failed to speak initial message: ${result.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "notify") {
|
||||
const delaySec = ctx.config.outbound.notifyHangupDelaySec;
|
||||
console.log(`[voice-call] Notify mode: auto-hangup in ${delaySec}s for call ${call.callId}`);
|
||||
setTimeout(async () => {
|
||||
const currentCall = ctx.activeCalls.get(call.callId);
|
||||
if (currentCall && !TerminalStates.has(currentCall.state)) {
|
||||
console.log(`[voice-call] Notify mode: hanging up call ${call.callId}`);
|
||||
await endCall(ctx, call.callId);
|
||||
}
|
||||
}, delaySec * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
export async function continueCall(
|
||||
ctx: ConversationContext,
|
||||
callId: CallId,
|
||||
prompt: string,
|
||||
): Promise<{ success: boolean; transcript?: string; error?: string }> {
|
||||
const connected = requireConnectedCall(ctx, callId);
|
||||
if (!connected.ok) {
|
||||
return { success: false, error: connected.error };
|
||||
}
|
||||
const { call, providerCallId, provider } = connected;
|
||||
|
||||
if (ctx.activeTurnCalls.has(callId) || ctx.transcriptWaiters.has(callId)) {
|
||||
return { success: false, error: "Already waiting for transcript" };
|
||||
}
|
||||
ctx.activeTurnCalls.add(callId);
|
||||
|
||||
const turnStartedAt = Date.now();
|
||||
const turnToken = provider.name === "twilio" ? crypto.randomUUID() : undefined;
|
||||
|
||||
try {
|
||||
await speak(ctx, callId, prompt);
|
||||
|
||||
transitionState(call, "listening");
|
||||
persistCallRecord(ctx.storePath, call);
|
||||
|
||||
const listenStartedAt = Date.now();
|
||||
await provider.startListening({ callId, providerCallId, turnToken });
|
||||
|
||||
const transcript = await waitForFinalTranscript(ctx, callId, turnToken);
|
||||
const transcriptReceivedAt = Date.now();
|
||||
|
||||
// Best-effort: stop listening after final transcript.
|
||||
await provider.stopListening({ callId, providerCallId });
|
||||
|
||||
const lastTurnLatencyMs = transcriptReceivedAt - turnStartedAt;
|
||||
const lastTurnListenWaitMs = transcriptReceivedAt - listenStartedAt;
|
||||
const turnCount =
|
||||
call.metadata && typeof call.metadata.turnCount === "number"
|
||||
? call.metadata.turnCount + 1
|
||||
: 1;
|
||||
|
||||
call.metadata = {
|
||||
...(call.metadata ?? {}),
|
||||
turnCount,
|
||||
lastTurnLatencyMs,
|
||||
lastTurnListenWaitMs,
|
||||
lastTurnCompletedAt: transcriptReceivedAt,
|
||||
};
|
||||
persistCallRecord(ctx.storePath, call);
|
||||
|
||||
console.log(
|
||||
"[voice-call] continueCall latency call=" +
|
||||
call.callId +
|
||||
" totalMs=" +
|
||||
String(lastTurnLatencyMs) +
|
||||
" listenWaitMs=" +
|
||||
String(lastTurnListenWaitMs),
|
||||
);
|
||||
|
||||
return { success: true, transcript };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
} finally {
|
||||
ctx.activeTurnCalls.delete(callId);
|
||||
clearTranscriptWaiter(ctx, callId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function endCall(
|
||||
ctx: EndCallContext,
|
||||
callId: CallId,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const lookup = lookupConnectedCall(ctx, callId);
|
||||
if (lookup.kind === "error") {
|
||||
return { success: false, error: lookup.error };
|
||||
}
|
||||
if (lookup.kind === "ended") {
|
||||
return { success: true };
|
||||
}
|
||||
const { call, providerCallId, provider } = lookup;
|
||||
|
||||
try {
|
||||
await provider.hangupCall({
|
||||
callId,
|
||||
providerCallId,
|
||||
reason: "hangup-bot",
|
||||
});
|
||||
|
||||
call.state = "hangup-bot";
|
||||
call.endedAt = Date.now();
|
||||
call.endReason = "hangup-bot";
|
||||
persistCallRecord(ctx.storePath, call);
|
||||
|
||||
clearMaxDurationTimer(ctx, callId);
|
||||
rejectTranscriptWaiter(ctx, callId, "Call ended: hangup-bot");
|
||||
|
||||
ctx.activeCalls.delete(callId);
|
||||
ctx.providerCallIdMap.delete(providerCallId);
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
48
openclaw/extensions/voice-call/src/manager/state.ts
Normal file
48
openclaw/extensions/voice-call/src/manager/state.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { TerminalStates, type CallRecord, type CallState, type TranscriptEntry } from "../types.js";
|
||||
|
||||
const ConversationStates = new Set<CallState>(["speaking", "listening"]);
|
||||
|
||||
const StateOrder: readonly CallState[] = [
|
||||
"initiated",
|
||||
"ringing",
|
||||
"answered",
|
||||
"active",
|
||||
"speaking",
|
||||
"listening",
|
||||
];
|
||||
|
||||
export function transitionState(call: CallRecord, newState: CallState): void {
|
||||
// No-op for same state or already terminal.
|
||||
if (call.state === newState || TerminalStates.has(call.state)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Terminal states can always be reached from non-terminal.
|
||||
if (TerminalStates.has(newState)) {
|
||||
call.state = newState;
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow cycling between speaking and listening (multi-turn conversations).
|
||||
if (ConversationStates.has(call.state) && ConversationStates.has(newState)) {
|
||||
call.state = newState;
|
||||
return;
|
||||
}
|
||||
|
||||
// Only allow forward transitions in state order.
|
||||
const currentIndex = StateOrder.indexOf(call.state);
|
||||
const newIndex = StateOrder.indexOf(newState);
|
||||
if (newIndex > currentIndex) {
|
||||
call.state = newState;
|
||||
}
|
||||
}
|
||||
|
||||
export function addTranscriptEntry(call: CallRecord, speaker: "bot" | "user", text: string): void {
|
||||
const entry: TranscriptEntry = {
|
||||
timestamp: Date.now(),
|
||||
speaker,
|
||||
text,
|
||||
isFinal: true,
|
||||
};
|
||||
call.transcript.push(entry);
|
||||
}
|
||||
94
openclaw/extensions/voice-call/src/manager/store.ts
Normal file
94
openclaw/extensions/voice-call/src/manager/store.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { CallRecordSchema, TerminalStates, type CallId, type CallRecord } from "../types.js";
|
||||
|
||||
export function persistCallRecord(storePath: string, call: CallRecord): void {
|
||||
const logPath = path.join(storePath, "calls.jsonl");
|
||||
const line = `${JSON.stringify(call)}\n`;
|
||||
// Fire-and-forget async write to avoid blocking event loop.
|
||||
fsp.appendFile(logPath, line).catch((err) => {
|
||||
console.error("[voice-call] Failed to persist call record:", err);
|
||||
});
|
||||
}
|
||||
|
||||
export function loadActiveCallsFromStore(storePath: string): {
|
||||
activeCalls: Map<CallId, CallRecord>;
|
||||
providerCallIdMap: Map<string, CallId>;
|
||||
processedEventIds: Set<string>;
|
||||
rejectedProviderCallIds: Set<string>;
|
||||
} {
|
||||
const logPath = path.join(storePath, "calls.jsonl");
|
||||
if (!fs.existsSync(logPath)) {
|
||||
return {
|
||||
activeCalls: new Map(),
|
||||
providerCallIdMap: new Map(),
|
||||
processedEventIds: new Set(),
|
||||
rejectedProviderCallIds: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(logPath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
const callMap = new Map<CallId, CallRecord>();
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const call = CallRecordSchema.parse(JSON.parse(line));
|
||||
callMap.set(call.callId, call);
|
||||
} catch {
|
||||
// Skip invalid lines.
|
||||
}
|
||||
}
|
||||
|
||||
const activeCalls = new Map<CallId, CallRecord>();
|
||||
const providerCallIdMap = new Map<string, CallId>();
|
||||
const processedEventIds = new Set<string>();
|
||||
const rejectedProviderCallIds = new Set<string>();
|
||||
|
||||
for (const [callId, call] of callMap) {
|
||||
if (TerminalStates.has(call.state)) {
|
||||
continue;
|
||||
}
|
||||
activeCalls.set(callId, call);
|
||||
if (call.providerCallId) {
|
||||
providerCallIdMap.set(call.providerCallId, callId);
|
||||
}
|
||||
for (const eventId of call.processedEventIds) {
|
||||
processedEventIds.add(eventId);
|
||||
}
|
||||
}
|
||||
|
||||
return { activeCalls, providerCallIdMap, processedEventIds, rejectedProviderCallIds };
|
||||
}
|
||||
|
||||
export async function getCallHistoryFromStore(
|
||||
storePath: string,
|
||||
limit = 50,
|
||||
): Promise<CallRecord[]> {
|
||||
const logPath = path.join(storePath, "calls.jsonl");
|
||||
|
||||
try {
|
||||
await fsp.access(logPath);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const content = await fsp.readFile(logPath, "utf-8");
|
||||
const lines = content.trim().split("\n").filter(Boolean);
|
||||
const calls: CallRecord[] = [];
|
||||
|
||||
for (const line of lines.slice(-limit)) {
|
||||
try {
|
||||
const parsed = CallRecordSchema.parse(JSON.parse(line));
|
||||
calls.push(parsed);
|
||||
} catch {
|
||||
// Skip invalid lines.
|
||||
}
|
||||
}
|
||||
|
||||
return calls;
|
||||
}
|
||||
112
openclaw/extensions/voice-call/src/manager/timers.ts
Normal file
112
openclaw/extensions/voice-call/src/manager/timers.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { TerminalStates, type CallId } from "../types.js";
|
||||
import type { CallManagerContext } from "./context.js";
|
||||
import { persistCallRecord } from "./store.js";
|
||||
|
||||
type TimerContext = Pick<
|
||||
CallManagerContext,
|
||||
"activeCalls" | "maxDurationTimers" | "config" | "storePath" | "transcriptWaiters"
|
||||
>;
|
||||
type MaxDurationTimerContext = Pick<
|
||||
TimerContext,
|
||||
"activeCalls" | "maxDurationTimers" | "config" | "storePath"
|
||||
>;
|
||||
type TranscriptWaiterContext = Pick<TimerContext, "transcriptWaiters">;
|
||||
|
||||
export function clearMaxDurationTimer(
|
||||
ctx: Pick<MaxDurationTimerContext, "maxDurationTimers">,
|
||||
callId: CallId,
|
||||
): void {
|
||||
const timer = ctx.maxDurationTimers.get(callId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
ctx.maxDurationTimers.delete(callId);
|
||||
}
|
||||
}
|
||||
|
||||
export function startMaxDurationTimer(params: {
|
||||
ctx: MaxDurationTimerContext;
|
||||
callId: CallId;
|
||||
onTimeout: (callId: CallId) => Promise<void>;
|
||||
}): void {
|
||||
clearMaxDurationTimer(params.ctx, params.callId);
|
||||
|
||||
const maxDurationMs = params.ctx.config.maxDurationSeconds * 1000;
|
||||
console.log(
|
||||
`[voice-call] Starting max duration timer (${params.ctx.config.maxDurationSeconds}s) for call ${params.callId}`,
|
||||
);
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
params.ctx.maxDurationTimers.delete(params.callId);
|
||||
const call = params.ctx.activeCalls.get(params.callId);
|
||||
if (call && !TerminalStates.has(call.state)) {
|
||||
console.log(
|
||||
`[voice-call] Max duration reached (${params.ctx.config.maxDurationSeconds}s), ending call ${params.callId}`,
|
||||
);
|
||||
call.endReason = "timeout";
|
||||
persistCallRecord(params.ctx.storePath, call);
|
||||
await params.onTimeout(params.callId);
|
||||
}
|
||||
}, maxDurationMs);
|
||||
|
||||
params.ctx.maxDurationTimers.set(params.callId, timer);
|
||||
}
|
||||
|
||||
export function clearTranscriptWaiter(ctx: TranscriptWaiterContext, callId: CallId): void {
|
||||
const waiter = ctx.transcriptWaiters.get(callId);
|
||||
if (!waiter) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(waiter.timeout);
|
||||
ctx.transcriptWaiters.delete(callId);
|
||||
}
|
||||
|
||||
export function rejectTranscriptWaiter(
|
||||
ctx: TranscriptWaiterContext,
|
||||
callId: CallId,
|
||||
reason: string,
|
||||
): void {
|
||||
const waiter = ctx.transcriptWaiters.get(callId);
|
||||
if (!waiter) {
|
||||
return;
|
||||
}
|
||||
clearTranscriptWaiter(ctx, callId);
|
||||
waiter.reject(new Error(reason));
|
||||
}
|
||||
|
||||
export function resolveTranscriptWaiter(
|
||||
ctx: TranscriptWaiterContext,
|
||||
callId: CallId,
|
||||
transcript: string,
|
||||
turnToken?: string,
|
||||
): boolean {
|
||||
const waiter = ctx.transcriptWaiters.get(callId);
|
||||
if (!waiter) {
|
||||
return false;
|
||||
}
|
||||
if (waiter.turnToken && waiter.turnToken !== turnToken) {
|
||||
return false;
|
||||
}
|
||||
clearTranscriptWaiter(ctx, callId);
|
||||
waiter.resolve(transcript);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function waitForFinalTranscript(
|
||||
ctx: TimerContext,
|
||||
callId: CallId,
|
||||
turnToken?: string,
|
||||
): Promise<string> {
|
||||
if (ctx.transcriptWaiters.has(callId)) {
|
||||
return Promise.reject(new Error("Already waiting for transcript"));
|
||||
}
|
||||
|
||||
const timeoutMs = ctx.config.transcriptTimeoutMs;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
ctx.transcriptWaiters.delete(callId);
|
||||
reject(new Error(`Timed out waiting for transcript after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
ctx.transcriptWaiters.set(callId, { resolve, reject, timeout, turnToken });
|
||||
});
|
||||
}
|
||||
9
openclaw/extensions/voice-call/src/manager/twiml.ts
Normal file
9
openclaw/extensions/voice-call/src/manager/twiml.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { escapeXml } from "../voice-mapping.js";
|
||||
|
||||
export function generateNotifyTwiml(message: string, voice: string): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Say voice="${voice}">${escapeXml(message)}</Say>
|
||||
<Hangup/>
|
||||
</Response>`;
|
||||
}
|
||||
Reference in New Issue
Block a user