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.
- package/bin/dankgrinder.js +11 -4
- package/lib/commands/farm.js +32 -1
- package/lib/commands/index.js +2 -0
- package/lib/commands/market.js +151 -0
- package/lib/grinder.js +149 -27
- package/package.json +1 -1
package/bin/dankgrinder.js
CHANGED
|
@@ -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}
|
|
19
|
-
npx dankgrinder --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
|
|
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 });
|
package/lib/commands/farm.js
CHANGED
|
@@ -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 (
|
|
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;
|
package/lib/commands/index.js
CHANGED
|
@@ -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(
|
|
779
|
-
headers: { Authorization: `Bearer ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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
|
-
|
|
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
|