diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8dc8599..a5acae7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,6 +1,7 @@ name: CI -on: workflow_dispatch + +on: "workflow_dispatch" jobs: @@ -78,8 +79,7 @@ jobs: - name: Publish to Pages uses: crazy-max/ghaction-github-pages@v2 with: - target_branch: gh-pages - if-no-files-found: error + target_branch: gh-pages build_dir: docs env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -98,6 +98,10 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v2.1.1 + + - name: Debug Release Creation + run: "echo ${{ toJson(needs) }}" + - name: Create Release id: releaseCreate continue-on-error: true diff --git a/.gitignore b/.gitignore index d609a01..10f1c6b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,12 @@ node_modules auth_info.json output.csv -package-lock.json */.DS_Store .DS_Store .env auth_info_browser.json yarn.lock browser-messages.json -package-lock.json -package-lock.json decoded-ws.json auth_info2.json lib diff --git a/Example/example.ts b/Example/example.ts index 155b3ff..afbd600 100644 --- a/Example/example.ts +++ b/Example/example.ts @@ -6,40 +6,46 @@ import { Mimetype, WALocationMessage, MessageLogLevel, - WAMessageType, + WA_MESSAGE_STUB_TYPES, + ReconnectMode, } from '../src/WAConnection/WAConnection' import * as fs from 'fs' async function example() { const conn = new WAConnection() // instantiate - conn.autoReconnect = true // auto reconnect on disconnect + conn.autoReconnect = ReconnectMode.onConnectionLost // only automatically reconnect when the connection breaks conn.logLevel = MessageLogLevel.info // set to unhandled to see what kind of stuff you can implement - // connect or timeout in 20 seconds (loads the auth file credentials if present) - const [user, chats, contacts] = await conn.connect('./auth_info.json', 20 * 1000) - const unread = chats.all().flatMap (chat => chat.messages.slice(chat.messages.length-chat.count)) + // loads the auth file credentials if present + if (fs.existsSync('./auth_info.json')) conn.loadAuthInfo ('./auth_info.json') + conn.on ('qr', qr => console.log (qr)) + // connect or timeout in 30 seconds + await conn.connect({ timeoutMs: 30 * 1000 }) - console.log('oh hello ' + user.name + ' (' + user.id + ')') - console.log('you have ' + chats.all().length + ' chats & ' + contacts.length + ' contacts') + const unread = await conn.loadAllUnreadMessages () + + console.log('oh hello ' + conn.user.name + ' (' + conn.user.id + ')') + console.log('you have ' + conn.chats.all().length + ' chats & ' + Object.keys(conn.contacts).length + ' contacts') console.log ('you have ' + unread.length + ' unread messages') const authInfo = conn.base64EncodedAuthInfo() // get all the auth info we need to restore this session fs.writeFileSync('./auth_info.json', JSON.stringify(authInfo, null, '\t')) // save this info to a file /* Note: one can take this auth_info.json file and login again from any computer without having to scan the QR code, and get full access to one's WhatsApp. Despite the convenience, be careful with this file */ - conn.setOnPresenceUpdate(json => console.log(json.id + ' presence is ' + json.type)) - conn.setOnMessageStatusChange(json => { + conn.on ('user-presence-update', json => console.log(json.id + ' presence is ' + json.type)) + conn.on ('message-update', json => { const participant = json.participant ? ' (' + json.participant + ')' : '' // participant exists when the message is from a group console.log(`${json.to}${participant} acknlowledged message(s) ${json.ids} as ${json.type}`) }) // set to false to NOT relay your own sent messages - conn.setOnUnreadMessage(true, async (m) => { - const messageStubType = WAMessageType[m.messageStubType] || 'MESSAGE' + conn.on('message-new', async (m) => { + const messageStubType = WA_MESSAGE_STUB_TYPES[m.messageStubType] || 'MESSAGE' console.log('got notification of type: ' + messageStubType) const messageContent = m.message // if it is not a regular text or media message if (!messageContent) return + if (m.key.fromMe) { console.log('relayed my own message') return @@ -112,14 +118,9 @@ async function example() { const batterylevel = parseInt(batteryLevelStr) console.log('battery level: ' + batterylevel) }) - conn.setOnUnexpectedDisconnect(reason => { - if (reason === 'replaced') { - // uncomment to reconnect whenever the connection gets taken over from somewhere else - // await conn.connect () - } else { - console.log ('oh no got disconnected: ' + reason) - } - }) + conn.on('close', ({reason, isReconnecting}) => ( + console.log ('oh no got disconnected: ' + reason + ', reconnecting: ' + isReconnecting) + )) } example().catch((err) => console.log(`encountered error: ${err}`)) diff --git a/README.md b/README.md index 6647ba1..e666672 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Baileys does not require Selenium or any other browser to be interface with WhatsApp Web, it does so directly using a **WebSocket**. Not running Selenium or Chromimum saves you like **half a gig** of ram :/ - Thank you to [@Sigalor](https://github.com/sigalor/whatsapp-web-reveng) for writing the guide to reverse engineering WhatsApp Web and thanks to [@Rhymen](https://github.com/Rhymen/go-whatsapp/) for the __go__ implementation. + Thank you to [@Sigalor](https://github.com/sigalor/whatsapp-web-reveng) for writing his observations on the workings of WhatsApp Web and thanks to [@Rhymen](https://github.com/Rhymen/go-whatsapp/) for the __go__ implementation. Baileys is type-safe, extensible and simple to use. If you require more functionality than provided, it'll super easy for you to write an extension. More on this [here](#WritingCustomFunctionality). @@ -21,7 +21,9 @@ To run the example script, download or clone the repo and then type the followin ## Install Create and cd to your NPM project directory and then in terminal, write: 1. stable: `npm install @adiwajshing/baileys` -2. stabl-ish w quicker fixes & latest features: `npm install github:adiwajshing/baileys` (major changes incoming right now) +2. stabl-ish w quicker fixes & latest features: `npm install github:adiwajshing/baileys` + +Do note, the library will most likely vary if you're using the NPM package, read that [here](https://www.npmjs.com/package/@adiwajshing/baileys) Then import in your code using: ``` ts @@ -40,16 +42,14 @@ import { WAConnection } from '@adiwajshing/baileys' async function connectToWhatsApp () { const conn = new WAConnection() - const [user, chats, contacts] = await conn.connect () - console.log ("oh hello " + user.name + " (" + user.id + ")") - console.log ("you have " + chats.length + " chats") - + + // 20 second timeout + await conn.connect ({timeoutMs: 30*1000}) + console.log ("oh hello " + conn.user.name + " (" + conn.user.id + ")") // every chat object has a list of most recent messages - // can use that to retreive all your pending unread messages - // the 'count' property a chat object reflects the number of unread messages - // the 'count' property is -1 if the entire thread has been marked unread - const unread = chats.all().flatMap (chat => chat.messages.slice(chat.messages.length-chat.count)) + console.log ("you have " + conn.chats.all().length + " chats") + const unread = await conn.loadAllUnreadMessages () console.log ("you have " + unread.length + " unread messages") } @@ -59,23 +59,10 @@ connectToWhatsApp () ``` If the connection is successful, you will see a QR code printed on your terminal screen, scan it with WhatsApp on your phone and you'll be logged in! + If you don't want to wait for WhatsApp to send all your chats while connecting, you can use the following function: ``` ts -import { WAConnection } from '@adiwajshing/baileys' - -async function connectToWhatsApp () { - const conn = new WAConnection() - const user = await conn.connectSlim () - console.log ("oh hello " + user.name + " (" + user.id + ")") - - conn.receiveChatsAndContacts () // wait for chats & contacts in the background - .then (([chats, contacts]) => { - console.log ("you have " + chats.all().length + " chats and " + contacts.length + " contacts") - }) -} -// run in main file -connectToWhatsApp () -.catch (err => console.log("unexpected error: " + err) ) // catch any errors +await conn.connect ({timeoutMs: 30*1000}, false) ``` Do note, the `chats` object returned is now a [KeyedDB](https://github.com/adiwajshing/keyed-db). This is done for the following reasons: @@ -87,100 +74,96 @@ Do note, the `chats` object returned is now a [KeyedDB](https://github.com/adiwa You obviously don't want to keep scanning the QR code every time you want to connect. -So, do the following the first time you connect: +So, do the following every time you open a new connection: ``` ts import * as fs from 'fs' const conn = new WAConnection() -conn.connectSlim() // connect first -.then (user => { - const creds = conn.base64EncodedAuthInfo () // contains all the keys you need to restore a session - fs.writeFileSync('./auth_info.json', JSON.stringify(creds, null, '\t')) // save JSON to file -}) +await conn.connect() // connect first +const creds = conn.base64EncodedAuthInfo () // contains all the keys you need to restore a session +fs.writeFileSync('./auth_info.json', JSON.stringify(creds, null, '\t')) // save JSON to file ``` Then, to restore a session: ``` ts const conn = new WAConnection() -conn.connectSlim('./auth_info.json') // will load JSON credentials from file -.then (user => { - // yay connected without scanning QR -}) - +conn.loadAuthInfo ('./auth_info.json') // will load JSON credentials from file +await conn.connect() +// yay connected without scanning QR /* Optionally, you can load the credentials yourself from somewhere - & pass in the JSON object to connectSlim () as well. + & pass in the JSON object to loadAuthInfo () as well. */ ``` If you're considering switching from a Chromium/Puppeteer based library, you can use WhatsApp Web's Browser credentials to restore sessions too: ``` ts -conn.loadAuthInfoFromBrowser ('./auth_info_browser.json') -conn.connectSlim(null, 20*1000) // use loaded credentials & timeout in 20s -.then (user => { - // yay! connected using browser keys & without scanning QR -}) +conn.loadAuthInfo ('./auth_info_browser.json') // use loaded credentials & timeout in 20s +await conn.connect() // works the same ``` -See the browser credentials type [here](/src/WAConnection/Constants.ts). +See the browser credentials type in the docs. ## QR Overriding If you want to do some custom processing with the QR code used to authenticate, you can override the following method: ``` ts -conn.onReadyForPhoneAuthentication = ([ref, publicKey, clientID]) => { - const str = ref + ',' + publicKey + ',' + clientID // the QR string - // Now, use 'str' to display in QR UI or send somewhere +conn.on('qr', qr => { + // Now, use the 'qr' string to display in QR UI or send somewhere } -const user = await conn.connect () +await conn.connect () ``` -If you need to regenerate the QR, you can also do so using: +The QR will auto-regenerate and will fire a new `qr` event after 30 seconds, if you don't want to regenerate or want to change the re-gen interval: ``` ts -let generateQR: async () => void // call generateQR on some timeout or error -conn.onReadyForPhoneAuthentication = ([ref, publicKey, clientID]) => { - generateQR = async () => { - ref = await conn.generateNewQRCode () // returns a new ref code to use for QR generation - const str = ref + ',' + publicKey + ',' + clientID // the QR string - // re-print str as QR or update in UI or send somewhere - //QR.generate(str, { small: true }) - } -} -const user = await conn.connect () +conn.regenerateQRIntervalMs = null // no QR regen +conn.regenerateQRIntervalMs = 20000 // QR regen every 20 seconds ``` + ## Handling Events -Implement the following callbacks in your code: +Baileys now uses the EventEmitter syntax for events. +They're all nicely typed up, so you shouldn't have any issues with an Intellisense editor like VS Code. -- Called when you have a pending unread message or recieve a new message - ``` ts - import { getNotificationType } from '@adiwajshing/baileys' - // set first param to `true` if you want to receive outgoing messages that may be sent from your phone - conn.setOnUnreadMessage (false, (m: WAMessage) => { - // get what type of notification it is -- message, group add notification etc. - const [notificationType, messageType] = getNotificationType(m) +Also, these events are fired regardless of whether they are initiated by the Baileys client or are relayed from your phone. + +``` ts + +/** when the connection has opened successfully */ +on (event: 'open', listener: () => void): this +/** when the connection is opening */ +on (event: 'connecting', listener: () => void): this +/** when the connection has closed */ +on (event: 'close', listener: (err: {reason?: string, isReconnecting: boolean}) => 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 */ +on (event: 'connection-phone-change', listener: (state: {connected: boolean}) => void): this +/** when a user's presence is updated */ +on (event: 'user-presence-update', listener: (update: PresenceUpdate) => void): this +/** when a user's status is updated */ +on (event: 'user-status-update', listener: (update: {jid: string, status?: string}) => void): this +/** when a new chat is added */ +on (event: 'chat-new', listener: (chat: WAChat) => void): this +/** when a chat is updated (archived, deleted, pinned, read, unread, name changed) */ +on (event: 'chat-update', listener: (chat: Partial & { jid: string }) => void): this +/** when a new message is relayed */ +on (event: 'message-new', listener: (message: WAMessage) => void): this +/** when a message is updated (deleted, delivered, read) */ +on (event: 'message-update', listener: (message: WAMessage) => void): this +/** when participants are added to a group */ +on (event: 'group-participants-add', listener: (update: {jid: string, participants: string[], actor?: string}) => void): this +/** when participants are removed or leave from a group */ +on (event: 'group-participants-remove', listener: (update: {jid: string, participants: string[], actor?: string}) => void): this +/** when participants are promoted in a group */ +on (event: 'group-participants-promote', listener: (update: {jid: string, participants: string[], actor?: string}) => void): this +/** when participants are demoted in a group */ +on (event: 'group-participants-demote', listener: (update: {jid: string, participants: string[], actor?: string}) => void): this +/** when the group settings is updated */ +on (event: 'group-settings-update', listener: (update: {jid: string, restrict?: string, announce?: string, actor?: string}) => void): this +/** when the group description is updated */ +on (event: 'group-description-update', listener: (update: {jid: string, description?: string, actor?: string}) => void): this +``` - console.log("got notification of type: " + notificationType) // message, groupAdd, groupLeave - console.log("message type: " + messageType) // conversation, imageMessage, videoMessage, contactMessage etc. - }) - ``` -- Called when you recieve an update on someone's presence, they went offline or online - ``` ts - conn.setOnPresenceUpdate ((json: PresenceUpdate) => console.log(json.id + " presence is " + json.type)) - ``` -- Called when your message gets delivered or read - ``` ts - conn.setOnMessageStatusChange ((json: MessageStatusUpdate) => { - let sent = json.to - if (json.participant) // participant exists when the message is from a group - sent += " ("+json.participant+")" // mention as the one sent to - // log that they acknowledged the message - console.log(sent + " acknowledged message(s) " + json.ids + " as " + json.type + " at " + json.timestamp) - }) - ``` -- Called when the connection gets disconnected (either the server loses internet, the phone gets unpaired, or the connection is taken over from somewhere) - ``` ts - conn.setOnUnexpectedDisconnect (reason => console.log ("disconnected unexpectedly: " + reason) ) - ``` ## Sending Messages Send like, all types of messages with a single function: @@ -191,7 +174,7 @@ const id = 'abcd@s.whatsapp.net' // the WhatsApp ID // send a simple text! conn.sendMessage (id, 'oh hello there', MessageType.text) // send a location! -conn.sendMessage(id, {degreeslatitude: 24.121231, degreesLongitude: 55.1121221}, MessageType.location) +conn.sendMessage(id, {degreesLatitude: 24.121231, degreesLongitude: 55.1121221}, MessageType.location) // send a contact! const vcard = 'BEGIN:VCARD\n' // metadata of the contact card + 'VERSION:3.0\n' @@ -205,11 +188,10 @@ const buffer = fs.readFileSync("Media/ma_gif.mp4") // load some gif const options: MessageOptions = {mimetype: Mimetype.gif, caption: "hello!"} // some metadata & caption conn.sendMessage(id, buffer, MessageType.video, options) ``` + To note: - `id` is the WhatsApp ID of the person or group you're sending the message to. - It must be in the format ```[country code][phone number]@s.whatsapp.net```, for example ```+19999999999@s.whatsapp.net``` for people. For groups, it must be in the format ``` 123456789-123345@g.us ```. - - **Do not attach** `@c.us` for individual people IDs, It won't work. - - Please do not explicitly disable ID validation (in `MessageOptions`) because then your messages may fail for no apparent reason. - For media messages, the thumbnail can be generated automatically for images & stickers. Thumbnails for videos can also be generated automatically, though, you need to have `ffmpeg` installed on your system. - **MessageOptions**: some extra info about the message. It can have the following __optional__ values: ``` ts @@ -218,7 +200,6 @@ To note: contextInfo: { forwardingScore: 2, isForwarded: true }, // some random context info // (can show a forwarded message with this too) timestamp: Date(), // optional, if you want to manually set the timestamp of the message - validateID: true, // if you want to validate the ID before sending the message, true by default caption: "hello there!", // (for media messages) the caption to send with the media (cannot be sent with stickers though) thumbnail: "23GD#4/==", /* (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. @@ -239,45 +220,44 @@ await conn.forwardMessage ('455@s.whatsapp.net', message) // WA forward the mess ``` ## Reading Messages + ``` ts const id = '1234-123@g.us' const messageID = 'AHASHH123123AHGA' // id of the message you want to read -await conn.sendReadReceipt(id, messageID) // mark as read await conn.sendReadReceipt (id) // mark all messages in chat as read +await conn.sendReadReceipt(id, messageID, 1) // mark the mentioned message as read -await conn.sendReadReceipt(id, null, 'unread') // mark the chat as unread +await conn.sendReadReceipt(id, null, -2) // mark the chat as unread ``` -- `id` is in the same format as mentioned earlier. -- The message ID is the unique identifier of the message that you are marking as read. -- On a `WAMessage`, the `messageID` can be accessed using ```messageID = message.key.id```. +The message ID is the unique identifier of the message that you are marking as read. On a `WAMessage`, the `messageID` can be accessed using ```messageID = message.key.id```. ## Update Presence ``` ts import { Presence } from '@adiwajshing/baileys' +await conn.updatePresence(id, Presence.available) -conn.updatePresence(id, Presence.available) ``` This lets the person/group with ``` id ``` know whether you're online, offline, typing etc. where ``` presence ``` can be one of the following: ``` ts export enum Presence { available = 'available', // "online" - unavailable = 'unavailable', // "offline" composing = 'composing', // "typing..." recording = 'recording', // "recording..." - paused = 'paused', // I have no clue } ``` -## Downloading Media +The presence expires after about 10 seconds. + +## Downloading Media Messages + If you want to save the media you received ``` ts -import { MessageType, extensionForMediaMessage } from '@adiwajshing/baileys' -conn.setOnUnreadMessage (false, async m => { +import { MessageType } from '@adiwajshing/baileys' +conn.on ('message-new', async m => { if (!m.message) return // if there is no text or media message - const messageType = Object.keys (m.message)[0]// get what type of message it is -- text, image, video // if the message is not a text message if (messageType !== MessageType.text && messageType !== MessageType.extendedText) { @@ -307,21 +287,27 @@ await conn.modifyChat (jid, ChatModification.archive) // archive chat await conn.modifyChat (jid, ChatModification.unarchive) // unarchive chat const response = await conn.modifyChat (jid, ChatModification.pin) // pin the chat -await conn.modifyChat (jid, ChatModification.unpin, {stamp: response.stamp}) +await conn.modifyChat (jid, ChatModification.unpin) // unpin it -const mutedate = new Date (new Date().getTime() + 8*60*60*1000) // mute for 8 hours in the future -await conn.modifyChat (jid, ChatModification.mute, {stamp: mutedate}) // mute +await conn.modifyChat (jid, ChatModification.mute, 8*60*60*1000) // mute for 8 hours setTimeout (() => { - conn.modifyChat (jid, ChatModification.unmute, {stamp: mutedate}) + conn.modifyChat (jid, ChatModification.unmute) }, 5000) // unmute after 5 seconds -await conn.deleteChat (jid) // will delete the chat (can be a group or broadcast list) +await conn.deleteChat (jid) // will delete the chat (can be a group or broadcast list as well) ``` **Note:** to unmute or unpin a chat, one must pass the timestamp of the pinning or muting. This is returned by the pin & mute functions. This is also available in the `WAChat` objects of the respective chats, as a `mute` or `pin` property. ## Misc +- To load chats in a paginated manner + ``` ts + const {chats, cursor} = await conn.loadChats (25) + if (cursor) { + const moreChats = await conn.loadChats (25, cursor) // load the next 25 chats + } + ``` - To check if a given ID is on WhatsApp ``` ts const id = 'xyz@s.whatsapp.net' @@ -331,12 +317,12 @@ await conn.deleteChat (jid) // will delete the chat (can be a group or broadcast - To query chat history on a group or with someone ``` ts // query the last 25 messages (replace 25 with the number of messages you want to query) - const messages = await conn.loadConversation ("xyz-abc@g.us", 25) + const messages = await conn.loadMessages ("xyz-abc@g.us", 25) console.log("got back " + messages.length + " messages") ``` You can also load the entire conversation history if you want ``` ts - await conn.loadEntireConversation ("xyz@c.us", message => console.log("Loaded message with ID: " + message.key.id)) + await conn.loadAllMessages ("xyz@c.us", message => console.log("Loaded message with ID: " + message.key.id)) console.log("queried all messages") // promise resolves once all messages are retreived ``` - To get the status of some person @@ -353,12 +339,12 @@ await conn.deleteChat (jid) // will delete the chat (can be a group or broadcast ``` ts const jid = '111234567890-1594482450@g.us' // can be your own too const img = fs.readFileSync ('new-profile-picture.jpeg') // can be PNG also - await conn.updateProfilePicture (jid, newPP) + await conn.updateProfilePicture (jid, img) ``` - To get someone's presence (if they're typing, online) ``` ts // the presence update is fetched and called here - conn.setOnPresenceUpdate (json => console.log(json.id + " presence is " + json.type)) + conn.on ('user-presence-update', json => console.log(json.id + " presence is " + json.type)) await conn.requestPresenceUpdate ("xyz@c.us") // request the update ``` - To search through messages @@ -369,7 +355,6 @@ await conn.deleteChat (jid) // will delete the chat (can be a group or broadcast const response2 = await conn.searchMessages ('so cool', '1234@c.us', 25, 1) // search in given chat ``` Of course, replace ``` xyz ``` with an actual ID. -Append ``` @s.whatsapp.net ``` for individuals & ``` @g.us ``` for groups. ## Groups - To create a group @@ -472,7 +457,7 @@ This will enable you to see all sorts of messages WhatsApp sends in the console. ``` ts conn.registerCallback (["Conn", "pushname"], json => { const pushname = json[1].pushname - conn.userMetaData.name = pushname // update on client too + conn.user.name = pushname // update on client too console.log ("Name updated: " + pushname) }) ``` @@ -483,4 +468,4 @@ A little more testing will reveal that almost all WhatsApp messages are in the f Note: except for the first parameter (in the above cases, ```"action"``` or ```"Conn"```), all the other parameters are optional. ### Note - This library is in no way affiliated with WhatsApp. Use at your own discretion. Do not spam people with this. + This library was originally a project for **CS-2362 at Ashoka University** and is in no way affiliated with WhatsApp. Use at your own discretion. Do not spam people with this. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..aee24d6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2643 @@ +{ + "name": "@adiwajshing/baileys", + "version": "3.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@adiwajshing/keyed-db": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@adiwajshing/keyed-db/-/keyed-db-0.1.2.tgz", + "integrity": "sha512-BfC2gIXnppbnBc0p2pBl3Ia0GTMMJknBp54fYYCgK0S4zP3u92/b0ryvWHfCA6qrfkJoEMa3T0RlIK2pO/5SZg==" + }, + "@babel/runtime": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.4.tgz", + "integrity": "sha512-UpTN5yUJr9b4EX2CnGNWIvER7Ab83ibv0pcvvHc4UOdrBI5jb8bj+32cCwPX6xu0mt2daFNjYhoi+X7beH0RSw==", + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "@jimp/bmp": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/bmp/-/bmp-0.14.0.tgz", + "integrity": "sha512-5RkX6tSS7K3K3xNEb2ygPuvyL9whjanhoaB/WmmXlJS6ub4DjTqrapu8j4qnIWmO4YYtFeTbDTXV6v9P1yMA5A==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.14.0", + "bmp-js": "^0.1.0" + } + }, + "@jimp/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/core/-/core-0.14.0.tgz", + "integrity": "sha512-S62FcKdtLtj3yWsGfJRdFXSutjvHg7aQNiFogMbwq19RP4XJWqS2nOphu7ScB8KrSlyy5nPF2hkWNhLRLyD82w==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.14.0", + "any-base": "^1.1.0", + "buffer": "^5.2.0", + "exif-parser": "^0.1.12", + "file-type": "^9.0.0", + "load-bmfont": "^1.3.1", + "mkdirp": "^0.5.1", + "phin": "^2.9.1", + "pixelmatch": "^4.0.2", + "tinycolor2": "^1.4.1" + } + }, + "@jimp/custom": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.14.0.tgz", + "integrity": "sha512-kQJMeH87+kWJdVw8F9GQhtsageqqxrvzg7yyOw3Tx/s7v5RToe8RnKyMM+kVtBJtNAG+Xyv/z01uYQ2jiZ3GwA==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/core": "^0.14.0" + } + }, + "@jimp/gif": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/gif/-/gif-0.14.0.tgz", + "integrity": "sha512-DHjoOSfCaCz72+oGGEh8qH0zE6pUBaBxPxxmpYJjkNyDZP7RkbBkZJScIYeQ7BmJxmGN4/dZn+MxamoQlr+UYg==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.14.0", + "gifwrap": "^0.9.2", + "omggif": "^1.0.9" + } + }, + "@jimp/jpeg": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/jpeg/-/jpeg-0.14.0.tgz", + "integrity": "sha512-561neGbr+87S/YVQYnZSTyjWTHBm9F6F1obYHiyU3wVmF+1CLbxY3FQzt4YolwyQHIBv36Bo0PY2KkkU8BEeeQ==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.14.0", + "jpeg-js": "^0.4.0" + } + }, + "@jimp/plugin-blit": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-0.14.0.tgz", + "integrity": "sha512-YoYOrnVHeX3InfgbJawAU601iTZMwEBZkyqcP1V/S33Qnz9uzH1Uj1NtC6fNgWzvX6I4XbCWwtr4RrGFb5CFrw==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.14.0" + } + }, + "@jimp/plugin-blur": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-0.14.0.tgz", + "integrity": "sha512-9WhZcofLrT0hgI7t0chf7iBQZib//0gJh9WcQMUt5+Q1Bk04dWs8vTgLNj61GBqZXgHSPzE4OpCrrLDBG8zlhQ==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.14.0" + } + }, + "@jimp/plugin-circle": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-0.14.0.tgz", + "integrity": "sha512-o5L+wf6QA44tvTum5HeLyLSc5eVfIUd5ZDVi5iRfO4o6GT/zux9AxuTSkKwnjhsG8bn1dDmywAOQGAx7BjrQVA==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.14.0" + } + }, + "@jimp/plugin-color": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-0.14.0.tgz", + "integrity": "sha512-JJz512SAILYV0M5LzBb9sbOm/XEj2fGElMiHAxb7aLI6jx+n0agxtHpfpV/AePTLm1vzzDxx6AJxXbKv355hBQ==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.14.0", + "tinycolor2": "^1.4.1" + } + }, + "@jimp/plugin-contain": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-0.14.0.tgz", + "integrity": "sha512-RX2q233lGyaxiMY6kAgnm9ScmEkNSof0hdlaJAVDS1OgXphGAYAeSIAwzESZN4x3ORaWvkFefeVH9O9/698Evg==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.14.0" + } + }, + "@jimp/plugin-cover": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-0.14.0.tgz", + "integrity": "sha512-0P/5XhzWES4uMdvbi3beUgfvhn4YuQ/ny8ijs5kkYIw6K8mHcl820HahuGpwWMx56DJLHRl1hFhJwo9CeTRJtQ==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.14.0" + } + }, + "@jimp/plugin-crop": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-0.14.0.tgz", + "integrity": "sha512-Ojtih+XIe6/XSGtpWtbAXBozhCdsDMmy+THUJAGu2x7ZgKrMS0JotN+vN2YC3nwDpYkM+yOJImQeptSfZb2Sug==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.14.0" + } + }, + "@jimp/plugin-displace": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-0.14.0.tgz", + "integrity": "sha512-c75uQUzMgrHa8vegkgUvgRL/PRvD7paFbFJvzW0Ugs8Wl+CDMGIPYQ3j7IVaQkIS+cAxv+NJ3TIRBQyBrfVEOg==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.14.0" + } + }, + "@jimp/plugin-dither": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-0.14.0.tgz", + "integrity": "sha512-g8SJqFLyYexXQQsoh4dc1VP87TwyOgeTElBcxSXX2LaaMZezypmxQfLTzOFzZoK8m39NuaoH21Ou1Ftsq7LzVQ==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.14.0" + } + }, + "@jimp/plugin-fisheye": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-0.14.0.tgz", + "integrity": "sha512-BFfUZ64EikCaABhCA6mR3bsltWhPpS321jpeIQfJyrILdpFsZ/OccNwCgpW1XlbldDHIoNtXTDGn3E+vCE7vDg==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.14.0" + } + }, + "@jimp/plugin-flip": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-0.14.0.tgz", + "integrity": "sha512-WtL1hj6ryqHhApih+9qZQYA6Ye8a4HAmdTzLbYdTMrrrSUgIzFdiZsD0WeDHpgS/+QMsWwF+NFmTZmxNWqKfXw==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.14.0" + } + }, + "@jimp/plugin-gaussian": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-gaussian/-/plugin-gaussian-0.14.0.tgz", + "integrity": "sha512-uaLwQ0XAQoydDlF9tlfc7iD9drYPriFe+jgYnWm8fbw5cN+eOIcnneEX9XCOOzwgLPkNCxGox6Kxjn8zY6GxtQ==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.14.0" + } + }, + "@jimp/plugin-invert": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-invert/-/plugin-invert-0.14.0.tgz", + "integrity": "sha512-UaQW9X9vx8orQXYSjT5VcITkJPwDaHwrBbxxPoDG+F/Zgv4oV9fP+udDD6qmkgI9taU+44Fy+zm/J/gGcMWrdg==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.14.0" + } + }, + "@jimp/plugin-mask": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-0.14.0.tgz", + "integrity": "sha512-tdiGM69OBaKtSPfYSQeflzFhEpoRZ+BvKfDEoivyTjauynbjpRiwB1CaiS8En1INTDwzLXTT0Be9SpI3LkJoEA==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.14.0" + } + }, + "@jimp/plugin-normalize": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-normalize/-/plugin-normalize-0.14.0.tgz", + "integrity": "sha512-AfY8sqlsbbdVwFGcyIPy5JH/7fnBzlmuweb+Qtx2vn29okq6+HelLjw2b+VT2btgGUmWWHGEHd86oRGSoWGyEQ==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.14.0" + } + }, + "@jimp/plugin-print": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-0.14.0.tgz", + "integrity": "sha512-MwP3sH+VS5AhhSTXk7pui+tEJFsxnTKFY3TraFJb8WFbA2Vo2qsRCZseEGwpTLhENB7p/JSsLvWoSSbpmxhFAQ==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.14.0", + "load-bmfont": "^1.4.0" + } + }, + "@jimp/plugin-resize": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-0.14.0.tgz", + "integrity": "sha512-qFeMOyXE/Bk6QXN0GQo89+CB2dQcXqoxUcDb2Ah8wdYlKqpi53skABkgVy5pW3EpiprDnzNDboMltdvDslNgLQ==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.14.0" + } + }, + "@jimp/plugin-rotate": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-0.14.0.tgz", + "integrity": "sha512-aGaicts44bvpTcq5Dtf93/8TZFu5pMo/61lWWnYmwJJU1RqtQlxbCLEQpMyRhKDNSfPbuP8nyGmaqXlM/82J0Q==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.14.0" + } + }, + "@jimp/plugin-scale": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-scale/-/plugin-scale-0.14.0.tgz", + "integrity": "sha512-ZcJk0hxY5ZKZDDwflqQNHEGRblgaR+piePZm7dPwPUOSeYEH31P0AwZ1ziceR74zd8N80M0TMft+e3Td6KGBHw==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.14.0" + } + }, + "@jimp/plugin-shadow": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-shadow/-/plugin-shadow-0.14.0.tgz", + "integrity": "sha512-p2igcEr/iGrLiTu0YePNHyby0WYAXM14c5cECZIVnq/UTOOIQ7xIcWZJ1lRbAEPxVVXPN1UibhZAbr3HAb5BjQ==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.14.0" + } + }, + "@jimp/plugin-threshold": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-0.14.0.tgz", + "integrity": "sha512-N4BlDgm/FoOMV/DQM2rSpzsgqAzkP0DXkWZoqaQrlRxQBo4zizQLzhEL00T/YCCMKnddzgEhnByaocgaaa0fKw==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.14.0" + } + }, + "@jimp/plugins": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/plugins/-/plugins-0.14.0.tgz", + "integrity": "sha512-vDO3XT/YQlFlFLq5TqNjQkISqjBHT8VMhpWhAfJVwuXIpilxz5Glu4IDLK6jp4IjPR6Yg2WO8TmRY/HI8vLrOw==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/plugin-blit": "^0.14.0", + "@jimp/plugin-blur": "^0.14.0", + "@jimp/plugin-circle": "^0.14.0", + "@jimp/plugin-color": "^0.14.0", + "@jimp/plugin-contain": "^0.14.0", + "@jimp/plugin-cover": "^0.14.0", + "@jimp/plugin-crop": "^0.14.0", + "@jimp/plugin-displace": "^0.14.0", + "@jimp/plugin-dither": "^0.14.0", + "@jimp/plugin-fisheye": "^0.14.0", + "@jimp/plugin-flip": "^0.14.0", + "@jimp/plugin-gaussian": "^0.14.0", + "@jimp/plugin-invert": "^0.14.0", + "@jimp/plugin-mask": "^0.14.0", + "@jimp/plugin-normalize": "^0.14.0", + "@jimp/plugin-print": "^0.14.0", + "@jimp/plugin-resize": "^0.14.0", + "@jimp/plugin-rotate": "^0.14.0", + "@jimp/plugin-scale": "^0.14.0", + "@jimp/plugin-shadow": "^0.14.0", + "@jimp/plugin-threshold": "^0.14.0", + "timm": "^1.6.1" + } + }, + "@jimp/png": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/png/-/png-0.14.0.tgz", + "integrity": "sha512-0RV/mEIDOrPCcNfXSPmPBqqSZYwGADNRVUTyMt47RuZh7sugbYdv/uvKmQSiqRdR0L1sfbCBMWUEa5G/8MSbdA==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.14.0", + "pngjs": "^3.3.3" + } + }, + "@jimp/tiff": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/tiff/-/tiff-0.14.0.tgz", + "integrity": "sha512-zBYDTlutc7j88G/7FBCn3kmQwWr0rmm1e0FKB4C3uJ5oYfT8645lftUsvosKVUEfkdmOaMAnhrf4ekaHcb5gQw==", + "requires": { + "@babel/runtime": "^7.7.2", + "utif": "^2.0.1" + } + }, + "@jimp/types": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/types/-/types-0.14.0.tgz", + "integrity": "sha512-hx3cXAW1KZm+b+XCrY3LXtdWy2U+hNtq0rPyJ7NuXCjU7lZR3vIkpz1DLJ3yDdS70hTi5QDXY3Cd9kd6DtloHQ==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/bmp": "^0.14.0", + "@jimp/gif": "^0.14.0", + "@jimp/jpeg": "^0.14.0", + "@jimp/png": "^0.14.0", + "@jimp/tiff": "^0.14.0", + "timm": "^1.6.1" + } + }, + "@jimp/utils": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-0.14.0.tgz", + "integrity": "sha512-MY5KFYUru0y74IsgM/9asDwb3ERxWxXEu3CRCZEvE7DtT86y1bR1XgtlSliMrptjz4qbivNGMQSvUBpEFJDp1A==", + "requires": { + "@babel/runtime": "^7.7.2", + "regenerator-runtime": "^0.13.3" + } + }, + "@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha1-m4sMxmPWaafY9vXQiToU00jzD78=" + }, + "@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + }, + "@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha1-NVy8mLr61ZePntCV85diHx0Ga3A=" + }, + "@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha1-upn7WYYUr2VwDBYZ/wbUVLDYTEU=", + "requires": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E=" + }, + "@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha1-/yAOPnzyQp4tyvwRQIKOjMY48Ik=" + }, + "@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha1-bMKyDFya1q0NzP0hynZz2Nf79o0=" + }, + "@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha1-Cf0V8tbTq/qbZbw2ZQbWrXhG/1Q=" + }, + "@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=" + }, + "@types/long": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz", + "integrity": "sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==" + }, + "@types/mocha": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-7.0.2.tgz", + "integrity": "sha512-ZvO2tAcjmMi8V/5Z3JsyofMe3hasRcaw88cto5etSVMwVQfeivGAlEYmaQgceUSVYFofVjT+ioHsATjdWcFt1w==", + "dev": true + }, + "@types/node": { + "version": "14.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.6.0.tgz", + "integrity": "sha512-mikldZQitV94akrc4sCcSjtJfsTKt4p+e/s0AGscVA6XArQ9kFclP+ZiYUMnq987rc6QlYxXv/EivqlfSLxpKA==", + "dev": true + }, + "@types/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-FKjsOVbC6B7bdSB5CuzyHCkK69I=", + "dev": true + }, + "@types/strip-json-comments": { + "version": "0.0.30", + "resolved": "https://registry.npmjs.org/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz", + "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==", + "dev": true + }, + "@types/ws": { + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.2.6.tgz", + "integrity": "sha512-Q07IrQUSNpr+cXU4E4LtkSIBPie5GLZyyMC1QtQYRLWz701+XcoVygGUZgvLqElq1nU4ICldMYPnexlBsg3dqQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "dev": true + }, + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "any-base": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz", + "integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==" + }, + "anymatch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz", + "integrity": "sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==", + "dev": true, + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, + "arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "array-filter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-filter/-/array-filter-1.0.0.tgz", + "integrity": "sha1-uveeYubvTCpMC4MSMtr/7CUfnYM=", + "dev": true + }, + "array-find-index": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", + "integrity": "sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=", + "dev": true + }, + "array.prototype.map": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array.prototype.map/-/array.prototype.map-1.0.2.tgz", + "integrity": "sha512-Az3OYxgsa1g7xDYp86l0nnN4bcmuEITGe1rbdEBVkrqkzMgDcbdQ2R7r41pNzti+4NMces3H8gMmuioZUilLgw==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.0-next.1", + "es-array-method-boxes-properly": "^1.0.0", + "is-string": "^1.0.4" + } + }, + "assert": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.0.0.tgz", + "integrity": "sha512-se5Cd+js9dXJnu6Ag2JFc00t+HmHOen+8Q+L7O9zI0PqQXr20uk2J0XQqMxZEeo5U50o8Nvmmx7dZrl+Ufr35A==", + "dev": true, + "requires": { + "es6-object-assign": "^1.1.0", + "is-nan": "^1.2.1", + "object-is": "^1.0.1", + "util": "^0.12.0" + } + }, + "at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true + }, + "available-typed-arrays": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.2.tgz", + "integrity": "sha512-XWX3OX8Onv97LMk/ftVyBibpGwY5a8SmuxZPzeOxqmuEqUCOM9ZE+uIaD1VNJ5QnvU2UQusvmKbuM1FR8QWGfQ==", + "dev": true, + "requires": { + "array-filter": "^1.0.0" + } + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "base64-js": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", + "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==" + }, + "binary-extensions": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.1.0.tgz", + "integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==", + "dev": true + }, + "bmp-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", + "integrity": "sha1-4Fpj95amwf8l9Hcex62twUjAcjM=" + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true + }, + "buffer": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", + "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" + } + }, + "buffer-equal": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-0.0.1.tgz", + "integrity": "sha1-kbx0sR6kBbyRa8aqkI+q+ltKrEs=" + }, + "buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", + "dev": true + }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "camelcase-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz", + "integrity": "sha1-MIvur/3ygRkFHvodkyITyRuPkuc=", + "dev": true, + "requires": { + "camelcase": "^2.0.0", + "map-obj": "^1.0.0" + }, + "dependencies": { + "camelcase": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", + "integrity": "sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=", + "dev": true + } + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "dependencies": { + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "chokidar": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.3.1.tgz", + "integrity": "sha512-4QYCEWOcK3OJrxwvyyAOxFuhpvOVCYkr33LPfFNBjAD/w3sEzWsp2BUOkI4l9bHvWioAd0rc6NlHUOEaWkTeqg==", + "dev": true, + "requires": { + "anymatch": "~3.1.1", + "braces": "~3.0.2", + "fsevents": "~2.1.2", + "glob-parent": "~5.1.0", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.3.0" + } + }, + "cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "dev": true, + "requires": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "currently-unhandled": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", + "integrity": "sha1-mI3zP+qxke95mmE2nddsF635V+o=", + "dev": true, + "requires": { + "array-find-index": "^1.0.1" + } + }, + "curve25519-js": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz", + "integrity": "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==" + }, + "dateformat": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-1.0.12.tgz", + "integrity": "sha1-nxJLZ1lMk3/3BpMuSmQsyo27/uk=", + "dev": true, + "requires": { + "get-stdin": "^4.0.1", + "meow": "^3.3.0" + } + }, + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", + "dev": true + }, + "define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "dev": true, + "requires": { + "object-keys": "^1.0.12" + } + }, + "diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true + }, + "dom-walk": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", + "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" + }, + "dotenv": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz", + "integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==", + "dev": true + }, + "dynamic-dedupe": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz", + "integrity": "sha1-BuRMIj9eTpTXjvnbI6ZRXOL5YqE=", + "dev": true, + "requires": { + "xtend": "^4.0.0" + } + }, + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "es-abstract": { + "version": "1.17.6", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.6.tgz", + "integrity": "sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.2.0", + "is-regex": "^1.1.0", + "object-inspect": "^1.7.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.0", + "string.prototype.trimend": "^1.0.1", + "string.prototype.trimstart": "^1.0.1" + } + }, + "es-array-method-boxes-properly": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", + "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", + "dev": true + }, + "es-get-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.0.tgz", + "integrity": "sha512-UfrmHuWQlNMTs35e1ypnvikg6jCz3SK8v8ImvmDsh36fCVUR1MqoFDiyn0/k52C8NqO3YsO8Oe0azeesNuqSsQ==", + "dev": true, + "requires": { + "es-abstract": "^1.17.4", + "has-symbols": "^1.0.1", + "is-arguments": "^1.0.4", + "is-map": "^2.0.1", + "is-set": "^2.0.1", + "is-string": "^1.0.5", + "isarray": "^2.0.5" + } + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "es6-object-assign": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es6-object-assign/-/es6-object-assign-1.1.0.tgz", + "integrity": "sha1-wsNYJlYkfDnqEHyx5mUrb58kUjw=", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, + "exif-parser": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz", + "integrity": "sha1-WKnS1ywCwfbwKg70qRZicrd2CSI=" + }, + "file-type": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-9.0.0.tgz", + "integrity": "sha512-Qe/5NJrgIOlwijpq3B7BEpzPFcgzggOTagZmkXQY4LA6bsXKTUstK7Wp12lEJ/mLKTpvIZxmIuRcLYWT6ov9lw==" + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "flat": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/flat/-/flat-4.1.0.tgz", + "integrity": "sha512-Px/TiLIznH7gEDlPXcUD4KnBusa6kR6ayRUVcnEAbreRIuhkqow/mun59BuRXwoYk7ZQOLW1ZM05ilIvK38hFw==", + "dev": true, + "requires": { + "is-buffer": "~2.0.3" + } + }, + "foreach": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz", + "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=", + "dev": true + }, + "fs-extra": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.0.1.tgz", + "integrity": "sha512-h2iAoN838FqAFJY2/qVpzFXy+EBxfVE220PalAqQLDVsFOHLJrZvut5puAbCdNv6WJk+B8ihI+k0c7JK5erwqQ==", + "dev": true, + "requires": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^1.0.0" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "fsevents": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz", + "integrity": "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==", + "dev": true, + "optional": true + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "futoin-hkdf": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/futoin-hkdf/-/futoin-hkdf-1.3.2.tgz", + "integrity": "sha512-3EVi3ETTyJg5PSXlxLCaUVVn0pSbDf62L3Gwxne7Uq+d8adOSNWQAad4gg7WToHkcgnCJb3Wlb1P8r4Evj4GPw==" + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true + }, + "get-stdin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz", + "integrity": "sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=", + "dev": true + }, + "gifwrap": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.9.2.tgz", + "integrity": "sha512-fcIswrPaiCDAyO8xnWvHSZdWChjKXUanKKpAiWWJ/UTkEi/aYKn5+90e7DE820zbEaVR9CE2y4z9bzhQijZ0BA==", + "requires": { + "image-q": "^1.1.1", + "omggif": "^1.0.10" + } + }, + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz", + "integrity": "sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, + "global": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/global/-/global-4.3.2.tgz", + "integrity": "sha1-52mJJopsdMOJCLEwWxD8DjlOnQ8=", + "requires": { + "min-document": "^2.19.0", + "process": "~0.5.1" + } + }, + "graceful-fs": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", + "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", + "dev": true + }, + "growl": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", + "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", + "dev": true + }, + "handlebars": { + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.6.tgz", + "integrity": "sha512-1f2BACcBfiwAfStCKZNrUCgqNZkGsAT7UM3kkYtXuLo0KnaVfjKOyf7PRzB6++aK9STyT1Pd2ZCPe3EGOXleXA==", + "dev": true, + "requires": { + "minimist": "^1.2.5", + "neo-async": "^2.6.0", + "source-map": "^0.6.1", + "uglify-js": "^3.1.4", + "wordwrap": "^1.0.0" + } + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "has-symbols": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", + "dev": true + }, + "he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true + }, + "highlight.js": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.1.2.tgz", + "integrity": "sha512-Q39v/Mn5mfBlMff9r+zzA+gWxRsCRKwEMvYTiisLr/XUiFI/4puWt0Ojdko3R3JCNWGdOWaA5g/Yxqa23kC5AA==", + "dev": true + }, + "hosted-git-info": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz", + "integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==", + "dev": true + }, + "ieee754": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", + "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" + }, + "image-q": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/image-q/-/image-q-1.1.1.tgz", + "integrity": "sha1-/IQJlmRGC5DKhi2TALa/u7+/gFY=" + }, + "indent-string": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz", + "integrity": "sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=", + "dev": true, + "requires": { + "repeating": "^2.0.0" + } + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "interpret": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", + "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", + "dev": true + }, + "is-arguments": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.0.4.tgz", + "integrity": "sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA==", + "dev": true + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "requires": { + "binary-extensions": "^2.0.0" + } + }, + "is-buffer": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.4.tgz", + "integrity": "sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A==", + "dev": true + }, + "is-callable": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.0.tgz", + "integrity": "sha512-pyVD9AaGLxtg6srb2Ng6ynWJqkHU9bEM087AKck0w8QwDarTfNcpIYoU8x8Hv2Icm8u6kFJM18Dag8lyqGkviw==", + "dev": true + }, + "is-date-object": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz", + "integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==", + "dev": true + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-finite": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz", + "integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "is-function": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz", + "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==" + }, + "is-generator-function": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.7.tgz", + "integrity": "sha512-YZc5EwyO4f2kWCax7oegfuSr9mFz1ZvieNYBEjmukLxgXfBUbxAWGVF7GZf0zidYtoBl3WvC07YK0wT76a+Rtw==", + "dev": true + }, + "is-glob": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", + "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-map": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.1.tgz", + "integrity": "sha512-T/S49scO8plUiAOA2DBTBG3JHpn1yiw0kRp6dgiZ0v2/6twi5eiB0rHtHFH9ZIrvlWc6+4O+m4zg5+Z833aXgw==", + "dev": true + }, + "is-nan": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.0.tgz", + "integrity": "sha512-z7bbREymOqt2CCaZVly8aC4ML3Xhfi0ekuOnjO2L8vKdl+CttdVoGZQhd4adMFAsxQ5VeRVwORs4tU8RH+HFtQ==", + "dev": true, + "requires": { + "define-properties": "^1.1.3" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=", + "dev": true + }, + "is-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.0.tgz", + "integrity": "sha512-iI97M8KTWID2la5uYXlkbSDQIg4F6o1sYboZKKTDpnDQMLtUL86zxhgDet3Q2SriaYsyGqZ6Mn2SjbRKeLHdqw==", + "dev": true, + "requires": { + "has-symbols": "^1.0.1" + } + }, + "is-set": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.1.tgz", + "integrity": "sha512-eJEzOtVyenDs1TMzSQ3kU3K+E0GUS9sno+F0OBT97xsgcJsF9nXMBtkT9/kut5JEpM7oL7X/0qxR17K3mcwIAA==", + "dev": true + }, + "is-string": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.5.tgz", + "integrity": "sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==", + "dev": true + }, + "is-symbol": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", + "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", + "dev": true, + "requires": { + "has-symbols": "^1.0.1" + } + }, + "is-typed-array": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.3.tgz", + "integrity": "sha512-BSYUBOK/HJibQ30wWkWold5txYwMUXQct9YHAQJr8fSwvZoiglcqB0pd7vEN23+Tsi9IUEjztdOSzl4qLVYGTQ==", + "dev": true, + "requires": { + "available-typed-arrays": "^1.0.0", + "es-abstract": "^1.17.4", + "foreach": "^2.0.5", + "has-symbols": "^1.0.1" + } + }, + "is-utf8": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", + "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=", + "dev": true + }, + "isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "iterate-iterator": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/iterate-iterator/-/iterate-iterator-1.0.1.tgz", + "integrity": "sha512-3Q6tudGN05kbkDQDI4CqjaBf4qf85w6W6GnuZDtUVYwKgtC1q8yxYX7CZed7N+tLzQqS6roujWvszf13T+n9aw==", + "dev": true + }, + "iterate-value": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/iterate-value/-/iterate-value-1.0.2.tgz", + "integrity": "sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ==", + "dev": true, + "requires": { + "es-get-iterator": "^1.0.2", + "iterate-iterator": "^1.0.1" + } + }, + "jimp": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/jimp/-/jimp-0.14.0.tgz", + "integrity": "sha512-8BXU+J8+SPmwwyq9ELihpSV4dWPTiOKBWCEgtkbnxxAVMjXdf3yGmyaLSshBfXc8sP/JQ9OZj5R8nZzz2wPXgA==", + "requires": { + "@babel/runtime": "^7.7.2", + "@jimp/custom": "^0.14.0", + "@jimp/plugins": "^0.14.0", + "@jimp/types": "^0.14.0", + "regenerator-runtime": "^0.13.3" + } + }, + "jpeg-js": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.1.tgz", + "integrity": "sha512-jA55yJiB5tCXEddos8JBbvW+IMrqY0y1tjjx9KNVtA+QPmu7ND5j0zkKopClpUTsaETL135uOM2XfcYG4XRjmw==" + }, + "js-yaml": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", + "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "jsonfile": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.0.1.tgz", + "integrity": "sha512-jR2b5v7d2vIOust+w3wtFKZIfpC2pnRmFAhAC/BuweZFQR8qZzxH1OyrQ10HmdVYiXWkYUqPVsz91cG7EL2FBg==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6", + "universalify": "^1.0.0" + } + }, + "load-bmfont": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/load-bmfont/-/load-bmfont-1.4.1.tgz", + "integrity": "sha512-8UyQoYmdRDy81Brz6aLAUhfZLwr5zV0L3taTQ4hju7m6biuwiWiJXjPhBJxbUQJA8PrkvJ/7Enqmwk2sM14soA==", + "requires": { + "buffer-equal": "0.0.1", + "mime": "^1.3.4", + "parse-bmfont-ascii": "^1.0.3", + "parse-bmfont-binary": "^1.0.5", + "parse-bmfont-xml": "^1.1.4", + "phin": "^2.9.1", + "xhr": "^2.0.1", + "xtend": "^4.0.0" + } + }, + "load-json-file": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", + "integrity": "sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^2.2.0", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0", + "strip-bom": "^2.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==", + "dev": true + }, + "log-symbols": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz", + "integrity": "sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==", + "dev": true, + "requires": { + "chalk": "^2.4.2" + } + }, + "long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" + }, + "loud-rejection": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz", + "integrity": "sha1-W0b4AUft7leIcPCG0Eghz5mOVR8=", + "dev": true, + "requires": { + "currently-unhandled": "^0.4.1", + "signal-exit": "^3.0.0" + } + }, + "lunr": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.8.tgz", + "integrity": "sha512-oxMeX/Y35PNFuZoHp+jUj5OSEmLCaIH4KTFJh7a93cHBoFmpw2IoPs22VIz7vyO2YUnx2Tn9dzIwO2P/4quIRg==", + "dev": true + }, + "make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, + "map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=", + "dev": true + }, + "marked": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-1.1.1.tgz", + "integrity": "sha512-mJzT8D2yPxoPh7h0UXkB+dBj4FykPJ2OIfxAWeIHrvoHDkFxukV/29QxoFQoPM6RLEwhIFdJpmKBlqVM3s2ZIw==", + "dev": true + }, + "meow": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz", + "integrity": "sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=", + "dev": true, + "requires": { + "camelcase-keys": "^2.0.0", + "decamelize": "^1.1.2", + "loud-rejection": "^1.0.0", + "map-obj": "^1.0.1", + "minimist": "^1.1.3", + "normalize-package-data": "^2.3.4", + "object-assign": "^4.0.1", + "read-pkg-up": "^1.0.1", + "redent": "^1.0.0", + "trim-newlines": "^1.0.0" + } + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + }, + "min-document": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", + "integrity": "sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU=", + "requires": { + "dom-walk": "^0.1.0" + } + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + }, + "mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "requires": { + "minimist": "^1.2.5" + } + }, + "mocha": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-8.1.1.tgz", + "integrity": "sha512-p7FuGlYH8t7gaiodlFreseLxEmxTgvyG9RgPHODFPySNhwUehu8NIb0vdSt3WFckSneswZ0Un5typYcWElk7HQ==", + "dev": true, + "requires": { + "ansi-colors": "4.1.1", + "browser-stdout": "1.3.1", + "chokidar": "3.3.1", + "debug": "3.2.6", + "diff": "4.0.2", + "escape-string-regexp": "1.0.5", + "find-up": "4.1.0", + "glob": "7.1.6", + "growl": "1.10.5", + "he": "1.2.0", + "js-yaml": "3.13.1", + "log-symbols": "3.0.0", + "minimatch": "3.0.4", + "ms": "2.1.2", + "object.assign": "4.1.0", + "promise.allsettled": "1.0.2", + "serialize-javascript": "4.0.0", + "strip-json-comments": "3.0.1", + "supports-color": "7.1.0", + "which": "2.0.2", + "wide-align": "1.1.3", + "workerpool": "6.0.0", + "yargs": "13.3.2", + "yargs-parser": "13.1.2", + "yargs-unparser": "1.6.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "node-fetch": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", + "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==" + }, + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "dev": true + }, + "object-inspect": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.8.0.tgz", + "integrity": "sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA==", + "dev": true + }, + "object-is": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.2.tgz", + "integrity": "sha512-5lHCz+0uufF6wZ7CRFWJN3hp8Jqblpgve06U5CMQ3f//6iDjPr2PEo9MWCjEssDsa+UZEL4PkFpr+BMop6aKzQ==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true + }, + "object.assign": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz", + "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "function-bind": "^1.1.1", + "has-symbols": "^1.0.0", + "object-keys": "^1.0.11" + } + }, + "omggif": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz", + "integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==" + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, + "parse-bmfont-ascii": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz", + "integrity": "sha1-Eaw8P/WPfCAgqyJ2kHkQjU36AoU=" + }, + "parse-bmfont-binary": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz", + "integrity": "sha1-0Di0dtPp3Z2x4RoLDlOiJ5K2kAY=" + }, + "parse-bmfont-xml": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/parse-bmfont-xml/-/parse-bmfont-xml-1.1.4.tgz", + "integrity": "sha512-bjnliEOmGv3y1aMEfREMBJ9tfL3WR0i0CKPj61DnSLaoxWR3nLrsQrEbCId/8rF4NyRF0cCqisSVXyQYWM+mCQ==", + "requires": { + "xml-parse-from-string": "^1.0.0", + "xml2js": "^0.4.5" + } + }, + "parse-headers": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.3.tgz", + "integrity": "sha512-QhhZ+DCCit2Coi2vmAKbq5RGTRcQUOE2+REgv8vdyu7MnYx2eZztegqtTx99TZ86GTIwqiy3+4nQTWZ2tgmdCA==" + }, + "parse-json": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", + "integrity": "sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=", + "dev": true, + "requires": { + "error-ex": "^1.2.0" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", + "dev": true + }, + "path-type": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz", + "integrity": "sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + } + }, + "phin": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/phin/-/phin-2.9.3.tgz", + "integrity": "sha512-CzFr90qM24ju5f88quFC/6qohjC144rehe5n6DH900lgXmUe86+xCKc10ev56gRKC4/BkHUoG4uSiQgBiIXwDA==" + }, + "picomatch": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", + "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==", + "dev": true + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + }, + "pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=", + "dev": true + }, + "pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "dev": true, + "requires": { + "pinkie": "^2.0.0" + } + }, + "pixelmatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-4.0.2.tgz", + "integrity": "sha1-j0fc7FARtHe2fbA8JDvB8wheiFQ=", + "requires": { + "pngjs": "^3.0.0" + } + }, + "pngjs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", + "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==" + }, + "process": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/process/-/process-0.5.2.tgz", + "integrity": "sha1-FjjYqONML0QKkduVq5rrZ3/Bhc8=" + }, + "progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true + }, + "promise.allsettled": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/promise.allsettled/-/promise.allsettled-1.0.2.tgz", + "integrity": "sha512-UpcYW5S1RaNKT6pd+s9jp9K9rlQge1UXKskec0j6Mmuq7UJCvlS2J2/s/yuPN8ehftf9HXMxWlKiPbGGUzpoRg==", + "dev": true, + "requires": { + "array.prototype.map": "^1.0.1", + "define-properties": "^1.1.3", + "es-abstract": "^1.17.0-next.1", + "function-bind": "^1.1.1", + "iterate-value": "^1.0.0" + } + }, + "protobufjs": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.10.1.tgz", + "integrity": "sha512-pb8kTchL+1Ceg4lFd5XUpK8PdWacbvV5SK2ULH2ebrYtl4GjJmS24m6CKME67jzV53tbJxHlnNOSqQHbTsR9JQ==", + "requires": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.1", + "@types/node": "^13.7.0", + "long": "^4.0.0" + }, + "dependencies": { + "@types/node": { + "version": "13.13.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-13.13.15.tgz", + "integrity": "sha512-kwbcs0jySLxzLsa2nWUAGOd/s21WU1jebrEdtzhsj1D4Yps1EOuyI1Qcu+FD56dL7NRNIJtDDjcqIG22NwkgLw==" + } + } + }, + "qrcode-terminal": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", + "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==" + }, + "randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.0" + } + }, + "read-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", + "integrity": "sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=", + "dev": true, + "requires": { + "load-json-file": "^1.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^1.0.0" + } + }, + "read-pkg-up": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz", + "integrity": "sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=", + "dev": true, + "requires": { + "find-up": "^1.0.0", + "read-pkg": "^1.0.0" + }, + "dependencies": { + "find-up": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", + "integrity": "sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=", + "dev": true, + "requires": { + "path-exists": "^2.0.0", + "pinkie-promise": "^2.0.0" + } + }, + "path-exists": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz", + "integrity": "sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=", + "dev": true, + "requires": { + "pinkie-promise": "^2.0.0" + } + } + } + }, + "readdirp": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.3.0.tgz", + "integrity": "sha512-zz0pAkSPOXXm1viEwygWIPSPkcBYjW1xU5j/JBh5t9bGCJwa6f9+BJa6VaB2g+b55yVrmXzqkyLf4xaWYM0IkQ==", + "dev": true, + "requires": { + "picomatch": "^2.0.7" + } + }, + "rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=", + "dev": true, + "requires": { + "resolve": "^1.1.6" + } + }, + "redent": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz", + "integrity": "sha1-z5Fqsf1fHxbfsggi3W7H9zDCr94=", + "dev": true, + "requires": { + "indent-string": "^2.1.0", + "strip-indent": "^1.0.1" + } + }, + "regenerator-runtime": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz", + "integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==" + }, + "repeating": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", + "integrity": "sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=", + "dev": true, + "requires": { + "is-finite": "^1.0.0" + } + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", + "dev": true + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "resolve": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz", + "integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==", + "dev": true, + "requires": { + "path-parse": "^1.0.6" + } + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "serialize-javascript": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", + "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", + "dev": true, + "requires": { + "randombytes": "^2.1.0" + } + }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", + "dev": true + }, + "shelljs": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.4.tgz", + "integrity": "sha512-7gk3UZ9kOfPLIAbslLzyWeGiEqx9e3rxwZM0KE6EL8GlGwjym9Mrlx5/p33bWTu9YG6vcS4MBxYZDHYr5lr8BQ==", + "dev": true, + "requires": { + "glob": "^7.0.0", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" + } + }, + "signal-exit": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "source-map-support": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz", + "integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "spdx-correct": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz", + "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==", + "dev": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", + "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "dev": true + }, + "spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz", + "integrity": "sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==", + "dev": true + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + } + }, + "string.prototype.trimend": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz", + "integrity": "sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "string.prototype.trimstart": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz", + "integrity": "sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + }, + "strip-bom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", + "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=", + "dev": true, + "requires": { + "is-utf8": "^0.2.0" + } + }, + "strip-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-1.0.1.tgz", + "integrity": "sha1-DHlipq3vp7vUrDZkYKY4VSrhoKI=", + "dev": true, + "requires": { + "get-stdin": "^4.0.1" + } + }, + "strip-json-comments": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.0.1.tgz", + "integrity": "sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw==", + "dev": true + }, + "supports-color": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", + "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + } + } + }, + "timm": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/timm/-/timm-1.6.2.tgz", + "integrity": "sha512-IH3DYDL1wMUwmIlVmMrmesw5lZD6N+ZOAFWEyLrtpoL9Bcrs9u7M/vyOnHzDD2SMs4irLkVjqxZbHrXStS/Nmw==" + }, + "tinycolor2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.1.tgz", + "integrity": "sha1-9PrTM0R7wLB9TcjpIJ2POaisd+g=" + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true + }, + "trim-newlines": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz", + "integrity": "sha1-WIeWa7WCpFA6QetST301ARgVphM=", + "dev": true + }, + "ts-node": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-8.10.2.tgz", + "integrity": "sha512-ISJJGgkIpDdBhWVu3jufsWpK3Rzo7bdiIXJjQc0ynKxVOVcg2oIrf2H2cejminGrptVc6q6/uynAHNCuWGbpVA==", + "dev": true, + "requires": { + "arg": "^4.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "source-map-support": "^0.5.17", + "yn": "3.1.1" + } + }, + "ts-node-dev": { + "version": "1.0.0-pre.60", + "resolved": "https://registry.npmjs.org/ts-node-dev/-/ts-node-dev-1.0.0-pre.60.tgz", + "integrity": "sha512-S1X/2dMH2cxzFEiOWo5r/DTD0oElKbEpG8lnWoEA1LrwxXMFCJs71vIMaXPu6p8ud3MHMI2Ans3syDQ8mkjEUg==", + "dev": true, + "requires": { + "chokidar": "^3.4.0", + "dateformat": "~1.0.4-1.2.3", + "dynamic-dedupe": "^0.3.0", + "minimist": "^1.2.5", + "mkdirp": "^1.0.4", + "resolve": "^1.0.0", + "rimraf": "^2.6.1", + "source-map-support": "^0.5.12", + "tree-kill": "^1.2.2", + "ts-node": "^8.10.2", + "tsconfig": "^7.0.0" + }, + "dependencies": { + "chokidar": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.4.2.tgz", + "integrity": "sha512-IZHaDeBeI+sZJRX7lGcXsdzgvZqKv6sECqsbErJA4mHWfpRrD8B97kSFN4cQz6nGBGiuFia1MKR4d6c1o8Cv7A==", + "dev": true, + "requires": { + "anymatch": "~3.1.1", + "braces": "~3.0.2", + "fsevents": "~2.1.2", + "glob-parent": "~5.1.0", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.4.0" + } + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true + }, + "readdirp": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.4.0.tgz", + "integrity": "sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ==", + "dev": true, + "requires": { + "picomatch": "^2.2.1" + } + } + } + }, + "tsconfig": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tsconfig/-/tsconfig-7.0.0.tgz", + "integrity": "sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==", + "dev": true, + "requires": { + "@types/strip-bom": "^3.0.0", + "@types/strip-json-comments": "0.0.30", + "strip-bom": "^3.0.0", + "strip-json-comments": "^2.0.0" + }, + "dependencies": { + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "dev": true + } + } + }, + "typedoc": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.18.0.tgz", + "integrity": "sha512-UgDQwapCGQCCdYhEQzQ+kGutmcedklilgUGf62Vw6RdI29u6FcfAXFQfRTiJEbf16aK3YnkB20ctQK1JusCRbA==", + "dev": true, + "requires": { + "fs-extra": "^9.0.1", + "handlebars": "^4.7.6", + "highlight.js": "^10.0.0", + "lodash": "^4.17.15", + "lunr": "^2.3.8", + "marked": "^1.1.1", + "minimatch": "^3.0.0", + "progress": "^2.0.3", + "shelljs": "^0.8.4", + "typedoc-default-themes": "^0.10.2" + } + }, + "typedoc-default-themes": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/typedoc-default-themes/-/typedoc-default-themes-0.10.2.tgz", + "integrity": "sha512-zo09yRj+xwLFE3hyhJeVHWRSPuKEIAsFK5r2u47KL/HBKqpwdUSanoaz5L34IKiSATFrjG5ywmIu98hPVMfxZg==", + "dev": true, + "requires": { + "lunr": "^2.3.8" + } + }, + "typescript": { + "version": "3.9.7", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.7.tgz", + "integrity": "sha512-BLbiRkiBzAwsjut4x/dsibSTB6yWpwT5qWmC2OfuCg3GgVQCSgMs4vEctYPhsaGtd0AeuuHMkjZ2h2WG8MSzRw==", + "dev": true + }, + "uglify-js": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.10.1.tgz", + "integrity": "sha512-RjxApKkrPJB6kjJxQS3iZlf///REXWYxYJxO/MpmlQzVkDWVI3PSnCBWezMecmTU/TRkNxrl8bmsfFQCp+LO+Q==", + "dev": true, + "optional": true + }, + "universalify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-1.0.0.tgz", + "integrity": "sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug==", + "dev": true + }, + "utif": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/utif/-/utif-2.0.1.tgz", + "integrity": "sha512-Z/S1fNKCicQTf375lIP9G8Sa1H/phcysstNrrSdZKj1f9g58J4NMgb5IgiEZN9/nLMPDwF0W7hdOe9Qq2IYoLg==", + "requires": { + "pako": "^1.0.5" + } + }, + "util": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.3.tgz", + "integrity": "sha512-I8XkoQwE+fPQEhy9v012V+TSdH2kp9ts29i20TaaDUXsg7x/onePbhFJUExBfv/2ay1ZOp/Vsm3nDlmnFGSAog==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "safe-buffer": "^5.1.2", + "which-typed-array": "^1.1.2" + } + }, + "validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", + "dev": true + }, + "which-typed-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.2.tgz", + "integrity": "sha512-KT6okrd1tE6JdZAy3o2VhMoYPh3+J6EMZLyrxBQsZflI1QCZIxMrIYLkosd8Twf+YfknVIHmYQPgJt238p8dnQ==", + "dev": true, + "requires": { + "available-typed-arrays": "^1.0.2", + "es-abstract": "^1.17.5", + "foreach": "^2.0.5", + "function-bind": "^1.1.1", + "has-symbols": "^1.0.1", + "is-typed-array": "^1.1.3" + } + }, + "wide-align": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", + "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", + "dev": true, + "requires": { + "string-width": "^1.0.2 || 2" + } + }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=", + "dev": true + }, + "workerpool": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.0.0.tgz", + "integrity": "sha512-fU2OcNA/GVAJLLyKUoHkAgIhKb0JoCpSjLC/G2vYKxUjVmQwGbRVeoPJ1a8U4pnVofz4AQV5Y/NEw8oKqxEBtA==", + "dev": true + }, + "wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "ws": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.3.1.tgz", + "integrity": "sha512-D3RuNkynyHmEJIpD2qrgVkc9DQ23OrN/moAwZX4L8DfvszsJxpjQuUq3LMx6HoYji9fbIOBY18XWBsAux1ZZUA==" + }, + "xhr": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/xhr/-/xhr-2.5.0.tgz", + "integrity": "sha512-4nlO/14t3BNUZRXIXfXe+3N6w3s1KoxcJUUURctd64BLRe67E4gRwp4PjywtDY72fXpZ1y6Ch0VZQRY/gMPzzQ==", + "requires": { + "global": "~4.3.0", + "is-function": "^1.0.1", + "parse-headers": "^2.0.0", + "xtend": "^4.0.0" + } + }, + "xml-parse-from-string": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz", + "integrity": "sha1-qQKekp09vN7RafPG4oI42VpdWig=" + }, + "xml2js": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "requires": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + } + }, + "xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==" + }, + "xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" + }, + "y18n": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", + "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", + "dev": true + }, + "yargs": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", + "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", + "dev": true, + "requires": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.2" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "yargs-parser": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", + "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + }, + "yargs-unparser": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-1.6.1.tgz", + "integrity": "sha512-qZV14lK9MWsGCmcr7u5oXGH0dbGqZAIxTDrWXZDo5zUr6b6iUmelNKO6x6R1dQT24AH3LgRxJpr8meWy2unolA==", + "dev": true, + "requires": { + "camelcase": "^5.3.1", + "decamelize": "^1.2.0", + "flat": "^4.1.0", + "is-plain-obj": "^1.1.0", + "yargs": "^14.2.3" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + }, + "yargs": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-14.2.3.tgz", + "integrity": "sha512-ZbotRWhF+lkjijC/VhmOT9wSgyBQ7+zr13+YLkhfsSiTriYsMzkTUFP18pFhWwBeMa5gUc1MzbhrO6/VB7c9Xg==", + "dev": true, + "requires": { + "cliui": "^5.0.0", + "decamelize": "^1.2.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^15.0.1" + } + }, + "yargs-parser": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-15.0.1.tgz", + "integrity": "sha512-0OAMV2mAZQrs3FkNpDQcBk1x5HXb8X4twADss4S0Iuk+2dGnLOE/fRHrsYm542GduMveyA77OF4wrNJuanRCWw==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } + }, + "yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true + } + } +} diff --git a/package.json b/package.json index ab83a2a..c5844aa 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,6 @@ "keywords": [ "whatsapp", "js-whatsapp", - "reverse engineer", "whatsapp-api", "whatsapp-web", "whatsapp", @@ -18,6 +17,7 @@ ], "scripts": { "test": "mocha --timeout 60000 -r ts-node/register src/Tests/Tests.*.ts", + "prepare": "npm run build", "lint": "eslint '*/*.ts' --quiet --fix", "build:tsc": "tsc", "build:docs": "typedoc", @@ -35,24 +35,24 @@ "./lib/**/*.js" ], "dependencies": { - "@adiwajshing/keyed-db": "^0.1.1", - "curve25519-js": "0.0.4", + "@adiwajshing/keyed-db": "^0.1.2", + "curve25519-js": "^0.0.4", "futoin-hkdf": "^1.3.2", "jimp": "^0.14.0", "node-fetch": "^2.6.0", - "protobufjs": "^6.9.0", + "protobufjs": "^6.10.1", "qrcode-terminal": "^0.12.0", - "ws": "^7.3.0" + "ws": "^7.3.1" }, "devDependencies": { "@types/mocha": "^7.0.2", - "@types/node": "^14.0.14", + "@types/node": "^14.6.0", "@types/ws": "^7.2.6", "assert": "^2.0.0", "dotenv": "^8.2.0", - "mocha": "^8.0.1", - "ts-node-dev": "^1.0.0-pre.49", + "mocha": "^8.1.1", + "ts-node-dev": "^1.0.0-pre.60", "typedoc": "^0.18.0", - "typescript": "^3.9.5" + "typescript": "^3.9.7" } } diff --git a/src/Tests/Common.ts b/src/Tests/Common.ts index e206e42..3323c26 100644 --- a/src/Tests/Common.ts +++ b/src/Tests/Common.ts @@ -1,4 +1,4 @@ -import { WAConnection, MessageLogLevel, MessageOptions, MessageType } from '../WAConnection/WAConnection' +import { WAConnection, MessageLogLevel, MessageOptions, MessageType, unixTimestampSeconds } from '../WAConnection/WAConnection' import * as assert from 'assert' import {promises as fs} from 'fs' @@ -7,22 +7,29 @@ export const testJid = process.env.TEST_JID || '1234@s.whatsapp.net' // set TEST export async function sendAndRetreiveMessage(conn: WAConnection, content, type: MessageType, options: MessageOptions = {}) { const response = await conn.sendMessage(testJid, content, type, options) - const messages = await conn.loadConversation(testJid, 10, null, true) + const {messages} = await conn.loadMessages(testJid, 10) const message = messages.find (m => m.key.id === response.key.id) assert.ok(message) + + const chat = conn.chats.get(testJid) + + assert.ok (chat.messages.find(m => m.key.id === response.key.id)) + assert.ok (chat.t >= (unixTimestampSeconds()-5) ) return message } -export function WAConnectionTest(name: string, func: (conn: WAConnection) => void) { +export const WAConnectionTest = (name: string, func: (conn: WAConnection) => void) => ( describe(name, () => { const conn = new WAConnection() conn.logLevel = MessageLogLevel.info before(async () => { + //conn.logLevel = MessageLogLevel.unhandled const file = './auth_info.json' - await conn.connectSlim(file) + await conn.loadAuthInfo(file).connect() await fs.writeFile(file, JSON.stringify(conn.base64EncodedAuthInfo(), null, '\t')) }) after(() => conn.close()) + func(conn) }) -} \ No newline at end of file +) \ No newline at end of file diff --git a/src/Tests/Tests.Connect.ts b/src/Tests/Tests.Connect.ts index d03b2a9..9bb3810 100644 --- a/src/Tests/Tests.Connect.ts +++ b/src/Tests/Tests.Connect.ts @@ -1,21 +1,25 @@ import * as assert from 'assert' -import * as QR from 'qrcode-terminal' import {WAConnection} from '../WAConnection/WAConnection' -import { AuthenticationCredentialsBase64 } from '../WAConnection/Constants' -import { createTimeout } from '../WAConnection/Utils' +import { AuthenticationCredentialsBase64, BaileysError, ReconnectMode } from '../WAConnection/Constants' +import { delay } from '../WAConnection/Utils' describe('QR Generation', () => { it('should generate QR', async () => { + const conn = new WAConnection() - let calledQR = false - conn.onReadyForPhoneAuthentication = ([ref, curveKey, clientID]) => { - assert.ok(ref, 'ref nil') - assert.ok(curveKey, 'curve key nil') - assert.ok(clientID, 'client ID nil') - calledQR = true - } - await assert.rejects(async () => conn.connectSlim(null, 5000), 'should have failed connect') - assert.equal(calledQR, true, 'QR not called') + conn.regenerateQRIntervalMs = 5000 + let calledQR = 0 + conn.removeAllListeners ('qr') + conn.on ('qr', qr => calledQR += 1) + + await conn.connect({ timeoutMs: 15000 }) + .then (() => assert.fail('should not have succeeded')) + .catch (error => { + assert.equal (error.message, 'timed out') + }) + assert.equal (conn['pendingRequests'].length, 0) + assert.equal (Object.keys(conn['callbacks']).filter(key => !key.startsWith('function:')).length, 0) + assert.ok(calledQR >= 2, 'QR not called') }) }) @@ -23,76 +27,143 @@ describe('Test Connect', () => { let auth: AuthenticationCredentialsBase64 it('should connect', async () => { console.log('please be ready to scan with your phone') + const conn = new WAConnection() - const user = await conn.connectSlim(null) - assert.ok(user) - assert.ok(user.id) + await conn.connect (null) + assert.ok(conn.user?.id) + assert.ok(conn.user?.phone) + assert.ok (conn.user?.imgUrl || conn.user.imgUrl === '') conn.close() auth = conn.base64EncodedAuthInfo() }) - it('should re-generate QR & connect', async () => { - const conn = new WAConnection() - conn.onReadyForPhoneAuthentication = async ([ref, publicKey, clientID]) => { - for (let i = 0; i < 2; i++) { - console.log ('called QR ' + i + ' times') - await createTimeout (3000) - ref = await conn.generateNewQRCode () - } - const str = ref + ',' + publicKey + ',' + clientID - QR.generate(str, { small: true }) - } - const user = await conn.connectSlim(null) - assert.ok(user) - assert.ok(user.id) - - conn.close() - }) it('should reconnect', async () => { const conn = new WAConnection() - const [user, chats, contacts] = await conn.connect(auth, 20*1000) + await conn + .loadAuthInfo (auth) + .connect ({timeoutMs: 20*1000}) + .then (conn => { + assert.ok(conn.user) + assert.ok(conn.user.id) - assert.ok(user) - assert.ok(user.id) - - assert.ok(chats) - - const chatArray = chats.all() - if (chatArray.length > 0) { - assert.ok(chatArray[0].jid) - assert.ok(chatArray[0].count !== null) - if (chatArray[0].messages.length > 0) { - assert.ok(chatArray[0].messages[0]) - } - } - assert.ok(contacts) - if (contacts.length > 0) { - assert.ok(contacts[0].jid) - } - await conn.logout() - await assert.rejects(async () => conn.connectSlim(auth), 'reconnect should have failed') + const chatArray = conn.chats.all() + if (chatArray.length > 0) { + assert.ok(chatArray[0].jid) + assert.ok(chatArray[0].count !== null) + if (chatArray[0].messages.length > 0) { + assert.ok(chatArray[0].messages[0]) + } + } + const contactValues = Object.values(conn.contacts) + if (contactValues[0]) { + assert.ok(contactValues[0].jid) + } + }) + .then (() => conn.logout()) + .then (() => conn.loadAuthInfo(auth)) + .then (() => ( + conn.connect() + .then (() => assert.fail('should not have reconnected')) + .catch (err => { + assert.ok (err instanceof BaileysError) + assert.ok ((err as BaileysError).status >= 400) + }) + )) + .finally (() => conn.close()) }) }) -describe ('Pending Requests', async () => { +describe ('Reconnects', () => { + it ('should disconnect & reconnect phone', async () => { + const conn = new WAConnection () + await conn.loadAuthInfo('./auth_info.json').connect () + assert.equal (conn.phoneConnected, true) + + try { + const waitForEvent = expect => new Promise (resolve => { + conn.on ('connection-phone-change', ({connected}) => { + assert.equal (connected, expect) + conn.removeAllListeners ('connection-phone-change') + resolve () + }) + }) + + console.log ('disconnect your phone from the internet') + await waitForEvent (false) + console.log ('reconnect your phone to the internet') + await waitForEvent (true) + } finally { + conn.close () + } + }) + it ('should reconnect on broken connection', async () => { + const conn = new WAConnection () + conn.autoReconnect = ReconnectMode.onConnectionLost + + await conn.loadAuthInfo('./auth_info.json').connect () + assert.equal (conn.phoneConnected, true) + + try { + const closeConn = () => conn['conn']?.terminate () + + const task = new Promise (resolve => { + let closes = 0 + conn.on ('close', ({reason, isReconnecting}) => { + console.log (`closed: ${reason}`) + assert.ok (reason) + assert.ok (isReconnecting) + closes += 1 + + // let it fail reconnect a few times + if (closes > 4) { + conn.removeAllListeners ('close') + conn.removeAllListeners ('connecting') + resolve () + } + }) + conn.on ('connecting', () => { + // close again + delay (3500).then (closeConn) + }) + }) + + closeConn () + await task + + await new Promise (resolve => { + conn.on ('open', () => { + conn.removeAllListeners ('open') + resolve () + }) + }) + + conn.close () + + conn.on ('connecting', () => assert.fail('should not connect')) + await delay (2000) + } finally { + conn.removeAllListeners ('connecting') + conn.removeAllListeners ('close') + conn.removeAllListeners ('open') + conn.close () + } + }) +}) +describe ('Pending Requests', () => { it('should queue requests when closed', async () => { const conn = new WAConnection () conn.pendingRequestTimeoutMs = null - await conn.connectSlim () + await conn.loadAuthInfo('./auth_info.json').connect () - await createTimeout (2000) + await delay (2000) conn.close () - const task: Promise = new Promise ((resolve, reject) => { - conn.query(['query', 'Status', conn.userMetaData.id]) - .then (json => resolve(json)) - .catch (error => reject ('should not have failed, got error: ' + error)) - }) + const task: Promise = conn.query({json: ['query', 'Status', conn.user.id]}) - await createTimeout (2000) + await delay (2000) - await conn.connectSlim () + conn.connect () const json = await task assert.ok (json.status) diff --git a/src/Tests/Tests.Groups.ts b/src/Tests/Tests.Groups.ts index a71a604..aa45779 100644 --- a/src/Tests/Tests.Groups.ts +++ b/src/Tests/Tests.Groups.ts @@ -1,12 +1,18 @@ -import { MessageType, GroupSettingChange, createTimeout, ChatModification, whatsappID } from '../WAConnection/WAConnection' +import { MessageType, GroupSettingChange, delay, ChatModification } from '../WAConnection/WAConnection' import * as assert from 'assert' -import { WAConnectionTest, testJid, sendAndRetreiveMessage } from './Common' +import { WAConnectionTest, testJid } from './Common' WAConnectionTest('Groups', (conn) => { let gid: string it('should create a group', async () => { const response = await conn.groupCreate('Cool Test Group', [testJid]) + assert.ok (conn.chats.get(response.gid)) + + const {chats} = await conn.loadChats(10, null) + assert.equal (chats[0].jid, response.gid) // first chat should be new group + gid = response.gid + console.log('created group: ' + JSON.stringify(response)) }) it('should retreive group invite code', async () => { @@ -22,8 +28,18 @@ WAConnectionTest('Groups', (conn) => { it('should update the group description', async () => { const newDesc = 'Wow this was set from Baileys' + const waitForEvent = new Promise (resolve => { + conn.on ('group-description-update', ({jid, actor}) => { + if (jid === gid) { + assert.ok (actor, conn.user.id) + resolve () + } + }) + }) await conn.groupUpdateDescription (gid, newDesc) - await createTimeout (1000) + await waitForEvent + + conn.removeAllListeners ('group-description-update') const metadata = await conn.groupMetadata(gid) assert.strictEqual(metadata.desc, newDesc) @@ -32,39 +48,102 @@ WAConnectionTest('Groups', (conn) => { await conn.sendMessage(gid, 'hello', MessageType.text) }) it('should quote a message on the group', async () => { - const messages = await conn.loadConversation (gid, 20) + const {messages} = await conn.loadMessages (gid, 100) const quotableMessage = messages.find (m => m.message) assert.ok (quotableMessage, 'need at least one message') - const response = await conn.sendMessage(gid, 'hello', MessageType.extendedText, {quoted: messages[0]}) - const messagesNew = await conn.loadConversation(gid, 10, null, true) - const message = messagesNew.find (m => m.key.id === response.key.id)?.message?.extendedTextMessage + const response = await conn.sendMessage(gid, 'hello', MessageType.extendedText, {quoted: quotableMessage}) + const loaded = await conn.loadMessages(gid, 10) + const message = loaded.messages.find (m => m.key.id === response.key.id)?.message?.extendedTextMessage assert.ok(message) assert.equal (message.contextInfo.stanzaId, quotableMessage.key.id) }) it('should update the subject', async () => { - const subject = 'V Cool Title' + const subject = 'Baileyz ' + Math.floor(Math.random()*5) + const waitForEvent = new Promise (resolve => { + conn.on ('chat-update', ({jid, name}) => { + if (jid === gid) { + assert.equal (name, subject) + resolve () + } + }) + }) await conn.groupUpdateSubject(gid, subject) + await waitForEvent + conn.removeAllListeners ('chat-update') const metadata = await conn.groupMetadata(gid) assert.strictEqual(metadata.subject, subject) }) it('should update the group settings', async () => { + const waitForEvent = new Promise (resolve => { + conn.on ('group-settings-update', ({jid, announce}) => { + if (jid === gid) { + assert.equal (announce, 'true') + resolve () + } + }) + }) await conn.groupSettingChange (gid, GroupSettingChange.messageSend, true) - await createTimeout (5000) + + await waitForEvent + conn.removeAllListeners ('group-settings-update') + + await delay (2000) await conn.groupSettingChange (gid, GroupSettingChange.settingsChange, true) }) it('should remove someone from a group', async () => { + const waitForEvent = new Promise (resolve => { + conn.on ('group-participants-remove', ({jid, participants}) => { + if (jid === gid) { + assert.equal (participants[0], testJid) + resolve () + } + }) + }) await conn.groupRemove(gid, [testJid]) + await waitForEvent + conn.removeAllListeners ('group-participants-remove') }) it('should leave the group', async () => { + const waitForEvent = new Promise (resolve => { + conn.on ('chat-update', ({jid, read_only}) => { + if (jid === gid) { + assert.equal (read_only, 'true') + resolve () + } + }) + }) await conn.groupLeave(gid) + await waitForEvent + conn.removeAllListeners ('chat-update') + await conn.groupMetadataMinimal (gid) }) it('should archive the group', async () => { + const waitForEvent = new Promise (resolve => { + conn.on ('chat-update', ({jid, archive}) => { + if (jid === gid) { + assert.equal (archive, 'true') + resolve () + } + }) + }) await conn.modifyChat(gid, ChatModification.archive) + await waitForEvent + conn.removeAllListeners ('chat-update') }) it('should delete the group', async () => { + const waitForEvent = new Promise (resolve => { + conn.on ('chat-update', (chat) => { + if (chat.jid === gid) { + assert.equal (chat['delete'], 'true') + resolve () + } + }) + }) await conn.deleteChat(gid) + await waitForEvent + conn.removeAllListeners ('chat-update') }) }) \ No newline at end of file diff --git a/src/Tests/Tests.Messages.ts b/src/Tests/Tests.Messages.ts index c513833..e421e87 100644 --- a/src/Tests/Tests.Messages.ts +++ b/src/Tests/Tests.Messages.ts @@ -1,18 +1,18 @@ -import { MessageType, Mimetype, createTimeout } from '../WAConnection/WAConnection' +import { MessageType, Mimetype, delay, promiseTimeout, WAMessage, WA_MESSAGE_STATUS_TYPE, MessageStatusUpdate } from '../WAConnection/WAConnection' import {promises as fs} from 'fs' import * as assert from 'assert' import { WAConnectionTest, testJid, sendAndRetreiveMessage } from './Common' WAConnectionTest('Messages', (conn) => { it('should send a text message', async () => { - const message = await sendAndRetreiveMessage(conn, 'hello fren', MessageType.text) - assert.strictEqual(message.message.conversation, 'hello fren') + //const message = await sendAndRetreiveMessage(conn, 'hello fren', MessageType.text) + //assert.strictEqual(message.message.conversation || message.message.extendedTextMessage?.text, 'hello fren') }) it('should forward a message', async () => { - let messages = await conn.loadConversation (testJid, 1) + let messages = await conn.loadMessages (testJid, 1) await conn.forwardMessage (testJid, messages[0], true) - messages = await conn.loadConversation (testJid, 1) + messages = await conn.loadMessages (testJid, 1) const message = messages[0] const content = message.message[ Object.keys(message.message)[0] ] assert.equal (content?.contextInfo?.isForwarded, true) @@ -28,11 +28,15 @@ WAConnectionTest('Messages', (conn) => { assert.ok (received.jpegThumbnail) }) it('should quote a message', async () => { - const messages = await conn.loadConversation(testJid, 2) + const {messages} = await conn.loadMessages(testJid, 2) const message = await sendAndRetreiveMessage(conn, 'hello fren 2', MessageType.extendedText, { quoted: messages[0], }) assert.strictEqual(message.message.extendedTextMessage.contextInfo.stanzaId, messages[0].key.id) + assert.strictEqual( + message.message.extendedTextMessage.contextInfo.participant, + messages[0].key.fromMe ? conn.user.id : messages[0].key.id + ) }) it('should send a gif', async () => { const content = await fs.readFile('./Media/ma_gif.mp4') @@ -48,21 +52,36 @@ WAConnectionTest('Messages', (conn) => { //const message2 = await sendAndRetreiveMessage (conn, 'this is a quote', MessageType.extendedText) }) it('should send an image & quote', async () => { - const messages = await conn.loadConversation(testJid, 1) + const messages = await conn.loadMessages(testJid, 1) const content = await fs.readFile('./Media/meme.jpeg') const message = await sendAndRetreiveMessage(conn, content, MessageType.image, { quoted: messages[0] }) await conn.downloadMediaMessage(message) // check for successful decoding assert.strictEqual(message.message.imageMessage.contextInfo.stanzaId, messages[0].key.id) }) - it('should send a text message & delete it', async () => { + it('should send a message & delete it', async () => { const message = await sendAndRetreiveMessage(conn, 'hello fren', MessageType.text) - await createTimeout (2000) + await delay (2000) await conn.deleteMessage (testJid, message.key) }) it('should clear the most recent message', async () => { - const messages = await conn.loadConversation (testJid, 1) - await createTimeout (2000) + const messages = await conn.loadMessages (testJid, 1) + await delay (2000) await conn.clearMessage (messages[0].key) }) -}) \ No newline at end of file +}) +WAConnectionTest('Message Events', (conn) => { + it('should deliver a message', async () => { + const waitForUpdate = + promiseTimeout(15000, resolve => { + conn.on('message-update', update => { + if (update.ids.includes(response.key.id)) { + resolve(update) + } + }) + }) as Promise + const response = await conn.sendMessage(testJid, 'My Name Jeff', MessageType.text) + const m = await waitForUpdate + assert.ok (m.type >= WA_MESSAGE_STATUS_TYPE.DELIVERY_ACK) + }) +}) diff --git a/src/Tests/Tests.Misc.ts b/src/Tests/Tests.Misc.ts index 9404858..bf06015 100644 --- a/src/Tests/Tests.Misc.ts +++ b/src/Tests/Tests.Misc.ts @@ -1,20 +1,9 @@ -import { MessageType, Presence, ChatModification, promiseTimeout, createTimeout } from '../WAConnection/WAConnection' +import { Presence, ChatModification, delay } from '../WAConnection/WAConnection' import {promises as fs} from 'fs' import * as assert from 'assert' import fetch from 'node-fetch' import { WAConnectionTest, testJid } from './Common' -WAConnectionTest('Presence', (conn) => { - it('should update presence', async () => { - const presences = Object.values(Presence) - for (const i in presences) { - const response = await conn.updatePresence(testJid, presences[i]) - assert.strictEqual(response.status, 200) - - await createTimeout(1500) - } - }) -}) WAConnectionTest('Misc', (conn) => { it('should tell if someone has an account on WhatsApp', async () => { const response = await conn.isOnWhatsApp(testJid) @@ -30,16 +19,28 @@ WAConnectionTest('Misc', (conn) => { it('should update status', async () => { const newStatus = 'v cool status' + const waitForEvent = new Promise (resolve => { + conn.on ('user-status-update', ({jid, status}) => { + if (jid === conn.user.id) { + assert.equal (status, newStatus) + conn.removeAllListeners ('user-status-update') + resolve () + } + }) + }) + const response = await conn.getStatus() assert.strictEqual(typeof response.status, 'string') - await createTimeout (1000) + await delay (1000) await conn.setStatus (newStatus) const response2 = await conn.getStatus() assert.equal (response2.status, newStatus) - await createTimeout (1000) + await waitForEvent + + await delay (1000) await conn.setStatus (response.status) // update back }) @@ -47,18 +48,18 @@ WAConnectionTest('Misc', (conn) => { await conn.getStories() }) it('should change the profile picture', async () => { - await createTimeout (5000) + await delay (5000) - const ppUrl = await conn.getProfilePicture(conn.userMetaData.id) + const ppUrl = await conn.getProfilePicture(conn.user.id) const fetched = await fetch(ppUrl, { headers: { Origin: 'https://web.whatsapp.com' } }) const buff = await fetched.buffer () const newPP = await fs.readFile ('./Media/cat.jpeg') - const response = await conn.updateProfilePicture (conn.userMetaData.id, newPP) + const response = await conn.updateProfilePicture (conn.user.id, newPP) - await createTimeout (10000) + await delay (10000) - await conn.updateProfilePicture (conn.userMetaData.id, buff) // revert back + await conn.updateProfilePicture (conn.user.id, buff) // revert back }) it('should return the profile picture', async () => { const response = await conn.getProfilePicture(testJid) @@ -70,23 +71,42 @@ WAConnectionTest('Misc', (conn) => { assert.ok(response) }) it('should mark a chat unread', async () => { - await conn.sendReadReceipt(testJid, null, 'unread') + const waitForEvent = new Promise (resolve => { + conn.on ('chat-update', ({jid, count}) => { + if (jid === testJid) { + assert.ok (count < 0) + conn.removeAllListeners ('chat-update') + resolve () + } + }) + }) + await conn.sendReadReceipt(testJid, null, -2) + await waitForEvent }) it('should archive & unarchive', async () => { await conn.modifyChat (testJid, ChatModification.archive) - await createTimeout (2000) + await delay (2000) await conn.modifyChat (testJid, ChatModification.unarchive) }) it('should pin & unpin a chat', async () => { - const response = await conn.modifyChat (testJid, ChatModification.pin) - await createTimeout (2000) - await conn.modifyChat (testJid, ChatModification.unpin, {stamp: response.stamp}) + await conn.modifyChat (testJid, ChatModification.pin) + await delay (2000) + await conn.modifyChat (testJid, ChatModification.unpin) }) it('should mute & unmute a chat', async () => { - const mutedate = new Date (new Date().getTime() + 8*60*60*1000) // 8 hours in the future - await conn.modifyChat (testJid, ChatModification.mute, {stamp: mutedate}) - await createTimeout (2000) - await conn.modifyChat (testJid, ChatModification.unmute, {stamp: mutedate}) + const waitForEvent = new Promise (resolve => { + conn.on ('chat-update', ({jid, mute}) => { + if (jid === testJid ) { + assert.ok (mute) + conn.removeAllListeners ('chat-update') + resolve () + } + }) + }) + await conn.modifyChat (testJid, ChatModification.mute, 8*60*60*1000) // 8 hours in the future + await waitForEvent + await delay (2000) + await conn.modifyChat (testJid, ChatModification.unmute) }) it('should return search results', async () => { const jids = [null, testJid] @@ -96,18 +116,22 @@ WAConnectionTest('Misc', (conn) => { assert.ok (response.messages.length >= 0) } }) -}) -WAConnectionTest('Events', (conn) => { - it('should deliver a message', async () => { - const waitForUpdate = () => - new Promise((resolve) => { - conn.setOnMessageStatusChange((update) => { - if (update.ids.includes(response.key.id)) { - resolve() - } - }) - }) - const response = await conn.sendMessage(testJid, 'My Name Jeff', MessageType.text) - await promiseTimeout(15000, waitForUpdate()) + it('should load a single message', async () => { + const {messages} = await conn.loadMessages (testJid, 10) + for (var message of messages) { + const loaded = await conn.loadMessage (testJid, message.key.id) + assert.equal (loaded.key.id, message.key.id) + await delay (1000) + } }) -}) + + it('should update presence', async () => { + const presences = Object.values(Presence) + for (const i in presences) { + const response = await conn.updatePresence(testJid, presences[i]) + assert.strictEqual(response.status, 200) + + await delay(1500) + } + }) +}) \ No newline at end of file diff --git a/src/WAConnection/0.Base.ts b/src/WAConnection/0.Base.ts index c8acc58..8ab70b5 100644 --- a/src/WAConnection/0.Base.ts +++ b/src/WAConnection/0.Base.ts @@ -1,4 +1,3 @@ -import * as QR from 'qrcode-terminal' import * as fs from 'fs' import WS from 'ws' import * as Utils from './Utils' @@ -6,120 +5,84 @@ import Encoder from '../Binary/Encoder' import Decoder from '../Binary/Decoder' import { AuthenticationCredentials, - UserMetaData, + WAUser, WANode, - AuthenticationCredentialsBase64, WATag, MessageLogLevel, - AuthenticationCredentialsBrowser, BaileysError, - WAConnectionMode, - WAMessage, - PresenceUpdate, - MessageStatusUpdate, WAMetric, WAFlag, + DisconnectReason, + WAConnectionState, + AnyAuthenticationCredentials, + WAContact, + WAChat, + WAQuery, + ReconnectMode, } from './Constants' +import { EventEmitter } from 'events' +import KeyedDB from '@adiwajshing/keyed-db' -/** Generate a QR code from the ref & the curve public key. This is scanned by the phone */ -const generateQRCode = function ([ref, publicKey, clientID]) { - const str = ref + ',' + publicKey + ',' + clientID - QR.generate(str, { small: true }) -} - -export class WAConnection { +export class WAConnection extends EventEmitter { /** The version of WhatsApp Web we're telling the servers we are */ - version: [number, number, number] = [2, 2027, 10] + version: [number, number, number] = [2, 2033, 7] /** The Browser we're telling the WhatsApp Web servers we are */ browserDescription: [string, string, string] = Utils.Browsers.baileys ('Chrome') /** Metadata like WhatsApp id, name set on WhatsApp etc. */ - userMetaData: UserMetaData = { id: null, name: null, phone: null } - /** Should reconnect automatically after an unexpected disconnect */ - autoReconnect = true - lastSeen: Date = null + user: WAUser /** What level of messages to log to the console */ logLevel: MessageLogLevel = MessageLogLevel.info /** Should requests be queued when the connection breaks in between; if false, then an error will be thrown */ pendingRequestTimeoutMs: number = null - connectionMode: WAConnectionMode = WAConnectionMode.onlyRequireValidation - /** What to do when you need the phone to authenticate the connection (generate QR code by default) */ - onReadyForPhoneAuthentication = generateQRCode - - protected unexpectedDisconnectCallback: (err: string) => any + /** The connection state */ + state: WAConnectionState = 'close' + /** New QR generation interval, set to null if you don't want to regenerate */ + regenerateQRIntervalMs = 30*1000 + + autoReconnect = ReconnectMode.onConnectionLost + /** Whether the phone is connected */ + phoneConnected: boolean = false + + maxCachedMessages = 25 + + contacts: {[k: string]: WAContact} = {} + chats: KeyedDB = new KeyedDB (Utils.waChatUniqueKey, value => value.jid) + /** Data structure of tokens & IDs used to establish one's identiy to WhatsApp Web */ - protected authInfo: AuthenticationCredentials = { - clientID: null, - serverToken: null, - clientToken: null, - encKey: null, - macKey: null, - } + protected authInfo: AuthenticationCredentials = null /** Curve keys to initially authenticate */ protected curveKeys: { private: Uint8Array; public: Uint8Array } /** The websocket connection */ protected conn: WS = null protected msgCount = 0 protected keepAliveReq: NodeJS.Timeout - protected callbacks = {} + protected callbacks: {[k: string]: any} = {} protected encoder = new Encoder() protected decoder = new Decoder() - protected pendingRequests: (() => void)[] = [] - protected reconnectLoop: () => Promise + protected pendingRequests: {resolve: () => void, reject: (error) => void}[] = [] protected referenceDate = new Date () // used for generating tags + protected lastSeen: Date = null // last keep alive received + protected qrTimeout: NodeJS.Timeout + protected phoneCheck: NodeJS.Timeout + + protected lastDisconnectReason: DisconnectReason + protected cancelledReconnect = false + protected cancelReconnect: () => void + constructor () { - this.registerCallback (['Cmd', 'type:disconnect'], json => this.unexpectedDisconnect(json[1].kind)) + super () + this.registerCallback (['Cmd', 'type:disconnect'], json => this.unexpectedDisconnect(json[1].kind || 'unknown')) } - async unexpectedDisconnect (error: string) { - this.close() - if ((error === 'lost' || error === 'closed') && this.autoReconnect) { - await this.reconnectLoop () - } else if (this.unexpectedDisconnectCallback) { - this.unexpectedDisconnectCallback (error) - } - } - /** Set the callback for message status updates (when a message is delivered, read etc.) */ - setOnMessageStatusChange(callback: (update: MessageStatusUpdate) => void) { - const func = json => { - json = json[1] - let ids = json.id - if (json.cmd === 'ack') { - ids = [json.id] - } - const data: MessageStatusUpdate = { - from: json.from, - to: json.to, - participant: json.participant, - timestamp: new Date(json.t * 1000), - ids: ids, - type: (+json.ack)+1, - } - callback(data) - } - this.registerCallback('Msg', func) - this.registerCallback('MsgInfo', func) - } - /** - * Set the callback for new/unread messages; if someone sends you a message, this callback will be fired - * @param callbackOnMyMessages - should the callback be fired on a message you sent from the phone - */ - setOnUnreadMessage(callbackOnMyMessages = false, callback: (m: WAMessage) => void) { - this.registerCallback(['action', 'add:relay', 'message'], (json) => { - const message = json[2][0][2] - if (!message.key.fromMe || callbackOnMyMessages) { - // if this message was sent to us, notify - callback(message as WAMessage) - } else { - this.log(`[Unhandled] message - ${JSON.stringify(message)}`, MessageLogLevel.unhandled) - } - }) - } - /** Set the callback for presence updates; if someone goes offline/online, this callback will be fired */ - setOnPresenceUpdate(callback: (p: PresenceUpdate) => void) { - this.registerCallback('Presence', json => callback(json[1])) - } - /** Set the callback for unexpected disconnects including take over events, log out events etc. */ - setOnUnexpectedDisconnect(callback: (error: string) => void) { - this.unexpectedDisconnectCallback = callback + async unexpectedDisconnect (error: DisconnectReason) { + const willReconnect = + (this.autoReconnect === ReconnectMode.onAllErrors || + (this.autoReconnect === ReconnectMode.onConnectionLost && (error !== 'replaced'))) && + error !== 'invalid_session' + + this.log (`got disconnected, reason ${error}${willReconnect ? ', reconnecting in a few seconds...' : ''}`, MessageLogLevel.info) + this.closeInternal(error, willReconnect) + + willReconnect && !this.cancelReconnect && this.reconnectLoop () } /** * base 64 encode the authentication credentials and return them @@ -135,68 +98,42 @@ export class WAConnection { macKey: this.authInfo.macKey.toString('base64'), } } - /** - * Clear authentication info so a new connection can be created - */ + /** Clear authentication info so a new connection can be created */ clearAuthInfo () { - this.authInfo = { - clientID: null, - serverToken: null, - clientToken: null, - encKey: null, - macKey: null, - } + this.authInfo = null + return this } /** * Load in the authentication credentials - * @param authInfo the authentication credentials or path to auth credentials JSON + * @param authInfo the authentication credentials or file path to auth credentials */ - loadAuthInfoFromBase64(authInfo: AuthenticationCredentialsBase64 | string) { - if (!authInfo) { - throw new Error('given authInfo is null') - } - if (typeof authInfo === 'string') { - this.log(`loading authentication credentials from ${authInfo}`, MessageLogLevel.info) - const file = fs.readFileSync(authInfo, { encoding: 'utf-8' }) // load a closed session back if it exists - authInfo = JSON.parse(file) as AuthenticationCredentialsBase64 - } - this.authInfo = { - clientID: authInfo.clientID, - serverToken: authInfo.serverToken, - clientToken: authInfo.clientToken, - encKey: Buffer.from(authInfo.encKey, 'base64'), // decode from base64 - macKey: Buffer.from(authInfo.macKey, 'base64'), // decode from base64 - } - } - /** - * Load in the authentication credentials - * @param authInfo the authentication credentials or path to browser credentials JSON - */ - loadAuthInfoFromBrowser(authInfo: AuthenticationCredentialsBrowser | string) { + loadAuthInfo(authInfo: AnyAuthenticationCredentials | string) { if (!authInfo) throw new Error('given authInfo is null') if (typeof authInfo === 'string') { this.log(`loading authentication credentials from ${authInfo}`, MessageLogLevel.info) const file = fs.readFileSync(authInfo, { encoding: 'utf-8' }) // load a closed session back if it exists - authInfo = JSON.parse(file) as AuthenticationCredentialsBrowser + authInfo = JSON.parse(file) as AnyAuthenticationCredentials } - const secretBundle: {encKey: string, macKey: string} = typeof authInfo === 'string' ? JSON.parse (authInfo): authInfo - this.authInfo = { - clientID: authInfo.WABrowserId.replace(/\"/g, ''), - serverToken: authInfo.WAToken2.replace(/\"/g, ''), - clientToken: authInfo.WAToken1.replace(/\"/g, ''), - encKey: Buffer.from(secretBundle.encKey, 'base64'), // decode from base64 - macKey: Buffer.from(secretBundle.macKey, 'base64'), // decode from base64 - } - } - /** - * Register for a callback for a certain function, will cancel automatically after one execution - * @param {[string, object, string] | string} parameters name of the function along with some optional specific parameters - */ - async registerCallbackOneTime(parameters) { - const json = await new Promise((resolve, _) => this.registerCallback(parameters, resolve)) - this.deregisterCallback(parameters) - return json + if ('clientID' in authInfo) { + this.authInfo = { + clientID: authInfo.clientID, + serverToken: authInfo.serverToken, + clientToken: authInfo.clientToken, + encKey: Buffer.isBuffer(authInfo.encKey) ? authInfo.encKey : Buffer.from(authInfo.encKey, 'base64'), + macKey: Buffer.isBuffer(authInfo.macKey) ? authInfo.macKey : Buffer.from(authInfo.macKey, 'base64'), + } + } else { + const secretBundle: {encKey: string, macKey: string} = typeof authInfo === 'string' ? JSON.parse (authInfo): authInfo + this.authInfo = { + clientID: authInfo.WABrowserId.replace(/\"/g, ''), + serverToken: authInfo.WAToken2.replace(/\"/g, ''), + clientToken: authInfo.WAToken1.replace(/\"/g, ''), + encKey: Buffer.from(secretBundle.encKey, 'base64'), // decode from base64 + macKey: Buffer.from(secretBundle.macKey, 'base64'), // decode from base64 + } + } + return this } /** * Register for a callback for a certain function @@ -247,30 +184,20 @@ export class WAConnection { * @param timeoutMs timeout after which the promise will reject */ async waitForMessage(tag: string, json: Object = null, timeoutMs: number = null) { - let promise = new Promise( + let promise = Utils.promiseTimeout(timeoutMs, (resolve, reject) => (this.callbacks[tag] = { queryJSON: json, callback: resolve, errCallback: reject }), ) - if (timeoutMs) { - promise = Utils.promiseTimeout(timeoutMs, promise).catch((err) => { - delete this.callbacks[tag] - throw err - }) - } + .catch((err) => { + delete this.callbacks[tag] + throw err + }) return promise as Promise } - /** - * Query something from the WhatsApp servers and error on a non-200 status - * @param json the query itself - * @param [binaryTags] the tags to attach if the query is supposed to be sent encoded in binary - * @param [timeoutMs] timeout after which the query will be failed (set to null to disable a timeout) - * @param [tag] the tag to attach to the message - */ - async queryExpecting200(json: any[] | WANode, binaryTags?: WATag, timeoutMs?: number, tag?: string) { - const response = await this.query(json, binaryTags, timeoutMs, tag) - if (response.status && Math.floor(+response.status / 100) !== 2) { - throw new BaileysError(`Unexpected status code in '${json[0] || 'generic query'}': ${response.status}`, {query: json}) - } - return response + /** Generic function for action, set queries */ + async setQuery (nodes: WANode[], binaryTags: WATag = [WAMetric.group, WAFlag.ignore], tag?: string) { + const json = ['action', {epoch: this.msgCount.toString(), type: 'set'}, nodes] + const result = await this.query({ json, binaryTags, tag, expect200: true }) as Promise<{status: number}> + return result } /** * Query something from the WhatsApp servers @@ -280,17 +207,23 @@ export class WAConnection { * @param tag the tag to attach to the message * recieved JSON */ - async query(json: any[] | WANode, binaryTags?: WATag, timeoutMs?: number, tag?: string) { + async query({json, binaryTags, tag, timeoutMs, expect200, waitForOpen}: WAQuery) { + waitForOpen = typeof waitForOpen === 'undefined' ? true : waitForOpen + await this.waitForConnection (waitForOpen) + if (binaryTags) tag = await this.sendBinary(json as WANode, binaryTags, tag) else tag = await this.sendJSON(json, tag) - return this.waitForMessage(tag, json, timeoutMs) - } - /** Generic function for action, set queries */ - async setQuery (nodes: WANode[], binaryTags: WATag = [WAMetric.group, WAFlag.ignore], tag?: string) { - const json = ['action', {epoch: this.msgCount.toString(), type: 'set'}, nodes] - const result = await this.queryExpecting200(json, binaryTags, null, tag) as Promise<{status: number}> - return result + const response = await this.waitForMessage(tag, json, timeoutMs) + if (expect200 && response.status && Math.floor(+response.status / 100) !== 2) { + if (response.status >= 500) { + this.unexpectedDisconnect ('bad_session') + const response = await this.query ({json, binaryTags, tag, timeoutMs, expect200, waitForOpen}) + return response + } + throw new BaileysError(`Unexpected status code in '${json[0] || 'generic query'}': ${response.status}`, {query: json}) + } + return response } /** * Send a binary encoded message @@ -299,9 +232,7 @@ export class WAConnection { * @param tag the tag to attach to the message * @return the message tag */ - protected async sendBinary(json: WANode, tags: WATag, tag?: string) { - if (!this.conn || this.conn.readyState !== this.conn.OPEN) await this.waitForConnection () - + protected sendBinary(json: WANode, tags: WATag, tag: string = null) { const binary = this.encoder.write(json) // encode the JSON to the WhatsApp binary format let buff = Utils.aesEncrypt(binary, this.authInfo.encKey) // encrypt it using AES and our encKey @@ -313,7 +244,7 @@ export class WAConnection { sign, // the HMAC sign of the message buff, // the actual encrypted buffer ]) - await this.send(buff) // send it off + this.send(buff) // send it off return tag } /** @@ -322,23 +253,22 @@ export class WAConnection { * @param tag the tag to attach to the message * @return the message tag */ - protected async sendJSON(json: any[] | WANode, tag: string = null) { + protected sendJSON(json: any[] | WANode, tag: string = null) { tag = tag || this.generateMessageTag() - await this.send(tag + ',' + JSON.stringify(json)) + this.send(`${tag},${JSON.stringify(json)}`) return tag } /** Send some message to the WhatsApp servers */ - protected async send(m) { - if (!this.conn || this.conn.readyState !== this.conn.OPEN) await this.waitForConnection () - + protected send(m) { this.msgCount += 1 // increment message count, it makes the 'epoch' field when sending binary messages return this.conn.send(m) } - protected async waitForConnection () { + protected async waitForConnection (waitForOpen: boolean=true) { + if (!waitForOpen || this.state === 'open') return + const timeout = this.pendingRequestTimeoutMs try { - const task = new Promise (resolve => this.pendingRequests.push(resolve)) - await Utils.promiseTimeout (timeout, task) + await Utils.promiseTimeout (timeout, (resolve, reject) => this.pendingRequests.push({resolve, reject})) } catch { throw new Error('cannot send message, disconnected from WhatsApp') } @@ -347,38 +277,55 @@ export class WAConnection { * Disconnect from the phone. Your auth credentials become invalid after sending a disconnect request. * @see close() if you just want to close the connection */ - async logout() { - if (!this.conn) throw new Error("You're not even connected, you can't log out") - - await new Promise(resolve => this.conn.send('goodbye,["admin","Conn","disconnect"]', null, resolve)) + async logout () { this.authInfo = null + if (this.state === 'open') { + //throw new Error("You're not even connected, you can't log out") + await new Promise(resolve => this.conn.send('goodbye,["admin","Conn","disconnect"]', null, resolve)) + } this.close() } - /** Close the connection to WhatsApp Web */ - close() { + close () { + this.closeInternal ('intentional') + this.cancelReconnect && this.cancelReconnect () + } + protected closeInternal (reason?: DisconnectReason, isReconnecting: boolean=false) { + this.qrTimeout && clearTimeout (this.qrTimeout) + this.phoneCheck && clearTimeout (this.phoneCheck) + + this.state = 'close' this.msgCount = 0 - if (this.conn) { - this.conn.removeAllListeners ('close') - this.conn.close() - this.conn = null + this.conn?.removeAllListeners ('close') + this.conn?.close() + this.conn = null + this.phoneConnected = false + this.lastDisconnectReason = reason + + if (reason === 'invalid_session' || reason === 'intentional') { + this.pendingRequests.forEach (({reject}) => reject(new Error('close'))) + this.pendingRequests = [] } - const keys = Object.keys(this.callbacks) - keys.forEach(key => { + + Object.keys(this.callbacks).forEach(key => { if (!key.includes('function:')) { - this.callbacks[key].errCallback('connection closed') + this.log (`cancelling message wait: ${key}`, MessageLogLevel.info) + this.callbacks[key].errCallback(new Error('close')) delete this.callbacks[key] } }) - if (this.keepAliveReq) { - clearInterval(this.keepAliveReq) - } + if (this.keepAliveReq) clearInterval(this.keepAliveReq) + + // reconnecting if the timeout is active for the reconnect loop + this.emit ('close', { reason, isReconnecting: this.cancelReconnect || isReconnecting}) + } + protected async reconnectLoop () { + } generateMessageTag () { - return `${Math.round(this.referenceDate.getTime())/1000}.--${this.msgCount}` + return `${Utils.unixTimestampSeconds(this.referenceDate)}.--${this.msgCount}` } protected log(text, level: MessageLogLevel) { - if (this.logLevel >= level) - console.log(`[Baileys][${new Date().toLocaleString()}] ${text}`) + (this.logLevel >= level) && console.log(`[Baileys][${new Date().toLocaleString()}] ${text}`) } } diff --git a/src/WAConnection/1.Validation.ts b/src/WAConnection/1.Validation.ts index ca5f079..e2ee615 100644 --- a/src/WAConnection/1.Validation.ts +++ b/src/WAConnection/1.Validation.ts @@ -6,36 +6,31 @@ import { MessageLogLevel, WAMetric, WAFlag, BaileysError, Presence } from './Con export class WAConnection extends Base { /** Authenticate the connection */ - protected async authenticate() { - if (!this.authInfo.clientID) { - // if no auth info is present, that is, a new session has to be established - // generate a client ID - this.authInfo = { - clientID: Utils.generateClientID(), - clientToken: null, - serverToken: null, - encKey: null, - macKey: null, - } + protected async authenticate (reconnect?: string) { + // if no auth info is present, that is, a new session has to be established + // generate a client ID + if (!this.authInfo?.clientID) { + this.authInfo = { clientID: Utils.generateClientID() } as any } this.referenceDate = new Date () // refresh reference date - const data = ['admin', 'init', this.version, this.browserDescription, this.authInfo.clientID, true] + const json = ['admin', 'init', this.version, this.browserDescription, this.authInfo?.clientID, true] - return this.queryExpecting200(data) + return this.query({json, expect200: true, waitForOpen: false}) .then(json => { // we're trying to establish a new connection or are trying to log in - if (this.authInfo.encKey && this.authInfo.macKey) { + if (this.authInfo?.encKey && this.authInfo?.macKey) { // if we have the info to restore a closed session - const data = [ + const json = [ 'admin', 'login', - this.authInfo.clientToken, - this.authInfo.serverToken, - this.authInfo.clientID, - 'takeover', + this.authInfo?.clientToken, + this.authInfo?.serverToken, + this.authInfo?.clientID, ] - return this.query(data, null, null, 's1') // wait for response with tag "s1" + if (reconnect) json.push(...['reconnect', reconnect.replace('@s.whatsapp.net', '@c.us')]) + else json.push ('takeover') + return this.query({ json, tag: 's1', waitForOpen: false }) // wait for response with tag "s1" } return this.generateKeysForAuth(json.ref) // generate keys which will in turn be the QR }) @@ -45,9 +40,9 @@ export class WAConnection extends Base { case 401: // if the phone was unpaired throw new BaileysError ('unpaired from phone', json) case 429: // request to login was denied, don't know why it happens - throw new BaileysError ('request denied, try reconnecting', json) + throw new BaileysError ('request denied', json) default: - throw new BaileysError ('unexpected status', json) + throw new BaileysError ('unexpected status ' + json.status, json) } } // if its a challenge request (we get it when logging in) @@ -62,31 +57,29 @@ export class WAConnection extends Base { this.validateNewConnection(json[1]) // validate the connection this.log('validated connection successfully', MessageLogLevel.info) - await this.sendPostConnectQueries () + this.sendPostConnectQueries () this.lastSeen = new Date() // set last seen to right now - return this.userMetaData }) } /** * Send the same queries WA Web sends after connect */ - async sendPostConnectQueries () { - await this.sendBinary (['query', {type: 'contacts', epoch: '1'}, null], [ WAMetric.queryContact, WAFlag.ignore ]) - await this.sendBinary (['query', {type: 'chat', epoch: '1'}, null], [ WAMetric.queryChat, WAFlag.ignore ]) - await this.sendBinary (['query', {type: 'status', epoch: '1'}, null], [ WAMetric.queryStatus, WAFlag.ignore ]) - await this.sendBinary (['query', {type: 'quick_reply', epoch: '1'}, null], [ WAMetric.queryQuickReply, WAFlag.ignore ]) - await this.sendBinary (['query', {type: 'label', epoch: '1'}, null], [ WAMetric.queryLabel, WAFlag.ignore ]) - await this.sendBinary (['query', {type: 'emoji', epoch: '1'}, null], [ WAMetric.queryEmoji, WAFlag.ignore ]) - await this.sendBinary (['action', {type: 'set', epoch: '1'}, [['presence', {type: Presence.available}, null]] ], [ WAMetric.presence, 160 ]) + sendPostConnectQueries () { + this.sendBinary (['query', {type: 'contacts', epoch: '1'}, null], [ WAMetric.queryContact, WAFlag.ignore ]) + this.sendBinary (['query', {type: 'chat', epoch: '1'}, null], [ WAMetric.queryChat, WAFlag.ignore ]) + this.sendBinary (['query', {type: 'status', epoch: '1'}, null], [ WAMetric.queryStatus, WAFlag.ignore ]) + this.sendBinary (['query', {type: 'quick_reply', epoch: '1'}, null], [ WAMetric.queryQuickReply, WAFlag.ignore ]) + this.sendBinary (['query', {type: 'label', epoch: '1'}, null], [ WAMetric.queryLabel, WAFlag.ignore ]) + this.sendBinary (['query', {type: 'emoji', epoch: '1'}, null], [ WAMetric.queryEmoji, WAFlag.ignore ]) + this.sendBinary (['action', {type: 'set', epoch: '1'}, [['presence', {type: Presence.available}, null]] ], [ WAMetric.presence, 160 ]) } /** * Refresh QR Code * @returns the new ref */ - async generateNewQRCode() { - const data = ['admin', 'Conn', 'reref'] - const response = await this.query(data) + async generateNewQRCodeRef() { + const response = await this.query({json: ['admin', 'Conn', 'reref'], expect200: true, waitForOpen: false}) return response.ref as string } /** @@ -97,12 +90,13 @@ export class WAConnection extends Base { private validateNewConnection(json) { const onValidationSuccess = () => { // set metadata: one's WhatsApp ID [cc][number]@s.whatsapp.net, name on WhatsApp, info about the phone - this.userMetaData = { - id: json.wid.replace('@c.us', '@s.whatsapp.net'), + this.user = { + id: Utils.whatsappID(json.wid), name: json.pushname, phone: json.phone, + imgUrl: null } - return this.userMetaData + return this.user } if (!json.secret) { @@ -154,18 +148,41 @@ export class WAConnection extends Base { protected respondToChallenge(challenge: string) { const bytes = Buffer.from(challenge, 'base64') // decode the base64 encoded challenge string const signed = Utils.hmacSign(bytes, this.authInfo.macKey).toString('base64') // sign the challenge string with our macKey - const data = ['admin', 'challenge', signed, this.authInfo.serverToken, this.authInfo.clientID] // prepare to send this signed string with the serverToken & clientID + const json = ['admin', 'challenge', signed, this.authInfo.serverToken, this.authInfo.clientID] // prepare to send this signed string with the serverToken & clientID this.log('resolving login challenge', MessageLogLevel.info) - return this.queryExpecting200(data) + return this.query({json, expect200: true, waitForOpen: false}) } /** When starting a new session, generate a QR code by generating a private/public key pair & the keys the server sends */ protected async generateKeysForAuth(ref: string) { this.curveKeys = Curve.generateKeyPair(Utils.randomBytes(32)) - this.onReadyForPhoneAuthentication([ - ref, - Buffer.from(this.curveKeys.public).toString('base64'), - this.authInfo.clientID, - ]) - return this.waitForMessage('s1', []) + const publicKey = Buffer.from(this.curveKeys.public).toString('base64') + + const emitQR = () => { + const qr = [ref, publicKey, this.authInfo.clientID].join(',') + this.emit ('qr', qr) + } + + const regenQR = () => { + this.qrTimeout = setTimeout (() => { + if (this.state === 'open') return + + this.log ('regenerated QR', MessageLogLevel.info) + + this.generateNewQRCodeRef () + .then (newRef => ref = newRef) + .then (emitQR) + .then (regenQR) + .catch (err => this.log (`error in QR gen: ${err}`, MessageLogLevel.info)) + }, this.regenerateQRIntervalMs) + } + + emitQR () + if (this.regenerateQRIntervalMs) regenQR () + + const json = await this.waitForMessage('s1', []) + this.qrTimeout && clearTimeout (this.qrTimeout) + this.qrTimeout = null + + return json } } diff --git a/src/WAConnection/3.Connect.ts b/src/WAConnection/3.Connect.ts index f91c2b6..c38d17f 100644 --- a/src/WAConnection/3.Connect.ts +++ b/src/WAConnection/3.Connect.ts @@ -1,162 +1,190 @@ -import WS from 'ws' -import KeyedDB from '@adiwajshing/keyed-db' import * as Utils from './Utils' -import { AuthenticationCredentialsBase64, UserMetaData, WAMessage, WAChat, WAContact, MessageLogLevel, WANode, WAConnectionMode } from './Constants' +import { WAMessage, WAChat, WAContact, MessageLogLevel, WANode, KEEP_ALIVE_INTERVAL_MS, BaileysError, WAConnectOptions } from './Constants' import {WAConnection as Base} from './1.Validation' import Decoder from '../Binary/Decoder' export class WAConnection extends Base { /** * Connect to WhatsAppWeb - * @param [authInfo] credentials or path to credentials to log back in - * @param [timeoutMs] timeout after which the connect will fail, set to null for an infinite timeout - * @return returns [userMetaData, chats, contacts] + * @param options the connect options */ - async connect(authInfo: AuthenticationCredentialsBase64 | string = null, timeoutMs: number = null) { + async connect(options: WAConnectOptions = {}) { + // if we're already connected, throw an error + if (this.state !== 'close') throw new Error('cannot connect when state=' + this.state) + + this.state = 'connecting' + this.emit ('connecting') + + const { ws, cancel } = Utils.openWebSocketConnection (5000, typeof options?.retryOnNetworkErrors === 'undefined' ? true : options?.retryOnNetworkErrors) + const promise = Utils.promiseTimeout(options?.timeoutMs, (resolve, reject) => { + ws + .then (conn => this.conn = conn) + .then (() => this.conn.on('message', data => this.onMessageRecieved(data as any))) + .then (() => this.log(`connected to WhatsApp Web server, authenticating via ${options.reconnectID ? 'reconnect' : 'takeover'}`, MessageLogLevel.info)) + .then (() => this.authenticate(options?.reconnectID)) + .then (() => { + this.startKeepAliveRequest() + this.conn.removeAllListeners ('error') + this.conn.removeAllListeners ('close') + this.conn.on ('close', () => this.unexpectedDisconnect ('close')) + }) + .then (resolve) + .catch (reject) + }) + .catch (err => { + cancel () + throw err + }) as Promise + try { - const userInfo = await this.connectSlim(authInfo, timeoutMs) - const chats = await this.receiveChatsAndContacts(timeoutMs) - return [userInfo, ...chats] as [UserMetaData, KeyedDB, WAContact[]] + const tasks = [promise] + + const waitForChats = typeof options?.waitForChats === 'undefined' ? true : options?.waitForChats + if (waitForChats) tasks.push (this.receiveChatsAndContacts(options?.timeoutMs, true)) + + await Promise.all (tasks) + + this.phoneConnected = true + this.state = 'open' + + this.user.imgUrl = await this.getProfilePicture (this.user.id).catch (err => '') + + this.emit ('open') + + this.releasePendingRequests () + this.log ('opened connection to WhatsApp Web', MessageLogLevel.info) + + return this } catch (error) { - this.close () + const loggedOut = error instanceof BaileysError && error.status >= 400 + if (loggedOut && this.cancelReconnect) this.cancelReconnect () + this.closeInternal (loggedOut ? 'invalid_session' : error.message) throw error } } - /** - * Connect to WhatsAppWeb, resolves without waiting for chats & contacts - * @param [authInfo] credentials to log back in - * @param [timeoutMs] timeout after which the connect will fail, set to null for an infinite timeout - * @return [userMetaData, chats, contacts, unreadMessages] - */ - async connectSlim(authInfo: AuthenticationCredentialsBase64 | string = null, timeoutMs: number = null) { - // if we're already connected, throw an error - if (this.conn) throw new Error('already connected or connecting') - // set authentication credentials if required - try { - this.loadAuthInfoFromBase64(authInfo) - } catch {} - - this.conn = new WS('wss://web.whatsapp.com/ws', null, { origin: 'https://web.whatsapp.com' }) - - const promise: Promise = new Promise((resolve, reject) => { - this.conn.on('open', () => { - this.log('connected to WhatsApp Web, authenticating...', MessageLogLevel.info) - // start sending keep alive requests (keeps the WebSocket alive & updates our last seen) - this.authenticate() - .then(user => { - this.startKeepAliveRequest() - - this.conn.removeAllListeners ('error') - this.conn.on ('close', () => this.unexpectedDisconnect ('closed')) - - resolve(user) - }) - .catch(reject) - }) - this.conn.on('message', m => this.onMessageRecieved(m)) - // if there was an error in the WebSocket - this.conn.on('error', error => { this.close(); reject(error) }) - }) - const user = await Utils.promiseTimeout(timeoutMs, promise).catch(err => {this.close(); throw err}) - if (this.connectionMode === WAConnectionMode.onlyRequireValidation) this.releasePendingRequests () - return user + /** Get the URL to download the profile picture of a person/group */ + async getProfilePicture(jid: string | null) { + const response = await this.query({ json: ['query', 'ProfilePicThumb', jid || this.user.id] }) + return response.eurl as string } /** - * Sets up callbacks to receive chats, contacts & unread messages. + * Sets up callbacks to receive chats, contacts & messages. * Must be called immediately after connect * @returns [chats, contacts] */ - async receiveChatsAndContacts(timeoutMs: number = null) { - let contacts: WAContact[] = [] - const chats: KeyedDB = new KeyedDB (Utils.waChatUniqueKey, value => value.jid) + protected async receiveChatsAndContacts(timeoutMs: number = null, stopAfterMostRecentMessage: boolean=false) { + this.contacts = {} + this.chats.clear () let receivedContacts = false let receivedMessages = false - let convoResolve: () => void - this.log('waiting for chats & contacts', MessageLogLevel.info) // wait for the message with chats - const waitForConvos = () => - new Promise(resolve => { - convoResolve = () => { - // de-register the callbacks, so that they don't get called again - this.deregisterCallback(['action', 'add:last']) - this.deregisterCallback(['action', 'add:before']) - this.deregisterCallback(['action', 'add:unread']) - resolve() - } - const chatUpdate = json => { - receivedMessages = true - const isLast = json[1].last - const messages = json[2] as WANode[] + let resolveTask: () => void + const deregisterCallbacks = () => { + // wait for actual messages to load, "last" is the most recent message, "before" contains prior messages + this.deregisterCallback(['action', 'add:last']) + if (!stopAfterMostRecentMessage) { + this.deregisterCallback(['action', 'add:before']) + this.deregisterCallback(['action', 'add:unread']) + } + this.deregisterCallback(['response', 'type:chat']) + this.deregisterCallback(['response', 'type:contacts']) + } + const checkForResolution = () => { + if (receivedContacts && receivedMessages) resolveTask () + } + + // wait for messages to load + const chatUpdate = json => { + receivedMessages = true + const isLast = json[1].last || stopAfterMostRecentMessage + const messages = json[2] as WANode[] - if (messages) { - messages.reverse().forEach (([, __, message]: ['message', null, WAMessage]) => { - const jid = message.key.remoteJid - const chat = chats.get(jid) - chat?.messages.unshift (message) - }) - } - // if received contacts before messages - if (isLast && receivedContacts) convoResolve () - } - // wait for actual messages to load, "last" is the most recent message, "before" contains prior messages - this.registerCallback(['action', 'add:last'], chatUpdate) - this.registerCallback(['action', 'add:before'], chatUpdate) - this.registerCallback(['action', 'add:unread'], chatUpdate) - }) - const waitForChats = async () => { - let json = await this.registerCallbackOneTime(['response', 'type:chat']) - if (json[1].duplicate) json = await this.registerCallbackOneTime (['response', 'type:chat']) + if (messages) { + messages.reverse().forEach (([,, message]: ['message', null, WAMessage]) => { + const jid = message.key.remoteJid + const chat = this.chats.get(jid) + chat?.messages.unshift (message) + }) + } + // if received contacts before messages + if (isLast && receivedContacts) checkForResolution () + } + + // wait for actual messages to load, "last" is the most recent message, "before" contains prior messages + this.registerCallback(['action', 'add:last'], chatUpdate) + if (!stopAfterMostRecentMessage) { + this.registerCallback(['action', 'add:before'], chatUpdate) + this.registerCallback(['action', 'add:unread'], chatUpdate) + } + + this.registerCallback(['response', 'type:chat'], json => { + if (json[1].duplicate || !json[2]) return - if (!json[2]) return - json[2] - .map(([item, chat]: [any, WAChat]) => { + .forEach(([item, chat]: [any, WAChat]) => { if (!chat) { this.log (`unexpectedly got null chat: ${item}, ${chat}`, MessageLogLevel.info) return } chat.jid = Utils.whatsappID (chat.jid) + chat.t = +chat.t chat.count = +chat.count chat.messages = [] - chats.insert (chat) // chats data (log json to see what it looks like) + + const oldChat = this.chats.get(chat.jid) + oldChat && this.chats.delete (oldChat) + + this.chats.insert (chat) // chats data (log json to see what it looks like) }) - .filter (Boolean) + + this.log ('received chats list', MessageLogLevel.info) + }) + // get contacts + this.registerCallback(['response', 'type:contacts'], json => { + if (json[1].duplicate) return - if (chats.all().length > 0) return waitForConvos() - } - const waitForContacts = async () => { - let json = await this.registerCallbackOneTime(['response', 'type:contacts']) - if (json[1].duplicate) json = await this.registerCallbackOneTime (['response', 'type:contacts']) - - contacts = json[2].map(item => item[1]) receivedContacts = true - // if you receive contacts after messages - // should probably resolve the promise - if (receivedMessages) convoResolve() - } + + json[2].forEach(([type, contact]: ['user', WAContact]) => { + if (!contact) return this.log (`unexpectedly got null contact: ${type}, ${contact}`, MessageLogLevel.info) + + contact.jid = Utils.whatsappID (contact.jid) + this.contacts[contact.jid] = contact + }) + this.log ('received contacts list', MessageLogLevel.info) + checkForResolution () + }) // wait for the chats & contacts to load - const promise = Promise.all([waitForChats(), waitForContacts()]) - await Utils.promiseTimeout (timeoutMs, promise) - - if (this.connectionMode === WAConnectionMode.requireChatsAndContacts) this.releasePendingRequests () - - return [chats, contacts] as [KeyedDB, WAContact[]] + await Utils.promiseTimeout (timeoutMs, (resolve, reject) => { + resolveTask = resolve + const rejectTask = (reason) => { + reject (new Error(reason)) + this.off ('close', rejectTask) + } + this.on ('close', rejectTask) + }).finally (deregisterCallbacks) + + this.chats + .all () + .forEach (chat => { + const respectiveContact = this.contacts[chat.jid] + chat.name = respectiveContact?.name || respectiveContact?.notify || chat.name + }) } private releasePendingRequests () { - this.pendingRequests.forEach (send => send()) // send off all pending request + this.pendingRequests.forEach (({resolve}) => resolve()) // send off all pending request this.pendingRequests = [] } - private onMessageRecieved(message) { + private onMessageRecieved(message: string | Buffer) { if (message[0] === '!') { // when the first character in the message is an '!', the server is updating the last seen - const timestamp = message.slice(1, message.length) + const timestamp = message.slice(1, message.length).toString ('utf-8') this.lastSeen = new Date(parseInt(timestamp)) } else { - const decrypted = Utils.decryptWA (message, this.authInfo.macKey, this.authInfo.encKey, new Decoder()) - if (!decrypted) { - return - } + const decrypted = Utils.decryptWA (message, this.authInfo?.macKey, this.authInfo?.encKey, new Decoder()) + if (!decrypted) return + const [messageTag, json] = decrypted if (this.logLevel === MessageLogLevel.all) { @@ -213,21 +241,55 @@ export class WAConnection extends Base { } /** Send a keep alive request every X seconds, server updates & responds with last seen */ private startKeepAliveRequest() { - const refreshInterval = 20 this.keepAliveReq = setInterval(() => { - const diff = (new Date().getTime() - this.lastSeen.getTime()) / 1000 + const diff = (new Date().getTime() - this.lastSeen.getTime()) /* check if it's been a suspicious amount of time since the server responded with our last seen it could be that the network is down */ - if (diff > refreshInterval + 5) this.unexpectedDisconnect ('lost') + if (diff > KEEP_ALIVE_INTERVAL_MS+5000) this.unexpectedDisconnect ('lost') else this.send ('?,,') // if its all good, send a keep alive request - }, refreshInterval * 1000) + }, KEEP_ALIVE_INTERVAL_MS) } + protected async reconnectLoop () { + this.cancelledReconnect = false + try { + while (true) { + const {delay, cancel} = Utils.delayCancellable (2500) + this.cancelReconnect = () => { + this.cancelledReconnect = true + this.cancelReconnect = null + cancel () + } + + await delay + try { + const reconnectID = this.lastDisconnectReason !== 'replaced' && this.lastDisconnectReason !== 'unknown' && this.user ? this.user.id.replace ('@s.whatsapp.net', '@c.us') : null + await this.connect ({ timeoutMs: 30000, retryOnNetworkErrors: true, reconnectID }) + this.cancelReconnect = null + break + } catch (error) { + // don't continue reconnecting if error is 400 + if (error instanceof BaileysError && error.status >= 400) { + break + } + this.log (`error in reconnecting: ${error}, reconnecting...`, MessageLogLevel.info) + } + } + } catch { - reconnectLoop = async () => { - // attempt reconnecting if the user wants us to - this.log('network is down, reconnecting...', MessageLogLevel.info) - return this.connectSlim(null, 25*1000).catch(this.reconnectLoop) + } + } + /** + * Check if your phone is connected + * @param timeoutMs max time for the phone to respond + */ + async checkPhoneConnection(timeoutMs = 5000) { + try { + const response = await this.query({json: ['admin', 'test'], timeoutMs}) + return response[1] as boolean + } catch (error) { + return false + } } } diff --git a/src/WAConnection/4.Events.ts b/src/WAConnection/4.Events.ts new file mode 100644 index 0000000..dfee878 --- /dev/null +++ b/src/WAConnection/4.Events.ts @@ -0,0 +1,321 @@ +import * as QR from 'qrcode-terminal' +import { WAConnection as Base } from './3.Connect' +import { MessageStatusUpdate, WAMessage, WAContact, WAChat, WAMessageProto, WA_MESSAGE_STUB_TYPE, WA_MESSAGE_STATUS_TYPE, MessageLogLevel, PresenceUpdate, BaileysEvent } from './Constants' +import { whatsappID, unixTimestampSeconds, isGroupID } from './Utils' + +export class WAConnection extends Base { + + constructor () { + super () + + this.registerOnMessageStatusChange () + this.registerOnUnreadMessage () + this.registerOnPresenceUpdate () + this.registerPhoneConnectionPoll () + + // If a message has been updated (usually called when a video message gets its upload url) + this.registerCallback (['action', 'add:update', 'message'], json => { + const message: WAMessage = json[2][0][2] + const jid = whatsappID(message.key.remoteJid) + const chat = this.chats.get(jid) + if (!chat) return + + const messageIndex = chat.messages.findIndex(m => m.key.id === message.key.id) + if (messageIndex >= 0) chat.messages[messageIndex] = message + + this.emit ('message-update', message) + }) + // If a user's contact has changed + this.registerCallback (['action', null, 'user'], json => { + const node = json[2][0] + if (node) { + const user = node[1] as WAContact + user.jid = whatsappID(user.jid) + this.contacts[user.jid] = user + + const chat = this.chats.get (user.jid) + if (chat) { + chat.name = user.name || user.notify + this.emit ('chat-update', { jid: chat.jid, name: chat.name }) + } + } + }) + // chat archive, pin etc. + this.registerCallback(['action', null, 'chat'], json => { + json = json[2][0] + + const updateType = json[1].type + const jid = whatsappID(json[1]?.jid) + + const chat = this.chats.get(jid) + if (!chat) return + + const FUNCTIONS = { + 'delete': () => { + chat['delete'] = 'true' + this.chats.delete(chat) + return 'delete' + }, + 'clear': () => { + json[2].forEach(item => chat.messages.filter(m => m.key.id !== item[1].index)) + return 'clear' + }, + 'archive': () => { + chat.archive = 'true' + return 'archive' + }, + 'unarchive': () => { + delete chat.archive + return 'archive' + }, + 'pin': () => { + chat.pin = json[1].pin + return 'pin' + } + } + const func = FUNCTIONS [updateType] + + if (func) { + const property = func () + this.emit ('chat-update', { jid, [property]: chat[property] || null }) + } + }) + // profile picture updates + this.registerCallback(['Cmd', 'type:picture'], async json => { + const jid = whatsappID(json[1].jid) + const chat = this.chats.get(jid) + if (!chat) return + + await this.setProfilePicture (chat) + this.emit ('chat-update', { jid, imgUrl: chat.imgUrl }) + }) + // status updates + this.registerCallback(['Status'], async json => { + const jid = whatsappID(json[1].id) + this.emit ('user-status-update', { jid, status: json[1].status }) + }) + // read updates + this.registerCallback (['action', null, 'read'], async json => { + const update = json[2][0][1] + + const chat = this.chats.get ( whatsappID(update.jid) ) + + if (update.type === 'false') chat.count = -1 + else chat.count = 0 + + this.emit ('chat-update', { jid: chat.jid, count: chat.count }) + }) + + this.on ('qr', qr => QR.generate(qr, { small: true })) + } + /** Set the callback for message status updates (when a message is delivered, read etc.) */ + protected registerOnMessageStatusChange() { + const func = json => { + json = json[1] + let ids = json.id + + if (json.cmd === 'ack') ids = [json.id] + + const update: MessageStatusUpdate = { + from: json.from, + to: json.to, + participant: json.participant, + timestamp: new Date(json.t * 1000), + ids: ids, + type: (+json.ack)+1, + } + + const chat = this.chats.get( whatsappID(update.to) ) + if (!chat) return + + this.emit ('message-update', update) + this.chatUpdatedMessage (update.ids, update.type as number, chat) + } + this.registerCallback('Msg', func) + this.registerCallback('MsgInfo', func) + } + protected registerOnUnreadMessage() { + this.registerCallback(['action', 'add:relay', 'message'], json => { + const message = json[2][0][2] as WAMessage + this.chatAddMessageAppropriate (message) + }) + } + /** Set the callback for presence updates; if someone goes offline/online, this callback will be fired */ + protected registerOnPresenceUpdate() { + this.registerCallback('Presence', json => this.emit('user-presence-update', json[1] as PresenceUpdate)) + } + /** inserts an empty chat into the DB */ + protected async chatAdd (jid: string, name?: string) { + const chat: WAChat = { + jid: jid, + t: unixTimestampSeconds(), + messages: [], + count: 0, + modify_tag: '', + spam: 'false', + name + } + await this.setProfilePicture (chat) + this.chats.insert (chat) + this.emit ('chat-new', chat) + return chat + } + /** find a chat or return an error */ + protected assertChatGet = jid => { + const chat = this.chats.get (jid) + if (!chat) throw new Error (`chat '${jid}' not found`) + return chat + } + /** Adds the given message to the appropriate chat, if the chat doesn't exist, it is created */ + protected async chatAddMessageAppropriate (message: WAMessage) { + const jid = whatsappID (message.key.remoteJid) + const chat = this.chats.get(jid) || await this.chatAdd (jid) + this.chatAddMessage (message, chat) + } + protected chatAddMessage (message: WAMessage, chat: WAChat) { + // add to count if the message isn't from me & there exists a message + if (!message.key.fromMe && message.message) chat.count += 1 + + const protocolMessage = message.message?.protocolMessage + + // if it's a message to delete another message + if (protocolMessage) { + switch (protocolMessage.type) { + case WAMessageProto.ProtocolMessage.PROTOCOL_MESSAGE_TYPE.REVOKE: + const found = chat.messages.find(m => m.key.id === protocolMessage.key.id) + if (found && found.message) { + + this.log ('deleting message: ' + protocolMessage.key.id + ' in chat: ' + protocolMessage.key.remoteJid, MessageLogLevel.info) + + found.messageStubType = WA_MESSAGE_STUB_TYPE.REVOKE + found.message = null + const update: MessageStatusUpdate = { + from: this.user.id, + to: message.key.remoteJid, + ids: [message.key.id], + timestamp: new Date(), + type: 'delete' + } + this.emit ('message-update', update) + } + break + default: + break + } + } else if (!chat.messages.find(m => m.key.id === message.key.id)) { + // this.log ('adding new message from ' + chat.jid) + chat.messages.push(message) + chat.messages = chat.messages.slice (-5) // only keep the last 5 messages + + // only update if it's an actual message + if (message.message) this.chatUpdateTime (chat) + + this.emit ('message-new', message) + + // check if the message is an action + if (message.messageStubType) { + const jid = chat.jid + let actor = whatsappID (message.participant) + let participants: string[] + switch (message.messageStubType) { + case WA_MESSAGE_STUB_TYPE.GROUP_PARTICIPANT_LEAVE: + case WA_MESSAGE_STUB_TYPE.GROUP_PARTICIPANT_REMOVE: + participants = message.messageStubParameters.map (whatsappID) + this.emit ('group-participants-remove', { jid, actor, participants}) + + // mark the chat read only if you left the group + if (participants.includes(this.user.id)) { + chat.read_only = 'true' + this.emit ('chat-update', { jid, read_only: chat.read_only }) + } + break + case WA_MESSAGE_STUB_TYPE.GROUP_PARTICIPANT_ADD: + case WA_MESSAGE_STUB_TYPE.GROUP_PARTICIPANT_INVITE: + participants = message.messageStubParameters.map (whatsappID) + this.emit ('group-participants-add', { jid, participants, actor }) + break + case WA_MESSAGE_STUB_TYPE.GROUP_CHANGE_ANNOUNCE: + const announce = message.messageStubParameters[0] === 'on' ? 'true' : 'false' + this.emit ('group-settings-update', { jid, announce, actor }) + break + case WA_MESSAGE_STUB_TYPE.GROUP_CHANGE_ANNOUNCE: + const restrict = message.messageStubParameters[0] === 'on' ? 'true' : 'false' + this.emit ('group-settings-update', { jid, restrict, actor }) + break + case WA_MESSAGE_STUB_TYPE.GROUP_CHANGE_DESCRIPTION: + this.emit ('group-description-update', { jid, actor }) + break + case WA_MESSAGE_STUB_TYPE.GROUP_CHANGE_SUBJECT: + chat.name = message.messageStubParameters[0] + this.emit ('chat-update', { jid, name: chat.name }) + break + } + } + } + } + protected chatUpdatedMessage (messageIDs: string[], status: number, chat: WAChat) { + for (let msg of chat.messages) { + if (messageIDs.includes(msg.key.id)) { + if (isGroupID(chat.jid)) msg.status = WA_MESSAGE_STATUS_TYPE.SERVER_ACK + else msg.status = status + } + } + } + protected chatUpdateTime = chat => this.chats.updateKey (chat, c => c.t = unixTimestampSeconds()) + /** sets the profile picture of a chat */ + protected async setProfilePicture (chat: WAChat) { + chat.imgUrl = await this.getProfilePicture (chat.jid).catch (err => '') + } + protected registerPhoneConnectionPoll () { + this.phoneCheck = setInterval (() => { + this.checkPhoneConnection (5000) // 5000 ms for timeout + .then (connected => { + if (this.phoneConnected != connected) { + this.emit ('connection-phone-change', {connected}) + } + this.phoneConnected = connected + }) + .catch (error => this.log(`error in getting phone connection: ${error}`, MessageLogLevel.info)) + }, 15000) + } + + // Add all event types + + /** when the connection has opened successfully */ + on (event: 'open', listener: () => void): this + /** when the connection is opening */ + on (event: 'connecting', listener: () => void): this + /** when the connection has closed */ + on (event: 'close', listener: (err: {reason?: string, isReconnecting: boolean}) => 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 */ + on (event: 'connection-phone-change', listener: (state: {connected: boolean}) => void): this + /** when a user's presence is updated */ + on (event: 'user-presence-update', listener: (update: PresenceUpdate) => void): this + /** when a user's status is updated */ + on (event: 'user-status-update', listener: (update: {jid: string, status?: string}) => void): this + /** when a new chat is added */ + on (event: 'chat-new', listener: (chat: WAChat) => void): this + /** when a chat is updated (archived, deleted, pinned) */ + on (event: 'chat-update', listener: (chat: Partial & { jid: string }) => void): this + /** when a new message is relayed */ + on (event: 'message-new', listener: (message: WAMessage) => void): this + /** when a message is updated (deleted, delivered, read, sent etc.) */ + on (event: 'message-update', listener: (message: MessageStatusUpdate) => void): this + /** when participants are added to a group */ + on (event: 'group-participants-add', listener: (update: {jid: string, participants: string[], actor?: string}) => void): this + /** when participants are removed or leave from a group */ + on (event: 'group-participants-remove', listener: (update: {jid: string, participants: string[], actor?: string}) => void): this + /** when participants are promoted in a group */ + on (event: 'group-participants-promote', listener: (update: {jid: string, participants: string[], actor?: string}) => void): this + /** when participants are demoted in a group */ + on (event: 'group-participants-demote', listener: (update: {jid: string, participants: string[], actor?: string}) => void): this + /** when the group settings is updated */ + on (event: 'group-settings-update', listener: (update: {jid: string, restrict?: string, announce?: string, actor?: string}) => void): this + /** when the group description is updated */ + on (event: 'group-description-update', listener: (update: {jid: string, description?: string, actor?: string}) => void): this + + on (event: BaileysEvent, listener: (...args: any[]) => void) { return super.on (event, listener) } + emit (event: BaileysEvent, ...args: any[]) { return super.emit (event, ...args) } +} diff --git a/src/WAConnection/4.User.ts b/src/WAConnection/4.User.ts deleted file mode 100644 index 5c34181..0000000 --- a/src/WAConnection/4.User.ts +++ /dev/null @@ -1,170 +0,0 @@ -import {WAConnection as Base} from './3.Connect' -import { Presence, WABroadcastListInfo, WAProfilePictureChange } from './Constants' -import { - WAMessage, - WANode, - WAMetric, - WAFlag, -} from '../WAConnection/Constants' -import { generateProfilePicture } from './Utils' - -// All user related functions -- get profile picture, set status etc. - -export class WAConnection extends Base { - /** Query whether a given number is registered on WhatsApp */ - isOnWhatsApp = (jid: string) => this.query(['query', 'exist', jid]).then((m) => m.status === 200) - /** - * Tell someone about your presence -- online, typing, offline etc. - * @param jid the ID of the person/group who you are updating - * @param type your presence - */ - async updatePresence(jid: string | null, type: Presence) { - const json = [ - 'action', - { epoch: this.msgCount.toString(), type: 'set' }, - [['presence', { type: type, to: jid }, null]], - ] - return this.queryExpecting200(json, [WAMetric.group, WAFlag.acknowledge]) as Promise<{ status: number }> - } - /** Request an update on the presence of a user */ - requestPresenceUpdate = async (jid: string) => this.queryExpecting200(['action', 'presence', 'subscribe', jid]) - /** Query the status of the person (see groupMetadata() for groups) */ - async getStatus (jid?: string) { - return this.query(['query', 'Status', jid || this.userMetaData.id]) as Promise<{ status: string }> - } - async setStatus (status: string) { - return this.setQuery ( - [ - [ - 'status', - null, - Buffer.from (status, 'utf-8') - ] - ] - ) - } - /** Get the URL to download the profile picture of a person/group */ - async getProfilePicture(jid: string | null) { - const response = await this.queryExpecting200(['query', 'ProfilePicThumb', jid || this.userMetaData.id]) - return response.eurl as string - } - /** Get your contacts */ - async getContacts() { - const json = ['query', { epoch: this.msgCount.toString(), type: 'contacts' }, null] - const response = await this.query(json, [6, WAFlag.ignore]) // this has to be an encrypted query - return response - } - /** Get the stories of your contacts */ - async getStories() { - const json = ['query', { epoch: this.msgCount.toString(), type: 'status' }, null] - const response = await this.queryExpecting200(json, [30, WAFlag.ignore]) as WANode - if (Array.isArray(response[2])) { - return response[2].map (row => ( - { - unread: row[1]?.unread, - count: row[1]?.count, - messages: Array.isArray(row[2]) ? row[2].map (m => m[2]) : [] - } as {unread: number, count: number, messages: WAMessage[]} - )) - } - return [] - } - /** Fetch your chats */ - async getChats() { - const json = ['query', { epoch: this.msgCount.toString(), type: 'chat' }, null] - return this.query(json, [5, WAFlag.ignore]) // this has to be an encrypted query - } - /** Query broadcast list info */ - async getBroadcastListInfo(jid: string) { return this.queryExpecting200(['query', 'contact', jid]) as Promise } - /** Delete the chat of a given ID */ - async deleteChat (jid: string) { - return this.setQuery ([ ['chat', {type: 'delete', jid: jid}, null] ], [12, WAFlag.ignore]) as Promise<{status: number}> - } - /** - * Check if your phone is connected - * @param timeoutMs max time for the phone to respond - */ - async isPhoneConnected(timeoutMs = 5000) { - try { - const response = await this.query(['admin', 'test'], null, timeoutMs) - return response[1] as boolean - } catch (error) { - return false - } - } - /** - * Load the conversation with a group or person - * @param count the number of messages to load - * @param [indexMessage] the data for which message to offset the query by - * @param [mostRecentFirst] retreive the most recent message first or retreive from the converation start - */ - async loadConversation( - jid: string, - count: number, - indexMessage: { id: string; fromMe: boolean } = null, - mostRecentFirst = true, - ) { - const json = [ - 'query', - { - epoch: this.msgCount.toString(), - type: 'message', - jid: jid, - kind: mostRecentFirst ? 'before' : 'after', - count: count.toString(), - index: indexMessage?.id, - owner: indexMessage?.fromMe === false ? 'false' : 'true', - }, - null, - ] - const response = await this.queryExpecting200(json, [WAMetric.queryMessages, WAFlag.ignore]) - return response[2] ? (response[2] as WANode[]).map((item) => item[2] as WAMessage) : [] - } - /** - * Load the entire friggin conversation with a group or person - * @param onMessage callback for every message retreived - * @param [chunkSize] the number of messages to load in a single request - * @param [mostRecentFirst] retreive the most recent message first or retreive from the converation start - */ - loadEntireConversation(jid: string, onMessage: (m: WAMessage) => void, chunkSize = 25, mostRecentFirst = true) { - let offsetID = null - const loadMessage = async () => { - const json = await this.loadConversation(jid, chunkSize, offsetID, mostRecentFirst) - // callback with most recent message first (descending order of date) - let lastMessage - if (mostRecentFirst) { - for (let i = json.length - 1; i >= 0; i--) { - onMessage(json[i]) - lastMessage = json[i] - } - } else { - for (let i = 0; i < json.length; i++) { - onMessage(json[i]) - lastMessage = json[i] - } - } - // if there are still more messages - if (json.length >= chunkSize) { - offsetID = lastMessage.key // get the last message - return new Promise((resolve, reject) => { - // send query after 200 ms - setTimeout(() => loadMessage().then(resolve).catch(reject), 200) - }) - } - } - return loadMessage() as Promise - } - async updateProfilePicture (jid: string, img: Buffer) { - const data = await generateProfilePicture (img) - const tag = this.generateMessageTag () - const query: WANode = [ - 'picture', - { jid: jid, id: tag, type: 'set' }, - [ - ['image', null, data.img], - ['preview', null, data.preview] - ] - ] - return this.setQuery ([query], [WAMetric.picture, 136], tag) as Promise - } -} diff --git a/src/WAConnection/5.Messages.ts b/src/WAConnection/5.Messages.ts deleted file mode 100644 index 016cd03..0000000 --- a/src/WAConnection/5.Messages.ts +++ /dev/null @@ -1,408 +0,0 @@ -import {WAConnection as Base} from './4.User' -import fetch from 'node-fetch' -import {promises as fs} from 'fs' -import { - MessageOptions, - MessageType, - Mimetype, - MimetypeMap, - MediaPathMap, - WALocationMessage, - WAContactMessage, - WASendMessageResponse, - WAMessageKey, - ChatModification, - MessageInfo, - WATextMessage, - WAUrlInfo, - WAMessageContent, WAMetric, WAFlag, WANode, WAMessage, WAMessageProto, BaileysError, MessageLogLevel, WA_MESSAGE_STATUS_TYPE -} from './Constants' -import { generateMessageID, sha256, hmacSign, aesEncrypWithIV, randomBytes, generateThumbnail, getMediaKeys, decodeMediaMessageBuffer, extensionForMediaMessage, whatsappID } from './Utils' - -export class WAConnection extends Base { - /** Get the message info, who has read it, who its been delivered to */ - async messageInfo (jid: string, messageID: string) { - const query = ['query', {type: 'message_info', index: messageID, jid: jid, epoch: this.msgCount.toString()}, null] - const response = (await this.queryExpecting200 (query, [22, WAFlag.ignore]))[2] - - const info: MessageInfo = {reads: [], deliveries: []} - if (response) { - //console.log (response) - const reads = response.filter (node => node[0] === 'read') - if (reads[0]) { - info.reads = reads[0][2].map (item => item[1]) - } - const deliveries = response.filter (node => node[0] === 'delivery') - if (deliveries[0]) { - info.deliveries = deliveries[0][2].map (item => item[1]) - } - } - return info - } - /** - * Send a read receipt to the given ID for a certain message - * @param jid the ID of the person/group whose message you want to mark read - * @param messageID optionally, the message ID - * @param type whether to read or unread the message - */ - async sendReadReceipt(jid: string, messageID?: string, type: 'read' | 'unread' = 'read') { - const attributes = { - jid: jid, - count: type === 'read' ? '1' : '-2', - index: messageID, - owner: messageID ? 'false' : null - } - return this.setQuery ([['read', attributes, null]]) - } - /** - * Modify a given chat (archive, pin etc.) - * @param jid the ID of the person/group you are modifiying - * @param options.stamp the timestamp of pinning/muting the chat. Is required when unpinning/unmuting - */ - async modifyChat (jid: string, type: ChatModification, options: {stamp: Date | string} = {stamp: new Date()}) { - let chatAttrs: Record = {jid: jid} - if ((type === ChatModification.unpin || type === ChatModification.unmute) && !options?.stamp) { - throw new Error('options.stamp must be set to the timestamp of the time of pinning/unpinning of the chat') - } - const strStamp = options.stamp && - (typeof options.stamp === 'string' ? options.stamp : Math.round(options.stamp.getTime ()/1000).toString ()) - switch (type) { - case ChatModification.pin: - case ChatModification.mute: - chatAttrs.type = type - chatAttrs[type] = strStamp - break - case ChatModification.unpin: - case ChatModification.unmute: - chatAttrs.type = type.replace ('un', '') // replace 'unpin' with 'pin' - chatAttrs.previous = strStamp - break - default: - chatAttrs.type = type - break - } - let response = await this.setQuery ([['chat', chatAttrs, null]]) as any - response.stamp = strStamp - return response as {status: number, stamp: string} - } - async loadMessage (jid: string, messageID: string) { - let messages - try { - messages = await this.loadConversation (jid, 1, {id: messageID, fromMe: true}, false) - } catch { - messages = await this.loadConversation (jid, 1, {id: messageID, fromMe: false}, false) - } - var index = null - if (messages.length > 0) index = messages[0].key - - const actual = await this.loadConversation (jid, 1, index) - return actual[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.queryExpecting200 (query, [26, WAFlag.ignore]) - - 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 - * @param inJid the ID of the chat to search in, set to null to search all chats - * @param count number of results to return - * @param page page number of results (starts from 1) - */ - async searchMessages(txt: string, inJid: string | null, count: number, page: number) { - const json = [ - 'query', - { - epoch: this.msgCount.toString(), - type: 'search', - search: txt, - count: count.toString(), - page: page.toString(), - jid: inJid - }, - null, - ] - const response: WANode = await this.queryExpecting200(json, [WAMetric.group, WAFlag.ignore]) // encrypt and send off - const messages = response[2] ? response[2].map (row => row[2]) : [] - return { last: response[1]['last'] === 'true', messages: messages as WAMessage[] } - } - /** - * Delete a message in a chat for yourself - * @param messageKey key of the message you want to delete - */ - async clearMessage (messageKey: WAMessageKey) { - const tag = Math.round(Math.random ()*1000000) - const attrs: WANode = [ - 'chat', - { jid: messageKey.remoteJid, modify_tag: tag.toString(), type: 'clear' }, - [ - ['item', {owner: `${messageKey.fromMe}`, index: messageKey.id}, null] - ] - ] - return this.setQuery ([attrs]) - } - /** - * Fetches the latest url & media key for the given message. - * You may need to call this when the message is old & the content is deleted off of the WA servers - * @param message - */ - async updateMediaMessage (message: WAMessage) { - const content = message.message?.audioMessage || message.message?.videoMessage || message.message?.imageMessage || message.message?.stickerMessage || message.message?.documentMessage - if (!content) throw new BaileysError (`given message ${message.key.id} is not a media message`, message) - - const query = ['query',{type: 'media', index: message.key.id, owner: message.key.fromMe ? 'true' : 'false', jid: message.key.remoteJid, epoch: this.msgCount.toString()},null] - const response = await this.query (query, [WAMetric.queryMedia, WAFlag.ignore]) - if (parseInt(response[1].code) !== 200) throw new BaileysError ('unexpected status ' + response[1].code, response) - - Object.keys (response[1]).forEach (key => content[key] = response[1][key]) // update message - } - /** - * Delete a message in a chat for everyone - * @param id the person or group where you're trying to delete the message - * @param messageKey key of the message you want to delete - */ - async deleteMessage (id: string, messageKey: WAMessageKey) { - const json: WAMessageContent = { - protocolMessage: { - key: messageKey, - type: WAMessageProto.ProtocolMessage.PROTOCOL_MESSAGE_TYPE.REVOKE - } - } - const waMessage = this.generateWAMessage (id, json, {}) - await this.relayWAMessage (waMessage) - return waMessage - } - /** - * Forward a message like WA does - * @param id the id to forward the message to - * @param message the message to forward - * @param forceForward will show the message as forwarded even if it is from you - */ - async forwardMessage(id: string, message: WAMessage, forceForward: boolean=false) { - const content = message.message - if (!content) throw new Error ('no content in message') - - let key = Object.keys(content)[0] - - let score = content[key].contextInfo?.forwardingScore || 0 - score += message.key.fromMe && !forceForward ? 0 : 1 - if (key === MessageType.text) { - content[MessageType.extendedText] = { text: content[key] } - delete content[MessageType.text] - - key = MessageType.extendedText - } - if (score > 0) content[key].contextInfo = { forwardingScore: score, isForwarded: true } - else content[key].contextInfo = {} - - const waMessage = this.generateWAMessage (id, content, {}) - await this.relayWAMessage (waMessage) - return waMessage - } - /** - * Send a message to the given ID (can be group, single, or broadcast) - * @param id - * @param message - * @param type - * @param options - */ - async sendMessage( - id: string, - message: string | WATextMessage | WALocationMessage | WAContactMessage | Buffer, - type: MessageType, - options: MessageOptions = {}, - ) { - const waMessage = await this.prepareMessage (id, message, type, options) - await this.relayWAMessage (waMessage) - return waMessage - } - /** Prepares a message for sending via sendWAMessage () */ - async prepareMessage( - id: string, - message: string | WATextMessage | WALocationMessage | WAContactMessage | Buffer, - type: MessageType, - options: MessageOptions = {}, - ) { - let m: WAMessageContent = {} - switch (type) { - case MessageType.text: - case MessageType.extendedText: - if (typeof message === 'string') { - m.extendedTextMessage = {text: message} - } else if ('text' in message) { - m.extendedTextMessage = message as WATextMessage - } else { - throw new BaileysError ('message needs to be a string or object with property \'text\'', message) - } - break - case MessageType.location: - case MessageType.liveLocation: - m.locationMessage = message as WALocationMessage - break - case MessageType.contact: - m.contactMessage = message as WAContactMessage - break - default: - m = await this.prepareMediaMessage(message as Buffer, type, options) - break - } - return this.generateWAMessage(id, m, options) - } - /** Prepare a media message for sending */ - async prepareMediaMessage(buffer: Buffer, mediaType: MessageType, options: MessageOptions = {}) { - if (mediaType === MessageType.document && !options.mimetype) { - throw new Error('mimetype required to send a document') - } - if (mediaType === MessageType.sticker && options.caption) { - throw new Error('cannot send a caption with a sticker') - } - if (!options.mimetype) { - options.mimetype = MimetypeMap[mediaType] - } - let isGIF = false - if (options.mimetype === Mimetype.gif) { - isGIF = true - options.mimetype = MimetypeMap[MessageType.video] - } - // generate a media key - const mediaKey = randomBytes(32) - const mediaKeys = getMediaKeys(mediaKey, mediaType) - const enc = aesEncrypWithIV(buffer, mediaKeys.cipherKey, mediaKeys.iv) - const mac = hmacSign(Buffer.concat([mediaKeys.iv, enc]), mediaKeys.macKey).slice(0, 10) - 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) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/\=+$/, '') - - await generateThumbnail(buffer, mediaType, options) - // send a query JSON to obtain the url & auth token to upload our media - const json = (await this.query(['query', 'mediaConn'])).media_conn - const auth = json.auth // the auth token - let hostname = 'https://' + json.hosts[0].hostname // first hostname available - hostname += MediaPathMap[mediaType] + '/' + fileEncSha256B64 // append path - hostname += '?auth=' + auth // add auth token - hostname += '&token=' + fileEncSha256B64 // file hash - - const urlFetch = await fetch(hostname, { - method: 'POST', - body: body, - headers: { Origin: 'https://web.whatsapp.com' }, - }) - const responseJSON = await urlFetch.json() - if (!responseJSON.url) { - throw new Error('Upload failed got: ' + JSON.stringify(responseJSON)) - } - const message = {} - message[mediaType] = { - url: responseJSON.url, - mediaKey: mediaKey.toString('base64'), - mimetype: options.mimetype, - fileEncSha256: fileEncSha256B64, - fileSha256: fileSha256.toString('base64'), - fileLength: buffer.length, - fileName: options.filename || 'file', - gifPlayback: isGIF || null, - caption: options.caption - } - return message as WAMessageContent - } - /** generates a WAMessage from the given content & options */ - generateWAMessage(id: string, message: WAMessageContent, options: MessageOptions) { - if (!options.timestamp) options.timestamp = new Date() // set timestamp to now - - // prevent an annoying bug (WA doesn't accept sending messages with '@c.us') - id = whatsappID (id) - - const key = Object.keys(message)[0] - const timestamp = options.timestamp.getTime()/1000 - const quoted = options.quoted - - if (options.contextInfo) message[key].contextInfo = options.contextInfo - - if (quoted) { - const participant = quoted.key.participant || quoted.key.remoteJid - - message[key].contextInfo = message[key].contextInfo || { } - message[key].contextInfo.participant = participant - message[key].contextInfo.stanzaId = quoted.key.id - message[key].contextInfo.quotedMessage = quoted.message - - // if a participant is quoted, then it must be a group - // hence, remoteJid of group must also be entered - if (quoted.key.participant) { - message[key].contextInfo.remoteJid = quoted.key.remoteJid - } - } - if (!message[key].jpegThumbnail) message[key].jpegThumbnail = options?.thumbnail - - const messageJSON = { - key: { - remoteJid: id, - fromMe: true, - id: generateMessageID(), - }, - message: message, - messageTimestamp: timestamp, - messageStubParameters: [], - participant: id.includes('@g.us') ? this.userMetaData.id : null, - status: WA_MESSAGE_STATUS_TYPE.PENDING - } - return messageJSON as WAMessage - } - /** Relay (send) a WAMessage; more advanced functionality to send a built WA Message, you may want to stick with sendMessage() */ - async relayWAMessage(message: WAMessage) { - const json = ['action', {epoch: this.msgCount.toString(), type: 'relay'}, [['message', null, message]]] - const flag = message.key.remoteJid === this.userMetaData.id ? WAFlag.acknowledge : WAFlag.ignore // acknowledge when sending message to oneself - await this.queryExpecting200(json, [WAMetric.message, flag], null, message.key.id) - } - /** - * Securely downloads the media from the message. - * Renews the download url automatically, if necessary. - */ - async downloadMediaMessage (message: WAMessage) { - const fetchHeaders = { } - try { - const buff = await decodeMediaMessageBuffer (message.message, fetchHeaders) - return buff - } catch (error) { - if (error instanceof BaileysError && error.status === 404) { // media needs to be updated - this.log (`updating media of message: ${message.key.id}`, MessageLogLevel.info) - await this.updateMediaMessage (message) - const buff = await decodeMediaMessageBuffer (message.message, fetchHeaders) - return buff - } - throw error - } - } - /** - * Securely downloads the media from the message and saves to a file. - * Renews the download url automatically, if necessary. - * @param message the media message you want to decode - * @param filename the name of the file where the media will be saved - * @param attachExtension should the parsed extension be applied automatically to the file - */ - async downloadAndSaveMediaMessage (message: WAMessage, filename: string, attachExtension: boolean=true) { - const buffer = await this.downloadMediaMessage (message) - const extension = extensionForMediaMessage (message.message) - const trueFileName = attachExtension ? (filename + '.' + extension) : filename - await fs.writeFile (trueFileName, buffer) - return trueFileName - } -} diff --git a/src/WAConnection/5.User.ts b/src/WAConnection/5.User.ts new file mode 100644 index 0000000..e5ebe4e --- /dev/null +++ b/src/WAConnection/5.User.ts @@ -0,0 +1,179 @@ +import {WAConnection as Base} from './4.Events' +import { Presence, WABroadcastListInfo, WAProfilePictureChange, WAChat, ChatModification } from './Constants' +import { + WAMessage, + WANode, + WAMetric, + WAFlag, +} from '../WAConnection/Constants' +import { generateProfilePicture, waChatUniqueKey, whatsappID, unixTimestampSeconds } from './Utils' + +// All user related functions -- get profile picture, set status etc. + +export class WAConnection extends Base { + /** Query whether a given number is registered on WhatsApp */ + isOnWhatsApp = (jid: string) => this.query({json: ['query', 'exist', jid]}).then((m) => m.status === 200) + /** + * Tell someone about your presence -- online, typing, offline etc. + * @param jid the ID of the person/group who you are updating + * @param type your presence + */ + updatePresence = (jid: string | null, type: Presence) => + this.query( + { + json: [ + 'action', + { epoch: this.msgCount.toString(), type: 'set' }, + [['presence', { type: type, to: jid }, null]], + ], + binaryTags: [WAMetric.group, WAFlag.acknowledge], + expect200: true + } + ) as Promise<{status: number}> + /** Request an update on the presence of a user */ + requestPresenceUpdate = async (jid: string) => this.query({json: ['action', 'presence', 'subscribe', jid]}) + /** Query the status of the person (see groupMetadata() for groups) */ + async getStatus (jid?: string) { + const status: { status: string } = await this.query({json: ['query', 'Status', jid || this.user.id]}) + return status + } + async setStatus (status: string) { + const response = await this.setQuery ( + [ + [ + 'status', + null, + Buffer.from (status, 'utf-8') + ] + ] + ) + this.emit ('user-status-update', { jid: this.user.id, status }) + return response + } + /** Get your contacts */ + async getContacts() { + const json = ['query', { epoch: this.msgCount.toString(), type: 'contacts' }, null] + const response = await this.query({ json, binaryTags: [6, WAFlag.ignore] }) // this has to be an encrypted query + return response + } + /** Get the stories of your contacts */ + async getStories() { + const json = ['query', { epoch: this.msgCount.toString(), type: 'status' }, null] + const response = await this.query({json, binaryTags: [30, WAFlag.ignore], expect200: true}) as WANode + if (Array.isArray(response[2])) { + return response[2].map (row => ( + { + unread: row[1]?.unread, + count: row[1]?.count, + messages: Array.isArray(row[2]) ? row[2].map (m => m[2]) : [] + } as {unread: number, count: number, messages: WAMessage[]} + )) + } + return [] + } + /** Fetch your chats */ + async getChats() { + const json = ['query', { epoch: this.msgCount.toString(), type: 'chat' }, null] + return this.query({ json, binaryTags: [5, WAFlag.ignore]}) // this has to be an encrypted query + } + /** Query broadcast list info */ + async getBroadcastListInfo(jid: string) { return this.query({json: ['query', 'contact', jid], expect200: true}) as Promise } + /** Delete the chat of a given ID */ + async deleteChat (jid: string) { + const response = await this.setQuery ([ ['chat', {type: 'delete', jid: jid}, null] ], [12, WAFlag.ignore]) as {status: number} + const chat = this.chats.get (jid) + if (chat) { + this.chats.delete (chat) + this.emit ('chat-update', { jid, delete: 'true' }) + } + return response + } + /** + * Load chats in a paginated manner + gets the profile picture + * @param before chats before the given cursor + * @param count number of results to return + * @param searchString optionally search for users + * @returns the chats & the cursor to fetch the next page + */ + async loadChats (count: number, before: number | null, searchString?: string) { + let db = this.chats + if (searchString) { + db = db.filter (value => value.name?.includes (searchString) || value.jid?.startsWith(searchString)) + } + const chats = db.paginated (before, count) + await Promise.all ( + chats.map (async chat => ( + chat.imgUrl === undefined && await this.setProfilePicture (chat) + )) + ) + const cursor = (chats[chats.length-1] && chats.length >= count) ? waChatUniqueKey (chats[chats.length-1]) : null + return { chats, cursor } + } + async updateProfilePicture (jid: string, img: Buffer) { + jid = whatsappID (jid) + const data = await generateProfilePicture (img) + const tag = this.generateMessageTag () + const query: WANode = [ + 'picture', + { jid: jid, id: tag, type: 'set' }, + [ + ['image', null, data.img], + ['preview', null, data.preview] + ] + ] + const response = await (this.setQuery ([query], [WAMetric.picture, 136], tag) as Promise) + if (jid === this.user.id) this.user.imgUrl = response.eurl + else if (this.chats.get(jid)) { + this.chats.get(jid).imgUrl = response.eurl + this.emit ('chat-update', { jid, imgUrl: response.eurl }) + } + return response + } + /** + * Modify a given chat (archive, pin etc.) + * @param jid the ID of the person/group you are modifiying + * @param durationMs only for muting, how long to mute the chat for + */ + async modifyChat (jid: string, type: ChatModification, durationMs?: number) { + jid = whatsappID (jid) + const chat = this.assertChatGet (jid) + + let chatAttrs: Record = {jid: jid} + if (type === ChatModification.mute && !durationMs) { + throw new Error('duration must be set to the timestamp of the time of pinning/unpinning of the chat') + } + + durationMs = durationMs || 0 + switch (type) { + case ChatModification.pin: + case ChatModification.mute: + const strStamp = (unixTimestampSeconds() + Math.floor(durationMs/1000)).toString() + chatAttrs.type = type + chatAttrs[type] = strStamp + break + case ChatModification.unpin: + case ChatModification.unmute: + chatAttrs.type = type.replace ('un', '') // replace 'unpin' with 'pin' + chatAttrs.previous = chat[type.replace ('un', '')] + break + default: + chatAttrs.type = type + break + } + + const response = await this.setQuery ([['chat', chatAttrs, null]]) + + if (chat) { + if (type.includes('un')) { + type = type.replace ('un', '') as ChatModification + delete chat[type.replace('un','')] + this.emit ('chat-update', { jid, [type]: false }) + } else { + chat[type] = chatAttrs[type] || 'true' + this.emit ('chat-update', { jid, [type]: chat[type] }) + } + } + + return response + } +} diff --git a/src/WAConnection/6.MessagesSend.ts b/src/WAConnection/6.MessagesSend.ts new file mode 100644 index 0000000..7c1ad08 --- /dev/null +++ b/src/WAConnection/6.MessagesSend.ts @@ -0,0 +1,235 @@ +import {WAConnection as Base} from './5.User' +import fetch from 'node-fetch' +import {promises as fs} from 'fs' +import { + MessageOptions, + MessageType, + Mimetype, + MimetypeMap, + MediaPathMap, + WALocationMessage, + WAContactMessage, + WATextMessage, + WAMessageContent, WAMetric, WAFlag, WAMessage, BaileysError, MessageLogLevel, WA_MESSAGE_STATUS_TYPE +} from './Constants' +import { generateMessageID, sha256, hmacSign, aesEncrypWithIV, randomBytes, generateThumbnail, getMediaKeys, decodeMediaMessageBuffer, extensionForMediaMessage, whatsappID, unixTimestampSeconds } from './Utils' + +export class WAConnection extends Base { + /** + * Send a message to the given ID (can be group, single, or broadcast) + * @param id the id to send to + * @param message the message can be a buffer, plain string, location message, extended text message + * @param type type of message + * @param options Extra options + */ + async sendMessage( + id: string, + message: string | WATextMessage | WALocationMessage | WAContactMessage | Buffer, + type: MessageType, + options: MessageOptions = {}, + ) { + const waMessage = await this.prepareMessage (id, message, type, options) + await this.relayWAMessage (waMessage) + return waMessage + } + /** Prepares a message for sending via sendWAMessage () */ + async prepareMessage( + id: string, + message: string | WATextMessage | WALocationMessage | WAContactMessage | Buffer, + type: MessageType, + options: MessageOptions = {}, + ) { + const content = await this.prepareMessageContent ( + message, + type, + options + ) + const preparedMessage = this.prepareMessageFromContent(id, content, options) + return preparedMessage + } + /** Prepares the message content */ + async prepareMessageContent (message: string | WATextMessage | WALocationMessage | WAContactMessage | Buffer, type: MessageType, options: MessageOptions) { + let m: WAMessageContent = {} + switch (type) { + case MessageType.text: + case MessageType.extendedText: + if (typeof message === 'string') { + m.extendedTextMessage = {text: message} + } else if ('text' in message) { + m.extendedTextMessage = message as WATextMessage + } else { + throw new BaileysError ('message needs to be a string or object with property \'text\'', message) + } + break + case MessageType.location: + case MessageType.liveLocation: + m.locationMessage = message as WALocationMessage + break + case MessageType.contact: + m.contactMessage = message as WAContactMessage + break + default: + m = await this.prepareMessageMedia(message as Buffer, type, options) + break + } + return m + } + /** Prepare a media message for sending */ + async prepareMessageMedia(buffer: Buffer, mediaType: MessageType, options: MessageOptions = {}) { + if (mediaType === MessageType.document && !options.mimetype) { + throw new Error('mimetype required to send a document') + } + if (mediaType === MessageType.sticker && options.caption) { + throw new Error('cannot send a caption with a sticker') + } + if (!options.mimetype) { + options.mimetype = MimetypeMap[mediaType] + } + let isGIF = false + if (options.mimetype === Mimetype.gif) { + isGIF = true + options.mimetype = MimetypeMap[MessageType.video] + } + // generate a media key + const mediaKey = randomBytes(32) + const mediaKeys = getMediaKeys(mediaKey, mediaType) + const enc = aesEncrypWithIV(buffer, mediaKeys.cipherKey, mediaKeys.iv) + const mac = hmacSign(Buffer.concat([mediaKeys.iv, enc]), mediaKeys.macKey).slice(0, 10) + 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) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/\=+$/, '') + + await generateThumbnail(buffer, mediaType, options) + // send a query JSON to obtain the url & auth token to upload our media + const json = (await this.query({json: ['query', 'mediaConn']})).media_conn + const auth = json.auth // the auth token + let hostname = 'https://' + json.hosts[0].hostname // first hostname available + hostname += MediaPathMap[mediaType] + '/' + fileEncSha256B64 // append path + hostname += '?auth=' + auth // add auth token + hostname += '&token=' + fileEncSha256B64 // file hash + + const urlFetch = await fetch(hostname, { + method: 'POST', + body: body, + headers: { Origin: 'https://web.whatsapp.com' }, + }) + const responseJSON = await urlFetch.json() + if (!responseJSON.url) { + throw new Error('Upload failed got: ' + JSON.stringify(responseJSON)) + } + const message = {} + message[mediaType] = { + url: responseJSON.url, + mediaKey: mediaKey.toString('base64'), + mimetype: options.mimetype, + fileEncSha256: fileEncSha256B64, + fileSha256: fileSha256.toString('base64'), + fileLength: buffer.length, + fileName: options.filename || 'file', + gifPlayback: isGIF || null, + caption: options.caption + } + return message as WAMessageContent + } + /** prepares a WAMessage for sending from the given content & options */ + prepareMessageFromContent(id: string, message: WAMessageContent, options: MessageOptions) { + if (!options.timestamp) options.timestamp = new Date() // set timestamp to now + + // prevent an annoying bug (WA doesn't accept sending messages with '@c.us') + id = whatsappID (id) + + const key = Object.keys(message)[0] + const timestamp = unixTimestampSeconds(options.timestamp) + const quoted = options.quoted + + if (options.contextInfo) message[key].contextInfo = options.contextInfo + + if (quoted) { + const participant = quoted.key.fromMe ? this.user.id : (quoted.key.participant || quoted.key.remoteJid) + + message[key].contextInfo = message[key].contextInfo || { } + message[key].contextInfo.participant = participant + message[key].contextInfo.stanzaId = quoted.key.id + message[key].contextInfo.quotedMessage = quoted.message + + // if a participant is quoted, then it must be a group + // hence, remoteJid of group must also be entered + if (quoted.key.participant) { + message[key].contextInfo.remoteJid = quoted.key.remoteJid + } + } + if (!message[key].jpegThumbnail) message[key].jpegThumbnail = options?.thumbnail + + const messageJSON = { + key: { + remoteJid: id, + fromMe: true, + id: generateMessageID(), + }, + message: message, + messageTimestamp: timestamp, + messageStubParameters: [], + participant: id.includes('@g.us') ? this.user.id : null, + status: WA_MESSAGE_STATUS_TYPE.PENDING + } + return messageJSON as WAMessage + } + /** Relay (send) a WAMessage; more advanced functionality to send a built WA Message, you may want to stick with sendMessage() */ + async relayWAMessage(message: WAMessage) { + const json = ['action', {epoch: this.msgCount.toString(), type: 'relay'}, [['message', null, message]]] + const flag = message.key.remoteJid === this.user.id ? WAFlag.acknowledge : WAFlag.ignore // acknowledge when sending message to oneself + await this.query({json, binaryTags: [WAMetric.message, flag], tag: message.key.id}) + await this.chatAddMessageAppropriate (message) + } + /** + * Fetches the latest url & media key for the given message. + * You may need to call this when the message is old & the content is deleted off of the WA servers + * @param message + */ + async updateMediaMessage (message: WAMessage) { + const content = message.message?.audioMessage || message.message?.videoMessage || message.message?.imageMessage || message.message?.stickerMessage || message.message?.documentMessage + if (!content) throw new BaileysError (`given message ${message.key.id} is not a media message`, message) + + const query = ['query',{type: 'media', index: message.key.id, owner: message.key.fromMe ? 'true' : 'false', jid: message.key.remoteJid, epoch: this.msgCount.toString()},null] + const response = await this.query ({json: query, binaryTags: [WAMetric.queryMedia, WAFlag.ignore], expect200: true}) + Object.keys (response[1]).forEach (key => content[key] = response[1][key]) // update message + } + /** + * Securely downloads the media from the message. + * Renews the download url automatically, if necessary. + */ + async downloadMediaMessage (message: WAMessage) { + const fetchHeaders = { } + try { + const buff = await decodeMediaMessageBuffer (message.message, fetchHeaders) + return buff + } catch (error) { + if (error instanceof BaileysError && error.status === 404) { // media needs to be updated + this.log (`updating media of message: ${message.key.id}`, MessageLogLevel.info) + await this.updateMediaMessage (message) + const buff = await decodeMediaMessageBuffer (message.message, fetchHeaders) + return buff + } + throw error + } + } + /** + * Securely downloads the media from the message and saves to a file. + * Renews the download url automatically, if necessary. + * @param message the media message you want to decode + * @param filename the name of the file where the media will be saved + * @param attachExtension should the parsed extension be applied automatically to the file + */ + async downloadAndSaveMediaMessage (message: WAMessage, filename: string, attachExtension: boolean=true) { + const buffer = await this.downloadMediaMessage (message) + const extension = extensionForMediaMessage (message.message) + const trueFileName = attachExtension ? (filename + '.' + extension) : filename + await fs.writeFile (trueFileName, buffer) + return trueFileName + } +} diff --git a/src/WAConnection/7.MessagesExtra.ts b/src/WAConnection/7.MessagesExtra.ts new file mode 100644 index 0000000..916dc5c --- /dev/null +++ b/src/WAConnection/7.MessagesExtra.ts @@ -0,0 +1,258 @@ +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 { whatsappID } from './Utils' + +export class WAConnection extends Base { + + async loadAllUnreadMessages () { + const tasks = this.chats.all() + .filter(chat => chat.count > 0) + .map (chat => this.loadMessages(chat.jid, chat.count)) + const list = await Promise.all (tasks) + const combined: WAMessage[] = [] + list.forEach (({messages}) => combined.push(...messages)) + return combined + } + + /** Get the message info, who has read it, who its been delivered to */ + async messageInfo (jid: string, messageID: string) { + const query = ['query', {type: 'message_info', index: messageID, jid: jid, epoch: this.msgCount.toString()}, null] + const response = (await this.query ({json: query, binaryTags: [22, WAFlag.ignore], expect200: true}))[2] + + const info: MessageInfo = {reads: [], deliveries: []} + if (response) { + //console.log (response) + const reads = response.filter (node => node[0] === 'read') + if (reads[0]) { + info.reads = reads[0][2].map (item => item[1]) + } + const deliveries = response.filter (node => node[0] === 'delivery') + if (deliveries[0]) { + info.deliveries = deliveries[0][2].map (item => item[1]) + } + } + return info + } + /** + * Read/unread messages of a chat; will mark the entire chat read by default + * @param jid the ID of the person/group whose message you want to mark read + * @param messageID optionally, the message ID + * @param count number of messages to read, set to < 0 to unread a message + */ + async sendReadReceipt(jid: string, messageID?: string, count?: number) { + jid = whatsappID (jid) + const chat = this.chats.get(jid) + count = count || Math.abs(chat?.count || 1) + + const attributes = { + jid: jid, + count: count.toString(), + index: messageID, + owner: messageID ? 'false' : null + } + const read = await this.setQuery ([['read', attributes, null]]) + if (chat) { + chat.count = count > 0 ? Math.max(chat.count-count, 0) : -1 + this.emit ('chat-update', {jid, count: chat.count}) + } + return read + } + /** + * Load the conversation with a group or person + * @param count the number of messages to load + * @param before the data for which message to offset the query by + * @param mostRecentFirst retreive the most recent message first or retreive from the converation start + */ + async loadMessages ( + jid: string, + count: number, + before?: { id?: string; fromMe?: boolean }, + mostRecentFirst: boolean = true + ) { + jid = whatsappID(jid) + + const retreive = async (count: number, indexMessage: any) => { + const json = [ + 'query', + { + epoch: this.msgCount.toString(), + type: 'message', + jid: jid, + kind: mostRecentFirst ? 'before' : 'after', + count: count.toString(), + index: indexMessage?.id, + owner: indexMessage?.fromMe === false ? 'false' : 'true', + }, + null, + ] + const response = await this.query({json, binaryTags: [WAMetric.queryMessages, WAFlag.ignore], expect200: true}) + const messages = response[2] ? (response[2] as WANode[]).map(item => item[2] as WAMessage) : [] + + return messages + } + const chat = this.chats.get (jid) + + let messages: WAMessage[] + if (!before && chat && mostRecentFirst) { + messages = chat.messages + if (messages.length < count) { + const extra = await retreive (count-messages.length, messages[0]?.key) + messages.unshift (...extra) + } + } else messages = await retreive (count, before) + + const cursor = messages[0] && messages[0].key + return {messages, cursor} + } + /** + * Load the entire friggin conversation with a group or person + * @param onMessage callback for every message retreived + * @param chunkSize the number of messages to load in a single request + * @param mostRecentFirst retreive the most recent message first or retreive from the converation start + */ + loadAllMessages(jid: string, onMessage: (m: WAMessage) => void, chunkSize = 25, mostRecentFirst = true) { + let offsetID = null + const loadMessage = async () => { + const {messages} = await this.loadMessages(jid, chunkSize, offsetID, mostRecentFirst) + // callback with most recent message first (descending order of date) + let lastMessage + if (mostRecentFirst) { + for (let i = messages.length - 1; i >= 0; i--) { + onMessage(messages[i]) + lastMessage = messages[i] + } + } else { + for (let i = 0; i < messages.length; i++) { + onMessage(messages[i]) + lastMessage = messages[i] + } + } + // if there are still more messages + if (messages.length >= chunkSize) { + offsetID = lastMessage.key // get the last message + return new Promise((resolve, reject) => { + // send query after 200 ms + setTimeout(() => loadMessage().then(resolve).catch(reject), 200) + }) + } + } + return loadMessage() as Promise + } + /** Load a single message specified by the ID */ + async loadMessage (jid: string, messageID: string) { + let messages: WAMessage[] + try { + messages = (await this.loadMessages (jid, 1, {id: messageID, fromMe: true})).messages + } catch { + messages = (await this.loadMessages (jid, 1, {id: messageID, fromMe: false})).messages + } + 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 + * @param inJid the ID of the chat to search in, set to null to search all chats + * @param count number of results to return + * @param page page number of results (starts from 1) + */ + async searchMessages(txt: string, inJid: string | null, count: number, page: number) { + const json = [ + 'query', + { + epoch: this.msgCount.toString(), + type: 'search', + search: txt, + count: count.toString(), + page: page.toString(), + jid: inJid + }, + null, + ] + const response: WANode = await this.query({json, binaryTags: [WAMetric.group, WAFlag.ignore], expect200: true}) // encrypt and send off + const messages = response[2] ? response[2].map (row => row[2]) : [] + return { last: response[1]['last'] === 'true', messages: messages as WAMessage[] } + } + /** + * Delete a message in a chat for yourself + * @param messageKey key of the message you want to delete + */ + async clearMessage (messageKey: WAMessageKey) { + const tag = Math.round(Math.random ()*1000000) + const attrs: WANode = [ + 'chat', + { jid: messageKey.remoteJid, modify_tag: tag.toString(), type: 'clear' }, + [ + ['item', {owner: `${messageKey.fromMe}`, index: messageKey.id}, null] + ] + ] + return this.setQuery ([attrs]) + } + /** + * Delete a message in a chat for everyone + * @param id the person or group where you're trying to delete the message + * @param messageKey key of the message you want to delete + */ + async deleteMessage (id: string, messageKey: WAMessageKey) { + const json: WAMessageContent = { + protocolMessage: { + key: messageKey, + type: WAMessageProto.ProtocolMessage.PROTOCOL_MESSAGE_TYPE.REVOKE + } + } + const waMessage = this.prepareMessageFromContent (id, json, {}) + await this.relayWAMessage (waMessage) + return waMessage + } + /** + * Forward a message like WA does + * @param id the id to forward the message to + * @param message the message to forward + * @param forceForward will show the message as forwarded even if it is from you + */ + async forwardMessage(id: string, message: WAMessage, forceForward: boolean=false) { + const content = message.message + if (!content) throw new Error ('no content in message') + + let key = Object.keys(content)[0] + + let score = content[key].contextInfo?.forwardingScore || 0 + score += message.key.fromMe && !forceForward ? 0 : 1 + if (key === MessageType.text) { + content[MessageType.extendedText] = { text: content[key] } + delete content[MessageType.text] + + key = MessageType.extendedText + } + if (score > 0) content[key].contextInfo = { forwardingScore: score, isForwarded: true } + else content[key].contextInfo = {} + + const waMessage = this.prepareMessageFromContent (id, content, {}) + await this.relayWAMessage (waMessage) + return waMessage + } +} \ No newline at end of file diff --git a/src/WAConnection/6.Groups.ts b/src/WAConnection/8.Groups.ts similarity index 74% rename from src/WAConnection/6.Groups.ts rename to src/WAConnection/8.Groups.ts index c5db5a3..06be771 100644 --- a/src/WAConnection/6.Groups.ts +++ b/src/WAConnection/8.Groups.ts @@ -1,4 +1,4 @@ -import {WAConnection as Base} from './5.Messages' +import {WAConnection as Base} from './7.MessagesExtra' import { WAMetric, WAFlag, WANode, WAGroupMetadata, WAGroupCreateResponse, WAGroupModification } from '../WAConnection/Constants' import { GroupSettingChange } from './Constants' import { generateMessageID } from '../WAConnection/Utils' @@ -10,23 +10,23 @@ export class WAConnection extends Base { const json: WANode = [ 'group', { - author: this.userMetaData.id, + author: this.user.id, id: tag, type: type, jid: jid, subject: subject, }, - participants ? participants.map(str => ['participant', { jid: str }, null]) : additionalNodes, + participants ? participants.map(jid => ['participant', { jid }, null]) : additionalNodes, ] - const result = await this.setQuery ([json], [WAMetric.group, WAFlag.ignore], tag) + const result = await this.setQuery ([json], [WAMetric.group, 136], tag) return result } /** Get the metadata of the group */ - groupMetadata = (jid: string) => this.queryExpecting200(['query', 'GroupMetadata', jid]) as Promise + groupMetadata = (jid: string) => this.query({json: ['query', 'GroupMetadata', jid], expect200: true}) as Promise /** Get the metadata (works after you've left the group also) */ groupMetadataMinimal = async (jid: string) => { const query = ['query', {type: 'group', jid: jid, epoch: this.msgCount.toString()}, null] - const response = await this.queryExpecting200(query, [WAMetric.group, WAFlag.ignore]) + const response = await this.query({json: query, binaryTags: [WAMetric.group, WAFlag.ignore], expect200: true}) const json = response[2][0] const creatorDesc = json[1] const participants = json[2] ? json[2].filter (item => item[0] === 'participant') : [] @@ -46,20 +46,38 @@ export class WAConnection extends Base { * @param title like, the title of the group * @param participants people to include in the group */ - groupCreate = (title: string, participants: string[]) => - this.groupQuery('create', null, title, participants) as Promise + groupCreate = async (title: string, participants: string[]) => { + const response = await this.groupQuery('create', null, title, participants) as WAGroupCreateResponse + await this.chatAdd (response.gid, title) + return response + } /** * Leave a group * @param jid the ID of the group */ - groupLeave = (jid: string) => this.groupQuery('leave', jid) as Promise<{ status: number }> + groupLeave = async (jid: string) => { + const response = await this.groupQuery('leave', jid) + + const chat = this.chats.get (jid) + if (chat) chat.read_only = 'true' + + return response + } /** * Update the subject of the group * @param {string} jid the ID of the group * @param {string} title the new title of the group */ - groupUpdateSubject = (jid: string, title: string) => - this.groupQuery('subject', jid, title) as Promise<{ status: number }> + groupUpdateSubject = async (jid: string, title: string) => { + const chat = this.chats.get (jid) + if (chat?.name === title) throw new Error ('redundant change') + + const response = await this.groupQuery('subject', jid, title) + if (chat) chat.name = title + + return response + } + /** * Update the group description * @param {string} jid the ID of the group @@ -72,7 +90,8 @@ export class WAConnection extends Base { {id: generateMessageID(), prev: metadata?.descId}, Buffer.from (description, 'utf-8') ] - return this.groupQuery ('description', jid, null, null, [node]) + const response = await this.groupQuery ('description', jid, null, null, [node]) + return response } /** * Add somebody to the group @@ -114,7 +133,7 @@ export class WAConnection extends Base { /** Get the invite link of the given group */ async groupInviteCode(jid: string) { const json = ['query', 'inviteCode', jid] - const response = await this.queryExpecting200(json) + const response = await this.query({json, expect200: true}) return response.code as string } } \ No newline at end of file diff --git a/src/WAConnection/Constants.ts b/src/WAConnection/Constants.ts index 6090263..30f0315 100644 --- a/src/WAConnection/Constants.ts +++ b/src/WAConnection/Constants.ts @@ -1,6 +1,32 @@ import { WA } from '../Binary/Constants' import { proto } from '../../WAMessage/WAMessage' +export const KEEP_ALIVE_INTERVAL_MS = 20*1000 + +// export the WAMessage Prototypes +export { proto as WAMessageProto } +export type WANode = WA.Node +export type WAMessage = proto.WebMessageInfo +export type WAMessageContent = proto.IMessage +export type WAContactMessage = proto.ContactMessage +export type WAMessageKey = proto.IMessageKey +export type WATextMessage = proto.ExtendedTextMessage +export type WAContextInfo = proto.IContextInfo +export import WA_MESSAGE_STUB_TYPE = proto.WebMessageInfo.WEB_MESSAGE_INFO_STUBTYPE +export import WA_MESSAGE_STATUS_TYPE = proto.WebMessageInfo.WEB_MESSAGE_INFO_STATUS + +export interface WALocationMessage { + degreesLatitude: number + degreesLongitude: number + address?: string +} +/** Reverse stub type dictionary */ +export const WA_MESSAGE_STUB_TYPES = function () { + const types = WA_MESSAGE_STUB_TYPE + const dict: Record = {} + Object.keys(types).forEach(element => dict[ types[element] ] = element) + return dict +}() export class BaileysError extends Error { status?: number @@ -13,7 +39,35 @@ export class BaileysError extends Error { this.context = context } } +export interface WAQuery { + json: any[] | WANode + binaryTags?: WATag + timeoutMs?: number + tag?: string + expect200?: boolean + waitForOpen?: boolean +} +export enum ReconnectMode { + /** does not reconnect */ + off = 0, + /** reconnects only when the connection is 'lost' or 'close' */ + onConnectionLost = 1, + /** reconnects on all disconnects, including take overs */ + onAllErrors = 2 +} +export type WAConnectOptions = { + /** timeout after which the connect will fail, set to null for an infinite timeout */ + timeoutMs?: number + /** should the chats be waited for */ + waitForChats?: boolean + /** retry on network errors while connecting */ + retryOnNetworkErrors?: boolean + /** use the 'reconnect' tag to reconnect instead of the 'takeover' tag */ + reconnectID?: string +} +export type WAConnectionState = 'open' | 'connecting' | 'close' +export type DisconnectReason = 'close' | 'lost' | 'replaced' | 'intentional' | 'invalid_session' | 'unknown' | 'bad_session' export enum MessageLogLevel { none=0, info=1, @@ -40,21 +94,14 @@ export interface AuthenticationCredentialsBrowser { WAToken1: string WAToken2: string } -export interface UserMetaData { +export type AnyAuthenticationCredentials = AuthenticationCredentialsBrowser | AuthenticationCredentialsBase64 | AuthenticationCredentials +export interface WAUser { id: string name: string phone: string + imgUrl: string } -export type WANode = WA.Node -export type WAMessage = proto.WebMessageInfo -export type WAMessageContent = proto.IMessage -export enum WAConnectionMode { - /** Baileys will let requests through after a simple connect */ - onlyRequireValidation = 0, - /** Baileys will let requests through only after chats & contacts are received */ - requireChatsAndContacts = 1 -} export interface WAGroupCreateResponse { status: number gid?: string @@ -68,6 +115,10 @@ export interface WAGroupMetadata { desc?: string descOwner?: string descId?: string + /** is set when the group only allows admins to change group settings */ + restrict?: 'true' + /** is set when the group only allows admins to write messages */ + announce?: 'true' participants: [{ id: string; isAdmin: boolean; isSuperAdmin: boolean }] } export interface WAGroupModification { @@ -83,16 +134,22 @@ export interface WAContact { short?: string } export interface WAChat { - t: string + jid: string + + t: number + /** number of unread messages, is < 0 if the chat is manually marked unread */ count: number archive?: 'true' | 'false' read_only?: 'true' | 'false' mute?: string pin?: string spam: 'false' | 'true' - jid: string modify_tag: string + name?: string + + // Baileys added properties messages: WAMessage[] + imgUrl?: string } export enum WAMetric { debugLog = 1, @@ -133,8 +190,6 @@ export enum WAFlag { } /** Tag used with binary queries */ export type WATag = [WAMetric, WAFlag] -// export the WAMessage Prototype as well -export { proto as WAMessageProto } from '../../WAMessage/WAMessage' /** set of statuses visible to other people; see updatePresence() in WhatsAppWeb.Send */ export enum Presence { @@ -144,12 +199,6 @@ export enum Presence { recording = 'recording', // "recording..." paused = 'paused', // I have no clue } -/** Status of a message sent or received */ -export enum MessageStatus { - sent = 'sent', - received = 'received', - read = 'read', -} /** Set of message types that are supported by the library */ export enum MessageType { text = 'conversation', @@ -229,7 +278,7 @@ export interface MessageStatusUpdate { /** Message IDs read/delivered */ ids: string[] /** Status of the Message IDs */ - type: WA_MESSAGE_STATUS_TYPE + type: WA_MESSAGE_STATUS_TYPE | 'delete' } export enum GroupSettingChange { messageSend = 'announcement', @@ -263,22 +312,21 @@ export interface WASendMessageResponse { messageID: string message: WAMessage } -export interface WALocationMessage { - degreesLatitude: number - degreesLongitude: number - address?: string -} -export import WA_MESSAGE_STUB_TYPE = proto.WebMessageInfo.WEB_MESSAGE_INFO_STUBTYPE -export import WA_MESSAGE_STATUS_TYPE = proto.WebMessageInfo.WEB_MESSAGE_INFO_STATUS - -/** Reverse stub type dictionary */ -export const WAMessageType = function () { - const types = WA_MESSAGE_STUB_TYPE - const dict: Record = {} - Object.keys(types).forEach(element => dict[ types[element] ] = element) - return dict -}() -export type WAContactMessage = proto.ContactMessage -export type WAMessageKey = proto.IMessageKey -export type WATextMessage = proto.ExtendedTextMessage -export type WAContextInfo = proto.IContextInfo +export type BaileysEvent = + 'open' | + 'connecting' | + 'close' | + 'qr' | + 'connection-phone-change' | + 'user-presence-update' | + 'user-status-update' | + 'chat-new' | + 'chat-update' | + 'message-new' | + 'message-update' | + 'group-participants-add' | + 'group-participants-remove' | + 'group-participants-promote' | + 'group-participants-demote' | + 'group-settings-update' | + 'group-description-update' \ No newline at end of file diff --git a/src/WAConnection/Utils.ts b/src/WAConnection/Utils.ts index a1fb053..3065096 100644 --- a/src/WAConnection/Utils.ts +++ b/src/WAConnection/Utils.ts @@ -5,9 +5,10 @@ import {promises as fs} from 'fs' import fetch from 'node-fetch' import { exec } from 'child_process' import {platform, release} from 'os' +import WS from 'ws' import Decoder from '../Binary/Decoder' -import { MessageType, HKDFInfoKeys, MessageOptions, WAChat, WAMessageType, WAMessage, WAMessageContent, BaileysError, WAMessageProto } from './Constants' +import { MessageType, HKDFInfoKeys, MessageOptions, WAChat, WAMessageContent, BaileysError, WAMessageProto } from './Constants' const platformMap = { 'aix': 'AIX', @@ -18,7 +19,7 @@ const platformMap = { export const Browsers = { ubuntu: browser => ['Ubuntu', browser, '18.04'] as [string, string, string], macOS: browser => ['Mac OS', browser, '10.15.3'] as [string, string, string], - baileys: browser => ['Baileys', browser, '2.0'] as [string, string, string], + baileys: browser => ['Baileys', browser, '3.0'] as [string, string, string], /** The appropriate browser based on your OS & release */ appropriate: browser => [ platformMap [platform()] || 'Ubuntu', browser, release() ] as [string, string, string] } @@ -27,11 +28,10 @@ function hashCode(s: string) { h = Math.imul(31, h) + s.charCodeAt(i) | 0; return h; } -export const waChatUniqueKey = (c: WAChat) => ((+c.t*100000) + (hashCode(c.jid)%100000))*-1 // -1 to sort descending +export const waChatUniqueKey = (c: WAChat) => ((c.t*100000) + (hashCode(c.jid)%100000))*-1 // -1 to sort descending +export const whatsappID = (jid: string) => jid?.replace ('@c.us', '@s.whatsapp.net') +export const isGroupID = (jid: string) => jid?.includes ('@g.us') -export function whatsappID (jid: string) { - return jid.replace ('@c.us', '@s.whatsapp.net') -} /** decrypt AES 256 CBC; where the IV is prefixed to the buffer */ export function aesDecrypt(buffer: Buffer, key: Buffer) { return aesDecryptWithIV(buffer.slice(16, buffer.length), key, buffer.slice(0, 16)) @@ -67,25 +67,85 @@ export function hkdf(buffer: Buffer, expandedLength: number, info = null) { export function randomBytes(length) { return Crypto.randomBytes(length) } -export const createTimeout = (timeout) => new Promise(resolve => setTimeout(resolve, timeout)) +/** unix timestamp of a date in seconds */ +export const unixTimestampSeconds = (date: Date = new Date()) => Math.floor(date.getTime()/1000) + +export const delay = (ms: number) => delayCancellable (ms).delay +export const delayCancellable = (ms: number) => { + let timeout: NodeJS.Timeout + let reject: (error) => void + const delay: Promise = new Promise((resolve, _reject) => { + timeout = setTimeout(resolve, ms) + reject = _reject + }) + const cancel = () => { + clearTimeout (timeout) + reject (new Error('cancelled')) + } + return { delay, cancel } +} +export async function promiseTimeout(ms: number, promise: (resolve: (v?: T)=>void, reject: (error) => void) => void) { + if (!ms) return new Promise (promise) -export async function promiseTimeout(ms: number, promise: Promise) { - if (!ms) return promise // Create a promise that rejects in milliseconds - let timeoutI - const timeout = new Promise( - (_, reject) => timeoutI = setTimeout(() => reject(new BaileysError ('Timed out', promise)), ms) - ) + const {delay, cancel} = delayCancellable (ms) + + let pReject: (error) => void + const p = new Promise ((resolve, reject) => { + promise (resolve, reject) + pReject = reject + }) + try { - const content = await Promise.race([promise, timeout]) + const content = await Promise.race([ + p, + delay.then(() => pReject(new BaileysError('timed out', p))) + ]) + cancel () return content as T } finally { - clearTimeout (timeoutI) + cancel () } } + +export const openWebSocketConnection = (timeoutMs: number, retryOnNetworkError: boolean) => { + const newWS = async () => { + const conn = new WS('wss://web.whatsapp.com/ws', null, { origin: 'https://web.whatsapp.com', timeout: timeoutMs }) + await new Promise ((resolve, reject) => { + conn.on('open', () => { + conn.removeAllListeners ('error') + conn.removeAllListeners ('close') + conn.removeAllListeners ('open') + + resolve () + }) + // if there was an error in the WebSocket + conn.on('error', reject) + conn.on('close', () => reject(new Error('close'))) + }) + return conn + } + let cancelled = false + const connect = async () => { + while (!cancelled) { + try { + const ws = await newWS() + if (!cancelled) return ws + break + } catch (error) { + if (!retryOnNetworkError) throw error + await delay (1000) + } + } + throw new Error ('cancelled') + } + const cancel = () => cancelled = true + return { ws: connect(), cancel } +} + // whatsapp requires a message tag for every message, we just use the timestamp as one export function generateMessageTag(epoch?: number) { - let tag = Math.round(new Date().getTime()/1000).toString() + let tag = unixTimestampSeconds().toString() if (epoch) tag += '.--' + epoch // attach epoch if provided return tag } @@ -115,7 +175,6 @@ export function decryptWA (message: string | Buffer, macKey: Buffer, encKey: Buf let json let tags = null if (typeof data === 'string') { - // if the first character is a "[", then the data must just be plain JSON array or object json = JSON.parse(data) // parse the JSON } else { if (!macKey || !encKey) { @@ -136,7 +195,12 @@ export function decryptWA (message: string | Buffer, macKey: Buffer, encKey: Buf const computedChecksum = hmacSign(data, macKey) // compute the sign of the message we recieved using our macKey if (!checksum.equals(computedChecksum)) { - throw new Error (`Checksums don't match:\nog: ${checksum.toString('hex')}\ncomputed: ${computedChecksum.toString('hex')}`) + throw new Error (` + Checksums don't match: + og: ${checksum.toString('hex')} + computed: ${computedChecksum.toString('hex')} + message: ${message.slice(0, 80).toString()} + `) } // the checksum the server sent, must match the one we computed for the message to be valid const decrypted = aesDecrypt(data, encKey) // decrypt using AES diff --git a/src/WAConnection/WAConnection.ts b/src/WAConnection/WAConnection.ts index 92d7a98..08502c1 100644 --- a/src/WAConnection/WAConnection.ts +++ b/src/WAConnection/WAConnection.ts @@ -1,3 +1,3 @@ -export * from './6.Groups' +export * from './8.Groups' export * from './Utils' export * from './Constants' \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index f30ced1..b35aa38 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,5 +13,5 @@ "lib": ["es2019", "esnext.array"] }, "include": ["src/*/*.ts"], - "exclude": ["node_modules", "src/*/Tests.ts"] + "exclude": ["node_modules", "src/Tests/*"] }