@babblevoice/babble-drachtio-callmanager
Version:
Call processing to create a PBX
1,883 lines (1,597 loc) • 128 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 Lifecycle callbacks supplied to newuac for this (outbound) call.
* @type { newuaccallbacks }
*/
this.callbacks = {}
/** @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
/* When we adopt-and-mix (enterprise queues and ring groups connect this
way rather than via #answerparent) we are the parent leg. preconnect
runs before the mix (projectrtp cannot play into a mixing channel),
then we mix, then postconnect. Only take the deferred (hook) path when a
hook is actually registered - otherwise mix synchronously in this tick,
exactly as before, so callers that assume the mix has been issued on
return (recording, queue setup) don't race a microtask. */
if( mix ) {
if( this.callbacks && ( this.callbacks.preconnect || this.callbacks.postconnect ) ) {
this.#notifyconnecthook( this, other, "preconnect" )
.then( () => this.mix( other ) )
.then( () => this.#notifyconnecthook( this, other, "postconnect" ) )
.catch( ( e ) => console.trace( e ) )
} else {
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
}
/**
* Register a handler to run once, on this (caller) leg, immediately BEFORE we
* are mixed with a party connecting to us - whether via a direct bridge
* (#answerparent) or an adopt-and-mix (enterprise queues, ring groups). Use
* this to play a prompt to each leg on connect: projectrtp cannot play into a
* channel that is being mixed, so it must happen before the mix. The handler
* is passed the connecting (callee/agent) leg and fires at most once.
* @param { connecthookcallback } handler
* @return { call }
*/
preconnect( handler ) {
this.callbacks.preconnect = handler
return this
}
/**
* Register a handler to run once, on this (caller) leg, immediately AFTER we
* are mixed with a party connecting to us. Counterpart to preconnect() for
* work that should happen once the two legs are bridged. The handler is passed
* the connecting (callee/agent) leg and fires at most once.
* @param { connecthookcallback } handler
* @return { call }
*/
postconnect( handler ) {
this.callbacks.postconnect = handler
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
}
/**
* Fire a parent leg's connect hook (preconnect/postconnect) exactly once, as
* a child leg connects to it. Handlers are registered on the caller leg via
* call.preconnect()/call.postconnect() (stored on callbacks - vars is reserved
* for client apps) and cleared here so each runs once per registration (i.e.
* once per call) even if the caller subsequently connects to further legs
* (re-queue, transfer, ...).
* @param { call } parentleg - the leg the handler was registered on (the caller)
* @param { call } childleg - the leg that is connecting (callee/agent)
* @param { string } hook - "preconnect" (before the mix) or "postconnect" (after)
* @returns { Promise< void > }
* @private
*/
async #notifyconnecthook( parentleg, childleg, hook ) {
if( !parentleg || !parentleg.callbacks || !parentleg.callbacks[ hook ] ) return
const handler = parentleg.callbacks[ hook ]
parentleg.callbacks[ hook ] = undefined /* fire once */
try {
await handler( childleg )
} catch( e ) {
console.trace( e )
}
}
/**
*
* @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
/* preconnect: our parent leg's handler runs BEFORE the mix. projectrtp
cannot play audio into a channel that is being mixed, so a prompt that
must be heard on connect is played to each leg separately here (both
channels are open by this point), then we mix. Only yield when a hook is
actually registered - a normal bridge must mix synchronously in this tick
exactly as before, or recording/queue setup that assumes the mix has been
issued can race. */
if( this.parent.callbacks.preconnect ) {
await this.#notifyconnecthook( this.parent, this, "preconnect" )
}
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 )
/* Now we are bridged/mixed with our parent, notify any post-connect handlers.
The child-level handler is the one supplied to this leg's own newuac; the
parent-leg postconnect is registered on the caller via call.postconnect(). */
if( this.callbacks.onconnect ) {
try {
await this.callbacks.onconnect( this )
} catch( e ) {
console.trace( e )
}
}
if( this.parent.callbacks.postconnect ) {
await this.#notifyconnecthook( this.parent, this, "postconnect" )
}
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 ]