Skip to content

Commit 07b9b3b

Browse files
committed
feat(events): add staticId, longer uppercase ids, mention LID->PN resolution, robust timestamps
uniqueId is now a 16-char uppercase hex; add staticId (stable hash of roomId+senderId). Resolve @lid mentions to PN via the lid mapping, normalize device suffixes, and rewrite inline @numbers in text to match. Parse messageTimestamp from number/string/Long/{low,high} so stored quoted messages keep their real time.
1 parent 9778321 commit 07b9b3b

6 files changed

Lines changed: 217 additions & 31 deletions

File tree

src/client/client.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,7 @@ export class Client extends TypedEventEmitter<ClientEventMap> {
710710
groupMetadata: (groupId) => this.group.metadata(groupId).catch(() => null),
711711
receiverName: () => Promise.resolve(this.resolveMe().name ?? null),
712712
resolveQuoted: (id, remoteJid) => this.lookupQuoted(id, remoteJid),
713+
resolveLidToPn: (lid) => this.lidToPn(lid),
713714
sendReply: async (target, content, opts, quoted) =>
714715
await this.send(target).text(content, opts).reply(quoted),
715716
react: (key, emoji) => this.react(key, emoji),
@@ -736,6 +737,17 @@ export class Client extends TypedEventEmitter<ClientEventMap> {
736737
}
737738
}
738739

740+
private async lidToPn(lid: string): Promise<string | null> {
741+
try {
742+
const repo = (this._socket as { signalRepository?: { lidMapping?: { getPNForLID?: (l: string) => Promise<string | null> } } } | undefined)?.signalRepository
743+
const mapping = repo?.lidMapping
744+
if (mapping == null || typeof mapping.getPNForLID !== 'function') return null
745+
return await mapping.getPNForLID(lid)
746+
} catch {
747+
return null
748+
}
749+
}
750+
739751
private async lookupQuoted(id: string, remoteJid: string): Promise<WAMessage | null> {
740752
for (const fromMe of [false, true]) {
741753
try {

src/events/context.ts

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ export type ContextMedia =
143143

144144
export interface MessageContext {
145145
uniqueId: string
146+
staticId: string
146147
channelId: string
147148
chatId: string
148149
chatType: ChatType
@@ -228,14 +229,37 @@ export const extractLinks = (text: string): string[] => {
228229
return matches.map((url) => url.replace(/[.,;:!?]+$/, ''))
229230
}
230231

231-
export const computeUniqueId = (key: WAMessageKey): string => {
232-
const input = `${key.remoteJid ?? ''}|${key.id ?? ''}|${key.fromMe === true ? '1' : '0'}`
233-
let hash = 0x811c9dc5
232+
const fnv1a = (input: string, seed = 0x811c9dc5): number => {
233+
let hash = seed >>> 0
234234
for (let i = 0; i < input.length; i++) {
235235
hash ^= input.charCodeAt(i)
236-
hash = (Math.imul(hash, 0x01000193) >>> 0)
236+
hash = Math.imul(hash, 0x01000193) >>> 0
237237
}
238-
return hash.toString(16).padStart(8, '0')
238+
return hash >>> 0
239+
}
240+
241+
const hashHex = (input: string): string =>
242+
(fnv1a(input).toString(16).padStart(8, '0') + fnv1a(input, 0x9dc5811c).toString(16).padStart(8, '0')).toUpperCase()
243+
244+
export const computeUniqueId = (key: WAMessageKey): string =>
245+
hashHex(`${key.remoteJid ?? ''}|${key.id ?? ''}|${key.fromMe === true ? '1' : '0'}`)
246+
247+
export const computeStaticId = (roomId: string | null, senderId: string): string =>
248+
hashHex(`${roomId ?? ''}|${senderId}`)
249+
250+
export const epochSecondsToMs = (value: unknown): number => {
251+
let secs: number | null = null
252+
if (typeof value === 'number') secs = value
253+
else if (typeof value === 'bigint') secs = Number(value)
254+
else if (typeof value === 'string') {
255+
const n = Number.parseInt(value, 10)
256+
secs = Number.isFinite(n) ? n : null
257+
} else if (value != null && typeof value === 'object') {
258+
const o = value as { toNumber?: () => number; low?: number; high?: number }
259+
if (typeof o.toNumber === 'function') secs = o.toNumber()
260+
else if (typeof o.low === 'number') secs = (typeof o.high === 'number' ? o.high : 0) * 4294967296 + (o.low >>> 0)
261+
}
262+
return secs != null && Number.isFinite(secs) && secs > 0 ? secs * 1000 : 0
239263
}
240264

241265
export const senderDeviceOf = (jid: string): SenderDevice => {
@@ -293,25 +317,26 @@ export const makeCitation = (
293317
export const buildMessageContext = (input: BuildContextInput): MessageContext => {
294318
const remoteJid = typeof input.key.remoteJid === 'string' ? input.key.remoteJid : null
295319
const isGroup = remoteJid !== null && isGroupJid(remoteJid)
320+
const senderId = input.sender.pn ?? input.sender.jid
321+
const roomId = isGroup
322+
? (remoteJid ? jidNormalizedUser(remoteJid) : null)
323+
: input.key.fromMe === true && remoteJid
324+
? jidNormalizedUser(remoteJid)
325+
: senderId
296326

297327
const ctx: MessageContext = {
298328
uniqueId: computeUniqueId(input.key),
329+
staticId: computeStaticId(roomId, senderId),
299330
channelId: input.channelId,
300331
chatId: input.key.id ?? '',
301332
chatType: input.chatType,
302333
receiverId: input.receiverId ? jidNormalizedUser(input.receiverId) : input.receiverId,
303-
roomId: isGroup
304-
? (remoteJid ? jidNormalizedUser(remoteJid) : null)
305-
: input.key.fromMe === true && remoteJid
306-
? jidNormalizedUser(remoteJid)
307-
: (input.sender.pn ?? input.sender.jid),
308-
senderId: input.sender.pn ?? input.sender.jid,
334+
roomId,
335+
senderId,
309336
senderLid: input.sender.lid ?? null,
310337
senderName: input.sender.pushName ?? null,
311338
senderDevice: senderDeviceOf(input.sender.jid),
312-
timestamp: typeof input.message.messageTimestamp === 'number'
313-
? input.message.messageTimestamp * 1000
314-
: 0,
339+
timestamp: epochSecondsToMs(input.message.messageTimestamp),
315340
text: input.text,
316341
mentions: input.mentions,
317342
links: extractLinks(input.text),

src/events/decoders/messages.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export interface DecodeContext {
2424
selfJid: string
2525
selfLid?: string
2626
selfName?: string
27+
mentionMap?: Map<string, string>
2728
logger?: DownloadLogger
2829
channelId?: string
2930
receiverId?: string
@@ -514,6 +515,33 @@ const decodeQuotedContext = async (
514515
}
515516
}
516517

518+
export const rawMentionsOf = (msg: WAMessage): string[] =>
519+
extractMentions(contextInfoOf(msg)).mentionedJids
520+
521+
const normalizeJid = (jid: string): string => {
522+
try {
523+
return jidNormalizedUser(jid)
524+
} catch {
525+
return jid
526+
}
527+
}
528+
529+
const mapMentions = (jids: string[], ctx: DecodeContext): string[] =>
530+
jids.map((j) => normalizeJid(ctx.mentionMap?.get(j) ?? j))
531+
532+
const userPart = (jid: string): string => (jid.split('@')[0] ?? '').split(':')[0] ?? ''
533+
534+
const syncMentionText =(text: string, map: Map<string, string> | undefined): string => {
535+
if (map == null || map.size === 0 || text.length === 0) return text
536+
let out = text
537+
for (const [lid, pn] of map) {
538+
const from = userPart(lid)
539+
const to = userPart(pn)
540+
if (from.length > 0 && to.length > 0 && from !== to) out = out.split(`@${from}`).join(`@${to}`)
541+
}
542+
return out
543+
}
544+
517545
const buildContext = (
518546
msg: WAMessage,
519547
ctx: DecodeContext,
@@ -567,15 +595,16 @@ const buildContext = (
567595
return ctx.react(key, emoji)
568596
}
569597

570-
const { mentionedJids } = extractMentions(contextInfo)
598+
const mentionedJids = mapMentions(extractMentions(contextInfo).mentionedJids, ctx)
599+
const syncedText = syncMentionText(text, ctx.mentionMap)
571600

572601
const baseInput = {
573602
message: msg,
574603
key,
575604
channelId,
576605
receiverId,
577606
selfJid: ctx.selfJid,
578-
text,
607+
text: syncedText,
579608
chatType,
580609
sender,
581610
mentions: mentionedJids,
@@ -689,9 +718,12 @@ export const decodeMention = (msg: WAMessage, ctx: DecodeContext): MentionContex
689718
const key = msg.key
690719
if (key == null) return null
691720
const contextInfo = contextInfoOf(msg)
692-
const { mentionedJids } = extractMentions(contextInfo)
693-
if (mentionedJids.length === 0) return null
694-
const hasSelf = mentionedJids.some((jid) => normalizedEquals(jid, ctx.selfJid))
721+
const rawMentions = extractMentions(contextInfo).mentionedJids
722+
if (rawMentions.length === 0) return null
723+
const mentionedJids = mapMentions(rawMentions, ctx)
724+
const hasSelf =
725+
mentionedJids.some((jid) => normalizedEquals(jid, ctx.selfJid)) ||
726+
(ctx.selfLid != null && rawMentions.some((jid) => normalizedEquals(jid, ctx.selfLid as string)))
695727
if (!hasSelf) return null
696728
const content = asRecord(msg.message)
697729
if (content == null) return null

src/events/pipeline.ts

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,10 @@ import {
4242
decodeSticker,
4343
decodeText,
4444
decodeVideo,
45+
rawMentionsOf,
4546
type DecodeContext,
4647
} from './decoders/messages.js'
48+
import { isLidJid } from './decoders/_shared.js'
4749
import {
4850
decodeDelete,
4951
decodeEdit,
@@ -70,6 +72,7 @@ export interface InboundPipelineContext {
7072
groupMetadata?: (groupId: string) => Promise<{ subject?: string } | null>
7173
receiverName?: () => Promise<string | null>
7274
resolveQuoted?: (id: string, remoteJid: string) => Promise<WAMessage | null>
75+
resolveLidToPn?: (lid: string) => Promise<string | null>
7376
sendReply?: (target: string, content: string, opts: TextOptions | undefined, quoted: WAMessage) => Promise<WAMessageKey>
7477
react?: (key: WAMessageKey, emoji: string) => Promise<WAMessageKey>
7578
ignoreMe?: boolean
@@ -145,20 +148,39 @@ export function attachInboundPipeline(
145148
cleanups.push(() => socket.ev.off(event, wrapped))
146149
}
147150

148-
const runMessage = (msg: WAMessage): void => {
149-
tryEmit(() => decodeMessage(msg, decodeCtx), (p) => client.emit('message', p))
150-
tryEmit(() => decodeText(msg, decodeCtx), (p) => client.emit('text', p))
151-
tryEmit(() => decodeImage(msg, decodeCtx), (p) => client.emit('image', p))
152-
tryEmit(() => decodeVideo(msg, decodeCtx), (p) => client.emit('video', p))
153-
tryEmit(() => decodeAudio(msg, decodeCtx), (p) => client.emit('audio', p))
154-
tryEmit(() => decodeDocument(msg, decodeCtx), (p) => client.emit('document', p))
155-
tryEmit(() => decodeSticker(msg, decodeCtx), (p) => client.emit('sticker', p))
156-
tryEmit(() => decodeMention(msg, decodeCtx), (p) => client.emit('mention', p))
157-
tryEmit(() => decodeMentionAll(msg, decodeCtx), (p) => client.emit('mention-all', p))
151+
const runMessage = (msg: WAMessage, msgCtx: DecodeContext): void => {
152+
tryEmit(() => decodeMessage(msg, msgCtx), (p) => client.emit('message', p))
153+
tryEmit(() => decodeText(msg, msgCtx), (p) => client.emit('text', p))
154+
tryEmit(() => decodeImage(msg, msgCtx), (p) => client.emit('image', p))
155+
tryEmit(() => decodeVideo(msg, msgCtx), (p) => client.emit('video', p))
156+
tryEmit(() => decodeAudio(msg, msgCtx), (p) => client.emit('audio', p))
157+
tryEmit(() => decodeDocument(msg, msgCtx), (p) => client.emit('document', p))
158+
tryEmit(() => decodeSticker(msg, msgCtx), (p) => client.emit('sticker', p))
159+
tryEmit(() => decodeMention(msg, msgCtx), (p) => client.emit('mention', p))
160+
tryEmit(() => decodeMentionAll(msg, msgCtx), (p) => client.emit('mention-all', p))
158161
tryEmit(() => decodeButtonClick(msg, interactiveCtx), (p) => client.emit('button-click', p))
159162
tryEmit(() => decodeListSelect(msg, interactiveCtx), (p) => client.emit('list-select', p))
160163
}
161164

165+
const buildMentionMap = async (msg: WAMessage): Promise<Map<string, string> | undefined> => {
166+
const resolve = ctx.resolveLidToPn
167+
if (resolve == null) return undefined
168+
const lids = [...new Set(rawMentionsOf(msg).filter(isLidJid))]
169+
if (lids.length === 0) return undefined
170+
const map = new Map<string, string>()
171+
await Promise.all(
172+
lids.map(async (lid) => {
173+
try {
174+
const pn = await resolve(lid)
175+
if (pn != null && pn.length > 0) map.set(lid, pn)
176+
} catch (err) {
177+
ctx.logger?.warn(err, 'inbound pipeline: lid->pn resolve threw')
178+
}
179+
}),
180+
)
181+
return map.size > 0 ? map : undefined
182+
}
183+
162184
const tryEmit = <T>(decode: () => T | null, emit: (payload: T) => void): void => {
163185
let payload: T | null
164186
try {
@@ -175,7 +197,15 @@ export function attachInboundPipeline(
175197
const upsert = dropSpoofedSelfOnly(raw as UpsertPayload)
176198
for (const msg of upsert.messages) {
177199
if (ctx.ignoreMe === true && msg.key?.fromMe === true) continue
178-
runMessage(msg)
200+
const needsResolve =
201+
ctx.resolveLidToPn != null && rawMentionsOf(msg).some(isLidJid)
202+
if (!needsResolve) {
203+
runMessage(msg, decodeCtx)
204+
continue
205+
}
206+
void buildMentionMap(msg).then((mentionMap) =>
207+
runMessage(msg, mentionMap != null ? { ...decodeCtx, mentionMap } : decodeCtx),
208+
)
179209
}
180210
})
181211

tests/events/context.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ vi.mock('baileys', async (importOriginal) => {
88
const {
99
extractLinks,
1010
computeUniqueId,
11+
computeStaticId,
12+
epochSecondsToMs,
1113
isQuestionOf,
1214
isPrefixOf,
1315
isTagMeOf,
@@ -63,14 +65,54 @@ describe('computeUniqueId', () => {
6365

6466
it('returns a non-empty hex string', () => {
6567
const id = computeUniqueId({ remoteJid: 'r', id: 'i', fromMe: false })
66-
expect(id).toMatch(/^[0-9a-f]+$/)
68+
expect(id).toMatch(/^[0-9A-F]+$/)
6769
expect(id.length).toBeGreaterThan(0)
6870
})
6971

7072
it('handles undefined key fields gracefully', () => {
7173
const id = computeUniqueId({ remoteJid: undefined, id: undefined, fromMe: undefined })
7274
expect(typeof id).toBe('string')
7375
})
76+
77+
it('emits a 16-char hex id', () => {
78+
expect(computeUniqueId({ remoteJid: 'r', id: 'i', fromMe: false })).toMatch(/^[0-9A-F]{16}$/)
79+
})
80+
})
81+
82+
describe('computeStaticId', () => {
83+
it('is stable for the same room+sender pair regardless of message', () => {
84+
const a = computeStaticId('628room@s.whatsapp.net', '628me@s.whatsapp.net')
85+
const b = computeStaticId('628room@s.whatsapp.net', '628me@s.whatsapp.net')
86+
expect(a).toBe(b)
87+
expect(a).toMatch(/^[0-9A-F]{16}$/)
88+
})
89+
90+
it('differs when room or sender differs', () => {
91+
const base = computeStaticId('room@g.us', 'a@s.whatsapp.net')
92+
expect(base).not.toBe(computeStaticId('room@g.us', 'b@s.whatsapp.net'))
93+
expect(base).not.toBe(computeStaticId('other@g.us', 'a@s.whatsapp.net'))
94+
})
95+
})
96+
97+
describe('epochSecondsToMs', () => {
98+
const SECS = 1782132231
99+
it('converts a number (seconds) to ms', () => {
100+
expect(epochSecondsToMs(SECS)).toBe(SECS * 1000)
101+
})
102+
it('parses string seconds', () => {
103+
expect(epochSecondsToMs(String(SECS))).toBe(SECS * 1000)
104+
})
105+
it('handles Long-like with toNumber', () => {
106+
expect(epochSecondsToMs({ toNumber: () => SECS })).toBe(SECS * 1000)
107+
})
108+
it('handles serialized Long {low, high}', () => {
109+
expect(epochSecondsToMs({ low: SECS, high: 0 })).toBe(SECS * 1000)
110+
})
111+
it('returns 0 for missing/invalid', () => {
112+
expect(epochSecondsToMs(undefined)).toBe(0)
113+
expect(epochSecondsToMs(0)).toBe(0)
114+
expect(epochSecondsToMs('nope')).toBe(0)
115+
})
74116
})
75117

76118
describe('isQuestionOf', () => {

tests/events/pipeline.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,51 @@ describe('attachInboundPipeline — messages.upsert', () => {
9595
expect(seen).not.toHaveBeenCalled()
9696
})
9797

98+
it('resolves LID mentions to PN via resolveLidToPn', async () => {
99+
const client = new TypedEventEmitter<ClientEventMap>()
100+
const socket = makeInboundSocket({ user: { id: SELF } })
101+
attachInboundPipeline(
102+
client,
103+
socket as unknown as Parameters<typeof attachInboundPipeline>[1],
104+
{
105+
selfJid: SELF,
106+
resolveLidToPn: async (lid) =>
107+
lid === '66554863583429@lid' ? '628999:0@s.whatsapp.net' : null,
108+
},
109+
)
110+
const seen = vi.fn()
111+
client.on('text', seen)
112+
socket.triggerMessagesUpsert({
113+
messages: [
114+
textMsg('hey @66554863583429', {
115+
message: { extendedTextMessage: { text: 'hey @66554863583429', contextInfo: { mentionedJid: ['66554863583429@lid'] } } },
116+
}),
117+
],
118+
type: 'notify',
119+
})
120+
await new Promise((r) => setTimeout(r, 0))
121+
expect(seen).toHaveBeenCalledTimes(1)
122+
const ctx = seen.mock.calls[0]?.[0]
123+
expect(ctx.mentions).toEqual(['628999@s.whatsapp.net'])
124+
expect(ctx.text).toBe('hey @628999')
125+
})
126+
127+
it('keeps unmapped LID mentions (best-effort) and stays sync without a resolver', () => {
128+
const { client, socket } = setup()
129+
const seen = vi.fn()
130+
client.on('text', seen)
131+
socket.triggerMessagesUpsert({
132+
messages: [
133+
textMsg('@x', {
134+
message: { extendedTextMessage: { text: '@x', contextInfo: { mentionedJid: ['66554863583429@lid'] } } },
135+
}),
136+
],
137+
type: 'notify',
138+
})
139+
expect(seen).toHaveBeenCalledTimes(1)
140+
expect(seen.mock.calls[0]?.[0].mentions).toEqual(['66554863583429@lid'])
141+
})
142+
98143
it('emits both text and mention when self mentioned (multi-decoder)', () => {
99144
const { client, socket } = setup()
100145
const text = vi.fn()

0 commit comments

Comments
 (0)