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.
- package/bin/dankgrinder.js +11 -4
- package/lib/commands/index.js +2 -0
- package/lib/commands/market.js +151 -0
- package/lib/grinder.js +155 -32
- 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/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
|
|
@@ -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 || '
|
|
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(
|
|
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,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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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])}`);
|