@builderbot/provider-baileys 1.4.2-alpha.6 → 1.4.2-alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -33678,6 +33678,963 @@ function requireDist () {
33678
33678
 
33679
33679
  var distExports = requireDist();
33680
33680
 
33681
+ /**
33682
+ * @fileoverview LID (Local Identifier) Cache for WhatsApp Baileys Provider
33683
+ *
33684
+ * This module provides a caching layer for mapping WhatsApp Local Identifiers (LIDs)
33685
+ * to Phone Numbers (PNs). LIDs are privacy-preserving identifiers used by WhatsApp
33686
+ * that don't reveal the user's actual phone number.
33687
+ *
33688
+ * ## Architecture
33689
+ *
33690
+ * The cache uses a hybrid memory+file approach:
33691
+ * - **Hot path**: In-memory lookups via NodeCache (O(1), ~1μs)
33692
+ * - **Persistence**: JSON file for cross-restart durability
33693
+ * - **Strategy**: Write-through to memory, async flush to disk every 30s
33694
+ *
33695
+ * ## Key Features
33696
+ *
33697
+ * - **Zero-config**: Works out of the box with sensible defaults
33698
+ * - **Device suffix normalization**: `123:45@lid` and `123:99@lid` resolve to same entry
33699
+ * - **Phone number normalization**: Accepts `+123 456-7890`, `1234567890@c.us`, etc.
33700
+ * - **Security**: File permissions 0o600, PII masking in logs
33701
+ * - **Resilience**: Corrupted files auto-rebuild, flush failures logged
33702
+ *
33703
+ * ## Usage
33704
+ *
33705
+ * ```typescript
33706
+ * // Default: Hybrid (memory + file)
33707
+ * const cache = new HybridLidCache('my-bot', 86400 * 7) // 7 day TTL
33708
+ * await cache.ready()
33709
+ *
33710
+ * await cache.set('123456789:45@lid', '+34 691 015 468')
33711
+ * const pn = await cache.get('123456789:99@lid') // Returns '34691015468@s.whatsapp.net'
33712
+ *
33713
+ * await cache.close() // Persists to disk
33714
+ * ```
33715
+ *
33716
+ * @module lidCache
33717
+ * @author BuilderBot Team
33718
+ * @since 1.4.2
33719
+ */
33720
+ // =============================================================================
33721
+ // CONSTANTS
33722
+ // =============================================================================
33723
+ /** File format version for migrations */
33724
+ const CACHE_FILE_VERSION = 1;
33725
+ /** Default TTL: 7 days (seconds) */
33726
+ const DEFAULT_TTL_SECONDS = 86400 * 7;
33727
+ /** Auto-flush interval: 30 seconds (milliseconds) */
33728
+ const DEFAULT_FLUSH_INTERVAL_MS = 30000;
33729
+ /** Compact file when exceeds 10 MB */
33730
+ const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
33731
+ /** Compact when more than 10k entries */
33732
+ const COMPACT_AT_ENTRIES = 10000;
33733
+ /** Disable persistence after 10 consecutive flush failures */
33734
+ const MAX_FLUSH_FAILURES = 10;
33735
+ /** Unix file permissions: owner read/write only (0o600) */
33736
+ const FILE_PERMISSIONS = 0o600;
33737
+ /**
33738
+ * Helper to brand a string as LidJid (runtime check).
33739
+ * Returns null if the string is not a valid LID.
33740
+ */
33741
+ function asLidJid(value) {
33742
+ return isValidLid(value) ? normalizeLid(value) : null;
33743
+ }
33744
+ /**
33745
+ * Helper to brand a string as PnJid (runtime check).
33746
+ * Returns null if the string is not a valid phone number JID.
33747
+ */
33748
+ function asPnJid(value) {
33749
+ if (!isValidPn(value))
33750
+ return null;
33751
+ const normalized = normalizePn(value);
33752
+ return normalized ? normalized : null;
33753
+ }
33754
+ // =============================================================================
33755
+ // TYPE GUARDS
33756
+ // =============================================================================
33757
+ /**
33758
+ * Validates if a value is a valid LID string.
33759
+ *
33760
+ * A valid LID:
33761
+ * - Is a string
33762
+ * - Contains `@lid` suffix
33763
+ * - Has minimum length of 5 (e.g., `1@lid`)
33764
+ *
33765
+ * @param value - Value to validate
33766
+ * @returns Type predicate: true if value is a valid LID string
33767
+ *
33768
+ * @example
33769
+ * ```typescript
33770
+ * isValidLid('123456789@lid') // true
33771
+ * isValidLid('123456789:45@lid') // true
33772
+ * isValidLid('123@c.us') // false
33773
+ * isValidLid('') // false
33774
+ * isValidLid(null) // false
33775
+ * ```
33776
+ */
33777
+ function isValidLid(value) {
33778
+ return typeof value === 'string' && value.length >= 5 && value.includes('@lid');
33779
+ }
33780
+ /**
33781
+ * Validates if a value is a valid phone number string.
33782
+ *
33783
+ * Accepts:
33784
+ * - Bare digits: `1234567890`
33785
+ * - International: `+1234567890`
33786
+ * - Formatted: `+1 (555) 123-4567`
33787
+ * - WhatsApp JIDs: `123@s.whatsapp.net`, `123@c.us`
33788
+ *
33789
+ * @param value - Value to validate
33790
+ * @returns true if value contains at least one digit or is already a JID
33791
+ */
33792
+ function isValidPn(value) {
33793
+ if (typeof value !== 'string')
33794
+ return false;
33795
+ if (value.length === 0)
33796
+ return false;
33797
+ // Accept if already has WhatsApp JID format
33798
+ if (value.includes('@s.whatsapp.net') || value.includes('@c.us'))
33799
+ return true;
33800
+ // Accept if contains any digit (will be cleaned during normalization)
33801
+ const hasDigits = /\d/.test(value);
33802
+ return hasDigits;
33803
+ }
33804
+ /**
33805
+ * Validates if a value matches the CacheEntry interface.
33806
+ *
33807
+ * @internal
33808
+ */
33809
+ function isCacheEntry(value) {
33810
+ if (typeof value !== 'object' || value === null)
33811
+ return false;
33812
+ const entry = value;
33813
+ return typeof entry.pn === 'string' && typeof entry.ts === 'number' && !isNaN(entry.ts);
33814
+ }
33815
+ /**
33816
+ * Validates if a value matches the CacheFileData interface.
33817
+ *
33818
+ * @internal
33819
+ */
33820
+ function isCacheFileData(value) {
33821
+ if (typeof value !== 'object' || value === null)
33822
+ return false;
33823
+ const data = value;
33824
+ return typeof data.version === 'number' && typeof data.entries === 'object' && data.entries !== null;
33825
+ }
33826
+ // =============================================================================
33827
+ // UTILITY FUNCTIONS
33828
+ // =============================================================================
33829
+ /**
33830
+ * Normalizes a LID by removing the device suffix.
33831
+ *
33832
+ * WhatsApp sends LIDs with device identifiers (e.g., `:45`, `:99`) that vary
33833
+ * based on the user's device. The same contact will have different suffixes
33834
+ * on phone vs web vs tablet. This function strips the suffix for consistent
33835
+ * caching.
33836
+ *
33837
+ * @param lid - LID to normalize (e.g., '123456789:45@lid')
33838
+ * @returns Normalized LID (e.g., '123456789@lid'), or original if invalid
33839
+ *
33840
+ * @example
33841
+ * ```typescript
33842
+ * normalizeLid('123456789:45@lid') // '123456789@lid'
33843
+ * normalizeLid('123456789:99@lid') // '123456789@lid'
33844
+ * normalizeLid('123456789@lid') // '123456789@lid' (no change)
33845
+ * normalizeLid('123456789@c.us') // '123456789@c.us' (no change, not a LID)
33846
+ * normalizeLid('invalid') // 'invalid' (no change, invalid)
33847
+ * ```
33848
+ */
33849
+ function normalizeLid(lid) {
33850
+ if (!isValidLid(lid))
33851
+ return lid;
33852
+ // Remove :digits before @lid suffix only
33853
+ return lid.replace(/:\d+(?=@lid$)/, '');
33854
+ }
33855
+ /**
33856
+ * Sanitizes a session name for safe filesystem usage.
33857
+ *
33858
+ * Prevents path traversal and invalid filename characters.
33859
+ *
33860
+ * @internal
33861
+ * @param name - Raw session name
33862
+ * @returns Sanitized name safe for use in filenames
33863
+ */
33864
+ function sanitizeSessionName(name) {
33865
+ // Replace dangerous characters for filenames
33866
+ return name.replace(/[\\/:"*?<>|]/g, '_').replace(/\.{2,}/g, '_');
33867
+ }
33868
+ /**
33869
+ * Normalizes a phone number to consistent `@s.whatsapp.net` format.
33870
+ *
33871
+ * Handles multiple input formats commonly encountered from WhatsApp:
33872
+ * - Bare digits: `1234567890` → `1234567890@s.whatsapp.net`
33873
+ * - International: `+1234567890` → `1234567890@s.whatsapp.net`
33874
+ * - Spaced: `123 456 7890` → `1234567890@s.whatsapp.net`
33875
+ * - Formatted: `+1 (555) 123-4567` → `15551234567@s.whatsapp.net`
33876
+ * - Legacy: `123@c.us` → `123@s.whatsapp.net`
33877
+ * - Already normalized: `123@s.whatsapp.net` → `123@s.whatsapp.net` (no change)
33878
+ *
33879
+ * @param pn - Phone number in any format
33880
+ * @returns Normalized phone number, or original if cannot be normalized
33881
+ *
33882
+ * @example
33883
+ * ```typescript
33884
+ * normalizePn('34691015468') // '34691015468@s.whatsapp.net'
33885
+ * normalizePn('+34691015468') // '34691015468@s.whatsapp.net'
33886
+ * normalizePn('34 691 015 468') // '34691015468@s.whatsapp.net'
33887
+ * normalizePn('+1 (555) 123-4567') // '15551234567@s.whatsapp.net'
33888
+ * normalizePn('34691015468@c.us') // '34691015468@s.whatsapp.net'
33889
+ * normalizePn('34691015468@s.whatsapp.net') // '34691015468@s.whatsapp.net'
33890
+ * normalizePn('not-a-number') // 'not-a-number' (unchanged)
33891
+ * ```
33892
+ */
33893
+ function normalizePn(pn) {
33894
+ if (!isValidPn(pn))
33895
+ return pn;
33896
+ // Already in WhatsApp JID format
33897
+ if (pn.includes('@s.whatsapp.net'))
33898
+ return pn;
33899
+ // Legacy format conversion
33900
+ if (pn.includes('@c.us')) {
33901
+ const digits = pn.replace('@c.us', '');
33902
+ if (/^\d+$/.test(digits))
33903
+ return `${digits}@s.whatsapp.net`;
33904
+ }
33905
+ // Clean common formatting: +, spaces, dashes, dots, parentheses
33906
+ const cleaned = pn.replace(/^\+/, '').replace(/[\s\-\.\(\)]/g, '');
33907
+ // Validate we now have only digits
33908
+ if (/^\d+$/.test(cleaned)) {
33909
+ return `${cleaned}@s.whatsapp.net`;
33910
+ }
33911
+ // Cannot normalize, return original
33912
+ return pn;
33913
+ }
33914
+ /**
33915
+ * Masks a LID for safe logging (PII protection).
33916
+ *
33917
+ * Replaces middle characters with `***` to prevent logging full identifiers
33918
+ * while keeping enough for debugging.
33919
+ *
33920
+ * @internal
33921
+ * @param lid - LID to mask
33922
+ * @returns Masked LID (e.g., '123456789@lid' → '123***@lid')
33923
+ */
33924
+ function maskLid(lid) {
33925
+ if (lid.length < 8)
33926
+ return '***@lid';
33927
+ return lid.slice(0, 3) + '***' + lid.slice(lid.indexOf('@'));
33928
+ }
33929
+ // =============================================================================
33930
+ // HYBRID CACHE (Memory + File)
33931
+ // =============================================================================
33932
+ /**
33933
+ * Hybrid LID cache implementation with memory hot-path and file persistence.
33934
+ *
33935
+ * This is the recommended production implementation, providing:
33936
+ * - **Speed**: O(1) in-memory lookups via NodeCache (~1μs)
33937
+ * - **Durability**: Automatic persistence to JSON file every 30s
33938
+ * - **Resilience**: Survives process restarts, handles corrupted files gracefully
33939
+ * - **Security**: File permissions 0o600 (owner read/write only)
33940
+ *
33941
+ * ## File Location
33942
+ *
33943
+ * Files are stored at: `{cwd}/{sessionName}_sessions/lid-cache.json`
33944
+ *
33945
+ * The session name is sanitized to prevent path traversal attacks.
33946
+ *
33947
+ * ## Configuration
33948
+ *
33949
+ * | Option | Default | Description |
33950
+ * |--------|---------|-------------|
33951
+ * | `sessionName` | required | Unique name for this bot instance |
33952
+ * | `ttlSeconds` | 604800 (7 days) | Time-to-live for cache entries |
33953
+ * | `basePath` | `process.cwd()` | Directory for session files |
33954
+ * | `logger` | `console` | Logger for operational events |
33955
+ *
33956
+ * @example
33957
+ * ```typescript
33958
+ * // Basic usage
33959
+ * const cache = new HybridLidCache('my-bot')
33960
+ * await cache.ready()
33961
+ *
33962
+ * // Custom TTL and logger
33963
+ * const cache = new HybridLidCache('my-bot', 86400 * 30, undefined, winstonLogger)
33964
+ *
33965
+ * // Custom base path
33966
+ * const cache = new HybridLidCache('my-bot', 86400, '/var/lib/bot')
33967
+ * ```
33968
+ */
33969
+ class HybridLidCache {
33970
+ /**
33971
+ * Creates a new HybridLidCache instance.
33972
+ *
33973
+ * @param sessionName - Unique identifier for this cache instance (used in filename)
33974
+ * @param ttlSeconds - Time-to-live for cache entries (default: 7 days)
33975
+ * @param basePath - Base directory for session files (default: process.cwd())
33976
+ * @param logger - Logger instance for operational events (default: console)
33977
+ *
33978
+ * @throws Error if sessionName is empty or not a string
33979
+ * @throws Error if ttlSeconds is less than 60
33980
+ *
33981
+ * @remarks
33982
+ * The constructor starts async file loading and sets up periodic auto-flush.
33983
+ * Use {@link ready()} to wait for initial load completion before operations
33984
+ * that require data from previous runs.
33985
+ */
33986
+ constructor(sessionName, ttlSeconds = DEFAULT_TTL_SECONDS, basePath, logger) {
33987
+ /** True if memory has unwritten changes */
33988
+ this.dirty = false;
33989
+ /** True if flushToDisk() is currently running (prevents concurrent flushes) */
33990
+ this.flushing = false;
33991
+ /** Consecutive flush failure count (disables persistence after threshold) */
33992
+ this.consecutiveFlushFailures = 0;
33993
+ /** True after close() has been called */
33994
+ this.isClosed = false;
33995
+ // Validate required arguments
33996
+ if (!sessionName || typeof sessionName !== 'string') {
33997
+ throw new Error('sessionName is required and must be a string');
33998
+ }
33999
+ if (ttlSeconds < 60) {
34000
+ throw new Error('ttlSeconds must be at least 60 (1 minute)');
34001
+ }
34002
+ this.ttlSeconds = ttlSeconds;
34003
+ this.logger = logger || console;
34004
+ const sanitizedSession = sanitizeSessionName(sessionName);
34005
+ const cwd = basePath || process.cwd();
34006
+ this.filePath = require$$1$1.join(cwd, `${sanitizedSession}_sessions`, 'lid-cache.json');
34007
+ // Initialize in-memory cache with TTL
34008
+ this.memory = new NodeCache({
34009
+ stdTTL: ttlSeconds,
34010
+ useClones: false, // Performance: don't clone objects
34011
+ deleteOnExpire: true, // Auto-cleanup expired entries
34012
+ });
34013
+ // Start async file load (tracked to prevent race conditions)
34014
+ this.loadPromise = this.loadFromDisk();
34015
+ // Setup periodic auto-flush (every 30s)
34016
+ this.flushInterval = setInterval(() => {
34017
+ if (!this.isClosed) {
34018
+ this.flushToDisk().catch((err) => {
34019
+ this.logger.error('[LID Cache] Periodic flush failed:', err);
34020
+ });
34021
+ }
34022
+ }, DEFAULT_FLUSH_INTERVAL_MS);
34023
+ // Unref interval so it doesn't block process exit (important for tests)
34024
+ if (this.flushInterval.unref) {
34025
+ this.flushInterval.unref();
34026
+ }
34027
+ }
34028
+ /**
34029
+ * Waits for the initial file load to complete.
34030
+ *
34031
+ * Use this method before operations that depend on data from previous runs:
34032
+ * - Before checking if a LID exists from a previous session
34033
+ * - In tests to ensure deterministic state
34034
+ * - During cache warming procedures
34035
+ *
34036
+ * @returns Promise that resolves when initial load completes
34037
+ *
34038
+ * @example
34039
+ * ```typescript
34040
+ * const cache = new HybridLidCache('my-bot')
34041
+ * await cache.ready() // Wait for any existing data to load
34042
+ * const pn = await cache.get('existing@lid') // Now safe to access
34043
+ * ```
34044
+ */
34045
+ async ready() {
34046
+ await this.loadPromise;
34047
+ }
34048
+ /**
34049
+ * Retrieves the phone number for a given LID.
34050
+ *
34051
+ * @param lid - WhatsApp Local Identifier (e.g., '123456789:45@lid')
34052
+ * @returns Phone number in format '1234567890@s.whatsapp.net', or null if:
34053
+ * - Cache is closed
34054
+ * - LID is invalid (doesn't match `*@lid` pattern)
34055
+ * - LID not found in cache
34056
+ * - Entry has expired
34057
+ */
34058
+ async get(lid) {
34059
+ if (this.isClosed)
34060
+ return null;
34061
+ if (!isValidLid(lid))
34062
+ return null;
34063
+ const normalized = normalizeLid(lid);
34064
+ const value = this.memory.get(normalized);
34065
+ if (!value)
34066
+ return null;
34067
+ // Refresh LRU timestamp (extends implicit TTL)
34068
+ this.memory.set(normalized, value);
34069
+ return value;
34070
+ }
34071
+ /**
34072
+ * Stores a LID → phone number mapping.
34073
+ *
34074
+ * Both the LID and phone number are normalized before storage:
34075
+ * - LID: Device suffix removed (`123:45@lid` → `123@lid`)
34076
+ * - PN: Formatted to `123@s.whatsapp.net`
34077
+ *
34078
+ * @param lid - WhatsApp Local Identifier
34079
+ * @param pn - Phone number in any accepted format
34080
+ * @returns Promise that resolves immediately (memory write is synchronous)
34081
+ *
34082
+ * @remarks
34083
+ * - Invalid inputs are silently rejected (no throw)
34084
+ * - The `dirty` flag is set, triggering async flush to disk within 30s
34085
+ * - If the cache is closed, this is a no-op
34086
+ */
34087
+ async set(lid, pn) {
34088
+ if (this.isClosed)
34089
+ return;
34090
+ if (!isValidLid(lid)) {
34091
+ this.logger.debug?.('[LID Cache] Rejected invalid LID:', maskLid(String(lid)));
34092
+ return;
34093
+ }
34094
+ if (!isValidPn(pn)) {
34095
+ this.logger.debug?.('[LID Cache] Rejected invalid PN:', pn);
34096
+ return;
34097
+ }
34098
+ const normalizedLid = normalizeLid(lid);
34099
+ const normalizedPn = normalizePn(pn);
34100
+ this.memory.set(normalizedLid, normalizedPn);
34101
+ this.dirty = true;
34102
+ }
34103
+ /**
34104
+ * Checks if a LID exists in the cache.
34105
+ *
34106
+ * @param lid - WhatsApp Local Identifier
34107
+ * @returns true if:
34108
+ * - LID is valid
34109
+ * - Cache is open
34110
+ * - Entry exists and hasn't expired
34111
+ *
34112
+ * @example
34113
+ * ```typescript
34114
+ * if (await cache.has('123@lid')) {
34115
+ * const pn = await cache.get('123@lid')
34116
+ * // ...
34117
+ * }
34118
+ * ```
34119
+ */
34120
+ async has(lid) {
34121
+ if (this.isClosed)
34122
+ return false;
34123
+ if (!isValidLid(lid))
34124
+ return false;
34125
+ const normalized = normalizeLid(lid);
34126
+ return this.memory.has(normalized);
34127
+ }
34128
+ /**
34129
+ * Clears all entries from the cache.
34130
+ *
34131
+ * @remarks
34132
+ * - Immediately clears memory
34133
+ * - Triggers synchronous flush to disk (empty file)
34134
+ * - Logs the operation at info level
34135
+ */
34136
+ async clear() {
34137
+ if (this.isClosed)
34138
+ return;
34139
+ this.memory.flushAll();
34140
+ this.dirty = true;
34141
+ this.logger.info?.('[LID Cache] Cache cleared');
34142
+ await this.flushToDisk();
34143
+ }
34144
+ /**
34145
+ * Closes the cache, releasing resources and triggering final persistence.
34146
+ *
34147
+ * This method:
34148
+ * 1. Stops the auto-flush interval
34149
+ * 2. Waits for any in-progress file load
34150
+ * 3. Performs a final flush to disk
34151
+ * 4. Closes the NodeCache instance
34152
+ * 5. Marks the cache as closed
34153
+ *
34154
+ * @returns Promise that resolves when cleanup is complete
34155
+ *
34156
+ * @remarks
34157
+ * - After close(), all operations return null/void
34158
+ * - Safe to call multiple times (subsequent calls are no-ops)
34159
+ * - Flush errors are logged but don't throw
34160
+ */
34161
+ async close() {
34162
+ if (this.isClosed)
34163
+ return;
34164
+ if (this.flushInterval) {
34165
+ clearInterval(this.flushInterval);
34166
+ this.flushInterval = undefined;
34167
+ }
34168
+ // Wait for initial load to complete if still in progress
34169
+ try {
34170
+ await this.loadPromise;
34171
+ }
34172
+ catch {
34173
+ // Ignore load errors during close
34174
+ }
34175
+ // Final flush attempt
34176
+ try {
34177
+ await this.flushToDisk();
34178
+ }
34179
+ catch (err) {
34180
+ this.logger.error('[LID Cache] Final flush failed on close:', err);
34181
+ }
34182
+ this.isClosed = true;
34183
+ this.memory.close();
34184
+ this.logger.info?.('[LID Cache] Closed');
34185
+ }
34186
+ /**
34187
+ * Forces compaction of the cache file by rewriting it with only valid entries.
34188
+ *
34189
+ * This removes any expired entries that might still be in the file
34190
+ * (since NodeCache auto-expiry only removes from memory, not disk).
34191
+ *
34192
+ * @returns Promise that resolves when compaction completes
34193
+ *
34194
+ * @remarks
34195
+ * - If the cache is empty, the file is deleted instead
34196
+ * - Automatically called when file exceeds 10MB or 10k entries
34197
+ */
34198
+ async compact() {
34199
+ if (this.isClosed)
34200
+ return;
34201
+ const keys = this.memory.keys();
34202
+ if (keys.length === 0) {
34203
+ // Empty cache - delete file
34204
+ try {
34205
+ await promises.unlink(this.filePath);
34206
+ this.dirty = false;
34207
+ this.logger.info?.('[LID Cache] Empty cache file removed');
34208
+ }
34209
+ catch {
34210
+ // File may not exist
34211
+ }
34212
+ return;
34213
+ }
34214
+ // Force full rewrite
34215
+ this.dirty = true;
34216
+ await this.flushToDisk();
34217
+ this.logger.info?.('[LID Cache] Compacted', { entries: keys.length });
34218
+ }
34219
+ /**
34220
+ * Persists the current cache state to disk.
34221
+ *
34222
+ * @internal
34223
+ * @remarks
34224
+ * - Concurrent calls are deduplicated (only one flush runs at a time)
34225
+ * - No-op if nothing has changed since last flush (`dirty` flag check)
34226
+ * - File is written with 0o600 permissions (owner read/write only)
34227
+ * - After 10 consecutive failures, persistence is disabled
34228
+ */
34229
+ async flushToDisk() {
34230
+ // Prevent concurrent flushes
34231
+ if (this.flushing)
34232
+ return;
34233
+ if (!this.dirty)
34234
+ return;
34235
+ this.flushing = true;
34236
+ try {
34237
+ await promises.mkdir(require$$1$1.dirname(this.filePath), { recursive: true });
34238
+ const keys = this.memory.keys();
34239
+ // Compact if entry count exceeds threshold
34240
+ if (keys.length > COMPACT_AT_ENTRIES) {
34241
+ await this.compact();
34242
+ }
34243
+ const entries = {};
34244
+ const now = Date.now();
34245
+ for (const key of keys) {
34246
+ const value = this.memory.get(key);
34247
+ if (!value)
34248
+ continue;
34249
+ entries[key] = {
34250
+ pn: value,
34251
+ ts: now,
34252
+ };
34253
+ }
34254
+ const data = {
34255
+ version: CACHE_FILE_VERSION,
34256
+ entries,
34257
+ };
34258
+ // Write with secure permissions
34259
+ await promises.writeFile(this.filePath, JSON.stringify(data, null, 2), {
34260
+ encoding: 'utf-8',
34261
+ mode: FILE_PERMISSIONS,
34262
+ });
34263
+ this.dirty = false;
34264
+ this.consecutiveFlushFailures = 0; // Reset on success
34265
+ // Check file size and compact if needed
34266
+ await this.checkAndCompactIfNeeded();
34267
+ }
34268
+ catch (err) {
34269
+ this.consecutiveFlushFailures++;
34270
+ if (this.consecutiveFlushFailures >= MAX_FLUSH_FAILURES) {
34271
+ this.logger.error(`[LID Cache] Flush failed ${MAX_FLUSH_FAILURES} times, disabling persistence. ` +
34272
+ 'Cache will work in-memory only until restart.', { error: err, filePath: this.filePath });
34273
+ this.dirty = false; // Stop trying
34274
+ }
34275
+ else {
34276
+ this.logger.warn(`[LID Cache] Flush failed (${this.consecutiveFlushFailures}/${MAX_FLUSH_FAILURES}):`, err);
34277
+ }
34278
+ throw err; // Re-throw for caller awareness
34279
+ }
34280
+ finally {
34281
+ this.flushing = false;
34282
+ }
34283
+ }
34284
+ /**
34285
+ * Checks file size and triggers compaction if exceeds threshold.
34286
+ *
34287
+ * @internal
34288
+ */
34289
+ async checkAndCompactIfNeeded() {
34290
+ try {
34291
+ const stats = await promises.stat(this.filePath);
34292
+ if (stats.size > MAX_FILE_SIZE_BYTES) {
34293
+ this.logger.warn(`[LID Cache] File size ${stats.size} bytes exceeds threshold, compacting...`);
34294
+ await this.compact();
34295
+ }
34296
+ }
34297
+ catch {
34298
+ // File may not exist
34299
+ }
34300
+ }
34301
+ /**
34302
+ * Loads cache data from disk on startup.
34303
+ *
34304
+ * @internal
34305
+ * @remarks
34306
+ * - Silently handles missing file (first run)
34307
+ * - Automatically removes and recovers from corrupted files
34308
+ * - Validates TTL on each entry (entries older than TTL are skipped)
34309
+ */
34310
+ async loadFromDisk() {
34311
+ try {
34312
+ await promises.access(this.filePath);
34313
+ }
34314
+ catch {
34315
+ // File doesn't exist - clean start
34316
+ return;
34317
+ }
34318
+ let data;
34319
+ try {
34320
+ const raw = await promises.readFile(this.filePath, 'utf-8');
34321
+ data = JSON.parse(raw);
34322
+ }
34323
+ catch (err) {
34324
+ // Corrupted file - remove and start fresh
34325
+ try {
34326
+ await promises.unlink(this.filePath);
34327
+ }
34328
+ catch {
34329
+ // ignore unlink errors
34330
+ }
34331
+ this.logger.error('[LID Cache] Corrupted cache file removed, starting fresh:', err);
34332
+ return;
34333
+ }
34334
+ // Validate file structure
34335
+ if (!isCacheFileData(data)) {
34336
+ this.logger.warn('[LID Cache] Invalid cache file format, starting fresh');
34337
+ return;
34338
+ }
34339
+ // Load valid, non-expired entries
34340
+ let loadedCount = 0;
34341
+ let expiredCount = 0;
34342
+ const now = Date.now();
34343
+ const ttlMs = this.ttlSeconds * 1000;
34344
+ for (const [key, entry] of Object.entries(data.entries)) {
34345
+ if (!isCacheEntry(entry))
34346
+ continue;
34347
+ // Check TTL from stored timestamp
34348
+ const age = now - entry.ts;
34349
+ if (age >= ttlMs) {
34350
+ expiredCount++;
34351
+ continue;
34352
+ }
34353
+ this.memory.set(key, entry.pn);
34354
+ loadedCount++;
34355
+ }
34356
+ if (loadedCount > 0 || expiredCount > 0) {
34357
+ this.logger.info('[LID Cache] Loaded entries from disk', {
34358
+ valid: loadedCount,
34359
+ expired: expiredCount,
34360
+ file: this.filePath,
34361
+ });
34362
+ }
34363
+ }
34364
+ /**
34365
+ * Returns cache statistics for monitoring.
34366
+ *
34367
+ * @returns Object with:
34368
+ * - `keys`: Number of entries currently in memory
34369
+ * - `hits`: Cache hit count (from NodeCache stats)
34370
+ * - `misses`: Cache miss count (from NodeCache stats)
34371
+ *
34372
+ * @example
34373
+ * ```typescript
34374
+ * const stats = cache.getStats()
34375
+ * console.log(`Cache: ${stats.keys} entries, ${stats.hits} hits, ${stats.misses} misses`)
34376
+ * // → Cache: 1523 entries, 4500 hits, 123 misses
34377
+ * ```
34378
+ */
34379
+ getStats() {
34380
+ return {
34381
+ keys: this.memory.keys().length,
34382
+ hits: this.memory.getStats()?.hits || 0,
34383
+ misses: this.memory.getStats()?.misses || 0,
34384
+ };
34385
+ }
34386
+ }
34387
+ // =============================================================================
34388
+ // MEMORY-ONLY CACHE (Testing)
34389
+ // =============================================================================
34390
+ /**
34391
+ * Memory-only LID cache implementation for testing.
34392
+ *
34393
+ * This implementation provides the same {@link LidCache} interface but without
34394
+ * file persistence. Useful for:
34395
+ * - Unit tests (no file I/O, no cleanup needed)
34396
+ * - Ephemeral caches that don't need durability
34397
+ * - Reducing disk wear in high-frequency test scenarios
34398
+ *
34399
+ * ## Differences from HybridLidCache
34400
+ *
34401
+ * | Feature | MemoryLidCache | HybridLidCache |
34402
+ * |---------|---------------|----------------|
34403
+ * | Persistence | ❌ None | ✅ JSON file |
34404
+ * | `ready()` | Optional | Recommended |
34405
+ * | `close()` | No-op | Flushes to disk |
34406
+ * | `compact()` | No-op | Rewrites file |
34407
+ * | Cross-restart | Data lost | Data preserved |
34408
+ *
34409
+ * @example
34410
+ * ```typescript
34411
+ * // Testing scenario
34412
+ * const cache = new MemoryLidCache(3600) // 1 hour TTL
34413
+ * await cache.set('123@lid', '456@s.whatsapp.net')
34414
+ * expect(await cache.get('123@lid')).toBe('456@s.whatsapp.net')
34415
+ * // No cleanup needed - data is ephemeral
34416
+ * ```
34417
+ */
34418
+ class MemoryLidCache {
34419
+ /**
34420
+ * Creates a new MemoryLidCache instance.
34421
+ *
34422
+ * @param ttlSeconds - Time-to-live for cache entries (default: 7 days)
34423
+ */
34424
+ constructor(ttlSeconds = DEFAULT_TTL_SECONDS) {
34425
+ this.memory = new NodeCache({
34426
+ stdTTL: ttlSeconds,
34427
+ useClones: false,
34428
+ });
34429
+ }
34430
+ /**
34431
+ * Retrieves the phone number for a given LID.
34432
+ *
34433
+ * @param lid - WhatsApp Local Identifier
34434
+ * @returns Phone number or null if not found/invalid
34435
+ */
34436
+ async get(lid) {
34437
+ if (!isValidLid(lid))
34438
+ return null;
34439
+ const normalized = normalizeLid(lid);
34440
+ return this.memory.get(normalized) || null;
34441
+ }
34442
+ /**
34443
+ * Stores a LID → phone number mapping.
34444
+ *
34445
+ * @param lid - WhatsApp Local Identifier
34446
+ * @param pn - Phone number in any format
34447
+ */
34448
+ async set(lid, pn) {
34449
+ if (!isValidLid(lid))
34450
+ return;
34451
+ if (!isValidPn(pn))
34452
+ return;
34453
+ const normalized = normalizeLid(lid);
34454
+ this.memory.set(normalized, pn);
34455
+ }
34456
+ /**
34457
+ * Checks if a LID exists in the cache.
34458
+ *
34459
+ * @param lid - WhatsApp Local Identifier
34460
+ * @returns true if entry exists and hasn't expired
34461
+ */
34462
+ async has(lid) {
34463
+ if (!isValidLid(lid))
34464
+ return false;
34465
+ const normalized = normalizeLid(lid);
34466
+ return this.memory.has(normalized);
34467
+ }
34468
+ /**
34469
+ * Clears all entries from the cache.
34470
+ */
34471
+ async clear() {
34472
+ this.memory.flushAll();
34473
+ }
34474
+ /**
34475
+ * Closes the cache. For MemoryLidCache, this is a no-op.
34476
+ *
34477
+ * @remarks The NodeCache instance is not closed to allow continued testing.
34478
+ * If you need to free memory, use `clear()` before `close()`.
34479
+ */
34480
+ async close() {
34481
+ // No-op for memory cache - data is already ephemeral
34482
+ }
34483
+ }
34484
+ /**
34485
+ * Factory function to create a LidCache instance based on configuration.
34486
+ *
34487
+ * This factory centralizes cache creation logic, making it reusable across
34488
+ * the codebase and easier to test/maintain.
34489
+ *
34490
+ * @param options - Factory configuration options
34491
+ * @returns Configured LidCache instance
34492
+ *
34493
+ * @example
34494
+ * ```typescript
34495
+ * // Default: HybridLidCache (file + memory)
34496
+ * const cache = createLidCache({ sessionName: 'my-bot' })
34497
+ *
34498
+ * // Memory-only (testing)
34499
+ * const cache = createLidCache({ strategy: 'memory', ttlSeconds: 3600 })
34500
+ *
34501
+ * // Custom implementation
34502
+ * const cache = createLidCache({ strategy: new RedisLidCache() })
34503
+ * ```
34504
+ */
34505
+ function createLidCache(options = {}) {
34506
+ const { strategy = 'file', sessionName = 'default', ttlSeconds = DEFAULT_TTL_SECONDS, basePath, logger } = options;
34507
+ // If custom instance passed, use it directly
34508
+ if (strategy && typeof strategy === 'object') {
34509
+ return strategy;
34510
+ }
34511
+ // Memory-only strategy
34512
+ if (strategy === 'memory') {
34513
+ return new MemoryLidCache(ttlSeconds);
34514
+ }
34515
+ // Default: Hybrid (file + memory)
34516
+ return new HybridLidCache(sessionName, ttlSeconds, basePath, logger);
34517
+ }
34518
+ /**
34519
+ * Type guard to check if a value is a valid MessageContext.
34520
+ *
34521
+ * @param value - Value to check
34522
+ * @returns true if the value matches MessageContext structure
34523
+ */
34524
+ function isMessageContext(value) {
34525
+ if (typeof value !== 'object' || value === null)
34526
+ return false;
34527
+ const ctx = value;
34528
+ // If key exists, it must be an object (MessageContextKey)
34529
+ if ('key' in ctx && ctx.key !== undefined) {
34530
+ if (typeof ctx.key !== 'object' || ctx.key === null)
34531
+ return false;
34532
+ }
34533
+ return true;
34534
+ }
34535
+ /**
34536
+ * Extracts and caches LID → PN mapping from an incoming message.
34537
+ *
34538
+ * This utility function inspects the message context to find LID/PN pairs
34539
+ * and stores them in the provided cache for future lookups.
34540
+ *
34541
+ * @param cache - LidCache instance to store the mapping
34542
+ * @param messageCtx - Baileys message context (WAMessage)
34543
+ * @returns Promise that resolves when caching is complete (or silently fails)
34544
+ *
34545
+ * @example
34546
+ * ```typescript
34547
+ * // In message handler:
34548
+ * for (const message of messages) {
34549
+ * await extractAndCacheLidFromMessage(lidCache, message)
34550
+ * }
34551
+ * ```
34552
+ */
34553
+ async function extractAndCacheLidFromMessage(cache, messageCtx) {
34554
+ try {
34555
+ const key = messageCtx?.key;
34556
+ if (!key)
34557
+ return;
34558
+ const isGroup = key.remoteJid?.includes('@g.us');
34559
+ if (isGroup) {
34560
+ // Groups: participant has the LID, participantAlt has the PN
34561
+ if (key.participant?.includes('@lid') && key.participantAlt) {
34562
+ await cache.set(key.participant, key.participantAlt);
34563
+ }
34564
+ }
34565
+ else {
34566
+ // DMs: remoteJid has the LID
34567
+ if (key.remoteJid?.includes('@lid')) {
34568
+ // Priority: remoteJidAlt > senderPn > participantPn
34569
+ const pn = key.remoteJidAlt || key.senderPn || key.participantPn;
34570
+ if (pn) {
34571
+ await cache.set(key.remoteJid, pn);
34572
+ }
34573
+ }
34574
+ }
34575
+ }
34576
+ catch {
34577
+ // Silent failure - don't block message processing
34578
+ }
34579
+ }
34580
+ /**
34581
+ * Resolves a LID to a phone number using cache-first strategy.
34582
+ *
34583
+ * This function implements the resolution chain:
34584
+ * 1. Check cache first (O(1), ~1μs)
34585
+ * 2. If miss, call fallback resolver (e.g., Baileys lidMapping)
34586
+ * 3. If resolved, store in cache for future lookups
34587
+ *
34588
+ * @param cache - LidCache instance for storing/retrieving mappings
34589
+ * @param fallbackResolver - Async function to resolve LID when not in cache
34590
+ * @param logger - Logger for operational events (optional)
34591
+ * @param lid - WhatsApp Local Identifier to resolve
34592
+ * @returns Phone number in format '1234567890@s.whatsapp.net', or null
34593
+ *
34594
+ * @example
34595
+ * ```typescript
34596
+ * const pn = await resolveLidToPn(
34597
+ * lidCache,
34598
+ * (lid) => baileysSignalRepo.lidMapping.getPNForLID(lid),
34599
+ * console,
34600
+ * '123456789@lid' as LidJid
34601
+ * )
34602
+ * ```
34603
+ */
34604
+ async function resolveLidToPn(cache, fallbackResolver, logger, lid) {
34605
+ try {
34606
+ // Validate/normalize the LID
34607
+ const normalizedLid = asLidJid(typeof lid === 'string' ? lid : lid);
34608
+ if (!normalizedLid) {
34609
+ logger?.error?.('[LID Cache] Invalid LID format:', lid);
34610
+ return null;
34611
+ }
34612
+ // 1. Check cache first (fast, O(1))
34613
+ const cached = await cache.get(normalizedLid);
34614
+ if (cached) {
34615
+ logger?.log?.(`[LID Cache] Hit: ${normalizedLid} -> ${cached}`);
34616
+ return cached;
34617
+ }
34618
+ // 2. Fallback to provided resolver
34619
+ const resolved = await fallbackResolver(normalizedLid);
34620
+ if (resolved) {
34621
+ // Validate the resolved value is a valid PN
34622
+ const normalizedPn = asPnJid(resolved);
34623
+ if (normalizedPn) {
34624
+ // Store in cache for next time
34625
+ await cache.set(normalizedLid, normalizedPn);
34626
+ logger?.log?.(`[LID Cache] Resolved: ${normalizedLid} -> ${normalizedPn}`);
34627
+ return normalizedPn;
34628
+ }
34629
+ }
34630
+ return null;
34631
+ }
34632
+ catch (e) {
34633
+ logger?.error?.('[LID Cache] Error resolving LID:', e);
34634
+ return null;
34635
+ }
34636
+ }
34637
+
33681
34638
  const keepFiles = ['creds.json', 'baileys_store.json', 'app-state-sync', 'session'];
33682
34639
  /**
33683
34640
  * @alpha
@@ -33950,6 +34907,8 @@ class BaileysProvider extends bot.ProviderClass {
33950
34907
  if (messageCtx?.key?.id && messageCtx?.message) {
33951
34908
  this.messageCache?.set(`msg:${messageCtx.key.id}`, messageCtx.message);
33952
34909
  }
34910
+ // Aprender mapeo LID→PN desde mensaje entrante (async, no bloqueante)
34911
+ this.cacheLidFromMessage(messageCtx).catch(() => { });
33953
34912
  if (messageCtx?.messageStubParameters?.length &&
33954
34913
  messageCtx.messageStubParameters[0].includes('absent'))
33955
34914
  continue;
@@ -34199,17 +35158,18 @@ class BaileysProvider extends bot.ProviderClass {
34199
35158
  }
34200
35159
  };
34201
35160
  /**
34202
- * Obtener número de teléfono (PN) para un LID (Local Identifier)
35161
+ * Obtener número de teléfono (PN) para un LID (Local Identifier).
35162
+ * Delegates to the standalone utility with Baileys lidMapping as fallback.
35163
+ *
34203
35164
  * @param lid - JID con formato '16424005304394@lid'
35165
+ * @returns Phone number en formato '1234567890@s.whatsapp.net', o null si no se resuelve
34204
35166
  */
34205
35167
  this.getPNForLID = async (lid) => {
34206
- try {
34207
- return (await this.lidMapping?.getPNForLID?.(lid)) ?? null;
34208
- }
34209
- catch (e) {
34210
- this.logger.log(`[${new Date().toISOString()}] Error getting PN for LID:`, e);
35168
+ // Normalize to branded type if valid
35169
+ const lidJid = typeof lid === 'string' ? asLidJid(lid) : lid;
35170
+ if (!lidJid)
34211
35171
  return null;
34212
- }
35172
+ return resolveLidToPn(this.lidCache, (id) => this.lidMapping?.getPNForLID?.(id) ?? Promise.resolve(null), this.logger, lidJid);
34213
35173
  };
34214
35174
  /**
34215
35175
  * Normaliza un número entrante a un JID válido para envío.
@@ -34494,6 +35454,8 @@ class BaileysProvider extends bot.ProviderClass {
34494
35454
  errorOnMissing: false,
34495
35455
  });
34496
35456
  this.globalVendorArgs = { ...this.globalVendorArgs, ...args };
35457
+ // Initialize LID cache (hybrid file+memory or memory-only based on config)
35458
+ this.lidCache = this.initializeLidCache();
34497
35459
  this.setupCleanupHandlers();
34498
35460
  this.setupPeriodicCleanup();
34499
35461
  }
@@ -34561,6 +35523,12 @@ class BaileysProvider extends bot.ProviderClass {
34561
35523
  this.messageCache.close();
34562
35524
  this.messageCache = undefined;
34563
35525
  }
35526
+ // Cerrar LID cache (flush final a disco)
35527
+ if (this.lidCache?.close) {
35528
+ this.lidCache.close().catch((err) => {
35529
+ this.logger.error(`[${new Date().toISOString()}] Error closing LID cache:`, err);
35530
+ });
35531
+ }
34564
35532
  this.mapSet.clear();
34565
35533
  this.idsDuplicates.length = 0;
34566
35534
  if (this.logStream && typeof this.logStream.end === 'function') {
@@ -34586,6 +35554,34 @@ class BaileysProvider extends bot.ProviderClass {
34586
35554
  .get('/', this.indexHome);
34587
35555
  }
34588
35556
  afterHttpServerInit() { }
35557
+ // =============================================================================
35558
+ // LID CACHE INTEGRATION
35559
+ // =============================================================================
35560
+ /**
35561
+ * Inicializa el caché LID/PN usando el factory.
35562
+ * @returns Instancia de LidCache configurada según globalVendorArgs
35563
+ */
35564
+ initializeLidCache() {
35565
+ return createLidCache({
35566
+ strategy: this.globalVendorArgs.lidCache,
35567
+ sessionName: this.globalVendorArgs.name,
35568
+ ttlSeconds: this.globalVendorArgs.lidCacheTtl,
35569
+ logger: this.logger,
35570
+ });
35571
+ }
35572
+ /**
35573
+ * Delegates to the standalone utility for caching LID→PN from messages.
35574
+ * This wrapper maintains the method signature for internal use while
35575
+ * leveraging the exported function for reusability.
35576
+ */
35577
+ async cacheLidFromMessage(messageCtx) {
35578
+ // Type guard: ensure the message context has the expected structure
35579
+ if (!isMessageContext(messageCtx)) {
35580
+ this.logger.debug?.('Invalid message context for LID caching');
35581
+ return;
35582
+ }
35583
+ return extractAndCacheLidFromMessage(this.lidCache, messageCtx);
35584
+ }
34589
35585
  /**
34590
35586
  * Accede al lidMapping del signalRepository de Baileys.
34591
35587
  */
@@ -34645,4 +35641,13 @@ class BaileysProvider extends bot.ProviderClass {
34645
35641
  }
34646
35642
 
34647
35643
  exports.BaileysProvider = BaileysProvider;
35644
+ exports.HybridLidCache = HybridLidCache;
35645
+ exports.MemoryLidCache = MemoryLidCache;
35646
+ exports.asLidJid = asLidJid;
35647
+ exports.asPnJid = asPnJid;
34648
35648
  exports.baileyCleanNumber = baileyCleanNumber;
35649
+ exports.createLidCache = createLidCache;
35650
+ exports.extractAndCacheLidFromMessage = extractAndCacheLidFromMessage;
35651
+ exports.isMessageContext = isMessageContext;
35652
+ exports.normalizeLid = normalizeLid;
35653
+ exports.resolveLidToPn = resolveLidToPn;