mirror of
https://github.com/hexastack/hexabot
synced 2025-04-08 15:04:54 +00:00
Merge pull request #546 from Hexastack/refactor/attachment-payload
feat: Refactor attachment payload + use public signed urls in web channel
This commit is contained in:
commit
cbbae7e274
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 [];
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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: [
|
||||
{
|
||||
|
@ -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) {}
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 =
|
||||
|
@ -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 {
|
||||
|
@ -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',
|
||||
},
|
||||
},
|
||||
|
@ -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]
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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(),
|
||||
|
@ -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,
|
||||
|
@ -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: {
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
@ -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);
|
||||
});
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
);
|
||||
|
@ -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
|
||||
|
@ -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) : [];
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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'],
|
||||
});
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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>;
|
||||
|
4
api/src/utils/test/fixtures/block.ts
vendored
4
api/src/utils/test/fixtures/block.ts
vendored
@ -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: [],
|
||||
|
7
api/src/utils/test/fixtures/content.ts
vendored
7
api/src/utils/test/fixtures/content.ts
vendored
@ -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,
|
||||
|
@ -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: [],
|
||||
|
@ -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"
|
||||
/>
|
||||
|
@ -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} />;
|
||||
};
|
||||
|
@ -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: [],
|
||||
},
|
||||
|
@ -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,
|
||||
},
|
||||
});
|
||||
}}
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user