dankgrinder 4.8.1 → 4.9.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,344 @@
1
+ /**
2
+ * Inventory command handler.
3
+ * Sends "pls inv", pages through all pages, parses items.
4
+ * Stores parsed inventory in Redis (no expiry) with gwapes.com net values.
5
+ * Supports incremental updates: add/remove/change qty.
6
+ */
7
+
8
+ const https = require('https');
9
+ const {
10
+ LOG, c, sleep, humanDelay, getFullText, getAllButtons,
11
+ safeClickButton, logMsg, isHoldTight, getHoldTightReason,
12
+ isCV2, ensureCV2,
13
+ } = require('./utils');
14
+
15
+ let _itemValuesCache = null;
16
+ let _itemValuesFetchedAt = 0;
17
+ const ITEM_CACHE_TTL = 3600_000; // 1 hour
18
+
19
+ /**
20
+ * Fetch all item values from gwapes.com API.
21
+ * Caches in-memory for 1 hour.
22
+ */
23
+ async function fetchItemValues() {
24
+ if (_itemValuesCache && Date.now() - _itemValuesFetchedAt < ITEM_CACHE_TTL) {
25
+ return _itemValuesCache;
26
+ }
27
+
28
+ return new Promise((resolve) => {
29
+ https.get('https://api.gwapes.com/items', (res) => {
30
+ let data = '';
31
+ res.on('data', (chunk) => data += chunk);
32
+ res.on('end', () => {
33
+ try {
34
+ const parsed = JSON.parse(data);
35
+ if (parsed.success && Array.isArray(parsed.body)) {
36
+ const map = {};
37
+ for (const item of parsed.body) {
38
+ map[item.name.toLowerCase()] = {
39
+ name: item.name,
40
+ value: item.value || 0,
41
+ net_value: item.net_value || 0,
42
+ type: item.type || 'Unknown',
43
+ };
44
+ }
45
+ _itemValuesCache = map;
46
+ _itemValuesFetchedAt = Date.now();
47
+ LOG.info(`[inv] Fetched ${Object.keys(map).length} item values from gwapes.com`);
48
+ resolve(map);
49
+ } else {
50
+ resolve(_itemValuesCache || {});
51
+ }
52
+ } catch (e) {
53
+ LOG.error(`[inv] gwapes API parse error: ${e.message}`);
54
+ resolve(_itemValuesCache || {});
55
+ }
56
+ });
57
+ }).on('error', (e) => {
58
+ LOG.error(`[inv] gwapes API error: ${e.message}`);
59
+ resolve(_itemValuesCache || {});
60
+ });
61
+ });
62
+ }
63
+
64
+ /** Strip Discord emoji tags like <:Name:ID> or <a:Name:ID> */
65
+ function stripEmojis(str) {
66
+ return str.replace(/<a?:[a-zA-Z0-9_]+:\d+>/g, '').trim();
67
+ }
68
+
69
+ /**
70
+ * Parse inventory items from a message.
71
+ * CV2 format: **<:EmojiName:ID> Item Name** ─ qty
72
+ * Legacy format: <emoji> **Item Name** ⎯ qty
73
+ */
74
+ function parseInventoryPage(msg) {
75
+ const items = [];
76
+ const text = getFullText(msg);
77
+ if (!text) return items;
78
+
79
+ const lines = text.split('\n');
80
+ for (const line of lines) {
81
+ let m;
82
+
83
+ // Primary: bold name with dash separator — handles emoji inside bold
84
+ // e.g. **<:AdventureTicket:123> Adventure Ticket** ─ 10
85
+ m = line.match(/\*{2}(.+?)\*{2}\s*[⎯—─\-]+\s*`?(\d[\d,]*)`?/);
86
+ if (m) {
87
+ const name = stripEmojis(m[1]).trim();
88
+ const qty = parseInt(m[2].replace(/,/g, ''), 10);
89
+ if (name && qty > 0 && name.length > 1 && name.length < 60) {
90
+ items.push({ name, qty });
91
+ continue;
92
+ }
93
+ }
94
+
95
+ // Fallback: non-bold with dash separator
96
+ m = line.match(/([A-Za-z][A-Za-z0-9 ''.\-]+?)\s*[⎯—─\-]+\s*`?(\d[\d,]*)`?/);
97
+ if (m) {
98
+ const name = stripEmojis(m[1]).replace(/^\*+|\*+$/g, '').trim();
99
+ const qty = parseInt(m[2].replace(/,/g, ''), 10);
100
+ if (name && qty > 0 && name.length > 2 && name.length < 60 && !/^(page|showing)/i.test(name)) {
101
+ items.push({ name, qty });
102
+ continue;
103
+ }
104
+ }
105
+
106
+ // x-quantity format: Item Name x3
107
+ m = line.match(/\*{0,2}([A-Za-z][A-Za-z0-9 ''.\-]+?)\*{0,2}\s+x\s*(\d[\d,]*)/);
108
+ if (m) {
109
+ const name = stripEmojis(m[1]).trim();
110
+ const qty = parseInt(m[2].replace(/,/g, ''), 10);
111
+ if (name && qty > 0 && name.length > 1 && name.length < 60) {
112
+ items.push({ name, qty });
113
+ }
114
+ }
115
+ }
116
+
117
+ return items;
118
+ }
119
+
120
+ /**
121
+ * Get current page / total pages from footer or text.
122
+ */
123
+ function parsePageInfo(msg) {
124
+ // Check embed footer first
125
+ for (const embed of msg.embeds || []) {
126
+ if (embed.footer?.text) {
127
+ const fm = embed.footer.text.match(/page\s*(\d+)\s*(?:\/|of)\s*(\d+)/i);
128
+ if (fm) return { page: parseInt(fm[1]), total: parseInt(fm[2]) };
129
+ }
130
+ }
131
+ const text = getFullText(msg);
132
+ const m = text.match(/page\s*(\d+)\s*(?:\/|of)\s*(\d+)/i);
133
+ if (m) return { page: parseInt(m[1]), total: parseInt(m[2]) };
134
+ return { page: 1, total: 1 };
135
+ }
136
+
137
+ /**
138
+ * Enrich items array with gwapes values.
139
+ */
140
+ async function enrichItems(items) {
141
+ const values = await fetchItemValues();
142
+ let totalValue = 0;
143
+ let totalMarket = 0;
144
+ for (const item of items) {
145
+ const info = values[item.name.toLowerCase()];
146
+ if (info) {
147
+ item.net_value = info.net_value;
148
+ item.market_value = info.value;
149
+ item.type = info.type;
150
+ item.total_net = info.net_value * item.qty;
151
+ item.total_market = info.value * item.qty;
152
+ totalValue += item.total_net;
153
+ totalMarket += item.total_market;
154
+ } else {
155
+ item.net_value = 0;
156
+ item.market_value = 0;
157
+ item.type = 'Unknown';
158
+ item.total_net = 0;
159
+ item.total_market = 0;
160
+ }
161
+ }
162
+ return { totalValue, totalMarket };
163
+ }
164
+
165
+ /**
166
+ * Check inventory for all pages and return full item list.
167
+ */
168
+ async function runInventory({ channel, waitForDankMemer, client, accountId, redis }) {
169
+ LOG.cmd(`${c.white}${c.bold}pls inv${c.reset}`);
170
+
171
+ await channel.send('pls inv');
172
+ let response = await waitForDankMemer(10000);
173
+
174
+ if (!response) {
175
+ LOG.warn('[inv] No response');
176
+ return { result: 'no response', items: [] };
177
+ }
178
+
179
+ if (isHoldTight(response)) {
180
+ const reason = getHoldTightReason(response);
181
+ LOG.warn(`[inv] Hold Tight${reason ? ` (reason: ${reason})` : ''}`);
182
+ await sleep(30000);
183
+ return { result: `hold tight (${reason || 'unknown'})`, items: [], holdTightReason: reason };
184
+ }
185
+
186
+ if (isCV2(response)) await ensureCV2(response);
187
+ logMsg(response, 'inv');
188
+
189
+ const allItems = [];
190
+ let { page, total } = parsePageInfo(response);
191
+ LOG.info(`[inv] Page ${page}/${total}`);
192
+
193
+ allItems.push(...parseInventoryPage(response));
194
+
195
+ while (page < total) {
196
+ const buttons = getAllButtons(response);
197
+ const nextBtn = buttons.find(b => {
198
+ if (b.disabled) return false;
199
+ const id = (b.customId || '').toLowerCase();
200
+ const label = (b.label || '').toLowerCase();
201
+ const emoji = (b.emoji?.name || '').toLowerCase();
202
+ return id.includes('next') || id.includes('right') || label.includes('next')
203
+ || label === '▶' || label === '→' || emoji.includes('right') || emoji === '▶';
204
+ });
205
+
206
+ if (!nextBtn) {
207
+ LOG.debug(`[inv] No next button on page ${page}`);
208
+ break;
209
+ }
210
+
211
+ await humanDelay(300, 600);
212
+ try {
213
+ const result = await safeClickButton(response, nextBtn);
214
+ if (result) {
215
+ response = result;
216
+ } else {
217
+ // CV2 button click returns null — wait for Discord to update the message
218
+ await sleep(2000);
219
+ }
220
+ } catch (e) {
221
+ LOG.error(`[inv] Next page click failed: ${e.message}`);
222
+ break;
223
+ }
224
+
225
+ // Clear cached CV2 data so ensureCV2 re-fetches the updated message
226
+ delete response._cv2;
227
+ delete response._cv2text;
228
+ delete response._cv2buttons;
229
+ await sleep(500);
230
+ if (isCV2(response)) await ensureCV2(response);
231
+ const pageInfo = parsePageInfo(response);
232
+ if (pageInfo.page <= page) break;
233
+ page = pageInfo.page;
234
+ total = pageInfo.total;
235
+
236
+ LOG.info(`[inv] Page ${page}/${total}`);
237
+ allItems.push(...parseInventoryPage(response));
238
+ }
239
+
240
+ // Deduplicate
241
+ const itemMap = {};
242
+ for (const item of allItems) {
243
+ const key = item.name.toLowerCase();
244
+ if (itemMap[key]) {
245
+ itemMap[key].qty = Math.max(itemMap[key].qty, item.qty);
246
+ } else {
247
+ itemMap[key] = { ...item };
248
+ }
249
+ }
250
+
251
+ const items = Object.values(itemMap);
252
+ LOG.success(`[inv] Found ${items.length} unique items across ${total} pages`);
253
+
254
+ const { totalValue, totalMarket } = await enrichItems(items);
255
+ LOG.info(`[inv] Net value: ${c.bold}${c.green}⏣ ${totalValue.toLocaleString()}${c.reset} Market: ${c.bold}⏣ ${totalMarket.toLocaleString()}${c.reset}`);
256
+
257
+ // Store in Redis — NO EXPIRY (permanent until next update)
258
+ if (redis && accountId) {
259
+ try {
260
+ const payload = {
261
+ items,
262
+ totalValue,
263
+ totalMarket,
264
+ itemCount: items.length,
265
+ updatedAt: Date.now(),
266
+ };
267
+ await redis.set(`dkg:inv:${accountId}`, JSON.stringify(payload));
268
+ LOG.success(`[inv] Stored in Redis (${items.length} items)`);
269
+ } catch (e) {
270
+ LOG.error(`[inv] Redis store failed: ${e.message}`);
271
+ }
272
+ }
273
+
274
+ return {
275
+ result: `inv: ${items.length} items, ⏣ ${totalValue.toLocaleString()} net`,
276
+ items,
277
+ totalValue,
278
+ totalMarket,
279
+ coins: 0,
280
+ };
281
+ }
282
+
283
+ // ── Incremental inventory updates ───────────────────────────
284
+
285
+ async function updateInventoryItem(redis, accountId, itemName, newQty) {
286
+ if (!redis || !accountId) return null;
287
+ try {
288
+ const raw = await redis.get(`dkg:inv:${accountId}`);
289
+ if (!raw) return null;
290
+ const inv = JSON.parse(raw);
291
+ const key = itemName.toLowerCase();
292
+ const idx = inv.items.findIndex(i => i.name.toLowerCase() === key);
293
+
294
+ if (newQty <= 0) {
295
+ // Delete item
296
+ if (idx >= 0) inv.items.splice(idx, 1);
297
+ } else if (idx >= 0) {
298
+ inv.items[idx].qty = newQty;
299
+ if (inv.items[idx].net_value) {
300
+ inv.items[idx].total_net = inv.items[idx].net_value * newQty;
301
+ inv.items[idx].total_market = (inv.items[idx].market_value || 0) * newQty;
302
+ }
303
+ } else {
304
+ inv.items.push({ name: itemName, qty: newQty, net_value: 0, market_value: 0, type: 'Unknown', total_net: 0, total_market: 0 });
305
+ }
306
+
307
+ // Recalculate totals
308
+ inv.totalValue = inv.items.reduce((s, i) => s + (i.total_net || 0), 0);
309
+ inv.totalMarket = inv.items.reduce((s, i) => s + (i.total_market || 0), 0);
310
+ inv.itemCount = inv.items.length;
311
+ inv.updatedAt = Date.now();
312
+
313
+ await redis.set(`dkg:inv:${accountId}`, JSON.stringify(inv));
314
+ return inv;
315
+ } catch { return null; }
316
+ }
317
+
318
+ async function deleteInventoryItem(redis, accountId, itemName) {
319
+ return updateInventoryItem(redis, accountId, itemName, 0);
320
+ }
321
+
322
+ async function getCachedInventory(redis, accountId) {
323
+ if (!redis || !accountId) return null;
324
+ try {
325
+ const data = await redis.get(`dkg:inv:${accountId}`);
326
+ return data ? JSON.parse(data) : null;
327
+ } catch { return null; }
328
+ }
329
+
330
+ async function getAllInventories(redis, accountIds) {
331
+ if (!redis) return {};
332
+ const result = {};
333
+ for (const id of accountIds) {
334
+ const inv = await getCachedInventory(redis, id);
335
+ if (inv) result[id] = inv;
336
+ }
337
+ return result;
338
+ }
339
+
340
+ module.exports = {
341
+ runInventory, fetchItemValues, enrichItems,
342
+ getCachedInventory, getAllInventories,
343
+ updateInventoryItem, deleteInventoryItem,
344
+ };