Files
upstream-mirror 30810eecd7
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
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;')
.replace(/\//g, '&#x2F;');
}
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;
}