UNPKG

wachan

Version:
585 lines (567 loc) 26.2 kB
const fs = require("fs") const { fileTypeFromBuffer } = require("file-type") const mime = require("mime-types") const { createHash } = require("crypto") const { extractMessageContent, downloadMediaMessage, decryptPollVote } = require("baileys") const vCard = require("vcard-parser") const { USER_JID, USER_LID, GROUP_JID } = require("../core/constants") const { store: { messageStore, groupStore, userStore } } = require("../core/store") const { getSocket, getBotInfo } = require("../core/socket") const { queue } = require("../utils/queue") class Message { constructor (m, context) { const botInfo = getBotInfo() this.id = m.key.id this.room = m.key.remoteJid this.isPrivate = this.room.endsWith(USER_JID) || this.room.endsWith(USER_LID) const senderIds = [ m.key.participant, m.key.participantAlt, m.key.remoteJid, m.key.remoteJidAlt ] const senderId = m.key.fromMe ? botInfo.id : senderIds.find(i=>i?.endsWith("@s.whatsapp.net")) const senderLid = m.key.fromMe ? botInfo.lid : senderIds.find(i=>i?.endsWith("@lid")) this.sender = { id: senderId, lid: senderLid, isMe: m.key.fromMe, name: m.key.fromMe ? (botInfo.name || context?.defaultBotName || "Wachan") : m.pushName, isAdmin: context?.senderIsAdmin || false } this.timestamp = m.messageTimestamp const realMessage = extractMessageContent( // m.message?.protocolMessage?.editedMessage || m.message?.groupMentionedMessage?.message || m.message?.viewOnceMessage?.message || m.message?.viewOnceMessageV2?.message || m.message ) let contextInfo for (const key in realMessage) { const content = realMessage[key] if (!content) continue switch (key) { case "conversation": case "extendedTextMessage": { this.type = "text" break } case "imageMessage": case "videoMessage": case "audioMessage": case "stickerMessage": case "documentMessage": { this.type = content.gifPlayback ? "gif" : content.ptt ? "vn" : key.slice(0,-7) this.isMedia = true this.downloadMedia = async function (saveTo) { const socket = getSocket() const buffer = await downloadMediaMessage(m, "buffer", {}, {reuploadRequest: socket.updateMediaMessage}) if (saveTo) await fs.promises.writeFile(saveTo, buffer) return buffer } this.streamMedia = function () { const socket = getSocket() const stream = downloadMediaMessage(m, "stream", {}, {reuploadRequest: socket.updateMediaMessage}) return stream } this.streamMedia = function () { const socket = getSocket() const stream = downloadMediaMessage(m, "stream", {}, {reuploadRequest: socket.updateMediaMessage}) return stream } if (content.fileName) this.fileName = content.fileName if (content.seconds) this.duration = content.seconds if (content.height) this.height = content.height if (content.width) this.width = content.width this.mimeType = content.mimetype this.fileSize = content.fileLength.toNumber() break } case "reactionMessage": { this.reaction = { emoji: content.text, key: content.key } break } case "interactiveMessage": { this.type = "buttons" this.text = content.body.text this.title = content.header.title this.footer = content.footer.text this.buttons = content.nativeFlowMessage.buttons .map(b => { const params = JSON.parse(b.buttonParamsJson) const button = {} switch (b.name) { case "quick_reply": button.type = "reply" button.text = params.display_text button.id = params.id break case "single_select": button.type = "list" button.title = params.title button.sections = params.sections break case "cta_url": button.type = "url" button.text = params.display_text button.url = params.url button.merchantUrl = params.merchant_url break case "cta_copy": button.type = "copy" button.text = params.display_text button.code = params.copy_code break case "cta_call": button.type = "call" button.text = params.display_text button.phoneNumber = params.phone_number break } return button }) break } case "templateButtonReplyMessage": { this.type = "buttonReply" this.buttonReply = { id: content.selectedId, text: content.selectedDisplayText, pos: content.selectedIndex } break } case "contactMessage": case "contactsArrayMessage": { const parseVcard = (vc) => { const data = vCard.parse(vc) const name = data.fn[0].value const number = data.tel[0].value return {name, number} } this.contacts = [ content.vcard || content.contacts?.map(c => c.vcard) ].flat().map(vc => parseVcard(vc)) break } case "pollCreationMessage": case "pollCreationMessageV2": case "pollCreationMessageV3": case "pollCreationMessageV4": case "pollCreationMessageV5": { this.type = "poll" const pollMessage = content this.poll = { title: pollMessage.name, options: pollMessage.options?.map(o => o.optionName) || [], multiple: pollMessage.selectableOptionsCount != 1, votes: {} } for (const option of this.poll.options) this.poll.votes[option] = [] break } case "pollUpdateMessage": { const pollUpdate = content const pollKey = pollUpdate.pollCreationMessageKey const pollMessage = messageStore.get(pollKey) if (!pollMessage) { console.log("Message: Poll message not found for this vote") this.vote = null break } const pollContext = { pollCreatorJid: pollKey.participant, pollMsgId: pollKey.id, pollEncKey: pollMessage.message.messageContextInfo.messageSecret, voterJid: m.key.participant } const voteMessage = decryptPollVote(pollUpdate.vote, pollContext) const pollCreationMessage = pollMessage.message.pollCreationMessage || pollMessage.message.pollCreationMessageV2 || pollMessage.message.pollCreationMessageV3 || pollMessage.message.pollCreationMessageV4 || pollMessage.message.pollCreationMessageV5 const options = pollCreationMessage.options.map(o=>o.optionName) this.vote = { pollId: pollKey.id, list: voteMessage .selectedOptions .map(o=>options?.find(opt=>hashPollOption(opt) == o.toString('base64'))) } break } case "protocolMessage": { if (content.type === 14) { // Edited message this.type = "edit" const [ editType, editContent ] = Object.entries(content.editedMessage)[0] this.edited = { type: editType === "extendedTextMessage" ? "text" : editType.replace(/Message$/, ""), text: editContent.text || editContent.caption || "", } } break } } if (key.endsWith("Message")) { const cleanName = key.replace(/Message$/, "") if (this.type === undefined) this.type = cleanName else { this.extraTypes ??= [] this.extraTypes.push(cleanName) } } contextInfo ??= content?.contextInfo this.isMedia ??= false const t = content?.text || content?.caption || content if (typeof t === "string" && this.type !== "reaction" && this.type !== "edit") this.text = t } this.receivedOnline = context?.receivedOnline ?? true this.getQuoted = async () => { if (!contextInfo?.quotedMessage) return null const user = userStore.find(contextInfo?.participant) const key = { id: contextInfo.stanzaId, remoteJid: contextInfo.remoteJid || m.key.remoteJid, fromMe: contextInfo.participant === botInfo.id || contextInfo.participant === botInfo.lid, participant: contextInfo.participant, participantPn: user?.id, participantLid: user?.lid, } const fakeQuotedMessage = { key, message: contextInfo?.quotedMessage, messageTimestamp: 0, broadcast: false, pushName: user?.pushName } const quotedContext = { receivedOnline: false, senderIsAdmin: await groupStore.isUserAdmin(key.participant, key.remoteJid), defaultBotName: context?.defaultBotName } return new Message(messageStore.get(key) || fakeQuotedMessage, quotedContext) } this.toBaileys = () => m } async edit (newText) { return await editMessage(this, newText) } async reply (options) { const opt = typeof options === "string" ? {text: options} : options return await sendMessage(this.room, {quoted: this, ...opt}) } async react (emoji) { return await sendReaction(this, emoji) } async delete () { return await deleteMessage(this) } } async function sendMessage(room, options) { const socket = getSocket(); if (!socket) return null if (!room) throw new Error("sendMessage: No room id specified") if (!room.endsWith(USER_JID) && !room.endsWith(USER_LID) && !room.endsWith(GROUP_JID)) throw new Error(`sendMessage: Invalid room id ${room}, must be a user or group chat`) const message = {} let media = null const mediaTypes = [ ["image"], ["video"], ["video", {gifPlayback:true}, "gif"], ["audio", {mimetype:"audio/mp4"}], ["audio", {mimetype:"audio/mp4", ptt:true}, "vn"], ["sticker"], ["document"] ] if (typeof options === "object") { for (const key in options) { const value = options[key] for (const type of mediaTypes) { const [mainType, data, subType] = type if (key === (subType||mainType) && message[mainType] === undefined) { media = { ...data } if (Buffer.isBuffer(value)) media[mainType] = value else if (typeof value === "string") media[mainType] = { url: value } else if (isReadableStream(value)) media[mainType] = { stream: value } Object.assign(message, media) break } } if (key === "buttons") { return await sendButtons(room, options) } if (key === "contacts") { return await sendContacts(room, options) } if (key === "poll") { return await sendPoll(room, options) } } } const text = typeof options === "string" ? options : options?.text || "" if (!text && !media) return null message[media ? "caption" : "text"] = text if (Buffer.isBuffer(media?.document)) { let ft = null const fileType = async () => ft ??= await fileTypeFromBuffer(media.document) message.mimetype = options?.mimetype || (await fileType())?.mime message.fileName = options?.fileName || ("media" + ((await fileType())?.ext ? `.${(await fileType())?.ext}` : "")) } if (media?.document?.url) { const mt = mime.lookup(media.document.url) const mtExt = mime.extension(mt) message.mimetype = options?.mimetype || mt message.fileName = options?.fileName || "media" + (mtExt ? `.${mtExt}` : "") } message.mentions = getMentionsFromText(text) const quoted = options?.quoted?.toBaileys() || options?.quoted const ephemeralExpiration = await getGroupData(room, "ephemeralDuration") const sendingOptions = { quoted, ephemeralExpiration } const sent = new Message( await queue("socket", null, () => socket.sendMessage(room, message, sendingOptions)), { senderIsAdmin: await groupStore.isUserAdmin(getBotInfo().id, room)} ) messageStore.add(sent.toBaileys()) return sent } async function editMessage(message, text) { const socket = getSocket() if (!socket) return null if (!message) throw new Error("editMessage: No target message specified") if (typeof text !== "string" || !text) throw new Error("editMessage: newText must be a non-empty string") const room = message.room const edit = new Message( await queue("socket", null, () => socket.sendMessage(room, { edit: message.toBaileys().key, text })) ) messageStore.add(edit.toBaileys()) return edit } async function sendReaction(message, emoji) { const socket = getSocket(); if (!socket) return null if (!message) throw new Error("sendReaction: No target message specified") if (emoji === undefined || emoji === null) throw new Error("sendReaction: No emoji specified") const room = message.room const sent = new Message( await queue("socket", null, () => socket.sendMessage(room, { react: { key: message.toBaileys().key, text: emoji } }), { senderIsAdmin: await groupStore.isUserAdmin(getBotInfo().id, room)}) ) messageStore.add(sent.toBaileys()) return sent } async function sendContacts(room, options) { const socket = getSocket(); if (!socket) return null if (!room) throw new Error("sendContacts: No room id specified") if (!room.endsWith(USER_JID) && !room.endsWith(USER_LID) && !room.endsWith(GROUP_JID)) throw new Error(`sendContacts: Invalid room id ${room}, must be a user or group chat`) if (!options?.contacts?.[0]) throw new Error("sendContacts: No contacts specified through options.contacts[]") const vcards = options.contacts.map((c) => { return { vcard: `BEGIN:VCARD\nVERSION:3.0\nFN:${ c.name }\nTEL;type=CELL;type=VOICE;waid=${c.number.replace(/\D/g, "")}:${ c.number }\nEND:VCARD`, }; }) const quoted = options?.quoted?.toBaileys() || options?.quoted const ephemeralExpiration = await getGroupData(room, "ephemeralDuration") const sendingOptions = { quoted, ephemeralExpiration } const sent = new Message( await queue("socket", null, () => socket.sendMessage( room, { contacts: { contacts: vcards } }, sendingOptions ), { senderIsAdmin: await groupStore.isUserAdmin(getBotInfo().id, room)}) ) messageStore.add(sent.toBaileys()) return sent } async function sendPoll(room, options) { const socket = getSocket() if (!socket) return null if (!room) throw new Error("sendPoll: No room id specified") if (!room.endsWith(GROUP_JID)) throw new Error(`sendPoll: Invalid room id ${room}, must be a group chat`) if (!options?.poll) throw new Error("sendPoll: No poll specified through options.poll") if (!options.poll.title) throw new Error("sendPoll: No poll title specified through options.poll.title") if (!options.poll.options) throw new Error("sendPoll: No poll options specified through options.poll.options") if (options.poll.options.length < 2) throw new Error("sendPoll: At least 2 poll options are required in options.poll.options") const selectableCount = options.poll.multiple ? 0 : 1 const quoted = options?.quoted?.toBaileys() || options?.quoted const ephemeralExpiration = await getGroupData(room, "ephemeralDuration") const sendingOptions = { quoted, ephemeralExpiration } const sent = new Message( await queue("socket", null, () => socket.sendMessage( room, { poll: { name: options.poll.title, values: options.poll.options, selectableCount } }, sendingOptions ), { senderIsAdmin: await groupStore.isUserAdmin(getBotInfo().id, room)}) ) messageStore.add(sent.toBaileys()) return sent } async function deleteMessage(message) { const socket = getSocket() if (!socket) return null if (!message) throw new Error("deleteMessage: No target message specified") const room = message.room return new Message( await queue("socket", null, () => socket.sendMessage(room, { delete: message.toBaileys().key }), { senderIsAdmin: await groupStore.isUserAdmin(getBotInfo().id, room)}) ) } async function sendButtons(room, options) { const socket = getSocket() if (!socket) return null if (!room) throw new Error("sendButtons: No room id specified") if (!room.endsWith(USER_JID) && !room.endsWith(USER_LID) && !room.endsWith(GROUP_JID)) throw new Error(`sendButtons: Invalid room id ${room}, must be a user or group chat`) if (!options?.text) throw new Error("sendButtons: No body text specified through options.text") if (!options?.buttons?.[0]) throw new Error("sendButtons: No buttons specified through options.buttons[]") const interactiveButtons = [] for (const btn of options.buttons) { // type: reply, list, url, copy, call if (!btn?.type) throw new Error("sendButtons: Each button must have a type property") switch (btn.type) { case "reply": { if (!btn.id) throw new Error("sendButtons: Reply button must have an id property") if (!btn.text) throw new Error("sendButtons: Reply button must have a text property") interactiveButtons.push({ name: "quick_reply", buttonParamsJson: JSON.stringify({ display_text: btn.text, id: btn.id }) }) break } case "list": { if (!btn.title) throw new Error("sendButtons: List button must have a title property") if (!btn.sections?.[0]) throw new Error("sendButtons: List button must have a sections[] property with at least one section") for (const section of btn.sections) { // optional property: title if (!section.rows?.[0]) throw new Error("sendButtons: List button's section must have at least one row in section.rows[]") for (const row of section.rows) { if (!row.id) throw new Error("sendButtons: List button's section row must have an id property") if (!row.title) throw new Error("sendButtons: List button's section row must have a title property") // there are optional properties: description, header } } interactiveButtons.push({ name: "single_select", buttonParamsJson: JSON.stringify({ title: btn.title, sections: btn.sections }) }) break } case "url": { if (!btn.text) throw new Error("sendButtons: URL button must have a text property") if (!btn.url) throw new Error("sendButtons: URL button must have a url property") interactiveButtons.push({ name: "cta_url", buttonParamsJson: JSON.stringify({ display_text: btn.text, url: btn.url, merchant_url: btn.merchantUrl }) }) break } case "copy": { if (!btn.text) throw new Error("sendButtons: Copy button must have a text property") if (!btn.code) throw new Error("sendButtons: Copy button must have a code property") interactiveButtons.push({ name: "cta_copy", buttonParamsJson: JSON.stringify({ display_text: btn.text, copy_code: btn.code }) }) break } case "call": { if (!btn.text) throw new Error("sendButtons: Call button must have a text property") if (!btn.phoneNumber) throw new Error("sendButtons: Call button must have a phoneNumber property") interactiveButtons.push({ name: "cta_call", buttonParamsJson: JSON.stringify({ display_text: btn.text, phone_number: btn.phoneNumber }) }) break } default: { throw new Error(`sendButtons: Unknown button type ${btn.type}`) } } } const content = { text: options.text, footer: options.footer, title: options.title, interactiveButtons, contextInfo: { quotedMessage: options?.quoted?.toBaileys() || options?.quoted, expiration: await getGroupData(room, "ephemeralDuration") } } const { sendInteractiveMessage } = require("baileys_helper") const sent = new Message( await queue("socket", null, () => sendInteractiveMessage(socket, room, content)), { senderIsAdmin: await groupStore.isUserAdmin(getBotInfo().id, room) } ) messageStore.add(sent.toBaileys()) return sent } async function getGroupData(jid, key) { const data = await groupStore.get(jid) return data?.[key] } function getMentionsFromText(text) { if (!text) return [] return (text.match(/@\d+/g) || []).map((m) => m.slice(1) + USER_LID); } function hashPollOption (name) { return createHash('sha256').update(Buffer.from(name)).digest().toString('base64') } function isReadableStream(obj) { return obj !== null && typeof obj === 'object' && typeof obj.pipe === 'function' && typeof obj.on === 'function' && obj.readable !== false } module.exports = { Message, sendMessage }