feat(telemetry): add unified_session telemetry to reduce detection

Implements WhatsApp's unified_session telemetry feature to reduce
detection of unofficial clients. This is an enterprise-grade implementation
inspired by whatsmeow PR #1057 and Baileys PR #2294.

Features:
- UnifiedSessionManager class with circuit breaker protection
- Server time synchronization for accurate session IDs
- Rate limiting to prevent spam (1 minute between sends)
- Prometheus metrics integration (unified_session_sent, errors)
- Structured logging for debugging
- Configurable via SocketConfig.enableUnifiedSession
- Environment variable support (BAILEYS_UNIFIED_SESSION_ENABLED)

Trigger points (matching official WhatsApp Web):
- After successful login (CB:success)
- After successful pairing (CB:iq,,pair-success)
- When sending 'available' presence

Implementation details:
- Session ID algorithm: (now + serverOffset + 3days) % 7days
- Time constants exported from Defaults/index.ts
- Full test coverage (31 tests)

References:
- https://github.com/tulir/whatsmeow/pull/1057
- https://github.com/WhiskeySockets/Baileys/pull/2294
- https://github.com/tulir/whatsmeow/issues/810
This commit is contained in:
Claude
2026-01-24 18:56:08 +00:00
parent a55055e55e
commit 9f17567951
7 changed files with 948 additions and 3 deletions
+39 -1
View File
@@ -106,7 +106,9 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = {
// SocketClient: 20 core events (connection, messaging, presence, groups, calls, etc.)
// + 20 dynamic listeners (user handlers, plugins)
// + 10 buffer slots for high-load scenarios = 50 total
maxSocketClientListeners: 50
maxSocketClientListeners: 50,
// Unified session telemetry (reduces detection of unofficial clients)
enableUnifiedSession: true
}
export const MEDIA_PATH_MAP: { [T in MediaType]?: string } = {
@@ -189,3 +191,39 @@ export const DEFAULT_CACHE_MAX_KEYS = {
// Re-export retry constants for backwards compatibility
// Actual definitions are in retry-utils.ts to avoid ESM initialization order issues
export { RETRY_BACKOFF_DELAYS, RETRY_JITTER_FACTOR } from '../Utils/retry-utils'
// ============================================
// Time Constants
// ============================================
/**
* Time constants in milliseconds for various timing calculations.
* Used by unified session, rate limiting, and other time-based features.
*
* @example
* ```typescript
* import { TimeMs } from './Defaults'
*
* // Calculate 3 days in milliseconds
* const threeDays = 3 * TimeMs.Day
*
* // Check if 1 week has passed
* if (Date.now() - lastUpdate > TimeMs.Week) {
* // do something
* }
* ```
*/
export const TimeMs = {
/** One second in milliseconds (1,000) */
Second: 1_000,
/** One minute in milliseconds (60,000) */
Minute: 60_000,
/** One hour in milliseconds (3,600,000) */
Hour: 3_600_000,
/** One day in milliseconds (86,400,000) */
Day: 86_400_000,
/** One week in milliseconds (604,800,000) */
Week: 604_800_000,
} as const
export type TimeMsKey = keyof typeof TimeMs
+9 -1
View File
@@ -68,7 +68,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
getMessage
} = config
const sock = makeSocket(config)
const { ev, ws, authState, generateMessageTag, sendNode, query, signalRepository, onUnexpectedError } = sock
const { ev, ws, authState, generateMessageTag, sendNode, query, signalRepository, onUnexpectedError, sendUnifiedSession } = sock
let privacySettings: { [_: string]: string } | undefined
@@ -674,6 +674,14 @@ export const makeChatsSocket = (config: SocketConfig) => {
type
}
})
// Send unified_session telemetry when going online
// This mimics official WhatsApp Web client behavior
if (type === 'available') {
sendUnifiedSession('presence').catch(err => {
logger.debug({ err }, 'Failed to send unified_session on presence available')
})
}
} else {
const { server } = jidDecode(toJid)!
const isLid = server === 'lid'
+71 -1
View File
@@ -60,6 +60,12 @@ import {
incrementActiveConnections,
decrementActiveConnections
} from '../Utils/prometheus-metrics'
import {
createUnifiedSessionManager,
extractServerTime,
shouldEnableUnifiedSession,
type UnifiedSessionManager
} from '../Utils/unified-session'
import { BinaryInfo } from '../WAM/BinaryInfo.js'
import { USyncQuery, USyncUser } from '../WAUSync/'
import { WebSocketClient } from './Client'
@@ -87,7 +93,8 @@ export const makeSocket = (config: SocketConfig) => {
enableCircuitBreaker = true,
queryCircuitBreaker: queryCircuitBreakerConfig,
connectionCircuitBreaker: connectionCircuitBreakerConfig,
preKeyCircuitBreaker: preKeyCircuitBreakerConfig
preKeyCircuitBreaker: preKeyCircuitBreakerConfig,
enableUnifiedSession = shouldEnableUnifiedSession()
} = config
// Initialize circuit breakers if enabled
@@ -141,6 +148,19 @@ export const makeSocket = (config: SocketConfig) => {
logger.info('Circuit breakers initialized for socket operations')
}
// Initialize Unified Session Manager for telemetry
// Note: sendNode will be set after it's defined below
let unifiedSessionManager: UnifiedSessionManager | undefined
if (enableUnifiedSession) {
unifiedSessionManager = createUnifiedSessionManager({
enabled: true,
logger,
enableCircuitBreaker
// sendNode will be configured after sendNode function is defined
})
logger.info('Unified session manager initialized')
}
const publicWAMBuffer = new BinaryInfo()
const uqTagId = generateMdTagPrefix()
@@ -220,6 +240,28 @@ export const makeSocket = (config: SocketConfig) => {
return sendRawMessage(buff)
}
// Configure unified session manager with sendNode now that it's defined
if (unifiedSessionManager) {
// Create a wrapper that matches the expected signature
const sendNodeForSession = async (node: BinaryNode): Promise<void> => {
await sendNode(node)
}
// Recreate with proper sendNode
unifiedSessionManager = createUnifiedSessionManager({
enabled: true,
logger,
enableCircuitBreaker,
sendNode: sendNodeForSession
})
}
/** Send unified_session telemetry */
const sendUnifiedSession = async (trigger: 'login' | 'pairing' | 'presence' | 'manual' = 'manual'): Promise<void> => {
if (unifiedSessionManager) {
await unifiedSessionManager.send(trigger)
}
}
/**
* Wait for a message with a certain tag to be received
* @param msgId the message tag to await
@@ -783,6 +825,9 @@ export const makeSocket = (config: SocketConfig) => {
connectionCircuitBreaker?.destroy()
preKeyCircuitBreaker?.destroy()
// Clean up unified session manager
unifiedSessionManager?.destroy()
ws.removeAllListeners('close')
ws.removeAllListeners('open')
ws.removeAllListeners('message')
@@ -1061,6 +1106,11 @@ export const makeSocket = (config: SocketConfig) => {
ev.emit('connection.update', { isNewLogin: true, qr: undefined })
await sendNode(reply)
// Send unified_session telemetry on successful pairing
sendUnifiedSession('pairing').catch(err => {
logger.debug({ err }, 'Failed to send unified_session on pairing')
})
} catch (error: any) {
logger.info({ trace: error.stack }, 'error in pairing')
void end(error)
@@ -1093,6 +1143,17 @@ export const makeSocket = (config: SocketConfig) => {
recordConnectionAttempt('success')
incrementActiveConnections()
// Update server time offset from success node
const serverTime = extractServerTime(node)
if (serverTime) {
unifiedSessionManager?.updateServerTimeOffset(serverTime)
}
// Send unified_session telemetry on successful login
sendUnifiedSession('login').catch(err => {
logger.debug({ err }, 'Failed to send unified_session on login')
})
if (node.attrs.lid && authState.creds.me?.id) {
const myLID = node.attrs.lid
process.nextTick(async () => {
@@ -1247,6 +1308,15 @@ export const makeSocket = (config: SocketConfig) => {
connectionCircuitBreaker?.reset()
preKeyCircuitBreaker?.reset()
logger.info('All circuit breakers reset to closed state')
},
// Unified Session Telemetry
/** Send unified_session telemetry manually */
sendUnifiedSession,
/** Get unified session manager state (for debugging) */
getUnifiedSessionState: () => unifiedSessionManager?.getState(),
/** Update server time offset (call when receiving server timestamps) */
updateServerTimeOffset: (serverTime: string | number) => {
unifiedSessionManager?.updateServerTimeOffset(serverTime)
}
}
}
+23
View File
@@ -229,4 +229,27 @@ export type SocketConfig = {
* WARNING: Setting to 0 disables limit and allows potential memory leaks!
*/
maxSocketClientListeners?: number
// === Unified Session Telemetry ===
/**
* Enable unified_session telemetry to reduce detection of unofficial clients.
*
* When enabled, sends time-based session identifiers that mimic official
* WhatsApp Web client behavior. This may help reduce "Your account may be
* at risk" warnings, though effectiveness is not guaranteed.
*
* Telemetry is sent at specific trigger points:
* - After successful login
* - After successful pairing
* - When sending 'available' presence
*
* Can also be controlled via environment variable:
* BAILEYS_UNIFIED_SESSION_ENABLED=true|false
*
* @default true
* @see https://github.com/tulir/whatsmeow/pull/1057
* @see https://github.com/WhiskeySockets/Baileys/pull/2294
*/
enableUnifiedSession?: boolean
}
+3
View File
@@ -36,6 +36,9 @@ export * from './cache-utils'
export * from './circuit-breaker'
export * from './retry-utils'
// Telemetry and detection mitigation
export * from './unified-session'
// Version management
export * from './version-cache'
+414
View File
@@ -0,0 +1,414 @@
/**
* Unified Session Telemetry Implementation
*
* This module implements WhatsApp's unified_session telemetry feature to reduce
* detection of unofficial clients. The implementation is inspired by:
* - whatsmeow PR #1057 (Go implementation)
* - Baileys PR #2294 (TypeScript implementation)
*
* The unified_session is a time-based identifier that mimics the behavior of
* official WhatsApp Web clients, potentially reducing account restriction warnings.
*
* @module Utils/unified-session
* @see https://github.com/tulir/whatsmeow/pull/1057
* @see https://github.com/WhiskeySockets/Baileys/pull/2294
*/
import { metrics } from './prometheus-metrics.js'
import type { ILogger } from './logger.js'
import type { BinaryNode } from '../WABinary/types.js'
import {
CircuitBreaker,
CircuitOpenError,
createConnectionCircuitBreaker
} from './circuit-breaker.js'
// ============================================
// Time Constants (Enterprise-grade)
// ============================================
// Import TimeMs from Defaults (single source of truth)
import { TimeMs } from '../Defaults/index.js'
// Re-export for convenience (but not as main export to avoid conflicts)
export { TimeMs }
/**
* Offset for session ID calculation (3 days in ms).
* This matches the WhatsApp Web official client behavior.
*/
const SESSION_OFFSET_MS = 3 * TimeMs.Day
// ============================================
// Types
// ============================================
/**
* Unified Session Manager options
*/
export interface UnifiedSessionOptions {
/** Whether unified session telemetry is enabled */
enabled?: boolean
/** Logger instance for debugging */
logger?: ILogger
/** Enable circuit breaker protection for send operations */
enableCircuitBreaker?: boolean
/** Function to send binary nodes to WhatsApp */
sendNode?: (node: BinaryNode) => Promise<void>
}
/**
* Unified Session state
*/
export interface UnifiedSessionState {
/** Server time offset in milliseconds (server time - local time) */
serverTimeOffset: number
/** Last time unified_session was sent (Unix timestamp ms) */
lastSentTime: number
/** Total number of unified_session messages sent */
sendCount: number
/** Whether the session manager is initialized */
isInitialized: boolean
}
/**
* Trigger types for unified session sending
*/
export type UnifiedSessionTrigger = 'login' | 'pairing' | 'presence' | 'manual'
// ============================================
// Core Implementation
// ============================================
/**
* Unified Session Manager
*
* Manages the unified_session telemetry feature with:
* - Server time synchronization
* - Rate limiting (prevents spam)
* - Circuit breaker protection
* - Prometheus metrics integration
* - Structured logging
*
* @example
* ```typescript
* const sessionManager = new UnifiedSessionManager({
* enabled: true,
* logger: myLogger,
* sendNode: (node) => sock.sendNode(node)
* })
*
* // Update server time offset when receiving server timestamp
* sessionManager.updateServerTimeOffset(serverTimeAttr)
*
* // Send unified_session on login
* await sessionManager.send('login')
* ```
*/
export class UnifiedSessionManager {
private state: UnifiedSessionState = {
serverTimeOffset: 0,
lastSentTime: 0,
sendCount: 0,
isInitialized: false
}
private readonly options: Required<UnifiedSessionOptions>
private circuitBreaker: CircuitBreaker | null = null
/** Minimum interval between unified_session sends (1 minute) */
private static readonly MIN_SEND_INTERVAL_MS = TimeMs.Minute
constructor(options: UnifiedSessionOptions = {}) {
this.options = {
enabled: options.enabled ?? true,
logger: options.logger ?? console as unknown as ILogger,
enableCircuitBreaker: options.enableCircuitBreaker ?? true,
sendNode: options.sendNode ?? (async () => {
throw new Error('sendNode function not configured')
})
}
// Initialize circuit breaker if enabled
if (this.options.enableCircuitBreaker) {
this.circuitBreaker = createConnectionCircuitBreaker({
name: 'unified-session',
failureThreshold: 3,
failureWindow: 60000,
resetTimeout: 30000,
successThreshold: 1,
timeout: 10000,
onStateChange: (from, to) => {
this.options.logger.debug?.({ from, to }, 'Unified session circuit breaker state changed')
},
onOpen: () => {
this.options.logger.warn?.('Unified session circuit breaker OPENED')
metrics.circuitBreakerTrips?.inc({ name: 'unified-session' })
}
})
}
this.state.isInitialized = true
this.options.logger.debug?.('UnifiedSessionManager initialized')
}
/**
* Update the server time offset from a received server timestamp.
*
* WhatsApp includes a 't' attribute in some nodes containing the server's
* Unix timestamp (in seconds). We use this to calculate the offset between
* server time and local time.
*
* @param serverTime - Server timestamp (seconds) from node.attrs.t
*/
updateServerTimeOffset(serverTime: string | number | undefined): void {
if (serverTime === undefined || serverTime === null) {
return
}
const serverTimeNum = typeof serverTime === 'string'
? parseInt(serverTime, 10)
: serverTime
if (isNaN(serverTimeNum) || serverTimeNum <= 0) {
this.options.logger.debug?.(
{ serverTime },
'Invalid server time received, ignoring'
)
return
}
// Server time is in seconds, convert to ms
const serverTimeMs = serverTimeNum * 1000
const localTimeMs = Date.now()
const newOffset = serverTimeMs - localTimeMs
// Only update if the offset changed significantly (>1 second)
if (Math.abs(newOffset - this.state.serverTimeOffset) > 1000) {
const oldOffset = this.state.serverTimeOffset
this.state.serverTimeOffset = newOffset
this.options.logger.debug?.(
{ oldOffset, newOffset, serverTime: serverTimeNum },
'Server time offset updated'
)
// Record metric
metrics.socketEvents?.inc({ event: 'server_time_sync' })
}
}
/**
* Calculate the unified session ID.
*
* The algorithm matches WhatsApp Web's official implementation:
* - Takes current time adjusted by server offset
* - Adds 3-day offset
* - Modulo 7 days (one week cycle)
* - Returns as string
*
* @returns The unified session ID as a string
*/
getSessionId(): string {
const adjustedTime = Date.now() + this.state.serverTimeOffset
const sessionId = (adjustedTime + SESSION_OFFSET_MS) % TimeMs.Week
return String(Math.floor(sessionId))
}
/**
* Check if enough time has passed since the last send.
* Prevents spamming the server with unified_session messages.
*/
private canSend(): boolean {
const timeSinceLastSend = Date.now() - this.state.lastSentTime
return timeSinceLastSend >= UnifiedSessionManager.MIN_SEND_INTERVAL_MS
}
/**
* Send the unified_session telemetry to WhatsApp.
*
* This should be called at specific trigger points:
* - After successful login (CB:success)
* - After successful pairing (CB:iq,,pair-success)
* - When sending 'available' presence
*
* @param trigger - What triggered this send (for logging/metrics)
* @returns Promise that resolves when sent, or void if skipped
*/
async send(trigger: UnifiedSessionTrigger = 'manual'): Promise<void> {
// Check if enabled
if (!this.options.enabled) {
this.options.logger.trace?.('Unified session is disabled, skipping')
return
}
// Rate limiting (except for login which always sends)
if (trigger !== 'login' && !this.canSend()) {
this.options.logger.trace?.(
{ trigger, lastSent: this.state.lastSentTime },
'Unified session rate limited, skipping'
)
return
}
const sessionId = this.getSessionId()
const startTime = Date.now()
const node: BinaryNode = {
tag: 'ib',
attrs: {},
content: [{
tag: 'unified_session',
attrs: { id: sessionId }
}]
}
const sendOperation = async (): Promise<void> => {
await this.options.sendNode(node)
// Update state on success
this.state.lastSentTime = Date.now()
this.state.sendCount++
const latency = Date.now() - startTime
this.options.logger.debug?.(
{ sessionId, trigger, latency, sendCount: this.state.sendCount },
'Unified session telemetry sent'
)
// Record metrics
metrics.socketEvents?.inc({ event: 'unified_session_sent' })
}
try {
// Execute with circuit breaker if available
if (this.circuitBreaker) {
await this.circuitBreaker.execute(sendOperation)
} else {
await sendOperation()
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
if (error instanceof CircuitOpenError) {
this.options.logger.warn?.(
{ trigger, circuitState: error.state },
'Unified session blocked by circuit breaker'
)
} else {
this.options.logger.warn?.(
{ trigger, error: errorMessage },
'Failed to send unified session telemetry'
)
}
// Record failure metric
metrics.errors?.inc({ category: 'unified_session', code: 'send_failed' })
// Don't rethrow - unified_session is non-critical
}
}
/**
* Get the current state for debugging/monitoring.
*/
getState(): Readonly<UnifiedSessionState> {
return { ...this.state }
}
/**
* Reset the manager state (useful for testing or reconnection).
*/
reset(): void {
this.state = {
serverTimeOffset: 0,
lastSentTime: 0,
sendCount: 0,
isInitialized: true
}
this.circuitBreaker?.reset()
this.options.logger.debug?.('UnifiedSessionManager reset')
}
/**
* Destroy the manager and clean up resources.
*/
destroy(): void {
this.circuitBreaker?.destroy()
this.state.isInitialized = false
this.options.logger.debug?.('UnifiedSessionManager destroyed')
}
}
// ============================================
// Factory Function
// ============================================
/**
* Create a new UnifiedSessionManager instance.
*
* @param options - Configuration options
* @returns A new UnifiedSessionManager instance
*
* @example
* ```typescript
* const sessionManager = createUnifiedSessionManager({
* enabled: config.enableUnifiedSession,
* logger: config.logger,
* sendNode: sock.sendNode
* })
* ```
*/
export function createUnifiedSessionManager(
options: UnifiedSessionOptions = {}
): UnifiedSessionManager {
return new UnifiedSessionManager(options)
}
// ============================================
// Utility Functions
// ============================================
/**
* Extract server time from a binary node's attributes.
* WhatsApp includes 't' attribute in various nodes.
*
* @param node - Binary node with potential time attribute
* @returns Server time in seconds, or undefined if not present
*/
export function extractServerTime(node: BinaryNode): number | undefined {
const timeAttr = node.attrs?.t
if (timeAttr === undefined || timeAttr === null) {
return undefined
}
const parsed = typeof timeAttr === 'string'
? parseInt(timeAttr, 10)
: typeof timeAttr === 'number'
? timeAttr
: undefined
if (parsed === undefined || isNaN(parsed) || parsed <= 0) {
return undefined
}
return parsed
}
/**
* Check if unified_session should be enabled based on environment.
* Can be controlled via environment variable for testing.
*
* @returns true if unified_session should be enabled
*/
export function shouldEnableUnifiedSession(): boolean {
const envValue = process.env.BAILEYS_UNIFIED_SESSION_ENABLED
if (envValue !== undefined) {
return envValue.toLowerCase() === 'true' || envValue === '1'
}
// Default: enabled
return true
}
export default UnifiedSessionManager
+389
View File
@@ -0,0 +1,389 @@
/**
* Unit tests for unified-session.ts
*
* Tests the UnifiedSessionManager implementation that mimics
* official WhatsApp Web client telemetry behavior.
*/
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'
import type { BinaryNode } from '../../WABinary/types.js'
import {
UnifiedSessionManager,
createUnifiedSessionManager,
extractServerTime,
shouldEnableUnifiedSession,
type UnifiedSessionOptions
} from '../../Utils/unified-session.js'
import { TimeMs } from '../../Defaults/index.js'
// Mock the prometheus metrics to avoid side effects
jest.mock('../../Utils/prometheus-metrics.js', () => ({
metrics: {
socketEvents: { inc: jest.fn() },
errors: { inc: jest.fn() },
circuitBreakerTrips: { inc: jest.fn() }
}
}))
// Mock logger
const createMockLogger = () => ({
trace: jest.fn(),
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
level: 'debug'
})
// Type for mock sendNode function
type MockSendNode = jest.Mock<(node: BinaryNode) => Promise<void>>
describe('UnifiedSessionManager', () => {
let manager: UnifiedSessionManager
let mockLogger: ReturnType<typeof createMockLogger>
let mockSendNode: MockSendNode
beforeEach(() => {
mockLogger = createMockLogger()
mockSendNode = jest.fn<(node: BinaryNode) => Promise<void>>().mockResolvedValue(undefined)
manager = createUnifiedSessionManager({
enabled: true,
logger: mockLogger as any,
enableCircuitBreaker: false, // Disable for simpler testing
sendNode: mockSendNode
})
})
afterEach(() => {
manager.destroy()
jest.clearAllMocks()
})
describe('TimeMs constants', () => {
it('should have correct time values', () => {
expect(TimeMs.Second).toBe(1_000)
expect(TimeMs.Minute).toBe(60_000)
expect(TimeMs.Hour).toBe(3_600_000)
expect(TimeMs.Day).toBe(86_400_000)
expect(TimeMs.Week).toBe(604_800_000)
})
it('should have readonly constants (enforced by TypeScript)', () => {
// TypeScript's 'as const' ensures compile-time immutability
// At runtime, we verify the values are what we expect
expect(TimeMs.Second).toBe(1_000)
expect(TimeMs.Week).toBe(604_800_000)
// Verify the object structure is correct
expect(Object.keys(TimeMs)).toEqual(['Second', 'Minute', 'Hour', 'Day', 'Week'])
})
})
describe('initialization', () => {
it('should initialize with default state', () => {
const state = manager.getState()
expect(state.isInitialized).toBe(true)
expect(state.serverTimeOffset).toBe(0)
expect(state.lastSentTime).toBe(0)
expect(state.sendCount).toBe(0)
})
it('should create with factory function', () => {
const newManager = createUnifiedSessionManager({ enabled: true })
expect(newManager.getState().isInitialized).toBe(true)
newManager.destroy()
})
it('should respect enabled option', async () => {
const disabledManager = createUnifiedSessionManager({
enabled: false,
sendNode: mockSendNode
})
await disabledManager.send('login')
expect(mockSendNode).not.toHaveBeenCalled()
disabledManager.destroy()
})
})
describe('getSessionId', () => {
it('should return a string', () => {
const sessionId = manager.getSessionId()
expect(typeof sessionId).toBe('string')
})
it('should return a numeric value as string', () => {
const sessionId = manager.getSessionId()
const parsed = parseInt(sessionId, 10)
expect(isNaN(parsed)).toBe(false)
expect(parsed).toBeGreaterThanOrEqual(0)
})
it('should return value within week range', () => {
const sessionId = manager.getSessionId()
const value = parseInt(sessionId, 10)
// Session ID should be modulo 7 days (in ms)
expect(value).toBeLessThan(TimeMs.Week)
})
it('should change with server time offset', () => {
const id1 = manager.getSessionId()
// Update offset by 1 hour
manager.updateServerTimeOffset(Math.floor(Date.now() / 1000) + 3600)
const id2 = manager.getSessionId()
// IDs should be different due to offset
expect(id1).not.toBe(id2)
})
})
describe('updateServerTimeOffset', () => {
it('should update offset from string timestamp', () => {
const serverTime = Math.floor(Date.now() / 1000) + 100
manager.updateServerTimeOffset(serverTime.toString())
const state = manager.getState()
// Offset should be approximately 100 seconds (100,000ms)
expect(Math.abs(state.serverTimeOffset - 100_000)).toBeLessThan(1000)
})
it('should update offset from number timestamp', () => {
const serverTime = Math.floor(Date.now() / 1000) - 50
manager.updateServerTimeOffset(serverTime)
const state = manager.getState()
// Offset should be approximately -50 seconds (-50,000ms)
expect(Math.abs(state.serverTimeOffset + 50_000)).toBeLessThan(1000)
})
it('should ignore undefined value', () => {
manager.updateServerTimeOffset(undefined)
expect(manager.getState().serverTimeOffset).toBe(0)
})
it('should ignore invalid values', () => {
manager.updateServerTimeOffset('invalid')
expect(manager.getState().serverTimeOffset).toBe(0)
manager.updateServerTimeOffset(-1)
expect(manager.getState().serverTimeOffset).toBe(0)
manager.updateServerTimeOffset(0)
expect(manager.getState().serverTimeOffset).toBe(0)
})
it('should only update on significant change (>1 second)', () => {
const serverTime = Math.floor(Date.now() / 1000) + 100
manager.updateServerTimeOffset(serverTime)
const offset1 = manager.getState().serverTimeOffset
// Small change (< 1 second) should not update
manager.updateServerTimeOffset(serverTime + 0.5)
const offset2 = manager.getState().serverTimeOffset
expect(offset1).toBe(offset2)
})
})
describe('send', () => {
it('should send unified_session node', async () => {
await manager.send('login')
expect(mockSendNode).toHaveBeenCalledTimes(1)
const sentNode = mockSendNode.mock.calls[0]?.[0] as BinaryNode | undefined
expect(sentNode).toBeDefined()
expect(sentNode!.tag).toBe('ib')
expect(Array.isArray(sentNode!.content)).toBe(true)
const content = sentNode!.content as BinaryNode[]
expect(content).toHaveLength(1)
expect(content[0]!.tag).toBe('unified_session')
expect(typeof content[0]!.attrs.id).toBe('string')
})
it('should update state after send', async () => {
expect(manager.getState().sendCount).toBe(0)
await manager.send('login')
const state = manager.getState()
expect(state.sendCount).toBe(1)
expect(state.lastSentTime).toBeGreaterThan(0)
})
it('should rate limit sends (except login)', async () => {
// First send should work
await manager.send('presence')
expect(mockSendNode).toHaveBeenCalledTimes(1)
// Immediate second send should be skipped (rate limited)
await manager.send('presence')
expect(mockSendNode).toHaveBeenCalledTimes(1)
})
it('should not rate limit login trigger', async () => {
await manager.send('login')
expect(mockSendNode).toHaveBeenCalledTimes(1)
// Login should always send, even immediately after
await manager.send('login')
expect(mockSendNode).toHaveBeenCalledTimes(2)
})
it('should not throw on send failure', async () => {
mockSendNode.mockRejectedValue(new Error('Network error'))
// Should not throw
await expect(manager.send('login')).resolves.toBeUndefined()
// Reset mock for subsequent tests
mockSendNode.mockResolvedValue(undefined)
})
it('should log warning on send failure', async () => {
mockSendNode.mockRejectedValue(new Error('Network error'))
await manager.send('login')
expect(mockLogger.warn).toHaveBeenCalled()
// Reset mock for subsequent tests
mockSendNode.mockResolvedValue(undefined)
})
})
describe('reset', () => {
it('should reset all state', async () => {
// Modify state
manager.updateServerTimeOffset(Math.floor(Date.now() / 1000) + 100)
await manager.send('login')
const stateBefore = manager.getState()
expect(stateBefore.serverTimeOffset).not.toBe(0)
expect(stateBefore.sendCount).toBe(1)
// Reset
manager.reset()
const stateAfter = manager.getState()
expect(stateAfter.serverTimeOffset).toBe(0)
expect(stateAfter.sendCount).toBe(0)
expect(stateAfter.lastSentTime).toBe(0)
expect(stateAfter.isInitialized).toBe(true)
})
})
describe('destroy', () => {
it('should mark as not initialized', () => {
manager.destroy()
expect(manager.getState().isInitialized).toBe(false)
})
})
})
describe('extractServerTime', () => {
it('should extract time from string attribute', () => {
const node = { tag: 'test', attrs: { t: '1234567890' } }
expect(extractServerTime(node as any)).toBe(1234567890)
})
it('should extract time from number attribute', () => {
const node = { tag: 'test', attrs: { t: 1234567890 } }
expect(extractServerTime(node as any)).toBe(1234567890)
})
it('should return undefined for missing attribute', () => {
const node = { tag: 'test', attrs: {} }
expect(extractServerTime(node as any)).toBeUndefined()
})
it('should return undefined for invalid values', () => {
expect(extractServerTime({ tag: 'test', attrs: { t: 'invalid' } } as any)).toBeUndefined()
expect(extractServerTime({ tag: 'test', attrs: { t: 0 } } as any)).toBeUndefined()
expect(extractServerTime({ tag: 'test', attrs: { t: -1 } } as any)).toBeUndefined()
})
})
describe('shouldEnableUnifiedSession', () => {
const originalEnv = process.env.BAILEYS_UNIFIED_SESSION_ENABLED
afterEach(() => {
if (originalEnv === undefined) {
delete process.env.BAILEYS_UNIFIED_SESSION_ENABLED
} else {
process.env.BAILEYS_UNIFIED_SESSION_ENABLED = originalEnv
}
})
it('should return true by default', () => {
delete process.env.BAILEYS_UNIFIED_SESSION_ENABLED
expect(shouldEnableUnifiedSession()).toBe(true)
})
it('should respect environment variable true', () => {
process.env.BAILEYS_UNIFIED_SESSION_ENABLED = 'true'
expect(shouldEnableUnifiedSession()).toBe(true)
process.env.BAILEYS_UNIFIED_SESSION_ENABLED = '1'
expect(shouldEnableUnifiedSession()).toBe(true)
process.env.BAILEYS_UNIFIED_SESSION_ENABLED = 'TRUE'
expect(shouldEnableUnifiedSession()).toBe(true)
})
it('should respect environment variable false', () => {
process.env.BAILEYS_UNIFIED_SESSION_ENABLED = 'false'
expect(shouldEnableUnifiedSession()).toBe(false)
process.env.BAILEYS_UNIFIED_SESSION_ENABLED = '0'
expect(shouldEnableUnifiedSession()).toBe(false)
process.env.BAILEYS_UNIFIED_SESSION_ENABLED = ''
expect(shouldEnableUnifiedSession()).toBe(false)
})
})
describe('UnifiedSessionManager with CircuitBreaker', () => {
let manager: UnifiedSessionManager
let mockSendNode: MockSendNode
beforeEach(() => {
mockSendNode = jest.fn<(node: BinaryNode) => Promise<void>>().mockResolvedValue(undefined)
manager = createUnifiedSessionManager({
enabled: true,
logger: createMockLogger() as any,
enableCircuitBreaker: true,
sendNode: mockSendNode
})
})
afterEach(() => {
manager.destroy()
})
it('should work with circuit breaker enabled', async () => {
await manager.send('login')
expect(mockSendNode).toHaveBeenCalledTimes(1)
})
it('should handle circuit breaker failures gracefully', async () => {
// Make send fail multiple times to trigger circuit breaker
let callCount = 0
mockSendNode.mockImplementation(async () => {
callCount++
if (callCount <= 3) {
throw new Error(`Fail ${callCount}`)
}
})
// These should not throw even with failures
await expect(manager.send('login')).resolves.toBeUndefined()
await expect(manager.send('login')).resolves.toBeUndefined()
await expect(manager.send('login')).resolves.toBeUndefined()
})
})