Files
habitica/website/server/controllers/api-v3/chat.js
2024-02-05 18:06:18 -06:00

496 lines
16 KiB
JavaScript

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 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 = {};
/**
* @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 <a href='https://github.com/HabitRPG/habitica/blob/develop/website/server/models/group.js#L51' target='_blank'>chat messages</a>
*
* @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);
},
};
/**
* @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'));
}
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', {
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 = 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 = {
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 <a href='https://github.com/HabitRPG/habitica/blob/develop/website/server/models/group.js#L51' target='_blank'>chat message</a>
*
* @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 }).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;