@openclaw/voice-call 2026.2.12 → 2026.2.14
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 +9 -0
- package/package.json +1 -1
- package/src/config.test.ts +15 -2
- package/src/config.ts +6 -7
- package/src/manager/context.ts +19 -1
- package/src/manager/events.test.ts +240 -0
- package/src/manager/events.ts +49 -8
- package/src/manager/outbound.ts +36 -5
- package/src/manager/store.ts +4 -1
- package/src/manager/timers.ts +19 -6
- package/src/manager.test.ts +40 -0
- package/src/manager.ts +48 -728
- package/src/providers/telnyx.test.ts +121 -0
- package/src/providers/telnyx.ts +7 -60
- package/src/runtime.ts +7 -2
- package/src/webhook-security.test.ts +35 -3
- package/src/webhook-security.ts +112 -13
- package/src/webhook.ts +12 -37
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -45,6 +45,14 @@ Put under `plugins.entries.voice-call.config`:
|
|
|
45
45
|
authToken: "your_token",
|
|
46
46
|
},
|
|
47
47
|
|
|
48
|
+
telnyx: {
|
|
49
|
+
apiKey: "KEYxxxx",
|
|
50
|
+
connectionId: "CONNxxxx",
|
|
51
|
+
// Telnyx webhook public key from the Telnyx Mission Control Portal
|
|
52
|
+
// (Base64 string; can also be set via TELNYX_PUBLIC_KEY).
|
|
53
|
+
publicKey: "...",
|
|
54
|
+
},
|
|
55
|
+
|
|
48
56
|
plivo: {
|
|
49
57
|
authId: "MAxxxxxxxxxxxxxxxxxxxx",
|
|
50
58
|
authToken: "your_token",
|
|
@@ -76,6 +84,7 @@ Notes:
|
|
|
76
84
|
|
|
77
85
|
- Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL.
|
|
78
86
|
- `mock` is a local dev provider (no network calls).
|
|
87
|
+
- Telnyx requires `telnyx.publicKey` (or `TELNYX_PUBLIC_KEY`) unless `skipSignatureVerification` is true.
|
|
79
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.
|
|
80
89
|
|
|
81
90
|
## TTS for calls
|
package/package.json
CHANGED
package/src/config.test.ts
CHANGED
|
@@ -47,6 +47,7 @@ describe("validateProviderConfig", () => {
|
|
|
47
47
|
delete process.env.TWILIO_AUTH_TOKEN;
|
|
48
48
|
delete process.env.TELNYX_API_KEY;
|
|
49
49
|
delete process.env.TELNYX_CONNECTION_ID;
|
|
50
|
+
delete process.env.TELNYX_PUBLIC_KEY;
|
|
50
51
|
delete process.env.PLIVO_AUTH_ID;
|
|
51
52
|
delete process.env.PLIVO_AUTH_TOKEN;
|
|
52
53
|
});
|
|
@@ -121,7 +122,7 @@ describe("validateProviderConfig", () => {
|
|
|
121
122
|
describe("telnyx provider", () => {
|
|
122
123
|
it("passes validation when credentials are in config", () => {
|
|
123
124
|
const config = createBaseConfig("telnyx");
|
|
124
|
-
config.telnyx = { apiKey: "KEY123", connectionId: "CONN456" };
|
|
125
|
+
config.telnyx = { apiKey: "KEY123", connectionId: "CONN456", publicKey: "public-key" };
|
|
125
126
|
|
|
126
127
|
const result = validateProviderConfig(config);
|
|
127
128
|
|
|
@@ -132,6 +133,7 @@ describe("validateProviderConfig", () => {
|
|
|
132
133
|
it("passes validation when credentials are in environment variables", () => {
|
|
133
134
|
process.env.TELNYX_API_KEY = "KEY123";
|
|
134
135
|
process.env.TELNYX_CONNECTION_ID = "CONN456";
|
|
136
|
+
process.env.TELNYX_PUBLIC_KEY = "public-key";
|
|
135
137
|
let config = createBaseConfig("telnyx");
|
|
136
138
|
config = resolveVoiceCallConfig(config);
|
|
137
139
|
|
|
@@ -163,7 +165,7 @@ describe("validateProviderConfig", () => {
|
|
|
163
165
|
|
|
164
166
|
expect(result.valid).toBe(false);
|
|
165
167
|
expect(result.errors).toContain(
|
|
166
|
-
"plugins.entries.voice-call.config.telnyx.publicKey is required
|
|
168
|
+
"plugins.entries.voice-call.config.telnyx.publicKey is required (or set TELNYX_PUBLIC_KEY env)",
|
|
167
169
|
);
|
|
168
170
|
});
|
|
169
171
|
|
|
@@ -181,6 +183,17 @@ describe("validateProviderConfig", () => {
|
|
|
181
183
|
expect(result.valid).toBe(true);
|
|
182
184
|
expect(result.errors).toEqual([]);
|
|
183
185
|
});
|
|
186
|
+
|
|
187
|
+
it("passes validation when skipSignatureVerification is true (even without public key)", () => {
|
|
188
|
+
const config = createBaseConfig("telnyx");
|
|
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([]);
|
|
196
|
+
});
|
|
184
197
|
});
|
|
185
198
|
|
|
186
199
|
describe("plivo provider", () => {
|
package/src/config.ts
CHANGED
|
@@ -207,8 +207,10 @@ export const VoiceCallTunnelConfigSchema = z
|
|
|
207
207
|
ngrokDomain: z.string().min(1).optional(),
|
|
208
208
|
/**
|
|
209
209
|
* Allow ngrok free tier compatibility mode.
|
|
210
|
-
* When true,
|
|
211
|
-
*
|
|
210
|
+
* When true, forwarded headers may be trusted for loopback requests
|
|
211
|
+
* to reconstruct the public ngrok URL used for signing.
|
|
212
|
+
*
|
|
213
|
+
* IMPORTANT: This does NOT bypass signature verification.
|
|
212
214
|
*/
|
|
213
215
|
allowNgrokFreeTierLoopbackBypass: z.boolean().default(false),
|
|
214
216
|
})
|
|
@@ -483,12 +485,9 @@ export function validateProviderConfig(config: VoiceCallConfig): {
|
|
|
483
485
|
"plugins.entries.voice-call.config.telnyx.connectionId is required (or set TELNYX_CONNECTION_ID env)",
|
|
484
486
|
);
|
|
485
487
|
}
|
|
486
|
-
if (
|
|
487
|
-
(config.inboundPolicy === "allowlist" || config.inboundPolicy === "pairing") &&
|
|
488
|
-
!config.telnyx?.publicKey
|
|
489
|
-
) {
|
|
488
|
+
if (!config.skipSignatureVerification && !config.telnyx?.publicKey) {
|
|
490
489
|
errors.push(
|
|
491
|
-
"plugins.entries.voice-call.config.telnyx.publicKey is required
|
|
490
|
+
"plugins.entries.voice-call.config.telnyx.publicKey is required (or set TELNYX_PUBLIC_KEY env)",
|
|
492
491
|
);
|
|
493
492
|
}
|
|
494
493
|
}
|
package/src/manager/context.ts
CHANGED
|
@@ -8,14 +8,32 @@ export type TranscriptWaiter = {
|
|
|
8
8
|
timeout: NodeJS.Timeout;
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
-
export type
|
|
11
|
+
export type CallManagerRuntimeState = {
|
|
12
12
|
activeCalls: Map<CallId, CallRecord>;
|
|
13
13
|
providerCallIdMap: Map<string, CallId>;
|
|
14
14
|
processedEventIds: Set<string>;
|
|
15
|
+
/** Provider call IDs we already sent a reject hangup for; avoids duplicate hangup calls. */
|
|
16
|
+
rejectedProviderCallIds: Set<string>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type CallManagerRuntimeDeps = {
|
|
15
20
|
provider: VoiceCallProvider | null;
|
|
16
21
|
config: VoiceCallConfig;
|
|
17
22
|
storePath: string;
|
|
18
23
|
webhookUrl: string | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type CallManagerTransientState = {
|
|
19
27
|
transcriptWaiters: Map<CallId, TranscriptWaiter>;
|
|
20
28
|
maxDurationTimers: Map<CallId, NodeJS.Timeout>;
|
|
21
29
|
};
|
|
30
|
+
|
|
31
|
+
export type CallManagerHooks = {
|
|
32
|
+
/** Optional runtime hook invoked after an event transitions a call into answered state. */
|
|
33
|
+
onCallAnswered?: (call: CallRecord) => void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type CallManagerContext = CallManagerRuntimeState &
|
|
37
|
+
CallManagerRuntimeDeps &
|
|
38
|
+
CallManagerTransientState &
|
|
39
|
+
CallManagerHooks;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import type { HangupCallInput, NormalizedEvent } from "../types.js";
|
|
6
|
+
import type { CallManagerContext } from "./context.js";
|
|
7
|
+
import { VoiceCallConfigSchema } from "../config.js";
|
|
8
|
+
import { processEvent } from "./events.js";
|
|
9
|
+
|
|
10
|
+
function createContext(overrides: Partial<CallManagerContext> = {}): CallManagerContext {
|
|
11
|
+
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-events-test-${Date.now()}`);
|
|
12
|
+
fs.mkdirSync(storePath, { recursive: true });
|
|
13
|
+
return {
|
|
14
|
+
activeCalls: new Map(),
|
|
15
|
+
providerCallIdMap: new Map(),
|
|
16
|
+
processedEventIds: new Set(),
|
|
17
|
+
rejectedProviderCallIds: new Set(),
|
|
18
|
+
provider: null,
|
|
19
|
+
config: VoiceCallConfigSchema.parse({
|
|
20
|
+
enabled: true,
|
|
21
|
+
provider: "plivo",
|
|
22
|
+
fromNumber: "+15550000000",
|
|
23
|
+
}),
|
|
24
|
+
storePath,
|
|
25
|
+
webhookUrl: null,
|
|
26
|
+
transcriptWaiters: new Map(),
|
|
27
|
+
maxDurationTimers: new Map(),
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("processEvent (functional)", () => {
|
|
33
|
+
it("calls provider hangup when rejecting inbound call", () => {
|
|
34
|
+
const hangupCalls: HangupCallInput[] = [];
|
|
35
|
+
const provider = {
|
|
36
|
+
name: "plivo" as const,
|
|
37
|
+
async hangupCall(input: HangupCallInput): Promise<void> {
|
|
38
|
+
hangupCalls.push(input);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const ctx = createContext({
|
|
43
|
+
config: VoiceCallConfigSchema.parse({
|
|
44
|
+
enabled: true,
|
|
45
|
+
provider: "plivo",
|
|
46
|
+
fromNumber: "+15550000000",
|
|
47
|
+
inboundPolicy: "disabled",
|
|
48
|
+
}),
|
|
49
|
+
provider,
|
|
50
|
+
});
|
|
51
|
+
const event: NormalizedEvent = {
|
|
52
|
+
id: "evt-1",
|
|
53
|
+
type: "call.initiated",
|
|
54
|
+
callId: "prov-1",
|
|
55
|
+
providerCallId: "prov-1",
|
|
56
|
+
timestamp: Date.now(),
|
|
57
|
+
direction: "inbound",
|
|
58
|
+
from: "+15559999999",
|
|
59
|
+
to: "+15550000000",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
processEvent(ctx, event);
|
|
63
|
+
|
|
64
|
+
expect(ctx.activeCalls.size).toBe(0);
|
|
65
|
+
expect(hangupCalls).toHaveLength(1);
|
|
66
|
+
expect(hangupCalls[0]).toEqual({
|
|
67
|
+
callId: "prov-1",
|
|
68
|
+
providerCallId: "prov-1",
|
|
69
|
+
reason: "hangup-bot",
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("does not call hangup when provider is null", () => {
|
|
74
|
+
const ctx = createContext({
|
|
75
|
+
config: VoiceCallConfigSchema.parse({
|
|
76
|
+
enabled: true,
|
|
77
|
+
provider: "plivo",
|
|
78
|
+
fromNumber: "+15550000000",
|
|
79
|
+
inboundPolicy: "disabled",
|
|
80
|
+
}),
|
|
81
|
+
provider: null,
|
|
82
|
+
});
|
|
83
|
+
const event: NormalizedEvent = {
|
|
84
|
+
id: "evt-2",
|
|
85
|
+
type: "call.initiated",
|
|
86
|
+
callId: "prov-2",
|
|
87
|
+
providerCallId: "prov-2",
|
|
88
|
+
timestamp: Date.now(),
|
|
89
|
+
direction: "inbound",
|
|
90
|
+
from: "+15551111111",
|
|
91
|
+
to: "+15550000000",
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
processEvent(ctx, event);
|
|
95
|
+
|
|
96
|
+
expect(ctx.activeCalls.size).toBe(0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("calls hangup only once for duplicate events for same rejected call", () => {
|
|
100
|
+
const hangupCalls: HangupCallInput[] = [];
|
|
101
|
+
const provider = {
|
|
102
|
+
name: "plivo" as const,
|
|
103
|
+
async hangupCall(input: HangupCallInput): Promise<void> {
|
|
104
|
+
hangupCalls.push(input);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
const ctx = createContext({
|
|
108
|
+
config: VoiceCallConfigSchema.parse({
|
|
109
|
+
enabled: true,
|
|
110
|
+
provider: "plivo",
|
|
111
|
+
fromNumber: "+15550000000",
|
|
112
|
+
inboundPolicy: "disabled",
|
|
113
|
+
}),
|
|
114
|
+
provider,
|
|
115
|
+
});
|
|
116
|
+
const event1: NormalizedEvent = {
|
|
117
|
+
id: "evt-init",
|
|
118
|
+
type: "call.initiated",
|
|
119
|
+
callId: "prov-dup",
|
|
120
|
+
providerCallId: "prov-dup",
|
|
121
|
+
timestamp: Date.now(),
|
|
122
|
+
direction: "inbound",
|
|
123
|
+
from: "+15552222222",
|
|
124
|
+
to: "+15550000000",
|
|
125
|
+
};
|
|
126
|
+
const event2: NormalizedEvent = {
|
|
127
|
+
id: "evt-ring",
|
|
128
|
+
type: "call.ringing",
|
|
129
|
+
callId: "prov-dup",
|
|
130
|
+
providerCallId: "prov-dup",
|
|
131
|
+
timestamp: Date.now(),
|
|
132
|
+
direction: "inbound",
|
|
133
|
+
from: "+15552222222",
|
|
134
|
+
to: "+15550000000",
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
processEvent(ctx, event1);
|
|
138
|
+
processEvent(ctx, event2);
|
|
139
|
+
|
|
140
|
+
expect(ctx.activeCalls.size).toBe(0);
|
|
141
|
+
expect(hangupCalls).toHaveLength(1);
|
|
142
|
+
expect(hangupCalls[0]?.providerCallId).toBe("prov-dup");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("updates providerCallId map when provider ID changes", () => {
|
|
146
|
+
const now = Date.now();
|
|
147
|
+
const ctx = createContext();
|
|
148
|
+
ctx.activeCalls.set("call-1", {
|
|
149
|
+
callId: "call-1",
|
|
150
|
+
providerCallId: "request-uuid",
|
|
151
|
+
provider: "plivo",
|
|
152
|
+
direction: "outbound",
|
|
153
|
+
state: "initiated",
|
|
154
|
+
from: "+15550000000",
|
|
155
|
+
to: "+15550000001",
|
|
156
|
+
startedAt: now,
|
|
157
|
+
transcript: [],
|
|
158
|
+
processedEventIds: [],
|
|
159
|
+
metadata: {},
|
|
160
|
+
});
|
|
161
|
+
ctx.providerCallIdMap.set("request-uuid", "call-1");
|
|
162
|
+
|
|
163
|
+
processEvent(ctx, {
|
|
164
|
+
id: "evt-provider-id-change",
|
|
165
|
+
type: "call.answered",
|
|
166
|
+
callId: "call-1",
|
|
167
|
+
providerCallId: "call-uuid",
|
|
168
|
+
timestamp: now + 1,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(ctx.activeCalls.get("call-1")?.providerCallId).toBe("call-uuid");
|
|
172
|
+
expect(ctx.providerCallIdMap.get("call-uuid")).toBe("call-1");
|
|
173
|
+
expect(ctx.providerCallIdMap.has("request-uuid")).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("invokes onCallAnswered hook for answered events", () => {
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
let answeredCallId: string | null = null;
|
|
179
|
+
const ctx = createContext({
|
|
180
|
+
onCallAnswered: (call) => {
|
|
181
|
+
answeredCallId = call.callId;
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
ctx.activeCalls.set("call-2", {
|
|
185
|
+
callId: "call-2",
|
|
186
|
+
providerCallId: "call-2-provider",
|
|
187
|
+
provider: "plivo",
|
|
188
|
+
direction: "inbound",
|
|
189
|
+
state: "ringing",
|
|
190
|
+
from: "+15550000002",
|
|
191
|
+
to: "+15550000000",
|
|
192
|
+
startedAt: now,
|
|
193
|
+
transcript: [],
|
|
194
|
+
processedEventIds: [],
|
|
195
|
+
metadata: {},
|
|
196
|
+
});
|
|
197
|
+
ctx.providerCallIdMap.set("call-2-provider", "call-2");
|
|
198
|
+
|
|
199
|
+
processEvent(ctx, {
|
|
200
|
+
id: "evt-answered-hook",
|
|
201
|
+
type: "call.answered",
|
|
202
|
+
callId: "call-2",
|
|
203
|
+
providerCallId: "call-2-provider",
|
|
204
|
+
timestamp: now + 1,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(answeredCallId).toBe("call-2");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("when hangup throws, logs and does not throw", () => {
|
|
211
|
+
const provider = {
|
|
212
|
+
name: "plivo" as const,
|
|
213
|
+
async hangupCall(): Promise<void> {
|
|
214
|
+
throw new Error("provider down");
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
const ctx = createContext({
|
|
218
|
+
config: VoiceCallConfigSchema.parse({
|
|
219
|
+
enabled: true,
|
|
220
|
+
provider: "plivo",
|
|
221
|
+
fromNumber: "+15550000000",
|
|
222
|
+
inboundPolicy: "disabled",
|
|
223
|
+
}),
|
|
224
|
+
provider,
|
|
225
|
+
});
|
|
226
|
+
const event: NormalizedEvent = {
|
|
227
|
+
id: "evt-fail",
|
|
228
|
+
type: "call.initiated",
|
|
229
|
+
callId: "prov-fail",
|
|
230
|
+
providerCallId: "prov-fail",
|
|
231
|
+
timestamp: Date.now(),
|
|
232
|
+
direction: "inbound",
|
|
233
|
+
from: "+15553333333",
|
|
234
|
+
to: "+15550000000",
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
expect(() => processEvent(ctx, event)).not.toThrow();
|
|
238
|
+
expect(ctx.activeCalls.size).toBe(0);
|
|
239
|
+
});
|
|
240
|
+
});
|
package/src/manager/events.ts
CHANGED
|
@@ -13,10 +13,21 @@ import {
|
|
|
13
13
|
startMaxDurationTimer,
|
|
14
14
|
} from "./timers.js";
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
type EventContext = Pick<
|
|
17
|
+
CallManagerContext,
|
|
18
|
+
| "activeCalls"
|
|
19
|
+
| "providerCallIdMap"
|
|
20
|
+
| "processedEventIds"
|
|
21
|
+
| "rejectedProviderCallIds"
|
|
22
|
+
| "provider"
|
|
23
|
+
| "config"
|
|
24
|
+
| "storePath"
|
|
25
|
+
| "transcriptWaiters"
|
|
26
|
+
| "maxDurationTimers"
|
|
27
|
+
| "onCallAnswered"
|
|
28
|
+
>;
|
|
29
|
+
|
|
30
|
+
function shouldAcceptInbound(config: EventContext["config"], from: string | undefined): boolean {
|
|
20
31
|
const { inboundPolicy: policy, allowFrom } = config;
|
|
21
32
|
|
|
22
33
|
switch (policy) {
|
|
@@ -49,7 +60,7 @@ function shouldAcceptInbound(
|
|
|
49
60
|
}
|
|
50
61
|
|
|
51
62
|
function createInboundCall(params: {
|
|
52
|
-
ctx:
|
|
63
|
+
ctx: EventContext;
|
|
53
64
|
providerCallId: string;
|
|
54
65
|
from: string;
|
|
55
66
|
to: string;
|
|
@@ -80,7 +91,7 @@ function createInboundCall(params: {
|
|
|
80
91
|
return callRecord;
|
|
81
92
|
}
|
|
82
93
|
|
|
83
|
-
export function processEvent(ctx:
|
|
94
|
+
export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
|
|
84
95
|
if (ctx.processedEventIds.has(event.id)) {
|
|
85
96
|
return;
|
|
86
97
|
}
|
|
@@ -94,7 +105,29 @@ export function processEvent(ctx: CallManagerContext, event: NormalizedEvent): v
|
|
|
94
105
|
|
|
95
106
|
if (!call && event.direction === "inbound" && event.providerCallId) {
|
|
96
107
|
if (!shouldAcceptInbound(ctx.config, event.from)) {
|
|
97
|
-
|
|
108
|
+
const pid = event.providerCallId;
|
|
109
|
+
if (!ctx.provider) {
|
|
110
|
+
console.warn(
|
|
111
|
+
`[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.`,
|
|
112
|
+
);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (ctx.rejectedProviderCallIds.has(pid)) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
ctx.rejectedProviderCallIds.add(pid);
|
|
119
|
+
const callId = event.callId ?? pid;
|
|
120
|
+
console.log(`[voice-call] Rejecting inbound call by policy: ${pid}`);
|
|
121
|
+
void ctx.provider
|
|
122
|
+
.hangupCall({
|
|
123
|
+
callId,
|
|
124
|
+
providerCallId: pid,
|
|
125
|
+
reason: "hangup-bot",
|
|
126
|
+
})
|
|
127
|
+
.catch((err) => {
|
|
128
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
129
|
+
console.warn(`[voice-call] Failed to reject inbound call ${pid}:`, message);
|
|
130
|
+
});
|
|
98
131
|
return;
|
|
99
132
|
}
|
|
100
133
|
|
|
@@ -113,9 +146,16 @@ export function processEvent(ctx: CallManagerContext, event: NormalizedEvent): v
|
|
|
113
146
|
return;
|
|
114
147
|
}
|
|
115
148
|
|
|
116
|
-
if (event.providerCallId &&
|
|
149
|
+
if (event.providerCallId && event.providerCallId !== call.providerCallId) {
|
|
150
|
+
const previousProviderCallId = call.providerCallId;
|
|
117
151
|
call.providerCallId = event.providerCallId;
|
|
118
152
|
ctx.providerCallIdMap.set(event.providerCallId, call.callId);
|
|
153
|
+
if (previousProviderCallId) {
|
|
154
|
+
const mapped = ctx.providerCallIdMap.get(previousProviderCallId);
|
|
155
|
+
if (mapped === call.callId) {
|
|
156
|
+
ctx.providerCallIdMap.delete(previousProviderCallId);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
119
159
|
}
|
|
120
160
|
|
|
121
161
|
call.processedEventIds.push(event.id);
|
|
@@ -139,6 +179,7 @@ export function processEvent(ctx: CallManagerContext, event: NormalizedEvent): v
|
|
|
139
179
|
await endCall(ctx, callId);
|
|
140
180
|
},
|
|
141
181
|
});
|
|
182
|
+
ctx.onCallAnswered?.(call);
|
|
142
183
|
break;
|
|
143
184
|
|
|
144
185
|
case "call.active":
|
package/src/manager/outbound.ts
CHANGED
|
@@ -19,8 +19,39 @@ import {
|
|
|
19
19
|
} from "./timers.js";
|
|
20
20
|
import { generateNotifyTwiml } from "./twiml.js";
|
|
21
21
|
|
|
22
|
+
type InitiateContext = Pick<
|
|
23
|
+
CallManagerContext,
|
|
24
|
+
"activeCalls" | "providerCallIdMap" | "provider" | "config" | "storePath" | "webhookUrl"
|
|
25
|
+
>;
|
|
26
|
+
|
|
27
|
+
type SpeakContext = Pick<
|
|
28
|
+
CallManagerContext,
|
|
29
|
+
"activeCalls" | "providerCallIdMap" | "provider" | "config" | "storePath"
|
|
30
|
+
>;
|
|
31
|
+
|
|
32
|
+
type ConversationContext = Pick<
|
|
33
|
+
CallManagerContext,
|
|
34
|
+
| "activeCalls"
|
|
35
|
+
| "providerCallIdMap"
|
|
36
|
+
| "provider"
|
|
37
|
+
| "config"
|
|
38
|
+
| "storePath"
|
|
39
|
+
| "transcriptWaiters"
|
|
40
|
+
| "maxDurationTimers"
|
|
41
|
+
>;
|
|
42
|
+
|
|
43
|
+
type EndCallContext = Pick<
|
|
44
|
+
CallManagerContext,
|
|
45
|
+
| "activeCalls"
|
|
46
|
+
| "providerCallIdMap"
|
|
47
|
+
| "provider"
|
|
48
|
+
| "storePath"
|
|
49
|
+
| "transcriptWaiters"
|
|
50
|
+
| "maxDurationTimers"
|
|
51
|
+
>;
|
|
52
|
+
|
|
22
53
|
export async function initiateCall(
|
|
23
|
-
ctx:
|
|
54
|
+
ctx: InitiateContext,
|
|
24
55
|
to: string,
|
|
25
56
|
sessionKey?: string,
|
|
26
57
|
options?: OutboundCallOptions | string,
|
|
@@ -113,7 +144,7 @@ export async function initiateCall(
|
|
|
113
144
|
}
|
|
114
145
|
|
|
115
146
|
export async function speak(
|
|
116
|
-
ctx:
|
|
147
|
+
ctx: SpeakContext,
|
|
117
148
|
callId: CallId,
|
|
118
149
|
text: string,
|
|
119
150
|
): Promise<{ success: boolean; error?: string }> {
|
|
@@ -149,7 +180,7 @@ export async function speak(
|
|
|
149
180
|
}
|
|
150
181
|
|
|
151
182
|
export async function speakInitialMessage(
|
|
152
|
-
ctx:
|
|
183
|
+
ctx: ConversationContext,
|
|
153
184
|
providerCallId: string,
|
|
154
185
|
): Promise<void> {
|
|
155
186
|
const call = getCallByProviderCallId({
|
|
@@ -197,7 +228,7 @@ export async function speakInitialMessage(
|
|
|
197
228
|
}
|
|
198
229
|
|
|
199
230
|
export async function continueCall(
|
|
200
|
-
ctx:
|
|
231
|
+
ctx: ConversationContext,
|
|
201
232
|
callId: CallId,
|
|
202
233
|
prompt: string,
|
|
203
234
|
): Promise<{ success: boolean; transcript?: string; error?: string }> {
|
|
@@ -234,7 +265,7 @@ export async function continueCall(
|
|
|
234
265
|
}
|
|
235
266
|
|
|
236
267
|
export async function endCall(
|
|
237
|
-
ctx:
|
|
268
|
+
ctx: EndCallContext,
|
|
238
269
|
callId: CallId,
|
|
239
270
|
): Promise<{ success: boolean; error?: string }> {
|
|
240
271
|
const call = ctx.activeCalls.get(callId);
|
package/src/manager/store.ts
CHANGED
|
@@ -16,6 +16,7 @@ export function loadActiveCallsFromStore(storePath: string): {
|
|
|
16
16
|
activeCalls: Map<CallId, CallRecord>;
|
|
17
17
|
providerCallIdMap: Map<string, CallId>;
|
|
18
18
|
processedEventIds: Set<string>;
|
|
19
|
+
rejectedProviderCallIds: Set<string>;
|
|
19
20
|
} {
|
|
20
21
|
const logPath = path.join(storePath, "calls.jsonl");
|
|
21
22
|
if (!fs.existsSync(logPath)) {
|
|
@@ -23,6 +24,7 @@ export function loadActiveCallsFromStore(storePath: string): {
|
|
|
23
24
|
activeCalls: new Map(),
|
|
24
25
|
providerCallIdMap: new Map(),
|
|
25
26
|
processedEventIds: new Set(),
|
|
27
|
+
rejectedProviderCallIds: new Set(),
|
|
26
28
|
};
|
|
27
29
|
}
|
|
28
30
|
|
|
@@ -45,6 +47,7 @@ export function loadActiveCallsFromStore(storePath: string): {
|
|
|
45
47
|
const activeCalls = new Map<CallId, CallRecord>();
|
|
46
48
|
const providerCallIdMap = new Map<string, CallId>();
|
|
47
49
|
const processedEventIds = new Set<string>();
|
|
50
|
+
const rejectedProviderCallIds = new Set<string>();
|
|
48
51
|
|
|
49
52
|
for (const [callId, call] of callMap) {
|
|
50
53
|
if (TerminalStates.has(call.state)) {
|
|
@@ -59,7 +62,7 @@ export function loadActiveCallsFromStore(storePath: string): {
|
|
|
59
62
|
}
|
|
60
63
|
}
|
|
61
64
|
|
|
62
|
-
return { activeCalls, providerCallIdMap, processedEventIds };
|
|
65
|
+
return { activeCalls, providerCallIdMap, processedEventIds, rejectedProviderCallIds };
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
export async function getCallHistoryFromStore(
|
package/src/manager/timers.ts
CHANGED
|
@@ -2,7 +2,20 @@ import type { CallManagerContext } from "./context.js";
|
|
|
2
2
|
import { TerminalStates, type CallId } from "../types.js";
|
|
3
3
|
import { persistCallRecord } from "./store.js";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
type TimerContext = Pick<
|
|
6
|
+
CallManagerContext,
|
|
7
|
+
"activeCalls" | "maxDurationTimers" | "config" | "storePath" | "transcriptWaiters"
|
|
8
|
+
>;
|
|
9
|
+
type MaxDurationTimerContext = Pick<
|
|
10
|
+
TimerContext,
|
|
11
|
+
"activeCalls" | "maxDurationTimers" | "config" | "storePath"
|
|
12
|
+
>;
|
|
13
|
+
type TranscriptWaiterContext = Pick<TimerContext, "transcriptWaiters">;
|
|
14
|
+
|
|
15
|
+
export function clearMaxDurationTimer(
|
|
16
|
+
ctx: Pick<MaxDurationTimerContext, "maxDurationTimers">,
|
|
17
|
+
callId: CallId,
|
|
18
|
+
): void {
|
|
6
19
|
const timer = ctx.maxDurationTimers.get(callId);
|
|
7
20
|
if (timer) {
|
|
8
21
|
clearTimeout(timer);
|
|
@@ -11,7 +24,7 @@ export function clearMaxDurationTimer(ctx: CallManagerContext, callId: CallId):
|
|
|
11
24
|
}
|
|
12
25
|
|
|
13
26
|
export function startMaxDurationTimer(params: {
|
|
14
|
-
ctx:
|
|
27
|
+
ctx: MaxDurationTimerContext;
|
|
15
28
|
callId: CallId;
|
|
16
29
|
onTimeout: (callId: CallId) => Promise<void>;
|
|
17
30
|
}): void {
|
|
@@ -38,7 +51,7 @@ export function startMaxDurationTimer(params: {
|
|
|
38
51
|
params.ctx.maxDurationTimers.set(params.callId, timer);
|
|
39
52
|
}
|
|
40
53
|
|
|
41
|
-
export function clearTranscriptWaiter(ctx:
|
|
54
|
+
export function clearTranscriptWaiter(ctx: TranscriptWaiterContext, callId: CallId): void {
|
|
42
55
|
const waiter = ctx.transcriptWaiters.get(callId);
|
|
43
56
|
if (!waiter) {
|
|
44
57
|
return;
|
|
@@ -48,7 +61,7 @@ export function clearTranscriptWaiter(ctx: CallManagerContext, callId: CallId):
|
|
|
48
61
|
}
|
|
49
62
|
|
|
50
63
|
export function rejectTranscriptWaiter(
|
|
51
|
-
ctx:
|
|
64
|
+
ctx: TranscriptWaiterContext,
|
|
52
65
|
callId: CallId,
|
|
53
66
|
reason: string,
|
|
54
67
|
): void {
|
|
@@ -61,7 +74,7 @@ export function rejectTranscriptWaiter(
|
|
|
61
74
|
}
|
|
62
75
|
|
|
63
76
|
export function resolveTranscriptWaiter(
|
|
64
|
-
ctx:
|
|
77
|
+
ctx: TranscriptWaiterContext,
|
|
65
78
|
callId: CallId,
|
|
66
79
|
transcript: string,
|
|
67
80
|
): void {
|
|
@@ -73,7 +86,7 @@ export function resolveTranscriptWaiter(
|
|
|
73
86
|
waiter.resolve(transcript);
|
|
74
87
|
}
|
|
75
88
|
|
|
76
|
-
export function waitForFinalTranscript(ctx:
|
|
89
|
+
export function waitForFinalTranscript(ctx: TimerContext, callId: CallId): Promise<string> {
|
|
77
90
|
// Only allow one in-flight waiter per call.
|
|
78
91
|
rejectTranscriptWaiter(ctx, callId, "Transcript waiter replaced");
|
|
79
92
|
|