@signalhousellc/sdk 1.0.52 → 1.0.54
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 +11 -2
- package/src/SignalHouseSDK.js +5 -0
- package/src/domains/Auth.js +46 -0
- package/src/domains/Brands.js +23 -0
- package/src/domains/Campaigns.js +3 -3
- package/src/domains/Groups.js +24 -0
- package/src/domains/Numbers.js +23 -2
- package/src/domains/Voice.js +26 -0
- package/src/domains/voice/Calls.js +112 -0
- package/src/domains/voice/SipProfiles.js +191 -0
- package/src/domains/voice/SipTrunks.js +160 -0
- package/src/domains/voice/Tokens.js +64 -0
- package/src/domains/voice/browser/Call.js +364 -0
- package/src/domains/voice/browser/Device.js +226 -0
- package/src/domains/voice/browser/index.js +17 -0
|
@@ -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,364 @@
|
|
|
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
|
+
* Mute or unmute the local microphone. Twilio-shape: pass `true` to mute,
|
|
146
|
+
* `false` to unmute. Stops/resumes sending audio RTP at the media layer —
|
|
147
|
+
* no SIP signaling, so it works regardless of server support.
|
|
148
|
+
* @param {boolean} [shouldMute=true]
|
|
149
|
+
*/
|
|
150
|
+
mute(shouldMute = true) {
|
|
151
|
+
if (this.status !== "in_progress") {
|
|
152
|
+
console.warn(`[Call.mute] no-op: call is not in_progress (status=${this.status})`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
if (shouldMute) this._session.mute({ audio: true });
|
|
157
|
+
else this._session.unmute({ audio: true });
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.warn(`[Call.mute] threw: ${err?.message || err}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Unmute the local microphone. Convenience for `mute(false)`. */
|
|
164
|
+
unmute() {
|
|
165
|
+
this.mute(false);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* @returns {boolean} whether the local microphone is currently muted.
|
|
170
|
+
*/
|
|
171
|
+
isMuted() {
|
|
172
|
+
try {
|
|
173
|
+
return Boolean(this._session.isMuted?.()?.audio);
|
|
174
|
+
} catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Put the call on hold, or resume it. Sends a re-INVITE with the audio
|
|
181
|
+
* direction set to sendonly (hold) / sendrecv (resume); the media server
|
|
182
|
+
* (rtpengine) handles it. Pass `false` to resume.
|
|
183
|
+
* @param {boolean} [shouldHold=true]
|
|
184
|
+
* @returns {boolean} whether the (un)hold was initiated.
|
|
185
|
+
*/
|
|
186
|
+
hold(shouldHold = true) {
|
|
187
|
+
if (this.status !== "in_progress") {
|
|
188
|
+
console.warn(`[Call.hold] no-op: call is not in_progress (status=${this.status})`);
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
return shouldHold ? this._session.hold() : this._session.unhold();
|
|
193
|
+
} catch (err) {
|
|
194
|
+
console.warn(`[Call.hold] threw: ${err?.message || err}`);
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Resume a held call. Convenience for `hold(false)`. */
|
|
200
|
+
unhold() {
|
|
201
|
+
return this.hold(false);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* @returns {boolean} whether this leg is locally on hold.
|
|
206
|
+
*/
|
|
207
|
+
isOnHold() {
|
|
208
|
+
try {
|
|
209
|
+
return Boolean(this._session.isOnHold?.()?.local);
|
|
210
|
+
} catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Blind-transfer (cold transfer) the call to another destination via SIP
|
|
217
|
+
* REFER. The far end is re-routed to `target` and this leg ends once the
|
|
218
|
+
* transfer is accepted. `target` may be a phone number (E.164 or bare
|
|
219
|
+
* digits) or a full SIP URI — bare numbers are normalized against the
|
|
220
|
+
* registered SIP domain by the underlying stack.
|
|
221
|
+
*
|
|
222
|
+
* NOTE: completing the transfer requires the SIP server to honor the REFER
|
|
223
|
+
* (route the new leg / bridge the parties). The client only initiates it.
|
|
224
|
+
* @param {string} target - Destination number or SIP URI.
|
|
225
|
+
*/
|
|
226
|
+
transfer(target) {
|
|
227
|
+
if (this.status !== "in_progress") {
|
|
228
|
+
console.warn(`[Call.transfer] no-op: call is not in_progress (status=${this.status})`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (!target) {
|
|
232
|
+
console.warn("[Call.transfer] no-op: a transfer target is required");
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
this._session.refer(target);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
console.warn(`[Call.transfer] refer threw: ${err?.message || err}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Attach an event listener. Events:
|
|
244
|
+
* "accepted" — 200 OK received (outbound) or sent (inbound after accept)
|
|
245
|
+
* "confirmed" — ACK exchanged, media flowing
|
|
246
|
+
* "ended" — normal hangup; payload: {cause}
|
|
247
|
+
* "failed" — error before/during; payload: {cause, status_code, reason}
|
|
248
|
+
* "track" — remote MediaStreamTrack added; payload: {track, streams}
|
|
249
|
+
* @param {string} event
|
|
250
|
+
* @param {Function} handler
|
|
251
|
+
*/
|
|
252
|
+
on(event, handler) {
|
|
253
|
+
if (!this._listeners.has(event)) this._listeners.set(event, new Set());
|
|
254
|
+
this._listeners.get(event).add(handler);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Remove a previously-registered listener.
|
|
259
|
+
*/
|
|
260
|
+
off(event, handler) {
|
|
261
|
+
this._listeners.get(event)?.delete(handler);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ─── Internal: wire JsSIP RTCSession events to Call events ───────────────
|
|
265
|
+
|
|
266
|
+
_emit(event, payload) {
|
|
267
|
+
for (const fn of this._listeners.get(event) || []) {
|
|
268
|
+
try { fn(payload); }
|
|
269
|
+
catch (err) { console.error(`[Call.${event} handler]`, err); }
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
_wireSessionEvents() {
|
|
274
|
+
const s = this._session;
|
|
275
|
+
|
|
276
|
+
// Attach the RTCPeerConnection "track" listener as soon as the PC
|
|
277
|
+
// exists. The browser can fire `track` when the remote SDP is applied,
|
|
278
|
+
// which happens BEFORE JsSIP emits "confirmed" — waiting until then
|
|
279
|
+
// can drop the event entirely (call connects, no audio).
|
|
280
|
+
//
|
|
281
|
+
// For outbound calls JsSIP creates the PC inside `ua.call()`, so
|
|
282
|
+
// session.connection may already exist by the time our Call is
|
|
283
|
+
// constructed — handle that synchronously. For inbound calls the PC
|
|
284
|
+
// is created inside session.answer(); JsSIP emits "peerconnection"
|
|
285
|
+
// when it's ready — attach there.
|
|
286
|
+
if (s.connection) this._attachTrackListener(s.connection);
|
|
287
|
+
s.on("peerconnection", (e) => {
|
|
288
|
+
const pc = e?.peerconnection || s.connection;
|
|
289
|
+
if (pc) this._attachTrackListener(pc);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
s.on("progress", (e) => {
|
|
293
|
+
this.status = "ringing";
|
|
294
|
+
this._emit("progress", { response: e.response });
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
s.on("accepted", () => {
|
|
298
|
+
this.status = "in_progress";
|
|
299
|
+
this._emit("accepted");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
s.on("confirmed", () => {
|
|
303
|
+
this.status = "in_progress";
|
|
304
|
+
// Defensive: in case "peerconnection" never fired but a PC exists by now.
|
|
305
|
+
if (this._session.connection) this._attachTrackListener(this._session.connection);
|
|
306
|
+
this._emit("confirmed");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
s.on("ended", (e) => {
|
|
310
|
+
this.status = "ended";
|
|
311
|
+
this._cleanupRemoteAudio();
|
|
312
|
+
this._emit("ended", { cause: e?.cause });
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
s.on("failed", (e) => {
|
|
316
|
+
this.status = "failed";
|
|
317
|
+
this._cleanupRemoteAudio();
|
|
318
|
+
this._emit("failed", {
|
|
319
|
+
cause: e?.cause,
|
|
320
|
+
statusCode: e?.message?.status_code,
|
|
321
|
+
reason: e?.message?.reason_phrase,
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Idempotent — wires the RTCPeerConnection "track" handler. Uses
|
|
328
|
+
* addEventListener (not pc.ontrack) so host apps can attach their own
|
|
329
|
+
* track listener without clobbering ours, and vice versa.
|
|
330
|
+
*
|
|
331
|
+
* When a remote track arrives:
|
|
332
|
+
* - Emit "track" so the host app can route audio anywhere it wants.
|
|
333
|
+
* - If `call.disableDefaultAudioOutput` is falsy, attach the stream to
|
|
334
|
+
* a hidden <audio> element so the user just hears the call. Host
|
|
335
|
+
* apps that want custom audio routing should set
|
|
336
|
+
* `call.disableDefaultAudioOutput = true` before accepting/connecting.
|
|
337
|
+
*/
|
|
338
|
+
_attachTrackListener(pc) {
|
|
339
|
+
if (this._trackListenerAttached) return;
|
|
340
|
+
this._trackListenerAttached = true;
|
|
341
|
+
|
|
342
|
+
pc.addEventListener("track", (evt) => {
|
|
343
|
+
this._emit("track", { track: evt.track, streams: evt.streams });
|
|
344
|
+
if (this.disableDefaultAudioOutput) return;
|
|
345
|
+
if (!this._remoteAudio) {
|
|
346
|
+
this._remoteAudio = document.createElement("audio");
|
|
347
|
+
this._remoteAudio.autoplay = true;
|
|
348
|
+
this._remoteAudio.setAttribute("data-signalhouse-call", this.id || "");
|
|
349
|
+
document.body.appendChild(this._remoteAudio);
|
|
350
|
+
}
|
|
351
|
+
this._remoteAudio.srcObject = evt.streams[0];
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
_cleanupRemoteAudio() {
|
|
356
|
+
if (this._remoteAudio) {
|
|
357
|
+
try {
|
|
358
|
+
this._remoteAudio.srcObject = null;
|
|
359
|
+
this._remoteAudio.remove();
|
|
360
|
+
} catch {}
|
|
361
|
+
this._remoteAudio = null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|