import { Conversation } from '@/entities/conversation.entity'; import { Message } from '@/entities/message.entity'; import { formatTextIfValid } from '@/features/conver-message'; import { formatTimeAU } from '@/features/format-time-au'; import { isBase64 } from '@/features/is-base64'; import AppResponse from '@/system/filters/response/app-response'; import { SystemLang } from '@/system/lang/system.lang'; import { BadRequestException, HttpStatus, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { isJSON } from 'class-validator'; import { paginate, PaginateQuery } from 'nestjs-paginate'; import { Repository } from 'typeorm'; import { CreateBulkMessageDto } from './dtos/create-bulk-message.dto'; import { CreateMessageDto } from './dtos/create-message.dto'; import { ReplyMessageDto } from './dtos/reply-message.dto'; import { SendMessageDto } from './dtos/send-message.dto'; import { MessagesEventService } from './messages-event.service'; import { ZulipService } from './zulip.service'; @Injectable() export class MessagesService { private readonly logger = new Logger(MessagesService.name); constructor( @InjectRepository(Message) readonly repo: Repository, @InjectRepository(Conversation) readonly conversationRepo: Repository, private readonly event: MessagesEventService, private readonly zulipService: ZulipService, ) {} async index(query: PaginateQuery) { const result = await paginate(query, this.repo, { sortableColumns: ['created_at'], searchableColumns: ['message'], defaultLimit: 10, filterableColumns: { id: true, time: true, message: true, time_raw: true, date_time: true, updated_at: true, created_at: true, 'conversation.id': true, }, maxLimit: Infinity, defaultSortBy: [['time_raw', 'DESC']], }); return AppResponse.toPagination(result, true, Message); } // async create( // dto: CreateMessageDto, // ): Promise<{ data: Message; exit: boolean }> { // const time = new Date(dto.time); // const existing = await this.repo.findOne({ // where: { time_raw: dto.time, room_id: dto.room_id }, // }); // if (existing) { // return { data: existing, exit: true }; // } // const conversation = await this.conversationRepo.findOne({ // where: { id: dto.room_id }, // }); // if (!conversation) // throw new BadRequestException( // AppResponse.toResponse(null, { // message: SystemLang.getText('messages', 'not_found'), // }), // ); // const entity = this.repo.create({ // ...dto, // time, // time_raw: dto.time, // }); // const result = await this.repo.save(entity); // if (result) { // if ( // !conversation.name // .toLocaleLowerCase() // .includes(process.env.GROUP_PREFIX.toLocaleLowerCase()) || // conversation.type !== 'group' // ) // return; // if (!conversation) return; // // Handle when message is a resource // if (isBase64(result.message)) { // const fileUrl = await this.zulipService.uploadFileToZulip( // result.message, // ); // const content = `[](${fileUrl})`; // await this.zulipService.sendMessageToTopic( // process.env.ZULIP_STREAMS_NAME, // conversation.name, // content, // ); // await this.repo.update(result.id, { message: fileUrl }); // } else { // const content = `** :rocket: ${result.name} sent - ${formatTimeAU(result.time_raw)}:** // \`\`\` // ${formatTextIfValid(result.message)} // \`\`\``; // await this.zulipService.sendMessageToTopic( // process.env.ZULIP_STREAMS_NAME, // conversation.name, // content, // ); // } // } // return { data: result, exit: false }; // } encodeSharingUrl(url: string) { let encoded = btoa(url); // Base64 thường encoded = encoded .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); return 'u!' + encoded; } toDownloadLink(encoded: string) { return `https://graph.microsoft.com/v1.0/shares/${encoded}/driveItem?select=restricted,webDavUrl,@microsoft.graph.downloadUrl,file,name`; } async create( dto: CreateMessageDto, ): Promise<{ data: Message; exit: boolean }> { const time = new Date(dto.time); // 1. Kiểm tra message đã tồn tại chưa const existing = await this.repo.findOne({ where: { time_raw: dto.time, room_id: dto.room_id }, }); if (existing) { return { data: existing, exit: true }; } // 2. Lấy thông tin conversation const conversation = await this.conversationRepo.findOne({ where: { id: dto.room_id }, }); if (!conversation) { throw new BadRequestException( AppResponse.toResponse(null, { message: SystemLang.getText('messages', 'not_found'), }), ); } // 3. Chuẩn hóa message trước khi lưu let messages: string[] = Array.isArray(dto.message) ? dto.message : [dto.message]; const finalMessages: string[] = []; // for (const msg of messages) { // if (isBase64(msg)) { // // Nếu là base64 → upload lên Zulip trước // const fileUrl = await this.zulipService.uploadBase64ToZulip(msg); // finalMessages.push(fileUrl); // Lưu link ảnh thay vì base64 // } else if (isJSON(msg)) { // console.log({ msg }); // const data = JSON.parse(msg); // if (!data?.url || !data?.token) { // finalMessages.push(msg); // Lưu nguyên text // continue; // } // const encoded = this.encodeSharingUrl(data?.url); // const downloadUrl = this.toDownloadLink(encoded); // try { // const fileUrl = await this.zulipService.uploadGraphLinkToZulip({ // url: downloadUrl, // token: data.token, // }); // console.log({ fileUrl }); // const newData = { ...data, url: fileUrl, origin_url: data.url }; // finalMessages.push(JSON.stringify(newData)); // Lưu nguyên text // } catch (error) { // finalMessages.push(msg); // Lưu nguyên text // continue; // } // finalMessages.push(msg); // Lưu nguyên text // } else { // finalMessages.push(msg); // Lưu nguyên text // } // } for (const msg of messages) { if (isBase64(msg)) { // Nếu là base64 → upload lên Zulip trước const fileUrl = await this.zulipService.uploadBase64ToZulip(msg); finalMessages.push(fileUrl); // Lưu link ảnh thay vì base64 continue; } if (isJSON(msg)) { const data = JSON.parse(msg); if (!data?.url || !data?.token) { finalMessages.push(msg); // Lưu nguyên text nếu thiếu dữ liệu continue; } const encoded = this.encodeSharingUrl(data.url); const downloadUrl = this.toDownloadLink(encoded); try { const fileUrl = await this.zulipService.uploadGraphLinkToZulip({ url: downloadUrl, token: data.token, }); const newData = { ...data, url: fileUrl, origin_url: data.url }; finalMessages.push(JSON.stringify(newData)); // ✅ chỉ push data đã cập nhật } catch (error) { finalMessages.push(msg); // push message gốc khi lỗi } continue; // kết thúc vòng lặp để tránh chạy xuống dưới } // Trường hợp còn lại → push nguyên text finalMessages.push(msg); } // 4. Lưu vào DB với message đã xử lý const entity = this.repo.create({ ...dto, message: JSON.stringify(finalMessages), time, time_raw: dto.time, }); const result = await this.repo.save(entity); if (!result) return { data: null, exit: false }; // 5. Kiểm tra conversation có hợp lệ để gửi Zulip không const groupPrefix = process.env.GROUP_PREFIX?.toLocaleLowerCase() || ''; if ( !conversation?.name?.toLocaleLowerCase().includes(groupPrefix) || conversation?.type !== 'group' ) { return { data: result, exit: false }; } const buildZulipMessageContent = ( msgs: string[], result: Message, ): string => { const imageUris: string[] = []; let textMessages: string[] = []; const fileLinks: string[] = []; // Chứa danh sách file dạng [name](url) for (const msg of msgs) { // 1. Nếu là JSON và có type === 'file' try { const parsed = JSON.parse(msg) || msg; if (parsed?.type === 'file' && parsed.url && parsed.name) { fileLinks.push(`[${parsed.name}](${parsed.url})`); continue; } } catch (error) { // Không phải JSON → bỏ qua, xử lý như text } // 2. Nếu là link ảnh upload → hiển thị dạng image if (/\/user_uploads\//.test(msg)) { imageUris.push(`[image](${msg.replace(/^\/api\/v1/, '')})`); continue; } // 3. Còn lại là text thường textMessages.push(formatTextIfValid(msg)); } // Loại bỏ các tin nhắn thừa msg: ImageImageImage const textIsImages = imageUris.map((img) => 'Image').join(''); if (textMessages.includes(textIsImages)) { textMessages = textMessages.filter((msg) => msg !== textIsImages); } // ==== Build nội dung cuối ==== let finalContent = `** :rocket: ${result.name} sent - ${formatTimeAU(result.time_raw)}:**\n`; // Text chính → code block if (textMessages.length > 0) { finalContent += `\`\`\`\n${textMessages.join('\n')}\n\`\`\`\n`; } // Ảnh → dưới text if (imageUris.length > 0) { finalContent += `${imageUris.join('\n')}\n`; } // File → phần cuối cùng if (fileLinks.length > 0) { finalContent += `\n**Files:**\n${fileLinks.join('\n')}`; } return finalContent.trim(); }; const finalMessage = buildZulipMessageContent(finalMessages, result); // 7. Gửi lên Zulip await this.zulipService.sendMessageToTopic( process.env.ZULIP_STREAMS_NAME, conversation.name, finalMessage, ); return { data: result, exit: false }; } async bulkCreate(dtos: CreateMessageDto[]): Promise { const conditions = dtos.map((dto) => ({ time_raw: dto.time, room_id: dto.room_id ?? null, })); // Dùng QueryBuilder để tìm các message đã tồn tại const qb = this.repo.createQueryBuilder('message'); conditions.forEach((cond, i) => { const timeParam = `time_${i}`; const roomParam = `room_${i}`; const clause = cond.room_id ? `(message.time_raw = :${timeParam} AND message.room_id = :${roomParam})` : `(message.time_raw = :${timeParam} AND message.room_id IS NULL)`; if (i === 0) { qb.where(clause); } else { qb.orWhere(clause); } qb.setParameter(timeParam, cond.time_raw); if (cond.room_id) qb.setParameter(roomParam, cond.room_id); }); const existingMessages = await qb.getMany(); const existingMap = new Map(); for (const m of existingMessages) { const key = `${m.time_raw}-${m.room_id ?? ''}`; existingMap.set(key, m); } const toUpdate: Message[] = []; const toCreate: Message[] = []; for (const dto of dtos) { const time = new Date(dto.time); const key = `${dto.time}-${dto.room_id ?? ''}`; const entity = this.repo.create({ ...dto, time, time_raw: dto.time, message: JSON.stringify(dto.message), }); const existing = existingMap.get(key); if (existing) { entity.id = existing.id; toUpdate.push(entity); } else { toCreate.push(entity); } } const updated = await this.repo.save(toUpdate); const created = await this.repo.save(toCreate); return [...updated, ...created]; } async sendMessage(data: SendMessageDto) { const conversation = await this.conversationRepo.findOne({ where: { id: data.conversation_id }, }); if (!conversation) throw new NotFoundException( AppResponse.toResponse(null, { message: SystemLang.getText('messages', 'not_found'), status_code: HttpStatus.NOT_FOUND, }), ); this.event.sendEvent(MessagesEventService.EVENTS.SEND_MESSAGE, data); return AppResponse.toResponse({ conversation, data, }); } async replyMessage(data: ReplyMessageDto) { const conversation = await this.conversationRepo.findOne({ where: { id: data.conversation_id, messages: { time_raw: data.time }, }, relations: { messages: true }, }); if (!conversation) throw new NotFoundException( AppResponse.toResponse(null, { message: SystemLang.getText('messages', 'not_found'), status_code: HttpStatus.NOT_FOUND, }), ); this.event.sendEvent(MessagesEventService.EVENTS.REPLY_MESSAGE, data); return AppResponse.toResponse({ conversation, data, }); } async createAndSendToZulip({ data }: CreateBulkMessageDto) { const results = []; // Đảo ngược array trước khi xử lý // const reversedData = [...data].reverse(); for (const mes of data.filter((message) => message.message.length)) { const result = await this.create(mes); if (result) { results.push(result); } } return results; } }