From 26675c5019d80001e477e21926e54488c2b85d2d Mon Sep 17 00:00:00 2001 From: Richard Hansen Date: Sat, 30 Oct 2021 16:58:28 -0400 Subject: [PATCH] chat: New `chatNewMessage` server-side hook --- CHANGELOG.md | 2 + doc/api/hooks_server-side.md | 17 +++ src/node/handler/PadMessageHandler.js | 1 + src/tests/backend/specs/chat.js | 160 ++++++++++++++++++++++++++ 4 files changed, 180 insertions(+) create mode 100644 src/tests/backend/specs/chat.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a2f74e1b8..b76219548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,8 @@ * New `chatSendMessage` client-side hook that enables plugins to process the text before sending it to the server or augment the message object with custom metadata. + * New `chatNewMessage` server-side hook to process new chat messages before + they are saved to the database and relayed to users. # 1.8.14 diff --git a/doc/api/hooks_server-side.md b/doc/api/hooks_server-side.md index 4836c4b73..0cf033220 100644 --- a/doc/api/hooks_server-side.md +++ b/doc/api/hooks_server-side.md @@ -853,3 +853,20 @@ exports.userLeave = async (hookName, {author, padId}) => { console.log(`${author} left pad ${padId}`); }; ``` + +## `chatNewMessage` + +Called from: `src/node/handler/PadMessageHandler.js` + +Called when a user (or plugin) generates a new chat message, just before it is +saved to the pad and relayed to all connected users. + +Context properties: + +* `message`: The chat message object. Plugins can mutate this object to change + the message text or add custom metadata to control how the message will be + rendered by the `chatNewMessage` client-side hook. The message's `authorId` + property can be trusted (the server overwrites any client-provided author ID + value with the user's actual author ID before this hook runs). +* `padId`: The pad's real (not read-only) identifier. +* `pad`: The pad's Pad object. diff --git a/src/node/handler/PadMessageHandler.js b/src/node/handler/PadMessageHandler.js index 64027f6e4..71cafd0b0 100644 --- a/src/node/handler/PadMessageHandler.js +++ b/src/node/handler/PadMessageHandler.js @@ -364,6 +364,7 @@ exports.sendChatMessageToPadClients = async (mt, puId, text = null, padId = null const message = mt instanceof ChatMessage ? mt : new ChatMessage(text, puId, mt); padId = mt instanceof ChatMessage ? puId : padId; const pad = await padManager.getPad(padId); + await hooks.aCallAll('chatNewMessage', {message, pad, padId}); // pad.appendChatMessage() ignores the displayName property so we don't need to wait for // authorManager.getAuthorName() to resolve before saving the message to the database. const promise = pad.appendChatMessage(message); diff --git a/src/tests/backend/specs/chat.js b/src/tests/backend/specs/chat.js new file mode 100644 index 000000000..aefa64183 --- /dev/null +++ b/src/tests/backend/specs/chat.js @@ -0,0 +1,160 @@ +'use strict'; + +const ChatMessage = require('../../../static/js/ChatMessage'); +const {Pad} = require('../../../node/db/Pad'); +const assert = require('assert').strict; +const common = require('../common'); +const padManager = require('../../../node/db/PadManager'); +const pluginDefs = require('../../../static/js/pluginfw/plugin_defs'); + +const logger = common.logger; + +const checkHook = async (hookName, checkFn) => { + if (pluginDefs.hooks[hookName] == null) pluginDefs.hooks[hookName] = []; + await new Promise((resolve, reject) => { + pluginDefs.hooks[hookName].push({ + hook_fn: async (hookName, context) => { + if (checkFn == null) return; + logger.debug(`hook ${hookName} invoked`); + try { + // Make sure checkFn is called only once. + const _checkFn = checkFn; + checkFn = null; + await _checkFn(context); + } catch (err) { + reject(err); + return; + } + resolve(); + }, + }); + }); +}; + +const sendMessage = (socket, data) => { + socket.send({ + type: 'COLLABROOM', + component: 'pad', + data, + }); +}; + +const sendChat = (socket, message) => sendMessage(socket, {type: 'CHAT_MESSAGE', message}); + +describe(__filename, function () { + const padId = 'testChatPad'; + const hooksBackup = {}; + + before(async function () { + for (const [name, defs] of Object.entries(pluginDefs.hooks)) { + if (defs == null) continue; + hooksBackup[name] = defs; + } + }); + + beforeEach(async function () { + for (const [name, defs] of Object.entries(hooksBackup)) pluginDefs.hooks[name] = [...defs]; + for (const name of Object.keys(pluginDefs.hooks)) { + if (hooksBackup[name] == null) delete pluginDefs.hooks[name]; + } + if (await padManager.doesPadExist(padId)) { + const pad = await padManager.getPad(padId); + await pad.remove(); + } + }); + + after(async function () { + Object.assign(pluginDefs.hooks, hooksBackup); + for (const name of Object.keys(pluginDefs.hooks)) { + if (hooksBackup[name] == null) delete pluginDefs.hooks[name]; + } + }); + + describe('chatNewMessage hook', function () { + let authorId; + let socket; + + beforeEach(async function () { + socket = await common.connect(); + const {data: clientVars} = await common.handshake(socket, padId); + authorId = clientVars.userId; + }); + + afterEach(async function () { + socket.close(); + }); + + it('message', async function () { + const start = Date.now(); + await Promise.all([ + checkHook('chatNewMessage', ({message}) => { + assert(message != null); + assert(message instanceof ChatMessage); + assert.equal(message.authorId, authorId); + assert.equal(message.text, this.test.title); + assert(message.time >= start); + assert(message.time <= Date.now()); + }), + sendChat(socket, {text: this.test.title}), + ]); + }); + + it('pad', async function () { + await Promise.all([ + checkHook('chatNewMessage', ({pad}) => { + assert(pad != null); + assert(pad instanceof Pad); + assert.equal(pad.id, padId); + }), + sendChat(socket, {text: this.test.title}), + ]); + }); + + it('padId', async function () { + await Promise.all([ + checkHook('chatNewMessage', (context) => { + assert.equal(context.padId, padId); + }), + sendChat(socket, {text: this.test.title}), + ]); + }); + + it('mutations propagate', async function () { + const listen = async (type) => await new Promise((resolve) => { + const handler = (msg) => { + if (msg.type !== 'COLLABROOM') return; + if (msg.data == null || msg.data.type !== type) return; + resolve(msg.data); + socket.off('message', handler); + }; + socket.on('message', handler); + }); + + const modifiedText = `${this.test.title} `; + const customMetadata = {foo: this.test.title}; + await Promise.all([ + checkHook('chatNewMessage', ({message}) => { + message.text = modifiedText; + message.customMetadata = customMetadata; + }), + (async () => { + const {message} = await listen('CHAT_MESSAGE'); + assert(message != null); + assert.equal(message.text, modifiedText); + assert.deepEqual(message.customMetadata, customMetadata); + })(), + sendChat(socket, {text: this.test.title}), + ]); + // Simulate fetch of historical chat messages when a pad is first loaded. + await Promise.all([ + (async () => { + const {messages: [message]} = await listen('CHAT_MESSAGES'); + assert(message != null); + assert.equal(message.text, modifiedText); + assert.deepEqual(message.customMetadata, customMetadata); + })(), + sendMessage(socket, {type: 'GET_CHAT_MESSAGES', start: 0, end: 0}), + ]); + }); + }); +});