Skip to content

Commit f7170a7

Browse files
committed
feat: chat ui channel (partial)
1 parent 4d30aa3 commit f7170a7

File tree

12 files changed

+521
-0
lines changed

12 files changed

+521
-0
lines changed

api/src/chat/schemas/types/channel.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ interface BaseChannelData {
1212
name: ChannelName; // channel name
1313
isSocket?: boolean;
1414
type?: any; //TODO: type has to be checked
15+
email?: string;
16+
passwordHash?: string;
1517
}
1618

1719
export type ChannelData = BaseChannelData;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"display_name": "Display Name",
3+
"avatar": "Avatar",
4+
"allowed_domains": "Allowed Domains",
5+
"persistent_menu": "Display Persistent Menu",
6+
"greeting_message": "Greeting Message",
7+
"show_emoji": "Enable Emoji Picker",
8+
"show_file": "Enable Attachment Uploader",
9+
"show_location": "Enable Geolocation Share",
10+
"allowed_upload_size": "Max Upload Size (in bytes)",
11+
"allowed_upload_types": "Allowed Upload Mime Types (comma separated)"
12+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"chatui_channel": "Chat UI"
3+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"display_name": "Nom d'affichage",
3+
"avatar": "Avatar",
4+
"allowed_domains": "Domaines autorisés",
5+
"persistent_menu": "Afficher le menu persistent",
6+
"greeting_message": "Message de bienvenue",
7+
"show_emoji": "Activer le sélecteur d'Emojis",
8+
"show_file": "Activer l'upload de fichiers",
9+
"show_location": "Activer le partage de géolocalisation",
10+
"allowed_upload_size": "Taille maximale de téléchargement (en octets)",
11+
"allowed_upload_types": "Types MIME autorisés pour le téléchargement (séparés par des virgules)"
12+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"chatui_channel": "Chat UI"
3+
}
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
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+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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 CHATUI_CHANNEL_SETTINGS, { CHATUI_CHANNEL_NAMESPACE } from './settings';
10+
11+
declare global {
12+
interface Settings extends SettingTree<typeof CHATUI_CHANNEL_SETTINGS> {}
13+
}
14+
15+
declare module '@nestjs/event-emitter' {
16+
interface IHookExtensionsOperationMap {
17+
[CHATUI_CHANNEL_NAMESPACE]: TDefinition<
18+
object,
19+
SettingMapByType<typeof CHATUI_CHANNEL_SETTINGS>
20+
>;
21+
}
22+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"name": "hexabot-channel-chatui",
3+
"version": "2.0.0",
4+
"description": "The Chat UI Channel Extension for Hexabot Chatbot / Agent Builder.",
5+
"author": "Hexastack",
6+
"license": "AGPL-3.0-only"
7+
}

0 commit comments

Comments
 (0)