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.
- package/BUILD-INFO.md +15 -0
- package/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/README.md +191 -2
- package/bench/wabinary.js +31 -0
- package/bridges/whatsmeow/README.md +42 -0
- package/docs/ENGINE-MATRIX.md +23 -0
- package/docs/PROTOCOL-ROADMAP.md +74 -0
- package/docs/RESEARCH-BASELINE.md +36 -0
- package/docs/WABINARY.md +37 -0
- package/examples/basic.js +10 -0
- package/examples/client-payload.js +21 -0
- package/examples/device-props.js +18 -0
- package/examples/sessions.js +11 -0
- package/examples/wabinary.js +15 -0
- package/licenses/THIRD-PARTY-NOTICES.md +12 -0
- package/package.json +31 -4
- package/scripts/import-token-table.js +95 -0
- package/src/backends/baileys.js +121 -0
- package/src/backends/base.js +102 -0
- package/src/backends/whatsmeow-process.js +202 -0
- package/src/backends/zapo.js +123 -0
- package/src/client/kersio-client.js +305 -0
- package/src/client/session-manager.js +67 -0
- package/src/index.js +36 -0
- package/src/pairing/adapter.js +115 -0
- package/src/pairing/lifecycle.js +120 -0
- package/src/pairing/qr.js +38 -0
- package/src/pairing/verified-commit.js +46 -0
- package/src/proto/client-payload.js +185 -0
- package/src/proto/device-props.js +143 -0
- package/src/proto/handshake.js +79 -0
- package/src/proto/wire.js +137 -0
- package/src/protocol/node-protocol.js +155 -0
- package/src/session/codec.js +25 -0
- package/src/session/encrypted-store.js +107 -0
- package/src/session/locking.js +233 -0
- package/src/session/manager.js +61 -0
- package/src/session/stores.js +154 -0
- package/src/socket/connection.js +278 -0
- package/src/socket/frame.js +77 -0
- package/src/socket/make.js +70 -0
- package/src/socket/noise.js +146 -0
- package/src/socket/transport.js +39 -0
- package/src/utils/crypto.js +52 -0
- package/src/utils/latency.js +32 -0
- package/src/utils/lru.js +76 -0
- package/src/wabinary/codec.js +374 -0
- package/src/wabinary/tags.js +25 -0
- package/src/wabinary/token-source.js +146 -0
- package/src/wabinary/tokens.json +5 -0
- package/test/backend-adapters.test.js +59 -0
- package/test/connection.test.js +173 -0
- package/test/device-props.test.js +67 -0
- package/test/encrypted-store.test.js +54 -0
- package/test/frame.test.js +37 -0
- package/test/helpers.js +43 -0
- package/test/locking-archive.test.js +49 -0
- package/test/node-protocol.test.js +90 -0
- package/test/noise.test.js +88 -0
- package/test/pairing-adapter.test.js +71 -0
- package/test/pairing.test.js +32 -0
- package/test/proto.test.js +64 -0
- package/test/session-manager.test.js +34 -0
- package/test/session.test.js +134 -0
- package/test/token-source.test.js +35 -0
- package/test/unified-client.test.js +63 -0
- package/test/verified-commit.test.js +43 -0
- package/test/wabinary.test.js +147 -0
- 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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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 }
|