baileys-antiban 4.6.0 → 4.7.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.
- package/README.md +163 -2
- package/dist/cjs/fleetEventStore.js +162 -0
- package/dist/cjs/humanEntropy.js +342 -0
- package/dist/cjs/index.js +12 -1
- package/dist/cjs/jidCircuitBreaker.js +157 -0
- package/dist/cjs/wrapper.js +73 -1
- package/dist/fleetEventStore.d.ts +65 -0
- package/dist/fleetEventStore.js +157 -0
- package/dist/humanEntropy.d.ts +133 -0
- package/dist/humanEntropy.js +337 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -0
- package/dist/jidCircuitBreaker.d.ts +48 -0
- package/dist/jidCircuitBreaker.js +152 -0
- package/dist/wrapper.d.ts +12 -0
- package/dist/wrapper.js +73 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
**Drop-in anti-ban middleware for Baileys WhatsApp bots. Free, self-hosted, TypeScript-first. Whapi.Cloud alternative — zero monthly fees.**
|
|
10
10
|
|
|
11
|
-
> Rate limiting with Gaussian jitter, 7-day warmup, session health monitoring, LID resolver, disconnect classification, contact graph enforcement — all in one `npm install`. Works with [Baileys](https://github.com/WhiskeySockets/Baileys) and [@oxidezap/baileyrs](https://github.com/oxidezap/baileyrs) (Rust/WASM).
|
|
11
|
+
> Rate limiting with Gaussian jitter, 7-day warmup, session health monitoring, LID resolver, disconnect classification, contact graph enforcement, device fingerprinting, group operation guards, recovery orchestration, cross-instance coordination — all in one `npm install`. Works with [Baileys](https://github.com/WhiskeySockets/Baileys) and [@oxidezap/baileyrs](https://github.com/oxidezap/baileyrs) (Rust/WASM).
|
|
12
12
|
|
|
13
|
-
> **New in
|
|
13
|
+
> **New in v4.7:** HumanEntropyService — background human-like activity (typing indicators, delayed read receipts, presence cycles) to prevent "too perfect bot" detection. Works with WaSP and any session manager, no socket access needed.
|
|
14
14
|
|
|
15
15
|
## Why Trust This Package
|
|
16
16
|
|
|
@@ -27,6 +27,167 @@ The npm WhatsApp ecosystem has a malware problem. In April 2026, [`lotusbail`](h
|
|
|
27
27
|
|
|
28
28
|
If you can't read the code yourself, lean on these signals: signed releases, public audit trail, no telemetry, and a real product behind it. That's the floor. Everything below is the feature set.
|
|
29
29
|
|
|
30
|
+
## v4.x New Features — Production-Grade Ban Prevention
|
|
31
|
+
|
|
32
|
+
v4.0–v4.7 ship seven major anti-ban modules. All are **auto-wired** by default via `wrapSocket()` or `wrapSocketWithFingerprint()`.
|
|
33
|
+
|
|
34
|
+
### v4.0 — GroupOperationGuard
|
|
35
|
+
|
|
36
|
+
Rate-limits group operations to prevent `account_reachout_restricted` errors. WA limits: ~3 adds/10min, 2 creates/10min.
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
import { wrapSocket } from 'baileys-antiban';
|
|
40
|
+
|
|
41
|
+
const sock = wrapSocket(makeWASocket({ ... }), {
|
|
42
|
+
groupOpGuard: { limits: { add: { max: 3, windowMs: 600_000 } } },
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Classify errors: `classifyGroupOpError(err)` returns `GROUP_OP_ERRORS.REACHOUT_RESTRICTED` | `RATE_OVERLIMIT` | `PRIVACY_BLOCK` | etc.
|
|
47
|
+
Disable: `groupOpGuard: false`
|
|
48
|
+
|
|
49
|
+
### v4.1 — LegitimacySignalInjector
|
|
50
|
+
|
|
51
|
+
Injects realistic imperfections: typos + corrections (2.5% of messages), read gaps, mid-typing pauses. WhatsApp's ML flags accounts that are "too perfect".
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
const sock = wrapSocket(makeWASocket({ ... }), {
|
|
55
|
+
legitimacySignals: { typoProbability: 0.03 },
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Disable: `legitimacySignals: false`
|
|
60
|
+
|
|
61
|
+
### v4.2 — BanRecoveryOrchestrator
|
|
62
|
+
|
|
63
|
+
Structured recovery after ban events. Auto-triggers via HealthMonitor at critical risk.
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
import { BanRecoveryOrchestrator } from 'baileys-antiban';
|
|
67
|
+
|
|
68
|
+
const recovery = new BanRecoveryOrchestrator({
|
|
69
|
+
onPhaseChange: (phase, plan) => console.log(`Phase: ${phase}`),
|
|
70
|
+
onHardBan: () => { /* replace SIM */ },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
recovery.triggerRecovery('timelock');
|
|
74
|
+
const status = recovery.getStatus();
|
|
75
|
+
console.log(status.rateMultiplier); // 0.1 = 10% speed
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Recovery plans:** timelock: 24h pause, 10% resume, 15%/week ramp | rate_overlimit: 4h, 25%, 25%/week | soft_ban: 48h, 5%, 10%/week | hard_ban: dead
|
|
79
|
+
Access via: `antiban.recoveryOrchestrator`
|
|
80
|
+
|
|
81
|
+
### v4.3 — wrapSocketWithFingerprint
|
|
82
|
+
|
|
83
|
+
One-call setup with device fingerprint randomization (appVersion, osVersion, deviceModel).
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
import { wrapSocketWithFingerprint } from 'baileys-antiban';
|
|
87
|
+
|
|
88
|
+
const sock = wrapSocketWithFingerprint(makeWASocket, { auth }, { preset: 'moderate' });
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Preset changes:** All presets default `groupProfiles: true`; `aggressive`/`high-volume` now `autoPauseAt: 'high'`
|
|
92
|
+
|
|
93
|
+
### v4.4 — Per-Contact Risk Delays + DeliveryTracker
|
|
94
|
+
|
|
95
|
+
Strangers get 2.5× delay, known contacts 1.0×. Active when `contactGraph.enabled: true`.
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
const sock = wrapSocket(makeWASocket({ ... }), { contactGraph: { enabled: true } });
|
|
99
|
+
// stranger: 2.5×, handshake_sent: 1.8×, handshake_complete: 1.3×, known: 1.0×
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**DeliveryTracker** tracks double-tick receipts. <60% delivery = soft-ban signal.
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
import { DeliveryTracker } from 'baileys-antiban';
|
|
106
|
+
|
|
107
|
+
const tracker = new DeliveryTracker({
|
|
108
|
+
lowRateThreshold: 0.6,
|
|
109
|
+
onLowDeliveryRate: (rate) => console.error(`Delivery: ${rate * 100}%`),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
sock.ev.on('messages.upsert', ({ messages }) => {
|
|
113
|
+
messages.forEach(m => m.key.fromMe && tracker.onMessageSent(m.key.id));
|
|
114
|
+
});
|
|
115
|
+
sock.ev.on('messages.update', (updates) => {
|
|
116
|
+
updates.forEach(({ key, update }) => {
|
|
117
|
+
if (update.status >= 3) tracker.onDeliveryReceipt(key.id);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### v4.5 — Adaptive Rate Limiting
|
|
123
|
+
|
|
124
|
+
Auto-adjusts rate based on delivery success: ≥85% → 100% speed, <55% → 25% speed. Auto-wired.
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
import { RateLimiter } from 'baileys-antiban';
|
|
128
|
+
|
|
129
|
+
const limiter = new RateLimiter({ maxPerMinute: 10 });
|
|
130
|
+
limiter.adaptLimits(0.5); // manual throttle to 50%
|
|
131
|
+
const factor = limiter.getCurrentFactor();
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### v4.6 — Cross-Instance Coordination
|
|
135
|
+
|
|
136
|
+
Shared token bucket across processes. Solves: 5 bots × 8/min = 40/min → flag.
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
const sock = wrapSocket(makeWASocket({ ... }), {
|
|
140
|
+
instanceCoordinator: '/tmp/wa-pool.json',
|
|
141
|
+
instancePoolMaxPerMinute: 20,
|
|
142
|
+
});
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
All instances share 20/min IP-level budget. Atomic writes via rename-swap.
|
|
146
|
+
|
|
147
|
+
### v4.7 — HumanEntropyService
|
|
148
|
+
|
|
149
|
+
Background noise generator that makes a WA session indistinguishable from a real human user during idle periods. Runs independently of your message flow — no socket access required, works with WaSP or any session manager.
|
|
150
|
+
|
|
151
|
+
**The problem it solves:** WhatsApp's ML flags accounts with "too perfect" patterns — instant read receipts, zero typing activity, always-on presence. A listen-only bot that never idles looks like a bot.
|
|
152
|
+
|
|
153
|
+
**What it does every 2-6 hours (randomized):**
|
|
154
|
+
- Sends typing indicator to a recent contact for 3-8 seconds, then stops (mimics "started typing, changed mind")
|
|
155
|
+
- Marks a received message as read with 10-60 min delay (mimics "opened notification, read later")
|
|
156
|
+
- Toggles own presence available → unavailable over 30-120s (mimics "checked phone, put it down")
|
|
157
|
+
|
|
158
|
+
**Safety:** Only contacts people who already messaged you first. Never cold-contacts strangers. All errors caught silently.
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
import { createHumanEntropyService } from 'baileys-antiban';
|
|
162
|
+
|
|
163
|
+
// Works with WaSP (no direct socket access needed)
|
|
164
|
+
const entropy = createHumanEntropyService(wasp, sessionId, {
|
|
165
|
+
enabled: true,
|
|
166
|
+
minIntervalMs: 2 * 60 * 60 * 1000, // 2 hours
|
|
167
|
+
maxIntervalMs: 6 * 60 * 60 * 1000, // 6 hours
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
entropy.start(); // runs in background
|
|
171
|
+
entropy.stop(); // call on shutdown
|
|
172
|
+
|
|
173
|
+
// Or with direct Baileys socket
|
|
174
|
+
import { HumanEntropyService } from 'baileys-antiban';
|
|
175
|
+
const svc = new HumanEntropyService(socket, { enabled: true });
|
|
176
|
+
svc.start();
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**Tracking recent contacts:** Feed incoming messages so the service knows who to interact with:
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
sock.ev.on('messages.upsert', ({ messages }) => {
|
|
183
|
+
messages.forEach(m => entropy.addRecentContact(m.key.remoteJid, m.key));
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Stats: `entropy.getStats()` returns `{ typingActions, readActions, presenceActions, cycles }`.
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
30
191
|
## v2.0 New Features — Session Stability Module
|
|
31
192
|
|
|
32
193
|
### What's New in v2.0
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Fleet Event Store — Multi-instance coordination via shared event log
|
|
4
|
+
*
|
|
5
|
+
* Enables multiple WhatsApp instances to share ban/warn/recovery signals
|
|
6
|
+
* via a pluggable backend (MySQL, in-memory, etc).
|
|
7
|
+
*
|
|
8
|
+
* Architecture:
|
|
9
|
+
* - EventStoreBackend interface — caller provides storage
|
|
10
|
+
* - Two built-in backends:
|
|
11
|
+
* 1. MySQLEventStoreBackend — persistent, multi-instance (peer dep)
|
|
12
|
+
* 2. InMemoryEventStoreBackend — ephemeral, single-instance, testing
|
|
13
|
+
*
|
|
14
|
+
* Usage with MySQL:
|
|
15
|
+
* import mysql from 'mysql2/promise';
|
|
16
|
+
* const pool = mysql.createPool({ ... });
|
|
17
|
+
* const backend = createMySQLEventStoreBackend(pool);
|
|
18
|
+
* const store = createFleetEventStore({
|
|
19
|
+
* connectionId: 'wa-instance-1',
|
|
20
|
+
* backend,
|
|
21
|
+
* pollIntervalMs: 10_000
|
|
22
|
+
* });
|
|
23
|
+
* store.emit('warn', { risk: 'medium' });
|
|
24
|
+
* store.startPolling((events) => console.log('New events:', events));
|
|
25
|
+
*
|
|
26
|
+
* Usage in-memory (testing):
|
|
27
|
+
* const backend = createInMemoryEventStoreBackend();
|
|
28
|
+
* const store = createFleetEventStore({ connectionId: 'test', backend });
|
|
29
|
+
*/
|
|
30
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
31
|
+
exports.createFleetEventStore = createFleetEventStore;
|
|
32
|
+
exports.createMySQLEventStoreBackend = createMySQLEventStoreBackend;
|
|
33
|
+
exports.createInMemoryEventStoreBackend = createInMemoryEventStoreBackend;
|
|
34
|
+
function createFleetEventStore(config) {
|
|
35
|
+
const { connectionId, backend, logger } = config;
|
|
36
|
+
const pollIntervalMs = config.pollIntervalMs ?? 10_000;
|
|
37
|
+
let lastSeenEpoch = Date.now();
|
|
38
|
+
let pollTimer = null;
|
|
39
|
+
const emit = async (eventType, payload) => {
|
|
40
|
+
const epoch = Date.now();
|
|
41
|
+
await backend.emit(connectionId, eventType, epoch, payload);
|
|
42
|
+
logger?.info('[fleet-events] emitted', { connectionId, eventType, epoch });
|
|
43
|
+
};
|
|
44
|
+
const startPolling = (onNewEvents) => {
|
|
45
|
+
if (pollTimer)
|
|
46
|
+
return;
|
|
47
|
+
pollTimer = setInterval(() => {
|
|
48
|
+
void (async () => {
|
|
49
|
+
try {
|
|
50
|
+
const events = await backend.poll(connectionId, lastSeenEpoch);
|
|
51
|
+
if (events.length > 0) {
|
|
52
|
+
// Update cursor
|
|
53
|
+
const maxEpoch = Math.max(...events.map((e) => e.epoch));
|
|
54
|
+
lastSeenEpoch = maxEpoch;
|
|
55
|
+
onNewEvents(events);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
logger?.warn('[fleet-events] poll failed', { connectionId, err: error });
|
|
60
|
+
}
|
|
61
|
+
})();
|
|
62
|
+
}, pollIntervalMs);
|
|
63
|
+
// @ts-ignore — unref exists on NodeJS.Timeout
|
|
64
|
+
pollTimer.unref?.();
|
|
65
|
+
logger?.info('[fleet-events] polling started', { connectionId, pollIntervalMs });
|
|
66
|
+
};
|
|
67
|
+
const stop = () => {
|
|
68
|
+
if (pollTimer) {
|
|
69
|
+
clearInterval(pollTimer);
|
|
70
|
+
pollTimer = null;
|
|
71
|
+
logger?.info('[fleet-events] polling stopped', { connectionId });
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
return { emit, startPolling, stop };
|
|
75
|
+
}
|
|
76
|
+
function createMySQLEventStoreBackend(pool) {
|
|
77
|
+
// Ensure table exists on first emit (idempotent)
|
|
78
|
+
let tableEnsured = false;
|
|
79
|
+
const ensureTable = async () => {
|
|
80
|
+
if (tableEnsured)
|
|
81
|
+
return;
|
|
82
|
+
const createTableSQL = `
|
|
83
|
+
CREATE TABLE IF NOT EXISTS antiban_events (
|
|
84
|
+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
85
|
+
connection_id VARCHAR(255) NOT NULL,
|
|
86
|
+
event_type VARCHAR(50) NOT NULL,
|
|
87
|
+
epoch BIGINT NOT NULL,
|
|
88
|
+
payload JSON,
|
|
89
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
90
|
+
INDEX idx_conn (connection_id),
|
|
91
|
+
INDEX idx_epoch (epoch)
|
|
92
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
93
|
+
`;
|
|
94
|
+
try {
|
|
95
|
+
await pool.query(createTableSQL);
|
|
96
|
+
tableEnsured = true;
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
// Log but don't throw — table might already exist
|
|
100
|
+
console.warn('[mysql-backend] table creation failed (may already exist)', error);
|
|
101
|
+
tableEnsured = true; // Assume it exists
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const emit = async (connectionId, eventType, epoch, payload) => {
|
|
105
|
+
try {
|
|
106
|
+
await ensureTable();
|
|
107
|
+
await pool.execute('INSERT INTO antiban_events (connection_id, event_type, epoch, payload) VALUES (?, ?, ?, ?)', [connectionId, eventType, epoch, payload ? JSON.stringify(payload) : null]);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
// Never throw — event emission is non-critical
|
|
111
|
+
console.warn('[mysql-backend] emit failed', { connectionId, eventType, error });
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const poll = async (connectionId, sinceEpoch) => {
|
|
115
|
+
try {
|
|
116
|
+
await ensureTable();
|
|
117
|
+
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]));
|
|
118
|
+
return rows.map((row) => ({
|
|
119
|
+
id: row.id,
|
|
120
|
+
connectionId: row.connection_id,
|
|
121
|
+
eventType: row.event_type,
|
|
122
|
+
epoch: row.epoch,
|
|
123
|
+
payload: row.payload ? JSON.parse(row.payload) : null,
|
|
124
|
+
createdAt: row.created_at,
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
console.warn('[mysql-backend] poll failed', { connectionId, error });
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
return { emit, poll };
|
|
133
|
+
}
|
|
134
|
+
// ===============================
|
|
135
|
+
// Built-in Backend: In-Memory
|
|
136
|
+
// ===============================
|
|
137
|
+
function createInMemoryEventStoreBackend() {
|
|
138
|
+
const events = [];
|
|
139
|
+
let nextId = 1;
|
|
140
|
+
const emit = async (connectionId, eventType, epoch, payload) => {
|
|
141
|
+
const event = {
|
|
142
|
+
id: nextId++,
|
|
143
|
+
connectionId,
|
|
144
|
+
eventType,
|
|
145
|
+
epoch,
|
|
146
|
+
payload: payload || null,
|
|
147
|
+
createdAt: new Date(),
|
|
148
|
+
};
|
|
149
|
+
events.push(event);
|
|
150
|
+
// Evict oldest if over 1000 entries
|
|
151
|
+
if (events.length > 1000) {
|
|
152
|
+
events.shift();
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
const poll = async (connectionId, sinceEpoch) => {
|
|
156
|
+
return events
|
|
157
|
+
.filter((e) => e.connectionId === connectionId && e.epoch > sinceEpoch)
|
|
158
|
+
.sort((a, b) => a.epoch - b.epoch)
|
|
159
|
+
.slice(0, 50);
|
|
160
|
+
};
|
|
161
|
+
return { emit, poll };
|
|
162
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* HumanEntropyService — Background noise for WA sessions
|
|
4
|
+
*
|
|
5
|
+
* Runs periodic human-like actions to make idle sessions appear more realistic:
|
|
6
|
+
* - Random typing presence to recent contacts
|
|
7
|
+
* - Delayed read receipts
|
|
8
|
+
* - Availability status toggles
|
|
9
|
+
*
|
|
10
|
+
* Design:
|
|
11
|
+
* - Works ONLY with WaSP's public API (no direct socket access)
|
|
12
|
+
* - Fail-silent (never crashes wa-pa)
|
|
13
|
+
* - Configurable intervals and probabilities
|
|
14
|
+
* - Only interacts with contacts who messaged first (never strangers)
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.HumanEntropyService = void 0;
|
|
18
|
+
exports.createHumanEntropyService = createHumanEntropyService;
|
|
19
|
+
const DEFAULT_CONFIG = {
|
|
20
|
+
enabled: true,
|
|
21
|
+
minIntervalMs: 2 * 60 * 60 * 1000, // 2 hours
|
|
22
|
+
maxIntervalMs: 6 * 60 * 60 * 1000, // 6 hours
|
|
23
|
+
maxRecentContacts: 30,
|
|
24
|
+
typingProbability: 0.3,
|
|
25
|
+
typingMinMs: 3000,
|
|
26
|
+
typingMaxMs: 8000,
|
|
27
|
+
readReceiptProbability: 0.2,
|
|
28
|
+
readReceiptMinDelayMs: 10 * 60 * 1000, // 10 min
|
|
29
|
+
readReceiptMaxDelayMs: 60 * 60 * 1000, // 60 min
|
|
30
|
+
presenceToggleProbability: 0.15,
|
|
31
|
+
presenceToggleMinMs: 30 * 1000, // 30 sec
|
|
32
|
+
presenceToggleMaxMs: 2 * 60 * 1000, // 2 min
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Human entropy service
|
|
36
|
+
*
|
|
37
|
+
* Adds realistic background noise to WhatsApp sessions by performing
|
|
38
|
+
* random human-like actions periodically.
|
|
39
|
+
*/
|
|
40
|
+
class HumanEntropyService {
|
|
41
|
+
config;
|
|
42
|
+
wasp; // WaSP instance
|
|
43
|
+
sessionId;
|
|
44
|
+
recentContacts = [];
|
|
45
|
+
unreadMessages = [];
|
|
46
|
+
cycleTimer = null;
|
|
47
|
+
isRunning = false;
|
|
48
|
+
stats = {
|
|
49
|
+
cyclesExecuted: 0,
|
|
50
|
+
typingActionsPerformed: 0,
|
|
51
|
+
readReceiptsMarked: 0,
|
|
52
|
+
presenceToggles: 0,
|
|
53
|
+
errors: 0,
|
|
54
|
+
lastCycleAt: null,
|
|
55
|
+
nextCycleAt: null,
|
|
56
|
+
};
|
|
57
|
+
constructor(wasp, sessionId, config = {}) {
|
|
58
|
+
this.wasp = wasp;
|
|
59
|
+
this.sessionId = sessionId;
|
|
60
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
61
|
+
// Listen to MESSAGE_RECEIVED events to track recent contacts
|
|
62
|
+
this.wasp.on('MESSAGE_RECEIVED', (event) => {
|
|
63
|
+
if (event.sessionId === this.sessionId) {
|
|
64
|
+
this.trackIncomingMessage(event.data);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
this.log('info', 'HumanEntropyService initialized', {
|
|
68
|
+
enabled: this.config.enabled,
|
|
69
|
+
minInterval: this.config.minIntervalMs,
|
|
70
|
+
maxInterval: this.config.maxIntervalMs,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Start the entropy service
|
|
75
|
+
*/
|
|
76
|
+
start() {
|
|
77
|
+
if (!this.config.enabled) {
|
|
78
|
+
this.log('info', 'Entropy service disabled, not starting');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (this.isRunning) {
|
|
82
|
+
this.log('warn', 'Entropy service already running');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
this.isRunning = true;
|
|
86
|
+
this.scheduleNextCycle();
|
|
87
|
+
this.log('info', 'Entropy service started');
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Stop the entropy service
|
|
91
|
+
*/
|
|
92
|
+
stop() {
|
|
93
|
+
this.isRunning = false;
|
|
94
|
+
if (this.cycleTimer) {
|
|
95
|
+
clearTimeout(this.cycleTimer);
|
|
96
|
+
this.cycleTimer = null;
|
|
97
|
+
}
|
|
98
|
+
this.stats.nextCycleAt = null;
|
|
99
|
+
this.log('info', 'Entropy service stopped');
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Get current statistics
|
|
103
|
+
*/
|
|
104
|
+
getStats() {
|
|
105
|
+
return { ...this.stats };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Track incoming message to build recent contacts list
|
|
109
|
+
*/
|
|
110
|
+
trackIncomingMessage(message) {
|
|
111
|
+
try {
|
|
112
|
+
// Skip outgoing messages
|
|
113
|
+
if (message.fromMe)
|
|
114
|
+
return;
|
|
115
|
+
const jid = message.from || message.chatId;
|
|
116
|
+
if (!jid)
|
|
117
|
+
return;
|
|
118
|
+
// Update or add to recent contacts
|
|
119
|
+
const existingIndex = this.recentContacts.findIndex(c => c.jid === jid);
|
|
120
|
+
if (existingIndex >= 0) {
|
|
121
|
+
this.recentContacts[existingIndex].lastMessageAt = new Date();
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
this.recentContacts.push({
|
|
125
|
+
jid,
|
|
126
|
+
lastMessageAt: new Date(),
|
|
127
|
+
isGroup: message.isGroup ?? false,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
// Trim to max size (keep most recent)
|
|
131
|
+
if (this.recentContacts.length > this.config.maxRecentContacts) {
|
|
132
|
+
this.recentContacts.sort((a, b) => b.lastMessageAt.getTime() - a.lastMessageAt.getTime());
|
|
133
|
+
this.recentContacts = this.recentContacts.slice(0, this.config.maxRecentContacts);
|
|
134
|
+
}
|
|
135
|
+
// Track for potential read receipt
|
|
136
|
+
if (message.id && message.key) {
|
|
137
|
+
this.unreadMessages.push({
|
|
138
|
+
jid,
|
|
139
|
+
messageKey: message.key,
|
|
140
|
+
receivedAt: new Date(),
|
|
141
|
+
});
|
|
142
|
+
// Trim old unread messages (keep last 50)
|
|
143
|
+
if (this.unreadMessages.length > 50) {
|
|
144
|
+
this.unreadMessages = this.unreadMessages.slice(-50);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
this.log('error', 'Error tracking incoming message', { error: error.message });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Schedule next entropy cycle
|
|
154
|
+
*/
|
|
155
|
+
scheduleNextCycle() {
|
|
156
|
+
if (!this.isRunning)
|
|
157
|
+
return;
|
|
158
|
+
const delay = this.randomBetween(this.config.minIntervalMs, this.config.maxIntervalMs);
|
|
159
|
+
this.stats.nextCycleAt = new Date(Date.now() + delay);
|
|
160
|
+
this.cycleTimer = setTimeout(() => {
|
|
161
|
+
this.executeCycle().catch(error => {
|
|
162
|
+
this.log('error', 'Entropy cycle failed', { error: error.message });
|
|
163
|
+
this.stats.errors++;
|
|
164
|
+
}).finally(() => {
|
|
165
|
+
this.scheduleNextCycle();
|
|
166
|
+
});
|
|
167
|
+
}, delay);
|
|
168
|
+
this.log('debug', `Next entropy cycle in ${Math.floor(delay / 1000)}s`);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Execute one entropy cycle
|
|
172
|
+
*/
|
|
173
|
+
async executeCycle() {
|
|
174
|
+
if (!this.isRunning || this.recentContacts.length === 0) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
this.log('info', 'Starting entropy cycle', {
|
|
178
|
+
recentContactsCount: this.recentContacts.length,
|
|
179
|
+
unreadMessagesCount: this.unreadMessages.length,
|
|
180
|
+
});
|
|
181
|
+
this.stats.cyclesExecuted++;
|
|
182
|
+
this.stats.lastCycleAt = new Date();
|
|
183
|
+
const actions = [];
|
|
184
|
+
// Action 1: Random typing presence
|
|
185
|
+
if (Math.random() < this.config.typingProbability) {
|
|
186
|
+
actions.push(this.performTypingPresence());
|
|
187
|
+
}
|
|
188
|
+
// Action 2: Mark random message as read
|
|
189
|
+
if (Math.random() < this.config.readReceiptProbability && this.unreadMessages.length > 0) {
|
|
190
|
+
actions.push(this.performReadReceipt());
|
|
191
|
+
}
|
|
192
|
+
// Action 3: Toggle presence status
|
|
193
|
+
if (Math.random() < this.config.presenceToggleProbability) {
|
|
194
|
+
actions.push(this.performPresenceToggle());
|
|
195
|
+
}
|
|
196
|
+
// Execute all actions in parallel
|
|
197
|
+
await Promise.allSettled(actions);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Send typing presence to a random recent contact
|
|
201
|
+
*/
|
|
202
|
+
async performTypingPresence() {
|
|
203
|
+
try {
|
|
204
|
+
// Pick a random non-group contact
|
|
205
|
+
const eligibleContacts = this.recentContacts.filter(c => !c.isGroup);
|
|
206
|
+
if (eligibleContacts.length === 0) {
|
|
207
|
+
this.log('debug', 'No eligible contacts for typing presence');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const contact = eligibleContacts[Math.floor(Math.random() * eligibleContacts.length)];
|
|
211
|
+
const duration = this.randomBetween(this.config.typingMinMs, this.config.typingMaxMs);
|
|
212
|
+
this.log('info', `Entropy action: typing to ${this.maskJid(contact.jid)} for ${duration}ms`);
|
|
213
|
+
// Get provider to access socket
|
|
214
|
+
const provider = this.wasp.getProvider(this.sessionId);
|
|
215
|
+
if (!provider || !provider.socket) {
|
|
216
|
+
throw new Error('Provider or socket not available');
|
|
217
|
+
}
|
|
218
|
+
// Send composing presence
|
|
219
|
+
await provider.socket.sendPresenceUpdate('composing', contact.jid);
|
|
220
|
+
// Wait for duration
|
|
221
|
+
await this.sleep(duration);
|
|
222
|
+
// Send paused presence
|
|
223
|
+
await provider.socket.sendPresenceUpdate('paused', contact.jid);
|
|
224
|
+
this.stats.typingActionsPerformed++;
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
this.log('error', 'Error performing typing presence', { error: error.message });
|
|
228
|
+
this.stats.errors++;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Mark a random recent message as read with delay
|
|
233
|
+
*/
|
|
234
|
+
async performReadReceipt() {
|
|
235
|
+
try {
|
|
236
|
+
const message = this.unreadMessages[Math.floor(Math.random() * this.unreadMessages.length)];
|
|
237
|
+
if (!message)
|
|
238
|
+
return;
|
|
239
|
+
const delay = this.randomBetween(this.config.readReceiptMinDelayMs, this.config.readReceiptMaxDelayMs);
|
|
240
|
+
this.log('info', `Entropy action: mark read ${this.maskJid(message.jid)} after ${Math.floor(delay / 1000)}s`);
|
|
241
|
+
// Wait for delay
|
|
242
|
+
await this.sleep(delay);
|
|
243
|
+
// Get provider to access socket
|
|
244
|
+
const provider = this.wasp.getProvider(this.sessionId);
|
|
245
|
+
if (!provider || !provider.socket) {
|
|
246
|
+
throw new Error('Provider or socket not available');
|
|
247
|
+
}
|
|
248
|
+
// Mark as read
|
|
249
|
+
await provider.socket.readMessages([message.messageKey]);
|
|
250
|
+
// Remove from unread list
|
|
251
|
+
this.unreadMessages = this.unreadMessages.filter(m => m !== message);
|
|
252
|
+
this.stats.readReceiptsMarked++;
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
this.log('error', 'Error performing read receipt', { error: error.message });
|
|
256
|
+
this.stats.errors++;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Toggle presence status (available → unavailable)
|
|
261
|
+
*/
|
|
262
|
+
async performPresenceToggle() {
|
|
263
|
+
try {
|
|
264
|
+
const duration = this.randomBetween(this.config.presenceToggleMinMs, this.config.presenceToggleMaxMs);
|
|
265
|
+
this.log('info', `Entropy action: presence toggle for ${Math.floor(duration / 1000)}s`);
|
|
266
|
+
// Get provider to access socket
|
|
267
|
+
const provider = this.wasp.getProvider(this.sessionId);
|
|
268
|
+
if (!provider || !provider.socket) {
|
|
269
|
+
throw new Error('Provider or socket not available');
|
|
270
|
+
}
|
|
271
|
+
// Set available
|
|
272
|
+
await provider.socket.sendPresenceUpdate('available');
|
|
273
|
+
// Wait for duration
|
|
274
|
+
await this.sleep(duration);
|
|
275
|
+
// Set unavailable
|
|
276
|
+
await provider.socket.sendPresenceUpdate('unavailable');
|
|
277
|
+
this.stats.presenceToggles++;
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
this.log('error', 'Error performing presence toggle', { error: error.message });
|
|
281
|
+
this.stats.errors++;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Random value between min and max (inclusive)
|
|
286
|
+
*/
|
|
287
|
+
randomBetween(min, max) {
|
|
288
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Sleep for specified milliseconds
|
|
292
|
+
*/
|
|
293
|
+
sleep(ms) {
|
|
294
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Mask JID for logging (privacy)
|
|
298
|
+
*/
|
|
299
|
+
maskJid(jid) {
|
|
300
|
+
if (jid.length < 8)
|
|
301
|
+
return '***';
|
|
302
|
+
return jid.substring(0, 4) + '***' + jid.substring(jid.length - 4);
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Log message
|
|
306
|
+
*/
|
|
307
|
+
log(level, message, meta) {
|
|
308
|
+
const prefix = '[entropy]';
|
|
309
|
+
const logData = meta ? `${message} ${JSON.stringify(meta)}` : message;
|
|
310
|
+
switch (level) {
|
|
311
|
+
case 'info':
|
|
312
|
+
console.log(`${prefix} ${logData}`);
|
|
313
|
+
break;
|
|
314
|
+
case 'warn':
|
|
315
|
+
console.warn(`${prefix} ${logData}`);
|
|
316
|
+
break;
|
|
317
|
+
case 'error':
|
|
318
|
+
console.error(`${prefix} ${logData}`);
|
|
319
|
+
break;
|
|
320
|
+
case 'debug':
|
|
321
|
+
// Skip debug logs in production
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
exports.HumanEntropyService = HumanEntropyService;
|
|
327
|
+
/**
|
|
328
|
+
* Factory function to create HumanEntropyService
|
|
329
|
+
*
|
|
330
|
+
* @param wasp WaSP instance
|
|
331
|
+
* @param sessionId Session ID
|
|
332
|
+
* @param config Optional configuration
|
|
333
|
+
* @returns HumanEntropyService instance with start() and stop() methods
|
|
334
|
+
*/
|
|
335
|
+
function createHumanEntropyService(wasp, sessionId, config) {
|
|
336
|
+
const service = new HumanEntropyService(wasp, sessionId, config);
|
|
337
|
+
return {
|
|
338
|
+
start: () => service.start(),
|
|
339
|
+
stop: () => service.stop(),
|
|
340
|
+
getStats: () => service.getStats(),
|
|
341
|
+
};
|
|
342
|
+
}
|