@warriorteam/zalo-personal
Version:
Unofficial Zalo Personal API for JavaScript - A powerful library for interacting with Zalo personal accounts with URL attachment support
243 lines (242 loc) • 11.9 kB
JavaScript
import FormData from "form-data";
import fs from "node:fs";
import { ZaloApiError } from "../Errors/ZaloApiError.js";
import { ThreadType } from "../models/index.js";
import { apiFactory, getFileExtension, getFileName, getFileSize, getImageMetaData, getMd5LargeFileObject, resolveResponse, isValidUrl, downloadFileFromUrl, } from "../utils.js";
const urlType = {
image: "photo_original/upload",
video: "asyncfile/upload",
others: "asyncfile/upload",
};
export const uploadAttachmentFactory = apiFactory()((api, ctx, utils) => {
const serviceURL = `${api.zpwServiceMap.file[0]}/api`;
const { sharefile } = ctx.settings.features;
function isExceedMaxFile(totalFile) {
return totalFile > sharefile.max_file;
}
function isExceedMaxFileSize(fileSize) {
return fileSize > sharefile.max_size_share_file_v3 * 1024 * 1024;
}
function isExtensionValid(ext) {
return sharefile.restricted_ext_file.indexOf(ext) == -1;
}
/**
* Upload an attachment to a thread
*
* @param sources path to files or attachment sources
* @param threadId Group or User ID
* @param type Message type (User or Group)
*
* @throws ZaloApiError
*/
return async function uploadAttachment(sources, threadId, type = ThreadType.User) {
if (!sources)
throw new ZaloApiError("Missing sources");
if (!Array.isArray(sources))
sources = [sources];
if (sources.length == 0)
throw new ZaloApiError("Missing sources");
if (isExceedMaxFile(sources.length))
throw new ZaloApiError("Exceed maximum file of " + sharefile.max_file);
if (!threadId)
throw new ZaloApiError("Missing threadId");
const chunkSize = ctx.settings.features.sharefile.chunk_size_file;
const isGroupMessage = type == ThreadType.Group;
let attachmentsData = [];
let url = `${serviceURL}/${isGroupMessage ? "group" : "message"}/`;
const typeParam = isGroupMessage ? "11" : "2";
let clientId = Date.now();
for (const source of sources) {
const isFilePath = typeof source == "string";
const isUrl = typeof source == "object" && "url" in source;
const isBuffer = typeof source == "object" && "data" in source && source.data instanceof Buffer;
if (!isFilePath && !isUrl && !isBuffer)
throw new ZaloApiError("Invalid source type");
let processedSource;
if (isFilePath) {
if (!fs.existsSync(source))
throw new ZaloApiError("File not found");
const fileName = getFileName(source);
const extFile = getFileExtension(source);
if (isExtensionValid(extFile) == false)
throw new ZaloApiError(`File extension "${extFile}" is not allowed`);
// Read file and get metadata
const fileBuffer = await fs.promises.readFile(source);
let metadata;
switch (extFile) {
case "jpg":
case "jpeg":
case "png":
case "webp":
const imageData = await getImageMetaData(source);
metadata = {
totalSize: imageData.totalSize || 0,
width: imageData.width,
height: imageData.height,
};
break;
default:
const fileSize = await getFileSize(source);
metadata = { totalSize: fileSize };
break;
}
processedSource = {
data: fileBuffer,
filename: fileName,
metadata,
};
}
else if (isUrl) {
if (!isValidUrl(source.url))
throw new ZaloApiError("Invalid URL");
// Download file from URL
const downloadResult = await downloadFileFromUrl(source.url, source.headers);
const fileName = source.filename || downloadResult.filename;
const extFile = getFileExtension(fileName);
if (isExtensionValid(extFile) == false)
throw new ZaloApiError(`File extension "${extFile}" is not allowed`);
processedSource = {
data: downloadResult.data,
filename: fileName,
metadata: downloadResult.metadata,
};
}
else if (isBuffer) {
if (!source.filename)
throw new ZaloApiError("Missing filename");
const extFile = getFileExtension(source.filename);
if (isExtensionValid(extFile) == false)
throw new ZaloApiError(`File extension "${extFile}" is not allowed`);
processedSource = {
data: source.data,
filename: source.filename,
metadata: source.metadata,
};
}
else {
throw new ZaloApiError("Invalid source type");
}
const extFile = getFileExtension(processedSource.filename);
const fileName = processedSource.filename;
// Remove duplicate validation since it's already done above
const data = {
filePath: processedSource.filename,
chunkContent: [],
params: {},
source: processedSource,
};
if (isGroupMessage)
data.params.grid = threadId;
else
data.params.toid = threadId;
switch (extFile) {
case "jpg":
case "jpeg":
case "png":
case "webp":
if (isExceedMaxFileSize(processedSource.metadata.totalSize))
throw new ZaloApiError(`File ${fileName} size exceed maximum size of ${sharefile.max_size_share_file_v3}MB`);
data.fileData = {
fileName,
totalSize: processedSource.metadata.totalSize,
width: processedSource.metadata.width,
height: processedSource.metadata.height,
};
data.fileType = "image";
data.params.totalChunk = Math.ceil(processedSource.metadata.totalSize / chunkSize);
data.params.fileName = fileName;
data.params.clientId = clientId++;
data.params.totalSize = processedSource.metadata.totalSize;
data.params.imei = ctx.imei;
data.params.isE2EE = 0;
data.params.jxl = 0;
data.params.chunkId = 1;
break;
case "mp4":
if (isExceedMaxFileSize(processedSource.metadata.totalSize))
throw new ZaloApiError(`File ${fileName} size exceed maximum size of ${sharefile.max_size_share_file_v3}MB`);
data.fileType = "video";
data.fileData = {
fileName,
totalSize: processedSource.metadata.totalSize,
};
data.params.totalChunk = Math.ceil(processedSource.metadata.totalSize / chunkSize);
data.params.fileName = fileName;
data.params.clientId = clientId++;
data.params.totalSize = processedSource.metadata.totalSize;
data.params.imei = ctx.imei;
data.params.isE2EE = 0;
data.params.jxl = 0;
data.params.chunkId = 1;
break;
default:
if (isExceedMaxFileSize(processedSource.metadata.totalSize))
throw new ZaloApiError(`File ${fileName} size exceed maximum size of ${sharefile.max_size_share_file_v3}MB`);
data.fileType = "others";
data.fileData = {
fileName,
totalSize: processedSource.metadata.totalSize,
};
data.params.totalChunk = Math.ceil(processedSource.metadata.totalSize / chunkSize);
data.params.fileName = fileName;
data.params.clientId = clientId++;
data.params.totalSize = processedSource.metadata.totalSize;
data.params.imei = ctx.imei;
data.params.isE2EE = 0;
data.params.jxl = 0;
data.params.chunkId = 1;
break;
}
const fileBuffer = processedSource.data;
for (let i = 0; i < data.params.totalChunk; i++) {
const formData = new FormData();
const slicedBuffer = fileBuffer.subarray(i * chunkSize, (i + 1) * chunkSize);
formData.append("chunkContent", slicedBuffer, {
filename: fileName,
contentType: "application/octet-stream",
});
data.chunkContent[i] = formData;
}
attachmentsData.push(data);
}
const requests = [], results = [];
for (const data of attachmentsData) {
for (let i = 0; i < data.params.totalChunk; i++) {
const encryptedParams = utils.encodeAES(JSON.stringify(data.params));
if (!encryptedParams)
throw new ZaloApiError("Failed to encrypt message");
requests.push(utils
.request(utils.makeURL(url + urlType[data.fileType], { type: typeParam, params: encryptedParams }), {
method: "POST",
headers: data.chunkContent[i].getHeaders(),
body: data.chunkContent[i].getBuffer(),
})
.then(async (response) => {
/**
* @TODO: better type rather than any
*/
const resData = await resolveResponse(ctx, response);
if (resData && resData.fileId != -1 && resData.photoId != -1)
await new Promise((resolve) => {
if (data.fileType == "video" || data.fileType == "others") {
const uploadCallback = async (wsData) => {
let result = Object.assign(Object.assign(Object.assign({ fileType: data.fileType }, resData), wsData), { totalSize: data.fileData.totalSize, fileName: data.fileData.fileName, checksum: (await getMd5LargeFileObject(data.source, data.fileData.totalSize)).data });
results.push(result);
resolve();
};
ctx.uploadCallbacks.set(resData.fileId, uploadCallback);
}
if (data.fileType == "image") {
let result = Object.assign({ fileType: "image", width: data.fileData.width, height: data.fileData.height, totalSize: data.fileData.totalSize, hdSize: data.fileData.totalSize }, resData);
results.push(result);
resolve();
}
});
}));
data.params.chunkId++;
}
}
await Promise.all(requests);
return results;
};
});