UNPKG

@babblevoice/babble-drachtio-callmanager

Version:
1,930 lines (1,614 loc) 117 kB
const { v4: uuidv4 } = require( "uuid" ) const events = require( "events" ) const dns = require( "node:dns" ) const projectrtp = require( "@babblevoice/projectrtp" ).projectrtp const sdpgen = require( "./sdp.js" ) const callstore = require( "./store.js" ) const sipauth = require( "@babblevoice/babble-drachtio-auth" ) const net = require( "net" ) const parseurire = /^(sips?):(?:([^\s>:@]+)(?::([^\s@>]+))?@)?([\w\-.]+)(?::(\d+))?((?:;[^\s=?>;]+(?:=[^\s?;]+)?)*)(?:\?(([^\s&=>]+=[^\s&=>]+)(&[^\s&=>]+=[^\s&=>]+)*))?$/ const parseuriparamsre = /([^;=]+)(=([^;=]+))?/g const parseuriheadersre = /[^&=]+=[^&=]+/g const sessionexpirevaluere = /^(\d+)(?:;refresher=(uac|uas))?$/ /** * * @param { string } s */ function parseuri( s ) { if( "object" === typeof s ) return s const r = parseurire.exec( s ) if( r ) { return { schema: r[ 1 ], user: r[ 2 ], password: r[ 3 ], host: r[ 4 ], uri: r[ 2 ] + "@" + r[ 4 ], port: +r[ 5 ], params: (r[ 6 ].match( parseuriparamsre ) || [] ) .map( function( s ) { return s.split( "=" ) } ) .reduce(function(params, x) { params[x[0]]=x[1] || null; return params }, {} ), headers: ( ( r[ 7 ] || "" ).match( parseuriheadersre ) || []) .map(function(s){ return s.split( "=") } ) .reduce(function(params, x) { params[ x[ 0 ] ] = x[ 1 ]; return params }, {} ) } } return {} } /** * Util to convert key names to lowercase * @param { object } obj * @returns */ function keynameslower( obj ) { return Object.keys( obj ).reduce( ( accumulator, currentvalue ) => { accumulator[ currentvalue.toLowerCase() ] = obj[ currentvalue ] return accumulator }, {} ) } /** * @summary Finds the correct key in a case-insensitive manner and retrieves the key and value. * @param {Object} headers - The headers object. * @param {string} targetKey - The key to search for. * @return {Object|undefined} - Returns an object with the correct key and value, or undefined if not found. */ function getheadervalue( headers, targetKey ) { const lowerCaseTargetKey = targetKey.toLowerCase() for(const key in headers) { if( key.toLowerCase() === lowerCaseTargetKey) { return { key, value: headers[key] } } } } /* Enum for different reasons for hangup. */ const hangupcodes = { /* Client error responses */ MOVED_PERMANENTLY: { "reason": "MOVED_PERMANENTLY", "sip": 301 }, MOVED_TEMPORARILY: { "reason": "MOVED_TEMPORARILY", "sip": 302 }, PAYMENT_REQUIRED: { "reason": "PAYMENT_REQUIRED", "sip": 402 }, FORBIDDEN: { "reason": "FORBIDDEN", "sip": 403 }, OUTGOING_CALL_BARRED: { "reason": "OUTGOING_CALL_BARRED", "sip": 403 }, INCOMING_CALL_BARRED: { "reason": "INCOMING_CALL_BARRED", "sip": 403 }, UNALLOCATED_NUMBER: { "reason": "UNALLOCATED_NUMBER", "sip": 404 }, NOT_ALLOWED: { "reason": "NOT_ALLOWED", "sip": 405 }, NOT_ACCEPTABLE: { "reason": "NOT_ACCEPTABLE", "sip": 406 }, PROXY_AUTHENTICATION: { "reason": "PROXY_AUTHENTICATION", "sip": 407 }, REQUEST_TIMEOUT: { "reason": "REQUEST_TIMEOUT", "sip": 408 }, USER_GONE: { "reason": "USER_GONE", "sip": 410 }, TEMPORARILY_UNAVAILABLE: { "reason": "TEMPORARILY_UNAVAILABLE", "sip": 480 }, CALL_DOES_NOT_EXIST: { "reason": "CALL_DOES_NOT_EXIST", "sip": 481 }, LOOP_DETECTED: { "reason": "LOOP_DETECTED", "sip": 482 }, TOO_MANY_HOPS: { "reason": "TOO_MANY_HOPS", "sip": 483 }, INVALID_NUMBER_FORMAT: { "reason": "INVALID_NUMBER_FORMAT", "sip": 484 }, AMBIGUOUS: { "reason": "AMBIGUOUS", "sip": 485 }, USER_BUSY: { "reason": "USER_BUSY", "sip": 486 }, NORMAL_CLEARING: { "reason": "NORMAL_CLEARING", "sip": 487 }, ORIGINATOR_CANCEL: { "reason": "ORIGINATOR_CANCEL", "sip": 487 }, USER_NOT_REGISTERED: { "reason": "USER_NOT_REGISTERED", "sip": 487 }, BLIND_TRANSFER: { "reason": "BLIND_TRANSFER", "sip": 487 }, ATTENDED_TRANSFER: { "reason": "ATTENDED_TRANSFER", "sip": 487 }, LOSE_RACE: { "reason": "LOSE_RACE", "sip": 487 }, PICKED_OFF: { "reason": "PICKED_OFF", "sip": 487 }, MANAGER_REQUEST: { "reason": "MANAGER_REQUEST", "sip": 487 }, REQUEST_TERMINATED: { "reason": "REQUEST_TERMINATED", "sip": 487 }, INCOMPATIBLE_DESTINATION: { "reason": "INCOMPATIBLE_DESTINATION", "sip": 488 }, /* Server error responses */ SERVER_ERROR: { "reason": "SERVER_ERROR", "sip": 500 }, FACILITY_REJECTED: { "reason": "FACILITY_REJECTED", "sip": 501 }, DESTINATION_OUT_OF_ORDER: { "reason": "DESTINATION_OUT_OF_ORDER", "sip": 502 }, SERVICE_UNAVAILABLE: { "reason": "SERVICE_UNAVAILABLE", "sip": 503 }, SERVER_TIMEOUT: { "reason": "SERVER_TIMEOUT", "sip": 504 }, MESSAGE_TOO_LARGE: { "reason": "MESSAGE_TOO_LARGE", "sip": 513 }, /* Global error responses */ BUSY_EVERYWHERE: { "reason": "BUSY_EVERYWHERE", "sip": 600 }, DECLINED: { "reason": "DECLINED", "sip": 603 }, DOES_NOT_EXIST_ANYWHERE: { "reason": "DOES_NOT_EXIST_ANYWHERE", "sip": 604 }, UNWANTED: { "reason": "UNWANTED", "sip": 607 }, REJECTED: { "reason": "REJECTED", "sip": 608 } } /** * Ecapsulate a SIP exception * @param { object } hangupcode * @param { string } message */ class SipError extends Error { constructor( hangupcode, message ) { super( message ) this.code = hangupcode.sip this.hangupcode = hangupcode this.name = "SipError" } } const inboundsiperros = {} const hangupcodeskeys = Object.keys( hangupcodes ) for (let i = 0; i < hangupcodeskeys.length; i++) { const key = hangupcodeskeys[ i ] const value = hangupcodes[ key ] inboundsiperros[ value.sip ] = value } let callmanager = { "options": {} } /** * @typedef { object } entity * @property { string } uri full uri * @property { string } [ username ] username part * @property { string } [ realm ] realm (domain) part * @property { string } [ display ] how the user should be displayed * @property { number } [ ccc ] - Current Call Count */ class call { #noack = false #seexpire = callmanager.options.seexpire #refresher = "uac" #expirelasttouch = +new Date() #timer = true /** * Construct our call object with all defaults, including a default UUID. * @constructs call * @hideconstructor */ constructor() { this.uuid = uuidv4() /** @enum { string } type "uas" | "uac" @summary The type (uac or uas) from our perspective. */ this.type = "uac" /** @typedef { Object } callstate @property { boolean } trying @property { boolean } early @property { boolean } ringing @property { boolean } established @property { boolean } canceled @property { boolean } destroyed @property { boolean } held @property { boolean } authed @property { boolean } cleaned @property { boolean } refered @property { boolean } picked */ /** @member { callstate } */ this.state = { "trying": false, "early": false, "ringing": false, "establishing": false, /** we have received 200 but we send ack - late neg only */ "established": false, "canceled": false, "destroyed": false, "held": false, "authed": false, "cleaned": false, "refered": false, "picked": false } this.counters = { "newuac": 0 } /** * Protected for this module */ this._state = { "_onhangup": false, "_hangup": false } /** * @private * @summary Headers to pass onto the b leg */ this.propagate = { headers: {} } /** * @summary Channels which have been created */ this.channels = { "audio": undefined, "closed": { "audio": [] }, "secure": { "audio": { "offer": true, "dtlscontroller": false, "reinvite": false } }, "count": 0 } /** * @summary Store our local and remote sdp objects */ this.sdp = { "local": undefined, "remote": undefined } /** * @summary UACs we create */ this.children = new Set() /** * @summary Who created us */ this.parent = undefined /** @summary Other channels which we might need - for things like opening a channel on the same node. */ this.relatives = new Set() /** * @typedef {Object} epochs * @property {number} startat UNIX timestamp of when the call was started (created) * @property {number} answerat UNIX timestamp of when the call was answered * @property {number} endat UNIX timestamp of when the call ended */ /** @member {epochs} */ this.epochs = { "startat": Math.floor( +new Date() / 1000 ), "answerat": 0, "endat": 0, "mix": 0 } /** * @typedef { object } sipdialog * @property { string } callid * @property { object } tags * @property { string } tags.local * @property { string } tags.remote */ /** @member { sipdialog } */ this.sip = { "callid": undefined, "tags": { "remote": "", "local": "" } } /** * For inbound calls - this is discovered by authentication. For outbound * this is requested by the caller - i.e. the destination is the registered user. * @type { entity } * Protected for this module. */ this._entity /** * @summary contains network information regarding call */ this.network = { "remote": { "address": "", "port": 0, "protocol": "" } } /** * @summary user definable object that allows other modules to store data in this call. */ this.vars = {} /** @member {string} @private */ this._receivedtelevents = "" /** * @private */ this._promises = { "resolve": { "auth": undefined, "hangup": undefined, "events": undefined, "channelevent": undefined }, "reject": { "auth": undefined, "channelevent": undefined }, "promise": { "hangup": undefined, "events": undefined, "channelevent": undefined } } this._promises.promise.hangup = new Promise( ( resolve ) => { this._promises.resolve.hangup = resolve } ) /** * @private */ this._timers = { "auth": undefined, "newuac": undefined, "events": undefined, "seinterval": undefined, "anyevent": undefined } /** * @private */ this._em = new events.EventEmitter() this._em.setMaxListeners( 0 ) /** * copy default * @type { calloptions } */ this.options = { ...callmanager.options } this.options.headers = {} /** * @private */ this._auth = sipauth.create( callmanager.options.proxy ) this.referauthrequired = callmanager.options.referauthrequired /** * Enable access for other modules. */ this.hangupcodes = hangupcodes /** * If the call is referred somewhere, this is the url we use * @type { string } */ this.referingtouri this.errors = [] } /** * * @returns { entity } */ #getentityforuac() { if( "uac" == this.type ) { return this.options.entity } } /** * * @returns { entity } */ #getentityforuas() { if( !this._entity ) return if( this._entity.uri ) { if( !this._entity.username ) { const uriparts = this._entity.uri.split( "@" ) this._entity.username = uriparts[ 0 ] if( 1 < uriparts.length ) this._entity.realm = uriparts[ 1 ] } } if( !this._entity.uri && this._entity.username && this._entity.realm ) { this._entity.uri = this._entity.username + "@" + this._entity.realm } return { "username": this._entity.username, "realm": this._entity.realm, "uri": this._entity.uri, "display": this._entity.display?this._entity.display:"" } } /** * Returns the entity if known (i.e. outbound or inbound authed). * @returns { Promise< entity > } */ get entity() { return ( async () => { let e if( "uac" == this.type ) { e = this.#getentityforuac() } else { e = this.#getentityforuas() } if( !e ) return const entitycalls = await callstore.getbyentity( this._entity.uri ) let entitycallcount = 0 if( false !== entitycalls ) entitycallcount = entitycalls.size e.ccc = entitycallcount return e } )() } /** * Set the entity information against this call. Itis either set by authentication * or externally (for example if we auth by network location). * Either ( e.uri ) or ( e.realm and e.username ) are required. * @param { entity } e */ set entity( e ) { if( !e.uri && !( e.username && e.realm ) ) return this._entity = e if( !this._entity.display ) this._entity.display = "" if( this._entity.uri ) { if( !this._entity.username ) { const uriparts = this._entity.uri.split( "@" ) this._entity.username = uriparts[ 0 ] if( 1 < uriparts.length ) this._entity.realm = uriparts[ 1 ] } } else if( this._entity.username && this._entity.realm ) { this._entity.uri = this._entity.username + "@" + this._entity.realm } } /** * @typedef remoteid * @property { string } [ schema ] * @property { string } [ password ] * @property { number } [ port ] * @property { Array } [ headers ] * @property { string } host * @property { string } user * @property { string } name * @property { string } uri * @property { boolean } privacy * @property { object } [ params ] * @property { string } type - "callerid" | "calledid" */ /** * * @returns { object } */ #getremotefromheaders() { let parsed = {} if( !this._req ) return parsed if( this._req.has( "p-asserted-identity" ) ) { parsed = this._req.getParsedHeader( "p-asserted-identity" ) } else if( this._req.has( "remote-party-id" ) ) { parsed = this._req.getParsedHeader( "remote-party-id" ) } return parsed } getparsedheader( hdr ) { if( !this._req ) return if( !this._req.has( hdr ) ) return return this._req.getParsedHeader( hdr ) } /** * * @param { object } parsed * @returns */ #fixparseduriobj( parsed ) { if( !parsed ) parsed = { name: "", user: "", host: "", uri: "", privacy: false, type: "callerid" } const name = parsed.name if( !parsed.uri && parsed.user && parsed.host ) parsed.uri = parsed.user + "@" + parsed.host if( parsed.uri && !parsed.user && !parsed.host ) parsed = parseuri( parsed.uri ) if( !parsed.params ) parsed.params = {} if( name ) parsed.name = name return parsed } /** * Remember: this is from the perspective of us - not the phone. * The phone perspective is the opposite * Inbound * A call is received - i.e. we receive a SIP invite * * Outbound * We make an outbound call. i.e. we send an INVITE * * Exceptions * partycalled/clicktocall - we send an INVITE but that is an inbound call * so the invite is i nthe opposie direction. * @returns { "inbound" | "outbound" } */ get direction() { if( this.options.partycalled ) return "inbound" return "uas"===this.type?"inbound":"outbound" } /** * * @param { object } startingpoint */ #overridecallerid( startingpoint ) { if( this.options.callerid ) { if( "number" in this.options.callerid ) startingpoint.user = this.options.callerid.number if( "name" in this.options.callerid ) startingpoint.name = this.options.callerid.name if( "host" in this.options.callerid ) startingpoint.host = this.options.callerid.host } } /** * * @param { object } startingpoint */ #overridecalledid( startingpoint ) { startingpoint.privacy = this.options.privacy if( this.options.calledid ) { if( "number" in this.options.calledid ) { startingpoint.user = this.options.calledid.number startingpoint.uri = this.options.calledid.number + "@" + startingpoint.host } if( "name" in this.options.calledid ) startingpoint.name = this.options.calledid.name } } /** * * @param { object } startingpoint * @returns */ #fromremoteheaders( startingpoint ) { const parsed = this.#fixparseduriobj( this.#getremotefromheaders() ) if( parsed.uri ) startingpoint.uri = parsed.uri if( parsed.user ) startingpoint.user = parsed.user if( parsed.host ) startingpoint.host = parsed.host if( parsed.name ) startingpoint.name = parsed.name } /** * * @param { object } startingpoint * @returns { boolean } */ #fromrefertouri( startingpoint ) { if( !this.referingtouri ) return false const referdest = parseuri( this.referingtouri ) if( !referdest ) return if( referdest.uri ) startingpoint.uri = referdest.uri if( referdest.user ) startingpoint.user = referdest.user if( referdest.host ) startingpoint.host = referdest.host return true } /** * * @param { object } startingpoint * @returns */ #fromcontact( startingpoint ) { const dest = parseuri( this.options.contact ) if( !dest ) return if( dest.uri ) startingpoint.uri = dest.uri if( dest.user ) startingpoint.user = dest.user if( dest.host ) startingpoint.host = dest.host } /** * * @param { object } startingpoint * @returns */ #fromdestination( startingpoint ) { const dest = this.destination startingpoint.user = dest.user startingpoint.host = dest.host } /** * * @param { object } startingpoint * @returns */ #fromoutentity( startingpoint ) { const entity = this.options.entity if( entity ) { startingpoint.name = entity.display startingpoint.uri = entity.uri startingpoint.user = entity.username startingpoint.host = entity.realm return true } return false } /** * * @param { object } startingpoint * @returns */ #frominentity( startingpoint ) { const entity = this._entity if( !entity ) return false startingpoint.name = entity.display startingpoint.uri = entity.uri startingpoint.user = entity.username startingpoint.host = entity.realm return true } /** * We have received an INVITE * @param { object } startingpoint */ #calledidforuas( startingpoint ) { this.#fromdestination( startingpoint ) } /** * We have sent an INVITE - which could also be 3rd party * @param { object } startingpoint */ #calledidforuac( startingpoint ) { if( !this.#fromoutentity( startingpoint ) ) { this.#fromcontact( startingpoint ) } } /** * We have sent an INVITE - but it could be 3rd party * @param { object } startingpoint * @returns */ #calleridforuac( startingpoint ) { if( !this.#fromoutentity( startingpoint ) ) { this.#fromcontact( startingpoint ) } } /** * We have received an INVITE - so caller ID comes from headers / auth * * @param { object } startingpoint */ #calleridforuas( startingpoint ) { this.#frominentity( startingpoint ) this.#fromremoteheaders( startingpoint ) } /** * * @returns { object } */ #callerid() { const startingpoint = { "name": "", "uri": "", "user": "0000000000", "host": "localhost.localdomain", "privacy": true === this.options.privacy, "type": "callerid" } if( "uas" === this.type ) this.#calleridforuas( startingpoint ) else { if( this.options.partycalled ) { this.#calleridforuac( startingpoint ) } else { const other = this.other if( other ) { if( "uas" === other.type ) { other.#calleridforuas( startingpoint ) other.#overridecallerid( startingpoint ) } else { other.#calledidforuac( startingpoint ) if( this.type !== other.type ) other.#fromrefertouri( startingpoint ) } } else { if ( this.options.partycaller ) { this.#overridecalledid( startingpoint ) return startingpoint } this.#calleridforuac( startingpoint ) } } } this.#overridecallerid( startingpoint ) return startingpoint } /** * @returns { remoteid } */ get callerid() { return this.#callerid() } /** * * @returns { object } */ #calledid() { const startingpoint = { "name": "", "uri": "", "user": "0000000000", "host": "localhost.localdomain", "privacy": true === this.options.privacy, "type": "calledid" } if( "uas" == this.type ) this.#calledidforuas( startingpoint ) else if( this.options.partycalled ) this.#fromrefertouri( startingpoint ) else this.#calledidforuac( startingpoint ) this.#overridecalledid( startingpoint ) return startingpoint } /** * Returns the called object. i.e. * If we are inbound then this is the destination * If we are outbound then this is the entity we are calling * @returns { remoteid } */ get calledid() { return this.#calledid() } /** * @param { string } c */ set callerid( c ) { if( undefined == c ) return if( !this.options.callerid ) this.options.callerid = {} if( !this.options.callerid.number ) this.options.callerid.number = "" this.options.callerid.number = c } /** * @param { string } c */ set calleridname( c ) { if( undefined == c ) return if( !this.options.callerid ) this.options.callerid = {} if( !this.options.callerid.name ) this.options.callerid.name = "" this.options.callerid.name = c } /** * @param { string } c */ set calledid( c ) { if( !this.options.calledid ) this.options.calledid = {} if( !this.options.calledid.number ) this.options.calledid.number = "" if( !this.options.calledid.name ) this.options.calledid.name = "" this.options.calledid.number = c } /** * @param { string } c */ set calledidname( c ) { if( !this.options.calledid ) this.options.calledid = {} if( !this.options.calledid.number ) this.options.calledid.number = "" if( !this.options.calledid.name ) this.options.calledid.name = "" this.options.calledid.name = c } /** @typedef destination @property { string } host @property { string } user */ /** * Return the destination of the call. * If not present returns the contact. * @return { object } destination - parsed uri */ get destination() { if( undefined !== this.referingtouri ) { const parsed = parseuri( this.referingtouri ) parsed.user = decodeURIComponent( parsed.user ) return parsed } return this.contact } /** Return the contact of the call. @return { object } destination - parsed uri */ get contact() { if( "uac" == this.type ) { const parsed = parseuri( this.options.contact ) parsed.user = decodeURIComponent( parsed.user ) return parsed } const parsed = parseuri( this._req.msg.uri ) parsed.user = decodeURIComponent( parsed.user ) return parsed } /* State functions Get state as a string According to state machine in RFC 4235, we send early if we have received a 1xx with tag I am going to use 100 and 180 - which should be the same. */ /** hasmedia @return { boolean } - true if the call has media (i.e. is established on not held). */ get hasmedia() { if( this.state.held ) return false return true == this.state.established } /** trying @return {bool} - true if the call has been trying. */ set trying( s ) { if( this.state.trying != s ) { this.state.trying = s } } /** trying @return { boolean } - true if the call has been trying. */ get trying() { return this.state.trying } /** ringing @return { boolean } - true if the call has been ringing. */ get ringing() { return this.state.ringing } /** * @param { boolean } r - the new state */ set ringing( r ) { this.state.ringing = r } /** established - if the call isn't already established then set the answerat time. @param { boolean } s - true if the call has been established. */ set established( s ) { if( this.state.established != s ) { this.epochs.answerat = Math.floor( +new Date() / 1000 ) this.state.established = s } } /** established @return { boolean } - true if the call has been established. */ get established() { return this.state.established } /** @summary canceled - if the call isn't already canceled then set the endat time. @type { boolean } */ set canceled( s ) { if( this.state.canceled != s ) { this.epochs.endat = Math.floor( +new Date() / 1000 ) this.state.canceled = s } } /** @summary is the call canceled @return {boolean} - true if the call has been canceled. */ get canceled() { return true == this.state.canceled } /** destroyed - if the call isn't already desroyed then set the endat time. @param { boolean } s - true if the call has been destroyed. */ set destroyed( s ) { if( this.state.destroyed != s ) { this.epochs.endat = Math.floor( +new Date() / 1000 ) this.state.destroyed = s } } /** destroyed @return { boolean } - true if teh call has been destroyed. */ get destroyed() { return true == this.state.destroyed } get destroyedcancelledorhungup() { return this.state.destroyed || this.state.canceled || this._state._hangup } /** @summary the current state of the call as a string: trying|proceeding|early|confirmed|terminated @return { string } */ get statestr() { if( this.state.established ) { return "confirmed" } if( this.state.ringing ) { return "early" } if( this.state.trying ) { return "proceeding" } if( this.state.destroyed ) { return "terminated" } return "trying" } /** duration @return { number } - the number of seconds between now (or endat if ended) and the time the call was started. */ get duration() { if( 0 !== this.epochs.endat ) return this.epochs.endat - this.epochs.startat return Math.floor( +new Date() / 1000 ) - this.epochs.startat } /** Get the estrablished time. @return { number } - the number of seconds between now (or endat if ended) and the time the call was answered. */ get billingduration() { if( 0 === this.epochs.answerat ) return 0 if( 0 !== this.epochs.endat ) return this.epochs.endat - this.epochs.answerat return Math.floor( +new Date() / 1000 ) - this.epochs.answerat } /** * Callback for events we pass back to inerested parties. * Registers an event callback for this specific call. An event sink registered * on this member will receive events only for this call. We emit on call specific * emitter and a global emitter. * @param { string } ev - The contact string for registered or other sip contact * @param { (...args: any[] ) => void } cb */ on( ev, cb ) { this._em.on( ev, cb ) } /** * See event emitter once * @param { string } ev - The contact string for registered or other sip contact * @param { (...args: any[] ) => void } cb */ once( ev, cb ) { this._em.once( ev, cb ) } /** * See event emitter off * @param { string } ev - The contact string for registered or other sip contact * @param { (...args: any[] ) => void } cb */ off( ev, cb ) { if( !cb ) { this._em.removeAllListeners( ev ) return } this._em.off( ev, cb ) } /** * See event emitter removeAllListeners * @param { string } [ ev ] - The contact string for registered or other sip contact */ removealllisteners( ev ) { if( !ev ) { const evnames = this._em.eventNames() for( const evname of evnames ) { this._em.removeAllListeners( evname ) } } else { this._em.removeAllListeners( ev ) } } /** See event emitter setMaxListeners @param { number } n */ setmaxlisteners( n ) { this._em.setMaxListeners( n ) } /** Allows 3rd parties to emit events to listeners specific to this call. @param { string } ev - event name @param { any } argv1 */ emit( ev, argv1 ) { if( argv1 ) return this._em.emit( ev, argv1 ) else return this._em.emit( ev, this ) } /** Call creation event. @event call.new @type {call} */ /** Emitted when a call is ringing @event call.ringing @type {call} */ /** Emitted when a call is receives early @event call.early @type {call} */ /** Emitted when a call is answered @event call.answered @type {call} */ /** Emitted when a call is answered @event call.updated @type {call} */ /** Emitted when a call is mixed with another call (not after unhold as this has it's own event) @event call.mix @type {call} */ /** Emitted when a call is authed @event call.auth.start @type { object } */ /** Emitted when a call is authed @event call.authed @type {call} */ /** Emitted when a call auth fails @event call.authed.failed @type {call} */ /** Emitted when a call is placed on hold @event call.hold @type {call} */ /** Emitted when a call is taken off hold @event call.unhold @type {call} */ /** Emitted when a call is destroyed @event call.destroyed @type {call} */ /** Emitted immediatly called after call.destroyed @event call.reporting @type {call} */ /** Emitted immediatly before a call is picked @event call.pick @type {call} */ /** Emits the event call.pick to allow other parts of dial plan to give up on further processing. It wuld be normal to bridge this call to another after this call has been made. @return { call } */ pick() { this.state.picked = true this._em.emit( "call.pick", this ) return this } /** Delink calls logically - any calls which have parent or children they are all removed. when the dialog is either answered (or doesn't answer for some reason). The promise resolves to a new call is one is generated, or undefined if not. @param { boolean } [ unmix ] if true also unmix this call @return { call } */ detach( unmix ) { if( this.parent ) { this.parent.children.delete( this ) } for( const child of this.children ) { child.parent = false } this.parent = undefined this.children.clear() this.relatives.clear() if( unmix && this.channels.audio ) this.channels.audio.unmix() return this } /** * Logically adopt a child call * @param { object } other * @param { boolean } [ mix ] * @return { call } */ adopt( other, mix ) { other.parent = this this.children.add( other ) other.moh = this.moh /* maintain privacy */ if ( this.options.privacy ) other.options.privacy = this.options.privacy if( mix ) this.mix( other ) return this } /** * Create a bond between us and another call. This is currently only used * to provide other channels we know we might need to open a channel on * the same node as. (replaces preferredcall) * @param { object } relative * @return { call } */ bond( relative ) { if( !relative ) return this this.relatives.add( relative ) return this } /** * Disown distant relatives. * @return { call } */ disown() { this.relatives.clear() return this } /** Called from newuac when we receive a 180 @private */ _onring() { if( this.state.ringing ) return this.state.ringing = true if( undefined !== this.parent ) { this.parent.ring() } this._em.emit( "call.ringing", this ) callmanager.options.em.emit( "call.ringing", this ) } /** Called from newuac when we receive a 183 @private @returns { Promise } */ async _onearly() { try { if( this.state.established ) return if( !this._res || !this._res.msg || !this._res.msg.body ) return /* we have this._res */ this.sdp.remote = sdpgen.create( this._res.msg.body ) this.#parsesrtpsetup() await this.answer( { "early": true } ) if( !this.parent || this.parent.state.established ) return await this.parent.answer( { "early": true } ) await this.channels.audio.mix( this.parent.channels.audio ) this.parent._res.send( 183, { headers: { "User-Agent": "project", "Supported": "replaces" }, "body": this.parent.sdp.local.toString() } ) } catch( e ) { throw new Error( e ) } } /** * * @returns { boolean } - true if an error has occured */ #answerparenterrors() { if( !this.parent.established ) { this.hangup( hangupcodes.USER_GONE ) return true } /* are we still established? */ if( !this.established || this.state.destroyed || this.parent.destroyed ) return true if( !this.channels.audio ) { /* something bad has happened */ this.hangup( hangupcodes.NOT_ACCEPTABLE ) return true } if( !this.parent.channels.audio ) { this.hangup( hangupcodes.USER_GONE ) return true } return false } /** * * @returns { Promise< object > } - return ourself */ async #answerparent() { if( !this.parent ) return if( !this.parent.established ) { try { await this.parent.answer() } catch( e ) { console.trace( e ) } } if( this.#answerparenterrors() ) return this this.channels.audio.mix( this.parent.channels.audio ) this._em.emit( "call.mix", this ) callmanager.options.em.emit( "call.mix", this ) this.parent._em.emit( "call.mix", this.parent ) callmanager.options.em.emit( "call.mix", this.parent ) this.epochs.mix = Math.floor( +new Date() / 1000 ) if( this.parent ) this.parent.epochs.mix = Math.floor( +new Date() / 1000 ) return this } /** On an early negotiation we have already sent our sdp without knowing what the otherside is going to offer. We now have the other sides SDP so we can work out the first common CODEC. this = child call (the new call - the bleg) @private */ async _onearlybridge() { if( this.destroyed ) return this.#addevents( this._dialog ) this.sdp.remote = sdpgen.create( this._dialog.remote.sdp ) const selectedcodec = this.sdp.remote.intersection( this.options.preferedcodecs, true ) if( !selectedcodec ) { return this.hangup( hangupcodes.INCOMPATIBLE_DESTINATION ) } this.sdp.local.select( selectedcodec ) if( true === this.options.rfc2833 && this.sdp.remote.has( "2833" ) ) { this.sdp.local.addcodecs( "2833" ) } this.#parsesrtpsetup() this.sdp.remote.setdynamepayloadtypes( this.sdp.local ) const channeldef = await this.#createchannelremotedef() if( !channeldef ) return this.hangup( hangupcodes.INCOMPATIBLE_DESTINATION ) this.channels.audio.remote( channeldef.remote ) await this.#answerparent() return this } /** Accept and bridge to calls with late negotiation. this = child call (the new call - the bleg) OR this = standalone call - no other legs @private */ async _onlatebridge() { if ( this._dialog.res ) this.sdp.remote = sdpgen.create( this._dialog.res.msg.body ) else this.sdp.remote = sdpgen.create( this._req.msg.body ) this.#parsesrtpsetup() const selectedcodec = this.sdp.remote.intersection( this.options.preferedcodecs, true ) if( !selectedcodec ) { return this.hangup( hangupcodes.INCOMPATIBLE_DESTINATION ) } const channeldef = await this.#createchannelremotedef() if( !channeldef ) { this.hangup( hangupcodes.INCOMPATIBLE_DESTINATION ) return } if ( this.channels.audio ) { this.channels.audio.close() delete this.channels.audio } await this.#openrelatedchannel( channeldef ) if ( !this.channels.audio ) { this.#servererror( "Failed to open channel" ) return } this.sdp.local = sdpgen.create() .addcodecs( selectedcodec ) .setconnectionaddress( this.channels.audio.local.address ) .setaudioport( this.channels.audio.local.port ) .setdynamepayloadtypes( this.sdp.remote ) if( true === this.options.rfc2833 ) { this.sdp.local.addcodecs( "2833" ) } if( this._iswebrtc ) { const ch = this.channels.audio this.sdp.local.addssrc( ch.local.ssrc ) .secure( ch.local.dtls.fingerprint, this.#getsrtpsetup( this.channels.secure.audio ) ) .addicecandidates( ch.local.address, ch.local.port, ch.local.icepwd ) .rtcpmux() } try { this._dialog = await this._dialog.ack( this.sdp.local.toString() ) } catch( e ) { if( this.destroyed ) return this.hangup( hangupcodes.USER_GONE ) return } this.established = true this.#addevents( this._dialog ) await this.#answerparent() return this } /** Sometimes we don't care who if we are the parent or child - we just want the other party @return { object | boolean } returns call object or if none false */ get other() { if( this.parent ) return this.parent return this.child } /** * Return specifically first child - prefer established otherwise first */ get child() { /* first established */ for( const child of this.children ) { if( child.established ) { return child } } /* or the first */ if( 0 < this.children.size ) return this.children.values().next().value return false } /** auth - returns promise. This will force a call to be authed by a client. If the call has been refered by another client that has been authed this call will assume that auth. @todo check refering call has been authed @return { Promise } Returns promise which resolves on success or rejects on failed auth. If not caught this framework will catch and cleanup. */ async auth() { if( undefined === callmanager.options.userlookup ) { console.trace( "no userlookup function provided" ) return } /* we have been requested to auth - so set our state to unauthed */ this.state.authed = false /* if we are supplied with an auth header that means it is getting the auth nonce elsewhere - our registrar. */ if( this._auth.has( this._req ) ) { /* if the client has supplied the header */ if( callmanager.options.registrar && !this._auth.equal( sipauth.parseauthheaders( this._req ) ) ) { const auth = callmanager.options.registrar.getauth( this._req ) if( auth ) { this._auth = auth } } /* whatever the outcome above, we must immediatly check */ await this._onauth( this._req, this._res ) return } const authtimeoutms = 10000 this._timers.auth = setTimeout( () => { /* something has overtaken events and cleaned us up already */ if( !this._promises.reject ) return if( !this._promises.reject.auth ) return const rej = this._promises.reject.auth this._promises.resolve.auth = undefined this._promises.reject.auth = undefined this._timers.auth = undefined rej( new SipError( hangupcodes.REQUEST_TIMEOUT, "Auth timed out" ) ) this.hangup( hangupcodes.REQUEST_TIMEOUT ) }, authtimeoutms ) const authpromise = new Promise( ( resolve, reject ) => { this._promises.resolve.auth = resolve this._promises.reject.auth = reject } ) const e = await this.entity let authrealm if( e ) authrealm = e.realm /* Fresh auth */ this._auth = sipauth.create( callmanager.options.proxy ) this._auth.requestauth( this._req, this._res, authrealm ) await authpromise } /** Called by us we handle the auth challenge in this function @private */ async _onauth( req, res ) { this._req = req this._res = res req.on( "cancel", ( /*req*/ ) => this._oncanceled( /*req*/ ) ) if( !this._auth.has( this._req ) ) return false const authorization = this._auth.parseauthheaders( this._req ) const user = await callmanager.options.userlookup( authorization.username, authorization.realm ) this._em.emit( "call.auth.start", { call: this, user } ) callmanager.options.em.emit( "call.auth.start", { call: this, user } ) if( !user || !this._auth.verifyauth( this._req, authorization, user.secret ) ) { if( this._auth.stale ) { const e = await this.entity let authrealm if( e ) authrealm = e.realm this._auth.requestauth( this._req, this._res, authrealm ) return false } this._em.emit( "call.authed.failed", this ) callmanager.options.em.emit( "call.authed.failed", this ) await this.hangup( hangupcodes.FORBIDDEN ) const reject = this._promises.reject.auth this._promises.resolve.auth = undefined this._promises.reject.auth = undefined clearTimeout( this._timers.auth ) this._timers.auth = undefined if( reject ) reject( new SipError( hangupcodes.FORBIDDEN, "Bad Auth" )) return false } clearTimeout( this._timers.auth ) this._timers.auth = false this._entity = { "username": authorization.username, "realm": authorization.realm, "uri": authorization.username + "@" + authorization.realm, "display": !user.display?"":user.display } this.state.authed = true await callstore.set( this ) const resolve = this._promises.resolve.auth this._promises.resolve.auth = undefined this._promises.reject.auth = undefined this._timers.auth = undefined if( resolve ) resolve() this._em.emit( "call.authed", this ) callmanager.options.em.emit( "call.authed", this ) return this.state.authed } /** Called by us to handle call cancelled @private */ _oncanceled( /*req, res*/ ) { this.canceled = true for( const child of this.children ) { child.hangup( hangupcodes.ORIGINATOR_CANCEL ) } this._onhangup( "wire", hangupcodes.ORIGINATOR_CANCEL ) } /** Called by us to handle DTMF events. If it finds a match it resolves the Promise created by waitfortelevents. @private */ _tevent( e ) { this._receivedtelevents += e if( undefined !== this.eventmatch ) { const ourmatch = this._receivedtelevents.match( this.eventmatch ) if( null !== ourmatch ) { delete this.eventmatch this._receivedtelevents = this._receivedtelevents.slice( ourmatch[ 0 ].length + ourmatch.index ) if( this._promises.resolve.events ) { const r = this._promises.resolve.events this._promises.resolve.events = undefined this._promises.promise.events = undefined r( ourmatch[ 0 ] ) } if( this._timers.events ) { clearTimeout( this._timers.events ) this._timers.events = undefined } } } } /** Called by our call plan to wait for events for auto attendant/IVR. @param { string | RegExp } [match] - reg exp matching what is required from the user. @param { number } [timeout] - time to wait before giving up. @return { Promise } - the promise either resolves to a string if it matches or undefined if it times out.. */ waitfortelevents( match = /[0-9A-D*#]/, timeout = 30000 ) { if( this.destroyed ) throw Error( "Call already destroyed" ) if( this._promises.promise.events ) return this._promises.promise.events this._promises.promise.events = new Promise( ( resolve ) => { this._timers.events = setTimeout( () => { if( this._promises.resolve.events ) { this._promises.resolve.events() } this._promises.resolve.events = undefined this._promises.promise.events = undefined this._timers.events = undefined delete this.eventmatch }, timeout ) if( "string" === typeof match ){ this.eventmatch = new RegExp( match ) } else { this.eventmatch = match } this._promises.resolve.events = resolve /* if we have something already in our buffer */ this._tevent( "" ) } ) return this._promises.promise.events } /** Clear our current buffer to ensure new input */ clearevents() { this._receivedtelevents = "" } /** If we are not ringing - send ringing to the other end. */ ring() { if( "uac" === this.type ) return if ( this.established ) { this.play( this.ringsoup ) } else if( !this.ringing ) { this.state.ringing = true this._res.send( 180, { headers: { "User-Agent": "project", "Supported": "replaces" } } ) this._em.emit( "call.ringing", this ) callmanager.options.em.emit( "call.ringing", this ) } } /** * Play a sound soup * @param { object } soup * @returns { Boolean } */ play( soup ) { if( !this.channels.audio ) return false if( !this.established && !this.state.early ) return false if( !soup ) return false this.channels.audio.play( soup ) return true } /**true Shortcut to hangup with the reason busy. */ busy() { this.hangup( hangupcodes.USER_BUSY ) } /** * @private */ get _iswebrtc() { /* Have we received remote SDP? */ if( this.sdp.remote ) { return this.sdp.remote.sdp.media[ 0 ] && -1 !== this.sdp.remote.sdp.media[ 0 ].protocol.toLowerCase().indexOf( "savpf" ) /* 'UDP/TLS/RTP/SAVPF' */ } if( !this.options || !this.options.contact ) return false return -1 !== this.options.contact.indexOf( ";transport=ws" ) } /** * * @param { object } channeldef */ async #openchannelsforanswer( channeldef ) { if( this.channels.audio ) { /* we have already opened a channel (probably early now answering) */ this.channels.audio.remote( channeldef.remote ) } else { await this.#openrelatedchannel( channeldef ) if( !this.channels.audio ) { this.#servererror( "Failed to open channel" ) return } this.sdp.local = sdpgen.create() .addcodecs( this.sdp.remote.selected.name ) .setconnectionaddress( this.channels.audio.local.address ) .setaudioport( this.channels.audio.local.port ) .setdynamepayloadtypes( this.sdp.remote ) this.#checkandadd2833() if( this._iswebrtc ) { this.sdp.local.addssrc( this.channels.audio.local.ssrc ) .secure( this.channels.audio.local.dtls.fingerprint, this.#getsrtpsetup( this.channels.secure.audio ) ) .addicecandidates( this.channels.audio.local.address, this.channels.audio.local.port, this.channels.audio.local.icepwd ) .rtcpmux() } } } /** * that can be used to open the new channel's node. * * @returns { Promise } */ async #choosecodecforanswer() { if( this._req.msg && this._req.msg.body ) { if( !this.sdp.remote.intersection( this.options.preferedcodecs, true ) ) { return this.hangup( hangupcodes.INCOMPATIBLE_DESTINATION ) } const channeldef = await this.#createchannelremotedef() if( !channeldef ) return this.hangup( hangupcodes.INCOMPATIBLE_DESTINATION ) /* We might have already opened our audio when we received 183 (early). */ await this.#openchannelsforanswer( channeldef ) } } /** * Answer this (inbound) call and store a channel which can be used. This framework will catch and cleanup this call if this is rejected. * @param { object } [ options ] * @param { boolean } [ options.early ] - don't answer the channel (establish) but establish early media (respond to 183). * * @return {Promise} Returns a promise which resolves if the call is answered, otherwise rejects the promise. */ async answer( options = { early: false } ) { if( this.canceled || this.established ) return await this.#choosecodecforanswer() if( !this.channels.audio ) { this.#servererror( "Failed to open channel" ) return } if( this.canceled ) return if( options.early ) { this.state.early = true this._em.emit( "call.early", this ) callmanager.options.em.emit( "call.early", this ) } else { if ( !this.sdp.local ) { this.#servererror( "No local SDP" ) return } const headers = { "User-Agent": "project", "Supported": "replaces" } if( this.#timer ) { headers[ "Supported" ] = "replaces, timer" headers[ "Session-Expires" ] = `${this.#seexpire/1000};refresher=${this.#refresher}` } const dialog = await callmanager.options.srf.createUAS( this._req, this._res, { localSdp: this.sdp.local.toString(), headers } ) this.#setdialog( dialog ) this.sip.tags.local = dialog.sip.localTag callstore.set( this ) this.#addevents( this._dialog ) this._em.emit( "call.answered", this ) callmanager.options.em.emit( "call.answered", this ) } } #handlechannelevclose( e ) { /* keep a record */ if( this.channels.audio && this.channels.audio.history ) { this.channels.closed.audio.push( this.channels.audio.history ) } else { this.channels.closed.audio.push( e ) } this.channels.count-- if ( 0 === this.channels.count ) { delete this.chann