From 649dc3e82ef85351c5bc1b4f616ebec8c7865f22 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 11 Nov 2024 13:00:52 +0000 Subject: [PATCH] feat: chat ui channel (partial) --- api/src/chat/schemas/types/channel.ts | 2 + .../channels/chatui/i18n/en/label.json | 12 + .../channels/chatui/i18n/en/title.json | 3 + .../channels/chatui/i18n/fr/label.json | 12 + .../channels/chatui/i18n/fr/title.json | 3 + .../channels/chatui/index.channel.ts | 310 ++++++++++++++++++ api/src/extensions/channels/chatui/index.d.ts | 22 ++ .../extensions/channels/chatui/package.json | 7 + .../extensions/channels/chatui/settings.ts | 79 +++++ api/src/extensions/channels/chatui/types.ts | 36 ++ api/src/extensions/channels/chatui/wrapper.ts | 28 ++ api/src/utils/helpers/misc.ts | 7 + 12 files changed, 521 insertions(+) create mode 100644 api/src/extensions/channels/chatui/i18n/en/label.json create mode 100644 api/src/extensions/channels/chatui/i18n/en/title.json create mode 100644 api/src/extensions/channels/chatui/i18n/fr/label.json create mode 100644 api/src/extensions/channels/chatui/i18n/fr/title.json create mode 100644 api/src/extensions/channels/chatui/index.channel.ts create mode 100644 api/src/extensions/channels/chatui/index.d.ts create mode 100644 api/src/extensions/channels/chatui/package.json create mode 100644 api/src/extensions/channels/chatui/settings.ts create mode 100644 api/src/extensions/channels/chatui/types.ts create mode 100644 api/src/extensions/channels/chatui/wrapper.ts diff --git a/api/src/chat/schemas/types/channel.ts b/api/src/chat/schemas/types/channel.ts index 38a690832..ad65ad075 100644 --- a/api/src/chat/schemas/types/channel.ts +++ b/api/src/chat/schemas/types/channel.ts @@ -12,6 +12,8 @@ interface BaseChannelData { name: ChannelName; // channel name isSocket?: boolean; type?: any; //TODO: type has to be checked + email?: string; + passwordHash?: string; } export type ChannelData = BaseChannelData; diff --git a/api/src/extensions/channels/chatui/i18n/en/label.json b/api/src/extensions/channels/chatui/i18n/en/label.json new file mode 100644 index 000000000..cd5806248 --- /dev/null +++ b/api/src/extensions/channels/chatui/i18n/en/label.json @@ -0,0 +1,12 @@ +{ + "display_name": "Display Name", + "avatar": "Avatar", + "allowed_domains": "Allowed Domains", + "persistent_menu": "Display Persistent Menu", + "greeting_message": "Greeting Message", + "show_emoji": "Enable Emoji Picker", + "show_file": "Enable Attachment Uploader", + "show_location": "Enable Geolocation Share", + "allowed_upload_size": "Max Upload Size (in bytes)", + "allowed_upload_types": "Allowed Upload Mime Types (comma separated)" +} diff --git a/api/src/extensions/channels/chatui/i18n/en/title.json b/api/src/extensions/channels/chatui/i18n/en/title.json new file mode 100644 index 000000000..b80c1009f --- /dev/null +++ b/api/src/extensions/channels/chatui/i18n/en/title.json @@ -0,0 +1,3 @@ +{ + "chatui_channel": "Chat UI" +} diff --git a/api/src/extensions/channels/chatui/i18n/fr/label.json b/api/src/extensions/channels/chatui/i18n/fr/label.json new file mode 100644 index 000000000..0c81a404f --- /dev/null +++ b/api/src/extensions/channels/chatui/i18n/fr/label.json @@ -0,0 +1,12 @@ +{ + "display_name": "Nom d'affichage", + "avatar": "Avatar", + "allowed_domains": "Domaines autorisés", + "persistent_menu": "Afficher le menu persistent", + "greeting_message": "Message de bienvenue", + "show_emoji": "Activer le sélecteur d'Emojis", + "show_file": "Activer l'upload de fichiers", + "show_location": "Activer le partage de géolocalisation", + "allowed_upload_size": "Taille maximale de téléchargement (en octets)", + "allowed_upload_types": "Types MIME autorisés pour le téléchargement (séparés par des virgules)" +} diff --git a/api/src/extensions/channels/chatui/i18n/fr/title.json b/api/src/extensions/channels/chatui/i18n/fr/title.json new file mode 100644 index 000000000..b80c1009f --- /dev/null +++ b/api/src/extensions/channels/chatui/i18n/fr/title.json @@ -0,0 +1,3 @@ +{ + "chatui_channel": "Chat UI" +} diff --git a/api/src/extensions/channels/chatui/index.channel.ts b/api/src/extensions/channels/chatui/index.channel.ts new file mode 100644 index 000000000..590310333 --- /dev/null +++ b/api/src/extensions/channels/chatui/index.channel.ts @@ -0,0 +1,310 @@ +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 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 { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { compareSync } from 'bcryptjs'; +import { Request, Response } from 'express'; +import Joi from 'joi'; + +import { AttachmentService } from '@/attachment/services/attachment.service'; +import { ChannelService } from '@/channel/channel.service'; +import { ChannelName } from '@/channel/types'; +import { SubscriberCreateDto } from '@/chat/dto/subscriber.dto'; +import { Thread } from '@/chat/schemas/thread.schema'; +import { MessageService } from '@/chat/services/message.service'; +import { SubscriberService } from '@/chat/services/subscriber.service'; +import { ThreadService } from '@/chat/services/thread.service'; +import { MenuService } from '@/cms/services/menu.service'; +import BaseWebChannelHandler from '@/extensions/channels/web/base-web-channel'; +import { Web } from '@/extensions/channels/web/types'; +import { I18nService } from '@/i18n/services/i18n.service'; +import { LoggerService } from '@/logger/logger.service'; +import { SettingService } from '@/setting/services/setting.service'; +import { hash } from '@/user/utilities/bcryptjs'; +import { truncate } from '@/utils/helpers/misc'; +import { + SocketGet, + SocketPost, +} from '@/websocket/decorators/socket-method.decorator'; +import { SocketReq } from '@/websocket/decorators/socket-req.decorator'; +import { SocketRes } from '@/websocket/decorators/socket-res.decorator'; +import { SocketRequest } from '@/websocket/utils/socket-request'; +import { SocketResponse } from '@/websocket/utils/socket-response'; +import { WebsocketGateway } from '@/websocket/websocket.gateway'; + +import { CHATUI_CHANNEL_NAME } from './settings'; +import { ChatUiWeb } from './types'; + +// Joi schema for validation +const signUpSchema = Joi.object({ + type: Joi.string().equal('sign_up'), + data: Joi.object({ + email: Joi.string().email().required().messages({ + 'string.email': 'Invalid email address', + 'any.required': 'Email is required', + }), + password: Joi.string().min(8).required().messages({ + 'string.min': 'Password must be at least 8 characters long', + 'any.required': 'Password is required', + }), + }), +}); + +const signInSchema = Joi.object({ + type: Joi.string().equal('sign_in'), + data: Joi.object({ + email: Joi.string().email().required().messages({ + 'string.email': 'Invalid email address', + 'any.required': 'Email is required', + }), + password: Joi.string().required().messages({ + 'any.required': 'Password is required', + }), + }), +}); + +@Injectable() +export default class ChatUiChannelHandler extends BaseWebChannelHandler< + typeof CHATUI_CHANNEL_NAME +> { + constructor( + settingService: SettingService, + channelService: ChannelService, + logger: LoggerService, + eventEmitter: EventEmitter2, + i18n: I18nService, + subscriberService: SubscriberService, + attachmentService: AttachmentService, + messageService: MessageService, + menuService: MenuService, + websocketGateway: WebsocketGateway, + private readonly threadService: ThreadService, + ) { + super( + CHATUI_CHANNEL_NAME, + settingService, + channelService, + logger, + eventEmitter, + i18n, + subscriberService, + attachmentService, + messageService, + menuService, + websocketGateway, + ); + } + + getPath(): string { + return __dirname; + } + + /** + * Fetches all the messages of a given thread. + * + * @param req - Socket request + * @returns Promise to an array of messages, rejects into error. + */ + private async fetchThreadMessages(thread: Thread): Promise { + const messages = await this.messageService.findByThread(thread); + return this.formatMessages(messages); + } + + private async signUp(req: SocketRequest, res: SocketResponse) { + const payload = req.body as ChatUiWeb.SignUpRequest; + // Validate the request body + const { error } = signUpSchema.validate(payload, { abortEarly: false }); + if (error) { + return res + .status(400) + .json({ errors: error.details.map((detail) => detail.message) }); + } + + try { + const { email, password } = payload.data; + // Check if user already exists + const existingUser = await this.subscriberService.findOne({ + ['channel.email' as string]: email, + }); + if (existingUser) { + return res.status(400).json({ message: 'Email is already in use' }); + } + + // Create new user + const channelData = this.getChannelData(req); + const newProfile: SubscriberCreateDto = { + foreign_id: this.generateId(), + first_name: 'Anon.', + last_name: 'Chat UI User', + assignedTo: null, + assignedAt: null, + lastvisit: new Date(), + retainedFrom: new Date(), + channel: { + ...channelData, + name: this.getName() as ChannelName, + email, + passwordHash: password ? hash(password) : undefined, + }, + language: '', + locale: '', + timezone: 0, + gender: 'male', + country: '', + labels: [], + }; + await this.subscriberService.create(newProfile); + + res.status(201).json({ message: 'Registration was successful' }); + } catch (error) { + res.status(500).json({ message: 'Registration failed' }); + } + } + + private async signIn(req: SocketRequest, res: SocketResponse) { + const payload = req.body as ChatUiWeb.SignInRequest; + // Validate the request body + const { error } = signInSchema.validate(payload, { abortEarly: false }); + if (error) { + return res + .status(400) + .json({ errors: error.details.map((detail) => detail.message) }); + } + const { email, password } = payload.data; + try { + // Check if user already exists + const profile = await this.subscriberService.findOne( + { + ['channel.email' as string]: email, + }, + { excludePrefixes: ['_'] }, + ); + + if (!profile) { + return res + .status(400) + .json({ message: 'Wrong credentials, try again' }); + } + + if (!compareSync(password, profile.channel.passwordHash)) { + return res + .status(400) + .json({ message: 'Wrong credentials, try again' }); + } + + // Create session + req.session.web = { + profile, + isSocket: 'isSocket' in req && !!req.isSocket, + messageQueue: [], + polling: false, + }; + + this.websocketGateway.saveSession(req.socket); + + // Join socket room when using websocket + await req.socket.join(profile.foreign_id); + + // Fetch last thread and messages + const thread = await this.threadService.findLast(profile); + + const messages = thread ? await this.fetchThreadMessages(thread) : []; + + return res.status(200).json({ profile, messages, thread }); + } catch (error) { + return res.status(500).json({ message: 'Registration failed' }); + } + } + + private async newThread(req: SocketRequest, res: SocketResponse) { + try { + const payload = req.body as Web.IncomingTextMessage; + const subscriber = req.session.web.profile.id; + return await this.threadService.create({ + title: truncate(payload.data.text), + subscriber, + }); + } catch (error) { + res.status(500).json({ message: 'Unable to start a new thread' }); + } + } + + /** + * Process incoming Web Channel data (finding out its type and assigning it to its proper handler) + * + * @param req + * @param res + */ + async handle(req: Request | SocketRequest, res: Response | SocketResponse) { + // Only handle websocket + if (!(req instanceof SocketRequest) || !(res instanceof SocketResponse)) { + return res.status(500).json({ err: 'Unexpected request!' }); + } + + // ChatUI Channel messaging can be done through websocket + try { + await this.checkRequest(req, res); + + const profile = req.session?.web?.profile; + + if (req.method === 'POST') { + const payload = req.body as ChatUiWeb.Event; + if (!profile) { + if (payload.type === ChatUiWeb.RequestType.sign_up) { + return this.signUp(req, res); + } else if (payload.type === ChatUiWeb.RequestType.sign_in) { + return this.signIn(req, res); + } + } else { + if ( + 'data' in payload && + // @ts-expect-error to be fixed + payload.type === Web.OutgoingMessageType.text && + // @ts-expect-error to be fixed + !payload.thread + ) { + const thread = await this.newThread(req, res); + // @ts-expect-error to be fixed + payload.thread = thread.id; + } + } + } + + if (profile) { + super._handleEvent(req, res); + } else { + return res + .status(401) + .json({ message: 'Unauthorized! Must be signed-in.' }); + } + } catch (err) { + this.logger.warn( + 'ChatUI Channel Handler : Something went wrong ...', + err, + ); + return res.status(403).json({ err: 'Something went wrong ...' }); + } + } + + /** + * Handles a websocket request for the web channel. + * + * @param req - The websocket request object. + * @param res - The websocket response object. + */ + @SocketGet(`/webhook/${CHATUI_CHANNEL_NAME}/`) + @SocketPost(`/webhook/${CHATUI_CHANNEL_NAME}/`) + handleWebsocketForWebChannel( + @SocketReq() req: SocketRequest, + @SocketRes() res: SocketResponse, + ) { + this.logger.log('Channel notification (Web Socket) : ', req.method); + return this.handle(req, res); + } +} diff --git a/api/src/extensions/channels/chatui/index.d.ts b/api/src/extensions/channels/chatui/index.d.ts new file mode 100644 index 000000000..50f080cf8 --- /dev/null +++ b/api/src/extensions/channels/chatui/index.d.ts @@ -0,0 +1,22 @@ +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 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 CHATUI_CHANNEL_SETTINGS, { CHATUI_CHANNEL_NAMESPACE } from './settings'; + +declare global { + interface Settings extends SettingTree {} +} + +declare module '@nestjs/event-emitter' { + interface IHookExtensionsOperationMap { + [CHATUI_CHANNEL_NAMESPACE]: TDefinition< + object, + SettingMapByType + >; + } +} diff --git a/api/src/extensions/channels/chatui/package.json b/api/src/extensions/channels/chatui/package.json new file mode 100644 index 000000000..d2adac8c3 --- /dev/null +++ b/api/src/extensions/channels/chatui/package.json @@ -0,0 +1,7 @@ +{ + "name": "hexabot-channel-chatui", + "version": "2.0.0", + "description": "The Chat UI Channel Extension for Hexabot Chatbot / Agent Builder.", + "author": "Hexastack", + "license": "AGPL-3.0-only" +} diff --git a/api/src/extensions/channels/chatui/settings.ts b/api/src/extensions/channels/chatui/settings.ts new file mode 100644 index 000000000..368940054 --- /dev/null +++ b/api/src/extensions/channels/chatui/settings.ts @@ -0,0 +1,79 @@ +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 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 { ChannelSetting } from '@/channel/types'; +import { config } from '@/config'; +import { SettingType } from '@/setting/schemas/types'; + +export const CHATUI_CHANNEL_NAME = 'chatui-channel'; + +export const CHATUI_CHANNEL_NAMESPACE = 'chatui_channel'; + +export default [ + { + group: CHATUI_CHANNEL_NAMESPACE, + label: 'display_name', + value: 'Bot', + type: SettingType.text, + }, + { + group: CHATUI_CHANNEL_NAMESPACE, + label: 'avatar', + value: null, + type: SettingType.attachment, + }, + { + group: CHATUI_CHANNEL_NAMESPACE, + label: 'allowed_domains', + value: config.frontendPath, + type: SettingType.text, + }, + { + group: CHATUI_CHANNEL_NAMESPACE, + label: 'persistent_menu', + value: true, + type: SettingType.checkbox, + }, + { + group: CHATUI_CHANNEL_NAMESPACE, + label: 'greeting_message', + value: 'Welcome! Ready to start a conversation with our chatbot?', + type: SettingType.textarea, + }, + { + group: CHATUI_CHANNEL_NAMESPACE, + label: 'show_emoji', + value: true, + type: SettingType.checkbox, + }, + { + group: CHATUI_CHANNEL_NAMESPACE, + label: 'show_file', + value: true, + type: SettingType.checkbox, + }, + { + group: CHATUI_CHANNEL_NAMESPACE, + label: 'show_location', + value: true, + type: SettingType.checkbox, + }, + { + group: CHATUI_CHANNEL_NAMESPACE, + label: 'allowed_upload_size', + value: 2500000, + type: SettingType.number, + }, + { + group: CHATUI_CHANNEL_NAMESPACE, + label: 'allowed_upload_types', + value: + 'audio/mpeg,audio/x-ms-wma,audio/vnd.rn-realaudio,audio/x-wav,image/gif,image/jpeg,image/png,image/tiff,image/vnd.microsoft.icon,image/vnd.djvu,image/svg+xml,text/css,text/csv,text/html,text/plain,text/xml,video/mpeg,video/mp4,video/quicktime,video/x-ms-wmv,video/x-msvideo,video/x-flv,video/web,application/msword,application/vnd.ms-powerpoint,application/pdf,application/vnd.ms-excel,application/vnd.oasis.opendocument.presentation,application/vnd.oasis.opendocument.tex,application/vnd.oasis.opendocument.spreadsheet,application/vnd.oasis.opendocument.graphics,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/vnd.openxmlformats-officedocument.wordprocessingml.document', + type: SettingType.textarea, + }, +] as const satisfies ChannelSetting[]; diff --git a/api/src/extensions/channels/chatui/types.ts b/api/src/extensions/channels/chatui/types.ts new file mode 100644 index 000000000..2703eecb9 --- /dev/null +++ b/api/src/extensions/channels/chatui/types.ts @@ -0,0 +1,36 @@ +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 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 { Web } from '@/extensions/channels/web/types'; + +export namespace ChatUiWeb { + export enum RequestType { + sign_up = 'sign_up', + sign_in = 'sign_in', + } + + export type SignUpRequest = { + type: RequestType.sign_up; + data: { + email: string; + password: string; + }; + }; + + export type SignInRequest = { + type: RequestType.sign_in; + data: { + email: string; + password: string; + }; + }; + + export type Request = SignUpRequest | SignInRequest; + + export type Event = Web.IncomingMessage | Web.StatusEvent | Request; +} diff --git a/api/src/extensions/channels/chatui/wrapper.ts b/api/src/extensions/channels/chatui/wrapper.ts new file mode 100644 index 000000000..bbeb64d24 --- /dev/null +++ b/api/src/extensions/channels/chatui/wrapper.ts @@ -0,0 +1,28 @@ +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 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 { ChannelName } from '@/channel/types'; +import BaseWebChannelHandler from '@/extensions/channels/web/base-web-channel'; +import { Web } from '@/extensions/channels/web/types'; +import WebEventWrapper from '@/extensions/channels/web/wrapper'; + +export default class ChaUiWebEventWrapper< + T extends + BaseWebChannelHandler = BaseWebChannelHandler, +> extends WebEventWrapper { + /** + * Constructor : channel's event wrapper + * + * @param handler - The channel's handler + * @param event - The message event received + * @param channelData - Channel's specific extra data {isSocket, ipAddress} + */ + constructor(handler: T, event: Web.Event, channelData: any) { + super(handler, event, channelData); + } +} diff --git a/api/src/utils/helpers/misc.ts b/api/src/utils/helpers/misc.ts index be1aafb4f..cad4b8166 100644 --- a/api/src/utils/helpers/misc.ts +++ b/api/src/utils/helpers/misc.ts @@ -13,3 +13,10 @@ export const isEmpty = (value: string): boolean => { export const hyphenToUnderscore = (str: string) => { return str.replaceAll('-', '_'); }; + +export const truncate = (str: string, length = 32) => { + if (str.length <= length) { + return str; + } + return str.slice(0, length) + '...'; +};