diff --git a/demo/demo-broadsoft.css b/demo/demo-broadsoft.css new file mode 100644 index 000000000..0936965c9 --- /dev/null +++ b/demo/demo-broadsoft.css @@ -0,0 +1,307 @@ +/* BroadSoft Demo Styles */ + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + max-width: 1200px; + margin: 0 auto; + padding: 20px; + background-color: #f5f5f5; + color: #333; +} + +h2 { + color: #2c3e50; + border-bottom: 3px solid #3498db; + padding-bottom: 10px; +} + +h3 { + color: #34495e; + margin-top: 0; +} + +h4 { + color: #7f8c8d; + margin: 10px 0; +} + +.description { + background-color: #ecf0f1; + padding: 15px; + border-radius: 5px; + margin-bottom: 20px; +} + +.section { + background-color: white; + padding: 20px; + margin-bottom: 20px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +/* Configuration Form */ +.config-form { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 15px; +} + +.form-group { + display: flex; + flex-direction: column; +} + +.form-group label { + font-weight: 600; + margin-bottom: 5px; + color: #555; +} + +.form-group input { + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; +} + +.form-group input:focus { + outline: none; + border-color: #3498db; + box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1); +} + +/* Buttons */ +.btn { + padding: 10px 20px; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + margin-right: 10px; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background-color: #3498db; + color: white; +} + +.btn-primary:hover:not(:disabled) { + background-color: #2980b9; +} + +.btn-secondary { + background-color: #95a5a6; + color: white; +} + +.btn-secondary:hover:not(:disabled) { + background-color: #7f8c8d; +} + +.btn-small { + padding: 6px 12px; + font-size: 12px; +} + +/* Status */ +.status { + margin-top: 15px; + padding: 10px; + border-radius: 4px; + font-weight: 600; +} + +.status-disconnected { + color: #e74c3c; +} + +.status-connecting { + color: #f39c12; +} + +.status-connected { + color: #27ae60; +} + +/* Controls */ +.controls { + display: flex; + flex-wrap: wrap; + gap: 15px; + align-items: center; +} + +.checkbox-group { + display: flex; + align-items: center; + gap: 8px; +} + +.checkbox-group input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; +} + +.checkbox-group input[type="checkbox"]:disabled { + cursor: not-allowed; +} + +.checkbox-group label { + font-weight: 500; + cursor: pointer; +} + +/* Feature Panels */ +.feature-panel { + background-color: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 6px; + padding: 15px; + margin-bottom: 15px; +} + +.feature-panel h4 { + margin-top: 0; + color: #495057; + border-bottom: 2px solid #dee2e6; + padding-bottom: 8px; +} + +.feature-status { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 10px; + margin-top: 10px; +} + +.feature-status > div { + padding: 8px; + background-color: white; + border-radius: 4px; + font-size: 14px; +} + +.value { + font-weight: 600; + color: #2c3e50; +} + +/* Event Log */ +.log-controls { + margin-bottom: 10px; +} + +.event-log { + background-color: #1e1e1e; + color: #d4d4d4; + padding: 15px; + border-radius: 4px; + font-family: "Courier New", Courier, monospace; + font-size: 13px; + max-height: 400px; + overflow-y: auto; + line-height: 1.6; +} + +.log-entry { + margin-bottom: 8px; + padding: 5px; + border-left: 3px solid transparent; +} + +.log-entry.info { + border-left-color: #3498db; +} + +.log-entry.success { + border-left-color: #27ae60; + background-color: rgba(39, 174, 96, 0.1); +} + +.log-entry.warning { + border-left-color: #f39c12; + background-color: rgba(243, 156, 18, 0.1); +} + +.log-entry.error { + border-left-color: #e74c3c; + background-color: rgba(231, 76, 60, 0.1); +} + +.log-timestamp { + color: #95a5a6; + font-size: 11px; +} + +.log-message { + margin-left: 10px; +} + +/* Instructions */ +.instructions { + background-color: #fff9e6; + border-left: 4px solid #f39c12; +} + +.test-scenario { + background-color: white; + padding: 15px; + margin-bottom: 15px; + border-radius: 4px; + border: 1px solid #e0e0e0; +} + +.test-scenario h4 { + color: #2c3e50; + margin-top: 0; +} + +.test-scenario ol { + margin: 10px 0; + padding-left: 25px; +} + +.test-scenario pre { + background-color: #2c3e50; + color: #ecf0f1; + padding: 10px; + border-radius: 4px; + overflow-x: auto; + font-size: 12px; + margin: 8px 0; +} + +/* Audio Element */ +audio { + width: 100%; + max-width: 500px; +} + +/* Responsive */ +@media (max-width: 768px) { + body { + padding: 10px; + } + + .config-form { + grid-template-columns: 1fr; + } + + .feature-status { + grid-template-columns: 1fr; + } + + .controls { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/demo/demo-broadsoft.html b/demo/demo-broadsoft.html new file mode 100644 index 000000000..b4c6a10c4 --- /dev/null +++ b/demo/demo-broadsoft.html @@ -0,0 +1,136 @@ + + + + + + + SIP.js Demo - BroadSoft Extensions + + + + + < Index + +

Demo: BroadSoft Extensions Integration Test

+ +
+ This demo is designed to test BroadSoft Access-Side Extensions with FreeSWITCH: +
    +
  1. Auto-Answer - Receives calls with Call-Info header (answer-after parameter)
  2. +
  3. Remote Control - Talk Events - Receives NOTIFY with Event: talk
  4. +
+
+ +
+

1. Configuration

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

2. Connection

+ + +
+ Status: Disconnected +
+
+ +
+

3. Audio

+ + +
+ +
+

4. Manual Controls (for comparison)

+
+ + +
+ + +
+
+
+ +
+

5. BroadSoft Features Status

+ +
+

Auto-Answer

+
+
Enabled: -
+
Delay: -
+
Countdown: -
+
Status: Waiting for call...
+
+
+ +
+

Remote Control - Talk Events

+
+
Last Event: None
+
Microphone: Active
+
Auto-Apply: βœ“ Enabled
+
+
+
+ +
+

6. Event Log

+
+ +
+
+
+ +
+

Testing Instructions

+
+

Test 1: Auto-Answer

+
    +
  1. Connect the SIP.js client above
  2. +
  3. From FreeSWITCH, originate a call with Call-Info header: +
    originate {sip_h_Call-Info=<sip:${domain}>; answer-after=2}user/1000 &echo
    +
  4. +
  5. Observe: Call should auto-answer after 2 seconds
  6. +
+
+ +
+

Test 2: Remote Control - Talk

+
    +
  1. Establish a call (auto-answer or manual)
  2. +
  3. From FreeSWITCH console, send talk event: +
    uuid_phone_event <uuid> talk
    +
  4. +
  5. Observe: Talk event is received and processed
  6. +
+
+
+ + + + + diff --git a/demo/demo-broadsoft.ts b/demo/demo-broadsoft.ts new file mode 100644 index 000000000..c044f6b35 --- /dev/null +++ b/demo/demo-broadsoft.ts @@ -0,0 +1,439 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +/* eslint-disable no-console */ +/** + * BroadSoft Extensions Integration Test Demo + * + * This demo demonstrates the integration of BroadSoft Access-Side Extensions + * with FreeSWITCH, including: + * 1. Auto-Answer (Call-Info header with answer-after parameter) + * 2. Remote Control - Talk Events (mute/unmute via NOTIFY) + */ + +import { Invitation } from "../lib/api/invitation.js"; +import { Notification } from "../lib/api/notification.js"; +import { Registerer } from "../lib/api/registerer.js"; +import { RegistererState } from "../lib/api/registerer-state.js"; +import { UserAgent } from "../lib/api/user-agent.js"; +import { UserAgentOptions } from "../lib/api/user-agent-options.js"; +import { + SessionDescriptionHandler, + defaultSessionDescriptionHandlerFactory +} from "../lib/platform/web/session-description-handler/index.js"; +import * as BroadSoft from "../lib/api/broadsoft/index.js"; + +// DOM Elements (will be initialized after DOM loads) +let serverInput: HTMLInputElement; +let userInput: HTMLInputElement; +let passwordInput: HTMLInputElement; +let domainInput: HTMLInputElement; + +let connectButton: HTMLButtonElement; +let disconnectButton: HTMLButtonElement; +let answerButton: HTMLButtonElement; +let hangupButton: HTMLButtonElement; + +let connectionStatus: HTMLSpanElement; +let remoteAudio: HTMLAudioElement; +let localAudio: HTMLAudioElement; + +let muteCheckbox: HTMLInputElement; + +// BroadSoft Status Elements +let autoAnswerEnabled: HTMLSpanElement; +let autoAnswerDelay: HTMLSpanElement; +let autoAnswerCountdown: HTMLSpanElement; +let autoAnswerStatus: HTMLSpanElement; + +let talkEvent: HTMLSpanElement; +let talkStatus: HTMLSpanElement; + +let eventLog: HTMLDivElement; +let clearLogButton: HTMLButtonElement; + +// State +let userAgent: UserAgent | undefined; +let registerer: Registerer | undefined; +let currentSession: Invitation | undefined; +let autoAnswerTimer: number | undefined; + +// Initialize +document.addEventListener("DOMContentLoaded", () => { + // Initialize DOM elements + serverInput = document.getElementById("server") as HTMLInputElement; + userInput = document.getElementById("user") as HTMLInputElement; + passwordInput = document.getElementById("password") as HTMLInputElement; + domainInput = document.getElementById("domain") as HTMLInputElement; + + connectButton = document.getElementById("connect") as HTMLButtonElement; + disconnectButton = document.getElementById("disconnect") as HTMLButtonElement; + answerButton = document.getElementById("answer") as HTMLButtonElement; + hangupButton = document.getElementById("hangup") as HTMLButtonElement; + + connectionStatus = document.getElementById("connection-status") as HTMLSpanElement; + remoteAudio = document.getElementById("remoteAudio") as HTMLAudioElement; + localAudio = document.getElementById("localAudio") as HTMLAudioElement; + + muteCheckbox = document.getElementById("mute") as HTMLInputElement; + + autoAnswerEnabled = document.getElementById("auto-answer-enabled") as HTMLSpanElement; + autoAnswerDelay = document.getElementById("auto-answer-delay") as HTMLSpanElement; + autoAnswerCountdown = document.getElementById("auto-answer-countdown") as HTMLSpanElement; + autoAnswerStatus = document.getElementById("auto-answer-status") as HTMLSpanElement; + + talkEvent = document.getElementById("talk-event") as HTMLSpanElement; + talkStatus = document.getElementById("talk-status") as HTMLSpanElement; + + eventLog = document.getElementById("event-log") as HTMLDivElement; + clearLogButton = document.getElementById("clear-log") as HTMLButtonElement; + + // Setup event listeners + connectButton.addEventListener("click", connect); + disconnectButton.addEventListener("click", disconnect); + answerButton.addEventListener("click", answerCall); + hangupButton.addEventListener("click", hangupCall); + muteCheckbox.addEventListener("change", toggleMute); + clearLogButton.addEventListener("click", clearLog); + + log("Demo loaded. Configure settings and click Connect.", "info"); +}); + +// Logging +function log(message: string, type: "info" | "success" | "warning" | "error" = "info"): void { + const timestamp = new Date().toLocaleTimeString(); + const entry = document.createElement("div"); + entry.className = `log-entry ${type}`; + entry.innerHTML = `[${timestamp}]${message}`; + eventLog.appendChild(entry); + eventLog.scrollTop = eventLog.scrollHeight; + console.log(`[${timestamp}] ${message}`); +} + +function clearLog(): void { + eventLog.innerHTML = ""; + log("Log cleared.", "info"); +} + +// Connection +async function connect(): Promise { + const server = serverInput.value; + const user = userInput.value; + const password = passwordInput.value; + const domain = domainInput.value; + + if (!server || !user || !password || !domain) { + log("Please fill in all configuration fields.", "error"); + return; + } + + log(`Connecting to ${server} as ${user}@${domain}...`, "info"); + updateConnectionStatus("connecting"); + + const uri = UserAgent.makeURI(`sip:${user}@${domain}`); + if (!uri) { + log("Invalid SIP URI", "error"); + updateConnectionStatus("disconnected"); + return; + } + + const options: UserAgentOptions = { + uri, + transportOptions: { + server + }, + authorizationUsername: user, + authorizationPassword: password, + sessionDescriptionHandlerFactory: defaultSessionDescriptionHandlerFactory(), + delegate: { + onInvite: (invitation: Invitation) => { + handleIncomingCall(invitation); + } + } + }; + + try { + userAgent = new UserAgent(options); + userAgent.start().then(() => { + log("WebSocket connected, registering...", "info"); + updateConnectionStatus("connecting"); + + // Create Registerer + registerer = new Registerer(userAgent as UserAgent); + + // Listen for registration state changes + registerer.stateChange.addListener((state: RegistererState) => { + log(`Registration state: ${state}`, "info"); + if (state === RegistererState.Registered) { + log("βœ“ Registered successfully! Ready to receive calls.", "success"); + updateConnectionStatus("connected"); + connectButton.disabled = true; + disconnectButton.disabled = false; + } else if (state === RegistererState.Unregistered) { + log("Unregistered", "warning"); + } else if (state === RegistererState.Terminated) { + log("Registration terminated", "error"); + updateConnectionStatus("disconnected"); + } + }); + + // Send REGISTER + registerer.register().catch((error) => { + log(`Registration failed: ${error}`, "error"); + updateConnectionStatus("disconnected"); + }); + }); + } catch (error) { + log(`Connection failed: ${error}`, "error"); + updateConnectionStatus("disconnected"); + } +} + +async function disconnect(): Promise { + // Unregister first + if (registerer && registerer.state === RegistererState.Registered) { + log("Unregistering...", "info"); + try { + await registerer.unregister(); + log("Unregistered successfully", "success"); + } catch (error) { + log(`Failed to unregister: ${error}`, "warning"); + } + } + + // Then stop user agent + if (userAgent) { + log("Disconnecting...", "info"); + await userAgent.stop(); + userAgent = undefined; + registerer = undefined; + updateConnectionStatus("disconnected"); + connectButton.disabled = false; + disconnectButton.disabled = true; + log("Disconnected.", "success"); + } +} + +function updateConnectionStatus(status: "disconnected" | "connecting" | "connected"): void { + connectionStatus.className = `status-${status}`; + connectionStatus.textContent = status.charAt(0).toUpperCase() + status.slice(1); +} + +// Call Handling +function handleIncomingCall(invitation: Invitation): void { + log("πŸ“ž Incoming call received", "info"); + currentSession = invitation; + + // Setup session delegate for remote control FIRST (before any NOTIFY arrives) + log("πŸ”§ Setting up session delegate for remote control", "info"); + setupSessionDelegate(invitation); + log("βœ… Session delegate setup complete", "info"); + + // Check for auto-answer + const shouldAutoAns = BroadSoft.shouldAutoAnswer(invitation); + const delay = BroadSoft.getAutoAnswerDelay(invitation.request); + + autoAnswerEnabled.textContent = shouldAutoAns ? "βœ“ Yes" : "βœ— No"; + autoAnswerDelay.textContent = delay !== undefined ? `${delay} seconds` : "N/A"; + + if (shouldAutoAns && delay !== undefined) { + log(`πŸ€– Auto-answer enabled with ${delay}s delay`, "success"); + autoAnswerStatus.textContent = "Auto-answering..."; + startAutoAnswerCountdown(delay); + + // Configure auto-answer options + const autoAnswerOptions: BroadSoft.AutoAnswerOptions = { + enabled: true, + onBeforeAutoAnswer: (delaySeconds) => { + log(`Auto-answer will trigger in ${delaySeconds} seconds`, "info"); + }, + onAfterAutoAnswer: () => { + log("βœ“ Call auto-answered!", "success"); + autoAnswerStatus.textContent = "Answered automatically"; + stopAutoAnswerCountdown(); + } + }; + + // Handle auto-answer + BroadSoft.handleAutoAnswer(invitation, autoAnswerOptions); + } else { + log("Manual answer required. Click 'Answer Call' button.", "warning"); + autoAnswerStatus.textContent = "Waiting for manual answer..."; + answerButton.disabled = false; + } + + // Setup session state listener + invitation.stateChange.addListener((state: string) => { + log(`Session state: ${state}`, "info"); + if (state === "Established") { + onCallEstablished(); + } else if (state === "Terminated") { + onCallTerminated(); + } + }); +} + +function setupSessionDelegate(session: Invitation): void { + // Per BroadSoft spec, remote control NOTIFY automatically triggers SIP signaling: + // - Event: talk β†’ Answers ringing call or resumes from hold (sends 200 OK or re-INVITE) + // These callbacks are for UI updates only + const remoteControlOptions: BroadSoft.RemoteControlOptions = { + enabled: true, + onTalkEvent: (action) => { + log(`🎀 Remote Control - Talk Event: ${action}`, "warning"); + talkEvent.textContent = action; + if (action === BroadSoft.TalkAction.Mute) { + talkStatus.textContent = "Muted (by remote)"; + talkStatus.style.color = "#e74c3c"; + muteCheckbox.checked = true; + } else { + talkStatus.textContent = "Active (call will be answered/resumed automatically)"; + talkStatus.style.color = "#27ae60"; + muteCheckbox.checked = false; + } + } + }; + + session.delegate = { + onNotify: (notification: Notification) => { + log("πŸ“¨ NOTIFY received", "info"); + + const isBroadSoft = BroadSoft.isBroadSoftNotification(notification); + + if (isBroadSoft) { + log("βœ“ BroadSoft remote control NOTIFY detected", "success"); + const eventType = BroadSoft.parseEventHeaderFromNotification(notification); + log(`Event type: ${eventType}`, "info"); + + BroadSoft.handleRemoteControlNotification(session, notification, remoteControlOptions) + .then(() => { + log("Remote control action applied", "success"); + }) + .catch((error) => { + log(`Error handling remote control: ${error}`, "error"); + }); + + notification.accept(); + } else { + log("Non-BroadSoft NOTIFY, accepting", "info"); + notification.accept(); + } + } + }; +} + +function startAutoAnswerCountdown(seconds: number): void { + let remaining = seconds; + autoAnswerCountdown.textContent = `${remaining}s`; + + autoAnswerTimer = window.setInterval(() => { + remaining--; + if (remaining >= 0) { + autoAnswerCountdown.textContent = `${remaining}s`; + } else { + stopAutoAnswerCountdown(); + } + }, 1000); +} + +function stopAutoAnswerCountdown(): void { + if (autoAnswerTimer) { + clearInterval(autoAnswerTimer); + autoAnswerTimer = undefined; + } + autoAnswerCountdown.textContent = "-"; +} + +async function answerCall(): Promise { + if (currentSession && currentSession.state === "Initial") { + log("Answering call manually...", "info"); + try { + await currentSession.accept(); + log("βœ“ Call answered", "success"); + answerButton.disabled = true; + } catch (error) { + log(`Failed to answer call: ${error}`, "error"); + } + } +} + +async function hangupCall(): Promise { + if (currentSession) { + log("Hanging up call...", "info"); + try { + await currentSession.bye(); + log("βœ“ Call hung up", "success"); + } catch (error) { + log(`Failed to hang up: ${error}`, "error"); + } + } +} + +function onCallEstablished(): void { + log("βœ“ Call established", "success"); + answerButton.disabled = true; + hangupButton.disabled = false; + muteCheckbox.disabled = false; + + // Setup audio + if (currentSession && currentSession.sessionDescriptionHandler) { + const sdh = currentSession.sessionDescriptionHandler as SessionDescriptionHandler; + + // Remote audio + const remoteStream = sdh.remoteMediaStream; + if (remoteStream) { + remoteAudio.srcObject = remoteStream; + remoteAudio.play().catch((e) => log(`Error playing audio: ${e}`, "error")); + } + + // Local audio (muted for monitoring) + const localStream = sdh.localMediaStream; + if (localStream) { + localAudio.srcObject = localStream; + } + } +} + +function onCallTerminated(): void { + log("Call terminated", "info"); + currentSession = undefined; + answerButton.disabled = true; + hangupButton.disabled = true; + muteCheckbox.disabled = true; + muteCheckbox.checked = false; + + // Reset BroadSoft status + autoAnswerEnabled.textContent = "-"; + autoAnswerDelay.textContent = "-"; + autoAnswerCountdown.textContent = "-"; + autoAnswerStatus.textContent = "Waiting for call..."; + talkEvent.textContent = "None"; + talkStatus.textContent = "Active"; + talkStatus.style.color = "#27ae60"; + + stopAutoAnswerCountdown(); + + // Clear audio + remoteAudio.srcObject = null; + localAudio.srcObject = null; +} + +// Manual Controls +async function toggleMute(): Promise { + if (currentSession && currentSession.sessionDescriptionHandler) { + const sdh = currentSession.sessionDescriptionHandler as SessionDescriptionHandler; + const localStream = sdh.localMediaStream; + if (localStream) { + const audioTracks = localStream.getAudioTracks(); + if (muteCheckbox.checked) { + log("🎀 Muting microphone (manual)", "info"); + audioTracks.forEach((track) => (track.enabled = false)); + talkStatus.textContent = "Muted (manual)"; + talkStatus.style.color = "#f39c12"; + } else { + log("🎀 Unmuting microphone (manual)", "info"); + audioTracks.forEach((track) => (track.enabled = true)); + talkStatus.textContent = "Active"; + talkStatus.style.color = "#27ae60"; + } + } + } +} diff --git a/demo/index.html b/demo/index.html index 8ce1d960a..f5ddbd4cc 100644 --- a/demo/index.html +++ b/demo/index.html @@ -47,6 +47,14 @@

3) Data Channel - Between Two Users

  • Answering incoming call with data channel
  • +

    4) BroadSoft Extensions - Integration Test

    +
      +
    • Auto-Answer with Call-Info header
    • +
    • Remote Control - Talk Events (mute/unmute)
    • +
    • Remote Control - Hold Events (hold/resume)
    • +
    • Integration testing with FreeSWITCH
    • +
    + \ No newline at end of file diff --git a/demo/webpack.config.cjs b/demo/webpack.config.cjs index 91d98ad86..39b7041cd 100644 --- a/demo/webpack.config.cjs +++ b/demo/webpack.config.cjs @@ -4,7 +4,8 @@ module.exports = { entry: { 'demo-1': './demo/demo-1.ts', 'demo-2': './demo/demo-2.ts', - 'demo-3': './demo/demo-3.ts' + 'demo-3': './demo/demo-3.ts', + 'demo-broadsoft': './demo/demo-broadsoft.ts' }, devtool: 'inline-source-map', mode: 'development', diff --git a/examples/broadsoft-extensions.ts b/examples/broadsoft-extensions.ts new file mode 100644 index 000000000..f67f28167 --- /dev/null +++ b/examples/broadsoft-extensions.ts @@ -0,0 +1,143 @@ +/* eslint-disable no-console */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable tree-shaking/no-side-effects-in-initialization */ +/** + * BroadSoft Access-Side Extensions Example + * + * This example demonstrates how to use the two common BroadSoft Access-Side extensions: + * 1. Call-Info: ...; answer-after=1 - Auto-Answer + * 2. NOTIFY (Event: talk) - Remote Control Event for answering/resuming calls + */ + +import { BroadSoft, Invitation, UserAgent, UserAgentOptions, Web } from "../src/api/index.js"; + +// Configure UserAgent +const userAgentOptions: UserAgentOptions = { + uri: UserAgent.makeURI("sip:alice@example.com"), + transportOptions: { + server: "wss://sip.example.com" + }, + // Use Web platform for SessionDescriptionHandler + sessionDescriptionHandlerFactory: Web.SessionDescriptionHandler.defaultFactory +}; + +const userAgent = new UserAgent(userAgentOptions); + +// Configure BroadSoft auto-answer options +const autoAnswerOptions: BroadSoft.AutoAnswerOptions = { + enabled: true, + onBeforeAutoAnswer: (delaySeconds) => { + console.log(`Auto-answering incoming call in ${delaySeconds} seconds...`); + }, + onAfterAutoAnswer: () => { + console.log("Call auto-answered successfully"); + } + // Optional: override the delay from the header + // delayOverride: 0 // Always answer immediately +}; + +// Configure BroadSoft remote control options +// Per BroadSoft spec, remote control NOTIFY automatically triggers SIP signaling: +// - Event: talk β†’ Answers ringing call or resumes from hold (sends 200 OK or re-INVITE) +// Callbacks are for UI updates only +const remoteControlOptions: BroadSoft.RemoteControlOptions = { + enabled: true, + onTalkEvent: (action) => { + console.log(`Remote control - Talk event: ${action}`); + if (action === BroadSoft.TalkAction.Talk) { + console.log("Call will be answered/resumed automatically"); + // Update UI to show active call state + } + } +}; + +// Set up UserAgent delegate to handle incoming invitations +userAgent.delegate = { + onInvite: (invitation: Invitation) => { + console.log("Incoming call received"); + + // Check if this call should be auto-answered + const shouldAutoAnswer = BroadSoft.shouldAutoAnswer(invitation); + const autoAnswerDelay = BroadSoft.getAutoAnswerDelay(invitation.request); + + if (shouldAutoAnswer) { + console.log(`Call has auto-answer enabled (delay: ${autoAnswerDelay}s)`); + // Handle auto-answer + BroadSoft.handleAutoAnswer(invitation, autoAnswerOptions); + } else { + console.log("Call does not have auto-answer, waiting for user action"); + // You can manually answer later with: invitation.accept() + } + + // Set up session delegate to handle NOTIFY requests + invitation.delegate = { + onNotify: (notification) => { + console.log("NOTIFY received"); + + // Check if this is a BroadSoft remote control NOTIFY + const isBroadSoft = BroadSoft.isBroadSoftNotification(notification); + + if (isBroadSoft) { + console.log("BroadSoft remote control NOTIFY detected"); + // Handle the remote control NOTIFY + BroadSoft.handleRemoteControlNotification(invitation, notification, remoteControlOptions).catch((error) => { + console.error("Error handling remote control NOTIFY:", error); + }); + + // Accept the NOTIFY + notification.accept(); + } else { + console.log("Non-BroadSoft NOTIFY, handling normally"); + // Handle other NOTIFY types + notification.accept(); + } + } + }; + + // Set up state change handlers + invitation.stateChange.addListener((state) => { + console.log(`Session state changed to: ${state}`); + }); + } +}; + +// Start the UserAgent +userAgent + .start() + .then(() => { + console.log("UserAgent started and ready to receive calls"); + }) + .catch((error) => { + console.error("Failed to start UserAgent:", error); + }); + +// Example: Parsing Call-Info headers manually +function examineCallInfoHeaders(invitation: Invitation): void { + const callInfoHeaders = BroadSoft.extractCallInfoHeaders(invitation.request); + + console.log(`Found ${callInfoHeaders.length} Call-Info header(s)`); + + for (const header of callInfoHeaders) { + console.log(` URI: ${header.uri}`); + console.log(` Parameters:`, header.params); + + if (header.params.answerAfter !== undefined) { + console.log(` Auto-answer delay: ${header.params.answerAfter} seconds`); + } + } +} + +// Graceful shutdown +process.on("SIGINT", () => { + console.log("Shutting down..."); + userAgent + .stop() + .then(() => { + console.log("UserAgent stopped"); + process.exit(0); + }) + .catch((error) => { + console.error("Error stopping UserAgent:", error); + process.exit(1); + }); +}); diff --git a/integration-tests/FREESWITCH.md b/integration-tests/FREESWITCH.md new file mode 100644 index 000000000..9b6dfeed3 --- /dev/null +++ b/integration-tests/FREESWITCH.md @@ -0,0 +1,348 @@ +# FreeSWITCH Configuration for BroadSoft Extensions Testing + +This document describes how to configure FreeSWITCH to test BroadSoft Access-Side Extensions with SIP.js. + +## Overview + +FreeSWITCH supports BroadSoft extensions through: +1. **Auto-Answer**: Using `sip_h_Call-Info` channel variable +2. **Remote Control Events**: Using `uuid_phone_event` command + +## Prerequisites + +- FreeSWITCH installed and running +- SIP user configured (e.g., 1000@localhost) +- WebSocket module enabled (mod_verto or mod_rtc) + +## 1. Auto-Answer Configuration + +### Call-Info Header Format + +The Call-Info header with `answer-after` parameter tells the SIP client to automatically answer the call after a specified delay. + +**Format:** +``` +Call-Info: ; answer-after= +``` + +### Setting Call-Info in Dialplan + +#### Method 1: Using Dialplan (XML) + +Create or edit `/usr/local/freeswitch/conf/dialplan/default/broadsoft-test.xml`: + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +#### Method 2: Using originate Command + +From FreeSWITCH console (`fs_cli`): + +```bash +# Immediate auto-answer (0 seconds) +originate {sip_h_Call-Info=; answer-after=0}user/1000@${domain} &echo + +# Auto-answer after 2 seconds +originate {sip_h_Call-Info=; answer-after=2}user/1000@${domain} &echo + +# Auto-answer after 5 seconds +originate {sip_h_Call-Info=; answer-after=5}user/1000@${domain} &echo +``` + +**Note**: Replace `${domain}` with your actual domain (e.g., `localhost` or your server's domain). + +### Testing Auto-Answer + +1. Start the SIP.js demo (`demo-broadsoft.html`) +2. Connect to FreeSWITCH +3. From `fs_cli`, run: + ```bash + originate {sip_h_Call-Info=; answer-after=2}user/1000@localhost &echo + ``` +4. Observe the SIP.js demo: + - Should detect auto-answer is enabled + - Should show 2 second countdown + - Should automatically answer after 2 seconds + +## 2. Remote Control - Talk Events + +### Overview + +FreeSWITCH's `uuid_phone_event` command can send NOTIFY messages with `Event: talk` to control microphone state. + +### Command + +```bash +uuid_phone_event talk +``` + +**Note**: This command sends a talk event notification. The SIP.js implementation interprets this as "unmute" (TalkAction.Talk). + +### SIP Message Format + +FreeSWITCH will send a NOTIFY message like: + +``` +NOTIFY sip:1000@localhost SIP/2.0 +Event: talk +Subscription-State: active +Content-Length: 0 + +``` + +**Important**: The NOTIFY body is empty (Content-Length: 0). The event type is conveyed through the `Event` header only. + +### Testing Talk Events + +1. Establish a call between FreeSWITCH and the SIP.js client +2. Get the call UUID from `fs_cli`: + ```bash + show channels + ``` +3. Send talk event: + ```bash + uuid_phone_event talk + ``` +4. Observe the SIP.js demo: + - Should receive NOTIFY with Event: talk + - Microphone should be unmuted (TalkAction.Talk is applied) + - Status should update to "Active" + - Event log shows "Remote Control - Talk Event: talk" + +## 3. Remote Control - Hold Events + +### Overview + +FreeSWITCH's `uuid_phone_event` command can send NOTIFY messages with `Event: hold` to control call hold state. + +### Command + +```bash +uuid_phone_event hold +``` + +**Note**: This command sends a hold event notification. The SIP.js implementation interprets this as "hold" (HoldAction.Hold). + +### SIP Message Format + +FreeSWITCH will send a NOTIFY message like: + +``` +NOTIFY sip:1000@localhost SIP/2.0 +Event: hold +Subscription-State: active +Content-Length: 0 + +``` + +**Important**: The NOTIFY body is empty (Content-Length: 0). The event type is conveyed through the `Event` header only. + +### Testing Hold Events + +1. Establish a call between FreeSWITCH and the SIP.js client +2. Get the call UUID from `fs_cli`: + ```bash + show channels + ``` +3. Send hold event: + ```bash + uuid_phone_event hold + ``` +4. Observe the SIP.js demo: + - Should receive NOTIFY with Event: hold + - Call should be placed on hold automatically (HoldAction.Hold is applied) + - Status should update to "On Hold (by remote)" + - Audio should be paused (SDP re-negotiation with sendonly) + - Event log shows "Remote Control - Hold Event: hold" + +## Complete Test Scenarios + +### Scenario 1: Auto-Answer with Immediate Response + +```bash +# From fs_cli +originate {sip_h_Call-Info=; answer-after=0}user/1000@localhost &echo + +# Expected behavior: +# - SIP.js demo receives INVITE with Call-Info header +# - Detects answer-after=0 +# - Automatically answers immediately +# - Call is established +``` + +### Scenario 2: Auto-Answer with Delay + +```bash +# From fs_cli +originate {sip_h_Call-Info=; answer-after=5}user/1000@localhost &echo + +# Expected behavior: +# - SIP.js demo receives INVITE with Call-Info header +# - Detects answer-after=5 +# - Shows countdown: 5, 4, 3, 2, 1 +# - Automatically answers after 5 seconds +# - Call is established +``` + +### Scenario 3: Remote Mute Control + +```bash +# Step 1: Establish a call +originate user/1000@localhost &echo + +# Step 2: Get the call UUID +show channels + +# Step 3: Mute the call +uuid_phone_event talk mute + +# Wait 3 seconds + +# Step 4: Unmute the call +uuid_phone_event talk talk + +# Expected behavior: +# - After mute command: microphone is muted +# - After unmute command: microphone is active +``` + +### Scenario 4: Remote Hold Control + +```bash +# Step 1: Establish a call +originate user/1000@localhost &echo + +# Step 2: Get the call UUID +show channels + +# Step 3: Place call on hold +uuid_phone_event hold hold + +# Wait 3 seconds + +# Step 4: Resume call +uuid_phone_event hold unhold + +# Expected behavior: +# - After hold command: call is placed on hold, audio pauses +# - After unhold command: call resumes, audio continues +``` + +### Scenario 5: Combined Test + +```bash +# Step 1: Auto-answer call +originate {sip_h_Call-Info=; answer-after=1}user/1000@localhost &echo + +# Wait for auto-answer (1 second) + +# Step 2: Get UUID +show channels + +# Step 3: Test mute +uuid_phone_event talk mute +# Wait 2 seconds +uuid_phone_event talk talk + +# Step 4: Test hold +uuid_phone_event hold hold +# Wait 2 seconds +uuid_phone_event hold unhold + +# Expected behavior: +# - Call auto-answers after 1 second +# - Microphone mutes and unmutes correctly +# - Call holds and resumes correctly +``` + +## Troubleshooting + +### Issue: Call-Info header not being sent + +**Solution**: Check that the `export` application is used, not just `set`: +```xml + +``` + +### Issue: uuid_phone_event not working + +**Check**: +1. Verify the call UUID is correct: + ```bash + show channels + ``` +2. Ensure the call is established (not ringing) +3. Check FreeSWITCH logs: + ```bash + fs_cli -x "console loglevel debug" + ``` + +### Issue: NOTIFY not received by SIP.js + +**Check**: +1. Enable SIP message logging in FreeSWITCH +2. Verify the NOTIFY is being sent +3. Check browser console for errors +4. Ensure the session delegate is properly set up + +## Reference + +### FreeSWITCH Commands + +- `originate` - Initiate a call +- `uuid_phone_event` - Send phone events to a call +- `show channels` - List active channels +- `uuid_dump ` - Show all variables for a call + +### SIP Headers + +- `Call-Info` - Contains auto-answer information +- `Event` - Specifies the event type in NOTIFY + +### Event Types + +- `talk` - Microphone control events + - Body: `mute` or `talk` +- `hold` - Hold control events + - Body: `hold`, `unhold`, or `resume` + +## Additional Resources + +- FreeSWITCH Documentation: https://freeswitch.org/confluence/ +- BroadWorks SIP Access Side Extensions +- RFC 3515 - REFER Method +- RFC 3265 - SIP-Specific Event Notification diff --git a/integration-tests/README.md b/integration-tests/README.md new file mode 100644 index 000000000..317d822f7 --- /dev/null +++ b/integration-tests/README.md @@ -0,0 +1,348 @@ +# BroadSoft Extensions Integration Tests + +This directory contains integration testing resources for BroadSoft Access-Side Extensions in SIP.js. + +## Overview + +These integration tests validate the implementation of two BroadSoft extensions: + +1. **Auto-Answer** - Automatic call answering based on Call-Info header +2. **Remote Control - Talk Events** - Remote control for answering/resuming calls + +## Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” SIP/WebSocket β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ SIP.js Demo β”‚ ◄───────────────────────────► β”‚ FreeSWITCH β”‚ +β”‚ (Web Browser) β”‚ β”‚ Server β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β–² β”‚ + β”‚ β”‚ + β”‚ 1. Receives INVITE with Call-Info β”‚ + β”‚ - Detects answer-after parameter β”‚ + β”‚ - Auto-answers after delay β”‚ + β”‚ β”‚ + β”‚ 2. Receives NOTIFY (Event: talk) β”‚ + β”‚ - Answers call or resumes from hold β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + Operator sends commands via fs_cli +``` + +## Prerequisites + +### Software Requirements + +- **Node.js**: v16 or later +- **FreeSWITCH**: 1.10.x or later +- **Web Browser**: Chrome, Firefox, or Safari (with WebRTC support) + +### FreeSWITCH Configuration + +1. **WebSocket Module**: Enable `mod_verto` or `mod_rtc` for WebSocket support +2. **SIP User**: Create a test user (e.g., 1000@localhost) +3. **Dialplan**: Configure dialplan for auto-answer (optional, can use originate command) + +See [FREESWITCH.md](./FREESWITCH.md) for detailed FreeSWITCH configuration instructions. + +## Setup + +### 1. Build SIP.js and Demo + +From the project root directory: + +```bash +# Install dependencies +npm install + +# Build the library +npm run build + +# Build the demo +npm run build-demo +``` + +### 2. Configure FreeSWITCH + +Follow the instructions in [FREESWITCH.md](./FREESWITCH.md) to: +- Set up SIP users +- Configure WebSocket transport +- (Optional) Set up dialplan for auto-answer + +### 3. Start Web Server + +You need a web server to serve the demo. You can use any web server, for example: + +```bash +# Using Python 3 +cd demo +python3 -m http.server 8080 + +# Or using Node.js http-server +npx http-server demo -p 8080 +``` + +## Running Tests + +### Test 1: Auto-Answer + +#### Objective +Verify that SIP.js automatically answers calls when the Call-Info header contains the `answer-after` parameter. + +#### Steps + +1. Open the demo in your browser: + ``` + http://localhost:8080/demo-broadsoft.html + ``` + +2. Configure connection settings: + - **WebSocket Server**: `ws://localhost:5066` (or your FreeSWITCH WebSocket address) + - **SIP User**: `1000` (your test user) + - **Password**: Your user's password + - **Domain**: `localhost` (or your domain) + +3. Click **Connect** and wait for "Connected" status + +4. From FreeSWITCH console (`fs_cli`), initiate a call with auto-answer: + ```bash + originate {sip_h_Call-Info=; answer-after=2}user/1000@localhost &echo + ``` + +5. **Expected Results**: + - Demo shows "Incoming call received" + - Auto-Answer panel shows: + - Enabled: βœ“ Yes + - Delay: 2 seconds + - Countdown: 2s β†’ 1s β†’ 0s + - Call is automatically answered after 2 seconds + - Status changes to "Answered automatically" + - Audio starts playing + +#### Variations + +Test different delays: +```bash +# Immediate answer (0 seconds) +originate {sip_h_Call-Info=; answer-after=0}user/1000@localhost &echo + +# 5 second delay +originate {sip_h_Call-Info=; answer-after=5}user/1000@localhost &echo +``` + +### Test 2: Remote Control - Talk Events + +#### Objective +Verify that SIP.js responds to remote talk commands via NOTIFY messages. + +#### Steps + +1. Establish a call (auto-answer or manual): + ```bash + originate user/1000@localhost &echo + ``` + +2. Get the call UUID: + ```bash + show channels + ``` + Look for the UUID in the output (first column) + +3. Send talk event: + ```bash + uuid_phone_event talk + ``` + +4. **Expected Results**: + - Demo event log shows: "🎀 Remote Control - Talk Event: talk" + - Talk Event panel shows: + - Last Event: talk + - Microphone status is updated + - Event is processed and logged + +### Test 3: Remote Control - Hold Events + +#### Objective +Verify that SIP.js responds to remote hold commands via NOTIFY messages. + +#### Steps + +1. Establish a call: + ```bash + originate user/1000@localhost &echo + ``` + +2. Get the call UUID: + ```bash + show channels + ``` + +3. Send hold event: + ```bash + uuid_phone_event hold + ``` + +4. **Expected Results**: + - Demo event log shows: "⏸️ Remote Control - Hold Event: hold" + - Hold Event panel shows: + - Last Event: hold + - Call status is updated + - Event is processed and logged + +### Test 4: Combined Scenario + +#### Objective +Test all three features in sequence. + +#### Steps + +1. Initiate auto-answer call: + ```bash + originate {sip_h_Call-Info=; answer-after=1}user/1000@localhost &echo + ``` + +2. Wait for auto-answer (1 second) + +3. Get the call UUID: + ```bash + show channels + ``` + +4. Test talk event: + ```bash + uuid_phone_event talk + ``` + +5. Test hold event: + ```bash + uuid_phone_event hold + ``` + +6. Hang up: + ```bash + uuid_kill + ``` + +#### Expected Results +- Call auto-answers after 1 second +- Talk event is received and processed correctly +- Hold event is received and processed correctly +- All events are logged in the event log +- All status panels update correctly + +## Manual Controls Comparison + +The demo provides manual controls (Mute checkbox, Hold checkbox) to compare behavior: + +- **Manual Mute**: Initiated by clicking the checkbox + - Status shows "Muted (manual)" in orange +- **Remote Mute**: Initiated by FreeSWITCH command + - Status shows "Muted (by remote)" in red + - Checkbox is automatically updated + +This helps verify that remote control commands work independently of manual controls. + +## Verification Checklist + +Use this checklist to verify all features: + +### Auto-Answer +- [ ] Call with answer-after=0 is answered immediately +- [ ] Call with answer-after=2 is answered after 2 seconds +- [ ] Call with answer-after=5 is answered after 5 seconds +- [ ] Countdown timer displays correctly +- [ ] Status updates show correct information +- [ ] Call without auto-answer requires manual answer + +### Remote Control - Talk +- [ ] Mute command mutes the microphone +- [ ] Unmute command unmutes the microphone +- [ ] Status panel updates correctly +- [ ] Event log shows all events +- [ ] Manual mute still works independently + +### Remote Control - Hold +- [ ] Hold command places call on hold +- [ ] Unhold command resumes call +- [ ] Audio behavior is correct +- [ ] Status panel updates correctly +- [ ] Event log shows all events +- [ ] Manual hold still works independently + +## Troubleshooting + +### Demo doesn't connect to FreeSWITCH + +**Check**: +1. FreeSWITCH WebSocket module is enabled +2. WebSocket port is correct (default: 5066 for mod_verto, 8081 for mod_rtc) +3. SIP user credentials are correct +4. Browser console for errors + +### Auto-Answer doesn't work + +**Check**: +1. Call-Info header is being sent by FreeSWITCH (check with SIP trace) +2. answer-after parameter format is correct +3. Browser console for JavaScript errors +4. Event log for error messages + +### Remote Control events not received + +**Check**: +1. Call is established (not just ringing) +2. UUID is correct (use `show channels`) +3. FreeSWITCH logs for NOTIFY messages +4. Browser console for errors in NOTIFY handling + +### Audio issues + +**Check**: +1. Browser permissions for microphone/speaker +2. Browser console for media errors +3. Remote audio element is playing +4. Volume is not muted in browser + +## Logs and Debugging + +### Browser Console +Open browser DevTools (F12) to see: +- SIP.js debug messages +- JavaScript errors +- Network activity + +### FreeSWITCH Console +From `fs_cli`: +```bash +# Enable debug logging +console loglevel debug + +# Show SIP messages +sofia global siptrace on + +# Monitor events +/events plain all +``` + +### Event Log in Demo +The demo includes an event log that shows: +- Connection events +- Call state changes +- BroadSoft events +- Errors and warnings + +Use the "Clear Log" button to reset the log. + +## Additional Resources + +- [FREESWITCH.md](./FREESWITCH.md) - FreeSWITCH configuration details +- [BroadSoft Extensions Documentation](../src/api/broadsoft/README.md) +- [Example Code](../examples/broadsoft-extensions.ts) +- [Unit Tests](../test/spec/api/broadsoft/) + +## Support + +For issues or questions: +- Check the troubleshooting section above +- Review FreeSWITCH and SIP.js logs +- Consult the BroadSoft extensions documentation +- Open an issue on the SIP.js GitHub repository diff --git a/src/api/broadsoft/README.md b/src/api/broadsoft/README.md new file mode 100644 index 000000000..d24402e79 --- /dev/null +++ b/src/api/broadsoft/README.md @@ -0,0 +1,216 @@ +# BroadSoft Access-Side Extensions + +This module provides support for two common BroadSoft Access-Side SIP extensions in SIP.js: + +1. **Call-Info with answer-after parameter** - Auto-Answer +2. **NOTIFY (Event: talk)** - Remote Control for answering/resuming calls + +## Features + +### 1. Auto-Answer (Call-Info Header) + +Automatically answer incoming calls when the `answer-after` parameter is present in the Call-Info header. + +**SIP Header Example:** +``` +Call-Info: ; answer-after=1 +``` + +**Usage:** +```typescript +import { BroadSoft, Invitation } from "sip.js"; + +const autoAnswerOptions: BroadSoft.AutoAnswerOptions = { + enabled: true, + onBeforeAutoAnswer: (delaySeconds) => { + console.log(`Auto-answering in ${delaySeconds}s`); + }, + onAfterAutoAnswer: () => { + console.log("Auto-answered"); + } +}; + +// In your onInvite handler: +userAgent.delegate = { + onInvite: (invitation: Invitation) => { + if (BroadSoft.shouldAutoAnswer(invitation)) { + BroadSoft.handleAutoAnswer(invitation, autoAnswerOptions); + } + } +}; +``` + +### 2. Remote Control - Talk Events (NOTIFY with Event: talk) + +Handle remote control of microphone state via NOTIFY messages. + +**SIP Message Example:** +``` +NOTIFY sip:alice@example.com SIP/2.0 +Event: talk +Content-Type: text/plain + +mute +``` + +**Actions:** +- `talk` - Unmute microphone +- `mute` - Mute microphone + +**Usage:** +```typescript +import { BroadSoft } from "sip.js"; + +const remoteControlOptions: BroadSoft.RemoteControlOptions = { + enabled: true, + autoApply: true, // Automatically apply the action + onTalkEvent: (action) => { + console.log(`Talk event: ${action}`); + } +}; + +// In your session delegate: +invitation.delegate = { + onNotify: (notification) => { + if (BroadSoft.isBroadSoftNotification(notification)) { + await BroadSoft.handleRemoteControlNotification( + invitation, + notification, + remoteControlOptions + ); + notification.accept(); + } + } +}; +``` + +## API Reference + +### Types + +#### `AutoAnswerOptions` +```typescript +interface AutoAnswerOptions { + enabled: boolean; + onBeforeAutoAnswer?: (answerAfter: number) => void; + onAfterAutoAnswer?: () => void; + delayOverride?: number; // Override header delay +} +``` + +#### `RemoteControlOptions` +```typescript +interface RemoteControlOptions { + enabled: boolean; + onTalkEvent?: (action: TalkAction) => void; + autoApply: boolean; // Auto-apply actions to session +} +``` + +#### `TalkAction` +```typescript +enum TalkAction { + Talk = "talk", // Unmute + Mute = "mute" // Mute +} +``` + +### Functions + +#### Auto-Answer Functions + +- `shouldAutoAnswer(invitation: Invitation): boolean` + - Check if invitation has auto-answer enabled + +- `getAutoAnswerDelay(request: IncomingRequestMessage): number | undefined` + - Get the answer-after delay in seconds + +- `handleAutoAnswer(invitation: Invitation, options: AutoAnswerOptions): boolean` + - Handle auto-answering an invitation + +- `extractCallInfoHeaders(request: IncomingRequestMessage): CallInfoHeader[]` + - Parse all Call-Info headers from a request + +#### Remote Control Functions + +**High-level API (recommended - works with SessionDelegate):** + +- `isBroadSoftNotification(notification: Notification): boolean` + - Check if a Notification is a BroadSoft remote control event + +- `handleRemoteControlNotification(session: Session, notification: Notification, options: RemoteControlOptions): Promise` + - Handle a BroadSoft remote control NOTIFY from SessionDelegate + +- `parseEventHeaderFromNotification(notification: Notification): BroadSoftEvent | undefined` + - Parse Event header from a Notification + +- `parseNotifyBodyFromNotification(notification: Notification, eventType: BroadSoftEvent): BroadSoftNotifyBody | undefined` + - Parse the body of a Notification + +**Low-level API (for advanced use cases):** + +- `isBroadSoftNotify(request: IncomingNotifyRequest): boolean` + - Check if NOTIFY is a BroadSoft remote control event + +- `handleRemoteControlNotify(session: Session, request: IncomingNotifyRequest, options: RemoteControlOptions): Promise` + - Handle a BroadSoft remote control NOTIFY + +- `parseEventHeader(request: IncomingNotifyRequest): BroadSoftEvent | undefined` + - Parse Event header from an IncomingNotifyRequest + +- `parseNotifyBody(request: IncomingNotifyRequest, eventType: BroadSoftEvent): BroadSoftNotifyBody | undefined` + - Parse the body of an IncomingNotifyRequest + +**Manual control functions:** + +- `applyTalkAction(session: Session, action: TalkAction): void` + - Manually apply a talk action to a session + +## Complete Example + +See [examples/broadsoft-extensions.ts](../../../examples/broadsoft-extensions.ts) for a complete working example. + +## Protocol Details + +### Call-Info Header Format + +``` +Call-Info: ; answer-after=[; other-params] +``` + +- `answer-after=0` - Answer immediately +- `answer-after=1` - Answer after 1 second delay +- `answer-after=N` - Answer after N seconds delay + +### NOTIFY Event Format + +**Talk Event:** +``` +NOTIFY sip:user@domain SIP/2.0 +Event: talk +Content-Type: text/plain +Content-Length: 4 + +mute +``` + +## Browser Compatibility + +The remote control features require WebRTC support and work with the Web platform `SessionDescriptionHandler`. They have been tested with: + +- Chrome/Edge 90+ +- Firefox 88+ +- Safari 14+ + +## Notes + +- Auto-answer respects the session state and will only answer if the session is in `Initial` or `Establishing` state +- Remote control actions are only applied if `autoApply: true` is set, otherwise only callbacks are invoked +- All callbacks are wrapped in try-catch to prevent errors from breaking the control flow + +## References + +- BroadSoft/Cisco BroadWorks Interface Specifications +- RFC 3261 - SIP: Session Initiation Protocol +- RFC 3264 - An Offer/Answer Model with SDP +- RFC 3515 - The Session Initiation Protocol (SIP) Refer Method diff --git a/src/api/broadsoft/auto-answer.ts b/src/api/broadsoft/auto-answer.ts new file mode 100644 index 000000000..fcd5154df --- /dev/null +++ b/src/api/broadsoft/auto-answer.ts @@ -0,0 +1,85 @@ +/** + * BroadSoft Auto-Answer Implementation + * + * Handles automatic answering of calls based on Call-Info header with answer-after parameter. + */ + +import { Invitation } from "../invitation.js"; +import { InvitationAcceptOptions } from "../invitation-accept-options.js"; +import { AutoAnswerOptions } from "./types.js"; +import { getAutoAnswerDelay, hasAutoAnswer } from "./call-info-parser.js"; + +/** + * Handle auto-answer for an incoming invitation + * + * This function checks if the invitation has an answer-after parameter in the Call-Info header. + * If present, it will automatically accept the invitation after the specified delay. + * + * @param invitation - The incoming invitation to potentially auto-answer + * @param options - Auto-answer configuration options + * @param acceptOptions - Options to pass to invitation.accept() + * @returns True if auto-answer was triggered, false otherwise + */ +export function handleAutoAnswer( + invitation: Invitation, + options: AutoAnswerOptions, + acceptOptions?: InvitationAcceptOptions +): boolean { + if (!options.enabled) { + return false; + } + + // Check for auto-answer in Call-Info header + const autoAnswerDelay = getAutoAnswerDelay(invitation.request); + + if (autoAnswerDelay === undefined) { + return false; + } + + // Use override delay if specified + const delaySeconds = options.delayOverride !== undefined ? options.delayOverride : autoAnswerDelay; + const delayMs = delaySeconds * 1000; + + // Invoke pre-answer callback + if (options.onBeforeAutoAnswer) { + try { + options.onBeforeAutoAnswer(delaySeconds); + } catch (e) { + // Silently ignore callback errors + } + } + + // Schedule auto-answer + setTimeout(() => { + // Check if invitation is still in a state that can be accepted + if (invitation.state === "Initial" || invitation.state === "Establishing") { + invitation + .accept(acceptOptions) + .then(() => { + // Invoke post-answer callback + if (options.onAfterAutoAnswer) { + try { + options.onAfterAutoAnswer(); + } catch (e) { + // Silently ignore callback errors + } + } + }) + .catch(() => { + // Silently ignore auto-answer failures + }); + } + }, delayMs); + + return true; +} + +/** + * Check if an invitation should be auto-answered + * + * @param invitation - The incoming invitation to check + * @returns True if the invitation has auto-answer enabled + */ +export function shouldAutoAnswer(invitation: Invitation): boolean { + return hasAutoAnswer(invitation.request); +} diff --git a/src/api/broadsoft/call-info-parser.ts b/src/api/broadsoft/call-info-parser.ts new file mode 100644 index 000000000..6a9642111 --- /dev/null +++ b/src/api/broadsoft/call-info-parser.ts @@ -0,0 +1,128 @@ +/** + * Call-Info Header Parser + * + * Utilities for parsing BroadSoft Call-Info headers with answer-after parameter. + */ + +import { IncomingRequestMessage } from "../../core/messages/incoming-request-message.js"; +import { CallInfoHeader } from "./types.js"; + +/** + * Parse a single Call-Info header value + * + * Call-Info format: ; param1=value1; param2=value2 + * Example: ; answer-after=1 + * + * @param headerValue - Raw Call-Info header value + * @returns Parsed CallInfoHeader object + */ +export function parseCallInfoHeader(headerValue: string): CallInfoHeader | undefined { + if (!headerValue) { + return undefined; + } + + // Extract URI (between < and >) + const uriMatch = headerValue.match(/<([^>]+)>/); + if (!uriMatch) { + return undefined; + } + + const uri = uriMatch[1]; + const params: { [key: string]: string | number | boolean } = {}; + + // Extract parameters after the URI + const matchIndex = uriMatch.index !== undefined ? uriMatch.index : 0; + const paramsString = headerValue.substring(matchIndex + uriMatch[0].length); + + // Split by semicolon and parse each parameter + const paramPairs = paramsString.split(";"); + + for (const pair of paramPairs) { + const trimmed = pair.trim(); + if (!trimmed) { + continue; + } + + const equalIndex = trimmed.indexOf("="); + if (equalIndex === -1) { + // Parameter without value (flag) + params[trimmed] = true; + } else { + const key = trimmed.substring(0, equalIndex).trim(); + let value: string | number = trimmed.substring(equalIndex + 1).trim(); + + // Remove quotes if present + if (value.startsWith('"') && value.endsWith('"')) { + value = value.substring(1, value.length - 1); + } + + // Convert to number if applicable + if (key === "answer-after" || key === "answerAfter") { + const numValue = parseInt(value as string, 10); + if (!isNaN(numValue)) { + params.answerAfter = numValue; + } + } else { + // Try to parse as number, otherwise keep as string + const numValue = parseFloat(value as string); + params[key] = isNaN(numValue) ? value : numValue; + } + } + } + + return { uri, params }; +} + +/** + * Extract all Call-Info headers from an incoming request + * + * @param request - Incoming SIP request message + * @returns Array of parsed CallInfoHeader objects + */ +export function extractCallInfoHeaders(request: IncomingRequestMessage): CallInfoHeader[] { + const headers: CallInfoHeader[] = []; + + try { + const callInfoHeaders = request.getHeaders("call-info"); + + for (const headerValue of callInfoHeaders) { + const parsed = parseCallInfoHeader(headerValue); + if (parsed) { + headers.push(parsed); + } + } + } catch (e) { + // Header not present or parse error + return headers; + } + + return headers; +} + +/** + * Check if a request has auto-answer enabled via Call-Info header + * + * @param request - Incoming SIP request message + * @returns Answer-after delay in seconds, or undefined if not present + */ +export function getAutoAnswerDelay(request: IncomingRequestMessage): number | undefined { + const callInfoHeaders = extractCallInfoHeaders(request); + + for (const header of callInfoHeaders) { + if (header.params.answerAfter !== undefined) { + return header.params.answerAfter; + } + } + + return undefined; +} + +/** + * Check if auto-answer is requested (answer-after parameter is present) + * + * @param request - Incoming SIP request message + * @returns True if auto-answer is requested + */ +export function hasAutoAnswer(request: IncomingRequestMessage): boolean { + return getAutoAnswerDelay(request) !== undefined; +} diff --git a/src/api/broadsoft/index.ts b/src/api/broadsoft/index.ts new file mode 100644 index 000000000..26686a177 --- /dev/null +++ b/src/api/broadsoft/index.ts @@ -0,0 +1,70 @@ +/** + * BroadSoft Access-Side Extensions for SIP.js + * + * This module provides support for two common BroadSoft Access-Side extensions: + * + * 1. Call-Info: ...; answer-after=1 - Auto-Answer + * Automatically answers incoming calls when the answer-after parameter is present + * in the Call-Info header. + * + * 2. NOTIFY (Event: talk) - Remote Control Event + * Automatically answers ringing calls or resumes calls from hold when receiving + * NOTIFY with Event: talk header. Performs SIP signaling per BroadSoft specification. + * + * @example + * ```typescript + * import { BroadSoft } from "sip.js/lib/api/broadsoft"; + * + * // Configure auto-answer + * const autoAnswerOptions: BroadSoft.AutoAnswerOptions = { + * enabled: true, + * onBeforeAutoAnswer: (delay) => console.log(`Auto-answering in ${delay}s`), + * onAfterAutoAnswer: () => console.log("Auto-answered") + * }; + * + * // Configure remote control + * const remoteControlOptions: BroadSoft.RemoteControlOptions = { + * enabled: true, + * onTalkEvent: (action) => console.log(`Talk event: ${action}`) + * }; + * + * // Handle incoming invitation + * userAgent.delegate = { + * onInvite: (invitation) => { + * // Check for auto-answer + * if (BroadSoft.shouldAutoAnswer(invitation)) { + * BroadSoft.handleAutoAnswer(invitation, autoAnswerOptions); + * } + * + * // Set up remote control handling + * invitation.delegate = { + * onNotifyRequest: (request) => { + * BroadSoft.handleRemoteControlNotify(invitation, request, remoteControlOptions); + * } + * }; + * } + * }; + * ``` + */ + +// Export all types +export * from "./types.js"; + +// Export Call-Info parsing utilities +export { parseCallInfoHeader, extractCallInfoHeaders, getAutoAnswerDelay, hasAutoAnswer } from "./call-info-parser.js"; + +// Export auto-answer utilities +export { handleAutoAnswer, shouldAutoAnswer } from "./auto-answer.js"; + +// Export remote control utilities +export { + parseEventHeader, + parseNotifyBody, + applyTalkAction, + handleRemoteControlNotify, + isBroadSoftNotify, + parseEventHeaderFromNotification, + parseNotifyBodyFromNotification, + handleRemoteControlNotification, + isBroadSoftNotification +} from "./remote-control.js"; diff --git a/src/api/broadsoft/remote-control.ts b/src/api/broadsoft/remote-control.ts new file mode 100644 index 000000000..098e8b51e --- /dev/null +++ b/src/api/broadsoft/remote-control.ts @@ -0,0 +1,361 @@ +/** + * BroadSoft Remote Control Implementation + * + * Handles NOTIFY events for remote control operations (Event: talk, Event: hold). + */ + +import { IncomingNotifyRequest } from "../../core/messages/methods/notify.js"; +import { Invitation } from "../invitation.js"; +import { Notification } from "../notification.js"; +import { Session } from "../session.js"; +import { SessionState } from "../session-state.js"; +import { BroadSoftEvent, BroadSoftNotifyBody, RemoteControlOptions, TalkAction, TalkNotifyBody } from "./types.js"; + +/** + * Parse the Event header from a NOTIFY request + * + * @param request - Incoming NOTIFY request + * @returns The event type, or undefined if not parseable + */ +export function parseEventHeader(request: IncomingNotifyRequest): BroadSoftEvent | undefined { + try { + const eventHeader = request.message.getHeader("event"); + if (!eventHeader) { + return undefined; + } + + // Event header format: "talk" (may have parameters) + const eventType = eventHeader.split(";")[0].trim().toLowerCase(); + + if (eventType === BroadSoftEvent.Talk) { + return eventType as BroadSoftEvent; + } + } catch (e) { + return undefined; + } + + return undefined; +} + +/** + * Parse the body of a NOTIFY request for BroadSoft events + * + * Expected body format: + * - For talk events: "talk" or "mute", or empty (defaults to "talk"/unmute) + * + * Note: FreeSWITCH's uuid_phone_event sends NOTIFY with empty body. + * When body is empty, Event: talk β†’ TalkAction.Talk (unmute) + * + * @param request - Incoming NOTIFY request + * @param eventType - The type of event (from Event header) + * @returns Parsed notify body, or undefined if not parseable + */ +export function parseNotifyBody( + request: IncomingNotifyRequest, + eventType: BroadSoftEvent +): BroadSoftNotifyBody | undefined { + try { + const body = request.message.body; + const action = body ? body.trim().toLowerCase() : ""; + + // Handle empty body (FreeSWITCH uuid_phone_event behavior) + if (action === "") { + if (eventType === BroadSoftEvent.Talk) { + // Empty body for talk event means unmute + return { + event: BroadSoftEvent.Talk, + action: TalkAction.Talk + } as TalkNotifyBody; + } + } + + // Handle explicit actions in body + if (eventType === BroadSoftEvent.Talk) { + if (action === TalkAction.Talk || action === TalkAction.Mute) { + return { + event: BroadSoftEvent.Talk, + action: action as TalkAction + } as TalkNotifyBody; + } + } + } catch (e) { + return undefined; + } + + return undefined; +} + +/** + * Apply a talk action to a session according to BroadSoft specification + * + * @remarks + * Per BroadSoft spec, Event: talk means "answer" or "resume": + * - If session is in Initial state (ringing): Accept the call (send 200 OK with SDP) + * - If session is in Established state: Send re-INVITE with a=sendrecv to resume from hold + * - Otherwise: No action taken + * + * @param session - The session to apply the action to + * @param action - The talk action (only TalkAction.Talk triggers SIP signaling) + */ +export async function applyTalkAction(session: Session, action: TalkAction): Promise { + // Only TalkAction.Talk triggers SIP signaling per BroadSoft spec + // TalkAction.Mute is not used in BroadSoft NOTIFY (FreeSWITCH sends empty body) + if (action !== TalkAction.Talk) { + return; + } + + // Log current session state for debugging + /* eslint-disable no-console */ + console.log(`[BroadSoft] applyTalkAction: session.state = ${session.state}`); + /* eslint-enable no-console */ + + if (session.state === SessionState.Initial) { + // Session is ringing - accept the call + if (session instanceof Invitation) { + /* eslint-disable no-console */ + console.log("[BroadSoft] applyTalkAction: Calling session.accept()"); + /* eslint-enable no-console */ + await session.accept(); + /* eslint-disable no-console */ + console.log("[BroadSoft] applyTalkAction: session.accept() completed"); + /* eslint-enable no-console */ + } else { + throw new Error("Cannot accept - session is not an Invitation"); + } + } else if (session.state === SessionState.Established) { + // Session is established - send re-INVITE with sendrecv to resume + // Use resumeModifier to explicitly set a=sendrecv in SDP + /* eslint-disable no-console */ + console.log("[BroadSoft] applyTalkAction: Sending re-INVITE with sendrecv"); + /* eslint-enable no-console */ + await session.invite({ + sessionDescriptionHandlerOptions: session.sessionDescriptionHandlerOptions, + sessionDescriptionHandlerModifiers: [resumeModifier] + }); + } else { + /* eslint-disable no-console */ + console.log(`[BroadSoft] applyTalkAction: No action for state ${session.state}`); + /* eslint-enable no-console */ + } + // For other states (Establishing, Terminating, Terminated), do nothing +} + +/** + * SDP modifier for resuming a call from hold + * + * This modifier sets the media direction to "sendrecv" by replacing sendonly/inactive + */ +function resumeModifier(description: RTCSessionDescriptionInit): Promise { + if (!description.sdp) { + return Promise.resolve(description); + } + + let sdp = description.sdp; + + // Replace sendonly with sendrecv and inactive with recvonly + sdp = sdp.replace(/a=sendonly\r\n/g, "a=sendrecv\r\n"); + sdp = sdp.replace(/a=inactive\r\n/g, "a=recvonly\r\n"); + + return Promise.resolve({ + type: description.type, + sdp: sdp + }); +} + +/** + * Handle a BroadSoft remote control NOTIFY request + * + * This function parses the NOTIFY request and automatically applies the appropriate + * SIP signaling action per BroadSoft specification. + * + * @param session - The session receiving the NOTIFY + * @param request - The incoming NOTIFY request + * @param options - Remote control configuration options + * @returns True if the NOTIFY was handled as a BroadSoft remote control event + */ +export async function handleRemoteControlNotify( + session: Session, + request: IncomingNotifyRequest, + options: RemoteControlOptions +): Promise { + if (!options.enabled) { + return false; + } + + const eventType = parseEventHeader(request); + if (!eventType) { + return false; + } + + const notifyBody = parseNotifyBody(request, eventType); + if (!notifyBody) { + return false; + } + + // Handle talk events + if (notifyBody.event === BroadSoftEvent.Talk) { + const talkBody = notifyBody as TalkNotifyBody; + + // Invoke callback + if (options.onTalkEvent) { + try { + options.onTalkEvent(talkBody.action); + } catch (e) { + // Silently ignore callback errors + } + } + + return true; + } + + return false; +} + +/** + * Check if a NOTIFY request is a BroadSoft remote control event + * + * @param request - The incoming NOTIFY request + * @returns True if this is a BroadSoft remote control event + */ +export function isBroadSoftNotify(request: IncomingNotifyRequest): boolean { + const eventType = parseEventHeader(request); + return eventType === BroadSoftEvent.Talk; +} + +/** + * Parse the Event header from a Notification + * + * @param notification - The notification wrapper + * @returns The event type, or undefined if not parseable + */ +export function parseEventHeaderFromNotification(notification: Notification): BroadSoftEvent | undefined { + try { + const eventHeader = notification.request.getHeader("event"); + if (!eventHeader) { + return undefined; + } + + const eventType = eventHeader.split(";")[0].trim().toLowerCase(); + + if (eventType === BroadSoftEvent.Talk) { + return eventType as BroadSoftEvent; + } + } catch (e) { + return undefined; + } + + return undefined; +} + +/** + * Parse the body of a Notification for BroadSoft events + * + * @param notification - The notification wrapper + * @param eventType - The type of event (from Event header) + * @returns Parsed notify body, or undefined if not parseable + */ +export function parseNotifyBodyFromNotification( + notification: Notification, + eventType: BroadSoftEvent +): BroadSoftNotifyBody | undefined { + try { + const body = notification.request.body; + const action = body ? body.trim().toLowerCase() : ""; + + // Handle empty body (FreeSWITCH uuid_phone_event behavior) + if (action === "") { + if (eventType === BroadSoftEvent.Talk) { + // Empty body for talk event means unmute/talk + return { + event: BroadSoftEvent.Talk, + action: TalkAction.Talk + } as TalkNotifyBody; + } + } + + // Handle explicit actions in body + if (eventType === BroadSoftEvent.Talk) { + if (action === TalkAction.Talk || action === TalkAction.Mute) { + return { + event: BroadSoftEvent.Talk, + action: action as TalkAction + } as TalkNotifyBody; + } + } + } catch (e) { + return undefined; + } + + return undefined; +} + +/** + * Handle a BroadSoft remote control NOTIFY from a Notification wrapper + * + * This is a convenience function for use with SessionDelegate.onNotify callback. + * Per BroadSoft specification, this function automatically applies the appropriate + * SIP signaling (accept call or resume from hold) after invoking callbacks. + * + * @param session - The session receiving the NOTIFY + * @param notification - The notification wrapper from delegate + * @param options - Remote control configuration options + * @returns True if the NOTIFY was handled as a BroadSoft remote control event + */ +export async function handleRemoteControlNotification( + session: Session, + notification: Notification, + options: RemoteControlOptions +): Promise { + if (!options.enabled) { + return false; + } + + const eventType = parseEventHeaderFromNotification(notification); + if (!eventType) { + return false; + } + + const notifyBody = parseNotifyBodyFromNotification(notification, eventType); + if (!notifyBody) { + return false; + } + + // Handle talk events + if (notifyBody.event === BroadSoftEvent.Talk) { + const talkBody = notifyBody as TalkNotifyBody; + + // Invoke callback + if (options.onTalkEvent) { + try { + options.onTalkEvent(talkBody.action); + } catch (e) { + // Silently ignore callback errors + } + } + + // Automatically apply SIP signaling per BroadSoft spec + try { + await applyTalkAction(session, talkBody.action); + } catch (e) { + // Log error but continue - don't block NOTIFY response + /* eslint-disable no-console */ + console.error("[BroadSoft] Error applying talk action:", e); + /* eslint-enable no-console */ + } + + return true; + } + + return false; +} + +/** + * Check if a Notification is a BroadSoft remote control event + * + * @param notification - The notification wrapper + * @returns True if this is a BroadSoft remote control event + */ +export function isBroadSoftNotification(notification: Notification): boolean { + const eventType = parseEventHeaderFromNotification(notification); + return eventType === BroadSoftEvent.Talk; +} diff --git a/src/api/broadsoft/types.ts b/src/api/broadsoft/types.ts new file mode 100644 index 000000000..9e4a45091 --- /dev/null +++ b/src/api/broadsoft/types.ts @@ -0,0 +1,85 @@ +/** + * BroadSoft Access-Side Extension Types + * + * This module provides type definitions for BroadSoft-specific SIP extensions. + */ + +/** + * Call-Info header parameters for BroadSoft extensions + */ +export interface CallInfoHeader { + /** The URI part of the Call-Info header */ + uri: string; + /** Parameters extracted from the Call-Info header */ + params: { + /** Auto-answer delay in seconds (0 = immediate, > 0 = delayed) */ + answerAfter?: number; + /** Additional parameters */ + [key: string]: string | number | boolean | undefined; + }; +} + +/** + * BroadSoft Event types for NOTIFY messages + */ +export enum BroadSoftEvent { + /** Remote control event for talk/mute operations */ + Talk = "talk" +} + +/** + * Talk event actions + */ +export enum TalkAction { + /** Unmute the microphone */ + Talk = "talk", + /** Mute the microphone */ + Mute = "mute" +} + +/** + * Parsed NOTIFY body for talk events + */ +export interface TalkNotifyBody { + event: BroadSoftEvent.Talk; + action: TalkAction; +} + +/** + * Type for BroadSoft NOTIFY body + */ +export type BroadSoftNotifyBody = TalkNotifyBody; + +/** + * Options for auto-answer behavior + */ +export interface AutoAnswerOptions { + /** Whether to enable auto-answer feature */ + enabled: boolean; + /** Callback invoked before auto-answering */ + onBeforeAutoAnswer?: (answerAfter: number) => void; + /** Callback invoked after auto-answering */ + onAfterAutoAnswer?: () => void; + /** Override delay (in seconds) regardless of header value */ + delayOverride?: number; +} + +/** + * Options for BroadSoft remote control handling + */ +export interface RemoteControlOptions { + /** Whether to enable remote control features */ + enabled: boolean; + /** Callback for talk events */ + onTalkEvent?: (action: TalkAction) => void; +} + +/** + * Complete BroadSoft extension options + */ +export interface BroadSoftOptions { + /** Auto-answer configuration */ + autoAnswer?: AutoAnswerOptions; + /** Remote control configuration */ + remoteControl?: RemoteControlOptions; +} diff --git a/src/api/index.ts b/src/api/index.ts index c92fcd3a3..1eb8fad07 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -58,3 +58,4 @@ export * from "./user-agent-delegate.js"; export * from "./user-agent-options.js"; export * from "./user-agent-state.js"; export * from "./user-agent.js"; +export * as BroadSoft from "./broadsoft/index.js"; diff --git a/src/api/invitation.ts b/src/api/invitation.ts index 943406c15..0e5adbf97 100644 --- a/src/api/invitation.ts +++ b/src/api/invitation.ts @@ -1,14 +1,20 @@ import { Grammar } from "../grammar/grammar.js"; import { NameAddrHeader } from "../grammar/name-addr-header.js"; import { Body, fromBodyLegacy, getBody } from "../core/messages/body.js"; +import { IncomingAckRequest } from "../core/messages/methods/ack.js"; +import { IncomingByeRequest } from "../core/messages/methods/bye.js"; +import { IncomingInfoRequest } from "../core/messages/methods/info.js"; import { IncomingInviteRequest } from "../core/messages/methods/invite.js"; +import { IncomingMessageRequest } from "../core/messages/methods/message.js"; +import { IncomingNotifyRequest } from "../core/messages/methods/notify.js"; import { IncomingPrackRequest } from "../core/messages/methods/prack.js"; +import { IncomingReferRequest } from "../core/messages/methods/refer.js"; import { IncomingRequestMessage } from "../core/messages/incoming-request-message.js"; import { InviteUserAgentServer } from "../core/user-agents/invite-user-agent-server.js"; import { Logger } from "../core/log/logger.js"; import { OutgoingResponse } from "../core/messages/outgoing-response.js"; import { OutgoingResponseWithSession } from "../core/messages/methods/invite.js"; -import { SignalingState } from "../core/session/session.js"; +import { Session as SessionDialog, SignalingState } from "../core/session/session.js"; import { Timers } from "../core/timers.js"; import { TransactionStateError } from "../core/exceptions/transaction-state-error.js"; import { getReasonPhrase } from "../core/messages/utils.js"; @@ -261,17 +267,7 @@ export class Invitation extends Session { this.sendAccept(options) // eslint-disable-next-line @typescript-eslint/no-unused-vars .then(({ message, session }) => { - session.delegate = { - onAck: (ackRequest): Promise => this.onAckRequest(ackRequest), - onAckTimeout: (): void => this.onAckTimeout(), - onBye: (byeRequest): void => this.onByeRequest(byeRequest), - onInfo: (infoRequest): void => this.onInfoRequest(infoRequest), - onInvite: (inviteRequest): void => this.onInviteRequest(inviteRequest), - onMessage: (messageRequest): void => this.onMessageRequest(messageRequest), - onNotify: (notifyRequest): void => this.onNotifyRequest(notifyRequest), - onPrack: (prackRequest): void => this.onPrackRequest(prackRequest), - onRefer: (referRequest): void => this.onReferRequest(referRequest) - }; + this.setupDialogDelegate(session); this._dialog = session; this.stateTransition(SessionState.Established); @@ -439,6 +435,25 @@ export class Invitation extends Session { this.stateTransition(SessionState.Terminated); } + /** + * Setup dialog delegate to handle incoming requests. + * @param dialog - The dialog (session) to setup. + * @internal + */ + private setupDialogDelegate(dialog: SessionDialog): void { + dialog.delegate = { + onAck: (ackRequest: IncomingAckRequest): Promise => this.onAckRequest(ackRequest), + onAckTimeout: (): void => this.onAckTimeout(), + onBye: (byeRequest: IncomingByeRequest): void => this.onByeRequest(byeRequest), + onInfo: (infoRequest: IncomingInfoRequest): void => this.onInfoRequest(infoRequest), + onInvite: (inviteRequest: IncomingInviteRequest): void => this.onInviteRequest(inviteRequest), + onMessage: (messageRequest: IncomingMessageRequest): void => this.onMessageRequest(messageRequest), + onNotify: (notifyRequest: IncomingNotifyRequest): void => this.onNotifyRequest(notifyRequest), + onPrack: (prackRequest: IncomingPrackRequest): void => this.onPrackRequest(prackRequest), + onRefer: (referRequest: IncomingReferRequest): void => this.onReferRequest(referRequest) + }; + } + /** * Helper function to handle offer/answer in a PRACK. */ @@ -628,6 +643,8 @@ export class Invitation extends Session { try { const progressResponse = this.incomingInviteRequest.progress({ statusCode, reasonPhrase, extraHeaders, body }); this._dialog = progressResponse.session; + // Setup dialog delegate to handle early-dialog NOTIFY (BroadSoft remote control) + this.setupDialogDelegate(progressResponse.session); return Promise.resolve(progressResponse); } catch (error) { return Promise.reject(error); @@ -652,6 +669,8 @@ export class Invitation extends Session { .then((body) => this.incomingInviteRequest.progress({ statusCode, reasonPhrase, extraHeaders, body })) .then((progressResponse) => { this._dialog = progressResponse.session; + // Setup dialog delegate to handle early-dialog NOTIFY (BroadSoft remote control) + this.setupDialogDelegate(progressResponse.session); return progressResponse; }); } @@ -696,10 +715,15 @@ export class Invitation extends Session { }) .then((progressResponse) => { this._dialog = progressResponse.session; + // Setup dialog delegate to handle early-dialog NOTIFY (BroadSoft remote control) + this.setupDialogDelegate(progressResponse.session); let prackRequest: IncomingPrackRequest; let prackResponse: OutgoingResponse; + // Override onPrack for reliable provisional response handling + const existingDelegate = progressResponse.session.delegate; progressResponse.session.delegate = { + ...existingDelegate, onPrack: (request): void => { prackRequest = request; // eslint-disable-next-line @typescript-eslint/no-use-before-define diff --git a/src/api/session.ts b/src/api/session.ts index 0eb30121a..d4cbe7e89 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -1026,7 +1026,10 @@ export abstract class Session { */ protected onNotifyRequest(request: IncomingNotifyRequest): void { this.logger.log("Session.onNotifyRequest"); - if (this.state !== SessionState.Established) { + + // Allow NOTIFY during Initial and Establishing states for BroadSoft remote control + // Only reject NOTIFY if session is terminating or terminated + if (this.state === SessionState.Terminating || this.state === SessionState.Terminated) { this.logger.error(`NOTIFY received while in state ${this.state}, dropping request`); return; } diff --git a/test/spec/api/broadsoft/auto-answer.spec.js b/test/spec/api/broadsoft/auto-answer.spec.js new file mode 100644 index 000000000..a11e085ac --- /dev/null +++ b/test/spec/api/broadsoft/auto-answer.spec.js @@ -0,0 +1,218 @@ +/** + * BroadSoft Auto-Answer Tests + */ +import { handleAutoAnswer, shouldAutoAnswer } from "../../../../lib/api/broadsoft/auto-answer.js"; +describe("BroadSoft Auto-Answer", () => { + describe("shouldAutoAnswer", () => { + it("should return true when answer-after is present", () => { + const mockInvitation = { + request: { + getHeaders: (name) => { + if (name === "call-info") { + return ["; answer-after=1"]; + } + return []; + } + } + }; + expect(shouldAutoAnswer(mockInvitation)).toBe(true); + }); + it("should return false when answer-after is not present", () => { + const mockInvitation = { + request: { + getHeaders: (name) => { + if (name === "call-info") { + return ['; purpose="icon"']; + } + return []; + } + } + }; + expect(shouldAutoAnswer(mockInvitation)).toBe(false); + }); + it("should return false when no Call-Info headers", () => { + const mockInvitation = { + request: { + getHeaders: () => [] + } + }; + expect(shouldAutoAnswer(mockInvitation)).toBe(false); + }); + }); + describe("handleAutoAnswer", () => { + beforeEach(() => { + jasmine.clock().install(); + }); + afterEach(() => { + jasmine.clock().uninstall(); + }); + it("should return false when auto-answer is disabled", () => { + const mockInvitation = { + request: { + getHeaders: (name) => { + if (name === "call-info") { + return ["; answer-after=1"]; + } + return []; + } + } + }; + const options = { + enabled: false + }; + const result = handleAutoAnswer(mockInvitation, options); + expect(result).toBe(false); + }); + it("should return false when no answer-after parameter", () => { + const mockInvitation = { + request: { + getHeaders: (name) => { + if (name === "call-info") { + return ['; purpose="icon"']; + } + return []; + } + } + }; + const options = { + enabled: true + }; + const result = handleAutoAnswer(mockInvitation, options); + expect(result).toBe(false); + }); + it("should schedule auto-answer with correct delay", (done) => { + const acceptCalled = false; + const mockInvitation = { + request: { + getHeaders: (name) => { + if (name === "call-info") { + return ["; answer-after=2"]; + } + return []; + } + }, + state: "Initial", + accept: jasmine.createSpy("accept").and.returnValue(Promise.resolve()) + }; + const options = { + enabled: true + }; + const result = handleAutoAnswer(mockInvitation, options); + expect(result).toBe(true); + expect(mockInvitation.accept).not.toHaveBeenCalled(); + // Advance time by 2 seconds + jasmine.clock().tick(2000); + // Tick once more to execute the promise microtask + jasmine.clock().tick(1); + // Wait for next tick to verify + setTimeout(() => { + expect(mockInvitation.accept).toHaveBeenCalled(); + done(); + }, 0); + // Tick to execute the setTimeout + jasmine.clock().tick(1); + }); + it("should call onBeforeAutoAnswer callback", () => { + const mockInvitation = { + request: { + getHeaders: (name) => { + if (name === "call-info") { + return ["; answer-after=1"]; + } + return []; + } + }, + state: "Initial", + accept: jasmine.createSpy("accept").and.returnValue(Promise.resolve()) + }; + const beforeCallback = jasmine.createSpy("beforeCallback"); + const options = { + enabled: true, + onBeforeAutoAnswer: beforeCallback + }; + handleAutoAnswer(mockInvitation, options); + expect(beforeCallback).toHaveBeenCalledWith(1); + }); + it("should call onAfterAutoAnswer callback after accepting", (done) => { + const mockInvitation = { + request: { + getHeaders: (name) => { + if (name === "call-info") { + return ["; answer-after=0"]; + } + return []; + } + }, + state: "Initial", + accept: jasmine.createSpy("accept").and.returnValue(Promise.resolve()) + }; + const afterCallback = jasmine.createSpy("afterCallback"); + const options = { + enabled: true, + onAfterAutoAnswer: afterCallback + }; + handleAutoAnswer(mockInvitation, options); + // Answer after=0 means immediate + jasmine.clock().tick(0); + // Use a microtask to wait for the promise chain to complete + Promise.resolve() + .then(() => Promise.resolve()) + .then(() => { + expect(afterCallback).toHaveBeenCalled(); + done(); + }); + // Tick to execute promise microtasks + jasmine.clock().tick(1); + }); + it("should use delayOverride when provided", () => { + const mockInvitation = { + request: { + getHeaders: (name) => { + if (name === "call-info") { + return ["; answer-after=5"]; + } + return []; + } + }, + state: "Initial", + accept: jasmine.createSpy("accept").and.returnValue(Promise.resolve()) + }; + const beforeCallback = jasmine.createSpy("beforeCallback"); + const options = { + enabled: true, + delayOverride: 0, + onBeforeAutoAnswer: beforeCallback + }; + handleAutoAnswer(mockInvitation, options); + // Should use override delay of 0, not header value of 5 + expect(beforeCallback).toHaveBeenCalledWith(0); + }); + it("should not accept if session is already terminated", (done) => { + const mockInvitation = { + request: { + getHeaders: (name) => { + if (name === "call-info") { + return ["; answer-after=0"]; + } + return []; + } + }, + state: "Terminated", + accept: jasmine.createSpy("accept").and.returnValue(Promise.resolve()) + }; + const options = { + enabled: true + }; + handleAutoAnswer(mockInvitation, options); + jasmine.clock().tick(0); + // Tick once more to execute the promise microtask + jasmine.clock().tick(1); + setTimeout(() => { + expect(mockInvitation.accept).not.toHaveBeenCalled(); + done(); + }, 10); + // Tick to execute the setTimeout + jasmine.clock().tick(11); + }); + }); +}); diff --git a/test/spec/api/broadsoft/auto-answer.spec.ts b/test/spec/api/broadsoft/auto-answer.spec.ts new file mode 100644 index 000000000..3b985c6cd --- /dev/null +++ b/test/spec/api/broadsoft/auto-answer.spec.ts @@ -0,0 +1,267 @@ +/** + * BroadSoft Auto-Answer Tests + */ + +import { Invitation } from "../../../../lib/api/invitation.js"; +import { IncomingRequestMessage } from "../../../../lib/core/messages/incoming-request-message.js"; +import { handleAutoAnswer, shouldAutoAnswer } from "../../../../lib/api/broadsoft/auto-answer.js"; +import { AutoAnswerOptions } from "../../../../lib/api/broadsoft/types.js"; + +describe("BroadSoft Auto-Answer", () => { + describe("shouldAutoAnswer", () => { + it("should return true when answer-after is present", () => { + const mockInvitation = { + request: { + getHeaders: (name: string) => { + if (name === "call-info") { + return ["; answer-after=1"]; + } + return []; + } + } as unknown as IncomingRequestMessage + } as Invitation; + + expect(shouldAutoAnswer(mockInvitation)).toBe(true); + }); + + it("should return false when answer-after is not present", () => { + const mockInvitation = { + request: { + getHeaders: (name: string) => { + if (name === "call-info") { + return ['; purpose="icon"']; + } + return []; + } + } as unknown as IncomingRequestMessage + } as Invitation; + + expect(shouldAutoAnswer(mockInvitation)).toBe(false); + }); + + it("should return false when no Call-Info headers", () => { + const mockInvitation = { + request: { + getHeaders: () => [] + } as unknown as IncomingRequestMessage + } as Invitation; + + expect(shouldAutoAnswer(mockInvitation)).toBe(false); + }); + }); + + describe("handleAutoAnswer", () => { + beforeEach(() => { + jasmine.clock().install(); + }); + + afterEach(() => { + jasmine.clock().uninstall(); + }); + + it("should return false when auto-answer is disabled", () => { + const mockInvitation = { + request: { + getHeaders: (name: string) => { + if (name === "call-info") { + return ["; answer-after=1"]; + } + return []; + } + } as unknown as IncomingRequestMessage + } as Invitation; + + const options: AutoAnswerOptions = { + enabled: false + }; + + const result = handleAutoAnswer(mockInvitation, options); + + expect(result).toBe(false); + }); + + it("should return false when no answer-after parameter", () => { + const mockInvitation = { + request: { + getHeaders: (name: string) => { + if (name === "call-info") { + return ['; purpose="icon"']; + } + return []; + } + } as unknown as IncomingRequestMessage + } as Invitation; + + const options: AutoAnswerOptions = { + enabled: true + }; + + const result = handleAutoAnswer(mockInvitation, options); + + expect(result).toBe(false); + }); + + it("should schedule auto-answer with correct delay", (done) => { + const acceptCalled = false; + const mockInvitation = { + request: { + getHeaders: (name: string) => { + if (name === "call-info") { + return ["; answer-after=2"]; + } + return []; + } + } as unknown as IncomingRequestMessage, + state: "Initial", + accept: jasmine.createSpy("accept").and.returnValue(Promise.resolve()) + } as unknown as Invitation; + + const options: AutoAnswerOptions = { + enabled: true + }; + + const result = handleAutoAnswer(mockInvitation, options); + + expect(result).toBe(true); + expect(mockInvitation.accept).not.toHaveBeenCalled(); + + // Advance time by 2 seconds + jasmine.clock().tick(2000); + + // Tick once more to execute the promise microtask + jasmine.clock().tick(1); + + // Wait for next tick to verify + setTimeout(() => { + expect(mockInvitation.accept).toHaveBeenCalled(); + done(); + }, 0); + + // Tick to execute the setTimeout + jasmine.clock().tick(1); + }); + + it("should call onBeforeAutoAnswer callback", () => { + const mockInvitation = { + request: { + getHeaders: (name: string) => { + if (name === "call-info") { + return ["; answer-after=1"]; + } + return []; + } + } as unknown as IncomingRequestMessage, + state: "Initial", + accept: jasmine.createSpy("accept").and.returnValue(Promise.resolve()) + } as unknown as Invitation; + + const beforeCallback = jasmine.createSpy("beforeCallback"); + const options: AutoAnswerOptions = { + enabled: true, + onBeforeAutoAnswer: beforeCallback + }; + + handleAutoAnswer(mockInvitation, options); + + expect(beforeCallback).toHaveBeenCalledWith(1); + }); + + it("should call onAfterAutoAnswer callback after accepting", (done) => { + const mockInvitation = { + request: { + getHeaders: (name: string) => { + if (name === "call-info") { + return ["; answer-after=0"]; + } + return []; + } + } as unknown as IncomingRequestMessage, + state: "Initial", + accept: jasmine.createSpy("accept").and.returnValue(Promise.resolve()) + } as unknown as Invitation; + + const afterCallback = jasmine.createSpy("afterCallback"); + const options: AutoAnswerOptions = { + enabled: true, + onAfterAutoAnswer: afterCallback + }; + + handleAutoAnswer(mockInvitation, options); + + // Answer after=0 means immediate + jasmine.clock().tick(0); + + // Use a microtask to wait for the promise chain to complete + Promise.resolve() + .then(() => Promise.resolve()) + .then(() => { + expect(afterCallback).toHaveBeenCalled(); + done(); + }); + + // Tick to execute promise microtasks + jasmine.clock().tick(1); + }); + + it("should use delayOverride when provided", () => { + const mockInvitation = { + request: { + getHeaders: (name: string) => { + if (name === "call-info") { + return ["; answer-after=5"]; + } + return []; + } + } as unknown as IncomingRequestMessage, + state: "Initial", + accept: jasmine.createSpy("accept").and.returnValue(Promise.resolve()) + } as unknown as Invitation; + + const beforeCallback = jasmine.createSpy("beforeCallback"); + const options: AutoAnswerOptions = { + enabled: true, + delayOverride: 0, + onBeforeAutoAnswer: beforeCallback + }; + + handleAutoAnswer(mockInvitation, options); + + // Should use override delay of 0, not header value of 5 + expect(beforeCallback).toHaveBeenCalledWith(0); + }); + + it("should not accept if session is already terminated", (done) => { + const mockInvitation = { + request: { + getHeaders: (name: string) => { + if (name === "call-info") { + return ["; answer-after=0"]; + } + return []; + } + } as unknown as IncomingRequestMessage, + state: "Terminated", + accept: jasmine.createSpy("accept").and.returnValue(Promise.resolve()) + } as unknown as Invitation; + + const options: AutoAnswerOptions = { + enabled: true + }; + + handleAutoAnswer(mockInvitation, options); + + jasmine.clock().tick(0); + + // Tick once more to execute the promise microtask + jasmine.clock().tick(1); + + setTimeout(() => { + expect(mockInvitation.accept).not.toHaveBeenCalled(); + done(); + }, 10); + + // Tick to execute the setTimeout + jasmine.clock().tick(11); + }); + }); +}); diff --git a/test/spec/api/broadsoft/call-info-parser.spec.js b/test/spec/api/broadsoft/call-info-parser.spec.js new file mode 100644 index 000000000..4c296e208 --- /dev/null +++ b/test/spec/api/broadsoft/call-info-parser.spec.js @@ -0,0 +1,144 @@ +/** + * BroadSoft Call-Info Parser Tests + */ +import { parseCallInfoHeader, extractCallInfoHeaders, getAutoAnswerDelay, hasAutoAnswer } from "../../../../lib/api/broadsoft/call-info-parser.js"; +describe("BroadSoft Call-Info Parser", () => { + describe("parseCallInfoHeader", () => { + it("should parse Call-Info header with answer-after parameter", () => { + const header = "; answer-after=1"; + const result = parseCallInfoHeader(header); + expect(result).toBeDefined(); + expect(result === null || result === void 0 ? void 0 : result.uri).toBe("sip:example.com"); + expect(result === null || result === void 0 ? void 0 : result.params.answerAfter).toBe(1); + }); + it("should parse Call-Info header with answer-after=0", () => { + const header = "; answer-after=0"; + const result = parseCallInfoHeader(header); + expect(result).toBeDefined(); + expect(result === null || result === void 0 ? void 0 : result.params.answerAfter).toBe(0); + }); + it("should parse Call-Info header with multiple parameters", () => { + const header = '; answer-after=2; purpose="info"'; + const result = parseCallInfoHeader(header); + expect(result).toBeDefined(); + expect(result === null || result === void 0 ? void 0 : result.uri).toBe("sip:example.com"); + expect(result === null || result === void 0 ? void 0 : result.params.answerAfter).toBe(2); + expect(result === null || result === void 0 ? void 0 : result.params.purpose).toBe("info"); + }); + it("should handle Call-Info header without answer-after", () => { + const header = '; purpose="icon"'; + const result = parseCallInfoHeader(header); + expect(result).toBeDefined(); + expect(result === null || result === void 0 ? void 0 : result.uri).toBe("sip:example.com"); + expect(result === null || result === void 0 ? void 0 : result.params.answerAfter).toBeUndefined(); + expect(result === null || result === void 0 ? void 0 : result.params.purpose).toBe("icon"); + }); + it("should return undefined for invalid header format", () => { + const header = "invalid-header-format"; + const result = parseCallInfoHeader(header); + expect(result).toBeUndefined(); + }); + it("should return undefined for empty header", () => { + const header = ""; + const result = parseCallInfoHeader(header); + expect(result).toBeUndefined(); + }); + it("should handle spaces in parameter values", () => { + const header = "; answer-after = 3 "; + const result = parseCallInfoHeader(header); + expect(result).toBeDefined(); + expect(result === null || result === void 0 ? void 0 : result.params.answerAfter).toBe(3); + }); + }); + describe("extractCallInfoHeaders", () => { + it("should extract Call-Info headers from request", () => { + const mockRequest = { + getHeaders: (name) => { + if (name === "call-info") { + return ["; answer-after=1", '; purpose="icon"']; + } + return []; + } + }; + const result = extractCallInfoHeaders(mockRequest); + expect(result.length).toBe(2); + expect(result[0].params.answerAfter).toBe(1); + expect(result[1].params.purpose).toBe("icon"); + }); + it("should return empty array when no Call-Info headers present", () => { + const mockRequest = { + getHeaders: () => [] + }; + const result = extractCallInfoHeaders(mockRequest); + expect(result.length).toBe(0); + }); + }); + describe("getAutoAnswerDelay", () => { + it("should return delay from Call-Info header", () => { + const mockRequest = { + getHeaders: (name) => { + if (name === "call-info") { + return ["; answer-after=5"]; + } + return []; + } + }; + const delay = getAutoAnswerDelay(mockRequest); + expect(delay).toBe(5); + }); + it("should return undefined when no answer-after parameter", () => { + const mockRequest = { + getHeaders: (name) => { + if (name === "call-info") { + return ['; purpose="icon"']; + } + return []; + } + }; + const delay = getAutoAnswerDelay(mockRequest); + expect(delay).toBeUndefined(); + }); + it("should return first answer-after when multiple headers present", () => { + const mockRequest = { + getHeaders: (name) => { + if (name === "call-info") { + return ["; answer-after=2", "; answer-after=5"]; + } + return []; + } + }; + const delay = getAutoAnswerDelay(mockRequest); + expect(delay).toBe(2); + }); + }); + describe("hasAutoAnswer", () => { + it("should return true when answer-after is present", () => { + const mockRequest = { + getHeaders: (name) => { + if (name === "call-info") { + return ["; answer-after=0"]; + } + return []; + } + }; + expect(hasAutoAnswer(mockRequest)).toBe(true); + }); + it("should return false when answer-after is not present", () => { + const mockRequest = { + getHeaders: (name) => { + if (name === "call-info") { + return ['; purpose="icon"']; + } + return []; + } + }; + expect(hasAutoAnswer(mockRequest)).toBe(false); + }); + it("should return false when no Call-Info headers", () => { + const mockRequest = { + getHeaders: () => [] + }; + expect(hasAutoAnswer(mockRequest)).toBe(false); + }); + }); +}); diff --git a/test/spec/api/broadsoft/call-info-parser.spec.ts b/test/spec/api/broadsoft/call-info-parser.spec.ts new file mode 100644 index 000000000..830d555be --- /dev/null +++ b/test/spec/api/broadsoft/call-info-parser.spec.ts @@ -0,0 +1,186 @@ +/** + * BroadSoft Call-Info Parser Tests + */ + +import { IncomingRequestMessage } from "../../../../lib/core/messages/incoming-request-message.js"; +import { + parseCallInfoHeader, + extractCallInfoHeaders, + getAutoAnswerDelay, + hasAutoAnswer +} from "../../../../lib/api/broadsoft/call-info-parser.js"; + +describe("BroadSoft Call-Info Parser", () => { + describe("parseCallInfoHeader", () => { + it("should parse Call-Info header with answer-after parameter", () => { + const header = "; answer-after=1"; + const result = parseCallInfoHeader(header); + + expect(result).toBeDefined(); + expect(result?.uri).toBe("sip:example.com"); + expect(result?.params.answerAfter).toBe(1); + }); + + it("should parse Call-Info header with answer-after=0", () => { + const header = "; answer-after=0"; + const result = parseCallInfoHeader(header); + + expect(result).toBeDefined(); + expect(result?.params.answerAfter).toBe(0); + }); + + it("should parse Call-Info header with multiple parameters", () => { + const header = '; answer-after=2; purpose="info"'; + const result = parseCallInfoHeader(header); + + expect(result).toBeDefined(); + expect(result?.uri).toBe("sip:example.com"); + expect(result?.params.answerAfter).toBe(2); + expect(result?.params.purpose).toBe("info"); + }); + + it("should handle Call-Info header without answer-after", () => { + const header = '; purpose="icon"'; + const result = parseCallInfoHeader(header); + + expect(result).toBeDefined(); + expect(result?.uri).toBe("sip:example.com"); + expect(result?.params.answerAfter).toBeUndefined(); + expect(result?.params.purpose).toBe("icon"); + }); + + it("should return undefined for invalid header format", () => { + const header = "invalid-header-format"; + const result = parseCallInfoHeader(header); + + expect(result).toBeUndefined(); + }); + + it("should return undefined for empty header", () => { + const header = ""; + const result = parseCallInfoHeader(header); + + expect(result).toBeUndefined(); + }); + + it("should handle spaces in parameter values", () => { + const header = "; answer-after = 3 "; + const result = parseCallInfoHeader(header); + + expect(result).toBeDefined(); + expect(result?.params.answerAfter).toBe(3); + }); + }); + + describe("extractCallInfoHeaders", () => { + it("should extract Call-Info headers from request", () => { + const mockRequest = { + getHeaders: (name: string) => { + if (name === "call-info") { + return ["; answer-after=1", '; purpose="icon"']; + } + return []; + } + } as unknown as IncomingRequestMessage; + + const result = extractCallInfoHeaders(mockRequest); + + expect(result.length).toBe(2); + expect(result[0].params.answerAfter).toBe(1); + expect(result[1].params.purpose).toBe("icon"); + }); + + it("should return empty array when no Call-Info headers present", () => { + const mockRequest = { + getHeaders: () => [] + } as unknown as IncomingRequestMessage; + + const result = extractCallInfoHeaders(mockRequest); + + expect(result.length).toBe(0); + }); + }); + + describe("getAutoAnswerDelay", () => { + it("should return delay from Call-Info header", () => { + const mockRequest = { + getHeaders: (name: string) => { + if (name === "call-info") { + return ["; answer-after=5"]; + } + return []; + } + } as unknown as IncomingRequestMessage; + + const delay = getAutoAnswerDelay(mockRequest); + + expect(delay).toBe(5); + }); + + it("should return undefined when no answer-after parameter", () => { + const mockRequest = { + getHeaders: (name: string) => { + if (name === "call-info") { + return ['; purpose="icon"']; + } + return []; + } + } as unknown as IncomingRequestMessage; + + const delay = getAutoAnswerDelay(mockRequest); + + expect(delay).toBeUndefined(); + }); + + it("should return first answer-after when multiple headers present", () => { + const mockRequest = { + getHeaders: (name: string) => { + if (name === "call-info") { + return ["; answer-after=2", "; answer-after=5"]; + } + return []; + } + } as unknown as IncomingRequestMessage; + + const delay = getAutoAnswerDelay(mockRequest); + + expect(delay).toBe(2); + }); + }); + + describe("hasAutoAnswer", () => { + it("should return true when answer-after is present", () => { + const mockRequest = { + getHeaders: (name: string) => { + if (name === "call-info") { + return ["; answer-after=0"]; + } + return []; + } + } as unknown as IncomingRequestMessage; + + expect(hasAutoAnswer(mockRequest)).toBe(true); + }); + + it("should return false when answer-after is not present", () => { + const mockRequest = { + getHeaders: (name: string) => { + if (name === "call-info") { + return ['; purpose="icon"']; + } + return []; + } + } as unknown as IncomingRequestMessage; + + expect(hasAutoAnswer(mockRequest)).toBe(false); + }); + + it("should return false when no Call-Info headers", () => { + const mockRequest = { + getHeaders: () => [] + } as unknown as IncomingRequestMessage; + + expect(hasAutoAnswer(mockRequest)).toBe(false); + }); + }); +}); diff --git a/test/spec/api/broadsoft/remote-control.spec.js b/test/spec/api/broadsoft/remote-control.spec.js new file mode 100644 index 000000000..e20a1b9eb --- /dev/null +++ b/test/spec/api/broadsoft/remote-control.spec.js @@ -0,0 +1,196 @@ +/** + * BroadSoft Remote Control Tests + */ +import { parseEventHeader, parseNotifyBody, isBroadSoftNotify } from "../../../../lib/api/broadsoft/remote-control.js"; +import { BroadSoftEvent, TalkAction } from "../../../../lib/api/broadsoft/types.js"; +describe("BroadSoft Remote Control", () => { + describe("parseEventHeader", () => { + it("should parse Event: talk header", () => { + const mockRequest = { + message: { + getHeader: (name) => { + if (name === "event") { + return "talk"; + } + return undefined; + } + } + }; + const result = parseEventHeader(mockRequest); + expect(result).toBe(BroadSoftEvent.Talk); + }); + it("should handle Event header with parameters", () => { + const mockRequest = { + message: { + getHeader: (name) => { + if (name === "event") { + return "talk; id=123"; + } + return undefined; + } + } + }; + const result = parseEventHeader(mockRequest); + expect(result).toBe(BroadSoftEvent.Talk); + }); + it("should return undefined for non-BroadSoft events", () => { + const mockRequest = { + message: { + getHeader: (name) => { + if (name === "event") { + return "presence"; + } + return undefined; + } + } + }; + const result = parseEventHeader(mockRequest); + expect(result).toBeUndefined(); + }); + it("should return undefined when Event header is missing", () => { + const mockRequest = { + message: { + getHeader: (name) => undefined + } + }; + const result = parseEventHeader(mockRequest); + expect(result).toBeUndefined(); + }); + it("should handle case-insensitive event values", () => { + const mockRequest = { + message: { + getHeader: (name) => { + if (name === "event") { + return "TALK"; + } + return undefined; + } + } + }; + const result = parseEventHeader(mockRequest); + expect(result).toBe(BroadSoftEvent.Talk); + }); + }); + describe("parseNotifyBody", () => { + it("should parse talk action from body", () => { + const mockRequest = { + message: { + body: "talk" + } + }; + const result = parseNotifyBody(mockRequest, BroadSoftEvent.Talk); + expect(result).toBeDefined(); + expect(result === null || result === void 0 ? void 0 : result.event).toBe(BroadSoftEvent.Talk); + expect(result === null || result === void 0 ? void 0 : result.action).toBe(TalkAction.Talk); + }); + it("should parse mute action from body", () => { + const mockRequest = { + message: { + body: "mute" + } + }; + const result = parseNotifyBody(mockRequest, BroadSoftEvent.Talk); + expect(result).toBeDefined(); + expect(result === null || result === void 0 ? void 0 : result.event).toBe(BroadSoftEvent.Talk); + expect(result === null || result === void 0 ? void 0 : result.action).toBe(TalkAction.Mute); + }); + it("should handle case-insensitive body", () => { + const mockRequest = { + message: { + body: "MUTE" + } + }; + const result = parseNotifyBody(mockRequest, BroadSoftEvent.Talk); + expect(result).toBeDefined(); + expect(result === null || result === void 0 ? void 0 : result.action).toBe(TalkAction.Mute); + }); + it("should handle body with whitespace", () => { + const mockRequest = { + message: { + body: " talk " + } + }; + const result = parseNotifyBody(mockRequest, BroadSoftEvent.Talk); + expect(result).toBeDefined(); + expect(result === null || result === void 0 ? void 0 : result.action).toBe(TalkAction.Talk); + }); + it("should return undefined for invalid action", () => { + const mockRequest = { + message: { + body: "invalid-action" + } + }; + const result = parseNotifyBody(mockRequest, BroadSoftEvent.Talk); + expect(result).toBeUndefined(); + }); + it("should parse empty body for talk event as Talk action", () => { + const mockRequest = { + message: { + body: undefined + } + }; + const result = parseNotifyBody(mockRequest, BroadSoftEvent.Talk); + expect(result).toBeDefined(); + expect(result === null || result === void 0 ? void 0 : result.event).toBe(BroadSoftEvent.Talk); + expect(result === null || result === void 0 ? void 0 : result.action).toBe(TalkAction.Talk); + }); + it("should parse empty string body for talk event as Talk action", () => { + const mockRequest = { + message: { + body: "" + } + }; + const result = parseNotifyBody(mockRequest, BroadSoftEvent.Talk); + expect(result).toBeDefined(); + expect(result === null || result === void 0 ? void 0 : result.event).toBe(BroadSoftEvent.Talk); + expect(result === null || result === void 0 ? void 0 : result.action).toBe(TalkAction.Talk); + }); + it("should parse whitespace-only body for talk event as Talk action", () => { + const mockRequest = { + message: { + body: " " + } + }; + const result = parseNotifyBody(mockRequest, BroadSoftEvent.Talk); + expect(result).toBeDefined(); + expect(result === null || result === void 0 ? void 0 : result.event).toBe(BroadSoftEvent.Talk); + expect(result === null || result === void 0 ? void 0 : result.action).toBe(TalkAction.Talk); + }); + }); + describe("isBroadSoftNotify", () => { + it("should return true for talk event", () => { + const mockRequest = { + message: { + getHeader: (name) => { + if (name === "event") { + return "talk"; + } + return undefined; + } + } + }; + expect(isBroadSoftNotify(mockRequest)).toBe(true); + }); + it("should return false for non-BroadSoft event", () => { + const mockRequest = { + message: { + getHeader: (name) => { + if (name === "event") { + return "presence"; + } + return undefined; + } + } + }; + expect(isBroadSoftNotify(mockRequest)).toBe(false); + }); + it("should return false when no Event header", () => { + const mockRequest = { + message: { + getHeader: (name) => undefined + } + }; + expect(isBroadSoftNotify(mockRequest)).toBe(false); + }); + }); +}); diff --git a/test/spec/api/broadsoft/remote-control.spec.ts b/test/spec/api/broadsoft/remote-control.spec.ts new file mode 100644 index 000000000..966f79276 --- /dev/null +++ b/test/spec/api/broadsoft/remote-control.spec.ts @@ -0,0 +1,243 @@ +/** + * BroadSoft Remote Control Tests + */ + +import { IncomingNotifyRequest } from "../../../../lib/core/messages/methods/notify.js"; +import { parseEventHeader, parseNotifyBody, isBroadSoftNotify } from "../../../../lib/api/broadsoft/remote-control.js"; +import { BroadSoftEvent, TalkAction } from "../../../../lib/api/broadsoft/types.js"; + +describe("BroadSoft Remote Control", () => { + describe("parseEventHeader", () => { + it("should parse Event: talk header", () => { + const mockRequest = { + message: { + getHeader: (name: string) => { + if (name === "event") { + return "talk"; + } + return undefined; + } + } + } as unknown as IncomingNotifyRequest; + + const result = parseEventHeader(mockRequest); + + expect(result).toBe(BroadSoftEvent.Talk); + }); + + it("should handle Event header with parameters", () => { + const mockRequest = { + message: { + getHeader: (name: string) => { + if (name === "event") { + return "talk; id=123"; + } + return undefined; + } + } + } as unknown as IncomingNotifyRequest; + + const result = parseEventHeader(mockRequest); + + expect(result).toBe(BroadSoftEvent.Talk); + }); + + it("should return undefined for non-BroadSoft events", () => { + const mockRequest = { + message: { + getHeader: (name: string) => { + if (name === "event") { + return "presence"; + } + return undefined; + } + } + } as unknown as IncomingNotifyRequest; + + const result = parseEventHeader(mockRequest); + + expect(result).toBeUndefined(); + }); + + it("should return undefined when Event header is missing", () => { + const mockRequest = { + message: { + getHeader: (name: string) => undefined + } + } as unknown as IncomingNotifyRequest; + + const result = parseEventHeader(mockRequest); + + expect(result).toBeUndefined(); + }); + + it("should handle case-insensitive event values", () => { + const mockRequest = { + message: { + getHeader: (name: string) => { + if (name === "event") { + return "TALK"; + } + return undefined; + } + } + } as unknown as IncomingNotifyRequest; + + const result = parseEventHeader(mockRequest); + + expect(result).toBe(BroadSoftEvent.Talk); + }); + }); + + describe("parseNotifyBody", () => { + it("should parse talk action from body", () => { + const mockRequest = { + message: { + body: "talk" + } + } as unknown as IncomingNotifyRequest; + + const result = parseNotifyBody(mockRequest, BroadSoftEvent.Talk); + + expect(result).toBeDefined(); + expect(result?.event).toBe(BroadSoftEvent.Talk); + expect(result?.action).toBe(TalkAction.Talk); + }); + + it("should parse mute action from body", () => { + const mockRequest = { + message: { + body: "mute" + } + } as unknown as IncomingNotifyRequest; + + const result = parseNotifyBody(mockRequest, BroadSoftEvent.Talk); + + expect(result).toBeDefined(); + expect(result?.event).toBe(BroadSoftEvent.Talk); + expect(result?.action).toBe(TalkAction.Mute); + }); + + it("should handle case-insensitive body", () => { + const mockRequest = { + message: { + body: "MUTE" + } + } as unknown as IncomingNotifyRequest; + + const result = parseNotifyBody(mockRequest, BroadSoftEvent.Talk); + + expect(result).toBeDefined(); + expect(result?.action).toBe(TalkAction.Mute); + }); + + it("should handle body with whitespace", () => { + const mockRequest = { + message: { + body: " talk " + } + } as unknown as IncomingNotifyRequest; + + const result = parseNotifyBody(mockRequest, BroadSoftEvent.Talk); + + expect(result).toBeDefined(); + expect(result?.action).toBe(TalkAction.Talk); + }); + + it("should return undefined for invalid action", () => { + const mockRequest = { + message: { + body: "invalid-action" + } + } as unknown as IncomingNotifyRequest; + + const result = parseNotifyBody(mockRequest, BroadSoftEvent.Talk); + + expect(result).toBeUndefined(); + }); + + it("should parse empty body for talk event as Talk action", () => { + const mockRequest = { + message: { + body: undefined + } + } as unknown as IncomingNotifyRequest; + + const result = parseNotifyBody(mockRequest, BroadSoftEvent.Talk); + + expect(result).toBeDefined(); + expect(result?.event).toBe(BroadSoftEvent.Talk); + expect(result?.action).toBe(TalkAction.Talk); + }); + + it("should parse empty string body for talk event as Talk action", () => { + const mockRequest = { + message: { + body: "" + } + } as unknown as IncomingNotifyRequest; + + const result = parseNotifyBody(mockRequest, BroadSoftEvent.Talk); + + expect(result).toBeDefined(); + expect(result?.event).toBe(BroadSoftEvent.Talk); + expect(result?.action).toBe(TalkAction.Talk); + }); + + it("should parse whitespace-only body for talk event as Talk action", () => { + const mockRequest = { + message: { + body: " " + } + } as unknown as IncomingNotifyRequest; + + const result = parseNotifyBody(mockRequest, BroadSoftEvent.Talk); + + expect(result).toBeDefined(); + expect(result?.event).toBe(BroadSoftEvent.Talk); + expect(result?.action).toBe(TalkAction.Talk); + }); + }); + + describe("isBroadSoftNotify", () => { + it("should return true for talk event", () => { + const mockRequest = { + message: { + getHeader: (name: string) => { + if (name === "event") { + return "talk"; + } + return undefined; + } + } + } as unknown as IncomingNotifyRequest; + + expect(isBroadSoftNotify(mockRequest)).toBe(true); + }); + + it("should return false for non-BroadSoft event", () => { + const mockRequest = { + message: { + getHeader: (name: string) => { + if (name === "event") { + return "presence"; + } + return undefined; + } + } + } as unknown as IncomingNotifyRequest; + + expect(isBroadSoftNotify(mockRequest)).toBe(false); + }); + + it("should return false when no Event header", () => { + const mockRequest = { + message: { + getHeader: (name: string) => undefined + } + } as unknown as IncomingNotifyRequest; + + expect(isBroadSoftNotify(mockRequest)).toBe(false); + }); + }); +});