import moment from 'moment'; import nconf from 'nconf'; import { authWithHeaders } from '../../middlewares/auth'; import { model as Group } from '../../models/group'; import { model as User } from '../../models/user'; import { chatModel as Chat, sanitizeText as sanitizeMessageText, } from '../../models/message'; import common from '../../../common'; import { BadRequest, NotFound, NotAuthorized, } from '../../libs/errors'; import { removeFromArray } from '../../libs/collectionManipulators'; import { getUserInfo } from '../../libs/email'; import * as slack from '../../libs/slack'; import { chatReporterFactory } from '../../libs/chatReporting/chatReporterFactory'; import bannedWords from '../../libs/bannedWords'; import { getMatchesByWordArray } from '../../libs/stringUtils'; import bannedSlurs from '../../libs/bannedSlurs'; import { apiError } from '../../libs/apiError'; import highlightMentions from '../../libs/highlightMentions'; import { getAnalyticsServiceByEnvironment } from '../../libs/analyticsService'; const analytics = getAnalyticsServiceByEnvironment(); const ACCOUNT_MIN_CHAT_AGE = Number(nconf.get('ACCOUNT_MIN_CHAT_AGE')); /** * @apiDefine MessageNotFound * @apiError (404) {NotFound} MessageNotFound The specified message could not be found. */ /** * @apiDefine GroupIdRequired * @apiError (400) {badRequest} groupIdRequired A group ID is required */ /** * @apiDefine ChatIdRequired * @apiError (400) {badRequest} chatIdRequired A chat ID is required */ /** * @apiDefine MessageIdRequired * @apiError (400) {badRequest} messageIdRequired A message ID is required */ const api = {}; function textContainsBannedSlur (message) { const bannedSlursMatched = getMatchesByWordArray(message, bannedSlurs); return bannedSlursMatched.length > 0; } /** * @api {get} /api/v3/groups/:groupId/chat Get chat messages from a group * @apiName GetChat * @apiGroup Chat * @apiDescription Fetches an array of messages from a group * * @apiParam (Path) {String} groupId The group _id ('party' for the user party and * 'habitrpg' for tavern are accepted). * * @apiSuccess {Array} data An array of chat messages * * @apiUse GroupNotFound * @apiUse GroupIdRequired */ api.getChat = { method: 'GET', url: '/groups/:groupId/chat', middlewares: [authWithHeaders()], async handler (req, res) { const { user } = res.locals; req.checkParams('groupId', apiError('groupIdRequired')).notEmpty(); const validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; const { groupId } = req.params; const group = await Group.getGroup({ user, groupId, fields: 'chat privacy' }); if (!group) throw new NotFound(res.t('groupNotFound')); if (group.privacy === 'public') { throw new BadRequest(res.t('featureRetired')); } const groupChat = await Group.toJSONCleanChat(group, user); res.respond(200, groupChat.chat); }, }; function getBannedWordsFromText (message) { return getMatchesByWordArray(message, bannedWords); } /** * @api {post} /api/v3/groups/:groupId/chat Post chat message to a group * @apiName PostChat * @apiGroup Chat * @apiDescription Posts a chat message to a group * * @apiParam (Path) {UUID} groupId The group _id ('party' for the user party and 'habitrpg' * for tavern are accepted) * @apiParam (Body) {String} message Message The message to post * @apiParam (Query) {UUID} previousMsg The previous chat message's UUID which will * force a return of the full group chat. * * @apiUse GroupNotFound * @apiUse GroupIdRequired * @apiError (400) {NotAuthorized} chatPriviledgesRevoked You cannot do that because * your chat privileges have been revoked. */ api.postChat = { method: 'POST', url: '/groups/:groupId/chat', middlewares: [authWithHeaders()], async handler (req, res) { const { user } = res.locals; const { groupId } = req.params; req.checkParams('groupId', apiError('groupIdRequired')).notEmpty(); req.sanitize('message').trim(); req.checkBody('message', res.t('messageGroupChatBlankMessage')).notEmpty(); const validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; const group = await Group.getGroup({ user, groupId }); if (!group) throw new NotFound(res.t('groupNotFound')); const { purchased } = group; const isUpgraded = purchased && purchased.plan && purchased.plan.customerId && (!purchased.plan.dateTerminated || moment().isBefore(purchased.plan.dateTerminated)); if (group.type !== 'party' && !isUpgraded) { throw new BadRequest(res.t('featureRetired')); } // Check message for banned slurs if (group && group.privacy !== 'private' && textContainsBannedSlur(req.body.message)) { const { message } = req.body; user.flags.chatRevoked = true; await user.save(); // Email the mods const authorEmail = getUserInfo(user, ['email']).email; // Slack the mods slack.sendSlurNotification({ authorEmail, author: user, group, message, }); throw new BadRequest(res.t('bannedSlurUsed')); } if (group.privacy === 'public' && user.flags.chatRevoked) { throw new NotAuthorized(res.t('chatPrivilegesRevoked')); } // prevent banned words being posted, except in private guilds/parties // and in certain public guilds with specific topics if (group.privacy === 'public' && !group.bannedWordsAllowed) { const matchedBadWords = getBannedWordsFromText(req.body.message); if (matchedBadWords.length > 0) { throw new BadRequest(res.t('bannedWordUsed', { swearWordsUsed: matchedBadWords.join(', ') })); } } const chatRes = await Group.toJSONCleanChat(group, user); const lastClientMsg = req.query.previousMsg; const chatUpdated = !!( lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg ); if (group.checkChatSpam(user)) { throw new NotAuthorized(res.t('messageGroupChatSpam')); } // Check if account is newer than the minimum age for chat participation if (moment().diff(user.auth.timestamps.created, 'minutes') < ACCOUNT_MIN_CHAT_AGE) { analytics.track('chat age error', { user, uuid: user._id, hitType: 'event', category: 'behavior', headers: req.headers, }); throw new BadRequest(res.t('chatTemporarilyUnavailable')); } const sanitizedMessageText = sanitizeMessageText(req.body.message); const [message, mentions, mentionedMembers] = await highlightMentions(sanitizedMessageText); let client = req.headers['x-client'] || '3rd Party'; if (client) { client = client.replace('habitica-', ''); } let flagCount = 0; if (group.privacy === 'public' && user.flags.chatShadowMuted) { flagCount = common.constants.CHAT_FLAG_FROM_SHADOW_MUTE; // Email the mods const authorEmail = getUserInfo(user, ['email']).email; // Slack the mods slack.sendShadowMutedPostNotification({ authorEmail, author: user, group, message, }); } const newChatMessage = await group.sendChat({ message, user, flagCount, metaData: null, client, translate: res.t, mentions, mentionedMembers, }); const toSave = [newChatMessage.save()]; if (group.type === 'party') { user.party.lastMessageSeen = newChatMessage.id; toSave.push(user.save()); } await Promise.all(toSave); const analyticsObject = { user, uuid: user._id, hitType: 'event', category: 'behavior', groupType: group.type, privacy: group.privacy, headers: req.headers, }; if (mentions) { analyticsObject.mentionsCount = mentions.length; } else { analyticsObject.mentionsCount = 0; } if (group.privacy === 'public') { analyticsObject.groupName = group.name; } res.analytics.track('group chat', analyticsObject); if (chatUpdated) { res.respond(200, { chat: chatRes.chat }); } else { res.respond(200, { message: newChatMessage }); } }, }; /** * @api {post} /api/v3/groups/:groupId/chat/:chatId/like Like a group chat message * @apiName LikeChat * @apiGroup Chat * @apiDescription Likes a chat message from a group * * @apiParam (Path) {UUID} groupId The group _id ('party' for the user party and 'habitrpg' * for tavern are accepted). * @apiParam (Path) {UUID} chatId The chat message _id * * @apiSuccess {Object} data The liked chat message * * @apiUse GroupNotFound * @apiUse MessageNotFound * @apiUse GroupIdRequired * @apiUse ChatIdRequired */ api.likeChat = { method: 'POST', url: '/groups/:groupId/chat/:chatId/like', middlewares: [authWithHeaders()], async handler (req, res) { const { user } = res.locals; const { groupId } = req.params; req.checkParams('groupId', apiError('groupIdRequired')).notEmpty(); req.checkParams('chatId', apiError('chatIdRequired')).notEmpty(); const validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; const group = await Group.getGroup({ user, groupId }); if (!group) throw new NotFound(res.t('groupNotFound')); if (group.privacy === 'public') { throw new BadRequest(res.t('featureRetired')); } const message = await Chat.findOne({ _id: req.params.chatId, groupId: group._id }).exec(); if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); if (!message.likes) message.likes = {}; message.likes[user._id] = !message.likes[user._id]; message.markModified('likes'); await message.save(); res.respond(200, message); // TODO what if the message is flagged and shouldn't be returned? }, }; /** * @api {post} /api/v3/groups/:groupId/chat/:chatId/flag Flag a group chat message * @apiDescription A message will be hidden from chat if two or more users flag a message. * It will be hidden immediately if a moderator flags the message. * An email is sent to the moderators about every flagged message. * @apiName FlagChat * @apiGroup Chat * * @apiParam (Path) {UUID} groupId The group id ('party' for the user party and 'habitrpg' * for tavern are accepted) * @apiParam (Path) {UUID} chatId The chat message id * @apiParam (Body) {String} [comment] explain why the message was flagged * * @apiSuccess {Object} data The flagged chat message * @apiSuccess {UUID} data.id The id of the message * @apiSuccess {String} data.text The text of the message * @apiSuccess {Number} data.timestamp The timestamp of the message in milliseconds * @apiSuccess {Object} data.likes The likes of the message * @apiSuccess {Object} data.flags The flags of the message * @apiSuccess {Number} data.flagCount The number of flags the message has * @apiSuccess {UUID} data.uuid The User ID of the author of the message * @apiSuccess {String} data.user The username of the author of the message * * @apiUse GroupNotFound * @apiUse MessageNotFound * @apiUse GroupIdRequired * @apiUse ChatIdRequired * @apiError (404) {NotFound} AlreadyFlagged Chat messages cannot be flagged more than once by a user * @apiError (404) {NotFound} messageGroupChatFlagAlreadyReported The message has already been flagged. */ api.flagChat = { method: 'POST', url: '/groups/:groupId/chat/:chatId/flag', middlewares: [authWithHeaders()], async handler (req, res) { const chatReporter = chatReporterFactory('Group', req, res); const message = await chatReporter.flag(); res.respond(200, message); }, }; /** * @api {post} /api/v3/groups/:groupId/chat/:chatId/clearflags Clear flags * @apiDescription Resets the flag count on a chat message. * Retains the id of the user's that have flagged the message. (Only visible to moderators) * @apiPermission Admin * @apiName ClearFlags * @apiGroup Chat * * @apiParam (Path) {UUID} groupId The group id ('party' for the user party and 'habitrpg' * for tavern are accepted) * @apiParam (Path) {UUID} chatId The chat message id * * @apiSuccess {Object} data An empty object * * @apiUse GroupNotFound * @apiUse MessageNotFound * @apiUse GroupIdRequired * @apiUse ChatIdRequired * @apiError (404) {NotAuthorized} MustBeAdmin Must be a moderator to use this route */ api.clearChatFlags = { method: 'Post', url: '/groups/:groupId/chat/:chatId/clearflags', middlewares: [authWithHeaders()], async handler (req, res) { const { user } = res.locals; const { groupId } = req.params; const { chatId } = req.params; req.checkParams('groupId', apiError('groupIdRequired')).notEmpty(); req.checkParams('chatId', apiError('chatIdRequired')).notEmpty(); const validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; if (!user.hasPermission('moderator')) { throw new NotAuthorized(res.t('messageGroupChatAdminClearFlagCount')); } const group = await Group.getGroup({ user, groupId, optionalMembership: user.hasPermission('moderator'), }); if (!group) throw new NotFound(res.t('groupNotFound')); const message = await Chat.findOne({ _id: chatId }).exec(); if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); message.flagCount = 0; await message.save(); res.respond(200, {}); }, }; /** * @api {post} /api/v3/groups/:groupId/chat/seen Mark all messages as read for a group * @apiName SeenChat * @apiGroup Chat * * @apiParam (Path) {UUID} groupId The group _id ('party' for the user party and 'habitrpg' * for tavern are accepted) * * @apiSuccess {Object} data An empty object * @apiUse GroupIdRequired */ api.seenChat = { method: 'POST', url: '/groups/:groupId/chat/seen', middlewares: [authWithHeaders()], async handler (req, res) { const { user } = res.locals; const { groupId } = req.params; req.checkParams('groupId', apiError('groupIdRequired')).notEmpty(); const validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; // Do not validate group existence, // it doesn't really matter and make it works if the group gets deleted // let group = await Group.getGroup({user, groupId}); // if (!group) throw new NotFound(res.t('groupNotFound')); const update = { $unset: {}, $pull: {}, }; update.$unset[`newMessages.${groupId}`] = true; update.$pull.notifications = { type: 'NEW_CHAT_MESSAGE', 'data.group.id': groupId, }; // Remove from response user.notifications = user.notifications.filter(n => { if (n && n.type === 'NEW_CHAT_MESSAGE' && n.data && n.data.group && n.data.group.id === groupId) { return false; } return true; }); // Update the user version field manually, // it cannot be updated in the pre update hook // See https://github.com/HabitRPG/habitica/pull/9321#issuecomment-354187666 for more info user._v += 1; await User.updateOne({ _id: user._id }, update).exec(); res.respond(200, {}); }, }; /** * @api {delete} /api/v3/groups/:groupId/chat/:chatId Delete chat message from a group * @apiName DeleteChat * @apiGroup Chat * @apiDescription Delete's a chat message from a group * * @apiParam (Query) {UUID} previousMsg The last message's ID fetched by the * client so that the whole chat will be returned only * if new messages have been posted in the meantime. * @apiParam (Path) {UUID} groupId The group _id ('party' for the user party and 'habitrpg' * for tavern are accepted). * @apiParam (Path) {UUID} chatId The chat message id * * @apiSuccess data The updated chat array or an empty object if no message was posted * after previousMsg. * @apiSuccess {Object} data An empty object when the previous message was deleted * * @apiUse GroupNotFound * @apiUse MessageNotFound * @apiUse GroupIdRequired * @apiUse ChatIdRequired * @apiError (400) onlyCreatorOrAdminCanDeleteChat Only the creator of the message and admins can delete a chat message. */ api.deleteChat = { method: 'DELETE', url: '/groups/:groupId/chat/:chatId', middlewares: [authWithHeaders()], async handler (req, res) { const { user } = res.locals; const { groupId } = req.params; const { chatId } = req.params; req.checkParams('groupId', apiError('groupIdRequired')).notEmpty(); req.checkParams('chatId', apiError('chatIdRequired')).notEmpty(); const validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; const group = await Group.getGroup({ user, groupId, fields: 'chat' }); if (!group) throw new NotFound(res.t('groupNotFound')); const message = await Chat.findOne({ _id: chatId }).exec(); if (!message) throw new NotFound(res.t('messageGroupChatNotFound')); if (user._id !== message.uuid && !user.hasPermission('moderator')) { throw new NotAuthorized(res.t('onlyCreatorOrAdminCanDeleteChat')); } const chatRes = await Group.toJSONCleanChat(group, user); const lastClientMsg = req.query.previousMsg; const chatUpdated = !!( lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg ); await Chat.deleteOne({ _id: message._id }).exec(); if (chatUpdated) { removeFromArray(chatRes.chat, { id: chatId }); res.respond(200, chatRes.chat); } else { res.respond(200, {}); } }, }; export default api;