@openclaw/voice-call 2026.3.13 → 2026.5.1-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -5
- package/api.ts +16 -0
- package/cli-metadata.ts +10 -0
- package/config-api.ts +12 -0
- package/index.test.ts +866 -0
- package/index.ts +353 -148
- package/openclaw.plugin.json +336 -157
- package/package.json +33 -5
- package/runtime-api.ts +20 -0
- package/runtime-entry.ts +1 -0
- package/setup-api.ts +47 -0
- package/src/allowlist.test.ts +18 -0
- package/src/cli.ts +533 -68
- package/src/config-compat.test.ts +120 -0
- package/src/config-compat.ts +227 -0
- package/src/config.test.ts +160 -12
- package/src/config.ts +243 -74
- package/src/core-bridge.ts +2 -147
- package/src/deep-merge.test.ts +40 -0
- package/src/gateway-continue-operation.ts +200 -0
- package/src/http-headers.ts +6 -3
- package/src/manager/context.ts +6 -5
- package/src/manager/events.test.ts +179 -19
- package/src/manager/events.ts +48 -30
- package/src/manager/lifecycle.ts +53 -0
- package/src/manager/lookup.test.ts +52 -0
- package/src/manager/outbound.test.ts +464 -0
- package/src/manager/outbound.ts +148 -55
- package/src/manager/store.ts +18 -6
- package/src/manager/timers.test.ts +129 -0
- package/src/manager/timers.ts +4 -3
- package/src/manager/twiml.test.ts +13 -0
- package/src/manager/twiml.ts +8 -0
- package/src/manager.closed-loop.test.ts +30 -12
- package/src/manager.inbound-allowlist.test.ts +77 -10
- package/src/manager.notify.test.ts +344 -20
- package/src/manager.restore.test.ts +95 -8
- package/src/manager.test-harness.ts +8 -6
- package/src/manager.ts +79 -5
- package/src/media-stream.test.ts +578 -81
- package/src/media-stream.ts +235 -54
- package/src/providers/base.ts +19 -0
- package/src/providers/mock.ts +7 -1
- package/src/providers/plivo.test.ts +50 -6
- package/src/providers/plivo.ts +14 -6
- package/src/providers/shared/call-status.ts +2 -1
- package/src/providers/shared/guarded-json-api.test.ts +106 -0
- package/src/providers/shared/guarded-json-api.ts +1 -1
- package/src/providers/telnyx.test.ts +178 -6
- package/src/providers/telnyx.ts +40 -3
- package/src/providers/twilio/api.test.ts +145 -0
- package/src/providers/twilio/api.ts +67 -16
- package/src/providers/twilio/twiml-policy.ts +6 -10
- package/src/providers/twilio/webhook.ts +1 -1
- package/src/providers/twilio.test.ts +425 -25
- package/src/providers/twilio.ts +230 -77
- package/src/providers/twilio.types.ts +17 -0
- package/src/realtime-defaults.ts +3 -0
- package/src/realtime-fast-context.test.ts +88 -0
- package/src/realtime-fast-context.ts +165 -0
- package/src/realtime-transcription.runtime.ts +4 -0
- package/src/realtime-voice.runtime.ts +5 -0
- package/src/response-generator.test.ts +277 -0
- package/src/response-generator.ts +186 -40
- package/src/response-model.test.ts +71 -0
- package/src/response-model.ts +23 -0
- package/src/runtime.test.ts +351 -0
- package/src/runtime.ts +254 -24
- package/src/telephony-audio.test.ts +61 -0
- package/src/telephony-audio.ts +1 -79
- package/src/telephony-tts.test.ts +133 -12
- package/src/telephony-tts.ts +155 -2
- package/src/test-fixtures.ts +26 -7
- package/src/tts-provider-voice.test.ts +34 -0
- package/src/tts-provider-voice.ts +21 -0
- package/src/tunnel.test.ts +166 -0
- package/src/tunnel.ts +1 -1
- package/src/types.ts +24 -37
- package/src/utils.test.ts +17 -0
- package/src/voice-mapping.test.ts +34 -0
- package/src/voice-mapping.ts +3 -2
- package/src/webhook/realtime-handler.test.ts +598 -0
- package/src/webhook/realtime-handler.ts +485 -0
- package/src/webhook/stale-call-reaper.test.ts +88 -0
- package/src/webhook/stale-call-reaper.ts +5 -0
- package/src/webhook/tailscale.test.ts +214 -0
- package/src/webhook/tailscale.ts +19 -5
- package/src/webhook-exposure.test.ts +33 -0
- package/src/webhook-exposure.ts +84 -0
- package/src/webhook-security.test.ts +172 -21
- package/src/webhook-security.ts +43 -29
- package/src/webhook.hangup-once.lifecycle.test.ts +135 -0
- package/src/webhook.test.ts +1145 -27
- package/src/webhook.ts +513 -100
- package/src/webhook.types.ts +5 -0
- package/src/websocket-test-support.ts +72 -0
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -121
- package/src/providers/index.ts +0 -10
- package/src/providers/stt-openai-realtime.test.ts +0 -42
- package/src/providers/stt-openai-realtime.ts +0 -311
- package/src/providers/tts-openai.test.ts +0 -43
- package/src/providers/tts-openai.ts +0 -221
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { deepMergeDefined } from "./deep-merge.js";
|
|
3
|
+
|
|
4
|
+
describe("deepMergeDefined", () => {
|
|
5
|
+
it("deep merges nested plain objects and preserves base values for undefined overrides", () => {
|
|
6
|
+
expect(
|
|
7
|
+
deepMergeDefined(
|
|
8
|
+
{
|
|
9
|
+
provider: { voice: "alloy", language: "en" },
|
|
10
|
+
enabled: true,
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
provider: { voice: "echo", language: undefined },
|
|
14
|
+
enabled: undefined,
|
|
15
|
+
},
|
|
16
|
+
),
|
|
17
|
+
).toEqual({
|
|
18
|
+
provider: { voice: "echo", language: "en" },
|
|
19
|
+
enabled: true,
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("replaces non-objects directly and blocks dangerous prototype keys", () => {
|
|
24
|
+
expect(deepMergeDefined(["a"], ["b"])).toEqual(["b"]);
|
|
25
|
+
expect(deepMergeDefined("base", undefined)).toBe("base");
|
|
26
|
+
expect(
|
|
27
|
+
deepMergeDefined(
|
|
28
|
+
{ safe: { keep: true } },
|
|
29
|
+
{
|
|
30
|
+
safe: { next: true },
|
|
31
|
+
__proto__: { polluted: true },
|
|
32
|
+
constructor: { polluted: true },
|
|
33
|
+
prototype: { polluted: true },
|
|
34
|
+
},
|
|
35
|
+
),
|
|
36
|
+
).toEqual({
|
|
37
|
+
safe: { keep: true, next: true },
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
3
|
+
import type { VoiceCallConfig } from "./config.js";
|
|
4
|
+
import type { CoreConfig } from "./core-bridge.js";
|
|
5
|
+
import type { VoiceCallRuntime } from "./runtime.js";
|
|
6
|
+
import { TELEPHONY_DEFAULT_TTS_TIMEOUT_MS } from "./telephony-tts.js";
|
|
7
|
+
|
|
8
|
+
const VOICE_CALL_CONTINUE_OPERATION_BUFFER_MS = 30000;
|
|
9
|
+
const VOICE_CALL_CONTINUE_OPERATION_CLEANUP_MS = 5 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
type VoiceCallContinueOperation =
|
|
12
|
+
| {
|
|
13
|
+
operationId: string;
|
|
14
|
+
status: "pending";
|
|
15
|
+
callId: string;
|
|
16
|
+
startedAtMs: number;
|
|
17
|
+
pollTimeoutMs: number;
|
|
18
|
+
}
|
|
19
|
+
| {
|
|
20
|
+
operationId: string;
|
|
21
|
+
status: "completed";
|
|
22
|
+
callId: string;
|
|
23
|
+
startedAtMs: number;
|
|
24
|
+
completedAtMs: number;
|
|
25
|
+
pollTimeoutMs: number;
|
|
26
|
+
result: { success: true; transcript?: string };
|
|
27
|
+
}
|
|
28
|
+
| {
|
|
29
|
+
operationId: string;
|
|
30
|
+
status: "failed";
|
|
31
|
+
callId: string;
|
|
32
|
+
startedAtMs: number;
|
|
33
|
+
completedAtMs: number;
|
|
34
|
+
pollTimeoutMs: number;
|
|
35
|
+
error: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type VoiceCallContinueOperationStartPayload = {
|
|
39
|
+
operationId: string;
|
|
40
|
+
status: "pending";
|
|
41
|
+
pollTimeoutMs: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type VoiceCallContinueOperationResultPayload =
|
|
45
|
+
| {
|
|
46
|
+
operationId: string;
|
|
47
|
+
status: "pending";
|
|
48
|
+
pollTimeoutMs: number;
|
|
49
|
+
}
|
|
50
|
+
| {
|
|
51
|
+
operationId: string;
|
|
52
|
+
status: "completed";
|
|
53
|
+
result: { success: true; transcript?: string };
|
|
54
|
+
}
|
|
55
|
+
| {
|
|
56
|
+
operationId: string;
|
|
57
|
+
status: "failed";
|
|
58
|
+
error: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type VoiceCallContinueOperationRequest = {
|
|
62
|
+
rt: VoiceCallRuntime;
|
|
63
|
+
callId: string;
|
|
64
|
+
message: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export function createVoiceCallContinueOperationStore(params: {
|
|
68
|
+
config: VoiceCallConfig;
|
|
69
|
+
coreConfig: CoreConfig;
|
|
70
|
+
}) {
|
|
71
|
+
const operations = new Map<string, VoiceCallContinueOperation>();
|
|
72
|
+
|
|
73
|
+
const resolvePollTimeoutMs = (rt: VoiceCallRuntime): number => {
|
|
74
|
+
const ttsTimeoutMs =
|
|
75
|
+
rt.config.tts?.timeoutMs ??
|
|
76
|
+
params.config.tts?.timeoutMs ??
|
|
77
|
+
params.coreConfig.messages?.tts?.timeoutMs ??
|
|
78
|
+
TELEPHONY_DEFAULT_TTS_TIMEOUT_MS;
|
|
79
|
+
return (
|
|
80
|
+
(rt.config.transcriptTimeoutMs ?? params.config.transcriptTimeoutMs) +
|
|
81
|
+
ttsTimeoutMs +
|
|
82
|
+
VOICE_CALL_CONTINUE_OPERATION_BUFFER_MS
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const scheduleCleanup = (operationId: string) => {
|
|
87
|
+
const timer = setTimeout(() => {
|
|
88
|
+
operations.delete(operationId);
|
|
89
|
+
}, VOICE_CALL_CONTINUE_OPERATION_CLEANUP_MS);
|
|
90
|
+
timer.unref?.();
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const start = (
|
|
94
|
+
request: VoiceCallContinueOperationRequest,
|
|
95
|
+
): VoiceCallContinueOperationStartPayload => {
|
|
96
|
+
const operationId = randomUUID();
|
|
97
|
+
const startedAtMs = Date.now();
|
|
98
|
+
const pollTimeoutMs = resolvePollTimeoutMs(request.rt);
|
|
99
|
+
operations.set(operationId, {
|
|
100
|
+
operationId,
|
|
101
|
+
status: "pending",
|
|
102
|
+
callId: request.callId,
|
|
103
|
+
startedAtMs,
|
|
104
|
+
pollTimeoutMs,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
void request.rt.manager
|
|
108
|
+
.continueCall(request.callId, request.message)
|
|
109
|
+
.then((result) => {
|
|
110
|
+
const current = operations.get(operationId);
|
|
111
|
+
if (!current || current.status !== "pending") {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (!result.success) {
|
|
115
|
+
operations.set(operationId, {
|
|
116
|
+
operationId,
|
|
117
|
+
status: "failed",
|
|
118
|
+
callId: request.callId,
|
|
119
|
+
startedAtMs,
|
|
120
|
+
completedAtMs: Date.now(),
|
|
121
|
+
pollTimeoutMs,
|
|
122
|
+
error: result.error || "continue failed",
|
|
123
|
+
});
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
operations.set(operationId, {
|
|
127
|
+
operationId,
|
|
128
|
+
status: "completed",
|
|
129
|
+
callId: request.callId,
|
|
130
|
+
startedAtMs,
|
|
131
|
+
completedAtMs: Date.now(),
|
|
132
|
+
pollTimeoutMs,
|
|
133
|
+
result: { success: true, transcript: result.transcript },
|
|
134
|
+
});
|
|
135
|
+
})
|
|
136
|
+
.catch((err) => {
|
|
137
|
+
const current = operations.get(operationId);
|
|
138
|
+
if (!current || current.status !== "pending") {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
operations.set(operationId, {
|
|
142
|
+
operationId,
|
|
143
|
+
status: "failed",
|
|
144
|
+
callId: request.callId,
|
|
145
|
+
startedAtMs,
|
|
146
|
+
completedAtMs: Date.now(),
|
|
147
|
+
pollTimeoutMs,
|
|
148
|
+
error: formatErrorMessage(err),
|
|
149
|
+
});
|
|
150
|
+
})
|
|
151
|
+
.finally(() => {
|
|
152
|
+
scheduleCleanup(operationId);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return { operationId, status: "pending", pollTimeoutMs };
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const read = (
|
|
159
|
+
operationId: string,
|
|
160
|
+
):
|
|
161
|
+
| { ok: true; payload: VoiceCallContinueOperationResultPayload }
|
|
162
|
+
| { ok: false; error: string } => {
|
|
163
|
+
const operation = operations.get(operationId);
|
|
164
|
+
if (!operation) {
|
|
165
|
+
return { ok: false, error: "operation not found" };
|
|
166
|
+
}
|
|
167
|
+
if (operation.status === "pending") {
|
|
168
|
+
return {
|
|
169
|
+
ok: true,
|
|
170
|
+
payload: {
|
|
171
|
+
operationId,
|
|
172
|
+
status: "pending",
|
|
173
|
+
pollTimeoutMs: operation.pollTimeoutMs,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (operation.status === "failed") {
|
|
178
|
+
operations.delete(operationId);
|
|
179
|
+
return {
|
|
180
|
+
ok: true,
|
|
181
|
+
payload: {
|
|
182
|
+
operationId,
|
|
183
|
+
status: "failed",
|
|
184
|
+
error: operation.error,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
operations.delete(operationId);
|
|
189
|
+
return {
|
|
190
|
+
ok: true,
|
|
191
|
+
payload: {
|
|
192
|
+
operationId,
|
|
193
|
+
status: "completed",
|
|
194
|
+
result: operation.result,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
return { start, read };
|
|
200
|
+
}
|
package/src/http-headers.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
|
2
|
+
|
|
3
|
+
type HttpHeaderMap = Record<string, string | string[] | undefined>;
|
|
2
4
|
|
|
3
5
|
export function getHeader(headers: HttpHeaderMap, name: string): string | undefined {
|
|
4
|
-
const target = name
|
|
6
|
+
const target = normalizeLowercaseStringOrEmpty(name);
|
|
5
7
|
const direct = headers[target];
|
|
6
8
|
const value =
|
|
7
|
-
direct ??
|
|
9
|
+
direct ??
|
|
10
|
+
Object.entries(headers).find(([key]) => normalizeLowercaseStringOrEmpty(key) === target)?.[1];
|
|
8
11
|
if (Array.isArray(value)) {
|
|
9
12
|
return value[0];
|
|
10
13
|
}
|
package/src/manager/context.ts
CHANGED
|
@@ -2,14 +2,14 @@ import type { VoiceCallConfig } from "../config.js";
|
|
|
2
2
|
import type { VoiceCallProvider } from "../providers/base.js";
|
|
3
3
|
import type { CallId, CallRecord } from "../types.js";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
type TranscriptWaiter = {
|
|
6
6
|
resolve: (text: string) => void;
|
|
7
7
|
reject: (err: Error) => void;
|
|
8
8
|
timeout: NodeJS.Timeout;
|
|
9
9
|
turnToken?: string;
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
type CallManagerRuntimeState = {
|
|
13
13
|
activeCalls: Map<CallId, CallRecord>;
|
|
14
14
|
providerCallIdMap: Map<string, CallId>;
|
|
15
15
|
processedEventIds: Set<string>;
|
|
@@ -17,20 +17,21 @@ export type CallManagerRuntimeState = {
|
|
|
17
17
|
rejectedProviderCallIds: Set<string>;
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
type CallManagerRuntimeDeps = {
|
|
21
21
|
provider: VoiceCallProvider | null;
|
|
22
22
|
config: VoiceCallConfig;
|
|
23
23
|
storePath: string;
|
|
24
24
|
webhookUrl: string | null;
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
type CallManagerTransientState = {
|
|
28
28
|
activeTurnCalls: Set<CallId>;
|
|
29
29
|
transcriptWaiters: Map<CallId, TranscriptWaiter>;
|
|
30
30
|
maxDurationTimers: Map<CallId, NodeJS.Timeout>;
|
|
31
|
+
initialMessageInFlight: Set<CallId>;
|
|
31
32
|
};
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
type CallManagerHooks = {
|
|
34
35
|
/** Optional runtime hook invoked after an event transitions a call into answered state. */
|
|
35
36
|
onCallAnswered?: (call: CallRecord) => void;
|
|
36
37
|
};
|
|
@@ -1,17 +1,34 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
5
|
import { VoiceCallConfigSchema } from "../config.js";
|
|
6
6
|
import type { VoiceCallProvider } from "../providers/base.js";
|
|
7
|
-
import type { HangupCallInput, NormalizedEvent } from "../types.js";
|
|
7
|
+
import type { AnswerCallInput, HangupCallInput, NormalizedEvent } from "../types.js";
|
|
8
8
|
import type { CallManagerContext } from "./context.js";
|
|
9
9
|
import { processEvent } from "./events.js";
|
|
10
|
+
import { flushPendingCallRecordWritesForTest } from "./store.js";
|
|
11
|
+
|
|
12
|
+
const contexts: CallManagerContext[] = [];
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
for (const ctx of contexts.splice(0)) {
|
|
16
|
+
for (const timer of ctx.maxDurationTimers.values()) {
|
|
17
|
+
clearTimeout(timer);
|
|
18
|
+
}
|
|
19
|
+
ctx.maxDurationTimers.clear();
|
|
20
|
+
for (const waiter of ctx.transcriptWaiters.values()) {
|
|
21
|
+
clearTimeout(waiter.timeout);
|
|
22
|
+
}
|
|
23
|
+
ctx.transcriptWaiters.clear();
|
|
24
|
+
await flushPendingCallRecordWritesForTest();
|
|
25
|
+
fs.rmSync(ctx.storePath, { recursive: true, force: true });
|
|
26
|
+
}
|
|
27
|
+
});
|
|
10
28
|
|
|
11
29
|
function createContext(overrides: Partial<CallManagerContext> = {}): CallManagerContext {
|
|
12
|
-
const storePath = path.join(os.tmpdir(),
|
|
13
|
-
|
|
14
|
-
return {
|
|
30
|
+
const storePath = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-voice-call-events-test-"));
|
|
31
|
+
const ctx: CallManagerContext = {
|
|
15
32
|
activeCalls: new Map(),
|
|
16
33
|
providerCallIdMap: new Map(),
|
|
17
34
|
processedEventIds: new Set(),
|
|
@@ -27,8 +44,11 @@ function createContext(overrides: Partial<CallManagerContext> = {}): CallManager
|
|
|
27
44
|
activeTurnCalls: new Set(),
|
|
28
45
|
transcriptWaiters: new Map(),
|
|
29
46
|
maxDurationTimers: new Map(),
|
|
47
|
+
initialMessageInFlight: new Set(),
|
|
30
48
|
...overrides,
|
|
31
49
|
};
|
|
50
|
+
contexts.push(ctx);
|
|
51
|
+
return ctx;
|
|
32
52
|
}
|
|
33
53
|
|
|
34
54
|
function createProvider(overrides: Partial<VoiceCallProvider> = {}): VoiceCallProvider {
|
|
@@ -89,6 +109,14 @@ function createRejectingInboundContext(): {
|
|
|
89
109
|
return { ctx, hangupCalls };
|
|
90
110
|
}
|
|
91
111
|
|
|
112
|
+
function requireFirstActiveCall(ctx: CallManagerContext) {
|
|
113
|
+
const call = [...ctx.activeCalls.values()][0];
|
|
114
|
+
if (!call) {
|
|
115
|
+
throw new Error("expected one active call");
|
|
116
|
+
}
|
|
117
|
+
return call;
|
|
118
|
+
}
|
|
119
|
+
|
|
92
120
|
describe("processEvent (functional)", () => {
|
|
93
121
|
it("calls provider hangup when rejecting inbound call", () => {
|
|
94
122
|
const { ctx, hangupCalls } = createRejectingInboundContext();
|
|
@@ -147,8 +175,50 @@ describe("processEvent (functional)", () => {
|
|
|
147
175
|
processEvent(ctx, event2);
|
|
148
176
|
|
|
149
177
|
expect(ctx.activeCalls.size).toBe(0);
|
|
150
|
-
expect(hangupCalls).
|
|
151
|
-
|
|
178
|
+
expect(hangupCalls).toEqual([
|
|
179
|
+
expect.objectContaining({
|
|
180
|
+
providerCallId: "prov-dup",
|
|
181
|
+
reason: "hangup-bot",
|
|
182
|
+
}),
|
|
183
|
+
]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("answers accepted inbound calls when the provider requires an answer command", () => {
|
|
187
|
+
const answerCalls: AnswerCallInput[] = [];
|
|
188
|
+
const provider = createProvider({
|
|
189
|
+
answerCall: async (input: AnswerCallInput): Promise<void> => {
|
|
190
|
+
answerCalls.push(input);
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
const ctx = createContext({
|
|
194
|
+
config: VoiceCallConfigSchema.parse({
|
|
195
|
+
enabled: true,
|
|
196
|
+
provider: "telnyx",
|
|
197
|
+
fromNumber: "+15550000000",
|
|
198
|
+
inboundPolicy: "open",
|
|
199
|
+
telnyx: {
|
|
200
|
+
apiKey: "KEY123",
|
|
201
|
+
connectionId: "CONN456",
|
|
202
|
+
},
|
|
203
|
+
skipSignatureVerification: true,
|
|
204
|
+
}),
|
|
205
|
+
provider,
|
|
206
|
+
});
|
|
207
|
+
const event = createInboundInitiatedEvent({
|
|
208
|
+
id: "evt-answer",
|
|
209
|
+
providerCallId: "call-control-1",
|
|
210
|
+
from: "+15552222222",
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
processEvent(ctx, event);
|
|
214
|
+
|
|
215
|
+
const call = requireFirstActiveCall(ctx);
|
|
216
|
+
expect(answerCalls).toEqual([
|
|
217
|
+
{
|
|
218
|
+
callId: call.callId,
|
|
219
|
+
providerCallId: "call-control-1",
|
|
220
|
+
},
|
|
221
|
+
]);
|
|
152
222
|
});
|
|
153
223
|
|
|
154
224
|
it("updates providerCallId map when provider ID changes", () => {
|
|
@@ -177,11 +247,57 @@ describe("processEvent (functional)", () => {
|
|
|
177
247
|
timestamp: now + 1,
|
|
178
248
|
});
|
|
179
249
|
|
|
180
|
-
|
|
250
|
+
const activeCall = ctx.activeCalls.get("call-1");
|
|
251
|
+
if (!activeCall) {
|
|
252
|
+
throw new Error("expected active call after provider id change");
|
|
253
|
+
}
|
|
254
|
+
expect(activeCall.providerCallId).toBe("call-uuid");
|
|
181
255
|
expect(ctx.providerCallIdMap.get("call-uuid")).toBe("call-1");
|
|
182
256
|
expect(ctx.providerCallIdMap.has("request-uuid")).toBe(false);
|
|
183
257
|
});
|
|
184
258
|
|
|
259
|
+
it("does not burn replay keys for unknown calls before a later replay can resolve them", () => {
|
|
260
|
+
const now = Date.now();
|
|
261
|
+
const ctx = createContext();
|
|
262
|
+
const event: NormalizedEvent = {
|
|
263
|
+
id: "evt-late-call",
|
|
264
|
+
dedupeKey: "stable-late-call",
|
|
265
|
+
type: "call.answered",
|
|
266
|
+
callId: "call-late",
|
|
267
|
+
providerCallId: "provider-late",
|
|
268
|
+
timestamp: now + 1,
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
processEvent(ctx, event);
|
|
272
|
+
|
|
273
|
+
expect(ctx.processedEventIds.size).toBe(0);
|
|
274
|
+
|
|
275
|
+
ctx.activeCalls.set("call-late", {
|
|
276
|
+
callId: "call-late",
|
|
277
|
+
providerCallId: "provider-late",
|
|
278
|
+
provider: "plivo",
|
|
279
|
+
direction: "inbound",
|
|
280
|
+
state: "ringing",
|
|
281
|
+
from: "+15550000002",
|
|
282
|
+
to: "+15550000000",
|
|
283
|
+
startedAt: now,
|
|
284
|
+
transcript: [],
|
|
285
|
+
processedEventIds: [],
|
|
286
|
+
metadata: {},
|
|
287
|
+
});
|
|
288
|
+
ctx.providerCallIdMap.set("provider-late", "call-late");
|
|
289
|
+
|
|
290
|
+
processEvent(ctx, event);
|
|
291
|
+
|
|
292
|
+
const call = ctx.activeCalls.get("call-late");
|
|
293
|
+
if (!call) {
|
|
294
|
+
throw new Error("expected replayed event to resolve after call registration");
|
|
295
|
+
}
|
|
296
|
+
expect(call.state).toBe("answered");
|
|
297
|
+
expect(call.answeredAt).toBe(now + 1);
|
|
298
|
+
expect(Array.from(ctx.processedEventIds)).toEqual(["stable-late-call"]);
|
|
299
|
+
});
|
|
300
|
+
|
|
185
301
|
it("invokes onCallAnswered hook for answered events", () => {
|
|
186
302
|
const now = Date.now();
|
|
187
303
|
let answeredCallId: string | null = null;
|
|
@@ -253,12 +369,12 @@ describe("processEvent (functional)", () => {
|
|
|
253
369
|
|
|
254
370
|
// Call should be registered in activeCalls and providerCallIdMap
|
|
255
371
|
expect(ctx.activeCalls.size).toBe(1);
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
expect(call
|
|
259
|
-
expect(call
|
|
260
|
-
expect(call
|
|
261
|
-
expect(call
|
|
372
|
+
const call = requireFirstActiveCall(ctx);
|
|
373
|
+
expect(ctx.providerCallIdMap.get("CA-external-123")).toBe(call.callId);
|
|
374
|
+
expect(call.providerCallId).toBe("CA-external-123");
|
|
375
|
+
expect(call.direction).toBe("outbound");
|
|
376
|
+
expect(call.from).toBe("+15550000000");
|
|
377
|
+
expect(call.to).toBe("+15559876543");
|
|
262
378
|
});
|
|
263
379
|
|
|
264
380
|
it("does not reject externally-initiated outbound calls even with disabled inbound policy", () => {
|
|
@@ -279,8 +395,8 @@ describe("processEvent (functional)", () => {
|
|
|
279
395
|
// External outbound calls bypass inbound policy — they should be accepted
|
|
280
396
|
expect(ctx.activeCalls.size).toBe(1);
|
|
281
397
|
expect(hangupCalls).toHaveLength(0);
|
|
282
|
-
const call =
|
|
283
|
-
expect(call
|
|
398
|
+
const call = requireFirstActiveCall(ctx);
|
|
399
|
+
expect(call.direction).toBe("outbound");
|
|
284
400
|
});
|
|
285
401
|
|
|
286
402
|
it("preserves inbound direction for auto-registered inbound calls", () => {
|
|
@@ -306,8 +422,8 @@ describe("processEvent (functional)", () => {
|
|
|
306
422
|
processEvent(ctx, event);
|
|
307
423
|
|
|
308
424
|
expect(ctx.activeCalls.size).toBe(1);
|
|
309
|
-
const call =
|
|
310
|
-
expect(call
|
|
425
|
+
const call = requireFirstActiveCall(ctx);
|
|
426
|
+
expect(call.direction).toBe("inbound");
|
|
311
427
|
});
|
|
312
428
|
|
|
313
429
|
it("deduplicates by dedupeKey even when event IDs differ", () => {
|
|
@@ -351,7 +467,51 @@ describe("processEvent (functional)", () => {
|
|
|
351
467
|
});
|
|
352
468
|
|
|
353
469
|
const call = ctx.activeCalls.get("call-dedupe");
|
|
354
|
-
|
|
470
|
+
if (!call) {
|
|
471
|
+
throw new Error("expected deduped call to remain active");
|
|
472
|
+
}
|
|
473
|
+
expect(call.transcript).toHaveLength(1);
|
|
355
474
|
expect(Array.from(ctx.processedEventIds)).toEqual(["stable-key-1"]);
|
|
356
475
|
});
|
|
476
|
+
|
|
477
|
+
it("keeps retryable call.error events replayable", () => {
|
|
478
|
+
const now = Date.now();
|
|
479
|
+
const ctx = createContext();
|
|
480
|
+
ctx.activeCalls.set("call-retryable-error", {
|
|
481
|
+
callId: "call-retryable-error",
|
|
482
|
+
providerCallId: "provider-retryable-error",
|
|
483
|
+
provider: "plivo",
|
|
484
|
+
direction: "outbound",
|
|
485
|
+
state: "active",
|
|
486
|
+
from: "+15550000000",
|
|
487
|
+
to: "+15550000001",
|
|
488
|
+
startedAt: now,
|
|
489
|
+
transcript: [],
|
|
490
|
+
processedEventIds: [],
|
|
491
|
+
metadata: {},
|
|
492
|
+
});
|
|
493
|
+
ctx.providerCallIdMap.set("provider-retryable-error", "call-retryable-error");
|
|
494
|
+
|
|
495
|
+
const event: NormalizedEvent = {
|
|
496
|
+
id: "evt-retryable-error",
|
|
497
|
+
dedupeKey: "stable-retryable-error",
|
|
498
|
+
type: "call.error",
|
|
499
|
+
callId: "call-retryable-error",
|
|
500
|
+
providerCallId: "provider-retryable-error",
|
|
501
|
+
timestamp: now + 1,
|
|
502
|
+
error: "temporary upstream failure",
|
|
503
|
+
retryable: true,
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
processEvent(ctx, event);
|
|
507
|
+
processEvent(ctx, event);
|
|
508
|
+
|
|
509
|
+
const call = ctx.activeCalls.get("call-retryable-error");
|
|
510
|
+
if (!call) {
|
|
511
|
+
throw new Error("expected retryable error call to remain active");
|
|
512
|
+
}
|
|
513
|
+
expect(call.state).toBe("active");
|
|
514
|
+
expect(Array.from(ctx.processedEventIds)).toEqual([]);
|
|
515
|
+
expect(call.processedEventIds).toEqual([]);
|
|
516
|
+
});
|
|
357
517
|
});
|