forked from Chatbot/v3-api
203 lines
6.2 KiB
TypeScript
203 lines
6.2 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Controller,
|
|
Get,
|
|
Param,
|
|
Post,
|
|
Query,
|
|
Req,
|
|
UploadedFile,
|
|
UseGuards,
|
|
UseInterceptors,
|
|
StreamableFile,
|
|
} from '@nestjs/common';
|
|
import { FileInterceptor } from '@nestjs/platform-express';
|
|
import { diskStorage } from 'multer';
|
|
import { tmpdir } from 'node:os';
|
|
import { extname } from 'node:path';
|
|
import { randomUUID } from 'node:crypto';
|
|
import {
|
|
ApiBearerAuth,
|
|
ApiBody,
|
|
ApiConsumes,
|
|
ApiOperation,
|
|
ApiParam,
|
|
ApiQuery,
|
|
ApiTags,
|
|
} from '@nestjs/swagger';
|
|
import type { Request } from 'express';
|
|
import { AdminGuard } from 'src/auth/guards/admin.guard';
|
|
import { AdminIdentity, CurrentIdentity } from 'src/common/decorators/Identity.decorator';
|
|
import { Permissions } from 'src/common/decorators/permission.decorator';
|
|
import { Permission } from 'src/common/types/permissions.catalog';
|
|
import { AdminDocument } from 'src/database/model/admin.model';
|
|
import { MAX_CHAT_VOICE_BYTES } from 'src/storage/storage.constants';
|
|
import { AttachmentSessionExpertGuard } from './attachment-session-expert.guard';
|
|
import { AttachmentSessionUserGuard } from './attachment-session-user.guard';
|
|
import { ChatAttachmentsService } from './chat-attachments.service';
|
|
|
|
const attachmentDiskStorage = diskStorage({
|
|
destination: (_req, _file, cb) => cb(null, tmpdir()),
|
|
filename: (_req, file, cb) => {
|
|
const raw = extname(file.originalname || '').toLowerCase().slice(0, 24);
|
|
const safe = /^\.[a-z0-9.]{1,20}$/.test(raw) ? raw : '';
|
|
cb(null, `chat-att-${randomUUID()}${safe}`);
|
|
},
|
|
});
|
|
|
|
@ApiTags('chat-attachments')
|
|
@Controller('chat/attachments')
|
|
@ApiBearerAuth()
|
|
export class ChatAttachmentsController {
|
|
constructor(private readonly attachments: ChatAttachmentsService) {}
|
|
|
|
private parseDurationSec(raw: unknown): number | undefined {
|
|
if (raw === undefined || raw === null || raw === '') return undefined;
|
|
const n = Number(raw);
|
|
return Number.isFinite(n) ? n : undefined;
|
|
}
|
|
|
|
@Post('user/:sessionId')
|
|
@ApiOperation({
|
|
summary:
|
|
'Upload chat attachment (voice FFmpeg→m4a, no image, no PDF). Then sendMessage with message=storageKey, presetMessageId, type matching fileType.',
|
|
})
|
|
@ApiParam({ name: 'sessionId' })
|
|
@ApiConsumes('multipart/form-data')
|
|
@ApiBody({
|
|
schema: {
|
|
type: 'object',
|
|
required: ['file', 'fileType'],
|
|
properties: {
|
|
file: { type: 'string', format: 'binary' },
|
|
fileType: {
|
|
type: 'string',
|
|
enum: ['voice'], //? 'image', 'document'
|
|
},
|
|
durationSec: { type: 'number', description: 'Voice: optional duration hint' },
|
|
},
|
|
},
|
|
})
|
|
@UseGuards(AttachmentSessionUserGuard)
|
|
@UseInterceptors(
|
|
FileInterceptor('file', {
|
|
storage: attachmentDiskStorage,
|
|
limits: { fileSize: MAX_CHAT_VOICE_BYTES },
|
|
}),
|
|
)
|
|
uploadUser(
|
|
@Param('sessionId') sessionId: string,
|
|
@UploadedFile() file: Express.Multer.File,
|
|
@Req() req: Request,
|
|
@CurrentIdentity() user: { _id?: unknown },
|
|
) {
|
|
if (!file) throw new BadRequestException('file_required');
|
|
if (!user?._id) throw new BadRequestException('user_token_required');
|
|
const fileType = this.attachments.parseFileType(
|
|
(req.body as Record<string, unknown>)?.fileType,
|
|
);
|
|
return this.attachments.uploadAttachment({
|
|
sessionId,
|
|
file,
|
|
uploaderId: String(user._id),
|
|
uploaderRole: 'User',
|
|
fileType,
|
|
durationSec: this.parseDurationSec(
|
|
(req.body as Record<string, unknown>)?.durationSec,
|
|
),
|
|
});
|
|
}
|
|
|
|
@Post('expert/:sessionId')
|
|
@ApiOperation({ summary: 'Upload chat attachment (expert JWT)' })
|
|
@ApiParam({ name: 'sessionId' })
|
|
@ApiConsumes('multipart/form-data')
|
|
@ApiBody({
|
|
schema: {
|
|
type: 'object',
|
|
required: ['file', 'fileType'],
|
|
properties: {
|
|
file: { type: 'string', format: 'binary' },
|
|
fileType: { type: 'string', enum: ['voice', 'image', 'document'] },
|
|
durationSec: { type: 'number' },
|
|
},
|
|
},
|
|
})
|
|
@UseGuards(AdminGuard, AttachmentSessionExpertGuard)
|
|
@Permissions(Permission.AttachmentsExpert)
|
|
@UseInterceptors(
|
|
FileInterceptor('file', {
|
|
storage: attachmentDiskStorage,
|
|
limits: { fileSize: MAX_CHAT_VOICE_BYTES },
|
|
}),
|
|
)
|
|
uploadExpert(
|
|
@Param('sessionId') sessionId: string,
|
|
@UploadedFile() file: Express.Multer.File,
|
|
@Req() req: Request,
|
|
@AdminIdentity() admin: AdminDocument,
|
|
) {
|
|
if (!file) throw new BadRequestException('file_required');
|
|
const fileType = this.attachments.parseFileType(
|
|
(req.body as Record<string, unknown>)?.fileType,
|
|
);
|
|
return this.attachments.uploadAttachment({
|
|
sessionId,
|
|
file,
|
|
uploaderId: String(admin._id),
|
|
uploaderRole: 'Expert',
|
|
fileType,
|
|
durationSec: this.parseDurationSec(
|
|
(req.body as Record<string, unknown>)?.durationSec,
|
|
),
|
|
});
|
|
}
|
|
|
|
@Get('user/:sessionId/stream')
|
|
@ApiOperation({
|
|
summary: 'Stream a chat attachment (private object). Query: key = full storageKey.',
|
|
})
|
|
@ApiParam({ name: 'sessionId' })
|
|
@ApiQuery({
|
|
name: 'key',
|
|
required: true,
|
|
description: 'Full object key (same as message text for attachment messages)',
|
|
example: 'chats/.../voice/....m4a',
|
|
})
|
|
@UseGuards(AttachmentSessionUserGuard)
|
|
async streamUser(
|
|
@Param('sessionId') sessionId: string,
|
|
@Query('key') storageKey: string,
|
|
): Promise<StreamableFile> {
|
|
const { stream, contentType } =
|
|
await this.attachments.streamPrivateAttachment({
|
|
sessionId,
|
|
storageKey,
|
|
});
|
|
return new StreamableFile(stream, {
|
|
type: contentType,
|
|
disposition: `inline; filename="attachment"`,
|
|
});
|
|
}
|
|
|
|
@Get('expert/:sessionId/stream')
|
|
@ApiOperation({ summary: 'Stream attachment (expert JWT)' })
|
|
@ApiParam({ name: 'sessionId' })
|
|
@ApiQuery({ name: 'key', required: true })
|
|
@UseGuards(AdminGuard, AttachmentSessionExpertGuard)
|
|
async streamExpert(
|
|
@Param('sessionId') sessionId: string,
|
|
@Query('key') storageKey: string,
|
|
): Promise<StreamableFile> {
|
|
const { stream, contentType } =
|
|
await this.attachments.streamPrivateAttachment({
|
|
sessionId,
|
|
storageKey,
|
|
});
|
|
return new StreamableFile(stream, {
|
|
type: contentType,
|
|
disposition: `inline; filename="attachment"`,
|
|
});
|
|
}
|
|
}
|