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
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:
4193
ee/packages/omnichannel-services/CHANGELOG.md
Normal file
4193
ee/packages/omnichannel-services/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
7
ee/packages/omnichannel-services/jest.config.ts
Normal file
7
ee/packages/omnichannel-services/jest.config.ts
Normal 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;
|
||||
53
ee/packages/omnichannel-services/package.json
Normal file
53
ee/packages/omnichannel-services/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
544
ee/packages/omnichannel-services/src/OmnichannelTranscript.ts
Normal file
544
ee/packages/omnichannel-services/src/OmnichannelTranscript.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
146
ee/packages/omnichannel-services/src/QueueWorker.ts
Normal file
146
ee/packages/omnichannel-services/src/QueueWorker.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
36
ee/packages/omnichannel-services/src/externals/mongo-message-queue.d.ts
vendored
Normal file
36
ee/packages/omnichannel-services/src/externals/mongo-message-queue.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
2
ee/packages/omnichannel-services/src/index.ts
Normal file
2
ee/packages/omnichannel-services/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { OmnichannelTranscript } from './OmnichannelTranscript';
|
||||
export { QueueWorker } from './QueueWorker';
|
||||
12
ee/packages/omnichannel-services/src/localTypes.ts
Normal file
12
ee/packages/omnichannel-services/src/localTypes.ts
Normal 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;
|
||||
};
|
||||
7
ee/packages/omnichannel-services/tsconfig.build.json
Normal file
7
ee/packages/omnichannel-services/tsconfig.build.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
},
|
||||
"exclude": ["./dist", "./src/**/*.spec.ts", "./src/**/*.fixtures.ts"],
|
||||
}
|
||||
10
ee/packages/omnichannel-services/tsconfig.json
Normal file
10
ee/packages/omnichannel-services/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user