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
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const zlib = require('node:zlib')
|
|
4
|
+
const fs = require('node:fs')
|
|
5
|
+
const path = require('node:path')
|
|
6
|
+
const { TAGS } = require('./tags')
|
|
7
|
+
const { validateTokenTable } = require('./token-source')
|
|
8
|
+
|
|
9
|
+
const DEFAULT_LIMITS = Object.freeze({ maxDepth: 64, maxNodes: 100000, maxBytes: 16 * 1024 * 1024 })
|
|
10
|
+
const NIBBLE_ALPHABET = '0123456789-.'
|
|
11
|
+
const HEX_ALPHABET = '0123456789ABCDEF'
|
|
12
|
+
|
|
13
|
+
function asBuffer(value, label = 'value') {
|
|
14
|
+
if (Buffer.isBuffer(value)) return value
|
|
15
|
+
if (value instanceof Uint8Array) return Buffer.from(value.buffer, value.byteOffset, value.byteLength)
|
|
16
|
+
throw new TypeError(`${label} harus Buffer atau Uint8Array`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function loadTokenTable(filePath = path.join(__dirname, 'tokens.json')) {
|
|
20
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
21
|
+
if (parsed._metadata && parsed._metadata.dictVersion !== 3) {
|
|
22
|
+
throw new Error(`WABinary token DictVersion mismatch: codec supports 3, file declares ${parsed._metadata.dictVersion}`)
|
|
23
|
+
}
|
|
24
|
+
if (parsed._metadata?.upstreamBlobSha1 && !/^[0-9a-f]{40}$/.test(parsed._metadata.upstreamBlobSha1)) {
|
|
25
|
+
throw new Error('WABinary token metadata has an invalid source blob SHA-1')
|
|
26
|
+
}
|
|
27
|
+
makeTokenTables(parsed)
|
|
28
|
+
if (parsed._metadata) {
|
|
29
|
+
validateTokenTable({ dictVersion: parsed._metadata.dictVersion, single: parsed.single, double: parsed.double }, { dictVersion: 3 })
|
|
30
|
+
}
|
|
31
|
+
return parsed
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeTokenTables(tokens = {}) {
|
|
35
|
+
const single = tokens.single || []
|
|
36
|
+
const double = tokens.double || [[], [], [], []]
|
|
37
|
+
if (!Array.isArray(single) || !Array.isArray(double) || double.length !== 4 || double.some((p) => !Array.isArray(p))) {
|
|
38
|
+
throw new TypeError('token table harus { single: string[], double: [string[], string[], string[], string[]] }')
|
|
39
|
+
}
|
|
40
|
+
const singleToId = new Map()
|
|
41
|
+
for (let i = 1; i < single.length && i < TAGS.DICTIONARY_0; i++) {
|
|
42
|
+
if (typeof single[i] === 'string' && single[i] && !singleToId.has(single[i])) singleToId.set(single[i], i)
|
|
43
|
+
}
|
|
44
|
+
const doubleToId = new Map()
|
|
45
|
+
for (let page = 0; page < 4; page++) {
|
|
46
|
+
for (let i = 0; i < double[page].length && i <= 255; i++) {
|
|
47
|
+
const token = double[page][i]
|
|
48
|
+
if (typeof token === 'string' && token && !doubleToId.has(token)) doubleToId.set(token, [page, i])
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { single, double, singleToId, doubleToId }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function pushUInt20(n, out) {
|
|
55
|
+
if (!Number.isInteger(n) || n < 0 || n > 0xfffff) throw new RangeError('panjang BINARY_20 di luar rentang')
|
|
56
|
+
out.push((n >>> 16) & 0x0f, (n >>> 8) & 0xff, n & 0xff)
|
|
57
|
+
}
|
|
58
|
+
function readUInt20(buf, state) {
|
|
59
|
+
need(buf, state, 3)
|
|
60
|
+
const a = buf[state.offset++], b = buf[state.offset++], c = buf[state.offset++]
|
|
61
|
+
return ((a & 0x0f) << 16) | (b << 8) | c
|
|
62
|
+
}
|
|
63
|
+
function need(buf, state, n) {
|
|
64
|
+
if (!Number.isSafeInteger(n) || n < 0 || state.offset + n > buf.length) throw new Error('WABinary: data terpotong')
|
|
65
|
+
}
|
|
66
|
+
function encodeListSize(size, out) {
|
|
67
|
+
if (!Number.isInteger(size) || size < 0 || size > 0xffff) throw new RangeError('ukuran list node di luar rentang')
|
|
68
|
+
if (size === 0) out.push(TAGS.LIST_EMPTY)
|
|
69
|
+
else if (size < 256) out.push(TAGS.LIST_8, size)
|
|
70
|
+
else { out.push(TAGS.LIST_16, (size >>> 8) & 0xff, size & 0xff) }
|
|
71
|
+
}
|
|
72
|
+
function encodeLengthAndBytes(bytes, out) {
|
|
73
|
+
if (bytes.length <= 0xff) out.push(TAGS.BINARY_8, bytes.length)
|
|
74
|
+
else if (bytes.length <= 0xfffff) { out.push(TAGS.BINARY_20); pushUInt20(bytes.length, out) }
|
|
75
|
+
else if (bytes.length <= 0x7fffffff) {
|
|
76
|
+
out.push(TAGS.BINARY_32, (bytes.length >>> 24) & 0xff, (bytes.length >>> 16) & 0xff, (bytes.length >>> 8) & 0xff, bytes.length & 0xff)
|
|
77
|
+
} else throw new RangeError('payload biner terlalu besar')
|
|
78
|
+
for (const b of bytes) out.push(b)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function packNibble(value, out) {
|
|
82
|
+
if (value.length > TAGS.PACKED_MAX || !/^[0-9.-]+$/.test(value)) return false
|
|
83
|
+
out.push(TAGS.NIBBLE_8)
|
|
84
|
+
const pairs = Math.ceil(value.length / 2)
|
|
85
|
+
out.push(pairs | (value.length % 2 ? 0x80 : 0))
|
|
86
|
+
for (let i = 0; i < value.length; i += 2) {
|
|
87
|
+
const first = NIBBLE_ALPHABET.indexOf(value[i])
|
|
88
|
+
const second = i + 1 < value.length ? NIBBLE_ALPHABET.indexOf(value[i + 1]) : 15
|
|
89
|
+
out.push((first << 4) | second)
|
|
90
|
+
}
|
|
91
|
+
return true
|
|
92
|
+
}
|
|
93
|
+
function packHex(value, out) {
|
|
94
|
+
if (value.length > TAGS.PACKED_MAX || !/^[0-9A-F]+$/.test(value)) return false
|
|
95
|
+
out.push(TAGS.HEX_8)
|
|
96
|
+
const pairs = Math.ceil(value.length / 2)
|
|
97
|
+
out.push(pairs | (value.length % 2 ? 0x80 : 0))
|
|
98
|
+
for (let i = 0; i < value.length; i += 2) {
|
|
99
|
+
const first = Number.parseInt(value[i], 16)
|
|
100
|
+
const second = i + 1 < value.length ? Number.parseInt(value[i + 1], 16) : 15
|
|
101
|
+
out.push((first << 4) | second)
|
|
102
|
+
}
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseJid(value) {
|
|
107
|
+
const at = value.indexOf('@')
|
|
108
|
+
if (at < 0 || at !== value.lastIndexOf('@') || at === value.length - 1) return null
|
|
109
|
+
const local = value.slice(0, at)
|
|
110
|
+
const server = value.slice(at + 1)
|
|
111
|
+
const match = /^([^:@_]*)(?:_(\d+))?(?::(\d+))?$/.exec(local)
|
|
112
|
+
if (!match) return null
|
|
113
|
+
const user = match[1]
|
|
114
|
+
const agent = match[2] === undefined ? 0 : Number(match[2])
|
|
115
|
+
const device = match[3] === undefined ? undefined : Number(match[3])
|
|
116
|
+
if (device !== undefined && (!Number.isInteger(device) || device < 0 || device > 255)) return null
|
|
117
|
+
if (!Number.isSafeInteger(agent) || agent < 0 || agent > 255) return null
|
|
118
|
+
return { user, server, agent, device, local }
|
|
119
|
+
}
|
|
120
|
+
function encodeJid(value, out, table) {
|
|
121
|
+
const jid = parseJid(value)
|
|
122
|
+
if (!jid) return false
|
|
123
|
+
if (jid.device !== undefined && ['s.whatsapp.net', 'lid', 'hosted', 'hosted.lid'].includes(jid.server)) {
|
|
124
|
+
const mapped = { 's.whatsapp.net': 0, lid: 1, hosted: 128, 'hosted.lid': 129 }[jid.server]
|
|
125
|
+
const domainType = jid.agent || mapped
|
|
126
|
+
out.push(TAGS.AD_JID, domainType, jid.device)
|
|
127
|
+
encodeString(jid.user, out, table, { jidPairs: false, allowJid: false })
|
|
128
|
+
return true
|
|
129
|
+
}
|
|
130
|
+
// Match the reference's JID_PAIR shape: encode decoded user and server separately;
|
|
131
|
+
// an empty user is represented by LIST_EMPTY, not an empty raw string.
|
|
132
|
+
out.push(TAGS.JID_PAIR)
|
|
133
|
+
if (jid.user.length) encodeString(jid.user, out, table, { jidPairs: false, allowJid: false })
|
|
134
|
+
else out.push(TAGS.LIST_EMPTY)
|
|
135
|
+
encodeString(jid.server, out, table, { jidPairs: false, allowJid: false })
|
|
136
|
+
return true
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function encodeString(value, out, table, options = {}) {
|
|
140
|
+
if (typeof value !== 'string') throw new TypeError('tag/atribut/string content harus string')
|
|
141
|
+
const token = table.singleToId.get(value)
|
|
142
|
+
if (token !== undefined) { out.push(token); return }
|
|
143
|
+
const double = table.doubleToId.get(value)
|
|
144
|
+
if (double) { out.push(TAGS.DICTIONARY_0 + double[0], double[1]); return }
|
|
145
|
+
if (options.jidPairs !== false && options.allowJid !== false && encodeJid(value, out, table)) return
|
|
146
|
+
if (packNibble(value, out) || packHex(value, out)) return
|
|
147
|
+
encodeLengthAndBytes(Buffer.from(value, 'utf8'), out)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function encodeNode(node, out, table, depth, limits, counter) {
|
|
151
|
+
if (depth > limits.maxDepth) throw new RangeError('WABinary: kedalaman node melampaui batas')
|
|
152
|
+
if (++counter.nodes > limits.maxNodes) throw new RangeError('WABinary: jumlah node melampaui batas')
|
|
153
|
+
if (!node || typeof node !== 'object' || Array.isArray(node)) throw new TypeError('node harus objek')
|
|
154
|
+
if (typeof node.tag !== 'string' || node.tag.length === 0) throw new TypeError('node.tag wajib string non-kosong')
|
|
155
|
+
const attrsObject = node.attrs || {}
|
|
156
|
+
if (!attrsObject || typeof attrsObject !== 'object' || Array.isArray(attrsObject)) throw new TypeError('node.attrs harus objek')
|
|
157
|
+
const entries = Object.entries(attrsObject).filter(([, value]) => value !== undefined && value !== null)
|
|
158
|
+
const content = node.content
|
|
159
|
+
const hasContent = content !== undefined && content !== null
|
|
160
|
+
const isChildren = Array.isArray(content)
|
|
161
|
+
if (hasContent && !isChildren && typeof content !== 'string' && !Buffer.isBuffer(content) && !(content instanceof Uint8Array)) {
|
|
162
|
+
throw new TypeError(`WABinary: tipe content node tidak didukung: ${typeof content}`)
|
|
163
|
+
}
|
|
164
|
+
const listSize = 1 + entries.length * 2 + (hasContent ? 1 : 0)
|
|
165
|
+
encodeListSize(listSize, out)
|
|
166
|
+
encodeString(node.tag, out, table)
|
|
167
|
+
for (const [key, value] of entries) {
|
|
168
|
+
if (typeof value !== 'string') throw new TypeError(`atribut ${key} harus string`)
|
|
169
|
+
encodeString(key, out, table)
|
|
170
|
+
encodeString(value, out, table)
|
|
171
|
+
}
|
|
172
|
+
if (!hasContent) return
|
|
173
|
+
if (isChildren) {
|
|
174
|
+
encodeListSize(content.length, out)
|
|
175
|
+
for (const child of content) encodeNode(child, out, table, depth + 1, limits, counter)
|
|
176
|
+
} else if (typeof content === 'string') encodeString(content, out, table)
|
|
177
|
+
else encodeLengthAndBytes(asBuffer(content, 'content'), out)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Encode a WABinary node. By default the standard 0x00 uncompressed node prefix
|
|
182
|
+
* is included. Set withPrefix:false only for raw codec fixtures, not socket sends.
|
|
183
|
+
* compress:true emits 0x02 followed by a zlib-compressed node body.
|
|
184
|
+
*/
|
|
185
|
+
function encodeBinaryNode(node, options = {}) {
|
|
186
|
+
const table = makeTokenTables(options.tokens)
|
|
187
|
+
const limits = { ...DEFAULT_LIMITS, ...(options.limits || {}) }
|
|
188
|
+
validateLimits(limits)
|
|
189
|
+
const out = []
|
|
190
|
+
encodeNode(node, out, table, 0, limits, { nodes: 0 })
|
|
191
|
+
const body = Buffer.from(out)
|
|
192
|
+
if (body.length > limits.maxBytes) throw new RangeError('WABinary: hasil melebihi maxBytes')
|
|
193
|
+
let result
|
|
194
|
+
if (options.compress) result = Buffer.concat([Buffer.from([0x02]), zlib.deflateSync(body, options.zlibOptions)])
|
|
195
|
+
else if (options.withPrefix !== false) result = Buffer.concat([Buffer.from([0x00]), body])
|
|
196
|
+
else result = body
|
|
197
|
+
if (result.length > limits.maxBytes) throw new RangeError('WABinary: hasil melebihi maxBytes')
|
|
198
|
+
return result
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function validateLimits(limits) {
|
|
202
|
+
for (const name of ['maxDepth', 'maxNodes', 'maxBytes']) {
|
|
203
|
+
if (!Number.isSafeInteger(limits[name]) || limits[name] < 1) throw new TypeError(`WABinary limits.${name} harus integer positif`)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function readByte(buf, state) { need(buf, state, 1); return buf[state.offset++] }
|
|
208
|
+
function readUInt16(buf, state) { need(buf, state, 2); const n = buf.readUInt16BE(state.offset); state.offset += 2; return n }
|
|
209
|
+
function readLength(buf, state, marker) {
|
|
210
|
+
if (marker === TAGS.BINARY_8) return readByte(buf, state)
|
|
211
|
+
if (marker === TAGS.BINARY_20) return readUInt20(buf, state)
|
|
212
|
+
if (marker === TAGS.BINARY_32) {
|
|
213
|
+
need(buf, state, 4)
|
|
214
|
+
const n = buf.readUInt32BE(state.offset); state.offset += 4
|
|
215
|
+
if (n > 0x7fffffff) throw new RangeError('WABinary: panjang binary terlalu besar')
|
|
216
|
+
return n
|
|
217
|
+
}
|
|
218
|
+
throw new Error(`WABinary: marker panjang tidak valid ${marker}`)
|
|
219
|
+
}
|
|
220
|
+
function readRawString(buf, state, length) {
|
|
221
|
+
need(buf, state, length)
|
|
222
|
+
const s = buf.toString('utf8', state.offset, state.offset + length)
|
|
223
|
+
state.offset += length
|
|
224
|
+
return s
|
|
225
|
+
}
|
|
226
|
+
function unpackNibble(n) {
|
|
227
|
+
if (n >= 0 && n <= 9) return String(n)
|
|
228
|
+
if (n === 10) return '-'
|
|
229
|
+
if (n === 11) return '.'
|
|
230
|
+
if (n === 15) return '\0'
|
|
231
|
+
throw new Error(`WABinary: nibble tidak valid ${n}`)
|
|
232
|
+
}
|
|
233
|
+
function unpackHex(n) {
|
|
234
|
+
if (n >= 0 && n <= 15) return HEX_ALPHABET[n]
|
|
235
|
+
throw new Error(`WABinary: hex nibble tidak valid ${n}`)
|
|
236
|
+
}
|
|
237
|
+
function readPacked8(buf, state, marker) {
|
|
238
|
+
const packedLength = readByte(buf, state)
|
|
239
|
+
const odd = (packedLength & 0x80) !== 0
|
|
240
|
+
const byteLength = packedLength & 0x7f
|
|
241
|
+
need(buf, state, byteLength)
|
|
242
|
+
let result = ''
|
|
243
|
+
for (let i = 0; i < byteLength; i++) {
|
|
244
|
+
const byte = buf[state.offset++]
|
|
245
|
+
result += marker === TAGS.HEX_8 ? unpackHex(byte >>> 4) : unpackNibble(byte >>> 4)
|
|
246
|
+
const low = byte & 0x0f
|
|
247
|
+
result += marker === TAGS.HEX_8 ? unpackHex(low) : unpackNibble(low)
|
|
248
|
+
}
|
|
249
|
+
if (odd) {
|
|
250
|
+
if (!result.endsWith('\0')) throw new Error('WABinary: filler nibble packed tidak valid')
|
|
251
|
+
result = result.slice(0, -1)
|
|
252
|
+
} else if (result.includes('\0')) throw new Error('WABinary: filler nibble tidak diizinkan di tengah string')
|
|
253
|
+
return result
|
|
254
|
+
}
|
|
255
|
+
function readListSize(buf, state, marker = readByte(buf, state)) {
|
|
256
|
+
if (marker === TAGS.LIST_EMPTY) return 0
|
|
257
|
+
if (marker === TAGS.LIST_8) return readByte(buf, state)
|
|
258
|
+
if (marker === TAGS.LIST_16) return readUInt16(buf, state)
|
|
259
|
+
throw new Error(`WABinary: marker list tidak valid ${marker}`)
|
|
260
|
+
}
|
|
261
|
+
function decodeString(buf, state, table, marker = readByte(buf, state)) {
|
|
262
|
+
if (marker > 0 && marker < TAGS.DICTIONARY_0) {
|
|
263
|
+
const value = table.single[marker]
|
|
264
|
+
if (typeof value !== 'string' || !value) throw new Error(`WABinary: token tunggal ${marker} tidak ada di tabel`)
|
|
265
|
+
return value
|
|
266
|
+
}
|
|
267
|
+
if (marker >= TAGS.DICTIONARY_0 && marker <= TAGS.DICTIONARY_3) {
|
|
268
|
+
const page = marker - TAGS.DICTIONARY_0
|
|
269
|
+
const id = readByte(buf, state)
|
|
270
|
+
const value = table.double[page][id]
|
|
271
|
+
if (typeof value !== 'string' || !value) throw new Error(`WABinary: token dictionary ${page}:${id} tidak ada di tabel`)
|
|
272
|
+
return value
|
|
273
|
+
}
|
|
274
|
+
if (marker === TAGS.LIST_EMPTY) return ''
|
|
275
|
+
if (marker === TAGS.BINARY_8 || marker === TAGS.BINARY_20 || marker === TAGS.BINARY_32) return readRawString(buf, state, readLength(buf, state, marker))
|
|
276
|
+
if (marker === TAGS.JID_PAIR) {
|
|
277
|
+
const user = decodeString(buf, state, table)
|
|
278
|
+
const server = decodeString(buf, state, table)
|
|
279
|
+
if (!server) throw new Error('WABinary: JID_PAIR tanpa server')
|
|
280
|
+
return `${user}@${server}`
|
|
281
|
+
}
|
|
282
|
+
if (marker === TAGS.AD_JID) {
|
|
283
|
+
const domainType = readByte(buf, state)
|
|
284
|
+
const device = readByte(buf, state)
|
|
285
|
+
const user = decodeString(buf, state, table)
|
|
286
|
+
const server = ({ 0: 's.whatsapp.net', 1: 'lid', 128: 'hosted', 129: 'hosted.lid' })[domainType]
|
|
287
|
+
if (!server) throw new Error(`WABinary: domainType AD_JID tidak dikenal ${domainType}`)
|
|
288
|
+
return device === 0 ? `${user}@${server}` : `${user}:${device}@${server}`
|
|
289
|
+
}
|
|
290
|
+
if (marker === TAGS.FB_JID) {
|
|
291
|
+
const user = decodeString(buf, state, table)
|
|
292
|
+
const device = readUInt16(buf, state)
|
|
293
|
+
const server = decodeString(buf, state, table)
|
|
294
|
+
if (!server) throw new Error('WABinary: FB_JID tanpa server')
|
|
295
|
+
return `${user}:${device}@${server}`
|
|
296
|
+
}
|
|
297
|
+
if (marker === TAGS.INTEROP_JID) {
|
|
298
|
+
const user = decodeString(buf, state, table)
|
|
299
|
+
const device = readUInt16(buf, state)
|
|
300
|
+
const integrator = readUInt16(buf, state)
|
|
301
|
+
let server = 'interop'
|
|
302
|
+
// The server suffix is optional in the observed wire format.
|
|
303
|
+
if (state.offset < buf.length) {
|
|
304
|
+
const before = state.offset
|
|
305
|
+
try {
|
|
306
|
+
server = decodeString(buf, state, table)
|
|
307
|
+
} catch {
|
|
308
|
+
state.offset = before
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return `${integrator}-${user}:${device}@${server}`
|
|
312
|
+
}
|
|
313
|
+
if (marker === TAGS.HEX_8 || marker === TAGS.NIBBLE_8) return readPacked8(buf, state, marker)
|
|
314
|
+
throw new Error(`WABinary: string marker tidak dikenal ${marker}`)
|
|
315
|
+
}
|
|
316
|
+
function decodeContent(buf, state, table, depth, limits) {
|
|
317
|
+
const marker = readByte(buf, state)
|
|
318
|
+
if (marker === TAGS.LIST_EMPTY || marker === TAGS.LIST_8 || marker === TAGS.LIST_16) {
|
|
319
|
+
const size = readListSize(buf, state, marker)
|
|
320
|
+
if (state.nodes + size > limits.maxNodes) throw new RangeError('WABinary: jumlah node melampaui batas')
|
|
321
|
+
const children = []
|
|
322
|
+
for (let i = 0; i < size; i++) children.push(decodeNode(buf, state, table, depth + 1, limits))
|
|
323
|
+
return children
|
|
324
|
+
}
|
|
325
|
+
if (marker === TAGS.BINARY_8 || marker === TAGS.BINARY_20 || marker === TAGS.BINARY_32) {
|
|
326
|
+
const length = readLength(buf, state, marker)
|
|
327
|
+
need(buf, state, length)
|
|
328
|
+
const data = Buffer.from(buf.subarray(state.offset, state.offset + length)); state.offset += length
|
|
329
|
+
return data
|
|
330
|
+
}
|
|
331
|
+
return decodeString(buf, state, table, marker)
|
|
332
|
+
}
|
|
333
|
+
function decodeNode(buf, state, table, depth, limits) {
|
|
334
|
+
if (depth > limits.maxDepth) throw new RangeError('WABinary: kedalaman node melampaui batas')
|
|
335
|
+
if (++state.nodes > limits.maxNodes) throw new RangeError('WABinary: jumlah node melampaui batas')
|
|
336
|
+
const size = readListSize(buf, state)
|
|
337
|
+
if (size < 1) throw new Error('WABinary: node list kosong')
|
|
338
|
+
if (size % 2 === 0 && size < 2) throw new Error('WABinary: ukuran node tidak valid')
|
|
339
|
+
const tag = decodeString(buf, state, table)
|
|
340
|
+
if (!tag) throw new Error('WABinary: tag node kosong')
|
|
341
|
+
const attrCount = Math.floor((size - 1) / 2)
|
|
342
|
+
const attrs = Object.create(null)
|
|
343
|
+
for (let i = 0; i < attrCount; i++) {
|
|
344
|
+
const key = decodeString(buf, state, table)
|
|
345
|
+
const value = decodeString(buf, state, table)
|
|
346
|
+
if (Object.hasOwn(attrs, key)) throw new Error(`WABinary: atribut duplikat ${key}`)
|
|
347
|
+
attrs[key] = value
|
|
348
|
+
}
|
|
349
|
+
let content
|
|
350
|
+
if (size % 2 === 0) content = decodeContent(buf, state, table, depth, limits)
|
|
351
|
+
return { tag, attrs, ...(content === undefined ? {} : { content }) }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function decodeBinaryNode(input, options = {}) {
|
|
355
|
+
let buf = asBuffer(input, 'input')
|
|
356
|
+
const limits = { ...DEFAULT_LIMITS, ...(options.limits || {}) }
|
|
357
|
+
validateLimits(limits)
|
|
358
|
+
if (buf.length > limits.maxBytes) throw new RangeError('WABinary: input melebihi maxBytes')
|
|
359
|
+
if (!buf.length) throw new Error('WABinary: input kosong')
|
|
360
|
+
if (options.decompress !== false) {
|
|
361
|
+
const prefix = buf[0]
|
|
362
|
+
if (prefix === 0x02) buf = zlib.inflateSync(buf.subarray(1), { maxOutputLength: limits.maxBytes })
|
|
363
|
+
else if (prefix === 0x00) buf = buf.subarray(1)
|
|
364
|
+
else if (options.requirePrefix === true) throw new Error(`WABinary: prefix node tidak dikenal ${prefix}`)
|
|
365
|
+
}
|
|
366
|
+
if (buf.length > limits.maxBytes) throw new RangeError('WABinary: hasil dekompresi melebihi maxBytes')
|
|
367
|
+
const table = makeTokenTables(options.tokens)
|
|
368
|
+
const state = { offset: 0, nodes: 0 }
|
|
369
|
+
const node = decodeNode(buf, state, table, 0, limits)
|
|
370
|
+
if (state.offset !== buf.length) throw new Error(`WABinary: ${buf.length - state.offset} byte tersisa setelah node`)
|
|
371
|
+
return node
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
module.exports = { encodeBinaryNode, decodeBinaryNode, makeTokenTables, loadTokenTable, TAGS, DEFAULT_LIMITS }
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// WABinary wire markers. Dictionary entries live in the versioned token-data file.
|
|
4
|
+
const TAGS = Object.freeze({
|
|
5
|
+
LIST_EMPTY: 0,
|
|
6
|
+
STREAM_END: 2,
|
|
7
|
+
DICTIONARY_0: 236,
|
|
8
|
+
DICTIONARY_1: 237,
|
|
9
|
+
DICTIONARY_2: 238,
|
|
10
|
+
DICTIONARY_3: 239,
|
|
11
|
+
INTEROP_JID: 245,
|
|
12
|
+
FB_JID: 246,
|
|
13
|
+
AD_JID: 247,
|
|
14
|
+
LIST_8: 248,
|
|
15
|
+
LIST_16: 249,
|
|
16
|
+
JID_PAIR: 250,
|
|
17
|
+
HEX_8: 251,
|
|
18
|
+
BINARY_8: 252,
|
|
19
|
+
BINARY_20: 253,
|
|
20
|
+
BINARY_32: 254,
|
|
21
|
+
NIBBLE_8: 255,
|
|
22
|
+
PACKED_MAX: 127
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
module.exports = { TAGS }
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Small parser for the version-pinned token data source. This file does not
|
|
4
|
+
// implement WABinary; it only converts the source's token arrays to JSON data.
|
|
5
|
+
function readGoString(source, start) {
|
|
6
|
+
if (source[start] !== '"') throw new Error('Go token parser: expected string literal')
|
|
7
|
+
let i = start + 1
|
|
8
|
+
let value = ''
|
|
9
|
+
while (i < source.length) {
|
|
10
|
+
const char = source[i++]
|
|
11
|
+
if (char === '"') return { value, next: i }
|
|
12
|
+
if (char !== '\\') {
|
|
13
|
+
value += char
|
|
14
|
+
continue
|
|
15
|
+
}
|
|
16
|
+
if (i >= source.length) throw new Error('Go token parser: incomplete escape')
|
|
17
|
+
const escape = source[i++]
|
|
18
|
+
const simple = {
|
|
19
|
+
a: '\x07', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t', v: '\v',
|
|
20
|
+
'\\': '\\', '"': '"', "'": "'"
|
|
21
|
+
}
|
|
22
|
+
if (Object.hasOwn(simple, escape)) {
|
|
23
|
+
value += simple[escape]
|
|
24
|
+
continue
|
|
25
|
+
}
|
|
26
|
+
if (escape === 'x' || escape === 'u' || escape === 'U') {
|
|
27
|
+
const digits = escape === 'x' ? 2 : escape === 'u' ? 4 : 8
|
|
28
|
+
const chunk = source.slice(i, i + digits)
|
|
29
|
+
if (chunk.length !== digits || !new RegExp(`^[0-9a-fA-F]{${digits}}$`).test(chunk)) {
|
|
30
|
+
throw new Error('Go token parser: invalid hexadecimal escape')
|
|
31
|
+
}
|
|
32
|
+
const codePoint = Number.parseInt(chunk, 16)
|
|
33
|
+
if (codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) {
|
|
34
|
+
throw new Error('Go token parser: invalid Unicode code point')
|
|
35
|
+
}
|
|
36
|
+
value += String.fromCodePoint(codePoint)
|
|
37
|
+
i += digits
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
if (/[0-7]/.test(escape)) {
|
|
41
|
+
const rest = source.slice(i, i + 2)
|
|
42
|
+
if (rest.length !== 2 || !/^[0-7]{2}$/.test(rest)) throw new Error('Go token parser: invalid octal escape')
|
|
43
|
+
value += String.fromCodePoint(Number.parseInt(escape + rest, 8))
|
|
44
|
+
i += 2
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
throw new Error(`Go token parser: unsupported escape \\${escape}`)
|
|
48
|
+
}
|
|
49
|
+
throw new Error('Go token parser: unterminated string literal')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function findAssignmentBrace(source, name) {
|
|
53
|
+
const match = new RegExp(`\\b${name}\\s*=`).exec(source)
|
|
54
|
+
if (!match) throw new Error(`Go token parser: assignment ${name} not found`)
|
|
55
|
+
const brace = source.indexOf('{', match.index + match[0].length)
|
|
56
|
+
if (brace < 0) throw new Error(`Go token parser: array ${name} has no opening brace`)
|
|
57
|
+
return brace
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseSingleArray(source) {
|
|
61
|
+
let i = findAssignmentBrace(source, 'SingleByteTokens') + 1
|
|
62
|
+
let depth = 1
|
|
63
|
+
const values = []
|
|
64
|
+
while (i < source.length && depth > 0) {
|
|
65
|
+
const ch = source[i]
|
|
66
|
+
if (ch === '"') {
|
|
67
|
+
const parsed = readGoString(source, i)
|
|
68
|
+
values.push(parsed.value)
|
|
69
|
+
i = parsed.next
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
if (ch === '{') depth++
|
|
73
|
+
if (ch === '}') depth--
|
|
74
|
+
i++
|
|
75
|
+
}
|
|
76
|
+
if (depth !== 0) throw new Error('Go token parser: unterminated SingleByteTokens array')
|
|
77
|
+
return values
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseDoubleArray(source) {
|
|
81
|
+
let i = findAssignmentBrace(source, 'DoubleByteTokens') + 1
|
|
82
|
+
let depth = 1
|
|
83
|
+
let page = null
|
|
84
|
+
const pages = []
|
|
85
|
+
while (i < source.length && depth > 0) {
|
|
86
|
+
const ch = source[i]
|
|
87
|
+
if (ch === '"') {
|
|
88
|
+
const parsed = readGoString(source, i)
|
|
89
|
+
if (depth === 2) {
|
|
90
|
+
if (!page) throw new Error('Go token parser: token outside dictionary page')
|
|
91
|
+
page.push(parsed.value)
|
|
92
|
+
}
|
|
93
|
+
i = parsed.next
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
if (ch === '{') {
|
|
97
|
+
depth++
|
|
98
|
+
if (depth === 2) page = []
|
|
99
|
+
i++
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
if (ch === '}') {
|
|
103
|
+
if (depth === 2) {
|
|
104
|
+
if (!page) throw new Error('Go token parser: missing dictionary page')
|
|
105
|
+
pages.push(page)
|
|
106
|
+
page = null
|
|
107
|
+
}
|
|
108
|
+
depth--
|
|
109
|
+
i++
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
i++
|
|
113
|
+
}
|
|
114
|
+
if (depth !== 0) throw new Error('Go token parser: unterminated DoubleByteTokens array')
|
|
115
|
+
return pages
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseGoTokenTable(source) {
|
|
119
|
+
if (typeof source !== 'string' || source.length === 0) throw new TypeError('Go token source must be non-empty text')
|
|
120
|
+
const versionMatch = /\bconst\s+DictVersion\s*=\s*(\d+)\b/.exec(source)
|
|
121
|
+
if (!versionMatch) throw new Error('Go token parser: DictVersion not found')
|
|
122
|
+
return {
|
|
123
|
+
dictVersion: Number(versionMatch[1]),
|
|
124
|
+
single: parseSingleArray(source),
|
|
125
|
+
double: parseDoubleArray(source)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function validateTokenTable(table, expected = {}) {
|
|
130
|
+
if (!table || !Array.isArray(table.single) || !Array.isArray(table.double)) throw new TypeError('token data is missing single/double arrays')
|
|
131
|
+
if (expected.dictVersion !== undefined && table.dictVersion !== expected.dictVersion) {
|
|
132
|
+
throw new Error(`DictVersion mismatch: expected ${expected.dictVersion}, received ${table.dictVersion}`)
|
|
133
|
+
}
|
|
134
|
+
if (table.single.length !== 236 || table.single[0] !== '') {
|
|
135
|
+
throw new Error(`single-byte token table shape unexpected: expected 236 entries with empty index 0, received ${table.single.length}`)
|
|
136
|
+
}
|
|
137
|
+
if (table.double.length !== 4 || table.double.some((page) => !Array.isArray(page) || page.length !== 256)) {
|
|
138
|
+
throw new Error('double-byte token table shape unexpected: expected four pages of 256 entries')
|
|
139
|
+
}
|
|
140
|
+
if (table.single.some((entry) => typeof entry !== 'string') || table.double.some((page) => page.some((entry) => typeof entry !== 'string'))) {
|
|
141
|
+
throw new TypeError('token table entries must all be strings')
|
|
142
|
+
}
|
|
143
|
+
return true
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = { parseGoTokenTable, validateTokenTable }
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Intentionally empty token data. Populate only from a versioned, verified WhatsApp Web token table and record its source/version in your own fixture metadata. Empty tables force raw-string encoding and cannot decode tokenized live traffic.",
|
|
3
|
+
"single": [],
|
|
4
|
+
"double": [[], [], [], []]
|
|
5
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const test = require('node:test')
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const { EventEmitter } = require('node:events')
|
|
5
|
+
const { ZapoBackend } = require('../src/backends/zapo')
|
|
6
|
+
const { BaileysBackend } = require('../src/backends/baileys')
|
|
7
|
+
|
|
8
|
+
class FakeZapo extends EventEmitter {
|
|
9
|
+
constructor() { super(); this.sent = []; this.message = { send: async (...args) => { this.sent.push(args); return { ok: true } } }; this.connected = 0 }
|
|
10
|
+
async connect() { this.connected++; this.emit('connection.update', { connection: 'open' }) }
|
|
11
|
+
async disconnect() { this.closed = true }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class FakeBaileysSocket {
|
|
15
|
+
constructor() { this.ev = new EventEmitter(); this.sent = []; this.pairCalls = []; this.ended = false }
|
|
16
|
+
async sendMessage(...args) { this.sent.push(args); return { key: { id: 'm1' } } }
|
|
17
|
+
async requestPairingCode(phone) { this.pairCalls.push(phone); return 'ABCD-1234' }
|
|
18
|
+
async end() { this.ended = true }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test('Zapo adapter forwards auth/message events and calls public message.send', async () => {
|
|
22
|
+
const raw = new FakeZapo()
|
|
23
|
+
const backend = new ZapoBackend(raw, { sessionId: 'test' })
|
|
24
|
+
const messages = []
|
|
25
|
+
const qr = []
|
|
26
|
+
backend.on('messages.upsert', (x) => messages.push(x))
|
|
27
|
+
backend.on('pairing.qr', (x) => qr.push(x.qr))
|
|
28
|
+
raw.emit('auth_qr', { qr: 'test-qr' })
|
|
29
|
+
const msg = { key: { remoteJid: '1@s.whatsapp.net', id: 'm1' }, message: { conversation: 'hi' } }
|
|
30
|
+
raw.emit('message', msg)
|
|
31
|
+
assert.deepEqual(qr, ['test-qr'])
|
|
32
|
+
assert.equal(messages[0].messages[0], msg)
|
|
33
|
+
await backend.connect()
|
|
34
|
+
await backend.sendMessage('1@s.whatsapp.net', 'pong')
|
|
35
|
+
assert.deepEqual(raw.sent[0], ['1@s.whatsapp.net', 'pong'])
|
|
36
|
+
await backend.close()
|
|
37
|
+
assert.equal(raw.closed, true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('Baileys adapter forwards connection/messages and maps common methods', async () => {
|
|
41
|
+
const socket = new FakeBaileysSocket()
|
|
42
|
+
const mod = { downloadMediaMessage: async () => Buffer.from('media') }
|
|
43
|
+
const backend = new BaileysBackend(socket, { baileysModule: mod })
|
|
44
|
+
const updates = []
|
|
45
|
+
const incoming = []
|
|
46
|
+
backend.on('pairing.qr', (p) => updates.push(p.qr))
|
|
47
|
+
backend.on('message', (m) => incoming.push(m))
|
|
48
|
+
socket.ev.emit('connection.update', { qr: 'q1' })
|
|
49
|
+
const msg = { key: { id: 'm1', remoteJid: '1@s.whatsapp.net' }, message: { conversation: 'hello' } }
|
|
50
|
+
socket.ev.emit('messages.upsert', { type: 'notify', messages: [msg] })
|
|
51
|
+
await backend.sendMessage('1@s.whatsapp.net', 'pong')
|
|
52
|
+
assert.deepEqual(updates, ['q1'])
|
|
53
|
+
assert.deepEqual(incoming, [msg])
|
|
54
|
+
assert.deepEqual(socket.sent[0][1], { text: 'pong' })
|
|
55
|
+
assert.equal(await backend.requestPairingCode('628123456789'), 'ABCD-1234')
|
|
56
|
+
assert.deepEqual(await backend.downloadMedia(msg), Buffer.from('media'))
|
|
57
|
+
await backend.close()
|
|
58
|
+
assert.equal(socket.ended, true)
|
|
59
|
+
})
|