mirror of
https://github.com/hexastack/hexabot
synced 2025-06-26 18:27:28 +00:00
Merge pull request #913 from Hexastack/fix/attachment-issues
Fix: attachment issues
This commit is contained in:
@@ -308,21 +308,20 @@ export default abstract class ChannelHandler<
|
||||
* @return A signed URL string for downloading the specified attachment.
|
||||
*/
|
||||
public async getPublicUrl(attachment: AttachmentRef | Attachment) {
|
||||
const [name, _suffix] = this.getName().split('-');
|
||||
if ('id' in attachment) {
|
||||
if (!attachment.id) {
|
||||
throw new TypeError(
|
||||
'Attachment ID is empty, unable to generate public URL.',
|
||||
);
|
||||
if (!attachment || !attachment.id) {
|
||||
return buildURL(config.apiBaseUrl, `/webhook/${name}/not-found`);
|
||||
}
|
||||
|
||||
const resource = await this.attachmentService.findOne(attachment.id);
|
||||
|
||||
if (!resource) {
|
||||
throw new NotFoundException('Unable to find attachment');
|
||||
this.logger.warn('Unable to find attachment sending fallback image');
|
||||
return buildURL(config.apiBaseUrl, `/webhook/${name}/not-found`);
|
||||
}
|
||||
|
||||
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)}`,
|
||||
@@ -331,7 +330,7 @@ export default abstract class ChannelHandler<
|
||||
// In case the url is external
|
||||
return attachment.url;
|
||||
} else {
|
||||
throw new TypeError('Unable to resolve the attachment public URL.');
|
||||
return buildURL(config.apiBaseUrl, `/webhook/${name}/not-found`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -88,4 +88,10 @@ export class WebhookController {
|
||||
this.logger.log('Channel notification : ', req.method, channel);
|
||||
return await this.channelService.handle(channel, req, res);
|
||||
}
|
||||
|
||||
@Roles('public')
|
||||
@Get(':channel/not-found')
|
||||
async handleNotFound(@Res() res: Response) {
|
||||
return res.status(404).send({ error: 'Not found!' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -11,6 +11,9 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
|
||||
import { AttachmentModule } from '@/attachment/attachment.module';
|
||||
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
|
||||
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
|
||||
import { AttachmentService } from '@/attachment/services/attachment.service';
|
||||
import { ChannelModule } from '@/channel/channel.module';
|
||||
import { CmsModule } from '@/cms/cms.module';
|
||||
import { UserModule } from '@/user/user.module';
|
||||
@@ -58,6 +61,7 @@ import { SubscriberService } from './services/subscriber.service';
|
||||
SubscriberModel,
|
||||
ConversationModel,
|
||||
SubscriberModel,
|
||||
AttachmentModel,
|
||||
]),
|
||||
forwardRef(() => ChannelModule),
|
||||
CmsModule,
|
||||
@@ -92,6 +96,8 @@ import { SubscriberService } from './services/subscriber.service';
|
||||
ConversationService,
|
||||
ChatService,
|
||||
BotService,
|
||||
AttachmentService,
|
||||
AttachmentRepository,
|
||||
],
|
||||
exports: [
|
||||
SubscriberService,
|
||||
|
||||
@@ -11,8 +11,14 @@ import {
|
||||
InternalServerErrorException,
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { Document, Query } from 'mongoose';
|
||||
|
||||
import { Attachment } from '@/attachment/schemas/attachment.schema';
|
||||
import { AttachmentService } from '@/attachment/services/attachment.service';
|
||||
import { DeleteResult } from '@/utils/generics/base-repository';
|
||||
import { BaseService } from '@/utils/generics/base-service';
|
||||
import { TFilterQuery } from '@/utils/types/filter.types';
|
||||
import {
|
||||
SocketGet,
|
||||
SocketPost,
|
||||
@@ -39,6 +45,7 @@ export class MessageService extends BaseService<
|
||||
|
||||
constructor(
|
||||
private readonly messageRepository: MessageRepository,
|
||||
private attachmentService: AttachmentService,
|
||||
@Optional() gateway?: WebsocketGateway,
|
||||
) {
|
||||
super(messageRepository);
|
||||
@@ -127,4 +134,40 @@ export class MessageService extends BaseService<
|
||||
|
||||
return lastMessages.reverse();
|
||||
}
|
||||
|
||||
@OnEvent('hook:attachment:preDelete')
|
||||
async handleDeleteImage(
|
||||
_query: Query<
|
||||
DeleteResult,
|
||||
Document<Attachment, any, any>,
|
||||
unknown,
|
||||
Attachment,
|
||||
'deleteOne' | 'deleteMany'
|
||||
>,
|
||||
criteria: TFilterQuery<Attachment>,
|
||||
) {
|
||||
try {
|
||||
this.logger.log(
|
||||
'deleting attachment messages containing deleted images',
|
||||
criteria,
|
||||
);
|
||||
const foundAttachments = await this.attachmentService.find(criteria);
|
||||
|
||||
for (const attachment of foundAttachments) {
|
||||
await this.updateMany(
|
||||
{
|
||||
'message.attachment.payload.id': attachment.id,
|
||||
},
|
||||
{
|
||||
['message.attachment.payload.id' as any]: null,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'Unable to cleanup old messages with attachment ids',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,11 @@
|
||||
"select_category": "Select a flow",
|
||||
"logout_failed": "Something went wrong during logout",
|
||||
"duplicate_labels_not_allowed": "Duplicate labels are not allowed",
|
||||
"duplicate_block_error": "Something went wrong while duplicating block"
|
||||
"duplicate_block_error": "Something went wrong while duplicating block",
|
||||
"image_error": "Image not found",
|
||||
"file_error": "File not found",
|
||||
"audio_error": "Audio not found",
|
||||
"video_error": "Video not found"
|
||||
},
|
||||
"menu": {
|
||||
"terms": "Terms of Use",
|
||||
|
||||
@@ -116,7 +116,11 @@
|
||||
"select_category": "Sélectionner une catégorie",
|
||||
"logout_failed": "Une erreur s'est produite lors de la déconnexion",
|
||||
"duplicate_labels_not_allowed": "Les étiquettes en double ne sont pas autorisées",
|
||||
"duplicate_block_error": "Une erreur est survenue lors de la duplication du bloc"
|
||||
"duplicate_block_error": "Une erreur est survenue lors de la duplication du bloc",
|
||||
"image_error": "Image introuvable",
|
||||
"file_error": "Fichier introuvable",
|
||||
"audio_error": "Audio introuvable",
|
||||
"video_error": "Vidéo introuvable"
|
||||
},
|
||||
"menu": {
|
||||
"terms": "Conditions d'utilisation",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import { Box, Button, Typography } from "@mui/material";
|
||||
import { FC } from "react";
|
||||
import { FC, useState } from "react";
|
||||
|
||||
import { useDialogs } from "@/hooks/useDialogs";
|
||||
import { useGetAttachmentMetadata } from "@/hooks/useGetAttachmentMetadata";
|
||||
@@ -30,11 +30,17 @@ interface AttachmentInterface {
|
||||
const componentMap: { [key in FileType]: FC<AttachmentInterface> } = {
|
||||
[FileType.image]: ({ url }: AttachmentInterface) => {
|
||||
const dialogs = useDialogs();
|
||||
const [imageErrored, setImageErrored] = useState(false);
|
||||
const { t } = useTranslate();
|
||||
|
||||
if (imageErrored || !url) {
|
||||
return <p>{t("message.image_error")}</p>;
|
||||
}
|
||||
if (url)
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
onError={() => setImageErrored(true)}
|
||||
width="auto"
|
||||
height={200}
|
||||
style={{ objectFit: "contain", cursor: "pointer" }}
|
||||
@@ -59,11 +65,24 @@ const componentMap: { [key in FileType]: FC<AttachmentInterface> } = {
|
||||
);
|
||||
},
|
||||
[FileType.audio]: (props: AttachmentInterface) => {
|
||||
return <audio controls src={props.url} />;
|
||||
const [audioErrored, setAudioErrored] = useState(false);
|
||||
const { t } = useTranslate();
|
||||
|
||||
if (audioErrored || !props.url) {
|
||||
return <p>{t("message.audio_error")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<audio controls src={props.url} onError={() => setAudioErrored(true)} />
|
||||
);
|
||||
},
|
||||
[FileType.file]: (props: AttachmentInterface) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
if (!props.url) {
|
||||
return <p>{t("message.file_error")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography
|
||||
@@ -84,11 +103,20 @@ const componentMap: { [key in FileType]: FC<AttachmentInterface> } = {
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
[FileType.video]: ({ url }: AttachmentInterface) => (
|
||||
<video controls width="250">
|
||||
<source src={url} />
|
||||
</video>
|
||||
),
|
||||
[FileType.video]: ({ url }: AttachmentInterface) => {
|
||||
const [videoErrored, setVideoErrored] = useState(false);
|
||||
const { t } = useTranslate();
|
||||
|
||||
if (videoErrored) {
|
||||
return <p>{t("message.video_error")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<video controls width="250">
|
||||
<source src={url} onError={() => setVideoErrored(true)} />
|
||||
</video>
|
||||
);
|
||||
},
|
||||
[FileType.unknown]: ({ url }: AttachmentInterface) => <>Unknown Type:{url}</>,
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,15 @@
|
||||
video {
|
||||
max-width: 100%;
|
||||
}
|
||||
.error-message {
|
||||
margin: 0;
|
||||
// padding: 10px 0 10px 10px;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
color: #666;
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.sc-message--content.sent .sc-message--file {
|
||||
|
||||
@@ -1,20 +1,52 @@
|
||||
/*
|
||||
* 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 React from "react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { useTranslation } from "../../hooks/useTranslation";
|
||||
import { useColors } from "../../providers/ColorProvider";
|
||||
import { Direction, TMessage } from "../../types/message.types";
|
||||
import FileIcon from "../icons/FileIcon";
|
||||
|
||||
import "./FileMessage.scss";
|
||||
|
||||
const getFileMessageType = (
|
||||
message: TMessage,
|
||||
): "image" | "audio" | "video" | "file" | "unknown" => {
|
||||
if (!("type" in message.data)) {
|
||||
throw new Error("Unable to detect type for file message");
|
||||
}
|
||||
|
||||
const type = message.data.type;
|
||||
|
||||
if (type === "unknown") {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (["image", "audio", "video", "file"].includes(type)) {
|
||||
return type as "image" | "audio" | "video" | "file";
|
||||
}
|
||||
|
||||
throw new Error("Unknown type for file message");
|
||||
};
|
||||
const hasUrl = (
|
||||
message: TMessage,
|
||||
): message is TMessage & { data: { url: string } } => {
|
||||
return (
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
"data" in message &&
|
||||
typeof message.data === "object" &&
|
||||
message.data !== null &&
|
||||
"url" in message.data &&
|
||||
typeof message.data.url === "string"
|
||||
);
|
||||
};
|
||||
|
||||
interface FileMessageProps {
|
||||
message: TMessage;
|
||||
}
|
||||
@@ -23,19 +55,25 @@ const FileMessage: React.FC<FileMessageProps> = ({ message }) => {
|
||||
const { t } = useTranslation();
|
||||
const { colors: allColors } = useColors();
|
||||
const colors = allColors[message.direction || Direction.received];
|
||||
const [videoErrored, setVideoErrored] = useState(false);
|
||||
const [audioErrored, setAudioErrored] = useState(false);
|
||||
const [imageErrored, setImageErrored] = useState(false);
|
||||
const type = getFileMessageType(message);
|
||||
|
||||
if (!("type" in message.data)) {
|
||||
throw new Error("Unable to detect type for file message");
|
||||
}
|
||||
|
||||
if (
|
||||
message.data &&
|
||||
message.data.type !== "image" &&
|
||||
message.data.type !== "audio" &&
|
||||
message.data.type !== "video" &&
|
||||
message.data.type !== "file"
|
||||
) {
|
||||
throw new Error("Uknown type for file message");
|
||||
if (type === "unknown") {
|
||||
return (
|
||||
<div
|
||||
className="sc-message--file"
|
||||
style={{
|
||||
color: colors.text,
|
||||
backgroundColor: colors.bg,
|
||||
}}
|
||||
>
|
||||
<p className="error-message">
|
||||
{t("messages.file_message.unsupported_file_type")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -46,28 +84,65 @@ const FileMessage: React.FC<FileMessageProps> = ({ message }) => {
|
||||
backgroundColor: colors.bg,
|
||||
}}
|
||||
>
|
||||
{message.data.type === "image" && (
|
||||
{type === "image" && (
|
||||
<div className="sc-message--file-icon">
|
||||
<img src={message.data.url || ""} className="sc-image" alt="File" />
|
||||
{imageErrored ? (
|
||||
<p
|
||||
className="error-message"
|
||||
style={{
|
||||
backgroundColor: colors.bg,
|
||||
}}
|
||||
>
|
||||
{t("messages.file_message.image_error")}
|
||||
</p>
|
||||
) : (
|
||||
<img
|
||||
onError={() => setImageErrored(true)}
|
||||
src={hasUrl(message) ? message.data.url : ""}
|
||||
className="sc-image"
|
||||
alt="File"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{message.data.type === "audio" && (
|
||||
{type === "audio" && (
|
||||
<div className="sc-message--file-audio">
|
||||
<audio controls>
|
||||
<source src={message.data.url} />
|
||||
{t("messages.file_message.browser_audio_unsupport")}
|
||||
</audio>
|
||||
{audioErrored ? (
|
||||
<p
|
||||
className="error-message"
|
||||
style={{
|
||||
backgroundColor: colors.bg,
|
||||
}}
|
||||
>
|
||||
{t("messages.file_message.audio_error")}
|
||||
</p>
|
||||
) : (
|
||||
<audio controls onError={() => setAudioErrored(true)}>
|
||||
<source src={hasUrl(message) ? message.data.url : ""} />
|
||||
</audio>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{message.data.type === "video" && (
|
||||
{type === "video" && (
|
||||
<div className="sc-message--file-video">
|
||||
<video controls width="100%">
|
||||
<source src={message.data.url} />
|
||||
{t("messages.file_message.browser_video_unsupport")}
|
||||
</video>
|
||||
{videoErrored ? (
|
||||
<p
|
||||
className="error-message"
|
||||
style={{
|
||||
backgroundColor: colors.bg,
|
||||
}}
|
||||
>
|
||||
{t("messages.file_message.video_error")}
|
||||
</p>
|
||||
) : (
|
||||
<video controls width="100%" onError={() => setVideoErrored(true)}>
|
||||
<source src={hasUrl(message) ? message.data.url : ""} />
|
||||
{t("messages.file_message.browser_video_unsupport")}
|
||||
</video>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{message.data.type === "file" && (
|
||||
{type === "file" && (
|
||||
<div
|
||||
className="sc-message--file-download"
|
||||
style={{
|
||||
@@ -75,15 +150,21 @@ const FileMessage: React.FC<FileMessageProps> = ({ message }) => {
|
||||
backgroundColor: colors.bg,
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={message.data.url ? message.data.url : "#"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
download
|
||||
>
|
||||
<FileIcon />
|
||||
{t("messages.file_message.download")}
|
||||
</a>
|
||||
{!hasUrl(message) ? (
|
||||
<p className="error-message" style={{ padding: 0 }}>
|
||||
{t("messages.file_message.file_error")}
|
||||
</p>
|
||||
) : (
|
||||
<a
|
||||
href={hasUrl(message) ? message.data.url : "#"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
download
|
||||
>
|
||||
<FileIcon />
|
||||
{t("messages.file_message.download")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,11 @@
|
||||
"browser_video_unsupport": "Browser does not support the video element.",
|
||||
"download": "Download",
|
||||
"unsupported_file_type": "This file type is not supported.",
|
||||
"unsupported_file_size": "This file size is not supported."
|
||||
"unsupported_file_size": "This file size is not supported.",
|
||||
"image_error": "Image not found",
|
||||
"file_error": "File not found",
|
||||
"audio_error": "Audio not found",
|
||||
"video_error": "Video not found"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,11 @@
|
||||
"browser_video_unsupport": "Le navigateur ne prend pas en charge l'élément vidéo.",
|
||||
"download": "Télécharger",
|
||||
"unsupported_file_type": "Ce type de fichier n'est pas pris en charge.",
|
||||
"unsupported_file_size": "Cette taille de fichier n'est pas prise en charge."
|
||||
"unsupported_file_size": "Cette taille de fichier n'est pas prise en charge.",
|
||||
"image_error": "Image introuvable",
|
||||
"file_error": "Fichier introuvable",
|
||||
"audio_error": "Audio introuvable",
|
||||
"video_error": "Vidéo introuvable"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user