mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 12:20:44 +00:00
fix: send twilio notify twiml directly
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user