Merge pull request #546 from Hexastack/refactor/attachment-payload
Some checks are pending
Build and Push Docker API Image / build-and-push (push) Waiting to run
Build and Push Docker Base Image / build-and-push (push) Waiting to run
Build and Push Docker UI Image / build-and-push (push) Waiting to run

feat: Refactor attachment payload + use public signed urls in web channel
This commit is contained in:
Med Marrouchi 2025-01-14 19:12:00 +01:00 committed by GitHub
commit cbbae7e274
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 692 additions and 576 deletions

View File

@ -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<Attachment> {
* @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<Attachment | undefined> {
@ -219,10 +219,11 @@ export class AttachmentService extends BaseService<Attachment> {
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<Attachment> {
*
* @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<Attachment> {
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<Stream | undefined> {
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
}
}
}

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.
@ -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<AttachmentForeignKey>[];
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<AttachmentForeignKey>[] {
getAttachments(): AttachmentPayload[] {
return [];
}

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.
@ -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) {

View File

@ -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<Attachment> = {
export const attachmentMessage: StdOutgoingAttachmentMessage = {
attachment: {
type: FileType.image,
payload: attachmentWithUrl,
payload: { id: attachment.id },
},
quickReplies: [
{

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.
@ -112,6 +112,16 @@ export class SubscriberCreateDto {
@IsNotEmpty()
@IsChannelData()
channel: SubscriberChannelData<ChannelName>;
@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) {}

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.
@ -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,
);
}

View File

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

View File

@ -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<A>;
attachment: AttachmentPayload;
quickReplies?: StdQuickReply[];
};
@ -119,7 +112,7 @@ export type BlockMessage =
| StdOutgoingQuickRepliesMessage
| StdOutgoingButtonsMessage
| StdOutgoingListMessage
| StdOutgoingAttachmentMessage<AttachmentForeignKey>
| StdOutgoingAttachmentMessage
| StdPluginMessage;
export type StdOutgoingMessage =
@ -127,7 +120,7 @@ export type StdOutgoingMessage =
| StdOutgoingQuickRepliesMessage
| StdOutgoingButtonsMessage
| StdOutgoingListMessage
| StdOutgoingAttachmentMessage<Attachment>;
| 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<Attachment>;
message: StdOutgoingAttachmentMessage;
}
export type StdOutgoingEnvelope =

View File

@ -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 {

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: {
id: '9'.repeat(24),
url: 'http://link.to/the/file',
},
},

View File

@ -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]

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.
@ -231,7 +231,7 @@ export class ChatService {
*/
@OnEvent('hook:chatbot:message')
async handleNewMessage(event: EventWrapper<any, any>) {
this.logger.debug('New message received', event);
this.logger.debug('New message received', event._adapter.raw);
const foreignId = event.getSenderForeignId();
const handler = event.getHandler();
@ -256,6 +256,8 @@ export class ChatService {
event.setSender(subscriber);
await event.preprocess();
// Trigger message received event
this.eventEmitter.emit('hook:chatbot:received', event);

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.
@ -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(),

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,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>(ContentService);
contentTypeService = module.get<ContentTypeService>(ContentTypeService);
contentRepository = module.get<ContentRepository>(ContentRepository);
attachmentService = module.get<AttachmentService>(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,

View File

@ -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<ContentElement[]> {
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: {

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

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 { 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<Web.IncomingMessageBase> {
// 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<Web.OutgoingMessageBase> {
// 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<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 +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 [];
}
@ -579,9 +584,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 +593,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 +651,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 +774,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<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;
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,
@ -762,49 +801,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 +986,15 @@ export default abstract class BaseWebChannelHandler<
*
* @returns A ready to be sent attachment message
*/
_attachmentFormat(
message: StdOutgoingAttachmentMessage<Attachment>,
async _attachmentFormat(
message: StdOutgoingAttachmentMessage,
_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),
},
};
if (message.quickReplies && message.quickReplies.length > 0) {
@ -982,36 +1011,34 @@ 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;
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) => {
@ -1047,11 +1074,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 +1093,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 +1122,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 +1146,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 +1160,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 +1177,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 +1237,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,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<Web.IncomingAttachmentMessage>;
attachment: Attachment | null;
};
// eslint-disable-next-line prettier/prettier
@ -216,16 +215,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,
id: this._adapter.attachment.id,
},
},
};
@ -266,19 +265,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,
id: this._adapter.attachment.id,
},
},
};
@ -297,7 +297,7 @@ export default class WebEventWrapper<N extends ChannelName> extends EventWrapper
* @deprecated
* @returns Received attachments message
*/
getAttachments(): AttachmentPayload<AttachmentForeignKey>[] {
getAttachments(): AttachmentPayload[] {
const message = this.getMessage() as any;
return 'attachment' in message ? [].concat(message.attachment) : [];
}

View File

@ -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,

View File

@ -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'],
});

View File

@ -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<Migration>,
) {}
@ -253,6 +255,7 @@ module.exports = {
const result = await migration[action]({
logger: this.logger,
http: this.httpService,
attachmentService: this.attachmentService,
});
if (result) {

View File

@ -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>(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 =
<A extends string, D extends Record<A, string>>(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<Content>['dynamicFields'],
callback: ReturnType<typeof buildRenameAttributeCallback>,
) => {
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>(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>(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;
},
};

View File

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

View File

@ -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<Buffer>;
readAsStream?(attachment: Attachment): Promise<Stream>;
store?(
file: Buffer | Readable | Express.Multer.File,
file: Buffer | Stream | Readable | Express.Multer.File,
metadata: AttachmentMetadataDto,
rootDir?: string,
): Promise<Attachment>;

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.
@ -142,7 +142,7 @@ export const blocks: TBlockFixtures['values'][] = [
attachment: {
type: FileType.image,
payload: {
attachment_id: '1',
id: '1',
},
},
quickReplies: [],

View File

@ -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,

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.
@ -191,7 +191,7 @@ export const attachmentBlock: BlockFull = {
type: FileType.image,
payload: {
url: 'https://fr.facebookbrand.com/wp-content/uploads/2016/09/messenger_icon2.png',
attachment_id: '1234',
id: '1234',
},
},
quickReplies: [],

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 LinkIcon from "@mui/icons-material/Link";
import {
Dialog,
@ -110,9 +111,9 @@ const ContentFieldInput: React.FC<ContentFieldInput> = ({
})}
{...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"
/>

View File

@ -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<AttachmentInterface> } = {
};
export const AttachmentViewer = (props: {
message:
| StdIncomingAttachmentMessage
| StdOutgoingAttachmentMessage<AttachmentAttrs>;
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 <AttachmentViewerForType url={message.attachment?.payload?.url} />;
return <AttachmentViewerForType url={url} />;
};

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 { IBlockAttributes } from "@/types/block.types";
import {
ButtonType,
@ -35,7 +36,7 @@ export const ATTACHMENT_BLOCK_TEMPLATE: Partial<IBlockAttributes> = {
message: {
attachment: {
type: FileType.unknown,
payload: { attachment_id: undefined },
payload: { id: null },
},
quickReplies: [],
},

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 { 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 = () => {
<AttachmentInput
label=""
{...rest}
value={value.payload?.attachment_id}
value={value.payload?.id}
accept={Object.values(MIME_TYPES).flat().join(",")}
format="full"
size={256}
@ -65,7 +65,7 @@ const AttachmentMessageForm = () => {
onChange({
type: type ? getFileType(type) : FileType.unknown,
payload: {
attachment_id: id,
id,
},
});
}}

View File

@ -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<AttachmentForeignKey>
| StdOutgoingAttachmentMessage
| StdPluginMessage;
export interface PayloadPattern {

View File

@ -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<A>;
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<AttachmentAttrs>;
| StdOutgoingAttachmentMessage;
export interface IMessageAttributes {
mid?: string;

View File

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