// chat.js // Copyright (C) 2024 DTP Technologies, LLC // All Rights Reserved 'use strict'; import mongoose from 'mongoose'; const ChatRoom = mongoose.model('ChatRoom'); const ChatMessage = mongoose.model('ChatMessage'); const ChatRoomInvite = mongoose.model('ChatRoomInvite'); import numeral from 'numeral'; import dayjs from 'dayjs'; import { SiteService, SiteError } from '../../lib/site-lib.js'; import { MAX_ROOM_CAPACITY } from '../models/lib/constants.js'; export default class ChatService extends SiteService { static get name ( ) { return 'ChatService'; } static get slug () { return 'chat'; } constructor (dtp) { super(dtp, ChatService); } async start ( ) { const { link: linkService, user: userService } = this.dtp.services; this.templates = { message: this.loadViewTemplate('chat/components/message-standalone.pug'), memberListItem: this.loadViewTemplate('chat/components/member-list-item-standalone.pug'), reactionBar: this.loadViewTemplate('chat/components/reaction-bar-standalone.pug'), }; this.populateChatRoom = [ { path: 'owner', select: userService.USER_SELECT, }, { path: 'present', select: userService.USER_SELECT, }, ]; this.populateChatMessage = [ { path: 'channel', }, { path: 'author', select: userService.USER_SELECT, }, { path: 'mentions', select: userService.USER_SELECT, }, { path: 'links', populate: linkService.populateLink, }, { path: 'attachments.images', }, { path: 'attachments.videos', }, { path: 'reactions', populate: [ { path: 'users', select: 'username displayName', }, ], }, ]; } async createRoom (owner, roomDefinition) { const { text: textService } = this.dtp.services; const NOW = new Date(); const room = new ChatRoom(); room.created = NOW; room.owner = owner._id; room.name = textService.filter(roomDefinition.name); if (roomDefinition.topic) { room.topic = textService.filter(roomDefinition.topic); } room.capacity = MAX_ROOM_CAPACITY; room.members = [owner._id]; room.stats.memberCount = 1; await room.save(); return room.toObject(); } async updateRoomSettings (room, settingsDefinition) { const { text: textService } = this.dtp.services; const update = { $set: { }, $unset: { } }; update.$set.name = textService.filter(settingsDefinition.name); if (!update.$set.name) { throw new SiteError(400, 'Room must have a name'); } const topic = textService.filter(settingsDefinition.topic); if (topic && (room.topic !== topic)) { update.$set.topic = topic; } else { update.$unset.topic = 1; } update.$set['settings.expireDays'] = parseInt(settingsDefinition.expireDays, 10); await ChatRoom.updateOne({ _id: room._id }, update); } async destroyRoom (user, room) { if (!user._id.equals(room.owner._id)) { throw new SiteError(401, 'This is not your chat room'); } await this.removeInvitesForRoom(room); await this.removeMessagesForChannel(room); await ChatRoom.deleteOne({ _id: room._id }); } async joinRoom (room, user) { const roomData = await ChatRoom.findOne({ _id: room._id, banned: user._id }).lean(); if (roomData) { throw new SiteError(401, 'You are banned from this chat room'); } const response = await ChatRoom.updateOne( { _id: room._id }, { $push: { members: user._id }, }, ); this.log.debug('joinRoom complete', { response }); return response; } async chatRoomCheckIn (room, member) { const NOW = new Date(); // indicate presence in the chat room's Mongo document const roomData = await ChatRoom.findOneAndUpdate( { _id: room._id }, { $addToSet: { present: member._id }, $inc: { 'stats.presentCount': 1 }, }, { new: true, }, ); this.log.debug('member checking into chat room', { room: { _id: room._id, name: room.name, presentCount: roomData.stats.presentCount, }, member: { _id: member._id, username: member.username, }, }); /* * Broadcast a control message to all room members that a new member has * joined the room. */ const displayList = this.createDisplayList('chat-control'); displayList.removeElement(`ul#present-members li[data-member-id="${member._id}"]`); displayList.addElement( `ul#chat-active-members[data-room-id="${room._id}"]`, 'afterBegin', this.templates.memberListItem({ room, member }), ); displayList.setTextContent( `.chat-present-count`, numeral(roomData.stats.presentCount).format('0,0'), ); const systemMessage = { created: NOW.toISOString(), content: `${member.displayName || member.username} has entered the room.`, }; this.dtp.emitter .to(room._id.toString()) .emit('chat-control', { displayList, audio: { playSound: 'chat-room-connect' }, systemMessages: [systemMessage], }); } async chatRoomCheckOut (room, member) { const NOW = new Date(); const roomData = await ChatRoom.findOneAndUpdate( { _id: room._id }, { $pull: { present: member._id }, $inc: { 'stats.presentCount': -1 }, }, { new: true, }, ); this.log.debug('member checking out of chat room', { room: { _id: room._id, name: room.name, presentCount: roomData.stats.presentCount, }, member: { _id: member._id, username: member.username, }, }); /* * Broadcast a control message to all room members that a new member has * joined the room. */ const displayList = this.createDisplayList('chat-control'); displayList.removeElement(`ul#chat-active-members li[data-member-id="${member._id}"]`); displayList.setTextContent( `.chat-present-count`, numeral(roomData.stats.presentCount).format('0,0'), ); const systemMessage = { created: NOW.toISOString(), content: `@${member.username} has connected to the room.`, }; this.dtp.emitter.to(room._id.toString()).emit('chat-control', { displayList, systemMessages: [systemMessage] }); } async sendRoomMessage (room, author, messageDefinition, imageFiles, videoFiles) { const NOW = new Date(); const { image: imageService, text: textService, user: userService, video: videoService, } = this.dtp.services; const message = new ChatMessage(); message.created = NOW; message.expires = dayjs(NOW).add(room?.settings?.expireDays || 7, 'day'); message.channelType = 'ChatRoom'; message.channel = room._id; message.author = author._id; message.content = textService.filter(messageDefinition.content); message.mentions = await textService.findMentions(message.content); message.hashtags = await textService.findHashtags(message.content); message.links = await textService.findLinks(author, message.content, { channelId: room._id }); if (imageFiles) { for (const imageFile of imageFiles) { const image = await imageService.create(author, { }, imageFile); message.attachments.images.push(image._id); } } if (videoFiles) { for (const videoFile of videoFiles) { switch (videoFile.mimetype) { case 'video/mp4': const video = await videoService.createVideo(author, { }, videoFile); message.attachments.videos.push(video._id); break; case 'video/quicktime': await videoService.transcodeMov(videoFile); const mov = await videoService.createVideo(author, { }, videoFile); message.attachments.videos.push(mov._id); break; case 'image/gif': await videoService.transcodeGif(videoFile); const gif = await videoService.createVideo(author, { fromGif: true }, videoFile); message.attachments.videos.push(gif._id); break; } } } await message.save(); await ChatMessage.populate(message, this.populateChatMessage); let viewModel = Object.assign({ }, this.dtp.app.locals); viewModel = Object.assign(viewModel, { user: author, message }); const html = this.templates.message(viewModel); const messageObj = message.toObject(); messageObj.author = userService.filterUserObject(author); this.dtp.emitter .to(room._id.toString()) .emit('chat-message', { message: messageObj, html }); return messageObj; } async getRoomMessages (room, pagination) { const messages = await ChatMessage .find({ channel: room._id }) .sort({ created: -1 }) .skip(pagination.skip) .limit(pagination.cpp) .populate(this.populateChatMessage) .lean(); return messages.reverse(); } async toggleMessageReaction (sender, message, reactionDefinition) { const reaction = message.reactions ? message.reactions.find((r) => r.emoji === reactionDefinition.emoji) : undefined; if (reaction) { const currentReact = reaction.users.find((user) => user._id.equals(sender._id)); if (currentReact) { if (reaction.users.length === 1) { // last user to react, remove the whole reaction for this emoji await ChatMessage.updateOne( { _id: message._id, 'reactions.emoji': reactionDefinition.emoji, }, { $pull: { 'reactions': { emoji: reactionDefinition.emoji }, }, }, ); return this.updateMessageReactionBar(message, { playSound: 'reaction-remove' }); } // just pull the user from the emoji's users array await ChatMessage.updateOne( { _id: message._id, 'reactions.emoji': reactionDefinition.emoji, }, { $pull: { 'reactions': { user: sender._id }, }, }, ); return this.updateMessageReactionBar(message, { playSound: 'reaction-remove' }); } else { // add sender to emoji's users array await ChatMessage.updateOne( { _id: message._id, 'reactions.emoji': reactionDefinition.emoji, }, { $push: { 'reactions.$.users': sender._id, } }, ); return this.updateMessageReactionBar(message, { playSound: 'reaction' }); } } // create a reaction for the emoji await ChatMessage.updateOne( { _id: message._id }, { $push: { reactions: { emoji: reactionDefinition.emoji, users: [sender._id], }, }, }, ); return this.updateMessageReactionBar(message, { playSound: 'reaction' }); } async updateMessageReactionBar (message, audio) { message = await ChatMessage .findOne({ _id: message._id }) .populate(this.populateChatMessage) .lean(); let viewModel = Object.assign({ }, this.dtp.app.locals); viewModel = Object.assign(viewModel, { message }); const displayList = this.createDisplayList('reaction-bar-update'); displayList.replaceElement( `.chat-message[data-message-id="${message._id}"] .message-reaction-bar`, this.templates.reactionBar(viewModel), ); const payload = { displayList }; if (audio) { payload.audio = audio; } this.dtp.emitter .to(message.channel._id.toString()) .emit('chat-control', payload); } async checkRoomMember (room, member) { if (room.owner._id.equals(member._id)) { return true; } const search = { _id: room._id, members: member._id }; const checkRoom = await ChatRoom.findOne(search).select('name').lean(); if (!checkRoom) { throw new SiteError(403, `You are not a member of ${checkRoom.name}`); } return true; } async isRoomMember (room, member) { if (room.owner._id.equals(member._id)) { return true; } const search = { _id: room._id, members: member._id }; const checkRoom = await ChatRoom.findOne(search).select('name').lean(); return !!checkRoom; } async leaveRoom (room, user) { await ChatRoom.updateOne( { _id: room._id }, { $pull: { members: user._id }, }, ); } async getRoomMemberList (room) { const roomData = await ChatRoom.findOne({ _id: room._id }).select('members'); if (!roomData) { throw new SiteError(404, 'Room not found'); } return roomData.members; } async getRoomBlockList (room) { const roomData = await ChatRoom.findOne({ _id: room._id }).select('members'); if (!roomData) { throw new SiteError(404, 'Room not found'); } return roomData.banned; } async getRoomById (roomId) { const room = await ChatRoom .findOne({ _id: roomId }) .populate(this.populateChatRoom) .lean(); return room; } async getRoomsForOwner (owner) { const rooms = await ChatRoom .find({ owner: owner._id }) .populate(this.populateChatRoom) .lean(); return rooms; } async getRoomsForMember (member, pagination) { const rooms = await ChatRoom .find({ members: member._id }) .populate(this.populateChatRoom) .skip(pagination.skip) .limit(pagination.cpp) .lean(); return rooms; } async removeInvitesForRoom (room) { await ChatRoomInvite.deleteMany({ room: room._id }); } async getMessageById (messageId) { const message = await ChatMessage .findOne({ _id: messageId }) .populate(this.populateChatMessage) .lean(); return message; } async removeMessagesForChannel (channel) { this.log.alert('removing all messages for channel', { channelId: channel._id }); await ChatMessage .find({ channel: channel._id }) .cursor() .eachAsync(async (message) => { await this.removeMessage(message); }, 4); } async expireMessages ( ) { const NOW = new Date(); this.log.info('expiring chat messages'); await ChatMessage .find({ $or: [ { expires: { $lt: NOW } }, { expires: { $exists: false } }, ], }) .cursor() .eachAsync(async (message) => { await this.removeMessage(message); }, 4); } async removeMessage (message) { const { image: imageService, video: videoService } = this.dtp.services; if (message.attachments) { if (Array.isArray(message.attachments.images) && (message.attachments.images.length > 0)) { for (const image of message.attachments.images) { this.log.debug('removing message attachment', { imageId: image._id }); await imageService.deleteImage(image); } } if (Array.isArray(message.attachments.videos) && (message.attachments.videos.length > 0)) { for (const video of message.attachments.videos) { this.log.debug('removing video attachment', { videoId: video._id }); await videoService.removeVideo(video); } } } this.log.debug('removing chat message', { messageId: message._id }); await ChatMessage.deleteOne({ _id: message._id }); const displayList = this.createDisplayList('remove-chat-message'); displayList.removeElement(`.chat-message[data-message-id="${message._id}"]`); this.dtp.emitter .to(message.channel._id.toString()) .emit('chat-control', { displayList, audio: { playSound: 'message-remove' }, }); } async removeAllForUser (user) { this.log.info('removing all chat rooms for user', { user: { _id: user._id, username: user.username, }, }); await ChatRoom .find({ owner: user._id }) .populate(this.populateChatRoom) .cursor() .eachAsync(async (room) => { await this.destroyRoom(room); }); this.log.info('removing all chat messages for user', { user: { _id: user._id, username: user.username, }, }); await ChatMessage .find({ author: user._id }) .populate(this.populateChatMessage) .cursor() .eachAsync(async (message) => { await this.removeMessage(message); }); } }