import { Message } from '@rocket.chat/core-services'; import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings'; import { MessageTypes } from '@rocket.chat/message-types'; import { Messages, Users, Rooms, Subscriptions } from '@rocket.chat/models'; import { ajv, isChatReportMessageProps, isChatGetURLPreviewProps, isChatUpdateProps, isChatGetThreadsListProps, isChatDeleteProps, isChatSyncMessagesProps, isChatGetMessageProps, isChatGetMessagesProps, isChatPostMessageProps, isChatSearchProps, isChatSendMessageProps, isChatIgnoreUserProps, isChatGetPinnedMessagesProps, isChatGetMentionedMessagesProps, isChatReactProps, isChatGetDeletedMessagesProps, isChatSyncThreadsListProps, isChatGetThreadMessagesProps, isChatSyncThreadMessagesProps, isChatGetStarredMessagesProps, isChatGetDiscussionsProps, validateBadRequestErrorResponse, validateNotFoundErrorResponse, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse, } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/tools'; import { Meteor } from 'meteor/meteor'; import { roomAccessAttributes } from '../../lib/authorization'; import { canAccessRoomAsync, canAccessRoomIdAsync, canAccessRoomIdsAsync } from '../../lib/authorization/canAccessRoom'; import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; import { callbacks } from '../../lib/callbacks'; import { applyAirGappedRestrictionsValidation } from '../../lib/cloud/license/airGappedRestrictionsWrapper'; import { deleteMessageValidatingPermission } from '../../lib/messages/deleteMessage'; import { processWebhookMessage } from '../../lib/messages/processWebhookMessage'; import { pinMessage, unpinMessage } from '../../lib/messaging/pins/pinMessage'; import { executeSetReaction } from '../../lib/messaging/reactions/setReaction'; import { starMessage } from '../../lib/messaging/stars/starMessage'; import { readThread } from '../../lib/messaging/threads/functions'; import { reportMessage } from '../../lib/moderation/reportMessage'; import { normalizeMessagesForUser } from '../../lib/utils/lib/normalizeMessagesForUser'; import { followMessage } from '../../meteor-methods/messages/followMessage'; import { getSingleMessage } from '../../meteor-methods/messages/getSingleMessage'; import { messageSearch } from '../../meteor-methods/messages/messageSearch'; import { executeSendMessage } from '../../meteor-methods/messages/sendMessage'; import { unfollowMessage } from '../../meteor-methods/messages/unfollowMessage'; import { executeUpdateMessage } from '../../meteor-methods/messages/updateMessage'; import { ignoreUser } from '../../meteor-methods/users/ignoreUser'; import { getMessageHistory } from '../../publications/messages'; import { settings } from '../../settings'; import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; import { getPaginationItems } from '../lib/getPaginationItems'; import { findDiscussionsFromRoom, findMentionedMessages, findStarredMessages } from '../lib/messages'; type ChatStarMessageLocal = { messageId: IMessage['_id']; }; type ChatUnstarMessageLocal = { messageId: IMessage['_id']; }; const ChatStarMessageLocalSchema = { type: 'object', properties: { messageId: { type: 'string', minLength: 1, }, }, required: ['messageId'], additionalProperties: false, }; const ChatUnstarMessageLocalSchema = { type: 'object', properties: { messageId: { type: 'string', minLength: 1, }, }, required: ['messageId'], additionalProperties: false, }; type ChatFollowMessageLocal = { mid: string; }; const ChatFollowMessageLocalSchema = { type: 'object', properties: { mid: { type: 'string', minLength: 1, }, }, required: ['mid'], additionalProperties: false, }; type ChatUnfollowMessageLocal = { mid: string; }; const ChatUnfollowMessageLocalSchema = { type: 'object', properties: { mid: { type: 'string', minLength: 1, }, }, required: ['mid'], additionalProperties: false, }; const isChatStarMessageLocalProps = ajv.compile(ChatStarMessageLocalSchema); const isChatUnstarMessageLocalProps = ajv.compile(ChatUnstarMessageLocalSchema); const isChatFollowMessageLocalProps = ajv.compile(ChatFollowMessageLocalSchema); const isChatUnfollowMessageLocalProps = ajv.compile(ChatUnfollowMessageLocalSchema); type ChatPinMessage = { messageId: IMessage['_id']; }; type ChatUnpinMessage = { messageId: IMessage['_id']; }; const ChatPinMessageSchema = { type: 'object', properties: { messageId: { type: 'string', minLength: 1, }, }, required: ['messageId'], additionalProperties: false, }; const ChatUnpinMessageSchema = { type: 'object', properties: { messageId: { type: 'string', minLength: 1, }, }, required: ['messageId'], additionalProperties: false, }; const isChatPinMessageProps = ajv.compile(ChatPinMessageSchema); const isChatUnpinMessageProps = ajv.compile(ChatUnpinMessageSchema); type ChatReadThread = { tmid: IMessage['_id']; }; const ChatReadThreadSchema = { type: 'object', properties: { tmid: { type: 'string', minLength: 1, }, }, required: ['tmid'], additionalProperties: false, }; const isChatReadThreadProps = ajv.compile(ChatReadThreadSchema); const chatEndpoints = API.v1 .post( 'chat.pinMessage', { authRequired: true, body: isChatPinMessageProps, response: { 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, 200: ajv.compile<{ message: IMessage }>({ type: 'object', properties: { message: { $ref: '#/components/schemas/IMessage' }, success: { type: 'boolean', enum: [true], }, }, required: ['message', 'success'], additionalProperties: false, }), }, }, async function action() { const msg = await Messages.findOneById(this.bodyParams.messageId); if (!msg) { throw new Meteor.Error('error-message-not-found', 'The provided "messageId" does not match any existing message.'); } const pinnedMessage = await pinMessage(msg, this.userId); const [message] = await normalizeMessagesForUser([pinnedMessage], this.userId); return API.v1.success({ message, }); }, ) .post( 'chat.unPinMessage', { authRequired: true, body: isChatUnpinMessageProps, response: { 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, 200: ajv.compile({ type: 'object', properties: { success: { type: 'boolean', enum: [true], }, }, required: ['success'], additionalProperties: false, }), }, }, async function action() { const msg = await Messages.findOneById(this.bodyParams.messageId); if (!msg) { throw new Meteor.Error('error-message-not-found', 'The provided "messageId" does not match any existing message.'); } await unpinMessage(this.userId, msg); return API.v1.success(); }, ) .post( 'chat.readThread', { authRequired: true, rateLimiterOptions: { numRequestsAllowed: 20, intervalTimeInMS: 10000 }, body: isChatReadThreadProps, response: { 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, 200: ajv.compile({ type: 'object', properties: { success: { type: 'boolean', enum: [true], }, }, required: ['success'], additionalProperties: false, }), }, }, async function action() { if (!settings.get('Threads_enabled')) { throw new Meteor.Error('error-not-allowed', 'Threads Disabled'); } const { tmid } = this.bodyParams; const thread = await Messages.findOneById(tmid, { projection: { rid: 1 } }); if (!thread?.rid) { throw new Meteor.Error('error-invalid-message', 'Invalid Message'); } const [user, room] = await Promise.all([ Users.findOneById(this.userId), Rooms.findOneById(thread.rid, { projection: { ...roomAccessAttributes, t: 1, _id: 1 } }), ]); if (!room) { throw new Meteor.Error('error-room-does-not-exist', 'This room does not exist'); } if (!user || !(await canAccessRoomAsync(room, user))) { throw new Meteor.Error('error-not-allowed', 'Not Allowed'); } await callbacks.run('beforeReadMessages', room._id, user._id); await readThread({ user, room, tmid }); return API.v1.success(); }, ) .post( 'chat.update', { authRequired: true, body: isChatUpdateProps, response: { 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, 200: ajv.compile<{ message: IMessage }>({ type: 'object', properties: { message: { $ref: '#/components/schemas/IMessage' }, success: { type: 'boolean', enum: [true], }, }, required: ['message', 'success'], additionalProperties: false, }), }, }, async function action() { const { bodyParams } = this; const msg = await Messages.findOneById(bodyParams.msgId); // Ensure the message exists if (!msg) { return API.v1.failure(`No message found with the id of "${bodyParams.msgId}".`); } if (bodyParams.roomId !== msg.rid) { return API.v1.failure('The room id provided does not match where the message is from.'); } const hasContent = 'content' in bodyParams; if (hasContent && msg.t !== 'e2e') { return API.v1.failure('Only encrypted messages can have content updated.'); } const updateData: Parameters = [ this.userId, hasContent ? { _id: msg._id, rid: msg.rid, content: bodyParams.content, ...(bodyParams.e2eMentions && { e2eMentions: bodyParams.e2eMentions }), } : { _id: msg._id, rid: msg.rid, msg: bodyParams.text, ...(bodyParams.customFields && { customFields: bodyParams.customFields }), }, 'previewUrls' in bodyParams ? bodyParams.previewUrls : undefined, ]; // Permission checks are already done in the updateMessage method, so no need to duplicate them await applyAirGappedRestrictionsValidation(() => executeUpdateMessage(...updateData)); const updatedMessage = await Messages.findOneById(msg._id); const [message] = await normalizeMessagesForUser(updatedMessage ? [updatedMessage] : [], this.userId); return API.v1.success({ message, }); }, ) .post( 'chat.starMessage', { authRequired: true, body: isChatStarMessageLocalProps, response: { 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, 200: ajv.compile({ type: 'object', properties: { success: { type: 'boolean', enum: [true], }, }, required: ['success'], additionalProperties: false, }), }, }, async function action() { const msg = await Messages.findOneById(this.bodyParams.messageId); if (!msg) { throw new Meteor.Error('error-message-not-found', 'The provided "messageId" does not match any existing message.'); } await starMessage(this.user, { _id: msg._id, rid: msg.rid, starred: true, }); return API.v1.success(); }, ) .post( 'chat.unStarMessage', { authRequired: true, body: isChatUnstarMessageLocalProps, response: { 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, 200: ajv.compile({ type: 'object', properties: { success: { type: 'boolean', enum: [true], }, }, required: ['success'], additionalProperties: false, }), }, }, async function action() { const msg = await Messages.findOneById(this.bodyParams.messageId); if (!msg) { throw new Meteor.Error('error-message-not-found', 'The provided "messageId" does not match any existing message.'); } await starMessage(this.user, { _id: msg._id, rid: msg.rid, starred: false, }); return API.v1.success(); }, ) .post( 'chat.followMessage', { authRequired: true, rateLimiterOptions: { numRequestsAllowed: 5, intervalTimeInMS: 5000 }, body: isChatFollowMessageLocalProps, response: { 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, 200: ajv.compile({ type: 'object', properties: { success: { type: 'boolean', enum: [true], }, }, required: ['success'], additionalProperties: false, }), }, }, async function action() { const { mid } = this.bodyParams; if (!mid) { throw new Meteor.Error('The required "mid" body param is missing.'); } await followMessage(this.user, { mid }); return API.v1.success(); }, ) .post( 'chat.unfollowMessage', { authRequired: true, rateLimiterOptions: { numRequestsAllowed: 5, intervalTimeInMS: 5000 }, body: isChatUnfollowMessageLocalProps, response: { 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, 200: ajv.compile({ type: 'object', properties: { success: { type: 'boolean', enum: [true], }, }, required: ['success'], additionalProperties: false, }), }, }, async function action() { const { mid } = this.bodyParams; if (!mid) { throw new Meteor.Error('The required "mid" body param is missing.'); } await unfollowMessage(this.user, { mid }); return API.v1.success(); }, ) .post( 'chat.react', { authRequired: true, body: isChatReactProps, response: { 200: ajv.compile({ type: 'object', properties: { success: { type: 'boolean', enum: [true], }, }, required: ['success'], additionalProperties: false, }), 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, }, async function action() { const msg = await Messages.findOneById(this.bodyParams.messageId); if (!msg) { throw new Meteor.Error('error-message-not-found', 'The provided "messageId" does not match any existing message.'); } const emoji = 'emoji' in this.bodyParams ? this.bodyParams.emoji : (this.bodyParams as { reaction: string }).reaction; if (!emoji) { throw new Meteor.Error('error-emoji-param-not-provided', 'The required "emoji" param is missing.'); } await executeSetReaction(this.userId, emoji, msg, this.bodyParams.shouldReact); return API.v1.success(); }, ) .post( 'chat.reportMessage', { authRequired: true, body: isChatReportMessageProps, response: { 200: ajv.compile({ type: 'object', properties: { success: { type: 'boolean', enum: [true], }, }, required: ['success'], additionalProperties: false, }), 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, }, async function action() { const { messageId, description } = this.bodyParams; await reportMessage(messageId, description, this.userId); return API.v1.success(); }, ) .post( 'chat.delete', { authRequired: true, body: isChatDeleteProps, response: { 200: ajv.compile<{ _id?: string; ts?: string; message?: Pick }>({ type: 'object', properties: { _id: { type: 'string' }, ts: { type: 'string' }, message: { type: 'object', properties: { _id: { type: 'string' }, rid: { type: 'string' }, u: { type: 'object' }, }, required: ['_id', 'rid', 'u'], additionalProperties: true, }, success: { type: 'boolean', enum: [true] }, }, required: ['success'], additionalProperties: false, }), 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, }, async function action() { // Deleting by fileId resolves the message that references the file and deletes it. // An orphan upload (a file with no associated message) is not deletable through this // endpoint and intentionally returns a failure below. const msg = 'fileId' in this.bodyParams ? await Messages.getMessageByFileId(this.bodyParams.fileId) : await Messages.findOneById(this.bodyParams.msgId, { projection: { u: 1, rid: 1 } }); if (!msg) { if ('fileId' in this.bodyParams) { return API.v1.failure(`No message found with the file id: "${this.bodyParams.fileId}".`); } return API.v1.failure(`No message found with the id of "${this.bodyParams.msgId}".`); } if ('roomId' in this.bodyParams && this.bodyParams.roomId !== msg.rid) { return API.v1.failure('The room id provided does not match where the message is from.'); } if (this.bodyParams.asUser && msg.u._id !== this.userId && !(await hasPermissionAsync(this.user, 'force-delete-message', msg.rid))) { return API.v1.failure('Unauthorized. You must have the permission "force-delete-message" to delete other\'s message as them.'); } const userId = this.bodyParams.asUser ? msg.u._id : this.userId; const user = await Users.findOneById(userId, { projection: { _id: 1 } }); if (!user) { return API.v1.failure('User not found'); } await deleteMessageValidatingPermission(msg, user._id); return API.v1.success({ _id: msg._id, ts: Date.now().toString(), message: { _id: msg._id, rid: msg.rid, u: msg.u }, }); }, ) .get( 'chat.syncMessages', { authRequired: true, query: isChatSyncMessagesProps, response: { 200: ajv.compile<{ result: { updated: IMessage[]; deleted: { _id: string; _deletedAt: string }[]; cursor?: { next: string | null; previous: string | null }; }; }>({ type: 'object', properties: { result: { type: 'object', properties: { updated: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, deleted: { type: 'array', items: { type: 'object', properties: { _id: { type: 'string' }, _deletedAt: { type: 'string', format: 'date-time' }, }, required: ['_id', '_deletedAt'], additionalProperties: false, }, }, cursor: { type: 'object', properties: { next: { type: ['string', 'null'] }, previous: { type: ['string', 'null'] }, }, }, }, required: ['updated', 'deleted'], additionalProperties: false, }, success: { type: 'boolean', enum: [true] }, }, required: ['result', 'success'], additionalProperties: false, }), 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, }, async function action() { const { roomId, lastUpdate, fromTs, count, next, previous, type } = this.queryParams; if (!roomId) { throw new Meteor.Error('error-param-required', 'The required "roomId" query param is missing'); } if (!lastUpdate && !type) { throw new Meteor.Error('error-param-required', 'The "type" or "lastUpdate" parameters must be provided'); } if (lastUpdate && isNaN(Date.parse(lastUpdate))) { throw new Meteor.Error('error-lastUpdate-param-invalid', 'The "lastUpdate" query parameter must be a valid date'); } const getMessagesQuery = { ...(lastUpdate && { lastUpdate: new Date(lastUpdate) }), ...(fromTs && { fromTs: new Date(fromTs) }), ...(next && { next }), ...(previous && { previous }), ...(count && { count }), ...(type && { type }), }; const result = await getMessageHistory(roomId, this.userId, getMessagesQuery); if (!result) { return API.v1.failure(); } return API.v1.success({ result: { updated: 'updated' in result ? await normalizeMessagesForUser(result.updated, this.userId) : [], deleted: 'deleted' in result ? result.deleted.map((msg) => ({ _id: msg._id, _deletedAt: '_deletedAt' in msg && msg._deletedAt instanceof Date ? msg._deletedAt.toISOString() : new Date().toISOString(), })) : [], cursor: 'cursor' in result ? result.cursor : undefined, }, }); }, ) .get( 'chat.getMessage', { authRequired: true, query: isChatGetMessageProps, response: { 200: ajv.compile<{ message: IMessage }>({ type: 'object', properties: { message: { $ref: '#/components/schemas/IMessage' }, success: { type: 'boolean', enum: [true] }, }, required: ['message', 'success'], additionalProperties: false, }), 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, }, async function action() { if (!this.queryParams.msgId) { return API.v1.failure('The "msgId" query parameter must be provided.'); } const msg = await getSingleMessage(this.userId, this.queryParams.msgId); if (!msg) { return API.v1.failure(); } const [message] = await normalizeMessagesForUser([msg], this.userId); return API.v1.success({ message, }); }, ) .post( 'chat.postMessage', { authRequired: true, body: isChatPostMessageProps, response: { 200: ajv.compile<{ ts: number; channel: string; message: IMessage }>({ type: 'object', properties: { ts: { type: 'number' }, channel: { type: 'string' }, message: { $ref: '#/components/schemas/IMessage' }, success: { type: 'boolean', enum: [true] }, }, required: ['ts', 'channel', 'message', 'success'], additionalProperties: false, }), 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, }, async function action() { const { text, attachments } = this.bodyParams; const maxAllowedSize = settings.get('Message_MaxAllowedSize') ?? 0; if (text && text.length > maxAllowedSize) { return API.v1.failure('error-message-size-exceeded'); } if (attachments && attachments.length > 0) { for (const attachment of attachments) { if (attachment.text && attachment.text.length > maxAllowedSize) { return API.v1.failure('error-message-size-exceeded'); } } } const messageReturn = (await applyAirGappedRestrictionsValidation(() => processWebhookMessage(this.bodyParams, this.user)))[0]; if (!messageReturn?.message) { return API.v1.failure('unknown-error'); } const [message] = await normalizeMessagesForUser([messageReturn.message], this.userId); return API.v1.success({ ts: Date.now(), channel: messageReturn.channel, message, }); }, ) .get( 'chat.search', { authRequired: true, query: isChatSearchProps, response: { 200: ajv.compile<{ messages: IMessage[] }>({ type: 'object', properties: { messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, success: { type: 'boolean', enum: [true] }, }, required: ['messages', 'success'], additionalProperties: false, }), 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, }, async function action() { const { roomId, searchText } = this.queryParams; const { offset, count } = await getPaginationItems(this.queryParams); if (!roomId) { throw new Meteor.Error('error-roomId-param-not-provided', 'The required "roomId" query param is missing.'); } if (!searchText) { throw new Meteor.Error('error-searchText-param-not-provided', 'The required "searchText" query param is missing.'); } const searchResult = await messageSearch(this.userId, searchText, roomId, count, offset); if (searchResult === false) { return API.v1.failure(); } if (!searchResult.message) { return API.v1.failure(); } const result = searchResult.message.docs; return API.v1.success({ messages: await normalizeMessagesForUser(result, this.userId), }); }, ) // The difference between `chat.postMessage` and `chat.sendMessage` is that `chat.sendMessage` allows // for passing a value for `_id` and the other one doesn't. Also, `chat.sendMessage` only sends it to // one channel whereas the other one allows for sending to more than one channel at a time. .post( 'chat.sendMessage', { authRequired: true, rateLimiterOptions: { numRequestsAllowed: 5, intervalTimeInMS: 1000, bypassPermissions: ['send-many-messages'] }, body: isChatSendMessageProps, response: { 200: ajv.compile<{ message: IMessage }>({ type: 'object', properties: { message: { $ref: '#/components/schemas/IMessage' }, success: { type: 'boolean', enum: [true] }, }, required: ['message', 'success'], additionalProperties: false, }), 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, }, async function action() { if (MessageTypes.isSystemMessage(this.bodyParams.message)) { throw new Error("Cannot send system messages using 'chat.sendMessage'"); } const sent = await applyAirGappedRestrictionsValidation(() => executeSendMessage(this.user, this.bodyParams.message as Pick, { previewUrls: this.bodyParams.previewUrls }), ); const [message] = await normalizeMessagesForUser([sent], this.userId); return API.v1.success({ message, }); }, ) .get( 'chat.ignoreUser', { authRequired: true, query: isChatIgnoreUserProps, response: { 200: ajv.compile({ type: 'object', properties: { success: { type: 'boolean', enum: [true] }, }, required: ['success'], additionalProperties: false, }), 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, }, async function action() { const { rid, userId } = this.queryParams; let { ignore = true } = this.queryParams; ignore = typeof ignore === 'string' ? /true|1/.test(ignore) : ignore; if (!rid?.trim()) { throw new Meteor.Error('error-room-id-param-not-provided', 'The required "rid" param is missing.'); } if (!userId?.trim()) { throw new Meteor.Error('error-user-id-param-not-provided', 'The required "userId" param is missing.'); } await ignoreUser(this.userId, { rid, userId, ignore }); return API.v1.success(); }, ) .get( 'chat.getDeletedMessages', { authRequired: true, query: isChatGetDeletedMessagesProps, response: { 200: ajv.compile<{ messages: Pick[]; count: number; offset: number; total: number }>({ type: 'object', properties: { messages: { type: 'array', items: { type: 'object' } }, // relaxed: only _id is projected, count: { type: 'number' }, offset: { type: 'number' }, total: { type: 'number' }, success: { type: 'boolean', enum: [true] }, }, required: ['messages', 'count', 'offset', 'total', 'success'], additionalProperties: false, }), 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, }, async function action() { const { roomId, since } = this.queryParams; const { offset, count } = await getPaginationItems(this.queryParams); const { cursor, totalCount } = Messages.trashFindPaginatedDeletedAfter( new Date(since), { rid: roomId }, { skip: offset, limit: count, projection: { _id: 1 }, }, ); const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); return API.v1.success({ messages, count: messages.length, offset, total, }); }, ) .get( 'chat.getPinnedMessages', { authRequired: true, query: isChatGetPinnedMessagesProps, response: { 200: ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({ type: 'object', properties: { messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, count: { type: 'number' }, offset: { type: 'number' }, total: { type: 'number' }, success: { type: 'boolean', enum: [true] }, }, required: ['messages', 'count', 'offset', 'total', 'success'], additionalProperties: false, }), 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, }, async function action() { const { roomId } = this.queryParams; const { offset, count } = await getPaginationItems(this.queryParams); if (!(await canAccessRoomIdAsync(roomId, this.userId))) { throw new Meteor.Error('error-not-allowed', 'Not allowed'); } const { cursor, totalCount } = Messages.findPaginatedPinnedByRoom(roomId, { skip: offset, limit: count, }); const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); return API.v1.success({ messages: await normalizeMessagesForUser(messages, this.userId), count: messages.length, offset, total, }); }, ) .get( 'chat.getThreadsList', { authRequired: true, rateLimiterOptions: { numRequestsAllowed: 20, intervalTimeInMS: 10000 }, query: isChatGetThreadsListProps, response: { 200: ajv.compile<{ threads: IThreadMainMessage[]; count: number; offset: number; total: number }>({ type: 'object', properties: { threads: { type: 'array', items: { type: 'object' } }, // relaxed: IThreadMainMessage not in OpenAPI schemas, count: { type: 'number' }, offset: { type: 'number' }, total: { type: 'number' }, success: { type: 'boolean', enum: [true] }, }, required: ['threads', 'count', 'offset', 'total', 'success'], additionalProperties: false, }), 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, }, async function action() { const { rid, type, text } = this.queryParams; const { offset, count } = await getPaginationItems(this.queryParams); const { sort, fields, query } = await this.parseJsonQuery(); if (!settings.get('Threads_enabled')) { throw new Meteor.Error('error-not-allowed', 'Threads Disabled'); } const user = await Users.findOneById(this.userId, { projection: { _id: 1 } }); const room = await Rooms.findOneById(rid, { projection: { ...roomAccessAttributes, t: 1, _id: 1 } }); if (!room || !user || !(await canAccessRoomAsync(room, user))) { throw new Meteor.Error('error-not-allowed', 'Not Allowed'); } const typeThread = { _hidden: { $ne: true }, ...(type === 'following' && { replies: { $in: [this.userId] } }), ...(type === 'unread' && { _id: { $in: (await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id))?.tunread || [] } }), msg: new RegExp(escapeRegExp(text || ''), 'i'), }; const threadQuery = { ...query, ...typeThread, rid: room._id, tcount: { $exists: true } }; const { cursor, totalCount } = await Messages.findPaginated(threadQuery, { sort: sort || { tlm: -1 }, skip: offset, limit: count, projection: fields, }); const [threads, total] = await Promise.all([cursor.toArray(), totalCount]); return API.v1.success({ threads: await normalizeMessagesForUser(threads, this.userId), count: threads.length, offset, total, }); }, ) .get( 'chat.syncThreadsList', { authRequired: true, query: isChatSyncThreadsListProps, response: { 200: ajv.compile<{ threads: { update: IMessage[]; remove: IMessage[] } }>({ type: 'object', properties: { threads: { type: 'object', properties: { update: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, remove: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, }, required: ['update', 'remove'], additionalProperties: false, }, success: { type: 'boolean', enum: [true] }, }, required: ['threads', 'success'], additionalProperties: false, }), 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, }, async function action() { const { rid } = this.queryParams; const { query, fields, sort } = await this.parseJsonQuery(); const { updatedSince } = this.queryParams; let updatedSinceDate; if (!settings.get('Threads_enabled')) { throw new Meteor.Error('error-not-allowed', 'Threads Disabled'); } if (isNaN(Date.parse(updatedSince))) { throw new Meteor.Error('error-updatedSince-param-invalid', 'The "updatedSince" query parameter must be a valid date.'); } else { updatedSinceDate = new Date(updatedSince); } const user = await Users.findOneById(this.userId, { projection: { _id: 1 } }); const room = await Rooms.findOneById(rid, { projection: { ...roomAccessAttributes, t: 1, _id: 1 } }); if (!room || !user || !(await canAccessRoomAsync(room, user))) { throw new Meteor.Error('error-not-allowed', 'Not Allowed'); } const threadQuery = Object.assign({}, query, { rid, tcount: { $exists: true } }); return API.v1.success({ threads: { update: await Messages.find( { ...threadQuery, _updatedAt: { $gt: updatedSinceDate } }, { sort, projection: fields, }, ).toArray(), remove: await Messages.trashFindDeletedAfter(updatedSinceDate, threadQuery, { sort, projection: fields, }).toArray(), }, }); }, ) .get( 'chat.getThreadMessages', { authRequired: true, rateLimiterOptions: { numRequestsAllowed: 20, intervalTimeInMS: 10000 }, query: isChatGetThreadMessagesProps, response: { 200: ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({ type: 'object', properties: { messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, count: { type: 'number' }, offset: { type: 'number' }, total: { type: 'number' }, success: { type: 'boolean', enum: [true] }, }, required: ['messages', 'count', 'offset', 'total', 'success'], additionalProperties: false, }), 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, }, async function action() { const { tmid, aroundId } = this.queryParams; const { query, fields, sort } = await this.parseJsonQuery(); const { offset, count } = await getPaginationItems(this.queryParams); if (!settings.get('Threads_enabled')) { throw new Meteor.Error('error-not-allowed', 'Threads Disabled'); } const thread = await Messages.findOneById(tmid, { projection: { rid: 1 } }); if (!thread?.rid) { throw new Meteor.Error('error-invalid-message', 'Invalid Message'); } const user = await Users.findOneById(this.userId, { projection: { _id: 1 } }); const room = await Rooms.findOneById(thread.rid, { projection: { ...roomAccessAttributes, t: 1, _id: 1 } }); if (!room || !user || !(await canAccessRoomAsync(room, user))) { throw new Meteor.Error('error-not-allowed', 'Not Allowed'); } let resolvedOffset = offset; let resolvedSort = sort || { ts: 1 }; if (aroundId) { resolvedSort = { ts: 1 }; if (aroundId === tmid) { resolvedOffset = 0; } else { const target = await Messages.findOneById(aroundId, { projection: { ts: 1, tmid: 1 } }); if (target?.tmid !== tmid || !target.ts) { throw new Meteor.Error('error-invalid-message', 'The provided "aroundId" does not belong to the thread'); } const before = await Messages.countDocuments({ ...query, tmid, _hidden: { $ne: true }, ts: { $lt: target.ts } }); resolvedOffset = Math.max(0, before - Math.floor(count / 2)); } } const { cursor, totalCount } = Messages.findPaginated( { ...query, tmid, _hidden: { $ne: true } }, { sort: resolvedSort, skip: resolvedOffset, limit: count, projection: fields, }, ); const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); return API.v1.success({ messages, count: messages.length, offset: resolvedOffset, total, }); }, ) .get( 'chat.syncThreadMessages', { authRequired: true, query: isChatSyncThreadMessagesProps, response: { 200: ajv.compile<{ messages: { update: IMessage[]; remove: IMessage[] } }>({ type: 'object', properties: { messages: { type: 'object', properties: { update: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, remove: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, }, required: ['update', 'remove'], additionalProperties: false, }, success: { type: 'boolean', enum: [true] }, }, required: ['messages', 'success'], additionalProperties: false, }), 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, }, async function action() { const { tmid } = this.queryParams; const { query, fields, sort } = await this.parseJsonQuery(); const { updatedSince } = this.queryParams; let updatedSinceDate; if (!settings.get('Threads_enabled')) { throw new Meteor.Error('error-not-allowed', 'Threads Disabled'); } if (isNaN(Date.parse(updatedSince))) { throw new Meteor.Error('error-updatedSince-param-invalid', 'The "updatedSince" query parameter must be a valid date.'); } else { updatedSinceDate = new Date(updatedSince); } const thread = await Messages.findOneById(tmid, { projection: { rid: 1 } }); if (!thread?.rid) { throw new Meteor.Error('error-invalid-message', 'Invalid Message'); } // TODO: promise.all? this.user? const user = await Users.findOneById(this.userId, { projection: { _id: 1 } }); const room = await Rooms.findOneById(thread.rid, { projection: { ...roomAccessAttributes, t: 1, _id: 1 } }); if (!room || !user || !(await canAccessRoomAsync(room, user))) { throw new Meteor.Error('error-not-allowed', 'Not Allowed'); } return API.v1.success({ messages: { update: await Messages.find({ ...query, tmid, _updatedAt: { $gt: updatedSinceDate } }, { projection: fields, sort }).toArray(), remove: await Messages.trashFindDeletedAfter(updatedSinceDate, { ...query, tmid }, { projection: fields, sort }).toArray(), }, }); }, ) .get( 'chat.getMentionedMessages', { authRequired: true, query: isChatGetMentionedMessagesProps, response: { 200: ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({ type: 'object', properties: { messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, count: { type: 'number' }, offset: { type: 'number' }, total: { type: 'number' }, success: { type: 'boolean', enum: [true] }, }, required: ['messages', 'count', 'offset', 'total', 'success'], additionalProperties: false, }), 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, }, async function action() { const { roomId } = this.queryParams; const { sort } = await this.parseJsonQuery(); const { offset, count } = await getPaginationItems(this.queryParams); const messages = await findMentionedMessages({ uid: this.userId, roomId, pagination: { offset, count, sort, }, }); return API.v1.success(messages); }, ) .get( 'chat.getStarredMessages', { authRequired: true, query: isChatGetStarredMessagesProps, response: { 200: ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({ type: 'object', properties: { messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, count: { type: 'number' }, offset: { type: 'number' }, total: { type: 'number' }, success: { type: 'boolean', enum: [true] }, }, required: ['messages', 'count', 'offset', 'total', 'success'], additionalProperties: false, }), 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, }, async function action() { const { roomId } = this.queryParams; const { sort } = await this.parseJsonQuery(); const { offset, count } = await getPaginationItems(this.queryParams); const messages = await findStarredMessages({ uid: this.userId, roomId, pagination: { offset, count, sort, }, }); messages.messages = await normalizeMessagesForUser(messages.messages, this.userId); return API.v1.success(messages); }, ) .get( 'chat.getDiscussions', { authRequired: true, query: isChatGetDiscussionsProps, response: { 200: ajv.compile<{ messages: IMessage[]; total: number }>({ type: 'object', properties: { messages: { type: 'array', items: { type: 'object' } }, // relaxed: discussions have extra room fields, total: { type: 'number' }, success: { type: 'boolean', enum: [true] }, }, required: ['messages', 'total', 'success'], additionalProperties: true, }), 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, }, async function action() { const { roomId, text } = this.queryParams; const { sort } = await this.parseJsonQuery(); const { offset, count } = await getPaginationItems(this.queryParams); const messages = await findDiscussionsFromRoom({ uid: this.userId, roomId, text: text || '', pagination: { offset, count, sort, }, }); return API.v1.success(messages); }, ) .get( 'chat.getURLPreview', { authRequired: true, query: isChatGetURLPreviewProps, response: { 200: ajv.compile<{ urlPreview: object }>({ type: 'object', properties: { urlPreview: { type: 'object' }, // relaxed: opaque preview shape, success: { type: 'boolean', enum: [true] }, }, required: ['urlPreview', 'success'], additionalProperties: false, }), 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, }, async function action() { const { roomId, url } = this.queryParams; if (!(await canAccessRoomIdAsync(roomId, this.userId))) { throw new Meteor.Error('error-not-allowed', 'Not allowed'); } const { urlPreview } = await Message.parseOEmbedUrl(url); urlPreview.ignoreParse = true; return API.v1.success({ urlPreview }); }, ) .post( 'chat.getMessages', { authRequired: true, body: isChatGetMessagesProps, response: { 200: ajv.compile<{ messages: IMessage[] }>({ type: 'object', properties: { messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, success: { type: 'boolean', enum: [true] }, }, required: ['messages', 'success'], additionalProperties: false, }), 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, 403: validateForbiddenErrorResponse, 404: validateNotFoundErrorResponse, }, }, async function action() { const { messageIds } = this.bodyParams; const messages = await Messages.findVisibleByIds(messageIds).toArray(); if (!messages.length) { return API.v1.notFound(); } const rids = [...new Set(messages.map(({ rid }) => rid))]; // The batch spans rooms, so one unreadable room rejects the whole request. if (!(await canAccessRoomIdsAsync(rids, this.user))) { return API.v1.forbidden(); } return API.v1.success({ messages: await normalizeMessagesForUser(messages, this.userId) }); }, ); export type ChatEndpoints = ExtractRoutesFromAPI; declare module '@rocket.chat/rest-typings' { // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface interface Endpoints extends ChatEndpoints {} }