RocketChat/Rocket.Chat

View on GitHub
ee/packages/omnichannel-services/src/OmnichannelTranscript.ts

Summary

Maintainability
F
3 days
Test Coverage
import type { Readable } from 'stream';

import {
    ServiceClass,
    Upload as uploadService,
    Message as messageService,
    Room as roomService,
    QueueWorker as queueService,
    Translation as translationService,
    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 { isQuoteAttachment, isFileAttachment, isFileImageAttachment } from '@rocket.chat/core-typings';
import type { Logger } from '@rocket.chat/logger';
import { parse } from '@rocket.chat/message-parser';
import type { Root } from '@rocket.chat/message-parser';
import { LivechatRooms, Messages, Uploads, Users, LivechatVisitors } from '@rocket.chat/models';
import { PdfWorker } from '@rocket.chat/pdf-worker';
import { guessTimezone, guessTimezoneFromOffset, streamToBuffer } from '@rocket.chat/tools';

const isPromiseRejectedResult = (result: any): result is PromiseRejectedResult => result.status === 'rejected';

type WorkDetails = {
    rid: IRoom['_id'];
    userId: IUser['_id'];
};

type WorkDetailsWithSource = WorkDetails & {
    from: string;
};

type Quote = { name: string; ts?: Date; md: Root };

type MessageData = Pick<IMessage, '_id' | 'ts' | 'u' | 'msg' | 'md'> & {
    files: ({ name?: string; buffer: Buffer | null; extension?: string } | undefined)[];
    quotes: (Quote | undefined)[];
};

type WorkerData = {
    siteName: string;
    visitor: Pick<ILivechatVisitor, '_id' | 'username' | 'name' | 'visitorEmails'> | null;
    agent: ILivechatAgent | undefined;
    closedAt?: Date;
    messages: MessageData[];
    timezone: string;
    dateFormat: string;
    timeAndDateFormat: string;
    translations: { key: string; value: string }[];
};

export class OmnichannelTranscript extends ServiceClass implements IOmnichannelTranscriptService {
    protected name = 'omnichannel-transcript';

    private worker: PdfWorker;

    private log: Logger;

    maxNumberOfConcurrentJobs = 25;

    currentJobNumber = 0;

    constructor(loggerClass: typeof Logger) {
        super();
        this.worker = new PdfWorker('chat-transcript');
        // eslint-disable-next-line new-cap
        this.log = new loggerClass('OmnichannelTranscript');
    }

    async getTimezone(user?: { utcOffset?: string | number }): Promise<string> {
        const reportingTimezone = await settingsService.get('Default_Timezone_For_Reporting');

        switch (reportingTimezone) {
            case 'custom':
                return settingsService.get<string>('Default_Custom_Timezone');
            case 'user':
                if (user?.utcOffset) {
                    return guessTimezoneFromOffset(user.utcOffset);
                }
                return guessTimezone();
            default:
                return guessTimezone();
        }
    }

    private getMessagesFromRoom({ rid }: { rid: string }): Promise<IMessage[]> {
        // Closing message should not appear :)
        return Messages.findLivechatMessagesWithoutClosing(rid, {
            sort: { ts: 1 },
            projection: { _id: 1, msg: 1, u: 1, t: 1, ts: 1, attachments: 1, files: 1, md: 1 },
        }).toArray();
    }

    async requestTranscript({ details }: { details: WorkDetails }): Promise<void> {
        this.log.info(`Requesting transcript for room ${details.rid} by user ${details.userId}`);
        const room = await LivechatRooms.findOneById<Pick<IOmnichannelRoom, '_id' | 'open' | 'v' | 'pdfTranscriptRequested'>>(details.rid, {
            projection: { _id: 1, open: 1, v: 1, pdfTranscriptRequested: 1 },
        });

        if (!room) {
            throw new Error('room-not-found');
        }

        if (room.open) {
            throw new Error('room-still-open');
        }

        if (!room.v) {
            throw new Error('improper-room-state');
        }

        // Don't request a transcript if there's already one requested :)
        if (room.pdfTranscriptRequested) {
            // TODO: use logger
            this.log.info(`Transcript already requested for room ${details.rid}`);
            return;
        }

        await LivechatRooms.setTranscriptRequestedPdfById(details.rid);

        // Make the whole process sync when running on test mode
        // This will prevent the usage of timeouts on the tests of this functionality :)
        if (process.env.TEST_MODE) {
            await this.workOnPdf({ details: { ...details, from: this.name } });
            return;
        }

        // Even when processing is done "in-house", we still need to queue the work
        // to avoid blocking the request
        this.log.info(`Queuing work for room ${details.rid}`);
        await queueService.queueWork('work', `${this.name}.workOnPdf`, {
            details: { ...details, from: this.name },
        });
    }

    private getQuotesFromMessage(message: IMessage): Quote[] {
        const quotes: Quote[] = [];

        if (!message.attachments) {
            return quotes;
        }

        for (const attachment of message.attachments) {
            if (isQuoteAttachment(attachment)) {
                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 async getMessagesData(messages: IMessage[]): Promise<MessageData[]> {
        const messagesData: MessageData[] = [];
        for await (const message of messages) {
            if (!message.attachments?.length) {
                // If there's no attachment and no message, what was sent? lol
                messagesData.push({
                    _id: message._id,
                    files: [],
                    quotes: [],
                    ts: message.ts,
                    u: message.u,
                    msg: message.msg,
                    md: message.md,
                });
                continue;
            }
            const files = [];
            const quotes = [];

            for await (const attachment of message.attachments) {
                if (isQuoteAttachment(attachment)) {
                    quotes.push(...this.getQuotesFromMessage(message));
                    continue;
                }

                if (!isFileAttachment(attachment)) {
                    this.log.error(`Invalid attachment type ${(attachment as any).type} for file ${attachment.title} in room ${message.rid}!`);
                    // ignore other types of attachments
                    continue;
                }
                if (!isFileImageAttachment(attachment)) {
                    this.log.error(`Invalid attachment type ${attachment.type} for file ${attachment.title} in room ${message.rid}!`);
                    // ignore other types of attachments
                    files.push({ name: attachment.title, buffer: null });
                    continue;
                }

                if (!this.worker.isMimeTypeValid(attachment.image_type)) {
                    this.log.error(`Invalid mime type ${attachment.image_type} for file ${attachment.title} in room ${message.rid}!`);
                    // ignore invalid mime types
                    files.push({ name: attachment.title, buffer: null });
                    continue;
                }
                let file = message.files?.map((v) => ({ _id: v._id, name: v.name })).find((file) => file.name === attachment.title);
                if (!file) {
                    this.log.warn(`File ${attachment.title} not found in room ${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(`File ${attachment.title} not found in room ${message.rid}!`);
                        // ignore attachments without file
                        files.push({ name: attachment.title, buffer: null });
                        continue;
                    }
                    file = { _id: fileId, name: attachment.title || 'upload' };
                }

                if (!file) {
                    this.log.warn(`File ${attachment.title} not found in room ${message.rid}!`);
                    // ignore attachments without file
                    files.push({ name: attachment.title, buffer: null });
                    continue;
                }

                const uploadedFile = await Uploads.findOneById(file._id);
                if (!uploadedFile) {
                    this.log.error(`Uploaded file ${file._id} not found in room ${message.rid}!`);
                    // ignore attachments without file
                    files.push({ name: file.name, buffer: null });
                    continue;
                }

                const fileBuffer = await uploadService.getFileBuffer({ file: uploadedFile });
                files.push({ name: file.name, buffer: fileBuffer, extension: uploadedFile.extension });
            }

            // 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({
                _id: message._id,
                msg,
                u: message.u,
                files: files.filter(Boolean),
                quotes,
                ts: message.ts,
                md: message.md,
            });
        }

        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'];

        return Promise.all(
            keys.map(async (key) => {
                return {
                    key,
                    value: await translationService.translateToServerLanguage(key),
                };
            }),
        );
    }

    async workOnPdf({ details }: { details: WorkDetailsWithSource }): Promise<void> {
        this.log.info(`Processing transcript for room ${details.rid} by user ${details.userId} - Received from queue`);
        if (this.maxNumberOfConcurrentJobs <= this.currentJobNumber) {
            this.log.error(`Processing transcript for room ${details.rid} by user ${details.userId} - Too many concurrent jobs, queuing again`);
            throw new Error('retry');
        }
        this.currentJobNumber++;
        try {
            const room = await LivechatRooms.findOneById(details.rid);
            if (!room) {
                throw new Error('room-not-found');
            }
            const messages = await this.getMessagesFromRoom({ rid: room._id });

            const visitor =
                room.v &&
                (await LivechatVisitors.findOneEnabledById(room.v._id, { projection: { _id: 1, name: 1, username: 1, visitorEmails: 1 } }));
            const agent =
                room.servedBy && (await Users.findOneAgentById(room.servedBy._id, { projection: { _id: 1, name: 1, username: 1, utcOffset: 1 } }));

            const messagesData = await this.getMessagesData(messages);

            const [siteName, dateFormat, timeAndDateFormat, timezone, translations] = 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,
                agent,
                closedAt: room.closedAt,
                siteName,
                messages: messagesData,
                dateFormat,
                timeAndDateFormat,
                timezone,
                translations,
            };

            await this.doRender({ data, details });
        } catch (error) {
            await this.pdfFailed({ details, e: error as Error });
        } finally {
            this.currentJobNumber--;
        }
    }

    async doRender({ data, details }: { data: WorkerData; details: WorkDetailsWithSource }): Promise<void> {
        const transcriptText = await translationService.translateToServerLanguage('Transcript');

        const stream = await this.worker.renderToStream({ data });
        const outBuff = await streamToBuffer(stream as Readable);

        try {
            const file = await uploadService.uploadFile({
                userId: details.userId,
                buffer: outBuff,
                details: {
                    // transcript_{company-name)_{date}_{hour}.pdf
                    name: `${transcriptText}_${data.siteName}_${new Intl.DateTimeFormat('en-US').format(new Date())}_${
                        data.visitor?.name || data.visitor?.username || 'Visitor'
                    }.pdf`,
                    type: 'application/pdf',
                    rid: details.rid,
                    // Rocket.cat is the goat
                    userId: 'rocket.cat',
                    size: outBuff.length,
                },
            });
            await this.pdfComplete({ details, file });
        } catch (e: any) {
            this.pdfFailed({ details, e });
        }
    }

    private async pdfFailed({ details, e }: { details: WorkDetailsWithSource; e: Error }): Promise<void> {
        this.log.error(`Transcript for room ${details.rid} by user ${details.userId} - Failed: ${e.message}`);
        const room = await LivechatRooms.findOneById(details.rid);
        if (!room) {
            return;
        }
        const user = await Users.findOneById(details.userId);
        if (!user) {
            return;
        }

        // Remove `transcriptRequestedPdf` from room to allow another request
        await LivechatRooms.unsetTranscriptRequestedPdfById(details.rid);

        const { rid } = await roomService.createDirectMessage({ to: details.userId, from: 'rocket.cat' });
        this.log.info(`Transcript for room ${details.rid} by user ${details.userId} - Sending error message to user`);
        await messageService.sendMessage({
            fromId: 'rocket.cat',
            rid,
            msg: `${await translationService.translate('pdf_error_message', user)}: ${e.message}`,
        });
    }

    private async pdfComplete({ details, file }: { details: WorkDetailsWithSource; file: IUpload }): Promise<void> {
        this.log.info(`Transcript for room ${details.rid} by user ${details.userId} - Complete`);
        const user = await Users.findOneById(details.userId);
        if (!user) {
            return;
        }
        // Send the file to the livechat room where this was requested, to keep it in context
        try {
            const [, { rid }] = await Promise.all([
                LivechatRooms.setPdfTranscriptFileIdById(details.rid, file._id),
                roomService.createDirectMessage({ to: details.userId, from: 'rocket.cat' }),
            ]);

            this.log.info(`Transcript for room ${details.rid} by user ${details.userId} - Sending success message to user`);
            const result = await Promise.allSettled([
                uploadService.sendFileMessage({
                    roomId: details.rid,
                    userId: 'rocket.cat',
                    file,
                    message: {
                        // Translate from service
                        msg: await translationService.translateToServerLanguage('pdf_success_message'),
                    },
                }),
                // Send the file to the user who requested it, so they can download it
                uploadService.sendFileMessage({
                    roomId: rid,
                    userId: 'rocket.cat',
                    file,
                    message: {
                        // Translate from service
                        msg: await translationService.translate('pdf_success_message', user),
                    },
                }),
            ]);
            const e = result.find((r) => isPromiseRejectedResult(r));
            if (e && isPromiseRejectedResult(e)) {
                throw e.reason;
            }
        } catch (err) {
            this.log.error({ msg: `Transcript for room ${details.rid} by user ${details.userId} - Failed to send message`, err });
        }
    }
}