feat: add tts service
This commit is contained in:
@@ -134,6 +134,9 @@ export const enum CommonMessage {
|
|||||||
|
|
||||||
export const enum UploaderMessage {
|
export const enum UploaderMessage {
|
||||||
UPLOAD_FILE_INVALID = "فایل معتبر نیست",
|
UPLOAD_FILE_INVALID = "فایل معتبر نیست",
|
||||||
|
UPLOAD_FILE_TOO_LARGE = "فایل بزرگتر از حد مجاز است . حداکثر حجم فایل [MAX_FILE_SIZE] مگابایت است",
|
||||||
|
UPLOAD_FILE_TYPE_NOT_SUPPORTED = "نوع فایل معتبر نیست . مجاز است : [MIME_TYPES]",
|
||||||
|
UPLOAD_FILE_SUCCESS = "فایل با موفقیت آپلود شد",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enum EmailMessage {
|
export const enum EmailMessage {
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ export const aiConfigProvider: FactoryProvider<AIConfig> = {
|
|||||||
baseUrl: configService.getOrThrow<string>("AI_BASE_URL"),
|
baseUrl: configService.getOrThrow<string>("AI_BASE_URL"),
|
||||||
apiKey: configService.getOrThrow<string>("AI_API_KEY"),
|
apiKey: configService.getOrThrow<string>("AI_API_KEY"),
|
||||||
defaultModel: configService.getOrThrow<string>("AI_DEFAULT_MODEL"),
|
defaultModel: configService.getOrThrow<string>("AI_DEFAULT_MODEL"),
|
||||||
|
defaultTTSModel: configService.getOrThrow<string>("AI_DEFAULT_TTS_MODEL"),
|
||||||
temperature: configService.get<number>("AI_TEMPERATURE", 0.7),
|
temperature: configService.get<number>("AI_TEMPERATURE", 0.7),
|
||||||
maxTokens: configService.get<number>("AI_MAX_TOKENS", 5000),
|
maxTokens: configService.get<number>("AI_MAX_TOKENS", 5000),
|
||||||
timeout: configService.get<number>("AI_TIMEOUT", 80000),
|
timeout: configService.get<number>("AI_TIMEOUT", 80000),
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString } from "class-validator";
|
||||||
|
|
||||||
|
export enum VoiceEnum {
|
||||||
|
ALLOY = "alloy",
|
||||||
|
ECHO = "echo",
|
||||||
|
FABLE = "fable",
|
||||||
|
ONYX = "onyx",
|
||||||
|
NOVA = "nova",
|
||||||
|
SHIMMER = "shimmer",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SpeedEnum {
|
||||||
|
SLOW = 0.5,
|
||||||
|
NORMAL = 1.0,
|
||||||
|
FAST = 1.3,
|
||||||
|
VERY_FAST = 2.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AudioFormatEnum {
|
||||||
|
MP3 = "mp3",
|
||||||
|
OPUS = "opus",
|
||||||
|
AAC = "aac",
|
||||||
|
FLAC = "flac",
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TextToSpeechDto {
|
||||||
|
@IsNotEmpty({ message: "Message ID is required" })
|
||||||
|
@IsNumber({}, { message: "Message ID must be a number" })
|
||||||
|
@ApiProperty({ description: "The message ID to fetch and convert to speech", example: 12345 })
|
||||||
|
messageId: number;
|
||||||
|
|
||||||
|
@IsNotEmpty({ message: "Mailbox ID is required" })
|
||||||
|
@IsString({ message: "Mailbox ID must be a string" })
|
||||||
|
@ApiProperty({ description: "The mailbox ID where the message is located", example: "507f1f77bcf86cd799439012" })
|
||||||
|
mailbox: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(VoiceEnum, { message: "Voice must be one of: alloy, echo, fable, onyx, nova, shimmer" })
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Voice to use for text-to-speech",
|
||||||
|
enum: VoiceEnum,
|
||||||
|
default: VoiceEnum.ALLOY,
|
||||||
|
})
|
||||||
|
voice?: VoiceEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(SpeedEnum, { message: "Speed must be one of: 0.5, 1.0, 1.5, 2.0" })
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Speech speed",
|
||||||
|
enum: SpeedEnum,
|
||||||
|
default: SpeedEnum.NORMAL,
|
||||||
|
})
|
||||||
|
speed?: SpeedEnum;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AudioFormatEnum, { message: "Format must be one of: mp3, opus, aac, flac" })
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Audio output format",
|
||||||
|
enum: AudioFormatEnum,
|
||||||
|
default: AudioFormatEnum.MP3,
|
||||||
|
})
|
||||||
|
format?: AudioFormatEnum;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { ApiOperation, ApiResponse } from "@nestjs/swagger";
|
|||||||
|
|
||||||
import { GenerateTemplateDto } from "./DTO/generate-template.dto";
|
import { GenerateTemplateDto } from "./DTO/generate-template.dto";
|
||||||
import { SummarizeEmailDto } from "./DTO/summarize-email.dto";
|
import { SummarizeEmailDto } from "./DTO/summarize-email.dto";
|
||||||
|
import { TextToSpeechDto } from "./DTO/text-to-speech.dto";
|
||||||
import { WriteEmailAssistanceDto } from "./DTO/write-email-assistance.dto";
|
import { WriteEmailAssistanceDto } from "./DTO/write-email-assistance.dto";
|
||||||
import { AiAssistantService } from "./services/ai-assistant.service";
|
import { AiAssistantService } from "./services/ai-assistant.service";
|
||||||
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
|
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
|
||||||
@@ -37,4 +38,14 @@ export class AiAssistantController {
|
|||||||
async generateTemplate(@Body() generateTemplateDto: GenerateTemplateDto) {
|
async generateTemplate(@Body() generateTemplateDto: GenerateTemplateDto) {
|
||||||
return this.aiAssistantService.generateTemplate(generateTemplateDto);
|
return this.aiAssistantService.generateTemplate(generateTemplateDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("text-to-speech")
|
||||||
|
@ApiOperation({ summary: "Convert email summary to speech audio and save as file" })
|
||||||
|
@ApiResponse({ status: 201, description: "Audio file generated and saved successfully" })
|
||||||
|
@ApiResponse({ status: 400, description: "Bad request - invalid message ID or mailbox" })
|
||||||
|
@ApiResponse({ status: 404, description: "Message not found or access denied" })
|
||||||
|
@ApiResponse({ status: 401, description: "Unauthorized - invalid authentication" })
|
||||||
|
async textToSpeech(@Body() textToSpeechDto: TextToSpeechDto, @UserDec("wildduckUserId") wildduckUserId: string) {
|
||||||
|
return this.aiAssistantService.textToSpeechEmailSummary(textToSpeechDto, wildduckUserId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { AiAssistantService } from "./services/ai-assistant.service";
|
|||||||
import { AiService } from "./services/ai.service";
|
import { AiService } from "./services/ai.service";
|
||||||
import { aiConfigProvider } from "../../configs/ai.config";
|
import { aiConfigProvider } from "../../configs/ai.config";
|
||||||
import { MailServerModule } from "../mail-server/mail-server.module";
|
import { MailServerModule } from "../mail-server/mail-server.module";
|
||||||
|
import { UploaderModule } from "../uploader/uploader.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [MailServerModule],
|
imports: [MailServerModule, UploaderModule],
|
||||||
controllers: [AiAssistantController],
|
controllers: [AiAssistantController],
|
||||||
providers: [AiAssistantService, AiService, aiConfigProvider],
|
providers: [AiAssistantService, AiService, aiConfigProvider],
|
||||||
exports: [AiAssistantService, AiService],
|
exports: [AiAssistantService, AiService],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { PriorityEnum } from "../../email/enums/email-header.enum";
|
|||||||
import { MessageAttachment } from "../../mail-server/interfaces/messages-response.interface";
|
import { MessageAttachment } from "../../mail-server/interfaces/messages-response.interface";
|
||||||
import { PersonalityDataType } from "../../templates/interfaces/structure.interface";
|
import { PersonalityDataType } from "../../templates/interfaces/structure.interface";
|
||||||
import { TemplatePurposeEnum, TemplateStyleEnum, TemplateThemeEnum } from "../DTO/generate-template.dto";
|
import { TemplatePurposeEnum, TemplateStyleEnum, TemplateThemeEnum } from "../DTO/generate-template.dto";
|
||||||
|
import { AudioFormatEnum, SpeedEnum, VoiceEnum } from "../DTO/text-to-speech.dto";
|
||||||
import { LanguageEnum, SentimentEnum, SummaryLengthEnum, SummaryToneEnum } from "../enums/ai.enum";
|
import { LanguageEnum, SentimentEnum, SummaryLengthEnum, SummaryToneEnum } from "../enums/ai.enum";
|
||||||
|
|
||||||
export interface IEmailData {
|
export interface IEmailData {
|
||||||
@@ -66,8 +67,31 @@ export interface AIWritingResponse {
|
|||||||
improvements: string[];
|
improvements: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TextToSpeechOptions {
|
||||||
|
voice: VoiceEnum;
|
||||||
|
speed: SpeedEnum;
|
||||||
|
format: AudioFormatEnum;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AITextToSpeechResponse {
|
||||||
|
audioBuffer: Buffer;
|
||||||
|
format: AudioFormatEnum;
|
||||||
|
duration?: number;
|
||||||
|
contentType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextToSpeechFileResponse {
|
||||||
|
fileUrl: string;
|
||||||
|
fileName: string;
|
||||||
|
format: AudioFormatEnum;
|
||||||
|
contentType: string;
|
||||||
|
fileSize: number;
|
||||||
|
s3Key: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AIServiceInterface {
|
export interface AIServiceInterface {
|
||||||
summarizeEmail(emailData: IEmailData, options: SummarizationOptions): Promise<AISummaryResponse>;
|
summarizeEmail(emailData: IEmailData, options: SummarizationOptions): Promise<AISummaryResponse>;
|
||||||
assistEmailWriting(prompt: string): Promise<AIWritingResponse>;
|
assistEmailWriting(prompt: string): Promise<AIWritingResponse>;
|
||||||
generateTemplate(request: ITemplateGenerationRequest): Promise<AITemplateGenerationResponse>;
|
generateTemplate(request: ITemplateGenerationRequest): Promise<AITemplateGenerationResponse>;
|
||||||
|
textToSpeech(text: string, options: TextToSpeechOptions): Promise<AITextToSpeechResponse>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export interface AIConfig {
|
|||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
defaultModel: string;
|
defaultModel: string;
|
||||||
|
defaultTTSModel: string;
|
||||||
temperature: number;
|
temperature: number;
|
||||||
maxTokens: number;
|
maxTokens: number;
|
||||||
timeout: number;
|
timeout: number;
|
||||||
|
|||||||
@@ -5,17 +5,27 @@ import { AiService } from "./ai.service";
|
|||||||
import { AiMessage } from "../../../common/enums/message.enum";
|
import { AiMessage } from "../../../common/enums/message.enum";
|
||||||
import { MessageDetails } from "../../mail-server/interfaces/messages-response.interface";
|
import { MessageDetails } from "../../mail-server/interfaces/messages-response.interface";
|
||||||
import { WildDuckMessagesService } from "../../mail-server/services/wildduck-messages.service";
|
import { WildDuckMessagesService } from "../../mail-server/services/wildduck-messages.service";
|
||||||
|
import { S3Service } from "../../uploader/providers/s3.service";
|
||||||
import { GenerateTemplateDto, TemplatePurposeEnum, TemplateStyleEnum, TemplateThemeEnum } from "../DTO/generate-template.dto";
|
import { GenerateTemplateDto, TemplatePurposeEnum, TemplateStyleEnum, TemplateThemeEnum } from "../DTO/generate-template.dto";
|
||||||
import { SummarizeEmailDto } from "../DTO/summarize-email.dto";
|
import { SummarizeEmailDto } from "../DTO/summarize-email.dto";
|
||||||
|
import { AudioFormatEnum, SpeedEnum, TextToSpeechDto, VoiceEnum } from "../DTO/text-to-speech.dto";
|
||||||
import { WriteEmailAssistanceDto } from "../DTO/write-email-assistance.dto";
|
import { WriteEmailAssistanceDto } from "../DTO/write-email-assistance.dto";
|
||||||
import { SummaryLengthEnum, SummaryToneEnum } from "../enums/ai.enum";
|
import { SummaryLengthEnum, SummaryToneEnum } from "../enums/ai.enum";
|
||||||
import { IEmailData, ITemplateGenerationRequest, SummarizationOptions, TemplateGenerationOptions } from "../interfaces/ai-service.interface";
|
import { AITextToSpeechResponse } from "../interfaces/ai-service.interface";
|
||||||
|
import {
|
||||||
|
IEmailData,
|
||||||
|
ITemplateGenerationRequest,
|
||||||
|
SummarizationOptions,
|
||||||
|
TemplateGenerationOptions,
|
||||||
|
TextToSpeechOptions,
|
||||||
|
} from "../interfaces/ai-service.interface";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AiAssistantService {
|
export class AiAssistantService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly messagesService: WildDuckMessagesService,
|
private readonly messagesService: WildDuckMessagesService,
|
||||||
private readonly aiService: AiService,
|
private readonly aiService: AiService,
|
||||||
|
private readonly s3Service: S3Service,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
//************************************************* */
|
//************************************************* */
|
||||||
@@ -72,6 +82,35 @@ export class AiAssistantService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//************************************************* */
|
||||||
|
async textToSpeechEmailSummary(textToSpeechDto: TextToSpeechDto, userId: string) {
|
||||||
|
const message = await firstValueFrom(this.messagesService.getMessage(userId, textToSpeechDto.mailbox, textToSpeechDto.messageId));
|
||||||
|
|
||||||
|
if (!message) throw new NotFoundException(AiMessage.MESSAGE_NOT_FOUND_OR_ACCESS_DENIED);
|
||||||
|
|
||||||
|
const emailData = this.extractEmailData(message);
|
||||||
|
const summaryResponse = await this.generateEmailSummary(emailData, SummaryLengthEnum.MEDIUM, SummaryToneEnum.PROFESSIONAL);
|
||||||
|
|
||||||
|
// const textForSpeech = this.buildTextForSpeech(summaryResponse, emailData);
|
||||||
|
const textForSpeech = summaryResponse.summary;
|
||||||
|
|
||||||
|
const ttsOptions: TextToSpeechOptions = {
|
||||||
|
voice: textToSpeechDto.voice || VoiceEnum.ALLOY,
|
||||||
|
speed: textToSpeechDto.speed || SpeedEnum.NORMAL,
|
||||||
|
format: textToSpeechDto.format || AudioFormatEnum.MP3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const audioResponse = await this.aiService.textToSpeech(textForSpeech, ttsOptions);
|
||||||
|
|
||||||
|
const fileResponse = await this.saveAudioFile(audioResponse, textToSpeechDto, emailData);
|
||||||
|
|
||||||
|
return {
|
||||||
|
file: fileResponse,
|
||||||
|
summary: summaryResponse,
|
||||||
|
textUsed: textForSpeech,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
//************************************************* */
|
//************************************************* */
|
||||||
private buildTemplateGenerationOptions(dto: GenerateTemplateDto): TemplateGenerationOptions {
|
private buildTemplateGenerationOptions(dto: GenerateTemplateDto): TemplateGenerationOptions {
|
||||||
return {
|
return {
|
||||||
@@ -176,6 +215,65 @@ export class AiAssistantService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//************************************************* */
|
||||||
|
private async saveAudioFile(audioResponse: AITextToSpeechResponse, textToSpeechDto: TextToSpeechDto, emailData: IEmailData) {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const sanitizedSubject = emailData.subject?.replace(/[^a-zA-Z0-9]/g, "_").substring(0, 50) || "email_summary";
|
||||||
|
const fileName = `tts_${sanitizedSubject}_${timestamp}.${audioResponse.format}`;
|
||||||
|
|
||||||
|
const s3Key = this.s3Service.generateFileKey(fileName, "audio");
|
||||||
|
|
||||||
|
const s3Result = await this.s3Service.uploadFile(audioResponse.audioBuffer, s3Key, audioResponse.contentType, {
|
||||||
|
originalName: fileName,
|
||||||
|
messageId: textToSpeechDto.messageId.toString(),
|
||||||
|
mailbox: textToSpeechDto.mailbox,
|
||||||
|
voice: textToSpeechDto.voice || VoiceEnum.ALLOY,
|
||||||
|
speed: (textToSpeechDto.speed || SpeedEnum.NORMAL).toString(),
|
||||||
|
format: audioResponse.format,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileUrl: s3Result.url,
|
||||||
|
fileName,
|
||||||
|
format: audioResponse.format,
|
||||||
|
contentType: audioResponse.contentType,
|
||||||
|
fileSize: audioResponse.audioBuffer.length,
|
||||||
|
s3Key: s3Result.key,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************************* */
|
||||||
|
// private buildTextForSpeech(summaryResponse: AISummaryResponse, emailData: IEmailData): string {
|
||||||
|
// let text = `Email Summary: `;
|
||||||
|
|
||||||
|
// if (emailData.subject) {
|
||||||
|
// text += `Subject: ${emailData.subject}. `;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (emailData.from) {
|
||||||
|
// text += `From: ${emailData.from}. `;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (summaryResponse.summary) {
|
||||||
|
// text += `Summary: ${summaryResponse.summary}. `;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (summaryResponse.keyPoints && summaryResponse.keyPoints.length > 0) {
|
||||||
|
// text += `Key Points: ${summaryResponse.keyPoints.join(". ")}. `;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (summaryResponse.suggestedActions && summaryResponse.suggestedActions.length > 0) {
|
||||||
|
// text += `Suggested Actions: ${summaryResponse.suggestedActions.join(". ")}. `;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (summaryResponse.priority) {
|
||||||
|
// text += `Priority: ${summaryResponse.priority}.`;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return text;
|
||||||
|
// }
|
||||||
|
|
||||||
//************************************************* */
|
//************************************************* */
|
||||||
private async generateEmailWritingAssistance(writeAssistanceDto: WriteEmailAssistanceDto, _userId: string) {
|
private async generateEmailWritingAssistance(writeAssistanceDto: WriteEmailAssistanceDto, _userId: string) {
|
||||||
const aiResponse = await this.aiService.assistEmailWriting(writeAssistanceDto.prompt);
|
const aiResponse = await this.aiService.assistEmailWriting(writeAssistanceDto.prompt);
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import {
|
|||||||
AIServiceInterface,
|
AIServiceInterface,
|
||||||
AISummaryResponse,
|
AISummaryResponse,
|
||||||
AITemplateGenerationResponse,
|
AITemplateGenerationResponse,
|
||||||
|
AITextToSpeechResponse,
|
||||||
AIWritingResponse,
|
AIWritingResponse,
|
||||||
IEmailData,
|
IEmailData,
|
||||||
ITemplateGenerationRequest,
|
ITemplateGenerationRequest,
|
||||||
SummarizationOptions,
|
SummarizationOptions,
|
||||||
|
TextToSpeechOptions,
|
||||||
} from "../interfaces/ai-service.interface";
|
} from "../interfaces/ai-service.interface";
|
||||||
import { AIConfig } from "../interfaces/ai.interface";
|
import { AIConfig } from "../interfaces/ai.interface";
|
||||||
|
|
||||||
@@ -118,6 +120,37 @@ export class AiService implements AIServiceInterface {
|
|||||||
|
|
||||||
return parsedResponse;
|
return parsedResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//************************************************* */
|
||||||
|
async textToSpeech(text: string, options: TextToSpeechOptions): Promise<AITextToSpeechResponse> {
|
||||||
|
try {
|
||||||
|
const response = await this.openai.audio.speech.create({
|
||||||
|
model: this.aiConfig.defaultTTSModel,
|
||||||
|
voice: options.voice,
|
||||||
|
input: text,
|
||||||
|
response_format: options.format,
|
||||||
|
speed: options.speed,
|
||||||
|
});
|
||||||
|
|
||||||
|
const audioBuffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
|
||||||
|
const contentTypeMap = {
|
||||||
|
mp3: "audio/mpeg",
|
||||||
|
opus: "audio/opus",
|
||||||
|
aac: "audio/aac",
|
||||||
|
flac: "audio/flac",
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioBuffer,
|
||||||
|
format: options.format,
|
||||||
|
contentType: contentTypeMap[options.format],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Error generating text-to-speech", error);
|
||||||
|
throw new InternalServerErrorException(AiMessage.NO_RESPONSE_FROM_AI_MODEL);
|
||||||
|
}
|
||||||
|
}
|
||||||
//************************************************* */
|
//************************************************* */
|
||||||
|
|
||||||
private parseJsonResponse<T>(response: string): T {
|
private parseJsonResponse<T>(response: string): T {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { File } from "@nest-lab/fastify-multer";
|
||||||
|
|
||||||
|
export interface FileUploadResult {
|
||||||
|
file: File;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileStreamResult {
|
||||||
|
stream: NodeJS.ReadableStream;
|
||||||
|
file: File;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileMetadata {
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
duration?: number;
|
||||||
|
bitrate?: number;
|
||||||
|
codec?: string;
|
||||||
|
language?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileType = "video" | "audio" | "image" | "document" | "text" | "unknown";
|
||||||
|
|
||||||
|
export interface FileValidationOptions {
|
||||||
|
maxFileSize: number;
|
||||||
|
allowedMimeTypes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileUploadOptions {
|
||||||
|
roomId?: string;
|
||||||
|
metadata?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// File processing status
|
||||||
|
export type FileProcessingStatus = "pending" | "processing" | "completed" | "failed";
|
||||||
|
|
||||||
|
export interface FileProcessingResult {
|
||||||
|
status: FileProcessingStatus;
|
||||||
|
metadata?: FileMetadata;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Readable } from "stream";
|
import { randomInt } from "node:crypto";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
|
||||||
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
|
||||||
|
import { FileType } from "../interfaces/file.interface";
|
||||||
import { S3UploadResult } from "../interfaces/s3.interface";
|
import { S3UploadResult } from "../interfaces/s3.interface";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -118,12 +120,21 @@ export class S3Service {
|
|||||||
/**
|
/**
|
||||||
* Generate S3 key for file
|
* Generate S3 key for file
|
||||||
*/
|
*/
|
||||||
generateFileKey(originalName: string): string {
|
generateFileKey(originalName: string, fileType: FileType): string {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const randomSuffix = Math.round(Math.random() * 1e9);
|
const randomSuffix = randomInt(1000000, 9999999);
|
||||||
const extension = originalName.split(".").pop();
|
const extension = originalName.split(".").pop();
|
||||||
|
|
||||||
const basePath = `images`;
|
const basePathMap: Record<FileType, string> = {
|
||||||
|
image: "images",
|
||||||
|
video: "videos",
|
||||||
|
audio: "audio",
|
||||||
|
document: "documents",
|
||||||
|
text: "texts",
|
||||||
|
unknown: "unknowns",
|
||||||
|
};
|
||||||
|
|
||||||
|
const basePath = basePathMap[fileType];
|
||||||
return `${basePath}/${timestamp}-${randomSuffix}.${extension}`;
|
return `${basePath}/${timestamp}-${randomSuffix}.${extension}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ConfigService } from "@nestjs/config";
|
|||||||
|
|
||||||
import { S3Service } from "./s3.service";
|
import { S3Service } from "./s3.service";
|
||||||
import { UploaderMessage } from "../../../common/enums/message.enum";
|
import { UploaderMessage } from "../../../common/enums/message.enum";
|
||||||
|
import { FileType } from "../interfaces/file.interface";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UploaderService {
|
export class UploaderService {
|
||||||
@@ -38,6 +39,27 @@ export class UploaderService {
|
|||||||
"image/svg+xml",
|
"image/svg+xml",
|
||||||
"image/tiff",
|
"image/tiff",
|
||||||
"image/bmp",
|
"image/bmp",
|
||||||
|
"application/pdf",
|
||||||
|
"text/plain",
|
||||||
|
"text/csv",
|
||||||
|
"application/msword",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.slideshow",
|
||||||
|
"application/vnd.ms-excel",
|
||||||
|
"application/vnd.ms-powerpoint",
|
||||||
|
"application/vnd.ms-excel.sheet.macroEnabled.12",
|
||||||
|
"application/vnd.ms-powerpoint.presentation.macroEnabled.12",
|
||||||
|
"application/vnd.ms-excel.template.macroEnabled.12",
|
||||||
|
"application/vnd.ms-powerpoint.template.macroEnabled.12",
|
||||||
|
"application/vnd.ms-excel.addin.macroEnabled.12",
|
||||||
|
"application/vnd.ms-powerpoint.addin.macroEnabled.12",
|
||||||
|
"application/vnd.ms-excel.sheet.binary.macroEnabled.12",
|
||||||
|
"application/vnd.ms-powerpoint.presentation.binary.macroEnabled.12",
|
||||||
|
"application/vnd.ms-excel.template.binary.macroEnabled.12",
|
||||||
|
"application/vnd.ms-powerpoint.template.binary.macroEnabled.12",
|
||||||
|
"application/vnd.ms-excel.addin.binary.macroEnabled.12",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,14 +76,11 @@ export class UploaderService {
|
|||||||
}
|
}
|
||||||
this.logger.log(`Uploading file: ${file.originalname}`);
|
this.logger.log(`Uploading file: ${file.originalname}`);
|
||||||
|
|
||||||
// Validate file
|
|
||||||
this.validateFile(file);
|
this.validateFile(file);
|
||||||
|
|
||||||
// Determine file type
|
const fileType = await this.determineFileType(file.mimetype);
|
||||||
this.determineFileType(file.mimetype);
|
|
||||||
|
|
||||||
// Generate S3 key
|
const s3Key = this.s3Service.generateFileKey(file.originalname, fileType);
|
||||||
const s3Key = this.s3Service.generateFileKey(file.originalname);
|
|
||||||
|
|
||||||
// Upload to S3
|
// Upload to S3
|
||||||
const s3Result = await this.s3Service.uploadFile(file.buffer, s3Key, file.mimetype, {
|
const s3Result = await this.s3Service.uploadFile(file.buffer, s3Key, file.mimetype, {
|
||||||
@@ -83,27 +102,28 @@ export class UploaderService {
|
|||||||
private validateFile(file: File): void {
|
private validateFile(file: File): void {
|
||||||
if (!file) throw new BadRequestException(UploaderMessage.UPLOAD_FILE_INVALID);
|
if (!file) throw new BadRequestException(UploaderMessage.UPLOAD_FILE_INVALID);
|
||||||
|
|
||||||
if (file.size && file.size > this.maxFileSize) throw new BadRequestException(`File too large. Maximum size is ${this.maxFileSize} bytes`);
|
if (file.size && file.size > this.maxFileSize)
|
||||||
|
throw new BadRequestException(UploaderMessage.UPLOAD_FILE_TOO_LARGE.replace("[MAX_FILE_SIZE]", this.maxFileSize.toString()));
|
||||||
|
|
||||||
if (!this.allowedMimeTypes.includes(file.mimetype)) throw new BadRequestException(`File type not supported: ${file.mimetype}`);
|
if (!this.allowedMimeTypes.includes(file.mimetype))
|
||||||
|
throw new BadRequestException(UploaderMessage.UPLOAD_FILE_TYPE_NOT_SUPPORTED.replace("[MIME_TYPES]", file.mimetype));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine file type based on mimetype
|
* Determine file type based on mimetype
|
||||||
*/
|
*/
|
||||||
private determineFileType(mimetype: string) {
|
private async determineFileType(mimetype: string): Promise<FileType> {
|
||||||
if (mimetype.startsWith("video/")) {
|
if (mimetype.startsWith("video/")) {
|
||||||
return "video";
|
return "video";
|
||||||
} else if (mimetype.startsWith("audio/")) {
|
} else if (mimetype.startsWith("audio/")) {
|
||||||
return "audio";
|
return "audio";
|
||||||
} else if (mimetype.startsWith("image/")) {
|
} else if (mimetype.startsWith("image/")) {
|
||||||
return "image";
|
return "image";
|
||||||
} else if (mimetype.startsWith("application/")) {
|
|
||||||
return "application";
|
|
||||||
} else if (mimetype.startsWith("text/")) {
|
} else if (mimetype.startsWith("text/")) {
|
||||||
return "text";
|
return "text";
|
||||||
|
} else if (mimetype.startsWith("application/")) {
|
||||||
|
return "document";
|
||||||
}
|
}
|
||||||
// Default to video for unknown types
|
return "unknown";
|
||||||
return "video";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export class UploaderController {
|
|||||||
@UseInterceptors(FileInterceptor("file"))
|
@UseInterceptors(FileInterceptor("file"))
|
||||||
@ApiBody({ type: UploadSingleFileDto })
|
@ApiBody({ type: UploadSingleFileDto })
|
||||||
@Post("single-file")
|
@Post("single-file")
|
||||||
async uploadFile(@UploadedFile() file: File) {
|
uploadFile(@UploadedFile() file: File) {
|
||||||
return this.uploaderService.uploadFile(file);
|
return this.uploaderService.uploadFile(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ import { UploaderController } from "./uploader.controller";
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [UploaderController],
|
controllers: [UploaderController],
|
||||||
providers: [UploaderService, S3Service],
|
providers: [UploaderService, S3Service],
|
||||||
exports: [UploaderService],
|
exports: [UploaderService, S3Service],
|
||||||
})
|
})
|
||||||
export class UploaderModule {}
|
export class UploaderModule {}
|
||||||
|
|||||||
Reference in New Issue
Block a user