@signalhousellc/sdk 1.0.63 → 1.0.65
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/package.json +1 -1
- package/src/domains/Auth.js +36 -3
- package/src/domains/Brands.js +4 -4
- package/src/domains/Groups.js +18 -1
- package/src/domains/Messages.js +10 -10
- package/src/domains/Numbers.js +6 -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/SipProfiles.js +19 -13
- package/src/domains/voice/Tokens.js +26 -0
- package/src/domains/voice/browser/Device.js +270 -24
package/package.json
CHANGED
package/src/domains/Auth.js
CHANGED
|
@@ -2,6 +2,28 @@ export class Auth {
|
|
|
2
2
|
constructor(client, enableAdmin) {
|
|
3
3
|
this.client = client;
|
|
4
4
|
this.enableAdmin = enableAdmin;
|
|
5
|
+
|
|
6
|
+
// Hidden Admin namespace INSIDE the domain
|
|
7
|
+
if (enableAdmin) {
|
|
8
|
+
this.admin = {
|
|
9
|
+
/**
|
|
10
|
+
* Force-log-out every human user in a specific group (SHGHL-2740) - for account-takeover
|
|
11
|
+
* response, independent of the automatic Rejected-lockout trigger.
|
|
12
|
+
* @async
|
|
13
|
+
* @roles signalhouse
|
|
14
|
+
* @param {Object} params - The parameters for forcing a group logout
|
|
15
|
+
* @param {string} params.groupId - The ID of the group whose users should be force-logged-out
|
|
16
|
+
* @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
|
|
17
|
+
* @throws {Error} Throws an error if the groupId parameter is missing
|
|
18
|
+
* @returns {Promise<Object>} - The response containing loggedOutCount
|
|
19
|
+
*/
|
|
20
|
+
forceLogoutGroupUsers: async ({ groupId, options = {} }) => {
|
|
21
|
+
this.client._require({ groupId });
|
|
22
|
+
const safeGroupId = encodeURIComponent(groupId);
|
|
23
|
+
return this.client(`/auth/groups/${safeGroupId}/logout-all`, { method: "POST", ...options });
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
5
27
|
}
|
|
6
28
|
|
|
7
29
|
/**
|
|
@@ -47,13 +69,22 @@ export class Auth {
|
|
|
47
69
|
}
|
|
48
70
|
|
|
49
71
|
/**
|
|
50
|
-
* Verify an account email using the single-use token from the verification link
|
|
72
|
+
* Verify an account email using the single-use token from the verification link, signing the
|
|
73
|
+
* account in on success.
|
|
74
|
+
*
|
|
75
|
+
* The response carries a session JWT (`token`) for the user the link was emailed to, so a click
|
|
76
|
+
* from any device establishes a session — use it exactly as you would the token from `login`.
|
|
77
|
+
* `token` is omitted when the link cannot establish a session (issued before auto-login shipped,
|
|
78
|
+
* or the account is inactive/ineligible); `verified` is still true in that case, so treat a
|
|
79
|
+
* missing token as "verified, ask them to log in" rather than as a failure.
|
|
80
|
+
*
|
|
81
|
+
* Rate limited per source IP. The token is single-use and expires 24 hours after it is issued.
|
|
51
82
|
* @async
|
|
52
83
|
* @roles public
|
|
53
84
|
* @param {Object} params
|
|
54
85
|
* @param {string} params.token - The verification token from the email link
|
|
55
86
|
* @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
|
|
56
|
-
* @returns {Promise<Object>} The response from the server ({ verified: true })
|
|
87
|
+
* @returns {Promise<Object>} The response from the server ({ verified: true, token?: string })
|
|
57
88
|
*/
|
|
58
89
|
async verifyEmail({ token, options = {} }) {
|
|
59
90
|
this.client._require({ token });
|
|
@@ -92,7 +123,9 @@ export class Auth {
|
|
|
92
123
|
}
|
|
93
124
|
|
|
94
125
|
/**
|
|
95
|
-
* Resend the account verification email to the authenticated caller
|
|
126
|
+
* Resend the account verification email to the authenticated caller.
|
|
127
|
+
*
|
|
128
|
+
* Rate limited per user; issuing a new link invalidates the previous one.
|
|
96
129
|
* @async
|
|
97
130
|
* @roles admin, developer, billing, user
|
|
98
131
|
* @param {Object} [params]
|
package/src/domains/Brands.js
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* @property {string} vertical - The vertical for the brand (PROFESSIONAL, REAL_ESTATE, HEALTHCARE, HUMAN_RESOURCES, ENERGY, ENTERTAINMENT, RETAIL, TRANSPORTATION, AGRICULTURE, INSURANCE, POSTAL, EDUCATION, HOSPITALITY, FINANCIAL, POLITICAL, GAMBLING, LEGAL, CONSTRUCTION, NGO, MANUFACTURING, GOVERNMENT, TECHNOLOGY, COMMUNICATION)
|
|
31
31
|
* @property {string} [referenceId] - A unique reference for the brand
|
|
32
32
|
* @property {Array<string>} [tag] - An array of tags to associate with the brand
|
|
33
|
-
* @property {boolean} [mock] - Whether to
|
|
33
|
+
* @property {boolean} [mock] - Whether to register the brand in TCR's mock environment instead of the live registry. TCR is still called and the mock brand auto-approves, so it does not exercise the rejection states a live registration can hit. Brand creation is not billed; campaign registration and number purchase are still billed as normal
|
|
34
34
|
* @property {string} [businessContactEmail] - The email address of the business contact for the brand (required if entityType is PUBLIC_PROFIT or NON_PROFIT)
|
|
35
35
|
*/
|
|
36
36
|
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
* @property {string} [altBusinessIdType] - The type of the alternative business identifier ("NONE", "DUNS", "LEI", "GIIN")
|
|
60
60
|
* @property {string} [brandRelationship] - Not in use - reserved for future use
|
|
61
61
|
* @property {string} [businessContactEmail] - The email address of the business contact for the brand (required if entityType is PUBLIC_PROFIT or NON_PROFIT)
|
|
62
|
-
* @property {Object} [tollFree] - Toll-Free editable fields, used when updating a Toll-Free brand. Optional sub-fields: legalEntityType, businessRegistrationType, taxId, countryCode, supportPhone, taxIdIssuingCountry, businessDBA, businessWebsite. (Toll-Free brands sync to Infobip; only DCA_PENDING/DCA_ACTIVE brands are editable and subgroupId is immutable.)
|
|
62
|
+
* @property {Object} [tollFree] - Toll-Free editable fields, used when updating a Toll-Free brand. Optional sub-fields: legalEntityType, businessRegistrationType, taxId, countryCode, supportPhone, taxIdIssuingCountry, businessDBA, businessWebsite. supportPhone must be E.164 with an optional leading "+" and no spaces, parentheses or hyphens (e.g. "17025550100"). (Toll-Free brands sync to Infobip; only DCA_PENDING/DCA_ACTIVE brands are editable and subgroupId is immutable.)
|
|
63
63
|
*/
|
|
64
64
|
|
|
65
65
|
/**
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
* @property {string} legalEntityType - The legal entity type (required, one of PRIVATE_COMPANY, PUBLIC_COMPANY, NON_PROFIT, GOVERNMENT, SOLE_PROPRIETOR)
|
|
69
69
|
* @property {string} taxId - The business tax identifier (required, at most 50 characters)
|
|
70
70
|
* @property {string} countryCode - The business country code (required, 2-letter ISO code)
|
|
71
|
-
* @property {string} supportPhone - The customer-support phone number (required,
|
|
71
|
+
* @property {string} supportPhone - The customer-support phone number (required, E.164 with an optional leading "+" and no spaces, parentheses or hyphens, e.g. "17025550100")
|
|
72
72
|
* @property {string} [taxIdIssuingCountry] - The country that issued the tax ID (optional, 2-letter ISO code)
|
|
73
73
|
* @property {string} [businessDBA] - The business "doing business as" name (optional, at most 255 characters)
|
|
74
74
|
* @property {string} [businessWebsite] - The customer's real website, sent to Infobip for brand verification (optional, valid URL, at most 255 characters; distinct from the top-level website / landing URL)
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
* @property {TollFreeBrandDetails} tollFree - The Toll-Free brand details (required)
|
|
91
91
|
* @property {string} [referenceId] - A unique reference for the brand (optional, at most 50 characters)
|
|
92
92
|
* @property {Array<string>} [tag] - An array of tags to associate with the brand (optional)
|
|
93
|
-
* @property {boolean} [mock] -
|
|
93
|
+
* @property {boolean} [mock] - Accepted and stored, but has no effect on Toll-Free registration: Toll-Free does not use TCR and is already free
|
|
94
94
|
* @property {string} [optInLink] - A URL linking to the opt-in flow for the brand (optional, valid URL, at most 255 characters)
|
|
95
95
|
* @property {string} [privacyPolicyLink] - A URL linking to the privacy policy for the brand (optional, valid URL, at most 255 characters)
|
|
96
96
|
* @property {string} [termsAndConditionsLink] - A URL linking to the terms and conditions for the brand (optional, valid URL, at most 255 characters)
|
package/src/domains/Groups.js
CHANGED
|
@@ -6,6 +6,23 @@ export class Groups {
|
|
|
6
6
|
// Hidden Admin namespace INSIDE the domain
|
|
7
7
|
if (enableAdmin) {
|
|
8
8
|
this.admin = {
|
|
9
|
+
/**
|
|
10
|
+
* Get resource counts grouped by group ID. Only signalhouse_admin can query all groups;
|
|
11
|
+
* every other allowed role is restricted to its JWT group scope.
|
|
12
|
+
* @async
|
|
13
|
+
* @roles signalhouse, api, admin, developer, billing, user
|
|
14
|
+
* @param {Object} [params] - Optional group-count filters and pagination.
|
|
15
|
+
* @param {string} [params.groupId] - Restrict results to a group.
|
|
16
|
+
* @param {number} [params.limit] - Maximum rows to return.
|
|
17
|
+
* @param {number} [params.offset] - Rows to skip; requires limit.
|
|
18
|
+
* @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
|
|
19
|
+
* @returns {Promise<Object>} - Group-level resource count rows.
|
|
20
|
+
*/
|
|
21
|
+
getGroupCounts: async ({ groupId, limit, offset, options = {} } = {}) => {
|
|
22
|
+
const queryString = this.client._getQueryString({ groupId, limit, offset });
|
|
23
|
+
return this.client(`/group/counts${queryString}`, { method: "GET", ...options });
|
|
24
|
+
},
|
|
25
|
+
|
|
9
26
|
/**
|
|
10
27
|
* Get a list of all groups with optional pagination parameters
|
|
11
28
|
* @async
|
|
@@ -27,7 +44,7 @@ export class Groups {
|
|
|
27
44
|
* @async
|
|
28
45
|
* @roles signalhouse
|
|
29
46
|
* @param {Object} params - The parameters for creating a new group
|
|
30
|
-
* @param {Object} params.groupData - The data for the new group, including required fields such as groupName
|
|
47
|
+
* @param {Object} params.groupData - The data for the new group, including required fields such as groupName. Optionally accepts `psPartnerKey` and `ownerEmail` to credit a referring partner.
|
|
31
48
|
* @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
|
|
32
49
|
* @throws {Error} Throws an error if the groupData parameter is missing or if required fields within groupData are missing
|
|
33
50
|
* @returns {Promise<Object>} - The created group object returned from the server
|
package/src/domains/Messages.js
CHANGED
|
@@ -18,10 +18,10 @@ export class Messages {
|
|
|
18
18
|
* @param {string} [params.status] - Filter messages by their status (ENQUEUED, DEQUEUED, SENT, FAILED, DELIVERED)
|
|
19
19
|
* @param {string} [params.direction] - Filter messages by their direction (INBOUND, OUTBOUND)
|
|
20
20
|
* @param {string|string[]} [params.messageType] - Filter messages by their type. Accepts a single value or an array (e.g. ["SMS", "MMS"]). Allowed values: SMS, MMS, RCS, WHATSAPP, VIBER, P2P. When omitted, defaults to all non-P2P types.
|
|
21
|
-
* @param {string|string[]} [params.channel] - Filter messages by channel. Accepts a single value or an array. Allowed values: tenDLC, tollFree, p2p. Each message records its channel when it is saved, and this filters on that stored value; older messages from before this feature have no channel and count as tenDLC. Ignored when matchAny is true (matchAny takes precedence).
|
|
21
|
+
* @param {string|string[]} [params.channel] - Filter messages by channel. Accepts a single value or an array. Allowed values: tenDLC, tollFree, shortCode, p2p. Each message records its channel when it is saved, and this filters on that stored value; older messages from before this feature have no channel and count as tenDLC. Ignored when matchAny is true (matchAny takes precedence).
|
|
22
22
|
* @param {boolean} [params.matchAny] - When true, the provided campaignId/brandId/phoneNumber filters are OR'd (a message matches ANY of them) instead of the default most-specific-single behavior. subgroupId stays an AND scope. Opt-in/additive.
|
|
23
23
|
* @param {string} [params.carrier] - Filter messages by the carrier used for sending
|
|
24
|
-
* @param {string} [params.senderPhoneNumber] - Filter messages by the sender's phone number
|
|
24
|
+
* @param {string} [params.senderPhoneNumber] - Filter messages by the sender's 5-6 digit Short Code or 10+ digit phone number
|
|
25
25
|
* @param {string} [params.recipientPhoneNumber] - Filter messages by the recipient's phone number
|
|
26
26
|
* @param {string} [params.startDate] - ISO-8601 date or timestamp; normalized to start-of-UTC-day (inclusive)
|
|
27
27
|
* @param {string} [params.endDate] - ISO-8601 date or timestamp; normalized to end-of-UTC-day (inclusive). Hourly resolution is not supported
|
|
@@ -94,7 +94,7 @@ export class Messages {
|
|
|
94
94
|
* @param {string} [params.startDate] - ISO-8601 date or timestamp; normalized to start-of-UTC-day (inclusive)
|
|
95
95
|
* @param {string} [params.endDate] - ISO-8601 date or timestamp; normalized to end-of-UTC-day (inclusive). Hourly resolution is not supported
|
|
96
96
|
* @param {boolean} [params.matchAny] - When true, the provided campaignId/brandId/phoneNumber filters are OR'd (match ANY) instead of the default most-specific-single behavior. subgroupId stays an AND scope. Opt-in/additive.
|
|
97
|
-
* @param {string|string[]} [params.channel] - Filter analytics by channel (tenDLC, tollFree, p2p). Filters on the stored channel column; a tenDLC selection also includes older messages that have no channel, and p2p is a real channel here (matched by carrier). Accepts a single value or an array. Ignored when matchAny is true (matchAny takes precedence).
|
|
97
|
+
* @param {string|string[]} [params.channel] - Filter analytics by channel (tenDLC, tollFree, shortCode, p2p). Filters on the stored channel column; a tenDLC selection also includes older messages that have no channel, and p2p is a real channel here (matched by carrier). Accepts a single value or an array. Ignored when matchAny is true (matchAny takes precedence).
|
|
98
98
|
* @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
|
|
99
99
|
* @returns {Promise<Object>} - A promise that resolves to the analytics data
|
|
100
100
|
*/
|
|
@@ -117,7 +117,7 @@ export class Messages {
|
|
|
117
117
|
* @param {string} [params.carrier] - Filter analytics by the carrier used for sending
|
|
118
118
|
* @param {string} [params.startDate] - ISO-8601 date or timestamp; normalized to start-of-UTC-day (inclusive)
|
|
119
119
|
* @param {string} [params.endDate] - ISO-8601 date or timestamp; normalized to end-of-UTC-day (inclusive). Hourly resolution is not supported
|
|
120
|
-
* @param {string|string[]} [params.channel] - Filter analytics by channel (tenDLC, tollFree, p2p). Filters on the stored channel column; a tenDLC selection also includes older messages that have no channel, and p2p is a real channel here. Accepts a single value or an array.
|
|
120
|
+
* @param {string|string[]} [params.channel] - Filter analytics by channel (tenDLC, tollFree, shortCode, p2p). Filters on the stored channel column; a tenDLC selection also includes older messages that have no channel, and p2p is a real channel here. Accepts a single value or an array.
|
|
121
121
|
* @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
|
|
122
122
|
* @returns {Promise<Array>} - A promise that resolves to an array of analytics snapshot records
|
|
123
123
|
*/
|
|
@@ -159,7 +159,7 @@ export class Messages {
|
|
|
159
159
|
* @param {string} [params.endDate]
|
|
160
160
|
* @param {number} [params.page=1]
|
|
161
161
|
* @param {number} [params.limit=50] - max 50 per page
|
|
162
|
-
* @param {("tenDLC"|"tollFree"|"p2p"|"both")|Array<"tenDLC"|"tollFree"|"p2p">} [params.channel="both"] - Filter and rank rows by the stored channel column. "both" (default) means all activity. tenDLC/tollFree/p2p (single value or array) limit to the selected channel(s)
|
|
162
|
+
* @param {("tenDLC"|"tollFree"|"shortCode"|"p2p"|"both")|Array<"tenDLC"|"tollFree"|"shortCode"|"p2p">} [params.channel="both"] - Filter and rank rows by the stored channel column. "both" (default) means all activity. tenDLC/tollFree/shortCode/p2p (single value or array) limit to the selected channel(s).
|
|
163
163
|
* @param {import('../SignalHouseSDK').RequestOptions} [params.options]
|
|
164
164
|
* @returns {Promise<Object>} - { rows, totalCount, page, limit }. Each row carries sms/mms/p2p metric columns plus `smsOptOuts` and `mmsOptOuts` from the DNC analytics MV (P2P has no opt-outs).
|
|
165
165
|
*/
|
|
@@ -185,7 +185,7 @@ export class Messages {
|
|
|
185
185
|
* @param {string} [params.endDate]
|
|
186
186
|
* @param {number} [params.page=1]
|
|
187
187
|
* @param {number} [params.limit=50] - max 50 per page
|
|
188
|
-
* @param {("tenDLC"|"tollFree"|"p2p"|"both")|Array<"tenDLC"|"tollFree"|"p2p">} [params.channel="both"] - Filter and rank rows by the stored channel column. "both" (default) means every code. tenDLC/tollFree/p2p (single value or array) limit to the selected channel(s); when one channel is selected, totalErrors reflects only that channel.
|
|
188
|
+
* @param {("tenDLC"|"tollFree"|"shortCode"|"p2p"|"both")|Array<"tenDLC"|"tollFree"|"shortCode"|"p2p">} [params.channel="both"] - Filter and rank rows by the stored channel column. "both" (default) means every code. tenDLC/tollFree/shortCode/p2p (single value or array) limit to the selected channel(s); when one channel is selected, totalErrors reflects only that channel.
|
|
189
189
|
* @param {import('../SignalHouseSDK').RequestOptions} [params.options]
|
|
190
190
|
* @returns {Promise<Object>} - { rows, totalCount, totalErrors: { sms, mms, p2p, all }, page, limit }
|
|
191
191
|
*/
|
|
@@ -208,7 +208,7 @@ export class Messages {
|
|
|
208
208
|
* @param {string} [params.carrier] - Filter by the carrier
|
|
209
209
|
* @param {string} [params.startDate] - ISO-8601 date or timestamp; normalized to start-of-UTC-day (inclusive)
|
|
210
210
|
* @param {string} [params.endDate] - ISO-8601 date or timestamp; normalized to end-of-UTC-day (inclusive). Hourly resolution is not supported
|
|
211
|
-
* @param {string|string[]} [params.channel] - Filter by channel (tenDLC, tollFree). Opt-outs are A2P-only, so p2p applies no filter. Filters on the stored channel column; a tenDLC selection also includes older opt-outs that have no channel.
|
|
211
|
+
* @param {string|string[]} [params.channel] - Filter by channel (tenDLC, tollFree, shortCode). Opt-outs are A2P-only, so p2p applies no filter. Filters on the stored channel column; a tenDLC selection also includes older opt-outs that have no channel.
|
|
212
212
|
* @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
|
|
213
213
|
* @returns {Promise<Object>} - A promise that resolves to the DNC analytics data (totals, byDate, byPhoneNumber, byCarrier)
|
|
214
214
|
*/
|
|
@@ -235,7 +235,7 @@ export class Messages {
|
|
|
235
235
|
* @param {number} [params.limit] - Number of records per page
|
|
236
236
|
* @param {string} [params.sortField] - Field to sort by
|
|
237
237
|
* @param {string} [params.sortOrder] - Sort direction (asc, desc)
|
|
238
|
-
* @param {string|string[]} [params.channel] - Filter by channel (tenDLC, tollFree). Opt-outs are A2P-only, so p2p applies no filter. Filters on the stored channel column; a tenDLC selection also includes older opt-outs that have no channel.
|
|
238
|
+
* @param {string|string[]} [params.channel] - Filter by channel (tenDLC, tollFree, shortCode). Opt-outs are A2P-only, so p2p applies no filter. Filters on the stored channel column; a tenDLC selection also includes older opt-outs that have no channel.
|
|
239
239
|
* @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
|
|
240
240
|
* @returns {Promise<Object>} - A promise that resolves to the paginated DNC records
|
|
241
241
|
*/
|
|
@@ -260,7 +260,7 @@ export class Messages {
|
|
|
260
260
|
* @async
|
|
261
261
|
* @roles api, admin, developer, billing, user
|
|
262
262
|
* @param {Object} params - The parameters for sending an SMS message
|
|
263
|
-
* @param {string} params.senderPhoneNumber - The phone number to send the message from
|
|
263
|
+
* @param {string} params.senderPhoneNumber - The 5-6 digit Short Code or 10+ digit phone number to send the message from
|
|
264
264
|
* @param {string|string[]} params.recipientPhoneNumbers - The phone number(s) to send the message to
|
|
265
265
|
* @param {string} params.messageBody - The body of the SMS message
|
|
266
266
|
* @param {string} [params.statusCallbackUrl] - The URL to receive status callbacks
|
|
@@ -341,7 +341,7 @@ export class Messages {
|
|
|
341
341
|
* @async
|
|
342
342
|
* @roles api, admin, developer, billing, user
|
|
343
343
|
* @param {Object} params - The parameters for sending an MMS message
|
|
344
|
-
* @param {string} params.senderPhoneNumber - The phone number to send the message from
|
|
344
|
+
* @param {string} params.senderPhoneNumber - The 5-6 digit Short Code or 10+ digit phone number to send the message from
|
|
345
345
|
* @param {string[]} params.recipientPhoneNumbers - The phone number(s) to send the message to
|
|
346
346
|
* @param {string} params.messageBody - The body of the MMS message
|
|
347
347
|
* @param {string[]} [params.mediaUrls] - The URLs of the media attachments
|
package/src/domains/Numbers.js
CHANGED
|
@@ -339,15 +339,18 @@ export class Numbers {
|
|
|
339
339
|
}
|
|
340
340
|
|
|
341
341
|
/**
|
|
342
|
-
* Assign phone numbers to a campaign by providing the campaign ID and a list of phone numbers to assign
|
|
342
|
+
* Assign phone numbers to a campaign by providing the campaign ID and a list of phone numbers to assign.
|
|
343
|
+
* Numbers that are not found, already RELEASED, already assigned to a different campaign, on a mismatched
|
|
344
|
+
* DCA, or (for a 10DLC campaign without a number pool) beyond the remaining 49-number-per-campaign limit
|
|
345
|
+
* are not queued - each is reported back individually in `skipped` instead of failing the whole request.
|
|
343
346
|
* @async
|
|
344
347
|
* @roles api, admin, developer, billing, user
|
|
345
348
|
* @param {Object} params - The parameters for assigning phone numbers to a campaign
|
|
346
349
|
* @param {string} params.campaignId - The ID of the campaign to assign phone numbers to
|
|
347
350
|
* @param {string[]} params.phoneNumbers - The list of phone numbers to assign to the campaign
|
|
348
351
|
* @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
|
|
349
|
-
* @throws {Error} Throws an error if the campaignId or phoneNumbers parameter is missing
|
|
350
|
-
* @returns {Promise<Object>} A promise that resolves to
|
|
352
|
+
* @throws {Error} Throws an error if the campaignId or phoneNumbers parameter is missing, if none of the phone numbers exist in the account, or if the campaign is not found or in a terminal state
|
|
353
|
+
* @returns {Promise<Object>} A promise that resolves to `{ Message, queued, skipped }` - `queued` numbers were sent for assignment; `skipped` numbers were rejected (not found, RELEASED, already assigned to a campaign, cross-DCA mismatch, or over the 49-number limit).
|
|
351
354
|
*/
|
|
352
355
|
async assignPhoneNumberToCampaign({ campaignId, phoneNumbers, options = {} }) {
|
|
353
356
|
this.client._require({ campaignId, phoneNumbers });
|
|
@@ -38,12 +38,16 @@ export class Onboarding {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* Get the caller's own onboarding record (customer-safe projection)
|
|
42
|
-
*
|
|
41
|
+
* Get the caller's own onboarding record (customer-safe projection) — the selections captured at
|
|
42
|
+
* signup plus the gate flags derived from them. Never returns internal KYC, staff assignment,
|
|
43
|
+
* referral, or external-reference data.
|
|
44
|
+
*
|
|
45
|
+
* Staff roles and sessions with no active group receive a synthetic verified, selection-free
|
|
46
|
+
* record rather than an error, so callers never have to special-case them.
|
|
43
47
|
* @async
|
|
44
48
|
* @param {Object} [params] - Optional parameters
|
|
45
49
|
* @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
|
|
46
|
-
* @returns {Promise<Object>} - `{ acceptedTermsOfService: boolean, emailVerified: boolean }`
|
|
50
|
+
* @returns {Promise<Object>} - `{ acceptedTermsOfService: boolean, emailVerified: boolean, products: string[], estimatedVolume: string|null, pendingExternalLink: "ghl"|"shopify"|null }`
|
|
47
51
|
*/
|
|
48
52
|
async getMyOnboarding({ options = {} } = {}) {
|
|
49
53
|
return this.client(`/group/onboarding/my`, { method: "GET", ...options });
|
package/src/domains/Subgroups.js
CHANGED
|
@@ -60,6 +60,24 @@ export class Subgroups {
|
|
|
60
60
|
return this.client(`/subgroup${queryString}`, { method: "GET", ...options });
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Get resource counts grouped by subgroup ID. Only signalhouse_admin can query all groups;
|
|
65
|
+
* every other allowed role is restricted to its JWT group scope.
|
|
66
|
+
* @async
|
|
67
|
+
* @roles signalhouse, api, admin, developer, billing, user
|
|
68
|
+
* @param {Object} [params={}] - Optional count filters.
|
|
69
|
+
* @param {string} [params.groupId] - Restrict results to a group.
|
|
70
|
+
* @param {string} [params.subgroupId] - Restrict results to a subgroup.
|
|
71
|
+
* @param {number} [params.limit] - Maximum rows to return.
|
|
72
|
+
* @param {number} [params.offset] - Rows to skip; requires limit.
|
|
73
|
+
* @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
|
|
74
|
+
* @returns {Promise<Object>} - Subgroup-level resource count rows.
|
|
75
|
+
*/
|
|
76
|
+
async getSubgroupCounts({ groupId, subgroupId, limit, offset, options = {} } = {}) {
|
|
77
|
+
const queryString = this.client._getQueryString({ groupId, subgroupId, limit, offset });
|
|
78
|
+
return this.client(`/subgroup/counts${queryString}`, { method: "GET", ...options });
|
|
79
|
+
}
|
|
80
|
+
|
|
63
81
|
/**
|
|
64
82
|
* Create a new subgroup with the specified subgroup data
|
|
65
83
|
* @async
|
package/src/domains/Tickets.js
CHANGED
|
@@ -32,6 +32,20 @@ export class Tickets {
|
|
|
32
32
|
return this.client(`/admin/ticket/jira/epics${queryString}`, { method: "GET", ...options });
|
|
33
33
|
},
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Search open non-Epic, non-Subtask issues for the Subtask parent picker. Staff-only.
|
|
37
|
+
* @async
|
|
38
|
+
* @roles signalhouse
|
|
39
|
+
* @param {Object} [params]
|
|
40
|
+
* @param {string} [params.query] - Optional issue summary/key filter
|
|
41
|
+
* @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
|
|
42
|
+
* @returns {Promise<Object>} - `{ issues: Array<{ id, key, summary }> }`
|
|
43
|
+
*/
|
|
44
|
+
searchJiraParentIssues: async ({ query, options = {} } = {}) => {
|
|
45
|
+
const queryString = this.client._getQueryString({ query });
|
|
46
|
+
return this.client(`/admin/ticket/jira/parent-issues${queryString}`, { method: "GET", ...options });
|
|
47
|
+
},
|
|
48
|
+
|
|
35
49
|
/**
|
|
36
50
|
* Search assignable Jira users for the assignee picker. Staff-only.
|
|
37
51
|
* @async
|
|
@@ -147,44 +147,50 @@ export class SipProfiles {
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
/**
|
|
150
|
-
* Assign a phone number to this SIP profile
|
|
151
|
-
* number to the endpoint)
|
|
150
|
+
* Assign a phone number to this SIP profile — routes inbound calls on that
|
|
151
|
+
* number to the endpoint. Prefer `e164` (numbers are Mongo-authoritative); the
|
|
152
|
+
* legacy `phoneNumberId` (a voice-backend phone-number UUID) is still accepted.
|
|
152
153
|
* @async
|
|
153
154
|
* @roles api, admin, developer, billing, user
|
|
154
155
|
* @param {Object} params - Request parameters.
|
|
155
156
|
* @param {string} params.id - The SIP profile UUID.
|
|
156
|
-
* @param {string} params.
|
|
157
|
+
* @param {string} [params.e164] - The phone number in E.164 to assign (preferred).
|
|
158
|
+
* @param {string} [params.phoneNumberId] - Legacy: a voice-backend phone number UUID.
|
|
157
159
|
* @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
|
|
158
|
-
* @throws {Error} If id or phoneNumberId is
|
|
160
|
+
* @throws {Error} If id is missing, or neither e164 nor phoneNumberId is provided.
|
|
159
161
|
* @returns {Promise<Object>} `{ sipProfile: {...} }`.
|
|
160
162
|
*/
|
|
161
|
-
async assignNumber({ id, phoneNumberId, options = {} }) {
|
|
162
|
-
this.client._require({ id
|
|
163
|
+
async assignNumber({ id, e164, phoneNumberId, options = {} }) {
|
|
164
|
+
this.client._require({ id });
|
|
165
|
+
if (!e164 && !phoneNumberId) this.client._require({ e164 });
|
|
163
166
|
const safeId = encodeURIComponent(id);
|
|
164
167
|
return this.client(`/voice/sip-profiles/${safeId}/assign-number`, {
|
|
165
168
|
method: "POST",
|
|
166
|
-
body: { phoneNumberId },
|
|
169
|
+
body: e164 ? { e164 } : { phoneNumberId },
|
|
167
170
|
...options,
|
|
168
171
|
});
|
|
169
172
|
}
|
|
170
173
|
|
|
171
174
|
/**
|
|
172
|
-
* Unassign a phone number from this SIP profile.
|
|
175
|
+
* Unassign a phone number from this SIP profile. Prefer `e164`; the legacy
|
|
176
|
+
* `phoneNumberId` (a voice-backend phone-number UUID) is still accepted.
|
|
173
177
|
* @async
|
|
174
178
|
* @roles api, admin, developer, billing, user
|
|
175
179
|
* @param {Object} params - Request parameters.
|
|
176
180
|
* @param {string} params.id - The SIP profile UUID.
|
|
177
|
-
* @param {string} params.
|
|
181
|
+
* @param {string} [params.e164] - The phone number in E.164 to unassign (preferred).
|
|
182
|
+
* @param {string} [params.phoneNumberId] - Legacy: a voice-backend phone number UUID.
|
|
178
183
|
* @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
|
|
179
|
-
* @throws {Error} If id or phoneNumberId is
|
|
184
|
+
* @throws {Error} If id is missing, or neither e164 nor phoneNumberId is provided.
|
|
180
185
|
* @returns {Promise<Object>} `{ sipProfile: {...} }`.
|
|
181
186
|
*/
|
|
182
|
-
async unassignNumber({ id, phoneNumberId, options = {} }) {
|
|
183
|
-
this.client._require({ id
|
|
187
|
+
async unassignNumber({ id, e164, phoneNumberId, options = {} }) {
|
|
188
|
+
this.client._require({ id });
|
|
189
|
+
if (!e164 && !phoneNumberId) this.client._require({ e164 });
|
|
184
190
|
const safeId = encodeURIComponent(id);
|
|
185
191
|
return this.client(`/voice/sip-profiles/${safeId}/unassign-number`, {
|
|
186
192
|
method: "POST",
|
|
187
|
-
body: { phoneNumberId },
|
|
193
|
+
body: e164 ? { e164 } : { phoneNumberId },
|
|
188
194
|
...options,
|
|
189
195
|
});
|
|
190
196
|
}
|
|
@@ -61,4 +61,30 @@ export class Tokens {
|
|
|
61
61
|
async create({ tokenData = {}, options = {} } = {}) {
|
|
62
62
|
return this.client(`/voice/tokens`, { method: "POST", body: tokenData, ...options });
|
|
63
63
|
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Refresh (extend) an existing voice token in place — identity-preserving.
|
|
67
|
+
* Wraps voice-backend's `/voice/tokens/refresh`. Keeps the same wrtc_
|
|
68
|
+
* identity + SIP credentials and only pushes out `expires_at`, so a
|
|
69
|
+
* long-lived softphone stays registered without re-establishing its SIP
|
|
70
|
+
* registration (unlike `create()`, which mints a NEW identity). Provide the
|
|
71
|
+
* current `token` (its identity is decoded server-side) or the `identity`
|
|
72
|
+
* directly. Rejects with a 404 when the token no longer exists for the
|
|
73
|
+
* account — mint a fresh one with `create()` in that case.
|
|
74
|
+
* @async
|
|
75
|
+
* @roles api, admin, developer, billing, user
|
|
76
|
+
* @param {Object} [params] - Request parameters.
|
|
77
|
+
* @param {string} [params.token] - The current voice token (JWT) whose identity to extend.
|
|
78
|
+
* @param {string} [params.identity] - The wrtc_ identity to extend, if held directly (from a prior mint/refresh response).
|
|
79
|
+
* @param {number} [params.ttl] - New TTL in seconds. Defaults to the server default, capped at the server maximum.
|
|
80
|
+
* @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
|
|
81
|
+
* @returns {Promise<CreateTokenResponse>} The refreshed token + embedded SIP credentials (same shape as create()).
|
|
82
|
+
*/
|
|
83
|
+
async refresh({ token, identity, ttl, options = {} } = {}) {
|
|
84
|
+
const body = {};
|
|
85
|
+
if (token) body.token = token;
|
|
86
|
+
if (identity) body.identity = identity;
|
|
87
|
+
if (ttl != null) body.ttl = ttl;
|
|
88
|
+
return this.client(`/voice/tokens/refresh`, { method: "POST", body, ...options });
|
|
89
|
+
}
|
|
64
90
|
}
|
|
@@ -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
|
});
|