@prototypevip/baileys 0.0.8 → 0.1.0

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.
Files changed (54) hide show
  1. package/lib/AntiBan/antiban.js +922 -0
  2. package/lib/AntiBan/banRecoveryOrchestrator.js +284 -0
  3. package/lib/AntiBan/cli.js +177 -0
  4. package/lib/AntiBan/contactGraph.js +236 -0
  5. package/lib/AntiBan/contentVariator.js +150 -0
  6. package/lib/AntiBan/credsSnapshot.js +121 -0
  7. package/lib/AntiBan/deliveryTracker.js +107 -0
  8. package/lib/AntiBan/deviceFingerprint.js +106 -0
  9. package/lib/AntiBan/fleetEventStore.js +157 -0
  10. package/lib/AntiBan/groupOperationGuard.js +116 -0
  11. package/lib/AntiBan/health.js +207 -0
  12. package/lib/AntiBan/humanEntropy.js +337 -0
  13. package/lib/AntiBan/index.js +77 -0
  14. package/lib/AntiBan/instanceCoordinator.js +175 -0
  15. package/lib/AntiBan/jidCanonicalizer.js +256 -0
  16. package/lib/AntiBan/jidCircuitBreaker.js +186 -0
  17. package/lib/AntiBan/legitimacySignalInjector.js +231 -0
  18. package/lib/AntiBan/lidFirstResolver.js +174 -0
  19. package/lib/AntiBan/lidResolver.js +324 -0
  20. package/lib/AntiBan/messageQueue.js +187 -0
  21. package/lib/AntiBan/messageRecovery.js +283 -0
  22. package/lib/AntiBan/messageTypeRegistry.js +369 -0
  23. package/lib/AntiBan/observability.js +145 -0
  24. package/lib/AntiBan/patch.js +302 -0
  25. package/lib/AntiBan/persist.js +87 -0
  26. package/lib/AntiBan/presenceChoreographer.js +430 -0
  27. package/lib/AntiBan/presets.js +105 -0
  28. package/lib/AntiBan/profiles.js +31 -0
  29. package/lib/AntiBan/proxyRotator.js +306 -0
  30. package/lib/AntiBan/rateLimiter.js +261 -0
  31. package/lib/AntiBan/readReceiptVariance.js +88 -0
  32. package/lib/AntiBan/reconnectThrottle.js +180 -0
  33. package/lib/AntiBan/replyRatio.js +161 -0
  34. package/lib/AntiBan/reputationVoucher.js +373 -0
  35. package/lib/AntiBan/retryReason.js +91 -0
  36. package/lib/AntiBan/retryTracker.js +172 -0
  37. package/lib/AntiBan/scheduler.js +111 -0
  38. package/lib/AntiBan/sessionFingerprint.js +248 -0
  39. package/lib/AntiBan/sessionStability.js +330 -0
  40. package/lib/AntiBan/stateAdapter.js +73 -0
  41. package/lib/AntiBan/stateExport.js +152 -0
  42. package/lib/AntiBan/stealthConnect.js +130 -0
  43. package/lib/AntiBan/timelockGuard.js +181 -0
  44. package/lib/AntiBan/topologyThrottler.js +339 -0
  45. package/lib/AntiBan/warmup.js +140 -0
  46. package/lib/AntiBan/webhooks.js +80 -0
  47. package/lib/AntiBan/wrapper.js +456 -0
  48. package/lib/Socket/groups.js +26 -21
  49. package/lib/Socket/index.js +132 -7
  50. package/lib/Socket/messages-send.js +68 -5
  51. package/lib/Utils/ai-rich-formatter.js +256 -0
  52. package/lib/Utils/index.js +1 -0
  53. package/lib/index.js +1 -1
  54. package/package.json +1 -1
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Content Variator — Auto-vary messages to avoid spam detection
3
+ *
4
+ * WhatsApp flags identical messages sent to multiple recipients.
5
+ * This module adds invisible variations so each message is technically unique.
6
+ */
7
+ const DEFAULT_CONFIG = {
8
+ zeroWidthChars: true,
9
+ punctuationVariation: true,
10
+ emojiPadding: false,
11
+ synonyms: false,
12
+ };
13
+ // Zero-width characters invisible to users
14
+ const ZERO_WIDTH = [
15
+ '\u200B', // zero-width space
16
+ '\u200C', // zero-width non-joiner
17
+ '\u200D', // zero-width joiner
18
+ '\uFEFF', // zero-width no-break space
19
+ ];
20
+ const SYNONYMS = {
21
+ 'hello': ['hi', 'hey', 'howdy'],
22
+ 'hi': ['hello', 'hey', 'howdy'],
23
+ 'thanks': ['thank you', 'thx', 'cheers'],
24
+ 'please': ['kindly', 'pls'],
25
+ 'great': ['awesome', 'excellent', 'wonderful'],
26
+ 'good': ['great', 'nice', 'fine'],
27
+ 'buy': ['purchase', 'get', 'grab'],
28
+ 'sell': ['offer', 'list'],
29
+ 'price': ['cost', 'amount', 'value'],
30
+ 'available': ['in stock', 'on offer'],
31
+ 'check': ['look at', 'see', 'view'],
32
+ 'join': ['participate', 'enter', 'come to'],
33
+ 'start': ['begin', 'kick off', 'commence'],
34
+ 'end': ['finish', 'close', 'conclude'],
35
+ 'bid': ['offer', 'place a bid'],
36
+ 'win': ['secure', 'take home'],
37
+ 'item': ['lot', 'piece', 'product'],
38
+ };
39
+ export class ContentVariator {
40
+ config;
41
+ counter = 0;
42
+ constructor(config = {}) {
43
+ this.config = { ...DEFAULT_CONFIG, ...config };
44
+ }
45
+ /**
46
+ * Create a unique variation of a message
47
+ * Each call produces a slightly different version
48
+ */
49
+ vary(text) {
50
+ let result = text;
51
+ this.counter++;
52
+ if (this.config.customVariator) {
53
+ return this.config.customVariator(result, this.counter);
54
+ }
55
+ if (this.config.synonyms) {
56
+ result = this.applySynonyms(result);
57
+ }
58
+ if (this.config.zeroWidthChars) {
59
+ result = this.addZeroWidth(result);
60
+ }
61
+ if (this.config.punctuationVariation) {
62
+ result = this.varyPunctuation(result);
63
+ }
64
+ if (this.config.emojiPadding) {
65
+ result = this.addEmojiPadding(result);
66
+ }
67
+ return result;
68
+ }
69
+ /**
70
+ * Create N unique variations of a message
71
+ */
72
+ varyBulk(text, count) {
73
+ const results = [];
74
+ const seen = new Set();
75
+ for (let i = 0; i < count; i++) {
76
+ let variation = this.vary(text);
77
+ // Ensure uniqueness
78
+ let attempts = 0;
79
+ while (seen.has(variation) && attempts < 10) {
80
+ variation = this.vary(text);
81
+ attempts++;
82
+ }
83
+ seen.add(variation);
84
+ results.push(variation);
85
+ }
86
+ return results;
87
+ }
88
+ addZeroWidth(text) {
89
+ const words = text.split(' ');
90
+ if (words.length < 2)
91
+ return text;
92
+ // Insert 1-2 zero-width chars at random positions between words
93
+ const positions = this.randomPositions(words.length - 1, Math.min(2, words.length - 1));
94
+ return words.map((word, i) => {
95
+ if (positions.includes(i)) {
96
+ const zwc = ZERO_WIDTH[Math.floor(Math.random() * ZERO_WIDTH.length)];
97
+ return word + zwc;
98
+ }
99
+ return word;
100
+ }).join(' ');
101
+ }
102
+ varyPunctuation(text) {
103
+ const variations = [
104
+ // Trailing space variations
105
+ () => text + ' ',
106
+ () => text + ' ',
107
+ // Period variations
108
+ () => text.endsWith('.') ? text.slice(0, -1) : text + '.',
109
+ // Nothing
110
+ () => text,
111
+ // Capitalize first letter variation
112
+ () => text.charAt(0) === text.charAt(0).toUpperCase()
113
+ ? text.charAt(0).toLowerCase() + text.slice(1)
114
+ : text,
115
+ ];
116
+ return variations[this.counter % variations.length]();
117
+ }
118
+ addEmojiPadding(text) {
119
+ const emojis = ['', ' 👍', ' ✅', ' 📌', ' 💬', ' 📢'];
120
+ return text + emojis[this.counter % emojis.length];
121
+ }
122
+ applySynonyms(text) {
123
+ const words = text.split(/\b/);
124
+ let replaced = false;
125
+ return words.map(word => {
126
+ if (replaced)
127
+ return word;
128
+ const lower = word.toLowerCase();
129
+ const synonymList = SYNONYMS[lower];
130
+ if (synonymList && Math.random() > 0.5) {
131
+ replaced = true; // Only replace one word per message
132
+ const synonym = synonymList[Math.floor(Math.random() * synonymList.length)];
133
+ // Preserve original casing
134
+ return word[0] === word[0].toUpperCase()
135
+ ? synonym.charAt(0).toUpperCase() + synonym.slice(1)
136
+ : synonym;
137
+ }
138
+ return word;
139
+ }).join('');
140
+ }
141
+ randomPositions(max, count) {
142
+ const positions = [];
143
+ while (positions.length < count) {
144
+ const pos = Math.floor(Math.random() * max);
145
+ if (!positions.includes(pos))
146
+ positions.push(pos);
147
+ }
148
+ return positions;
149
+ }
150
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Atomic Credentials Snapshot
3
+ *
4
+ * Pre-reconnect backup to kill code-500 corruption loop.
5
+ * Take snapshots before risky operations, restore on corruption.
6
+ *
7
+ * @author Kobus Wentzel <kobie@pop.co.za>
8
+ * @license MIT
9
+ */
10
+ import { promises as fs } from 'fs';
11
+ import * as path from 'path';
12
+ const noop = () => { };
13
+ export function credsSnapshot(config) {
14
+ const { credsPath, snapshotDir = path.join(path.dirname(credsPath), '.snapshots'), keep = 3, logger = {}, } = config;
15
+ const log = {
16
+ info: logger.info || noop,
17
+ warn: logger.warn || noop,
18
+ error: logger.error || noop,
19
+ };
20
+ async function take() {
21
+ try {
22
+ // Check if creds file exists
23
+ try {
24
+ await fs.access(credsPath);
25
+ }
26
+ catch {
27
+ log.warn(`[credsSnapshot] Creds file not found: ${credsPath}`);
28
+ return null;
29
+ }
30
+ // Ensure snapshot dir exists
31
+ await fs.mkdir(snapshotDir, { recursive: true });
32
+ // Generate snapshot path
33
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
34
+ const snapshotPath = path.join(snapshotDir, `creds-${timestamp}.json`);
35
+ const tmpPath = `${snapshotPath}.tmp`;
36
+ // Atomic copy: write to .tmp, then rename
37
+ await fs.copyFile(credsPath, tmpPath);
38
+ await fs.rename(tmpPath, snapshotPath);
39
+ log.info(`[credsSnapshot] Snapshot taken: ${snapshotPath}`);
40
+ // Rotate old snapshots
41
+ await rotate();
42
+ return snapshotPath;
43
+ }
44
+ catch (err) {
45
+ log.error(`[credsSnapshot] Failed to take snapshot: ${err}`);
46
+ return null;
47
+ }
48
+ }
49
+ async function rotate() {
50
+ try {
51
+ const snapshots = await list();
52
+ const toDelete = snapshots.slice(keep);
53
+ for (const snap of toDelete) {
54
+ await fs.unlink(snap.path);
55
+ log.info(`[credsSnapshot] Rotated out: ${snap.path}`);
56
+ }
57
+ }
58
+ catch (err) {
59
+ log.error(`[credsSnapshot] Rotation failed: ${err}`);
60
+ }
61
+ }
62
+ async function list() {
63
+ try {
64
+ await fs.access(snapshotDir);
65
+ }
66
+ catch {
67
+ return [];
68
+ }
69
+ try {
70
+ const files = await fs.readdir(snapshotDir);
71
+ const snapshots = await Promise.all(files
72
+ .filter((f) => f.startsWith('creds-') && f.endsWith('.json'))
73
+ .map(async (f) => {
74
+ const fullPath = path.join(snapshotDir, f);
75
+ const stat = await fs.stat(fullPath);
76
+ // Use file mtime for timestamp (simpler than parsing filename)
77
+ return {
78
+ path: fullPath,
79
+ takenAt: stat.mtime,
80
+ size: stat.size,
81
+ };
82
+ }));
83
+ // Sort newest first
84
+ return snapshots.sort((a, b) => b.takenAt.getTime() - a.takenAt.getTime());
85
+ }
86
+ catch (err) {
87
+ log.error(`[credsSnapshot] Failed to list snapshots: ${err}`);
88
+ return [];
89
+ }
90
+ }
91
+ async function restoreLatest() {
92
+ const snapshots = await list();
93
+ if (snapshots.length === 0) {
94
+ log.warn('[credsSnapshot] No snapshots available to restore');
95
+ return false;
96
+ }
97
+ return restore(snapshots[0].path);
98
+ }
99
+ async function restore(snapshotPath) {
100
+ try {
101
+ // Verify snapshot exists
102
+ await fs.access(snapshotPath);
103
+ // Atomic restore: copy to .tmp, then rename
104
+ const tmpPath = `${credsPath}.tmp`;
105
+ await fs.copyFile(snapshotPath, tmpPath);
106
+ await fs.rename(tmpPath, credsPath);
107
+ log.info(`[credsSnapshot] Restored from: ${snapshotPath}`);
108
+ return true;
109
+ }
110
+ catch (err) {
111
+ log.error(`[credsSnapshot] Failed to restore from ${snapshotPath}: ${err}`);
112
+ return false;
113
+ }
114
+ }
115
+ return {
116
+ take,
117
+ restoreLatest,
118
+ restore,
119
+ list,
120
+ };
121
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * DeliveryTracker — tracks actual delivery rate vs sent
3
+ *
4
+ * WhatsApp delivery receipts arrive via messages.update events as
5
+ * update.update.status = 3 (DELIVERY_ACK) or 4 (READ).
6
+ *
7
+ * Low delivery rate (< 60%) = strong soft-ban signal.
8
+ * Exposes deliveryRate for health monitoring and stats.
9
+ */
10
+ const DEFAULT_CONFIG = {
11
+ windowMs: 3600000, // 1 hour
12
+ minSampleSize: 10,
13
+ onLowDeliveryRate: () => { },
14
+ lowRateThreshold: 0.6,
15
+ };
16
+ export class DeliveryTracker {
17
+ config;
18
+ messages = new Map();
19
+ lastLowRateAlert = 0;
20
+ constructor(config = {}) {
21
+ this.config = { ...DEFAULT_CONFIG, ...config };
22
+ }
23
+ /**
24
+ * Register a sent message.
25
+ */
26
+ onMessageSent(msgId) {
27
+ this.messages.set(msgId, {
28
+ sentAt: Date.now(),
29
+ delivered: false,
30
+ });
31
+ this.pruneOldMessages();
32
+ }
33
+ /**
34
+ * Mark a message as delivered (status 3 or 4).
35
+ */
36
+ onDeliveryReceipt(msgId) {
37
+ const record = this.messages.get(msgId);
38
+ if (record) {
39
+ record.delivered = true;
40
+ }
41
+ this.pruneOldMessages();
42
+ this.checkDeliveryRate();
43
+ }
44
+ /**
45
+ * Get current delivery statistics.
46
+ */
47
+ getStats() {
48
+ this.pruneOldMessages();
49
+ const now = Date.now();
50
+ const cutoff = now - this.config.windowMs;
51
+ let sentInWindow = 0;
52
+ let deliveredInWindow = 0;
53
+ for (const record of this.messages.values()) {
54
+ if (record.sentAt >= cutoff) {
55
+ sentInWindow++;
56
+ if (record.delivered) {
57
+ deliveredInWindow++;
58
+ }
59
+ }
60
+ }
61
+ const deliveryRate = sentInWindow >= this.config.minSampleSize
62
+ ? deliveredInWindow / sentInWindow
63
+ : null;
64
+ return {
65
+ sentInWindow,
66
+ deliveredInWindow,
67
+ deliveryRate,
68
+ windowMs: this.config.windowMs,
69
+ };
70
+ }
71
+ /**
72
+ * Prune messages older than the window.
73
+ */
74
+ pruneOldMessages() {
75
+ const now = Date.now();
76
+ const cutoff = now - this.config.windowMs;
77
+ for (const [msgId, record] of this.messages.entries()) {
78
+ if (record.sentAt < cutoff) {
79
+ this.messages.delete(msgId);
80
+ }
81
+ }
82
+ }
83
+ /**
84
+ * Check delivery rate and trigger callback if below threshold.
85
+ */
86
+ checkDeliveryRate() {
87
+ const stats = this.getStats();
88
+ // Only alert if we have enough samples
89
+ if (stats.deliveryRate === null)
90
+ return;
91
+ // Only alert once per hour to avoid spam
92
+ const now = Date.now();
93
+ if (now - this.lastLowRateAlert < 3600000)
94
+ return;
95
+ if (stats.deliveryRate < this.config.lowRateThreshold) {
96
+ this.lastLowRateAlert = now;
97
+ this.config.onLowDeliveryRate(stats.deliveryRate);
98
+ }
99
+ }
100
+ /**
101
+ * Reset all tracked messages.
102
+ */
103
+ reset() {
104
+ this.messages.clear();
105
+ this.lastLowRateAlert = 0;
106
+ }
107
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Device Fingerprint Randomization
3
+ *
4
+ * Randomizes appVersion, osVersion, and deviceModel to prevent Meta's
5
+ * clientPayload fingerprinting. Addresses the #1 gap in anti-ban coverage.
6
+ *
7
+ * @author Kobus Wentzel <kobie@pop.co.za>
8
+ * @license MIT
9
+ */
10
+ // Default pools - real-world values observed in the wild
11
+ const DEFAULT_APP_VERSION_POOL = [
12
+ [2, 24, 5, 18],
13
+ [2, 24, 5, 17],
14
+ [2, 24, 4, 77],
15
+ [2, 24, 5, 15],
16
+ [2, 24, 3, 91],
17
+ [2, 24, 5, 20],
18
+ ];
19
+ const DEFAULT_OS_VERSION_POOL = ['10', '11', '12', '13', '14'];
20
+ const DEFAULT_DEVICE_MODEL_POOL = [
21
+ 'Pixel 6',
22
+ 'Pixel 7',
23
+ 'Galaxy S22',
24
+ 'Galaxy S23',
25
+ 'Xiaomi 13',
26
+ 'Xiaomi 12',
27
+ 'OnePlus 11',
28
+ 'Moto G84',
29
+ 'Moto G54',
30
+ 'Realme 11',
31
+ 'Vivo V29',
32
+ 'Oppo Find X6',
33
+ ];
34
+ /**
35
+ * Simple deterministic PRNG using mulberry32
36
+ * Seeded from string hash for consistent results per session
37
+ */
38
+ class SeededRandom {
39
+ state;
40
+ constructor(seed) {
41
+ // Hash string to 32-bit seed
42
+ let hash = 0;
43
+ for (let i = 0; i < seed.length; i++) {
44
+ hash = (hash << 5) - hash + seed.charCodeAt(i);
45
+ hash = hash & hash; // Convert to 32-bit int
46
+ }
47
+ this.state = Math.abs(hash) || 1;
48
+ }
49
+ next() {
50
+ let t = (this.state += 0x6d2b79f5);
51
+ t = Math.imul(t ^ (t >>> 15), t | 1);
52
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
53
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
54
+ }
55
+ pick(array) {
56
+ return array[Math.floor(this.next() * array.length)];
57
+ }
58
+ }
59
+ /**
60
+ * Generate a randomized fingerprint for one session.
61
+ * Stable for the same sessionId — call once per socket init.
62
+ */
63
+ export function generateFingerprint(config = {}, sessionId) {
64
+ const { enabled = true, randomizeAppVersion = true, randomizeOsVersion = true, randomizeDeviceModel = true, seed, appVersionPool = DEFAULT_APP_VERSION_POOL, osVersionPool = DEFAULT_OS_VERSION_POOL, deviceModelPool = DEFAULT_DEVICE_MODEL_POOL, } = config;
65
+ const finalSessionId = sessionId || `session-${Date.now()}-${Math.random()}`;
66
+ const rng = new SeededRandom(seed || finalSessionId);
67
+ // Pick random values if enabled, otherwise use first pool item
68
+ const appVersion = enabled && randomizeAppVersion
69
+ ? rng.pick(appVersionPool)
70
+ : appVersionPool[0];
71
+ const osVersion = enabled && randomizeOsVersion ? rng.pick(osVersionPool) : osVersionPool[0];
72
+ const deviceModel = enabled && randomizeDeviceModel
73
+ ? rng.pick(deviceModelPool)
74
+ : deviceModelPool[0];
75
+ return {
76
+ appVersion: [...appVersion], // Copy to avoid mutation
77
+ osVersion,
78
+ deviceModel,
79
+ sessionId: finalSessionId,
80
+ };
81
+ }
82
+ /**
83
+ * Apply fingerprint to a Baileys SocketConfig before makeWASocket().
84
+ *
85
+ * Example:
86
+ * const fp = generateFingerprint({});
87
+ * const sock = makeWASocket(applyFingerprint(socketConfig, fp));
88
+ */
89
+ export function applyFingerprint(socketConfig, fp) {
90
+ // Create defensive copy
91
+ const config = { ...socketConfig };
92
+ // Apply version if field exists
93
+ if (config.version !== undefined || 'version' in config || true) {
94
+ config.version = fp.appVersion;
95
+ }
96
+ // Apply browser tuple if field exists
97
+ // Baileys browser format: [deviceName, osVersion, appVersion]
98
+ if (config.browser !== undefined || 'browser' in config || true) {
99
+ config.browser = [
100
+ fp.deviceModel,
101
+ fp.osVersion,
102
+ `WhatsApp/${fp.appVersion.join('.')}`,
103
+ ];
104
+ }
105
+ return config;
106
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Fleet Event Store — Multi-instance coordination via shared event log
3
+ *
4
+ * Enables multiple WhatsApp instances to share ban/warn/recovery signals
5
+ * via a pluggable backend (MySQL, in-memory, etc).
6
+ *
7
+ * Architecture:
8
+ * - EventStoreBackend interface — caller provides storage
9
+ * - Two built-in backends:
10
+ * 1. MySQLEventStoreBackend — persistent, multi-instance (peer dep)
11
+ * 2. InMemoryEventStoreBackend — ephemeral, single-instance, testing
12
+ *
13
+ * Usage with MySQL:
14
+ * import mysql from 'mysql2/promise';
15
+ * const pool = mysql.createPool({ ... });
16
+ * const backend = createMySQLEventStoreBackend(pool);
17
+ * const store = createFleetEventStore({
18
+ * connectionId: 'wa-instance-1',
19
+ * backend,
20
+ * pollIntervalMs: 10_000
21
+ * });
22
+ * store.emit('warn', { risk: 'medium' });
23
+ * store.startPolling((events) => console.log('New events:', events));
24
+ *
25
+ * Usage in-memory (testing):
26
+ * const backend = createInMemoryEventStoreBackend();
27
+ * const store = createFleetEventStore({ connectionId: 'test', backend });
28
+ */
29
+ export function createFleetEventStore(config) {
30
+ const { connectionId, backend, logger } = config;
31
+ const pollIntervalMs = config.pollIntervalMs ?? 10_000;
32
+ let lastSeenEpoch = Date.now();
33
+ let pollTimer = null;
34
+ const emit = async (eventType, payload) => {
35
+ const epoch = Date.now();
36
+ await backend.emit(connectionId, eventType, epoch, payload);
37
+ logger?.info('[fleet-events] emitted', { connectionId, eventType, epoch });
38
+ };
39
+ const startPolling = (onNewEvents) => {
40
+ if (pollTimer)
41
+ return;
42
+ pollTimer = setInterval(() => {
43
+ void (async () => {
44
+ try {
45
+ const events = await backend.poll(connectionId, lastSeenEpoch);
46
+ if (events.length > 0) {
47
+ // Update cursor
48
+ const maxEpoch = Math.max(...events.map((e) => e.epoch));
49
+ lastSeenEpoch = maxEpoch;
50
+ onNewEvents(events);
51
+ }
52
+ }
53
+ catch (error) {
54
+ logger?.warn('[fleet-events] poll failed', { connectionId, err: error });
55
+ }
56
+ })();
57
+ }, pollIntervalMs);
58
+ // @ts-ignore — unref exists on NodeJS.Timeout
59
+ pollTimer.unref?.();
60
+ logger?.info('[fleet-events] polling started', { connectionId, pollIntervalMs });
61
+ };
62
+ const stop = () => {
63
+ if (pollTimer) {
64
+ clearInterval(pollTimer);
65
+ pollTimer = null;
66
+ logger?.info('[fleet-events] polling stopped', { connectionId });
67
+ }
68
+ };
69
+ return { emit, startPolling, stop };
70
+ }
71
+ export function createMySQLEventStoreBackend(pool) {
72
+ // Ensure table exists on first emit (idempotent)
73
+ let tableEnsured = false;
74
+ const ensureTable = async () => {
75
+ if (tableEnsured)
76
+ return;
77
+ const createTableSQL = `
78
+ CREATE TABLE IF NOT EXISTS antiban_events (
79
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
80
+ connection_id VARCHAR(255) NOT NULL,
81
+ event_type VARCHAR(50) NOT NULL,
82
+ epoch BIGINT NOT NULL,
83
+ payload JSON,
84
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
85
+ INDEX idx_conn (connection_id),
86
+ INDEX idx_epoch (epoch)
87
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
88
+ `;
89
+ try {
90
+ await pool.query(createTableSQL);
91
+ tableEnsured = true;
92
+ }
93
+ catch (error) {
94
+ // Log but don't throw — table might already exist
95
+ console.warn('[mysql-backend] table creation failed (may already exist)', error);
96
+ tableEnsured = true; // Assume it exists
97
+ }
98
+ };
99
+ const emit = async (connectionId, eventType, epoch, payload) => {
100
+ try {
101
+ await ensureTable();
102
+ await pool.execute('INSERT INTO antiban_events (connection_id, event_type, epoch, payload) VALUES (?, ?, ?, ?)', [connectionId, eventType, epoch, payload ? JSON.stringify(payload) : null]);
103
+ }
104
+ catch (error) {
105
+ // Never throw — event emission is non-critical
106
+ console.warn('[mysql-backend] emit failed', { connectionId, eventType, error });
107
+ }
108
+ };
109
+ const poll = async (connectionId, sinceEpoch) => {
110
+ try {
111
+ await ensureTable();
112
+ const [rows] = (await pool.execute('SELECT id, connection_id, event_type, epoch, payload, created_at FROM antiban_events WHERE connection_id = ? AND epoch > ? ORDER BY epoch ASC LIMIT 50', [connectionId, sinceEpoch]));
113
+ return rows.map((row) => ({
114
+ id: row.id,
115
+ connectionId: row.connection_id,
116
+ eventType: row.event_type,
117
+ epoch: row.epoch,
118
+ payload: row.payload ? JSON.parse(row.payload) : null,
119
+ createdAt: row.created_at,
120
+ }));
121
+ }
122
+ catch (error) {
123
+ console.warn('[mysql-backend] poll failed', { connectionId, error });
124
+ return [];
125
+ }
126
+ };
127
+ return { emit, poll };
128
+ }
129
+ // ===============================
130
+ // Built-in Backend: In-Memory
131
+ // ===============================
132
+ export function createInMemoryEventStoreBackend() {
133
+ const events = [];
134
+ let nextId = 1;
135
+ const emit = async (connectionId, eventType, epoch, payload) => {
136
+ const event = {
137
+ id: nextId++,
138
+ connectionId,
139
+ eventType,
140
+ epoch,
141
+ payload: payload || null,
142
+ createdAt: new Date(),
143
+ };
144
+ events.push(event);
145
+ // Evict oldest if over 1000 entries
146
+ if (events.length > 1000) {
147
+ events.shift();
148
+ }
149
+ };
150
+ const poll = async (connectionId, sinceEpoch) => {
151
+ return events
152
+ .filter((e) => e.connectionId === connectionId && e.epoch > sinceEpoch)
153
+ .sort((a, b) => a.epoch - b.epoch)
154
+ .slice(0, 50);
155
+ };
156
+ return { emit, poll };
157
+ }