Read the voice-call plugin source code at /home/ccuser/.clawdbot/extensions/voice-call/index.ts (or index.js). I need to understand:
1. How it handles the webhook request
2. What TwiML it returns
3. How Twilio signature verification works - specifically what URL it validates against
4. How media streaming works when enabled
5. How TTS is applied (ElevenLabs vs Say fallback)

Also check for any other .ts or .js files in /home/ccuser/.clawdbot/extensions/voice-call/ that might contain the webhook handler logic.

Report the relevant code sections.


I'll start by exploring the voice-call extension directory structure and then read the relevant source files.


Bash

find /home/ccuser/.clawdbot/extensions/voice-call -type f -name "*.ts" -o -name "*.js" | head -20


Let me look for the main source files specifically in the root of the voice-call extension, excluding node_modules:


ls -la /home/ccuser/.clawdbot/extensions/voice-call/ | grep -E "\.(ts|js)$"


-rw-r--r-- 1 ccuser ccuser 17738 Feb 11 18:22 index.ts


Perfect. Now let me read the main index.ts file:


     1→import { Type } from "@sinclair/typebox";
     2→
     3→import type { CoreConfig } from "./src/core-bridge.js";
     4→import {
     5→  VoiceCallConfigSchema,
     6→  validateProviderConfig,
     7→  type VoiceCallConfig,
     8→} from "./src/config.js";
     9→import { registerVoiceCallCli } from "./src/cli.js";
    10→import { createVoiceCallRuntime, type VoiceCallRuntime } from "./src/runtime.js";
    11→
    12→const voiceCallConfigSchema = {
    13→  parse(value: unknown): VoiceCallConfig {
    14→    const raw =
    15→      value && typeof value === "object" && !Array.isArray(value)
    16→        ? (value as Record<string, unknown>)
    17→        : {};
    18→
    19→    const twilio = raw.twilio as Record<string, unknown> | undefined;
    20→    const legacyFrom = typeof twilio?.from === "string" ? twilio.from : undefined;
    21→
    22→    const enabled = typeof raw.enabled === "boolean" ? raw.enabled : true;
    23→    const providerRaw = raw.provider === "log" ? "mock" : raw.provider;
    24→    const provider = providerRaw ?? (enabled ? "mock" : undefined);
    25→
    26→    return VoiceCallConfigSchema.parse({
    27→      ...raw,
    28→      enabled,
    29→      provider,
    30→      fromNumber: raw.fromNumber ?? legacyFrom,
    31→    });
    32→  },
    33→  uiHints: {
    34→    provider: {
    35→      label: "Provider",
    36→      help: "Use twilio, telnyx, or mock for dev/no-network.",
    37→    },
    38→    fromNumber: { label: "From Number", placeholder: "+15550001234" },
    39→    toNumber: { label: "Default To Number", placeholder: "+15550001234" },
    40→    inboundPolicy: { label: "Inbound Policy" },
    41→    allowFrom: { label: "Inbound Allowlist" },
    42→    inboundGreeting: { label: "Inbound Greeting", advanced: true },
    43→    "telnyx.apiKey": { label: "Telnyx API Key", sensitive: true },
    44→    "telnyx.connectionId": { label: "Telnyx Connection ID" },
    45→    "telnyx.publicKey": { label: "Telnyx Public Key", sensitive: true },
    46→    "twilio.accountSid": { label: "Twilio Account SID" },
    47→    "twilio.authToken": { label: "Twilio Auth Token", sensitive: true },
    48→    "outbound.defaultMode": { label: "Default Call Mode" },
    49→    "outbound.notifyHangupDelaySec": {
    50→      label: "Notify Hangup Delay (sec)",
    51→      advanced: true,
    52→    },
    53→    "serve.port": { label: "Webhook Port" },
    54→    "serve.bind": { label: "Webhook Bind" },
    55→    "serve.path": { label: "Webhook Path" },
    56→    "tailscale.mode": { label: "Tailscale Mode", advanced: true },
    57→    "tailscale.path": { label: "Tailscale Path", advanced: true },
    58→    "tunnel.provider": { label: "Tunnel Provider", advanced: true },
    59→    "tunnel.ngrokAuthToken": {
    60→      label: "ngrok Auth Token",
    61→      sensitive: true,
    62→      advanced: true,
    63→    },
    64→    "tunnel.ngrokDomain": { label: "ngrok Domain", advanced: true },
    65→    "tunnel.allowNgrokFreeTier": {
    66→      label: "Allow ngrok Free Tier",
    67→      advanced: true,
    68→    },
    69→    "streaming.enabled": { label: "Enable Streaming", advanced: true },
    70→    "streaming.openaiApiKey": {
    71→      label: "OpenAI Realtime API Key",
    72→      sensitive: true,
    73→      advanced: true,
    74→    },
    75→    "streaming.sttModel": { label: "Realtime STT Model", advanced: true },
    76→    "streaming.streamPath": { label: "Media Stream Path", advanced: true },
    77→    "tts.provider": {
    78→      label: "TTS Provider Override",
    79→      help: "Deep-merges with messages.tts (Edge is ignored for calls).",
    80→      advanced: true,
    81→    },
    82→    "tts.openai.model": { label: "OpenAI TTS Model", advanced: true },
    83→    "tts.openai.voice": { label: "OpenAI TTS Voice", advanced: true },
    84→    "tts.openai.apiKey": {
    85→      label: "OpenAI API Key",
    86→      sensitive: true,
    87→      advanced: true,
    88→    },
    89→    "tts.elevenlabs.modelId": { label: "ElevenLabs Model ID", advanced: true },
    90→    "tts.elevenlabs.voiceId": { label: "ElevenLabs Voice ID", advanced: true },
    91→    "tts.elevenlabs.apiKey": {
    92→      label: "ElevenLabs API Key",
    93→      sensitive: true,
    94→      advanced: true,
    95→    },
    96→    "tts.elevenlabs.baseUrl": { label: "ElevenLabs Base URL", advanced: true },
    97→    publicUrl: { label: "Public Webhook URL", advanced: true },
    98→    skipSignatureVerification: {
    99→      label: "Skip Signature Verification",
   100→      advanced: true,
   101→    },
   102→    store: { label: "Call Log Store Path", advanced: true },
   103→    responseModel: { label: "Response Model", advanced: true },
   104→    responseSystemPrompt: { label: "Response System Prompt", advanced: true },
   105→    responseTimeoutMs: { label: "Response Timeout (ms)", advanced: true },
   106→  },
   107→};
   108→
   109→const VoiceCallToolSchema = Type.Union([
   110→  Type.Object({
   111→    action: Type.Literal("initiate_call"),
   112→    to: Type.Optional(Type.String({ description: "Call target" })),
   113→    message: Type.String({ description: "Intro message" }),
   114→    mode: Type.Optional(Type.Union([Type.Literal("notify"), Type.Literal("conversation")])),
   115→  }),
   116→  Type.Object({
   117→    action: Type.Literal("continue_call"),
   118→    callId: Type.String({ description: "Call ID" }),
   119→    message: Type.String({ description: "Follow-up message" }),
   120→  }),
   121→  Type.Object({
   122→    action: Type.Literal("speak_to_user"),
   123→    callId: Type.String({ description: "Call ID" }),
   124→    message: Type.String({ description: "Message to speak" }),
   125→  }),
   126→  Type.Object({
   127→    action: Type.Literal("end_call"),
   128→    callId: Type.String({ description: "Call ID" }),
   129→  }),
   130→  Type.Object({
   131→    action: Type.Literal("get_status"),
   132→    callId: Type.String({ description: "Call ID" }),
   133→  }),
   134→  Type.Object({
   135→    mode: Type.Optional(Type.Union([Type.Literal("call"), Type.Literal("status")])),
   136→    to: Type.Optional(Type.String({ description: "Call target" })),
   137→    sid: Type.Optional(Type.String({ description: "Call SID" })),
   138→    message: Type.Optional(Type.String({ description: "Optional intro message" })),
   139→  }),
   140→]);
   141→
   142→const voiceCallPlugin = {
   143→  id: "voice-call",
   144→  name: "Voice Call",
   145→  description: "Voice-call plugin with Telnyx/Twilio/Plivo providers",
   146→  configSchema: voiceCallConfigSchema,
   147→  register(api) {
   148→    const cfg = voiceCallConfigSchema.parse(api.pluginConfig);
   149→    const validation = validateProviderConfig(cfg);
   150→
   151→    if (api.pluginConfig && typeof api.pluginConfig === "object") {
   152→      const raw = api.pluginConfig as Record<string, unknown>;
   153→      const twilio = raw.twilio as Record<string, unknown> | undefined;
   154→      if (raw.provider === "log") {
   155→        api.logger.warn(
   156→          "[voice-call] provider \"log\" is deprecated; use \"mock\" instead",
   157→        );
   158→      }
   159→      if (typeof twilio?.from === "string") {
   160→        api.logger.warn(
   161→          "[voice-call] twilio.from is deprecated; use fromNumber instead",
   162→        );
   163→      }
   164→    }
   165→
   166→    let runtimePromise: Promise<VoiceCallRuntime> | null = null;
   167→    let runtime: VoiceCallRuntime | null = null;
   168→
   169→    const ensureRuntime = async () => {
   170→      if (!cfg.enabled) {
   171→        throw new Error("Voice call disabled in plugin config");
   172→      }
   173→      if (!validation.valid) {
   174→        throw new Error(validation.errors.join("; "));
   175→      }
   176→      if (runtime) return runtime;
   177→      if (!runtimePromise) {
   178→        runtimePromise = createVoiceCallRuntime({
   179→          config: cfg,
   180→          coreConfig: api.config as CoreConfig,
   181→          ttsRuntime: api.runtime.tts,
   182→          logger: api.logger,
   183→        });
   184→      }
   185→      runtime = await runtimePromise;
   186→      return runtime;
   187→    };
   188→
   189→    const sendError = (respond: (ok: boolean, payload?: unknown) => void, err: unknown) => {
   190→      respond(false, { error: err instanceof Error ? err.message : String(err) });
   191→    };
   192→
   193→    api.registerGatewayMethod("voicecall.initiate", async ({ params, respond }) => {
   194→      try {
   195→        const message =
   196→          typeof params?.message === "string" ? params.message.trim() : "";
   197→        if (!message) {
   198→          respond(false, { error: "message required" });
   199→          return;
   200→        }
   201→        const rt = await ensureRuntime();
   202→        const to =
   203→          typeof params?.to === "string" && params.to.trim()
   204→            ? params.to.trim()
   205→            : rt.config.toNumber;
   206→        if (!to) {
   207→          respond(false, { error: "to required" });
   208→          return;
   209→        }
   210→        const mode =
   211→          params?.mode === "notify" || params?.mode === "conversation"
   212→            ? params.mode
   213→            : undefined;
   214→        const result = await rt.manager.initiateCall(to, undefined, {
   215→          message,
   216→          mode,
   217→        });
   218→        if (!result.success) {
   219→          respond(false, { error: result.error || "initiate failed" });
   220→          return;
   221→        }
   222→        respond(true, { callId: result.callId, initiated: true });
   223→      } catch (err) {
   224→        sendError(respond, err);
   225→      }
   226→    });
   227→
   228→    api.registerGatewayMethod("voicecall.continue", async ({ params, respond }) => {
   229→      try {
   230→        const callId =
   231→          typeof params?.callId === "string" ? params.callId.trim() : "";
   232→        const message =
   233→          typeof params?.message === "string" ? params.message.trim() : "";
   234→        if (!callId || !message) {
   235→          respond(false, { error: "callId and message required" });
   236→          return;
   237→        }
   238→        const rt = await ensureRuntime();
   239→        const result = await rt.manager.continueCall(callId, message);
   240→        if (!result.success) {
   241→          respond(false, { error: result.error || "continue failed" });
   242→          return;
   243→        }
   244→        respond(true, { success: true, transcript: result.transcript });
   245→      } catch (err) {
   246→        sendError(respond, err);
   247→      }
   248→    });
   249→
   250→    api.registerGatewayMethod("voicecall.speak", async ({ params, respond }) => {
   251→      try {
   252→        const callId =
   253→          typeof params?.callId === "string" ? params.callId.trim() : "";
   254→        const message =
   255→          typeof params?.message === "string" ? params.message.trim() : "";
   256→        if (!callId || !message) {
   257→          respond(false, { error: "callId and message required" });
   258→          return;
   259→        }
   260→        const rt = await ensureRuntime();
   261→        const result = await rt.manager.speak(callId, message);
   262→        if (!result.success) {
   263→          respond(false, { error: result.error || "speak failed" });
   264→          return;
   265→        }
   266→        respond(true, { success: true });
   267→      } catch (err) {
   268→        sendError(respond, err);
   269→      }
   270→    });
   271→
   272→    api.registerGatewayMethod("voicecall.end", async ({ params, respond }) => {
   273→      try {
   274→        const callId =
   275→          typeof params?.callId === "string" ? params.callId.trim() : "";
   276→        if (!callId) {
   277→          respond(false, { error: "callId required" });
   278→          return;
   279→        }
   280→        const rt = await ensureRuntime();
   281→        const result = await rt.manager.endCall(callId);
   282→        if (!result.success) {
   283→          respond(false, { error: result.error || "end failed" });
   284→          return;
   285→        }
   286→        respond(true, { success: true });
   287→      } catch (err) {
   288→        sendError(respond, err);
   289→      }
   290→    });
   291→
   292→    api.registerGatewayMethod("voicecall.status", async ({ params, respond }) => {
   293→      try {
   294→        const raw =
   295→          typeof params?.callId === "string"
   296→            ? params.callId.trim()
   297→            : typeof params?.sid === "string"
   298→              ? params.sid.trim()
   299→              : "";
   300→        if (!raw) {
   301→          respond(false, { error: "callId required" });
   302→          return;
   303→        }
   304→        const rt = await ensureRuntime();
   305→        const call =
   306→          rt.manager.getCall(raw) || rt.manager.getCallByProviderCallId(raw);
   307→        if (!call) {
   308→          respond(true, { found: false });
   309→          return;
   310→        }
   311→        respond(true, { found: true, call });
   312→      } catch (err) {
   313→        sendError(respond, err);
   314→      }
   315→    });
   316→
   317→    api.registerGatewayMethod("voicecall.start", async ({ params, respond }) => {
   318→      try {
   319→        const to = typeof params?.to === "string" ? params.to.trim() : "";
   320→        const message =
   321→          typeof params?.message === "string" ? params.message.trim() : "";
   322→        if (!to) {
   323→          respond(false, { error: "to required" });
   324→          return;
   325→        }
   326→        const rt = await ensureRuntime();
   327→        const result = await rt.manager.initiateCall(to, undefined, {
   328→          message: message || undefined,
   329→        });
   330→        if (!result.success) {
   331→          respond(false, { error: result.error || "initiate failed" });
   332→          return;
   333→        }
   334→        respond(true, { callId: result.callId, initiated: true });
   335→      } catch (err) {
   336→        sendError(respond, err);
   337→      }
   338→    });
   339→
   340→    api.registerTool({
   341→      name: "voice_call",
   342→      label: "Voice Call",
   343→      description:
   344→        "Make phone calls and have voice conversations via the voice-call plugin.",
   345→      parameters: VoiceCallToolSchema,
   346→      async execute(_toolCallId, params) {
   347→        const json = (payload: unknown) => ({
   348→          content: [
   349→            { type: "text", text: JSON.stringify(payload, null, 2) },
   350→          ],
   351→          details: payload,
   352→        });
   353→
   354→        try {
   355→          const rt = await ensureRuntime();
   356→
   357→          if (typeof params?.action === "string") {
   358→            switch (params.action) {
   359→              case "initiate_call": {
   360→                const message = String(params.message || "").trim();
   361→                if (!message) throw new Error("message required");
   362→                const to =
   363→                  typeof params.to === "string" && params.to.trim()
   364→                    ? params.to.trim()
   365→                    : rt.config.toNumber;
   366→                if (!to) throw new Error("to required");
   367→                const result = await rt.manager.initiateCall(to, undefined, {
   368→                  message,
   369→                  mode:
   370→                    params.mode === "notify" || params.mode === "conversation"
   371→                      ? params.mode
   372→                      : undefined,
   373→                });
   374→                if (!result.success) {
   375→                  throw new Error(result.error || "initiate failed");
   376→                }
   377→                return json({ callId: result.callId, initiated: true });
   378→              }
   379→              case "continue_call": {
   380→                const callId = String(params.callId || "").trim();
   381→                const message = String(params.message || "").trim();
   382→                if (!callId || !message) {
   383→                  throw new Error("callId and message required");
   384→                }
   385→                const result = await rt.manager.continueCall(callId, message);
   386→                if (!result.success) {
   387→                  throw new Error(result.error || "continue failed");
   388→                }
   389→                return json({ success: true, transcript: result.transcript });
   390→              }
   391→              case "speak_to_user": {
   392→                const callId = String(params.callId || "").trim();
   393→                const message = String(params.message || "").trim();
   394→                if (!callId || !message) {
   395→                  throw new Error("callId and message required");
   396→                }
   397→                const result = await rt.manager.speak(callId, message);
   398→                if (!result.success) {
   399→                  throw new Error(result.error || "speak failed");
   400→                }
   401→                return json({ success: true });
   402→              }
   403→              case "end_call": {
   404→                const callId = String(params.callId || "").trim();
   405→                if (!callId) throw new Error("callId required");
   406→                const result = await rt.manager.endCall(callId);
   407→                if (!result.success) {
   408→                  throw new Error(result.error || "end failed");
   409→                }
   410→                return json({ success: true });
   411→              }
   412→              case "get_status": {
   413→                const callId = String(params.callId || "").trim();
   414→                if (!callId) throw new Error("callId required");
   415→                const call =
   416→                  rt.manager.getCall(callId) ||
   417→                  rt.manager.getCallByProviderCallId(callId);
   418→                return json(call ? { found: true, call } : { found: false });
   419→              }
   420→            }
   421→          }
   422→
   423→          const mode = params?.mode ?? "call";
   424→          if (mode === "status") {
   425→            const sid =
   426→              typeof params.sid === "string" ? params.sid.trim() : "";
   427→            if (!sid) throw new Error("sid required for status");
   428→            const call =
   429→              rt.manager.getCall(sid) || rt.manager.getCallByProviderCallId(sid);
   430→            return json(call ? { found: true, call } : { found: false });
   431→          }
   432→
   433→          const to =
   434→            typeof params.to === "string" && params.to.trim()
   435→              ? params.to.trim()
   436→              : rt.config.toNumber;
   437→          if (!to) throw new Error("to required for call");
   438→          const result = await rt.manager.initiateCall(to, undefined, {
   439→            message:
   440→              typeof params.message === "string" && params.message.trim()
   441→                ? params.message.trim()
   442→                : undefined,
   443→          });
   444→          if (!result.success) {
   445→            throw new Error(result.error || "initiate failed");
   446→          }
   447→          return json({ callId: result.callId, initiated: true });
   448→        } catch (err) {
   449→          return json({
   450→            error: err instanceof Error ? err.message : String(err),
   451→          });
   452→        }
   453→      },
   454→    });
   455→
   456→    api.registerCli(
   457→      ({ program }) =>
   458→        registerVoiceCallCli({
   459→          program,
   460→          config: cfg,
   461→          ensureRuntime,
   462→          logger: api.logger,
   463→        }),
   464→      { commands: ["voicecall"] },
   465→    );
   466→
   467→    api.registerService({
   468→      id: "voicecall",
   469→      start: async () => {
   470→        if (!cfg.enabled) return;
   471→        try {
   472→          await ensureRuntime();
   473→        } catch (err) {
   474→          api.logger.error(
   475→            `[voice-call] Failed to start runtime: ${
   476→              err instanceof Error ? err.message : String(err)
   477→            }`,
   478→          );
   479→        }
   480→      },
   481→      stop: async () => {
   482→        if (!runtimePromise) return;
   483→        try {
   484→          const rt = await runtimePromise;
   485→          await rt.stop();
   486→        } finally {
   487→          runtimePromise = null;
   488→          runtime = null;
   489→        }
   490→      },
   491→    });
   492→  },
   493→};
   494→
   495→export default voiceCallPlugin;
   496→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


Now let me check the source directory for the actual webhook and TwiML logic:


ls -la /home/ccuser/.clawdbot/extensions/voice-call/src/ | grep -E "\.(ts|js)$"


-rw-r--r-- 1 ccuser ccuser  9900 Feb 11 18:22 cli.ts
-rw-r--r-- 1 ccuser ccuser 15522 Feb 11 18:22 config.ts
-rw-r--r-- 1 ccuser ccuser  6015 Feb 11 18:22 core-bridge.ts
-rw-r--r-- 1 ccuser ccuser  3620 Feb 11 18:22 manager.test.ts
-rw-r--r-- 1 ccuser ccuser 25543 Feb 11 18:22 manager.ts
-rw-r--r-- 1 ccuser ccuser  2481 Feb 11 18:22 media-stream.test.ts
-rw-r--r-- 1 ccuser ccuser 10631 Feb 11 18:22 media-stream.ts
-rw-r--r-- 1 ccuser ccuser  5072 Feb 11 18:22 response-generator.ts
-rw-r--r-- 1 ccuser ccuser  6300 Feb 11 18:22 runtime.ts
-rw-r--r-- 1 ccuser ccuser  2396 Feb 11 18:22 telephony-audio.ts
-rw-r--r-- 1 ccuser ccuser  2588 Feb 11 18:22 telephony-tts.ts
-rw-r--r-- 1 ccuser ccuser  8086 Feb 11 18:22 tunnel.ts
-rw-r--r-- 1 ccuser ccuser  7318 Feb 11 18:22 types.ts
-rw-r--r-- 1 ccuser ccuser   353 Feb 11 18:22 utils.ts
-rw-r--r-- 1 ccuser ccuser  1780 Feb 11 18:22 voice-mapping.ts
-rw-r--r-- 1 ccuser ccuser  5624 Feb 11 18:22 webhook-security.test.ts
-rw-r--r-- 1 ccuser ccuser 12680 Feb 11 18:22 webhook-security.ts
-rw-r--r-- 1 ccuser ccuser 14176 Feb 11 18:22 webhook.ts


Perfect! Now let me read the critical files for understanding webhook handling, TwiML, signature verification, and TTS:


     1→import { spawn } from "node:child_process";
     2→import http from "node:http";
     3→import { URL } from "node:url";
     4→
     5→import type { VoiceCallConfig } from "./config.js";
     6→import type { CoreConfig } from "./core-bridge.js";
     7→import type { CallManager } from "./manager.js";
     8→import type { MediaStreamConfig } from "./media-stream.js";
     9→import { MediaStreamHandler } from "./media-stream.js";
    10→import type { VoiceCallProvider } from "./providers/base.js";
    11→import { OpenAIRealtimeSTTProvider } from "./providers/stt-openai-realtime.js";
    12→import type { TwilioProvider } from "./providers/twilio.js";
    13→import type { NormalizedEvent, WebhookContext } from "./types.js";
    14→
    15→/**
    16→ * HTTP server for receiving voice call webhooks from providers.
    17→ * Supports WebSocket upgrades for media streams when streaming is enabled.
    18→ */
    19→export class VoiceCallWebhookServer {
    20→  private server: http.Server | null = null;
    21→  private config: VoiceCallConfig;
    22→  private manager: CallManager;
    23→  private provider: VoiceCallProvider;
    24→  private coreConfig: CoreConfig | null;
    25→
    26→  /** Media stream handler for bidirectional audio (when streaming enabled) */
    27→  private mediaStreamHandler: MediaStreamHandler | null = null;
    28→
    29→  constructor(
    30→    config: VoiceCallConfig,
    31→    manager: CallManager,
    32→    provider: VoiceCallProvider,
    33→    coreConfig?: CoreConfig,
    34→  ) {
    35→    this.config = config;
    36→    this.manager = manager;
    37→    this.provider = provider;
    38→    this.coreConfig = coreConfig ?? null;
    39→
    40→    // Initialize media stream handler if streaming is enabled
    41→    if (config.streaming?.enabled) {
    42→      this.initializeMediaStreaming();
    43→    }
    44→  }
    45→
    46→  /**
    47→   * Get the media stream handler (for wiring to provider).
    48→   */
    49→  getMediaStreamHandler(): MediaStreamHandler | null {
    50→    return this.mediaStreamHandler;
    51→  }
    52→
    53→  /**
    54→   * Initialize media streaming with OpenAI Realtime STT.
    55→   */
    56→  private initializeMediaStreaming(): void {
    57→    const apiKey =
    58→      this.config.streaming?.openaiApiKey || process.env.OPENAI_API_KEY;
    59→
    60→    if (!apiKey) {
    61→      console.warn(
    62→        "[voice-call] Streaming enabled but no OpenAI API key found",
    63→      );
    64→      return;
    65→    }
    66→
    67→    const sttProvider = new OpenAIRealtimeSTTProvider({
    68→      apiKey,
    69→      model: this.config.streaming?.sttModel,
    70→      silenceDurationMs: this.config.streaming?.silenceDurationMs,
    71→      vadThreshold: this.config.streaming?.vadThreshold,
    72→    });
    73→
    74→    const streamConfig: MediaStreamConfig = {
    75→      sttProvider,
    76→      onTranscript: (providerCallId, transcript) => {
    77→        console.log(
    78→          `[voice-call] Transcript for ${providerCallId}: ${transcript}`,
    79→        );
    80→
    81→        // Clear TTS queue on barge-in (user started speaking, interrupt current playback)
    82→        if (this.provider.name === "twilio") {
    83→          (this.provider as TwilioProvider).clearTtsQueue(providerCallId);
    84→        }
    85→
    86→        // Look up our internal call ID from the provider call ID
    87→        const call = this.manager.getCallByProviderCallId(providerCallId);
    88→        if (!call) {
    89→          console.warn(
    90→            `[voice-call] No active call found for provider ID: ${providerCallId}`,
    91→          );
    92→          return;
    93→        }
    94→
    95→        // Create a speech event and process it through the manager
    96→        const event: NormalizedEvent = {
    97→          id: `stream-transcript-${Date.now()}`,
    98→          type: "call.speech",
    99→          callId: call.callId,
   100→          providerCallId,
   101→          timestamp: Date.now(),
   102→          transcript,
   103→          isFinal: true,
   104→        };
   105→        this.manager.processEvent(event);
   106→
   107→        // Auto-respond in conversation mode (inbound always, outbound if mode is conversation)
   108→        const callMode = call.metadata?.mode as string | undefined;
   109→        const shouldRespond =
   110→          call.direction === "inbound" || callMode === "conversation";
   111→        if (shouldRespond) {
   112→          this.handleInboundResponse(call.callId, transcript).catch((err) => {
   113→            console.warn(`[voice-call] Failed to auto-respond:`, err);
   114→          });
   115→        }
   116→      },
   117→      onSpeechStart: (providerCallId) => {
   118→        if (this.provider.name === "twilio") {
   119→          (this.provider as TwilioProvider).clearTtsQueue(providerCallId);
   120→        }
   121→      },
   122→      onPartialTranscript: (callId, partial) => {
   123→        console.log(`[voice-call] Partial for ${callId}: ${partial}`);
   124→      },
   125→      onConnect: (callId, streamSid) => {
   126→        console.log(
   127→          `[voice-call] Media stream connected: ${callId} -> ${streamSid}`,
   128→        );
   129→        // Register stream with provider for TTS routing
   130→        if (this.provider.name === "twilio") {
   131→          (this.provider as TwilioProvider).registerCallStream(
   132→            callId,
   133→            streamSid,
   134→          );
   135→        }
   136→
   137→        // Speak initial message if one was provided when call was initiated
   138→        // Use setTimeout to allow stream setup to complete
   139→        setTimeout(() => {
   140→          this.manager.speakInitialMessage(callId).catch((err) => {
   141→            console.warn(`[voice-call] Failed to speak initial message:`, err);
   142→          });
   143→        }, 500);
   144→      },
   145→      onDisconnect: (callId) => {
   146→        console.log(`[voice-call] Media stream disconnected: ${callId}`);
   147→        if (this.provider.name === "twilio") {
   148→          (this.provider as TwilioProvider).unregisterCallStream(callId);
   149→        }
   150→      },
   151→    };
   152→
   153→    this.mediaStreamHandler = new MediaStreamHandler(streamConfig);
   154→    console.log("[voice-call] Media streaming initialized");
   155→  }
   156→
   157→  /**
   158→   * Start the webhook server.
   159→   */
   160→  async start(): Promise<string> {
   161→    const { port, bind, path: webhookPath } = this.config.serve;
   162→    const streamPath = this.config.streaming?.streamPath || "/voice/stream";
   163→
   164→    return new Promise((resolve, reject) => {
   165→      this.server = http.createServer((req, res) => {
   166→        this.handleRequest(req, res, webhookPath).catch((err) => {
   167→          console.error("[voice-call] Webhook error:", err);
   168→          res.statusCode = 500;
   169→          res.end("Internal Server Error");
   170→        });
   171→      });
   172→
   173→      // Handle WebSocket upgrades for media streams
   174→      if (this.mediaStreamHandler) {
   175→        this.server.on("upgrade", (request, socket, head) => {
   176→          const url = new URL(
   177→            request.url || "/",
   178→            `http://${request.headers.host}`,
   179→          );
   180→
   181→          if (url.pathname === streamPath) {
   182→            console.log("[voice-call] WebSocket upgrade for media stream");
   183→            this.mediaStreamHandler?.handleUpgrade(request, socket, head);
   184→          } else {
   185→            socket.destroy();
   186→          }
   187→        });
   188→      }
   189→
   190→      this.server.on("error", reject);
   191→
   192→      this.server.listen(port, bind, () => {
   193→        const url = `http://${bind}:${port}${webhookPath}`;
   194→        console.log(`[voice-call] Webhook server listening on ${url}`);
   195→        if (this.mediaStreamHandler) {
   196→          console.log(
   197→            `[voice-call] Media stream WebSocket on ws://${bind}:${port}${streamPath}`,
   198→          );
   199→        }
   200→        resolve(url);
   201→      });
   202→    });
   203→  }
   204→
   205→  /**
   206→   * Stop the webhook server.
   207→   */
   208→  async stop(): Promise<void> {
   209→    return new Promise((resolve) => {
   210→      if (this.server) {
   211→        this.server.close(() => {
   212→          this.server = null;
   213→          resolve();
   214→        });
   215→      } else {
   216→        resolve();
   217→      }
   218→    });
   219→  }
   220→
   221→  /**
   222→   * Handle incoming HTTP request.
   223→   */
   224→  private async handleRequest(
   225→    req: http.IncomingMessage,
   226→    res: http.ServerResponse,
   227→    webhookPath: string,
   228→  ): Promise<void> {
   229→    const url = new URL(req.url || "/", `http://${req.headers.host}`);
   230→
   231→    // Check path
   232→    if (!url.pathname.startsWith(webhookPath)) {
   233→      res.statusCode = 404;
   234→      res.end("Not Found");
   235→      return;
   236→    }
   237→
   238→    // Only accept POST
   239→    if (req.method !== "POST") {
   240→      res.statusCode = 405;
   241→      res.end("Method Not Allowed");
   242→      return;
   243→    }
   244→
   245→    // Read body
   246→    const body = await this.readBody(req);
   247→
   248→    // Build webhook context
   249→    const ctx: WebhookContext = {
   250→      headers: req.headers as Record<string, string | string[] | undefined>,
   251→      rawBody: body,
   252→      url: `http://${req.headers.host}${req.url}`,
   253→      method: "POST",
   254→      query: Object.fromEntries(url.searchParams),
   255→    };
   256→
   257→    // Verify signature
   258→    const verification = this.provider.verifyWebhook(ctx);
   259→    if (!verification.ok) {
   260→      console.warn(
   261→        `[voice-call] Webhook verification failed: ${verification.reason}`,
   262→      );
   263→      res.statusCode = 401;
   264→      res.end("Unauthorized");
   265→      return;
   266→    }
   267→
   268→    // Parse events
   269→    const result = this.provider.parseWebhookEvent(ctx);
   270→
   271→    // Process each event
   272→    for (const event of result.events) {
   273→      try {
   274→        this.manager.processEvent(event);
   275→      } catch (err) {
   276→        console.error(
   277→          `[voice-call] Error processing event ${event.type}:`,
   278→          err,
   279→        );
   280→      }
   281→    }
   282→
   283→    // Send response
   284→    res.statusCode = result.statusCode || 200;
   285→
   286→    if (result.providerResponseHeaders) {
   287→      for (const [key, value] of Object.entries(
   288→        result.providerResponseHeaders,
   289→      )) {
   290→        res.setHeader(key, value);
   291→      }
   292→    }
   293→
   294→    res.end(result.providerResponseBody || "OK");
   295→  }
   296→
   297→  /**
   298→   * Read request body as string.
   299→   */
   300→  private readBody(req: http.IncomingMessage): Promise<string> {
   301→    return new Promise((resolve, reject) => {
   302→      const chunks: Buffer[] = [];
   303→      req.on("data", (chunk) => chunks.push(chunk));
   304→      req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
   305→      req.on("error", reject);
   306→    });
   307→  }
   308→
   309→  /**
   310→   * Handle auto-response for inbound calls using the agent system.
   311→   * Supports tool calling for richer voice interactions.
   312→   */
   313→  private async handleInboundResponse(
   314→    callId: string,
   315→    userMessage: string,
   316→  ): Promise<void> {
   317→    console.log(
   318→      `[voice-call] Auto-responding to inbound call ${callId}: "${userMessage}"`,
   319→    );
   320→
   321→    // Get call context for conversation history
   322→    const call = this.manager.getCall(callId);
   323→    if (!call) {
   324→      console.warn(`[voice-call] Call ${callId} not found for auto-response`);
   325→      return;
   326→    }
   327→
   328→    if (!this.coreConfig) {
   329→      console.warn("[voice-call] Core config missing; skipping auto-response");
   330→      return;
   331→    }
   332→
   333→    try {
   334→      const { generateVoiceResponse } = await import("./response-generator.js");
   335→
   336→      const result = await generateVoiceResponse({
   337→        voiceConfig: this.config,
   338→        coreConfig: this.coreConfig,
   339→        callId,
   340→        from: call.from,
   341→        transcript: call.transcript,
   342→        userMessage,
   343→      });
   344→
   345→      if (result.error) {
   346→        console.error(
   347→          `[voice-call] Response generation error: ${result.error}`,
   348→        );
   349→        return;
   350→      }
   351→
   352→      if (result.text) {
   353→        console.log(`[voice-call] AI response: "${result.text}"`);
   354→        await this.manager.speak(callId, result.text);
   355→      }
   356→    } catch (err) {
   357→      console.error(`[voice-call] Auto-response error:`, err);
   358→    }
   359→  }
   360→}
   361→
   362→/**
   363→ * Resolve the current machine's Tailscale DNS name.
   364→ */
   365→export type TailscaleSelfInfo = {
   366→  dnsName: string | null;
   367→  nodeId: string | null;
   368→};
   369→
   370→/**
   371→ * Run a tailscale command with timeout, collecting stdout.
   372→ */
   373→function runTailscaleCommand(
   374→  args: string[],
   375→  timeoutMs = 2500,
   376→): Promise<{ code: number; stdout: string }> {
   377→  return new Promise((resolve) => {
   378→    const proc = spawn("tailscale", args, {
   379→      stdio: ["ignore", "pipe", "pipe"],
   380→    });
   381→
   382→    let stdout = "";
   383→    proc.stdout.on("data", (data) => {
   384→      stdout += data;
   385→    });
   386→
   387→    const timer = setTimeout(() => {
   388→      proc.kill("SIGKILL");
   389→      resolve({ code: -1, stdout: "" });
   390→    }, timeoutMs);
   391→
   392→    proc.on("close", (code) => {
   393→      clearTimeout(timer);
   394→      resolve({ code: code ?? -1, stdout });
   395→    });
   396→  });
   397→}
   398→
   399→export async function getTailscaleSelfInfo(): Promise<TailscaleSelfInfo | null> {
   400→  const { code, stdout } = await runTailscaleCommand(["status", "--json"]);
   401→  if (code !== 0) return null;
   402→
   403→  try {
   404→    const status = JSON.parse(stdout);
   405→    return {
   406→      dnsName: status.Self?.DNSName?.replace(/\.$/, "") || null,
   407→      nodeId: status.Self?.ID || null,
   408→    };
   409→  } catch {
   410→    return null;
   411→  }
   412→}
   413→
   414→export async function getTailscaleDnsName(): Promise<string | null> {
   415→  const info = await getTailscaleSelfInfo();
   416→  return info?.dnsName ?? null;
   417→}
   418→
   419→export async function setupTailscaleExposureRoute(opts: {
   420→  mode: "serve" | "funnel";
   421→  path: string;
   422→  localUrl: string;
   423→}): Promise<string | null> {
   424→  const dnsName = await getTailscaleDnsName();
   425→  if (!dnsName) {
   426→    console.warn("[voice-call] Could not get Tailscale DNS name");
   427→    return null;
   428→  }
   429→
   430→  const { code } = await runTailscaleCommand([
   431→    opts.mode,
   432→    "--bg",
   433→    "--yes",
   434→    "--set-path",
   435→    opts.path,
   436→    opts.localUrl,
   437→  ]);
   438→
   439→  if (code === 0) {
   440→    const publicUrl = `https://${dnsName}${opts.path}`;
   441→    console.log(`[voice-call] Tailscale ${opts.mode} active: ${publicUrl}`);
   442→    return publicUrl;
   443→  }
   444→
   445→  console.warn(`[voice-call] Tailscale ${opts.mode} failed`);
   446→  return null;
   447→}
   448→
   449→export async function cleanupTailscaleExposureRoute(opts: {
   450→  mode: "serve" | "funnel";
   451→  path: string;
   452→}): Promise<void> {
   453→  await runTailscaleCommand([opts.mode, "off", opts.path]);
   454→}
   455→
   456→/**
   457→ * Setup Tailscale serve/funnel for the webhook server.
   458→ * This is a helper that shells out to `tailscale serve` or `tailscale funnel`.
   459→ */
   460→export async function setupTailscaleExposure(
   461→  config: VoiceCallConfig,
   462→): Promise<string | null> {
   463→  if (config.tailscale.mode === "off") {
   464→    return null;
   465→  }
   466→
   467→  const mode = config.tailscale.mode === "funnel" ? "funnel" : "serve";
   468→  // Include the path suffix so tailscale forwards to the correct endpoint
   469→  // (tailscale strips the mount path prefix when proxying)
   470→  const localUrl = `http://127.0.0.1:${config.serve.port}${config.serve.path}`;
   471→  return setupTailscaleExposureRoute({
   472→    mode,
   473→    path: config.tailscale.path,
   474→    localUrl,
   475→  });
   476→}
   477→
   478→/**
   479→ * Cleanup Tailscale serve/funnel.
   480→ */
   481→export async function cleanupTailscaleExposure(
   482→  config: VoiceCallConfig,
   483→): Promise<void> {
   484→  if (config.tailscale.mode === "off") {
   485→    return;
   486→  }
   487→
   488→  const mode = config.tailscale.mode === "funnel" ? "funnel" : "serve";
   489→  await cleanupTailscaleExposureRoute({ mode, path: config.tailscale.path });
   490→}
   491→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→import crypto from "node:crypto";
     2→
     3→import type { WebhookContext } from "./types.js";
     4→
     5→/**
     6→ * Validate Twilio webhook signature using HMAC-SHA1.
     7→ *
     8→ * Twilio signs requests by concatenating the URL with sorted POST params,
     9→ * then computing HMAC-SHA1 with the auth token.
    10→ *
    11→ * @see https://www.twilio.com/docs/usage/webhooks/webhooks-security
    12→ */
    13→export function validateTwilioSignature(
    14→  authToken: string,
    15→  signature: string | undefined,
    16→  url: string,
    17→  params: URLSearchParams,
    18→): boolean {
    19→  if (!signature) {
    20→    return false;
    21→  }
    22→
    23→  // Build the string to sign: URL + sorted params (key+value pairs)
    24→  let dataToSign = url;
    25→
    26→  // Sort params alphabetically and append key+value
    27→  const sortedParams = Array.from(params.entries()).sort((a, b) =>
    28→    a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0,
    29→  );
    30→
    31→  for (const [key, value] of sortedParams) {
    32→    dataToSign += key + value;
    33→  }
    34→
    35→  // HMAC-SHA1 with auth token, then base64 encode
    36→  const expectedSignature = crypto
    37→    .createHmac("sha1", authToken)
    38→    .update(dataToSign)
    39→    .digest("base64");
    40→
    41→  // Use timing-safe comparison to prevent timing attacks
    42→  return timingSafeEqual(signature, expectedSignature);
    43→}
    44→
    45→/**
    46→ * Timing-safe string comparison to prevent timing attacks.
    47→ */
    48→function timingSafeEqual(a: string, b: string): boolean {
    49→  if (a.length !== b.length) {
    50→    // Still do comparison to maintain constant time
    51→    const dummy = Buffer.from(a);
    52→    crypto.timingSafeEqual(dummy, dummy);
    53→    return false;
    54→  }
    55→
    56→  const bufA = Buffer.from(a);
    57→  const bufB = Buffer.from(b);
    58→  return crypto.timingSafeEqual(bufA, bufB);
    59→}
    60→
    61→/**
    62→ * Reconstruct the public webhook URL from request headers.
    63→ *
    64→ * When behind a reverse proxy (Tailscale, nginx, ngrok), the original URL
    65→ * used by Twilio differs from the local request URL. We use standard
    66→ * forwarding headers to reconstruct it.
    67→ *
    68→ * Priority order:
    69→ * 1. X-Forwarded-Proto + X-Forwarded-Host (standard proxy headers)
    70→ * 2. X-Original-Host (nginx)
    71→ * 3. Ngrok-Forwarded-Host (ngrok specific)
    72→ * 4. Host header (direct connection)
    73→ */
    74→export function reconstructWebhookUrl(ctx: WebhookContext): string {
    75→  const { headers } = ctx;
    76→
    77→  const proto = getHeader(headers, "x-forwarded-proto") || "https";
    78→
    79→  const forwardedHost =
    80→    getHeader(headers, "x-forwarded-host") ||
    81→    getHeader(headers, "x-original-host") ||
    82→    getHeader(headers, "ngrok-forwarded-host") ||
    83→    getHeader(headers, "host") ||
    84→    "";
    85→
    86→  // Extract path from the context URL (fallback to "/" on parse failure)
    87→  let path = "/";
    88→  try {
    89→    const parsed = new URL(ctx.url);
    90→    path = parsed.pathname + parsed.search;
    91→  } catch {
    92→    // URL parsing failed
    93→  }
    94→
    95→  // Remove port from host (ngrok URLs don't have ports)
    96→  const host = forwardedHost.split(":")[0] || forwardedHost;
    97→
    98→  return `${proto}://${host}${path}`;
    99→}
   100→
   101→function buildTwilioVerificationUrl(
   102→  ctx: WebhookContext,
   103→  publicUrl?: string,
   104→): string {
   105→  if (!publicUrl) {
   106→    return reconstructWebhookUrl(ctx);
   107→  }
   108→
   109→  try {
   110→    const base = new URL(publicUrl);
   111→    const requestUrl = new URL(ctx.url);
   112→    base.pathname = requestUrl.pathname;
   113→    base.search = requestUrl.search;
   114→    return base.toString();
   115→  } catch {
   116→    return publicUrl;
   117→  }
   118→}
   119→
   120→/**
   121→ * Get a header value, handling both string and string[] types.
   122→ */
   123→function getHeader(
   124→  headers: Record<string, string | string[] | undefined>,
   125→  name: string,
   126→): string | undefined {
   127→  const value = headers[name.toLowerCase()];
   128→  if (Array.isArray(value)) {
   129→    return value[0];
   130→  }
   131→  return value;
   132→}
   133→
   134→/**
   135→ * Result of Twilio webhook verification with detailed info.
   136→ */
   137→export interface TwilioVerificationResult {
   138→  ok: boolean;
   139→  reason?: string;
   140→  /** The URL that was used for verification (for debugging) */
   141→  verificationUrl?: string;
   142→  /** Whether we're running behind ngrok free tier */
   143→  isNgrokFreeTier?: boolean;
   144→}
   145→
   146→/**
   147→ * Verify Twilio webhook with full context and detailed result.
   148→ *
   149→ * Handles the special case of ngrok free tier where signature validation
   150→ * may fail due to URL discrepancies (ngrok adds interstitial page handling).
   151→ */
   152→export function verifyTwilioWebhook(
   153→  ctx: WebhookContext,
   154→  authToken: string,
   155→  options?: {
   156→    /** Override the public URL (e.g., from config) */
   157→    publicUrl?: string;
   158→    /** Allow ngrok free tier compatibility mode (less secure) */
   159→    allowNgrokFreeTier?: boolean;
   160→    /** Skip verification entirely (only for development) */
   161→    skipVerification?: boolean;
   162→  },
   163→): TwilioVerificationResult {
   164→  // Allow skipping verification for development/testing
   165→  if (options?.skipVerification) {
   166→    return { ok: true, reason: "verification skipped (dev mode)" };
   167→  }
   168→
   169→  const signature = getHeader(ctx.headers, "x-twilio-signature");
   170→
   171→  if (!signature) {
   172→    return { ok: false, reason: "Missing X-Twilio-Signature header" };
   173→  }
   174→
   175→  // Reconstruct the URL Twilio used
   176→  const verificationUrl = buildTwilioVerificationUrl(ctx, options?.publicUrl);
   177→
   178→  // Parse the body as URL-encoded params
   179→  const params = new URLSearchParams(ctx.rawBody);
   180→
   181→  // Validate signature
   182→  const isValid = validateTwilioSignature(
   183→    authToken,
   184→    signature,
   185→    verificationUrl,
   186→    params,
   187→  );
   188→
   189→  if (isValid) {
   190→    return { ok: true, verificationUrl };
   191→  }
   192→
   193→  // Check if this is ngrok free tier - the URL might have different format
   194→  const isNgrokFreeTier =
   195→    verificationUrl.includes(".ngrok-free.app") ||
   196→    verificationUrl.includes(".ngrok.io");
   197→
   198→  if (isNgrokFreeTier && options?.allowNgrokFreeTier) {
   199→    console.warn(
   200→      "[voice-call] Twilio signature validation failed (proceeding for ngrok free tier compatibility)",
   201→    );
   202→    return {
   203→      ok: true,
   204→      reason: "ngrok free tier compatibility mode",
   205→      verificationUrl,
   206→      isNgrokFreeTier: true,
   207→    };
   208→  }
   209→
   210→  return {
   211→    ok: false,
   212→    reason: `Invalid signature for URL: ${verificationUrl}`,
   213→    verificationUrl,
   214→    isNgrokFreeTier,
   215→  };
   216→}
   217→
   218→// -----------------------------------------------------------------------------
   219→// Plivo webhook verification
   220→// -----------------------------------------------------------------------------
   221→
   222→/**
   223→ * Result of Plivo webhook verification with detailed info.
   224→ */
   225→export interface PlivoVerificationResult {
   226→  ok: boolean;
   227→  reason?: string;
   228→  verificationUrl?: string;
   229→  /** Signature version used for verification */
   230→  version?: "v3" | "v2";
   231→}
   232→
   233→function normalizeSignatureBase64(input: string): string {
   234→  // Canonicalize base64 to match Plivo SDK behavior (decode then re-encode).
   235→  return Buffer.from(input, "base64").toString("base64");
   236→}
   237→
   238→function getBaseUrlNoQuery(url: string): string {
   239→  const u = new URL(url);
   240→  return `${u.protocol}//${u.host}${u.pathname}`;
   241→}
   242→
   243→function timingSafeEqualString(a: string, b: string): boolean {
   244→  if (a.length !== b.length) {
   245→    const dummy = Buffer.from(a);
   246→    crypto.timingSafeEqual(dummy, dummy);
   247→    return false;
   248→  }
   249→  return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
   250→}
   251→
   252→function validatePlivoV2Signature(params: {
   253→  authToken: string;
   254→  signature: string;
   255→  nonce: string;
   256→  url: string;
   257→}): boolean {
   258→  const baseUrl = getBaseUrlNoQuery(params.url);
   259→  const digest = crypto
   260→    .createHmac("sha256", params.authToken)
   261→    .update(baseUrl + params.nonce)
   262→    .digest("base64");
   263→  const expected = normalizeSignatureBase64(digest);
   264→  const provided = normalizeSignatureBase64(params.signature);
   265→  return timingSafeEqualString(expected, provided);
   266→}
   267→
   268→type PlivoParamMap = Record<string, string[]>;
   269→
   270→function toParamMapFromSearchParams(sp: URLSearchParams): PlivoParamMap {
   271→  const map: PlivoParamMap = {};
   272→  for (const [key, value] of sp.entries()) {
   273→    if (!map[key]) map[key] = [];
   274→    map[key].push(value);
   275→  }
   276→  return map;
   277→}
   278→
   279→function sortedQueryString(params: PlivoParamMap): string {
   280→  const parts: string[] = [];
   281→  for (const key of Object.keys(params).sort()) {
   282→    const values = [...params[key]].sort();
   283→    for (const value of values) {
   284→      parts.push(`${key}=${value}`);
   285→    }
   286→  }
   287→  return parts.join("&");
   288→}
   289→
   290→function sortedParamsString(params: PlivoParamMap): string {
   291→  const parts: string[] = [];
   292→  for (const key of Object.keys(params).sort()) {
   293→    const values = [...params[key]].sort();
   294→    for (const value of values) {
   295→      parts.push(`${key}${value}`);
   296→    }
   297→  }
   298→  return parts.join("");
   299→}
   300→
   301→function constructPlivoV3BaseUrl(params: {
   302→  method: "GET" | "POST";
   303→  url: string;
   304→  postParams: PlivoParamMap;
   305→}): string {
   306→  const hasPostParams = Object.keys(params.postParams).length > 0;
   307→  const u = new URL(params.url);
   308→  const baseNoQuery = `${u.protocol}//${u.host}${u.pathname}`;
   309→
   310→  const queryMap = toParamMapFromSearchParams(u.searchParams);
   311→  const queryString = sortedQueryString(queryMap);
   312→
   313→  // In the Plivo V3 algorithm, the query portion is always sorted, and if we
   314→  // have POST params we add a '.' separator after the query string.
   315→  let baseUrl = baseNoQuery;
   316→  if (queryString.length > 0 || hasPostParams) {
   317→    baseUrl = `${baseNoQuery}?${queryString}`;
   318→  }
   319→  if (queryString.length > 0 && hasPostParams) {
   320→    baseUrl = `${baseUrl}.`;
   321→  }
   322→
   323→  if (params.method === "GET") {
   324→    return baseUrl;
   325→  }
   326→
   327→  return baseUrl + sortedParamsString(params.postParams);
   328→}
   329→
   330→function validatePlivoV3Signature(params: {
   331→  authToken: string;
   332→  signatureHeader: string;
   333→  nonce: string;
   334→  method: "GET" | "POST";
   335→  url: string;
   336→  postParams: PlivoParamMap;
   337→}): boolean {
   338→  const baseUrl = constructPlivoV3BaseUrl({
   339→    method: params.method,
   340→    url: params.url,
   341→    postParams: params.postParams,
   342→  });
   343→
   344→  const hmacBase = `${baseUrl}.${params.nonce}`;
   345→  const digest = crypto
   346→    .createHmac("sha256", params.authToken)
   347→    .update(hmacBase)
   348→    .digest("base64");
   349→  const expected = normalizeSignatureBase64(digest);
   350→
   351→  // Header can contain multiple signatures separated by commas.
   352→  const provided = params.signatureHeader
   353→    .split(",")
   354→    .map((s) => s.trim())
   355→    .filter(Boolean)
   356→    .map((s) => normalizeSignatureBase64(s));
   357→
   358→  for (const sig of provided) {
   359→    if (timingSafeEqualString(expected, sig)) return true;
   360→  }
   361→  return false;
   362→}
   363→
   364→/**
   365→ * Verify Plivo webhooks using V3 signature if present; fall back to V2.
   366→ *
   367→ * Header names (case-insensitive; Node provides lower-case keys):
   368→ * - V3: X-Plivo-Signature-V3 / X-Plivo-Signature-V3-Nonce
   369→ * - V2: X-Plivo-Signature-V2 / X-Plivo-Signature-V2-Nonce
   370→ */
   371→export function verifyPlivoWebhook(
   372→  ctx: WebhookContext,
   373→  authToken: string,
   374→  options?: {
   375→    /** Override the public URL origin (host) used for verification */
   376→    publicUrl?: string;
   377→    /** Skip verification entirely (only for development) */
   378→    skipVerification?: boolean;
   379→  },
   380→): PlivoVerificationResult {
   381→  if (options?.skipVerification) {
   382→    return { ok: true, reason: "verification skipped (dev mode)" };
   383→  }
   384→
   385→  const signatureV3 = getHeader(ctx.headers, "x-plivo-signature-v3");
   386→  const nonceV3 = getHeader(ctx.headers, "x-plivo-signature-v3-nonce");
   387→  const signatureV2 = getHeader(ctx.headers, "x-plivo-signature-v2");
   388→  const nonceV2 = getHeader(ctx.headers, "x-plivo-signature-v2-nonce");
   389→
   390→  const reconstructed = reconstructWebhookUrl(ctx);
   391→  let verificationUrl = reconstructed;
   392→  if (options?.publicUrl) {
   393→    try {
   394→      const req = new URL(reconstructed);
   395→      const base = new URL(options.publicUrl);
   396→      base.pathname = req.pathname;
   397→      base.search = req.search;
   398→      verificationUrl = base.toString();
   399→    } catch {
   400→      verificationUrl = reconstructed;
   401→    }
   402→  }
   403→
   404→  if (signatureV3 && nonceV3) {
   405→    const method =
   406→      ctx.method === "GET" || ctx.method === "POST" ? ctx.method : null;
   407→
   408→    if (!method) {
   409→      return {
   410→        ok: false,
   411→        version: "v3",
   412→        verificationUrl,
   413→        reason: `Unsupported HTTP method for Plivo V3 signature: ${ctx.method}`,
   414→      };
   415→    }
   416→
   417→    const postParams = toParamMapFromSearchParams(new URLSearchParams(ctx.rawBody));
   418→    const ok = validatePlivoV3Signature({
   419→      authToken,
   420→      signatureHeader: signatureV3,
   421→      nonce: nonceV3,
   422→      method,
   423→      url: verificationUrl,
   424→      postParams,
   425→    });
   426→    return ok
   427→      ? { ok: true, version: "v3", verificationUrl }
   428→      : {
   429→          ok: false,
   430→          version: "v3",
   431→          verificationUrl,
   432→          reason: "Invalid Plivo V3 signature",
   433→        };
   434→  }
   435→
   436→  if (signatureV2 && nonceV2) {
   437→    const ok = validatePlivoV2Signature({
   438→      authToken,
   439→      signature: signatureV2,
   440→      nonce: nonceV2,
   441→      url: verificationUrl,
   442→    });
   443→    return ok
   444→      ? { ok: true, version: "v2", verificationUrl }
   445→      : {
   446→          ok: false,
   447→          version: "v2",
   448→          verificationUrl,
   449→          reason: "Invalid Plivo V2 signature",
   450→        };
   451→  }
   452→
   453→  return {
   454→    ok: false,
   455→    reason: "Missing Plivo signature headers (V3 or V2)",
   456→    verificationUrl,
   457→  };
   458→}
   459→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→import type { CoreConfig } from "./core-bridge.js";
     2→import type { VoiceCallTtsConfig } from "./config.js";
     3→import { convertPcmToMulaw8k } from "./telephony-audio.js";
     4→
     5→export type TelephonyTtsRuntime = {
     6→  textToSpeechTelephony: (params: {
     7→    text: string;
     8→    cfg: CoreConfig;
     9→    prefsPath?: string;
    10→  }) => Promise<{
    11→    success: boolean;
    12→    audioBuffer?: Buffer;
    13→    sampleRate?: number;
    14→    provider?: string;
    15→    error?: string;
    16→  }>;
    17→};
    18→
    19→export type TelephonyTtsProvider = {
    20→  synthesizeForTelephony: (text: string) => Promise<Buffer>;
    21→};
    22→
    23→export function createTelephonyTtsProvider(params: {
    24→  coreConfig: CoreConfig;
    25→  ttsOverride?: VoiceCallTtsConfig;
    26→  runtime: TelephonyTtsRuntime;
    27→}): TelephonyTtsProvider {
    28→  const { coreConfig, ttsOverride, runtime } = params;
    29→  const mergedConfig = applyTtsOverride(coreConfig, ttsOverride);
    30→
    31→  return {
    32→    synthesizeForTelephony: async (text: string) => {
    33→      const result = await runtime.textToSpeechTelephony({
    34→        text,
    35→        cfg: mergedConfig,
    36→      });
    37→
    38→      if (!result.success || !result.audioBuffer || !result.sampleRate) {
    39→        throw new Error(result.error ?? "TTS conversion failed");
    40→      }
    41→
    42→      return convertPcmToMulaw8k(result.audioBuffer, result.sampleRate);
    43→    },
    44→  };
    45→}
    46→
    47→function applyTtsOverride(
    48→  coreConfig: CoreConfig,
    49→  override?: VoiceCallTtsConfig,
    50→): CoreConfig {
    51→  if (!override) return coreConfig;
    52→
    53→  const base = coreConfig.messages?.tts;
    54→  const merged = mergeTtsConfig(base, override);
    55→  if (!merged) return coreConfig;
    56→
    57→  return {
    58→    ...coreConfig,
    59→    messages: {
    60→      ...(coreConfig.messages ?? {}),
    61→      tts: merged,
    62→    },
    63→  };
    64→}
    65→
    66→function mergeTtsConfig(
    67→  base?: VoiceCallTtsConfig,
    68→  override?: VoiceCallTtsConfig,
    69→): VoiceCallTtsConfig | undefined {
    70→  if (!base && !override) return undefined;
    71→  if (!override) return base;
    72→  if (!base) return override;
    73→  return deepMerge(base, override);
    74→}
    75→
    76→function deepMerge<T>(base: T, override: T): T {
    77→  if (!isPlainObject(base) || !isPlainObject(override)) {
    78→    return override;
    79→  }
    80→  const result: Record<string, unknown> = { ...base };
    81→  for (const [key, value] of Object.entries(override)) {
    82→    if (value === undefined) continue;
    83→    const existing = (base as Record<string, unknown>)[key];
    84→    if (isPlainObject(existing) && isPlainObject(value)) {
    85→      result[key] = deepMerge(existing, value);
    86→    } else {
    87→      result[key] = value;
    88→    }
    89→  }
    90→  return result as T;
    91→}
    92→
    93→function isPlainObject(value: unknown): value is Record<string, unknown> {
    94→  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
    95→}
    96→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→/**
     2→ * Media Stream Handler
     3→ *
     4→ * Handles bidirectional audio streaming between Twilio and the AI services.
     5→ * - Receives mu-law audio from Twilio via WebSocket
     6→ * - Forwards to OpenAI Realtime STT for transcription
     7→ * - Sends TTS audio back to Twilio
     8→ */
     9→
    10→import type { IncomingMessage } from "node:http";
    11→import type { Duplex } from "node:stream";
    12→
    13→import { WebSocket, WebSocketServer } from "ws";
    14→
    15→import type {
    16→  OpenAIRealtimeSTTProvider,
    17→  RealtimeSTTSession,
    18→} from "./providers/stt-openai-realtime.js";
    19→
    20→/**
    21→ * Configuration for the media stream handler.
    22→ */
    23→export interface MediaStreamConfig {
    24→  /** STT provider for transcription */
    25→  sttProvider: OpenAIRealtimeSTTProvider;
    26→  /** Callback when transcript is received */
    27→  onTranscript?: (callId: string, transcript: string) => void;
    28→  /** Callback for partial transcripts (streaming UI) */
    29→  onPartialTranscript?: (callId: string, partial: string) => void;
    30→  /** Callback when stream connects */
    31→  onConnect?: (callId: string, streamSid: string) => void;
    32→  /** Callback when speech starts (barge-in) */
    33→  onSpeechStart?: (callId: string) => void;
    34→  /** Callback when stream disconnects */
    35→  onDisconnect?: (callId: string) => void;
    36→}
    37→
    38→/**
    39→ * Active media stream session.
    40→ */
    41→interface StreamSession {
    42→  callId: string;
    43→  streamSid: string;
    44→  ws: WebSocket;
    45→  sttSession: RealtimeSTTSession;
    46→}
    47→
    48→type TtsQueueEntry = {
    49→  playFn: (signal: AbortSignal) => Promise<void>;
    50→  controller: AbortController;
    51→  resolve: () => void;
    52→  reject: (error: unknown) => void;
    53→};
    54→
    55→/**
    56→ * Manages WebSocket connections for Twilio media streams.
    57→ */
    58→export class MediaStreamHandler {
    59→  private wss: WebSocketServer | null = null;
    60→  private sessions = new Map<string, StreamSession>();
    61→  private config: MediaStreamConfig;
    62→  /** TTS playback queues per stream (serialize audio to prevent overlap) */
    63→  private ttsQueues = new Map<string, TtsQueueEntry[]>();
    64→  /** Whether TTS is currently playing per stream */
    65→  private ttsPlaying = new Map<string, boolean>();
    66→  /** Active TTS playback controllers per stream */
    67→  private ttsActiveControllers = new Map<string, AbortController>();
    68→
    69→  constructor(config: MediaStreamConfig) {
    70→    this.config = config;
    71→  }
    72→
    73→  /**
    74→   * Handle WebSocket upgrade for media stream connections.
    75→   */
    76→  handleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer): void {
    77→    if (!this.wss) {
    78→      this.wss = new WebSocketServer({ noServer: true });
    79→      this.wss.on("connection", (ws, req) => this.handleConnection(ws, req));
    80→    }
    81→
    82→    this.wss.handleUpgrade(request, socket, head, (ws) => {
    83→      this.wss?.emit("connection", ws, request);
    84→    });
    85→  }
    86→
    87→  /**
    88→   * Handle new WebSocket connection from Twilio.
    89→   */
    90→  private async handleConnection(
    91→    ws: WebSocket,
    92→    _request: IncomingMessage,
    93→  ): Promise<void> {
    94→    let session: StreamSession | null = null;
    95→
    96→    ws.on("message", async (data: Buffer) => {
    97→      try {
    98→        const message = JSON.parse(data.toString()) as TwilioMediaMessage;
    99→
   100→        switch (message.event) {
   101→          case "connected":
   102→            console.log("[MediaStream] Twilio connected");
   103→            break;
   104→
   105→          case "start":
   106→            session = await this.handleStart(ws, message);
   107→            break;
   108→
   109→          case "media":
   110→            if (session && message.media?.payload) {
   111→              // Forward audio to STT
   112→              const audioBuffer = Buffer.from(message.media.payload, "base64");
   113→              session.sttSession.sendAudio(audioBuffer);
   114→            }
   115→            break;
   116→
   117→          case "stop":
   118→            if (session) {
   119→              this.handleStop(session);
   120→              session = null;
   121→            }
   122→            break;
   123→        }
   124→      } catch (error) {
   125→        console.error("[MediaStream] Error processing message:", error);
   126→      }
   127→    });
   128→
   129→    ws.on("close", () => {
   130→      if (session) {
   131→        this.handleStop(session);
   132→      }
   133→    });
   134→
   135→    ws.on("error", (error) => {
   136→      console.error("[MediaStream] WebSocket error:", error);
   137→    });
   138→  }
   139→
   140→  /**
   141→   * Handle stream start event.
   142→   */
   143→  private async handleStart(
   144→    ws: WebSocket,
   145→    message: TwilioMediaMessage,
   146→  ): Promise<StreamSession> {
   147→    const streamSid = message.streamSid || "";
   148→    const callSid = message.start?.callSid || "";
   149→
   150→    console.log(
   151→      `[MediaStream] Stream started: ${streamSid} (call: ${callSid})`,
   152→    );
   153→
   154→    // Create STT session
   155→    const sttSession = this.config.sttProvider.createSession();
   156→
   157→    // Set up transcript callbacks
   158→    sttSession.onPartial((partial) => {
   159→      this.config.onPartialTranscript?.(callSid, partial);
   160→    });
   161→
   162→    sttSession.onTranscript((transcript) => {
   163→      this.config.onTranscript?.(callSid, transcript);
   164→    });
   165→
   166→    sttSession.onSpeechStart(() => {
   167→      this.config.onSpeechStart?.(callSid);
   168→    });
   169→
   170→    const session: StreamSession = {
   171→      callId: callSid,
   172→      streamSid,
   173→      ws,
   174→      sttSession,
   175→    };
   176→
   177→    this.sessions.set(streamSid, session);
   178→
   179→    // Notify connection BEFORE STT connect so TTS can work even if STT fails
   180→    this.config.onConnect?.(callSid, streamSid);
   181→
   182→    // Connect to OpenAI STT (non-blocking, log errors but don't fail the call)
   183→    sttSession.connect().catch((err) => {
   184→      console.warn(
   185→        `[MediaStream] STT connection failed (TTS still works):`,
   186→        err.message,
   187→      );
   188→    });
   189→
   190→    return session;
   191→  }
   192→
   193→  /**
   194→   * Handle stream stop event.
   195→   */
   196→  private handleStop(session: StreamSession): void {
   197→    console.log(`[MediaStream] Stream stopped: ${session.streamSid}`);
   198→
   199→    this.clearTtsState(session.streamSid);
   200→    session.sttSession.close();
   201→    this.sessions.delete(session.streamSid);
   202→    this.config.onDisconnect?.(session.callId);
   203→  }
   204→
   205→  /**
   206→   * Get an active session with an open WebSocket, or undefined if unavailable.
   207→   */
   208→  private getOpenSession(streamSid: string): StreamSession | undefined {
   209→    const session = this.sessions.get(streamSid);
   210→    return session?.ws.readyState === WebSocket.OPEN ? session : undefined;
   211→  }
   212→
   213→  /**
   214→   * Send a message to a stream's WebSocket if available.
   215→   */
   216→  private sendToStream(streamSid: string, message: unknown): void {
   217→    const session = this.getOpenSession(streamSid);
   218→    session?.ws.send(JSON.stringify(message));
   219→  }
   220→
   221→  /**
   222→   * Send audio to a specific stream (for TTS playback).
   223→   * Audio should be mu-law encoded at 8kHz mono.
   224→   */
   225→  sendAudio(streamSid: string, muLawAudio: Buffer): void {
   226→    this.sendToStream(streamSid, {
   227→      event: "media",
   228→      streamSid,
   229→      media: { payload: muLawAudio.toString("base64") },
   230→    });
   231→  }
   232→
   233→  /**
   234→   * Send a mark event to track audio playback position.
   235→   */
   236→  sendMark(streamSid: string, name: string): void {
   237→    this.sendToStream(streamSid, {
   238→      event: "mark",
   239→      streamSid,
   240→      mark: { name },
   241→    });
   242→  }
   243→
   244→  /**
   245→   * Clear audio buffer (interrupt playback).
   246→   */
   247→  clearAudio(streamSid: string): void {
   248→    this.sendToStream(streamSid, { event: "clear", streamSid });
   249→  }
   250→
   251→  /**
   252→   * Queue a TTS operation for sequential playback.
   253→   * Only one TTS operation plays at a time per stream to prevent overlap.
   254→   */
   255→  async queueTts(
   256→    streamSid: string,
   257→    playFn: (signal: AbortSignal) => Promise<void>,
   258→  ): Promise<void> {
   259→    const queue = this.getTtsQueue(streamSid);
   260→    let resolveEntry: () => void;
   261→    let rejectEntry: (error: unknown) => void;
   262→    const promise = new Promise<void>((resolve, reject) => {
   263→      resolveEntry = resolve;
   264→      rejectEntry = reject;
   265→    });
   266→
   267→    queue.push({
   268→      playFn,
   269→      controller: new AbortController(),
   270→      resolve: resolveEntry!,
   271→      reject: rejectEntry!,
   272→    });
   273→
   274→    if (!this.ttsPlaying.get(streamSid)) {
   275→      void this.processQueue(streamSid);
   276→    }
   277→
   278→    return promise;
   279→  }
   280→
   281→  /**
   282→   * Clear TTS queue and interrupt current playback (barge-in).
   283→   */
   284→  clearTtsQueue(streamSid: string): void {
   285→    const queue = this.getTtsQueue(streamSid);
   286→    queue.length = 0;
   287→    this.ttsActiveControllers.get(streamSid)?.abort();
   288→    this.clearAudio(streamSid);
   289→  }
   290→
   291→  /**
   292→   * Get active session by call ID.
   293→   */
   294→  getSessionByCallId(callId: string): StreamSession | undefined {
   295→    return [...this.sessions.values()].find(
   296→      (session) => session.callId === callId,
   297→    );
   298→  }
   299→
   300→  /**
   301→   * Close all sessions.
   302→   */
   303→  closeAll(): void {
   304→    for (const session of this.sessions.values()) {
   305→      this.clearTtsState(session.streamSid);
   306→      session.sttSession.close();
   307→      session.ws.close();
   308→    }
   309→    this.sessions.clear();
   310→  }
   311→
   312→  private getTtsQueue(streamSid: string): TtsQueueEntry[] {
   313→    const existing = this.ttsQueues.get(streamSid);
   314→    if (existing) return existing;
   315→    const queue: TtsQueueEntry[] = [];
   316→    this.ttsQueues.set(streamSid, queue);
   317→    return queue;
   318→  }
   319→
   320→  /**
   321→   * Process the TTS queue for a stream.
   322→   * Uses iterative approach to avoid stack accumulation from recursion.
   323→   */
   324→  private async processQueue(streamSid: string): Promise<void> {
   325→    this.ttsPlaying.set(streamSid, true);
   326→
   327→    while (true) {
   328→      const queue = this.ttsQueues.get(streamSid);
   329→      if (!queue || queue.length === 0) {
   330→        this.ttsPlaying.set(streamSid, false);
   331→        this.ttsActiveControllers.delete(streamSid);
   332→        return;
   333→      }
   334→
   335→      const entry = queue.shift()!;
   336→      this.ttsActiveControllers.set(streamSid, entry.controller);
   337→
   338→      try {
   339→        await entry.playFn(entry.controller.signal);
   340→        entry.resolve();
   341→      } catch (error) {
   342→        if (entry.controller.signal.aborted) {
   343→          entry.resolve();
   344→        } else {
   345→          console.error("[MediaStream] TTS playback error:", error);
   346→          entry.reject(error);
   347→        }
   348→      } finally {
   349→        if (this.ttsActiveControllers.get(streamSid) === entry.controller) {
   350→          this.ttsActiveControllers.delete(streamSid);
   351→        }
   352→      }
   353→    }
   354→  }
   355→
   356→  private clearTtsState(streamSid: string): void {
   357→    const queue = this.ttsQueues.get(streamSid);
   358→    if (queue) queue.length = 0;
   359→    this.ttsActiveControllers.get(streamSid)?.abort();
   360→    this.ttsActiveControllers.delete(streamSid);
   361→    this.ttsPlaying.delete(streamSid);
   362→    this.ttsQueues.delete(streamSid);
   363→  }
   364→}
   365→
   366→/**
   367→ * Twilio Media Stream message format.
   368→ */
   369→interface TwilioMediaMessage {
   370→  event: "connected" | "start" | "media" | "stop" | "mark" | "clear";
   371→  sequenceNumber?: string;
   372→  streamSid?: string;
   373→  start?: {
   374→    streamSid: string;
   375→    accountSid: string;
   376→    callSid: string;
   377→    tracks: string[];
   378→    mediaFormat: {
   379→      encoding: string;
   380→      sampleRate: number;
   381→      channels: number;
   382→    };
   383→  };
   384→  media?: {
   385→    track?: string;
   386→    chunk?: string;
   387→    timestamp?: string;
   388→    payload?: string;
   389→  };
   390→  mark?: {
   391→    name: string;
   392→  };
   393→}
   394→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


Now let me look at the runtime.ts and manager.ts files to understand TwiML generation and how TTS gets applied:


     1→import type { CoreConfig } from "./core-bridge.js";
     2→import type { VoiceCallConfig } from "./config.js";
     3→import { validateProviderConfig } from "./config.js";
     4→import { CallManager } from "./manager.js";
     5→import type { VoiceCallProvider } from "./providers/base.js";
     6→import { MockProvider } from "./providers/mock.js";
     7→import { PlivoProvider } from "./providers/plivo.js";
     8→import { TelnyxProvider } from "./providers/telnyx.js";
     9→import { TwilioProvider } from "./providers/twilio.js";
    10→import type { TelephonyTtsRuntime } from "./telephony-tts.js";
    11→import { createTelephonyTtsProvider } from "./telephony-tts.js";
    12→import { startTunnel, type TunnelResult } from "./tunnel.js";
    13→import {
    14→  cleanupTailscaleExposure,
    15→  setupTailscaleExposure,
    16→  VoiceCallWebhookServer,
    17→} from "./webhook.js";
    18→
    19→export type VoiceCallRuntime = {
    20→  config: VoiceCallConfig;
    21→  provider: VoiceCallProvider;
    22→  manager: CallManager;
    23→  webhookServer: VoiceCallWebhookServer;
    24→  webhookUrl: string;
    25→  publicUrl: string | null;
    26→  stop: () => Promise<void>;
    27→};
    28→
    29→type Logger = {
    30→  info: (message: string) => void;
    31→  warn: (message: string) => void;
    32→  error: (message: string) => void;
    33→  debug: (message: string) => void;
    34→};
    35→
    36→function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
    37→  switch (config.provider) {
    38→    case "telnyx":
    39→      return new TelnyxProvider({
    40→        apiKey: config.telnyx?.apiKey ?? process.env.TELNYX_API_KEY,
    41→        connectionId:
    42→          config.telnyx?.connectionId ?? process.env.TELNYX_CONNECTION_ID,
    43→        publicKey: config.telnyx?.publicKey ?? process.env.TELNYX_PUBLIC_KEY,
    44→      });
    45→    case "twilio":
    46→      return new TwilioProvider(
    47→        {
    48→          accountSid:
    49→            config.twilio?.accountSid ?? process.env.TWILIO_ACCOUNT_SID,
    50→          authToken: config.twilio?.authToken ?? process.env.TWILIO_AUTH_TOKEN,
    51→        },
    52→        {
    53→          allowNgrokFreeTier: config.tunnel?.allowNgrokFreeTier ?? true,
    54→          publicUrl: config.publicUrl,
    55→          skipVerification: config.skipSignatureVerification,
    56→          streamPath: config.streaming?.enabled
    57→            ? config.streaming.streamPath
    58→            : undefined,
    59→        },
    60→      );
    61→    case "plivo":
    62→      return new PlivoProvider(
    63→        {
    64→          authId: config.plivo?.authId ?? process.env.PLIVO_AUTH_ID,
    65→          authToken: config.plivo?.authToken ?? process.env.PLIVO_AUTH_TOKEN,
    66→        },
    67→        {
    68→          publicUrl: config.publicUrl,
    69→          skipVerification: config.skipSignatureVerification,
    70→          ringTimeoutSec: Math.max(1, Math.floor(config.ringTimeoutMs / 1000)),
    71→        },
    72→      );
    73→    case "mock":
    74→      return new MockProvider();
    75→    default:
    76→      throw new Error(
    77→        `Unsupported voice-call provider: ${String(config.provider)}`,
    78→      );
    79→  }
    80→}
    81→
    82→export async function createVoiceCallRuntime(params: {
    83→  config: VoiceCallConfig;
    84→  coreConfig: CoreConfig;
    85→  ttsRuntime?: TelephonyTtsRuntime;
    86→  logger?: Logger;
    87→}): Promise<VoiceCallRuntime> {
    88→  const { config, coreConfig, ttsRuntime, logger } = params;
    89→  const log = logger ?? {
    90→    info: console.log,
    91→    warn: console.warn,
    92→    error: console.error,
    93→    debug: console.debug,
    94→  };
    95→
    96→  if (!config.enabled) {
    97→    throw new Error(
    98→      "Voice call disabled. Enable the plugin entry in config.",
    99→    );
   100→  }
   101→
   102→  const validation = validateProviderConfig(config);
   103→  if (!validation.valid) {
   104→    throw new Error(`Invalid voice-call config: ${validation.errors.join("; ")}`);
   105→  }
   106→
   107→  const provider = resolveProvider(config);
   108→  const manager = new CallManager(config);
   109→  const webhookServer = new VoiceCallWebhookServer(
   110→    config,
   111→    manager,
   112→    provider,
   113→    coreConfig,
   114→  );
   115→
   116→  const localUrl = await webhookServer.start();
   117→
   118→  // Determine public URL - priority: config.publicUrl > tunnel > legacy tailscale
   119→  let publicUrl: string | null = config.publicUrl ?? null;
   120→  let tunnelResult: TunnelResult | null = null;
   121→
   122→  if (!publicUrl && config.tunnel?.provider && config.tunnel.provider !== "none") {
   123→    try {
   124→      tunnelResult = await startTunnel({
   125→        provider: config.tunnel.provider,
   126→        port: config.serve.port,
   127→        path: config.serve.path,
   128→        ngrokAuthToken:
   129→          config.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN,
   130→        ngrokDomain: config.tunnel.ngrokDomain ?? process.env.NGROK_DOMAIN,
   131→      });
   132→      publicUrl = tunnelResult?.publicUrl ?? null;
   133→    } catch (err) {
   134→      log.error(
   135→        `[voice-call] Tunnel setup failed: ${
   136→          err instanceof Error ? err.message : String(err)
   137→        }`,
   138→      );
   139→    }
   140→  }
   141→
   142→  if (!publicUrl && config.tailscale?.mode !== "off") {
   143→    publicUrl = await setupTailscaleExposure(config);
   144→  }
   145→
   146→  const webhookUrl = publicUrl ?? localUrl;
   147→
   148→  if (publicUrl && provider.name === "twilio") {
   149→    (provider as TwilioProvider).setPublicUrl(publicUrl);
   150→  }
   151→
   152→  if (provider.name === "twilio" && config.streaming?.enabled) {
   153→    const twilioProvider = provider as TwilioProvider;
   154→    if (ttsRuntime?.textToSpeechTelephony) {
   155→      try {
   156→        const ttsProvider = createTelephonyTtsProvider({
   157→          coreConfig,
   158→          ttsOverride: config.tts,
   159→          runtime: ttsRuntime,
   160→        });
   161→        twilioProvider.setTTSProvider(ttsProvider);
   162→        log.info("[voice-call] Telephony TTS provider configured");
   163→      } catch (err) {
   164→        log.warn(
   165→          `[voice-call] Failed to initialize telephony TTS: ${
   166→            err instanceof Error ? err.message : String(err)
   167→          }`,
   168→        );
   169→      }
   170→    } else {
   171→      log.warn("[voice-call] Telephony TTS unavailable; streaming TTS disabled");
   172→    }
   173→
   174→    const mediaHandler = webhookServer.getMediaStreamHandler();
   175→    if (mediaHandler) {
   176→      twilioProvider.setMediaStreamHandler(mediaHandler);
   177→      log.info("[voice-call] Media stream handler wired to provider");
   178→    }
   179→  }
   180→
   181→  manager.initialize(provider, webhookUrl);
   182→
   183→  const stop = async () => {
   184→    if (tunnelResult) {
   185→      await tunnelResult.stop();
   186→    }
   187→    await cleanupTailscaleExposure(config);
   188→    await webhookServer.stop();
   189→  };
   190→
   191→  log.info("[voice-call] Runtime initialized");
   192→  log.info(`[voice-call] Webhook URL: ${webhookUrl}`);
   193→  if (publicUrl) {
   194→    log.info(`[voice-call] Public URL: ${publicUrl}`);
   195→  }
   196→
   197→  return {
   198→    config,
   199→    provider,
   200→    manager,
   201→    webhookServer,
   202→    webhookUrl,
   203→    publicUrl,
   204→    stop,
   205→  };
   206→}
   207→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


import crypto from "node:crypto";
import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";

import { resolveUserPath } from "./utils.js";
import type { CallMode, VoiceCallConfig } from "./config.js";
import type { VoiceCallProvider } from "./providers/base.js";
import {
  type CallId,
  type CallRecord,
  CallRecordSchema,
  type CallState,
  type NormalizedEvent,
  type OutboundCallOptions,
  TerminalStates,
  type TranscriptEntry,
} from "./types.js";
import { escapeXml, mapVoiceToPolly } from "./voice-mapping.js";

/**
 * Manages voice calls: state machine, persistence, and provider coordination.
 */
export class CallManager {
  private activeCalls = new Map<CallId, CallRecord>();
  private providerCallIdMap = new Map<string, CallId>(); // providerCallId -> internal callId
  private processedEventIds = new Set<string>();
  private provider: VoiceCallProvider | null = null;
  private config: VoiceCallConfig;
  private storePath: string;
  private webhookUrl: string | null = null;
  private transcriptWaiters = new Map<
    CallId,
    {
      resolve: (text: string) => void;
      reject: (err: Error) => void;
      timeout: NodeJS.Timeout;
    }
  >();
  /** Max duration timers to auto-hangup calls after configured timeout */
  private maxDurationTimers = new Map<CallId, NodeJS.Timeout>();

  constructor(config: VoiceCallConfig, storePath?: string) {
    this.config = config;
    // Resolve store path with tilde expansion (like other config values)
    const rawPath =
      storePath ||
      config.store ||
      path.join(os.homedir(), "clawd", "voice-calls");
    this.storePath = resolveUserPath(rawPath);
  }

  /**
   * Initialize the call manager with a provider.
   */
  initialize(provider: VoiceCallProvider, webhookUrl: string): void {
    this.provider = provider;
    this.webhookUrl = webhookUrl;

    // Ensure store directory exists
    fs.mkdirSync(this.storePath, { recursive: true });

    // Load any persisted active calls
    this.loadActiveCalls();
  }

  /**
   * Get the current provider.
   */
  getProvider(): VoiceCallProvider | null {
    return this.provider;
  }

  /**
   * Initiate an outbound call.
   * @param to - The phone number to call
   * @param sessionKey - Optional session key for context
   * @param options - Optional call options (message, mode)
   */
  async initiateCall(
    to: string,
    sessionKey?: string,
    options?: OutboundCallOptions | string,
  ): Promise<{ callId: CallId; success: boolean; error?: string }> {
    // Support legacy string argument for initialMessage
    const opts: OutboundCallOptions =
      typeof options === "string" ? { message: options } : (options ?? {});
    const initialMessage = opts.message;
    const mode = opts.mode ?? this.config.outbound.defaultMode;
    if (!this.provider) {
      return { callId: "", success: false, error: "Provider not initialized" };
    }

    if (!this.webhookUrl) {
      return {
        callId: "",
        success: false,
        error: "Webhook URL not configured",
      };
    }

    // Check concurrent call limit
    const activeCalls = this.getActiveCalls();
    if (activeCalls.length >= this.config.maxConcurrentCalls) {
      return {
        callId: "",
        success: false,
        error: `Maximum concurrent calls (${this.config.maxConcurrentCalls}) reached`,
      };
    }

    const callId = crypto.randomUUID();
    const from =
      this.config.fromNumber ||
      (this.provider?.name === "mock" ? "+15550000000" : undefined);
    if (!from) {
      return { callId: "", success: false, error: "fromNumber not configured" };
    }

    // Create call record with mode in metadata
    const callRecord: CallRecord = {
      callId,
      provider: this.provider.name,
      direction: "outbound",
      state: "initiated",
      from,
      to,
      sessionKey,
      startedAt: Date.now(),
      transcript: [],
      processedEventIds: [],
      metadata: {
        ...(initialMessage && { initialMessage }),
        mode,
      },
    };

    this.activeCalls.set(callId, callRecord);
    this.persistCallRecord(callRecord);

    try {
      // For notify mode with a message, use inline TwiML with <Say>
      let inlineTwiml: string | undefined;
      if (mode === "notify" && initialMessage) {
        const pollyVoice = mapVoiceToPolly(this.config.tts?.openai?.voice);
        inlineTwiml = this.generateNotifyTwiml(initialMessage, pollyVoice);
        console.log(
          `[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`,
        );
      }

      const result = await this.provider.initiateCall({
        callId,
        from,
        to,
        webhookUrl: this.webhookUrl,
        inlineTwiml,
      });

      callRecord.providerCallId = result.providerCallId;
      this.providerCallIdMap.set(result.providerCallId, callId); // Map providerCallId to internal callId
      this.persistCallRecord(callRecord);

      return { callId, success: true };
    } catch (err) {
      callRecord.state = "failed";
      callRecord.endedAt = Date.now();
      callRecord.endReason = "failed";
      this.persistCallRecord(callRecord);
      this.activeCalls.delete(callId);
      if (callRecord.providerCallId) {
        this.providerCallIdMap.delete(callRecord.providerCallId);
      }

      return {
        callId,
        success: false,
        error: err instanceof Error ? err.message : String(err),
      };
    }
  }

  /**
   * Speak to user in an active call.
   */
  async speak(
    callId: CallId,
    text: string,
  ): Promise<{ success: boolean; error?: string }> {
    const call = this.activeCalls.get(callId);
    if (!call) {
      return { success: false, error: "Call not found" };
    }

    if (!this.provider || !call.providerCallId) {
      return { success: false, error: "Call not connected" };
    }

    if (TerminalStates.has(call.state)) {


Let me continue reading the manager.ts to see TwiML generation and TTS handling:


grep -n "generateNotifyTwiml\|generateTwiml\|Say\|TwiML\|ElevenLabs\|synthesize" /home/ccuser/.clawdbot/extensions/voice-call/src/manager.ts | head -40


143:      // For notify mode with a message, use inline TwiML with <Say>
147:        inlineTwiml = this.generateNotifyTwiml(initialMessage, pollyVoice);
149:          `[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`,
679:    // Twilio has provider-specific state for speaking (<Say> fallback) and can
867:   * Generate TwiML for notify mode (speak message and hang up).
869:  private generateNotifyTwiml(message: string, voice: string): string {
872:  <Say voice="${voice}">${escapeXml(message)}</Say>


Let me read more of manager.ts around the TwiML generation and speak functions:


sed -n '850,950p' /home/ccuser/.clawdbot/extensions/voice-call/src/manager.ts


    // Only keep non-terminal calls
    for (const [callId, call] of callMap) {
      if (!TerminalStates.has(call.state)) {
        this.activeCalls.set(callId, call);
        // Populate providerCallId mapping for lookups
        if (call.providerCallId) {
          this.providerCallIdMap.set(call.providerCallId, callId);
        }
        // Populate processed event IDs
        for (const eventId of call.processedEventIds) {
          this.processedEventIds.add(eventId);
        }
      }
    }
  }

  /**
   * Generate TwiML for notify mode (speak message and hang up).
   */
  private generateNotifyTwiml(message: string, voice: string): string {
    return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say voice="${voice}">${escapeXml(message)}</Say>
  <Hangup/>
</Response>`;
  }
}


Let me check the provider files to understand how TwiML is generated and TTS is applied in the webhook response:


total 104
drwxrwxr-x 3 ccuser ccuser  4096 Feb 11 18:22 .
drwxrwxr-x 4 ccuser ccuser  4096 Feb 11 18:22 ..
-rw-r--r-- 1 ccuser ccuser  1748 Feb 11 18:22 base.ts
-rw-r--r-- 1 ccuser ccuser   356 Feb 11 18:22 index.ts
-rw-r--r-- 1 ccuser ccuser  4212 Feb 11 18:22 mock.ts
-rw-r--r-- 1 ccuser ccuser  1134 Feb 11 18:22 plivo.test.ts
-rw-r--r-- 1 ccuser ccuser 14998 Feb 11 18:22 plivo.ts
-rw-r--r-- 1 ccuser ccuser  8763 Feb 11 18:22 stt-openai-realtime.ts
-rw-r--r-- 1 ccuser ccuser  9899 Feb 11 18:22 telnyx.ts
-rw-r--r-- 1 ccuser ccuser  7300 Feb 11 18:22 tts-openai.ts
drwxrwxr-x 2 ccuser ccuser  4096 Feb 11 18:22 twilio
-rw-r--r-- 1 ccuser ccuser  1876 Feb 11 18:22 twilio.test.ts
-rw-r--r-- 1 ccuser ccuser 19091 Feb 11 18:22 twilio.ts


Now let me read the twilio.ts provider file since that's likely where the TwiML and webhook response logic is: