feat: Send Status (status@broadcast) {text, media, audio(with waveform)} (#249)

Co-authored-by: Davidson Gomes <contato@agenciadgcode.com>
This commit is contained in:
Davidson Gomes
2023-07-18 10:25:16 -03:00
committed by GitHub
parent 3ed3e77f58
commit cba9827851
6 changed files with 86 additions and 18 deletions

View File

@@ -98,4 +98,4 @@
} }
}, },
"packageManager": "yarn@1.22.19" "packageManager": "yarn@1.22.19"
} }

View File

@@ -302,19 +302,22 @@ export const makeMessagesSocket = (config: SocketConfig) => {
const relayMessage = async( const relayMessage = async(
jid: string, jid: string,
message: proto.IMessage, message: proto.IMessage,
{ messageId: msgId, participant, additionalAttributes, useUserDevicesCache, cachedGroupMetadata }: MessageRelayOptions { messageId: msgId, participant, additionalAttributes, useUserDevicesCache, cachedGroupMetadata, statusJidList }: MessageRelayOptions
) => { ) => {
const meId = authState.creds.me!.id const meId = authState.creds.me!.id
let shouldIncludeDeviceIdentity = false let shouldIncludeDeviceIdentity = false
const { user, server } = jidDecode(jid)! const { user, server } = jidDecode(jid)!
const statusJid = 'status@broadcast'
const isGroup = server === 'g.us' const isGroup = server === 'g.us'
const isStatus = jid === statusJid
msgId = msgId || generateMessageID() msgId = msgId || generateMessageID()
useUserDevicesCache = useUserDevicesCache !== false useUserDevicesCache = useUserDevicesCache !== false
const participants: BinaryNode[] = [] const participants: BinaryNode[] = []
const destinationJid = jidEncode(user, isGroup ? 'g.us' : 's.whatsapp.net') const destinationJid = (!isStatus) ? jidEncode(user, isGroup ? 'g.us' : 's.whatsapp.net') : statusJid
const binaryNodeContent: BinaryNode[] = [] const binaryNodeContent: BinaryNode[] = []
const devices: JidWithDevice[] = [] const devices: JidWithDevice[] = []
@@ -329,7 +332,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
// when the retry request is not for a group // when the retry request is not for a group
// only send to the specific device that asked for a retry // only send to the specific device that asked for a retry
// otherwise the message is sent out to every device that should be a recipient // otherwise the message is sent out to every device that should be a recipient
if(!isGroup) { if(!isGroup && !isStatus) {
additionalAttributes = { ...additionalAttributes, 'device_fanout': 'false' } additionalAttributes = { ...additionalAttributes, 'device_fanout': 'false' }
} }
@@ -340,7 +343,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
await authState.keys.transaction( await authState.keys.transaction(
async() => { async() => {
const mediaType = getMediaType(message) const mediaType = getMediaType(message)
if(isGroup) { if(isGroup || isStatus) {
const [groupData, senderKeyMap] = await Promise.all([ const [groupData, senderKeyMap] = await Promise.all([
(async() => { (async() => {
let groupData = cachedGroupMetadata ? await cachedGroupMetadata(jid) : undefined let groupData = cachedGroupMetadata ? await cachedGroupMetadata(jid) : undefined
@@ -348,14 +351,14 @@ export const makeMessagesSocket = (config: SocketConfig) => {
logger.trace({ jid, participants: groupData.participants.length }, 'using cached group metadata') logger.trace({ jid, participants: groupData.participants.length }, 'using cached group metadata')
} }
if(!groupData) { if(!groupData && !isStatus) {
groupData = await groupMetadata(jid) groupData = await groupMetadata(jid)
} }
return groupData return groupData
})(), })(),
(async() => { (async() => {
if(!participant) { if(!participant && !isStatus) {
const result = await authState.keys.get('sender-key-memory', [jid]) const result = await authState.keys.get('sender-key-memory', [jid])
return result[jid] || { } return result[jid] || { }
} }
@@ -365,7 +368,11 @@ export const makeMessagesSocket = (config: SocketConfig) => {
]) ])
if(!participant) { if(!participant) {
const participantsList = groupData.participants.map(p => p.id) const participantsList = (groupData && !isStatus) ? groupData.participants.map(p => p.id) : []
if(isStatus && statusJidList) {
participantsList.push(...statusJidList)
}
const additionalDevices = await getUSyncDevices(participantsList, !!useUserDevicesCache, false) const additionalDevices = await getUSyncDevices(participantsList, !!useUserDevicesCache, false)
devices.push(...additionalDevices) devices.push(...additionalDevices)
} }
@@ -746,7 +753,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
additionalAttributes.edit = '1' additionalAttributes.edit = '1'
} }
await relayMessage(jid, fullMsg.message!, { messageId: fullMsg.key.id!, cachedGroupMetadata: options.cachedGroupMetadata, additionalAttributes }) await relayMessage(jid, fullMsg.message!, { messageId: fullMsg.key.id!, cachedGroupMetadata: options.cachedGroupMetadata, additionalAttributes, statusJidList: options.statusJidList })
if(config.emitOwnEvents) { if(config.emitOwnEvents) {
process.nextTick(() => { process.nextTick(() => {
processingMutex.mutex(() => ( processingMutex.mutex(() => (

View File

@@ -198,6 +198,8 @@ export type MessageRelayOptions = MinimalRelayOptions & {
additionalAttributes?: { [_: string]: string } additionalAttributes?: { [_: string]: string }
/** should we use the devices cache, or fetch afresh from the server; default assumed to be "true" */ /** should we use the devices cache, or fetch afresh from the server; default assumed to be "true" */
useUserDevicesCache?: boolean useUserDevicesCache?: boolean
/** jid list of participants for status@broadcast */
statusJidList?: string[]
} }
export type MiscMessageGenerationOptions = MinimalRelayOptions & { export type MiscMessageGenerationOptions = MinimalRelayOptions & {
@@ -209,6 +211,12 @@ export type MiscMessageGenerationOptions = MinimalRelayOptions & {
ephemeralExpiration?: number | string ephemeralExpiration?: number | string
/** timeout for media upload to WA server */ /** timeout for media upload to WA server */
mediaUploadTimeoutMs?: number mediaUploadTimeoutMs?: number
/** jid list of participants for status@broadcast */
statusJidList?: string[]
/** backgroundcolor for status */
backgroundColor?: string
/** font type for status */
font?: number
} }
export type MessageGenerationOptionsFromContent = MiscMessageGenerationOptions & { export type MessageGenerationOptionsFromContent = MiscMessageGenerationOptions & {
userJid: string userJid: string
@@ -226,6 +234,10 @@ export type MediaGenerationOptions = {
mediaUploadTimeoutMs?: number mediaUploadTimeoutMs?: number
options?: AxiosRequestConfig options?: AxiosRequestConfig
backgroundColor?: string
font?: number
} }
export type MessageContentGenerationOptions = MediaGenerationOptions & { export type MessageContentGenerationOptions = MediaGenerationOptions & {
getUrlInfo?: (text: string) => Promise<WAUrlInfo | undefined> getUrlInfo?: (text: string) => Promise<WAUrlInfo | undefined>

View File

@@ -207,11 +207,20 @@ export async function getAudioDuration(buffer: Buffer | string | Readable) {
/** /**
referenced from and modifying https://github.com/wppconnect-team/wa-js/blob/main/src/chat/functions/prepareAudioWaveform.ts referenced from and modifying https://github.com/wppconnect-team/wa-js/blob/main/src/chat/functions/prepareAudioWaveform.ts
*/ */
export async function getAudioWaveform(bodyPath: string, logger?: Logger) { export async function getAudioWaveform(buffer: Buffer | string | Readable, logger?: Logger) {
try { try {
const { default: audioDecode } = await import('audio-decode') const audioDecode = (...args) => import('audio-decode').then(({ default: audioDecode }) => audioDecode(...args))
const fileBuffer = await fs.readFile(bodyPath) let audioData: Buffer
const audioBuffer = await audioDecode.default(fileBuffer) if(Buffer.isBuffer(buffer)) {
audioData = buffer
} else if(typeof buffer === 'string') {
const rStream = createReadStream(buffer)
audioData = await toBuffer(rStream)
} else {
audioData = await toBuffer(buffer)
}
const audioBuffer = await audioDecode(audioData)
const rawData = audioBuffer.getChannelData(0) // We only need to work with one channel of data const rawData = audioBuffer.getChannelData(0) // We only need to work with one channel of data
const samples = 64 // Number of samples we want to have in our final data set const samples = 64 // Number of samples we want to have in our final data set
@@ -773,3 +782,8 @@ const MEDIA_RETRY_STATUS_MAP = {
[proto.MediaRetryNotification.ResultType.NOT_FOUND]: 404, [proto.MediaRetryNotification.ResultType.NOT_FOUND]: 404,
[proto.MediaRetryNotification.ResultType.GENERAL_ERROR]: 418, [proto.MediaRetryNotification.ResultType.GENERAL_ERROR]: 418,
} as const } as const
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function __importStar(arg0: any): any {
throw new Error('Function not implemented.')
}

View File

@@ -23,7 +23,7 @@ import {
WAProto, WAProto,
WATextMessage, WATextMessage,
} from '../Types' } from '../Types'
import { isJidGroup, jidNormalizedUser } from '../WABinary' import { isJidGroup, isJidStatusBroadcast, jidNormalizedUser } from '../WABinary'
import { sha256 } from './crypto' import { sha256 } from './crypto'
import { generateMessageID, getKeyAuthor, unixTimestampSeconds } from './generics' import { generateMessageID, getKeyAuthor, unixTimestampSeconds } from './generics'
import { downloadContentFromMessage, encryptedStream, generateThumbnail, getAudioDuration, getAudioWaveform, MediaDownloadOptions } from './messages-media' import { downloadContentFromMessage, encryptedStream, generateThumbnail, getAudioDuration, getAudioWaveform, MediaDownloadOptions } from './messages-media'
@@ -31,7 +31,7 @@ import { downloadContentFromMessage, encryptedStream, generateThumbnail, getAudi
type MediaUploadData = { type MediaUploadData = {
media: WAMediaUpload media: WAMediaUpload
caption?: string caption?: string
ptt?: boolean ptt?: boolean | string
seconds?: number seconds?: number
gifPlayback?: boolean gifPlayback?: boolean
fileName?: string fileName?: string
@@ -40,6 +40,7 @@ type MediaUploadData = {
width?: number width?: number
height?: number height?: number
waveform?: Uint8Array waveform?: Uint8Array
backgroundArgb?: number
} }
const MIMETYPE_MAP: { [T in MediaType]?: string } = { const MIMETYPE_MAP: { [T in MediaType]?: string } = {
@@ -82,6 +83,21 @@ export const generateLinkPreviewIfRequired = async(text: string, getUrlInfo: Mes
} }
} }
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( export const prepareWAMessageMedia = async(
message: AnyMediaMessageContent, message: AnyMediaMessageContent,
options: MediaGenerationOptions options: MediaGenerationOptions
@@ -139,7 +155,8 @@ export const prepareWAMessageMedia = async(
const requiresDurationComputation = mediaType === 'audio' && typeof uploadData.seconds === 'undefined' const requiresDurationComputation = mediaType === 'audio' && typeof uploadData.seconds === 'undefined'
const requiresThumbnailComputation = (mediaType === 'image' || mediaType === 'video') && const requiresThumbnailComputation = (mediaType === 'image' || mediaType === 'video') &&
(typeof uploadData['jpegThumbnail'] === 'undefined') (typeof uploadData['jpegThumbnail'] === 'undefined')
const requiresWaveformProcessing = mediaType === 'audio' && uploadData?.ptt === true const requiresWaveformProcessing = mediaType === 'audio' && uploadData.ptt === 'true' || true
const requiresAudioBackground = options.backgroundColor && mediaType === 'audio' && uploadData.ptt === 'true' || true
const requiresOriginalForSomeProcessing = requiresDurationComputation || requiresThumbnailComputation const requiresOriginalForSomeProcessing = requiresDurationComputation || requiresThumbnailComputation
const { const {
mediaKey, mediaKey,
@@ -195,6 +212,16 @@ export const prepareWAMessageMedia = async(
uploadData.waveform = await getAudioWaveform(bodyPath!, logger) uploadData.waveform = await getAudioWaveform(bodyPath!, logger)
logger?.debug('processed waveform') logger?.debug('processed waveform')
} }
if(requiresWaveformProcessing) {
uploadData.waveform = await getAudioWaveform(bodyPath!, logger)
logger?.debug('processed waveform')
}
if(requiresAudioBackground) {
uploadData.backgroundArgb = await assertColor(options.backgroundColor)
logger?.debug('computed backgroundColor audio status')
}
} catch(error) { } catch(error) {
logger?.warn({ trace: error.stack }, 'failed to obtain extra info') logger?.warn({ trace: error.stack }, 'failed to obtain extra info')
} }
@@ -321,6 +348,14 @@ export const generateWAMessageContent = async(
} }
} }
if(options.backgroundColor) {
extContent.backgroundArgb = await assertColor(options.backgroundColor)
}
if(options.font) {
extContent.font = options.font
}
m.extendedTextMessage = extContent m.extendedTextMessage = extContent
} else if('contacts' in message) { } else if('contacts' in message) {
const contactLen = message.contacts.contacts.length const contactLen = message.contacts.contacts.length
@@ -583,7 +618,7 @@ export const generateWAMessageFromContent = (
message: message, message: message,
messageTimestamp: timestamp, messageTimestamp: timestamp,
messageStubParameters: [], messageStubParameters: [],
participant: isJidGroup(jid) ? userJid : undefined, participant: isJidGroup(jid) || isJidStatusBroadcast(jid) ? userJid : undefined,
status: WAMessageStatus.PENDING status: WAMessageStatus.PENDING
} }
return WAProto.WebMessageInfo.fromObject(messageJSON) return WAProto.WebMessageInfo.fromObject(messageJSON)

View File

@@ -7704,4 +7704,4 @@ yargs@^16.2.0:
yn@3.1.1: yn@3.1.1:
version "3.1.1" version "3.1.1"
resolved "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz" resolved "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz"
integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==