mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:40:44 +00:00
feat(voice-call): route inbound calls per number
This commit is contained in:
@@ -80,6 +80,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Web search/Firecrawl: allow self-hosted private/internal Firecrawl `baseUrl` endpoints, including HTTP for private targets, while keeping hosted Firecrawl on the strict official endpoint. Fixes #63877 and supersedes #59666, #63941, and #74013. Thanks @jhthompson12, @jzakirov, @Mlightsnow, and @shad0wca7.
|
||||
- CLI/models: report gateway model fallback attempts in `infer model run --json` and avoid double-prefixing provider-qualified defaults such as `openrouter/auto` in `models status`. Partially fixes #69527. Thanks @alexifra.
|
||||
- Providers/OpenRouter: strip trailing assistant prefill turns from verified OpenRouter Anthropic model requests when reasoning is enabled, so Claude 4.6 routes no longer fail with Anthropic's prefill rejection through the OpenAI-compatible adapter. Fixes #75395. Thanks @sbmilburn.
|
||||
- Voice Call: add per-number inbound routing for dialed-number greetings, response agents/models/prompts, and TTS voice overrides. Fixes #56604. Thanks @healthstatus.
|
||||
- Feishu: preserve Feishu/Lark HTTP error bodies for message sends, media sends, and chat member lookups, so HTTP 400 failures include vendor code, message, log id, and troubleshooter details. Fixes #73860. Thanks @desksk.
|
||||
- Agents/transcripts: avoid reopening large Pi transcript files through the synchronous session manager for maintenance rewrites, persisted tool-result truncation, manual compaction boundary hardening, and queued compaction rotation. Thanks @mariozechner.
|
||||
- Web search/Exa: accept `plugins.entries.exa.config.webSearch.baseUrl`, normalize it to the Exa `/search` endpoint, and partition cached results by endpoint. Fixes #54928 and supersedes #54939. Thanks @mrpl327 and @lyfuci.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
a7158716d9262edba32ef9a18ab04d9f48f83cb903444b6f87b991977b6be52f config-baseline.json
|
||||
366770fd037ace1092595b351fbd83473ee1ecce188bceb0ab4510a5579a9073 config-baseline.json
|
||||
2d132b4c2e3b0e0f2524fc1cc889d3be658ad0e40c970b2d367bf27348883658 config-baseline.core.json
|
||||
f42329d45c095881bd226bdb192c235980658fd250606d0c0badc2b12f12f5d3 config-baseline.channel.json
|
||||
de03faf42db470fe419a3f93a5777161f830f0355912603c6795945e42f39735 config-baseline.plugin.json
|
||||
fffe0e74eab92a88c3c57952a70bc932438ce3a7f5f9982688437f2cdaee0bcb config-baseline.plugin.json
|
||||
|
||||
@@ -110,6 +110,17 @@ Voice-call credentials accept SecretRefs. `plugins.entries.voice-call.config.twi
|
||||
fromNumber: "+15550001234", // or TWILIO_FROM_NUMBER for Twilio
|
||||
toNumber: "+15550005678",
|
||||
sessionScope: "per-phone", // per-phone | per-call
|
||||
numbers: {
|
||||
"+15550009999": {
|
||||
inboundGreeting: "Silver Fox Cards, how can I help?",
|
||||
responseSystemPrompt: "You are a concise baseball card specialist.",
|
||||
tts: {
|
||||
providers: {
|
||||
openai: { voice: "alloy" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
twilio: {
|
||||
accountSid: "ACxxxxxxxx",
|
||||
@@ -500,6 +511,57 @@ identity.
|
||||
Auto-responses use the agent system. Tune with `responseModel`,
|
||||
`responseSystemPrompt`, and `responseTimeoutMs`.
|
||||
|
||||
### Per-number Routing
|
||||
|
||||
Use `numbers` when one Voice Call plugin receives calls for multiple phone
|
||||
numbers and each number should behave like a different line. For example, one
|
||||
number can use a casual personal assistant while another uses a business
|
||||
persona, a different response agent, and a different TTS voice.
|
||||
|
||||
Routes are selected from the provider-supplied dialed `To` number. Keys must be
|
||||
E.164 numbers. When a call arrives, Voice Call resolves the matching route once,
|
||||
stores the matched route on the call record, and reuses that effective config
|
||||
for the greeting, classic auto-response path, realtime consult path, and TTS
|
||||
playback. If no route matches, the global Voice Call config is used.
|
||||
Outbound calls do not use `numbers`; pass the outbound target, message, and
|
||||
session explicitly when initiating the call.
|
||||
|
||||
Route overrides currently support:
|
||||
|
||||
- `inboundGreeting`
|
||||
- `tts`
|
||||
- `agentId`
|
||||
- `responseModel`
|
||||
- `responseSystemPrompt`
|
||||
- `responseTimeoutMs`
|
||||
|
||||
The `tts` route value deep-merges over the global Voice Call `tts` config, so
|
||||
you can usually override only the provider voice:
|
||||
|
||||
```json5
|
||||
{
|
||||
inboundGreeting: "Hello from the main line.",
|
||||
responseSystemPrompt: "You are the default voice assistant.",
|
||||
tts: {
|
||||
provider: "openai",
|
||||
providers: {
|
||||
openai: { voice: "coral" },
|
||||
},
|
||||
},
|
||||
numbers: {
|
||||
"+15550001111": {
|
||||
inboundGreeting: "Silver Fox Cards, how can I help?",
|
||||
responseSystemPrompt: "You are a concise baseball card specialist.",
|
||||
tts: {
|
||||
providers: {
|
||||
openai: { voice: "alloy" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Spoken output contract
|
||||
|
||||
For auto-responses, Voice Call appends a strict spoken-output contract to
|
||||
|
||||
@@ -42,6 +42,11 @@ const voiceCallConfigSchema = {
|
||||
inboundPolicy: { label: "Inbound Policy" },
|
||||
allowFrom: { label: "Inbound Allowlist" },
|
||||
inboundGreeting: { label: "Inbound Greeting", advanced: true },
|
||||
numbers: {
|
||||
label: "Per-number Routing",
|
||||
help: "Inbound overrides keyed by dialed E.164 number.",
|
||||
advanced: true,
|
||||
},
|
||||
"telnyx.apiKey": { label: "Telnyx API Key", sensitive: true },
|
||||
"telnyx.connectionId": { label: "Telnyx Connection ID" },
|
||||
"telnyx.publicKey": { label: "Telnyx Public Key", sensitive: true },
|
||||
|
||||
@@ -45,6 +45,11 @@
|
||||
"label": "Inbound Greeting",
|
||||
"advanced": true
|
||||
},
|
||||
"numbers": {
|
||||
"label": "Per-number Routing",
|
||||
"help": "Inbound overrides keyed by dialed E.164 number.",
|
||||
"advanced": true
|
||||
},
|
||||
"telnyx.apiKey": {
|
||||
"label": "Telnyx API Key",
|
||||
"sensitive": true
|
||||
@@ -279,6 +284,38 @@
|
||||
"inboundGreeting": {
|
||||
"type": "string"
|
||||
},
|
||||
"numbers": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"pattern": "^\\+[1-9]\\d{1,14}$"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"inboundGreeting": {
|
||||
"type": "string"
|
||||
},
|
||||
"tts": {
|
||||
"$ref": "#/properties/tts"
|
||||
},
|
||||
"agentId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"responseModel": {
|
||||
"type": "string"
|
||||
},
|
||||
"responseSystemPrompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"responseTimeoutMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"outbound": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
VoiceCallConfigSchema,
|
||||
resolveTwilioAuthToken,
|
||||
resolveVoiceCallEffectiveConfig,
|
||||
resolveVoiceCallNumberRouteKey,
|
||||
resolveVoiceCallSessionKey,
|
||||
validateProviderConfig,
|
||||
normalizeVoiceCallConfig,
|
||||
@@ -304,6 +306,69 @@ describe("resolveVoiceCallConfig", () => {
|
||||
}),
|
||||
).toBe("meet-room-1");
|
||||
});
|
||||
|
||||
it("resolves per-number inbound route overrides over global voice settings", () => {
|
||||
const config = resolveVoiceCallConfig({
|
||||
enabled: true,
|
||||
provider: "mock",
|
||||
inboundGreeting: "Hello from global.",
|
||||
agentId: "main",
|
||||
responseModel: "openai/gpt-5.4-mini",
|
||||
responseSystemPrompt: "Global voice assistant.",
|
||||
responseTimeoutMs: 10000,
|
||||
tts: {
|
||||
provider: "openai",
|
||||
providers: {
|
||||
openai: { voice: "coral", speed: 1 },
|
||||
},
|
||||
},
|
||||
numbers: {
|
||||
"+15550001111": {
|
||||
inboundGreeting: "Silver Fox Cards, how can I help?",
|
||||
agentId: "cards",
|
||||
responseModel: "openai/gpt-5.5",
|
||||
responseSystemPrompt: "You are a baseball card expert.",
|
||||
responseTimeoutMs: 20000,
|
||||
tts: {
|
||||
providers: {
|
||||
openai: { voice: "alloy" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveVoiceCallNumberRouteKey(config, "+1 (555) 000-1111")).toBe("+15550001111");
|
||||
const effective = resolveVoiceCallEffectiveConfig(config, "+1 (555) 000-1111");
|
||||
|
||||
expect(effective.numberRouteKey).toBe("+15550001111");
|
||||
expect(effective.config.inboundGreeting).toBe("Silver Fox Cards, how can I help?");
|
||||
expect(effective.config.agentId).toBe("cards");
|
||||
expect(effective.config.responseModel).toBe("openai/gpt-5.5");
|
||||
expect(effective.config.responseSystemPrompt).toBe("You are a baseball card expert.");
|
||||
expect(effective.config.responseTimeoutMs).toBe(20000);
|
||||
expect(effective.config.tts?.provider).toBe("openai");
|
||||
expect(effective.config.tts?.providers?.openai).toEqual({ voice: "alloy", speed: 1 });
|
||||
});
|
||||
|
||||
it("falls back to global voice settings when no per-number route matches", () => {
|
||||
const config = resolveVoiceCallConfig({
|
||||
enabled: true,
|
||||
provider: "mock",
|
||||
inboundGreeting: "Hello from global.",
|
||||
numbers: {
|
||||
"+15550001111": {
|
||||
inboundGreeting: "Hello from route.",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const effective = resolveVoiceCallEffectiveConfig(config, "+15550002222");
|
||||
|
||||
expect(effective.numberRouteKey).toBeUndefined();
|
||||
expect(effective.config).toBe(config);
|
||||
expect(effective.config.inboundGreeting).toBe("Hello from global.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeVoiceCallConfig", () => {
|
||||
|
||||
@@ -74,6 +74,24 @@ export type PlivoConfig = z.infer<typeof PlivoConfigSchema>;
|
||||
|
||||
export type VoiceCallTtsConfig = z.infer<typeof TtsConfigSchema>;
|
||||
|
||||
const VoiceCallNumberRouteConfigSchema = z
|
||||
.object({
|
||||
/** Greeting message for inbound calls to this number. */
|
||||
inboundGreeting: z.string().optional(),
|
||||
/** TTS override for inbound calls to this number. Deep-merges with global voice-call TTS. */
|
||||
tts: TtsConfigSchema,
|
||||
/** Agent ID to use for voice response generation for this number. */
|
||||
agentId: z.string().min(1).optional(),
|
||||
/** Optional model override for voice responses for this number. */
|
||||
responseModel: z.string().optional(),
|
||||
/** System prompt for voice responses for this number. */
|
||||
responseSystemPrompt: z.string().optional(),
|
||||
/** Timeout for response generation in ms for this number. */
|
||||
responseTimeoutMs: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict();
|
||||
export type VoiceCallNumberRouteConfig = z.infer<typeof VoiceCallNumberRouteConfigSchema>;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Webhook Server Configuration
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -353,6 +371,9 @@ export const VoiceCallConfigSchema = z
|
||||
/** Greeting message for inbound calls */
|
||||
inboundGreeting: z.string().optional(),
|
||||
|
||||
/** Per-dialed-number overrides for inbound calls. Keys are E.164 numbers. */
|
||||
numbers: z.record(E164Schema, VoiceCallNumberRouteConfigSchema).default({}),
|
||||
|
||||
/** Outbound call configuration */
|
||||
outbound: OutboundConfigSchema,
|
||||
|
||||
@@ -426,6 +447,10 @@ export const VoiceCallConfigSchema = z
|
||||
.strict();
|
||||
|
||||
export type VoiceCallConfig = z.infer<typeof VoiceCallConfigSchema>;
|
||||
export type VoiceCallEffectiveConfigResult = {
|
||||
config: VoiceCallConfig;
|
||||
numberRouteKey?: string;
|
||||
};
|
||||
type DeepPartial<T> = T extends SecretInput
|
||||
? T
|
||||
: T extends Array<infer U>
|
||||
@@ -480,6 +505,56 @@ function normalizeVoiceCallTtsConfig(
|
||||
return TtsConfigSchema.parse(deepMergeDefined(defaults ?? {}, overrides ?? {}));
|
||||
}
|
||||
|
||||
function normalizePhoneRouteKey(phone: string | undefined): string {
|
||||
return phone?.replace(/\D/g, "") ?? "";
|
||||
}
|
||||
|
||||
export function resolveVoiceCallNumberRouteKey(
|
||||
config: Pick<VoiceCallConfig, "numbers">,
|
||||
phone: string | undefined,
|
||||
): string | undefined {
|
||||
const routes = config.numbers;
|
||||
if (!routes) {
|
||||
return undefined;
|
||||
}
|
||||
if (phone && Object.prototype.hasOwnProperty.call(routes, phone)) {
|
||||
return phone;
|
||||
}
|
||||
|
||||
const normalizedPhone = normalizePhoneRouteKey(phone);
|
||||
if (!normalizedPhone) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.keys(routes).find(
|
||||
(routeKey) => normalizePhoneRouteKey(routeKey) === normalizedPhone,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveVoiceCallEffectiveConfig(
|
||||
config: VoiceCallConfig,
|
||||
phoneOrRouteKey: string | undefined,
|
||||
): VoiceCallEffectiveConfigResult {
|
||||
const numberRouteKey = resolveVoiceCallNumberRouteKey(config, phoneOrRouteKey);
|
||||
if (!numberRouteKey) {
|
||||
return { config };
|
||||
}
|
||||
|
||||
const route = config.numbers[numberRouteKey];
|
||||
if (!route) {
|
||||
return { config };
|
||||
}
|
||||
|
||||
return {
|
||||
numberRouteKey,
|
||||
config: {
|
||||
...config,
|
||||
...route,
|
||||
tts: normalizeVoiceCallTtsConfig(config.tts, route.tts),
|
||||
numbers: config.numbers,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeVoiceCallProviderConfigs(
|
||||
value: Record<string, Record<string, unknown> | undefined> | undefined,
|
||||
): Record<string, Record<string, unknown>> {
|
||||
@@ -493,6 +568,19 @@ function sanitizeVoiceCallProviderConfigs(
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeVoiceCallNumberRoutes(
|
||||
value: Record<string, unknown> | undefined,
|
||||
): Record<string, VoiceCallNumberRouteConfig> {
|
||||
if (!value) {
|
||||
return {};
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter((entry): entry is [string, unknown] => entry[1] !== undefined)
|
||||
.map(([key, route]) => [key, VoiceCallNumberRouteConfigSchema.parse(route)]),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveTwilioAuthToken(
|
||||
config: Pick<VoiceCallConfig, "twilio">,
|
||||
): string | undefined {
|
||||
@@ -522,6 +610,9 @@ export function normalizeVoiceCallConfig(config: VoiceCallConfigInput): VoiceCal
|
||||
...defaults,
|
||||
...config,
|
||||
allowFrom: config.allowFrom ?? defaults.allowFrom,
|
||||
numbers: sanitizeVoiceCallNumberRoutes(
|
||||
(config.numbers ?? defaults.numbers) as Record<string, unknown>,
|
||||
),
|
||||
outbound: { ...defaults.outbound, ...config.outbound },
|
||||
serve,
|
||||
tailscale: { ...defaults.tailscale, ...config.tailscale },
|
||||
|
||||
@@ -453,6 +453,43 @@ describe("processEvent (functional)", () => {
|
||||
expect(call.sessionKey).toBe(`voice:call:${call.callId}`);
|
||||
});
|
||||
|
||||
it("applies per-number inbound greeting and stores the matched route key", () => {
|
||||
const ctx = createContext({
|
||||
config: VoiceCallConfigSchema.parse({
|
||||
enabled: true,
|
||||
provider: "plivo",
|
||||
fromNumber: "+15550000000",
|
||||
inboundPolicy: "open",
|
||||
inboundGreeting: "Hello from global.",
|
||||
numbers: {
|
||||
"+15550002222": {
|
||||
inboundGreeting: "Silver Fox Cards, how can I help?",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const event: NormalizedEvent = {
|
||||
id: "evt-inbound-number-route",
|
||||
type: "call.initiated",
|
||||
callId: "CA-inbound-number-route",
|
||||
providerCallId: "CA-inbound-number-route",
|
||||
timestamp: Date.now(),
|
||||
direction: "inbound",
|
||||
from: "+15554444444",
|
||||
to: "+1 (555) 000-2222",
|
||||
};
|
||||
|
||||
processEvent(ctx, event);
|
||||
|
||||
const call = requireFirstActiveCall(ctx);
|
||||
expect(call.metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
initialMessage: "Silver Fox Cards, how can I help?",
|
||||
numberRouteKey: "+15550002222",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("deduplicates by dedupeKey even when event IDs differ", () => {
|
||||
const now = Date.now();
|
||||
const ctx = createContext();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { isAllowlistedCaller, normalizePhoneNumber } from "../allowlist.js";
|
||||
import { resolveVoiceCallSessionKey } from "../config.js";
|
||||
import { resolveVoiceCallEffectiveConfig, resolveVoiceCallSessionKey } from "../config.js";
|
||||
import type { CallRecord, NormalizedEvent } from "../types.js";
|
||||
import type { CallManagerContext } from "./context.js";
|
||||
import { finalizeCall } from "./lifecycle.js";
|
||||
@@ -65,6 +65,11 @@ function createWebhookCall(params: {
|
||||
to: string;
|
||||
}): CallRecord {
|
||||
const callId = crypto.randomUUID();
|
||||
const effective = resolveVoiceCallEffectiveConfig(
|
||||
params.ctx.config,
|
||||
params.direction === "inbound" ? params.to : undefined,
|
||||
);
|
||||
const effectiveConfig = effective.config;
|
||||
|
||||
const callRecord: CallRecord = {
|
||||
callId,
|
||||
@@ -75,7 +80,7 @@ function createWebhookCall(params: {
|
||||
from: params.from,
|
||||
to: params.to,
|
||||
sessionKey: resolveVoiceCallSessionKey({
|
||||
config: params.ctx.config,
|
||||
config: effectiveConfig,
|
||||
callId,
|
||||
phone: params.direction === "outbound" ? params.to : params.from,
|
||||
}),
|
||||
@@ -85,8 +90,9 @@ function createWebhookCall(params: {
|
||||
metadata: {
|
||||
initialMessage:
|
||||
params.direction === "inbound"
|
||||
? params.ctx.config.inboundGreeting || "Hello! How can I help you today?"
|
||||
? effectiveConfig.inboundGreeting || "Hello! How can I help you today?"
|
||||
: undefined,
|
||||
...(effective.numberRouteKey ? { numberRouteKey: effective.numberRouteKey } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -361,6 +361,44 @@ describe("voice-call outbound helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses per-number route TTS voice for routed inbound calls", async () => {
|
||||
const call = {
|
||||
callId: "call-1",
|
||||
providerCallId: "provider-1",
|
||||
state: "active",
|
||||
to: "+15550002222",
|
||||
metadata: { numberRouteKey: "+15550002222" },
|
||||
};
|
||||
const playTts = vi.fn(async () => {});
|
||||
const ctx = {
|
||||
activeCalls: new Map([["call-1", call]]),
|
||||
providerCallIdMap: new Map(),
|
||||
provider: { name: "twilio", playTts },
|
||||
config: {
|
||||
tts: { provider: "openai", providers: { openai: { voice: "coral" } } },
|
||||
numbers: {
|
||||
"+15550002222": {
|
||||
tts: {
|
||||
providers: {
|
||||
openai: { voice: "alloy" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
storePath: "/tmp/voice-call.json",
|
||||
};
|
||||
|
||||
await expect(speak(ctx as never, "call-1", "hello")).resolves.toEqual({ success: true });
|
||||
|
||||
expect(playTts).toHaveBeenCalledWith({
|
||||
callId: "call-1",
|
||||
providerCallId: "provider-1",
|
||||
text: "hello",
|
||||
voice: "alloy",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends DTMF through connected provider calls", async () => {
|
||||
const call = { callId: "call-1", providerCallId: "provider-1", state: "active" };
|
||||
const sendDtmfProvider = vi.fn(async () => {});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import crypto from "node:crypto";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { resolveVoiceCallSessionKey, type CallMode } from "../config.js";
|
||||
import {
|
||||
resolveVoiceCallEffectiveConfig,
|
||||
resolveVoiceCallSessionKey,
|
||||
type CallMode,
|
||||
} from "../config.js";
|
||||
import { resolvePreferredTtsVoice } from "../tts-provider-voice.js";
|
||||
import {
|
||||
type EndReason,
|
||||
@@ -242,7 +246,11 @@ export async function speak(
|
||||
transitionState(call, "speaking");
|
||||
persistCallRecord(ctx.storePath, call);
|
||||
|
||||
const voice = resolvePreferredTtsVoice(ctx.config);
|
||||
const numberRouteKey =
|
||||
typeof call.metadata?.numberRouteKey === "string" ? call.metadata.numberRouteKey : call.to;
|
||||
const voice = resolvePreferredTtsVoice(
|
||||
resolveVoiceCallEffectiveConfig(ctx.config, numberRouteKey).config,
|
||||
);
|
||||
await provider.playTts({
|
||||
callId,
|
||||
providerCallId,
|
||||
|
||||
@@ -44,6 +44,7 @@ vi.mock("./config.js", () => ({
|
||||
const normalizedPhone = params.phone?.replace(/\D/g, "");
|
||||
return normalizedPhone ? `voice:${normalizedPhone}` : `voice:${params.callId}`;
|
||||
},
|
||||
resolveVoiceCallEffectiveConfig: (config: VoiceCallConfig) => ({ config }),
|
||||
resolveVoiceCallConfig: mocks.resolveVoiceCallConfig,
|
||||
resolveTwilioAuthToken: mocks.resolveTwilioAuthToken,
|
||||
validateProviderConfig: mocks.validateProviderConfig,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/realtime-voice";
|
||||
import type { VoiceCallConfig } from "./config.js";
|
||||
import {
|
||||
resolveVoiceCallEffectiveConfig,
|
||||
resolveVoiceCallSessionKey,
|
||||
resolveTwilioAuthToken,
|
||||
resolveVoiceCallConfig,
|
||||
@@ -339,13 +340,21 @@ export async function createVoiceCallRuntime(params: {
|
||||
if (!call) {
|
||||
return { error: `Call "${callId}" not found` };
|
||||
}
|
||||
const agentId = config.agentId ?? "main";
|
||||
const sessionKey = resolveVoiceCallConsultSessionKey({ ...call, config });
|
||||
const numberRouteKey =
|
||||
typeof call.metadata?.numberRouteKey === "string"
|
||||
? call.metadata.numberRouteKey
|
||||
: call.to;
|
||||
const effectiveConfig = resolveVoiceCallEffectiveConfig(config, numberRouteKey).config;
|
||||
const agentId = effectiveConfig.agentId ?? "main";
|
||||
const sessionKey = resolveVoiceCallConsultSessionKey({
|
||||
...call,
|
||||
config: effectiveConfig,
|
||||
});
|
||||
const fastContext = await resolveRealtimeFastContextConsult({
|
||||
cfg,
|
||||
agentId,
|
||||
sessionKey,
|
||||
config: config.realtime.fastContext,
|
||||
config: effectiveConfig.realtime.fastContext,
|
||||
args,
|
||||
logger: log,
|
||||
});
|
||||
@@ -353,7 +362,7 @@ export async function createVoiceCallRuntime(params: {
|
||||
return fastContext.result;
|
||||
}
|
||||
const { provider: agentProvider, model } = resolveVoiceResponseModel({
|
||||
voiceConfig: config,
|
||||
voiceConfig: effectiveConfig,
|
||||
agentRuntime,
|
||||
});
|
||||
const thinkLevel = agentRuntime.resolveThinkingDefault({
|
||||
@@ -379,8 +388,10 @@ export async function createVoiceCallRuntime(params: {
|
||||
provider: agentProvider,
|
||||
model,
|
||||
thinkLevel,
|
||||
timeoutMs: config.responseTimeoutMs,
|
||||
toolsAllow: resolveRealtimeVoiceAgentConsultToolsAllow(config.realtime.toolPolicy),
|
||||
timeoutMs: effectiveConfig.responseTimeoutMs,
|
||||
toolsAllow: resolveRealtimeVoiceAgentConsultToolsAllow(
|
||||
effectiveConfig.realtime.toolPolicy,
|
||||
),
|
||||
extraSystemPrompt: REALTIME_VOICE_CONSULT_SYSTEM_PROMPT,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ export function createVoiceCallBaseConfig(params?: {
|
||||
fromNumber: "+15550001234",
|
||||
inboundPolicy: "disabled",
|
||||
allowFrom: [],
|
||||
numbers: {},
|
||||
outbound: { defaultMode: "notify", notifyHangupDelaySec: 3 },
|
||||
maxDurationSeconds: 300,
|
||||
staleCallReaperSeconds: 600,
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
requestBodyErrorToText,
|
||||
} from "../api.js";
|
||||
import { isAllowlistedCaller, normalizePhoneNumber } from "./allowlist.js";
|
||||
import { normalizeVoiceCallConfig, type VoiceCallConfig } from "./config.js";
|
||||
import {
|
||||
normalizeVoiceCallConfig,
|
||||
resolveVoiceCallEffectiveConfig,
|
||||
type VoiceCallConfig,
|
||||
} from "./config.js";
|
||||
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
|
||||
import { getHeader } from "./http-headers.js";
|
||||
import type { CallManager } from "./manager.js";
|
||||
@@ -873,9 +877,12 @@ export class VoiceCallWebhookServer {
|
||||
|
||||
try {
|
||||
const { generateVoiceResponse } = await loadResponseGeneratorModule();
|
||||
const numberRouteKey =
|
||||
typeof call.metadata?.numberRouteKey === "string" ? call.metadata.numberRouteKey : call.to;
|
||||
const effectiveConfig = resolveVoiceCallEffectiveConfig(this.config, numberRouteKey).config;
|
||||
|
||||
const result = await generateVoiceResponse({
|
||||
voiceConfig: this.config,
|
||||
voiceConfig: effectiveConfig,
|
||||
coreConfig: this.coreConfig,
|
||||
agentRuntime: this.agentRuntime,
|
||||
callId,
|
||||
|
||||
Reference in New Issue
Block a user