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,305 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { EventEmitter } = require('node:events')
|
|
4
|
+
const { assertJid, assertMessageContent, BackendNotSupportedError } = require('../backends/base')
|
|
5
|
+
const { ZapoBackend } = require('../backends/zapo')
|
|
6
|
+
const { BaileysBackend } = require('../backends/baileys')
|
|
7
|
+
const { WhatsmeowProcessBackend } = require('../backends/whatsmeow-process')
|
|
8
|
+
|
|
9
|
+
const EVENT_FORWARD = [
|
|
10
|
+
'connection.update', 'pairing.qr', 'pairing.code', 'pairing.success',
|
|
11
|
+
'messages.upsert', 'messages.update', 'messages.delete', 'message',
|
|
12
|
+
'message.receipt', 'contacts.upsert', 'contacts.update', 'chats.upsert',
|
|
13
|
+
'chats.update', 'groups.upsert', 'groups.update', 'group-participants.update',
|
|
14
|
+
'messaging-history.set', 'creds.update', 'credentials.update', 'protocol.error',
|
|
15
|
+
'protocol.warning', 'backend.error', 'bridge.stderr'
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
class KersioClient extends EventEmitter {
|
|
19
|
+
constructor(backend, { sessionId = 'default', archive = null } = {}) {
|
|
20
|
+
super()
|
|
21
|
+
if (!backend || typeof backend.sendMessage !== 'function' || typeof backend.connect !== 'function') {
|
|
22
|
+
throw new TypeError('KersioClient membutuhkan backend adapter yang valid')
|
|
23
|
+
}
|
|
24
|
+
this.backend = backend
|
|
25
|
+
this.engine = backend.engine || 'custom'
|
|
26
|
+
this.sessionId = sessionId
|
|
27
|
+
this.archive = archive
|
|
28
|
+
this._lastReplayable = new Map()
|
|
29
|
+
this.state = 'idle'
|
|
30
|
+
this._closed = false
|
|
31
|
+
this._unsubs = []
|
|
32
|
+
for (const event of EVENT_FORWARD) {
|
|
33
|
+
const handler = (payload) => {
|
|
34
|
+
if (event === 'connection.update' && payload && typeof payload.connection === 'string') {
|
|
35
|
+
if (payload.connection === 'open' || payload.connection === 'noise-ready') this.state = 'open'
|
|
36
|
+
else if (payload.connection === 'close') this.state = 'close'
|
|
37
|
+
else if (payload.connection === 'connecting' || payload.connection === 'handshaking') this.state = 'connecting'
|
|
38
|
+
this._emitSafely('client.state', { state: this.state, engine: this.engine, update: payload })
|
|
39
|
+
}
|
|
40
|
+
this._emitSafely(event, payload)
|
|
41
|
+
if (event === 'messages.upsert' && this.archive && payload && Array.isArray(payload.messages)) {
|
|
42
|
+
this._archiveMessages(payload.messages).catch((error) => this._emitSafely('archive.error', error))
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
backend.on(event, handler)
|
|
46
|
+
this._unsubs.push(() => backend.off(event, handler))
|
|
47
|
+
if (typeof backend.replay === 'function') backend.replay(event, handler)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
on(event, listener) {
|
|
52
|
+
super.on(event, listener)
|
|
53
|
+
if (this._lastReplayable.has(event)) {
|
|
54
|
+
const payload = this._lastReplayable.get(event)
|
|
55
|
+
queueMicrotask(() => {
|
|
56
|
+
if (this._closed || this.listeners(event).length === 0) return
|
|
57
|
+
try { Reflect.apply(listener, this, [payload]) } catch (error) {
|
|
58
|
+
if (event !== 'client.error') this._emitSafely('client.error', { error, sourceEvent: event })
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
return this
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_emitSafely(event, payload) {
|
|
66
|
+
if (event === 'pairing.success' || (event === 'connection.update' && payload?.connection === 'close')) {
|
|
67
|
+
this._lastReplayable.delete('pairing.qr')
|
|
68
|
+
this._lastReplayable.delete('pairing.code')
|
|
69
|
+
}
|
|
70
|
+
if (['connection.update', 'pairing.qr', 'pairing.code', 'pairing.success'].includes(event)) this._lastReplayable.set(event, payload)
|
|
71
|
+
for (const listener of this.rawListeners(event)) {
|
|
72
|
+
try { listener.call(this, payload) } catch (error) {
|
|
73
|
+
if (event !== 'client.error') {
|
|
74
|
+
for (const onError of this.rawListeners('client.error')) {
|
|
75
|
+
try { onError.call(this, { error, sourceEvent: event }) } catch { /* listeners remain isolated */ }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async _archiveMessages(messages) {
|
|
83
|
+
for (const message of messages) await this.archive.put(message)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async connect() {
|
|
87
|
+
this._assertOpen()
|
|
88
|
+
if (this.state === 'connecting' || this.state === 'open') return this
|
|
89
|
+
this.state = 'connecting'
|
|
90
|
+
this._emitSafely('client.state', { state: this.state, engine: this.engine })
|
|
91
|
+
try {
|
|
92
|
+
await this.backend.connect()
|
|
93
|
+
// Do not infer authentication from a resolved connect() call. Only a backend
|
|
94
|
+
// connection.update event may mark the unified facade as open.
|
|
95
|
+
this._emitSafely('client.state', { state: this.state, engine: this.engine })
|
|
96
|
+
return this
|
|
97
|
+
} catch (error) {
|
|
98
|
+
this.state = 'close'
|
|
99
|
+
this._emitSafely('client.state', { state: this.state, engine: this.engine, error })
|
|
100
|
+
throw error
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async sendMessage(jid, content, options) {
|
|
105
|
+
this._assertOpen()
|
|
106
|
+
assertJid(jid)
|
|
107
|
+
assertMessageContent(content)
|
|
108
|
+
if (this.state !== 'open') {
|
|
109
|
+
throw new Error(`client belum open (state=${this.state}); panggil connect() dulu`)
|
|
110
|
+
}
|
|
111
|
+
return this.backend.sendMessage(jid, content, options)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async sendText(jid, text, options) {
|
|
115
|
+
if (typeof text !== 'string' || !text.trim()) throw new TypeError('text harus string non-kosong')
|
|
116
|
+
return this.sendMessage(jid, { text }, options)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async sendImage(jid, image, options = {}) {
|
|
120
|
+
requireMedia(image, 'image')
|
|
121
|
+
return this.sendMessage(jid, { image, ...assertOptions(options) })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async sendVideo(jid, video, options = {}) {
|
|
125
|
+
requireMedia(video, 'video')
|
|
126
|
+
return this.sendMessage(jid, { video, ...assertOptions(options) })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async sendAudio(jid, audio, options = {}) {
|
|
130
|
+
requireMedia(audio, 'audio')
|
|
131
|
+
return this.sendMessage(jid, { audio, ...assertOptions(options) })
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async sendDocument(jid, document, options = {}) {
|
|
135
|
+
requireMedia(document, 'document')
|
|
136
|
+
return this.sendMessage(jid, { document, ...assertOptions(options) })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async sendSticker(jid, sticker, options = {}) {
|
|
140
|
+
requireMedia(sticker, 'sticker')
|
|
141
|
+
return this.sendMessage(jid, { sticker, ...assertOptions(options) })
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async sendLocation(jid, { latitude, longitude, name, address } = {}, options = {}) {
|
|
145
|
+
if (!Number.isFinite(latitude) || !Number.isFinite(longitude) || latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
|
|
146
|
+
throw new TypeError('latitude/longitude tidak valid')
|
|
147
|
+
}
|
|
148
|
+
return this.sendMessage(jid, {
|
|
149
|
+
location: {
|
|
150
|
+
degreesLatitude: latitude,
|
|
151
|
+
degreesLongitude: longitude,
|
|
152
|
+
...(name == null ? {} : { name: String(name) }),
|
|
153
|
+
...(address == null ? {} : { address: String(address) })
|
|
154
|
+
}, ...assertOptions(options)
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async sendContact(jid, { displayName, vcard } = {}, options = {}) {
|
|
159
|
+
if (typeof displayName !== 'string' || !displayName.trim()) throw new TypeError('displayName wajib diisi')
|
|
160
|
+
if (typeof vcard !== 'string' || !vcard.includes('BEGIN:VCARD') || !vcard.includes('END:VCARD')) throw new TypeError('vcard harus berisi BEGIN:VCARD dan END:VCARD')
|
|
161
|
+
return this.sendMessage(jid, {
|
|
162
|
+
contacts: { displayName, contacts: [{ displayName, vcard }] }, ...assertOptions(options)
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async sendPoll(jid, { name, values, selectableCount = 1 } = {}, options = {}) {
|
|
167
|
+
if (typeof name !== 'string' || !name.trim()) throw new TypeError('name polling wajib diisi')
|
|
168
|
+
if (!Array.isArray(values) || values.length < 2 || values.length > 12 || values.some((v) => typeof v !== 'string' || !v.trim())) {
|
|
169
|
+
throw new TypeError('values polling harus 2–12 opsi berupa string non-kosong')
|
|
170
|
+
}
|
|
171
|
+
if (!Number.isSafeInteger(selectableCount) || selectableCount < 0 || selectableCount > values.length) throw new RangeError('selectableCount tidak valid')
|
|
172
|
+
return this.sendMessage(jid, { poll: { name, values, selectableCount }, ...assertOptions(options) })
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async react(jid, key, text) {
|
|
176
|
+
assertMessageKey(key)
|
|
177
|
+
if (typeof text !== 'string') throw new TypeError('reaction harus string')
|
|
178
|
+
return this.sendMessage(jid, { react: { text, key } })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async editMessage(jid, key, text) {
|
|
182
|
+
assertMessageKey(key)
|
|
183
|
+
if (typeof text !== 'string' || !text.trim()) throw new TypeError('text edit tidak boleh kosong')
|
|
184
|
+
return this.sendMessage(jid, { text, edit: key })
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async deleteMessage(jid, key) {
|
|
188
|
+
assertMessageKey(key)
|
|
189
|
+
return this.sendMessage(jid, { delete: key })
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async requestPairingCode(phoneNumber) {
|
|
193
|
+
this._assertOpen()
|
|
194
|
+
if (typeof this.backend.requestPairingCode !== 'function') throw new BackendNotSupportedError(this.engine, 'requestPairingCode')
|
|
195
|
+
return this.backend.requestPairingCode(phoneNumber)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async downloadMedia(message, options) {
|
|
199
|
+
this._assertOpen()
|
|
200
|
+
if (typeof this.backend.downloadMedia !== 'function') throw new BackendNotSupportedError(this.engine, 'downloadMedia')
|
|
201
|
+
return this.backend.downloadMedia(message, options)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async invoke(method, ...args) {
|
|
205
|
+
this._assertOpen()
|
|
206
|
+
if (typeof this.backend.invoke !== 'function') throw new BackendNotSupportedError(this.engine, method)
|
|
207
|
+
return this.backend.invoke(method, ...args)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
capabilities() {
|
|
211
|
+
return typeof this.backend.capabilities === 'function'
|
|
212
|
+
? this.backend.capabilities()
|
|
213
|
+
: { engine: this.engine, live: false, messaging: true, media: false, groups: false, historySync: false }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async close() {
|
|
217
|
+
if (this._closed) return
|
|
218
|
+
this._closed = true
|
|
219
|
+
this.state = 'closing'
|
|
220
|
+
for (const off of this._unsubs.splice(0)) {
|
|
221
|
+
try { off() } catch { /* best effort */ }
|
|
222
|
+
}
|
|
223
|
+
try { await this.backend.close() } finally {
|
|
224
|
+
this.state = 'closed'
|
|
225
|
+
this._emitSafely('client.state', { state: this.state, engine: this.engine })
|
|
226
|
+
this.removeAllListeners()
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
_assertOpen() { if (this._closed) throw new Error('KersioClient sudah ditutup') }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function createKersioClient(options = {}) {
|
|
234
|
+
const sessionId = options.sessionId || 'default'
|
|
235
|
+
let backend = options.backend
|
|
236
|
+
if (!backend) {
|
|
237
|
+
switch (options.engine) {
|
|
238
|
+
case 'zapo':
|
|
239
|
+
case 'zapo-js': {
|
|
240
|
+
const { client } = options
|
|
241
|
+
if (client) backend = new ZapoBackend(client, { sessionId })
|
|
242
|
+
else {
|
|
243
|
+
let mod
|
|
244
|
+
try { mod = await import('zapo-js') } catch (error) { throw dependencyError('zapo-js', error) }
|
|
245
|
+
const clientFactory = options.clientFactory
|
|
246
|
+
if (typeof clientFactory === 'function') {
|
|
247
|
+
const zapoClient = await clientFactory(mod, options)
|
|
248
|
+
backend = new ZapoBackend(zapoClient, { sessionId })
|
|
249
|
+
} else {
|
|
250
|
+
const store = options.store || (typeof options.storeFactory === 'function' ? await options.storeFactory(mod, options) : null)
|
|
251
|
+
if (!store) throw new TypeError("engine 'zapo' perlu options.client atau options.store/storeFactory; Kersio tidak menebak konfigurasi provider penyimpanan kredensial")
|
|
252
|
+
const WaClient = mod.WaClient
|
|
253
|
+
if (typeof WaClient !== 'function') throw new TypeError("versi zapo-js ini tidak mengekspor 'WaClient'")
|
|
254
|
+
const logger = options.logger || (typeof mod.ConsoleLogger === 'function' ? new mod.ConsoleLogger(options.logLevel || 'info') : undefined)
|
|
255
|
+
const args = [{ store, sessionId }, logger].filter((v) => v !== undefined)
|
|
256
|
+
const client = new WaClient(...args)
|
|
257
|
+
backend = new ZapoBackend(client, { sessionId })
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
break
|
|
261
|
+
}
|
|
262
|
+
case 'baileys':
|
|
263
|
+
backend = await BaileysBackend.create(options)
|
|
264
|
+
break
|
|
265
|
+
case 'whatsmeow':
|
|
266
|
+
case 'whatsmeow-go':
|
|
267
|
+
backend = new WhatsmeowProcessBackend({ ...options.bridge, sessionId })
|
|
268
|
+
break
|
|
269
|
+
case 'native':
|
|
270
|
+
throw new Error('engine native hanya menyediakan transport/protokol tingkat rendah di v0.6.0-dev; pairing kriptografis, Signal dan message engine native belum complete. Pilih engine zapo, baileys, atau whatsmeow dengan bridge yang kompatibel untuk fitur pesan live.')
|
|
271
|
+
default:
|
|
272
|
+
throw new TypeError("options.engine wajib salah satu: 'zapo', 'baileys', 'whatsmeow', atau berikan options.backend")
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return new KersioClient(backend, { sessionId, archive: options.archive || null })
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function requireMedia(value, type) {
|
|
279
|
+
if (value === undefined || value === null || value === '') throw new TypeError(`${type} wajib diisi`)
|
|
280
|
+
if (typeof value === 'object' && !Buffer.isBuffer(value) && !(value instanceof Uint8Array) && !('url' in value) && !('stream' in value) && !('path' in value)) {
|
|
281
|
+
throw new TypeError(`${type} harus Buffer, Uint8Array, URL/path/stream, atau input yang didukung backend`)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function assertOptions(value) {
|
|
286
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('options harus objek')
|
|
287
|
+
const copy = { ...value }
|
|
288
|
+
for (const forbidden of ['image', 'video', 'audio', 'document', 'sticker', 'location', 'contacts', 'poll', 'react', 'edit', 'delete']) {
|
|
289
|
+
if (Object.hasOwn(copy, forbidden)) throw new TypeError(`jangan isi options.${forbidden}; gunakan parameter konten khusus`)
|
|
290
|
+
}
|
|
291
|
+
return copy
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function assertMessageKey(key) {
|
|
295
|
+
if (!key || typeof key !== 'object' || typeof key.id !== 'string' || !key.id) throw new TypeError('message key harus objek dengan id non-kosong')
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function dependencyError(pkg, cause) {
|
|
299
|
+
const error = new Error(`Backend '${pkg}' belum terpasang. Instal '${pkg}' atau pass instance client yang sudah dibuat.`)
|
|
300
|
+
error.code = 'ERR_OPTIONAL_DEPENDENCY_MISSING'
|
|
301
|
+
error.cause = cause
|
|
302
|
+
return error
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
module.exports = { KersioClient, createKersioClient, EVENT_FORWARD }
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { createKersioClient } = require('./kersio-client')
|
|
4
|
+
|
|
5
|
+
class KersioSessionManager {
|
|
6
|
+
constructor({ clientFactory = createKersioClient } = {}) {
|
|
7
|
+
if (typeof clientFactory !== 'function') throw new TypeError('clientFactory harus function')
|
|
8
|
+
this.clientFactory = clientFactory
|
|
9
|
+
this.sessions = new Map()
|
|
10
|
+
this.opening = new Map()
|
|
11
|
+
this.closed = false
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async open(sessionId, options = {}) {
|
|
15
|
+
assertSessionId(sessionId)
|
|
16
|
+
if (this.closed) throw new Error('KersioSessionManager sudah ditutup')
|
|
17
|
+
if (this.sessions.has(sessionId)) throw new Error(`session '${sessionId}' sudah dibuka`)
|
|
18
|
+
if (this.opening.has(sessionId)) return this.opening.get(sessionId)
|
|
19
|
+
const operation = (async () => {
|
|
20
|
+
const client = await this.clientFactory({ ...options, sessionId })
|
|
21
|
+
if (this.closed) { await client.close(); throw new Error('manager ditutup saat session dibuka') }
|
|
22
|
+
this.sessions.set(sessionId, client)
|
|
23
|
+
if (options.autoConnect !== false) {
|
|
24
|
+
try { await client.connect() } catch (error) { this.sessions.delete(sessionId); await client.close().catch(() => {}); throw error }
|
|
25
|
+
}
|
|
26
|
+
return client
|
|
27
|
+
})()
|
|
28
|
+
this.opening.set(sessionId, operation)
|
|
29
|
+
try { return await operation } finally { if (this.opening.get(sessionId) === operation) this.opening.delete(sessionId) }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get(sessionId) { return this.sessions.get(sessionId) }
|
|
33
|
+
|
|
34
|
+
async close(sessionId) {
|
|
35
|
+
const opening = this.opening.get(sessionId)
|
|
36
|
+
if (opening) { try { await opening } catch { /* no live session to close */ } }
|
|
37
|
+
const client = this.sessions.get(sessionId)
|
|
38
|
+
if (!client) return false
|
|
39
|
+
this.sessions.delete(sessionId)
|
|
40
|
+
await client.close()
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async closeAll() {
|
|
45
|
+
this.closed = true
|
|
46
|
+
await Promise.allSettled([...this.sessions.keys()].map((id) => this.close(id)))
|
|
47
|
+
await Promise.allSettled([...this.opening.values()])
|
|
48
|
+
this.sessions.clear()
|
|
49
|
+
this.opening.clear()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
list() {
|
|
53
|
+
return [...this.sessions.entries()].map(([sessionId, client]) => ({ sessionId, engine: client.engine, state: client.state }))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
stats() {
|
|
57
|
+
const sessions = this.list()
|
|
58
|
+
return { total: sessions.length, connecting: sessions.filter((x) => x.state === 'connecting').length, open: sessions.filter((x) => x.state === 'open').length, closed: this.closed, sessions }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function createKersioSessionManager(options) { return new KersioSessionManager(options) }
|
|
63
|
+
function assertSessionId(id) {
|
|
64
|
+
if (typeof id !== 'string' || !id.trim() || id.length > 128 || /[\0\r\n]/.test(id)) throw new TypeError('sessionId harus string non-kosong maksimal 128 karakter')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { KersioSessionManager, createKersioSessionManager }
|
package/src/index.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { makeSocket } = require('./socket/make')
|
|
3
|
+
const { createSessionManager } = require('./session/manager')
|
|
4
|
+
const { MemoryStore, FileStore, SQLiteStore, RedisStore, MongoStore } = require('./session/stores')
|
|
5
|
+
const { EncryptedStore, ENCRYPTED_STORE_FORMAT } = require('./session/encrypted-store')
|
|
6
|
+
const { LRUCache } = require('./utils/lru')
|
|
7
|
+
const { generateKeyPair } = require('./utils/crypto')
|
|
8
|
+
const { encodeClientPayload, PLATFORM, RELEASE_CHANNEL, WEB_SUB_PLATFORM, CONNECT_REASON, CONNECT_TYPE, PRODUCT, DEVICE_TYPE, IOS_APP_EXTENSION, TRAFFIC_ANONYMIZATION, ACCOUNT_TYPE } = require('./proto/client-payload')
|
|
9
|
+
const { PLATFORM_TYPE, encodeDeviceProps, encodeHistorySyncConfig, encodeBigEndianUnsigned, buildAppVersionHash, createDevicePairingData } = require('./proto/device-props')
|
|
10
|
+
const { PairingLifecycle } = require('./pairing/lifecycle')
|
|
11
|
+
const { encodeBinaryNode, decodeBinaryNode, makeTokenTables, loadTokenTable, TAGS } = require('./wabinary/codec')
|
|
12
|
+
const { buildPairingQrPayload, extractPairingRefs, LINKED_DEVICES_PREFIX } = require('./pairing/qr')
|
|
13
|
+
const { attachQrPairingAdapter } = require('./pairing/adapter')
|
|
14
|
+
const { NodeProtocol, QueryTimeoutError, RemoteIQError } = require('./protocol/node-protocol')
|
|
15
|
+
const { KersioClient, createKersioClient } = require('./client/kersio-client')
|
|
16
|
+
const { ZapoBackend } = require('./backends/zapo')
|
|
17
|
+
const { BaileysBackend } = require('./backends/baileys')
|
|
18
|
+
const { WhatsmeowProcessBackend, jsonSafe: bridgeJsonSafe } = require('./backends/whatsmeow-process')
|
|
19
|
+
const { BackendNotSupportedError } = require('./backends/base')
|
|
20
|
+
const { KeyedMutex, FileLockManager, LockedStore, MessageArchive, LockTimeoutError } = require('./session/locking')
|
|
21
|
+
const { commitVerifiedPairSuccess } = require('./pairing/verified-commit')
|
|
22
|
+
const { KersioSessionManager, createKersioSessionManager } = require('./client/session-manager')
|
|
23
|
+
|
|
24
|
+
module.exports = {
|
|
25
|
+
makeSocket, createSessionManager,
|
|
26
|
+
MemoryStore, FileStore, SQLiteStore, RedisStore, MongoStore, EncryptedStore, ENCRYPTED_STORE_FORMAT,
|
|
27
|
+
LRUCache, generateKeyPair, encodeClientPayload, PairingLifecycle,
|
|
28
|
+
PLATFORM, RELEASE_CHANNEL, WEB_SUB_PLATFORM, CONNECT_REASON, CONNECT_TYPE, PRODUCT, DEVICE_TYPE, IOS_APP_EXTENSION, TRAFFIC_ANONYMIZATION, ACCOUNT_TYPE,
|
|
29
|
+
PLATFORM_TYPE, encodeDeviceProps, encodeHistorySyncConfig, encodeBigEndianUnsigned, buildAppVersionHash, createDevicePairingData,
|
|
30
|
+
encodeBinaryNode, decodeBinaryNode, makeTokenTables, loadTokenTable, WABINARY_TAGS: TAGS,
|
|
31
|
+
buildPairingQrPayload, extractPairingRefs, LINKED_DEVICES_PREFIX, attachQrPairingAdapter,
|
|
32
|
+
NodeProtocol, QueryTimeoutError, RemoteIQError,
|
|
33
|
+
KersioClient, createKersioClient, ZapoBackend, BaileysBackend, WhatsmeowProcessBackend, bridgeJsonSafe,
|
|
34
|
+
BackendNotSupportedError, KeyedMutex, FileLockManager, LockedStore, MessageArchive, LockTimeoutError,
|
|
35
|
+
commitVerifiedPairSuccess, KersioSessionManager, createKersioSessionManager
|
|
36
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { buildPairingQrPayload, extractPairingRefs } = require('./qr')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Optional WABinary QR adapter. It responds to a server pair-device IQ and emits
|
|
7
|
+
* payload strings only when caller-supplied account key material is complete.
|
|
8
|
+
* It does not validate pair-success signatures or mark an account authenticated.
|
|
9
|
+
*/
|
|
10
|
+
function attachQrPairingAdapter(sock, options = {}) {
|
|
11
|
+
if (!sock || !sock.ev || !sock.protocol || !sock.pairing) {
|
|
12
|
+
throw new TypeError('attachQrPairingAdapter memerlukan socket dengan nodeProtocol aktif')
|
|
13
|
+
}
|
|
14
|
+
const firstQrMs = options.firstQrMs ?? 60000
|
|
15
|
+
const nextQrMs = options.nextQrMs ?? 20000
|
|
16
|
+
if (!Number.isSafeInteger(firstQrMs) || firstQrMs < 1 || !Number.isSafeInteger(nextQrMs) || nextQrMs < 1) {
|
|
17
|
+
throw new TypeError('firstQrMs dan nextQrMs harus integer positif')
|
|
18
|
+
}
|
|
19
|
+
let timer = null
|
|
20
|
+
let detached = false
|
|
21
|
+
|
|
22
|
+
const clearTimer = () => { if (timer) clearTimeout(timer); timer = null }
|
|
23
|
+
const fail = (error) => {
|
|
24
|
+
clearTimer()
|
|
25
|
+
sock.pairing.fail(error)
|
|
26
|
+
sock.ev._safeEmit('pairing.adapter.error', error)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const onNode = async (node) => {
|
|
30
|
+
if (detached || !node || node.tag !== 'iq') return
|
|
31
|
+
const children = Array.isArray(node.content) ? node.content : []
|
|
32
|
+
const pairDevice = children.find((child) => child && child.tag === 'pair-device')
|
|
33
|
+
const pairSuccess = children.find((child) => child && child.tag === 'pair-success')
|
|
34
|
+
|
|
35
|
+
if (pairSuccess) {
|
|
36
|
+
clearTimer()
|
|
37
|
+
// Deliberately a candidate event: callers must verify all key/signature/HMAC
|
|
38
|
+
// material before persisting credentials or calling PairingLifecycle.complete().
|
|
39
|
+
sock.ev._safeEmit('pairing.success.candidate', { sessionId: sock.sessionId, node })
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
if (!pairDevice || node.attrs?.type !== 'set') return
|
|
43
|
+
|
|
44
|
+
const id = node.attrs?.id
|
|
45
|
+
const from = node.attrs?.from
|
|
46
|
+
if (typeof id !== 'string' || !id || typeof from !== 'string' || !from) {
|
|
47
|
+
fail(new Error('node pair-device tidak memiliki attrs id/from yang valid'))
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
// Match the observed protocol request/response exchange: acknowledge the IQ first.
|
|
53
|
+
sock.sendNode({ tag: 'iq', attrs: { to: 's.whatsapp.net', type: 'result', id } })
|
|
54
|
+
} catch (error) {
|
|
55
|
+
fail(error)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (sock.pairing.state !== 'waiting-qr') {
|
|
60
|
+
fail(new Error("pair-device diterima saat lifecycle bukan 'waiting-qr'; panggil sock.pairing.begin('qr') terlebih dahulu"))
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let creds
|
|
65
|
+
try { creds = typeof options.credentials === 'function' ? await options.credentials() : options.credentials } catch (error) {
|
|
66
|
+
fail(error)
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
const refs = extractPairingRefs(node)
|
|
70
|
+
if (!refs.length) {
|
|
71
|
+
fail(new Error('pair-device tidak berisi ref biner'))
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const noisePublicKey = creds?.noiseKey?.public
|
|
76
|
+
const identityPublicKey = creds?.signedIdentityKey?.public || creds?.identityKey?.public
|
|
77
|
+
const advSecretKey = creds?.advSecretKey
|
|
78
|
+
// buildPairingQrPayload validates the key widths and clientType; no dummy keys are generated here.
|
|
79
|
+
const payloads = refs.map((ref) => buildPairingQrPayload({
|
|
80
|
+
ref, noisePublicKey, identityPublicKey, advSecretKey, clientType: options.clientType
|
|
81
|
+
}))
|
|
82
|
+
clearTimer()
|
|
83
|
+
let index = 0
|
|
84
|
+
const emitNext = () => {
|
|
85
|
+
if (detached || sock.pairing.state !== 'waiting-qr') return
|
|
86
|
+
if (index >= payloads.length) {
|
|
87
|
+
fail(new Error('ref QR habis sebelum pairing selesai; perlu koneksi/permintaan pairing baru'))
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
const expiresInMs = index === 0 ? firstQrMs : nextQrMs
|
|
91
|
+
sock.pairing.acceptQr(payloads[index], { index, total: payloads.length, expiresInMs })
|
|
92
|
+
index++
|
|
93
|
+
timer = setTimeout(emitNext, expiresInMs)
|
|
94
|
+
}
|
|
95
|
+
emitNext()
|
|
96
|
+
} catch (error) {
|
|
97
|
+
fail(error)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const onConnectionUpdate = ({ connection }) => {
|
|
102
|
+
if (connection === 'close') clearTimer()
|
|
103
|
+
}
|
|
104
|
+
sock.ev.on('node', onNode)
|
|
105
|
+
sock.ev.on('connection.update', onConnectionUpdate)
|
|
106
|
+
|
|
107
|
+
return function detach() {
|
|
108
|
+
detached = true
|
|
109
|
+
clearTimer()
|
|
110
|
+
sock.ev.off('node', onNode)
|
|
111
|
+
sock.ev.off('connection.update', onConnectionUpdate)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = { attachQrPairingAdapter }
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { EventEmitter } = require('node:events')
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Local pairing lifecycle/state machine.
|
|
6
|
+
*
|
|
7
|
+
* This class isolates pairing state and events per session. The optional WABinary
|
|
8
|
+
* QR adapter feeds observed QR candidates into this lifecycle, but success must
|
|
9
|
+
* only be finalized by a caller after full cryptographic verification.
|
|
10
|
+
*/
|
|
11
|
+
class PairingLifecycle extends EventEmitter {
|
|
12
|
+
constructor({ sessionId = 'default' } = {}) {
|
|
13
|
+
super()
|
|
14
|
+
this.sessionId = sessionId
|
|
15
|
+
this.state = 'idle'
|
|
16
|
+
this.method = null
|
|
17
|
+
this.startedAt = 0
|
|
18
|
+
this.completedAt = 0
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
begin(method) {
|
|
22
|
+
if (!['qr', 'code'].includes(method)) throw new TypeError("metode pairing harus 'qr' atau 'code'")
|
|
23
|
+
if (this.state === 'waiting-qr' || this.state === 'waiting-code') throw new Error('pairing sedang berlangsung')
|
|
24
|
+
if (this.state === 'closed') throw new Error('pairing lifecycle sudah ditutup')
|
|
25
|
+
this.method = method
|
|
26
|
+
this.state = method === 'qr' ? 'waiting-qr' : 'waiting-code'
|
|
27
|
+
this.startedAt = Date.now()
|
|
28
|
+
this.completedAt = 0
|
|
29
|
+
this._emitUpdate()
|
|
30
|
+
return this
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
acceptQr(qrPayload, metadata = {}) {
|
|
34
|
+
if (this.state !== 'waiting-qr') throw new Error('QR tidak diharapkan pada state sekarang')
|
|
35
|
+
if (typeof qrPayload !== 'string' || qrPayload.length === 0) throw new TypeError('qrPayload harus string non-kosong')
|
|
36
|
+
this._safeEmit('pairing.qr', { sessionId: this.sessionId, qr: qrPayload, ...metadata })
|
|
37
|
+
return this
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
acceptCode(code, metadata = {}) {
|
|
41
|
+
if (this.state !== 'waiting-code') throw new Error('kode tidak diharapkan pada state sekarang')
|
|
42
|
+
if (typeof code !== 'string' || !/^[A-Z0-9-]{4,16}$/i.test(code)) throw new TypeError('kode pairing memiliki format tidak valid')
|
|
43
|
+
this._safeEmit('pairing.code', { sessionId: this.sessionId, code, ...metadata })
|
|
44
|
+
return this
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
complete(credentials) {
|
|
48
|
+
if (!['waiting-qr', 'waiting-code'].includes(this.state)) throw new Error('pairing tidak sedang menunggu konfirmasi')
|
|
49
|
+
if (!credentials || typeof credentials !== 'object') throw new TypeError('credentials hasil pairing wajib berupa objek')
|
|
50
|
+
this.state = 'paired'
|
|
51
|
+
this.completedAt = Date.now()
|
|
52
|
+
this._emitUpdate()
|
|
53
|
+
this._safeEmit('pairing.success', { sessionId: this.sessionId, credentials })
|
|
54
|
+
return this
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
fail(error) {
|
|
58
|
+
if (this.state === 'closed') return this
|
|
59
|
+
const normalized = error instanceof Error ? error : new Error(String(error || 'pairing gagal'))
|
|
60
|
+
this.state = 'failed'
|
|
61
|
+
this._emitUpdate({ error: normalized.message })
|
|
62
|
+
this._safeEmit('pairing.error', { sessionId: this.sessionId, error: normalized })
|
|
63
|
+
return this
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
reset() {
|
|
67
|
+
if (this.state === 'closed') throw new Error('pairing lifecycle sudah ditutup')
|
|
68
|
+
this.state = 'idle'
|
|
69
|
+
this.method = null
|
|
70
|
+
this.startedAt = 0
|
|
71
|
+
this.completedAt = 0
|
|
72
|
+
this._emitUpdate()
|
|
73
|
+
return this
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
close() {
|
|
77
|
+
if (this.state === 'closed') return this
|
|
78
|
+
this.state = 'closed'
|
|
79
|
+
this._emitUpdate()
|
|
80
|
+
this.removeAllListeners()
|
|
81
|
+
return this
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
_safeEmit(event, ...args) {
|
|
86
|
+
for (const listener of this.rawListeners(event)) {
|
|
87
|
+
try {
|
|
88
|
+
const result = Reflect.apply(listener, this, args)
|
|
89
|
+
if (result && typeof result.then === 'function') {
|
|
90
|
+
Promise.resolve(result).catch((error) => this._reportListenerError(error, event))
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
this._reportListenerError(error, event)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
_reportListenerError(error, sourceEvent) {
|
|
99
|
+
if (sourceEvent === 'pairing.handler.error') return
|
|
100
|
+
const normalized = error instanceof Error ? error : new Error(String(error))
|
|
101
|
+
for (const listener of this.rawListeners('pairing.handler.error')) {
|
|
102
|
+
try { Reflect.apply(listener, this, [normalized, sourceEvent]) } catch { /* never break pairing state transitions */ }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
stats() {
|
|
107
|
+
return {
|
|
108
|
+
sessionId: this.sessionId,
|
|
109
|
+
state: this.state,
|
|
110
|
+
method: this.method,
|
|
111
|
+
elapsedMs: this.startedAt ? (this.completedAt || Date.now()) - this.startedAt : 0
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_emitUpdate(extra = {}) {
|
|
116
|
+
this._safeEmit('pairing.update', { ...this.stats(), ...extra })
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = { PairingLifecycle }
|