@signalhousellc/sdk 1.0.51 → 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.
@@ -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
+ }