diff --git a/package-lock.json b/package-lock.json index e23b073..adceca3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index f2ea230..18941ac 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/SipAudioElements.ts b/src/lib/SipAudioElements.ts new file mode 100644 index 0000000..1fa205a --- /dev/null +++ b/src/lib/SipAudioElements.ts @@ -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(); + } +} \ No newline at end of file diff --git a/src/lib/SipSession.ts b/src/lib/SipSession.ts new file mode 100644 index 0000000..e6d6987 --- /dev/null +++ b/src/lib/SipSession.ts @@ -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}); + } + +} \ No newline at end of file diff --git a/src/lib/SipSessionManager.ts b/src/lib/SipSessionManager.ts new file mode 100644 index 0000000..01dc943 --- /dev/null +++ b/src/lib/SipSessionManager.ts @@ -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; + } +} \ No newline at end of file diff --git a/src/lib/SipUA.ts b/src/lib/SipUA.ts new file mode 100644 index 0000000..e8ad65b --- /dev/null +++ b/src/lib/SipUA.ts @@ -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); + } +} \ No newline at end of file diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 0000000..31c1ef4 --- /dev/null +++ b/src/lib/index.ts @@ -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 +} \ No newline at end of file diff --git a/src/lib/sip-constants.ts b/src/lib/sip-constants.ts new file mode 100644 index 0000000..930bdd1 --- /dev/null +++ b/src/lib/sip-constants.ts @@ -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"; diff --git a/src/lib/sip-models.ts b/src/lib/sip-models.ts new file mode 100644 index 0000000..f49ce8f --- /dev/null +++ b/src/lib/sip-models.ts @@ -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 +} \ No newline at end of file diff --git a/src/lib/sip-utils.ts b/src/lib/sip-utils.ts new file mode 100644 index 0000000..6b5492c --- /dev/null +++ b/src/lib/sip-utils.ts @@ -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 +} \ No newline at end of file diff --git a/src/webrtc-widget/phone.js b/src/webrtc-widget/phone.js index 120b563..91986f5 100644 --- a/src/webrtc-widget/phone.js +++ b/src/webrtc-widget/phone.js @@ -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` -