@openclaw/voice-call 2026.2.15 → 2026.2.19
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/CHANGELOG.md +12 -0
- package/README.md +20 -0
- package/index.ts +2 -2
- package/package.json +1 -1
- package/src/cli.ts +92 -1
- package/src/config.test.ts +74 -117
- package/src/config.ts +8 -0
- package/src/manager/context.ts +1 -0
- package/src/manager/events.test.ts +26 -13
- package/src/manager/events.ts +1 -1
- package/src/manager/outbound.ts +36 -2
- package/src/manager/timers.ts +4 -3
- package/src/manager.test.ts +225 -4
- package/src/manager.ts +4 -2
- package/src/media-stream.test.ts +1 -1
- package/src/providers/plivo.ts +1 -1
- package/src/providers/telnyx.test.ts +33 -48
- package/src/providers/telnyx.ts +1 -1
- package/src/providers/twilio/webhook.ts +1 -1
- package/src/providers/twilio.ts +2 -2
- package/src/runtime.ts +3 -3
- package/src/telephony-tts.test.ts +75 -0
- package/src/telephony-tts.ts +3 -1
- package/src/webhook.test.ts +118 -0
- package/src/webhook.ts +51 -2
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -87,6 +87,26 @@ Notes:
|
|
|
87
87
|
- Telnyx requires `telnyx.publicKey` (or `TELNYX_PUBLIC_KEY`) unless `skipSignatureVerification` is true.
|
|
88
88
|
- `tunnel.allowNgrokFreeTierLoopbackBypass: true` allows Twilio webhooks with invalid signatures **only** when `tunnel.provider="ngrok"` and `serve.bind` is loopback (ngrok local agent). Use for local dev only.
|
|
89
89
|
|
|
90
|
+
## Stale call reaper
|
|
91
|
+
|
|
92
|
+
Use `staleCallReaperSeconds` to end calls that never receive a terminal webhook
|
|
93
|
+
(for example, notify-mode calls that never complete). The default is `0`
|
|
94
|
+
(disabled).
|
|
95
|
+
|
|
96
|
+
Recommended ranges:
|
|
97
|
+
|
|
98
|
+
- **Production:** `120`–`300` seconds for notify-style flows.
|
|
99
|
+
- Keep this value **higher than `maxDurationSeconds`** so normal calls can
|
|
100
|
+
finish. A good starting point is `maxDurationSeconds + 30–60` seconds.
|
|
101
|
+
|
|
102
|
+
Example:
|
|
103
|
+
|
|
104
|
+
```json5
|
|
105
|
+
{
|
|
106
|
+
staleCallReaperSeconds: 360,
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
90
110
|
## TTS for calls
|
|
91
111
|
|
|
92
112
|
Voice Call uses the core `messages.tts` configuration (OpenAI or ElevenLabs) for
|
package/index.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import type { GatewayRequestHandlerOptions, OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
1
|
import { Type } from "@sinclair/typebox";
|
|
3
|
-
import type {
|
|
2
|
+
import type { GatewayRequestHandlerOptions, OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
4
3
|
import { registerVoiceCallCli } from "./src/cli.js";
|
|
5
4
|
import {
|
|
6
5
|
VoiceCallConfigSchema,
|
|
@@ -8,6 +7,7 @@ import {
|
|
|
8
7
|
validateProviderConfig,
|
|
9
8
|
type VoiceCallConfig,
|
|
10
9
|
} from "./src/config.js";
|
|
10
|
+
import type { CoreConfig } from "./src/core-bridge.js";
|
|
11
11
|
import { createVoiceCallRuntime, type VoiceCallRuntime } from "./src/runtime.js";
|
|
12
12
|
|
|
13
13
|
const voiceCallConfigSchema = {
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { Command } from "commander";
|
|
2
1
|
import fs from "node:fs";
|
|
3
2
|
import os from "node:os";
|
|
4
3
|
import path from "node:path";
|
|
4
|
+
import type { Command } from "commander";
|
|
5
5
|
import { sleep } from "openclaw/plugin-sdk";
|
|
6
6
|
import type { VoiceCallConfig } from "./config.js";
|
|
7
7
|
import type { VoiceCallRuntime } from "./runtime.js";
|
|
@@ -41,6 +41,46 @@ function resolveDefaultStorePath(config: VoiceCallConfig): string {
|
|
|
41
41
|
return path.join(base, "calls.jsonl");
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
function percentile(values: number[], p: number): number {
|
|
45
|
+
if (values.length === 0) {
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
49
|
+
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
|
|
50
|
+
return sorted[idx] ?? 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function summarizeSeries(values: number[]): {
|
|
54
|
+
count: number;
|
|
55
|
+
minMs: number;
|
|
56
|
+
maxMs: number;
|
|
57
|
+
avgMs: number;
|
|
58
|
+
p50Ms: number;
|
|
59
|
+
p95Ms: number;
|
|
60
|
+
} {
|
|
61
|
+
if (values.length === 0) {
|
|
62
|
+
return { count: 0, minMs: 0, maxMs: 0, avgMs: 0, p50Ms: 0, p95Ms: 0 };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const minMs = values.reduce(
|
|
66
|
+
(min, value) => (value < min ? value : min),
|
|
67
|
+
Number.POSITIVE_INFINITY,
|
|
68
|
+
);
|
|
69
|
+
const maxMs = values.reduce(
|
|
70
|
+
(max, value) => (value > max ? value : max),
|
|
71
|
+
Number.NEGATIVE_INFINITY,
|
|
72
|
+
);
|
|
73
|
+
const avgMs = values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
74
|
+
return {
|
|
75
|
+
count: values.length,
|
|
76
|
+
minMs,
|
|
77
|
+
maxMs,
|
|
78
|
+
avgMs,
|
|
79
|
+
p50Ms: percentile(values, 50),
|
|
80
|
+
p95Ms: percentile(values, 95),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
44
84
|
export function registerVoiceCallCli(params: {
|
|
45
85
|
program: Command;
|
|
46
86
|
config: VoiceCallConfig;
|
|
@@ -216,6 +256,57 @@ export function registerVoiceCallCli(params: {
|
|
|
216
256
|
}
|
|
217
257
|
});
|
|
218
258
|
|
|
259
|
+
root
|
|
260
|
+
.command("latency")
|
|
261
|
+
.description("Summarize turn latency metrics from voice-call JSONL logs")
|
|
262
|
+
.option("--file <path>", "Path to calls.jsonl", resolveDefaultStorePath(config))
|
|
263
|
+
.option("--last <n>", "Analyze last N records", "200")
|
|
264
|
+
.action(async (options: { file: string; last?: string }) => {
|
|
265
|
+
const file = options.file;
|
|
266
|
+
const last = Math.max(1, Number(options.last ?? 200));
|
|
267
|
+
|
|
268
|
+
if (!fs.existsSync(file)) {
|
|
269
|
+
throw new Error("No log file at " + file);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const content = fs.readFileSync(file, "utf8");
|
|
273
|
+
const lines = content.split("\n").filter(Boolean).slice(-last);
|
|
274
|
+
|
|
275
|
+
const turnLatencyMs: number[] = [];
|
|
276
|
+
const listenWaitMs: number[] = [];
|
|
277
|
+
|
|
278
|
+
for (const line of lines) {
|
|
279
|
+
try {
|
|
280
|
+
const parsed = JSON.parse(line) as {
|
|
281
|
+
metadata?: { lastTurnLatencyMs?: unknown; lastTurnListenWaitMs?: unknown };
|
|
282
|
+
};
|
|
283
|
+
const latency = parsed.metadata?.lastTurnLatencyMs;
|
|
284
|
+
const listenWait = parsed.metadata?.lastTurnListenWaitMs;
|
|
285
|
+
if (typeof latency === "number" && Number.isFinite(latency)) {
|
|
286
|
+
turnLatencyMs.push(latency);
|
|
287
|
+
}
|
|
288
|
+
if (typeof listenWait === "number" && Number.isFinite(listenWait)) {
|
|
289
|
+
listenWaitMs.push(listenWait);
|
|
290
|
+
}
|
|
291
|
+
} catch {
|
|
292
|
+
// ignore malformed JSON lines
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// eslint-disable-next-line no-console
|
|
297
|
+
console.log(
|
|
298
|
+
JSON.stringify(
|
|
299
|
+
{
|
|
300
|
+
recordsScanned: lines.length,
|
|
301
|
+
turnLatency: summarizeSeries(turnLatencyMs),
|
|
302
|
+
listenWait: summarizeSeries(listenWaitMs),
|
|
303
|
+
},
|
|
304
|
+
null,
|
|
305
|
+
2,
|
|
306
|
+
),
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
|
|
219
310
|
root
|
|
220
311
|
.command("expose")
|
|
221
312
|
.description("Enable/disable Tailscale serve/funnel for the webhook")
|
package/src/config.test.ts
CHANGED
|
@@ -10,6 +10,7 @@ function createBaseConfig(provider: "telnyx" | "twilio" | "plivo" | "mock"): Voi
|
|
|
10
10
|
allowFrom: [],
|
|
11
11
|
outbound: { defaultMode: "notify", notifyHangupDelaySec: 3 },
|
|
12
12
|
maxDurationSeconds: 300,
|
|
13
|
+
staleCallReaperSeconds: 600,
|
|
13
14
|
silenceTimeoutMs: 800,
|
|
14
15
|
transcriptTimeoutMs: 180000,
|
|
15
16
|
ringTimeoutMs: 30000,
|
|
@@ -32,7 +33,10 @@ function createBaseConfig(provider: "telnyx" | "twilio" | "plivo" | "mock"): Voi
|
|
|
32
33
|
},
|
|
33
34
|
skipSignatureVerification: false,
|
|
34
35
|
stt: { provider: "openai", model: "whisper-1" },
|
|
35
|
-
tts: {
|
|
36
|
+
tts: {
|
|
37
|
+
provider: "openai",
|
|
38
|
+
openai: { model: "gpt-4o-mini-tts", voice: "coral" },
|
|
39
|
+
},
|
|
36
40
|
responseModel: "openai/gpt-4o-mini",
|
|
37
41
|
responseTimeoutMs: 30000,
|
|
38
42
|
};
|
|
@@ -40,9 +44,7 @@ function createBaseConfig(provider: "telnyx" | "twilio" | "plivo" | "mock"): Voi
|
|
|
40
44
|
|
|
41
45
|
describe("validateProviderConfig", () => {
|
|
42
46
|
const originalEnv = { ...process.env };
|
|
43
|
-
|
|
44
|
-
beforeEach(() => {
|
|
45
|
-
// Clear all relevant env vars before each test
|
|
47
|
+
const clearProviderEnv = () => {
|
|
46
48
|
delete process.env.TWILIO_ACCOUNT_SID;
|
|
47
49
|
delete process.env.TWILIO_AUTH_TOKEN;
|
|
48
50
|
delete process.env.TELNYX_API_KEY;
|
|
@@ -50,6 +52,10 @@ describe("validateProviderConfig", () => {
|
|
|
50
52
|
delete process.env.TELNYX_PUBLIC_KEY;
|
|
51
53
|
delete process.env.PLIVO_AUTH_ID;
|
|
52
54
|
delete process.env.PLIVO_AUTH_TOKEN;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
clearProviderEnv();
|
|
53
59
|
});
|
|
54
60
|
|
|
55
61
|
afterEach(() => {
|
|
@@ -57,29 +63,43 @@ describe("validateProviderConfig", () => {
|
|
|
57
63
|
process.env = { ...originalEnv };
|
|
58
64
|
});
|
|
59
65
|
|
|
60
|
-
describe("
|
|
61
|
-
it("passes validation when credentials
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
66
|
+
describe("provider credential sources", () => {
|
|
67
|
+
it("passes validation when credentials come from config or environment", () => {
|
|
68
|
+
for (const provider of ["twilio", "telnyx", "plivo"] as const) {
|
|
69
|
+
clearProviderEnv();
|
|
70
|
+
const fromConfig = createBaseConfig(provider);
|
|
71
|
+
if (provider === "twilio") {
|
|
72
|
+
fromConfig.twilio = { accountSid: "AC123", authToken: "secret" };
|
|
73
|
+
} else if (provider === "telnyx") {
|
|
74
|
+
fromConfig.telnyx = {
|
|
75
|
+
apiKey: "KEY123",
|
|
76
|
+
connectionId: "CONN456",
|
|
77
|
+
publicKey: "public-key",
|
|
78
|
+
};
|
|
79
|
+
} else {
|
|
80
|
+
fromConfig.plivo = { authId: "MA123", authToken: "secret" };
|
|
81
|
+
}
|
|
82
|
+
expect(validateProviderConfig(fromConfig)).toMatchObject({ valid: true, errors: [] });
|
|
83
|
+
|
|
84
|
+
clearProviderEnv();
|
|
85
|
+
if (provider === "twilio") {
|
|
86
|
+
process.env.TWILIO_ACCOUNT_SID = "AC123";
|
|
87
|
+
process.env.TWILIO_AUTH_TOKEN = "secret";
|
|
88
|
+
} else if (provider === "telnyx") {
|
|
89
|
+
process.env.TELNYX_API_KEY = "KEY123";
|
|
90
|
+
process.env.TELNYX_CONNECTION_ID = "CONN456";
|
|
91
|
+
process.env.TELNYX_PUBLIC_KEY = "public-key";
|
|
92
|
+
} else {
|
|
93
|
+
process.env.PLIVO_AUTH_ID = "MA123";
|
|
94
|
+
process.env.PLIVO_AUTH_TOKEN = "secret";
|
|
95
|
+
}
|
|
96
|
+
const fromEnv = resolveVoiceCallConfig(createBaseConfig(provider));
|
|
97
|
+
expect(validateProviderConfig(fromEnv)).toMatchObject({ valid: true, errors: [] });
|
|
98
|
+
}
|
|
81
99
|
});
|
|
100
|
+
});
|
|
82
101
|
|
|
102
|
+
describe("twilio provider", () => {
|
|
83
103
|
it("passes validation with mixed config and env vars", () => {
|
|
84
104
|
process.env.TWILIO_AUTH_TOKEN = "secret";
|
|
85
105
|
let config = createBaseConfig("twilio");
|
|
@@ -92,57 +112,27 @@ describe("validateProviderConfig", () => {
|
|
|
92
112
|
expect(result.errors).toEqual([]);
|
|
93
113
|
});
|
|
94
114
|
|
|
95
|
-
it("fails validation when
|
|
115
|
+
it("fails validation when required twilio credentials are missing", () => {
|
|
96
116
|
process.env.TWILIO_AUTH_TOKEN = "secret";
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const result = validateProviderConfig(config);
|
|
101
|
-
|
|
102
|
-
expect(result.valid).toBe(false);
|
|
103
|
-
expect(result.errors).toContain(
|
|
117
|
+
const missingSid = validateProviderConfig(resolveVoiceCallConfig(createBaseConfig("twilio")));
|
|
118
|
+
expect(missingSid.valid).toBe(false);
|
|
119
|
+
expect(missingSid.errors).toContain(
|
|
104
120
|
"plugins.entries.voice-call.config.twilio.accountSid is required (or set TWILIO_ACCOUNT_SID env)",
|
|
105
121
|
);
|
|
106
|
-
});
|
|
107
122
|
|
|
108
|
-
|
|
123
|
+
delete process.env.TWILIO_AUTH_TOKEN;
|
|
109
124
|
process.env.TWILIO_ACCOUNT_SID = "AC123";
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
expect(result.valid).toBe(false);
|
|
116
|
-
expect(result.errors).toContain(
|
|
125
|
+
const missingToken = validateProviderConfig(
|
|
126
|
+
resolveVoiceCallConfig(createBaseConfig("twilio")),
|
|
127
|
+
);
|
|
128
|
+
expect(missingToken.valid).toBe(false);
|
|
129
|
+
expect(missingToken.errors).toContain(
|
|
117
130
|
"plugins.entries.voice-call.config.twilio.authToken is required (or set TWILIO_AUTH_TOKEN env)",
|
|
118
131
|
);
|
|
119
132
|
});
|
|
120
133
|
});
|
|
121
134
|
|
|
122
135
|
describe("telnyx provider", () => {
|
|
123
|
-
it("passes validation when credentials are in config", () => {
|
|
124
|
-
const config = createBaseConfig("telnyx");
|
|
125
|
-
config.telnyx = { apiKey: "KEY123", connectionId: "CONN456", publicKey: "public-key" };
|
|
126
|
-
|
|
127
|
-
const result = validateProviderConfig(config);
|
|
128
|
-
|
|
129
|
-
expect(result.valid).toBe(true);
|
|
130
|
-
expect(result.errors).toEqual([]);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("passes validation when credentials are in environment variables", () => {
|
|
134
|
-
process.env.TELNYX_API_KEY = "KEY123";
|
|
135
|
-
process.env.TELNYX_CONNECTION_ID = "CONN456";
|
|
136
|
-
process.env.TELNYX_PUBLIC_KEY = "public-key";
|
|
137
|
-
let config = createBaseConfig("telnyx");
|
|
138
|
-
config = resolveVoiceCallConfig(config);
|
|
139
|
-
|
|
140
|
-
const result = validateProviderConfig(config);
|
|
141
|
-
|
|
142
|
-
expect(result.valid).toBe(true);
|
|
143
|
-
expect(result.errors).toEqual([]);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
136
|
it("fails validation when apiKey is missing everywhere", () => {
|
|
147
137
|
process.env.TELNYX_CONNECTION_ID = "CONN456";
|
|
148
138
|
let config = createBaseConfig("telnyx");
|
|
@@ -156,69 +146,36 @@ describe("validateProviderConfig", () => {
|
|
|
156
146
|
);
|
|
157
147
|
});
|
|
158
148
|
|
|
159
|
-
it("
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
expect(result.valid).toBe(false);
|
|
167
|
-
expect(result.errors).toContain(
|
|
149
|
+
it("requires a public key unless signature verification is skipped", () => {
|
|
150
|
+
const missingPublicKey = createBaseConfig("telnyx");
|
|
151
|
+
missingPublicKey.inboundPolicy = "allowlist";
|
|
152
|
+
missingPublicKey.telnyx = { apiKey: "KEY123", connectionId: "CONN456" };
|
|
153
|
+
const missingPublicKeyResult = validateProviderConfig(missingPublicKey);
|
|
154
|
+
expect(missingPublicKeyResult.valid).toBe(false);
|
|
155
|
+
expect(missingPublicKeyResult.errors).toContain(
|
|
168
156
|
"plugins.entries.voice-call.config.telnyx.publicKey is required (or set TELNYX_PUBLIC_KEY env)",
|
|
169
157
|
);
|
|
170
|
-
});
|
|
171
158
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
config.telnyx = {
|
|
159
|
+
const withPublicKey = createBaseConfig("telnyx");
|
|
160
|
+
withPublicKey.inboundPolicy = "allowlist";
|
|
161
|
+
withPublicKey.telnyx = {
|
|
176
162
|
apiKey: "KEY123",
|
|
177
163
|
connectionId: "CONN456",
|
|
178
164
|
publicKey: "public-key",
|
|
179
165
|
};
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
config.skipSignatureVerification = true;
|
|
190
|
-
config.telnyx = { apiKey: "KEY123", connectionId: "CONN456" };
|
|
191
|
-
|
|
192
|
-
const result = validateProviderConfig(config);
|
|
193
|
-
|
|
194
|
-
expect(result.valid).toBe(true);
|
|
195
|
-
expect(result.errors).toEqual([]);
|
|
166
|
+
expect(validateProviderConfig(withPublicKey)).toMatchObject({ valid: true, errors: [] });
|
|
167
|
+
|
|
168
|
+
const skippedVerification = createBaseConfig("telnyx");
|
|
169
|
+
skippedVerification.skipSignatureVerification = true;
|
|
170
|
+
skippedVerification.telnyx = { apiKey: "KEY123", connectionId: "CONN456" };
|
|
171
|
+
expect(validateProviderConfig(skippedVerification)).toMatchObject({
|
|
172
|
+
valid: true,
|
|
173
|
+
errors: [],
|
|
174
|
+
});
|
|
196
175
|
});
|
|
197
176
|
});
|
|
198
177
|
|
|
199
178
|
describe("plivo provider", () => {
|
|
200
|
-
it("passes validation when credentials are in config", () => {
|
|
201
|
-
const config = createBaseConfig("plivo");
|
|
202
|
-
config.plivo = { authId: "MA123", authToken: "secret" };
|
|
203
|
-
|
|
204
|
-
const result = validateProviderConfig(config);
|
|
205
|
-
|
|
206
|
-
expect(result.valid).toBe(true);
|
|
207
|
-
expect(result.errors).toEqual([]);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it("passes validation when credentials are in environment variables", () => {
|
|
211
|
-
process.env.PLIVO_AUTH_ID = "MA123";
|
|
212
|
-
process.env.PLIVO_AUTH_TOKEN = "secret";
|
|
213
|
-
let config = createBaseConfig("plivo");
|
|
214
|
-
config = resolveVoiceCallConfig(config);
|
|
215
|
-
|
|
216
|
-
const result = validateProviderConfig(config);
|
|
217
|
-
|
|
218
|
-
expect(result.valid).toBe(true);
|
|
219
|
-
expect(result.errors).toEqual([]);
|
|
220
|
-
});
|
|
221
|
-
|
|
222
179
|
it("fails validation when authId is missing everywhere", () => {
|
|
223
180
|
process.env.PLIVO_AUTH_TOKEN = "secret";
|
|
224
181
|
let config = createBaseConfig("plivo");
|
package/src/config.ts
CHANGED
|
@@ -273,6 +273,14 @@ export const VoiceCallConfigSchema = z
|
|
|
273
273
|
/** Maximum call duration in seconds */
|
|
274
274
|
maxDurationSeconds: z.number().int().positive().default(300),
|
|
275
275
|
|
|
276
|
+
/**
|
|
277
|
+
* Maximum age of a call in seconds before it is automatically reaped.
|
|
278
|
+
* Catches calls stuck in unexpected states (e.g., notify-mode calls that
|
|
279
|
+
* never receive a terminal webhook). Set to 0 to disable.
|
|
280
|
+
* Default: 0 (disabled). Recommended: 120-300 for production.
|
|
281
|
+
*/
|
|
282
|
+
staleCallReaperSeconds: z.number().int().nonnegative().default(0),
|
|
283
|
+
|
|
276
284
|
/** Silence timeout for end-of-speech detection (ms) */
|
|
277
285
|
silenceTimeoutMs: z.number().int().positive().default(800),
|
|
278
286
|
|
package/src/manager/context.ts
CHANGED
|
@@ -2,9 +2,10 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { VoiceCallConfigSchema } from "../config.js";
|
|
6
|
+
import type { VoiceCallProvider } from "../providers/base.js";
|
|
5
7
|
import type { HangupCallInput, NormalizedEvent } from "../types.js";
|
|
6
8
|
import type { CallManagerContext } from "./context.js";
|
|
7
|
-
import { VoiceCallConfigSchema } from "../config.js";
|
|
8
9
|
import { processEvent } from "./events.js";
|
|
9
10
|
|
|
10
11
|
function createContext(overrides: Partial<CallManagerContext> = {}): CallManagerContext {
|
|
@@ -23,21 +24,35 @@ function createContext(overrides: Partial<CallManagerContext> = {}): CallManager
|
|
|
23
24
|
}),
|
|
24
25
|
storePath,
|
|
25
26
|
webhookUrl: null,
|
|
27
|
+
activeTurnCalls: new Set(),
|
|
26
28
|
transcriptWaiters: new Map(),
|
|
27
29
|
maxDurationTimers: new Map(),
|
|
28
30
|
...overrides,
|
|
29
31
|
};
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
function createProvider(overrides: Partial<VoiceCallProvider> = {}): VoiceCallProvider {
|
|
35
|
+
return {
|
|
36
|
+
name: "plivo",
|
|
37
|
+
verifyWebhook: () => ({ ok: true }),
|
|
38
|
+
parseWebhookEvent: () => ({ events: [] }),
|
|
39
|
+
initiateCall: async () => ({ providerCallId: "provider-call-id", status: "initiated" }),
|
|
40
|
+
hangupCall: async () => {},
|
|
41
|
+
playTts: async () => {},
|
|
42
|
+
startListening: async () => {},
|
|
43
|
+
stopListening: async () => {},
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
describe("processEvent (functional)", () => {
|
|
33
49
|
it("calls provider hangup when rejecting inbound call", () => {
|
|
34
50
|
const hangupCalls: HangupCallInput[] = [];
|
|
35
|
-
const provider = {
|
|
36
|
-
|
|
37
|
-
async hangupCall(input: HangupCallInput): Promise<void> {
|
|
51
|
+
const provider = createProvider({
|
|
52
|
+
hangupCall: async (input: HangupCallInput): Promise<void> => {
|
|
38
53
|
hangupCalls.push(input);
|
|
39
54
|
},
|
|
40
|
-
};
|
|
55
|
+
});
|
|
41
56
|
|
|
42
57
|
const ctx = createContext({
|
|
43
58
|
config: VoiceCallConfigSchema.parse({
|
|
@@ -98,12 +113,11 @@ describe("processEvent (functional)", () => {
|
|
|
98
113
|
|
|
99
114
|
it("calls hangup only once for duplicate events for same rejected call", () => {
|
|
100
115
|
const hangupCalls: HangupCallInput[] = [];
|
|
101
|
-
const provider = {
|
|
102
|
-
|
|
103
|
-
async hangupCall(input: HangupCallInput): Promise<void> {
|
|
116
|
+
const provider = createProvider({
|
|
117
|
+
hangupCall: async (input: HangupCallInput): Promise<void> => {
|
|
104
118
|
hangupCalls.push(input);
|
|
105
119
|
},
|
|
106
|
-
};
|
|
120
|
+
});
|
|
107
121
|
const ctx = createContext({
|
|
108
122
|
config: VoiceCallConfigSchema.parse({
|
|
109
123
|
enabled: true,
|
|
@@ -208,12 +222,11 @@ describe("processEvent (functional)", () => {
|
|
|
208
222
|
});
|
|
209
223
|
|
|
210
224
|
it("when hangup throws, logs and does not throw", () => {
|
|
211
|
-
const provider = {
|
|
212
|
-
|
|
213
|
-
async hangupCall(): Promise<void> {
|
|
225
|
+
const provider = createProvider({
|
|
226
|
+
hangupCall: async (): Promise<void> => {
|
|
214
227
|
throw new Error("provider down");
|
|
215
228
|
},
|
|
216
|
-
};
|
|
229
|
+
});
|
|
217
230
|
const ctx = createContext({
|
|
218
231
|
config: VoiceCallConfigSchema.parse({
|
|
219
232
|
enabled: true,
|
package/src/manager/events.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
+
import { isAllowlistedCaller, normalizePhoneNumber } from "../allowlist.js";
|
|
2
3
|
import type { CallRecord, CallState, NormalizedEvent } from "../types.js";
|
|
3
4
|
import type { CallManagerContext } from "./context.js";
|
|
4
|
-
import { isAllowlistedCaller, normalizePhoneNumber } from "../allowlist.js";
|
|
5
5
|
import { findCall } from "./lookup.js";
|
|
6
6
|
import { endCall } from "./outbound.js";
|
|
7
7
|
import { addTranscriptEntry, transitionState } from "./state.js";
|
package/src/manager/outbound.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import type { CallMode } from "../config.js";
|
|
3
|
-
import type { CallManagerContext } from "./context.js";
|
|
4
3
|
import {
|
|
5
4
|
TerminalStates,
|
|
6
5
|
type CallId,
|
|
@@ -8,6 +7,7 @@ import {
|
|
|
8
7
|
type OutboundCallOptions,
|
|
9
8
|
} from "../types.js";
|
|
10
9
|
import { mapVoiceToPolly } from "../voice-mapping.js";
|
|
10
|
+
import type { CallManagerContext } from "./context.js";
|
|
11
11
|
import { getCallByProviderCallId } from "./lookup.js";
|
|
12
12
|
import { addTranscriptEntry, transitionState } from "./state.js";
|
|
13
13
|
import { persistCallRecord } from "./store.js";
|
|
@@ -36,6 +36,7 @@ type ConversationContext = Pick<
|
|
|
36
36
|
| "provider"
|
|
37
37
|
| "config"
|
|
38
38
|
| "storePath"
|
|
39
|
+
| "activeTurnCalls"
|
|
39
40
|
| "transcriptWaiters"
|
|
40
41
|
| "maxDurationTimers"
|
|
41
42
|
>;
|
|
@@ -158,7 +159,6 @@ export async function speak(
|
|
|
158
159
|
if (TerminalStates.has(call.state)) {
|
|
159
160
|
return { success: false, error: "Call has ended" };
|
|
160
161
|
}
|
|
161
|
-
|
|
162
162
|
try {
|
|
163
163
|
transitionState(call, "speaking");
|
|
164
164
|
persistCallRecord(ctx.storePath, call);
|
|
@@ -242,6 +242,12 @@ export async function continueCall(
|
|
|
242
242
|
if (TerminalStates.has(call.state)) {
|
|
243
243
|
return { success: false, error: "Call has ended" };
|
|
244
244
|
}
|
|
245
|
+
if (ctx.activeTurnCalls.has(callId) || ctx.transcriptWaiters.has(callId)) {
|
|
246
|
+
return { success: false, error: "Already waiting for transcript" };
|
|
247
|
+
}
|
|
248
|
+
ctx.activeTurnCalls.add(callId);
|
|
249
|
+
|
|
250
|
+
const turnStartedAt = Date.now();
|
|
245
251
|
|
|
246
252
|
try {
|
|
247
253
|
await speak(ctx, callId, prompt);
|
|
@@ -249,17 +255,45 @@ export async function continueCall(
|
|
|
249
255
|
transitionState(call, "listening");
|
|
250
256
|
persistCallRecord(ctx.storePath, call);
|
|
251
257
|
|
|
258
|
+
const listenStartedAt = Date.now();
|
|
252
259
|
await ctx.provider.startListening({ callId, providerCallId: call.providerCallId });
|
|
253
260
|
|
|
254
261
|
const transcript = await waitForFinalTranscript(ctx, callId);
|
|
262
|
+
const transcriptReceivedAt = Date.now();
|
|
255
263
|
|
|
256
264
|
// Best-effort: stop listening after final transcript.
|
|
257
265
|
await ctx.provider.stopListening({ callId, providerCallId: call.providerCallId });
|
|
258
266
|
|
|
267
|
+
const lastTurnLatencyMs = transcriptReceivedAt - turnStartedAt;
|
|
268
|
+
const lastTurnListenWaitMs = transcriptReceivedAt - listenStartedAt;
|
|
269
|
+
const turnCount =
|
|
270
|
+
call.metadata && typeof call.metadata.turnCount === "number"
|
|
271
|
+
? call.metadata.turnCount + 1
|
|
272
|
+
: 1;
|
|
273
|
+
|
|
274
|
+
call.metadata = {
|
|
275
|
+
...(call.metadata ?? {}),
|
|
276
|
+
turnCount,
|
|
277
|
+
lastTurnLatencyMs,
|
|
278
|
+
lastTurnListenWaitMs,
|
|
279
|
+
lastTurnCompletedAt: transcriptReceivedAt,
|
|
280
|
+
};
|
|
281
|
+
persistCallRecord(ctx.storePath, call);
|
|
282
|
+
|
|
283
|
+
console.log(
|
|
284
|
+
"[voice-call] continueCall latency call=" +
|
|
285
|
+
call.callId +
|
|
286
|
+
" totalMs=" +
|
|
287
|
+
String(lastTurnLatencyMs) +
|
|
288
|
+
" listenWaitMs=" +
|
|
289
|
+
String(lastTurnListenWaitMs),
|
|
290
|
+
);
|
|
291
|
+
|
|
259
292
|
return { success: true, transcript };
|
|
260
293
|
} catch (err) {
|
|
261
294
|
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
262
295
|
} finally {
|
|
296
|
+
ctx.activeTurnCalls.delete(callId);
|
|
263
297
|
clearTranscriptWaiter(ctx, callId);
|
|
264
298
|
}
|
|
265
299
|
}
|
package/src/manager/timers.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { CallManagerContext } from "./context.js";
|
|
2
1
|
import { TerminalStates, type CallId } from "../types.js";
|
|
2
|
+
import type { CallManagerContext } from "./context.js";
|
|
3
3
|
import { persistCallRecord } from "./store.js";
|
|
4
4
|
|
|
5
5
|
type TimerContext = Pick<
|
|
@@ -87,8 +87,9 @@ export function resolveTranscriptWaiter(
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
export function waitForFinalTranscript(ctx: TimerContext, callId: CallId): Promise<string> {
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
if (ctx.transcriptWaiters.has(callId)) {
|
|
91
|
+
return Promise.reject(new Error("Already waiting for transcript"));
|
|
92
|
+
}
|
|
92
93
|
|
|
93
94
|
const timeoutMs = ctx.config.transcriptTimeoutMs;
|
|
94
95
|
return new Promise((resolve, reject) => {
|