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,155 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto')
|
|
4
|
+
const { encodeBinaryNode, decodeBinaryNode, loadTokenTable, makeTokenTables } = require('../wabinary/codec')
|
|
5
|
+
|
|
6
|
+
class QueryTimeoutError extends Error {
|
|
7
|
+
constructor(id, timeoutMs) {
|
|
8
|
+
super(`WABinary IQ ${id} timeout setelah ${timeoutMs} ms`)
|
|
9
|
+
this.name = 'QueryTimeoutError'
|
|
10
|
+
this.code = 'ERR_QUERY_TIMEOUT'
|
|
11
|
+
this.id = id
|
|
12
|
+
this.timeoutMs = timeoutMs
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class RemoteIQError extends Error {
|
|
17
|
+
constructor(node) {
|
|
18
|
+
const code = node?.attrs?.code ? ` (code ${node.attrs.code})` : ''
|
|
19
|
+
super(`server mengembalikan IQ error${code}`)
|
|
20
|
+
this.name = 'RemoteIQError'
|
|
21
|
+
this.code = 'ERR_REMOTE_IQ'
|
|
22
|
+
this.node = node
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Node-level protocol layer above Noise transport. It does not implement Signal,
|
|
28
|
+
* registration credentials, or authenticated pairing-success verification.
|
|
29
|
+
*/
|
|
30
|
+
class NodeProtocol {
|
|
31
|
+
constructor(connection, options = {}) {
|
|
32
|
+
this.connection = connection
|
|
33
|
+
const hasExplicitTokenTable = options.tokens !== undefined && options.tokens !== null
|
|
34
|
+
this.tokens = hasExplicitTokenTable ? options.tokens : loadTokenTable(options.tokenTablePath)
|
|
35
|
+
makeTokenTables(this.tokens) // validate dictionary structure at construction time
|
|
36
|
+
const populated = (this.tokens.single || []).some(Boolean) || (this.tokens.double || []).some((page) => page.some(Boolean))
|
|
37
|
+
if (!populated && options.allowEmptyTokenTable !== true) {
|
|
38
|
+
throw new Error('NodeProtocol membutuhkan token dictionary non-kosong; jalankan npm run refresh:tokens terlebih dahulu (allowEmptyTokenTable hanya untuk tes lokal)')
|
|
39
|
+
}
|
|
40
|
+
if (this.tokens._metadata && this.tokens._metadata.dictVersion !== 3) {
|
|
41
|
+
throw new Error(`NodeProtocol mendukung DictVersion 3, menerima ${this.tokens._metadata.dictVersion}`)
|
|
42
|
+
}
|
|
43
|
+
this.timeoutMs = options.timeoutMs ?? 30000
|
|
44
|
+
this.maxPending = options.maxPending ?? 1024
|
|
45
|
+
this.closeOnDecodeError = options.closeOnDecodeError !== false
|
|
46
|
+
if (!Number.isSafeInteger(this.timeoutMs) || this.timeoutMs < 1) throw new TypeError('timeoutMs harus integer positif')
|
|
47
|
+
if (!Number.isSafeInteger(this.maxPending) || this.maxPending < 1) throw new TypeError('maxPending harus integer positif')
|
|
48
|
+
this.pending = new Map()
|
|
49
|
+
this.closed = false
|
|
50
|
+
this.onFrame = (frame) => this._handleFrame(frame)
|
|
51
|
+
this.onConnectionUpdate = ({ connection: state }) => {
|
|
52
|
+
if (state === 'close') this._rejectAll(new Error('koneksi ditutup sebelum IQ selesai'))
|
|
53
|
+
}
|
|
54
|
+
connection.on('frame', this.onFrame)
|
|
55
|
+
connection.on('connection.update', this.onConnectionUpdate)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
sendNode(node) {
|
|
59
|
+
if (this.closed) throw new Error('NodeProtocol sudah ditutup')
|
|
60
|
+
const bytes = encodeBinaryNode(node, { tokens: this.tokens })
|
|
61
|
+
this.connection.sendFrame(bytes)
|
|
62
|
+
return bytes.length
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
query(node, options = {}) {
|
|
66
|
+
if (this.closed) return Promise.reject(new Error('NodeProtocol sudah ditutup'))
|
|
67
|
+
if (!node || node.tag !== 'iq') return Promise.reject(new TypeError('query() hanya menerima node IQ'))
|
|
68
|
+
if (this.pending.size >= this.maxPending) return Promise.reject(new Error('batas IQ pending tercapai'))
|
|
69
|
+
const timeoutMs = options.timeoutMs ?? this.timeoutMs
|
|
70
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) return Promise.reject(new TypeError('timeoutMs harus integer positif'))
|
|
71
|
+
const attrs = { ...(node.attrs || {}) }
|
|
72
|
+
const id = attrs.id === undefined || attrs.id === null ? this._newId() : String(attrs.id)
|
|
73
|
+
if (this.pending.has(id)) return Promise.reject(new Error(`IQ id duplikat: ${id}`))
|
|
74
|
+
attrs.id = id
|
|
75
|
+
const outgoing = { ...node, attrs }
|
|
76
|
+
if (options.signal?.aborted) return Promise.reject(options.signal.reason || new Error('IQ dibatalkan'))
|
|
77
|
+
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const entry = { resolve, reject, timer: null, signal: options.signal, abort: null }
|
|
80
|
+
entry.timer = setTimeout(() => {
|
|
81
|
+
this._dropPending(id)
|
|
82
|
+
reject(new QueryTimeoutError(id, timeoutMs))
|
|
83
|
+
}, timeoutMs)
|
|
84
|
+
if (entry.signal) {
|
|
85
|
+
entry.abort = () => {
|
|
86
|
+
this._dropPending(id)
|
|
87
|
+
reject(entry.signal.reason || new Error('IQ dibatalkan'))
|
|
88
|
+
}
|
|
89
|
+
entry.signal.addEventListener('abort', entry.abort, { once: true })
|
|
90
|
+
}
|
|
91
|
+
this.pending.set(id, entry)
|
|
92
|
+
try { this.sendNode(outgoing) } catch (error) {
|
|
93
|
+
this._dropPending(id)
|
|
94
|
+
reject(error)
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
close(reason = new Error('NodeProtocol ditutup')) {
|
|
100
|
+
if (this.closed) return
|
|
101
|
+
this.closed = true
|
|
102
|
+
this.connection.off('frame', this.onFrame)
|
|
103
|
+
this.connection.off('connection.update', this.onConnectionUpdate)
|
|
104
|
+
this._rejectAll(reason)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
stats() {
|
|
108
|
+
return { pendingQueries: this.pending.size, maxPending: this.maxPending, closed: this.closed }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
_newId() {
|
|
112
|
+
return `${Date.now().toString(36)}-${crypto.randomBytes(6).toString('hex')}`
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_handleFrame(frame) {
|
|
116
|
+
if (this.closed) return
|
|
117
|
+
let node
|
|
118
|
+
try {
|
|
119
|
+
node = decodeBinaryNode(frame, { tokens: this.tokens, requirePrefix: true })
|
|
120
|
+
} catch (error) {
|
|
121
|
+
this.connection._safeEmit('protocol.error', error)
|
|
122
|
+
if (this.closeOnDecodeError) this.connection.failProtocol(error)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
this.connection._safeEmit('node', node)
|
|
126
|
+
this.connection._safeEmit(`node:${node.tag}`, node)
|
|
127
|
+
if (node.tag === 'iq') {
|
|
128
|
+
this.connection._safeEmit('iq', node)
|
|
129
|
+
const id = node.attrs?.id
|
|
130
|
+
const entry = id === undefined ? undefined : this.pending.get(String(id))
|
|
131
|
+
if (entry && (node.attrs?.type === 'result' || node.attrs?.type === 'error')) {
|
|
132
|
+
this._dropPending(String(id))
|
|
133
|
+
if (node.attrs.type === 'error') entry.reject(new RemoteIQError(node))
|
|
134
|
+
else entry.resolve(node)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
_dropPending(id) {
|
|
140
|
+
const entry = this.pending.get(id)
|
|
141
|
+
if (!entry) return
|
|
142
|
+
clearTimeout(entry.timer)
|
|
143
|
+
if (entry.signal && entry.abort) entry.signal.removeEventListener('abort', entry.abort)
|
|
144
|
+
this.pending.delete(id)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
_rejectAll(error) {
|
|
148
|
+
for (const [id, entry] of this.pending) {
|
|
149
|
+
this._dropPending(id)
|
|
150
|
+
entry.reject(error)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = { NodeProtocol, QueryTimeoutError, RemoteIQError }
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* Serialisasi nilai session ke JSON dengan dukungan Buffer/Uint8Array (base64) dan bigint.
|
|
4
|
+
* Dipakai oleh store berbasis teks (file, SQLite, Redis, Mongo).
|
|
5
|
+
*/
|
|
6
|
+
function encode(value) {
|
|
7
|
+
return JSON.stringify(value, function (key, v) {
|
|
8
|
+
const raw = this[key] // JSON.stringify sudah memanggil Buffer.toJSON sebelum replacer
|
|
9
|
+
if (raw instanceof Uint8Array) return { $b: Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString('base64') }
|
|
10
|
+
if (typeof raw === 'bigint') return { $n: raw.toString() }
|
|
11
|
+
return v
|
|
12
|
+
})
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function decode(text) {
|
|
16
|
+
return JSON.parse(text, (key, v) => {
|
|
17
|
+
if (v && typeof v === 'object') {
|
|
18
|
+
if (typeof v.$b === 'string' && Object.keys(v).length === 1) return Buffer.from(v.$b, 'base64')
|
|
19
|
+
if (typeof v.$n === 'string' && Object.keys(v).length === 1) return BigInt(v.$n)
|
|
20
|
+
}
|
|
21
|
+
return v
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = { encode, decode }
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto')
|
|
4
|
+
const { encode, decode } = require('./codec')
|
|
5
|
+
|
|
6
|
+
const FORMAT = 'kersiosocket:aes-256-gcm:v1'
|
|
7
|
+
const IV_BYTES = 12
|
|
8
|
+
const TAG_BYTES = 16
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Authenticated-encryption wrapper for any compatible session store.
|
|
12
|
+
* Supply a 32-byte master key from a secret manager or environment variable.
|
|
13
|
+
* The wrapper never generates or persists its own master key.
|
|
14
|
+
*/
|
|
15
|
+
class EncryptedStore {
|
|
16
|
+
constructor(store, masterKey) {
|
|
17
|
+
if (!store || typeof store.get !== 'function' || typeof store.set !== 'function' || typeof store.delete !== 'function') {
|
|
18
|
+
throw new TypeError('EncryptedStore memerlukan store dengan get/set/delete')
|
|
19
|
+
}
|
|
20
|
+
if (!Buffer.isBuffer(masterKey) && !(masterKey instanceof Uint8Array)) {
|
|
21
|
+
throw new TypeError('masterKey harus Buffer/Uint8Array 32 byte; jangan simpan key di source code')
|
|
22
|
+
}
|
|
23
|
+
const key = Buffer.from(masterKey.buffer, masterKey.byteOffset, masterKey.byteLength)
|
|
24
|
+
if (key.length !== 32) throw new RangeError('masterKey harus tepat 32 byte untuk AES-256-GCM')
|
|
25
|
+
this.store = store
|
|
26
|
+
this.key = Buffer.from(key)
|
|
27
|
+
this.closed = false
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async get(key) {
|
|
31
|
+
this._assertOpen()
|
|
32
|
+
assertStorageKey(key)
|
|
33
|
+
const envelope = await this.store.get(key)
|
|
34
|
+
if (envelope === undefined) return undefined
|
|
35
|
+
if (!envelope || envelope.format !== FORMAT || typeof envelope.iv !== 'string' || typeof envelope.tag !== 'string' || typeof envelope.data !== 'string') {
|
|
36
|
+
throw new Error(`EncryptedStore: data '${key}' tidak memiliki format terenkripsi yang dikenali; tidak ada fallback plaintext`)
|
|
37
|
+
}
|
|
38
|
+
const iv = decodeBase64Strict(envelope.iv, 'iv')
|
|
39
|
+
const tag = decodeBase64Strict(envelope.tag, 'tag')
|
|
40
|
+
const data = decodeBase64Strict(envelope.data, 'data')
|
|
41
|
+
if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) throw new Error(`EncryptedStore: envelope '${key}' malformed`)
|
|
42
|
+
try {
|
|
43
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', this.key, iv)
|
|
44
|
+
decipher.setAAD(this._aad(key))
|
|
45
|
+
decipher.setAuthTag(tag)
|
|
46
|
+
const plain = Buffer.concat([decipher.update(data), decipher.final()])
|
|
47
|
+
return decode(plain.toString('utf8'))
|
|
48
|
+
} catch {
|
|
49
|
+
throw new Error(`EncryptedStore: autentikasi/dekripsi gagal untuk '${key}' (key salah atau data diubah)`)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async set(key, value) {
|
|
54
|
+
this._assertOpen()
|
|
55
|
+
assertStorageKey(key)
|
|
56
|
+
const iv = crypto.randomBytes(IV_BYTES)
|
|
57
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', this.key, iv)
|
|
58
|
+
cipher.setAAD(this._aad(key))
|
|
59
|
+
const plain = Buffer.from(encode(value), 'utf8')
|
|
60
|
+
const data = Buffer.concat([cipher.update(plain), cipher.final()])
|
|
61
|
+
await this.store.set(key, {
|
|
62
|
+
format: FORMAT,
|
|
63
|
+
iv: iv.toString('base64'),
|
|
64
|
+
tag: cipher.getAuthTag().toString('base64'),
|
|
65
|
+
data: data.toString('base64')
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async delete(key) {
|
|
70
|
+
this._assertOpen()
|
|
71
|
+
assertStorageKey(key)
|
|
72
|
+
await this.store.delete(key)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async keys(prefix = '') {
|
|
76
|
+
this._assertOpen()
|
|
77
|
+
if (typeof prefix !== 'string') throw new TypeError('prefix harus string')
|
|
78
|
+
if (typeof this.store.keys !== 'function') throw new Error('store dasar tidak mendukung keys()')
|
|
79
|
+
return this.store.keys(prefix)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async close() {
|
|
83
|
+
if (this.closed) return
|
|
84
|
+
this.closed = true
|
|
85
|
+
this.key.fill(0)
|
|
86
|
+
if (typeof this.store.close === 'function') await this.store.close()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
_assertOpen() {
|
|
90
|
+
if (this.closed) throw new Error('EncryptedStore sudah ditutup')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
_aad(key) { return Buffer.from(`${FORMAT}\0${key}`, 'utf8') }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function assertStorageKey(key) {
|
|
97
|
+
if (typeof key !== 'string' || key.length === 0 || key.includes('\0')) throw new TypeError('key harus string non-kosong tanpa NUL')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function decodeBase64Strict(value, name) {
|
|
101
|
+
if (value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
|
|
102
|
+
throw new Error(`EncryptedStore: ${name} bukan base64 valid`)
|
|
103
|
+
}
|
|
104
|
+
return Buffer.from(value, 'base64')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { EncryptedStore, ENCRYPTED_STORE_FORMAT: FORMAT }
|
|
@@ -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 }
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { makeSocket } = require('../socket/make')
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Banyak sesi (banyak nomor) dalam satu proses, semuanya memakai satu store.
|
|
6
|
+
*
|
|
7
|
+
* const sessions = createSessionManager({ store: new FileStore('./sessions'), logger })
|
|
8
|
+
* const a = sessions.open('nomor-a') // dibuat sekali, dipakai ulang
|
|
9
|
+
* const b = sessions.open('nomor-b')
|
|
10
|
+
* await sessions.list() // semua sesi yang tersimpan di store
|
|
11
|
+
* sessions.stats() // ringkasan per sesi + total RAM proses
|
|
12
|
+
*/
|
|
13
|
+
function createSessionManager({ store, ...defaults } = {}) {
|
|
14
|
+
if (!store) throw new Error('createSessionManager: store wajib diisi')
|
|
15
|
+
const live = new Map()
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
open(id, overrides = {}) {
|
|
19
|
+
if (!id || /[:/\\]/.test(id)) throw new Error('id sesi tidak valid (tanpa : / \\)')
|
|
20
|
+
let sock = live.get(id)
|
|
21
|
+
if (!sock) {
|
|
22
|
+
sock = makeSocket({ ...defaults, ...overrides, store, sessionId: id })
|
|
23
|
+
live.set(id, sock)
|
|
24
|
+
}
|
|
25
|
+
return sock
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
get: (id) => live.get(id),
|
|
29
|
+
|
|
30
|
+
/** Hentikan koneksi sebuah sesi. Dengan { purge: true } kredensialnya juga dihapus dari store. */
|
|
31
|
+
async close(id, { purge = false } = {}) {
|
|
32
|
+
const sock = live.get(id)
|
|
33
|
+
if (sock) { await sock.ready; sock.end(); live.delete(id) }
|
|
34
|
+
if (purge) await store.delete(`${id}:creds`)
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
async closeAll() {
|
|
38
|
+
await Promise.all([...live.keys()].map((id) => this.close(id)))
|
|
39
|
+
if (store.close) await store.close()
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
async list() {
|
|
43
|
+
if (!store.keys) throw new Error('store ini tidak mendukung keys()')
|
|
44
|
+
const ids = new Set()
|
|
45
|
+
for (const k of await store.keys()) {
|
|
46
|
+
const i = k.indexOf(':')
|
|
47
|
+
if (i > 0 && k.slice(i + 1) === 'creds') ids.add(k.slice(0, i))
|
|
48
|
+
}
|
|
49
|
+
return [...ids]
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
stats() {
|
|
53
|
+
const m = process.memoryUsage()
|
|
54
|
+
const sessions = {}
|
|
55
|
+
for (const [id, s] of live) sessions[id] = s.stats()
|
|
56
|
+
return { sessions, active: live.size, rssMB: +(m.rss / 1048576).toFixed(1), heapUsedMB: +(m.heapUsed / 1048576).toFixed(1) }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { createSessionManager }
|