From 0737cd99c602a644f19ff2831c3360c9d6b339e5 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Thu, 9 Jan 2025 12:42:52 +0100 Subject: [PATCH] feat: use attachment_id instead of url + in messages + webchannel and secure public urls --- api/src/channel/lib/EventWrapper.ts | 4 +- api/src/channel/lib/Handler.ts | 4 +- api/src/chat/schemas/types/attachment.ts | 7 +- api/src/chat/services/block.service.spec.ts | 3 +- .../channels/web/__test__/data.mock.ts | 12 +- .../channels/web/__test__/events.mock.ts | 16 +- .../channels/web/__test__/index.spec.ts | 19 +- .../channels/web/__test__/wrapper.spec.ts | 23 +- .../channels/web/base-web-channel.ts | 362 ++++++++++-------- api/src/extensions/channels/web/types.ts | 8 +- api/src/extensions/channels/web/wrapper.ts | 31 +- 11 files changed, 276 insertions(+), 213 deletions(-) diff --git a/api/src/channel/lib/EventWrapper.ts b/api/src/channel/lib/EventWrapper.ts index b5ed69fd..daa8861b 100644 --- a/api/src/channel/lib/EventWrapper.ts +++ b/api/src/channel/lib/EventWrapper.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * 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. @@ -126,6 +126,7 @@ export default abstract class EventWrapper< /** * Sets an event attribute value * + * @deprecated * @param attr - Event attribute name * @param value - The value to set for the specified attribute. */ @@ -136,6 +137,7 @@ export default abstract class EventWrapper< /** * Returns an event attribute value, default value if it does exist * + * @deprecated * @param attr - Event attribute name * @param otherwise - Default value if attribute does not exist * diff --git a/api/src/channel/lib/Handler.ts b/api/src/channel/lib/Handler.ts index 85554a62..733a8ae8 100644 --- a/api/src/channel/lib/Handler.ts +++ b/api/src/channel/lib/Handler.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * 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. @@ -234,7 +234,7 @@ export default abstract class ChannelHandler< * @param attachment The attachment ID or object to generate a signed URL for. * @return A signed URL string for downloading the specified attachment. */ - protected async getPublicUrl(attachment: string | Attachment) { + public async getPublicUrl(attachment: string | Attachment) { const resource = typeof attachment === 'string' ? await this.attachmentService.findOne(attachment) diff --git a/api/src/chat/schemas/types/attachment.ts b/api/src/chat/schemas/types/attachment.ts index 777dd7e9..830e55ed 100644 --- a/api/src/chat/schemas/types/attachment.ts +++ b/api/src/chat/schemas/types/attachment.ts @@ -17,8 +17,9 @@ export enum FileType { } export type AttachmentForeignKey = { - url?: string; attachment_id: string; + /** @deprecated use "attachment_id" instead */ + url?: string; }; export interface AttachmentPayload< @@ -30,7 +31,5 @@ export interface AttachmentPayload< export interface IncomingAttachmentPayload { type: FileType; - payload: { - url: string; - }; + payload: AttachmentForeignKey; } diff --git a/api/src/chat/services/block.service.spec.ts b/api/src/chat/services/block.service.spec.ts index e15cbc75..48c59eb8 100644 --- a/api/src/chat/services/block.service.spec.ts +++ b/api/src/chat/services/block.service.spec.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * 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. @@ -390,6 +390,7 @@ describe('BlockService', () => { attachments: { type: FileType.file, payload: { + attachment_id: '9'.repeat(24), url: 'http://link.to/the/file', }, }, diff --git a/api/src/extensions/channels/web/__test__/data.mock.ts b/api/src/extensions/channels/web/__test__/data.mock.ts index ae5fcd29..efa30e1a 100644 --- a/api/src/extensions/channels/web/__test__/data.mock.ts +++ b/api/src/extensions/channels/web/__test__/data.mock.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * 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. @@ -77,7 +77,7 @@ export const webList: Web.OutgoingMessageBase = { type: ButtonType.postback, }, ], - image_url: 'http://localhost:4000/attachment/download/1/attachment.jpg', + image_url: 'http://public.url/download/filename.extension?t=any', subtitle: 'About being first', title: 'First', }, @@ -89,7 +89,7 @@ export const webList: Web.OutgoingMessageBase = { type: ButtonType.postback, }, ], - image_url: 'http://localhost:4000/attachment/download/1/attachment.jpg', + image_url: 'http://public.url/download/filename.extension?t=any', subtitle: 'About being second', title: 'Second', }, @@ -109,7 +109,7 @@ export const webCarousel: Web.OutgoingMessageBase = { type: ButtonType.postback, }, ], - image_url: 'http://localhost:4000/attachment/download/1/attachment.jpg', + image_url: 'http://public.url/download/filename.extension?t=any', subtitle: 'About being first', title: 'First', }, @@ -121,7 +121,7 @@ export const webCarousel: Web.OutgoingMessageBase = { type: ButtonType.postback, }, ], - image_url: 'http://localhost:4000/attachment/download/1/attachment.jpg', + image_url: 'http://public.url/download/filename.extension?t=any', subtitle: 'About being second', title: 'Second', }, @@ -140,7 +140,7 @@ export const webAttachment: Web.OutgoingMessageBase = { }, ], type: FileType.image, - url: 'http://localhost:4000/attachment/download/1/attachment.jpg', + url: 'http://public.url/download/filename.extension?t=any', }, type: Web.OutgoingMessageType.file, }; diff --git a/api/src/extensions/channels/web/__test__/events.mock.ts b/api/src/extensions/channels/web/__test__/events.mock.ts index 334482be..9be7b095 100644 --- a/api/src/extensions/channels/web/__test__/events.mock.ts +++ b/api/src/extensions/channels/web/__test__/events.mock.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * 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. @@ -14,9 +14,6 @@ import { import { Web } from '../types'; -const img_url = - 'http://demo.hexabot.ai/attachment/download/5c334078e2c41d11206bd152/myimage.png'; - // Web events const webEventPayload: Web.Event = { type: Web.IncomingMessageType.postback, @@ -55,9 +52,10 @@ const webEventLocation: Web.IncomingMessage = { const webEventFile: Web.Event = { type: Web.IncomingMessageType.file, data: { - type: FileType.image, - url: img_url, + type: 'image/png', size: 500, + name: 'filename.extension', + file: Buffer.from('my-image', 'utf-8'), }, author: 'web-9be8ac09-b43a-432d-bca0-f11b98cec1ad', mid: 'web-event-file', @@ -151,18 +149,18 @@ export const webEvents: [string, Web.IncomingMessage, any][] = [ attachments: { type: FileType.image, payload: { - url: img_url, + attachment_id: '9'.repeat(24), }, }, }, message: { attachment: { payload: { - url: img_url, + attachment_id: '9'.repeat(24), }, type: FileType.image, }, - serialized_text: `attachment:image:${img_url}`, + serialized_text: 'attachment:image:filename.extension', type: IncomingMessageType.attachments, }, }, diff --git a/api/src/extensions/channels/web/__test__/index.spec.ts b/api/src/extensions/channels/web/__test__/index.spec.ts index ffd12cfe..548f2969 100644 --- a/api/src/extensions/channels/web/__test__/index.spec.ts +++ b/api/src/extensions/channels/web/__test__/index.spec.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * 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. @@ -125,9 +125,14 @@ describe('WebChannelHandler', () => { }).compile(); subscriberService = module.get(SubscriberService); handler = module.get(WebChannelHandler); + + jest + .spyOn(handler, 'getPublicUrl') + .mockResolvedValue('http://public.url/download/filename.extension?t=any'); }); afterAll(async () => { + jest.restoreAllMocks(); await closeInMongodConnection(); }); @@ -204,15 +209,15 @@ describe('WebChannelHandler', () => { expect(formatted).toEqual(webButtons); }); - it('should format list properly', () => { - const formatted = handler._listFormat(contentMessage, { + it('should format list properly', async () => { + const formatted = await handler._listFormat(contentMessage, { content: contentMessage.options, }); expect(formatted).toEqual(webList); }); - it('should format carousel properly', () => { - const formatted = handler._carouselFormat(contentMessage, { + it('should format carousel properly', async () => { + const formatted = await handler._carouselFormat(contentMessage, { content: { ...contentMessage.options, display: OutgoingMessageFormat.carousel, @@ -221,8 +226,8 @@ describe('WebChannelHandler', () => { expect(formatted).toEqual(webCarousel); }); - it('should format attachment properly', () => { - const formatted = handler._attachmentFormat(attachmentMessage, {}); + it('should format attachment properly', async () => { + const formatted = await handler._attachmentFormat(attachmentMessage, {}); expect(formatted).toEqual(webAttachment); }); diff --git a/api/src/extensions/channels/web/__test__/wrapper.spec.ts b/api/src/extensions/channels/web/__test__/wrapper.spec.ts index 9d2839e3..d219834b 100644 --- a/api/src/extensions/channels/web/__test__/wrapper.spec.ts +++ b/api/src/extensions/channels/web/__test__/wrapper.spec.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * 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. @@ -13,13 +13,20 @@ import { MongooseModule } from '@nestjs/mongoose'; import { Test, TestingModule } from '@nestjs/testing'; import { AttachmentRepository } from '@/attachment/repositories/attachment.repository'; -import { AttachmentModel } from '@/attachment/schemas/attachment.schema'; +import { + Attachment, + AttachmentModel, +} from '@/attachment/schemas/attachment.schema'; import { AttachmentService } from '@/attachment/services/attachment.service'; import { ChannelService } from '@/channel/channel.service'; import { MessageRepository } from '@/chat/repositories/message.repository'; import { SubscriberRepository } from '@/chat/repositories/subscriber.repository'; import { MessageModel } from '@/chat/schemas/message.schema'; import { SubscriberModel } from '@/chat/schemas/subscriber.schema'; +import { + IncomingMessageType, + StdEventType, +} from '@/chat/schemas/types/message'; import { MessageService } from '@/chat/services/message.service'; import { SubscriberService } from '@/chat/services/subscriber.service'; import { MenuRepository } from '@/cms/repositories/menu.repository'; @@ -122,6 +129,18 @@ describe(`Web event wrapper`, () => { e, expected.channelData, ); + + if ( + event._adapter.eventType === StdEventType.message && + event._adapter.messageType === IncomingMessageType.attachments + ) { + event._adapter.attachment = { + id: '9'.repeat(24), + type: 'image/png', + name: 'filename.extension', + } as Attachment; + } + expect(event.getChannelData()).toEqual({ ...expected.channelData, name: WEB_CHANNEL_NAME, diff --git a/api/src/extensions/channels/web/base-web-channel.ts b/api/src/extensions/channels/web/base-web-channel.ts index 273a3429..84216a0e 100644 --- a/api/src/extensions/channels/web/base-web-channel.ts +++ b/api/src/extensions/channels/web/base-web-channel.ts @@ -22,11 +22,13 @@ 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 { AttachmentForeignKey } from '@/chat/schemas/types/attachment'; import { Button, ButtonType } from '@/chat/schemas/types/button'; import { AnyMessage, ContentElement, IncomingMessage, + IncomingMessageType, OutgoingMessage, OutgoingMessageFormat, PayloadType, @@ -127,9 +129,9 @@ export default abstract class BaseWebChannelHandler< * @param incoming - Incoming message * @returns Formatted web message */ - private formatIncomingHistoryMessage( + private async formatIncomingHistoryMessage( incoming: IncomingMessage, - ): Web.IncomingMessageBase { + ): Promise { // Format incoming message if ('type' in incoming.message) { if (incoming.message.type === PayloadType.location) { @@ -145,14 +147,17 @@ export default abstract class BaseWebChannelHandler< }; } else { // @TODO : handle multiple files - const attachment = Array.isArray(incoming.message.attachment) + const attachmentPayload = Array.isArray(incoming.message.attachment) ? incoming.message.attachment[0] : incoming.message.attachment; + return { type: Web.IncomingMessageType.file, data: { - type: attachment.type, - url: attachment.payload.url, + type: attachmentPayload.type, + url: await this.getPublicUrl( + attachmentPayload.payload.attachment_id, + ), }, }; } @@ -170,9 +175,9 @@ export default abstract class BaseWebChannelHandler< * @param outgoing - The outgoing message * @returns Formatted web message */ - private formatOutgoingHistoryMessage( + private async formatOutgoingHistoryMessage( outgoing: OutgoingMessage, - ): Web.OutgoingMessageBase { + ): Promise { // Format outgoing message if ('buttons' in outgoing.message) { return this._buttonsFormat(outgoing.message); @@ -182,11 +187,11 @@ export default abstract class BaseWebChannelHandler< return this._quickRepliesFormat(outgoing.message); } else if ('options' in outgoing.message) { if (outgoing.message.options.display === 'carousel') { - return this._carouselFormat(outgoing.message, { + return await this._carouselFormat(outgoing.message, { content: outgoing.message.options, }); } else { - return this._listFormat(outgoing.message, { + return await this._listFormat(outgoing.message, { content: outgoing.message.options, }); } @@ -212,12 +217,14 @@ export default abstract class BaseWebChannelHandler< * * @returns Formatted message */ - protected formatMessages(messages: AnyMessage[]): Web.Message[] { + protected async formatMessages( + messages: AnyMessage[], + ): Promise { const formattedMessages: Web.Message[] = []; for (const anyMessage of messages) { if (this.isIncomingMessage(anyMessage)) { - const message = this.formatIncomingHistoryMessage(anyMessage); + const message = await this.formatIncomingHistoryMessage(anyMessage); formattedMessages.push({ ...message, author: anyMessage.sender, @@ -226,7 +233,7 @@ export default abstract class BaseWebChannelHandler< createdAt: anyMessage.createdAt, }); } else { - const message = this.formatOutgoingHistoryMessage(anyMessage); + const message = await this.formatOutgoingHistoryMessage(anyMessage); formattedMessages.push({ ...message, author: 'chatbot', @@ -261,7 +268,7 @@ export default abstract class BaseWebChannelHandler< until, n, ); - return this.formatMessages(messages.reverse()); + return await this.formatMessages(messages.reverse()); } return []; } @@ -286,7 +293,7 @@ export default abstract class BaseWebChannelHandler< since, n, ); - return this.formatMessages(messages); + return await this.formatMessages(messages); } return []; } @@ -579,9 +586,8 @@ export default abstract class BaseWebChannelHandler< 'since' in req.query ? req.query.since // Long polling case : req.body?.since || undefined; // Websocket case - return this.fetchHistory(req, criteria).then((messages) => { - return res.status(200).json({ profile, messages }); - }); + const messages = await this.fetchHistory(req, criteria); + return res.status(200).json({ profile, messages }); } catch (err) { this.logger.warn('Web Channel Handler : Unable to subscribe ', err); return res.status(500).json({ err: 'Unable to subscribe' }); @@ -589,59 +595,51 @@ export default abstract class BaseWebChannelHandler< } /** - * Upload file as attachment if provided + * Handle upload via WebSocket * - * @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 + * @returns The stored attachment or null */ - async handleFilesUpload( - req: Request | SocketRequest, - res: Response | SocketResponse, - next: ( - err: null | Error, - result?: Web.IncomingAttachmentMessageData, - ) => void, - ): Promise { - // Check if any file is provided - if (!req.session.web) { - this.logger.debug('Web Channel Handler : No session provided'); - return next(null); - } + async handleWsUpload(req: SocketRequest): Promise { + try { + const { type, data } = req.body as Web.IncomingMessage; - if (this.isSocketRequest(req)) { - try { - const { type, data } = req.body as Web.IncomingMessage; - - // Check if any file is provided - if (type !== 'file' || !('file' in data) || !data.file) { - this.logger.debug('Web Channel Handler : No files provided'); - return next(null); - } - - const size = Buffer.byteLength(data.file); - - if (size > config.parameters.maxUploadSize) { - return next(new Error('Max upload size has been exceeded')); - } - - const attachment = await this.attachmentService.store(data.file, { - name: data.name, - size: Buffer.byteLength(data.file), - type: data.type, - }); - next(null, { - type: Attachment.getTypeByMime(attachment.type), - url: Attachment.getAttachmentUrl(attachment.id, attachment.name), - }); - } catch (err) { - this.logger.error( - 'Web Channel Handler : Unable to write uploaded file', - err, - ); - return next(new Error('Unable to upload file!')); + // Check if any file is provided + if (type !== 'file' || !('file' in data) || !data.file) { + this.logger.debug('Web Channel Handler : No files provided'); + return null; } - } else { + + const size = Buffer.byteLength(data.file); + + if (size > config.parameters.maxUploadSize) { + throw new Error('Max upload size has been exceeded'); + } + + const attachment = await this.attachmentService.store(data.file, { + name: data.name, + size: Buffer.byteLength(data.file), + type: data.type, + }); + return attachment; + } catch (err) { + this.logger.error( + 'Web Channel Handler : 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 { const upload = multer({ limits: { fileSize: config.parameters.maxUploadSize, @@ -655,36 +653,66 @@ export default abstract class BaseWebChannelHandler< })(), }).single('file'); // 'file' is the field name in the form - upload(req as Request, res as Response, async (err?: any) => { - if (err) { - this.logger.error( - 'Web Channel Handler : Unable to write uploaded file', - err, - ); - return next(new Error('Unable to upload file!')); - } + const multerUpload = new Promise( + (resolve, reject) => { + upload(req as Request, res as Response, async (err?: any) => { + if (err) { + this.logger.error( + 'Web Channel Handler : Unable to store uploaded file', + err, + ); + reject(new Error('Unable to upload file!')); + } - // Check if any file is provided - if (!req.file) { - this.logger.debug('Web Channel Handler : No files provided'); - return next(null); - } + resolve(req.file); + }); + }, + ); - try { - const file = req.file; - const attachment = await this.attachmentService.store(file, { - name: file.originalname, - size: file.size, - type: file.mimetype, - }); - next(null, { - type: Attachment.getTypeByMime(attachment.type), - url: Attachment.getAttachmentUrl(attachment.id, attachment.name), - }); - } catch (err) { - next(err); - } + const file = await multerUpload; + + // Check if any file is provided + if (!req.file) { + this.logger.debug('Web Channel Handler : No files provided'); + return null; + } + + const attachment = await this.attachmentService.store(file, { + name: file.originalname, + size: file.size, + type: file.mimetype, }); + return attachment; + } catch (err) { + this.logger.error( + 'Web Channel Handler : 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('Web Channel Handler : No session provided'); + return null; + } + + if (this.isSocketRequest(req)) { + return this.handleWsUpload(req); + } else { + return this.handleWebUpload(req, res as Response); } } @@ -748,12 +776,21 @@ export default abstract class BaseWebChannelHandler< : req.body.data; } - this.validateSession(req, res, (profile) => { - this.handleFilesUpload( - req, - res, - (err: Error, data?: Web.IncomingAttachmentMessageData) => { - if (err) { + 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; + } + } catch (err) { this.logger.warn( 'Web Channel Handler : Unable to upload file ', err, @@ -762,49 +799,39 @@ export default abstract class BaseWebChannelHandler< .status(403) .json({ err: 'Web Channel Handler : File upload failed!' }); } - // Set data in file upload case - const body: Web.IncomingMessage = data - ? { - ...req.body, - data, - } - : req.body; + } - const channelAttrs = this.getChannelAttributes(req); - const event = new WebEventWrapper(this, body, channelAttrs); - if (event.getEventType() === 'message') { - // 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.set('mid', this.generateId()); - } - } + // 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.set('author', profile.foreign_id); - event.setSender(profile); + event._adapter.raw.author = profile.foreign_id; + } + } - const type = event.getEventType(); - if (type) { - this.eventEmitter.emit(`hook:chatbot:${type}`, event); - } else { - this.logger.error( - 'Web Channel Handler : Webhook received unknown event ', - event, - ); - } - res.status(200).json(event._adapter.raw); - }, - ); + event.setSender(profile); + + const type = event.getEventType(); + if (type) { + this.eventEmitter.emit(`hook:chatbot:${type}`, event); + } else { + this.logger.error( + 'Web Channel Handler : Webhook received unknown event ', + event, + ); + } + res.status(200).json(event._adapter.raw); }); } @@ -957,15 +984,15 @@ export default abstract class BaseWebChannelHandler< * * @returns A ready to be sent attachment message */ - _attachmentFormat( + async _attachmentFormat( message: StdOutgoingAttachmentMessage, _options?: BlockOptions, - ): Web.OutgoingMessageBase { + ): Promise { const payload: Web.OutgoingMessageBase = { type: Web.OutgoingMessageType.file, data: { type: message.attachment.type, - url: message.attachment.payload.url, + url: await this.getPublicUrl(message.attachment.payload.id), }, }; if (message.quickReplies && message.quickReplies.length > 0) { @@ -982,35 +1009,42 @@ export default abstract class BaseWebChannelHandler< * * @returns An array of elements object */ - _formatElements( + async _formatElements( data: ContentElement[], options: BlockOptions, - ): Web.MessageElement[] { + ): 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; - return data.map((item) => { + 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 attachmentPayload = item[fields.image_url].payload; + const attachmentPayload = item[fields.image_url] + .payload as AttachmentForeignKey; if (attachmentPayload.url) { - if (!attachmentPayload.id) { + if (!attachmentPayload.attachment_id) { // @deprecated this.logger.warn( 'Web Channel Handler: Attachment remote url has been deprecated', item, ); } - element.image_url = attachmentPayload.url; + element.image_url = await this.getPublicUrl( + attachmentPayload.attachment_id, + ); } } @@ -1047,11 +1081,15 @@ export default abstract class BaseWebChannelHandler< } element.buttons?.push(btn); }); + if (Array.isArray(element.buttons) && element.buttons.length === 0) { delete element.buttons; } - return element; - }); + + result.push(element); + } + + return result; } /** @@ -1062,10 +1100,10 @@ export default abstract class BaseWebChannelHandler< * * @returns A ready to be sent list template message */ - _listFormat( + async _listFormat( message: StdOutgoingListMessage, options: BlockOptions, - ): Web.OutgoingMessageBase { + ): Promise { const data = message.elements || []; const pagination = message.pagination; let buttons: Button[] = [], @@ -1091,7 +1129,7 @@ export default abstract class BaseWebChannelHandler< } // Populate items (elements/cards) with content - elements = this._formatElements(data, options); + elements = await this._formatElements(data, options); const topElementStyle = options.content?.top_element_style ? { top_element_style: options.content?.top_element_style, @@ -1115,10 +1153,10 @@ export default abstract class BaseWebChannelHandler< * * @returns A carousel ready to be sent as a message */ - _carouselFormat( + async _carouselFormat( message: StdOutgoingListMessage, options: BlockOptions, - ): Web.OutgoingMessageBase { + ): Promise { const data = message.elements || []; // Items count min check if (data.length === 0) { @@ -1129,7 +1167,7 @@ export default abstract class BaseWebChannelHandler< } // Populate items (elements/cards) with content - const elements = this._formatElements(data, options); + const elements = await this._formatElements(data, options); return { type: Web.OutgoingMessageType.carousel, data: { @@ -1146,19 +1184,19 @@ export default abstract class BaseWebChannelHandler< * * @returns A template filled with its payload */ - _formatMessage( + async _formatMessage( envelope: StdOutgoingEnvelope, options: BlockOptions, - ): Web.OutgoingMessageBase { + ): Promise { switch (envelope.format) { case OutgoingMessageFormat.attachment: - return this._attachmentFormat(envelope.message, options); + return await this._attachmentFormat(envelope.message, options); case OutgoingMessageFormat.buttons: return this._buttonsFormat(envelope.message, options); case OutgoingMessageFormat.carousel: - return this._carouselFormat(envelope.message, options); + return await this._carouselFormat(envelope.message, options); case OutgoingMessageFormat.list: - return this._listFormat(envelope.message, options); + return await this._listFormat(envelope.message, options); case OutgoingMessageFormat.quickReplies: return this._quickRepliesFormat(envelope.message, options); case OutgoingMessageFormat.text: @@ -1206,7 +1244,7 @@ export default abstract class BaseWebChannelHandler< options: BlockOptions, _context?: any, ): Promise<{ mid: string }> { - const messageBase: Web.OutgoingMessageBase = this._formatMessage( + const messageBase: Web.OutgoingMessageBase = await this._formatMessage( envelope, options, ); diff --git a/api/src/extensions/channels/web/types.ts b/api/src/extensions/channels/web/types.ts index a650c1c0..fe1f5a33 100644 --- a/api/src/extensions/channels/web/types.ts +++ b/api/src/extensions/channels/web/types.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * 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. @@ -59,13 +59,13 @@ export namespace Web { }; }; - // Depending if it's has been processed or not export type IncomingAttachmentMessageData = - // After upload and attachment is processed + // When it's a incoming history message | { type: FileType; url: string; // file download url - } // Before upload and attachment is processed + } + // When it's a file upload message | { type: string; // mime type size: number; // file size diff --git a/api/src/extensions/channels/web/wrapper.ts b/api/src/extensions/channels/web/wrapper.ts index 6ae44fa7..ea6515e0 100644 --- a/api/src/extensions/channels/web/wrapper.ts +++ b/api/src/extensions/channels/web/wrapper.ts @@ -1,11 +1,12 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * 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 { Attachment } from '@/attachment/schemas/attachment.schema'; import EventWrapper from '@/channel/lib/EventWrapper'; import { ChannelName } from '@/channel/types'; import { @@ -66,14 +67,13 @@ type WebEventAdapter = eventType: StdEventType.message; messageType: IncomingMessageType.attachments; raw: Web.IncomingMessage; + attachment: Attachment | null; }; // eslint-disable-next-line prettier/prettier -export default class WebEventWrapper extends EventWrapper< - WebEventAdapter, - Web.Event, - N -> { +export default class WebEventWrapper< + N extends ChannelName, +> extends EventWrapper { /** * Constructor : channel's event wrapper * @@ -216,16 +216,16 @@ export default class WebEventWrapper extends EventWrapper }; } case IncomingMessageType.attachments: - if (!('url' in this._adapter.raw.data)) { + if (!this._adapter.attachment) { throw new Error('Attachment has not been processed'); } return { type: PayloadType.attachments, attachments: { - type: this._adapter.raw.data.type, + type: Attachment.getTypeByMime(this._adapter.raw.data.type), payload: { - url: this._adapter.raw.data.url, + attachment_id: this._adapter.attachment.id, }, }, }; @@ -266,19 +266,20 @@ export default class WebEventWrapper extends EventWrapper } case IncomingMessageType.attachments: { - const attachment = this._adapter.raw.data; - - if (!('url' in attachment)) { + if (!this._adapter.attachment) { throw new Error('Attachment has not been processed'); } + const fileType = Attachment.getTypeByMime( + this._adapter.attachment.type, + ); return { type: PayloadType.attachments, - serialized_text: `attachment:${attachment.type}:${attachment.url}`, + serialized_text: `attachment:${fileType}:${this._adapter.attachment.name}`, attachment: { - type: attachment.type, + type: fileType, payload: { - url: attachment.url, + attachment_id: this._adapter.attachment.id, }, }, };