From 30cee92758874c2398f7ce91f28bf4741e555f59 Mon Sep 17 00:00:00 2001 From: Adhiraj Date: Tue, 22 Sep 2020 22:50:29 +0530 Subject: [PATCH] PTT Audio + Automatic link preview generation + Minor changes --- .gitignore | 1 + README.md | 6 +++- src/Tests/Tests.Messages.ts | 13 ++++++-- src/WAConnection/0.Base.ts | 8 +++-- src/WAConnection/3.Connect.ts | 52 ++++++++++++----------------- src/WAConnection/4.Events.ts | 2 ++ src/WAConnection/6.MessagesSend.ts | 49 +++++++++++++++++++++------ src/WAConnection/7.MessagesExtra.ts | 26 +-------------- src/WAConnection/Constants.ts | 21 ++++++++++++ 9 files changed, 104 insertions(+), 74 deletions(-) diff --git a/.gitignore b/.gitignore index e410cec..ab30d05 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ lib docs browser-token.json auth_info_messcat.json +Proxy \ No newline at end of file diff --git a/README.md b/README.md index 8259733..eae967e 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,11 @@ To note: mimetype: Mimetype.pdf, /* (for media messages) specify the type of media (optional for all media types except documents), 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 diff --git a/src/Tests/Tests.Messages.ts b/src/Tests/Tests.Messages.ts index adb230b..f7ae2f5 100644 --- a/src/Tests/Tests.Messages.ts +++ b/src/Tests/Tests.Messages.ts @@ -18,11 +18,11 @@ WAConnectionTest('Messages', conn => { assert.equal (content?.contextInfo?.isForwarded, true) }) it('should send a link preview', async () => { - const content = await conn.generateLinkPreview ('hello this is from https://www.github.com/adiwajshing/Baileys') - const message = await sendAndRetreiveMessage(conn, content, MessageType.text) + const text = 'hello this is from https://www.github.com/adiwajshing/Baileys' + const message = await sendAndRetreiveMessage(conn, text, MessageType.text, { detectLinks: true }) const received = message.message.extendedTextMessage - assert.strictEqual(received.text, content.text) + assert.strictEqual(received.text, text) assert.ok (received.canonicalUrl) assert.ok (received.title) assert.ok (received.description) @@ -58,6 +58,13 @@ WAConnectionTest('Messages', conn => { 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 () => { const content = await fs.readFile('./Media/meme.jpeg') const message = await sendAndRetreiveMessage(conn, content, MessageType.image) diff --git a/src/WAConnection/0.Base.ts b/src/WAConnection/0.Base.ts index 7e39a50..92bc2fe 100644 --- a/src/WAConnection/0.Base.ts +++ b/src/WAConnection/0.Base.ts @@ -26,7 +26,7 @@ import { } from './Constants' import { EventEmitter } from 'events' import KeyedDB from '@adiwajshing/keyed-db' -import { STATUS_CODES } from 'http' +import { STATUS_CODES, Agent } from 'http' export class WAConnection extends EventEmitter { /** The version of WhatsApp Web we're telling the servers we are */ @@ -341,6 +341,8 @@ export class WAConnection extends EventEmitter { } protected endConnection () { this.conn?.removeAllListeners ('close') + this.conn?.removeAllListeners ('error') + this.conn?.removeAllListeners ('open') this.conn?.removeAllListeners ('message') //this.conn?.close () this.conn?.terminate() @@ -358,12 +360,12 @@ export class WAConnection extends EventEmitter { /** * 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, { method, body, headers: { Origin: DEFAULT_ORIGIN }, - agent: this.connectOptions.agent + agent: agent || this.connectOptions.agent }) ) generateMessageTag (longTag: boolean = false) { diff --git a/src/WAConnection/3.Connect.ts b/src/WAConnection/3.Connect.ts index 5eebe51..87b87de 100644 --- a/src/WAConnection/3.Connect.ts +++ b/src/WAConnection/3.Connect.ts @@ -35,14 +35,15 @@ export class WAConnection extends Base { const loggedOut = error instanceof BaileysError && UNAUTHORIZED_CODES.includes(error.status) 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) if ((this.state as string) !== 'close' && !willReconnect) { - this.closeInternal (loggedOut ? DisconnectReason.invalidSession : error.message) + this.closeInternal (reason) } - if (!willReconnect) throw error + this.emit ('intermediate-close', {reason}) } } @@ -67,19 +68,12 @@ export class WAConnection extends Base { let cancel: () => void const task = Utils.promiseTimeout(timeoutMs, (resolve, reject) => { - let task: Promise = Promise.resolve () + cancel = () => reject (CancelledError()) const checkIdleTime = () => { this.debounceTimeout && clearTimeout (this.debounceTimeout) this.debounceTimeout = setTimeout (() => rejectSafe (TimedOutError()), this.connectOptions.maxIdleTimeMs) } 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 const shouldUseReconnect = this.lastDisconnectReason !== DisconnectReason.replaced && this.lastDisconnectReason !== DisconnectReason.unknown && @@ -93,38 +87,34 @@ export class WAConnection extends Base { this.conn.on ('open', async () => { this.log(`connected to WhatsApp Web server, authenticating via ${reconnectID ? 'reconnect' : 'takeover'}`, MessageLogLevel.info) - + let waitForChats: Promise<{[k: string]: Partial}> + // 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 { - task = Promise.all ([ - task, - // debounce the timeout once validated - this.authenticate (debouncedTimeout, reconnectID) - .then ( - () => { - this.conn - .removeAllListeners ('error') - .removeAllListeners ('close') - } - ) - ]) - const [result] = await task - + await this.authenticate (debouncedTimeout, reconnectID) + this.conn + .removeAllListeners ('error') + .removeAllListeners ('close') + const result = waitForChats && (await waitForChats) this.conn.removeEventListener ('message', checkIdleTime) - resolve (result) } catch (error) { reject (error) } }) - const rejectSafe = error => { - task = task.catch (() => {}) - reject (error) - } + const rejectSafe = error => reject (error) this.conn.on('error', rejectSafe) this.conn.on('close', () => rejectSafe(new Error('close'))) }) as Promise }> - return { promise: task, cancel } + return { promise: task, cancel: cancel } } let promise = Promise.resolve () diff --git a/src/WAConnection/4.Events.ts b/src/WAConnection/4.Events.ts index 6386ec3..45a6441 100644 --- a/src/WAConnection/4.Events.ts +++ b/src/WAConnection/4.Events.ts @@ -321,6 +321,8 @@ export class WAConnection extends Base { on (event: 'connecting', listener: () => void): this /** when the connection has closed */ 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 */ on (event: 'qr', listener: (qr: string) => void): this /** when the connection to the phone changes */ diff --git a/src/WAConnection/6.MessagesSend.ts b/src/WAConnection/6.MessagesSend.ts index 2dd158e..69f013f 100644 --- a/src/WAConnection/6.MessagesSend.ts +++ b/src/WAConnection/6.MessagesSend.ts @@ -9,7 +9,7 @@ import { WALocationMessage, WAContactMessage, 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' import { generateMessageID, sha256, hmacSign, aesEncrypWithIV, randomBytes, generateThumbnail, getMediaKeys, decodeMediaMessageBuffer, extensionForMediaMessage, whatsappID, unixTimestampSeconds } from './Utils' import { Mutex } from './Mutex' @@ -53,9 +53,14 @@ export class WAConnection extends Base { switch (type) { case MessageType.text: case MessageType.extendedText: - if (typeof message === 'string') { - m.extendedTextMessage = WAMessageProto.ExtendedTextMessage.create({text: message} as any) - } else if ('text' in message) { + if (typeof message === 'string') message = {text: message} as WATextMessage + + 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) } else { 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 fileSha256 = sha256(buffer) // url safe Base64 encode the SHA256 hash of the body - const fileEncSha256B64 = sha256(body) + const fileEncSha256 = sha256(body) + const fileEncSha256B64 = fileEncSha256 .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') @@ -114,11 +120,14 @@ export class WAConnection extends Base { for (let host of json.hosts) { const hostname = `https://${host.hostname}${MediaPathMap[mediaType]}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}` try { - const urlFetch = await this.fetchRequest(hostname, 'POST', body) + const urlFetch = await this.fetchRequest(hostname, 'POST', body, options.uploadAgent) mediaUrl = (await urlFetch.json())?.url if (mediaUrl) break - else throw new Error (`upload failed`) + else { + await this.refreshMediaConn (true) + throw new Error (`upload failed`) + } } catch (error) { const isLast = host.hostname === json.hosts[json.hosts.length-1].hostname this.log (`Error in uploading to ${host.hostname}${isLast ? '' : ', retrying...'}`, MessageLogLevel.info) @@ -132,12 +141,13 @@ export class WAConnection extends Base { url: mediaUrl, mediaKey: mediaKey, mimetype: options.mimetype, - fileEncSha256: sha256(body),//fileEncSha256B64, + fileEncSha256: fileEncSha256, fileSha256: fileSha256, fileLength: buffer.length, fileName: options.filename || 'file', - gifPlayback: isGIF || null, + gifPlayback: isGIF || undefined, caption: options.caption, + ptt: options.ptt } ) } @@ -244,9 +254,26 @@ export class WAConnection extends Base { await fs.writeFile (trueFileName, buffer) 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 (!this.mediaConn || (new Date().getTime()-this.mediaConn.fetchDate.getTime()) > this.mediaConn.ttl*1000) { + 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 + } + + 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']}) this.mediaConn = result.media_conn this.mediaConn.fetchDate = new Date() diff --git a/src/WAConnection/7.MessagesExtra.ts b/src/WAConnection/7.MessagesExtra.ts index ba46211..e0be2d1 100644 --- a/src/WAConnection/7.MessagesExtra.ts +++ b/src/WAConnection/7.MessagesExtra.ts @@ -1,12 +1,5 @@ import {WAConnection as Base} from './6.MessagesSend' -import { - MessageType, - WAMessageKey, - MessageInfo, - WATextMessage, - WAUrlInfo, - WAMessageContent, WAMetric, WAFlag, WANode, WAMessage, WAMessageProto, BaileysError, MessageLogLevel, WA_MESSAGE_STATUS_TYPE -} from './Constants' +import { MessageType, WAMessageKey, MessageInfo, WAMessageContent, WAMetric, WAFlag, WANode, WAMessage, WAMessageProto } from './Constants' import { whatsappID, delay, toNumber, unixTimestampSeconds } from './Utils' 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) 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 * @param txt the search string diff --git a/src/WAConnection/Constants.ts b/src/WAConnection/Constants.ts index 4020c54..e8604e2 100644 --- a/src/WAConnection/Constants.ts +++ b/src/WAConnection/Constants.ts @@ -82,6 +82,8 @@ export type WAConnectOptions = { /** agent which can be used for proxying connections */ 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' @@ -166,6 +168,7 @@ export interface WAGroupModification { } export interface WAContact { + verify?: string /** name of the contact, the contact has set on their own on WA */ notify?: string jid: string @@ -302,13 +305,30 @@ export enum Mimetype { webp = 'image/webp', } export interface MessageOptions { + /** the message you want to quote */ quoted?: WAMessage + /** some random context info (can show a forwarded message with this too) */ contextInfo?: WAContextInfo + /** optional, if you want to manually set the timestamp of the message */ timestamp?: Date + /** (for media messages) the caption to send with the media (cannot be sent with stickers though) */ 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 + /** (for media messages) specify the type of media (optional for all media types except documents) */ mimetype?: Mimetype | string + /** (for media messages) file name for the media */ 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 { status: number @@ -388,6 +408,7 @@ export type BaileysEvent = 'open' | 'connecting' | 'close' | + 'intermediate-close' | 'qr' | 'connection-phone-change' | 'user-presence-update' |