     1→import crypto from "node:crypto";
     2→
     3→import type { TwilioConfig } from "../config.js";
     4→import type { MediaStreamHandler } from "../media-stream.js";
     5→import type {
     6→  HangupCallInput,
     7→  InitiateCallInput,
     8→  InitiateCallResult,
     9→  NormalizedEvent,
    10→  PlayTtsInput,
    11→  ProviderWebhookParseResult,
    12→  StartListeningInput,
    13→  StopListeningInput,
    14→  WebhookContext,
    15→  WebhookVerificationResult,
    16→} from "../types.js";
    17→import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js";
    18→import { chunkAudio } from "../telephony-audio.js";
    19→import type { TelephonyTtsProvider } from "../telephony-tts.js";
    20→import type { VoiceCallProvider } from "./base.js";
    21→import { twilioApiRequest } from "./twilio/api.js";
    22→import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
    23→
    24→/**
    25→ * Twilio Voice API provider implementation.
    26→ *
    27→ * Uses Twilio Programmable Voice API with Media Streams for real-time
    28→ * bidirectional audio streaming.
    29→ *
    30→ * @see https://www.twilio.com/docs/voice
    31→ * @see https://www.twilio.com/docs/voice/media-streams
    32→ */
    33→export interface TwilioProviderOptions {
    34→  /** Allow ngrok free tier compatibility mode (less secure) */
    35→  allowNgrokFreeTier?: boolean;
    36→  /** Override public URL for signature verification */
    37→  publicUrl?: string;
    38→  /** Path for media stream WebSocket (e.g., /voice/stream) */
    39→  streamPath?: string;
    40→  /** Skip webhook signature verification (development only) */
    41→  skipVerification?: boolean;
    42→}
    43→
    44→export class TwilioProvider implements VoiceCallProvider {
    45→  readonly name = "twilio" as const;
    46→
    47→  private readonly accountSid: string;
    48→  private readonly authToken: string;
    49→  private readonly baseUrl: string;
    50→  private readonly callWebhookUrls = new Map<string, string>();
    51→  private readonly options: TwilioProviderOptions;
    52→
    53→  /** Current public webhook URL (set when tunnel starts or from config) */
    54→  private currentPublicUrl: string | null = null;
    55→
    56→  /** Optional telephony TTS provider for streaming TTS */
    57→  private ttsProvider: TelephonyTtsProvider | null = null;
    58→
    59→  /** Optional media stream handler for sending audio */
    60→  private mediaStreamHandler: MediaStreamHandler | null = null;
    61→
    62→  /** Map of call SID to stream SID for media streams */
    63→  private callStreamMap = new Map<string, string>();
    64→
    65→  /** Storage for TwiML content (for notify mode with URL-based TwiML) */
    66→  private readonly twimlStorage = new Map<string, string>();
    67→  /** Track notify-mode calls to avoid streaming on follow-up callbacks */
    68→  private readonly notifyCalls = new Set<string>();
    69→
    70→  /**
    71→   * Delete stored TwiML for a given `callId`.
    72→   *
    73→   * We keep TwiML in-memory only long enough to satisfy the initial Twilio
    74→   * webhook request (notify mode). Subsequent webhooks should not reuse it.
    75→   */
    76→  private deleteStoredTwiml(callId: string): void {
    77→    this.twimlStorage.delete(callId);
    78→    this.notifyCalls.delete(callId);
    79→  }
    80→
    81→  /**
    82→   * Delete stored TwiML for a call, addressed by Twilio's provider call SID.
    83→   *
    84→   * This is used when we only have `providerCallId` (e.g. hangup).
    85→   */
    86→  private deleteStoredTwimlForProviderCall(providerCallId: string): void {
    87→    const webhookUrl = this.callWebhookUrls.get(providerCallId);
    88→    if (!webhookUrl) return;
    89→
    90→    const callIdMatch = webhookUrl.match(/callId=([^&]+)/);
    91→    if (!callIdMatch) return;
    92→
    93→    this.deleteStoredTwiml(callIdMatch[1]);
    94→  }
    95→
    96→  constructor(config: TwilioConfig, options: TwilioProviderOptions = {}) {
    97→    if (!config.accountSid) {
    98→      throw new Error("Twilio Account SID is required");
    99→    }
   100→    if (!config.authToken) {
   101→      throw new Error("Twilio Auth Token is required");
   102→    }
   103→
   104→    this.accountSid = config.accountSid;
   105→    this.authToken = config.authToken;
   106→    this.baseUrl = `https://api.twilio.com/2010-04-01/Accounts/${this.accountSid}`;
   107→    this.options = options;
   108→
   109→    if (options.publicUrl) {
   110→      this.currentPublicUrl = options.publicUrl;
   111→    }
   112→  }
   113→
   114→  setPublicUrl(url: string): void {
   115→    this.currentPublicUrl = url;
   116→  }
   117→
   118→  getPublicUrl(): string | null {
   119→    return this.currentPublicUrl;
   120→  }
   121→
   122→  setTTSProvider(provider: TelephonyTtsProvider): void {
   123→    this.ttsProvider = provider;
   124→  }
   125→
   126→  setMediaStreamHandler(handler: MediaStreamHandler): void {
   127→    this.mediaStreamHandler = handler;
   128→  }
   129→
   130→  registerCallStream(callSid: string, streamSid: string): void {
   131→    this.callStreamMap.set(callSid, streamSid);
   132→  }
   133→
   134→  unregisterCallStream(callSid: string): void {
   135→    this.callStreamMap.delete(callSid);
   136→  }
   137→
   138→  /**
   139→   * Clear TTS queue for a call (barge-in).
   140→   * Used when user starts speaking to interrupt current TTS playback.
   141→   */
   142→  clearTtsQueue(callSid: string): void {
   143→    const streamSid = this.callStreamMap.get(callSid);
   144→    if (streamSid && this.mediaStreamHandler) {
   145→      this.mediaStreamHandler.clearTtsQueue(streamSid);
   146→    }
   147→  }
   148→
   149→  /**
   150→   * Make an authenticated request to the Twilio API.
   151→   */
   152→  private async apiRequest<T = unknown>(
   153→    endpoint: string,
   154→    params: Record<string, string | string[]>,
   155→    options?: { allowNotFound?: boolean },
   156→  ): Promise<T> {
   157→    return await twilioApiRequest<T>({
   158→      baseUrl: this.baseUrl,
   159→      accountSid: this.accountSid,
   160→      authToken: this.authToken,
   161→      endpoint,
   162→      body: params,
   163→      allowNotFound: options?.allowNotFound,
   164→    });
   165→  }
   166→
   167→  /**
   168→   * Verify Twilio webhook signature using HMAC-SHA1.
   169→   *
   170→   * Handles reverse proxy scenarios (Tailscale, nginx, ngrok) by reconstructing
   171→   * the public URL from forwarding headers.
   172→   *
   173→   * @see https://www.twilio.com/docs/usage/webhooks/webhooks-security
   174→   */
   175→  verifyWebhook(ctx: WebhookContext): WebhookVerificationResult {
   176→    return verifyTwilioProviderWebhook({
   177→      ctx,
   178→      authToken: this.authToken,
   179→      currentPublicUrl: this.currentPublicUrl,
   180→      options: this.options,
   181→    });
   182→  }
   183→
   184→  /**
   185→   * Parse Twilio webhook event into normalized format.
   186→   */
   187→  parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult {
   188→    try {
   189→      const params = new URLSearchParams(ctx.rawBody);
   190→      const callIdFromQuery =
   191→        typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
   192→          ? ctx.query.callId.trim()
   193→          : undefined;
   194→      const event = this.normalizeEvent(params, callIdFromQuery);
   195→
   196→      // For Twilio, we must return TwiML. Most actions are driven by Calls API updates,
   197→      // so the webhook response is typically a pause to keep the call alive.
   198→      const twiml = this.generateTwimlResponse(ctx);
   199→
   200→      return {
   201→        events: event ? [event] : [],
   202→        providerResponseBody: twiml,
   203→        providerResponseHeaders: { "Content-Type": "application/xml" },
   204→        statusCode: 200,
   205→      };
   206→    } catch {
   207→      return { events: [], statusCode: 400 };
   208→    }
   209→  }
   210→
   211→  /**
   212→   * Parse Twilio direction to normalized format.
   213→   */
   214→  private static parseDirection(
   215→    direction: string | null,
   216→  ): "inbound" | "outbound" | undefined {
   217→    if (direction === "inbound") return "inbound";
   218→    if (direction === "outbound-api" || direction === "outbound-dial")
   219→      return "outbound";
   220→    return undefined;
   221→  }
   222→
   223→  /**
   224→   * Convert Twilio webhook params to normalized event format.
   225→   */
   226→  private normalizeEvent(
   227→    params: URLSearchParams,
   228→    callIdOverride?: string,
   229→  ): NormalizedEvent | null {
   230→    const callSid = params.get("CallSid") || "";
   231→
   232→    const baseEvent = {
   233→      id: crypto.randomUUID(),
   234→      callId: callIdOverride || callSid,
   235→      providerCallId: callSid,
   236→      timestamp: Date.now(),
   237→      direction: TwilioProvider.parseDirection(params.get("Direction")),
   238→      from: params.get("From") || undefined,
   239→      to: params.get("To") || undefined,
   240→    };
   241→
   242→    // Handle speech result (from <Gather>)
   243→    const speechResult = params.get("SpeechResult");
   244→    if (speechResult) {
   245→      return {
   246→        ...baseEvent,
   247→        type: "call.speech",
   248→        transcript: speechResult,
   249→        isFinal: true,
   250→        confidence: parseFloat(params.get("Confidence") || "0.9"),
   251→      };
   252→    }
   253→
   254→    // Handle DTMF
   255→    const digits = params.get("Digits");
   256→    if (digits) {
   257→      return { ...baseEvent, type: "call.dtmf", digits };
   258→    }
   259→
   260→    // Handle call status changes
   261→    const callStatus = params.get("CallStatus");
   262→    switch (callStatus) {
   263→      case "initiated":
   264→        return { ...baseEvent, type: "call.initiated" };
   265→      case "ringing":
   266→        return { ...baseEvent, type: "call.ringing" };
   267→      case "in-progress":
   268→        return { ...baseEvent, type: "call.answered" };
   269→      case "completed":
   270→      case "busy":
   271→      case "no-answer":
   272→      case "failed":
   273→        if (callIdOverride) {
   274→          this.deleteStoredTwiml(callIdOverride);
   275→        }
   276→        return { ...baseEvent, type: "call.ended", reason: callStatus };
   277→      case "canceled":
   278→        if (callIdOverride) {
   279→          this.deleteStoredTwiml(callIdOverride);
   280→        }
   281→        return { ...baseEvent, type: "call.ended", reason: "hangup-bot" };
   282→      default:
   283→        return null;
   284→    }
   285→  }
   286→
   287→  private static readonly EMPTY_TWIML =
   288→    '<?xml version="1.0" encoding="UTF-8"?><Response></Response>';
   289→
   290→  private static readonly PAUSE_TWIML = `<?xml version="1.0" encoding="UTF-8"?>
   291→<Response>
   292→  <Pause length="30"/>
   293→</Response>`;
   294→
   295→  /**
   296→   * Generate TwiML response for webhook.
   297→   * When a call is answered, connects to media stream for bidirectional audio.
   298→   */
   299→  private generateTwimlResponse(ctx?: WebhookContext): string {
   300→    if (!ctx) return TwilioProvider.EMPTY_TWIML;
   301→
   302→    const params = new URLSearchParams(ctx.rawBody);
   303→    const type =
   304→      typeof ctx.query?.type === "string" ? ctx.query.type.trim() : undefined;
   305→    const isStatusCallback = type === "status";
   306→    const callStatus = params.get("CallStatus");
   307→    const direction = params.get("Direction");
   308→    const isOutbound = direction?.startsWith("outbound") ?? false;
   309→    const callIdFromQuery =
   310→      typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
   311→        ? ctx.query.callId.trim()
   312→        : undefined;
   313→
   314→    // Avoid logging webhook params/TwiML (may contain PII).
   315→
   316→    // Handle initial TwiML request (when Twilio first initiates the call)
   317→    // Check if we have stored TwiML for this call (notify mode)
   318→    if (callIdFromQuery && !isStatusCallback) {
   319→      const storedTwiml = this.twimlStorage.get(callIdFromQuery);
   320→      if (storedTwiml) {
   321→        // Clean up after serving (one-time use)
   322→        this.deleteStoredTwiml(callIdFromQuery);
   323→        return storedTwiml;
   324→      }
   325→      if (this.notifyCalls.has(callIdFromQuery)) {
   326→        return TwilioProvider.EMPTY_TWIML;
   327→      }
   328→
   329→      // Conversation mode: return streaming TwiML immediately for outbound calls.
   330→      if (isOutbound) {
   331→        const streamUrl = this.getStreamUrl();
   332→        return streamUrl
   333→          ? this.getStreamConnectXml(streamUrl)
   334→          : TwilioProvider.PAUSE_TWIML;
   335→      }
   336→    }
   337→
   338→    // Status callbacks should not receive TwiML.
   339→    if (isStatusCallback) {
   340→      return TwilioProvider.EMPTY_TWIML;
   341→    }
   342→
   343→    // Handle subsequent webhook requests (status callbacks, etc.)
   344→    // For inbound calls, answer immediately with stream
   345→    if (direction === "inbound") {
   346→      const streamUrl = this.getStreamUrl();
   347→      return streamUrl
   348→        ? this.getStreamConnectXml(streamUrl)
   349→        : TwilioProvider.PAUSE_TWIML;
   350→    }
   351→
   352→    // For outbound calls, only connect to stream when call is in-progress
   353→    if (callStatus !== "in-progress") {
   354→      return TwilioProvider.EMPTY_TWIML;
   355→    }
   356→
   357→    const streamUrl = this.getStreamUrl();
   358→    return streamUrl
   359→      ? this.getStreamConnectXml(streamUrl)
   360→      : TwilioProvider.PAUSE_TWIML;
   361→  }
   362→
   363→  /**
   364→   * Get the WebSocket URL for media streaming.
   365→   * Derives from the public URL origin + stream path.
   366→   */
   367→  private getStreamUrl(): string | null {
   368→    if (!this.currentPublicUrl || !this.options.streamPath) {
   369→      return null;
   370→    }
   371→
   372→    // Extract just the origin (host) from the public URL, ignoring any path
   373→    const url = new URL(this.currentPublicUrl);
   374→    const origin = url.origin;
   375→
   376→    // Convert https:// to wss:// for WebSocket
   377→    const wsOrigin = origin
   378→      .replace(/^https:\/\//, "wss://")
   379→      .replace(/^http:\/\//, "ws://");
   380→
   381→    // Append the stream path
   382→    const path = this.options.streamPath.startsWith("/")
   383→      ? this.options.streamPath
   384→      : `/${this.options.streamPath}`;
   385→
   386→    return `${wsOrigin}${path}`;
   387→  }
   388→
   389→  /**
   390→   * Generate TwiML to connect a call to a WebSocket media stream.
   391→   * This enables bidirectional audio streaming for real-time STT/TTS.
   392→   *
   393→   * @param streamUrl - WebSocket URL (wss://...) for the media stream
   394→   */
   395→  getStreamConnectXml(streamUrl: string): string {
   396→    return `<?xml version="1.0" encoding="UTF-8"?>
   397→<Response>
   398→  <Connect>
   399→    <Stream url="${escapeXml(streamUrl)}" />
   400→  </Connect>
   401→</Response>`;
   402→  }
   403→
   404→  /**
   405→   * Initiate an outbound call via Twilio API.
   406→   * If inlineTwiml is provided, uses that directly (for notify mode).
   407→   * Otherwise, uses webhook URL for dynamic TwiML.
   408→   */
   409→  async initiateCall(input: InitiateCallInput): Promise<InitiateCallResult> {
   410→    const url = new URL(input.webhookUrl);
   411→    url.searchParams.set("callId", input.callId);
   412→
   413→    // Create separate URL for status callbacks (required by Twilio)
   414→    const statusUrl = new URL(input.webhookUrl);
   415→    statusUrl.searchParams.set("callId", input.callId);
   416→    statusUrl.searchParams.set("type", "status"); // Differentiate from TwiML requests
   417→
   418→    // Store TwiML content if provided (for notify mode)
   419→    // We now serve it from the webhook endpoint instead of sending inline
   420→    if (input.inlineTwiml) {
   421→      this.twimlStorage.set(input.callId, input.inlineTwiml);
   422→      this.notifyCalls.add(input.callId);
   423→    }
   424→
   425→    // Build request params - always use URL-based TwiML.
   426→    // Twilio silently ignores `StatusCallback` when using the inline `Twiml` parameter.
   427→    const params: Record<string, string | string[]> = {
   428→      To: input.to,
   429→      From: input.from,
   430→      Url: url.toString(), // TwiML serving endpoint
   431→      StatusCallback: statusUrl.toString(), // Separate status callback endpoint
   432→      StatusCallbackEvent: ["initiated", "ringing", "answered", "completed"],
   433→      Timeout: "30",
   434→    };
   435→
   436→    const result = await this.apiRequest<TwilioCallResponse>(
   437→      "/Calls.json",
   438→      params,
   439→    );
   440→
   441→    this.callWebhookUrls.set(result.sid, url.toString());
   442→
   443→    return {
   444→      providerCallId: result.sid,
   445→      status: result.status === "queued" ? "queued" : "initiated",
   446→    };
   447→  }
   448→
   449→  /**
   450→   * Hang up a call via Twilio API.
   451→   */
   452→  async hangupCall(input: HangupCallInput): Promise<void> {
   453→    this.deleteStoredTwimlForProviderCall(input.providerCallId);
   454→
   455→    this.callWebhookUrls.delete(input.providerCallId);
   456→
   457→    await this.apiRequest(
   458→      `/Calls/${input.providerCallId}.json`,
   459→      { Status: "completed" },
   460→      { allowNotFound: true },
   461→    );
   462→  }
   463→
   464→  /**
   465→   * Play TTS audio via Twilio.
   466→   *
   467→   * Two modes:
   468→   * 1. Core TTS + Media Streams: If TTS provider and media stream are available,
   469→   *    generates audio via core TTS and streams it through WebSocket (preferred).
   470→   * 2. TwiML <Say>: Falls back to Twilio's native TTS with Polly voices.
   471→   *    Note: This may not work on all Twilio accounts.
   472→   */
   473→  async playTts(input: PlayTtsInput): Promise<void> {
   474→    // Try telephony TTS via media stream first (if configured)
   475→    const streamSid = this.callStreamMap.get(input.providerCallId);
   476→    if (this.ttsProvider && this.mediaStreamHandler && streamSid) {
   477→      try {
   478→        await this.playTtsViaStream(input.text, streamSid);
   479→        return;
   480→      } catch (err) {
   481→        console.warn(
   482→          `[voice-call] Telephony TTS failed, falling back to Twilio <Say>:`,
   483→          err instanceof Error ? err.message : err,
   484→        );
   485→        // Fall through to TwiML <Say> fallback
   486→      }
   487→    }
   488→
   489→    // Fall back to TwiML <Say> (may not work on all accounts)
   490→    const webhookUrl = this.callWebhookUrls.get(input.providerCallId);
   491→    if (!webhookUrl) {
   492→      throw new Error(
   493→        "Missing webhook URL for this call (provider state not initialized)",
   494→      );
   495→    }
   496→
   497→    console.warn(
   498→      "[voice-call] Using TwiML <Say> fallback - telephony TTS not configured or media stream not active",
   499→    );
   500→
   501→    const pollyVoice = mapVoiceToPolly(input.voice);
   502→    const twiml = `<?xml version="1.0" encoding="UTF-8"?>
   503→<Response>
   504→  <Say voice="${pollyVoice}" language="${input.locale || "en-US"}">${escapeXml(input.text)}</Say>
   505→  <Gather input="speech" speechTimeout="auto" action="${escapeXml(webhookUrl)}" method="POST">
   506→    <Say>.</Say>
   507→  </Gather>
   508→</Response>`;
   509→
   510→    await this.apiRequest(`/Calls/${input.providerCallId}.json`, {
   511→      Twiml: twiml,
   512→    });
   513→  }
   514→
   515→  /**
   516→   * Play TTS via core TTS and Twilio Media Streams.
   517→   * Generates audio with core TTS, converts to mu-law, and streams via WebSocket.
   518→   * Uses a queue to serialize playback and prevent overlapping audio.
   519→   */
   520→  private async playTtsViaStream(
   521→    text: string,
   522→    streamSid: string,
   523→  ): Promise<void> {
   524→    if (!this.ttsProvider || !this.mediaStreamHandler) {
   525→      throw new Error("TTS provider and media stream handler required");
   526→    }
   527→
   528→    // Stream audio in 20ms chunks (160 bytes at 8kHz mu-law)
   529→    const CHUNK_SIZE = 160;
   530→    const CHUNK_DELAY_MS = 20;
   531→
   532→    const handler = this.mediaStreamHandler;
   533→    const ttsProvider = this.ttsProvider;
   534→    await handler.queueTts(streamSid, async (signal) => {
   535→      // Generate audio with core TTS (returns mu-law at 8kHz)
   536→      const muLawAudio = await ttsProvider.synthesizeForTelephony(text);
   537→      for (const chunk of chunkAudio(muLawAudio, CHUNK_SIZE)) {
   538→        if (signal.aborted) break;
   539→        handler.sendAudio(streamSid, chunk);
   540→
   541→        // Pace the audio to match real-time playback
   542→        await new Promise((resolve) => setTimeout(resolve, CHUNK_DELAY_MS));
   543→        if (signal.aborted) break;
   544→      }
   545→
   546→      if (!signal.aborted) {
   547→        // Send a mark to track when audio finishes
   548→        handler.sendMark(streamSid, `tts-${Date.now()}`);
   549→      }
   550→    });
   551→  }
   552→
   553→  /**
   554→   * Start listening for speech via Twilio <Gather>.
   555→   */
   556→  async startListening(input: StartListeningInput): Promise<void> {
   557→    const webhookUrl = this.callWebhookUrls.get(input.providerCallId);
   558→    if (!webhookUrl) {
   559→      throw new Error(
   560→        "Missing webhook URL for this call (provider state not initialized)",
   561→      );
   562→    }
   563→
   564→    const twiml = `<?xml version="1.0" encoding="UTF-8"?>
   565→<Response>
   566→  <Gather input="speech" speechTimeout="auto" language="${input.language || "en-US"}" action="${escapeXml(webhookUrl)}" method="POST">
   567→  </Gather>
   568→</Response>`;
   569→
   570→    await this.apiRequest(`/Calls/${input.providerCallId}.json`, {
   571→      Twiml: twiml,
   572→    });
   573→  }
   574→
   575→  /**
   576→   * Stop listening - for Twilio this is a no-op as <Gather> auto-ends.
   577→   */
   578→  async stopListening(_input: StopListeningInput): Promise<void> {
   579→    // Twilio's <Gather> automatically stops on speech end
   580→    // No explicit action needed
   581→  }
   582→}
   583→
   584→// -----------------------------------------------------------------------------
   585→// Twilio-specific types
   586→// -----------------------------------------------------------------------------
   587→
   588→interface TwilioCallResponse {
   589→  sid: string;
   590→  status: string;
   591→  direction: string;
   592→  from: string;
   593→  to: string;
   594→  uri: string;
   595→}
   596→

<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>


Perfect! Now let me check the webhook verification module in twilio:


total 16
drwxrwxr-x 2 ccuser ccuser 4096 Feb 11 18:22 .
drwxrwxr-x 3 ccuser ccuser 4096 Feb 11 18:22 ..
-rw-r--r-- 1 ccuser ccuser 1404 Feb 11 18:22 api.ts
-rw-r--r-- 1 ccuser ccuser  939 Feb 11 18:22 webhook.ts


     1→import type { WebhookContext, WebhookVerificationResult } from "../../types.js";
     2→import { verifyTwilioWebhook } from "../../webhook-security.js";
     3→
     4→import type { TwilioProviderOptions } from "../twilio.js";
     5→
     6→export function verifyTwilioProviderWebhook(params: {
     7→  ctx: WebhookContext;
     8→  authToken: string;
     9→  currentPublicUrl?: string | null;
    10→  options: TwilioProviderOptions;
    11→}): WebhookVerificationResult {
    12→  const result = verifyTwilioWebhook(params.ctx, params.authToken, {
    13→    publicUrl: params.currentPublicUrl || undefined,
    14→    allowNgrokFreeTier: params.options.allowNgrokFreeTier ?? true,
    15→    skipVerification: params.options.skipVerification,
    16→  });
    17→
    18→  if (!result.ok) {
    19→    console.warn(`[twilio] Webhook verification failed: ${result.reason}`);
    20→    if (result.verificationUrl) {
    21→      console.warn(`[twilio] Verification URL: ${result.verificationUrl}`);
    22→    }
    23→  }
    24→
    25→  return {
    26→    ok: result.ok,
    27→    reason: result.reason,
    28→  };
    29→}
    30→

<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→export async function twilioApiRequest<T = unknown>(params: {
     2→  baseUrl: string;
     3→  accountSid: string;
     4→  authToken: string;
     5→  endpoint: string;
     6→  body: URLSearchParams | Record<string, string | string[]>;
     7→  allowNotFound?: boolean;
     8→}): Promise<T> {
     9→  const bodyParams =
    10→    params.body instanceof URLSearchParams
    11→      ? params.body
    12→      : Object.entries(params.body).reduce<URLSearchParams>(
    13→          (acc, [key, value]) => {
    14→            if (Array.isArray(value)) {
    15→              for (const entry of value) {
    16→                acc.append(key, entry);
    17→              }
    18→            } else if (typeof value === "string") {
    19→              acc.append(key, value);
    20→            }
    21→            return acc;
    22→          },
    23→          new URLSearchParams(),
    24→        );
    25→
    26→  const response = await fetch(`${params.baseUrl}${params.endpoint}`, {
    27→    method: "POST",
    28→    headers: {
    29→      Authorization: `Basic ${Buffer.from(`${params.accountSid}:${params.authToken}`).toString("base64")}`,
    30→      "Content-Type": "application/x-www-form-urlencoded",
    31→    },
    32→    body: bodyParams,
    33→  });
    34→
    35→  if (!response.ok) {
    36→    if (params.allowNotFound && response.status === 404) {
    37→      return undefined as T;
    38→    }
    39→    const errorText = await response.text();
    40→    throw new Error(`Twilio API error: ${response.status} ${errorText}`);
    41→  }
    42→
    43→  const text = await response.text();
    44→  return text ? (JSON.parse(text) as T) : (undefined as T);
    45→}
    46→

<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>


Excellent! Now let me get a quick look at the speak method in manager.ts to see how TTS is applied:


Bash

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


  /**
   * Speak the initial message for a call (called when media stream connects).
   * This is used to auto-play the message passed to initiateCall.
   * In notify mode, auto-hangup after the message is delivered.
   */
  async speakInitialMessage(providerCallId: string): Promise<void> {
    const call = this.getCallByProviderCallId(providerCallId);
    if (!call) {
      console.warn(
        `[voice-call] speakInitialMessage: no call found for ${providerCallId}`,
      );
      return;
    }

    const initialMessage = call.metadata?.initialMessage as string | undefined;
    const mode = (call.metadata?.mode as CallMode) ?? "conversation";

    if (!initialMessage) {
      console.log(
        `[voice-call] speakInitialMessage: no initial message for ${call.callId}`,
      );
      return;
    }

    // Clear the initial message so we don't speak it again
    if (call.metadata) {
      delete call.metadata.initialMessage;
      this.persistCallRecord(call);
    }

    console.log(
      `[voice-call] Speaking initial message for call ${call.callId} (mode: ${mode})`,
    );
    const result = await this.speak(call.callId, initialMessage);
    if (!result.success) {
      console.warn(
        `[voice-call] Failed to speak initial message: ${result.error}`,
      );
      return;
    }

    // In notify mode, auto-hangup after delay
    if (mode === "notify") {
      const delaySec = this.config.outbound.notifyHangupDelaySec;
      console.log(
        `[voice-call] Notify mode: auto-hangup in ${delaySec}s for call ${call.callId}`,
      );
      setTimeout(async () => {
        const currentCall = this.getCall(call.callId);
        if (currentCall && !TerminalStates.has(currentCall.state)) {
          console.log(
            `[voice-call] Notify mode: hanging up call ${call.callId}`,
          );
          await this.endCall(call.callId);
        }
      }, delaySec * 1000);
    }
  }

  /**
   * Start max duration timer for a call.
   * Auto-hangup when maxDurationSeconds is reached.
   */
  private startMaxDurationTimer(callId: CallId): void {
    // Clear any existing timer
    this.clearMaxDurationTimer(callId);

    const maxDurationMs = this.config.maxDurationSeconds * 1000;
    console.log(
      `[voice-call] Starting max duration timer (${this.config.maxDurationSeconds}s) for call ${callId}`,
    );

    const timer = setTimeout(async () => {
      this.maxDurationTimers.delete(callId);
      const call = this.getCall(callId);
      if (call && !TerminalStates.has(call.state)) {
        console.log(
          `[voice-call] Max duration reached (${this.config.maxDurationSeconds}s), ending call ${callId}`,
        );
        call.endReason = "timeout";
        this.persistCallRecord(call);
        await this.endCall(callId);
      }
    }, maxDurationMs);

    this.maxDurationTimers.set(callId, timer);
  }

  /**
   * Clear max duration timer for a call.


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


   */
  async continueCall(
    callId: CallId,
    prompt: string,
  ): Promise<{ success: boolean; transcript?: string; 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)) {
      return { success: false, error: "Call has ended" };
    }

    try {
      await this.speak(callId, prompt);

      call.state = "listening";
      this.persistCallRecord(call);

      await this.provider.startListening({
        callId,
        providerCallId: call.providerCallId,
      });

      const transcript = await this.waitForFinalTranscript(callId);

      // Best-effort: stop listening after final transcript.
      await this.provider.stopListening({
        callId,
        providerCallId: call.providerCallId,
      });

      return { success: true, transcript };
    } catch (err) {
      return {
        success: false,
        error: err instanceof Error ? err.message : String(err),
      };
    } finally {
      this.clearTranscriptWaiter(callId);
    }
  }

  /**
   * End an active call.
   */
  async endCall(callId: CallId): 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)) {
      return { success: true }; // Already ended
    }

    try {
      await this.provider.hangupCall({
        callId,
        providerCallId: call.providerCallId,
        reason: "hangup-bot",
      });

      call.state = "hangup-bot";
      call.endedAt = Date.now();
      call.endReason = "hangup-bot";
      this.persistCallRecord(call);
      this.clearMaxDurationTimer(callId);
      this.rejectTranscriptWaiter(callId, "Call ended: hangup-bot");
      this.activeCalls.delete(callId);
      if (call.providerCallId) {
        this.providerCallIdMap.delete(call.providerCallId);
      }

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

  /**
   * Check if an inbound call should be accepted based on policy.
   */
  private shouldAcceptInbound(from: string | undefined): boolean {
    const { inboundPolicy: policy, allowFrom } = this.config;

    switch (policy) {
      case "disabled":
        console.log("[voice-call] Inbound call rejected: policy is disabled");
        return false;

      case "open":
        console.log("[voice-call] Inbound call accepted: policy is open");
        return true;

      case "allowlist":
      case "pairing": {
        const normalized = from?.replace(/\D/g, "") || "";
        const allowed = (allowFrom || []).some((num) => {
          const normalizedAllow = num.replace(/\D/g, "");
          return (
            normalized.endsWith(normalizedAllow) ||
            normalizedAllow.endsWith(normalized)
          );
        });
        const status = allowed ? "accepted" : "rejected";
        console.log(
          `[voice-call] Inbound call ${status}: ${from} ${allowed ? "is in" : "not in"} allowlist`,
        );
        return allowed;
      }

      default:
        return false;
    }
  }

  /**
   * Create a call record for an inbound call.


Now let me find the speak method:


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


   * Clear max duration timer for a call.
   */
  private clearMaxDurationTimer(callId: CallId): void {
    const timer = this.maxDurationTimers.get(callId);
    if (timer) {
      clearTimeout(timer);
      this.maxDurationTimers.delete(callId);
    }
  }

  private clearTranscriptWaiter(callId: CallId): void {
    const waiter = this.transcriptWaiters.get(callId);
    if (!waiter) return;
    clearTimeout(waiter.timeout);
    this.transcriptWaiters.delete(callId);
  }

  private rejectTranscriptWaiter(callId: CallId, reason: string): void {
    const waiter = this.transcriptWaiters.get(callId);
    if (!waiter) return;
    this.clearTranscriptWaiter(callId);
    waiter.reject(new Error(reason));
  }

  private resolveTranscriptWaiter(callId: CallId, transcript: string): void {
    const waiter = this.transcriptWaiters.get(callId);
    if (!waiter) return;
    this.clearTranscriptWaiter(callId);
    waiter.resolve(transcript);
  }

  private waitForFinalTranscript(callId: CallId): Promise<string> {
    // Only allow one in-flight waiter per call.
    this.rejectTranscriptWaiter(callId, "Transcript waiter replaced");

    const timeoutMs = this.config.transcriptTimeoutMs;
    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        this.transcriptWaiters.delete(callId);
        reject(
          new Error(`Timed out waiting for transcript after ${timeoutMs}ms`),
        );
      }, timeoutMs);

      this.transcriptWaiters.set(callId, { resolve, reject, timeout });
    });
  }

  /**
   * Continue call: speak prompt, then wait for user's final transcript.
   */


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


      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)) {
      return { success: false, error: "Call has ended" };
    }

    try {
      // Update state
      call.state = "speaking";
      this.persistCallRecord(call);

      // Add to transcript
      this.addTranscriptEntry(call, "bot", text);

      // Play TTS
      const voice =
        this.provider?.name === "twilio" ? this.config.tts?.openai?.voice : undefined;
      await this.provider.playTts({
        callId,
        providerCallId: call.providerCallId,
        text,
        voice,
      });

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


Perfect! Now I have all the information needed. Let me compile a comprehensive report.

## Voice-Call Plugin Source Code Analysis

I've thoroughly reviewed the voice-call plugin at `/home/ccuser/.clawdbot/extensions/voice-call/`. Here are the detailed findings for your questions:

---

## 1. Webhook Request Handling

**File:** `/home/ccuser/.clawdbot/extensions/voice-call/src/webhook.ts` (lines 224-295)

The webhook handler is implemented in the `VoiceCallWebhookServer` class:

```typescript
private async handleRequest(
  req: http.IncomingMessage,
  res: http.ServerResponse,
  webhookPath: string,
): Promise<void> {
  const url = new URL(req.url || "/", `http://${req.headers.host}`);

  // Check path
  if (!url.pathname.startsWith(webhookPath)) {
    res.statusCode = 404;
    res.end("Not Found");
    return;
  }

  // Only accept POST
  if (req.method !== "POST") {
    res.statusCode = 405;
    res.end("Method Not Allowed");
    return;
  }

  // Read body
  const body = await this.readBody(req);

  // Build webhook context
  const ctx: WebhookContext = {
    headers: req.headers as Record<string, string | string[] | undefined>,
    rawBody: body,
    url: `http://${req.headers.host}${req.url}`,
    method: "POST",
    query: Object.fromEntries(url.searchParams),
  };

  // Verify signature
  const verification = this.provider.verifyWebhook(ctx);
  if (!verification.ok) {
    console.warn(
      `[voice-call] Webhook verification failed: ${verification.reason}`,
    );
    res.statusCode = 401;
    res.end("Unauthorized");
    return;
  }

  // Parse events
  const result = this.provider.parseWebhookEvent(ctx);

  // Process each event
  for (const event of result.events) {
    try {
      this.manager.processEvent(event);
    } catch (err) {
      console.error(
        `[voice-call] Error processing event ${event.type}:`,
        err,
      );
    }
  }

  // Send response
  res.statusCode = result.statusCode || 200;

  if (result.providerResponseHeaders) {
    for (const [key, value] of Object.entries(
      result.providerResponseHeaders,
    )) {
      res.setHeader(key, value);
    }
  }

  res.end(result.providerResponseBody || "OK");
}
```

---

## 2. TwiML Response Generation

**File:** `/home/ccuser/.clawdbot/extensions/voice-call/src/providers/twilio.ts` (lines 287-402)

The provider generates different TwiML responses based on call state and mode:

### Notify Mode TwiML (inline Say + Hangup):
**File:** `/home/ccuser/.clawdbot/extensions/voice-call/src/manager.ts` (lines 869-873)

```typescript
private generateNotifyTwiml(message: string, voice: string): string {
  return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say voice="${voice}">${escapeXml(message)}</Say>
  <Hangup/>
</Response>`;
}
```

### Streaming TwiML (Connect + Stream):
**File:** `/home/ccuser/.clawdbot/extensions/voice-call/src/providers/twilio.ts` (lines 395-401)

```typescript
getStreamConnectXml(streamUrl: string): string {
  return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Connect>
    <Stream url="${escapeXml(streamUrl)}" />
  </Connect>
</Response>`;
}
```

### Main Response Generator Logic:
**File:** `/home/ccuser/.clawdbot/extensions/voice-call/src/providers/twilio.ts` (lines 299-361)

```typescript
private generateTwimlResponse(ctx?: WebhookContext): string {
  if (!ctx) return TwilioProvider.EMPTY_TWIML;

  const params = new URLSearchParams(ctx.rawBody);
  const type =
    typeof ctx.query?.type === "string" ? ctx.query.type.trim() : undefined;
  const isStatusCallback = type === "status";
  const callStatus = params.get("CallStatus");
  const direction = params.get("Direction");
  const isOutbound = direction?.startsWith("outbound") ?? false;
  const callIdFromQuery =
    typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
      ? ctx.query.callId.trim()
      : undefined;

  // Handle initial TwiML request (when Twilio first initiates the call)
  // Check if we have stored TwiML for this call (notify mode)
  if (callIdFromQuery && !isStatusCallback) {
    const storedTwiml = this.twimlStorage.get(callIdFromQuery);
    if (storedTwiml) {
      // Clean up after serving (one-time use)
      this.deleteStoredTwiml(callIdFromQuery);
      return storedTwiml;
    }
    if (this.notifyCalls.has(callIdFromQuery)) {
      return TwilioProvider.EMPTY_TWIML;
    }

    // Conversation mode: return streaming TwiML immediately for outbound calls.
    if (isOutbound) {
      const streamUrl = this.getStreamUrl();
      return streamUrl
        ? this.getStreamConnectXml(streamUrl)
        : TwilioProvider.PAUSE_TWIML;
    }
  }

  // Status callbacks should not receive TwiML.
  if (isStatusCallback) {
    return TwilioProvider.EMPTY_TWIML;
  }

  // Handle subsequent webhook requests (status callbacks, etc.)
  // For inbound calls, answer immediately with stream
  if (direction === "inbound") {
    const streamUrl = this.getStreamUrl();
    return streamUrl
      ? this.getStreamConnectXml(streamUrl)
      : TwilioProvider.PAUSE_TWIML;
  }

  // For outbound calls, only connect to stream when call is in-progress
  if (callStatus !== "in-progress") {
    return TwilioProvider.EMPTY_TWIML;
  }

  const streamUrl = this.getStreamUrl();
  return streamUrl
    ? this.getStreamConnectXml(streamUrl)
    : TwilioProvider.PAUSE_TWIML;
}
```

---

## 3. Twilio Signature Verification

**File:** `/home/ccuser/.clawdbot/extensions/voice-call/src/webhook-security.ts` (lines 13-43, 74-118, 152-216)

### Core Signature Validation:
```typescript
export function validateTwilioSignature(
  authToken: string,
  signature: string | undefined,
  url: string,
  params: URLSearchParams,
): boolean {
  if (!signature) {
    return false;
  }

  // Build the string to sign: URL + sorted params (key+value pairs)
  let dataToSign = url;

  // Sort params alphabetically and append key+value
  const sortedParams = Array.from(params.entries()).sort((a, b) =>
    a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0,
  );

  for (const [key, value] of sortedParams) {
    dataToSign += key + value;
  }

  // HMAC-SHA1 with auth token, then base64 encode
  const expectedSignature = crypto
    .createHmac("sha1", authToken)
    .update(dataToSign)
    .digest("base64");

  // Use timing-safe comparison to prevent timing attacks
  return timingSafeEqual(signature, expectedSignature);
}
```

### **Critical: URL Validation for Signature**

The signature is validated against **reconstructed URLs with special proxy handling** (lines 74-118):

```typescript
export function reconstructWebhookUrl(ctx: WebhookContext): string {
  const { headers } = ctx;

  const proto = getHeader(headers, "x-forwarded-proto") || "https";

  const forwardedHost =
    getHeader(headers, "x-forwarded-host") ||
    getHeader(headers, "x-original-host") ||
    getHeader(headers, "ngrok-forwarded-host") ||
    getHeader(headers, "host") ||
    "";

  // Extract path from the context URL (fallback to "/" on parse failure)
  let path = "/";
  try {
    const parsed = new URL(ctx.url);
    path = parsed.pathname + parsed.search;
  } catch {
    // URL parsing failed
  }

  // Remove port from host (ngrok URLs don't have ports)
  const host = forwardedHost.split(":")[0] || forwardedHost;

  return `${proto}://${host}${path}`;
}

function buildTwilioVerificationUrl(
  ctx: WebhookContext,
  publicUrl?: string,
): string {
  if (!publicUrl) {
    return reconstructWebhookUrl(ctx);
  }

  try {
    const base = new URL(publicUrl);
    const requestUrl = new URL(ctx.url);
    base.pathname = requestUrl.pathname;
    base.search = requestUrl.search;
    return base.toString();
  } catch {
    return publicUrl;
  }
}
```

**Key points:**
- Validates against **reconstructed public URL** (not the local request URL)
- Priority order: `X-Forwarded-Proto` + `X-Forwarded-Host` > `X-Original-Host` > `Ngrok-Forwarded-Host` > `Host` header
- If `publicUrl` is configured in settings, it uses that as the origin but keeps the pathname/search from the actual request
- Timing-safe comparison prevents timing attacks

**Full verification function** (lines 152-216):
```typescript
export function verifyTwilioWebhook(
  ctx: WebhookContext,
  authToken: string,
  options?: {
    publicUrl?: string;
    allowNgrokFreeTier?: boolean;
    skipVerification?: boolean;
  },
): TwilioVerificationResult {
  // Allow skipping verification for development/testing
  if (options?.skipVerification) {
    return { ok: true, reason: "verification skipped (dev mode)" };
  }

  const signature = getHeader(ctx.headers, "x-twilio-signature");

  if (!signature) {
    return { ok: false, reason: "Missing X-Twilio-Signature header" };
  }

  // Reconstruct the URL Twilio used
  const verificationUrl = buildTwilioVerificationUrl(ctx, options?.publicUrl);

  // Parse the body as URL-encoded params
  const params = new URLSearchParams(ctx.rawBody);

  // Validate signature
  const isValid = validateTwilioSignature(
    authToken,
    signature,
    verificationUrl,
    params,
  );

  if (isValid) {
    return { ok: true, verificationUrl };
  }

  // Check if this is ngrok free tier - the URL might have different format
  const isNgrokFreeTier =
    verificationUrl.includes(".ngrok-free.app") ||
    verificationUrl.includes(".ngrok.io");

  if (isNgrokFreeTier && options?.allowNgrokFreeTier) {
    console.warn(
      "[voice-call] Twilio signature validation failed (proceeding for ngrok free tier compatibility)",
    );
    return {
      ok: true,
      reason: "ngrok free tier compatibility mode",
      verificationUrl,
      isNgrokFreeTier: true,
    };
  }

  return {
    ok: false,
    reason: `Invalid signature for URL: ${verificationUrl}`,
    verificationUrl,
    isNgrokFreeTier,
  };
}
```

---

## 4. Media Streaming Implementation

**File:** `/home/ccuser/.clawdbot/extensions/voice-call/src/media-stream.ts`

### Initialization:
**File:** `/home/ccuser/.clawdbot/extensions/voice-call/src/webhook.ts` (lines 56-155)

```typescript
private initializeMediaStreaming(): void {
  const apiKey =
    this.config.streaming?.openaiApiKey || process.env.OPENAI_API_KEY;

  if (!apiKey) {
    console.warn(
      "[voice-call] Streaming enabled but no OpenAI API key found",
    );
    return;
  }

  const sttProvider = new OpenAIRealtimeSTTProvider({
    apiKey,
    model: this.config.streaming?.sttModel,
    silenceDurationMs: this.config.streaming?.silenceDurationMs,
    vadThreshold: this.config.streaming?.vadThreshold,
  });

  const streamConfig: MediaStreamConfig = {
    sttProvider,
    onTranscript: (providerCallId, transcript) => {
      console.log(
        `[voice-call] Transcript for ${providerCallId}: ${transcript}`,
      );

      // Clear TTS queue on barge-in (user started speaking, interrupt current playback)
      if (this.provider.name === "twilio") {
        (this.provider as TwilioProvider).clearTtsQueue(providerCallId);
      }

      // Look up our internal call ID from the provider call ID
      const call = this.manager.getCallByProviderCallId(providerCallId);
      if (!call) {
        console.warn(
          `[voice-call] No active call found for provider ID: ${providerCallId}`,
        );
        return;
      }

      // Create a speech event and process it through the manager
      const event: NormalizedEvent = {
        id: `stream-transcript-${Date.now()}`,
        type: "call.speech",
        callId: call.callId,
        providerCallId,
        timestamp: Date.now(),
        transcript,
        isFinal: true,
      };
      this.manager.processEvent(event);

      // Auto-respond in conversation mode
      const callMode = call.metadata?.mode as string | undefined;
      const shouldRespond =
        call.direction === "inbound" || callMode === "conversation";
      if (shouldRespond) {
        this.handleInboundResponse(call.callId, transcript).catch((err) => {
          console.warn(`[voice-call] Failed to auto-respond:`, err);
        });
      }
    },
    // ... other callbacks for speech start, partial transcripts, etc.
  };

  this.mediaStreamHandler = new MediaStreamHandler(streamConfig);
}
```

### WebSocket Connection:
**File:** `/home/ccuser/.clawdbot/extensions/voice-call/src/webhook.ts` (lines 173-188)

```typescript
// Handle WebSocket upgrades for media streams
if (this.mediaStreamHandler) {
  this.server.on("upgrade", (request, socket, head) => {
    const url = new URL(
      request.url || "/",
      `http://${request.headers.host}`,
    );

    if (url.pathname === streamPath) {
      console.log("[voice-call] WebSocket upgrade for media stream");
      this.mediaStreamHandler?.handleUpgrade(request, socket, head);
    } else {
      socket.destroy();
    }
  });
}
```

### TTS Streaming via Media Stream:
**File:** `/home/ccuser/.clawdbot/extensions/voice-call/src/providers/twilio.ts` (lines 520-551)

```typescript
private async playTtsViaStream(
  text: string,
  streamSid: string,
): Promise<void> {
  if (!this.ttsProvider || !this.mediaStreamHandler) {
    throw new Error("TTS provider and media stream handler required");
  }

  // Stream audio in 20ms chunks (160 bytes at 8kHz mu-law)
  const CHUNK_SIZE = 160;
  const CHUNK_DELAY_MS = 20;

  const handler = this.mediaStreamHandler;
  const ttsProvider = this.ttsProvider;
  await handler.queueTts(streamSid, async (signal) => {
    // Generate audio with core TTS (returns mu-law at 8kHz)
    const muLawAudio = await ttsProvider.synthesizeForTelephony(text);
    for (const chunk of chunkAudio(muLawAudio, CHUNK_SIZE)) {
      if (signal.aborted) break;
      handler.sendAudio(streamSid, chunk);

      // Pace the audio to match real-time playback
      await new Promise((resolve) => setTimeout(resolve, CHUNK_DELAY_MS));
      if (signal.aborted) break;
    }

    if (!signal.aborted) {
      // Send a mark to track when audio finishes
      handler.sendMark(streamSid, `tts-${Date.now()}`);
    }
  });
}
```

### Barge-in (Interrupt) Handling:
**File:** `/home/ccuser/.clawdbot/extensions/voice-call/src/providers/twilio.ts` (lines 142-147)

```typescript
clearTtsQueue(callSid: string): void {
  const streamSid = this.callStreamMap.get(callSid);
  if (streamSid && this.mediaStreamHandler) {
    this.mediaStreamHandler.clearTtsQueue(streamSid);
  }
}
```

When user speech is detected during streaming, the TTS queue is cleared to allow natural conversation flow.

---

## 5. TTS Application (ElevenLabs vs Say Fallback)

**File:** `/home/ccuser/.clawdbot/extensions/voice-call/src/providers/twilio.ts` (lines 473-513)

### Dual-Mode TTS Strategy:

```typescript
async playTts(input: PlayTtsInput): Promise<void> {
  // Try telephony TTS via media stream first (if configured)
  const streamSid = this.callStreamMap.get(input.providerCallId);
  if (this.ttsProvider && this.mediaStreamHandler && streamSid) {
    try {
      await this.playTtsViaStream(input.text, streamSid);
      return;
    } catch (err) {
      console.warn(
        `[voice-call] Telephony TTS failed, falling back to Twilio <Say>:`,
        err instanceof Error ? err.message : err,
      );
      // Fall through to TwiML <Say> fallback
    }
  }

  // Fall back to TwiML <Say> (may not work on all accounts)
  const webhookUrl = this.callWebhookUrls.get(input.providerCallId);
  if (!webhookUrl) {
    throw new Error(
      "Missing webhook URL for this call (provider state not initialized)",
    );
  }

  console.warn(
    "[voice-call] Using TwiML <Say> fallback - telephony TTS not configured or media stream not active",
  );

  const pollyVoice = mapVoiceToPolly(input.voice);
  const twiml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say voice="${pollyVoice}" language="${input.locale || "en-US"}">${escapeXml(input.text)}</Say>
  <Gather input="speech" speechTimeout="auto" action="${escapeXml(webhookUrl)}" method="POST">
    <Say>.</Say>
  </Gather>
</Response>`;

  await this.apiRequest(`/Calls/${input.providerCallId}.json`, {
    Twiml: twiml,
  });
}
```

### TTS Provider Configuration:
**File:** `/home/ccuser/.clawdbot/extensions/voice-call/src/telephony-tts.ts` (lines 23-45)

```typescript
export function createTelephonyTtsProvider(params: {
  coreConfig: CoreConfig;
  ttsOverride?: VoiceCallTtsConfig;
  runtime: TelephonyTtsRuntime;
}): TelephonyTtsProvider {
  const { coreConfig, ttsOverride, runtime } = params;
  const mergedConfig = applyTtsOverride(coreConfig, ttsOverride);

  return {
    synthesizeForTelephony: async (text: string) => {
      const result = await runtime.textToSpeechTelephony({
        text,
        cfg: mergedConfig,
      });

      if (!result.success || !result.audioBuffer || !result.sampleRate) {
        throw new Error(result.error ?? "TTS conversion failed");
      }

      return convertPcmToMulaw8k(result.audioBuffer, result.sampleRate);
    },
  };
}
```

**Key points:**
1. **Primary path:** Uses core TTS provider (which can be ElevenLabs, OpenAI TTS, or other providers from the core system)
2. **Configuration merge:** The voice-call plugin's TTS config overrides/deep-merges with the core system's TTS config
3. **Audio conversion:** Audio is converted from PCM to mu-law 8kHz format for Twilio streaming
4. **Fallback:** If streaming TTS fails or is not configured, falls back to Twilio's native `<Say>` element with Polly voices
5. **Voice mapping:** OpenAI voice names are mapped to Polly voice names for the fallback

### Voice Mapping:
**File:** `/home/ccuser/.clawdbot/extensions/voice-call/src/voice-mapping.ts`

This maps voice IDs to Polly voice names for the TwiML fallback.

---

## Summary Table

| Component | Details |
|-----------|---------|
| **Webhook Path** | Configurable via `serve.path` (default/standard) |
| **HTTP Method** | POST only |
| **Signature Verification** | HMAC-SHA1 with `X-Twilio-Signature` header |
| **URL for Verification** | Reconstructed from proxy headers (X-Forwarded-Proto, X-Forwarded-Host) or `publicUrl` config |
| **Initial TwiML** | Stored in memory if notify mode; otherwise returns `<Connect><Stream>` for streaming |
| **Media Streaming** | WebSocket upgrade on `/voice/stream` (configurable) |
| **STT Provider** | OpenAI Realtime for streaming calls |
| **TTS Priority** | 1. Core TTS (ElevenLabs/OpenAI) via media stream 2. Fallback to `<Say>` with Polly voices |
| **Audio Format** | mu-law 8kHz mono for streaming |
| **Barge-in Support** | Yes - clears TTS queue when user starts speaking |

All paths are absolute as requested. The architecture supports both simple notify-mode calls (with inline `<Say>` TwiML) and advanced streaming conversations with bidirectional audio, intelligent TTS fallback, and speech interruption handling.