Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/spicy-vans-cough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@rocket.chat/ui-voip': minor
'@rocket.chat/i18n': minor
'@rocket.chat/meteor': minor
---

Improves handling of errors during voice calls
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ it('should properly render unknown error calls', async () => {
const session = createMockVoipErrorSession({ error: { status: -1, reason: '' } });
render(<VoipErrorView session={session} />, { 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();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,16 @@ 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:
return t('Caller_is_busy');
case 480:
return t('Temporarily_unavailable');
default:
return t('Unable_to_complete_call');
return t('Unable_to_complete_call__code', { statusCode: status });
}
}, [status, t]);

Expand Down
16 changes: 15 additions & 1 deletion packages/ui-voip/src/lib/VoipClient.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<CoreVoipEvents, 'ringing' | 'callestablished' | 'incomingcall'> & {
callestablished: ContactInfo;
incomingcall: ContactInfo;
outgoingcall: ContactInfo;
dialer: { open: boolean };
incomingcallerror: string;
};

type SessionError = {
Expand Down Expand Up @@ -770,6 +772,7 @@ class VoipClient extends Emitter<VoipEvents> {
}

private setError(error: SessionError | null) {
console.error(error);
this.error = error;
this.emit('stateChanged');
}
Expand Down Expand Up @@ -843,12 +846,23 @@ class VoipClient extends Emitter<VoipEvents> {
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<void> => {
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);
Expand Down
59 changes: 59 additions & 0 deletions packages/ui-voip/src/lib/getMainInviteRejectionReason.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
39 changes: 39 additions & 0 deletions packages/ui-voip/src/lib/getMainInviteRejectionReason.ts
Original file line number Diff line number Diff line change
@@ -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);
}
149 changes: 149 additions & 0 deletions packages/ui-voip/src/lib/parseInviteRejectionReasons.spec.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
35 changes: 35 additions & 0 deletions packages/ui-voip/src/lib/parseInviteRejectionReasons.ts
Original file line number Diff line number Diff line change
@@ -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 [];
}
}
Loading
Loading