feat: send media as stickers (#479)

Adds the option `sendMediaAsSticker` that will take care of converting media to appropriate formats and send it as a sticker. Note that ffmpeg is required to properly convert animated stickers that are not in webp format.

Co-authored-by: Pedro S. Lopez <pedroslopez@me.com>
This commit is contained in:
Marcelo Carvalho
2020-12-25 19:20:12 -03:00
committed by Pedro S. Lopez
parent 0c0a5a752b
commit e2a642a81b
7 changed files with 199 additions and 8 deletions

View File

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

5
index.d.ts vendored
View File

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

View File

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

View File

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

View File

@@ -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'
};
/**

View File

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

View File

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