feat(messages): Allow Custom Fields in Messages (#32224)

pull/32238/head
Rodrigo Nascimento 2 years ago committed by GitHub
parent d134a4d714
commit c47a8e3514
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 7
      .changeset/four-eyes-sniff.md
  2. 12
      apps/meteor/app/api/server/v1/chat.ts
  3. 2
      apps/meteor/app/lib/server/functions/processWebhookMessage.ts
  4. 9
      apps/meteor/app/lib/server/functions/sendMessage.ts
  5. 9
      apps/meteor/app/lib/server/functions/updateMessage.ts
  6. 44
      apps/meteor/app/lib/server/lib/validateCustomMessageFields.ts
  7. 2
      apps/meteor/app/lib/server/methods/updateMessage.ts
  8. 31
      apps/meteor/server/settings/message.ts
  9. 308
      apps/meteor/tests/end-to-end/api/05-chat.js
  10. 86
      apps/meteor/tests/unit/app/lib/server/lib/validateCustomMessageFields.tests.ts
  11. 4
      packages/core-typings/src/IMessage/IMessage.ts
  12. 3
      packages/i18n/src/locales/en.i18n.json
  13. 37
      packages/rest-typings/src/v1/chat.ts

@ -0,0 +1,7 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/core-typings": minor
"@rocket.chat/i18n": minor
---
Allow Custom Fields in Messages. API-only feature. It can be enabled and configured in Workspace Settings.

@ -312,6 +312,7 @@ API.v1.addRoute(
roomId: String,
msgId: String,
text: String, // Using text to be consistant with chat.postMessage
customFields: Match.Maybe(Object),
previewUrls: Match.Maybe([String]),
}),
);
@ -328,7 +329,16 @@ API.v1.addRoute(
}
// Permission checks are already done in the updateMessage method, so no need to duplicate them
await executeUpdateMessage(this.userId, { _id: msg._id, msg: this.bodyParams.text, rid: msg.rid }, this.bodyParams.previewUrls);
await executeUpdateMessage(
this.userId,
{
_id: msg._id,
msg: this.bodyParams.text,
rid: msg.rid,
customFields: this.bodyParams.customFields as Record<string, any> | undefined,
},
this.bodyParams.previewUrls,
);
const updatedMessage = await Messages.findOneById(msg._id);
const [message] = await normalizeMessagesForUser(updatedMessage ? [updatedMessage] : [], this.userId);

@ -25,6 +25,7 @@ type Payload = {
bot?: IMessage['bot'];
groupable?: IMessage['groupable'];
tmid?: IMessage['tmid'];
customFields?: IMessage['customFields'];
};
type DefaultValues = {
@ -109,6 +110,7 @@ export const processWebhookMessage = async function (
bot: messageObj.bot,
groupable: messageObj.groupable !== undefined ? messageObj.groupable : false,
tmid: messageObj.tmid,
customFields: messageObj.customFields,
};
if (!_.isEmpty(messageObj.icon_url) || !_.isEmpty(messageObj.avatar)) {

@ -11,6 +11,7 @@ import { broadcastMessageFromData } from '../../../../server/modules/watchers/li
import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission';
import { FileUpload } from '../../../file-upload/server';
import { settings } from '../../../settings/server';
import { validateCustomMessageFields } from '../lib/validateCustomMessageFields';
import { parseUrlsInMessage } from './parseUrlsInMessage';
// TODO: most of the types here are wrong, but I don't want to change them now
@ -171,6 +172,14 @@ export const validateMessage = async (message: any, room: any, user: any) => {
if (Array.isArray(message.attachments) && message.attachments.length) {
validateBodyAttachments(message.attachments);
}
if (message.customFields) {
validateCustomMessageFields({
customFields: message.customFields,
messageCustomFieldsEnabled: settings.get<boolean>('Message_CustomFields_Enabled'),
messageCustomFields: settings.get<string>('Message_CustomFields'),
});
}
};
export function prepareMessageObject(

@ -7,6 +7,7 @@ import { Meteor } from 'meteor/meteor';
import { callbacks } from '../../../../lib/callbacks';
import { broadcastMessageFromData } from '../../../../server/modules/watchers/lib/messages';
import { settings } from '../../../settings/server';
import { validateCustomMessageFields } from '../lib/validateCustomMessageFields';
import { parseUrlsInMessage } from './parseUrlsInMessage';
export const updateMessage = async function (
@ -59,6 +60,14 @@ export const updateMessage = async function (
messageData = await Message.beforeSave({ message: messageData, room, user });
if (messageData.customFields) {
validateCustomMessageFields({
customFields: messageData.customFields,
messageCustomFieldsEnabled: settings.get<boolean>('Message_CustomFields_Enabled'),
messageCustomFields: settings.get<string>('Message_CustomFields'),
});
}
const { _id, ...editedMessage } = messageData;
if (!editedMessage.msg) {

@ -0,0 +1,44 @@
import Ajv from 'ajv';
import mem from 'mem';
const ajv = new Ajv();
const customFieldsValidate = mem(
(customFieldsSetting: string) => {
const schema = JSON.parse(customFieldsSetting);
if (schema.type && schema.type !== 'object') {
throw new Error('Invalid custom fields config');
}
return ajv.compile({
...schema,
type: 'object',
additionalProperties: false,
});
},
{ maxAge: 1000 * 60 },
);
export const validateCustomMessageFields = ({
customFields,
messageCustomFieldsEnabled,
messageCustomFields,
}: {
customFields: Record<string, any>;
messageCustomFieldsEnabled: boolean;
messageCustomFields: string;
}) => {
// get the json schema for the custom fields of the message and validate it using ajv
// if the validation fails, throw an error
// if there are no custom fields, the message object remains unchanged
if (messageCustomFieldsEnabled !== true) {
throw new Error('Custom fields not enabled');
}
const validate = customFieldsValidate(messageCustomFields);
if (!validate(customFields)) {
throw new Error('Invalid custom fields');
}
};

@ -10,7 +10,7 @@ import { hasPermissionAsync } from '../../../authorization/server/functions/hasP
import { settings } from '../../../settings/server';
import { updateMessage } from '../functions/updateMessage';
const allowedEditedFields = ['tshow', 'alias', 'attachments', 'avatar', 'emoji', 'msg'];
const allowedEditedFields = ['tshow', 'alias', 'attachments', 'avatar', 'emoji', 'msg', 'customFields'];
export async function executeUpdateMessage(uid: IUser['_id'], message: AtLeast<IMessage, '_id' | 'rid' | 'msg'>, previewUrls?: string[]) {
const originalMessage = await Messages.findOneById(message._id);

@ -385,4 +385,35 @@ export const createMessageSettings = () =>
type: 'boolean',
public: true,
});
await this.add('Message_CustomFields_Enabled', false, {
type: 'boolean',
});
await this.add(
'Message_CustomFields',
`
{
"properties": {
"priority": {
"type": "string",
"nullable": false,
"enum": ["low", "medium", "high"]
}
},
"required": ["priority"]
}
`,
{
type: 'code',
code: 'application/json',
invalidValue: '',
multiline: true,
enableQuery: [
{
_id: 'Message_CustomFields_Enabled',
value: true,
},
],
},
);
});

@ -1101,6 +1101,314 @@ describe('[Chat]', function () {
})
.end(done);
});
describe('customFields', () => {
describe('when disabled', () => {
it('should not allow sending custom fields', async () => {
await request
.post(api('chat.sendMessage'))
.set(credentials)
.send({
message: {
rid: testChannel._id,
msg: 'Sample message',
customFields: {
field1: 'value1',
},
},
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error', 'Custom fields not enabled');
});
await request
.post(api('chat.postMessage'))
.set(credentials)
.send({
roomId: testChannel._id,
msg: 'Sample message',
customFields: {
field1: 'value1',
},
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error', 'Custom fields not enabled');
});
});
it('should not allow update custom fields', async () => {
const res = await sendSimpleMessage({ roomId: testChannel._id });
const msgId = res.body.message._id;
await request
.post(api('chat.update'))
.set(credentials)
.send({
roomId: testChannel._id,
msgId,
text: 'Sample message Updated',
customFields: {
field1: 'value1',
},
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error', 'Custom fields not enabled');
});
});
});
describe('when enabled', () => {
before(async () => {
await updateSetting('Message_CustomFields_Enabled', true);
await updateSetting(
'Message_CustomFields',
JSON.stringify({
properties: {
priority: {
type: 'string',
nullable: false,
enum: ['low', 'medium', 'high'],
},
},
required: ['priority'],
}),
);
});
after(async () => {
await updateSetting('Message_CustomFields_Enabled', false);
});
it('should allow not sending custom fields', async () => {
await request
.post(api('chat.sendMessage'))
.set(credentials)
.send({
message: {
rid: testChannel._id,
msg: 'Sample message',
},
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
await request
.post(api('chat.postMessage'))
.set(credentials)
.send({
roomId: testChannel._id,
msg: 'Sample message',
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
});
it('should not allow sending empty custom fields', async () => {
await request
.post(api('chat.sendMessage'))
.set(credentials)
.send({
message: {
rid: testChannel._id,
msg: 'Sample message',
customFields: {},
},
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
});
await request
.post(api('chat.postMessage'))
.set(credentials)
.send({
roomId: testChannel._id,
msg: 'Sample message',
customFields: {},
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
});
});
it('should not allow sending wrong custom fields', async () => {
await request
.post(api('chat.sendMessage'))
.set(credentials)
.send({
message: {
rid: testChannel._id,
msg: 'Sample message',
customFields: {
field1: 'value1',
},
},
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
});
await request
.post(api('chat.postMessage'))
.set(credentials)
.send({
roomId: testChannel._id,
msg: 'Sample message',
customFields: {
field1: 'value1',
},
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
});
});
it('should allow sending correct custom fields', async () => {
await request
.post(api('chat.sendMessage'))
.set(credentials)
.send({
message: {
rid: testChannel._id,
msg: 'Sample message',
customFields: {
priority: 'low',
},
},
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body.message).to.have.property('customFields').to.deep.equal({ priority: 'low' });
});
await request
.post(api('chat.postMessage'))
.set(credentials)
.send({
roomId: testChannel._id,
msg: 'Sample message',
customFields: {
priority: 'low',
},
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body.message).to.have.property('customFields').to.deep.equal({ priority: 'low' });
});
});
it('should allow not sending custom fields on update', async () => {
const res = await sendSimpleMessage({ roomId: testChannel._id });
const msgId = res.body.message._id;
await request
.post(api('chat.update'))
.set(credentials)
.send({
roomId: testChannel._id,
msgId,
text: 'Sample message Updated',
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
});
it('should not allow update empty custom fields', async () => {
const res = await sendSimpleMessage({ roomId: testChannel._id });
const msgId = res.body.message._id;
await request
.post(api('chat.update'))
.set(credentials)
.send({
roomId: testChannel._id,
msgId,
text: 'Sample message Updated',
customFields: {},
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
});
});
it('should not allow update wrong custom fields', async () => {
const res = await sendSimpleMessage({ roomId: testChannel._id });
const msgId = res.body.message._id;
await request
.post(api('chat.update'))
.set(credentials)
.send({
roomId: testChannel._id,
msgId,
text: 'Sample message Updated',
customFields: {
field1: 'value1',
},
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
});
});
it('should allow update correct custom fields', async () => {
const res = await sendSimpleMessage({ roomId: testChannel._id });
const msgId = res.body.message._id;
await request
.post(api('chat.update'))
.set(credentials)
.send({
roomId: testChannel._id,
msgId,
text: 'Sample message Updated',
customFields: {
priority: 'low',
},
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body.message).to.have.property('customFields').to.deep.equal({ priority: 'low' });
});
});
});
});
});
describe('/chat.update', () => {

@ -0,0 +1,86 @@
import { expect } from 'chai';
import { validateCustomMessageFields } from '../../../../../../app/lib/server/lib/validateCustomMessageFields';
describe('validateCustomMessageFields', () => {
describe('When not enabled', () => {
it('should not allow to pass custom fields', () => {
const customFields = {
test: 'test',
};
expect(() => validateCustomMessageFields({ customFields, messageCustomFieldsEnabled: false, messageCustomFields: '' })).to.throw(
'Custom fields not enabled',
);
});
});
describe('When enabled', () => {
it('should not allow to pass invalid custom fields config', () => {
const customFields = {
test: 'test',
};
expect(() => validateCustomMessageFields({ customFields, messageCustomFieldsEnabled: true, messageCustomFields: '' })).to.throw(
'Unexpected end of JSON input',
);
});
it('should not allow to pass a property not present in config', () => {
const customFields = {
test: 'test',
};
const messageCustomFields = JSON.stringify({
properties: {
priority: {
type: 'string',
},
},
additionalProperties: true,
});
expect(() => validateCustomMessageFields({ customFields, messageCustomFieldsEnabled: true, messageCustomFields })).to.throw(
'Invalid custom fields',
);
});
it('should not allow to pass an invalid custom field value', () => {
const customFields = {
test: 123,
};
const messageCustomFields = JSON.stringify({
properties: {
priority: {
type: 'string',
},
},
additionalProperties: true,
});
expect(() => validateCustomMessageFields({ customFields, messageCustomFieldsEnabled: true, messageCustomFields })).to.throw(
'Invalid custom fields',
);
});
it('should not allow to pass anything different from an object', () => {
const customFields = [1, 2];
const messageCustomFields = JSON.stringify({
type: 'array',
items: [{ type: 'integer' }, { type: 'integer' }],
});
expect(() => validateCustomMessageFields({ customFields, messageCustomFieldsEnabled: true, messageCustomFields })).to.throw(
'Invalid custom fields config',
);
});
it('should allow to pass a valid custom fields config', () => {
const customFields = {
test: 'test',
};
const messageCustomFields = JSON.stringify({
properties: {
test: {
type: 'string',
},
},
});
expect(() => validateCustomMessageFields({ customFields, messageCustomFieldsEnabled: true, messageCustomFields })).to.not.throw();
});
});
});

@ -127,6 +127,8 @@ export type MessageMention = {
fname?: string; // incase of channel mentions
};
export interface IMessageCustomFields {}
export interface IMessage extends IRocketChatRecord {
rid: RoomID;
msg: string;
@ -219,6 +221,8 @@ export interface IMessage extends IRocketChatRecord {
definedBy: Pick<IUser, '_id' | 'username'>;
priority?: Pick<ILivechatPriority, 'name' | 'i18n'>;
};
customFields?: IMessageCustomFields;
}
export type MessageSystem = {

@ -3504,6 +3504,9 @@
"Message_Code_highlight_Description": "Comma separated list of languages (all supported languages at [highlight.js](https://github.com/highlightjs/highlight.js/tree/11.6.0#supported-languages)) that will be used to highlight code blocks",
"Message_CustomDomain_AutoLink": "Custom Domain Whitelist for Auto Link",
"Message_CustomDomain_AutoLink_Description": "If you want to auto link internal links like `https://internaltool.intranet` or `internaltool.intranet`, you need to add the `intranet` domain to the field, multiple domains need to be separated by comma.",
"Message_CustomFields_Enabled": "Allow Custom Fields in Messages",
"Message_CustomFields": "Custom Fields Validation",
"Message_CustomFields_Description": "Custom Fields will be validated according to the rules defined in this setting.\nCheck [ajv.js.org](https://ajv.js.org/json-schema.html) for more information regarding validation options.\nProperties `type` and `additionalProperties` will be forced to `object` and `false` respectively.",
"message_counter_one": "{{count}} message",
"message_counter_other": "{{count}} messages",
"Message_DateFormat": "Date Format",

@ -63,6 +63,10 @@ const chatSendMessageSchema = {
},
nullable: true,
},
customFields: {
type: 'object',
nullable: true,
},
},
},
previewUrls: {
@ -439,6 +443,7 @@ type ChatUpdate = {
msgId: string;
text: string;
previewUrls?: string[];
customFields: IMessage['customFields'];
};
const ChatUpdateSchema = {
@ -460,6 +465,10 @@ const ChatUpdateSchema = {
},
nullable: true,
},
customFields: {
type: 'object',
nullable: true,
},
},
required: ['roomId', 'msgId', 'text'],
additionalProperties: false,
@ -697,8 +706,24 @@ const ChatGetDeletedMessagesSchema = {
export const isChatGetDeletedMessagesProps = ajv.compile<ChatGetDeletedMessages>(ChatGetDeletedMessagesSchema);
type ChatPostMessage =
| { roomId: string | string[]; text?: string; alias?: string; emoji?: string; avatar?: string; attachments?: MessageAttachment[] }
| { channel: string | string[]; text?: string; alias?: string; emoji?: string; avatar?: string; attachments?: MessageAttachment[] };
| {
roomId: string | string[];
text?: string;
alias?: string;
emoji?: string;
avatar?: string;
attachments?: MessageAttachment[];
customFields?: IMessage['customFields'];
}
| {
channel: string | string[];
text?: string;
alias?: string;
emoji?: string;
avatar?: string;
attachments?: MessageAttachment[];
customFields?: IMessage['customFields'];
};
const ChatPostMessageSchema = {
oneOf: [
@ -739,6 +764,10 @@ const ChatPostMessageSchema = {
},
nullable: true,
},
customFields: {
type: 'object',
nullable: true,
},
},
required: ['roomId'],
additionalProperties: false,
@ -780,6 +809,10 @@ const ChatPostMessageSchema = {
},
nullable: true,
},
customFields: {
type: 'object',
nullable: true,
},
},
required: ['channel'],
additionalProperties: false,

Loading…
Cancel
Save