mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:50:43 +00:00
Fix voice-call CLI gateway delegation by returning protocol-shaped errors and running delegated continue turns through operation-id polling instead of one long Gateway RPC.\n\nThanks @serrurco and @DougButdorf.
749 lines
27 KiB
TypeScript
749 lines
27 KiB
TypeScript
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
import { ErrorCodes, errorShape } from "openclaw/plugin-sdk/gateway-runtime";
|
|
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
|
import { Type } from "typebox";
|
|
import {
|
|
definePluginEntry,
|
|
type GatewayRequestHandlerOptions,
|
|
type OpenClawPluginApi,
|
|
} from "./api.js";
|
|
import { createVoiceCallRuntime, type VoiceCallRuntime } from "./runtime-entry.js";
|
|
import { registerVoiceCallCli } from "./src/cli.js";
|
|
import {
|
|
formatVoiceCallLegacyConfigWarnings,
|
|
normalizeVoiceCallLegacyConfigInput,
|
|
parseVoiceCallPluginConfig,
|
|
} from "./src/config-compat.js";
|
|
import {
|
|
resolveVoiceCallConfig,
|
|
validateProviderConfig,
|
|
type VoiceCallConfig,
|
|
} from "./src/config.js";
|
|
import type { CoreConfig } from "./src/core-bridge.js";
|
|
import { createVoiceCallContinueOperationStore } from "./src/gateway-continue-operation.js";
|
|
|
|
const voiceCallConfigSchema = {
|
|
parse(value: unknown): VoiceCallConfig {
|
|
const normalized = normalizeVoiceCallLegacyConfigInput(value);
|
|
const enabled = typeof normalized.enabled === "boolean" ? normalized.enabled : true;
|
|
return parseVoiceCallPluginConfig({
|
|
...normalized,
|
|
enabled,
|
|
provider: normalized.provider ?? (enabled ? "mock" : undefined),
|
|
});
|
|
},
|
|
uiHints: {
|
|
provider: {
|
|
label: "Provider",
|
|
help: "Use twilio, telnyx, or mock for dev/no-network.",
|
|
},
|
|
fromNumber: { label: "From Number", placeholder: "+15550001234" },
|
|
toNumber: { label: "Default To Number", placeholder: "+15550001234" },
|
|
inboundPolicy: { label: "Inbound Policy" },
|
|
allowFrom: { label: "Inbound Allowlist" },
|
|
inboundGreeting: { label: "Inbound Greeting", advanced: true },
|
|
"telnyx.apiKey": { label: "Telnyx API Key", sensitive: true },
|
|
"telnyx.connectionId": { label: "Telnyx Connection ID" },
|
|
"telnyx.publicKey": { label: "Telnyx Public Key", sensitive: true },
|
|
"twilio.accountSid": { label: "Twilio Account SID" },
|
|
"twilio.authToken": { label: "Twilio Auth Token", sensitive: true },
|
|
"outbound.defaultMode": { label: "Default Call Mode" },
|
|
"outbound.notifyHangupDelaySec": {
|
|
label: "Notify Hangup Delay (sec)",
|
|
advanced: true,
|
|
},
|
|
"serve.port": { label: "Webhook Port" },
|
|
"serve.bind": { label: "Webhook Bind" },
|
|
"serve.path": { label: "Webhook Path" },
|
|
"tailscale.mode": { label: "Tailscale Mode", advanced: true },
|
|
"tailscale.path": { label: "Tailscale Path", advanced: true },
|
|
"tunnel.provider": { label: "Tunnel Provider", advanced: true },
|
|
"tunnel.ngrokAuthToken": {
|
|
label: "ngrok Auth Token",
|
|
sensitive: true,
|
|
advanced: true,
|
|
},
|
|
"tunnel.ngrokDomain": { label: "ngrok Domain", advanced: true },
|
|
"tunnel.allowNgrokFreeTierLoopbackBypass": {
|
|
label: "Allow ngrok Free Tier (Loopback Bypass)",
|
|
advanced: true,
|
|
},
|
|
"streaming.enabled": { label: "Enable Streaming", advanced: true },
|
|
"streaming.provider": {
|
|
label: "Streaming Provider",
|
|
help: "Uses the first registered realtime transcription provider when unset.",
|
|
advanced: true,
|
|
},
|
|
"streaming.providers": { label: "Streaming Provider Config", advanced: true },
|
|
"streaming.streamPath": { label: "Media Stream Path", advanced: true },
|
|
"realtime.enabled": { label: "Enable Realtime Voice", advanced: true },
|
|
"realtime.provider": {
|
|
label: "Realtime Voice Provider",
|
|
help: "Uses the first registered realtime voice provider when unset.",
|
|
advanced: true,
|
|
},
|
|
"realtime.streamPath": { label: "Realtime Stream Path", advanced: true },
|
|
"realtime.instructions": { label: "Realtime Instructions", advanced: true },
|
|
"realtime.toolPolicy": {
|
|
label: "Realtime Tool Policy",
|
|
help: "Controls the shared openclaw_agent_consult tool.",
|
|
advanced: true,
|
|
},
|
|
"realtime.providers": { label: "Realtime Provider Config", advanced: true },
|
|
"tts.provider": {
|
|
label: "TTS Provider Override",
|
|
help: "Deep-merges with messages.tts (Microsoft is ignored for calls).",
|
|
advanced: true,
|
|
},
|
|
"tts.providers": { label: "TTS Provider Config", advanced: true },
|
|
publicUrl: { label: "Public Webhook URL", advanced: true },
|
|
skipSignatureVerification: {
|
|
label: "Skip Signature Verification",
|
|
advanced: true,
|
|
},
|
|
store: { label: "Call Log Store Path", advanced: true },
|
|
agentId: {
|
|
label: "Response Agent ID",
|
|
help: 'Agent workspace used for voice response generation. Defaults to "main".',
|
|
advanced: true,
|
|
},
|
|
responseModel: {
|
|
label: "Response Model",
|
|
help: "Optional override. Falls back to the runtime default model when unset.",
|
|
advanced: true,
|
|
},
|
|
responseSystemPrompt: { label: "Response System Prompt", advanced: true },
|
|
responseTimeoutMs: { label: "Response Timeout (ms)", advanced: true },
|
|
},
|
|
};
|
|
|
|
const VoiceCallToolSchema = Type.Union([
|
|
Type.Object({
|
|
action: Type.Literal("initiate_call"),
|
|
to: Type.Optional(Type.String({ description: "Call target" })),
|
|
message: Type.String({ description: "Intro message" }),
|
|
mode: Type.Optional(Type.Union([Type.Literal("notify"), Type.Literal("conversation")])),
|
|
dtmfSequence: Type.Optional(Type.String({ description: "DTMF digits to play before connect" })),
|
|
}),
|
|
Type.Object({
|
|
action: Type.Literal("continue_call"),
|
|
callId: Type.String({ description: "Call ID" }),
|
|
message: Type.String({ description: "Follow-up message" }),
|
|
}),
|
|
Type.Object({
|
|
action: Type.Literal("speak_to_user"),
|
|
callId: Type.String({ description: "Call ID" }),
|
|
message: Type.String({ description: "Message to speak" }),
|
|
}),
|
|
Type.Object({
|
|
action: Type.Literal("send_dtmf"),
|
|
callId: Type.String({ description: "Call ID" }),
|
|
digits: Type.String({ description: "DTMF digits to send" }),
|
|
}),
|
|
Type.Object({
|
|
action: Type.Literal("end_call"),
|
|
callId: Type.String({ description: "Call ID" }),
|
|
}),
|
|
Type.Object({
|
|
action: Type.Literal("get_status"),
|
|
callId: Type.String({ description: "Call ID" }),
|
|
}),
|
|
Type.Object({
|
|
mode: Type.Optional(Type.Union([Type.Literal("call"), Type.Literal("status")])),
|
|
to: Type.Optional(Type.String({ description: "Call target" })),
|
|
sid: Type.Optional(Type.String({ description: "Call SID" })),
|
|
message: Type.Optional(Type.String({ description: "Optional intro message" })),
|
|
dtmfSequence: Type.Optional(Type.String({ description: "DTMF digits to play before connect" })),
|
|
}),
|
|
]);
|
|
|
|
function asParamRecord(params: unknown): Record<string, unknown> {
|
|
return params && typeof params === "object" && !Array.isArray(params)
|
|
? (params as Record<string, unknown>)
|
|
: {};
|
|
}
|
|
|
|
function isCliOnlyProcess(): boolean {
|
|
return process.env.OPENCLAW_CLI === "1" && !process.argv.slice(2).includes("gateway");
|
|
}
|
|
|
|
const VOICE_CALL_RUNTIME_KEY = Symbol.for("openclaw.voice-call.runtime");
|
|
const VOICE_CALL_RUNTIME_PROMISE_KEY = Symbol.for("openclaw.voice-call.runtimePromise");
|
|
const VOICE_CALL_RUNTIME_STOP_PROMISE_KEY = Symbol.for("openclaw.voice-call.runtimeStopPromise");
|
|
|
|
type VoiceCallRuntimeGlobalState = typeof globalThis & {
|
|
[VOICE_CALL_RUNTIME_KEY]?: VoiceCallRuntime | null;
|
|
[VOICE_CALL_RUNTIME_PROMISE_KEY]?: Promise<VoiceCallRuntime> | null;
|
|
[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY]?: Promise<void> | null;
|
|
};
|
|
|
|
function getVoiceCallRuntimeGlobalState(): VoiceCallRuntimeGlobalState {
|
|
const state = globalThis as VoiceCallRuntimeGlobalState;
|
|
state[VOICE_CALL_RUNTIME_KEY] ??= null;
|
|
state[VOICE_CALL_RUNTIME_PROMISE_KEY] ??= null;
|
|
state[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY] ??= null;
|
|
return state;
|
|
}
|
|
|
|
export default definePluginEntry({
|
|
id: "voice-call",
|
|
name: "Voice Call",
|
|
description: "Voice-call plugin with Telnyx/Twilio/Plivo providers",
|
|
configSchema: voiceCallConfigSchema,
|
|
register(api: OpenClawPluginApi) {
|
|
const config = resolveVoiceCallConfig(voiceCallConfigSchema.parse(api.pluginConfig));
|
|
const validation = validateProviderConfig(config);
|
|
|
|
if (api.pluginConfig && typeof api.pluginConfig === "object") {
|
|
for (const warning of formatVoiceCallLegacyConfigWarnings({
|
|
value: api.pluginConfig,
|
|
configPathPrefix: "plugins.entries.voice-call.config",
|
|
doctorFixCommand: "openclaw doctor --fix",
|
|
})) {
|
|
api.logger.warn(warning);
|
|
}
|
|
}
|
|
|
|
const runtimeState = getVoiceCallRuntimeGlobalState();
|
|
const continueOperationStore = createVoiceCallContinueOperationStore({
|
|
config,
|
|
coreConfig: api.config as CoreConfig,
|
|
});
|
|
|
|
const ensureRuntime = async (): Promise<VoiceCallRuntime> => {
|
|
if (!config.enabled) {
|
|
throw new Error("Voice call disabled in plugin config");
|
|
}
|
|
if (!validation.valid) {
|
|
throw new Error(validation.errors.join("; "));
|
|
}
|
|
|
|
while (true) {
|
|
if (runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY]) {
|
|
await runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY];
|
|
continue;
|
|
}
|
|
|
|
const runtime = runtimeState[VOICE_CALL_RUNTIME_KEY];
|
|
if (runtime) {
|
|
return runtime;
|
|
}
|
|
|
|
let runtimePromise = runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY];
|
|
if (!runtimePromise) {
|
|
runtimePromise = createVoiceCallRuntime({
|
|
config,
|
|
coreConfig: api.config as CoreConfig,
|
|
fullConfig: api.config,
|
|
agentRuntime: api.runtime.agent,
|
|
ttsRuntime: api.runtime.tts,
|
|
logger: api.logger,
|
|
});
|
|
runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] = runtimePromise;
|
|
}
|
|
|
|
try {
|
|
const createdRuntime = await runtimePromise;
|
|
if (runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY]) {
|
|
continue;
|
|
}
|
|
if (runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] !== runtimePromise) {
|
|
continue;
|
|
}
|
|
runtimeState[VOICE_CALL_RUNTIME_KEY] = createdRuntime;
|
|
return createdRuntime;
|
|
} catch (err) {
|
|
if (runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] === runtimePromise) {
|
|
// Reset shared state so the next call can retry instead of caching
|
|
// a rejected promise across plugin contexts. See: #32387, #58115.
|
|
runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] = null;
|
|
runtimeState[VOICE_CALL_RUNTIME_KEY] = null;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
};
|
|
|
|
const respondError = (
|
|
respond: GatewayRequestHandlerOptions["respond"],
|
|
message: string,
|
|
code: (typeof ErrorCodes)[keyof typeof ErrorCodes] = ErrorCodes.UNAVAILABLE,
|
|
) => {
|
|
respond(false, undefined, errorShape(code, message));
|
|
};
|
|
|
|
const sendError = (respond: GatewayRequestHandlerOptions["respond"], err: unknown) => {
|
|
respondError(respond, formatErrorMessage(err));
|
|
};
|
|
|
|
const resolveCallMessageRequest = async (params: GatewayRequestHandlerOptions["params"]) => {
|
|
const callId = normalizeOptionalString(params?.callId) ?? "";
|
|
const message = normalizeOptionalString(params?.message) ?? "";
|
|
if (!callId || !message) {
|
|
return { error: "callId and message required" } as const;
|
|
}
|
|
const rt = await ensureRuntime();
|
|
return { rt, callId, message } as const;
|
|
};
|
|
|
|
const initiateCallAndRespond = async (params: {
|
|
rt: VoiceCallRuntime;
|
|
respond: GatewayRequestHandlerOptions["respond"];
|
|
to: string;
|
|
message?: string;
|
|
mode?: "notify" | "conversation";
|
|
dtmfSequence?: string;
|
|
}) => {
|
|
const result = await params.rt.manager.initiateCall(params.to, undefined, {
|
|
message: params.message,
|
|
mode: params.mode,
|
|
dtmfSequence: params.dtmfSequence,
|
|
});
|
|
if (!result.success) {
|
|
respondError(params.respond, result.error || "initiate failed");
|
|
return;
|
|
}
|
|
params.respond(true, { callId: result.callId, initiated: true });
|
|
};
|
|
|
|
const respondToCallMessageAction = async (params: {
|
|
requestParams: GatewayRequestHandlerOptions["params"];
|
|
respond: GatewayRequestHandlerOptions["respond"];
|
|
action: (
|
|
request: Exclude<Awaited<ReturnType<typeof resolveCallMessageRequest>>, { error: string }>,
|
|
) => Promise<{
|
|
success: boolean;
|
|
error?: string;
|
|
transcript?: string;
|
|
}>;
|
|
failure: string;
|
|
includeTranscript?: boolean;
|
|
}) => {
|
|
const request = await resolveCallMessageRequest(params.requestParams);
|
|
if ("error" in request) {
|
|
respondError(
|
|
params.respond,
|
|
request.error ?? "callId and message required",
|
|
ErrorCodes.INVALID_REQUEST,
|
|
);
|
|
return;
|
|
}
|
|
const result = await params.action(request);
|
|
if (!result.success) {
|
|
respondError(params.respond, result.error || params.failure);
|
|
return;
|
|
}
|
|
params.respond(
|
|
true,
|
|
params.includeTranscript
|
|
? { success: true, transcript: result.transcript }
|
|
: { success: true },
|
|
);
|
|
};
|
|
|
|
api.registerGatewayMethod(
|
|
"voicecall.initiate",
|
|
async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
try {
|
|
const message = normalizeOptionalString(params?.message) ?? "";
|
|
if (!message) {
|
|
respondError(respond, "message required", ErrorCodes.INVALID_REQUEST);
|
|
return;
|
|
}
|
|
const rt = await ensureRuntime();
|
|
const to = normalizeOptionalString(params?.to) ?? rt.config.toNumber;
|
|
if (!to) {
|
|
respondError(respond, "to required", ErrorCodes.INVALID_REQUEST);
|
|
return;
|
|
}
|
|
const mode =
|
|
params?.mode === "notify" || params?.mode === "conversation" ? params.mode : undefined;
|
|
await initiateCallAndRespond({
|
|
rt,
|
|
respond,
|
|
to,
|
|
message,
|
|
mode,
|
|
});
|
|
} catch (err) {
|
|
sendError(respond, err);
|
|
}
|
|
},
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"voicecall.continue",
|
|
async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
try {
|
|
await respondToCallMessageAction({
|
|
requestParams: params,
|
|
respond,
|
|
action: (request) => request.rt.manager.continueCall(request.callId, request.message),
|
|
failure: "continue failed",
|
|
includeTranscript: true,
|
|
});
|
|
} catch (err) {
|
|
sendError(respond, err);
|
|
}
|
|
},
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"voicecall.continue.start",
|
|
async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
try {
|
|
const request = await resolveCallMessageRequest(params);
|
|
if ("error" in request) {
|
|
respondError(
|
|
respond,
|
|
request.error ?? "callId and message required",
|
|
ErrorCodes.INVALID_REQUEST,
|
|
);
|
|
return;
|
|
}
|
|
respond(true, continueOperationStore.start(request));
|
|
} catch (err) {
|
|
sendError(respond, err);
|
|
}
|
|
},
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"voicecall.continue.result",
|
|
async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
try {
|
|
const operationId = normalizeOptionalString(params?.operationId) ?? "";
|
|
if (!operationId) {
|
|
respondError(respond, "operationId required", ErrorCodes.INVALID_REQUEST);
|
|
return;
|
|
}
|
|
const operation = continueOperationStore.read(operationId);
|
|
if (!operation.ok) {
|
|
respondError(respond, operation.error, ErrorCodes.INVALID_REQUEST);
|
|
return;
|
|
}
|
|
respond(true, operation.payload);
|
|
} catch (err) {
|
|
sendError(respond, err);
|
|
}
|
|
},
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"voicecall.speak",
|
|
async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
try {
|
|
const request = await resolveCallMessageRequest(params);
|
|
if ("error" in request) {
|
|
respondError(
|
|
respond,
|
|
request.error ?? "callId and message required",
|
|
ErrorCodes.INVALID_REQUEST,
|
|
);
|
|
return;
|
|
}
|
|
if (request.rt.config.realtime.enabled) {
|
|
const realtimeResult = request.rt.webhookServer.speakRealtime(
|
|
request.callId,
|
|
request.message,
|
|
);
|
|
if (realtimeResult.success) {
|
|
respond(true, { success: true });
|
|
return;
|
|
}
|
|
}
|
|
const result = await request.rt.manager.speak(request.callId, request.message);
|
|
if (!result.success) {
|
|
respondError(respond, result.error || "speak failed");
|
|
return;
|
|
}
|
|
respond(true, { success: true });
|
|
} catch (err) {
|
|
sendError(respond, err);
|
|
}
|
|
},
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"voicecall.dtmf",
|
|
async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
try {
|
|
const callId = normalizeOptionalString(params?.callId) ?? "";
|
|
const digits = normalizeOptionalString(params?.digits) ?? "";
|
|
if (!callId || !digits) {
|
|
respondError(respond, "callId and digits required", ErrorCodes.INVALID_REQUEST);
|
|
return;
|
|
}
|
|
const rt = await ensureRuntime();
|
|
const result = await rt.manager.sendDtmf(callId, digits);
|
|
if (!result.success) {
|
|
respondError(respond, result.error || "dtmf failed");
|
|
return;
|
|
}
|
|
respond(true, { success: true });
|
|
} catch (err) {
|
|
sendError(respond, err);
|
|
}
|
|
},
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"voicecall.end",
|
|
async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
try {
|
|
const callId = normalizeOptionalString(params?.callId) ?? "";
|
|
if (!callId) {
|
|
respondError(respond, "callId required", ErrorCodes.INVALID_REQUEST);
|
|
return;
|
|
}
|
|
const rt = await ensureRuntime();
|
|
const result = await rt.manager.endCall(callId);
|
|
if (!result.success) {
|
|
respondError(respond, result.error || "end failed");
|
|
return;
|
|
}
|
|
respond(true, { success: true });
|
|
} catch (err) {
|
|
sendError(respond, err);
|
|
}
|
|
},
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"voicecall.status",
|
|
async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
try {
|
|
const raw =
|
|
normalizeOptionalString(params?.callId) ?? normalizeOptionalString(params?.sid) ?? "";
|
|
const rt = await ensureRuntime();
|
|
if (!raw) {
|
|
respond(true, { found: true, calls: rt.manager.getActiveCalls() });
|
|
return;
|
|
}
|
|
const call = rt.manager.getCall(raw) || rt.manager.getCallByProviderCallId(raw);
|
|
if (!call) {
|
|
respond(true, { found: false });
|
|
return;
|
|
}
|
|
respond(true, { found: true, call });
|
|
} catch (err) {
|
|
sendError(respond, err);
|
|
}
|
|
},
|
|
);
|
|
|
|
api.registerGatewayMethod(
|
|
"voicecall.start",
|
|
async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
|
try {
|
|
const to = normalizeOptionalString(params?.to) ?? "";
|
|
const message = normalizeOptionalString(params?.message) ?? "";
|
|
const dtmfSequence = normalizeOptionalString(params?.dtmfSequence);
|
|
if (!to) {
|
|
respondError(respond, "to required", ErrorCodes.INVALID_REQUEST);
|
|
return;
|
|
}
|
|
const rt = await ensureRuntime();
|
|
const mode =
|
|
params?.mode === "notify" || params?.mode === "conversation" ? params.mode : undefined;
|
|
await initiateCallAndRespond({
|
|
rt,
|
|
respond,
|
|
to,
|
|
message: message || undefined,
|
|
mode,
|
|
dtmfSequence,
|
|
});
|
|
} catch (err) {
|
|
sendError(respond, err);
|
|
}
|
|
},
|
|
);
|
|
|
|
api.registerTool({
|
|
name: "voice_call",
|
|
label: "Voice Call",
|
|
description: "Make phone calls and have voice conversations via the voice-call plugin.",
|
|
parameters: VoiceCallToolSchema,
|
|
async execute(_toolCallId, params) {
|
|
const rawParams = asParamRecord(params);
|
|
const json = (payload: unknown) => ({
|
|
content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }],
|
|
details: payload,
|
|
});
|
|
|
|
try {
|
|
const rt = await ensureRuntime();
|
|
|
|
if (typeof rawParams.action === "string") {
|
|
switch (rawParams.action) {
|
|
case "initiate_call": {
|
|
const message = normalizeOptionalString(rawParams.message) ?? "";
|
|
if (!message) {
|
|
throw new Error("message required");
|
|
}
|
|
const to = normalizeOptionalString(rawParams.to) ?? rt.config.toNumber;
|
|
if (!to) {
|
|
throw new Error("to required");
|
|
}
|
|
const result = await rt.manager.initiateCall(to, undefined, {
|
|
message,
|
|
dtmfSequence: normalizeOptionalString(rawParams.dtmfSequence),
|
|
mode:
|
|
rawParams.mode === "notify" || rawParams.mode === "conversation"
|
|
? rawParams.mode
|
|
: undefined,
|
|
});
|
|
if (!result.success) {
|
|
throw new Error(result.error || "initiate failed");
|
|
}
|
|
return json({ callId: result.callId, initiated: true });
|
|
}
|
|
case "continue_call": {
|
|
const callId = normalizeOptionalString(rawParams.callId) ?? "";
|
|
const message = normalizeOptionalString(rawParams.message) ?? "";
|
|
if (!callId || !message) {
|
|
throw new Error("callId and message required");
|
|
}
|
|
const result = await rt.manager.continueCall(callId, message);
|
|
if (!result.success) {
|
|
throw new Error(result.error || "continue failed");
|
|
}
|
|
return json({ success: true, transcript: result.transcript });
|
|
}
|
|
case "speak_to_user": {
|
|
const callId = normalizeOptionalString(rawParams.callId) ?? "";
|
|
const message = normalizeOptionalString(rawParams.message) ?? "";
|
|
if (!callId || !message) {
|
|
throw new Error("callId and message required");
|
|
}
|
|
const result = await rt.manager.speak(callId, message);
|
|
if (!result.success) {
|
|
throw new Error(result.error || "speak failed");
|
|
}
|
|
return json({ success: true });
|
|
}
|
|
case "send_dtmf": {
|
|
const callId = normalizeOptionalString(rawParams.callId) ?? "";
|
|
const digits = normalizeOptionalString(rawParams.digits) ?? "";
|
|
if (!callId || !digits) {
|
|
throw new Error("callId and digits required");
|
|
}
|
|
const result = await rt.manager.sendDtmf(callId, digits);
|
|
if (!result.success) {
|
|
throw new Error(result.error || "dtmf failed");
|
|
}
|
|
return json({ success: true });
|
|
}
|
|
case "end_call": {
|
|
const callId = normalizeOptionalString(rawParams.callId) ?? "";
|
|
if (!callId) {
|
|
throw new Error("callId required");
|
|
}
|
|
const result = await rt.manager.endCall(callId);
|
|
if (!result.success) {
|
|
throw new Error(result.error || "end failed");
|
|
}
|
|
return json({ success: true });
|
|
}
|
|
case "get_status": {
|
|
const callId = normalizeOptionalString(rawParams.callId) ?? "";
|
|
if (!callId) {
|
|
throw new Error("callId required");
|
|
}
|
|
const call =
|
|
rt.manager.getCall(callId) || rt.manager.getCallByProviderCallId(callId);
|
|
return json(call ? { found: true, call } : { found: false });
|
|
}
|
|
}
|
|
}
|
|
|
|
const mode = rawParams.mode ?? "call";
|
|
if (mode === "status") {
|
|
const sid = normalizeOptionalString(rawParams.sid) ?? "";
|
|
if (!sid) {
|
|
throw new Error("sid required for status");
|
|
}
|
|
const call = rt.manager.getCall(sid) || rt.manager.getCallByProviderCallId(sid);
|
|
return json(call ? { found: true, call } : { found: false });
|
|
}
|
|
|
|
const to = normalizeOptionalString(rawParams.to) ?? rt.config.toNumber;
|
|
if (!to) {
|
|
throw new Error("to required for call");
|
|
}
|
|
const result = await rt.manager.initiateCall(to, undefined, {
|
|
dtmfSequence: normalizeOptionalString(rawParams.dtmfSequence),
|
|
message: normalizeOptionalString(rawParams.message),
|
|
});
|
|
if (!result.success) {
|
|
throw new Error(result.error || "initiate failed");
|
|
}
|
|
return json({ callId: result.callId, initiated: true });
|
|
} catch (err) {
|
|
return json({
|
|
error: formatErrorMessage(err),
|
|
});
|
|
}
|
|
},
|
|
});
|
|
|
|
api.registerCli(
|
|
({ program }) =>
|
|
registerVoiceCallCli({
|
|
program,
|
|
config,
|
|
ensureRuntime,
|
|
logger: api.logger,
|
|
}),
|
|
{ commands: ["voicecall"] },
|
|
);
|
|
|
|
api.registerService({
|
|
id: "voicecall",
|
|
start: () => {
|
|
if (isCliOnlyProcess()) {
|
|
return;
|
|
}
|
|
if (!config.enabled) {
|
|
return;
|
|
}
|
|
if (!validation.valid) {
|
|
api.logger.warn(
|
|
`[voice-call] Runtime not started; setup incomplete: ${validation.errors.join("; ")}`,
|
|
);
|
|
return;
|
|
}
|
|
void ensureRuntime().catch((err) => {
|
|
api.logger.error(`[voice-call] Failed to start runtime: ${formatErrorMessage(err)}`);
|
|
});
|
|
},
|
|
stop: async () => {
|
|
if (runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY]) {
|
|
await runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY];
|
|
return;
|
|
}
|
|
const runtime = runtimeState[VOICE_CALL_RUNTIME_KEY];
|
|
const runtimePromise = runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY];
|
|
if (!runtime && !runtimePromise) {
|
|
return;
|
|
}
|
|
runtimeState[VOICE_CALL_RUNTIME_KEY] = null;
|
|
runtimeState[VOICE_CALL_RUNTIME_PROMISE_KEY] = null;
|
|
const stopPromise = (async () => {
|
|
const rt = runtime ?? (await runtimePromise!);
|
|
await rt.stop();
|
|
})();
|
|
runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY] = stopPromise;
|
|
try {
|
|
await stopPromise;
|
|
} finally {
|
|
if (runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY] === stopPromise) {
|
|
runtimeState[VOICE_CALL_RUNTIME_STOP_PROMISE_KEY] = null;
|
|
}
|
|
}
|
|
},
|
|
});
|
|
},
|
|
});
|