@oxidezap/baileyrs 0.0.35 → 0.1.0
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/README.md +77 -24
- package/lib/Compatibility/proto-runtime.js +30 -20
- package/lib/Compatibility/websocket-client.d.ts +23 -2
- package/lib/Compatibility/websocket-client.js +47 -18
- package/lib/Socket/bridge-client-owner.d.ts +89 -0
- package/lib/Socket/bridge-client-owner.js +135 -0
- package/lib/Socket/events.d.ts +31 -0
- package/lib/Socket/events.js +139 -36
- package/lib/Socket/index.d.ts +28 -2
- package/lib/Socket/index.js +432 -131
- package/lib/Socket/messages.d.ts +4 -1
- package/lib/Socket/messages.js +5 -3
- package/lib/Socket/terminal-close-reporter.d.ts +79 -0
- package/lib/Socket/terminal-close-reporter.js +108 -0
- package/lib/Socket/terminal-close.d.ts +39 -0
- package/lib/Socket/terminal-close.js +51 -0
- package/lib/Utils/event-buffer.js +31 -0
- package/lib/Utils/messages.d.ts +11 -0
- package/lib/Utils/messages.js +35 -14
- package/package.json +2 -2
package/lib/Socket/events.js
CHANGED
|
@@ -22,6 +22,7 @@ import { buildGroupCreateStubMessage, buildGroupJoinRequestEvents, buildGroupNot
|
|
|
22
22
|
import { emitMessageUpsert } from '../Compatibility/message-upsert.js';
|
|
23
23
|
import { extractMessageCappingPayload } from './message-capping.js';
|
|
24
24
|
import { mapReachoutTimelock } from './reachout.js';
|
|
25
|
+
import { isReconnectableConnectFailure } from './terminal-close.js';
|
|
25
26
|
const CANONICAL_MESSAGE_EVENT = 'message';
|
|
26
27
|
const MESSAGE_UPSERT_APPEND = 'append';
|
|
27
28
|
const MESSAGE_UPSERT_NOTIFY = 'notify';
|
|
@@ -104,15 +105,72 @@ const clearHistorySyncPausedTimeout = (state) => {
|
|
|
104
105
|
clearTimeout(state.pausedTimeout);
|
|
105
106
|
state.pausedTimeout = undefined;
|
|
106
107
|
};
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Report a disconnect the engine will NOT recover from.
|
|
110
|
+
*
|
|
111
|
+
* `connection: 'close'` carries one meaning on this socket, the same one it
|
|
112
|
+
* carries in upstream Baileys: the socket is finished and the consumer has to
|
|
113
|
+
* build a new one. Disconnects the engine is still retrying never come through
|
|
114
|
+
* here — see `emitRetrying` and `terminal-close.ts`.
|
|
115
|
+
*
|
|
116
|
+
* `onTerminalClose` owns both the teardown and the publish, so the socket can
|
|
117
|
+
* be fully torn down before the consumer sees the event — the order upstream
|
|
118
|
+
* uses, and what keeps a replacement socket from overlapping the old one's
|
|
119
|
+
* store flush. Without that callback (a bare `makeEventHandler`), the close
|
|
120
|
+
* goes out immediately.
|
|
121
|
+
*/
|
|
122
|
+
const emitClose = ({ ctx, callbacks, historySync }, reason, statusCode, data) => {
|
|
123
|
+
// Every terminal path cancels the history-sync pause timer, not just the
|
|
124
|
+
// one in `disconnected`. A transient drop deliberately keeps it armed, so
|
|
125
|
+
// without this a drop followed by a terminal close leaves it to fire a
|
|
126
|
+
// `messaging-history.status: paused` from a socket that has already ended.
|
|
127
|
+
clearHistorySyncPausedTimeout(historySync);
|
|
128
|
+
const error = new Boom(reason, { statusCode, data });
|
|
129
|
+
const publish = () => ctx.ev.emit('connection.update', {
|
|
130
|
+
connection: 'close',
|
|
131
|
+
lastDisconnect: { error, date: new Date() }
|
|
132
|
+
});
|
|
133
|
+
if (callbacks?.onTerminalClose) {
|
|
134
|
+
callbacks.onTerminalClose(error, publish);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
publish();
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* Report a disconnect the engine is retrying on its own.
|
|
141
|
+
*
|
|
142
|
+
* Emitted as `connecting` rather than `close` on purpose. The canonical
|
|
143
|
+
* upstream handler is `if (connection === 'close') reconnect()`, and upstream
|
|
144
|
+
* has no auto-reconnect — so surfacing a transient drop as `close` makes that
|
|
145
|
+
* handler build a second socket while the engine is already backing off to
|
|
146
|
+
* restore the first. Two live sockets for one connection. `connecting` is the
|
|
147
|
+
* state upstream itself uses while a connection is being (re)established, so
|
|
148
|
+
* consumers read this as `open → connecting → open` and stay out of the way.
|
|
149
|
+
*/
|
|
150
|
+
const emitRetrying = (ctx) => ctx.ev.emit('connection.update', {
|
|
151
|
+
connection: 'connecting',
|
|
152
|
+
// Same shape upstream's own `connecting` always carries. Without the
|
|
153
|
+
// explicit clear, a consumer merging partial state keeps rendering the
|
|
154
|
+
// QR from before the drop — one the server has already rotated away.
|
|
155
|
+
qr: undefined,
|
|
156
|
+
receivedPendingNotifications: false
|
|
110
157
|
});
|
|
158
|
+
/**
|
|
159
|
+
* `<failure reason="405">` — the wire code, kept as-is rather than folded into
|
|
160
|
+
* `DisconnectReason`, which has no member for it. See the `clientOutdated`
|
|
161
|
+
* dispatcher for why `badSession` was the wrong home.
|
|
162
|
+
*/
|
|
163
|
+
const CLIENT_OUTDATED_STATUS = 405;
|
|
111
164
|
/**
|
|
112
165
|
* Map bridge `ConnectFailureReason` wire codes (per the bridge's
|
|
113
166
|
* `.d.ts` annotation) onto upstream Baileys' `DisconnectReason`.
|
|
114
167
|
* Unknown codes fall through to `connectionClosed` so existing
|
|
115
168
|
* reconnect heuristics keep working.
|
|
169
|
+
*
|
|
170
|
+
* Several cases here are belt-and-braces: the engine dispatches its own event
|
|
171
|
+
* for `is_logged_out()` reasons (401/403/406) and for 405, so those never
|
|
172
|
+
* reach `connectFailure` in practice. Kept because they cost nothing and the
|
|
173
|
+
* engine's routing is not ours to depend on.
|
|
116
174
|
*/
|
|
117
175
|
const mapConnectFailureToDisconnect = (reason) => {
|
|
118
176
|
switch (reason) {
|
|
@@ -123,7 +181,7 @@ const mapConnectFailureToDisconnect = (reason) => {
|
|
|
123
181
|
case 402: // TempBanned
|
|
124
182
|
return DisconnectReason.forbidden;
|
|
125
183
|
case 405: // ClientOutdated
|
|
126
|
-
return
|
|
184
|
+
return CLIENT_OUTDATED_STATUS;
|
|
127
185
|
case 411: // MultideviceMismatch (legacy alias)
|
|
128
186
|
return DisconnectReason.multideviceMismatch;
|
|
129
187
|
case 503: // ServiceUnavailable
|
|
@@ -168,9 +226,26 @@ const DISPATCHERS = {
|
|
|
168
226
|
offlineSyncCompleted: (_, { ctx }) => emitConnectionUpdate(ctx, {
|
|
169
227
|
receivedPendingNotifications: true
|
|
170
228
|
}),
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
229
|
+
// The engine dispatches `Disconnected` only for an *unexpected* loop exit,
|
|
230
|
+
// and every terminal path marks `expected_disconnect` first — so this is
|
|
231
|
+
// normally "the Fibonacci backoff is already running".
|
|
232
|
+
//
|
|
233
|
+
// The exception is `sock.setAutoReconnect(false)`: `client/lifecycle.rs`
|
|
234
|
+
// dispatches `Disconnected` and only *then* tests the flag and breaks out
|
|
235
|
+
// of the run loop. Treating that as transient would leave the socket
|
|
236
|
+
// reporting `connecting` forever, with the wasm client never freed and the
|
|
237
|
+
// consumer's reconnect handler never firing.
|
|
238
|
+
disconnected: (_, dispatchCtx) => {
|
|
239
|
+
if (dispatchCtx.callbacks?.isAutoReconnectEnabled?.() === false) {
|
|
240
|
+
emitClose(dispatchCtx, 'Connection closed', DisconnectReason.connectionClosed);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
// The pause timer survives a retrying drop. It is the only pending
|
|
244
|
+
// transition to `messaging-history.status: paused`, and this socket
|
|
245
|
+
// lives on — clearing it left a RECENT sync that had reported progress
|
|
246
|
+
// below 100 with neither `paused` nor `complete` if the reconnect
|
|
247
|
+
// produced no further chunk, hanging consumers waiting on hydration.
|
|
248
|
+
emitRetrying(dispatchCtx.ctx);
|
|
174
249
|
},
|
|
175
250
|
qr: (evt, { ctx }) => emitConnectionUpdate(ctx, { qr: evt.code }),
|
|
176
251
|
pairSuccess: (evt, { ctx, callbacks }) => {
|
|
@@ -183,47 +258,57 @@ const DISPATCHERS = {
|
|
|
183
258
|
// a compat hook for upstream's lifecycle.
|
|
184
259
|
ctx.ev.emit('creds.update', { registered: true, me: { id, lid, name: businessName }, platform });
|
|
185
260
|
},
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
261
|
+
// Pairing failed, but the engine keeps its loop and re-emits a QR — nothing
|
|
262
|
+
// about the client is dead, so this must not read as `close`. It still
|
|
263
|
+
// belongs on the bus: the QR the user was shown is spent, and `connecting`
|
|
264
|
+
// clears it while telling the consumer a fresh one is coming.
|
|
265
|
+
pairError: (evt, { ctx }) => {
|
|
266
|
+
ctx.logger.error({ err: evt.error }, 'pairing failed; the engine will retry');
|
|
267
|
+
emitRetrying(ctx);
|
|
268
|
+
},
|
|
269
|
+
loggedOut: (evt, dispatchCtx) => emitClose(dispatchCtx, evt.reason ? `Logged out: ${evt.reason}` : 'Logged out', DisconnectReason.loggedOut),
|
|
270
|
+
connectFailure: (evt, dispatchCtx) => {
|
|
189
271
|
// Map bridge `ConnectFailureReason` wire codes onto Baileys'
|
|
190
272
|
// DisconnectReason. Defaults to connectionClosed for unknown codes.
|
|
191
273
|
// LoggedOut paths (401/403/406) drive bots' "should I re-pair?"
|
|
192
274
|
// branch — folding them into connectionClosed kept that broken.
|
|
275
|
+
// "Reconnectable" is only true while the engine is allowed to reconnect.
|
|
276
|
+
// With `setAutoReconnect(false)` the run loop breaks on the next pass,
|
|
277
|
+
// so reporting `connecting` for a 500/503 would leave the socket stuck
|
|
278
|
+
// in that state with nothing left to restore it.
|
|
279
|
+
if (isReconnectableConnectFailure(evt.reason) && dispatchCtx.callbacks?.isAutoReconnectEnabled?.() !== false) {
|
|
280
|
+
dispatchCtx.ctx.logger.warn({ reason: evt.reason, message: evt.message }, 'connect failure; the engine will retry');
|
|
281
|
+
emitRetrying(dispatchCtx.ctx);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
193
284
|
const status = mapConnectFailureToDisconnect(evt.reason);
|
|
194
|
-
emitClose(
|
|
285
|
+
emitClose(dispatchCtx, evt.message ?? 'Connection failure', status);
|
|
195
286
|
},
|
|
196
|
-
//
|
|
197
|
-
// branch
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
287
|
+
// NOT a close. The engine dispatches `StreamError` only from its catch-all
|
|
288
|
+
// `<stream:error>` branch — an unknown code, an `<ack/>` it still owes the
|
|
289
|
+
// server, or `<xml-not-well-formed>` — and it keeps or deliberately recycles
|
|
290
|
+
// the connection in every one of those. This used to report
|
|
291
|
+
// `DisconnectReason.badSession`, the code bots use to wipe credentials and
|
|
292
|
+
// re-pair, so a routine stream recycle could destroy a working session. The
|
|
293
|
+
// coded stream errors (401/409/515/516) never arrive here; they dispatch
|
|
294
|
+
// `loggedOut` / `streamReplaced` of their own.
|
|
295
|
+
streamError: (evt, { ctx }) => ctx.logger.warn({ code: evt.code }, 'stream error; the connection is preserved'),
|
|
296
|
+
streamReplaced: (_, dispatchCtx) => emitClose(dispatchCtx, 'Connection replaced', DisconnectReason.connectionReplaced),
|
|
297
|
+
// Carries the wire code (405), not `DisconnectReason.badSession`. Upstream
|
|
298
|
+
// does the same for stream errors — `+node.attrs.code` first, `badSession`
|
|
299
|
+
// only as the no-code fallback (`Utils/generics.ts` `getErrorCodeFromStreamError`).
|
|
300
|
+
// badSession is 500, the code bots use to wipe credentials and re-pair; an
|
|
301
|
+
// outdated build is the one failure where wiping a perfectly good session
|
|
302
|
+
// helps nobody, and the next connect fails identically.
|
|
303
|
+
clientOutdated: (_, dispatchCtx) => emitClose(dispatchCtx, 'Client outdated', CLIENT_OUTDATED_STATUS),
|
|
304
|
+
temporaryBan: (evt, dispatchCtx) => {
|
|
211
305
|
// Surface the wire code + expire on the Boom so consumers reading
|
|
212
306
|
// `lastDisconnect.error.data` can act on the specific reason.
|
|
213
307
|
const reason = describeTempBan(evt.code);
|
|
214
308
|
const message = evt.expire
|
|
215
309
|
? `Temporary ban (${reason}); expires at ${new Date(evt.expire * 1000).toISOString()}`
|
|
216
310
|
: `Temporary ban (${reason})`;
|
|
217
|
-
|
|
218
|
-
connection: 'close',
|
|
219
|
-
lastDisconnect: {
|
|
220
|
-
error: new Boom(message, {
|
|
221
|
-
statusCode: DisconnectReason.forbidden,
|
|
222
|
-
data: { code: evt.code, expire: evt.expire }
|
|
223
|
-
}),
|
|
224
|
-
date: new Date()
|
|
225
|
-
}
|
|
226
|
-
});
|
|
311
|
+
emitClose(dispatchCtx, message, DisconnectReason.forbidden, { code: evt.code, expire: evt.expire });
|
|
227
312
|
},
|
|
228
313
|
qrScannedWithoutMultidevice: (_, { ctx }) => ctx.logger.warn('QR scanned but multi-device not enabled on phone'),
|
|
229
314
|
// ── Messages ──
|
|
@@ -784,6 +869,7 @@ export const makeEventHandlers = (ctx, callbacks) => {
|
|
|
784
869
|
callbacks,
|
|
785
870
|
historySync: { initialBootstrapComplete: false, recentSyncComplete: false }
|
|
786
871
|
};
|
|
872
|
+
callbacks?.onCleanup?.(() => clearHistorySyncPausedTimeout(dispatchCtx.historySync));
|
|
787
873
|
const onEvent = (event) => {
|
|
788
874
|
const canonical = adaptBridgeEvent(event, ctx.logger);
|
|
789
875
|
if (canonical)
|
|
@@ -857,12 +943,29 @@ export const makeEventHandlers = (ctx, callbacks) => {
|
|
|
857
943
|
for (const data of acks)
|
|
858
944
|
onEvent({ type: 'server_ack', data });
|
|
859
945
|
};
|
|
946
|
+
// Opting the two packed paths into the bridge's borrowing contract, which
|
|
947
|
+
// lets it hand every batch out of one reused buffer instead of allocating
|
|
948
|
+
// per batch. Both handlers already satisfied the contract unchanged: each
|
|
949
|
+
// decodes as its first act, and `decodeReceiptWireBatch` /
|
|
950
|
+
// `decodeServerAckWireBatch` materialise every field rather than keeping a
|
|
951
|
+
// view, so nothing points at the buffer once they return. Neither is async,
|
|
952
|
+
// and no exception escapes them — the decode is guarded here and the
|
|
953
|
+
// dispatch is guarded in `dispatchCanonicalEvent` — both of which the
|
|
954
|
+
// contract requires, since either one drops the whole session back to a
|
|
955
|
+
// buffer per batch.
|
|
956
|
+
//
|
|
957
|
+
// Declaring a borrowing name replaces its copying counterpart as the sink,
|
|
958
|
+
// so the pair below is the whole opt-in. There is deliberately none for
|
|
959
|
+
// messages: `decodeMessageWireBatch` returns views over the batch, so reuse
|
|
960
|
+
// would alias the decoded result and not just the buffer.
|
|
860
961
|
return {
|
|
861
962
|
onEvent,
|
|
862
963
|
onMessageBatch,
|
|
863
964
|
onHistorySyncBatch,
|
|
864
965
|
onReceiptBatch,
|
|
865
966
|
onServerAckBatch,
|
|
967
|
+
onReceiptBatchBorrowed: onReceiptBatch,
|
|
968
|
+
onServerAckBatchBorrowed: onServerAckBatch,
|
|
866
969
|
historySyncConversationTypes: CONVERSATION_HISTORY_SYNC_TYPES
|
|
867
970
|
};
|
|
868
971
|
};
|
package/lib/Socket/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
|
-
import { type UploadMediaResult
|
|
2
|
+
import { type UploadMediaResult } from '@oxidezap/whatsapp-rust-bridge';
|
|
3
3
|
import { WebSocketClient } from '../Compatibility/websocket-client.js';
|
|
4
4
|
import { type MediaType } from '../Defaults/index.js';
|
|
5
5
|
import type { BinaryNode, AuthenticationCreds, ConnectionState, Contact, ReachoutTimelockState, SignalKeyStoreWithTransaction, UserFacingSocketConfig, WAPrivacyGroupAddValue, WAPrivacyOnlineValue, WAPrivacyValue, WABusinessProfile, WAReadReceiptsValue, WAMessage, WAMessageKey } from '../Types/index.js';
|
|
@@ -120,8 +120,34 @@ declare const makeWASocket: (config: UserFacingSocketConfig) => {
|
|
|
120
120
|
logger: import("../Utils/logger.js").ILogger;
|
|
121
121
|
ws: WebSocketClient;
|
|
122
122
|
type: 'md';
|
|
123
|
+
/**
|
|
124
|
+
* `await using sock = makeWASocket(config)` — frees the wasm client on
|
|
125
|
+
* scope exit. Nothing else can: the bridge holds the JS event callbacks
|
|
126
|
+
* as wasm-bindgen externrefs and those callbacks reach back to this
|
|
127
|
+
* closure, so the reference cycle crosses the JS/wasm boundary and no
|
|
128
|
+
* `FinalizationRegistry` will ever fire for the client.
|
|
129
|
+
*
|
|
130
|
+
* Delegates to `end()`, so it flushes the auth store and is idempotent.
|
|
131
|
+
*
|
|
132
|
+
* Then awaits `initPromise`, because `end()` alone does not satisfy the
|
|
133
|
+
* async-disposal contract: called before `createWhatsAppClient()`
|
|
134
|
+
* settles, it sees `client === undefined`, frees nothing, and resolves
|
|
135
|
+
* while initialization is still running. What actually disposes that
|
|
136
|
+
* client is the `ended` guard inside `init()` — so code after the
|
|
137
|
+
* `await using` scope would otherwise overlap with bridge construction,
|
|
138
|
+
* event callbacks, and store access. `end()` runs first because it sets
|
|
139
|
+
* `ended` synchronously, which is what makes `init()` bail out early.
|
|
140
|
+
*
|
|
141
|
+
* The `finally` is load-bearing: `end()` rethrows the first auth-store
|
|
142
|
+
* flush failure, and letting that propagate directly would skip the wait
|
|
143
|
+
* and resolve the disposer with `init()` still in flight — losing the
|
|
144
|
+
* one guarantee it exists to provide, in exactly the situation where
|
|
145
|
+
* cleanup already went wrong. `initPromise` swallows its own failures,
|
|
146
|
+
* so awaiting it here cannot mask the flush error.
|
|
147
|
+
*/
|
|
148
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
123
149
|
user: Contact | undefined;
|
|
124
|
-
waClient: WasmWhatsAppClient | undefined;
|
|
150
|
+
waClient: import("@oxidezap/whatsapp-rust-bridge").WasmWhatsAppClient | undefined;
|
|
125
151
|
isConnected: boolean;
|
|
126
152
|
isLoggedIn: boolean;
|
|
127
153
|
authState: {
|