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:
* 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
*

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:
* 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)

View File

@ -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;
}

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:
* 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',
},
},

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:
* 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,
};

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:
* 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,
},
},

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:
* 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>(SubscriberService);
handler = module.get<WebChannelHandler>(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);
});

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:
* 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,

View File

@ -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<Web.IncomingMessageBase> {
// 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<Web.OutgoingMessageBase> {
// 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<Web.Message[]> {
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<void> {
// 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<Attachment | null> {
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<Attachment | null> {
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<Express.Multer.File | null>(
(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<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;
}
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<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(
'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<N>(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<Attachment>,
_options?: BlockOptions,
): Web.OutgoingMessageBase {
): Promise<Web.OutgoingMessageBase> {
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<Web.MessageElement[]> {
if (!options.content || !options.content.fields) {
throw new Error('Content options are missing the fields');
}
const fields = options.content.fields;
const buttons: Button[] = options.content.buttons;
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<Web.OutgoingMessageBase> {
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<Web.OutgoingMessageBase> {
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<Web.OutgoingMessageBase> {
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,
);

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:
* 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

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:
* 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<Web.IncomingAttachmentMessage>;
attachment: Attachment | null;
};
// eslint-disable-next-line prettier/prettier
export default class WebEventWrapper<N extends ChannelName> extends EventWrapper<
WebEventAdapter,
Web.Event,
N
> {
export default class WebEventWrapper<
N extends ChannelName,
> extends EventWrapper<WebEventAdapter, Web.Event, N> {
/**
* Constructor : channel's event wrapper
*
@ -216,16 +216,16 @@ export default class WebEventWrapper<N extends ChannelName> 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<N extends ChannelName> 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,
},
},
};