/** * 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', ``]); 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'; } }