feat: Allow to change subject for email transcript (#32792)

pull/32752/head^2
Kevin Aleman 1 year ago committed by GitHub
parent 05db8aa223
commit b4bbcbfc9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      .changeset/cuddly-brooms-approve.md
  2. 200
      apps/meteor/app/livechat/server/lib/LivechatTyped.ts
  3. 212
      apps/meteor/app/livechat/server/lib/sendTranscript.ts
  4. 5
      apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx
  5. 7
      apps/meteor/server/settings/omnichannel.ts
  6. 205
      apps/meteor/tests/unit/app/livechat/server/lib/sendTranscript.spec.ts
  7. 2
      packages/i18n/src/locales/en.i18n.json

@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/i18n": minor
---
Allows admins to customize the `Subject` field of Omnichannel email transcripts via setting. By passing a value to the setting `Custom email subject for transcript`, system will use it as the `Subject` field, unless a custom subject is passed when requesting a transcript. If there's no custom subject and setting value is empty, the current default value will be used

@ -9,7 +9,6 @@ import type {
IUser,
MessageTypesValues,
ILivechatVisitor,
IOmnichannelSystemMessage,
SelectedAgent,
ILivechatAgent,
IMessage,
@ -23,8 +22,7 @@ import type {
LivechatDepartmentDTO,
OmnichannelSourceType,
} from '@rocket.chat/core-typings';
import { ILivechatAgentStatus, UserStatus, isFileAttachment, isFileImageAttachment, isOmnichannelRoom } from '@rocket.chat/core-typings';
import colors from '@rocket.chat/fuselage-tokens/colors.json';
import { ILivechatAgentStatus, UserStatus, isOmnichannelRoom } from '@rocket.chat/core-typings';
import { Logger, type MainLogger } from '@rocket.chat/logger';
import {
LivechatDepartment,
@ -38,12 +36,10 @@ import {
ReadReceipts,
Rooms,
LivechatCustomField,
Uploads,
} from '@rocket.chat/models';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import moment from 'moment-timezone';
import type { Filter, FindCursor } from 'mongodb';
import UAParser from 'ua-parser-js';
@ -70,12 +66,12 @@ import {
import * as Mailer from '../../../mailer/server/api';
import { metrics } from '../../../metrics/server';
import { settings } from '../../../settings/server';
import { getTimezone } from '../../../utils/server/lib/getTimezone';
import { businessHourManager } from '../business-hour';
import { parseAgentCustomFields, updateDepartmentAgents, validateEmail, normalizeTransferredByData } from './Helper';
import { QueueManager } from './QueueManager';
import { RoutingManager } from './RoutingManager';
import { isDepartmentCreationAvailable } from './isDepartmentCreationAvailable';
import { sendTranscript as sendTranscriptFunc } from './sendTranscript';
type RegisterGuestType = Partial<Pick<ILivechatVisitor, 'token' | 'name' | 'department' | 'status' | 'username'>> & {
id?: string;
@ -567,182 +563,6 @@ class LivechatClass {
}
}
async sendTranscript({
token,
rid,
email,
subject,
user,
}: {
token: string;
rid: string;
email: string;
subject?: string;
user?: Pick<IUser, '_id' | 'name' | 'username' | 'utcOffset'> | null;
}): Promise<boolean> {
check(rid, String);
check(email, String);
this.logger.debug(`Sending conversation transcript of room ${rid} to user with token ${token}`);
const room = await LivechatRooms.findOneById(rid);
const visitor = await LivechatVisitors.getVisitorByToken(token, {
projection: { _id: 1, token: 1, language: 1, username: 1, name: 1 },
});
if (!visitor) {
throw new Error('error-invalid-token');
}
// @ts-expect-error - Visitor typings should include language?
const userLanguage = visitor?.language || settings.get('Language') || 'en';
const timezone = getTimezone(user);
this.logger.debug(`Transcript will be sent using ${timezone} as timezone`);
if (!room) {
throw new Error('error-invalid-room');
}
// allow to only user to send transcripts from their own chats
if (room.t !== 'l' || !room.v || room.v.token !== token) {
throw new Error('error-invalid-room');
}
const showAgentInfo = settings.get<boolean>('Livechat_show_agent_info');
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',
];
const acceptableImageMimeTypes = ['image/jpeg', 'image/png', 'image/jpg'];
const messages = await Messages.findVisibleByRoomIdNotContainingTypesBeforeTs(
rid,
ignoredMessageTypes,
closingMessage?.ts ? new Date(closingMessage.ts) : new Date(),
{
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>`;
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 });
}
let messageContent = message.msg;
let filesHTML = '';
if (message.attachments && message.attachments?.length > 0) {
messageContent = message.attachments[0].description || '';
for await (const attachment of message.attachments) {
if (!isFileAttachment(attachment)) {
// ignore other types of attachments
continue;
}
if (!isFileImageAttachment(attachment)) {
filesHTML += `<div>${attachment.title || ''}${InvalidFileMessage}</div>`;
continue;
}
if (!attachment.image_type || !acceptableImageMimeTypes.includes(attachment.image_type)) {
filesHTML += `<div>${attachment.title || ''}${InvalidFileMessage}</div>`;
continue;
}
// Image attachment can be rendered in email body
const file = message.files?.find((file) => file.name === attachment.title);
if (!file) {
filesHTML += `<div>${attachment.title || ''}${InvalidFileMessage}</div>`;
continue;
}
const uploadedFile = await Uploads.findOneById(file._id);
if (!uploadedFile) {
filesHTML += `<div>${file.name}${InvalidFileMessage}</div>`;
continue;
}
const uploadedFileBuffer = await FileUpload.getBuffer(uploadedFile);
filesHTML += `<div styles="color: ${colors.n700}; margin-top: 4px; flex-direction: "column";"><p>${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>${author}</strong> <em>${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');
}
const mailSubject = subject || i18n.t('Transcript_of_your_livechat_conversation', { lng: userLanguage });
await this.sendEmail(emailFromRegexp, email, emailFromRegexp, 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) {
this.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;
}
async registerGuest({
id,
token,
@ -2041,6 +1861,22 @@ class LivechatClass {
return departmentDB;
}
async sendTranscript({
token,
rid,
email,
subject,
user,
}: {
token: string;
rid: string;
email: string;
subject?: string;
user?: Pick<IUser, '_id' | 'name' | 'username' | 'utcOffset'> | null;
}): Promise<boolean> {
return sendTranscriptFunc({ token, rid, email, subject, user });
}
}
export const Livechat = new LivechatClass();

@ -0,0 +1,212 @@
import { Message } from '@rocket.chat/core-services';
import {
type IUser,
type MessageTypesValues,
type IOmnichannelSystemMessage,
isFileAttachment,
isFileImageAttachment,
} from '@rocket.chat/core-typings';
import colors from '@rocket.chat/fuselage-tokens/colors';
import { Logger } from '@rocket.chat/logger';
import { LivechatRooms, LivechatVisitors, Messages, Uploads, Users } from '@rocket.chat/models';
import { check } from 'meteor/check';
import moment from 'moment-timezone';
import { callbacks } from '../../../../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');
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> {
check(rid, String);
check(email, String);
logger.debug(`Sending conversation transcript of room ${rid} to user with token ${token}`);
const room = await LivechatRooms.findOneById(rid);
const visitor = await LivechatVisitors.getVisitorByToken(token, {
projection: { _id: 1, token: 1, language: 1, username: 1, name: 1 },
});
if (!visitor) {
throw new Error('error-invalid-token');
}
// @ts-expect-error - Visitor typings should include language?
const userLanguage = visitor?.language || settings.get('Language') || 'en';
const timezone = getTimezone(user);
logger.debug(`Transcript will be sent using ${timezone} as timezone`);
if (!room) {
throw new Error('error-invalid-room');
}
// allow to only user to send transcripts from their own chats
if (room.t !== 'l' || !room.v || room.v.token !== token) {
throw new Error('error-invalid-room');
}
const showAgentInfo = settings.get<boolean>('Livechat_show_agent_info');
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',
];
const acceptableImageMimeTypes = ['image/jpeg', 'image/png', 'image/jpg'];
const messages = await Messages.findVisibleByRoomIdNotContainingTypesBeforeTs(
rid,
ignoredMessageTypes,
closingMessage?.ts ? new Date(closingMessage.ts) : new Date(),
{
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>`;
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 });
}
let messageContent = message.msg;
let filesHTML = '';
if (message.attachments && message.attachments?.length > 0) {
messageContent = message.attachments[0].description || '';
for await (const attachment of message.attachments) {
if (!isFileAttachment(attachment)) {
// ignore other types of attachments
continue;
}
if (!isFileImageAttachment(attachment)) {
filesHTML += `<div>${attachment.title || ''}${InvalidFileMessage}</div>`;
continue;
}
if (!attachment.image_type || !acceptableImageMimeTypes.includes(attachment.image_type)) {
filesHTML += `<div>${attachment.title || ''}${InvalidFileMessage}</div>`;
continue;
}
// Image attachment can be rendered in email body
const file = message.files?.find((file) => file.name === attachment.title);
if (!file) {
filesHTML += `<div>${attachment.title || ''}${InvalidFileMessage}</div>`;
continue;
}
const uploadedFile = await Uploads.findOneById(file._id);
if (!uploadedFile) {
filesHTML += `<div>${file.name}${InvalidFileMessage}</div>`;
continue;
}
const uploadedFileBuffer = await FileUpload.getBuffer(uploadedFile);
filesHTML += `<div styles="color: ${colors.n700}; margin-top: 4px; flex-direction: "column";"><p>${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>${author}</strong> <em>${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;
}

@ -52,6 +52,7 @@ const CloseChatModal = ({
} = useForm();
const commentRequired = useSetting('Livechat_request_comment_when_closing_conversation') as boolean;
const customSubject = useSetting<string>('Livechat_transcript_email_subject');
const [tagRequired, setTagRequired] = useState(false);
const tags = watch('tags');
@ -132,9 +133,9 @@ const CloseChatModal = ({
dispatchToastMessage({ type: 'error', message: t('Customer_without_registered_email') });
return;
}
setValue('subject', subject || t('Transcript_of_your_livechat_conversation'));
setValue('subject', subject || customSubject || t('Transcript_of_your_livechat_conversation'));
}
}, [transcriptEmail, setValue, visitorEmail, subject, t]);
}, [transcriptEmail, setValue, visitorEmail, subject, t, customSubject]);
if (commentRequired || tagRequired || canSendTranscript) {
return (

@ -420,6 +420,13 @@ export const createOmniSettings = () =>
enableQuery: [{ _id: 'Livechat_enable_transcript', value: true }, omnichannelEnabledQuery],
});
await this.add('Livechat_transcript_email_subject', '', {
type: 'string',
group: 'Omnichannel',
public: true,
enableQuery: omnichannelEnabledQuery,
});
await this.add('Omnichannel_enable_department_removal', false, {
type: 'boolean',
group: 'Omnichannel',

@ -0,0 +1,205 @@
import { expect } from 'chai';
import p from 'proxyquire';
import sinon from 'sinon';
const modelsMock = {
LivechatRooms: {
findOneById: sinon.stub(),
},
LivechatVisitors: {
getVisitorByToken: sinon.stub(),
},
Messages: {
findLivechatClosingMessage: sinon.stub(),
findVisibleByRoomIdNotContainingTypesBeforeTs: sinon.stub(),
},
Users: {
findOneById: sinon.stub(),
},
};
const checkMock = sinon.stub();
const mockLogger = class {
debug() {
return null;
}
error() {
return null;
}
warn() {
return null;
}
info() {
return null;
}
};
const mockSettingValues: Record<string, any> = {
Livechat_show_agent_info: true,
Language: 'en',
From_Email: 'test@rocket.chat',
};
const settingsMock = function (key: string) {
return mockSettingValues[key] || null;
};
const getTimezoneMock = sinon.stub();
const mailerMock = sinon.stub();
const tStub = sinon.stub();
const { sendTranscript } = p.noCallThru().load('../../../../../../app/livechat/server/lib/sendTranscript', {
'@rocket.chat/models': modelsMock,
'@rocket.chat/logger': { Logger: mockLogger },
'meteor/check': { check: checkMock },
'../../../../lib/callbacks': {
callbacks: {
run: sinon.stub(),
},
},
'../../../../server/lib/i18n': { i18n: { t: tStub } },
'../../../mailer/server/api': { send: mailerMock },
'../../../settings/server': { settings: { get: settingsMock } },
'../../../utils/server/lib/getTimezone': { getTimezone: getTimezoneMock },
// TODO: add tests for file handling on transcripts
'../../../file-upload/server': { FileUpload: {} },
});
describe('Send transcript', () => {
beforeEach(() => {
checkMock.reset();
modelsMock.LivechatRooms.findOneById.reset();
modelsMock.LivechatVisitors.getVisitorByToken.reset();
modelsMock.Messages.findLivechatClosingMessage.reset();
modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.reset();
modelsMock.Users.findOneById.reset();
mailerMock.reset();
tStub.reset();
});
it('should throw error when rid or email are invalid params', async () => {
checkMock.throws(new Error('Invalid params'));
await expect(sendTranscript({})).to.be.rejectedWith(Error);
});
it('should throw error when visitor not found', async () => {
modelsMock.LivechatVisitors.getVisitorByToken.resolves(null);
await expect(sendTranscript({ rid: 'rid', email: 'email', logger: mockLogger })).to.be.rejectedWith(Error);
});
it('should attempt to send an email when params are valid using default subject', async () => {
modelsMock.LivechatVisitors.getVisitorByToken.resolves({ language: null });
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'token' } });
modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.resolves([]);
tStub.returns('Conversation Transcript');
await sendTranscript({
rid: 'rid',
token: 'token',
email: 'email',
user: { _id: 'x', name: 'x', utcOffset: '-6', username: 'x' },
});
expect(getTimezoneMock.calledWith({ _id: 'x', name: 'x', utcOffset: '-6', username: 'x' })).to.be.true;
expect(modelsMock.Messages.findLivechatClosingMessage.calledWith('rid', { projection: { ts: 1 } })).to.be.true;
expect(modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.called).to.be.true;
expect(
mailerMock.calledWith({
to: 'email',
from: 'test@rocket.chat',
subject: 'Conversation Transcript',
replyTo: 'test@rocket.chat',
html: '<div> <hr></div>',
}),
).to.be.true;
});
it('should use provided subject', async () => {
modelsMock.LivechatVisitors.getVisitorByToken.resolves({ language: null });
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'token' } });
modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.resolves([]);
await sendTranscript({
rid: 'rid',
token: 'token',
email: 'email',
subject: 'A custom subject',
user: { _id: 'x', name: 'x', utcOffset: '-6', username: 'x' },
});
expect(getTimezoneMock.calledWith({ _id: 'x', name: 'x', utcOffset: '-6', username: 'x' })).to.be.true;
expect(modelsMock.Messages.findLivechatClosingMessage.calledWith('rid', { projection: { ts: 1 } })).to.be.true;
expect(modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.called).to.be.true;
expect(
mailerMock.calledWith({
to: 'email',
from: 'test@rocket.chat',
subject: 'A custom subject',
replyTo: 'test@rocket.chat',
html: '<div> <hr></div>',
}),
).to.be.true;
});
it('should use subject from setting (when configured) when no subject provided', async () => {
modelsMock.LivechatVisitors.getVisitorByToken.resolves({ language: null });
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'token' } });
modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.resolves([]);
mockSettingValues.Livechat_transcript_email_subject = 'A custom subject obtained from setting.get';
await sendTranscript({
rid: 'rid',
token: 'token',
email: 'email',
user: { _id: 'x', name: 'x', utcOffset: '-6', username: 'x' },
});
expect(getTimezoneMock.calledWith({ _id: 'x', name: 'x', utcOffset: '-6', username: 'x' })).to.be.true;
expect(modelsMock.Messages.findLivechatClosingMessage.calledWith('rid', { projection: { ts: 1 } })).to.be.true;
expect(modelsMock.Messages.findVisibleByRoomIdNotContainingTypesBeforeTs.called).to.be.true;
expect(
mailerMock.calledWith({
to: 'email',
from: 'test@rocket.chat',
subject: 'A custom subject obtained from setting.get',
replyTo: 'test@rocket.chat',
html: '<div> <hr></div>',
}),
).to.be.true;
});
it('should fail if room provided is invalid', async () => {
modelsMock.LivechatRooms.findOneById.resolves(null);
modelsMock.LivechatVisitors.getVisitorByToken.resolves({ language: null });
await expect(sendTranscript({ rid: 'rid', email: 'email', logger: mockLogger })).to.be.rejectedWith(Error);
});
it('should fail if room provided is of different type', async () => {
modelsMock.LivechatRooms.findOneById.resolves({ t: 'c' });
modelsMock.LivechatVisitors.getVisitorByToken.resolves({ language: null });
await expect(sendTranscript({ rid: 'rid', email: 'email' })).to.be.rejectedWith(Error);
});
it('should fail if room is of valid type, but doesnt doesnt have `v` property', async () => {
modelsMock.LivechatVisitors.getVisitorByToken.resolves({ language: null });
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l' });
await expect(sendTranscript({ rid: 'rid', email: 'email' })).to.be.rejectedWith(Error);
});
it('should fail if room is of valid type, has `v` prop, but it doesnt contain `token`', async () => {
modelsMock.LivechatVisitors.getVisitorByToken.resolves({ language: null });
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { otherProp: 'xxx' } });
await expect(sendTranscript({ rid: 'rid', email: 'email' })).to.be.rejectedWith(Error);
});
it('should fail if room is of valid type, has `v.token`, but its different from the one on param (room from another visitor)', async () => {
modelsMock.LivechatVisitors.getVisitorByToken.resolves({ language: null });
modelsMock.LivechatRooms.findOneById.resolves({ t: 'l', v: { token: 'xxx' } });
await expect(sendTranscript({ rid: 'rid', email: 'email', token: 'xveasdf' })).to.be.rejectedWith(Error);
});
});

@ -3271,6 +3271,8 @@
"Livechat_email_transcript_has_been_requested": "The transcript has been requested. It may take a few seconds.",
"Livechat_transcript_request_has_been_canceled": "The chat transcription request has been canceled.",
"Livechat_transcript_sent": "Omnichannel transcript sent",
"Livechat_transcript_email_subject": "Custom email subject for transcript",
"Livechat_transcript_email_subject_Description": "Allows to customize the email subject for transcripts sent via email. It can be overriden by passing a `subject` property when closing a room. Leave it empty to use default subject.",
"Livechat_transfer_return_to_the_queue": "{{from}} returned the chat to the queue",
"Livechat_transfer_return_to_the_queue_with_a_comment": "{{from}} returned the chat to the queue with a comment: {{comment}}",
"Livechat_transfer_return_to_the_queue_auto_transfer_unanswered_chat": "{{from}} returned the chat to the queue since it was unanswered for {{duration}} seconds",

Loading…
Cancel
Save