470 lines
14 KiB
TypeScript
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;
|
|
}
|
|
}
|