import { Message } from '@rocket.chat/core-services'; import type { IMessage, IRoom, IUser, MessageAttachmentDefault } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Messages, Rooms, Users } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; import { check, Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { afterSaveMessageAsync } from '../../hooks/messages/afterSaveMessage'; import { canSendMessageAsync } from '../../lib/authorization/canSendMessage'; import { hasAtLeastOnePermissionAsync } from '../../lib/authorization/hasPermission'; import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; import { i18n } from '../../lib/i18n'; import { attachMessage } from '../../lib/messages/attachMessage'; import { sendMessage } from '../../lib/messages/sendMessage'; import { addUserToRoom } from '../../lib/rooms/addUserToRoom'; import { createRoom } from '../../lib/rooms/createRoom'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; import { settings } from '../../settings'; const getParentRoom = async (rid: IRoom['_id']) => { const room = await Rooms.findOne(rid); return room && (room.prid ? Rooms.findOne(room.prid, { projection: { _id: 1 } }) : room); }; async function createDiscussionMessage( rid: IRoom['_id'], user: IUser, drid: IRoom['_id'], msg: IMessage['msg'], messageEmbedded?: MessageAttachmentDefault, ): Promise { return Message.saveSystemMessage('discussion-created', rid, msg, user, { drid, ...(messageEmbedded && { attachments: [messageEmbedded] }), }); } async function mentionMessage( rid: IRoom['_id'], { _id, username, name }: Pick, messageEmbedded?: MessageAttachmentDefault, ) { if (!username) { return null; } await Messages.insertOne({ rid, msg: '', u: { _id, username, name }, ts: new Date(), _updatedAt: new Date(), ...(messageEmbedded && { attachments: [messageEmbedded] }), }); } type CreateDiscussionProperties = { prid: IRoom['_id']; pmid?: IMessage['_id']; t_name: string; reply?: string; users: Array>; user: IUser; encrypted?: boolean; topic?: string; }; const create = async ({ prid, pmid, t_name: discussionName, reply, users, user, encrypted, topic, }: CreateDiscussionProperties): Promise => { // if you set both, prid and pmid, and the rooms dont match... should throw an error) let message: null | IMessage = null; if (pmid) { message = await Messages.findOneById(pmid); if (!message) { throw new Meteor.Error('error-invalid-message', 'Invalid message', { method: 'DiscussionCreation', }); } if (prid) { const parentRoom = await getParentRoom(message.rid); if (prid !== parentRoom?._id) { throw new Meteor.Error('error-invalid-arguments', 'Root message room ID does not match parent room ID ', { method: 'DiscussionCreation', }); } } else { prid = message.rid; } } if (!prid) { throw new Meteor.Error('error-invalid-arguments', 'Missing parent room ID', { method: 'DiscussionCreation' }); } let parentRoom; try { parentRoom = await canSendMessageAsync(prid, { uid: user._id, username: user.username, type: user.type }); } catch (error) { throw new Meteor.Error((error as Error).message); } if (parentRoom.prid) { throw new Meteor.Error('error-nested-discussion', 'Cannot create nested discussions', { method: 'DiscussionCreation', }); } if (typeof encrypted !== 'boolean') { encrypted = Boolean(parentRoom.encrypted); } if (encrypted && reply) { throw new Meteor.Error('error-invalid-arguments', 'Encrypted discussions must not receive an initial reply.', { method: 'DiscussionCreation', }); } if (pmid) { // No projection: the full room is spread into the returned discussion below. const discussionAlreadyExists = await Rooms.findOne({ prid, pmid, }); if (discussionAlreadyExists) { // do not allow multiple discussions to the same message'\ await addUserToRoom(discussionAlreadyExists._id, user); return { ...discussionAlreadyExists, rid: discussionAlreadyExists._id }; } } const name = Random.id(); // auto invite the replied message owner const invitedUsers = message ? [message.u.username, ...users] : users; const type = await roomCoordinator.getRoomDirectives(parentRoom.t).getDiscussionType(parentRoom); const description = parentRoom.encrypted ? '' : message?.msg; const discussionTopic = topic || parentRoom.name; if (!type) { throw new Meteor.Error('error-invalid-type', 'Cannot define discussion room type', { method: 'DiscussionCreation', }); } if ( type === 'p' && !encrypted && settings.get('E2E_Enable') && settings.get('E2E_Force_Encryption_For_Private_Rooms') ) { throw new Meteor.Error( 'error-encrypted-private-rooms-enforced-discussion', 'Workspace policy requires all private rooms to be encrypted. To create this discussion, make the parent channel public or enable encryption on it.', { method: 'DiscussionCreation' }, ); } const discussion = await createRoom( type, name, user, [...new Set(invitedUsers)].filter(Boolean), false, false, { fname: discussionName, description, // TODO discussions remove topic: discussionTopic, prid, encrypted, }, { creator: user._id, }, ); let discussionMsg; if (message) { if (parentRoom.encrypted) { message.msg = i18n.t('Encrypted_message'); } await mentionMessage(discussion._id, user, attachMessage(message, parentRoom)); discussionMsg = await createDiscussionMessage(message.rid, user, discussion._id, discussionName, attachMessage(message, parentRoom)); } else { discussionMsg = await createDiscussionMessage(prid, user, discussion._id, discussionName); } if (reply) { await sendMessage(user, { msg: reply }, discussion); } if (discussionMsg) { afterSaveMessageAsync(discussionMsg, parentRoom, user); } return discussion; }; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { createDiscussion: typeof create; } } export const createDiscussion = async ( userId: string, { prid, pmid, t_name: discussionName, reply, users, encrypted, topic }: Omit, ): Promise< IRoom & { rid: string; } > => { if (!settings.get('Discussion_enabled')) { throw new Meteor.Error('error-action-not-allowed', 'You are not allowed to create a discussion', { method: 'createDiscussion' }); } if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'DiscussionCreation', }); } if (!(await hasAtLeastOnePermissionAsync(userId, ['start-discussion', 'start-discussion-other-user'], prid))) { throw new Meteor.Error('error-action-not-allowed', 'You are not allowed to create a discussion', { method: 'createDiscussion' }); } const user = await Users.findOneById(userId, { projection: { services: 0 } }); if (!user) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'createDiscussion', }); } return create({ prid, pmid, t_name: discussionName, reply, users, user, encrypted, topic }); }; Meteor.methods({ /** * Create discussion by room or message * @constructor * @param {string} prid - Parent Room Id - The room id, optional if you send pmid. * @param {string} pmid - Parent Message Id - Create the discussion by a message, optional. * @param {string} reply - The reply, optional * @param {string} t_name - discussion name * @param {string[]} users - users to be added * @param {boolean} encrypted - if the discussion's e2e encryption should be enabled. */ async createDiscussion({ prid, pmid, t_name: discussionName, reply, users, encrypted }: CreateDiscussionProperties) { methodDeprecationLogger.method('createDiscussion', '9.0.0', '/v1/rooms.createDiscussion'); check(prid, Match.Maybe(String)); check(pmid, Match.Maybe(String)); check(reply, Match.Maybe(String)); check(discussionName, String); check(users, [String]); check(encrypted, Match.Maybe(Boolean)); const uid = Meteor.userId(); if (!uid) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'DiscussionCreation', }); } return createDiscussion(uid, { prid, pmid, t_name: discussionName, reply, users, encrypted }); }, });