Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use an API route for sending chat messages. #512

Draft
wants to merge 14 commits into
base: default
Choose a base branch
from
2 changes: 2 additions & 0 deletions locale/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ uwave:
noSelfFavorite: "You can't favorite your own plays."
noSelfMute: "You can't mute yourself."
noSelfUnmute: "You can't unmute yourself."
chatMuted: 'You have been muted in the chat.'
tooManyTags: 'Too much tag data: only up to {{maxLength}} bytes are allowed.'
sourceNotFound: 'Source "{{name}}" not found.'
sourceNoImport: 'Source "{{name}}" does not support importing.'
tooManyNameChanges: 'You can only change your username five times per hour. Try again in {{retryAfter}}.'
Expand Down
8 changes: 7 additions & 1 deletion src/SocketServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const WebSocket = require('ws');
const Ajv = require('ajv').default;
const ms = require('ms');
const debug = require('debug')('uwave:api:sockets');
const { ChatMutedError } = require('./errors');
const { socketVote } = require('./controllers/booth');
const { disconnectUser } = require('./controllers/users');
const AuthRegistry = require('./AuthRegistry');
Expand Down Expand Up @@ -213,7 +214,12 @@ class SocketServer {
this.#clientActions = {
sendChat: (user, message) => {
debug('sendChat', user, message);
this.#uw.chat.send(user, message);
this.#uw.chat.send(user, { message }).catch((error) => {
if (error instanceof ChatMutedError) {
return;
}
debug('sendChat', error);
});
},
vote: (user, direction) => {
socketVote(this.#uw, user.id, direction);
Expand Down
19 changes: 19 additions & 0 deletions src/controllers/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,24 @@ async function unmuteUser(req) {
return toItemResponse({});
}

/**
* @typedef {object} SendMessageBody
* @prop {string} message
* @prop {Partial<import('../types').ChatTags>} [tags]
*/

/**
* @type {import('../types').AuthenticatedController<{}, {}, SendMessageBody>}
*/
async function sendMessage(req) {
const { user } = req;
const { message, tags } = req.body;
const { chat } = req.uwave;

const result = await chat.send(user, { message, tags });
return toItemResponse(result);
}

/**
* @type {import('../types').AuthenticatedController}
*/
Expand Down Expand Up @@ -110,6 +128,7 @@ async function deleteMessage(req) {

exports.muteUser = muteUser;
exports.unmuteUser = unmuteUser;
exports.sendMessage = sendMessage;
exports.deleteAll = deleteAll;
exports.deleteByUser = deleteByUser;
exports.deleteMessage = deleteMessage;
14 changes: 14 additions & 0 deletions src/errors/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,18 @@ const CannotSelfMuteError = createErrorClass('CannotSelfMuteError', {
base: Forbidden,
});

const ChatMutedError = createErrorClass('ChatMutedError', {
code: 'chat-muted',
string: 'errors.chatMuted',
base: Forbidden,
});

const TooManyTagsError = createErrorClass('TooManyTagsError', {
code: 'too-many-tags',
string: 'errors.tooManyTags',
base: BadRequest,
});

const SourceNotFoundError = createErrorClass('SourceNotFoundError', {
code: 'source-not-found',
string: 'errors.sourceNotFound',
Expand Down Expand Up @@ -270,6 +282,8 @@ exports.MediaNotFoundError = MediaNotFoundError;
exports.ItemNotInPlaylistError = ItemNotInPlaylistError;
exports.CannotSelfFavoriteError = CannotSelfFavoriteError;
exports.CannotSelfMuteError = CannotSelfMuteError;
exports.ChatMutedError = ChatMutedError;
exports.TooManyTagsError = TooManyTagsError;
exports.SourceNotFoundError = SourceNotFoundError;
exports.SourceNoImportError = SourceNoImportError;
exports.EmptyPlaylistError = EmptyPlaylistError;
Expand Down
44 changes: 38 additions & 6 deletions src/plugins/chat.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
'use strict';

const { randomUUID } = require('crypto');
const { ChatMutedError, TooManyTagsError } = require('../errors');
const routes = require('../routes/chat');

/**
* @typedef {import('../models').User} User
*
* @typedef {object} ChatOptions
* @prop {number} maxLength
*
* @typedef {object} ChatMessage
* @prop {string} message
* @prop {Partial<import('../types').ChatTags>} [tags]
*/

/** @type {ChatOptions} */
Expand Down Expand Up @@ -87,19 +92,46 @@ class Chat {

/**
* @param {User} user
* @param {string} message
* @param {ChatMessage} data
*/
async send(user, message) {
async send(user, { message, tags }) {
const { acl } = this.#uw;

const maxLength = 2048;
if (tags && JSON.stringify(tags).length > maxLength) {
throw new TooManyTagsError({ maxLength });
}

if (await this.isMuted(user)) {
return;
throw new ChatMutedError();
}

const permissions = tags ? await acl.getAllPermissions(user) : [];
const globalTags = new Set(['id', 'replyTo']);
const filteredTags = tags
? Object.fromEntries(
Object.entries(tags)
.filter(([name]) => globalTags.has(name) || permissions.includes(name)),
)
: {};

const id = randomUUID();
const timestamp = Date.now();
const truncatedMessage = this.truncate(message);
this.#uw.publish('chat:message', {
id: randomUUID(),
id,
userID: user.id,
message: this.truncate(message),
timestamp: Date.now(),
message: truncatedMessage,
timestamp,
tags: filteredTags,
});

return {
_id: id,
message: truncatedMessage,
timestamp,
tags: filteredTags,
};
}

/**
Expand Down
3 changes: 3 additions & 0 deletions src/redisMessages.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { ChatTags } from './types'; // eslint-disable-line node/no-missing-import

export type ServerActionParameters = {
'advance:complete': {
historyID: string,
Expand Down Expand Up @@ -35,6 +37,7 @@ export type ServerActionParameters = {
userID: string,
message: string,
timestamp: number,
tags?: Partial<ChatTags>,
},
'chat:delete': {
filter: { id: string } | { userID: string } | Record<string, never>,
Expand Down
7 changes: 7 additions & 0 deletions src/routes/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ const controller = require('../controllers/chat');

function chatRoutes() {
return Router()
// POST /chat/ - Send a chat message.
.post(
'/',
protect('chat.send'),
schema(validations.sendChatMessage),
route(controller.sendMessage),
)
// DELETE /chat/ - Clear the chat (delete all messages).
.delete(
'/',
Expand Down
9 changes: 8 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import type { Model } from 'mongoose';
import type { ParsedQs } from 'qs';
import type { JsonObject } from 'type-fest';
import type { JsonObject, JsonValue } from 'type-fest';
import type { Request as ExpressRequest, Response as ExpressResponse } from 'express';
import type UwaveServer from './Uwave';
import type { HttpApi } from './HttpApi';
Expand Down Expand Up @@ -85,3 +85,10 @@ type OffsetPaginationQuery = {
page?: { offset?: string, limit?: string },
};
export type PaginationQuery = LegacyPaginationQuery | OffsetPaginationQuery;

/** Well known chat message tag types. */
export type ChatTags = {
id: string,
replyTo: string,
[key: `${string}:${string}`]: JsonValue,
}
19 changes: 19 additions & 0 deletions src/validations.js
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,25 @@ exports.getRoomHistory = /** @type {const} */ ({

// Validations for chat routes:

exports.sendChatMessage = /** @type {const} */ ({
body: {
type: 'object',
properties: {
message: { type: 'string', minLength: 1 },
tags: {
type: 'object',
properties: {
id: { type: 'string', maxLength: 40 },
replyTo: { type: 'string', minLength: 1, maxLength: 40 },
},
// In the future we should support custom tags, maybe namespaced with a : in the name.
additionalProperties: false,
},
},
required: ['message'],
},
});

exports.deleteChatByUser = /** @type {const} */ ({
params: {
type: 'object',
Expand Down
Loading