dankgrinder 8.107.0 → 8.110.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.
@@ -108,14 +108,31 @@ const SAFE_KEYWORDS = Object.freeze(['flee', 'run', 'hide', 'avoid', 'ignore', '
108
108
  const RISKY_KEYWORDS = Object.freeze(['reach', 'grab', 'fight', 'attack', 'steal', 'open', 'touch', 'eat', 'drink']);
109
109
  const ADVENTURE_PREFERRED_TYPES = Object.freeze(['space', 'out west']);
110
110
 
111
- function pickSafeChoice(choices) {
111
+ function pickSafeChoice(choices, adventureAnswers) {
112
112
  if (choices.length === 0) return null;
113
113
  if (choices.length === 1) return choices[0];
114
114
 
115
- // Check labels for safe/risky keywords
116
115
  const labels = choices.map(b => (b.label || '').toLowerCase());
117
116
 
118
- // Prefer safe keywords
117
+ // ── 1) Config answer maps (dmg pattern — highest priority) ──
118
+ if (adventureAnswers) {
119
+ for (const [question, answers] of Object.entries(adventureAnswers)) {
120
+ const matched = labels.find(l => l.includes(question));
121
+ if (matched && Array.isArray(answers)) {
122
+ for (const answer of answers) {
123
+ const found = choices.find(c =>
124
+ (c.label || '').toLowerCase().includes(answer.toLowerCase())
125
+ );
126
+ if (found) {
127
+ LOG.info(`[adventure] Config answer: "${found.label}" matched question "${question}"`);
128
+ return found;
129
+ }
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ // ── 2) Hardcoded safe keywords ──
119
136
  for (let i = 0; i < labels.length; i++) {
120
137
  for (let k = 0; k < SAFE_KEYWORDS.length; k++) {
121
138
  const kw = SAFE_KEYWORDS[k];
@@ -126,7 +143,7 @@ function pickSafeChoice(choices) {
126
143
  }
127
144
  }
128
145
 
129
- // Avoid risky keywords
146
+ // ── 3) Avoid risky keywords ──
130
147
  const nonRisky = choices.filter((b, i) => {
131
148
  return !RISKY_KEYWORDS.some(kw => labels[i].includes(kw));
132
149
  });
@@ -143,7 +160,7 @@ function pickSafeChoice(choices) {
143
160
  }
144
161
 
145
162
  // ── Play through all adventure rounds ────────────────────────────
146
- async function playAdventureRounds(channel, msg) {
163
+ async function playAdventureRounds(channel, msg, adventureAnswers) {
147
164
  let current = msg;
148
165
  let interactions = 0;
149
166
  const MAX_INTERACTIONS = 30;
@@ -171,7 +188,7 @@ async function playAdventureRounds(channel, msg) {
171
188
  // ── If there are choices, pick one first ─────────────────
172
189
  if (choices.length > 0) {
173
190
  LOG.info(`[adventure] ${choices.length} choices: [${choices.map(b => `"${b.label}"`).join(', ')}]`);
174
- const choice = pickSafeChoice(choices);
191
+ const choice = pickSafeChoice(choices, adventureAnswers);
175
192
  if (choice) {
176
193
  LOG.info(`[adventure] → Choosing: "${choice.label}"`);
177
194
  await sleep(50);
@@ -258,9 +275,10 @@ async function playAdventureRounds(channel, msg) {
258
275
  * @param {object} opts.channel - Discord channel
259
276
  * @param {function} opts.waitForDankMemer - Waits for Dank Memer response
260
277
  * @param {object} [opts.client] - Discord client (for modal handling in shop)
278
+ * @param {object} [opts.adventureAnswers] - Per-type answer map from AccountWorker.ADVENTURE_ANSWERS
261
279
  * @returns {Promise<{result: string, coins: number, nextCooldownSec: number|null}>}
262
280
  */
263
- async function runAdventure({ channel, waitForDankMemer, client }) {
281
+ async function runAdventure({ channel, waitForDankMemer, client, adventureAnswers }) {
264
282
  LOG.cmd(`${c.white}${c.bold}pls adventure${c.reset}`);
265
283
 
266
284
  // Step 1: Send the command
@@ -334,7 +352,7 @@ async function runAdventure({ channel, waitForDankMemer, client }) {
334
352
  if (hasNextBtn && menus.length === 0) {
335
353
  // Already mid-adventure from a previous run — jump straight to rounds
336
354
  LOG.info('[adventure] Resuming mid-adventure...');
337
- const { text: finalText, coins, interactions, rewards, finalMsg } = await playAdventureRounds(channel, response);
355
+ const { text: finalText, coins, interactions, rewards, finalMsg } = await playAdventureRounds(channel, response, adventureAnswers);
338
356
  return buildResult(finalText, coins, interactions, rewards, finalMsg);
339
357
  }
340
358
 
@@ -434,7 +452,7 @@ async function runAdventure({ channel, waitForDankMemer, client }) {
434
452
  }
435
453
 
436
454
  // ── Play through all adventure rounds ──────────────────────
437
- const { text: finalText, coins, interactions, rewards, finalMsg } = await playAdventureRounds(channel, response);
455
+ const { text: finalText, coins, interactions, rewards, finalMsg } = await playAdventureRounds(channel, response, adventureAnswers);
438
456
  return buildResult(finalText, coins, interactions, rewards, finalMsg);
439
457
  }
440
458
 
@@ -16,7 +16,7 @@ const { runPostMemes } = require('./postmemes');
16
16
  const { runScratch } = require('./scratch');
17
17
  const { runBlackjack } = require('./blackjack');
18
18
  const { runTrivia, triviaDB } = require('./trivia');
19
- const { runWorkShift } = require('./work');
19
+ const { runWorkShift, autoApplyForJob } = require('./work');
20
20
  const { runCointoss, runRoulette, runSlots, runSnakeeyes } = require('./gamble');
21
21
  const { runDeposit } = require('./deposit');
22
22
  const { runGeneric, runAlert } = require('./generic');
@@ -44,6 +44,7 @@ module.exports = {
44
44
  runBlackjack,
45
45
  runTrivia,
46
46
  runWorkShift,
47
+ autoApplyForJob,
47
48
  runCointoss,
48
49
  runRoulette,
49
50
  runSlots,
package/lib/grinder.js CHANGED
@@ -598,6 +598,37 @@ class MinHeap {
598
598
 
599
599
  get size() { return this.heap.length; }
600
600
 
601
+ // ── Message handler registration (dmg pattern) ──────────────
602
+ // Allows plugins/commands to register handlers for gateway events.
603
+ // handlers: { messageCreate: Map<name, fn>, messageUpdate: Map<name, fn> }
604
+ // ── Dispatch events to registered handlers (dmg pattern) ──
605
+ _dispatchToHandlers(event, msg) {
606
+ const handlers = this._handlers[event];
607
+ if (!handlers || handlers.size === 0) return;
608
+ for (const [name, fn] of handlers) {
609
+ try {
610
+ const result = fn(msg);
611
+ if (result && typeof result.then === 'function') {
612
+ result.catch(e => this.log('error', `[handler:${name}] ${e.message}`));
613
+ }
614
+ } catch (e) {
615
+ this.log('error', `[handler:${name}] ${e.message}`);
616
+ }
617
+ }
618
+ }
619
+
620
+ _registerHandler(event, name, fn) {
621
+ if (this._handlers[event]) {
622
+ this._handlers[event].set(name, fn);
623
+ }
624
+ }
625
+
626
+ _unregisterHandler(event, name) {
627
+ if (this._handlers[event]) {
628
+ this._handlers[event].delete(name);
629
+ }
630
+ }
631
+
601
632
  _bubbleUp(i) {
602
633
  while (i > 0) {
603
634
  const parent = (i - 1) >> 1;
@@ -670,12 +701,21 @@ class AccountWorker {
670
701
  this._levelQuestActive = false;
671
702
  this._levelQuestQueue = [];
672
703
  this._levelQuestDone = new Set();
673
- this._levelQuestProgressCache = new Map();
704
+ this._levelQuestProgressCache = new Map();
705
+ this._levelQuestRunMap = new Map(); // level -> { startedAt, updatedAt, entries: Map<cmd, state> }
674
706
  this._questBetOverride = null;
675
- this._lastCommandMeta = null;
707
+ this._lastCommandMeta = null;
676
708
  this._commandRunning = false; // prevents grinding commands from overlapping with quest commands
677
709
  this._verifyLevelUnlock = null; // holds level to verify after quest completion
678
710
  this.commandQueue = null;
711
+
712
+ // ── Message handler routing (dmg pattern) ─────────────────
713
+ // Map of event → handler functions. Plugins register here.
714
+ // Usage: _handlers.messageCreate.get('adventure')?.(msg)
715
+ this._handlers = {
716
+ messageCreate: new Map(),
717
+ messageUpdate: new Map(),
718
+ };
679
719
  this.lastHealthCheck = Date.now();
680
720
  this.doneToday = new Map();
681
721
 
@@ -761,6 +801,8 @@ class AccountWorker {
761
801
  }
762
802
  function handler(msg) {
763
803
  if (msg.author.id === DANK_MEMER_ID && msg.channel.id === self.channel.id) {
804
+ // ── Dispatch to registered handlers (dmg pattern) ────
805
+ self._dispatchToHandlers('messageCreate', msg);
764
806
  const hasComponentPayload = Array.isArray(msg.components)
765
807
  && msg.components.some(c => c && (c.components || c.content || c.type || c.label || c.customId));
766
808
  const hasContent = (msg.content && msg.content.length > 0)
@@ -790,6 +832,8 @@ class AccountWorker {
790
832
  // Reject edits to messages created well before our command was sent
791
833
  const msgTs = newMsg.createdTimestamp || newMsg.createdAt?.getTime?.() || 0;
792
834
  if (msgTs > 0 && msgTs < sentAt - 1500) return;
835
+ // ── Dispatch to registered handlers ─────────────────
836
+ self._dispatchToHandlers('messageUpdate', newMsg);
793
837
  cleanup();
794
838
  resolve(newMsg);
795
839
  }
@@ -843,148 +887,13 @@ class AccountWorker {
843
887
  }
844
888
 
845
889
  async buyItem(itemName, quantity = 1) {
846
- const MAX_RETRIES = 1;
847
- for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
848
- this.log('buy', `Opening shop to buy ${c.bold}${quantity}x ${itemName}${c.reset}... (attempt ${attempt}/${MAX_RETRIES})`);
849
- if (this.account.use_slash) {
850
- await this.channel.sendSlash(DANK_MEMER_ID, 'shop', 'view').catch(() => this.channel.send('pls shop view'));
851
- } else {
852
- await this.channel.send('pls shop view');
853
- }
854
- let response = await this.waitForDankMemer(10000);
855
- if (!response) {
856
- this.log('warn', 'No response to shop view command.');
857
- if (attempt < MAX_RETRIES) { await humanDelay(2000, 4000); continue; }
858
- return false;
859
- }
860
-
861
- const responseText = getFullText(response).toLowerCase();
862
- const hasShopComponents = (response.components || []).some(row =>
863
- (row.components || []).some(comp => comp.type === 3 || (comp.type === 2 && comp.label && comp.label.toLowerCase().includes('buy')))
864
- );
865
-
866
- if (!hasShopComponents && (responseText.includes('lucky') || responseText.includes('event') || responseText.includes('for the rest of the day'))) {
867
- this.log('warn', 'Got event response instead of shop. Retrying...');
868
- await humanDelay(3000, 5000);
869
- continue;
870
- }
871
- if (!hasShopComponents && responseText.includes('shop')) {
872
- const shopUI = await this.waitForDankMemer(8000);
873
- if (shopUI) response = shopUI;
874
- }
875
-
876
- // Navigate to Coin Shop
877
- let coinShopMenuId = null;
878
- let coinShopOption = null;
879
- for (const row of response.components || []) {
880
- for (const comp of row.components || []) {
881
- if (comp.type === 3) {
882
- const opt = (comp.options || []).find(o => o.label && o.label.includes('Coin Shop'));
883
- if (opt) { coinShopMenuId = comp.customId; coinShopOption = opt; }
884
- }
885
- }
886
- }
887
-
888
- if (coinShopMenuId && coinShopOption) {
889
- this.log('buy', 'Navigating to Coin Shop...');
890
- try {
891
- await response.selectMenu(coinShopMenuId, [coinShopOption.value]);
892
- const updatedMsg = await this.waitForDankMemer(8000);
893
- if (updatedMsg) response = updatedMsg;
894
- } catch (e) {
895
- this.log('error', `Failed to open Coin Shop: ${e.message}`);
896
- }
897
- }
898
- await humanDelay(300, 600);
899
-
900
- // Find Buy button — match by full name or partial name
901
- let buyBtn = null;
902
- const searchNames = [
903
- itemName.toLowerCase(),
904
- itemName.toLowerCase().replace('hunting ', '').replace('fishing ', ''),
905
- itemName.toLowerCase().split(' ')[0],
906
- ];
907
- for (const row of response.components || []) {
908
- for (const comp of row.components || []) {
909
- if (comp.type !== 2 || !comp.label) continue;
910
- const label = comp.label.toLowerCase();
911
- if (searchNames.some(s => label.includes(s) || s.includes(label))) {
912
- buyBtn = comp; break;
913
- }
914
- }
915
- if (buyBtn) break;
916
- }
917
-
918
- if (!buyBtn) {
919
- this.log('warn', `Could not find Buy button for ${itemName} (attempt ${attempt})`);
920
- if (attempt < MAX_RETRIES) { await humanDelay(2000, 4000); continue; }
921
- return false;
922
- }
923
-
924
- this.log('buy', `Clicking Buy ${itemName}...`);
925
- try { await safeClickButton(response, buyBtn); } catch (e) {
926
- this.log('error', `Buy click failed: ${e.message}`);
927
- if (attempt < MAX_RETRIES) { await humanDelay(2000, 4000); continue; }
928
- return false;
929
- }
930
-
931
- // Handle Modal
932
- const modal = await new Promise((resolve) => {
933
- const timer = setTimeout(() => resolve(null), 8000);
934
- const handler = (m) => {
935
- clearTimeout(timer);
936
- this.client.removeListener('interactionModalCreate', handler);
937
- resolve(m);
938
- };
939
- this.client.on('interactionModalCreate', handler);
940
- });
941
-
942
- if (modal) {
943
- this.log('buy', `Submitting quantity ${c.bold}${quantity}${c.reset} in modal...`);
944
- try {
945
- const quantityInputId = modal.components[0].components[0].customId;
946
- await fetch('https://discord.com/api/v9/interactions', {
947
- method: 'POST',
948
- headers: { 'Authorization': this.client.token, 'Content-Type': 'application/json' },
949
- body: JSON.stringify({
950
- type: 5, application_id: modal.applicationId,
951
- channel_id: this.channel.id, guild_id: this.channel.guild?.id,
952
- data: {
953
- id: modal.id, custom_id: modal.customId,
954
- components: [{ type: 1, components: [{ type: 4, custom_id: quantityInputId, value: String(quantity) }] }]
955
- },
956
- session_id: this.client.sessionId || "dummy_session",
957
- nonce: Date.now().toString()
958
- })
959
- });
960
- } catch (e) {
961
- this.log('error', `Modal submit failed: ${e.message}`);
962
- if (attempt < MAX_RETRIES) { await humanDelay(2000, 4000); continue; }
963
- return false;
964
- }
965
- } else {
966
- this.log('warn', 'No modal appeared after clicking buy.');
967
- if (attempt < MAX_RETRIES) { await humanDelay(2000, 4000); continue; }
968
- return false;
969
- }
970
-
971
- const confirmMsg = await this.waitForDankMemer(8000);
972
- if (confirmMsg) {
973
- const text = getFullText(confirmMsg).toLowerCase();
974
- if (text.includes('bought') || text.includes('purchased') || text.includes('success')) {
975
- this.log('success', `Bought ${c.bold}${quantity}x ${itemName}${c.reset}!`);
976
- return true;
977
- }
978
- if (text.includes('not enough') || text.includes("can't afford") || text.includes('insufficient')) {
979
- this.log('warn', `Not enough coins to buy ${itemName}.`);
980
- return false;
981
- }
982
- }
983
- this.log('success', `Submitted purchase for ${quantity}x ${itemName}.`);
984
- return true;
985
- }
986
- this.log('error', `Failed to buy ${itemName} after ${MAX_RETRIES} attempts.`);
987
- return false;
890
+ return !!(await commands.buyItem({
891
+ channel: this.channel,
892
+ waitForDankMemer: (timeout) => this.waitForDankMemer(timeout),
893
+ client: this.client,
894
+ itemName,
895
+ quantity,
896
+ }));
988
897
  }
989
898
 
990
899
  // ── Check Balance ───────────────────────────────────────────
@@ -1387,7 +1296,7 @@ class AccountWorker {
1387
1296
  // Each modular command handler sends the command, waits for response,
1388
1297
  // handles Hold Tight / cooldowns / item-buying internally.
1389
1298
  async runCommand(cmdName, prefix) {
1390
- this._lastCommandMeta = { cmdName, nonPlay: false, spent: 0, earned: 0, holdTightReason: null, newMinBet: null, levelLocked: null };
1299
+ this._lastCommandMeta = { cmdName, nonPlay: false, spent: 0, earned: 0, holdTightReason: null, newMinBet: null, levelLocked: null, nextRetrySec: 0 };
1391
1300
  let cmdString;
1392
1301
  const bjBet = Math.max(5000, this.account.bet_amount || 5000);
1393
1302
  const gambBet = Math.max(10000, this.account.bet_amount || 10000);
@@ -1490,6 +1399,7 @@ class AccountWorker {
1490
1399
  client: this.client,
1491
1400
  safeAnswers: cmdName === 'search' ? safeParseJSON(this.account.search_answers, []) :
1492
1401
  cmdName === 'crime' ? safeParseJSON(this.account.crime_answers, []) : [],
1402
+ adventureAnswers: AccountWorker.ADVENTURE_ANSWERS,
1493
1403
  betAmount: this._questBetOverride || (['blackjack'].includes(cmdName) ? bjBet : gambBet),
1494
1404
  accountId: this.account.id,
1495
1405
  redis,
@@ -1502,7 +1412,7 @@ class AccountWorker {
1502
1412
  case 'search': cmdResult = await commands.runSearch(cmdOpts); break;
1503
1413
  case 'crime': cmdResult = await commands.runCrime(cmdOpts); break;
1504
1414
  case 'hl': cmdResult = await commands.runHighLow(cmdOpts); break;
1505
- case 'farm': cmdResult = await commands.runFarm(cmdOpts); break;
1415
+ case 'farm': cmdResult = await commands.runFarm(cmdOpts); break;
1506
1416
  case 'pm': cmdResult = await commands.runPostMemes(cmdOpts); break;
1507
1417
  case 'hunt': cmdResult = await commands.runHunt(cmdOpts); break;
1508
1418
  case 'dig': cmdResult = await commands.runDig(cmdOpts); break;
@@ -1512,6 +1422,20 @@ class AccountWorker {
1512
1422
  case 'blackjack': cmdResult = await commands.runBlackjack(cmdOpts); break;
1513
1423
  case 'trivia': cmdResult = await commands.runTrivia(cmdOpts); break;
1514
1424
  case 'work shift': cmdResult = await commands.runWorkShift(cmdOpts); break;
1425
+ case 'work apply': {
1426
+ const ar = await commands.autoApplyForJob(cmdOpts);
1427
+ cmdResult = ar?.applied
1428
+ ? { result: 'work apply completed', coins: 0, nextCooldownSec: 8 }
1429
+ : { result: `work apply failed${ar?.cooldownSec ? ` (${Math.ceil(ar.cooldownSec / 60)}m)` : ''}`, coins: 0, nonPlay: true, nextCooldownSec: ar?.cooldownSec || 60 };
1430
+ break;
1431
+ }
1432
+ case 'shop view': {
1433
+ const bought = await commands.buyItem({ ...cmdOpts, itemName: 'Shovel', quantity: 1 });
1434
+ cmdResult = bought
1435
+ ? { result: 'shop buy item completed', coins: 0, nextCooldownSec: 10 }
1436
+ : { result: 'shop buy item failed', coins: 0, nonPlay: true, nextCooldownSec: 25 };
1437
+ break;
1438
+ }
1515
1439
  case 'cointoss': cmdResult = await commands.runCointoss(cmdOpts); break;
1516
1440
  case 'roulette': cmdResult = await commands.runRoulette(cmdOpts); break;
1517
1441
  case 'slots': cmdResult = await commands.runSlots(cmdOpts); break;
@@ -1526,6 +1450,7 @@ class AccountWorker {
1526
1450
 
1527
1451
  const result = cmdResult.result || 'done';
1528
1452
  const resultLower = result.toLowerCase();
1453
+ this._lastCommandMeta.result = result;
1529
1454
 
1530
1455
  // Rate limit detection — progressive backoff based on frequency
1531
1456
  if (resultLower.includes('slow down') || resultLower.includes('rate limit') || resultLower.includes('too fast')) {
@@ -1535,6 +1460,7 @@ class AccountWorker {
1535
1460
  const cooldownSec = Math.min(30 * Math.pow(2, Math.min(this._rateLimitHits - 1, 3)), 300);
1536
1461
  this.log('warn', `Rate limited! ${cooldownSec}s cooldown (hit #${this._rateLimitHits})`);
1537
1462
  this.globalCooldownUntil = Date.now() + cooldownSec * 1000;
1463
+ this._lastCommandMeta.nextRetrySec = cooldownSec;
1538
1464
  await this.setCooldown(cmdName, cooldownSec);
1539
1465
  // Reset rate limit count after 10 minutes of no hits
1540
1466
  setTimeout(() => { if (this._rateLimitHits > 0) this._rateLimitHits = Math.max(0, this._rateLimitHits - 1); }, 600_000);
@@ -1545,6 +1471,8 @@ class AccountWorker {
1545
1471
  if (resultLower.includes('cannot post another meme') || resultLower.includes('dead meme')) {
1546
1472
  const minMatch = result.match(/(\d+)\s*minute/i);
1547
1473
  const cdSec = minMatch ? parseInt(minMatch[1]) * 60 + 30 : 150; // dead meme = N min + 30s buffer
1474
+ this._lastCommandMeta.nonPlay = true;
1475
+ this._lastCommandMeta.nextRetrySec = cdSec;
1548
1476
  this.log('warn', `${cmdName} on cooldown: ${cdSec}s`);
1549
1477
  await this.setCooldown(cmdName, cdSec);
1550
1478
  return;
@@ -1565,6 +1493,28 @@ class AccountWorker {
1565
1493
  this._mergeLevelQuestProgress(targetLv, progress);
1566
1494
  }
1567
1495
  this.log('warn', `Command /${cmdName} requires Level ${targetLv} quests`);
1496
+ } else {
1497
+ // Fallback: some responses are truncated/ephemeral and omit "Level X" in result text.
1498
+ // Run one profile check and infer lock level from raw profile text.
1499
+ try {
1500
+ const profile = await this.checkProfile(true);
1501
+ const profileText = String(profile?.rawText || '');
1502
+ if (profileText.toLowerCase().includes('not unlocked') || profileText.toLowerCase().includes('have not unlocked')) {
1503
+ const pm = profileText.match(/level\s*(\d+)/i);
1504
+ if (pm) {
1505
+ const targetLv = parseInt(pm[1], 10);
1506
+ if (Number.isFinite(targetLv) && targetLv > 0) {
1507
+ cmdResult.levelLocked = targetLv;
1508
+ const progress = this._parseLevelQuestProgress(targetLv, profileText);
1509
+ if (progress) {
1510
+ cmdResult.levelQuestProgress = progress;
1511
+ this._mergeLevelQuestProgress(targetLv, progress);
1512
+ }
1513
+ this.log('warn', `Command /${cmdName} requires Level ${targetLv} quests (profile fallback)`);
1514
+ }
1515
+ }
1516
+ }
1517
+ } catch {}
1568
1518
  }
1569
1519
  }
1570
1520
 
@@ -1660,6 +1610,7 @@ class AccountWorker {
1660
1610
  if (resultLower.includes('already got your daily') || resultLower.includes('already got your weekly') ||
1661
1611
  resultLower.includes('already got your monthly') || resultLower.includes('already claimed') ||
1662
1612
  resultLower.includes('try again <t:')) {
1613
+ this._lastCommandMeta.nonPlay = true;
1663
1614
  this.log('info', `${cmdName} already claimed — waiting`);
1664
1615
  const timeMatch = result.match(/<t:(\d+):R>/);
1665
1616
  let waitSec;
@@ -1670,6 +1621,7 @@ class AccountWorker {
1670
1621
  const defaultWaits = { daily: 86400, weekly: 604800, monthly: 2592000 };
1671
1622
  waitSec = defaultWaits[cmdName] || 86400;
1672
1623
  }
1624
+ this._lastCommandMeta.nextRetrySec = waitSec;
1673
1625
  await this.setCooldown(cmdName, waitSec);
1674
1626
  this.doneToday.set(cmdName, Date.now() + waitSec * 1000);
1675
1627
  if (redis) try { await redis.set(`dkg:done:${this.account.id}:${cmdName}`, '1', 'EX', waitSec); } catch {}
@@ -1695,6 +1647,7 @@ class AccountWorker {
1695
1647
  // Track net earnings (add wins, subtract losses)
1696
1648
  this.stats.coins += (earned - spent);
1697
1649
  if (cmdResult.nextCooldownSec) {
1650
+ this._lastCommandMeta.nextRetrySec = Math.max(this._lastCommandMeta.nextRetrySec || 0, cmdResult.nextCooldownSec);
1698
1651
  await this.setCooldown(cmdName, cmdResult.nextCooldownSec);
1699
1652
  this._lastCooldownOverride = cmdResult.nextCooldownSec;
1700
1653
  // Learn: record this cooldown as the known value for future fallback use
@@ -1738,6 +1691,7 @@ class AccountWorker {
1738
1691
  this._lastCommandMeta.nonPlay = true;
1739
1692
  this._lastCommandMeta.holdTightReason = reason;
1740
1693
  const holdSec = 35;
1694
+ this._lastCommandMeta.nextRetrySec = Math.max(this._lastCommandMeta.nextRetrySec || 0, holdSec);
1741
1695
  this.log('warn', `Hold Tight: /${reason} — ${holdSec}s global cooldown`);
1742
1696
  const reasonMap = { postmemes: 'pm', highlow: 'hl', blackjack: 'bj', 'work shift': 'work shift' };
1743
1697
  const mappedCmd = reasonMap[reason] || reason;
@@ -1911,16 +1865,63 @@ class AccountWorker {
1911
1865
  ].map(Object.freeze);
1912
1866
 
1913
1867
  // ── Level Quest System — blocks grinding until quests complete ──
1868
+ // ── Adventure answer maps (per type) ──────────────────────────
1869
+ // Pattern: question text → answer choice to click
1870
+ // Supports: space, out west, brazil, vacation, halloween, museum
1871
+ static ADVENTURE_ANSWERS = {
1872
+ space: {
1873
+ 'what do you want to do': ['search for debris', 'scavenge wreckage'],
1874
+ 'how do you respond': ['stay calm', 'analyze situation'],
1875
+ 'what do you choose': ['try to communicate', 'observe first'],
1876
+ },
1877
+ west: {
1878
+ 'what do you do': ['draw first', 'hide behind cover'],
1879
+ 'how do you respond': ['draw', 'shoot'],
1880
+ 'sheriff wants': ['accept bounty', 'decline'],
1881
+ },
1882
+ brazil: {
1883
+ 'what do you want to do': ['search for treasure', 'ask locals'],
1884
+ 'how do you respond': ['negotiate', 'accept offer'],
1885
+ 'locals offer': ['take the deal', 'refuse'],
1886
+ },
1887
+ vacation: {
1888
+ 'stranger offers': ['accept', 'decline'],
1889
+ 'what do you do': ['explore', 'rest'],
1890
+ 'something suspicious': ['investigate', 'ignore'],
1891
+ },
1892
+ halloween: {
1893
+ 'knock at door': ['open it', 'hide'],
1894
+ 'what do you do': ['investigate', 'run away'],
1895
+ 'ghost appears': ['speak to it', 'hide'],
1896
+ },
1897
+ museum: {
1898
+ 'what do you do': ['touch exhibit', 'keep distance'],
1899
+ 'alarm sounds': ['hide', 'run'],
1900
+ 'guard asks': ['tell truth', 'lie'],
1901
+ },
1902
+ };
1903
+
1914
1904
  static LEVEL_QUESTS = {
1915
1905
  1: [
1916
1906
  { cmd: 'beg', times: 2 },
1917
1907
  { cmd: 'search', times: 2 },
1918
1908
  { cmd: 'tidy', times: 2 },
1919
1909
  ],
1910
+ 2: [
1911
+ { cmd: 'inventory', times: 1, progressMatch: 'inventory', progressSlash: 'inventory' },
1912
+ { cmd: 'balance', times: 1, progressMatch: 'balance', progressSlash: 'balance' },
1913
+ { cmd: 'hunt', times: 2, progressMatch: 'hunt', progressSlash: 'hunt' },
1914
+ { cmd: 'dig', times: 2, progressMatch: 'dig', progressSlash: 'dig' },
1915
+ ],
1920
1916
  3: [
1921
- { cmd: 'work apply', times: 1 },
1922
- { cmd: 'work shift', times: 1 },
1923
- { cmd: 'shop sell common coin 1', times: 2 },
1917
+ { cmd: 'work apply', times: 1, progressMatch: 'get a job', progressSlash: 'work apply' },
1918
+ { cmd: 'work shift', times: 1, progressMatch: 'work a shift', progressSlash: 'work shift' },
1919
+ { cmd: 'shop sell item shovel 1',times: 2, progressMatch: 'sell items', progressSlash: 'shop sell' },
1920
+ { cmd: 'shop view', times: 1, progressMatch: 'buy an item', progressSlash: 'shop view' },
1921
+ ],
1922
+ 4: [
1923
+ // Keep this command-driven so the quest runner can continue progression.
1924
+ { cmd: 'bal', times: 1, progressMatch: 'balance', progressSlash: 'balance' },
1924
1925
  ],
1925
1926
  5: [
1926
1927
  // "Lose 50,000 coins in /slots" etc — loseTarget means keep playing until cumulative losses hit 50k
@@ -1928,24 +1929,89 @@ class AccountWorker {
1928
1929
  { cmd: 'cointoss', loseTarget: 50000, bet: 10000, times: 50 },
1929
1930
  { cmd: 'snakeeyes', loseTarget: 50000, bet: 10000, times: 50 },
1930
1931
  ],
1932
+ 6: [
1933
+ // Slash text in lock-card maps to regular pls command strings below.
1934
+ { cmd: 'item new player pack', times: 1, progressMatch: 'new player pack', progressSlash: 'item' },
1935
+ { cmd: 'use player 1', times: 1, progressMatch: 'new player pack', progressSlash: 'use' },
1936
+ { cmd: 'use normie 1', times: 1, progressMatch: 'normie box', progressSlash: 'use' },
1937
+ ],
1938
+ 7: [
1939
+ { cmd: 'title set newbie', times: 1, progressMatch: 'newbie title', progressSlash: 'title' },
1940
+ { cmd: 'profile', times: 1, progressMatch: 'profile', progressSlash: 'profile' },
1941
+ { cmd: 'daily', times: 1, progressMatch: 'daily', progressSlash: 'daily' },
1942
+ { cmd: 'hl', times: 3, progressMatch: 'highlow', progressSlash: 'highlow' },
1943
+ ],
1931
1944
  };
1932
1945
 
1933
1946
  _parseLevelQuestProgress(targetLevel, rawText) {
1934
- if (targetLevel !== 5) return null;
1935
1947
  const text = String(rawText || '');
1936
1948
  const out = {};
1937
- const re = /(\d[\d,]*)\s*\/\s*50,000[\s\S]{0,180}?<\/(slots|cointoss|snakeeyes):/gi;
1938
- let m;
1939
- while ((m = re.exec(text)) !== null) {
1940
- const cur = parseInt(String(m[1] || '').replace(/,/g, ''), 10);
1941
- const cmd = String(m[2] || '').toLowerCase();
1942
- if (Number.isFinite(cur) && cmd) out[cmd] = Math.max(0, cur);
1949
+
1950
+ if (targetLevel === 5) {
1951
+ const re = /(\d[\d,]*)\s*\/\s*50,000[\s\S]{0,180}?<\/(slots|cointoss|snakeeyes):/gi;
1952
+ let m;
1953
+ while ((m = re.exec(text)) !== null) {
1954
+ const cur = parseInt(String(m[1] || '').replace(/,/g, ''), 10);
1955
+ const cmd = String(m[2] || '').toLowerCase();
1956
+ if (Number.isFinite(cur) && cmd) out[cmd] = Math.max(0, cur);
1957
+ }
1943
1958
  }
1959
+
1960
+ if (targetLevel === 6) {
1961
+ // handled below by generic times-based parser
1962
+ }
1963
+
1964
+ if (targetLevel !== 5) {
1965
+ const quests = AccountWorker.LEVEL_QUESTS[targetLevel] || [];
1966
+ const lines = text.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
1967
+ for (const q of quests) {
1968
+ if (q.loseTarget) continue;
1969
+ const cmd = String(q.cmd || '').toLowerCase();
1970
+ const cmdParts = cmd.split(/\s+/).filter(Boolean);
1971
+ if (cmdParts.length === 0) continue;
1972
+
1973
+ const slashHints = new Set();
1974
+ slashHints.add(cmdParts[0]);
1975
+ if (q.progressSlash) {
1976
+ for (const p of String(q.progressSlash).toLowerCase().split(/[\s,/|]+/).filter(Boolean)) {
1977
+ slashHints.add(p);
1978
+ }
1979
+ }
1980
+ if (cmd === 'hl') slashHints.add('highlow');
1981
+ if (cmd === 'highlow') slashHints.add('hl');
1982
+
1983
+ const matchPhrase = String(q.progressMatch || q.cmd || '').toLowerCase();
1984
+ const tail = matchPhrase
1985
+ .split(/\s+/)
1986
+ .filter(Boolean)
1987
+ .filter(t => !/^\d+$/.test(t))
1988
+ .filter(t => t !== cmdParts[0]);
1989
+
1990
+ for (const line of lines) {
1991
+ const lower = line.toLowerCase();
1992
+ const hasSlash = Array.from(slashHints).some(h => lower.includes(`/${h}`));
1993
+ if (!hasSlash) continue;
1994
+
1995
+ let ok = true;
1996
+ for (const token of tail) {
1997
+ if (!lower.includes(token)) { ok = false; break; }
1998
+ }
1999
+ if (!ok) continue;
2000
+
2001
+ const m = lower.match(/(\d[\d,]*)\s*\/\s*(\d[\d,]*)/);
2002
+ if (!m) continue;
2003
+ const cur = parseInt(String(m[1] || '').replace(/,/g, ''), 10);
2004
+ if (!Number.isFinite(cur)) continue;
2005
+ out[cmd] = Math.max(out[cmd] || 0, cur);
2006
+ }
2007
+ }
2008
+ }
2009
+
1944
2010
  return Object.keys(out).length > 0 ? out : null;
1945
2011
  }
1946
2012
 
1947
2013
  _mergeLevelQuestProgress(targetLevel, progressHint) {
1948
- if (!progressHint || targetLevel !== 5) return;
2014
+ if (!progressHint) return;
1949
2015
  const prev = this._levelQuestProgressCache.get(targetLevel) || {};
1950
2016
  const merged = { ...prev };
1951
2017
  for (const [cmd, val] of Object.entries(progressHint)) {
@@ -1963,13 +2029,31 @@ class AccountWorker {
1963
2029
 
1964
2030
  this._mergeLevelQuestProgress(targetLevel, progressHint);
1965
2031
  const cached = this._levelQuestProgressCache.get(targetLevel) || {};
1966
- const seededQueue = quests
2032
+ const seededAll = quests
1967
2033
  .map(q => {
1968
2034
  const target = Number(q.loseTarget || 0);
1969
- const seeded = target > 0 ? Math.min(target, Math.max(0, Number(cached[q.cmd] || 0))) : 0;
1970
- return { ...q, level: targetLevel, _lostSoFar: seeded };
1971
- })
1972
- .filter(q => !(q.loseTarget && q._lostSoFar >= q.loseTarget));
2035
+ const seededLoss = target > 0 ? Math.min(target, Math.max(0, Number(cached[q.cmd] || 0))) : 0;
2036
+ const seededDone = target === 0 ? Math.max(0, Math.min(Number(q.times || 0), Number(cached[q.cmd] || 0))) : 0;
2037
+ const timesRemaining = target === 0 ? Math.max(0, Number(q.times || 0) - seededDone) : Number(q.times || 0);
2038
+ const defaultCdSec = this._getCommandDefaultCooldownSec(q.cmd);
2039
+ return {
2040
+ ...q,
2041
+ level: targetLevel,
2042
+ _lostSoFar: seededLoss,
2043
+ _doneSoFar: seededDone,
2044
+ times: timesRemaining,
2045
+ _defaultCdSec: defaultCdSec,
2046
+ _nextRunAt: 0,
2047
+ };
2048
+ });
2049
+
2050
+ const seededQueue = seededAll
2051
+ .filter(q => {
2052
+ if (q.loseTarget) return q._lostSoFar < q.loseTarget;
2053
+ return q.times > 0;
2054
+ });
2055
+
2056
+ this._initLevelQuestRunState(targetLevel, seededAll);
1973
2057
 
1974
2058
  if (seededQueue.length === 0) {
1975
2059
  this.log('info', `[QUEST] Level ${targetLevel} tasks already complete from progress cache — verifying unlock`);
@@ -1997,6 +2081,71 @@ class AccountWorker {
1997
2081
  return true;
1998
2082
  }
1999
2083
 
2084
+ _getCommandDefaultCooldownSec(cmdName) {
2085
+ const cmd = String(cmdName || '').toLowerCase().trim();
2086
+ if (!cmd) return 8;
2087
+ const map = AccountWorker.COMMAND_MAP || [];
2088
+ const exact = map.find(r => String(r.cmd || '').toLowerCase() === cmd);
2089
+ if (exact && Number.isFinite(exact.defaultCd) && exact.defaultCd > 0) return exact.defaultCd;
2090
+ if (cmd === 'highlow') {
2091
+ const hl = map.find(r => String(r.cmd || '').toLowerCase() === 'hl');
2092
+ if (hl?.defaultCd) return hl.defaultCd;
2093
+ }
2094
+ const head = cmd.split(/\s+/)[0];
2095
+ const byHead = map.find(r => String(r.cmd || '').toLowerCase() === head);
2096
+ if (byHead && Number.isFinite(byHead.defaultCd) && byHead.defaultCd > 0) return byHead.defaultCd;
2097
+ return 10;
2098
+ }
2099
+
2100
+ _initLevelQuestRunState(targetLevel, questRows) {
2101
+ const startedAt = Date.now();
2102
+ const state = {
2103
+ level: targetLevel,
2104
+ startedAt,
2105
+ updatedAt: startedAt,
2106
+ completedAt: 0,
2107
+ entries: new Map(),
2108
+ };
2109
+
2110
+ for (const q of (questRows || [])) {
2111
+ const cmd = String(q.cmd || '').toLowerCase();
2112
+ if (!cmd) continue;
2113
+ const required = q.loseTarget
2114
+ ? Number(q.loseTarget || 0)
2115
+ : Math.max(0, Number(q._doneSoFar || 0) + Number(q.times || 0));
2116
+ const completed = q.loseTarget
2117
+ ? Math.max(0, Number(q._lostSoFar || 0))
2118
+ : Math.max(0, Number(q._doneSoFar || 0));
2119
+ const remaining = q.loseTarget
2120
+ ? Math.max(0, required - completed)
2121
+ : Math.max(0, Number(q.times || 0));
2122
+
2123
+ state.entries.set(cmd, {
2124
+ cmd,
2125
+ required,
2126
+ completed,
2127
+ remaining,
2128
+ attemptsLeft: Number(q.times || 0),
2129
+ runs: 0,
2130
+ success: 0,
2131
+ failed: 0,
2132
+ nonPlay: 0,
2133
+ lastResult: '',
2134
+ lastRunAt: 0,
2135
+ nextRunAt: Number(q._nextRunAt || 0),
2136
+ status: remaining <= 0 ? 'done' : 'pending',
2137
+ });
2138
+ }
2139
+
2140
+ this._levelQuestRunMap.set(targetLevel, state);
2141
+ }
2142
+
2143
+ _getLevelQuestRunEntry(level, cmdName) {
2144
+ const st = this._levelQuestRunMap.get(level);
2145
+ if (!st?.entries) return null;
2146
+ return st.entries.get(String(cmdName || '').toLowerCase()) || null;
2147
+ }
2148
+
2000
2149
  async buildCommandQueue() {
2001
2150
  const heap = new MinHeap();
2002
2151
  const now = Date.now();
@@ -2259,6 +2408,11 @@ class AccountWorker {
2259
2408
  this.log('success', `[QUEST] Level ${targetLv} verified ✓ — level unlocked!`);
2260
2409
  this._level = currentLv;
2261
2410
  this._levelQuestProgressCache.delete(targetLv);
2411
+ const st = this._levelQuestRunMap.get(targetLv);
2412
+ if (st) {
2413
+ st.updatedAt = Date.now();
2414
+ st.verifiedAt = Date.now();
2415
+ }
2262
2416
  } else {
2263
2417
  this.log('warn', `[QUEST] Level ${targetLv} NOT verified — still at ${currentLv}. Re-triggering quests.`);
2264
2418
  // Re-trigger quests for this level
@@ -2279,7 +2433,22 @@ class AccountWorker {
2279
2433
 
2280
2434
  // BLOCK: quest mode active — run quests only, no normal grinding
2281
2435
  if (this._levelQuestActive && this._levelQuestQueue.length > 0) {
2436
+ const now = Date.now();
2437
+ let dueIdx = this._levelQuestQueue.findIndex(q => !q._nextRunAt || q._nextRunAt <= now);
2438
+ if (dueIdx < 0) {
2439
+ const soonest = Math.min(...this._levelQuestQueue.map(q => Number(q._nextRunAt || now + 5000)));
2440
+ const waitMs = Math.max(1200, Math.min(60000, soonest - now));
2441
+ this.setStatus(`[QUEST] cooldown wait ${Math.ceil(waitMs / 1000)}s`);
2442
+ this.tickTimeout = setTimeout(() => this.tick(), waitMs);
2443
+ return;
2444
+ }
2445
+ if (dueIdx > 0) {
2446
+ const [dueQuest] = this._levelQuestQueue.splice(dueIdx, 1);
2447
+ this._levelQuestQueue.unshift(dueQuest);
2448
+ }
2449
+
2282
2450
  const quest = this._levelQuestQueue[0];
2451
+ let nextQuestDelayMs = 1200;
2283
2452
  if (quest.loseTarget) {
2284
2453
  const remaining = Math.max(0, quest.loseTarget - (quest._lostSoFar || 0));
2285
2454
  const floorBet = quest._minBet || quest.bet || 1;
@@ -2306,6 +2475,12 @@ class AccountWorker {
2306
2475
  this.busy = true;
2307
2476
  await this.runCommand(quest.cmd, prefix);
2308
2477
  const questMeta = this._lastCommandMeta || {};
2478
+ const qEntry = this._getLevelQuestRunEntry(quest.level, quest.cmd);
2479
+ if (qEntry) {
2480
+ qEntry.runs++;
2481
+ qEntry.lastRunAt = Date.now();
2482
+ qEntry.lastResult = String(questMeta.result || '');
2483
+ }
2309
2484
  this._commandRunning = false;
2310
2485
  this.busy = false;
2311
2486
  this._questBetOverride = null;
@@ -2328,6 +2503,10 @@ class AccountWorker {
2328
2503
  if (!nonPlay) {
2329
2504
  quest._lostSoFar = (quest._lostSoFar || 0) + lostThisRound;
2330
2505
  this._mergeLevelQuestProgress(quest.level, { [quest.cmd]: quest._lostSoFar });
2506
+ if (qEntry) qEntry.success++;
2507
+ } else if (qEntry) {
2508
+ qEntry.nonPlay++;
2509
+ qEntry.failed++;
2331
2510
  }
2332
2511
 
2333
2512
  this.log('info', `[QUEST] ${quest.cmd} — lost ⏣${quest._lostSoFar.toLocaleString()} / ⏣${quest.loseTarget.toLocaleString()}`);
@@ -2346,10 +2525,57 @@ class AccountWorker {
2346
2525
  questStepDone = true;
2347
2526
  this.log('info', `[QUEST] ${quest.cmd} max attempts reached — moving on`);
2348
2527
  }
2528
+
2529
+ if (qEntry) {
2530
+ qEntry.completed = Math.max(0, Number(quest._lostSoFar || 0));
2531
+ qEntry.remaining = Math.max(0, Number(quest.loseTarget || 0) - qEntry.completed);
2532
+ qEntry.attemptsLeft = Number(quest.times || 0);
2533
+ qEntry.status = questStepDone ? 'done' : (nonPlay ? 'cooldown' : 'progress');
2534
+ }
2349
2535
  } else {
2350
2536
  // Normal times-based quest
2351
- quest.times--;
2537
+ const nonPlay = !!questMeta.nonPlay;
2538
+ if (!nonPlay) {
2539
+ quest.times--;
2540
+ quest._doneSoFar = (quest._doneSoFar || 0) + 1;
2541
+ this._mergeLevelQuestProgress(quest.level, { [quest.cmd]: quest._doneSoFar });
2542
+ if (qEntry) qEntry.success++;
2543
+ } else {
2544
+ if (qEntry) {
2545
+ qEntry.nonPlay++;
2546
+ qEntry.failed++;
2547
+ }
2548
+ this.log('warn', `[QUEST] ${quest.cmd} non-play response — progress unchanged`);
2549
+ // Rotate to next quest so we don't spam one command during cooldown.
2550
+ if (this._levelQuestQueue.length > 1) {
2551
+ const rotated = this._levelQuestQueue.shift();
2552
+ this._levelQuestQueue.push(rotated);
2553
+ }
2554
+ }
2352
2555
  if (quest.times <= 0) questStepDone = true;
2556
+
2557
+ if (qEntry) {
2558
+ qEntry.completed = Math.max(0, Number(quest._doneSoFar || 0));
2559
+ qEntry.remaining = Math.max(0, Number(quest.times || 0));
2560
+ qEntry.attemptsLeft = Number(quest.times || 0);
2561
+ qEntry.status = questStepDone ? 'done' : (nonPlay ? 'cooldown' : 'progress');
2562
+ }
2563
+ }
2564
+
2565
+ const retrySec = Math.max(
2566
+ 1,
2567
+ Number(questMeta.nextRetrySec || 0),
2568
+ Number(quest._defaultCdSec || 0)
2569
+ );
2570
+ quest._nextRunAt = Date.now() + retrySec * 1000;
2571
+ nextQuestDelayMs = Math.max(nextQuestDelayMs, Math.min(60000, Math.floor(retrySec * 1000)));
2572
+ if (qEntry) qEntry.nextRunAt = Number(quest._nextRunAt || 0);
2573
+
2574
+ const runState = this._levelQuestRunMap.get(quest.level);
2575
+ if (runState) runState.updatedAt = Date.now();
2576
+
2577
+ if (questMeta.nextRetrySec && Number.isFinite(questMeta.nextRetrySec) && questMeta.nextRetrySec > 0) {
2578
+ nextQuestDelayMs = Math.max(nextQuestDelayMs, Math.min(60000, Math.floor(questMeta.nextRetrySec * 1000)));
2353
2579
  }
2354
2580
 
2355
2581
  if (questStepDone) this._levelQuestQueue.shift();
@@ -2358,6 +2584,11 @@ class AccountWorker {
2358
2584
  this._levelQuestActive = false;
2359
2585
  this._levelQuestDone.add(quest.level);
2360
2586
  const justCompletedLevel = quest.level;
2587
+ const st = this._levelQuestRunMap.get(justCompletedLevel);
2588
+ if (st) {
2589
+ st.completedAt = Date.now();
2590
+ st.updatedAt = st.completedAt;
2591
+ }
2361
2592
  this.log('success', `[QUEST] Level ${justCompletedLevel} quests DONE — verifying unlock...`);
2362
2593
  // Null out commandQueue so buildCommandQueue picks only unlocked commands
2363
2594
  this.commandQueue = null;
@@ -2367,7 +2598,7 @@ class AccountWorker {
2367
2598
  this.tickTimeout = setTimeout(() => this.tick(), 3000);
2368
2599
  return;
2369
2600
  }
2370
- this.tickTimeout = setTimeout(() => this.tick(), 2500);
2601
+ this.tickTimeout = setTimeout(() => this.tick(), nextQuestDelayMs);
2371
2602
  return;
2372
2603
  }
2373
2604
 
package/lib/rawLogger.js CHANGED
@@ -457,6 +457,20 @@ async function store(d, event) {
457
457
  pipe.ltrim(accKey, 0, 4999);
458
458
  pipe.expire(accKey, LOG_TTL);
459
459
  }
460
+ // Track ephemeral in memory for quick access before they vanish
461
+ if (parsed.isEphemeral || (d.flags & 32832)) {
462
+ let chMap = ephemeralByChannel.get(d.channel_id);
463
+ if (!chMap) {
464
+ chMap = new Map();
465
+ ephemeralByChannel.set(d.channel_id, chMap);
466
+ }
467
+ chMap.set(d.id, parsed);
468
+ // Cap per-channel ephemeral buffer at 50
469
+ if (chMap.size > 50) {
470
+ const firstKey = chMap.keys().next().value;
471
+ chMap.delete(firstKey);
472
+ }
473
+ }
460
474
  // Ephemeral log
461
475
  if (parsed.isEphemeral || (d.flags & 32832)) {
462
476
  pipe.lpush('raw:ephemeral:log', `${d.id}:${parsed._capturedAt}:${parsed.command}:${d.channel_id}`);
@@ -494,8 +508,17 @@ function onDmEvent(fn) { dmListeners.push(fn); }
494
508
 
495
509
  // ── Ephemeral message callbacks ──
496
510
  const ephemeralListeners = [];
511
+ const ephemeralByChannel = new Map(); // channelId → Map<msgId, parsed msg>
512
+
497
513
  function onNextEphemeral(fn) { ephemeralListeners.push(fn); }
498
514
 
515
+ // Track ephemeral messages per channel — useful for reading state before vanish
516
+ function getEphemeralMsgs(channelId, count = 20) {
517
+ const map = ephemeralByChannel.get(channelId);
518
+ if (!map) return [];
519
+ return [...map.values()].slice(-count);
520
+ }
521
+
499
522
  function _notifyEphemeral(parsed) {
500
523
  if (!ephemeralListeners.length) return;
501
524
  const listeners = [...ephemeralListeners];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dankgrinder",
3
- "version": "8.107.0",
3
+ "version": "8.110.0",
4
4
  "description": "Dank Memer automation engine — grind coins while you sleep",
5
5
  "bin": {
6
6
  "dankgrinder": "bin/dankgrinder.js"