apps/meteor/server/lib/parseMessageSearchQuery.ts
import type { IMessage, IUser } from '@rocket.chat/core-typings';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import type { Filter, FindOptions } from 'mongodb';
class MessageSearchQueryParser {
private query: Exclude<Filter<IMessage>, Partial<IMessage>> = {};
private options: FindOptions<IMessage> = {
projection: {},
sort: {
ts: -1,
},
skip: 0,
limit: 20,
};
private user: IUser | undefined;
private forceRegex = false;
constructor({
user,
offset = 0,
limit = 20,
forceRegex = false,
}: {
user?: IUser;
offset?: number;
limit?: number;
forceRegex?: boolean;
}) {
this.user = user;
this.options.skip = offset;
this.options.limit = limit;
this.forceRegex = forceRegex;
}
private consumeFrom(text: string) {
const from: string[] = [];
return text.replace(/from:([a-z0-9.\-_]+)/gi, (_, username) => {
if (username === 'me' && this.user?.username && !from.includes(this.user.username)) {
username = this.user.username;
}
from.push(username);
this.query['u.username'] = {
$regex: from.join('|'),
$options: 'i',
};
return '';
});
}
private consumeMention(text: string) {
const mentions: string[] = [];
return text.replace(/mention:([a-z0-9.\-_]+)/gi, (_: string, username: string) => {
mentions.push(username);
this.query['mentions.username'] = {
$regex: mentions.join('|'),
$options: 'i',
};
return '';
});
}
/**
* Filter on messages that are starred by the current user.
*/
private consumeHasStar(text: string) {
return text.replace(/has:star/g, () => {
if (this.user?._id) {
this.query['starred._id'] = this.user._id;
}
return '';
});
}
/**
* Filter on messages that have an url.
*/
private consumeHasUrl(text: string) {
return text.replace(/has:url|has:link/g, () => {
this.query['urls.0'] = {
$exists: true,
};
return '';
});
}
/**
* Filter on pinned messages.
*/
private consumeIsPinned(text: string) {
return text.replace(/is:pinned|has:pin/g, () => {
this.query.pinned = true;
return '';
});
}
/**
* Filter on messages which have a location attached.
*/
private consumeHasLocation(text: string) {
return text.replace(/has:location|has:map/g, () => {
this.query.location = {
$exists: true,
};
return '';
});
}
/**
* Filter image tags
*/
private consumeLabel(text: string) {
return text.replace(/label:(\w+)/g, (_: string, tag: string) => {
this.query['attachments.0.labels'] = {
$regex: escapeRegExp(tag),
$options: 'i',
};
return '';
});
}
/**
* Filter on description of messages.
*/
private consumeFileDescription(text: string) {
return text.replace(/file-desc:(\w+)/g, (_: string, tag: string) => {
this.query['attachments.description'] = {
$regex: escapeRegExp(tag),
$options: 'i',
};
return '';
});
}
/**
* Filter on title of messages.
*/
private consumeFileTitle(text: string) {
return text.replace(/file-title:(\w+)/g, (_: string, tag: string) => {
this.query['attachments.title'] = {
$regex: escapeRegExp(tag),
$options: 'i',
};
return '';
});
}
/**
* Filter on messages that have been sent before a date.
*/
private consumeBefore(text: string) {
return text.replace(/before:(\d{1,2})[\/\.-](\d{1,2})[\/\.-](\d{4})/g, (_: string, day: string, month: string, year: string) => {
const beforeDate = new Date(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10));
beforeDate.setUTCHours(beforeDate.getUTCHours() + beforeDate.getTimezoneOffset() / 60 + (this.user?.utcOffset ?? 0));
this.query.ts = {
...this.query.ts,
$lte: beforeDate,
};
return '';
});
}
/**
* Filter on messages that have been sent after a date.
*/
private consumeAfter(text: string) {
return text.replace(/after:(\d{1,2})[\/\.-](\d{1,2})[\/\.-](\d{4})/g, (_: string, day: string, month: string, year: string) => {
const afterDate = new Date(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10) + 1);
afterDate.setUTCHours(afterDate.getUTCHours() + afterDate.getTimezoneOffset() / 60 + (this.user?.utcOffset ?? 0));
this.query.ts = {
...this.query.ts,
$gte: afterDate,
};
return '';
});
}
/**
* Filter on messages that have been sent on a date.
*/
private consumeOn(text: string) {
return text.replace(/on:(\d{1,2})[\/\.-](\d{1,2})[\/\.-](\d{4})/g, (_: string, day: string, month: string, year: string) => {
const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10));
date.setUTCHours(date.getUTCHours() + date.getTimezoneOffset() / 60 + (this.user?.utcOffset ?? 0));
const dayAfter = new Date(date);
dayAfter.setDate(dayAfter.getDate() + 1);
this.query.ts = {
$gte: date,
$lt: dayAfter,
};
return '';
});
}
/**
* Sort by timestamp.
*/
consumeOrder(text: string) {
return text.replace(/(?:order|sort):(asc|ascend|ascending|desc|descend|descending)/g, (_: string, direction: string) => {
if (direction.startsWith('asc')) {
this.options.sort = {
...(typeof this.options.sort === 'object' && !Array.isArray(this.options.sort) ? this.options.sort : {}),
ts: 1,
};
} else if (direction.startsWith('desc')) {
this.options.sort = {
...(typeof this.options.sort === 'object' && !Array.isArray(this.options.sort) ? this.options.sort : {}),
ts: -1,
};
}
return '';
});
}
/**
* Query in message text
*/
private consumeMessageText(text: string) {
text = text.trim().replace(/\s\s/g, ' ');
if (text === '') {
return text;
}
if (/^\/.+\/[imxs]*$/.test(text)) {
const r = text.split('/');
this.query.msg = {
$regex: r[1],
$options: r[2],
};
} else if (this.forceRegex) {
this.query.msg = {
$regex: text,
$options: 'i',
};
} else {
this.query.$text = {
$search: text,
};
this.options.projection = {
score: {
$meta: 'textScore',
},
};
}
return text;
}
parse(text: string) {
[
(input: string) => this.consumeFrom(input),
(input: string) => this.consumeMention(input),
(input: string) => this.consumeHasStar(input),
(input: string) => this.consumeHasUrl(input),
(input: string) => this.consumeIsPinned(input),
(input: string) => this.consumeHasLocation(input),
(input: string) => this.consumeLabel(input),
(input: string) => this.consumeFileDescription(input),
(input: string) => this.consumeFileTitle(input),
(input: string) => this.consumeBefore(input),
(input: string) => this.consumeAfter(input),
(input: string) => this.consumeOn(input),
(input: string) => this.consumeOrder(input),
(input: string) => this.consumeMessageText(input),
].reduce((text, fn) => fn(text), text);
return {
query: this.query,
options: this.options,
};
}
}
/**
* Parses a message search query and returns a MongoDB query and options
* @param text The query text
* @param options The options
* @param options.user The user object
* @param options.offset The offset
* @param options.limit The limit
* @param options.forceRegex Whether to force the use of regex
* @returns The MongoDB query and options
* @private
* @example
* const { query, options } = parseMessageSearchQuery('from:rocket.cat', {
* user: await Meteor.userAsync(),
* offset: 0,
* limit: 20,
* forceRegex: false,
* });
*/
export function parseMessageSearchQuery(
text: string,
{
user,
offset = 0,
limit = 20,
forceRegex = false,
}: {
user?: IUser;
offset?: number;
limit?: number;
forceRegex?: boolean;
},
) {
const parser = new MessageSearchQueryParser({ user, offset, limit, forceRegex });
return parser.parse(text);
}