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.
@@ -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 };