import { Message, Omnichannel } from '@rocket.chat/core-services'; import { type IUser, type MessageTypesValues, type IOmnichannelSystemMessage, type ILivechatVisitor, type IOmnichannelRoom, isFileAttachment, isFileImageAttachment, type AtLeast, } from '@rocket.chat/core-typings'; import colors from '@rocket.chat/fuselage-tokens/colors'; import { Logger } from '@rocket.chat/logger'; import { MessageTypes } from '@rocket.chat/message-types'; import { LivechatRooms, Messages, Uploads, Users } from '@rocket.chat/models'; import createDOMPurify from 'dompurify'; import { JSDOM } from 'jsdom'; import moment from 'moment-timezone'; import { callbacks } from '../../../../server/lib/callbacks'; import { i18n } from '../../../../server/lib/i18n'; import { FileUpload } from '../../../file-upload/server'; import * as Mailer from '../../../mailer/server/api'; import { settings } from '../../../settings/server'; import { getTimezone } from '../../../utils/server/lib/getTimezone'; const logger = new Logger('Livechat-SendTranscript'); const DOMPurify = createDOMPurify(new JSDOM('').window); export async function sendTranscript({ token, rid, email, subject, user, }: { token: string; rid: string; email: string; subject?: string; user?: Pick | null; }): Promise { logger.debug(`Sending conversation transcript of room ${rid} to user with token ${token}`); const room = await LivechatRooms.findOneById>(rid, { projection: { _id: 1, v: 1 } }); if (!room) { throw new Error('error-invalid-room'); } const visitor = room?.v as ILivechatVisitor; if (token !== visitor?.token) { throw new Error('error-invalid-visitor'); } const userLanguage = settings.get('Language') || 'en'; const timezone = getTimezone(user); logger.debug(`Transcript will be sent using ${timezone} as timezone`); const showAgentInfo = settings.get('Livechat_show_agent_info'); const showSystemMessages = settings.get('Livechat_transcript_show_system_messages'); const closingMessage = await Messages.findLivechatClosingMessage(rid, { projection: { ts: 1 } }); const ignoredMessageTypes: MessageTypesValues[] = [ 'livechat_navigation_history', 'livechat_transcript_history', 'command', 'livechat-close', 'livechat-started', 'livechat_video_call', 'omnichannel_priority_change_history', ]; const messages = Messages.findVisibleByRoomIdNotContainingTypesBeforeTs( rid, ignoredMessageTypes, closingMessage?.ts ? new Date(closingMessage.ts) : new Date(), showSystemMessages, { sort: { ts: 1 }, }, ); let html = '

'; const InvalidFileMessage = `
${i18n.t( 'This_attachment_is_not_supported', { lng: userLanguage }, )}
`; function escapeHtml(str: string): string { if (typeof str !== 'string') return ''; return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') .replace(/\//g, '/'); } for await (const message of messages) { let author; if (message.u._id === visitor._id) { author = i18n.t('You', { lng: userLanguage }); } else { author = showAgentInfo ? message.u.name || message.u.username : i18n.t('Agent', { lng: userLanguage }); } const messageType = MessageTypes.getType(message); let messageContent = messageType?.system ? DOMPurify.sanitize(` ${messageType.text(i18n.cloneInstance({ interpolation: { escapeValue: false } }).t, message)}}`) : escapeHtml(message.msg); let filesHTML = ''; if (message.attachments && message.attachments?.length > 0) { messageContent = message.attachments[0].description || ''; escapeHtml(messageContent); for await (const attachment of message.attachments) { if (!isFileAttachment(attachment)) { continue; } if (!isFileImageAttachment(attachment)) { filesHTML += `
${escapeHtml(attachment.title || '')}${InvalidFileMessage}
`; continue; } const file = message.files?.find((file) => file.name === attachment.title); if (!file) { filesHTML += `
${escapeHtml(attachment.title || '')}${InvalidFileMessage}
`; continue; } const uploadedFile = await Uploads.findOneById(file._id); if (!uploadedFile) { filesHTML += `
${escapeHtml(file.name)}${InvalidFileMessage}
`; continue; } const uploadedFileBuffer = await FileUpload.getBuffer(uploadedFile); filesHTML += `

${escapeHtml(file.name)}

`; } } const datetime = moment.tz(message.ts, timezone).locale(userLanguage).format('LLL'); const singleMessage = `

${escapeHtml(author)} ${escapeHtml(datetime)}

${messageContent}

${filesHTML}

`; html += singleMessage; } html = `${html}
`; const fromEmail = settings.get('From_Email').match(/\b[A-Z0-9._%+-]+@(?:[A-Z0-9-]+\.)+[A-Z]{2,4}\b/i); let emailFromRegexp = ''; if (fromEmail) { emailFromRegexp = fromEmail[0]; } else { emailFromRegexp = settings.get('From_Email'); } // Some endpoints allow the caller to pass a different `subject` via parameter. // IF subject is passed, we'll use that one and treat it as an override // IF no subject is passed, we fallback to the setting `Livechat_transcript_email_subject` // IF that is not configured, we fallback to 'Transcript of your livechat conversation', which is the default value // As subject and setting value are user input, we don't translate them const mailSubject = subject || settings.get('Livechat_transcript_email_subject') || i18n.t('Transcript_of_your_livechat_conversation', { lng: userLanguage }); await Mailer.send({ to: email, from: emailFromRegexp, replyTo: emailFromRegexp, subject: mailSubject, html, }); setImmediate(() => { void callbacks.run('livechat.sendTranscript', messages, email); }); const requestData: IOmnichannelSystemMessage['requestData'] = { type: 'user', visitor, user, }; if (!user?.username) { const cat = await Users.findOneById('rocket.cat', { projection: { _id: 1, username: 1, name: 1 } }); if (cat) { requestData.user = cat; requestData.type = 'visitor'; } } if (!requestData.user) { logger.error('rocket.cat user not found'); throw new Error('No user provided and rocket.cat not found'); } await Message.saveSystemMessage('livechat_transcript_history', room._id, '', requestData.user, { requestData, }); return true; } export async function requestTranscript({ rid, email, subject, user, }: { rid: string; email: string; subject: string; user: AtLeast; }) { const room = await LivechatRooms.findOneById(rid, { projection: { _id: 1, open: 1, transcriptRequest: 1 } }); if (!room?.open) { throw new Meteor.Error('error-invalid-room', 'Invalid room'); } if (room.transcriptRequest) { throw new Meteor.Error('error-transcript-already-requested', 'Transcript already requested'); } if (!(await Omnichannel.isWithinMACLimit(room))) { throw new Error('error-mac-limit-reached'); } const { _id, username, name, utcOffset } = user; const transcriptRequest = { requestedAt: new Date(), requestedBy: { _id, username, name, utcOffset, }, email, subject, }; await LivechatRooms.setEmailTranscriptRequestedByRoomId(rid, transcriptRequest); return true; }