dankgrinder 7.11.0 → 7.58.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.
@@ -15,13 +15,18 @@ if (args.includes('--help') || args.includes('-h')) {
15
15
  console.log(`
16
16
  ${C.b}${C.mag}DANK${C.cyn}GRINDER${C.r} ${C.d}v${pkg.version}${C.r}
17
17
 
18
- ${C.b}Usage:${C.r}
19
- npx dankgrinder --key <API_KEY>
18
+ ${C.b}Local usage:${C.r}
19
+ npx dankgrinder --key <YOUR_API_KEY>
20
+
21
+ ${C.b}Cloud mode (admin):${C.r}
22
+ npx dankgrinder --cloud --key <CLOUD_ADMIN_KEY>
20
23
 
21
24
  ${C.b}Options:${C.r}
22
- --key <key> API key from dashboard (required)
25
+ --key <key> API key (required)
23
26
  --url <url> Override dashboard URL (default: ${DEFAULT_URL})
24
27
  --redis <url> Redis URL for cooldowns & trivia DB
28
+ --cloud Cloud mode: fetch ALL cloud-enabled accounts from ALL users
29
+ Requires CLOUD_ADMIN_KEY env var and admin API endpoint
25
30
  --help, -h Show this help
26
31
  --version, -v Show version
27
32
  `);
@@ -36,11 +41,13 @@ if (args.includes('--version') || args.includes('-v')) {
36
41
  let apiKey = process.env.DANKGRINDER_KEY || process.env.GRINDER_API_KEY || '';
37
42
  let apiUrl = '';
38
43
  let redisUrl = '';
44
+ let cloudMode = false;
39
45
 
40
46
  for (let i = 0; i < args.length; i++) {
41
47
  if (args[i] === '--key' && args[i + 1]) apiKey = args[i + 1];
42
48
  if (args[i] === '--url' && args[i + 1]) apiUrl = args[i + 1];
43
49
  if (args[i] === '--redis' && args[i + 1]) redisUrl = args[i + 1];
50
+ if (args[i] === '--cloud') cloudMode = true;
44
51
  }
45
52
 
46
53
  apiUrl = apiUrl || process.env.DANKGRINDER_URL || process.env.GRINDER_URL || DEFAULT_URL;
@@ -71,4 +78,4 @@ if (!apiKey) {
71
78
  process.exit(1);
72
79
  }
73
80
 
74
- start(apiKey, apiUrl);
81
+ start(apiKey, apiUrl, { cloud: cloudMode });
@@ -1409,6 +1409,7 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1409
1409
  let forcedNextAction = null; // When advancing a phase, force the next action check
1410
1410
  let lastApplyResp = null; // Track the last apply response for coin/cooldown parsing
1411
1411
  let lastRejectedAction = null; // Track last rejected action to break empty-farm loops
1412
+ let lastRejectedAction2 = null; // Track previous rejected action to detect 3-in-a-row loops
1412
1413
 
1413
1414
  while (cycleDepth < 5) {
1414
1415
  // Reset per-cycle state
@@ -1589,6 +1590,30 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1589
1590
  const ephem = lastApplyResp?._capturedEphemeral;
1590
1591
  const ephemText = (ephem?.cv2Text || ephem?.allText || '').toLowerCase();
1591
1592
 
1593
+ // Generic "nothing to X" no-op: when an action completes but has nothing to act on,
1594
+ // cascade forward without treating it as a rejection. Also use this as a safety break
1595
+ // when the same action keeps getting rejected (stuck in a loop).
1596
+ if (
1597
+ // "Nothing to water/hoe/plant/harvest" — action succeeded but was a no-op
1598
+ /nothing to (water|hoe|plant|harvest)|no crops? (to |to )?(water|harvest)|0 x [a-z]|nothing to do/i.test(ephemText) ||
1599
+ // Safety: if the same action was rejected 3+ cycles in a row, something is stuck — break
1600
+ (lastRejectedAction === action && lastRejectedAction2 === action)
1601
+ ) {
1602
+ LOG.warn(`[farm:cycle:${cycleDepth}:reject] ${action} is a no-op or stuck loop (${ephemText.slice(0, 80)}) — cascading to next phase`);
1603
+ const currentIdx = FARM_PHASE_ORDER.indexOf(action);
1604
+ const nextPhase = FARM_PHASE_ORDER[currentIdx + 1] || null;
1605
+ lastRejectedAction2 = lastRejectedAction;
1606
+ lastRejectedAction = action;
1607
+ forcedNextAction = nextPhase;
1608
+ lastAction = action;
1609
+ justRejected = true;
1610
+ cycleDepth++;
1611
+ if (!nextPhase) { forcedNextAction = null; break; }
1612
+ await reenterManage();
1613
+ await sleep(300);
1614
+ continue;
1615
+ }
1616
+
1592
1617
  // Helper: re-enter the manage menu from the farm view so the next
1593
1618
  // findNextFarmActionFromManage iteration has buttons to work with.
1594
1619
  async function reenterManage() {
@@ -1614,6 +1639,8 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1614
1639
  LOG.warn(`[farm:cycle:${cycleDepth}:reject] Hoe rejected: tiles not empty — cascading to water`);
1615
1640
  const currentIdx = FARM_PHASE_ORDER.indexOf(action);
1616
1641
  const nextPhase = FARM_PHASE_ORDER[currentIdx + 1] || null;
1642
+ lastRejectedAction2 = lastRejectedAction;
1643
+ lastRejectedAction = action;
1617
1644
  forcedNextAction = nextPhase;
1618
1645
  lastAction = action;
1619
1646
  justRejected = true;
@@ -1630,11 +1657,13 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1630
1657
  LOG.warn(`[farm:cycle:${cycleDepth}:reject] Water rejected (${ephemText.slice(0, 100)}) — cascading to plant`);
1631
1658
  const currentIdx = FARM_PHASE_ORDER.indexOf(action);
1632
1659
  const nextPhase = FARM_PHASE_ORDER[currentIdx + 1] || null;
1660
+ lastRejectedAction2 = lastRejectedAction;
1661
+ lastRejectedAction = action;
1633
1662
  forcedNextAction = nextPhase;
1634
1663
  lastAction = action;
1635
1664
  justRejected = true;
1636
1665
  // Break oscillation guard: if we bounced back here from plant, stop.
1637
- if (lastRejectedAction === 'plant' && action === 'water') {
1666
+ if (lastRejectedAction2 === 'plant' && lastRejectedAction === 'water') {
1638
1667
  LOG.warn(`[farm:cycle:${cycleDepth}:reject] hoe→water→plant oscillation detected — breaking`);
1639
1668
  break;
1640
1669
  }
@@ -1651,6 +1680,8 @@ async function runFarm({ channel, waitForDankMemer, client, redis, accountId, fo
1651
1680
  LOG.warn(`[farm:cycle:${cycleDepth}:reject] Plant rejected (${ephemText.slice(0, 100)}) — cascading to harvest`);
1652
1681
  const currentIdx = FARM_PHASE_ORDER.indexOf(action);
1653
1682
  const nextPhase = FARM_PHASE_ORDER[currentIdx + 1] || null;
1683
+ lastRejectedAction2 = lastRejectedAction;
1684
+ lastRejectedAction = action;
1654
1685
  forcedNextAction = nextPhase;
1655
1686
  lastAction = action;
1656
1687
  justRejected = true;
@@ -25,6 +25,7 @@ const { runDrops } = require('./drops');
25
25
  const { buyItem, ITEM_COSTS } = require('./shop');
26
26
  const { getPlayerLevel, meetsLevelRequirement, runProfile } = require('./profile');
27
27
  const { runInventory, fetchItemValues, enrichItems, getCachedInventory, getAllInventories, updateInventoryItem, deleteInventoryItem } = require('./inventory');
28
+ const { runMarketPost } = require('./market');
28
29
 
29
30
  module.exports = {
30
31
  // Individual commands
@@ -56,6 +57,7 @@ module.exports = {
56
57
 
57
58
  // Inventory
58
59
  runInventory,
60
+ runMarketPost,
59
61
  fetchItemValues,
60
62
  enrichItems,
61
63
  getCachedInventory,
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Market command handler.
3
+ * Posts an item to Dank Memer's marketplace.
4
+ * Handles sell listings with optional partial sales and privacy settings.
5
+ *
6
+ * Dank Memer market post format:
7
+ * pls market post for_coins <listing_type> <qty> <item> <price> <days> <allow_partial> <is_private> <partial_min>
8
+ *
9
+ * listing_type: "sell" | "auction"
10
+ * allow_partial: "true" | "false"
11
+ * is_private: "true" | "false"
12
+ * partial_min: only meaningful when allow_partial is true
13
+ */
14
+
15
+ const {
16
+ LOG, c, getFullText, parseCoins, logMsg, isHoldTight,
17
+ getHoldTightReason, sleep, humanDelay, getAllButtons, safeClickButton,
18
+ findSelectMenuOption,
19
+ } = require('./utils');
20
+
21
+ /**
22
+ * @param {object} opts
23
+ * @param {object} opts.channel
24
+ * @param {function} opts.waitForDankMemer
25
+ * @param {number} opts.quantity
26
+ * @param {string} opts.itemName
27
+ * @param {number} opts.pricePerItem
28
+ * @param {number} [opts.days=1]
29
+ * @param {boolean} [opts.allowPartial=false]
30
+ * @param {boolean} [opts.isPrivate=false]
31
+ * @param {number} [opts.partialMin=1]
32
+ * @param {string} [opts.priceType="per_item"]
33
+ * @returns {Promise<{result: string, coins: number}>}
34
+ */
35
+ async function runMarketPost({
36
+ channel,
37
+ waitForDankMemer,
38
+ quantity = 1,
39
+ itemName,
40
+ pricePerItem = 0,
41
+ days = 1,
42
+ allowPartial = false,
43
+ isPrivate = false,
44
+ partialMin = 1,
45
+ priceType = 'per_item',
46
+ }) {
47
+ if (!itemName) {
48
+ return { result: 'no item specified', coins: 0 };
49
+ }
50
+
51
+ const cmdParts = [
52
+ 'pls', 'market', 'post',
53
+ 'for_coins', // price type: "for_coins" fixed price
54
+ 'sell', // listing type: "sell" | "auction"
55
+ String(quantity), // quantity
56
+ itemName, // item name
57
+ String(pricePerItem * quantity), // TOTAL price (price per item × quantity)
58
+ String(days), // days (1-7)
59
+ allowPartial ? 'true' : 'false', // allow partial
60
+ isPrivate ? 'true' : 'false', // is private
61
+ String(allowPartial ? partialMin : 0), // partial minimum (only meaningful when allowPartial is true)
62
+ ];
63
+
64
+ const cmdString = cmdParts.join(' ');
65
+ LOG.cmd(`${c.white}${c.bold}${cmdString}${c.reset}`);
66
+
67
+ await channel.send(cmdString);
68
+ const response = await waitForDankMemer(12000);
69
+
70
+ if (!response) {
71
+ LOG.warn('[market] No response');
72
+ return { result: 'no response', coins: 0 };
73
+ }
74
+
75
+ if (isHoldTight(response)) {
76
+ const reason = getHoldTightReason(response);
77
+ LOG.warn(`[market] Hold Tight${reason ? ` (reason: /${reason})` : ''} — waiting 30s`);
78
+ await sleep(30000);
79
+ return { result: `hold tight (${reason || 'unknown'})`, coins: 0, holdTightReason: reason };
80
+ }
81
+
82
+ logMsg(response, 'market');
83
+ const text = getFullText(response);
84
+
85
+ // Check for common errors
86
+ const lowerText = text.toLowerCase();
87
+ if (lowerText.includes('you don\'t have') || lowerText.includes('not enough') || lowerText.includes('invalid item') || lowerText.includes('partial minimum can\'t') || lowerText.includes('set allow_partial')) {
88
+ return { result: text.substring(0, 80) || 'error listing item', coins: 0 };
89
+ }
90
+
91
+ if (lowerText.includes('listed') || lowerText.includes('posted') || lowerText.includes('now selling') || lowerText.includes('auction created') || lowerText.includes('now listed') || lowerText.includes('successfully listed')) {
92
+ const earned = parseCoins(text);
93
+ LOG.success(`[market] Listed ${quantity}x ${itemName} at ⏣ ${pricePerItem.toLocaleString()}/ea (total ⏣ ${(pricePerItem * quantity).toLocaleString()})${earned > 0 ? ` → earned ⏣ ${earned.toLocaleString()}` : ''}`);
94
+ return { result: `listed ${quantity}x ${itemName} at ⏣ ${pricePerItem.toLocaleString()}/ea`, coins: earned };
95
+ }
96
+
97
+ // Handle confirmation buttons (e.g. "Confirm" for expensive listings)
98
+ const buttons = getAllButtons(response);
99
+ if (buttons.length > 0) {
100
+ // Handle fee payment dropdown first (select "coins" option)
101
+ const coinsOpt = findSelectMenuOption(response, 'coin');
102
+ if (coinsOpt) {
103
+ try {
104
+ await humanDelay();
105
+ await response.selectMenu(coinsOpt.menuCustomId, [coinsOpt.option.value]);
106
+ LOG.info(`[market] Selected fee payment: ${coinsOpt.option.label}`);
107
+ await sleep(500);
108
+ // Re-fetch message to get updated state after select
109
+ const msgId = response.id;
110
+ const updated = await channel.messages.fetch(msgId).catch(() => null);
111
+ if (updated) response = updated;
112
+ } catch (e) {
113
+ LOG.warn(`[market] Select menu error: ${e.message}`);
114
+ }
115
+ }
116
+
117
+ const confirmBtn = buttons.find(b =>
118
+ !b.disabled &&
119
+ (b.label?.toLowerCase().includes('confirm') ||
120
+ b.label?.toLowerCase().includes('post') ||
121
+ b.label?.toLowerCase().includes('list'))
122
+ );
123
+ if (confirmBtn) {
124
+ await humanDelay();
125
+ try {
126
+ const updatedMsg = await safeClickButton(response, confirmBtn);
127
+ // If the listing posted, Dank Memer usually sends a confirmation.
128
+ // But sometimes it posts silently — treat as success either way.
129
+ if (updatedMsg) {
130
+ logMsg(updatedMsg, 'market-confirm');
131
+ const fText = getFullText(updatedMsg);
132
+ const lowerF = fText.toLowerCase();
133
+ if (lowerF.includes('listed') || lowerF.includes('posted') || lowerF.includes('now selling') || lowerF.includes('now listed') || lowerF.includes('successfully listed') || lowerF.includes('offer to sell')) {
134
+ const earned = parseCoins(fText);
135
+ LOG.success(`[market] Listed ${quantity}x ${itemName} at ⏣ ${pricePerItem.toLocaleString()}/ea → earned ⏣ ${earned.toLocaleString()}`);
136
+ return { result: `listed ${quantity}x ${itemName} at ⏣ ${pricePerItem.toLocaleString()}/ea`, coins: earned };
137
+ }
138
+ }
139
+ // Silent post — assume it worked
140
+ LOG.success(`[market] Listed ${quantity}x ${itemName} at ⏣ ${pricePerItem.toLocaleString()}/ea (confirmation received)`);
141
+ return { result: `listed ${quantity}x ${itemName} at ⏣ ${pricePerItem.toLocaleString()}/ea`, coins: 0 };
142
+ } catch (e) {
143
+ LOG.error(`[market] Click error: ${e.message}`);
144
+ }
145
+ }
146
+ }
147
+
148
+ return { result: text.substring(0, 80) || 'market post sent', coins: 0 };
149
+ }
150
+
151
+ module.exports = { runMarketPost };
package/lib/grinder.js CHANGED
@@ -114,6 +114,7 @@ const SAFE_CRIME_OPTIONS = Object.freeze([
114
114
  ]);
115
115
 
116
116
  let API_KEY = '';
117
+ let CLOUD_ADMIN_KEY = '';
117
118
  let API_URL = '';
118
119
  let REDIS_URL = process.env.REDIS_URL || '';
119
120
  let redis = null;
@@ -467,7 +468,7 @@ function renderDashboard() {
467
468
  const invalidCount = workers.filter(w => w._tokenInvalid).length;
468
469
  const pausedCount = workers.filter(w => w.paused || w.dashboardPaused).length;
469
470
  const recovCount = workers.filter(w => w._recoveryAttempts > 0 && w._errorCooldownUntil > Date.now()).length;
470
- const mode = CLUSTER_ENABLED ? `${Cy}CLUSTER${c.reset}` : `${D}Standalone${c.reset}`;
471
+ const mode = CLOUD_MODE ? `${Cy}CLOUD${c.reset}` : (CLUSTER_ENABLED ? `${Cy}CLUSTER${c.reset}` : `${D}Standalone${c.reset}`);
471
472
  const cmdCount = AccountWorker.COMMAND_MAP.length;
472
473
 
473
474
  const netQ = workers.length > 0
@@ -770,21 +771,42 @@ function log(type, msg, label) {
770
771
  }
771
772
  }
772
773
 
773
- async function fetchConfig(retries = 3, delayMs = 1500) {
774
+ async function fetchConfig(retries = 3, delayMs = 1500, opts = {}) {
775
+ const isCloudMode = opts.cloud === true;
776
+ // Cloud mode uses admin endpoint + CLOUD_ADMIN_KEY
777
+ const url = isCloudMode
778
+ ? `${API_URL}/api/cloud/grinders`
779
+ : `${API_URL}/api/grinder/config`;
780
+ const authKey = isCloudMode ? (CLOUD_ADMIN_KEY || API_KEY) : API_KEY;
781
+
774
782
  for (let attempt = 1; attempt <= retries; attempt++) {
775
783
  try {
776
784
  const controller = new AbortController();
777
785
  const t = setTimeout(() => controller.abort(), 10000);
778
- const res = await fetch(`${API_URL}/api/grinder/config`, {
779
- headers: { Authorization: `Bearer ${API_KEY}` },
786
+ const res = await fetch(url, {
787
+ headers: { Authorization: `Bearer ${authKey}` },
780
788
  signal: controller.signal,
781
789
  });
782
790
  clearTimeout(t);
791
+ if (res.status === 403) {
792
+ const data = await res.json();
793
+ log('error', data.error || 'Forbidden');
794
+ return { error: data.error };
795
+ }
796
+ if (res.status === 401) {
797
+ const data = await res.json();
798
+ log('error', data.error || 'Unauthorized');
799
+ return { error: data.error };
800
+ }
783
801
  const data = await res.json();
784
802
  if (data.error) {
785
803
  log('error', `Config fetch failed: ${data.error}`);
786
804
  return null;
787
805
  }
806
+ // Mark cloud accounts so workers know their userId for SSE routing
807
+ if (isCloudMode && data.accounts) {
808
+ data.accounts = data.accounts.map(acc => ({ ...acc, _cloud: true }));
809
+ }
788
810
  return data;
789
811
  } catch (err) {
790
812
  if (attempt < retries) {
@@ -804,7 +826,7 @@ async function sendLog(accountName, command, response, status) {
804
826
  const safeResponse = stripAnsi(String(response || '')).replace(/\s+/g, ' ').trim();
805
827
  await fetch(`${API_URL}/api/grinder/log`, {
806
828
  method: 'POST',
807
- headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
829
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
808
830
  body: JSON.stringify({ account_name: accountName, command: safeCommand, response: safeResponse, status }),
809
831
  });
810
832
  } catch { /* silent */ }
@@ -818,7 +840,7 @@ const earningsBatch = new AsyncBatchQueue(async (batch) => {
818
840
  try {
819
841
  await fetch(`${API_URL}/api/grinder/earnings-batch`, {
820
842
  method: 'POST',
821
- headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
843
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
822
844
  body: JSON.stringify({ items: batch }),
823
845
  });
824
846
  } catch {
@@ -827,7 +849,7 @@ const earningsBatch = new AsyncBatchQueue(async (batch) => {
827
849
  try {
828
850
  await fetch(`${API_URL}/api/grinder/earnings`, {
829
851
  method: 'POST',
830
- headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
852
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
831
853
  body: JSON.stringify(item),
832
854
  });
833
855
  } catch {}
@@ -850,7 +872,7 @@ async function reportCommandFeed(accountId, accountName, data) {
850
872
  try {
851
873
  await fetch(`${API_URL}/api/grinder/command-feed`, {
852
874
  method: 'POST',
853
- headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
875
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
854
876
  body: JSON.stringify({ account_id: accountId, account_name: accountName, ...normalized }),
855
877
  });
856
878
  } catch { /* silent — dashboard just won't see this update */ }
@@ -1103,6 +1125,8 @@ class AccountWorker {
1103
1125
  this.channel = null;
1104
1126
  this.running = false;
1105
1127
  this.busy = false;
1128
+ // Per-account API key (cloud mode) falls back to global key (local mode)
1129
+ this.apiKey = account.api_key || API_KEY;
1106
1130
  this.username = account.label || `Account ${idx + 1}`;
1107
1131
  this.tickTimeout = null;
1108
1132
  this.stats = { coins: 0, commands: 0, successes: 0, errors: 0, balance: 0 };
@@ -1111,6 +1135,7 @@ class AccountWorker {
1111
1135
  this.lastCommandRun = 0;
1112
1136
  this.paused = false;
1113
1137
  this.dashboardPaused = false;
1138
+ this._sellRunning = false;
1114
1139
  this.failStreak = 0;
1115
1140
  this.globalCooldownUntil = 0;
1116
1141
  this.commandQueue = null;
@@ -1487,7 +1512,7 @@ class AccountWorker {
1487
1512
  try {
1488
1513
  await fetch(`${API_URL}/api/grinder/inventory`, {
1489
1514
  method: 'POST',
1490
- headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
1515
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
1491
1516
  body: JSON.stringify({
1492
1517
  account_id: this.account.id,
1493
1518
  items: result.items || [],
@@ -1551,7 +1576,7 @@ class AccountWorker {
1551
1576
  try {
1552
1577
  await fetch(`${API_URL}/api/grinder/profile`, {
1553
1578
  method: 'POST',
1554
- headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
1579
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
1555
1580
  body: JSON.stringify({
1556
1581
  account_id: this.account.id,
1557
1582
  level: result.level,
@@ -1580,6 +1605,44 @@ class AccountWorker {
1580
1605
  }
1581
1606
  }
1582
1607
 
1608
+ // ── Market Sell ──────────────────────────────────────────────
1609
+ // Pauses the grind loop, posts item to Dank Memer marketplace, resumes.
1610
+ async sellItem({ itemName, quantity, pricePerItem, days = 1, allowPartial = false, isPrivate = false, partialMin = 1, priceType = 'per_item' }) {
1611
+ if (!this.channel || !this.running) {
1612
+ this.log('warn', '[sell] Cannot sell — account not running');
1613
+ return { result: 'account not running', coins: 0 };
1614
+ }
1615
+
1616
+ this._sellRunning = true;
1617
+ this.log('info', `[sell] Posting ${quantity}x ${itemName} at ⏣ ${pricePerItem.toLocaleString()}/ea on market`);
1618
+
1619
+ try {
1620
+ const result = await commands.runMarketPost({
1621
+ channel: this.channel,
1622
+ waitForDankMemer: (t) => this.waitForDankMemer(t),
1623
+ quantity,
1624
+ itemName,
1625
+ pricePerItem,
1626
+ days,
1627
+ allowPartial,
1628
+ isPrivate,
1629
+ partialMin,
1630
+ priceType,
1631
+ });
1632
+
1633
+ if (result.result) {
1634
+ this.log('info', `[sell] Result: ${result.result} · earned ⏣ ${result.coins.toLocaleString()}`);
1635
+ }
1636
+
1637
+ return result;
1638
+ } catch (e) {
1639
+ this.log('error', `[sell] Error: ${e.message}`);
1640
+ return { result: e.message, coins: 0 };
1641
+ } finally {
1642
+ this._sellRunning = false;
1643
+ }
1644
+ }
1645
+
1583
1646
  async checkBalance(silent = false) {
1584
1647
  const prefix = this.account.use_slash ? '/' : 'pls';
1585
1648
  const sentAt = Date.now();
@@ -1688,13 +1751,14 @@ class AccountWorker {
1688
1751
  try {
1689
1752
  await fetch(`${API_URL}/api/grinder/status`, {
1690
1753
  method: 'POST',
1691
- headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
1754
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
1692
1755
  body: JSON.stringify({
1693
1756
  account_id: this.account.id,
1694
1757
  balance: wallet,
1695
1758
  bank_balance: bank,
1696
1759
  total_balance: wallet + bank,
1697
1760
  lifesavers: this._lifesavers ?? null,
1761
+ userId: this.account.userId,
1698
1762
  }),
1699
1763
  });
1700
1764
  } catch { /* silent */ }
@@ -1951,8 +2015,8 @@ class AccountWorker {
1951
2015
  try {
1952
2016
  await fetch(`${API_URL}/api/grinder/status`, {
1953
2017
  method: 'POST',
1954
- headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
1955
- body: JSON.stringify({ account_id: this.account.id, active: false }),
2018
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
2019
+ body: JSON.stringify({ account_id: this.account.id, active: false, userId: this.account.userId }),
1956
2020
  });
1957
2021
  } catch {}
1958
2022
  await sendLog(this.username, cmdString, 'VERIFICATION — account deactivated', 'error');
@@ -2506,7 +2570,7 @@ class AccountWorker {
2506
2570
  this.tickTimeout = setTimeout(() => this.tick(), 5000);
2507
2571
  return;
2508
2572
  }
2509
- if (this.busy || this._invRunning) {
2573
+ if (this.busy || this._invRunning || this._sellRunning) {
2510
2574
  this.tickTimeout = setTimeout(() => this.tick(), 2000);
2511
2575
  return;
2512
2576
  }
@@ -2824,7 +2888,7 @@ class AccountWorker {
2824
2888
  async refreshConfig() {
2825
2889
  try {
2826
2890
  const res = await fetch(`${API_URL}/api/grinder/status`, {
2827
- headers: { Authorization: `Bearer ${API_KEY}` },
2891
+ headers: { Authorization: `Bearer ${this.apiKey}` },
2828
2892
  });
2829
2893
  const data = await res.json();
2830
2894
  if (data.accounts) {
@@ -2840,7 +2904,7 @@ class AccountWorker {
2840
2904
  try {
2841
2905
  await fetch(`${API_URL}/api/grinder/actions`, {
2842
2906
  method: 'DELETE',
2843
- headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
2907
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
2844
2908
  body: JSON.stringify({ action_id: action.id }),
2845
2909
  });
2846
2910
  } catch {}
@@ -2851,7 +2915,30 @@ class AccountWorker {
2851
2915
  try {
2852
2916
  await fetch(`${API_URL}/api/grinder/actions`, {
2853
2917
  method: 'DELETE',
2854
- headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
2918
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
2919
+ body: JSON.stringify({ action_id: action.id }),
2920
+ });
2921
+ } catch {}
2922
+ }
2923
+ if (action.action === 'sell_item' && !this.busy && !this._sellRunning && action.data) {
2924
+ const { itemName, quantity, pricePerItem, days, allowPartial, isPrivate, partialMin, priceType, accountId } = action.data;
2925
+ // Only process if this action targets this specific account, or has no accountId (legacy/global)
2926
+ if (accountId != null && accountId !== this.account.id) {
2927
+ try {
2928
+ await fetch(`${API_URL}/api/grinder/actions`, {
2929
+ method: 'DELETE',
2930
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
2931
+ body: JSON.stringify({ action_id: action.id }),
2932
+ });
2933
+ } catch {}
2934
+ return;
2935
+ }
2936
+ this.log('info', `Dashboard requested sell: ${quantity}x ${itemName}`);
2937
+ await this.sellItem({ itemName, quantity, pricePerItem, days, allowPartial, isPrivate, partialMin, priceType }).catch(() => {});
2938
+ try {
2939
+ await fetch(`${API_URL}/api/grinder/actions`, {
2940
+ method: 'DELETE',
2941
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
2855
2942
  body: JSON.stringify({ action_id: action.id }),
2856
2943
  });
2857
2944
  } catch {}
@@ -2893,8 +2980,8 @@ class AccountWorker {
2893
2980
  // Report status non-blocking
2894
2981
  fetch(`${API_URL}/api/grinder/status`, {
2895
2982
  method: 'POST',
2896
- headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
2897
- body: JSON.stringify({ account_id: this.account.id, discord_username: this.username, avatar_url: this.avatarUrl }),
2983
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
2984
+ body: JSON.stringify({ account_id: this.account.id, discord_username: this.username, avatar_url: this.avatarUrl, userId: this.account.userId }),
2898
2985
  }).catch(() => {});
2899
2986
 
2900
2987
  this.channel = await this.client.channels.fetch(this.account.channel_id).catch(() => null);
@@ -2959,8 +3046,8 @@ class AccountWorker {
2959
3046
  // Report invalid status to API
2960
3047
  fetch(`${API_URL}/api/grinder/status`, {
2961
3048
  method: 'POST',
2962
- headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
2963
- body: JSON.stringify({ account_id: this.account.id, active: false, status: 'token_invalid' }),
3049
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
3050
+ body: JSON.stringify({ account_id: this.account.id, active: false, status: 'token_invalid', userId: this.account.userId }),
2964
3051
  }).catch(() => {});
2965
3052
  sendWebhook('Token Invalid', `**${this.account.label || this.account.id}** has an invalid Discord token.`, 0xef4444);
2966
3053
  } else {
@@ -2996,8 +3083,8 @@ class AccountWorker {
2996
3083
  this.stats.bankBalance = bank;
2997
3084
  await fetch(`${API_URL}/api/grinder/status`, {
2998
3085
  method: 'POST',
2999
- headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
3000
- body: JSON.stringify({ account_id: this.account.id, balance: wallet, bank_balance: bank, total_balance: wallet + bank }),
3086
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
3087
+ body: JSON.stringify({ account_id: this.account.id, balance: wallet, bank_balance: bank, total_balance: wallet + bank, userId: this.account.userId }),
3001
3088
  }).catch(() => {});
3002
3089
  }
3003
3090
  }
@@ -3030,6 +3117,7 @@ class AccountWorker {
3030
3117
  this.paused = false;
3031
3118
  this.dashboardPaused = false;
3032
3119
  this.busy = false;
3120
+ this._sellRunning = false;
3033
3121
  if (this.tickTimeout) { clearTimeout(this.tickTimeout); this.tickTimeout = null; }
3034
3122
  if (this.configInterval) { clearInterval(this.configInterval); this.configInterval = null; }
3035
3123
  if (this._alertHandler) {
@@ -3071,9 +3159,17 @@ captchaDetector.build();
3071
3159
  // ═ Main Entry
3072
3160
  // ══════════════════════════════════════════════════════════════
3073
3161
 
3074
- async function start(apiKey, apiUrl) {
3162
+ async function start(apiKey, apiUrl, opts = {}) {
3163
+ CLOUD_ADMIN_KEY = process.env.CLOUD_ADMIN_KEY || '';
3075
3164
  API_KEY = apiKey;
3076
3165
  API_URL = apiUrl || process.env.DANKGRINDER_URL || 'http://localhost:3000';
3166
+ const CLOUD_MODE = opts.cloud === true;
3167
+
3168
+ if (CLOUD_MODE) {
3169
+ // In cloud mode, API_KEY is the CLOUD_ADMIN_KEY — not used for user auth.
3170
+ // Per-account keys are fetched per-account from /api/cloud/grinders.
3171
+ console.log('🌥️ Starting in CLOUD MODE — grinding all cloud-enabled accounts');
3172
+ }
3077
3173
  REDIS_URL = process.env.REDIS_URL || '';
3078
3174
  WEBHOOK_URL = process.env.WEBHOOK_URL || '';
3079
3175
 
@@ -3089,7 +3185,7 @@ async function start(apiKey, apiUrl) {
3089
3185
  console.log(
3090
3186
  ` ${rgb(139, 92, 246)}v${PKG_VERSION}${c.reset}` +
3091
3187
  ` ${c.dim}·${c.reset} ${c.white}${AccountWorker.COMMAND_MAP.length} Commands${c.reset}` +
3092
- ` ${c.dim}·${c.reset} ${rgb(34, 211, 238)}${CLUSTER_ENABLED ? 'Cluster Mode' : 'Standalone'}${c.reset}` +
3188
+ ` ${c.dim}·${c.reset} ${rgb(34, 211, 238)}${CLOUD_MODE ? 'Cloud Mode' : (CLUSTER_ENABLED ? 'Cluster Mode' : 'Standalone')}${c.reset}` +
3093
3189
  ` ${c.dim}·${c.reset} ${rgb(52, 211, 153)}Auto-Recovery${c.reset}` +
3094
3190
  ` ${c.dim}·${c.reset} ${rgb(251, 191, 36)}Loss Limiter${c.reset}`
3095
3191
  );
@@ -3097,12 +3193,38 @@ async function start(apiKey, apiUrl) {
3097
3193
 
3098
3194
  log('info', `${c.dim}Fetching accounts...${c.reset}`);
3099
3195
 
3100
- let data = await fetchConfig(4, 2000);
3196
+ const fetchOpts = CLOUD_MODE ? { cloud: true } : {};
3197
+ let data = await fetchConfig(4, 2000, fetchOpts);
3101
3198
  while (!data) {
3102
3199
  log('error', `Cannot connect to API`);
3103
3200
  log('warn', `Will retry in 10s (check internet/API URL if this repeats).`);
3104
3201
  await new Promise((r) => setTimeout(r, 10000));
3105
- data = await fetchConfig(4, 2000);
3202
+ data = await fetchConfig(4, 2000, fetchOpts);
3203
+ }
3204
+
3205
+ if (data && data.error) {
3206
+ log('error', `${data.error}`);
3207
+ return;
3208
+ }
3209
+
3210
+ // Cloud mode: post heartbeat every 30s
3211
+ if (CLOUD_MODE) {
3212
+ const CLOUD_HEARTBEAT_MS = 30_000;
3213
+ const postHeartbeat = async () => {
3214
+ if (!process.env.REDIS_URL) return;
3215
+ const uptime = Math.floor((Date.now() - startTime) / 1000);
3216
+ const memMB = Math.round((process.memoryUsage?.rss?.() ?? process.memoryUsage().rss) / 1048576);
3217
+ try {
3218
+ await fetch(`${API_URL}/api/cloud/status`, {
3219
+ method: 'POST',
3220
+ headers: { Authorization: `Bearer ${CLOUD_ADMIN_KEY}`, 'Content-Type': 'application/json' },
3221
+ body: JSON.stringify({ uptime, accounts: workers.filter(w => w.running).length, memMB, pid: process.pid }),
3222
+ });
3223
+ } catch {}
3224
+ };
3225
+ postHeartbeat();
3226
+ setInterval(postHeartbeat, CLOUD_HEARTBEAT_MS);
3227
+ log('info', `${rgb(159, 92, 246)}Cloud heartbeat started${c.reset} ${c.dim}(every ${CLOUD_HEARTBEAT_MS / 1000}s)${c.reset}`);
3106
3228
  }
3107
3229
 
3108
3230
  // Pull Redis/Webhook URLs from API config if not in env
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dankgrinder",
3
- "version": "7.11.0",
3
+ "version": "7.58.0",
4
4
  "description": "Dank Memer automation engine — grind coins while you sleep",
5
5
  "bin": {
6
6
  "dankgrinder": "bin/dankgrinder.js"