/* * 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, PayloadType } from '@/chat/schemas/types/button'; import { AnyMessage, ContentElement, IncomingMessage, IncomingMessageType, OutgoingMessage, OutgoingMessageFormat, 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 { 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 { // 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 { // 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 { 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, 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 || 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 { 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 { 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(); // 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 { 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 { 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 { 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 { // 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(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 { 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 { 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); } buttons.forEach((button: Button, index) => { const btn = { ...button }; if (btn.type === ButtonType.web_url) { // Get built-in or an external URL from custom field const urlField = fields.url; btn.url = urlField && item[urlField] ? item[urlField] : Content.getUrl(item); if (!btn.url.startsWith('http')) { btn.url = 'https://' + btn.url; } // Set default action the same as the first web_url button if (!element.default_action) { const { title: _title, ...defaultAction } = btn; element.default_action = defaultAction; } } else { if ( 'action_payload' in fields && fields.action_payload && fields.action_payload in item ) { btn.payload = btn.title + ':' + item[fields.action_payload]; } else { const postback = Content.getPayload(item); btn.payload = btn.title + ':' + postback; } } // Set custom title for first button if provided if (index === 0 && fields.action_title && item[fields.action_title]) { btn.title = item[fields.action_title]; } element.buttons?.push(btn); }); if (Array.isArray(element.buttons) && element.buttons.length === 0) { delete element.buttons; } result.push(element); } return result; } /** * Format a list of elements * * @param message - Contains elements to be sent to the end user * @param options - Might contain additional settings * * @returns A ready to be sent list template message */ async _listFormat( message: StdOutgoingListMessage, options: BlockOptions, ): Promise { const data = message.elements || []; const pagination = message.pagination; let buttons: Button[] = [], elements: Web.MessageElement[] = []; // Items count min check if (!data.length) { this.logger.error('Unsufficient content count (must be >= 0 for list)'); throw new Error('Unsufficient content count (list >= 0)'); } // Toggle "View More" button (check if there's more items to display) if (pagination.total - pagination.skip - pagination.limit > 0) { buttons = [ { type: ButtonType.postback, title: this.i18n.t('View More'), payload: VIEW_MORE_PAYLOAD, }, ]; } // Populate items (elements/cards) with content elements = await this._formatElements(data, options); const topElementStyle = options.content?.top_element_style ? { top_element_style: options.content?.top_element_style, } : {}; return { type: Web.OutgoingMessageType.list, data: { elements, buttons, ...topElementStyle, }, }; } /** * Format a carousel message * * @param message - Contains elements to be sent to the end user * @param options - Might contain additional settings * * @returns A carousel ready to be sent as a message */ async _carouselFormat( message: StdOutgoingListMessage, options: BlockOptions, ): Promise { const data = message.elements || []; // Items count min check if (data.length === 0) { this.logger.error( 'Unsufficient content count (must be > 0 for carousel)', ); throw new Error('Unsufficient content count (carousel > 0)'); } // Populate items (elements/cards) with content const elements = await this._formatElements(data, options); return { type: Web.OutgoingMessageType.carousel, data: { elements, }, }; } /** * Creates an widget compliant data structure for any message envelope * * @param envelope - The message standard envelope * @param options - The block options related to the message * * @returns A template filled with its payload */ async _formatMessage( envelope: StdOutgoingEnvelope, options: BlockOptions, ): Promise { switch (envelope.format) { case OutgoingMessageFormat.attachment: return await this._attachmentFormat(envelope.message, options); case OutgoingMessageFormat.buttons: return this._buttonsFormat(envelope.message, options); case OutgoingMessageFormat.carousel: return await this._carouselFormat(envelope.message, options); case OutgoingMessageFormat.list: return await this._listFormat(envelope.message, options); case OutgoingMessageFormat.quickReplies: return this._quickRepliesFormat(envelope.message, options); case OutgoingMessageFormat.text: return this._textFormat(envelope.message, options); default: throw new Error('Unknown message format'); } } /** * Sends a message to the end-user using websocket * * @param subscriber - End-user toward which message will be sent * @param type - The message to be sent (message, typing, ...) * @param content - The message payload contain additional settings */ private broadcast( subscriber: Subscriber, type: StdEventType, content: any, ): void { const channelData = Subscriber.getChannelData(subscriber); if (channelData.isSocket) { this.websocketGateway.broadcast(subscriber, type, content); } else { // Do nothing, messages will be retrieved via polling } } /** * Send a Web Channel Message to the end-user * * @param event - Incoming event/message being responded to * @param envelope - The message to be sent {format, message} * @param options - Might contain additional settings * @param _context - Contextual data * * @returns The web's response, otherwise an error */ async sendMessage( event: WebEventWrapper, envelope: StdOutgoingEnvelope, options: BlockOptions, _context?: any, ): Promise<{ mid: string }> { const messageBase: Web.OutgoingMessageBase = await this._formatMessage( envelope, options, ); const subscriber = event.getSender(); const message: Web.OutgoingMessage = { ...messageBase, mid: this.generateId(), author: 'chatbot', createdAt: new Date(), handover: !!(options && options.assignTo), }; const next = async (): Promise => { this.broadcast(subscriber, StdEventType.message, message); return { mid: message.mid }; }; if (options && options.typing) { const autoTimeout = message && message.data && 'text' in message.data ? message.data.text.length * 10 : 1000; const timeout = typeof options.typing === 'number' ? options.typing : autoTimeout; try { await this.sendTypingIndicator(subscriber, timeout); return next(); } catch (err) { this.logger.error('Failed in sending typing indicator ', err); } } return next(); } /** * Send a typing indicator (waterline) to the end user for a given duration * * @param recipient - The end-user object * @param timeout - Duration of the typing indicator in milliseconds */ async sendTypingIndicator( recipient: Subscriber, timeout: number, ): Promise { return new Promise((resolve, reject) => { try { this.broadcast(recipient, StdEventType.typing, true); setTimeout(() => { this.broadcast(recipient, StdEventType.typing, false); return resolve(); }, timeout); } catch (err) { reject(err); } }); } /** * Fetch the end-user profile data * * @param event - The message event received * * @returns The web's response, otherwise an error */ async getSubscriberData( event: WebEventWrapper, ): Promise { const sender = event.getSender(); const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = sender; const subscriber: SubscriberCreateDto = { ...rest, channel: Subscriber.getChannelData(sender), }; return subscriber; } /** * Checks if the request is authorized to download a given attachment file. * * @param attachment The attachment object * @param req - The HTTP express request object. * @return True, if requester is authorized to download the attachment */ public async hasDownloadAccess(attachment: Attachment, req: Request) { const subscriberId = req.session?.web?.profile?.id as string; if (attachment.access === AttachmentAccess.Public) { return true; } else if (!subscriberId) { this.logger.warn( `Unauthorized access attempt to attachment ${attachment.id}`, ); return false; } else if ( attachment.createdByRef === AttachmentCreatedByRef.Subscriber && subscriberId === attachment.createdBy ) { // Either subscriber wants to access the attachment he sent return true; } else { // Or, he would like to access an attachment sent to him privately const message = await this.messageService.findOne({ ['recipient' as any]: subscriberId, $or: [ { 'message.attachment.payload.id': attachment.id }, { 'message.attachment': { $elemMatch: { 'payload.id': attachment.id }, }, }, ], }); return !!message; } } /** * Web channel middleware * * @param req Express Request * @param res Express Response * @param next Callback function */ async middleware(req: Request, res: Response, next: NextFunction) { if (!this.isSocketRequest(req)) { if (req.headers['content-type']?.includes('multipart/form-data')) { // Handle multipart uploads (Long Pooling only) return upload(req, res, next); } else if (req.headers['content-type']?.includes('text/plain')) { // Handle plain text payloads as JSON (retro-compability) const textParser = bodyParser.text({ type: 'text/plain' }); return textParser(req, res, () => { try { req.body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body; next(); } catch (err) { next(err); } }); } } // Do nothing next(); } }