niahere 0.2.91 → 0.3.1
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 +4 -1
- package/package.json +1 -1
- package/skills/nia-phone/SKILL.md +153 -58
- package/src/channels/index.ts +12 -0
- package/src/channels/phone/index.ts +109 -154
- package/src/channels/sms.ts +189 -0
- package/src/channels/twilio/dedup.ts +36 -0
- package/src/channels/twilio/media-cache.ts +107 -0
- package/src/channels/twilio/media.ts +79 -0
- package/src/channels/twilio/rate-limit.ts +33 -0
- package/src/channels/twilio/rest.ts +133 -0
- package/src/channels/twilio/server.ts +255 -0
- package/src/channels/twilio/signature.ts +36 -0
- package/src/channels/twilio/transcribe.ts +58 -0
- package/src/channels/whatsapp.ts +408 -0
- package/src/cli/phone.ts +32 -22
- package/src/commands/init.ts +67 -0
- package/src/mcp/tools.ts +1 -1
- package/src/types/config.ts +41 -11
- package/src/types/enums.ts +1 -1
- package/src/types/index.ts +10 -1
- package/src/utils/config.ts +92 -42
- package/src/channels/phone/twilio.ts +0 -125
package/src/types/config.ts
CHANGED
|
@@ -28,23 +28,36 @@ export interface SlackConfig {
|
|
|
28
28
|
watch: Record<string, SlackWatchChannel> | null;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Shared config for all Twilio-based channels (phone/sms/whatsapp).
|
|
33
|
+
* Credentials, owner identity, public URL, and the local webhook port
|
|
34
|
+
* live here so individual channels don't reach into each other's configs.
|
|
35
|
+
*/
|
|
36
|
+
export interface TwilioConfig {
|
|
37
|
+
/** SID used for both URL paths and Basic auth.
|
|
38
|
+
* Usually the Account SID (AC…). Can be an API Key SID (SK…) — Twilio resolves it. */
|
|
39
|
+
sid: string | null;
|
|
40
|
+
/** Basic auth password. Account Auth Token if sid is AC…, API Key Secret if SK…. */
|
|
41
|
+
secret: string | null;
|
|
42
|
+
/** Account-level Auth Token. Used to verify X-Twilio-Signature on inbound webhooks.
|
|
43
|
+
* If sid is an API Key SID (SK…), this MUST be set separately. Falls back to `secret`
|
|
44
|
+
* when sid is an Account SID and `secret` is the Auth Token. */
|
|
45
|
+
auth_token: string | null;
|
|
46
|
+
/** Owner's phone number (E.164). Highest-trust caller / messenger. */
|
|
41
47
|
owner_number: string | null;
|
|
42
48
|
/** Extra allowlisted E.164 numbers (family, close contacts). */
|
|
43
49
|
allowlist: string[];
|
|
44
50
|
/** Public base URL Twilio hits (e.g. https://nia.example.com). No trailing slash. */
|
|
45
51
|
public_base_url: string | null;
|
|
46
|
-
/** Local HTTP port
|
|
52
|
+
/** Local HTTP port the shared Twilio webhook server binds to. */
|
|
47
53
|
port: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Voice (Twilio Programmable Voice + OpenAI Realtime). */
|
|
57
|
+
export interface PhoneConfig {
|
|
58
|
+
enabled: boolean;
|
|
59
|
+
/** Twilio number Nia dials from / inbound voice number (E.164). */
|
|
60
|
+
from_number: string | null;
|
|
48
61
|
/** OpenAI API key for the Realtime voice loop. */
|
|
49
62
|
openai_api_key: string | null;
|
|
50
63
|
/** OpenAI Realtime model id. */
|
|
@@ -53,12 +66,29 @@ export interface PhoneConfig {
|
|
|
53
66
|
voice: string;
|
|
54
67
|
}
|
|
55
68
|
|
|
69
|
+
/** SMS via Twilio (uses the shared TwilioConfig credentials). */
|
|
70
|
+
export interface SmsConfig {
|
|
71
|
+
enabled: boolean;
|
|
72
|
+
/** E.164 number SMS is sent from. Defaults to phone.from_number. */
|
|
73
|
+
from_number: string | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** WhatsApp via Twilio (sandbox by default; uses shared TwilioConfig). */
|
|
77
|
+
export interface WhatsappConfig {
|
|
78
|
+
enabled: boolean;
|
|
79
|
+
/** WhatsApp sender E.164. Defaults to Twilio Sandbox shared number +14155238886. */
|
|
80
|
+
from_number: string | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
56
83
|
export interface ChannelsConfig {
|
|
57
84
|
enabled: boolean;
|
|
58
85
|
default: string;
|
|
59
86
|
telegram: TelegramConfig;
|
|
60
87
|
slack: SlackConfig;
|
|
88
|
+
twilio: TwilioConfig;
|
|
61
89
|
phone: PhoneConfig;
|
|
90
|
+
sms: SmsConfig;
|
|
91
|
+
whatsapp: WhatsappConfig;
|
|
62
92
|
}
|
|
63
93
|
|
|
64
94
|
export interface SessionFinalizationConfig {
|
package/src/types/enums.ts
CHANGED
package/src/types/index.ts
CHANGED
|
@@ -5,7 +5,16 @@ export type { SendResult, StreamCallback, ActivityCallback, SendCallbacks, ChatE
|
|
|
5
5
|
export type { AuditEntry, JobState, CronState } from "./audit";
|
|
6
6
|
export type { Channel, ChannelFactory } from "./channel";
|
|
7
7
|
export type { ChatState } from "./chat-state";
|
|
8
|
-
export type {
|
|
8
|
+
export type {
|
|
9
|
+
Config,
|
|
10
|
+
ChannelsConfig,
|
|
11
|
+
TelegramConfig,
|
|
12
|
+
SlackConfig,
|
|
13
|
+
TwilioConfig,
|
|
14
|
+
PhoneConfig,
|
|
15
|
+
SmsConfig,
|
|
16
|
+
WhatsappConfig,
|
|
17
|
+
} from "./config";
|
|
9
18
|
export type { Paths } from "./paths";
|
|
10
19
|
export type { SaveMessageParams, RoomStats, RecentMessage, SearchResult, SessionMessage } from "./message";
|
|
11
20
|
export type { AgentInfo } from "./agent";
|
package/src/utils/config.ts
CHANGED
|
@@ -36,19 +36,30 @@ const DEFAULTS: Config = {
|
|
|
36
36
|
workspace_url: null,
|
|
37
37
|
watch: null,
|
|
38
38
|
},
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
from_number: null,
|
|
39
|
+
twilio: {
|
|
40
|
+
sid: null,
|
|
41
|
+
secret: null,
|
|
42
|
+
auth_token: null,
|
|
44
43
|
owner_number: null,
|
|
45
44
|
allowlist: [],
|
|
46
45
|
public_base_url: null,
|
|
47
46
|
port: 7079,
|
|
47
|
+
},
|
|
48
|
+
phone: {
|
|
49
|
+
enabled: true,
|
|
50
|
+
from_number: null,
|
|
48
51
|
openai_api_key: null,
|
|
49
52
|
realtime_model: "gpt-realtime",
|
|
50
53
|
voice: "marin",
|
|
51
54
|
},
|
|
55
|
+
sms: {
|
|
56
|
+
enabled: true,
|
|
57
|
+
from_number: null,
|
|
58
|
+
},
|
|
59
|
+
whatsapp: {
|
|
60
|
+
enabled: true,
|
|
61
|
+
from_number: "+14155238886",
|
|
62
|
+
},
|
|
52
63
|
},
|
|
53
64
|
};
|
|
54
65
|
|
|
@@ -131,6 +142,19 @@ export function loadConfig(): Config {
|
|
|
131
142
|
const chTg = (ch.telegram || {}) as Record<string, unknown>;
|
|
132
143
|
const chSl = (ch.slack || {}) as Record<string, unknown>;
|
|
133
144
|
const chPh = (ch.phone || {}) as Record<string, unknown>;
|
|
145
|
+
const chTw = (ch.twilio || {}) as Record<string, unknown>;
|
|
146
|
+
const chSms = (ch.sms || {}) as Record<string, unknown>;
|
|
147
|
+
const chWa = (ch.whatsapp || {}) as Record<string, unknown>;
|
|
148
|
+
|
|
149
|
+
// Helper: read a value from `channels.twilio.<newKey>`, falling back to
|
|
150
|
+
// legacy `channels.phone.<oldKey>` (the pre-refactor location), then env.
|
|
151
|
+
const twilioOrPhone = (newKey: string, oldKey: string, envKey: string | null): string | null => {
|
|
152
|
+
const envVal = envKey ? process.env[envKey] : undefined;
|
|
153
|
+
if (envVal) return envVal;
|
|
154
|
+
if (typeof chTw[newKey] === "string") return chTw[newKey] as string;
|
|
155
|
+
if (typeof chPh[oldKey] === "string") return chPh[oldKey] as string;
|
|
156
|
+
return null;
|
|
157
|
+
};
|
|
134
158
|
|
|
135
159
|
const channelsEnabled = ch.enabled !== false;
|
|
136
160
|
|
|
@@ -162,29 +186,42 @@ export function loadConfig(): Config {
|
|
|
162
186
|
const slWorkspaceId = typeof chSl.workspace_id === "string" ? chSl.workspace_id : null;
|
|
163
187
|
const slWorkspaceUrl = typeof chSl.workspace_url === "string" ? chSl.workspace_url : null;
|
|
164
188
|
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
189
|
+
// --- Twilio shared config (used by phone, sms, whatsapp) ---
|
|
190
|
+
// New shape: channels.twilio.{sid,secret,auth_token,owner_number,allowlist,public_base_url,port}.
|
|
191
|
+
// Legacy shape kept for one release: channels.phone.{twilio_sid,twilio_secret,twilio_auth_token,
|
|
192
|
+
// owner_number,allowlist,public_base_url,port}. Env vars take precedence over both.
|
|
193
|
+
const twSid = twilioOrPhone("sid", "twilio_sid", "TWILIO_SID");
|
|
194
|
+
const twSecret = twilioOrPhone("secret", "twilio_secret", "TWILIO_SECRET");
|
|
195
|
+
const twAuthToken = twilioOrPhone("auth_token", "twilio_auth_token", "TWILIO_AUTH_TOKEN");
|
|
196
|
+
const twOwnerNumber = twilioOrPhone("owner_number", "owner_number", "PRIMARY_PHONE_USER");
|
|
197
|
+
const twPublicBaseUrl =
|
|
198
|
+
(twilioOrPhone("public_base_url", "public_base_url", "PUBLIC_BASE_URL") || "").replace(/\/$/, "") || null;
|
|
199
|
+
|
|
200
|
+
const twPortRaw = process.env.PHONE_PORT ? Number(process.env.PHONE_PORT) : null;
|
|
201
|
+
const twPort =
|
|
202
|
+
twPortRaw && Number.isFinite(twPortRaw)
|
|
203
|
+
? twPortRaw
|
|
204
|
+
: typeof chTw.port === "number"
|
|
205
|
+
? chTw.port
|
|
206
|
+
: typeof chPh.port === "number"
|
|
207
|
+
? chPh.port
|
|
208
|
+
: DEFAULTS.channels.twilio.port;
|
|
209
|
+
|
|
210
|
+
const twAllowlistRaw =
|
|
211
|
+
process.env.PHONE_ALLOWLIST ||
|
|
212
|
+
(Array.isArray(chTw.allowlist)
|
|
213
|
+
? (chTw.allowlist as unknown[]).filter((x): x is string => typeof x === "string").join(",")
|
|
214
|
+
: Array.isArray(chPh.allowlist)
|
|
215
|
+
? (chPh.allowlist as unknown[]).filter((x): x is string => typeof x === "string").join(",")
|
|
216
|
+
: "");
|
|
217
|
+
const twAllowlist = twAllowlistRaw
|
|
218
|
+
.split(",")
|
|
219
|
+
.map((s) => s.trim())
|
|
220
|
+
.filter((s) => s.length > 0);
|
|
221
|
+
|
|
222
|
+
// --- Phone (voice) — env vars and new keys override legacy ---
|
|
171
223
|
const phFromNumber =
|
|
172
224
|
process.env.PHONE_FROM_NUMBER || (typeof chPh.from_number === "string" ? chPh.from_number : null);
|
|
173
|
-
const phOwnerNumber =
|
|
174
|
-
process.env.PRIMARY_PHONE_USER || (typeof chPh.owner_number === "string" ? chPh.owner_number : null);
|
|
175
|
-
const phPublicBaseUrl =
|
|
176
|
-
(
|
|
177
|
-
process.env.PUBLIC_BASE_URL ||
|
|
178
|
-
(typeof chPh.public_base_url === "string" ? chPh.public_base_url : null) ||
|
|
179
|
-
""
|
|
180
|
-
).replace(/\/$/, "") || null;
|
|
181
|
-
const phPortRaw = process.env.PHONE_PORT ? Number(process.env.PHONE_PORT) : null;
|
|
182
|
-
const phPort =
|
|
183
|
-
phPortRaw && Number.isFinite(phPortRaw)
|
|
184
|
-
? phPortRaw
|
|
185
|
-
: typeof chPh.port === "number"
|
|
186
|
-
? chPh.port
|
|
187
|
-
: DEFAULTS.channels.phone.port;
|
|
188
225
|
const phOpenAiKey =
|
|
189
226
|
process.env.OPENAI_API_KEY || (typeof chPh.openai_api_key === "string" ? chPh.openai_api_key : null);
|
|
190
227
|
const phRealtimeModel =
|
|
@@ -192,16 +229,18 @@ export function loadConfig(): Config {
|
|
|
192
229
|
(typeof chPh.realtime_model === "string" ? chPh.realtime_model : DEFAULTS.channels.phone.realtime_model);
|
|
193
230
|
const phVoice =
|
|
194
231
|
process.env.PHONE_VOICE || (typeof chPh.voice === "string" ? chPh.voice : DEFAULTS.channels.phone.voice);
|
|
232
|
+
const phEnabled = chPh.enabled !== false;
|
|
195
233
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
.
|
|
204
|
-
|
|
234
|
+
// --- SMS ---
|
|
235
|
+
const smsFromNumber =
|
|
236
|
+
process.env.SMS_FROM_NUMBER || (typeof chSms.from_number === "string" ? chSms.from_number : null);
|
|
237
|
+
const smsEnabled = chSms.enabled !== false;
|
|
238
|
+
|
|
239
|
+
// --- WhatsApp ---
|
|
240
|
+
const waFromNumber =
|
|
241
|
+
process.env.WHATSAPP_FROM_NUMBER ||
|
|
242
|
+
(typeof chWa.from_number === "string" ? chWa.from_number : DEFAULTS.channels.whatsapp.from_number);
|
|
243
|
+
const waEnabled = chWa.enabled !== false;
|
|
205
244
|
|
|
206
245
|
// Slack watch channels — behavior is optional (defaults to key name lookup)
|
|
207
246
|
const rawWatch = chSl.watch as Record<string, unknown> | undefined;
|
|
@@ -242,19 +281,30 @@ export function loadConfig(): Config {
|
|
|
242
281
|
workspace_url: slWorkspaceUrl,
|
|
243
282
|
watch: slWatch,
|
|
244
283
|
},
|
|
284
|
+
twilio: {
|
|
285
|
+
sid: twSid,
|
|
286
|
+
secret: twSecret,
|
|
287
|
+
auth_token: twAuthToken,
|
|
288
|
+
owner_number: twOwnerNumber,
|
|
289
|
+
allowlist: twAllowlist,
|
|
290
|
+
public_base_url: twPublicBaseUrl,
|
|
291
|
+
port: twPort,
|
|
292
|
+
},
|
|
245
293
|
phone: {
|
|
246
|
-
|
|
247
|
-
twilio_secret: phTwilioSecret,
|
|
248
|
-
twilio_auth_token: phTwilioAuthToken,
|
|
294
|
+
enabled: phEnabled,
|
|
249
295
|
from_number: phFromNumber,
|
|
250
|
-
owner_number: phOwnerNumber,
|
|
251
|
-
allowlist: phAllowlist,
|
|
252
|
-
public_base_url: phPublicBaseUrl,
|
|
253
|
-
port: phPort,
|
|
254
296
|
openai_api_key: phOpenAiKey,
|
|
255
297
|
realtime_model: phRealtimeModel,
|
|
256
298
|
voice: phVoice,
|
|
257
299
|
},
|
|
300
|
+
sms: {
|
|
301
|
+
enabled: smsEnabled,
|
|
302
|
+
from_number: smsFromNumber,
|
|
303
|
+
},
|
|
304
|
+
whatsapp: {
|
|
305
|
+
enabled: waEnabled,
|
|
306
|
+
from_number: waFromNumber,
|
|
307
|
+
},
|
|
258
308
|
},
|
|
259
309
|
};
|
|
260
310
|
}
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Minimal Twilio REST + webhook-signature helpers.
|
|
3
|
-
* Skips the official SDK so the daemon's dependency surface stays small —
|
|
4
|
-
* the surface we use (place call, hang up, swap URL, validate signature) is
|
|
5
|
-
* a handful of well-documented endpoints.
|
|
6
|
-
*/
|
|
7
|
-
import { createHmac, timingSafeEqual } from "crypto";
|
|
8
|
-
|
|
9
|
-
const TWILIO_BASE = "https://api.twilio.com/2010-04-01";
|
|
10
|
-
|
|
11
|
-
interface TwilioCreds {
|
|
12
|
-
accountSid: string;
|
|
13
|
-
authToken: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function basicAuth({ accountSid, authToken }: TwilioCreds): string {
|
|
17
|
-
return `Basic ${Buffer.from(`${accountSid}:${authToken}`).toString("base64")}`;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function accountUrl({ accountSid }: TwilioCreds, suffix: string): string {
|
|
21
|
-
return `${TWILIO_BASE}/Accounts/${encodeURIComponent(accountSid)}${suffix}`;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Validate a Twilio webhook signature.
|
|
26
|
-
* Algorithm (per Twilio's webhook security docs):
|
|
27
|
-
* 1. Take the full URL Twilio sent the request to (including query string).
|
|
28
|
-
* 2. For application/x-www-form-urlencoded bodies, sort POST keys and
|
|
29
|
-
* append each "key" + "value" to the URL string.
|
|
30
|
-
* 3. HMAC-SHA1 with the account AuthToken, base64-encode.
|
|
31
|
-
* 4. Timing-safe compare with the X-Twilio-Signature header.
|
|
32
|
-
*/
|
|
33
|
-
export function validateTwilioSignature(opts: {
|
|
34
|
-
authToken: string;
|
|
35
|
-
fullUrl: string;
|
|
36
|
-
params: Record<string, string>;
|
|
37
|
-
signature: string;
|
|
38
|
-
}): boolean {
|
|
39
|
-
const { authToken, fullUrl, params, signature } = opts;
|
|
40
|
-
if (!signature) return false;
|
|
41
|
-
|
|
42
|
-
const sortedKeys = Object.keys(params).sort();
|
|
43
|
-
let data = fullUrl;
|
|
44
|
-
for (const key of sortedKeys) {
|
|
45
|
-
data += key + params[key];
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const computed = createHmac("sha1", authToken).update(data, "utf8").digest("base64");
|
|
49
|
-
const a = Buffer.from(computed);
|
|
50
|
-
const b = Buffer.from(signature);
|
|
51
|
-
if (a.length !== b.length) return false;
|
|
52
|
-
return timingSafeEqual(a, b);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export interface PlaceCallOpts extends TwilioCreds {
|
|
56
|
-
to: string;
|
|
57
|
-
from: string;
|
|
58
|
-
twimlUrl: string;
|
|
59
|
-
statusCallbackUrl?: string;
|
|
60
|
-
/** Hard cap on call duration (seconds). Maps to Twilio's TimeLimit param. */
|
|
61
|
-
maxDurationSec?: number;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export interface PlaceCallResult {
|
|
65
|
-
callSid: string;
|
|
66
|
-
status: string;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export async function placeCall(opts: PlaceCallOpts): Promise<PlaceCallResult> {
|
|
70
|
-
const body = new URLSearchParams({
|
|
71
|
-
To: opts.to,
|
|
72
|
-
From: opts.from,
|
|
73
|
-
Url: opts.twimlUrl,
|
|
74
|
-
Method: "POST",
|
|
75
|
-
});
|
|
76
|
-
if (opts.statusCallbackUrl) {
|
|
77
|
-
body.set("StatusCallback", opts.statusCallbackUrl);
|
|
78
|
-
body.set("StatusCallbackMethod", "POST");
|
|
79
|
-
body.append("StatusCallbackEvent", "initiated");
|
|
80
|
-
body.append("StatusCallbackEvent", "answered");
|
|
81
|
-
body.append("StatusCallbackEvent", "completed");
|
|
82
|
-
}
|
|
83
|
-
if (opts.maxDurationSec && opts.maxDurationSec > 0) {
|
|
84
|
-
body.set("TimeLimit", String(opts.maxDurationSec));
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const resp = await fetch(accountUrl(opts, "/Calls.json"), {
|
|
88
|
-
method: "POST",
|
|
89
|
-
headers: { Authorization: basicAuth(opts), "Content-Type": "application/x-www-form-urlencoded" },
|
|
90
|
-
body: body.toString(),
|
|
91
|
-
});
|
|
92
|
-
if (!resp.ok) {
|
|
93
|
-
const text = await resp.text().catch(() => "");
|
|
94
|
-
throw new Error(`Twilio placeCall failed: ${resp.status} ${text}`);
|
|
95
|
-
}
|
|
96
|
-
const data = (await resp.json()) as { sid: string; status: string };
|
|
97
|
-
return { callSid: data.sid, status: data.status };
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** Swap the TwiML URL on an in-flight call (used to inject the real callSid into the path). */
|
|
101
|
-
export async function updateCallUrl(opts: TwilioCreds & { callSid: string; url: string }): Promise<void> {
|
|
102
|
-
const body = new URLSearchParams({ Url: opts.url, Method: "POST" });
|
|
103
|
-
const resp = await fetch(accountUrl(opts, `/Calls/${encodeURIComponent(opts.callSid)}.json`), {
|
|
104
|
-
method: "POST",
|
|
105
|
-
headers: { Authorization: basicAuth(opts), "Content-Type": "application/x-www-form-urlencoded" },
|
|
106
|
-
body: body.toString(),
|
|
107
|
-
});
|
|
108
|
-
if (!resp.ok) {
|
|
109
|
-
const text = await resp.text().catch(() => "");
|
|
110
|
-
throw new Error(`Twilio updateCallUrl failed: ${resp.status} ${text}`);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export async function hangupCall(opts: TwilioCreds & { callSid: string }): Promise<void> {
|
|
115
|
-
const body = new URLSearchParams({ Status: "completed" });
|
|
116
|
-
const resp = await fetch(accountUrl(opts, `/Calls/${encodeURIComponent(opts.callSid)}.json`), {
|
|
117
|
-
method: "POST",
|
|
118
|
-
headers: { Authorization: basicAuth(opts), "Content-Type": "application/x-www-form-urlencoded" },
|
|
119
|
-
body: body.toString(),
|
|
120
|
-
});
|
|
121
|
-
if (!resp.ok) {
|
|
122
|
-
const text = await resp.text().catch(() => "");
|
|
123
|
-
throw new Error(`Twilio hangupCall failed: ${resp.status} ${text}`);
|
|
124
|
-
}
|
|
125
|
-
}
|