dankgrinder 7.12.0 → 7.60.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 });
@@ -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
@@ -585,7 +586,7 @@ function renderDashboard() {
585
586
 
586
587
  for (const wk of visibleWorkers) {
587
588
  const origNum = (wk.idx + 1).toString().padStart(colNum);
588
- const rawStat = (wk.lastStatus || 'idle').replace(RE, '');
589
+ const rawStat = (wk.lastStatus || 'ready').replace(RE, '');
589
590
  const activityText = rawStat.substring(0, colActivity);
590
591
 
591
592
  // ── Status icon ──
@@ -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,14 +1125,18 @@ 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 };
1109
1133
  this.lastRunTime = {};
1110
1134
  this.cycleCount = 0;
1111
1135
  this.lastCommandRun = 0;
1136
+ this.lastStatus = 'ready';
1112
1137
  this.paused = false;
1113
1138
  this.dashboardPaused = false;
1139
+ this._sellRunning = false;
1114
1140
  this.failStreak = 0;
1115
1141
  this.globalCooldownUntil = 0;
1116
1142
  this.commandQueue = null;
@@ -1487,7 +1513,7 @@ class AccountWorker {
1487
1513
  try {
1488
1514
  await fetch(`${API_URL}/api/grinder/inventory`, {
1489
1515
  method: 'POST',
1490
- headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
1516
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
1491
1517
  body: JSON.stringify({
1492
1518
  account_id: this.account.id,
1493
1519
  items: result.items || [],
@@ -1551,7 +1577,7 @@ class AccountWorker {
1551
1577
  try {
1552
1578
  await fetch(`${API_URL}/api/grinder/profile`, {
1553
1579
  method: 'POST',
1554
- headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
1580
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
1555
1581
  body: JSON.stringify({
1556
1582
  account_id: this.account.id,
1557
1583
  level: result.level,
@@ -1580,6 +1606,44 @@ class AccountWorker {
1580
1606
  }
1581
1607
  }
1582
1608
 
1609
+ // ── Market Sell ──────────────────────────────────────────────
1610
+ // Pauses the grind loop, posts item to Dank Memer marketplace, resumes.
1611
+ async sellItem({ itemName, quantity, pricePerItem, days = 1, allowPartial = false, isPrivate = false, partialMin = 1, priceType = 'per_item' }) {
1612
+ if (!this.channel || !this.running) {
1613
+ this.log('warn', '[sell] Cannot sell — account not running');
1614
+ return { result: 'account not running', coins: 0 };
1615
+ }
1616
+
1617
+ this._sellRunning = true;
1618
+ this.log('info', `[sell] Posting ${quantity}x ${itemName} at ⏣ ${pricePerItem.toLocaleString()}/ea on market`);
1619
+
1620
+ try {
1621
+ const result = await commands.runMarketPost({
1622
+ channel: this.channel,
1623
+ waitForDankMemer: (t) => this.waitForDankMemer(t),
1624
+ quantity,
1625
+ itemName,
1626
+ pricePerItem,
1627
+ days,
1628
+ allowPartial,
1629
+ isPrivate,
1630
+ partialMin,
1631
+ priceType,
1632
+ });
1633
+
1634
+ if (result.result) {
1635
+ this.log('info', `[sell] Result: ${result.result} · earned ⏣ ${result.coins.toLocaleString()}`);
1636
+ }
1637
+
1638
+ return result;
1639
+ } catch (e) {
1640
+ this.log('error', `[sell] Error: ${e.message}`);
1641
+ return { result: e.message, coins: 0 };
1642
+ } finally {
1643
+ this._sellRunning = false;
1644
+ }
1645
+ }
1646
+
1583
1647
  async checkBalance(silent = false) {
1584
1648
  const prefix = this.account.use_slash ? '/' : 'pls';
1585
1649
  const sentAt = Date.now();
@@ -1688,13 +1752,14 @@ class AccountWorker {
1688
1752
  try {
1689
1753
  await fetch(`${API_URL}/api/grinder/status`, {
1690
1754
  method: 'POST',
1691
- headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
1755
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
1692
1756
  body: JSON.stringify({
1693
1757
  account_id: this.account.id,
1694
1758
  balance: wallet,
1695
1759
  bank_balance: bank,
1696
1760
  total_balance: wallet + bank,
1697
1761
  lifesavers: this._lifesavers ?? null,
1762
+ userId: this.account.userId,
1698
1763
  }),
1699
1764
  });
1700
1765
  } catch { /* silent */ }
@@ -1951,8 +2016,8 @@ class AccountWorker {
1951
2016
  try {
1952
2017
  await fetch(`${API_URL}/api/grinder/status`, {
1953
2018
  method: 'POST',
1954
- headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
1955
- body: JSON.stringify({ account_id: this.account.id, active: false }),
2019
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
2020
+ body: JSON.stringify({ account_id: this.account.id, active: false, userId: this.account.userId }),
1956
2021
  });
1957
2022
  } catch {}
1958
2023
  await sendLog(this.username, cmdString, 'VERIFICATION — account deactivated', 'error');
@@ -2506,7 +2571,7 @@ class AccountWorker {
2506
2571
  this.tickTimeout = setTimeout(() => this.tick(), 5000);
2507
2572
  return;
2508
2573
  }
2509
- if (this.busy || this._invRunning) {
2574
+ if (this.busy || this._invRunning || this._sellRunning) {
2510
2575
  this.tickTimeout = setTimeout(() => this.tick(), 2000);
2511
2576
  return;
2512
2577
  }
@@ -2824,7 +2889,7 @@ class AccountWorker {
2824
2889
  async refreshConfig() {
2825
2890
  try {
2826
2891
  const res = await fetch(`${API_URL}/api/grinder/status`, {
2827
- headers: { Authorization: `Bearer ${API_KEY}` },
2892
+ headers: { Authorization: `Bearer ${this.apiKey}` },
2828
2893
  });
2829
2894
  const data = await res.json();
2830
2895
  if (data.accounts) {
@@ -2840,7 +2905,7 @@ class AccountWorker {
2840
2905
  try {
2841
2906
  await fetch(`${API_URL}/api/grinder/actions`, {
2842
2907
  method: 'DELETE',
2843
- headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
2908
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
2844
2909
  body: JSON.stringify({ action_id: action.id }),
2845
2910
  });
2846
2911
  } catch {}
@@ -2851,7 +2916,30 @@ class AccountWorker {
2851
2916
  try {
2852
2917
  await fetch(`${API_URL}/api/grinder/actions`, {
2853
2918
  method: 'DELETE',
2854
- headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
2919
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
2920
+ body: JSON.stringify({ action_id: action.id }),
2921
+ });
2922
+ } catch {}
2923
+ }
2924
+ if (action.action === 'sell_item' && !this.busy && !this._sellRunning && action.data) {
2925
+ const { itemName, quantity, pricePerItem, days, allowPartial, isPrivate, partialMin, priceType, accountId } = action.data;
2926
+ // Only process if this action targets this specific account, or has no accountId (legacy/global)
2927
+ if (accountId != null && accountId !== this.account.id) {
2928
+ try {
2929
+ await fetch(`${API_URL}/api/grinder/actions`, {
2930
+ method: 'DELETE',
2931
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
2932
+ body: JSON.stringify({ action_id: action.id }),
2933
+ });
2934
+ } catch {}
2935
+ return;
2936
+ }
2937
+ this.log('info', `Dashboard requested sell: ${quantity}x ${itemName}`);
2938
+ await this.sellItem({ itemName, quantity, pricePerItem, days, allowPartial, isPrivate, partialMin, priceType }).catch(() => {});
2939
+ try {
2940
+ await fetch(`${API_URL}/api/grinder/actions`, {
2941
+ method: 'DELETE',
2942
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
2855
2943
  body: JSON.stringify({ action_id: action.id }),
2856
2944
  });
2857
2945
  } catch {}
@@ -2893,8 +2981,8 @@ class AccountWorker {
2893
2981
  // Report status non-blocking
2894
2982
  fetch(`${API_URL}/api/grinder/status`, {
2895
2983
  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 }),
2984
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
2985
+ body: JSON.stringify({ account_id: this.account.id, discord_username: this.username, avatar_url: this.avatarUrl, userId: this.account.userId }),
2898
2986
  }).catch(() => {});
2899
2987
 
2900
2988
  this.channel = await this.client.channels.fetch(this.account.channel_id).catch(() => null);
@@ -2959,8 +3047,8 @@ class AccountWorker {
2959
3047
  // Report invalid status to API
2960
3048
  fetch(`${API_URL}/api/grinder/status`, {
2961
3049
  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' }),
3050
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
3051
+ body: JSON.stringify({ account_id: this.account.id, active: false, status: 'token_invalid', userId: this.account.userId }),
2964
3052
  }).catch(() => {});
2965
3053
  sendWebhook('Token Invalid', `**${this.account.label || this.account.id}** has an invalid Discord token.`, 0xef4444);
2966
3054
  } else {
@@ -2996,8 +3084,8 @@ class AccountWorker {
2996
3084
  this.stats.bankBalance = bank;
2997
3085
  await fetch(`${API_URL}/api/grinder/status`, {
2998
3086
  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 }),
3087
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
3088
+ body: JSON.stringify({ account_id: this.account.id, balance: wallet, bank_balance: bank, total_balance: wallet + bank, userId: this.account.userId }),
3001
3089
  }).catch(() => {});
3002
3090
  }
3003
3091
  }
@@ -3030,6 +3118,7 @@ class AccountWorker {
3030
3118
  this.paused = false;
3031
3119
  this.dashboardPaused = false;
3032
3120
  this.busy = false;
3121
+ this._sellRunning = false;
3033
3122
  if (this.tickTimeout) { clearTimeout(this.tickTimeout); this.tickTimeout = null; }
3034
3123
  if (this.configInterval) { clearInterval(this.configInterval); this.configInterval = null; }
3035
3124
  if (this._alertHandler) {
@@ -3071,9 +3160,17 @@ captchaDetector.build();
3071
3160
  // ═ Main Entry
3072
3161
  // ══════════════════════════════════════════════════════════════
3073
3162
 
3074
- async function start(apiKey, apiUrl) {
3163
+ async function start(apiKey, apiUrl, opts = {}) {
3164
+ CLOUD_ADMIN_KEY = process.env.CLOUD_ADMIN_KEY || '';
3075
3165
  API_KEY = apiKey;
3076
3166
  API_URL = apiUrl || process.env.DANKGRINDER_URL || 'http://localhost:3000';
3167
+ const CLOUD_MODE = opts.cloud === true;
3168
+
3169
+ if (CLOUD_MODE) {
3170
+ // In cloud mode, API_KEY is the CLOUD_ADMIN_KEY — not used for user auth.
3171
+ // Per-account keys are fetched per-account from /api/cloud/grinders.
3172
+ console.log('🌥️ Starting in CLOUD MODE — grinding all cloud-enabled accounts');
3173
+ }
3077
3174
  REDIS_URL = process.env.REDIS_URL || '';
3078
3175
  WEBHOOK_URL = process.env.WEBHOOK_URL || '';
3079
3176
 
@@ -3089,7 +3186,7 @@ async function start(apiKey, apiUrl) {
3089
3186
  console.log(
3090
3187
  ` ${rgb(139, 92, 246)}v${PKG_VERSION}${c.reset}` +
3091
3188
  ` ${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}` +
3189
+ ` ${c.dim}·${c.reset} ${rgb(34, 211, 238)}${CLOUD_MODE ? 'Cloud Mode' : (CLUSTER_ENABLED ? 'Cluster Mode' : 'Standalone')}${c.reset}` +
3093
3190
  ` ${c.dim}·${c.reset} ${rgb(52, 211, 153)}Auto-Recovery${c.reset}` +
3094
3191
  ` ${c.dim}·${c.reset} ${rgb(251, 191, 36)}Loss Limiter${c.reset}`
3095
3192
  );
@@ -3097,12 +3194,38 @@ async function start(apiKey, apiUrl) {
3097
3194
 
3098
3195
  log('info', `${c.dim}Fetching accounts...${c.reset}`);
3099
3196
 
3100
- let data = await fetchConfig(4, 2000);
3197
+ const fetchOpts = CLOUD_MODE ? { cloud: true } : {};
3198
+ let data = await fetchConfig(4, 2000, fetchOpts);
3101
3199
  while (!data) {
3102
3200
  log('error', `Cannot connect to API`);
3103
3201
  log('warn', `Will retry in 10s (check internet/API URL if this repeats).`);
3104
3202
  await new Promise((r) => setTimeout(r, 10000));
3105
- data = await fetchConfig(4, 2000);
3203
+ data = await fetchConfig(4, 2000, fetchOpts);
3204
+ }
3205
+
3206
+ if (data && data.error) {
3207
+ log('error', `${data.error}`);
3208
+ return;
3209
+ }
3210
+
3211
+ // Cloud mode: post heartbeat every 30s
3212
+ if (CLOUD_MODE) {
3213
+ const CLOUD_HEARTBEAT_MS = 30_000;
3214
+ const postHeartbeat = async () => {
3215
+ if (!process.env.REDIS_URL) return;
3216
+ const uptime = Math.floor((Date.now() - startTime) / 1000);
3217
+ const memMB = Math.round((process.memoryUsage?.rss?.() ?? process.memoryUsage().rss) / 1048576);
3218
+ try {
3219
+ await fetch(`${API_URL}/api/cloud/status`, {
3220
+ method: 'POST',
3221
+ headers: { Authorization: `Bearer ${CLOUD_ADMIN_KEY}`, 'Content-Type': 'application/json' },
3222
+ body: JSON.stringify({ uptime, accounts: workers.filter(w => w.running).length, memMB, pid: process.pid }),
3223
+ });
3224
+ } catch {}
3225
+ };
3226
+ postHeartbeat();
3227
+ setInterval(postHeartbeat, CLOUD_HEARTBEAT_MS);
3228
+ log('info', `${rgb(159, 92, 246)}Cloud heartbeat started${c.reset} ${c.dim}(every ${CLOUD_HEARTBEAT_MS / 1000}s)${c.reset}`);
3106
3229
  }
3107
3230
 
3108
3231
  // Pull Redis/Webhook URLs from API config if not in env
@@ -3524,12 +3647,12 @@ async function start(apiKey, apiUrl) {
3524
3647
  parts.push(`${D}${pulse}♥?${c.reset}`);
3525
3648
  }
3526
3649
  if (parts.length > 0) {
3527
- console.log(` ${c.dim}├${c.reset} ${c.bold}${w.username}${c.reset} ${parts.join(' ')}`);
3650
+ recentLogs.push({ ts: Date.now(), username: w.username, color: w.color, command: 'dm check', response: parts.join(' '), status: 'ok' });
3528
3651
  }
3529
3652
  } catch {}
3530
3653
  }
3531
3654
  if (dmNoLs.length > 0) {
3532
- console.log(` ${rgb(239, 68, 68)}⚠${c.reset} ${c.bold}${c.red}DM confirms 0 lifesavers:${c.reset} ${dmNoLs.join(', ')}`);
3655
+ recentLogs.push({ ts: Date.now(), username: 'system', color: rgb(239, 68, 68), command: 'dm check', response: `⚠ No lifesavers: ${dmNoLs.join(', ')}`, status: 'warn' });
3533
3656
  // Set Redis keys to block crime/search
3534
3657
  for (const w of activeWorkers) {
3535
3658
  if (dmNoLs.includes(w.username) && redis) {
@@ -3541,7 +3664,7 @@ async function start(apiKey, apiUrl) {
3541
3664
  }
3542
3665
  }
3543
3666
  if (dmUnknown.length > 0) {
3544
- console.log(` ${rgb(251, 191, 36)}⚠${c.reset} ${c.dim}Lifesavers unknown — live DM monitor active:${c.reset} ${dmUnknown.join(', ')}`);
3667
+ recentLogs.push({ ts: Date.now(), username: 'system', color: rgb(251, 191, 36), command: 'dm check', response: `⚠ Lifesavers unknown — live monitor: ${dmUnknown.join(', ')}`, status: 'warn' });
3545
3668
  // Crime/search on these accounts will be skipped via safety hold until the live
3546
3669
  // DM gateway listener detects a death (→ sets count) or confirms clean.
3547
3670
  }
@@ -3549,7 +3672,7 @@ async function start(apiKey, apiUrl) {
3549
3672
  if (dmDeaths > 0) dmSummaryParts.push(`${dmDeaths} deaths`);
3550
3673
  if (dmLevelUps > 0) dmSummaryParts.push(`${dmLevelUps} level-ups`);
3551
3674
  if (dmUnknown.length > 0) dmSummaryParts.push(`${dmUnknown.length} pending`);
3552
- console.log(` ${rgb(52, 211, 153)}✓${c.reset} ${c.bold}DM check${c.reset} ${dmSummaryParts.length > 0 ? c.dim + dmSummaryParts.join(', ') + c.reset : c.dim + 'clean — no deaths or level-ups' + c.reset}`);
3675
+ recentLogs.push({ ts: Date.now(), username: 'system', color: rgb(52, 211, 153), command: 'dm check', response: dmSummaryParts.length > 0 ? dmSummaryParts.join(', ') : 'clean — no deaths or level-ups', status: 'ok' });
3553
3676
  console.log('');
3554
3677
 
3555
3678
  console.log(` ${rgb(139, 92, 246)}${c.bold}>>>${c.reset} ${gradientText('Starting grind loops...', [139, 92, 246], [52, 211, 153])}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dankgrinder",
3
- "version": "7.12.0",
3
+ "version": "7.60.0",
4
4
  "description": "Dank Memer automation engine — grind coins while you sleep",
5
5
  "bin": {
6
6
  "dankgrinder": "bin/dankgrinder.js"