Full-featured SIP router with multi-provider trunking, browser softphone via WebRTC, real-time Opus/G.722/PCM transcoding in Rust, RNNoise ML noise suppression, Kokoro neural TTS announcements, and a Lit-based web dashboard with live call monitoring and REST API.
281 lines
9.1 KiB
TypeScript
281 lines
9.1 KiB
TypeScript
/**
|
||
* SipDialog — tracks the state of a SIP dialog (RFC 3261 §12).
|
||
*
|
||
* A dialog is created by a dialog-establishing request (INVITE, SUBSCRIBE, …)
|
||
* and its 1xx/2xx response. It manages local/remote tags, CSeq counters,
|
||
* the route set, and provides helpers to build in-dialog requests (ACK, BYE,
|
||
* re-INVITE, …).
|
||
*
|
||
* Usage:
|
||
* ```ts
|
||
* // Caller (UAC) side — create from the outgoing INVITE we just sent:
|
||
* const dialog = SipDialog.fromUacInvite(invite);
|
||
*
|
||
* // When a 200 OK arrives:
|
||
* dialog.processResponse(response200);
|
||
*
|
||
* // Build ACK for the 2xx:
|
||
* const ack = dialog.createAck();
|
||
*
|
||
* // Later — hang up:
|
||
* const bye = dialog.createRequest('BYE');
|
||
* ```
|
||
*/
|
||
|
||
import { SipMessage } from './message.ts';
|
||
import { generateTag, generateBranch } from './helpers.ts';
|
||
import { Buffer } from 'node:buffer';
|
||
|
||
export type TDialogState = 'early' | 'confirmed' | 'terminated';
|
||
|
||
export class SipDialog {
|
||
callId: string;
|
||
localTag: string;
|
||
remoteTag: string | null = null;
|
||
localUri: string;
|
||
remoteUri: string;
|
||
localCSeq: number;
|
||
remoteCSeq: number = 0;
|
||
routeSet: string[] = [];
|
||
remoteTarget: string; // Contact URI of the remote party
|
||
state: TDialogState = 'early';
|
||
|
||
// Transport info for sending in-dialog messages.
|
||
localHost: string;
|
||
localPort: number;
|
||
|
||
constructor(options: {
|
||
callId: string;
|
||
localTag: string;
|
||
remoteTag?: string;
|
||
localUri: string;
|
||
remoteUri: string;
|
||
localCSeq: number;
|
||
remoteTarget: string;
|
||
localHost: string;
|
||
localPort: number;
|
||
routeSet?: string[];
|
||
}) {
|
||
this.callId = options.callId;
|
||
this.localTag = options.localTag;
|
||
this.remoteTag = options.remoteTag ?? null;
|
||
this.localUri = options.localUri;
|
||
this.remoteUri = options.remoteUri;
|
||
this.localCSeq = options.localCSeq;
|
||
this.remoteTarget = options.remoteTarget;
|
||
this.localHost = options.localHost;
|
||
this.localPort = options.localPort;
|
||
this.routeSet = options.routeSet ?? [];
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Factory: create dialog from an outgoing INVITE (UAC side)
|
||
// -------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Create a dialog from an INVITE we are sending.
|
||
* The dialog enters "early" state; call `processResponse()` when
|
||
* provisional or final responses arrive.
|
||
*/
|
||
static fromUacInvite(invite: SipMessage, localHost: string, localPort: number): SipDialog {
|
||
const from = invite.getHeader('From') || '';
|
||
const to = invite.getHeader('To') || '';
|
||
return new SipDialog({
|
||
callId: invite.callId,
|
||
localTag: SipMessage.extractTag(from) || generateTag(),
|
||
localUri: SipMessage.extractUri(from) || '',
|
||
remoteUri: SipMessage.extractUri(to) || '',
|
||
localCSeq: parseInt((invite.getHeader('CSeq') || '1').split(/\s+/)[0], 10),
|
||
remoteTarget: invite.requestUri || SipMessage.extractUri(to) || '',
|
||
localHost: localHost,
|
||
localPort: localPort,
|
||
});
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Factory: create dialog from an incoming INVITE (UAS side)
|
||
// -------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Create a dialog from an INVITE we received.
|
||
* Typically used when acting as a UAS (e.g. for call-back scenarios).
|
||
*/
|
||
static fromUasInvite(invite: SipMessage, localTag: string, localHost: string, localPort: number): SipDialog {
|
||
const from = invite.getHeader('From') || '';
|
||
const to = invite.getHeader('To') || '';
|
||
const contact = invite.getHeader('Contact');
|
||
return new SipDialog({
|
||
callId: invite.callId,
|
||
localTag,
|
||
remoteTag: SipMessage.extractTag(from) || undefined,
|
||
localUri: SipMessage.extractUri(to) || '',
|
||
remoteUri: SipMessage.extractUri(from) || '',
|
||
localCSeq: 0,
|
||
remoteTarget: (contact ? SipMessage.extractUri(contact) : null) || SipMessage.extractUri(from) || '',
|
||
localHost,
|
||
localPort,
|
||
});
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Response processing
|
||
// -------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Update dialog state from a received response.
|
||
* - 1xx with To-tag → early dialog
|
||
* - 2xx → confirmed dialog
|
||
* - 3xx–6xx → terminated
|
||
*/
|
||
processResponse(response: SipMessage): void {
|
||
const to = response.getHeader('To') || '';
|
||
const tag = SipMessage.extractTag(to);
|
||
const code = response.statusCode ?? 0;
|
||
// Always update remoteTag from 2xx (RFC 3261 §12.1.2: tag in 2xx is definitive).
|
||
if (tag && (code >= 200 && code < 300)) {
|
||
this.remoteTag = tag;
|
||
} else if (tag && !this.remoteTag) {
|
||
this.remoteTag = tag;
|
||
}
|
||
|
||
// Update remote target from Contact.
|
||
const contact = response.getHeader('Contact');
|
||
if (contact) {
|
||
const uri = SipMessage.extractUri(contact);
|
||
if (uri) this.remoteTarget = uri;
|
||
}
|
||
|
||
// Record-Route → route set (in reverse for UAC).
|
||
if (this.state === 'early') {
|
||
const rr: string[] = [];
|
||
for (const [n, v] of response.headers) {
|
||
if (n.toLowerCase() === 'record-route') rr.push(v);
|
||
}
|
||
if (rr.length) this.routeSet = rr.reverse();
|
||
}
|
||
|
||
if (code >= 200 && code < 300) {
|
||
this.state = 'confirmed';
|
||
} else if (code >= 300) {
|
||
this.state = 'terminated';
|
||
}
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Request building
|
||
// -------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Build an in-dialog request (BYE, re-INVITE, INFO, …).
|
||
* Automatically increments the local CSeq.
|
||
*/
|
||
createRequest(method: string, options?: {
|
||
body?: string;
|
||
contentType?: string;
|
||
extraHeaders?: [string, string][];
|
||
}): SipMessage {
|
||
this.localCSeq++;
|
||
const branch = generateBranch();
|
||
|
||
const headers: [string, string][] = [
|
||
['Via', `SIP/2.0/UDP ${this.localHost}:${this.localPort};branch=${branch};rport`],
|
||
['From', `<${this.localUri}>;tag=${this.localTag}`],
|
||
['To', `<${this.remoteUri}>${this.remoteTag ? `;tag=${this.remoteTag}` : ''}`],
|
||
['Call-ID', this.callId],
|
||
['CSeq', `${this.localCSeq} ${method}`],
|
||
['Max-Forwards', '70'],
|
||
];
|
||
|
||
// Route set → Route headers.
|
||
for (const route of this.routeSet) {
|
||
headers.push(['Route', route]);
|
||
}
|
||
|
||
headers.push(['Contact', `<sip:${this.localHost}:${this.localPort}>`]);
|
||
|
||
if (options?.extraHeaders) headers.push(...options.extraHeaders);
|
||
|
||
const body = options?.body || '';
|
||
if (body && options?.contentType) {
|
||
headers.push(['Content-Type', options.contentType]);
|
||
}
|
||
headers.push(['Content-Length', String(Buffer.byteLength(body, 'utf8'))]);
|
||
|
||
// Determine Request-URI from route set or remote target.
|
||
let ruri = this.remoteTarget;
|
||
if (this.routeSet.length) {
|
||
const topRoute = SipMessage.extractUri(this.routeSet[0]);
|
||
if (topRoute && topRoute.includes(';lr')) {
|
||
ruri = this.remoteTarget; // loose routing — RURI stays as remote target
|
||
} else if (topRoute) {
|
||
ruri = topRoute; // strict routing — top route becomes RURI
|
||
}
|
||
}
|
||
|
||
return new SipMessage(`${method} ${ruri} SIP/2.0`, headers, body);
|
||
}
|
||
|
||
/**
|
||
* Build an ACK for a 2xx response to INVITE (RFC 3261 §13.2.2.4).
|
||
* ACK for 2xx is a new transaction, so it gets its own Via/branch.
|
||
*/
|
||
createAck(): SipMessage {
|
||
const branch = generateBranch();
|
||
|
||
const headers: [string, string][] = [
|
||
['Via', `SIP/2.0/UDP ${this.localHost}:${this.localPort};branch=${branch};rport`],
|
||
['From', `<${this.localUri}>;tag=${this.localTag}`],
|
||
['To', `<${this.remoteUri}>${this.remoteTag ? `;tag=${this.remoteTag}` : ''}`],
|
||
['Call-ID', this.callId],
|
||
['CSeq', `${this.localCSeq} ACK`],
|
||
['Max-Forwards', '70'],
|
||
];
|
||
|
||
for (const route of this.routeSet) {
|
||
headers.push(['Route', route]);
|
||
}
|
||
|
||
headers.push(['Content-Length', '0']);
|
||
|
||
let ruri = this.remoteTarget;
|
||
if (this.routeSet.length) {
|
||
const topRoute = SipMessage.extractUri(this.routeSet[0]);
|
||
if (topRoute && topRoute.includes(';lr')) {
|
||
ruri = this.remoteTarget;
|
||
} else if (topRoute) {
|
||
ruri = topRoute;
|
||
}
|
||
}
|
||
|
||
return new SipMessage(`ACK ${ruri} SIP/2.0`, headers, '');
|
||
}
|
||
|
||
/**
|
||
* Build a CANCEL for the original INVITE (same branch, CSeq).
|
||
* Used before the dialog is confirmed.
|
||
*/
|
||
createCancel(originalInvite: SipMessage): SipMessage {
|
||
const via = originalInvite.getHeader('Via') || '';
|
||
const from = originalInvite.getHeader('From') || '';
|
||
const to = originalInvite.getHeader('To') || '';
|
||
|
||
const headers: [string, string][] = [
|
||
['Via', via],
|
||
['From', from],
|
||
['To', to],
|
||
['Call-ID', this.callId],
|
||
['CSeq', `${this.localCSeq} CANCEL`],
|
||
['Max-Forwards', '70'],
|
||
['Content-Length', '0'],
|
||
];
|
||
|
||
const ruri = originalInvite.requestUri || this.remoteTarget;
|
||
return new SipMessage(`CANCEL ${ruri} SIP/2.0`, headers, '');
|
||
}
|
||
|
||
/** Transition the dialog to terminated state. */
|
||
terminate(): void {
|
||
this.state = 'terminated';
|
||
}
|
||
}
|