Compare commits
2 Commits
main
...
fix/voice-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63ac6ccd20 | ||
|
|
340a054f73 |
@ -30,6 +30,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
|
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
|
||||||
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
|
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
|
||||||
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
|
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
|
||||||
|
- Voice call: separate TwiML fetches from status callbacks to avoid stuck calls. (#1180) — thanks @andrew-kurin.
|
||||||
|
|
||||||
## 2026.1.18-5
|
## 2026.1.18-5
|
||||||
|
|
||||||
@ -47,6 +48,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
|
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
|
||||||
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
|
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
|
||||||
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
|
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
|
||||||
|
- Voice call: separate TwiML fetches from status callbacks to avoid stuck calls. (#1180) — thanks @andrew-kurin.
|
||||||
|
|
||||||
## 2026.1.18-5
|
## 2026.1.18-5
|
||||||
|
|
||||||
|
|||||||
92
extensions/voice-call/src/providers/twilio.test.ts
Normal file
92
extensions/voice-call/src/providers/twilio.test.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { TwilioProvider } from "./twilio.js";
|
||||||
|
|
||||||
|
const mockTwilioConfig = {
|
||||||
|
accountSid: "AC00000000000000000000000000000000",
|
||||||
|
authToken: "test-token",
|
||||||
|
};
|
||||||
|
|
||||||
|
const callId = "internal-call-id";
|
||||||
|
const twimlPayload =
|
||||||
|
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><Response><Say>Hi</Say></Response>";
|
||||||
|
|
||||||
|
describe("TwilioProvider", () => {
|
||||||
|
it("serves stored TwiML on twiml requests without emitting events", async () => {
|
||||||
|
const provider = new TwilioProvider(mockTwilioConfig);
|
||||||
|
(provider as unknown as { apiRequest: (endpoint: string, params: Record<string, string>) => Promise<unknown> }).apiRequest =
|
||||||
|
async () => ({
|
||||||
|
sid: "CA00000000000000000000000000000000",
|
||||||
|
status: "queued",
|
||||||
|
direction: "outbound-api",
|
||||||
|
from: "+15550000000",
|
||||||
|
to: "+15550000001",
|
||||||
|
uri: "/Calls/CA00000000000000000000000000000000.json",
|
||||||
|
});
|
||||||
|
|
||||||
|
await provider.initiateCall({
|
||||||
|
callId,
|
||||||
|
to: "+15550000000",
|
||||||
|
from: "+15550000001",
|
||||||
|
webhookUrl: "https://example.com/voice/webhook?provider=twilio",
|
||||||
|
inlineTwiml: twimlPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = provider.parseWebhookEvent({
|
||||||
|
headers: { host: "example.com" },
|
||||||
|
rawBody:
|
||||||
|
"CallSid=CA00000000000000000000000000000000&CallStatus=initiated&Direction=outbound-api",
|
||||||
|
url: `https://example.com/voice/webhook?provider=twilio&callId=${callId}&type=twiml`,
|
||||||
|
method: "POST",
|
||||||
|
query: { provider: "twilio", callId, type: "twiml" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.events).toHaveLength(0);
|
||||||
|
expect(result.providerResponseBody).toBe(twimlPayload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not consume stored TwiML on status callbacks", async () => {
|
||||||
|
const provider = new TwilioProvider(mockTwilioConfig);
|
||||||
|
(provider as unknown as { apiRequest: (endpoint: string, params: Record<string, string>) => Promise<unknown> }).apiRequest =
|
||||||
|
async () => ({
|
||||||
|
sid: "CA00000000000000000000000000000000",
|
||||||
|
status: "queued",
|
||||||
|
direction: "outbound-api",
|
||||||
|
from: "+15550000000",
|
||||||
|
to: "+15550000001",
|
||||||
|
uri: "/Calls/CA00000000000000000000000000000000.json",
|
||||||
|
});
|
||||||
|
|
||||||
|
await provider.initiateCall({
|
||||||
|
callId,
|
||||||
|
to: "+15550000000",
|
||||||
|
from: "+15550000001",
|
||||||
|
webhookUrl: "https://example.com/voice/webhook?provider=twilio",
|
||||||
|
inlineTwiml: twimlPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusResult = provider.parseWebhookEvent({
|
||||||
|
headers: { host: "example.com" },
|
||||||
|
rawBody:
|
||||||
|
"CallSid=CA00000000000000000000000000000000&CallStatus=initiated&Direction=outbound-api&From=%2B15550000000&To=%2B15550000001",
|
||||||
|
url: `https://example.com/voice/webhook?provider=twilio&callId=${callId}&type=status`,
|
||||||
|
method: "POST",
|
||||||
|
query: { provider: "twilio", callId, type: "status" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(statusResult.events).toHaveLength(1);
|
||||||
|
expect(statusResult.events[0]?.type).toBe("call.initiated");
|
||||||
|
expect(statusResult.providerResponseBody).not.toBe(twimlPayload);
|
||||||
|
|
||||||
|
const twimlResult = provider.parseWebhookEvent({
|
||||||
|
headers: { host: "example.com" },
|
||||||
|
rawBody:
|
||||||
|
"CallSid=CA00000000000000000000000000000000&CallStatus=initiated&Direction=outbound-api",
|
||||||
|
url: `https://example.com/voice/webhook?provider=twilio&callId=${callId}&type=twiml`,
|
||||||
|
method: "POST",
|
||||||
|
query: { provider: "twilio", callId, type: "twiml" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(twimlResult.providerResponseBody).toBe(twimlPayload);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -62,6 +62,37 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
/** Map of call SID to stream SID for media streams */
|
/** Map of call SID to stream SID for media streams */
|
||||||
private callStreamMap = new Map<string, string>();
|
private callStreamMap = new Map<string, string>();
|
||||||
|
|
||||||
|
/** Storage for TwiML content (for notify mode with URL-based TwiML) */
|
||||||
|
private readonly twimlStorage = new Map<string, string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete stored TwiML for a given `callId`.
|
||||||
|
*
|
||||||
|
* We keep TwiML in-memory only long enough to satisfy the initial Twilio
|
||||||
|
* webhook request (notify mode). Subsequent webhooks should not reuse it.
|
||||||
|
*/
|
||||||
|
private deleteStoredTwiml(callId: string): void {
|
||||||
|
this.twimlStorage.delete(callId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete stored TwiML for a call, addressed by Twilio's provider call SID.
|
||||||
|
*
|
||||||
|
* This is used when we only have `providerCallId` (e.g. hangup).
|
||||||
|
*/
|
||||||
|
private deleteStoredTwimlForProviderCall(providerCallId: string): void {
|
||||||
|
const webhookUrl = this.callWebhookUrls.get(providerCallId);
|
||||||
|
if (!webhookUrl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const callId = new URL(webhookUrl).searchParams.get("callId");
|
||||||
|
if (!callId) return;
|
||||||
|
this.deleteStoredTwiml(callId);
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed URLs; best-effort cleanup only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
constructor(config: TwilioConfig, options: TwilioProviderOptions = {}) {
|
constructor(config: TwilioConfig, options: TwilioProviderOptions = {}) {
|
||||||
if (!config.accountSid) {
|
if (!config.accountSid) {
|
||||||
throw new Error("Twilio Account SID is required");
|
throw new Error("Twilio Account SID is required");
|
||||||
@ -149,7 +180,14 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
|
typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
|
||||||
? ctx.query.callId.trim()
|
? ctx.query.callId.trim()
|
||||||
: undefined;
|
: undefined;
|
||||||
const event = this.normalizeEvent(params, callIdFromQuery);
|
const requestType =
|
||||||
|
typeof ctx.query?.type === "string" && ctx.query.type.trim()
|
||||||
|
? ctx.query.type.trim()
|
||||||
|
: undefined;
|
||||||
|
const isTwimlRequest = requestType === "twiml";
|
||||||
|
const event = isTwimlRequest
|
||||||
|
? null
|
||||||
|
: this.normalizeEvent(params, callIdFromQuery);
|
||||||
|
|
||||||
// For Twilio, we must return TwiML. Most actions are driven by Calls API updates,
|
// For Twilio, we must return TwiML. Most actions are driven by Calls API updates,
|
||||||
// so the webhook response is typically a pause to keep the call alive.
|
// so the webhook response is typically a pause to keep the call alive.
|
||||||
@ -228,8 +266,14 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
case "busy":
|
case "busy":
|
||||||
case "no-answer":
|
case "no-answer":
|
||||||
case "failed":
|
case "failed":
|
||||||
|
if (callIdOverride) {
|
||||||
|
this.deleteStoredTwiml(callIdOverride);
|
||||||
|
}
|
||||||
return { ...baseEvent, type: "call.ended", reason: callStatus };
|
return { ...baseEvent, type: "call.ended", reason: callStatus };
|
||||||
case "canceled":
|
case "canceled":
|
||||||
|
if (callIdOverride) {
|
||||||
|
this.deleteStoredTwiml(callIdOverride);
|
||||||
|
}
|
||||||
return { ...baseEvent, type: "call.ended", reason: "hangup-bot" };
|
return { ...baseEvent, type: "call.ended", reason: "hangup-bot" };
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
@ -254,11 +298,29 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
const params = new URLSearchParams(ctx.rawBody);
|
const params = new URLSearchParams(ctx.rawBody);
|
||||||
const callStatus = params.get("CallStatus");
|
const callStatus = params.get("CallStatus");
|
||||||
const direction = params.get("Direction");
|
const direction = params.get("Direction");
|
||||||
|
const callIdFromQuery =
|
||||||
|
typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
|
||||||
|
? ctx.query.callId.trim()
|
||||||
|
: undefined;
|
||||||
|
const requestType =
|
||||||
|
typeof ctx.query?.type === "string" && ctx.query.type.trim()
|
||||||
|
? ctx.query.type.trim()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
console.log(
|
// Avoid logging webhook params/TwiML (may contain PII).
|
||||||
`[voice-call] generateTwimlResponse: status=${callStatus} direction=${direction}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
|
// Handle initial TwiML request (when Twilio first initiates the call)
|
||||||
|
// Check if we have stored TwiML for this call (notify mode)
|
||||||
|
if (callIdFromQuery && requestType !== "status") {
|
||||||
|
const storedTwiml = this.twimlStorage.get(callIdFromQuery);
|
||||||
|
if (storedTwiml) {
|
||||||
|
// Clean up after serving (one-time use)
|
||||||
|
this.deleteStoredTwiml(callIdFromQuery);
|
||||||
|
return storedTwiml;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle subsequent webhook requests (status callbacks, etc.)
|
||||||
// For inbound calls, answer immediately with stream
|
// For inbound calls, answer immediately with stream
|
||||||
if (direction === "inbound") {
|
if (direction === "inbound") {
|
||||||
const streamUrl = this.getStreamUrl();
|
const streamUrl = this.getStreamUrl();
|
||||||
@ -325,31 +387,39 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
* Otherwise, uses webhook URL for dynamic TwiML.
|
* Otherwise, uses webhook URL for dynamic TwiML.
|
||||||
*/
|
*/
|
||||||
async initiateCall(input: InitiateCallInput): Promise<InitiateCallResult> {
|
async initiateCall(input: InitiateCallInput): Promise<InitiateCallResult> {
|
||||||
const url = new URL(input.webhookUrl);
|
const webhookUrl = new URL(input.webhookUrl);
|
||||||
url.searchParams.set("callId", input.callId);
|
webhookUrl.searchParams.set("callId", input.callId);
|
||||||
|
|
||||||
// Build request params
|
const twimlUrl = new URL(webhookUrl);
|
||||||
|
twimlUrl.searchParams.set("type", "twiml");
|
||||||
|
|
||||||
|
// Create separate URL for status callbacks (required by Twilio)
|
||||||
|
const statusUrl = new URL(webhookUrl);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build request params - always use URL-based TwiML.
|
||||||
|
// Twilio silently ignores `StatusCallback` when using the inline `Twiml` parameter.
|
||||||
const params: Record<string, string> = {
|
const params: Record<string, string> = {
|
||||||
To: input.to,
|
To: input.to,
|
||||||
From: input.from,
|
From: input.from,
|
||||||
StatusCallback: url.toString(),
|
Url: twimlUrl.toString(), // TwiML serving endpoint
|
||||||
|
StatusCallback: statusUrl.toString(), // Separate status callback endpoint
|
||||||
StatusCallbackEvent: "initiated ringing answered completed",
|
StatusCallbackEvent: "initiated ringing answered completed",
|
||||||
Timeout: "30",
|
Timeout: "30",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use inline TwiML for notify mode (simpler, no webhook needed)
|
|
||||||
if (input.inlineTwiml) {
|
|
||||||
params.Twiml = input.inlineTwiml;
|
|
||||||
} else {
|
|
||||||
params.Url = url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.apiRequest<TwilioCallResponse>(
|
const result = await this.apiRequest<TwilioCallResponse>(
|
||||||
"/Calls.json",
|
"/Calls.json",
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.callWebhookUrls.set(result.sid, url.toString());
|
this.callWebhookUrls.set(result.sid, webhookUrl.toString());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
providerCallId: result.sid,
|
providerCallId: result.sid,
|
||||||
@ -361,6 +431,8 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
* Hang up a call via Twilio API.
|
* Hang up a call via Twilio API.
|
||||||
*/
|
*/
|
||||||
async hangupCall(input: HangupCallInput): Promise<void> {
|
async hangupCall(input: HangupCallInput): Promise<void> {
|
||||||
|
this.deleteStoredTwimlForProviderCall(input.providerCallId);
|
||||||
|
|
||||||
this.callWebhookUrls.delete(input.providerCallId);
|
this.callWebhookUrls.delete(input.providerCallId);
|
||||||
|
|
||||||
await this.apiRequest(
|
await this.apiRequest(
|
||||||
|
|||||||
@ -185,7 +185,7 @@ export function registerExecApprovalsCli(program: Command) {
|
|||||||
}
|
}
|
||||||
allowlistEntries.push({ pattern: trimmed, lastUsedAt: Date.now() });
|
allowlistEntries.push({ pattern: trimmed, lastUsedAt: Date.now() });
|
||||||
agent.allowlist = allowlistEntries;
|
agent.allowlist = allowlistEntries;
|
||||||
file.agents = { ...(file.agents ?? {}), [agentKey]: agent };
|
file.agents = { ...file.agents, [agentKey]: agent };
|
||||||
const next = await saveSnapshot(opts, nodeId, file, snapshot.hash);
|
const next = await saveSnapshot(opts, nodeId, file, snapshot.hash);
|
||||||
const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2);
|
const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2);
|
||||||
defaultRuntime.log(payload);
|
defaultRuntime.log(payload);
|
||||||
@ -229,11 +229,11 @@ export function registerExecApprovalsCli(program: Command) {
|
|||||||
agent.allowlist = nextEntries;
|
agent.allowlist = nextEntries;
|
||||||
}
|
}
|
||||||
if (isEmptyAgent(agent)) {
|
if (isEmptyAgent(agent)) {
|
||||||
const agents = { ...(file.agents ?? {}) };
|
const agents = { ...file.agents };
|
||||||
delete agents[agentKey];
|
delete agents[agentKey];
|
||||||
file.agents = Object.keys(agents).length > 0 ? agents : undefined;
|
file.agents = Object.keys(agents).length > 0 ? agents : undefined;
|
||||||
} else {
|
} else {
|
||||||
file.agents = { ...(file.agents ?? {}), [agentKey]: agent };
|
file.agents = { ...file.agents, [agentKey]: agent };
|
||||||
}
|
}
|
||||||
const next = await saveSnapshot(opts, nodeId, file, snapshot.hash);
|
const next = await saveSnapshot(opts, nodeId, file, snapshot.hash);
|
||||||
const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2);
|
const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2);
|
||||||
|
|||||||
@ -242,7 +242,7 @@ function parseFirstToken(command: string): string | null {
|
|||||||
if (end > 1) return trimmed.slice(1, end);
|
if (end > 1) return trimmed.slice(1, end);
|
||||||
return trimmed.slice(1);
|
return trimmed.slice(1);
|
||||||
}
|
}
|
||||||
const match = /^[^\\s]+/.exec(trimmed);
|
const match = /^\S+/.exec(trimmed);
|
||||||
return match ? match[0] : null;
|
return match ? match[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -36,7 +36,11 @@ function extractErrorMessage(err: unknown): string | undefined {
|
|||||||
if (typeof err === "object" && "message" in err && typeof err.message === "string") {
|
if (typeof err === "object" && "message" in err && typeof err.message === "string") {
|
||||||
return err.message;
|
return err.message;
|
||||||
}
|
}
|
||||||
return String(err);
|
try {
|
||||||
|
return JSON.stringify(err);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function logRemoteBinProbeFailure(nodeId: string, err: unknown) {
|
function logRemoteBinProbeFailure(nodeId: string, err: unknown) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user