diff --git a/README.md b/README.md index 1a22cf2..4174741 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Take a look at [example.js](https://github.com/pedroslopez/whatsapp-web.js/blob/ | Receive messages | ✅ | | Send media (images/audio/documents) | ✅ | | Send media (video) | ✅ [(requires google chrome)](https://github.com/pedroslopez/whatsapp-web.js/issues/78#issuecomment-592723583) | -| Send stickers | _pending_ | +| Send stickers | ✅ | | Receive media (images/audio/video/documents) | ✅ | | Send contact cards | ✅ | | Send location | ✅ | diff --git a/index.d.ts b/index.d.ts index 678357a..0f8dcc3 100644 --- a/index.d.ts +++ b/index.d.ts @@ -277,6 +277,9 @@ declare namespace WAWebJS { /** User agent to use in puppeteer. * @default 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.109 Safari/537.36' */ userAgent?: string + /** Ffmpeg path to use when formating videos to webp while sending stickers + * @default 'ffmpeg' */ + ffmpegPath?: string } /** Represents a Whatsapp client session */ @@ -553,6 +556,8 @@ declare namespace WAWebJS { linkPreview?: boolean /** Send audio as voice message */ sendAudioAsVoice?: boolean + /** Send media as sticker */ + sendMediaAsSticker?: boolean /** Send media as document */ sendMediaAsDocument?: boolean /** Automatically parse vCards and send them as contacts */ diff --git a/package.json b/package.json index c79d8ed..0c73130 100644 --- a/package.json +++ b/package.json @@ -29,9 +29,11 @@ "homepage": "https://github.com/pedroslopez/whatsapp-web.js#readme", "dependencies": { "@pedroslopez/moduleraid": "^4.1.0", + "fluent-ffmpeg": "^2.1.2", "jsqr": "^1.3.1", "mime": "^2.4.5", - "puppeteer": "^5.2.1" + "puppeteer": "^5.2.1", + "sharp": "^0.26.3" }, "devDependencies": { "eslint": "^6.8.0", diff --git a/src/Client.js b/src/Client.js index 3db3fdb..37c3962 100644 --- a/src/Client.js +++ b/src/Client.js @@ -29,6 +29,7 @@ const { ClientInfo, Message, MessageMedia, Contact, Location, GroupNotification * @param {number} options.takeoverOnConflict - If another whatsapp web session is detected (another browser), take over the session in the current browser * @param {number} options.takeoverTimeoutMs - How much time to wait before taking over the session * @param {string} options.userAgent - User agent to use in puppeteer + * @param {string} options.ffmpegPath - Ffmpeg path to use when formating videos to webp while sending stickers * * @fires Client#qr * @fires Client#authenticated @@ -55,6 +56,8 @@ class Client extends EventEmitter { this.pupBrowser = null; this.pupPage = null; + + Util.setFfmpegPath(this.options.ffmpegPath); } /** @@ -420,6 +423,7 @@ class Client extends EventEmitter { * @typedef {Object} MessageSendOptions * @property {boolean} [linkPreview=true] - Show links preview * @property {boolean} [sendAudioAsVoice=false] - Send audio as voice message + * @property {boolean} [sendMediaAsSticker=false] - Send media as a sticker * @property {boolean} [sendMediaAsDocument=false] - Send media as a document * @property {boolean} [parseVCards=true] - Automatically parse vCards and send them as contacts * @property {string} [caption] - Image or video caption @@ -441,6 +445,7 @@ class Client extends EventEmitter { let internalOptions = { linkPreview: options.linkPreview === false ? undefined : true, sendAudioAsVoice: options.sendAudioAsVoice, + sendMediaAsSticker: options.sendMediaAsSticker, sendMediaAsDocument: options.sendMediaAsDocument, caption: options.caption, quotedMessageId: options.quotedMessageId, @@ -468,6 +473,10 @@ class Client extends EventEmitter { content = ''; } + if (internalOptions.sendMediaAsSticker && internalOptions.attachment) { + internalOptions.attachment = await Util.formatToWebpSticker(internalOptions.attachment); + } + const newMessage = await this.pupPage.evaluate(async (chatId, message, options, sendSeen) => { const chatWid = window.Store.WidFactory.createWid(chatId); const chat = await window.Store.Chat.find(chatWid); diff --git a/src/util/Constants.js b/src/util/Constants.js index 2534c58..a98266f 100644 --- a/src/util/Constants.js +++ b/src/util/Constants.js @@ -13,7 +13,8 @@ exports.DefaultOptions = { authTimeoutMs: 45000, takeoverOnConflict: false, takeoverTimeoutMs: 0, - userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.109 Safari/537.36' + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.109 Safari/537.36', + ffmpegPath: 'ffmpeg' }; /** diff --git a/src/util/Injected.js b/src/util/Injected.js index 654b361..d019563 100644 --- a/src/util/Injected.js +++ b/src/util/Injected.js @@ -29,6 +29,8 @@ exports.ExposeStore = (moduleRaidStr) => { window.Store.WidFactory = window.mR.findModule('createWid')[0]; window.Store.BlockContact = window.mR.findModule('blockContact')[0]; window.Store.GroupMetadata = window.mR.findModule((module) => module.default && module.default.handlePendingInvite)[0].default; + window.Store.Sticker = window.mR.findModule('Sticker')[0].default.Sticker; + window.Store.UploadUtils = window.mR.findModule((module) => (module.default && module.default.encryptAndUpload) ? module.default : null)[0].default; window.Store.Label = window.mR.findModule('LabelCollection')[0].default; }; @@ -56,12 +58,17 @@ exports.LoadUtils = () => { window.WWebJS.sendMessage = async (chat, content, options = {}) => { let attOptions = {}; if (options.attachment) { - attOptions = await window.WWebJS.processMediaData(options.attachment, { - forceVoice: options.sendAudioAsVoice, - forceDocument: options.sendMediaAsDocument - }); - content = attOptions.preview; + attOptions = options.sendMediaAsSticker + ? await window.WWebJS.processStickerData(options.attachment) + : await window.WWebJS.processMediaData(options.attachment, { + forceVoice: options.sendAudioAsVoice, + forceDocument: options.sendMediaAsDocument + }); + + content = options.sendMediaAsSticker ? undefined : attOptions.preview; + delete options.attachment; + delete options.sendMediaAsSticker; } let quotedMsgOptions = {}; @@ -160,6 +167,33 @@ exports.LoadUtils = () => { return window.Store.Msg.get(newMsgId._serialized); }; + window.WWebJS.processStickerData = async (mediaInfo) => { + if (mediaInfo.mimetype !== 'image/webp') throw new Error('Invalid media type'); + + const file = window.WWebJS.mediaInfoToFile(mediaInfo); + let filehash = await window.WWebJS.getFileHash(file); + let mediaKey = await window.WWebJS.generateHash(32); + + const controller = new AbortController(); + const uploadedInfo = await window.Store.UploadUtils.encryptAndUpload({ + blob: file, + type: 'sticker', + signal: controller.signal, + mediaKey + }); + + const stickerInfo = { + ...uploadedInfo, + clientUrl: uploadedInfo.url, + uploadhash: uploadedInfo.encFilehash, + size: file.size, + type: 'sticker', + filehash + }; + + return stickerInfo; + }; + window.WWebJS.processMediaData = async (mediaInfo, { forceVoice, forceDocument }) => { const file = window.WWebJS.mediaInfoToFile(mediaInfo); const mData = await window.Store.OpaqueData.createFromData(file, file.type); @@ -338,6 +372,22 @@ exports.LoadUtils = () => { }); }; + window.WWebJS.getFileHash = async (data) => { + let buffer = await data.arrayBuffer(); + const hashBuffer = await crypto.subtle.digest('SHA-256', buffer); + return btoa(String.fromCharCode(...new Uint8Array(hashBuffer))); + }; + + window.WWebJS.generateHash = async (length) => { + var result = ''; + var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + var charactersLength = characters.length; + for ( var i = 0; i < length; i++ ) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + return result; + }; + window.WWebJS.sendClearChat = async (chatId) => { let chat = window.Store.Chat.get(chatId); if (chat !== undefined) { diff --git a/src/util/Util.js b/src/util/Util.js index bb09215..ec245e1 100644 --- a/src/util/Util.js +++ b/src/util/Util.js @@ -1,5 +1,12 @@ 'use strict'; +const sharp = require('sharp'); +const path = require('path'); +const Crypto = require('crypto'); +const { tmpdir } = require('os'); +const ffmpeg = require('fluent-ffmpeg'); +const fs = require('fs').promises; + const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k); /** @@ -30,6 +37,123 @@ class Util { return given; } + + /** + * Formats a image to webp + * @param {MessageMedia} media + * + * @returns {Promise} media in webp format + */ + static async formatImageToWebpSticker(media) { + if (!media.mimetype.includes('image')) + throw new Error('media is not a image'); + + if (media.mimetype.includes('webp')) { + return media; + } + + const buff = Buffer.from(media.data, 'base64'); + + let sharpImg = sharp(buff); + sharpImg = sharpImg.webp(); + + sharpImg = sharpImg.resize(512, 512, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }); + + let webpBase64 = (await sharpImg.toBuffer()).toString('base64'); + + return { + mimetype: 'image/webp', + data: webpBase64, + filename: media.filename, + }; + } + + /** + * Formats a video to webp + * @param {MessageMedia} media + * + * @returns {Promise} media in webp format + */ + static async formatVideoToWebpSticker(media) { + if (!media.mimetype.includes('video')) + throw new Error('media is not a video'); + + const videoType = media.mimetype.split('/')[1]; + + const tempFile = path.join( + tmpdir(), + `${Crypto.randomBytes(6).readUIntLE(0, 6).toString(36)}.webp` + ); + + const stream = new (require('stream').Readable)(); + const buffer = Buffer.from( + media.data.replace(`data:${media.mimetype};base64,`, ''), + 'base64' + ); + stream.push(buffer); + stream.push(null); + + await new Promise((resolve, reject) => { + ffmpeg(stream) + .inputFormat(videoType) + .on('error', reject) + .on('end', () => resolve(true)) + .addOutputOptions([ + '-vcodec', + 'libwebp', + '-vf', + // eslint-disable-next-line no-useless-escape + 'scale=\'iw*min(300/iw\,300/ih)\':\'ih*min(300/iw\,300/ih)\',format=rgba,pad=300:300:\'(300-iw)/2\':\'(300-ih)/2\':\'#00000000\',setsar=1,fps=10', + '-loop', + '0', + '-ss', + '00:00:00.0', + '-t', + '00:00:05.0', + '-preset', + 'default', + '-an', + '-vsync', + '0', + '-s', + '512:512', + ]) + .toFormat('webp') + .save(tempFile); + }); + + const data = await fs.readFile(tempFile, 'base64'); + await fs.unlink(tempFile); + + return { + mimetype: 'image/webp', + data: data, + filename: media.filename, + }; + } + + /** + * Formats a media to webp + * @param {MessageMedia} media + * + * @returns {Promise} media in webp format + */ + static async formatToWebpSticker(media) { + if (media.mimetype.includes('image')) return this.formatImageToWebpSticker(media); + else if (media.mimetype.includes('video')) return this.formatVideoToWebpSticker(media); + else throw new Error('Invalid media format'); + } + + /** + * Configure ffmpeg path + * @param {string} path + */ + static setFfmpegPath(path) { + ffmpeg.setFfmpegPath(path); + } } module.exports = Util; \ No newline at end of file