fix: eliminate 40s message delivery delay caused by libsignal console dumps
fix: eliminate 40s message delivery delay caused by libsignal console dumps
This commit is contained in:
@@ -24,9 +24,6 @@ jobs:
|
|||||||
corepack enable
|
corepack enable
|
||||||
corepack prepare yarn@4.x --activate
|
corepack prepare yarn@4.x --activate
|
||||||
|
|
||||||
- name: Fix Git config
|
|
||||||
run: git config --global core.autocrlf input
|
|
||||||
|
|
||||||
- name: Configure Git for HTTPS
|
- name: Configure Git for HTTPS
|
||||||
run: |
|
run: |
|
||||||
# Reescreve SSH -> HTTPS para repositórios públicos do GitHub (sem token, evita erros de autenticação)
|
# Reescreve SSH -> HTTPS para repositórios públicos do GitHub (sem token, evita erros de autenticação)
|
||||||
@@ -48,6 +45,8 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
GIT_TERMINAL_PROMPT: 0
|
GIT_TERMINAL_PROMPT: 0
|
||||||
|
YARN_HTTP_TIMEOUT: 600000
|
||||||
|
YARN_HTTP_RETRY: 10
|
||||||
run: yarn install --immutable
|
run: yarn install --immutable
|
||||||
|
|
||||||
- name: Auto-fix formatting
|
- name: Auto-fix formatting
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
nodeLinker: node-modules
|
nodeLinker: node-modules
|
||||||
|
httpRetry: 10
|
||||||
|
httpTimeout: 600000
|
||||||
|
|||||||
@@ -552,7 +552,10 @@ export function makeLibSignalRepository(
|
|||||||
|
|
||||||
// All devices already confirmed as migrated — skip DB lookups entirely
|
// All devices already confirmed as migrated — skip DB lookups entirely
|
||||||
if (uncachedDevices.length === 0) {
|
if (uncachedDevices.length === 0) {
|
||||||
logger.debug({ fromJid, totalDevices: userDevices.length }, 'bulk device migration - all devices already cached, skipping')
|
logger.debug(
|
||||||
|
{ fromJid, totalDevices: userDevices.length },
|
||||||
|
'bulk device migration - all devices already cached, skipping'
|
||||||
|
)
|
||||||
return { migrated: 0, skipped: 0, total: userDevices.length }
|
return { migrated: 0, skipped: 0, total: userDevices.length }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -593,6 +596,7 @@ export function makeLibSignalRepository(
|
|||||||
for (const device of uncachedDevices) {
|
for (const device of uncachedDevices) {
|
||||||
migratedSessionCache.set(`${user}.${device}`, true)
|
migratedSessionCache.set(`${user}.${device}`, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { migrated: 0, skipped: 0, total: userDevices.length }
|
return { migrated: 0, skipped: 0, total: userDevices.length }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -2,7 +2,12 @@ import NodeCache from '@cacheable/node-cache'
|
|||||||
import { Boom } from '@hapi/boom'
|
import { Boom } from '@hapi/boom'
|
||||||
import { LRUCache } from 'lru-cache'
|
import { LRUCache } from 'lru-cache'
|
||||||
import { proto } from '../../WAProto/index.js'
|
import { proto } from '../../WAProto/index.js'
|
||||||
import { DEFAULT_CACHE_MAX_KEYS, DEFAULT_CACHE_TTLS, HISTORY_SYNC_PAUSED_TIMEOUT_MS, PROCESSABLE_HISTORY_TYPES } from '../Defaults'
|
import {
|
||||||
|
DEFAULT_CACHE_MAX_KEYS,
|
||||||
|
DEFAULT_CACHE_TTLS,
|
||||||
|
HISTORY_SYNC_PAUSED_TIMEOUT_MS,
|
||||||
|
PROCESSABLE_HISTORY_TYPES
|
||||||
|
} from '../Defaults'
|
||||||
import type {
|
import type {
|
||||||
BotListInfo,
|
BotListInfo,
|
||||||
CacheStore,
|
CacheStore,
|
||||||
@@ -777,7 +782,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
|||||||
me && (normalizedJid === jidNormalizedUser(me.id) || (me.lid && normalizedJid === jidNormalizedUser(me.lid)))
|
me && (normalizedJid === jidNormalizedUser(me.id) || (me.lid && normalizedJid === jidNormalizedUser(me.lid)))
|
||||||
let content: BinaryNode[] | undefined = baseContent
|
let content: BinaryNode[] | undefined = baseContent
|
||||||
|
|
||||||
if(isUserJid && !isSelf) {
|
if (isUserJid && !isSelf) {
|
||||||
content = await buildTcTokenFromJid({
|
content = await buildTcTokenFromJid({
|
||||||
authState,
|
authState,
|
||||||
jid: normalizedJid,
|
jid: normalizedJid,
|
||||||
@@ -1438,7 +1443,9 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
|||||||
// of the state machine phase (see the Syncing → Online path below).
|
// of the state machine phase (see the Syncing → Online path below).
|
||||||
const isReconnection = (authState.creds.accountSyncCounter ?? 0) > 0
|
const isReconnection = (authState.creds.accountSyncCounter ?? 0) > 0
|
||||||
if (isReconnection) {
|
if (isReconnection) {
|
||||||
logger.info('Reconnection detected (accountSyncCounter > 0), skipping AwaitingInitialSync wait. Transitioning to Online immediately.')
|
logger.info(
|
||||||
|
'Reconnection detected (accountSyncCounter > 0), skipping AwaitingInitialSync wait. Transitioning to Online immediately.'
|
||||||
|
)
|
||||||
blockedCollections.clear()
|
blockedCollections.clear()
|
||||||
syncState = SyncState.Online
|
syncState = SyncState.Online
|
||||||
const accountSyncCounter = (authState.creds.accountSyncCounter || 0) + 1
|
const accountSyncCounter = (authState.creds.accountSyncCounter || 0) + 1
|
||||||
|
|||||||
+73
-48
@@ -47,8 +47,8 @@ import {
|
|||||||
MISSING_KEYS_ERROR_TEXT,
|
MISSING_KEYS_ERROR_TEXT,
|
||||||
NACK_REASONS,
|
NACK_REASONS,
|
||||||
NO_MESSAGE_FOUND_ERROR_TEXT,
|
NO_MESSAGE_FOUND_ERROR_TEXT,
|
||||||
SERVER_ERROR_CODES,
|
|
||||||
normalizeMessageJids,
|
normalizeMessageJids,
|
||||||
|
SERVER_ERROR_CODES,
|
||||||
toNumber,
|
toNumber,
|
||||||
unixTimestampSeconds,
|
unixTimestampSeconds,
|
||||||
xmppPreKey,
|
xmppPreKey,
|
||||||
@@ -63,6 +63,7 @@ import {
|
|||||||
recordMessageReceived,
|
recordMessageReceived,
|
||||||
recordMessageRetry
|
recordMessageRetry
|
||||||
} from '../Utils/prometheus-metrics.js'
|
} from '../Utils/prometheus-metrics.js'
|
||||||
|
import { isTcTokenExpired, resolveTcTokenJid } from '../Utils/tc-token-utils'
|
||||||
import {
|
import {
|
||||||
areJidsSameUser,
|
areJidsSameUser,
|
||||||
type BinaryNode,
|
type BinaryNode,
|
||||||
@@ -81,7 +82,6 @@ import {
|
|||||||
jidNormalizedUser,
|
jidNormalizedUser,
|
||||||
S_WHATSAPP_NET
|
S_WHATSAPP_NET
|
||||||
} from '../WABinary'
|
} from '../WABinary'
|
||||||
import { isTcTokenExpired, resolveTcTokenJid } from '../Utils/tc-token-utils'
|
|
||||||
import { extractGroupMetadata } from './groups'
|
import { extractGroupMetadata } from './groups'
|
||||||
import { makeMessagesSocket } from './messages-send'
|
import { makeMessagesSocket } from './messages-send'
|
||||||
|
|
||||||
@@ -166,23 +166,25 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
try {
|
try {
|
||||||
const data = await authState.keys.get('tctoken', [TC_TOKEN_INDEX_KEY, TC_TOKEN_PRUNE_TS_KEY])
|
const data = await authState.keys.get('tctoken', [TC_TOKEN_INDEX_KEY, TC_TOKEN_PRUNE_TS_KEY])
|
||||||
const entry = data[TC_TOKEN_INDEX_KEY]
|
const entry = data[TC_TOKEN_INDEX_KEY]
|
||||||
if(entry?.token) {
|
if (entry?.token) {
|
||||||
const stored = JSON.parse(Buffer.from(entry.token).toString('utf8'))
|
const stored = JSON.parse(Buffer.from(entry.token).toString('utf8'))
|
||||||
if(Array.isArray(stored)) {
|
if (Array.isArray(stored)) {
|
||||||
for(const jid of stored) tcTokenKnownJids.add(jid)
|
for (const jid of stored) tcTokenKnownJids.add(jid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pruneEntry = data[TC_TOKEN_PRUNE_TS_KEY]
|
const pruneEntry = data[TC_TOKEN_PRUNE_TS_KEY]
|
||||||
if(pruneEntry?.timestamp) {
|
if (pruneEntry?.timestamp) {
|
||||||
lastTcTokenPruneTs = Number(pruneEntry.timestamp)
|
lastTcTokenPruneTs = Number(pruneEntry.timestamp)
|
||||||
}
|
}
|
||||||
} catch { /* first run or corrupt index — start fresh */ }
|
} catch {
|
||||||
|
/* first run or corrupt index — start fresh */
|
||||||
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
/** Debounced save of the tctoken JID index (5s) */
|
/** Debounced save of the tctoken JID index (5s) */
|
||||||
const scheduleTcTokenIndexSave = () => {
|
const scheduleTcTokenIndexSave = () => {
|
||||||
if(tcTokenIndexSaveTimer) clearTimeout(tcTokenIndexSaveTimer)
|
if (tcTokenIndexSaveTimer) clearTimeout(tcTokenIndexSaveTimer)
|
||||||
tcTokenIndexSaveTimer = setTimeout(async () => {
|
tcTokenIndexSaveTimer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const arr = Array.from(tcTokenKnownJids)
|
const arr = Array.from(tcTokenKnownJids)
|
||||||
@@ -194,7 +196,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch(err) {
|
} catch (err) {
|
||||||
logger.debug({ err }, 'failed to persist tctoken index')
|
logger.debug({ err }, 'failed to persist tctoken index')
|
||||||
}
|
}
|
||||||
}, 5000)
|
}, 5000)
|
||||||
@@ -207,13 +209,13 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
const survivingJids: string[] = []
|
const survivingJids: string[] = []
|
||||||
|
|
||||||
const jidsToCheck = Array.from(tcTokenKnownJids).filter(j => j !== TC_TOKEN_INDEX_KEY)
|
const jidsToCheck = Array.from(tcTokenKnownJids).filter(j => j !== TC_TOKEN_INDEX_KEY)
|
||||||
if(!jidsToCheck.length) return
|
if (!jidsToCheck.length) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const allData = await authState.keys.get('tctoken', jidsToCheck)
|
const allData = await authState.keys.get('tctoken', jidsToCheck)
|
||||||
for(const jid of jidsToCheck) {
|
for (const jid of jidsToCheck) {
|
||||||
const entry = allData[jid]
|
const entry = allData[jid]
|
||||||
if(!entry?.token || isTcTokenExpired(entry.timestamp)) {
|
if (!entry?.token || isTcTokenExpired(entry.timestamp)) {
|
||||||
pruneSet[jid] = null
|
pruneSet[jid] = null
|
||||||
} else {
|
} else {
|
||||||
survivingJids.push(jid)
|
survivingJids.push(jid)
|
||||||
@@ -224,10 +226,10 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pruneCount = Object.keys(pruneSet).length
|
const pruneCount = Object.keys(pruneSet).length
|
||||||
if(pruneCount > 0) {
|
if (pruneCount > 0) {
|
||||||
await authState.keys.set({ tctoken: pruneSet })
|
await authState.keys.set({ tctoken: pruneSet })
|
||||||
tcTokenKnownJids.clear()
|
tcTokenKnownJids.clear()
|
||||||
for(const jid of survivingJids) tcTokenKnownJids.add(jid)
|
for (const jid of survivingJids) tcTokenKnownJids.add(jid)
|
||||||
scheduleTcTokenIndexSave()
|
scheduleTcTokenIndexSave()
|
||||||
logTcToken('prune', { pruned: pruneCount, remaining: survivingJids.length })
|
logTcToken('prune', { pruned: pruneCount, remaining: survivingJids.length })
|
||||||
}
|
}
|
||||||
@@ -720,22 +722,26 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// When a session is refreshed (identity change), re-issue tctoken fire-and-forget
|
// When a session is refreshed (identity change), re-issue tctoken fire-and-forget
|
||||||
if(result.action === 'session_refreshed') {
|
if (result.action === 'session_refreshed') {
|
||||||
const normalizedJid = jidNormalizedUser(from)
|
const normalizedJid = jidNormalizedUser(from)
|
||||||
resolveTcTokenJid(normalizedJid, getLIDForPN).then(async (tcJid) => {
|
resolveTcTokenJid(normalizedJid, getLIDForPN)
|
||||||
|
.then(async tcJid => {
|
||||||
const tcData = await authState.keys.get('tctoken', [tcJid])
|
const tcData = await authState.keys.get('tctoken', [tcJid])
|
||||||
const entry = tcData[tcJid]
|
const entry = tcData[tcJid]
|
||||||
if(entry?.token?.length && !isTcTokenExpired(entry.timestamp)) {
|
if (entry?.token?.length && !isTcTokenExpired(entry.timestamp)) {
|
||||||
const senderTs = unixTimestampSeconds()
|
const senderTs = unixTimestampSeconds()
|
||||||
logTcToken('reissue', { jid: normalizedJid, reason: 'session_refreshed' })
|
logTcToken('reissue', { jid: normalizedJid, reason: 'session_refreshed' })
|
||||||
getPrivacyTokens([normalizedJid], senderTs).catch(err => {
|
getPrivacyTokens([normalizedJid], senderTs).catch(err => {
|
||||||
logTcToken('reissue_fail', { jid: normalizedJid, error: err?.message })
|
logTcToken('reissue_fail', { jid: normalizedJid, error: err?.message })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}).catch(() => { /* ignore resolution errors */ })
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* ignore resolution errors */
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if(result.action === 'no_identity_node') {
|
if (result.action === 'no_identity_node') {
|
||||||
logger.info({ node }, 'unknown encrypt notification')
|
logger.info({ node }, 'unknown encrypt notification')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1060,32 +1066,32 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
const tokensNode = getBinaryNodeChild(node, 'tokens')
|
const tokensNode = getBinaryNodeChild(node, 'tokens')
|
||||||
const from = jidNormalizedUser(node.attrs.from)
|
const from = jidNormalizedUser(node.attrs.from)
|
||||||
|
|
||||||
if(!tokensNode) return
|
if (!tokensNode) return
|
||||||
|
|
||||||
const tokenNodes = getBinaryNodeChildren(tokensNode, 'token')
|
const tokenNodes = getBinaryNodeChildren(tokensNode, 'token')
|
||||||
|
|
||||||
for(const tokenNode of tokenNodes) {
|
for (const tokenNode of tokenNodes) {
|
||||||
const { attrs, content } = tokenNode
|
const { attrs, content } = tokenNode
|
||||||
const type = attrs.type
|
const type = attrs.type
|
||||||
const timestamp = attrs.t
|
const timestamp = attrs.t
|
||||||
|
|
||||||
if(type === 'trusted_contact' && content instanceof Uint8Array) {
|
if (type === 'trusted_contact' && content instanceof Uint8Array) {
|
||||||
// Resolve to LID for consistent storage key
|
// Resolve to LID for consistent storage key
|
||||||
const senderLid = attrs.sender_lid ? jidNormalizedUser(attrs.sender_lid) : undefined
|
const senderLid = attrs.sender_lid ? jidNormalizedUser(attrs.sender_lid) : undefined
|
||||||
const storageJid = senderLid || await resolveTcTokenJid(from, getLIDForPN)
|
const storageJid = senderLid || (await resolveTcTokenJid(from, getLIDForPN))
|
||||||
|
|
||||||
// Timestamp monotonicity guard — only store if incoming >= existing
|
// Timestamp monotonicity guard — only store if incoming >= existing
|
||||||
const existingData = await authState.keys.get('tctoken', [storageJid])
|
const existingData = await authState.keys.get('tctoken', [storageJid])
|
||||||
const existing = existingData[storageJid]
|
const existing = existingData[storageJid]
|
||||||
const existingTs = existing?.timestamp ? Number(existing.timestamp) : 0
|
const existingTs = existing?.timestamp ? Number(existing.timestamp) : 0
|
||||||
const incomingTs = timestamp ? Number(timestamp) : 0
|
const incomingTs = timestamp ? Number(timestamp) : 0
|
||||||
if(existingTs > 0 && incomingTs > 0 && existingTs > incomingTs) {
|
if (existingTs > 0 && incomingTs > 0 && existingTs > incomingTs) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't store timestamp-less tokens — they expire immediately and would
|
// Don't store timestamp-less tokens — they expire immediately and would
|
||||||
// corrupt a valid existing entry if one is already present
|
// corrupt a valid existing entry if one is already present
|
||||||
if(!incomingTs) {
|
if (!incomingTs) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1263,7 +1269,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (shouldIgnoreJid(remoteJid!) && remoteJid !== S_WHATSAPP_NET) {
|
if (shouldIgnoreJid(remoteJid!) && remoteJid !== S_WHATSAPP_NET) {
|
||||||
logger.debug({ remoteJid }, 'ignoring receipt from jid')
|
logger.trace({ remoteJid }, 'ignoring receipt from jid')
|
||||||
await sendMessageAck(node)
|
await sendMessageAck(node)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1343,7 +1349,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
const handleNotification = async (node: BinaryNode) => {
|
const handleNotification = async (node: BinaryNode) => {
|
||||||
const remoteJid = node.attrs.from
|
const remoteJid = node.attrs.from
|
||||||
if (shouldIgnoreJid(remoteJid!) && remoteJid !== S_WHATSAPP_NET) {
|
if (shouldIgnoreJid(remoteJid!) && remoteJid !== S_WHATSAPP_NET) {
|
||||||
logger.debug({ remoteJid, id: node.attrs.id }, 'ignored notification')
|
logger.trace({ remoteJid }, 'ignored notification')
|
||||||
await sendMessageAck(node)
|
await sendMessageAck(node)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1379,8 +1385,11 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
|
|
||||||
const handleMessage = async (node: BinaryNode) => {
|
const handleMessage = async (node: BinaryNode) => {
|
||||||
if (shouldIgnoreJid(node.attrs.from!) && node.attrs.from !== S_WHATSAPP_NET) {
|
if (shouldIgnoreJid(node.attrs.from!) && node.attrs.from !== S_WHATSAPP_NET) {
|
||||||
logger.debug({ key: node.attrs.key }, 'ignored message')
|
logger.trace({ from: node.attrs.from }, 'ignored message')
|
||||||
await sendMessageAck(node, NACK_REASONS.UnhandledError)
|
// Send a clean ACK (no error code) so the server considers the
|
||||||
|
// message delivered. Using error 500 (UnhandledError) previously
|
||||||
|
// caused the server to retry delivery, generating duplicate traffic.
|
||||||
|
await sendMessageAck(node)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1879,8 +1888,8 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
|
|
||||||
// error in acknowledgement,
|
// error in acknowledgement,
|
||||||
// device could not display the message
|
// device could not display the message
|
||||||
if(attrs.error) {
|
if (attrs.error) {
|
||||||
if(attrs.error === SERVER_ERROR_CODES.MissingTcToken) {
|
if (attrs.error === SERVER_ERROR_CODES.MissingTcToken) {
|
||||||
const msgId = attrs.id
|
const msgId = attrs.id
|
||||||
const jid = jidNormalizedUser(attrs.from)
|
const jid = jidNormalizedUser(attrs.from)
|
||||||
logTcToken('error_463', { jid, msgId })
|
logTcToken('error_463', { jid, msgId })
|
||||||
@@ -1889,11 +1898,10 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
// then resend. A Set prevents infinite retry loops.
|
// then resend. A Set prevents infinite retry loops.
|
||||||
// Composite key (jid:msgId) ensures retries are isolated per destination.
|
// Composite key (jid:msgId) ensures retries are isolated per destination.
|
||||||
const retryKey = `${jid}:${msgId}`
|
const retryKey = `${jid}:${msgId}`
|
||||||
if(msgId && jid && !tcTokenRetriedMsgIds.has(retryKey)) {
|
if (msgId && jid && !tcTokenRetriedMsgIds.has(retryKey)) {
|
||||||
tcTokenRetriedMsgIds.add(retryKey)
|
tcTokenRetriedMsgIds.add(retryKey)
|
||||||
// Each entry auto-expires after 60s — naturally bounded under normal use
|
// Each entry auto-expires after 60s — naturally bounded under normal use
|
||||||
setTimeout(() => tcTokenRetriedMsgIds.delete(retryKey), 60_000)
|
setTimeout(() => tcTokenRetriedMsgIds.delete(retryKey), 60_000)
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
await delay(1500)
|
await delay(1500)
|
||||||
@@ -1901,21 +1909,26 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
(await getMessage(key)) ??
|
(await getMessage(key)) ??
|
||||||
// Fallback: ack can arrive <30ms after send, before store persists
|
// Fallback: ack can arrive <30ms after send, before store persists
|
||||||
messageRetryManager?.getRecentMessage(jid, msgId)?.message
|
messageRetryManager?.getRecentMessage(jid, msgId)?.message
|
||||||
if(msg) {
|
if (msg) {
|
||||||
await relayMessage(jid, msg, { messageId: msgId, useUserDevicesCache: true })
|
await relayMessage(jid, msg, { messageId: msgId, useUserDevicesCache: true })
|
||||||
logTcToken('retry_463_ok', { jid, msgId })
|
logTcToken('retry_463_ok', { jid, msgId })
|
||||||
} else {
|
} else {
|
||||||
logger.warn({ jid, msgId }, '463 retry: message not found in store')
|
logger.warn({ jid, msgId }, '463 retry: message not found in store')
|
||||||
ev.emit('messages.update', [{ key, update: { status: WAMessageStatus.ERROR, messageStubParameters: ['463'] } }])
|
ev.emit('messages.update', [
|
||||||
|
{ key, update: { status: WAMessageStatus.ERROR, messageStubParameters: ['463'] } }
|
||||||
|
])
|
||||||
}
|
}
|
||||||
} catch(err: any) {
|
} catch (err: any) {
|
||||||
logger.warn({ jid, msgId, err: err?.message }, '463 retry failed')
|
logger.warn({ jid, msgId, err: err?.message }, '463 retry failed')
|
||||||
ev.emit('messages.update', [{ key, update: { status: WAMessageStatus.ERROR, messageStubParameters: ['463'] } }])
|
ev.emit('messages.update', [
|
||||||
|
{ key, update: { status: WAMessageStatus.ERROR, messageStubParameters: ['463'] } }
|
||||||
|
])
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else if(attrs.error === SERVER_ERROR_CODES.SmaxInvalid) {
|
} else if (attrs.error === SERVER_ERROR_CODES.SmaxInvalid) {
|
||||||
logTcToken('error_479', { jid: attrs.from, msgId: attrs.id })
|
logTcToken('error_479', { jid: attrs.from, msgId: attrs.id })
|
||||||
} else {
|
} else {
|
||||||
logger.warn({ attrs }, 'received error in ack')
|
logger.warn({ attrs }, 'received error in ack')
|
||||||
@@ -2087,42 +2100,54 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
|
|
||||||
ev.on('connection.update', ({ isOnline, connection }) => {
|
ev.on('connection.update', ({ isOnline, connection }) => {
|
||||||
// Flush pending tctoken index save on disconnect to avoid writing after close
|
// Flush pending tctoken index save on disconnect to avoid writing after close
|
||||||
if(connection === 'close' && tcTokenIndexSaveTimer) {
|
if (connection === 'close' && tcTokenIndexSaveTimer) {
|
||||||
clearTimeout(tcTokenIndexSaveTimer)
|
clearTimeout(tcTokenIndexSaveTimer)
|
||||||
tcTokenIndexSaveTimer = undefined
|
tcTokenIndexSaveTimer = undefined
|
||||||
// Await index load first — prevents overwriting a more complete persisted index
|
// Await index load first — prevents overwriting a more complete persisted index
|
||||||
// if the connection closes before the initial load finishes.
|
// if the connection closes before the initial load finishes.
|
||||||
tcTokenIndexLoaded.then(() => {
|
tcTokenIndexLoaded
|
||||||
Promise.resolve(authState.keys.set({
|
.then(() => {
|
||||||
|
Promise.resolve(
|
||||||
|
authState.keys.set({
|
||||||
tctoken: {
|
tctoken: {
|
||||||
[TC_TOKEN_INDEX_KEY]: {
|
[TC_TOKEN_INDEX_KEY]: {
|
||||||
token: Buffer.from(JSON.stringify([...tcTokenKnownJids]), 'utf8'),
|
token: Buffer.from(JSON.stringify([...tcTokenKnownJids]), 'utf8'),
|
||||||
timestamp: unixTimestampSeconds().toString()
|
timestamp: unixTimestampSeconds().toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})).catch(() => { /* non-critical */ })
|
})
|
||||||
}).catch(() => { /* non-critical */ })
|
).catch(() => {
|
||||||
|
/* non-critical */
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* non-critical */
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if(typeof isOnline !== 'undefined') {
|
if (typeof isOnline !== 'undefined') {
|
||||||
sendActiveReceipts = isOnline
|
sendActiveReceipts = isOnline
|
||||||
logger.trace(`sendActiveReceipts set to "${sendActiveReceipts}"`)
|
logger.trace(`sendActiveReceipts set to "${sendActiveReceipts}"`)
|
||||||
|
|
||||||
// Prune expired tctokens when coming online (max once per 24h)
|
// Prune expired tctokens when coming online (max once per 24h)
|
||||||
if(isOnline) {
|
if (isOnline) {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const ONE_DAY_MS = 86400000
|
const ONE_DAY_MS = 86400000
|
||||||
if(now - lastTcTokenPruneTs > ONE_DAY_MS) {
|
if (now - lastTcTokenPruneTs > ONE_DAY_MS) {
|
||||||
lastTcTokenPruneTs = now
|
lastTcTokenPruneTs = now
|
||||||
// Persist prune timestamp so it survives restarts
|
// Persist prune timestamp so it survives restarts
|
||||||
Promise.resolve(authState.keys.set({
|
Promise.resolve(
|
||||||
|
authState.keys.set({
|
||||||
tctoken: {
|
tctoken: {
|
||||||
[TC_TOKEN_PRUNE_TS_KEY]: {
|
[TC_TOKEN_PRUNE_TS_KEY]: {
|
||||||
token: Buffer.alloc(0),
|
token: Buffer.alloc(0),
|
||||||
timestamp: now.toString()
|
timestamp: now.toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})).catch(() => { /* non-critical */ })
|
})
|
||||||
|
).catch(() => {
|
||||||
|
/* non-critical */
|
||||||
|
})
|
||||||
pruneExpiredTcTokens().catch(err => {
|
pruneExpiredTcTokens().catch(err => {
|
||||||
logger.debug({ err: err?.message }, 'tctoken pruning failed')
|
logger.debug({ err: err?.message }, 'tctoken pruning failed')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1457,9 +1457,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
|
|||||||
const is1on1Send = !isGroup && !isRetryResend && !isStatus && !isNewsletter && !isPeerMessage
|
const is1on1Send = !isGroup && !isRetryResend && !isStatus && !isNewsletter && !isPeerMessage
|
||||||
|
|
||||||
// Resolve destination to LID for tctoken storage — matches Signal session key pattern
|
// Resolve destination to LID for tctoken storage — matches Signal session key pattern
|
||||||
const tcTokenJid = is1on1Send
|
const tcTokenJid = is1on1Send ? await resolveTcTokenJid(destinationJid, getLIDForPN) : destinationJid
|
||||||
? await resolveTcTokenJid(destinationJid, getLIDForPN)
|
|
||||||
: destinationJid
|
|
||||||
const contactTcTokenData = is1on1Send ? await authState.keys.get('tctoken', [tcTokenJid]) : {}
|
const contactTcTokenData = is1on1Send ? await authState.keys.get('tctoken', [tcTokenJid]) : {}
|
||||||
const existingTokenEntry = contactTcTokenData[tcTokenJid]
|
const existingTokenEntry = contactTcTokenData[tcTokenJid]
|
||||||
let tcTokenBuffer = existingTokenEntry?.token
|
let tcTokenBuffer = existingTokenEntry?.token
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ export const makeSocket = (config: SocketConfig) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Catch timeout and return undefined instead of throwing
|
// Catch timeout and return undefined instead of throwing
|
||||||
if (error instanceof Boom && error.output?.statusCode === DisconnectReason.timedOut) {
|
if (error instanceof Boom && error.output?.statusCode === DisconnectReason.timedOut) {
|
||||||
logger?.warn?.({ msgId }, 'timed out waiting for message')
|
logger?.debug?.({ msgId }, 'timed out waiting for message')
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1539,6 +1539,7 @@ export const makeSocket = (config: SocketConfig) => {
|
|||||||
if (passiveResult.status === 'rejected') {
|
if (passiveResult.status === 'rejected') {
|
||||||
logger.warn({ err: passiveResult.reason }, 'failed to send initial passive iq')
|
logger.warn({ err: passiveResult.reason }, 'failed to send initial passive iq')
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const result of results.slice(1)) {
|
for (const result of results.slice(1)) {
|
||||||
if (result.status === 'rejected') {
|
if (result.status === 'rejected') {
|
||||||
logger.warn({ err: result.reason }, 'background key operation failed after login (non-critical)')
|
logger.warn({ err: result.reason }, 'background key operation failed after login (non-critical)')
|
||||||
@@ -1559,8 +1560,8 @@ export const makeSocket = (config: SocketConfig) => {
|
|||||||
// for the same storage locks while the offline-message backlog is draining.
|
// for the same storage locks while the offline-message backlog is draining.
|
||||||
// start() is idempotent (guarded by cleanupInterval check) so deferring is safe.
|
// start() is idempotent (guarded by cleanupInterval check) so deferring is safe.
|
||||||
const _cleanupStartTimer = setTimeout(() => sessionCleanup.start(), 5_000)
|
const _cleanupStartTimer = setTimeout(() => sessionCleanup.start(), 5_000)
|
||||||
if (typeof (_cleanupStartTimer as NodeJS.Timeout).unref === 'function') {
|
if (typeof _cleanupStartTimer.unref === 'function') {
|
||||||
;(_cleanupStartTimer as NodeJS.Timeout).unref()
|
_cleanupStartTimer.unref()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start session activity tracker immediately (lightweight, no DB scan)
|
// Start session activity tracker immediately (lightweight, no DB scan)
|
||||||
|
|||||||
@@ -958,7 +958,19 @@ export function logLidMapping(
|
|||||||
* // Output: [BAILEYS] 🔑 TcToken expired → 5511999999999@s.whatsapp.net { age: 32d }
|
* // Output: [BAILEYS] 🔑 TcToken expired → 5511999999999@s.whatsapp.net { age: 32d }
|
||||||
*/
|
*/
|
||||||
export function logTcToken(
|
export function logTcToken(
|
||||||
event: 'stored' | 'expired' | 'fetch' | 'fetched' | 'reissue' | 'reissue_ok' | 'reissue_fail' | 'prune' | 'error_463' | 'error_479' | 'attached' | 'retry_463_ok',
|
event:
|
||||||
|
| 'stored'
|
||||||
|
| 'expired'
|
||||||
|
| 'fetch'
|
||||||
|
| 'fetched'
|
||||||
|
| 'reissue'
|
||||||
|
| 'reissue_ok'
|
||||||
|
| 'reissue_fail'
|
||||||
|
| 'prune'
|
||||||
|
| 'error_463'
|
||||||
|
| 'error_479'
|
||||||
|
| 'attached'
|
||||||
|
| 'retry_463_ok',
|
||||||
data?: Record<string, unknown>,
|
data?: Record<string, unknown>,
|
||||||
sessionName?: string
|
sessionName?: string
|
||||||
): void {
|
): void {
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ export const ensureLTHashStateVersion = (state: LTHashState): LTHashState => {
|
|||||||
if (typeof state.version !== 'number' || isNaN(state.version)) {
|
if (typeof state.version !== 'number' || isNaN(state.version)) {
|
||||||
state.version = 0
|
state.version = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -376,9 +376,9 @@ export const getErrorCodeFromStreamError = (node: BinaryNode) => {
|
|||||||
|
|
||||||
// Conflict child: type attribute determines connectionReplaced vs loggedOut.
|
// Conflict child: type attribute determines connectionReplaced vs loggedOut.
|
||||||
// WA Web default: any type other than 'replaced' is treated as device_removed (loggedOut).
|
// WA Web default: any type other than 'replaced' is treated as device_removed (loggedOut).
|
||||||
if(reason === 'conflict') {
|
if (reason === 'conflict') {
|
||||||
const conflictType = reasonNode!.attrs?.type
|
const conflictType = reasonNode!.attrs?.type
|
||||||
if(conflictType === 'replaced') {
|
if (conflictType === 'replaced') {
|
||||||
return { reason: 'replaced', statusCode: DisconnectReason.connectionReplaced }
|
return { reason: 'replaced', statusCode: DisconnectReason.connectionReplaced }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,11 +388,11 @@ export const getErrorCodeFromStreamError = (node: BinaryNode) => {
|
|||||||
// Child-level code parsing: parent code attr > child code attr > CODE_MAP from child tag > badSession
|
// Child-level code parsing: parent code attr > child code attr > CODE_MAP from child tag > badSession
|
||||||
const statusCode = +(node.attrs.code || reasonNode?.attrs?.code || CODE_MAP[reason] || DisconnectReason.badSession)
|
const statusCode = +(node.attrs.code || reasonNode?.attrs?.code || CODE_MAP[reason] || DisconnectReason.badSession)
|
||||||
|
|
||||||
if(statusCode === DisconnectReason.restartRequired) {
|
if (statusCode === DisconnectReason.restartRequired) {
|
||||||
reason = 'restart required'
|
reason = 'restart required'
|
||||||
} else if(statusCode === DisconnectReason.sessionInvalidated) {
|
} else if (statusCode === DisconnectReason.sessionInvalidated) {
|
||||||
reason = 'session invalidated'
|
reason = 'session invalidated'
|
||||||
} else if(node.attrs.code) {
|
} else if (node.attrs.code) {
|
||||||
reason = `code ${statusCode}`
|
reason = `code ${statusCode}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ export class MessageRetryManager {
|
|||||||
// to prevent repeated futile lookups on subsequent retry receipts.
|
// to prevent repeated futile lookups on subsequent retry receipts.
|
||||||
this.messageKeyIndex.delete(id)
|
this.messageKeyIndex.delete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1241,7 +1241,7 @@ export const generateWAMessageContent = async (
|
|||||||
const generated = await generateCarouselMessage(carouselOptions, options)
|
const generated = await generateCarouselMessage(carouselOptions, options)
|
||||||
// Direct interactiveMessage — no viewOnceMessage wrapper, no root header/body/footer
|
// Direct interactiveMessage — no viewOnceMessage wrapper, no root header/body/footer
|
||||||
m.interactiveMessage = generated.interactiveMessage
|
m.interactiveMessage = generated.interactiveMessage
|
||||||
return m as proto.IMessage
|
return m
|
||||||
}
|
}
|
||||||
// Check for nativeList
|
// Check for nativeList
|
||||||
else if (hasNonNullishProperty(message, 'nativeList')) {
|
else if (hasNonNullishProperty(message, 'nativeList')) {
|
||||||
@@ -1349,7 +1349,10 @@ export const generateWAMessageContent = async (
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
m.listMessage = listMessage
|
m.listMessage = listMessage
|
||||||
options.logger?.info({ sections: listMessage.sections?.length || 0 }, '[Interactive] Sending listMessage (sections: ' + (listMessage.sections?.length || 0) + ')')
|
options.logger?.info(
|
||||||
|
{ sections: listMessage.sections?.length || 0 },
|
||||||
|
'[Interactive] Sending listMessage (sections: ' + (listMessage.sections?.length || 0) + ')'
|
||||||
|
)
|
||||||
} else if (hasNonNullishProperty(message, 'carousel')) {
|
} else if (hasNonNullishProperty(message, 'carousel')) {
|
||||||
// Process carousel/interactive messages with viewOnceMessage wrapper
|
// Process carousel/interactive messages with viewOnceMessage wrapper
|
||||||
const carousel = (message as any).carousel
|
const carousel = (message as any).carousel
|
||||||
@@ -1810,7 +1813,8 @@ export const generateWAMessageFromContent = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Skip ephemeral contextInfo for carousel messages
|
// Skip ephemeral contextInfo for carousel messages
|
||||||
const isCarouselEphemeral = !!(message as any)?.interactiveMessage?.carouselMessage ||
|
const isCarouselEphemeral =
|
||||||
|
!!(message as any)?.interactiveMessage?.carouselMessage ||
|
||||||
!!(message as any)?.viewOnceMessage?.message?.interactiveMessage?.carouselMessage
|
!!(message as any)?.viewOnceMessage?.message?.interactiveMessage?.carouselMessage
|
||||||
if (
|
if (
|
||||||
// if we want to send a disappearing message
|
// if we want to send a disappearing message
|
||||||
@@ -1834,7 +1838,8 @@ export const generateWAMessageFromContent = (
|
|||||||
|
|
||||||
// Skip Message.create() for carousel — InteractiveMessage has oneOf (fields 4-7)
|
// Skip Message.create() for carousel — InteractiveMessage has oneOf (fields 4-7)
|
||||||
// and create() may corrupt the carouselMessage/nativeFlowMessage oneOf resolution
|
// and create() may corrupt the carouselMessage/nativeFlowMessage oneOf resolution
|
||||||
const isCarouselMsg = !!(message as any)?.interactiveMessage?.carouselMessage ||
|
const isCarouselMsg =
|
||||||
|
!!(message as any)?.interactiveMessage?.carouselMessage ||
|
||||||
!!(message as any)?.viewOnceMessage?.message?.interactiveMessage?.carouselMessage
|
!!(message as any)?.viewOnceMessage?.message?.interactiveMessage?.carouselMessage
|
||||||
if (!isCarouselMsg) {
|
if (!isCarouselMsg) {
|
||||||
message = WAProto.Message.create(message)
|
message = WAProto.Message.create(message)
|
||||||
|
|||||||
+12
-12
@@ -18,9 +18,9 @@ const TC_TOKEN_NUM_BUCKETS = 4
|
|||||||
* If WA ever diverges these, add a `mode` parameter here.
|
* If WA ever diverges these, add a `mode` parameter here.
|
||||||
*/
|
*/
|
||||||
export function isTcTokenExpired(timestamp: number | string | null | undefined): boolean {
|
export function isTcTokenExpired(timestamp: number | string | null | undefined): boolean {
|
||||||
if(timestamp === null || timestamp === undefined) return true
|
if (timestamp === null || timestamp === undefined) return true
|
||||||
const ts = typeof timestamp === 'string' ? Number(timestamp) : timestamp
|
const ts = typeof timestamp === 'string' ? Number(timestamp) : timestamp
|
||||||
if(isNaN(ts)) return true
|
if (isNaN(ts)) return true
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
const currentBucket = Math.floor(now / TC_TOKEN_BUCKET_DURATION)
|
const currentBucket = Math.floor(now / TC_TOKEN_BUCKET_DURATION)
|
||||||
const cutoffBucket = currentBucket - (TC_TOKEN_NUM_BUCKETS - 1)
|
const cutoffBucket = currentBucket - (TC_TOKEN_NUM_BUCKETS - 1)
|
||||||
@@ -35,7 +35,7 @@ export function isTcTokenExpired(timestamp: number | string | null | undefined):
|
|||||||
* Returns true if senderTimestamp is null/undefined or in a previous bucket.
|
* Returns true if senderTimestamp is null/undefined or in a previous bucket.
|
||||||
*/
|
*/
|
||||||
export function shouldSendNewTcToken(senderTimestamp: number | undefined): boolean {
|
export function shouldSendNewTcToken(senderTimestamp: number | undefined): boolean {
|
||||||
if(senderTimestamp === undefined) return true
|
if (senderTimestamp === undefined) return true
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
const currentBucket = Math.floor(now / TC_TOKEN_BUCKET_DURATION)
|
const currentBucket = Math.floor(now / TC_TOKEN_BUCKET_DURATION)
|
||||||
const senderBucket = Math.floor(senderTimestamp / TC_TOKEN_BUCKET_DURATION)
|
const senderBucket = Math.floor(senderTimestamp / TC_TOKEN_BUCKET_DURATION)
|
||||||
@@ -58,7 +58,7 @@ export async function resolveTcTokenJid(
|
|||||||
getLIDForPN: (pn: string) => Promise<string | null>
|
getLIDForPN: (pn: string) => Promise<string | null>
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const normalized = jidNormalizedUser(jid)
|
const normalized = jidNormalizedUser(jid)
|
||||||
if(isLidUser(normalized)) return normalized
|
if (isLidUser(normalized)) return normalized
|
||||||
const lid = await getLIDForPN(normalized)
|
const lid = await getLIDForPN(normalized)
|
||||||
return lid ?? normalized
|
return lid ?? normalized
|
||||||
}
|
}
|
||||||
@@ -84,9 +84,9 @@ export async function buildTcTokenFromJid({
|
|||||||
const entry = tcTokenData?.[storageJid]
|
const entry = tcTokenData?.[storageJid]
|
||||||
const tcTokenBuffer = entry?.token
|
const tcTokenBuffer = entry?.token
|
||||||
|
|
||||||
if(!tcTokenBuffer?.length || isTcTokenExpired(entry?.timestamp)) {
|
if (!tcTokenBuffer?.length || isTcTokenExpired(entry?.timestamp)) {
|
||||||
// Opportunistic cleanup: remove expired token from store
|
// Opportunistic cleanup: remove expired token from store
|
||||||
if(tcTokenBuffer) {
|
if (tcTokenBuffer) {
|
||||||
await authState.keys.set({ tctoken: { [storageJid]: null } })
|
await authState.keys.set({ tctoken: { [storageJid]: null } })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ export async function buildTcTokenFromJid({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return baseContent
|
return baseContent
|
||||||
} catch(error) {
|
} catch (error) {
|
||||||
return baseContent.length > 0 ? baseContent : undefined
|
return baseContent.length > 0 ? baseContent : undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,11 +127,11 @@ export async function storeTcTokensFromIqResult({
|
|||||||
onNewJidStored
|
onNewJidStored
|
||||||
}: StoreTcTokensParams) {
|
}: StoreTcTokensParams) {
|
||||||
const tokensNode = getBinaryNodeChild(result, 'tokens')
|
const tokensNode = getBinaryNodeChild(result, 'tokens')
|
||||||
if(!tokensNode) return
|
if (!tokensNode) return
|
||||||
|
|
||||||
const tokenNodes = getBinaryNodeChildren(tokensNode, 'token')
|
const tokenNodes = getBinaryNodeChildren(tokensNode, 'token')
|
||||||
for(const tokenNode of tokenNodes) {
|
for (const tokenNode of tokenNodes) {
|
||||||
if(tokenNode.attrs.type !== 'trusted_contact' || !(tokenNode.content instanceof Uint8Array)) {
|
if (tokenNode.attrs.type !== 'trusted_contact' || !(tokenNode.content instanceof Uint8Array)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,13 +144,13 @@ export async function storeTcTokensFromIqResult({
|
|||||||
// Matches WA Web handleIncomingTcToken
|
// Matches WA Web handleIncomingTcToken
|
||||||
const existingTs = existingEntry?.timestamp ? Number(existingEntry.timestamp) : 0
|
const existingTs = existingEntry?.timestamp ? Number(existingEntry.timestamp) : 0
|
||||||
const incomingTs = tokenNode.attrs.t ? Number(tokenNode.attrs.t) : 0
|
const incomingTs = tokenNode.attrs.t ? Number(tokenNode.attrs.t) : 0
|
||||||
if(existingTs > 0 && incomingTs > 0 && existingTs > incomingTs) {
|
if (existingTs > 0 && incomingTs > 0 && existingTs > incomingTs) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't store timestamp-less tokens at all — isTcTokenExpired treats them
|
// Don't store timestamp-less tokens at all — isTcTokenExpired treats them
|
||||||
// as immediately expired regardless of whether an existing entry is present
|
// as immediately expired regardless of whether an existing entry is present
|
||||||
if(!incomingTs) {
|
if (!incomingTs) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -178,7 +178,6 @@ export class UnifiedSessionManager {
|
|||||||
const serverTimeNum = typeof serverTime === 'string' ? parseInt(serverTime, 10) : serverTime
|
const serverTimeNum = typeof serverTime === 'string' ? parseInt(serverTime, 10) : serverTime
|
||||||
|
|
||||||
if (isNaN(serverTimeNum) || serverTimeNum <= 0) {
|
if (isNaN(serverTimeNum) || serverTimeNum <= 0) {
|
||||||
this.options.logger.debug?.({ serverTime }, 'Invalid server time received, ignoring')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,12 +186,20 @@ export class UnifiedSessionManager {
|
|||||||
const localTimeMs = Date.now()
|
const localTimeMs = Date.now()
|
||||||
const newOffset = serverTimeMs - localTimeMs
|
const newOffset = serverTimeMs - localTimeMs
|
||||||
|
|
||||||
|
// Reject outliers: if the new offset differs from the current stable
|
||||||
|
// offset by more than 30 seconds, this timestamp is likely stale
|
||||||
|
// (e.g. replayed device notification with old 't' value).
|
||||||
|
// The first sample (offset === 0) is always accepted.
|
||||||
|
const MAX_DRIFT_MS = 30_000
|
||||||
|
if (this.state.serverTimeOffset !== 0 && Math.abs(newOffset - this.state.serverTimeOffset) > MAX_DRIFT_MS) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Only update if the offset changed significantly (>1 second)
|
// Only update if the offset changed significantly (>1 second)
|
||||||
if (Math.abs(newOffset - this.state.serverTimeOffset) > 1000) {
|
if (Math.abs(newOffset - this.state.serverTimeOffset) > 1000) {
|
||||||
const oldOffset = this.state.serverTimeOffset
|
|
||||||
this.state.serverTimeOffset = newOffset
|
this.state.serverTimeOffset = newOffset
|
||||||
|
|
||||||
this.options.logger.debug?.({ oldOffset, newOffset, serverTime: serverTimeNum }, 'Server time offset updated')
|
this.options.logger.trace?.({ newOffset, serverTime: serverTimeNum }, 'Server time offset updated')
|
||||||
|
|
||||||
// Record metric
|
// Record metric
|
||||||
metrics.socketEvents?.inc({ event: 'server_time_sync' })
|
metrics.socketEvents?.inc({ event: 'server_time_sync' })
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ interface MockMessageRetryManager {
|
|||||||
/** Mirrors jidNormalizedUser: strips device suffix from JID user part */
|
/** Mirrors jidNormalizedUser: strips device suffix from JID user part */
|
||||||
function jidNormalizedUser(jid: string): string {
|
function jidNormalizedUser(jid: string): string {
|
||||||
const atIdx = jid.indexOf('@')
|
const atIdx = jid.indexOf('@')
|
||||||
if(atIdx < 0) return jid
|
if (atIdx < 0) return jid
|
||||||
const user = jid.slice(0, atIdx)
|
const user = jid.slice(0, atIdx)
|
||||||
const server = jid.slice(atIdx + 1)
|
const server = jid.slice(atIdx + 1)
|
||||||
const normalizedUser = user.includes(':') ? user.split(':')[0] : user
|
const normalizedUser = user.includes(':') ? user.split(':')[0] : user
|
||||||
@@ -46,9 +46,9 @@ async function handleBadAck463(
|
|||||||
const jid = jidNormalizedUser(attrs.from)
|
const jid = jidNormalizedUser(attrs.from)
|
||||||
const key: MockKey = { remoteJid: attrs.from, fromMe: true, id: msgId }
|
const key: MockKey = { remoteJid: attrs.from, fromMe: true, id: msgId }
|
||||||
|
|
||||||
if(attrs.error === '463') {
|
if (attrs.error === '463') {
|
||||||
const retryKey = `${jid}:${msgId}`
|
const retryKey = `${jid}:${msgId}`
|
||||||
if(msgId && jid && !tcTokenRetriedMsgIds.has(retryKey)) {
|
if (msgId && jid && !tcTokenRetriedMsgIds.has(retryKey)) {
|
||||||
tcTokenRetriedMsgIds.add(retryKey)
|
tcTokenRetriedMsgIds.add(retryKey)
|
||||||
// Each entry auto-expires after 60s — naturally bounded under normal use
|
// Each entry auto-expires after 60s — naturally bounded under normal use
|
||||||
setTimeout(() => tcTokenRetriedMsgIds.delete(retryKey), 60_000)
|
setTimeout(() => tcTokenRetriedMsgIds.delete(retryKey), 60_000)
|
||||||
@@ -57,7 +57,7 @@ async function handleBadAck463(
|
|||||||
(await getMessage(key)) ??
|
(await getMessage(key)) ??
|
||||||
// Fallback: ack can arrive <30ms after send, before store persists
|
// Fallback: ack can arrive <30ms after send, before store persists
|
||||||
messageRetryManager?.getRecentMessage(jid, msgId)?.message
|
messageRetryManager?.getRecentMessage(jid, msgId)?.message
|
||||||
if(msg) {
|
if (msg) {
|
||||||
try {
|
try {
|
||||||
await delayFn(1500)
|
await delayFn(1500)
|
||||||
await relayMessage(jid, msg, {
|
await relayMessage(jid, msg, {
|
||||||
@@ -190,7 +190,7 @@ describe('handleBadAck error 463 retry', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should not retry for non-463 errors', async () => {
|
it('should not retry for non-463 errors', async () => {
|
||||||
for(const errorCode of ['479', '421']) {
|
for (const errorCode of ['479', '421']) {
|
||||||
mockGetMessage.mockClear()
|
mockGetMessage.mockClear()
|
||||||
mockRelayMessage.mockClear()
|
mockRelayMessage.mockClear()
|
||||||
mockEmit.mockClear()
|
mockEmit.mockClear()
|
||||||
@@ -274,7 +274,9 @@ describe('handleBadAck error 463 retry', () => {
|
|||||||
it('should fall back to messageRetryManager when getMessage returns undefined', async () => {
|
it('should fall back to messageRetryManager when getMessage returns undefined', async () => {
|
||||||
const cachedMsg = { conversation: 'cached' }
|
const cachedMsg = { conversation: 'cached' }
|
||||||
const mockRetryManager: MockMessageRetryManager = {
|
const mockRetryManager: MockMessageRetryManager = {
|
||||||
getRecentMessage: jest.fn<(jid: string, msgId: string) => { message: any } | undefined>().mockReturnValue({ message: cachedMsg })
|
getRecentMessage: jest
|
||||||
|
.fn<(jid: string, msgId: string) => { message: any } | undefined>()
|
||||||
|
.mockReturnValue({ message: cachedMsg })
|
||||||
}
|
}
|
||||||
mockGetMessage.mockResolvedValue(undefined)
|
mockGetMessage.mockResolvedValue(undefined)
|
||||||
mockRelayMessage.mockResolvedValue(undefined)
|
mockRelayMessage.mockResolvedValue(undefined)
|
||||||
|
|||||||
@@ -36,15 +36,11 @@ function makeState(): OfflineBufferState {
|
|||||||
* Mirrors the process.nextTick block that arms the offline-buffer timer.
|
* Mirrors the process.nextTick block that arms the offline-buffer timer.
|
||||||
* Only called when creds.me?.id is set (reconnection path).
|
* Only called when creds.me?.id is set (reconnection path).
|
||||||
*/
|
*/
|
||||||
function startBuffer(
|
function startBuffer(state: OfflineBufferState, flush: () => void, warn: () => void): void {
|
||||||
state: OfflineBufferState,
|
|
||||||
flush: () => void,
|
|
||||||
warn: () => void
|
|
||||||
): void {
|
|
||||||
state.didStartBuffer = true
|
state.didStartBuffer = true
|
||||||
state.offlineBufferTimeout = setTimeout(() => {
|
state.offlineBufferTimeout = setTimeout(() => {
|
||||||
state.offlineBufferTimeout = undefined
|
state.offlineBufferTimeout = undefined
|
||||||
if(state.didStartBuffer) {
|
if (state.didStartBuffer) {
|
||||||
warn()
|
warn()
|
||||||
flush()
|
flush()
|
||||||
state.didStartBuffer = false
|
state.didStartBuffer = false
|
||||||
@@ -57,12 +53,12 @@ function startBuffer(
|
|||||||
* delivers all offline notifications before the safety timer fires.
|
* delivers all offline notifications before the safety timer fires.
|
||||||
*/
|
*/
|
||||||
function onOffline(state: OfflineBufferState, flush: () => void): void {
|
function onOffline(state: OfflineBufferState, flush: () => void): void {
|
||||||
if(state.offlineBufferTimeout) {
|
if (state.offlineBufferTimeout) {
|
||||||
clearTimeout(state.offlineBufferTimeout)
|
clearTimeout(state.offlineBufferTimeout)
|
||||||
state.offlineBufferTimeout = undefined
|
state.offlineBufferTimeout = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
if(state.didStartBuffer) {
|
if (state.didStartBuffer) {
|
||||||
flush()
|
flush()
|
||||||
state.didStartBuffer = false
|
state.didStartBuffer = false
|
||||||
}
|
}
|
||||||
@@ -73,7 +69,7 @@ function onOffline(state: OfflineBufferState, flush: () => void): void {
|
|||||||
* flag so a closing socket cannot emit stale events after the fact.
|
* flag so a closing socket cannot emit stale events after the fact.
|
||||||
*/
|
*/
|
||||||
function onClose(state: OfflineBufferState): void {
|
function onClose(state: OfflineBufferState): void {
|
||||||
if(state.offlineBufferTimeout) {
|
if (state.offlineBufferTimeout) {
|
||||||
clearTimeout(state.offlineBufferTimeout)
|
clearTimeout(state.offlineBufferTimeout)
|
||||||
state.offlineBufferTimeout = undefined
|
state.offlineBufferTimeout = undefined
|
||||||
}
|
}
|
||||||
@@ -97,7 +93,7 @@ describe('offline-buffer safety timer (socket.ts)', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
// Clean up any remaining timer to avoid cross-test interference
|
// Clean up any remaining timer to avoid cross-test interference
|
||||||
if(state.offlineBufferTimeout) {
|
if (state.offlineBufferTimeout) {
|
||||||
clearTimeout(state.offlineBufferTimeout)
|
clearTimeout(state.offlineBufferTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -596,8 +596,8 @@ describe('tctoken integration scenarios', () => {
|
|||||||
const expiredJids: string[] = []
|
const expiredJids: string[] = []
|
||||||
const validJids: string[] = []
|
const validJids: string[] = []
|
||||||
|
|
||||||
for(const [jid, entry] of Object.entries(entries)) {
|
for (const [jid, entry] of Object.entries(entries)) {
|
||||||
if(isTcTokenExpired(entry.timestamp)) {
|
if (isTcTokenExpired(entry.timestamp)) {
|
||||||
expiredJids.push(jid)
|
expiredJids.push(jid)
|
||||||
} else {
|
} else {
|
||||||
validJids.push(jid)
|
validJids.push(jid)
|
||||||
@@ -617,8 +617,8 @@ describe('tctoken integration scenarios', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deletions: Record<string, null> = {}
|
const deletions: Record<string, null> = {}
|
||||||
for(const [jid, entry] of Object.entries(entries)) {
|
for (const [jid, entry] of Object.entries(entries)) {
|
||||||
if(isTcTokenExpired(entry.timestamp)) {
|
if (isTcTokenExpired(entry.timestamp)) {
|
||||||
deletions[jid] = null
|
deletions[jid] = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -635,8 +635,8 @@ describe('tctoken integration scenarios', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deletions: Record<string, null> = {}
|
const deletions: Record<string, null> = {}
|
||||||
for(const [jid, entry] of Object.entries(entries)) {
|
for (const [jid, entry] of Object.entries(entries)) {
|
||||||
if(isTcTokenExpired(entry.timestamp)) {
|
if (isTcTokenExpired(entry.timestamp)) {
|
||||||
deletions[jid] = null
|
deletions[jid] = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -652,8 +652,8 @@ describe('tctoken integration scenarios', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deletions: Record<string, null> = {}
|
const deletions: Record<string, null> = {}
|
||||||
for(const [jid, entry] of Object.entries(entries)) {
|
for (const [jid, entry] of Object.entries(entries)) {
|
||||||
if(isTcTokenExpired(entry.timestamp)) {
|
if (isTcTokenExpired(entry.timestamp)) {
|
||||||
deletions[jid] = null
|
deletions[jid] = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -669,8 +669,8 @@ describe('tctoken integration scenarios', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deletions: Record<string, null> = {}
|
const deletions: Record<string, null> = {}
|
||||||
for(const [jid, entry] of Object.entries(entries)) {
|
for (const [jid, entry] of Object.entries(entries)) {
|
||||||
if(isTcTokenExpired((entry as any).timestamp)) {
|
if (isTcTokenExpired((entry as any).timestamp)) {
|
||||||
deletions[jid] = null
|
deletions[jid] = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -769,8 +769,8 @@ describe('tctoken integration scenarios', () => {
|
|||||||
it('index ignores sentinel key when loading', () => {
|
it('index ignores sentinel key when loading', () => {
|
||||||
const jids = [JID_A, INDEX_KEY, JID_B, '', null as any]
|
const jids = [JID_A, INDEX_KEY, JID_B, '', null as any]
|
||||||
const loaded = new Set<string>()
|
const loaded = new Set<string>()
|
||||||
for(const jid of jids) {
|
for (const jid of jids) {
|
||||||
if(jid && jid !== INDEX_KEY) {
|
if (jid && jid !== INDEX_KEY) {
|
||||||
loaded.add(jid)
|
loaded.add(jid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -794,9 +794,9 @@ describe('tctoken integration scenarios', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const expiredDeletions: Record<string, null> = {}
|
const expiredDeletions: Record<string, null> = {}
|
||||||
for(const jid of knownJids) {
|
for (const jid of knownJids) {
|
||||||
const entry = allTokens[jid]
|
const entry = allTokens[jid]
|
||||||
if(!entry?.token || isTcTokenExpired(entry.timestamp)) {
|
if (!entry?.token || isTcTokenExpired(entry.timestamp)) {
|
||||||
expiredDeletions[jid] = null
|
expiredDeletions[jid] = null
|
||||||
knownJids.delete(jid)
|
knownJids.delete(jid)
|
||||||
}
|
}
|
||||||
@@ -823,7 +823,7 @@ describe('tctoken integration scenarios', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// First add → triggers save
|
// First add → triggers save
|
||||||
if(!knownJids.has(JID_A)) {
|
if (!knownJids.has(JID_A)) {
|
||||||
knownJids.add(JID_A)
|
knownJids.add(JID_A)
|
||||||
scheduleSave()
|
scheduleSave()
|
||||||
}
|
}
|
||||||
@@ -831,7 +831,7 @@ describe('tctoken integration scenarios', () => {
|
|||||||
expect(saveCount).toBe(1)
|
expect(saveCount).toBe(1)
|
||||||
|
|
||||||
// Duplicate add → no save
|
// Duplicate add → no save
|
||||||
if(!knownJids.has(JID_A)) {
|
if (!knownJids.has(JID_A)) {
|
||||||
knownJids.add(JID_A)
|
knownJids.add(JID_A)
|
||||||
scheduleSave()
|
scheduleSave()
|
||||||
}
|
}
|
||||||
@@ -839,7 +839,7 @@ describe('tctoken integration scenarios', () => {
|
|||||||
expect(saveCount).toBe(1)
|
expect(saveCount).toBe(1)
|
||||||
|
|
||||||
// New JID → triggers save
|
// New JID → triggers save
|
||||||
if(!knownJids.has(JID_B)) {
|
if (!knownJids.has(JID_B)) {
|
||||||
knownJids.add(JID_B)
|
knownJids.add(JID_B)
|
||||||
scheduleSave()
|
scheduleSave()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,28 @@
|
|||||||
// This MUST be at the very top to intercept console before libsignal loads
|
// This MUST be at the very top to intercept console before libsignal loads
|
||||||
// ============================================
|
// ============================================
|
||||||
const _origConsoleError = console.error
|
const _origConsoleError = console.error
|
||||||
|
const _origConsoleLog = console.log
|
||||||
|
const _origConsoleInfo = console.info
|
||||||
|
|
||||||
|
// Suppress libsignal session lifecycle dumps from console.log / console.info.
|
||||||
|
// libsignal's session_record.js uses console.info("Removing old closed session:", obj)
|
||||||
|
// and console.log("Closing session:", obj) which dump full session objects (~500ms I/O each).
|
||||||
|
const _SESSION_LIFECYCLE_RE = /^(Closing session|Removing old closed session)/
|
||||||
|
console.log = function (...args: unknown[]) {
|
||||||
|
if (args.length > 0 && typeof args[0] === 'string' && _SESSION_LIFECYCLE_RE.test(args[0])) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_origConsoleLog.apply(console, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info = function (...args: unknown[]) {
|
||||||
|
if (args.length > 0 && typeof args[0] === 'string' && _SESSION_LIFECYCLE_RE.test(args[0])) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_origConsoleInfo.apply(console, args)
|
||||||
|
}
|
||||||
|
|
||||||
// Track errors by type + JID to avoid duplicates (using Map for better performance)
|
// Track errors by type + JID to avoid duplicates (using Map for better performance)
|
||||||
const _errorTimestamps = new Map<string, number>()
|
const _errorTimestamps = new Map<string, number>()
|
||||||
|
|||||||
Reference in New Issue
Block a user