@warriorteam/zalo-personal
Version:
Unofficial Zalo Personal API for JavaScript - A powerful library for interacting with Zalo personal accounts with URL attachment support, auto-reply, product catalog, and business features
453 lines (452 loc) • 20.7 kB
JavaScript
import FormData from "form-data";
import fs from "node:fs/promises";
import { ZaloApiError } from "../Errors/ZaloApiError.js";
import { ThreadType } from "../models/index.js";
import { apiFactory, getClientMessageType, getFileExtension, getFileName, getGifMetaData, getMd5LargeFileObject, removeUndefinedKeys, resolveResponse, } from "../utils.js";
const attachmentUrlType = {
image: "photo_original/send?",
gif: "gif?",
video: "asyncfile/msg?",
others: "asyncfile/msg?",
};
function prepareQMSGAttach(quote) {
const quoteData = quote;
if (typeof quoteData.content == "string")
return quoteData.propertyExt;
if (quoteData.msgType == "chat.todo")
return {
properties: {
color: 0,
size: 0,
type: 0,
subType: 0,
ext: '{"shouldParseLinkOrContact":0}',
},
};
return Object.assign(Object.assign({}, quoteData.content), { thumbUrl: quoteData.content.thumb, oriUrl: quoteData.content.href, normalUrl: quoteData.content.href });
}
function prepareQMSG(quote) {
const quoteData = quote;
if (quoteData.msgType == "chat.todo" && typeof quoteData.content == "object" && typeof quoteData.content.params == "string") {
return JSON.parse(quoteData.content.params).item.content;
}
return "";
}
export var TextStyle;
(function (TextStyle) {
TextStyle["Bold"] = "b";
TextStyle["Italic"] = "i";
TextStyle["Underline"] = "u";
TextStyle["StrikeThrough"] = "s";
TextStyle["Red"] = "c_db342e";
TextStyle["Orange"] = "c_f27806";
TextStyle["Yellow"] = "c_f7b503";
TextStyle["Green"] = "c_15a85f";
TextStyle["Small"] = "f_13";
TextStyle["Big"] = "f_18";
TextStyle["UnorderedList"] = "lst_1";
TextStyle["OrderedList"] = "lst_2";
TextStyle["Indent"] = "ind_$";
})(TextStyle || (TextStyle = {}));
export var Urgency;
(function (Urgency) {
Urgency[Urgency["Default"] = 0] = "Default";
Urgency[Urgency["Important"] = 1] = "Important";
Urgency[Urgency["Urgent"] = 2] = "Urgent";
})(Urgency || (Urgency = {}));
export const sendMessageFactory = apiFactory()((api, ctx, utils) => {
const serviceURLs = {
message: {
[ThreadType.User]: utils.makeURL(`${api.zpwServiceMap.chat[0]}/api/message`, {
nretry: 0,
}),
[ThreadType.Group]: utils.makeURL(`${api.zpwServiceMap.group[0]}/api/group`, {
nretry: 0,
}),
},
attachment: {
[ThreadType.User]: `${api.zpwServiceMap.file[0]}/api/message/`,
[ThreadType.Group]: `${api.zpwServiceMap.file[0]}/api/group/`,
},
};
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 getGroupLayoutId() {
return Date.now();
}
async function send(data) {
if (!Array.isArray(data))
data = [data];
const requests = [];
for (const each of data) {
requests.push((async () => {
const response = await utils.request(each.url, {
method: "POST",
body: each.body,
headers: each.headers,
});
return await resolveResponse(ctx, response);
})());
}
return await Promise.all(requests);
}
async function upthumb(source, url) {
const formData = new FormData();
const buffer = typeof source == "string" ? await fs.readFile(source) : source.data;
formData.append("fileContent", buffer, {
filename: "blob",
contentType: "image/png",
});
const params = {
clientId: Date.now(),
imei: ctx.imei,
};
const encryptedParams = utils.encodeAES(JSON.stringify(params));
if (!encryptedParams)
throw new ZaloApiError("Failed to encrypt message");
const response = await utils.request(utils.makeURL(url + "upthumb?", {
params: encryptedParams,
}), {
method: "POST",
headers: formData.getHeaders(),
body: formData.getBuffer(),
});
return await resolveResponse(ctx, response);
}
function handleMentions(type, msg, mentions) {
let totalMentionLen = 0;
const mentionsFinal = Array.isArray(mentions) && type == ThreadType.Group
? mentions
.filter((m) => m.pos >= 0 && m.uid && m.len > 0)
.map((m) => {
totalMentionLen += m.len;
return {
pos: m.pos,
uid: m.uid,
len: m.len,
type: m.uid == "-1" ? 1 : 0,
};
})
: [];
if (totalMentionLen > msg.length) {
throw new ZaloApiError("Invalid mentions: total mention characters exceed message length");
}
return {
mentionsFinal,
msgFinal: msg,
};
}
function handleStyles(params, styles) {
if (styles)
Object.assign(params, {
textProperties: JSON.stringify({
styles: styles.map((e) => {
var _a;
const styleFinal = Object.assign(Object.assign({}, e), { indentSize: undefined, st: e.st == TextStyle.Indent
? TextStyle.Indent.replace(/\$/g, `${(_a = e.indentSize) !== null && _a !== void 0 ? _a : 1}0`)
: e.st });
removeUndefinedKeys(styleFinal);
return styleFinal;
}),
ver: 0,
}),
});
}
function handleUrgency(params, urgency) {
if (urgency == Urgency.Important || urgency == Urgency.Urgent) {
Object.assign(params, { metaData: { urgency } });
}
}
async function handleMessage({ msg, styles, urgency, mentions, quote, ttl }, threadId, type) {
if (!msg || msg.length == 0)
throw new ZaloApiError("Missing message content");
const isGroupMessage = type == ThreadType.Group;
const { mentionsFinal, msgFinal } = handleMentions(type, msg, mentions);
msg = msgFinal;
if (quote) {
if (typeof quote.content != "string" && quote.msgType == "webchat") {
throw new ZaloApiError("This kind of `webchat` quote type is not available");
}
if (quote.msgType == "group.poll") {
throw new ZaloApiError("The `group.poll` quote type is not available");
}
}
const isMentionsValid = mentionsFinal.length > 0 && isGroupMessage;
const params = quote
? {
toid: isGroupMessage ? undefined : threadId,
grid: isGroupMessage ? threadId : undefined,
message: msg,
clientId: Date.now(),
mentionInfo: isMentionsValid ? JSON.stringify(mentionsFinal) : undefined,
qmsgOwner: quote.uidFrom,
qmsgId: quote.msgId,
qmsgCliId: quote.cliMsgId,
qmsgType: getClientMessageType(quote.msgType),
qmsgTs: quote.ts,
qmsg: typeof quote.content == "string" ? quote.content : prepareQMSG(quote),
imei: isGroupMessage ? undefined : ctx.imei,
visibility: isGroupMessage ? 0 : undefined,
qmsgAttach: isGroupMessage ? JSON.stringify(prepareQMSGAttach(quote)) : undefined,
qmsgTTL: quote.ttl,
ttl: ttl !== null && ttl !== void 0 ? ttl : 0,
}
: {
message: msg,
clientId: Date.now(),
mentionInfo: isMentionsValid ? JSON.stringify(mentionsFinal) : undefined,
imei: isGroupMessage ? undefined : ctx.imei,
ttl: ttl !== null && ttl !== void 0 ? ttl : 0,
visibility: isGroupMessage ? 0 : undefined,
toid: isGroupMessage ? undefined : threadId,
grid: isGroupMessage ? threadId : undefined,
};
handleStyles(params, styles);
handleUrgency(params, urgency);
removeUndefinedKeys(params);
const encryptedParams = utils.encodeAES(JSON.stringify(params));
if (!encryptedParams)
throw new ZaloApiError("Failed to encrypt message");
const finalServiceUrl = new URL(serviceURLs.message[type]);
if (quote) {
finalServiceUrl.pathname = finalServiceUrl.pathname + "/quote";
}
else {
finalServiceUrl.pathname =
finalServiceUrl.pathname +
"/" +
(isGroupMessage ? (params.mentionInfo ? "mention" : "sendmsg") : "sms");
}
return {
url: finalServiceUrl.toString(),
body: new URLSearchParams({ params: encryptedParams }),
};
}
async function handleAttachment({ msg, attachments, mentions, quote, ttl, urgency }, threadId, type) {
if (!attachments)
throw new ZaloApiError("Missing attachments");
if (!Array.isArray(attachments))
attachments = [attachments];
if (attachments.length == 0)
throw new ZaloApiError("Missing attachments");
const firstSource = attachments[0];
const isFilePath = typeof firstSource == "string";
const firstExtFile = getFileExtension(isFilePath ? firstSource : firstSource.filename);
const isSingleFile = attachments.length == 1;
const isGroupMessage = type == ThreadType.Group;
const canBeDesc = isSingleFile && ["jpg", "jpeg", "png", "webp"].includes(firstExtFile);
const gifFiles = attachments.filter((e) => getFileExtension(typeof e == "string" ? e : e.filename) == "gif");
attachments = attachments.filter((e) => getFileExtension(typeof e == "string" ? e : e.filename) != "gif");
const uploadAttachment = attachments.length == 0 ? [] : await api.uploadAttachment(attachments, threadId, type);
const attachmentsData = [];
let indexInGroupLayout = 0;
const groupLayoutId = getGroupLayoutId().toString();
const { mentionsFinal, msgFinal } = handleMentions(type, msg, mentions);
msg = msgFinal;
const isMentionsValid = mentionsFinal.length > 0 && isGroupMessage && attachments.length == 1;
const isMultiFile = attachments.length > 1;
let clientId = Date.now();
for (const attachment of uploadAttachment) {
let data;
switch (attachment.fileType) {
case "image": {
data = {
fileType: attachment.fileType,
params: {
photoId: attachment.photoId,
clientId: (clientId++).toString(),
desc: msg,
width: attachment.width,
height: attachment.height,
toid: isGroupMessage ? undefined : String(threadId),
grid: isGroupMessage ? String(threadId) : undefined,
rawUrl: attachment.normalUrl,
hdUrl: attachment.hdUrl,
thumbUrl: attachment.thumbUrl,
oriUrl: isGroupMessage ? attachment.normalUrl : undefined,
normalUrl: isGroupMessage ? undefined : attachment.normalUrl,
hdSize: String(attachment.totalSize),
zsource: -1,
ttl: ttl !== null && ttl !== void 0 ? ttl : 0,
jcp: '{"convertible":"jxl"}',
groupLayoutId: isMultiFile ? groupLayoutId : undefined,
isGroupLayout: isMultiFile ? 1 : undefined,
idInGroup: isMultiFile ? indexInGroupLayout++ : undefined,
totalItemInGroup: isMultiFile ? uploadAttachment.length : undefined,
extMsgProp: isMultiFile ? `{"groupMediaMsg":{"groupLayoutId":"${groupLayoutId}"}}` : undefined,
mentionInfo: isMentionsValid && canBeDesc && !quote ? JSON.stringify(mentionsFinal) : undefined,
},
body: new URLSearchParams(),
};
break;
}
case "video": {
data = {
fileType: attachment.fileType,
params: {
fileId: attachment.fileId,
checksum: attachment.checksum,
checksumSha: "",
extention: getFileExtension(attachment.fileName),
totalSize: attachment.totalSize,
fileName: attachment.fileName,
clientId: attachment.clientFileId,
fType: 1,
fileCount: 0,
fdata: "{}",
toid: isGroupMessage ? undefined : String(threadId),
grid: isGroupMessage ? String(threadId) : undefined,
fileUrl: attachment.fileUrl,
zsource: -1,
ttl: ttl !== null && ttl !== void 0 ? ttl : 0,
},
body: new URLSearchParams(),
};
break;
}
case "others": {
data = {
fileType: attachment.fileType,
params: {
fileId: attachment.fileId,
checksum: attachment.checksum,
checksumSha: "",
extention: getFileExtension(attachment.fileName),
totalSize: attachment.totalSize,
fileName: attachment.fileName,
clientId: attachment.clientFileId,
fType: 1,
fileCount: 0,
fdata: "{}",
toid: isGroupMessage ? undefined : String(threadId),
grid: isGroupMessage ? String(threadId) : undefined,
fileUrl: attachment.fileUrl,
zsource: -1,
ttl: ttl !== null && ttl !== void 0 ? ttl : 0,
},
body: new URLSearchParams(),
};
break;
}
}
handleUrgency(data.params, urgency);
removeUndefinedKeys(data.params);
const encryptedParams = utils.encodeAES(JSON.stringify(data.params));
if (!encryptedParams)
throw new ZaloApiError("Failed to encrypt message");
data.body.append("params", encryptedParams);
attachmentsData.push(data);
}
for (const gif of gifFiles) {
const isFilePath = typeof gif == "string";
const gifData = isFilePath ? await getGifMetaData(ctx, gif) : Object.assign(Object.assign({}, gif.metadata), { fileName: gif.filename });
if (isExceedMaxFileSize(gifData.totalSize))
throw new ZaloApiError(`File ${isFilePath ? getFileName(gif) : gif.filename} size exceed maximum size of ${sharefile.max_size_share_file_v3}MB`);
const _upthumb = await upthumb(gif, serviceURLs.attachment[ThreadType.User]);
const formData = new FormData();
formData.append("chunkContent", isFilePath ? await fs.readFile(gif) : gif.data, {
filename: isFilePath ? getFileName(gif) : gif.filename,
contentType: "application/octet-stream",
});
const params = {
clientId: Date.now().toString(),
fileName: gifData.fileName,
totalSize: gifData.totalSize,
width: gifData.width,
height: gifData.height,
msg: msg,
type: 1,
ttl: ttl !== null && ttl !== void 0 ? ttl : 0,
visibility: isGroupMessage ? 0 : undefined,
toid: isGroupMessage ? undefined : threadId,
grid: isGroupMessage ? threadId : undefined,
thumb: _upthumb.url,
checksum: (await getMd5LargeFileObject(gif, gifData.totalSize)).data,
totalChunk: 1,
chunkId: 1,
};
handleUrgency(params, urgency);
removeUndefinedKeys(params);
const encryptedParams = utils.encodeAES(JSON.stringify(params));
if (!encryptedParams)
throw new ZaloApiError("Failed to encrypt message");
attachmentsData.push({
query: {
params: encryptedParams,
type: "1",
},
body: formData.getBuffer(),
headers: formData.getHeaders(),
fileType: "gif",
});
}
const responses = [];
for (const data of attachmentsData) {
responses.push({
url: utils.makeURL(serviceURLs.attachment[type] + attachmentUrlType[data.fileType], Object.assign({
nretry: "0",
}, data.query || {})),
body: data.body,
headers: data.fileType == "gif" ? data.headers : {},
});
}
return responses;
}
/**
* Send a message to a thread
*
* @param message Message content
* @param threadId group or user id
* @param type Message type (User or Group)
* @param quote Message or GroupMessage instance (optional), used for quoting
*
* @throws {ZaloApiError | ZaloApiMissingImageMetadataGetter}
*/
return async function sendMessage(message, threadId, type = ThreadType.User) {
if (!message)
throw new ZaloApiError("Missing message content");
if (!threadId)
throw new ZaloApiError("Missing threadId");
if (typeof message == "string")
message = { msg: message };
let { msg, attachments, mentions } = message;
const { quote, ttl, styles, urgency } = message;
if (attachments && !Array.isArray(attachments)) {
attachments = [attachments];
}
if (!msg && (!attachments || (attachments && attachments.length == 0)))
throw new ZaloApiError("Missing message content");
if (attachments && isExceedMaxFile(attachments.length))
throw new ZaloApiError("Exceed maximum file of " + sharefile.max_file);
const responses = {
message: null,
attachment: [],
};
if (attachments && attachments.length > 0) {
const firstExtFile = getFileExtension(typeof attachments[0] == "string" ? attachments[0] : attachments[0].filename);
const isSingleFile = attachments.length == 1;
const canBeDesc = isSingleFile && ["jpg", "jpeg", "png", "webp"].includes(firstExtFile);
if ((!canBeDesc && msg.length > 0) || (msg.length > 0 && quote)) {
// send message and attachment separately
await handleMessage(message, threadId, type).then(async (data) => {
responses.message = (await send(data))[0];
});
msg = "";
mentions = undefined;
}
const handledData = await handleAttachment({ msg, mentions, attachments, quote, ttl, styles, urgency }, threadId, type);
responses.attachment = await send(handledData);
msg = "";
}
if (msg.length > 0) {
const handledData = await handleMessage(message, threadId, type);
responses.message = (await send(handledData))[0];
}
return responses;
};
});