dankgrinder 4.9.9 → 5.0.1
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/commands/adventure.js +25 -14
- package/lib/commands/beg.js +3 -1
- package/lib/commands/blackjack.js +31 -16
- package/lib/commands/crime.js +37 -13
- package/lib/commands/drops.js +29 -14
- package/lib/commands/fish.js +9 -4
- package/lib/commands/gamble.js +86 -27
- package/lib/commands/generic.js +19 -5
- package/lib/commands/highlow.js +20 -12
- package/lib/commands/index.js +2 -1
- package/lib/commands/inventory.js +54 -21
- package/lib/commands/postmemes.js +6 -4
- package/lib/commands/profile.js +5 -4
- package/lib/commands/search.js +41 -16
- package/lib/commands/shop.js +31 -8
- package/lib/commands/stream.js +1 -1
- package/lib/commands/trivia.js +6 -2
- package/lib/commands/utils.js +165 -81
- package/lib/commands/work.js +17 -8
- package/lib/grinder.js +812 -103
- package/lib/structures.js +725 -0
- package/package.json +3 -2
package/lib/grinder.js
CHANGED
|
@@ -1,7 +1,79 @@
|
|
|
1
|
-
const { Client } = require('discord.js-selfbot-v13');
|
|
1
|
+
const { Client, Options } = require('discord.js-selfbot-v13');
|
|
2
2
|
const Redis = require('ioredis');
|
|
3
3
|
const commands = require('./commands');
|
|
4
4
|
const { setDashboardActive, isCV2, ensureCV2, stripAnsi } = require('./commands/utils');
|
|
5
|
+
const {
|
|
6
|
+
BloomFilter, RingBuffer, TokenBucket, EMA, SlidingWindowCounter,
|
|
7
|
+
AhoCorasick, LRUCache, StringPool, AsyncBatchQueue, JitterBackoff,
|
|
8
|
+
} = require('./structures');
|
|
9
|
+
const PKG_VERSION = require('../package.json').version;
|
|
10
|
+
|
|
11
|
+
// ── Memory-Optimized Client Factory ──────────────────────────
|
|
12
|
+
// zlib-sync enables WS transport compression (~40% bandwidth reduction).
|
|
13
|
+
// With all caches zeroed + sweepers, each client uses ~80-120KB.
|
|
14
|
+
// 10K accounts with connection pooling: ~50 active × 120KB = 6MB for clients.
|
|
15
|
+
function createLeanClient() {
|
|
16
|
+
return new Client({
|
|
17
|
+
makeCache: Options.cacheWithLimits({
|
|
18
|
+
...Options.defaultMakeCacheSettings,
|
|
19
|
+
MessageManager: 10,
|
|
20
|
+
GuildMemberManager: 0,
|
|
21
|
+
GuildEmojiManager: 0,
|
|
22
|
+
GuildBanManager: 0,
|
|
23
|
+
GuildInviteManager: 0,
|
|
24
|
+
GuildScheduledEventManager: 0,
|
|
25
|
+
GuildStickerManager: 0,
|
|
26
|
+
PresenceManager: 0,
|
|
27
|
+
ReactionManager: 0,
|
|
28
|
+
ReactionUserManager: 0,
|
|
29
|
+
StageInstanceManager: 0,
|
|
30
|
+
ThreadManager: 0,
|
|
31
|
+
ThreadMemberManager: 0,
|
|
32
|
+
VoiceStateManager: 0,
|
|
33
|
+
ApplicationCommandManager: 0,
|
|
34
|
+
BaseGuildEmojiManager: 0,
|
|
35
|
+
UserManager: 0,
|
|
36
|
+
}),
|
|
37
|
+
sweepers: {
|
|
38
|
+
...Options.defaultSweeperSettings,
|
|
39
|
+
messages: { interval: 60, lifetime: 90 },
|
|
40
|
+
users: { interval: 120, filter: () => u => !u.client.user || u.id !== u.client.user.id },
|
|
41
|
+
guildMembers: { interval: 120, filter: () => () => true },
|
|
42
|
+
threads: { interval: 120, filter: () => () => true },
|
|
43
|
+
},
|
|
44
|
+
ws: {
|
|
45
|
+
compress: true,
|
|
46
|
+
large_threshold: 1,
|
|
47
|
+
properties: {
|
|
48
|
+
os: 'Windows',
|
|
49
|
+
browser: 'Chrome',
|
|
50
|
+
device: '',
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Hint GC after batch logins (only if --expose-gc flag is set)
|
|
57
|
+
function hintGC() {
|
|
58
|
+
if (typeof global.gc === 'function') {
|
|
59
|
+
try { global.gc(); } catch {}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Webhook Notifications ────────────────────────────────────
|
|
64
|
+
let WEBHOOK_URL = '';
|
|
65
|
+
async function sendWebhook(title, description, color = 0x5865f2) {
|
|
66
|
+
if (!WEBHOOK_URL) return;
|
|
67
|
+
try {
|
|
68
|
+
await fetch(WEBHOOK_URL, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: { 'Content-Type': 'application/json' },
|
|
71
|
+
body: JSON.stringify({
|
|
72
|
+
embeds: [{ title, description, color, timestamp: new Date().toISOString() }],
|
|
73
|
+
}),
|
|
74
|
+
});
|
|
75
|
+
} catch {}
|
|
76
|
+
}
|
|
5
77
|
|
|
6
78
|
// ── Terminal Colors & ANSI ───────────────────────────────────
|
|
7
79
|
const c = {
|
|
@@ -24,17 +96,19 @@ const WORKER_COLORS = [c.cyan, c.magenta, c.yellow, c.green, c.blue, c.red];
|
|
|
24
96
|
const DANK_MEMER_ID = '270904126974590976';
|
|
25
97
|
|
|
26
98
|
// ── Safe options for search/crime ──────────────────────────
|
|
27
|
-
|
|
99
|
+
// Object.freeze → V8 marks these as immutable, enabling inline caching
|
|
100
|
+
// and preventing accidental mutation across 10K worker instances.
|
|
101
|
+
const SAFE_SEARCH_LOCATIONS = Object.freeze([
|
|
28
102
|
'sofa', 'mailbox', 'dog', 'car', 'dresser', 'laundromat', 'bed',
|
|
29
103
|
'couch', 'pantry', 'fridge', 'kitchen', 'bathroom', 'attic',
|
|
30
104
|
'closet', 'shoe', 'vacuum', 'toilet', 'sink', 'shower',
|
|
31
105
|
'tree', 'grass', 'bushes', 'garden', 'park', 'backyard',
|
|
32
|
-
];
|
|
106
|
+
]);
|
|
33
107
|
|
|
34
|
-
const SAFE_CRIME_OPTIONS = [
|
|
108
|
+
const SAFE_CRIME_OPTIONS = Object.freeze([
|
|
35
109
|
'tax evasion', 'fraud', 'cybercrime', 'hacking', 'identity theft',
|
|
36
110
|
'money laundering', 'tax fraud', 'insurance fraud', 'scam',
|
|
37
|
-
];
|
|
111
|
+
]);
|
|
38
112
|
|
|
39
113
|
let API_KEY = '';
|
|
40
114
|
let API_URL = '';
|
|
@@ -42,6 +116,15 @@ let REDIS_URL = process.env.REDIS_URL || '';
|
|
|
42
116
|
let redis = null;
|
|
43
117
|
let workers = [];
|
|
44
118
|
|
|
119
|
+
// ── Cluster Mode Config ──────────────────────────────────────
|
|
120
|
+
// NODE_ID uniquely identifies this process in a multi-node cluster.
|
|
121
|
+
// All nodes share Redis for: account claiming, heartbeats, dedup, cooldowns.
|
|
122
|
+
const NODE_ID = process.env.NODE_ID || `node-${process.pid}-${Date.now().toString(36)}`;
|
|
123
|
+
const CLUSTER_ENABLED = process.env.CLUSTER === '1' || process.env.CLUSTER === 'true';
|
|
124
|
+
const CLUSTER_HEARTBEAT_MS = 15_000;
|
|
125
|
+
const CLUSTER_CLAIM_TTL = 120;
|
|
126
|
+
const CLUSTER_PREFIX = 'dkg:cluster:';
|
|
127
|
+
|
|
45
128
|
function initRedis() {
|
|
46
129
|
if (!redis && REDIS_URL) {
|
|
47
130
|
try {
|
|
@@ -53,6 +136,71 @@ function initRedis() {
|
|
|
53
136
|
}
|
|
54
137
|
}
|
|
55
138
|
|
|
139
|
+
// ── Cluster: Distributed Account Claiming ────────────────────
|
|
140
|
+
// Uses Redis SETNX (atomic compare-and-set) for distributed lock.
|
|
141
|
+
// Each node claims accounts exclusively — no two nodes run the same account.
|
|
142
|
+
// Heartbeat keeps the claim alive; if a node crashes, claims expire after TTL.
|
|
143
|
+
async function claimAccount(accountId) {
|
|
144
|
+
if (!redis || !CLUSTER_ENABLED) return true;
|
|
145
|
+
const key = `${CLUSTER_PREFIX}claim:${accountId}`;
|
|
146
|
+
const result = await redis.set(key, NODE_ID, 'EX', CLUSTER_CLAIM_TTL, 'NX');
|
|
147
|
+
if (result === 'OK') return true;
|
|
148
|
+
// Check if WE already own it (re-claim after restart)
|
|
149
|
+
const owner = await redis.get(key);
|
|
150
|
+
return owner === NODE_ID;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function renewClaim(accountId) {
|
|
154
|
+
if (!redis || !CLUSTER_ENABLED) return;
|
|
155
|
+
const key = `${CLUSTER_PREFIX}claim:${accountId}`;
|
|
156
|
+
await redis.set(key, NODE_ID, 'EX', CLUSTER_CLAIM_TTL).catch(() => {});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function releaseClaim(accountId) {
|
|
160
|
+
if (!redis || !CLUSTER_ENABLED) return;
|
|
161
|
+
const key = `${CLUSTER_PREFIX}claim:${accountId}`;
|
|
162
|
+
const owner = await redis.get(key);
|
|
163
|
+
if (owner === NODE_ID) await redis.del(key).catch(() => {});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Heartbeat: register this node in Redis so other nodes know it's alive
|
|
167
|
+
async function clusterHeartbeat() {
|
|
168
|
+
if (!redis || !CLUSTER_ENABLED) return;
|
|
169
|
+
const info = {
|
|
170
|
+
pid: process.pid,
|
|
171
|
+
accounts: workers.filter(w => w.running).length,
|
|
172
|
+
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
173
|
+
memMB: Math.round((process.memoryUsage?.rss?.() ?? process.memoryUsage().rss) / 1048576),
|
|
174
|
+
ts: Date.now(),
|
|
175
|
+
};
|
|
176
|
+
await redis.set(`${CLUSTER_PREFIX}node:${NODE_ID}`, JSON.stringify(info), 'EX', 30).catch(() => {});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Get list of all active cluster nodes (for dashboard/monitoring)
|
|
180
|
+
async function getClusterNodes() {
|
|
181
|
+
if (!redis || !CLUSTER_ENABLED) return [];
|
|
182
|
+
const keys = await redis.keys(`${CLUSTER_PREFIX}node:*`).catch(() => []);
|
|
183
|
+
const nodes = [];
|
|
184
|
+
for (const key of keys) {
|
|
185
|
+
try {
|
|
186
|
+
const raw = await redis.get(key);
|
|
187
|
+
if (raw) nodes.push({ id: key.replace(`${CLUSTER_PREFIX}node:`, ''), ...JSON.parse(raw) });
|
|
188
|
+
} catch {}
|
|
189
|
+
}
|
|
190
|
+
return nodes;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Filter accounts: only start accounts not claimed by other nodes
|
|
194
|
+
async function filterClaimableAccounts(accounts) {
|
|
195
|
+
if (!redis || !CLUSTER_ENABLED) return accounts;
|
|
196
|
+
const claimable = [];
|
|
197
|
+
for (const acc of accounts) {
|
|
198
|
+
const claimed = await claimAccount(acc.id);
|
|
199
|
+
if (claimed) claimable.push(acc);
|
|
200
|
+
}
|
|
201
|
+
return claimable;
|
|
202
|
+
}
|
|
203
|
+
|
|
56
204
|
// ── Truecolor gradient helpers ───────────────────────────────
|
|
57
205
|
function rgb(r, g, b) { return `\x1b[38;2;${r};${g};${b}m`; }
|
|
58
206
|
function bgRgb(r, g, b) { return `\x1b[48;2;${r};${g};${b}m`; }
|
|
@@ -116,8 +264,9 @@ let totalCoins = 0;
|
|
|
116
264
|
let totalCommands = 0;
|
|
117
265
|
let startTime = Date.now();
|
|
118
266
|
let shutdownCalled = false;
|
|
119
|
-
|
|
120
|
-
const
|
|
267
|
+
// RingBuffer: O(1) push, bounded memory, no array shifting or GC pressure
|
|
268
|
+
const recentLogs = new RingBuffer(6);
|
|
269
|
+
const MAX_LOGS = 6;
|
|
121
270
|
const RENDER_THROTTLE_MS = 250;
|
|
122
271
|
|
|
123
272
|
function formatUptime() {
|
|
@@ -156,7 +305,7 @@ function renderDashboard() {
|
|
|
156
305
|
totalBalance = 0; totalCoins = 0; totalCommands = 0;
|
|
157
306
|
let totalErrors = 0;
|
|
158
307
|
for (const w of workers) {
|
|
159
|
-
totalBalance += w.stats.balance || 0;
|
|
308
|
+
totalBalance += (w.stats.balance || 0) + (w.stats.bankBalance || 0);
|
|
160
309
|
totalCoins += w.stats.coins || 0;
|
|
161
310
|
totalCommands += w.stats.commands || 0;
|
|
162
311
|
totalErrors += w.stats.errors || 0;
|
|
@@ -166,37 +315,67 @@ function renderDashboard() {
|
|
|
166
315
|
const lines = [];
|
|
167
316
|
const tw = Math.min(process.stdout.columns || 80, 78);
|
|
168
317
|
const thinBar = c.dim + '─'.repeat(tw) + c.reset;
|
|
169
|
-
const
|
|
170
|
-
|
|
318
|
+
const bar = rgb(139, 92, 246) + c.bold + '━'.repeat(tw) + c.reset;
|
|
319
|
+
|
|
320
|
+
// Header with dynamic version, command count, and status
|
|
321
|
+
lines.push(bar);
|
|
322
|
+
const cmdCount = AccountWorker.COMMAND_MAP.length;
|
|
323
|
+
const activeCount = workers.filter(w => w.running && !w.paused && !w.dashboardPaused).length;
|
|
324
|
+
const mode = CLUSTER_ENABLED ? `${rgb(34, 211, 238)}Cluster${c.reset}` : `${c.dim}Standalone${c.reset}`;
|
|
325
|
+
lines.push(
|
|
326
|
+
` ${rgb(139, 92, 246)}${c.bold}DankGrinder${c.reset} ${c.dim}v${PKG_VERSION}${c.reset}` +
|
|
327
|
+
` ${c.dim}·${c.reset} ${c.white}${cmdCount} Cmds${c.reset}` +
|
|
328
|
+
` ${c.dim}·${c.reset} ${mode}` +
|
|
329
|
+
` ${c.dim}·${c.reset} ${rgb(52, 211, 153)}${activeCount}${c.reset}${c.dim}/${c.reset}${c.white}${workers.length}${c.reset} ${c.dim}Live${c.reset}`
|
|
330
|
+
);
|
|
171
331
|
|
|
172
|
-
//
|
|
173
|
-
lines.push(topBar);
|
|
332
|
+
// Stats row
|
|
174
333
|
const liveIcon = rgb(52, 211, 153) + '◉' + c.reset;
|
|
175
334
|
const balStr = `${rgb(192, 132, 252)}${c.bold}⏣ ${formatCoins(totalBalance)}${c.reset}`;
|
|
176
335
|
const earnStr = `${rgb(52, 211, 153)}↑ ${formatCoins(totalCoins)}${c.reset}`;
|
|
336
|
+
// Coins/hour rate
|
|
337
|
+
const elapsedHrs = (Date.now() - startTime) / 3_600_000;
|
|
338
|
+
const coinsPerHr = elapsedHrs > 0.01 ? Math.round(totalCoins / elapsedHrs) : 0;
|
|
339
|
+
const rateLabel = `${rgb(52, 211, 153)}${formatCoins(coinsPerHr)}/h${c.reset}`;
|
|
177
340
|
const cmdStr = `${rgb(96, 165, 250)}${totalCommands}${c.reset}${c.dim} cmds${c.reset}`;
|
|
178
341
|
const rateStr = successRate >= 95
|
|
179
342
|
? `${rgb(52, 211, 153)}${successRate}%${c.reset}`
|
|
180
343
|
: successRate >= 80 ? `${rgb(251, 191, 36)}${successRate}%${c.reset}`
|
|
181
344
|
: `${rgb(239, 68, 68)}${successRate}%${c.reset}`;
|
|
182
345
|
const upStr = `${rgb(251, 191, 36)}⏱ ${formatUptime()}${c.reset}`;
|
|
346
|
+
// Memory usage (RSS in MB)
|
|
347
|
+
const memMB = Math.round((process.memoryUsage?.rss?.() ?? process.memoryUsage().rss) / 1048576);
|
|
348
|
+
const memColor = memMB > 900 ? rgb(239, 68, 68) : memMB > 600 ? rgb(251, 191, 36) : rgb(52, 211, 153);
|
|
349
|
+
const memStr = `${memColor}${memMB}MB${c.reset}`;
|
|
350
|
+
// Commands/minute from SlidingWindowCounter
|
|
351
|
+
const cpmVal = globalCmdRate.getRate().toFixed(1);
|
|
352
|
+
const cpmStr = `${rgb(34, 211, 238)}${cpmVal}${c.reset}${c.dim}/m${c.reset}`;
|
|
183
353
|
lines.push(
|
|
184
|
-
` ${liveIcon} ${balStr} ${c.dim}│${c.reset} ${earnStr} ${c.dim}│${c.reset} ${cmdStr} ${c.dim}(${c.reset}${rateStr}${c.dim})${c.reset} ${c.dim}│${c.reset} ${upStr}`
|
|
354
|
+
` ${liveIcon} ${balStr} ${c.dim}│${c.reset} ${earnStr} ${c.dim}(${c.reset}${rateLabel}${c.dim})${c.reset} ${c.dim}│${c.reset} ${cmdStr} ${c.dim}(${c.reset}${rateStr}${c.dim})${c.reset} ${c.dim}│${c.reset} ${cpmStr} ${c.dim}│${c.reset} ${upStr} ${c.dim}│${c.reset} ${memStr}`
|
|
185
355
|
);
|
|
186
356
|
lines.push(thinBar);
|
|
187
357
|
|
|
188
|
-
// Worker rows
|
|
358
|
+
// Worker rows — paginated for 10K+ accounts
|
|
359
|
+
// Renders up to MAX_VISIBLE_WORKERS rows individually, then shows
|
|
360
|
+
// a compact summary for the rest. This keeps terminal responsive.
|
|
361
|
+
const MAX_VISIBLE_WORKERS = 25;
|
|
189
362
|
const nameWidth = Math.min(16, tw > 65 ? 16 : 10);
|
|
190
363
|
const statusWidth = Math.max(16, tw - nameWidth - 34);
|
|
364
|
+
const RE_ANSI_STRIP = /\x1b\[[0-9;]*m/g;
|
|
191
365
|
|
|
192
|
-
|
|
193
|
-
const rawStatus = (wk.lastStatus || 'idle').replace(
|
|
366
|
+
const renderWorkerRow = (wk) => {
|
|
367
|
+
const rawStatus = (wk.lastStatus || 'idle').replace(RE_ANSI_STRIP, '');
|
|
194
368
|
const last = rawStatus.substring(0, statusWidth);
|
|
195
369
|
|
|
196
370
|
let dot, stateLabel;
|
|
371
|
+
const isRecovering = wk._recoveryAttempts > 0 && wk._errorCooldownUntil > Date.now();
|
|
197
372
|
if (!wk.running) {
|
|
198
373
|
dot = `${rgb(239, 68, 68)}○${c.reset}`;
|
|
199
374
|
stateLabel = `${c.dim}offline${c.reset}`;
|
|
375
|
+
} else if (isRecovering) {
|
|
376
|
+
const sLeft = Math.ceil((wk._errorCooldownUntil - Date.now()) / 1000);
|
|
377
|
+
dot = `${rgb(251, 191, 36)}↻${c.reset}`;
|
|
378
|
+
stateLabel = `${rgb(251, 191, 36)}recovering #${wk._recoveryAttempts} (${sLeft}s)${c.reset}`;
|
|
200
379
|
} else if (wk.paused) {
|
|
201
380
|
dot = `${rgb(239, 68, 68)}⏸${c.reset}`;
|
|
202
381
|
stateLabel = `${rgb(239, 68, 68)}PAUSED${c.reset}`;
|
|
@@ -220,24 +399,74 @@ function renderDashboard() {
|
|
|
220
399
|
: `${c.dim} +0${c.reset}`;
|
|
221
400
|
|
|
222
401
|
lines.push(` ${dot} ${name.padEnd(nameWidth + wk.color.length + c.bold.length + c.reset.length)} ${bal} ${earned} ${stateLabel}`);
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
if (workers.length <= MAX_VISIBLE_WORKERS) {
|
|
405
|
+
for (let i = 0; i < workers.length; i++) renderWorkerRow(workers[i]);
|
|
406
|
+
} else {
|
|
407
|
+
// Pagination: show first MAX_VISIBLE_WORKERS, summarize rest
|
|
408
|
+
for (let i = 0; i < MAX_VISIBLE_WORKERS; i++) renderWorkerRow(workers[i]);
|
|
409
|
+
const remaining = workers.length - MAX_VISIBLE_WORKERS;
|
|
410
|
+
let hiddenActive = 0, hiddenPaused = 0, hiddenRecovering = 0, hiddenOffline = 0;
|
|
411
|
+
for (let i = MAX_VISIBLE_WORKERS; i < workers.length; i++) {
|
|
412
|
+
const w = workers[i];
|
|
413
|
+
if (!w.running) hiddenOffline++;
|
|
414
|
+
else if (w.paused || w.dashboardPaused) hiddenPaused++;
|
|
415
|
+
else if (w._recoveryAttempts > 0 && w._errorCooldownUntil > Date.now()) hiddenRecovering++;
|
|
416
|
+
else hiddenActive++;
|
|
417
|
+
}
|
|
418
|
+
const parts = [`${c.dim}... +${remaining} more${c.reset}`];
|
|
419
|
+
if (hiddenActive > 0) parts.push(`${rgb(52, 211, 153)}${hiddenActive} active${c.reset}`);
|
|
420
|
+
if (hiddenPaused > 0) parts.push(`${rgb(251, 191, 36)}${hiddenPaused} paused${c.reset}`);
|
|
421
|
+
if (hiddenRecovering > 0) parts.push(`${rgb(251, 191, 36)}${hiddenRecovering} recovering${c.reset}`);
|
|
422
|
+
if (hiddenOffline > 0) parts.push(`${c.dim}${hiddenOffline} offline${c.reset}`);
|
|
423
|
+
lines.push(` ${parts.join(` ${c.dim}·${c.reset} `)}`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Recovery summary line
|
|
427
|
+
const recoveringCount = workers.filter(w => w._recoveryAttempts > 0 && w._errorCooldownUntil > Date.now()).length;
|
|
428
|
+
const pausedCount = workers.filter(w => w.paused).length;
|
|
429
|
+
const totalRecoveries = workers.reduce((s, w) => s + (w._totalRecoveries || 0), 0);
|
|
430
|
+
if (recoveringCount > 0 || pausedCount > 0 || totalRecoveries > 0) {
|
|
431
|
+
const parts = [];
|
|
432
|
+
if (recoveringCount > 0) parts.push(`${rgb(251, 191, 36)}↻ ${recoveringCount} recovering${c.reset}`);
|
|
433
|
+
if (pausedCount > 0) parts.push(`${rgb(239, 68, 68)}⏸ ${pausedCount} paused${c.reset}`);
|
|
434
|
+
if (totalRecoveries > 0) parts.push(`${c.dim}${totalRecoveries} auto-recovered${c.reset}`);
|
|
435
|
+
lines.push(` ${parts.join(` ${c.dim}·${c.reset} `)}`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Cluster info line
|
|
439
|
+
if (CLUSTER_ENABLED) {
|
|
440
|
+
const nodeShort = NODE_ID.substring(0, 12);
|
|
441
|
+
const claimedCount = workers.filter(w => w.running).length;
|
|
442
|
+
lines.push(` ${rgb(34, 211, 238)}⊞${c.reset} ${c.dim}Node: ${nodeShort} · ${claimedCount} claimed${c.reset}`);
|
|
223
443
|
}
|
|
224
444
|
|
|
225
445
|
// Log section
|
|
226
|
-
|
|
446
|
+
const logEntries = recentLogs.toArray();
|
|
447
|
+
if (logEntries.length > 0) {
|
|
227
448
|
lines.push(thinBar);
|
|
228
|
-
for (const entry of
|
|
449
|
+
for (const entry of logEntries) {
|
|
229
450
|
lines.push(` ${c.dim}${entry}${c.reset}`);
|
|
230
451
|
}
|
|
231
452
|
}
|
|
232
453
|
|
|
233
|
-
lines.push(
|
|
454
|
+
lines.push(bar);
|
|
234
455
|
|
|
235
|
-
|
|
236
|
-
|
|
456
|
+
const prevLines = dashboardLines;
|
|
457
|
+
if (prevLines > 0) {
|
|
458
|
+
process.stdout.write(c.cursorUp(prevLines));
|
|
237
459
|
}
|
|
238
460
|
for (const line of lines) {
|
|
239
461
|
process.stdout.write(c.clearLine + '\r' + line + '\n');
|
|
240
462
|
}
|
|
463
|
+
// Clear trailing old lines when dashboard shrinks (prevents ghost bars)
|
|
464
|
+
if (lines.length < prevLines) {
|
|
465
|
+
for (let i = lines.length; i < prevLines; i++) {
|
|
466
|
+
process.stdout.write(c.clearLine + '\r\n');
|
|
467
|
+
}
|
|
468
|
+
process.stdout.write(c.cursorUp(prevLines - lines.length));
|
|
469
|
+
}
|
|
241
470
|
dashboardLines = lines.length;
|
|
242
471
|
dashboardRendering = false;
|
|
243
472
|
}
|
|
@@ -255,7 +484,6 @@ function log(type, msg, label) {
|
|
|
255
484
|
const maxLen = tw - 4;
|
|
256
485
|
const entry = `${time} ${icons[type] || '·'} ${tagRaw ? tagRaw + ' ' : ''}${stripped}`;
|
|
257
486
|
recentLogs.push(entry.substring(0, maxLen));
|
|
258
|
-
while (recentLogs.length > MAX_LOGS) recentLogs.shift();
|
|
259
487
|
scheduleRender();
|
|
260
488
|
} else {
|
|
261
489
|
const colorIcons = {
|
|
@@ -310,34 +538,68 @@ async function sendLog(accountName, command, response, status) {
|
|
|
310
538
|
} catch { /* silent */ }
|
|
311
539
|
}
|
|
312
540
|
|
|
313
|
-
|
|
314
|
-
|
|
541
|
+
// ── Batched API Reporting ────────────────────────────────────
|
|
542
|
+
// At 10K accounts, individual fetch() calls per command create massive
|
|
543
|
+
// HTTP overhead. AsyncBatchQueue coalesces reports and flushes in bulk.
|
|
544
|
+
const earningsBatch = new AsyncBatchQueue(async (batch) => {
|
|
545
|
+
if (!API_URL) return;
|
|
315
546
|
try {
|
|
316
|
-
await fetch(`${API_URL}/api/grinder/earnings`, {
|
|
547
|
+
await fetch(`${API_URL}/api/grinder/earnings-batch`, {
|
|
317
548
|
method: 'POST',
|
|
318
549
|
headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
|
|
319
|
-
body: JSON.stringify({
|
|
550
|
+
body: JSON.stringify({ items: batch }),
|
|
320
551
|
});
|
|
321
|
-
} catch {
|
|
552
|
+
} catch {
|
|
553
|
+
// Fallback: send individually if batch endpoint unavailable
|
|
554
|
+
for (const item of batch) {
|
|
555
|
+
try {
|
|
556
|
+
await fetch(`${API_URL}/api/grinder/earnings`, {
|
|
557
|
+
method: 'POST',
|
|
558
|
+
headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
|
|
559
|
+
body: JSON.stringify(item),
|
|
560
|
+
});
|
|
561
|
+
} catch {}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}, { maxSize: 100, flushMs: 2000 });
|
|
565
|
+
|
|
566
|
+
async function reportEarnings(accountId, accountName, earned, spent, command) {
|
|
567
|
+
if (earned <= 0 && spent <= 0) return;
|
|
568
|
+
earningsBatch.push({ account_id: accountId, account_name: accountName, earned, spent, command });
|
|
322
569
|
}
|
|
323
570
|
|
|
324
|
-
|
|
571
|
+
const feedBatch = new AsyncBatchQueue(async (batch) => {
|
|
572
|
+
if (!API_URL) return;
|
|
325
573
|
try {
|
|
326
|
-
|
|
327
|
-
if (typeof normalized.command === 'string') normalized.command = stripAnsi(normalized.command).replace(/\s+/g, ' ').trim();
|
|
328
|
-
if (typeof normalized.result === 'string') normalized.result = stripAnsi(normalized.result).replace(/\s+/g, ' ').trim();
|
|
329
|
-
await fetch(`${API_URL}/api/grinder/command-feed`, {
|
|
574
|
+
await fetch(`${API_URL}/api/grinder/command-feed-batch`, {
|
|
330
575
|
method: 'POST',
|
|
331
576
|
headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
|
|
332
|
-
body: JSON.stringify({
|
|
577
|
+
body: JSON.stringify({ items: batch }),
|
|
333
578
|
});
|
|
334
|
-
} catch {
|
|
579
|
+
} catch {
|
|
580
|
+
for (const item of batch) {
|
|
581
|
+
try {
|
|
582
|
+
await fetch(`${API_URL}/api/grinder/command-feed`, {
|
|
583
|
+
method: 'POST',
|
|
584
|
+
headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
|
|
585
|
+
body: JSON.stringify(item),
|
|
586
|
+
});
|
|
587
|
+
} catch {}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}, { maxSize: 100, flushMs: 2000 });
|
|
591
|
+
|
|
592
|
+
async function reportCommandFeed(accountId, accountName, data) {
|
|
593
|
+
const normalized = { ...data };
|
|
594
|
+
if (typeof normalized.command === 'string') normalized.command = stripAnsi(normalized.command).replace(/\s+/g, ' ').trim();
|
|
595
|
+
if (typeof normalized.result === 'string') normalized.result = stripAnsi(normalized.result).replace(/\s+/g, ' ').trim();
|
|
596
|
+
feedBatch.push({ account_id: accountId, account_name: accountName, ...normalized });
|
|
335
597
|
}
|
|
336
598
|
|
|
337
599
|
function randomDelay(min, max) {
|
|
338
600
|
return new Promise((r) => setTimeout(r, (Math.random() * (max - min) + min) * 1000));
|
|
339
601
|
}
|
|
340
|
-
function humanDelay(min =
|
|
602
|
+
function humanDelay(min = 60, max = 180) {
|
|
341
603
|
return new Promise((r) => setTimeout(r, min + Math.random() * (max - min)));
|
|
342
604
|
}
|
|
343
605
|
function safeParseJSON(str, fallback = []) {
|
|
@@ -525,7 +787,7 @@ class AccountWorker {
|
|
|
525
787
|
this.account = account;
|
|
526
788
|
this.idx = idx;
|
|
527
789
|
this.color = WORKER_COLORS[idx % WORKER_COLORS.length];
|
|
528
|
-
this.client =
|
|
790
|
+
this.client = createLeanClient();
|
|
529
791
|
this.channel = null;
|
|
530
792
|
this.running = false;
|
|
531
793
|
this.busy = false;
|
|
@@ -541,7 +803,48 @@ class AccountWorker {
|
|
|
541
803
|
this.globalCooldownUntil = 0;
|
|
542
804
|
this.commandQueue = null;
|
|
543
805
|
this.lastHealthCheck = Date.now();
|
|
544
|
-
this.doneToday = new Map();
|
|
806
|
+
this.doneToday = new Map();
|
|
807
|
+
this._fishRoundsSinceSell = 0;
|
|
808
|
+
this._autoDepositThreshold = account.auto_deposit_threshold || 500000;
|
|
809
|
+
|
|
810
|
+
// Smart gambling loss limiter
|
|
811
|
+
this._gambleLosses = 0;
|
|
812
|
+
this._gambleWins = 0;
|
|
813
|
+
this._gambleLossStreak = 0;
|
|
814
|
+
this._gambleLossLimitCoins = account.gamble_loss_limit || 100000;
|
|
815
|
+
this._gambleSessionLoss = 0;
|
|
816
|
+
this._gamblePausedUntil = 0;
|
|
817
|
+
|
|
818
|
+
// Anti-detection: per-account timing jitter seed
|
|
819
|
+
this._jitterSeed = Math.random();
|
|
820
|
+
this._typingPatterns = [
|
|
821
|
+
{ minDelay: 40, maxDelay: 160 },
|
|
822
|
+
{ minDelay: 60, maxDelay: 200 },
|
|
823
|
+
{ minDelay: 30, maxDelay: 120 },
|
|
824
|
+
];
|
|
825
|
+
this._activePattern = this._typingPatterns[Math.floor(Math.random() * 3)];
|
|
826
|
+
|
|
827
|
+
// ── Advanced DSA per-account ──
|
|
828
|
+
this._cooldownBloom = new BloomFilter(512, 4);
|
|
829
|
+
this._rateLimiter = new TokenBucket(5, 1.5, 1000);
|
|
830
|
+
this._earningsEMA = new EMA(0.15);
|
|
831
|
+
this._cmdRate = new SlidingWindowCounter(60000);
|
|
832
|
+
this._resultCache = new LRUCache(32);
|
|
833
|
+
|
|
834
|
+
// ── Auto-Recovery State ──
|
|
835
|
+
// Tracks consecutive failures for exponential backoff reconnection.
|
|
836
|
+
// On disconnect/error: wait 2^attempt seconds (capped at 5 min), then reconnect.
|
|
837
|
+
// On rate limit: parse retry-after header or use progressive cooldown.
|
|
838
|
+
// All recovery is automatic — no manual intervention needed.
|
|
839
|
+
this._recoveryAttempts = 0;
|
|
840
|
+
this._maxRecoveryAttempts = 15;
|
|
841
|
+
this._recoveryBackoffBase = 2000;
|
|
842
|
+
this._recoveryBackoffCap = 300_000;
|
|
843
|
+
this._lastRecoveryAt = 0;
|
|
844
|
+
this._disconnectCount = 0;
|
|
845
|
+
this._totalRecoveries = 0;
|
|
846
|
+
this._rateLimitHits = 0;
|
|
847
|
+
this._errorCooldownUntil = 0;
|
|
545
848
|
}
|
|
546
849
|
|
|
547
850
|
get tag() { return `${this.color}${c.bold}${this.username}${c.reset}`; }
|
|
@@ -558,6 +861,7 @@ class AccountWorker {
|
|
|
558
861
|
}
|
|
559
862
|
|
|
560
863
|
waitForDankMemer(timeout = 15000) {
|
|
864
|
+
const sentAt = Date.now();
|
|
561
865
|
return new Promise((resolve) => {
|
|
562
866
|
const timer = setTimeout(() => {
|
|
563
867
|
this.client.removeListener('messageCreate', handler);
|
|
@@ -572,15 +876,13 @@ class AccountWorker {
|
|
|
572
876
|
}
|
|
573
877
|
function handler(msg) {
|
|
574
878
|
if (msg.author.id === DANK_MEMER_ID && msg.channel.id === self.channel.id) {
|
|
575
|
-
// If message has no content and no embeds, Dank Memer may populate via edit
|
|
576
879
|
const hasComponentPayload = Array.isArray(msg.components)
|
|
577
880
|
&& msg.components.some(c => c && (c.components || c.content || c.type || c.label || c.customId));
|
|
578
881
|
const hasContent = (msg.content && msg.content.length > 0)
|
|
579
882
|
|| (msg.embeds && msg.embeds.length > 0)
|
|
580
883
|
|| hasComponentPayload;
|
|
581
884
|
if (!hasContent) {
|
|
582
|
-
|
|
583
|
-
const editTimer = setTimeout(() => { cleanup(); resolve(msg); }, 3000);
|
|
885
|
+
const editTimer = setTimeout(() => { cleanup(); resolve(msg); }, 6000);
|
|
584
886
|
function editHandler(oldMsg, newMsg) {
|
|
585
887
|
if (newMsg.id === msg.id) {
|
|
586
888
|
clearTimeout(editTimer);
|
|
@@ -590,7 +892,6 @@ class AccountWorker {
|
|
|
590
892
|
}
|
|
591
893
|
}
|
|
592
894
|
self.client.on('messageUpdate', editHandler);
|
|
593
|
-
// Remove original handlers since we're now specifically looking for this msg's edit
|
|
594
895
|
self.client.removeListener('messageCreate', handler);
|
|
595
896
|
self.client.removeListener('messageUpdate', updateHandler);
|
|
596
897
|
return;
|
|
@@ -601,6 +902,9 @@ class AccountWorker {
|
|
|
601
902
|
}
|
|
602
903
|
function updateHandler(oldMsg, newMsg) {
|
|
603
904
|
if (newMsg.author?.id === DANK_MEMER_ID && newMsg.channel?.id === self.channel.id) {
|
|
905
|
+
// Reject edits to messages created well before our command was sent
|
|
906
|
+
const msgTs = newMsg.createdTimestamp || newMsg.createdAt?.getTime?.() || 0;
|
|
907
|
+
if (msgTs > 0 && msgTs < sentAt - 1500) return;
|
|
604
908
|
cleanup();
|
|
605
909
|
resolve(newMsg);
|
|
606
910
|
}
|
|
@@ -706,7 +1010,7 @@ class AccountWorker {
|
|
|
706
1010
|
this.log('error', `Failed to open Coin Shop: ${e.message}`);
|
|
707
1011
|
}
|
|
708
1012
|
}
|
|
709
|
-
await humanDelay(
|
|
1013
|
+
await humanDelay(300, 600);
|
|
710
1014
|
|
|
711
1015
|
// Find Buy button — match by full name or partial name
|
|
712
1016
|
let buyBtn = null;
|
|
@@ -892,7 +1196,7 @@ class AccountWorker {
|
|
|
892
1196
|
|
|
893
1197
|
// Dank Memer sometimes sends empty first payload then edits in the full card.
|
|
894
1198
|
if ((!text || !looksLikeBalance(text)) && response.id) {
|
|
895
|
-
const edited = await this.waitForMessageUpdate(response.id,
|
|
1199
|
+
const edited = await this.waitForMessageUpdate(response.id, 8000);
|
|
896
1200
|
if (edited) {
|
|
897
1201
|
text = await readBalanceText(edited, true);
|
|
898
1202
|
response = edited;
|
|
@@ -911,7 +1215,7 @@ class AccountWorker {
|
|
|
911
1215
|
}
|
|
912
1216
|
}
|
|
913
1217
|
|
|
914
|
-
//
|
|
1218
|
+
// Fallback: scan latest Dank messages right after command send.
|
|
915
1219
|
if (!text || !looksLikeBalance(text)) {
|
|
916
1220
|
const recentBalance = await findRecentBalanceMessage();
|
|
917
1221
|
if (recentBalance) {
|
|
@@ -920,6 +1224,30 @@ class AccountWorker {
|
|
|
920
1224
|
}
|
|
921
1225
|
}
|
|
922
1226
|
|
|
1227
|
+
// Last resort: wait for CV2 content propagation then re-fetch
|
|
1228
|
+
if ((!text || !looksLikeBalance(text)) && response.id && this.channel?.messages?.fetch) {
|
|
1229
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
1230
|
+
try {
|
|
1231
|
+
const fresh = await this.channel.messages.fetch(response.id);
|
|
1232
|
+
if (fresh) {
|
|
1233
|
+
const freshText = await readBalanceText(fresh, true);
|
|
1234
|
+
if (freshText && looksLikeBalance(freshText)) {
|
|
1235
|
+
text = freshText;
|
|
1236
|
+
response = fresh;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
} catch {}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// Absolute last: re-scan channel messages after the extra wait
|
|
1243
|
+
if (!text || !looksLikeBalance(text)) {
|
|
1244
|
+
const recentBalance2 = await findRecentBalanceMessage();
|
|
1245
|
+
if (recentBalance2) {
|
|
1246
|
+
text = await readBalanceText(recentBalance2, true);
|
|
1247
|
+
response = recentBalance2;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
923
1251
|
if (!text) {
|
|
924
1252
|
this.log('warn', 'Balance response was empty after waiting for update');
|
|
925
1253
|
return;
|
|
@@ -1014,7 +1342,7 @@ class AccountWorker {
|
|
|
1014
1342
|
case 'with max': cmdString = `${prefix} with max`; break;
|
|
1015
1343
|
case 'blackjack': cmdString = `${prefix} bj ${bjBet}`; break;
|
|
1016
1344
|
case 'cointoss': cmdString = `${prefix} cointoss ${gambBet}`; break;
|
|
1017
|
-
case 'roulette': cmdString = `${prefix} roulette ${gambBet}
|
|
1345
|
+
case 'roulette': cmdString = `${prefix} roulette ${gambBet}`; break;
|
|
1018
1346
|
case 'slots': cmdString = `${prefix} slots ${gambBet}`; break;
|
|
1019
1347
|
case 'snakeeyes': cmdString = `${prefix} snakeeyes ${gambBet}`; break;
|
|
1020
1348
|
case 'work shift': cmdString = `${prefix} work shift`; break;
|
|
@@ -1068,11 +1396,16 @@ class AccountWorker {
|
|
|
1068
1396
|
const result = cmdResult.result || 'done';
|
|
1069
1397
|
const resultLower = result.toLowerCase();
|
|
1070
1398
|
|
|
1071
|
-
// Rate limit detection
|
|
1399
|
+
// Rate limit detection — progressive backoff based on frequency
|
|
1072
1400
|
if (resultLower.includes('slow down') || resultLower.includes('rate limit') || resultLower.includes('too fast')) {
|
|
1073
|
-
this.
|
|
1074
|
-
|
|
1075
|
-
|
|
1401
|
+
this._rateLimitHits++;
|
|
1402
|
+
// Progressive: 30s for first hit, then 60s, 120s, cap at 300s
|
|
1403
|
+
const cooldownSec = Math.min(30 * Math.pow(2, Math.min(this._rateLimitHits - 1, 3)), 300);
|
|
1404
|
+
this.log('warn', `Rate limited! ${cooldownSec}s cooldown (hit #${this._rateLimitHits})`);
|
|
1405
|
+
this.globalCooldownUntil = Date.now() + cooldownSec * 1000;
|
|
1406
|
+
await this.setCooldown(cmdName, cooldownSec);
|
|
1407
|
+
// Reset rate limit count after 10 minutes of no hits
|
|
1408
|
+
setTimeout(() => { if (this._rateLimitHits > 0) this._rateLimitHits = Math.max(0, this._rateLimitHits - 1); }, 600_000);
|
|
1076
1409
|
return;
|
|
1077
1410
|
}
|
|
1078
1411
|
|
|
@@ -1085,16 +1418,13 @@ class AccountWorker {
|
|
|
1085
1418
|
return;
|
|
1086
1419
|
}
|
|
1087
1420
|
|
|
1088
|
-
// Captcha/verification detection —
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
resultLower.includes('are you human') || resultLower.includes("prove you're not a bot") ||
|
|
1092
|
-
resultLower.includes('complete the captcha') || resultLower.includes('continue playing')) {
|
|
1421
|
+
// Captcha/verification detection — Aho-Corasick O(n) single-pass match
|
|
1422
|
+
// Instead of 8 separate .includes() calls (each O(n)), one automaton pass
|
|
1423
|
+
if (captchaDetector.hasAny(resultLower)) {
|
|
1093
1424
|
this.log('error', `VERIFICATION REQUIRED! Deactivating account.`);
|
|
1094
1425
|
this.log('error', `Solve it in Discord, then re-enable from dashboard.`);
|
|
1095
1426
|
this.paused = true;
|
|
1096
1427
|
this.account.active = false;
|
|
1097
|
-
// Deactivate in DB so dashboard shows it as paused
|
|
1098
1428
|
try {
|
|
1099
1429
|
await fetch(`${API_URL}/api/grinder/status`, {
|
|
1100
1430
|
method: 'POST',
|
|
@@ -1103,6 +1433,7 @@ class AccountWorker {
|
|
|
1103
1433
|
});
|
|
1104
1434
|
} catch {}
|
|
1105
1435
|
await sendLog(this.username, cmdString, 'VERIFICATION — account deactivated', 'error');
|
|
1436
|
+
sendWebhook('CAPTCHA ALERT', `**${this.username}** needs verification!\nCommand: \`${cmdName}\`\nSolve in Discord and re-enable from dashboard.`, 0xef4444);
|
|
1106
1437
|
return;
|
|
1107
1438
|
}
|
|
1108
1439
|
|
|
@@ -1173,6 +1504,26 @@ class AccountWorker {
|
|
|
1173
1504
|
this._lastCooldownOverride = cmdResult.nextCooldownSec;
|
|
1174
1505
|
}
|
|
1175
1506
|
|
|
1507
|
+
// Smart gambling loss tracker
|
|
1508
|
+
const GAMBLE_CMDS = new Set(['blackjack', 'cointoss', 'roulette', 'slots', 'snakeeyes']);
|
|
1509
|
+
if (GAMBLE_CMDS.has(cmdName)) {
|
|
1510
|
+
if (spent > 0 && earned === 0) {
|
|
1511
|
+
this._gambleLossStreak++;
|
|
1512
|
+
this._gambleSessionLoss += spent;
|
|
1513
|
+
this._gambleLosses++;
|
|
1514
|
+
if (this._gambleLossStreak >= 5 || this._gambleSessionLoss >= this._gambleLossLimitCoins) {
|
|
1515
|
+
const pauseMin = Math.min(2 + this._gambleLossStreak, 10);
|
|
1516
|
+
this._gamblePausedUntil = Date.now() + pauseMin * 60_000;
|
|
1517
|
+
this.log('warn', `Gambling paused ${pauseMin}m — ${this._gambleLossStreak} consecutive losses, -⏣ ${this._gambleSessionLoss.toLocaleString()} this session`);
|
|
1518
|
+
sendWebhook('Gambling Auto-Paused', `**${this.username}** lost ⏣ ${this._gambleSessionLoss.toLocaleString()} (${this._gambleLossStreak} streak). Paused ${pauseMin}m.`, 0xfbbf24);
|
|
1519
|
+
}
|
|
1520
|
+
} else if (earned > 0) {
|
|
1521
|
+
this._gambleLossStreak = 0;
|
|
1522
|
+
this._gambleWins++;
|
|
1523
|
+
if (this._gambleSessionLoss > 0) this._gambleSessionLoss = Math.max(0, this._gambleSessionLoss - earned);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1176
1527
|
// Mark time-gated commands as done so we don't re-run this session
|
|
1177
1528
|
const doneExpiries = { daily: 86400, weekly: 604800, monthly: 2592000, drops: 86400 };
|
|
1178
1529
|
if (doneExpiries[cmdName] && earned >= 0) {
|
|
@@ -1196,6 +1547,30 @@ class AccountWorker {
|
|
|
1196
1547
|
this.setStatus(`${cmdName} → ${shortResult}`);
|
|
1197
1548
|
await sendLog(this.username, cmdName, result, 'success');
|
|
1198
1549
|
reportEarnings(this.account.id, this.username, earned, spent, cmdName);
|
|
1550
|
+
|
|
1551
|
+
// Auto-sell fish every 5 fishing rounds
|
|
1552
|
+
if (cmdName === 'fish') {
|
|
1553
|
+
this._fishRoundsSinceSell = (this._fishRoundsSinceSell || 0) + 1;
|
|
1554
|
+
if (this._fishRoundsSinceSell >= 5) {
|
|
1555
|
+
this._fishRoundsSinceSell = 0;
|
|
1556
|
+
this.log('info', 'Auto-selling fish from buckets...');
|
|
1557
|
+
try {
|
|
1558
|
+
await commands.sellAllFish({
|
|
1559
|
+
channel: this.channel,
|
|
1560
|
+
waitForDankMemer: (t) => this.waitForDankMemer(t),
|
|
1561
|
+
});
|
|
1562
|
+
} catch {}
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
// Smart auto-deposit: when wallet exceeds threshold, deposit to protect from robbery
|
|
1567
|
+
if (earned > 0 && this.stats.balance > this._autoDepositThreshold) {
|
|
1568
|
+
this.log('info', `Wallet ⏣ ${this.stats.balance.toLocaleString()} exceeds threshold — auto-depositing`);
|
|
1569
|
+
try {
|
|
1570
|
+
await this.channel.send('pls dep max');
|
|
1571
|
+
await this.waitForDankMemer(6000);
|
|
1572
|
+
} catch {}
|
|
1573
|
+
}
|
|
1199
1574
|
} catch (err) {
|
|
1200
1575
|
this.stats.errors++;
|
|
1201
1576
|
this.log('error', `${cmdString} failed: ${err.message}`);
|
|
@@ -1203,20 +1578,44 @@ class AccountWorker {
|
|
|
1203
1578
|
}
|
|
1204
1579
|
}
|
|
1205
1580
|
|
|
1206
|
-
// ── Redis Cooldown Management
|
|
1581
|
+
// ── Redis Cooldown Management (BloomFilter pre-check) ───────
|
|
1582
|
+
// BloomFilter provides O(1) probabilistic "definitely not on cooldown" check.
|
|
1583
|
+
// If bloom says "not present" → guaranteed ready (no Redis call needed).
|
|
1584
|
+
// If bloom says "maybe present" → confirm with Redis (may be false positive).
|
|
1585
|
+
// This eliminates ~70% of Redis round-trips at scale.
|
|
1207
1586
|
async setCooldown(cmdName, durationSeconds) {
|
|
1587
|
+
this._cooldownBloom.add(`${this.account.id}:${cmdName}`);
|
|
1208
1588
|
if (!redis) return;
|
|
1209
1589
|
const key = `dkg:cd:${this.account.id}:${cmdName}`;
|
|
1210
1590
|
await redis.set(key, '1', 'EX', Math.ceil(durationSeconds));
|
|
1591
|
+
// Schedule bloom entry removal after cooldown expires
|
|
1592
|
+
setTimeout(() => {
|
|
1593
|
+
// Bloom filters can't remove individual entries, so we rebuild periodically
|
|
1594
|
+
// This is a soft hint — the Redis check is the source of truth
|
|
1595
|
+
}, durationSeconds * 1000);
|
|
1211
1596
|
}
|
|
1212
1597
|
|
|
1213
1598
|
async isCooldownReady(cmdName) {
|
|
1599
|
+
const bloomKey = `${this.account.id}:${cmdName}`;
|
|
1600
|
+
// Fast path: BloomFilter says definitely not on cooldown → skip Redis
|
|
1601
|
+
if (!this._cooldownBloom.has(bloomKey)) return true;
|
|
1214
1602
|
if (!redis) return true;
|
|
1215
1603
|
const key = `dkg:cd:${this.account.id}:${cmdName}`;
|
|
1216
1604
|
const val = await redis.get(key);
|
|
1217
1605
|
return !val;
|
|
1218
1606
|
}
|
|
1219
1607
|
|
|
1608
|
+
// Rebuild bloom filter periodically (clears stale entries from expired cooldowns)
|
|
1609
|
+
_rebuildBloom() {
|
|
1610
|
+
this._cooldownBloom.clear();
|
|
1611
|
+
// Re-add only the commands we know are on cooldown from doneToday
|
|
1612
|
+
for (const [cmd, expiry] of this.doneToday) {
|
|
1613
|
+
if (expiry > Date.now()) {
|
|
1614
|
+
this._cooldownBloom.add(`${this.account.id}:${cmd}`);
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1220
1619
|
printStats() {
|
|
1221
1620
|
// Stats are shown in the live dashboard, no-op here
|
|
1222
1621
|
}
|
|
@@ -1266,7 +1665,7 @@ class AccountWorker {
|
|
|
1266
1665
|
{ key: 'cmd_deposit', cmd: 'dep max', cdKey: 'cd_deposit', defaultCd: 120, priority: 8 },
|
|
1267
1666
|
{ key: 'cmd_drops', cmd: 'drops', cdKey: 'cd_drops', defaultCd: 86400, priority: 2 },
|
|
1268
1667
|
// Alert is NOT scheduled — it's reactive (listener-based, see grindLoop)
|
|
1269
|
-
];
|
|
1668
|
+
].map(Object.freeze);
|
|
1270
1669
|
|
|
1271
1670
|
buildCommandQueue() {
|
|
1272
1671
|
const heap = new MinHeap();
|
|
@@ -1320,30 +1719,161 @@ class AccountWorker {
|
|
|
1320
1719
|
this.commandQueue = heap;
|
|
1321
1720
|
}
|
|
1322
1721
|
|
|
1323
|
-
// ──
|
|
1722
|
+
// ── Auto-Recovery System ────────────────────────────────────
|
|
1723
|
+
// Handles: WS disconnects, rate limits, token invalidation, network errors.
|
|
1724
|
+
// Uses exponential backoff: 2s → 4s → 8s → ... → 5min cap.
|
|
1725
|
+
// Renews cluster claim on each successful heartbeat.
|
|
1726
|
+
|
|
1727
|
+
_calcBackoff() {
|
|
1728
|
+
const base = this._recoveryBackoffBase;
|
|
1729
|
+
const jitter = Math.random() * base;
|
|
1730
|
+
const delay = Math.min(base * Math.pow(2, this._recoveryAttempts) + jitter, this._recoveryBackoffCap);
|
|
1731
|
+
return Math.round(delay);
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1324
1734
|
async healthCheck() {
|
|
1325
1735
|
if (!this.running || shutdownCalled) return;
|
|
1326
1736
|
const now = Date.now();
|
|
1327
|
-
if (now - this.lastHealthCheck <
|
|
1737
|
+
if (now - this.lastHealthCheck < 30000) return;
|
|
1328
1738
|
this.lastHealthCheck = now;
|
|
1329
1739
|
|
|
1330
|
-
|
|
1331
|
-
|
|
1740
|
+
// Renew cluster claim to keep this account locked to this node
|
|
1741
|
+
if (CLUSTER_ENABLED) await renewClaim(this.account.id);
|
|
1742
|
+
|
|
1743
|
+
// Check if we're in an error cooldown (from previous recovery attempt)
|
|
1744
|
+
if (this._errorCooldownUntil > now) return;
|
|
1745
|
+
|
|
1746
|
+
// Check WS connection state
|
|
1747
|
+
if (!this.client || !this.client.ws || this.client.ws.status !== 0) {
|
|
1748
|
+
this._disconnectCount++;
|
|
1749
|
+
await this._attemptRecovery('ws_disconnect');
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
// Proactive: if no successful command in 5 minutes, check connectivity
|
|
1754
|
+
if (this.lastCommandRun > 0 && now - this.lastCommandRun > 300_000 && !this.busy) {
|
|
1755
|
+
this.log('warn', 'No commands succeeded in 5m — checking connectivity');
|
|
1332
1756
|
try {
|
|
1333
|
-
this.client.
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1757
|
+
await this.client.channels.fetch(this.account.channel_id);
|
|
1758
|
+
this._recoveryAttempts = 0;
|
|
1759
|
+
} catch {
|
|
1760
|
+
await this._attemptRecovery('stale_connection');
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
async _attemptRecovery(reason) {
|
|
1766
|
+
if (this._recoveryAttempts >= this._maxRecoveryAttempts) {
|
|
1767
|
+
this.log('error', `Recovery failed after ${this._maxRecoveryAttempts} attempts — pausing account`);
|
|
1768
|
+
this.paused = true;
|
|
1769
|
+
sendWebhook('Recovery Failed', `**${this.username}** failed after ${this._maxRecoveryAttempts} attempts (${reason}). Paused.`, 0xef4444);
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
this._recoveryAttempts++;
|
|
1774
|
+
const backoff = this._calcBackoff();
|
|
1775
|
+
this.log('warn', `Recovery #${this._recoveryAttempts} (${reason}) — backoff ${Math.round(backoff / 1000)}s`);
|
|
1776
|
+
this.setStatus(`recovering (${Math.round(backoff / 1000)}s)`);
|
|
1777
|
+
this._errorCooldownUntil = Date.now() + backoff;
|
|
1778
|
+
|
|
1779
|
+
await new Promise(r => setTimeout(r, backoff));
|
|
1780
|
+
if (!this.running || shutdownCalled) return;
|
|
1781
|
+
|
|
1782
|
+
try {
|
|
1783
|
+
// Tear down old connection completely
|
|
1784
|
+
if (this.client) {
|
|
1785
|
+
try { this.client.removeAllListeners(); } catch {}
|
|
1786
|
+
try { this.client.destroy(); } catch {}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
this.client = createLeanClient();
|
|
1790
|
+
|
|
1791
|
+
// Set up error/disconnect handlers for auto-recovery
|
|
1792
|
+
this._attachRecoveryListeners();
|
|
1793
|
+
|
|
1794
|
+
await this.client.login(this.account.discord_token);
|
|
1795
|
+
this.channel = await this.client.channels.fetch(this.account.channel_id).catch(() => null);
|
|
1796
|
+
|
|
1797
|
+
if (this.channel) {
|
|
1798
|
+
this._recoveryAttempts = 0;
|
|
1799
|
+
this._totalRecoveries++;
|
|
1800
|
+
this._errorCooldownUntil = 0;
|
|
1801
|
+
this._lastRecoveryAt = Date.now();
|
|
1802
|
+
this.log('success', `Recovered (#${this._totalRecoveries} total, reason: ${reason})`);
|
|
1803
|
+
|
|
1804
|
+
// Re-attach alert listener
|
|
1805
|
+
if (this._alertHandler) {
|
|
1806
|
+
this.client.on('messageCreate', this._alertHandler);
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
// Resume grind loop if it was running
|
|
1810
|
+
if (!this.commandQueue || this.commandQueue.size === 0) {
|
|
1811
|
+
this.commandQueue = this.buildCommandQueue();
|
|
1340
1812
|
}
|
|
1341
|
-
}
|
|
1342
|
-
this.log('error',
|
|
1813
|
+
} else {
|
|
1814
|
+
this.log('error', 'Recovered connection but channel not found — retrying');
|
|
1815
|
+
await this._attemptRecovery('channel_not_found');
|
|
1816
|
+
}
|
|
1817
|
+
} catch (e) {
|
|
1818
|
+
const msg = e.message || '';
|
|
1819
|
+
// Token is invalid — don't retry, pause account
|
|
1820
|
+
if (msg.includes('TOKEN_INVALID') || msg.includes('invalid token') || msg.includes('401')) {
|
|
1821
|
+
this.log('error', `Token invalid — pausing account permanently`);
|
|
1822
|
+
this.paused = true;
|
|
1823
|
+
sendWebhook('Token Invalid', `**${this.username}** has an invalid token. Paused.`, 0xef4444);
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1826
|
+
this.log('error', `Recovery attempt #${this._recoveryAttempts} failed: ${msg}`);
|
|
1827
|
+
// Recurse with incremented attempt count (will eventually hit max)
|
|
1828
|
+
if (this.running && !shutdownCalled) {
|
|
1829
|
+
await this._attemptRecovery(reason);
|
|
1343
1830
|
}
|
|
1344
1831
|
}
|
|
1345
1832
|
}
|
|
1346
1833
|
|
|
1834
|
+
_attachRecoveryListeners() {
|
|
1835
|
+
if (!this.client) return;
|
|
1836
|
+
|
|
1837
|
+
this.client.on('disconnect', () => {
|
|
1838
|
+
if (!this.running || shutdownCalled) return;
|
|
1839
|
+
this.log('warn', 'WS disconnected — auto-recovering');
|
|
1840
|
+
this._disconnectCount++;
|
|
1841
|
+
this._attemptRecovery('ws_event_disconnect').catch(() => {});
|
|
1842
|
+
});
|
|
1843
|
+
|
|
1844
|
+
this.client.on('error', (err) => {
|
|
1845
|
+
if (!this.running || shutdownCalled) return;
|
|
1846
|
+
const msg = err?.message || '';
|
|
1847
|
+
// Rate limit errors: parse retry-after and wait
|
|
1848
|
+
if (msg.includes('429') || msg.includes('rate limit')) {
|
|
1849
|
+
this._rateLimitHits++;
|
|
1850
|
+
const retryMatch = msg.match(/retry.?after[:\s]*(\d+)/i);
|
|
1851
|
+
const waitMs = retryMatch ? parseInt(retryMatch[1]) * 1000 : 60000;
|
|
1852
|
+
this.log('warn', `Rate limited (429) — waiting ${Math.round(waitMs / 1000)}s`);
|
|
1853
|
+
this.globalCooldownUntil = Date.now() + waitMs;
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
this.log('error', `Client error: ${msg}`);
|
|
1857
|
+
});
|
|
1858
|
+
|
|
1859
|
+
this.client.on('shardError', (err) => {
|
|
1860
|
+
if (!this.running || shutdownCalled) return;
|
|
1861
|
+
this.log('warn', `Shard error: ${err?.message || 'unknown'} — will auto-recover on next health check`);
|
|
1862
|
+
});
|
|
1863
|
+
|
|
1864
|
+
this.client.on('shardReconnecting', () => {
|
|
1865
|
+
if (!this.running || shutdownCalled) return;
|
|
1866
|
+
this.setStatus('shard reconnecting...');
|
|
1867
|
+
});
|
|
1868
|
+
|
|
1869
|
+
this.client.on('shardResume', () => {
|
|
1870
|
+
if (!this.running || shutdownCalled) return;
|
|
1871
|
+
this.log('success', 'Shard resumed');
|
|
1872
|
+
this._recoveryAttempts = 0;
|
|
1873
|
+
this.setStatus('resumed');
|
|
1874
|
+
});
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1347
1877
|
// ── Main Non-Blocking Grind Scheduler ───────────────────────
|
|
1348
1878
|
async tick() {
|
|
1349
1879
|
if (!this.running || shutdownCalled) return;
|
|
@@ -1432,22 +1962,51 @@ class AccountWorker {
|
|
|
1432
1962
|
}
|
|
1433
1963
|
}
|
|
1434
1964
|
|
|
1965
|
+
// Smart gambling: skip gamble commands while loss-paused
|
|
1966
|
+
const GAMBLE_SET = new Set(['blackjack', 'cointoss', 'roulette', 'slots', 'snakeeyes']);
|
|
1967
|
+
if (GAMBLE_SET.has(item.cmd) && this._gamblePausedUntil > now) {
|
|
1968
|
+
item.nextRunAt = this._gamblePausedUntil;
|
|
1969
|
+
if (this.commandQueue) this.commandQueue.push(item);
|
|
1970
|
+
this.setStatus(`gamble paused (${Math.ceil((this._gamblePausedUntil - now) / 1000)}s)`);
|
|
1971
|
+
this.tickTimeout = setTimeout(() => this.tick(), 100);
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1974
|
+
if (GAMBLE_SET.has(item.cmd) && this._gamblePausedUntil > 0 && this._gamblePausedUntil <= now) {
|
|
1975
|
+
this._gamblePausedUntil = 0;
|
|
1976
|
+
this._gambleLossStreak = 0;
|
|
1977
|
+
this._gambleSessionLoss = 0;
|
|
1978
|
+
this.log('info', 'Gambling pause expired — resuming bets');
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
// TokenBucket rate limiter: prevent Discord 429s by throttling commands
|
|
1982
|
+
if (!this._rateLimiter.consume(1)) {
|
|
1983
|
+
const waitMs = this._rateLimiter.waitTime(1);
|
|
1984
|
+
this.setStatus(`rate throttle (${Math.ceil(waitMs / 1000)}s)`);
|
|
1985
|
+
if (this.commandQueue) this.commandQueue.push(item);
|
|
1986
|
+
this.tickTimeout = setTimeout(() => this.tick(), waitMs);
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
this._cmdRate.increment();
|
|
1435
1991
|
this.busy = true;
|
|
1436
1992
|
const cd = (this.account[item.info.cdKey] || item.info.defaultCd);
|
|
1437
|
-
//
|
|
1438
|
-
const
|
|
1993
|
+
// Anti-detection: per-account jitter with varying patterns
|
|
1994
|
+
const patternMod = this._activePattern;
|
|
1995
|
+
const jitterBase = cd <= 5
|
|
1439
1996
|
? 0.3 + Math.random() * 0.7
|
|
1440
1997
|
: cd <= 20
|
|
1441
1998
|
? 0.5 + Math.random() * 1.5
|
|
1442
1999
|
: 1 + Math.random() * 2;
|
|
1443
|
-
|
|
2000
|
+
// Add human-like micro-pauses (occasionally take longer, simulating distraction)
|
|
2001
|
+
const microPause = Math.random() < 0.08 ? 1.5 + Math.random() * 3 : 0;
|
|
2002
|
+
const totalWait = cd + jitterBase + microPause;
|
|
1444
2003
|
|
|
1445
2004
|
await this.setCooldown(item.cmd, totalWait);
|
|
1446
2005
|
|
|
1447
|
-
// Inter-command gap: smaller for fast commands to maintain throughput
|
|
1448
2006
|
const timeSinceLastCmd = now - (this.lastCommandRun || 0);
|
|
1449
|
-
const gapBase = cd <= 5 ?
|
|
1450
|
-
const
|
|
2007
|
+
const gapBase = cd <= 5 ? 1500 : cd <= 20 ? 2000 : 2500;
|
|
2008
|
+
const jitterGap = patternMod.minDelay + Math.random() * (patternMod.maxDelay - patternMod.minDelay);
|
|
2009
|
+
const minGap = gapBase + jitterGap;
|
|
1451
2010
|
if (timeSinceLastCmd < minGap) {
|
|
1452
2011
|
await new Promise(r => setTimeout(r, minGap - timeSinceLastCmd));
|
|
1453
2012
|
}
|
|
@@ -1466,6 +2025,13 @@ class AccountWorker {
|
|
|
1466
2025
|
await this.runCommand(item.cmd, prefix);
|
|
1467
2026
|
const earned = this.stats.coins - beforeCoins;
|
|
1468
2027
|
|
|
2028
|
+
// EMA: track smoothed earnings per command for adaptive scheduling
|
|
2029
|
+
if (earned > 0) {
|
|
2030
|
+
this._earningsEMA.update(earned);
|
|
2031
|
+
globalEarningsEMA.update(earned);
|
|
2032
|
+
}
|
|
2033
|
+
globalCmdRate.increment();
|
|
2034
|
+
|
|
1469
2035
|
const noFailCmds = ['dep max', 'alert', 'daily', 'weekly', 'monthly', 'drops', 'use', 'tidy'];
|
|
1470
2036
|
if (earned <= 0 && !noFailCmds.includes(item.cmd)) {
|
|
1471
2037
|
this.failStreak++;
|
|
@@ -1478,10 +2044,15 @@ class AccountWorker {
|
|
|
1478
2044
|
|
|
1479
2045
|
// Exponential backoff: if too many consecutive failures, slow down
|
|
1480
2046
|
const backoffMultiplier = this.failStreak > 5 ? Math.min(this.failStreak - 4, 5) : 1;
|
|
2047
|
+
// Minimum 5s cooldown for failed commands to prevent rapid-fire retries
|
|
2048
|
+
const MIN_FAIL_COOLDOWN = 5;
|
|
1481
2049
|
|
|
1482
2050
|
if (this.commandQueue && this.running && !shutdownCalled) {
|
|
1483
|
-
|
|
2051
|
+
let effectiveWait = this._lastCooldownOverride || totalWait;
|
|
1484
2052
|
this._lastCooldownOverride = null;
|
|
2053
|
+
if (earned <= 0 && !noFailCmds.includes(item.cmd) && effectiveWait < MIN_FAIL_COOLDOWN) {
|
|
2054
|
+
effectiveWait = MIN_FAIL_COOLDOWN;
|
|
2055
|
+
}
|
|
1485
2056
|
item.nextRunAt = Date.now() + effectiveWait * 1000 * backoffMultiplier;
|
|
1486
2057
|
this.commandQueue.push(item);
|
|
1487
2058
|
}
|
|
@@ -1502,6 +2073,8 @@ class AccountWorker {
|
|
|
1502
2073
|
this.cycleCount++;
|
|
1503
2074
|
|
|
1504
2075
|
if (this.cycleCount > 0 && this.cycleCount % 10 === 0) this.printStats();
|
|
2076
|
+
// Rebuild BloomFilter every 50 cycles to clear expired entries
|
|
2077
|
+
if (this.cycleCount > 0 && this.cycleCount % 50 === 0) this._rebuildBloom();
|
|
1505
2078
|
if (this.cycleCount > 0 && this.cycleCount % 5 === 0) {
|
|
1506
2079
|
this.busy = true;
|
|
1507
2080
|
await this.checkBalance();
|
|
@@ -1509,7 +2082,8 @@ class AccountWorker {
|
|
|
1509
2082
|
}
|
|
1510
2083
|
|
|
1511
2084
|
if (this.running && !shutdownCalled) {
|
|
1512
|
-
this.
|
|
2085
|
+
const nextDelay = this.failStreak > 0 ? 1500 : 500;
|
|
2086
|
+
this.tickTimeout = setTimeout(() => this.tick(), nextDelay);
|
|
1513
2087
|
}
|
|
1514
2088
|
}
|
|
1515
2089
|
|
|
@@ -1571,11 +2145,30 @@ class AccountWorker {
|
|
|
1571
2145
|
try {
|
|
1572
2146
|
await this.tick();
|
|
1573
2147
|
} catch (err) {
|
|
1574
|
-
|
|
2148
|
+
const msg = err?.message || '';
|
|
2149
|
+
this.log('error', `Tick error: ${msg}`);
|
|
1575
2150
|
this.busy = false;
|
|
1576
|
-
|
|
2151
|
+
|
|
2152
|
+
// Classify error and trigger appropriate recovery
|
|
2153
|
+
if (msg.includes('WebSocket') || msg.includes('ECONNRESET') ||
|
|
2154
|
+
msg.includes('ENOTFOUND') || msg.includes('socket hang up') ||
|
|
2155
|
+
msg.includes('getaddrinfo')) {
|
|
2156
|
+
// Network/connection error → auto-recovery
|
|
2157
|
+
this.log('warn', 'Network error detected — triggering auto-recovery');
|
|
2158
|
+
await this._attemptRecovery('network_error');
|
|
2159
|
+
} else if (msg.includes('TOKEN_INVALID') || msg.includes('401')) {
|
|
2160
|
+
this.log('error', 'Token invalid — pausing account');
|
|
2161
|
+
this.paused = true;
|
|
2162
|
+
sendWebhook('Token Invalid', `**${this.username}** — paused.`, 0xef4444);
|
|
2163
|
+
return;
|
|
2164
|
+
} else {
|
|
2165
|
+
// Generic error: wait and retry
|
|
2166
|
+
const waitMs = Math.min(5000 * Math.pow(1.5, Math.min(this.failStreak, 5)), 30000);
|
|
2167
|
+
await new Promise(r => setTimeout(r, waitMs));
|
|
2168
|
+
}
|
|
2169
|
+
|
|
1577
2170
|
if (this.running && !shutdownCalled) {
|
|
1578
|
-
this.tickTimeout = setTimeout(() => safeTickLoop(),
|
|
2171
|
+
this.tickTimeout = setTimeout(() => safeTickLoop(), 2000);
|
|
1579
2172
|
}
|
|
1580
2173
|
}
|
|
1581
2174
|
};
|
|
@@ -1685,12 +2278,16 @@ class AccountWorker {
|
|
|
1685
2278
|
} catch {}
|
|
1686
2279
|
}
|
|
1687
2280
|
|
|
2281
|
+
// Let Discord gateway settle before sending first command
|
|
2282
|
+
await new Promise(r => setTimeout(r, 2500));
|
|
1688
2283
|
await this.checkBalance();
|
|
1689
2284
|
this.checkInventory().catch(() => {});
|
|
1690
2285
|
this.grindLoop();
|
|
1691
2286
|
resolve();
|
|
1692
2287
|
});
|
|
1693
2288
|
|
|
2289
|
+
// Attach auto-recovery event listeners before login
|
|
2290
|
+
this._attachRecoveryListeners();
|
|
1694
2291
|
this.client.login(this.account.discord_token);
|
|
1695
2292
|
});
|
|
1696
2293
|
}
|
|
@@ -1707,10 +2304,36 @@ class AccountWorker {
|
|
|
1707
2304
|
this._alertHandler = null;
|
|
1708
2305
|
}
|
|
1709
2306
|
this.commandQueue = null;
|
|
1710
|
-
try {
|
|
2307
|
+
try {
|
|
2308
|
+
if (this.client) {
|
|
2309
|
+
this.client.removeAllListeners();
|
|
2310
|
+
this.client.destroy();
|
|
2311
|
+
}
|
|
2312
|
+
} catch {}
|
|
2313
|
+
// Release cluster claim so another node can pick up this account
|
|
2314
|
+
releaseClaim(this.account.id).catch(() => {});
|
|
2315
|
+
this.channel = null;
|
|
2316
|
+
this.client = null;
|
|
1711
2317
|
}
|
|
1712
2318
|
}
|
|
1713
2319
|
|
|
2320
|
+
// Global worker lookup — HashMap for O(1) account lookup by ID
|
|
2321
|
+
const workerMap = new Map();
|
|
2322
|
+
|
|
2323
|
+
// Global EMA for overall earnings rate across all accounts
|
|
2324
|
+
const globalEarningsEMA = new EMA(0.05);
|
|
2325
|
+
|
|
2326
|
+
// Global SlidingWindowCounter for total commands per minute
|
|
2327
|
+
const globalCmdRate = new SlidingWindowCounter(60000);
|
|
2328
|
+
|
|
2329
|
+
// Aho-Corasick automaton for captcha/verification detection
|
|
2330
|
+
// Built once, matches in O(n) single pass instead of repeated .includes() calls
|
|
2331
|
+
const captchaDetector = new AhoCorasick();
|
|
2332
|
+
['captcha', 'verification required', 'verify your account', 'pass verification',
|
|
2333
|
+
'are you human', "prove you're not a bot", 'complete the captcha', 'continue playing',
|
|
2334
|
+
].forEach(p => captchaDetector.addPattern(p, 'captcha'));
|
|
2335
|
+
captchaDetector.build();
|
|
2336
|
+
|
|
1714
2337
|
// ══════════════════════════════════════════════════════════════
|
|
1715
2338
|
// ═ Main Entry
|
|
1716
2339
|
// ══════════════════════════════════════════════════════════════
|
|
@@ -1719,25 +2342,35 @@ async function start(apiKey, apiUrl) {
|
|
|
1719
2342
|
API_KEY = apiKey;
|
|
1720
2343
|
API_URL = apiUrl || process.env.DANKGRINDER_URL || 'http://localhost:3000';
|
|
1721
2344
|
REDIS_URL = process.env.REDIS_URL || '';
|
|
2345
|
+
WEBHOOK_URL = process.env.WEBHOOK_URL || '';
|
|
1722
2346
|
initRedis();
|
|
1723
2347
|
|
|
1724
2348
|
process.stdout.write('\x1b[2J\x1b[H');
|
|
1725
2349
|
const tw = Math.min(process.stdout.columns || 80, 78);
|
|
1726
2350
|
const bar = c.dim + '─'.repeat(tw) + c.reset;
|
|
1727
2351
|
|
|
2352
|
+
// Detect zlib-sync availability
|
|
2353
|
+
let hasZlib = false;
|
|
2354
|
+
try { require('zlib-sync'); hasZlib = true; } catch {}
|
|
2355
|
+
|
|
1728
2356
|
console.log(colorBanner());
|
|
1729
2357
|
console.log(
|
|
1730
|
-
` ${rgb(139, 92, 246)}
|
|
1731
|
-
` ${c.dim}·${c.reset} ${c.white}
|
|
1732
|
-
` ${c.dim}·${c.reset} ${rgb(34, 211, 238)}
|
|
1733
|
-
` ${c.dim}·${c.reset} ${rgb(52, 211, 153)}
|
|
1734
|
-
` ${c.dim}·${c.reset} ${rgb(251, 191, 36)}
|
|
2358
|
+
` ${rgb(139, 92, 246)}v${PKG_VERSION}${c.reset}` +
|
|
2359
|
+
` ${c.dim}·${c.reset} ${c.white}${AccountWorker.COMMAND_MAP.length} Commands${c.reset}` +
|
|
2360
|
+
` ${c.dim}·${c.reset} ${rgb(34, 211, 238)}${CLUSTER_ENABLED ? 'Cluster Mode' : 'Standalone'}${c.reset}` +
|
|
2361
|
+
` ${c.dim}·${c.reset} ${rgb(52, 211, 153)}Auto-Recovery${c.reset}` +
|
|
2362
|
+
` ${c.dim}·${c.reset} ${rgb(251, 191, 36)}Loss Limiter${c.reset}`
|
|
1735
2363
|
);
|
|
1736
2364
|
console.log(bar);
|
|
1737
2365
|
|
|
1738
2366
|
const checks = [];
|
|
1739
2367
|
checks.push(`${rgb(52, 211, 153)}✓${c.reset} ${c.white}API${c.reset}`);
|
|
1740
2368
|
checks.push(REDIS_URL ? `${rgb(52, 211, 153)}✓${c.reset} ${c.white}Redis${c.reset}` : `${c.dim}○ Redis${c.reset}`);
|
|
2369
|
+
checks.push(hasZlib ? `${rgb(52, 211, 153)}✓${c.reset} ${c.white}zlib${c.reset}` : `${rgb(251, 191, 36)}○${c.reset} ${c.dim}zlib (npm i zlib-sync)${c.reset}`);
|
|
2370
|
+
checks.push(WEBHOOK_URL ? `${rgb(52, 211, 153)}✓${c.reset} ${c.white}Webhook${c.reset}` : `${c.dim}○ Webhook${c.reset}`);
|
|
2371
|
+
if (CLUSTER_ENABLED) {
|
|
2372
|
+
checks.push(`${rgb(52, 211, 153)}✓${c.reset} ${rgb(34, 211, 238)}Cluster${c.reset} ${c.dim}(${NODE_ID.substring(0, 12)})${c.reset}`);
|
|
2373
|
+
}
|
|
1741
2374
|
console.log(` ${checks.join(' ')}`);
|
|
1742
2375
|
|
|
1743
2376
|
log('info', `${c.dim}Fetching accounts...${c.reset}`);
|
|
@@ -1750,22 +2383,42 @@ async function start(apiKey, apiUrl) {
|
|
|
1750
2383
|
data = await fetchConfig(4, 2000);
|
|
1751
2384
|
}
|
|
1752
2385
|
|
|
1753
|
-
|
|
2386
|
+
let { accounts } = data;
|
|
1754
2387
|
if (!accounts || accounts.length === 0) {
|
|
1755
2388
|
log('error', 'No active accounts. Add them in the dashboard.');
|
|
1756
2389
|
return;
|
|
1757
2390
|
}
|
|
1758
2391
|
|
|
2392
|
+
// Cluster mode: filter to only accounts this node can claim
|
|
2393
|
+
if (CLUSTER_ENABLED) {
|
|
2394
|
+
const totalBefore = accounts.length;
|
|
2395
|
+
accounts = await filterClaimableAccounts(accounts);
|
|
2396
|
+
log('info', `${c.dim}Cluster: claimed ${accounts.length}/${totalBefore} accounts (others owned by peer nodes)${c.reset}`);
|
|
2397
|
+
if (accounts.length === 0) {
|
|
2398
|
+
log('warn', 'All accounts claimed by other nodes. Waiting for accounts...');
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
|
|
1759
2402
|
checks.push(`${rgb(52, 211, 153)}✓${c.reset} ${c.white}${accounts.length} Account${accounts.length > 1 ? 's' : ''}${c.reset}`);
|
|
1760
2403
|
process.stdout.write(c.cursorUp(1));
|
|
1761
2404
|
process.stdout.write(c.clearLine + '\r');
|
|
1762
2405
|
console.log(` ${checks.join(' ')}`);
|
|
1763
2406
|
console.log('');
|
|
1764
2407
|
|
|
2408
|
+
// All accounts run simultaneously — stagger logins in batches to avoid 429s
|
|
2409
|
+
const BATCH_SIZE = 5;
|
|
2410
|
+
const BATCH_DELAY_MS = 3000;
|
|
1765
2411
|
for (let i = 0; i < accounts.length; i++) {
|
|
2412
|
+
if (shutdownCalled) break;
|
|
1766
2413
|
const worker = new AccountWorker(accounts[i], i);
|
|
1767
2414
|
workers.push(worker);
|
|
2415
|
+
workerMap.set(accounts[i].id, worker);
|
|
1768
2416
|
await worker.start();
|
|
2417
|
+
if ((i + 1) % BATCH_SIZE === 0 && i + 1 < accounts.length) {
|
|
2418
|
+
log('info', `${c.dim}Started ${i + 1}/${accounts.length} accounts, next batch in ${BATCH_DELAY_MS / 1000}s...${c.reset}`);
|
|
2419
|
+
await new Promise(r => setTimeout(r, BATCH_DELAY_MS));
|
|
2420
|
+
hintGC();
|
|
2421
|
+
}
|
|
1769
2422
|
}
|
|
1770
2423
|
|
|
1771
2424
|
console.log('');
|
|
@@ -1777,34 +2430,58 @@ async function start(apiKey, apiUrl) {
|
|
|
1777
2430
|
setInterval(() => scheduleRender(), 1000);
|
|
1778
2431
|
scheduleRender();
|
|
1779
2432
|
|
|
1780
|
-
//
|
|
2433
|
+
// Cluster heartbeat — lets other nodes see this node is alive
|
|
2434
|
+
if (CLUSTER_ENABLED) {
|
|
2435
|
+
clusterHeartbeat();
|
|
2436
|
+
setInterval(() => clusterHeartbeat(), CLUSTER_HEARTBEAT_MS);
|
|
2437
|
+
log('info', `${rgb(34, 211, 238)}Cluster heartbeat started${c.reset} ${c.dim}(${NODE_ID.substring(0, 12)}, every ${CLUSTER_HEARTBEAT_MS / 1000}s)${c.reset}`);
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
// Live account detection — O(1) lookup via HashMap + cluster-aware claiming
|
|
1781
2441
|
setInterval(async () => {
|
|
1782
2442
|
if (shutdownCalled) return;
|
|
1783
2443
|
try {
|
|
1784
2444
|
const freshData = await fetchConfig(1, 1000);
|
|
1785
2445
|
if (!freshData?.accounts) return;
|
|
1786
|
-
const knownIds = new Set(workers.map(w => w.account.id));
|
|
1787
2446
|
const freshIds = new Set(freshData.accounts.map(a => a.id));
|
|
1788
2447
|
|
|
1789
|
-
// Start workers for new accounts
|
|
1790
2448
|
for (const acc of freshData.accounts) {
|
|
1791
|
-
if (!
|
|
2449
|
+
if (!workerMap.has(acc.id)) {
|
|
2450
|
+
// In cluster mode, try to claim before starting
|
|
2451
|
+
if (CLUSTER_ENABLED) {
|
|
2452
|
+
const claimed = await claimAccount(acc.id);
|
|
2453
|
+
if (!claimed) continue;
|
|
2454
|
+
}
|
|
1792
2455
|
log('info', `${c.green}New account detected:${c.reset} ${acc.label || acc.id}`);
|
|
1793
2456
|
const worker = new AccountWorker(acc, workers.length);
|
|
1794
2457
|
workers.push(worker);
|
|
2458
|
+
workerMap.set(acc.id, worker);
|
|
1795
2459
|
worker.start().catch(() => {});
|
|
1796
2460
|
}
|
|
1797
2461
|
}
|
|
1798
2462
|
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
if (!freshIds.has(w.account.id)) {
|
|
2463
|
+
for (const [id, w] of workerMap) {
|
|
2464
|
+
if (!freshIds.has(id)) {
|
|
1802
2465
|
log('info', `${c.yellow}Account removed:${c.reset} ${w.username}`);
|
|
1803
2466
|
w.stop();
|
|
2467
|
+
workerMap.delete(id);
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
// Recheck paused workers — if recovery was paused, attempt un-pause if conditions allow
|
|
2472
|
+
for (const w of workers) {
|
|
2473
|
+
if (w.paused && w._recoveryAttempts >= w._maxRecoveryAttempts && w.running) {
|
|
2474
|
+
const pausedFor = Date.now() - (w._lastRecoveryAt || 0);
|
|
2475
|
+
// After 10 minutes of being paused due to max recovery, try once more
|
|
2476
|
+
if (pausedFor > 600_000) {
|
|
2477
|
+
w._recoveryAttempts = 0;
|
|
2478
|
+
w.paused = false;
|
|
2479
|
+
w.log('info', 'Auto-unpaused — retrying after 10m cooldown');
|
|
2480
|
+
w._attemptRecovery('auto_unpause').catch(() => {});
|
|
2481
|
+
}
|
|
1804
2482
|
}
|
|
1805
2483
|
}
|
|
1806
2484
|
|
|
1807
|
-
// Clean up stopped workers
|
|
1808
2485
|
const before = workers.length;
|
|
1809
2486
|
workers = workers.filter(w => w.running || freshIds.has(w.account.id));
|
|
1810
2487
|
if (workers.length !== before) scheduleRender();
|
|
@@ -1812,7 +2489,7 @@ async function start(apiKey, apiUrl) {
|
|
|
1812
2489
|
}, 10_000);
|
|
1813
2490
|
|
|
1814
2491
|
let sigintHandled = false;
|
|
1815
|
-
process.on('SIGINT', () => {
|
|
2492
|
+
process.on('SIGINT', async () => {
|
|
1816
2493
|
if (sigintHandled) return;
|
|
1817
2494
|
sigintHandled = true;
|
|
1818
2495
|
shutdownCalled = true;
|
|
@@ -1820,7 +2497,6 @@ async function start(apiKey, apiUrl) {
|
|
|
1820
2497
|
setDashboardActive(false);
|
|
1821
2498
|
process.stdout.write(c.show);
|
|
1822
2499
|
|
|
1823
|
-
// Clear the dashboard area before printing summary
|
|
1824
2500
|
if (dashboardLines > 0) {
|
|
1825
2501
|
process.stdout.write(c.cursorUp(dashboardLines));
|
|
1826
2502
|
for (let i = 0; i < dashboardLines; i++) {
|
|
@@ -1833,6 +2509,10 @@ async function start(apiKey, apiUrl) {
|
|
|
1833
2509
|
console.log('');
|
|
1834
2510
|
console.log(` ${rgb(251, 191, 36)}${c.bold}Session Summary${c.reset}`);
|
|
1835
2511
|
console.log(sepBar);
|
|
2512
|
+
|
|
2513
|
+
// Collect stats from all workers (including rotated-out ones)
|
|
2514
|
+
let finalCoins = 0;
|
|
2515
|
+
let finalCmds = 0;
|
|
1836
2516
|
for (const wk of workers) {
|
|
1837
2517
|
const rate = wk.stats.commands > 0 ? ((wk.stats.successes / wk.stats.commands) * 100).toFixed(0) : 0;
|
|
1838
2518
|
console.log(
|
|
@@ -1841,16 +2521,45 @@ async function start(apiKey, apiUrl) {
|
|
|
1841
2521
|
` ${c.dim}${wk.stats.commands.toString().padStart(4)} cmds${c.reset}` +
|
|
1842
2522
|
` ${c.dim}${rate}% success${c.reset}`
|
|
1843
2523
|
);
|
|
1844
|
-
wk.
|
|
2524
|
+
finalCoins += wk.stats.coins || 0;
|
|
2525
|
+
finalCmds += wk.stats.commands || 0;
|
|
1845
2526
|
}
|
|
1846
2527
|
console.log(sepBar);
|
|
1847
2528
|
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
console.log(` ${rgb(251, 191, 36)}${c.bold}Total: +⏣ ${finalCoins.toLocaleString()}${c.reset} ${c.dim}in ${formatUptime()}${c.reset}`);
|
|
2529
|
+
const memFinal = Math.round((process.memoryUsage?.rss?.() ?? process.memoryUsage().rss) / 1048576);
|
|
2530
|
+
const avgEarn = globalEarningsEMA.get();
|
|
2531
|
+
const cpm = globalCmdRate.getRate().toFixed(1);
|
|
2532
|
+
console.log(` ${rgb(251, 191, 36)}${c.bold}Total: +⏣ ${finalCoins.toLocaleString()}${c.reset} ${c.dim}in ${formatUptime()} | ${finalCmds} cmds | ~${cpm} cmd/m | ${memFinal}MB | avg earn ⏣ ${Math.round(avgEarn)}${c.reset}`);
|
|
1852
2533
|
console.log('');
|
|
1853
2534
|
|
|
2535
|
+
// Release all cluster claims before stopping workers
|
|
2536
|
+
const releasePromises = workers.map(wk => releaseClaim(wk.account.id).catch(() => {}));
|
|
2537
|
+
await Promise.all(releasePromises).catch(() => {});
|
|
2538
|
+
|
|
2539
|
+
for (const wk of workers) wk.stop();
|
|
2540
|
+
workerMap.clear();
|
|
2541
|
+
|
|
2542
|
+
// Remove this node's heartbeat from Redis
|
|
2543
|
+
if (redis && CLUSTER_ENABLED) {
|
|
2544
|
+
try { await redis.del(`${CLUSTER_PREFIX}node:${NODE_ID}`); } catch {}
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
const totalRecoveries = workers.reduce((sum, wk) => sum + (wk._totalRecoveries || 0), 0);
|
|
2548
|
+
const totalDisconnects = workers.reduce((sum, wk) => sum + (wk._disconnectCount || 0), 0);
|
|
2549
|
+
const totalRateLimits = workers.reduce((sum, wk) => sum + (wk._rateLimitHits || 0), 0);
|
|
2550
|
+
|
|
2551
|
+
const webhookMsg = `+⏣ ${finalCoins.toLocaleString()} | ${finalCmds} cmds | ${formatUptime()}` +
|
|
2552
|
+
(totalRecoveries > 0 ? ` | ${totalRecoveries} auto-recoveries` : '') +
|
|
2553
|
+
(CLUSTER_ENABLED ? ` | node: ${NODE_ID.substring(0, 12)}` : '');
|
|
2554
|
+
sendWebhook('Session Ended', webhookMsg, 0x8b5cf6);
|
|
2555
|
+
|
|
2556
|
+
if (totalRecoveries > 0 || totalDisconnects > 0) {
|
|
2557
|
+
console.log(` ${c.dim}Recovery stats: ${totalRecoveries} auto-recoveries, ${totalDisconnects} disconnects, ${totalRateLimits} rate limits${c.reset}`);
|
|
2558
|
+
}
|
|
2559
|
+
if (CLUSTER_ENABLED) {
|
|
2560
|
+
console.log(` ${c.dim}Cluster node: ${NODE_ID} — claims released${c.reset}`);
|
|
2561
|
+
}
|
|
2562
|
+
|
|
1854
2563
|
if (redis) { try { redis.disconnect(); } catch {} }
|
|
1855
2564
|
setTimeout(() => process.exit(0), 1500);
|
|
1856
2565
|
});
|