fix: send twilio notify twiml directly

This commit is contained in:
Peter Steinberger
2026-05-01 12:35:40 +01:00
parent 050f0f50c9
commit ec69c07b27
5 changed files with 80 additions and 18 deletions

View File

@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Voice Call/Twilio: send notify-mode initial TwiML directly in the outbound create-call request while keeping conversation and pre-connect DTMF calls webhook-driven, so one-shot notify calls do not depend on a first-answer webhook fetch. Supersedes #72758. Thanks @tyshepps.
- Discord/Slack: defer status-reaction cleanup until run finalization so queued, thinking, tool, and terminal reactions no longer flicker during normal progress updates. (#75582)
- Discord/voice: rerun configured voice auto-join after Discord gateway RESUMED events and ignore already-destroyed stale voice connections during reconnect cleanup, so health-monitor account restarts can rejoin configured channels. Fixes #40665. Thanks @liz709.
- Discord/voice: lengthen the default voice join Ready wait, add configurable `voice.connectTimeoutMs`/`voice.reconnectGraceMs`, and warn before destroying unrecovered disconnected sessions so slow Discord voice handshakes and reconnects no longer fail silently. Fixes #63098; refs #39825 and #65039. Thanks @darealgege, @kzicherman, and @ayochim.

View File

@@ -668,6 +668,12 @@ space, because the carrier cannot call back into those addresses. Do not use
`localhost`, `127.0.0.1`, `0.0.0.0`, `10.x`, `172.16.x`-`172.31.x`,
`192.168.x`, `169.254.x`, `fc00::/7`, or `fd00::/8` as `publicUrl`.
Twilio notify-mode outbound calls send their initial `<Say>` TwiML directly in
the create-call request, so the first spoken message does not depend on Twilio
fetching webhook TwiML. A public webhook is still required for status callbacks,
conversation calls, pre-connect DTMF, realtime streams, and post-connect call
control.
Use one public exposure path:
```json5

View File

@@ -54,8 +54,8 @@ type TwilioApiRequest = (
options?: { allowNotFound?: boolean },
) => Promise<unknown>;
function createApiRequestMock() {
return vi.fn<TwilioApiRequest>(async () => ({}));
function createApiRequestMock(impl?: TwilioApiRequest) {
return vi.fn<TwilioApiRequest>(impl ?? (async () => ({})));
}
function createTwilioCallStateRaceError(): TwilioApiError {
@@ -88,6 +88,63 @@ function configureTelephonyTwiMlFallback(params: { providerCallId: string; strea
}
describe("TwilioProvider", () => {
it("sends direct initial TwiML for notify-mode outbound calls", async () => {
const provider = createProvider();
const apiRequest = createApiRequestMock(async () => ({ sid: "CA123", status: "queued" }));
(
provider as unknown as {
apiRequest: TwilioApiRequest;
}
).apiRequest = apiRequest;
const result = await provider.initiateCall({
callId: "call-1",
from: "+14155550100",
to: "+14155550123",
webhookUrl: "https://example.ngrok.app/voice/webhook",
inlineTwiml: "<Response><Say>Hello</Say></Response>",
});
expect(result).toEqual({ providerCallId: "CA123", status: "queued" });
expect(apiRequest).toHaveBeenCalledWith(
"/Calls.json",
expect.objectContaining({
To: "+14155550123",
From: "+14155550100",
Twiml: "<Response><Say>Hello</Say></Response>",
StatusCallback: "https://example.ngrok.app/voice/webhook?callId=call-1&type=status",
StatusCallbackEvent: ["initiated", "ringing", "answered", "completed"],
}),
);
expect(apiRequest.mock.calls[0]?.[1]).not.toHaveProperty("Url");
});
it("uses the webhook URL for conversation outbound calls", async () => {
const provider = createProvider();
const apiRequest = createApiRequestMock(async () => ({ sid: "CA123", status: "queued" }));
(
provider as unknown as {
apiRequest: TwilioApiRequest;
}
).apiRequest = apiRequest;
await provider.initiateCall({
callId: "call-1",
from: "+14155550100",
to: "+14155550123",
webhookUrl: "https://example.ngrok.app/voice/webhook",
});
expect(apiRequest).toHaveBeenCalledWith(
"/Calls.json",
expect.objectContaining({
Url: "https://example.ngrok.app/voice/webhook?callId=call-1",
StatusCallback: "https://example.ngrok.app/voice/webhook?callId=call-1&type=status",
}),
);
expect(apiRequest.mock.calls[0]?.[1]).not.toHaveProperty("Twiml");
});
it("returns streaming TwiML for outbound conversation calls before in-progress", () => {
const provider = createProvider();
const ctx = createContext("CallStatus=initiated&Direction=outbound-api&CallSid=CA123", {

View File

@@ -537,8 +537,8 @@ export class TwilioProvider implements VoiceCallProvider {
/**
* Initiate an outbound call via Twilio API.
* If inlineTwiml or preConnectTwiml is provided, the first webhook request
* receives that TwiML before normal dynamic TwiML resumes.
* If preConnectTwiml is provided, the first webhook request receives that
* TwiML before normal dynamic TwiML resumes.
*/
async initiateCall(input: InitiateCallInput): Promise<InitiateCallResult> {
const url = new URL(input.webhookUrl);
@@ -549,32 +549,30 @@ export class TwilioProvider implements VoiceCallProvider {
statusUrl.searchParams.set("callId", input.callId);
statusUrl.searchParams.set("type", "status"); // Differentiate from TwiML requests
// Store TwiML content if provided (for notify mode)
// We now serve it from the webhook endpoint instead of sending inline
if (input.inlineTwiml) {
this.twimlStorage.set(input.callId, input.inlineTwiml);
this.notifyCalls.add(input.callId);
console.log(
`[voice-call] Stored Twilio initial TwiML for call ${input.callId} (kind=notify)`,
);
} else if (input.preConnectTwiml) {
if (!input.inlineTwiml && input.preConnectTwiml) {
this.twimlStorage.set(input.callId, input.preConnectTwiml);
console.log(
`[voice-call] Stored Twilio initial TwiML for call ${input.callId} (kind=pre-connect)`,
);
}
// Build request params - always use URL-based TwiML.
// Twilio silently ignores `StatusCallback` when using the inline `Twiml` parameter.
const params: Record<string, string | string[]> = {
To: input.to,
From: input.from,
Url: url.toString(), // TwiML serving endpoint
StatusCallback: statusUrl.toString(), // Separate status callback endpoint
StatusCallback: statusUrl.toString(),
StatusCallbackEvent: ["initiated", "ringing", "answered", "completed"],
Timeout: "30",
};
if (input.inlineTwiml) {
params.Twiml = input.inlineTwiml;
console.log(
`[voice-call] Sending direct Twilio initial TwiML for call ${input.callId} (kind=notify)`,
);
} else {
params.Url = url.toString();
}
const result = await this.apiRequest<TwilioCallResponse>("/Calls.json", params);
this.callWebhookUrls.set(result.sid, url.toString());

View File

@@ -211,7 +211,7 @@ export type InitiateCallInput = {
to: string;
webhookUrl: string;
clientState?: Record<string, string>;
/** Inline TwiML to execute (skips webhook, used for notify mode) */
/** Inline TwiML to execute without fetching webhook TwiML. */
inlineTwiml?: string;
/** TwiML to serve once before normal webhook-driven call handling resumes. */
preConnectTwiml?: string;