diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000000..f59ec20aabf --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +* \ No newline at end of file diff --git a/Example/example.ts b/Example/example.ts index 33db559ed26..10bd8336362 100644 --- a/Example/example.ts +++ b/Example/example.ts @@ -1,6 +1,8 @@ import { Boom } from '@hapi/boom' +import parsePhoneNumber from 'libphonenumber-js' import NodeCache from 'node-cache' -import makeWASocket, { AnyMessageContent, delay, DisconnectReason, fetchLatestBaileysVersion, getAggregateVotesInPollMessage, makeCacheableSignalKeyStore, makeInMemoryStore, proto, useMultiFileAuthState, WAMessageContent, WAMessageKey } from '../src' +import readline from 'readline' +import makeWASocket, { AnyMessageContent, delay, DisconnectReason, fetchLatestBaileysVersion, getAggregateVotesInPollMessage, makeCacheableSignalKeyStore, makeInMemoryStore, PHONENUMBER_MCC, proto, useMultiFileAuthState, WAMessageContent, WAMessageKey } from '../src' import MAIN_LOGGER from '../src/Utils/logger' const logger = MAIN_LOGGER.child({}) @@ -8,6 +10,7 @@ logger.level = 'trace' const useStore = !process.argv.includes('--no-store') const doReplies = !process.argv.includes('--no-reply') +const useMobile = process.argv.includes('--mobile') // external map to store retry counts of messages when decryption/encryption fails // keep this out of the socket itself, so as to prevent a message decryption/encryption loop across socket restarts @@ -33,6 +36,7 @@ const startSock = async() => { version, logger, printQRInTerminal: true, + mobile: useMobile, auth: { creds: state.creds, /** caching makes the store faster to send/recv messages */ @@ -49,6 +53,67 @@ const startSock = async() => { store?.bind(sock.ev) + // If mobile was chosen, ask for the code + if(useMobile && !sock.authState.creds.registered) { + const question = (text: string) => new Promise((resolve) => rl.question(text, resolve)) + + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) + const { registration } = sock.authState.creds || { registration: {} } + + if(!registration.phoneNumber) { + registration.phoneNumber = await question('Please enter your mobile phone number:\n') + } + + const phoneNumber = parsePhoneNumber(registration!.phoneNumber) + if(!phoneNumber?.isValid()) { + throw new Error('Invalid phone number: ' + registration!.phoneNumber) + } + + registration.phoneNumber = phoneNumber.format('E.164') + registration.phoneNumberCountryCode = phoneNumber.countryCallingCode + registration.phoneNumberNationalNumber = phoneNumber.nationalNumber + const mcc = PHONENUMBER_MCC[phoneNumber.countryCallingCode] + if(!mcc) { + throw new Error('Could not find MCC for phone number: ' + registration!.phoneNumber + '\nPlease specify the MCC manually.') + } + + registration.phoneNumberMobileCountryCode = mcc + + async function enterCode() { + try { + const code = await question('Please enter the one time code:\n') + const response = await sock.register(code.replace(/["']/g, '').trim().toLowerCase()) + console.log('Successfully registered your phone number.') + console.log(response) + rl.close() + } catch(error) { + console.error('Failed to register your phone number. Please try again.\n', error) + await askForOTP() + } + } + + async function askForOTP() { + let code = await question('How would you like to receive the one time code for registration? "sms" or "voice"\n') + code = code.replace(/["']/g, '').trim().toLowerCase() + + if(code !== 'sms' && code !== 'voice') { + return await askForOTP() + } + + registration.method = code + + try { + await sock.requestRegistrationCode(registration) + await enterCode() + } catch(error) { + console.error('Failed to request registration code. Please try again.\n', error) + await askForOTP() + } + } + + askForOTP() + } + const sendMessageWTyping = async(msg: AnyMessageContent, jid: string) => { await sock.presenceSubscribe(jid) await delay(500) diff --git a/README.md b/README.md index ce43e3c80a2..c9aaa344a08 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,9 @@ import makeWASocket from '@whiskeysockets/baileys' TODO -## Connecting +## Connecting multi device (recommended) + +WhatsApp provides a multi-device API that allows Baileys to be authenticated as a second WhatsApp client by scanning a QR code with WhatsApp on your phone. ``` ts import makeWASocket, { DisconnectReason } from '@whiskeysockets/baileys' @@ -82,6 +84,12 @@ If the connection is successful, you will see a QR code printed on your terminal **Note:** the code to support the legacy version of WA Web (pre multi-device) has been removed in v5. Only the standard multi-device connection is now supported. This is done as WA seems to have completely dropped support for the legacy version. +## Connecting native mobile api + +Baileys also supports the native mobile API, which allows users to authenticate as a standalone WhatsApp client using their phone number. + +Run the [example](Example/example.ts) file with ``--mobile`` cli flag to use the native mobile API. + ## Configuring the Connection You can configure the connection by passing a `SocketConfig` object. diff --git a/package.json b/package.json index a73cad06584..6c070904600 100644 --- a/package.json +++ b/package.json @@ -45,11 +45,13 @@ "@hapi/boom": "^9.1.3", "axios": "^1.3.3", "futoin-hkdf": "^1.5.1", + "libphonenumber-js": "^1.10.20", "libsignal": "https://github.com/adiwajshing/libsignal-node.git", "music-metadata": "^7.12.3", "node-cache": "^5.1.2", "pino": "^7.0.0", "protobufjs": "^6.11.3", + "uuid": "^9.0.0", "ws": "^8.0.0" }, "devDependencies": { diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index 6491a8c2a93..e964a9fcd73 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -4,22 +4,36 @@ import type { AuthenticationState, MediaType, SocketConfig, WAVersion } from '.. import { Browsers } from '../Utils' import logger from '../Utils/logger' import { version } from './baileys-version.json' +import phoneNumberMCC from './phonenumber-mcc.json' export const UNAUTHORIZED_CODES = [401, 403, 419] +export const PHONENUMBER_MCC = phoneNumberMCC + export const DEFAULT_ORIGIN = 'https://web.whatsapp.com' +export const MOBILE_ENDPOINT = 'g.whatsapp.net' +export const MOBILE_PORT = 443 export const DEF_CALLBACK_PREFIX = 'CB:' export const DEF_TAG_PREFIX = 'TAG:' export const PHONE_CONNECTION_CB = 'CB:Pong' export const WA_DEFAULT_EPHEMERAL = 7 * 24 * 60 * 60 +export const MOBILE_TOKEN = Buffer.from('0a1mLfGUIBVrMKF1RdvLI5lkRBvof6vn0fD2QRSM4174c0243f5277a5d7720ce842cc4ae6') +export const MOBILE_REGISTRATION_ENDPOINT = 'https://v.whatsapp.net/v2' +export const MOBILE_USERAGENT = 'WhatsApp/2.22.24.81 iOS/15.3.1 Device/Apple-iPhone_7' +export const REGISTRATION_PUBLIC_KEY = Buffer.from([ + 5, 142, 140, 15, 116, 195, 235, 197, 215, 166, 134, 92, 108, 60, 132, 56, 86, 176, 97, 33, 204, 232, 234, 119, 77, + 34, 251, 111, 18, 37, 18, 48, 45, +]) export const NOISE_MODE = 'Noise_XX_25519_AESGCM_SHA256\0\0\0\0' export const DICT_VERSION = 2 export const KEY_BUNDLE_TYPE = Buffer.from([5]) export const NOISE_WA_HEADER = Buffer.from( [ 87, 65, 6, DICT_VERSION ] ) // last is "DICT_VERSION" +export const PROTOCOL_VERSION = [5, 2] +export const MOBILE_NOISE_HEADER = Buffer.concat([Buffer.from('WA'), Buffer.from(PROTOCOL_VERSION)]) /** from: https://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url */ export const URL_REGEX = /(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/ export const URL_EXCLUDE_REGEX = /.*@.*/ @@ -74,7 +88,8 @@ export const MEDIA_PATH_MAP: { [T in MediaType]?: string } = { sticker: '/mms/image', 'thumbnail-link': '/mms/image', 'product-catalog-image': '/product/image', - 'md-app-state': '' + 'md-app-state': '', + 'md-msg-hist': '/mms/md-app-state', } export const MEDIA_HKDF_KEY_MAPPING = { diff --git a/src/Defaults/phonenumber-mcc.json b/src/Defaults/phonenumber-mcc.json new file mode 100644 index 00000000000..2fd7c9fe4f4 --- /dev/null +++ b/src/Defaults/phonenumber-mcc.json @@ -0,0 +1,223 @@ +{ + "93": 412, + "355": 276, + "213": 603, + "1-684": 544, + "376": 213, + "244": 631, + "1-264": 365, + "1-268": 344, + "54": 722, + "374": 283, + "297": 363, + "61": 505, + "43": 232, + "994": 400, + "1-242": 364, + "973": 426, + "880": 470, + "1-246": 342, + "375": 257, + "32": 206, + "501": 702, + "229": 616, + "1-441": 350, + "975": 402, + "591": 736, + "387": 218, + "267": 652, + "55": 724, + "1-284": 348, + "673": 528, + "359": 284, + "226": 613, + "257": 642, + "855": 456, + "237": 624, + "238": 625, + "1-345": 346, + "236": 623, + "235": 622, + "56": 730, + "86": 454, + "57": 732, + "269": 654, + "682": 548, + "506": 712, + "385": 219, + "53": 368, + "357": 280, + "420": 230, + "243": 630, + "45": 238, + "253": 638, + "1-767": 366, + "1-809": 370, + "1-849": 370, + "1-829": 370, + "593": 740, + "20": 602, + "503": 706, + "240": 627, + "291": 657, + "372": 248, + "251": 636, + "500": 750, + "298": 288, + "679": 542, + "358": 244, + "33": 208, + "689": 547, + "241": 628, + "220": 607, + "995": 282, + "49": 262, + "233": 620, + "350": 266, + "30": 202, + "299": 290, + "1-473": 352, + "1-671": 535, + "502": 704, + "224": 537, + "592": 738, + "509": 372, + "504": 708, + "852": 454, + "36": 216, + "354": 274, + "91": 404, + "62": 510, + "98": 432, + "964": 418, + "353": 234, + "972": 425, + "39": 222, + "225": 612, + "1-876": 338, + "81": 440, + "962": 416, + "254": 639, + "686": 545, + "383": 221, + "965": 419, + "371": 247, + "961": 415, + "266": 651, + "231": 618, + "218": 606, + "423": 295, + "370": 246, + "352": 270, + "389": 294, + "261": 646, + "265": 650, + "60": 502, + "960": 472, + "223": 610, + "356": 278, + "692": 551, + "222": 609, + "230": 617, + "52": 334, + "691": 550, + "373": 259, + "377": 212, + "976": 428, + "382": 297, + "1-664": 354, + "212": 604, + "258": 643, + "95": 414, + "264": 649, + "674": 536, + "977": 429, + "31": 204, + "687": 546, + "64": 530, + "505": 710, + "227": 614, + "234": 621, + "683": 555, + "1-670": 534, + "47": 242, + "968": 226, + "92": 410, + "680": 552, + "970": 423, + "507": 714, + "675": 537, + "595": 744, + "51": 716, + "63": 515, + "48": 260, + "351": 268, + "1-787, 1-939": 330, + "974": 427, + "242": 630, + "40": 226, + "7": 250, + "250": 635, + "290": 658, + "1-869": 356, + "1-758": 358, + "508": 308, + "1-784": 360, + "685": 544, + "378": 292, + "239": 626, + "966": 420, + "221": 608, + "381": 220, + "248": 633, + "232": 619, + "65": 525, + "386": 293, + "677": 540, + "27": 655, + "211": 659, + "34": 214, + "94": 413, + "249": 634, + "597": 746, + "268": 653, + "46": 240, + "41": 228, + "963": 417, + "886": 466, + "992": 436, + "255": 640, + "66": 520, + "228": 615, + "690": 554, + "676": 539, + "1-868": 374, + "216": 605, + "90": 286, + "993": 438, + "1-649": 376, + "688": 553, + "1-340": 332, + "256": 641, + "380": 255, + "971": 424, + "44": 234, + "1": 310, + "598": 748, + "998": 434, + "678": 541, + "379": 225, + "58": 734, + "681": 543, + "967": 421, + "260": 645, + "263": 648, + "670": 514, + "245": 632, + "856": 457, + "599": 362, + "850": 467, + "262": 647, + "82": 450, + "84": 452 +} diff --git a/src/Socket/chats.ts b/src/Socket/chats.ts index eb731a202d8..2932c427406 100644 --- a/src/Socket/chats.ts +++ b/src/Socket/chats.ts @@ -335,8 +335,8 @@ export const makeChatsSocket = (config: SocketConfig) => { } } - const updateAccountSyncTimestamp = async(fromTimestamp: number | string) => { - logger.info({ fromTimestamp }, 'requesting account sync') + const cleanDirtyBits = async(type: 'account_sync' | 'groups', fromTimestamp?: number | string) => { + logger.info({ fromTimestamp }, 'clean dirty bits ' + type) await sendNode({ tag: 'iq', attrs: { @@ -349,8 +349,8 @@ export const makeChatsSocket = (config: SocketConfig) => { { tag: 'clean', attrs: { - type: 'account_sync', - timestamp: fromTimestamp.toString(), + type, + ...(fromTimestamp ? { timestamp: fromTimestamp.toString() } : null), } } ] @@ -920,13 +920,16 @@ export const makeChatsSocket = (config: SocketConfig) => { if(attrs.timestamp) { let { lastAccountSyncTimestamp } = authState.creds if(lastAccountSyncTimestamp) { - await updateAccountSyncTimestamp(lastAccountSyncTimestamp) + await cleanDirtyBits('account_sync', lastAccountSyncTimestamp) } lastAccountSyncTimestamp = +attrs.timestamp ev.emit('creds.update', { lastAccountSyncTimestamp }) } + break + case 'groups': + // handled in groups.ts break default: logger.info({ node }, 'received unknown sync') @@ -953,7 +956,7 @@ export const makeChatsSocket = (config: SocketConfig) => { // if we don't have the app state key // we keep buffering events until we finally have // the key and can sync the messages - if(!authState.creds?.myAppStateKeyId) { + if(!authState.creds?.myAppStateKeyId && !config.mobile) { ev.buffer() needToFlushWithAppStateSync = true } @@ -987,6 +990,7 @@ export const makeChatsSocket = (config: SocketConfig) => { getBusinessProfile, resyncAppState, chatModify, + cleanDirtyBits, addChatLabel, removeChatLabel, addMessageLabel, diff --git a/src/Socket/groups.ts b/src/Socket/groups.ts index 6a0dcca9de4..7eb5aa7966d 100644 --- a/src/Socket/groups.ts +++ b/src/Socket/groups.ts @@ -29,6 +29,55 @@ export const makeGroupsSocket = (config: SocketConfig) => { return extractGroupMetadata(result) } + + const groupFetchAllParticipating = async() => { + const result = await query({ + tag: 'iq', + attrs: { + to: '@g.us', + xmlns: 'w:g2', + type: 'get', + }, + content: [ + { + tag: 'participating', + attrs: { }, + content: [ + { tag: 'participants', attrs: { } }, + { tag: 'description', attrs: { } } + ] + } + ] + }) + const data: { [_: string]: GroupMetadata } = { } + const groupsChild = getBinaryNodeChild(result, 'groups') + if(groupsChild) { + const groups = getBinaryNodeChildren(groupsChild, 'group') + for(const groupNode of groups) { + const meta = extractGroupMetadata({ + tag: 'result', + attrs: { }, + content: [groupNode] + }) + data[meta.id] = meta + } + } + + sock.ev.emit('groups.update', Object.values(data)) + + return data + } + + sock.ws.on('CB:ib,,dirty', async(node: BinaryNode) => { + const { attrs } = getBinaryNodeChild(node, 'dirty')! + if(attrs.type !== 'groups') { + return + } + + await groupFetchAllParticipating() + await sock.cleanDirtyBits('groups') + }) + return { ...sock, groupMetadata, @@ -211,41 +260,7 @@ export const makeGroupsSocket = (config: SocketConfig) => { groupSettingUpdate: async(jid: string, setting: 'announcement' | 'not_announcement' | 'locked' | 'unlocked') => { await groupQuery(jid, 'set', [ { tag: setting, attrs: { } } ]) }, - groupFetchAllParticipating: async() => { - const result = await query({ - tag: 'iq', - attrs: { - to: '@g.us', - xmlns: 'w:g2', - type: 'get', - }, - content: [ - { - tag: 'participating', - attrs: { }, - content: [ - { tag: 'participants', attrs: { } }, - { tag: 'description', attrs: { } } - ] - } - ] - }) - const data: { [_: string]: GroupMetadata } = { } - const groupsChild = getBinaryNodeChild(result, 'groups') - if(groupsChild) { - const groups = getBinaryNodeChildren(groupsChild, 'group') - for(const groupNode of groups) { - const meta = extractGroupMetadata({ - tag: 'result', - attrs: { }, - content: [groupNode] - }) - data[meta.id] = meta - } - } - - return data - } + groupFetchAllParticipating } } diff --git a/src/Socket/index.ts b/src/Socket/index.ts index 7efebf0b2b5..7338c9cefd2 100644 --- a/src/Socket/index.ts +++ b/src/Socket/index.ts @@ -1,6 +1,6 @@ import { DEFAULT_CONNECTION_CONFIG } from '../Defaults' import { UserFacingSocketConfig } from '../Types' -import { makeBusinessSocket as _makeSocket } from './business' +import { makeRegistrationSocket as _makeSocket } from './registration' // export the last socket layer const makeWASocket = (config: UserFacingSocketConfig) => ( diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index 88d8fa5ffa9..fd0bf9cffdb 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -564,7 +564,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { if(msg.messageStubType === proto.WebMessageInfo.StubType.CIPHERTEXT) { retryMutex.mutex( async() => { - if(ws.readyState === ws.OPEN) { + if(ws.isOpen) { const encNode = getBinaryNodeChild(node, 'enc') await sendRetryRequest(node, !encNode) if(retryRequestDelayMs) { diff --git a/src/Socket/mobile-socket.ts b/src/Socket/mobile-socket.ts new file mode 100644 index 00000000000..f61c5183166 --- /dev/null +++ b/src/Socket/mobile-socket.ts @@ -0,0 +1,46 @@ +import { Socket } from 'net' +import { MOBILE_ENDPOINT, MOBILE_PORT } from '../Defaults' +import { SocketConfig } from '../Types' + +export class MobileSocket extends Socket { + constructor(public config: SocketConfig) { + super() + + this.on('data', (d) => { + this.emit('message', d) + }) + } + + override connect() { + return super.connect({ + host: MOBILE_ENDPOINT, + port: MOBILE_PORT, + }, () => { + this.emit('open') + }) + } + + get isOpen(): boolean { + return this.readyState === 'open' + } + + get isClosed(): boolean { + return this.readyState === 'closed' + } + + get isClosing(): boolean { + return this.isClosed + } + + get isConnecting(): boolean { + return this.readyState === 'opening' + } + + close(): void { + this.end() + } + + send(data: unknown, cb?: ((err?: Error | undefined) => void) | undefined) { + return super.write(data as Uint8Array | string, undefined, cb as ((err?: Error | undefined) => void)) + } +} \ No newline at end of file diff --git a/src/Socket/registration.ts b/src/Socket/registration.ts new file mode 100644 index 00000000000..66f90aa2e75 --- /dev/null +++ b/src/Socket/registration.ts @@ -0,0 +1,250 @@ +/* eslint-disable camelcase */ +import axios, { AxiosRequestConfig } from 'axios' +import { MOBILE_REGISTRATION_ENDPOINT, MOBILE_TOKEN, MOBILE_USERAGENT, REGISTRATION_PUBLIC_KEY } from '../Defaults' +import { KeyPair, SignedKeyPair, SocketConfig } from '../Types' +import { aesEncryptGCM, Curve, md5 } from '../Utils/crypto' +import { jidEncode } from '../WABinary' +import { makeBusinessSocket } from './business' +import { MobileSocket } from './mobile-socket' + +function urlencode(str: string) { + return str.replace(/-/g, '%2d').replace(/_/g, '%5f').replace(/~/g, '%7e') +} + +const validRegistrationOptions = (config: RegistrationOptions) => config?.phoneNumberCountryCode && + config.phoneNumberNationalNumber && + config.phoneNumberMobileCountryCode + +export const makeRegistrationSocket = (config: SocketConfig) => { + const sock = makeBusinessSocket(config) + + const register = async(code: string) => { + if(!validRegistrationOptions(config.auth.creds.registration)) { + throw new Error('please specify the registration options') + } + + const result = await mobileRegister({ ...sock.authState.creds, ...sock.authState.creds.registration as RegistrationOptions, code }, config.options) + + sock.authState.creds.me = { + id: jidEncode(result.login!, 's.whatsapp.net'), + name: '~' + } + + sock.authState.creds.registered = true + sock.ev.emit('creds.update', sock.authState.creds) + + if(sock.ws instanceof MobileSocket) { + sock.ws.connect() + } + + return result + } + + const requestRegistrationCode = async(registrationOptions?: RegistrationOptions) => { + registrationOptions = registrationOptions || config.auth.creds.registration + if(!validRegistrationOptions(registrationOptions)) { + throw new Error('Invalid registration options') + } + + sock.authState.creds.registration = registrationOptions + + sock.ev.emit('creds.update', sock.authState.creds) + + return mobileRegisterCode({ ...config.auth.creds, ...registrationOptions }, config.options) + } + + return { + ...sock, + register, + requestRegistrationCode, + } +} + +// Backup_token: Base64.getEncoder().encodeToString(Arrays.copyOfRange(Base64.getDecoder().decode(UUID.randomUUID().toString().replace('-','')),0,15)) + +export interface RegistrationData { + registrationId: number + signedPreKey: SignedKeyPair + noiseKey: KeyPair + signedIdentityKey: KeyPair + identityId: Buffer + phoneId: string + deviceId: string + backupToken: Buffer +} + +export interface RegistrationOptions { + /** your phone number */ + phoneNumber?: string + /** the country code of your phone number */ + phoneNumberCountryCode: string + /** your phone number without country code */ + phoneNumberNationalNumber: string + /** the country code of your mobile network + * @see {@link https://de.wikipedia.org/wiki/Mobile_Country_Code} + */ + phoneNumberMobileCountryCode: string + /** the network code of your mobile network + * @see {@link https://de.wikipedia.org/wiki/Mobile_Network_Code} + */ + phoneNumberMobileNetworkCode: string + /** + * How to send the one time code + */ + method?: 'sms' | 'voice' +} + +export type RegistrationParams = RegistrationData & RegistrationOptions + +function convertBufferToUrlHex(buffer: Buffer) { + var id = '' + + buffer.forEach((x) => { + // encode random identity_id buffer as percentage url encoding + id += `%${x.toString(16).padStart(2, '0').toLowerCase()}` + }) + + return id +} + +export function registrationParams(params: RegistrationParams) { + const e_regid = Buffer.alloc(4) + e_regid.writeInt32BE(params.registrationId) + + const e_skey_id = Buffer.alloc(3) + e_skey_id.writeInt16BE(params.signedPreKey.keyId) + + params.phoneNumberCountryCode = params.phoneNumberCountryCode.replace('+', '').trim() + params.phoneNumberNationalNumber = params.phoneNumberNationalNumber.replace(/[/-\s)(]/g, '').trim() + + return { + cc: params.phoneNumberCountryCode, + in: params.phoneNumberNationalNumber, + Rc: '0', + lg: 'en', + lc: 'GB', + mistyped: '6', + authkey: Buffer.from(params.noiseKey.public).toString('base64url'), + e_regid: e_regid.toString('base64url'), + e_keytype: 'BQ', + e_ident: Buffer.from(params.signedIdentityKey.public).toString('base64url'), + // e_skey_id: e_skey_id.toString('base64url'), + e_skey_id: 'AAAA', + e_skey_val: Buffer.from(params.signedPreKey.keyPair.public).toString('base64url'), + e_skey_sig: Buffer.from(params.signedPreKey.signature).toString('base64url'), + fdid: params.phoneId, + network_ratio_type: '1', + expid: params.deviceId, + simnum: '1', + hasinrc: '1', + pid: Math.floor(Math.random() * 1000).toString(), + id: convertBufferToUrlHex(params.identityId), + backup_token: convertBufferToUrlHex(params.backupToken), + token: md5(Buffer.concat([MOBILE_TOKEN, Buffer.from(params.phoneNumberNationalNumber)])).toString('hex'), + } +} + +/** + * Requests a registration code for the given phone number. + */ +export function mobileRegisterCode(params: RegistrationParams, fetchOptions?: AxiosRequestConfig) { + return mobileRegisterFetch('/code', { + params: { + ...registrationParams(params), + mcc: `${params.phoneNumberMobileCountryCode}`.padStart(3, '0'), + mnc: `${params.phoneNumberMobileNetworkCode || '001'}`.padStart(3, '0'), + sim_mcc: '000', + sim_mnc: '000', + method: params?.method || 'sms', + reason: '', + hasav: '1' + }, + ...fetchOptions, + }) +} + +export function mobileRegisterExists(params: RegistrationParams, fetchOptions?: AxiosRequestConfig) { + return mobileRegisterFetch('/exist', { + params: registrationParams(params), + ...fetchOptions + }) +} + +/** + * Registers the phone number on whatsapp with the received OTP code. + */ +export async function mobileRegister(params: RegistrationParams & { code: string }, fetchOptions?: AxiosRequestConfig) { + //const result = await mobileRegisterFetch(`/reg_onboard_abprop?cc=${params.phoneNumberCountryCode}&in=${params.phoneNumberNationalNumber}&rc=0`) + + return mobileRegisterFetch('/register', { + params: { ...registrationParams(params), code: params.code.replace('-', '') }, + ...fetchOptions, + }) +} + +/** + * Encrypts the given string as AEAD aes-256-gcm with the public whatsapp key and a random keypair. + */ +export function mobileRegisterEncrypt(data: string) { + const keypair = Curve.generateKeyPair() + const key = Curve.sharedKey(keypair.private, REGISTRATION_PUBLIC_KEY) + + const buffer = aesEncryptGCM(Buffer.from(data), new Uint8Array(key), Buffer.alloc(12), Buffer.alloc(0)) + + return Buffer.concat([Buffer.from(keypair.public), buffer]).toString('base64url') +} + +export async function mobileRegisterFetch(path: string, opts: AxiosRequestConfig = {}) { + let url = `${MOBILE_REGISTRATION_ENDPOINT}${path}` + + if(opts.params) { + const parameter = [] as string[] + + for(const param in opts.params) { + parameter.push(param + '=' + urlencode(opts.params[param])) + } + + console.log('parameter', opts.params, parameter) + + // const params = urlencode(mobileRegisterEncrypt(parameter.join('&'))) + // url += `?ENC=${params}` + url += `?${parameter.join('&')}` + delete opts.params + } + + if(!opts.headers) { + opts.headers = {} + } + + opts.headers['User-Agent'] = MOBILE_USERAGENT + + const response = await axios(url, opts) + + var json = response.data + + if(response.status > 300 || json.reason) { + throw json + } + + if(json.status && !['ok', 'sent'].includes(json.status)) { + throw json + } + + return json as ExistsResponse +} + + +export interface ExistsResponse { + status: 'fail' + voice_length?: number + voice_wait?: number + sms_length?: number + sms_wait?: number + reason?: 'incorrect' | 'missing_param' + login?: string + flash_type?: number + ab_hash?: string + ab_key?: string + exp_cfg?: string + lid?: string +} diff --git a/src/Socket/socket.ts b/src/Socket/socket.ts index b91a3a3e764..9853dd0432b 100644 --- a/src/Socket/socket.ts +++ b/src/Socket/socket.ts @@ -1,12 +1,13 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { Boom } from '@hapi/boom' import { promisify } from 'util' -import WebSocket from 'ws' import { proto } from '../../WAProto' -import { DEF_CALLBACK_PREFIX, DEF_TAG_PREFIX, DEFAULT_ORIGIN, INITIAL_PREKEY_COUNT, MIN_PREKEY_COUNT } from '../Defaults' +import { DEF_CALLBACK_PREFIX, DEF_TAG_PREFIX, INITIAL_PREKEY_COUNT, MIN_PREKEY_COUNT, MOBILE_NOISE_HEADER, NOISE_WA_HEADER } from '../Defaults' import { DisconnectReason, SocketConfig } from '../Types' -import { addTransactionCapability, bindWaitForConnectionUpdate, configureSuccessfulPairing, Curve, generateLoginNode, generateMdTagPrefix, generateRegistrationNode, getCodeFromWSError, getErrorCodeFromStreamError, getNextPreKeysNode, makeNoiseHandler, printQRIfNecessaryListener, promiseTimeout } from '../Utils' +import { addTransactionCapability, bindWaitForConnectionUpdate, configureSuccessfulPairing, Curve, generateLoginNode, generateMdTagPrefix, generateMobileNode, generateRegistrationNode, getCodeFromWSError, getErrorCodeFromStreamError, getNextPreKeysNode, makeNoiseHandler, printQRIfNecessaryListener, promiseTimeout } from '../Utils' import { makeEventBuffer } from '../Utils/event-buffer' -import { assertNodeErrorFree, BinaryNode, encodeBinaryNode, getBinaryNodeChild, getBinaryNodeChildren, S_WHATSAPP_NET } from '../WABinary' +import { assertNodeErrorFree, BinaryNode, binaryNodeToString, encodeBinaryNode, getBinaryNodeChild, getBinaryNodeChildren, S_WHATSAPP_NET } from '../WABinary' +import { MobileSocket } from './mobile-socket' /** * Connects to WA servers and performs: @@ -14,37 +15,40 @@ import { assertNodeErrorFree, BinaryNode, encodeBinaryNode, getBinaryNodeChild, * - listen to messages and emit events * - query phone connection */ -export const makeSocket = ({ - waWebSocketUrl, - connectTimeoutMs, - logger, - agent, - keepAliveIntervalMs, - version, - browser, - auth: authState, - printQRInTerminal, - defaultQueryTimeoutMs, - syncFullHistory, - transactionOpts, - qrTimeout, - options, - makeSignalRepository -}: SocketConfig) => { - const ws = new WebSocket(waWebSocketUrl, undefined, { - origin: DEFAULT_ORIGIN, - headers: options.headers as {}, - handshakeTimeout: connectTimeoutMs, - timeout: connectTimeoutMs, - agent - }) - ws.setMaxListeners(0) + +export const makeSocket = (config: SocketConfig) => { + const { + connectTimeoutMs, + logger, + keepAliveIntervalMs, + browser, + auth: authState, + printQRInTerminal, + defaultQueryTimeoutMs, + transactionOpts, + qrTimeout, + makeSignalRepository, + } = config + + config.mobile = config.mobile || config.auth.creds.registered + const ws = new MobileSocket(config) + ws.setMaxListeners?.(0) + + // if not mobile or already registered -> auto connect + if(!config.mobile || config.auth.creds.registered) { + ws.connect() + } const ev = makeEventBuffer(logger) /** ephemeral key pair used to encrypt/decrypt communication. Unique for each connection */ const ephemeralKeyPair = Curve.generateKeyPair() /** WA noise protocol wrapper */ - const noise = makeNoiseHandler(ephemeralKeyPair, logger) + const noise = makeNoiseHandler({ + keyPair: ephemeralKeyPair, + NOISE_HEADER: config.mobile ? MOBILE_NOISE_HEADER : NOISE_WA_HEADER, + mobile: config.mobile, + logger + }) const { creds } = authState // add transaction capability @@ -63,7 +67,7 @@ export const makeSocket = ({ const sendPromise = promisify(ws.send) /** send a raw buffer */ const sendRawMessage = async(data: Uint8Array | Buffer) => { - if(ws.readyState !== ws.OPEN) { + if(!ws.isOpen) { throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }) } @@ -84,7 +88,7 @@ export const makeSocket = ({ /** send a binary node */ const sendNode = (frame: BinaryNode) => { if(logger.level === 'trace') { - logger.trace({ msgId: frame.attrs.id, fromMe: true, frame }, 'communication') + logger.trace(binaryNodeToString(frame), 'xml send') } const buff = encodeBinaryNode(frame) @@ -101,7 +105,7 @@ export const makeSocket = ({ /** await the next incoming message */ const awaitNextMessage = async(sendMsg?: Uint8Array) => { - if(ws.readyState !== ws.OPEN) { + if(!ws.isOpen) { throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }) @@ -186,21 +190,21 @@ export const makeSocket = ({ } helloMsg = proto.HandshakeMessage.fromObject(helloMsg) - logger.info({ browser, helloMsg }, 'connected to WA Web') + logger.info({ browser, helloMsg }, 'connected to WA') const init = proto.HandshakeMessage.encode(helloMsg).finish() const result = await awaitNextMessage(init) const handshake = proto.HandshakeMessage.decode(result) - logger.trace({ handshake }, 'handshake recv from WA Web') + logger.trace({ handshake }, 'handshake recv from WA') const keyEnc = noise.processHandshake(handshake, creds.noiseKey) - const config = { version, browser, syncFullHistory } - let node: proto.IClientPayload - if(!creds.me) { + if(config.mobile) { + node = generateMobileNode(config) + } else if(!creds.me) { node = generateRegistrationNode(creds, config) logger.info({ node }, 'not logged in, attempting registration...') } else { @@ -276,7 +280,7 @@ export const makeSocket = ({ const msgId = frame.attrs.id if(logger.level === 'trace') { - logger.trace({ msgId, fromMe: false, frame }, 'communication') + logger.trace(binaryNodeToString(frame), 'recv xml') } /* Check if this is a response to a message we sent */ @@ -321,7 +325,7 @@ export const makeSocket = ({ ws.removeAllListeners('open') ws.removeAllListeners('message') - if(ws.readyState !== ws.CLOSED && ws.readyState !== ws.CLOSING) { + if(!ws.isClosed && !ws.isClosing) { try { ws.close() } catch{ } @@ -338,11 +342,11 @@ export const makeSocket = ({ } const waitForSocketOpen = async() => { - if(ws.readyState === ws.OPEN) { + if(ws.isOpen) { return } - if(ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) { + if(ws.isClosed || ws.isClosing) { throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }) } @@ -375,7 +379,7 @@ export const makeSocket = ({ */ if(diff > keepAliveIntervalMs + 5000) { end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost })) - } else if(ws.readyState === ws.OPEN) { + } else if(ws.isOpen) { // if its all good, send a keep alive request query( { @@ -472,7 +476,7 @@ export const makeSocket = ({ let qrMs = qrTimeout || 60_000 // time to let a QR live const genPairQR = () => { - if(ws.readyState !== ws.OPEN) { + if(!ws.isOpen) { return } @@ -623,7 +627,7 @@ function mapWebSocketError(handler: (err: Error) => void) { return (error: Error) => { handler( new Boom( - `WebSocket Error (${error.message})`, + `WebSocket Error (${error?.message})`, { statusCode: getCodeFromWSError(error), data: error } ) ) diff --git a/src/Types/Auth.ts b/src/Types/Auth.ts index 6c063331df2..f49b6488b38 100644 --- a/src/Types/Auth.ts +++ b/src/Types/Auth.ts @@ -1,4 +1,5 @@ import type { proto } from '../../WAProto' +import { RegistrationOptions } from '../Socket/registration' import type { Contact } from './Contact' import type { MinimalMessage } from './Message' @@ -58,6 +59,13 @@ export type AuthenticationCreds = SignalCreds & { /** number of times history & app state has been synced */ accountSyncCounter: number accountSettings: AccountSettings + // mobile creds + deviceId: string + phoneId: string + identityId: Buffer + registered: boolean + backupToken: Buffer + registration: RegistrationOptions } export type SignalDataTypeMap = { diff --git a/src/Types/Socket.ts b/src/Types/Socket.ts index 42edaa7b063..51dd6f72f65 100644 --- a/src/Types/Socket.ts +++ b/src/Types/Socket.ts @@ -31,6 +31,8 @@ export type SocketConfig = { defaultQueryTimeoutMs: number | undefined /** ping-pong interval for WS connection */ keepAliveIntervalMs: number + /** should baileys use the mobile api instead of the multi device api */ + mobile?: boolean /** proxy agent */ agent?: Agent /** pino logger */ diff --git a/src/Utils/auth-utils.ts b/src/Utils/auth-utils.ts index 4f41e1273ae..1f79da53b83 100644 --- a/src/Utils/auth-utils.ts +++ b/src/Utils/auth-utils.ts @@ -1,6 +1,7 @@ import { randomBytes } from 'crypto' import NodeCache from 'node-cache' import type { Logger } from 'pino' +import { v4 as uuidv4 } from 'uuid' import { DEFAULT_CACHE_TTLS } from '../Defaults' import type { AuthenticationCreds, CacheStore, SignalDataSet, SignalDataTypeMap, SignalKeyStore, SignalKeyStoreWithTransaction, TransactionCapabilityOptions } from '../Types' import { Curve, signedKeyPair } from './crypto' @@ -205,6 +206,13 @@ export const initAuthCreds = (): AuthenticationCreds => { accountSyncCounter: 0, accountSettings: { unarchiveChats: false - } + }, + // mobile creds + deviceId: Buffer.from(uuidv4().replace(/-/g, ''), 'hex').toString('base64url'), + phoneId: uuidv4(), + identityId: randomBytes(20), + registered: false, + backupToken: randomBytes(20), + registration: {} as never } } \ No newline at end of file diff --git a/src/Utils/crypto.ts b/src/Utils/crypto.ts index 7855eb595b1..46c020faf9d 100644 --- a/src/Utils/crypto.ts +++ b/src/Utils/crypto.ts @@ -107,6 +107,10 @@ export function sha256(buffer: Buffer) { return createHash('sha256').update(buffer).digest() } +export function md5(buffer: Buffer) { + return createHash('md5').update(buffer).digest() +} + // HKDF key expansion export function hkdf(buffer: Uint8Array | Buffer, expandedLength: number, info: { salt?: Buffer, info?: string }) { return HKDF(!Buffer.isBuffer(buffer) ? Buffer.from(buffer) : buffer, expandedLength, info) diff --git a/src/Utils/generics.ts b/src/Utils/generics.ts index e7aa6544583..74383ca8f9b 100644 --- a/src/Utils/generics.ts +++ b/src/Utils/generics.ts @@ -355,13 +355,13 @@ const UNEXPECTED_SERVER_CODE_TEXT = 'Unexpected server response: ' export const getCodeFromWSError = (error: Error) => { let statusCode = 500 - if(error.message.includes(UNEXPECTED_SERVER_CODE_TEXT)) { - const code = +error.message.slice(UNEXPECTED_SERVER_CODE_TEXT.length) + if(error?.message?.includes(UNEXPECTED_SERVER_CODE_TEXT)) { + const code = +error?.message.slice(UNEXPECTED_SERVER_CODE_TEXT.length) if(!Number.isNaN(code) && code >= 400) { statusCode = code } } else if( - (error as any).code?.startsWith('E') + (error as any)?.code?.startsWith('E') || error?.message?.includes('timed out') ) { // handle ETIMEOUT, ENOTFOUND etc statusCode = 408 diff --git a/src/Utils/noise-handler.ts b/src/Utils/noise-handler.ts index bd61ddfc521..a6928560084 100644 --- a/src/Utils/noise-handler.ts +++ b/src/Utils/noise-handler.ts @@ -1,7 +1,7 @@ import { Boom } from '@hapi/boom' import { Logger } from 'pino' import { proto } from '../../WAProto' -import { NOISE_MODE, NOISE_WA_HEADER, WA_CERT_DETAILS } from '../Defaults' +import { NOISE_MODE, WA_CERT_DETAILS } from '../Defaults' import { KeyPair } from '../Types' import { BinaryNode, decodeBinaryNode } from '../WABinary' import { aesDecryptGCM, aesEncryptGCM, Curve, hkdf, sha256 } from './crypto' @@ -13,10 +13,17 @@ const generateIV = (counter: number) => { return new Uint8Array(iv) } -export const makeNoiseHandler = ( - { public: publicKey, private: privateKey }: KeyPair, +export const makeNoiseHandler = ({ + keyPair: { private: privateKey, public: publicKey }, + NOISE_HEADER, + mobile, + logger, +}: { + keyPair: KeyPair + NOISE_HEADER: Uint8Array + mobile: boolean logger: Logger -) => { +}) => { logger = logger.child({ class: 'ns' }) const authenticate = (data: Uint8Array) => { @@ -86,7 +93,7 @@ export const makeNoiseHandler = ( let inBytes = Buffer.alloc(0) - authenticate(NOISE_WA_HEADER) + authenticate(NOISE_HEADER) authenticate(publicKey) return { @@ -103,12 +110,17 @@ export const makeNoiseHandler = ( mixIntoKey(Curve.sharedKey(privateKey, decStaticContent)) const certDecoded = decrypt(serverHello!.payload!) - const { intermediate: certIntermediate } = proto.CertChain.decode(certDecoded) - const { issuerSerial } = proto.CertChain.NoiseCertificate.Details.decode(certIntermediate!.details!) + if(mobile) { + proto.CertChain.NoiseCertificate.decode(certDecoded) + } else { + const { intermediate: certIntermediate } = proto.CertChain.decode(certDecoded) - if(issuerSerial !== WA_CERT_DETAILS.SERIAL) { - throw new Boom('certification match failed', { statusCode: 400 }) + const { issuerSerial } = proto.CertChain.NoiseCertificate.Details.decode(certIntermediate!.details!) + + if(issuerSerial !== WA_CERT_DETAILS.SERIAL) { + throw new Boom('certification match failed', { statusCode: 400 }) + } } const keyEnc = encrypt(noiseKey.public) @@ -121,11 +133,11 @@ export const makeNoiseHandler = ( data = encrypt(data) } - const introSize = sentIntro ? 0 : NOISE_WA_HEADER.length + const introSize = sentIntro ? 0 : NOISE_HEADER.length const frame = Buffer.alloc(introSize + 3 + data.byteLength) if(!sentIntro) { - frame.set(NOISE_WA_HEADER) + frame.set(NOISE_HEADER) sentIntro = true } diff --git a/src/Utils/validate-connection.ts b/src/Utils/validate-connection.ts index 444634d853f..6b9e92b0b39 100644 --- a/src/Utils/validate-connection.ts +++ b/src/Utils/validate-connection.ts @@ -8,53 +8,67 @@ import { Curve, hmacSign } from './crypto' import { encodeBigEndian } from './generics' import { createSignalIdentity } from './signal' -type ClientPayloadConfig = Pick +const getUserAgent = (config: SocketConfig): proto.ClientPayload.IUserAgent => { + const osVersion = config.mobile ? '15.3.1' : '0.1' + const version = config.mobile ? [2, 22, 24] : config.version + const device = config.mobile ? 'iPhone_7' : 'Desktop' + const manufacturer = config.mobile ? 'Apple' : '' + const platform = config.mobile ? proto.ClientPayload.UserAgent.Platform.IOS : proto.ClientPayload.UserAgent.Platform.MACOS + const phoneId = config.mobile ? { phoneId: config.auth.creds.phoneId } : {} -const getUserAgent = ({ version }: ClientPayloadConfig): proto.ClientPayload.IUserAgent => { - const osVersion = '0.1' return { appVersion: { primary: version[0], secondary: version[1], tertiary: version[2], }, - platform: proto.ClientPayload.UserAgent.Platform.WEB, + platform, releaseChannel: proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE, - mcc: '000', - mnc: '000', + mcc: config.auth.creds.registration?.phoneNumberMobileCountryCode || '000', + mnc: config.auth.creds.registration?.phoneNumberMobileNetworkCode || '000', osVersion: osVersion, - manufacturer: '', - device: 'Desktop', + manufacturer, + device, osBuildNumber: osVersion, localeLanguageIso6391: 'en', localeCountryIso31661Alpha2: 'US', + ...phoneId } } -const PLATFORM_MAP = { - 'Mac OS': proto.ClientPayload.WebInfo.WebSubPlatform.DARWIN, - 'Windows': proto.ClientPayload.WebInfo.WebSubPlatform.WIN32 -} - -const getWebInfo = (config: ClientPayloadConfig): proto.ClientPayload.IWebInfo => { - let webSubPlatform = proto.ClientPayload.WebInfo.WebSubPlatform.WEB_BROWSER - if(config.syncFullHistory && PLATFORM_MAP[config.browser[0]]) { - webSubPlatform = PLATFORM_MAP[config.browser[0]] +const getClientPayload = (config: SocketConfig) => { + const payload: proto.IClientPayload = { + connectType: proto.ClientPayload.ConnectType.WIFI_UNKNOWN, + connectReason: proto.ClientPayload.ConnectReason.USER_ACTIVATED, + userAgent: getUserAgent(config), } - return { webSubPlatform } + return payload } -const getClientPayload = (config: ClientPayloadConfig): proto.IClientPayload => { - return { - connectType: proto.ClientPayload.ConnectType.WIFI_UNKNOWN, - connectReason: proto.ClientPayload.ConnectReason.USER_ACTIVATED, - userAgent: getUserAgent(config), - webInfo: getWebInfo(config), +export const generateMobileNode = (config: SocketConfig): proto.IClientPayload => { + if(!config.auth.creds) { + throw new Boom('No registration data found', { data: config }) + } + + const payload: proto.IClientPayload = { + ...getClientPayload(config), + sessionId: Math.floor(Math.random() * 999999999 + 1), + shortConnect: true, + connectAttemptCount: 0, + device: 0, + dnsSource: { + appCached: false, + dnsMethod: proto.ClientPayload.DNSSource.DNSResolutionMethod.SYSTEM, + }, + passive: false, // XMPP heartbeat setting (false: server actively pings) (true: client actively pings) + pushName: 'test', + username: Number(`${config.auth.creds.registration.phoneNumberCountryCode}${config.auth.creds.registration.phoneNumberNationalNumber}`), } + return proto.ClientPayload.fromObject(payload) } -export const generateLoginNode = (userJid: string, config: ClientPayloadConfig): proto.IClientPayload => { +export const generateLoginNode = (userJid: string, config: SocketConfig): proto.IClientPayload => { const { user, device } = jidDecode(userJid)! const payload: proto.IClientPayload = { ...getClientPayload(config), @@ -67,24 +81,17 @@ export const generateLoginNode = (userJid: string, config: ClientPayloadConfig): export const generateRegistrationNode = ( { registrationId, signedPreKey, signedIdentityKey }: SignalCreds, - config: ClientPayloadConfig + config: SocketConfig ) => { // the app version needs to be md5 hashed // and passed in const appVersionBuf = createHash('md5') .update(config.version.join('.')) // join as string .digest() - const browserVersion = config.browser[2].split('.') const companion: proto.IDeviceProps = { os: config.browser[0], - version: { - primary: +(browserVersion[0] || 0), - secondary: +(browserVersion[1] || 1), - tertiary: +(browserVersion[2] || 0), - }, - platformType: proto.DeviceProps.PlatformType[config.browser[1].toUpperCase()] - || proto.DeviceProps.PlatformType.UNKNOWN, + platformType: proto.DeviceProps.PlatformType.DESKTOP, requireFullSync: config.syncFullHistory, } diff --git a/src/WABinary/generic-utils.ts b/src/WABinary/generic-utils.ts index dd386207f1a..48b323c5ba4 100644 --- a/src/WABinary/generic-utils.ts +++ b/src/WABinary/generic-utils.ts @@ -87,4 +87,35 @@ function bufferToUInt(e: Uint8Array | Buffer, t: number) { } return a +} + +const tabs = (n: number) => '\t'.repeat(n) + +export function binaryNodeToString(node: BinaryNode | BinaryNode['content'], i = 0) { + if(!node) { + return node + } + + if(typeof node === 'string') { + return tabs(i) + node + } + + if(node instanceof Uint8Array) { + return tabs(i) + Buffer.from(node).toString('hex') + } + + if(Array.isArray(node)) { + return node.map((x) => tabs(i + 1) + binaryNodeToString(x, i + 1)).join('\n') + } + + const children = binaryNodeToString(node.content, i + 1) + + const tag = `<${node.tag} ${Object.entries(node.attrs || {}) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => `${k}='${v}'`) + .join(' ')}` + + const content: string = children ? `>\n${children}\n${tabs(i)}` : '/>' + + return tag + content } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 0c6b749d6e7..78a95001cf0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1298,6 +1298,11 @@ resolved "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz" integrity sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw== +"@types/uuid@^9.0.0": + version "9.0.1" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.1.tgz#98586dc36aee8dacc98cc396dbca8d0429647aa6" + integrity sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA== + "@types/ws@^8.0.0": version "8.5.3" resolved "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz" @@ -2142,6 +2147,11 @@ color@^4.2.3: color-convert "^2.0.1" color-string "^1.9.0" +colorette@^2.0.7: + version "2.0.20" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" + integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== + combined-stream@^1.0.6, combined-stream@^1.0.8: version "1.0.8" resolved "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz" @@ -3121,6 +3131,11 @@ fast-redact@^3.0.0: resolved "https://registry.npmjs.org/fast-redact/-/fast-redact-3.1.1.tgz" integrity sha512-odVmjC8x8jNeMZ3C+rPMESzXVSEU8tSWSHv9HFxP2mm89G/1WwqhrerJDQm9Zus8X6aoRgQDThKqptdNA6bt+A== +fast-safe-stringify@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" + integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== + fastq@^1.6.0: version "1.15.0" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.15.0.tgz#d04d07c6a2a68fe4599fea8d2e103a937fae6b3a" @@ -4654,6 +4669,11 @@ jimp@^0.16.1: "@jimp/types" "^0.16.1" regenerator-runtime "^0.13.3" +joycon@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/joycon/-/joycon-3.1.1.tgz#bce8596d6ae808f8b68168f5fc69280996894f03" + integrity sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw== + jpeg-js@0.4.2: version "0.4.2" resolved "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.2.tgz" @@ -4822,6 +4842,11 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +libphonenumber-js@^1.10.20: + version "1.10.28" + resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.10.28.tgz#cae7e929cad96cee5ecc9449027192ecba39ee72" + integrity sha512-1eAgjLrZA0+2Wgw4hs+4Q/kEBycxQo8ZLYnmOvZ3AlM8ImAVAJgDPlZtISLEzD1vunc2q8s2Pn7XwB7I8U3Kzw== + "libsignal@https://github.com/adiwajshing/libsignal-node.git": version "2.0.1" resolved "https://github.com/adiwajshing/libsignal-node.git#11dbd962ea108187c79a7c46fe4d6f790e23da97" @@ -5403,6 +5428,11 @@ on-exit-leak-free@^0.2.0: resolved "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-0.2.0.tgz" integrity sha512-dqaz3u44QbRXQooZLTUKU41ZrzYrcvLISVgbrzbyCMxpmSLJvZ3ZamIJIZ29P6OhZIkNIQKosdeM6t1LYbA9hg== +on-exit-leak-free@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/on-exit-leak-free/-/on-exit-leak-free-2.1.0.tgz#5c703c968f7e7f851885f6459bf8a8a57edc9cc4" + integrity sha512-VuCaZZAjReZ3vUwgOB8LxAosIurDiAW0s13rI1YwmaP++jvcxP77AWoQvenZebpCA2m8WC1/EosPYPMjnRAp/w== + once@^1.3.0, once@^1.3.1, once@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz" @@ -5730,6 +5760,26 @@ pino-abstract-transport@v0.5.0: duplexify "^4.1.2" split2 "^4.0.0" +pino-pretty@^9.4.0: + version "9.4.0" + resolved "https://registry.yarnpkg.com/pino-pretty/-/pino-pretty-9.4.0.tgz#fc4026e83c87272cbdfb7afed121770e6000940c" + integrity sha512-NIudkNLxnl7MGj1XkvsqVyRgo6meFP82ECXF2PlOI+9ghmbGuBUUqKJ7IZPIxpJw4vhhSva0IuiDSAuGh6TV9g== + dependencies: + colorette "^2.0.7" + dateformat "^4.6.3" + fast-copy "^3.0.0" + fast-safe-stringify "^2.1.1" + help-me "^4.0.1" + joycon "^3.1.1" + minimist "^1.2.6" + on-exit-leak-free "^2.1.0" + pino-abstract-transport "^1.0.0" + pump "^3.0.0" + readable-stream "^4.0.0" + secure-json-parse "^2.4.0" + sonic-boom "^3.0.0" + strip-json-comments "^3.1.1" + pino-std-serializers@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-4.0.0.tgz" @@ -6526,6 +6576,13 @@ sonic-boom@^2.2.1: dependencies: atomic-sleep "^1.0.0" +sonic-boom@^3.0.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/sonic-boom/-/sonic-boom-3.3.0.tgz#cffab6dafee3b2bcb88d08d589394198bee1838c" + integrity sha512-LYxp34KlZ1a2Jb8ZQgFCK3niIHzibdwtwNUWKg0qQRzsDoJ3Gfgkf8KdBTFU3SkejDEIlWwnSnpVdOZIhFMl/g== + dependencies: + atomic-sleep "^1.0.0" + source-map-support@^0.5.6: version "0.5.21" resolved "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz"