Files
habitica/website/server/controllers/api-v3/chat.js
Blade Barringer 35b92f13a3 Webhook improvements (#7879)
* refactor: Move translate test utility to helpers directory

* Add kind property to webhooks

* feat: Add options to create webhook route

* refactor: Move webhook ops into single file

* refactor: Create webhook objects for specific webhook behavior

* chore(tests): Add default sleep helper value of 1 second

* feat(api): Add method for groups to send out webhook

* feat(api): Add taskCreated webhook task creation

* feat(api): Send chat webhooks after a chat is sent

* refactor: Move webhook routes to own controller

* lint: Correct linting errors

* fix(api): Correct taskCreated webhook method

* fix(api): Fix webhook logging to only log when there is an error

* fix: Update groupChatRecieved webhook creation

* chore: Add integration tests for webhooks

* fix: Set webhook creation response to 201

* fix: Correct how task scored webhook data is sent

* Revert group chat recieved webhook to only support one group

* Remove quest activity option for webhooks

* feat: Send webhook for each task created

* feat: Allow webhooks without a type to default to taskScored

* feat: Add logic for adding ids to webhook

* feat: optimize webhook url check by shortcircuiting if no url is passed

* refactor: Use full name for webhook variable

* feat: Add missing params to client webhook

* lint: Add missing semicolon

* chore(tests): Fix inccorect webhook tests

* chore: Add migration to update task scored webhooks

* feat: Allow default value of webhook add route to be enabled

* chore: Update webhook documentation

* chore: Remove special handling for v2

* refactor: adjust addComputedStatsToJSONObject to work for webhooks

* refactor: combine taskScored and taskActivity webhooks

* feat(api): Add task activity to task update and delete routes

* chore: Change references to taskScored to taskActivity

* fix: Correct stats object being passed in for transform

* chore: Remove extra line break

* fix: Pass in the language to use for the translations

* refactor(api): Move webhooks from user.preferences.webhooks to user.webhooks

* chore: Update migration to set webhook array

* lint: Correct brace spacing

* chore: convert webhook lib to use user.webhooks

* refactor(api): Consolidate filters

* chore: clarify migration instructions

* fix(test): Correct user creation in user anonymized tests

* chore: add test that webhooks cannot be updated via PUT /user

* refactor: Simplify default webhook id value

* refactor(client): Push newly created webhook instead of doing a sync

* chore(test): Add test file for webhook model

* refactor: Remove webhook validation

* refactor: Remove need for watch on webhooks

* refactor(client): Update webhooks object without syncing

* chore: update webhook documentation

* Fix migrations issues

* chore: remove v2 test helper

* fix(api): Provide webhook type in task scored webhook

* fix(client): Fix webhook deletion appearing to delete all webhooks

* feat(api): add optional label field for webhooks

* feat: provide empty string as default for webhook label

* chore: Update webhook migration

* chore: update webhook migration name
2016-10-02 09:16:22 -05:00

457 lines
16 KiB
JavaScript

import { authWithHeaders } from '../../middlewares/auth';
import { model as Group } from '../../models/group';
import { model as User } from '../../models/user';
import {
NotFound,
NotAuthorized,
} from '../../libs/errors';
import _ from 'lodash';
import { removeFromArray } from '../../libs/collectionManipulators';
import { getUserInfo, getGroupUrl, sendTxn } from '../../libs/email';
import slack from '../../libs/slack';
import pusher from '../../libs/pusher';
import nconf from 'nconf';
import Bluebird from 'bluebird';
const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map((email) => {
return { email, canSend: true };
});
let api = {};
async function getAuthorEmailFromMessage (message) {
let authorId = message.uuid;
if (authorId === 'system') {
return 'system';
}
let author = await User.findOne({_id: authorId}, {auth: 1});
if (author) {
return getUserInfo(author, ['email']).email;
} else {
return 'Author Account Deleted';
}
}
/**
* @api {get} /api/v3/groups/:groupId/chat Get chat messages from a group
* @apiVersion 3.0.0
* @apiName GetChat
* @apiGroup Chat
*
* @apiParam {String} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
*
* @apiSuccess {Array} data An array of chat messages
*/
api.getChat = {
method: 'GET',
url: '/groups/:groupId/chat',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId: req.params.groupId, fields: 'chat'});
if (!group) throw new NotFound(res.t('groupNotFound'));
res.respond(200, Group.toJSONCleanChat(group, user).chat);
},
};
/**
* @api {post} /api/v3/groups/:groupId/chat Post chat message to a group
* @apiVersion 3.0.0
* @apiName PostChat
* @apiGroup Chat
*
* @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
* @apiParam {String} message Body parameter - message The message to post
* @apiParam {UUID} previousMsg Query parameter - The previous chat message which will force a return of the full group chat
*
* @apiSuccess data An array of chat messages if a new message was posted after previousMsg, otherwise the posted message
*/
api.postChat = {
method: 'POST',
url: '/groups/:groupId/chat',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let groupId = req.params.groupId;
let chatUpdated;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
req.checkBody('message', res.t('messageGroupChatBlankMessage')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId});
if (!group) throw new NotFound(res.t('groupNotFound'));
if (group.type !== 'party' && user.flags.chatRevoked) {
throw new NotFound('Your chat privileges have been revoked.');
}
let lastClientMsg = req.query.previousMsg;
chatUpdated = lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg ? true : false;
let newChatMessage = group.sendChat(req.body.message, user);
let toSave = [group.save()];
if (group.type === 'party') {
user.party.lastMessageSeen = group.chat[0].id;
toSave.push(user.save());
}
let [savedGroup] = await Bluebird.all(toSave);
// real-time chat is only enabled for private groups (for now only for parties)
if (savedGroup.privacy === 'private' && savedGroup.type === 'party') {
// req.body.pusherSocketId is sent from official clients to identify the sender user's real time socket
// see https://pusher.com/docs/server_api_guide/server_excluding_recipients
pusher.trigger(`presence-group-${savedGroup._id}`, 'new-chat', newChatMessage, req.body.pusherSocketId);
}
if (chatUpdated) {
res.respond(200, {chat: Group.toJSONCleanChat(savedGroup, user).chat});
} else {
res.respond(200, {message: savedGroup.chat[0]});
}
group.sendGroupChatReceivedWebhooks(newChatMessage);
},
};
/**
* @api {post} /api/v3/groups/:groupId/chat/:chatId/like Like a group chat message
* @apiVersion 3.0.0
* @apiName LikeChat
* @apiGroup Chat
*
* @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
* @apiParam {UUID} chatId The chat message _id
*
* @apiSuccess {Object} data The liked chat message
*/
api.likeChat = {
method: 'POST',
url: '/groups/:groupId/chat/:chatId/like',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let groupId = req.params.groupId;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
req.checkParams('chatId', res.t('chatIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId});
if (!group) throw new NotFound(res.t('groupNotFound'));
let message = _.find(group.chat, {id: req.params.chatId});
if (!message) throw new NotFound(res.t('messageGroupChatNotFound'));
if (message.uuid === user._id) throw new NotFound(res.t('messageGroupChatLikeOwnMessage'));
let update = {$set: {}};
if (!message.likes) message.likes = {};
message.likes[user._id] = !message.likes[user._id];
update.$set[`chat.$.likes.${user._id}`] = message.likes[user._id];
await Group.update(
{_id: group._id, 'chat.id': message.id},
update
);
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 {UUID} groupId The group id ('party' for the user party and 'habitrpg' for tavern are accepted)
* @apiParam {UUID} chatId The chat message id
*
* @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
*
* @apiError GroupNotFound Group could not be found or you don't have access
* @apiError ChatNotFound Chat message with specified id could not be found
* @apiError FlagOwnMessage Chat messages cannot be flagged by the author of the message
* @apiError AlreadyFlagged Chat messages cannot be flagged more than once by a user
*/
api.flagChat = {
method: 'POST',
url: '/groups/:groupId/chat/:chatId/flag',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let groupId = req.params.groupId;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
req.checkParams('chatId', res.t('chatIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({
user,
groupId,
optionalMembership: user.contributor.admin,
});
if (!group) throw new NotFound(res.t('groupNotFound'));
let message = _.find(group.chat, {id: req.params.chatId});
if (!message) throw new NotFound(res.t('messageGroupChatNotFound'));
let update = {$set: {}};
// Log user ids that have flagged the message
if (!message.flags) message.flags = {};
if (message.flags[user._id] && !user.contributor.admin) throw new NotFound(res.t('messageGroupChatFlagAlreadyReported'));
message.flags[user._id] = true;
update.$set[`chat.$.flags.${user._id}`] = true;
// Log total number of flags (publicly viewable)
if (!message.flagCount) message.flagCount = 0;
if (user.contributor.admin) {
// Arbitrary amount, higher than 2
message.flagCount = 5;
} else {
message.flagCount++;
}
update.$set['chat.$.flagCount'] = message.flagCount;
await Group.update(
{_id: group._id, 'chat.id': message.id},
update
);
let reporterEmailContent = getUserInfo(user, ['email']).email;
let authorEmail = await getAuthorEmailFromMessage(message);
let groupUrl = getGroupUrl(group);
sendTxn(FLAG_REPORT_EMAILS, 'flag-report-to-mods', [
{name: 'MESSAGE_TIME', content: (new Date(message.timestamp)).toString()},
{name: 'MESSAGE_TEXT', content: message.text},
{name: 'REPORTER_USERNAME', content: user.profile.name},
{name: 'REPORTER_UUID', content: user._id},
{name: 'REPORTER_EMAIL', content: reporterEmailContent},
{name: 'REPORTER_MODAL_URL', content: `/static/front/#?memberId=${user._id}`},
{name: 'AUTHOR_USERNAME', content: message.user},
{name: 'AUTHOR_UUID', content: message.uuid},
{name: 'AUTHOR_EMAIL', content: authorEmail},
{name: 'AUTHOR_MODAL_URL', content: `/static/front/#?memberId=${message.uuid}`},
{name: 'GROUP_NAME', content: group.name},
{name: 'GROUP_TYPE', content: group.type},
{name: 'GROUP_ID', content: group._id},
{name: 'GROUP_URL', content: groupUrl},
]);
slack.sendFlagNotification({
authorEmail,
flagger: user,
group,
message,
});
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 Moderators
* @apiName ClearFlags
* @apiGroup Chat
*
* @apiParam {UUID} groupId The group id ('party' for the user party and 'habitrpg' for tavern are accepted)
* @apiParam {UUID} chatId The chat message id
*
* @apiSuccess {Object} data An empty object
*
* @apiError MustBeAdmin Must be a moderator to use this route
* @apiError GroupNotFound Group could not be found or you don't have access
* @apiError ChatNotFound Chat message with specified id could not be found
*/
api.clearChatFlags = {
method: 'Post',
url: '/groups/:groupId/chat/:chatId/clearflags',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let groupId = req.params.groupId;
let chatId = req.params.chatId;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
req.checkParams('chatId', res.t('chatIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
if (!user.contributor.admin) {
throw new NotAuthorized(res.t('messageGroupChatAdminClearFlagCount'));
}
let group = await Group.getGroup({
user,
groupId,
optionalMembership: user.contributor.admin,
});
if (!group) throw new NotFound(res.t('groupNotFound'));
let message = _.find(group.chat, {id: chatId});
if (!message) throw new NotFound(res.t('messageGroupChatNotFound'));
message.flagCount = 0;
await Group.update(
{_id: group._id, 'chat.id': message.id},
{$set: {'chat.$.flagCount': message.flagCount}}
);
let adminEmailContent = getUserInfo(user, ['email']).email;
let authorEmail = getAuthorEmailFromMessage(message);
let groupUrl = getGroupUrl(group);
sendTxn(FLAG_REPORT_EMAILS, 'unflag-report-to-mods', [
{name: 'MESSAGE_TIME', content: (new Date(message.timestamp)).toString()},
{name: 'MESSAGE_TEXT', content: message.text},
{name: 'ADMIN_USERNAME', content: user.profile.name},
{name: 'ADMIN_UUID', content: user._id},
{name: 'ADMIN_EMAIL', content: adminEmailContent},
{name: 'ADMIN_MODAL_URL', content: `/static/front/#?memberId=${user._id}`},
{name: 'AUTHOR_USERNAME', content: message.user},
{name: 'AUTHOR_UUID', content: message.uuid},
{name: 'AUTHOR_EMAIL', content: authorEmail},
{name: 'AUTHOR_MODAL_URL', content: `/static/front/#?memberId=${message.uuid}`},
{name: 'GROUP_NAME', content: group.name},
{name: 'GROUP_TYPE', content: group.type},
{name: 'GROUP_ID', content: group._id},
{name: 'GROUP_URL', content: groupUrl},
]);
res.respond(200, {});
},
};
/**
* @api {post} /api/v3/groups/:groupId/chat/seen Mark all messages as read for a group
* @apiVersion 3.0.0
* @apiName SeenChat
* @apiGroup Chat
*
* @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
*
* @apiSuccess {Object} data An empty object
*/
api.seenChat = {
method: 'POST',
url: '/groups/:groupId/chat/seen',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let groupId = req.params.groupId;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
let 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'));
let update = {$unset: {}};
update.$unset[`newMessages.${groupId}`] = true;
await User.update({_id: user._id}, update).exec();
res.respond(200, {});
},
};
/**
* @api {delete} /api/v3/groups/:groupId/chat/:chatId Delete chat message from a group
* @apiVersion 3.0.0
* @apiName DeleteChat
* @apiGroup Chat
*
* @apiParam {String} previousMsg Query parameter - The last message fetched by the client so that the whole chat will be returned only if new messages have been posted in the meantime
* @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
* @apiParam {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
*/
api.deleteChat = {
method: 'DELETE',
url: '/groups/:groupId/chat/:chatId',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let groupId = req.params.groupId;
let chatId = req.params.chatId;
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty();
req.checkParams('chatId', res.t('chatIdRequired')).notEmpty();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let group = await Group.getGroup({user, groupId, fields: 'chat'});
if (!group) throw new NotFound(res.t('groupNotFound'));
let message = _.find(group.chat, {id: chatId});
if (!message) throw new NotFound(res.t('messageGroupChatNotFound'));
if (user._id !== message.uuid && !user.contributor.admin) {
throw new NotAuthorized(res.t('onlyCreatorOrAdminCanDeleteChat'));
}
let lastClientMsg = req.query.previousMsg;
let chatUpdated = lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg ? true : false;
await Group.update(
{_id: group._id},
{$pull: {chat: {id: chatId}}}
);
if (chatUpdated) {
let chatRes = Group.toJSONCleanChat(group, user).chat;
removeFromArray(chatRes, {id: chatId});
res.respond(200, chatRes);
} else {
res.respond(200, {});
}
},
};
module.exports = api;