|
| 1 | +/* |
| 2 | + * Copyright © 2024 Hexastack. All rights reserved. |
| 3 | + * |
| 4 | + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: |
| 5 | + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. |
| 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). |
| 7 | + */ |
| 8 | + |
| 9 | +import { Injectable } from '@nestjs/common'; |
| 10 | +import { EventEmitter2 } from '@nestjs/event-emitter'; |
| 11 | +import { compareSync } from 'bcryptjs'; |
| 12 | +import { Request, Response } from 'express'; |
| 13 | +import Joi from 'joi'; |
| 14 | + |
| 15 | +import { AttachmentService } from '@/attachment/services/attachment.service'; |
| 16 | +import { ChannelService } from '@/channel/channel.service'; |
| 17 | +import { ChannelName } from '@/channel/types'; |
| 18 | +import { SubscriberCreateDto } from '@/chat/dto/subscriber.dto'; |
| 19 | +import { Thread } from '@/chat/schemas/thread.schema'; |
| 20 | +import { MessageService } from '@/chat/services/message.service'; |
| 21 | +import { SubscriberService } from '@/chat/services/subscriber.service'; |
| 22 | +import { ThreadService } from '@/chat/services/thread.service'; |
| 23 | +import { MenuService } from '@/cms/services/menu.service'; |
| 24 | +import BaseWebChannelHandler from '@/extensions/channels/web/base-web-channel'; |
| 25 | +import { Web } from '@/extensions/channels/web/types'; |
| 26 | +import { I18nService } from '@/i18n/services/i18n.service'; |
| 27 | +import { LoggerService } from '@/logger/logger.service'; |
| 28 | +import { SettingService } from '@/setting/services/setting.service'; |
| 29 | +import { hash } from '@/user/utilities/bcryptjs'; |
| 30 | +import { truncate } from '@/utils/helpers/misc'; |
| 31 | +import { |
| 32 | + SocketGet, |
| 33 | + SocketPost, |
| 34 | +} from '@/websocket/decorators/socket-method.decorator'; |
| 35 | +import { SocketReq } from '@/websocket/decorators/socket-req.decorator'; |
| 36 | +import { SocketRes } from '@/websocket/decorators/socket-res.decorator'; |
| 37 | +import { SocketRequest } from '@/websocket/utils/socket-request'; |
| 38 | +import { SocketResponse } from '@/websocket/utils/socket-response'; |
| 39 | +import { WebsocketGateway } from '@/websocket/websocket.gateway'; |
| 40 | + |
| 41 | +import { CHATUI_CHANNEL_NAME } from './settings'; |
| 42 | +import { ChatUiWeb } from './types'; |
| 43 | + |
| 44 | +// Joi schema for validation |
| 45 | +const signUpSchema = Joi.object({ |
| 46 | + type: Joi.string().equal('sign_up'), |
| 47 | + data: Joi.object({ |
| 48 | + email: Joi.string().email().required().messages({ |
| 49 | + 'string.email': 'Invalid email address', |
| 50 | + 'any.required': 'Email is required', |
| 51 | + }), |
| 52 | + password: Joi.string().min(8).required().messages({ |
| 53 | + 'string.min': 'Password must be at least 8 characters long', |
| 54 | + 'any.required': 'Password is required', |
| 55 | + }), |
| 56 | + }), |
| 57 | +}); |
| 58 | + |
| 59 | +const signInSchema = Joi.object({ |
| 60 | + type: Joi.string().equal('sign_in'), |
| 61 | + data: Joi.object({ |
| 62 | + email: Joi.string().email().required().messages({ |
| 63 | + 'string.email': 'Invalid email address', |
| 64 | + 'any.required': 'Email is required', |
| 65 | + }), |
| 66 | + password: Joi.string().required().messages({ |
| 67 | + 'any.required': 'Password is required', |
| 68 | + }), |
| 69 | + }), |
| 70 | +}); |
| 71 | + |
| 72 | +@Injectable() |
| 73 | +export default class ChatUiChannelHandler extends BaseWebChannelHandler< |
| 74 | + typeof CHATUI_CHANNEL_NAME |
| 75 | +> { |
| 76 | + constructor( |
| 77 | + settingService: SettingService, |
| 78 | + channelService: ChannelService, |
| 79 | + logger: LoggerService, |
| 80 | + eventEmitter: EventEmitter2, |
| 81 | + i18n: I18nService, |
| 82 | + subscriberService: SubscriberService, |
| 83 | + attachmentService: AttachmentService, |
| 84 | + messageService: MessageService, |
| 85 | + menuService: MenuService, |
| 86 | + websocketGateway: WebsocketGateway, |
| 87 | + private readonly threadService: ThreadService, |
| 88 | + ) { |
| 89 | + super( |
| 90 | + CHATUI_CHANNEL_NAME, |
| 91 | + settingService, |
| 92 | + channelService, |
| 93 | + logger, |
| 94 | + eventEmitter, |
| 95 | + i18n, |
| 96 | + subscriberService, |
| 97 | + attachmentService, |
| 98 | + messageService, |
| 99 | + menuService, |
| 100 | + websocketGateway, |
| 101 | + ); |
| 102 | + } |
| 103 | + |
| 104 | + getPath(): string { |
| 105 | + return __dirname; |
| 106 | + } |
| 107 | + |
| 108 | + /** |
| 109 | + * Fetches all the messages of a given thread. |
| 110 | + * |
| 111 | + * @param req - Socket request |
| 112 | + * @returns Promise to an array of messages, rejects into error. |
| 113 | + */ |
| 114 | + private async fetchThreadMessages(thread: Thread): Promise<Web.Message[]> { |
| 115 | + const messages = await this.messageService.findByThread(thread); |
| 116 | + return this.formatMessages(messages); |
| 117 | + } |
| 118 | + |
| 119 | + private async signUp(req: SocketRequest, res: SocketResponse) { |
| 120 | + const payload = req.body as ChatUiWeb.SignUpRequest; |
| 121 | + // Validate the request body |
| 122 | + const { error } = signUpSchema.validate(payload, { abortEarly: false }); |
| 123 | + if (error) { |
| 124 | + return res |
| 125 | + .status(400) |
| 126 | + .json({ errors: error.details.map((detail) => detail.message) }); |
| 127 | + } |
| 128 | + |
| 129 | + try { |
| 130 | + const { email, password } = payload.data; |
| 131 | + // Check if user already exists |
| 132 | + const existingUser = await this.subscriberService.findOne({ |
| 133 | + ['channel.email' as string]: email, |
| 134 | + }); |
| 135 | + if (existingUser) { |
| 136 | + return res.status(400).json({ message: 'Email is already in use' }); |
| 137 | + } |
| 138 | + |
| 139 | + // Create new user |
| 140 | + const channelData = this.getChannelData(req); |
| 141 | + const newProfile: SubscriberCreateDto = { |
| 142 | + foreign_id: this.generateId(), |
| 143 | + first_name: 'Anon.', |
| 144 | + last_name: 'Chat UI User', |
| 145 | + assignedTo: null, |
| 146 | + assignedAt: null, |
| 147 | + lastvisit: new Date(), |
| 148 | + retainedFrom: new Date(), |
| 149 | + channel: { |
| 150 | + ...channelData, |
| 151 | + name: this.getName() as ChannelName, |
| 152 | + email, |
| 153 | + passwordHash: password ? hash(password) : undefined, |
| 154 | + }, |
| 155 | + language: '', |
| 156 | + locale: '', |
| 157 | + timezone: 0, |
| 158 | + gender: 'male', |
| 159 | + country: '', |
| 160 | + labels: [], |
| 161 | + }; |
| 162 | + await this.subscriberService.create(newProfile); |
| 163 | + |
| 164 | + res.status(201).json({ message: 'Registration was successful' }); |
| 165 | + } catch (error) { |
| 166 | + res.status(500).json({ message: 'Registration failed' }); |
| 167 | + } |
| 168 | + } |
| 169 | + |
| 170 | + private async signIn(req: SocketRequest, res: SocketResponse) { |
| 171 | + const payload = req.body as ChatUiWeb.SignInRequest; |
| 172 | + // Validate the request body |
| 173 | + const { error } = signInSchema.validate(payload, { abortEarly: false }); |
| 174 | + if (error) { |
| 175 | + return res |
| 176 | + .status(400) |
| 177 | + .json({ errors: error.details.map((detail) => detail.message) }); |
| 178 | + } |
| 179 | + const { email, password } = payload.data; |
| 180 | + try { |
| 181 | + // Check if user already exists |
| 182 | + const profile = await this.subscriberService.findOne( |
| 183 | + { |
| 184 | + ['channel.email' as string]: email, |
| 185 | + }, |
| 186 | + { excludePrefixes: ['_'] }, |
| 187 | + ); |
| 188 | + |
| 189 | + if (!profile) { |
| 190 | + return res |
| 191 | + .status(400) |
| 192 | + .json({ message: 'Wrong credentials, try again' }); |
| 193 | + } |
| 194 | + |
| 195 | + if (!compareSync(password, profile.channel.passwordHash)) { |
| 196 | + return res |
| 197 | + .status(400) |
| 198 | + .json({ message: 'Wrong credentials, try again' }); |
| 199 | + } |
| 200 | + |
| 201 | + // Create session |
| 202 | + req.session.web = { |
| 203 | + profile, |
| 204 | + isSocket: 'isSocket' in req && !!req.isSocket, |
| 205 | + messageQueue: [], |
| 206 | + polling: false, |
| 207 | + }; |
| 208 | + |
| 209 | + this.websocketGateway.saveSession(req.socket); |
| 210 | + |
| 211 | + // Join socket room when using websocket |
| 212 | + await req.socket.join(profile.foreign_id); |
| 213 | + |
| 214 | + // Fetch last thread and messages |
| 215 | + const thread = await this.threadService.findLast(profile); |
| 216 | + |
| 217 | + const messages = thread ? await this.fetchThreadMessages(thread) : []; |
| 218 | + |
| 219 | + return res.status(200).json({ profile, messages, thread }); |
| 220 | + } catch (error) { |
| 221 | + return res.status(500).json({ message: 'Registration failed' }); |
| 222 | + } |
| 223 | + } |
| 224 | + |
| 225 | + private async newThread(req: SocketRequest, res: SocketResponse) { |
| 226 | + try { |
| 227 | + const payload = req.body as Web.IncomingTextMessage; |
| 228 | + const subscriber = req.session.web.profile.id; |
| 229 | + return await this.threadService.create({ |
| 230 | + title: truncate(payload.data.text), |
| 231 | + subscriber, |
| 232 | + }); |
| 233 | + } catch (error) { |
| 234 | + res.status(500).json({ message: 'Unable to start a new thread' }); |
| 235 | + } |
| 236 | + } |
| 237 | + |
| 238 | + /** |
| 239 | + * Process incoming Web Channel data (finding out its type and assigning it to its proper handler) |
| 240 | + * |
| 241 | + * @param req |
| 242 | + * @param res |
| 243 | + */ |
| 244 | + async handle(req: Request | SocketRequest, res: Response | SocketResponse) { |
| 245 | + // Only handle websocket |
| 246 | + if (!(req instanceof SocketRequest) || !(res instanceof SocketResponse)) { |
| 247 | + return res.status(500).json({ err: 'Unexpected request!' }); |
| 248 | + } |
| 249 | + |
| 250 | + // ChatUI Channel messaging can be done through websocket |
| 251 | + try { |
| 252 | + await this.checkRequest(req, res); |
| 253 | + |
| 254 | + const profile = req.session?.web?.profile; |
| 255 | + |
| 256 | + if (req.method === 'POST') { |
| 257 | + const payload = req.body as ChatUiWeb.Event; |
| 258 | + if (!profile) { |
| 259 | + if (payload.type === ChatUiWeb.RequestType.sign_up) { |
| 260 | + return this.signUp(req, res); |
| 261 | + } else if (payload.type === ChatUiWeb.RequestType.sign_in) { |
| 262 | + return this.signIn(req, res); |
| 263 | + } |
| 264 | + } else { |
| 265 | + if ( |
| 266 | + 'data' in payload && |
| 267 | + // @ts-expect-error to be fixed |
| 268 | + payload.type === Web.OutgoingMessageType.text && |
| 269 | + // @ts-expect-error to be fixed |
| 270 | + !payload.thread |
| 271 | + ) { |
| 272 | + const thread = await this.newThread(req, res); |
| 273 | + // @ts-expect-error to be fixed |
| 274 | + payload.thread = thread.id; |
| 275 | + } |
| 276 | + } |
| 277 | + } |
| 278 | + |
| 279 | + if (profile) { |
| 280 | + super._handleEvent(req, res); |
| 281 | + } else { |
| 282 | + return res |
| 283 | + .status(401) |
| 284 | + .json({ message: 'Unauthorized! Must be signed-in.' }); |
| 285 | + } |
| 286 | + } catch (err) { |
| 287 | + this.logger.warn( |
| 288 | + 'ChatUI Channel Handler : Something went wrong ...', |
| 289 | + err, |
| 290 | + ); |
| 291 | + return res.status(403).json({ err: 'Something went wrong ...' }); |
| 292 | + } |
| 293 | + } |
| 294 | + |
| 295 | + /** |
| 296 | + * Handles a websocket request for the web channel. |
| 297 | + * |
| 298 | + * @param req - The websocket request object. |
| 299 | + * @param res - The websocket response object. |
| 300 | + */ |
| 301 | + @SocketGet(`/webhook/${CHATUI_CHANNEL_NAME}/`) |
| 302 | + @SocketPost(`/webhook/${CHATUI_CHANNEL_NAME}/`) |
| 303 | + handleWebsocketForWebChannel( |
| 304 | + @SocketReq() req: SocketRequest, |
| 305 | + @SocketRes() res: SocketResponse, |
| 306 | + ) { |
| 307 | + this.logger.log('Channel notification (Web Socket) : ', req.method); |
| 308 | + return this.handle(req, res); |
| 309 | + } |
| 310 | +} |
0 commit comments