kersiosocket 0.0.0-stage → 0.6.0-dev

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/BUILD-INFO.md +15 -0
  2. package/CHANGELOG.md +30 -0
  3. package/LICENSE +21 -0
  4. package/README.md +191 -2
  5. package/bench/wabinary.js +31 -0
  6. package/bridges/whatsmeow/README.md +42 -0
  7. package/docs/ENGINE-MATRIX.md +23 -0
  8. package/docs/PROTOCOL-ROADMAP.md +74 -0
  9. package/docs/RESEARCH-BASELINE.md +36 -0
  10. package/docs/WABINARY.md +37 -0
  11. package/examples/basic.js +10 -0
  12. package/examples/client-payload.js +21 -0
  13. package/examples/device-props.js +18 -0
  14. package/examples/sessions.js +11 -0
  15. package/examples/wabinary.js +15 -0
  16. package/licenses/THIRD-PARTY-NOTICES.md +12 -0
  17. package/package.json +31 -4
  18. package/scripts/import-token-table.js +95 -0
  19. package/src/backends/baileys.js +121 -0
  20. package/src/backends/base.js +102 -0
  21. package/src/backends/whatsmeow-process.js +202 -0
  22. package/src/backends/zapo.js +123 -0
  23. package/src/client/kersio-client.js +305 -0
  24. package/src/client/session-manager.js +67 -0
  25. package/src/index.js +36 -0
  26. package/src/pairing/adapter.js +115 -0
  27. package/src/pairing/lifecycle.js +120 -0
  28. package/src/pairing/qr.js +38 -0
  29. package/src/pairing/verified-commit.js +46 -0
  30. package/src/proto/client-payload.js +185 -0
  31. package/src/proto/device-props.js +143 -0
  32. package/src/proto/handshake.js +79 -0
  33. package/src/proto/wire.js +137 -0
  34. package/src/protocol/node-protocol.js +155 -0
  35. package/src/session/codec.js +25 -0
  36. package/src/session/encrypted-store.js +107 -0
  37. package/src/session/locking.js +233 -0
  38. package/src/session/manager.js +61 -0
  39. package/src/session/stores.js +154 -0
  40. package/src/socket/connection.js +278 -0
  41. package/src/socket/frame.js +77 -0
  42. package/src/socket/make.js +70 -0
  43. package/src/socket/noise.js +146 -0
  44. package/src/socket/transport.js +39 -0
  45. package/src/utils/crypto.js +52 -0
  46. package/src/utils/latency.js +32 -0
  47. package/src/utils/lru.js +76 -0
  48. package/src/wabinary/codec.js +374 -0
  49. package/src/wabinary/tags.js +25 -0
  50. package/src/wabinary/token-source.js +146 -0
  51. package/src/wabinary/tokens.json +5 -0
  52. package/test/backend-adapters.test.js +59 -0
  53. package/test/connection.test.js +173 -0
  54. package/test/device-props.test.js +67 -0
  55. package/test/encrypted-store.test.js +54 -0
  56. package/test/frame.test.js +37 -0
  57. package/test/helpers.js +43 -0
  58. package/test/locking-archive.test.js +49 -0
  59. package/test/node-protocol.test.js +90 -0
  60. package/test/noise.test.js +88 -0
  61. package/test/pairing-adapter.test.js +71 -0
  62. package/test/pairing.test.js +32 -0
  63. package/test/proto.test.js +64 -0
  64. package/test/session-manager.test.js +34 -0
  65. package/test/session.test.js +134 -0
  66. package/test/token-source.test.js +35 -0
  67. package/test/unified-client.test.js +63 -0
  68. package/test/verified-commit.test.js +43 -0
  69. package/test/wabinary.test.js +147 -0
  70. package/test/whatsmeow-process.test.js +43 -0
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "kersiosocket",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.6.0-dev",
4
+ "description": "Unified multi-engine WhatsApp Web client facade with Zapo-JS, Baileys, Go whatsmeow JSONL bridge, hardened native research transport, session archive and lock manager.",
5
+ "main": "src/index.js",
6
+ "engines": {
7
+ "node": ">=22.5"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test \"test/*.test.js\"",
11
+ "test:serial": "node --test --test-concurrency=1 \"test/*.test.js\"",
12
+ "refresh:tokens": "node scripts/import-token-table.js",
13
+ "bench:wabinary": "node bench/wabinary.js",
14
+ "test:backends": "node --test test/unified-client.test.js test/backend-adapters.test.js test/whatsmeow-process.test.js"
15
+ },
16
+ "peerDependencies": {
17
+ "ws": ">=8",
18
+ "zapo-js": ">=1.0.0",
19
+ "@whiskeysockets/baileys": ">=7.0.0"
20
+ },
21
+ "peerDependenciesMeta": {
22
+ "ws": {
23
+ "optional": true
24
+ },
25
+ "zapo-js": {
26
+ "optional": true
27
+ },
28
+ "@whiskeysockets/baileys": {
29
+ "optional": true
30
+ }
31
+ },
32
+ "license": "MIT"
33
+ }
@@ -0,0 +1,95 @@
1
+ 'use strict'
2
+
3
+ const crypto = require('node:crypto')
4
+ const fs = require('node:fs/promises')
5
+ const path = require('node:path')
6
+ const { parseGoTokenTable, validateTokenTable } = require('../src/wabinary/token-source')
7
+
8
+ // Deliberately pinned to an immutable upstream commit, never to a moving branch.
9
+ const SOURCE = Object.freeze({
10
+ repository: 'tulir/whatsmeow',
11
+ commit: 'b2b1e71d45e2ff171153b025b5a42f475bb30ef8',
12
+ file: 'binary/token/token.go',
13
+ blobSha1: '361acd19d93c8dea30f516e27ce8c77fdaecc56f',
14
+ dictVersion: 3,
15
+ license: 'MPL-2.0'
16
+ })
17
+ const RAW_BASE = `https://raw.githubusercontent.com/${SOURCE.repository}/${SOURCE.commit}`
18
+
19
+ async function fetchText(url) {
20
+ const response = await fetch(url, { signal: AbortSignal.timeout(20000) })
21
+ if (!response.ok) throw new Error(`download gagal (${response.status}) untuk ${url}`)
22
+ return response.text()
23
+ }
24
+
25
+ function gitBlobSha1(text) {
26
+ const bytes = Buffer.from(text, 'utf8')
27
+ return crypto.createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex')
28
+ }
29
+
30
+ async function atomicWrite(filePath, contents) {
31
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
32
+ const temp = `${filePath}.${process.pid}.tmp`
33
+ try {
34
+ await fs.writeFile(temp, contents, { encoding: 'utf8', flag: 'wx' })
35
+ await fs.rename(temp, filePath)
36
+ } catch (error) {
37
+ await fs.rm(temp, { force: true }).catch(() => {})
38
+ throw error
39
+ }
40
+ }
41
+
42
+ async function main() {
43
+ const root = path.resolve(__dirname, '..')
44
+ const sourceUrl = `${RAW_BASE}/${SOURCE.file}`
45
+ const sourceText = await fetchText(sourceUrl)
46
+ const observedSha = gitBlobSha1(sourceText)
47
+ if (observedSha !== SOURCE.blobSha1) {
48
+ throw new Error(`source hash mismatch; expected ${SOURCE.blobSha1}, received ${observedSha}. Refusing to import.`)
49
+ }
50
+
51
+ const parsed = parseGoTokenTable(sourceText)
52
+ validateTokenTable(parsed, { dictVersion: SOURCE.dictVersion })
53
+
54
+ const licenseUrl = `${RAW_BASE}/LICENSE`
55
+ const licenseText = await fetchText(licenseUrl)
56
+ if (!licenseText.startsWith('Mozilla Public License Version 2.0')) {
57
+ throw new Error('lisensi sumber tidak cocok dengan MPL-2.0; impor dibatalkan')
58
+ }
59
+
60
+ const licensePath = path.join(root, 'licenses', 'whatsmeow-MPL-2.0.txt')
61
+ await atomicWrite(licensePath, licenseText)
62
+
63
+ const tokenJson = {
64
+ _metadata: {
65
+ kind: 'third-party protocol token data',
66
+ upstream: SOURCE.repository,
67
+ upstreamFile: SOURCE.file,
68
+ upstreamCommit: SOURCE.commit,
69
+ upstreamBlobSha1: SOURCE.blobSha1,
70
+ dictVersion: parsed.dictVersion,
71
+ license: SOURCE.license,
72
+ licenseFile: 'licenses/whatsmeow-MPL-2.0.txt',
73
+ importedAt: new Date().toISOString(),
74
+ verification: 'Pinned source and Git blob hash checked; not independently confirmed against a live WhatsApp server build.',
75
+ implementationNote: 'KersioSocket WABinary encoder/decoder implementation remains separate; this file contains sourced token data.'
76
+ },
77
+ single: parsed.single,
78
+ double: parsed.double
79
+ }
80
+ const tablePath = path.join(root, 'src', 'wabinary', 'tokens.json')
81
+ await atomicWrite(tablePath, `${JSON.stringify(tokenJson, null, 2)}\n`)
82
+
83
+ console.log('Token table imported and validated.')
84
+ console.log(`Source: ${SOURCE.repository}/${SOURCE.file}@${SOURCE.commit}`)
85
+ console.log(`DictVersion: ${parsed.dictVersion}`)
86
+ console.log(`Single entries: ${parsed.single.length}`)
87
+ console.log(`Double pages: ${parsed.double.length} x ${parsed.double[0].length}`)
88
+ console.log(`Source Git blob SHA-1: ${observedSha}`)
89
+ console.log('Notice: this is a version-pinned public reverse-engineering dataset, not an official WhatsApp-issued compatibility guarantee.')
90
+ }
91
+
92
+ main().catch((error) => {
93
+ console.error(`Import token table gagal: ${error.message}`)
94
+ process.exitCode = 1
95
+ })
@@ -0,0 +1,121 @@
1
+ 'use strict'
2
+
3
+ const { BaseBackend } = require('./base')
4
+
5
+ const BAILEYS_EVENTS = Object.freeze([
6
+ 'connection.update', 'creds.update', 'messages.upsert', 'messages.update',
7
+ 'messages.delete', 'message-receipt.update', 'contacts.upsert', 'contacts.update',
8
+ 'chats.upsert', 'chats.update', 'groups.upsert', 'groups.update',
9
+ 'group-participants.update', 'messaging-history.set', 'presence.update', 'blocklist.set'
10
+ ])
11
+
12
+ /** Adapter ke socket @whiskeysockets/baileys; auth state tetap dimiliki Baileys. */
13
+ class BaileysBackend extends BaseBackend {
14
+ constructor(socket, { baileysModule = null } = {}) {
15
+ super('baileys')
16
+ if (!socket || !socket.ev || typeof socket.ev.on !== 'function' || typeof socket.sendMessage !== 'function') {
17
+ throw new TypeError('BaileysBackend memerlukan socket Baileys yang menyediakan ev.on() dan sendMessage()')
18
+ }
19
+ this.socket = socket
20
+ this.module = baileysModule
21
+ for (const name of BAILEYS_EVENTS) {
22
+ this.subscribe(socket.ev, name, (payload) => this._forward(name, payload))
23
+ }
24
+ }
25
+
26
+ static async create({ socket, socketOptions = {}, baileysModule } = {}) {
27
+ let mod = baileysModule
28
+ if (!socket) {
29
+ if (!mod) {
30
+ try { mod = await import('@whiskeysockets/baileys') } catch (error) {
31
+ throw dependencyError('@whiskeysockets/baileys', 'BaileysBackend', error)
32
+ }
33
+ }
34
+ const factory = mod.default || mod.makeWASocket || mod.makeSocket
35
+ if (typeof factory !== 'function') throw new TypeError('Modul Baileys tidak mengekspor factory socket yang dikenal')
36
+ socket = factory(socketOptions)
37
+ }
38
+ return new BaileysBackend(socket, { baileysModule: mod })
39
+ }
40
+
41
+ _forward(name, payload) {
42
+ this.emitSafe(name, payload)
43
+ if (name === 'connection.update' && payload && payload.qr) {
44
+ this.emitSafe('pairing.qr', { qr: payload.qr, engine: this.engine })
45
+ }
46
+ if (name === 'connection.update' && payload && payload.pairingCode) {
47
+ this.emitSafe('pairing.code', { code: payload.pairingCode, engine: this.engine })
48
+ }
49
+ if (name === 'messages.upsert' && payload && Array.isArray(payload.messages)) {
50
+ for (const message of payload.messages) this.emitSafe('message', message)
51
+ }
52
+ if (name === 'creds.update') this.emitSafe('credentials.update', payload)
53
+ }
54
+
55
+ async connect() {
56
+ if (this._closed) throw new Error('BaileysBackend sudah ditutup')
57
+ // makeWASocket starts its connection when created; there may be no connect() method.
58
+ if (typeof this.socket.connect === 'function') await this.socket.connect()
59
+ return this
60
+ }
61
+
62
+ async sendMessage(jid, content, options) {
63
+ const normalized = typeof content === 'string' ? { text: content } : content
64
+ return options === undefined
65
+ ? this.socket.sendMessage(jid, normalized)
66
+ : this.socket.sendMessage(jid, normalized, options)
67
+ }
68
+
69
+ async requestPairingCode(phoneNumber) {
70
+ if (typeof this.socket.requestPairingCode !== 'function') return this.unsupported('requestPairingCode')
71
+ if (typeof phoneNumber !== 'string' || !/^\d{8,15}$/.test(phoneNumber)) {
72
+ throw new TypeError('phoneNumber wajib berupa digit saja, termasuk kode negara, sepanjang 8–15 digit')
73
+ }
74
+ return this.socket.requestPairingCode(phoneNumber)
75
+ }
76
+
77
+ async downloadMedia(message, options) {
78
+ if (typeof this.module?.downloadMediaMessage !== 'function') return this.unsupported('downloadMedia')
79
+ const type = options?.type || 'buffer'
80
+ return this.module.downloadMediaMessage(message, type, options?.options || {}, options?.context || {})
81
+ }
82
+
83
+ async invoke(method, ...args) {
84
+ if (typeof method !== 'string' || !/^[a-zA-Z_$][\w$]*$/.test(method)) throw new TypeError('method capability tidak valid')
85
+ const fn = this.socket[method]
86
+ if (typeof fn !== 'function') return this.unsupported(method)
87
+ return fn.apply(this.socket, args)
88
+ }
89
+
90
+ async close() {
91
+ if (this._closed) return
92
+ this._closed = true
93
+ this.detachSubscriptions()
94
+ if (typeof this.socket.end === 'function') await this.socket.end()
95
+ else if (typeof this.socket.close === 'function') await this.socket.close()
96
+ else if (this.socket.ws && typeof this.socket.ws.close === 'function') this.socket.ws.close()
97
+ this.removeAllListeners()
98
+ }
99
+
100
+ capabilities() {
101
+ return {
102
+ engine: this.engine,
103
+ live: true,
104
+ messaging: typeof this.socket.sendMessage === 'function',
105
+ media: typeof this.module?.downloadMediaMessage === 'function',
106
+ groups: typeof this.socket.groupMetadata === 'function' || typeof this.socket.groupCreate === 'function',
107
+ historySync: true,
108
+ authQr: true,
109
+ authPairingCode: typeof this.socket.requestPairingCode === 'function'
110
+ }
111
+ }
112
+ }
113
+
114
+ function dependencyError(pkg, adapter, cause) {
115
+ const error = new Error(`${adapter} membutuhkan dependency opsional '${pkg}'. Instal dependency itu atau berikan instance client/socket yang sudah dibuat.`)
116
+ error.code = 'ERR_OPTIONAL_DEPENDENCY_MISSING'
117
+ error.cause = cause
118
+ return error
119
+ }
120
+
121
+ module.exports = { BaileysBackend }
@@ -0,0 +1,102 @@
1
+ 'use strict'
2
+
3
+ const { EventEmitter } = require('node:events')
4
+
5
+ const NORMALIZED_EVENTS = Object.freeze([
6
+ 'connection.update', 'pairing.qr', 'pairing.code', 'pairing.success',
7
+ 'messages.upsert', 'messages.update', 'messages.delete', 'message',
8
+ 'message.receipt', 'contacts.upsert', 'contacts.update', 'chats.upsert',
9
+ 'chats.update', 'groups.upsert', 'groups.update', 'group-participants.update',
10
+ 'messaging-history.set', 'creds.update', 'protocol.error', 'backend.error'
11
+ ])
12
+
13
+ class BackendNotSupportedError extends Error {
14
+ constructor(engine, capability) {
15
+ super(`Backend '${engine}' tidak menyediakan capability '${capability}'`)
16
+ this.name = 'BackendNotSupportedError'
17
+ this.code = 'ERR_BACKEND_CAPABILITY_UNSUPPORTED'
18
+ this.engine = engine
19
+ this.capability = capability
20
+ }
21
+ }
22
+
23
+ class BaseBackend extends EventEmitter {
24
+ constructor(engine) {
25
+ super()
26
+ this.engine = engine
27
+ this._subscriptions = []
28
+ this._closed = false
29
+ this._lastEvents = new Map()
30
+ }
31
+
32
+ subscribe(emitter, event, handler) {
33
+ if (!emitter || typeof emitter.on !== 'function') return
34
+ emitter.on(event, handler)
35
+ this._subscriptions.push(() => {
36
+ if (typeof emitter.off === 'function') emitter.off(event, handler)
37
+ else if (typeof emitter.removeListener === 'function') emitter.removeListener(event, handler)
38
+ })
39
+ }
40
+
41
+ detachSubscriptions() {
42
+ for (const off of this._subscriptions.splice(0)) {
43
+ try { off() } catch { /* best-effort listener cleanup */ }
44
+ }
45
+ }
46
+
47
+ emitSafe(event, payload) {
48
+ if (event === 'pairing.success' || (event === 'connection.update' && payload?.connection === 'close')) {
49
+ this._lastEvents.delete('pairing.qr')
50
+ this._lastEvents.delete('pairing.code')
51
+ }
52
+ if (['connection.update', 'pairing.qr', 'pairing.code', 'pairing.success'].includes(event)) this._lastEvents.set(event, payload)
53
+ // A consumer listener must not crash the protocol/event source.
54
+ for (const listener of this.rawListeners(event)) {
55
+ try { listener.call(this, payload) } catch (error) {
56
+ if (event !== 'backend.error') {
57
+ for (const onError of this.rawListeners('backend.error')) {
58
+ try { onError.call(this, { error, sourceEvent: event }) } catch { /* isolate listeners */ }
59
+ }
60
+ }
61
+ }
62
+ }
63
+ }
64
+
65
+ replay(event, listener) {
66
+ if (this._lastEvents.has(event)) listener(this._lastEvents.get(event))
67
+ }
68
+
69
+ unsupported(capability) { throw new BackendNotSupportedError(this.engine, capability) }
70
+
71
+ async connect() { this.unsupported('connect') }
72
+ async close() { this._closed = true; this.detachSubscriptions() }
73
+ async sendMessage() { this.unsupported('sendMessage') }
74
+ async requestPairingCode() { this.unsupported('requestPairingCode') }
75
+ async downloadMedia() { this.unsupported('downloadMedia') }
76
+ async invoke() { this.unsupported('invoke') }
77
+
78
+ capabilities() {
79
+ return { engine: this.engine, live: false, messaging: false, media: false, groups: false, historySync: false }
80
+ }
81
+ }
82
+
83
+ function assertJid(jid) {
84
+ if (typeof jid !== 'string' || !jid.trim() || jid.length > 256 || /[\r\n\0]/.test(jid)) {
85
+ throw new TypeError('jid harus string non-kosong dan tidak mengandung control character')
86
+ }
87
+ return jid
88
+ }
89
+
90
+ function assertMessageContent(content) {
91
+ if (typeof content === 'string') {
92
+ if (!content.length) throw new TypeError('teks pesan tidak boleh kosong')
93
+ return content
94
+ }
95
+ if (!content || typeof content !== 'object' || Array.isArray(content) || Buffer.isBuffer(content)) {
96
+ throw new TypeError('content harus string atau objek konten pesan')
97
+ }
98
+ if (Object.keys(content).length === 0) throw new TypeError('content pesan tidak boleh kosong')
99
+ return content
100
+ }
101
+
102
+ module.exports = { BaseBackend, BackendNotSupportedError, NORMALIZED_EVENTS, assertJid, assertMessageContent }
@@ -0,0 +1,202 @@
1
+ 'use strict'
2
+
3
+ const { spawn } = require('node:child_process')
4
+ const { randomUUID } = require('node:crypto')
5
+ const { BaseBackend } = require('./base')
6
+
7
+ const DEFAULT_MAX_LINE_BYTES = 16 * 1024 * 1024
8
+
9
+ /**
10
+ * JSONL-RPC transport for a user-supplied Go process that wraps tulir/whatsmeow.
11
+ * This is transport/normalization only: the companion executable is not bundled,
12
+ * and this class never pretends to implement the WhatsApp protocol in JavaScript.
13
+ */
14
+ class WhatsmeowProcessBackend extends BaseBackend {
15
+ constructor({ command, args = [], cwd, env, sessionId = 'default', requestTimeoutMs = 30000, maxPending = 256, maxLineBytes = DEFAULT_MAX_LINE_BYTES, spawnImpl = spawn } = {}) {
16
+ super('whatsmeow-go')
17
+ if (typeof command !== 'string' || !command.trim()) throw new TypeError('command executable Go bridge wajib diisi')
18
+ if (!Array.isArray(args) || args.some((x) => typeof x !== 'string')) throw new TypeError('args harus array string')
19
+ if (!Number.isSafeInteger(requestTimeoutMs) || requestTimeoutMs < 1) throw new RangeError('requestTimeoutMs harus integer positif')
20
+ if (!Number.isSafeInteger(maxPending) || maxPending < 1) throw new RangeError('maxPending harus integer positif')
21
+ if (!Number.isSafeInteger(maxLineBytes) || maxLineBytes < 1024) throw new RangeError('maxLineBytes minimal 1024')
22
+ this.options = { command, args: [...args], cwd, env, sessionId, requestTimeoutMs, maxPending, maxLineBytes, spawnImpl }
23
+ this.child = null
24
+ this.pending = new Map()
25
+ this.counter = 0
26
+ this.buffer = ''
27
+ this._spawned = false
28
+ this._exitPromise = null
29
+ }
30
+
31
+ async connect() {
32
+ if (this._closed) throw new Error('WhatsmeowProcessBackend sudah ditutup')
33
+ if (this.child) return this
34
+ const { command, args, cwd, env, spawnImpl } = this.options
35
+ const child = spawnImpl(command, args, {
36
+ cwd,
37
+ env: env ? { ...process.env, ...env } : process.env,
38
+ stdio: ['pipe', 'pipe', 'pipe'],
39
+ shell: false,
40
+ windowsHide: true
41
+ })
42
+ this.child = child
43
+ this._exitPromise = new Promise((resolve) => child.once('close', (code, signal) => resolve({ code, signal })))
44
+ child.stdout.on('data', (chunk) => this._onStdout(chunk))
45
+ child.stderr.on('data', (chunk) => this.emitSafe('bridge.stderr', Buffer.from(chunk).toString('utf8')))
46
+ child.on('error', (error) => this._failAll(error))
47
+ child.on('close', (code, signal) => {
48
+ const error = new Error(`whatsmeow bridge ditutup (code=${code}, signal=${signal || 'none'})`)
49
+ this._failAll(error)
50
+ this.emitSafe('connection.update', { connection: 'close', error, code, signal })
51
+ })
52
+ await new Promise((resolve, reject) => {
53
+ if (child.pid) return resolve()
54
+ const onSpawn = () => { cleanup(); resolve() }
55
+ const onError = (error) => { cleanup(); reject(error) }
56
+ const cleanup = () => { child.off('spawn', onSpawn); child.off('error', onError) }
57
+ child.once('spawn', onSpawn)
58
+ child.once('error', onError)
59
+ })
60
+ await this.request('connect', { sessionId: this.options.sessionId })
61
+ this.emitSafe('connection.update', { connection: 'open', engine: this.engine })
62
+ return this
63
+ }
64
+
65
+ _onStdout(chunk) {
66
+ this.buffer += Buffer.from(chunk).toString('utf8')
67
+ if (Buffer.byteLength(this.buffer, 'utf8') > this.options.maxLineBytes && !this.buffer.includes('\n')) {
68
+ this._failAll(new Error('whatsmeow bridge mengirim JSONL melebihi batas ukuran'))
69
+ if (this.child && !this.child.killed) this.child.kill()
70
+ this.buffer = ''
71
+ return
72
+ }
73
+ let newline
74
+ while ((newline = this.buffer.indexOf('\n')) !== -1) {
75
+ const line = this.buffer.slice(0, newline)
76
+ this.buffer = this.buffer.slice(newline + 1)
77
+ if (Buffer.byteLength(line, 'utf8') > this.options.maxLineBytes) {
78
+ this._failAll(new Error('whatsmeow bridge mengirim baris JSONL terlalu besar'))
79
+ if (this.child && !this.child.killed) this.child.kill()
80
+ return
81
+ }
82
+ if (!line.trim()) continue
83
+ this._handleLine(line)
84
+ }
85
+ }
86
+
87
+ _handleLine(line) {
88
+ let message
89
+ try { message = JSON.parse(line) } catch (error) {
90
+ this.emitSafe('protocol.error', new Error(`JSONL bridge tidak valid: ${error.message}`))
91
+ return
92
+ }
93
+ if (!message || typeof message !== 'object' || Array.isArray(message)) {
94
+ this.emitSafe('protocol.error', new Error('JSONL bridge harus berupa objek JSON'))
95
+ return
96
+ }
97
+ if (typeof message.event === 'string') {
98
+ this.emitSafe(message.event, message.data)
99
+ if (message.event === 'messages.upsert' && Array.isArray(message.data?.messages)) {
100
+ for (const row of message.data.messages) this.emitSafe('message', row)
101
+ }
102
+ return
103
+ }
104
+ const pending = this.pending.get(String(message.id ?? ''))
105
+ if (!pending) {
106
+ this.emitSafe('protocol.warning', { reason: 'unknown-response-id', id: message.id })
107
+ return
108
+ }
109
+ this.pending.delete(String(message.id))
110
+ clearTimeout(pending.timer)
111
+ if (message.error) {
112
+ const error = new Error(String(message.error.message || 'whatsmeow bridge error'))
113
+ if (message.error.code != null) error.code = String(message.error.code)
114
+ pending.reject(error)
115
+ } else pending.resolve(message.result)
116
+ }
117
+
118
+ request(method, params = {}, { timeoutMs = this.options.requestTimeoutMs, signal } = {}) {
119
+ if (!this.child || !this.child.stdin || this.child.stdin.destroyed) return Promise.reject(new Error('whatsmeow bridge belum terhubung'))
120
+ if (this.pending.size >= this.options.maxPending) return Promise.reject(new Error('jumlah request bridge pending sudah mencapai batas'))
121
+ if (typeof method !== 'string' || !/^[a-zA-Z][\w.-]{0,127}$/.test(method)) return Promise.reject(new TypeError('nama method RPC tidak valid'))
122
+ if (signal?.aborted) return Promise.reject(signal.reason || new Error('request dibatalkan'))
123
+ const id = `${++this.counter}-${randomUUID()}`
124
+ const wire = JSON.stringify({ id, method, params: jsonSafe(params) }) + '\n'
125
+ return new Promise((resolve, reject) => {
126
+ const timer = setTimeout(() => {
127
+ this.pending.delete(id)
128
+ cleanup()
129
+ reject(new Error(`timeout bridge RPC '${method}' setelah ${timeoutMs} ms`))
130
+ }, timeoutMs)
131
+ const onAbort = () => {
132
+ this.pending.delete(id)
133
+ clearTimeout(timer)
134
+ cleanup()
135
+ reject(signal.reason || new Error('request dibatalkan'))
136
+ }
137
+ const cleanup = () => signal?.removeEventListener('abort', onAbort)
138
+ signal?.addEventListener('abort', onAbort, { once: true })
139
+ this.pending.set(id, { resolve: (v) => { cleanup(); resolve(v) }, reject: (e) => { cleanup(); reject(e) }, timer })
140
+ this.child.stdin.write(wire, (error) => {
141
+ if (!error) return
142
+ const pending = this.pending.get(id)
143
+ if (!pending) return
144
+ this.pending.delete(id)
145
+ clearTimeout(timer)
146
+ cleanup()
147
+ reject(error)
148
+ })
149
+ })
150
+ }
151
+
152
+ _failAll(error) {
153
+ for (const [id, pending] of this.pending) {
154
+ this.pending.delete(id)
155
+ clearTimeout(pending.timer)
156
+ pending.reject(error)
157
+ }
158
+ }
159
+
160
+ async sendMessage(jid, content, options) {
161
+ return this.request('sendMessage', { jid, content, ...(options === undefined ? {} : { options }) })
162
+ }
163
+
164
+ async requestPairingCode(phoneNumber) { return this.request('requestPairingCode', { phoneNumber }) }
165
+ async downloadMedia(message, options) { return this.request('downloadMedia', { message, options }) }
166
+ async invoke(method, ...args) { return this.request('invoke', { method, args }) }
167
+ async capabilities() {
168
+ try { return { engine: this.engine, ...(await this.request('capabilities', {})) } }
169
+ catch { return { engine: this.engine, live: true, messaging: true, media: false, groups: false, historySync: false, remoteCapabilitiesUnknown: true } }
170
+ }
171
+
172
+ async close() {
173
+ if (this._closed) return
174
+ this._closed = true
175
+ if (this.child && !this.child.killed && this.child.stdin && !this.child.stdin.destroyed) {
176
+ try { await this.request('close', {}, { timeoutMs: Math.min(2000, this.options.requestTimeoutMs) }) } catch { /* process may exit after its close response */ }
177
+ }
178
+ this.detachSubscriptions()
179
+ this._failAll(new Error('WhatsmeowProcessBackend ditutup'))
180
+ if (this.child && !this.child.killed) this.child.kill()
181
+ this.removeAllListeners()
182
+ }
183
+ }
184
+
185
+ function jsonSafe(value, seen = new WeakSet()) {
186
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array) return { $base64: Buffer.from(value).toString('base64') }
187
+ if (value === null || typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number') return value
188
+ if (typeof value === 'bigint') return value.toString()
189
+ if (typeof value !== 'object') throw new TypeError(`nilai tidak dapat dikirim melalui bridge JSON: ${typeof value}`)
190
+ if (seen.has(value)) throw new TypeError('objek siklik tidak bisa dikirim melalui bridge JSON')
191
+ seen.add(value)
192
+ let result
193
+ if (Array.isArray(value)) result = value.map((v) => jsonSafe(v, seen))
194
+ else {
195
+ result = {}
196
+ for (const [key, child] of Object.entries(value)) result[key] = jsonSafe(child, seen)
197
+ }
198
+ seen.delete(value)
199
+ return result
200
+ }
201
+
202
+ module.exports = { WhatsmeowProcessBackend, jsonSafe, DEFAULT_MAX_LINE_BYTES }
@@ -0,0 +1,123 @@
1
+ 'use strict'
2
+
3
+ const { BaseBackend } = require('./base')
4
+
5
+ const ZAPO_EVENTS = Object.freeze([
6
+ 'auth_qr', 'auth_paired', 'auth_pairing_code', 'connection.update',
7
+ 'message', 'messages.upsert', 'messages.update', 'messages.delete',
8
+ 'contacts.upsert', 'contacts.update', 'chats.upsert', 'chats.update',
9
+ 'groups.upsert', 'groups.update', 'group-participants.update',
10
+ 'messaging-history.set', 'creds.update', 'error'
11
+ ])
12
+
13
+ /**
14
+ * Adapter ke API publik zapo-js. Tidak menyalin atau mengubah sesi/protokol Zapo;
15
+ * satu WaClient tetap menjadi pemilik state autentikasi dan Signal.
16
+ */
17
+ class ZapoBackend extends BaseBackend {
18
+ constructor(client, { sessionId = 'default' } = {}) {
19
+ super('zapo-js')
20
+ if (!client || typeof client.on !== 'function' || !client.message || typeof client.message.send !== 'function') {
21
+ throw new TypeError('ZapoBackend memerlukan WaClient zapo-js dengan .on() dan .message.send()')
22
+ }
23
+ this.client = client
24
+ this.sessionId = sessionId
25
+ for (const name of ZAPO_EVENTS) {
26
+ this.subscribe(client, name, (payload) => this._forward(name, payload))
27
+ }
28
+ }
29
+
30
+ _forward(name, payload) {
31
+ if (name === 'auth_qr') {
32
+ const qr = payload && typeof payload === 'object' ? payload.qr : payload
33
+ if (qr !== undefined) this.emitSafe('pairing.qr', { qr, raw: payload, engine: this.engine })
34
+ return
35
+ }
36
+ if (name === 'auth_pairing_code') {
37
+ const code = payload && typeof payload === 'object' ? payload.code : payload
38
+ if (code !== undefined) this.emitSafe('pairing.code', { code, raw: payload, engine: this.engine })
39
+ return
40
+ }
41
+ if (name === 'auth_paired') {
42
+ this.emitSafe('pairing.success', { ...asObject(payload), raw: payload, engine: this.engine })
43
+ this.emitSafe('connection.update', { connection: 'open', engine: this.engine, authenticated: true })
44
+ return
45
+ }
46
+ if (name === 'message') {
47
+ this.emitSafe('connection.update', { connection: 'open', engine: this.engine, observedMessage: true })
48
+ this.emitSafe('message', payload)
49
+ if (payload && payload.key) this.emitSafe('messages.upsert', { type: 'notify', messages: [payload], engine: this.engine })
50
+ return
51
+ }
52
+ if (name === 'error') {
53
+ this.emitSafe('backend.error', { error: payload instanceof Error ? payload : new Error(String(payload)), engine: this.engine })
54
+ return
55
+ }
56
+ this.emitSafe(name, payload)
57
+ }
58
+
59
+ async connect() {
60
+ if (this._closed) throw new Error('ZapoBackend sudah ditutup')
61
+ if (typeof this.client.connect !== 'function') this.unsupported('connect')
62
+ await this.client.connect()
63
+ return this
64
+ }
65
+
66
+ async sendMessage(jid, content, options) {
67
+ if (options === undefined) return this.client.message.send(jid, content)
68
+ return this.client.message.send(jid, content, options)
69
+ }
70
+
71
+ async requestPairingCode(phoneNumber) {
72
+ if (typeof this.client.requestPairingCode === 'function') return this.client.requestPairingCode(phoneNumber)
73
+ if (typeof this.client.auth?.requestPairingCode === 'function') return this.client.auth.requestPairingCode(phoneNumber)
74
+ return this.unsupported('requestPairingCode')
75
+ }
76
+
77
+ async downloadMedia(message, options) {
78
+ if (typeof this.client.media?.download === 'function') return this.client.media.download(message, options)
79
+ if (typeof this.client.message?.downloadMedia === 'function') return this.client.message.downloadMedia(message, options)
80
+ return this.unsupported('downloadMedia')
81
+ }
82
+
83
+ async invoke(path, ...args) {
84
+ if (typeof path !== 'string' || !/^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)*$/.test(path)) {
85
+ throw new TypeError('path capability tidak valid')
86
+ }
87
+ const parts = path.split('.')
88
+ let owner = this.client
89
+ for (const part of parts.slice(0, -1)) {
90
+ if (!owner || typeof owner !== 'object') return this.unsupported(path)
91
+ owner = owner[part]
92
+ }
93
+ const fn = owner && owner[parts[parts.length - 1]]
94
+ if (typeof fn !== 'function') return this.unsupported(path)
95
+ return fn.apply(owner, args)
96
+ }
97
+
98
+ async close() {
99
+ if (this._closed) return
100
+ this._closed = true
101
+ this.detachSubscriptions()
102
+ const close = this.client.disconnect || this.client.close || this.client.end
103
+ if (typeof close === 'function') await close.call(this.client)
104
+ this.removeAllListeners()
105
+ }
106
+
107
+ capabilities() {
108
+ return {
109
+ engine: this.engine,
110
+ live: typeof this.client.connect === 'function',
111
+ messaging: typeof this.client.message?.send === 'function',
112
+ media: Boolean(this.client.media || this.client.message?.downloadMedia),
113
+ groups: Boolean(this.client.group || this.client.groups),
114
+ historySync: Boolean(this.client.history || this.client.sync),
115
+ authQr: true,
116
+ authPairingCode: typeof this.client.requestPairingCode === 'function' || typeof this.client.auth?.requestPairingCode === 'function'
117
+ }
118
+ }
119
+ }
120
+
121
+ function asObject(value) { return value && typeof value === 'object' ? value : { value } }
122
+
123
+ module.exports = { ZapoBackend }