diff --git a/.changeset/spicy-vans-cough.md b/.changeset/spicy-vans-cough.md new file mode 100644 index 0000000000000..e674bde84a2b4 --- /dev/null +++ b/.changeset/spicy-vans-cough.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/ui-voip': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Improves handling of errors during voice calls diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index d20ebda4f568f..ed345c30be898 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -6002,7 +6002,9 @@ "unable-to-get-file": "Unable to get file", "Unable_to_load_active_connections": "Unable to load active connections", "Unable_to_complete_call": "Unable to complete call", + "Unable_to_complete_call__code": "Unable to complete call. Error code [{{statusCode}}]", "Unable_to_make_calls_while_another_is_ongoing": "Unable to make calls while another call is ongoing", + "Unable_to_negotiate_call_params": "Unable to negotiate call params.", "Unassigned": "Unassigned", "Unassign_extension": "Unassign extension", "unauthorized": "Not authorized", @@ -6795,6 +6797,8 @@ "Sidebar_Sections_Order": "Sidebar sections order", "Sidebar_Sections_Order_Description": "Select the categories in your preferred order", "Incoming_Calls": "Incoming calls", + "Incoming_voice_call_canceled_suddenly": "An Incoming Voice Call was canceled suddenly.", + "Incoming_voice_call_canceled_user_not_registered": "An Incoming Voice Call was canceled due to an unexpected error.", "Advanced_settings": "Advanced settings", "Security_and_permissions": "Security and permissions", "Security_and_privacy": "Security and privacy", diff --git a/packages/ui-voip/src/components/VoipPopup/views/VoipErrorView.spec.tsx b/packages/ui-voip/src/components/VoipPopup/views/VoipErrorView.spec.tsx index c9e25a4f9aa88..25bcabc52a30d 100644 --- a/packages/ui-voip/src/components/VoipPopup/views/VoipErrorView.spec.tsx +++ b/packages/ui-voip/src/components/VoipPopup/views/VoipErrorView.spec.tsx @@ -39,7 +39,7 @@ it('should properly render unknown error calls', async () => { const session = createMockVoipErrorSession({ error: { status: -1, reason: '' } }); render(, { wrapper: appRoot.build() }); - expect(screen.getByText('Unable_to_complete_call')).toBeInTheDocument(); + expect(screen.getByText('Unable_to_complete_call__code')).toBeInTheDocument(); await userEvent.click(screen.getByRole('button', { name: 'End_call' })); expect(session.end).toHaveBeenCalled(); }); diff --git a/packages/ui-voip/src/components/VoipPopup/views/VoipErrorView.tsx b/packages/ui-voip/src/components/VoipPopup/views/VoipErrorView.tsx index 9c6dbd32b346e..92febc6dc2b52 100644 --- a/packages/ui-voip/src/components/VoipPopup/views/VoipErrorView.tsx +++ b/packages/ui-voip/src/components/VoipPopup/views/VoipErrorView.tsx @@ -24,6 +24,8 @@ const VoipErrorView = ({ session, position }: VoipErrorViewProps) => { const title = useMemo(() => { switch (status) { + case 488: + return t('Unable_to_negotiate_call_params'); case 487: return t('Call_terminated'); case 486: @@ -31,7 +33,7 @@ const VoipErrorView = ({ session, position }: VoipErrorViewProps) => { case 480: return t('Temporarily_unavailable'); default: - return t('Unable_to_complete_call'); + return t('Unable_to_complete_call__code', { statusCode: status }); } }, [status, t]); diff --git a/packages/ui-voip/src/lib/VoipClient.ts b/packages/ui-voip/src/lib/VoipClient.ts index 7ab1fe3906eea..a996e0baed798 100644 --- a/packages/ui-voip/src/lib/VoipClient.ts +++ b/packages/ui-voip/src/lib/VoipClient.ts @@ -1,6 +1,6 @@ import type { SignalingSocketEvents, VoipEvents as CoreVoipEvents, VoIPUserConfiguration } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; -import type { InvitationAcceptOptions, Message, Referral, Session, SessionInviteOptions } from 'sip.js'; +import type { InvitationAcceptOptions, Message, Referral, Session, SessionInviteOptions, Cancel as SipCancel } from 'sip.js'; import { Registerer, RequestPendingError, SessionState, UserAgent, Invitation, Inviter, RegistererState, UserAgentState } from 'sip.js'; import type { IncomingResponse, OutgoingByeRequest, URI } from 'sip.js/lib/core'; import type { SessionDescriptionHandlerOptions } from 'sip.js/lib/platform/web'; @@ -9,12 +9,14 @@ import { SessionDescriptionHandler } from 'sip.js/lib/platform/web'; import type { ContactInfo, VoipSession } from '../definitions'; import LocalStream from './LocalStream'; import RemoteStream from './RemoteStream'; +import { getMainInviteRejectionReason } from './getMainInviteRejectionReason'; export type VoipEvents = Omit & { callestablished: ContactInfo; incomingcall: ContactInfo; outgoingcall: ContactInfo; dialer: { open: boolean }; + incomingcallerror: string; }; type SessionError = { @@ -770,6 +772,7 @@ class VoipClient extends Emitter { } private setError(error: SessionError | null) { + console.error(error); this.error = error; this.emit('stateChanged'); } @@ -843,12 +846,23 @@ class VoipClient extends Emitter { this.emit('unregistrationerror', error); }; + private onInvitationCancel(invitation: Invitation, message: SipCancel): void { + const reason = getMainInviteRejectionReason(invitation, message); + if (reason) { + this.emit('incomingcallerror', reason); + } + } + private onIncomingCall = async (invitation: Invitation): Promise => { if (!this.isRegistered() || this.session) { await invitation.reject(); return; } + invitation.delegate = { + onCancel: (cancel: SipCancel) => this.onInvitationCancel(invitation, cancel), + }; + this.initSession(invitation); this.emit('incomingcall', this.getContactInfo() as ContactInfo); diff --git a/packages/ui-voip/src/lib/getMainInviteRejectionReason.spec.ts b/packages/ui-voip/src/lib/getMainInviteRejectionReason.spec.ts new file mode 100644 index 0000000000000..42187442f3ef3 --- /dev/null +++ b/packages/ui-voip/src/lib/getMainInviteRejectionReason.spec.ts @@ -0,0 +1,59 @@ +import { type Cancel as SipCancel, type Invitation, type SessionState } from 'sip.js'; + +import { getMainInviteRejectionReason } from './getMainInviteRejectionReason'; + +const mockInvitation = (state: SessionState[keyof SessionState]): Invitation => + ({ + state, + }) as any; + +const mockSipCancel = (reasons: string[]): SipCancel => + ({ + request: { + headers: { + Reason: reasons.map((raw) => ({ raw })), + }, + }, + }) as any; + +describe('getMainInviteRejectionReason', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return undefined for natural endings', () => { + const result = getMainInviteRejectionReason(mockInvitation('Terminated'), mockSipCancel(['SIP ;cause=487 ;text="ORIGINATOR_CANCEL"'])); + expect(result).toBeUndefined(); + }); + + it('should return priorityErrorEndings if present', () => { + const result = getMainInviteRejectionReason( + mockInvitation('Terminated'), + mockSipCancel(['SIP ;cause=488 ;text="USER_NOT_REGISTERED"']), + ); + expect(result).toBe('USER_NOT_REGISTERED'); + }); + + it('should return the first parsed reason if call was canceled at the initial state', () => { + const result = getMainInviteRejectionReason( + mockInvitation('Initial'), + mockSipCancel(['text="UNEXPECTED_REASON"', 'text="ANOTHER_REASON"']), + ); + expect(result).toBe('UNEXPECTED_REASON'); + }); + + it('should log a warning if call was canceled for unexpected reason', () => { + console.warn = jest.fn(); + const result = getMainInviteRejectionReason( + mockInvitation('Terminated'), + mockSipCancel(['text="UNEXPECTED_REASON"', 'text="ANOTHER_REASON"']), + ); + expect(console.warn).toHaveBeenCalledWith('The call was canceled for an unexpected reason', ['UNEXPECTED_REASON', 'ANOTHER_REASON']); + expect(result).toBeUndefined(); + }); + + it('should handle empty parsed reasons array gracefully', () => { + const result = getMainInviteRejectionReason(mockInvitation('Terminated'), mockSipCancel([])); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/ui-voip/src/lib/getMainInviteRejectionReason.ts b/packages/ui-voip/src/lib/getMainInviteRejectionReason.ts new file mode 100644 index 0000000000000..23a7e6b2a1468 --- /dev/null +++ b/packages/ui-voip/src/lib/getMainInviteRejectionReason.ts @@ -0,0 +1,39 @@ +import type { Cancel as SipCancel, Invitation } from 'sip.js'; + +import { parseInviteRejectionReasons } from './parseInviteRejectionReasons'; + +const naturalEndings = [ + 'ORIGINATOR_CANCEL', + 'NO_ANSWER', + 'NORMAL_CLEARING', + 'USER_BUSY', + 'NO_USER_RESPONSE', + 'NORMAL_UNSPECIFIED', +] as const; + +const priorityErrorEndings = ['USER_NOT_REGISTERED'] as const; + +export function getMainInviteRejectionReason(invitation: Invitation, message: SipCancel): string | undefined { + const parsedReasons = parseInviteRejectionReasons(message); + + for (const ending of naturalEndings) { + if (parsedReasons.includes(ending)) { + // Do not emit any errors for normal endings + return; + } + } + + for (const ending of priorityErrorEndings) { + if (parsedReasons.includes(ending)) { + // An error definitely happened + return ending; + } + } + + if (invitation?.state === 'Initial') { + // Call was canceled at the initial state and it was not due to one of the natural reasons, treat it as unexpected + return parsedReasons.shift(); + } + + console.warn('The call was canceled for an unexpected reason', parsedReasons); +} diff --git a/packages/ui-voip/src/lib/parseInviteRejectionReasons.spec.ts b/packages/ui-voip/src/lib/parseInviteRejectionReasons.spec.ts new file mode 100644 index 0000000000000..5184dd3c614db --- /dev/null +++ b/packages/ui-voip/src/lib/parseInviteRejectionReasons.spec.ts @@ -0,0 +1,149 @@ +import type { Cancel as SipCancel } from 'sip.js'; + +import { parseInviteRejectionReasons } from './parseInviteRejectionReasons'; + +describe('parseInviteRejectionReasons', () => { + it('should return an empty array when message is undefined', () => { + expect(parseInviteRejectionReasons(undefined as any)).toEqual([]); + }); + + it('should return an empty array when headers are not defined', () => { + const message: SipCancel = { request: {} } as any; + expect(parseInviteRejectionReasons(message)).toEqual([]); + }); + + it('should return an empty array when Reason header is not defined', () => { + const message: SipCancel = { request: { headers: {} } } as any; + expect(parseInviteRejectionReasons(message)).toEqual([]); + }); + + it('should parse a single text reason correctly', () => { + const message: SipCancel = { + request: { + headers: { + Reason: [{ raw: 'text="Busy Here"' }], + }, + }, + } as any; + + expect(parseInviteRejectionReasons(message)).toEqual(['Busy Here']); + }); + + it('should extract cause from Reason header if text is not present', () => { + const message: SipCancel = { + request: { + headers: { + Reason: [{ raw: 'SIP ;cause=404' }], + }, + }, + } as any; + + expect(parseInviteRejectionReasons(message)).toEqual(['404']); + }); + + it('should extract text from Reason header when both text and cause are present ', () => { + const message: SipCancel = { + request: { + headers: { Reason: [{ raw: 'SIP ;cause=200 ;text="OK"' }] }, + }, + } as any; + expect(parseInviteRejectionReasons(message)).toEqual(['OK']); + }); + + it('should return the raw reason if no matching text or cause is found', () => { + const message: SipCancel = { + request: { + headers: { + Reason: [{ raw: 'code=486' }], + }, + }, + } as any; + + expect(parseInviteRejectionReasons(message)).toEqual(['code=486']); + }); + + it('should parse multiple reasons and return only the text parts', () => { + const message: SipCancel = { + request: { + headers: { + Reason: [{ raw: 'text="Busy Here"' }, { raw: 'text="Server Internal Error"' }], + }, + }, + } as any; + + expect(parseInviteRejectionReasons(message)).toEqual(['Busy Here', 'Server Internal Error']); + }); + + it('should return an array of parsed reasons when valid reasons are present', () => { + const mockMessage: SipCancel = { + request: { + headers: { + Reason: [{ raw: 'SIP ;cause=200 ;text="Call completed elsewhere"' }, { raw: 'SIP ;cause=486 ;text="Busy Here"' }], + }, + }, + } as any; + + const result = parseInviteRejectionReasons(mockMessage); + expect(result).toEqual(['Call completed elsewhere', 'Busy Here']); + }); + + it('should parse multiple reasons and return the mixed text, cause and raw items, on this order', () => { + const message: SipCancel = { + request: { + headers: { + Reason: [{ raw: 'text="Busy Here"' }, { raw: 'code=503' }, { raw: 'cause=488' }, { raw: 'text="Forbidden"' }], + }, + }, + } as any; + + expect(parseInviteRejectionReasons(message)).toEqual(['Busy Here', 'Forbidden', '488', 'code=503']); + }); + + it('should filter out any undefined or null values from the resulting array', () => { + const message: SipCancel = { + request: { + headers: { + Reason: [ + { raw: 'SIP ;cause=500 ;text="Server Error"' }, + { raw: null as unknown as string }, // Simulate an edge case { raw: '' } + ], + }, + }, + } as any; + expect(parseInviteRejectionReasons(message)).toEqual(['Server Error']); + }); + + it('should handle non-string raw values gracefully and return only valid matches', () => { + const message: SipCancel = { + request: { + headers: { + Reason: [ + { raw: 'text="Service Unavailable"' }, + { raw: { notAString: true } as unknown as string }, // Intentional type misuse for testing + { raw: 'code=486' }, + ], + }, + }, + } as any; + + expect(parseInviteRejectionReasons(message)).toEqual(['Service Unavailable', 'code=486']); + }); + + it('should return an empty array when exceptions are thrown', () => { + // Mock the function to throw an error + const faultyMessage: SipCancel = { + request: { + headers: { + Reason: [ + { + raw: () => { + throw new Error('unexpected error'); + }, + }, + ] as any, + }, + }, + } as any; + expect(parseInviteRejectionReasons(faultyMessage)).toEqual([]); + }); +}); diff --git a/packages/ui-voip/src/lib/parseInviteRejectionReasons.ts b/packages/ui-voip/src/lib/parseInviteRejectionReasons.ts new file mode 100644 index 0000000000000..28aadbf626238 --- /dev/null +++ b/packages/ui-voip/src/lib/parseInviteRejectionReasons.ts @@ -0,0 +1,35 @@ +import type { Cancel as SipCancel } from 'sip.js'; + +export function parseInviteRejectionReasons(message: SipCancel): string[] { + try { + const reasons = message?.request?.headers?.Reason; + const parsedTextReasons: string[] = []; + const parsedCauseReasons: string[] = []; + const rawReasons: string[] = []; + + if (reasons) { + for (const { raw } of reasons) { + if (!raw || typeof raw !== 'string') { + continue; + } + + const textMatch = raw.match(/text="(.+)"/); + if (textMatch?.length && textMatch.length > 1) { + parsedTextReasons.push(textMatch[1]); + continue; + } + const causeMatch = raw.match(/cause=_?(\d+)/); + if (causeMatch?.length && causeMatch.length > 1) { + parsedCauseReasons.push(causeMatch[1]); + continue; + } + + rawReasons.push(raw); + } + } + + return [...parsedTextReasons, ...parsedCauseReasons, ...rawReasons]; + } catch { + return []; + } +} diff --git a/packages/ui-voip/src/providers/VoipProvider.tsx b/packages/ui-voip/src/providers/VoipProvider.tsx index f752162999fe6..2899fd41c47a0 100644 --- a/packages/ui-voip/src/providers/VoipProvider.tsx +++ b/packages/ui-voip/src/providers/VoipProvider.tsx @@ -88,6 +88,16 @@ const VoipProvider = ({ children }: { children: ReactNode }) => { dispatchToastMessage({ type: 'error', message: t('Voice_calling_registration_failed') }); }; + const onIncomingCallError = (reason: string) => { + console.error('incoming call canceled', reason); + if (reason === 'USER_NOT_REGISTERED') { + dispatchToastMessage({ type: 'error', message: t('Incoming_voice_call_canceled_user_not_registered') }); + return; + } + + dispatchToastMessage({ type: 'error', message: t('Incoming_voice_call_canceled_suddenly') }); + }; + const onRegistered = () => { setStorageRegistered(true); }; @@ -103,6 +113,7 @@ const VoipProvider = ({ children }: { children: ReactNode }) => { voipClient.on('registrationerror', onRegistrationError); voipClient.on('registered', onRegistered); voipClient.on('unregistered', onUnregister); + voipClient.on('incomingcallerror', onIncomingCallError); voipClient.networkEmitter.on('disconnected', onNetworkDisconnected); voipClient.networkEmitter.on('connectionerror', onNetworkDisconnected); voipClient.networkEmitter.on('localnetworkoffline', onNetworkDisconnected); @@ -116,6 +127,7 @@ const VoipProvider = ({ children }: { children: ReactNode }) => { voipClient.off('registrationerror', onRegistrationError); voipClient.off('registered', onRegistered); voipClient.off('unregistered', onUnregister); + voipClient.off('incomingcallerror', onIncomingCallError); voipClient.networkEmitter.off('disconnected', onNetworkDisconnected); voipClient.networkEmitter.off('connectionerror', onNetworkDisconnected); voipClient.networkEmitter.off('localnetworkoffline', onNetworkDisconnected);