@prototypevip/baileys 0.0.5 → 0.0.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/lib/AntiBan/antiban.js +922 -0
- package/lib/AntiBan/banRecoveryOrchestrator.js +284 -0
- package/lib/AntiBan/contactGraph.js +236 -0
- package/lib/AntiBan/contentVariator.js +150 -0
- package/lib/AntiBan/credsSnapshot.js +121 -0
- package/lib/AntiBan/deliveryTracker.js +107 -0
- package/lib/AntiBan/deviceFingerprint.js +106 -0
- package/lib/AntiBan/fleetEventStore.js +157 -0
- package/lib/AntiBan/groupOperationGuard.js +116 -0
- package/lib/AntiBan/health.js +207 -0
- package/lib/AntiBan/humanEntropy.js +337 -0
- package/lib/AntiBan/index.js +77 -0
- package/lib/AntiBan/instanceCoordinator.js +175 -0
- package/lib/AntiBan/jidCanonicalizer.js +256 -0
- package/lib/AntiBan/jidCircuitBreaker.js +186 -0
- package/lib/AntiBan/legitimacySignalInjector.js +231 -0
- package/lib/AntiBan/lidFirstResolver.js +174 -0
- package/lib/AntiBan/lidResolver.js +324 -0
- package/lib/AntiBan/messageQueue.js +187 -0
- package/lib/AntiBan/messageRecovery.js +283 -0
- package/lib/AntiBan/messageTypeRegistry.js +369 -0
- package/lib/AntiBan/observability.js +145 -0
- package/lib/AntiBan/patch.js +302 -0
- package/lib/AntiBan/persist.js +87 -0
- package/lib/AntiBan/presenceChoreographer.js +430 -0
- package/lib/AntiBan/presets.js +105 -0
- package/lib/AntiBan/profiles.js +31 -0
- package/lib/AntiBan/proxyRotator.js +306 -0
- package/lib/AntiBan/rateLimiter.js +261 -0
- package/lib/AntiBan/readReceiptVariance.js +88 -0
- package/lib/AntiBan/reconnectThrottle.js +180 -0
- package/lib/AntiBan/replyRatio.js +161 -0
- package/lib/AntiBan/reputationVoucher.js +373 -0
- package/lib/AntiBan/retryReason.js +91 -0
- package/lib/AntiBan/retryTracker.js +172 -0
- package/lib/AntiBan/scheduler.js +111 -0
- package/lib/AntiBan/sessionFingerprint.js +248 -0
- package/lib/AntiBan/sessionStability.js +330 -0
- package/lib/AntiBan/stateAdapter.js +73 -0
- package/lib/AntiBan/stateExport.js +152 -0
- package/lib/AntiBan/stealthConnect.js +130 -0
- package/lib/AntiBan/timelockGuard.js +181 -0
- package/lib/AntiBan/topologyThrottler.js +339 -0
- package/lib/AntiBan/warmup.js +140 -0
- package/lib/AntiBan/webhooks.js +80 -0
- package/lib/AntiBan/wrapper.js +456 -0
- package/lib/Socket/chats.js +29 -4
- package/lib/Socket/groups.js +7 -2
- package/lib/Socket/index.js +68 -3
- package/lib/Socket/messages-send.js +44 -8
- package/lib/Socket/socket.js +2 -2
- package/lib/Store/prototype-store.js +10 -39
- package/lib/index.js +5 -0
- package/package.json +7 -3
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GroupOperationGuard — Rate limiting for WhatsApp group operations
|
|
3
|
+
*
|
|
4
|
+
* Prevents account_reachout_restricted and rate-overlimit errors
|
|
5
|
+
* by enforcing per-operation windows on group adds, removes, and creates.
|
|
6
|
+
*
|
|
7
|
+
* WA unofficial limits (observed):
|
|
8
|
+
* - groupParticipantsUpdate (add): ~3 new contacts per 10 min
|
|
9
|
+
* - groupCreate: ~2 per 10 min
|
|
10
|
+
* - Rapid retries after 403: triggers account_reachout_restricted
|
|
11
|
+
*/
|
|
12
|
+
const DEFAULT_LIMITS = {
|
|
13
|
+
add: { max: 3, windowMs: 10 * 60 * 1000 }, // 3 adds / 10 min
|
|
14
|
+
remove: { max: 5, windowMs: 10 * 60 * 1000 }, // 5 removes / 10 min
|
|
15
|
+
create: { max: 2, windowMs: 10 * 60 * 1000 }, // 2 creates / 10 min
|
|
16
|
+
invite: { max: 10, windowMs: 10 * 60 * 1000 }, // 10 invite fetches / 10 min
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Known WA error patterns for group operations.
|
|
20
|
+
* Use with classifyGroupOpError() to classify errors before retrying.
|
|
21
|
+
*/
|
|
22
|
+
export const GROUP_OP_ERRORS = {
|
|
23
|
+
REACHOUT_RESTRICTED: 'account_reachout_restricted',
|
|
24
|
+
RATE_OVERLIMIT: 'rate-overlimit',
|
|
25
|
+
PRIVACY_BLOCK: '403', // groupParticipantsUpdate returned status '403'
|
|
26
|
+
INVITE_EXPIRED: 'gone', // groupInviteCode returned 'gone' — code expired
|
|
27
|
+
GROUP_LOCKED: 'locked', // groupRevokeInvite returned 'locked'
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Classify a caught error from a group operation.
|
|
31
|
+
*/
|
|
32
|
+
export function classifyGroupOpError(err) {
|
|
33
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
34
|
+
if (msg.includes('account_reachout_restricted') || msg.includes('reachout'))
|
|
35
|
+
return GROUP_OP_ERRORS.REACHOUT_RESTRICTED;
|
|
36
|
+
if (msg.includes('rate-overlimit') || msg.includes('429'))
|
|
37
|
+
return GROUP_OP_ERRORS.RATE_OVERLIMIT;
|
|
38
|
+
if (msg.includes('locked'))
|
|
39
|
+
return GROUP_OP_ERRORS.GROUP_LOCKED;
|
|
40
|
+
if (msg.includes('gone'))
|
|
41
|
+
return GROUP_OP_ERRORS.INVITE_EXPIRED;
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
export function extractPrivacyBlock(result) {
|
|
45
|
+
if (!Array.isArray(result))
|
|
46
|
+
return { blocked: false };
|
|
47
|
+
for (const r of result) {
|
|
48
|
+
const entry = r;
|
|
49
|
+
if (entry.status !== '403')
|
|
50
|
+
continue;
|
|
51
|
+
// Traverse content to find add_request with code
|
|
52
|
+
const content = Array.isArray(entry.content) ? entry.content : [];
|
|
53
|
+
for (const c of content) {
|
|
54
|
+
if (c.tag === 'add_request' && typeof c.attrs === 'object') {
|
|
55
|
+
const attrs = c.attrs;
|
|
56
|
+
if (attrs.code) {
|
|
57
|
+
return {
|
|
58
|
+
blocked: true,
|
|
59
|
+
inviteCode: attrs.code,
|
|
60
|
+
inviteLink: `https://chat.whatsapp.com/${attrs.code}`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// 403 without invite code — privacy block but WA didn't provide link
|
|
66
|
+
return { blocked: true };
|
|
67
|
+
}
|
|
68
|
+
return { blocked: false };
|
|
69
|
+
}
|
|
70
|
+
export class GroupOperationGuard {
|
|
71
|
+
limits;
|
|
72
|
+
windows = new Map();
|
|
73
|
+
constructor(config = {}) {
|
|
74
|
+
this.limits = { ...DEFAULT_LIMITS, ...config.limits };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Check whether an operation is allowed under the current rate limits.
|
|
78
|
+
* @param op Operation type
|
|
79
|
+
* @param key Unique key scoping the limit (e.g. groupJid, clientId, or composite)
|
|
80
|
+
*/
|
|
81
|
+
check(op, key) {
|
|
82
|
+
const limit = this.limits[op];
|
|
83
|
+
const windowKey = `${op}:${key}`;
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
const entry = this.windows.get(windowKey);
|
|
86
|
+
if (!entry || now > entry.resetAt) {
|
|
87
|
+
this.windows.set(windowKey, { count: 1, resetAt: now + limit.windowMs });
|
|
88
|
+
return { allowed: true };
|
|
89
|
+
}
|
|
90
|
+
if (entry.count >= limit.max) {
|
|
91
|
+
const retryAfterSec = Math.ceil((entry.resetAt - now) / 1000);
|
|
92
|
+
return {
|
|
93
|
+
allowed: false,
|
|
94
|
+
reason: `Too many ${op} attempts. WhatsApp rate-limits group operations — wait ${Math.ceil(retryAfterSec / 60)} min before trying again.`,
|
|
95
|
+
retryAfterSec,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
entry.count++;
|
|
99
|
+
return { allowed: true };
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Reset the counter for a specific operation + key.
|
|
103
|
+
* Call this if you want to allow immediate retry after a successful operation.
|
|
104
|
+
*/
|
|
105
|
+
reset(op, key) {
|
|
106
|
+
this.windows.delete(`${op}:${key}`);
|
|
107
|
+
}
|
|
108
|
+
/** Snapshot of all active windows (for observability). */
|
|
109
|
+
getStats() {
|
|
110
|
+
const stats = {};
|
|
111
|
+
for (const [key, val] of this.windows) {
|
|
112
|
+
stats[key] = { ...val };
|
|
113
|
+
}
|
|
114
|
+
return stats;
|
|
115
|
+
}
|
|
116
|
+
}
|