-
Notifications
You must be signed in to change notification settings - Fork 120
/
Copy pathbase-web-channel.ts
1350 lines (1261 loc) · 41.5 KB
/
base-web-channel.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
* Copyright © 2025 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 { BadRequestException, Injectable } from '@nestjs/common';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import bodyParser from 'body-parser';
import { NextFunction, Request, Response } from 'express';
import multer, { diskStorage, memoryStorage } from 'multer';
import { Socket } from 'socket.io';
import { v4 as uuidv4 } from 'uuid';
import { Attachment } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import {
AttachmentAccess,
AttachmentCreatedByRef,
AttachmentResourceRef,
} from '@/attachment/types';
import { ChannelService } from '@/channel/channel.service';
import ChannelHandler from '@/channel/lib/Handler';
import { ChannelName } from '@/channel/types';
import { MessageCreateDto } from '@/chat/dto/message.dto';
import { SubscriberCreateDto } from '@/chat/dto/subscriber.dto';
import { VIEW_MORE_PAYLOAD } from '@/chat/helpers/constants';
import { Subscriber, SubscriberFull } from '@/chat/schemas/subscriber.schema';
import { AttachmentRef } from '@/chat/schemas/types/attachment';
import { Button, ButtonType } from '@/chat/schemas/types/button';
import {
AnyMessage,
ContentElement,
IncomingMessage,
IncomingMessageType,
OutgoingMessage,
OutgoingMessageFormat,
PayloadType,
StdEventType,
StdOutgoingAttachmentMessage,
StdOutgoingButtonsMessage,
StdOutgoingEnvelope,
StdOutgoingListMessage,
StdOutgoingMessage,
StdOutgoingQuickRepliesMessage,
StdOutgoingTextMessage,
} from '@/chat/schemas/types/message';
import { BlockOptions } from '@/chat/schemas/types/options';
import { MessageService } from '@/chat/services/message.service';
import { SubscriberService } from '@/chat/services/subscriber.service';
import { Content } from '@/cms/schemas/content.schema';
import { MenuService } from '@/cms/services/menu.service';
import { config } from '@/config';
import { I18nService } from '@/i18n/services/i18n.service';
import { LoggerService } from '@/logger/logger.service';
import { SettingService } from '@/setting/services/setting.service';
import { SocketRequest } from '@/websocket/utils/socket-request';
import { SocketResponse } from '@/websocket/utils/socket-response';
import { WebsocketGateway } from '@/websocket/websocket.gateway';
import { WEB_CHANNEL_NAME, WEB_CHANNEL_NAMESPACE } from './settings';
import { Web } from './types';
import WebEventWrapper from './wrapper';
// Handle multipart uploads (Long Pooling only)
const upload = multer({
limits: {
fileSize: config.parameters.maxUploadSize,
},
storage: (() => {
if (config.parameters.storageMode === 'memory') {
return memoryStorage();
} else {
return diskStorage({});
}
})(),
}).single('file'); // 'file' is the field name in the form
@Injectable()
export default abstract class BaseWebChannelHandler<
N extends ChannelName,
> extends ChannelHandler<N> {
constructor(
name: N,
settingService: SettingService,
channelService: ChannelService,
logger: LoggerService,
protected readonly eventEmitter: EventEmitter2,
protected readonly i18n: I18nService,
protected readonly subscriberService: SubscriberService,
public readonly attachmentService: AttachmentService,
protected readonly messageService: MessageService,
protected readonly menuService: MenuService,
protected readonly websocketGateway: WebsocketGateway,
) {
super(name, settingService, channelService, logger);
}
/**
* No init needed for the moment
*
* @returns -
*/
init(): void {
this.logger.debug('initialization ...');
}
/**
* Verify web websocket connection and return settings
*
* @param client - The socket client
*/
@OnEvent('hook:websocket:connection', { async: true })
async onWebSocketConnection(client: Socket) {
try {
const settings = await this.getSettings();
const handshake = client.handshake;
const { channel } = handshake.query;
if (channel !== this.getName()) {
return;
}
this.logger.debug('WS connected .. sending settings');
try {
const menu = await this.menuService.getTree();
return client.emit('settings', { menu, ...settings });
} catch (err) {
this.logger.warn('Unable to retrieve menu ', err);
return client.emit('settings', settings);
}
} catch (err) {
this.logger.error('Unable to initiate websocket connection', err);
client.disconnect();
}
}
/**
* Adapt incoming message structure for web channel
*
* @param incoming - Incoming message
* @returns Formatted web message
*/
private async formatIncomingHistoryMessage(
incoming: IncomingMessage,
): Promise<Web.IncomingMessageBase> {
// Format incoming message
if ('type' in incoming.message) {
if (incoming.message.type === PayloadType.location) {
const coordinates = incoming.message.coordinates;
return {
type: Web.IncomingMessageType.location,
data: {
coordinates: {
lat: coordinates.lat,
lng: coordinates.lon,
},
},
};
} else {
// @TODO : handle multiple files
const attachmentPayload = Array.isArray(incoming.message.attachment)
? incoming.message.attachment[0]
: incoming.message.attachment;
return {
type: Web.IncomingMessageType.file,
data: {
type: attachmentPayload.type,
url: await this.getPublicUrl(attachmentPayload.payload),
},
};
}
} else {
return {
type: Web.IncomingMessageType.text,
data: incoming.message,
};
}
}
/**
* Adapt the outgoing message structure for web channel
*
* @param outgoing - The outgoing message
* @returns Formatted web message
*/
private async formatOutgoingHistoryMessage(
outgoing: OutgoingMessage,
): Promise<Web.OutgoingMessageBase> {
// Format outgoing message
if ('buttons' in outgoing.message) {
return this._buttonsFormat(outgoing.message);
} else if ('attachment' in outgoing.message) {
return this._attachmentFormat(outgoing.message);
} else if ('quickReplies' in outgoing.message) {
return this._quickRepliesFormat(outgoing.message);
} else if ('options' in outgoing.message) {
if (outgoing.message.options.display === 'carousel') {
return await this._carouselFormat(outgoing.message, {
content: outgoing.message.options,
});
} else {
return await this._listFormat(outgoing.message, {
content: outgoing.message.options,
});
}
} else {
return this._textFormat(outgoing.message);
}
}
/**
* Checks if a given message is an IncomingMessage
*
* @param message Any type of message
* @returns True, if it's a incoming message
*/
private isIncomingMessage(message: AnyMessage): message is IncomingMessage {
return 'sender' in message && !!message.sender;
}
/**
* Adapt the message structure for web channel
*
* @param messages - The messages to be formatted
*
* @returns Formatted message
*/
protected async formatMessages(
messages: AnyMessage[],
): Promise<Web.Message[]> {
const formattedMessages: Web.Message[] = [];
for (const anyMessage of messages) {
if (this.isIncomingMessage(anyMessage)) {
const message = await this.formatIncomingHistoryMessage(anyMessage);
formattedMessages.push({
...message,
author: anyMessage.sender,
read: true, // Temporary fix as read is false in the bd
mid: anyMessage.mid?.[0],
createdAt: anyMessage.createdAt,
});
} else {
const message = await this.formatOutgoingHistoryMessage(anyMessage);
formattedMessages.push({
...message,
author: 'chatbot',
read: true, // Temporary fix as read is false in the bd
mid: anyMessage.mid?.[0] || this.generateId(),
handover: !!anyMessage.handover,
createdAt: anyMessage.createdAt,
});
}
}
return formattedMessages;
}
/**
* Fetches the messaging history from the DB.
*
* @param until - Date before which to fetch
* @param n - Number of messages to fetch
* @returns Promise to an array of message, rejects into error.
* Promise to fetch the 'n' last message since a giving date the session profile.
*/
protected async fetchHistory(
req: Request | SocketRequest,
until: Date = new Date(),
n: number = 30,
): Promise<Web.Message[]> {
const profile = req.session?.web?.profile;
if (profile) {
const messages = await this.messageService.findHistoryUntilDate(
profile,
until,
n,
);
return await this.formatMessages(messages.reverse());
}
return [];
}
/**
* Poll new messages by a giving start datetime
*
* @param since - Date after which to fetch
* @param n - Number of messages to fetch
* @returns Promise to an array of message, rejects into error.
* Promise to fetch the 'n' new messages since a giving date for the session profile.
*/
private async pollMessages(
req: Request,
since: Date = new Date(10e14),
n: number = 30,
): Promise<Web.Message[]> {
const profile = req.session?.web?.profile;
if (profile) {
const messages = await this.messageService.findHistorySinceDate(
profile,
since,
n,
);
return await this.formatMessages(messages);
}
return [];
}
/**
* Verify the origin against whitelisted domains.
*
* @param req
* @param res
*/
private async validateCors(
req: Request | SocketRequest,
res: Response | SocketResponse,
) {
const settings = await this.getSettings<typeof WEB_CHANNEL_NAMESPACE>();
// Check if we have an origin header...
if (!req.headers?.origin) {
this.logger.debug('No origin ', req.headers);
throw new Error('CORS - No origin provided!');
}
const originUrl = new URL(req.headers.origin);
const allowedProtocols = new Set(['http:', 'https:']);
if (!allowedProtocols.has(originUrl.protocol)) {
throw new Error('CORS - Invalid origin!');
}
// Get the allowed origins
const origins: string[] = settings.allowed_domains.split(',');
const foundOrigin = origins
.map((origin) => {
try {
return new URL(origin.trim()).origin;
} catch (error) {
this.logger.error(`Invalid URL in allowed domains: ${origin}`, error);
return null;
}
})
.filter(
(normalizedOrigin): normalizedOrigin is string =>
normalizedOrigin !== null,
)
.some((origin: string) => {
// If we find a whitelisted origin, send the Access-Control-Allow-Origin header
// to greenlight the request.
return origin === originUrl.origin;
});
if (!foundOrigin && !origins.includes('*')) {
// For HTTP requests, set the Access-Control-Allow-Origin header to '', which the browser will
// interpret as, 'no way Jose.'
res.set('Access-Control-Allow-Origin', '');
this.logger.debug('No origin found ', req.headers.origin);
throw new Error('CORS - Domain not allowed!');
} else {
res.set('Access-Control-Allow-Origin', originUrl.origin);
}
// Determine whether or not to allow cookies to be passed cross-origin
res.set('Access-Control-Allow-Credentials', 'true');
// This header lets a server whitelist headers that browsers are allowed to access
res.set('Access-Control-Expose-Headers', '');
// Handle preflight requests
if (req.method == 'OPTIONS') {
res.set('Access-Control-Allow-Methods', 'GET, POST');
res.set('Access-Control-Allow-Headers', 'content-type');
}
}
/**
* Makes sure that message request is legitimate.
*
* @param req
* @param res
*/
private validateSession(
req: Request | SocketRequest,
res: Response | SocketResponse,
next: (profile: Subscriber) => void,
) {
if (!req.session?.web?.profile?.id) {
this.logger.warn('No session ID to be found!', req.session);
return res
.status(403)
.json({ err: 'Web Channel Handler : Unauthorized!' });
} else if (
(this.isSocketRequest(req) &&
!!req.isSocket !== req.session.web.isSocket) ||
!Array.isArray(req.session.web.messageQueue)
) {
this.logger.warn(
'Mixed channel request or invalid session data!',
req.session,
);
return res
.status(403)
.json({ err: 'Web Channel Handler : Unauthorized!' });
}
next(req.session?.web?.profile);
}
/**
* Perform all security measures on the request
*
* @param req
* @param res
*/
protected async checkRequest(
req: Request | SocketRequest,
res: Response | SocketResponse,
) {
try {
await this.validateCors(req, res);
} catch (err) {
this.logger.warn('Attempt to access from an unauthorized origin', err);
throw new Error('Unauthorized, invalid origin !');
}
}
/**
* Get or create a session profile for the subscriber
*
* @param req
*
* @returns Subscriber's profile
*/
protected async getOrCreateSession(
req: Request | SocketRequest,
): Promise<SubscriberFull> {
const data = req.query;
// Subscriber has already a session
const sessionProfile = req.session?.web?.profile;
if (sessionProfile) {
const subscriber = await this.subscriberService.findOneAndPopulate(
sessionProfile.id,
);
if (!subscriber || !req.session.web) {
throw new Error('Subscriber session was not persisted in DB');
}
req.session.web.profile = subscriber;
return subscriber;
}
const newProfile: SubscriberCreateDto = {
foreign_id: this.generateId(),
first_name: data.first_name ? data.first_name.toString() : 'Anon.',
last_name: data.last_name ? data.last_name.toString() : 'Web User',
assignedTo: null,
assignedAt: null,
lastvisit: new Date(),
retainedFrom: new Date(),
channel: {
name: this.getName(),
...this.getChannelAttributes(req),
},
language: '',
locale: '',
timezone: 0,
gender: 'male',
country: '',
labels: [],
};
const subscriber = await this.subscriberService.create(newProfile);
// Init session
const profile: SubscriberFull = {
...subscriber,
labels: [],
assignedTo: null,
avatar: null,
};
req.session.web = {
profile,
isSocket: this.isSocketRequest(req),
messageQueue: [],
polling: false,
};
return profile;
}
/**
* Return message queue (using by long polling case only)
*
* @param req HTTP Express Request
* @param res HTTP Express Response
*/
private getMessageQueue(req: Request, res: Response) {
// Polling not authorized when using websockets
if (this.isSocketRequest(req)) {
this.logger.warn('Polling not authorized when using websockets');
return res
.status(403)
.json({ err: 'Polling not authorized when using websockets' });
}
// Session must be active
if (!(req.session && req.session.web && req.session.web.profile.id)) {
this.logger.warn('Must be connected to poll messages');
return res
.status(403)
.json({ err: 'Polling not authorized : Must be connected' });
}
// Can only request polling once at a time
if (req.session && req.session.web && req.session.web.polling) {
this.logger.warn('Poll rejected ... already requested');
return res
.status(403)
.json({ err: 'Poll rejected ... already requested' });
}
req.session.web.polling = true;
const fetchMessages = async (req: Request, res: Response, retrials = 1) => {
try {
if (!req.query.since)
throw new BadRequestException(`QueryParam 'since' is missing`);
const since = new Date(req.query.since.toString());
const messages = await this.pollMessages(req, since);
if (messages.length === 0 && retrials <= 5) {
// No messages found, retry after 5 sec
setTimeout(async () => {
await fetchMessages(req, res, retrials * 2);
}, retrials * 1000);
} else if (req.session.web) {
req.session.web.polling = false;
return res.status(200).json(messages.map((msg) => ['message', msg]));
} else {
this.logger.error('Polling failed .. no session data');
return res.status(500).json({ err: 'No session data' });
}
} catch (err) {
if (req.session.web) {
req.session.web.polling = false;
}
this.logger.error('Polling failed', err);
return res.status(500).json({ err: 'Polling failed' });
}
};
fetchMessages(req, res);
}
/**
* Allow the subscription to a web's webhook after verification
*
* @param req
* @param res
*/
protected async subscribe(
req: Request | SocketRequest,
res: Response | SocketResponse,
) {
this.logger.debug('subscribe (isSocket=' + this.isSocketRequest(req) + ')');
try {
const profile = await this.getOrCreateSession(req);
// Join socket room when using websocket
if (this.isSocketRequest(req)) {
try {
await req.socket.join(profile.foreign_id);
} catch (err) {
this.logger.error('Unable to subscribe via websocket', err);
}
}
// Fetch message history
const criteria =
'since' in req.query
? req.query.since // Long polling case
: req.body?.since || undefined; // Websocket case
const messages = await this.fetchHistory(req, criteria);
return res.status(200).json({ profile, messages });
} catch (err) {
this.logger.warn('Unable to subscribe ', err);
return res.status(500).json({ err: 'Unable to subscribe' });
}
}
/**
* Handle upload via WebSocket
*
* @returns The stored attachment or null
*/
async handleWsUpload(req: SocketRequest): Promise<Attachment | null> {
try {
const { type, data } = req.body as Web.IncomingMessage;
if (!req.session?.web?.profile?.id) {
this.logger.debug('No session');
return null;
}
// Check if any file is provided
if (type !== 'file' || !('file' in data) || !data.file) {
this.logger.debug('No files provided');
return null;
}
const size = Buffer.byteLength(data.file);
if (size > config.parameters.maxUploadSize) {
throw new Error('Max upload size has been exceeded');
}
return await this.attachmentService.store(data.file, {
name: data.name,
size: Buffer.byteLength(data.file),
type: data.type,
resourceRef: AttachmentResourceRef.MessageAttachment,
access: AttachmentAccess.Private,
createdByRef: AttachmentCreatedByRef.Subscriber,
createdBy: req.session?.web?.profile?.id,
});
} catch (err) {
this.logger.error('Unable to store uploaded file', err);
throw new Error('Unable to upload file!');
}
}
/**
* Handle multipart/form-data upload
*
* @returns The stored attachment or null
*/
async handleWebUpload(
req: Request,
_res: Response,
): Promise<Attachment | null | undefined> {
try {
// Check if any file is provided
if (!req.file) {
this.logger.debug('No files provided');
return null;
}
return await this.attachmentService.store(req.file, {
name: req.file.originalname,
size: req.file.size,
type: req.file.mimetype,
resourceRef: AttachmentResourceRef.MessageAttachment,
access: AttachmentAccess.Private,
createdByRef: AttachmentCreatedByRef.Subscriber,
createdBy: req.session.web.profile?.id,
});
} catch (err) {
this.logger.error('Unable to store uploaded file', err);
throw err;
}
}
/**
* Upload file as attachment if provided
*
* @param req Either a HTTP Express request or a WS request (Synthetic Object)
* @param res Either a HTTP Express response or a WS response (Synthetic Object)
* @param next Callback Function
*/
async handleUpload(
req: Request | SocketRequest,
res: Response | SocketResponse,
): Promise<Attachment | null | undefined> {
// Check if any file is provided
if (!req.session.web) {
this.logger.debug('No session provided');
return null;
}
if (this.isSocketRequest(req)) {
return this.handleWsUpload(req);
} else {
return this.handleWebUpload(req, res as Response);
}
}
/**
* Returns the request client IP address
*
* @param req Either a HTTP request or a WS Request (Synthetic object)
*
* @returns IP Address
*/
protected getIpAddress(req: Request | SocketRequest): string {
if (this.isSocketRequest(req)) {
return req.socket.handshake.address;
} else if (Array.isArray(req.ips) && req.ips.length > 0) {
// If config.http.trustProxy is enabled, this variable contains the IP addresses
// in this request's "X-Forwarded-For" header as an array of the IP address strings.
// Otherwise an empty array is returned.
return req.ips.join(',');
} else {
return req.ip || '0.0.0.0';
}
}
/**
* Return subscriber channel specific attributes
*
* @param req Either a HTTP Express request or a WS request (Synthetic Object)
*
* @returns The subscriber channel's attributes
*/
getChannelAttributes(
req: Request | SocketRequest,
): SubscriberChannelDict[typeof WEB_CHANNEL_NAME] {
return {
isSocket: this.isSocketRequest(req),
ipAddress: this.getIpAddress(req),
agent: req.headers['user-agent'] || 'browser',
};
}
/**
* Handle channel event (probably a message)
*
* @param req Either a HTTP Express request or a WS request (Synthetic Object)
* @param res Either a HTTP Express response or a WS response (Synthetic Object)
*/
_handleEvent(
req: Request | SocketRequest,
res: Response | SocketResponse,
): void {
// @TODO: perform payload validation
if (!req.body) {
this.logger.debug('Empty body');
res.status(400).json({ err: 'Web Channel Handler : Bad Request!' });
return;
} else {
// Parse json form data (in case of content-type multipart/form-data)
req.body.data =
typeof req.body.data === 'string'
? JSON.parse(req.body.data)
: req.body.data;
}
this.validateSession(req, res, async (profile) => {
// Set data in file upload case
const body: Web.IncomingMessage = req.body;
const channelAttrs = this.getChannelAttributes(req);
const event = new WebEventWrapper<N>(this, body, channelAttrs);
if (event._adapter.eventType === StdEventType.message) {
// Handle upload when files are provided
if (event._adapter.messageType === IncomingMessageType.attachments) {
try {
const attachment = await this.handleUpload(req, res);
if (attachment) {
event._adapter.attachment = attachment;
event._adapter.raw.data = {
type: Attachment.getTypeByMime(attachment.type),
url: await this.getPublicUrl(attachment),
};
}
} catch (err) {
this.logger.warn('Unable to upload file ', err);
return res
.status(403)
.json({ err: 'Web Channel Handler : File upload failed!' });
}
}
// Handler sync message sent by chabbot
if (body.sync && body.author === 'chatbot') {
const sentMessage: MessageCreateDto = {
mid: event.getId(),
message: event.getMessage() as StdOutgoingMessage,
recipient: profile.id,
read: true,
delivery: true,
};
this.eventEmitter.emit('hook:chatbot:sent', sentMessage, event);
return res.status(200).json(event._adapter.raw);
} else {
// Generate unique ID and handle message
event._adapter.raw.mid = this.generateId();
// Force author id from session
event._adapter.raw.author = profile.foreign_id;
}
}
event.setSender(profile);
const type = event.getEventType();
if (type) {
this.eventEmitter.emit(`hook:chatbot:${type}`, event);
} else {
this.logger.error('Webhook received unknown event ', event);
}
res.status(200).json(event._adapter.raw);
});
}
/**
* Checks if a given request is a socket request
*
* @param req Either a HTTP express request or a WS request
* @returns True if it's a WS request
*/
isSocketRequest(req: Request | SocketRequest): req is SocketRequest {
return 'isSocket' in req && req.isSocket;
}
/**
* Process incoming Web Channel data (finding out its type and assigning it to its proper handler)
*
* @param req Either a HTTP Express request or a WS request (Synthetic Object)
* @param res Either a HTTP Express response or a WS response (Synthetic Object)
*/
async handle(req: Request | SocketRequest, res: Response | SocketResponse) {
const settings = await this.getSettings();
// Web Channel messaging can be done through websockets or long-polling
try {
await this.checkRequest(req, res);
if (req.method === 'GET') {
if (!this.isSocketRequest(req) && req.query._get) {
switch (req.query._get) {
case 'settings':
this.logger.debug('connected .. sending settings');
try {
const menu = await this.menuService.getTree();
return res.status(200).json({
menu,
server_date: new Date().toISOString(),
...settings,
});
} catch (err) {
this.logger.warn('Unable to retrieve menu ', err);
return res.status(500).json({ err: 'Unable to retrieve menu' });
}
case 'polling':
// Handle polling when user is not connected via websocket
return this.getMessageQueue(req, res as Response);
default:
this.logger.error('Webhook received unknown command');
return res
.status(500)
.json({ err: 'Webhook received unknown command' });
}
} else if (req.query._disconnect) {
req.session.web = undefined;
return res.status(200).json({ _disconnect: true });
} else {
// Handle webhook subscribe requests
return await this.subscribe(req, res);
}
} else {
// Handle incoming messages (through POST)
return this._handleEvent(req, res);
}
} catch (err) {
this.logger.warn('Request check failed', err);
return res
.status(403)
.json({ err: 'Web Channel Handler : Unauthorized!' });
}
}
/**
* Returns a unique identifier for the subscriber
*
* @returns UUID
*/
generateId(): string {
return 'web-' + uuidv4();
}
/**
* Formats a text message that will be sent to the widget
*
* @param message - A text to be sent to the end user
* @param _options - might contain additional settings
*
* @returns A ready to be sent text message
*/
_textFormat(
message: StdOutgoingTextMessage,
_options?: BlockOptions,
): Web.OutgoingMessageBase {
return {
type: Web.OutgoingMessageType.text,
data: message,
};
}
/**
* Formats a text + quick replies message that can be sent back
*
* @param message - A text + quick replies to be sent to the end user
* @param _options - might contain additional settings
*
* @returns A ready to be sent text message
*/
_quickRepliesFormat(
message: StdOutgoingQuickRepliesMessage,
_options?: BlockOptions,
): Web.OutgoingMessageBase {
return {
type: Web.OutgoingMessageType.quick_replies,
data: {
text: message.text,
quick_replies: message.quickReplies,
},
};
}
/**
* Formats a text + buttons message that can be sent back
*
* @param message - A text + buttons to be sent to the end user
* @param _options - Might contain additional settings
*
* @returns A formatted Object understandable by the widget
*/
_buttonsFormat(
message: StdOutgoingButtonsMessage,
_options?: BlockOptions,
): Web.OutgoingMessageBase {
return {
type: Web.OutgoingMessageType.buttons,
data: {
text: message.text,
buttons: message.buttons,
},
};
}
/**
* Formats an attachment + quick replies message that can be sent to the widget
*
* @param message - An attachment + quick replies to be sent to the end user
* @param _options - Might contain additional settings
*
* @returns A ready to be sent attachment message
*/
async _attachmentFormat(
message: StdOutgoingAttachmentMessage,
_options?: BlockOptions,
): Promise<Web.OutgoingMessageBase> {
const payload: Web.OutgoingMessageBase = {
type: Web.OutgoingMessageType.file,
data: {
type: message.attachment.type,
url: await this.getPublicUrl(message.attachment.payload),
},
};
if (message.quickReplies && message.quickReplies.length > 0) {
return {
...payload,
data: {
...payload.data,
quick_replies: message.quickReplies,
} as Web.OutgoingFileMessageData,
};
}
return payload;
}
/**
* Formats a collection of items to be sent to the widget (carousel/list)
*
* @param data - A list of data items to be sent to the end user
* @param options - Might contain additional settings
*
* @returns An array of elements object
*/
async _formatElements(
data: ContentElement[],
options: BlockOptions,
): Promise<Web.MessageElement[]> {
if (!options.content || !options.content.fields) {
throw new Error('Content options are missing the fields');
}
const fields = options.content.fields;
const buttons: Button[] = options.content.buttons;
const result: Web.MessageElement[] = [];
for (const item of data) {
const element: Web.MessageElement = {
title: item[fields.title],
buttons: item.buttons || [],
};
if (fields.subtitle && item[fields.subtitle]) {
element.subtitle = item[fields.subtitle];
}
if (fields.image_url && item[fields.image_url]) {
const attachmentRef =
typeof item[fields.image_url] === 'string'
? { url: item[fields.image_url] }
: (item[fields.image_url].payload as AttachmentRef);
element.image_url = await this.getPublicUrl(attachmentRef);
}