@openclaw/voice-call 2026.2.1 → 2026.2.3
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 +7 -1
- package/package.json +1 -1
- package/src/allowlist.ts +19 -0
- package/src/config.test.ts +33 -0
- package/src/config.ts +49 -6
- package/src/core-bridge.ts +19 -60
- package/src/manager/events.ts +7 -5
- package/src/manager.test.ts +120 -1
- package/src/manager.ts +27 -6
- package/src/media-stream.ts +34 -2
- package/src/providers/plivo.ts +18 -5
- package/src/providers/telnyx.ts +16 -3
- package/src/providers/twilio/webhook.ts +4 -0
- package/src/providers/twilio.test.ts +5 -5
- package/src/providers/twilio.ts +47 -4
- package/src/runtime.ts +14 -6
- package/src/webhook-security.test.ts +130 -4
- package/src/webhook-security.ts +247 -23
- package/src/webhook.ts +38 -3
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
package/src/allowlist.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function normalizePhoneNumber(input?: string): string {
|
|
2
|
+
if (!input) {
|
|
3
|
+
return "";
|
|
4
|
+
}
|
|
5
|
+
return input.replace(/\D/g, "");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function isAllowlistedCaller(
|
|
9
|
+
normalizedFrom: string,
|
|
10
|
+
allowFrom: string[] | undefined,
|
|
11
|
+
): boolean {
|
|
12
|
+
if (!normalizedFrom) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
return (allowFrom ?? []).some((num) => {
|
|
16
|
+
const normalizedAllow = normalizePhoneNumber(num);
|
|
17
|
+
return normalizedAllow !== "" && normalizedAllow === normalizedFrom;
|
|
18
|
+
});
|
|
19
|
+
}
|
package/src/config.test.ts
CHANGED
|
@@ -17,6 +17,11 @@ function createBaseConfig(provider: "telnyx" | "twilio" | "plivo" | "mock"): Voi
|
|
|
17
17
|
serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" },
|
|
18
18
|
tailscale: { mode: "off", path: "/voice/webhook" },
|
|
19
19
|
tunnel: { provider: "none", allowNgrokFreeTierLoopbackBypass: false },
|
|
20
|
+
webhookSecurity: {
|
|
21
|
+
allowedHosts: [],
|
|
22
|
+
trustForwardingHeaders: false,
|
|
23
|
+
trustedProxyIPs: [],
|
|
24
|
+
},
|
|
20
25
|
streaming: {
|
|
21
26
|
enabled: false,
|
|
22
27
|
sttProvider: "openai-realtime",
|
|
@@ -148,6 +153,34 @@ describe("validateProviderConfig", () => {
|
|
|
148
153
|
"plugins.entries.voice-call.config.telnyx.apiKey is required (or set TELNYX_API_KEY env)",
|
|
149
154
|
);
|
|
150
155
|
});
|
|
156
|
+
|
|
157
|
+
it("fails validation when allowlist inbound policy lacks public key", () => {
|
|
158
|
+
const config = createBaseConfig("telnyx");
|
|
159
|
+
config.inboundPolicy = "allowlist";
|
|
160
|
+
config.telnyx = { apiKey: "KEY123", connectionId: "CONN456" };
|
|
161
|
+
|
|
162
|
+
const result = validateProviderConfig(config);
|
|
163
|
+
|
|
164
|
+
expect(result.valid).toBe(false);
|
|
165
|
+
expect(result.errors).toContain(
|
|
166
|
+
"plugins.entries.voice-call.config.telnyx.publicKey is required for inboundPolicy allowlist/pairing",
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("passes validation when allowlist inbound policy has public key", () => {
|
|
171
|
+
const config = createBaseConfig("telnyx");
|
|
172
|
+
config.inboundPolicy = "allowlist";
|
|
173
|
+
config.telnyx = {
|
|
174
|
+
apiKey: "KEY123",
|
|
175
|
+
connectionId: "CONN456",
|
|
176
|
+
publicKey: "public-key",
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const result = validateProviderConfig(config);
|
|
180
|
+
|
|
181
|
+
expect(result.valid).toBe(true);
|
|
182
|
+
expect(result.errors).toEqual([]);
|
|
183
|
+
});
|
|
151
184
|
});
|
|
152
185
|
|
|
153
186
|
describe("plivo provider", () => {
|
package/src/config.ts
CHANGED
|
@@ -211,16 +211,37 @@ export const VoiceCallTunnelConfigSchema = z
|
|
|
211
211
|
* will be allowed only for loopback requests (ngrok local agent).
|
|
212
212
|
*/
|
|
213
213
|
allowNgrokFreeTierLoopbackBypass: z.boolean().default(false),
|
|
214
|
-
/**
|
|
215
|
-
* Legacy ngrok free tier compatibility mode (deprecated).
|
|
216
|
-
* Use allowNgrokFreeTierLoopbackBypass instead.
|
|
217
|
-
*/
|
|
218
|
-
allowNgrokFreeTier: z.boolean().optional(),
|
|
219
214
|
})
|
|
220
215
|
.strict()
|
|
221
216
|
.default({ provider: "none", allowNgrokFreeTierLoopbackBypass: false });
|
|
222
217
|
export type VoiceCallTunnelConfig = z.infer<typeof VoiceCallTunnelConfigSchema>;
|
|
223
218
|
|
|
219
|
+
// -----------------------------------------------------------------------------
|
|
220
|
+
// Webhook Security Configuration
|
|
221
|
+
// -----------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
export const VoiceCallWebhookSecurityConfigSchema = z
|
|
224
|
+
.object({
|
|
225
|
+
/**
|
|
226
|
+
* Allowed hostnames for webhook URL reconstruction.
|
|
227
|
+
* Only these hosts are accepted from forwarding headers.
|
|
228
|
+
*/
|
|
229
|
+
allowedHosts: z.array(z.string().min(1)).default([]),
|
|
230
|
+
/**
|
|
231
|
+
* Trust X-Forwarded-* headers without a hostname allowlist.
|
|
232
|
+
* WARNING: Only enable if you trust your proxy configuration.
|
|
233
|
+
*/
|
|
234
|
+
trustForwardingHeaders: z.boolean().default(false),
|
|
235
|
+
/**
|
|
236
|
+
* Trusted proxy IP addresses. Forwarded headers are only trusted when
|
|
237
|
+
* the remote IP matches one of these addresses.
|
|
238
|
+
*/
|
|
239
|
+
trustedProxyIPs: z.array(z.string().min(1)).default([]),
|
|
240
|
+
})
|
|
241
|
+
.strict()
|
|
242
|
+
.default({ allowedHosts: [], trustForwardingHeaders: false, trustedProxyIPs: [] });
|
|
243
|
+
export type WebhookSecurityConfig = z.infer<typeof VoiceCallWebhookSecurityConfigSchema>;
|
|
244
|
+
|
|
224
245
|
// -----------------------------------------------------------------------------
|
|
225
246
|
// Outbound Call Configuration
|
|
226
247
|
// -----------------------------------------------------------------------------
|
|
@@ -339,6 +360,9 @@ export const VoiceCallConfigSchema = z
|
|
|
339
360
|
/** Tunnel configuration (unified ngrok/tailscale) */
|
|
340
361
|
tunnel: VoiceCallTunnelConfigSchema,
|
|
341
362
|
|
|
363
|
+
/** Webhook signature reconstruction and proxy trust configuration */
|
|
364
|
+
webhookSecurity: VoiceCallWebhookSecurityConfigSchema,
|
|
365
|
+
|
|
342
366
|
/** Real-time audio streaming configuration */
|
|
343
367
|
streaming: VoiceCallStreamingConfigSchema,
|
|
344
368
|
|
|
@@ -409,10 +433,21 @@ export function resolveVoiceCallConfig(config: VoiceCallConfig): VoiceCallConfig
|
|
|
409
433
|
allowNgrokFreeTierLoopbackBypass: false,
|
|
410
434
|
};
|
|
411
435
|
resolved.tunnel.allowNgrokFreeTierLoopbackBypass =
|
|
412
|
-
resolved.tunnel.allowNgrokFreeTierLoopbackBypass
|
|
436
|
+
resolved.tunnel.allowNgrokFreeTierLoopbackBypass ?? false;
|
|
413
437
|
resolved.tunnel.ngrokAuthToken = resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN;
|
|
414
438
|
resolved.tunnel.ngrokDomain = resolved.tunnel.ngrokDomain ?? process.env.NGROK_DOMAIN;
|
|
415
439
|
|
|
440
|
+
// Webhook Security Config
|
|
441
|
+
resolved.webhookSecurity = resolved.webhookSecurity ?? {
|
|
442
|
+
allowedHosts: [],
|
|
443
|
+
trustForwardingHeaders: false,
|
|
444
|
+
trustedProxyIPs: [],
|
|
445
|
+
};
|
|
446
|
+
resolved.webhookSecurity.allowedHosts = resolved.webhookSecurity.allowedHosts ?? [];
|
|
447
|
+
resolved.webhookSecurity.trustForwardingHeaders =
|
|
448
|
+
resolved.webhookSecurity.trustForwardingHeaders ?? false;
|
|
449
|
+
resolved.webhookSecurity.trustedProxyIPs = resolved.webhookSecurity.trustedProxyIPs ?? [];
|
|
450
|
+
|
|
416
451
|
return resolved;
|
|
417
452
|
}
|
|
418
453
|
|
|
@@ -448,6 +483,14 @@ export function validateProviderConfig(config: VoiceCallConfig): {
|
|
|
448
483
|
"plugins.entries.voice-call.config.telnyx.connectionId is required (or set TELNYX_CONNECTION_ID env)",
|
|
449
484
|
);
|
|
450
485
|
}
|
|
486
|
+
if (
|
|
487
|
+
(config.inboundPolicy === "allowlist" || config.inboundPolicy === "pairing") &&
|
|
488
|
+
!config.telnyx?.publicKey
|
|
489
|
+
) {
|
|
490
|
+
errors.push(
|
|
491
|
+
"plugins.entries.voice-call.config.telnyx.publicKey is required for inboundPolicy allowlist/pairing",
|
|
492
|
+
);
|
|
493
|
+
}
|
|
451
494
|
}
|
|
452
495
|
|
|
453
496
|
if (config.provider === "twilio") {
|
package/src/core-bridge.ts
CHANGED
|
@@ -121,15 +121,29 @@ function resolveOpenClawRoot(): string {
|
|
|
121
121
|
throw new Error("Unable to resolve core root. Set OPENCLAW_ROOT to the package root.");
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
async function
|
|
125
|
-
|
|
126
|
-
|
|
124
|
+
async function importCoreExtensionAPI(): Promise<{
|
|
125
|
+
resolveAgentDir: CoreAgentDeps["resolveAgentDir"];
|
|
126
|
+
resolveAgentWorkspaceDir: CoreAgentDeps["resolveAgentWorkspaceDir"];
|
|
127
|
+
DEFAULT_MODEL: string;
|
|
128
|
+
DEFAULT_PROVIDER: string;
|
|
129
|
+
resolveAgentIdentity: CoreAgentDeps["resolveAgentIdentity"];
|
|
130
|
+
resolveThinkingDefault: CoreAgentDeps["resolveThinkingDefault"];
|
|
131
|
+
runEmbeddedPiAgent: CoreAgentDeps["runEmbeddedPiAgent"];
|
|
132
|
+
resolveAgentTimeoutMs: CoreAgentDeps["resolveAgentTimeoutMs"];
|
|
133
|
+
ensureAgentWorkspace: CoreAgentDeps["ensureAgentWorkspace"];
|
|
134
|
+
resolveStorePath: CoreAgentDeps["resolveStorePath"];
|
|
135
|
+
loadSessionStore: CoreAgentDeps["loadSessionStore"];
|
|
136
|
+
saveSessionStore: CoreAgentDeps["saveSessionStore"];
|
|
137
|
+
resolveSessionFilePath: CoreAgentDeps["resolveSessionFilePath"];
|
|
138
|
+
}> {
|
|
139
|
+
// Do not import any other module. You can't touch this or you will be fired.
|
|
140
|
+
const distPath = path.join(resolveOpenClawRoot(), "dist", "extensionAPI.js");
|
|
127
141
|
if (!fs.existsSync(distPath)) {
|
|
128
142
|
throw new Error(
|
|
129
143
|
`Missing core module at ${distPath}. Run \`pnpm build\` or install the official package.`,
|
|
130
144
|
);
|
|
131
145
|
}
|
|
132
|
-
return
|
|
146
|
+
return await import(pathToFileURL(distPath).href);
|
|
133
147
|
}
|
|
134
148
|
|
|
135
149
|
export async function loadCoreAgentDeps(): Promise<CoreAgentDeps> {
|
|
@@ -138,62 +152,7 @@ export async function loadCoreAgentDeps(): Promise<CoreAgentDeps> {
|
|
|
138
152
|
}
|
|
139
153
|
|
|
140
154
|
coreDepsPromise = (async () => {
|
|
141
|
-
|
|
142
|
-
agentScope,
|
|
143
|
-
defaults,
|
|
144
|
-
identity,
|
|
145
|
-
modelSelection,
|
|
146
|
-
piEmbedded,
|
|
147
|
-
timeout,
|
|
148
|
-
workspace,
|
|
149
|
-
sessions,
|
|
150
|
-
] = await Promise.all([
|
|
151
|
-
importCoreModule<{
|
|
152
|
-
resolveAgentDir: CoreAgentDeps["resolveAgentDir"];
|
|
153
|
-
resolveAgentWorkspaceDir: CoreAgentDeps["resolveAgentWorkspaceDir"];
|
|
154
|
-
}>("agents/agent-scope.js"),
|
|
155
|
-
importCoreModule<{
|
|
156
|
-
DEFAULT_MODEL: string;
|
|
157
|
-
DEFAULT_PROVIDER: string;
|
|
158
|
-
}>("agents/defaults.js"),
|
|
159
|
-
importCoreModule<{
|
|
160
|
-
resolveAgentIdentity: CoreAgentDeps["resolveAgentIdentity"];
|
|
161
|
-
}>("agents/identity.js"),
|
|
162
|
-
importCoreModule<{
|
|
163
|
-
resolveThinkingDefault: CoreAgentDeps["resolveThinkingDefault"];
|
|
164
|
-
}>("agents/model-selection.js"),
|
|
165
|
-
importCoreModule<{
|
|
166
|
-
runEmbeddedPiAgent: CoreAgentDeps["runEmbeddedPiAgent"];
|
|
167
|
-
}>("agents/pi-embedded.js"),
|
|
168
|
-
importCoreModule<{
|
|
169
|
-
resolveAgentTimeoutMs: CoreAgentDeps["resolveAgentTimeoutMs"];
|
|
170
|
-
}>("agents/timeout.js"),
|
|
171
|
-
importCoreModule<{
|
|
172
|
-
ensureAgentWorkspace: CoreAgentDeps["ensureAgentWorkspace"];
|
|
173
|
-
}>("agents/workspace.js"),
|
|
174
|
-
importCoreModule<{
|
|
175
|
-
resolveStorePath: CoreAgentDeps["resolveStorePath"];
|
|
176
|
-
loadSessionStore: CoreAgentDeps["loadSessionStore"];
|
|
177
|
-
saveSessionStore: CoreAgentDeps["saveSessionStore"];
|
|
178
|
-
resolveSessionFilePath: CoreAgentDeps["resolveSessionFilePath"];
|
|
179
|
-
}>("config/sessions.js"),
|
|
180
|
-
]);
|
|
181
|
-
|
|
182
|
-
return {
|
|
183
|
-
resolveAgentDir: agentScope.resolveAgentDir,
|
|
184
|
-
resolveAgentWorkspaceDir: agentScope.resolveAgentWorkspaceDir,
|
|
185
|
-
resolveAgentIdentity: identity.resolveAgentIdentity,
|
|
186
|
-
resolveThinkingDefault: modelSelection.resolveThinkingDefault,
|
|
187
|
-
runEmbeddedPiAgent: piEmbedded.runEmbeddedPiAgent,
|
|
188
|
-
resolveAgentTimeoutMs: timeout.resolveAgentTimeoutMs,
|
|
189
|
-
ensureAgentWorkspace: workspace.ensureAgentWorkspace,
|
|
190
|
-
resolveStorePath: sessions.resolveStorePath,
|
|
191
|
-
loadSessionStore: sessions.loadSessionStore,
|
|
192
|
-
saveSessionStore: sessions.saveSessionStore,
|
|
193
|
-
resolveSessionFilePath: sessions.resolveSessionFilePath,
|
|
194
|
-
DEFAULT_MODEL: defaults.DEFAULT_MODEL,
|
|
195
|
-
DEFAULT_PROVIDER: defaults.DEFAULT_PROVIDER,
|
|
196
|
-
};
|
|
155
|
+
return await importCoreExtensionAPI();
|
|
197
156
|
})();
|
|
198
157
|
|
|
199
158
|
return coreDepsPromise;
|
package/src/manager/events.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import type { CallRecord, CallState, NormalizedEvent } from "../types.js";
|
|
3
3
|
import type { CallManagerContext } from "./context.js";
|
|
4
|
+
import { isAllowlistedCaller, normalizePhoneNumber } from "../allowlist.js";
|
|
4
5
|
import { findCall } from "./lookup.js";
|
|
5
6
|
import { endCall } from "./outbound.js";
|
|
6
7
|
import { addTranscriptEntry, transitionState } from "./state.js";
|
|
@@ -29,11 +30,12 @@ function shouldAcceptInbound(
|
|
|
29
30
|
|
|
30
31
|
case "allowlist":
|
|
31
32
|
case "pairing": {
|
|
32
|
-
const normalized = from
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return
|
|
36
|
-
}
|
|
33
|
+
const normalized = normalizePhoneNumber(from);
|
|
34
|
+
if (!normalized) {
|
|
35
|
+
console.log("[voice-call] Inbound call rejected: missing caller ID");
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
const allowed = isAllowlistedCaller(normalized, allowFrom);
|
|
37
39
|
const status = allowed ? "accepted" : "rejected";
|
|
38
40
|
console.log(
|
|
39
41
|
`[voice-call] Inbound call ${status}: ${from} ${allowed ? "is in" : "not in"} allowlist`,
|
package/src/manager.test.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { CallManager } from "./manager.js";
|
|
|
19
19
|
class FakeProvider implements VoiceCallProvider {
|
|
20
20
|
readonly name = "plivo" as const;
|
|
21
21
|
readonly playTtsCalls: PlayTtsInput[] = [];
|
|
22
|
+
readonly hangupCalls: HangupCallInput[] = [];
|
|
22
23
|
|
|
23
24
|
verifyWebhook(_ctx: WebhookContext): WebhookVerificationResult {
|
|
24
25
|
return { ok: true };
|
|
@@ -29,7 +30,9 @@ class FakeProvider implements VoiceCallProvider {
|
|
|
29
30
|
async initiateCall(_input: InitiateCallInput): Promise<InitiateCallResult> {
|
|
30
31
|
return { providerCallId: "request-uuid", status: "initiated" };
|
|
31
32
|
}
|
|
32
|
-
async hangupCall(
|
|
33
|
+
async hangupCall(input: HangupCallInput): Promise<void> {
|
|
34
|
+
this.hangupCalls.push(input);
|
|
35
|
+
}
|
|
33
36
|
async playTts(input: PlayTtsInput): Promise<void> {
|
|
34
37
|
this.playTtsCalls.push(input);
|
|
35
38
|
}
|
|
@@ -102,4 +105,120 @@ describe("CallManager", () => {
|
|
|
102
105
|
expect(provider.playTtsCalls).toHaveLength(1);
|
|
103
106
|
expect(provider.playTtsCalls[0]?.text).toBe("Hello there");
|
|
104
107
|
});
|
|
108
|
+
|
|
109
|
+
it("rejects inbound calls with missing caller ID when allowlist enabled", () => {
|
|
110
|
+
const config = VoiceCallConfigSchema.parse({
|
|
111
|
+
enabled: true,
|
|
112
|
+
provider: "plivo",
|
|
113
|
+
fromNumber: "+15550000000",
|
|
114
|
+
inboundPolicy: "allowlist",
|
|
115
|
+
allowFrom: ["+15550001234"],
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
|
|
119
|
+
const provider = new FakeProvider();
|
|
120
|
+
const manager = new CallManager(config, storePath);
|
|
121
|
+
manager.initialize(provider, "https://example.com/voice/webhook");
|
|
122
|
+
|
|
123
|
+
manager.processEvent({
|
|
124
|
+
id: "evt-allowlist-missing",
|
|
125
|
+
type: "call.initiated",
|
|
126
|
+
callId: "call-missing",
|
|
127
|
+
providerCallId: "provider-missing",
|
|
128
|
+
timestamp: Date.now(),
|
|
129
|
+
direction: "inbound",
|
|
130
|
+
to: "+15550000000",
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(manager.getCallByProviderCallId("provider-missing")).toBeUndefined();
|
|
134
|
+
expect(provider.hangupCalls).toHaveLength(1);
|
|
135
|
+
expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-missing");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("rejects inbound calls with anonymous caller ID when allowlist enabled", () => {
|
|
139
|
+
const config = VoiceCallConfigSchema.parse({
|
|
140
|
+
enabled: true,
|
|
141
|
+
provider: "plivo",
|
|
142
|
+
fromNumber: "+15550000000",
|
|
143
|
+
inboundPolicy: "allowlist",
|
|
144
|
+
allowFrom: ["+15550001234"],
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
|
|
148
|
+
const provider = new FakeProvider();
|
|
149
|
+
const manager = new CallManager(config, storePath);
|
|
150
|
+
manager.initialize(provider, "https://example.com/voice/webhook");
|
|
151
|
+
|
|
152
|
+
manager.processEvent({
|
|
153
|
+
id: "evt-allowlist-anon",
|
|
154
|
+
type: "call.initiated",
|
|
155
|
+
callId: "call-anon",
|
|
156
|
+
providerCallId: "provider-anon",
|
|
157
|
+
timestamp: Date.now(),
|
|
158
|
+
direction: "inbound",
|
|
159
|
+
from: "anonymous",
|
|
160
|
+
to: "+15550000000",
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(manager.getCallByProviderCallId("provider-anon")).toBeUndefined();
|
|
164
|
+
expect(provider.hangupCalls).toHaveLength(1);
|
|
165
|
+
expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-anon");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("rejects inbound calls that only match allowlist suffixes", () => {
|
|
169
|
+
const config = VoiceCallConfigSchema.parse({
|
|
170
|
+
enabled: true,
|
|
171
|
+
provider: "plivo",
|
|
172
|
+
fromNumber: "+15550000000",
|
|
173
|
+
inboundPolicy: "allowlist",
|
|
174
|
+
allowFrom: ["+15550001234"],
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
|
|
178
|
+
const provider = new FakeProvider();
|
|
179
|
+
const manager = new CallManager(config, storePath);
|
|
180
|
+
manager.initialize(provider, "https://example.com/voice/webhook");
|
|
181
|
+
|
|
182
|
+
manager.processEvent({
|
|
183
|
+
id: "evt-allowlist-suffix",
|
|
184
|
+
type: "call.initiated",
|
|
185
|
+
callId: "call-suffix",
|
|
186
|
+
providerCallId: "provider-suffix",
|
|
187
|
+
timestamp: Date.now(),
|
|
188
|
+
direction: "inbound",
|
|
189
|
+
from: "+99915550001234",
|
|
190
|
+
to: "+15550000000",
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(manager.getCallByProviderCallId("provider-suffix")).toBeUndefined();
|
|
194
|
+
expect(provider.hangupCalls).toHaveLength(1);
|
|
195
|
+
expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-suffix");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("accepts inbound calls that exactly match the allowlist", () => {
|
|
199
|
+
const config = VoiceCallConfigSchema.parse({
|
|
200
|
+
enabled: true,
|
|
201
|
+
provider: "plivo",
|
|
202
|
+
fromNumber: "+15550000000",
|
|
203
|
+
inboundPolicy: "allowlist",
|
|
204
|
+
allowFrom: ["+15550001234"],
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
|
|
208
|
+
const manager = new CallManager(config, storePath);
|
|
209
|
+
manager.initialize(new FakeProvider(), "https://example.com/voice/webhook");
|
|
210
|
+
|
|
211
|
+
manager.processEvent({
|
|
212
|
+
id: "evt-allowlist-exact",
|
|
213
|
+
type: "call.initiated",
|
|
214
|
+
callId: "call-exact",
|
|
215
|
+
providerCallId: "provider-exact",
|
|
216
|
+
timestamp: Date.now(),
|
|
217
|
+
direction: "inbound",
|
|
218
|
+
from: "+15550001234",
|
|
219
|
+
to: "+15550000000",
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(manager.getCallByProviderCallId("provider-exact")).toBeDefined();
|
|
223
|
+
});
|
|
105
224
|
});
|
package/src/manager.ts
CHANGED
|
@@ -5,6 +5,7 @@ import os from "node:os";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import type { CallMode, VoiceCallConfig } from "./config.js";
|
|
7
7
|
import type { VoiceCallProvider } from "./providers/base.js";
|
|
8
|
+
import { isAllowlistedCaller, normalizePhoneNumber } from "./allowlist.js";
|
|
8
9
|
import {
|
|
9
10
|
type CallId,
|
|
10
11
|
type CallRecord,
|
|
@@ -474,11 +475,12 @@ export class CallManager {
|
|
|
474
475
|
|
|
475
476
|
case "allowlist":
|
|
476
477
|
case "pairing": {
|
|
477
|
-
const normalized = from
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
return
|
|
481
|
-
}
|
|
478
|
+
const normalized = normalizePhoneNumber(from);
|
|
479
|
+
if (!normalized) {
|
|
480
|
+
console.log("[voice-call] Inbound call rejected: missing caller ID");
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
const allowed = isAllowlistedCaller(normalized, allowFrom);
|
|
482
484
|
const status = allowed ? "accepted" : "rejected";
|
|
483
485
|
console.log(
|
|
484
486
|
`[voice-call] Inbound call ${status}: ${from} ${allowed ? "is in" : "not in"} allowlist`,
|
|
@@ -551,7 +553,7 @@ export class CallManager {
|
|
|
551
553
|
if (!call && event.direction === "inbound" && event.providerCallId) {
|
|
552
554
|
// Check if we should accept this inbound call
|
|
553
555
|
if (!this.shouldAcceptInbound(event.from)) {
|
|
554
|
-
|
|
556
|
+
void this.rejectInboundCall(event);
|
|
555
557
|
return;
|
|
556
558
|
}
|
|
557
559
|
|
|
@@ -653,6 +655,25 @@ export class CallManager {
|
|
|
653
655
|
this.persistCallRecord(call);
|
|
654
656
|
}
|
|
655
657
|
|
|
658
|
+
private async rejectInboundCall(event: NormalizedEvent): Promise<void> {
|
|
659
|
+
if (!this.provider || !event.providerCallId) {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const callId = event.callId || event.providerCallId;
|
|
663
|
+
try {
|
|
664
|
+
await this.provider.hangupCall({
|
|
665
|
+
callId,
|
|
666
|
+
providerCallId: event.providerCallId,
|
|
667
|
+
reason: "hangup-bot",
|
|
668
|
+
});
|
|
669
|
+
} catch (err) {
|
|
670
|
+
console.warn(
|
|
671
|
+
`[voice-call] Failed to reject inbound call ${event.providerCallId}:`,
|
|
672
|
+
err instanceof Error ? err.message : err,
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
656
677
|
private maybeSpeakInitialMessageOnAnswered(call: CallRecord): void {
|
|
657
678
|
const initialMessage =
|
|
658
679
|
typeof call.metadata?.initialMessage === "string" ? call.metadata.initialMessage.trim() : "";
|
package/src/media-stream.ts
CHANGED
|
@@ -21,6 +21,8 @@ import type {
|
|
|
21
21
|
export interface MediaStreamConfig {
|
|
22
22
|
/** STT provider for transcription */
|
|
23
23
|
sttProvider: OpenAIRealtimeSTTProvider;
|
|
24
|
+
/** Validate whether to accept a media stream for the given call ID */
|
|
25
|
+
shouldAcceptStream?: (params: { callId: string; streamSid: string; token?: string }) => boolean;
|
|
24
26
|
/** Callback when transcript is received */
|
|
25
27
|
onTranscript?: (callId: string, transcript: string) => void;
|
|
26
28
|
/** Callback for partial transcripts (streaming UI) */
|
|
@@ -87,6 +89,7 @@ export class MediaStreamHandler {
|
|
|
87
89
|
*/
|
|
88
90
|
private async handleConnection(ws: WebSocket, _request: IncomingMessage): Promise<void> {
|
|
89
91
|
let session: StreamSession | null = null;
|
|
92
|
+
const streamToken = this.getStreamToken(_request);
|
|
90
93
|
|
|
91
94
|
ws.on("message", async (data: Buffer) => {
|
|
92
95
|
try {
|
|
@@ -98,7 +101,7 @@ export class MediaStreamHandler {
|
|
|
98
101
|
break;
|
|
99
102
|
|
|
100
103
|
case "start":
|
|
101
|
-
session = await this.handleStart(ws, message);
|
|
104
|
+
session = await this.handleStart(ws, message, streamToken);
|
|
102
105
|
break;
|
|
103
106
|
|
|
104
107
|
case "media":
|
|
@@ -135,11 +138,28 @@ export class MediaStreamHandler {
|
|
|
135
138
|
/**
|
|
136
139
|
* Handle stream start event.
|
|
137
140
|
*/
|
|
138
|
-
private async handleStart(
|
|
141
|
+
private async handleStart(
|
|
142
|
+
ws: WebSocket,
|
|
143
|
+
message: TwilioMediaMessage,
|
|
144
|
+
streamToken?: string,
|
|
145
|
+
): Promise<StreamSession | null> {
|
|
139
146
|
const streamSid = message.streamSid || "";
|
|
140
147
|
const callSid = message.start?.callSid || "";
|
|
141
148
|
|
|
142
149
|
console.log(`[MediaStream] Stream started: ${streamSid} (call: ${callSid})`);
|
|
150
|
+
if (!callSid) {
|
|
151
|
+
console.warn("[MediaStream] Missing callSid; closing stream");
|
|
152
|
+
ws.close(1008, "Missing callSid");
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
if (
|
|
156
|
+
this.config.shouldAcceptStream &&
|
|
157
|
+
!this.config.shouldAcceptStream({ callId: callSid, streamSid, token: streamToken })
|
|
158
|
+
) {
|
|
159
|
+
console.warn(`[MediaStream] Rejecting stream for unknown call: ${callSid}`);
|
|
160
|
+
ws.close(1008, "Unknown call");
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
143
163
|
|
|
144
164
|
// Create STT session
|
|
145
165
|
const sttSession = this.config.sttProvider.createSession();
|
|
@@ -189,6 +209,18 @@ export class MediaStreamHandler {
|
|
|
189
209
|
this.config.onDisconnect?.(session.callId);
|
|
190
210
|
}
|
|
191
211
|
|
|
212
|
+
private getStreamToken(request: IncomingMessage): string | undefined {
|
|
213
|
+
if (!request.url || !request.headers.host) {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
const url = new URL(request.url, `http://${request.headers.host}`);
|
|
218
|
+
return url.searchParams.get("token") ?? undefined;
|
|
219
|
+
} catch {
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
192
224
|
/**
|
|
193
225
|
* Get an active session with an open WebSocket, or undefined if unavailable.
|
|
194
226
|
*/
|
package/src/providers/plivo.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
import type { PlivoConfig } from "../config.js";
|
|
2
|
+
import type { PlivoConfig, WebhookSecurityConfig } from "../config.js";
|
|
3
3
|
import type {
|
|
4
4
|
HangupCallInput,
|
|
5
5
|
InitiateCallInput,
|
|
@@ -23,6 +23,8 @@ export interface PlivoProviderOptions {
|
|
|
23
23
|
skipVerification?: boolean;
|
|
24
24
|
/** Outbound ring timeout in seconds */
|
|
25
25
|
ringTimeoutSec?: number;
|
|
26
|
+
/** Webhook security options (forwarded headers/allowlist) */
|
|
27
|
+
webhookSecurity?: WebhookSecurityConfig;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
type PendingSpeak = { text: string; locale?: string };
|
|
@@ -92,6 +94,10 @@ export class PlivoProvider implements VoiceCallProvider {
|
|
|
92
94
|
const result = verifyPlivoWebhook(ctx, this.authToken, {
|
|
93
95
|
publicUrl: this.options.publicUrl,
|
|
94
96
|
skipVerification: this.options.skipVerification,
|
|
97
|
+
allowedHosts: this.options.webhookSecurity?.allowedHosts,
|
|
98
|
+
trustForwardingHeaders: this.options.webhookSecurity?.trustForwardingHeaders,
|
|
99
|
+
trustedProxyIPs: this.options.webhookSecurity?.trustedProxyIPs,
|
|
100
|
+
remoteIP: ctx.remoteAddress,
|
|
95
101
|
});
|
|
96
102
|
|
|
97
103
|
if (!result.ok) {
|
|
@@ -112,7 +118,7 @@ export class PlivoProvider implements VoiceCallProvider {
|
|
|
112
118
|
// Keep providerCallId mapping for later call control.
|
|
113
119
|
const callUuid = parsed.get("CallUUID") || undefined;
|
|
114
120
|
if (callUuid) {
|
|
115
|
-
const webhookBase =
|
|
121
|
+
const webhookBase = this.baseWebhookUrlFromCtx(ctx);
|
|
116
122
|
if (webhookBase) {
|
|
117
123
|
this.callUuidToWebhookUrl.set(callUuid, webhookBase);
|
|
118
124
|
}
|
|
@@ -444,7 +450,7 @@ export class PlivoProvider implements VoiceCallProvider {
|
|
|
444
450
|
ctx: WebhookContext,
|
|
445
451
|
opts: { flow: string; callId?: string },
|
|
446
452
|
): string | null {
|
|
447
|
-
const base =
|
|
453
|
+
const base = this.baseWebhookUrlFromCtx(ctx);
|
|
448
454
|
if (!base) {
|
|
449
455
|
return null;
|
|
450
456
|
}
|
|
@@ -458,9 +464,16 @@ export class PlivoProvider implements VoiceCallProvider {
|
|
|
458
464
|
return u.toString();
|
|
459
465
|
}
|
|
460
466
|
|
|
461
|
-
private
|
|
467
|
+
private baseWebhookUrlFromCtx(ctx: WebhookContext): string | null {
|
|
462
468
|
try {
|
|
463
|
-
const u = new URL(
|
|
469
|
+
const u = new URL(
|
|
470
|
+
reconstructWebhookUrl(ctx, {
|
|
471
|
+
allowedHosts: this.options.webhookSecurity?.allowedHosts,
|
|
472
|
+
trustForwardingHeaders: this.options.webhookSecurity?.trustForwardingHeaders,
|
|
473
|
+
trustedProxyIPs: this.options.webhookSecurity?.trustedProxyIPs,
|
|
474
|
+
remoteIP: ctx.remoteAddress,
|
|
475
|
+
}),
|
|
476
|
+
);
|
|
464
477
|
return `${u.origin}${u.pathname}`;
|
|
465
478
|
} catch {
|
|
466
479
|
return null;
|