From a7e243de397136d9c7e126889204cc372e49ac38 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Wed, 13 Nov 2024 04:21:42 +0000 Subject: [PATCH] feat: replace joi by zod --- api/package-lock.json | 55 ++----- api/package.json | 4 +- .../attachment/schemas/attachment.schema.ts | 14 +- api/src/channel/lib/__test__/common.mock.ts | 3 +- api/src/chat/schemas/types/attachment.ts | 58 ++++--- api/src/chat/schemas/types/button.ts | 53 +++++-- api/src/chat/schemas/types/capture-var.ts | 31 +++- api/src/chat/schemas/types/message.ts | 20 +-- api/src/chat/schemas/types/pattern.ts | 56 ++++--- api/src/chat/schemas/types/quick-reply.ts | 79 ++++++++-- api/src/chat/services/block.service.spec.ts | 9 +- api/src/chat/validation-rules/is-message.ts | 146 +++++++----------- .../chat/validation-rules/is-pattern-list.ts | 58 +------ .../chat/validation-rules/is-valid-capture.ts | 39 +---- api/src/cms/services/content.service.spec.ts | 7 +- .../channels/web/__test__/data.mock.ts | 3 +- .../channels/web/__test__/events.mock.ts | 7 +- .../channels/web/base-web-channel.ts | 6 +- api/src/extensions/channels/web/types.ts | 2 +- api/src/extensions/channels/web/wrapper.ts | 9 +- api/src/utils/test/fixtures/block.ts | 5 +- api/src/utils/test/mocks/block.ts | 11 +- 22 files changed, 315 insertions(+), 360 deletions(-) diff --git a/api/package-lock.json b/api/package-lock.json index b50360df..23b0f675 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -33,7 +33,6 @@ "dotenv": "^16.3.1", "ejs": "^3.1.9", "express-session": "^1.17.3", - "joi": "^17.11.0", "module-alias": "^2.2.3", "mongoose": "^8.0.0", "mongoose-lean-defaults": "^2.2.1", @@ -54,7 +53,8 @@ "sanitize-filename": "^1.6.3", "slug": "^8.2.2", "ts-migrate-mongoose": "^3.8.4", - "uuid": "^9.0.1" + "uuid": "^9.0.1", + "zod": "^3.23.8" }, "devDependencies": { "@compodoc/compodoc": "^1.1.24", @@ -3628,19 +3628,6 @@ "integrity": "sha512-59SgoZ3EXbkfSX7b63tsou/SDGzwUEK6MuB5sKqgVK1/XE0fxmpsOb9DQI8LXW3KfGnAjImCGhhEb7uPPAUVNA==", "dev": true }, - "node_modules/@hapi/hoek": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==" - }, - "node_modules/@hapi/topo": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", - "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", - "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", @@ -5238,24 +5225,6 @@ "url": "https://ko-fi.com/killymxi" } }, - "node_modules/@sideway/address": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", - "integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==", - "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, - "node_modules/@sideway/formula": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", - "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==" - }, - "node_modules/@sideway/pinpoint": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" - }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -12930,18 +12899,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/joi": { - "version": "17.11.0", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.11.0.tgz", - "integrity": "sha512-NgB+lZLNoqISVy1rZocE9PZI36bL/77ie924Ri43yEvi9GUUMPeyVIr8KdFTMUlby1p0PBYMk9spIxEUQYqrJQ==", - "dependencies": { - "@hapi/hoek": "^9.0.0", - "@hapi/topo": "^5.0.0", - "@sideway/address": "^4.1.3", - "@sideway/formula": "^3.0.1", - "@sideway/pinpoint": "^2.0.0" - } - }, "node_modules/js-stringify": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz", @@ -19421,6 +19378,14 @@ "resolved": "https://registry.npmjs.org/zepto/-/zepto-1.2.0.tgz", "integrity": "sha512-C1x6lfvBICFTQIMgbt3JqMOno3VOtkWat/xEakLTOurskYIHPmzJrzd1e8BnmtdDVJlGuk5D+FxyCA8MPmkIyA==", "dev": true + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/api/package.json b/api/package.json index f52398be..4a368446 100644 --- a/api/package.json +++ b/api/package.json @@ -71,7 +71,6 @@ "dotenv": "^16.3.1", "ejs": "^3.1.9", "express-session": "^1.17.3", - "joi": "^17.11.0", "module-alias": "^2.2.3", "mongoose": "^8.0.0", "mongoose-lean-defaults": "^2.2.1", @@ -92,7 +91,8 @@ "sanitize-filename": "^1.6.3", "slug": "^8.2.2", "ts-migrate-mongoose": "^3.8.4", - "uuid": "^9.0.1" + "uuid": "^9.0.1", + "zod": "^3.23.8" }, "devDependencies": { "@compodoc/compodoc": "^1.1.24", diff --git a/api/src/attachment/schemas/attachment.schema.ts b/api/src/attachment/schemas/attachment.schema.ts index 6d042ba5..601fa869 100644 --- a/api/src/attachment/schemas/attachment.schema.ts +++ b/api/src/attachment/schemas/attachment.schema.ts @@ -102,14 +102,14 @@ export class Attachment extends BaseSchema { * @returns The attachment type ('image', 'audio', 'video' or 'file') */ static getTypeByMime(mimeType: string): FileType { - if (mimeType.startsWith(FileType.image)) { - return FileType.image; - } else if (mimeType.startsWith(FileType.audio)) { - return FileType.audio; - } else if (mimeType.startsWith(FileType.video)) { - return FileType.video; + if (mimeType.startsWith('image')) { + return 'image'; + } else if (mimeType.startsWith('audio')) { + return 'audio'; + } else if (mimeType.startsWith('video')) { + return 'video'; } else { - return FileType.file; + return 'file'; } } } diff --git a/api/src/channel/lib/__test__/common.mock.ts b/api/src/channel/lib/__test__/common.mock.ts index 9acc3c37..89e0ae75 100644 --- a/api/src/channel/lib/__test__/common.mock.ts +++ b/api/src/channel/lib/__test__/common.mock.ts @@ -10,7 +10,6 @@ import { Attachment } from '@/attachment/schemas/attachment.schema'; import { WithUrl } from '@/chat/schemas/types/attachment'; import { ButtonType } from '@/chat/schemas/types/button'; import { - FileType, OutgoingMessageFormat, StdOutgoingAttachmentMessage, StdOutgoingButtonsMessage, @@ -160,7 +159,7 @@ export const attachmentMessage: StdOutgoingAttachmentMessage< WithUrl > = { attachment: { - type: FileType.image, + type: 'image', payload: attachmentWithUrl, }, quickReplies: [ diff --git a/api/src/chat/schemas/types/attachment.ts b/api/src/chat/schemas/types/attachment.ts index 975d78fd..201c22fa 100644 --- a/api/src/chat/schemas/types/attachment.ts +++ b/api/src/chat/schemas/types/attachment.ts @@ -6,33 +6,47 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ +import { z } from 'zod'; + import { Attachment } from '@/attachment/schemas/attachment.schema'; -export enum FileType { - image = 'image', - video = 'video', - audio = 'audio', - file = 'file', - unknown = 'unknown', -} +// Enum for FileType +export const fileTypeSchema = z.enum([ + 'image', + 'video', + 'audio', + 'file', + 'unknown', +]); -export type AttachmentForeignKey = { - url?: string; - attachment_id: string; -}; +export type FileType = z.infer; +// AttachmentForeignKey type schema +export const attachmentForeignKeySchema = z.object({ + url: z.string().url().optional(), + attachment_id: z.string().nullable(), +}); + +export type AttachmentForeignKey = z.infer; + +// WithUrl helper type export type WithUrl = A & { url?: string }; -export interface AttachmentPayload< +// Generic AttachmentPayload type schema +export const attachmentPayloadSchema = < A extends WithUrl | AttachmentForeignKey, -> { - type: FileType; - payload: A; -} +>( + payloadSchema: z.ZodType, +) => + z.object({ + type: fileTypeSchema, + payload: payloadSchema, + }); -export interface IncomingAttachmentPayload { - type: FileType; - payload: { - url: string; - }; -} +export type AttachmentPayload< + A extends WithUrl | AttachmentForeignKey, +> = z.infer>>; + +export type IncomingAttachmentPayload = z.infer< + ReturnType> +>; diff --git a/api/src/chat/schemas/types/button.ts b/api/src/chat/schemas/types/button.ts index c38cfe7b..5a2d72ec 100644 --- a/api/src/chat/schemas/types/button.ts +++ b/api/src/chat/schemas/types/button.ts @@ -6,23 +6,50 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ +import { z } from 'zod'; + +// Enum for ButtonType export enum ButtonType { postback = 'postback', web_url = 'web_url', } -export type PostBackButton = { - type: ButtonType.postback; - title: string; - payload: string; -}; +// Zod schema for ButtonType +export const buttonTypeSchema = z.enum([ + ButtonType.postback, + ButtonType.web_url, +]); -export type WebUrlButton = { - type: ButtonType.web_url; - title: string; - url: string; - messenger_extensions?: boolean; - webview_height_ratio?: 'compact' | 'tall' | 'full'; -}; +// Base schema for shared fields +export const baseButtonSchema = z.object({ + type: buttonTypeSchema, + title: z.string().max(20), +}); -export type Button = PostBackButton | WebUrlButton; +// Conditional schemas +export const postBackButtonSchema = baseButtonSchema.extend({ + type: z.literal(ButtonType.postback), + payload: z.string().max(1000), + // No `url`, `messenger_extensions`, or `webview_height_ratio` fields here +}); + +export const webUrlButtonSchema = baseButtonSchema.extend({ + type: z.literal(ButtonType.web_url), + url: z.string().url(), + messenger_extensions: z.boolean().optional(), + webview_height_ratio: z.enum(['compact', 'tall', 'full']).optional(), + // No `payload` field here +}); + +// Union schema for Button +export const buttonSchema = z.union([postBackButtonSchema, webUrlButtonSchema]); + +// Array schema for buttons +export const buttonsSchema = z.array(buttonSchema).max(3); + +// Infer types +export type PostBackButton = z.infer; + +export type WebUrlButton = z.infer; + +export type Button = z.infer; diff --git a/api/src/chat/schemas/types/capture-var.ts b/api/src/chat/schemas/types/capture-var.ts index 002340ab..b6e2c93f 100644 --- a/api/src/chat/schemas/types/capture-var.ts +++ b/api/src/chat/schemas/types/capture-var.ts @@ -6,10 +6,27 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ -export interface CaptureVar { - // entity=`-1` to match text message - // entity=`-2` for postback payload - // entity is `String` for NLP entities - entity: number | string; - context_var: string; -} +import { z } from 'zod'; + +// Zod schema for CaptureVar +const captureVarSchema = z.object({ + entity: z.union([ + // entity=`-1` to match text message + // entity=`-2` for postback payload + // entity is `String` for NLP entities + z + .number() + .int() + .refine((val) => val === -1 || val === -2, { + message: "entity must be -1 or -2 when it's a number", + }), + z.string(), // entity is a string for NLP entities + ]), + context_var: z.string(), +}); + +// Infer the TypeScript type +type CaptureVar = z.infer; + +// Export the schema and type +export { CaptureVar, captureVarSchema }; diff --git a/api/src/chat/schemas/types/message.ts b/api/src/chat/schemas/types/message.ts index 8d1edbcd..7c120222 100644 --- a/api/src/chat/schemas/types/message.ts +++ b/api/src/chat/schemas/types/message.ts @@ -56,22 +56,6 @@ export enum OutgoingMessageFormat { carousel = 'carousel', } -/** - * FileType enum is declared, and currently not used - **/ -export enum FileType { - image = 'image', - video = 'video', - audio = 'audio', - file = 'file', - unknown = 'unknown', -} - -export enum PayloadType { - location = 'location', - attachments = 'attachments', -} - export type StdOutgoingTextMessage = { text: string }; export type StdOutgoingQuickRepliesMessage = { @@ -130,7 +114,7 @@ export type StdIncomingPostBackMessage = StdIncomingTextMessage & { }; export type StdIncomingLocationMessage = { - type: PayloadType.location; + type: 'location'; coordinates: { lat: number; lon: number; @@ -138,7 +122,7 @@ export type StdIncomingLocationMessage = { }; export type StdIncomingAttachmentMessage = { - type: PayloadType.attachments; + type: 'attachments'; serialized_text: string; attachment: IncomingAttachmentPayload | IncomingAttachmentPayload[]; }; diff --git a/api/src/chat/schemas/types/pattern.ts b/api/src/chat/schemas/types/pattern.ts index 8b0ceab1..c23ea8ff 100644 --- a/api/src/chat/schemas/types/pattern.ts +++ b/api/src/chat/schemas/types/pattern.ts @@ -6,24 +6,44 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ -import { PayloadType } from './message'; +import { z } from 'zod'; -export interface PayloadPattern { - label: string; - value: string; - // @todo : rename 'attachment' to 'attachments' - type?: PayloadType; -} +export const payloadTypeSchema = z.enum(['location', 'attachments']); -export type NlpPattern = - | { - entity: string; - match: 'entity'; - } - | { - entity: string; - match: 'value'; - value: string; - }; +// Define PayloadPattern schema +export const PayloadPatternSchema = z.object({ + label: z.string(), + value: z.string(), + type: payloadTypeSchema.optional(), // Optional field +}); -export type Pattern = string | RegExp | PayloadPattern | NlpPattern[]; +export type PayloadPattern = z.infer; + +// Define NlpPattern schema +export const NlpPatternEntitySchema = z.object({ + entity: z.string(), + match: z.literal('entity'), +}); + +export const NlpPatternValueSchema = z.object({ + entity: z.string(), + match: z.literal('value'), + value: z.string(), +}); + +export const NlpPatternSchema = z.union([ + NlpPatternEntitySchema, + NlpPatternValueSchema, +]); + +export type NlpPattern = z.infer; + +// Define Pattern as a union of possible types +export const patternSchema = z.union([ + z.string(), + z.instanceof(RegExp), + PayloadPatternSchema, + z.array(NlpPatternSchema), +]); + +export type Pattern = z.infer; diff --git a/api/src/chat/schemas/types/quick-reply.ts b/api/src/chat/schemas/types/quick-reply.ts index 700cbb7c..2e980527 100644 --- a/api/src/chat/schemas/types/quick-reply.ts +++ b/api/src/chat/schemas/types/quick-reply.ts @@ -6,26 +6,47 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ -import { IncomingAttachmentPayload } from './attachment'; +import { z } from 'zod'; -export enum PayloadType { - location = 'location', - attachments = 'attachments', -} +import { + attachmentForeignKeySchema, + attachmentPayloadSchema, + IncomingAttachmentPayload, +} from './attachment'; + +export const payloadTypeSchema = z.enum(['location', 'attachments']); + +export type PayloadType = z.infer; + +// Define the Payload schema +export const payloadSchema = z.union([ + z.object({ + type: z.literal('location'), + coordinates: z.object({ + lat: z.number(), + lon: z.number(), + }), + }), + z.object({ + type: z.literal('attachments'), + attachments: attachmentPayloadSchema(attachmentForeignKeySchema), + }), +]); export type Payload = | { - type: PayloadType.location; + type: 'location'; coordinates: { lat: number; lon: number; }; } | { - type: PayloadType.attachments; + type: 'attachments'; attachments: IncomingAttachmentPayload; }; +// Enum for QuickReplyType export enum QuickReplyType { text = 'text', location = 'location', @@ -33,8 +54,42 @@ export enum QuickReplyType { user_email = 'user_email', } -export interface StdQuickReply { - content_type: QuickReplyType; - title: string; - payload: string; -} +export const quickReplyTypeSchema = z.enum( + Object.values(QuickReplyType) as [string, ...string[]], +); + +// Schema for StdQuickReply with conditional constraints using superRefine +export const stdQuickReplySchema = z + .object({ + content_type: quickReplyTypeSchema, + title: z.string().max(20).optional(), + payload: z.string().max(1000).optional(), + }) + .superRefine((val, ctx) => { + if (val.content_type === 'text') { + if (!val.title) { + ctx.addIssue({ + path: ['title'], + code: z.ZodIssueCode.custom, + message: "Title is required when content_type is 'text'.", + }); + } + + if (!val.payload) { + ctx.addIssue({ + path: ['payload'], + code: z.ZodIssueCode.custom, + message: "Payload is required when content_type is 'text'.", + }); + } + } + }); + +export type StdQuickReply = z.infer; + +// Schema for the array with max 11 items +export const quickRepliesArraySchema = z + .array(stdQuickReplySchema) + .max(11, { message: 'You can provide up to 11 quick replies.' }); + +export type QuickRepliesArray = z.infer; diff --git a/api/src/chat/services/block.service.spec.ts b/api/src/chat/services/block.service.spec.ts index 066e6fee..93c39376 100644 --- a/api/src/chat/services/block.service.spec.ts +++ b/api/src/chat/services/block.service.spec.ts @@ -62,9 +62,8 @@ import { BlockRepository } from '../repositories/block.repository'; import { Block, BlockModel } from '../schemas/block.schema'; import { Category, CategoryModel } from '../schemas/category.schema'; import { LabelModel } from '../schemas/label.schema'; -import { FileType } from '../schemas/types/attachment'; import { Context } from '../schemas/types/context'; -import { PayloadType, StdOutgoingListMessage } from '../schemas/types/message'; +import { StdOutgoingListMessage } from '../schemas/types/message'; import { SubscriberContext } from '../schemas/types/subscriberContext'; import { CategoryRepository } from './../repositories/category.repository'; @@ -360,7 +359,7 @@ describe('BlockService', () => { it("should match payload when it's an attachment location", () => { const result = blockService.matchPayload( { - type: PayloadType.location, + type: 'location', coordinates: { lat: 15, lon: 23, @@ -374,9 +373,9 @@ describe('BlockService', () => { it("should match payload when it's an attachment file", () => { const result = blockService.matchPayload( { - type: PayloadType.attachments, + type: 'attachments', attachments: { - type: FileType.file, + type: 'file', payload: { url: 'http://link.to/the/file', }, diff --git a/api/src/chat/validation-rules/is-message.ts b/api/src/chat/validation-rules/is-message.ts index fbd1589b..47ab2226 100644 --- a/api/src/chat/validation-rules/is-message.ts +++ b/api/src/chat/validation-rules/is-message.ts @@ -12,103 +12,61 @@ import { ValidatorConstraint, ValidatorConstraintInterface, } from 'class-validator'; -import Joi from 'joi'; +import { z } from 'zod'; +import attachmentSchema from '@/attachment/schemas/attachment.schema'; + +import { buttonsSchema } from '../schemas/types/button'; import { BlockMessage } from '../schemas/types/message'; +import { quickRepliesArraySchema } from '../schemas/types/quick-reply'; -export function isValidMessage(msg: any) { - if (typeof msg === 'string' && msg !== '') { - // Custom code - const MESSAGE_REGEX = /^function \(context\) \{[^]+\}/; - if (!MESSAGE_REGEX.test(msg)) { - // eslint-disable-next-line - console.error('Block Model : Invalid custom code.', msg); - return false; - } else { - return true; - } - } else if (Array.isArray(msg)) { - // Simple text message - const textSchema = Joi.array().items(Joi.string().max(1000).required()); - const textCheck = textSchema.validate(msg); - return !textCheck.error; - } else if (typeof msg === 'object') { - if ('plugin' in msg) { - return true; - } else { - const buttonsSchema = Joi.array().items( - Joi.object().keys({ - type: Joi.string().valid('postback', 'web_url').required(), - title: Joi.string().max(20), - payload: Joi.alternatives().conditional('type', { - is: 'postback', - then: Joi.string().max(1000).required(), - otherwise: Joi.forbidden(), - }), - url: Joi.alternatives().conditional('type', { - is: 'web_url', - then: Joi.string().uri(), - otherwise: Joi.forbidden(), - }), - messenger_extensions: Joi.alternatives().conditional('type', { - is: 'web_url', - then: Joi.boolean(), - otherwise: Joi.forbidden(), - }), - webview_height_ratio: Joi.alternatives().conditional('type', { - is: 'web_url', - then: Joi.string().valid('compact', 'tall', 'full'), - otherwise: Joi.forbidden(), - }), - }), - ); - // Attachment message - const objectSchema = Joi.object().keys({ - text: Joi.string().max(1000), - attachment: Joi.object().keys({ - type: Joi.string() - .valid('image', 'audio', 'video', 'file', 'unknown') - .required(), - payload: Joi.object().keys({ - url: Joi.string().uri(), - attachment_id: Joi.string().allow(null), - }), - }), - elements: Joi.boolean(), - cards: Joi.object().keys({ - default_action: buttonsSchema.max(1), - buttons: buttonsSchema.max(3), - }), - buttons: buttonsSchema.max(3), - quickReplies: Joi.array() - .items( - Joi.object().keys({ - content_type: Joi.string() - .valid('text', 'location', 'user_phone_number', 'user_email') - .required(), - title: Joi.alternatives().conditional('content_type', { - is: 'text', - then: Joi.string().max(20).required(), - }), - payload: Joi.alternatives().conditional('content_type', { - is: 'text', - then: Joi.string().max(1000).required(), - }), - }), - ) - .max(11), - }); - const objectCheck = objectSchema.validate(msg); - if (objectCheck.error) { - // eslint-disable-next-line - console.log('Message validation failed! ', objectCheck); - } - return !objectCheck.error; - } - } else { - return false; - } -} +// Schemas for different components +const textSchema = z.array(z.string().max(1000)); + +const pluginSchema = z.object({ + plugin: z.string(), + args: z.record(z.any()), // Plugin-specific settings +}); + +// Message schema variations +const baseTextMessageSchema = z.object({ + text: z.string().max(1000).optional(), +}); + +const messageWithButtonsSchema = baseTextMessageSchema.extend({ + buttons: buttonsSchema, +}); + +const messageWithQuickRepliesSchema = baseTextMessageSchema.extend({ + quickReplies: quickRepliesArraySchema, +}); + +const messageWithAttachmentSchema = z.object({ + attachment: attachmentSchema, + quickReplies: quickRepliesArraySchema.optional(), +}); + +const messageWithElementsSchema = z.object({ + elements: z.array(z.record(z.any())), // Array of generic elements +}); + +const messageWithPluginSchema = z.object({ + plugin: pluginSchema, +}); + +// Union of all possible message types +const messageSchema = z.union([ + textSchema, + messageWithButtonsSchema, + messageWithQuickRepliesSchema, + messageWithAttachmentSchema, + messageWithElementsSchema, + messageWithPluginSchema, +]); + +export const isValidMessage = (msg: unknown): boolean => { + return messageSchema.safeParse(msg).success; +}; @ValidatorConstraint({ async: false }) export class MessageValidator implements ValidatorConstraintInterface { diff --git a/api/src/chat/validation-rules/is-pattern-list.ts b/api/src/chat/validation-rules/is-pattern-list.ts index 528144ee..f0643120 100644 --- a/api/src/chat/validation-rules/is-pattern-list.ts +++ b/api/src/chat/validation-rules/is-pattern-list.ts @@ -12,62 +12,14 @@ import { ValidatorConstraint, ValidatorConstraintInterface, } from 'class-validator'; -import Joi from 'joi'; +import { z } from 'zod'; -import { Pattern } from '../schemas/types/pattern'; +import { Pattern, patternSchema } from '../schemas/types/pattern'; +// Function to check if the given input is a valid Pattern list export function isPatternList(patterns: Pattern[]) { - return ( - Array.isArray(patterns) && - patterns.every((pattern) => { - if (typeof pattern === 'string') { - // Check if valid regex - if (pattern.endsWith('/') && pattern.startsWith('/')) { - try { - new RegExp(pattern.slice(1, -1), 'gi'); - } catch (err) { - return false; - } - return true; - } - // Check if valid string (Equals/Like) - return pattern !== ''; - } else if (Array.isArray(pattern)) { - // Check if valid NLP pattern - const nlpSchema = Joi.array() - .items( - Joi.object().keys({ - entity: Joi.string().required(), - match: Joi.string().valid('entity', 'value').required(), - value: Joi.string().required(), - }), - ) - .min(1); - const nlpCheck = nlpSchema.validate(pattern); - if (nlpCheck.error) { - // console.log('Message validation failed! ', nlpCheck); - } - return !nlpCheck.error; - } else if (typeof pattern === 'object') { - // Invalid structure? - const payloadSchema = Joi.object().keys({ - label: Joi.string().required(), - value: Joi.any().required(), - type: Joi.string(), - }); - const payloadCheck = payloadSchema.validate(pattern); - if (payloadCheck.error) { - // console.log( - // 'Message validation failed! ', - // payloadCheck, - // ); - } - return !payloadCheck.error; - } else { - return false; - } - }) - ); + const patternArraySchema = z.array(patternSchema); + return patternArraySchema.safeParse(patterns).success; } @ValidatorConstraint({ async: false }) diff --git a/api/src/chat/validation-rules/is-valid-capture.ts b/api/src/chat/validation-rules/is-valid-capture.ts index 664280ea..970e7bd3 100644 --- a/api/src/chat/validation-rules/is-valid-capture.ts +++ b/api/src/chat/validation-rules/is-valid-capture.ts @@ -12,43 +12,16 @@ import { ValidatorConstraint, ValidatorConstraintInterface, } from 'class-validator'; -import Joi from 'joi'; +import { z } from 'zod'; -type Tentity = -1 | -2; +import { CaptureVar, captureVarSchema } from '../schemas/types/capture-var'; -export interface CaptureVar { - // entity=`-1` to match text message - // entity=`-2` for postback payload - // entity is `String` for NLP entities - entity: Tentity | string; - context_var: string; -} - -const allowedEntityValues: Tentity[] = [-1, -2]; +// Define the array schema +const captureVarArraySchema = z.array(captureVarSchema); +// Validation function export function isValidVarCapture(vars: CaptureVar[]) { - const captureSchema = Joi.array().items( - Joi.object().keys({ - entity: Joi.alternatives().try( - // `-1` to match text message & `-2` for postback payload - Joi.number() - .valid(...allowedEntityValues) - .required(), - // String for NLP entities - Joi.string().required(), - ), - context_var: Joi.string() - .regex(/^[a-z][a-z_0-9]*$/) - .required(), - }), - ); - - const captureCheck = captureSchema.validate(vars); - if (captureCheck.error) { - // eslint-disable-next-line - console.log('Capture vars validation failed!', captureCheck.error); - } - return !captureCheck.error; + return captureVarArraySchema.safeParse(vars).success; } @ValidatorConstraint({ async: false }) diff --git a/api/src/cms/services/content.service.spec.ts b/api/src/cms/services/content.service.spec.ts index 3844bedd..285fbebf 100644 --- a/api/src/cms/services/content.service.spec.ts +++ b/api/src/cms/services/content.service.spec.ts @@ -13,7 +13,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AttachmentRepository } from '@/attachment/repositories/attachment.repository'; import { AttachmentModel } from '@/attachment/schemas/attachment.schema'; import { AttachmentService } from '@/attachment/services/attachment.service'; -import { FileType } from '@/chat/schemas/types/attachment'; import { OutgoingMessageFormat } from '@/chat/schemas/types/message'; import { ContentOptions } from '@/chat/schemas/types/options'; import { LoggerService } from '@/logger/logger.service'; @@ -116,7 +115,7 @@ describe('ContentService', () => { status: true, dynamicFields: { image: { - type: FileType.image, + type: 'image', payload: { attachment_id: '123', }, @@ -132,7 +131,7 @@ describe('ContentService', () => { status: true, dynamicFields: { image: { - type: FileType.image, + type: 'image', payload: { attachment_id: '456', }, @@ -148,7 +147,7 @@ describe('ContentService', () => { status: true, dynamicFields: { image: { - type: FileType.image, + type: 'image', payload: { url: 'https://remote.file/image.jpg', }, diff --git a/api/src/extensions/channels/web/__test__/data.mock.ts b/api/src/extensions/channels/web/__test__/data.mock.ts index ae5fcd29..115aa8ac 100644 --- a/api/src/extensions/channels/web/__test__/data.mock.ts +++ b/api/src/extensions/channels/web/__test__/data.mock.ts @@ -9,7 +9,6 @@ import { textMessage } from '@/channel/lib/__test__/common.mock'; import { VIEW_MORE_PAYLOAD } from '@/chat/helpers/constants'; import { ButtonType } from '@/chat/schemas/types/button'; -import { FileType } from '@/chat/schemas/types/message'; import { QuickReplyType } from '@/chat/schemas/types/quick-reply'; import { Web } from '../types'; @@ -139,7 +138,7 @@ export const webAttachment: Web.OutgoingMessageBase = { title: 'Next >', }, ], - type: FileType.image, + type: 'image', url: 'http://localhost:4000/attachment/download/1/attachment.jpg', }, type: Web.OutgoingMessageType.file, diff --git a/api/src/extensions/channels/web/__test__/events.mock.ts b/api/src/extensions/channels/web/__test__/events.mock.ts index 334482be..65b13487 100644 --- a/api/src/extensions/channels/web/__test__/events.mock.ts +++ b/api/src/extensions/channels/web/__test__/events.mock.ts @@ -6,7 +6,6 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ -import { FileType } from '@/chat/schemas/types/attachment'; import { IncomingMessageType, StdEventType, @@ -55,7 +54,7 @@ const webEventLocation: Web.IncomingMessage = { const webEventFile: Web.Event = { type: Web.IncomingMessageType.file, data: { - type: FileType.image, + type: 'image', url: img_url, size: 500, }, @@ -149,7 +148,7 @@ export const webEvents: [string, Web.IncomingMessage, any][] = [ payload: { type: IncomingMessageType.attachments, attachments: { - type: FileType.image, + type: 'image', payload: { url: img_url, }, @@ -160,7 +159,7 @@ export const webEvents: [string, Web.IncomingMessage, any][] = [ payload: { url: img_url, }, - type: FileType.image, + type: 'image', }, serialized_text: `attachment:image:${img_url}`, type: IncomingMessageType.attachments, diff --git a/api/src/extensions/channels/web/base-web-channel.ts b/api/src/extensions/channels/web/base-web-channel.ts index cb8907a6..c8d6ab44 100644 --- a/api/src/extensions/channels/web/base-web-channel.ts +++ b/api/src/extensions/channels/web/base-web-channel.ts @@ -31,11 +31,9 @@ import { WithUrl } from '@/chat/schemas/types/attachment'; import { Button, ButtonType } from '@/chat/schemas/types/button'; import { AnyMessage, - FileType, IncomingMessage, OutgoingMessage, OutgoingMessageFormat, - PayloadType, StdEventType, StdOutgoingAttachmentMessage, StdOutgoingButtonsMessage, @@ -138,7 +136,7 @@ export default abstract class BaseWebChannelHandler< ): Web.IncomingMessageBase { // Format incoming message if ('type' in incoming.message) { - if (incoming.message.type === PayloadType.location) { + if (incoming.message.type === 'location') { const coordinates = incoming.message.coordinates; return { type: Web.IncomingMessageType.location, @@ -682,7 +680,7 @@ export default abstract class BaseWebChannelHandler< this.storeAttachment( { name: file.filename, - type: file.mimetype as FileType, // @Todo : test this + type: Attachment.getTypeByMime(file.mimetype), // @Todo : test this size: file.size, }, file.path.replace(dirPath, ''), diff --git a/api/src/extensions/channels/web/types.ts b/api/src/extensions/channels/web/types.ts index 5d475728..a7da407c 100644 --- a/api/src/extensions/channels/web/types.ts +++ b/api/src/extensions/channels/web/types.ts @@ -7,8 +7,8 @@ */ import { SubscriberFull } from '@/chat/schemas/subscriber.schema'; +import { FileType } from '@/chat/schemas/types/attachment'; import { Button, WebUrlButton } from '@/chat/schemas/types/button'; -import { FileType } from '@/chat/schemas/types/message'; import { StdQuickReply } from '@/chat/schemas/types/quick-reply'; export namespace Web { diff --git a/api/src/extensions/channels/web/wrapper.ts b/api/src/extensions/channels/web/wrapper.ts index e36ff5d9..e744e624 100644 --- a/api/src/extensions/channels/web/wrapper.ts +++ b/api/src/extensions/channels/web/wrapper.ts @@ -14,7 +14,6 @@ import { } from '@/chat/schemas/types/attachment'; import { IncomingMessageType, - PayloadType, StdEventType, StdIncomingMessage, } from '@/chat/schemas/types/message'; @@ -215,7 +214,7 @@ export default class WebEventWrapper< case IncomingMessageType.location: { const coordinates = this._adapter.raw.data.coordinates; return { - type: PayloadType.location, + type: 'location', coordinates: { lat: coordinates.lat, lon: coordinates.lng, @@ -224,7 +223,7 @@ export default class WebEventWrapper< } case IncomingMessageType.attachments: return { - type: PayloadType.attachments, + type: 'attachments', attachments: { type: this._adapter.raw.data.type, payload: { @@ -260,7 +259,7 @@ export default class WebEventWrapper< case IncomingMessageType.location: { const coordinates = this._adapter.raw.data.coordinates; return { - type: PayloadType.location, + type: 'location', coordinates: { lat: coordinates.lat, lon: coordinates.lng, @@ -271,7 +270,7 @@ export default class WebEventWrapper< case IncomingMessageType.attachments: { const attachment = this._adapter.raw.data; return { - type: PayloadType.attachments, + type: 'attachments', serialized_text: `attachment:${attachment.type}:${attachment.url}`, attachment: { type: attachment.type, diff --git a/api/src/utils/test/fixtures/block.ts b/api/src/utils/test/fixtures/block.ts index b66611ce..6b32be99 100644 --- a/api/src/utils/test/fixtures/block.ts +++ b/api/src/utils/test/fixtures/block.ts @@ -9,9 +9,8 @@ import mongoose from 'mongoose'; import { BlockCreateDto } from '@/chat/dto/block.dto'; -import { BlockModel, Block } from '@/chat/schemas/block.schema'; +import { Block, BlockModel } from '@/chat/schemas/block.schema'; import { CategoryModel } from '@/chat/schemas/category.schema'; -import { FileType } from '@/chat/schemas/types/attachment'; import { ButtonType } from '@/chat/schemas/types/button'; import { QuickReplyType } from '@/chat/schemas/types/quick-reply'; @@ -129,7 +128,7 @@ export const blocks: BlockCreateDto[] = [ }, message: { attachment: { - type: FileType.image, + type: 'image', payload: { attachment_id: '1', }, diff --git a/api/src/utils/test/mocks/block.ts b/api/src/utils/test/mocks/block.ts index f7322e2f..9a9f17c2 100644 --- a/api/src/utils/test/mocks/block.ts +++ b/api/src/utils/test/mocks/block.ts @@ -11,13 +11,12 @@ import { labelMock, } from '@/channel/lib/__test__/label.mock'; import { BlockFull } from '@/chat/schemas/block.schema'; -import { FileType } from '@/chat/schemas/types/attachment'; import { ButtonType } from '@/chat/schemas/types/button'; +import { CaptureVar } from '@/chat/schemas/types/capture-var'; import { OutgoingMessageFormat } from '@/chat/schemas/types/message'; import { BlockOptions, ContentOptions } from '@/chat/schemas/types/options'; import { Pattern } from '@/chat/schemas/types/pattern'; -import { PayloadType, QuickReplyType } from '@/chat/schemas/types/quick-reply'; -import { CaptureVar } from '@/chat/validation-rules/is-valid-capture'; +import { QuickReplyType } from '@/chat/schemas/types/quick-reply'; import { modelInstance } from './misc'; @@ -183,7 +182,7 @@ export const attachmentBlock: BlockFull = { patterns: ['image'], message: { attachment: { - type: FileType.image, + type: 'image', payload: { url: 'https://fr.facebookbrand.com/wp-content/uploads/2016/09/messenger_icon2.png', attachment_id: '1234', @@ -221,12 +220,12 @@ export const blockGetStarted: BlockFull = { { label: 'Tounes', value: 'Tounes', - type: PayloadType.location, + type: 'location', }, { label: 'Livre', value: 'Livre', - type: PayloadType.attachments, + type: 'attachments', }, [ {