mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-17 22:57:21 +01:00
Real-time Chat (#7664)
* feat(realtime-chat): add Pusher library to the server * feat(realtime-chat): only for private groups * feat(realtime-chat): add authentication endpoint for Pusher * feat(realtime-chat): client proof of concept * fix typo in apidoc * feat(realtime-chat): redo authentication and write integration tests * remove firebase code * fix client side tests * fix line ending in bower.json * feat(realtime chat): use presence channels for parties, send events & disconnect clients if user leaves or is removed from party, automatically update UI * pusher: enable all events in the background * fix pusher integration tests
This commit is contained in:
@@ -18,8 +18,8 @@ import { model as Group } from '../../models/group';
|
||||
import { model as EmailUnsubscription } from '../../models/emailUnsubscription';
|
||||
import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email';
|
||||
import { decrypt } from '../../libs/api-v3/encryption';
|
||||
import FirebaseTokenGenerator from 'firebase-token-generator';
|
||||
import { send as sendEmail } from '../../libs/api-v3/email';
|
||||
import pusher from '../../libs/api-v3/pusher';
|
||||
|
||||
let api = {};
|
||||
|
||||
@@ -282,6 +282,76 @@ api.loginSocial = {
|
||||
},
|
||||
};
|
||||
|
||||
/*
|
||||
* @apiIgnore Private route
|
||||
* @api {post} /api/v3/user/auth/pusher Pusher.com authentication
|
||||
* @apiDescription Authentication for Pusher.com private and presence channels
|
||||
* @apiVersion 3.0.0
|
||||
* @apiName UserAuthPusher
|
||||
* @apiGroup User
|
||||
*
|
||||
* @apiParam {String} socket_id Body parameter
|
||||
* @apiParam {String} channel_name Body parameter
|
||||
*
|
||||
* @apiSuccess {String} auth The authentication token
|
||||
*/
|
||||
api.pusherAuth = {
|
||||
method: 'POST',
|
||||
middlewares: [authWithHeaders()],
|
||||
url: '/user/auth/pusher',
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
||||
req.checkBody('socket_id').notEmpty();
|
||||
req.checkBody('channel_name').notEmpty();
|
||||
|
||||
let validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let socketId = req.body.socket_id;
|
||||
let channelName = req.body.channel_name;
|
||||
|
||||
// Channel names are in the form of {presence|private}-{group|...}-{resourceId}
|
||||
let [channelType, resourceType, ...resourceId] = channelName.split('-');
|
||||
|
||||
if (['presence'].indexOf(channelType) === -1) { // presence is used only for parties, private for guilds too
|
||||
throw new BadRequest('Invalid Pusher channel type.');
|
||||
}
|
||||
|
||||
if (resourceType !== 'group') { // only groups are supported
|
||||
throw new BadRequest('Invalid Pusher resource type.');
|
||||
}
|
||||
|
||||
resourceId = resourceId.join('-'); // the split at the beginning had split resourceId too
|
||||
if (!validator.isUUID(resourceId)) {
|
||||
throw new BadRequest('Invalid Pusher resource id, must be a UUID.');
|
||||
}
|
||||
|
||||
// Only the user's party is supported for now
|
||||
if (user.party._id !== resourceId) {
|
||||
throw new NotFound('Resource id must be the user\'s party.');
|
||||
}
|
||||
|
||||
let authResult;
|
||||
|
||||
// Max 100 members for presence channel - parties only
|
||||
if (channelType === 'presence') {
|
||||
let presenceData = {
|
||||
user_id: user._id, // eslint-disable-line camelcase
|
||||
// Max 1KB
|
||||
user_info: {}, // eslint-disable-line camelcase
|
||||
};
|
||||
|
||||
authResult = pusher.authenticate(socketId, channelName, presenceData);
|
||||
} else {
|
||||
authResult = pusher.authenticate(socketId, channelName);
|
||||
}
|
||||
|
||||
// Not using res.respond because Pusher requires a different response format
|
||||
res.status(200).json(authResult);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {put} /api/v3/user/auth/update-username Update username
|
||||
* @apiDescription Update the username of a local user
|
||||
@@ -472,28 +542,6 @@ api.updateEmail = {
|
||||
},
|
||||
};
|
||||
|
||||
const firebaseTokenGenerator = new FirebaseTokenGenerator(nconf.get('FIREBASE:SECRET'));
|
||||
|
||||
// Internal route
|
||||
api.getFirebaseToken = {
|
||||
method: 'POST',
|
||||
url: '/user/auth/firebase',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
// Expires 24 hours from now (60*60*24*1000) (in milliseconds)
|
||||
let expires = new Date();
|
||||
expires.setTime(expires.getTime() + 86400000);
|
||||
|
||||
let token = firebaseTokenGenerator.createToken({
|
||||
uid: user._id,
|
||||
isHabiticaUser: true,
|
||||
}, { expires });
|
||||
|
||||
res.respond(200, {token, expires});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {delete} /api/v3/user/auth/social/:network Delete social authentication method
|
||||
* @apiDescription Remove a social authentication method (only facebook supported) from a user profile. The user must have local authentication enabled
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import _ from 'lodash';
|
||||
import { removeFromArray } from '../../libs/api-v3/collectionManipulators';
|
||||
import { getUserInfo, getGroupUrl, sendTxn } from '../../libs/api-v3/email';
|
||||
import pusher from '../../libs/api-v3/pusher';
|
||||
import nconf from 'nconf';
|
||||
import Bluebird from 'bluebird';
|
||||
|
||||
@@ -53,8 +54,8 @@ api.getChat = {
|
||||
* @apiGroup Chat
|
||||
*
|
||||
* @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
|
||||
* @apiParam {message} Body parameter - message The message to post
|
||||
* @apiParam {previousMsg} previousMsg Query parameter - The previous chat message which will force a return of the full group chat
|
||||
* @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
|
||||
*/
|
||||
@@ -83,7 +84,7 @@ api.postChat = {
|
||||
let lastClientMsg = req.query.previousMsg;
|
||||
chatUpdated = lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg ? true : false;
|
||||
|
||||
group.sendChat(req.body.message, user);
|
||||
let newChatMessage = group.sendChat(req.body.message, user);
|
||||
|
||||
let toSave = [group.save()];
|
||||
|
||||
@@ -93,6 +94,14 @@ api.postChat = {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -107,8 +116,8 @@ api.postChat = {
|
||||
* @apiName LikeChat
|
||||
* @apiGroup Chat
|
||||
*
|
||||
* @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
|
||||
* @apiParam {chatId} chatId The chat message _id
|
||||
* @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
|
||||
*/
|
||||
@@ -154,8 +163,8 @@ api.likeChat = {
|
||||
* @apiName FlagChat
|
||||
* @apiGroup Chat
|
||||
*
|
||||
* @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
|
||||
* @apiParam {chatId} chatId The chat message id
|
||||
* @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
|
||||
*/
|
||||
@@ -243,8 +252,8 @@ api.flagChat = {
|
||||
* @apiName ClearFlags
|
||||
* @apiGroup Chat
|
||||
*
|
||||
* @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
|
||||
* @apiParam {chatId} chatId The chat message id
|
||||
* @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
|
||||
*/
|
||||
@@ -318,7 +327,7 @@ api.clearChatFlags = {
|
||||
* @apiName SeenChat
|
||||
* @apiGroup Chat
|
||||
*
|
||||
* @apiParam {groupId} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
|
||||
* @apiParam {UUID} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
|
||||
*
|
||||
* @apiSuccess {Object} data An empty object
|
||||
*/
|
||||
@@ -354,8 +363,8 @@ api.seenChat = {
|
||||
* @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 {string} groupId The group _id ('party' for the user party and 'habitrpg' for tavern are accepted)
|
||||
* @apiParam {string} chatId The chat message id
|
||||
* @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
|
||||
|
||||
@@ -17,10 +17,11 @@ import {
|
||||
NotAuthorized,
|
||||
} from '../../libs/api-v3/errors';
|
||||
import { removeFromArray } from '../../libs/api-v3/collectionManipulators';
|
||||
import * as firebase from '../../libs/api-v3/firebase';
|
||||
import { sendTxn as sendTxnEmail } from '../../libs/api-v3/email';
|
||||
import { encrypt } from '../../libs/api-v3/encryption';
|
||||
import sendPushNotification from '../../libs/api-v3/pushNotifications';
|
||||
import pusher from '../../libs/api-v3/pusher';
|
||||
|
||||
let api = {};
|
||||
|
||||
/**
|
||||
@@ -66,9 +67,6 @@ api.createGroup = {
|
||||
profile: {name: user.profile.name},
|
||||
};
|
||||
res.respond(201, response); // do not remove chat flags data as we've just created the group
|
||||
|
||||
firebase.updateGroupData(savedGroup);
|
||||
firebase.addUserToGroup(savedGroup._id, user._id);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -179,8 +177,6 @@ api.updateGroup = {
|
||||
};
|
||||
}
|
||||
res.respond(200, response);
|
||||
|
||||
firebase.updateGroupData(savedGroup);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -278,8 +274,6 @@ api.joinGroup = {
|
||||
response.leader = leader.toJSON({minimize: true});
|
||||
}
|
||||
res.respond(200, response);
|
||||
|
||||
firebase.addUserToGroup(group._id, user._id);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -449,7 +443,15 @@ api.removeGroupMember = {
|
||||
if (isInGroup === 'guild') {
|
||||
removeFromArray(member.guilds, group._id);
|
||||
}
|
||||
if (isInGroup === 'party') member.party._id = undefined; // TODO remove quest information too? Use group.leave()?
|
||||
if (isInGroup === 'party') {
|
||||
// Tell the realtime clients that a user is being removed
|
||||
// If the user that is being removed is still connected, they'll get disconnected automatically
|
||||
pusher.trigger(`presence-group-${group._id}`, 'user-removed', {
|
||||
userId: user._id,
|
||||
});
|
||||
|
||||
member.party._id = undefined; // TODO remove quest information too? Use group.leave()?
|
||||
}
|
||||
|
||||
if (member.newMessages[group._id]) {
|
||||
member.newMessages[group._id] = undefined;
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import { model as User } from '../../models/user';
|
||||
import Bluebird from 'bluebird';
|
||||
import _ from 'lodash';
|
||||
import * as firebase from '../../libs/api-v3/firebase';
|
||||
import * as passwordUtils from '../../libs/api-v3/password';
|
||||
|
||||
let api = {};
|
||||
@@ -230,8 +229,6 @@ api.deleteUser = {
|
||||
await user.remove();
|
||||
|
||||
res.respond(200, {});
|
||||
|
||||
firebase.deleteUser(user._id);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user