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

Quelle  cli.ts

  Sprache: JAVA
 

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

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { format } from "node:util";
import type { Command } from "commander";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
import { sleep } from "../api.js";
import { validateProviderConfig, type VoiceCallConfig } from "./config.js";
import type { VoiceCallRuntime } from "./runtime.js";
import { resolveUserPath } from "./utils.js";
import {
  cleanupTailscaleExposureRoute,
  getTailscaleSelfInfo,
  setupTailscaleExposureRoute,
} from "./webhook/tailscale.js";

type Logger = {
  info: (message: string) => void;
  warn: (message: string) => void;
  error: (message: string) => void;
};

type SetupCheck = {
  id: string;
  ok: boolean;
  message: string;
};

type SetupStatus = {
  ok: boolean;
  checks: SetupCheck[];
};

function writeStdoutLine(...values: unknown[]): void {
  process.stdout.write(`${format(...values)}\n`);
}

function writeStdoutJson(value: unknown): void {
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}

function resolveMode(input: string): "off" | "serve" | "funnel" {
  const raw = normalizeOptionalLowercaseString(input) ?? "";
  if (raw === "serve" || raw === "off") {
    return raw;
  }
  return "funnel";
}

function resolveDefaultStorePath(config: VoiceCallConfig): string {
  const preferred = path.join(os.homedir(), ".openclaw", "voice-calls");
  const resolvedPreferred = resolveUserPath(preferred);
  const existing =
    [resolvedPreferred].find((dir) => {
      try {
        return fs.existsSync(path.join(dir, "calls.jsonl")) || fs.existsSync(dir);
      } catch {
        return false;
      }
    }) ?? resolvedPreferred;
  const base = config.store?.trim() ? resolveUserPath(config.store) : existing;
  return path.join(base, "calls.jsonl");
}

function percentile(values: number[], p: number): number {
  if (values.length === 0) {
    return 0;
  }
  const sorted = [...values].toSorted((a, b) => a - b);
  const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
  return sorted[idx] ?? 0;
}

function summarizeSeries(values: number[]): {
  count: number;
  minMs: number;
  maxMs: number;
  avgMs: number;
  p50Ms: number;
  p95Ms: number;
} {
  if (values.length === 0) {
    return { count: 0, minMs: 0, maxMs: 0, avgMs: 0, p50Ms: 0, p95Ms: 0 };
  }

  const minMs = values.reduce(
    (min, value) => (value < min ? value : min),
    Number.POSITIVE_INFINITY,
  );
  const maxMs = values.reduce(
    (max, value) => (value > max ? value : max),
    Number.NEGATIVE_INFINITY,
  );
  const avgMs = values.reduce((sum, value) => sum + value, 0) / values.length;
  return {
    count: values.length,
    minMs,
    maxMs,
    avgMs,
    p50Ms: percentile(values, 50),
    p95Ms: percentile(values, 95),
  };
}

function resolveCallMode(mode?: string): "notify" | "conversation" | undefined {
  return mode === "notify" || mode === "conversation" ? mode : undefined;
}

function hasPublicExposure(config: VoiceCallConfig): boolean {
  return Boolean(
    config.publicUrl ||
    (config.tunnel?.provider && config.tunnel.provider !== "none") ||
    (config.tailscale?.mode && config.tailscale.mode !== "off"),
  );
}

function buildSetupStatus(config: VoiceCallConfig): SetupStatus {
  const validation = validateProviderConfig(config);
  const checks: SetupCheck[] = [
    {
      id: "plugin-enabled",
      ok: config.enabled,
      message: config.enabled
        ? "Voice Call plugin is enabled"
        : "Enable plugins.entries.voice-call.enabled",
    },
    {
      id: "provider",
      ok: Boolean(config.provider),
      message: config.provider
        ? `Provider configured: ${config.provider}`
        : "Set plugins.entries.voice-call.config.provider",
    },
    {
      id: "provider-config",
      ok: validation.valid,
      message: validation.valid
        ? "Provider credentials/config look complete"
        : validation.errors.join("; "),
    },
    {
      id: "webhook-exposure",
      ok: config.provider === "mock" || hasPublicExposure(config),
      message:
        config.provider === "mock"
          ? "Mock provider does not need a public webhook"
          : hasPublicExposure(config)
            ? config.publicUrl
              ? `Public webhook URL configured: ${config.publicUrl}`
              : "Webhook exposure configured through tunnel or Tailscale"
            : "Set publicUrl or configure tunnel/tailscale so the provider can reach webhooks",
    },
    {
      id: "mode",
      ok: !(config.streaming.enabled && config.realtime.enabled),
      message:
        config.streaming.enabled && config.realtime.enabled
          ? "streaming.enabled and realtime.enabled cannot both be true"
          : config.realtime.enabled
            ? `Realtime voice enabled (${config.realtime.provider ?? "first registered provider"})`
            : config.streaming.enabled
              ? `Streaming transcription enabled (${config.streaming.provider ?? "first registered provider"})`
              : "Notify/conversation calls use normal TTS/STT flow",
    },
  ];
  return {
    ok: checks.every((check) => check.ok),
    checks,
  };
}

function writeSetupStatus(status: SetupStatus): void {
  writeStdoutLine("Voice Call setup: %s", status.ok ? "OK" : "needs attention");
  for (const check of status.checks) {
    writeStdoutLine("%s %s: %s", check.ok ? "OK" : "FAIL", check.id, check.message);
  }
}

async function initiateCallAndPrintId(params: {
  runtime: VoiceCallRuntime;
  to: string;
  message?: string;
  mode?: string;
}) {
  const result = await params.runtime.manager.initiateCall(params.to, undefined, {
    message: params.message,
    mode: resolveCallMode(params.mode),
  });
  if (!result.success) {
    throw new Error(result.error || "initiate failed");
  }
  writeStdoutJson({ callId: result.callId });
}

export function registerVoiceCallCli(params: {
  program: Command;
  config: VoiceCallConfig;
  ensureRuntime: () => Promise<VoiceCallRuntime>;
  logger: Logger;
}) {
  const { program, config, ensureRuntime, logger } = params;
  const root = program
    .command("voicecall")
    .description("Voice call utilities")
    .addHelpText("after", () => `\nDocs: https://docs.openclaw.ai/cli/voicecall\n`);

  root
    .command("setup")
    .description("Show Voice Call provider and webhook setup status")
    .option("--json", "Print machine-readable JSON")
    .action((options: { json?: boolean }) => {
      const status = buildSetupStatus(config);
      if (options.json) {
        writeStdoutJson(status);
        return;
      }
      writeSetupStatus(status);
    });

  root
    .command("smoke")
    .description("Check Voice Call readiness and optionally place a short outbound test call")
    .option("-t, --to <phone>", "Phone number to call for a live smoke")
    .option(
      "--message <text>",
      "Message to speak during the smoke call",
      "OpenClaw voice call smoke test.",
    )
    .option("--mode <mode>", "Call mode: notify or conversation", "notify")
    .option("--yes", "Actually place the live outbound call")
    .option("--json", "Print machine-readable JSON")
    .action(
      async (options: {
        to?: string;
        message?: string;
        mode?: string;
        yes?: boolean;
        json?: boolean;
      }) => {
        const setup = buildSetupStatus(config);
        if (!setup.ok) {
          if (options.json) {
            writeStdoutJson({ ok: false, setup });
          } else {
            writeSetupStatus(setup);
          }
          process.exitCode = 1;
          return;
        }
        if (!options.to) {
          if (options.json) {
            writeStdoutJson({ ok: true, setup, liveCall: false });
          } else {
            writeSetupStatus(setup);
            writeStdoutLine("live-call: skipped (pass --to and --yes to place one)");
          }
          return;
        }
        if (!options.yes) {
          if (options.json) {
            writeStdoutJson({ ok: true, setup, liveCall: false, wouldCall: options.to });
          } else {
            writeSetupStatus(setup);
            writeStdoutLine("live-call: dry run for %s (add --yes to place it)", options.to);
          }
          return;
        }
        const rt = await ensureRuntime();
        const result = await rt.manager.initiateCall(options.to, undefined, {
          message: options.message,
          mode: resolveCallMode(options.mode) ?? "notify",
        });
        if (!result.success) {
          throw new Error(result.error || "smoke call failed");
        }
        if (options.json) {
          writeStdoutJson({ ok: true, setup, liveCall: true, callId: result.callId });
          return;
        }
        writeSetupStatus(setup);
        writeStdoutLine("live-call: started %s", result.callId);
      },
    );

  root
    .command("call")
    .description("Initiate an outbound voice call")
    .requiredOption("-m, --message <text>", "Message to speak when call connects")
    .option(
      "-t, --to <phone>",
      "Phone number to call (E.164 format, uses config toNumber if not set)",
    )
    .option(
      "--mode <mode>",
      "Call mode: notify (hangup after message) or conversation (stay open)",
      "conversation",
    )
    .action(async (options: { message: string; to?: string; mode?: string }) => {
      const rt = await ensureRuntime();
      const to = options.to ?? rt.config.toNumber;
      if (!to) {
        throw new Error("Missing --to and no toNumber configured");
      }
      await initiateCallAndPrintId({
        runtime: rt,
        to,
        message: options.message,
        mode: options.mode,
      });
    });

  root
    .command("start")
    .description("Alias for voicecall call")
    .requiredOption("--to <phone>", "Phone number to call")
    .option("--message <text>", "Message to speak when call connects")
    .option(
      "--mode <mode>",
      "Call mode: notify (hangup after message) or conversation (stay open)",
      "conversation",
    )
    .action(async (options: { to: string; message?: string; mode?: string }) => {
      const rt = await ensureRuntime();
      await initiateCallAndPrintId({
        runtime: rt,
        to: options.to,
        message: options.message,
        mode: options.mode,
      });
    });

  root
    .command("continue")
    .description("Speak a message and wait for a response")
    .requiredOption("--call-id <id>", "Call ID")
    .requiredOption("--message <text>", "Message to speak")
    .action(async (options: { callId: string; message: string }) => {
      const rt = await ensureRuntime();
      const result = await rt.manager.continueCall(options.callId, options.message);
      if (!result.success) {
        throw new Error(result.error || "continue failed");
      }
      writeStdoutJson(result);
    });

  root
    .command("speak")
    .description("Speak a message without waiting for response")
    .requiredOption("--call-id <id>", "Call ID")
    .requiredOption("--message <text>", "Message to speak")
    .action(async (options: { callId: string; message: string }) => {
      const rt = await ensureRuntime();
      const result = await rt.manager.speak(options.callId, options.message);
      if (!result.success) {
        throw new Error(result.error || "speak failed");
      }
      writeStdoutJson(result);
    });

  root
    .command("dtmf")
    .description("Send DTMF digits to an active call")
    .requiredOption("--call-id <id>", "Call ID")
    .requiredOption("--digits <digits>", "DTMF digits")
    .action(async (options: { callId: string; digits: string }) => {
      const rt = await ensureRuntime();
      const result = await rt.manager.sendDtmf(options.callId, options.digits);
      if (!result.success) {
        throw new Error(result.error || "dtmf failed");
      }
      writeStdoutJson(result);
    });

  root
    .command("end")
    .description("Hang up an active call")
    .requiredOption("--call-id <id>", "Call ID")
    .action(async (options: { callId: string }) => {
      const rt = await ensureRuntime();
      const result = await rt.manager.endCall(options.callId);
      if (!result.success) {
        throw new Error(result.error || "end failed");
      }
      writeStdoutJson(result);
    });

  root
    .command("status")
    .description("Show call status")
    .requiredOption("--call-id <id>", "Call ID")
    .action(async (options: { callId: string }) => {
      const rt = await ensureRuntime();
      const call = rt.manager.getCall(options.callId);
      writeStdoutJson(call ?? { found: false });
    });

  root
    .command("tail")
    .description("Tail voice-call JSONL logs (prints new lines; useful during provider tests)")
    .option("--file <path>", "Path to calls.jsonl", resolveDefaultStorePath(config))
    .option("--since <n>", "Print last N lines first", "25")
    .option("--poll <ms>", "Poll interval in ms", "250")
    .action(async (options: { file: string; since?: string; poll?: string }) => {
      const file = options.file;
      const since = Math.max(0, Number(options.since ?? 0));
      const pollMs = Math.max(50, Number(options.poll ?? 250));

      if (!fs.existsSync(file)) {
        logger.error(`No log file at ${file}`);
        process.exit(1);
      }

      const initial = fs.readFileSync(file, "utf8");
      const lines = initial.split("\n").filter(Boolean);
      for (const line of lines.slice(Math.max(0, lines.length - since))) {
        writeStdoutLine(line);
      }

      let offset = Buffer.byteLength(initial, "utf8");

      for (;;) {
        try {
          const stat = fs.statSync(file);
          if (stat.size < offset) {
            offset = 0;
          }
          if (stat.size > offset) {
            const fd = fs.openSync(file, "r");
            try {
              const buf = Buffer.alloc(stat.size - offset);
              fs.readSync(fd, buf, 0, buf.length, offset);
              offset = stat.size;
              const text = buf.toString("utf8");
              for (const line of text.split("\n").filter(Boolean)) {
                writeStdoutLine(line);
              }
            } finally {
              fs.closeSync(fd);
            }
          }
        } catch {
          // ignore and retry
        }
        await sleep(pollMs);
      }
    });

  root
    .command("latency")
    .description("Summarize turn latency metrics from voice-call JSONL logs")
    .option("--file <path>", "Path to calls.jsonl", resolveDefaultStorePath(config))
    .option("--last <n>", "Analyze last N records", "200")
    .action(async (options: { file: string; last?: string }) => {
      const file = options.file;
      const last = Math.max(1, Number(options.last ?? 200));

      if (!fs.existsSync(file)) {
        throw new Error("No log file at " + file);
      }

      const content = fs.readFileSync(file, "utf8");
      const lines = content.split("\n").filter(Boolean).slice(-last);

      const turnLatencyMs: number[] = [];
      const listenWaitMs: number[] = [];

      for (const line of lines) {
        try {
          const parsed = JSON.parse(line) as {
            metadata?: { lastTurnLatencyMs?: unknown; lastTurnListenWaitMs?: unknown };
          };
          const latency = parsed.metadata?.lastTurnLatencyMs;
          const listenWait = parsed.metadata?.lastTurnListenWaitMs;
          if (typeof latency === "number" && Number.isFinite(latency)) {
            turnLatencyMs.push(latency);
          }
          if (typeof listenWait === "number" && Number.isFinite(listenWait)) {
            listenWaitMs.push(listenWait);
          }
        } catch {
          // ignore malformed JSON lines
        }
      }

      writeStdoutJson({
        recordsScanned: lines.length,
        turnLatency: summarizeSeries(turnLatencyMs),
        listenWait: summarizeSeries(listenWaitMs),
      });
    });

  root
    .command("expose")
    .description("Enable/disable Tailscale serve/funnel for the webhook")
    .option("--mode <mode>", "off | serve (tailnet) | funnel (public)", "funnel")
    .option("--path <path>", "Tailscale path to expose (recommend matching serve.path)")
    .option("--port <port>", "Local webhook port")
    .option("--serve-path <path>", "Local webhook path")
    .action(
      async (options: { mode?: string; port?: string; path?: string; servePath?: string }) => {
        const mode = resolveMode(options.mode ?? "funnel");
        const servePort = Number(options.port ?? config.serve.port ?? 3334);
        const servePath = options.servePath ?? config.serve.path ?? "/voice/webhook";
        const tsPath = options.path ?? config.tailscale?.path ?? servePath;

        const localUrl = `http://127.0.0.1:${servePort}`;

        if (mode === "off") {
          await cleanupTailscaleExposureRoute({ mode: "serve", path: tsPath });
          await cleanupTailscaleExposureRoute({ mode: "funnel", path: tsPath });
          writeStdoutJson({ ok: true, mode: "off", path: tsPath });
          return;
        }

        const publicUrl = await setupTailscaleExposureRoute({
          mode,
          path: tsPath,
          localUrl,
        });

        const tsInfo = publicUrl ? null : await getTailscaleSelfInfo();
        const enableUrl = tsInfo?.nodeId
          ? `https://login.tailscale.com/f/${mode}?node=${tsInfo.nodeId}`
          : null;

        writeStdoutJson({
          ok: Boolean(publicUrl),
          mode,
          path: tsPath,
          localUrl,
          publicUrl,
          hint: publicUrl
            ? undefined
            : {
                note: "Tailscale serve/funnel may be disabled on this tailnet (or require admin enable).",
                enableUrl,
              },
        });
      },
    );
}

¤ Dauer der Verarbeitung: 0.27 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.