apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts
import { mkdir, writeFile } from 'fs/promises';
import type { IMessage, IRoom, IUser, MessageAttachment, FileProp, RoomType } from '@rocket.chat/core-typings';
import { Messages } from '@rocket.chat/models';
import { settings } from '../../../app/settings/server';
import { readSecondaryPreferred } from '../../database/readSecondaryPreferred';
import { joinPath } from '../fileUtils';
import { i18n } from '../i18n';
const hideUserName = (
username: string,
userData: Pick<IUser, 'username'> | undefined,
usersMap: { userNameTable: Record<string, string> },
) => {
if (!usersMap.userNameTable) {
usersMap.userNameTable = {};
}
if (!usersMap.userNameTable[username]) {
if (userData && username === userData.username) {
usersMap.userNameTable[username] = username;
} else {
usersMap.userNameTable[username] = `User_${Object.keys(usersMap.userNameTable).length + 1}`;
}
}
return usersMap.userNameTable[username];
};
const getAttachmentData = (attachment: MessageAttachment, message: IMessage) => {
return {
type: 'type' in attachment ? attachment.type : undefined,
title: attachment.title,
title_link: attachment.title_link,
image_url: 'image_url' in attachment ? attachment.image_url : undefined,
audio_url: 'audio_url' in attachment ? attachment.audio_url : undefined,
video_url: 'video_url' in attachment ? attachment.video_url : undefined,
message_link: 'message_link' in attachment ? attachment.message_link : undefined,
image_type: 'image_type' in attachment ? attachment.image_type : undefined,
image_size: 'image_size' in attachment ? attachment.image_size : undefined,
video_size: 'video_size' in attachment ? attachment.video_size : undefined,
video_type: 'video_type' in attachment ? attachment.video_type : undefined,
audio_size: 'audio_size' in attachment ? attachment.audio_size : undefined,
audio_type: 'audio_type' in attachment ? attachment.audio_type : undefined,
url:
attachment.title_link ||
('image_url' in attachment ? attachment.image_url : undefined) ||
('audio_url' in attachment ? attachment.audio_url : undefined) ||
('video_url' in attachment ? attachment.video_url : undefined) ||
('message_link' in attachment ? attachment.message_link : undefined) ||
null,
remote: !message.file?._id,
fileId: message.file?._id,
fileName: message.file?.name,
};
};
export type MessageData = Pick<IMessage, 'msg' | 'ts'> & {
username?: IUser['username'] | IUser['name'];
attachments?: ReturnType<typeof getAttachmentData>[];
type?: IMessage['t'];
};
export const getMessageData = (
msg: IMessage,
hideUsers: boolean,
userData: Pick<IUser, 'username'> | undefined,
usersMap: { userNameTable: Record<string, string> },
): MessageData => {
const username = hideUsers ? hideUserName(msg.u.username || msg.u.name || '', userData, usersMap) : msg.u.username;
const messageObject = {
msg: msg.msg,
username,
ts: msg.ts,
...(msg.attachments && {
attachments: msg.attachments.map((attachment) => getAttachmentData(attachment, msg)),
}),
...(msg.t && { type: msg.t }),
};
switch (msg.t) {
case 'uj':
messageObject.msg = i18n.t('User_joined_the_channel');
break;
case 'ul':
messageObject.msg = i18n.t('User_left_this_channel');
break;
case 'ult':
messageObject.msg = i18n.t('User_left_this_team');
break;
case 'user-added-room-to-team':
messageObject.msg = i18n.t('added__roomName__to_this_team', {
roomName: msg.msg,
});
break;
case 'user-converted-to-team':
messageObject.msg = i18n.t('Converted__roomName__to_a_team', {
roomName: msg.msg,
});
break;
case 'user-converted-to-channel':
messageObject.msg = i18n.t('Converted__roomName__to_a_channel', {
roomName: msg.msg,
});
break;
case 'user-deleted-room-from-team':
messageObject.msg = i18n.t('Deleted__roomName__room', {
roomName: msg.msg,
});
break;
case 'user-removed-room-from-team':
messageObject.msg = i18n.t('Removed__roomName__from_the_team', {
roomName: msg.msg,
});
break;
case 'ujt':
messageObject.msg = i18n.t('User_joined_the_team');
break;
case 'au':
messageObject.msg = i18n.t('User_added_to', {
user_added: hideUserName(msg.msg, userData, usersMap),
user_by: username,
});
break;
case 'added-user-to-team':
messageObject.msg = i18n.t('Added__username__to_this_team', {
user_added: msg.msg,
});
break;
case 'r':
messageObject.msg = i18n.t('Room_name_changed_to', {
room_name: msg.msg,
user_by: username,
});
break;
case 'ru':
messageObject.msg = i18n.t('User_has_been_removed', {
user_removed: hideUserName(msg.msg, userData, usersMap),
user_by: username,
});
break;
case 'removed-user-from-team':
messageObject.msg = i18n.t('Removed__username__from_the_team', {
user_removed: hideUserName(msg.msg, userData, usersMap),
});
break;
case 'wm':
messageObject.msg = i18n.t('Welcome', { user: username });
break;
case 'livechat-close':
messageObject.msg = i18n.t('Conversation_finished');
break;
case 'livechat-started':
messageObject.msg = i18n.t('Chat_started');
break;
}
return messageObject;
};
export const exportMessageObject = (type: 'json' | 'html', messageObject: MessageData, messageFile?: FileProp): string => {
if (type === 'json') {
return JSON.stringify(messageObject);
}
const file = [];
const messageType = messageObject.type;
const timestamp = messageObject.ts ? new Date(messageObject.ts).toUTCString() : '';
const italicTypes: IMessage['t'][] = ['uj', 'ul', 'au', 'r', 'ru', 'wm', 'livechat-close'];
const message = italicTypes.includes(messageType) ? `<i>${messageObject.msg}</i>` : messageObject.msg;
file.push(`<p><strong>${messageObject.username}</strong> (${timestamp}):<br/>`);
file.push(message);
if (messageFile?._id) {
const attachment = messageObject.attachments?.find((att) => att.type === 'file' && att.title_link?.includes(messageFile._id));
const description = attachment?.title || i18n.t('Message_Attachments');
const assetUrl = `./assets/${messageFile._id}-${messageFile.name}`;
const link = `<br/><a href="${assetUrl}">${description}</a>`;
file.push(link);
}
file.push('</p>');
return file.join('\n');
};
export const exportRoomMessages = async (
rid: IRoom['_id'],
exportType: 'json' | 'html',
skip: number,
limit: number,
userData: any,
filter: any = {},
usersMap: any = {},
hideUsers = true,
) => {
const readPreference = readSecondaryPreferred();
const { cursor, totalCount } = Messages.findPaginated(
{ ...filter, rid },
{
sort: { ts: 1 },
skip,
limit,
readPreference,
},
);
const [results, total] = await Promise.all([cursor.toArray(), totalCount]);
const result = {
total,
exported: results.length,
messages: [] as string[],
uploads: [] as FileProp[],
};
results.forEach((msg) => {
const messageObject = getMessageData(msg, hideUsers, userData, usersMap);
if (msg.file) {
result.uploads.push(msg.file);
}
result.messages.push(exportMessageObject(exportType, messageObject, msg.file));
});
return result;
};
export const exportRoomMessagesToFile = async function (
exportPath: string,
assetsPath: string,
exportType: 'json' | 'html',
roomList: (
| {
roomId: string;
roomName: string;
userId: string | undefined;
exportedCount: number;
status: string;
type: RoomType;
targetFile: string;
}
| Record<string, never>
)[],
userData: IUser,
messagesFilter = {},
usersMap = {},
hideUsers = true,
) {
await mkdir(exportPath, { recursive: true });
await mkdir(assetsPath, { recursive: true });
const result = {
fileList: [] as FileProp[],
};
const limit =
settings.get<number>('UserData_MessageLimitPerRequest') > 0 ? settings.get<number>('UserData_MessageLimitPerRequest') : 1000;
for await (const exportOpRoomData of roomList) {
if (!('targetFile' in exportOpRoomData)) {
continue;
}
const filePath = joinPath(exportPath, exportOpRoomData.targetFile);
if (exportOpRoomData.status === 'pending') {
exportOpRoomData.status = 'exporting';
if (exportType === 'html') {
await writeFile(filePath, '<meta http-equiv="content-type" content="text/html; charset=utf-8">', { encoding: 'utf8' });
}
}
const skip = exportOpRoomData.exportedCount;
const { total, exported, uploads, messages } = await exportRoomMessages(
exportOpRoomData.roomId,
exportType,
skip,
limit,
userData,
messagesFilter,
usersMap,
hideUsers,
);
result.fileList.push(...uploads);
exportOpRoomData.exportedCount += exported;
if (total <= exportOpRoomData.exportedCount) {
exportOpRoomData.status = 'completed';
}
await writeFile(filePath, `${messages.join('\n')}\n`, { encoding: 'utf8', flag: 'a' });
}
return result;
};