feat(Omnichannel): System messages in transcripts (#32752)

pull/32803/head^2
Yash Rajpal 1 year ago committed by GitHub
parent 0de8ee18c7
commit 03c8b066f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 12
      .changeset/new-scissors-love.md
  2. 7
      .changeset/weak-pets-talk.md
  3. 17
      apps/meteor/app/livechat/server/lib/sendTranscript.ts
  4. 32
      apps/meteor/server/models/raw/Messages.ts
  5. 17
      apps/meteor/server/services/translation/service.ts
  6. 7
      apps/meteor/server/settings/omnichannel.ts
  7. 9
      ee/packages/omnichannel-services/jest.config.ts
  8. 1
      ee/packages/omnichannel-services/package.json
  9. 70
      ee/packages/omnichannel-services/src/OmnichannelTranscript.fixtures.ts
  10. 119
      ee/packages/omnichannel-services/src/OmnichannelTranscript.spec.ts
  11. 172
      ee/packages/omnichannel-services/src/OmnichannelTranscript.ts
  12. 239
      ee/packages/omnichannel-services/src/livechatSystemMessages.ts
  13. 6
      ee/packages/pdf-worker/src/strategies/ChatTranscript.spec.ts
  14. 14
      ee/packages/pdf-worker/src/templates/ChatTranscript/ChatTranscript.fixtures.ts
  15. 45
      ee/packages/pdf-worker/src/templates/ChatTranscript/components/Message.tsx
  16. 7
      ee/packages/pdf-worker/src/templates/ChatTranscript/components/MessageList.spec.tsx
  17. 34
      ee/packages/pdf-worker/src/templates/ChatTranscript/components/MessageList.tsx
  18. 12
      ee/packages/pdf-worker/src/templates/ChatTranscript/index.tsx
  19. 54
      ee/packages/pdf-worker/src/worker.fixtures.ts
  20. 14
      ee/packages/pdf-worker/src/worker.spec.ts
  21. 5
      packages/core-services/src/types/ITranslationService.ts
  22. 1
      packages/i18n/src/locales/en.i18n.json
  23. 8
      packages/model-typings/src/models/IMessagesModel.ts
  24. 1
      yarn.lock

@ -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.

@ -17,6 +17,7 @@ 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 { MessageTypes } from '../../../ui-utils/lib/MessageTypes';
import { getTimezone } from '../../../utils/server/lib/getTimezone';
const logger = new Logger('Livechat-SendTranscript');
@ -63,6 +64,7 @@ export async function sendTranscript({
}
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',
@ -71,12 +73,14 @@ export async function sendTranscript({
'livechat-close',
'livechat-started',
'livechat_video_call',
'omnichannel_priority_change_history',
];
const acceptableImageMimeTypes = ['image/jpeg', 'image/png', 'image/jpg'];
const messages = await Messages.findVisibleByRoomIdNotContainingTypesBeforeTs(
rid,
ignoredMessageTypes,
closingMessage?.ts ? new Date(closingMessage.ts) : new Date(),
showSystemMessages,
{
sort: { ts: 1 },
},
@ -98,7 +102,18 @@ export async function sendTranscript({
author = showAgentInfo ? message.u.name || message.u.username : i18n.t('Agent', { lng: userLanguage });
}
let messageContent = message.msg;
const isSystemMessage = MessageTypes.isSystemMessage(message);
const messageType = isSystemMessage && MessageTypes.getType(message);
let messageContent = messageType
? `<i>${i18n.t(
messageType.message,
messageType.data
? { ...messageType.data(message), interpolation: { escapeValue: false } }
: { interpolation: { escapeValue: false } },
)}</i>`
: message.msg;
let filesHTML = '';
if (message.attachments && message.attachments?.length > 0) {

@ -364,6 +364,7 @@ export class MessagesRaw extends BaseRaw<IMessage> implements IMessagesModel {
roomId: IRoom['_id'],
types: IMessage['t'][],
ts: Date,
showSystemMessages: boolean,
options?: FindOptions<IMessage>,
showThreadMessages = true,
): FindCursor<IMessage> {
@ -389,6 +390,10 @@ export class MessagesRaw extends BaseRaw<IMessage> implements IMessagesModel {
query.t = { $nin: types };
}
if (!showSystemMessages) {
query.t = { $exists: false };
}
return this.find(query, options);
}
@ -424,14 +429,25 @@ export class MessagesRaw extends BaseRaw<IMessage> implements IMessagesModel {
return this.find(query, options);
}
findLivechatMessagesWithoutClosing(rid: IRoom['_id'], options?: FindOptions<IMessage>): FindCursor<IMessage> {
return this.find(
{
rid,
t: { $exists: false },
},
options,
);
findLivechatMessagesWithoutTypes(
rid: IRoom['_id'],
ignoredTypes: IMessage['t'][],
showSystemMessages: boolean,
options?: FindOptions<IMessage>,
): FindCursor<IMessage> {
const query: Filter<IMessage> = {
rid,
};
if (ignoredTypes.length > 0) {
query.t = { $nin: ignoredTypes };
}
if (!showSystemMessages) {
query.t = { $exists: false };
}
return this.find(query, options);
}
async setBlocksById(_id: string, blocks: Required<IMessage>['blocks']): Promise<void> {

@ -17,8 +17,8 @@ export class TranslationService extends ServiceClassInternal implements ITransla
}
// Use translateText when you already know the language, or want to translate to a predefined language
translateText(text: string, targetLanguage: string): Promise<string> {
return Promise.resolve(i18n.t(text, { lng: targetLanguage }));
translateText(text: string, targetLanguage: string, args?: Record<string, string>): Promise<string> {
return Promise.resolve(i18n.t(text, { lng: targetLanguage, ...args }));
}
// Use translate when you want to translate to the user's language, or server's as a fallback
@ -28,9 +28,18 @@ export class TranslationService extends ServiceClassInternal implements ITransla
return this.translateText(text, language);
}
async translateToServerLanguage(text: string): Promise<string> {
async translateToServerLanguage(text: string, args?: Record<string, string>): Promise<string> {
const language = await this.getServerLanguageCached();
return this.translateText(text, language);
return this.translateText(text, language, args);
}
async translateMultipleToServerLanguage(keys: string[]): Promise<Array<{ key: string; value: string }>> {
const language = await this.getServerLanguageCached();
return keys.map((key) => ({
key,
value: i18n.t(key, { lng: language, fallbackLng: 'en' }),
}));
}
}

@ -412,6 +412,13 @@ export const createOmniSettings = () =>
enableQuery: omnichannelEnabledQuery,
});
await this.add('Livechat_transcript_show_system_messages', false, {
type: 'boolean',
group: 'Omnichannel',
public: true,
enableQuery: omnichannelEnabledQuery,
});
await this.add('Livechat_transcript_message', '', {
type: 'string',
group: 'Omnichannel',

@ -0,0 +1,9 @@
export default {
preset: 'ts-jest',
errorOnDeprecated: true,
testEnvironment: 'jsdom',
modulePathIgnorePatterns: ['<rootDir>/dist/'],
moduleNameMapper: {
'\\.css$': 'identity-obj-proxy',
},
};

@ -22,6 +22,7 @@
"@rocket.chat/string-helpers": "~0.31.25",
"@rocket.chat/tools": "workspace:^",
"@types/node": "^14.18.63",
"date-fns": "^2.28.0",
"ejson": "^2.2.3",
"emoji-toolkit": "^7.0.1",
"eventemitter3": "^4.0.7",

@ -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();
});
});

@ -10,7 +10,16 @@ import {
Settings as settingsService,
} from '@rocket.chat/core-services';
import type { IOmnichannelTranscriptService } from '@rocket.chat/core-services';
import type { IMessage, IUser, IRoom, IUpload, ILivechatVisitor, ILivechatAgent, IOmnichannelRoom } from '@rocket.chat/core-typings';
import type {
IMessage,
IUser,
IRoom,
IUpload,
ILivechatVisitor,
ILivechatAgent,
IOmnichannelRoom,
IOmnichannelSystemMessage,
} from '@rocket.chat/core-typings';
import { isQuoteAttachment, isFileAttachment, isFileImageAttachment } from '@rocket.chat/core-typings';
import type { Logger } from '@rocket.chat/logger';
import { parse } from '@rocket.chat/message-parser';
@ -19,6 +28,8 @@ import { LivechatRooms, Messages, Uploads, Users, LivechatVisitors } from '@rock
import { PdfWorker } from '@rocket.chat/pdf-worker';
import { guessTimezone, guessTimezoneFromOffset, streamToBuffer } from '@rocket.chat/tools';
import { getAllSystemMessagesKeys, getSystemMessage } from './livechatSystemMessages';
const isPromiseRejectedResult = (result: any): result is PromiseRejectedResult => result.status === 'rejected';
type WorkDetails = {
@ -32,7 +43,22 @@ type WorkDetailsWithSource = WorkDetails & {
type Quote = { name: string; ts?: Date; md: Root };
type MessageData = Pick<IMessage, '_id' | 'ts' | 'u' | 'msg' | 'md'> & {
export type MessageData = Pick<
IOmnichannelSystemMessage,
| 'msg'
| '_id'
| 'u'
| 'ts'
| 'md'
| 't'
| 'navigation'
| 'transferData'
| 'requestData'
| 'webRtcCallEndTs'
| 'comment'
| 'slaData'
| 'priorityData'
> & {
files: ({ name?: string; buffer: Buffer | null; extension?: string } | undefined)[];
quotes: (Quote | undefined)[];
};
@ -49,6 +75,12 @@ type WorkerData = {
translations: { key: string; value: string }[];
};
const customSprintfInterpolation = (template: string, values: Record<string, string>) => {
return template.replace(/{{(\w+)}}/g, (match, key) => {
return typeof values[key] !== 'undefined' ? values[key] : match;
});
};
export class OmnichannelTranscript extends ServiceClass implements IOmnichannelTranscriptService {
protected name = 'omnichannel-transcript';
@ -56,6 +88,10 @@ export class OmnichannelTranscript extends ServiceClass implements IOmnichannelT
private log: Logger;
// this is initialized as undefined and will be set when the first pdf is requested.
// if we try to initialize it at the start of the service using IIAFE, for some reason i18next doesn't return translations, maybe i18n isn't initialised yet
private translations?: Array<{ key: string; value: string }> = undefined;
maxNumberOfConcurrentJobs = 25;
currentJobNumber = 0;
@ -83,11 +119,29 @@ export class OmnichannelTranscript extends ServiceClass implements IOmnichannelT
}
}
private getMessagesFromRoom({ rid }: { rid: string }): Promise<IMessage[]> {
private async getMessagesFromRoom({ rid }: { rid: string }): Promise<IMessage[]> {
const showSystemMessages = await settingsService.get<boolean>('Livechat_transcript_show_system_messages');
// Closing message should not appear :)
return Messages.findLivechatMessagesWithoutClosing(rid, {
return Messages.findLivechatMessagesWithoutTypes(rid, ['command'], showSystemMessages, {
sort: { ts: 1 },
projection: { _id: 1, msg: 1, u: 1, t: 1, ts: 1, attachments: 1, files: 1, md: 1 },
projection: {
_id: 1,
msg: 1,
u: 1,
t: 1,
ts: 1,
attachments: 1,
files: 1,
md: 1,
navigation: 1,
requestData: 1,
transferData: 1,
webRtcCallEndTs: 1,
comment: 1,
priorityData: 1,
slaData: 1,
},
}).toArray();
}
@ -159,22 +213,49 @@ export class OmnichannelTranscript extends ServiceClass implements IOmnichannelT
return quotes;
}
private async getMessagesData(messages: IMessage[]): Promise<MessageData[]> {
private getSystemMessage(message: IMessage): false | MessageData {
if (!message.t) {
return false;
}
const systemMessageDefinition = getSystemMessage(message.t);
if (!systemMessageDefinition) {
return false;
}
const args = systemMessageDefinition.data && systemMessageDefinition?.data(message, this.getTranslation.bind(this));
const systemMessage = this.getTranslation(systemMessageDefinition.message, args);
return {
...message,
msg: systemMessage,
files: [],
quotes: [],
};
}
async getMessagesData(messages: IMessage[]): Promise<MessageData[]> {
const messagesData: MessageData[] = [];
for await (const message of messages) {
const systemMessage = this.getSystemMessage(message);
if (systemMessage) {
messagesData.push(systemMessage);
continue;
}
if (!message.attachments?.length) {
// If there's no attachment and no message, what was sent? lol
messagesData.push({
_id: message._id,
...message,
files: [],
quotes: [],
ts: message.ts,
u: message.u,
msg: message.msg,
md: message.md,
});
continue;
}
const files = [];
const quotes = [];
@ -267,17 +348,61 @@ export class OmnichannelTranscript extends ServiceClass implements IOmnichannelT
return messagesData;
}
private async getTranslations(): Promise<Array<{ key: string; value: string }>> {
const keys: string[] = ['Agent', 'Date', 'Customer', 'Not_assigned', 'Time', 'Chat_transcript', 'This_attachment_is_not_supported'];
private async getAllTranslations(): Promise<Array<{ key: string; value: string }>> {
const keys: string[] = [
'Agent',
'Date',
'Customer',
'Not_assigned',
'Time',
'Chat_transcript',
'This_attachment_is_not_supported',
'Livechat_transfer_to_agent',
'Livechat_transfer_to_agent_with_a_comment',
'Livechat_transfer_to_department',
'Livechat_transfer_to_department_with_a_comment',
'Livechat_transfer_return_to_the_queue',
'Livechat_transfer_return_to_the_queue_with_a_comment',
'Livechat_transfer_to_agent_auto_transfer_unanswered_chat',
'Livechat_transfer_return_to_the_queue_auto_transfer_unanswered_chat',
'Livechat_visitor_transcript_request',
'Livechat_user_sent_chat_transcript_to_visitor',
'WebRTC_call_ended_message',
'WebRTC_call_declined_message',
'Without_SLA',
'Unknown_User',
'Livechat_transfer_failed_fallback',
'Unprioritized',
'Unknown_User',
'Without_priority',
...getAllSystemMessagesKeys(),
];
return translationService.translateMultipleToServerLanguage(keys);
}
return Promise.all(
keys.map(async (key) => {
return {
key,
value: await translationService.translateToServerLanguage(key),
};
}),
);
private getTranslation(translationKey: string, args?: Record<string, string>): string {
const translationValue = this.translations?.find(({ key }) => key === translationKey)?.value;
if (!translationValue) {
return translationKey;
}
if (!args) {
return translationValue;
}
const translation = customSprintfInterpolation(translationValue, args);
return translation;
}
private async loadTranslations() {
if (!this.translations) {
this.translations = await this.getAllTranslations();
}
return this.translations;
}
async workOnPdf({ details }: { details: WorkDetailsWithSource }): Promise<void> {
@ -300,14 +425,15 @@ export class OmnichannelTranscript extends ServiceClass implements IOmnichannelT
const agent =
room.servedBy && (await Users.findOneAgentById(room.servedBy._id, { projection: { _id: 1, name: 1, username: 1, utcOffset: 1 } }));
const translations = await this.loadTranslations();
const messagesData = await this.getMessagesData(messages);
const [siteName, dateFormat, timeAndDateFormat, timezone, translations] = await Promise.all([
const [siteName, dateFormat, timeAndDateFormat, timezone] = await Promise.all([
settingsService.get<string>('Site_Name'),
settingsService.get<string>('Message_DateFormat'),
settingsService.get<string>('Message_TimeAndDateFormat'),
this.getTimezone(agent),
this.getTranslations(),
]);
const data = {
visitor,

@ -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'),
};
},
});

@ -60,4 +60,10 @@ describe('Strategies/ChatTranscript', () => {
result.t('invalidKey');
}).toThrow('Translation not found for key: invalidKey');
});
it('should parse the system message', () => {
const data = { ...validData, translations: translationsData.translations };
const result = chatTranscript.parseTemplateData(data);
expect(result.messages[2]).toHaveProperty('t', 'livechat-started');
});
});

@ -1,3 +1,5 @@
import type { MessageTypesValues } from '@rocket.chat/core-typings';
import type { Data } from '../../types/Data';
const base64Image =
@ -14,6 +16,7 @@ export const validData = {
messages: [
{ ts: '2022-11-21T16:00:00.000Z', text: 'Hello, how can I help you today?' },
{ ts: '2022-11-21T16:00:00.000Z', text: 'I am having trouble with my account.' },
{ ts: '2022-11-21T16:00:00.000Z', t: 'livechat-started' },
],
translations: [
{ key: 'transcript', value: 'Transcript' },
@ -73,6 +76,17 @@ export const validMessage = {
},
};
export const validSystemMessage = {
msg: 'livechat-started',
ts: '2022-11-21T16:00:00.000Z',
u: {
_id: '123',
name: 'Juanito De Ponce',
username: 'juanito.ponce',
},
t: 'livechat-started' as MessageTypesValues,
};
export const exampleData = {
agent: {
name: 'Juanito De Ponce',

@ -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,7 +1,7 @@
import { render } from '@testing-library/react';
import '@testing-library/jest-dom';
import { invalidFile, validFile, validMessage } from '../ChatTranscript.fixtures';
import { invalidFile, validFile, validMessage, validSystemMessage } from '../ChatTranscript.fixtures';
import { MessageList } from './MessageList';
jest.mock('@react-pdf/renderer', () => ({
@ -37,4 +37,9 @@ describe('components/MessageList', () => {
expect(getByText(validMessage.msg)).toBeInTheDocument();
expect(getByText('invalid message')).toBeInTheDocument();
});
it('should render valid system message', () => {
const { getByText } = render(<MessageList messages={[{ ...validSystemMessage, files: [] }]} invalidFileMessage='' />);
expect(getByText(validSystemMessage.t)).toBeInTheDocument();
});
});

@ -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,7 @@
import * as path from 'path';
import ReactPDF, { Font, Document, Page, StyleSheet } from '@react-pdf/renderer';
import type { ILivechatAgent, ILivechatVisitor, IMessage, Serialized } from '@rocket.chat/core-typings';
import type { ILivechatAgent, ILivechatVisitor, IOmnichannelSystemMessage, Serialized } from '@rocket.chat/core-typings';
import colors from '@rocket.chat/fuselage-tokens/colors.json';
import type { Root } from '@rocket.chat/message-parser';
@ -14,7 +14,15 @@ export type PDFFile = { name?: string; buffer: Buffer | null; extension?: 'png'
export type Quote = { md: Root; name: string; ts: string };
export type PDFMessage = Serialized<Omit<Pick<IMessage, 'msg' | 'u' | 'ts' | 'md'>, 'files'>> & {
export type PDFMessage = Serialized<
Omit<
Pick<
IOmnichannelSystemMessage,
'msg' | 'u' | 'ts' | 'md' | 't' | 'navigation' | 'transferData' | 'requestData' | 'webRtcCallEndTs' | 'comment'
>,
'files'
>
> & {
files?: PDFFile[];
} & { divider?: string } & { quotes?: Quote[] };

@ -714,3 +714,57 @@ Maecenas pretium, sem non eleifend sodales, sapien ligula sollicitudin mauris, v
},
],
};
export const dataWithASingleSystemMessage = {
agent: {
name: 'Juanito De Ponce',
username: 'juanito.ponce',
},
visitor: {
name: 'Christian Castro',
username: 'christian.castro',
},
siteName: 'Rocket.Chat',
closedAt: '2022-11-21T00:00:00.000Z',
dateFormat: 'MMM D, YYYY',
timeAndDateFormat: 'MMM D, YYYY H:mm:ss',
timezone: 'Etc/GMT+1',
translations: [
{
key: 'Agent',
value: 'Agent',
},
{
key: 'Date',
value: 'Date',
},
{
key: 'Customer',
value: 'Customer',
},
{
key: 'Chat_transcript',
value: 'Chat transcript',
},
{
key: 'Time',
value: 'Time',
},
{
key: 'This_attachment_is_not_supported',
value: 'Attachment format not supported',
},
],
messages: [
{
ts: '2022-11-21T16:00:00.000Z',
u: {
_id: '123',
name: 'Juanito De Ponce',
username: 'juanito.ponce',
},
t: 'livechat-started',
msg: 'livechat started',
},
],
};

@ -6,6 +6,7 @@ import {
dataWithASingleMessageButAReallyLongMessage,
dataWithMultipleMessagesAndABigMessage,
dataWithASingleMessageAndAnImage,
dataWithASingleSystemMessage,
} from './worker.fixtures';
const streamToBuffer = async (stream: NodeJS.ReadableStream) => {
@ -17,6 +18,12 @@ const streamToBuffer = async (stream: NodeJS.ReadableStream) => {
return Buffer.concat(chunks as Buffer[]);
};
jest.mock('@rocket.chat/core-services', () => ({
Translation: {
translateToServerLanguage: (e: string) => e,
},
}));
const pdfWorker = new PdfWorker('chat-transcript');
describe('PdfWorker', () => {
@ -62,6 +69,13 @@ describe('PdfWorker', () => {
expect(buffer).toBeTruthy();
});
it('should generate a pdf transcript for a single system message', async () => {
const stream = await pdfWorker.renderToStream({ data: dataWithASingleSystemMessage });
const buffer = await streamToBuffer(stream);
expect(buffer).toBeTruthy();
});
describe('isMimeTypeValid', () => {
it('should return true if mimeType is valid', () => {
expect(pdfWorker.isMimeTypeValid('image/png')).toBe(true);

@ -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 }>>;
}

@ -3268,6 +3268,7 @@
"Livechat_title_color": "Livechat Title Background Color",
"Livechat_transcript_already_requested_warning": "The transcript of this chat has already been requested and will be sent as soon as the conversation ends.",
"Livechat_transcript_has_been_requested": "Export requested. It may take a few seconds.",
"Livechat_transcript_show_system_messages": "Include system messages in transcripts",
"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",

@ -71,7 +71,12 @@ export interface IMessagesModel extends IBaseModel<IMessage> {
findLivechatClosedMessages(rid: IRoom['_id'], searchTerm?: string, options?: FindOptions<IMessage>): FindPaginated<FindCursor<IMessage>>;
findLivechatMessages(rid: IRoom['_id'], options?: FindOptions<IMessage>): FindCursor<IMessage>;
findLivechatMessagesWithoutClosing(rid: IRoom['_id'], options?: FindOptions<IMessage>): FindCursor<IMessage>;
findLivechatMessagesWithoutTypes(
rid: IRoom['_id'],
ignoredTypes: IMessage['t'][],
showSystemMessages: boolean,
options?: FindOptions<IMessage>,
): FindCursor<IMessage>;
countRoomsWithStarredMessages(options: AggregateOptions): Promise<number>;
countRoomsWithPinnedMessages(options: AggregateOptions): Promise<number>;
@ -113,6 +118,7 @@ export interface IMessagesModel extends IBaseModel<IMessage> {
roomId: IRoom['_id'],
types: IMessage['t'][],
ts: Date,
showSystemMessages: boolean,
options?: FindOptions<IMessage>,
showThreadMessages?: boolean,
): FindCursor<IMessage>;

@ -9792,6 +9792,7 @@ __metadata:
"@rocket.chat/tools": "workspace:^"
"@types/jest": ~29.5.7
"@types/node": ^14.18.63
date-fns: ^2.28.0
ejson: ^2.2.3
emoji-toolkit: ^7.0.1
eslint: ~8.45.0

Loading…
Cancel
Save