@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
package/README.md
CHANGED
|
@@ -102,12 +102,125 @@ Notes:
|
|
|
102
102
|
- Twilio defaults to US1. For a non-US Region, set `twilio.region` to `ie1` or `au1` and use credentials created in that Region; see [Twilio's regional REST API guide](https://www.twilio.com/docs/global-infrastructure/using-the-twilio-rest-api-in-a-non-us-region).
|
|
103
103
|
- `mock` is a local dev provider (no network calls).
|
|
104
104
|
- Telnyx requires `telnyx.publicKey` (or `TELNYX_PUBLIC_KEY`) unless `skipSignatureVerification` is true.
|
|
105
|
-
- Runtime accepts canonical config only.
|
|
105
|
+
- Runtime accepts canonical config only. Doctor retains migrations for `provider: "log"`, `twilio.from`, legacy `streaming.*` OpenAI keys, and `realtime.agentContext.includeSystemPrompt` because supported releases can still preserve these settings. Run `openclaw doctor --fix` to normalize them; existing canonical values are preserved.
|
|
106
106
|
- advanced webhook, streaming, and tunnel notes: `https://docs.openclaw.ai/plugins/voice-call`
|
|
107
107
|
- `responseModel` is optional. When unset, voice responses use the runtime default model.
|
|
108
108
|
- `sessionScope` defaults to `per-phone`, preserving caller memory across calls. Use `per-call` for reception, booking, IVR, and bridge flows where each carrier call should start fresh. Use `main` to share the configured agent's main session (`agent:<agentId>:main`, or `global` when core `session.scope` is `"global"`). Custom core `session.mainKey` values are ignored.
|
|
109
109
|
- `realtime.consultThinkingLevel` is optional. When set, it overrides the thinking level used by the model behind realtime `openclaw_agent_consult` calls.
|
|
110
110
|
- `realtime.consultFastMode` is optional. When set, it toggles fast mode for realtime `openclaw_agent_consult` calls.
|
|
111
|
+
- `realtime.idleHangupMs` is optional. When set to a positive integer, an active realtime call ends after neither side has spoken for that many milliseconds; in-flight agent consults pause the timer. Unset keeps the current no-speech-idle-limit behavior.
|
|
112
|
+
|
|
113
|
+
## Per-call briefs and errands
|
|
114
|
+
|
|
115
|
+
`initiate_call` and `voicecall.initiate` accept an optional `brief`. The opening
|
|
116
|
+
`message` remains the first verbatim line; the brief guides the conversation
|
|
117
|
+
after it. The realtime voice and its consult agent receive the same brief.
|
|
118
|
+
|
|
119
|
+
All brief fields are optional:
|
|
120
|
+
|
|
121
|
+
| Field | Value |
|
|
122
|
+
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
123
|
+
| `task` | Plain text describing what to achieve, up to 2000 characters. |
|
|
124
|
+
| `context` | Facts the voice may use, up to 4000 characters. |
|
|
125
|
+
| `language` | Language name or BCP-47 tag, up to 100 characters. |
|
|
126
|
+
| `identity` | Introduction text, or `{ introduction, disclose: "volunteer" \| "when-asked" }`; up to 500 characters. Defaults to introducing only when asked. |
|
|
127
|
+
| `disclosures` | Up to 20 strings listing permitted personal details, each up to 500 characters. Defaults to none beyond the permitted identity. |
|
|
128
|
+
| `approvals` | What the voice may agree to, up to 2000 characters. Defaults to no spending or commitments beyond the task. |
|
|
129
|
+
| `voicemailMessage` | Message to leave on a machine, up to 1000 characters. Defaults to the supplied introduction or a neutral message, followed by a promise to try again later; excludes task details. |
|
|
130
|
+
| `successCriteria` | What counts as done, up to 1000 characters. |
|
|
131
|
+
| `maxDurationSeconds` | Positive integer that shortens this call's duration limit; values above the configured cap are capped. |
|
|
132
|
+
|
|
133
|
+
Briefs have an overall limit of 8000 JSON characters. Unknown fields are rejected.
|
|
134
|
+
For a plumber visit:
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
{
|
|
138
|
+
"action": "initiate_call",
|
|
139
|
+
"to": "+15550005678",
|
|
140
|
+
"mode": "conversation",
|
|
141
|
+
"message": "Hello, can I arrange a plumber visit?",
|
|
142
|
+
"brief": {
|
|
143
|
+
"task": "Book a plumber to repair a leaking kitchen tap",
|
|
144
|
+
"context": "The owner is available Tuesday or Thursday, 9 AM to noon. Ask for the callout fee before agreeing.",
|
|
145
|
+
"language": "en",
|
|
146
|
+
"identity": {
|
|
147
|
+
"introduction": "I am calling on behalf of Alex",
|
|
148
|
+
"disclose": "volunteer"
|
|
149
|
+
},
|
|
150
|
+
"disclosures": ["The owner's first name is Alex"],
|
|
151
|
+
"approvals": "A visit on Tuesday or Thursday morning with a callout fee up to 20 EUR. No repair charges are approved.",
|
|
152
|
+
"successCriteria": "Confirm the day, arrival window, callout fee and booking reference",
|
|
153
|
+
"voicemailMessage": "Hello, I am calling to ask about a plumber visit for a leaking tap. The owner will follow up.",
|
|
154
|
+
"maxDurationSeconds": 180
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
CLI callers can pass `--brief 'Ask whether a plumber is available Tuesday'`,
|
|
160
|
+
`--brief '<JSON>'`, or `--brief-file ./plumber-brief.json`. The two brief flags
|
|
161
|
+
are mutually exclusive. Use `--mode conversation` for a conversation.
|
|
162
|
+
|
|
163
|
+
## Reports, live transcript, callbacks and voicemail
|
|
164
|
+
|
|
165
|
+
These features are disabled by default. Configure them under
|
|
166
|
+
`plugins.entries.voice-call.config`:
|
|
167
|
+
|
|
168
|
+
```json5
|
|
169
|
+
{
|
|
170
|
+
reports: {
|
|
171
|
+
enabled: true,
|
|
172
|
+
includeTranscript: true, // default true
|
|
173
|
+
// summaryModel: "<provider>/<model>",
|
|
174
|
+
// inboundSessionKey: "agent:reception:main",
|
|
175
|
+
},
|
|
176
|
+
live: { transcript: true, minIntervalMs: 5000 },
|
|
177
|
+
inboundPolicy: "allowlist",
|
|
178
|
+
callbacks: {
|
|
179
|
+
enabled: true,
|
|
180
|
+
windowMinutes: 60,
|
|
181
|
+
// greeting: "Hello, I can take a message for the owner.",
|
|
182
|
+
// brief: { task: "Take a message for the owner" },
|
|
183
|
+
},
|
|
184
|
+
voicemail: { detection: "twilio", onMachine: "leave-message" },
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
- Reports go to the session that requested an outbound call. They include its
|
|
189
|
+
outcome, important facts, duration, end reason and, by default, transcript.
|
|
190
|
+
`reports.inboundSessionKey` selects the destination for ordinary inbound calls.
|
|
191
|
+
A Gateway restart marks an interrupted pending report or live-transcript delivery
|
|
192
|
+
as failed. OpenClaw does not resend it because the earlier process may already
|
|
193
|
+
have reached the channel.
|
|
194
|
+
- Live transcript sends both sides' final lines to the requester in batches,
|
|
195
|
+
at most once per `live.minIntervalMs` (default 5000).
|
|
196
|
+
Each batch's position is saved before it is sent, so a call that survives a
|
|
197
|
+
restart continues with new lines only and never repeats an earlier batch.
|
|
198
|
+
- With realtime voice enabled and `inboundPolicy: "allowlist"`, callbacks from a
|
|
199
|
+
number called within `callbacks.windowMinutes` (default 60) are also accepted.
|
|
200
|
+
They are linked to the original outbound call and take a message for the owner
|
|
201
|
+
without sharing details. The classic STT/TTS path never admits callbacks. Other
|
|
202
|
+
numbers follow the existing inbound policy. `callbacks.greeting` and
|
|
203
|
+
`callbacks.brief` can customize that conversation.
|
|
204
|
+
- With Twilio machine detection, outbound conversation calls hold realtime input
|
|
205
|
+
and the opening until a human or unknown result, or 30 seconds after the bridge
|
|
206
|
+
is ready. A machine result blocks realtime speech even after that cap.
|
|
207
|
+
- Twilio machine detection leaves the brief's voicemail message once, waits for
|
|
208
|
+
realtime voice playback to finish, and then hangs up. When realtime playback
|
|
209
|
+
is unavailable, it retires the realtime stream and uses carrier text-to-speech
|
|
210
|
+
followed by hang-up. With `onMachine: "hang-up"`, it ends the call immediately.
|
|
211
|
+
Notify calls wait for detection before playing the appropriate message.
|
|
212
|
+
The detected answer type is recorded. The mock provider can simulate this; Telnyx and Plivo behavior
|
|
213
|
+
is unchanged. `voicemail.detection` defaults to `"off"`.
|
|
214
|
+
|
|
215
|
+
During a call, `steer_call` or `voicecall.steer` accepts `{ callId, message, mode }`.
|
|
216
|
+
`mode: "guidance"` (the default) changes the voice's guidance and subsequent consults;
|
|
217
|
+
`mode: "say"` makes the voice say the message verbatim now. Messages are limited
|
|
218
|
+
to 500 characters. Only the requesting session or an operator may steer that call.
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
openclaw voicecall steer --call-id <id> --message "Ask for the callout fee" --mode guidance
|
|
222
|
+
openclaw voicecall steer --call-id <id> --message "The owner can also do Thursday morning." --mode say
|
|
223
|
+
```
|
|
111
224
|
|
|
112
225
|
## Stale call reaper
|
|
113
226
|
|
|
@@ -139,7 +252,8 @@ Tool name: `voice_call`
|
|
|
139
252
|
|
|
140
253
|
Actions:
|
|
141
254
|
|
|
142
|
-
- `initiate_call` (message, to?, mode?)
|
|
255
|
+
- `initiate_call` (message, to?, mode?, brief?)
|
|
256
|
+
- `steer_call` (callId, message, mode?: guidance | say)
|
|
143
257
|
- `continue_call` (callId, message)
|
|
144
258
|
- `speak_to_user` (callId, message)
|
|
145
259
|
- `end_call` (callId)
|
|
@@ -147,7 +261,8 @@ Actions:
|
|
|
147
261
|
|
|
148
262
|
## Gateway RPC
|
|
149
263
|
|
|
150
|
-
- `voicecall.initiate` (to?, message, mode?)
|
|
264
|
+
- `voicecall.initiate` (to?, message, mode?, brief?)
|
|
265
|
+
- `voicecall.steer` (callId, message, mode?: guidance | say)
|
|
151
266
|
- `voicecall.continue` (callId, message)
|
|
152
267
|
- `voicecall.speak` (callId, message)
|
|
153
268
|
- `voicecall.end` (callId)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { fetchWithSsrFGuard } from "../api.js";
|
|
2
|
-
import {
|
|
2
|
+
import { c as getHeader, r as normalizeProxyIp } from "./runtime-entry-STjFmi_B.mjs";
|
|
3
3
|
import { n as reserveWebhookReplay, t as createWebhookReplayCache } from "./webhook-replay-B7RJNWNG.mjs";
|
|
4
4
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
5
5
|
import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
|
|
@@ -181,19 +181,10 @@ function redactTwilioVerificationUrlForDiagnostics(url) {
|
|
|
181
181
|
return "<invalid verification URL>";
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
|
-
function stripPortFromUrl(url) {
|
|
185
|
-
try {
|
|
186
|
-
const parsed = new URL(url);
|
|
187
|
-
if (!parsed.port) return url;
|
|
188
|
-
parsed.port = "";
|
|
189
|
-
return parsed.toString();
|
|
190
|
-
} catch {
|
|
191
|
-
return url;
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
184
|
function setPortOnUrl(url, port) {
|
|
195
185
|
try {
|
|
196
186
|
const parsed = new URL(url);
|
|
187
|
+
if (!port && !parsed.port) return url;
|
|
197
188
|
parsed.port = port;
|
|
198
189
|
return parsed.toString();
|
|
199
190
|
} catch {
|
|
@@ -297,21 +288,24 @@ function verifyTwilioWebhook(ctx, authToken, options) {
|
|
|
297
288
|
remoteIP: options?.remoteIP
|
|
298
289
|
});
|
|
299
290
|
const params = new URLSearchParams(ctx.rawBody);
|
|
300
|
-
|
|
291
|
+
const verifyUrl = (candidateUrl) => {
|
|
292
|
+
if (!validateTwilioSignature(authToken, signature, candidateUrl, params)) return;
|
|
301
293
|
const replayKey = createTwilioReplayKey({
|
|
302
|
-
verificationUrl,
|
|
294
|
+
verificationUrl: candidateUrl,
|
|
303
295
|
signature,
|
|
304
296
|
requestParams: params
|
|
305
297
|
});
|
|
306
298
|
return {
|
|
307
299
|
ok: true,
|
|
308
|
-
verificationUrl,
|
|
300
|
+
verificationUrl: candidateUrl,
|
|
309
301
|
...reserveWebhookReplay(twilioReplayCache, replayKey)
|
|
310
302
|
};
|
|
311
|
-
}
|
|
303
|
+
};
|
|
304
|
+
const verified = verifyUrl(verificationUrl);
|
|
305
|
+
if (verified) return verified;
|
|
312
306
|
const variants = /* @__PURE__ */ new Set();
|
|
313
307
|
variants.add(verificationUrl);
|
|
314
|
-
variants.add(
|
|
308
|
+
variants.add(setPortOnUrl(verificationUrl, ""));
|
|
315
309
|
if (options?.publicUrl) try {
|
|
316
310
|
const publicPort = new URL(options.publicUrl).port;
|
|
317
311
|
if (publicPort) variants.add(setPortOnUrl(verificationUrl, publicPort));
|
|
@@ -320,17 +314,8 @@ function verifyTwilioWebhook(ctx, authToken, options) {
|
|
|
320
314
|
if (hostHeaderPort) variants.add(setPortOnUrl(verificationUrl, hostHeaderPort));
|
|
321
315
|
for (const candidateUrl of variants) {
|
|
322
316
|
if (candidateUrl === verificationUrl) continue;
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
verificationUrl: candidateUrl,
|
|
326
|
-
signature,
|
|
327
|
-
requestParams: params
|
|
328
|
-
});
|
|
329
|
-
return {
|
|
330
|
-
ok: true,
|
|
331
|
-
verificationUrl: candidateUrl,
|
|
332
|
-
...reserveWebhookReplay(twilioReplayCache, replayKey)
|
|
333
|
-
};
|
|
317
|
+
const result = verifyUrl(candidateUrl);
|
|
318
|
+
if (result) return result;
|
|
334
319
|
}
|
|
335
320
|
const isNgrokFreeTier = verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok.io");
|
|
336
321
|
return {
|
|
@@ -346,15 +331,6 @@ function getBaseUrlNoQuery(url) {
|
|
|
346
331
|
const u = new URL(url);
|
|
347
332
|
return `${u.protocol}//${u.host}${u.pathname}`;
|
|
348
333
|
}
|
|
349
|
-
function createPlivoV2ReplayKey(url, nonce) {
|
|
350
|
-
return `plivo:v2:${sha256Hex(`${getBaseUrlNoQuery(url)}\n${nonce}`)}`;
|
|
351
|
-
}
|
|
352
|
-
function validatePlivoV2Signature(params) {
|
|
353
|
-
const baseUrl = getBaseUrlNoQuery(params.url);
|
|
354
|
-
const expected = normalizeSignatureBase64(crypto.createHmac("sha256", params.authToken).update(baseUrl + params.nonce).digest("base64"));
|
|
355
|
-
const provided = normalizeSignatureBase64(params.signature);
|
|
356
|
-
return safeEqualSecret(expected, provided);
|
|
357
|
-
}
|
|
358
334
|
function sortedPlivoParams(params, format) {
|
|
359
335
|
return [...params].toSorted(([leftKey, leftValue], [rightKey, rightValue]) => {
|
|
360
336
|
const left = leftKey === rightKey ? leftValue : leftKey;
|
|
@@ -373,10 +349,24 @@ function constructPlivoV3BaseUrl(params) {
|
|
|
373
349
|
if (params.method === "GET") return baseUrl;
|
|
374
350
|
return baseUrl + sortedPlivoParams(params.postParams, "body");
|
|
375
351
|
}
|
|
376
|
-
function
|
|
377
|
-
const hmacBase = `${params.baseUrl}
|
|
352
|
+
function verifyPlivoSignature(params) {
|
|
353
|
+
const hmacBase = `${params.baseUrl}${params.version === "v3" ? "." : ""}${params.nonce}`;
|
|
378
354
|
const expected = normalizeSignatureBase64(crypto.createHmac("sha256", params.authToken).update(hmacBase).digest("base64"));
|
|
379
|
-
|
|
355
|
+
const ok = params.signatures.map(normalizeSignatureBase64).some((signature) => safeEqualSecret(expected, signature));
|
|
356
|
+
const result = {
|
|
357
|
+
ok,
|
|
358
|
+
version: params.version,
|
|
359
|
+
verificationUrl: params.verificationUrl
|
|
360
|
+
};
|
|
361
|
+
if (!ok) return {
|
|
362
|
+
...result,
|
|
363
|
+
reason: `Invalid Plivo ${params.version.toUpperCase()} signature`
|
|
364
|
+
};
|
|
365
|
+
const replayKey = `plivo:${params.version}:${sha256Hex(`${params.baseUrl}\n${params.nonce}`)}`;
|
|
366
|
+
return {
|
|
367
|
+
...result,
|
|
368
|
+
...reserveWebhookReplay(plivoReplayCache, replayKey)
|
|
369
|
+
};
|
|
380
370
|
}
|
|
381
371
|
/**
|
|
382
372
|
* Verify Plivo webhooks using V3 signature if present; fall back to V2.
|
|
@@ -420,45 +410,23 @@ function verifyPlivoWebhook(ctx, authToken, options) {
|
|
|
420
410
|
url: verificationUrl,
|
|
421
411
|
postParams
|
|
422
412
|
});
|
|
423
|
-
|
|
413
|
+
return verifyPlivoSignature({
|
|
424
414
|
authToken,
|
|
425
|
-
signatureHeader: signatureV3,
|
|
426
|
-
nonce: nonceV3,
|
|
427
|
-
baseUrl
|
|
428
|
-
})) return {
|
|
429
|
-
ok: false,
|
|
430
415
|
version: "v3",
|
|
431
416
|
verificationUrl,
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
ok: true,
|
|
437
|
-
version: "v3",
|
|
438
|
-
verificationUrl,
|
|
439
|
-
...reserveWebhookReplay(plivoReplayCache, replayKey)
|
|
440
|
-
};
|
|
441
|
-
}
|
|
442
|
-
if (signatureV2 && nonceV2) {
|
|
443
|
-
if (!validatePlivoV2Signature({
|
|
444
|
-
authToken,
|
|
445
|
-
signature: signatureV2,
|
|
446
|
-
nonce: nonceV2,
|
|
447
|
-
url: verificationUrl
|
|
448
|
-
})) return {
|
|
449
|
-
ok: false,
|
|
450
|
-
version: "v2",
|
|
451
|
-
verificationUrl,
|
|
452
|
-
reason: "Invalid Plivo V2 signature"
|
|
453
|
-
};
|
|
454
|
-
const replayKey = createPlivoV2ReplayKey(verificationUrl, nonceV2);
|
|
455
|
-
return {
|
|
456
|
-
ok: true,
|
|
457
|
-
version: "v2",
|
|
458
|
-
verificationUrl,
|
|
459
|
-
...reserveWebhookReplay(plivoReplayCache, replayKey)
|
|
460
|
-
};
|
|
417
|
+
signatures: normalizeStringEntries(signatureV3.split(",")),
|
|
418
|
+
nonce: nonceV3,
|
|
419
|
+
baseUrl
|
|
420
|
+
});
|
|
461
421
|
}
|
|
422
|
+
if (signatureV2 && nonceV2) return verifyPlivoSignature({
|
|
423
|
+
authToken,
|
|
424
|
+
version: "v2",
|
|
425
|
+
verificationUrl,
|
|
426
|
+
signatures: [signatureV2],
|
|
427
|
+
nonce: nonceV2,
|
|
428
|
+
baseUrl: getBaseUrlNoQuery(verificationUrl)
|
|
429
|
+
});
|
|
462
430
|
return {
|
|
463
431
|
ok: false,
|
|
464
432
|
reason: "Missing Plivo signature headers (V3 or V2)",
|
|
@@ -506,21 +474,57 @@ async function guardedJsonApiRequest(params) {
|
|
|
506
474
|
await release();
|
|
507
475
|
}
|
|
508
476
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
477
|
+
//#endregion
|
|
478
|
+
//#region extensions/voice-call/src/providers/shared/carrier-api.ts
|
|
479
|
+
/** Keep call controls and recovery probes on the same carrier endpoint and credentials. */
|
|
480
|
+
function createCarrierApi(name, baseUrl, authorization, options = {}) {
|
|
481
|
+
const provider = name.toLowerCase();
|
|
482
|
+
const headers = { Authorization: authorization };
|
|
483
|
+
const allowedHostnames = [new URL(baseUrl).hostname];
|
|
484
|
+
const request = (endpoint, body, params) => guardedJsonApiRequest({
|
|
485
|
+
method: params?.method ?? "POST",
|
|
486
|
+
allowNotFound: params?.allowNotFound,
|
|
487
|
+
body,
|
|
488
|
+
url: `${baseUrl}${endpoint}`,
|
|
489
|
+
headers: {
|
|
490
|
+
...headers,
|
|
491
|
+
"Content-Type": options.contentType ?? "application/json"
|
|
492
|
+
},
|
|
493
|
+
allowedHostnames,
|
|
494
|
+
auditContext: `voice-call.${provider}.api`,
|
|
495
|
+
errorPrefix: `${name} API error`,
|
|
496
|
+
createError: options.createError,
|
|
497
|
+
malformedJsonMessage: options.malformedJsonMessage
|
|
498
|
+
});
|
|
499
|
+
return {
|
|
500
|
+
request,
|
|
501
|
+
async getCallStatus(endpoint, describe) {
|
|
502
|
+
try {
|
|
503
|
+
const data = await guardedJsonApiRequest({
|
|
504
|
+
url: `${baseUrl}${endpoint}`,
|
|
505
|
+
method: "GET",
|
|
506
|
+
allowNotFound: true,
|
|
507
|
+
headers: options.statusContentType ? {
|
|
508
|
+
...headers,
|
|
509
|
+
"Content-Type": options.statusContentType
|
|
510
|
+
} : headers,
|
|
511
|
+
allowedHostnames,
|
|
512
|
+
auditContext: `${provider}-get-call-status`,
|
|
513
|
+
errorPrefix: `${name} get call status error`
|
|
514
|
+
});
|
|
515
|
+
return data ? describe(data) : {
|
|
516
|
+
status: "not-found",
|
|
517
|
+
isTerminal: true
|
|
518
|
+
};
|
|
519
|
+
} catch {
|
|
520
|
+
return {
|
|
521
|
+
status: "error",
|
|
522
|
+
isTerminal: false,
|
|
523
|
+
isUnknown: true
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
};
|
|
524
528
|
}
|
|
525
529
|
//#endregion
|
|
526
|
-
export {
|
|
530
|
+
export { verifyTwilioWebhook as a, verifyTelnyxWebhook as i, reconstructWebhookUrl as n, verifyPlivoWebhook as r, createCarrierApi as t };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { sleep } from "../api.js";
|
|
2
|
-
import { l as resolveVoiceCallStreamExposurePaths, u as validateProviderConfig } from "./config-
|
|
3
|
-
import { _ as
|
|
2
|
+
import { g as CallBriefSchema, l as resolveVoiceCallStreamExposurePaths, u as validateProviderConfig } from "./config-CsrGuamE.mjs";
|
|
3
|
+
import { S as resolveVoiceCallAgentId, _ as findCallInStore, b as setVoiceCallStateRuntime, d as getTailscaleSelfInfo, f as setupTailscaleExposureRoutes, g as MAX_CALL_RECORD_EVENTS, l as resolveWebhookExposureStatus, u as cleanupTailscaleExposureRoute, v as getCallHistoryFromStore, y as loadActiveCallsFromStore } from "./runtime-entry-STjFmi_B.mjs";
|
|
4
4
|
import { r as resolveUserPath, t as resolveDefaultVoiceCallStoreDir } from "./store-path-Cfwff6j7.mjs";
|
|
5
5
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
6
6
|
import { callGatewayFromCli, isGatewayClientRequestError, isGatewayTransportError, redactSensitiveUrlLikeString } from "openclaw/plugin-sdk/gateway-runtime";
|
|
@@ -8,6 +8,7 @@ import { isRecord, normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/
|
|
|
8
8
|
import { MAX_TCP_PORT, MAX_TIMER_TIMEOUT_MS, addTimerTimeoutGraceMs, clampTimerTimeoutMs, parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime";
|
|
9
9
|
import fs from "node:fs";
|
|
10
10
|
import path from "node:path";
|
|
11
|
+
import { readFile } from "node:fs/promises";
|
|
11
12
|
import { createDeferred } from "openclaw/plugin-sdk/concurrency-runtime";
|
|
12
13
|
import { once } from "node:events";
|
|
13
14
|
import { format } from "node:util";
|
|
@@ -232,43 +233,23 @@ function resolveOperationTimeout(config) {
|
|
|
232
233
|
function resolveContinueTimeout(config) {
|
|
233
234
|
return clampTimerTimeoutMs(config.transcriptTimeoutMs + VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS + VOICE_CALL_GATEWAY_TRANSCRIPT_BUFFER_MS) ?? 1;
|
|
234
235
|
}
|
|
235
|
-
function resolveVoiceCallDeadlineMs(timeoutMs, nowMs = Date.now()) {
|
|
236
|
-
return nowMs + (clampTimerTimeoutMs(timeoutMs) ?? MAX_TIMER_TIMEOUT_MS);
|
|
237
|
-
}
|
|
238
|
-
function readGatewayPollTimeoutMs(payload, fallbackTimeoutMs) {
|
|
239
|
-
if (isRecord(payload) && typeof payload.pollTimeoutMs === "number") return clampTimerTimeoutMs(payload.pollTimeoutMs) ?? fallbackTimeoutMs;
|
|
240
|
-
return fallbackTimeoutMs;
|
|
241
|
-
}
|
|
242
|
-
function readCompletedContinueResult(payload) {
|
|
243
|
-
if (!isRecord(payload)) throw new Error("voicecall gateway response missing operation status");
|
|
244
|
-
if (payload.status === "pending") return { status: "pending" };
|
|
245
|
-
if (payload.status === "failed") return {
|
|
246
|
-
status: "failed",
|
|
247
|
-
error: typeof payload.error === "string" ? payload.error : "continue failed"
|
|
248
|
-
};
|
|
249
|
-
if (payload.status === "completed") return {
|
|
250
|
-
status: "completed",
|
|
251
|
-
result: payload.result
|
|
252
|
-
};
|
|
253
|
-
throw new Error("voicecall gateway response has unknown operation status");
|
|
254
|
-
}
|
|
255
236
|
async function pollContinueGateway(payload, fallbackTimeoutMs) {
|
|
256
237
|
if (!isRecord(payload) || typeof payload.operationId !== "string") return payload;
|
|
257
238
|
if (!payload.operationId) throw new Error("voicecall gateway response missing operationId");
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
};
|
|
262
|
-
const deadlineMs = resolveVoiceCallDeadlineMs(params.timeoutMs);
|
|
239
|
+
const operationId = payload.operationId;
|
|
240
|
+
const timeoutMs = typeof payload.pollTimeoutMs === "number" ? clampTimerTimeoutMs(payload.pollTimeoutMs) ?? fallbackTimeoutMs : fallbackTimeoutMs;
|
|
241
|
+
const deadlineMs = performance.now() + (clampTimerTimeoutMs(timeoutMs) ?? MAX_TIMER_TIMEOUT_MS);
|
|
263
242
|
for (;;) {
|
|
264
|
-
const remainingMs = deadlineMs -
|
|
243
|
+
const remainingMs = deadlineMs - performance.now();
|
|
265
244
|
if (remainingMs <= 0) break;
|
|
266
|
-
const gateway = await callVoiceCallGateway("voicecall.continue.result", { operationId
|
|
245
|
+
const gateway = await callVoiceCallGateway("voicecall.continue.result", { operationId }, { timeoutMs: Math.min(VOICE_CALL_GATEWAY_DEFAULT_TIMEOUT_MS, remainingMs) });
|
|
267
246
|
if (!gateway.ok) throw new Error(`gateway unavailable while waiting for voicecall continue result: ${formatErrorMessage(gateway.error)}`);
|
|
268
|
-
const result =
|
|
247
|
+
const result = gateway.payload;
|
|
248
|
+
if (!isRecord(result)) throw new Error("voicecall gateway response missing operation status");
|
|
269
249
|
if (result.status === "completed") return result.result;
|
|
270
|
-
if (result.status === "failed") throw new Error(result.error);
|
|
271
|
-
|
|
250
|
+
if (result.status === "failed") throw new Error(typeof result.error === "string" ? result.error : "continue failed");
|
|
251
|
+
if (result.status !== "pending") throw new Error("voicecall gateway response has unknown operation status");
|
|
252
|
+
const sleepMs = Math.min(VOICE_CALL_GATEWAY_POLL_INTERVAL_MS, deadlineMs - performance.now());
|
|
272
253
|
if (sleepMs <= 0) break;
|
|
273
254
|
await sleep(sleepMs);
|
|
274
255
|
}
|
|
@@ -306,7 +287,8 @@ async function initiateVoiceCall(params) {
|
|
|
306
287
|
const gateway = await callVoiceCallGateway(params.method, {
|
|
307
288
|
...params.to ? { to: params.to } : {},
|
|
308
289
|
...params.message ? { message: params.message } : {},
|
|
309
|
-
...mode ? { mode } : {}
|
|
290
|
+
...mode ? { mode } : {},
|
|
291
|
+
...params.brief ? { brief: params.brief } : {}
|
|
310
292
|
}, { timeoutMs: resolveOperationTimeout(params.config) });
|
|
311
293
|
if (gateway.ok) return readGatewayCallId(gateway.payload, params.failureMessage);
|
|
312
294
|
const runtime = await ensureStandaloneRuntime(params);
|
|
@@ -314,6 +296,7 @@ async function initiateVoiceCall(params) {
|
|
|
314
296
|
if (!to) throw new Error("Missing --to and no toNumber configured");
|
|
315
297
|
const result = await runtime.manager.initiateCall(to, void 0, {
|
|
316
298
|
message: params.message,
|
|
299
|
+
brief: params.brief,
|
|
317
300
|
mode
|
|
318
301
|
});
|
|
319
302
|
if (!result.success) throw new Error(result.error || params.failureMessage || "initiate failed");
|
|
@@ -322,6 +305,14 @@ async function initiateVoiceCall(params) {
|
|
|
322
305
|
}
|
|
323
306
|
//#endregion
|
|
324
307
|
//#region extensions/voice-call/src/cli.ts
|
|
308
|
+
async function readCliBrief(options) {
|
|
309
|
+
if (options.brief && options.briefFile) throw new Error("Use either --brief or --brief-file");
|
|
310
|
+
const raw = options.briefFile ? await readFile(resolveUserPath(options.briefFile), "utf8") : options.brief;
|
|
311
|
+
if (raw === void 0) return;
|
|
312
|
+
if (raw.length > 16e3) throw new Error("Brief input is too large");
|
|
313
|
+
const value = options.briefFile || raw.trimStart().startsWith("{") || raw.trimStart().startsWith("[") ? JSON.parse(raw) : { task: raw };
|
|
314
|
+
return CallBriefSchema.parse(value);
|
|
315
|
+
}
|
|
325
316
|
function resolveMode(input) {
|
|
326
317
|
const raw = normalizeOptionalLowercaseString(input) ?? "";
|
|
327
318
|
if (raw === "serve" || raw === "off") return raw;
|
|
@@ -422,6 +413,11 @@ function registerVoiceCallCli(params) {
|
|
|
422
413
|
if (stateRuntime) setVoiceCallStateRuntime({ state: stateRuntime });
|
|
423
414
|
};
|
|
424
415
|
const root = program.command("voicecall").description("Voice call utilities").addHelpText("after", () => `\nDocs: https://docs.openclaw.ai/cli/voicecall\n`);
|
|
416
|
+
const managerAction = (command) => runWithStandaloneRuntime(createRuntime, (ensureRuntime) => runGatewayManagerCommand({
|
|
417
|
+
config,
|
|
418
|
+
ensureRuntime,
|
|
419
|
+
...command
|
|
420
|
+
}));
|
|
425
421
|
root.command("setup").description("Show Voice Call provider and webhook setup status").option("--json", "Print machine-readable JSON").action((options) => {
|
|
426
422
|
const status = buildSetupStatus(config, coreConfig);
|
|
427
423
|
if (options.json) {
|
|
@@ -441,28 +437,17 @@ function registerVoiceCallCli(params) {
|
|
|
441
437
|
process.exitCode = 1;
|
|
442
438
|
return;
|
|
443
439
|
}
|
|
444
|
-
if (!options.to) {
|
|
445
|
-
if (options.json) writeCliJson({
|
|
446
|
-
ok: true,
|
|
447
|
-
setup,
|
|
448
|
-
liveCall: false
|
|
449
|
-
});
|
|
450
|
-
else {
|
|
451
|
-
writeSetupStatus(setup);
|
|
452
|
-
writeCliLine("live-call: skipped (pass --to and --yes to place one)");
|
|
453
|
-
}
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
|
-
if (!options.yes) {
|
|
440
|
+
if (!options.to || !options.yes) {
|
|
457
441
|
if (options.json) writeCliJson({
|
|
458
442
|
ok: true,
|
|
459
443
|
setup,
|
|
460
444
|
liveCall: false,
|
|
461
|
-
wouldCall: options.to
|
|
445
|
+
...options.to ? { wouldCall: options.to } : {}
|
|
462
446
|
});
|
|
463
447
|
else {
|
|
464
448
|
writeSetupStatus(setup);
|
|
465
|
-
writeCliLine("live-call: dry run for %s (add --yes to place it)", options.to);
|
|
449
|
+
if (options.to) writeCliLine("live-call: dry run for %s (add --yes to place it)", options.to);
|
|
450
|
+
else writeCliLine("live-call: skipped (pass --to and --yes to place one)");
|
|
466
451
|
}
|
|
467
452
|
return;
|
|
468
453
|
}
|
|
@@ -493,22 +478,21 @@ function registerVoiceCallCli(params) {
|
|
|
493
478
|
ensureRuntime,
|
|
494
479
|
config,
|
|
495
480
|
method,
|
|
481
|
+
brief: await readCliBrief(options),
|
|
496
482
|
to: options.to,
|
|
497
483
|
message: options.message,
|
|
498
484
|
mode: options.mode
|
|
499
485
|
}) });
|
|
500
486
|
});
|
|
501
|
-
root.command("call").description("Initiate an outbound voice call").requiredOption("-m, --message <text>", "Message to speak when call connects").option("-t, --to <phone>", "Phone number to call (E.164 format, uses config toNumber if not set)").option("--mode <mode>", "Call mode: notify (hangup after message) or conversation (stay open)", "conversation").action(callAction("voicecall.initiate"));
|
|
502
|
-
root.command("start").description("Alias for voicecall call").requiredOption("--to <phone>", "Phone number to call").option("--message <text>", "Message to speak when call connects").option("--mode <mode>", "Call mode: notify (hangup after message) or conversation (stay open)", "conversation").action(callAction("voicecall.start"));
|
|
503
|
-
root.command("continue").description("Speak a message and wait for a response").requiredOption("--call-id <id>", "Call ID").requiredOption("--message <text>", "Message to speak").action(
|
|
487
|
+
root.command("call").description("Initiate an outbound voice call").requiredOption("-m, --message <text>", "Message to speak when call connects").option("-t, --to <phone>", "Phone number to call (E.164 format, uses config toNumber if not set)").option("--mode <mode>", "Call mode: notify (hangup after message) or conversation (stay open)", "conversation").option("--brief <text-or-json>", "Per-call task or structured JSON brief").option("--brief-file <path>", "Read a structured JSON brief from a file").action(callAction("voicecall.initiate"));
|
|
488
|
+
root.command("start").description("Alias for voicecall call").requiredOption("--to <phone>", "Phone number to call").option("--message <text>", "Message to speak when call connects").option("--mode <mode>", "Call mode: notify (hangup after message) or conversation (stay open)", "conversation").option("--brief <text-or-json>", "Per-call task or structured JSON brief").option("--brief-file <path>", "Read a structured JSON brief from a file").action(callAction("voicecall.start"));
|
|
489
|
+
root.command("continue").description("Speak a message and wait for a response").requiredOption("--call-id <id>", "Call ID").requiredOption("--message <text>", "Message to speak").action((options) => {
|
|
504
490
|
const gatewayParams = {
|
|
505
491
|
callId: options.callId,
|
|
506
492
|
message: options.message
|
|
507
493
|
};
|
|
508
494
|
const continueTimeoutMs = resolveContinueTimeout(config);
|
|
509
|
-
|
|
510
|
-
config,
|
|
511
|
-
ensureRuntime,
|
|
495
|
+
return managerAction({
|
|
512
496
|
gatewayCall: async () => {
|
|
513
497
|
try {
|
|
514
498
|
return await callVoiceCallGateway("voicecall.continue.start", gatewayParams, { timeoutMs: resolveOperationTimeout(config) });
|
|
@@ -521,39 +505,37 @@ function registerVoiceCallCli(params) {
|
|
|
521
505
|
managerFallback: (manager) => manager.continueCall(options.callId, options.message),
|
|
522
506
|
failureLabel: "continue"
|
|
523
507
|
});
|
|
508
|
+
});
|
|
509
|
+
root.command("speak").description("Speak a message without waiting for response").requiredOption("--call-id <id>", "Call ID").requiredOption("--message <text>", "Message to speak").action((options) => managerAction({
|
|
510
|
+
gatewayCall: () => callVoiceCallGateway("voicecall.speak", {
|
|
511
|
+
callId: options.callId,
|
|
512
|
+
message: options.message
|
|
513
|
+
}),
|
|
514
|
+
managerFallback: (manager) => manager.speak(options.callId, options.message),
|
|
515
|
+
failureLabel: "speak"
|
|
524
516
|
}));
|
|
525
|
-
root.command("
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
message: options.message
|
|
532
|
-
}),
|
|
533
|
-
managerFallback: (manager) => manager.speak(options.callId, options.message),
|
|
534
|
-
failureLabel: "speak"
|
|
535
|
-
});
|
|
536
|
-
}));
|
|
537
|
-
root.command("dtmf").description("Send DTMF digits to an active call").requiredOption("--call-id <id>", "Call ID").requiredOption("--digits <digits>", "DTMF digits").action(async (options) => runWithStandaloneRuntime(createRuntime, async (ensureRuntime) => {
|
|
538
|
-
await runGatewayManagerCommand({
|
|
539
|
-
config,
|
|
540
|
-
ensureRuntime,
|
|
541
|
-
gatewayCall: () => callVoiceCallGateway("voicecall.dtmf", {
|
|
542
|
-
callId: options.callId,
|
|
543
|
-
digits: options.digits
|
|
544
|
-
}),
|
|
545
|
-
managerFallback: (manager) => manager.sendDtmf(options.callId, options.digits),
|
|
546
|
-
failureLabel: "dtmf"
|
|
517
|
+
root.command("steer").description("Steer an active realtime call through its owning Gateway").requiredOption("--call-id <id>", "Call ID").requiredOption("--message <text>", "Owner instruction").option("--mode <mode>", "guidance or say", "guidance").action(async (options) => {
|
|
518
|
+
if (options.mode !== "say" && options.mode !== "guidance") throw new Error("mode must be say or guidance");
|
|
519
|
+
const gateway = await callVoiceCallGateway("voicecall.steer", {
|
|
520
|
+
callId: options.callId,
|
|
521
|
+
message: options.message,
|
|
522
|
+
mode: options.mode
|
|
547
523
|
});
|
|
524
|
+
if (!gateway.ok) throw new Error("Steering requires the running Gateway that owns the active call");
|
|
525
|
+
writeCliJson(gateway.payload);
|
|
526
|
+
});
|
|
527
|
+
root.command("dtmf").description("Send DTMF digits to an active call").requiredOption("--call-id <id>", "Call ID").requiredOption("--digits <digits>", "DTMF digits").action((options) => managerAction({
|
|
528
|
+
gatewayCall: () => callVoiceCallGateway("voicecall.dtmf", {
|
|
529
|
+
callId: options.callId,
|
|
530
|
+
digits: options.digits
|
|
531
|
+
}),
|
|
532
|
+
managerFallback: (manager) => manager.sendDtmf(options.callId, options.digits),
|
|
533
|
+
failureLabel: "dtmf"
|
|
548
534
|
}));
|
|
549
|
-
root.command("end").description("Hang up an active call").requiredOption("--call-id <id>", "Call ID").action(
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
gatewayCall: () => callVoiceCallGateway("voicecall.end", { callId: options.callId }),
|
|
554
|
-
managerFallback: (manager) => manager.endCall(options.callId),
|
|
555
|
-
failureLabel: "end"
|
|
556
|
-
});
|
|
535
|
+
root.command("end").description("Hang up an active call").requiredOption("--call-id <id>", "Call ID").action((options) => managerAction({
|
|
536
|
+
gatewayCall: () => callVoiceCallGateway("voicecall.end", { callId: options.callId }),
|
|
537
|
+
managerFallback: (manager) => manager.endCall(options.callId),
|
|
538
|
+
failureLabel: "end"
|
|
557
539
|
}));
|
|
558
540
|
root.command("status").description("Show call status").option("--call-id <id>", "Call ID").option("--json", "Print machine-readable JSON").action(async (options) => {
|
|
559
541
|
const gateway = await callVoiceCallGateway("voicecall.status", options.callId ? { callId: options.callId } : void 0);
|