dankgrinder 5.14.0 → 5.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,7 +15,7 @@
15
15
 
16
16
  const {
17
17
  LOG, c, sleep, humanDelay, getFullText,
18
- getAllButtons, findSelectMenuOption,
18
+ getAllButtons, findSelectMenuOption, getAllSelectMenus, safeClickButton,
19
19
  logMsg, isHoldTight, isCV2, ensureCV2, stripAnsi,
20
20
  } = require('./utils');
21
21
  const { LRUCache, Trie } = require('../structures');
@@ -29,6 +29,13 @@ const ITEM_COSTS = Object.freeze({
29
29
  'adventure ticket': 250000,
30
30
  'keyboard': 10000,
31
31
  'mouse': 10000,
32
+ 'hoe': 25000,
33
+ 'watering can': 25000,
34
+ 'water can': 25000,
35
+ 'water bucket': 100000,
36
+ 'seeds': 5000,
37
+ 'seed': 5000,
38
+ 'corn seeds': 5000,
32
39
  });
33
40
 
34
41
  const ITEM_SEARCH_TERM = Object.freeze({
@@ -39,6 +46,13 @@ const ITEM_SEARCH_TERM = Object.freeze({
39
46
  'adventure ticket': 'ticket',
40
47
  'keyboard': 'keyboard',
41
48
  'mouse': 'mouse',
49
+ 'hoe': 'hoe',
50
+ 'watering can': 'water can',
51
+ 'water can': 'water can',
52
+ 'water bucket': 'water bucket',
53
+ 'seeds': 'seeds',
54
+ 'seed': 'seed',
55
+ 'corn seeds': 'corn seeds',
42
56
  });
43
57
 
44
58
  const ITEM_SHOP_TAB = Object.freeze({
@@ -49,6 +63,13 @@ const ITEM_SHOP_TAB = Object.freeze({
49
63
  'adventure ticket': 'Coin Shop',
50
64
  'keyboard': 'Coin Shop',
51
65
  'mouse': 'Coin Shop',
66
+ 'hoe': 'Coin Shop',
67
+ 'watering can': 'Coin Shop',
68
+ 'water can': 'Coin Shop',
69
+ 'water bucket': 'Coin Shop',
70
+ 'seeds': 'Coin Shop',
71
+ 'seed': 'Coin Shop',
72
+ 'corn seeds': 'Coin Shop',
52
73
  });
53
74
 
54
75
  // Trie for item button label matching — O(k) vs O(n) linear scan
@@ -80,50 +101,56 @@ function isBuyFailText(text) {
80
101
  || lower.includes('missing items');
81
102
  }
82
103
 
83
- /**
84
- * Buy an item from the Dank Memer shop.
85
- *
86
- * @param {object} opts
87
- * @param {object} opts.channel
88
- * @param {function} opts.waitForDankMemer
89
- * @param {string} opts.itemName - e.g. 'Hunting Rifle', 'Shovel'
90
- * @param {number} [opts.quantity=1]
91
- * @param {object} [opts.client] - Discord client
92
- * @returns {Promise<boolean>} true if purchase succeeded
93
- */
94
- async function buyItem({ channel, waitForDankMemer, itemName, quantity = 1, client }) {
95
- const key = itemName.toLowerCase();
96
- const searchTerm = ITEM_SEARCH_TERM[key] || key.split(' ').pop();
97
- const targetTab = ITEM_SHOP_TAB[key] || 'Coin Shop';
104
+ function findBuyButtonInMessage(msg, searchTerm, key) {
105
+ const allBtns = getAllButtons(msg);
106
+ const normalizedSearch = String(searchTerm || '').toLowerCase().replace(/\s+/g, '');
107
+ const normalizedKey = String(key || '').toLowerCase().replace(/\s+/g, '');
108
+ return allBtns.find(b => {
109
+ const label = String(b.label || '').toLowerCase();
110
+ const id = String(b.customId || b.custom_id || '').toLowerCase().replace(/\s+/g, '');
111
+ return label.includes(searchTerm)
112
+ || (normalizedSearch && id.includes(normalizedSearch))
113
+ || (normalizedKey && id.includes(normalizedKey));
114
+ }) || null;
115
+ }
98
116
 
99
- // LRU dedup: skip if we bought this item in the last 10 seconds
100
- const lastBuy = recentPurchases.get(key);
101
- if (lastBuy && Date.now() - lastBuy < 10000) {
102
- LOG.info(`[shop] Skipping ${itemName} — just purchased ${Math.round((Date.now() - lastBuy) / 1000)}s ago`);
103
- return true;
104
- }
117
+ function findNextShopPageButton(msg) {
118
+ const btns = getAllButtons(msg).filter(b => !b.disabled);
119
+ return btns.find(b => {
120
+ const id = String(b.customId || b.custom_id || '').toLowerCase();
121
+ if (!id.includes('shop-view')) return false;
122
+ return id.endsWith(':1') || id.includes(':1:');
123
+ }) || null;
124
+ }
105
125
 
106
- LOG.buy(`Opening shop to buy ${c.bold}${quantity}x ${itemName}${c.reset}`);
126
+ function normalizeShopRequest(itemName, quantity = 1) {
127
+ const key = String(itemName || '').toLowerCase();
128
+ return {
129
+ itemName,
130
+ quantity,
131
+ key,
132
+ searchTerm: ITEM_SEARCH_TERM[key] || key.split(' ').pop(),
133
+ targetTab: ITEM_SHOP_TAB[key] || 'Coin Shop',
134
+ };
135
+ }
107
136
 
108
- // Step 1: Open shop
137
+ async function openShopView({ channel, waitForDankMemer }) {
109
138
  await channel.send('pls shop view');
110
139
  let response = await waitForDankMemer(10000);
111
140
 
112
- if (!response) {
113
- LOG.warn('[shop] No response to pls shop view');
114
- return false;
115
- }
141
+ if (!response) return { ok: false, reason: 'no-response', response: null };
116
142
  if (isHoldTight(response)) {
117
143
  LOG.warn('[shop] Hold tight — waiting 30s');
118
144
  await sleep(30000);
119
- return false;
145
+ return { ok: false, reason: 'hold-tight', response: null };
120
146
  }
121
147
 
122
148
  if (isCV2(response)) await ensureCV2(response);
123
-
124
149
  logMsg(response, 'shop');
150
+ return { ok: true, reason: 'ok', response };
151
+ }
125
152
 
126
- // Step 2: Switch shop tab if needed (e.g. Fishing Shop for fishing pole)
153
+ async function ensureShopTab({ response, waitForDankMemer, targetTab }) {
127
154
  const selectInfo = findSelectMenuOption(response, targetTab);
128
155
  if (selectInfo) {
129
156
  const isAlreadySelected = (selectInfo.component.options || [])
@@ -137,7 +164,7 @@ async function buyItem({ channel, waitForDankMemer, itemName, quantity = 1, clie
137
164
  const row = response.components[i];
138
165
  for (const comp of row?.components || []) {
139
166
  if ((comp.type === 'STRING_SELECT' || comp.type === 3) &&
140
- comp.customId === selectInfo.menuCustomId) {
167
+ (comp.customId || comp.custom_id) === selectInfo.menuCustomId) {
141
168
  menuRowIdx = i;
142
169
  break;
143
170
  }
@@ -148,46 +175,62 @@ async function buyItem({ channel, waitForDankMemer, itemName, quantity = 1, clie
148
175
  menuRowIdx >= 0 ? menuRowIdx : selectInfo.menuCustomId,
149
176
  [selectInfo.option.value]
150
177
  );
151
- if (result) {
152
- response = result;
153
- if (isCV2(response)) await ensureCV2(response);
154
- LOG.success(`Switched to ${targetTab}`);
155
- }
178
+ response = result || (await waitForDankMemer(6000)) || response;
179
+ if (isCV2(response)) await ensureCV2(response);
180
+ LOG.success(`Switched to ${targetTab}`);
156
181
  await sleep(300);
157
182
  } catch (e) {
158
183
  LOG.error(`[shop] Tab switch failed: ${e.message}`);
159
- return false;
184
+ }
185
+ }
186
+ } else {
187
+ const menus = getAllSelectMenus(response);
188
+ const menu = menus.find(m => (m.options || []).length > 0);
189
+ if (menu) {
190
+ const desired = (menu.options || []).find(o => (o.label || '').toLowerCase().includes(targetTab.toLowerCase()));
191
+ if (desired) {
192
+ try {
193
+ await response.selectMenu(menu.customId || menu.custom_id, [desired.value]);
194
+ const maybeUpdated = await waitForDankMemer(6000);
195
+ if (maybeUpdated) {
196
+ response = maybeUpdated;
197
+ if (isCV2(response)) await ensureCV2(response);
198
+ LOG.success(`Switched to ${targetTab}`);
199
+ }
200
+ } catch {}
160
201
  }
161
202
  }
162
203
  }
204
+ return response;
205
+ }
163
206
 
164
- // Step 3: Find the Buy button for our item
165
- const allBtns = getAllButtons(response);
166
- const normalizedSearch = String(searchTerm || '').toLowerCase().replace(/\s+/g, '');
167
- const normalizedKey = String(key || '').toLowerCase().replace(/\s+/g, '');
168
- const buyBtn = allBtns.find(b => {
169
- const label = String(b.label || '').toLowerCase();
170
- const id = String(b.customId || b.custom_id || '').toLowerCase().replace(/\s+/g, '');
171
- return label.includes(searchTerm)
172
- || (normalizedSearch && id.includes(normalizedSearch))
173
- || (normalizedKey && id.includes(normalizedKey));
174
- });
207
+ async function findBuyButtonPaged({ response, waitForDankMemer, searchTerm, key }) {
208
+ let buyBtn = null;
209
+ for (let page = 0; page < 10; page++) {
210
+ buyBtn = findBuyButtonInMessage(response, searchTerm, key);
211
+ if (buyBtn) break;
175
212
 
176
- if (!buyBtn) {
177
- LOG.warn(`[shop] No button for "${itemName}" (search: "${searchTerm}")`);
178
- const named = allBtns.filter(b => b.label);
179
- if (named.length > 0) {
180
- LOG.debug(`[shop] Available: ${named.map(b => `"${b.label}"(${b.disabled ? 'DIS' : 'EN'})`).join(', ')}`);
213
+ const nextPageBtn = findNextShopPageButton(response);
214
+ if (!nextPageBtn) break;
215
+ try {
216
+ const moved = await safeClickButton(response, nextPageBtn);
217
+ response = moved || (await waitForDankMemer(5000)) || response;
218
+ if (isCV2(response)) await ensureCV2(response);
219
+ await sleep(200);
220
+ } catch {
221
+ break;
181
222
  }
182
- return false;
183
223
  }
224
+ return { response, buyBtn };
225
+ }
184
226
 
227
+ async function performBuyFromButton({ response, buyBtn, waitForDankMemer, itemName, quantity, key }) {
228
+ if (!buyBtn) return { ok: false, reason: 'not-found', response };
185
229
  if (buyBtn.disabled) {
186
230
  LOG.warn(`[shop] "${buyBtn.label}" is DISABLED — not enough coins for ${itemName}`);
187
- return false;
231
+ return { ok: false, reason: 'disabled', response };
188
232
  }
189
233
 
190
- // Step 4: Click the Buy button → Modal with quantity input
191
234
  LOG.buy(`Clicking "${buyBtn.label}"...`);
192
235
  const btnId = buyBtn.customId || buyBtn.custom_id;
193
236
  let clickResult;
@@ -195,11 +238,9 @@ async function buyItem({ channel, waitForDankMemer, itemName, quantity = 1, clie
195
238
  clickResult = await response.clickButton(btnId);
196
239
  } catch (e) {
197
240
  LOG.error(`[shop] Button click failed: ${e.message}`);
198
- return false;
241
+ return { ok: false, reason: 'click-failed', response };
199
242
  }
200
243
 
201
- // Some shop buttons buy immediately (no quantity modal).
202
- // If we received a message-like response, parse it directly.
203
244
  if (clickResult && !clickResult.components?.[0]?.components?.[0]?.setValue) {
204
245
  if (isCV2(clickResult)) await ensureCV2(clickResult);
205
246
  const directText = getFullText(clickResult);
@@ -207,14 +248,13 @@ async function buyItem({ channel, waitForDankMemer, itemName, quantity = 1, clie
207
248
  if (isBuySuccessText(directText)) {
208
249
  LOG.success(`${c.green}${c.bold}Purchased ${quantity}x ${itemName}!${c.reset}`);
209
250
  recentPurchases.set(key, Date.now());
210
- return true;
251
+ return { ok: true, reason: 'direct-success', response: clickResult };
211
252
  }
212
253
  if (isBuyFailText(directText)) {
213
254
  LOG.warn(`[shop] Direct buy failed for ${itemName}: ${directText.substring(0, 120)}`);
214
- return false;
255
+ return { ok: false, reason: 'direct-failed', response: clickResult };
215
256
  }
216
257
 
217
- // Fallback: wait one follow-up message for purchase outcome text.
218
258
  const follow = await waitForDankMemer(6000);
219
259
  if (follow) {
220
260
  if (isCV2(follow)) await ensureCV2(follow);
@@ -223,27 +263,26 @@ async function buyItem({ channel, waitForDankMemer, itemName, quantity = 1, clie
223
263
  if (isBuySuccessText(fText)) {
224
264
  LOG.success(`${c.green}${c.bold}Purchased ${quantity}x ${itemName}!${c.reset}`);
225
265
  recentPurchases.set(key, Date.now());
226
- return true;
266
+ return { ok: true, reason: 'followup-success', response: follow };
227
267
  }
228
268
  if (isBuyFailText(fText)) {
229
269
  LOG.warn(`[shop] Buy failed for ${itemName}: ${fText.substring(0, 120)}`);
230
- return false;
270
+ return { ok: false, reason: 'followup-failed', response: follow };
231
271
  }
272
+ return { ok: true, reason: 'followup-unknown', response: follow };
232
273
  }
233
274
 
234
275
  LOG.success(`Buy click sent for ${itemName} (no modal path)`);
235
276
  recentPurchases.set(key, Date.now());
236
- return true;
277
+ return { ok: true, reason: 'direct-unknown', response: clickResult };
237
278
  }
238
279
 
239
280
  const modal = clickResult;
240
-
241
281
  if (!modal || !modal.components) {
242
282
  LOG.warn('[shop] No modal returned from button click');
243
- return false;
283
+ return { ok: false, reason: 'no-modal', response };
244
284
  }
245
285
 
246
- // Step 5: Set quantity and submit the modal
247
286
  LOG.buy(`Modal "${modal.title}" — qty=${quantity}`);
248
287
  try {
249
288
  modal.components[0].components[0].setValue(String(quantity));
@@ -256,20 +295,129 @@ async function buyItem({ channel, waitForDankMemer, itemName, quantity = 1, clie
256
295
  if (isBuySuccessText(text)) {
257
296
  LOG.success(`${c.green}${c.bold}Purchased ${quantity}x ${itemName}!${c.reset}`);
258
297
  recentPurchases.set(key, Date.now());
259
- return true;
298
+ return { ok: true, reason: 'modal-success', response: result };
260
299
  }
261
300
  if (isBuyFailText(text)) {
262
301
  LOG.warn(`Not enough coins to buy ${itemName}`);
263
- return false;
302
+ return { ok: false, reason: 'modal-failed', response: result };
264
303
  }
304
+ return { ok: true, reason: 'modal-unknown', response: result };
265
305
  }
266
306
 
267
307
  LOG.success(`Modal submitted for ${quantity}x ${itemName} (assuming success)`);
268
- return true;
308
+ return { ok: true, reason: 'modal-submitted', response };
269
309
  } catch (e) {
270
310
  LOG.error(`[shop] Modal submit failed: ${e.message}`);
311
+ return { ok: false, reason: 'modal-submit-failed', response };
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Buy an item from the Dank Memer shop.
317
+ *
318
+ * @param {object} opts
319
+ * @param {object} opts.channel
320
+ * @param {function} opts.waitForDankMemer
321
+ * @param {string} opts.itemName - e.g. 'Hunting Rifle', 'Shovel'
322
+ * @param {number} [opts.quantity=1]
323
+ * @param {object} [opts.client] - Discord client
324
+ * @returns {Promise<boolean>} true if purchase succeeded
325
+ */
326
+ async function buyItem({ channel, waitForDankMemer, itemName, quantity = 1, client }) {
327
+ const { key, searchTerm, targetTab } = normalizeShopRequest(itemName, quantity);
328
+
329
+ // LRU dedup: skip if we bought this item in the last 10 seconds
330
+ const lastBuy = recentPurchases.get(key);
331
+ if (lastBuy && Date.now() - lastBuy < 10000) {
332
+ LOG.info(`[shop] Skipping ${itemName} — just purchased ${Math.round((Date.now() - lastBuy) / 1000)}s ago`);
333
+ return true;
334
+ }
335
+
336
+ LOG.buy(`Opening shop to buy ${c.bold}${quantity}x ${itemName}${c.reset}`);
337
+
338
+ // Step 1: Open shop
339
+ const opened = await openShopView({ channel, waitForDankMemer });
340
+ if (!opened.ok) {
341
+ LOG.warn('[shop] No response to pls shop view');
271
342
  return false;
272
343
  }
344
+ let response = opened.response;
345
+
346
+ // Step 2: Switch shop tab if needed
347
+ response = await ensureShopTab({ response, waitForDankMemer, targetTab });
348
+
349
+ // Step 3: Find the Buy button for our item (scan pages in current tab)
350
+ const located = await findBuyButtonPaged({ response, waitForDankMemer, searchTerm, key });
351
+ response = located.response;
352
+ const buyBtn = located.buyBtn;
353
+
354
+ if (!buyBtn) {
355
+ LOG.warn(`[shop] No button for "${itemName}" (search: "${searchTerm}")`);
356
+ const named = getAllButtons(response).filter(b => b.label);
357
+ if (named.length > 0) {
358
+ LOG.debug(`[shop] Available: ${named.map(b => `"${b.label}"(${b.disabled ? 'DIS' : 'EN'})`).join(', ')}`);
359
+ }
360
+ return false;
361
+ }
362
+
363
+ if (buyBtn.disabled) {
364
+ LOG.warn(`[shop] "${buyBtn.label}" is DISABLED — not enough coins for ${itemName}`);
365
+ return false;
366
+ }
367
+
368
+ // Step 4/5: purchase
369
+ const bought = await performBuyFromButton({ response, buyBtn, waitForDankMemer, itemName, quantity, key });
370
+ return !!bought.ok;
371
+ }
372
+
373
+ async function buyItemsBatch({ channel, waitForDankMemer, client, items = [] }) {
374
+ const requests = items
375
+ .map(i => normalizeShopRequest(i.itemName || i.item || '', i.quantity || i.qty || 1))
376
+ .filter(r => r.itemName && r.quantity > 0);
377
+
378
+ if (requests.length === 0) return { ok: true, results: [] };
379
+
380
+ const opened = await openShopView({ channel, waitForDankMemer });
381
+ if (!opened.ok) return { ok: false, results: requests.map(r => ({ itemName: r.itemName, success: false, reason: opened.reason })) };
382
+
383
+ let response = opened.response;
384
+ const results = [];
385
+
386
+ for (const req of requests) {
387
+ const lastBuy = recentPurchases.get(req.key);
388
+ if (lastBuy && Date.now() - lastBuy < 10000) {
389
+ results.push({ itemName: req.itemName, success: true, reason: 'recent' });
390
+ continue;
391
+ }
392
+
393
+ response = await ensureShopTab({ response, waitForDankMemer, targetTab: req.targetTab });
394
+ const located = await findBuyButtonPaged({ response, waitForDankMemer, searchTerm: req.searchTerm, key: req.key });
395
+ response = located.response;
396
+
397
+ if (!located.buyBtn) {
398
+ const named = getAllButtons(response).filter(b => b.label);
399
+ if (named.length > 0) {
400
+ LOG.debug(`[shop] Available: ${named.map(b => `"${b.label}"(${b.disabled ? 'DIS' : 'EN'})`).join(', ')}`);
401
+ }
402
+ results.push({ itemName: req.itemName, success: false, reason: 'not-found' });
403
+ continue;
404
+ }
405
+
406
+ const bought = await performBuyFromButton({
407
+ response,
408
+ buyBtn: located.buyBtn,
409
+ waitForDankMemer,
410
+ itemName: req.itemName,
411
+ quantity: req.quantity,
412
+ key: req.key,
413
+ });
414
+ response = bought.response || response;
415
+ results.push({ itemName: req.itemName, success: !!bought.ok, reason: bought.reason });
416
+
417
+ await sleep(300);
418
+ }
419
+
420
+ return { ok: results.every(r => r.success), results };
273
421
  }
274
422
 
275
- module.exports = { buyItem, ITEM_COSTS };
423
+ module.exports = { buyItem, buyItemsBatch, ITEM_COSTS };
@@ -1,12 +1,13 @@
1
1
  const {
2
2
  LOG, c, getFullText, parseCoins, getAllButtons, getAllSelectMenus,
3
3
  safeClickButton, logMsg, isHoldTight, getHoldTightReason, sleep, humanDelay, needsItem,
4
- isCV2, ensureCV2, stripAnsi,
4
+ isCV2, ensureCV2, stripAnsi, clickCV2Button,
5
5
  } = require('./utils');
6
6
  const { buyItem } = require('./shop');
7
7
 
8
8
  const STREAM_ITEMS = Object.freeze(['keyboard', 'mouse']);
9
9
  const STREAM_ACTION_LABELS = Object.freeze(['run ad', 'read chat', 'collect donations']);
10
+ const RE_STREAM_INTERACT_MIN = /interact\s+with\s+your\s+stream\s+every\s+`?(\d+)`?\s*minutes?/i;
10
11
 
11
12
  function normalizeLower(text) {
12
13
  return String(text || '')
@@ -30,6 +31,27 @@ async function refetchMsg(channel, msgId) {
30
31
  try { return await channel.messages.fetch(msgId); } catch { return null; }
31
32
  }
32
33
 
34
+ async function waitForMessageEditById(client, msgId, channelId, timeoutMs = 6000) {
35
+ if (!client?.on) return null;
36
+ return new Promise((resolve) => {
37
+ const timer = setTimeout(() => {
38
+ try { client.removeListener('messageUpdate', onUpdate); } catch {}
39
+ resolve(null);
40
+ }, timeoutMs);
41
+
42
+ function onUpdate(_oldMsg, newMsg) {
43
+ if (!newMsg) return;
44
+ if (newMsg.id !== msgId) return;
45
+ if (channelId && newMsg.channel?.id !== channelId && newMsg.channelId !== channelId) return;
46
+ clearTimeout(timer);
47
+ try { client.removeListener('messageUpdate', onUpdate); } catch {}
48
+ resolve(newMsg);
49
+ }
50
+
51
+ client.on('messageUpdate', onUpdate);
52
+ });
53
+ }
54
+
33
55
  function messageStateSignature(msg) {
34
56
  const text = normalizeLower(stripAnsi(getFullText(msg))).slice(0, 220);
35
57
  const btnSig = getAllButtons(msg)
@@ -40,10 +62,16 @@ function messageStateSignature(msg) {
40
62
  return `${text}||${btnSig}`;
41
63
  }
42
64
 
43
- async function waitForStreamTransition({ channel, waitForDankMemer, prevMsg, timeoutMs = 12000 }) {
65
+ async function waitForStreamTransition({ channel, waitForDankMemer, prevMsg, client, timeoutMs = 12000 }) {
44
66
  const start = Date.now();
45
67
  const prevSig = messageStateSignature(prevMsg);
46
68
  while (Date.now() - start < timeoutMs) {
69
+ const byIdUpdate = await waitForMessageEditById(client, prevMsg.id, prevMsg.channel?.id || prevMsg.channelId, 1800);
70
+ if (byIdUpdate) {
71
+ await hydrate(byIdUpdate);
72
+ if (messageStateSignature(byIdUpdate) !== prevSig) return byIdUpdate;
73
+ }
74
+
47
75
  const evt = await waitForDankMemer(1500);
48
76
  if (evt) {
49
77
  await hydrate(evt);
@@ -75,11 +103,14 @@ function isLiveDashboard(msg, lowerText) {
75
103
  return labels.some(l => l.includes('end stream')) && labels.some(l => STREAM_ACTION_LABELS.some(a => l.includes(a)));
76
104
  }
77
105
 
78
- function isStreamManagerScreen(lowerText) {
106
+ function isStreamManagerScreen(msg, lowerText) {
107
+ const labels = getAllButtons(msg).map(b => (b.label || '').toLowerCase());
108
+ const hasManagerButtons = labels.some(l => l.includes('go live')) || labels.some(l => l.includes('view setup'));
79
109
  return lowerText.includes('stream manager')
80
110
  || lowerText.includes('what game do you want to stream')
81
111
  || lowerText.includes('view setup')
82
- || lowerText.includes('go live');
112
+ || lowerText.includes('go live')
113
+ || hasManagerButtons;
83
114
  }
84
115
 
85
116
  function isActionResultText(lowerText) {
@@ -90,6 +121,16 @@ function isActionResultText(lowerText) {
90
121
  || lowerText.includes('received');
91
122
  }
92
123
 
124
+ function parseStreamInteractCooldownSec(text) {
125
+ const clean = String(stripAnsi(text || '')).replace(/\s+/g, ' ').trim();
126
+ const mm = clean.match(RE_STREAM_INTERACT_MIN);
127
+ if (mm) {
128
+ const mins = parseInt(mm[1], 10);
129
+ if (Number.isFinite(mins) && mins > 0) return mins * 60;
130
+ }
131
+ return 600;
132
+ }
133
+
93
134
  async function selectRandomStreamOption(msg) {
94
135
  const menus = getAllSelectMenus(msg);
95
136
  if (menus.length === 0) return false;
@@ -142,6 +183,7 @@ async function runStream({ channel, waitForDankMemer, client }) {
142
183
  logMsg(response, 'stream');
143
184
  let text = getFullText(response);
144
185
  let lower = normalizeLower(stripAnsi(text));
186
+ let nextCooldownSec = parseStreamInteractCooldownSec(text);
145
187
 
146
188
  // Missing items — buy keyboard + mouse
147
189
  const missing = needsItem(lower);
@@ -169,15 +211,18 @@ async function runStream({ channel, waitForDankMemer, client }) {
169
211
  logMsg(response, 'stream-retry');
170
212
  text = getFullText(response);
171
213
  lower = normalizeLower(stripAnsi(text));
214
+ nextCooldownSec = parseStreamInteractCooldownSec(text);
172
215
  }
173
216
 
174
217
  // Setup flow: manager -> option select (game/platform) -> Go Live -> live dashboard
218
+ let lastGoLiveCustomId = null;
175
219
  for (let step = 0; step < 6; step++) {
176
220
  text = getFullText(response);
177
221
  lower = normalizeLower(stripAnsi(text));
222
+ nextCooldownSec = parseStreamInteractCooldownSec(text);
178
223
 
179
- if (isLiveDashboard(response, lower)) break;
180
- if (!isStreamManagerScreen(lower)) break;
224
+ if (isLiveDashboard(response, lower)) break;
225
+ if (!isStreamManagerScreen(response, lower)) break;
181
226
 
182
227
  const selected = await selectRandomStreamOption(response);
183
228
  if (selected) {
@@ -191,7 +236,34 @@ async function runStream({ channel, waitForDankMemer, client }) {
191
236
  }
192
237
 
193
238
  const goLiveBtn = getGoLiveButton(response);
239
+ if (goLiveBtn?.customId || goLiveBtn?.custom_id) {
240
+ lastGoLiveCustomId = goLiveBtn.customId || goLiveBtn.custom_id;
241
+ }
194
242
  if (!goLiveBtn) {
243
+ // Parser fallback: game prompt text detected but buttons/selects may be hidden by library.
244
+ // Use previously known stream-game custom_id directly against current message.
245
+ if (lower.includes('what game do you want to stream') && lastGoLiveCustomId) {
246
+ LOG.info('[stream] Go Live button not visible; trying raw stream-game fallback');
247
+ try {
248
+ await clickCV2Button(response, lastGoLiveCustomId);
249
+ const transitioned = await waitForStreamTransition({
250
+ channel,
251
+ waitForDankMemer,
252
+ client,
253
+ prevMsg: response,
254
+ timeoutMs: 12000,
255
+ });
256
+ if (transitioned) {
257
+ response = transitioned;
258
+ await hydrate(response);
259
+ logMsg(response, `stream-go-live-raw-${step}`);
260
+ continue;
261
+ }
262
+ } catch (e) {
263
+ LOG.error(`[stream] Raw Go Live fallback failed: ${e.message}`);
264
+ }
265
+ }
266
+
195
267
  await sleep(300);
196
268
  const fresh = await refetchMsg(channel, response.id);
197
269
  if (fresh) {
@@ -207,12 +279,22 @@ async function runStream({ channel, waitForDankMemer, client }) {
207
279
  try {
208
280
  const before = response;
209
281
  const liveResult = await safeClickButton(response, goLiveBtn);
210
- const next = liveResult || (await waitForStreamTransition({
211
- channel,
212
- waitForDankMemer,
213
- prevMsg: before,
214
- timeoutMs: 12000,
215
- }));
282
+ const liveLooksLikeMessage = Boolean(liveResult?.id && (liveResult?.channel?.id || liveResult?.channelId));
283
+ let next = liveLooksLikeMessage ? liveResult : null;
284
+ if (!next) {
285
+ next = await waitForStreamTransition({
286
+ channel,
287
+ waitForDankMemer,
288
+ client,
289
+ prevMsg: before,
290
+ timeoutMs: 12000,
291
+ });
292
+ }
293
+ if (!next) {
294
+ // Last fallback: explicit refetch of the original message after click.
295
+ const fetched = await refetchMsg(channel, before.id);
296
+ if (fetched) next = fetched;
297
+ }
216
298
  if (next) {
217
299
  response = next;
218
300
  await hydrate(response);
@@ -234,6 +316,7 @@ async function runStream({ channel, waitForDankMemer, client }) {
234
316
  const afterFallback = fallbackRes || (await waitForStreamTransition({
235
317
  channel,
236
318
  waitForDankMemer,
319
+ client,
237
320
  prevMsg: before,
238
321
  timeoutMs: 8000,
239
322
  }));
@@ -255,11 +338,13 @@ async function runStream({ channel, waitForDankMemer, client }) {
255
338
  // Scheduler uses nextCooldownSec=600, so this executes every 10 minutes.
256
339
  text = getFullText(response);
257
340
  lower = normalizeLower(stripAnsi(text));
341
+ nextCooldownSec = parseStreamInteractCooldownSec(text);
258
342
 
259
343
  if (isLiveDashboard(response, lower)) {
260
344
  const actions = getLiveActionButtons(response);
261
345
  if (actions.length > 0) {
262
346
  const action = pickRandom(actions);
347
+ const actionAt = new Date();
263
348
  LOG.info(`[stream] Live action: "${action.label}"`);
264
349
  await humanDelay(120, 320);
265
350
  try {
@@ -292,10 +377,12 @@ async function runStream({ channel, waitForDankMemer, client }) {
292
377
  }
293
378
  text = bestText;
294
379
  lower = normalizeLower(stripAnsi(text));
380
+ nextCooldownSec = parseStreamInteractCooldownSec(text);
295
381
  }
296
382
  } catch (e) {
297
383
  LOG.error(`[stream] Action click failed: ${e.message}`);
298
384
  }
385
+ LOG.info(`[stream] Action timestamp: ${actionAt.toISOString()} | next interaction in ${Math.ceil(nextCooldownSec / 60)}m`);
299
386
  } else {
300
387
  LOG.info('[stream] Live dashboard found but no action buttons available yet');
301
388
  }
@@ -305,13 +392,13 @@ async function runStream({ channel, waitForDankMemer, client }) {
305
392
  const coins = parseCoins(text);
306
393
  if (coins > 0) {
307
394
  LOG.coin(`[stream] ${c.green}+⏣ ${coins.toLocaleString()}${c.reset}`);
308
- return { result: `stream → +⏣ ${coins.toLocaleString()}`, coins, nextCooldownSec: 600 };
395
+ return { result: `stream → +⏣ ${coins.toLocaleString()}`, coins, nextCooldownSec };
309
396
  }
310
397
 
311
- if (ended) return { result: 'stream ended', coins: 0, nextCooldownSec: 600 };
398
+ if (ended) return { result: 'stream ended', coins: 0, nextCooldownSec };
312
399
 
313
400
  const short = normalizeLower(stripAnsi(text)).substring(0, 60);
314
- return { result: short || 'streamed', coins: 0, nextCooldownSec: 600 };
401
+ return { result: short || 'streamed', coins: 0, nextCooldownSec };
315
402
  }
316
403
 
317
404
  module.exports = { runStream };