Skip to content

Commit 2e375bf

Browse files
committed
fix(events): bound LID mention resolution with a timeout and avoid mention-text collisions
A hanging lid->pn resolver no longer swallows the message: resolution is capped at 3s and the message emits with the raw LID on timeout or failure. Inline @Number rewrites now apply longest-first to avoid substring clashes.
1 parent 07b9b3b commit 2e375bf

3 files changed

Lines changed: 55 additions & 10 deletions

File tree

src/events/decoders/messages.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -531,14 +531,14 @@ const mapMentions = (jids: string[], ctx: DecodeContext): string[] =>
531531

532532
const userPart = (jid: string): string => (jid.split('@')[0] ?? '').split(':')[0] ?? ''
533533

534-
const syncMentionText =(text: string, map: Map<string, string> | undefined): string => {
534+
const syncMentionText = (text: string, map: Map<string, string> | undefined): string => {
535535
if (map == null || map.size === 0 || text.length === 0) return text
536+
const pairs = [...map]
537+
.map(([lid, pn]) => [userPart(lid), userPart(pn)] as const)
538+
.filter(([from, to]) => from.length > 0 && to.length > 0 && from !== to)
539+
.sort((a, b) => b[0].length - a[0].length)
536540
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-
}
541+
for (const [from, to] of pairs) out = out.split(`@${from}`).join(`@${to}`)
542542
return out
543543
}
544544

src/events/pipeline.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,17 @@ type ClientEmitter = TypedEventEmitter<ClientEventMap>
9191

9292
const asArray = <T>(value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : [])
9393

94+
const MENTION_RESOLVE_TIMEOUT_MS = 3000
95+
96+
const raceTimeout = <T>(p: Promise<T>, ms: number): Promise<T | null> =>
97+
new Promise<T | null>((resolve) => {
98+
const timer = setTimeout(() => resolve(null), ms)
99+
p.then(
100+
(v) => { clearTimeout(timer); resolve(v) },
101+
() => { clearTimeout(timer); resolve(null) },
102+
)
103+
})
104+
94105
const connectionReachout = (update: unknown): RawReachoutTimelock | null => {
95106
if (update == null || typeof update !== 'object') return null
96107
const lock = (update as { reachoutTimeLock?: unknown }).reachoutTimeLock
@@ -171,7 +182,7 @@ export function attachInboundPipeline(
171182
await Promise.all(
172183
lids.map(async (lid) => {
173184
try {
174-
const pn = await resolve(lid)
185+
const pn = await raceTimeout(Promise.resolve(resolve(lid)), MENTION_RESOLVE_TIMEOUT_MS)
175186
if (pn != null && pn.length > 0) map.set(lid, pn)
176187
} catch (err) {
177188
ctx.logger?.warn(err, 'inbound pipeline: lid->pn resolve threw')
@@ -203,9 +214,14 @@ export function attachInboundPipeline(
203214
runMessage(msg, decodeCtx)
204215
continue
205216
}
206-
void buildMentionMap(msg).then((mentionMap) =>
207-
runMessage(msg, mentionMap != null ? { ...decodeCtx, mentionMap } : decodeCtx),
208-
)
217+
void buildMentionMap(msg)
218+
.then((mentionMap) =>
219+
runMessage(msg, mentionMap != null ? { ...decodeCtx, mentionMap } : decodeCtx),
220+
)
221+
.catch((err) => {
222+
ctx.logger?.warn(err, 'inbound pipeline: deferred mention emit failed')
223+
runMessage(msg, decodeCtx)
224+
})
209225
}
210226
})
211227

tests/events/pipeline.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,35 @@ describe('attachInboundPipeline — messages.upsert', () => {
124124
expect(ctx.text).toBe('hey @628999')
125125
})
126126

127+
it('still emits the message when the LID resolver hangs (timeout fallback)', async () => {
128+
vi.useFakeTimers()
129+
try {
130+
const client = new TypedEventEmitter<ClientEventMap>()
131+
const socket = makeInboundSocket({ user: { id: SELF } })
132+
attachInboundPipeline(
133+
client,
134+
socket as unknown as Parameters<typeof attachInboundPipeline>[1],
135+
{ selfJid: SELF, resolveLidToPn: () => new Promise<string | null>(() => {}) },
136+
)
137+
const seen = vi.fn()
138+
client.on('text', seen)
139+
socket.triggerMessagesUpsert({
140+
messages: [
141+
textMsg('hi @66554863583429', {
142+
message: { extendedTextMessage: { text: 'hi @66554863583429', contextInfo: { mentionedJid: ['66554863583429@lid'] } } },
143+
}),
144+
],
145+
type: 'notify',
146+
})
147+
expect(seen).not.toHaveBeenCalled()
148+
await vi.advanceTimersByTimeAsync(3001)
149+
expect(seen).toHaveBeenCalledTimes(1)
150+
expect(seen.mock.calls[0]?.[0].mentions).toEqual(['66554863583429@lid'])
151+
} finally {
152+
vi.useRealTimers()
153+
}
154+
})
155+
127156
it('keeps unmapped LID mentions (best-effort) and stays sync without a resolver', () => {
128157
const { client, socket } = setup()
129158
const seen = vi.fn()

0 commit comments

Comments
 (0)