@openclaw/voice-call 2026.2.13 → 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 +6 -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 +17 -3
- package/src/manager/events.ts +17 -6
- package/src/manager/outbound.ts +36 -5
- package/src/manager/timers.ts +19 -6
- package/src/manager.ts +31 -437
- 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/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,18 +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
15
|
/** Provider call IDs we already sent a reject hangup for; avoids duplicate hangup calls. */
|
|
16
16
|
rejectedProviderCallIds: Set<string>;
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type CallManagerRuntimeDeps = {
|
|
19
20
|
provider: VoiceCallProvider | null;
|
|
20
21
|
config: VoiceCallConfig;
|
|
21
22
|
storePath: string;
|
|
22
23
|
webhookUrl: string | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type CallManagerTransientState = {
|
|
23
27
|
transcriptWaiters: Map<CallId, TranscriptWaiter>;
|
|
24
28
|
maxDurationTimers: Map<CallId, NodeJS.Timeout>;
|
|
25
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;
|
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
|
}
|
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/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
|
|
package/src/manager.ts
CHANGED
|
@@ -1,23 +1,21 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
1
|
import fs from "node:fs";
|
|
3
|
-
import fsp from "node:fs/promises";
|
|
4
2
|
import os from "node:os";
|
|
5
3
|
import path from "node:path";
|
|
6
|
-
import type {
|
|
4
|
+
import type { VoiceCallConfig } from "./config.js";
|
|
7
5
|
import type { CallManagerContext } from "./manager/context.js";
|
|
8
6
|
import type { VoiceCallProvider } from "./providers/base.js";
|
|
7
|
+
import type { CallId, CallRecord, NormalizedEvent, OutboundCallOptions } from "./types.js";
|
|
9
8
|
import { processEvent as processManagerEvent } from "./manager/events.js";
|
|
9
|
+
import { getCallByProviderCallId as getCallByProviderCallIdFromMaps } from "./manager/lookup.js";
|
|
10
10
|
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
} from "./types.js";
|
|
11
|
+
continueCall as continueCallWithContext,
|
|
12
|
+
endCall as endCallWithContext,
|
|
13
|
+
initiateCall as initiateCallWithContext,
|
|
14
|
+
speak as speakWithContext,
|
|
15
|
+
speakInitialMessage as speakInitialMessageWithContext,
|
|
16
|
+
} from "./manager/outbound.js";
|
|
17
|
+
import { getCallHistoryFromStore, loadActiveCallsFromStore } from "./manager/store.js";
|
|
19
18
|
import { resolveUserPath } from "./utils.js";
|
|
20
|
-
import { escapeXml, mapVoiceToPolly } from "./voice-mapping.js";
|
|
21
19
|
|
|
22
20
|
function resolveDefaultStoreBase(config: VoiceCallConfig, storePath?: string): string {
|
|
23
21
|
const rawOverride = storePath?.trim() || config.store?.trim();
|
|
@@ -38,11 +36,11 @@ function resolveDefaultStoreBase(config: VoiceCallConfig, storePath?: string): s
|
|
|
38
36
|
}
|
|
39
37
|
|
|
40
38
|
/**
|
|
41
|
-
* Manages voice calls: state
|
|
39
|
+
* Manages voice calls: state ownership and delegation to manager helper modules.
|
|
42
40
|
*/
|
|
43
41
|
export class CallManager {
|
|
44
42
|
private activeCalls = new Map<CallId, CallRecord>();
|
|
45
|
-
private providerCallIdMap = new Map<string, CallId>();
|
|
43
|
+
private providerCallIdMap = new Map<string, CallId>();
|
|
46
44
|
private processedEventIds = new Set<string>();
|
|
47
45
|
private rejectedProviderCallIds = new Set<string>();
|
|
48
46
|
private provider: VoiceCallProvider | null = null;
|
|
@@ -57,12 +55,10 @@ export class CallManager {
|
|
|
57
55
|
timeout: NodeJS.Timeout;
|
|
58
56
|
}
|
|
59
57
|
>();
|
|
60
|
-
/** Max duration timers to auto-hangup calls after configured timeout */
|
|
61
58
|
private maxDurationTimers = new Map<CallId, NodeJS.Timeout>();
|
|
62
59
|
|
|
63
60
|
constructor(config: VoiceCallConfig, storePath?: string) {
|
|
64
61
|
this.config = config;
|
|
65
|
-
// Resolve store path with tilde expansion (like other config values)
|
|
66
62
|
this.storePath = resolveDefaultStoreBase(config, storePath);
|
|
67
63
|
}
|
|
68
64
|
|
|
@@ -73,11 +69,13 @@ export class CallManager {
|
|
|
73
69
|
this.provider = provider;
|
|
74
70
|
this.webhookUrl = webhookUrl;
|
|
75
71
|
|
|
76
|
-
// Ensure store directory exists
|
|
77
72
|
fs.mkdirSync(this.storePath, { recursive: true });
|
|
78
73
|
|
|
79
|
-
|
|
80
|
-
this.
|
|
74
|
+
const persisted = loadActiveCallsFromStore(this.storePath);
|
|
75
|
+
this.activeCalls = persisted.activeCalls;
|
|
76
|
+
this.providerCallIdMap = persisted.providerCallIdMap;
|
|
77
|
+
this.processedEventIds = persisted.processedEventIds;
|
|
78
|
+
this.rejectedProviderCallIds = persisted.rejectedProviderCallIds;
|
|
81
79
|
}
|
|
82
80
|
|
|
83
81
|
/**
|
|
@@ -89,242 +87,27 @@ export class CallManager {
|
|
|
89
87
|
|
|
90
88
|
/**
|
|
91
89
|
* Initiate an outbound call.
|
|
92
|
-
* @param to - The phone number to call
|
|
93
|
-
* @param sessionKey - Optional session key for context
|
|
94
|
-
* @param options - Optional call options (message, mode)
|
|
95
90
|
*/
|
|
96
91
|
async initiateCall(
|
|
97
92
|
to: string,
|
|
98
93
|
sessionKey?: string,
|
|
99
94
|
options?: OutboundCallOptions | string,
|
|
100
95
|
): Promise<{ callId: CallId; success: boolean; error?: string }> {
|
|
101
|
-
|
|
102
|
-
const opts: OutboundCallOptions =
|
|
103
|
-
typeof options === "string" ? { message: options } : (options ?? {});
|
|
104
|
-
const initialMessage = opts.message;
|
|
105
|
-
const mode = opts.mode ?? this.config.outbound.defaultMode;
|
|
106
|
-
if (!this.provider) {
|
|
107
|
-
return { callId: "", success: false, error: "Provider not initialized" };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (!this.webhookUrl) {
|
|
111
|
-
return {
|
|
112
|
-
callId: "",
|
|
113
|
-
success: false,
|
|
114
|
-
error: "Webhook URL not configured",
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Check concurrent call limit
|
|
119
|
-
const activeCalls = this.getActiveCalls();
|
|
120
|
-
if (activeCalls.length >= this.config.maxConcurrentCalls) {
|
|
121
|
-
return {
|
|
122
|
-
callId: "",
|
|
123
|
-
success: false,
|
|
124
|
-
error: `Maximum concurrent calls (${this.config.maxConcurrentCalls}) reached`,
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const callId = crypto.randomUUID();
|
|
129
|
-
const from =
|
|
130
|
-
this.config.fromNumber || (this.provider?.name === "mock" ? "+15550000000" : undefined);
|
|
131
|
-
if (!from) {
|
|
132
|
-
return { callId: "", success: false, error: "fromNumber not configured" };
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Create call record with mode in metadata
|
|
136
|
-
const callRecord: CallRecord = {
|
|
137
|
-
callId,
|
|
138
|
-
provider: this.provider.name,
|
|
139
|
-
direction: "outbound",
|
|
140
|
-
state: "initiated",
|
|
141
|
-
from,
|
|
142
|
-
to,
|
|
143
|
-
sessionKey,
|
|
144
|
-
startedAt: Date.now(),
|
|
145
|
-
transcript: [],
|
|
146
|
-
processedEventIds: [],
|
|
147
|
-
metadata: {
|
|
148
|
-
...(initialMessage && { initialMessage }),
|
|
149
|
-
mode,
|
|
150
|
-
},
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
this.activeCalls.set(callId, callRecord);
|
|
154
|
-
this.persistCallRecord(callRecord);
|
|
155
|
-
|
|
156
|
-
try {
|
|
157
|
-
// For notify mode with a message, use inline TwiML with <Say>
|
|
158
|
-
let inlineTwiml: string | undefined;
|
|
159
|
-
if (mode === "notify" && initialMessage) {
|
|
160
|
-
const pollyVoice = mapVoiceToPolly(this.config.tts?.openai?.voice);
|
|
161
|
-
inlineTwiml = this.generateNotifyTwiml(initialMessage, pollyVoice);
|
|
162
|
-
console.log(`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const result = await this.provider.initiateCall({
|
|
166
|
-
callId,
|
|
167
|
-
from,
|
|
168
|
-
to,
|
|
169
|
-
webhookUrl: this.webhookUrl,
|
|
170
|
-
inlineTwiml,
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
callRecord.providerCallId = result.providerCallId;
|
|
174
|
-
this.providerCallIdMap.set(result.providerCallId, callId); // Map providerCallId to internal callId
|
|
175
|
-
this.persistCallRecord(callRecord);
|
|
176
|
-
|
|
177
|
-
return { callId, success: true };
|
|
178
|
-
} catch (err) {
|
|
179
|
-
callRecord.state = "failed";
|
|
180
|
-
callRecord.endedAt = Date.now();
|
|
181
|
-
callRecord.endReason = "failed";
|
|
182
|
-
this.persistCallRecord(callRecord);
|
|
183
|
-
this.activeCalls.delete(callId);
|
|
184
|
-
if (callRecord.providerCallId) {
|
|
185
|
-
this.providerCallIdMap.delete(callRecord.providerCallId);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
return {
|
|
189
|
-
callId,
|
|
190
|
-
success: false,
|
|
191
|
-
error: err instanceof Error ? err.message : String(err),
|
|
192
|
-
};
|
|
193
|
-
}
|
|
96
|
+
return initiateCallWithContext(this.getContext(), to, sessionKey, options);
|
|
194
97
|
}
|
|
195
98
|
|
|
196
99
|
/**
|
|
197
100
|
* Speak to user in an active call.
|
|
198
101
|
*/
|
|
199
102
|
async speak(callId: CallId, text: string): Promise<{ success: boolean; error?: string }> {
|
|
200
|
-
|
|
201
|
-
if (!call) {
|
|
202
|
-
return { success: false, error: "Call not found" };
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
if (!this.provider || !call.providerCallId) {
|
|
206
|
-
return { success: false, error: "Call not connected" };
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
if (TerminalStates.has(call.state)) {
|
|
210
|
-
return { success: false, error: "Call has ended" };
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
try {
|
|
214
|
-
// Update state
|
|
215
|
-
call.state = "speaking";
|
|
216
|
-
this.persistCallRecord(call);
|
|
217
|
-
|
|
218
|
-
// Add to transcript
|
|
219
|
-
this.addTranscriptEntry(call, "bot", text);
|
|
220
|
-
|
|
221
|
-
// Play TTS
|
|
222
|
-
const voice = this.provider?.name === "twilio" ? this.config.tts?.openai?.voice : undefined;
|
|
223
|
-
await this.provider.playTts({
|
|
224
|
-
callId,
|
|
225
|
-
providerCallId: call.providerCallId,
|
|
226
|
-
text,
|
|
227
|
-
voice,
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
return { success: true };
|
|
231
|
-
} catch (err) {
|
|
232
|
-
return {
|
|
233
|
-
success: false,
|
|
234
|
-
error: err instanceof Error ? err.message : String(err),
|
|
235
|
-
};
|
|
236
|
-
}
|
|
103
|
+
return speakWithContext(this.getContext(), callId, text);
|
|
237
104
|
}
|
|
238
105
|
|
|
239
106
|
/**
|
|
240
107
|
* Speak the initial message for a call (called when media stream connects).
|
|
241
|
-
* This is used to auto-play the message passed to initiateCall.
|
|
242
|
-
* In notify mode, auto-hangup after the message is delivered.
|
|
243
108
|
*/
|
|
244
109
|
async speakInitialMessage(providerCallId: string): Promise<void> {
|
|
245
|
-
|
|
246
|
-
if (!call) {
|
|
247
|
-
console.warn(`[voice-call] speakInitialMessage: no call found for ${providerCallId}`);
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const initialMessage = call.metadata?.initialMessage as string | undefined;
|
|
252
|
-
const mode = (call.metadata?.mode as CallMode) ?? "conversation";
|
|
253
|
-
|
|
254
|
-
if (!initialMessage) {
|
|
255
|
-
console.log(`[voice-call] speakInitialMessage: no initial message for ${call.callId}`);
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// Clear the initial message so we don't speak it again
|
|
260
|
-
if (call.metadata) {
|
|
261
|
-
delete call.metadata.initialMessage;
|
|
262
|
-
this.persistCallRecord(call);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
console.log(`[voice-call] Speaking initial message for call ${call.callId} (mode: ${mode})`);
|
|
266
|
-
const result = await this.speak(call.callId, initialMessage);
|
|
267
|
-
if (!result.success) {
|
|
268
|
-
console.warn(`[voice-call] Failed to speak initial message: ${result.error}`);
|
|
269
|
-
return;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// In notify mode, auto-hangup after delay
|
|
273
|
-
if (mode === "notify") {
|
|
274
|
-
const delaySec = this.config.outbound.notifyHangupDelaySec;
|
|
275
|
-
console.log(`[voice-call] Notify mode: auto-hangup in ${delaySec}s for call ${call.callId}`);
|
|
276
|
-
setTimeout(async () => {
|
|
277
|
-
const currentCall = this.getCall(call.callId);
|
|
278
|
-
if (currentCall && !TerminalStates.has(currentCall.state)) {
|
|
279
|
-
console.log(`[voice-call] Notify mode: hanging up call ${call.callId}`);
|
|
280
|
-
await this.endCall(call.callId);
|
|
281
|
-
}
|
|
282
|
-
}, delaySec * 1000);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* Clear max duration timer for a call.
|
|
288
|
-
*/
|
|
289
|
-
private clearMaxDurationTimer(callId: CallId): void {
|
|
290
|
-
const timer = this.maxDurationTimers.get(callId);
|
|
291
|
-
if (timer) {
|
|
292
|
-
clearTimeout(timer);
|
|
293
|
-
this.maxDurationTimers.delete(callId);
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
private clearTranscriptWaiter(callId: CallId): void {
|
|
298
|
-
const waiter = this.transcriptWaiters.get(callId);
|
|
299
|
-
if (!waiter) {
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
clearTimeout(waiter.timeout);
|
|
303
|
-
this.transcriptWaiters.delete(callId);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
private rejectTranscriptWaiter(callId: CallId, reason: string): void {
|
|
307
|
-
const waiter = this.transcriptWaiters.get(callId);
|
|
308
|
-
if (!waiter) {
|
|
309
|
-
return;
|
|
310
|
-
}
|
|
311
|
-
this.clearTranscriptWaiter(callId);
|
|
312
|
-
waiter.reject(new Error(reason));
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
private waitForFinalTranscript(callId: CallId): Promise<string> {
|
|
316
|
-
// Only allow one in-flight waiter per call.
|
|
317
|
-
this.rejectTranscriptWaiter(callId, "Transcript waiter replaced");
|
|
318
|
-
|
|
319
|
-
const timeoutMs = this.config.transcriptTimeoutMs;
|
|
320
|
-
return new Promise((resolve, reject) => {
|
|
321
|
-
const timeout = setTimeout(() => {
|
|
322
|
-
this.transcriptWaiters.delete(callId);
|
|
323
|
-
reject(new Error(`Timed out waiting for transcript after ${timeoutMs}ms`));
|
|
324
|
-
}, timeoutMs);
|
|
325
|
-
|
|
326
|
-
this.transcriptWaiters.set(callId, { resolve, reject, timeout });
|
|
327
|
-
});
|
|
110
|
+
return speakInitialMessageWithContext(this.getContext(), providerCallId);
|
|
328
111
|
}
|
|
329
112
|
|
|
330
113
|
/**
|
|
@@ -334,91 +117,14 @@ export class CallManager {
|
|
|
334
117
|
callId: CallId,
|
|
335
118
|
prompt: string,
|
|
336
119
|
): Promise<{ success: boolean; transcript?: string; error?: string }> {
|
|
337
|
-
|
|
338
|
-
if (!call) {
|
|
339
|
-
return { success: false, error: "Call not found" };
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
if (!this.provider || !call.providerCallId) {
|
|
343
|
-
return { success: false, error: "Call not connected" };
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
if (TerminalStates.has(call.state)) {
|
|
347
|
-
return { success: false, error: "Call has ended" };
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
try {
|
|
351
|
-
await this.speak(callId, prompt);
|
|
352
|
-
|
|
353
|
-
call.state = "listening";
|
|
354
|
-
this.persistCallRecord(call);
|
|
355
|
-
|
|
356
|
-
await this.provider.startListening({
|
|
357
|
-
callId,
|
|
358
|
-
providerCallId: call.providerCallId,
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
const transcript = await this.waitForFinalTranscript(callId);
|
|
362
|
-
|
|
363
|
-
// Best-effort: stop listening after final transcript.
|
|
364
|
-
await this.provider.stopListening({
|
|
365
|
-
callId,
|
|
366
|
-
providerCallId: call.providerCallId,
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
return { success: true, transcript };
|
|
370
|
-
} catch (err) {
|
|
371
|
-
return {
|
|
372
|
-
success: false,
|
|
373
|
-
error: err instanceof Error ? err.message : String(err),
|
|
374
|
-
};
|
|
375
|
-
} finally {
|
|
376
|
-
this.clearTranscriptWaiter(callId);
|
|
377
|
-
}
|
|
120
|
+
return continueCallWithContext(this.getContext(), callId, prompt);
|
|
378
121
|
}
|
|
379
122
|
|
|
380
123
|
/**
|
|
381
124
|
* End an active call.
|
|
382
125
|
*/
|
|
383
126
|
async endCall(callId: CallId): Promise<{ success: boolean; error?: string }> {
|
|
384
|
-
|
|
385
|
-
if (!call) {
|
|
386
|
-
return { success: false, error: "Call not found" };
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
if (!this.provider || !call.providerCallId) {
|
|
390
|
-
return { success: false, error: "Call not connected" };
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
if (TerminalStates.has(call.state)) {
|
|
394
|
-
return { success: true }; // Already ended
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
try {
|
|
398
|
-
await this.provider.hangupCall({
|
|
399
|
-
callId,
|
|
400
|
-
providerCallId: call.providerCallId,
|
|
401
|
-
reason: "hangup-bot",
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
call.state = "hangup-bot";
|
|
405
|
-
call.endedAt = Date.now();
|
|
406
|
-
call.endReason = "hangup-bot";
|
|
407
|
-
this.persistCallRecord(call);
|
|
408
|
-
this.clearMaxDurationTimer(callId);
|
|
409
|
-
this.rejectTranscriptWaiter(callId, "Call ended: hangup-bot");
|
|
410
|
-
this.activeCalls.delete(callId);
|
|
411
|
-
if (call.providerCallId) {
|
|
412
|
-
this.providerCallIdMap.delete(call.providerCallId);
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
return { success: true };
|
|
416
|
-
} catch (err) {
|
|
417
|
-
return {
|
|
418
|
-
success: false,
|
|
419
|
-
error: err instanceof Error ? err.message : String(err),
|
|
420
|
-
};
|
|
421
|
-
}
|
|
127
|
+
return endCallWithContext(this.getContext(), callId);
|
|
422
128
|
}
|
|
423
129
|
|
|
424
130
|
private getContext(): CallManagerContext {
|
|
@@ -427,15 +133,15 @@ export class CallManager {
|
|
|
427
133
|
providerCallIdMap: this.providerCallIdMap,
|
|
428
134
|
processedEventIds: this.processedEventIds,
|
|
429
135
|
rejectedProviderCallIds: this.rejectedProviderCallIds,
|
|
430
|
-
onCallAnswered: (call) => {
|
|
431
|
-
this.maybeSpeakInitialMessageOnAnswered(call);
|
|
432
|
-
},
|
|
433
136
|
provider: this.provider,
|
|
434
137
|
config: this.config,
|
|
435
138
|
storePath: this.storePath,
|
|
436
139
|
webhookUrl: this.webhookUrl,
|
|
437
140
|
transcriptWaiters: this.transcriptWaiters,
|
|
438
141
|
maxDurationTimers: this.maxDurationTimers,
|
|
142
|
+
onCallAnswered: (call) => {
|
|
143
|
+
this.maybeSpeakInitialMessageOnAnswered(call);
|
|
144
|
+
},
|
|
439
145
|
};
|
|
440
146
|
}
|
|
441
147
|
|
|
@@ -478,20 +184,11 @@ export class CallManager {
|
|
|
478
184
|
* Get an active call by provider call ID (e.g., Twilio CallSid).
|
|
479
185
|
*/
|
|
480
186
|
getCallByProviderCallId(providerCallId: string): CallRecord | undefined {
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// Fallback: linear search for cases where map wasn't populated
|
|
488
|
-
// (e.g., providerCallId set directly on call record)
|
|
489
|
-
for (const call of this.activeCalls.values()) {
|
|
490
|
-
if (call.providerCallId === providerCallId) {
|
|
491
|
-
return call;
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
return undefined;
|
|
187
|
+
return getCallByProviderCallIdFromMaps({
|
|
188
|
+
activeCalls: this.activeCalls,
|
|
189
|
+
providerCallIdMap: this.providerCallIdMap,
|
|
190
|
+
providerCallId,
|
|
191
|
+
});
|
|
495
192
|
}
|
|
496
193
|
|
|
497
194
|
/**
|
|
@@ -505,109 +202,6 @@ export class CallManager {
|
|
|
505
202
|
* Get call history (from persisted logs).
|
|
506
203
|
*/
|
|
507
204
|
async getCallHistory(limit = 50): Promise<CallRecord[]> {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
try {
|
|
511
|
-
await fsp.access(logPath);
|
|
512
|
-
} catch {
|
|
513
|
-
return [];
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
const content = await fsp.readFile(logPath, "utf-8");
|
|
517
|
-
const lines = content.trim().split("\n").filter(Boolean);
|
|
518
|
-
const calls: CallRecord[] = [];
|
|
519
|
-
|
|
520
|
-
// Parse last N lines
|
|
521
|
-
for (const line of lines.slice(-limit)) {
|
|
522
|
-
try {
|
|
523
|
-
const parsed = CallRecordSchema.parse(JSON.parse(line));
|
|
524
|
-
calls.push(parsed);
|
|
525
|
-
} catch {
|
|
526
|
-
// Skip invalid lines
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
return calls;
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
/**
|
|
534
|
-
* Add an entry to the call transcript.
|
|
535
|
-
*/
|
|
536
|
-
private addTranscriptEntry(call: CallRecord, speaker: "bot" | "user", text: string): void {
|
|
537
|
-
const entry: TranscriptEntry = {
|
|
538
|
-
timestamp: Date.now(),
|
|
539
|
-
speaker,
|
|
540
|
-
text,
|
|
541
|
-
isFinal: true,
|
|
542
|
-
};
|
|
543
|
-
call.transcript.push(entry);
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
/**
|
|
547
|
-
* Persist a call record to disk (fire-and-forget async).
|
|
548
|
-
*/
|
|
549
|
-
private persistCallRecord(call: CallRecord): void {
|
|
550
|
-
const logPath = path.join(this.storePath, "calls.jsonl");
|
|
551
|
-
const line = `${JSON.stringify(call)}\n`;
|
|
552
|
-
// Fire-and-forget async write to avoid blocking event loop
|
|
553
|
-
fsp.appendFile(logPath, line).catch((err) => {
|
|
554
|
-
console.error("[voice-call] Failed to persist call record:", err);
|
|
555
|
-
});
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
/**
|
|
559
|
-
* Load active calls from persistence (for crash recovery).
|
|
560
|
-
* Uses streaming to handle large log files efficiently.
|
|
561
|
-
*/
|
|
562
|
-
private loadActiveCalls(): void {
|
|
563
|
-
const logPath = path.join(this.storePath, "calls.jsonl");
|
|
564
|
-
if (!fs.existsSync(logPath)) {
|
|
565
|
-
return;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
// Read file synchronously and parse lines
|
|
569
|
-
const content = fs.readFileSync(logPath, "utf-8");
|
|
570
|
-
const lines = content.split("\n");
|
|
571
|
-
|
|
572
|
-
// Build map of latest state per call
|
|
573
|
-
const callMap = new Map<CallId, CallRecord>();
|
|
574
|
-
|
|
575
|
-
for (const line of lines) {
|
|
576
|
-
if (!line.trim()) {
|
|
577
|
-
continue;
|
|
578
|
-
}
|
|
579
|
-
try {
|
|
580
|
-
const call = CallRecordSchema.parse(JSON.parse(line));
|
|
581
|
-
callMap.set(call.callId, call);
|
|
582
|
-
} catch {
|
|
583
|
-
// Skip invalid lines
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
// Only keep non-terminal calls
|
|
588
|
-
for (const [callId, call] of callMap) {
|
|
589
|
-
if (!TerminalStates.has(call.state)) {
|
|
590
|
-
this.activeCalls.set(callId, call);
|
|
591
|
-
// Populate providerCallId mapping for lookups
|
|
592
|
-
if (call.providerCallId) {
|
|
593
|
-
this.providerCallIdMap.set(call.providerCallId, callId);
|
|
594
|
-
}
|
|
595
|
-
// Populate processed event IDs
|
|
596
|
-
for (const eventId of call.processedEventIds) {
|
|
597
|
-
this.processedEventIds.add(eventId);
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
/**
|
|
604
|
-
* Generate TwiML for notify mode (speak message and hang up).
|
|
605
|
-
*/
|
|
606
|
-
private generateNotifyTwiml(message: string, voice: string): string {
|
|
607
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
608
|
-
<Response>
|
|
609
|
-
<Say voice="${voice}">${escapeXml(message)}</Say>
|
|
610
|
-
<Hangup/>
|
|
611
|
-
</Response>`;
|
|
205
|
+
return getCallHistoryFromStore(this.storePath, limit);
|
|
612
206
|
}
|
|
613
207
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import type { WebhookContext } from "../types.js";
|
|
4
|
+
import { TelnyxProvider } from "./telnyx.js";
|
|
5
|
+
|
|
6
|
+
function createCtx(params?: Partial<WebhookContext>): WebhookContext {
|
|
7
|
+
return {
|
|
8
|
+
headers: {},
|
|
9
|
+
rawBody: "{}",
|
|
10
|
+
url: "http://localhost/voice/webhook",
|
|
11
|
+
method: "POST",
|
|
12
|
+
query: {},
|
|
13
|
+
remoteAddress: "127.0.0.1",
|
|
14
|
+
...params,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function decodeBase64Url(input: string): Buffer {
|
|
19
|
+
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
20
|
+
const padLen = (4 - (normalized.length % 4)) % 4;
|
|
21
|
+
const padded = normalized + "=".repeat(padLen);
|
|
22
|
+
return Buffer.from(padded, "base64");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("TelnyxProvider.verifyWebhook", () => {
|
|
26
|
+
it("fails closed when public key is missing and skipVerification is false", () => {
|
|
27
|
+
const provider = new TelnyxProvider(
|
|
28
|
+
{ apiKey: "KEY123", connectionId: "CONN456", publicKey: undefined },
|
|
29
|
+
{ skipVerification: false },
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const result = provider.verifyWebhook(createCtx());
|
|
33
|
+
expect(result.ok).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("allows requests when skipVerification is true (development only)", () => {
|
|
37
|
+
const provider = new TelnyxProvider(
|
|
38
|
+
{ apiKey: "KEY123", connectionId: "CONN456", publicKey: undefined },
|
|
39
|
+
{ skipVerification: true },
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const result = provider.verifyWebhook(createCtx());
|
|
43
|
+
expect(result.ok).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("fails when signature headers are missing (with public key configured)", () => {
|
|
47
|
+
const provider = new TelnyxProvider(
|
|
48
|
+
{ apiKey: "KEY123", connectionId: "CONN456", publicKey: "public-key" },
|
|
49
|
+
{ skipVerification: false },
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const result = provider.verifyWebhook(createCtx({ headers: {} }));
|
|
53
|
+
expect(result.ok).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("verifies a valid signature with a raw Ed25519 public key (Base64)", () => {
|
|
57
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
|
58
|
+
|
|
59
|
+
const jwk = publicKey.export({ format: "jwk" }) as JsonWebKey;
|
|
60
|
+
expect(jwk.kty).toBe("OKP");
|
|
61
|
+
expect(jwk.crv).toBe("Ed25519");
|
|
62
|
+
expect(typeof jwk.x).toBe("string");
|
|
63
|
+
|
|
64
|
+
const rawPublicKey = decodeBase64Url(jwk.x as string);
|
|
65
|
+
const rawPublicKeyBase64 = rawPublicKey.toString("base64");
|
|
66
|
+
|
|
67
|
+
const provider = new TelnyxProvider(
|
|
68
|
+
{ apiKey: "KEY123", connectionId: "CONN456", publicKey: rawPublicKeyBase64 },
|
|
69
|
+
{ skipVerification: false },
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const rawBody = JSON.stringify({
|
|
73
|
+
event_type: "call.initiated",
|
|
74
|
+
payload: { call_control_id: "x" },
|
|
75
|
+
});
|
|
76
|
+
const timestamp = String(Math.floor(Date.now() / 1000));
|
|
77
|
+
const signedPayload = `${timestamp}|${rawBody}`;
|
|
78
|
+
const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64");
|
|
79
|
+
|
|
80
|
+
const result = provider.verifyWebhook(
|
|
81
|
+
createCtx({
|
|
82
|
+
rawBody,
|
|
83
|
+
headers: {
|
|
84
|
+
"telnyx-signature-ed25519": signature,
|
|
85
|
+
"telnyx-timestamp": timestamp,
|
|
86
|
+
},
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
expect(result.ok).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("verifies a valid signature with a DER SPKI public key (Base64)", () => {
|
|
93
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
|
94
|
+
const spkiDer = publicKey.export({ format: "der", type: "spki" }) as Buffer;
|
|
95
|
+
const spkiDerBase64 = spkiDer.toString("base64");
|
|
96
|
+
|
|
97
|
+
const provider = new TelnyxProvider(
|
|
98
|
+
{ apiKey: "KEY123", connectionId: "CONN456", publicKey: spkiDerBase64 },
|
|
99
|
+
{ skipVerification: false },
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const rawBody = JSON.stringify({
|
|
103
|
+
event_type: "call.initiated",
|
|
104
|
+
payload: { call_control_id: "x" },
|
|
105
|
+
});
|
|
106
|
+
const timestamp = String(Math.floor(Date.now() / 1000));
|
|
107
|
+
const signedPayload = `${timestamp}|${rawBody}`;
|
|
108
|
+
const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64");
|
|
109
|
+
|
|
110
|
+
const result = provider.verifyWebhook(
|
|
111
|
+
createCtx({
|
|
112
|
+
rawBody,
|
|
113
|
+
headers: {
|
|
114
|
+
"telnyx-signature-ed25519": signature,
|
|
115
|
+
"telnyx-timestamp": timestamp,
|
|
116
|
+
},
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
expect(result.ok).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
});
|
package/src/providers/telnyx.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
WebhookVerificationResult,
|
|
15
15
|
} from "../types.js";
|
|
16
16
|
import type { VoiceCallProvider } from "./base.js";
|
|
17
|
+
import { verifyTelnyxWebhook } from "../webhook-security.js";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Telnyx Voice API provider implementation.
|
|
@@ -22,8 +23,8 @@ import type { VoiceCallProvider } from "./base.js";
|
|
|
22
23
|
* @see https://developers.telnyx.com/docs/api/v2/call-control
|
|
23
24
|
*/
|
|
24
25
|
export interface TelnyxProviderOptions {
|
|
25
|
-
/**
|
|
26
|
-
|
|
26
|
+
/** Skip webhook signature verification (development only, NOT for production) */
|
|
27
|
+
skipVerification?: boolean;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export class TelnyxProvider implements VoiceCallProvider {
|
|
@@ -82,65 +83,11 @@ export class TelnyxProvider implements VoiceCallProvider {
|
|
|
82
83
|
* Verify Telnyx webhook signature using Ed25519.
|
|
83
84
|
*/
|
|
84
85
|
verifyWebhook(ctx: WebhookContext): WebhookVerificationResult {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return { ok: true, reason: "verification skipped (no public key configured)" };
|
|
89
|
-
}
|
|
90
|
-
return {
|
|
91
|
-
ok: false,
|
|
92
|
-
reason: "Missing telnyx.publicKey (configure to verify webhooks)",
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const signature = ctx.headers["telnyx-signature-ed25519"];
|
|
97
|
-
const timestamp = ctx.headers["telnyx-timestamp"];
|
|
98
|
-
|
|
99
|
-
if (!signature || !timestamp) {
|
|
100
|
-
return { ok: false, reason: "Missing signature or timestamp header" };
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const signatureStr = Array.isArray(signature) ? signature[0] : signature;
|
|
104
|
-
const timestampStr = Array.isArray(timestamp) ? timestamp[0] : timestamp;
|
|
105
|
-
|
|
106
|
-
if (!signatureStr || !timestampStr) {
|
|
107
|
-
return { ok: false, reason: "Empty signature or timestamp" };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
try {
|
|
111
|
-
const signedPayload = `${timestampStr}|${ctx.rawBody}`;
|
|
112
|
-
const signatureBuffer = Buffer.from(signatureStr, "base64");
|
|
113
|
-
const publicKeyBuffer = Buffer.from(this.publicKey, "base64");
|
|
114
|
-
|
|
115
|
-
const isValid = crypto.verify(
|
|
116
|
-
null, // Ed25519 doesn't use a digest
|
|
117
|
-
Buffer.from(signedPayload),
|
|
118
|
-
{
|
|
119
|
-
key: publicKeyBuffer,
|
|
120
|
-
format: "der",
|
|
121
|
-
type: "spki",
|
|
122
|
-
},
|
|
123
|
-
signatureBuffer,
|
|
124
|
-
);
|
|
125
|
-
|
|
126
|
-
if (!isValid) {
|
|
127
|
-
return { ok: false, reason: "Invalid signature" };
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Check timestamp is within 5 minutes
|
|
131
|
-
const eventTime = parseInt(timestampStr, 10) * 1000;
|
|
132
|
-
const now = Date.now();
|
|
133
|
-
if (Math.abs(now - eventTime) > 5 * 60 * 1000) {
|
|
134
|
-
return { ok: false, reason: "Timestamp too old" };
|
|
135
|
-
}
|
|
86
|
+
const result = verifyTelnyxWebhook(ctx, this.publicKey, {
|
|
87
|
+
skipVerification: this.options.skipVerification,
|
|
88
|
+
});
|
|
136
89
|
|
|
137
|
-
|
|
138
|
-
} catch (err) {
|
|
139
|
-
return {
|
|
140
|
-
ok: false,
|
|
141
|
-
reason: `Verification error: ${err instanceof Error ? err.message : String(err)}`,
|
|
142
|
-
};
|
|
143
|
-
}
|
|
90
|
+
return { ok: result.ok, reason: result.reason };
|
|
144
91
|
}
|
|
145
92
|
|
|
146
93
|
/**
|
package/src/runtime.ts
CHANGED
|
@@ -55,8 +55,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
|
|
|
55
55
|
publicKey: config.telnyx?.publicKey,
|
|
56
56
|
},
|
|
57
57
|
{
|
|
58
|
-
|
|
59
|
-
config.inboundPolicy === "open" || config.inboundPolicy === "disabled",
|
|
58
|
+
skipVerification: config.skipSignatureVerification,
|
|
60
59
|
},
|
|
61
60
|
);
|
|
62
61
|
case "twilio":
|
|
@@ -113,6 +112,12 @@ export async function createVoiceCallRuntime(params: {
|
|
|
113
112
|
throw new Error("Voice call disabled. Enable the plugin entry in config.");
|
|
114
113
|
}
|
|
115
114
|
|
|
115
|
+
if (config.skipSignatureVerification) {
|
|
116
|
+
log.warn(
|
|
117
|
+
"[voice-call] SECURITY WARNING: skipSignatureVerification=true disables webhook signature verification (development only). Do not use in production.",
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
116
121
|
const validation = validateProviderConfig(config);
|
|
117
122
|
if (!validation.valid) {
|
|
118
123
|
throw new Error(`Invalid voice-call config: ${validation.errors.join("; ")}`);
|
|
@@ -222,9 +222,16 @@ describe("verifyTwilioWebhook", () => {
|
|
|
222
222
|
expect(result.reason).toMatch(/Invalid signature/);
|
|
223
223
|
});
|
|
224
224
|
|
|
225
|
-
it("
|
|
225
|
+
it("accepts valid signatures for ngrok free tier on loopback when compatibility mode is enabled", () => {
|
|
226
226
|
const authToken = "test-auth-token";
|
|
227
227
|
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
|
|
228
|
+
const webhookUrl = "https://local.ngrok-free.app/voice/webhook";
|
|
229
|
+
|
|
230
|
+
const signature = twilioSignature({
|
|
231
|
+
authToken,
|
|
232
|
+
url: webhookUrl,
|
|
233
|
+
postBody,
|
|
234
|
+
});
|
|
228
235
|
|
|
229
236
|
const result = verifyTwilioWebhook(
|
|
230
237
|
{
|
|
@@ -232,7 +239,7 @@ describe("verifyTwilioWebhook", () => {
|
|
|
232
239
|
host: "127.0.0.1:3334",
|
|
233
240
|
"x-forwarded-proto": "https",
|
|
234
241
|
"x-forwarded-host": "local.ngrok-free.app",
|
|
235
|
-
"x-twilio-signature":
|
|
242
|
+
"x-twilio-signature": signature,
|
|
236
243
|
},
|
|
237
244
|
rawBody: postBody,
|
|
238
245
|
url: "http://127.0.0.1:3334/voice/webhook",
|
|
@@ -244,8 +251,33 @@ describe("verifyTwilioWebhook", () => {
|
|
|
244
251
|
);
|
|
245
252
|
|
|
246
253
|
expect(result.ok).toBe(true);
|
|
254
|
+
expect(result.verificationUrl).toBe(webhookUrl);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("does not allow invalid signatures for ngrok free tier on loopback", () => {
|
|
258
|
+
const authToken = "test-auth-token";
|
|
259
|
+
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
|
|
260
|
+
|
|
261
|
+
const result = verifyTwilioWebhook(
|
|
262
|
+
{
|
|
263
|
+
headers: {
|
|
264
|
+
host: "127.0.0.1:3334",
|
|
265
|
+
"x-forwarded-proto": "https",
|
|
266
|
+
"x-forwarded-host": "local.ngrok-free.app",
|
|
267
|
+
"x-twilio-signature": "invalid",
|
|
268
|
+
},
|
|
269
|
+
rawBody: postBody,
|
|
270
|
+
url: "http://127.0.0.1:3334/voice/webhook",
|
|
271
|
+
method: "POST",
|
|
272
|
+
remoteAddress: "127.0.0.1",
|
|
273
|
+
},
|
|
274
|
+
authToken,
|
|
275
|
+
{ allowNgrokFreeTierLoopbackBypass: true },
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
expect(result.ok).toBe(false);
|
|
279
|
+
expect(result.reason).toMatch(/Invalid signature/);
|
|
247
280
|
expect(result.isNgrokFreeTier).toBe(true);
|
|
248
|
-
expect(result.reason).toMatch(/compatibility mode/);
|
|
249
281
|
});
|
|
250
282
|
|
|
251
283
|
it("ignores attacker X-Forwarded-Host without allowedHosts or trustForwardingHeaders", () => {
|
package/src/webhook-security.ts
CHANGED
|
@@ -330,6 +330,111 @@ export interface TwilioVerificationResult {
|
|
|
330
330
|
isNgrokFreeTier?: boolean;
|
|
331
331
|
}
|
|
332
332
|
|
|
333
|
+
export interface TelnyxVerificationResult {
|
|
334
|
+
ok: boolean;
|
|
335
|
+
reason?: string;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function decodeBase64OrBase64Url(input: string): Buffer {
|
|
339
|
+
// Telnyx docs say Base64; some tooling emits Base64URL. Accept both.
|
|
340
|
+
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
341
|
+
const padLen = (4 - (normalized.length % 4)) % 4;
|
|
342
|
+
const padded = normalized + "=".repeat(padLen);
|
|
343
|
+
return Buffer.from(padded, "base64");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function base64UrlEncode(buf: Buffer): string {
|
|
347
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function importEd25519PublicKey(publicKey: string): crypto.KeyObject | string {
|
|
351
|
+
const trimmed = publicKey.trim();
|
|
352
|
+
|
|
353
|
+
// PEM (spki) support.
|
|
354
|
+
if (trimmed.startsWith("-----BEGIN")) {
|
|
355
|
+
return trimmed;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Base64-encoded raw Ed25519 key (32 bytes) or Base64-encoded DER SPKI key.
|
|
359
|
+
const decoded = decodeBase64OrBase64Url(trimmed);
|
|
360
|
+
if (decoded.length === 32) {
|
|
361
|
+
// JWK is the easiest portable way to import raw Ed25519 keys in Node crypto.
|
|
362
|
+
return crypto.createPublicKey({
|
|
363
|
+
key: { kty: "OKP", crv: "Ed25519", x: base64UrlEncode(decoded) },
|
|
364
|
+
format: "jwk",
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return crypto.createPublicKey({
|
|
369
|
+
key: decoded,
|
|
370
|
+
format: "der",
|
|
371
|
+
type: "spki",
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Verify Telnyx webhook signature using Ed25519.
|
|
377
|
+
*
|
|
378
|
+
* Telnyx signs `timestamp|payload` and provides:
|
|
379
|
+
* - `telnyx-signature-ed25519` (Base64 signature)
|
|
380
|
+
* - `telnyx-timestamp` (Unix seconds)
|
|
381
|
+
*/
|
|
382
|
+
export function verifyTelnyxWebhook(
|
|
383
|
+
ctx: WebhookContext,
|
|
384
|
+
publicKey: string | undefined,
|
|
385
|
+
options?: {
|
|
386
|
+
/** Skip verification entirely (only for development) */
|
|
387
|
+
skipVerification?: boolean;
|
|
388
|
+
/** Maximum allowed clock skew (ms). Defaults to 5 minutes. */
|
|
389
|
+
maxSkewMs?: number;
|
|
390
|
+
},
|
|
391
|
+
): TelnyxVerificationResult {
|
|
392
|
+
if (options?.skipVerification) {
|
|
393
|
+
return { ok: true, reason: "verification skipped (dev mode)" };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (!publicKey) {
|
|
397
|
+
return { ok: false, reason: "Missing telnyx.publicKey (configure to verify webhooks)" };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const signature = getHeader(ctx.headers, "telnyx-signature-ed25519");
|
|
401
|
+
const timestamp = getHeader(ctx.headers, "telnyx-timestamp");
|
|
402
|
+
|
|
403
|
+
if (!signature || !timestamp) {
|
|
404
|
+
return { ok: false, reason: "Missing signature or timestamp header" };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const eventTimeSec = parseInt(timestamp, 10);
|
|
408
|
+
if (!Number.isFinite(eventTimeSec)) {
|
|
409
|
+
return { ok: false, reason: "Invalid timestamp header" };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
const signedPayload = `${timestamp}|${ctx.rawBody}`;
|
|
414
|
+
const signatureBuffer = decodeBase64OrBase64Url(signature);
|
|
415
|
+
const key = importEd25519PublicKey(publicKey);
|
|
416
|
+
|
|
417
|
+
const isValid = crypto.verify(null, Buffer.from(signedPayload), key, signatureBuffer);
|
|
418
|
+
if (!isValid) {
|
|
419
|
+
return { ok: false, reason: "Invalid signature" };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const maxSkewMs = options?.maxSkewMs ?? 5 * 60 * 1000;
|
|
423
|
+
const eventTimeMs = eventTimeSec * 1000;
|
|
424
|
+
const now = Date.now();
|
|
425
|
+
if (Math.abs(now - eventTimeMs) > maxSkewMs) {
|
|
426
|
+
return { ok: false, reason: "Timestamp too old" };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return { ok: true };
|
|
430
|
+
} catch (err) {
|
|
431
|
+
return {
|
|
432
|
+
ok: false,
|
|
433
|
+
reason: `Verification error: ${err instanceof Error ? err.message : String(err)}`,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
333
438
|
/**
|
|
334
439
|
* Verify Twilio webhook with full context and detailed result.
|
|
335
440
|
*/
|
|
@@ -339,7 +444,13 @@ export function verifyTwilioWebhook(
|
|
|
339
444
|
options?: {
|
|
340
445
|
/** Override the public URL (e.g., from config) */
|
|
341
446
|
publicUrl?: string;
|
|
342
|
-
/**
|
|
447
|
+
/**
|
|
448
|
+
* Allow ngrok free tier compatibility mode (loopback only).
|
|
449
|
+
*
|
|
450
|
+
* IMPORTANT: This does NOT bypass signature verification.
|
|
451
|
+
* It only enables trusting forwarded headers on loopback so we can
|
|
452
|
+
* reconstruct the public ngrok URL that Twilio used for signing.
|
|
453
|
+
*/
|
|
343
454
|
allowNgrokFreeTierLoopbackBypass?: boolean;
|
|
344
455
|
/** Skip verification entirely (only for development) */
|
|
345
456
|
skipVerification?: boolean;
|
|
@@ -401,18 +512,6 @@ export function verifyTwilioWebhook(
|
|
|
401
512
|
const isNgrokFreeTier =
|
|
402
513
|
verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok.io");
|
|
403
514
|
|
|
404
|
-
if (isNgrokFreeTier && options?.allowNgrokFreeTierLoopbackBypass && isLoopback) {
|
|
405
|
-
console.warn(
|
|
406
|
-
"[voice-call] Twilio signature validation failed (ngrok free tier compatibility, loopback only)",
|
|
407
|
-
);
|
|
408
|
-
return {
|
|
409
|
-
ok: true,
|
|
410
|
-
reason: "ngrok free tier compatibility mode (loopback only)",
|
|
411
|
-
verificationUrl,
|
|
412
|
-
isNgrokFreeTier: true,
|
|
413
|
-
};
|
|
414
|
-
}
|
|
415
|
-
|
|
416
515
|
return {
|
|
417
516
|
ok: false,
|
|
418
517
|
reason: `Invalid signature for URL: ${verificationUrl}`,
|