snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
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 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
import server from '@rocket.chat/jest-presets/server';
import type { Config } from 'jest';
export default {
preset: server.preset,
testMatch: ['<rootDir>/src/**/*.spec.(ts|js|mjs)'],
} satisfies Config;

View File

@@ -0,0 +1,53 @@
{
"name": "@rocket.chat/omnichannel-services",
"version": "0.3.57",
"private": true,
"main": "./dist/index.js",
"typings": "./dist/index.d.ts",
"files": [
"/dist"
],
"scripts": {
"build": "rm -rf dist && tsc -p tsconfig.build.json",
"dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"test": "jest",
"testunit": "jest",
"typecheck": "tsc --noEmit --skipLibCheck"
},
"dependencies": {
"@rocket.chat/core-services": "workspace:^",
"@rocket.chat/core-typings": "workspace:^",
"@rocket.chat/emitter": "workspace:~",
"@rocket.chat/i18n": "workspace:^",
"@rocket.chat/logger": "workspace:^",
"@rocket.chat/message-types": "workspace:~",
"@rocket.chat/model-typings": "workspace:^",
"@rocket.chat/models": "workspace:^",
"@rocket.chat/pdf-worker": "workspace:^",
"@rocket.chat/rest-typings": "workspace:^",
"@rocket.chat/tools": "workspace:^",
"@types/node": "~24.13.2",
"date-fns": "~4.1.0",
"ejson": "^2.2.3",
"eventemitter3": "^5.0.4",
"i18next": "~23.4.9",
"mem": "^8.1.1",
"moment-timezone": "^0.5.48",
"mongo-message-queue": "^1.1.0",
"mongodb": "6.16.0",
"pino": "10.3.1"
},
"devDependencies": {
"@rocket.chat/jest-presets": "workspace:~",
"@rocket.chat/tsconfig": "workspace:*",
"@types/jest": "~30.0.0",
"eslint": "~9.39.5",
"jest": "~30.2.0",
"typescript": "~5.9.3"
},
"volta": {
"extends": "../../../package.json"
}
}

View File

@@ -0,0 +1,81 @@
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 validTranslatableSystemMessage = {
ts: '2022-11-21T16:00:00.000Z',
u: {
_id: '123',
name: 'Juanito De Ponce',
username: 'juanito.ponce',
},
msg: 'Conversation closed by user',
t: 'livechat-close' 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,
};

View File

@@ -0,0 +1,168 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { defaultTranslationNamespace, availableTranslationNamespaces, extractTranslationNamespaces } from '@rocket.chat/i18n';
import { Logger } from '@rocket.chat/logger';
import * as i18next from 'i18next';
import sprintf from 'i18next-sprintf-postprocessor';
import { OmnichannelTranscript } from './OmnichannelTranscript';
import { invalidSystemMessage, messages, validSystemMessage, validTranslatableSystemMessage } 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 {
onSettingChanged = jest.fn();
},
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' }),
},
Settings: {
get: jest.fn().mockResolvedValue(''),
},
}));
jest.mock('@rocket.chat/models', () => ({
LivechatRooms: {
findOneById: jest.fn().mockResolvedValue({}),
},
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(async () => {
const i18n = i18next.use(sprintf);
i18next.use(sprintf);
await i18n.init({
lng: 'en',
fallbackLng: 'en',
defaultNS: defaultTranslationNamespace,
ns: availableTranslationNamespaces,
nsSeparator: '.',
resources: {
en: extractTranslationNamespaces({
Conversation_closed: 'Conversation closed: {{comment}}.',
}),
},
initImmediate: true,
});
omnichannelTranscript = new OmnichannelTranscript(Logger, i18n);
});
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[], i18next.t);
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 without a valid translation key', async () => {
const parsedMessages = await omnichannelTranscript.getMessagesData(
[...messages, validSystemMessage] as unknown as IMessage[],
i18next.t,
);
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 translate a system message with available translation keys', async () => {
const parsedMessages = await omnichannelTranscript.getMessagesData(
[...messages, validTranslatableSystemMessage] as unknown as IMessage[],
i18next.t,
);
const systemMessage = parsedMessages[3];
expect(parsedMessages).toBeDefined();
expect(parsedMessages).toHaveLength(4);
expect(systemMessage).toHaveProperty('t');
expect(systemMessage.t).toBe('livechat-close');
expect(systemMessage).toHaveProperty('msg');
expect(systemMessage.msg).toBe('Conversation closed: Conversation closed by user.');
expect(systemMessage).toHaveProperty('files');
expect(systemMessage.files).toHaveLength(0);
expect(systemMessage).toHaveProperty('quotes');
expect(systemMessage.quotes).toHaveLength(0);
});
it('should translate a system message to the default language when the server language provided doesnt work', async () => {
const parsedMessages = await omnichannelTranscript.getMessagesData(
[...messages, validTranslatableSystemMessage] as unknown as IMessage[],
i18next.getFixedT('es'),
);
const systemMessage = parsedMessages[3];
expect(parsedMessages).toBeDefined();
expect(parsedMessages).toHaveLength(4);
expect(systemMessage).toHaveProperty('t');
expect(systemMessage.t).toBe('livechat-close');
expect(systemMessage).toHaveProperty('msg');
expect(systemMessage.msg).toBe('Conversation closed: Conversation closed by user.');
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[],
i18next.t,
);
const systemMessage = parsedMessages[3];
expect(parsedMessages).toBeDefined();
expect(parsedMessages).toHaveLength(4);
expect(systemMessage).toHaveProperty('t');
expect(systemMessage.t).toBe('some-system-message');
expect(systemMessage.msg).toBeUndefined();
});
});

View File

@@ -0,0 +1,544 @@
import { Readable } from 'node:stream';
import {
ServiceClass,
Upload as uploadService,
Message as messageService,
Room as roomService,
Settings as settingsService,
} from '@rocket.chat/core-services';
import type { IOmnichannelTranscriptService } from '@rocket.chat/core-services';
import type { IMessage, IUpload, ILivechatAgent, AtLeast, IOmnichannelRoom, IUser, ILivechatVisitor } 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';
import { MessageTypes } from '@rocket.chat/message-types';
import { LivechatRooms, Messages, Uploads, Users, LivechatVisitors } from '@rocket.chat/models';
import { PdfWorker } from '@rocket.chat/pdf-worker';
import type { MessageData, Quote, WorkerData } from '@rocket.chat/pdf-worker';
import { guessTimezone, guessTimezoneFromOffset, streamToBuffer } from '@rocket.chat/tools';
import type { TFunction, i18n } from 'i18next';
import type { WorkDetailsWithSource } from './localTypes';
import { isPromiseRejectedResult } from './localTypes';
export class OmnichannelTranscript extends ServiceClass implements IOmnichannelTranscriptService {
protected name = 'omnichannel-transcript';
private worker: PdfWorker;
private log: Logger;
maxNumberOfConcurrentJobs = 25;
currentJobNumber = 0;
siteName = 'Rocket.Chat';
dateFormat = 'LL';
timeAndDateFormat = 'LLL';
serverLanguage = 'en';
reportingTimezone: 'server' | 'custom' | 'user' = 'server';
defaultCustomTimezone = 'UTC';
showSystemMessages = false;
constructor(
loggerConstructor: typeof Logger,
// Instance of i18n. Should already be init'd and loaded with the translation files
private readonly translator: i18n,
) {
super();
this.worker = new PdfWorker('chat-transcript');
// eslint-disable-next-line new-cap
this.log = new loggerConstructor('OmnichannelTranscript');
this.onSettingChanged('Site_Name', async ({ setting }): Promise<void> => {
this.siteName = setting.value as string;
});
this.onSettingChanged('Message_DateFormat', async ({ setting }): Promise<void> => {
this.dateFormat = setting.value as string;
});
this.onSettingChanged('Message_TimeAndDateFormat', async ({ setting }): Promise<void> => {
this.timeAndDateFormat = setting.value as string;
});
this.onSettingChanged('Language', async ({ setting }): Promise<void> => {
this.serverLanguage = setting.value as string;
});
this.onSettingChanged('Default_Timezone_For_Reporting', async ({ setting }): Promise<void> => {
this.reportingTimezone = setting.value as 'server' | 'custom' | 'user';
});
this.onSettingChanged('Default_Custom_Timezone', async ({ setting }): Promise<void> => {
this.defaultCustomTimezone = setting.value as string;
});
this.onSettingChanged('Livechat_transcript_show_system_messages', async ({ setting }): Promise<void> => {
this.showSystemMessages = setting.value as boolean;
});
}
override async started(): Promise<void> {
// TODO: cache these with mem
const [siteName, dateFormat, timeAndDateFormat, serverLanguage, reportingTimezone, defaultCustomTimezone, showSystemMessages] =
await Promise.all([
settingsService.get<string>('Site_Name'),
settingsService.get<string>('Message_DateFormat'),
settingsService.get<string>('Message_TimeAndDateFormat'),
settingsService.get<string>('Language'),
settingsService.get<'server' | 'custom' | 'user'>('Default_Timezone_For_Reporting'),
settingsService.get<string>('Default_Custom_Timezone'),
settingsService.get<boolean>('Livechat_transcript_show_system_messages'),
]);
this.siteName = siteName;
this.dateFormat = dateFormat;
this.timeAndDateFormat = timeAndDateFormat;
this.serverLanguage = serverLanguage;
this.reportingTimezone = reportingTimezone;
this.defaultCustomTimezone = defaultCustomTimezone;
this.showSystemMessages = showSystemMessages;
}
async getTimezone(agent?: AtLeast<ILivechatAgent, 'utcOffset'> | null): Promise<string> {
switch (this.reportingTimezone) {
case 'custom':
return this.defaultCustomTimezone;
case 'user':
if (agent?.utcOffset) {
return guessTimezoneFromOffset(agent.utcOffset);
}
return guessTimezone();
default:
return guessTimezone();
}
}
private async getMessagesFromRoom({ rid }: { rid: string }): Promise<IMessage[]> {
// Closing message should not appear :)
return Messages.findLivechatMessagesWithoutTypes(rid, ['command'], this.showSystemMessages, {
sort: { ts: 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,
rid: 1,
},
}).toArray();
}
private getQuotesFromMessage(message: IMessage): Quote[] {
const quotes: Quote[] = [];
if (!message.attachments) {
return quotes;
}
for (const attachment of message.attachments) {
if (!isQuoteAttachment(attachment)) {
continue;
}
const { text, author_name: name, md, ts } = attachment;
if (text) {
quotes.push({
name,
md: md ?? parse(text),
ts,
});
}
quotes.push(...this.getQuotesFromMessage({ attachments: attachment.attachments } as IMessage));
}
return quotes;
}
private getSystemMessage(message: IMessage, t: TFunction): MessageData | undefined {
if (!message.t) return undefined;
const systemMessageDefinition = MessageTypes.getType(message);
if (!systemMessageDefinition) return undefined;
return {
...message,
msg: systemMessageDefinition.text(t, message),
};
}
async getMessagesData(messages: IMessage[], t: TFunction): Promise<MessageData[]> {
const messagesData: MessageData[] = [];
for (const message of messages) {
const systemMessage = this.getSystemMessage(message, t);
if (systemMessage) {
messagesData.push({
...systemMessage,
files: systemMessage.files ?? [],
quotes: systemMessage.quotes ?? [],
});
continue;
}
if (!message.attachments?.length) {
// If there's no attachment and no message, what was sent? lol
messagesData.push({
...message,
files: [],
quotes: [],
});
continue;
}
const files = [];
const quotes = [];
for (const attachment of message.attachments) {
if (isQuoteAttachment(attachment)) {
quotes.push(...this.getQuotesFromMessage(message));
continue;
}
if (!isFileAttachment(attachment)) {
this.log.error({
msg: 'Invalid attachment type for file in room',
attachmentType: (attachment as { type?: string }).type,
title: attachment.title,
rid: message.rid,
});
// ignore other types of attachments
continue;
}
if (!isFileImageAttachment(attachment)) {
this.log.error({
msg: 'Invalid attachment type for file in room',
attachmentType: attachment.type,
title: attachment.title,
rid: message.rid,
});
// ignore other types of attachments
files.push({ name: attachment.title });
continue;
}
if (!this.worker.isMimeTypeValid(attachment.image_type)) {
this.log.error({
msg: 'Invalid mime type for file in room',
mimeType: attachment.image_type,
title: attachment.title,
rid: message.rid,
});
// ignore invalid mime types
files.push({ name: attachment.title });
continue;
}
let file = message.files?.map((v) => ({ _id: v._id, name: v.name })).find((file) => file.name === attachment.title);
if (!file) {
this.log.warn({
msg: 'File not found in room',
title: attachment.title,
rid: message.rid,
});
// For some reason, when an image is uploaded from clipboard, it doesn't have a file :(
// So, we'll try to get the FILE_ID from the `title_link` prop which has the format `/file-upload/FILE_ID/FILE_NAME` using a regex
const fileId = attachment.title_link?.match(/\/file-upload\/(.*)\/.*/)?.[1];
if (!fileId) {
this.log.error({
msg: 'File not found in room',
title: attachment.title,
rid: message.rid,
});
// ignore attachments without file
files.push({ name: attachment.title });
continue;
}
file = { _id: fileId, name: attachment.title || 'upload' };
}
if (!file) {
this.log.warn({
msg: 'File not found in room',
title: attachment.title,
rid: message.rid,
});
// ignore attachments without file
files.push({ name: attachment.title });
continue;
}
const uploadedFile = await Uploads.findOneById(file._id);
if (!uploadedFile) {
this.log.error({
msg: 'Uploaded file not found in room',
fileId: file._id,
rid: message.rid,
});
// ignore attachments without file
files.push({ name: file.name });
continue;
}
try {
const stream = await uploadService.streamUploadedFile({ file: uploadedFile, imageResizeOpts: { width: 400, height: 240 } });
files.push({ name: file.name, buffer: await streamToBuffer(stream), extension: uploadedFile.extension });
} catch (err: unknown) {
this.log.error({ msg: 'Failed to fetch file buffer', err });
// Push empty buffer so parser processes this as "unsupported file"
files.push({ name: file.name });
// TODO: this is a NATS error message, even when we shouldn't tie it, since it's the only way we have right now we'll live with it for a while
if ((err as Error).message === 'MAX_PAYLOAD_EXCEEDED') {
this.log.error(
`File is too big to be processed by NATS. See NATS config for allowing bigger messages to be sent between services`,
);
}
}
}
// When you send a file message, the things you type in the modal are not "msg", they're in "description" of the attachment
// So, we'll fetch the the msg, if empty, go for the first description on an attachment, if empty, empty string
const msg = message.msg || message.attachments.find((attachment) => attachment.description)?.description || '';
// Remove nulls from final array
messagesData.push({
msg,
u: message.u,
files,
quotes,
ts: message.ts,
md: message.md,
});
}
return messagesData;
}
async workOnPdf({ details }: { details: WorkDetailsWithSource }): Promise<void> {
this.log.info({
msg: 'Processing transcript received from queue',
rid: details.rid,
userId: details.userId,
});
if (this.maxNumberOfConcurrentJobs <= this.currentJobNumber) {
this.log.error({
msg: 'Processing transcript exceeded concurrent jobs limit',
rid: details.rid,
userId: details.userId,
});
throw new Error('retry');
}
this.currentJobNumber++;
const user = await Users.findOneById<Pick<IUser, '_id' | 'language'>>(details.userId, { projection: { _id: 1, language: 1 } });
if (!user) return;
const language = user.language ?? this.serverLanguage;
const i18n = this.translator.cloneInstance({ lng: language });
try {
const room = await LivechatRooms.findOneById<Pick<IOmnichannelRoom, '_id' | 'v' | 'pdfTranscriptFileId' | 'closedAt' | 'servedBy'>>(
details.rid,
{
projection: { v: 1, servedBy: 1, pdfTranscriptFileId: 1, closedAt: 1 },
},
);
if (!room) {
throw new Error('room-not-found');
}
if (room.pdfTranscriptFileId) {
this.log.info({
msg: 'Processing transcript skipped because PDF already exists',
rid: details.rid,
userId: details.userId,
});
return;
}
const messages = await this.getMessagesFromRoom({ rid: room._id });
const visitor = room.v
? await LivechatVisitors.findOneEnabledById<Pick<ILivechatVisitor, '_id' | 'name' | 'username' | 'visitorEmails'>>(room.v._id, {
projection: { _id: 1, name: 1, username: 1, visitorEmails: 1 },
})
: null;
const agent = room.servedBy
? await Users.findOneAgentById<Pick<ILivechatAgent, '_id' | 'name' | 'username' | 'utcOffset'>>(room.servedBy._id, {
projection: { _id: 1, name: 1, username: 1, utcOffset: 1 },
})
: null;
const messagesData = await this.getMessagesData(messages, i18n.t);
const timezone = await this.getTimezone(agent);
this.log.info({ msg: 'Loading translations', language });
const data: WorkerData = {
visitor,
agent,
closedAt: room.closedAt,
siteName: this.siteName,
messages: messagesData,
dateFormat: this.dateFormat,
timeAndDateFormat: this.timeAndDateFormat,
timezone,
};
await this.doRender({ data, details, i18n });
} catch (error) {
await this.pdfFailed({ details, e: error as Error, i18n });
} finally {
this.currentJobNumber--;
}
}
private async doRender({ data, details, i18n }: { data: WorkerData; details: WorkDetailsWithSource; i18n: i18n }): Promise<void> {
const transcriptText = i18n.t('Transcript');
const stream = await this.worker.renderToStream({ data, i18n });
try {
const { rid } = await roomService.createDirectMessage({ to: details.userId, from: 'rocket.cat' });
const [rocketCatFile, transcriptFile] = await this.uploadFiles({
streamParam: Readable.from(stream),
roomIds: [rid, details.rid],
data,
transcriptText,
});
await this.pdfComplete({ details, transcriptFile, rocketCatFile, i18n });
} catch (error) {
void this.pdfFailed({ details, e: error as Error, i18n });
}
}
private async pdfFailed({ details, e, i18n }: { details: WorkDetailsWithSource; e: Error; i18n: i18n }): Promise<void> {
this.log.error({
msg: 'Transcript generation failed',
rid: details.rid,
userId: details.userId,
err: e,
});
const room = await LivechatRooms.findOneById<Pick<IOmnichannelRoom, '_id'>>(details.rid, { projection: { _id: 1 } });
if (!room) {
return;
}
const { rid } = await roomService.createDirectMessage({ to: details.userId, from: 'rocket.cat' });
this.log.info({
msg: 'Transcript error message being sent to user',
rid: details.rid,
userId: details.userId,
});
await messageService.sendMessage({
fromId: 'rocket.cat',
rid,
msg: `${i18n.t('pdf_error_message')}: ${e.message}`,
});
}
private async uploadFiles({
streamParam,
roomIds,
data,
transcriptText,
}: {
streamParam: Readable;
roomIds: string[];
data: Pick<WorkerData, 'siteName' | 'visitor'>;
transcriptText: string;
}): Promise<IUpload[]> {
return Promise.all(
roomIds.map((roomId) => {
return uploadService.uploadFileFromStream({
streamParam,
details: {
// transcript_{company-name}_{date}_{hour}.pdf
name: `${transcriptText}_${data.siteName}_${new Intl.DateTimeFormat('en-US').format(new Date()).replaceAll('/', '-')}_${
data.visitor?.name || data.visitor?.username || 'Visitor'
}.pdf`,
type: 'application/pdf',
rid: roomId,
// Rocket.cat is the goat
userId: 'rocket.cat',
},
});
}),
);
}
private async pdfComplete({
details,
transcriptFile,
rocketCatFile,
i18n,
}: {
details: WorkDetailsWithSource;
transcriptFile: IUpload;
rocketCatFile: IUpload;
i18n: i18n;
}): Promise<void> {
this.log.info({
msg: 'Transcript completed successfully',
rid: details.rid,
userId: details.userId,
});
// Send the file to the livechat room where this was requested, to keep it in context
try {
await LivechatRooms.setPdfTranscriptFileIdById(details.rid, transcriptFile._id);
this.log.info({
msg: 'Transcript success message being sent to user',
rid: details.rid,
userId: details.userId,
});
const result = await Promise.allSettled([
uploadService.sendFileMessage({
roomId: details.rid,
userId: 'rocket.cat',
file: transcriptFile,
message: {
// Translate from service
msg: i18n.t('pdf_success_message'),
},
}),
// Send the file to the user who requested it, so they can download it
uploadService.sendFileMessage({
roomId: rocketCatFile.rid || '',
userId: 'rocket.cat',
file: rocketCatFile,
message: {
// Translate from service
msg: i18n.t('pdf_success_message'),
},
}),
]);
const e = result.find((r) => isPromiseRejectedResult(r));
if (e && isPromiseRejectedResult(e)) {
throw e.reason;
}
} catch (err) {
this.log.error({
msg: 'Transcript failed to send message',
rid: details.rid,
userId: details.userId,
err,
});
}
}
}

View File

@@ -0,0 +1,146 @@
import { ServiceClass, api } from '@rocket.chat/core-services';
import type { IQueueWorkerService, HealthAggResult } from '@rocket.chat/core-services';
import type { Logger } from '@rocket.chat/logger';
import type { Actions, ValidResult, Work } from 'mongo-message-queue';
import MessageQueue from 'mongo-message-queue';
import type { Db } from 'mongodb';
export class QueueWorker extends ServiceClass implements IQueueWorkerService {
protected name = 'queue-worker';
protected retryCount = 5;
// Default delay is 5 seconds
protected retryDelay = Number(process.env.RETRY_DELAY) || 5000;
protected queue: MessageQueue;
private logger: Logger;
constructor(
private readonly db: Db,
loggerClass: typeof Logger,
) {
super();
// eslint-disable-next-line new-cap
this.logger = new loggerClass('QueueWorker');
this.queue = new MessageQueue();
this.queue.pollingInterval = Number(process.env.POLLING_INTERVAL) || 5000;
}
isServiceNotFoundMessage(message: string): boolean {
return message.includes('is not found');
}
isServiceRetryError(message: string): boolean {
return message.includes('retry');
}
override async created(): Promise<void> {
this.queue.databasePromise = () => {
return Promise.resolve(this.db);
};
try {
await this.createIndexes();
this.registerWorkers();
} catch (e) {
this.logger.fatal({ msg: 'Fatal error occurred when registering workers', err: e });
process.exit(1);
}
}
async createIndexes(): Promise<void> {
this.logger.info('Creating indexes for queue worker');
// Library doesn't create indexes by itself, for some reason
// This should create the indexes we need and improve queue perf on reading
await this.db.collection(this.queue.collectionName).createIndex({ type: 1 });
await this.db.collection(this.queue.collectionName).createIndex({ rejectedTime: 1 }, { sparse: true });
await this.db.collection(this.queue.collectionName).createIndex({ nextReceivableTime: 1 }, { sparse: true });
await this.db.collection(this.queue.collectionName).createIndex({ receivedTime: 1 }, { sparse: true });
}
override async stopped(): Promise<void> {
this.logger.info('Stopping queue worker');
this.queue.stopPolling();
}
private isRetryableError(error: string): boolean {
// Let's retry on 2 circumstances: (for now)
// 1. When the error is "service not found" -> this means the service is not yet registered
// 2. When the error is "retry" -> this means the service is registered, but is not willing to process it right now, maybe due to load
return this.isServiceNotFoundMessage(error) || this.isServiceRetryError(error);
}
private async workerCallback(queueItem: Work<{ to: string; data: any }>): Promise<ValidResult> {
this.logger.info({ msg: 'Processing queue item for work', queueItemId: queueItem._id });
this.logger.info({ msg: 'Queue item is trying to call', to: queueItem.message.to });
try {
await api.call(queueItem.message.to, [queueItem.message]);
this.logger.info({ msg: 'Queue item completed', queueItemId: queueItem._id });
return 'Completed' as const;
} catch (err: unknown) {
const e = err as Error;
this.logger.error({ msg: 'Queue item errored', queueItemId: queueItem._id, err: e });
queueItem.releasedReason = e.message;
// Let's only retry for X times when the error is "service not found"
// For any other error, we'll just reject the item
if ((queueItem.retryCount || 0) < this.retryCount && this.isRetryableError(e.message)) {
this.logger.info({ msg: 'Queue item will be retried', queueItemId: queueItem._id, retry: this.retryDelay });
queueItem.nextReceivableTime = new Date(Date.now() + this.retryDelay);
return 'Retry' as const;
}
this.logger.info({ msg: 'Queue item will be rejected', queueItemId: queueItem._id });
return 'Rejected' as const;
}
}
// Registers the actual workers, the actions lib will try to fetch elements to work on
private registerWorkers(): void {
this.logger.info('Registering workers of type "work"');
this.queue.registerWorker('work', this.workerCallback.bind(this));
this.logger.info('Registering workers of type "workComplete"');
this.queue.registerWorker('workComplete', this.workerCallback.bind(this));
}
private matchServiceCall(service: string): boolean {
const [namespace, action] = service.split('.');
if (!namespace || !action) {
return false;
}
return true;
}
// Queues an action of type "X" to be processed by the workers
// Action receives a record of unknown data that will be passed to the actual service
// `to` is a service name that will be called, including namespace + action
// This is a "generic" job that allows you to call any service
async queueWork<T extends Record<string, unknown>>(queue: Actions, to: string, data: T): Promise<void> {
this.logger.info({ msg: 'Queueing work for', to });
if (!this.matchServiceCall(to)) {
// We don't want to queue calls to invalid service names
throw new Error(`Invalid service name ${to}`);
}
await this.queue.enqueue<typeof data>(queue, { ...data, to });
}
async queueInfo(): Promise<HealthAggResult[]> {
return this.db
.collection(this.queue.collectionName)
.aggregate<HealthAggResult>([
{
$addFields: {
status: { $cond: [{ $ifNull: ['$rejectionReason', false] }, 'Rejected', 'In progress'] },
},
},
{ $group: { _id: { type: '$type', status: '$status' }, elements: { $push: '$$ROOT' }, total: { $sum: 1 } } },
// Project from each group the type, status and total of elements
{ $project: { _id: 0, type: '$_id.type', status: '$_id.status', total: 1 } },
])
.toArray();
}
}

View File

@@ -0,0 +1,36 @@
declare module 'mongo-message-queue' {
import type { Db } from 'mongodb';
export type Work<T> = {
_id: string;
dateCreated: Date;
type: string;
message: T;
priority: number;
receivedTime: Date;
releasedReason?: string;
retryCount?: number;
nextReceivableTime?: Date;
rejectionReason?: string;
};
export type ValidResult = 'Completed' | 'Rejected' | 'Retry';
export type Actions = 'work' | 'workComplete';
export default class MessageQueue {
collectionName: string;
databasePromise: () => Promise<Db>;
registerWorker<T>(type: Actions, worker: (queueItem: Work<T>) => Promise<ValidResult>): void;
enqueue<T>(type: Actions, message: T, options?: { nextReceivableTime: Date; priority: number }): Promise<void>;
enqueueAndProcess<T>(type: Actions, message: T, options?: { nextReceivableTime: Date; priority: number }): Promise<void>;
stopPolling(): void;
pollingInterval?: number;
}
}

View File

@@ -0,0 +1,2 @@
export { OmnichannelTranscript } from './OmnichannelTranscript';
export { QueueWorker } from './QueueWorker';

View File

@@ -0,0 +1,12 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
export const isPromiseRejectedResult = (result: any): result is PromiseRejectedResult => result.status === 'rejected';
export type WorkDetails = {
rid: IRoom['_id'];
userId: IUser['_id'];
};
export type WorkDetailsWithSource = WorkDetails & {
from: string;
};

View File

@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
},
"exclude": ["./dist", "./src/**/*.spec.ts", "./src/**/*.fixtures.ts"],
}

View File

@@ -0,0 +1,10 @@
{
"extends": "@rocket.chat/tsconfig/server.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
},
"include": ["./src/**/*", "../../../apps/meteor/definition/externals/meteor"],
"exclude": ["./dist"]
}