@signalhousellc/sdk 1.0.52 → 1.0.53

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 CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@signalhousellc/sdk",
3
- "version": "1.0.52",
3
+ "version": "1.0.53",
4
4
  "description": "Signal House SDK for use with the Signal House platform",
5
5
  "type": "module",
6
6
  "main": "src/SignalHouseSDK.js",
7
7
  "exports": {
8
- ".": "./src/SignalHouseSDK.js"
8
+ ".": "./src/SignalHouseSDK.js",
9
+ "./voice-browser": "./src/domains/voice/browser/index.js"
9
10
  },
10
11
  "files": [
11
12
  "src/",
@@ -15,5 +16,13 @@
15
16
  "license": "ISC",
16
17
  "dependencies": {
17
18
  "axios": "^1.13.5"
19
+ },
20
+ "peerDependencies": {
21
+ "jssip": "^3.13.0"
22
+ },
23
+ "peerDependenciesMeta": {
24
+ "jssip": {
25
+ "optional": true
26
+ }
18
27
  }
19
28
  }
@@ -22,6 +22,7 @@ import { Subscriptions } from "./domains/Subscriptions.js";
22
22
  import { Users } from "./domains/Users.js";
23
23
  import { Notifications } from "./domains/Notifications.js";
24
24
  import { Webhooks } from "./domains/Webhooks.js";
25
+ import { Voice } from "./domains/Voice.js";
25
26
 
26
27
  export class SignalHouseSDK {
27
28
  /**
@@ -29,6 +30,7 @@ export class SignalHouseSDK {
29
30
  * @param {Object} config - The configuration object for initializing the SDK
30
31
  * @param {string} config.apiKey - The API key for authenticating requests to the SignalHouse API
31
32
  * @param {string} config.baseUrl - The base URL for the SignalHouse API (e.g., "https://api.signalhouse.com")
33
+ * @param {boolean} [config.enableAdmin=false] - Enable admin-scoped behaviors on domains that support them.
32
34
  * @throws {Error} Throws an error if the API key or base URL is missing from the configuration
33
35
  * @returns {SignalHouseSDK} An instance of the SignalHouseSDK
34
36
  */
@@ -66,6 +68,9 @@ export class SignalHouseSDK {
66
68
  this.users = new Users(client, this.enableAdmin);
67
69
  this.notifications = new Notifications(client, this.enableAdmin);
68
70
  this.webhooks = new Webhooks(client, this.enableAdmin);
71
+ // Voice domain targets the voice-backend service under /voice. Available
72
+ // like any other domain; gating (if any) will be decided later.
73
+ this.voice = new Voice(client, this.enableAdmin);
69
74
  }
70
75
 
71
76
  /**
@@ -58,6 +58,37 @@ export class Auth {
58
58
  return this.client(`/auth/verify-email`, { method: "POST", body: { token }, ...options });
59
59
  }
60
60
 
61
+ /**
62
+ * Request a password-reset link be emailed to the given address (public). Always resolves
63
+ * successfully regardless of whether an account exists for the email, so it cannot be used to
64
+ * enumerate registered emails.
65
+ * @async
66
+ * @roles public
67
+ * @param {Object} params
68
+ * @param {string} params.email - The email address to send a reset link to
69
+ * @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
70
+ * @returns {Promise<Object>} The response from the server ({ success: true })
71
+ */
72
+ async forgotPassword({ email, options = {} }) {
73
+ this.client._require({ email });
74
+ return this.client(`/auth/forgot-password`, { method: "POST", body: { email }, ...options });
75
+ }
76
+
77
+ /**
78
+ * Reset a password using the single-use token from a password-reset email link (public)
79
+ * @async
80
+ * @roles public
81
+ * @param {Object} params
82
+ * @param {string} params.token - The single-use reset token from the email link
83
+ * @param {string} params.password - The new password to set (min 8 characters)
84
+ * @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
85
+ * @returns {Promise<Object>} The response from the server ({ success: true })
86
+ */
87
+ async resetPasswordWithToken({ token, password, options = {} }) {
88
+ this.client._require({ token, password });
89
+ return this.client(`/auth/reset-password`, { method: "POST", body: { token, password }, ...options });
90
+ }
91
+
61
92
  /**
62
93
  * Resend the account verification email to the authenticated caller
63
94
  * @async
@@ -115,4 +146,19 @@ export class Auth {
115
146
  async logoutAll({ options = {} } = {}) {
116
147
  return this.client(`/auth/logout-all`, { method: "POST", ...options });
117
148
  }
149
+
150
+ /**
151
+ * Mint a single-use, short-lived external-link token for the authenticated caller. The token is
152
+ * handed to the GHL/Shopify backend so it can link the caller's existing V2 group to its tenant.
153
+ * @async
154
+ * @roles admin, developer, billing, user
155
+ * @param {Object} params
156
+ * @param {string} params.product - The external system to link to ("ghl" or "shopify")
157
+ * @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
158
+ * @returns {Promise<Object>} The response from the server ({ token })
159
+ */
160
+ async requestExternalLinkToken({ product, options = {} }) {
161
+ this.client._require({ product });
162
+ return this.client(`/auth/external-link-token`, { method: "POST", body: { product }, ...options });
163
+ }
118
164
  }
@@ -64,7 +64,7 @@
64
64
  * @property {string} [privacyPolicyLink] - A URL linking to the privacy policy for the campaign (optional, must be at most 2048 characters if provided)
65
65
  * @property {string} [termsAndConditionsLink] - A URL linking to the terms and conditions for the campaign (optional, must be at most 2048 characters if provided)
66
66
  * @property {string} [embeddedLinkSample] - A sample of the embedded link used in the campaign (optional, must be at most 255 characters if provided)
67
- * @property {Object} [tollFree] - Toll-Free editable fields, used when updating a Toll-Free campaign. Mirrors the CreateTollFreeCampaignData `tollFree` object with every field optional (useCase, messageVolume, programSummary (≤500), exampleMessage, customerCareEmail, optInImageURLs, optIns, additionalInformation (≤500)). Phone numbers cannot be changed via update — Toll-Free numbers are locked to their campaign.
67
+ * @property {Object} [tollFree] - Toll-Free editable fields, used when updating a Toll-Free campaign. Mirrors the CreateTollFreeCampaignData `tollFree` object with every field optional (useCase, messageVolume, programSummary (≤500), exampleMessage, customerCareEmail, optInImageURLs, optIns, multiNumberReason (≤500)). Phone numbers cannot be changed via update — Toll-Free numbers are locked to their campaign.
68
68
  */
69
69
 
70
70
  /**
@@ -87,7 +87,7 @@
87
87
  * @property {TollFreeOptIn} [optIns.web] - Online (web) opt-in details
88
88
  * @property {TollFreeOptIn} [optIns.keyword] - Keyword opt-in details
89
89
  * @property {TollFreeOptIn} [optIns.interactiveVoiceResponse] - IVR opt-in details
90
- * @property {string} [additionalInformation] - Free-text justification sent upstream to the carrier (optional, at most 500 characters). Required when more than one phone number is assigned to the campaign.
90
+ * @property {string} [multiNumberReason] - Internally-stored reason the customer requested more than one Toll-Free number on the campaign (optional, at most 500 characters). Required when more than one phone number is assigned; captured for Signal House review only and never sent to the carrier.
91
91
  */
92
92
 
93
93
  /**
@@ -196,7 +196,7 @@ export class Campaigns {
196
196
  /**
197
197
  * Create a new Toll-Free (TFN) campaign and submit it for Signal House review
198
198
  * @async
199
- * @roles api, admin, developer, billing, user
199
+ * @roles api, admin, developer, user
200
200
  * @param {Object} params - The parameters for creating the toll-free campaign
201
201
  * @param {CreateTollFreeCampaignData} params.campaignData - The data for the toll-free campaign to be created (see CreateTollFreeCampaignData typedef for details)
202
202
  * @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
@@ -52,6 +52,30 @@ export class Groups {
52
52
  const safeGroupId = encodeURIComponent(groupId);
53
53
  return this.client(`/group/${safeGroupId}`, { method: "DELETE", ...options });
54
54
  },
55
+
56
+ /**
57
+ * Link an external tenant (GHL/Shopify) to a V2 group (server-to-server). Exchanges a
58
+ * single-use link token for a canonical group, adopting an empty portal group, repointing
59
+ * to an existing group, or flagging for manual review.
60
+ * @async
61
+ * @roles signalhouse
62
+ * @param {Object} params - The parameters for linking an external account
63
+ * @param {string} params.linkToken - The single-use external-link token minted by the portal user
64
+ * @param {string} params.externalSystem - The external system ("ghl" or "shopify")
65
+ * @param {string} params.externalId - The external tenant identifier
66
+ * @param {string} [params.existingGroupId] - An existing V2 group ID to repoint to, if any
67
+ * @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
68
+ * @throws {Error} Throws an error if linkToken, externalSystem, or externalId is missing
69
+ * @returns {Promise<Object>} - The link outcome ({ status, canonicalGroupId, ... })
70
+ */
71
+ linkExternal: async ({ linkToken, externalSystem, externalId, existingGroupId, options = {} }) => {
72
+ this.client._require({ linkToken, externalSystem, externalId });
73
+ return this.client(`/group/link-external`, {
74
+ method: "POST",
75
+ body: { linkToken, externalSystem, externalId, ...(existingGroupId ? { existingGroupId } : {}) },
76
+ ...options,
77
+ });
78
+ },
55
79
  };
56
80
  }
57
81
  }
@@ -115,7 +115,8 @@ export class Numbers {
115
115
  * Purchase one or more Toll-Free numbers via the asynchronous resource-request flow.
116
116
  *
117
117
  * Toll-Free numbers are ordered by quantity (not picked individually) and provisioned asynchronously;
118
- * per-number completion is delivered via webhook/polling, not in this response.
118
+ * per-number completion is delivered via webhook/polling, not in this response. The returned `orderId`
119
+ * can be polled with {@link Numbers#getTollFreeOrderStatus} for the order's outcome.
119
120
  * @async
120
121
  * @roles api, admin, developer, billing, user
121
122
  * @param {Object} params - The parameters for purchasing toll-free numbers
@@ -123,13 +124,32 @@ export class Numbers {
123
124
  * @param {string} params.subgroupId - The subgroup the purchased numbers are assigned to
124
125
  * @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
125
126
  * @throws {Error} Throws an error if the quantity or subgroupId parameter is missing
126
- * @returns {Promise<Object>} A promise that resolves to `{ message }` once the request is queued.
127
+ * @returns {Promise<Object>} A promise that resolves to `{ message, orderId }` once the request is queued.
127
128
  */
128
129
  async purchaseTollFreeNumbers({ quantity, subgroupId, options = {} }) {
129
130
  this.client._require({ quantity, subgroupId });
130
131
  return this.client(`/number/toll-free`, { method: "POST", body: { quantity, subgroupId }, ...options });
131
132
  }
132
133
 
134
+ /**
135
+ * Read the outcome of a Toll-Free number purchase by the `orderId` returned from
136
+ * {@link Numbers#purchaseTollFreeNumbers}. Reports per-number status as ready / provisioning / failed
137
+ * counts (plus failure reasons) so a client can poll until the order reaches a terminal state. The
138
+ * provisioned numbers themselves appear in the regular numbers list as they materialize.
139
+ * @async
140
+ * @roles api, admin, developer, billing, user
141
+ * @param {Object} params - The parameters for reading the order status
142
+ * @param {string} params.orderId - The order id returned by `purchaseTollFreeNumbers`
143
+ * @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
144
+ * @throws {Error} Throws an error if the orderId parameter is missing
145
+ * @returns {Promise<Object>} A promise that resolves to `{ orderId, counts: { ready, provisioning, failed }, failures: [{ reason }] }`.
146
+ */
147
+ async getTollFreeOrderStatus({ orderId, options = {} }) {
148
+ this.client._require({ orderId });
149
+ const safeOrderId = encodeURIComponent(orderId);
150
+ return this.client(`/number/toll-free/order/${safeOrderId}`, { method: "GET", ...options });
151
+ }
152
+
133
153
  /**
134
154
  * Update an existing phone number's details (e.g., setting a friendly name)
135
155
  * @async
@@ -0,0 +1,26 @@
1
+ import { SipTrunks } from "./voice/SipTrunks.js";
2
+ import { SipProfiles } from "./voice/SipProfiles.js";
3
+ import { Calls } from "./voice/Calls.js";
4
+ import { Tokens } from "./voice/Tokens.js";
5
+
6
+ /**
7
+ * Voice domain — wraps the voice-backend service (mounted under /voice on the
8
+ * platform host). Groups all voice-service sub-resources under a single
9
+ * namespace, accessed via `sdk.voice.*`.
10
+ *
11
+ * Sub-resources:
12
+ * - `sdk.voice.sipTrunks` — SIP trunk (peer-to-peer connections to PBX/carrier).
13
+ * - `sdk.voice.sipProfiles` — SIP profile / endpoint (single registerable UA).
14
+ * - `sdk.voice.calls` — Outbound call origination + call log queries.
15
+ * - `sdk.voice.tokens` — Mint ephemeral SIP credentials for the browser voice SDK.
16
+ */
17
+ export class Voice {
18
+ constructor(client, enableAdmin) {
19
+ this.client = client;
20
+ this.enableAdmin = enableAdmin;
21
+ this.sipTrunks = new SipTrunks(client, enableAdmin);
22
+ this.sipProfiles = new SipProfiles(client, enableAdmin);
23
+ this.calls = new Calls(client, enableAdmin);
24
+ this.tokens = new Tokens(client, enableAdmin);
25
+ }
26
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @typedef {Object} CreateCallData
3
+ * @property {string} to - Destination phone number in E.164 format (e.g. "+15551234567") or SIP URI.
4
+ * @property {string} from - Caller ID to present on the outbound leg. Must be an account-owned phone number when not using a SIP trunk.
5
+ * @property {string} [sip_trunk_id] - Route via a configured SIP trunk. Mutually exclusive with `sip_profile_id` and `to_identity` for the trunk path.
6
+ * @property {string} [sip_profile_id] - Two-leg-with-bridge: ring this persistent SIP profile first, then dial `to` and bridge. Mutually exclusive with `to_identity`.
7
+ * @property {string} [to_identity] - Two-leg-with-bridge: ring the SDK-registered identity by logical name (the value passed to `tokens.create({identity})`), then dial `to` and bridge. Mutually exclusive with `sip_profile_id`.
8
+ * @property {string} [answer_url] - Webhook URL fetched on call answer; returns call-control instructions.
9
+ * @property {"GET"|"POST"} [answer_method] - HTTP method for `answer_url`. Defaults to POST.
10
+ * @property {string} [status_callback] - Webhook URL for call status updates (QUEUED, RINGING, ANSWERED, COMPLETED, FAILED).
11
+ * @property {"GET"|"POST"} [status_callback_method] - HTTP method for `status_callback`. Defaults to POST.
12
+ * @property {boolean} [recording_enabled] - Record both legs of the call. Defaults to false.
13
+ * @property {boolean} [transcription_enabled] - Transcribe the recording. Defaults to false.
14
+ * @property {string} [ivr_flow_id] - Route the call into an AI IVR flow on answer instead of webhook control.
15
+ * @property {Object} [metadata] - Free-form metadata persisted on the call log.
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} ListCallsFilters
20
+ * @property {number} [page] - Page number (1-indexed). Defaults to 1.
21
+ * @property {number} [limit] - Page size (1-100). Defaults to 20.
22
+ * @property {"QUEUED"|"RINGING"|"IN_PROGRESS"|"COMPLETED"|"FAILED"|"BUSY"|"NO_ANSWER"|"CANCELED"} [status] - Filter by call status.
23
+ * @property {"INBOUND"|"OUTBOUND"} [direction] - Filter by call direction.
24
+ * @property {string} [from] - Filter by caller number.
25
+ * @property {string} [to] - Filter by destination number.
26
+ * @property {string} [date_from] - ISO-8601 lower bound on start_time.
27
+ * @property {string} [date_to] - ISO-8601 upper bound on start_time.
28
+ */
29
+
30
+ /**
31
+ * Voice calls. REST API for placing outbound calls and inspecting call logs.
32
+ * Wraps voice-backend's `/voice/v1/calls` surface. Accessed via `sdk.voice.calls`.
33
+ *
34
+ * Origination modes:
35
+ * - **Single-leg** (no trunk/profile/identity): Twilio-style direct outbound — dial `to` from caller-ID `from`. Customer's `from` must be an account-owned number.
36
+ * - **SIP trunk** (`sip_trunk_id`): Route via a configured trunk; the trunk's auth/ACL governs caller-ID rules.
37
+ * - **Two-leg-with-bridge** (`sip_profile_id` OR `to_identity`): Ring the SDK-registered endpoint first, then dial `to` and bridge. `to_identity` resolves to the most recently-minted unexpired ephemeral identity for that name.
38
+ */
39
+ export class Calls {
40
+ constructor(client, enableAdmin) {
41
+ this.client = client;
42
+ this.enableAdmin = enableAdmin;
43
+ }
44
+
45
+ /**
46
+ * Create an outbound call.
47
+ * @async
48
+ * @roles api, admin, developer, billing, user
49
+ * @param {Object} params - Request parameters.
50
+ * @param {CreateCallData} params.callData - The call to place.
51
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
52
+ * @throws {Error} If callData is missing.
53
+ * @returns {Promise<Object>} The created call: `{ call_id, status, direction, from, to, created_at }`.
54
+ */
55
+ async create({ callData, options = {} }) {
56
+ this.client._require({ callData });
57
+ return this.client(`/voice/v1/calls`, { method: "POST", body: callData, ...options });
58
+ }
59
+
60
+ /**
61
+ * List call logs for the current account, paginated and filterable.
62
+ * @async
63
+ * @roles api, admin, developer, billing, user
64
+ * @param {Object} [params] - Request parameters.
65
+ * @param {ListCallsFilters} [params.filters] - Optional filters.
66
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
67
+ * @returns {Promise<Object>} `{ calls: [...], total, page, limit }`.
68
+ */
69
+ async list({ filters = {}, options = {} } = {}) {
70
+ const query = new URLSearchParams();
71
+ for (const [k, v] of Object.entries(filters)) {
72
+ if (v !== undefined && v !== null && v !== "") query.set(k, String(v));
73
+ }
74
+ const qs = query.toString();
75
+ const path = qs ? `/voice/v1/calls?${qs}` : `/voice/v1/calls`;
76
+ return this.client(path, { method: "GET", ...options });
77
+ }
78
+
79
+ /**
80
+ * Get a single call log by ID.
81
+ * @async
82
+ * @roles api, admin, developer, billing, user
83
+ * @param {Object} params - Request parameters.
84
+ * @param {string} params.id - The call log UUID (the value returned as `call_id` from `create`).
85
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
86
+ * @throws {Error} If id is missing.
87
+ * @returns {Promise<Object>} The call log.
88
+ */
89
+ async get({ id, options = {} }) {
90
+ this.client._require({ id });
91
+ const safeId = encodeURIComponent(id);
92
+ return this.client(`/voice/v1/calls/${safeId}`, { method: "GET", ...options });
93
+ }
94
+
95
+ /**
96
+ * Hang up an in-progress call.
97
+ * @async
98
+ * @roles api, admin, developer, billing, user
99
+ * @param {Object} params - Request parameters.
100
+ * @param {string} params.id - The call log UUID.
101
+ * @param {"NORMAL"|"BUSY"|"NO_ANSWER"|"REJECTED"} [params.reason] - Hangup reason recorded on the call log. Defaults to NORMAL.
102
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
103
+ * @throws {Error} If id is missing.
104
+ * @returns {Promise<Object>} `{ success: boolean, message: string }`.
105
+ */
106
+ async hangup({ id, reason, options = {} }) {
107
+ this.client._require({ id });
108
+ const safeId = encodeURIComponent(id);
109
+ const body = reason ? { reason } : {};
110
+ return this.client(`/voice/v1/calls/${safeId}/hangup`, { method: "POST", body, ...options });
111
+ }
112
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * @typedef {Object} CreateSipProfileData
3
+ * @property {string} name - Profile name (required).
4
+ * @property {boolean} [recordingAllowed] - Permit call recording on this endpoint.
5
+ * @property {boolean} [transcriptionEnabled] - Permit transcription on calls to/from this endpoint.
6
+ * @property {boolean} [sentimentFlag] - Permit sentiment analysis on this endpoint.
7
+ */
8
+
9
+ /**
10
+ * @typedef {Object} UpdateSipProfileData
11
+ * @property {string} [name] - Profile name.
12
+ * @property {string} [sipUsername] - SIP username override.
13
+ * @property {string} [password] - New password (also the way to "regenerate" — pass a fresh value).
14
+ * @property {"UDP"|"TCP"|"TLS"} [transport] - SIP transport.
15
+ * @property {boolean} [recordingAllowed] - Permit call recording.
16
+ * @property {boolean} [transcriptionEnabled] - Permit transcription.
17
+ * @property {boolean} [sentimentFlag] - Permit sentiment analysis.
18
+ */
19
+
20
+ /**
21
+ * SIP Profile / endpoint management. A SIP profile is a single registerable
22
+ * UA — a desk phone, a softphone, a SIP-capable device — that registers to
23
+ * Signal House with a username and password. Distinct from a SIP trunk,
24
+ * which represents a peer-to-peer link to another PBX or carrier.
25
+ *
26
+ * Calls the voice-backend service mounted under /voice. Accessed via
27
+ * `sdk.voice.sipProfiles`.
28
+ */
29
+ export class SipProfiles {
30
+ constructor(client, enableAdmin) {
31
+ this.client = client;
32
+ this.enableAdmin = enableAdmin;
33
+ }
34
+
35
+ /**
36
+ * List all SIP profiles belonging to the current account.
37
+ * @async
38
+ * @roles api, admin, developer, billing, user
39
+ * @param {Object} [params] - Request parameters.
40
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
41
+ * @returns {Promise<Object>} `{ sipProfiles: [...] }`.
42
+ */
43
+ async list({ options = {} } = {}) {
44
+ return this.client(`/voice/sip-profiles`, { method: "GET", ...options });
45
+ }
46
+
47
+ /**
48
+ * Get a single SIP profile by ID. NOTE: response does NOT include the
49
+ * password — use `getPassword({ id })` to retrieve it.
50
+ * @async
51
+ * @roles api, admin, developer, billing, user
52
+ * @param {Object} params - Request parameters.
53
+ * @param {string} params.id - The SIP profile UUID.
54
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
55
+ * @throws {Error} If id is missing.
56
+ * @returns {Promise<Object>} `{ sipProfile: {...} }`.
57
+ */
58
+ async get({ id, options = {} }) {
59
+ this.client._require({ id });
60
+ const safeId = encodeURIComponent(id);
61
+ return this.client(`/voice/sip-profiles/${safeId}`, { method: "GET", ...options });
62
+ }
63
+
64
+ /**
65
+ * Get the password for a SIP profile. The password is generated by the
66
+ * server on create and stored; this endpoint returns the stored value
67
+ * (it is NOT one-time-visible like the create response — calling getPassword
68
+ * later still returns the password as long as it has not been replaced).
69
+ * @async
70
+ * @roles api, admin, developer, billing, user
71
+ * @param {Object} params - Request parameters.
72
+ * @param {string} params.id - The SIP profile UUID.
73
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
74
+ * @throws {Error} If id is missing.
75
+ * @returns {Promise<Object>} `{ password: string|null }`.
76
+ */
77
+ async getPassword({ id, options = {} }) {
78
+ this.client._require({ id });
79
+ const safeId = encodeURIComponent(id);
80
+ return this.client(`/voice/sip-profiles/${safeId}/password`, { method: "GET", ...options });
81
+ }
82
+
83
+ /**
84
+ * List valid SIP transports + their address/port per region from the DB.
85
+ * Used by edit-profile UI to populate transport selectors.
86
+ * @async
87
+ * @roles api, admin, developer, billing, user
88
+ * @param {Object} [params] - Request parameters.
89
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
90
+ * @returns {Promise<Object>} `{ transports: [{ transport, address, port }, ...] }`.
91
+ */
92
+ async getTransports({ options = {} } = {}) {
93
+ return this.client(`/voice/sip-profiles/transports`, { method: "GET", ...options });
94
+ }
95
+
96
+ /**
97
+ * Create a new SIP profile. The server generates the SIP username and
98
+ * password; the password is returned on the create response at the top
99
+ * level (NOT nested inside `sipProfile`) and is the only chance to surface
100
+ * it without an explicit `getPassword` lookup.
101
+ * @async
102
+ * @roles api, admin, developer, billing, user
103
+ * @param {Object} params - Request parameters.
104
+ * @param {CreateSipProfileData} params.profileData - The SIP profile to create.
105
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
106
+ * @throws {Error} If profileData is missing.
107
+ * @returns {Promise<Object>} `{ sipProfile: {...}, password: string, sipServer: string, message: string }`.
108
+ */
109
+ async create({ profileData, options = {} }) {
110
+ this.client._require({ profileData });
111
+ return this.client(`/voice/sip-profiles`, { method: "POST", body: profileData, ...options });
112
+ }
113
+
114
+ /**
115
+ * Update an existing SIP profile. To rotate the password, pass a new
116
+ * `password` value here — there is no dedicated regenerate endpoint.
117
+ * @async
118
+ * @roles api, admin, developer, billing, user
119
+ * @param {Object} params - Request parameters.
120
+ * @param {string} params.id - The SIP profile UUID.
121
+ * @param {UpdateSipProfileData} params.updateData - Fields to update.
122
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
123
+ * @throws {Error} If id or updateData is missing.
124
+ * @returns {Promise<Object>} `{ sipProfile: {...} }`.
125
+ */
126
+ async update({ id, updateData, options = {} }) {
127
+ this.client._require({ id, updateData });
128
+ const safeId = encodeURIComponent(id);
129
+ return this.client(`/voice/sip-profiles/${safeId}`, { method: "PATCH", body: updateData, ...options });
130
+ }
131
+
132
+ /**
133
+ * Delete a SIP profile by ID. Unassigns any linked numbers and frees the
134
+ * extension as a side-effect.
135
+ * @async
136
+ * @roles api, admin, developer, billing, user
137
+ * @param {Object} params - Request parameters.
138
+ * @param {string} params.id - The SIP profile UUID.
139
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
140
+ * @throws {Error} If id is missing.
141
+ * @returns {Promise<Object>} `{ message: string }`.
142
+ */
143
+ async delete({ id, options = {} }) {
144
+ this.client._require({ id });
145
+ const safeId = encodeURIComponent(id);
146
+ return this.client(`/voice/sip-profiles/${safeId}`, { method: "DELETE", ...options });
147
+ }
148
+
149
+ /**
150
+ * Assign a phone number to this SIP profile (routes inbound calls on that
151
+ * number to the endpoint).
152
+ * @async
153
+ * @roles api, admin, developer, billing, user
154
+ * @param {Object} params - Request parameters.
155
+ * @param {string} params.id - The SIP profile UUID.
156
+ * @param {string} params.phoneNumberId - The phone number UUID to assign.
157
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
158
+ * @throws {Error} If id or phoneNumberId is missing.
159
+ * @returns {Promise<Object>} `{ sipProfile: {...} }`.
160
+ */
161
+ async assignNumber({ id, phoneNumberId, options = {} }) {
162
+ this.client._require({ id, phoneNumberId });
163
+ const safeId = encodeURIComponent(id);
164
+ return this.client(`/voice/sip-profiles/${safeId}/assign-number`, {
165
+ method: "POST",
166
+ body: { phoneNumberId },
167
+ ...options,
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Unassign a phone number from this SIP profile.
173
+ * @async
174
+ * @roles api, admin, developer, billing, user
175
+ * @param {Object} params - Request parameters.
176
+ * @param {string} params.id - The SIP profile UUID.
177
+ * @param {string} params.phoneNumberId - The phone number UUID to unassign.
178
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
179
+ * @throws {Error} If id or phoneNumberId is missing.
180
+ * @returns {Promise<Object>} `{ sipProfile: {...} }`.
181
+ */
182
+ async unassignNumber({ id, phoneNumberId, options = {} }) {
183
+ this.client._require({ id, phoneNumberId });
184
+ const safeId = encodeURIComponent(id);
185
+ return this.client(`/voice/sip-profiles/${safeId}/unassign-number`, {
186
+ method: "POST",
187
+ body: { phoneNumberId },
188
+ ...options,
189
+ });
190
+ }
191
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * @typedef {Object} SipTrunkHost
3
+ * @property {string} host - IP address, hostname, FQDN, or CIDR.
4
+ * @property {string} [port] - Port number as a string (1025-65535). Destination hosts only.
5
+ */
6
+
7
+ /**
8
+ * @typedef {Object} CreateSipTrunkData
9
+ * @property {string} name - Trunk name (required, 1-255 chars).
10
+ * @property {string} region - SIP POP region the trunk lives in (required), e.g. "us-east-1".
11
+ * @property {"IP_AUTH"|"REGISTRATION"} connectionType - IP_AUTH authenticates by source-IP allow-list; REGISTRATION authenticates via SIP REGISTER with username/password.
12
+ * @property {string[]} [allowedIps] - Allowed source IPs (IP_AUTH only). Defaults to [].
13
+ * @property {"tcp"|"udp"|"tls"} [transport] - SIP transport protocol. Defaults to "tcp".
14
+ * @property {number} [maxSpendPerMinute] - Per-minute spend cap override for this trunk.
15
+ * @property {SipTrunkHost[]} [destinationHosts] - Where to send outbound SIP traffic. Defaults to [].
16
+ * @property {SipTrunkHost[]} [sourceHosts] - Allowed source hosts for inbound traffic. Defaults to [].
17
+ */
18
+
19
+ /**
20
+ * @typedef {Object} UpdateSipTrunkData
21
+ * @property {string} [name] - Trunk name (1-255 chars).
22
+ * @property {string} [region] - SIP POP region.
23
+ * @property {"IP_AUTH"|"REGISTRATION"} [connectionType] - Connection/auth type.
24
+ * @property {string[]} [allowedIps] - Allowed source IPs (IP_AUTH only).
25
+ * @property {"tcp"|"udp"|"tls"} [transport] - SIP transport protocol.
26
+ * @property {number} [maxSpendPerMinute] - Per-minute spend cap override.
27
+ * @property {SipTrunkHost[]} [destinationHosts] - Outbound SIP destinations.
28
+ * @property {SipTrunkHost[]} [sourceHosts] - Allowed inbound source hosts.
29
+ */
30
+
31
+ /**
32
+ * SIP Trunk management. Calls the voice-backend service mounted under /voice.
33
+ * Accessed via `sdk.voice.sipTrunks`.
34
+ */
35
+ export class SipTrunks {
36
+ constructor(client, enableAdmin) {
37
+ this.client = client;
38
+ this.enableAdmin = enableAdmin;
39
+ }
40
+
41
+ /**
42
+ * List all SIP trunks belonging to the current account.
43
+ * @async
44
+ * @roles api, admin, developer, billing, user
45
+ * @param {Object} [params] - Request parameters.
46
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
47
+ * @returns {Promise<Object>} The list of SIP trunks: `{ sipTrunks: [...] }`.
48
+ */
49
+ async list({ options = {} } = {}) {
50
+ return this.client(`/voice/sip-trunks`, { method: "GET", ...options });
51
+ }
52
+
53
+ /**
54
+ * Get a single SIP trunk by ID.
55
+ * @async
56
+ * @roles api, admin, developer, billing, user
57
+ * @param {Object} params - Request parameters.
58
+ * @param {string} params.id - The SIP trunk UUID.
59
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
60
+ * @throws {Error} If id is missing.
61
+ * @returns {Promise<Object>} The SIP trunk: `{ sipTrunk: {...} }`.
62
+ */
63
+ async get({ id, options = {} }) {
64
+ this.client._require({ id });
65
+ const safeId = encodeURIComponent(id);
66
+ return this.client(`/voice/sip-trunks/${safeId}`, { method: "GET", ...options });
67
+ }
68
+
69
+ /**
70
+ * Create a new SIP trunk.
71
+ * @async
72
+ * @roles api, admin, developer, billing, user
73
+ * @param {Object} params - Request parameters.
74
+ * @param {CreateSipTrunkData} params.trunkData - The SIP trunk to create.
75
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
76
+ * @throws {Error} If trunkData is missing.
77
+ * @returns {Promise<Object>} The created SIP trunk: `{ sipTrunk: {...} }`.
78
+ */
79
+ async create({ trunkData, options = {} }) {
80
+ this.client._require({ trunkData });
81
+ return this.client(`/voice/sip-trunks`, { method: "POST", body: trunkData, ...options });
82
+ }
83
+
84
+ /**
85
+ * Update an existing SIP trunk.
86
+ * @async
87
+ * @roles api, admin, developer, billing, user
88
+ * @param {Object} params - Request parameters.
89
+ * @param {string} params.id - The SIP trunk UUID.
90
+ * @param {UpdateSipTrunkData} params.updateData - Fields to update.
91
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
92
+ * @throws {Error} If id or updateData is missing.
93
+ * @returns {Promise<Object>} The updated SIP trunk: `{ sipTrunk: {...} }`.
94
+ */
95
+ async update({ id, updateData, options = {} }) {
96
+ this.client._require({ id, updateData });
97
+ const safeId = encodeURIComponent(id);
98
+ return this.client(`/voice/sip-trunks/${safeId}`, { method: "PATCH", body: updateData, ...options });
99
+ }
100
+
101
+ /**
102
+ * Delete a SIP trunk by ID.
103
+ * @async
104
+ * @roles api, admin, developer, billing, user
105
+ * @param {Object} params - Request parameters.
106
+ * @param {string} params.id - The SIP trunk UUID.
107
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
108
+ * @throws {Error} If id is missing.
109
+ * @returns {Promise<Object>} `{ success: boolean, message: string }`.
110
+ */
111
+ async delete({ id, options = {} }) {
112
+ this.client._require({ id });
113
+ const safeId = encodeURIComponent(id);
114
+ return this.client(`/voice/sip-trunks/${safeId}`, { method: "DELETE", ...options });
115
+ }
116
+
117
+ /**
118
+ * Toggle a SIP trunk's active/inactive status.
119
+ * @async
120
+ * @roles api, admin, developer, billing, user
121
+ * @param {Object} params - Request parameters.
122
+ * @param {string} params.id - The SIP trunk UUID.
123
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
124
+ * @throws {Error} If id is missing.
125
+ * @returns {Promise<Object>} The updated SIP trunk: `{ sipTrunk: {...} }`.
126
+ */
127
+ async toggleActive({ id, options = {} }) {
128
+ this.client._require({ id });
129
+ const safeId = encodeURIComponent(id);
130
+ return this.client(`/voice/sip-trunks/${safeId}/toggle-active`, { method: "POST", ...options });
131
+ }
132
+
133
+ /**
134
+ * Regenerate the SIP password for a REGISTRATION-type trunk.
135
+ * @async
136
+ * @roles api, admin, developer, billing, user
137
+ * @param {Object} params - Request parameters.
138
+ * @param {string} params.id - The SIP trunk UUID.
139
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
140
+ * @throws {Error} If id is missing.
141
+ * @returns {Promise<Object>} `{ password: string }`.
142
+ */
143
+ async regeneratePassword({ id, options = {} }) {
144
+ this.client._require({ id });
145
+ const safeId = encodeURIComponent(id);
146
+ return this.client(`/voice/sip-trunks/${safeId}/regenerate-password`, { method: "POST", ...options });
147
+ }
148
+
149
+ /**
150
+ * List available SIP Points of Presence (POPs) and their domains/ports.
151
+ * @async
152
+ * @roles api, admin, developer, billing, user
153
+ * @param {Object} [params] - Request parameters.
154
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
155
+ * @returns {Promise<Object>} `{ sipPops: [...] }`.
156
+ */
157
+ async getPops({ options = {} } = {}) {
158
+ return this.client(`/voice/sip-trunks/pops`, { method: "GET", ...options });
159
+ }
160
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @typedef {Object} VoiceGrants
3
+ * @property {Object} [voice] - Voice grants object.
4
+ * @property {Object} [voice.incoming] - Inbound call grants.
5
+ * @property {boolean} [voice.incoming.allow] - Allow inbound calls to this identity.
6
+ * @property {Object} [voice.outgoing] - Outbound call grants.
7
+ * @property {string[]} [voice.outgoing.allowedNumbers] - E.164 numbers this identity may present as caller ID.
8
+ */
9
+
10
+ /**
11
+ * @typedef {Object} CreateTokenData
12
+ * @property {string} [identity] - Customer-facing logical identity name (e.g. "alice"). Persisted as `requested_identity` so server-initiated `calls.create({to_identity: "alice"})` can resolve it later. Defaults to "anonymous".
13
+ * @property {number} [ttl] - Token TTL in seconds. Clamped to the server's max. Defaults to VOICE_TOKEN_DEFAULT_TTL_SEC.
14
+ * @property {VoiceGrants} [grants] - Capability grants attached to the token.
15
+ */
16
+
17
+ /**
18
+ * @typedef {Object} SipCredentials
19
+ * @property {string} username - Server-generated SIP username (e.g. "wrtc_<hex>"). Use for the SIP-over-WSS registration.
20
+ * @property {string} password - Plaintext SIP password — sent ONCE, never recoverable. Use for digest auth on REGISTER + INVITE.
21
+ * @property {string} domain - SIP domain to register against (the part after `@` in the URI).
22
+ * @property {string} wss_url - WebSocket Secure URL the SDK opens to send SIP traffic.
23
+ */
24
+
25
+ /**
26
+ * @typedef {Object} CreateTokenResponse
27
+ * @property {string} token - JWT to pass to the SignalHouse browser voice SDK.
28
+ * @property {string} identity - The server-generated SIP username (wrtc_<hex>). Distinct from the logical `requested_identity` passed in.
29
+ * @property {string} expires_at - ISO-8601 expiry timestamp.
30
+ * @property {SipCredentials} sip_credentials - Ephemeral SIP creds embedded in the token; surfaced separately for direct SIP clients.
31
+ */
32
+
33
+ /**
34
+ * WebRTC voice tokens. Mints ephemeral SIP identities the browser/mobile voice
35
+ * SDK uses to register against the SignalHouse voice fabric. Wraps voice-
36
+ * backend's `/voice/tokens` endpoint. Accessed via `sdk.voice.tokens`.
37
+ *
38
+ * Customer flow:
39
+ * 1. Backend calls `sdk.voice.tokens.create({ identity: "alice", ttl: 1800 })`
40
+ * and returns the resulting `token` to the customer's browser/app.
41
+ * 2. Browser SDK registers using the embedded `sip_credentials`.
42
+ * 3. Outbound calls from the SDK go through OpenSIPS → Asterisk → carrier;
43
+ * the server-side `calls.create({ to_identity: "alice", ... })` flow can
44
+ * also ring this identity for click-to-call patterns.
45
+ */
46
+ export class Tokens {
47
+ constructor(client, enableAdmin) {
48
+ this.client = client;
49
+ this.enableAdmin = enableAdmin;
50
+ }
51
+
52
+ /**
53
+ * Mint an ephemeral voice token + SIP credentials.
54
+ * @async
55
+ * @roles api, admin, developer, billing, user
56
+ * @param {Object} [params] - Request parameters.
57
+ * @param {CreateTokenData} [params.tokenData] - Token mint options (identity, ttl, grants). All fields optional.
58
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
59
+ * @returns {Promise<CreateTokenResponse>} The token + embedded SIP credentials.
60
+ */
61
+ async create({ tokenData = {}, options = {} } = {}) {
62
+ return this.client(`/voice/tokens`, { method: "POST", body: tokenData, ...options });
63
+ }
64
+ }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * @typedef {"inbound"|"outbound"} CallDirection
3
+ * @typedef {"initial"|"ringing"|"in_progress"|"ended"|"failed"} CallStatus
4
+ */
5
+
6
+ /**
7
+ * Wraps a single SIP session — outbound or inbound — and exposes a
8
+ * Twilio-shape Call API on top of the underlying JsSIP RTCSession.
9
+ *
10
+ * Lifecycle (outbound):
11
+ * new (status="initial") → ringing → in_progress → ended | failed
12
+ *
13
+ * Lifecycle (inbound, before .accept()):
14
+ * new (status="ringing") → in_progress (on accept) → ended | failed
15
+ *
16
+ * Custom SIP headers from inbound INVITEs are exposed on the instance
17
+ * (notably `initiatorId` from `X-SignalHouse-Initiator`) so the host app
18
+ * can correlate a server-initiated callback to its triggering REST call.
19
+ *
20
+ * Methods reject when the session is no longer in a state that supports
21
+ * them (e.g., `.accept()` on an already-accepted call).
22
+ */
23
+ export class Call {
24
+ /**
25
+ * @param {Object} init - Initializer.
26
+ * @param {Object} init.session - The JsSIP RTCSession.
27
+ * @param {CallDirection} init.direction
28
+ * @param {Object} [init.request] - Inbound INVITE request, when direction === "inbound".
29
+ */
30
+ constructor({ session, direction, request }) {
31
+ this._session = session;
32
+ this._listeners = new Map();
33
+ this._remoteAudio = null;
34
+
35
+ this.direction = direction;
36
+ this.id = session.id || session._request?.call_id || null;
37
+ this.status = direction === "inbound" ? "ringing" : "initial";
38
+
39
+ // From / To URIs. For inbound the session._request carries them; for
40
+ // outbound JsSIP populates from session.remote_identity / local_identity
41
+ // once the dialog is established.
42
+ this.from = session.remote_identity?.uri?.toString?.() ?? request?.from?.uri?.toString?.() ?? null;
43
+ this.to = session.local_identity?.uri?.toString?.() ?? request?.to?.uri?.toString?.() ?? null;
44
+
45
+ // Surface useful inbound headers. Most important: X-SignalHouse-Initiator,
46
+ // which carries the call_id of the REST POST /v1/calls that triggered a
47
+ // server-side two-leg-with-bridge ring. Host app can compare against its
48
+ // pending dial set to decide whether to auto-accept.
49
+ this.headers = {};
50
+ if (request && typeof request.getHeader === "function") {
51
+ for (const name of [
52
+ "X-SignalHouse-Initiator",
53
+ "X-Call-UUID",
54
+ "X-Call-Type",
55
+ "X-Route-Type",
56
+ "X-Dest",
57
+ ]) {
58
+ const v = request.getHeader(name);
59
+ if (v) this.headers[name] = v;
60
+ }
61
+ }
62
+ this.initiatorId = this.headers["X-SignalHouse-Initiator"] || null;
63
+ this.callUuid = this.headers["X-Call-UUID"] || null;
64
+
65
+ this._wireSessionEvents();
66
+ }
67
+
68
+ // ─── Public API ─────────────────────────────────────────────────────────
69
+
70
+ /**
71
+ * Accept an inbound call. No-op (warning) if outbound or already accepted.
72
+ * @param {Object} [opts]
73
+ * @param {MediaStream} [opts.mediaStream] - Pre-acquired stream from getUserMedia.
74
+ * If omitted, the SDK calls getUserMedia({audio: true}) and uses the result.
75
+ * @param {RTCConfiguration} [opts.pcConfig] - WebRTC peer-connection config.
76
+ * Falls back to {iceServers: [{urls: "stun:stun.l.google.com:19302"}]}.
77
+ * @returns {Promise<void>}
78
+ */
79
+ async accept({ mediaStream, pcConfig } = {}) {
80
+ if (this.direction !== "inbound") {
81
+ console.warn("[Call.accept] no-op: only inbound calls can be accepted");
82
+ return;
83
+ }
84
+ if (this.status !== "ringing") {
85
+ console.warn(`[Call.accept] no-op: call is not ringing (status=${this.status})`);
86
+ return;
87
+ }
88
+ const stream = mediaStream || (await navigator.mediaDevices.getUserMedia({ audio: true, video: false }));
89
+ this._session.answer({
90
+ mediaConstraints: { audio: true, video: false },
91
+ mediaStream: stream,
92
+ // Default: no STUN (rtpengine trust-address handles NAT). Override via pcConfig.
93
+ pcConfig: pcConfig || { iceServers: [] },
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Reject an inbound call with 486 Busy (or supplied SIP code).
99
+ * @param {Object} [opts]
100
+ * @param {number} [opts.statusCode=486]
101
+ * @param {string} [opts.reason="Busy Here"]
102
+ */
103
+ reject({ statusCode = 486, reason = "Busy Here" } = {}) {
104
+ if (this.direction !== "inbound" || this.status !== "ringing") {
105
+ console.warn("[Call.reject] no-op: only ringing inbound calls can be rejected");
106
+ return;
107
+ }
108
+ try {
109
+ this._session.terminate({ status_code: statusCode, reason_phrase: reason });
110
+ } catch (err) {
111
+ // JsSIP throws if the session is already gone — non-fatal here
112
+ console.warn(`[Call.reject] terminate threw: ${err?.message || err}`);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Hang up an in-progress or ringing outbound call. Idempotent.
118
+ */
119
+ hangup() {
120
+ if (this.status === "ended" || this.status === "failed") return;
121
+ try {
122
+ this._session.terminate();
123
+ } catch (err) {
124
+ console.warn(`[Call.hangup] terminate threw: ${err?.message || err}`);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Send DTMF digits during an in-progress call. Accepts 0-9, *, #, w (wait).
130
+ * @param {string} digits
131
+ */
132
+ sendDigits(digits) {
133
+ if (this.status !== "in_progress") {
134
+ console.warn(`[Call.sendDigits] no-op: call is not in_progress (status=${this.status})`);
135
+ return;
136
+ }
137
+ try {
138
+ this._session.sendDTMF(String(digits));
139
+ } catch (err) {
140
+ console.warn(`[Call.sendDigits] sendDTMF threw: ${err?.message || err}`);
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Attach an event listener. Events:
146
+ * "accepted" — 200 OK received (outbound) or sent (inbound after accept)
147
+ * "confirmed" — ACK exchanged, media flowing
148
+ * "ended" — normal hangup; payload: {cause}
149
+ * "failed" — error before/during; payload: {cause, status_code, reason}
150
+ * "track" — remote MediaStreamTrack added; payload: {track, streams}
151
+ * @param {string} event
152
+ * @param {Function} handler
153
+ */
154
+ on(event, handler) {
155
+ if (!this._listeners.has(event)) this._listeners.set(event, new Set());
156
+ this._listeners.get(event).add(handler);
157
+ }
158
+
159
+ /**
160
+ * Remove a previously-registered listener.
161
+ */
162
+ off(event, handler) {
163
+ this._listeners.get(event)?.delete(handler);
164
+ }
165
+
166
+ // ─── Internal: wire JsSIP RTCSession events to Call events ───────────────
167
+
168
+ _emit(event, payload) {
169
+ for (const fn of this._listeners.get(event) || []) {
170
+ try { fn(payload); }
171
+ catch (err) { console.error(`[Call.${event} handler]`, err); }
172
+ }
173
+ }
174
+
175
+ _wireSessionEvents() {
176
+ const s = this._session;
177
+
178
+ // Attach the RTCPeerConnection "track" listener as soon as the PC
179
+ // exists. The browser can fire `track` when the remote SDP is applied,
180
+ // which happens BEFORE JsSIP emits "confirmed" — waiting until then
181
+ // can drop the event entirely (call connects, no audio).
182
+ //
183
+ // For outbound calls JsSIP creates the PC inside `ua.call()`, so
184
+ // session.connection may already exist by the time our Call is
185
+ // constructed — handle that synchronously. For inbound calls the PC
186
+ // is created inside session.answer(); JsSIP emits "peerconnection"
187
+ // when it's ready — attach there.
188
+ if (s.connection) this._attachTrackListener(s.connection);
189
+ s.on("peerconnection", (e) => {
190
+ const pc = e?.peerconnection || s.connection;
191
+ if (pc) this._attachTrackListener(pc);
192
+ });
193
+
194
+ s.on("progress", (e) => {
195
+ this.status = "ringing";
196
+ this._emit("progress", { response: e.response });
197
+ });
198
+
199
+ s.on("accepted", () => {
200
+ this.status = "in_progress";
201
+ this._emit("accepted");
202
+ });
203
+
204
+ s.on("confirmed", () => {
205
+ this.status = "in_progress";
206
+ // Defensive: in case "peerconnection" never fired but a PC exists by now.
207
+ if (this._session.connection) this._attachTrackListener(this._session.connection);
208
+ this._emit("confirmed");
209
+ });
210
+
211
+ s.on("ended", (e) => {
212
+ this.status = "ended";
213
+ this._cleanupRemoteAudio();
214
+ this._emit("ended", { cause: e?.cause });
215
+ });
216
+
217
+ s.on("failed", (e) => {
218
+ this.status = "failed";
219
+ this._cleanupRemoteAudio();
220
+ this._emit("failed", {
221
+ cause: e?.cause,
222
+ statusCode: e?.message?.status_code,
223
+ reason: e?.message?.reason_phrase,
224
+ });
225
+ });
226
+ }
227
+
228
+ /**
229
+ * Idempotent — wires the RTCPeerConnection "track" handler. Uses
230
+ * addEventListener (not pc.ontrack) so host apps can attach their own
231
+ * track listener without clobbering ours, and vice versa.
232
+ *
233
+ * When a remote track arrives:
234
+ * - Emit "track" so the host app can route audio anywhere it wants.
235
+ * - If `call.disableDefaultAudioOutput` is falsy, attach the stream to
236
+ * a hidden <audio> element so the user just hears the call. Host
237
+ * apps that want custom audio routing should set
238
+ * `call.disableDefaultAudioOutput = true` before accepting/connecting.
239
+ */
240
+ _attachTrackListener(pc) {
241
+ if (this._trackListenerAttached) return;
242
+ this._trackListenerAttached = true;
243
+
244
+ pc.addEventListener("track", (evt) => {
245
+ this._emit("track", { track: evt.track, streams: evt.streams });
246
+ if (this.disableDefaultAudioOutput) return;
247
+ if (!this._remoteAudio) {
248
+ this._remoteAudio = document.createElement("audio");
249
+ this._remoteAudio.autoplay = true;
250
+ this._remoteAudio.setAttribute("data-signalhouse-call", this.id || "");
251
+ document.body.appendChild(this._remoteAudio);
252
+ }
253
+ this._remoteAudio.srcObject = evt.streams[0];
254
+ });
255
+ }
256
+
257
+ _cleanupRemoteAudio() {
258
+ if (this._remoteAudio) {
259
+ try {
260
+ this._remoteAudio.srcObject = null;
261
+ this._remoteAudio.remove();
262
+ } catch {}
263
+ this._remoteAudio = null;
264
+ }
265
+ }
266
+ }
@@ -0,0 +1,226 @@
1
+ import JsSIP from "jssip";
2
+ import { Call } from "./Call.js";
3
+
4
+ /**
5
+ * @typedef {Object} SipCredentials
6
+ * @property {string} username - SIP username (e.g. "wrtc_<hex>"); from /voice/tokens response.
7
+ * @property {string} password - Plaintext SIP password.
8
+ * @property {string} domain - SIP domain (e.g. "webrtc.signalhouse.io").
9
+ * @property {string} wss_url - WebSocket Secure URL to OpenSIPS.
10
+ */
11
+
12
+ /**
13
+ * @typedef {Object} VoiceTokenResponse
14
+ * @property {string} token - JWT (informational; SDK does not currently re-send it).
15
+ * @property {string} identity - The wrtc_<hex> SIP username.
16
+ * @property {string} expires_at - ISO timestamp.
17
+ * @property {SipCredentials} sip_credentials
18
+ */
19
+
20
+ /**
21
+ * @typedef {Object} ConnectParams
22
+ * @property {string} to - Destination — E.164 ("+15551234") or full SIP URI.
23
+ * @property {string} [from] - Caller ID to present (must be an account-owned DID).
24
+ * Sent as a P-Preferred-Identity header per RFC 3325; OpenSIPS validates
25
+ * ownership and uses it as the carrier-bound From. If omitted, OpenSIPS
26
+ * falls back to the account's default outbound DID.
27
+ * @property {MediaStream} [mediaStream] - Pre-acquired stream from getUserMedia.
28
+ * When absent the SDK calls getUserMedia({audio: true}) itself.
29
+ * @property {RTCConfiguration} [pcConfig] - WebRTC peer-connection config.
30
+ * @property {string[]} [extraHeaders] - Additional SIP headers to attach to
31
+ * the outbound INVITE (advanced).
32
+ */
33
+
34
+ /**
35
+ * Browser-side SignalHouse voice client. Wraps JsSIP behind a Twilio-shape
36
+ * Device + Call API.
37
+ *
38
+ * const device = new Device(tokenResponse);
39
+ * await device.register();
40
+ * const call = await device.connect({ to: "+15551234", from: "+15559999" });
41
+ * device.on("incoming", call => call.accept());
42
+ *
43
+ * Token shape mirrors what `POST /voice/tokens` returns from the SignalHouse
44
+ * voice-backend. You can also hand in the inner `sip_credentials` object
45
+ * directly if your app already unwrapped the response.
46
+ *
47
+ * Events: "registered", "unregistered", "registrationFailed", "incoming",
48
+ * "connected" (WSS established), "disconnected", "error".
49
+ */
50
+ export class Device {
51
+ /**
52
+ * @param {VoiceTokenResponse | SipCredentials} tokenOrCreds
53
+ * @param {Object} [opts]
54
+ * @param {number} [opts.registerExpires=600] - REGISTER expires interval, in seconds.
55
+ * @param {string} [opts.userAgent="SignalHouse-Voice-SDK/0.1"]
56
+ * @param {boolean} [opts.autoRegister=false] - Call register() on construct.
57
+ */
58
+ constructor(tokenOrCreds, opts = {}) {
59
+ const creds = tokenOrCreds?.sip_credentials || tokenOrCreds;
60
+ if (!creds?.username || !creds?.password || !creds?.domain || !creds?.wss_url) {
61
+ throw new Error("Device: token/credentials missing required fields (username, password, domain, wss_url)");
62
+ }
63
+
64
+ this.identity = tokenOrCreds?.identity || creds.username;
65
+ this.expiresAt = tokenOrCreds?.expires_at || null;
66
+
67
+ this._credentials = creds;
68
+ this._opts = {
69
+ registerExpires: opts.registerExpires ?? 600,
70
+ userAgent: opts.userAgent ?? "SignalHouse-Voice-SDK/0.1",
71
+ };
72
+
73
+ this._ua = null;
74
+ this._listeners = new Map();
75
+ this._registered = false;
76
+
77
+ if (opts.autoRegister) this.register().catch(() => { /* surfaced via "registrationFailed" event */ });
78
+ }
79
+
80
+ // ─── Public API ─────────────────────────────────────────────────────────
81
+
82
+ /**
83
+ * Open the WSS connection and REGISTER. Resolves on first "registered"
84
+ * event, rejects on first "registrationFailed".
85
+ * @returns {Promise<void>}
86
+ */
87
+ register() {
88
+ if (this._registered) return Promise.resolve();
89
+ if (!this._ua) this._initUA();
90
+
91
+ return new Promise((resolve, reject) => {
92
+ const onOk = () => {
93
+ this._ua.removeListener("registered", onOk);
94
+ this._ua.removeListener("registrationFailed", onFail);
95
+ resolve();
96
+ };
97
+ const onFail = (e) => {
98
+ this._ua.removeListener("registered", onOk);
99
+ this._ua.removeListener("registrationFailed", onFail);
100
+ reject(new Error(`registrationFailed: ${e?.cause || "unknown"}`));
101
+ };
102
+ this._ua.on("registered", onOk);
103
+ this._ua.on("registrationFailed", onFail);
104
+ this._ua.start();
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Unregister and close the WSS connection. Idempotent.
110
+ * @returns {Promise<void>}
111
+ */
112
+ unregister() {
113
+ if (!this._ua) return Promise.resolve();
114
+ return new Promise((resolve) => {
115
+ const done = () => { try { this._ua.stop(); } catch {} resolve(); };
116
+ if (!this._registered) return done();
117
+ this._ua.once("unregistered", done);
118
+ try { this._ua.unregister(); }
119
+ catch { done(); }
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Place an outbound call. Returns a Call once the INVITE is sent.
125
+ * The returned Call fires "progress" → "accepted" → "confirmed" as
126
+ * the dialog establishes; listen for those before assuming audio is live.
127
+ *
128
+ * @param {ConnectParams} params
129
+ * @returns {Promise<Call>}
130
+ */
131
+ async connect({ to, from, mediaStream, pcConfig, extraHeaders = [] } = {}) {
132
+ if (!this._ua || !this._registered) {
133
+ throw new Error("Device.connect: not registered yet — call register() first");
134
+ }
135
+ if (!to) throw new Error("Device.connect: 'to' is required");
136
+
137
+ const target = /^sip:/i.test(to)
138
+ ? to
139
+ : `sip:${String(to).replace(/^\+?/, "")}@${this._credentials.domain}`;
140
+
141
+ // P-Preferred-Identity (RFC 3325) — OpenSIPS validates this is an
142
+ // account-owned DID, then uses it as the carrier From header. If
143
+ // not supplied, OpenSIPS falls back to the account's default DID.
144
+ const headers = [...extraHeaders];
145
+ if (from) {
146
+ headers.push(`P-Preferred-Identity: <sip:${from}@${this._credentials.domain}>`);
147
+ }
148
+
149
+ const stream = mediaStream || (await navigator.mediaDevices.getUserMedia({ audio: true, video: false }));
150
+
151
+ // Default to no STUN servers — SignalHouse's rtpengine is configured
152
+ // with `trust-address` (uses the actual source IP from incoming RTP
153
+ // packets rather than the SDP), so host candidates are enough and
154
+ // ICE gathering completes in milliseconds instead of waiting on
155
+ // public STUN. Customers behind restrictive NATs can override via
156
+ // `pcConfig: { iceServers: [{urls: "stun:..."}] }`.
157
+ const session = this._ua.call(target, {
158
+ mediaConstraints: { audio: true, video: false },
159
+ mediaStream: stream,
160
+ pcConfig: pcConfig || { iceServers: [] },
161
+ rtcOfferConstraints: { offerToReceiveAudio: 1, offerToReceiveVideo: 0 },
162
+ extraHeaders: headers,
163
+ });
164
+
165
+ return new Call({ session, direction: "outbound" });
166
+ }
167
+
168
+ /**
169
+ * Attach event listener. See class JSDoc for event list.
170
+ */
171
+ on(event, handler) {
172
+ if (!this._listeners.has(event)) this._listeners.set(event, new Set());
173
+ this._listeners.get(event).add(handler);
174
+ }
175
+
176
+ /**
177
+ * Remove a previously-registered listener.
178
+ */
179
+ off(event, handler) {
180
+ this._listeners.get(event)?.delete(handler);
181
+ }
182
+
183
+ /** True iff currently REGISTERED. */
184
+ get isRegistered() { return this._registered; }
185
+
186
+ // ─── Internal ───────────────────────────────────────────────────────────
187
+
188
+ _emit(event, payload) {
189
+ for (const fn of this._listeners.get(event) || []) {
190
+ try { fn(payload); }
191
+ catch (err) { console.error(`[Device.${event} handler]`, err); }
192
+ }
193
+ }
194
+
195
+ _initUA() {
196
+ const socket = new JsSIP.WebSocketInterface(this._credentials.wss_url);
197
+ this._ua = new JsSIP.UA({
198
+ sockets: [socket],
199
+ uri: `sip:${this._credentials.username}@${this._credentials.domain}`,
200
+ password: this._credentials.password,
201
+ register: true,
202
+ register_expires: this._opts.registerExpires,
203
+ user_agent: this._opts.userAgent,
204
+ });
205
+
206
+ this._ua.on("connecting", () => this._emit("connecting"));
207
+ this._ua.on("connected", () => this._emit("connected"));
208
+ this._ua.on("disconnected", (e) => this._emit("disconnected", { reason: e?.reason }));
209
+ this._ua.on("registered", () => { this._registered = true; this._emit("registered"); });
210
+ this._ua.on("unregistered", () => { this._registered = false; this._emit("unregistered"); });
211
+ this._ua.on("registrationFailed", (e) => {
212
+ this._registered = false;
213
+ this._emit("registrationFailed", {
214
+ cause: e?.cause,
215
+ statusCode: e?.response?.status_code,
216
+ reason: e?.response?.reason_phrase,
217
+ });
218
+ });
219
+
220
+ this._ua.on("newRTCSession", (e) => {
221
+ if (e.originator !== "remote") return; // outbound: we already wrap in connect()
222
+ const call = new Call({ session: e.session, direction: "inbound", request: e.request });
223
+ this._emit("incoming", call);
224
+ });
225
+ }
226
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Browser-only entry point for the SignalHouse voice SDK.
3
+ *
4
+ * Imported as a subpath:
5
+ *
6
+ * import { Device } from "@signalhousellc/sdk/voice-browser";
7
+ *
8
+ * Loads JsSIP transitively. Node-only consumers should NOT import this
9
+ * subpath; the REST surface (`sdk.voice.calls`, `sdk.voice.tokens`, etc.)
10
+ * is exported from the package root and is JsSIP-free.
11
+ *
12
+ * Pairs with `sdk.voice.tokens.create()` from the REST surface — call that
13
+ * server-side to mint a token, hand the response to `new Device(token)`
14
+ * in the browser.
15
+ */
16
+ export { Device } from "./Device.js";
17
+ export { Call } from "./Call.js";