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.
599 lines
15 KiB
599 lines
15 KiB
// chat.js
|
|
// Copyright (C) 2022 DTP Technologies, LLC
|
|
// License: Apache-2.0
|
|
|
|
'use strict';
|
|
|
|
const Redis = require('ioredis');
|
|
|
|
const mongoose = require('mongoose');
|
|
|
|
const ChatRoom = mongoose.model('ChatRoom');
|
|
const ChatRoomInvite = mongoose.model('ChatRoomInvite');
|
|
|
|
const ChatMessage = mongoose.model('ChatMessage');
|
|
const EmojiReaction = mongoose.model('EmojiReaction');
|
|
|
|
const ioEmitter = require('socket.io-emitter');
|
|
|
|
const marked = require('marked');
|
|
const hljs = require('highlight.js');
|
|
|
|
const striptags = require('striptags');
|
|
const unzalgo = require('unzalgo');
|
|
const stringSimilarity = require('string-similarity');
|
|
|
|
const { SiteService, SiteError } = require('../../lib/site-lib');
|
|
|
|
class ChatService extends SiteService {
|
|
|
|
constructor (dtp) {
|
|
super(dtp, module.exports);
|
|
|
|
const USER_SELECT = '_id username username_lc displayName picture';
|
|
this.populateChatMessage = [
|
|
{
|
|
path: 'channel',
|
|
},
|
|
{
|
|
path: 'author',
|
|
select: USER_SELECT,
|
|
},
|
|
{
|
|
path: 'stickers',
|
|
},
|
|
];
|
|
|
|
this.populateChatRoom = [
|
|
{
|
|
path: 'owner',
|
|
select: USER_SELECT,
|
|
},
|
|
{
|
|
path: 'members.member',
|
|
select: USER_SELECT,
|
|
},
|
|
];
|
|
|
|
this.populateChatRoomInvite = [
|
|
{
|
|
path: 'room',
|
|
populate: [
|
|
{
|
|
path: 'owner',
|
|
select: USER_SELECT,
|
|
},
|
|
{
|
|
path: 'members.member',
|
|
select: USER_SELECT,
|
|
},
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
async start ( ) {
|
|
this.markedRenderer = new marked.Renderer();
|
|
this.markedRenderer.link = (href, title, text) => { return text; };
|
|
this.markedRenderer.image = (href, title, text) => { return text; };
|
|
|
|
this.markedConfig = {
|
|
renderer: this.markedRenderer,
|
|
highlight: function(code, lang) {
|
|
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
|
|
return hljs.highlight(code, { language }).value;
|
|
},
|
|
langPrefix: 'hljs language-', // highlight.js css expects a top-level 'hljs' class.
|
|
pedantic: false,
|
|
gfm: true,
|
|
breaks: false,
|
|
sanitize: false,
|
|
smartLists: true,
|
|
smartypants: false,
|
|
xhtml: false,
|
|
};
|
|
|
|
/*
|
|
* The chat message rate limiter uses Redis to provide accurate atomic
|
|
* accounting regardless of which host is currently hosting a user's chat
|
|
* connection and session.
|
|
*/
|
|
|
|
const { RateLimiterRedis } = require('rate-limiter-flexible');
|
|
const rateLimiterRedisClient = new Redis({
|
|
host: process.env.REDIS_HOST,
|
|
port: process.env.REDIS_PORT,
|
|
password: process.env.REDIS_PASSWORD,
|
|
keyPrefix: process.env.REDIS_KEY_PREFIX || 'dtp',
|
|
lazyConnect: false,
|
|
enableOfflineQueue: false,
|
|
});
|
|
|
|
this.chatMessageLimiter = new RateLimiterRedis({
|
|
storeClient: rateLimiterRedisClient,
|
|
points: 20,
|
|
duration: 60,
|
|
blockDuration: 60 * 3,
|
|
execEvenly: false,
|
|
keyPrefix: 'rl:chatmsg',
|
|
});
|
|
|
|
this.reactionLimiter = new RateLimiterRedis({
|
|
storeClient: rateLimiterRedisClient,
|
|
points: 60,
|
|
duration: 60,
|
|
blockDuration: 60 * 3,
|
|
execEvenly: false,
|
|
keyPrefix: 'rl:react',
|
|
});
|
|
|
|
/*
|
|
* The Redis Emitter is a Socket.io-compatible message emitter that operates
|
|
* with greater efficiency than using Socket.io itself.
|
|
*/
|
|
|
|
this.emitter = ioEmitter(this.dtp.redis);
|
|
}
|
|
|
|
middleware (options) {
|
|
options = Object.assign({
|
|
maxOwnedRooms: 10,
|
|
maxJoinedRooms: 10,
|
|
});
|
|
return async (req, res, next) => {
|
|
try {
|
|
res.locals.ownedChatRooms = await this.getRoomsForOwner(req.user, {
|
|
skip: 0,
|
|
cpp: options.maxOwnedRooms,
|
|
});
|
|
res.locals.joinedChatRooms = await this.getRoomsForMember(req.user, {
|
|
skip: 0,
|
|
cpp: options.maxJoinedRooms,
|
|
});
|
|
return next();
|
|
} catch (error) {
|
|
this.log.error('failed to execute chat middleware', { error });
|
|
return next(error);
|
|
}
|
|
};
|
|
}
|
|
|
|
async createRoom (owner, roomDefinition) {
|
|
const NOW = new Date();
|
|
|
|
const room = new ChatRoom();
|
|
room.created = NOW;
|
|
room.lastActivity = NOW;
|
|
|
|
room.ownerType = owner.type;
|
|
room.owner = owner._id;
|
|
|
|
room.name = this.filterText(roomDefinition.name);
|
|
|
|
if (roomDefinition.description) {
|
|
room.description = this.filterText(roomDefinition.description);
|
|
}
|
|
|
|
if (roomDefinition.policy) {
|
|
room.policy = this.filterText(roomDefinition.policy);
|
|
}
|
|
|
|
room.visibility = roomDefinition.visibility;
|
|
room.membershipPolicy = roomDefinition.membershipPolicy;
|
|
|
|
room.members = [ ];
|
|
|
|
await room.save();
|
|
return room.toObject();
|
|
}
|
|
|
|
async updateRoom (room, roomDefinition) {
|
|
const NOW = new Date();
|
|
const updateOp = {
|
|
$set: {
|
|
lastActivity: NOW,
|
|
},
|
|
$unset: { },
|
|
};
|
|
|
|
updateOp.$set.name = this.filterText(roomDefinition.name);
|
|
|
|
if (roomDefinition.description && roomDefinition.description.length > 0) {
|
|
updateOp.$set.description = this.filterText(roomDefinition.description);
|
|
} else {
|
|
updateOp.$unset.description = 1;
|
|
}
|
|
|
|
await ChatRoom.updateOne({ _id: room._id }, updateOp);
|
|
}
|
|
|
|
async getRoomsForOwner (owner, pagination) {
|
|
pagination = Object.assign({
|
|
skip: 0,
|
|
cpp: 50
|
|
}, pagination);
|
|
const rooms = await ChatRoom
|
|
.find({ owner: owner._id })
|
|
.sort({ lastActivity: -1, created: -1 })
|
|
.skip(pagination.skip)
|
|
.limit(pagination.cpp)
|
|
.populate(this.populateChatRoom)
|
|
.lean();
|
|
return rooms;
|
|
}
|
|
|
|
async getRoomsForMember (member, pagination) {
|
|
pagination = Object.assign({
|
|
skip: 0,
|
|
cpp: 50
|
|
}, pagination);
|
|
const rooms = await ChatRoom
|
|
.find({ 'members.member': member._id })
|
|
.sort({ lastActivity: -1, created: -1 })
|
|
.skip(pagination.skip)
|
|
.limit(pagination.cpp)
|
|
.populate(this.populateChatRoom)
|
|
.lean();
|
|
return rooms;
|
|
}
|
|
|
|
async getPublicRooms (pagination) {
|
|
pagination = Object.assign({
|
|
skip: 0,
|
|
cpp: 50
|
|
}, pagination);
|
|
const rooms = await ChatRoom
|
|
.find({ 'flags.isPublic': true })
|
|
.sort({ lastActivity: -1, created: -1 })
|
|
.skip(pagination.skip)
|
|
.limit(pagination.cpp)
|
|
.populate(this.populateChatRoom)
|
|
.lean();
|
|
return rooms;
|
|
}
|
|
|
|
async getRoomById (roomId) {
|
|
const room = await ChatRoom
|
|
.findById(roomId)
|
|
.populate(this.populateChatRoom)
|
|
.lean();
|
|
return room;
|
|
}
|
|
|
|
async joinRoom (room, member) {
|
|
if (!room.flags.isOpen) {
|
|
throw new SiteError(403, 'The room is not open');
|
|
}
|
|
await ChatRoom.updateOne(
|
|
{ _id: room._id },
|
|
{
|
|
$addToSet: {
|
|
members: {
|
|
memberType: member.type,
|
|
member: member._id,
|
|
},
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
async leaveRoom (room, memberId) {
|
|
await ChatRoom.updateOne(
|
|
{ _id: room._id },
|
|
{
|
|
$pull: { members: { _id: memberId } },
|
|
},
|
|
);
|
|
}
|
|
|
|
async sendRoomInvite (room, member) {
|
|
const NOW = new Date();
|
|
|
|
/*
|
|
* See if there's already an outstanding invite, and return it.
|
|
*/
|
|
|
|
let invite = await ChatRoomInvite
|
|
.findOne({ room: room._id, member: member._id })
|
|
.populate(this.populateChatRoomInvite)
|
|
.lean();
|
|
if (invite) {
|
|
return invite;
|
|
}
|
|
|
|
/*
|
|
* Create new invite
|
|
*/
|
|
|
|
invite = new ChatRoomInvite();
|
|
invite.created = NOW;
|
|
invite.room = room._id;
|
|
invite.memberType = member.type;
|
|
invite.member = member._id;
|
|
invite.status = 'new';
|
|
await invite.save();
|
|
|
|
this.log.info('chat room invite created', {
|
|
roomId: room._id,
|
|
memberId: member._id,
|
|
inviteId: invite._id,
|
|
});
|
|
|
|
return invite.toObject();
|
|
}
|
|
|
|
async acceptRoomInvite (invite) {
|
|
this.log.info('accepting invite to chat room', {
|
|
roomId: invite.room._id,
|
|
memberId: invite.member._id,
|
|
});
|
|
await ChatRoom.updateOne(
|
|
{ _id: invite.room._id },
|
|
{
|
|
$addToSet: {
|
|
members: {
|
|
memberType: invite.memberType,
|
|
member: invite.member._id,
|
|
},
|
|
},
|
|
},
|
|
);
|
|
|
|
this.log.info('updating chat invite', { inviteId: invite._id, status: 'accepted' });
|
|
await ChatRoomInvite.updateOne(
|
|
{ _id: invite._id },
|
|
{
|
|
$set: { stats: 'accepted' },
|
|
},
|
|
);
|
|
}
|
|
|
|
async rejectRoomInvite (invite) {
|
|
this.log.info('rejecting chat room invite', {
|
|
inviteId: invite._id,
|
|
roomId: invite.room._id,
|
|
memberId: invite.member._id,
|
|
});
|
|
await ChatRoomInvite.updateOne(
|
|
{ _id: invite._id },
|
|
{ $set: { status: 'rejected' } },
|
|
);
|
|
}
|
|
|
|
async deleteRoomInvite (invite) {
|
|
this.log.info('deleting chat room invite', { inviteId: invite._id });
|
|
await ChatRoomInvite.deleteOne({ _id: invite._id });
|
|
}
|
|
|
|
async createMessage (author, messageDefinition) {
|
|
const { sticker: stickerService, user: userService } = this.dtp.services;
|
|
|
|
author = await userService.getUserAccount(author._id);
|
|
if (!author || !author.permissions || !author.permissions.canChat) {
|
|
throw new SiteError(403, `You are not permitted to chat at all on ${this.dtp.config.site.name}`);
|
|
}
|
|
|
|
const NOW = new Date();
|
|
|
|
/*
|
|
* Record the chat message to the database
|
|
*/
|
|
|
|
let message = new ChatMessage();
|
|
message.created = NOW;
|
|
|
|
message.channelType = messageDefinition.channelType;
|
|
message.channel = mongoose.Types.ObjectId(messageDefinition.channel._id || messageDefinition.channel);
|
|
|
|
message.authorType = author.type;
|
|
message.author = author._id;
|
|
|
|
message.content = this.filterText(messageDefinition.content);
|
|
message.analysis = await this.analyzeContent(author, message.content);
|
|
|
|
const stickerSlugs = this.findStickers(message.content);
|
|
stickerSlugs.forEach((sticker) => {
|
|
const re = new RegExp(`:${sticker}:`, 'gi');
|
|
message.content = message.content.replace(re, '').trim();
|
|
});
|
|
|
|
const stickers = await stickerService.resolveStickerSlugs(stickerSlugs);
|
|
message.stickers = stickers.map((sticker) => sticker._id);
|
|
|
|
await message.save();
|
|
message = message.toObject();
|
|
|
|
/*
|
|
* Update room's latest message pointer
|
|
*/
|
|
|
|
await ChatRoom.updateOne(
|
|
{ _id: message.channel },
|
|
{ $set: { latestMessage: message._id } },
|
|
);
|
|
|
|
/*
|
|
* Prepare a message payload that can be transmitted over sockets to clients
|
|
* and rendered for display.
|
|
*/
|
|
|
|
const renderedContent = this.renderMessageContent(message.content);
|
|
const payload = {
|
|
_id: message._id,
|
|
user: {
|
|
_id: author._id,
|
|
displayName: author.displayName,
|
|
username: author.username,
|
|
picture: {
|
|
large: {
|
|
_id: author.picture.large._id,
|
|
},
|
|
small: {
|
|
_id: author.picture.small._id,
|
|
},
|
|
},
|
|
},
|
|
content: renderedContent,
|
|
stickers,
|
|
};
|
|
|
|
/*
|
|
* Return both things
|
|
*/
|
|
|
|
return { message, payload };
|
|
}
|
|
|
|
renderMessageContent (content) {
|
|
return marked.parse(content, this.markedConfig);
|
|
}
|
|
|
|
findStickers (content) {
|
|
const tokens = content.split(' ');
|
|
const stickers = [ ];
|
|
tokens.forEach((token) => {
|
|
if ((token[0] !== ':') || (token[token.length -1 ] !== ':')) {
|
|
return;
|
|
}
|
|
|
|
token = token.slice(1, token.length - 1 ).toLowerCase();
|
|
if (token.includes('/') || token.includes(':') || token.includes(' ')) {
|
|
return; // trimmed token includes invalid characters
|
|
}
|
|
|
|
this.log.debug('found sticker request', { token });
|
|
if (!stickers.includes(token)) {
|
|
stickers.push(striptags(token));
|
|
}
|
|
});
|
|
|
|
return stickers.slice(0, 4);
|
|
}
|
|
|
|
async removeMessage (message) {
|
|
await ChatMessage.deleteOne({ _id: message._id });
|
|
this.emitter(`site:${this.dtp.config.site.domainKey}:chat`, {
|
|
command: 'removeMessage',
|
|
params: { messageId: message._id },
|
|
});
|
|
}
|
|
|
|
async getChannelHistory (channel, pagination) {
|
|
const messages = await ChatMessage
|
|
.find({ channel: channel._id })
|
|
.sort({ created: -1 })
|
|
.skip(pagination.skip)
|
|
.limit(pagination.cpp)
|
|
.populate(this.populateChatMessage)
|
|
.lean();
|
|
return messages.reverse();
|
|
}
|
|
|
|
async getUserHistory (user, pagination) {
|
|
const messages = await ChatMessage
|
|
.find({ author: user._id })
|
|
.sort({ created: -1 })
|
|
.skip(pagination.skip)
|
|
.limit(pagination.cpp)
|
|
.populate(this.populateChatMessage)
|
|
.lean();
|
|
return messages.reverse();
|
|
}
|
|
|
|
/**
|
|
* Filters an input string to remove "zalgo" text and to strip all HTML tags.
|
|
* This prevents cross-site scripting and the malicious destruction of text
|
|
* layouts.
|
|
* @param {String} content The text content to be filtered.
|
|
* @returns the filtered text
|
|
*/
|
|
filterText (content) {
|
|
return striptags(unzalgo.clean(content.trim()));
|
|
}
|
|
|
|
/**
|
|
* Analyze an input chat message against a user's history for similarity and
|
|
* other abusive content. Returns a response object with various scores
|
|
* allowing the caller to implement various policies and make various
|
|
* decisions.
|
|
* @param {User} author The author of the chat message
|
|
* @param {*} content The text of the chat message as would be distributed.
|
|
* @returns response object with various scores indicating the results of
|
|
* analyses performed.
|
|
*/
|
|
async analyzeContent (author, content) {
|
|
const response = { similarity: 0.0 };
|
|
|
|
/*
|
|
* Compare versus their recent chat messages, score for similarity, and
|
|
* block based on repetition. Spammers are redundant. This stops them.
|
|
*/
|
|
|
|
const history = await ChatMessage
|
|
.find({ author: author._id })
|
|
.sort({ created: -1 })
|
|
.select('content')
|
|
.limit(10)
|
|
.lean();
|
|
|
|
history.forEach((message) => {
|
|
const similarity = stringSimilarity.compareTwoStrings(content, message.content);
|
|
if (similarity > 0.9) { // 90% or greater match with history entry
|
|
response.similarity += similarity;
|
|
}
|
|
});
|
|
|
|
return response;
|
|
}
|
|
|
|
async sendMessage (channel, messageName, payload) {
|
|
this.emitter.to(channel).emit(messageName, payload);
|
|
}
|
|
|
|
async sendSystemMessage (socket, content, options) {
|
|
const NOW = new Date();
|
|
|
|
options = Object.assign({
|
|
type: 'info',
|
|
}, options || { });
|
|
|
|
const payload = {
|
|
created: NOW,
|
|
type: options.type,
|
|
content,
|
|
};
|
|
|
|
if (options.channelId) {
|
|
socket.to(options.channelId).emit('system-message', payload);
|
|
return;
|
|
}
|
|
|
|
socket.emit('system-message', payload);
|
|
}
|
|
|
|
async createEmojiReaction (user, reactionDefinition) {
|
|
const NOW = new Date();
|
|
const reaction = new EmojiReaction();
|
|
|
|
reaction.created = NOW;
|
|
reaction.subjectType = reactionDefinition.subjectType;
|
|
reaction.subject = mongoose.Types.ObjectId(reactionDefinition.subject);
|
|
reaction.userType = user.type;
|
|
reaction.user = user._id;
|
|
reaction.reaction = reactionDefinition.reaction;
|
|
|
|
if (reactionDefinition.timestamp) {
|
|
reaction.timestamp = reactionDefinition.timestamp;
|
|
}
|
|
|
|
await reaction.save();
|
|
|
|
return reaction.toObject();
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
slug: 'chat',
|
|
name: 'chat',
|
|
create: (dtp) => { return new ChatService(dtp); },
|
|
};
|