Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
929
extensions/voice-call/src/cli.ts
Normal file
929
extensions/voice-call/src/cli.ts
Normal file
@@ -0,0 +1,929 @@
|
||||
// Voice Call plugin module implements cli behavior.
|
||||
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 { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { callGatewayFromCli } from "openclaw/plugin-sdk/gateway-runtime";
|
||||
import {
|
||||
addTimerTimeoutGraceMs,
|
||||
clampTimerTimeoutMs,
|
||||
MAX_TIMER_TIMEOUT_MS,
|
||||
MAX_TCP_PORT,
|
||||
parseStrictNonNegativeInteger,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
isRecord,
|
||||
normalizeOptionalLowercaseString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { sleep } from "../api.js";
|
||||
import { validateProviderConfig, type VoiceCallConfig } from "./config.js";
|
||||
import { getCallHistoryFromStore } from "./manager/store.js";
|
||||
import { setVoiceCallStateRuntime, type VoiceCallStateRuntime } from "./runtime-state.js";
|
||||
import type { VoiceCallRuntime } from "./runtime.js";
|
||||
import { resolveUserPath } from "./utils.js";
|
||||
import { resolveWebhookExposureStatus } from "./webhook-exposure.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[];
|
||||
};
|
||||
|
||||
type VoiceCallGatewayMethod =
|
||||
| "voicecall.initiate"
|
||||
| "voicecall.start"
|
||||
| "voicecall.continue"
|
||||
| "voicecall.continue.start"
|
||||
| "voicecall.continue.result"
|
||||
| "voicecall.speak"
|
||||
| "voicecall.dtmf"
|
||||
| "voicecall.end"
|
||||
| "voicecall.status";
|
||||
|
||||
type VoiceCallGatewayCallResult = { ok: true; payload: unknown } | { ok: false; error: unknown };
|
||||
|
||||
const VOICE_CALL_GATEWAY_DEFAULT_TIMEOUT_MS = 5000;
|
||||
const VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS = 30000;
|
||||
const VOICE_CALL_GATEWAY_TRANSCRIPT_BUFFER_MS = 10000;
|
||||
const VOICE_CALL_GATEWAY_POLL_INTERVAL_MS = 1000;
|
||||
|
||||
const voiceCallCliDeps = {
|
||||
callGatewayFromCli,
|
||||
};
|
||||
|
||||
export const testing = {
|
||||
setCallGatewayFromCliForTests(next?: typeof callGatewayFromCli): void {
|
||||
voiceCallCliDeps.callGatewayFromCli = next ?? callGatewayFromCli;
|
||||
},
|
||||
isGatewayUnavailableForLocalFallback,
|
||||
parseVoiceCallIntOption,
|
||||
resolveGatewayContinueTimeoutMs,
|
||||
resolveGatewayOperationTimeoutMs,
|
||||
readGatewayPollTimeoutMs,
|
||||
resolveVoiceCallDeadlineMs,
|
||||
};
|
||||
|
||||
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 parseVoiceCallIntOption(
|
||||
raw: string | undefined,
|
||||
optionName: string,
|
||||
opts?: { min?: number; max?: number },
|
||||
): number {
|
||||
const min = opts?.min ?? 0;
|
||||
const value = raw?.trim() ?? "";
|
||||
const parsed = parseStrictNonNegativeInteger(value);
|
||||
if (parsed === undefined || parsed < min || (opts?.max !== undefined && parsed > opts.max)) {
|
||||
throw new Error(`Invalid numeric value for ${optionName}: ${raw ?? ""}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function isGatewayUnavailableForLocalFallback(err: unknown): boolean {
|
||||
const message = formatErrorMessage(err);
|
||||
return (
|
||||
message.includes("ECONNREFUSED") ||
|
||||
message.includes("ECONNRESET") ||
|
||||
message.includes("EHOSTUNREACH") ||
|
||||
message.includes("ENOTFOUND") ||
|
||||
message.includes("gateway closed (1006") ||
|
||||
message.includes("gateway not connected")
|
||||
);
|
||||
}
|
||||
|
||||
async function callVoiceCallGateway(
|
||||
method: VoiceCallGatewayMethod,
|
||||
params?: Record<string, unknown>,
|
||||
opts?: { timeoutMs?: number },
|
||||
): Promise<VoiceCallGatewayCallResult> {
|
||||
try {
|
||||
const timeoutMs =
|
||||
typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs)
|
||||
? Math.max(1, Math.ceil(opts.timeoutMs))
|
||||
: VOICE_CALL_GATEWAY_DEFAULT_TIMEOUT_MS;
|
||||
const payload = await voiceCallCliDeps.callGatewayFromCli(
|
||||
method,
|
||||
{ json: true, timeout: String(timeoutMs) },
|
||||
params,
|
||||
{ progress: false },
|
||||
);
|
||||
return { ok: true, payload };
|
||||
} catch (err) {
|
||||
if (isGatewayUnavailableForLocalFallback(err)) {
|
||||
return { ok: false, error: err };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGatewayOperationTimeoutMs(config: VoiceCallConfig): number {
|
||||
return Math.max(
|
||||
VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS,
|
||||
addTimerTimeoutGraceMs(config.ringTimeoutMs) ?? 1,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveGatewayContinueTimeoutMs(config: VoiceCallConfig): number {
|
||||
return (
|
||||
clampTimerTimeoutMs(
|
||||
config.transcriptTimeoutMs +
|
||||
VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS +
|
||||
VOICE_CALL_GATEWAY_TRANSCRIPT_BUFFER_MS,
|
||||
) ?? 1
|
||||
);
|
||||
}
|
||||
|
||||
function resolveVoiceCallDeadlineMs(timeoutMs: number, nowMs = Date.now()): number {
|
||||
return nowMs + (clampTimerTimeoutMs(timeoutMs) ?? MAX_TIMER_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
function isUnknownGatewayMethod(err: unknown, method: VoiceCallGatewayMethod): boolean {
|
||||
return formatErrorMessage(err).includes(`unknown method: ${method}`);
|
||||
}
|
||||
|
||||
function readGatewayOperationId(payload: unknown): string {
|
||||
if (isRecord(payload) && typeof payload.operationId === "string" && payload.operationId) {
|
||||
return payload.operationId;
|
||||
}
|
||||
throw new Error("voicecall gateway response missing operationId");
|
||||
}
|
||||
|
||||
function readGatewayPollTimeoutMs(payload: unknown, fallbackTimeoutMs: number): number {
|
||||
if (isRecord(payload) && typeof payload.pollTimeoutMs === "number") {
|
||||
return clampTimerTimeoutMs(payload.pollTimeoutMs) ?? fallbackTimeoutMs;
|
||||
}
|
||||
return fallbackTimeoutMs;
|
||||
}
|
||||
|
||||
function readCompletedContinueResult(
|
||||
payload: unknown,
|
||||
):
|
||||
| { status: "pending" }
|
||||
| { status: "completed"; result: unknown }
|
||||
| { status: "failed"; error: string } {
|
||||
if (!isRecord(payload)) {
|
||||
throw new Error("voicecall gateway response missing operation status");
|
||||
}
|
||||
if (payload.status === "pending") {
|
||||
return { status: "pending" };
|
||||
}
|
||||
if (payload.status === "failed") {
|
||||
return {
|
||||
status: "failed",
|
||||
error: typeof payload.error === "string" ? payload.error : "continue failed",
|
||||
};
|
||||
}
|
||||
if (payload.status === "completed") {
|
||||
return { status: "completed", result: payload.result };
|
||||
}
|
||||
throw new Error("voicecall gateway response has unknown operation status");
|
||||
}
|
||||
|
||||
async function pollVoiceCallContinueGateway(params: {
|
||||
operationId: string;
|
||||
timeoutMs: number;
|
||||
}): Promise<unknown> {
|
||||
const deadlineMs = resolveVoiceCallDeadlineMs(params.timeoutMs);
|
||||
|
||||
while (Date.now() <= deadlineMs) {
|
||||
const gateway = await callVoiceCallGateway(
|
||||
"voicecall.continue.result",
|
||||
{ operationId: params.operationId },
|
||||
{ timeoutMs: VOICE_CALL_GATEWAY_DEFAULT_TIMEOUT_MS },
|
||||
);
|
||||
if (!gateway.ok) {
|
||||
throw new Error(
|
||||
`gateway unavailable while waiting for voicecall continue result: ${formatErrorMessage(
|
||||
gateway.error,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
const result = readCompletedContinueResult(gateway.payload);
|
||||
if (result.status === "completed") {
|
||||
return result.result;
|
||||
}
|
||||
if (result.status === "failed") {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
await sleep(
|
||||
Math.min(VOICE_CALL_GATEWAY_POLL_INTERVAL_MS, Math.max(1, deadlineMs - Date.now())),
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error("voicecall continue timed out waiting for gateway operation");
|
||||
}
|
||||
|
||||
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 buildSetupStatus(config: VoiceCallConfig): SetupStatus {
|
||||
const validation = validateProviderConfig(config);
|
||||
const webhookExposure = resolveWebhookExposureStatus(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: webhookExposure.ok,
|
||||
message: webhookExposure.message,
|
||||
},
|
||||
{
|
||||
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 });
|
||||
}
|
||||
|
||||
function writeGatewayCallId(payload: unknown): void {
|
||||
if (isRecord(payload) && typeof payload.callId === "string") {
|
||||
writeStdoutJson({ callId: payload.callId });
|
||||
return;
|
||||
}
|
||||
if (isRecord(payload) && typeof payload.error === "string") {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
throw new Error("voicecall gateway response missing callId");
|
||||
}
|
||||
|
||||
async function initiateCallViaGatewayOrRuntime(params: {
|
||||
ensureRuntime: () => Promise<VoiceCallRuntime>;
|
||||
config: VoiceCallConfig;
|
||||
method: "voicecall.initiate" | "voicecall.start";
|
||||
to?: string;
|
||||
message?: string;
|
||||
mode?: string;
|
||||
}) {
|
||||
const mode = resolveCallMode(params.mode);
|
||||
const gateway = await callVoiceCallGateway(
|
||||
params.method,
|
||||
{
|
||||
...(params.to ? { to: params.to } : {}),
|
||||
...(params.message ? { message: params.message } : {}),
|
||||
...(mode ? { mode } : {}),
|
||||
},
|
||||
{
|
||||
timeoutMs: resolveGatewayOperationTimeoutMs(params.config),
|
||||
},
|
||||
);
|
||||
if (gateway.ok) {
|
||||
writeGatewayCallId(gateway.payload);
|
||||
return;
|
||||
}
|
||||
|
||||
const rt = await params.ensureRuntime();
|
||||
const to = params.to ?? rt.config.toNumber;
|
||||
if (!to) {
|
||||
throw new Error("Missing --to and no toNumber configured");
|
||||
}
|
||||
await initiateCallAndPrintId({
|
||||
runtime: rt,
|
||||
to,
|
||||
message: params.message,
|
||||
mode: params.mode,
|
||||
});
|
||||
}
|
||||
|
||||
export function registerVoiceCallCli(params: {
|
||||
program: Command;
|
||||
config: VoiceCallConfig;
|
||||
ensureRuntime: () => Promise<VoiceCallRuntime>;
|
||||
stateRuntime?: VoiceCallStateRuntime["state"];
|
||||
logger: Logger;
|
||||
}) {
|
||||
const { program, config, ensureRuntime, stateRuntime } = params;
|
||||
const ensureHistoryStateRuntime = (): void => {
|
||||
if (stateRuntime) {
|
||||
setVoiceCallStateRuntime({ state: stateRuntime });
|
||||
}
|
||||
};
|
||||
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 mode = resolveCallMode(options.mode) ?? "notify";
|
||||
const gateway = await callVoiceCallGateway(
|
||||
"voicecall.start",
|
||||
{
|
||||
to: options.to,
|
||||
...(options.message ? { message: options.message } : {}),
|
||||
mode,
|
||||
},
|
||||
{
|
||||
timeoutMs: resolveGatewayOperationTimeoutMs(config),
|
||||
},
|
||||
);
|
||||
let callId: unknown;
|
||||
if (gateway.ok) {
|
||||
callId = isRecord(gateway.payload) ? gateway.payload.callId : undefined;
|
||||
} else {
|
||||
const rt = await ensureRuntime();
|
||||
const result = await rt.manager.initiateCall(options.to, undefined, {
|
||||
message: options.message,
|
||||
mode,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "smoke call failed");
|
||||
}
|
||||
callId = result.callId;
|
||||
}
|
||||
if (typeof callId !== "string" || !callId) {
|
||||
throw new Error("smoke call failed");
|
||||
}
|
||||
if (options.json) {
|
||||
writeStdoutJson({ ok: true, setup, liveCall: true, callId });
|
||||
return;
|
||||
}
|
||||
writeSetupStatus(setup);
|
||||
writeStdoutLine("live-call: started %s", 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 }) => {
|
||||
await initiateCallViaGatewayOrRuntime({
|
||||
ensureRuntime,
|
||||
config,
|
||||
method: "voicecall.initiate",
|
||||
to: options.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 }) => {
|
||||
await initiateCallViaGatewayOrRuntime({
|
||||
ensureRuntime,
|
||||
config,
|
||||
method: "voicecall.start",
|
||||
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 }) => {
|
||||
let gateway: VoiceCallGatewayCallResult;
|
||||
try {
|
||||
gateway = await callVoiceCallGateway(
|
||||
"voicecall.continue.start",
|
||||
{
|
||||
callId: options.callId,
|
||||
message: options.message,
|
||||
},
|
||||
{
|
||||
timeoutMs: resolveGatewayOperationTimeoutMs(config),
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
if (!isUnknownGatewayMethod(err, "voicecall.continue.start")) {
|
||||
throw err;
|
||||
}
|
||||
gateway = await callVoiceCallGateway(
|
||||
"voicecall.continue",
|
||||
{
|
||||
callId: options.callId,
|
||||
message: options.message,
|
||||
},
|
||||
{
|
||||
timeoutMs: resolveGatewayContinueTimeoutMs(config),
|
||||
},
|
||||
);
|
||||
}
|
||||
if (gateway.ok) {
|
||||
if (isRecord(gateway.payload) && typeof gateway.payload.operationId === "string") {
|
||||
const result = await pollVoiceCallContinueGateway({
|
||||
operationId: readGatewayOperationId(gateway.payload),
|
||||
timeoutMs: readGatewayPollTimeoutMs(
|
||||
gateway.payload,
|
||||
resolveGatewayContinueTimeoutMs(config),
|
||||
),
|
||||
});
|
||||
writeStdoutJson(result);
|
||||
return;
|
||||
}
|
||||
writeStdoutJson(gateway.payload);
|
||||
return;
|
||||
}
|
||||
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 gateway = await callVoiceCallGateway("voicecall.speak", {
|
||||
callId: options.callId,
|
||||
message: options.message,
|
||||
});
|
||||
if (gateway.ok) {
|
||||
writeStdoutJson(gateway.payload);
|
||||
return;
|
||||
}
|
||||
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 gateway = await callVoiceCallGateway("voicecall.dtmf", {
|
||||
callId: options.callId,
|
||||
digits: options.digits,
|
||||
});
|
||||
if (gateway.ok) {
|
||||
writeStdoutJson(gateway.payload);
|
||||
return;
|
||||
}
|
||||
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 gateway = await callVoiceCallGateway("voicecall.end", {
|
||||
callId: options.callId,
|
||||
});
|
||||
if (gateway.ok) {
|
||||
writeStdoutJson(gateway.payload);
|
||||
return;
|
||||
}
|
||||
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")
|
||||
.option("--call-id <id>", "Call ID")
|
||||
.option("--json", "Print machine-readable JSON")
|
||||
.action(async (options: { callId?: string; json?: boolean }) => {
|
||||
const gateway = await callVoiceCallGateway(
|
||||
"voicecall.status",
|
||||
options.callId ? { callId: options.callId } : undefined,
|
||||
);
|
||||
if (gateway.ok) {
|
||||
if (options.callId && isRecord(gateway.payload)) {
|
||||
if (gateway.payload.found === true && "call" in gateway.payload) {
|
||||
writeStdoutJson(gateway.payload.call);
|
||||
return;
|
||||
}
|
||||
if (gateway.payload.found === false) {
|
||||
writeStdoutJson({ found: false });
|
||||
return;
|
||||
}
|
||||
}
|
||||
writeStdoutJson(gateway.payload);
|
||||
return;
|
||||
}
|
||||
const rt = await ensureRuntime();
|
||||
if (options.callId) {
|
||||
const call = rt.manager.getCall(options.callId);
|
||||
writeStdoutJson(call ?? { found: false });
|
||||
return;
|
||||
}
|
||||
writeStdoutJson({
|
||||
found: true,
|
||||
calls: rt.manager.getActiveCalls(),
|
||||
});
|
||||
});
|
||||
|
||||
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 = parseVoiceCallIntOption(options.since, "--since", { min: 0 });
|
||||
const pollMs = parseVoiceCallIntOption(options.poll, "--poll", { min: 50 });
|
||||
|
||||
const tailSqliteHistory = async (initialLimit: number): Promise<never> => {
|
||||
ensureHistoryStateRuntime();
|
||||
const seen = new Set<string>();
|
||||
const printCall = (call: unknown): void => {
|
||||
const line = JSON.stringify(call);
|
||||
if (!seen.has(line)) {
|
||||
seen.add(line);
|
||||
writeStdoutLine(line);
|
||||
}
|
||||
};
|
||||
if (initialLimit > 0) {
|
||||
for (const call of await getCallHistoryFromStore(path.dirname(file), initialLimit)) {
|
||||
printCall(call);
|
||||
}
|
||||
}
|
||||
for (;;) {
|
||||
try {
|
||||
for (const call of await getCallHistoryFromStore(path.dirname(file), 1000)) {
|
||||
printCall(call);
|
||||
}
|
||||
} catch {
|
||||
// ignore and retry
|
||||
}
|
||||
await sleep(pollMs);
|
||||
}
|
||||
};
|
||||
|
||||
if (fs.existsSync(file) && path.basename(file) !== "calls.jsonl") {
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
await tailSqliteHistory(since);
|
||||
}
|
||||
});
|
||||
|
||||
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 = parseVoiceCallIntOption(options.last, "--last", { min: 1 });
|
||||
|
||||
if (fs.existsSync(file) && path.basename(file) !== "calls.jsonl") {
|
||||
const content = fs.readFileSync(file, "utf8");
|
||||
const calls = content
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.slice(-last)
|
||||
.map((line) => {
|
||||
try {
|
||||
const parsed = JSON.parse(line) as { call?: unknown };
|
||||
return (parsed.call ?? parsed) as { metadata?: Record<string, unknown> };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((call): call is { metadata?: Record<string, unknown> } => call !== null);
|
||||
writeVoiceCallLatencySummary(calls);
|
||||
} else {
|
||||
ensureHistoryStateRuntime();
|
||||
writeVoiceCallLatencySummary(await getCallHistoryFromStore(path.dirname(file), last));
|
||||
}
|
||||
});
|
||||
|
||||
function writeVoiceCallLatencySummary(calls: Array<{ metadata?: Record<string, unknown> }>) {
|
||||
const turnLatencyMs: number[] = [];
|
||||
const listenWaitMs: number[] = [];
|
||||
|
||||
for (const call of calls) {
|
||||
const latency = call.metadata?.lastTurnLatencyMs;
|
||||
const listenWait = call.metadata?.lastTurnListenWaitMs;
|
||||
if (typeof latency === "number" && Number.isFinite(latency)) {
|
||||
turnLatencyMs.push(latency);
|
||||
}
|
||||
if (typeof listenWait === "number" && Number.isFinite(listenWait)) {
|
||||
listenWaitMs.push(listenWait);
|
||||
}
|
||||
}
|
||||
|
||||
writeStdoutJson({
|
||||
recordsScanned: calls.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 = parseVoiceCallIntOption(
|
||||
options.port ?? String(config.serve.port ?? 3334),
|
||||
"--port",
|
||||
{ min: 1, max: MAX_TCP_PORT },
|
||||
);
|
||||
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,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
export { testing as __testing };
|
||||
Reference in New Issue
Block a user