update view message
This commit is contained in:
parent
b008e6a420
commit
a4298d444f
File diff suppressed because one or more lines are too long
|
|
@ -1,3 +1,4 @@
|
|||
import { imageUrlToBase64, isBlobUrl } from "@/features/app";
|
||||
import axios from "@/lib/axios";
|
||||
|
||||
class MessageApiService {
|
||||
|
|
@ -14,7 +15,9 @@ class MessageApiService {
|
|||
|
||||
async sendBulkMessages(messages: IMessage[]) {
|
||||
try {
|
||||
const { data } = await axios.post("/messages/bulk", { data: messages });
|
||||
const { data } = await axios.post("/messages/bulk", {
|
||||
data: messages,
|
||||
});
|
||||
console.log("[NestJS] Response (bulk):", data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
|
|
@ -25,9 +28,49 @@ class MessageApiService {
|
|||
|
||||
async createAndSendToZulip(messages: IMessage[]) {
|
||||
try {
|
||||
console.log({ messages });
|
||||
const processedMessages = await Promise.all(
|
||||
messages.map(async (msg) => {
|
||||
// Nếu message là mảng
|
||||
if (Array.isArray(msg.message)) {
|
||||
const processedArray = await Promise.all(
|
||||
msg.message.map(async (item) => {
|
||||
if (isBlobUrl(item)) {
|
||||
console.log(
|
||||
"Found blob URL in array, converting to Base64:",
|
||||
item
|
||||
);
|
||||
const base64 = await imageUrlToBase64(item);
|
||||
return base64;
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
...msg,
|
||||
message: processedArray,
|
||||
};
|
||||
}
|
||||
|
||||
// Nếu message là string
|
||||
if (isBlobUrl(msg.message || "")) {
|
||||
console.log("Found blob URL, converting to Base64:", msg.message);
|
||||
const base64 = await imageUrlToBase64(msg.message || "");
|
||||
return {
|
||||
...msg,
|
||||
message: base64,
|
||||
};
|
||||
}
|
||||
|
||||
return msg;
|
||||
})
|
||||
);
|
||||
|
||||
const { data } = await axios.post("/messages/create-and-send", {
|
||||
data: messages,
|
||||
data: processedMessages,
|
||||
});
|
||||
|
||||
console.log("[NestJS] Response (create and send):", data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -15,3 +15,26 @@ export function removeFalsyValues<T extends Record<string, any>>(
|
|||
export function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function imageUrlToBase64(url: string): Promise<string> {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
// Kết quả là data URL (base64)
|
||||
resolve(reader.result.split(",")[1]);
|
||||
} else {
|
||||
reject("Không thể đọc dữ liệu ảnh");
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export function isBlobUrl(url: string): boolean {
|
||||
return url.startsWith("blob:");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
interface IMessage {
|
||||
name?: string;
|
||||
message?: string;
|
||||
message?: string | string[];
|
||||
time: number;
|
||||
room_id?: string;
|
||||
room_name?: string;
|
||||
|
|
|
|||
|
|
@ -529,7 +529,7 @@ export class ContentService {
|
|||
() => {
|
||||
this.getConversations();
|
||||
},
|
||||
60000
|
||||
120000
|
||||
);
|
||||
console.log("✅ startSyncConversations running");
|
||||
} else {
|
||||
|
|
@ -556,6 +556,7 @@ export class ContentService {
|
|||
|
||||
for (const chat of data) {
|
||||
this._clickToConversation(chat.id as string);
|
||||
|
||||
await delay(2000);
|
||||
|
||||
const currentRoom = this.service.getCurrentRoomInfo();
|
||||
|
|
@ -579,7 +580,7 @@ export class ContentService {
|
|||
});
|
||||
}
|
||||
});
|
||||
}, 30000);
|
||||
}, 40000);
|
||||
|
||||
console.log("✅ autoSyncConversationPrefixMessages running with PQueue");
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,22 @@ export class TeamsChatService {
|
|||
chat_input: '[placeholder="Type a message"]',
|
||||
};
|
||||
|
||||
private _getImageFormEl(el: HTMLElement): HTMLImageElement[] {
|
||||
// Tìm tất cả img có data-gallery-src trong el
|
||||
let sharedImages = Array.from(
|
||||
el.querySelectorAll("img[data-gallery-src]")
|
||||
) as HTMLImageElement[];
|
||||
|
||||
// Nếu không tìm thấy thì thử tìm trong parentElement
|
||||
if (sharedImages.length === 0 && el.parentElement) {
|
||||
sharedImages = Array.from(
|
||||
el.parentElement.querySelectorAll("img[data-gallery-src]")
|
||||
) as HTMLImageElement[];
|
||||
}
|
||||
|
||||
return sharedImages; // Luôn trả về array (có thể rỗng)
|
||||
}
|
||||
|
||||
public getCurrentRoomInfo(): { room_id?: string; room_name?: string } {
|
||||
// const roomId = document
|
||||
// .querySelector(this.elTags.root_id)
|
||||
|
|
@ -36,18 +52,25 @@ export class TeamsChatService {
|
|||
return { room_id: roomId, room_name: roomName };
|
||||
}
|
||||
|
||||
private _getMessageByEl(el: HTMLElement | null): string {
|
||||
private _getMessageByEl(el: HTMLElement | null): string | string[] {
|
||||
if (!el) return "";
|
||||
|
||||
// Lấy text ban đầu (nếu có)
|
||||
let message = el.innerText || "";
|
||||
|
||||
// Nếu có ảnh gửi kèm (ảnh chia sẻ), thì ưu tiên trả về ảnh
|
||||
const sharedImage = el.querySelector(
|
||||
"img[data-gallery-src]"
|
||||
) as HTMLImageElement | null;
|
||||
if (sharedImage) {
|
||||
return sharedImage.getAttribute("data-gallery-src") || "";
|
||||
const sharedImages = this._getImageFormEl(el);
|
||||
|
||||
if (sharedImages.length) {
|
||||
const arrMessage = sharedImages.map(
|
||||
(img) => img.getAttribute("src") || ""
|
||||
);
|
||||
|
||||
if (message?.length) {
|
||||
arrMessage.push(message);
|
||||
}
|
||||
|
||||
return arrMessage;
|
||||
}
|
||||
|
||||
// Tìm tất cả emoji theo itemtype
|
||||
|
|
|
|||
|
|
@ -24,12 +24,14 @@
|
|||
"@nestjs/throttler": "^6.4.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"@nestjs/websockets": "^11.1.5",
|
||||
"axios": "^1.11.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cache-manager": "^7.0.1",
|
||||
"cacheable": "^1.10.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"fs-extra": "^11.3.1",
|
||||
"helmet": "^8.1.0",
|
||||
"image-size": "^2.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
|
|
@ -53,6 +55,7 @@
|
|||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cookie-parser": "^1.4.9",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/multer": "^2.0.0",
|
||||
|
|
@ -3670,6 +3673,17 @@
|
|||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/fs-extra": {
|
||||
"version": "11.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz",
|
||||
"integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/jsonfile": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/graceful-fs": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
|
||||
|
|
@ -3739,6 +3753,16 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsonfile": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz",
|
||||
"integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jsonwebtoken": {
|
||||
"version": "9.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.7.tgz",
|
||||
|
|
@ -4929,7 +4953,6 @@
|
|||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
|
||||
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
|
|
@ -7290,7 +7313,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
|
|
@ -7360,6 +7382,21 @@
|
|||
"webpack": "^5.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fork-ts-checker-webpack-plugin/node_modules/fs-extra": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
||||
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
|
|
@ -7444,10 +7481,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
||||
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
||||
"dev": true,
|
||||
"version": "11.3.1",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz",
|
||||
"integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
|
|
@ -7455,7 +7491,7 @@
|
|||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-monkey": {
|
||||
|
|
@ -7699,7 +7735,6 @@
|
|||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/graphemer": {
|
||||
|
|
@ -9075,7 +9110,6 @@
|
|||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
|
||||
"integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
|
|
@ -10690,8 +10724,7 @@
|
|||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
|
|
@ -13001,7 +13034,6 @@
|
|||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
|
|
|
|||
|
|
@ -40,12 +40,14 @@
|
|||
"@nestjs/throttler": "^6.4.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"@nestjs/websockets": "^11.1.5",
|
||||
"axios": "^1.11.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cache-manager": "^7.0.1",
|
||||
"cacheable": "^1.10.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"fs-extra": "^11.3.1",
|
||||
"helmet": "^8.1.0",
|
||||
"image-size": "^2.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
|
|
@ -69,6 +71,7 @@
|
|||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cookie-parser": "^1.4.9",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/multer": "^2.0.0",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export class Message {
|
|||
@Column({ nullable: true })
|
||||
name: string; // tên người gửi
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
@Column({ type: 'json', nullable: true })
|
||||
message: string; // nội dung
|
||||
|
||||
@Column({ type: 'timestamp' })
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
export function isBase64(str: string): boolean {
|
||||
if (!str || typeof str !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Bỏ phần prefix nếu có (ví dụ: data:image/png;base64,)
|
||||
const cleanedStr = str.includes(',') ? str.split(',')[1] : str;
|
||||
|
||||
// Base64 chỉ chứa các ký tự A-Z, a-z, 0-9, +, / và có thể kết thúc bằng = hoặc ==
|
||||
const base64Regex =
|
||||
/^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/;
|
||||
|
||||
return base64Regex.test(cleanedStr);
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
export function isUrl(str: string): boolean {
|
||||
if (!str || typeof str !== 'string') return false;
|
||||
|
||||
try {
|
||||
const url = new URL(str);
|
||||
return ['http:', 'https:'].includes(url.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,7 @@
|
|||
import { IsArray, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer'; // ⚠️ Đúng package
|
||||
import { IsArray } from 'class-validator';
|
||||
import { CreateMessageDto } from './create-message.dto';
|
||||
|
||||
export class CreateBulkMessageDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateMessageDto)
|
||||
data: CreateMessageDto[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export class CreateMessageDto {
|
|||
@IsOptional()
|
||||
@IsString()
|
||||
@Expose()
|
||||
message?: string;
|
||||
message?: string | string[];
|
||||
|
||||
@IsNumber()
|
||||
@Expose()
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ 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';
|
||||
import { isUrl } from '@/features/is-url';
|
||||
import { isBase64 } from '@/features/is-base64';
|
||||
@Injectable()
|
||||
export class MessagesService {
|
||||
constructor(
|
||||
|
|
@ -50,61 +52,183 @@ export class MessagesService {
|
|||
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 };
|
||||
// }
|
||||
|
||||
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)
|
||||
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.uploadFileToZulip(msg);
|
||||
finalMessages.push(fileUrl); // Lưu link ảnh thay vì base64
|
||||
} else {
|
||||
finalMessages.push(msg); // Lưu nguyên text
|
||||
}
|
||||
}
|
||||
|
||||
// 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 };
|
||||
|
||||
if (result) {
|
||||
if (
|
||||
!conversation.name
|
||||
.toLocaleLowerCase()
|
||||
.includes(process.env.GROUP_PREFIX.toLocaleLowerCase()) ||
|
||||
conversation.type !== 'group'
|
||||
)
|
||||
return;
|
||||
|
||||
if (!conversation) return;
|
||||
|
||||
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,
|
||||
);
|
||||
// 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 };
|
||||
}
|
||||
|
||||
// 6. Build message để gửi lên Zulip
|
||||
const buildZulipMessageContent = (
|
||||
msgs: string[],
|
||||
result: Message,
|
||||
): string => {
|
||||
const imageUris: string[] = [];
|
||||
const textMessages: string[] = [];
|
||||
|
||||
for (const msg of msgs) {
|
||||
// Nếu là link `/user_uploads/...` thì render ảnh
|
||||
if (/\/user_uploads\//.test(msg)) {
|
||||
imageUris.push(`[image](${msg.replace(/^\/api\/v1/, '')})`);
|
||||
} else {
|
||||
textMessages.push(formatTextIfValid(msg));
|
||||
}
|
||||
}
|
||||
|
||||
let finalContent = `** :rocket: ${result.name} sent - ${formatTimeAU(result.time_raw)}:**\n`;
|
||||
|
||||
// Nếu có text → thêm block code
|
||||
if (textMessages.length > 0) {
|
||||
finalContent += `\`\`\`\n${textMessages.join('\n')}\n\`\`\`\n`;
|
||||
}
|
||||
|
||||
// Nếu có ảnh → thêm danh sách ảnh ở cuối
|
||||
if (imageUris.length > 0) {
|
||||
finalContent += imageUris.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 };
|
||||
}
|
||||
|
||||
|
|
@ -144,7 +268,12 @@ ${formatTextIfValid(result.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 });
|
||||
const entity = this.repo.create({
|
||||
...dto,
|
||||
time,
|
||||
time_raw: dto.time,
|
||||
message: JSON.stringify(dto.message),
|
||||
});
|
||||
|
||||
const existing = existingMap.get(key);
|
||||
if (existing) {
|
||||
|
|
@ -213,7 +342,7 @@ ${formatTextIfValid(result.message)}
|
|||
// Đảo ngược array trước khi xử lý
|
||||
// const reversedData = [...data].reverse();
|
||||
|
||||
for (const mes of data) {
|
||||
for (const mes of data.filter((message) => message.message.length)) {
|
||||
const result = await this.create(mes);
|
||||
|
||||
if (result) {
|
||||
|
|
|
|||
|
|
@ -1,22 +1,106 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import axios from 'axios';
|
||||
import * as FormData from 'form-data';
|
||||
import * as fs from 'fs-extra';
|
||||
import * as path from 'path';
|
||||
|
||||
@Injectable()
|
||||
export class ZulipService {
|
||||
private readonly logger = new Logger(ZulipService.name);
|
||||
|
||||
constructor(private readonly httpService: HttpService) {}
|
||||
private readonly zulipUrl: string;
|
||||
private readonly botEmail: string;
|
||||
private readonly apiKey: string;
|
||||
|
||||
constructor(
|
||||
private readonly httpService: HttpService,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.zulipUrl = this.configService.get<string>('ZULIP_API_URL');
|
||||
this.botEmail = this.configService.get<string>('ZULIP_BOT_EMAIL');
|
||||
this.apiKey = this.configService.get<string>('ZULIP_API_KEY');
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload bất kỳ file nào từ external URL lên Zulip
|
||||
*/
|
||||
async uploadFileToZulip(base64Data: string): Promise<string> {
|
||||
const tempDir = path.join(process.cwd(), 'tmp');
|
||||
await fs.ensureDir(tempDir);
|
||||
|
||||
// 1. Lấy thông tin MIME type và phần dữ liệu base64
|
||||
let mimeType = 'application/octet-stream';
|
||||
let base64Content = base64Data;
|
||||
|
||||
const base64Regex = /^data:(.*?);base64,(.*)$/;
|
||||
const match = base64Data.match(base64Regex);
|
||||
if (match) {
|
||||
mimeType = match[1];
|
||||
base64Content = match[2];
|
||||
}
|
||||
|
||||
// 2. Lấy phần đuôi file từ MIME type
|
||||
const extension = 'png';
|
||||
const filename = `${Date.now()}-${Math.random().toString(36).substring(7)}.${extension}`;
|
||||
const filePath = path.join(tempDir, filename);
|
||||
|
||||
try {
|
||||
this.logger.log(`Decoding and saving file from base64...`);
|
||||
|
||||
// 3. Decode Base64 và lưu vào file tạm
|
||||
const fileBuffer = Buffer.from(base64Content, 'base64');
|
||||
await fs.writeFile(filePath, fileBuffer);
|
||||
|
||||
this.logger.log(`File saved temporarily at: ${filePath}`);
|
||||
|
||||
// 4. Upload file lên Zulip
|
||||
const form = new FormData();
|
||||
form.append('filename', fs.createReadStream(filePath));
|
||||
|
||||
const uploadRes = await axios.post(
|
||||
`${this.zulipUrl}/user_uploads`,
|
||||
form,
|
||||
{
|
||||
auth: {
|
||||
username: this.botEmail,
|
||||
password: this.apiKey,
|
||||
},
|
||||
headers: form.getHeaders(),
|
||||
},
|
||||
);
|
||||
|
||||
const zulipFileUrl = `${uploadRes.data.uri}`;
|
||||
this.logger.log(`Uploaded to Zulip: ${zulipFileUrl}`);
|
||||
|
||||
return zulipFileUrl;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'Upload to Zulip failed',
|
||||
error?.response?.data || error.message,
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
// 5. Xóa file tạm
|
||||
if (await fs.pathExists(filePath)) {
|
||||
await fs.remove(filePath);
|
||||
this.logger.log(`Deleted temp file: ${filePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gửi tin nhắn vào một topic trong stream
|
||||
*/
|
||||
async sendMessageToTopic(stream: string, topic: string, content: string) {
|
||||
const url = process.env.ZULIP_API_URL;
|
||||
const botEmail = process.env.ZULIP_BOT_EMAIL;
|
||||
const apiKey = process.env.ZULIP_API_KEY;
|
||||
const apiUrl = this.zulipUrl + '/messages';
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.post(
|
||||
url,
|
||||
apiUrl,
|
||||
new URLSearchParams({
|
||||
type: 'stream',
|
||||
to: stream,
|
||||
|
|
@ -24,12 +108,10 @@ export class ZulipService {
|
|||
content: content,
|
||||
}).toString(),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
auth: {
|
||||
username: botEmail,
|
||||
password: apiKey,
|
||||
username: this.botEmail,
|
||||
password: this.apiKey,
|
||||
},
|
||||
},
|
||||
),
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Loading…
Reference in New Issue