mirror of
https://github.com/jambonz/webrtc-client.git
synced 2025-12-19 08:47:44 +00:00
-added jssip and session manager
-added typescript support
This commit is contained in:
45
package-lock.json
generated
45
package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"jssip": "3.10.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-scripts": "5.0.1",
|
||||
@@ -20,11 +21,14 @@
|
||||
"@rollup/plugin-commonjs": "^25.0.2",
|
||||
"@rollup/plugin-json": "^6.0.0",
|
||||
"@rollup/plugin-node-resolve": "^15.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.25.0",
|
||||
"@typescript-eslint/parser": "^5.25.0",
|
||||
"lit": "^2.7.5",
|
||||
"rollup": "^2.79.1",
|
||||
"rollup-plugin-lit-css": "^4.0.1",
|
||||
"rollup-plugin-node-polyfills": "^0.2.1",
|
||||
"rollup-plugin-terser": "^7.0.2"
|
||||
"rollup-plugin-terser": "^7.0.2",
|
||||
"typescript": "~4.9.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@aashutoshrathi/word-wrap": {
|
||||
@@ -4201,6 +4205,14 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.8.tgz",
|
||||
"integrity": "sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==",
|
||||
"dependencies": {
|
||||
"@types/ms": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/eslint": {
|
||||
"version": "8.40.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.40.2.tgz",
|
||||
@@ -4224,6 +4236,11 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz",
|
||||
"integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA=="
|
||||
},
|
||||
"node_modules/@types/events": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz",
|
||||
"integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g=="
|
||||
},
|
||||
"node_modules/@types/express": {
|
||||
"version": "4.17.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz",
|
||||
@@ -4544,6 +4561,11 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
|
||||
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw=="
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "0.7.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz",
|
||||
"integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA=="
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.3.tgz",
|
||||
@@ -12056,6 +12078,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jssip": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/jssip/-/jssip-3.10.0.tgz",
|
||||
"integrity": "sha512-iJj+bhnNl0S296sUDc2ZjIbAetnelzZ92aWARyW01oKZ0X8t+5aGrYfJdMFliLFm8hxMcnkep3vmSRGe/yRjsA==",
|
||||
"dependencies": {
|
||||
"@types/debug": "^4.1.7",
|
||||
"@types/events": "^3.0.0",
|
||||
"debug": "^4.3.1",
|
||||
"events": "^3.3.0",
|
||||
"sdp-transform": "^2.14.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jsx-ast-utils": {
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.4.tgz",
|
||||
@@ -15478,6 +15512,14 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/sdp-transform": {
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.14.1.tgz",
|
||||
"integrity": "sha512-RjZyX3nVwJyCuTo5tGPx+PZWkDMCg7oOLpSlhjDdZfwUoNqG1mM8nyj31IGHyaPWXhjbP7cdK3qZ2bmkJ1GzRw==",
|
||||
"bin": {
|
||||
"sdp-verify": "checker.js"
|
||||
}
|
||||
},
|
||||
"node_modules/select-hose": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
|
||||
@@ -16673,7 +16715,6 @@
|
||||
"version": "4.9.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
||||
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
||||
@@ -9,9 +9,12 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"web-vitals": "^2.1.4"
|
||||
"web-vitals": "^2.1.4",
|
||||
"jssip": "3.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^5.25.0",
|
||||
"@typescript-eslint/parser": "^5.25.0",
|
||||
"@rollup/plugin-commonjs": "^25.0.2",
|
||||
"@rollup/plugin-json": "^6.0.0",
|
||||
"@rollup/plugin-node-resolve": "^15.1.0",
|
||||
@@ -19,7 +22,8 @@
|
||||
"rollup-plugin-lit-css": "^4.0.1",
|
||||
"rollup-plugin-node-polyfills": "^0.2.1",
|
||||
"rollup-plugin-terser": "^7.0.2",
|
||||
"lit": "^2.7.5"
|
||||
"lit": "^2.7.5",
|
||||
"typescript": "~4.9.5"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
|
||||
63
src/lib/SipAudioElements.ts
Normal file
63
src/lib/SipAudioElements.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// @ts-ignore
|
||||
|
||||
const ringingURL: string = "https://vibe-public.s3.eu-west-1.amazonaws.com/Tone-Telephone-UK-Ring+Tone-Loop.mp3";
|
||||
const failedURL: string = "https://vibe-public.s3.eu-west-1.amazonaws.com/windows-error-sound-effect-35894.mp3";
|
||||
const answeredURL: string = "https://vibe-public.s3.eu-west-1.amazonaws.com/lazer-96499.mp3";
|
||||
|
||||
export default class SipAudioElements {
|
||||
#ringing: HTMLAudioElement;
|
||||
#failed: HTMLAudioElement;
|
||||
#answer: HTMLAudioElement;
|
||||
#remote: HTMLAudioElement;
|
||||
|
||||
|
||||
constructor() {
|
||||
this.#ringing = new Audio();
|
||||
this.#ringing.loop = true;
|
||||
this.#ringing.src = ringingURL;
|
||||
this.#ringing.volume = 0.8;
|
||||
this.#failed = new Audio();
|
||||
this.#failed.src = failedURL;
|
||||
this.#failed.volume = 0.3;
|
||||
this.#answer = new Audio();
|
||||
this.#answer.src = answeredURL;
|
||||
this.#answer.volume = 0.3;
|
||||
this.#remote = new Audio();
|
||||
}
|
||||
|
||||
playRinging(volume: number | undefined): void {
|
||||
if (volume) {
|
||||
this.#ringing.volume = volume;
|
||||
}
|
||||
this.#ringing.play();
|
||||
}
|
||||
|
||||
pauseRinging(): void {
|
||||
this.#ringing.pause();
|
||||
}
|
||||
|
||||
playFailed(volume: number | undefined): void {
|
||||
this.pauseRinging();
|
||||
if (volume) {
|
||||
this.#failed.volume = volume;
|
||||
}
|
||||
this.#failed.play();
|
||||
}
|
||||
|
||||
playAnswer(volume: number | undefined): void {
|
||||
this.pauseRinging();
|
||||
if (volume) {
|
||||
this.#answer.volume = volume;
|
||||
}
|
||||
this.#answer.play();
|
||||
}
|
||||
|
||||
isRemoteAudioPaused(): boolean {
|
||||
return this.#remote.paused;
|
||||
}
|
||||
|
||||
playRemote(stream: MediaStream) {
|
||||
this.#remote.srcObject = stream;
|
||||
this.#remote.play();
|
||||
}
|
||||
}
|
||||
270
src/lib/SipSession.ts
Normal file
270
src/lib/SipSession.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import {
|
||||
EndEvent,
|
||||
HoldEvent,
|
||||
IceCandidateEvent,
|
||||
PeerConnectionEvent,
|
||||
ReferEvent,
|
||||
RTCPeerConnectionDeprecated,
|
||||
RTCSession
|
||||
} from "jssip/lib/RTCSession";
|
||||
import {SipConstants, SipAudioElements, randomId} from "./index";
|
||||
import {DTMF_TRANSPORT} from "jssip/lib/Constants";
|
||||
import {IncomingResponse} from "jssip/lib/SIPMessage";
|
||||
import * as events from "events";
|
||||
import {C, Grammar} from "jssip";
|
||||
|
||||
export default class SipSession extends events.EventEmitter {
|
||||
#id: string;
|
||||
#rtcOptions: any;
|
||||
#audio: SipAudioElements;
|
||||
#rtcSession: RTCSession;
|
||||
#active: boolean;
|
||||
|
||||
constructor(rtcSession: RTCSession, rtcConfig: RTCConfiguration, audio: SipAudioElements) {
|
||||
super();
|
||||
this.setMaxListeners(Infinity);
|
||||
this.#id = randomId("");
|
||||
this.#rtcOptions = {
|
||||
mediaConstraints: {audio: true, video: false},
|
||||
pcConfig: rtcConfig
|
||||
};
|
||||
this.#rtcSession = rtcSession;
|
||||
this.#audio = audio;
|
||||
this.#active = false;
|
||||
this.addListeners();
|
||||
}
|
||||
|
||||
addListeners(): void {
|
||||
|
||||
if (this.#rtcSession.connection) {
|
||||
this.addPeerConnectionListener(this.#rtcSession.connection);
|
||||
} else {
|
||||
this.#rtcSession.on('peerconnection', (data: PeerConnectionEvent): void => {
|
||||
let pc: RTCPeerConnectionDeprecated = data.peerconnection;
|
||||
this.addPeerConnectionListener(pc);
|
||||
});
|
||||
}
|
||||
|
||||
this.#rtcSession.on('progress', (): void => {
|
||||
this.emit(SipConstants.SESSION_RINGING, {
|
||||
status: SipConstants.SESSION_RINGING,
|
||||
});
|
||||
if (this.#audio.isRemoteAudioPaused() && !this.replaces) {
|
||||
this.#audio.playRinging(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
this.#rtcSession.on('accepted', () => {
|
||||
this.emit(SipConstants.SESSION_ANSWERED, {
|
||||
status: SipConstants.SESSION_ANSWERED
|
||||
});
|
||||
this.#audio.playAnswer(undefined);
|
||||
});
|
||||
|
||||
this.#rtcSession.on('failed', (data: EndEvent): void => {
|
||||
let {originator, cause, message} = data;
|
||||
let description;
|
||||
if (message && originator === 'remote' && message instanceof IncomingResponse && message.status_code) {
|
||||
description = `${message.status_code}`.trim();
|
||||
}
|
||||
if (originator === 'local' && cause === C.causes.CANCELED) {
|
||||
description = 'Cancelled by user';
|
||||
}
|
||||
if (originator === 'local' && cause === C.causes.REJECTED) {
|
||||
description = 'Rejected by user';
|
||||
}
|
||||
this.emit(SipConstants.SESSION_FAILED, {
|
||||
cause: cause,
|
||||
status: SipConstants.SESSION_FAILED,
|
||||
originator: originator,
|
||||
description: description
|
||||
});
|
||||
if (originator === 'remote') {
|
||||
this.#audio.playFailed(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
this.#rtcSession.on("ended", (data: EndEvent): void => {
|
||||
const {originator, cause, message} = data;
|
||||
let description;
|
||||
if (message && originator === 'remote' && message.hasHeader('Reason')) {
|
||||
const reason = Grammar.parse(message.getHeader('Reason'), 'Reason');
|
||||
if (reason) {
|
||||
description = `${reason.cause}`.trim();
|
||||
}
|
||||
}
|
||||
this.emit(SipConstants.SESSION_ENDED, {
|
||||
cause: cause,
|
||||
status: SipConstants.SESSION_ENDED,
|
||||
originator: originator,
|
||||
description: description
|
||||
});
|
||||
});
|
||||
|
||||
this.#rtcSession.on("muted", (): void => {
|
||||
this.emit(SipConstants.SESSION_MUTED, {
|
||||
status: "muted"
|
||||
});
|
||||
});
|
||||
|
||||
this.#rtcSession.on("unmuted", (): void => {
|
||||
this.emit(SipConstants.SESSION_MUTED, {
|
||||
status: "unmuted"
|
||||
});
|
||||
});
|
||||
|
||||
this.#rtcSession.on("hold", (data: HoldEvent): void => {
|
||||
this.emit(SipConstants.SESSION_HOLD, {
|
||||
status: "hold",
|
||||
originator: data.originator
|
||||
});
|
||||
});
|
||||
|
||||
this.#rtcSession.on("unhold", (data: HoldEvent): void => {
|
||||
this.emit(SipConstants.SESSION_HOLD, {
|
||||
status: "unhold",
|
||||
originator: data.originator
|
||||
});
|
||||
});
|
||||
|
||||
this.#rtcSession.on("refer", (data: ReferEvent): void => {
|
||||
let {accept} = data;
|
||||
accept((rtcSession: RTCSession): void => {
|
||||
rtcSession.data.replaces = true;
|
||||
this.emit(SipConstants.SESSION_REFER, {
|
||||
session: rtcSession,
|
||||
type: 'refer'
|
||||
});
|
||||
}, this.#rtcOptions);
|
||||
});
|
||||
|
||||
this.#rtcSession.on("replaces", (data: ReferEvent): void => {
|
||||
data.accept((rtcSession: RTCSession): void => {
|
||||
rtcSession.data.replaces = true;
|
||||
if (!rtcSession.isEstablished()) {
|
||||
rtcSession.answer(this.#rtcOptions);
|
||||
this.emit(SipConstants.SESSION_REPLACES, {
|
||||
session: rtcSession,
|
||||
type: 'replaces'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.#rtcSession.on('icecandidate', (evt: IceCandidateEvent): void => {
|
||||
let type: string[] = evt.candidate.candidate.split(' ');
|
||||
let candidate: string = type[7];
|
||||
if (['srflx', 'relay'].indexOf(candidate) > -1) {
|
||||
evt.ready();
|
||||
this.emit(SipConstants.SESSION_ICE_READY, {
|
||||
candidate: candidate,
|
||||
status: 'ready'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addPeerConnectionListener(pc: RTCPeerConnection): void {
|
||||
pc.addEventListener('addstream', (event: any): void => {
|
||||
if (this.#rtcSession.direction === 'outgoing') {
|
||||
this.#audio.pauseRinging();
|
||||
}
|
||||
this.#audio.playRemote(event.stream);
|
||||
this.emit(SipConstants.SESSION_ADD_STREAM, {
|
||||
direction: this.#rtcSession.direction
|
||||
})
|
||||
});
|
||||
pc.addEventListener('track', (event: RTCPeerConnectionEventMap["track"]): void => {
|
||||
const stream: MediaStream = new MediaStream([event.track])
|
||||
if (this.#rtcSession.direction === 'outgoing') {
|
||||
this.#audio.pauseRinging();
|
||||
}
|
||||
this.#audio.playRemote(stream);
|
||||
this.emit(SipConstants.SESSION_TRACK, {
|
||||
direction: this.#rtcSession.direction
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
get rtcSession() {
|
||||
return this.#rtcSession;
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.#id;
|
||||
}
|
||||
|
||||
get user() {
|
||||
return this.#rtcSession.remote_identity.uri.user;
|
||||
}
|
||||
|
||||
get active() {
|
||||
return this.#active;
|
||||
}
|
||||
|
||||
get answerTime(): Date {
|
||||
return this.#rtcSession.start_time;
|
||||
}
|
||||
|
||||
get duration() {
|
||||
if (!this.answerTime) {
|
||||
return 0;
|
||||
}
|
||||
let now: number = new Date().getUTCMilliseconds();
|
||||
return Math.floor((now - this.answerTime.getUTCMilliseconds()) / 1000);
|
||||
}
|
||||
|
||||
get replaces() {
|
||||
return Boolean(this.#rtcSession.data.replaces);
|
||||
}
|
||||
|
||||
setActive(flag: boolean): void {
|
||||
let wasActive: boolean = this.#active;
|
||||
this.#active = flag;
|
||||
if (this.#rtcSession.isEstablished()) {
|
||||
if (this.replaces) {
|
||||
return;
|
||||
}
|
||||
if (this.#active) {
|
||||
this.unhold();
|
||||
} else {
|
||||
this.hold();
|
||||
}
|
||||
}
|
||||
if (this.#active && !wasActive) {
|
||||
this.emit(SipConstants.SESSION_ACTIVE);
|
||||
}
|
||||
}
|
||||
|
||||
answer() {
|
||||
this.#rtcSession.answer(this.#rtcOptions);
|
||||
}
|
||||
|
||||
terminate(sipCode: number, sipReason: string): void {
|
||||
this.#rtcSession.terminate({
|
||||
status_code: sipCode,
|
||||
reason_phrase: sipReason
|
||||
});
|
||||
}
|
||||
|
||||
mute(): void {
|
||||
this.#rtcSession.mute({audio: true, video: true});
|
||||
}
|
||||
|
||||
unmute(): void {
|
||||
this.#rtcSession.unmute({audio: true, video: true});
|
||||
}
|
||||
|
||||
hold(): void {
|
||||
this.#rtcSession.hold();
|
||||
}
|
||||
|
||||
unhold(): void {
|
||||
this.#rtcSession.unhold();
|
||||
}
|
||||
|
||||
sendDtmf(tone: number): void {
|
||||
this.#rtcSession.sendDTMF(tone, {transportType: DTMF_TRANSPORT.RFC2833});
|
||||
}
|
||||
|
||||
}
|
||||
99
src/lib/SipSessionManager.ts
Normal file
99
src/lib/SipSessionManager.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
SipSession, SipModel, SipConstants
|
||||
} from "./index";
|
||||
|
||||
|
||||
export default class SipSessionManager {
|
||||
|
||||
#sessions: SipModel.SipSessionState[];
|
||||
|
||||
constructor() {
|
||||
this.#sessions = [];
|
||||
}
|
||||
|
||||
activate(session: SipSession) {
|
||||
this.#sessions.forEach(state => {
|
||||
if (session.id !== state.id) {
|
||||
state.active = false;
|
||||
session.setActive(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateSession(field: string, session: SipSession, args: any): void {
|
||||
const state: SipModel.SipSessionState = this.getSessionState(session.id);
|
||||
if (state) {
|
||||
switch (field) {
|
||||
case SipConstants.SESSION_RINGING:
|
||||
state.status = args.status;
|
||||
break;
|
||||
case SipConstants.SESSION_ANSWERED:
|
||||
state.status = args.status;
|
||||
break;
|
||||
case SipConstants.SESSION_FAILED || SipConstants.SESSION_ENDED:
|
||||
state.status = args.status;
|
||||
state.endState = {
|
||||
cause: args.cause,
|
||||
status: args.status,
|
||||
originator: args.endState,
|
||||
description: args.description
|
||||
}
|
||||
break;
|
||||
case SipConstants.SESSION_MUTED:
|
||||
state.muteStatus = args.status;
|
||||
break;
|
||||
case SipConstants.SESSION_HOLD:
|
||||
state.holdState = {
|
||||
originator: args.originator,
|
||||
status: args.status
|
||||
}
|
||||
break;
|
||||
case SipConstants.SESSION_ICE_READY:
|
||||
state.iceReady = true;
|
||||
break;
|
||||
case SipConstants.SESSION_ACTIVE:
|
||||
state.active = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSessionState(id: string): SipModel.SipSessionState {
|
||||
const state = this.#sessions.find(value => value.id === id);
|
||||
if (!state) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
getSession(id: string): SipSession {
|
||||
return this.getSessionState(id).sipSession;
|
||||
}
|
||||
|
||||
|
||||
|
||||
newSession(session: SipSession): void {
|
||||
this.#sessions.push({
|
||||
id: session.id,
|
||||
sipSession: session,
|
||||
startDateTime: new Date(),
|
||||
active: true,
|
||||
status: 'init',
|
||||
});
|
||||
}
|
||||
|
||||
get activeSession(): SipSession {
|
||||
const state = this.#sessions.find(value => value.active);
|
||||
if (state) {
|
||||
return state.sipSession;
|
||||
}
|
||||
if (this.#sessions.length === 0) {
|
||||
throw new Error("No sessions");
|
||||
}
|
||||
return this.#sessions[0].sipSession;
|
||||
}
|
||||
|
||||
get count() {
|
||||
return this.#sessions.length;
|
||||
}
|
||||
}
|
||||
149
src/lib/SipUA.ts
Normal file
149
src/lib/SipUA.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import * as events from "events";
|
||||
import {UA, WebSocketInterface} from "jssip";
|
||||
import {DisconnectEvent} from "jssip/lib/WebSocketInterface";
|
||||
import {RTCSession} from "jssip/lib/RTCSession";
|
||||
import {
|
||||
ConnectedEvent,
|
||||
IncomingRTCSessionEvent,
|
||||
RegisteredEvent,
|
||||
UAConnectingEvent,
|
||||
UnRegisteredEvent,
|
||||
} from "jssip/lib/UA";
|
||||
import {
|
||||
SessionManager,
|
||||
SipAudioElements,
|
||||
SipSession,
|
||||
SipConstants,
|
||||
SipModel,
|
||||
normalizeNumber,
|
||||
} from "./index";
|
||||
|
||||
export default class SipUA extends events.EventEmitter {
|
||||
|
||||
#ua: UA;
|
||||
#rtcConfig: RTCConfiguration;
|
||||
#sessionManager: SessionManager;
|
||||
|
||||
constructor(client: SipModel.ClientAuth, settings: SipModel.ClientOptions) {
|
||||
super();
|
||||
this.#sessionManager = new SessionManager();
|
||||
this.#rtcConfig = settings.pcConfig;
|
||||
this.#ua = new UA({
|
||||
uri: `sip:${client.username}`,
|
||||
password: client.password,
|
||||
display_name: client.name,
|
||||
sockets: [new WebSocketInterface(settings.wsUri)],
|
||||
register: true,
|
||||
});
|
||||
this.#ua.on("connecting", (data: UAConnectingEvent) => this.emit(SipConstants.UA_CONNECTING, {...data, client}))
|
||||
this.#ua.on("connected", (data: ConnectedEvent) => this.emit(SipConstants.UA_CONNECTED, {...data, client}))
|
||||
this.#ua.on("disconnected", (data: DisconnectEvent) => this.emit(SipConstants.UA_DISCONNECTED, {
|
||||
...data,
|
||||
client
|
||||
}))
|
||||
this.#ua.on("registered", (data: RegisteredEvent) => this.emit(SipConstants.UA_REGISTERED, {...data, client}))
|
||||
this.#ua.on("unregistered", (data: UnRegisteredEvent) => this.emit(SipConstants.UA_UNREGISTERED, {
|
||||
...data,
|
||||
client
|
||||
}))
|
||||
this.#ua.on('newRTCSession', (data: IncomingRTCSessionEvent) => {
|
||||
const rtcSession: RTCSession = data.session;
|
||||
const session: SipSession = new SipSession(rtcSession, this.#rtcConfig, new SipAudioElements());
|
||||
session.on(SipConstants.SESSION_RINGING, args => this.#sessionManager.updateSession(SipConstants.SESSION_RINGING, session, args));
|
||||
session.on(SipConstants.SESSION_ANSWERED, args => this.#sessionManager.updateSession(SipConstants.SESSION_ANSWERED, session, args));
|
||||
session.on(SipConstants.SESSION_FAILED, args => this.#sessionManager.updateSession(SipConstants.SESSION_FAILED, session, args));
|
||||
session.on(SipConstants.SESSION_ENDED, args => this.#sessionManager.updateSession(SipConstants.SESSION_ENDED, session, args));
|
||||
session.on(SipConstants.SESSION_MUTED, args => this.#sessionManager.updateSession(SipConstants.SESSION_MUTED, session, args));
|
||||
session.on(SipConstants.SESSION_HOLD, args => this.#sessionManager.updateSession(SipConstants.SESSION_HOLD, session, args));
|
||||
session.on(SipConstants.SESSION_ICE_READY, args => this.#sessionManager.updateSession(SipConstants.SESSION_ICE_READY, session, args));
|
||||
session.on(SipConstants.SESSION_ACTIVE, args => {
|
||||
this.#sessionManager.updateSession(SipConstants.SESSION_ACTIVE, session, args);
|
||||
});
|
||||
session.setActive(true);
|
||||
this.#sessionManager.newSession(session);
|
||||
});
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.#ua.start();
|
||||
this.emit(SipConstants.UA_START);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.#ua.stop();
|
||||
this.emit(SipConstants.UA_STOP);
|
||||
}
|
||||
|
||||
call(number: string):
|
||||
void {
|
||||
let normalizedNumber: string = normalizeNumber(number);
|
||||
this.#ua.call(normalizedNumber, {
|
||||
extraHeaders: [`X-Original-Number:${number}`],
|
||||
mediaConstraints: {audio: true, video: false},
|
||||
pcConfig: this.#rtcConfig,
|
||||
});
|
||||
}
|
||||
|
||||
mute(id: string | undefined): void {
|
||||
if (id) {
|
||||
this.#sessionManager.getSession(id).mute();
|
||||
} else {
|
||||
this.#sessionManager.activeSession.mute();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
unmute(id: string | undefined): void {
|
||||
if (id) {
|
||||
this.#sessionManager.getSession(id).unmute();
|
||||
} else {
|
||||
this.#sessionManager.activeSession.unmute();
|
||||
}
|
||||
}
|
||||
|
||||
hold(id: string | undefined): void {
|
||||
if (id) {
|
||||
this.#sessionManager.getSession(id).hold();
|
||||
} else {
|
||||
this.#sessionManager.activeSession.hold();
|
||||
}
|
||||
}
|
||||
|
||||
unhold(id: string | undefined): void {
|
||||
if (id) {
|
||||
this.#sessionManager.getSession(id).unhold();
|
||||
} else {
|
||||
this.#sessionManager.activeSession.unhold();
|
||||
}
|
||||
}
|
||||
|
||||
dtmf(tone: number, id: string | undefined): void {
|
||||
if (id) {
|
||||
this.#sessionManager.getSession(id).sendDtmf(tone);
|
||||
} else {
|
||||
this.#sessionManager.activeSession.sendDtmf(tone);
|
||||
}
|
||||
}
|
||||
|
||||
terminate(sipCode: number, reason: string, id: string | undefined): void {
|
||||
if (id) {
|
||||
this.#sessionManager.getSession(id).terminate(sipCode, reason);
|
||||
} else {
|
||||
this.#sessionManager.activeSession.terminate(sipCode, reason);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
answer(id: string | undefined): void {
|
||||
if (id) {
|
||||
this.#sessionManager.getSession(id).answer();
|
||||
} else {
|
||||
this.#sessionManager.activeSession.answer();
|
||||
}
|
||||
}
|
||||
|
||||
activate(id: string) {
|
||||
const session: SipSession = this.#sessionManager.getSession(id);
|
||||
session.setActive(true);
|
||||
}
|
||||
}
|
||||
18
src/lib/index.ts
Normal file
18
src/lib/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import SipSession from "./SipSession";
|
||||
import SessionManager from "./SipSessionManager";
|
||||
import SipAudioElements from "./SipAudioElements";
|
||||
import SipUA from "./SipUA";
|
||||
import {normalizeNumber, randomId} from "./sip-utils";
|
||||
import * as SipModel from "./sip-models";
|
||||
import * as SipConstants from "./sip-constants";
|
||||
|
||||
export {
|
||||
SipUA,
|
||||
SipSession,
|
||||
SessionManager,
|
||||
SipAudioElements,
|
||||
randomId,
|
||||
normalizeNumber,
|
||||
SipConstants,
|
||||
SipModel
|
||||
}
|
||||
19
src/lib/sip-constants.ts
Normal file
19
src/lib/sip-constants.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export const SESSION_RINGING :string = "ringing";
|
||||
export const SESSION_ANSWERED :string = "answered";
|
||||
export const SESSION_FAILED :string = "failed";
|
||||
export const SESSION_ENDED :string = "ended";
|
||||
export const SESSION_MUTED :string = "muted";
|
||||
export const SESSION_HOLD :string = "hold";
|
||||
export const SESSION_REFER :string = "hold";
|
||||
export const SESSION_REPLACES :string = "replaces";
|
||||
export const SESSION_ICE_READY :string = "ice-ready";
|
||||
export const SESSION_ADD_STREAM :string = "add-stream";
|
||||
export const SESSION_TRACK :string = "track";
|
||||
export const SESSION_ACTIVE :string = "active";
|
||||
export const UA_CONNECTING :string = "connecting";
|
||||
export const UA_CONNECTED :string = "connected";
|
||||
export const UA_DISCONNECTED :string = "disconnected";
|
||||
export const UA_REGISTERED :string = "registered";
|
||||
export const UA_UNREGISTERED :string = "unregistered";
|
||||
export const UA_START :string = "start";
|
||||
export const UA_STOP :string = "stop";
|
||||
54
src/lib/sip-models.ts
Normal file
54
src/lib/sip-models.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import SipSession from "./SipSession";
|
||||
|
||||
interface SipSessionState {
|
||||
id: string;
|
||||
sipSession: SipSession;
|
||||
startDateTime: Date;
|
||||
active: boolean;
|
||||
status: string;
|
||||
muteStatus?: string;
|
||||
iceReady?: boolean;
|
||||
endState?: EndState;
|
||||
holdState?: HoldState;
|
||||
}
|
||||
|
||||
interface EndState {
|
||||
cause: string,
|
||||
status: string,
|
||||
originator: string,
|
||||
description: string
|
||||
}
|
||||
|
||||
interface HoldState {
|
||||
status: string,
|
||||
originator: string
|
||||
}
|
||||
|
||||
interface ClientAuth {
|
||||
username:string;
|
||||
password:string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ClientOptions {
|
||||
pcConfig:PeerConnectionConfig;
|
||||
wsUri:string;
|
||||
}
|
||||
|
||||
interface PeerConnectionConfig {
|
||||
iceServers: IceServer[];
|
||||
}
|
||||
|
||||
interface IceServer {
|
||||
urls : string[];
|
||||
}
|
||||
|
||||
export {
|
||||
SipSessionState,
|
||||
EndState,
|
||||
HoldState,
|
||||
ClientAuth,
|
||||
ClientOptions,
|
||||
PeerConnectionConfig,
|
||||
IceServer
|
||||
}
|
||||
23
src/lib/sip-utils.ts
Normal file
23
src/lib/sip-utils.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
function normalizeNumber(number: string): string {
|
||||
if (/^(sips?|tel):/i.test(number)) {
|
||||
return number;
|
||||
} else if (/@/i.test(number)) {
|
||||
return number;
|
||||
} else {
|
||||
return number.replace(/[()\-. ]*/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
function randomId(prefix: string): string {
|
||||
const id: string = [...Array(16)].map(() => Math.floor(Math.random() * 16).toString(16)).join('');
|
||||
if (prefix) {
|
||||
return `${prefix}-${id}`;
|
||||
} else {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
normalizeNumber,
|
||||
randomId
|
||||
}
|
||||
@@ -1,142 +1,169 @@
|
||||
import { LitElement, html, css } from 'lit-element';
|
||||
import {LitElement, html, css} from 'lit-element';
|
||||
import {SipUA} from "../lib";
|
||||
import {SipConstants} from "../lib";
|
||||
|
||||
class Phone extends LitElement {
|
||||
static styles = css`
|
||||
.number-display {
|
||||
margin: auto;
|
||||
width: 200px;
|
||||
padding: 20px;
|
||||
border: solid 1px #333;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
background-color: #f1f1f1;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
static styles = css`
|
||||
.number-display {
|
||||
margin: auto;
|
||||
width: 200px;
|
||||
padding: 20px;
|
||||
border: solid 1px #333;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
background-color: #f1f1f1;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.number-input-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.number-input-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.number-label {
|
||||
width: 20%;
|
||||
text-align: right;
|
||||
padding-right: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.number-label {
|
||||
width: 20%;
|
||||
text-align: right;
|
||||
padding-right: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.number-input {
|
||||
width: 70%;
|
||||
padding: 5px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.number-input {
|
||||
width: 70%;
|
||||
padding: 5px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.dial-pad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
max-width: 200px;
|
||||
margin: auto;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.dial-pad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
max-width: 200px;
|
||||
margin: auto;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.num-button,
|
||||
.call-button {
|
||||
padding: 10px;
|
||||
background-color: #fafafa;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.num-button,
|
||||
.call-button {
|
||||
padding: 10px;
|
||||
background-color: #fafafa;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.num-button:hover,
|
||||
.call-button:hover {
|
||||
background-color: #e1e1e1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.num-button:hover,
|
||||
.call-button:hover {
|
||||
background-color: #e1e1e1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.call-button {
|
||||
margin-top: 20px;
|
||||
background-color: green;
|
||||
color: white;
|
||||
}
|
||||
`;
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
props: { type: Object },
|
||||
toNumber: { type: String }
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.props = {};
|
||||
this.toNumber = '';
|
||||
this.sipClient = this._createSipClient();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="number-display">
|
||||
<div class="number-input-container">
|
||||
<label class="number-label">From</label>
|
||||
<input type="text" class="number-input" placeholder="Enter number here" disabled .value="${this.props.caller}"/>
|
||||
</div>
|
||||
<div class="number-input-container">
|
||||
<label class="number-label">To</label>
|
||||
<input type="text" class="number-input" placeholder="Enter number here" .value="${this.toNumber}" @input="${this._handleInput}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dial-pad">
|
||||
${Array(12).fill(0).map((_, index) => {
|
||||
let displayNum;
|
||||
if (index === 9) displayNum = "*";
|
||||
else if (index === 10) displayNum = 0;
|
||||
else if (index === 11) displayNum = "#";
|
||||
else displayNum = index + 1; // 1-9
|
||||
|
||||
return html`
|
||||
<button
|
||||
class="num-button"
|
||||
@click="${() => this._handleClick(displayNum)}"
|
||||
>
|
||||
${displayNum}
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button class="call-button" @click="${this._handleCall}">
|
||||
Call
|
||||
</button>
|
||||
.call-button {
|
||||
margin-top: 20px;
|
||||
background-color: green;
|
||||
color: white;
|
||||
}
|
||||
`;
|
||||
}
|
||||
_createSipClient () {
|
||||
}
|
||||
|
||||
_handleClick(num) {
|
||||
this.toNumber += num;
|
||||
}
|
||||
static get properties() {
|
||||
return {
|
||||
props: {type: Object},
|
||||
toNumber: {type: String}
|
||||
};
|
||||
}
|
||||
|
||||
_handleCall() {
|
||||
console.log(`Calling...`);
|
||||
this.sipClient.call(this.toNumber, '121241231');
|
||||
}
|
||||
constructor() {
|
||||
super();
|
||||
this.props = {};
|
||||
this.toNumber = '';
|
||||
this.sipClient = this._createSipClient();
|
||||
}
|
||||
|
||||
_handleInput(event) {
|
||||
this.toNumber = event.target.value;
|
||||
}
|
||||
render() {
|
||||
return html`
|
||||
<div class="number-display">
|
||||
<div class="number-input-container">
|
||||
<label class="number-label">From</label>
|
||||
<input type="text" class="number-input" placeholder="Enter number here" disabled
|
||||
.value="${this.props.caller}"/>
|
||||
</div>
|
||||
<div class="number-input-container">
|
||||
<label class="number-label">To</label>
|
||||
<input type="text" class="number-input" placeholder="Enter number here" .value="${this.toNumber}"
|
||||
@input="${this._handleInput}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dial-pad">
|
||||
${Array(12).fill(0).map((_, index) => {
|
||||
let displayNum;
|
||||
if (index === 9) displayNum = "*";
|
||||
else if (index === 10) displayNum = 0;
|
||||
else if (index === 11) displayNum = "#";
|
||||
else displayNum = index + 1; // 1-9
|
||||
|
||||
return html`
|
||||
<button
|
||||
class="num-button"
|
||||
@click="${() => this._handleClick(displayNum)}"
|
||||
>
|
||||
${displayNum}
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button class="call-button" @click="${this._handleCall}">
|
||||
Call
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
_createSipClient() {
|
||||
const client = {
|
||||
username: "xxx@jambonz.org",
|
||||
password: "1234",
|
||||
name: "Antony Jukes"
|
||||
}
|
||||
const settings = {
|
||||
pcConfig: {
|
||||
iceServers: [{urls: ['stun:stun.l.google.com:19302']}],
|
||||
},
|
||||
wsUri: "wss://foo.jambonz.org:8443",
|
||||
};
|
||||
const sipUA = new SipUA(client, settings);
|
||||
sipUA.on(SipConstants.UA_CONNECTING, args => {
|
||||
console.log(SipConstants.UA_CONNECTING, args);
|
||||
});
|
||||
sipUA.on(SipConstants.UA_REGISTERED, args => {
|
||||
console.log(SipConstants.UA_REGISTERED, args);
|
||||
});
|
||||
sipUA.on(SipConstants.UA_UNREGISTERED, args => {
|
||||
console.log(SipConstants.UA_UNREGISTERED, args);
|
||||
});
|
||||
sipUA.start();
|
||||
}
|
||||
|
||||
_handleClick(num) {
|
||||
this.toNumber += num;
|
||||
}
|
||||
|
||||
_handleCall() {
|
||||
console.log(`Calling...`);
|
||||
this.sipClient.call(this.toNumber, '121241231');
|
||||
}
|
||||
|
||||
_handleInput(event) {
|
||||
this.toNumber = event.target.value;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('phone-element', Phone);
|
||||
33
tsconfig.json
Normal file
33
tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2019",
|
||||
"module": "es2020",
|
||||
"lib": ["es2020", "DOM", "DOM.Iterable"],
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"inlineSources": true,
|
||||
"outDir": "./",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitThis": true,
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"experimentalDecorators": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitOverride": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "ts-lit-plugin",
|
||||
"strict": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": []
|
||||
}
|
||||
Reference in New Issue
Block a user