feat(Omnichannel): System messages in transcripts (#32752)
parent
0de8ee18c7
commit
03c8b066f9
@ -0,0 +1,12 @@ |
||||
--- |
||||
'@rocket.chat/omnichannel-services': minor |
||||
'@rocket.chat/pdf-worker': minor |
||||
'@rocket.chat/core-services': minor |
||||
'@rocket.chat/model-typings': minor |
||||
'@rocket.chat/i18n': minor |
||||
'@rocket.chat/meteor': minor |
||||
--- |
||||
|
||||
Added system messages support for Omnichannel PDF transcripts and email transcripts. Currently these transcripts don't render system messages and is shown as an empty message in PDF/email. This PR adds this support for all valid livechat system messages. |
||||
|
||||
Also added a new setting under transcripts, to toggle the inclusion of system messages in email and PDF transcripts. |
||||
@ -0,0 +1,7 @@ |
||||
--- |
||||
'@rocket.chat/omnichannel-services': patch |
||||
'@rocket.chat/core-services': patch |
||||
'@rocket.chat/meteor': patch |
||||
--- |
||||
|
||||
Reduced time on generation of PDF transcripts. Earlier Rocket.Chat was fetching the required translations everytime a PDF transcript was requested, this process was async and was being unnecessarily being performed on every pdf transcript request. This PR improves this and now the translations are loaded at the start and kept in memory to process further pdf transcripts requests. This reduces the time of asynchronously fetching translations again and again. |
||||
@ -0,0 +1,9 @@ |
||||
export default { |
||||
preset: 'ts-jest', |
||||
errorOnDeprecated: true, |
||||
testEnvironment: 'jsdom', |
||||
modulePathIgnorePatterns: ['<rootDir>/dist/'], |
||||
moduleNameMapper: { |
||||
'\\.css$': 'identity-obj-proxy', |
||||
}, |
||||
}; |
||||
@ -0,0 +1,70 @@ |
||||
import type { MessageTypesValues } from '@rocket.chat/core-typings'; |
||||
|
||||
export const validFile = { name: 'screenshot.png', buffer: Buffer.from([1, 2, 3]) }; |
||||
|
||||
export const invalidFile = { name: 'audio.mp3', buffer: null }; |
||||
|
||||
export const messages = [ |
||||
{ |
||||
msg: 'Hello, how can I help you today?', |
||||
ts: '2022-11-21T16:00:00.000Z', |
||||
u: { |
||||
_id: '123', |
||||
name: 'Juanito De Ponce', |
||||
username: 'juanito.ponce', |
||||
}, |
||||
}, |
||||
{ |
||||
msg: 'I am having trouble with my account.', |
||||
ts: '2022-11-21T16:00:00.000Z', |
||||
u: { |
||||
_id: '321', |
||||
name: 'Christian Castro', |
||||
username: 'cristiano.castro', |
||||
}, |
||||
md: [ |
||||
{ |
||||
type: 'UNORDERED_LIST', |
||||
value: [ |
||||
{ type: 'LIST_ITEM', value: [{ type: 'PLAIN_TEXT', value: 'I am having trouble with my account;' }] }, |
||||
{ |
||||
type: 'LIST_ITEM', |
||||
value: [ |
||||
{ type: 'PLAIN_TEXT', value: 'I am having trouble with my password. ' }, |
||||
{ type: 'EMOJI', value: undefined, unicode: '🙂' }, |
||||
], |
||||
}, |
||||
], |
||||
}, |
||||
], |
||||
}, |
||||
{ |
||||
msg: 'Can you please provide your account email?', |
||||
ts: '2022-11-21T16:00:00.000Z', |
||||
u: { |
||||
_id: '123', |
||||
name: 'Juanito De Ponce', |
||||
username: 'juanito.ponce', |
||||
}, |
||||
}, |
||||
]; |
||||
|
||||
export const validSystemMessage = { |
||||
ts: '2022-11-21T16:00:00.000Z', |
||||
u: { |
||||
_id: '123', |
||||
name: 'Juanito De Ponce', |
||||
username: 'juanito.ponce', |
||||
}, |
||||
t: 'livechat-started' as MessageTypesValues, |
||||
}; |
||||
|
||||
export const invalidSystemMessage = { |
||||
ts: '2022-11-21T16:00:00.000Z', |
||||
u: { |
||||
_id: '123', |
||||
name: 'Juanito De Ponce', |
||||
username: 'juanito.ponce', |
||||
}, |
||||
t: 'some-system-message' as MessageTypesValues, |
||||
}; |
||||
@ -0,0 +1,119 @@ |
||||
import '@testing-library/jest-dom'; |
||||
import type { IMessage } from '@rocket.chat/core-typings'; |
||||
import { Logger } from '@rocket.chat/logger'; |
||||
|
||||
import { OmnichannelTranscript } from './OmnichannelTranscript'; |
||||
import { invalidSystemMessage, messages, validSystemMessage } from './OmnichannelTranscript.fixtures'; |
||||
|
||||
jest.mock('@rocket.chat/pdf-worker', () => ({ |
||||
PdfWorker: jest.fn().mockImplementation(() => ({ |
||||
renderToStream: jest.fn().mockResolvedValue(Buffer.from('')), |
||||
isMimeTypeValid: jest.fn(() => true), |
||||
})), |
||||
})); |
||||
|
||||
jest.mock('@rocket.chat/core-services', () => ({ |
||||
ServiceClass: class {}, |
||||
Upload: { |
||||
getFileBuffer: jest.fn().mockResolvedValue(Buffer.from('')), |
||||
uploadFile: jest.fn().mockResolvedValue({ _id: 'fileId', name: 'fileName' }), |
||||
sendFileMessage: jest.fn(), |
||||
}, |
||||
Message: { |
||||
sendMessage: jest.fn(), |
||||
}, |
||||
Room: { |
||||
createDirectMessage: jest.fn().mockResolvedValue({ rid: 'roomId' }), |
||||
}, |
||||
QueueWorker: { |
||||
queueWork: jest.fn(), |
||||
}, |
||||
Translation: { |
||||
translate: jest.fn().mockResolvedValue('translated message'), |
||||
translateToServerLanguage: jest.fn().mockResolvedValue('translated server message'), |
||||
translateMultipleToServerLanguage: jest.fn((keys) => keys.map((key: any) => ({ key, value: key }))), |
||||
}, |
||||
Settings: { |
||||
get: jest.fn().mockResolvedValue(''), |
||||
}, |
||||
})); |
||||
|
||||
jest.mock('@rocket.chat/models', () => ({ |
||||
LivechatRooms: { |
||||
findOneById: jest.fn().mockResolvedValue({}), |
||||
setTranscriptRequestedPdfById: jest.fn(), |
||||
unsetTranscriptRequestedPdfById: jest.fn(), |
||||
setPdfTranscriptFileIdById: jest.fn(), |
||||
}, |
||||
Messages: { |
||||
findLivechatMessagesWithoutTypes: jest.fn().mockReturnValue({ |
||||
toArray: jest.fn().mockResolvedValue([]), |
||||
}), |
||||
}, |
||||
Uploads: { |
||||
findOneById: jest.fn().mockResolvedValue({}), |
||||
}, |
||||
Users: { |
||||
findOneById: jest.fn().mockResolvedValue({}), |
||||
findOneAgentById: jest.fn().mockResolvedValue({}), |
||||
}, |
||||
LivechatVisitors: { |
||||
findOneEnabledById: jest.fn().mockResolvedValue({}), |
||||
}, |
||||
})); |
||||
|
||||
jest.mock('@rocket.chat/tools', () => ({ |
||||
guessTimezone: jest.fn().mockReturnValue('UTC'), |
||||
guessTimezoneFromOffset: jest.fn().mockReturnValue('UTC'), |
||||
streamToBuffer: jest.fn().mockResolvedValue(Buffer.from('')), |
||||
})); |
||||
|
||||
describe('OmnichannelTranscript', () => { |
||||
let omnichannelTranscript: OmnichannelTranscript; |
||||
|
||||
beforeEach(() => { |
||||
omnichannelTranscript = new OmnichannelTranscript(Logger); |
||||
}); |
||||
|
||||
it('should return default timezone', async () => { |
||||
const timezone = await omnichannelTranscript.getTimezone(); |
||||
expect(timezone).toBe('UTC'); |
||||
}); |
||||
|
||||
it('should parse the messages', async () => { |
||||
const parsedMessages = await omnichannelTranscript.getMessagesData(messages as unknown as IMessage[]); |
||||
console.log(parsedMessages[0]); |
||||
expect(parsedMessages).toBeDefined(); |
||||
expect(parsedMessages).toHaveLength(3); |
||||
expect(parsedMessages[0]).toHaveProperty('files'); |
||||
expect(parsedMessages[0].files).toHaveLength(0); |
||||
expect(parsedMessages[0]).toHaveProperty('quotes'); |
||||
expect(parsedMessages[0].quotes).toHaveLength(0); |
||||
}); |
||||
|
||||
it('should parse system message', async () => { |
||||
const parsedMessages = await omnichannelTranscript.getMessagesData([...messages, validSystemMessage] as unknown as IMessage[]); |
||||
const systemMessage = parsedMessages[3]; |
||||
expect(parsedMessages).toBeDefined(); |
||||
expect(parsedMessages).toHaveLength(4); |
||||
expect(systemMessage).toHaveProperty('t'); |
||||
expect(systemMessage.t).toBe('livechat-started'); |
||||
expect(systemMessage).toHaveProperty('msg'); |
||||
expect(systemMessage.msg).toBe('Chat_started'); |
||||
expect(systemMessage).toHaveProperty('files'); |
||||
expect(systemMessage.files).toHaveLength(0); |
||||
expect(systemMessage).toHaveProperty('quotes'); |
||||
expect(systemMessage.quotes).toHaveLength(0); |
||||
}); |
||||
|
||||
it('should parse an invalid system message', async () => { |
||||
const parsedMessages = await omnichannelTranscript.getMessagesData([...messages, invalidSystemMessage] as unknown as IMessage[]); |
||||
const systemMessage = parsedMessages[3]; |
||||
console.log(parsedMessages[3]); |
||||
expect(parsedMessages).toBeDefined(); |
||||
expect(parsedMessages).toHaveLength(4); |
||||
expect(systemMessage).toHaveProperty('t'); |
||||
expect(systemMessage.t).toBe('some-system-message'); |
||||
expect(systemMessage.msg).toBeUndefined(); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,239 @@ |
||||
import type { IOmnichannelSystemMessage, MessageTypesValues } from '@rocket.chat/core-typings'; |
||||
import { escapeHTML } from '@rocket.chat/string-helpers'; |
||||
import formatDistance from 'date-fns/formatDistance'; |
||||
import moment from 'moment'; |
||||
|
||||
const livechatSystemMessagesMap = new Map< |
||||
MessageTypesValues, |
||||
{ |
||||
message: string; |
||||
data?: (message: IOmnichannelSystemMessage, t: (k: string, obj?: Record<string, string>) => string) => Record<string, string>; |
||||
} |
||||
>(); |
||||
|
||||
const addType = ( |
||||
id: MessageTypesValues, |
||||
options: { |
||||
message: string; |
||||
data?: (message: IOmnichannelSystemMessage, t: (k: string, obj?: Record<string, string>) => string) => Record<string, string>; |
||||
}, |
||||
) => livechatSystemMessagesMap.set(id, options); |
||||
|
||||
export const getSystemMessage = (t: MessageTypesValues) => livechatSystemMessagesMap.get(t); |
||||
|
||||
export const getAllSystemMessagesKeys = () => Array.from(livechatSystemMessagesMap.values()).map((item) => item.message); |
||||
|
||||
addType('livechat-started', { message: 'Chat_started' }); |
||||
|
||||
addType('uj', { |
||||
message: 'User_joined_the_channel', |
||||
data(message) { |
||||
return { |
||||
user: message.u.username, |
||||
}; |
||||
}, |
||||
}); |
||||
|
||||
addType('livechat_video_call', { |
||||
message: 'New_videocall_request', |
||||
}); |
||||
|
||||
addType('livechat-close', { |
||||
message: 'Conversation_closed', |
||||
data(message) { |
||||
return { |
||||
comment: message.msg, |
||||
}; |
||||
}, |
||||
}); |
||||
|
||||
addType('livechat_navigation_history', { |
||||
message: 'New_visitor_navigation', |
||||
data(message: IOmnichannelSystemMessage) { |
||||
return { |
||||
history: message.navigation |
||||
? `${(message.navigation.page.title ? `${message.navigation.page.title} - ` : '') + message.navigation.page.location.href}` |
||||
: '', |
||||
}; |
||||
}, |
||||
}); |
||||
|
||||
addType('livechat_transfer_history', { |
||||
message: 'New_chat_transfer', |
||||
data(message: IOmnichannelSystemMessage, t) { |
||||
if (!message.transferData) { |
||||
return { |
||||
transfer: '', |
||||
}; |
||||
} |
||||
|
||||
const { comment } = message.transferData; |
||||
const commentLabel = comment && comment !== '' ? '_with_a_comment' : ''; |
||||
const from = |
||||
message.transferData.transferredBy && (message.transferData.transferredBy.name || message.transferData.transferredBy.username); |
||||
const transferTypes = { |
||||
agent: (): string => |
||||
t(`Livechat_transfer_to_agent${commentLabel}`, { |
||||
from, |
||||
to: message?.transferData?.transferredTo?.name || message?.transferData?.transferredTo?.username || '', |
||||
...(comment && { comment }), |
||||
}), |
||||
department: (): string => |
||||
t(`Livechat_transfer_to_department${commentLabel}`, { |
||||
from, |
||||
to: message?.transferData?.nextDepartment?.name || '', |
||||
...(comment && { comment }), |
||||
}), |
||||
queue: (): string => |
||||
t(`Livechat_transfer_return_to_the_queue${commentLabel}`, { |
||||
from, |
||||
...(comment && { comment }), |
||||
}), |
||||
autoTransferUnansweredChatsToAgent: (): string => |
||||
t(`Livechat_transfer_to_agent_auto_transfer_unanswered_chat`, { |
||||
from, |
||||
to: message?.transferData?.transferredTo?.name || message?.transferData?.transferredTo?.username || '', |
||||
duration: comment, |
||||
}), |
||||
autoTransferUnansweredChatsToQueue: (): string => |
||||
t(`Livechat_transfer_return_to_the_queue_auto_transfer_unanswered_chat`, { |
||||
from, |
||||
duration: comment, |
||||
}), |
||||
}; |
||||
return { |
||||
transfer: transferTypes[message.transferData.scope](), |
||||
}; |
||||
}, |
||||
}); |
||||
|
||||
addType('livechat_transcript_history', { |
||||
message: 'Livechat_chat_transcript_sent', |
||||
data(message: IOmnichannelSystemMessage, t) { |
||||
if (!message.requestData) { |
||||
return { |
||||
transcript: '', |
||||
}; |
||||
} |
||||
|
||||
const { requestData: { type, visitor, user } = { type: 'user' } } = message; |
||||
const requestTypes = { |
||||
visitor: (): string => |
||||
t('Livechat_visitor_transcript_request', { |
||||
guest: visitor?.name || visitor?.username || '', |
||||
}), |
||||
user: (): string => |
||||
t('Livechat_user_sent_chat_transcript_to_visitor', { |
||||
agent: user?.name || user?.username || '', |
||||
guest: visitor?.name || visitor?.username || '', |
||||
}), |
||||
}; |
||||
|
||||
return { |
||||
transcript: requestTypes[type](), |
||||
}; |
||||
}, |
||||
}); |
||||
|
||||
addType('livechat_webrtc_video_call', { |
||||
message: 'room_changed_type', |
||||
data(message: IOmnichannelSystemMessage, t) { |
||||
if (message.msg === 'ended' && message.webRtcCallEndTs && message.ts) { |
||||
return { |
||||
message: t('WebRTC_call_ended_message', { |
||||
callDuration: formatDistance(new Date(message.webRtcCallEndTs), new Date(message.ts)), |
||||
endTime: moment(message.webRtcCallEndTs).format('h:mm A'), |
||||
}), |
||||
}; |
||||
} |
||||
if (message.msg === 'declined' && message.webRtcCallEndTs) { |
||||
return { |
||||
message: t('WebRTC_call_declined_message'), |
||||
}; |
||||
} |
||||
return { |
||||
message: escapeHTML(message.msg), |
||||
}; |
||||
}, |
||||
}); |
||||
|
||||
addType('omnichannel_placed_chat_on_hold', { |
||||
message: 'Omnichannel_placed_chat_on_hold', |
||||
data(message: IOmnichannelSystemMessage) { |
||||
return { |
||||
comment: message.comment ? message.comment : 'No comment provided', |
||||
}; |
||||
}, |
||||
}); |
||||
|
||||
addType('omnichannel_on_hold_chat_resumed', { |
||||
message: 'Omnichannel_on_hold_chat_resumed', |
||||
data(message: IOmnichannelSystemMessage) { |
||||
return { |
||||
comment: message.comment ? message.comment : 'No comment provided', |
||||
}; |
||||
}, |
||||
}); |
||||
|
||||
addType('ul', { |
||||
message: 'User_left_this_channel', |
||||
}); |
||||
|
||||
addType('omnichannel_priority_change_history', { |
||||
message: 'omnichannel_priority_change_history', |
||||
data(message: IOmnichannelSystemMessage, t): Record<string, string> { |
||||
if (!message.priorityData) { |
||||
return { |
||||
user: t('Unknown_User'), |
||||
priority: t('Without_priority'), |
||||
}; |
||||
} |
||||
const { |
||||
definedBy: { username }, |
||||
priority: { name = null, i18n } = {}, |
||||
} = message.priorityData; |
||||
|
||||
return { |
||||
user: username || t('Unknown_User'), |
||||
priority: name || (i18n && t(i18n)) || t('Unprioritized'), |
||||
}; |
||||
}, |
||||
}); |
||||
|
||||
addType('livechat_transfer_history_fallback', { |
||||
message: 'New_chat_transfer_fallback', |
||||
data(message: any, t) { |
||||
if (!message.transferData) { |
||||
return { |
||||
fallback: 'SHOULD_NEVER_HAPPEN', |
||||
}; |
||||
} |
||||
const from = message.transferData.prevDepartment; |
||||
const to = message.transferData.department.name; |
||||
|
||||
return { |
||||
fallback: t('Livechat_transfer_failed_fallback', { from, to }), |
||||
}; |
||||
}, |
||||
}); |
||||
|
||||
addType('omnichannel_sla_change_history', { |
||||
message: 'omnichannel_sla_change_history', |
||||
data(message: IOmnichannelSystemMessage, t): Record<string, string> { |
||||
if (!message.slaData) { |
||||
return { |
||||
user: t('Unknown_User'), |
||||
priority: t('Without_SLA'), |
||||
}; |
||||
} |
||||
const { |
||||
definedBy: { username }, |
||||
sla: { name = null } = {}, |
||||
} = message.slaData; |
||||
|
||||
return { |
||||
user: username || t('Unknown_User'), |
||||
sla: name || t('Without_SLA'), |
||||
}; |
||||
}, |
||||
}); |
||||
@ -0,0 +1,45 @@ |
||||
import { Text, View, StyleSheet } from '@react-pdf/renderer'; |
||||
import { fontScales } from '@rocket.chat/fuselage-tokens/typography.json'; |
||||
|
||||
import type { PDFMessage } from '..'; |
||||
import { Markup } from '../markup'; |
||||
import { Divider } from './Divider'; |
||||
import { Files } from './Files'; |
||||
import { MessageHeader } from './MessageHeader'; |
||||
import { Quotes } from './Quotes'; |
||||
|
||||
const styles = StyleSheet.create({ |
||||
wrapper: { |
||||
marginBottom: 16, |
||||
paddingBottom: 16, |
||||
paddingHorizontal: 32, |
||||
}, |
||||
message: { |
||||
marginTop: 1, |
||||
fontSize: fontScales.p2.fontSize, |
||||
}, |
||||
systemMessage: { |
||||
fontStyle: 'italic', |
||||
}, |
||||
}); |
||||
|
||||
const messageLongerThanPage = (message: string) => message.length > 1200; |
||||
|
||||
const isSystemMessage = (message: PDFMessage) => !!message.t; |
||||
|
||||
const Message = ({ message, invalidFileMessage }: { message: PDFMessage; invalidFileMessage: string }) => ( |
||||
<View style={styles.wrapper}> |
||||
<View wrap={!!message.quotes || messageLongerThanPage(message.msg)}> |
||||
{message.divider && <Divider divider={message.divider} />} |
||||
<MessageHeader name={message.u.name || message.u.username} time={message.ts} /> |
||||
<View style={{ ...styles.message, ...(isSystemMessage(message) && styles.systemMessage) }}> |
||||
{message.md ? <Markup tokens={message.md} /> : <Text>{message.msg}</Text>} |
||||
</View> |
||||
{message.quotes && <Quotes quotes={message.quotes} />} |
||||
</View> |
||||
|
||||
{message.files && <Files files={message.files} invalidMessage={invalidFileMessage} />} |
||||
</View> |
||||
); |
||||
|
||||
export default Message; |
||||
@ -1,40 +1,10 @@ |
||||
import { Text, View, StyleSheet } from '@react-pdf/renderer'; |
||||
import { fontScales } from '@rocket.chat/fuselage-tokens/typography.json'; |
||||
|
||||
import type { ChatTranscriptData } from '..'; |
||||
import { Markup } from '../markup'; |
||||
import { Divider } from './Divider'; |
||||
import { Files } from './Files'; |
||||
import { MessageHeader } from './MessageHeader'; |
||||
import { Quotes } from './Quotes'; |
||||
|
||||
const styles = StyleSheet.create({ |
||||
wrapper: { |
||||
marginBottom: 16, |
||||
paddingBottom: 16, |
||||
paddingHorizontal: 32, |
||||
}, |
||||
message: { |
||||
marginTop: 1, |
||||
fontSize: fontScales.p2.fontSize, |
||||
}, |
||||
}); |
||||
|
||||
const messageLongerThanPage = (message: string) => message.length > 1200; |
||||
import Message from './Message'; |
||||
|
||||
export const MessageList = ({ messages, invalidFileMessage }: { messages: ChatTranscriptData['messages']; invalidFileMessage: string }) => ( |
||||
<> |
||||
{messages.map((message, index) => ( |
||||
<View key={index} style={styles.wrapper}> |
||||
<View wrap={!!message.quotes || messageLongerThanPage(message.msg)}> |
||||
{message.divider && <Divider divider={message.divider} />} |
||||
<MessageHeader name={message.u.name || message.u.username} time={message.ts} /> |
||||
<View style={styles.message}>{message.md ? <Markup tokens={message.md} /> : <Text>{message.msg}</Text>}</View> |
||||
{message.quotes && <Quotes quotes={message.quotes} />} |
||||
</View> |
||||
|
||||
{message.files && <Files files={message.files} invalidMessage={invalidFileMessage} />} |
||||
</View> |
||||
<Message invalidFileMessage={invalidFileMessage} message={message} key={index} /> |
||||
))} |
||||
</> |
||||
); |
||||
|
||||
@ -1,7 +1,8 @@ |
||||
import type { IUser } from '@rocket.chat/core-typings'; |
||||
|
||||
export interface ITranslationService { |
||||
translateText(text: string, targetLanguage: string): Promise<string>; |
||||
translateText(text: string, targetLanguage: string, args?: Record<string, string>): Promise<string>; |
||||
translate(text: string, user: IUser): Promise<string>; |
||||
translateToServerLanguage(text: string): Promise<string>; |
||||
translateToServerLanguage(text: string, args?: Record<string, string>): Promise<string>; |
||||
translateMultipleToServerLanguage(keys: string[]): Promise<Array<{ key: string; value: string }>>; |
||||
} |
||||
|
||||
Loading…
Reference in new issue