apps/meteor/server/models/raw/Messages.ts
import type {
ILivechatDepartment,
IMessage,
IRoom,
IUser,
MessageTypesValues,
RocketChatRecordDeleted,
MessageAttachment,
IMessageWithPendingFileImport,
} from '@rocket.chat/core-typings';
import type { FindPaginated, IMessagesModel } from '@rocket.chat/model-typings';
import type { PaginatedRequest } from '@rocket.chat/rest-typings';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import type {
AggregationCursor,
Collection,
CountDocumentsOptions,
AggregateOptions,
FindCursor,
Db,
Filter,
FindOptions,
IndexDescription,
InsertOneResult,
DeleteResult,
UpdateResult,
Document,
UpdateFilter,
ModifyResult,
} from 'mongodb';
import { otrSystemMessages } from '../../../app/otr/lib/constants';
import { readSecondaryPreferred } from '../../database/readSecondaryPreferred';
import { BaseRaw } from './BaseRaw';
type DeepWritable<T> = T extends (...args: any) => any
? T
: {
-readonly [P in keyof T]: DeepWritable<T[P]>;
};
export class MessagesRaw extends BaseRaw<IMessage> implements IMessagesModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IMessage>>) {
super(db, 'message', trash);
}
protected modelIndexes(): IndexDescription[] {
return [
{ key: { rid: 1, ts: 1, _updatedAt: 1 } },
{ key: { ts: 1 } },
{ key: { 'u._id': 1 } },
{ key: { editedAt: 1 }, sparse: true },
{ key: { 'editedBy._id': 1 }, sparse: true },
{ key: { 'rid': 1, 't': 1, 'u._id': 1 } },
{ key: { expireAt: 1 }, expireAfterSeconds: 0 },
{ key: { msg: 'text' } },
{ key: { 'file._id': 1 }, sparse: true },
{ key: { 'mentions.username': 1 }, sparse: true },
{ key: { pinned: 1 }, sparse: true },
{ key: { location: '2dsphere' } },
{ key: { slackTs: 1, slackBotId: 1 }, sparse: true },
{ key: { unread: 1 }, sparse: true },
{ key: { 'pinnedBy._id': 1 }, sparse: true },
{ key: { 'starred._id': 1 }, sparse: true },
// discussions
{ key: { drid: 1 }, sparse: true },
// threads
{ key: { tmid: 1 }, sparse: true },
{ key: { tcount: 1, tlm: 1 }, sparse: true },
{ key: { rid: 1, tlm: -1 }, partialFilterExpression: { tcount: { $exists: true } } }, // used for the List Threads
{ key: { rid: 1, tcount: 1 } }, // used for the List Threads Count
// livechat
{ key: { 'navigation.token': 1 }, sparse: true },
{ key: { 'federation.eventId': 1 }, sparse: true },
{ key: { t: 1 }, sparse: true },
];
}
findVisibleByMentionAndRoomId(username: IUser['username'], rid: IRoom['_id'], options?: FindOptions<IMessage>): FindCursor<IMessage> {
const query: Filter<IMessage> = {
'_hidden': { $ne: true },
'mentions.username': username,
rid,
};
return this.find(query, options);
}
findPaginatedVisibleByMentionAndRoomId(
username: IUser['username'],
rid: IRoom['_id'],
options?: FindOptions<IMessage>,
): FindPaginated<FindCursor<IMessage>> {
const query: Filter<IMessage> = {
'_hidden': { $ne: true },
'mentions.username': username,
rid,
};
return this.findPaginated(query, options);
}
findStarredByUserAtRoom(
userId: IUser['_id'],
roomId: IRoom['_id'],
options?: FindOptions<IMessage>,
): FindPaginated<FindCursor<IMessage>> {
const query: Filter<IMessage> = {
'_hidden': { $ne: true },
'starred._id': userId,
'rid': roomId,
};
return this.findPaginated(query, options);
}
findPaginatedByRoomIdAndType(
roomId: IRoom['_id'],
type: IMessage['t'],
options: FindOptions<IMessage> = {},
): FindPaginated<FindCursor<IMessage>> {
const query = {
rid: roomId,
t: type,
};
return this.findPaginated(query, options);
}
// TODO: do we need this? currently not used anywhere
findDiscussionsByRoom(rid: IRoom['_id'], options?: FindOptions<IMessage>): FindCursor<IMessage> {
const query: Filter<IMessage> = { rid, drid: { $exists: true } };
return this.find(query, options);
}
findDiscussionsByRoomAndText(rid: IRoom['_id'], text: string, options?: FindOptions<IMessage>): FindPaginated<FindCursor<IMessage>> {
const query: Filter<IMessage> = {
rid,
drid: { $exists: true },
msg: new RegExp(escapeRegExp(text), 'i'),
};
return this.findPaginated(query, options);
}
findAllNumberOfTransferredRooms({
start,
end,
departmentId,
onlyCount,
options,
}: {
start: Date;
end: Date;
departmentId?: ILivechatDepartment['_id'];
onlyCount: true;
options?: PaginatedRequest;
}): AggregationCursor<{ total: number }>;
findAllNumberOfTransferredRooms({
start,
end,
departmentId,
onlyCount,
options,
}: {
start: Date;
end: Date;
departmentId?: ILivechatDepartment['_id'];
onlyCount?: false;
options?: PaginatedRequest;
}): AggregationCursor<{ _id: string | null; numberOfTransferredRooms: number }>;
findAllNumberOfTransferredRooms({
start,
end,
departmentId,
onlyCount = false,
options = {},
}: {
start: Date;
end: Date;
departmentId?: ILivechatDepartment['_id'];
onlyCount?: boolean;
options?: PaginatedRequest;
}): AggregationCursor<{ total: number }> | AggregationCursor<{ _id: string | null; numberOfTransferredRooms: number }> {
// FIXME: aggregation type definitions
const match = {
$match: {
t: 'livechat_transfer_history',
ts: { $gte: new Date(start), $lte: new Date(end) },
},
};
const lookup = {
$lookup: {
from: 'rocketchat_room',
localField: 'rid',
foreignField: '_id',
as: 'room',
},
};
const unwind = {
$unwind: {
path: '$room',
preserveNullAndEmptyArrays: true,
},
};
const group = {
$group: {
_id: {
_id: null,
departmentId: '$room.departmentId',
},
numberOfTransferredRooms: { $sum: 1 },
},
};
const project = {
$project: {
_id: { $ifNull: ['$_id.departmentId', null] },
numberOfTransferredRooms: 1,
},
};
const firstParams: Exclude<Parameters<Collection<IMessage>['aggregate']>[0], undefined> = [match, lookup, unwind];
if (departmentId) {
firstParams.push({
$match: {
'room.departmentId': departmentId,
},
});
}
const sort = { $sort: options.sort || { name: 1 } };
const params = [...firstParams, group, project, sort];
if (onlyCount) {
params.push({ $count: 'total' });
return this.col.aggregate<{ total: number }>(params, { readPreference: readSecondaryPreferred() });
}
if (options.offset) {
params.push({ $skip: options.offset });
}
if (options.count) {
params.push({ $limit: options.count });
}
return this.col.aggregate<{ _id: string | null; numberOfTransferredRooms: number }>(params, {
allowDiskUse: true,
readPreference: readSecondaryPreferred(),
});
}
getTotalOfMessagesSentByDate({ start, end, options = {} }: { start: Date; end: Date; options?: PaginatedRequest }): Promise<any[]> {
const params: Exclude<Parameters<Collection<IMessage>['aggregate']>[0], undefined> = [
{ $match: { t: { $exists: false }, ts: { $gte: start, $lte: end } } },
{
$group: {
_id: {
rid: '$rid',
date: {
$dateToString: { format: '%Y%m%d', date: '$ts' },
},
},
messages: { $sum: 1 },
},
},
{
$group: {
_id: '$_id.rid',
data: {
$push: {
date: '$_id.date',
messages: '$messages',
},
},
},
},
{
$lookup: {
from: 'rocketchat_room',
localField: '_id',
foreignField: '_id',
as: 'room',
},
},
{
$unwind: {
path: '$room',
},
},
{
$project: {
data: '$data',
room: {
_id: '$room._id',
name: {
$cond: [{ $ifNull: ['$room.fname', false] }, '$room.fname', '$room.name'],
},
t: '$room.t',
usernames: {
$cond: [{ $ifNull: ['$room.usernames', false] }, '$room.usernames', []],
},
},
type: 'messages',
},
},
{
$unwind: {
path: '$data',
},
},
{
$project: {
_id: 0,
date: '$data.date',
room: 1,
type: 1,
messages: '$data.messages',
},
},
];
if (options.sort) {
params.push({ $sort: options.sort });
}
if (options.count) {
params.push({ $limit: options.count });
}
return this.col.aggregate(params, { allowDiskUse: true, readPreference: readSecondaryPreferred() }).toArray();
}
findLivechatClosedMessages(rid: IRoom['_id'], searchTerm?: string, options?: FindOptions<IMessage>): FindPaginated<FindCursor<IMessage>> {
return this.findPaginated(
{
rid,
$or: [{ t: { $exists: false } }, { t: 'livechat-close' }],
...(searchTerm && { msg: new RegExp(escapeRegExp(searchTerm), 'ig') }),
},
options,
);
}
findLivechatClosingMessage(rid: IRoom['_id'], options?: FindOptions<IMessage>): Promise<IMessage | null> {
return this.findOne<IMessage>(
{
rid,
t: 'livechat-close',
},
options,
);
}
findLivechatMessages(rid: IRoom['_id'], options?: FindOptions<IMessage>): FindCursor<IMessage> {
return this.find(
{
rid,
$or: [{ t: { $exists: false } }, { t: 'livechat-close' }],
},
options,
);
}
findVisibleByRoomIdNotContainingTypesBeforeTs(
roomId: IRoom['_id'],
types: IMessage['t'][],
ts: Date,
showSystemMessages: boolean,
options?: FindOptions<IMessage>,
showThreadMessages = true,
): FindCursor<IMessage> {
const query: Filter<IMessage> = {
_hidden: {
$ne: true,
},
rid: roomId,
ts: { $lt: ts },
...(!showThreadMessages && {
$or: [
{
tmid: { $exists: false },
},
{
tshow: true,
},
],
}),
};
if (types.length > 0) {
query.t = { $nin: types };
}
if (!showSystemMessages) {
query.t = { $exists: false };
}
return this.find(query, options);
}
findVisibleByRoomIdNotContainingTypesAndUsers(
roomId: IRoom['_id'],
types: IMessage['t'][],
users?: string[],
options?: FindOptions<IMessage>,
showThreadMessages = true,
): FindCursor<IMessage> {
const query: Filter<IMessage> = {
_hidden: {
$ne: true,
},
...(Array.isArray(users) && users.length > 0 && { 'u._id': { $nin: users } }),
rid: roomId,
...(!showThreadMessages && {
$or: [
{
tmid: { $exists: false },
},
{
tshow: true,
},
],
}),
};
if (types.length > 0) {
query.t = { $nin: types };
}
return this.find(query, options);
}
findLivechatMessagesWithoutTypes(
rid: IRoom['_id'],
ignoredTypes: IMessage['t'][],
showSystemMessages: boolean,
options?: FindOptions<IMessage>,
): FindCursor<IMessage> {
const query: Filter<IMessage> = {
rid,
};
if (ignoredTypes.length > 0) {
query.t = { $nin: ignoredTypes };
}
if (!showSystemMessages) {
query.t = { $exists: false };
}
return this.find(query, options);
}
async setBlocksById(_id: string, blocks: Required<IMessage>['blocks']): Promise<void> {
await this.updateOne(
{ _id },
{
$set: {
blocks,
},
},
);
}
async addBlocksById(_id: string, blocks: Required<IMessage>['blocks']): Promise<void> {
await this.updateOne({ _id }, { $addToSet: { blocks: { $each: blocks } } });
}
async countRoomsWithStarredMessages(options: AggregateOptions): Promise<number> {
const queryResult = await this.col
.aggregate<{ _id: null; total: number }>(
[
{ $match: { 'starred._id': { $exists: true } } },
{ $group: { _id: '$rid' } },
{
$group: {
_id: null,
total: { $sum: 1 },
},
},
],
options,
)
.next();
return queryResult?.total || 0;
}
async countRoomsWithMessageType(type: IMessage['t'], options: AggregateOptions): Promise<number> {
const queryResult = await this.col
.aggregate<{ _id: null; total: number }>(
[
{ $match: { t: type } },
{ $group: { _id: '$rid' } },
{
$group: {
_id: null,
total: { $sum: 1 },
},
},
],
options,
)
.next();
return queryResult?.total || 0;
}
async countByType(type: IMessage['t'], options: CountDocumentsOptions): Promise<number> {
return this.col.countDocuments({ t: type }, options);
}
async countRoomsWithPinnedMessages(options: AggregateOptions): Promise<number> {
const queryResult = await this.col
.aggregate<{ _id: null; total: number }>(
[
{ $match: { pinned: true } },
{ $group: { _id: '$rid' } },
{
$group: {
_id: null,
total: { $sum: 1 },
},
},
],
options,
)
.next();
return queryResult?.total || 0;
}
findPinned(options?: FindOptions<IMessage>): FindCursor<IMessage> {
const query: Filter<IMessage> = {
t: { $ne: 'rm' as MessageTypesValues },
_hidden: { $ne: true },
pinned: true,
};
return this.find(query, options);
}
findPaginatedPinnedByRoom(roomId: IMessage['rid'], options?: FindOptions<IMessage>): FindPaginated<FindCursor<IMessage>> {
const query: Filter<IMessage> = {
t: { $ne: 'rm' },
_hidden: { $ne: true },
pinned: true,
rid: roomId,
};
return this.findPaginated(query, options);
}
findStarred(options?: FindOptions<IMessage>): FindCursor<IMessage> {
const query: Filter<IMessage> = {
'_hidden': { $ne: true },
'starred._id': { $exists: true },
};
return this.find(query, options);
}
async setFederationReactionEventId(username: string, _id: string, reaction: string, federationEventId: string): Promise<void> {
await this.updateOne(
{ _id },
{
$set: {
[`reactions.${reaction}.federationReactionEventIds.${federationEventId}`]: username,
} as any,
},
);
}
async unsetFederationReactionEventId(federationEventId: string, _id: string, reaction: string): Promise<void> {
await this.updateOne(
{ _id },
{
$unset: {
[`reactions.${reaction}.federationReactionEventIds.${federationEventId}`]: 1,
},
},
);
}
async findOneByFederationId(federationEventId: string): Promise<IMessage | null> {
return this.findOne({ 'federation.eventId': federationEventId });
}
async setFederationEventIdById(_id: string, federationEventId: string): Promise<void> {
await this.updateOne(
{ _id },
{
$set: {
'federation.eventId': federationEventId,
},
},
);
}
async findOneByFederationIdAndUsernameOnReactions(federationEventId: string, username: string): Promise<IMessage | null> {
return (
await this.col
.aggregate(
[
{
$match: {
t: { $ne: 'rm' },
},
},
{
$project: {
document: '$$ROOT',
reactions: { $objectToArray: '$reactions' },
},
},
{
$unwind: {
path: '$reactions',
},
},
{
$match: {
$and: [
{ 'reactions.v.usernames': { $in: [username] } },
{ [`reactions.v.federationReactionEventIds.${federationEventId}`]: username },
],
},
},
{ $replaceRoot: { newRoot: '$document' } },
],
{ readPreference: readSecondaryPreferred() },
)
.toArray()
)[0] as IMessage;
}
removeByRoomId(roomId: string): Promise<DeleteResult> {
return this.deleteMany({ rid: roomId });
}
setReactions(messageId: string, reactions: IMessage['reactions']): Promise<UpdateResult> {
return this.updateOne({ _id: messageId }, { $set: { reactions } });
}
keepHistoryForToken(token: string): Promise<UpdateResult | Document> {
return this.updateMany(
{
'navigation.token': token,
'expireAt': {
$exists: true,
},
},
{
$unset: {
expireAt: 1,
},
},
);
}
setRoomIdByToken(token: string, rid: string): Promise<UpdateResult | Document> {
return this.updateMany(
{
'navigation.token': token,
// @ts-expect-error - mongo allows it, but types don't :(
'rid': null,
},
{
$set: {
rid,
},
},
);
}
unsetReactions(messageId: string): Promise<UpdateResult> {
return this.updateOne({ _id: messageId }, { $unset: { reactions: 1 } });
}
deleteOldOTRMessages(roomId: string, ts: Date): Promise<DeleteResult> {
const query: Filter<IMessage> = {
rid: roomId,
t: {
$in: [
'otr',
otrSystemMessages.USER_JOINED_OTR,
otrSystemMessages.USER_REQUESTED_OTR_KEY_REFRESH,
otrSystemMessages.USER_KEY_REFRESHED_SUCCESSFULLY,
],
},
ts: { $lte: ts },
};
return this.col.deleteMany(query);
}
addTranslations(messageId: string, translations: Record<string, string>, providerName: string): Promise<UpdateResult> {
const updateObj: DeepWritable<UpdateFilter<IMessage>['$set']> = { translationProvider: providerName };
Object.keys(translations).forEach((key) => {
const translation = translations[key];
updateObj[`translations.${key}`] = translation;
});
return this.updateOne({ _id: messageId }, { $set: updateObj });
}
addAttachmentTranslations(messageId: string, attachmentIndex: string, translations: Record<string, string>): Promise<UpdateResult> {
const updateObj: DeepWritable<UpdateFilter<IMessage>['$set']> = {};
Object.keys(translations).forEach((key) => {
const translation = translations[key];
updateObj[`attachments.${attachmentIndex}.translations.${key}`] = translation;
});
return this.updateOne({ _id: messageId }, { $set: updateObj });
}
setImportFileRocketChatAttachment(
importFileId: string,
rocketChatUrl: string,
attachment: MessageAttachment,
): Promise<UpdateResult | Document> {
const query = {
'_importFile.id': importFileId,
};
return this.updateMany(query, {
$set: {
'_importFile.rocketChatUrl': rocketChatUrl,
'_importFile.downloaded': true,
},
$addToSet: {
attachments: attachment,
},
});
}
countVisibleByRoomIdBetweenTimestampsInclusive(roomId: string, afterTimestamp: Date, beforeTimestamp: Date): Promise<number> {
const query = {
_hidden: {
$ne: true,
},
rid: roomId,
ts: {
$gte: afterTimestamp,
$lte: beforeTimestamp,
},
};
return this.col.countDocuments(query);
}
// FIND
findByMention(username: string, options?: FindOptions<IMessage>): FindCursor<IMessage> {
const query = { 'mentions.username': username };
return this.find(query, options);
}
findFilesByUserId(userId: string, options: FindOptions<IMessage> = {}): FindCursor<Pick<IMessage, 'file'>> {
const query = {
'u._id': userId,
'file._id': { $exists: true },
};
return this.find(query, { projection: { 'file._id': 1 }, ...options });
}
findFilesByRoomIdPinnedTimestampAndUsers(
rid: string,
excludePinned: boolean,
ignoreDiscussion = true,
ts: Filter<IMessage>['ts'],
users: string[] = [],
ignoreThreads = true,
options: FindOptions<IMessage> = {},
): FindCursor<IMessage> {
const query: Filter<IMessage> = {
rid,
ts,
'file._id': { $exists: true },
...(excludePinned ? { pinned: { $ne: true } } : {}),
...(ignoreThreads ? { tmid: { $exists: false }, tcount: { $exists: false } } : {}),
...(ignoreDiscussion ? { drid: { $exists: false } } : {}),
...(users.length ? { 'u.username': { $in: users } } : {}),
};
return this.find(query, { projection: { 'file._id': 1 }, ...options });
}
findDiscussionByRoomIdPinnedTimestampAndUsers(
rid: string,
excludePinned: boolean,
ts: Filter<IMessage>['ts'],
users: string[] = [],
options: FindOptions<IMessage> = {},
): FindCursor<IMessage> {
const query: Filter<IMessage> = {
rid,
ts,
drid: { $exists: true },
...(excludePinned ? { pinned: { $ne: true } } : {}),
...(users.length ? { 'u.username': { $in: users } } : {}),
};
return this.find(query, options);
}
findVisibleByRoomId<T extends IMessage = IMessage>(rid: string, options?: FindOptions<T>): FindCursor<T> {
const query = {
_hidden: {
$ne: true,
},
rid,
};
return this.find(query, options);
}
findVisibleByIds(ids: string[], options?: FindOptions<IMessage>): FindCursor<IMessage> {
const query = {
_id: { $in: ids },
_hidden: {
$ne: true,
},
};
return this.find(query, options);
}
findVisibleThreadByThreadId(tmid: string, options?: FindOptions<IMessage>): FindCursor<IMessage> {
const query = {
_hidden: {
$ne: true,
},
tmid,
};
return this.find(query, options);
}
findVisibleByRoomIdNotContainingTypes(
roomId: string,
types: MessageTypesValues[],
options?: FindOptions<IMessage>,
showThreadMessages = true,
): FindCursor<IMessage> {
const query: Filter<IMessage> = {
_hidden: {
$ne: true,
},
rid: roomId,
...(!showThreadMessages && {
$or: [
{
tmid: { $exists: false },
},
{
tshow: true,
},
],
}),
...(Array.isArray(types) &&
types.length > 0 && {
t: { $nin: types },
}),
};
return this.find(query, options);
}
findVisibleByRoomIdAfterTimestamp(roomId: string, timestamp: Date, options?: FindOptions<IMessage>): FindCursor<IMessage> {
const query = {
_hidden: {
$ne: true,
},
rid: roomId,
ts: {
$gt: timestamp,
},
};
return this.find(query, options);
}
findForUpdates(roomId: string, timestamp: Date, options?: FindOptions<IMessage>): FindCursor<IMessage> {
const query = {
_hidden: {
$ne: true,
},
rid: roomId,
_updatedAt: {
$gt: timestamp,
},
};
return this.find(query, options);
}
findVisibleByRoomIdBeforeTimestamp(roomId: string, timestamp: Date, options?: FindOptions<IMessage>): FindCursor<IMessage> {
const query = {
_hidden: {
$ne: true,
},
rid: roomId,
ts: {
$lt: timestamp,
},
};
return this.find(query, options);
}
findVisibleByRoomIdBeforeTimestampNotContainingTypes(
roomId: string,
timestamp: Date,
types: MessageTypesValues[],
options?: FindOptions<IMessage>,
showThreadMessages = true,
inclusive = false,
): FindCursor<IMessage> {
const query = {
_hidden: {
$ne: true,
},
rid: roomId,
ts: {
[inclusive ? '$lte' : '$lt']: timestamp,
},
...(!showThreadMessages && {
$or: [
{
tmid: { $exists: false },
},
{
tshow: true,
},
],
}),
...(Array.isArray(types) &&
types.length > 0 && {
t: { $nin: types },
}),
};
return this.find(query, options);
}
findVisibleByRoomIdBetweenTimestampsNotContainingTypes(
roomId: string,
afterTimestamp: Date,
beforeTimestamp: Date,
types: MessageTypesValues[],
options: FindOptions<IMessage> = {},
showThreadMessages = true,
inclusive = false,
): FindCursor<IMessage> {
const query = {
_hidden: {
$ne: true,
},
rid: roomId,
ts: {
[inclusive ? '$gte' : '$gt']: afterTimestamp,
[inclusive ? '$lte' : '$lt']: beforeTimestamp,
},
...(!showThreadMessages && {
$or: [
{
tmid: { $exists: false },
},
{
tshow: true,
},
],
}),
...(Array.isArray(types) &&
types.length > 0 && {
t: { $nin: types },
}),
};
return this.find(query, options);
}
countVisibleByRoomIdBetweenTimestampsNotContainingTypes(
roomId: string,
afterTimestamp: Date,
beforeTimestamp: Date,
types: MessageTypesValues[],
showThreadMessages = true,
inclusive = false,
): Promise<number> {
const query = {
_hidden: {
$ne: true,
},
rid: roomId,
ts: {
[inclusive ? '$gte' : '$gt']: afterTimestamp,
[inclusive ? '$lte' : '$lt']: beforeTimestamp,
},
...(!showThreadMessages && {
$or: [
{
tmid: { $exists: false },
},
{
tshow: true,
},
],
}),
...(Array.isArray(types) &&
types.length > 0 && {
t: { $nin: types },
}),
};
return this.col.countDocuments(query);
}
async getLastTimestamp(options: FindOptions<IMessage> = { projection: { _id: 0, ts: 1 } }): Promise<Date | undefined> {
options.sort = { ts: -1 };
options.limit = 1;
const [message] = await this.find({}, options).toArray();
return message?.ts;
}
findByRoomIdAndMessageIds(rid: string, messageIds: string[], options?: FindOptions<IMessage>): FindCursor<IMessage> {
const query = {
rid,
_id: {
$in: messageIds,
},
};
return this.find(query, options);
}
findOneBySlackBotIdAndSlackTs(slackBotId: string, slackTs: Date): Promise<IMessage | null> {
const query = {
slackBotId,
slackTs,
};
return this.findOne(query);
}
findOneBySlackTs(slackTs: Date): Promise<IMessage | null> {
const query = { slackTs };
return this.findOne(query);
}
findOneByRoomIdAndMessageId(rid: string, messageId: string, options?: FindOptions<IMessage>): Promise<IMessage | null> {
const query = {
rid,
_id: messageId,
};
return this.findOne(query, options);
}
getLastVisibleUserMessageSentByRoomId(rid: string, messageId?: string): Promise<IMessage | null> {
const query: Filter<IMessage> = {
rid,
_hidden: { $ne: true },
$or: [{ t: 'e2e' }, { t: { $exists: false }, tmid: { $exists: false } }, { t: { $exists: false }, tshow: true }],
...(messageId && { _id: { $ne: messageId } }),
};
const options: FindOptions<IMessage> = {
sort: {
ts: -1,
},
};
return this.findOne<IMessage>(query, options);
}
async cloneAndSaveAsHistoryByRecord(record: IMessage, user: IMessage['u']): Promise<InsertOneResult<IMessage>> {
const { _id: _, ...nRecord } = record;
return this.insertOne({
...nRecord,
_hidden: true,
// @ts-expect-error - mongo allows it, but types don't :(
parent: record._id,
editedAt: new Date(),
editedBy: {
_id: user._id,
username: user.username,
},
});
}
async cloneAndSaveAsHistoryById(_id: string, user: IMessage['u']): Promise<InsertOneResult<IMessage>> {
const record = await this.findOneById(_id);
if (!record) {
throw new Error('Record not found');
}
return this.cloneAndSaveAsHistoryByRecord(record, user);
}
// UPDATE
setHiddenById(_id: string, hidden: boolean): Promise<UpdateResult> {
if (hidden == null) {
hidden = true;
}
const query = { _id };
const update: UpdateFilter<IMessage> = {
$set: {
_hidden: hidden,
},
};
return this.updateOne(query, update);
}
setHiddenByIds(ids: string[], hidden: boolean): Promise<Document | UpdateResult> {
if (hidden == null) {
hidden = true;
}
const query = { _id: { $in: ids } };
const update: UpdateFilter<IMessage> = {
$set: {
_hidden: hidden,
},
};
return this.updateMany(query, update);
}
setAsDeletedByIdAndUser(_id: string, user: IMessage['u']): Promise<UpdateResult> {
const query = { _id };
const update: UpdateFilter<IMessage> = {
$set: {
msg: '',
t: 'rm',
urls: [],
mentions: [],
attachments: [],
reactions: {},
editedAt: new Date(),
editedBy: {
_id: user._id,
username: user.username,
},
},
$unset: {
md: 1,
blocks: 1,
tshow: 1,
},
};
return this.updateOne(query, update);
}
setAsDeletedByIdsAndUser(ids: string[], user: IMessage['u']): Promise<Document | UpdateResult> {
const query = { _id: { $in: ids } };
const update: UpdateFilter<IMessage> = {
$set: {
msg: '',
t: 'rm',
urls: [],
mentions: [],
attachments: [],
reactions: {},
editedAt: new Date(),
editedBy: {
_id: user._id,
username: user.username,
},
},
$unset: {
md: 1,
blocks: 1,
tshow: 1,
},
};
return this.updateMany(query, update);
}
setPinnedByIdAndUserId(
_id: string,
pinnedBy: Pick<IUser, '_id' | 'username'> | undefined,
pinned?: boolean,
pinnedAt?: Date,
): Promise<UpdateResult> {
if (pinned == null) {
pinned = true;
}
if (pinnedAt == null) {
pinnedAt = undefined;
}
const query = { _id };
const update: UpdateFilter<IMessage> = {
$set: {
pinned,
pinnedAt: pinnedAt || new Date(),
pinnedBy,
},
};
return this.updateOne(query, update);
}
setUrlsById(_id: string, urls: NonNullable<IMessage['urls']>): Promise<UpdateResult> {
const query = { _id };
const update: UpdateFilter<IMessage> = {
$set: {
urls,
},
};
return this.updateOne(query, update);
}
updateAllUsernamesByUserId(userId: string, username: string): Promise<UpdateResult | Document> {
const query = { 'u._id': userId };
const update = {
$set: {
'u.username': username,
},
};
return this.updateMany(query, update);
}
updateUsernameOfEditByUserId(userId: string, username: string): Promise<UpdateResult | Document> {
const query = { 'editedBy._id': userId };
const update = {
$set: {
'editedBy.username': username,
},
};
return this.updateMany(query, update);
}
updateUsernameAndMessageOfMentionByIdAndOldUsername(
_id: string,
oldUsername: string,
newUsername: string,
newMessage: string,
): Promise<UpdateResult> {
const query = {
_id,
'mentions.username': oldUsername,
};
const update: UpdateFilter<IMessage> = {
$set: {
'mentions.$.username': newUsername,
'msg': newMessage,
},
$unset: {
md: 1,
},
};
return this.updateOne(query, update);
}
updateUserStarById(_id: string, userId: string, starred?: boolean): Promise<UpdateResult> {
let update: UpdateFilter<IMessage>;
const query = { _id };
if (starred) {
update = {
$addToSet: {
starred: { _id: userId },
},
};
} else {
update = {
$pull: {
starred: { _id: userId },
},
};
}
return this.updateOne(query, update);
}
setMessageAttachments(_id: string, attachments: IMessage['attachments']): Promise<UpdateResult> {
const query = { _id };
const update: UpdateFilter<IMessage> = {
$set: {
attachments,
},
};
return this.updateOne(query, update);
}
setSlackBotIdAndSlackTs(_id: string, slackBotId: string, slackTs: Date): Promise<UpdateResult> {
const query = { _id };
const update: UpdateFilter<IMessage> = {
$set: {
slackBotId,
slackTs,
},
};
return this.updateOne(query, update);
}
unlinkUserId(userId: string, newUserId: string, newUsername: string, newNameAlias: string): Promise<UpdateResult | Document> {
const query = {
'u._id': userId,
};
const update = {
$set: {
'alias': newNameAlias,
'u._id': newUserId,
'u.username': newUsername,
'u.name': undefined,
},
};
return this.updateMany(query, update);
}
// INSERT
async createWithTypeRoomIdMessageUserAndUnread(
type: MessageTypesValues,
rid: string,
message: string,
user: Pick<IMessage['u'], '_id' | 'username' | 'name'>,
unread?: boolean,
extraData?: Partial<IMessage>,
): Promise<InsertOneResult<IMessage>> {
const record: Omit<IMessage, '_id' | '_updatedAt'> = {
t: type,
rid,
ts: new Date(),
msg: message,
u: {
_id: user._id,
username: user.username,
name: user.name,
},
groupable: false as const,
...(unread && { unread: true }),
};
const data = Object.assign(record, extraData);
return this.insertOne(data);
}
// REMOVE
removeByRoomIds(rids: string[]): Promise<DeleteResult> {
return this.deleteMany({ rid: { $in: rids } });
}
findThreadsByRoomIdPinnedTimestampAndUsers(
{
rid,
pinned,
ignoreDiscussion = true,
ts,
users = [],
}: { rid: string; pinned: boolean; ignoreDiscussion?: boolean; ts: Filter<IMessage>['ts']; users: string[] },
options?: FindOptions<IMessage>,
): FindCursor<IMessage> {
const query: Filter<IMessage> = {
rid,
ts,
tlm: { $exists: true },
tcount: { $exists: true },
...(users.length > 0 && { 'u.username': { $in: users } }),
};
if (pinned) {
query.pinned = { $ne: true };
}
if (ignoreDiscussion) {
query.drid = { $exists: false };
}
return this.find(query, options);
}
async findByIdPinnedTimestampLimitAndUsers(
rid: string,
ignorePinned: boolean,
ignoreDiscussion = true,
ts: Filter<IMessage>['ts'],
limit: number,
users: string[] = [],
ignoreThreads = true,
): Promise<string[]> {
const query: Filter<IMessage> = {
rid,
ts,
...(users.length > 0 && { 'u.username': { $in: users } }),
};
if (ignorePinned) {
query.pinned = { $ne: true };
}
if (ignoreDiscussion) {
query.drid = { $exists: false };
}
if (ignoreThreads) {
query.tmid = { $exists: false };
query.tcount = { $exists: false };
}
return (
await this.find(query, {
projection: {
_id: 1,
},
limit,
}).toArray()
).map(({ _id }) => _id);
}
async removeByIdPinnedTimestampLimitAndUsers(
rid: string,
ignorePinned: boolean,
ignoreDiscussion = true,
ts: Filter<IMessage>['ts'],
limit: number,
users: string[] = [],
ignoreThreads = true,
selectedMessageIds: string[] = [],
): Promise<number> {
const query: Filter<IMessage> = {
rid,
ts,
...(users.length > 0 && { 'u.username': { $in: users } }),
};
if (ignorePinned) {
query.pinned = { $ne: true };
}
if (ignoreDiscussion) {
query.drid = { $exists: false };
}
if (ignoreThreads) {
query.tmid = { $exists: false };
query.tcount = { $exists: false };
}
const notCountedMessages = (
await this.find(
{
...query,
$or: [{ _hidden: true }, { editedAt: { $exists: true }, editedBy: { $exists: true }, t: 'rm' }],
},
{
projection: {
_id: 1,
},
limit,
},
).toArray()
).length;
if (!limit) {
const count = (await this.deleteMany(query)).deletedCount - notCountedMessages;
return count;
}
const count =
(
await this.deleteMany({
_id: {
$in: selectedMessageIds,
},
})
).deletedCount - notCountedMessages;
return count;
}
removeByUserId(userId: string): Promise<DeleteResult> {
const query = { 'u._id': userId };
return this.deleteMany(query);
}
getMessageByFileId(fileID: string): Promise<IMessage | null> {
return this.findOne({ 'file._id': fileID });
}
getMessageByFileIdAndUsername(fileID: string, userId: string): Promise<IMessage | null> {
const query = {
'file._id': fileID,
'u._id': userId,
};
const options = {
projection: {
unread: 0,
mentions: 0,
channels: 0,
groupable: 0,
},
};
return this.findOne(query, options);
}
setVisibleMessagesAsRead(rid: string, until: Date): Promise<UpdateResult | Document> {
return this.updateMany(
{
rid,
unread: true,
ts: { $lt: until },
$or: [
{
tmid: { $exists: false },
},
{
tshow: true,
},
],
},
{
$unset: {
unread: 1,
},
},
);
}
setThreadMessagesAsRead(tmid: string, until: Date): Promise<UpdateResult | Document> {
return this.updateMany(
{
tmid,
unread: true,
ts: { $lt: until },
},
{
$unset: {
unread: 1,
},
},
);
}
setAsReadById(_id: string): Promise<UpdateResult> {
return this.updateOne(
{
_id,
},
{
$unset: {
unread: 1,
},
},
);
}
findVisibleUnreadMessagesByRoomAndDate(rid: string, after: Date): FindCursor<Pick<IMessage, '_id' | 't' | 'pinned' | 'drid' | 'tmid'>> {
const query = {
unread: true,
rid,
$or: [
{
tmid: { $exists: false },
},
{
tshow: true,
},
],
...(after && { ts: { $gt: after } }),
};
return this.find(query, {
projection: {
_id: 1,
t: 1,
pinned: 1,
drid: 1,
tmid: 1,
},
});
}
findUnreadThreadMessagesByDate(
tmid: string,
userId: string,
after: Date,
): FindCursor<Pick<IMessage, '_id' | 't' | 'pinned' | 'drid' | 'tmid'>> {
const query = {
'u._id': { $ne: userId },
'unread': true,
tmid,
'tshow': { $exists: false },
...(after && { ts: { $gt: after } }),
};
return this.find(query, {
projection: {
_id: 1,
t: 1,
pinned: 1,
drid: 1,
tmid: 1,
},
});
}
/**
* Copy metadata from the discussion to the system message in the parent channel
* which links to the discussion.
* Since we don't pass this metadata into the model's function, it is not a subject
* to race conditions: If multiple updates occur, the current state will be updated
* only if the new state of the discussion room is really newer.
*/
async refreshDiscussionMetadata(room: Pick<IRoom, '_id' | 'msgs' | 'lm'>): Promise<ModifyResult<IMessage>> {
const { _id: drid, msgs: dcount, lm: dlm } = room;
const query = {
drid,
};
return this.findOneAndUpdate(
query,
{
$set: {
dcount,
dlm,
},
},
{ returnDocument: 'after' },
);
}
// //////////////////////////////////////////////////////////////////
// threads
countThreads(): Promise<number> {
return this.col.countDocuments({ tcount: { $exists: true } });
}
updateRepliesByThreadId(tmid: string, replies: string[], ts: Date): Promise<UpdateResult> {
const query = {
_id: tmid,
};
const update: UpdateFilter<IMessage> = {
$addToSet: {
replies: {
$each: replies,
},
},
$set: {
tlm: ts,
},
$inc: {
tcount: 1,
},
};
return this.updateOne(query, update);
}
async getThreadFollowsByThreadId(tmid: string): Promise<string[] | undefined> {
const msg = await this.findOneById(tmid, { projection: { replies: 1 } });
return msg?.replies;
}
addThreadFollowerByThreadId(tmid: string, userId: string): Promise<UpdateResult> {
const query = {
_id: tmid,
};
const update: UpdateFilter<IMessage> = {
$addToSet: {
replies: userId,
},
};
return this.updateOne(query, update);
}
removeThreadFollowerByThreadId(tmid: string, userId: string): Promise<UpdateResult> {
const query = {
_id: tmid,
};
const update: UpdateFilter<IMessage> = {
$pull: {
replies: userId,
},
};
return this.updateOne(query, update);
}
findThreadsByRoomId(rid: string, skip: number, limit: number): FindCursor<IMessage> {
return this.find({ rid, tcount: { $exists: true } }, { sort: { tlm: -1 }, skip, limit });
}
findAgentLastMessageByVisitorLastMessageTs(roomId: string, visitorLastMessageTs: Date): Promise<IMessage | null> {
const query = {
rid: roomId,
ts: { $gt: visitorLastMessageTs },
token: { $exists: false },
};
return this.findOne(query, { sort: { ts: 1 } });
}
findAllImportedMessagesWithFilesToDownload(): FindCursor<IMessageWithPendingFileImport> {
const query = {
'_importFile.downloadUrl': {
$exists: true,
},
'_importFile.rocketChatUrl': {
$exists: false,
},
'_importFile.downloaded': {
$ne: true,
},
'_importFile.external': {
$ne: true,
},
};
return this.find<IMessageWithPendingFileImport>(query);
}
countAllImportedMessagesWithFilesToDownload(): Promise<number> {
const query = {
'_importFile.downloadUrl': {
$exists: true,
},
'_importFile.rocketChatUrl': {
$exists: false,
},
'_importFile.downloaded': {
$ne: true,
},
'_importFile.external': {
$ne: true,
},
};
return this.col.countDocuments(query);
}
decreaseReplyCountById(_id: string, inc = -1): Promise<UpdateResult> {
const query = { _id };
const update: UpdateFilter<IMessage> = {
$inc: {
tcount: inc,
},
};
return this.updateOne(query, update);
}
}