dankgrinder 6.37.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, ' ')}`);
@@ -68,11 +68,30 @@ function parseFarmCooldownSec(text) {
68
68
  const hasCooldownContext = /easy tiger|slow it down|already farmed|farm again|cooldown|can be used again|on cooldown/.test(lower);
69
69
  if (!hasCooldownContext) return null;
70
70
 
71
- const ts = clean.match(RE_TS);
72
- if (ts) {
73
- const diff = parseInt(ts[1], 10) - Math.floor(Date.now() / 1000);
74
- if (diff > 0) return diff;
71
+ // Handle all Discord timestamp formats: :R, :f, :F, :T, :t, :d, :D
72
+ // :R = relative seconds from now (already seconds since epoch difference)
73
+ // :f/:F = absolute datetime diff from now
74
+ // :T/:t/:d/:D = not useful for cooldowns (no time component)
75
+ const RE_TS_ALL = /<t:(\d+):([tTdDfFR])>/g;
76
+ const now = Math.floor(Date.now() / 1000);
77
+ let best = null;
78
+ for (const m of clean.matchAll(RE_TS_ALL)) {
79
+ const ts = parseInt(m[1], 10);
80
+ const fmt = m[2];
81
+ if (!Number.isFinite(ts)) continue;
82
+ let diff;
83
+ if (fmt === 'R') {
84
+ diff = ts - now; // :R is already relative seconds
85
+ } else if (fmt === 'f' || fmt === 'F' || fmt === 'T') {
86
+ diff = ts - now; // :f/:F/:T are absolute → diff gives seconds remaining
87
+ } else {
88
+ // :t/:d/:D don't have enough info for cooldown calc — skip
89
+ continue;
90
+ }
91
+ if (diff > 0 && (best === null || diff < best)) best = diff;
75
92
  }
93
+ if (best !== null) return Math.max(5, best);
94
+
76
95
  const mm = clean.match(RE_MIN);
77
96
  if (mm) return parseInt(mm[1], 10) * 60;
78
97
  const hh = clean.match(RE_HR);
@@ -1589,16 +1608,23 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId })
1589
1608
  LOG.coin(`[farm] ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`);
1590
1609
  // After a harvest, Dank Memer auto-plants new crops. Record harvest time
1591
1610
  // in Redis so the next run knows when crops should be ready.
1592
- // Also: if the farm shows "empty" or "manage" state (no grow timestamp),
1593
- // set a short re-check window instead of trusting a potentially stale grow queue.
1611
+ // Only force a short 30s re-check if there is NO known grow queue timer.
1612
+ // If growReadyEnd is set (e.g. "ready in 26m"), that takes priority.
1594
1613
  const afterHarvestState = analyzeFarmState({ msg: cycleResponse, text });
1595
1614
  const farmIsHarvested = afterHarvestState.stage === 'overview'
1596
1615
  || /seems? pretty empty|empty\.{0,3}|hoe|water|plant|harvest/i.test(text);
1597
1616
  if (farmIsHarvested) {
1598
- // Farm was harvested and is back to manage/empty state.
1599
- // Re-check in 30s to start the next cycle (hoe→water→plant→harvest).
1600
- nextCd = Math.min(nextCd, 30);
1601
- LOG.info(`[farm] harvest complete (+⏣ ${coins.toLocaleString()}), farm is ${afterHarvestState.stage} re-checking in 30s`);
1617
+ const hasGrowTimer = Number.isFinite(growReadyEnd) && growReadyEnd > 0;
1618
+ if (hasGrowTimer) {
1619
+ // Crops were planted and a grow queue timer exists (e.g. "ready in 26m").
1620
+ // Respect that timer the 30s cap should NOT override it.
1621
+ LOG.info(`[farm] harvest complete (+⏣ ${coins.toLocaleString()}), farm is ${afterHarvestState.stage}, grow in ${Math.ceil(growReadyEnd / 60)}m — waiting for grow queue`);
1622
+ } else {
1623
+ // No grow queue timer visible (farm is truly empty or manage menu without timestamp).
1624
+ // Short re-check to start the next hoe→water→plant→harvest cycle.
1625
+ nextCd = Math.min(nextCd, 30);
1626
+ LOG.info(`[farm] harvest complete (+⏣ ${coins.toLocaleString()}), farm is ${afterHarvestState.stage} — re-checking in 30s`);
1627
+ }
1602
1628
  }
1603
1629
  // Record in Redis for cross-instance awareness
1604
1630
  let growDurMs = null;
@@ -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
+ };
@@ -206,7 +206,7 @@ async function runStream({ channel, waitForDankMemer, client }) {
206
206
 
207
207
  if (isHoldTight(response)) {
208
208
  const reason = getHoldTightReason(response);
209
- return { result: `hold tight (${reason || 'unknown'})`, coins: 0, holdTightReason: reason };
209
+ return { result: `hold tight (${reason || 'unknown'})`, coins: 0, holdTightReason: reason, nextCooldownSec: 30 };
210
210
  }
211
211
 
212
212
  await hydrate(response);
@@ -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),
@@ -418,7 +418,7 @@ async function runWorkShift({ channel, waitForDankMemer }) {
418
418
  const reason = getHoldTightReason(current);
419
419
  LOG.warn(`[work] Hold Tight${reason ? ` (reason: /${reason})` : ''} — waiting 30s`);
420
420
  await sleep(30000);
421
- return { result: `hold tight (${reason || 'unknown'})`, coins: 0, holdTightReason: reason };
421
+ return { result: `hold tight (${reason || 'unknown'})`, coins: 0, holdTightReason: reason, nextCooldownSec: 30 };
422
422
  }
423
423
 
424
424
  if (isCV2(current)) await ensureCV2(current);