@openclaw/voice-call 2026.10.2 → 2026.10.5-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +118 -3
- package/dist/.setup/{guarded-json-api-x55yqHvW.mjs → carrier-api-BYKw_fsQ.mjs} +94 -90
- package/dist/.setup/{cli-Cn1leykM.mjs → cli-dyyK8Upw.mjs} +66 -84
- package/dist/.setup/{config-CX9J-053.mjs → config-CsrGuamE.mjs} +95 -69
- package/dist/.setup/{mock-eLv_Dc4o.mjs → mock-rySvj-G6.mjs} +12 -3
- package/dist/.setup/{plivo-CpPIENu4.mjs → plivo-COfD-Kuc.mjs} +35 -89
- package/dist/.setup/{realtime-handler-Do7Sz7Gh.mjs → realtime-handler-BdAuKAC2.mjs} +749 -464
- package/dist/.setup/{response-generator-DAhbXEta.mjs → response-generator-COu0_8-d.mjs} +14 -32
- package/dist/.setup/{runtime-entry-CbSKxSKk.mjs → runtime-entry-STjFmi_B.mjs} +1053 -446
- package/dist/.setup/{telnyx-DPilutCH.mjs → telnyx-B4P0peEe.mjs} +30 -61
- package/dist/.setup/{twilio-BWFNGyVx.mjs → twilio-BzAYgH7M.mjs} +75 -90
- package/dist/.setup/{voice-mapping-B_iAUkER.mjs → voice-mapping-1lzFmzOF.mjs} +0 -10
- package/dist/cli-output-mode.js +1 -0
- package/dist/doctor-contract-api.js +19 -23
- package/dist/index.js +212 -89
- package/dist/runtime-entry.js +1 -1
- package/dist/setup-api.js +7 -6
- package/openclaw.plugin.json +210 -2
- package/package.json +4 -4
|
@@ -1,6 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { i as verifyTelnyxWebhook, t as createCarrierApi } from "./carrier-api-BYKw_fsQ.mjs";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
//#region extensions/voice-call/src/providers/telnyx.ts
|
|
4
|
+
const HANGUP_REASONS = /* @__PURE__ */ new Map([
|
|
5
|
+
["normal_clearing", "completed"],
|
|
6
|
+
["normal_unspecified", "completed"],
|
|
7
|
+
["originator_cancel", "hangup-bot"],
|
|
8
|
+
["call_rejected", "busy"],
|
|
9
|
+
["user_busy", "busy"],
|
|
10
|
+
["no_answer", "no-answer"],
|
|
11
|
+
["no_user_response", "no-answer"],
|
|
12
|
+
["destination_out_of_order", "failed"],
|
|
13
|
+
["network_out_of_order", "failed"],
|
|
14
|
+
["service_unavailable", "failed"],
|
|
15
|
+
["recovery_on_timer_expire", "failed"],
|
|
16
|
+
["machine_detected", "voicemail"],
|
|
17
|
+
["fax_detected", "voicemail"],
|
|
18
|
+
["user_hangup", "hangup-user"],
|
|
19
|
+
["subscriber_absent", "hangup-user"]
|
|
20
|
+
]);
|
|
4
21
|
function normalizeTelnyxDirection(direction) {
|
|
5
22
|
if (direction === "incoming" || direction === "inbound") return "inbound";
|
|
6
23
|
if (direction === "outgoing" || direction === "outbound") return "outbound";
|
|
@@ -16,29 +33,12 @@ function decodeClientStateBase64(value) {
|
|
|
16
33
|
var TelnyxProvider = class {
|
|
17
34
|
constructor(config, options = {}) {
|
|
18
35
|
this.name = "telnyx";
|
|
19
|
-
this.baseUrl = "https://api.telnyx.com/v2";
|
|
20
|
-
this.apiHost = "api.telnyx.com";
|
|
21
36
|
if (!config.apiKey) throw new Error("Telnyx API key is required");
|
|
22
37
|
if (!config.connectionId) throw new Error("Telnyx connection ID is required");
|
|
23
|
-
this.apiKey = config.apiKey;
|
|
24
38
|
this.connectionId = config.connectionId;
|
|
25
39
|
this.publicKey = config.publicKey;
|
|
26
40
|
this.options = options;
|
|
27
|
-
|
|
28
|
-
async apiRequest(endpoint, body, options) {
|
|
29
|
-
return await guardedJsonApiRequest({
|
|
30
|
-
url: `${this.baseUrl}${endpoint}`,
|
|
31
|
-
method: "POST",
|
|
32
|
-
headers: {
|
|
33
|
-
Authorization: `Bearer ${this.apiKey}`,
|
|
34
|
-
"Content-Type": "application/json"
|
|
35
|
-
},
|
|
36
|
-
body,
|
|
37
|
-
allowNotFound: options?.allowNotFound,
|
|
38
|
-
allowedHostnames: [this.apiHost],
|
|
39
|
-
auditContext: "voice-call.telnyx.api",
|
|
40
|
-
errorPrefix: "Telnyx API error"
|
|
41
|
-
});
|
|
41
|
+
this.api = createCarrierApi("Telnyx", "https://api.telnyx.com/v2", `Bearer ${config.apiKey}`, { statusContentType: "application/json" });
|
|
42
42
|
}
|
|
43
43
|
verifyWebhook(ctx) {
|
|
44
44
|
return verifyTelnyxWebhook(ctx, this.publicKey, { skipVerification: this.options.skipVerification });
|
|
@@ -116,31 +116,11 @@ var TelnyxProvider = class {
|
|
|
116
116
|
default: return null;
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
|
-
/**
|
|
120
|
-
* Map Telnyx hangup cause to normalized end reason.
|
|
121
|
-
* @see https://developers.telnyx.com/docs/api/v2/call-control/Call-Commands#hangup-causes
|
|
122
|
-
*/
|
|
123
119
|
mapHangupCause(cause) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
case "call_rejected":
|
|
129
|
-
case "user_busy": return "busy";
|
|
130
|
-
case "no_answer":
|
|
131
|
-
case "no_user_response": return "no-answer";
|
|
132
|
-
case "destination_out_of_order":
|
|
133
|
-
case "network_out_of_order":
|
|
134
|
-
case "service_unavailable":
|
|
135
|
-
case "recovery_on_timer_expire": return "failed";
|
|
136
|
-
case "machine_detected":
|
|
137
|
-
case "fax_detected": return "voicemail";
|
|
138
|
-
case "user_hangup":
|
|
139
|
-
case "subscriber_absent": return "hangup-user";
|
|
140
|
-
default:
|
|
141
|
-
if (cause) console.warn(`[telnyx] Unknown hangup cause: ${cause}`);
|
|
142
|
-
return "completed";
|
|
143
|
-
}
|
|
120
|
+
const reason = cause && HANGUP_REASONS.get(cause);
|
|
121
|
+
if (reason) return reason;
|
|
122
|
+
if (cause) console.warn(`[telnyx] Unknown hangup cause: ${cause}`);
|
|
123
|
+
return "completed";
|
|
144
124
|
}
|
|
145
125
|
async initiateCall(input) {
|
|
146
126
|
const body = {
|
|
@@ -154,22 +134,22 @@ var TelnyxProvider = class {
|
|
|
154
134
|
...buildTelnyxStreamingFields(input.streamUrl, input.streamAuthToken)
|
|
155
135
|
};
|
|
156
136
|
return {
|
|
157
|
-
providerCallId: (await this.
|
|
137
|
+
providerCallId: (await this.api.request("/calls", body)).data.call_control_id,
|
|
158
138
|
status: "initiated"
|
|
159
139
|
};
|
|
160
140
|
}
|
|
161
141
|
async hangupCall(input) {
|
|
162
|
-
await this.
|
|
142
|
+
await this.api.request(`/calls/${input.providerCallId}/actions/hangup`, { command_id: crypto.randomUUID() }, { allowNotFound: true });
|
|
163
143
|
}
|
|
164
144
|
async answerCall(input) {
|
|
165
145
|
const body = {
|
|
166
146
|
command_id: `openclaw-answer-${input.callId}`,
|
|
167
147
|
...buildTelnyxStreamingFields(input.streamUrl, input.streamAuthToken)
|
|
168
148
|
};
|
|
169
|
-
await this.
|
|
149
|
+
await this.api.request(`/calls/${input.providerCallId}/actions/answer`, body);
|
|
170
150
|
}
|
|
171
151
|
async playTts(input) {
|
|
172
|
-
await this.
|
|
152
|
+
await this.api.request(`/calls/${input.providerCallId}/actions/speak`, {
|
|
173
153
|
command_id: crypto.randomUUID(),
|
|
174
154
|
payload: input.text,
|
|
175
155
|
voice: input.voice || "female",
|
|
@@ -177,27 +157,16 @@ var TelnyxProvider = class {
|
|
|
177
157
|
});
|
|
178
158
|
}
|
|
179
159
|
async startListening(input) {
|
|
180
|
-
await this.
|
|
160
|
+
await this.api.request(`/calls/${input.providerCallId}/actions/transcription_start`, {
|
|
181
161
|
command_id: crypto.randomUUID(),
|
|
182
162
|
language: input.language || "en"
|
|
183
163
|
});
|
|
184
164
|
}
|
|
185
165
|
async stopListening(input) {
|
|
186
|
-
await this.
|
|
166
|
+
await this.api.request(`/calls/${input.providerCallId}/actions/transcription_stop`, { command_id: crypto.randomUUID() }, { allowNotFound: true });
|
|
187
167
|
}
|
|
188
168
|
async getCallStatus(input) {
|
|
189
|
-
return
|
|
190
|
-
url: `${this.baseUrl}/calls/${input.providerCallId}`,
|
|
191
|
-
method: "GET",
|
|
192
|
-
headers: {
|
|
193
|
-
Authorization: `Bearer ${this.apiKey}`,
|
|
194
|
-
"Content-Type": "application/json"
|
|
195
|
-
},
|
|
196
|
-
allowNotFound: true,
|
|
197
|
-
allowedHostnames: [this.apiHost],
|
|
198
|
-
auditContext: "telnyx-get-call-status",
|
|
199
|
-
errorPrefix: "Telnyx get call status error"
|
|
200
|
-
}), (data) => {
|
|
169
|
+
return this.api.getCallStatus(`/calls/${input.providerCallId}`, (data) => {
|
|
201
170
|
const status = data.data?.state ?? "unknown";
|
|
202
171
|
const isAlive = data.data?.is_alive;
|
|
203
172
|
return isAlive === void 0 ? {
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
+
import { a as mapProviderStatusToEndReason, c as getHeader, h as generateDtmfRedirectTwiml, i as isProviderStatusTerminal, o as normalizeProviderStatus } from "./runtime-entry-STjFmi_B.mjs";
|
|
1
2
|
import { n as requireSupportedTwilioApiHostname, r as resolveTwilioApiBaseUrl } from "./twilio-region-wBOx-8sz.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { n as readProviderCallStatus, o as verifyTwilioWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-x55yqHvW.mjs";
|
|
3
|
+
import { n as mapVoiceToPolly, t as escapeXml } from "./voice-mapping-1lzFmzOF.mjs";
|
|
4
|
+
import { a as verifyTwilioWebhook, t as createCarrierApi } from "./carrier-api-BYKw_fsQ.mjs";
|
|
5
5
|
import { asNullableObjectRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
6
6
|
import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
|
|
7
7
|
import crypto from "node:crypto";
|
|
8
8
|
import { safeParseJson } from "openclaw/plugin-sdk/text-utility-runtime";
|
|
9
9
|
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
|
|
10
10
|
import { retryAsync } from "openclaw/plugin-sdk/retry-runtime";
|
|
11
|
+
import { raceWithTimeout } from "openclaw/plugin-sdk/time-runtime";
|
|
11
12
|
//#region extensions/voice-call/src/providers/twilio/api.ts
|
|
12
13
|
var TwilioApiError = class extends Error {
|
|
13
14
|
constructor(httpStatus, responseText) {
|
|
@@ -20,28 +21,27 @@ var TwilioApiError = class extends Error {
|
|
|
20
21
|
this.twilioCode = typeof parsed?.code === "number" ? parsed.code : void 0;
|
|
21
22
|
}
|
|
22
23
|
};
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
else if (typeof value === "string") acc.append(key, value);
|
|
28
|
-
return acc;
|
|
29
|
-
}, new URLSearchParams());
|
|
30
|
-
return guardedJsonApiRequest({
|
|
31
|
-
url: `${params.baseUrl}${params.endpoint}`,
|
|
32
|
-
method: "POST",
|
|
33
|
-
headers: {
|
|
34
|
-
Authorization: `Basic ${Buffer.from(`${params.accountSid}:${params.authToken}`).toString("base64")}`,
|
|
35
|
-
"Content-Type": "application/x-www-form-urlencoded"
|
|
36
|
-
},
|
|
37
|
-
body: bodyParams,
|
|
38
|
-
allowNotFound: params.allowNotFound,
|
|
39
|
-
allowedHostnames: [requireSupportedTwilioApiHostname(params.baseUrl)],
|
|
40
|
-
auditContext: "voice-call.twilio.api",
|
|
41
|
-
errorPrefix: "Twilio API error",
|
|
24
|
+
function createTwilioApi(params) {
|
|
25
|
+
requireSupportedTwilioApiHostname(params.baseUrl);
|
|
26
|
+
const api = createCarrierApi("Twilio", params.baseUrl, `Basic ${Buffer.from(`${params.accountSid}:${params.authToken}`).toString("base64")}`, {
|
|
27
|
+
contentType: "application/x-www-form-urlencoded",
|
|
42
28
|
malformedJsonMessage: "Twilio API returned malformed JSON.",
|
|
43
29
|
createError: (status, text) => new TwilioApiError(status, text)
|
|
44
30
|
});
|
|
31
|
+
return {
|
|
32
|
+
request: (endpoint, body, options) => {
|
|
33
|
+
const form = body instanceof URLSearchParams ? body : new URLSearchParams();
|
|
34
|
+
if (!(body instanceof URLSearchParams)) for (const [key, value] of Object.entries(body)) for (const entry of Array.isArray(value) ? value : [value]) form.append(key, entry);
|
|
35
|
+
return api.request(endpoint, form, options);
|
|
36
|
+
},
|
|
37
|
+
getCallStatus: ({ providerCallId }) => api.getCallStatus(`/Calls/${providerCallId}.json`, (data) => {
|
|
38
|
+
const status = normalizeProviderStatus(data.status);
|
|
39
|
+
return {
|
|
40
|
+
status,
|
|
41
|
+
isTerminal: isProviderStatusTerminal(status)
|
|
42
|
+
};
|
|
43
|
+
})
|
|
44
|
+
};
|
|
45
45
|
}
|
|
46
46
|
//#endregion
|
|
47
47
|
//#region extensions/voice-call/src/providers/twilio.ts
|
|
@@ -87,12 +87,17 @@ var TwilioProvider = class TwilioProvider {
|
|
|
87
87
|
this.twimlStorage = /* @__PURE__ */ new Map();
|
|
88
88
|
if (!config.accountSid) throw new Error("Twilio Account SID is required");
|
|
89
89
|
if (!config.authToken) throw new Error("Twilio Auth Token is required");
|
|
90
|
-
this.accountSid = config.accountSid;
|
|
91
90
|
this.authToken = config.authToken;
|
|
92
|
-
|
|
93
|
-
accountSid:
|
|
94
|
-
|
|
91
|
+
const api = createTwilioApi({
|
|
92
|
+
accountSid: config.accountSid,
|
|
93
|
+
authToken: config.authToken,
|
|
94
|
+
baseUrl: resolveTwilioApiBaseUrl({
|
|
95
|
+
accountSid: config.accountSid,
|
|
96
|
+
region: config.region
|
|
97
|
+
})
|
|
95
98
|
});
|
|
99
|
+
this.apiRequest = api.request;
|
|
100
|
+
this.getCallStatus = api.getCallStatus;
|
|
96
101
|
this.options = options;
|
|
97
102
|
if (options.publicUrl) this.currentPublicUrl = options.publicUrl;
|
|
98
103
|
}
|
|
@@ -131,16 +136,6 @@ var TwilioProvider = class TwilioProvider {
|
|
|
131
136
|
if (!streamSid || !this.mediaStreamHandler) return;
|
|
132
137
|
this.mediaStreamHandler.clearTtsQueue(streamSid, reason);
|
|
133
138
|
}
|
|
134
|
-
async apiRequest(endpoint, params, options) {
|
|
135
|
-
return await twilioApiRequest({
|
|
136
|
-
baseUrl: this.baseUrl,
|
|
137
|
-
accountSid: this.accountSid,
|
|
138
|
-
authToken: this.authToken,
|
|
139
|
-
endpoint,
|
|
140
|
-
body: params,
|
|
141
|
-
allowNotFound: options?.allowNotFound
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
139
|
async updateLiveCallTwiml(providerCallId, twiml, operation) {
|
|
145
140
|
await retryAsync(() => this.apiRequest(`/Calls/${providerCallId}.json`, { Twiml: twiml }), {
|
|
146
141
|
attempts: TWILIO_CALL_UPDATE_RETRY_DELAYS_MS.length + 1,
|
|
@@ -181,7 +176,8 @@ var TwilioProvider = class TwilioProvider {
|
|
|
181
176
|
const event = this.normalizeEvent(params, {
|
|
182
177
|
callIdOverride: callIdFromQuery,
|
|
183
178
|
dedupeKey,
|
|
184
|
-
turnToken: turnTokenFromQuery
|
|
179
|
+
turnToken: turnTokenFromQuery,
|
|
180
|
+
amdCallback: normalizeOptionalString(ctx.query?.type) === "amd"
|
|
185
181
|
});
|
|
186
182
|
if (event?.direction === "inbound" && event.type !== "call.ended" && event.providerCallId && this.currentPublicUrl && !this.callWebhookUrls.has(event.providerCallId)) this.callWebhookUrls.set(event.providerCallId, this.currentPublicUrl);
|
|
187
183
|
return {
|
|
@@ -209,17 +205,25 @@ var TwilioProvider = class TwilioProvider {
|
|
|
209
205
|
normalizeEvent(params, options) {
|
|
210
206
|
const callSid = params.get("CallSid") || "";
|
|
211
207
|
const callIdOverride = options?.callIdOverride;
|
|
208
|
+
const answeredBy = params.get("AnsweredBy")?.trim().toLowerCase();
|
|
212
209
|
const baseEvent = {
|
|
213
210
|
id: crypto.randomUUID(),
|
|
214
211
|
dedupeKey: options?.dedupeKey,
|
|
215
212
|
callId: callIdOverride || callSid,
|
|
216
213
|
providerCallId: callSid,
|
|
217
214
|
timestamp: Date.now(),
|
|
215
|
+
...answeredBy ? { answeredBy } : {},
|
|
218
216
|
turnToken: options?.turnToken,
|
|
219
217
|
direction: TwilioProvider.parseDirection(params.get("Direction")),
|
|
220
218
|
from: params.get("From") || void 0,
|
|
221
219
|
to: params.get("To") || void 0
|
|
222
220
|
};
|
|
221
|
+
if (answeredBy) console.log(`[voice-call] AMD classification callId=${baseEvent.callId} answeredBy=${answeredBy} at=${baseEvent.timestamp}`);
|
|
222
|
+
if (options?.amdCallback && answeredBy) return {
|
|
223
|
+
...baseEvent,
|
|
224
|
+
type: "call.amd",
|
|
225
|
+
answeredBy
|
|
226
|
+
};
|
|
223
227
|
const speechResult = params.get("SpeechResult");
|
|
224
228
|
if (speechResult?.trim()) return {
|
|
225
229
|
...baseEvent,
|
|
@@ -234,7 +238,8 @@ var TwilioProvider = class TwilioProvider {
|
|
|
234
238
|
type: "call.dtmf",
|
|
235
239
|
digits
|
|
236
240
|
};
|
|
237
|
-
const
|
|
241
|
+
const rawCallStatus = params.get("CallStatus");
|
|
242
|
+
const callStatus = normalizeProviderStatus(rawCallStatus);
|
|
238
243
|
if (callStatus === "initiated") return {
|
|
239
244
|
...baseEvent,
|
|
240
245
|
type: "call.initiated"
|
|
@@ -257,6 +262,10 @@ var TwilioProvider = class TwilioProvider {
|
|
|
257
262
|
this.releaseCallState(callSid, callIdOverride);
|
|
258
263
|
return event;
|
|
259
264
|
}
|
|
265
|
+
if (callSid && baseEvent.direction === "inbound" && rawCallStatus === null && !params.has("SpeechResult") && !params.has("Digits")) return {
|
|
266
|
+
...baseEvent,
|
|
267
|
+
type: "call.initiated"
|
|
268
|
+
};
|
|
260
269
|
return null;
|
|
261
270
|
}
|
|
262
271
|
static {
|
|
@@ -277,7 +286,8 @@ var TwilioProvider = class TwilioProvider {
|
|
|
277
286
|
}
|
|
278
287
|
generateTwimlResponse(ctx) {
|
|
279
288
|
const params = new URLSearchParams(ctx.rawBody);
|
|
280
|
-
const
|
|
289
|
+
const callbackType = normalizeOptionalString(ctx.query?.type);
|
|
290
|
+
const isStatusCallback = callbackType === "status" || callbackType === "amd";
|
|
281
291
|
const callId = normalizeOptionalString(ctx.query?.callId);
|
|
282
292
|
const callStatus = params.get("CallStatus");
|
|
283
293
|
const direction = params.get("Direction");
|
|
@@ -299,7 +309,8 @@ var TwilioProvider = class TwilioProvider {
|
|
|
299
309
|
}
|
|
300
310
|
consumeInitialTwiML(ctx) {
|
|
301
311
|
const params = new URLSearchParams(ctx.rawBody);
|
|
302
|
-
const
|
|
312
|
+
const callbackType = normalizeOptionalString(ctx.query?.type);
|
|
313
|
+
const isStatusCallback = callbackType === "status" || callbackType === "amd";
|
|
303
314
|
const callId = normalizeOptionalString(ctx.query?.callId);
|
|
304
315
|
const callSid = params.get("CallSid") || void 0;
|
|
305
316
|
if (!callId || isStatusCallback) return null;
|
|
@@ -361,6 +372,18 @@ var TwilioProvider = class TwilioProvider {
|
|
|
361
372
|
],
|
|
362
373
|
Timeout: "30"
|
|
363
374
|
};
|
|
375
|
+
if (input.voicemail?.detection === "twilio") {
|
|
376
|
+
const amdUrl = new URL(url.toString());
|
|
377
|
+
amdUrl.searchParams.set("type", "amd");
|
|
378
|
+
params.MachineDetection = input.voicemail.onMachine === "leave-message" ? "DetectMessageEnd" : "Enable";
|
|
379
|
+
params.AsyncAmd = "true";
|
|
380
|
+
params.AsyncAmdStatusCallback = amdUrl.toString();
|
|
381
|
+
params.AsyncAmdStatusCallbackMethod = "POST";
|
|
382
|
+
params.MachineDetectionSpeechThreshold = String(input.voicemail.machineDetectionSpeechThresholdMs);
|
|
383
|
+
params.MachineDetectionSpeechEndThreshold = String(input.voicemail.machineDetectionSpeechEndThresholdMs);
|
|
384
|
+
params.MachineDetectionSilenceTimeout = String(input.voicemail.machineDetectionSilenceTimeoutMs);
|
|
385
|
+
params.MachineDetectionTimeout = String(input.voicemail.machineDetectionTimeoutMs / 1e3);
|
|
386
|
+
}
|
|
364
387
|
if (input.inlineTwiml) {
|
|
365
388
|
params.Twiml = input.inlineTwiml;
|
|
366
389
|
console.log(`[voice-call] Sending direct Twilio initial TwiML for call ${input.callId} (kind=notify)`);
|
|
@@ -372,6 +395,9 @@ var TwilioProvider = class TwilioProvider {
|
|
|
372
395
|
status: result.status === "queued" ? "queued" : "initiated"
|
|
373
396
|
};
|
|
374
397
|
}
|
|
398
|
+
async playMessageAndHangup(input) {
|
|
399
|
+
await this.apiRequest(`/Calls/${input.providerCallId}.json`, { Twiml: `<?xml version="1.0" encoding="UTF-8"?><Response><Say voice="${escapeXml(input.voice || "alice")}">${escapeXml(input.text)}</Say><Hangup/></Response>` });
|
|
400
|
+
}
|
|
375
401
|
async hangupCall(input) {
|
|
376
402
|
await this.apiRequest(`/Calls/${input.providerCallId}.json`, { Status: "completed" }, { allowNotFound: true });
|
|
377
403
|
this.releaseCallState(input.providerCallId, input.callId);
|
|
@@ -410,11 +436,6 @@ var TwilioProvider = class TwilioProvider {
|
|
|
410
436
|
async sendDtmf(input) {
|
|
411
437
|
await this.updateLiveCallTwiml(input.providerCallId, generateDtmfRedirectTwiml(input.digits, this.requireCallWebhookUrl(input.providerCallId)), "sendDtmf");
|
|
412
438
|
}
|
|
413
|
-
/**
|
|
414
|
-
* Play TTS via core TTS and Twilio Media Streams.
|
|
415
|
-
* Generates audio with core TTS, converts to mu-law, and streams via WebSocket.
|
|
416
|
-
* Uses a queue to serialize playback and prevent overlapping audio.
|
|
417
|
-
*/
|
|
418
439
|
async playTtsViaStream(text, streamSid) {
|
|
419
440
|
if (!this.ttsProvider || !this.mediaStreamHandler) throw new Error("TTS provider and media stream handler required");
|
|
420
441
|
const CHUNK_SIZE = 160;
|
|
@@ -423,41 +444,23 @@ var TwilioProvider = class TwilioProvider {
|
|
|
423
444
|
const handler = this.mediaStreamHandler;
|
|
424
445
|
const ttsProvider = this.ttsProvider;
|
|
425
446
|
await handler.queueTts(streamSid, async (signal) => {
|
|
426
|
-
|
|
427
|
-
handler.sendAudio(streamSid, SILENCE_CHUNK);
|
|
428
|
-
};
|
|
429
|
-
sendKeepAlive();
|
|
447
|
+
handler.sendAudio(streamSid, SILENCE_CHUNK);
|
|
430
448
|
const keepAlive = setInterval(() => {
|
|
431
|
-
if (!signal.aborted)
|
|
449
|
+
if (!signal.aborted) handler.sendAudio(streamSid, SILENCE_CHUNK);
|
|
432
450
|
}, CHUNK_DELAY_MS);
|
|
433
451
|
let muLawAudio;
|
|
434
|
-
let synthTimeout = null;
|
|
435
|
-
let removeAbortListener = () => {};
|
|
436
452
|
const synthTimeoutMs = ttsProvider.synthesisTimeoutMs;
|
|
437
453
|
try {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
const onAbort = () => {
|
|
446
|
-
reject(signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("Telephony TTS synthesis aborted"));
|
|
447
|
-
};
|
|
448
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
449
|
-
removeAbortListener = () => signal.removeEventListener("abort", onAbort);
|
|
450
|
-
if (signal.aborted) onAbort();
|
|
454
|
+
muLawAudio = await raceWithTimeout(ttsProvider.synthesizeForTelephony(text), synthTimeoutMs, () => {
|
|
455
|
+
throw new Error(`Telephony TTS synthesis timed out after ${synthTimeoutMs}ms`);
|
|
456
|
+
}, {
|
|
457
|
+
signal,
|
|
458
|
+
onAbort: () => {
|
|
459
|
+
throw signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("Telephony TTS synthesis aborted");
|
|
460
|
+
}
|
|
451
461
|
});
|
|
452
|
-
muLawAudio = await Promise.race([
|
|
453
|
-
synthPromise,
|
|
454
|
-
timeoutPromise,
|
|
455
|
-
abortPromise
|
|
456
|
-
]);
|
|
457
462
|
} finally {
|
|
458
|
-
if (synthTimeout) clearTimeout(synthTimeout);
|
|
459
463
|
clearInterval(keepAlive);
|
|
460
|
-
removeAbortListener();
|
|
461
464
|
}
|
|
462
465
|
if (muLawAudio.length === 0) throw new Error("Telephony TTS produced no audio");
|
|
463
466
|
let chunkAttempts = 0;
|
|
@@ -477,7 +480,6 @@ var TwilioProvider = class TwilioProvider {
|
|
|
477
480
|
break;
|
|
478
481
|
}
|
|
479
482
|
nextChunkDueAt += CHUNK_DELAY_MS;
|
|
480
|
-
if (signal.aborted) break;
|
|
481
483
|
}
|
|
482
484
|
if (signal.aborted) return;
|
|
483
485
|
const markName = `tts-${Date.now()}-${++this.playbackMarkSequence}`;
|
|
@@ -500,23 +502,6 @@ var TwilioProvider = class TwilioProvider {
|
|
|
500
502
|
return webhookUrl;
|
|
501
503
|
}
|
|
502
504
|
async stopListening(_input) {}
|
|
503
|
-
async getCallStatus(input) {
|
|
504
|
-
return readProviderCallStatus(() => guardedJsonApiRequest({
|
|
505
|
-
url: `${this.baseUrl}/Calls/${input.providerCallId}.json`,
|
|
506
|
-
method: "GET",
|
|
507
|
-
headers: { Authorization: `Basic ${Buffer.from(`${this.accountSid}:${this.authToken}`).toString("base64")}` },
|
|
508
|
-
allowNotFound: true,
|
|
509
|
-
allowedHostnames: [new URL(this.baseUrl).hostname],
|
|
510
|
-
auditContext: "twilio-get-call-status",
|
|
511
|
-
errorPrefix: "Twilio get call status error"
|
|
512
|
-
}), (data) => {
|
|
513
|
-
const status = normalizeProviderStatus(data.status);
|
|
514
|
-
return {
|
|
515
|
-
status,
|
|
516
|
-
isTerminal: isProviderStatusTerminal(status)
|
|
517
|
-
};
|
|
518
|
-
});
|
|
519
|
-
}
|
|
520
505
|
};
|
|
521
506
|
//#endregion
|
|
522
507
|
export { TwilioProvider };
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
2
2
|
//#region extensions/voice-call/src/voice-mapping.ts
|
|
3
|
-
/**
|
|
4
|
-
* Escape XML special characters for TwiML and other XML responses.
|
|
5
|
-
*/
|
|
6
3
|
function escapeXml(text) {
|
|
7
4
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
8
5
|
}
|
|
@@ -15,13 +12,6 @@ const OPENAI_TO_POLLY_MAP = /* @__PURE__ */ new Map([
|
|
|
15
12
|
["shimmer", "Polly.Kimberly"]
|
|
16
13
|
]);
|
|
17
14
|
const DEFAULT_POLLY_VOICE = "Polly.Joanna";
|
|
18
|
-
/**
|
|
19
|
-
* Map OpenAI voice names to Twilio Polly equivalents.
|
|
20
|
-
* Falls through if already a valid Polly/Google voice.
|
|
21
|
-
*
|
|
22
|
-
* @param voice - OpenAI voice name (alloy, echo, etc.) or Polly voice name
|
|
23
|
-
* @returns Polly voice name suitable for Twilio TwiML
|
|
24
|
-
*/
|
|
25
15
|
function mapVoiceToPolly(voice) {
|
|
26
16
|
if (!voice) return DEFAULT_POLLY_VOICE;
|
|
27
17
|
if (voice.startsWith("Polly.") || voice.startsWith("Google.")) return voice;
|
package/dist/cli-output-mode.js
CHANGED
|
@@ -27,28 +27,24 @@ function resolveVoiceCallStateDatabaseEnv(params) {
|
|
|
27
27
|
OPENCLAW_STATE_DIR: resolveVoiceCallStorePath(params)
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
case "strict-tables-v3": return "tables -> SQLite STRICT typing";
|
|
49
|
-
}
|
|
50
|
-
return migration.kind;
|
|
51
|
-
}
|
|
30
|
+
const schemaMigrationDescriptions = {
|
|
31
|
+
"agent-databases-composite-primary-key": "agent database registry primary key -> agent_id,path",
|
|
32
|
+
"agent-databases-relative-paths-v9": "agent database registry paths -> state-relative paths",
|
|
33
|
+
"audit-events-v2": "audit event ledger -> versioned message lifecycle schema",
|
|
34
|
+
"commitments-retirement-v7": "retired commitments storage -> discarded rows, table, and indexes",
|
|
35
|
+
"state-table-retirement-v10": "retired shared-state tables -> removed tables and indexes",
|
|
36
|
+
"state-table-retirement-v11": "retired skill curator tables -> removed tables and indexes",
|
|
37
|
+
"singleton-state-foldin-v12": "singleton state tables -> shared configuration state",
|
|
38
|
+
"state-consolidation-v13": "cron jobs and subagent runs -> canonical JSON storage",
|
|
39
|
+
"creator-namespace-v14": "cron creators -> explicit principal namespaces",
|
|
40
|
+
"conversation-binding-targets-v15": "conversation bindings -> exact target keys without agent/session projections",
|
|
41
|
+
"prepared-worker-ownership-v17": "prepared workers -> one-use capacity and fixed workspace ownership",
|
|
42
|
+
"github-publication-requester-authority-v18": "GitHub publication receipts -> original requesting authority",
|
|
43
|
+
"worker-placement-execution-mode-v8": "cloud worker placements -> execution-mode claims",
|
|
44
|
+
"operator-approvals-system-agent": "operator approvals -> OpenClaw system changes",
|
|
45
|
+
"session-watch-cursor-provenance-v4": "session watch cursors -> provenance column",
|
|
46
|
+
"strict-tables-v3": "tables -> SQLite STRICT typing"
|
|
47
|
+
};
|
|
52
48
|
/** Doctor migrations owned by the voice-call plugin. */
|
|
53
49
|
const stateMigrations = [...stateMigrations$1, {
|
|
54
50
|
id: "voice-call-sqlite-schema",
|
|
@@ -59,7 +55,7 @@ const stateMigrations = [...stateMigrations$1, {
|
|
|
59
55
|
const { detectOpenClawStateDatabaseSchemaMigrations } = await import("openclaw/plugin-sdk/doctor-repair-runtime");
|
|
60
56
|
const schemaMigrations = detectOpenClawStateDatabaseSchemaMigrations({ env: resolveVoiceCallStateDatabaseEnv(params) });
|
|
61
57
|
if (schemaMigrations.length === 0) return null;
|
|
62
|
-
return { preview: schemaMigrations.map((migration) => `- Voice Call SQLite schema: ${
|
|
58
|
+
return { preview: schemaMigrations.map((migration) => `- Voice Call SQLite schema: ${schemaMigrationDescriptions[migration.kind]}`) };
|
|
63
59
|
},
|
|
64
60
|
async migrateLegacyState(params) {
|
|
65
61
|
const changes = [];
|