Skip to content

Commit 7bc4f03

Browse files
chore: send a message on the DM when an internal call ends (#37419)
Co-authored-by: gabriellsh <henriques.gabriell@gmail.com> Co-authored-by: gabriellsh <40830821+gabriellsh@users.noreply.github.com>
1 parent 15f1a20 commit 7bc4f03

File tree

6 files changed

+416
-2
lines changed

6 files changed

+416
-2
lines changed

apps/meteor/jest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export default {
4343
'<rootDir>/app/api/server/**.spec.ts',
4444
'<rootDir>/app/api/server/helpers/**.spec.ts',
4545
'<rootDir>/app/api/server/middlewares/**.spec.ts',
46+
'<rootDir>/server/services/media-call/**.spec.ts',
4647
],
4748
coveragePathIgnorePatterns: ['/node_modules/'],
4849
},
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
import { callStateToTranslationKey, callStateToIcon, getFormattedCallDuration, getHistoryMessagePayload } from './getHistoryMessagePayload';
2+
3+
const appId = 'media-call-core';
4+
describe('callStateToTranslationKey', () => {
5+
it('should return correct translation key for "ended" state', () => {
6+
const result = callStateToTranslationKey('ended');
7+
expect(result).toEqual({ type: 'mrkdwn', i18n: { key: 'Call_ended_bold' }, text: 'Call ended' });
8+
});
9+
10+
it('should return correct translation key for "not-answered" state', () => {
11+
const result = callStateToTranslationKey('not-answered');
12+
expect(result).toEqual({ type: 'mrkdwn', i18n: { key: 'Call_not_answered_bold' }, text: 'Call not answered' });
13+
});
14+
15+
it('should return correct translation key for "failed" state', () => {
16+
const result = callStateToTranslationKey('failed');
17+
expect(result).toEqual({ type: 'mrkdwn', i18n: { key: 'Call_failed_bold' }, text: 'Call failed' });
18+
});
19+
20+
it('should return correct translation key for "error" state', () => {
21+
const result = callStateToTranslationKey('error');
22+
expect(result).toEqual({ type: 'mrkdwn', i18n: { key: 'Call_failed_bold' }, text: 'Call failed' });
23+
});
24+
25+
it('should return correct translation key for "transferred" state', () => {
26+
const result = callStateToTranslationKey('transferred');
27+
expect(result).toEqual({ type: 'mrkdwn', i18n: { key: 'Call_transferred_bold' }, text: 'Call transferred' });
28+
});
29+
});
30+
31+
describe('callStateToIcon', () => {
32+
it('should return correct icon for "ended" state', () => {
33+
const result = callStateToIcon('ended');
34+
expect(result).toEqual({ type: 'icon', icon: 'phone-off', variant: 'secondary' });
35+
});
36+
37+
it('should return correct icon for "not-answered" state', () => {
38+
const result = callStateToIcon('not-answered');
39+
expect(result).toEqual({ type: 'icon', icon: 'clock', variant: 'danger' });
40+
});
41+
42+
it('should return correct icon for "failed" state', () => {
43+
const result = callStateToIcon('failed');
44+
expect(result).toEqual({ type: 'icon', icon: 'phone-issue', variant: 'danger' });
45+
});
46+
47+
it('should return correct icon for "error" state', () => {
48+
const result = callStateToIcon('error');
49+
expect(result).toEqual({ type: 'icon', icon: 'phone-issue', variant: 'danger' });
50+
});
51+
52+
it('should return correct icon for "transferred" state', () => {
53+
const result = callStateToIcon('transferred');
54+
expect(result).toEqual({ type: 'icon', icon: 'arrow-forward', variant: 'secondary' });
55+
});
56+
});
57+
58+
describe('getFormattedCallDuration', () => {
59+
it('should return undefined when callDuration is undefined', () => {
60+
const result = getFormattedCallDuration(undefined);
61+
expect(result).toBeUndefined();
62+
});
63+
64+
it('should return undefined when callDuration is 0', () => {
65+
const result = getFormattedCallDuration(0);
66+
expect(result).toBeUndefined();
67+
});
68+
69+
it('should format duration correctly for seconds only (less than 60 seconds)', () => {
70+
const result = getFormattedCallDuration(30);
71+
expect(result).toEqual({ type: 'mrkdwn', text: '*00:30*' });
72+
});
73+
74+
it('should format duration correctly for minutes and seconds (less than 1 hour)', () => {
75+
const result = getFormattedCallDuration(125); // 2 minutes 5 seconds
76+
expect(result).toEqual({ type: 'mrkdwn', text: '*02:05*' });
77+
});
78+
79+
it('should format duration correctly for exactly 1 minute', () => {
80+
const result = getFormattedCallDuration(60);
81+
expect(result).toEqual({ type: 'mrkdwn', text: '*01:00*' });
82+
});
83+
84+
it('should format duration correctly for hours, minutes, and seconds', () => {
85+
const result = getFormattedCallDuration(3665); // 1 hour 1 minute 5 seconds
86+
expect(result).toEqual({ type: 'mrkdwn', text: '*01:01:05*' });
87+
});
88+
89+
it('should format duration correctly for multiple hours', () => {
90+
const result = getFormattedCallDuration(7325); // 2 hours 2 minutes 5 seconds
91+
expect(result).toEqual({ type: 'mrkdwn', text: '*02:02:05*' });
92+
});
93+
94+
it('should pad single digit values with zeros', () => {
95+
const result = getFormattedCallDuration(61); // 1 minute 1 second
96+
expect(result).toEqual({ type: 'mrkdwn', text: '*01:01*' });
97+
});
98+
99+
it('should handle large durations correctly', () => {
100+
const result = getFormattedCallDuration(36661); // 10 hours 11 minutes 1 second
101+
expect(result).toEqual({ type: 'mrkdwn', text: '*10:11:01*' });
102+
});
103+
});
104+
105+
describe('getHistoryMessagePayload', () => {
106+
it('should return correct payload for "ended" state without duration', () => {
107+
const result = getHistoryMessagePayload('ended', undefined);
108+
expect(result).toEqual({
109+
msg: '',
110+
groupable: false,
111+
blocks: [
112+
{
113+
appId,
114+
type: 'info_card',
115+
rows: [
116+
{
117+
background: 'default',
118+
elements: [
119+
{ type: 'icon', icon: 'phone-off', variant: 'secondary' },
120+
{ type: 'mrkdwn', i18n: { key: 'Call_ended_bold' }, text: 'Call ended' },
121+
],
122+
},
123+
],
124+
},
125+
],
126+
});
127+
});
128+
129+
it('should return correct payload for "ended" state with duration', () => {
130+
const result = getHistoryMessagePayload('ended', 125);
131+
expect(result).toEqual({
132+
msg: '',
133+
groupable: false,
134+
blocks: [
135+
{
136+
appId,
137+
type: 'info_card',
138+
rows: [
139+
{
140+
background: 'default',
141+
elements: [
142+
{ type: 'icon', icon: 'phone-off', variant: 'secondary' },
143+
{ type: 'mrkdwn', i18n: { key: 'Call_ended_bold' }, text: 'Call ended' },
144+
],
145+
},
146+
{
147+
background: 'secondary',
148+
elements: [{ type: 'mrkdwn', text: '*02:05*' }],
149+
},
150+
],
151+
},
152+
],
153+
});
154+
});
155+
156+
it('should return correct payload for "not-answered" state', () => {
157+
const result = getHistoryMessagePayload('not-answered', undefined);
158+
expect(result).toEqual({
159+
msg: '',
160+
groupable: false,
161+
blocks: [
162+
{
163+
appId,
164+
type: 'info_card',
165+
rows: [
166+
{
167+
background: 'default',
168+
elements: [
169+
{ type: 'icon', icon: 'clock', variant: 'danger' },
170+
{ type: 'mrkdwn', i18n: { key: 'Call_not_answered_bold' }, text: 'Call not answered' },
171+
],
172+
},
173+
],
174+
},
175+
],
176+
});
177+
});
178+
179+
it('should return correct payload for "failed" state', () => {
180+
const result = getHistoryMessagePayload('failed', undefined);
181+
expect(result).toEqual({
182+
msg: '',
183+
groupable: false,
184+
blocks: [
185+
{
186+
appId,
187+
type: 'info_card',
188+
rows: [
189+
{
190+
background: 'default',
191+
elements: [
192+
{ type: 'icon', icon: 'phone-issue', variant: 'danger' },
193+
{ type: 'mrkdwn', i18n: { key: 'Call_failed_bold' }, text: 'Call failed' },
194+
],
195+
},
196+
],
197+
},
198+
],
199+
});
200+
});
201+
202+
it('should return correct payload for "error" state', () => {
203+
const result = getHistoryMessagePayload('error', undefined);
204+
expect(result).toEqual({
205+
msg: '',
206+
groupable: false,
207+
blocks: [
208+
{
209+
appId,
210+
type: 'info_card',
211+
rows: [
212+
{
213+
background: 'default',
214+
elements: [
215+
{ type: 'icon', icon: 'phone-issue', variant: 'danger' },
216+
{ type: 'mrkdwn', i18n: { key: 'Call_failed_bold' }, text: 'Call failed' },
217+
],
218+
},
219+
],
220+
},
221+
],
222+
});
223+
});
224+
225+
it('should return correct payload for "transferred" state', () => {
226+
const result = getHistoryMessagePayload('transferred', undefined);
227+
expect(result).toEqual({
228+
msg: '',
229+
groupable: false,
230+
blocks: [
231+
{
232+
appId,
233+
type: 'info_card',
234+
rows: [
235+
{
236+
background: 'default',
237+
elements: [
238+
{ type: 'icon', icon: 'arrow-forward', variant: 'secondary' },
239+
{ type: 'mrkdwn', i18n: { key: 'Call_transferred_bold' }, text: 'Call transferred' },
240+
],
241+
},
242+
],
243+
},
244+
],
245+
});
246+
});
247+
248+
it('should include duration row when duration is provided', () => {
249+
const result = getHistoryMessagePayload('ended', 3665);
250+
251+
expect(result.blocks[0].rows).toHaveLength(2);
252+
expect(result.blocks[0].rows[1]).toEqual({
253+
background: 'secondary',
254+
elements: [{ type: 'mrkdwn', text: '*01:01:05*' }],
255+
});
256+
});
257+
258+
it('should not include duration row when duration is undefined', () => {
259+
const result = getHistoryMessagePayload('ended', undefined);
260+
expect(result.blocks[0].rows).toHaveLength(1);
261+
});
262+
263+
it('should handle all call states with duration correctly', () => {
264+
const states = ['ended', 'transferred', 'not-answered', 'failed', 'error'] as const;
265+
const duration = 125;
266+
267+
states.forEach((state) => {
268+
const result = getHistoryMessagePayload(state, duration);
269+
expect(result.msg).toBe('');
270+
expect(result.groupable).toBe(false);
271+
expect(result.blocks).toHaveLength(1);
272+
expect(result.blocks[0].type).toBe('info_card');
273+
expect(result.blocks[0].rows).toHaveLength(2);
274+
expect(result.blocks[0].rows[1].background).toBe('secondary');
275+
expect(result.blocks[0].rows[1].elements[0].type).toBe('mrkdwn');
276+
});
277+
});
278+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { CallHistoryItemState, IMessage } from '@rocket.chat/core-typings';
2+
import type { IconElement, InfoCardBlock, TextObject } from '@rocket.chat/ui-kit';
3+
import { intervalToDuration, secondsToMilliseconds } from 'date-fns';
4+
5+
const APP_ID = 'media-call-core';
6+
7+
// TODO bold the text
8+
export const callStateToTranslationKey = (callState: CallHistoryItemState): TextObject => {
9+
switch (callState) {
10+
case 'ended':
11+
return { type: 'mrkdwn', i18n: { key: 'Call_ended_bold' }, text: 'Call ended' };
12+
case 'not-answered':
13+
return { type: 'mrkdwn', i18n: { key: 'Call_not_answered_bold' }, text: 'Call not answered' };
14+
case 'failed':
15+
case 'error':
16+
return { type: 'mrkdwn', i18n: { key: 'Call_failed_bold' }, text: 'Call failed' };
17+
case 'transferred':
18+
return { type: 'mrkdwn', i18n: { key: 'Call_transferred_bold' }, text: 'Call transferred' };
19+
}
20+
};
21+
22+
export const callStateToIcon = (callState: CallHistoryItemState): IconElement => {
23+
switch (callState) {
24+
case 'ended':
25+
return { type: 'icon', icon: 'phone-off', variant: 'secondary' };
26+
case 'not-answered':
27+
return { type: 'icon', icon: 'clock', variant: 'danger' };
28+
case 'failed':
29+
case 'error':
30+
return { type: 'icon', icon: 'phone-issue', variant: 'danger' };
31+
case 'transferred':
32+
return { type: 'icon', icon: 'arrow-forward', variant: 'secondary' };
33+
}
34+
};
35+
36+
const buildDurationString = (...values: number[]): string => {
37+
return values.map((value) => value.toString().padStart(2, '0')).join(':');
38+
};
39+
40+
export const getFormattedCallDuration = (callDuration: number | undefined): TextObject | undefined => {
41+
if (!callDuration || typeof callDuration !== 'number') {
42+
return undefined;
43+
}
44+
45+
const milliseconds = secondsToMilliseconds(callDuration);
46+
const duration = { minutes: 0, seconds: 0, ...intervalToDuration({ start: 0, end: milliseconds }) };
47+
48+
if (duration.hours && duration.hours > 0) {
49+
return { type: 'mrkdwn', text: `*${buildDurationString(duration.hours, duration.minutes, duration.seconds)}*` } as const;
50+
}
51+
52+
return {
53+
type: 'mrkdwn',
54+
text: `*${buildDurationString(duration.minutes, duration.seconds)}*`,
55+
} as const;
56+
};
57+
58+
// TODO proper translation keys
59+
export const getHistoryMessagePayload = (
60+
callState: CallHistoryItemState,
61+
callDuration: number | undefined,
62+
): Pick<IMessage, 'msg' | 'groupable'> & { blocks: [InfoCardBlock] } => {
63+
const callStateTranslationKey = callStateToTranslationKey(callState);
64+
const icon = callStateToIcon(callState);
65+
const callDurationFormatted = getFormattedCallDuration(callDuration);
66+
67+
return {
68+
msg: '',
69+
groupable: false,
70+
blocks: [
71+
{
72+
appId: APP_ID,
73+
type: 'info_card',
74+
rows: [
75+
{
76+
background: 'default',
77+
elements: [icon, callStateTranslationKey],
78+
},
79+
...(callDurationFormatted
80+
? [
81+
{
82+
background: 'secondary',
83+
elements: [callDurationFormatted],
84+
} as const,
85+
]
86+
: []),
87+
],
88+
},
89+
],
90+
};
91+
};

0 commit comments

Comments
 (0)