@oxidezap/baileyrs 0.1.3 → 0.2.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.
@@ -24,14 +24,17 @@
24
24
  */
25
25
  import { processHistoryMessage } from '../Utils/process-history-message.js';
26
26
  import { isJidGroup } from '../WABinary/jid-utils.js';
27
- import { asBoolOr, asJidAddressString, asJidString, asNumber, asString, isObject, normalizeDiscriminator, toUnixSeconds } from './primitives.js';
27
+ import { absoluteFromDuration, asBool, asBoolOr, asStringArray, asDurationSeconds, asInt64, asJidAddressString, asJidString, asNumber, asString, asUnixSeconds, isObject, normalizeDiscriminator, toUnixSeconds } from './primitives.js';
28
28
  /**
29
29
  * Bridge sync-action events (`pin_update`, `mute_update`, etc.) carry the
30
30
  * proto action under `data.action`, but the bridge `.d.ts` types it as
31
31
  * the unexported `PinAction` / `MuteAction` (resolves to `any`). Narrow
32
32
  * once at the call site.
33
33
  */
34
- const extractAction = (data) => isObject(data.action) ? data.action : undefined;
34
+ // Tolerates a missing `data` slot: the adapter table has to be total against
35
+ // whatever the runtime sends, and an entry that reads the action before any
36
+ // other guard would otherwise throw rather than drop (`bridge:adapt-total`).
37
+ const extractAction = (data) => isObject(data?.action) ? data.action : undefined;
35
38
  /** A group JID is authoritative when an older producer leaves `is_group` false. */
36
39
  const resolveIsGroup = (wireValue, chatJid) => asBoolOr(wireValue, false) || isJidGroup(chatJid) === true;
37
40
  const resolveParticipantAlt = (senderAlt, isGroup) => isGroup ? senderAlt : undefined;
@@ -60,7 +63,12 @@ const ADAPTERS = {
60
63
  temporary_ban: data => ({
61
64
  type: 'temporaryBan',
62
65
  code: asNumber(data?.code),
63
- expire: asNumber(data?.expire)
66
+ // The wire sends how long the ban lasts, and WA Web renders it that way
67
+ // ("you'll be able to use WhatsApp again in {duration}"). The canonical
68
+ // event promises the instant it lifts, which is what the socket formats
69
+ // and what a reconnect delay subtracts `Date.now()` from — so the
70
+ // conversion happens here, once, rather than in each of them.
71
+ expire: absoluteFromDuration(asDurationSeconds(data?.expire))
64
72
  }),
65
73
  qr_scanned_without_multidevice: () => ({ type: 'qrScannedWithoutMultidevice' }),
66
74
  logged_out: data => ({
@@ -68,51 +76,51 @@ const ADAPTERS = {
68
76
  onConnect: asBoolOr(data?.on_connect, false),
69
77
  reason: asString(data?.reason)
70
78
  }),
71
- qr: data => (data.code ? { type: 'qr', code: data.code } : null),
72
- pairing_code: data => (data.code ? { type: 'qr', code: data.code } : null),
79
+ qr: data => (data?.code ? { type: 'qr', code: data?.code } : null),
80
+ pairing_code: data => (data?.code ? { type: 'qr', code: data?.code } : null),
73
81
  pairing_code_refresh: data => ({
74
82
  type: 'noop',
75
83
  bridgeType: 'pairing_code_refresh',
76
- detail: data.force_manual ? 'force_manual' : 'automatic'
84
+ detail: data?.force_manual ? 'force_manual' : 'automatic'
77
85
  }),
78
86
  pair_passkey_request: () => ({ type: 'noop', bridgeType: 'pair_passkey_request' }),
79
87
  pair_passkey_confirmation: data => ({
80
88
  type: 'noop',
81
89
  bridgeType: 'pair_passkey_confirmation',
82
- detail: data.skip_handoff_ux ? 'handoff_verified' : 'confirmation_required'
90
+ detail: data?.skip_handoff_ux ? 'handoff_verified' : 'confirmation_required'
83
91
  }),
84
92
  pair_passkey_error: data => ({
85
93
  type: 'noop',
86
94
  bridgeType: 'pair_passkey_error',
87
- detail: asString(data.error)
95
+ detail: asString(data?.error)
88
96
  }),
89
97
  pair_success: data => {
90
98
  // `id` and `lid` come typed as `Jid` in the bridge .d.ts, but the
91
99
  // bridge actually serializes pair_success.{id,lid} as strings (see
92
100
  // the wire log) — accept both shapes.
93
- const id = typeof data.id === 'string' ? data.id : asJidString(data.id);
101
+ const id = typeof data?.id === 'string' ? data?.id : asJidString(data?.id);
94
102
  if (!id)
95
103
  return null;
96
104
  return {
97
105
  type: 'pairSuccess',
98
106
  id,
99
- lid: typeof data.lid === 'string' ? data.lid : asJidString(data.lid),
100
- platform: asString(data.platform),
101
- businessName: asString(data.business_name)
107
+ lid: typeof data?.lid === 'string' ? data?.lid : asJidString(data?.lid),
108
+ platform: asString(data?.platform),
109
+ businessName: asString(data?.business_name)
102
110
  };
103
111
  },
104
112
  pair_error: data => ({
105
113
  type: 'pairError',
106
- error: asString(data.error) ?? 'Unknown pairing error',
107
- id: typeof data.id === 'string' ? data.id : asJidString(data.id),
108
- lid: typeof data.lid === 'string' ? data.lid : asJidString(data.lid),
109
- businessName: asString(data.business_name),
110
- platform: asString(data.platform)
114
+ error: asString(data?.error) ?? 'Unknown pairing error',
115
+ id: typeof data?.id === 'string' ? data?.id : asJidString(data?.id),
116
+ lid: typeof data?.lid === 'string' ? data?.lid : asJidString(data?.lid),
117
+ businessName: asString(data?.business_name),
118
+ platform: asString(data?.platform)
111
119
  }),
112
120
  connect_failure: data => isObject(data)
113
- ? { type: 'connectFailure', message: asString(data.message), reason: asNumber(data.reason) }
121
+ ? { type: 'connectFailure', message: asString(data?.message), reason: asNumber(data?.reason) }
114
122
  : { type: 'connectFailure' },
115
- stream_error: data => ({ type: 'streamError', code: asString(data.code) ?? 'unknown' }),
123
+ stream_error: data => ({ type: 'streamError', code: asString(data?.code) ?? 'unknown' }),
116
124
  // ── Messages ──
117
125
  message: (data, logger) => adaptMessage(data, logger),
118
126
  receipt: (data, logger) => adaptReceipt(data, logger),
@@ -121,16 +129,16 @@ const ADAPTERS = {
121
129
  // while NACKs become `messages.update` with status ERROR. Preserve every
122
130
  // typed field here so the dispatcher can reproduce both surfaces.
123
131
  server_ack: data => {
124
- const id = asString(data.id);
132
+ const id = asString(data?.id);
125
133
  if (!id)
126
134
  return null;
127
135
  return {
128
136
  type: 'serverAck',
129
137
  id,
130
- class: asString(data.class),
131
- from: asJidString(data.from),
132
- timestamp: asNumber(data.timestamp),
133
- error: asString(data.error)
138
+ class: asString(data?.class),
139
+ from: asJidString(data?.from),
140
+ timestamp: asUnixSeconds(data?.timestamp),
141
+ error: asString(data?.error)
134
142
  };
135
143
  },
136
144
  undecryptable_message: data => {
@@ -141,7 +149,7 @@ const ADAPTERS = {
141
149
  // `messages-recv.ts:1352`.
142
150
  if (!isObject(data))
143
151
  return null;
144
- const info = isObject(data.info) ? data.info : undefined;
152
+ const info = isObject(data?.info) ? data?.info : undefined;
145
153
  if (!info)
146
154
  return null;
147
155
  const src = isObject(info.source) ? info.source : undefined;
@@ -164,45 +172,45 @@ const ADAPTERS = {
164
172
  pushName: asString(info.push_name),
165
173
  participantAlt: resolveParticipantAlt(senderAlt, isGroup),
166
174
  remoteJidAlt: resolveRemoteJidAlt(senderAlt, recipientAlt, isGroup, isFromMe),
167
- isUnavailable: asBoolOr(data.is_unavailable, false),
168
- unavailableType: asString(data.unavailable_type),
169
- decryptFailMode: asString(data.decrypt_fail_mode),
175
+ isUnavailable: asBoolOr(data?.is_unavailable, false),
176
+ unavailableType: asString(data?.unavailable_type),
177
+ decryptFailMode: asString(data?.decrypt_fail_mode),
170
178
  raw: data
171
179
  };
172
180
  },
173
181
  // ── Contacts ──
174
182
  push_name_update: data => {
175
- const jid = asJidString(data.jid);
176
- return jid ? { type: 'pushNameUpdate', jid, newPushName: asString(data.new_push_name) } : null;
183
+ const jid = asJidString(data?.jid);
184
+ return jid ? { type: 'pushNameUpdate', jid, newPushName: asString(data?.new_push_name) } : null;
177
185
  },
178
186
  contact_update: data => adaptContactUpdate(data),
179
187
  contact_updated: data => adaptContactUpdate(data),
180
188
  picture_update: data => {
181
- const jid = asJidString(data.jid);
189
+ const jid = asJidString(data?.jid);
182
190
  if (!jid)
183
191
  return null;
184
192
  return {
185
193
  type: 'pictureUpdate',
186
194
  jid,
187
- removed: asBoolOr(data.removed, false),
188
- author: asJidString(data.author),
189
- pictureId: asString(data.picture_id)
195
+ removed: asBoolOr(data?.removed, false),
196
+ author: asJidString(data?.author),
197
+ pictureId: asString(data?.picture_id)
190
198
  };
191
199
  },
192
200
  // ── Presence ──
193
201
  presence: data => {
194
- const from = asJidString(data.from);
202
+ const from = asJidString(data?.from);
195
203
  if (!from)
196
204
  return null;
197
205
  return {
198
206
  type: 'presence',
199
207
  from,
200
- unavailable: asBoolOr(data.unavailable, false),
201
- lastSeen: asNumber(data.last_seen)
208
+ unavailable: asBoolOr(data?.unavailable, false),
209
+ lastSeen: asUnixSeconds(data?.last_seen)
202
210
  };
203
211
  },
204
212
  chat_presence: data => {
205
- const src = isObject(data.source) ? data.source : undefined;
213
+ const src = isObject(data?.source) ? data?.source : undefined;
206
214
  if (!src)
207
215
  return null;
208
216
  const chat = asJidString(src.chat);
@@ -211,11 +219,11 @@ const ADAPTERS = {
211
219
  return null;
212
220
  // Bridge sends `media: ''` for "no media" — normalize to undefined
213
221
  // so consumers can rely on field omission as the absence signal.
214
- const media = asString(data.media);
222
+ const media = asString(data?.media);
215
223
  // Bridge `state` MUST be one of the two canonical values; defaulting
216
224
  // to 'composing' on an unknown/missing value would synthesize a
217
225
  // false typing indicator. Drop the event instead.
218
- const rawState = asString(data.state);
226
+ const rawState = asString(data?.state);
219
227
  if (rawState !== 'composing' && rawState !== 'paused')
220
228
  return null;
221
229
  return {
@@ -230,44 +238,44 @@ const ADAPTERS = {
230
238
  group_update: (data, logger) => adaptGroupUpdate(data, logger),
231
239
  // ── Chat state ──
232
240
  archive_update: data => {
233
- const jid = asJidString(data.jid);
241
+ const jid = asJidString(data?.jid);
234
242
  if (!jid)
235
243
  return null;
236
244
  return { type: 'archiveUpdate', jid, archived: asBoolOr(extractAction(data)?.archived, true) };
237
245
  },
238
246
  pin_update: data => {
239
- const jid = asJidString(data.jid);
247
+ const jid = asJidString(data?.jid);
240
248
  if (!jid)
241
249
  return null;
242
250
  return {
243
251
  type: 'pinUpdate',
244
252
  jid,
245
- timestamp: asNumber(data.timestamp),
253
+ timestamp: asUnixSeconds(data?.timestamp),
246
254
  pinned: asBoolOr(extractAction(data)?.pinned, true)
247
255
  };
248
256
  },
249
257
  mute_update: data => {
250
- const jid = asJidString(data.jid);
258
+ const jid = asJidString(data?.jid);
251
259
  if (!jid)
252
260
  return null;
253
261
  const action = extractAction(data);
254
262
  return {
255
263
  type: 'muteUpdate',
256
264
  jid,
257
- timestamp: asNumber(data.timestamp),
265
+ timestamp: asUnixSeconds(data?.timestamp),
258
266
  muted: asBoolOr(action?.muted, true),
259
- muteEndTimestamp: asNumber(action?.muteEndTimestamp) ?? asNumber(action?.mute_end_timestamp)
267
+ muteEndTimestamp: asInt64(action?.muteEndTimestamp) ?? asInt64(action?.mute_end_timestamp)
260
268
  };
261
269
  },
262
270
  star_update: data => adaptStarUpdate(data),
263
271
  mark_chat_as_read_update: data => {
264
- const jid = asJidString(data.jid);
272
+ const jid = asJidString(data?.jid);
265
273
  if (!jid)
266
274
  return null;
267
275
  return { type: 'markChatAsReadUpdate', jid, read: asBoolOr(extractAction(data)?.read, true) };
268
276
  },
269
277
  label_edit_update: data => {
270
- const labelId = asString(data.label_id);
278
+ const labelId = asString(data?.label_id);
271
279
  if (!labelId)
272
280
  return { type: 'noop', bridgeType: 'label_edit_update' };
273
281
  const action = extractAction(data);
@@ -284,39 +292,128 @@ const ADAPTERS = {
284
292
  };
285
293
  },
286
294
  label_association_update: data => {
287
- const labelId = asString(data.label_id);
288
- const chatJid = asJidString(data.chat_jid);
295
+ const labelId = asString(data?.label_id);
296
+ const chatJid = asJidString(data?.chat_jid);
289
297
  if (!labelId || !chatJid)
290
298
  return { type: 'noop', bridgeType: 'label_association_update' };
291
299
  // `action.labeled === true` → label added to the chat, else removed.
292
300
  return { type: 'labelAssociation', labelId, chatJid, labeled: asBoolOr(extractAction(data)?.labeled, true) };
293
301
  },
302
+ /**
303
+ * The per-message half of `labels.association`, which used to have no path.
304
+ * Same canonical event as the chat one, told apart by carrying a message.
305
+ */
306
+ message_label_association_update: data => {
307
+ const labelId = asString(data?.label_id);
308
+ const chatJid = asJidString(data?.chat_jid);
309
+ const messageId = asString(data?.message_id);
310
+ if (!labelId || !chatJid || !messageId) {
311
+ return { type: 'noop', bridgeType: 'message_label_association_update' };
312
+ }
313
+ return {
314
+ type: 'labelAssociation',
315
+ labelId,
316
+ chatJid,
317
+ messageId,
318
+ labeled: asBoolOr(extractAction(data)?.labeled, true)
319
+ };
320
+ },
321
+ /**
322
+ * What a degraded app-state sync left behind. The engine announces the
323
+ * connection anyway, so without this a consumer is told a session with no
324
+ * push name is healthy and has nothing to read that says otherwise.
325
+ */
326
+ app_state_sync_failed: data => ({
327
+ type: 'appStateSyncFailed',
328
+ fatal: asStringArray(data?.fatal),
329
+ retryable: asStringArray(data?.retryable),
330
+ skipped: asStringArray(data?.skipped),
331
+ connected: asBoolOr(data?.connected, false)
332
+ }),
333
+ /**
334
+ * The QR refs ran out. Upstream ends the socket with `timedOut` when its own
335
+ * QR timer gives up (`Socket/socket.ts`), which is the same end state, so
336
+ * this becomes the same terminal close rather than a new signal to learn.
337
+ */
338
+ pairing_qr_codes_exhausted: () => ({ type: 'qrCodesExhausted' }),
339
+ /**
340
+ * Another linked device turned link previews on or off account-wide.
341
+ *
342
+ * Upstream carries this on `settings.update`, reached through app state
343
+ * (`Utils/chat-utils.ts` branches on `privacySettingDisableLinkPreviewsAction`
344
+ * and emits the action as the value). Same event, different pipe — so the
345
+ * value is the action itself, not the decoded flag.
346
+ *
347
+ * `previews_disabled` is that flag already decoded by the bridge, which is
348
+ * what fills the action in when the payload carried the flag alone. The
349
+ * bridge only emits this event when the wire carried the flag, so the last
350
+ * fallback is unreachable in practice and exists so the value always has
351
+ * the field upstream's consumers read.
352
+ */
353
+ disable_link_previews_update: data => {
354
+ const action = extractAction(data);
355
+ return {
356
+ type: 'settingUpdate',
357
+ setting: 'disableLinkPreviews',
358
+ value: {
359
+ ...action,
360
+ isPreviewsDisabled: asBool(action?.isPreviewsDisabled) ?? asBoolOr(data?.previews_disabled, false)
361
+ }
362
+ };
363
+ },
364
+ /**
365
+ * A pair-code request failed, so any code the user was shown is spent.
366
+ *
367
+ * Same lifecycle as `pair_error`, which is why it adapts to the same
368
+ * canonical event: the socket lives on and the engine takes another
369
+ * request, so this must not read as a close, and the code on screen has to
370
+ * stop being offered. `pairError` is the handler that does both — a
371
+ * pairing code surfaces as `qr` (see `pairing_code` above), and `connecting`
372
+ * clears it while saying a fresh one can be asked for.
373
+ *
374
+ * `rejection` and `backoff` ride along for the log. Neither changes what a
375
+ * consumer does here, and the engine owns the retry — but a throttle the
376
+ * server named itself is the difference between a code that will come back
377
+ * and one that will not, and dropping it leaves that unexplained.
378
+ */
379
+ pairing_code_error: data => ({
380
+ type: 'pairError',
381
+ error: asString(data?.error) ?? 'pairing code rejected',
382
+ rejection: asNumber(data?.rejection),
383
+ backoff: asNumber(data?.backoff)
384
+ }),
385
+ // Acknowledged with no Baileys equivalent: upstream has no channel for a
386
+ // contact deletion (`contacts.update` only upserts), for quick replies, or
387
+ // for a call placed on the phone.
388
+ contact_removed: () => ({ type: 'noop', bridgeType: 'contact_removed' }),
389
+ quick_reply_update: () => ({ type: 'noop', bridgeType: 'quick_reply_update' }),
390
+ call_log_sync: () => ({ type: 'noop', bridgeType: 'call_log_sync' }),
294
391
  // ── Calls ──
295
392
  incoming_call: (data, logger) => adaptIncomingCall(data, logger),
296
393
  missed_call: data => {
297
- const from = asJidString(data.from);
298
- const callId = asString(data.call_id);
394
+ const from = asJidString(data?.from);
395
+ const callId = asString(data?.call_id);
299
396
  if (!from || !callId)
300
397
  return null;
301
398
  return {
302
399
  type: 'incomingCall',
303
400
  from,
304
- timestamp: toUnixSeconds(data.timestamp),
305
- offline: data.reason === 'offline',
401
+ timestamp: toUnixSeconds(data?.timestamp),
402
+ offline: data?.reason === 'offline',
306
403
  action: { type: 'timeout', callId }
307
404
  };
308
405
  },
309
406
  call_ended_elsewhere: data => {
310
- const from = asJidString(data.from);
311
- const callId = asString(data.call_id);
407
+ const from = asJidString(data?.from);
408
+ const callId = asString(data?.call_id);
312
409
  if (!from || !callId)
313
410
  return null;
314
411
  return {
315
412
  type: 'incomingCall',
316
413
  from,
317
- timestamp: toUnixSeconds(data.timestamp),
414
+ timestamp: toUnixSeconds(data?.timestamp),
318
415
  offline: false,
319
- action: { type: data.outcome === 'accepted' ? 'accept' : 'reject', callId }
416
+ action: { type: data?.outcome === 'accepted' ? 'accept' : 'reject', callId }
320
417
  };
321
418
  },
322
419
  // ── History sync — fully decoded by the bridge, normalized 1:1 with upstream ──
@@ -361,35 +458,35 @@ const ADAPTERS = {
361
458
  self_push_name_updated: () => ({ type: 'noop', bridgeType: 'self_push_name_updated' }),
362
459
  offline_sync_completed: data => ({
363
460
  type: 'offlineSyncCompleted',
364
- count: asNumber(data.count) ?? 0
461
+ count: asNumber(data?.count) ?? 0
365
462
  }),
366
463
  offline_sync_preview: () => ({ type: 'noop', bridgeType: 'offline_sync_preview' }),
367
464
  dirty_state: data => {
368
- const dirtyType = asString(data.dirty_type);
465
+ const dirtyType = asString(data?.dirty_type);
369
466
  if (!dirtyType)
370
467
  return null;
371
- return { type: 'dirtyState', dirtyType, timestamp: asNumber(data.timestamp) };
468
+ return { type: 'dirtyState', dirtyType, timestamp: asNumber(data?.timestamp) };
372
469
  },
373
470
  device_list_update: () => ({ type: 'noop', bridgeType: 'device_list_update' }),
374
471
  identity_change: () => ({ type: 'noop', bridgeType: 'identity_change' }),
375
472
  disappearing_mode_changed: data => {
376
- const jid = asJidString(data.from);
377
- const duration = asNumber(data.duration);
473
+ const jid = asJidString(data?.from);
474
+ const duration = asNumber(data?.duration);
378
475
  if (!jid || duration == null)
379
476
  return { type: 'noop', bridgeType: 'disappearing_mode_changed' };
380
477
  return {
381
478
  type: 'disappearingModeChanged',
382
479
  jid,
383
480
  duration,
384
- settingTimestamp: asNumber(data.setting_timestamp)
481
+ settingTimestamp: asNumber(data?.setting_timestamp)
385
482
  };
386
483
  },
387
484
  business_status_update: () => ({ type: 'noop', bridgeType: 'business_status_update' }),
388
485
  newsletter_live_update: data => {
389
- const newsletterJid = asJidString(data.newsletter_jid);
486
+ const newsletterJid = asJidString(data?.newsletter_jid);
390
487
  if (!newsletterJid)
391
488
  return { type: 'noop', bridgeType: 'newsletter_live_update' };
392
- const rawMessages = Array.isArray(data.messages) ? data.messages : [];
489
+ const rawMessages = Array.isArray(data?.messages) ? data?.messages : [];
393
490
  const messages = rawMessages
394
491
  .map(m => {
395
492
  if (!isObject(m))
@@ -422,10 +519,10 @@ const ADAPTERS = {
422
519
  // (old_lid, old_jid) and (new_lid, new_jid). We learn whatever's
423
520
  // present and let the dispatcher fan out one upstream event per
424
521
  // pair. Mirrors upstream `messages-recv.ts:287`.
425
- const oldJid = asJidString(data.old_jid);
426
- const newJid = asJidString(data.new_jid);
427
- const oldLid = asJidString(data.old_lid);
428
- const newLid = asJidString(data.new_lid);
522
+ const oldJid = asJidString(data?.old_jid);
523
+ const newJid = asJidString(data?.new_jid);
524
+ const oldLid = asJidString(data?.old_lid);
525
+ const newLid = asJidString(data?.new_lid);
429
526
  const mappings = [];
430
527
  if (oldLid && oldJid)
431
528
  mappings.push({ lid: oldLid, pn: oldJid });
@@ -438,30 +535,30 @@ const ADAPTERS = {
438
535
  contact_sync_requested: () => ({ type: 'noop', bridgeType: 'contact_sync_requested' }),
439
536
  user_about_update: () => ({ type: 'noop', bridgeType: 'user_about_update' }),
440
537
  delete_chat_update: data => {
441
- const jid = asJidString(data.jid);
538
+ const jid = asJidString(data?.jid);
442
539
  return jid ? { type: 'chatDelete', jid } : { type: 'noop', bridgeType: 'delete_chat_update' };
443
540
  },
444
541
  clear_chat_update: data => {
445
542
  // Clear = drop all messages but keep the chat. Maps to upstream
446
543
  // `messages.delete` `{ jid, all: true }` (the chat-clear surface noted in
447
544
  // the messageDelete dispatcher), distinct from chatDelete (whole chat gone).
448
- const jid = asJidString(data.jid);
545
+ const jid = asJidString(data?.jid);
449
546
  return jid ? { type: 'chatClear', jid } : { type: 'noop', bridgeType: 'clear_chat_update' };
450
547
  },
451
548
  // Muting a contact's status (stories) updates. Forwarded for surface completeness,
452
549
  // but noop'd: upstream Baileys has no status-mute event/chatModify to map it onto.
453
550
  user_status_mute_update: () => ({ type: 'noop', bridgeType: 'user_status_mute_update' }),
454
551
  delete_message_for_me_update: data => {
455
- const chatJid = asJidString(data.chat_jid);
456
- const messageId = asString(data.message_id);
552
+ const chatJid = asJidString(data?.chat_jid);
553
+ const messageId = asString(data?.message_id);
457
554
  if (!chatJid || !messageId)
458
555
  return { type: 'noop', bridgeType: 'delete_message_for_me_update' };
459
556
  return {
460
557
  type: 'messageDelete',
461
558
  chatJid,
462
559
  messageId,
463
- fromMe: asBoolOr(data.from_me, false),
464
- participantJid: asJidString(data.participant_jid)
560
+ fromMe: asBoolOr(data?.from_me, false),
561
+ participantJid: asJidString(data?.participant_jid)
465
562
  };
466
563
  },
467
564
  // ── Generic notification / raw node ──
@@ -474,8 +571,8 @@ const ADAPTERS = {
474
571
  // CanonicalNotification.attrs (typed `Record<string, string>`).
475
572
  // Drop non-string values, coerce the rest.
476
573
  const attrs = {};
477
- if (isObject(data.attrs)) {
478
- for (const [k, v] of Object.entries(data.attrs)) {
574
+ if (isObject(data?.attrs)) {
575
+ for (const [k, v] of Object.entries(data?.attrs)) {
479
576
  if (typeof v === 'string')
480
577
  attrs[k] = v;
481
578
  else if (typeof v === 'number' || typeof v === 'boolean')
@@ -484,26 +581,26 @@ const ADAPTERS = {
484
581
  // sense on a flat attrs map anyway.
485
582
  }
486
583
  }
487
- return { type: 'notification', tag: asString(data.tag) ?? 'notification', attrs };
584
+ return { type: 'notification', tag: asString(data?.tag) ?? 'notification', attrs };
488
585
  },
489
586
  raw_node: data => {
490
587
  // `BinaryNode` is shaped exactly like the bridge payload
491
588
  // (`{ tag, attrs, content }`). Minimal sanity check on `tag`.
492
- if (!isObject(data) || typeof data.tag !== 'string')
589
+ if (!isObject(data) || typeof data?.tag !== 'string')
493
590
  return null;
494
591
  return { type: 'rawNode', node: data };
495
592
  },
496
593
  mex_notification: data => {
497
- const opName = asString(data.op_name);
594
+ const opName = asString(data?.op_name);
498
595
  if (!opName)
499
596
  return null;
500
- const payload = isObject(data.payload) ? data.payload : {};
597
+ const payload = isObject(data?.payload) ? data?.payload : {};
501
598
  return {
502
599
  type: 'mexNotification',
503
600
  opName,
504
- from: asJidString(data.from),
505
- stanzaId: asString(data.stanza_id),
506
- offline: asBoolOr(data.offline, false),
601
+ from: asJidString(data?.from),
602
+ stanzaId: asString(data?.stanza_id),
603
+ offline: asBoolOr(data?.offline, false),
507
604
  payload
508
605
  };
509
606
  }
@@ -516,11 +613,35 @@ export const KNOWN_BRIDGE_EVENT_TYPES = new Set(Object.keys(ADAPTERS));
516
613
  */
517
614
  export const adaptBridgeEventViaSchema = (event, logger) => {
518
615
  const typed = event;
519
- const adapter = ADAPTERS[typed.type];
520
- if (!adapter) {
616
+ // An own property of the table, not anything the prototype chain answers.
617
+ //
618
+ // The type string comes from the runtime, which gets it from the server, so it
619
+ // is untrusted input into a plain-object lookup. `ADAPTERS.constructor` and
620
+ // `ADAPTERS.toString` resolve to inherited functions: they were called and
621
+ // their return values handed on as canonical events — measured, `constructor`
622
+ // produced an object and `toString` a string, neither of which is an event.
623
+ // `__proto__` and `valueOf` reached the other failure mode and threw.
624
+ if (!Object.hasOwn(ADAPTERS, typed.type)) {
521
625
  logger?.debug({ eventType: typed.type }, 'unknown bridge event (no canonical mapping)');
522
626
  return null;
523
627
  }
628
+ const adapter = ADAPTERS[typed.type];
629
+ // The slot is passed through exactly as it arrived, nullish included.
630
+ //
631
+ // The contract these adapters are documented under is "null on unrecoverable
632
+ // shape mismatch, never a throw", and a throw here does not stay local: it
633
+ // propagates into the socket's event dispatch and takes down the whole event
634
+ // loop rather than the one event. 30 of the 58 declared types threw on an
635
+ // event that arrived with no data — `data.code` on `undefined` — so every
636
+ // adapter in the table now reads its slot optionally.
637
+ //
638
+ // Substituting an empty object here instead was tried and reverted: `{}` is a
639
+ // perfectly good object, so it walks straight past the `isObject(data)` guard
640
+ // that several adapters use to decide the payload is unrecoverable.
641
+ // `history_sync` then built an empty final batch and the socket emitted a
642
+ // spurious `messaging-history.set` where it had previously dropped the event.
643
+ // Each adapter owns that decision; the dispatch must not pre-empt it.
644
+ //
524
645
  // `data` cast here is the only `as` in the public path — the bridge
525
646
  // runtime is the source of truth that the type matches the discriminator.
526
647
  return adapter(typed.data, logger);
@@ -37,6 +37,17 @@ export interface CanonicalPairSuccess {
37
37
  export interface CanonicalPairError {
38
38
  type: 'pairError';
39
39
  error: string;
40
+ /**
41
+ * The server's own refusal code, when it answered with one.
42
+ *
43
+ * Absent when the failure was local (validation, no connection) or the
44
+ * request went unanswered — nothing was refused, so there is no status.
45
+ * Carried for the log only: the consumer-visible outcome is the same
46
+ * either way, and the engine owns the retry.
47
+ */
48
+ rejection?: number;
49
+ /** Seconds the server asked the client to wait before asking again. */
50
+ backoff?: number;
40
51
  /** Account JID after pairing (may be set even on error). */
41
52
  id?: string;
42
53
  /** LID for the account. */
@@ -445,9 +456,57 @@ export interface CanonicalLabelAssociation {
445
456
  type: 'labelAssociation';
446
457
  labelId: string;
447
458
  chatJid: string;
448
- /** `true` = label added to the chat, `false` = removed. */
459
+ /**
460
+ * The message the label is on, for a per-message association.
461
+ *
462
+ * Absent means the label is on the chat itself. The two arrive on separate
463
+ * bridge events and upstream carries both on `labels.association`, keyed by
464
+ * the association's own `type`.
465
+ */
466
+ messageId?: string;
467
+ /** `true` = label added, `false` = removed. */
449
468
  labeled: boolean;
450
469
  }
470
+ /**
471
+ * The server ran out of QR refs before anyone scanned one.
472
+ *
473
+ * Terminal: nothing else is coming on this socket, and the consumer builds a
474
+ * new one to be shown a fresh code.
475
+ */
476
+ export interface CanonicalQrCodesExhausted {
477
+ type: 'qrCodesExhausted';
478
+ }
479
+ /**
480
+ * A batched app-state sync that did not leave every collection synced.
481
+ *
482
+ * Collections are named as they appear on the wire. `fatal` is the one a
483
+ * consumer usually has to act on: the server refused it and asking again gets
484
+ * the same answer. `connected` says whether the session was announced anyway,
485
+ * which is the difference between "degraded but usable" and "still retrying" —
486
+ * the engine connects on a degraded sync rather than withholding the session,
487
+ * so this event is the only thing that says what is missing from it.
488
+ */
489
+ export interface CanonicalAppStateSyncFailed {
490
+ type: 'appStateSyncFailed';
491
+ fatal: string[];
492
+ retryable: string[];
493
+ skipped: string[];
494
+ connected: boolean;
495
+ }
496
+ /**
497
+ * An account-wide setting another linked device changed.
498
+ *
499
+ * Upstream reaches the same place through app state — `Utils/chat-utils.ts`
500
+ * emits `settings.update` for a `privacySettingDisableLinkPreviewsAction` —
501
+ * so this is that channel's payload arriving over a different pipe, not a
502
+ * new contract. The shape is the event's rather than one setting's: another
503
+ * setting the bridge starts reporting is a new arm here, not a new event.
504
+ */
505
+ export interface CanonicalSettingUpdate {
506
+ type: 'settingUpdate';
507
+ setting: 'disableLinkPreviews';
508
+ value: proto.SyncActionValue.IPrivacySettingDisableLinkPreviewsAction;
509
+ }
451
510
  export type CanonicalCallActionType = 'offer' | 'preaccept' | 'transport' | 'relaylatency' | 'accept' | 'reject' | 'terminate' | 'timeout';
452
511
  export interface CanonicalCallAction {
453
512
  type: CanonicalCallActionType;
@@ -654,5 +713,5 @@ export interface CanonicalServerAck {
654
713
  timestamp?: number;
655
714
  error?: string;
656
715
  }
657
- export type CanonicalEvent = CanonicalConnected | CanonicalDisconnected | CanonicalQR | CanonicalPairSuccess | CanonicalPairError | CanonicalLoggedOut | CanonicalConnectFailure | CanonicalStreamError | CanonicalStreamReplaced | CanonicalClientOutdated | CanonicalTemporaryBan | CanonicalQrScannedWithoutMultidevice | CanonicalMessage | CanonicalReceipt | CanonicalPushNameUpdate | CanonicalContactUpdate | CanonicalPictureUpdate | CanonicalPresence | CanonicalChatPresence | CanonicalGroupUpdate | CanonicalArchiveUpdate | CanonicalPinUpdate | CanonicalMuteUpdate | CanonicalStarUpdate | CanonicalMarkChatAsReadUpdate | CanonicalLabelEdit | CanonicalLabelAssociation | CanonicalIncomingCall | CanonicalUndecryptableMessage | CanonicalLidMappingUpdate | CanonicalNewsletterLiveUpdate | CanonicalChatDelete | CanonicalChatClear | CanonicalMessageDelete | CanonicalDisappearingModeChanged | CanonicalHistorySync | CanonicalOfflineSyncCompleted | CanonicalDirtyState | CanonicalRawNode | CanonicalNotification | CanonicalMexNotification | CanonicalServerAck | CanonicalNoop;
716
+ export type CanonicalEvent = CanonicalConnected | CanonicalDisconnected | CanonicalQR | CanonicalPairSuccess | CanonicalPairError | CanonicalLoggedOut | CanonicalConnectFailure | CanonicalStreamError | CanonicalStreamReplaced | CanonicalClientOutdated | CanonicalTemporaryBan | CanonicalQrScannedWithoutMultidevice | CanonicalMessage | CanonicalReceipt | CanonicalPushNameUpdate | CanonicalContactUpdate | CanonicalPictureUpdate | CanonicalPresence | CanonicalChatPresence | CanonicalGroupUpdate | CanonicalArchiveUpdate | CanonicalPinUpdate | CanonicalMuteUpdate | CanonicalStarUpdate | CanonicalMarkChatAsReadUpdate | CanonicalLabelEdit | CanonicalLabelAssociation | CanonicalAppStateSyncFailed | CanonicalQrCodesExhausted | CanonicalSettingUpdate | CanonicalIncomingCall | CanonicalUndecryptableMessage | CanonicalLidMappingUpdate | CanonicalNewsletterLiveUpdate | CanonicalChatDelete | CanonicalChatClear | CanonicalMessageDelete | CanonicalDisappearingModeChanged | CanonicalHistorySync | CanonicalOfflineSyncCompleted | CanonicalDirtyState | CanonicalRawNode | CanonicalNotification | CanonicalMexNotification | CanonicalServerAck | CanonicalNoop;
658
717
  //# sourceMappingURL=types.d.ts.map