Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2c5e6b6
chore(deps): take the bridge 0.8.0 reader and numeric contracts
claude Aug 10, 2026
81c9cd8
fix(proto): put back the two inputs the bridge codec stopped accepting
claude Aug 10, 2026
42b2138
fix(proto): absorb the bridge 0.8.0 strictness at the send path too
claude Aug 10, 2026
be9a4c6
fix(types): keep 64-bit fields declared as upstream declares them
claude Aug 10, 2026
ed4aafb
test(fuzz): frame a mutated payload with the schema, as wire.ts requires
claude Aug 10, 2026
2d3a9dc
chore(deps): bridge 0.8.1, and document the merge it made conformant
claude Aug 11, 2026
5f31eb4
test(fuzz): let the merge entry cover the repeated fields it also gov…
claude Aug 11, 2026
c0c140b
Merge remote-tracking branch 'origin/main' into chore/bridge-0.8.0
jlucaso1 Aug 12, 2026
08d6cd5
feat(events): move to bridge 0.10.0 and deliver the events it added
jlucaso1 Aug 12, 2026
e2e8c89
test(fuzz): scope the merge entry to payloads that were concatenated
claude Aug 12, 2026
c1ba9a3
test(fuzz): tie each allowlist entry to the difference it documents
jlucaso1 Aug 12, 2026
20cea47
test(fuzz): record the renamed favicon field where the harness alread…
jlucaso1 Aug 12, 2026
651c9bc
feat(events): deliver the link-preview setting and the pair-code failure
claude Aug 12, 2026
1e67bc3
fix(fuzz): read bridge field numbers, and drop the allowlist members …
claude Aug 12, 2026
d7e6f4d
test(fuzz): pin the lying-length entry to the salvage it documents
claude Aug 12, 2026
6032d89
test(fuzz): degrade a contested field number rather than framing agai…
claude Aug 12, 2026
90d07fd
fix(fuzz): read the merged repeated field as a suffix, and repair at …
claude Aug 12, 2026
b369a76
style: format the repair walk
claude Aug 12, 2026
a8b25f2
test(fuzz): require the substitution itself, and never let it stand a…
claude Aug 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"url": "https://github.com/oxidezap/baileyrs/issues"
},
"license": "MIT",
"author": "João Lucas <jlucaso@hotmail.com>",
"author": "Jo\u00e3o Lucas <jlucaso@hotmail.com>",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
Expand Down Expand Up @@ -82,7 +82,7 @@
},
"dependencies": {
"@hapi/boom": "^9.1.4",
"@oxidezap/whatsapp-rust-bridge": "0.7.2",
"@oxidezap/whatsapp-rust-bridge": "0.10.0",
"long": "^5.3.2",
"pino": "^10.3.1",
"protobufjs": "^7.6.5"
Expand Down
6 changes: 5 additions & 1 deletion scripts/compatibility/proto-runtime-audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,14 @@ const KNOWN_WIRE_GAPS = [
'BotMetadata.avatarMetadata',
'Message.AudioMessage.mediaKeyDomain',
'Message.DocumentMessage.mediaKeyDomain',
// Renamed rather than absent: WhatsApp schema 2.3000.1044659339 spells field
// 33 `faviconMmsMetadata`, which bridge 0.10.0 regenerated against, while
// baileys 7.0.0-rc13 still declares `faviconMMSMetadata`. The wire is
// identical; the gap closes when upstream regenerates its proto.
'Message.ExtendedTextMessage.faviconMMSMetadata',
'Message.ImageMessage.mediaKeyDomain',
'Message.MMSThumbnailMetadata.mediaKeyDomain',
'Message.MessageHistoryMetadata.oldestMessageTimestamp',
'Message.PaymentExtendedMetadata.messageParamsJson',
'Message.StickerMessage.mediaKeyDomain',
'Message.VideoMessage.mediaKeyDomain',
'Message.pollResultSnapshotMessageV3',
Expand Down
4 changes: 4 additions & 0 deletions src/Bridge/primitives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,3 +213,7 @@ export const absoluteFromDuration = (seconds: number | undefined): number | unde

/** `Date` holds ±8.64e15 ms, which is this many whole seconds. */
const MAX_DATE_SECONDS = 8_640_000_000_000

/** Wire collection names and the like: anything that is not a string is not one. */
export const asStringArray = (x: unknown): string[] =>
Array.isArray(x) ? x.filter((item): item is string => typeof item === 'string') : []
104 changes: 102 additions & 2 deletions src/Bridge/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ import type {
} from './types.ts'
import {
absoluteFromDuration,
asBool,
asBoolOr,
asStringArray,
asDurationSeconds,
asInt64,
asJidAddressString,
Expand Down Expand Up @@ -75,8 +77,11 @@ type AdapterMap = { [K in BridgeEventType]: AdapterFn<K> }
* the unexported `PinAction` / `MuteAction` (resolves to `any`). Narrow
* once at the call site.
*/
const extractAction = (data: { action?: unknown }): Record<string, unknown> | undefined =>
isObject(data.action) ? data.action : undefined
// Tolerates a missing `data` slot: the adapter table has to be total against
// whatever the runtime sends, and an entry that reads the action before any
// other guard would otherwise throw rather than drop (`bridge:adapt-total`).
const extractAction = (data: { action?: unknown } | undefined): Record<string, unknown> | undefined =>
isObject(data?.action) ? data.action : undefined

/** A group JID is authoritative when an older producer leaves `is_group` false. */
const resolveIsGroup = (wireValue: unknown, chatJid: string): boolean =>
Expand Down Expand Up @@ -352,6 +357,101 @@ const ADAPTERS = {
return { type: 'labelAssociation', labelId, chatJid, labeled: asBoolOr(extractAction(data)?.labeled, true) }
},

/**
* The per-message half of `labels.association`, which used to have no path.
* Same canonical event as the chat one, told apart by carrying a message.
*/
message_label_association_update: data => {
const labelId = asString(data?.label_id)
const chatJid = asJidString(data?.chat_jid)
const messageId = asString(data?.message_id)
if (!labelId || !chatJid || !messageId) {
return { type: 'noop', bridgeType: 'message_label_association_update' }
}
return {
type: 'labelAssociation',
labelId,
chatJid,
messageId,
labeled: asBoolOr(extractAction(data)?.labeled, true)
}
},

/**
* What a degraded app-state sync left behind. The engine announces the
* connection anyway, so without this a consumer is told a session with no
* push name is healthy and has nothing to read that says otherwise.
*/
app_state_sync_failed: data => ({
type: 'appStateSyncFailed',
fatal: asStringArray(data?.fatal),
retryable: asStringArray(data?.retryable),
skipped: asStringArray(data?.skipped),
connected: asBoolOr(data?.connected, false)
}),

/**
* The QR refs ran out. Upstream ends the socket with `timedOut` when its own
* QR timer gives up (`Socket/socket.ts`), which is the same end state, so
* this becomes the same terminal close rather than a new signal to learn.
*/
pairing_qr_codes_exhausted: () => ({ type: 'qrCodesExhausted' }),

/**
* Another linked device turned link previews on or off account-wide.
*
* Upstream carries this on `settings.update`, reached through app state
* (`Utils/chat-utils.ts` branches on `privacySettingDisableLinkPreviewsAction`
* and emits the action as the value). Same event, different pipe — so the
* value is the action itself, not the decoded flag.
*
* `previews_disabled` is that flag already decoded by the bridge, which is
* what fills the action in when the payload carried the flag alone. The
* bridge only emits this event when the wire carried the flag, so the last
* fallback is unreachable in practice and exists so the value always has
* the field upstream's consumers read.
*/
disable_link_previews_update: data => {
const action = extractAction(data)
return {
type: 'settingUpdate',
setting: 'disableLinkPreviews',
value: {
...action,
isPreviewsDisabled: asBool(action?.isPreviewsDisabled) ?? asBoolOr(data?.previews_disabled, false)
}
}
},

/**
* A pair-code request failed, so any code the user was shown is spent.
*
* Same lifecycle as `pair_error`, which is why it adapts to the same
* canonical event: the socket lives on and the engine takes another
* request, so this must not read as a close, and the code on screen has to
* stop being offered. `pairError` is the handler that does both — a
* pairing code surfaces as `qr` (see `pairing_code` above), and `connecting`
* clears it while saying a fresh one can be asked for.
*
* `rejection` and `backoff` ride along for the log. Neither changes what a
* consumer does here, and the engine owns the retry — but a throttle the
* server named itself is the difference between a code that will come back
* and one that will not, and dropping it leaves that unexplained.
*/
pairing_code_error: data => ({
type: 'pairError',
error: asString(data?.error) ?? 'pairing code rejected',
rejection: asNumber(data?.rejection),
backoff: asNumber(data?.backoff)
}),

// Acknowledged with no Baileys equivalent: upstream has no channel for a
// contact deletion (`contacts.update` only upserts), for quick replies, or
// for a call placed on the phone.
contact_removed: () => ({ type: 'noop', bridgeType: 'contact_removed' }),
quick_reply_update: () => ({ type: 'noop', bridgeType: 'quick_reply_update' }),
call_log_sync: () => ({ type: 'noop', bridgeType: 'call_log_sync' }),

// ── Calls ──
incoming_call: (data, logger) => adaptIncomingCall(data, logger),
missed_call: data => {
Expand Down
67 changes: 66 additions & 1 deletion src/Bridge/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ export interface CanonicalPairSuccess {
export interface CanonicalPairError {
type: 'pairError'
error: string
/**
* The server's own refusal code, when it answered with one.
*
* Absent when the failure was local (validation, no connection) or the
* request went unanswered — nothing was refused, so there is no status.
* Carried for the log only: the consumer-visible outcome is the same
* either way, and the engine owns the retry.
*/
rejection?: number
/** Seconds the server asked the client to wait before asking again. */
backoff?: number
/** Account JID after pairing (may be set even on error). */
id?: string
/** LID for the account. */
Expand Down Expand Up @@ -432,10 +443,61 @@ export interface CanonicalLabelAssociation {
type: 'labelAssociation'
labelId: string
chatJid: string
/** `true` = label added to the chat, `false` = removed. */
/**
* The message the label is on, for a per-message association.
*
* Absent means the label is on the chat itself. The two arrive on separate
* bridge events and upstream carries both on `labels.association`, keyed by
* the association's own `type`.
*/
messageId?: string
/** `true` = label added, `false` = removed. */
labeled: boolean
}

/**
* The server ran out of QR refs before anyone scanned one.
*
* Terminal: nothing else is coming on this socket, and the consumer builds a
* new one to be shown a fresh code.
*/
export interface CanonicalQrCodesExhausted {
type: 'qrCodesExhausted'
}

/**
* A batched app-state sync that did not leave every collection synced.
*
* Collections are named as they appear on the wire. `fatal` is the one a
* consumer usually has to act on: the server refused it and asking again gets
* the same answer. `connected` says whether the session was announced anyway,
* which is the difference between "degraded but usable" and "still retrying" —
* the engine connects on a degraded sync rather than withholding the session,
* so this event is the only thing that says what is missing from it.
*/
export interface CanonicalAppStateSyncFailed {
type: 'appStateSyncFailed'
fatal: string[]
retryable: string[]
skipped: string[]
connected: boolean
}

/**
* An account-wide setting another linked device changed.
*
* Upstream reaches the same place through app state — `Utils/chat-utils.ts`
* emits `settings.update` for a `privacySettingDisableLinkPreviewsAction` —
* so this is that channel's payload arriving over a different pipe, not a
* new contract. The shape is the event's rather than one setting's: another
* setting the bridge starts reporting is a new arm here, not a new event.
*/
export interface CanonicalSettingUpdate {
type: 'settingUpdate'
setting: 'disableLinkPreviews'
value: proto.SyncActionValue.IPrivacySettingDisableLinkPreviewsAction
}

// ── Calls ──

export type CanonicalCallActionType =
Expand Down Expand Up @@ -693,6 +755,9 @@ export type CanonicalEvent =
| CanonicalMarkChatAsReadUpdate
| CanonicalLabelEdit
| CanonicalLabelAssociation
| CanonicalAppStateSyncFailed
| CanonicalQrCodesExhausted
| CanonicalSettingUpdate
| CanonicalIncomingCall
| CanonicalUndecryptableMessage
| CanonicalLidMappingUpdate
Expand Down
30 changes: 30 additions & 0 deletions src/Compatibility/encode-proto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { encodeProto } from '@oxidezap/whatsapp-rust-bridge'
import { repairProtoMessage } from './proto-runtime.ts'

/**
* `encodeProto`, with the two inputs the bridge codec stopped accepting put back.
*
* From 0.8.0 the codec refuses an empty string where the schema declares a
* 64-bit integer, and an unpaired surrogate in a text field. Both were written
* before — as `0` and as U+FFFD — and upstream Baileys still encodes both, so a
* message that used to reach the server would now throw in the caller's face.
* This is where that is absorbed, so the strict contract stays true of the
* bridge and the tolerant one stays true of this library.
*
* Repair on failure rather than check on write: the ordinary encode is exactly
* the call it was before, with no scan of any field, and the repair runs only
* for a message that was already going to throw. `encodeProto` returns finished
* bytes rather than a lazy writer, so one try/catch covers it.
*/
export const encodeProtoCompat = (path: string, message: unknown): Uint8Array => {
try {
return encodeProto(path, message)
} catch (error) {
const repaired = repairProtoMessage(path, message)
// Reference equality: nothing was coerced, so the failure is something this
// does not explain — an unmodelled type, a number no int64 can hold — and
// it has to keep propagating rather than be retried into a second throw.
if (repaired === message) throw error
return encodeProto(path, repaired)
}
}
Loading
Loading