update view message

This commit is contained in:
Admin 2025-09-10 16:27:35 +07:00
parent b008e6a420
commit a4298d444f
18 changed files with 428 additions and 71 deletions

File diff suppressed because one or more lines are too long

View File

@ -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) {

View File

@ -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:");
}

View File

@ -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;

View File

@ -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 {

View File

@ -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

View File

@ -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"

View File

@ -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",

View File

@ -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' })

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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[];
}

View File

@ -11,7 +11,7 @@ export class CreateMessageDto {
@IsOptional()
@IsString()
@Expose()
message?: string;
message?: string | string[];
@IsNumber()
@Expose()

View File

@ -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) {

View File

@ -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,
},
},
),

0
server/test.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB