feat: use attachment_id instead of url + in messages + webchannel and secure public urls

This commit is contained in:
Mohamed Marrouchi 2025-01-09 12:42:52 +01:00
parent d48b88f41e
commit 0737cd99c6
11 changed files with 276 additions and 213 deletions

View File

@ -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: * 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. * 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 * Sets an event attribute value
* *
* @deprecated
* @param attr - Event attribute name * @param attr - Event attribute name
* @param value - The value to set for the specified attribute. * @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 * Returns an event attribute value, default value if it does exist
* *
* @deprecated
* @param attr - Event attribute name * @param attr - Event attribute name
* @param otherwise - Default value if attribute does not exist * @param otherwise - Default value if attribute does not exist
* *

View File

@ -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: * 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. * 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. * @param attachment The attachment ID or object to generate a signed URL for.
* @return A signed URL string for downloading the specified attachment. * @return A signed URL string for downloading the specified attachment.
*/ */
protected async getPublicUrl(attachment: string | Attachment) { public async getPublicUrl(attachment: string | Attachment) {
const resource = const resource =
typeof attachment === 'string' typeof attachment === 'string'
? await this.attachmentService.findOne(attachment) ? await this.attachmentService.findOne(attachment)

View File

@ -17,8 +17,9 @@ export enum FileType {
} }
export type AttachmentForeignKey = { export type AttachmentForeignKey = {
url?: string;
attachment_id: string; attachment_id: string;
/** @deprecated use "attachment_id" instead */
url?: string;
}; };
export interface AttachmentPayload< export interface AttachmentPayload<
@ -30,7 +31,5 @@ export interface AttachmentPayload<
export interface IncomingAttachmentPayload { export interface IncomingAttachmentPayload {
type: FileType; type: FileType;
payload: { payload: AttachmentForeignKey;
url: string;
};
} }

View File

@ -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: * 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. * 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: { attachments: {
type: FileType.file, type: FileType.file,
payload: { payload: {
attachment_id: '9'.repeat(24),
url: 'http://link.to/the/file', url: 'http://link.to/the/file',
}, },
}, },

View File

@ -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: * 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. * 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, 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', subtitle: 'About being first',
title: 'First', title: 'First',
}, },
@ -89,7 +89,7 @@ export const webList: Web.OutgoingMessageBase = {
type: ButtonType.postback, 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', subtitle: 'About being second',
title: 'Second', title: 'Second',
}, },
@ -109,7 +109,7 @@ export const webCarousel: Web.OutgoingMessageBase = {
type: ButtonType.postback, 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', subtitle: 'About being first',
title: 'First', title: 'First',
}, },
@ -121,7 +121,7 @@ export const webCarousel: Web.OutgoingMessageBase = {
type: ButtonType.postback, 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', subtitle: 'About being second',
title: 'Second', title: 'Second',
}, },
@ -140,7 +140,7 @@ export const webAttachment: Web.OutgoingMessageBase = {
}, },
], ],
type: FileType.image, 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, type: Web.OutgoingMessageType.file,
}; };

View File

@ -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: * 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. * 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'; import { Web } from '../types';
const img_url =
'http://demo.hexabot.ai/attachment/download/5c334078e2c41d11206bd152/myimage.png';
// Web events // Web events
const webEventPayload: Web.Event = { const webEventPayload: Web.Event = {
type: Web.IncomingMessageType.postback, type: Web.IncomingMessageType.postback,
@ -55,9 +52,10 @@ const webEventLocation: Web.IncomingMessage = {
const webEventFile: Web.Event = { const webEventFile: Web.Event = {
type: Web.IncomingMessageType.file, type: Web.IncomingMessageType.file,
data: { data: {
type: FileType.image, type: 'image/png',
url: img_url,
size: 500, size: 500,
name: 'filename.extension',
file: Buffer.from('my-image', 'utf-8'),
}, },
author: 'web-9be8ac09-b43a-432d-bca0-f11b98cec1ad', author: 'web-9be8ac09-b43a-432d-bca0-f11b98cec1ad',
mid: 'web-event-file', mid: 'web-event-file',
@ -151,18 +149,18 @@ export const webEvents: [string, Web.IncomingMessage, any][] = [
attachments: { attachments: {
type: FileType.image, type: FileType.image,
payload: { payload: {
url: img_url, attachment_id: '9'.repeat(24),
}, },
}, },
}, },
message: { message: {
attachment: { attachment: {
payload: { payload: {
url: img_url, attachment_id: '9'.repeat(24),
}, },
type: FileType.image, type: FileType.image,
}, },
serialized_text: `attachment:image:${img_url}`, serialized_text: 'attachment:image:filename.extension',
type: IncomingMessageType.attachments, type: IncomingMessageType.attachments,
}, },
}, },

View File

@ -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: * 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. * 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(); }).compile();
subscriberService = module.get<SubscriberService>(SubscriberService); subscriberService = module.get<SubscriberService>(SubscriberService);
handler = module.get<WebChannelHandler>(WebChannelHandler); handler = module.get<WebChannelHandler>(WebChannelHandler);
jest
.spyOn(handler, 'getPublicUrl')
.mockResolvedValue('http://public.url/download/filename.extension?t=any');
}); });
afterAll(async () => { afterAll(async () => {
jest.restoreAllMocks();
await closeInMongodConnection(); await closeInMongodConnection();
}); });
@ -204,15 +209,15 @@ describe('WebChannelHandler', () => {
expect(formatted).toEqual(webButtons); expect(formatted).toEqual(webButtons);
}); });
it('should format list properly', () => { it('should format list properly', async () => {
const formatted = handler._listFormat(contentMessage, { const formatted = await handler._listFormat(contentMessage, {
content: contentMessage.options, content: contentMessage.options,
}); });
expect(formatted).toEqual(webList); expect(formatted).toEqual(webList);
}); });
it('should format carousel properly', () => { it('should format carousel properly', async () => {
const formatted = handler._carouselFormat(contentMessage, { const formatted = await handler._carouselFormat(contentMessage, {
content: { content: {
...contentMessage.options, ...contentMessage.options,
display: OutgoingMessageFormat.carousel, display: OutgoingMessageFormat.carousel,
@ -221,8 +226,8 @@ describe('WebChannelHandler', () => {
expect(formatted).toEqual(webCarousel); expect(formatted).toEqual(webCarousel);
}); });
it('should format attachment properly', () => { it('should format attachment properly', async () => {
const formatted = handler._attachmentFormat(attachmentMessage, {}); const formatted = await handler._attachmentFormat(attachmentMessage, {});
expect(formatted).toEqual(webAttachment); expect(formatted).toEqual(webAttachment);
}); });

View File

@ -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: * 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. * 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 { Test, TestingModule } from '@nestjs/testing';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository'; 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 { AttachmentService } from '@/attachment/services/attachment.service';
import { ChannelService } from '@/channel/channel.service'; import { ChannelService } from '@/channel/channel.service';
import { MessageRepository } from '@/chat/repositories/message.repository'; import { MessageRepository } from '@/chat/repositories/message.repository';
import { SubscriberRepository } from '@/chat/repositories/subscriber.repository'; import { SubscriberRepository } from '@/chat/repositories/subscriber.repository';
import { MessageModel } from '@/chat/schemas/message.schema'; import { MessageModel } from '@/chat/schemas/message.schema';
import { SubscriberModel } from '@/chat/schemas/subscriber.schema'; import { SubscriberModel } from '@/chat/schemas/subscriber.schema';
import {
IncomingMessageType,
StdEventType,
} from '@/chat/schemas/types/message';
import { MessageService } from '@/chat/services/message.service'; import { MessageService } from '@/chat/services/message.service';
import { SubscriberService } from '@/chat/services/subscriber.service'; import { SubscriberService } from '@/chat/services/subscriber.service';
import { MenuRepository } from '@/cms/repositories/menu.repository'; import { MenuRepository } from '@/cms/repositories/menu.repository';
@ -122,6 +129,18 @@ describe(`Web event wrapper`, () => {
e, e,
expected.channelData, 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({ expect(event.getChannelData()).toEqual({
...expected.channelData, ...expected.channelData,
name: WEB_CHANNEL_NAME, name: WEB_CHANNEL_NAME,

View File

@ -22,11 +22,13 @@ import { MessageCreateDto } from '@/chat/dto/message.dto';
import { SubscriberCreateDto } from '@/chat/dto/subscriber.dto'; import { SubscriberCreateDto } from '@/chat/dto/subscriber.dto';
import { VIEW_MORE_PAYLOAD } from '@/chat/helpers/constants'; import { VIEW_MORE_PAYLOAD } from '@/chat/helpers/constants';
import { Subscriber, SubscriberFull } from '@/chat/schemas/subscriber.schema'; import { Subscriber, SubscriberFull } from '@/chat/schemas/subscriber.schema';
import { AttachmentForeignKey } from '@/chat/schemas/types/attachment';
import { Button, ButtonType } from '@/chat/schemas/types/button'; import { Button, ButtonType } from '@/chat/schemas/types/button';
import { import {
AnyMessage, AnyMessage,
ContentElement, ContentElement,
IncomingMessage, IncomingMessage,
IncomingMessageType,
OutgoingMessage, OutgoingMessage,
OutgoingMessageFormat, OutgoingMessageFormat,
PayloadType, PayloadType,
@ -127,9 +129,9 @@ export default abstract class BaseWebChannelHandler<
* @param incoming - Incoming message * @param incoming - Incoming message
* @returns Formatted web message * @returns Formatted web message
*/ */
private formatIncomingHistoryMessage( private async formatIncomingHistoryMessage(
incoming: IncomingMessage, incoming: IncomingMessage,
): Web.IncomingMessageBase { ): Promise<Web.IncomingMessageBase> {
// Format incoming message // Format incoming message
if ('type' in incoming.message) { if ('type' in incoming.message) {
if (incoming.message.type === PayloadType.location) { if (incoming.message.type === PayloadType.location) {
@ -145,14 +147,17 @@ export default abstract class BaseWebChannelHandler<
}; };
} else { } else {
// @TODO : handle multiple files // @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[0]
: incoming.message.attachment; : incoming.message.attachment;
return { return {
type: Web.IncomingMessageType.file, type: Web.IncomingMessageType.file,
data: { data: {
type: attachment.type, type: attachmentPayload.type,
url: attachment.payload.url, url: await this.getPublicUrl(
attachmentPayload.payload.attachment_id,
),
}, },
}; };
} }
@ -170,9 +175,9 @@ export default abstract class BaseWebChannelHandler<
* @param outgoing - The outgoing message * @param outgoing - The outgoing message
* @returns Formatted web message * @returns Formatted web message
*/ */
private formatOutgoingHistoryMessage( private async formatOutgoingHistoryMessage(
outgoing: OutgoingMessage, outgoing: OutgoingMessage,
): Web.OutgoingMessageBase { ): Promise<Web.OutgoingMessageBase> {
// Format outgoing message // Format outgoing message
if ('buttons' in outgoing.message) { if ('buttons' in outgoing.message) {
return this._buttonsFormat(outgoing.message); return this._buttonsFormat(outgoing.message);
@ -182,11 +187,11 @@ export default abstract class BaseWebChannelHandler<
return this._quickRepliesFormat(outgoing.message); return this._quickRepliesFormat(outgoing.message);
} else if ('options' in outgoing.message) { } else if ('options' in outgoing.message) {
if (outgoing.message.options.display === 'carousel') { if (outgoing.message.options.display === 'carousel') {
return this._carouselFormat(outgoing.message, { return await this._carouselFormat(outgoing.message, {
content: outgoing.message.options, content: outgoing.message.options,
}); });
} else { } else {
return this._listFormat(outgoing.message, { return await this._listFormat(outgoing.message, {
content: outgoing.message.options, content: outgoing.message.options,
}); });
} }
@ -212,12 +217,14 @@ export default abstract class BaseWebChannelHandler<
* *
* @returns Formatted message * @returns Formatted message
*/ */
protected formatMessages(messages: AnyMessage[]): Web.Message[] { protected async formatMessages(
messages: AnyMessage[],
): Promise<Web.Message[]> {
const formattedMessages: Web.Message[] = []; const formattedMessages: Web.Message[] = [];
for (const anyMessage of messages) { for (const anyMessage of messages) {
if (this.isIncomingMessage(anyMessage)) { if (this.isIncomingMessage(anyMessage)) {
const message = this.formatIncomingHistoryMessage(anyMessage); const message = await this.formatIncomingHistoryMessage(anyMessage);
formattedMessages.push({ formattedMessages.push({
...message, ...message,
author: anyMessage.sender, author: anyMessage.sender,
@ -226,7 +233,7 @@ export default abstract class BaseWebChannelHandler<
createdAt: anyMessage.createdAt, createdAt: anyMessage.createdAt,
}); });
} else { } else {
const message = this.formatOutgoingHistoryMessage(anyMessage); const message = await this.formatOutgoingHistoryMessage(anyMessage);
formattedMessages.push({ formattedMessages.push({
...message, ...message,
author: 'chatbot', author: 'chatbot',
@ -261,7 +268,7 @@ export default abstract class BaseWebChannelHandler<
until, until,
n, n,
); );
return this.formatMessages(messages.reverse()); return await this.formatMessages(messages.reverse());
} }
return []; return [];
} }
@ -286,7 +293,7 @@ export default abstract class BaseWebChannelHandler<
since, since,
n, n,
); );
return this.formatMessages(messages); return await this.formatMessages(messages);
} }
return []; return [];
} }
@ -579,9 +586,8 @@ export default abstract class BaseWebChannelHandler<
'since' in req.query 'since' in req.query
? req.query.since // Long polling case ? req.query.since // Long polling case
: req.body?.since || undefined; // Websocket case : req.body?.since || undefined; // Websocket case
return this.fetchHistory(req, criteria).then((messages) => { const messages = await this.fetchHistory(req, criteria);
return res.status(200).json({ profile, messages }); return res.status(200).json({ profile, messages });
});
} catch (err) { } catch (err) {
this.logger.warn('Web Channel Handler : Unable to subscribe ', err); this.logger.warn('Web Channel Handler : Unable to subscribe ', err);
return res.status(500).json({ err: 'Unable to subscribe' }); 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) * @returns The stored attachment or null
* @param res Either a HTTP Express response or a WS response (Synthetic Object)
* @param next Callback Function
*/ */
async handleFilesUpload( async handleWsUpload(req: SocketRequest): Promise<Attachment | null> {
req: Request | SocketRequest, try {
res: Response | SocketResponse, const { type, data } = req.body as Web.IncomingMessage;
next: (
err: null | Error,
result?: Web.IncomingAttachmentMessageData,
) => void,
): Promise<void> {
// Check if any file is provided
if (!req.session.web) {
this.logger.debug('Web Channel Handler : No session provided');
return next(null);
}
if (this.isSocketRequest(req)) { // Check if any file is provided
try { if (type !== 'file' || !('file' in data) || !data.file) {
const { type, data } = req.body as Web.IncomingMessage; this.logger.debug('Web Channel Handler : No files provided');
return null;
// 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!'));
} }
} 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<Attachment | null> {
try {
const upload = multer({ const upload = multer({
limits: { limits: {
fileSize: config.parameters.maxUploadSize, fileSize: config.parameters.maxUploadSize,
@ -655,36 +653,66 @@ export default abstract class BaseWebChannelHandler<
})(), })(),
}).single('file'); // 'file' is the field name in the form }).single('file'); // 'file' is the field name in the form
upload(req as Request, res as Response, async (err?: any) => { const multerUpload = new Promise<Express.Multer.File | null>(
if (err) { (resolve, reject) => {
this.logger.error( upload(req as Request, res as Response, async (err?: any) => {
'Web Channel Handler : Unable to write uploaded file', if (err) {
err, this.logger.error(
); 'Web Channel Handler : Unable to store uploaded file',
return next(new Error('Unable to upload file!')); err,
} );
reject(new Error('Unable to upload file!'));
}
// Check if any file is provided resolve(req.file);
if (!req.file) { });
this.logger.debug('Web Channel Handler : No files provided'); },
return next(null); );
}
try { const file = await multerUpload;
const file = req.file;
const attachment = await this.attachmentService.store(file, { // Check if any file is provided
name: file.originalname, if (!req.file) {
size: file.size, this.logger.debug('Web Channel Handler : No files provided');
type: file.mimetype, return null;
}); }
next(null, {
type: Attachment.getTypeByMime(attachment.type), const attachment = await this.attachmentService.store(file, {
url: Attachment.getAttachmentUrl(attachment.id, attachment.name), name: file.originalname,
}); size: file.size,
} catch (err) { type: file.mimetype,
next(err);
}
}); });
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<Attachment | null> {
// 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; : req.body.data;
} }
this.validateSession(req, res, (profile) => { this.validateSession(req, res, async (profile) => {
this.handleFilesUpload( // Set data in file upload case
req, const body: Web.IncomingMessage = req.body;
res,
(err: Error, data?: Web.IncomingAttachmentMessageData) => { const channelAttrs = this.getChannelAttributes(req);
if (err) { 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;
}
} catch (err) {
this.logger.warn( this.logger.warn(
'Web Channel Handler : Unable to upload file ', 'Web Channel Handler : Unable to upload file ',
err, err,
@ -762,49 +799,39 @@ export default abstract class BaseWebChannelHandler<
.status(403) .status(403)
.json({ err: 'Web Channel Handler : File upload failed!' }); .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); // Handler sync message sent by chabbot
const event = new WebEventWrapper<N>(this, body, channelAttrs); if (body.sync && body.author === 'chatbot') {
if (event.getEventType() === 'message') { const sentMessage: MessageCreateDto = {
// Handler sync message sent by chabbot mid: event.getId(),
if (body.sync && body.author === 'chatbot') { message: event.getMessage() as StdOutgoingMessage,
const sentMessage: MessageCreateDto = { recipient: profile.id,
mid: event.getId(), read: true,
message: event.getMessage() as StdOutgoingMessage, delivery: true,
recipient: profile.id, };
read: true, this.eventEmitter.emit('hook:chatbot:sent', sentMessage, event);
delivery: true, return res.status(200).json(event._adapter.raw);
}; } else {
this.eventEmitter.emit('hook:chatbot:sent', sentMessage, event); // Generate unique ID and handle message
return res.status(200).json(event._adapter.raw); event._adapter.raw.mid = this.generateId();
} else {
// Generate unique ID and handle message
event.set('mid', this.generateId());
}
}
// Force author id from session // Force author id from session
event.set('author', profile.foreign_id); event._adapter.raw.author = profile.foreign_id;
event.setSender(profile); }
}
const type = event.getEventType(); event.setSender(profile);
if (type) {
this.eventEmitter.emit(`hook:chatbot:${type}`, event); const type = event.getEventType();
} else { if (type) {
this.logger.error( this.eventEmitter.emit(`hook:chatbot:${type}`, event);
'Web Channel Handler : Webhook received unknown event ', } else {
event, this.logger.error(
); 'Web Channel Handler : Webhook received unknown event ',
} event,
res.status(200).json(event._adapter.raw); );
}, }
); res.status(200).json(event._adapter.raw);
}); });
} }
@ -957,15 +984,15 @@ export default abstract class BaseWebChannelHandler<
* *
* @returns A ready to be sent attachment message * @returns A ready to be sent attachment message
*/ */
_attachmentFormat( async _attachmentFormat(
message: StdOutgoingAttachmentMessage<Attachment>, message: StdOutgoingAttachmentMessage<Attachment>,
_options?: BlockOptions, _options?: BlockOptions,
): Web.OutgoingMessageBase { ): Promise<Web.OutgoingMessageBase> {
const payload: Web.OutgoingMessageBase = { const payload: Web.OutgoingMessageBase = {
type: Web.OutgoingMessageType.file, type: Web.OutgoingMessageType.file,
data: { data: {
type: message.attachment.type, type: message.attachment.type,
url: message.attachment.payload.url, url: await this.getPublicUrl(message.attachment.payload.id),
}, },
}; };
if (message.quickReplies && message.quickReplies.length > 0) { if (message.quickReplies && message.quickReplies.length > 0) {
@ -982,35 +1009,42 @@ export default abstract class BaseWebChannelHandler<
* *
* @returns An array of elements object * @returns An array of elements object
*/ */
_formatElements( async _formatElements(
data: ContentElement[], data: ContentElement[],
options: BlockOptions, options: BlockOptions,
): Web.MessageElement[] { ): Promise<Web.MessageElement[]> {
if (!options.content || !options.content.fields) { if (!options.content || !options.content.fields) {
throw new Error('Content options are missing the fields'); throw new Error('Content options are missing the fields');
} }
const fields = options.content.fields; const fields = options.content.fields;
const buttons: Button[] = options.content.buttons; const buttons: Button[] = options.content.buttons;
return data.map((item) => { const result: Web.MessageElement[] = [];
for (const item of data) {
const element: Web.MessageElement = { const element: Web.MessageElement = {
title: item[fields.title], title: item[fields.title],
buttons: item.buttons || [], buttons: item.buttons || [],
}; };
if (fields.subtitle && item[fields.subtitle]) { if (fields.subtitle && item[fields.subtitle]) {
element.subtitle = item[fields.subtitle]; element.subtitle = item[fields.subtitle];
} }
if (fields.image_url && item[fields.image_url]) { 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.url) {
if (!attachmentPayload.id) { if (!attachmentPayload.attachment_id) {
// @deprecated // @deprecated
this.logger.warn( this.logger.warn(
'Web Channel Handler: Attachment remote url has been deprecated', 'Web Channel Handler: Attachment remote url has been deprecated',
item, 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); element.buttons?.push(btn);
}); });
if (Array.isArray(element.buttons) && element.buttons.length === 0) { if (Array.isArray(element.buttons) && element.buttons.length === 0) {
delete element.buttons; 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 * @returns A ready to be sent list template message
*/ */
_listFormat( async _listFormat(
message: StdOutgoingListMessage, message: StdOutgoingListMessage,
options: BlockOptions, options: BlockOptions,
): Web.OutgoingMessageBase { ): Promise<Web.OutgoingMessageBase> {
const data = message.elements || []; const data = message.elements || [];
const pagination = message.pagination; const pagination = message.pagination;
let buttons: Button[] = [], let buttons: Button[] = [],
@ -1091,7 +1129,7 @@ export default abstract class BaseWebChannelHandler<
} }
// Populate items (elements/cards) with content // Populate items (elements/cards) with content
elements = this._formatElements(data, options); elements = await this._formatElements(data, options);
const topElementStyle = options.content?.top_element_style const topElementStyle = options.content?.top_element_style
? { ? {
top_element_style: 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 * @returns A carousel ready to be sent as a message
*/ */
_carouselFormat( async _carouselFormat(
message: StdOutgoingListMessage, message: StdOutgoingListMessage,
options: BlockOptions, options: BlockOptions,
): Web.OutgoingMessageBase { ): Promise<Web.OutgoingMessageBase> {
const data = message.elements || []; const data = message.elements || [];
// Items count min check // Items count min check
if (data.length === 0) { if (data.length === 0) {
@ -1129,7 +1167,7 @@ export default abstract class BaseWebChannelHandler<
} }
// Populate items (elements/cards) with content // Populate items (elements/cards) with content
const elements = this._formatElements(data, options); const elements = await this._formatElements(data, options);
return { return {
type: Web.OutgoingMessageType.carousel, type: Web.OutgoingMessageType.carousel,
data: { data: {
@ -1146,19 +1184,19 @@ export default abstract class BaseWebChannelHandler<
* *
* @returns A template filled with its payload * @returns A template filled with its payload
*/ */
_formatMessage( async _formatMessage(
envelope: StdOutgoingEnvelope, envelope: StdOutgoingEnvelope,
options: BlockOptions, options: BlockOptions,
): Web.OutgoingMessageBase { ): Promise<Web.OutgoingMessageBase> {
switch (envelope.format) { switch (envelope.format) {
case OutgoingMessageFormat.attachment: case OutgoingMessageFormat.attachment:
return this._attachmentFormat(envelope.message, options); return await this._attachmentFormat(envelope.message, options);
case OutgoingMessageFormat.buttons: case OutgoingMessageFormat.buttons:
return this._buttonsFormat(envelope.message, options); return this._buttonsFormat(envelope.message, options);
case OutgoingMessageFormat.carousel: case OutgoingMessageFormat.carousel:
return this._carouselFormat(envelope.message, options); return await this._carouselFormat(envelope.message, options);
case OutgoingMessageFormat.list: case OutgoingMessageFormat.list:
return this._listFormat(envelope.message, options); return await this._listFormat(envelope.message, options);
case OutgoingMessageFormat.quickReplies: case OutgoingMessageFormat.quickReplies:
return this._quickRepliesFormat(envelope.message, options); return this._quickRepliesFormat(envelope.message, options);
case OutgoingMessageFormat.text: case OutgoingMessageFormat.text:
@ -1206,7 +1244,7 @@ export default abstract class BaseWebChannelHandler<
options: BlockOptions, options: BlockOptions,
_context?: any, _context?: any,
): Promise<{ mid: string }> { ): Promise<{ mid: string }> {
const messageBase: Web.OutgoingMessageBase = this._formatMessage( const messageBase: Web.OutgoingMessageBase = await this._formatMessage(
envelope, envelope,
options, options,
); );

View File

@ -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: * 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. * 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 = export type IncomingAttachmentMessageData =
// After upload and attachment is processed // When it's a incoming history message
| { | {
type: FileType; type: FileType;
url: string; // file download url url: string; // file download url
} // Before upload and attachment is processed }
// When it's a file upload message
| { | {
type: string; // mime type type: string; // mime type
size: number; // file size size: number; // file size

View File

@ -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: * 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. * 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). * 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 EventWrapper from '@/channel/lib/EventWrapper';
import { ChannelName } from '@/channel/types'; import { ChannelName } from '@/channel/types';
import { import {
@ -66,14 +67,13 @@ type WebEventAdapter =
eventType: StdEventType.message; eventType: StdEventType.message;
messageType: IncomingMessageType.attachments; messageType: IncomingMessageType.attachments;
raw: Web.IncomingMessage<Web.IncomingAttachmentMessage>; raw: Web.IncomingMessage<Web.IncomingAttachmentMessage>;
attachment: Attachment | null;
}; };
// eslint-disable-next-line prettier/prettier // eslint-disable-next-line prettier/prettier
export default class WebEventWrapper<N extends ChannelName> extends EventWrapper< export default class WebEventWrapper<
WebEventAdapter, N extends ChannelName,
Web.Event, > extends EventWrapper<WebEventAdapter, Web.Event, N> {
N
> {
/** /**
* Constructor : channel's event wrapper * Constructor : channel's event wrapper
* *
@ -216,16 +216,16 @@ export default class WebEventWrapper<N extends ChannelName> extends EventWrapper
}; };
} }
case IncomingMessageType.attachments: case IncomingMessageType.attachments:
if (!('url' in this._adapter.raw.data)) { if (!this._adapter.attachment) {
throw new Error('Attachment has not been processed'); throw new Error('Attachment has not been processed');
} }
return { return {
type: PayloadType.attachments, type: PayloadType.attachments,
attachments: { attachments: {
type: this._adapter.raw.data.type, type: Attachment.getTypeByMime(this._adapter.raw.data.type),
payload: { payload: {
url: this._adapter.raw.data.url, attachment_id: this._adapter.attachment.id,
}, },
}, },
}; };
@ -266,19 +266,20 @@ export default class WebEventWrapper<N extends ChannelName> extends EventWrapper
} }
case IncomingMessageType.attachments: { case IncomingMessageType.attachments: {
const attachment = this._adapter.raw.data; if (!this._adapter.attachment) {
if (!('url' in attachment)) {
throw new Error('Attachment has not been processed'); throw new Error('Attachment has not been processed');
} }
const fileType = Attachment.getTypeByMime(
this._adapter.attachment.type,
);
return { return {
type: PayloadType.attachments, type: PayloadType.attachments,
serialized_text: `attachment:${attachment.type}:${attachment.url}`, serialized_text: `attachment:${fileType}:${this._adapter.attachment.name}`,
attachment: { attachment: {
type: attachment.type, type: fileType,
payload: { payload: {
url: attachment.url, attachment_id: this._adapter.attachment.id,
}, },
}, },
}; };