diff --git a/api/src/attachment/services/attachment.service.ts b/api/src/attachment/services/attachment.service.ts index 1ad83a1b..665e0d2b 100644 --- a/api/src/attachment/services/attachment.service.ts +++ b/api/src/attachment/services/attachment.service.ts @@ -8,7 +8,7 @@ import fs, { createReadStream, promises as fsPromises } from 'fs'; import { join, resolve } from 'path'; -import { Readable } from 'stream'; +import { Readable, Stream } from 'stream'; import { Injectable, @@ -202,7 +202,7 @@ export class AttachmentService extends BaseService { * @returns A promise that resolves to an array of uploaded attachments. */ async store( - file: Buffer | Readable | Express.Multer.File, + file: Buffer | Stream | Readable | Express.Multer.File, metadata: AttachmentMetadataDto, rootDir = config.parameters.uploadDir, ): Promise { @@ -219,10 +219,11 @@ export class AttachmentService extends BaseService { if (Buffer.isBuffer(file)) { await fsPromises.writeFile(filePath, file); - } else if (file instanceof Readable) { + } else if (file instanceof Readable || file instanceof Stream) { await new Promise((resolve, reject) => { const writeStream = fs.createWriteStream(filePath); file.pipe(writeStream); + // @TODO: Calc size here? writeStream.on('finish', resolve); writeStream.on('error', reject); }); @@ -285,7 +286,7 @@ export class AttachmentService extends BaseService { * * @param attachment - The attachment to download. * @param rootDir - Root folder path where the attachment should be located. - * @returns A promise that resolves to a Buffer representing the downloaded attachment. + * @returns A promise that resolves to a Buffer representing the attachment file. */ async readAsBuffer( attachment: Attachment, @@ -303,4 +304,28 @@ export class AttachmentService extends BaseService { return await fs.promises.readFile(path); // Reads the file content as a Buffer } } + + /** + * Returns an attachment identified by the provided parameters as a Stream. + * + * @param attachment - The attachment to download. + * @param rootDir - Root folder path where the attachment should be located. + * @returns A promise that resolves to a Stream representing the attachment file. + */ + async readAsStream( + attachment: Attachment, + rootDir = config.parameters.uploadDir, + ): Promise { + if (this.getStoragePlugin()) { + return await this.getStoragePlugin()?.readAsStream?.(attachment); + } else { + const path = resolve(join(rootDir, attachment.location)); + + if (!fileExists(path)) { + throw new NotFoundException('No file was found'); + } + + return fs.createReadStream(path); // Reads the file content as a Buffer + } + } } diff --git a/api/src/channel/lib/EventWrapper.ts b/api/src/channel/lib/EventWrapper.ts index b5ed69fd..c8a9e47a 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. @@ -7,10 +7,7 @@ */ import { Subscriber } from '@/chat/schemas/subscriber.schema'; -import { - AttachmentForeignKey, - AttachmentPayload, -} from '@/chat/schemas/types/attachment'; +import { AttachmentPayload } from '@/chat/schemas/types/attachment'; import { SubscriberChannelData } from '@/chat/schemas/types/channel'; import { IncomingMessageType, @@ -101,7 +98,7 @@ export default abstract class EventWrapper< * * @returns The current instance of the channel handler. */ - getHandler(): ChannelHandler { + getHandler(): C { return this._handler; } @@ -126,6 +123,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 +134,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 * @@ -190,6 +189,16 @@ export default abstract class EventWrapper< this._profile = profile; } + /** + * Pre-Process the message event + * + * Child class can perform operations such as storing files as attachments. + */ + preprocess() { + // Nothing ... + return Promise.resolve(); + } + /** * Returns event recipient id * @@ -249,7 +258,7 @@ export default abstract class EventWrapper< * * @returns Received attachments message */ - abstract getAttachments(): AttachmentPayload[]; + abstract getAttachments(): AttachmentPayload[]; /** * Returns the list of delivered messages @@ -378,7 +387,7 @@ export class GenericEventWrapper extends EventWrapper< * @returns A list of received attachments * @deprecated - This method is deprecated */ - getAttachments(): AttachmentPayload[] { + getAttachments(): AttachmentPayload[] { return []; } diff --git a/api/src/channel/lib/Handler.ts b/api/src/channel/lib/Handler.ts index 85554a62..d488cc72 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. @@ -21,6 +21,7 @@ import { NextFunction, Request, Response } from 'express'; import { Attachment } from '@/attachment/schemas/attachment.schema'; import { AttachmentService } from '@/attachment/services/attachment.service'; import { SubscriberCreateDto } from '@/chat/dto/subscriber.dto'; +import { AttachmentRef } from '@/chat/schemas/types/attachment'; import { StdOutgoingEnvelope, StdOutgoingMessage, @@ -234,22 +235,32 @@ 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) { - const resource = - typeof attachment === 'string' - ? await this.attachmentService.findOne(attachment) - : attachment; + public async getPublicUrl(attachment: AttachmentRef | Attachment) { + if ('id' in attachment) { + if (!attachment.id) { + throw new TypeError( + 'Attachment ID is empty, unable to generate public URL.', + ); + } - if (!resource) { - throw new NotFoundException('Unable to find attachment'); + const resource = await this.attachmentService.findOne(attachment.id); + + if (!resource) { + throw new NotFoundException('Unable to find attachment'); + } + + const token = this.jwtService.sign({ ...resource }, this.jwtSignOptions); + const [name, _suffix] = this.getName().split('-'); + return buildURL( + config.apiBaseUrl, + `/webhook/${name}/download/${resource.name}?t=${encodeURIComponent(token)}`, + ); + } else if ('url' in attachment && attachment.url) { + // In case the url is external + return attachment.url; + } else { + throw new TypeError('Unable to resolve the attachment public URL.'); } - - const token = this.jwtService.sign({ ...resource }, this.jwtSignOptions); - const [name, _suffix] = this.getName().split('-'); - return buildURL( - config.apiBaseUrl, - `/webhook/${name}/download/${resource.name}?t=${encodeURIComponent(token)}`, - ); } /** @@ -266,7 +277,11 @@ export default abstract class ChannelHandler< */ public async download(token: string, _req: Request) { try { - const result = this.jwtService.verify(token, this.jwtSignOptions); + const { + exp: _exp, + iat: _iat, + ...result + } = this.jwtService.verify(token, this.jwtSignOptions); const attachment = plainToClass(Attachment, result); return await this.attachmentService.download(attachment); } catch (err) { diff --git a/api/src/channel/lib/__test__/common.mock.ts b/api/src/channel/lib/__test__/common.mock.ts index e85a9542..ebc85516 100644 --- a/api/src/channel/lib/__test__/common.mock.ts +++ b/api/src/channel/lib/__test__/common.mock.ts @@ -78,25 +78,20 @@ export const urlButtonsMessage: StdOutgoingButtonsMessage = { }; const attachment: Attachment = { - id: '1', + id: '1'.repeat(24), name: 'attachment.jpg', type: 'image/jpeg', size: 3539, location: '39991e51-55c6-4a26-9176-b6ba04f180dc.jpg', channel: { - ['dimelo']: { - id: 'attachment-id-dimelo', + ['any-channel']: { + id: 'any-channel-attachment-id', }, }, createdAt: new Date(), updatedAt: new Date(), }; -const attachmentWithUrl: Attachment = { - ...attachment, - url: 'http://localhost:4000/attachment/download/1/attachment.jpg', -}; - export const contentMessage: StdOutgoingListMessage = { options: { display: OutgoingMessageFormat.list, @@ -121,7 +116,8 @@ export const contentMessage: StdOutgoingListMessage = { title: 'First', desc: 'About being first', thumbnail: { - payload: attachmentWithUrl, + type: 'image', + payload: { id: attachment.id }, }, getPayload() { return this.title; @@ -136,7 +132,8 @@ export const contentMessage: StdOutgoingListMessage = { title: 'Second', desc: 'About being second', thumbnail: { - payload: attachmentWithUrl, + type: 'image', + payload: { id: attachment.id }, }, getPayload() { return this.title; @@ -149,14 +146,14 @@ export const contentMessage: StdOutgoingListMessage = { pagination: { total: 3, skip: 0, - limit: 1, + limit: 2, }, }; -export const attachmentMessage: StdOutgoingAttachmentMessage = { +export const attachmentMessage: StdOutgoingAttachmentMessage = { attachment: { type: FileType.image, - payload: attachmentWithUrl, + payload: { id: attachment.id }, }, quickReplies: [ { diff --git a/api/src/chat/dto/subscriber.dto.ts b/api/src/chat/dto/subscriber.dto.ts index 02e0e2fe..cc56e91c 100644 --- a/api/src/chat/dto/subscriber.dto.ts +++ b/api/src/chat/dto/subscriber.dto.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. @@ -112,6 +112,16 @@ export class SubscriberCreateDto { @IsNotEmpty() @IsChannelData() channel: SubscriberChannelData; + + @ApiPropertyOptional({ + description: 'Subscriber Avatar', + type: String, + default: null, + }) + @IsOptional() + @IsString() + @IsObjectId({ message: 'Avatar Attachment ID must be a valid ObjectId' }) + avatar?: string | null = null; } export class SubscriberUpdateDto extends PartialType(SubscriberCreateDto) {} diff --git a/api/src/chat/repositories/block.repository.ts b/api/src/chat/repositories/block.repository.ts index 14cf333f..c10f11c6 100644 --- a/api/src/chat/repositories/block.repository.ts +++ b/api/src/chat/repositories/block.repository.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. @@ -58,7 +58,7 @@ export class BlockRepository extends BaseRepository< 'url' in block.message.attachment.payload ) { this.logger?.error( - 'NOTE: `url` payload has been deprecated in favor of `attachment_id`', + 'NOTE: `url` payload has been deprecated in favor of `id`', block.name, ); } diff --git a/api/src/chat/schemas/types/attachment.ts b/api/src/chat/schemas/types/attachment.ts index 777dd7e9..cdf02e5f 100644 --- a/api/src/chat/schemas/types/attachment.ts +++ b/api/src/chat/schemas/types/attachment.ts @@ -6,8 +6,6 @@ * 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'; - export enum FileType { image = 'image', video = 'video', @@ -16,21 +14,24 @@ export enum FileType { unknown = 'unknown', } -export type AttachmentForeignKey = { - url?: string; - attachment_id: string; -}; +/** + * The `AttachmentRef` type defines two possible ways to reference an attachment: + * 1. By `id`: This is used when the attachment is uploaded and stored in the Hexabot system. + * The `id` field represents the unique identifier of the uploaded attachment in the system. + * 2. By `url`: This is used when the attachment is externally hosted, especially when + * the content is generated or retrieved by a plugin that consumes a third-party API. + * In this case, the `url` field contains the direct link to the external resource. + */ +export type AttachmentRef = + | { + id: string | null; + } + | { + /** @deprecated To be used only for external URLs (plugins), for stored attachments use "id" instead */ + url: string; + }; -export interface AttachmentPayload< - A extends Attachment | AttachmentForeignKey, -> { +export interface AttachmentPayload { type: FileType; - payload: A; -} - -export interface IncomingAttachmentPayload { - type: FileType; - payload: { - url: string; - }; + payload: AttachmentRef; } diff --git a/api/src/chat/schemas/types/message.ts b/api/src/chat/schemas/types/message.ts index 78b9fc1c..c8c8e14f 100644 --- a/api/src/chat/schemas/types/message.ts +++ b/api/src/chat/schemas/types/message.ts @@ -6,16 +6,11 @@ * 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 { PluginName } from '@/plugins/types'; import { Message } from '../message.schema'; -import { - AttachmentForeignKey, - AttachmentPayload, - IncomingAttachmentPayload, -} from './attachment'; +import { AttachmentPayload } from './attachment'; import { Button } from './button'; import { ContentOptions } from './options'; import { StdQuickReply } from './quick-reply'; @@ -100,11 +95,9 @@ export type StdOutgoingListMessage = { }; }; -export type StdOutgoingAttachmentMessage< - A extends Attachment | AttachmentForeignKey, -> = { +export type StdOutgoingAttachmentMessage = { // Stored in DB as `AttachmentPayload`, `Attachment` when populated for channels relaying - attachment: AttachmentPayload; + attachment: AttachmentPayload; quickReplies?: StdQuickReply[]; }; @@ -119,7 +112,7 @@ export type BlockMessage = | StdOutgoingQuickRepliesMessage | StdOutgoingButtonsMessage | StdOutgoingListMessage - | StdOutgoingAttachmentMessage + | StdOutgoingAttachmentMessage | StdPluginMessage; export type StdOutgoingMessage = @@ -127,7 +120,7 @@ export type StdOutgoingMessage = | StdOutgoingQuickRepliesMessage | StdOutgoingButtonsMessage | StdOutgoingListMessage - | StdOutgoingAttachmentMessage; + | StdOutgoingAttachmentMessage; type StdIncomingTextMessage = { text: string }; @@ -146,7 +139,7 @@ export type StdIncomingLocationMessage = { export type StdIncomingAttachmentMessage = { type: PayloadType.attachments; serialized_text: string; - attachment: IncomingAttachmentPayload | IncomingAttachmentPayload[]; + attachment: AttachmentPayload | AttachmentPayload[]; }; export type StdIncomingMessage = @@ -191,7 +184,7 @@ export interface StdOutgoingListEnvelope { export interface StdOutgoingAttachmentEnvelope { format: OutgoingMessageFormat.attachment; - message: StdOutgoingAttachmentMessage; + message: StdOutgoingAttachmentMessage; } export type StdOutgoingEnvelope = diff --git a/api/src/chat/schemas/types/quick-reply.ts b/api/src/chat/schemas/types/quick-reply.ts index df47e1a3..18ae2b37 100644 --- a/api/src/chat/schemas/types/quick-reply.ts +++ b/api/src/chat/schemas/types/quick-reply.ts @@ -1,12 +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 { IncomingAttachmentPayload } from './attachment'; +import { AttachmentPayload } from './attachment'; import { PayloadType } from './message'; export type Payload = @@ -19,7 +19,7 @@ export type Payload = } | { type: PayloadType.attachments; - attachments: IncomingAttachmentPayload; + attachments: AttachmentPayload; }; export enum QuickReplyType { diff --git a/api/src/chat/services/block.service.spec.ts b/api/src/chat/services/block.service.spec.ts index 6e46a30f..f4d21374 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. @@ -392,6 +392,7 @@ describe('BlockService', () => { attachments: { type: FileType.file, payload: { + id: '9'.repeat(24), url: 'http://link.to/the/file', }, }, diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 44ebb732..736c5595 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -417,7 +417,7 @@ export class BlockService extends BaseService< 'url' in block.message.attachment.payload ) { this.logger.error( - 'Attachment Model : `url` payload has been deprecated in favor of `attachment_id`', + 'Attachment Block : `url` payload has been deprecated in favor of `id`', block.id, block.message, ); @@ -527,21 +527,11 @@ export class BlockService extends BaseService< } } else if (blockMessage && 'attachment' in blockMessage) { const attachmentPayload = blockMessage.attachment.payload; - if (!attachmentPayload.attachment_id) { + if (!('id' in attachmentPayload)) { this.checkDeprecatedAttachmentUrl(block); - throw new Error('Remote attachments are no longer supported!'); - } - - const attachment = await this.attachmentService.findOne( - attachmentPayload.attachment_id, - ); - - if (!attachment) { - this.logger.debug( - 'Unable to locate the attachment for the given block', - block, + throw new Error( + 'Remote attachments in blocks are no longer supported!', ); - throw new Error('Unable to find attachment.'); } const envelope: StdOutgoingEnvelope = { @@ -549,7 +539,7 @@ export class BlockService extends BaseService< message: { attachment: { type: blockMessage.attachment.type, - payload: attachment, + payload: blockMessage.attachment.payload, }, quickReplies: blockMessage.quickReplies ? [...blockMessage.quickReplies] diff --git a/api/src/chat/services/chat.service.ts b/api/src/chat/services/chat.service.ts index e13f4506..ddf71e4d 100644 --- a/api/src/chat/services/chat.service.ts +++ b/api/src/chat/services/chat.service.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. @@ -237,7 +237,7 @@ export class ChatService { */ @OnEvent('hook:chatbot:message') async handleNewMessage(event: EventWrapper) { - this.logger.debug('New message received', event); + this.logger.debug('New message received', event._adapter.raw); const foreignId = event.getSenderForeignId(); const handler = event.getHandler(); @@ -262,6 +262,8 @@ export class ChatService { event.setSender(subscriber); + await event.preprocess(); + // Trigger message received event this.eventEmitter.emit('hook:chatbot:received', event); diff --git a/api/src/chat/validation-rules/is-message.ts b/api/src/chat/validation-rules/is-message.ts index fbd1589b..67688d94 100644 --- a/api/src/chat/validation-rules/is-message.ts +++ b/api/src/chat/validation-rules/is-message.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. @@ -71,7 +71,7 @@ export function isValidMessage(msg: any) { .required(), payload: Joi.object().keys({ url: Joi.string().uri(), - attachment_id: Joi.string().allow(null), + id: Joi.string().allow(null), }), }), elements: Joi.boolean(), diff --git a/api/src/cms/services/content.service.spec.ts b/api/src/cms/services/content.service.spec.ts index 696732cd..7edd605c 100644 --- a/api/src/cms/services/content.service.spec.ts +++ b/api/src/cms/services/content.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. @@ -13,11 +13,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AttachmentRepository } from '@/attachment/repositories/attachment.repository'; import { AttachmentModel } from '@/attachment/schemas/attachment.schema'; import { AttachmentService } from '@/attachment/services/attachment.service'; -import { FileType } from '@/chat/schemas/types/attachment'; -import { - ContentElement, - OutgoingMessageFormat, -} from '@/chat/schemas/types/message'; +import { OutgoingMessageFormat } from '@/chat/schemas/types/message'; import { ContentOptions } from '@/chat/schemas/types/options'; import { LoggerService } from '@/logger/logger.service'; import { IGNORED_TEST_FIELDS } from '@/utils/test/constants'; @@ -43,7 +39,6 @@ describe('ContentService', () => { let contentService: ContentService; let contentTypeService: ContentTypeService; let contentRepository: ContentRepository; - let attachmentService: AttachmentService; beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -69,7 +64,6 @@ describe('ContentService', () => { contentService = module.get(ContentService); contentTypeService = module.get(ContentTypeService); contentRepository = module.get(ContentRepository); - attachmentService = module.get(AttachmentService); }); afterAll(async () => { @@ -111,103 +105,6 @@ describe('ContentService', () => { }); }); - describe('getAttachmentIds', () => { - const contents: Content[] = [ - { - id: '1', - title: 'store 1', - entity: 'stores', - status: true, - dynamicFields: { - image: { - type: FileType.image, - payload: { - attachment_id: '123', - }, - }, - }, - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: '2', - title: 'store 2', - entity: 'stores', - status: true, - dynamicFields: { - image: { - type: FileType.image, - payload: { - attachment_id: '456', - }, - }, - }, - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: '3', - title: 'store 3', - entity: 'stores', - status: true, - dynamicFields: { - image: { - type: FileType.image, - payload: { - url: 'https://remote.file/image.jpg', - }, - }, - }, - createdAt: new Date(), - updatedAt: new Date(), - }, - ]; - it('should return all content attachment ids', () => { - const result = contentService.getAttachmentIds( - contents.map(Content.toElement), - 'image', - ); - expect(result).toEqual(['123', '456']); - }); - - it('should not return any of the attachment ids', () => { - const result = contentService.getAttachmentIds(contents, 'file'); - expect(result).toEqual([]); - }); - }); - - describe('populateAttachments', () => { - it('should return populated content', async () => { - const storeContents = await contentService.find({ title: /^store/ }); - const elements: ContentElement[] = await Promise.all( - storeContents.map(Content.toElement).map(async (store) => { - const attachmentId = store.image.payload.attachment_id; - if (attachmentId) { - const attachment = await attachmentService.findOne(attachmentId); - if (attachment) { - return { - ...store, - image: { - type: 'image', - payload: { - ...attachment, - url: `http://localhost:4000/attachment/download/${attachment.id}/${attachment.name}`, - }, - }, - }; - } - } - return store; - }), - ); - const result = await contentService.populateAttachments( - storeContents.map(Content.toElement), - 'image', - ); - expect(result).toEqualPayload(elements); - }); - }); - describe('getContent', () => { const contentOptions: ContentOptions = { display: OutgoingMessageFormat.list, diff --git a/api/src/cms/services/content.service.ts b/api/src/cms/services/content.service.ts index 7b78bbe5..e4424aea 100644 --- a/api/src/cms/services/content.service.ts +++ b/api/src/cms/services/content.service.ts @@ -8,12 +8,8 @@ import { Injectable } from '@nestjs/common'; -import { Attachment } from '@/attachment/schemas/attachment.schema'; import { AttachmentService } from '@/attachment/services/attachment.service'; -import { - ContentElement, - StdOutgoingListMessage, -} from '@/chat/schemas/types/message'; +import { StdOutgoingListMessage } from '@/chat/schemas/types/message'; import { ContentOptions } from '@/chat/schemas/types/options'; import { LoggerService } from '@/logger/logger.service'; import { BaseService } from '@/utils/generics/base-service'; @@ -53,93 +49,6 @@ export class ContentService extends BaseService< return await this.repository.textSearch(query); } - /** - * Extracts attachment IDs from content entities, issuing warnings for any issues. - * - * @param contents - An array of content entities. - * @param attachmentFieldName - The name of the attachment field to check for. - * - * @return A list of attachment IDs. - */ - getAttachmentIds(contents: ContentElement[], attachmentFieldName: string) { - return contents.reduce((acc, content) => { - if (attachmentFieldName in content) { - const attachment = content[attachmentFieldName]; - - if ( - typeof attachment === 'object' && - 'attachment_id' in attachment.payload - ) { - acc.push(attachment.payload.attachment_id); - } else { - this.logger.error( - `Remote attachments have been deprecated, content "${content.title}" is missing the "attachment_id"`, - ); - } - } else { - this.logger.warn( - `Field "${attachmentFieldName}" not found in content "${content.title}"`, - ); - } - return acc; - }, [] as string[]); - } - - /** - * Populates attachment fields within content entities with detailed attachment information. - * - * @param elements - An array of content entities. - * @param attachmentFieldName - The name of the attachment field to populate. - * - * @return A list of content with populated attachment data. - */ - async populateAttachments( - elements: ContentElement[], - attachmentFieldName: string, - ): Promise { - const attachmentIds = this.getAttachmentIds(elements, attachmentFieldName); - - if (attachmentIds.length > 0) { - const attachments = await this.attachmentService.find({ - _id: { $in: attachmentIds }, - }); - - const attachmentsById = attachments.reduce( - (acc, curr) => { - acc[curr.id] = curr; - return acc; - }, - {} as { [key: string]: Attachment }, - ); - const populatedContents = elements.map((content) => { - const attachmentField = content[attachmentFieldName]; - if ( - typeof attachmentField === 'object' && - 'attachment_id' in attachmentField.payload - ) { - const attachmentId = attachmentField?.payload?.attachment_id; - return { - ...content, - [attachmentFieldName]: { - type: attachmentField.type, - payload: { - ...(attachmentsById[attachmentId] || attachmentField.payload), - url: Attachment.getAttachmentUrl( - attachmentId, - attachmentsById[attachmentId].name, - ), - }, - }, - }; - } else { - return content; - } - }); - return populatedContents; - } - return elements; - } - /** * Retrieves content based on the provided options and pagination settings. * @@ -177,21 +86,6 @@ export class ContentService extends BaseService< sort: ['createdAt', 'desc'], }); const elements = contents.map(Content.toElement); - const attachmentFieldName = options.fields.image_url; - if (attachmentFieldName) { - // Populate attachment when there's an image field - return { - elements: await this.populateAttachments( - elements, - attachmentFieldName, - ), - pagination: { - total, - skip, - limit, - }, - }; - } return { elements, pagination: { 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..1c85ac63 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, + id: '9'.repeat(24), }, }, }, message: { attachment: { payload: { - url: img_url, + 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 2d4123f8..28c49bff 100644 --- a/api/src/extensions/channels/web/base-web-channel.ts +++ b/api/src/extensions/channels/web/base-web-channel.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. @@ -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 { AttachmentRef } 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,15 @@ 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), }, }; } @@ -170,9 +173,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 +185,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 +215,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 +231,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 +266,7 @@ export default abstract class BaseWebChannelHandler< until, n, ); - return this.formatMessages(messages.reverse()); + return await this.formatMessages(messages.reverse()); } return []; } @@ -286,7 +291,7 @@ export default abstract class BaseWebChannelHandler< since, n, ); - return this.formatMessages(messages); + return await this.formatMessages(messages); } return []; } @@ -582,9 +587,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' }); @@ -592,61 +596,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, - }); - - if (attachment) - 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, @@ -660,42 +654,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, - }); - if (!attachment) { - this.logger.debug( - 'Web Channel Handler : failed to store attachment', - ); - return next(null); - } - 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); } } @@ -759,12 +777,25 @@ 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; + event._adapter.raw.data = { + type: Attachment.getTypeByMime(attachment.type), + url: await this.getPublicUrl(attachment), + }; + } + } catch (err) { this.logger.warn( 'Web Channel Handler : Unable to upload file ', err, @@ -773,49 +804,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); }); } @@ -968,15 +989,15 @@ export default abstract class BaseWebChannelHandler< * * @returns A ready to be sent attachment message */ - _attachmentFormat( - message: StdOutgoingAttachmentMessage, + 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), }, }; if (message.quickReplies && message.quickReplies.length > 0) { @@ -999,36 +1020,34 @@ 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; - if (attachmentPayload.url) { - if (!attachmentPayload.id) { - // @deprecated - this.logger.warn( - 'Web Channel Handler: Attachment remote url has been deprecated', - item, - ); - } - element.image_url = attachmentPayload.url; - } + const attachmentRef = + typeof item[fields.image_url] === 'string' + ? { url: item[fields.image_url] } + : (item[fields.image_url].payload as AttachmentRef); + element.image_url = await this.getPublicUrl(attachmentRef); } buttons.forEach((button: Button, index) => { @@ -1064,11 +1083,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; } /** @@ -1079,10 +1102,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[] = [], @@ -1108,7 +1131,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, @@ -1132,10 +1155,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) { @@ -1146,7 +1169,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: { @@ -1163,19 +1186,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: @@ -1223,7 +1246,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..b26cf7ac 100644 --- a/api/src/extensions/channels/web/wrapper.ts +++ b/api/src/extensions/channels/web/wrapper.ts @@ -1,17 +1,15 @@ /* - * 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 { - AttachmentForeignKey, - AttachmentPayload, -} from '@/chat/schemas/types/attachment'; +import { AttachmentPayload } from '@/chat/schemas/types/attachment'; import { IncomingMessageType, PayloadType, @@ -66,6 +64,7 @@ type WebEventAdapter = eventType: StdEventType.message; messageType: IncomingMessageType.attachments; raw: Web.IncomingMessage; + attachment: Attachment | null; }; // eslint-disable-next-line prettier/prettier @@ -216,16 +215,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, + id: this._adapter.attachment.id, }, }, }; @@ -266,19 +265,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, + id: this._adapter.attachment.id, }, }, }; @@ -297,7 +297,7 @@ export default class WebEventWrapper extends EventWrapper * @deprecated * @returns Received attachments message */ - getAttachments(): AttachmentPayload[] { + getAttachments(): AttachmentPayload[] { const message = this.getMessage() as any; return 'attachment' in message ? [].concat(message.attachment) : []; } diff --git a/api/src/migration/migration.module.ts b/api/src/migration/migration.module.ts index 1f09f473..08885a0c 100644 --- a/api/src/migration/migration.module.ts +++ b/api/src/migration/migration.module.ts @@ -12,6 +12,7 @@ import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; +import { AttachmentModule } from '@/attachment/attachment.module'; import { LoggerModule } from '@/logger/logger.module'; import { MigrationCommand } from './migration.command'; @@ -23,6 +24,7 @@ import { MigrationService } from './migration.service'; MongooseModule.forFeature([MigrationModel]), LoggerModule, HttpModule, + AttachmentModule, ], providers: [ MigrationService, diff --git a/api/src/migration/migration.service.spec.ts b/api/src/migration/migration.service.spec.ts index 87a64ba3..31827bae 100644 --- a/api/src/migration/migration.service.spec.ts +++ b/api/src/migration/migration.service.spec.ts @@ -14,6 +14,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { getModelToken, MongooseModule } from '@nestjs/mongoose'; import { Test, TestingModule } from '@nestjs/testing'; +import { AttachmentService } from '@/attachment/services/attachment.service'; import { LoggerService } from '@/logger/logger.service'; import { MetadataRepository } from '@/setting/repositories/metadata.repository'; import { Metadata, MetadataModel } from '@/setting/schemas/metadata.schema'; @@ -54,6 +55,10 @@ describe('MigrationService', () => { provide: HttpService, useValue: {}, }, + { + provide: AttachmentService, + useValue: {}, + }, { provide: ModuleRef, useValue: { @@ -278,6 +283,7 @@ describe('MigrationService', () => { }); expect(loadMigrationFileSpy).toHaveBeenCalledWith('v2.1.9'); expect(migrationMock.up).toHaveBeenCalledWith({ + attachmentService: service['attachmentService'], logger: service['logger'], http: service['httpService'], }); @@ -308,6 +314,7 @@ describe('MigrationService', () => { }); expect(loadMigrationFileSpy).toHaveBeenCalledWith('v2.1.9'); expect(migrationMock.up).toHaveBeenCalledWith({ + attachmentService: service['attachmentService'], logger: service['logger'], http: service['httpService'], }); @@ -338,6 +345,7 @@ describe('MigrationService', () => { }); expect(loadMigrationFileSpy).toHaveBeenCalledWith('v2.1.9'); expect(migrationMock.up).toHaveBeenCalledWith({ + attachmentService: service['attachmentService'], logger: service['logger'], http: service['httpService'], }); diff --git a/api/src/migration/migration.service.ts b/api/src/migration/migration.service.ts index e74a9b0e..88744095 100644 --- a/api/src/migration/migration.service.ts +++ b/api/src/migration/migration.service.ts @@ -19,6 +19,7 @@ import leanDefaults from 'mongoose-lean-defaults'; import leanGetters from 'mongoose-lean-getters'; import leanVirtuals from 'mongoose-lean-virtuals'; +import { AttachmentService } from '@/attachment/services/attachment.service'; import { config } from '@/config'; import { LoggerService } from '@/logger/logger.service'; import { MetadataService } from '@/setting/services/metadata.service'; @@ -43,6 +44,7 @@ export class MigrationService implements OnApplicationBootstrap { private readonly logger: LoggerService, private readonly metadataService: MetadataService, private readonly httpService: HttpService, + private readonly attachmentService: AttachmentService, @InjectModel(Migration.name) private readonly migrationModel: Model, ) {} @@ -253,6 +255,7 @@ module.exports = { const result = await migration[action]({ logger: this.logger, http: this.httpService, + attachmentService: this.attachmentService, }); if (result) { diff --git a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts index 6ec88545..843ed4ae 100644 --- a/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts +++ b/api/src/migration/migrations/1735836154221-v-2-2-0.migration.ts @@ -9,17 +9,22 @@ import { existsSync } from 'fs'; import { join, resolve } from 'path'; -import mongoose from 'mongoose'; +import mongoose, { HydratedDocument } from 'mongoose'; +import { v4 as uuidv4 } from 'uuid'; import attachmentSchema, { Attachment, } from '@/attachment/schemas/attachment.schema'; +import blockSchema, { Block } from '@/chat/schemas/block.schema'; +import messageSchema, { Message } from '@/chat/schemas/message.schema'; import subscriberSchema, { Subscriber } from '@/chat/schemas/subscriber.schema'; +import { StdOutgoingAttachmentMessage } from '@/chat/schemas/types/message'; +import contentSchema, { Content } from '@/cms/schemas/content.schema'; import { config } from '@/config'; import userSchema, { User } from '@/user/schemas/user.schema'; import { moveFile, moveFiles } from '@/utils/helpers/fs'; -import { MigrationServices } from '../types'; +import { MigrationAction, MigrationServices } from '../types'; /** * Updates subscriber documents with their corresponding avatar attachments @@ -260,15 +265,230 @@ const restoreOldAvatarsPath = async ({ logger }: MigrationServices) => { } }; +/** + * Handles updates on block documents for blocks that contain "message.attachment". + * + * @param updateField - Field to set during the update operation. + * @param unsetField - Field to unset during the update operation. + * @param logger - Logger service for logging messages. + */ +const migrateAttachmentBlocks = async ( + action: MigrationAction, + { logger }: MigrationServices, +) => { + const updateField = action === MigrationAction.UP ? 'id' : 'attachment_id'; + const unsetField = action === MigrationAction.UP ? 'attachment_id' : 'id'; + const BlockModel = mongoose.model(Block.name, blockSchema); + + const cursor = BlockModel.find({ + 'message.attachment': { $exists: true }, + }).cursor(); + + for await (const block of cursor) { + try { + const blockMessage = block.message as StdOutgoingAttachmentMessage; + const fieldValue = + blockMessage.attachment?.payload && + unsetField in blockMessage.attachment?.payload + ? blockMessage.attachment?.payload[unsetField] + : null; + + await BlockModel.updateOne( + { _id: block._id }, + { + $set: { + [`message.attachment.payload.${updateField}`]: fieldValue, + }, + $unset: { + [`message.attachment.payload.${unsetField}`]: '', + }, + }, + ); + } catch (error) { + logger.error(`Failed to process block ${block._id}: ${error.message}`); + } + } +}; + +/** + * Generates a function that renames a given attribute + * @param source Source name + * @param target Target name + * @returns Function to perform the renaming + */ +const buildRenameAttributeCallback = + >(source: A, target: A) => + (obj: D) => { + obj[target] = obj[source]; + delete obj[source]; + return obj; + }; + +/** + * Traverses an content document to search for any attachment object + * @param obj + * @param callback + * @returns + */ +const updateAttachmentPayload = ( + obj: HydratedDocument['dynamicFields'], + callback: ReturnType, +) => { + if (obj && typeof obj === 'object') { + for (const key in obj) { + if (obj[key] && typeof obj[key] === 'object' && 'payload' in obj[key]) { + obj[key].payload = callback(obj[key].payload); + } + } + } + return obj; +}; + +/** + * Updates content documents for blocks that contain attachment "*.payload": + * - Rename 'attachment_id' to 'id' + * + * @returns Resolves when the migration process is complete. + */ +const migrateAttachmentContents = async ( + action: MigrationAction, + { logger }: MigrationServices, +) => { + const updateField = action === MigrationAction.UP ? 'id' : 'attachment_id'; + const unsetField = action === MigrationAction.UP ? 'attachment_id' : 'id'; + const ContentModel = mongoose.model(Content.name, contentSchema); + // Find blocks where "message.attachment" exists + const cursor = ContentModel.find({}).cursor(); + + for await (const content of cursor) { + try { + content.dynamicFields = updateAttachmentPayload( + content.dynamicFields, + buildRenameAttributeCallback(unsetField, updateField), + ); + + await ContentModel.replaceOne({ _id: content._id }, content); + } catch (error) { + logger.error(`Failed to update content ${content._id}: ${error.message}`); + } + } +}; + +/** + * Updates message documents that contain attachment "message.attachment" + * to apply one of the following operation: + * - Rename 'attachment_id' to 'id' + * - Parse internal url for to get the 'id' + * - Fetch external url, stores the attachment and store the 'id' + * + * @returns Resolves when the migration process is complete. + */ +const migrateAttachmentMessages = async ({ + logger, + http, + attachmentService, +}: MigrationServices) => { + const MessageModel = mongoose.model(Message.name, messageSchema); + + // Find blocks where "message.attachment" exists + const cursor = MessageModel.find({ + 'message.attachment.payload': { $exists: true }, + 'message.attachment.payload.id': { $exists: false }, + }).cursor(); + + // Helper function to update the attachment ID in the database + const updateAttachmentId = async ( + messageId: mongoose.Types.ObjectId, + attachmentId: string | null, + ) => { + await MessageModel.updateOne( + { _id: messageId }, + { $set: { 'message.attachment.payload.id': attachmentId } }, + ); + }; + + for await (const msg of cursor) { + try { + if ( + 'attachment' in msg.message && + 'payload' in msg.message.attachment && + msg.message.attachment.payload + ) { + if ('attachment_id' in msg.message.attachment.payload) { + await updateAttachmentId( + msg._id, + msg.message.attachment.payload.attachment_id as string, + ); + } else if ('url' in msg.message.attachment.payload) { + const url = msg.message.attachment.payload.url; + const regex = + /^https?:\/\/[\w.-]+\/attachment\/download\/([a-f\d]{24})\/.+$/; + // Test the URL and extract the ID + const match = url.match(regex); + if (match) { + const [, attachmentId] = match; + await updateAttachmentId(msg._id, attachmentId); + } else if (url) { + logger.log( + `Migrate message ${msg._id}: Handling an external url ...`, + ); + const response = await http.axiosRef.get(url, { + responseType: 'arraybuffer', // Ensures the response is returned as a Buffer + }); + const fileBuffer = Buffer.from(response.data); + const attachment = await attachmentService.store(fileBuffer, { + name: uuidv4(), + size: fileBuffer.length, + type: response.headers['content-type'], + channel: {}, + }); + await updateAttachmentId(msg._id, attachment.id); + } + } else { + logger.warn( + `Unable to migrate message ${msg._id}: No ID nor URL was found`, + ); + + throw new Error( + 'Unable to process message attachment: No ID or URL to be processed', + ); + } + } else { + throw new Error( + 'Unable to process message attachment: Invalid Payload', + ); + } + } catch (error) { + logger.error( + `Failed to update message ${msg._id}: ${error.message}, defaulting to null`, + ); + try { + await updateAttachmentId(msg._id, null); + } catch (err) { + logger.error( + `Failed to update message ${msg._id}: ${error.message}, unable to default to null`, + ); + } + } + } +}; + module.exports = { async up(services: MigrationServices) { await populateSubscriberAvatar(services); await updateOldAvatarsPath(services); + await migrateAttachmentBlocks(MigrationAction.UP, services); + await migrateAttachmentContents(MigrationAction.UP, services); + // Given the complexity and inconsistency data, this method does not have + // a revert equivalent, at the same time, thus, it doesn't "unset" any attribute + await migrateAttachmentMessages(services); return true; }, async down(services: MigrationServices) { await unpopulateSubscriberAvatar(services); await restoreOldAvatarsPath(services); + await migrateAttachmentBlocks(MigrationAction.DOWN, services); + await migrateAttachmentContents(MigrationAction.DOWN, services); return true; }, }; diff --git a/api/src/migration/types.ts b/api/src/migration/types.ts index 2bf93cdd..e2a37c0f 100644 --- a/api/src/migration/types.ts +++ b/api/src/migration/types.ts @@ -8,6 +8,7 @@ import { HttpService } from '@nestjs/axios'; +import { AttachmentService } from '@/attachment/services/attachment.service'; import { LoggerService } from '@/logger/logger.service'; import { MigrationDocument } from './migration.schema'; @@ -34,4 +35,5 @@ export interface MigrationSuccessCallback extends MigrationRunParams { export type MigrationServices = { logger: LoggerService; http: HttpService; + attachmentService: AttachmentService; }; diff --git a/api/src/plugins/base-storage-plugin.ts b/api/src/plugins/base-storage-plugin.ts index 8d01ce46..8a0ac262 100644 --- a/api/src/plugins/base-storage-plugin.ts +++ b/api/src/plugins/base-storage-plugin.ts @@ -6,7 +6,7 @@ * 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 { Readable } from 'stream'; +import { Readable, Stream } from 'stream'; import { Injectable, StreamableFile } from '@nestjs/common'; @@ -47,8 +47,10 @@ export abstract class BaseStoragePlugin extends BasePlugin { readAsBuffer?(attachment: Attachment): Promise; + readAsStream?(attachment: Attachment): Promise; + store?( - file: Buffer | Readable | Express.Multer.File, + file: Buffer | Stream | Readable | Express.Multer.File, metadata: AttachmentMetadataDto, rootDir?: string, ): Promise; diff --git a/api/src/utils/test/fixtures/block.ts b/api/src/utils/test/fixtures/block.ts index ed7c3e29..a73bc1e3 100644 --- a/api/src/utils/test/fixtures/block.ts +++ b/api/src/utils/test/fixtures/block.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. @@ -140,7 +140,7 @@ export const blocks: TBlockFixtures['values'][] = [ attachment: { type: FileType.image, payload: { - attachment_id: '1', + id: '1', }, }, quickReplies: [], diff --git a/api/src/utils/test/fixtures/content.ts b/api/src/utils/test/fixtures/content.ts index ddf46a4d..03720101 100644 --- a/api/src/utils/test/fixtures/content.ts +++ b/api/src/utils/test/fixtures/content.ts @@ -105,7 +105,7 @@ const contents: TContentFixtures['values'][] = [ image: { type: 'image', payload: { - attachment_id: null, + id: null, }, }, }, @@ -117,7 +117,7 @@ const contents: TContentFixtures['values'][] = [ image: { type: 'image', payload: { - attachment_id: null, + id: null, }, }, }, @@ -154,8 +154,7 @@ export const installContentFixtures = async () => { ({ name }) => name === `${contentFixture.title.replace(' ', '')}.jpg`, ); if (attachment) { - contentFixture.dynamicFields.image.payload.attachment_id = - attachment.id; + contentFixture.dynamicFields.image.payload.id = attachment.id; } return { ...contentFixture, diff --git a/api/src/utils/test/mocks/block.ts b/api/src/utils/test/mocks/block.ts index 392455e6..556d0399 100644 --- a/api/src/utils/test/mocks/block.ts +++ b/api/src/utils/test/mocks/block.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. @@ -191,7 +191,7 @@ export const attachmentBlock = { type: FileType.image, payload: { url: 'https://fr.facebookbrand.com/wp-content/uploads/2016/09/messenger_icon2.png', - attachment_id: '1234', + id: '1234', }, }, quickReplies: [], diff --git a/frontend/src/components/contents/ContentDialog.tsx b/frontend/src/components/contents/ContentDialog.tsx index 8b2e965a..c04b3a75 100644 --- a/frontend/src/components/contents/ContentDialog.tsx +++ b/frontend/src/components/contents/ContentDialog.tsx @@ -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 LinkIcon from "@mui/icons-material/Link"; import { Dialog, @@ -110,9 +111,9 @@ const ContentFieldInput: React.FC = ({ })} {...field} onChange={(id, mimeType) => { - field.onChange({ type: mimeType, payload: { attachment_id: id } }); + field.onChange({ type: mimeType, payload: { id } }); }} - value={field.value?.payload?.attachment_id} + value={field.value?.payload?.id} accept={MIME_TYPES["images"].join(",")} format="full" /> diff --git a/frontend/src/components/inbox/components/AttachmentViewer.tsx b/frontend/src/components/inbox/components/AttachmentViewer.tsx index f8ba00b6..e8b98306 100644 --- a/frontend/src/components/inbox/components/AttachmentViewer.tsx +++ b/frontend/src/components/inbox/components/AttachmentViewer.tsx @@ -6,16 +6,15 @@ * 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 DownloadIcon from "@mui/icons-material/Download"; import { Button, Dialog, DialogContent } from "@mui/material"; import { FC } from "react"; import { DialogTitle } from "@/app-components/dialogs"; +import { useConfig } from "@/hooks/useConfig"; import { useDialog } from "@/hooks/useDialog"; import { useTranslate } from "@/hooks/useTranslate"; import { - AttachmentAttrs, FileType, StdIncomingAttachmentMessage, StdOutgoingAttachmentMessage, @@ -93,11 +92,10 @@ const componentMap: { [key in FileType]: FC } = { }; export const AttachmentViewer = (props: { - message: - | StdIncomingAttachmentMessage - | StdOutgoingAttachmentMessage; + message: StdIncomingAttachmentMessage | StdOutgoingAttachmentMessage; }) => { const message = props.message; + const { apiUrl } = useConfig(); // if the attachment is an array show a 4x4 grid with a +{number of remaining attachment} and open a modal to show the list of attachments // Remark: Messenger doesn't send multiple attachments when user sends multiple at once, it only relays the first one to Hexabot @@ -106,6 +104,10 @@ export const AttachmentViewer = (props: { return <>Not yet Implemented; } const AttachmentViewerForType = componentMap[message.attachment.type]; + const url = + "id" in message.attachment?.payload && message.attachment?.payload.id + ? `${apiUrl}attachment/download/${message.attachment?.payload.id}` + : message.attachment?.payload?.url; - return ; + return ; }; diff --git a/frontend/src/components/visual-editor/constants.ts b/frontend/src/components/visual-editor/constants.ts index 380ad827..2dcc132d 100644 --- a/frontend/src/components/visual-editor/constants.ts +++ b/frontend/src/components/visual-editor/constants.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 { IBlockAttributes } from "@/types/block.types"; import { ButtonType, @@ -35,7 +36,7 @@ export const ATTACHMENT_BLOCK_TEMPLATE: Partial = { message: { attachment: { type: FileType.unknown, - payload: { attachment_id: undefined }, + payload: { id: null }, }, quickReplies: [], }, diff --git a/frontend/src/components/visual-editor/form/AttachmentMessageForm.tsx b/frontend/src/components/visual-editor/form/AttachmentMessageForm.tsx index 99bc1031..35da99fb 100644 --- a/frontend/src/components/visual-editor/form/AttachmentMessageForm.tsx +++ b/frontend/src/components/visual-editor/form/AttachmentMessageForm.tsx @@ -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 { Controller, useFormContext } from "react-hook-form"; import AttachmentInput from "@/app-components/attachment/AttachmentInput"; @@ -41,8 +42,7 @@ const AttachmentMessageForm = () => { validate: { required: (value) => { return ( - !!value?.payload?.attachment_id || - t("message.attachment_is_required") + !!value?.payload?.id || t("message.attachment_is_required") ); }, }, @@ -55,7 +55,7 @@ const AttachmentMessageForm = () => { { onChange({ type: type ? getFileType(type) : FileType.unknown, payload: { - attachment_id: id, + id, }, }); }} diff --git a/frontend/src/types/block.types.ts b/frontend/src/types/block.types.ts index 3822cf18..2b40d51f 100644 --- a/frontend/src/types/block.types.ts +++ b/frontend/src/types/block.types.ts @@ -1,17 +1,17 @@ /* - * 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 { EntityType, Format } from "@/services/types"; import { IBaseSchema, IFormat, OmitPopulate } from "./base.types"; import { ILabel } from "./label.types"; import { - AttachmentForeignKey, ContentOptions, PayloadType, StdOutgoingAttachmentMessage, @@ -57,7 +57,7 @@ export type BlockMessage = | StdOutgoingQuickRepliesMessage | StdOutgoingButtonsMessage | StdOutgoingListMessage - | StdOutgoingAttachmentMessage + | StdOutgoingAttachmentMessage | StdPluginMessage; export interface PayloadPattern { diff --git a/frontend/src/types/message.types.ts b/frontend/src/types/message.types.ts index 3c386de3..5e0d4cf0 100644 --- a/frontend/src/types/message.types.ts +++ b/frontend/src/types/message.types.ts @@ -52,26 +52,17 @@ export interface AttachmentAttrs { } export type AttachmentForeignKey = { + id: string | null; + /** @deprecated use id instead */ url?: string; - attachment_id: string | undefined; }; -export interface AttachmentPayload< - A extends AttachmentAttrs | AttachmentForeignKey, -> { +export interface AttachmentPayload { type: FileType; - payload?: A; -} - -export interface IncomingAttachmentPayload { - type: FileType; - payload: { - url: string; - }; + payload: AttachmentForeignKey; } // Content - export interface ContentOptions { display: OutgoingMessageFormat.list | OutgoingMessageFormat.carousel; fields: { @@ -104,7 +95,7 @@ export type Payload = } | { type: PayloadType.attachments; - attachments: IncomingAttachmentPayload; + attachments: AttachmentPayload; }; export enum QuickReplyType { @@ -171,11 +162,9 @@ export type StdOutgoingListMessage = { limit: number; }; }; -export type StdOutgoingAttachmentMessage< - A extends AttachmentAttrs | AttachmentForeignKey, -> = { +export type StdOutgoingAttachmentMessage = { // Stored in DB as `AttachmentPayload`, `Attachment` when populated for channels relaying - attachment: AttachmentPayload; + attachment: AttachmentPayload; quickReplies?: StdQuickReply[]; }; @@ -198,7 +187,7 @@ export type StdIncomingLocationMessage = { export type StdIncomingAttachmentMessage = { type: PayloadType.attachments; serialized_text: string; - attachment: IncomingAttachmentPayload | IncomingAttachmentPayload[]; + attachment: AttachmentPayload | AttachmentPayload[]; }; export type StdPluginMessage = { @@ -217,7 +206,7 @@ export type StdOutgoingMessage = | StdOutgoingQuickRepliesMessage | StdOutgoingButtonsMessage | StdOutgoingListMessage - | StdOutgoingAttachmentMessage; + | StdOutgoingAttachmentMessage; export interface IMessageAttributes { mid?: string; diff --git a/widget/src/components/Suggestions.scss b/widget/src/components/Suggestions.scss index 9a31b691..6d562540 100644 --- a/widget/src/components/Suggestions.scss +++ b/widget/src/components/Suggestions.scss @@ -4,7 +4,7 @@ } .sc-suggestions-element { - margin: 0 0 0 0.25rem; + margin: 0 0 0.25rem 0.25rem; padding: 0.25rem 0.5rem; border: 1px solid; border-radius: 2rem;