dankgrinder 4.0.0 → 4.1.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/README.md +25 -16
- package/bin/dankgrinder.js +18 -28
- package/lib/commands/adventure.js +502 -0
- package/lib/commands/beg.js +45 -0
- package/lib/commands/blackjack.js +85 -0
- package/lib/commands/crime.js +94 -0
- package/lib/commands/deposit.js +46 -0
- package/lib/commands/dig.js +82 -0
- package/lib/commands/fish.js +615 -0
- package/lib/commands/fishVision.js +141 -0
- package/lib/commands/gamble.js +96 -0
- package/lib/commands/generic.js +181 -0
- package/lib/commands/highlow.js +112 -0
- package/lib/commands/hunt.js +85 -0
- package/lib/commands/index.js +59 -0
- package/lib/commands/postmemes.js +148 -0
- package/lib/commands/profile.js +99 -0
- package/lib/commands/scratch.js +83 -0
- package/lib/commands/search.js +102 -0
- package/lib/commands/shop.js +262 -0
- package/lib/commands/trivia.js +146 -0
- package/lib/commands/utils.js +287 -0
- package/lib/commands/work.js +400 -0
- package/lib/grinder.js +560 -656
- package/package.json +4 -3
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostMemes command handler.
|
|
3
|
+
* Send "pls pm", select platform + meme type from dropdowns, click Post.
|
|
4
|
+
*
|
|
5
|
+
* Flow:
|
|
6
|
+
* 1. "pls pm" → response with 2 select menus (location + kind) + disabled Post button
|
|
7
|
+
* 2. Select a platform from location dropdown → message updates, Post still disabled
|
|
8
|
+
* 3. Select a meme type from kind dropdown → message updates, Post becomes enabled
|
|
9
|
+
* 4. Click Post → response with coins earned
|
|
10
|
+
*
|
|
11
|
+
* CRITICAL: After each selectMenu call, the returned object is the interaction response.
|
|
12
|
+
* We need to re-fetch the message to get the updated select menus and buttons.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
LOG, c, getFullText, parseCoins, getAllButtons, getAllSelectMenus,
|
|
17
|
+
findButton, safeClickButton, logMsg, isHoldTight, getHoldTightReason,
|
|
18
|
+
sleep, humanDelay,
|
|
19
|
+
} = require('./utils');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Re-fetch a message to get updated state after an interaction.
|
|
23
|
+
*/
|
|
24
|
+
async function refetchMsg(channel, msgId) {
|
|
25
|
+
try { return await channel.messages.fetch(msgId); }
|
|
26
|
+
catch { return null; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {object} opts
|
|
31
|
+
* @param {object} opts.channel
|
|
32
|
+
* @param {function} opts.waitForDankMemer
|
|
33
|
+
* @returns {Promise<{result: string, coins: number}>}
|
|
34
|
+
*/
|
|
35
|
+
async function runPostMemes({ channel, waitForDankMemer }) {
|
|
36
|
+
LOG.cmd(`${c.white}${c.bold}pls pm${c.reset}`);
|
|
37
|
+
|
|
38
|
+
await channel.send('pls pm');
|
|
39
|
+
let response = await waitForDankMemer(10000);
|
|
40
|
+
|
|
41
|
+
if (!response) {
|
|
42
|
+
LOG.warn('[pm] No response');
|
|
43
|
+
return { result: 'no response', coins: 0 };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (isHoldTight(response)) {
|
|
47
|
+
const reason = getHoldTightReason(response);
|
|
48
|
+
LOG.warn(`[pm] Hold Tight${reason ? ` (reason: /${reason})` : ''} — waiting 30s`);
|
|
49
|
+
await sleep(30000);
|
|
50
|
+
return { result: `hold tight (${reason || 'unknown'})`, coins: 0, holdTightReason: reason };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
logMsg(response, 'pm');
|
|
54
|
+
|
|
55
|
+
// Check for cooldown or direct text response (no select menus)
|
|
56
|
+
let selects = getAllSelectMenus(response);
|
|
57
|
+
if (selects.length === 0) {
|
|
58
|
+
const text = getFullText(response);
|
|
59
|
+
const coins = parseCoins(text);
|
|
60
|
+
if (coins > 0) {
|
|
61
|
+
LOG.coin(`[pm] ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`);
|
|
62
|
+
return { result: `pm → +⏣ ${coins.toLocaleString()}`, coins };
|
|
63
|
+
}
|
|
64
|
+
LOG.info(`[pm] No menus: ${text.substring(0, 100).replace(/\n/g, ' ')}`);
|
|
65
|
+
return { result: text.substring(0, 60), coins: 0 };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const msgId = response.id;
|
|
69
|
+
|
|
70
|
+
// CRITICAL: The message from waitForDankMemer (messageCreate/Update event) may have
|
|
71
|
+
// incomplete component objects (missing minValues/maxValues). Re-fetch the message
|
|
72
|
+
// from the API to get fully-hydrated components before calling selectMenu().
|
|
73
|
+
response = await refetchMsg(channel, msgId) || response;
|
|
74
|
+
|
|
75
|
+
// Find row indices for our select menus
|
|
76
|
+
// Layout: Row 0 = platform select, Row 1 = kind select, Row 2 = Post button
|
|
77
|
+
let locRowIdx = -1, kindRowIdx = -1;
|
|
78
|
+
for (let i = 0; i < (response.components || []).length; i++) {
|
|
79
|
+
const row = response.components[i];
|
|
80
|
+
for (const comp of row.components || []) {
|
|
81
|
+
if ((comp.type === 'STRING_SELECT' || comp.type === 3) && !comp.disabled) {
|
|
82
|
+
const cid = (comp.customId || '').toLowerCase();
|
|
83
|
+
const ph = (comp.placeholder || '').toLowerCase();
|
|
84
|
+
if (cid.includes('location') || ph.includes('platform')) locRowIdx = i;
|
|
85
|
+
else if (cid.includes('kind') || ph.includes('meme type')) kindRowIdx = i;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
LOG.debug(`[pm] locRowIdx=${locRowIdx}, kindRowIdx=${kindRowIdx}`);
|
|
91
|
+
|
|
92
|
+
// Step 1: Select platform
|
|
93
|
+
if (locRowIdx >= 0) {
|
|
94
|
+
const locMenu = response.components[locRowIdx].components[0];
|
|
95
|
+
const opt = locMenu.options[Math.floor(Math.random() * locMenu.options.length)];
|
|
96
|
+
LOG.info(`[pm] Platform: "${opt.label}"`);
|
|
97
|
+
try {
|
|
98
|
+
await response.selectMenu(locRowIdx, [opt.value]);
|
|
99
|
+
} catch (e) { LOG.error(`[pm] Platform select error: ${e.message}`); }
|
|
100
|
+
await sleep(600);
|
|
101
|
+
const updated = await refetchMsg(channel, msgId);
|
|
102
|
+
if (updated) { response = updated; logMsg(response, 'pm-after-platform'); }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Step 2: Select meme type
|
|
106
|
+
if (kindRowIdx >= 0) {
|
|
107
|
+
const kindMenu = response.components[kindRowIdx].components[0];
|
|
108
|
+
const opt = kindMenu.options[Math.floor(Math.random() * kindMenu.options.length)];
|
|
109
|
+
LOG.info(`[pm] MemeType: "${opt.label}"`);
|
|
110
|
+
try {
|
|
111
|
+
await response.selectMenu(kindRowIdx, [opt.value]);
|
|
112
|
+
} catch (e) { LOG.error(`[pm] Kind select error: ${e.message}`); }
|
|
113
|
+
await sleep(600);
|
|
114
|
+
const updated = await refetchMsg(channel, msgId);
|
|
115
|
+
if (updated) { response = updated; logMsg(response, 'pm-after-kind'); }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Step 3: Click Post button
|
|
119
|
+
const postBtn = getAllButtons(response).find(b =>
|
|
120
|
+
!b.disabled && (b.label || '').toLowerCase().includes('post')
|
|
121
|
+
);
|
|
122
|
+
if (postBtn) {
|
|
123
|
+
LOG.info(`[pm] Clicking "${postBtn.label}"...`);
|
|
124
|
+
try {
|
|
125
|
+
await safeClickButton(response, postBtn);
|
|
126
|
+
await sleep(600);
|
|
127
|
+
const final = await refetchMsg(channel, msgId);
|
|
128
|
+
if (final) {
|
|
129
|
+
logMsg(final, 'pm-result');
|
|
130
|
+
const text = getFullText(final);
|
|
131
|
+
const coins = parseCoins(text);
|
|
132
|
+
if (coins > 0) {
|
|
133
|
+
LOG.coin(`[pm] ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`);
|
|
134
|
+
return { result: `pm → +⏣ ${coins.toLocaleString()}`, coins };
|
|
135
|
+
}
|
|
136
|
+
return { result: 'pm → posted', coins: 0 };
|
|
137
|
+
}
|
|
138
|
+
} catch (e) { LOG.error(`[pm] Post click error: ${e.message}`); }
|
|
139
|
+
} else {
|
|
140
|
+
LOG.warn('[pm] Post button disabled or not found after both selections');
|
|
141
|
+
const allBtns = getAllButtons(response);
|
|
142
|
+
for (const b of allBtns) LOG.debug(`[pm] btn: "${b.label}" disabled=${b.disabled}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { result: 'pm done', coins: 0 };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = { runPostMemes };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile level checker.
|
|
3
|
+
* Send "pls profile", parse level, cache in Redis.
|
|
4
|
+
* Used to gate commands like scratch (requires level 25).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
LOG, c, getFullText, logMsg, isHoldTight, sleep,
|
|
9
|
+
} = require('./utils');
|
|
10
|
+
|
|
11
|
+
// In-memory cache: { accountId: { level: number, checkedAt: timestamp } }
|
|
12
|
+
const levelCache = {};
|
|
13
|
+
const CACHE_TTL = 10 * 60 * 1000; // 10 minutes
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse level from profile response text.
|
|
17
|
+
* Looks for patterns like "Level 25", "Lvl 25", "level: 25"
|
|
18
|
+
*/
|
|
19
|
+
function parseLevelFromText(text) {
|
|
20
|
+
// Pattern: "Level XX" or "Lvl XX" or "level XX"
|
|
21
|
+
const match = text.match(/(?:level|lvl)\s*:?\s*(\d+)/i);
|
|
22
|
+
if (match) return parseInt(match[1]);
|
|
23
|
+
// Fallback: "Prestige X Level Y" pattern
|
|
24
|
+
const presMatch = text.match(/prestige\s+\d+\s+level\s+(\d+)/i);
|
|
25
|
+
if (presMatch) return parseInt(presMatch[1]);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check player level. Returns cached value if fresh, otherwise sends "pls profile".
|
|
31
|
+
* @param {object} opts
|
|
32
|
+
* @param {object} opts.channel
|
|
33
|
+
* @param {function} opts.waitForDankMemer
|
|
34
|
+
* @param {string} [opts.accountId] - for cache key
|
|
35
|
+
* @param {object} [opts.redis] - Redis client for persistent cache
|
|
36
|
+
* @returns {Promise<number|null>} Level number or null if can't determine
|
|
37
|
+
*/
|
|
38
|
+
async function getPlayerLevel({ channel, waitForDankMemer, accountId = 'default', redis }) {
|
|
39
|
+
// Check in-memory cache first
|
|
40
|
+
const cached = levelCache[accountId];
|
|
41
|
+
if (cached && (Date.now() - cached.checkedAt) < CACHE_TTL) {
|
|
42
|
+
return cached.level;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check Redis cache
|
|
46
|
+
if (redis) {
|
|
47
|
+
try {
|
|
48
|
+
const redisLevel = await redis.get(`dkg:level:${accountId}`);
|
|
49
|
+
if (redisLevel) {
|
|
50
|
+
const level = parseInt(redisLevel);
|
|
51
|
+
levelCache[accountId] = { level, checkedAt: Date.now() };
|
|
52
|
+
return level;
|
|
53
|
+
}
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Fetch from Discord
|
|
58
|
+
LOG.debug('[profile] Checking level...');
|
|
59
|
+
await channel.send('pls profile');
|
|
60
|
+
const response = await waitForDankMemer(8000);
|
|
61
|
+
|
|
62
|
+
if (!response) {
|
|
63
|
+
LOG.warn('[profile] No response');
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (isHoldTight(response)) {
|
|
68
|
+
await sleep(5000);
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
logMsg(response, 'profile');
|
|
73
|
+
const text = getFullText(response);
|
|
74
|
+
const level = parseLevelFromText(text);
|
|
75
|
+
|
|
76
|
+
if (level !== null) {
|
|
77
|
+
LOG.info(`[profile] Level: ${c.bold}${level}${c.reset}`);
|
|
78
|
+
levelCache[accountId] = { level, checkedAt: Date.now() };
|
|
79
|
+
// Persist to Redis for 10 min
|
|
80
|
+
if (redis) {
|
|
81
|
+
try { await redis.set(`dkg:level:${accountId}`, String(level), 'EX', 600); } catch {}
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
LOG.warn(`[profile] Could not parse level from: ${text.substring(0, 100)}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return level;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Check if player meets minimum level requirement.
|
|
92
|
+
*/
|
|
93
|
+
async function meetsLevelRequirement(opts, minLevel) {
|
|
94
|
+
const level = await getPlayerLevel(opts);
|
|
95
|
+
if (level === null) return false; // can't determine, skip
|
|
96
|
+
return level >= minLevel;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { getPlayerLevel, meetsLevelRequirement, parseLevelFromText };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scratch command handler.
|
|
3
|
+
* Send "pls scratch", click through scratch card buttons.
|
|
4
|
+
* Requires level 25 — checks profile level before running.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
LOG, c, getFullText, parseCoins, getAllButtons, safeClickButton,
|
|
9
|
+
logMsg, isHoldTight, getHoldTightReason, sleep, humanDelay,
|
|
10
|
+
} = require('./utils');
|
|
11
|
+
const { meetsLevelRequirement } = require('./profile');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} opts
|
|
15
|
+
* @param {object} opts.channel
|
|
16
|
+
* @param {function} opts.waitForDankMemer
|
|
17
|
+
* @param {string} [opts.accountId]
|
|
18
|
+
* @param {object} [opts.redis]
|
|
19
|
+
* @returns {Promise<{result: string, coins: number}>}
|
|
20
|
+
*/
|
|
21
|
+
async function runScratch({ channel, waitForDankMemer, accountId, redis }) {
|
|
22
|
+
// Check level 25 requirement before wasting a command
|
|
23
|
+
const canRun = await meetsLevelRequirement({ channel, waitForDankMemer, accountId, redis }, 25);
|
|
24
|
+
if (!canRun) {
|
|
25
|
+
LOG.warn(`[scratch] Skipped — need level 25`);
|
|
26
|
+
return { result: 'skipped (need level 25)', coins: 0, skipReason: 'level' };
|
|
27
|
+
}
|
|
28
|
+
LOG.cmd(`${c.white}${c.bold}pls scratch${c.reset}`);
|
|
29
|
+
|
|
30
|
+
await channel.send('pls scratch');
|
|
31
|
+
const response = await waitForDankMemer(10000);
|
|
32
|
+
|
|
33
|
+
if (!response) {
|
|
34
|
+
LOG.warn('[scratch] No response');
|
|
35
|
+
return { result: 'no response', coins: 0 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (isHoldTight(response)) {
|
|
39
|
+
const reason = getHoldTightReason(response);
|
|
40
|
+
LOG.warn(`[scratch] Hold Tight${reason ? ` (reason: /${reason})` : ''} — waiting 30s`);
|
|
41
|
+
await sleep(30000);
|
|
42
|
+
return { result: `hold tight (${reason || 'unknown'})`, coins: 0, holdTightReason: reason };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
logMsg(response, 'scratch');
|
|
46
|
+
const buttons = getAllButtons(response);
|
|
47
|
+
|
|
48
|
+
if (buttons.length === 0) {
|
|
49
|
+
const text = getFullText(response);
|
|
50
|
+
const coins = parseCoins(text);
|
|
51
|
+
if (coins > 0) {
|
|
52
|
+
LOG.coin(`[scratch] ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`);
|
|
53
|
+
return { result: `scratch → +⏣ ${coins.toLocaleString()}`, coins };
|
|
54
|
+
}
|
|
55
|
+
return { result: text.substring(0, 60) || 'done', coins: 0 };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Click through all scratch card cells
|
|
59
|
+
let lastResponse = response;
|
|
60
|
+
let clickCount = 0;
|
|
61
|
+
for (let i = 0; i < Math.min(buttons.length, 9); i++) {
|
|
62
|
+
const btn = buttons[i];
|
|
63
|
+
if (btn && !btn.disabled) {
|
|
64
|
+
await humanDelay(300, 700);
|
|
65
|
+
try {
|
|
66
|
+
const followUp = await safeClickButton(lastResponse, btn);
|
|
67
|
+
if (followUp) { lastResponse = followUp; clickCount++; }
|
|
68
|
+
} catch { break; }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const finalText = getFullText(lastResponse);
|
|
73
|
+
const coins = parseCoins(finalText);
|
|
74
|
+
|
|
75
|
+
if (coins > 0) {
|
|
76
|
+
LOG.coin(`[scratch] ${clickCount} clicks → ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`);
|
|
77
|
+
return { result: `scratch (${clickCount} clicks) → +⏣ ${coins.toLocaleString()}`, coins };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { result: `scratch done (${clickCount} clicks)`, coins: 0 };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { runScratch };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search command handler.
|
|
3
|
+
* Send "pls search", pick a safe button, click it, parse coins.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
LOG, c, getFullText, parseCoins, getAllButtons, safeClickButton,
|
|
8
|
+
logMsg, isHoldTight, getHoldTightReason, sleep, humanDelay,
|
|
9
|
+
} = require('./utils');
|
|
10
|
+
|
|
11
|
+
const SAFE_SEARCH_LOCATIONS = [
|
|
12
|
+
'sofa', 'mailbox', 'dog', 'car', 'dresser', 'laundromat', 'bed',
|
|
13
|
+
'couch', 'pantry', 'fridge', 'kitchen', 'bathroom', 'attic',
|
|
14
|
+
'closet', 'shoe', 'vacuum', 'toilet', 'sink', 'shower',
|
|
15
|
+
'tree', 'grass', 'bushes', 'garden', 'park', 'backyard',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function pickSafeButton(buttons, customSafe) {
|
|
19
|
+
if (!buttons || buttons.length === 0) return null;
|
|
20
|
+
// Try custom safe list first
|
|
21
|
+
if (customSafe && customSafe.length > 0) {
|
|
22
|
+
for (const btn of buttons) {
|
|
23
|
+
const label = (btn.label || '').toLowerCase();
|
|
24
|
+
if (customSafe.some(s => label.includes(s.toLowerCase()))) return btn;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// Try built-in safe list
|
|
28
|
+
for (const btn of buttons) {
|
|
29
|
+
const label = (btn.label || '').toLowerCase();
|
|
30
|
+
if (SAFE_SEARCH_LOCATIONS.some(s => label.includes(s))) return btn;
|
|
31
|
+
}
|
|
32
|
+
// Fallback: random non-disabled
|
|
33
|
+
const clickable = buttons.filter(b => !b.disabled);
|
|
34
|
+
return clickable.length > 0 ? clickable[Math.floor(Math.random() * clickable.length)] : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {object} opts
|
|
39
|
+
* @param {object} opts.channel
|
|
40
|
+
* @param {function} opts.waitForDankMemer
|
|
41
|
+
* @param {string[]} [opts.safeAnswers] - Custom safe search locations
|
|
42
|
+
* @returns {Promise<{result: string, coins: number}>}
|
|
43
|
+
*/
|
|
44
|
+
async function runSearch({ channel, waitForDankMemer, safeAnswers }) {
|
|
45
|
+
LOG.cmd(`${c.white}${c.bold}pls search${c.reset}`);
|
|
46
|
+
|
|
47
|
+
await channel.send('pls search');
|
|
48
|
+
const response = await waitForDankMemer(10000);
|
|
49
|
+
|
|
50
|
+
if (!response) {
|
|
51
|
+
LOG.warn('[search] No response');
|
|
52
|
+
return { result: 'no response', coins: 0 };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (isHoldTight(response)) {
|
|
56
|
+
const reason = getHoldTightReason(response);
|
|
57
|
+
LOG.warn(`[search] Hold Tight${reason ? ` (reason: /${reason})` : ''} — waiting 30s`);
|
|
58
|
+
await sleep(30000);
|
|
59
|
+
return { result: `hold tight (${reason || 'unknown'})`, coins: 0, holdTightReason: reason };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
logMsg(response, 'search');
|
|
63
|
+
const buttons = getAllButtons(response);
|
|
64
|
+
|
|
65
|
+
if (buttons.length === 0) {
|
|
66
|
+
const text = getFullText(response);
|
|
67
|
+
LOG.info(`[search] No buttons: ${text.substring(0, 80)}`);
|
|
68
|
+
return { result: text.substring(0, 60), coins: 0 };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const btn = pickSafeButton(buttons, safeAnswers);
|
|
72
|
+
if (!btn) {
|
|
73
|
+
LOG.warn('[search] No safe button found');
|
|
74
|
+
return { result: 'no safe option', coins: 0 };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
LOG.info(`[search] Picking: "${btn.label}"`);
|
|
78
|
+
await humanDelay();
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const followUp = await safeClickButton(response, btn);
|
|
82
|
+
if (followUp) {
|
|
83
|
+
logMsg(followUp, 'search-result');
|
|
84
|
+
const text = getFullText(followUp);
|
|
85
|
+
const coins = parseCoins(text);
|
|
86
|
+
if (coins > 0) {
|
|
87
|
+
LOG.coin(`[search] ${btn.label} → ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`);
|
|
88
|
+
return { result: `${btn.label} → +⏣ ${coins.toLocaleString()}`, coins };
|
|
89
|
+
}
|
|
90
|
+
if (text.toLowerCase().includes('nothing')) {
|
|
91
|
+
return { result: `${btn.label} → found nothing`, coins: 0 };
|
|
92
|
+
}
|
|
93
|
+
return { result: `${btn.label} → done`, coins: 0 };
|
|
94
|
+
}
|
|
95
|
+
return { result: `clicked: ${btn.label}`, coins: 0 };
|
|
96
|
+
} catch (e) {
|
|
97
|
+
LOG.error(`[search] Click error: ${e.message}`);
|
|
98
|
+
return { result: `error: ${e.message}`, coins: 0 };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { runSearch, SAFE_SEARCH_LOCATIONS };
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shop / Buy Item command handler.
|
|
3
|
+
* Handles opening the shop, navigating to Coin Shop, clicking buy, and submitting modal.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
LOG, c, sleep, humanDelay, getFullText, parseCoins,
|
|
8
|
+
getAllButtons, findButton, findSelectMenuOption, safeClickButton,
|
|
9
|
+
logMsg, dumpMessage, isHoldTight, DANK_MEMER_ID,
|
|
10
|
+
} = require('./utils');
|
|
11
|
+
|
|
12
|
+
// Known items and their approximate costs
|
|
13
|
+
const ITEM_COSTS = {
|
|
14
|
+
'rifle': 50000,
|
|
15
|
+
'hunting rifle': 50000,
|
|
16
|
+
'shovel': 25000,
|
|
17
|
+
'fishing pole': 25000,
|
|
18
|
+
'adventure ticket': 250000,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Buy an item from the Dank Memer shop.
|
|
23
|
+
*
|
|
24
|
+
* @param {object} opts
|
|
25
|
+
* @param {object} opts.channel - Discord channel to send commands in
|
|
26
|
+
* @param {function} opts.waitForDankMemer - Function that returns a promise resolving to Dank Memer's response
|
|
27
|
+
* @param {string} opts.itemName - Name of the item to buy (e.g. 'Rifle', 'Shovel', 'Adventure Ticket')
|
|
28
|
+
* @param {number} [opts.quantity=1] - How many to buy
|
|
29
|
+
* @param {object} [opts.client] - Discord client (needed for modal via interactionModalCreate)
|
|
30
|
+
* @returns {Promise<boolean>} true if purchase succeeded
|
|
31
|
+
*/
|
|
32
|
+
async function buyItem({ channel, waitForDankMemer, itemName, quantity = 1, client }) {
|
|
33
|
+
const MAX_RETRIES = 3;
|
|
34
|
+
const searchName = itemName.toLowerCase().replace('hunting ', '').replace('fishing ', '').replace('adventure ', '');
|
|
35
|
+
|
|
36
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
37
|
+
LOG.buy(`Opening shop to buy ${c.bold}${quantity}x ${itemName}${c.reset} (attempt ${attempt}/${MAX_RETRIES})`);
|
|
38
|
+
|
|
39
|
+
// Step 1: Open shop
|
|
40
|
+
await channel.send('pls shop view');
|
|
41
|
+
let response = await waitForDankMemer(10000);
|
|
42
|
+
|
|
43
|
+
if (!response) {
|
|
44
|
+
LOG.warn('No response to shop view command.');
|
|
45
|
+
if (attempt < MAX_RETRIES) { await humanDelay(2000, 4000); continue; }
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Check for Hold Tight
|
|
50
|
+
if (isHoldTight(response)) {
|
|
51
|
+
LOG.warn('Hold Tight detected — waiting 30s...');
|
|
52
|
+
await sleep(30000);
|
|
53
|
+
if (attempt < MAX_RETRIES) continue;
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
logMsg(response, 'shop');
|
|
58
|
+
|
|
59
|
+
const responseText = getFullText(response).toLowerCase();
|
|
60
|
+
|
|
61
|
+
// Check if we got an event/lucky response instead of shop
|
|
62
|
+
const allBtnsCheck = getAllButtons(response);
|
|
63
|
+
const hasShopComponents = allBtnsCheck.some(b => b.label && b.label.toLowerCase().includes('buy')) ||
|
|
64
|
+
allBtnsCheck.some(b => b.label && b.label.toLowerCase().includes('ticket'));
|
|
65
|
+
|
|
66
|
+
if (!hasShopComponents && (responseText.includes('lucky') || responseText.includes('event'))) {
|
|
67
|
+
LOG.warn('Got event response instead of shop. Retrying...');
|
|
68
|
+
await humanDelay(3000, 5000);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// If we got a shop text but no components, wait for the full shop UI
|
|
73
|
+
if (!hasShopComponents && responseText.includes('shop')) {
|
|
74
|
+
LOG.debug('Waiting for shop UI to fully load...');
|
|
75
|
+
const shopUI = await waitForDankMemer(8000);
|
|
76
|
+
if (shopUI) response = shopUI;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Step 2: Navigate to Coin Shop tab
|
|
80
|
+
// Re-fetch message to get hydrated components (minValues/maxValues for selectMenu)
|
|
81
|
+
const freshShopMsg = await channel.messages.fetch(response.id).catch(() => null);
|
|
82
|
+
if (freshShopMsg) response = freshShopMsg;
|
|
83
|
+
|
|
84
|
+
const csInfo = findSelectMenuOption(response, 'Coin Shop');
|
|
85
|
+
if (csInfo) {
|
|
86
|
+
const alreadySelected = csInfo.component.options.find(o => o.value === csInfo.option.value)?.default === true;
|
|
87
|
+
if (!alreadySelected) {
|
|
88
|
+
LOG.buy('Switching to Coin Shop tab...');
|
|
89
|
+
// Find row index for the select menu
|
|
90
|
+
let shopMenuRowIdx = -1;
|
|
91
|
+
for (let i = 0; i < (response.components || []).length; i++) {
|
|
92
|
+
const row = response.components[i];
|
|
93
|
+
for (const comp of row.components || []) {
|
|
94
|
+
if ((comp.type === 'STRING_SELECT' || comp.type === 3) && comp.customId === csInfo.menuCustomId) {
|
|
95
|
+
shopMenuRowIdx = i; break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (shopMenuRowIdx >= 0) break;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const result = await response.selectMenu(shopMenuRowIdx >= 0 ? shopMenuRowIdx : csInfo.menuCustomId, [csInfo.option.value]);
|
|
102
|
+
if (result) { response = result; LOG.success('Switched to Coin Shop'); }
|
|
103
|
+
} catch (e) {
|
|
104
|
+
LOG.error(`Failed to switch to Coin Shop: ${e.message}`);
|
|
105
|
+
if (attempt < MAX_RETRIES) { await humanDelay(2000, 4000); continue; }
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
await sleep(600);
|
|
109
|
+
} else {
|
|
110
|
+
LOG.debug('Already in Coin Shop');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
logMsg(response, 'shop-after-nav');
|
|
115
|
+
|
|
116
|
+
// Step 3: Find the Buy button for our item
|
|
117
|
+
let buyBtn = getAllButtons(response).find(b => b.label && b.label.toLowerCase().includes(searchName));
|
|
118
|
+
|
|
119
|
+
if (!buyBtn) {
|
|
120
|
+
LOG.warn(`No Buy button found for "${itemName}" (searched: "${searchName}")`);
|
|
121
|
+
// Log all available buttons for debugging
|
|
122
|
+
const allBtns = getAllButtons(response);
|
|
123
|
+
if (allBtns.length > 0) {
|
|
124
|
+
LOG.debug(`Available buttons: ${allBtns.map(b => `"${b.label}"`).join(', ')}`);
|
|
125
|
+
}
|
|
126
|
+
// Maybe we need to scroll/paginate?
|
|
127
|
+
const nextBtn = findButton(response, 'next') || findButton(response, '▶') || findButton(response, '→');
|
|
128
|
+
if (nextBtn && !nextBtn.disabled) {
|
|
129
|
+
LOG.buy('Clicking next page to find item...');
|
|
130
|
+
try {
|
|
131
|
+
const nextPage = await safeClickButton(response, nextBtn);
|
|
132
|
+
if (nextPage) {
|
|
133
|
+
response = nextPage;
|
|
134
|
+
logMsg(response, 'shop-page2');
|
|
135
|
+
// Try finding button again
|
|
136
|
+
buyBtn = getAllButtons(response).find(b => b.label && b.label.toLowerCase().includes(searchName));
|
|
137
|
+
}
|
|
138
|
+
} catch (e) {
|
|
139
|
+
LOG.error(`Page nav failed: ${e.message}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!buyBtn) {
|
|
144
|
+
if (attempt < MAX_RETRIES) { await humanDelay(2000, 4000); continue; }
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (buyBtn.disabled) {
|
|
150
|
+
LOG.warn(`"${buyBtn.label}" button is DISABLED — not enough coins to buy ${itemName}`);
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Step 4: Click the Buy button
|
|
155
|
+
LOG.buy(`Clicking "${buyBtn.label}"...`);
|
|
156
|
+
let clickResult;
|
|
157
|
+
try {
|
|
158
|
+
clickResult = await safeClickButton(response, buyBtn);
|
|
159
|
+
} catch (e) {
|
|
160
|
+
LOG.error(`Buy click failed: ${e.message}`);
|
|
161
|
+
if (attempt < MAX_RETRIES) { await humanDelay(2000, 4000); continue; }
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Step 5: Handle Modal (quantity input)
|
|
166
|
+
// Try two approaches: direct modal from clickResult, or listen for interactionModalCreate
|
|
167
|
+
let modalHandled = false;
|
|
168
|
+
|
|
169
|
+
// Approach A: clickResult is a Modal
|
|
170
|
+
if (clickResult && (clickResult.isMessage === false || clickResult.constructor?.name === 'Modal')) {
|
|
171
|
+
const modal = clickResult;
|
|
172
|
+
LOG.buy(`Modal appeared: "${modal.title || modal.customId}"`);
|
|
173
|
+
try {
|
|
174
|
+
modal.components[0].components[0].setValue(String(quantity));
|
|
175
|
+
LOG.buy(`Submitting modal (qty: ${quantity})...`);
|
|
176
|
+
const submitResult = await modal.reply();
|
|
177
|
+
LOG.success('Modal submitted!');
|
|
178
|
+
if (submitResult) {
|
|
179
|
+
const text = getFullText(submitResult);
|
|
180
|
+
logMsg(submitResult, 'buy-result');
|
|
181
|
+
if (text.toLowerCase().includes('success') || text.toLowerCase().includes('bought') || text.toLowerCase().includes('purchased')) {
|
|
182
|
+
LOG.success(`${c.green}${c.bold}Purchased ${quantity}x ${itemName}!${c.reset}`);
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
modalHandled = true;
|
|
187
|
+
} catch (e) {
|
|
188
|
+
LOG.error(`Modal submit failed: ${e.message}`);
|
|
189
|
+
if (attempt < MAX_RETRIES) { await humanDelay(2000, 4000); continue; }
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Approach B: Listen for interactionModalCreate event on client
|
|
195
|
+
if (!modalHandled && client) {
|
|
196
|
+
const modal = await new Promise((resolve) => {
|
|
197
|
+
const timer = setTimeout(() => resolve(null), 8000);
|
|
198
|
+
const handler = (m) => {
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
client.removeListener('interactionModalCreate', handler);
|
|
201
|
+
resolve(m);
|
|
202
|
+
};
|
|
203
|
+
client.on('interactionModalCreate', handler);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
if (modal) {
|
|
207
|
+
LOG.buy(`Modal (event): "${modal.title || modal.customId}"`);
|
|
208
|
+
try {
|
|
209
|
+
const quantityInputId = modal.components[0].components[0].customId;
|
|
210
|
+
await fetch('https://discord.com/api/v9/interactions', {
|
|
211
|
+
method: 'POST',
|
|
212
|
+
headers: { 'Authorization': client.token, 'Content-Type': 'application/json' },
|
|
213
|
+
body: JSON.stringify({
|
|
214
|
+
type: 5, application_id: modal.applicationId,
|
|
215
|
+
channel_id: channel.id, guild_id: channel.guild?.id,
|
|
216
|
+
data: {
|
|
217
|
+
id: modal.id, custom_id: modal.customId,
|
|
218
|
+
components: [{ type: 1, components: [{ type: 4, custom_id: quantityInputId, value: String(quantity) }] }]
|
|
219
|
+
},
|
|
220
|
+
session_id: client.sessionId || "dummy_session",
|
|
221
|
+
nonce: Date.now().toString()
|
|
222
|
+
})
|
|
223
|
+
});
|
|
224
|
+
LOG.success('Modal submitted via API!');
|
|
225
|
+
modalHandled = true;
|
|
226
|
+
} catch (e) {
|
|
227
|
+
LOG.error(`Modal API submit failed: ${e.message}`);
|
|
228
|
+
if (attempt < MAX_RETRIES) { await humanDelay(2000, 4000); continue; }
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Step 6: Wait for confirmation message from Dank Memer
|
|
235
|
+
const confirmMsg = await waitForDankMemer(8000);
|
|
236
|
+
if (confirmMsg) {
|
|
237
|
+
const text = getFullText(confirmMsg).toLowerCase();
|
|
238
|
+
logMsg(confirmMsg, 'buy-confirm');
|
|
239
|
+
if (text.includes('bought') || text.includes('purchased') || text.includes('success')) {
|
|
240
|
+
LOG.success(`${c.green}${c.bold}Purchased ${quantity}x ${itemName}!${c.reset}`);
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
if (text.includes('not enough') || text.includes("can't afford") || text.includes('insufficient')) {
|
|
244
|
+
LOG.warn(`Not enough coins to buy ${itemName}.`);
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (modalHandled) {
|
|
250
|
+
LOG.success(`Submitted purchase for ${quantity}x ${itemName} (result unclear but modal was submitted)`);
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
LOG.warn(`Purchase result unclear (attempt ${attempt})`);
|
|
255
|
+
if (attempt < MAX_RETRIES) { await humanDelay(2000, 4000); continue; }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
LOG.error(`Failed to buy ${itemName} after ${MAX_RETRIES} attempts.`);
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
module.exports = { buyItem, ITEM_COSTS };
|