feat(voice-call): route inbound calls per number

This commit is contained in:
Peter Steinberger
2026-05-02 09:44:32 +01:00
parent de67311b96
commit 39a931f1bf
15 changed files with 385 additions and 15 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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 },

View File

@@ -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,

View File

@@ -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", () => {

View File

@@ -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 },

View File

@@ -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();

View File

@@ -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 } : {}),
},
};

View File

@@ -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 () => {});

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
});
},

View File

@@ -11,6 +11,7 @@ export function createVoiceCallBaseConfig(params?: {
fromNumber: "+15550001234",
inboundPolicy: "disabled",
allowFrom: [],
numbers: {},
outbound: { defaultMode: "notify", notifyHangupDelaySec: 3 },
maxDurationSeconds: 300,
staleCallReaperSeconds: 600,

View File

@@ -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,