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);