PTT Audio + Automatic link preview generation + Minor changes

This commit is contained in:
Adhiraj
2020-09-22 22:50:29 +05:30
parent e880556115
commit 30cee92758
9 changed files with 104 additions and 74 deletions

1
.gitignore vendored
View File

@@ -12,3 +12,4 @@ lib
docs docs
browser-token.json browser-token.json
auth_info_messcat.json auth_info_messcat.json
Proxy

View File

@@ -223,7 +223,11 @@ To note:
mimetype: Mimetype.pdf, /* (for media messages) specify the type of media (optional for all media types except documents), mimetype: Mimetype.pdf, /* (for media messages) specify the type of media (optional for all media types except documents),
import {Mimetype} from '@adiwajshing/baileys' import {Mimetype} from '@adiwajshing/baileys'
*/ */
filename: 'somefile.pdf' // (for media messages) file name for the media filename: 'somefile.pdf', // (for media messages) file name for the media
/* will send audio messages as voice notes, if set to true */
ptt: true,
// will detect links & generate a link preview automatically (default true)
detectLinks: true
} }
``` ```
## Forwarding Messages ## Forwarding Messages

View File

@@ -18,11 +18,11 @@ WAConnectionTest('Messages', conn => {
assert.equal (content?.contextInfo?.isForwarded, true) assert.equal (content?.contextInfo?.isForwarded, true)
}) })
it('should send a link preview', async () => { it('should send a link preview', async () => {
const content = await conn.generateLinkPreview ('hello this is from https://www.github.com/adiwajshing/Baileys') const text = 'hello this is from https://www.github.com/adiwajshing/Baileys'
const message = await sendAndRetreiveMessage(conn, content, MessageType.text) const message = await sendAndRetreiveMessage(conn, text, MessageType.text, { detectLinks: true })
const received = message.message.extendedTextMessage const received = message.message.extendedTextMessage
assert.strictEqual(received.text, content.text) assert.strictEqual(received.text, text)
assert.ok (received.canonicalUrl) assert.ok (received.canonicalUrl)
assert.ok (received.title) assert.ok (received.title)
assert.ok (received.description) assert.ok (received.description)
@@ -58,6 +58,13 @@ WAConnectionTest('Messages', conn => {
await conn.downloadAndSaveMediaMessage(message,'./Media/received_aud') await conn.downloadAndSaveMediaMessage(message,'./Media/received_aud')
}) })
it('should send an audio as a voice note', async () => {
const content = await fs.readFile('./Media/sonata.mp3')
const message = await sendAndRetreiveMessage(conn, content, MessageType.audio, { mimetype: Mimetype.ogg, ptt: true })
assert.equal (message.message?.audioMessage?.ptt, true)
await conn.downloadAndSaveMediaMessage(message,'./Media/received_aud')
})
it('should send an image', async () => { it('should send an image', async () => {
const content = await fs.readFile('./Media/meme.jpeg') const content = await fs.readFile('./Media/meme.jpeg')
const message = await sendAndRetreiveMessage(conn, content, MessageType.image) const message = await sendAndRetreiveMessage(conn, content, MessageType.image)

View File

@@ -26,7 +26,7 @@ import {
} from './Constants' } from './Constants'
import { EventEmitter } from 'events' import { EventEmitter } from 'events'
import KeyedDB from '@adiwajshing/keyed-db' import KeyedDB from '@adiwajshing/keyed-db'
import { STATUS_CODES } from 'http' import { STATUS_CODES, Agent } from 'http'
export class WAConnection extends EventEmitter { export class WAConnection extends EventEmitter {
/** The version of WhatsApp Web we're telling the servers we are */ /** The version of WhatsApp Web we're telling the servers we are */
@@ -341,6 +341,8 @@ export class WAConnection extends EventEmitter {
} }
protected endConnection () { protected endConnection () {
this.conn?.removeAllListeners ('close') this.conn?.removeAllListeners ('close')
this.conn?.removeAllListeners ('error')
this.conn?.removeAllListeners ('open')
this.conn?.removeAllListeners ('message') this.conn?.removeAllListeners ('message')
//this.conn?.close () //this.conn?.close ()
this.conn?.terminate() this.conn?.terminate()
@@ -358,12 +360,12 @@ export class WAConnection extends EventEmitter {
/** /**
* Does a fetch request with the configuration of the connection * Does a fetch request with the configuration of the connection
*/ */
protected fetchRequest = (endpoint: string, method: string = 'GET', body?: any) => ( protected fetchRequest = (endpoint: string, method: string = 'GET', body?: any, agent?: Agent) => (
fetch(endpoint, { fetch(endpoint, {
method, method,
body, body,
headers: { Origin: DEFAULT_ORIGIN }, headers: { Origin: DEFAULT_ORIGIN },
agent: this.connectOptions.agent agent: agent || this.connectOptions.agent
}) })
) )
generateMessageTag (longTag: boolean = false) { generateMessageTag (longTag: boolean = false) {

View File

@@ -35,14 +35,15 @@ export class WAConnection extends Base {
const loggedOut = error instanceof BaileysError && UNAUTHORIZED_CODES.includes(error.status) const loggedOut = error instanceof BaileysError && UNAUTHORIZED_CODES.includes(error.status)
const willReconnect = !loggedOut && (tries <= (options?.maxRetries || 5)) && this.state === 'connecting' const willReconnect = !loggedOut && (tries <= (options?.maxRetries || 5)) && this.state === 'connecting'
const reason = loggedOut ? DisconnectReason.invalidSession : error.message
this.log (`connect attempt ${tries} failed: ${error}${ willReconnect ? ', retrying...' : ''}`, MessageLogLevel.info) this.log (`connect attempt ${tries} failed: ${error}${ willReconnect ? ', retrying...' : ''}`, MessageLogLevel.info)
if ((this.state as string) !== 'close' && !willReconnect) { if ((this.state as string) !== 'close' && !willReconnect) {
this.closeInternal (loggedOut ? DisconnectReason.invalidSession : error.message) this.closeInternal (reason)
} }
if (!willReconnect) throw error if (!willReconnect) throw error
this.emit ('intermediate-close', {reason})
} }
} }
@@ -67,19 +68,12 @@ export class WAConnection extends Base {
let cancel: () => void let cancel: () => void
const task = Utils.promiseTimeout(timeoutMs, (resolve, reject) => { const task = Utils.promiseTimeout(timeoutMs, (resolve, reject) => {
let task: Promise<any> = Promise.resolve () cancel = () => reject (CancelledError())
const checkIdleTime = () => { const checkIdleTime = () => {
this.debounceTimeout && clearTimeout (this.debounceTimeout) this.debounceTimeout && clearTimeout (this.debounceTimeout)
this.debounceTimeout = setTimeout (() => rejectSafe (TimedOutError()), this.connectOptions.maxIdleTimeMs) this.debounceTimeout = setTimeout (() => rejectSafe (TimedOutError()), this.connectOptions.maxIdleTimeMs)
} }
const debouncedTimeout = () => this.connectOptions.maxIdleTimeMs && this.conn.addEventListener ('message', checkIdleTime) const debouncedTimeout = () => this.connectOptions.maxIdleTimeMs && this.conn.addEventListener ('message', checkIdleTime)
// add wait for chats promise if required
if (typeof options?.waitForChats === 'undefined' ? true : options?.waitForChats) {
const {waitForChats, cancelChats} = this.receiveChatsAndContacts(this.connectOptions.waitOnlyForLastMessage)
task = waitForChats
cancel = cancelChats
}
// determine whether reconnect should be used or not // determine whether reconnect should be used or not
const shouldUseReconnect = this.lastDisconnectReason !== DisconnectReason.replaced && const shouldUseReconnect = this.lastDisconnectReason !== DisconnectReason.replaced &&
this.lastDisconnectReason !== DisconnectReason.unknown && this.lastDisconnectReason !== DisconnectReason.unknown &&
@@ -93,38 +87,34 @@ export class WAConnection extends Base {
this.conn.on ('open', async () => { this.conn.on ('open', async () => {
this.log(`connected to WhatsApp Web server, authenticating via ${reconnectID ? 'reconnect' : 'takeover'}`, MessageLogLevel.info) this.log(`connected to WhatsApp Web server, authenticating via ${reconnectID ? 'reconnect' : 'takeover'}`, MessageLogLevel.info)
let waitForChats: Promise<{[k: string]: Partial<WAChat>}>
// add wait for chats promise if required
if (typeof options?.waitForChats === 'undefined' ? true : options?.waitForChats) {
const recv = this.receiveChatsAndContacts(this.connectOptions.waitOnlyForLastMessage)
waitForChats = recv.waitForChats
cancel = () => {
reject (CancelledError())
recv.cancelChats ()
}
}
try { try {
task = Promise.all ([ await this.authenticate (debouncedTimeout, reconnectID)
task, this.conn
// debounce the timeout once validated .removeAllListeners ('error')
this.authenticate (debouncedTimeout, reconnectID) .removeAllListeners ('close')
.then ( const result = waitForChats && (await waitForChats)
() => {
this.conn
.removeAllListeners ('error')
.removeAllListeners ('close')
}
)
])
const [result] = await task
this.conn.removeEventListener ('message', checkIdleTime) this.conn.removeEventListener ('message', checkIdleTime)
resolve (result) resolve (result)
} catch (error) { } catch (error) {
reject (error) reject (error)
} }
}) })
const rejectSafe = error => { const rejectSafe = error => reject (error)
task = task.catch (() => {})
reject (error)
}
this.conn.on('error', rejectSafe) this.conn.on('error', rejectSafe)
this.conn.on('close', () => rejectSafe(new Error('close'))) this.conn.on('close', () => rejectSafe(new Error('close')))
}) as Promise<void | { [k: string]: Partial<WAChat> }> }) as Promise<void | { [k: string]: Partial<WAChat> }>
return { promise: task, cancel } return { promise: task, cancel: cancel }
} }
let promise = Promise.resolve () let promise = Promise.resolve ()

View File

@@ -321,6 +321,8 @@ export class WAConnection extends Base {
on (event: 'connecting', listener: () => void): this on (event: 'connecting', listener: () => void): this
/** when the connection has closed */ /** when the connection has closed */
on (event: 'close', listener: (err: {reason?: DisconnectReason | string, isReconnecting: boolean}) => void): this on (event: 'close', listener: (err: {reason?: DisconnectReason | string, isReconnecting: boolean}) => void): this
/** when the connection has closed */
on (event: 'intermediate-close', listener: (err: {reason?: DisconnectReason | string}) => void): this
/** when a new QR is generated, ready for scanning */ /** when a new QR is generated, ready for scanning */
on (event: 'qr', listener: (qr: string) => void): this on (event: 'qr', listener: (qr: string) => void): this
/** when the connection to the phone changes */ /** when the connection to the phone changes */

View File

@@ -9,7 +9,7 @@ import {
WALocationMessage, WALocationMessage,
WAContactMessage, WAContactMessage,
WATextMessage, WATextMessage,
WAMessageContent, WAMetric, WAFlag, WAMessage, BaileysError, MessageLogLevel, WA_MESSAGE_STATUS_TYPE, WAMessageProto, MediaConnInfo, MessageTypeProto WAMessageContent, WAMetric, WAFlag, WAMessage, BaileysError, MessageLogLevel, WA_MESSAGE_STATUS_TYPE, WAMessageProto, MediaConnInfo, MessageTypeProto, URL_REGEX, WAUrlInfo
} from './Constants' } from './Constants'
import { generateMessageID, sha256, hmacSign, aesEncrypWithIV, randomBytes, generateThumbnail, getMediaKeys, decodeMediaMessageBuffer, extensionForMediaMessage, whatsappID, unixTimestampSeconds } from './Utils' import { generateMessageID, sha256, hmacSign, aesEncrypWithIV, randomBytes, generateThumbnail, getMediaKeys, decodeMediaMessageBuffer, extensionForMediaMessage, whatsappID, unixTimestampSeconds } from './Utils'
import { Mutex } from './Mutex' import { Mutex } from './Mutex'
@@ -53,9 +53,14 @@ export class WAConnection extends Base {
switch (type) { switch (type) {
case MessageType.text: case MessageType.text:
case MessageType.extendedText: case MessageType.extendedText:
if (typeof message === 'string') { if (typeof message === 'string') message = {text: message} as WATextMessage
m.extendedTextMessage = WAMessageProto.ExtendedTextMessage.create({text: message} as any)
} else if ('text' in message) { if ('text' in message) {
if (options.detectLinks !== false && message.text.match(URL_REGEX)) {
try {
message = await this.generateLinkPreview (message.text)
} catch { } // ignore if fails
}
m.extendedTextMessage = WAMessageProto.ExtendedTextMessage.create(message as any) m.extendedTextMessage = WAMessageProto.ExtendedTextMessage.create(message as any)
} else { } else {
throw new BaileysError ('message needs to be a string or object with property \'text\'', message) throw new BaileysError ('message needs to be a string or object with property \'text\'', message)
@@ -98,7 +103,8 @@ export class WAConnection extends Base {
const body = Buffer.concat([enc, mac]) // body is enc + mac const body = Buffer.concat([enc, mac]) // body is enc + mac
const fileSha256 = sha256(buffer) const fileSha256 = sha256(buffer)
// url safe Base64 encode the SHA256 hash of the body // url safe Base64 encode the SHA256 hash of the body
const fileEncSha256B64 = sha256(body) const fileEncSha256 = sha256(body)
const fileEncSha256B64 = fileEncSha256
.toString('base64') .toString('base64')
.replace(/\+/g, '-') .replace(/\+/g, '-')
.replace(/\//g, '_') .replace(/\//g, '_')
@@ -114,11 +120,14 @@ export class WAConnection extends Base {
for (let host of json.hosts) { for (let host of json.hosts) {
const hostname = `https://${host.hostname}${MediaPathMap[mediaType]}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}` const hostname = `https://${host.hostname}${MediaPathMap[mediaType]}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}`
try { try {
const urlFetch = await this.fetchRequest(hostname, 'POST', body) const urlFetch = await this.fetchRequest(hostname, 'POST', body, options.uploadAgent)
mediaUrl = (await urlFetch.json())?.url mediaUrl = (await urlFetch.json())?.url
if (mediaUrl) break if (mediaUrl) break
else throw new Error (`upload failed`) else {
await this.refreshMediaConn (true)
throw new Error (`upload failed`)
}
} catch (error) { } catch (error) {
const isLast = host.hostname === json.hosts[json.hosts.length-1].hostname const isLast = host.hostname === json.hosts[json.hosts.length-1].hostname
this.log (`Error in uploading to ${host.hostname}${isLast ? '' : ', retrying...'}`, MessageLogLevel.info) this.log (`Error in uploading to ${host.hostname}${isLast ? '' : ', retrying...'}`, MessageLogLevel.info)
@@ -132,12 +141,13 @@ export class WAConnection extends Base {
url: mediaUrl, url: mediaUrl,
mediaKey: mediaKey, mediaKey: mediaKey,
mimetype: options.mimetype, mimetype: options.mimetype,
fileEncSha256: sha256(body),//fileEncSha256B64, fileEncSha256: fileEncSha256,
fileSha256: fileSha256, fileSha256: fileSha256,
fileLength: buffer.length, fileLength: buffer.length,
fileName: options.filename || 'file', fileName: options.filename || 'file',
gifPlayback: isGIF || null, gifPlayback: isGIF || undefined,
caption: options.caption, caption: options.caption,
ptt: options.ptt
} }
) )
} }
@@ -244,9 +254,26 @@ export class WAConnection extends Base {
await fs.writeFile (trueFileName, buffer) await fs.writeFile (trueFileName, buffer)
return trueFileName return trueFileName
} }
/** Query a string to check if it has a url, if it does, return required extended text message */
async generateLinkPreview (text: string) {
const query = ['query', {type: 'url', url: text, epoch: this.msgCount.toString()}, null]
const response = await this.query ({json: query, binaryTags: [26, WAFlag.ignore], expect200: true})
protected async refreshMediaConn () { if (response[1]) response[1].jpegThumbnail = response[2]
if (!this.mediaConn || (new Date().getTime()-this.mediaConn.fetchDate.getTime()) > this.mediaConn.ttl*1000) { const data = response[1] as WAUrlInfo
const content = {text} as WATextMessage
content.canonicalUrl = data['canonical-url']
content.matchedText = data['matched-text']
content.jpegThumbnail = data.jpegThumbnail
content.description = data.description
content.title = data.title
content.previewType = 0
return content
}
protected async refreshMediaConn (forceGet = false) {
if (!this.mediaConn || (new Date().getTime()-this.mediaConn.fetchDate.getTime()) > this.mediaConn.ttl*1000 || forceGet) {
const result = await this.query({json: ['query', 'mediaConn']}) const result = await this.query({json: ['query', 'mediaConn']})
this.mediaConn = result.media_conn this.mediaConn = result.media_conn
this.mediaConn.fetchDate = new Date() this.mediaConn.fetchDate = new Date()

View File

@@ -1,12 +1,5 @@
import {WAConnection as Base} from './6.MessagesSend' import {WAConnection as Base} from './6.MessagesSend'
import { import { MessageType, WAMessageKey, MessageInfo, WAMessageContent, WAMetric, WAFlag, WANode, WAMessage, WAMessageProto } from './Constants'
MessageType,
WAMessageKey,
MessageInfo,
WATextMessage,
WAUrlInfo,
WAMessageContent, WAMetric, WAFlag, WANode, WAMessage, WAMessageProto, BaileysError, MessageLogLevel, WA_MESSAGE_STATUS_TYPE
} from './Constants'
import { whatsappID, delay, toNumber, unixTimestampSeconds } from './Utils' import { whatsappID, delay, toNumber, unixTimestampSeconds } from './Utils'
import { Mutex } from './Mutex' import { Mutex } from './Mutex'
@@ -211,23 +204,6 @@ export class WAConnection extends Base {
const actual = await this.loadMessages (jid, 1, messages[0] && messages[0].key, false) const actual = await this.loadMessages (jid, 1, messages[0] && messages[0].key, false)
return actual.messages[0] return actual.messages[0]
} }
/** Query a string to check if it has a url, if it does, return required extended text message */
async generateLinkPreview (text: string) {
const query = ['query', {type: 'url', url: text, epoch: this.msgCount.toString()}, null]
const response = await this.query ({json: query, binaryTags: [26, WAFlag.ignore], expect200: true})
if (response[1]) response[1].jpegThumbnail = response[2]
const data = response[1] as WAUrlInfo
const content = {text} as WATextMessage
content.canonicalUrl = data['canonical-url']
content.matchedText = data['matched-text']
content.jpegThumbnail = data.jpegThumbnail
content.description = data.description
content.title = data.title
content.previewType = 0
return content
}
/** /**
* Search WhatsApp messages with a given text string * Search WhatsApp messages with a given text string
* @param txt the search string * @param txt the search string

View File

@@ -82,6 +82,8 @@ export type WAConnectOptions = {
/** agent which can be used for proxying connections */ /** agent which can be used for proxying connections */
agent?: Agent agent?: Agent
} }
/** from: https://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url */
export const URL_REGEX = /[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)?/gi
export type WAConnectionState = 'open' | 'connecting' | 'close' export type WAConnectionState = 'open' | 'connecting' | 'close'
@@ -166,6 +168,7 @@ export interface WAGroupModification {
} }
export interface WAContact { export interface WAContact {
verify?: string
/** name of the contact, the contact has set on their own on WA */ /** name of the contact, the contact has set on their own on WA */
notify?: string notify?: string
jid: string jid: string
@@ -302,13 +305,30 @@ export enum Mimetype {
webp = 'image/webp', webp = 'image/webp',
} }
export interface MessageOptions { export interface MessageOptions {
/** the message you want to quote */
quoted?: WAMessage quoted?: WAMessage
/** some random context info (can show a forwarded message with this too) */
contextInfo?: WAContextInfo contextInfo?: WAContextInfo
/** optional, if you want to manually set the timestamp of the message */
timestamp?: Date timestamp?: Date
/** (for media messages) the caption to send with the media (cannot be sent with stickers though) */
caption?: string caption?: string
/**
* For location & media messages -- has to be a base 64 encoded JPEG if you want to send a custom thumb,
* or set to null if you don't want to send a thumbnail.
* Do not enter this field if you want to automatically generate a thumb
* */
thumbnail?: string thumbnail?: string
/** (for media messages) specify the type of media (optional for all media types except documents) */
mimetype?: Mimetype | string mimetype?: Mimetype | string
/** (for media messages) file name for the media */
filename?: string filename?: string
/** For audio messages, if set to true, will send as a `voice note` */
ptt?: boolean
/** Optional agent for media uploads */
uploadAgent?: Agent
/** If set to true (default), automatically detects if you're sending a link & attaches the preview*/
detectLinks?: boolean
} }
export interface WABroadcastListInfo { export interface WABroadcastListInfo {
status: number status: number
@@ -388,6 +408,7 @@ export type BaileysEvent =
'open' | 'open' |
'connecting' | 'connecting' |
'close' | 'close' |
'intermediate-close' |
'qr' | 'qr' |
'connection-phone-change' | 'connection-phone-change' |
'user-presence-update' | 'user-presence-update' |