@babblevoice/babble-drachtio-callmanager
Version:
Call processing to create a PBX
1,946 lines (1,637 loc) • 121 kB
JavaScript
const { v4: uuidv4 } = require( "uuid" )
const events = require( "events" )
const dns = require( "node:dns" )
// @ts-ignore
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] }
}
}
}
/**
* Used in caller id etc
* @param { string } input
* @returns { string }
*/
function sanitizecalleridnumber( input ) {
if (typeof input !== "string") return ""
// Digits, +, #, *
return input.replace( /[^a-zA-Z0-9+*#._-]/g, "").slice( 0, 32 )
}
/**
* used in caller id etc
* @param { string } input
* @returns { string }
*/
function sanitizecalleridname( input ) {
if (typeof input !== "string") return ""
// Allow letters, numbers, space, dash, underscore, dot
return input.replace( /[^a-zA-Z0-9 \-_.',()/+*#&]/g, "" ).slice( 0, 40 )
}
/*
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,
"adoptions": 0,
"adoptees": 0,
"mixes": 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,
"roleset": 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" )
}
/*
bug in dratchio - this._req.has( "remote-party-id" ) returns
name actually quoted in the string.
*/
if( parsed.name ) parsed.name = parsed.name.replace( /^["']+|["']+$/g, '' )
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 - we send an INVITE but that is an inbound call
* so the invite is in the opposite direction.
* @returns { "inbound" | "outbound" }
*/
get direction() {
if( this.options.partycalled ) return "outbound"
if( this.options.partycaller ) {
console.error( "WARNING: partycaller retired - use partycalling instead" )
return "inbound"
}
if( this.options.partycalling ) 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 ) return false
startingpoint.name = entity.display
startingpoint.uri = entity.uri
startingpoint.user = entity.username
startingpoint.host = entity.realm
return true
}
/**
*
* @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 )
}
/**
* Get the caller id when we have another leg.
* @param { object } startingpoint
* @param { object } other
*/
#calleridother( startingpoint, other ) {
if( "uas" === other.type ) {
other.#calleridforuas( startingpoint )
other.#overridecallerid( startingpoint )
} else {
other.#calledidforuac( startingpoint )
if( this.type !== other.type ) other.#fromrefertouri( startingpoint )
}
}
/**
*
* @param { object } startingpoint
* @param { object } other
*/
#calledidother( startingpoint, other ) {
if( "uas" === other.type ) {
other.#calledidforuas( startingpoint )
other.#overridecalledid( startingpoint )
} else {
other.#calleridforuac( startingpoint )
}
}
/**
*
* @returns { object }
*/
#callerid() {
const startingpoint = {
"name": "",
"uri": "",
"user": "0000000000",
"host": "localhost.localdomain",
"privacy": true === this.options.privacy,
"type": "callerid"
}
if( "uas" === this.type ) {
if( this.options.partycalled ) {
this.#calleridforuac( startingpoint )
const other = this.other
if( other ) {
this.#calleridother( startingpoint, other )
}
} else {
this.#calleridforuas( startingpoint )
}
} else {
if( this.options.partycalling ) {
this.#fromoutentity( startingpoint )
this.#overridecallerid( startingpoint )
return startingpoint
} else if ( this.options.partycaller ) {
console.error( "WARNING: partycaller retired - use partycalling instead" )
this.#fromoutentity( startingpoint )
this.#overridecallerid( startingpoint )
return startingpoint
}
const other = this.other
if( other ) {
this.#calleridother( startingpoint, other )
} else {
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 ) {
if( this.options.partycalled ) {
this.#fromrefertouri( startingpoint )
} else {
this.#calledidforuas( startingpoint )
}
} else {
if( this.options.partycalling ) {
const other = this.other
if( other ) {
this.#calledidother( startingpoint, other )
} else {
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 = sanitizecalleridnumber( 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 = sanitizecalleridname( 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 updated
@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
this.counters.adoptions += 1
other.counters.adoptees += 1
/* 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()
.icelite()
}
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 = 5000
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()
.icelite()
}
}
}
/**
* 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