Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 2s
263 lines
7.8 KiB
TypeScript
263 lines
7.8 KiB
TypeScript
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/dist/colors.json';
|
|
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 { Meteor } from 'meteor/meteor';
|
|
import moment from 'moment-timezone';
|
|
|
|
import { settings } from '../../settings';
|
|
import { callbacks } from '../callbacks';
|
|
import { i18n } from '../i18n';
|
|
import { FileUpload } from '../media/file-upload';
|
|
import * as Mailer from '../notifications/email/api';
|
|
import { getTimezone } from '../utils/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<IUser, '_id' | 'name' | 'username' | 'utcOffset'> | null;
|
|
}): Promise<boolean> {
|
|
logger.debug({ msg: 'Sending conversation transcript', rid, token });
|
|
|
|
const room = await LivechatRooms.findOneById<Pick<IOmnichannelRoom, '_id' | 'v'>>(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<string>('Language') || 'en';
|
|
const timezone = getTimezone(user);
|
|
logger.debug({ msg: 'Transcript will be sent using timezone', timezone });
|
|
|
|
const showAgentInfo = settings.get<boolean>('Livechat_show_agent_info');
|
|
const showSystemMessages = settings.get<boolean>('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 = '<div> <hr>';
|
|
const InvalidFileMessage = `<div style="background-color: ${colors.n100}; text-align: center; border-color: ${
|
|
colors.n250
|
|
}; border-width: 1px; border-style: solid; border-radius: 4px; padding-top: 8px; padding-bottom: 8px; margin-top: 4px;">${i18n.t(
|
|
'This_attachment_is_not_supported',
|
|
{ lng: userLanguage },
|
|
)}</div>`;
|
|
|
|
function escapeHtml(str: string): string {
|
|
if (typeof str !== 'string') return '';
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.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(`
|
|
<i>${messageType.text(i18n.cloneInstance({ interpolation: { escapeValue: false } }).t, message)}}</i>`)
|
|
: 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 += `<div>${escapeHtml(attachment.title || '')}${InvalidFileMessage}</div>`;
|
|
continue;
|
|
}
|
|
|
|
const file = message.files?.find((file) => file.name === attachment.title);
|
|
|
|
if (!file) {
|
|
filesHTML += `<div>${escapeHtml(attachment.title || '')}${InvalidFileMessage}</div>`;
|
|
continue;
|
|
}
|
|
|
|
const uploadedFile = await Uploads.findOneById(file._id);
|
|
|
|
if (!uploadedFile) {
|
|
filesHTML += `<div>${escapeHtml(file.name)}${InvalidFileMessage}</div>`;
|
|
continue;
|
|
}
|
|
|
|
const uploadedFileBuffer = await FileUpload.getBuffer(uploadedFile);
|
|
filesHTML += `<div style="color: ${colors.n700}; margin-top: 4px; flex-direction: column;">
|
|
<p>${escapeHtml(file.name)}</p>
|
|
<img src="data:${attachment.image_type};base64,${uploadedFileBuffer.toString('base64')}" style="width: 400px; max-height: 240px; object-fit: contain; object-position: 0;" />
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
const datetime = moment.tz(message.ts, timezone).locale(userLanguage).format('LLL');
|
|
const singleMessage = `
|
|
<p><strong>${escapeHtml(author)}</strong> <em>${escapeHtml(datetime)}</em></p>
|
|
<p>${messageContent}</p>
|
|
<p>${filesHTML}</p>
|
|
`;
|
|
html += singleMessage;
|
|
}
|
|
|
|
html = `${html}</div>`;
|
|
|
|
const fromEmail = settings.get<string>('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<string>('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<string>('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<IOmnichannelSystemMessage>('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<IUser, '_id' | 'username' | 'utcOffset' | 'name'>;
|
|
}) {
|
|
// `v` is required by the MAC-limit check below
|
|
const room = await LivechatRooms.findOneById(rid, { projection: { _id: 1, open: 1, transcriptRequest: 1, v: 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;
|
|
}
|