@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 CHANGED
@@ -1,6 +1,12 @@
1
1
  # Changelog
2
2
 
3
- ## 2026.2.1
3
+ ## 2026.2.3
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.2.2
4
10
 
5
11
  ### Changes
6
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/voice-call",
3
- "version": "2026.2.1",
3
+ "version": "2026.2.3",
4
4
  "description": "OpenClaw voice-call plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -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
+ }
@@ -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 || resolved.tunnel.allowNgrokFreeTier || false;
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") {
@@ -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 importCoreModule<T>(relativePath: string): Promise<T> {
125
- const root = resolveOpenClawRoot();
126
- const distPath = path.join(root, "dist", relativePath);
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 (await import(pathToFileURL(distPath).href)) as T;
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
- const [
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;
@@ -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?.replace(/\D/g, "") || "";
33
- const allowed = (allowFrom || []).some((num) => {
34
- const normalizedAllow = num.replace(/\D/g, "");
35
- return normalized.endsWith(normalizedAllow) || normalizedAllow.endsWith(normalized);
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`,
@@ -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(_input: HangupCallInput): Promise<void> {}
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?.replace(/\D/g, "") || "";
478
- const allowed = (allowFrom || []).some((num) => {
479
- const normalizedAllow = num.replace(/\D/g, "");
480
- return normalized.endsWith(normalizedAllow) || normalizedAllow.endsWith(normalized);
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
- // TODO: Could hang up the call here
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() : "";
@@ -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(ws: WebSocket, message: TwilioMediaMessage): Promise<StreamSession> {
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
  */
@@ -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 = PlivoProvider.baseWebhookUrlFromCtx(ctx);
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 = PlivoProvider.baseWebhookUrlFromCtx(ctx);
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 static baseWebhookUrlFromCtx(ctx: WebhookContext): string | null {
467
+ private baseWebhookUrlFromCtx(ctx: WebhookContext): string | null {
462
468
  try {
463
- const u = new URL(reconstructWebhookUrl(ctx));
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;