Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 2s
1529 lines
43 KiB
TypeScript
1529 lines
43 KiB
TypeScript
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<ChatStarMessageLocal>(ChatStarMessageLocalSchema);
|
|
|
|
const isChatUnstarMessageLocalProps = ajv.compile<ChatUnstarMessageLocal>(ChatUnstarMessageLocalSchema);
|
|
|
|
const isChatFollowMessageLocalProps = ajv.compile<ChatFollowMessageLocal>(ChatFollowMessageLocalSchema);
|
|
|
|
const isChatUnfollowMessageLocalProps = ajv.compile<ChatUnfollowMessageLocal>(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<ChatPinMessage>(ChatPinMessageSchema);
|
|
|
|
const isChatUnpinMessageProps = ajv.compile<ChatUnpinMessage>(ChatUnpinMessageSchema);
|
|
|
|
type ChatReadThread = {
|
|
tmid: IMessage['_id'];
|
|
};
|
|
|
|
const ChatReadThreadSchema = {
|
|
type: 'object',
|
|
properties: {
|
|
tmid: {
|
|
type: 'string',
|
|
minLength: 1,
|
|
},
|
|
},
|
|
required: ['tmid'],
|
|
additionalProperties: false,
|
|
};
|
|
|
|
const isChatReadThreadProps = ajv.compile<ChatReadThread>(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<void>({
|
|
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<void>({
|
|
type: 'object',
|
|
properties: {
|
|
success: {
|
|
type: 'boolean',
|
|
enum: [true],
|
|
},
|
|
},
|
|
required: ['success'],
|
|
additionalProperties: false,
|
|
}),
|
|
},
|
|
},
|
|
async function action() {
|
|
if (!settings.get<boolean>('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<typeof executeUpdateMessage> = [
|
|
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<void>({
|
|
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<void>({
|
|
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<void>({
|
|
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<void>({
|
|
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<void>({
|
|
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<void>({
|
|
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<IMessage, '_id' | 'rid' | 'u'> }>({
|
|
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<number>('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<IMessage, 'rid'>, { 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<void>({
|
|
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<IMessage, '_id'>[]; 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<boolean>('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<IThreadMainMessage>(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<boolean>('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<boolean>('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<typeof chatEndpoints>;
|
|
|
|
declare module '@rocket.chat/rest-typings' {
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
|
|
interface Endpoints extends ChatEndpoints {}
|
|
}
|