You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
594 lines
16 KiB
594 lines
16 KiB
// 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: `<a href="/member/${member.username}", uk-tooltip="Visit ${member.username}">@${member.username}</a> 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);
|
|
});
|
|
}
|
|
}
|