Quellcodebibliothek Statistik Leitseite products/Sources/formale Sprachen/Java/Openclaw/extensions/voice-call/src/providers/   (KI Agentensystem Version 22©)  Datei vom 26.3.2026 mit Größe 17 kB image not shown  

Quelle  plivo.ts

  Sprache: JAVA
 

Spracherkennung für: .ts vermutete Sprache: Unknown {[0] [0] [0]} [Methode: Schwerpunktbildung, einfache Gewichte, sechs Dimensionen]

import crypto from "node:crypto";
import {
  normalizeLowercaseStringOrEmpty,
  normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
import type { PlivoConfig, WebhookSecurityConfig } from "../config.js";
import { getHeader } from "../http-headers.js";
import type {
  GetCallStatusInput,
  GetCallStatusResult,
  HangupCallInput,
  InitiateCallInput,
  InitiateCallResult,
  NormalizedEvent,
  PlayTtsInput,
  ProviderWebhookParseResult,
  StartListeningInput,
  StopListeningInput,
  WebhookContext,
  WebhookParseOptions,
  WebhookVerificationResult,
} from "../types.js";
import { escapeXml } from "../voice-mapping.js";
import { reconstructWebhookUrl, verifyPlivoWebhook } from "../webhook-security.js";
import type { VoiceCallProvider } from "./base.js";
import { guardedJsonApiRequest } from "./shared/guarded-json-api.js";

export interface PlivoProviderOptions {
  /** Override public URL origin for signature verification */
  publicUrl?: string;
  /** Skip webhook signature verification (development only) */
  skipVerification?: boolean;
  /** Outbound ring timeout in seconds */
  ringTimeoutSec?: number;
  /** Webhook security options (forwarded headers/allowlist) */
  webhookSecurity?: WebhookSecurityConfig;
}

type PendingSpeak = { text: string; locale?: string };
type PendingListen = { language?: string };

function createPlivoRequestDedupeKey(ctx: WebhookContext): string {
  const nonceV3 = getHeader(ctx.headers, "x-plivo-signature-v3-nonce");
  if (nonceV3) {
    return `plivo:v3:${nonceV3}`;
  }
  const nonceV2 = getHeader(ctx.headers, "x-plivo-signature-v2-nonce");
  if (nonceV2) {
    return `plivo:v2:${nonceV2}`;
  }
  return `plivo:fallback:${crypto.createHash("sha256").update(ctx.rawBody).digest("hex")}`;
}

export class PlivoProvider implements VoiceCallProvider {
  readonly name = "plivo" as const;

  private readonly authId: string;
  private readonly authToken: string;
  private readonly baseUrl: string;
  private readonly options: PlivoProviderOptions;
  private readonly apiHost: string;

  // Best-effort mapping between create-call request UUID and call UUID.
  private requestUuidToCallUuid = new Map<string, string>();

  // Used for transfer URLs and GetInput action URLs.
  private callIdToWebhookUrl = new Map<string, string>();
  private callUuidToWebhookUrl = new Map<string, string>();

  private pendingSpeakByCallId = new Map<string, PendingSpeak>();
  private pendingListenByCallId = new Map<string, PendingListen>();

  constructor(config: PlivoConfig, options: PlivoProviderOptions = {}) {
    if (!config.authId) {
      throw new Error("Plivo Auth ID is required");
    }
    if (!config.authToken) {
      throw new Error("Plivo Auth Token is required");
    }

    this.authId = config.authId;
    this.authToken = config.authToken;
    this.baseUrl = `https://api.plivo.com/v1/Account/${this.authId}`;
    this.apiHost = new URL(this.baseUrl).hostname;
    this.options = options;
  }

  private async apiRequest<T = unknown>(params: {
    method: "GET" | "POST" | "DELETE";
    endpoint: string;
    body?: Record<string, unknown>;
    allowNotFound?: boolean;
  }): Promise<T> {
    const { method, endpoint, body, allowNotFound } = params;
    return await guardedJsonApiRequest<T>({
      url: `${this.baseUrl}${endpoint}`,
      method,
      headers: {
        Authorization: `Basic ${Buffer.from(`${this.authId}:${this.authToken}`).toString("base64")}`,
        "Content-Type": "application/json",
      },
      body,
      allowNotFound,
      allowedHostnames: [this.apiHost],
      auditContext: "voice-call.plivo.api",
      errorPrefix: "Plivo API error",
    });
  }

  verifyWebhook(ctx: WebhookContext): WebhookVerificationResult {
    const result = verifyPlivoWebhook(ctx, this.authToken, {
      publicUrl: this.options.publicUrl,
      skipVerification: this.options.skipVerification,
      allowedHosts: this.options.webhookSecurity?.allowedHosts,
      trustForwardingHeaders: this.options.webhookSecurity?.trustForwardingHeaders,
      trustedProxyIPs: this.options.webhookSecurity?.trustedProxyIPs,
      remoteIP: ctx.remoteAddress,
    });

    if (!result.ok) {
      console.warn(`[plivo] Webhook verification failed: ${result.reason}`);
    }

    return {
      ok: result.ok,
      reason: result.reason,
      isReplay: result.isReplay,
      verifiedRequestKey: result.verifiedRequestKey,
    };
  }

  parseWebhookEvent(
    ctx: WebhookContext,
    options?: WebhookParseOptions,
  ): ProviderWebhookParseResult {
    const flow = normalizeOptionalString(ctx.query?.flow) ?? "";

    const parsed = this.parseBody(ctx.rawBody);
    if (!parsed) {
      return { events: [], statusCode: 400 };
    }

    // Keep providerCallId mapping for later call control.
    const callUuid = parsed.get("CallUUID") || undefined;
    if (callUuid) {
      const webhookBase = this.baseWebhookUrlFromCtx(ctx);
      if (webhookBase) {
        this.callUuidToWebhookUrl.set(callUuid, webhookBase);
      }
    }

    // Special flows that exist only to return Plivo XML (no events).
    if (flow === "xml-speak") {
      const callId = this.getCallIdFromQuery(ctx);
      const pending = callId ? this.pendingSpeakByCallId.get(callId) : undefined;
      if (callId) {
        this.pendingSpeakByCallId.delete(callId);
      }

      const xml = pending
        ? PlivoProvider.xmlSpeak(pending.text, pending.locale)
        : PlivoProvider.xmlKeepAlive();
      return {
        events: [],
        providerResponseBody: xml,
        providerResponseHeaders: { "Content-Type": "text/xml" },
        statusCode: 200,
      };
    }

    if (flow === "xml-listen") {
      const callId = this.getCallIdFromQuery(ctx);
      const pending = callId ? this.pendingListenByCallId.get(callId) : undefined;
      if (callId) {
        this.pendingListenByCallId.delete(callId);
      }

      const actionUrl = this.buildActionUrl(ctx, {
        flow: "getinput",
        callId,
      });

      const xml =
        actionUrl && callId
          ? PlivoProvider.xmlGetInputSpeech({
              actionUrl,
              language: pending?.language,
            })
          : PlivoProvider.xmlKeepAlive();

      return {
        events: [],
        providerResponseBody: xml,
        providerResponseHeaders: { "Content-Type": "text/xml" },
        statusCode: 200,
      };
    }

    // Normal events.
    const callIdFromQuery = this.getCallIdFromQuery(ctx);
    const dedupeKey = options?.verifiedRequestKey ?? createPlivoRequestDedupeKey(ctx);
    const event = this.normalizeEvent(parsed, callIdFromQuery, dedupeKey);

    return {
      events: event ? [event] : [],
      providerResponseBody:
        flow === "answer" || flow === "getinput"
          ? PlivoProvider.xmlKeepAlive()
          : PlivoProvider.xmlEmpty(),
      providerResponseHeaders: { "Content-Type": "text/xml" },
      statusCode: 200,
    };
  }

  private normalizeEvent(
    params: URLSearchParams,
    callIdOverride?: string,
    dedupeKey?: string,
  ): NormalizedEvent | null {
    const callUuid = params.get("CallUUID") || "";
    const requestUuid = params.get("RequestUUID") || "";

    if (requestUuid && callUuid) {
      this.requestUuidToCallUuid.set(requestUuid, callUuid);
    }

    const direction = params.get("Direction");
    const from = params.get("From") || undefined;
    const to = params.get("To") || undefined;
    const callStatus = params.get("CallStatus");

    const baseEvent = {
      id: crypto.randomUUID(),
      dedupeKey,
      callId: callIdOverride || callUuid || requestUuid,
      providerCallId: callUuid || requestUuid || undefined,
      timestamp: Date.now(),
      direction:
        direction === "inbound"
          ? ("inbound" as const)
          : direction === "outbound"
            ? ("outbound" as const)
            : undefined,
      from,
      to,
    };

    const digits = params.get("Digits");
    if (digits) {
      return { ...baseEvent, type: "call.dtmf", digits };
    }

    const transcript = PlivoProvider.extractTranscript(params);
    if (transcript) {
      return {
        ...baseEvent,
        type: "call.speech",
        transcript,
        isFinal: true,
      };
    }

    // Call lifecycle.
    if (callStatus === "ringing") {
      return { ...baseEvent, type: "call.ringing" };
    }

    if (callStatus === "in-progress") {
      return { ...baseEvent, type: "call.answered" };
    }

    if (
      callStatus === "completed" ||
      callStatus === "busy" ||
      callStatus === "no-answer" ||
      callStatus === "failed"
    ) {
      return {
        ...baseEvent,
        type: "call.ended",
        reason:
          callStatus === "completed"
            ? "completed"
            : callStatus === "busy"
              ? "busy"
              : callStatus === "no-answer"
                ? "no-answer"
                : "failed",
      };
    }

    // Plivo will call our answer_url when the call is answered; if we don't have
    // a CallStatus for some reason, treat it as answered so the call can proceed.
    if (params.get("Event") === "StartApp" && callUuid) {
      return { ...baseEvent, type: "call.answered" };
    }

    return null;
  }

  async initiateCall(input: InitiateCallInput): Promise<InitiateCallResult> {
    const webhookUrl = new URL(input.webhookUrl);
    webhookUrl.searchParams.set("provider", "plivo");
    webhookUrl.searchParams.set("callId", input.callId);

    const answerUrl = new URL(webhookUrl);
    answerUrl.searchParams.set("flow", "answer");

    const hangupUrl = new URL(webhookUrl);
    hangupUrl.searchParams.set("flow", "hangup");

    this.callIdToWebhookUrl.set(input.callId, input.webhookUrl);

    const ringTimeoutSec = this.options.ringTimeoutSec ?? 30;

    const result = await this.apiRequest<PlivoCreateCallResponse>({
      method: "POST",
      endpoint: "/Call/",
      body: {
        from: PlivoProvider.normalizeNumber(input.from),
        to: PlivoProvider.normalizeNumber(input.to),
        answer_url: answerUrl.toString(),
        answer_method: "POST",
        hangup_url: hangupUrl.toString(),
        hangup_method: "POST",
        // Plivo's API uses `hangup_on_ring` for outbound ring timeout.
        hangup_on_ring: ringTimeoutSec,
      },
    });

    const requestUuid = Array.isArray(result.request_uuid)
      ? result.request_uuid[0]
      : result.request_uuid;
    if (!requestUuid) {
      throw new Error("Plivo call create returned no request_uuid");
    }

    return { providerCallId: requestUuid, status: "initiated" };
  }

  async hangupCall(input: HangupCallInput): Promise<void> {
    const callUuid = this.requestUuidToCallUuid.get(input.providerCallId);
    if (callUuid) {
      await this.apiRequest({
        method: "DELETE",
        endpoint: `/Call/${callUuid}/`,
        allowNotFound: true,
      });
      return;
    }

    // Best-effort: try hangup (call UUID), then cancel (request UUID).
    await this.apiRequest({
      method: "DELETE",
      endpoint: `/Call/${input.providerCallId}/`,
      allowNotFound: true,
    });
    await this.apiRequest({
      method: "DELETE",
      endpoint: `/Request/${input.providerCallId}/`,
      allowNotFound: true,
    });
  }

  private resolveCallContext(params: {
    providerCallId: string;
    callId: string;
    operation: string;
  }): {
    callUuid: string;
    webhookBase: string;
  } {
    const callUuid = this.requestUuidToCallUuid.get(params.providerCallId) ?? params.providerCallId;
    const webhookBase =
      this.callUuidToWebhookUrl.get(callUuid) || this.callIdToWebhookUrl.get(params.callId);
    if (!webhookBase) {
      throw new Error("Missing webhook URL for this call (provider state missing)");
    }
    if (!callUuid) {
      throw new Error(`Missing Plivo CallUUID for ${params.operation}`);
    }
    return { callUuid, webhookBase };
  }

  private async transferCallLeg(params: {
    callUuid: string;
    webhookBase: string;
    callId: string;
    flow: "xml-speak" | "xml-listen";
  }): Promise<void> {
    const transferUrl = new URL(params.webhookBase);
    transferUrl.searchParams.set("provider", "plivo");
    transferUrl.searchParams.set("flow", params.flow);
    transferUrl.searchParams.set("callId", params.callId);

    await this.apiRequest({
      method: "POST",
      endpoint: `/Call/${params.callUuid}/`,
      body: {
        legs: "aleg",
        aleg_url: transferUrl.toString(),
        aleg_method: "POST",
      },
    });
  }

  async playTts(input: PlayTtsInput): Promise<void> {
    const { callUuid, webhookBase } = this.resolveCallContext({
      providerCallId: input.providerCallId,
      callId: input.callId,
      operation: "playTts",
    });

    this.pendingSpeakByCallId.set(input.callId, {
      text: input.text,
      locale: input.locale,
    });

    await this.transferCallLeg({
      callUuid,
      webhookBase,
      callId: input.callId,
      flow: "xml-speak",
    });
  }

  async startListening(input: StartListeningInput): Promise<void> {
    const { callUuid, webhookBase } = this.resolveCallContext({
      providerCallId: input.providerCallId,
      callId: input.callId,
      operation: "startListening",
    });

    this.pendingListenByCallId.set(input.callId, {
      language: input.language,
    });

    await this.transferCallLeg({
      callUuid,
      webhookBase,
      callId: input.callId,
      flow: "xml-listen",
    });
  }

  async stopListening(_input: StopListeningInput): Promise<void> {
    // GetInput ends automatically when speech ends.
  }

  async getCallStatus(input: GetCallStatusInput): Promise<GetCallStatusResult> {
    const terminalStatuses = new Set([
      "completed",
      "busy",
      "failed",
      "timeout",
      "no-answer",
      "cancel",
      "machine",
      "hangup",
    ]);
    try {
      const data = await guardedJsonApiRequest<{ call_status?: string }>({
        url: `${this.baseUrl}/Call/${input.providerCallId}/`,
        method: "GET",
        headers: {
          Authorization: `Basic ${Buffer.from(`${this.authId}:${this.authToken}`).toString("base64")}`,
        },
        allowNotFound: true,
        allowedHostnames: [this.apiHost],
        auditContext: "plivo-get-call-status",
        errorPrefix: "Plivo get call status error",
      });

      if (!data) {
        return { status: "not-found", isTerminal: true };
      }

      const status = data.call_status ?? "unknown";
      return { status, isTerminal: terminalStatuses.has(status) };
    } catch {
      return { status: "error", isTerminal: false, isUnknown: true };
    }
  }

  private static normalizeNumber(numberOrSip: string): string {
    const trimmed = numberOrSip.trim();
    if (normalizeLowercaseStringOrEmpty(trimmed).startsWith("sip:")) {
      return trimmed;
    }
    return trimmed.replace(/[^\d+]/g, "");
  }

  private static xmlEmpty(): string {
    return `<?xml version="1.0" encoding="UTF-8"?><Response></Response>`;
  }

  private static xmlKeepAlive(): string {
    return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Wait length="300" />
</Response>`;
  }

  private static xmlSpeak(text: string, locale?: string): string {
    const language = locale || "en-US";
    return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Speak language="${escapeXml(language)}">${escapeXml(text)}</Speak>
  <Wait length="300" />
</Response>`;
  }

  private static xmlGetInputSpeech(params: { actionUrl: string; language?: string }): string {
    const language = params.language || "en-US";
    return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <GetInput inputType="speech" method="POST" action="${escapeXml(params.actionUrl)}" language="${escapeXml(language)}" executionTimeout="30" speechEndTimeout="1" redirect="false">
  </GetInput>
  <Wait length="300" />
</Response>`;
  }

  private getCallIdFromQuery(ctx: WebhookContext): string | undefined {
    const callId = normalizeOptionalString(ctx.query?.callId);
    return callId || undefined;
  }

  private buildActionUrl(
    ctx: WebhookContext,
    opts: { flow: string; callId?: string },
  ): string | null {
    const base = this.baseWebhookUrlFromCtx(ctx);
    if (!base) {
      return null;
    }

    const u = new URL(base);
    u.searchParams.set("provider", "plivo");
    u.searchParams.set("flow", opts.flow);
    if (opts.callId) {
      u.searchParams.set("callId", opts.callId);
    }
    return u.toString();
  }

  private baseWebhookUrlFromCtx(ctx: WebhookContext): string | null {
    try {
      if (this.options.publicUrl) {
        const base = new URL(this.options.publicUrl);
        const requestUrl = new URL(ctx.url);
        base.pathname = requestUrl.pathname;
        return `${base.origin}${base.pathname}`;
      }

      const u = new URL(
        reconstructWebhookUrl(ctx, {
          allowedHosts: this.options.webhookSecurity?.allowedHosts,
          trustForwardingHeaders: this.options.webhookSecurity?.trustForwardingHeaders,
          trustedProxyIPs: this.options.webhookSecurity?.trustedProxyIPs,
          remoteIP: ctx.remoteAddress,
        }),
      );
      return `${u.origin}${u.pathname}`;
    } catch {
      return null;
    }
  }

  private parseBody(rawBody: string): URLSearchParams | null {
    try {
      return new URLSearchParams(rawBody);
    } catch {
      return null;
    }
  }

  private static extractTranscript(params: URLSearchParams): string | null {
    const candidates = [
      "Speech",
      "Transcription",
      "TranscriptionText",
      "SpeechResult",
      "RecognizedSpeech",
      "Text",
    ] as const;

    for (const key of candidates) {
      const value = params.get(key);
      if (value && value.trim()) {
        return value.trim();
      }
    }
    return null;
  }
}

type PlivoCreateCallResponse = {
  api_id?: string;
  message?: string;
  request_uuid?: string | string[];
};

¤ Dauer der Verarbeitung: 0.20 Sekunden  (vorverarbeitet am  2026-04-27) ¤

*© Formatika GbR, Deutschland






Wurzel

Suchen

Beweissystem der NASA

Beweissystem Isabelle

NIST Cobol Testsuite

Cephes Mathematical Library

Wiener Entwicklungsmethode

Haftungshinweis

Die Informationen auf dieser Webseite wurden nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit, noch Qualität der bereit gestellten Informationen zugesichert.

Bemerkung:

Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.