teams-bots/server/src/modules/messages/messages.service.ts

470 lines
14 KiB
TypeScript

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<Message>,
@InjectRepository(Conversation)
readonly conversationRepo: Repository<Conversation>,
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<Message>(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<Message[]> {
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<string, Message>();
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;
}
}