/** * SIP helper utilities — ID generation and SDP construction. */ import { randomBytes, createHash } from 'node:crypto'; // --------------------------------------------------------------------------- // ID generators // --------------------------------------------------------------------------- /** Generate a random SIP Call-ID. */ export function generateCallId(domain?: string): string { const id = randomBytes(16).toString('hex'); return domain ? `${id}@${domain}` : id; } /** Generate a random SIP From/To tag. */ export function generateTag(): string { return randomBytes(8).toString('hex'); } /** Generate a RFC 3261 compliant Via branch (starts with z9hG4bK magic cookie). */ export function generateBranch(): string { return `z9hG4bK-${randomBytes(8).toString('hex')}`; } // --------------------------------------------------------------------------- // Codec registry // --------------------------------------------------------------------------- const CODEC_NAMES: Record = { 0: 'PCMU/8000', 3: 'GSM/8000', 4: 'G723/8000', 8: 'PCMA/8000', 9: 'G722/8000', 18: 'G729/8000', 101: 'telephone-event/8000', }; /** Look up the rtpmap name for a static payload type. */ export function codecName(pt: number): string { return CODEC_NAMES[pt] || `unknown/${pt}`; } // --------------------------------------------------------------------------- // SDP builder // --------------------------------------------------------------------------- export interface ISdpOptions { /** IP address for the c= and o= lines. */ ip: string; /** Audio port for the m=audio line. */ port: number; /** RTP payload type numbers (e.g. [9, 0, 8, 101]). */ payloadTypes?: number[]; /** SDP session ID (random if omitted). */ sessionId?: string; /** Session name for the s= line (defaults to '-'). */ sessionName?: string; /** Direction attribute (defaults to 'sendrecv'). */ direction?: 'sendrecv' | 'recvonly' | 'sendonly' | 'inactive'; /** Extra a= lines to append (without "a=" prefix). */ attributes?: string[]; } /** * Build a minimal SDP body suitable for SIP INVITE offers/answers. * * ```ts * const sdp = buildSdp({ * ip: '192.168.5.66', * port: 20000, * payloadTypes: [9, 0, 101], * }); * ``` */ export function buildSdp(options: ISdpOptions): string { const { ip, port, payloadTypes = [9, 0, 8, 101], sessionId = String(Math.floor(Math.random() * 1e9)), sessionName = '-', direction = 'sendrecv', attributes = [], } = options; const lines: string[] = [ 'v=0', `o=- ${sessionId} ${sessionId} IN IP4 ${ip}`, `s=${sessionName}`, `c=IN IP4 ${ip}`, 't=0 0', `m=audio ${port} RTP/AVP ${payloadTypes.join(' ')}`, ]; for (const pt of payloadTypes) { const name = CODEC_NAMES[pt]; if (name) lines.push(`a=rtpmap:${pt} ${name}`); if (pt === 101) lines.push(`a=fmtp:101 0-16`); } lines.push(`a=${direction}`); for (const attr of attributes) lines.push(`a=${attr}`); lines.push(''); // trailing CRLF return lines.join('\r\n'); } // --------------------------------------------------------------------------- // SIP Digest authentication (RFC 2617) // --------------------------------------------------------------------------- export interface IDigestChallenge { realm: string; nonce: string; algorithm?: string; opaque?: string; qop?: string; } /** * Parse a `Proxy-Authenticate` or `WWW-Authenticate` header value * into its constituent fields. */ export function parseDigestChallenge(header: string): IDigestChallenge | null { if (!header.toLowerCase().startsWith('digest ')) return null; const params = header.slice(7); const get = (key: string): string | undefined => { const re = new RegExp(`${key}\\s*=\\s*"([^"]*)"`, 'i'); const m = params.match(re); if (m) return m[1]; // unquoted value const re2 = new RegExp(`${key}\\s*=\\s*([^,\\s]+)`, 'i'); const m2 = params.match(re2); return m2 ? m2[1] : undefined; }; const realm = get('realm'); const nonce = get('nonce'); if (!realm || !nonce) return null; return { realm, nonce, algorithm: get('algorithm'), opaque: get('opaque'), qop: get('qop') }; } function md5(s: string): string { return createHash('md5').update(s).digest('hex'); } /** * Compute a SIP Digest `Proxy-Authorization` or `Authorization` header value. */ export function computeDigestAuth(options: { username: string; password: string; realm: string; nonce: string; method: string; uri: string; algorithm?: string; opaque?: string; }): string { const ha1 = md5(`${options.username}:${options.realm}:${options.password}`); const ha2 = md5(`${options.method}:${options.uri}`); const response = md5(`${ha1}:${options.nonce}:${ha2}`); let header = `Digest username="${options.username}", realm="${options.realm}", ` + `nonce="${options.nonce}", uri="${options.uri}", response="${response}", ` + `algorithm=${options.algorithm || 'MD5'}`; if (options.opaque) header += `, opaque="${options.opaque}"`; return header; } /** * Parse the audio media port and connection address from an SDP body. * Returns null when no c= + m=audio pair is found. */ export function parseSdpEndpoint(sdp: string): { address: string; port: number } | null { let addr: string | null = null; let port: number | null = null; for (const raw of sdp.replace(/\r\n/g, '\n').split('\n')) { const line = raw.trim(); if (line.startsWith('c=IN IP4 ')) { addr = line.slice('c=IN IP4 '.length).trim(); } else if (line.startsWith('m=audio ')) { const parts = line.split(' '); if (parts.length >= 2) port = parseInt(parts[1], 10); } } return addr && port ? { address: addr, port } : null; } // --------------------------------------------------------------------------- // MWI (Message Waiting Indicator) — RFC 3842 // --------------------------------------------------------------------------- /** * Build a SIP NOTIFY request for Message Waiting Indicator. * * Sent out-of-dialog to notify a device about voicemail message counts. * Uses the message-summary event package per RFC 3842. */ export interface IMwiOptions { /** Proxy LAN IP and port (Via / From / Contact). */ proxyHost: string; proxyPort: number; /** Target device URI (e.g. "sip:user@192.168.5.100:5060"). */ targetUri: string; /** Account URI for the voicebox (used in the From header). */ accountUri: string; /** Number of new (unheard) voice messages. */ newMessages: number; /** Number of old (heard) voice messages. */ oldMessages: number; } /** * Build the body and headers for an MWI NOTIFY (RFC 3842 message-summary). * * Returns the body string and extra headers needed. The caller builds * the SipMessage via SipMessage.createRequest('NOTIFY', ...). */ export function buildMwiBody(newMessages: number, oldMessages: number, accountUri: string): { body: string; contentType: string; extraHeaders: [string, string][]; } { const hasNew = newMessages > 0; const body = `Messages-Waiting: ${hasNew ? 'yes' : 'no'}\r\n` + `Message-Account: ${accountUri}\r\n` + `Voice-Message: ${newMessages}/${oldMessages}\r\n`; return { body, contentType: 'application/simple-message-summary', extraHeaders: [ ['Event', 'message-summary'], ['Subscription-State', 'terminated;reason=noresource'], ], }; }