From eebb6d2163200fb63244ab0f177500a27484819d Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Fri, 5 Jun 2026 23:20:22 -0300 Subject: [PATCH 1/4] fix(newsletter): parse metadata and fetch-messages responses (#2620 port) (#503) fix(newsletter): parse metadata and fetch-messages responses (#2620 port) (#503) --- src/Socket/newsletter.ts | 137 ++++++++++++++++++++++++++++++++++----- src/Types/Newsletter.ts | 19 ------ 2 files changed, 122 insertions(+), 34 deletions(-) diff --git a/src/Socket/newsletter.ts b/src/Socket/newsletter.ts index 5752bddddd2..2183ccf6b02 100644 --- a/src/Socket/newsletter.ts +++ b/src/Socket/newsletter.ts @@ -1,8 +1,9 @@ +import { proto } from '../../WAProto/index.js' import type { NewsletterCreateResponse, SocketConfig, WAMediaUpload } from '../Types' import type { NewsletterMetadata, NewsletterUpdate } from '../Types' import { QueryIds, XWAPaths } from '../Types' import { generateProfilePicture } from '../Utils/messages-media' -import { getBinaryNodeChild } from '../WABinary' +import { type BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, S_WHATSAPP_NET } from '../WABinary' import { makeGroupsSocket } from './groups' import { executeWMexQuery as genericExecuteWMexQuery } from './mex' @@ -39,19 +40,108 @@ const parseNewsletterCreateResponse = (response: NewsletterCreateResponse): News } const parseNewsletterMetadata = (result: unknown): NewsletterMetadata | null => { + // Port of upstream PR #2620 (vinikjkkj). Earlier behavior cast the raw + // server response to `NewsletterMetadata` whole, which broke two + // callers in practice: + // 1. The actual response shape uses snake_case (`thread_metadata. + // subscribers_count`, `picture.direct_path`) while + // `NewsletterMetadata` is the flat camelCase shape we expose — + // casting lost the channel name, subscribers count, etc. + // 2. For preview-only responses (e.g. fetching a non-followed + // channel via invite), `thread_metadata.picture` is absent and + // the server returns `image` / `preview` siblings instead, so + // reading `.picture` alone returned undefined. + // + // Fix: unwrap the `result` envelope if present, require a string `id`, + // and translate fields into the flat shape with the documented + // fallback chain for the picture. if (typeof result !== 'object' || result === null) { return null } - if ('id' in result && typeof result.id === 'string') { - return result as NewsletterMetadata + const raw = result as Record + const node = (raw.result && typeof raw.result === 'object' ? raw.result : raw) as Record + + if (typeof node.id !== 'string') { + return null + } + + const thread = node.thread_metadata ?? {} + const viewer = node.viewer_metadata ?? {} + const pic = thread.picture ?? thread.image ?? thread.preview + + return { + id: node.id, + name: thread.name?.text ?? '', + description: thread.description?.text, + invite: thread.invite, + creation_time: thread.creation_time ? parseInt(thread.creation_time, 10) : undefined, + subscribers: thread.subscribers_count ? parseInt(thread.subscribers_count, 10) : undefined, + picture: pic ? { id: pic.id, directPath: pic.direct_path } : undefined, + verification: thread.verification, + mute_state: viewer.mute } +} - if ('result' in result && typeof result.result === 'object' && result.result !== null && 'id' in result.result) { - return result.result as NewsletterMetadata +/** + * Parse a single `` node from a newsletter `` response. + * + * Port of upstream PR #2620 (vinikjkkj). The server wraps the proto-encoded + * `Message` in a `` child and adorns the node with counters + * (views/forwards/responses), edit metadata, reaction tallies, poll vote + * tallies and an optional `<rcat>` blob for media-category routing. Returning + * the full structured shape lets callers see all of that without re-parsing. + */ +const parseFetchedNewsletterMessage = (node: BinaryNode) => { + const plaintext = getBinaryNodeChild(node, 'plaintext') + const plaintextContent = plaintext?.content + const meta = getBinaryNodeChild(node, 'meta') + const viewsCount = getBinaryNodeChild(node, 'views_count') + const forwardsCount = getBinaryNodeChild(node, 'forwards_count') + const responsesCount = getBinaryNodeChild(node, 'responses_count') + const rcat = getBinaryNodeChild(node, 'rcat') + + const reactionsNode = getBinaryNodeChild(node, 'reactions') + const reactions = reactionsNode + ? getBinaryNodeChildren(reactionsNode, 'reaction').map(r => ({ + code: r.attrs.code, + count: r.attrs.count ? parseInt(r.attrs.count, 10) : 0 + })) + : [] + + const votesNode = getBinaryNodeChild(node, 'votes') + const pollVotes = votesNode + ? getBinaryNodeChildren(votesNode, 'vote').map(v => ({ + count: v.attrs.count ? parseInt(v.attrs.count, 10) : 0, + hash: v.content instanceof Uint8Array ? v.content : undefined + })) + : [] + + let message: proto.IMessage | undefined + if (plaintextContent instanceof Uint8Array) { + try { + message = proto.Message.decode(plaintextContent) + } catch { + message = undefined + } } - return null + return { + id: node.attrs.id, + serverId: node.attrs.server_id, + type: node.attrs.type, + timestamp: node.attrs.t ? parseInt(node.attrs.t, 10) : undefined, + isSender: node.attrs.is_sender === 'true', + views: viewsCount?.attrs?.count ? parseInt(viewsCount.attrs.count, 10) : undefined, + forwards: forwardsCount?.attrs?.count ? parseInt(forwardsCount.attrs.count, 10) : undefined, + responses: responsesCount?.attrs?.count ? parseInt(responsesCount.attrs.count, 10) : undefined, + editTimestamp: meta?.attrs?.msg_edit_t ? parseInt(meta.attrs.msg_edit_t, 10) : undefined, + originalTimestamp: meta?.attrs?.original_msg_t ? parseInt(meta.attrs.original_msg_t, 10) : undefined, + mediaRcat: rcat?.content instanceof Uint8Array ? rcat.content : undefined, + reactions, + pollVotes, + message + } } export const makeNewsletterSocket = (config: SocketConfig) => { @@ -167,15 +257,30 @@ export const makeNewsletterSocket = (config: SocketConfig) => { }, newsletterFetchMessages: async (jid: string, count: number, since: number, after: number) => { - const messageUpdateAttrs: { count: string; since?: string; after?: string } = { + // Port of upstream PR #2620 (vinikjkkj). The previous shape was + // `<iq to='<channel-jid>' xmlns='newsletter'><message_updates count + // since after/></iq>` which the server simply doesn't answer — it + // hangs until the request times out (#2555 in upstream). + // + // The correct shape, captured from WA Web, is: + // <iq to='s.whatsapp.net' xmlns='newsletter'> + // <messages type='jid' jid='<channel-jid>' count='N' [before/after]/> + // </iq> + // + // Note also: the param name flipped from `since` to `before` in the + // XML attribute. The function signature stays the same so existing + // callers keep working. + const messagesAttrs: { type: string; jid: string; count: string; before?: string; after?: string } = { + type: 'jid', + jid, count: count.toString() } - if (typeof since === 'number') { - messageUpdateAttrs.since = since.toString() + if (typeof since === 'number' && since) { + messagesAttrs.before = since.toString() } if (after) { - messageUpdateAttrs.after = after.toString() + messagesAttrs.after = after.toString() } const result = await query({ @@ -183,17 +288,19 @@ export const makeNewsletterSocket = (config: SocketConfig) => { attrs: { id: generateMessageTag(), type: 'get', - xmlns: 'newsletter', - to: jid + to: S_WHATSAPP_NET, + xmlns: 'newsletter' }, content: [ { - tag: 'message_updates', - attrs: messageUpdateAttrs + tag: 'messages', + attrs: messagesAttrs } ] }) - return result + + const messagesNode = getBinaryNodeChild(result, 'messages') + return getBinaryNodeChildren(messagesNode, 'message').map(parseFetchedNewsletterMessage) }, subscribeNewsletterUpdates: async (jid: string): Promise<{ duration: string } | null> => { diff --git a/src/Types/Newsletter.ts b/src/Types/Newsletter.ts index 07dfb942a3c..77950d22b58 100644 --- a/src/Types/Newsletter.ts +++ b/src/Types/Newsletter.ts @@ -58,25 +58,6 @@ export interface NewsletterCreateResponse { role: NewsletterViewRole } } -export interface NewsletterCreateResponse { - id: string - state: { type: string } - thread_metadata: { - creation_time: string - description: { id: string; text: string; update_time: string } - handle: string | null - invite: string - name: { id: string; text: string; update_time: string } - picture: { direct_path: string; id: string; type: string } - preview: { direct_path: string; id: string; type: string } - subscribers_count: string - verification: 'VERIFIED' | 'UNVERIFIED' - } - viewer_metadata: { - mute: 'ON' | 'OFF' - role: NewsletterViewRole - } -} export type NewsletterViewRole = 'ADMIN' | 'GUEST' | 'OWNER' | 'SUBSCRIBER' export interface NewsletterMetadata { id: string From f335c1f26d1239799031c5e2d764402cca13805d Mon Sep 17 00:00:00 2001 From: Renato Alcara <alcararenato@gmail.com> Date: Sat, 6 Jun 2026 18:07:32 -0300 Subject: [PATCH 2/4] =?UTF-8?q?fix(contacts):=20resolve=20LID=E2=86=94PN?= =?UTF-8?q?=20for=20profile-picture=20contacts.update=20(port=20PR=20#2605?= =?UTF-8?q?)=20(#506)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(contacts): resolve LID↔PN for profile-picture contacts.update (port PR #2605) (#506) --- src/Socket/messages-recv.ts | 34 +++--- src/Utils/contact-picture-identity.ts | 49 ++++++++ src/Utils/index.ts | 1 + .../Utils/contact-picture-identity.test.ts | 113 ++++++++++++++++++ 4 files changed, 183 insertions(+), 14 deletions(-) create mode 100644 src/Utils/contact-picture-identity.ts create mode 100644 src/__tests__/Utils/contact-picture-identity.test.ts diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index d52b42fe67f..7c679178946 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -58,6 +58,7 @@ import { NO_MESSAGE_FOUND_ERROR_TEXT, normalizeKeyLidToPn, normalizeMessageJids, + resolveContactPictureIdentity, resolveLidToPn, safeCacheSet, SERVER_ERROR_CODES, @@ -154,6 +155,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { } = sock const getLIDForPN = signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping) + const getPNForLID = signalRepository.lidMapping.getPNForLID.bind(signalRepository.lidMapping) /** this mutex ensures that each retryRequest will wait for the previous one to finish */ const retryMutex = makeMutex() @@ -2283,37 +2285,41 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { } break - case 'picture': + case 'picture': { const setPicture = getBinaryNodeChild(node, 'set') const delPicture = getBinaryNodeChild(node, 'delete') - - { - const rawPictureJid = jidNormalizedUser(node?.attrs?.from) || (setPicture || delPicture)?.attrs?.hash || '' - const pictureJid = (await resolveLidToPn(rawPictureJid, signalRepository.lidMapping, logger)) || rawPictureJid - ev.emit('contacts.update', [ - { - id: pictureJid, - imgUrl: setPicture ? 'changed' : 'removed' - } - ]) - } + const pictureNode = setPicture || delPicture + const pictureFrom = jidNormalizedUser(node?.attrs?.from) || pictureNode?.attrs?.hash + const pictureImgUrl = setPicture ? 'changed' : 'removed' if (isJidGroup(from)) { - const node = setPicture || delPicture + if (pictureFrom) { + ev.emit('contacts.update', [{ id: pictureFrom, imgUrl: pictureImgUrl }]) + } + result.messageStubType = WAMessageStubType.GROUP_CHANGE_ICON if (setPicture) { result.messageStubParameters = [setPicture.attrs.id!] } - result.participant = node?.attrs.author + result.participant = pictureNode?.attrs.author result.key = { ...(result.key || {}), participant: setPicture?.attrs.author } + } else if (pictureFrom) { + const identity = await resolveContactPictureIdentity(pictureFrom, { + getPNForLID, + getLIDForPN, + meId: authState.creds.me?.id, + meLid: authState.creds.me?.lid + }) + ev.emit('contacts.update', [{ ...identity, imgUrl: pictureImgUrl }]) } break + } case 'account_sync': if (child!.tag === 'disappearing_mode') { const newDuration = +child!.attrs.duration! diff --git a/src/Utils/contact-picture-identity.ts b/src/Utils/contact-picture-identity.ts new file mode 100644 index 00000000000..bd05c62b7cf --- /dev/null +++ b/src/Utils/contact-picture-identity.ts @@ -0,0 +1,49 @@ +import { areJidsSameUser, isLidUser, isPnUser, jidNormalizedUser } from '../WABinary' + +export type ContactPictureIdentityContext = { + getPNForLID: (lid: string) => Promise<string | null> + getLIDForPN: (pn: string) => Promise<string | null> + meId: string | undefined + meLid: string | undefined +} + +/** + * Resolve the best-effort contact identity for a profile-picture notification. + * `from` must be the already-normalized individual JID (never a group jid). + * + * Returns the fields to merge into a `contacts.update` entry. When `from` is a LID we + * attempt to resolve the PN (and vice-versa) so consumers can correlate the change with a + * cached contact regardless of which addressing form they store. For non-saved contacts WA + * omits the canonical identity, so resolution may fail — in that case we still return the + * raw LID so the event is never empty. + */ +export async function resolveContactPictureIdentity( + from: string, + ctx: ContactPictureIdentityContext +): Promise<{ id: string; lid?: string; phoneNumber?: string }> { + const result: { id: string; lid?: string; phoneNumber?: string } = { id: from } + + if (isLidUser(from)) { + result.lid = from + const resolvedPn = await ctx.getPNForLID(from).catch(() => null) + const normalizedPn = jidNormalizedUser(resolvedPn || undefined) + // guard: discard a resolution that points at our own PN unless `from` is our own LID + const isBogusSelf = + !!normalizedPn && + !!ctx.meId && + areJidsSameUser(normalizedPn, ctx.meId) && + !(ctx.meLid && areJidsSameUser(from, ctx.meLid)) + if (normalizedPn && !isBogusSelf) { + result.id = normalizedPn + result.phoneNumber = normalizedPn + } + } else if (isPnUser(from)) { + result.phoneNumber = from + const resolvedLid = await ctx.getLIDForPN(from).catch(() => null) + if (resolvedLid && isLidUser(resolvedLid)) { + result.lid = jidNormalizedUser(resolvedLid) + } + } + + return result +} diff --git a/src/Utils/index.ts b/src/Utils/index.ts index 7c143474ce6..9d1e66ae7f8 100644 --- a/src/Utils/index.ts +++ b/src/Utils/index.ts @@ -23,6 +23,7 @@ export * from './browser-utils' export * from './companion-reg-client-utils' // === Identity and Session Management === +export * from './contact-picture-identity' export * from './identity-change-handler' // === Observability and Resilience Utilities === diff --git a/src/__tests__/Utils/contact-picture-identity.test.ts b/src/__tests__/Utils/contact-picture-identity.test.ts new file mode 100644 index 00000000000..3a4d41d4e24 --- /dev/null +++ b/src/__tests__/Utils/contact-picture-identity.test.ts @@ -0,0 +1,113 @@ +import { jest } from '@jest/globals' +import { + type ContactPictureIdentityContext, + resolveContactPictureIdentity +} from '../../Utils/contact-picture-identity' + +type ResolverFn = (jid: string) => Promise<string | null> + +describe('resolveContactPictureIdentity', () => { + let mockGetPNForLID: jest.Mock<ResolverFn> + let mockGetLIDForPN: jest.Mock<ResolverFn> + + const ME_ID = 'myuser@s.whatsapp.net' + const ME_LID = 'mylid@lid' + + /** Build a resolver context wired to the per-test mock resolvers and our own identity. */ + function createContext(): ContactPictureIdentityContext { + return { + getPNForLID: mockGetPNForLID, + getLIDForPN: mockGetLIDForPN, + meId: ME_ID, + meLid: ME_LID + } + } + + beforeEach(() => { + jest.clearAllMocks() + mockGetPNForLID = jest.fn<ResolverFn>().mockResolvedValue(null) + mockGetLIDForPN = jest.fn<ResolverFn>().mockResolvedValue(null) + }) + + it('resolves a LID to a PN and fills id, phoneNumber and lid', async () => { + mockGetPNForLID.mockResolvedValue('12345:0@s.whatsapp.net') + + const result = await resolveContactPictureIdentity('98765@lid', createContext()) + + expect(result).toEqual({ + id: '12345@s.whatsapp.net', + phoneNumber: '12345@s.whatsapp.net', + lid: '98765@lid' + }) + expect(mockGetPNForLID).toHaveBeenCalledWith('98765@lid') + }) + + it('falls back to the raw LID when it cannot be resolved', async () => { + mockGetPNForLID.mockResolvedValue(null) + + const result = await resolveContactPictureIdentity('98765@lid', createContext()) + + expect(result).toEqual({ id: '98765@lid', lid: '98765@lid' }) + expect(result.phoneNumber).toBeUndefined() + }) + + it('discards a resolution that points at our own PN (bogus self) and keeps the raw LID', async () => { + // a stranger's LID wrongly resolving to our own number must not be emitted as the contact + mockGetPNForLID.mockResolvedValue(ME_ID) + + const result = await resolveContactPictureIdentity('98765@lid', createContext()) + + expect(result).toEqual({ id: '98765@lid', lid: '98765@lid' }) + expect(result.phoneNumber).toBeUndefined() + }) + + it('keeps the resolved own PN when the source LID is our own LID', async () => { + mockGetPNForLID.mockResolvedValue(ME_ID) + + const result = await resolveContactPictureIdentity(ME_LID, createContext()) + + expect(result).toEqual({ + id: ME_ID, + phoneNumber: ME_ID, + lid: ME_LID + }) + }) + + it('fills lid from getLIDForPN when the input is a PN', async () => { + mockGetLIDForPN.mockResolvedValue('98765@lid') + + const result = await resolveContactPictureIdentity('12345@s.whatsapp.net', createContext()) + + expect(result).toEqual({ + id: '12345@s.whatsapp.net', + phoneNumber: '12345@s.whatsapp.net', + lid: '98765@lid' + }) + expect(mockGetLIDForPN).toHaveBeenCalledWith('12345@s.whatsapp.net') + }) + + it('only fills phoneNumber when the PN resolves to a non-LID value', async () => { + mockGetLIDForPN.mockResolvedValue('not-a-lid@s.whatsapp.net') + + const result = await resolveContactPictureIdentity('12345@s.whatsapp.net', createContext()) + + expect(result).toEqual({ id: '12345@s.whatsapp.net', phoneNumber: '12345@s.whatsapp.net' }) + expect(result.lid).toBeUndefined() + }) + + it('swallows resolver errors and falls back to the raw LID', async () => { + mockGetPNForLID.mockRejectedValue(new Error('lookup failed')) + + const result = await resolveContactPictureIdentity('98765@lid', createContext()) + + expect(result).toEqual({ id: '98765@lid', lid: '98765@lid' }) + }) + + it('does not attempt resolution for hosted LIDs (leaves id untouched)', async () => { + const result = await resolveContactPictureIdentity('98765@hosted.lid', createContext()) + + expect(result).toEqual({ id: '98765@hosted.lid' }) + expect(mockGetPNForLID).not.toHaveBeenCalled() + expect(mockGetLIDForPN).not.toHaveBeenCalled() + }) +}) From 3f409e75b1913bfc1e4bc6ba596d617ecb662873 Mon Sep 17 00:00:00 2001 From: Renato Alcara <alcararenato@gmail.com> Date: Sat, 6 Jun 2026 18:26:16 -0300 Subject: [PATCH 3/4] =?UTF-8?q?feat(phase9):=20add=20status.db=20(14th=20f?= =?UTF-8?q?ile)=20=E2=80=94=20Status=20feed=20+=20channel-crosspost=20sche?= =?UTF-8?q?ma=20(#507)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(phase9): add status.db (14th file) — Status feed + channel-crosspost schema (#507) --- src/Utils/multi-db-sqlite/schemas/index.ts | 12 +- src/Utils/multi-db-sqlite/schemas/status.ts | 182 ++++++++++++++++++ src/Utils/multi-db-sqlite/store.ts | 8 +- .../use-multi-db-sqlite-auth-state.ts | 15 +- .../Utils/multi-db-sqlite-auth-state.test.ts | 30 ++- 5 files changed, 232 insertions(+), 15 deletions(-) create mode 100644 src/Utils/multi-db-sqlite/schemas/status.ts diff --git a/src/Utils/multi-db-sqlite/schemas/index.ts b/src/Utils/multi-db-sqlite/schemas/index.ts index 1a2bd309a4e..dd56aa663c6 100644 --- a/src/Utils/multi-db-sqlite/schemas/index.ts +++ b/src/Utils/multi-db-sqlite/schemas/index.ts @@ -8,6 +8,7 @@ export { MSGSTORE_SCHEMA } from './msgstore' export { PAYMENTS_SCHEMA } from './payments' export { PROMETHEUS_SCHEMA } from './prometheus' export { SMB_SCHEMA } from './smb' +export { STATUS_SCHEMA } from './status' export { STICKERS_SCHEMA } from './stickers' export { SYNC_SCHEMA } from './sync' export { WA_SCHEMA } from './wa' @@ -22,12 +23,13 @@ import { MSGSTORE_SCHEMA } from './msgstore' import { PAYMENTS_SCHEMA } from './payments' import { PROMETHEUS_SCHEMA } from './prometheus' import { SMB_SCHEMA } from './smb' +import { STATUS_SCHEMA } from './status' import { STICKERS_SCHEMA } from './stickers' import { SYNC_SCHEMA } from './sync' import { WA_SCHEMA } from './wa' /** - * The 13 physical SQLite files we open in multi-DB mode, one per concern: + * The 14 physical SQLite files we open in multi-DB mode, one per concern: * * - `creds.db` — auth credentials root + app-state sync keys * - `axolotl.db` — Signal Protocol (sessions, prekeys, identities, @@ -45,6 +47,12 @@ import { WA_SCHEMA } from './wa' * - `payments.db` — payment state (consumer + merchant) * - `stickers.db` — sticker pack catalog and recent state * - `smb.db` — Small Business / Marketing Messages state + * - `status.db` — Status (24h feed) + channel-crosspost state. + * Schema ships ahead of callers — no Baileys + * feature consumes it today, but the file is + * opened so future status-feed / channel-share + * features can land without retrofitting the + * MULTI_DB_FILES list. * - `prometheus.db` — observability / metrics history (isolated so * high-frequency writes never contend with the * message-send hot path) @@ -62,6 +70,7 @@ export const MULTI_DB_FILES = [ 'payments.db', 'stickers.db', 'smb.db', + 'status.db', 'prometheus.db' ] as const @@ -80,5 +89,6 @@ export const SCHEMAS: Record<MultiDbFile, string> = { 'payments.db': PAYMENTS_SCHEMA, 'stickers.db': STICKERS_SCHEMA, 'smb.db': SMB_SCHEMA, + 'status.db': STATUS_SCHEMA, 'prometheus.db': PROMETHEUS_SCHEMA } diff --git a/src/Utils/multi-db-sqlite/schemas/status.ts b/src/Utils/multi-db-sqlite/schemas/status.ts new file mode 100644 index 00000000000..11425411695 --- /dev/null +++ b/src/Utils/multi-db-sqlite/schemas/status.ts @@ -0,0 +1,182 @@ +/** + * Schema for `status.db` — Status (24h feed) + channel-crosspost state. + * + * Mirrors the canonical mobile schema discovered on WA Business 2.26.21.75 + * via Frida (the verbatim mobile dump captured at the same time is preserved + * outside the repo for future reference). The mobile DB has ~25 tables; we + * ship the core subset that any Baileys status / channel-crosspost feature + * would touch — `status` (the root feed), `status_attribution` (channel- + * crosspost reference), `status_info` (per-chat aggregates), the read-receipt + * + privacy + media-link companion tables, and the `status_crossposting_v3` + * outbound queue. Remaining tables (reporting, orphan, interactions, add_on, + * etc.) can be appended in future PRs as concrete callers land — the + * bookkeeping schema-migrations helper lets new tables be introduced safely + * against existing databases. + * + * State machine for `status.state` (empirical): + * 0 → creating / uploading + * 1 → sent locally (queued for server) + * 3 → server-confirmed receipt + * 6 → expired / deleted + * + * `status.type` observed values: + * 4 → standard photo/text/crosspost (dominant) + * 5 → variant (other senders, flags=32) + * + * `status_attribution.type` observed values: + * 1 → newsletter / channel crosspost (proto ~43 bytes) + * 3 → other variant (16–111 byte protos) + * + * Column names match the canonical mobile schema verbatim. + */ +export const STATUS_SCHEMA = ` +CREATE TABLE IF NOT EXISTS android_metadata (locale TEXT); + +CREATE TABLE IF NOT EXISTS status ( + row_id INTEGER PRIMARY KEY AUTOINCREMENT, + sort_id INTEGER NOT NULL, + uuid TEXT NOT NULL, + sender_user_jid TEXT NOT NULL, + status_info_row_id INTEGER NOT NULL, + type INTEGER NOT NULL, + timestamp INTEGER NOT NULL, + server_receipt_timestamp INTEGER, + text_data TEXT, + state INTEGER NOT NULL, + secret BLOB, + content_proto BLOB, + fp_proto BLOB, + origin INTEGER NOT NULL, + flags INTEGER NOT NULL, + audience_type INTEGER NOT NULL DEFAULT 0, + is_archived INTEGER NOT NULL, + stanza_xml BLOB, + received_timestamp INTEGER +); + +CREATE INDEX IF NOT EXISTS status_is_archived_index ON status (is_archived); +CREATE UNIQUE INDEX IF NOT EXISTS status_info_sort_id_index + ON status (status_info_row_id, sort_id); + +CREATE TABLE IF NOT EXISTS status_attribution ( + row_id INTEGER PRIMARY KEY AUTOINCREMENT, + status_row_id INTEGER NOT NULL, + type INTEGER NOT NULL, + content_proto BLOB +); + +CREATE INDEX IF NOT EXISTS status_attribution_index + ON status_attribution (status_row_id); + +CREATE TABLE IF NOT EXISTS status_crossposting_v3 ( + row_id INTEGER PRIMARY KEY AUTOINCREMENT, + status_row_id INTEGER, + crossposting_session_id TEXT, + crossposting_status_unique_id TEXT, + state INTEGER, + media_file_path TEXT, + direct_url_path TEXT, + destination INTEGER +); + +CREATE UNIQUE INDEX IF NOT EXISTS status_crossposting_v3_index + ON status_crossposting_v3 (status_row_id, destination); +CREATE INDEX IF NOT EXISTS status_crossposting_v3_state_index + ON status_crossposting_v3 (state); + +CREATE TABLE IF NOT EXISTS status_info ( + row_id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_jid TEXT NOT NULL, + total_count INTEGER NOT NULL, + unread_count INTEGER NOT NULL, + last_status_sort_id INTEGER, + first_unread_sort_id INTEGER, + is_muted INTEGER NOT NULL, + last_status_timestamp INTEGER, + pending_count INTEGER, + failed_count INTEGER, + type INTEGER NOT NULL DEFAULT 0, + unread_count_close_friends INTEGER NOT NULL DEFAULT 0 +); + +CREATE UNIQUE INDEX IF NOT EXISTS status_info_chat_index ON status_info (chat_jid); +CREATE INDEX IF NOT EXISTS status_info_last_status_sort_id_index + ON status_info (last_status_sort_id); +CREATE INDEX IF NOT EXISTS status_info_type_index ON status_info (type); + +CREATE TABLE IF NOT EXISTS status_text ( + status_row_id INTEGER PRIMARY KEY, + url TEXT, + page_title TEXT, + page_description TEXT, + font_style INTEGER, + text_color INTEGER, + background_color INTEGER, + preview_type INTEGER, + invite_link_group_type INTEGER, + thumbnail BLOB, + text_content_proto BLOB +); + +CREATE TABLE IF NOT EXISTS status_media_link ( + row_id INTEGER PRIMARY KEY AUTOINCREMENT, + status_row_id INTEGER NOT NULL, + media_content_row_id INTEGER NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS status_media_link_index + ON status_media_link (status_row_id, media_content_row_id); +CREATE INDEX IF NOT EXISTS status_media_link_media_content_row_id_index + ON status_media_link (media_content_row_id); + +CREATE TABLE IF NOT EXISTS status_thumbnail ( + row_id INTEGER PRIMARY KEY AUTOINCREMENT, + status_row_id INTEGER NOT NULL, + media_content_row_id INTEGER, + thumbnail BLOB, + thumbnail_path TEXT, + highres_thumbnail_path TEXT +); + +CREATE INDEX IF NOT EXISTS status_thumbnail_status_row_id_index + ON status_thumbnail (status_row_id); +CREATE INDEX IF NOT EXISTS status_thumbnail_media_content_row_id_index + ON status_thumbnail (media_content_row_id); + +CREATE TABLE IF NOT EXISTS status_seen_receipt ( + row_id INTEGER PRIMARY KEY AUTOINCREMENT, + status_row_id INTEGER, + receipt_user_jid TEXT NOT NULL, + received_timestamp INTEGER, + seen_timestamp INTEGER +); + +CREATE UNIQUE INDEX IF NOT EXISTS status_seen_receipt_index + ON status_seen_receipt (status_row_id, receipt_user_jid); + +CREATE TABLE IF NOT EXISTS status_privacy_custom_list ( + row_id INTEGER PRIMARY KEY AUTOINCREMENT, + list_id TEXT NOT NULL, + name TEXT, + emoji TEXT, + is_selected INTEGER NOT NULL DEFAULT 0, + member_jids TEXT, + source_group_jids TEXT, + allow_list_selected INTEGER +); + +CREATE UNIQUE INDEX IF NOT EXISTS status_privacy_custom_list_list_id_index + ON status_privacy_custom_list (list_id); + +CREATE TABLE IF NOT EXISTS key_value_store ( + row_id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, + value TEXT +); + +CREATE TABLE IF NOT EXISTS props ( + _id INTEGER PRIMARY KEY AUTOINCREMENT, + prop_name TEXT UNIQUE, + prop_value TEXT +); +` diff --git a/src/Utils/multi-db-sqlite/store.ts b/src/Utils/multi-db-sqlite/store.ts index 4e02ad118c7..1aaeecf1f4f 100644 --- a/src/Utils/multi-db-sqlite/store.ts +++ b/src/Utils/multi-db-sqlite/store.ts @@ -2,7 +2,7 @@ * `MultiDbSqliteStore` — multi-handle SQLite store with one physical * `.db` file per concern (creds, axolotl, msgstore, wa, sync, media, * companion_devices, chatsettings, location, payments, stickers, smb, - * prometheus — 13 files total; see `MULTI_DB_FILES`). + * status, prometheus — 14 files total; see `MULTI_DB_FILES`). * * Why multiple files instead of one consolidated DB? * @@ -37,7 +37,7 @@ const DEFAULT_PRAGMAS: ReadonlyArray<string> = [ 'journal_mode = WAL', 'synchronous = NORMAL', 'busy_timeout = 5000', - // Defensively enabled. The 13 schemas in this folder currently do NOT + // Defensively enabled. The 14 schemas in this folder currently do NOT // define `FOREIGN KEY ... REFERENCES ...` clauses (they mirror the // canonical mobile layout, which also keeps FK enforcement off). This // pragma is set so that any future schema additions that DO add foreign @@ -47,10 +47,10 @@ const DEFAULT_PRAGMAS: ReadonlyArray<string> = [ // every opened handle — DEFAULT_PRAGMAS is the right place. 'foreign_keys = ON', // Audit memory MEM-001 — sem esta pragma, SQLite cai no default de - // `-2000` (~2 MB de page cache por handle). Com 13 handles × N + // `-2000` (~2 MB de page cache por handle). Com 14 handles × N // sessões, isso vira pressão de RSS desnecessária pro workload da // lib (point reads em signal_kv/jid_map, sem joins grandes). `-512` - // = 512 KiB por handle → ~6.5 MB por sessão em vez de ~26 MB. + // = 512 KiB por handle → ~7 MB por sessão em vez de ~28 MB. // Quem precisa de mais cache pode override via `extraPragmas`. 'cache_size = -512', // Audit memory MEM-002 — `mmap_size = 0` desabilita explicitamente o diff --git a/src/Utils/multi-db-sqlite/use-multi-db-sqlite-auth-state.ts b/src/Utils/multi-db-sqlite/use-multi-db-sqlite-auth-state.ts index c63ba866d54..636ffc305dd 100644 --- a/src/Utils/multi-db-sqlite/use-multi-db-sqlite-auth-state.ts +++ b/src/Utils/multi-db-sqlite/use-multi-db-sqlite-auth-state.ts @@ -38,9 +38,9 @@ export type UseMultiDbSqliteAuthStateOptions = MultiDbSqliteStoreOptions & { * Multi-DB authentication state for Baileys. * * Same API as `useMultiFileAuthState` / `useSqliteAuthState`, but the - * underlying persistence is split across 13 physical SQLite files, one per + * underlying persistence is split across 14 physical SQLite files, one per * concern (creds, axolotl, msgstore, wa, sync, media, companion_devices, - * chatsettings, location, payments, stickers, smb, prometheus): + * chatsettings, location, payments, stickers, smb, status, prometheus): * * sessionDir/ * creds.db — auth credentials (the `app_state_sync_keys` table @@ -52,6 +52,9 @@ export type UseMultiDbSqliteAuthStateOptions = MultiDbSqliteStoreOptions & { * (schemas reserved for phases 9.1–9.4) * wa.db — contacts + TC tokens (schemas reserved for phase 9.6) * sync.db — app-state sync (schemas reserved for phase 9.7) + * status.db — Status (24h feed) + channel-crosspost state + * (schema ships ahead of callers — no Baileys feature + * consumes it today) * prometheus.db — metrics history; isolated so high-frequency writes * never contend with the message-send hot path * @@ -61,10 +64,10 @@ export type UseMultiDbSqliteAuthStateOptions = MultiDbSqliteStoreOptions & { * schemas but their typed tables remain empty until the corresponding * follow-up phases route the respective components to them. * - * Why open all 13 files up front instead of lazily? Disk allocation + WAL + * Why open all 14 files up front instead of lazily? Disk allocation + WAL * checkpointing both have one-time costs; doing them at startup means the - * first message flow doesn't pay them. The cost is ~200 KB per session - * for empty WAL files (13 files × ~15 KB each) — negligible. + * first message flow doesn't pay them. The cost is ~210 KB per session + * for empty WAL files (14 files × ~15 KB each) — negligible. */ export async function useMultiDbSqliteAuthState(opts: UseMultiDbSqliteAuthStateOptions): Promise<{ state: AuthenticationState @@ -76,7 +79,7 @@ export async function useMultiDbSqliteAuthState(opts: UseMultiDbSqliteAuthStateO // Reuse an injected store when supplied; otherwise open our own. The // injected-store path lets a single MultiDbSqliteStore be shared with // `SocketConfig.multiDbStore` and with cache adapters, eliminating the - // duplicate 13-handle open the quick-start docs previously showed. + // duplicate 14-handle open the quick-start docs previously showed. const ownsStore = !opts.store const store = ownsStore ? new MultiDbSqliteStore(opts) : (opts.store as MultiDbSqliteStore) diff --git a/src/__tests__/Utils/multi-db-sqlite-auth-state.test.ts b/src/__tests__/Utils/multi-db-sqlite-auth-state.test.ts index 80b81d7ff5d..20bd8456778 100644 --- a/src/__tests__/Utils/multi-db-sqlite-auth-state.test.ts +++ b/src/__tests__/Utils/multi-db-sqlite-auth-state.test.ts @@ -2,9 +2,9 @@ * Phase 9 — `useMultiDbSqliteAuthState` skeleton smoke test. * * Covers: - * - creates all 13 physical .db files (creds, axolotl, msgstore, wa, sync, + * - creates all 14 physical .db files (creds, axolotl, msgstore, wa, sync, * media, companion_devices, chatsettings, location, payments, stickers, - * smb, prometheus); + * smb, status, prometheus); * - typed tables exist with expected names in the right .db files; * - creds round-trip via creds.db; * - signal data round-trip via axolotl.db.signal_kv (opaque key-value @@ -13,7 +13,7 @@ * * Uses on-disk DBs in a tmp directory because the multi-file layout * requires real files (`:memory:` is per-connection and doesn't apply - * across the 13 handles). + * across the 14 handles). */ import { mkdtemp, rm } from 'fs/promises' import { tmpdir } from 'os' @@ -34,7 +34,7 @@ describe('useMultiDbSqliteAuthState', () => { await rm(dir, { recursive: true, force: true }) }) - it('opens all 13 physical .db files on first open', async () => { + it('opens all 14 physical .db files on first open', async () => { const { close } = await useMultiDbSqliteAuthState({ sessionDir: dir }) const { promises: fs } = await import('fs') const files = await fs.readdir(dir) @@ -52,6 +52,7 @@ describe('useMultiDbSqliteAuthState', () => { 'payments.db', 'stickers.db', 'smb.db', + 'status.db', 'prometheus.db' ]) ) @@ -135,6 +136,27 @@ describe('useMultiDbSqliteAuthState', () => { expect.arrayContaining(['metric_samples', 'metric_descriptors', 'retention_policies', 'pruning_log']) ) + const statusTables = ( + store.handle('status.db').prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ + name: string + }> + ).map(r => r.name) + expect(statusTables).toEqual( + expect.arrayContaining([ + 'status', + 'status_attribution', + 'status_crossposting_v3', + 'status_info', + 'status_text', + 'status_media_link', + 'status_thumbnail', + 'status_seen_receipt', + 'status_privacy_custom_list', + 'key_value_store', + 'props' + ]) + ) + close() }) From 3d03204d8bd57766b9352b5b074421332594bbac Mon Sep 17 00:00:00 2001 From: Renato Alcara <alcararenato@gmail.com> Date: Sat, 6 Jun 2026 19:05:13 -0300 Subject: [PATCH 4/4] fix(newsletter): cover node.{image,preview} as parseNewsletterMetadata picture fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #508 review caught an ambiguity in the picture-fallback chain inherited from upstream PR #2620 (#503 port). The original chain only looked inside `thread_metadata`: const pic = thread.picture ?? thread.image ?? thread.preview but the in-code comment noted that for preview-only responses (non-followed channel via invite) the server returns `image` / `preview` SIBLINGS of `thread_metadata` rather than children of it. The chain was missing the sibling case, so the picture came back undefined for that flow. Extend the fallback to also try `node.image` and `node.preview`. Additive and defensive — `undefined ?? undefined` short-circuits cleanly when the server returns the followed-channel shape (which has worked since #503 merged). Also refine the comment so the chain is unambiguous on the next read. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --- src/Socket/newsletter.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Socket/newsletter.ts b/src/Socket/newsletter.ts index 2183ccf6b02..299fd070178 100644 --- a/src/Socket/newsletter.ts +++ b/src/Socket/newsletter.ts @@ -50,7 +50,10 @@ const parseNewsletterMetadata = (result: unknown): NewsletterMetadata | null => // 2. For preview-only responses (e.g. fetching a non-followed // channel via invite), `thread_metadata.picture` is absent and // the server returns `image` / `preview` siblings instead, so - // reading `.picture` alone returned undefined. + // reading `.picture` alone returned undefined. The siblings have + // been observed at TWO different shapes — sometimes inside + // `thread_metadata.{image,preview}` and sometimes alongside it + // at `node.{image,preview}`. Both are tolerated below. // // Fix: unwrap the `result` envelope if present, require a string `id`, // and translate fields into the flat shape with the documented @@ -68,7 +71,7 @@ const parseNewsletterMetadata = (result: unknown): NewsletterMetadata | null => const thread = node.thread_metadata ?? {} const viewer = node.viewer_metadata ?? {} - const pic = thread.picture ?? thread.image ?? thread.preview + const pic = thread.picture ?? thread.image ?? thread.preview ?? node.image ?? node.preview return { id: node.id,