dankgrinder 6.39.0 → 6.42.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.
@@ -1,17 +1,20 @@
1
1
  /**
2
2
  * Beg command handler.
3
3
  * Simple command: send "pls beg", parse coins from response.
4
+ * Detects: positive coins, Life Saver drops, negative (no coins).
4
5
  */
5
6
 
6
7
  const { LOG, c, getFullText, parseCoins, logMsg, isHoldTight, getHoldTightReason, sleep } = require('./utils');
7
8
 
8
9
  const RE_NEWLINE = /\n/g;
10
+ const RE_LIFE_SAVER = /life\s*saver/i;
11
+ const RE_BEG_COINS = /you\s+received\s*:\s*[\s\S]{0,200}?([\d,]+)/i;
9
12
 
10
13
  /**
11
14
  * @param {object} opts
12
15
  * @param {object} opts.channel
13
16
  * @param {function} opts.waitForDankMemer
14
- * @returns {Promise<{result: string, coins: number}>}
17
+ * @returns {Promise<{result: string, coins: number, lifeSaver?: boolean}>}
15
18
  */
16
19
  async function runBeg({ channel, waitForDankMemer }) {
17
20
  LOG.cmd(`${c.white}${c.bold}pls beg${c.reset}`);
@@ -33,11 +36,30 @@ async function runBeg({ channel, waitForDankMemer }) {
33
36
 
34
37
  logMsg(response, 'beg');
35
38
  const text = getFullText(response);
36
- const coins = parseCoins(text);
39
+
40
+ // Extract coins: prefer "You received" pattern for beg responses
41
+ let coins = 0;
42
+ const begMatch = text.match(RE_BEG_COINS);
43
+ if (begMatch) {
44
+ coins = parseInt(begMatch[1].replace(/,/g, ''), 10) || 0;
45
+ }
46
+ // Fallback to general parseCoins
47
+ if (coins === 0) {
48
+ coins = parseCoins(text);
49
+ }
50
+
51
+ // Detect Life Saver drops
52
+ const lifeSaver = RE_LIFE_SAVER.test(text);
37
53
 
38
54
  if (coins > 0) {
39
- LOG.coin(`[beg] ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`);
40
- return { result: `beg ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`, coins };
55
+ const lsNote = lifeSaver ? ` + ${c.cyan}Life Saver${c.reset}` : '';
56
+ LOG.coin(`[beg] ${c.green}+⏣ ${coins.toLocaleString()}${lsNote}${c.reset}`);
57
+ return { result: `beg → ${c.green}+⏣ ${coins.toLocaleString()}${lifeSaver ? ' + Life Saver' : ''}${c.reset}`, coins, lifeSaver };
58
+ }
59
+
60
+ if (lifeSaver) {
61
+ LOG.coin(`[beg] ${c.cyan}Life Saver${c.reset} (no coins)`);
62
+ return { result: 'beg → Life Saver', coins: 0, lifeSaver };
41
63
  }
42
64
 
43
65
  LOG.info(`[beg] ${text.substring(0, 80).replace(RE_NEWLINE, ' ')}`);
@@ -11,10 +11,27 @@ const sharp = require('sharp');
11
11
  const https = require('https');
12
12
  const http = require('http');
13
13
 
14
+ // Allowed CDN hostnames for image downloads (prevents SSRF).
15
+ const ALLOWED_IMAGE_HOSTS = new Set([
16
+ 'cdn.discordapp.com',
17
+ 'media.discordapp.net',
18
+ 'images.discordapp.net',
19
+ ]);
20
+
21
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5 MB cap — prevents memory exhaustion
22
+
14
23
  /**
15
24
  * Download an image from a URL and return as Buffer.
25
+ * Only allows Discord CDN URLs and enforces a max size limit.
16
26
  */
17
27
  function downloadImage(url) {
28
+ // ── SSRF check ──────────────────────────────────────────────
29
+ let hostname;
30
+ try { hostname = new URL(url).hostname; } catch { return Promise.reject(new Error('invalid URL')); }
31
+ if (!ALLOWED_IMAGE_HOSTS.has(hostname)) {
32
+ return Promise.reject(new Error(`disallowed host: ${hostname}`));
33
+ }
34
+
18
35
  return new Promise((resolve, reject) => {
19
36
  const proto = url.startsWith('https') ? https : http;
20
37
  const req = proto.get(url, res => {
@@ -22,7 +39,16 @@ function downloadImage(url) {
22
39
  return downloadImage(res.headers.location).then(resolve, reject);
23
40
  }
24
41
  const chunks = [];
25
- res.on('data', c => chunks.push(c));
42
+ let bytesReceived = 0;
43
+ res.on('data', c => {
44
+ bytesReceived += c.length;
45
+ if (bytesReceived > MAX_IMAGE_BYTES) {
46
+ req.destroy();
47
+ reject(new Error(`image too large: ${bytesReceived} bytes (max ${MAX_IMAGE_BYTES})`));
48
+ return;
49
+ }
50
+ chunks.push(c);
51
+ });
26
52
  res.on('end', () => resolve(Buffer.concat(chunks)));
27
53
  res.on('error', reject);
28
54
  });
@@ -13,7 +13,6 @@ const { runHunt } = require('./hunt');
13
13
  const { runDig } = require('./dig');
14
14
  const { runFish, sellAllFish } = require('./fish');
15
15
  const { runPostMemes } = require('./postmemes');
16
- const { runScratch } = require('./scratch');
17
16
  const { runBlackjack } = require('./blackjack');
18
17
  const { runTrivia, triviaDB } = require('./trivia');
19
18
  const { runWorkShift } = require('./work');
@@ -23,7 +22,7 @@ const { runGeneric, runAlert } = require('./generic');
23
22
  const { runStream } = require('./stream');
24
23
  const { runDrops } = require('./drops');
25
24
  const { buyItem, ITEM_COSTS } = require('./shop');
26
- const { getPlayerLevel, meetsLevelRequirement } = require('./profile');
25
+ const { getPlayerLevel, meetsLevelRequirement, runProfile } = require('./profile');
27
26
  const { runInventory, fetchItemValues, enrichItems, getCachedInventory, getAllInventories, updateInventoryItem, deleteInventoryItem } = require('./inventory');
28
27
 
29
28
  module.exports = {
@@ -39,7 +38,6 @@ module.exports = {
39
38
  runFish,
40
39
  sellAllFish,
41
40
  runPostMemes,
42
- runScratch,
43
41
  runBlackjack,
44
42
  runTrivia,
45
43
  runWorkShift,
@@ -66,6 +64,7 @@ module.exports = {
66
64
  // Profile / Level
67
65
  getPlayerLevel,
68
66
  meetsLevelRequirement,
67
+ runProfile,
69
68
 
70
69
  // Constants
71
70
  SAFE_SEARCH_LOCATIONS,
@@ -1,49 +1,170 @@
1
+ "use strict";
2
+
1
3
  /**
2
- * Profile level checker.
3
- * Send "pls profile", parse level, cache in Redis.
4
- * Used to gate commands like scratch (requires level 25).
4
+ * Profile command — parses Dank Memer `pls profile` responses.
5
+ * Extracts: level, XP, badges (emoji IDs), wallet/bank/net, items, commands, pets, showcase.
5
6
  */
6
7
 
7
8
  const {
8
- LOG, c, getFullText, logMsg, isHoldTight, sleep,
9
+ LOG, c, getFullText, logMsg, isHoldTight, sleep, stripAnsi,
9
10
  } = require('./utils');
10
11
 
11
- // In-memory cache: { accountId: { level: number, checkedAt: timestamp } }
12
- const levelCache = {};
13
12
  const CACHE_TTL = 10 * 60 * 1000; // 10 minutes
14
13
 
15
- const RE_PROFILE_LEVEL = /(?:level|lvl)\s*:?\s*`?(\d+)`?/i;
16
- const RE_PROFILE_PRESTIGE_LEVEL = /prestige\s+\d+\s+level\s+`?(\d+)`?/i;
14
+ // ── Regex patterns for each profile field ────────────────────────────────────
17
15
 
18
- /**
19
- * Parse level from profile response text.
20
- * Looks for patterns like "Level 25", "Lvl 25", "level: 25"
21
- */
22
- function parseLevelFromText(text) {
23
- const match = text.match(RE_PROFILE_LEVEL);
24
- if (match) return parseInt(match[1]);
25
- const presMatch = text.match(RE_PROFILE_PRESTIGE_LEVEL);
26
- if (presMatch) return parseInt(presMatch[1]);
27
- return null;
16
+ // "Level: `73`" or "Level: Level: `73`"
17
+ const RE_LEVEL = /(?:global\s+)?level[:\s]+[`'"]?(\d+)[`'"]?/i;
18
+
19
+ // "Experience: `69/100`"
20
+ const RE_XP = /experience[:\s]+`?(\d+)\s*\/\s*(\d+)`?/i;
21
+
22
+ // Badges: "<:PB1C:123456789>..." (custom Discord emojis)
23
+ const RE_BADGE = /<a?:(\w+):(\d+)>/g;
24
+
25
+ // "Wallet: `⏣ 3.2M`"
26
+ const RE_WALLET = /wallet[:\s]+[`⏣]?\s*([\d,.KMB]+(?:[KMB])?(?:\.\d+[KMB])?)/i;
27
+ const RE_BANK = /bank[:\s]+[`⏣]?\s*([\d,.KMB]+(?:[KMB])?(?:\.\d+[KMB])?)/i;
28
+ const RE_NET = /net[:\s]+[`⏣]?\s*([\d,.KMB]+(?:[KMB])?(?:\.\d+[KMB])?)/i;
29
+
30
+ // "Items: Unique: `70`\nTotal: `766`\nWorth: `⏣ 31M`"
31
+ const RE_ITEMS_UNIQUE = /unique[:\s]+[`']?(\d+)[`']?/i;
32
+ const RE_ITEMS_TOTAL = /total[:\s]+[`']?(\d+)[`']?/i;
33
+ const RE_ITEMS_WORTH = /worth[:\s]+[`⏣]?\s*([\d,.KMB]+(?:[KMB])?(?:\.\d+[KMB])?)/i;
34
+
35
+ // "Commands: Total: `5.1K`\nFavorite: `hunt`"
36
+ const RE_CMDS_TOTAL = /(?:total\s+)?commands?[:\s]+[`']?([\d,.KMB]+(?:[KMB])?(?:\.\d+[KMB])?)[`']?/i;
37
+ const RE_FAVORITE = /favorite[:\s]+[`']?(\w+)[`']?/i;
38
+
39
+ // Pets: "Pets: <:Rock:861584585250308106> **Rock**\n`Level 1 Rock`"
40
+ const RE_PET_BLOCK = /pets[:\s]*((?:<a?:\w+:\d+>\s*\*+\*?\*?\*?\s*(?:[\w ]+)\*+\s*`[^`]+`\s*)+)/gi;
41
+ const RE_PET_ENTRY = /<a?:(\w+):(\d+)>[\s\*]*([\w ]+?)[\s\*]*`([^`]+)`/gi;
42
+
43
+ // Showcase: "Showcase: ..." or "Showcase: No showcase"
44
+ const RE_SHOWCASE = /showcase[:\s]*(.+?)(?:\n|$)/i;
45
+
46
+ // ── Coin number parser ───────────────────────────────────────────────────────
47
+
48
+ function parseCoinNum(str) {
49
+ if (!str) return 0;
50
+ str = str.replace(/[⏣,\s]/g, '').toUpperCase();
51
+ const m = parseFloat(str);
52
+ if (isNaN(m)) return 0;
53
+ if (str.includes('B')) return Math.round(m * 1_000_000_000);
54
+ if (str.includes('M')) return Math.round(m * 1_000_000);
55
+ if (str.includes('K')) return Math.round(m * 1_000);
56
+ return Math.round(m);
28
57
  }
29
58
 
59
+ // ── Badge extraction ──────────────────────────────────────────────────────────
60
+
61
+ function parseBadges(text) {
62
+ const badges = [];
63
+ let m;
64
+ const re = /<a?:([^>]+):(\d+)>/g;
65
+ while ((m = re.exec(text)) !== null) {
66
+ badges.push({ name: m[1], emojiId: m[2] });
67
+ }
68
+ return badges;
69
+ }
70
+
71
+ // ── Pet extraction ────────────────────────────────────────────────────────────
72
+
73
+ function parsePets(text) {
74
+ const pets = [];
75
+ let m;
76
+ // Match: emoji + bold name + level line
77
+ // e.g. "<:Rock:861584585250308106> **Rock**\n`Level 1 Rock`"
78
+ const re = /<a?:(\w+):(\d+)>\s*\*\*([^\*]+)\*\*\s*`([^`]+)`/gi;
79
+ while ((m = re.exec(text)) !== null) {
80
+ pets.push({
81
+ emojiName: m[1],
82
+ emojiId: m[2],
83
+ name: m[3].trim(),
84
+ level: m[4].trim(),
85
+ });
86
+ }
87
+ return pets;
88
+ }
89
+
90
+ // ── Main parse function ──────────────────────────────────────────────────────
91
+
30
92
  /**
31
- * Check player level. Returns cached value if fresh, otherwise sends "pls profile".
32
- * @param {object} opts
33
- * @param {object} opts.channel
34
- * @param {function} opts.waitForDankMemer
35
- * @param {string} [opts.accountId] - for cache key
36
- * @param {object} [opts.redis] - Redis client for persistent cache
37
- * @returns {Promise<number|null>} Level number or null if can't determine
93
+ * Parse all profile fields from Dank Memer `pls profile` response.
94
+ * @param {object} msg - Discord message (or partial with embeds)
95
+ * @returns {object} Parsed profile data
38
96
  */
97
+ function parseProfile(msg) {
98
+ const text = getFullText(msg);
99
+
100
+ // Level
101
+ let level = null;
102
+ const levelMatch = text.match(RE_LEVEL);
103
+ if (levelMatch) level = parseInt(levelMatch[1], 10);
104
+
105
+ // XP
106
+ let xpCurrent = null, xpMax = null;
107
+ const xpMatch = text.match(RE_XP);
108
+ if (xpMatch) {
109
+ xpCurrent = parseInt(xpMatch[1], 10);
110
+ xpMax = parseInt(xpMatch[2], 10);
111
+ }
112
+
113
+ // Coins
114
+ const wallet = parseCoinNum((text.match(RE_WALLET) || [])[1]);
115
+ const bank = parseCoinNum((text.match(RE_BANK) || [])[1]);
116
+ const net = parseCoinNum((text.match(RE_NET) || [])[1]);
117
+
118
+ // Items
119
+ const itemsUnique = parseInt((text.match(RE_ITEMS_UNIQUE) || [])[1], 10) || 0;
120
+ const itemsTotal = parseInt((text.match(RE_ITEMS_TOTAL) || [])[1], 10) || 0;
121
+ const itemsWorth = parseCoinNum((text.match(RE_ITEMS_WORTH) || [])[1]);
122
+
123
+ // Commands
124
+ const cmdsTotal = (text.match(RE_CMDS_TOTAL) || [])[1] || '0';
125
+ const favorite = ((text.match(RE_FAVORITE) || [])[1] || '').trim().toLowerCase();
126
+
127
+ // Badges
128
+ const badges = parseBadges(text);
129
+
130
+ // Pets
131
+ const pets = parsePets(text);
132
+
133
+ // Showcase
134
+ const showcaseRaw = (text.match(RE_SHOWCASE) || [])[1] || '';
135
+ const showcase = showcaseRaw.toLowerCase().includes('no showcase')
136
+ ? []
137
+ : showcaseRaw.split('\n').filter(l => l.trim()).map(l => l.trim().replace(/^\*\*|\*\*$/g, ''));
138
+
139
+ return {
140
+ level,
141
+ xpCurrent,
142
+ xpMax,
143
+ wallet,
144
+ bank,
145
+ net,
146
+ itemsUnique,
147
+ itemsTotal,
148
+ itemsWorth,
149
+ cmdsTotal,
150
+ favorite,
151
+ badges,
152
+ pets,
153
+ showcase,
154
+ rawText: text.substring(0, 2000),
155
+ parsedAt: Date.now(),
156
+ };
157
+ }
158
+
159
+ // ── In-memory cache ─────────────────────────────────────────────────────────
160
+
161
+ const levelCache = {};
162
+
39
163
  async function getPlayerLevel({ channel, waitForDankMemer, accountId = 'default', redis }) {
40
- // Check in-memory cache first
41
164
  const cached = levelCache[accountId];
42
165
  if (cached && (Date.now() - cached.checkedAt) < CACHE_TTL) {
43
166
  return cached.level;
44
167
  }
45
-
46
- // Check Redis cache
47
168
  if (redis) {
48
169
  try {
49
170
  const redisLevel = await redis.get(`dkg:level:${accountId}`);
@@ -54,48 +175,75 @@ async function getPlayerLevel({ channel, waitForDankMemer, accountId = 'default'
54
175
  }
55
176
  } catch {}
56
177
  }
57
-
58
- // Fetch from Discord
59
- LOG.debug('[profile] Checking level...');
60
178
  await channel.send('pls profile');
61
179
  const response = await waitForDankMemer(8000);
180
+ if (!response) { LOG.warn('[profile] No response'); return null; }
181
+ if (isHoldTight(response)) { await sleep(5000); return null; }
182
+ logMsg(response, 'profile');
183
+ const parsed = parseProfile(response);
184
+ if (parsed.level !== null) {
185
+ levelCache[accountId] = { level: parsed.level, checkedAt: Date.now() };
186
+ if (redis) {
187
+ const ttl = parsed.level >= 25 ? 2592000 : 600;
188
+ try { await redis.set(`dkg:level:${accountId}`, String(parsed.level), 'EX', ttl); } catch {}
189
+ }
190
+ }
191
+ return parsed.level;
192
+ }
62
193
 
194
+ /**
195
+ * Run full profile check, parse all fields, and return structured data.
196
+ * @param {object} opts
197
+ * @param {object} opts.channel
198
+ * @param {function} opts.waitForDankMemer
199
+ * @param {string} [opts.accountId]
200
+ * @param {object} [opts.redis]
201
+ * @returns {Promise<object>} Parsed profile data
202
+ */
203
+ async function runProfile({ channel, waitForDankMemer, accountId = 'default', redis }) {
204
+ LOG.cmd(`${c.white}${c.bold}pls profile${c.reset}`);
205
+ await channel.send('pls profile');
206
+ const response = await waitForDankMemer(8000);
63
207
  if (!response) {
64
208
  LOG.warn('[profile] No response');
65
- return null;
209
+ return { error: 'no_response' };
66
210
  }
67
-
68
211
  if (isHoldTight(response)) {
69
- await sleep(5000);
70
- return null;
212
+ LOG.warn('[profile] Hold Tight');
213
+ await sleep(30000);
214
+ return { error: 'hold_tight' };
71
215
  }
72
-
73
216
  logMsg(response, 'profile');
74
- const text = getFullText(response);
75
- const level = parseLevelFromText(text);
217
+ const parsed = parseProfile(response);
76
218
 
77
- if (level !== null) {
78
- LOG.info(`[profile] Level: ${c.bold}${level}${c.reset}`);
79
- levelCache[accountId] = { level, checkedAt: Date.now() };
80
- // Persist to Redis — longer TTL for higher levels since they only go up
219
+ if (parsed.level !== null) {
220
+ levelCache[accountId] = { level: parsed.level, checkedAt: Date.now() };
81
221
  if (redis) {
82
- const ttl = level >= 25 ? 2592000 : 600; // 30 days if ≥25, 10 min otherwise
83
- try { await redis.set(`dkg:level:${accountId}`, String(level), 'EX', ttl); } catch {}
222
+ const ttl = parsed.level >= 25 ? 2592000 : 600;
223
+ try { await redis.set(`dkg:level:${accountId}`, String(parsed.level), 'EX', ttl); } catch {}
224
+ try {
225
+ await redis.set(`dkg:profile:${accountId}`, JSON.stringify(parsed), 'EX', CACHE_TTL / 1000);
226
+ } catch {}
84
227
  }
85
- } else {
86
- LOG.warn(`[profile] Could not parse level from: ${text.substring(0, 100)}`);
87
228
  }
88
229
 
89
- return level;
230
+ LOG.info(`[profile] Level ${parsed.level} · ⏣ ${parsed.wallet?.toLocaleString() || 0} wallet · ⏣ ${parsed.net?.toLocaleString() || 0} net`);
231
+ return parsed;
90
232
  }
91
233
 
92
- /**
93
- * Check if player meets minimum level requirement.
94
- */
95
234
  async function meetsLevelRequirement(opts, minLevel) {
96
235
  const level = await getPlayerLevel(opts);
97
- if (level === null) return false; // can't determine, skip
236
+ if (level === null) return false;
98
237
  return level >= minLevel;
99
238
  }
100
239
 
101
- module.exports = { getPlayerLevel, meetsLevelRequirement, parseLevelFromText };
240
+ module.exports = {
241
+ getPlayerLevel,
242
+ meetsLevelRequirement,
243
+ parseLevelFromText: (text) => parseProfile({ content: text, embeds: [] }).level,
244
+ runProfile,
245
+ parseProfile,
246
+ parseCoinNum,
247
+ parseBadges,
248
+ parsePets,
249
+ };
@@ -558,7 +558,8 @@ async function clickCV2Button(msg, customId) {
558
558
  const gId = msg.guildId || msg.guild?.id;
559
559
  if (!token) throw new Error('No token for CV2 click');
560
560
  const sessionId = msg.client?.ws?.shards?.first?.()?.sessionId;
561
- const nonce = `${BigInt(Date.now() - 1420070400000) << 22n}`;
561
+ const { randomUUID } = require('crypto');
562
+ const nonce = randomUUID();
562
563
  const payloadObj = {
563
564
  type: 3,
564
565
  application_id: String(msg.applicationId || DANK_MEMER_ID),
@@ -603,7 +604,8 @@ async function clickCV2SelectMenu(msg, customId, values = []) {
603
604
  const gId = msg.guildId || msg.guild?.id;
604
605
  if (!token) throw new Error('No token for CV2 select');
605
606
  const sessionId = msg.client?.ws?.shards?.first?.()?.sessionId;
606
- const nonce = `${BigInt(Date.now() - 1420070400000) << 22n}`;
607
+ const { randomUUID } = require('crypto');
608
+ const nonce = randomUUID();
607
609
  const payloadObj = {
608
610
  type: 3,
609
611
  application_id: String(msg.applicationId || DANK_MEMER_ID),
package/lib/grinder.js CHANGED
@@ -5,7 +5,7 @@ const { setDashboardActive, isCV2, ensureCV2, stripAnsi } = require('./commands/
5
5
  const rawLogger = require('./rawLogger');
6
6
  const {
7
7
  BloomFilter, RingBuffer, TokenBucket, EMA, SlidingWindowCounter,
8
- AhoCorasick, LRUCache, StringPool, AsyncBatchQueue, JitterBackoff,
8
+ AhoCorasick, LRUCache, StringPool, AsyncBatchQueue,
9
9
  } = require('./structures');
10
10
  const PKG_VERSION = require('../package.json').version;
11
11
 
@@ -279,9 +279,9 @@ function boxTop(w, color) { return color + BOX.dtl + BOX.dh.repeat(w - 2) + BOX.
279
279
  function boxMid(w, color) { return color + BOX.tee + BOX.h.repeat(w - 2) + BOX.teeR + c.reset; }
280
280
  function boxBot(w, color) { return color + BOX.dbl + BOX.dh.repeat(w - 2) + BOX.dbr + c.reset; }
281
281
  function boxLine(content, w, color) {
282
- const stripped = content.replace(/\x1b\[[0-9;]*m/g, '');
282
+ const stripped = stripAnsi(content);
283
283
  const pad = Math.max(0, w - 4 - stripped.length);
284
- return color + BOX.dv + c.reset + ' ' + content + ' '.repeat(pad) + ' ' + color + BOX.dv + c.reset;
284
+ return color + BOX.dv + c.reset + ' ' + stripAnsi(content) + ' '.repeat(pad) + ' ' + color + BOX.dv + c.reset;
285
285
  }
286
286
  function thinLine(w) { return ' ' + c.dim + BOX.h.repeat(w - 4) + c.reset; }
287
287
 
@@ -868,8 +868,8 @@ const CMD_NAMES_CLEAN = {
868
868
  bj: 'Blackjack', blackjack: 'Blackjack', hl: 'High Low', pm: 'Post Memes', postmemes: 'Post Memes',
869
869
  ct: 'Coin Toss', cointoss: 'Coin Toss', se: 'Snake Eyes', snakeeyes: 'Snake Eyes',
870
870
  hunt: 'Hunt', dig: 'Dig', fish: 'Fish', beg: 'Beg', search: 'Search', crime: 'Crime',
871
- tidy: 'Tidy', farm: 'Farm', daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly',
872
- scratch: 'Scratch', adventure: 'Adventure', trivia: 'Trivia', stream: 'Stream',
871
+ tidy: 'Tidy', farm: 'Farm', daily: 'Daily', monthly: 'Monthly',
872
+ adventure: 'Adventure', trivia: 'Trivia', stream: 'Stream',
873
873
  drops: 'Drops', use: 'Use Item', dep: 'Deposit', deposit: 'Deposit', inv: 'Inventory',
874
874
  work: 'Work', stream: 'Stream', roulette: 'Roulette', slots: 'Slots',
875
875
  };
@@ -1490,7 +1490,9 @@ class AccountWorker {
1490
1490
  totalValue: result.totalValue || 0,
1491
1491
  }),
1492
1492
  });
1493
- } catch {}
1493
+ } catch (err) {
1494
+ console.error(`[${this.username}] inventory API error:`, err?.message || err);
1495
+ }
1494
1496
 
1495
1497
  return {
1496
1498
  ok: true,
@@ -1524,6 +1526,60 @@ class AccountWorker {
1524
1526
  }
1525
1527
  }
1526
1528
 
1529
+ // ── Profile Check ────────────────────────────────────────────
1530
+ async checkProfile(silent = false) {
1531
+ try {
1532
+ const result = await commands.runProfile({
1533
+ channel: this.channel,
1534
+ waitForDankMemer: (t) => this.waitForDankMemer(t),
1535
+ accountId: this.account.id,
1536
+ redis,
1537
+ });
1538
+
1539
+ if (result.error) {
1540
+ if (!silent) this.log('warn', `[profile] Check failed: ${result.error}`);
1541
+ return result;
1542
+ }
1543
+
1544
+ if (!silent) {
1545
+ this.log('info', `[profile] Level ${result.level} · ⏣ ${(result.wallet || 0).toLocaleString()} wallet · ⏣ ${(result.net || 0).toLocaleString()} net`);
1546
+ }
1547
+
1548
+ // Push to web dashboard via grinder status endpoint
1549
+ try {
1550
+ await fetch(`${API_URL}/api/grinder/profile`, {
1551
+ method: 'POST',
1552
+ headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
1553
+ body: JSON.stringify({
1554
+ account_id: this.account.id,
1555
+ level: result.level,
1556
+ xp_current: result.xpCurrent,
1557
+ xp_max: result.xpMax,
1558
+ wallet: result.wallet,
1559
+ bank: result.bank,
1560
+ net: result.net,
1561
+ items_unique: result.itemsUnique,
1562
+ items_total: result.itemsTotal,
1563
+ items_worth: result.itemsWorth,
1564
+ cmds_total: result.cmdsTotal,
1565
+ favorite: result.favorite,
1566
+ badges: result.badges || [],
1567
+ pets: result.pets || [],
1568
+ showcase: result.showcase || [],
1569
+ raw_text: result.rawText || '',
1570
+ }),
1571
+ });
1572
+ } catch (err) {
1573
+ console.error(`[${this.username}] profile API error:`, err?.message || err);
1574
+ }
1575
+
1576
+ return { ok: true, ...result };
1577
+ } catch (e) {
1578
+ if (!silent) this.log('warn', `[profile] Check error: ${e.message}`);
1579
+ return { ok: false, error: e.message };
1580
+ }
1581
+ }
1582
+
1527
1583
  async checkBalance(silent = false) {
1528
1584
  const prefix = this.account.use_slash ? '/' : 'pls';
1529
1585
  const sentAt = Date.now();
@@ -1724,22 +1780,48 @@ class AccountWorker {
1724
1780
  // Each modular command handler sends the command, waits for response,
1725
1781
  // handles Hold Tight / cooldowns / item-buying internally.
1726
1782
  async runCommand(cmdName, prefix) {
1727
- let cmdString;
1783
+ // Slash commands use '/cmd' without a space between slash and subcommand
1784
+ // (e.g. '/dep max', '/work shift'). Legacy prefix uses 'pls cmd'.
1785
+ const SLASH_CMD = new Map([
1786
+ ['dep max', '/dep max'],
1787
+ ['with max', '/with max'],
1788
+ ['work shift', '/work shift'],
1789
+ ['monthly', '/monthly'],
1790
+ ]);
1728
1791
  const bjBet = Math.max(5000, this.account.bet_amount || 5000);
1729
1792
  const gambBet = Math.max(10000, this.account.bet_amount || 10000);
1730
1793
 
1731
- switch (cmdName) {
1732
- case 'dep max': cmdString = `${prefix} dep max`; break;
1733
- case 'with max': cmdString = `${prefix} with max`; break;
1734
- case 'blackjack': cmdString = `${prefix} bj ${bjBet}`; break;
1735
- case 'cointoss': cmdString = `${prefix} cointoss ${gambBet}`; break;
1736
- case 'roulette': cmdString = `${prefix} roulette ${gambBet}`; break;
1737
- case 'slots': cmdString = `${prefix} slots ${gambBet}`; break;
1738
- case 'snakeeyes': cmdString = `${prefix} snakeeyes ${gambBet}`; break;
1739
- case 'work shift': cmdString = `${prefix} work shift`; break;
1740
- case 'weekly': cmdString = `${prefix} weekly`; break;
1741
- case 'monthly': cmdString = `${prefix} monthly`; break;
1742
- default: cmdString = `${prefix} ${cmdName}`;
1794
+ let cmdString;
1795
+ if (prefix === '/') {
1796
+ const slashVariant = SLASH_CMD.get(cmdName);
1797
+ if (slashVariant) {
1798
+ cmdString = slashVariant;
1799
+ } else if (cmdName === 'blackjack') {
1800
+ cmdString = `/bj ${bjBet}`;
1801
+ } else if (cmdName === 'cointoss') {
1802
+ cmdString = `/cointoss ${gambBet}`;
1803
+ } else if (cmdName === 'roulette') {
1804
+ cmdString = `/roulette ${gambBet}`;
1805
+ } else if (cmdName === 'slots') {
1806
+ cmdString = `/slots ${gambBet}`;
1807
+ } else if (cmdName === 'snakeeyes') {
1808
+ cmdString = `/snakeeyes ${gambBet}`;
1809
+ } else {
1810
+ cmdString = `/${cmdName}`;
1811
+ }
1812
+ } else {
1813
+ switch (cmdName) {
1814
+ case 'dep max': cmdString = `${prefix} dep max`; break;
1815
+ case 'with max': cmdString = `${prefix} with max`; break;
1816
+ case 'blackjack': cmdString = `${prefix} bj ${bjBet}`; break;
1817
+ case 'cointoss': cmdString = `${prefix} cointoss ${gambBet}`; break;
1818
+ case 'roulette': cmdString = `${prefix} roulette ${gambBet}`; break;
1819
+ case 'slots': cmdString = `${prefix} slots ${gambBet}`; break;
1820
+ case 'snakeeyes': cmdString = `${prefix} snakeeyes ${gambBet}`; break;
1821
+ case 'work shift': cmdString = `${prefix} work shift`; break;
1822
+ case 'monthly': cmdString = `${prefix} monthly`; break;
1823
+ default: cmdString = `${prefix} ${cmdName}`;
1824
+ }
1743
1825
  }
1744
1826
 
1745
1827
  if (shutdownCalled || !this.running) return;
@@ -1837,7 +1919,6 @@ class AccountWorker {
1837
1919
  case 'hunt': cmdResult = await commands.runHunt(cmdOpts); break;
1838
1920
  case 'dig': cmdResult = await commands.runDig(cmdOpts); break;
1839
1921
  case 'fish': cmdResult = await commands.runFish(cmdOpts); break;
1840
- case 'scratch': cmdResult = await commands.runScratch(cmdOpts); break;
1841
1922
  case 'adventure': cmdResult = await commands.runAdventure(cmdOpts); break;
1842
1923
  case 'blackjack': cmdResult = await commands.runBlackjack(cmdOpts); break;
1843
1924
  case 'trivia': cmdResult = await commands.runTrivia(cmdOpts); break;
@@ -1892,7 +1973,9 @@ class AccountWorker {
1892
1973
  headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
1893
1974
  body: JSON.stringify({ account_id: this.account.id, active: false }),
1894
1975
  });
1895
- } catch {}
1976
+ } catch (err) {
1977
+ console.error(`[${this.username}] status deactivation API error:`, err?.message || err);
1978
+ }
1896
1979
  await sendLog(this.username, cmdString, 'VERIFICATION — account deactivated', 'error');
1897
1980
  sendWebhook('CAPTCHA ALERT', `**${this.username}** needs verification!\nCommand: \`${cmdName}\`\nSolve in Discord and re-enable from dashboard.`, 0xef4444);
1898
1981
  return;
@@ -1971,8 +2054,9 @@ class AccountWorker {
1971
2054
  const timeMatch = result.match(/<t:(\d+):R>/);
1972
2055
  let waitSec;
1973
2056
  if (timeMatch) {
1974
- const nextAvail = parseInt(timeMatch[1]) * 1000;
1975
- waitSec = Math.max(60, Math.ceil((nextAvail - Date.now()) / 1000));
2057
+ // Discord <t:TS:R> format: :R = relative seconds from NOW (not Unix ms).
2058
+ // The captured number IS already the number of seconds to wait.
2059
+ waitSec = Math.max(60, parseInt(timeMatch[1]));
1976
2060
  } else {
1977
2061
  const defaultWaits = { daily: 86400, weekly: 604800, monthly: 2592000 };
1978
2062
  waitSec = defaultWaits[cmdName] || 86400;
@@ -2152,13 +2236,13 @@ class AccountWorker {
2152
2236
 
2153
2237
  // ── Command Map (shared across ticks, used to build the heap) ──
2154
2238
  // Priority: higher = runs first when multiple commands are ready simultaneously.
2155
- // 10 = time-gated (daily/weekly/monthly — never miss),
2239
+ // 10 = time-gated (daily/monthly — never miss),
2156
2240
  // 8 = financial safety (deposit),
2157
2241
  // 7 = gambling fast-cycle (2-3s CD — run MOST often),
2158
2242
  // 6 = fast grinders (10s CD),
2159
2243
  // 5 = medium grinders (20-40s CD),
2160
2244
  // 4 = resource grinders (hunt/dig — need items),
2161
- // 3 = interactive/long CD (adventure/stream/work/scratch),
2245
+ // 3 = interactive/long CD (adventure/stream/work),
2162
2246
  // 2 = utility (drops/use/tidy)
2163
2247
  static COMMAND_MAP = [
2164
2248
  // Gambling — 2-3s CD, highest frequency
@@ -2169,7 +2253,7 @@ class AccountWorker {
2169
2253
  { key: 'cmd_snakeeyes', cmd: 'snakeeyes', cdKey: 'cd_snakeeyes', defaultCd: 3, priority: 7 },
2170
2254
  // Fast grinders — 10s CD
2171
2255
  { key: 'cmd_hl', cmd: 'hl', cdKey: 'cd_hl', defaultCd: 10, priority: 6 },
2172
- { key: 'cmd_farm', cmd: 'farm', cdKey: 'cd_farm', defaultCd: 30, priority: 4 },
2256
+ { key: 'cmd_farm', cmd: 'farm', cdKey: 'cd_farm', defaultCd: 30, priority: 4 },
2173
2257
  { key: 'cmd_trivia', cmd: 'trivia', cdKey: 'cd_trivia', defaultCd: 10, priority: 6 },
2174
2258
  { key: 'cmd_use', cmd: 'use', cdKey: 'cd_use', defaultCd: 10, priority: 2 },
2175
2259
  // Medium grinders — 20-25s CD
@@ -2180,20 +2264,19 @@ class AccountWorker {
2180
2264
  { key: 'cmd_search', cmd: 'search', cdKey: 'cd_search', defaultCd: 25, priority: 5 },
2181
2265
  // Slow grinders — 40s CD
2182
2266
  { key: 'cmd_beg', cmd: 'beg', cdKey: 'cd_beg', defaultCd: 40, priority: 5 },
2183
- { key: 'cmd_crime', cmd: 'crime', cdKey: 'cd_crime', defaultCd: 40, priority: 5 },
2184
- { key: 'cmd_tidy', cmd: 'tidy', cdKey: 'cd_tidy', defaultCd: 40, priority: 2 },
2267
+ { key: 'cmd_crime', cmd: 'crime', cdKey: 'cd_crime', defaultCd: 40, priority: 5 },
2268
+ { key: 'cmd_tidy', cmd: 'tidy', cdKey: 'cd_tidy', defaultCd: 40, priority: 2 },
2185
2269
  // Interactive — response-driven CD (handler sets nextCooldownSec)
2186
- { key: 'cmd_adventure', cmd: 'adventure', cdKey: 'cd_adventure', defaultCd: 300, priority: 3 },
2187
- { key: 'cmd_stream', cmd: 'stream', cdKey: 'cd_stream', defaultCd: 600, priority: 3 },
2188
- // scratch removed requires voting which can't be automated
2189
- { key: 'cmd_work', cmd: 'work shift', cdKey: 'cd_work', defaultCd: 1800, priority: 3 },
2270
+ { key: 'cmd_adventure', cmd: 'adventure', cdKey: 'cd_adventure', defaultCd: 300, priority: 3 },
2271
+ { key: 'cmd_stream', cmd: 'stream', cdKey: 'cd_stream', defaultCd: 600, priority: 3 },
2272
+ { key: 'cmd_work', cmd: 'work shift', cdKey: 'cd_work', defaultCd: 1800, priority: 3 },
2190
2273
  // Time-gated (run ASAP when available)
2191
- { key: 'cmd_daily', cmd: 'daily', cdKey: 'cd_daily', defaultCd: 86400, priority: 10 },
2192
- // weekly removed — premium only, not available for free users
2193
- { key: 'cmd_monthly', cmd: 'monthly', cdKey: 'cd_monthly', defaultCd: 2592000,priority: 10 },
2274
+ { key: 'cmd_daily', cmd: 'daily', cdKey: 'cd_daily', defaultCd: 86400, priority: 10 },
2275
+ // monthly — premium only
2276
+ { key: 'cmd_monthly', cmd: 'monthly', cdKey: 'cd_monthly', defaultCd: 2592000, priority: 10 },
2194
2277
  // Financial safety
2195
- { key: 'cmd_deposit', cmd: 'dep max', cdKey: 'cd_deposit', defaultCd: 3600, priority: 8 },
2196
- { key: 'cmd_drops', cmd: 'drops', cdKey: 'cd_drops', defaultCd: 86400, priority: 2 },
2278
+ { key: 'cmd_deposit', cmd: 'dep max', cdKey: 'cd_deposit', defaultCd: 3600, priority: 8 },
2279
+ { key: 'cmd_drops', cmd: 'drops', cdKey: 'cd_drops', defaultCd: 86400, priority: 2 },
2197
2280
  // Alert is NOT scheduled — it's reactive (listener-based, see grindLoop)
2198
2281
  ].map(Object.freeze);
2199
2282
 
@@ -2470,91 +2553,58 @@ class AccountWorker {
2470
2553
  return;
2471
2554
  }
2472
2555
 
2473
- const top = this.commandQueue.peek();
2474
- if (top.nextRunAt > now) {
2475
- const waitMs = Math.min(top.nextRunAt - now, 2000);
2476
- this.setStatus('cooldown...');
2477
- this.tickTimeout = setTimeout(() => this.tick(), waitMs);
2478
- return;
2479
- }
2480
-
2481
- const item = this.commandQueue.pop();
2482
- if (!item) {
2483
- this.tickTimeout = setTimeout(() => this.tick(), 1000);
2484
- return;
2556
+ // ── Scan entire queue for ready commands ─────────────────────
2557
+ // Previously we only peeked the top item, missing other commands that
2558
+ // were already ready while a slower command was at the top of the heap.
2559
+ // Now we drain the queue and separate ready vs. waiting commands.
2560
+ // All ready commands fire immediately — no priority, FIFO order.
2561
+ const readyItems = [];
2562
+ const waitingItems = [];
2563
+ while (this.commandQueue.size > 0) {
2564
+ const item = this.commandQueue.pop();
2565
+ if (!item) break;
2566
+ if (item.nextRunAt <= now) {
2567
+ readyItems.push(item);
2568
+ } else {
2569
+ waitingItems.push(item);
2570
+ }
2485
2571
  }
2486
2572
 
2487
- const ready = await this.isCooldownReady(item.cmd);
2488
- if (!ready) {
2489
- const cd = (this.account[item.info.cdKey] || item.info.defaultCd);
2490
- item.nextRunAt = now + cd * 1000;
2491
- if (this.commandQueue) this.commandQueue.push(item);
2492
- this.tickTimeout = setTimeout(() => this.tick(), 100);
2493
- return;
2573
+ // Re-insert waiting items back into the queue (they still have time to wait)
2574
+ for (const item of waitingItems) {
2575
+ this.commandQueue.push(item);
2494
2576
  }
2495
2577
 
2496
- // Skip time-gated commands if already claimed (in-memory + Redis)
2497
- if (item.cmd === 'daily' || item.cmd === 'weekly' || item.cmd === 'monthly' || item.cmd === 'drops') {
2498
- const memExpiry = this.doneToday.get(item.cmd);
2499
- if (memExpiry && Date.now() < memExpiry) {
2500
- item.nextRunAt = memExpiry;
2501
- if (this.commandQueue) this.commandQueue.push(item);
2502
- this.tickTimeout = setTimeout(() => this.tick(), 100);
2503
- return;
2578
+ if (readyItems.length === 0) {
2579
+ // Nothing ready wait until the soonest command becomes available.
2580
+ let minWaitMs = 5000; // cap at 5s to stay responsive
2581
+ for (const item of waitingItems) {
2582
+ const diff = item.nextRunAt - now;
2583
+ if (diff < minWaitMs) minWaitMs = diff;
2504
2584
  }
2505
- if (redis) {
2506
- try {
2507
- const done = await redis.get(`dkg:done:${this.account.id}:${item.cmd}`);
2508
- if (done) {
2509
- const ttlMap = { daily: 86400, weekly: 604800, monthly: 2592000, drops: 86400 };
2510
- const ttl = ttlMap[item.cmd] || 86400;
2511
- const expiry = now + ttl * 1000;
2512
- this.doneToday.set(item.cmd, expiry);
2513
- item.nextRunAt = expiry;
2514
- if (this.commandQueue) this.commandQueue.push(item);
2515
- this.tickTimeout = setTimeout(() => this.tick(), 100);
2516
- return;
2517
- }
2518
- } catch {}
2519
- }
2520
- }
2521
-
2522
- // Smart gambling: skip gamble commands while loss-paused
2523
- const GAMBLE_SET = new Set(['blackjack', 'cointoss', 'roulette', 'slots', 'snakeeyes']);
2524
- if (GAMBLE_SET.has(item.cmd) && this._gamblePausedUntil > now) {
2525
- item.nextRunAt = this._gamblePausedUntil;
2526
- if (this.commandQueue) this.commandQueue.push(item);
2527
- this.setStatus(`gamble paused (${Math.ceil((this._gamblePausedUntil - now) / 1000)}s)`);
2528
- this.tickTimeout = setTimeout(() => this.tick(), 100);
2585
+ this.setStatus('cooldown...');
2586
+ this.tickTimeout = setTimeout(() => this.tick(), Math.max(100, Math.min(minWaitMs, 5000)));
2529
2587
  return;
2530
2588
  }
2531
- if (GAMBLE_SET.has(item.cmd) && this._gamblePausedUntil > 0 && this._gamblePausedUntil <= now) {
2532
- this._gamblePausedUntil = 0;
2533
- this._gambleLossStreak = 0;
2534
- this._gambleSessionLoss = 0;
2535
- this.log('info', 'Gambling pause expired — resuming bets');
2536
- }
2537
2589
 
2538
- // TokenBucket rate limiter: prevent Discord 429s by throttling commands
2539
- if (!this._rateLimiter.consume(1)) {
2540
- const waitMs = this._rateLimiter.waitTime(1);
2541
- this.setStatus(`rate throttle (${Math.ceil(waitMs / 1000)}s)`);
2542
- if (this.commandQueue) this.commandQueue.push(item);
2543
- this.tickTimeout = setTimeout(() => this.tick(), waitMs);
2544
- return;
2590
+ // FIFO execute commands in the order they became ready.
2591
+ // Commands that aren't picked this tick go back into the queue for next tick.
2592
+ const item = readyItems[0];
2593
+
2594
+ // Any remaining ready items that we didn't execute go back immediately
2595
+ // so they run in the next tick without waiting for the slow top item.
2596
+ for (let i = 1; i < readyItems.length; i++) {
2597
+ this.commandQueue.push(readyItems[i]);
2545
2598
  }
2546
2599
 
2547
- this._cmdRate.increment();
2548
- this.busy = true;
2600
+ // Anti-detection: per-account jitter + micro-pauses for this command
2549
2601
  const cd = (this.account[item.info.cdKey] || item.info.defaultCd);
2550
- // Anti-detection: per-account jitter with varying patterns
2551
2602
  const patternMod = this._activePattern;
2552
2603
  const jitterBase = cd <= 5
2553
2604
  ? 0.3 + Math.random() * 0.7
2554
2605
  : cd <= 20
2555
2606
  ? 0.5 + Math.random() * 1.5
2556
2607
  : 1 + Math.random() * 2;
2557
- // Add human-like micro-pauses (occasionally take longer, simulating distraction)
2558
2608
  const microPause = Math.random() < 0.08 ? 1.5 + Math.random() * 3 : 0;
2559
2609
  const totalWait = cd + jitterBase + microPause;
2560
2610
 
@@ -2566,6 +2616,20 @@ class AccountWorker {
2566
2616
  await new Promise(r => setTimeout(r, minGap - timeSinceLastCmd));
2567
2617
  }
2568
2618
 
2619
+ // TokenBucket rate limiter — prevent Discord 429s
2620
+ if (!this._rateLimiter.consume(1)) {
2621
+ const waitMs = this._rateLimiter.waitTime(1);
2622
+ this.setStatus(`rate throttle (${Math.ceil(waitMs / 1000)}s)`);
2623
+ if (this.commandQueue) this.commandQueue.push(item);
2624
+ this.tickTimeout = setTimeout(() => this.tick(), waitMs);
2625
+ return;
2626
+ }
2627
+
2628
+ this.busy = true;
2629
+
2630
+ // ── Run command (with interactive retry) ───────────────────
2631
+ // Commands run ONE BY ONE — sequential execution, no concurrency within this account.
2632
+ // Each runCommand() call waits for Dank Memer's Discord response before returning.
2569
2633
  const prefix = this.account.use_slash ? '/' : 'pls';
2570
2634
  this.setStatus(formatCommandName(item.cmd));
2571
2635
 
@@ -2579,15 +2643,40 @@ class AccountWorker {
2579
2643
  next_run_at: nextItemRun?.nextRunAt || null,
2580
2644
  });
2581
2645
 
2582
- const beforeCoins = this.stats.coins;
2583
- await this.runCommand(item.cmd, prefix);
2584
- const earned = this.stats.coins - beforeCoins;
2646
+ // Interactive commands (button-click): retry up to 3 times on failure.
2647
+ // Non-interactive commands run once.
2648
+ const INTERACTIVE_CMDS = new Set(['hl', 'blackjack', 'trivia', 'adventure', 'stream', 'fish', 'farm', 'work shift']);
2649
+ const isInteractive = INTERACTIVE_CMDS.has(item.cmd);
2650
+ const maxAttempts = isInteractive ? 3 : 1;
2651
+ let attempt = 0;
2652
+ let lastError = null;
2653
+ let earned = 0;
2654
+ while (attempt < maxAttempts) {
2655
+ attempt++;
2656
+ try {
2657
+ const beforeCoins = this.stats.coins;
2658
+ await this.runCommand(item.cmd, prefix);
2659
+ earned = this.stats.coins - beforeCoins;
2660
+ lastError = null;
2661
+ this.lastCommandRun = Date.now();
2662
+ break; // success
2663
+ } catch (err) {
2664
+ lastError = err;
2665
+ this.log('warn', `${item.cmd} attempt ${attempt}/${maxAttempts} failed: ${err.message}`);
2666
+ if (attempt < maxAttempts) {
2667
+ await new Promise(r => setTimeout(r, 1000 + Math.random() * 1000));
2668
+ }
2669
+ }
2670
+ }
2671
+ if (lastError) {
2672
+ this.log('error', `${item.cmd} failed after ${maxAttempts} attempts — skipping`);
2673
+ }
2674
+
2675
+ this._cmdRate.increment();
2585
2676
 
2586
- // Grace period for interactive (button-click) commands — Dank Memer
2587
- // needs time to process the interaction before accepting the next command.
2588
- // Without this, the next command gets "Hold Tight" errors.
2589
- const INTERACTIVE_CMDS = new Set(['hl', 'blackjack', 'trivia', 'scratch', 'adventure', 'stream', 'fish', 'farm', 'work shift']);
2590
- if (INTERACTIVE_CMDS.has(item.cmd)) {
2677
+ // Grace period for interactive commands — Dank Memer needs time to process
2678
+ // the interaction before accepting the next command.
2679
+ if (isInteractive) {
2591
2680
  await new Promise(r => setTimeout(r, 2500 + Math.random() * 1500));
2592
2681
  }
2593
2682
 
@@ -2605,8 +2694,6 @@ class AccountWorker {
2605
2694
  this.failStreak = 0;
2606
2695
  }
2607
2696
 
2608
- this.lastCommandRun = Date.now();
2609
-
2610
2697
  // Exponential backoff: if too many consecutive failures, slow down
2611
2698
  const backoffMultiplier = this.failStreak > 5 ? Math.min(this.failStreak - 4, 5) : 1;
2612
2699
  // Minimum 5s cooldown for failed commands to prevent rapid-fire retries
@@ -2781,11 +2868,28 @@ class AccountWorker {
2781
2868
  headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
2782
2869
  body: JSON.stringify({ action_id: action.id }),
2783
2870
  });
2784
- } catch {}
2871
+ } catch (err) {
2872
+ console.error(`[${this.username}] delete pending action error:`, err?.message || err);
2873
+ }
2874
+ }
2875
+ if (action.action === 'check_profile' && !this.busy) {
2876
+ this.log('info', 'Dashboard requested profile check');
2877
+ await this.checkProfile().catch(() => {});
2878
+ try {
2879
+ await fetch(`${API_URL}/api/grinder/actions`, {
2880
+ method: 'DELETE',
2881
+ headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
2882
+ body: JSON.stringify({ action_id: action.id }),
2883
+ });
2884
+ } catch (err) {
2885
+ console.error(`[${this.username}] delete pending action error:`, err?.message || err);
2886
+ }
2785
2887
  }
2786
2888
  }
2787
2889
  }
2788
- } catch { /* silent */ }
2890
+ } catch (err) {
2891
+ console.error(`[${this.username}] refreshConfig error:`, err?.message || err);
2892
+ }
2789
2893
  }
2790
2894
 
2791
2895
  async start() {
@@ -2835,9 +2939,8 @@ class AccountWorker {
2835
2939
  { key: 'cmd_fish', l: 'fish' }, { key: 'cmd_beg', l: 'beg' },
2836
2940
  { key: 'cmd_search', l: 'search' }, { key: 'cmd_hl', l: 'hl' },
2837
2941
  { key: 'cmd_crime', l: 'crime' }, { key: 'cmd_pm', l: 'pm' },
2838
- { key: 'cmd_daily', l: 'daily' }, { key: 'cmd_weekly', l: 'weekly' },
2839
- { key: 'cmd_monthly', l: 'monthly' }, { key: 'cmd_work', l: 'work' },
2840
- { key: 'cmd_stream', l: 'stream' }, { key: 'cmd_scratch', l: 'scratch' },
2942
+ { key: 'cmd_daily', l: 'daily' }, { key: 'cmd_monthly', l: 'monthly' },
2943
+ { key: 'cmd_work', l: 'work' }, { key: 'cmd_stream', l: 'stream' },
2841
2944
  { key: 'cmd_adventure', l: 'adv' }, { key: 'cmd_farm', l: 'farm' },
2842
2945
  { key: 'cmd_tidy', l: 'tidy' }, { key: 'cmd_blackjack', l: 'bj' },
2843
2946
  { key: 'cmd_cointoss', l: 'toss' }, { key: 'cmd_roulette', l: 'roul' },
package/lib/rawLogger.js CHANGED
@@ -151,24 +151,26 @@ function detectCommand(d) {
151
151
  // Non-gambling CV2
152
152
  if (cv2Text.includes('fishing') || cv2Text.includes('fisherfolk')) return 'fish';
153
153
  if (cv2Text.includes('deposit') || cv2Text.includes('bank account')) return 'deposit';
154
- if (cv2Text.includes('begging') || cv2Text.includes('imagine begging')) return 'beg';
155
- if (cv2Text.includes('hunting') || cv2Text.includes('went hunting') || cv2Text.includes('hunting rifle') || cv2Text.includes('your aim was so bad') || cv2Text.includes('animals laughed') || cv2Text.includes('animals attacked') || cv2Text.includes('barely escaped') || cv2Text.includes('fell asleep in a tree') || cv2Text.includes('caught nothing') || cv2Text.includes('brought back literally nothing') || cv2Text.includes('rifle broke')) return 'hunt';
156
- if (cv2Text.includes('digging') || cv2Text.includes('found nothing while') || cv2Text.includes('you dig') || cv2Text.includes('dug in the dirt') || cv2Text.includes('brought back') && (cv2Text.includes('ant') || cv2Text.includes('worm') || cv2Text.includes('stickbug') || cv2Text.includes('ladybug'))) return 'dig';
154
+ if (cv2Text.includes('begging') || cv2Text.includes('imagine begging') || cv2Text.includes('wumpus gives you') || (cv2Text.includes('you received') && !cv2Text.includes('search') && !cv2Text.includes('hunt') && !cv2Text.includes('dig'))) return 'beg';
155
+ if (cv2Text.includes('hunting') || cv2Text.includes('went hunting') || cv2Text.includes('hunting rifle') || cv2Text.includes('your aim was so bad') || cv2Text.includes('animals laughed') || cv2Text.includes('animals attacked') || cv2Text.includes('barely escaped') || cv2Text.includes('fell asleep in a tree') || cv2Text.includes('caught nothing') || cv2Text.includes('brought back literally nothing') || cv2Text.includes('rifle broke') || (cv2Text.includes('brought back') && (cv2Text.includes('deer') || cv2Text.includes('wolf') || cv2Text.includes('bear') || cv2Text.includes('boar') || cv2Text.includes('lion') || cv2Text.includes('rabbit') || cv2Text.includes('squirrel') || cv2Text.includes('moose') || cv2Text.includes('bird') || cv2Text.includes('elk') || cv2Text.includes('hunting') || cv2Text.includes('capybara')))) return 'hunt';
156
+ if (cv2Text.includes('digging') || cv2Text.includes('found nothing while') || cv2Text.includes('you dig') || cv2Text.includes('dug in the dirt') || (cv2Text.includes('brought back') && (cv2Text.includes('ant') || cv2Text.includes('worm') || cv2Text.includes('stickbug') || cv2Text.includes('ladybug')))) return 'dig';
157
157
  if (cv2Text.includes('great work') || cv2Text.includes('for your shift') || cv2Text.includes('working as') || cv2Text.includes('work shift') || cv2Text.includes('what color was') || cv2Text.includes('remember words order') || cv2Text.includes('remember the colors') || cv2Text.includes('remember the emojis') || cv2Text.includes('what word was repeated') || cv2Text.includes('unscramble') || cv2Text.includes('remember the number') || cv2Text.includes('click the buttons in correct order') || cv2Text.includes('babysitter') || cv2Text.includes('click the matching')) return 'work';
158
+ // Quest completions (before generic daily/weekly)
159
+ if (cv2Text.includes('locations') && (cv2Text.includes('inventory locations') || cv2Text.includes('found locations') || cv2Text.includes('completed your') || cv2Text.includes('locations remaining'))) return 'search';
158
160
  if (cv2Text.includes('weekly')) return 'weekly';
159
161
  if (cv2Text.includes('daily')) return 'daily';
160
- if (cv2Text.includes('inventory')) return 'inventory';
162
+ // Only match inventory if it looks like an actual inventory display (header format or sellable indicator)
163
+ if (cv2Text.includes('inventory') && (cv2Text.includes('###') || cv2Text.includes('sellable') || cv2Text.includes('<:reply:'))) return 'inventory';
161
164
  if (cv2Text.includes('profile') || cv2Text.includes('level:')) return 'profile';
162
165
  if (cv2Text.includes('balances') && cv2Text.includes('global rank')) return 'balance';
163
166
 
164
167
  // Check content text (plain message content)
165
168
  const contentText = (d.content || '').toLowerCase();
166
169
  if (contentText.includes('balances') && contentText.includes('global rank')) return 'balance';
167
- if (contentText.includes('your aim was so bad') || contentText.includes('animals laughed')) return 'hunt';
168
- if (contentText.includes('imagine going into the woods')) return 'hunt';
170
+ if (contentText.includes('your aim was so bad') || contentText.includes('animals laughed') || contentText.includes('imagine going into the woods')) return 'hunt';
169
171
  if (contentText.includes('you ran an ad for') && contentText.includes('received')) return 'stream';
170
172
  if (contentText.includes('you can\'t interact with your stream')) return 'stream';
171
- if (contentText.includes('you dug in the dirt') || contentText.includes('found nothing while digging')) return 'dig';
173
+ if (contentText.includes('you dug in the dirt') || (contentText.includes('found nothing while digging') && (contentText.includes('dug') || contentText.includes('dirt')))) return 'dig';
172
174
 
173
175
  // Check embed text
174
176
  const embedText = extractEmbedText(d.embeds).toLowerCase();
@@ -197,8 +199,8 @@ function detectCommand(d) {
197
199
  return 'search';
198
200
  }
199
201
  // Hunt / dig
200
- if (embedText.includes('hunting') || embedText.includes('came back with') || embedText.includes('hunting rifle') || embedText.includes('dragon\'s fireball') || embedText.includes('dodge the') || embedText.includes('went hunting') || embedText.includes('your aim was so bad') || embedText.includes('animals laughed') || embedText.includes('animals attacked') || embedText.includes('barely escaped') || embedText.includes('fell asleep in a tree') || embedText.includes('caught nothing') || embedText.includes('brought back literally nothing') || embedText.includes('rifle broke') || embedText.includes('imagine going into the woods')) return 'hunt';
201
- if (embedText.includes('digging') || embedText.includes('you dig') || embedText.includes('dug in the dirt') || embedText.includes('found nothing while') || embedText.includes('what are the odds lol') || embedText.includes('brought back') && (embedText.includes('ant') || embedText.includes('worm') || embedText.includes('stickbug') || embedText.includes('ladybug'))) return 'dig';
202
+ if (embedText.includes('hunting') || embedText.includes('came back with') || embedText.includes('hunting rifle') || embedText.includes('dragon\'s fireball') || embedText.includes('dodge the') || embedText.includes('went hunting') || embedText.includes('your aim was so bad') || embedText.includes('animals laughed') || embedText.includes('animals attacked') || embedText.includes('barely escaped') || embedText.includes('fell asleep in a tree') || embedText.includes('caught nothing') || embedText.includes('brought back literally nothing') || embedText.includes('rifle broke') || embedText.includes('imagine going into the woods') || (embedText.includes('brought back') && (embedText.includes('deer') || embedText.includes('wolf') || embedText.includes('bear') || embedText.includes('boar') || embedText.includes('lion') || embedText.includes('rabbit') || embedText.includes('squirrel') || embedText.includes('moose') || embedText.includes('bird') || embedText.includes('elk') || embedText.includes('capybara')))) return 'hunt';
203
+ if (embedText.includes('digging') || embedText.includes('you dig') || embedText.includes('dug in the dirt') || embedText.includes('found nothing while') || embedText.includes('what are the odds lol') || (embedText.includes('brought back') && (embedText.includes('ant') || embedText.includes('worm') || embedText.includes('stickbug') || embedText.includes('ladybug')))) return 'dig';
202
204
  // Work — match both minigame prompt AND completion
203
205
  if (embedText.includes('work') && (embedText.includes('shift') || embedText.includes('mini-game') || embedText.includes('color') || embedText.includes('what color') || embedText.includes('babysitter') || embedText.includes('great work') || embedText.includes('for your shift'))) return 'work';
204
206
  if (embedText.includes('you were given') && embedText.includes('shift')) return 'work';
@@ -207,7 +209,7 @@ function detectCommand(d) {
207
209
  // Postmemes
208
210
  if (embedText.includes('pick a meme') || embedText.includes('meme posting')) return 'postmemes';
209
211
  // Stream
210
- if (embedText.includes('stream manager') || embedText.includes('go live') || embedText.includes('what game do you want to stream') || embedText.includes('you ran an ad for') || embedText.includes('you received') && embedText.includes('from your sponsors') || embedText.includes('### chat') && embedText.includes('hasanbabi')) return 'stream';
212
+ if (embedText.includes('stream manager') || embedText.includes('go live') || embedText.includes('what game do you want to stream') || embedText.includes('you ran an ad for') || (embedText.includes('you received') && embedText.includes('from your sponsors')) || (embedText.includes('### chat') && embedText.includes('hasanbabi'))) return 'stream';
211
213
  if (embedText.includes('you can\'t interact with your stream') || embedText.includes('stream can last')) return 'stream';
212
214
  // Deposit
213
215
  if (embedText.includes('deposited') && embedText.includes('bank balance')) return 'deposit';
@@ -228,7 +230,7 @@ function detectCommand(d) {
228
230
  // Farm
229
231
  if (embedText.includes('farm') && (embedText.includes('harvest') || embedText.includes('plant') || embedText.includes('hoe') || embedText.includes('water'))) return 'farm';
230
232
  // Beg
231
- if (embedText.includes('begging')) return 'beg';
233
+ if (embedText.includes('begging') || embedText.includes('wumpus gives you') || embedText.includes('life saver') || embedText.includes('lifesaver')) return 'beg';
232
234
  // Daily/weekly quest
233
235
  if (embedText.includes('daily quest')) return 'daily';
234
236
  // Fish
package/lib/structures.js CHANGED
@@ -443,6 +443,25 @@ class MinHeap {
443
443
 
444
444
  peek() { return this.heap[0]; }
445
445
 
446
+ /**
447
+ * Remove a specific node from the heap by reference.
448
+ * Used by CommandScheduler.unschedule() to cancel a pending scheduled command.
449
+ * O(n) — linear scan since we store node references in a Map.
450
+ */
451
+ remove(node) {
452
+ const idx = this.heap.indexOf(node);
453
+ if (idx === -1) return false;
454
+ const last = this.heap.pop();
455
+ if (idx < this.heap.length) {
456
+ this.heap[idx] = last;
457
+ // Bubble up or sink down depending on where the removed item was
458
+ if (this._bubbleUp(idx) === idx) {
459
+ this._sinkDown(idx);
460
+ }
461
+ }
462
+ return true;
463
+ }
464
+
446
465
  _bubbleUp(i) {
447
466
  while (i > 0) {
448
467
  const parent = (i - 1) >>> 1;
@@ -680,31 +699,6 @@ class AsyncBatchQueue {
680
699
  destroy() { if (this._timer) clearTimeout(this._timer); this.queue.length = 0; }
681
700
  }
682
701
 
683
- // ═══════════════════════════════════════════════════════════════
684
- // JitterBackoff – Decorrelated jitter (AWS-style), O(1)
685
- // Standard exponential backoff causes "thundering herd" when 10K
686
- // accounts retry simultaneously. Decorrelated jitter spreads them:
687
- // sleep = min(cap, random_between(base, sleep_prev * 3))
688
- // This is the recommended strategy from AWS Architecture Blog.
689
- // ═══════════════════════════════════════════════════════════════
690
- class JitterBackoff {
691
- constructor(baseMs = 1000, capMs = 30000) {
692
- this.baseMs = baseMs;
693
- this.capMs = capMs;
694
- this._sleep = baseMs;
695
- this.attempt = 0;
696
- }
697
-
698
- next() {
699
- this._sleep = Math.min(this.capMs, this.baseMs + Math.random() * (this._sleep * 3 - this.baseMs));
700
- this.attempt++;
701
- return this._sleep;
702
- }
703
-
704
- reset() { this._sleep = this.baseMs; this.attempt = 0; }
705
- get current() { return this._sleep; }
706
- }
707
-
708
702
  module.exports = {
709
703
  BloomFilter,
710
704
  LRUCache,
@@ -721,5 +715,4 @@ module.exports = {
721
715
  ObjectPool,
722
716
  TimerWheel,
723
717
  AsyncBatchQueue,
724
- JitterBackoff,
725
718
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dankgrinder",
3
- "version": "6.39.0",
3
+ "version": "6.42.0",
4
4
  "description": "Dank Memer automation engine — grind coins while you sleep",
5
5
  "bin": {
6
6
  "dankgrinder": "bin/dankgrinder.js"
@@ -1,83 +0,0 @@
1
- /**
2
- * Scratch command handler.
3
- * Send "pls scratch", click through scratch card buttons.
4
- * Requires level 25 — checks profile level before running.
5
- */
6
-
7
- const {
8
- LOG, c, getFullText, parseCoins, getAllButtons, safeClickButton,
9
- logMsg, isHoldTight, getHoldTightReason, sleep, humanDelay,
10
- } = require('./utils');
11
- const { meetsLevelRequirement } = require('./profile');
12
-
13
- /**
14
- * @param {object} opts
15
- * @param {object} opts.channel
16
- * @param {function} opts.waitForDankMemer
17
- * @param {string} [opts.accountId]
18
- * @param {object} [opts.redis]
19
- * @returns {Promise<{result: string, coins: number}>}
20
- */
21
- async function runScratch({ channel, waitForDankMemer, accountId, redis }) {
22
- // Check level 25 requirement before wasting a command
23
- const canRun = await meetsLevelRequirement({ channel, waitForDankMemer, accountId, redis }, 25);
24
- if (!canRun) {
25
- LOG.warn(`[scratch] Skipped — need level 25`);
26
- return { result: 'skipped (need level 25)', coins: 0, skipReason: 'level' };
27
- }
28
- LOG.cmd(`${c.white}${c.bold}pls scratch${c.reset}`);
29
-
30
- await channel.send('pls scratch');
31
- const response = await waitForDankMemer(10000);
32
-
33
- if (!response) {
34
- LOG.warn('[scratch] No response');
35
- return { result: 'no response', coins: 0 };
36
- }
37
-
38
- if (isHoldTight(response)) {
39
- const reason = getHoldTightReason(response);
40
- LOG.warn(`[scratch] Hold Tight${reason ? ` (reason: /${reason})` : ''} — waiting 30s`);
41
- await sleep(30000);
42
- return { result: `hold tight (${reason || 'unknown'})`, coins: 0, holdTightReason: reason };
43
- }
44
-
45
- logMsg(response, 'scratch');
46
- const buttons = getAllButtons(response);
47
-
48
- if (buttons.length === 0) {
49
- const text = getFullText(response);
50
- const coins = parseCoins(text);
51
- if (coins > 0) {
52
- LOG.coin(`[scratch] ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`);
53
- return { result: `scratch → +⏣ ${coins.toLocaleString()}`, coins };
54
- }
55
- return { result: text.substring(0, 60) || 'done', coins: 0 };
56
- }
57
-
58
- // Click through all scratch card cells
59
- let lastResponse = response;
60
- let clickCount = 0;
61
- for (let i = 0; i < Math.min(buttons.length, 9); i++) {
62
- const btn = buttons[i];
63
- if (btn && !btn.disabled) {
64
- await humanDelay(300, 700);
65
- try {
66
- const followUp = await safeClickButton(lastResponse, btn);
67
- if (followUp) { lastResponse = followUp; clickCount++; }
68
- } catch { break; }
69
- }
70
- }
71
-
72
- const finalText = getFullText(lastResponse);
73
- const coins = parseCoins(finalText);
74
-
75
- if (coins > 0) {
76
- LOG.coin(`[scratch] ${clickCount} clicks → ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`);
77
- return { result: `scratch (${clickCount} clicks) → +⏣ ${coins.toLocaleString()}`, coins };
78
- }
79
-
80
- return { result: `scratch done (${clickCount} clicks)`, coins: 0 };
81
- }
82
-
83
- module.exports = { runScratch };