mirror of
https://github.com/cheveguerra/whatsapp-web.js.git
synced 2026-04-20 12:39:20 +00:00
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:
committed by
Pedro S. Lopez
parent
0c0a5a752b
commit
e2a642a81b
@@ -47,7 +47,7 @@ Take a look at [example.js](https://github.com/pedroslopez/whatsapp-web.js/blob/
|
|||||||
| Receive messages | ✅ |
|
| Receive messages | ✅ |
|
||||||
| Send media (images/audio/documents) | ✅ |
|
| Send media (images/audio/documents) | ✅ |
|
||||||
| Send media (video) | ✅ [(requires google chrome)](https://github.com/pedroslopez/whatsapp-web.js/issues/78#issuecomment-592723583) |
|
| 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) | ✅ |
|
| Receive media (images/audio/video/documents) | ✅ |
|
||||||
| Send contact cards | ✅ |
|
| Send contact cards | ✅ |
|
||||||
| Send location | ✅ |
|
| Send location | ✅ |
|
||||||
|
|||||||
5
index.d.ts
vendored
5
index.d.ts
vendored
@@ -277,6 +277,9 @@ declare namespace WAWebJS {
|
|||||||
/** User agent to use in puppeteer.
|
/** 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' */
|
* @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
|
userAgent?: string
|
||||||
|
/** Ffmpeg path to use when formating videos to webp while sending stickers
|
||||||
|
* @default 'ffmpeg' */
|
||||||
|
ffmpegPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Represents a Whatsapp client session */
|
/** Represents a Whatsapp client session */
|
||||||
@@ -553,6 +556,8 @@ declare namespace WAWebJS {
|
|||||||
linkPreview?: boolean
|
linkPreview?: boolean
|
||||||
/** Send audio as voice message */
|
/** Send audio as voice message */
|
||||||
sendAudioAsVoice?: boolean
|
sendAudioAsVoice?: boolean
|
||||||
|
/** Send media as sticker */
|
||||||
|
sendMediaAsSticker?: boolean
|
||||||
/** Send media as document */
|
/** Send media as document */
|
||||||
sendMediaAsDocument?: boolean
|
sendMediaAsDocument?: boolean
|
||||||
/** Automatically parse vCards and send them as contacts */
|
/** Automatically parse vCards and send them as contacts */
|
||||||
|
|||||||
@@ -29,9 +29,11 @@
|
|||||||
"homepage": "https://github.com/pedroslopez/whatsapp-web.js#readme",
|
"homepage": "https://github.com/pedroslopez/whatsapp-web.js#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pedroslopez/moduleraid": "^4.1.0",
|
"@pedroslopez/moduleraid": "^4.1.0",
|
||||||
|
"fluent-ffmpeg": "^2.1.2",
|
||||||
"jsqr": "^1.3.1",
|
"jsqr": "^1.3.1",
|
||||||
"mime": "^2.4.5",
|
"mime": "^2.4.5",
|
||||||
"puppeteer": "^5.2.1"
|
"puppeteer": "^5.2.1",
|
||||||
|
"sharp": "^0.26.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^6.8.0",
|
"eslint": "^6.8.0",
|
||||||
|
|||||||
@@ -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.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 {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.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#qr
|
||||||
* @fires Client#authenticated
|
* @fires Client#authenticated
|
||||||
@@ -55,6 +56,8 @@ class Client extends EventEmitter {
|
|||||||
|
|
||||||
this.pupBrowser = null;
|
this.pupBrowser = null;
|
||||||
this.pupPage = null;
|
this.pupPage = null;
|
||||||
|
|
||||||
|
Util.setFfmpegPath(this.options.ffmpegPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -420,6 +423,7 @@ class Client extends EventEmitter {
|
|||||||
* @typedef {Object} MessageSendOptions
|
* @typedef {Object} MessageSendOptions
|
||||||
* @property {boolean} [linkPreview=true] - Show links preview
|
* @property {boolean} [linkPreview=true] - Show links preview
|
||||||
* @property {boolean} [sendAudioAsVoice=false] - Send audio as voice message
|
* @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} [sendMediaAsDocument=false] - Send media as a document
|
||||||
* @property {boolean} [parseVCards=true] - Automatically parse vCards and send them as contacts
|
* @property {boolean} [parseVCards=true] - Automatically parse vCards and send them as contacts
|
||||||
* @property {string} [caption] - Image or video caption
|
* @property {string} [caption] - Image or video caption
|
||||||
@@ -441,6 +445,7 @@ class Client extends EventEmitter {
|
|||||||
let internalOptions = {
|
let internalOptions = {
|
||||||
linkPreview: options.linkPreview === false ? undefined : true,
|
linkPreview: options.linkPreview === false ? undefined : true,
|
||||||
sendAudioAsVoice: options.sendAudioAsVoice,
|
sendAudioAsVoice: options.sendAudioAsVoice,
|
||||||
|
sendMediaAsSticker: options.sendMediaAsSticker,
|
||||||
sendMediaAsDocument: options.sendMediaAsDocument,
|
sendMediaAsDocument: options.sendMediaAsDocument,
|
||||||
caption: options.caption,
|
caption: options.caption,
|
||||||
quotedMessageId: options.quotedMessageId,
|
quotedMessageId: options.quotedMessageId,
|
||||||
@@ -468,6 +473,10 @@ class Client extends EventEmitter {
|
|||||||
content = '';
|
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 newMessage = await this.pupPage.evaluate(async (chatId, message, options, sendSeen) => {
|
||||||
const chatWid = window.Store.WidFactory.createWid(chatId);
|
const chatWid = window.Store.WidFactory.createWid(chatId);
|
||||||
const chat = await window.Store.Chat.find(chatWid);
|
const chat = await window.Store.Chat.find(chatWid);
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ exports.DefaultOptions = {
|
|||||||
authTimeoutMs: 45000,
|
authTimeoutMs: 45000,
|
||||||
takeoverOnConflict: false,
|
takeoverOnConflict: false,
|
||||||
takeoverTimeoutMs: 0,
|
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'
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ exports.ExposeStore = (moduleRaidStr) => {
|
|||||||
window.Store.WidFactory = window.mR.findModule('createWid')[0];
|
window.Store.WidFactory = window.mR.findModule('createWid')[0];
|
||||||
window.Store.BlockContact = window.mR.findModule('blockContact')[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.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;
|
window.Store.Label = window.mR.findModule('LabelCollection')[0].default;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,12 +58,17 @@ exports.LoadUtils = () => {
|
|||||||
window.WWebJS.sendMessage = async (chat, content, options = {}) => {
|
window.WWebJS.sendMessage = async (chat, content, options = {}) => {
|
||||||
let attOptions = {};
|
let attOptions = {};
|
||||||
if (options.attachment) {
|
if (options.attachment) {
|
||||||
attOptions = await window.WWebJS.processMediaData(options.attachment, {
|
attOptions = options.sendMediaAsSticker
|
||||||
|
? await window.WWebJS.processStickerData(options.attachment)
|
||||||
|
: await window.WWebJS.processMediaData(options.attachment, {
|
||||||
forceVoice: options.sendAudioAsVoice,
|
forceVoice: options.sendAudioAsVoice,
|
||||||
forceDocument: options.sendMediaAsDocument
|
forceDocument: options.sendMediaAsDocument
|
||||||
});
|
});
|
||||||
content = attOptions.preview;
|
|
||||||
|
content = options.sendMediaAsSticker ? undefined : attOptions.preview;
|
||||||
|
|
||||||
delete options.attachment;
|
delete options.attachment;
|
||||||
|
delete options.sendMediaAsSticker;
|
||||||
}
|
}
|
||||||
|
|
||||||
let quotedMsgOptions = {};
|
let quotedMsgOptions = {};
|
||||||
@@ -160,6 +167,33 @@ exports.LoadUtils = () => {
|
|||||||
return window.Store.Msg.get(newMsgId._serialized);
|
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 }) => {
|
window.WWebJS.processMediaData = async (mediaInfo, { forceVoice, forceDocument }) => {
|
||||||
const file = window.WWebJS.mediaInfoToFile(mediaInfo);
|
const file = window.WWebJS.mediaInfoToFile(mediaInfo);
|
||||||
const mData = await window.Store.OpaqueData.createFromData(file, file.type);
|
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) => {
|
window.WWebJS.sendClearChat = async (chatId) => {
|
||||||
let chat = window.Store.Chat.get(chatId);
|
let chat = window.Store.Chat.get(chatId);
|
||||||
if (chat !== undefined) {
|
if (chat !== undefined) {
|
||||||
|
|||||||
124
src/util/Util.js
124
src/util/Util.js
@@ -1,5 +1,12 @@
|
|||||||
'use strict';
|
'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);
|
const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,6 +37,123 @@ class Util {
|
|||||||
|
|
||||||
return given;
|
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;
|
module.exports = Util;
|
||||||
Reference in New Issue
Block a user