chat: New `chatNewMessage` server-side hook

pull/5256/head
Richard Hansen 2021-10-30 16:58:28 -04:00
parent 23a98e5946
commit 26675c5019
4 changed files with 180 additions and 0 deletions

View File

@ -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

View File

@ -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.

View File

@ -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);

View File

@ -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} <added changes>`;
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}),
]);
});
});
});