850 lines
33 KiB
JavaScript
850 lines
33 KiB
JavaScript
import { Boom } from '@hapi/boom';
|
|
import { randomBytes } from 'crypto';
|
|
import { promises as fs } from 'fs';
|
|
import {} from 'stream';
|
|
import { proto } from '../../WAProto/index.js';
|
|
import { CALL_AUDIO_PREFIX, CALL_VIDEO_PREFIX, MEDIA_KEYS, URL_REGEX, WA_DEFAULT_EPHEMERAL } from '../Defaults/index.js';
|
|
import { WAMessageStatus, WAProto } from '../Types/index.js';
|
|
import { isJidGroup, isJidNewsletter, isJidStatusBroadcast, jidNormalizedUser } from '../WABinary/index.js';
|
|
import { sha256 } from './crypto.js';
|
|
import { generateMessageIDV2, getKeyAuthor, unixTimestampSeconds } from './generics.js';
|
|
import { downloadContentFromMessage, encryptedStream, generateThumbnail, getAudioDuration, getAudioWaveform, getRawMediaUploadData } from './messages-media.js';
|
|
const MIMETYPE_MAP = {
|
|
image: 'image/jpeg',
|
|
video: 'video/mp4',
|
|
document: 'application/pdf',
|
|
audio: 'audio/ogg; codecs=opus',
|
|
sticker: 'image/webp',
|
|
'product-catalog-image': 'image/jpeg'
|
|
};
|
|
const MessageTypeProto = {
|
|
image: WAProto.Message.ImageMessage,
|
|
video: WAProto.Message.VideoMessage,
|
|
audio: WAProto.Message.AudioMessage,
|
|
sticker: WAProto.Message.StickerMessage,
|
|
document: WAProto.Message.DocumentMessage
|
|
};
|
|
/**
|
|
* Uses a regex to test whether the string contains a URL, and returns the URL if it does.
|
|
* @param text eg. hello https://google.com
|
|
* @returns the URL, eg. https://google.com
|
|
*/
|
|
export const extractUrlFromText = (text) => text.match(URL_REGEX)?.[0];
|
|
export const generateLinkPreviewIfRequired = async (text, getUrlInfo, logger) => {
|
|
const url = extractUrlFromText(text);
|
|
if (!!getUrlInfo && url) {
|
|
try {
|
|
const urlInfo = await getUrlInfo(url);
|
|
return urlInfo;
|
|
}
|
|
catch (error) {
|
|
// ignore if fails
|
|
logger?.warn({ trace: error.stack }, 'url generation failed');
|
|
}
|
|
}
|
|
};
|
|
const assertColor = async (color) => {
|
|
let assertedColor;
|
|
if (typeof color === 'number') {
|
|
assertedColor = color > 0 ? color : 0xffffffff + Number(color) + 1;
|
|
}
|
|
else {
|
|
let hex = color.trim().replace('#', '');
|
|
if (hex.length <= 6) {
|
|
hex = 'FF' + hex.padStart(6, '0');
|
|
}
|
|
assertedColor = parseInt(hex, 16);
|
|
return assertedColor;
|
|
}
|
|
};
|
|
export const prepareWAMessageMedia = async (message, options) => {
|
|
const logger = options.logger;
|
|
let mediaType;
|
|
for (const key of MEDIA_KEYS) {
|
|
if (key in message) {
|
|
mediaType = key;
|
|
}
|
|
}
|
|
if (!mediaType) {
|
|
throw new Boom('Invalid media type', { statusCode: 400 });
|
|
}
|
|
const uploadData = {
|
|
...message,
|
|
media: message[mediaType]
|
|
};
|
|
delete uploadData[mediaType];
|
|
// check if cacheable + generate cache key
|
|
const cacheableKey = typeof uploadData.media === 'object' &&
|
|
'url' in uploadData.media &&
|
|
!!uploadData.media.url &&
|
|
!!options.mediaCache &&
|
|
mediaType + ':' + uploadData.media.url.toString();
|
|
if (mediaType === 'document' && !uploadData.fileName) {
|
|
uploadData.fileName = 'file';
|
|
}
|
|
if (!uploadData.mimetype) {
|
|
uploadData.mimetype = MIMETYPE_MAP[mediaType];
|
|
}
|
|
if (cacheableKey) {
|
|
const mediaBuff = await options.mediaCache.get(cacheableKey);
|
|
if (mediaBuff) {
|
|
logger?.debug({ cacheableKey }, 'got media cache hit');
|
|
const obj = proto.Message.decode(mediaBuff);
|
|
const key = `${mediaType}Message`;
|
|
Object.assign(obj[key], { ...uploadData, media: undefined });
|
|
return obj;
|
|
}
|
|
}
|
|
const isNewsletter = !!options.jid && isJidNewsletter(options.jid);
|
|
if (isNewsletter) {
|
|
logger?.info({ key: cacheableKey }, 'Preparing raw media for newsletter');
|
|
const { filePath, fileSha256, fileLength } = await getRawMediaUploadData(uploadData.media, options.mediaTypeOverride || mediaType, logger);
|
|
const fileSha256B64 = fileSha256.toString('base64');
|
|
const { mediaUrl, directPath } = await options.upload(filePath, {
|
|
fileEncSha256B64: fileSha256B64,
|
|
mediaType: mediaType,
|
|
timeoutMs: options.mediaUploadTimeoutMs
|
|
});
|
|
await fs.unlink(filePath);
|
|
const obj = WAProto.Message.fromObject({
|
|
// todo: add more support here
|
|
[`${mediaType}Message`]: MessageTypeProto[mediaType].fromObject({
|
|
url: mediaUrl,
|
|
directPath,
|
|
fileSha256,
|
|
fileLength,
|
|
...uploadData,
|
|
media: undefined
|
|
})
|
|
});
|
|
if (uploadData.ptv) {
|
|
obj.ptvMessage = obj.videoMessage;
|
|
delete obj.videoMessage;
|
|
}
|
|
if (obj.stickerMessage) {
|
|
obj.stickerMessage.stickerSentTs = Date.now();
|
|
}
|
|
if (cacheableKey) {
|
|
logger?.debug({ cacheableKey }, 'set cache');
|
|
await options.mediaCache.set(cacheableKey, WAProto.Message.encode(obj).finish());
|
|
}
|
|
return obj;
|
|
}
|
|
const requiresDurationComputation = mediaType === 'audio' && typeof uploadData.seconds === 'undefined';
|
|
const requiresThumbnailComputation = (mediaType === 'image' || mediaType === 'video') && typeof uploadData['jpegThumbnail'] === 'undefined';
|
|
const requiresWaveformProcessing = mediaType === 'audio' && uploadData.ptt === true;
|
|
const requiresAudioBackground = options.backgroundColor && mediaType === 'audio' && uploadData.ptt === true;
|
|
const requiresOriginalForSomeProcessing = requiresDurationComputation || requiresThumbnailComputation;
|
|
const { mediaKey, encFilePath, originalFilePath, fileEncSha256, fileSha256, fileLength } = await encryptedStream(uploadData.media, options.mediaTypeOverride || mediaType, {
|
|
logger,
|
|
saveOriginalFileIfRequired: requiresOriginalForSomeProcessing,
|
|
opts: options.options
|
|
});
|
|
const fileEncSha256B64 = fileEncSha256.toString('base64');
|
|
const [{ mediaUrl, directPath }] = await Promise.all([
|
|
(async () => {
|
|
const result = await options.upload(encFilePath, {
|
|
fileEncSha256B64,
|
|
mediaType,
|
|
timeoutMs: options.mediaUploadTimeoutMs
|
|
});
|
|
logger?.debug({ mediaType, cacheableKey }, 'uploaded media');
|
|
return result;
|
|
})(),
|
|
(async () => {
|
|
try {
|
|
if (requiresThumbnailComputation) {
|
|
const { thumbnail, originalImageDimensions } = await generateThumbnail(originalFilePath, mediaType, options);
|
|
uploadData.jpegThumbnail = thumbnail;
|
|
if (!uploadData.width && originalImageDimensions) {
|
|
uploadData.width = originalImageDimensions.width;
|
|
uploadData.height = originalImageDimensions.height;
|
|
logger?.debug('set dimensions');
|
|
}
|
|
logger?.debug('generated thumbnail');
|
|
}
|
|
if (requiresDurationComputation) {
|
|
uploadData.seconds = await getAudioDuration(originalFilePath);
|
|
logger?.debug('computed audio duration');
|
|
}
|
|
if (requiresWaveformProcessing) {
|
|
uploadData.waveform = await getAudioWaveform(originalFilePath, logger);
|
|
logger?.debug('processed waveform');
|
|
}
|
|
if (requiresAudioBackground) {
|
|
uploadData.backgroundArgb = await assertColor(options.backgroundColor);
|
|
logger?.debug('computed backgroundColor audio status');
|
|
}
|
|
}
|
|
catch (error) {
|
|
logger?.warn({ trace: error.stack }, 'failed to obtain extra info');
|
|
}
|
|
})()
|
|
]).finally(async () => {
|
|
try {
|
|
await fs.unlink(encFilePath);
|
|
if (originalFilePath) {
|
|
await fs.unlink(originalFilePath);
|
|
}
|
|
logger?.debug('removed tmp files');
|
|
}
|
|
catch (error) {
|
|
logger?.warn('failed to remove tmp file');
|
|
}
|
|
});
|
|
const obj = WAProto.Message.fromObject({
|
|
[`${mediaType}Message`]: MessageTypeProto[mediaType].fromObject({
|
|
url: mediaUrl,
|
|
directPath,
|
|
mediaKey,
|
|
fileEncSha256,
|
|
fileSha256,
|
|
fileLength,
|
|
mediaKeyTimestamp: unixTimestampSeconds(),
|
|
...uploadData,
|
|
media: undefined
|
|
})
|
|
});
|
|
if (uploadData.ptv) {
|
|
obj.ptvMessage = obj.videoMessage;
|
|
delete obj.videoMessage;
|
|
}
|
|
if (cacheableKey) {
|
|
logger?.debug({ cacheableKey }, 'set cache');
|
|
await options.mediaCache.set(cacheableKey, WAProto.Message.encode(obj).finish());
|
|
}
|
|
return obj;
|
|
};
|
|
export const prepareDisappearingMessageSettingContent = (ephemeralExpiration) => {
|
|
ephemeralExpiration = ephemeralExpiration || 0;
|
|
const content = {
|
|
ephemeralMessage: {
|
|
message: {
|
|
protocolMessage: {
|
|
type: WAProto.Message.ProtocolMessage.Type.EPHEMERAL_SETTING,
|
|
ephemeralExpiration
|
|
}
|
|
}
|
|
}
|
|
};
|
|
return WAProto.Message.fromObject(content);
|
|
};
|
|
/**
|
|
* Generate forwarded message content like WA does
|
|
* @param message the message to forward
|
|
* @param options.forceForward will show the message as forwarded even if it is from you
|
|
*/
|
|
export const generateForwardMessageContent = (message, forceForward) => {
|
|
let content = message.message;
|
|
if (!content) {
|
|
throw new Boom('no content in message', { statusCode: 400 });
|
|
}
|
|
// hacky copy
|
|
content = normalizeMessageContent(content);
|
|
content = proto.Message.decode(proto.Message.encode(content).finish());
|
|
let key = Object.keys(content)[0];
|
|
let score = content?.[key]?.contextInfo?.forwardingScore || 0;
|
|
score += message.key.fromMe && !forceForward ? 0 : 1;
|
|
if (key === 'conversation') {
|
|
content.extendedTextMessage = { text: content[key] };
|
|
delete content.conversation;
|
|
key = 'extendedTextMessage';
|
|
}
|
|
const key_ = content?.[key];
|
|
if (score > 0) {
|
|
key_.contextInfo = { forwardingScore: score, isForwarded: true };
|
|
}
|
|
else {
|
|
key_.contextInfo = {};
|
|
}
|
|
return content;
|
|
};
|
|
export const generateWAMessageContent = async (message, options) => {
|
|
var _a, _b;
|
|
let m = {};
|
|
if ('text' in message) {
|
|
const extContent = { text: message.text };
|
|
let urlInfo = message.linkPreview;
|
|
if (typeof urlInfo === 'undefined') {
|
|
urlInfo = await generateLinkPreviewIfRequired(message.text, options.getUrlInfo, options.logger);
|
|
}
|
|
if (urlInfo) {
|
|
extContent.matchedText = urlInfo['matched-text'];
|
|
extContent.jpegThumbnail = urlInfo.jpegThumbnail;
|
|
extContent.description = urlInfo.description;
|
|
extContent.title = urlInfo.title;
|
|
extContent.previewType = 0;
|
|
const img = urlInfo.highQualityThumbnail;
|
|
if (img) {
|
|
extContent.thumbnailDirectPath = img.directPath;
|
|
extContent.mediaKey = img.mediaKey;
|
|
extContent.mediaKeyTimestamp = img.mediaKeyTimestamp;
|
|
extContent.thumbnailWidth = img.width;
|
|
extContent.thumbnailHeight = img.height;
|
|
extContent.thumbnailSha256 = img.fileSha256;
|
|
extContent.thumbnailEncSha256 = img.fileEncSha256;
|
|
}
|
|
}
|
|
if (options.backgroundColor) {
|
|
extContent.backgroundArgb = await assertColor(options.backgroundColor);
|
|
}
|
|
if (options.font) {
|
|
extContent.font = options.font;
|
|
}
|
|
m.extendedTextMessage = extContent;
|
|
}
|
|
else if ('contacts' in message) {
|
|
const contactLen = message.contacts.contacts.length;
|
|
if (!contactLen) {
|
|
throw new Boom('require atleast 1 contact', { statusCode: 400 });
|
|
}
|
|
if (contactLen === 1) {
|
|
m.contactMessage = WAProto.Message.ContactMessage.create(message.contacts.contacts[0]);
|
|
}
|
|
else {
|
|
m.contactsArrayMessage = WAProto.Message.ContactsArrayMessage.create(message.contacts);
|
|
}
|
|
}
|
|
else if ('location' in message) {
|
|
m.locationMessage = WAProto.Message.LocationMessage.create(message.location);
|
|
}
|
|
else if ('react' in message) {
|
|
if (!message.react.senderTimestampMs) {
|
|
message.react.senderTimestampMs = Date.now();
|
|
}
|
|
m.reactionMessage = WAProto.Message.ReactionMessage.create(message.react);
|
|
}
|
|
else if ('delete' in message) {
|
|
m.protocolMessage = {
|
|
key: message.delete,
|
|
type: WAProto.Message.ProtocolMessage.Type.REVOKE
|
|
};
|
|
}
|
|
else if ('forward' in message) {
|
|
m = generateForwardMessageContent(message.forward, message.force);
|
|
}
|
|
else if ('disappearingMessagesInChat' in message) {
|
|
const exp = typeof message.disappearingMessagesInChat === 'boolean'
|
|
? message.disappearingMessagesInChat
|
|
? WA_DEFAULT_EPHEMERAL
|
|
: 0
|
|
: message.disappearingMessagesInChat;
|
|
m = prepareDisappearingMessageSettingContent(exp);
|
|
}
|
|
else if ('groupInvite' in message) {
|
|
m.groupInviteMessage = {};
|
|
m.groupInviteMessage.inviteCode = message.groupInvite.inviteCode;
|
|
m.groupInviteMessage.inviteExpiration = message.groupInvite.inviteExpiration;
|
|
m.groupInviteMessage.caption = message.groupInvite.text;
|
|
m.groupInviteMessage.groupJid = message.groupInvite.jid;
|
|
m.groupInviteMessage.groupName = message.groupInvite.subject;
|
|
//TODO: use built-in interface and get disappearing mode info etc.
|
|
//TODO: cache / use store!?
|
|
if (options.getProfilePicUrl) {
|
|
const pfpUrl = await options.getProfilePicUrl(message.groupInvite.jid, 'preview');
|
|
if (pfpUrl) {
|
|
const resp = await fetch(pfpUrl, { method: 'GET', dispatcher: options?.options?.dispatcher });
|
|
if (resp.ok) {
|
|
const buf = Buffer.from(await resp.arrayBuffer());
|
|
m.groupInviteMessage.jpegThumbnail = buf;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else if ('pin' in message) {
|
|
m.pinInChatMessage = {};
|
|
m.messageContextInfo = {};
|
|
m.pinInChatMessage.key = message.pin;
|
|
m.pinInChatMessage.type = message.type;
|
|
m.pinInChatMessage.senderTimestampMs = Date.now();
|
|
m.messageContextInfo.messageAddOnDurationInSecs = message.type === 1 ? message.time || 86400 : 0;
|
|
}
|
|
else if ('buttonReply' in message) {
|
|
switch (message.type) {
|
|
case 'template':
|
|
m.templateButtonReplyMessage = {
|
|
selectedDisplayText: message.buttonReply.displayText,
|
|
selectedId: message.buttonReply.id,
|
|
selectedIndex: message.buttonReply.index
|
|
};
|
|
break;
|
|
case 'plain':
|
|
m.buttonsResponseMessage = {
|
|
selectedButtonId: message.buttonReply.id,
|
|
selectedDisplayText: message.buttonReply.displayText,
|
|
type: proto.Message.ButtonsResponseMessage.Type.DISPLAY_TEXT
|
|
};
|
|
break;
|
|
}
|
|
}
|
|
else if ('ptv' in message && message.ptv) {
|
|
const { videoMessage } = await prepareWAMessageMedia({ video: message.video }, options);
|
|
m.ptvMessage = videoMessage;
|
|
}
|
|
else if ('product' in message) {
|
|
const { imageMessage } = await prepareWAMessageMedia({ image: message.product.productImage }, options);
|
|
m.productMessage = WAProto.Message.ProductMessage.create({
|
|
...message,
|
|
product: {
|
|
...message.product,
|
|
productImage: imageMessage
|
|
}
|
|
});
|
|
}
|
|
else if ('listReply' in message) {
|
|
m.listResponseMessage = { ...message.listReply };
|
|
}
|
|
else if ('event' in message) {
|
|
m.eventMessage = {};
|
|
const startTime = Math.floor(message.event.startDate.getTime() / 1000);
|
|
if (message.event.call && options.getCallLink) {
|
|
const token = await options.getCallLink(message.event.call, { startTime });
|
|
m.eventMessage.joinLink = (message.event.call === 'audio' ? CALL_AUDIO_PREFIX : CALL_VIDEO_PREFIX) + token;
|
|
}
|
|
m.messageContextInfo = {
|
|
// encKey
|
|
messageSecret: message.event.messageSecret || randomBytes(32)
|
|
};
|
|
m.eventMessage.name = message.event.name;
|
|
m.eventMessage.description = message.event.description;
|
|
m.eventMessage.startTime = startTime;
|
|
m.eventMessage.endTime = message.event.endDate ? message.event.endDate.getTime() / 1000 : undefined;
|
|
m.eventMessage.isCanceled = message.event.isCancelled ?? false;
|
|
m.eventMessage.extraGuestsAllowed = message.event.extraGuestsAllowed;
|
|
m.eventMessage.isScheduleCall = message.event.isScheduleCall ?? false;
|
|
m.eventMessage.location = message.event.location;
|
|
}
|
|
else if ('poll' in message) {
|
|
(_a = message.poll).selectableCount || (_a.selectableCount = 0);
|
|
(_b = message.poll).toAnnouncementGroup || (_b.toAnnouncementGroup = false);
|
|
if (!Array.isArray(message.poll.values)) {
|
|
throw new Boom('Invalid poll values', { statusCode: 400 });
|
|
}
|
|
if (message.poll.selectableCount < 0 || message.poll.selectableCount > message.poll.values.length) {
|
|
throw new Boom(`poll.selectableCount in poll should be >= 0 and <= ${message.poll.values.length}`, {
|
|
statusCode: 400
|
|
});
|
|
}
|
|
m.messageContextInfo = {
|
|
// encKey
|
|
messageSecret: message.poll.messageSecret || randomBytes(32)
|
|
};
|
|
const pollCreationMessage = {
|
|
name: message.poll.name,
|
|
selectableOptionsCount: message.poll.selectableCount,
|
|
options: message.poll.values.map(optionName => ({ optionName }))
|
|
};
|
|
if (message.poll.toAnnouncementGroup) {
|
|
// poll v2 is for community announcement groups (single select and multiple)
|
|
m.pollCreationMessageV2 = pollCreationMessage;
|
|
}
|
|
else {
|
|
if (message.poll.selectableCount === 1) {
|
|
//poll v3 is for single select polls
|
|
m.pollCreationMessageV3 = pollCreationMessage;
|
|
}
|
|
else {
|
|
// poll for multiple choice polls
|
|
m.pollCreationMessage = pollCreationMessage;
|
|
}
|
|
}
|
|
}
|
|
else if ('sharePhoneNumber' in message) {
|
|
m.protocolMessage = {
|
|
type: proto.Message.ProtocolMessage.Type.SHARE_PHONE_NUMBER
|
|
};
|
|
}
|
|
else if ('requestPhoneNumber' in message) {
|
|
m.requestPhoneNumberMessage = {};
|
|
}
|
|
else if ('limitSharing' in message) {
|
|
m.protocolMessage = {
|
|
type: proto.Message.ProtocolMessage.Type.LIMIT_SHARING,
|
|
limitSharing: {
|
|
sharingLimited: message.limitSharing === true,
|
|
trigger: 1,
|
|
limitSharingSettingTimestamp: Date.now(),
|
|
initiatedByMe: true
|
|
}
|
|
};
|
|
}
|
|
else {
|
|
m = await prepareWAMessageMedia(message, options);
|
|
}
|
|
if ('viewOnce' in message && !!message.viewOnce) {
|
|
m = { viewOnceMessage: { message: m } };
|
|
}
|
|
if ('mentions' in message && message.mentions?.length) {
|
|
const messageType = Object.keys(m)[0];
|
|
const key = m[messageType];
|
|
if ('contextInfo' in key && !!key.contextInfo) {
|
|
key.contextInfo.mentionedJid = message.mentions;
|
|
}
|
|
else if (key) {
|
|
key.contextInfo = {
|
|
mentionedJid: message.mentions
|
|
};
|
|
}
|
|
}
|
|
if ('edit' in message) {
|
|
m = {
|
|
protocolMessage: {
|
|
key: message.edit,
|
|
editedMessage: m,
|
|
timestampMs: Date.now(),
|
|
type: WAProto.Message.ProtocolMessage.Type.MESSAGE_EDIT
|
|
}
|
|
};
|
|
}
|
|
if ('contextInfo' in message && !!message.contextInfo) {
|
|
const messageType = Object.keys(m)[0];
|
|
const key = m[messageType];
|
|
if ('contextInfo' in key && !!key.contextInfo) {
|
|
key.contextInfo = { ...key.contextInfo, ...message.contextInfo };
|
|
}
|
|
else if (key) {
|
|
key.contextInfo = message.contextInfo;
|
|
}
|
|
}
|
|
return WAProto.Message.create(m);
|
|
};
|
|
export const generateWAMessageFromContent = (jid, message, options) => {
|
|
// set timestamp to now
|
|
// if not specified
|
|
if (!options.timestamp) {
|
|
options.timestamp = new Date();
|
|
}
|
|
const innerMessage = normalizeMessageContent(message);
|
|
const key = getContentType(innerMessage);
|
|
const timestamp = unixTimestampSeconds(options.timestamp);
|
|
const { quoted, userJid } = options;
|
|
if (quoted && !isJidNewsletter(jid)) {
|
|
const participant = quoted.key.fromMe
|
|
? userJid // TODO: Add support for LIDs
|
|
: quoted.participant || quoted.key.participant || quoted.key.remoteJid;
|
|
let quotedMsg = normalizeMessageContent(quoted.message);
|
|
const msgType = getContentType(quotedMsg);
|
|
// strip any redundant properties
|
|
quotedMsg = proto.Message.create({ [msgType]: quotedMsg[msgType] });
|
|
const quotedContent = quotedMsg[msgType];
|
|
if (typeof quotedContent === 'object' && quotedContent && 'contextInfo' in quotedContent) {
|
|
delete quotedContent.contextInfo;
|
|
}
|
|
const contextInfo = ('contextInfo' in innerMessage[key] && innerMessage[key]?.contextInfo) || {};
|
|
contextInfo.participant = jidNormalizedUser(participant);
|
|
contextInfo.stanzaId = quoted.key.id;
|
|
contextInfo.quotedMessage = quotedMsg;
|
|
// if a participant is quoted, then it must be a group
|
|
// hence, remoteJid of group must also be entered
|
|
if (jid !== quoted.key.remoteJid) {
|
|
contextInfo.remoteJid = quoted.key.remoteJid;
|
|
}
|
|
if (contextInfo && innerMessage[key]) {
|
|
/* @ts-ignore */
|
|
innerMessage[key].contextInfo = contextInfo;
|
|
}
|
|
}
|
|
if (
|
|
// if we want to send a disappearing message
|
|
!!options?.ephemeralExpiration &&
|
|
// and it's not a protocol message -- delete, toggle disappear message
|
|
key !== 'protocolMessage' &&
|
|
// already not converted to disappearing message
|
|
key !== 'ephemeralMessage' &&
|
|
// newsletters don't support ephemeral messages
|
|
!isJidNewsletter(jid)) {
|
|
/* @ts-ignore */
|
|
innerMessage[key].contextInfo = {
|
|
...(innerMessage[key].contextInfo || {}),
|
|
expiration: options.ephemeralExpiration || WA_DEFAULT_EPHEMERAL
|
|
//ephemeralSettingTimestamp: options.ephemeralOptions.eph_setting_ts?.toString()
|
|
};
|
|
}
|
|
message = WAProto.Message.create(message);
|
|
const messageJSON = {
|
|
key: {
|
|
remoteJid: jid,
|
|
fromMe: true,
|
|
id: options?.messageId || generateMessageIDV2()
|
|
},
|
|
message: message,
|
|
messageTimestamp: timestamp,
|
|
messageStubParameters: [],
|
|
participant: isJidGroup(jid) || isJidStatusBroadcast(jid) ? userJid : undefined, // TODO: Add support for LIDs
|
|
status: WAMessageStatus.PENDING
|
|
};
|
|
return WAProto.WebMessageInfo.fromObject(messageJSON);
|
|
};
|
|
export const generateWAMessage = async (jid, content, options) => {
|
|
// ensure msg ID is with every log
|
|
options.logger = options?.logger?.child({ msgId: options.messageId });
|
|
// Pass jid in the options to generateWAMessageContent
|
|
return generateWAMessageFromContent(jid, await generateWAMessageContent(content, { ...options, jid }), options);
|
|
};
|
|
/** Get the key to access the true type of content */
|
|
export const getContentType = (content) => {
|
|
if (content) {
|
|
const keys = Object.keys(content);
|
|
const key = keys.find(k => (k === 'conversation' || k.includes('Message')) && k !== 'senderKeyDistributionMessage');
|
|
return key;
|
|
}
|
|
};
|
|
/**
|
|
* Normalizes ephemeral, view once messages to regular message content
|
|
* Eg. image messages in ephemeral messages, in view once messages etc.
|
|
* @param content
|
|
* @returns
|
|
*/
|
|
export const normalizeMessageContent = (content) => {
|
|
if (!content) {
|
|
return undefined;
|
|
}
|
|
// set max iterations to prevent an infinite loop
|
|
for (let i = 0; i < 5; i++) {
|
|
const inner = getFutureProofMessage(content);
|
|
if (!inner) {
|
|
break;
|
|
}
|
|
content = inner.message;
|
|
}
|
|
return content;
|
|
function getFutureProofMessage(message) {
|
|
return (message?.ephemeralMessage ||
|
|
message?.viewOnceMessage ||
|
|
message?.documentWithCaptionMessage ||
|
|
message?.viewOnceMessageV2 ||
|
|
message?.viewOnceMessageV2Extension ||
|
|
message?.editedMessage);
|
|
}
|
|
};
|
|
/**
|
|
* Extract the true message content from a message
|
|
* Eg. extracts the inner message from a disappearing message/view once message
|
|
*/
|
|
export const extractMessageContent = (content) => {
|
|
const extractFromTemplateMessage = (msg) => {
|
|
if (msg.imageMessage) {
|
|
return { imageMessage: msg.imageMessage };
|
|
}
|
|
else if (msg.documentMessage) {
|
|
return { documentMessage: msg.documentMessage };
|
|
}
|
|
else if (msg.videoMessage) {
|
|
return { videoMessage: msg.videoMessage };
|
|
}
|
|
else if (msg.locationMessage) {
|
|
return { locationMessage: msg.locationMessage };
|
|
}
|
|
else {
|
|
return {
|
|
conversation: 'contentText' in msg ? msg.contentText : 'hydratedContentText' in msg ? msg.hydratedContentText : ''
|
|
};
|
|
}
|
|
};
|
|
content = normalizeMessageContent(content);
|
|
if (content?.buttonsMessage) {
|
|
return extractFromTemplateMessage(content.buttonsMessage);
|
|
}
|
|
if (content?.templateMessage?.hydratedFourRowTemplate) {
|
|
return extractFromTemplateMessage(content?.templateMessage?.hydratedFourRowTemplate);
|
|
}
|
|
if (content?.templateMessage?.hydratedTemplate) {
|
|
return extractFromTemplateMessage(content?.templateMessage?.hydratedTemplate);
|
|
}
|
|
if (content?.templateMessage?.fourRowTemplate) {
|
|
return extractFromTemplateMessage(content?.templateMessage?.fourRowTemplate);
|
|
}
|
|
return content;
|
|
};
|
|
/**
|
|
* Returns the device predicted by message ID
|
|
*/
|
|
export const getDevice = (id) => /^3A.{18}$/.test(id)
|
|
? 'ios'
|
|
: /^3E.{20}$/.test(id)
|
|
? 'web'
|
|
: /^(.{21}|.{32})$/.test(id)
|
|
? 'android'
|
|
: /^(3F|.{18}$)/.test(id)
|
|
? 'desktop'
|
|
: 'unknown';
|
|
/** Upserts a receipt in the message */
|
|
export const updateMessageWithReceipt = (msg, receipt) => {
|
|
msg.userReceipt = msg.userReceipt || [];
|
|
const recp = msg.userReceipt.find(m => m.userJid === receipt.userJid);
|
|
if (recp) {
|
|
Object.assign(recp, receipt);
|
|
}
|
|
else {
|
|
msg.userReceipt.push(receipt);
|
|
}
|
|
};
|
|
/** Update the message with a new reaction */
|
|
export const updateMessageWithReaction = (msg, reaction) => {
|
|
const authorID = getKeyAuthor(reaction.key);
|
|
const reactions = (msg.reactions || []).filter(r => getKeyAuthor(r.key) !== authorID);
|
|
reaction.text = reaction.text || '';
|
|
reactions.push(reaction);
|
|
msg.reactions = reactions;
|
|
};
|
|
/** Update the message with a new poll update */
|
|
export const updateMessageWithPollUpdate = (msg, update) => {
|
|
const authorID = getKeyAuthor(update.pollUpdateMessageKey);
|
|
const reactions = (msg.pollUpdates || []).filter(r => getKeyAuthor(r.pollUpdateMessageKey) !== authorID);
|
|
if (update.vote?.selectedOptions?.length) {
|
|
reactions.push(update);
|
|
}
|
|
msg.pollUpdates = reactions;
|
|
};
|
|
/** Update the message with a new event response */
|
|
export const updateMessageWithEventResponse = (msg, update) => {
|
|
const authorID = getKeyAuthor(update.eventResponseMessageKey);
|
|
const responses = (msg.eventResponses || []).filter(r => getKeyAuthor(r.eventResponseMessageKey) !== authorID);
|
|
responses.push(update);
|
|
msg.eventResponses = responses;
|
|
};
|
|
/**
|
|
* Aggregates all poll updates in a poll.
|
|
* @param msg the poll creation message
|
|
* @param meId your jid
|
|
* @returns A list of options & their voters
|
|
*/
|
|
export function getAggregateVotesInPollMessage({ message, pollUpdates }, meId) {
|
|
const opts = message?.pollCreationMessage?.options ||
|
|
message?.pollCreationMessageV2?.options ||
|
|
message?.pollCreationMessageV3?.options ||
|
|
[];
|
|
const voteHashMap = opts.reduce((acc, opt) => {
|
|
const hash = sha256(Buffer.from(opt.optionName || '')).toString();
|
|
acc[hash] = {
|
|
name: opt.optionName || '',
|
|
voters: []
|
|
};
|
|
return acc;
|
|
}, {});
|
|
for (const update of pollUpdates || []) {
|
|
const { vote } = update;
|
|
if (!vote) {
|
|
continue;
|
|
}
|
|
for (const option of vote.selectedOptions || []) {
|
|
const hash = option.toString();
|
|
let data = voteHashMap[hash];
|
|
if (!data) {
|
|
voteHashMap[hash] = {
|
|
name: 'Unknown',
|
|
voters: []
|
|
};
|
|
data = voteHashMap[hash];
|
|
}
|
|
voteHashMap[hash].voters.push(getKeyAuthor(update.pollUpdateMessageKey, meId));
|
|
}
|
|
}
|
|
return Object.values(voteHashMap);
|
|
}
|
|
/**
|
|
* Aggregates all event responses in an event message.
|
|
* @param msg the event creation message
|
|
* @param meId your jid
|
|
* @returns A list of response types & their responders
|
|
*/
|
|
export function getAggregateResponsesInEventMessage({ eventResponses }, meId) {
|
|
const responseTypes = ['GOING', 'NOT_GOING', 'MAYBE'];
|
|
const responseMap = {};
|
|
for (const type of responseTypes) {
|
|
responseMap[type] = {
|
|
response: type,
|
|
responders: []
|
|
};
|
|
}
|
|
for (const update of eventResponses || []) {
|
|
const responseType = update.eventResponse || 'UNKNOWN';
|
|
if (responseType !== 'UNKNOWN' && responseMap[responseType]) {
|
|
responseMap[responseType].responders.push(getKeyAuthor(update.eventResponseMessageKey, meId));
|
|
}
|
|
}
|
|
return Object.values(responseMap);
|
|
}
|
|
/** Given a list of message keys, aggregates them by chat & sender. Useful for sending read receipts in bulk */
|
|
export const aggregateMessageKeysNotFromMe = (keys) => {
|
|
const keyMap = {};
|
|
for (const { remoteJid, id, participant, fromMe } of keys) {
|
|
if (!fromMe) {
|
|
const uqKey = `${remoteJid}:${participant || ''}`;
|
|
if (!keyMap[uqKey]) {
|
|
keyMap[uqKey] = {
|
|
jid: remoteJid,
|
|
participant: participant,
|
|
messageIds: []
|
|
};
|
|
}
|
|
keyMap[uqKey].messageIds.push(id);
|
|
}
|
|
}
|
|
return Object.values(keyMap);
|
|
};
|
|
const REUPLOAD_REQUIRED_STATUS = [410, 404];
|
|
/**
|
|
* Downloads the given message. Throws an error if it's not a media message
|
|
*/
|
|
export const downloadMediaMessage = async (message, type, options, ctx) => {
|
|
const result = await downloadMsg().catch(async (error) => {
|
|
if (ctx &&
|
|
typeof error?.status === 'number' && // treat errors with status as HTTP failures requiring reupload
|
|
REUPLOAD_REQUIRED_STATUS.includes(error.status)) {
|
|
ctx.logger.info({ key: message.key }, 'sending reupload media request...');
|
|
// request reupload
|
|
message = await ctx.reuploadRequest(message);
|
|
const result = await downloadMsg();
|
|
return result;
|
|
}
|
|
throw error;
|
|
});
|
|
return result;
|
|
async function downloadMsg() {
|
|
const mContent = extractMessageContent(message.message);
|
|
if (!mContent) {
|
|
throw new Boom('No message present', { statusCode: 400, data: message });
|
|
}
|
|
const contentType = getContentType(mContent);
|
|
let mediaType = contentType?.replace('Message', '');
|
|
const media = mContent[contentType];
|
|
if (!media || typeof media !== 'object' || (!('url' in media) && !('thumbnailDirectPath' in media))) {
|
|
throw new Boom(`"${contentType}" message is not a media message`);
|
|
}
|
|
let download;
|
|
if ('thumbnailDirectPath' in media && !('url' in media)) {
|
|
download = {
|
|
directPath: media.thumbnailDirectPath,
|
|
mediaKey: media.mediaKey
|
|
};
|
|
mediaType = 'thumbnail-link';
|
|
}
|
|
else {
|
|
download = media;
|
|
}
|
|
const stream = await downloadContentFromMessage(download, mediaType, options);
|
|
if (type === 'buffer') {
|
|
const bufferArray = [];
|
|
for await (const chunk of stream) {
|
|
bufferArray.push(chunk);
|
|
}
|
|
return Buffer.concat(bufferArray);
|
|
}
|
|
return stream;
|
|
}
|
|
};
|
|
/** Checks whether the given message is a media message; if it is returns the inner content */
|
|
export const assertMediaContent = (content) => {
|
|
content = extractMessageContent(content);
|
|
const mediaContent = content?.documentMessage ||
|
|
content?.imageMessage ||
|
|
content?.videoMessage ||
|
|
content?.audioMessage ||
|
|
content?.stickerMessage;
|
|
if (!mediaContent) {
|
|
throw new Boom('given message is not a media message', { statusCode: 400, data: content });
|
|
}
|
|
return mediaContent;
|
|
};
|
|
//# sourceMappingURL=messages.js.map
|