@signalhousellc/sdk 1.0.64 → 1.0.66
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 +19 -0
- package/package.json +13 -1
- package/scripts/install-skills.mjs +329 -0
- package/skills/VENDORED.json +22 -0
- package/skills/signalhouse-10dlc/SKILL.md +176 -0
- package/skills/signalhouse-10dlc/examples/brightsmile-onboarding.md +62 -0
- package/skills/signalhouse-10dlc/references/tcr-rejection-causes.md +114 -0
- package/skills/signalhouse-advisor/SKILL.md +84 -0
- package/skills/signalhouse-numbers/SKILL.md +83 -0
- package/skills/signalhouse-setup/SKILL.md +91 -0
- package/skills/signalhouse-sms/SKILL.md +81 -0
- package/skills/signalhouse-webhooks/SKILL.md +116 -0
- package/src/domains/Auth.js +36 -3
- package/src/domains/Brands.js +4 -4
- package/src/domains/Campaigns.js +21 -4
- package/src/domains/Groups.js +18 -1
- package/src/domains/Landings.js +84 -0
- package/src/domains/Messages.js +42 -21
- package/src/domains/Numbers.js +17 -3
- package/src/domains/Onboarding.js +7 -3
- package/src/domains/Subgroups.js +18 -0
- package/src/domains/Tickets.js +14 -0
- package/src/domains/voice/ProgrammableVoiceProfiles.js +4 -2
- package/src/domains/voice/Tokens.js +26 -0
- package/src/domains/voice/browser/Device.js +270 -24
|
@@ -11,9 +11,14 @@ import { Call } from "./Call.js";
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* @typedef {Object} VoiceTokenResponse
|
|
14
|
-
* @property {string} token - JWT (informational
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* @property {string} token - JWT (informational). Tokens are ephemeral: refresh
|
|
15
|
+
* before `expires_at` (see updateToken()) so the softphone keeps a valid
|
|
16
|
+
* registration.
|
|
17
|
+
* @property {string} identity - The wrtc_<hex> SIP username. The refresh
|
|
18
|
+
* endpoint (POST /voice/tokens/refresh) keeps this identity stable; a fresh
|
|
19
|
+
* mint (POST /voice/tokens) returns a new one.
|
|
20
|
+
* @property {string} expires_at - ISO timestamp. The Device schedules a
|
|
21
|
+
* `tokenWillExpire` event at this minus `tokenRefreshMs`.
|
|
17
22
|
* @property {SipCredentials} sip_credentials
|
|
18
23
|
*/
|
|
19
24
|
|
|
@@ -40,12 +45,33 @@ import { Call } from "./Call.js";
|
|
|
40
45
|
* const call = await device.connect({ to: "+15551234", from: "+15559999" });
|
|
41
46
|
* device.on("incoming", call => call.accept());
|
|
42
47
|
*
|
|
43
|
-
* Token
|
|
44
|
-
*
|
|
45
|
-
*
|
|
48
|
+
* Token refresh (REQUIRED for long-lived sessions): voice tokens are short-
|
|
49
|
+
* lived — a token's SIP credential authenticates only until `expires_at`. If
|
|
50
|
+
* you let it lapse, the softphone can no longer re-register and inbound calls
|
|
51
|
+
* stop arriving (they go to voicemail). The Device raises a `tokenWillExpire`
|
|
52
|
+
* event shortly before expiry so you can keep it alive. Mirrors Twilio's
|
|
53
|
+
* Device.updateToken() model:
|
|
54
|
+
*
|
|
55
|
+
* // Manual — fetch a fresh token from your backend and hand it back:
|
|
56
|
+
* device.on("tokenWillExpire", async () => {
|
|
57
|
+
* const fresh = await fetch("/my/backend/voice-token").then((r) => r.json());
|
|
58
|
+
* await device.updateToken(fresh);
|
|
59
|
+
* });
|
|
60
|
+
*
|
|
61
|
+
* // Automatic — let the Device refresh itself:
|
|
62
|
+
* const device = new Device(tokenResponse, {
|
|
63
|
+
* tokenProvider: () => fetch("/my/backend/voice-token").then((r) => r.json()),
|
|
64
|
+
* });
|
|
65
|
+
*
|
|
66
|
+
* Preferred: have your backend call POST /voice/tokens/refresh, which extends
|
|
67
|
+
* the SAME identity in place — the Device then just reschedules and keeps its
|
|
68
|
+
* existing registration (no re-register, seamless). If instead you mint a fresh
|
|
69
|
+
* token (new identity), the Device re-registers under it (break-before-make,
|
|
70
|
+
* deferred while a call is active).
|
|
46
71
|
*
|
|
47
72
|
* Events: "registered", "unregistered", "registrationFailed", "incoming",
|
|
48
|
-
* "connected" (WSS established), "disconnected", "
|
|
73
|
+
* "connected" (WSS established), "disconnected", "tokenWillExpire",
|
|
74
|
+
* "tokenRefreshFailed", "error".
|
|
49
75
|
*/
|
|
50
76
|
export class Device {
|
|
51
77
|
/**
|
|
@@ -54,25 +80,36 @@ export class Device {
|
|
|
54
80
|
* @param {number} [opts.registerExpires=600] - REGISTER expires interval, in seconds.
|
|
55
81
|
* @param {string} [opts.userAgent="SignalHouse-Voice-SDK/0.1"]
|
|
56
82
|
* @param {boolean} [opts.autoRegister=false] - Call register() on construct.
|
|
83
|
+
* @param {number} [opts.tokenRefreshMs=30000] - How long before `expires_at`
|
|
84
|
+
* to raise `tokenWillExpire` (and auto-refresh, when `tokenProvider` is set).
|
|
85
|
+
* @param {() => (VoiceTokenResponse|Promise<VoiceTokenResponse>)} [opts.tokenProvider]
|
|
86
|
+
* Optional. A function YOU supply that returns a fresh token — ideally by
|
|
87
|
+
* calling POST /voice/tokens/refresh through your backend (identity-
|
|
88
|
+
* preserving), or a fresh mint. When set, the Device calls it on
|
|
89
|
+
* `tokenWillExpire` and applies the result via updateToken() automatically.
|
|
57
90
|
*/
|
|
58
91
|
constructor(tokenOrCreds, opts = {}) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
throw new Error("Device: token/credentials missing required fields (username, password, domain, wss_url)");
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
this.identity = tokenOrCreds?.identity || creds.username;
|
|
92
|
+
this._credentials = this._parseCreds(tokenOrCreds, "Device");
|
|
93
|
+
this.identity = tokenOrCreds?.identity || this._credentials.username;
|
|
65
94
|
this.expiresAt = tokenOrCreds?.expires_at || null;
|
|
66
95
|
|
|
67
|
-
this._credentials = creds;
|
|
68
96
|
this._opts = {
|
|
69
97
|
registerExpires: opts.registerExpires ?? 600,
|
|
70
98
|
userAgent: opts.userAgent ?? "SignalHouse-Voice-SDK/0.1",
|
|
99
|
+
tokenRefreshMs: opts.tokenRefreshMs ?? 30000,
|
|
100
|
+
tokenProvider: typeof opts.tokenProvider === "function" ? opts.tokenProvider : null,
|
|
71
101
|
};
|
|
72
102
|
|
|
73
103
|
this._ua = null;
|
|
74
104
|
this._listeners = new Map();
|
|
75
105
|
this._registered = false;
|
|
106
|
+
this._stopped = false; // set by unregister(); guards in-flight refreshes
|
|
107
|
+
|
|
108
|
+
// Token-refresh state.
|
|
109
|
+
this._refreshTimer = null; // fires `tokenWillExpire` before expiry
|
|
110
|
+
this._activeSessions = new Set(); // in-flight calls; defer an identity swap while busy
|
|
111
|
+
this._pendingToken = null; // identity-change token handed to updateToken() while busy
|
|
112
|
+
this._refreshPendingAuto = false; // tokenProvider refresh deferred while busy
|
|
76
113
|
|
|
77
114
|
if (opts.autoRegister) this.register().catch(() => { /* surfaced via "registrationFailed" event */ });
|
|
78
115
|
}
|
|
@@ -81,10 +118,12 @@ export class Device {
|
|
|
81
118
|
|
|
82
119
|
/**
|
|
83
120
|
* Open the WSS connection and REGISTER. Resolves on first "registered"
|
|
84
|
-
* event, rejects on first "registrationFailed".
|
|
121
|
+
* event, rejects on first "registrationFailed". On success, schedules the
|
|
122
|
+
* `tokenWillExpire` refresh timer from the current token's `expires_at`.
|
|
85
123
|
* @returns {Promise<void>}
|
|
86
124
|
*/
|
|
87
125
|
register() {
|
|
126
|
+
this._stopped = false; // an intentional (re)start re-arms the device
|
|
88
127
|
if (this._registered) return Promise.resolve();
|
|
89
128
|
if (!this._ua) this._initUA();
|
|
90
129
|
|
|
@@ -92,6 +131,7 @@ export class Device {
|
|
|
92
131
|
const onOk = () => {
|
|
93
132
|
this._ua.removeListener("registered", onOk);
|
|
94
133
|
this._ua.removeListener("registrationFailed", onFail);
|
|
134
|
+
this._scheduleTokenRefresh();
|
|
95
135
|
resolve();
|
|
96
136
|
};
|
|
97
137
|
const onFail = (e) => {
|
|
@@ -106,18 +146,55 @@ export class Device {
|
|
|
106
146
|
}
|
|
107
147
|
|
|
108
148
|
/**
|
|
109
|
-
* Unregister and close the WSS connection. Idempotent.
|
|
149
|
+
* Unregister and close the WSS connection. Idempotent. Marks the device
|
|
150
|
+
* stopped so any in-flight token refresh cannot bring it back online, and
|
|
151
|
+
* cancels the refresh timer + pending state.
|
|
110
152
|
* @returns {Promise<void>}
|
|
111
153
|
*/
|
|
112
154
|
unregister() {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
155
|
+
this._stopped = true;
|
|
156
|
+
this._clearTokenRefresh();
|
|
157
|
+
this._pendingToken = null;
|
|
158
|
+
this._refreshPendingAuto = false;
|
|
159
|
+
return this._teardownUA();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Apply a fresh token so the registration never lapses — call this in
|
|
164
|
+
* response to `tokenWillExpire` (or let `tokenProvider` do it).
|
|
165
|
+
*
|
|
166
|
+
* Identity-preserving fast path: if the token carries the SAME identity +
|
|
167
|
+
* password as the current one (i.e. your backend called POST
|
|
168
|
+
* /voice/tokens/refresh to extend in place), the existing registration is
|
|
169
|
+
* still valid, so the Device just updates `expires_at` and reschedules — no
|
|
170
|
+
* re-register, fully seamless, safe even mid-call.
|
|
171
|
+
*
|
|
172
|
+
* Identity change (a fresh mint): the Device re-registers under the new
|
|
173
|
+
* identity (break-before-make), deferred while a call is active and applied
|
|
174
|
+
* once the last call ends, so live audio is never disrupted.
|
|
175
|
+
*
|
|
176
|
+
* @param {VoiceTokenResponse | SipCredentials} tokenOrCreds
|
|
177
|
+
* @returns {Promise<void>} resolves once applied (or immediately when a swap
|
|
178
|
+
* is deferred behind an active call).
|
|
179
|
+
*/
|
|
180
|
+
async updateToken(tokenOrCreds) {
|
|
181
|
+
const creds = this._parseCreds(tokenOrCreds, "Device.updateToken");
|
|
182
|
+
const newIdentity = tokenOrCreds?.identity || creds.username;
|
|
183
|
+
|
|
184
|
+
// Identity-preserving fast path — same credential, just extended expiry.
|
|
185
|
+
if (this._ua && this._registered && !this._stopped &&
|
|
186
|
+
newIdentity === this.identity && creds.password === this._credentials.password) {
|
|
187
|
+
this.expiresAt = tokenOrCreds?.expires_at || this.expiresAt;
|
|
188
|
+
this._scheduleTokenRefresh();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Identity changed → full re-register; defer while a call is active.
|
|
193
|
+
if (this._activeSessions.size > 0) {
|
|
194
|
+
this._pendingToken = tokenOrCreds;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
await this._applyToken(tokenOrCreds);
|
|
121
198
|
}
|
|
122
199
|
|
|
123
200
|
/**
|
|
@@ -162,6 +239,7 @@ export class Device {
|
|
|
162
239
|
extraHeaders: headers,
|
|
163
240
|
});
|
|
164
241
|
|
|
242
|
+
this._trackSession(session);
|
|
165
243
|
return new Call({ session, direction: "outbound" });
|
|
166
244
|
}
|
|
167
245
|
|
|
@@ -192,6 +270,173 @@ export class Device {
|
|
|
192
270
|
}
|
|
193
271
|
}
|
|
194
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Unwrap `sip_credentials` from a token response (or accept a bare creds
|
|
275
|
+
* object) and validate the four required SIP fields.
|
|
276
|
+
* @param {VoiceTokenResponse | SipCredentials} tokenOrCreds
|
|
277
|
+
* @param {string} ctx - Caller label used in the error message.
|
|
278
|
+
* @returns {SipCredentials}
|
|
279
|
+
* @throws {Error} when a required field is missing.
|
|
280
|
+
*/
|
|
281
|
+
_parseCreds(tokenOrCreds, ctx) {
|
|
282
|
+
const creds = tokenOrCreds?.sip_credentials || tokenOrCreds;
|
|
283
|
+
if (!creds?.username || !creds?.password || !creds?.domain || !creds?.wss_url) {
|
|
284
|
+
throw new Error(`${ctx}: token/credentials missing required fields (username, password, domain, wss_url)`);
|
|
285
|
+
}
|
|
286
|
+
return creds;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Stop the current UA and close its WSS, without touching lifecycle flags —
|
|
291
|
+
* used both by unregister() and by _applyToken()'s break-before-make swap.
|
|
292
|
+
* @returns {Promise<void>}
|
|
293
|
+
*/
|
|
294
|
+
_teardownUA() {
|
|
295
|
+
if (!this._ua) return Promise.resolve();
|
|
296
|
+
return new Promise((resolve) => {
|
|
297
|
+
const ua = this._ua;
|
|
298
|
+
const done = () => { try { ua.stop(); } catch {} resolve(); };
|
|
299
|
+
if (!this._registered) return done();
|
|
300
|
+
ua.once("unregistered", done);
|
|
301
|
+
try { ua.unregister(); }
|
|
302
|
+
catch { done(); }
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Re-register under fresh credentials when the identity changed
|
|
308
|
+
* (break-before-make). Only invoked when no call is active (updateToken
|
|
309
|
+
* defers while busy). Bails if the app tore the device down mid-swap; if the
|
|
310
|
+
* new registration fails, surfaces `tokenRefreshFailed` and re-arms a short
|
|
311
|
+
* retry so the device recovers rather than sitting unregistered.
|
|
312
|
+
* @param {VoiceTokenResponse | SipCredentials} tokenOrCreds
|
|
313
|
+
*/
|
|
314
|
+
async _applyToken(tokenOrCreds) {
|
|
315
|
+
const creds = this._parseCreds(tokenOrCreds, "Device.updateToken");
|
|
316
|
+
this._clearTokenRefresh();
|
|
317
|
+
try { await this._teardownUA(); } catch { /* ignore — tearing down anyway */ }
|
|
318
|
+
if (this._stopped) return; // unregister() ran while we were tearing down
|
|
319
|
+
|
|
320
|
+
this._ua = null;
|
|
321
|
+
this._registered = false;
|
|
322
|
+
this._credentials = creds;
|
|
323
|
+
this.identity = tokenOrCreds?.identity || creds.username;
|
|
324
|
+
this.expiresAt = tokenOrCreds?.expires_at || null;
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
await this.register(); // rebuilds the UA + reschedules the refresh timer
|
|
328
|
+
} catch (err) {
|
|
329
|
+
this._emit("tokenRefreshFailed", { error: err });
|
|
330
|
+
if (!this._stopped) {
|
|
331
|
+
this._clearTokenRefresh();
|
|
332
|
+
this._refreshTimer = setTimeout(() => this._onTokenWillExpire(), 5000);
|
|
333
|
+
}
|
|
334
|
+
throw err; // propagate to a manual updateToken() caller
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** `expires_at` as epoch ms, or null when unparseable/absent. */
|
|
339
|
+
_expiresAtMs() {
|
|
340
|
+
const t = Date.parse(this.expiresAt || "");
|
|
341
|
+
return Number.isFinite(t) ? t : null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* True if the given token has too little life left to safely apply (would
|
|
346
|
+
* expire within tokenRefreshMs). Tokens with no parseable expiry are treated
|
|
347
|
+
* as usable.
|
|
348
|
+
* @param {VoiceTokenResponse} tokenOrCreds
|
|
349
|
+
* @returns {boolean}
|
|
350
|
+
*/
|
|
351
|
+
_isTokenStale(tokenOrCreds) {
|
|
352
|
+
const exp = Date.parse(tokenOrCreds?.expires_at || "");
|
|
353
|
+
if (!Number.isFinite(exp)) return false;
|
|
354
|
+
return exp - Date.now() <= this._opts.tokenRefreshMs;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Arm the one-shot `tokenWillExpire` timer for tokenRefreshMs before expiry. */
|
|
358
|
+
_scheduleTokenRefresh() {
|
|
359
|
+
this._clearTokenRefresh();
|
|
360
|
+
const expMs = this._expiresAtMs();
|
|
361
|
+
if (expMs === null) return; // no expiry info → caller manages refresh manually
|
|
362
|
+
const delay = Math.max(0, expMs - Date.now() - this._opts.tokenRefreshMs);
|
|
363
|
+
this._refreshTimer = setTimeout(() => this._onTokenWillExpire(), delay);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Cancel the pending `tokenWillExpire` timer, if any. */
|
|
367
|
+
_clearTokenRefresh() {
|
|
368
|
+
if (this._refreshTimer) { clearTimeout(this._refreshTimer); this._refreshTimer = null; }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Fired shortly before the current token expires. Emits `tokenWillExpire`
|
|
373
|
+
* for manual handlers; if a `tokenProvider` is configured, also fetches a
|
|
374
|
+
* fresh token and applies it. Deferred while a call is active (the fetch
|
|
375
|
+
* happens only once idle, so a fetched token can't go stale in hand). Bails
|
|
376
|
+
* if the device was torn down while the fetch was in flight.
|
|
377
|
+
*/
|
|
378
|
+
async _onTokenWillExpire() {
|
|
379
|
+
if (this._stopped) return;
|
|
380
|
+
this._emit("tokenWillExpire", { identity: this.identity, expiresAt: this.expiresAt });
|
|
381
|
+
if (!this._opts.tokenProvider) return; // app handles it via updateToken()
|
|
382
|
+
if (this._activeSessions.size > 0) {
|
|
383
|
+
this._refreshPendingAuto = true; // fetch + apply once the call ends
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
const fresh = await this._opts.tokenProvider();
|
|
388
|
+
if (this._stopped) return; // torn down while the provider fetch was pending
|
|
389
|
+
if (!fresh) throw new Error("tokenProvider returned no token");
|
|
390
|
+
await this.updateToken(fresh);
|
|
391
|
+
} catch (err) {
|
|
392
|
+
if (this._stopped) return;
|
|
393
|
+
this._emit("tokenRefreshFailed", { error: err });
|
|
394
|
+
// Retry shortly so a transient failure doesn't strand us, provided
|
|
395
|
+
// there's still time before the current token expires.
|
|
396
|
+
const expMs = this._expiresAtMs();
|
|
397
|
+
if (expMs !== null && expMs - Date.now() > 5000) {
|
|
398
|
+
this._clearTokenRefresh();
|
|
399
|
+
this._refreshTimer = setTimeout(() => this._onTokenWillExpire(), 5000);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Track a call session so an identity swap can be deferred until it ends. */
|
|
405
|
+
_trackSession(session) {
|
|
406
|
+
if (!session) return;
|
|
407
|
+
this._activeSessions.add(session);
|
|
408
|
+
const done = () => this._untrackSession(session);
|
|
409
|
+
try { session.on("ended", done); session.on("failed", done); }
|
|
410
|
+
catch { /* not a JsSIP session — best-effort tracking only */ }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Stop tracking a finished session. When the line goes fully idle, apply any
|
|
415
|
+
* token refresh that was deferred behind the call: a `tokenProvider` fetch
|
|
416
|
+
* (fresh) or a stored identity-change token — re-fetching instead if that
|
|
417
|
+
* stored token has gone stale while the call ran.
|
|
418
|
+
* @param {object} session - The JsSIP session that ended/failed.
|
|
419
|
+
*/
|
|
420
|
+
_untrackSession(session) {
|
|
421
|
+
this._activeSessions.delete(session);
|
|
422
|
+
if (this._activeSessions.size > 0 || this._stopped) return;
|
|
423
|
+
|
|
424
|
+
if (this._refreshPendingAuto) {
|
|
425
|
+
this._refreshPendingAuto = false;
|
|
426
|
+
this._onTokenWillExpire();
|
|
427
|
+
} else if (this._pendingToken) {
|
|
428
|
+
const pending = this._pendingToken;
|
|
429
|
+
this._pendingToken = null;
|
|
430
|
+
// A long call can outlive the token we stashed — don't register with
|
|
431
|
+
// stale creds; get a fresh one the same way the timer would.
|
|
432
|
+
if (this._isTokenStale(pending)) {
|
|
433
|
+
this._onTokenWillExpire();
|
|
434
|
+
} else {
|
|
435
|
+
this._applyToken(pending).catch((err) => this._emit("tokenRefreshFailed", { error: err }));
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
195
440
|
_initUA() {
|
|
196
441
|
const socket = new JsSIP.WebSocketInterface(this._credentials.wss_url);
|
|
197
442
|
this._ua = new JsSIP.UA({
|
|
@@ -219,6 +464,7 @@ export class Device {
|
|
|
219
464
|
|
|
220
465
|
this._ua.on("newRTCSession", (e) => {
|
|
221
466
|
if (e.originator !== "remote") return; // outbound: we already wrap in connect()
|
|
467
|
+
this._trackSession(e.session);
|
|
222
468
|
const call = new Call({ session: e.session, direction: "inbound", request: e.request });
|
|
223
469
|
this._emit("incoming", call);
|
|
224
470
|
});
|