Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
34 changes: 20 additions & 14 deletions src/Socket/messages-recv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
NO_MESSAGE_FOUND_ERROR_TEXT,
normalizeKeyLidToPn,
normalizeMessageJids,
resolveContactPictureIdentity,
resolveLidToPn,
safeCacheSet,
SERVER_ERROR_CODES,
Expand Down Expand Up @@ -154,6 +155,7 @@
} = 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()
Expand Down Expand Up @@ -598,7 +600,7 @@
return
}

let data: any

Check warning on line 603 in src/Socket/messages-recv.ts

View workflow job for this annotation

GitHub Actions / check-lint

Unexpected any. Specify a different type

Check warning on line 603 in src/Socket/messages-recv.ts

View workflow job for this annotation

GitHub Actions / check-lint

Unexpected any. Specify a different type
try {
// Narrow payload content type before JSON.parse:
// content can be string (UTF-16 internally), Uint8Array, Buffer, or
Expand Down Expand Up @@ -811,7 +813,7 @@
case 'update': {
const settingsNode = getBinaryNodeChild(child, 'settings')
if (settingsNode) {
const update: Record<string, any> = {}

Check warning on line 816 in src/Socket/messages-recv.ts

View workflow job for this annotation

GitHub Actions / check-lint

Unexpected any. Specify a different type

Check warning on line 816 in src/Socket/messages-recv.ts

View workflow job for this annotation

GitHub Actions / check-lint

Unexpected any. Specify a different type
const nameNode = getBinaryNodeChild(settingsNode, 'name')
if (nameNode?.content) update.name = nameNode.content.toString()

Expand Down Expand Up @@ -2283,37 +2285,41 @@
}

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'
Comment thread
rsalcara marked this conversation as resolved.

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!
Expand Down Expand Up @@ -3707,7 +3713,7 @@
;(async () => {
try {
await delay(1500)
const msg =

Check warning on line 3716 in src/Socket/messages-recv.ts

View workflow job for this annotation

GitHub Actions / check-lint

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check warning on line 3716 in src/Socket/messages-recv.ts

View workflow job for this annotation

GitHub Actions / check-lint

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
(await getMessage(key)) ??
// Fallback: ack can arrive <30ms after send, before store persists
messageRetryManager?.getRecentMessage(jid, msgId)?.message
Expand Down
140 changes: 125 additions & 15 deletions src/Socket/newsletter.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -39,19 +40,111 @@ 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. 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
// 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<string, unknown>
const node = (raw.result && typeof raw.result === 'object' ? raw.result : raw) as Record<string, any>

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 ?? node.image ?? node.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,
Comment thread
rsalcara marked this conversation as resolved.
Comment thread
rsalcara marked this conversation as resolved.
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 `<message>` node from a newsletter `<messages>` response.
*
* Port of upstream PR #2620 (vinikjkkj). The server wraps the proto-encoded
* `Message` in a `<plaintext>` 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) => {
Expand Down Expand Up @@ -167,33 +260,50 @@ 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) {
Comment thread
rsalcara marked this conversation as resolved.
messagesAttrs.before = since.toString()
}
Comment thread
rsalcara marked this conversation as resolved.

if (after) {
messageUpdateAttrs.after = after.toString()
messagesAttrs.after = after.toString()
}

const result = await query({
tag: 'iq',
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> => {
Expand Down
19 changes: 0 additions & 19 deletions src/Types/Newsletter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions src/Utils/contact-picture-identity.ts
Original file line number Diff line number Diff line change
@@ -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) {
Comment thread
rsalcara marked this conversation as resolved.
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
}
1 change: 1 addition & 0 deletions src/Utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ===
Expand Down
12 changes: 11 additions & 1 deletion src/Utils/multi-db-sqlite/schemas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -62,6 +70,7 @@ export const MULTI_DB_FILES = [
'payments.db',
'stickers.db',
'smb.db',
'status.db',
'prometheus.db'
] as const

Expand All @@ -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
}
Loading
Loading