diff --git a/package.json b/package.json index 8dd34ec..00c2226 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "pino": "^7.0.0", "protobufjs": "^6.11.3", "uuid": "^9.0.0", - "ws": "^8.0.0" + "ws": "^8.13.0" }, "devDependencies": { "@adiwajshing/eslint-config": "https://github.com/adiwajshing/eslint-config.git", diff --git a/src/Socket/Client/abstract-socket-client.ts b/src/Socket/Client/abstract-socket-client.ts new file mode 100644 index 0000000..5d78298 --- /dev/null +++ b/src/Socket/Client/abstract-socket-client.ts @@ -0,0 +1,19 @@ +import { EventEmitter } from 'events' +import { URL } from 'url' +import { SocketConfig } from '../../Types' + +export abstract class AbstractSocketClient extends EventEmitter { + abstract get isOpen(): boolean + abstract get isClosed(): boolean + abstract get isClosing(): boolean + abstract get isConnecting(): boolean + + constructor(public url: URL, public config: SocketConfig) { + super() + this.setMaxListeners(0) + } + + abstract connect(): Promise + abstract close(): Promise + abstract send(str: Uint8Array | string, cb?: (err?: Error) => void): boolean; +} \ No newline at end of file diff --git a/src/Socket/Client/index.ts b/src/Socket/Client/index.ts new file mode 100644 index 0000000..8843b0a --- /dev/null +++ b/src/Socket/Client/index.ts @@ -0,0 +1,3 @@ +export * from './abstract-socket-client' +export * from './mobile-socket-client' +export * from './web-socket-client' \ No newline at end of file diff --git a/src/Socket/Client/mobile-socket-client.ts b/src/Socket/Client/mobile-socket-client.ts new file mode 100644 index 0000000..90cc63c --- /dev/null +++ b/src/Socket/Client/mobile-socket-client.ts @@ -0,0 +1,66 @@ +import { connect, Socket } from 'net' +import { AbstractSocketClient } from './abstract-socket-client' + +export class MobileSocketClient extends AbstractSocketClient { + protected socket: Socket | null = null + + get isOpen(): boolean { + return this.socket?.readyState === 'open' + } + get isClosed(): boolean { + return this.socket === null || this.socket?.readyState === 'closed' + } + get isClosing(): boolean { + return this.socket === null || this.socket?.readyState === 'closed' + } + get isConnecting(): boolean { + return this.socket?.readyState === 'opening' + } + + async connect(): Promise { + if(this.socket) { + return + } + + if(this.config.agent) { + + throw new Error('There are not support for proxy agent for mobile connection') + } else { + this.socket = connect({ + host: this.url.hostname, + port: Number(this.url.port) || 443 + }) + } + + this.socket.setMaxListeners(0) + + const events = ['close', 'connect', 'data', 'drain', 'end', 'error', 'lookup', 'ready', 'timeout'] + + for(const event of events) { + this.socket?.on(event, (...args: any[]) => this.emit(event, ...args)) + } + + this.socket.on('data', (...args: any[]) => this.emit('message', ...args)) + this.socket.on('ready', (...args: any[]) => this.emit('open', ...args)) + } + + async close(): Promise { + if(!this.socket) { + return + } + + return new Promise(resolve => { + this.socket!.end(resolve) + this.socket = null + }) + } + + send(str: string | Uint8Array, cb?: (err?: Error) => void): boolean { + if(this.socket === null) { + return false + } + + return this.socket.write(str, undefined, cb) + } + +} diff --git a/src/Socket/Client/web-socket-client.ts b/src/Socket/Client/web-socket-client.ts new file mode 100644 index 0000000..987d0af --- /dev/null +++ b/src/Socket/Client/web-socket-client.ts @@ -0,0 +1,57 @@ +import WebSocket from 'ws' +import { DEFAULT_ORIGIN } from '../../Defaults' +import { AbstractSocketClient } from './abstract-socket-client' + +export class WebSocketClient extends AbstractSocketClient { + + protected socket: WebSocket | null = null + + get isOpen(): boolean { + return this.socket?.readyState === WebSocket.OPEN + } + get isClosed(): boolean { + return this.socket === null || this.socket?.readyState === WebSocket.CLOSED + } + get isClosing(): boolean { + return this.socket === null || this.socket?.readyState === WebSocket.CLOSING + } + get isConnecting(): boolean { + return this.socket?.readyState === WebSocket.CONNECTING + } + + async connect(): Promise { + if(this.socket) { + return + } + + this.socket = new WebSocket(this.url, { + origin: DEFAULT_ORIGIN, + headers: this.config.options?.headers as {}, + handshakeTimeout: this.config.connectTimeoutMs, + timeout: this.config.connectTimeoutMs, + agent: this.config.agent, + }) + + this.socket.setMaxListeners(0) + + const events = ['close', 'error', 'upgrade', 'message', 'open', 'ping', 'pong', 'unexpected-response'] + + for(const event of events) { + this.socket?.on(event, (...args: any[]) => this.emit(event, ...args)) + } + } + + async close(): Promise { + if(!this.socket) { + return + } + + this.socket.close() + this.socket = null + } + send(str: string | Uint8Array, cb?: (err?: Error) => void): boolean { + this.socket?.send(str, cb) + + return Boolean(this.socket) + } +} diff --git a/src/Socket/mobile-socket.ts b/src/Socket/mobile-socket.ts deleted file mode 100644 index f61c518..0000000 --- a/src/Socket/mobile-socket.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 index 66f90aa..3909152 100644 --- a/src/Socket/registration.ts +++ b/src/Socket/registration.ts @@ -5,7 +5,6 @@ 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') @@ -33,10 +32,6 @@ export const makeRegistrationSocket = (config: SocketConfig) => { sock.authState.creds.registered = true sock.ev.emit('creds.update', sock.authState.creds) - if(sock.ws instanceof MobileSocket) { - sock.ws.connect() - } - return result } diff --git a/src/Socket/socket.ts b/src/Socket/socket.ts index 9853dd0..259c7cc 100644 --- a/src/Socket/socket.ts +++ b/src/Socket/socket.ts @@ -1,13 +1,14 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { Boom } from '@hapi/boom' +import { URL } from 'url' import { promisify } from 'util' import { proto } from '../../WAProto' -import { DEF_CALLBACK_PREFIX, DEF_TAG_PREFIX, INITIAL_PREKEY_COUNT, MIN_PREKEY_COUNT, MOBILE_NOISE_HEADER, NOISE_WA_HEADER } from '../Defaults' +import { DEF_CALLBACK_PREFIX, DEF_TAG_PREFIX, INITIAL_PREKEY_COUNT, MIN_PREKEY_COUNT, MOBILE_ENDPOINT, MOBILE_NOISE_HEADER, MOBILE_PORT, NOISE_WA_HEADER } from '../Defaults' import { DisconnectReason, SocketConfig } from '../Types' 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, binaryNodeToString, encodeBinaryNode, getBinaryNodeChild, getBinaryNodeChildren, S_WHATSAPP_NET } from '../WABinary' -import { MobileSocket } from './mobile-socket' +import { MobileSocketClient, WebSocketClient } from './Client' /** * Connects to WA servers and performs: @@ -18,6 +19,7 @@ import { MobileSocket } from './mobile-socket' export const makeSocket = (config: SocketConfig) => { const { + waWebSocketUrl, connectTimeoutMs, logger, keepAliveIntervalMs, @@ -30,15 +32,18 @@ export const makeSocket = (config: SocketConfig) => { makeSignalRepository, } = config - config.mobile = config.mobile || config.auth.creds.registered - const ws = new MobileSocket(config) - ws.setMaxListeners?.(0) + let url = typeof waWebSocketUrl === 'string' ? new URL(waWebSocketUrl) : waWebSocketUrl - // if not mobile or already registered -> auto connect - if(!config.mobile || config.auth.creds.registered) { - ws.connect() + config.mobile = config.mobile || config.auth?.creds?.registered || url.protocol === 'tcp:' + + if(config.mobile && url.protocol !== 'tcp:') { + url = new URL(`tcp://${MOBILE_ENDPOINT}:${MOBILE_PORT}`) } + const ws = config.mobile ? new MobileSocketClient(url, config) : new WebSocketClient(url, config) + + ws.connect() + const ev = makeEventBuffer(logger) /** ephemeral key pair used to encrypt/decrypt communication. Unique for each connection */ const ephemeralKeyPair = Curve.generateKeyPair() @@ -64,7 +69,7 @@ export const makeSocket = (config: SocketConfig) => { const uqTagId = generateMdTagPrefix() const generateMessageTag = () => `${uqTagId}${epoch++}` - const sendPromise = promisify(ws.send) + const sendPromise = promisify(ws.send) /** send a raw buffer */ const sendRawMessage = async(data: Uint8Array | Buffer) => { if(!ws.isOpen) { @@ -135,12 +140,12 @@ export const makeSocket = (config: SocketConfig) => { } /** - * Wait for a message with a certain tag to be received - * @param tag the message tag to await - * @param json query that was sent - * @param timeoutMs timeout after which the promise will reject - */ - const waitForMessage = async(msgId: string, timeoutMs = defaultQueryTimeoutMs) => { + * Wait for a message with a certain tag to be received + * @param tag the message tag to await + * @param json query that was sent + * @param timeoutMs timeout after which the promise will reject + */ + const waitForMessage = async(msgId: string, timeoutMs = defaultQueryTimeoutMs) => { let onRecv: (json) => void let onErr: (err) => void try { @@ -237,7 +242,7 @@ export const makeSocket = (config: SocketConfig) => { to: S_WHATSAPP_NET }, content: [ - { tag: 'count', attrs: { } } + { tag: 'count', attrs: {} } ] }) const countChild = getBinaryNodeChild(result, 'count') @@ -287,7 +292,7 @@ export const makeSocket = (config: SocketConfig) => { anyTriggered = ws.emit(`${DEF_TAG_PREFIX}${msgId}`, frame) || anyTriggered /* Check if this is a response to a message we are expecting */ const l0 = frame.tag - const l1 = frame.attrs || { } + const l1 = frame.attrs || {} const l2 = Array.isArray(frame.content) ? frame.content[0]?.tag : '' Object.keys(l1).forEach(key => { @@ -374,9 +379,9 @@ export const makeSocket = (config: SocketConfig) => { const diff = Date.now() - lastDateRecv.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 - */ + 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 > keepAliveIntervalMs + 5000) { end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost })) } else if(ws.isOpen) { @@ -390,7 +395,7 @@ export const makeSocket = (config: SocketConfig) => { type: 'get', xmlns: 'w:p', }, - content: [{ tag: 'ping', attrs: { } }] + content: [{ tag: 'ping', attrs: {} }] } ) .catch(err => { @@ -411,7 +416,7 @@ export const makeSocket = (config: SocketConfig) => { type: 'set', }, content: [ - { tag, attrs: { } } + { tag, attrs: {} } ] }) ) diff --git a/yarn.lock b/yarn.lock index fe4498b..3e0519e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7501,10 +7501,10 @@ ws@^7.4.6: resolved "https://registry.npmjs.org/ws/-/ws-7.5.8.tgz" integrity sha512-ri1Id1WinAX5Jqn9HejiGb8crfRio0Qgu8+MtL36rlTA6RLsMdWt1Az/19A2Qij6uSHUMphEFaTKa4WG+UNHNw== -ws@^8.0.0: - version "8.7.0" - resolved "https://registry.npmjs.org/ws/-/ws-8.7.0.tgz" - integrity sha512-c2gsP0PRwcLFzUiA8Mkr37/MI7ilIlHQxaEAtd0uNMbVMoy8puJyafRlm0bV9MbGSabUPeLrRRaqIBcFcA2Pqg== +ws@^8.13.0: + version "8.13.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0" + integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA== xdg-basedir@^5.0.1, xdg-basedir@^5.1.0: version "5.1.0"