kersiosocket 0.5.0-dev → 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 +8 -7
- package/CHANGELOG.md +11 -0
- package/README.md +151 -105
- package/bridges/whatsmeow/README.md +42 -0
- package/docs/ENGINE-MATRIX.md +23 -0
- package/docs/PROTOCOL-ROADMAP.md +32 -1
- package/docs/RESEARCH-BASELINE.md +11 -0
- package/examples/basic.js +7 -15
- package/licenses/THIRD-PARTY-NOTICES.md +5 -0
- package/package.json +14 -20
- 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 +12 -1
- package/src/pairing/verified-commit.js +46 -0
- package/src/session/locking.js +233 -0
- package/src/socket/connection.js +16 -3
- package/src/socket/noise.js +22 -2
- package/src/wabinary/tokens.json +3 -1285
- package/test/backend-adapters.test.js +59 -0
- package/test/connection.test.js +24 -4
- package/test/locking-archive.test.js +49 -0
- package/test/noise.test.js +22 -0
- package/test/session-manager.test.js +34 -0
- package/test/unified-client.test.js +63 -0
- package/test/verified-commit.test.js +43 -0
- package/test/whatsmeow-process.test.js +43 -0
- package/licenses/whatsmeow-MPL-2.0.txt +0 -374
|
@@ -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
CHANGED
|
@@ -12,6 +12,14 @@ const { encodeBinaryNode, decodeBinaryNode, makeTokenTables, loadTokenTable, TAG
|
|
|
12
12
|
const { buildPairingQrPayload, extractPairingRefs, LINKED_DEVICES_PREFIX } = require('./pairing/qr')
|
|
13
13
|
const { attachQrPairingAdapter } = require('./pairing/adapter')
|
|
14
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')
|
|
15
23
|
|
|
16
24
|
module.exports = {
|
|
17
25
|
makeSocket, createSessionManager,
|
|
@@ -21,5 +29,8 @@ module.exports = {
|
|
|
21
29
|
PLATFORM_TYPE, encodeDeviceProps, encodeHistorySyncConfig, encodeBigEndianUnsigned, buildAppVersionHash, createDevicePairingData,
|
|
22
30
|
encodeBinaryNode, decodeBinaryNode, makeTokenTables, loadTokenTable, WABINARY_TAGS: TAGS,
|
|
23
31
|
buildPairingQrPayload, extractPairingRefs, LINKED_DEVICES_PREFIX, attachQrPairingAdapter,
|
|
24
|
-
NodeProtocol, QueryTimeoutError, RemoteIQError
|
|
32
|
+
NodeProtocol, QueryTimeoutError, RemoteIQError,
|
|
33
|
+
KersioClient, createKersioClient, ZapoBackend, BaileysBackend, WhatsmeowProcessBackend, bridgeJsonSafe,
|
|
34
|
+
BackendNotSupportedError, KeyedMutex, FileLockManager, LockedStore, MessageArchive, LockTimeoutError,
|
|
35
|
+
commitVerifiedPairSuccess, KersioSessionManager, createKersioSessionManager
|
|
25
36
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fail-closed commit guard for a protocol-specific pair-success verifier.
|
|
5
|
+
* This function is intentionally not itself the WhatsApp ADV/signature verifier: callers
|
|
6
|
+
* must supply an independently validated implementation and test vectors.
|
|
7
|
+
*/
|
|
8
|
+
async function commitVerifiedPairSuccess({
|
|
9
|
+
node,
|
|
10
|
+
verifyPairSuccess,
|
|
11
|
+
store,
|
|
12
|
+
sessionId,
|
|
13
|
+
sendNode,
|
|
14
|
+
lifecycle
|
|
15
|
+
} = {}) {
|
|
16
|
+
if (typeof verifyPairSuccess !== 'function') throw new TypeError('verifyPairSuccess wajib diisi; pairing tidak boleh dipercaya tanpa verifier')
|
|
17
|
+
if (!store || typeof store.set !== 'function') throw new TypeError('store wajib menyediakan set() untuk menyimpan credentials')
|
|
18
|
+
if (typeof sendNode !== 'function') throw new TypeError('sendNode wajib diisi untuk mengirim balasan pairing')
|
|
19
|
+
if (!lifecycle || typeof lifecycle.complete !== 'function') throw new TypeError('lifecycle pairing tidak valid')
|
|
20
|
+
|
|
21
|
+
const effectiveSessionId = sessionId || lifecycle.sessionId || 'default'
|
|
22
|
+
const result = await verifyPairSuccess(node)
|
|
23
|
+
if (!result || result.verified !== true || !result.checks ||
|
|
24
|
+
result.checks.deviceIdentity !== true ||
|
|
25
|
+
result.checks.accountSignature !== true ||
|
|
26
|
+
result.checks.advHmac !== true) {
|
|
27
|
+
const error = new Error('pair-success ditolak: pemeriksaan identity/signature/ADV-HMAC tidak lengkap atau gagal')
|
|
28
|
+
error.code = 'ERR_PAIR_SUCCESS_UNVERIFIED'
|
|
29
|
+
throw error
|
|
30
|
+
}
|
|
31
|
+
if (!result.credentials || typeof result.credentials !== 'object' || Array.isArray(result.credentials)) {
|
|
32
|
+
throw new TypeError('verifier tidak mengembalikan credentials terverifikasi')
|
|
33
|
+
}
|
|
34
|
+
if (!result.replyNode || typeof result.replyNode !== 'object' || typeof result.replyNode.tag !== 'string') {
|
|
35
|
+
throw new TypeError('verifier tidak mengembalikan node balasan pair-device-sign yang valid')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Persist before replying/finalizing. A crash after persistence is recoverable from
|
|
39
|
+
// auth state; a failed verification can never be saved or mark lifecycle as paired.
|
|
40
|
+
await store.set(`auth:${effectiveSessionId}:credentials`, result.credentials)
|
|
41
|
+
await sendNode(result.replyNode)
|
|
42
|
+
lifecycle.complete({ verified: true, sessionId: effectiveSessionId, completedAt: Date.now() })
|
|
43
|
+
return { verified: true, sessionId: effectiveSessionId, credentials: result.credentials }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { commitVerifiedPairSuccess }
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs/promises')
|
|
4
|
+
const path = require('node:path')
|
|
5
|
+
const crypto = require('node:crypto')
|
|
6
|
+
|
|
7
|
+
class LockTimeoutError extends Error {
|
|
8
|
+
constructor(key, timeoutMs) {
|
|
9
|
+
super(`timeout menunggu lock '${key}' setelah ${timeoutMs} ms`)
|
|
10
|
+
this.name = 'LockTimeoutError'
|
|
11
|
+
this.code = 'ERR_LOCK_TIMEOUT'
|
|
12
|
+
this.key = key
|
|
13
|
+
this.timeoutMs = timeoutMs
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** In-process keyed mutex. Use FileLockManager when more than one process shares a store. */
|
|
18
|
+
class KeyedMutex {
|
|
19
|
+
constructor() { this.queues = new Map() }
|
|
20
|
+
|
|
21
|
+
async run(key, task) {
|
|
22
|
+
if (typeof key !== 'string' || !key) throw new TypeError('key lock wajib string non-kosong')
|
|
23
|
+
if (typeof task !== 'function') throw new TypeError('task lock wajib function')
|
|
24
|
+
const previous = this.queues.get(key) || Promise.resolve()
|
|
25
|
+
const current = previous.catch(() => {}).then(task)
|
|
26
|
+
this.queues.set(key, current)
|
|
27
|
+
try { return await current } finally {
|
|
28
|
+
if (this.queues.get(key) === current) this.queues.delete(key)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Cross-process lease lock based on atomic mkdir().
|
|
35
|
+
* It protects cooperating processes sharing the same local filesystem; it is not a
|
|
36
|
+
* distributed lock for network filesystems with broken/weak rename semantics.
|
|
37
|
+
*/
|
|
38
|
+
class FileLockManager {
|
|
39
|
+
constructor(dir, { staleMs = 60000, heartbeatMs = 10000, retryMs = 40 } = {}) {
|
|
40
|
+
if (typeof dir !== 'string' || !dir.trim()) throw new TypeError('direktori lock wajib diisi')
|
|
41
|
+
if (!Number.isSafeInteger(staleMs) || staleMs < 3000) throw new RangeError('staleMs minimal 3000')
|
|
42
|
+
if (!Number.isSafeInteger(heartbeatMs) || heartbeatMs < 500 || heartbeatMs >= staleMs) throw new RangeError('heartbeatMs harus >=500 dan < staleMs')
|
|
43
|
+
if (!Number.isSafeInteger(retryMs) || retryMs < 5) throw new RangeError('retryMs minimal 5')
|
|
44
|
+
this.dir = path.resolve(dir)
|
|
45
|
+
this.staleMs = staleMs
|
|
46
|
+
this.heartbeatMs = heartbeatMs
|
|
47
|
+
this.retryMs = retryMs
|
|
48
|
+
this.ready = fs.mkdir(this.dir, { recursive: true })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
run(key, task, options) { return this.withLock(key, task, options) }
|
|
52
|
+
|
|
53
|
+
async withLock(key, task, { timeoutMs = 15000, signal } = {}) {
|
|
54
|
+
if (typeof key !== 'string' || !key) throw new TypeError('key lock wajib string non-kosong')
|
|
55
|
+
if (typeof task !== 'function') throw new TypeError('task lock wajib function')
|
|
56
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new RangeError('timeoutMs harus integer positif')
|
|
57
|
+
await this.ready
|
|
58
|
+
const token = crypto.randomUUID()
|
|
59
|
+
const lockDir = path.join(this.dir, `${crypto.createHash('sha256').update(key).digest('hex')}.lock`)
|
|
60
|
+
const deadline = Date.now() + timeoutMs
|
|
61
|
+
let acquired = false
|
|
62
|
+
while (!acquired) {
|
|
63
|
+
if (signal?.aborted) throw signal.reason || new Error('lock dibatalkan')
|
|
64
|
+
try {
|
|
65
|
+
await fs.mkdir(lockDir)
|
|
66
|
+
acquired = true
|
|
67
|
+
try {
|
|
68
|
+
await fs.writeFile(path.join(lockDir, 'owner.json'), JSON.stringify({ token, keyHash: path.basename(lockDir), pid: process.pid, createdAt: Date.now() }), { flag: 'wx', mode: 0o600 })
|
|
69
|
+
const now = new Date()
|
|
70
|
+
await fs.utimes(lockDir, now, now)
|
|
71
|
+
} catch (error) {
|
|
72
|
+
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => {})
|
|
73
|
+
throw error
|
|
74
|
+
}
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error.code !== 'EEXIST') throw error
|
|
77
|
+
await this._reclaimIfStale(lockDir)
|
|
78
|
+
if (Date.now() >= deadline) throw new LockTimeoutError(key, timeoutMs)
|
|
79
|
+
await sleep(this.retryMs + Math.floor(Math.random() * Math.max(1, this.retryMs)), signal)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const heartbeat = setInterval(async () => {
|
|
84
|
+
try {
|
|
85
|
+
const owner = JSON.parse(await fs.readFile(path.join(lockDir, 'owner.json'), 'utf8'))
|
|
86
|
+
if (owner.token !== token) return
|
|
87
|
+
const now = new Date()
|
|
88
|
+
await fs.utimes(lockDir, now, now)
|
|
89
|
+
} catch { /* lease may have been reclaimed; fn should remain responsible for errors */ }
|
|
90
|
+
}, this.heartbeatMs)
|
|
91
|
+
heartbeat.unref?.()
|
|
92
|
+
try { return await task() } finally {
|
|
93
|
+
clearInterval(heartbeat)
|
|
94
|
+
try {
|
|
95
|
+
const owner = JSON.parse(await fs.readFile(path.join(lockDir, 'owner.json'), 'utf8'))
|
|
96
|
+
if (owner.token === token) await fs.rm(lockDir, { recursive: true, force: true })
|
|
97
|
+
} catch { /* never delete a lock not proven to be ours */ }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async _reclaimIfStale(lockDir) {
|
|
102
|
+
// Serialize stale reclamation so one waiter cannot rename a newly acquired lock
|
|
103
|
+
// after another waiter has already removed the stale directory.
|
|
104
|
+
const reclaimDir = `${lockDir}.reclaim`
|
|
105
|
+
try { await fs.mkdir(reclaimDir) } catch (error) {
|
|
106
|
+
if (error.code === 'EEXIST') return
|
|
107
|
+
throw error
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
let stat
|
|
111
|
+
try { stat = await fs.stat(lockDir) } catch (error) { if (error.code === 'ENOENT') return; throw error }
|
|
112
|
+
if (Date.now() - stat.mtimeMs <= this.staleMs) return
|
|
113
|
+
const tombstone = `${lockDir}.stale-${process.pid}-${crypto.randomBytes(4).toString('hex')}`
|
|
114
|
+
try {
|
|
115
|
+
await fs.rename(lockDir, tombstone)
|
|
116
|
+
await fs.rm(tombstone, { recursive: true, force: true })
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (!['ENOENT', 'EEXIST', 'ENOTEMPTY'].includes(error.code)) throw error
|
|
119
|
+
}
|
|
120
|
+
} finally {
|
|
121
|
+
await fs.rm(reclaimDir, { recursive: true, force: true }).catch(() => {})
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Small persistent message archive. It stores full backend message objects (including
|
|
128
|
+
* buffers supported by the configured store codec) and offers Baileys-style getMessage.
|
|
129
|
+
*/
|
|
130
|
+
class LockedStore {
|
|
131
|
+
constructor(store, lockManager) {
|
|
132
|
+
if (!store || typeof store.get !== 'function' || typeof store.set !== 'function' || typeof store.delete !== 'function') throw new TypeError('LockedStore memerlukan store get/set/delete')
|
|
133
|
+
if (!lockManager || typeof lockManager.run !== 'function') throw new TypeError('lockManager harus menyediakan run(key, task)')
|
|
134
|
+
this.store = store
|
|
135
|
+
this.lockManager = lockManager
|
|
136
|
+
}
|
|
137
|
+
get(key) { return this.lockManager.run(`store:${key}`, () => this.store.get(key)) }
|
|
138
|
+
set(key, value) { return this.lockManager.run(`store:${key}`, () => this.store.set(key, value)) }
|
|
139
|
+
delete(key) { return this.lockManager.run(`store:${key}`, () => this.store.delete(key)) }
|
|
140
|
+
keys(prefix = '') { return this.lockManager.run('store:__keys__', () => {
|
|
141
|
+
if (typeof this.store.keys !== 'function') throw new Error('store dasar tidak mendukung keys()')
|
|
142
|
+
return this.store.keys(prefix)
|
|
143
|
+
}) }
|
|
144
|
+
close() { return typeof this.store.close === 'function' ? this.store.close() : undefined }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
class MessageArchive {
|
|
148
|
+
constructor(store, { sessionId = 'default', maxMessagesPerChat = 1000, mutex = new KeyedMutex() } = {}) {
|
|
149
|
+
if (!store || typeof store.get !== 'function' || typeof store.set !== 'function' || typeof store.delete !== 'function') {
|
|
150
|
+
throw new TypeError('MessageArchive memerlukan store dengan get/set/delete')
|
|
151
|
+
}
|
|
152
|
+
if (!Number.isSafeInteger(maxMessagesPerChat) || maxMessagesPerChat < 1) throw new RangeError('maxMessagesPerChat harus integer positif')
|
|
153
|
+
this.store = store
|
|
154
|
+
this.sessionId = validateScope(sessionId)
|
|
155
|
+
this.maxMessagesPerChat = maxMessagesPerChat
|
|
156
|
+
this.mutex = mutex
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async put(message) {
|
|
160
|
+
const key = message?.key
|
|
161
|
+
const jid = key?.remoteJid
|
|
162
|
+
const id = key?.id
|
|
163
|
+
if (typeof jid !== 'string' || !jid || typeof id !== 'string' || !id) return false
|
|
164
|
+
const indexKey = this._indexKey(jid)
|
|
165
|
+
const recordKey = this._recordKey(jid, id)
|
|
166
|
+
return this.mutex.run(indexKey, async () => {
|
|
167
|
+
await this.store.set(recordKey, message)
|
|
168
|
+
const current = await this.store.get(indexKey)
|
|
169
|
+
const ids = Array.isArray(current) ? current.filter((entry) => entry && entry.id !== id) : []
|
|
170
|
+
ids.push({ id, key: recordKey, timestamp: Date.now() })
|
|
171
|
+
const removed = ids.splice(0, Math.max(0, ids.length - this.maxMessagesPerChat))
|
|
172
|
+
await this.store.set(indexKey, ids)
|
|
173
|
+
for (const old of removed) await this.store.delete(old.key)
|
|
174
|
+
return true
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async getRecord(key) {
|
|
179
|
+
if (!key || typeof key.remoteJid !== 'string' || typeof key.id !== 'string') return undefined
|
|
180
|
+
return this.store.get(this._recordKey(key.remoteJid, key.id))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async getMessage(key) {
|
|
184
|
+
const record = await this.getRecord(key)
|
|
185
|
+
return record?.message
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async recent(jid, limit = 50) {
|
|
189
|
+
if (typeof jid !== 'string' || !jid) throw new TypeError('jid wajib diisi')
|
|
190
|
+
if (!Number.isSafeInteger(limit) || limit < 1) throw new RangeError('limit harus integer positif')
|
|
191
|
+
const ids = await this.store.get(this._indexKey(jid))
|
|
192
|
+
if (!Array.isArray(ids)) return []
|
|
193
|
+
const selected = ids.slice(-limit).reverse()
|
|
194
|
+
const out = []
|
|
195
|
+
for (const entry of selected) {
|
|
196
|
+
const message = await this.store.get(entry.key)
|
|
197
|
+
if (message !== undefined) out.push(message)
|
|
198
|
+
}
|
|
199
|
+
return out
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async delete(key) {
|
|
203
|
+
if (!key || typeof key.remoteJid !== 'string' || typeof key.id !== 'string') return false
|
|
204
|
+
const indexKey = this._indexKey(key.remoteJid)
|
|
205
|
+
return this.mutex.run(indexKey, async () => {
|
|
206
|
+
const recordKey = this._recordKey(key.remoteJid, key.id)
|
|
207
|
+
await this.store.delete(recordKey)
|
|
208
|
+
const ids = await this.store.get(indexKey)
|
|
209
|
+
if (Array.isArray(ids)) await this.store.set(indexKey, ids.filter((entry) => entry.key !== recordKey))
|
|
210
|
+
return true
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
_indexKey(jid) { return `archive:${this.sessionId}:${encodeURIComponent(jid)}:index` }
|
|
215
|
+
_recordKey(jid, id) { return `archive:${this.sessionId}:${encodeURIComponent(jid)}:message:${encodeURIComponent(id)}` }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function validateScope(value) {
|
|
219
|
+
if (typeof value !== 'string' || !value.trim() || value.includes('\0')) throw new TypeError('sessionId harus string non-kosong')
|
|
220
|
+
return value
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function sleep(ms, signal) {
|
|
224
|
+
return new Promise((resolve, reject) => {
|
|
225
|
+
if (signal?.aborted) return reject(signal.reason || new Error('operasi dibatalkan'))
|
|
226
|
+
const timer = setTimeout(done, ms)
|
|
227
|
+
const onAbort = () => { clearTimeout(timer); signal.removeEventListener('abort', onAbort); reject(signal.reason || new Error('operasi dibatalkan')) }
|
|
228
|
+
function done() { signal?.removeEventListener('abort', onAbort); resolve() }
|
|
229
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
module.exports = { KeyedMutex, FileLockManager, LockedStore, MessageArchive, LockTimeoutError }
|