@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.
- package/package.json +11 -2
- package/src/SignalHouseSDK.js +5 -0
- package/src/domains/Auth.js +46 -0
- package/src/domains/Brands.js +54 -2
- package/src/domains/Campaigns.js +53 -2
- package/src/domains/Groups.js +24 -0
- package/src/domains/Numbers.js +39 -0
- 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 +266 -0
- package/src/domains/voice/browser/Device.js +226 -0
- package/src/domains/voice/browser/index.js +17 -0
|
@@ -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";
|