Skip to content

Commit 996f5b9

Browse files
feat: improve voip error handling (#35776)
1 parent d8eb824 commit 996f5b9

10 files changed

+324
-3
lines changed

.changeset/spicy-vans-cough.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@rocket.chat/ui-voip': minor
3+
'@rocket.chat/i18n': minor
4+
'@rocket.chat/meteor': minor
5+
---
6+
7+
Improves handling of errors during voice calls

packages/i18n/src/locales/en.i18n.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6002,7 +6002,9 @@
60026002
"unable-to-get-file": "Unable to get file",
60036003
"Unable_to_load_active_connections": "Unable to load active connections",
60046004
"Unable_to_complete_call": "Unable to complete call",
6005+
"Unable_to_complete_call__code": "Unable to complete call. Error code [{{statusCode}}]",
60056006
"Unable_to_make_calls_while_another_is_ongoing": "Unable to make calls while another call is ongoing",
6007+
"Unable_to_negotiate_call_params": "Unable to negotiate call params.",
60066008
"Unassigned": "Unassigned",
60076009
"Unassign_extension": "Unassign extension",
60086010
"unauthorized": "Not authorized",
@@ -6795,6 +6797,8 @@
67956797
"Sidebar_Sections_Order": "Sidebar sections order",
67966798
"Sidebar_Sections_Order_Description": "Select the categories in your preferred order",
67976799
"Incoming_Calls": "Incoming calls",
6800+
"Incoming_voice_call_canceled_suddenly": "An Incoming Voice Call was canceled suddenly.",
6801+
"Incoming_voice_call_canceled_user_not_registered": "An Incoming Voice Call was canceled due to an unexpected error.",
67986802
"Advanced_settings": "Advanced settings",
67996803
"Security_and_permissions": "Security and permissions",
68006804
"Security_and_privacy": "Security and privacy",

packages/ui-voip/src/components/VoipPopup/views/VoipErrorView.spec.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ it('should properly render unknown error calls', async () => {
3939
const session = createMockVoipErrorSession({ error: { status: -1, reason: '' } });
4040
render(<VoipErrorView session={session} />, { wrapper: appRoot.build() });
4141

42-
expect(screen.getByText('Unable_to_complete_call')).toBeInTheDocument();
42+
expect(screen.getByText('Unable_to_complete_call__code')).toBeInTheDocument();
4343
await userEvent.click(screen.getByRole('button', { name: 'End_call' }));
4444
expect(session.end).toHaveBeenCalled();
4545
});

packages/ui-voip/src/components/VoipPopup/views/VoipErrorView.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,16 @@ const VoipErrorView = ({ session, position }: VoipErrorViewProps) => {
2424

2525
const title = useMemo(() => {
2626
switch (status) {
27+
case 488:
28+
return t('Unable_to_negotiate_call_params');
2729
case 487:
2830
return t('Call_terminated');
2931
case 486:
3032
return t('Caller_is_busy');
3133
case 480:
3234
return t('Temporarily_unavailable');
3335
default:
34-
return t('Unable_to_complete_call');
36+
return t('Unable_to_complete_call__code', { statusCode: status });
3537
}
3638
}, [status, t]);
3739

packages/ui-voip/src/lib/VoipClient.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { SignalingSocketEvents, VoipEvents as CoreVoipEvents, VoIPUserConfiguration } from '@rocket.chat/core-typings';
22
import { Emitter } from '@rocket.chat/emitter';
3-
import type { InvitationAcceptOptions, Message, Referral, Session, SessionInviteOptions } from 'sip.js';
3+
import type { InvitationAcceptOptions, Message, Referral, Session, SessionInviteOptions, Cancel as SipCancel } from 'sip.js';
44
import { Registerer, RequestPendingError, SessionState, UserAgent, Invitation, Inviter, RegistererState, UserAgentState } from 'sip.js';
55
import type { IncomingResponse, OutgoingByeRequest, URI } from 'sip.js/lib/core';
66
import type { SessionDescriptionHandlerOptions } from 'sip.js/lib/platform/web';
@@ -9,12 +9,14 @@ import { SessionDescriptionHandler } from 'sip.js/lib/platform/web';
99
import type { ContactInfo, VoipSession } from '../definitions';
1010
import LocalStream from './LocalStream';
1111
import RemoteStream from './RemoteStream';
12+
import { getMainInviteRejectionReason } from './getMainInviteRejectionReason';
1213

1314
export type VoipEvents = Omit<CoreVoipEvents, 'ringing' | 'callestablished' | 'incomingcall'> & {
1415
callestablished: ContactInfo;
1516
incomingcall: ContactInfo;
1617
outgoingcall: ContactInfo;
1718
dialer: { open: boolean };
19+
incomingcallerror: string;
1820
};
1921

2022
type SessionError = {
@@ -770,6 +772,7 @@ class VoipClient extends Emitter<VoipEvents> {
770772
}
771773

772774
private setError(error: SessionError | null) {
775+
console.error(error);
773776
this.error = error;
774777
this.emit('stateChanged');
775778
}
@@ -843,12 +846,23 @@ class VoipClient extends Emitter<VoipEvents> {
843846
this.emit('unregistrationerror', error);
844847
};
845848

849+
private onInvitationCancel(invitation: Invitation, message: SipCancel): void {
850+
const reason = getMainInviteRejectionReason(invitation, message);
851+
if (reason) {
852+
this.emit('incomingcallerror', reason);
853+
}
854+
}
855+
846856
private onIncomingCall = async (invitation: Invitation): Promise<void> => {
847857
if (!this.isRegistered() || this.session) {
848858
await invitation.reject();
849859
return;
850860
}
851861

862+
invitation.delegate = {
863+
onCancel: (cancel: SipCancel) => this.onInvitationCancel(invitation, cancel),
864+
};
865+
852866
this.initSession(invitation);
853867

854868
this.emit('incomingcall', this.getContactInfo() as ContactInfo);
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { type Cancel as SipCancel, type Invitation, type SessionState } from 'sip.js';
2+
3+
import { getMainInviteRejectionReason } from './getMainInviteRejectionReason';
4+
5+
const mockInvitation = (state: SessionState[keyof SessionState]): Invitation =>
6+
({
7+
state,
8+
}) as any;
9+
10+
const mockSipCancel = (reasons: string[]): SipCancel =>
11+
({
12+
request: {
13+
headers: {
14+
Reason: reasons.map((raw) => ({ raw })),
15+
},
16+
},
17+
}) as any;
18+
19+
describe('getMainInviteRejectionReason', () => {
20+
beforeEach(() => {
21+
jest.clearAllMocks();
22+
});
23+
24+
it('should return undefined for natural endings', () => {
25+
const result = getMainInviteRejectionReason(mockInvitation('Terminated'), mockSipCancel(['SIP ;cause=487 ;text="ORIGINATOR_CANCEL"']));
26+
expect(result).toBeUndefined();
27+
});
28+
29+
it('should return priorityErrorEndings if present', () => {
30+
const result = getMainInviteRejectionReason(
31+
mockInvitation('Terminated'),
32+
mockSipCancel(['SIP ;cause=488 ;text="USER_NOT_REGISTERED"']),
33+
);
34+
expect(result).toBe('USER_NOT_REGISTERED');
35+
});
36+
37+
it('should return the first parsed reason if call was canceled at the initial state', () => {
38+
const result = getMainInviteRejectionReason(
39+
mockInvitation('Initial'),
40+
mockSipCancel(['text="UNEXPECTED_REASON"', 'text="ANOTHER_REASON"']),
41+
);
42+
expect(result).toBe('UNEXPECTED_REASON');
43+
});
44+
45+
it('should log a warning if call was canceled for unexpected reason', () => {
46+
console.warn = jest.fn();
47+
const result = getMainInviteRejectionReason(
48+
mockInvitation('Terminated'),
49+
mockSipCancel(['text="UNEXPECTED_REASON"', 'text="ANOTHER_REASON"']),
50+
);
51+
expect(console.warn).toHaveBeenCalledWith('The call was canceled for an unexpected reason', ['UNEXPECTED_REASON', 'ANOTHER_REASON']);
52+
expect(result).toBeUndefined();
53+
});
54+
55+
it('should handle empty parsed reasons array gracefully', () => {
56+
const result = getMainInviteRejectionReason(mockInvitation('Terminated'), mockSipCancel([]));
57+
expect(result).toBeUndefined();
58+
});
59+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { Cancel as SipCancel, Invitation } from 'sip.js';
2+
3+
import { parseInviteRejectionReasons } from './parseInviteRejectionReasons';
4+
5+
const naturalEndings = [
6+
'ORIGINATOR_CANCEL',
7+
'NO_ANSWER',
8+
'NORMAL_CLEARING',
9+
'USER_BUSY',
10+
'NO_USER_RESPONSE',
11+
'NORMAL_UNSPECIFIED',
12+
] as const;
13+
14+
const priorityErrorEndings = ['USER_NOT_REGISTERED'] as const;
15+
16+
export function getMainInviteRejectionReason(invitation: Invitation, message: SipCancel): string | undefined {
17+
const parsedReasons = parseInviteRejectionReasons(message);
18+
19+
for (const ending of naturalEndings) {
20+
if (parsedReasons.includes(ending)) {
21+
// Do not emit any errors for normal endings
22+
return;
23+
}
24+
}
25+
26+
for (const ending of priorityErrorEndings) {
27+
if (parsedReasons.includes(ending)) {
28+
// An error definitely happened
29+
return ending;
30+
}
31+
}
32+
33+
if (invitation?.state === 'Initial') {
34+
// Call was canceled at the initial state and it was not due to one of the natural reasons, treat it as unexpected
35+
return parsedReasons.shift();
36+
}
37+
38+
console.warn('The call was canceled for an unexpected reason', parsedReasons);
39+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import type { Cancel as SipCancel } from 'sip.js';
2+
3+
import { parseInviteRejectionReasons } from './parseInviteRejectionReasons';
4+
5+
describe('parseInviteRejectionReasons', () => {
6+
it('should return an empty array when message is undefined', () => {
7+
expect(parseInviteRejectionReasons(undefined as any)).toEqual([]);
8+
});
9+
10+
it('should return an empty array when headers are not defined', () => {
11+
const message: SipCancel = { request: {} } as any;
12+
expect(parseInviteRejectionReasons(message)).toEqual([]);
13+
});
14+
15+
it('should return an empty array when Reason header is not defined', () => {
16+
const message: SipCancel = { request: { headers: {} } } as any;
17+
expect(parseInviteRejectionReasons(message)).toEqual([]);
18+
});
19+
20+
it('should parse a single text reason correctly', () => {
21+
const message: SipCancel = {
22+
request: {
23+
headers: {
24+
Reason: [{ raw: 'text="Busy Here"' }],
25+
},
26+
},
27+
} as any;
28+
29+
expect(parseInviteRejectionReasons(message)).toEqual(['Busy Here']);
30+
});
31+
32+
it('should extract cause from Reason header if text is not present', () => {
33+
const message: SipCancel = {
34+
request: {
35+
headers: {
36+
Reason: [{ raw: 'SIP ;cause=404' }],
37+
},
38+
},
39+
} as any;
40+
41+
expect(parseInviteRejectionReasons(message)).toEqual(['404']);
42+
});
43+
44+
it('should extract text from Reason header when both text and cause are present ', () => {
45+
const message: SipCancel = {
46+
request: {
47+
headers: { Reason: [{ raw: 'SIP ;cause=200 ;text="OK"' }] },
48+
},
49+
} as any;
50+
expect(parseInviteRejectionReasons(message)).toEqual(['OK']);
51+
});
52+
53+
it('should return the raw reason if no matching text or cause is found', () => {
54+
const message: SipCancel = {
55+
request: {
56+
headers: {
57+
Reason: [{ raw: 'code=486' }],
58+
},
59+
},
60+
} as any;
61+
62+
expect(parseInviteRejectionReasons(message)).toEqual(['code=486']);
63+
});
64+
65+
it('should parse multiple reasons and return only the text parts', () => {
66+
const message: SipCancel = {
67+
request: {
68+
headers: {
69+
Reason: [{ raw: 'text="Busy Here"' }, { raw: 'text="Server Internal Error"' }],
70+
},
71+
},
72+
} as any;
73+
74+
expect(parseInviteRejectionReasons(message)).toEqual(['Busy Here', 'Server Internal Error']);
75+
});
76+
77+
it('should return an array of parsed reasons when valid reasons are present', () => {
78+
const mockMessage: SipCancel = {
79+
request: {
80+
headers: {
81+
Reason: [{ raw: 'SIP ;cause=200 ;text="Call completed elsewhere"' }, { raw: 'SIP ;cause=486 ;text="Busy Here"' }],
82+
},
83+
},
84+
} as any;
85+
86+
const result = parseInviteRejectionReasons(mockMessage);
87+
expect(result).toEqual(['Call completed elsewhere', 'Busy Here']);
88+
});
89+
90+
it('should parse multiple reasons and return the mixed text, cause and raw items, on this order', () => {
91+
const message: SipCancel = {
92+
request: {
93+
headers: {
94+
Reason: [{ raw: 'text="Busy Here"' }, { raw: 'code=503' }, { raw: 'cause=488' }, { raw: 'text="Forbidden"' }],
95+
},
96+
},
97+
} as any;
98+
99+
expect(parseInviteRejectionReasons(message)).toEqual(['Busy Here', 'Forbidden', '488', 'code=503']);
100+
});
101+
102+
it('should filter out any undefined or null values from the resulting array', () => {
103+
const message: SipCancel = {
104+
request: {
105+
headers: {
106+
Reason: [
107+
{ raw: 'SIP ;cause=500 ;text="Server Error"' },
108+
{ raw: null as unknown as string }, // Simulate an edge case { raw: '' }
109+
],
110+
},
111+
},
112+
} as any;
113+
expect(parseInviteRejectionReasons(message)).toEqual(['Server Error']);
114+
});
115+
116+
it('should handle non-string raw values gracefully and return only valid matches', () => {
117+
const message: SipCancel = {
118+
request: {
119+
headers: {
120+
Reason: [
121+
{ raw: 'text="Service Unavailable"' },
122+
{ raw: { notAString: true } as unknown as string }, // Intentional type misuse for testing
123+
{ raw: 'code=486' },
124+
],
125+
},
126+
},
127+
} as any;
128+
129+
expect(parseInviteRejectionReasons(message)).toEqual(['Service Unavailable', 'code=486']);
130+
});
131+
132+
it('should return an empty array when exceptions are thrown', () => {
133+
// Mock the function to throw an error
134+
const faultyMessage: SipCancel = {
135+
request: {
136+
headers: {
137+
Reason: [
138+
{
139+
raw: () => {
140+
throw new Error('unexpected error');
141+
},
142+
},
143+
] as any,
144+
},
145+
},
146+
} as any;
147+
expect(parseInviteRejectionReasons(faultyMessage)).toEqual([]);
148+
});
149+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { Cancel as SipCancel } from 'sip.js';
2+
3+
export function parseInviteRejectionReasons(message: SipCancel): string[] {
4+
try {
5+
const reasons = message?.request?.headers?.Reason;
6+
const parsedTextReasons: string[] = [];
7+
const parsedCauseReasons: string[] = [];
8+
const rawReasons: string[] = [];
9+
10+
if (reasons) {
11+
for (const { raw } of reasons) {
12+
if (!raw || typeof raw !== 'string') {
13+
continue;
14+
}
15+
16+
const textMatch = raw.match(/text="(.+)"/);
17+
if (textMatch?.length && textMatch.length > 1) {
18+
parsedTextReasons.push(textMatch[1]);
19+
continue;
20+
}
21+
const causeMatch = raw.match(/cause=_?(\d+)/);
22+
if (causeMatch?.length && causeMatch.length > 1) {
23+
parsedCauseReasons.push(causeMatch[1]);
24+
continue;
25+
}
26+
27+
rawReasons.push(raw);
28+
}
29+
}
30+
31+
return [...parsedTextReasons, ...parsedCauseReasons, ...rawReasons];
32+
} catch {
33+
return [];
34+
}
35+
}

0 commit comments

Comments
 (0)