chat: New `chatNewMessage` server-side hook
parent
23a98e5946
commit
26675c5019
|
@ -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
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue