diff --git a/waifu-deal-sniper/.env.example b/waifu-deal-sniper/.env.example new file mode 100644 index 0000000..2df6424 --- /dev/null +++ b/waifu-deal-sniper/.env.example @@ -0,0 +1,7 @@ +# Discord Bot Token +# Get from: https://discord.com/developers/applications +DISCORD_TOKEN=your_discord_bot_token_here + +# TinyFish Mino API Key +# Get from: https://mino.ai +MINO_API_KEY=your_mino_api_key_here diff --git a/waifu-deal-sniper/.gitignore b/waifu-deal-sniper/.gitignore new file mode 100644 index 0000000..062a2f7 --- /dev/null +++ b/waifu-deal-sniper/.gitignore @@ -0,0 +1,27 @@ +# Dependencies +node_modules/ + +# Environment variables +.env +.env.local +.env.production + +# Database +*.db +*.sqlite + +# OS files +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ + +# Logs +*.log +npm-debug.log* + +# Build +dist/ +build/ diff --git a/waifu-deal-sniper/README.md b/waifu-deal-sniper/README.md new file mode 100644 index 0000000..a4bf8e7 --- /dev/null +++ b/waifu-deal-sniper/README.md @@ -0,0 +1,232 @@ +# ๐ŸŽŽ Waifu Deal Sniper + +**Live Demo:** [https://discord.com/oauth2/authorize?client_id=1465346765611077871&permissions=277025508352&scope=bot] + +A Discord bot that helps anime figure collectors find discounted pre-owned figures by scraping deals in real-time from multiple sites using the TinyFish Mino API. + +--- + +## ๐ŸŽฏ What It Does + +Waifu Deal Sniper lets users search for anime figures across **AmiAmi**, **Mercari US**, and **Solaris Japan** directly from Discord. The bot uses TinyFish's Mino API to scrape real-time pricing, condition grades, and availability โ€” then presents results with a fun, personality-driven interface including gacha mode, roast mode, and copium dispensary. + +**Where TinyFish API is used:** The Mino API powers all figure searches by scraping e-commerce sites with natural language goals, extracting structured data (prices, conditions, images, stock status) from pages that don't have public APIs. + +--- + +## ๐ŸŽฌ Demo + +https://github.com/user-attachments/assets/demo.mp4 + +**Commands examples:** +- `rem bunny` - Search AmiAmi for Rem bunny figures +- `mercari miku` - Search Mercari US for Miku figures +- `all makima` - Search all 3 sites simultaneously +- `gacha rem` - Random figure gacha with rarity scoring +- `roast` - Get roasted for your figure taste + +--- + +## ๐Ÿ“ฆ TinyFish API Integration + +```javascript +const MINO_ENDPOINT = 'https://mino.ai/v1/automation/run-sse'; + +async function searchSite(siteKey, query, maxPrice = null) { + const site = SITES[siteKey]; + const searchUrl = site.searchUrl(query); + + // Natural language goal for Mino + const goal = `Scrape pre-owned figure listings from this page. + For each product (max 8), extract: + - raw_title: Full product title + - price: Price (number only) + - url: Product link + - image: Image URL + - in_stock: true/false + - condition: Item condition + - manufacturer: Company name + Return JSON array.`; + + const response = await fetch(MINO_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': process.env.MINO_API_KEY, + }, + body: JSON.stringify({ url: searchUrl, goal }), + }); + + // Parse SSE response + const text = await response.text(); + const lines = text.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + const event = JSON.parse(line.slice(6)); + if (event.type === 'COMPLETE') { + return event.items || event.result; + } + } + } +} +``` + +--- + +## ๐Ÿš€ How to Run + +### Prerequisites +- Node.js 18+ +- Discord Bot Token +- TinyFish Mino API Key + +### 1. Clone the repository +```bash +git clone https://github.com/YOUR_USERNAME/TinyFish-cookbook.git +cd TinyFish-cookbook/waifu-deal-sniper +``` + +### 2. Install dependencies +```bash +npm install +``` + +### 3. Set environment variables +```bash +export DISCORD_TOKEN=your_discord_bot_token +export MINO_API_KEY=your_tinyfish_mino_api_key +``` + +Or create a `.env` file: +```env +DISCORD_TOKEN=your_discord_bot_token +MINO_API_KEY=your_tinyfish_mino_api_key +``` + +### 4. Run the bot +```bash +node bot.js +``` + +### 5. Invite the bot to your server +``` +https://discord.com/oauth2/authorize?client_id=YOUR_CLIENT_ID&permissions=277025508352&scope=bot +``` + +--- + +## ๐Ÿ—๏ธ Architecture Diagram + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ DISCORD USER โ”‚ +โ”‚ โ”‚ +โ”‚ "mercari rem bunny" โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ DISCORD BOT (Node.js) โ”‚ +โ”‚ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Message โ”‚โ”€โ”€โ”€โ–ถโ”‚ Intent โ”‚โ”€โ”€โ”€โ–ถโ”‚ Site Router โ”‚ โ”‚ +โ”‚ โ”‚ Parser โ”‚ โ”‚ Router โ”‚ โ”‚ (amiami/mercari/all) โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ SQLite โ”‚โ—€โ”€โ”€โ–ถโ”‚ Rate โ”‚โ—€โ”€โ”€โ–ถโ”‚ Search Handler โ”‚ โ”‚ +โ”‚ โ”‚ Database โ”‚ โ”‚ Limiter โ”‚ โ”‚ + Rarity Scoring โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ TINYFISH MINO API โ”‚ +โ”‚ โ”‚ +โ”‚ POST /v1/automation/run-sse โ”‚ +โ”‚ { url: "https://mercari.com/search?keyword=rem", goal: "..." } โ”‚ +โ”‚ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Headless Browser โ†’ Navigate โ†’ Extract โ†’ Return Structured JSON โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ–ผ โ–ผ โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ ๐Ÿ‡ฏ๐Ÿ‡ต โ”‚ โ”‚ ๐Ÿ‡บ๐Ÿ‡ธ โ”‚ โ”‚ โ˜€๏ธ โ”‚ + โ”‚ AmiAmi โ”‚ โ”‚ Mercari โ”‚ โ”‚ Solaris โ”‚ + โ”‚ (JPY) โ”‚ โ”‚ (USD) โ”‚ โ”‚ (USD) โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +--- + +## ๐Ÿ“‹ Features + +| Feature | Description | +|---------|-------------| +| **Multi-Site Search** | AmiAmi, Mercari US, Solaris Japan | +| **Real-Time Scraping** | Live prices via Mino API | +| **Rarity Scoring** | SSR/SR/R/N based on scale, manufacturer, exclusivity | +| **Gacha Mode** | Random figure picks with dramatic reveals | +| **Roast Mode** | Get roasted for your waifu choices | +| **Copium Mode** | Consolation when figures are sold out | +| **Watchlist** | DM alerts when deals appear | +| **Rate Limiting** | Prevents API abuse | + +--- + +## ๐Ÿ“ Project Structure + +``` +waifu-deal-sniper/ +โ”œโ”€โ”€ bot.js # Main bot logic (1,543 lines) +โ”œโ”€โ”€ database.js # SQLite database layer +โ”œโ”€โ”€ templates.js # 670+ personality responses +โ”œโ”€โ”€ package.json # Dependencies +โ””โ”€โ”€ README.md # This file +``` + +--- + +## ๐Ÿ”ง Environment Variables + +| Variable | Description | Required | +|----------|-------------|----------| +| `DISCORD_TOKEN` | Discord bot token | โœ… | +| `MINO_API_KEY` | TinyFish Mino API key | โœ… | + +--- + +## ๐Ÿ“œ Commands + +| Command | Description | +|---------|-------------| +| `rem` | Search AmiAmi (default) | +| `mercari rem` | Search Mercari US | +| `solaris rem` | Search Solaris Japan | +| `all rem` | Search all sites | +| `gacha rem` | Random gacha pick | +| `roll` | Reroll gacha | +| `roast` | Get roasted | +| `copium` | Dispense cope | +| `watch rem under 15000` | Set price alert | +| `watchlist` | View alerts | +| `stats` | Your stats | +| `help` | Help message | + +--- + +## ๐Ÿ™ Credits + +Built with [TinyFish Mino API](https://tinyfish.io) for web scraping. + +--- + +## ๐Ÿ“„ License + +MIT diff --git a/waifu-deal-sniper/attachments/demo.mp4 b/waifu-deal-sniper/attachments/demo.mp4 new file mode 100644 index 0000000..927d506 Binary files /dev/null and b/waifu-deal-sniper/attachments/demo.mp4 differ diff --git a/waifu-deal-sniper/bot.js b/waifu-deal-sniper/bot.js new file mode 100644 index 0000000..d40c426 --- /dev/null +++ b/waifu-deal-sniper/bot.js @@ -0,0 +1,1611 @@ +// ===================================== +// ๐ŸŽŽ WAIFU DEAL SNIPER - PRODUCTION BOT +// ===================================== +// "Protect the waifu. Save the laifu. Snipe the deal." +// +// A hosted Discord bot for anime figure collectors +// Users just DM the bot - no setup required! + +require('dotenv').config(); + +const { Client, GatewayIntentBits, EmbedBuilder, ActivityType, Partials } = require('discord.js'); +const { TEMPLATES, SPICY_KEYWORDS, HUSBANDO_KEYWORDS, FIGURE_TYPE_KEYWORDS, GACHA_TEMPLATES, ROAST_TEMPLATES, COPIUM_TEMPLATES } = require('./templates'); +const db = require('./database'); + +// Store last search results per user for gacha/roast +const lastSearchResults = new Map(); + +// Cleanup old search results every 15 minutes to prevent memory leak +setInterval(() => { + const now = Date.now(); + const maxAge = 15 * 60 * 1000; // 15 minutes + for (const [userId, data] of lastSearchResults.entries()) { + if (now - data.timestamp > maxAge) { + lastSearchResults.delete(userId); + } + } +}, 15 * 60 * 1000); + +// ===================================== +// โš™๏ธ CONFIG +// ===================================== +const CONFIG = { + DISCORD_TOKEN: process.env.DISCORD_TOKEN, + MINO_API_KEY: process.env.MINO_API_KEY, + MINO_ENDPOINT: 'https://mino.ai/v1/automation/run-sse', + WATCH_INTERVAL: 5 * 60 * 1000, // 5 minutes + RATE_LIMIT_WINDOW: 60000, // 1 minute + RATE_LIMIT_MAX: 10, // 10 searches per minute + MAX_WATCHES_PER_USER: 20, +}; + +// ===================================== +// ๐ŸŽฒ HELPERS +// ===================================== +function pick(arr) { + if (!arr || arr.length === 0) return ''; + return arr[Math.floor(Math.random() * arr.length)]; +} + +function fill(template, vars) { + if (!template) return ''; + let result = template; + for (const [key, val] of Object.entries(vars)) { + const safeVal = sanitizeForDisplay(String(val)); + result = result.replace(new RegExp(`\\{${key}\\}`, 'g'), safeVal); + } + return result; +} + +// ===================================== +// ๐Ÿ”’ SECURITY HELPERS +// ===================================== + +// Sanitize for Discord display (prevent markdown injection) +function sanitizeForDisplay(str) { + if (!str) return ''; + return str + .replace(/`/g, '\\`') + .replace(/@/g, '๏ผ ') // Full-width @ to prevent mentions + .replace(/#/g, '๏ผƒ') // Full-width # to prevent channel mentions + .slice(0, 200); +} + +// Validate search query +function sanitizeQuery(query) { + if (!query || typeof query !== 'string') return null; + let clean = query.trim().replace(/\s+/g, ' '); + if (clean.length > 100) clean = clean.slice(0, 100); + if (clean.length < 2) return null; + return clean; +} + +// Validate price +function sanitizePrice(price) { + if (price === null || price === undefined) return null; + const num = parseInt(price, 10); + if (isNaN(num) || num < 0) return null; + if (num > 10000000) return 10000000; + return num; +} + +// Rate limiting +const rateLimits = new Map(); + +function checkRateLimit(userId) { + const now = Date.now(); + const userLimits = rateLimits.get(userId) || { count: 0, resetAt: now + CONFIG.RATE_LIMIT_WINDOW }; + + if (now > userLimits.resetAt) { + userLimits.count = 0; + userLimits.resetAt = now + CONFIG.RATE_LIMIT_WINDOW; + } + + userLimits.count++; + rateLimits.set(userId, userLimits); + + return userLimits.count <= CONFIG.RATE_LIMIT_MAX; +} + +// Cleanup old rate limits every 10 minutes to prevent memory leak +setInterval(() => { + const now = Date.now(); + for (const [userId, limits] of rateLimits.entries()) { + if (now > limits.resetAt + 60000) { + rateLimits.delete(userId); + } + } +}, 10 * 60 * 1000); + +// ===================================== +// ๐ŸŽญ PERSONALITY DETECTION +// ===================================== +function isSpicy(query) { + const q = query.toLowerCase(); + return SPICY_KEYWORDS.some(kw => q.includes(kw)); +} + +function isHusbando(query) { + const q = query.toLowerCase(); + return HUSBANDO_KEYWORDS.some(kw => q.includes(kw)); +} + +function getFigureType(query) { + const q = query.toLowerCase(); + for (const [type, keywords] of Object.entries(FIGURE_TYPE_KEYWORDS)) { + if (keywords.some(kw => q.includes(kw))) return type; + } + return null; +} + +function getCharacterReaction(query) { + const q = query.toLowerCase(); + for (const [char, reactions] of Object.entries(TEMPLATES.characters)) { + if (q.includes(char)) return pick(reactions); + } + return null; +} + +function getPriceReaction(price) { + if (price < 3000) return pick(TEMPLATES.prices.budget); + if (price < 10000) return pick(TEMPLATES.prices.mid); + if (price < 25000) return pick(TEMPLATES.prices.expensive); + return pick(TEMPLATES.prices.whale); +} + +function getConditionComment(itemGrade, boxGrade) { + const item = (itemGrade || '').toUpperCase(); + const box = (boxGrade || '').toUpperCase(); + + if ((item === 'A' || item === 'A-') && (box === 'B' || box === 'B-' || box === 'C')) { + return pick(TEMPLATES.condition.mint_box_damaged); + } + if (item === 'A' && box === 'A') { + return pick(TEMPLATES.condition.mint_mint); + } + if (item === 'A-' || item === 'B+') { + return pick(TEMPLATES.condition.good); + } + return pick(TEMPLATES.condition.used); +} + +function isDeal(item) { + const itemGrade = (item.item_grade || '').toUpperCase(); + const boxGrade = (item.box_grade || '').toUpperCase(); + return (itemGrade === 'A' || itemGrade === 'A-') && + (boxGrade === 'B' || boxGrade === 'B-' || boxGrade === 'C'); +} + +// ===================================== +// ๐Ÿ” SMART PARSER - Find items in any response format +// ===================================== +function findItemsArray(obj) { + if (!obj) return null; + + // If it's a string, try to parse as JSON + if (typeof obj === 'string') { + try { + obj = JSON.parse(obj.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim()); + } catch (e) { + return null; + } + } + + // If it's already an array of objects with url/price, return it + if (Array.isArray(obj) && obj.length > 0 && typeof obj[0] === 'object' && (obj[0].url || obj[0].price)) { + return obj; + } + + // Search through all properties for an array of items + if (typeof obj === 'object') { + for (const key of Object.keys(obj)) { + const value = obj[key]; + + // Check if this property is an array of objects with url or price + if (Array.isArray(value) && value.length > 0 && typeof value[0] === 'object') { + if (value[0].url || value[0].price || value[0].name || value[0].raw_title) { + console.log(`Found items in field: "${key}" (${value.length} items)`); + return value; + } + } + + // Recursively check nested objects (but not arrays) + if (typeof value === 'object' && !Array.isArray(value)) { + const nested = findItemsArray(value); + if (nested) return nested; + } + } + } + + return null; +} + +// ===================================== +// ๐Ÿ” MINO API - Multi-Site Search +// ===================================== + +// Site configurations +const SITES = { + amiami: { + name: 'AmiAmi', + emoji: '๐Ÿ‡ฏ๐Ÿ‡ต', + currency: 'JPY', + searchUrl: (query) => `https://www.amiami.com/eng/search/list/?s_keywords=${encodeURIComponent(query)}&s_st_condition_flg=1`, + goal: `Scrape pre-owned figure listings from this AmiAmi page. + +The title contains condition grades like "(Pre-owned ITEM:A/BOX:B)". + +For each product (max 8), extract: +- raw_title: FULL title text including "(Pre-owned ITEM:X/BOX:Y)" +- price: Price in JPY (number only) +- url: Product link +- image: Image URL +- in_stock: true/false +- scale: Figure scale if shown (e.g., "1/4", "1/7", "1/8") or null +- manufacturer: Company name (e.g., "Good Smile Company", "FREEing", "Alter", "Kotobukiya", "SEGA", "Banpresto") +- line: Product line if shown (e.g., "B-Style", "POP UP PARADE", "Nendoroid", "figma", "Prize Figure") +- exclusive: true if exclusive (contains "Exclusive", "Limited", "Event"), false otherwise + +Return JSON array.`, + }, + + mercari: { + name: 'Mercari US', + emoji: '๐Ÿ‡บ๐Ÿ‡ธ', + currency: 'USD', + searchUrl: (query) => `https://www.mercari.com/search/?keyword=${encodeURIComponent(query + ' figure')}&status=sold_out%3Afalse`, + goal: `Scrape figure listings from this Mercari search page. + +For each product (max 8), extract: +- raw_title: Full product title +- price: Price in USD (number only, no $) +- url: Product link +- image: Image URL +- in_stock: true if available, false if sold +- condition: Item condition (e.g., "New", "Like new", "Good") +- seller: Seller name if visible + +Return JSON array.`, + }, + + solaris: { + name: 'Solaris Japan', + emoji: 'โ˜€๏ธ', + currency: 'USD', + searchUrl: (query) => `https://solarisjapan.com/search?q=${encodeURIComponent(query)}&filter.category=Figures`, + goal: `Scrape figure listings from this Solaris Japan search page. + +For each product (max 8), extract: +- raw_title: Full product name +- price: Price in USD (number only, no $) +- url: Product link +- image: Image URL +- in_stock: true if "Add to Cart" visible, false if "Sold Out" or "Notify Me" +- condition: Condition text (e.g., "BRAND NEW", "PRE ORDER", "Pre-owned") +- manufacturer: Company name if visible in title (e.g., "Good Smile Company", "Taito") + +Return JSON array.`, + }, +}; + +// Rarity scoring based on actual figure attributes +function calculateRarity(item) { + let score = 0; + const name = (item.raw_title || item.name || '').toLowerCase(); + const manufacturer = (item.manufacturer || '').toLowerCase(); + const line = (item.line || '').toLowerCase(); + const scale = item.scale || ''; + const price = parseInt(item.price) || 0; + + // === SCALE SCORING === + if (scale.includes('1/4')) score += 30; + else if (scale.includes('1/6')) score += 20; + else if (scale.includes('1/7')) score += 15; + else if (scale.includes('1/8')) score += 10; + else if (name.includes('1/4')) score += 30; + else if (name.includes('1/6')) score += 20; + else if (name.includes('1/7')) score += 15; + else if (name.includes('1/8')) score += 10; + + // === MANUFACTURER SCORING === + const premiumMakers = ['alter', 'freeing', 'native', 'orchid seed', 'vertex', 'b\'full', 'binding']; + const goodMakers = ['good smile', 'kotobukiya', 'max factory', 'megahouse', 'phat', 'aquamarine', 'ques q', 'wing']; + const budgetMakers = ['sega', 'banpresto', 'taito', 'furyu', 'bandai spirits', 'prize']; + + if (premiumMakers.some(m => manufacturer.includes(m) || name.includes(m))) score += 25; + else if (goodMakers.some(m => manufacturer.includes(m) || name.includes(m))) score += 15; + else if (budgetMakers.some(m => manufacturer.includes(m) || name.includes(m))) score -= 10; + + // === LINE SCORING === + if (line.includes('b-style') || name.includes('b-style')) score += 25; + if (line.includes('native') || name.includes('native')) score += 20; + if (name.includes('bunny') && (name.includes('1/4') || price > 20000)) score += 20; + if (line.includes('pop up parade') || name.includes('pop up parade')) score -= 5; + if (name.includes('prize') || name.includes('game-prize') || name.includes('ichiban kuji')) score -= 15; + if (line.includes('nendoroid') || name.includes('nendoroid')) score += 5; + if (line.includes('figma') || name.includes('figma')) score += 10; + + // === EXCLUSIVE SCORING === + if (item.exclusive || name.includes('exclusive') || name.includes('limited')) score += 15; + if (name.includes('event') || name.includes('wf ') || name.includes('wonder festival')) score += 20; + + // === CONDITION SCORING === + const itemGrade = (item.item_grade || '').toUpperCase(); + const boxGrade = (item.box_grade || '').toUpperCase(); + if (itemGrade === 'A' && boxGrade === 'A') score += 10; + if (itemGrade === 'A' && (boxGrade === 'B' || boxGrade === 'C')) score += 5; // Deal! + + // === PRICE SANITY CHECK === + if (price > 30000) score += 10; + else if (price > 20000) score += 5; + else if (price < 2000) score -= 10; + + // Determine rarity tier + if (score >= 50) return { tier: 'ssr', score, label: '๐ŸŒˆ SSR - LEGENDARY' }; + if (score >= 30) return { tier: 'sr', score, label: 'โญ SR - RARE' }; + if (score >= 10) return { tier: 'r', score, label: '๐Ÿ“ฆ R - COMMON' }; + return { tier: 'salt', score, label: '๐Ÿง‚ N - BUDGET' }; +} + +// Get rarity details for display +function getRarityDetails(item) { + const details = []; + const name = (item.raw_title || item.name || '').toLowerCase(); + + // Scale + const scaleMatch = name.match(/1\/[4-8]/); + if (scaleMatch) details.push(`๐Ÿ“ ${scaleMatch[0]} Scale`); + + // Manufacturer + if (item.manufacturer) details.push(`๐Ÿญ ${item.manufacturer}`); + + // Line + if (item.line) details.push(`๐Ÿ“ฆ ${item.line}`); + + // Special tags + if (name.includes('exclusive') || name.includes('limited') || item.exclusive) details.push(`โœจ Limited/Exclusive`); + if (name.includes('b-style') || name.includes('bunny')) details.push(`๐Ÿฐ Bunny`); + if (name.includes('native')) details.push(`๐Ÿ”ž Native`); + if (name.includes('prize') || name.includes('game-prize')) details.push(`๐ŸŽฎ Prize Figure`); + + return details; +} + +async function searchSite(siteKey, query, maxPrice = null) { + const site = SITES[siteKey]; + if (!site) return { success: false, error: 'Unknown site' }; + + const searchUrl = site.searchUrl(query); + const goal = site.goal + (maxPrice ? `\n\nOnly items under ${maxPrice} JPY.` : ''); + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 90000); + + const response = await fetch(CONFIG.MINO_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': CONFIG.MINO_API_KEY, + }, + body: JSON.stringify({ url: searchUrl, goal }), + signal: controller.signal, + }); + + clearTimeout(timeout); + + if (!response.ok) { + console.error(`${site.name} API error:`, response.status); + return { success: false, error: `API error: ${response.status}` }; + } + + // Parse SSE response + const text = await response.text(); + const lines = text.split('\n'); + + console.log(`${site.name} response length:`, text.length, 'bytes'); + + let foundItems = null; + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const event = JSON.parse(line.slice(6)); + + if (event.type) { + console.log(`${site.name} event: ${event.type}`); + } + + if (event.type === 'COMPLETE') { + console.log(`${site.name} COMPLETE event received`); + let items = findItemsArray(event); + if (items && items.length > 0) { + foundItems = items; + } + } + + if (event.type === 'ERROR' || event.status === 'FAILED') { + console.error(`${site.name} error event:`, event.error || event.message); + return { success: false, error: event.error || event.message }; + } + } catch (e) { + // Not valid JSON, skip + } + } + } + + if (foundItems && foundItems.length > 0) { + // Post-process items + foundItems = foundItems.map(item => { + // Parse grades from title + const title = item.raw_title || item.full_title || item.name || ''; + const gradeMatch = title.match(/ITEM:\s*([A-C][+-]?)\s*[\/\s]*BOX:\s*([A-C][+-]?)/i); + + if (gradeMatch) { + item.item_grade = gradeMatch[1].toUpperCase(); + item.box_grade = gradeMatch[2].toUpperCase(); + item.name = title.replace(/^\(Pre-owned\s+ITEM:[A-C][+-]?\s*[\/\s]*BOX:[A-C][+-]?\)\s*/i, '').trim() || item.name; + } else { + item.item_grade = item.item_grade || null; + item.box_grade = item.box_grade || null; + item.name = item.name || title; + } + + // Calculate rarity + item.rarity = calculateRarity(item); + item.rarityDetails = getRarityDetails(item); + item.site = siteKey; + item.siteName = site.name; + item.siteEmoji = site.emoji; + + console.log(` โ†’ ${(item.name || 'Unknown').slice(0, 40)}... | ${item.rarity.label}`); + + return item; + }); + + console.log(`โœ… ${site.name} found ${foundItems.length} items`); + return { success: true, items: foundItems, site: siteKey }; + } + + // Fallback + try { + const fullJson = JSON.parse(text); + const items = findItemsArray(fullJson); + if (items && items.length > 0) { + const processedItems = items.map(item => { + item.rarity = calculateRarity(item); + item.rarityDetails = getRarityDetails(item); + item.site = siteKey; + item.siteName = site.name; + item.siteEmoji = site.emoji; + return item; + }); + console.log(`โœ… ${site.name} found ${processedItems.length} items (fallback)`); + return { success: true, items: processedItems, site: siteKey }; + } + } catch (e) { + // Not valid JSON + } + + console.log(`${site.name} response tail:`, text.slice(-500)); + console.error(`โŒ No items found from ${site.name}`); + return { success: false, error: 'No results found' }; + } catch (error) { + console.error(`${site.name} search error:`, error.message); + return { success: false, error: error.message }; + } +} + +// Main search function - defaults to AmiAmi, can specify site +async function searchAmiAmi(query, maxPrice = null, siteKey = 'amiami') { + return searchSite(siteKey, query, maxPrice); +} + +// Search multiple sites at once +async function searchAllSites(query, maxPrice = null) { + const siteKeys = ['amiami', 'mercari', 'solaris']; + + const results = await Promise.allSettled( + siteKeys.map(site => searchSite(site, query, maxPrice)) + ); + + const allItems = []; + const siteResults = {}; + + results.forEach((result, index) => { + const siteKey = siteKeys[index]; + if (result.status === 'fulfilled' && result.value.success) { + siteResults[siteKey] = result.value; + allItems.push(...result.value.items); + } else { + console.log(`${siteKey} failed:`, result.reason?.message || result.value?.error); + } + }); + + // Sort by rarity score + allItems.sort((a, b) => (b.rarity?.score || 0) - (a.rarity?.score || 0)); + + return { + success: allItems.length > 0, + items: allItems, + siteResults, + sitesSearched: siteKeys, + }; +} + +// ===================================== +// ๐ŸŽจ DISCORD EMBEDS +// ===================================== +function createFigureEmbed(item) { + const isGoodDeal = isDeal(item); + const price = parseInt(item.price) || 0; + const rarity = item.rarity?.tier || 'r'; + const rarityLabel = item.rarity?.label || ''; + + // Color based on rarity or deal status + const rarityColors = { + ssr: 0xFFD700, // Gold + sr: 0xA855F7, // Purple + r: 0x3B82F6, // Blue + salt: 0x6B7280, // Gray + }; + const embedColor = isGoodDeal ? 0xFF6B6B : (rarityColors[rarity] || 0x6C5CE7); + + // Title prefix based on rarity + const rarityPrefix = rarity === 'ssr' ? '๐ŸŒˆ ' : rarity === 'sr' ? 'โญ ' : ''; + + const embed = new EmbedBuilder() + .setColor(embedColor) + .setTitle(`${isGoodDeal ? '๐Ÿ”ฅ ' : rarityPrefix}${(item.name || 'Figure').slice(0, 250)}`) + .setURL(item.url || 'https://www.amiami.com'); + + // Only set thumbnail if it's a valid URL + if (item.image && item.image.startsWith('http')) { + embed.setThumbnail(item.image); + } + + let desc = ''; + if (isGoodDeal) { + desc += `**${pick(TEMPLATES.deal_alert)}**\n\n`; + } else if (rarity === 'ssr') { + desc += `**${rarityLabel}**\n\n`; + } + + desc += `๐Ÿ’ด **ยฅ${price.toLocaleString()}**\n`; + desc += `โœจ Figure: **${item.item_grade || '?'}** | ๐Ÿ“ฆ Box: **${item.box_grade || '?'}**\n`; + desc += `${item.in_stock !== false ? 'โœ… In Stock' : 'โŒ Sold Out'}`; + + // Add rarity tags if present + if (item.rarityDetails && item.rarityDetails.length > 0) { + desc += `\n\n๐Ÿท๏ธ ${item.rarityDetails.slice(0, 3).join(' โ€ข ')}`; + } + + desc += `\n\n*${getConditionComment(item.item_grade, item.box_grade)}*`; + + embed.setDescription(desc); + + // Footer with site info if multi-site + const siteInfo = item.siteEmoji ? `${item.siteEmoji} ${item.siteName} โ€ข ` : ''; + embed.setFooter({ text: `${siteInfo}${getPriceReaction(price)} โ€ข Click title to buy!` }); + + return embed; +} + +function createResultsSummaryEmbed(items, query, spicy) { + const deals = items.filter(isDeal); + const templates = spicy ? TEMPLATES.found.spicy : TEMPLATES.found.normal; + + const embed = new EmbedBuilder() + .setColor(spicy ? 0xE91E63 : 0x6C5CE7) + .setTitle(`๐ŸŽฏ Results for "${sanitizeForDisplay(query)}"`) + .setDescription(fill(pick(templates), { count: items.length, query })); + + if (deals.length > 0) { + embed.addFields({ + name: '๐Ÿ”ฅ Deals Found!', + value: `${deals.length} item(s) with mint figure + damaged box discount!` + }); + } + + embed.setFooter({ text: `Say "watch ${query}" to get alerts! ๐Ÿ””` }); + + return embed; +} + +// ===================================== +// ๐Ÿ—ฃ๏ธ NATURAL LANGUAGE PARSER +// ===================================== +function parseMessage(content) { + const lower = content.toLowerCase().trim(); + + // Help + if (/^(help|commands|how|what can you do)/i.test(lower)) { + return { intent: 'help' }; + } + + // Greetings + if (/^(hey|hi|hello|yo|sup|henlo|hii+|hewwo|ohayo)(!|\?)?$/i.test(lower)) { + return { intent: 'greeting' }; + } + + // Watchlist + if (/^(my )?(watchlist|watches|alerts|list|hunting)$/i.test(lower)) { + return { intent: 'watchlist' }; + } + + // Stop watching + const unwatchMatch = lower.match(/^(stop watching|unwatch|remove|cancel|delete)\s+(.+)/i); + if (unwatchMatch) { + return { intent: 'unwatch', query: unwatchMatch[2].trim() }; + } + + // === NEW FEATURES === + + // Gacha mode + const gachaMatch = lower.match(/^(?:gacha|roll|spin|gamble|yolo)\s+(.+)/i); + if (gachaMatch) { + return { intent: 'gacha', query: gachaMatch[1].trim() }; + } + if (/^(?:gacha|roll|spin)$/i.test(lower)) { + return { intent: 'gacha_last' }; + } + + // Roast mode + if (/^(?:roast|roast me|roast this|judge|judge me|flame)$/i.test(lower)) { + return { intent: 'roast' }; + } + const roastMatch = lower.match(/^(?:roast|judge|flame)\s+(.+)/i); + if (roastMatch) { + return { intent: 'roast_query', query: roastMatch[1].trim() }; + } + + // Copium mode + if (/^(?:copium|cope|copium mode|inhale|sad|pain)$/i.test(lower)) { + return { intent: 'copium' }; + } + + // === MULTI-SITE SEARCH === + + // Search all sites + const allSitesMatch = lower.match(/^(?:all|everywhere|all sites)\s+(.+?)(?:\s+under\s+|\s*<\s*)?(\d+)?$/i); + if (allSitesMatch) { + const query = allSitesMatch[1].replace(/\s*(figures?|deals?)\s*/gi, ' ').trim(); + const price = allSitesMatch[2] ? parseInt(allSitesMatch[2]) : null; + if (query.length > 2) { + return { intent: 'search_all', query, maxPrice: price }; + } + } + + // Site-specific search: mercari , solaris , amiami + const siteMatch = lower.match(/^(mercari|solaris|amiami)\s+(.+?)(?:\s+under\s+|\s*<\s*)?(\d+)?$/i); + if (siteMatch) { + const site = siteMatch[1].toLowerCase(); + const query = siteMatch[2].replace(/\s*(figures?|deals?)\s*/gi, ' ').trim(); + const price = siteMatch[3] ? parseInt(siteMatch[3]) : null; + if (query.length > 2) { + return { intent: 'search_site', site, query, maxPrice: price }; + } + } + + // Watch/alert + const watchPatterns = [ + /^(?:watch|alert|notify|ping|dm|tell)\s+(?:me\s+)?(?:for\s+|when\s+|if\s+)?(.+?)(?:\s+under\s+|\s*<\s*|\s+max\s+)?(\d+)?$/i, + /^(.+?)\s+(?:alert|notify|watch)(?:\s+under\s+|\s*<\s*)?(\d+)?$/i, + ]; + for (const pattern of watchPatterns) { + const match = lower.match(pattern); + if (match) { + const query = match[1].replace(/^(for|when|if)\s+/i, '').replace(/\s+(appears?|drops?|available|shows? up).*$/i, '').trim(); + const price = match[2] ? parseInt(match[2]) : null; + if (query.length > 2) { + return { intent: 'watch', query, maxPrice: price || 999999 }; + } + } + } + + // Search patterns - extract query and optional price + // First, detect if price is in USD (need to convert to JPY for AmiAmi) + const usdPattern = /[\$](\d+)|(\d+)\s*[\$]|(\d+)\s*(dollars?|bucks?|usd)/i; + const usdMatch = lower.match(usdPattern); + const isUSD = !!usdMatch; + const USD_TO_JPY = 150; // Approximate conversion rate + + // Clean the input of conversational fluff + let cleanedInput = lower + .replace(/^(yo|hey|hi|hello|sup|bro|dude|man|guys?),?\s*/gi, '') // Remove greetings + .replace(/^bro,?\s*/gi, '') // Remove "bro" again if still there + .replace(/,?\s*(anything\s+)?(under|below|max|less than)\s*[\$ยฅ]?(\d+)[\$ยฅ]?\s*(works|dollars?|bucks?|usd|jpy|yen)?.*$/i, ' under $3') // Normalize price + .trim(); + + const searchPatterns = [ + // "find me some figure of ganyu from genshin impact under 500" + /^(?:looking for|find|search|hunt|show|got any|get me|i want|i need)\s+(?:me\s+)?(?:some\s+)?(?:figure[s]?\s+of\s+)?(.+?)(?:\s+under\s+)?(\d+)?$/i, + // "any ganyu figures under 500" + /^(?:any\s+)?(.+?)\s+(?:figures?|deals?)(?:\s+under\s+)?(\d+)?$/i, + // "ganyu under 500" + /^(.+?)\s+under\s+(\d+)$/i, + ]; + + for (const pattern of searchPatterns) { + const match = cleanedInput.match(pattern); + if (match) { + let query = match[1] + .replace(/\s*(figures?|deals?|please|pls|thx|thanks)\s*/gi, ' ') + .replace(/\s+/g, ' ') + .trim(); + + // Extract "X from Y" โ†’ "X Y" (e.g., "ganyu from genshin" โ†’ "ganyu genshin") + const fromMatch = query.match(/(.+?)\s+from\s+(.+)/i); + if (fromMatch) { + query = fromMatch[1].trim() + ' ' + fromMatch[2].trim(); + } + + let price = match[2] ? parseInt(match[2]) : null; + + // Convert USD to JPY if detected + if (price && isUSD) { + price = Math.round(price * USD_TO_JPY); + } + + if (query.length > 2) { + return { intent: 'search', query, maxPrice: price, isUSD }; + } + } + } + + // Stats + if (/^(stats|statistics|my stats|status)$/i.test(lower)) { + return { intent: 'stats' }; + } + + // Default: treat short text as search + if (lower.length > 3 && lower.length < 50 && !lower.includes('?')) { + return { intent: 'search', query: lower }; + } + + return { intent: 'unknown' }; +} + +// ===================================== +// ๐Ÿค– MESSAGE HANDLERS +// ===================================== +async function handleMessage(message, content) { + const username = message.author.username; + const discordId = message.author.id; + + console.log(` โ†’ handleMessage called with: "${content}"`); + + // Get or create user + const user = db.getOrCreateUser(discordId, username); + console.log(` โ†’ User: ${user ? 'found/created' : 'NULL'}`); + + db.updateUserActivity(discordId); + + const isNew = db.isNewUser(discordId); + const parsed = parseMessage(content); + + console.log(` โ†’ Parsed intent: ${parsed.intent}, query: ${parsed.query || 'none'}`); + + try { + switch (parsed.intent) { + case 'help': + await message.reply(TEMPLATES.help[0]); + break; + + case 'greeting': + if (isNew) { + await message.reply(fill(TEMPLATES.welcome[0], { user: username })); + } else { + await message.reply(fill(pick(TEMPLATES.greetings.returning), { user: username })); + } + break; + + case 'search': + await handleSearch(message, user, parsed.query, parsed.maxPrice, parsed.isUSD); + break; + + case 'watch': + await handleWatch(message, user, parsed.query, parsed.maxPrice); + break; + + case 'watchlist': + await handleWatchlist(message, user); + break; + + case 'unwatch': + await handleUnwatch(message, user, parsed.query); + break; + + case 'stats': + await handleStats(message, user); + break; + + // === NEW FEATURES === + case 'gacha': + await handleGacha(message, user, parsed.query); + break; + + case 'gacha_last': + await handleGachaLast(message, user); + break; + + case 'roast': + await handleRoast(message, user); + break; + + case 'roast_query': + await handleRoastQuery(message, user, parsed.query); + break; + + case 'copium': + await handleCopium(message, user); + break; + + // === MULTI-SITE SEARCH === + case 'search_site': + await handleSearchSite(message, user, parsed.site, parsed.query, parsed.maxPrice); + break; + + case 'search_all': + await handleSearchAll(message, user, parsed.query, parsed.maxPrice); + break; + + default: + if (!message.guild) { // DM + const response = isNew + ? fill(TEMPLATES.welcome[0], { user: username }) + : `๐Ÿค” Not sure what you mean! Try:\nโ€ข \`looking for rem figures\`\nโ€ข \`watch marin under 15000\`\nโ€ข \`help\``; + await message.reply(response); + } + } + } catch (error) { + console.error('Handler error:', error); + await message.reply(pick(TEMPLATES.errors.search_failed)).catch(() => {}); + } +} + +async function handleSearch(message, user, query, maxPrice, isUSD = false) { + // Validate inputs + const cleanQuery = sanitizeQuery(query); + if (!cleanQuery) { + await message.reply("๐Ÿค” That search doesn't look right. Try: `looking for rem figures`"); + return; + } + + const cleanPrice = sanitizePrice(maxPrice); + + // Rate limit check + if (!checkRateLimit(user.discord_id)) { + await message.reply("โณ Slow down! Too many searches. Try again in a minute~"); + return; + } + + const spicy = isSpicy(cleanQuery); + const husbando = isHusbando(cleanQuery); + const figureType = getFigureType(cleanQuery); + const charReaction = getCharacterReaction(cleanQuery); + + // Build response + let searchMsg = ''; + + // Show USD conversion notice + if (isUSD && cleanPrice) { + const originalUSD = Math.round(cleanPrice / 150); + searchMsg += `๐Ÿ’ฑ *$${originalUSD} USD โ†’ ยฅ${cleanPrice.toLocaleString()} JPY*\n\n`; + } + + if (charReaction) { + searchMsg += charReaction + '\n\n'; + } else if (figureType && TEMPLATES.figure_types[figureType]) { + searchMsg += pick(TEMPLATES.figure_types[figureType]) + '\n\n'; + } + + const templates = husbando ? TEMPLATES.searching.husbando : + spicy ? TEMPLATES.searching.spicy : + TEMPLATES.searching.normal; + searchMsg += fill(pick(templates), { query: cleanQuery }); + + const statusMsg = await message.reply(searchMsg); + + // Search! + const result = await searchAmiAmi(cleanQuery, cleanPrice); + db.incrementSearchCount(user.id); + + if (!result.success) { + await statusMsg.edit(searchMsg + '\n\n' + pick(TEMPLATES.errors.search_failed)); + return; + } + + if (!result.items || result.items.length === 0) { + const noResult = fill( + pick(spicy ? TEMPLATES.no_results.spicy : TEMPLATES.no_results.normal), + { query: cleanQuery } + ); + await statusMsg.edit(searchMsg + '\n\n' + noResult); + return; + } + + // Log & count deals + db.logSearch(user.id, cleanQuery, result.items.length); + const deals = result.items.filter(isDeal); + if (deals.length > 0) { + db.incrementDealsFound(user.id, deals.length); + } + + // Send results + const summaryEmbed = createResultsSummaryEmbed(result.items, cleanQuery, spicy); + await statusMsg.edit({ content: searchMsg, embeds: [summaryEmbed] }); + + const toShow = result.items.slice(0, 5); + for (const item of toShow) { + await message.channel.send({ embeds: [createFigureEmbed(item)] }); + } + + if (result.items.length > 5) { + await message.channel.send(`*...and ${result.items.length - 5} more! Say \`watch ${sanitizeForDisplay(cleanQuery)}\` to get alerts~*`); + } +} + +// ===================================== +// ๐ŸŒ MULTI-SITE SEARCH HANDLERS +// ===================================== +async function handleSearchSite(message, user, siteKey, query, maxPrice) { + const site = SITES[siteKey]; + if (!site) { + await message.reply(`๐Ÿค” Unknown site! Try: \`mercari rem\`, \`solaris miku\`, or \`amiami power\``); + return; + } + + const cleanQuery = sanitizeQuery(query); + if (!cleanQuery) { + await message.reply(`๐Ÿค” What should I search on ${site.name}? Try: \`${siteKey} rem figures\``); + return; + } + + const cleanPrice = sanitizePrice(maxPrice); + + // Rate limit + if (!checkRateLimit(user.discord_id)) { + await message.reply("โณ Slow down! Too many searches. Try again in a minute~"); + return; + } + + // Send searching message + const searchMsg = `${site.emoji} Searching **${site.name}** for **${sanitizeForDisplay(cleanQuery)}**...`; + const statusMsg = await message.reply(searchMsg); + + // Search + const result = await searchSite(siteKey, cleanQuery, cleanPrice); + db.incrementSearchCount(user.id); + + if (!result.success) { + await statusMsg.edit(searchMsg + `\n\n๐Ÿ’€ ${site.name} search failed... Try again?`); + return; + } + + if (!result.items || result.items.length === 0) { + await statusMsg.edit(searchMsg + `\n\n๐Ÿ˜ข No results on ${site.name}! Try a different search.`); + return; + } + + // Store for gacha/roast + lastSearchResults.set(user.discord_id, { query: cleanQuery, items: result.items, timestamp: Date.now() }); + + // Log + db.logSearch(user.id, `${siteKey}:${cleanQuery}`, result.items.length); + + // Build summary + const currency = site.currency === 'USD' ? '$' : 'ยฅ'; + const avgPrice = result.items.reduce((sum, i) => sum + (parseInt(i.price) || 0), 0) / result.items.length; + + const summaryEmbed = new EmbedBuilder() + .setColor(siteKey === 'mercari' ? 0xE53935 : siteKey === 'solaris' ? 0xFFA726 : 0x6C5CE7) + .setTitle(`${site.emoji} ${site.name} Results`) + .setDescription(`Found **${result.items.length}** results for **${sanitizeForDisplay(cleanQuery)}**\n\nAverage price: **${currency}${Math.round(avgPrice).toLocaleString()}**`); + + await statusMsg.edit({ content: null, embeds: [summaryEmbed] }); + + // Show items + const toShow = result.items.slice(0, 5); + for (const item of toShow) { + const embed = createSiteEmbed(item, site); + await message.channel.send({ embeds: [embed] }); + } + + if (result.items.length > 5) { + await message.channel.send(`*...and ${result.items.length - 5} more on ${site.name}!*`); + } +} + +async function handleSearchAll(message, user, query, maxPrice) { + const cleanQuery = sanitizeQuery(query); + if (!cleanQuery) { + await message.reply("๐Ÿค” What should I search? Try: `all rem figures`"); + return; + } + + const cleanPrice = sanitizePrice(maxPrice); + + // Rate limit + if (!checkRateLimit(user.discord_id)) { + await message.reply("โณ Slow down! Too many searches. Try again in a minute~"); + return; + } + + // Send searching message + const siteList = Object.values(SITES).map(s => s.emoji).join(' '); + const searchMsg = `๐ŸŒ Searching **ALL SITES** for **${sanitizeForDisplay(cleanQuery)}**...\n${siteList}`; + const statusMsg = await message.reply(searchMsg); + + // Search all sites in parallel + const result = await searchAllSites(cleanQuery, cleanPrice); + db.incrementSearchCount(user.id); + + if (!result.success || !result.items || result.items.length === 0) { + await statusMsg.edit(searchMsg + `\n\n๐Ÿ˜ข No results found on any site!`); + return; + } + + // Store for gacha/roast + lastSearchResults.set(user.discord_id, { query: cleanQuery, items: result.items, timestamp: Date.now() }); + + // Log + db.logSearch(user.id, `all:${cleanQuery}`, result.items.length); + + // Count by site + const siteCounts = {}; + result.items.forEach(item => { + siteCounts[item.site] = (siteCounts[item.site] || 0) + 1; + }); + + // Build summary + let siteBreakdown = Object.entries(siteCounts) + .map(([site, count]) => `${SITES[site]?.emoji || '๐Ÿ“ฆ'} ${SITES[site]?.name || site}: ${count}`) + .join('\n'); + + const summaryEmbed = new EmbedBuilder() + .setColor(0x9B59B6) + .setTitle(`๐ŸŒ Multi-Site Results`) + .setDescription(`Found **${result.items.length}** total results for **${sanitizeForDisplay(cleanQuery)}**\n\n${siteBreakdown}`) + .setFooter({ text: 'Sorted by rarity score' }); + + await statusMsg.edit({ content: null, embeds: [summaryEmbed] }); + + // Show top items (mixed from all sites) + const toShow = result.items.slice(0, 6); + for (const item of toShow) { + const site = SITES[item.site] || { emoji: '๐Ÿ“ฆ', name: 'Unknown', currency: 'JPY' }; + const embed = createSiteEmbed(item, site); + await message.channel.send({ embeds: [embed] }); + } + + if (result.items.length > 6) { + await message.channel.send(`*...and ${result.items.length - 6} more across all sites!*`); + } +} + +// Create embed for site-specific results +function createSiteEmbed(item, site) { + const price = parseInt(item.price) || 0; + const currency = site.currency === 'USD' ? '$' : 'ยฅ'; + const rarity = item.rarity?.tier || 'r'; + + const rarityColors = { + ssr: 0xFFD700, + sr: 0xA855F7, + r: 0x3B82F6, + salt: 0x6B7280, + }; + + const embed = new EmbedBuilder() + .setColor(rarityColors[rarity] || 0x6C5CE7) + .setTitle(`${(item.name || item.raw_title || 'Figure').slice(0, 250)}`) + .setURL(item.url || site.searchUrl('')); + + // Only set thumbnail if it's a valid URL + if (item.image && item.image.startsWith('http')) { + embed.setThumbnail(item.image); + } + + let desc = `${site.emoji} **${site.name}**\n\n`; + desc += `๐Ÿ’ฐ **${currency}${price.toLocaleString()}**\n`; + + // Condition (varies by site) + if (item.item_grade && item.box_grade) { + desc += `โœจ Figure: **${item.item_grade}** | ๐Ÿ“ฆ Box: **${item.box_grade}**\n`; + } else if (item.condition) { + desc += `โœจ Condition: **${item.condition}**\n`; + } + + // Seller (Mercari) + if (item.seller) { + desc += `๐Ÿ‘ค Seller: ${item.seller}\n`; + } + + // Manufacturer + if (item.manufacturer) { + desc += `๐Ÿญ ${item.manufacturer}\n`; + } + + desc += `${item.in_stock !== false ? 'โœ… Available' : 'โŒ Sold Out'}`; + + embed.setDescription(desc); + embed.setFooter({ text: `${site.emoji} ${site.name} โ€ข Click title to buy!` }); + + return embed; +} + +async function handleWatch(message, user, query, maxPrice) { + const cleanQuery = sanitizeQuery(query); + if (!cleanQuery) { + await message.reply("๐Ÿค” That doesn't look right. Try: `watch rem under 10000`"); + return; + } + + const cleanPrice = sanitizePrice(maxPrice) || 999999; + + // Check limit + const currentWatches = db.getUserWatchlist(user.id); + if (currentWatches.length >= CONFIG.MAX_WATCHES_PER_USER) { + await message.reply(`๐Ÿ˜… You have ${CONFIG.MAX_WATCHES_PER_USER} watches! Remove some with \`stop watching
\` first.`); + return; + } + + const result = db.addToWatchlist(user.id, cleanQuery, cleanPrice); + const template = result.new ? pick(TEMPLATES.watch.added) : pick(TEMPLATES.watch.already_watching); + await message.reply(fill(template, { query: cleanQuery, price: cleanPrice.toLocaleString() })); +} + +async function handleWatchlist(message, user) { + const watches = db.getUserWatchlist(user.id); + + if (watches.length === 0) { + await message.reply(pick(TEMPLATES.watch.list_empty)); + return; + } + + let response = pick(TEMPLATES.watch.list_header) + '\n\n'; + watches.forEach((w, i) => { + const price = w.max_price < 999999 ? `under ยฅ${w.max_price.toLocaleString()}` : 'any price'; + response += `${i + 1}. **${sanitizeForDisplay(w.query)}** โ€” ${price}\n`; + }); + response += `\n*Say \`stop watching \` to remove~*`; + + await message.reply(response); +} + +async function handleUnwatch(message, user, query) { + const cleanQuery = sanitizeQuery(query); + if (!cleanQuery) { + await message.reply("๐Ÿค” What should I stop watching? Say `watchlist` to see your hunts!"); + return; + } + + const removed = db.removeFromWatchlist(user.id, cleanQuery); + if (removed) { + await message.reply(fill(pick(TEMPLATES.watch.removed), { query: cleanQuery })); + } else { + await message.reply(`๐Ÿค” Couldn't find "${sanitizeForDisplay(cleanQuery)}" in your watchlist.`); + } +} + +async function handleStats(message, user) { + const stats = db.getUserStats(user.discord_id); + const globalStats = db.getStats(); + + const embed = new EmbedBuilder() + .setColor(0x6C5CE7) + .setTitle('๐Ÿ“Š Your Hunting Stats') + .setDescription(` +๐Ÿ” **Searches:** ${stats.total_searches} +๐Ÿ”ฅ **Deals Found:** ${stats.deals_found} +๐Ÿ‘€ **Active Watches:** ${stats.active_watches} +๐Ÿ“… **Joined:** ${new Date(stats.created_at).toLocaleDateString()} + `) + .setFooter({ text: `๐ŸŒ Global: ${globalStats.totalUsers} hunters โ€ข ${globalStats.totalSearches} searches` }); + + await message.reply({ embeds: [embed] }); +} + +// ===================================== +// ๐ŸŽฐ GACHA MODE - Let fate decide! +// ===================================== +async function handleGacha(message, user, query) { + const cleanQuery = sanitizeQuery(query); + if (!cleanQuery) { + await message.reply("๐ŸŽฐ Gacha what? Try: `gacha rem` or `gacha miku figures`"); + return; + } + + // Rate limit + if (!checkRateLimit(user.discord_id)) { + await message.reply("โณ Even gacha has rate limits! Try again in a minute~"); + return; + } + + // Show rolling message + const rollingMsg = await message.reply(pick(GACHA_TEMPLATES.rolling)); + + // Search for figures + const result = await searchAmiAmi(cleanQuery); + db.incrementSearchCount(user.id); + + if (!result.success || !result.items || result.items.length === 0) { + await rollingMsg.edit("๐ŸŽฐ The gacha machine is empty... No figures found! Try a different search."); + return; + } + + // Store for later gacha + lastSearchResults.set(user.discord_id, { query: cleanQuery, items: result.items, timestamp: Date.now() }); + + // Pick random figure + const chosen = result.items[Math.floor(Math.random() * result.items.length)]; + const price = parseInt(chosen.price) || 0; + + // Use calculated rarity from the item (now includes scale, manufacturer, etc.) + const rarity = chosen.rarity?.tier || 'r'; + const rarityLabel = chosen.rarity?.label || '๐Ÿ“ฆ R - COMMON'; + const rarityScore = chosen.rarity?.score || 0; + const rarityDetails = chosen.rarityDetails || []; + + // Build response + await new Promise(r => setTimeout(r, 1500)); // Dramatic pause + + // Rarity-based colors + const rarityColors = { + ssr: 0xFFD700, // Gold + sr: 0xA855F7, // Purple + r: 0x3B82F6, // Blue + salt: 0x6B7280, // Gray + }; + + const embed = new EmbedBuilder() + .setColor(rarityColors[rarity] || 0x6C5CE7) + .setTitle(`๐ŸŽฐ ${pick(GACHA_TEMPLATES.rarity[rarity])}`) + .setDescription(`${pick(GACHA_TEMPLATES.reveal)}\n\n**${(chosen.name || 'Mystery Figure').slice(0, 200)}**`) + .addFields( + { name: '๐Ÿ’ด Price', value: `ยฅ${price.toLocaleString()}`, inline: true }, + { name: 'โœจ Condition', value: `Item: ${chosen.item_grade || '?'} | Box: ${chosen.box_grade || '?'}`, inline: true }, + { name: '๐Ÿ“ฆ Stock', value: chosen.in_stock !== false ? 'โœ… Available!' : 'โŒ Sold Out', inline: true } + ) + .setURL(chosen.url || 'https://www.amiami.com'); + + // Add rarity details if any + if (rarityDetails.length > 0) { + embed.addFields({ name: '๐Ÿท๏ธ Tags', value: rarityDetails.slice(0, 4).join(' โ€ข '), inline: false }); + } + + embed.setFooter({ text: `${rarityLabel} (Score: ${rarityScore}) โ€ข ๐ŸŽฒ Rolled from ${result.items.length} figures` }); + + if (chosen.image) { + embed.setThumbnail(chosen.image); + } + + await rollingMsg.edit({ content: null, embeds: [embed] }); +} + +async function handleGachaLast(message, user) { + const lastSearch = lastSearchResults.get(user.discord_id); + + if (!lastSearch || Date.now() - lastSearch.timestamp > 10 * 60 * 1000) { + await message.reply("๐ŸŽฐ No recent search to gacha from! Try: `gacha rem` or search something first."); + return; + } + + if (!lastSearch.items || lastSearch.items.length === 0) { + await message.reply("๐ŸŽฐ No figures in the last search! Try: `gacha rem`"); + return; + } + + // Reuse the stored results + const chosen = lastSearch.items[Math.floor(Math.random() * lastSearch.items.length)]; + const price = parseInt(chosen.price) || 0; + + // Use calculated rarity + const rarity = chosen.rarity?.tier || 'r'; + const rarityLabel = chosen.rarity?.label || '๐Ÿ“ฆ R - COMMON'; + const rarityScore = chosen.rarity?.score || 0; + const rarityDetails = chosen.rarityDetails || []; + + // Rarity-based colors + const rarityColors = { + ssr: 0xFFD700, // Gold + sr: 0xA855F7, // Purple + r: 0x3B82F6, // Blue + salt: 0x6B7280, // Gray + }; + + const embed = new EmbedBuilder() + .setColor(rarityColors[rarity] || 0x6C5CE7) + .setTitle(`๐ŸŽฐ ${pick(GACHA_TEMPLATES.rarity[rarity])}`) + .setDescription(`**${(chosen.name || 'Mystery Figure').slice(0, 200)}**\n\n๐Ÿ’ด ยฅ${price.toLocaleString()}`) + .setURL(chosen.url || 'https://www.amiami.com'); + + if (rarityDetails.length > 0) { + embed.addFields({ name: '๐Ÿท๏ธ Tags', value: rarityDetails.slice(0, 3).join(' โ€ข '), inline: false }); + } + + embed.setFooter({ text: `${rarityLabel} (Score: ${rarityScore}) โ€ข ๐ŸŽฒ Rerolled from "${lastSearch.query}"` }); + + if (chosen.image) { + embed.setThumbnail(chosen.image); + } + + await message.reply({ embeds: [embed] }); +} + +// ===================================== +// ๐Ÿ”ฅ ROAST MODE - Savage feedback +// ===================================== +async function handleRoast(message, user) { + const lastSearch = lastSearchResults.get(user.discord_id); + + if (!lastSearch || Date.now() - lastSearch.timestamp > 10 * 60 * 1000) { + await message.reply("๐Ÿ”ฅ Roast what? Search for something first, then say `roast`!"); + return; + } + + await handleRoastQuery(message, user, lastSearch.query, lastSearch.items); +} + +async function handleRoastQuery(message, user, query, existingItems = null) { + const cleanQuery = sanitizeQuery(query); + if (!cleanQuery) { + await message.reply("๐Ÿ”ฅ Can't roast nothing! Try: `roast rem` or search first then say `roast`"); + return; + } + + // Use existing items if provided (from previous search), otherwise roast without price data + // NO API CALL - roasts are template-based! + const items = existingItems || []; + + // Build the roast + let roast = ''; + + // Character-specific roast + const lowerQuery = cleanQuery.toLowerCase(); + for (const [char, roasts] of Object.entries(ROAST_TEMPLATES.character_specific)) { + if (lowerQuery.includes(char)) { + roast += pick(roasts) + '\n\n'; + break; + } + } + + // General roast + roast += fill(pick(ROAST_TEMPLATES.general), { query: cleanQuery }); + + // Add price roast ONLY if we have items from a previous search + if (items && items.length > 0) { + const avgPrice = items.reduce((sum, i) => sum + (parseInt(i.price) || 0), 0) / items.length; + + if (avgPrice > 15000) { + const meals = Math.floor(avgPrice / 500); + roast += '\n\n' + fill(pick(ROAST_TEMPLATES.expensive), { price: Math.round(avgPrice).toLocaleString(), meals }); + } else if (avgPrice < 2000) { + roast += '\n\n' + fill(pick(ROAST_TEMPLATES.cheap), { price: Math.round(avgPrice).toLocaleString() }); + } + + // Sold out roast + const soldOut = items.filter(i => i.in_stock === false).length; + if (soldOut > items.length / 2) { + roast += '\n\n' + pick(ROAST_TEMPLATES.soldout); + } + } + + const embed = new EmbedBuilder() + .setColor(0xFF4444) + .setTitle(`๐Ÿ”ฅ ROAST TIME ๐Ÿ”ฅ`) + .setDescription(roast) + .setFooter({ text: "Don't shoot the messenger~ ๐Ÿ’…" }); + + await message.reply({ embeds: [embed] }); +} + +// ===================================== +// ๐Ÿ˜ญ COPIUM MODE - Maximum cope +// ===================================== +async function handleCopium(message, user) { + const lastSearch = lastSearchResults.get(user.discord_id); + + let copiumType = 'no_results'; + let context = ''; + + if (lastSearch && Date.now() - lastSearch.timestamp < 10 * 60 * 1000) { + const items = lastSearch.items || []; + const soldOut = items.filter(i => i.in_stock === false).length; + const avgPrice = items.length > 0 + ? items.reduce((sum, i) => sum + (parseInt(i.price) || 0), 0) / items.length + : 0; + + if (items.length === 0) { + copiumType = 'no_results'; + } else if (soldOut > items.length / 2) { + copiumType = 'sold_out'; + context = `\n\n*${soldOut}/${items.length} items sold out*`; + } else if (avgPrice > 15000) { + copiumType = 'expensive'; + context = `\n\n*Average price: ยฅ${Math.round(avgPrice).toLocaleString()}*`; + } else { + // Check for damaged boxes (deals) + const hasDamagedBox = items.some(i => + (i.box_grade === 'B' || i.box_grade === 'C' || i.box_grade === 'B-') && + (i.item_grade === 'A' || i.item_grade === 'A-') + ); + if (hasDamagedBox) { + copiumType = 'damaged_box'; + } + } + } + + const copiumMessages = COPIUM_TEMPLATES[copiumType] || COPIUM_TEMPLATES.no_results; + + // Pick 2-3 random copium messages + const shuffled = [...copiumMessages].sort(() => Math.random() - 0.5); + const selectedCopium = shuffled.slice(0, Math.min(3, shuffled.length)); + + const embed = new EmbedBuilder() + .setColor(0x9B59B6) + .setTitle(`๐Ÿ’จ COPIUM DISPENSARY ๐Ÿ’จ`) + .setDescription(selectedCopium.join('\n\n') + context) + .setFooter({ text: "Remember: Figures can't leave you... unlike your wallet ๐Ÿ’ธ" }); + + await message.reply({ embeds: [embed] }); +} + +// ===================================== +// ๐Ÿ”” BACKGROUND WATCH CHECKER +// ===================================== +let watchCheckerRunning = false; + +async function runWatchChecker(client) { + // Prevent overlapping runs + if (watchCheckerRunning) { + console.log('๐Ÿ”” Watch checker already running, skipping...'); + return; + } + + watchCheckerRunning = true; + console.log('๐Ÿ”” Running watch checker...'); + + try { + const watches = db.getAllActiveWatches(); + console.log(` Checking ${watches.length} active watches`); + + // Limit batch size to prevent long-running loops + const MAX_BATCH = 50; + const batch = watches.slice(0, MAX_BATCH); + + if (watches.length > MAX_BATCH) { + console.log(` โš ๏ธ Limited to ${MAX_BATCH} watches this cycle`); + } + + for (const watch of batch) { + try { + await new Promise(r => setTimeout(r, 2000)); // 2s between checks + + const result = await searchAmiAmi(watch.query, watch.max_price); + db.updateWatchChecked(watch.id); + + if (!result.success || !result.items?.length) continue; + + const user = db.getOrCreateUser(watch.discord_id); + const deals = result.items.filter(isDeal); + + for (const deal of deals) { + if (!deal.url || db.hasBeenNotified(user.id, deal.url)) continue; + + try { + const discordUser = await client.users.fetch(watch.discord_id); + const embed = createFigureEmbed(deal); + embed.setTitle(`๐Ÿšจ DEAL: ${(deal.name || watch.query).slice(0, 200)}`); + + await discordUser.send({ + content: `๐Ÿ”” **Found a deal for "${sanitizeForDisplay(watch.query)}"!**`, + embeds: [embed] + }); + + db.markNotified(user.id, deal.url); + db.incrementWatchNotified(watch.id); + console.log(` โœ… Notified ${watch.discord_id}`); + } catch (e) { + console.log(` โŒ Couldn't DM ${watch.discord_id}: ${e.message}`); + } + } + } catch (e) { + console.error(` Error on watch ${watch.id}:`, e.message); + } + } + + console.log('๐Ÿ”” Watch check complete'); + } finally { + watchCheckerRunning = false; + } +} + +// ===================================== +// ๐Ÿš€ START BOT +// ===================================== +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages, + ], + partials: [Partials.Channel, Partials.Message], +}); + +client.once('ready', () => { + console.log(''); + console.log('๐ŸŽŽ โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + console.log('๐ŸŽŽ WAIFU DEAL SNIPER is ONLINE!'); + console.log(`๐ŸŽŽ Logged in as ${client.user.tag}`); + console.log(`๐ŸŽŽ Serving ${client.guilds.cache.size} servers`); + console.log('๐ŸŽŽ โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + console.log(''); + + client.user.setActivity('DM me to hunt figures! ๐ŸŽŽ', { type: ActivityType.Custom }); + + // Start watch checker + setInterval(() => runWatchChecker(client), CONFIG.WATCH_INTERVAL); + setTimeout(() => runWatchChecker(client), 30000); +}); + +client.on('messageCreate', async (message) => { + try { + if (message.author.bot) return; + + const isDM = !message.guild; + const isMentioned = message.mentions.has(client.user); + + // Debug logging + console.log(`๐Ÿ“จ Message from ${message.author.username}: "${message.content.slice(0, 50)}" (DM: ${isDM})`); + + if (isDM || isMentioned) { + // Remove bot mention from content if present + const cleanContent = message.content.replace(/<@!?\d+>/g, '').trim(); + console.log(` โ†’ Clean content: "${cleanContent}"`); + if (cleanContent || isDM) { + await handleMessage(message, cleanContent || message.content); + console.log(` โ†’ handleMessage completed`); + } + } + } catch (error) { + console.error('Message handler error:', error); + console.error('Stack:', error.stack); + try { + await message.reply("๐Ÿ˜ต Something went wrong! Try again?").catch(() => {}); + } catch (e) { + // Can't reply, just log + } + } +}); + +client.on('error', console.error); +process.on('unhandledRejection', console.error); + +// Graceful shutdown +process.on('SIGTERM', () => { + console.log('๐Ÿ‘‹ Shutting down gracefully...'); + client.destroy(); + process.exit(0); +}); + +if (!CONFIG.DISCORD_TOKEN) { + console.error('โŒ DISCORD_TOKEN not set!'); + process.exit(1); +} + +if (!CONFIG.MINO_API_KEY) { + console.error('โŒ MINO_API_KEY not set!'); + process.exit(1); +} + +// Initialize database then start bot +db.initDb().then(() => { + console.log('๐Ÿ’พ Database initialized'); + client.login(CONFIG.DISCORD_TOKEN); +}).catch(err => { + console.error('โŒ Database init failed:', err); + process.exit(1); +}); diff --git a/waifu-deal-sniper/database.js b/waifu-deal-sniper/database.js new file mode 100644 index 0000000..6c7de22 --- /dev/null +++ b/waifu-deal-sniper/database.js @@ -0,0 +1,318 @@ +// ===================================== +// ๐Ÿ’พ DATABASE - sql.js User Management +// Pure JavaScript SQLite (no native compilation!) +// ===================================== + +const initSqlJs = require('sql.js'); +const fs = require('fs'); +const path = require('path'); + +const DB_PATH = process.env.DATABASE_PATH || './data/waifu.db'; + +// Ensure data directory exists +const dataDir = path.dirname(DB_PATH); +if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); +} + +let db = null; +let SQL = null; +let initialized = false; + +// Initialize database +async function initDb() { + if (initialized && db) return db; + + SQL = await initSqlJs(); + + // Load existing database or create new + try { + if (fs.existsSync(DB_PATH)) { + const buffer = fs.readFileSync(DB_PATH); + db = new SQL.Database(buffer); + } else { + db = new SQL.Database(); + } + } catch (e) { + db = new SQL.Database(); + } + + // Initialize tables + db.run(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + discord_id TEXT UNIQUE NOT NULL, + username TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_active DATETIME DEFAULT CURRENT_TIMESTAMP, + total_searches INTEGER DEFAULT 0, + deals_found INTEGER DEFAULT 0 + ) + `); + + db.run(` + CREATE TABLE IF NOT EXISTS watchlist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + query TEXT NOT NULL, + max_price INTEGER DEFAULT 999999, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_checked DATETIME, + times_notified INTEGER DEFAULT 0, + active INTEGER DEFAULT 1, + FOREIGN KEY (user_id) REFERENCES users(id), + UNIQUE(user_id, query) + ) + `); + + db.run(` + CREATE TABLE IF NOT EXISTS search_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + query TEXT NOT NULL, + results_count INTEGER DEFAULT 0, + searched_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + `); + + db.run(` + CREATE TABLE IF NOT EXISTS notified_deals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + product_url TEXT NOT NULL, + notified_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + UNIQUE(user_id, product_url) + ) + `); + + // Create indexes + try { + db.run(`CREATE INDEX IF NOT EXISTS idx_users_discord ON users(discord_id)`); + db.run(`CREATE INDEX IF NOT EXISTS idx_watchlist_user ON watchlist(user_id)`); + db.run(`CREATE INDEX IF NOT EXISTS idx_watchlist_active ON watchlist(active)`); + } catch (e) { + // Indexes might already exist + } + + initialized = true; + saveDb(); + return db; +} + +// Save database to file +function saveDb() { + if (!db) return; + try { + const data = db.export(); + const buffer = Buffer.from(data); + fs.writeFileSync(DB_PATH, buffer); + } catch (e) { + console.error('Error saving database:', e); + } +} + +// Auto-save every 30 seconds +setInterval(saveDb, 30000); + +// Helper to run queries +function run(sql, params = []) { + if (!db) throw new Error('Database not initialized'); + db.run(sql, params); + saveDb(); +} + +function get(sql, params = []) { + if (!db) throw new Error('Database not initialized'); + const stmt = db.prepare(sql); + stmt.bind(params); + if (stmt.step()) { + const row = stmt.getAsObject(); + stmt.free(); + return row; + } + stmt.free(); + return null; +} + +function all(sql, params = []) { + if (!db) throw new Error('Database not initialized'); + const stmt = db.prepare(sql); + stmt.bind(params); + const rows = []; + while (stmt.step()) { + rows.push(stmt.getAsObject()); + } + stmt.free(); + return rows; +} + +// ===== USER FUNCTIONS ===== + +function getOrCreateUser(discordId, username = null) { + let user = get('SELECT * FROM users WHERE discord_id = ?', [discordId]); + + if (!user) { + run('INSERT INTO users (discord_id, username) VALUES (?, ?)', [discordId, username]); + user = get('SELECT * FROM users WHERE discord_id = ?', [discordId]); + } else if (username && user.username !== username) { + run('UPDATE users SET username = ? WHERE id = ?', [username, user.id]); + } + + return user; +} + +function updateUserActivity(discordId) { + run('UPDATE users SET last_active = CURRENT_TIMESTAMP WHERE discord_id = ?', [discordId]); +} + +function incrementSearchCount(userId) { + run('UPDATE users SET total_searches = total_searches + 1 WHERE id = ?', [userId]); +} + +function incrementDealsFound(userId, count = 1) { + run('UPDATE users SET deals_found = deals_found + ? WHERE id = ?', [count, userId]); +} + +function isNewUser(discordId) { + const user = get('SELECT total_searches FROM users WHERE discord_id = ?', [discordId]); + return !user || user.total_searches === 0; +} + +// ===== WATCHLIST FUNCTIONS ===== + +function addToWatchlist(userId, query, maxPrice = 999999) { + const existing = get( + 'SELECT id FROM watchlist WHERE user_id = ? AND query = ?', + [userId, query.toLowerCase()] + ); + + if (existing) { + run( + 'UPDATE watchlist SET max_price = ?, active = 1 WHERE id = ?', + [maxPrice, existing.id] + ); + return { success: true, new: false }; + } + + run( + 'INSERT INTO watchlist (user_id, query, max_price) VALUES (?, ?, ?)', + [userId, query.toLowerCase(), maxPrice] + ); + return { success: true, new: true }; +} + +function removeFromWatchlist(userId, query) { + const before = get('SELECT COUNT(*) as count FROM watchlist WHERE user_id = ? AND active = 1', [userId]); + run( + 'UPDATE watchlist SET active = 0 WHERE user_id = ? AND query LIKE ?', + [userId, `%${query.toLowerCase()}%`] + ); + const after = get('SELECT COUNT(*) as count FROM watchlist WHERE user_id = ? AND active = 1', [userId]); + return before.count > after.count; +} + +function getUserWatchlist(userId) { + return all( + 'SELECT * FROM watchlist WHERE user_id = ? AND active = 1 ORDER BY created_at DESC', + [userId] + ); +} + +function getAllActiveWatches() { + return all(` + SELECT w.*, u.discord_id + FROM watchlist w + JOIN users u ON w.user_id = u.id + WHERE w.active = 1 + `); +} + +function updateWatchChecked(watchId) { + run('UPDATE watchlist SET last_checked = CURRENT_TIMESTAMP WHERE id = ?', [watchId]); +} + +function incrementWatchNotified(watchId) { + run('UPDATE watchlist SET times_notified = times_notified + 1 WHERE id = ?', [watchId]); +} + +// ===== NOTIFICATION DEDUP ===== + +function hasBeenNotified(userId, productUrl) { + const result = get( + 'SELECT 1 FROM notified_deals WHERE user_id = ? AND product_url = ?', + [userId, productUrl] + ); + return !!result; +} + +function markNotified(userId, productUrl) { + try { + run( + 'INSERT OR IGNORE INTO notified_deals (user_id, product_url) VALUES (?, ?)', + [userId, productUrl] + ); + } catch (e) { + // Ignore duplicates + } +} + +// ===== SEARCH HISTORY ===== + +function logSearch(userId, query, resultsCount) { + run( + 'INSERT INTO search_history (user_id, query, results_count) VALUES (?, ?, ?)', + [userId, query, resultsCount] + ); +} + +// ===== STATS ===== + +function getStats() { + const users = get('SELECT COUNT(*) as count FROM users'); + const searches = get('SELECT SUM(total_searches) as count FROM users'); + const watches = get('SELECT COUNT(*) as count FROM watchlist WHERE active = 1'); + + return { + totalUsers: users?.count || 0, + totalSearches: searches?.count || 0, + activeWatches: watches?.count || 0, + }; +} + +function getUserStats(discordId) { + const user = get('SELECT * FROM users WHERE discord_id = ?', [discordId]); + if (!user) return null; + + const watches = get( + 'SELECT COUNT(*) as count FROM watchlist WHERE user_id = ? AND active = 1', + [user.id] + ); + + return { + ...user, + active_watches: watches?.count || 0, + }; +} + +module.exports = { + initDb, + getOrCreateUser, + updateUserActivity, + incrementSearchCount, + incrementDealsFound, + isNewUser, + addToWatchlist, + removeFromWatchlist, + getUserWatchlist, + getAllActiveWatches, + updateWatchChecked, + incrementWatchNotified, + hasBeenNotified, + markNotified, + logSearch, + getStats, + getUserStats, + get db() { return db; }, +}; diff --git a/waifu-deal-sniper/package.json b/waifu-deal-sniper/package.json new file mode 100644 index 0000000..adbc69c --- /dev/null +++ b/waifu-deal-sniper/package.json @@ -0,0 +1,27 @@ +{ + "name": "waifu-deal-sniper", + "version": "2.0.0", + "description": "Discord bot that finds discounted pre-owned anime figures using TinyFish Mino API", + "main": "bot.js", + "scripts": { + "start": "node bot.js", + "dev": "node --watch bot.js" + }, + "keywords": [ + "discord-bot", + "anime", + "figures", + "web-scraping", + "tinyfish", + "mino-api" + ], + "author": "Shubham Khandelwal", + "license": "MIT", + "dependencies": { + "discord.js": "^14.14.1", + "sql.js": "^1.10.3" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/waifu-deal-sniper/templates.js b/waifu-deal-sniper/templates.js new file mode 100644 index 0000000..88bdf90 --- /dev/null +++ b/waifu-deal-sniper/templates.js @@ -0,0 +1,992 @@ +// ===================================== +// ๐ŸŽญ PERSONALITY TEMPLATES +// 200+ responses for maximum vibes +// ===================================== + +const TEMPLATES = { + + // ===== FIRST TIME USER ===== + welcome: [ + "Hey {user}! ๐Ÿ‘‹ I'm **Waifu Deal Sniper** โ€” your personal figure hunting assistant!\n\n" + + "๐ŸŽŽ I search AmiAmi's pre-owned section in real-time\n" + + "๐Ÿ’ฐ I find \"mint figure, damaged box\" deals (40-50% off!)\n" + + "๐Ÿ”” I can alert you when your grails appear\n\n" + + "Just tell me what you're looking for! Like:\n" + + "โ€ข `looking for chainsaw man figures`\n" + + "โ€ข `any rem bunny under 15000?`\n" + + "โ€ข `find me sonico`\n\n" + + "What are we hunting today? ๐ŸŽฏ", + ], + + // ===== GREETINGS ===== + greetings: { + normal: [ + "Hey {user}! Ready to hunt some figures? ๐ŸŽฏ", + "Yo {user}! What are we hunting today?", + "Hey hey! What figure can I find for you?", + "{user}! Let's find you some deals! ๐Ÿ’ฐ", + "Sup {user}! Looking to expand the collection?", + "Heya! Your figure hunter is ready~ What do you need?", + "Hey {user}! What waifu/husbando are we hunting? ๐Ÿ‘€", + ], + returning: [ + "Welcome back {user}! Miss me? ๐Ÿ˜", + "{user}! Back for more, huh? I like your dedication~", + "Oh look who's back! Ready to hurt your wallet again? ๐Ÿ’ธ", + "{user} returns! The hunt continues~", + "Ayyy {user}! Ready to find some deals?", + "The hunter returns! What are we sniping today?", + ], + }, + + // ===== SEARCHING ===== + searching: { + normal: [ + "๐Ÿ” Hunting for **{query}**... Give me a sec!", + "๐ŸŽฏ Locking onto **{query}**... Stand by!", + "๐Ÿ‘€ Scanning AmiAmi for **{query}**...", + "๐Ÿ”Ž Let me check what's available for **{query}**...", + "โณ Searching the depths of AmiAmi for **{query}**...", + "๐ŸŽฏ On the hunt for **{query}**...", + "๐Ÿ” Scouting **{query}** deals...", + "๐Ÿ‘๏ธ Eyes on **{query}**... searching...", + ], + spicy: [ + "๐Ÿ‘€ Oh? **{query}**? A person of culture I see... Searching~", + "๐Ÿ˜ **{query}** huh? Naughty naughty~ Let me look...", + "๐Ÿ”ฅ Down bad for **{query}**? Say no more fam, searching...", + "๐Ÿ‘€ **{query}**... Your FBI agent is taking notes. Searching anyway~", + "๐Ÿ˜ณ **{query}**?! Okay okay, no judgment here... *searches*", + "๐Ÿท Ah, **{query}**... A fellow researcher. Let me assist~", + "๐Ÿ‘€๐Ÿ’ฆ **{query}**... For \"display purposes\" right? RIGHT? Searching...", + "๐Ÿ˜ Looking for **{query}**... I respect the honesty. Searching~", + "๐Ÿ”ฅ **{query}**? The horny jail can wait. Searching...", + "๐Ÿ‘€ Ah yes, **{query}**... *tips fedora* Searching, m'collector...", + "๐Ÿ˜ **{query}**... I see you're a scholar of the arts~", + "๐ŸŒถ๏ธ **{query}**? Spicy choice. Let me look...", + ], + husbando: [ + "๐Ÿ˜ **{query}**? Valid. Respectfully simping. Searching...", + "๐Ÿ‘€ **{query}**? Excellent taste in husbandos! Looking...", + "๐Ÿ”ฅ **{query}** huh? I don't blame you. Searching~", + "๐Ÿ’• Ah, **{query}**... A cultured choice. Let me find him~", + "โœจ **{query}**? *chef's kiss* Looking now~", + "๐Ÿ˜ณ **{query}**... understandable. Searching!", + ], + }, + + // ===== FOUND RESULTS ===== + found: { + normal: [ + "๐ŸŽ‰ Found **{count}** results for **{query}**!", + "โœจ Got **{count}** hits for **{query}**!", + "๐ŸŽฏ Locked on! **{count}** figures found:", + "๐Ÿ“ฆ **{count}** **{query}** figures spotted:", + "๐Ÿ’ซ Boom! **{count}** results:", + "๐Ÿ”ฅ Got **{count}** for you:", + ], + spicy: [ + "๐Ÿ˜ Found **{count}** \"research materials\" for **{query}**:", + "๐Ÿ‘€ **{count}** cultured items found for **{query}**:", + "๐Ÿ”ฅ **{count}** spicy finds for **{query}**... bon appรฉtit:", + "๐Ÿ’ฆ Here's **{count}** **{query}** figures for your... collection:", + "๐Ÿ“š **{count}** \"art pieces\" found for **{query}**:", + "๐Ÿ˜ **{count}** items for your \"research\" on **{query}**:", + "๐Ÿท A refined selection of **{count}** **{query}** figures:", + ], + single: [ + "๐ŸŽฏ Found one! Here's the **{query}**:", + "โœจ Got a hit on **{query}**!", + "๐Ÿ‘€ Spotted a **{query}**:", + ], + }, + + // ===== DEAL ALERTS ===== + deal_alert: [ + "๐Ÿšจ **DEAL ALERT!** Mint figure, damaged box = BIG SAVINGS", + "๐Ÿ’ฐ **THE SWEET SPOT** โ€” Perfect figure, sad box", + "๐Ÿ”ฅ **SNIPER SPECIAL** โ€” Box took an L so you don't have to", + "๐Ÿ‘€ **CULTURED DEAL** โ€” Who displays the box anyway?", + "๐ŸŽฏ **SMART MONEY** โ€” Mint figure, discount price", + "๐Ÿ’ธ **STEAL ALERT** โ€” Box got yeeted, figure pristine", + "๐Ÿง  **BIG BRAIN DEAL** โ€” Same figure, fraction of the price", + ], + + // ===== NO RESULTS ===== + no_results: { + normal: [ + "๐Ÿ˜ข No **{query}** found right now... Want me to alert you when one appears?", + "๐Ÿ’จ **{query}** is sold out or not listed atm. I can watch for you!", + "๐Ÿซฅ Nothing for **{query}** at the moment. Shall I keep an eye out?", + "๐Ÿ˜ค The scalpers got to **{query}** first... Want alerts for restocks?", + "๐Ÿ” Couldn't find **{query}** right now. Say `watch {query}` and I'll ping you when it appears!", + "๐Ÿ˜… **{query}** is playing hard to get... Want me to stalk it for you?", + ], + spicy: [ + "๐Ÿ˜ข No **{query}** available... Your fellow degenerates bought them all", + "๐Ÿ’จ **{query}** is gone... Too many people of culture out there", + "๐Ÿซฅ Someone beat you to the **{query}**... Down bad together ๐Ÿ˜”", + "๐Ÿ˜ค All the **{query}** got sniped... The FBI was faster", + ], + }, + + // ===== CONDITION COMMENTARY ===== + condition: { + mint_box_damaged: [ + "๐ŸŽฏ THE PLAY โ€” Mint figure, crushed box. Who displays boxes anyway?", + "๐Ÿ’ฐ Box got yeeted but figure is *chef's kiss*", + "๐Ÿง  Big brain deal โ€” perfect figure, discount price", + "๐Ÿ‘€ Box took one for the team. Figure is immaculate.", + "๐Ÿ”ฅ Damaged box = your wallet's best friend", + "๐Ÿ’ธ Box said ๐Ÿ“ฆ๐Ÿ’€ but figure said โœจ๐Ÿ˜Œโœจ", + "๐ŸŽฏ Box is mid, figure is mint. Easy choice.", + "๐Ÿ’ฐ Box went through customs hell. Figure survived.", + ], + mint_mint: [ + "โœจ Pristine condition. Instagram-ready.", + "๐Ÿ’Ž Perfect condition but you're paying for it~", + "๐Ÿ‘‘ Mint everything. Treat yourself, king/queen.", + "โญ Flawless. Museum quality.", + "โœจ Immaculate vibes. No notes.", + ], + good: [ + "๐Ÿ‘ Good condition! Solid pickup.", + "โœจ Looking good! Minor wear at most.", + "๐Ÿ‘Œ Nice condition for pre-owned!", + ], + used: [ + "๐Ÿ‘€ Has some wear but still displayable", + "๐Ÿค” Pre-loved. Character building, as they say.", + "๐Ÿ’ญ Someone else's ex-waifu. Could be yours now.", + "๐Ÿ“ฆ Lived a life. Still got it though.", + ], + }, + + // ===== FIGURE TYPE REACTIONS ===== + figure_types: { + bunny: [ + "๐Ÿฐ Bunny suit? Excellent choice, fellow intellectual ๐Ÿ˜", + "๐Ÿฐ Ah yes, the bunny aesthetic... For \"artistic\" reasons", + "๐Ÿฐ Bunny figures hit different... and hit the wallet too ๐Ÿ’ธ", + "๐Ÿฐ B-style energy. Your shelf is about to glow up~", + "๐Ÿฐ Bunny ver? The pinnacle of culture.", + ], + bikini: [ + "๐Ÿ‘™ Bikini figure? Research purposes, I assume? ๐Ÿ“š", + "๐Ÿ‘™ Summer vibes~ Your display case is getting warmer", + "๐Ÿ‘™ Bikini ver... for your beach-themed shelf, obviously", + "๐Ÿ‘™ Swimsuit figure? Hydration is important. Stay cultured.", + ], + wedding: [ + "๐Ÿ’’ Wedding dress ver? DOWN ASTRONOMICAL ๐Ÿ’€", + "๐Ÿ’’ Marrying your waifu in figure form... valid honestly", + "๐Ÿ’’ Wedding ver... This is commitment. I respect it.", + "๐Ÿ’’ Bridal figure? Someone's ready to settle down~", + "๐Ÿ’’ Wedding dress? This is a PROPOSAL ๐Ÿ’", + ], + maid: [ + "๐ŸŽ€ Maid outfit? Cultured AND classy~", + "๐ŸŽ€ Ah, the maid aesthetic... A timeless choice", + "๐ŸŽ€ Maid ver? Someone knows what they want ๐Ÿ˜", + "๐ŸŽ€ Maid figure? *tips hat* Excellent taste.", + ], + nurse: [ + "๐Ÿ’‰ Nurse outfit? For... medical appreciation? ๐Ÿ˜", + "๐Ÿ’‰ Nurse ver! Here to heal your collection~", + "๐Ÿ’‰ Medical professional? I'm suddenly feeling unwell...", + ], + racing: [ + "๐ŸŽ๏ธ Racing ver? Speed AND style, I see you~", + "๐ŸŽ๏ธ Racing queen aesthetic? Cultured choice!", + "๐ŸŽ๏ธ Racing figure? Fast and fabulous~", + ], + school: [ + "๐ŸŽ“ School uniform ver! Classic anime aesthetic~", + "๐ŸŽ“ Uniform figure? Clean and simple. Nice.", + "๐ŸŽ“ Seifuku vibes? A timeless classic.", + ], + china_dress: [ + "๐Ÿงง China dress? Elegant AND spicy~", + "๐Ÿงง Qipao ver? Immaculate taste.", + ], + kimono: [ + "๐ŸŽŽ Kimono figure? Traditional beauty~", + "๐ŸŽŽ Kimono ver? Elegant choice!", + ], + }, + + // ===== CHARACTER REACTIONS ===== + characters: { + // Chainsaw Man + "power": [ + "๐Ÿฉธ POWER! Best girl energy. Nobel Prize worthy taste.", + "๐Ÿฉธ Power figure?! You understand greatness.", + "๐Ÿฉธ Ah, Power... The blood fiend of our hearts~", + "๐Ÿฉธ POWER SUPREMACY! Let's find her!", + ], + "makima": [ + "๐Ÿ• Makima? Down bad for the control devil I see...", + "๐Ÿ• Makima figure... She's already controlling your wallet", + "๐Ÿ• woof. (You know what you're getting into)", + "๐Ÿ• Makima? Understandable. *sits*", + ], + "reze": [ + "๐Ÿ’ฃ Reze! Explosive taste, literally~", + "๐Ÿ’ฃ Bomb girl? Your heart AND wallet will explode", + ], + "denji": [ + "๐Ÿชš Denji! Chainsawman himself!", + "๐Ÿชš Denji figure? Roof dog energy~", + ], + "aki": [ + "๐Ÿšฌ Aki? Pain incoming. Good taste though.", + "๐Ÿšฌ Aki figure... *cries in manga reader*", + ], + + // Sonico & friends + "sonico": [ + "๐ŸŽง Super Sonico! The OG thicc queen since 2006~", + "๐ŸŽง Sonico? Headphones AND curves. A classic.", + "๐ŸŽง Ah, Sonico... A person of refined taste I see ๐Ÿ˜", + "๐ŸŽง Sonico figure? There's literally 500. Let me narrow it down~", + ], + + // My Dress-Up Darling + "marin": [ + "๐Ÿ“ธ MARIN?! Elite taste detected! The cosplay girlfriend everyone wants~", + "๐Ÿ“ธ Marin Kitagawa! JuJu-sama approves ๐Ÿ˜", + "๐Ÿ“ธ My Dress-Up Darling? More like My Wallet's Nightmare amirite", + "๐Ÿ“ธ Marin? Peak fiction. Peak waifu. Let's go!", + ], + + // Re:Zero + "rem": [ + "๐Ÿ’™ Rem! The maid that launched a thousand collections~", + "๐Ÿ’™ Rem > Ram (I will not be taking questions)", + "๐Ÿ’™ Ah, Rem... Who's Emilia again? ๐Ÿ˜", + "๐Ÿ’™ Rem figure? Your taste is *chef's kiss*", + ], + "ram": [ + "๐Ÿ’— Ram! A rare but valid choice~", + "๐Ÿ’— Ram enjoyer spotted! Underrated pick.", + "๐Ÿ’— Ram figure? Finally some Ram appreciation!", + ], + "emilia": [ + "๐Ÿ’œ Emilia-tan! The actual main girl~", + "๐Ÿ’œ Emilia? Subaru would be proud.", + ], + "echidna": [ + "๐Ÿ–ค Echidna? Tea-drinking witch supremacy~", + "๐Ÿ–ค Witch of Greed? Cultured choice.", + ], + + // Vocaloid + "miku": [ + "๐ŸŽค Hatsune Miku! The virtual diva herself~", + "๐ŸŽค Miku? There's like 9000 figures of her. Let me narrow it down...", + "๐ŸŽค Miku collector? Your wallet has my condolences ๐Ÿ’", + "๐ŸŽค Miku figure? Which era? Which outfit? Which dimension? ๐Ÿ˜‚", + ], + + // High School DxD + "rias": [ + "๐Ÿ˜ˆ Rias Gremory? Going full cultured tonight I see ๐Ÿท", + "๐Ÿ˜ˆ High School DxD... A fellow researcher of the oppai arts", + "๐Ÿ˜ˆ Rias? Crimson-haired cultured choice~", + ], + "akeno": [ + "โšก Akeno? Ara ara~ Good taste.", + "โšก Akeno figure? Thunder waifu appreciation!", + ], + + // Fate + "saber": [ + "โš”๏ธ Saber! The OG Fate waifu~", + "โš”๏ธ Artoria? A classic choice. Unlimited Budget Works incoming.", + "โš”๏ธ Saber figure? Which version? There's only like... 500 ๐Ÿ˜…", + ], + "rin": [ + "๐Ÿ’Ž Rin Tohsaka! Tsundere supremacy~", + "๐Ÿ’Ž Rin? Twin-tails and thigh-highs. Classic.", + ], + "sakura": [ + "๐ŸŒธ Sakura Matou! The angst queen~", + "๐ŸŒธ Sakura figure? Heaven's Feel taste.", + ], + + // Darling in the Franxx + "zero two": [ + "๐Ÿฆ• Zero Two! Dino girl supremacy~", + "๐Ÿฆ• Dahling~ Zero Two figure located!", + "๐Ÿฆ• 002? A person of culture since 2018~", + ], + + // Demon Slayer + "nezuko": [ + "๐ŸŽ‹ Nezuko! Must protecc energy~", + "๐ŸŽ‹ Nezuko-chan! Wholesome choice!", + ], + "shinobu": [ + "๐Ÿฆ‹ Shinobu! Ara ara with a blade~", + "๐Ÿฆ‹ Shinobu figure? Butterfly beauty!", + ], + "mitsuri": [ + "๐Ÿ’• Mitsuri! Love hashira energy~", + "๐Ÿ’• Mitsuri? Pink AND powerful!", + ], + + // Spy x Family + "yor": [ + "๐Ÿ—ก๏ธ Yor! Mommy? Sorry. Mommy? Sorry. Mommy?", + "๐Ÿ—ก๏ธ Yor Forger? Assassin waifu supremacy!", + "๐Ÿ—ก๏ธ Yor? She can step on meโ€” I mean, nice choice!", + ], + "anya": [ + "๐Ÿฅœ Anya! Waku waku! ๐Ÿฅœ", + "๐Ÿฅœ Anya figure? Heh~ *smug face*", + ], + + // Overlord + "albedo": [ + "๐Ÿ–ค Albedo! Bone daddy's #1 simp~", + "๐Ÿ–ค Overlord's Albedo? Cultured Nazarick enjoyer detected", + ], + "shalltear": [ + "๐Ÿฉธ Shalltear! Vampire chair loli~", + "๐Ÿฉธ Shalltear? True vampire enthusiast!", + ], + + // Konosuba + "megumin": [ + "๐Ÿ’ฅ EXPLOSION! Megumin best girl!", + "๐Ÿ’ฅ Megumin? Bakuretsu bakuretsu la la la~", + ], + "darkness": [ + "โš”๏ธ Darkness? She'd enjoy being hunted like this~", + "โš”๏ธ Lalatina! *gets bonked*", + ], + "aqua": [ + "๐Ÿ’ง Aqua! Useless goddess but we love her~", + "๐Ÿ’ง Aqua figure? Nature's beauty! (party tricks not included)", + ], + + // Dragon Maid + "tohru": [ + "๐Ÿ‰ Tohru! Dragon maid of culture~", + "๐Ÿ‰ Tohru figure? THICC dragon energy incoming", + ], + "kanna": [ + "โšก Kanna! Ravioli ravioli~", + "โšก Kanna? Must protect the dragon loli!", + ], + "lucoa": [ + "๐ŸŒฝ Lucoa?! ๐Ÿ‘€๐Ÿ‘€๐Ÿ‘€ Searching...", + "๐ŸŒฝ Quetzalcoatl? Top heavy dragon incoming~", + ], + "ilulu": [ + "๐Ÿ”ฅ Ilulu! Smol but stacked dragon~", + "๐Ÿ”ฅ Ilulu figure? Chaos energy!", + ], + + // Genshin + "raiden": [ + "โšก Raiden Shogun! Eternity waifu~", + "โšก Ei? Booba sword supremacy!", + ], + "hu tao": [ + "๐Ÿ”ฅ Hu Tao! Funeral parlor bestie~", + "๐Ÿ”ฅ Hu Tao? Who? Tao, yeah!", + ], + "ganyu": [ + "๐Ÿ Ganyu! Cocogoat located!", + "๐Ÿ Ganyu figure? Cryo waifu secured!", + ], + "keqing": [ + "โšก Keqing! Hardworking cat girl~", + "โšก Keqing? Electro queen!", + ], + + // Husbandos - JJK + "gojo": [ + "๐Ÿ‘๏ธ Gojo? Valid. Those eyes... I get it.", + "๐Ÿ‘๏ธ Satoru Gojo! The blindfold can stay on or off, your choice~", + "๐Ÿ‘๏ธ Gojo? He IS the honored one.", + ], + "sukuna": [ + "๐Ÿ‘น Sukuna?! Down bad for the King of Curses I see~", + "๐Ÿ‘น Ryomen Sukuna! Malevolent but make it hot.", + ], + "toji": [ + "๐Ÿ’ช Toji? DILF of culture detected", + "๐Ÿ’ช Toji Fushiguro! The sorcerer killer and heart stealer~", + ], + "nanami": [ + "๐Ÿ‘” Nanami! Working overtime in your heart~", + "๐Ÿ‘” Kento Nanami? 9-5 husband material.", + ], + "geto": [ + "๐Ÿ–ค Geto? The better villain?", + "๐Ÿ–ค Suguru Geto! *cries*", + ], + "megumi": [ + "๐Ÿ• Megumi? Good boy energy!", + "๐Ÿ• Fushiguro! Ten shadows taste~", + ], + + // Husbandos - AoT + "levi": [ + "๐Ÿงน Levi Ackerman! Short king energy~", + "๐Ÿงน Levi? Clean taste. He'd approve.", + "๐Ÿงน Captain Levi? *salutes*", + ], + "eren": [ + "๐Ÿ”ฅ Eren? *paths noises*", + "๐Ÿ”ฅ Eren Yeager! Freedom!", + ], + + // Husbandos - Misc + "kakashi": [ + "๐Ÿ“– Kakashi! Reading... literature. ๐Ÿ‘€", + "๐Ÿ“– Kakashi-sensei? Cultured choice.", + ], + "itachi": [ + "๐ŸŒ€ Itachi... *cries in Sasuke*", + "๐ŸŒ€ Itachi Uchiha? Pain. Beautiful pain.", + ], + }, + + // ===== PRICE REACTIONS ===== + prices: { + budget: [ + "๐Ÿ’ฐ That's a steal! Your wallet says thanks~", + "๐Ÿค‘ Budget-friendly AND cute? We love to see it", + "๐Ÿ’ต Cheap AND good? This is the way.", + "๐Ÿ’ฐ Your bank account approves this message.", + ], + mid: [ + "๐Ÿ’ด Fair price for the quality~", + "๐Ÿ’ต Not bad, not bad. Solid deal.", + "๐Ÿ’ฐ Reasonable! Your wallet will survive.", + "๐Ÿ‘ Standard pricing. No complaints.", + ], + expensive: [ + "๐Ÿ’ธ Pricey but she's worth it... right? RIGHT?", + "๐Ÿ’ฐ Your wallet is crying but your shelf will be happy", + "๐Ÿ’ณ Credit card-kun is sweating rn", + "๐Ÿ’ธ Expensive? Yes. Worth it? Also yes.", + ], + whale: [ + "๐Ÿ‹ WHALE ALERT. This is commitment.", + "๐Ÿ’Ž Grail-tier pricing. Only for the dedicated.", + "๐Ÿ’ธ๐Ÿ’ธ๐Ÿ’ธ Your bank account will remember this decision.", + "๐Ÿฆ Time to sell a kidney? Worth it honestly.", + "๐Ÿ’ณ Credit card just fainted.", + ], + }, + + // ===== WATCH/SUBSCRIBE ===== + watch: { + added: [ + "โœ… Got it! I'll DM you when **{query}** appears under ยฅ{price}!", + "๐Ÿ”” Subscribed! You'll be first to know about **{query}** deals~", + "๐Ÿ‘€ I'm watching **{query}** for you now. I never sleep. Never blink.", + "๐ŸŽฏ Alert set! I'll ping you faster than scalpers can checkout~", + "๐Ÿ”” **{query}** is on my radar! I'll DM you when it drops!", + ], + already_watching: [ + "๐Ÿ‘€ You're already watching **{query}**! I gotchu~", + "๐Ÿ”” **{query}** is already on your list! Patience, hunter~", + ], + removed: [ + "โŒ Removed **{query}** from your watchlist. Giving up? ๐Ÿ˜ข", + "๐Ÿ”• Unsubscribed from **{query}**. Your wallet thanks you... for now.", + "๐Ÿ‘‹ **{query}** removed. The hunt ends... for now.", + ], + list_header: [ + "๐Ÿ“‹ **Your Watchlist** โ€” I'm hunting these for you:", + "๐Ÿ‘€ **Active Hunts** โ€” Always watching~", + "๐ŸŽฏ **Your Targets** โ€” I never sleep:", + ], + list_empty: [ + "๐Ÿ“‹ Your watchlist is empty! Tell me what to hunt~", + "๐Ÿ‘€ Nothing on your radar yet. What should I watch for?", + "๐ŸŽฏ No active hunts. Give me a target!", + ], + }, + + // ===== HELP ===== + help: [ + "**๐ŸŽŽ WAIFU DEAL SNIPER โ€” How to Use**\n\n" + + "Just chat with me naturally! I understand:\n\n" + + "๐Ÿ” **Searching**\n" + + "โ€ข `looking for rem figures`\n" + + "โ€ข `any sonico bikini under 10000?`\n" + + "โ€ข `find me chainsaw man power`\n\n" + + "๐Ÿ”” **Watch Alerts** (I'll DM you!)\n" + + "โ€ข `watch marin under 15000`\n" + + "โ€ข `alert me for zero two`\n" + + "โ€ข `notify me when gojo appears`\n\n" + + "๐Ÿ“‹ **Manage Watchlist**\n" + + "โ€ข `my watchlist` โ€” see your hunts\n" + + "โ€ข `stop watching rem` โ€” remove alert\n\n" + + "๐Ÿ’ก **Tips**\n" + + "โ€ข I find **\"mint figure, damaged box\"** deals โ€” 40-50% off!\n" + + "โ€ข Be specific: `rem bunny` > just `rem`\n" + + "โ€ข I search AmiAmi's pre-owned section\n\n" + + "*Happy hunting!* ๐ŸŽฏ", + ], + + // ===== ERROR / MISC ===== + errors: { + search_failed: [ + "๐Ÿ˜ต Something went wrong! The site might be down or my brain broke. Try again?", + "๐Ÿ’€ Error! The hunt failed... Let's try again?", + "๐Ÿซ  Oops, something died. Not the waifus though, they're fine.", + "๐Ÿ˜… Technical difficulties! Even the best hunters miss sometimes. Retry?", + ], + slow: [ + "โณ The site is being slow... Must be all the collectors shopping", + "โณ Taking a moment... *taps table impatiently*", + "โณ Loading... The waifu hunt requires patience~", + ], + invalid_price: [ + "๐Ÿค” I couldn't understand that price. Try like: `watch rem under 10000`", + "โ“ Price unclear! Use numbers like: `watch sonico 15000`", + ], + }, + + // ===== FUN FACTS / EASTER EGGS ===== + fun_facts: [ + "๐Ÿ’ก Did you know? The average figure collector has 47 figures and 0 savings.", + "๐Ÿ’ก Fun fact: 'I'll just buy one more' is the biggest lie in the hobby.", + "๐Ÿ’ก Remember: You're not addicted, you're โœจpassionateโœจ", + "๐Ÿ’ก Hot take: Nendoroids are gateway drugs to 1/4 scale bunnies.", + "๐Ÿ’ก Pro tip: Damaged box figures are the secret meta.", + "๐Ÿ’ก Studies show: 100% of figure collectors have excellent taste.", + ], + +}; + +// ===== KEYWORD LISTS ===== +const SPICY_KEYWORDS = [ + 'bikini', 'bunny', 'swimsuit', 'bath', 'lingerie', 'maid', 'nurse', + 'wedding', 'bride', 'naked', 'cast off', 'b-style', 'freeing', + 'oppai', 'ecchi', 'sexy', 'hot', 'thicc', '1/4', 'bare leg', + 'succubus', 'demon girl', 'devil', 'china dress', 'leotard', +]; + +const HUSBANDO_KEYWORDS = [ + 'gojo', 'levi', 'eren', 'sukuna', 'toji', 'nanami', 'geto', 'megumi', + 'deku', 'bakugo', 'todoroki', 'aizawa', 'hawks', + 'kakashi', 'itachi', 'sasuke', 'naruto', 'minato', + 'zoro', 'sanji', 'law', 'ace', 'shanks', + 'diluc', 'zhongli', 'childe', 'ayato', 'alhaitham', 'xiao', + 'cloud', 'sephiroth', 'noctis', 'leon', +]; + +const FIGURE_TYPE_KEYWORDS = { + bunny: ['bunny', 'b-style', 'freeing', 'rabbit'], + bikini: ['bikini', 'swimsuit', 'swim', 'beach', 'summer'], + wedding: ['wedding', 'bride', 'bridal'], + maid: ['maid', 'meido'], + nurse: ['nurse', 'medical'], + school: ['school', 'uniform', 'seifuku'], + racing: ['racing', 'race queen'], + china_dress: ['china dress', 'qipao', 'chinese dress'], + kimono: ['kimono', 'yukata', 'japanese dress'], +}; + +// ===== GACHA MODE ===== +const GACHA_TEMPLATES = { + rolling: [ + "๐ŸŽฐ **GACHA TIME!** Spinning the wheel of fate...", + "๐ŸŽฒ **ROLLING THE DICE!** Your destiny awaits...", + "โœจ **FATE DECIDES!** Let's see what the gacha gods give you...", + "๐ŸŽฐ **GACHA PULL!** Will it be SSR or salt?", + "๐Ÿ”ฎ **THE ORB HAS SPOKEN!** Revealing your destiny...", + "โšก **SUMMONING RITUAL INITIATED!** The spirits are deciding...", + "๐ŸŒ€ **SPINNING THE WHEEL OF BANKRUPTCY!** Here we go...", + "๐Ÿƒ **SHUFFLING THE DECK OF FATE!** What will you draw?", + "๐Ÿ’ซ **CONSULTING THE FIGURE GODS!** They're debating...", + "๐ŸŽช **WELCOME TO THE GACHA CIRCUS!** You're the clown and the audience!", + "๐Ÿ”ฅ **IGNITING THE GACHA FLAMES!** Burn, wallet, burn...", + "๐ŸŒŠ **DIVING INTO THE GACHA ABYSS!** No turning back now...", + "โญ **WISHING UPON A PLASTIC STAR!** Will it come true?", + "๐ŸŽญ **THE GACHA THEATER PRESENTS...** Your financial demise!", + "๐Ÿš€ **LAUNCHING GACHA SEQUENCE!** 3... 2... 1... REGRET!", + "๐ŸŽฏ **AIMING FOR GREATNESS!** (probably hitting mid tho)", + "๐Ÿ’Ž **CRACKING OPEN A GACHA!** Please don't be trash...", + "๐ŸŒˆ **CHASING THE RAINBOW!** (it's probably just salt)", + "๐ŸŽก **ROUND AND ROUND WE GO!** Where your money stops, nobody knows!", + "โš”๏ธ **DRAWING YOUR GACHA SWORD!** Is it Excalibur or a butter knife?", + ], + reveal: [ + "๐ŸŒŸ **THE GACHA GODS HAVE CHOSEN!**\n\nYour destined figure is...", + "โœจ **FATE HAS DECIDED!**\n\nYou are meant to own...", + "๐ŸŽŠ **CONGRATULATIONS!**\n\nThe universe says you need...", + "๐Ÿ’ซ **DESTINY REVEALS!**\n\nYour wallet's fate is sealed with...", + "๐ŸŽฐ **JACKPOT!** (or is it?)\n\nThe gacha has spoken...", + "๐Ÿ”ฎ **THE PROPHECY IS CLEAR!**\n\nYou shall acquire...", + "โšก **LIGHTNING STRIKES!**\n\nThe gods bestow upon you...", + "๐ŸŒ™ **BY THE LIGHT OF THE MOON!**\n\nYour figure emerges...", + "๐ŸŽญ **THE CURTAIN RISES!**\n\nBehold your destiny...", + "๐Ÿ‘๏ธ **THE ALL-SEEING GACHA REVEALS!**\n\nYour fate is...", + "๐ŸŒธ **PETALS FALL, REVEALING...**\n\nThe figure chosen for you...", + "๐Ÿ’€ **FROM THE DEPTHS OF YOUR WALLET...**\n\nRises...", + "๐ŸŽช **AND THE WINNER IS...**\n\n(spoiler: it's always your wallet losing)", + "๐Ÿ”ฅ **EMERGING FROM THE FLAMES!**\n\nYour destined companion...", + "โ„๏ธ **FROZEN IN TIME, NOW THAWED...**\n\nYour gacha result...", + ], + rarity: { + ssr: [ + "๐ŸŒˆ **SSR PULL!** THE GACHA GODS SMILE UPON YOU!", + "๐Ÿ’Ž **ULTRA RARE!** You lucky dog!", + "๐Ÿ‘‘ **LEGENDARY!** Buy a lottery ticket!", + "๐Ÿ† **WHALE TERRITORY!** Your dedication is... concerning but impressive!", + "โญ **FIVE STAR BABY!** Screenshot this for the flex!", + "๐ŸŒŸ **ASCENDED PULL!** The stars aligned for once!", + "๐Ÿ’ซ **COSMIC LUCK!** Did you sell your soul?", + "๐Ÿ”ฅ **BLAZING SSR!** Your luck stat is MAXED!", + "๐Ÿ‘ผ **BLESSED BY THE FIGURE ANGELS!** Hallelujah!", + "๐ŸŽ† **FIREWORKS GO OFF!** THIS IS THE ONE!", + "๐Ÿ’ฐ **MONEY WELL SPENT!** (for once)", + "๐Ÿฆ„ **UNICORN PULL!** Rarer than your social life!", + ], + sr: [ + "โญ **SR PULL!** Not bad, not bad~", + "โœจ **RARE!** The gacha was kind today!", + "๐ŸŒŸ **NICE PULL!** Could be worse!", + "๐Ÿ’ซ **SOLID CHOICE!** The gacha didn't totally scam you!", + "๐ŸŽฏ **HIT THE TARGET!** Well, at least the outer rings...", + "๐Ÿ“ˆ **ABOVE AVERAGE!** Like your taste in figures!", + "๐Ÿฅˆ **SILVER TIER!** Not gold, but hey, shiny!", + "๐ŸŒ™ **MOONLIT PULL!** Decent vibes, decent figure!", + "โœ… **ACCEPTABLE!** Your standards have been met... barely!", + "๐ŸŽ **UNWRAPPED SOMETHING DECENT!** No complaints!", + ], + r: [ + "๐Ÿ“ฆ **R PULL!** It's... something!", + "๐ŸŽ **COMMON!** But hey, a figure is a figure!", + "๐Ÿƒ **STANDARD!** The gacha giveth... meh.", + "๐Ÿ˜ **PARTICIPATION TROPHY!** At least you tried!", + "๐Ÿฅ‰ **BRONZE TIER!** Third place is still a place!", + "๐Ÿ“‰ **BELOW EXPECTATIONS!** But were they ever high?", + "๐ŸŽช **CARNIVAL PRIZE!** You won... something!", + "๐Ÿงธ **BUDGET BLESSED!** Your wallet says thanks?", + "๐Ÿคท **IT IS WHAT IT IS!** Copium loading...", + "๐ŸŽญ **MYSTERY BOX OPENED!** Contents: mid.", + ], + salt: [ + "๐Ÿง‚ **SALT!** The gacha gods mock you!", + "๐Ÿ’€ **F!** Better luck next time...", + "๐Ÿ˜ญ **DESPAIR!** Why do we even roll...", + "๐Ÿ—‘๏ธ **DUMPSTER TIER!** The gacha has forsaken you!", + "๐Ÿ’” **HEARTBREAK!** Your luck has left the chat!", + "๐Ÿคก **CLOWNED!** Honk honk, here's your L!", + "โ˜ ๏ธ **DEAD ON ARRIVAL!** RIP your hopes!", + "๐ŸŒง๏ธ **RAIN OF TEARS!** The forecast: endless salt!", + "๐Ÿ˜ค **RIGGED!** (it's not but let's blame something)", + "๐Ÿชฆ **HERE LIES YOUR LUCK!** Cause of death: gacha!", + "๐ŸŽฐ **THE HOUSE ALWAYS WINS!** And the house is AmiAmi!", + "๐Ÿ’ธ **MONEY EVAPORATED!** Poof! Gone! Vanished!", + "๐Ÿคฎ **GACHA FOOD POISONING!** This taste... is salt!", + "๐Ÿ“‰ **STOCK MARKET CRASH!** But for your luck!", + ], + }, +}; + +// ===== ROAST MODE ===== +const ROAST_TEMPLATES = { + general: [ + "Ah yes, another {query} figure. How terribly original. ๐Ÿ™„", + "Let me guess, you're gonna 'think about it' and then buy it at 3 AM anyway?", + "{query}? Your shelf space called, it's filing for divorce.", + "Ah, {query}. Because your wallet wasn't suffering enough already.", + "Bold of you to assume your bank account has recovered from last time.", + "Sure, let's search for {query}. It's not like you need to pay rent or anything.", + "{query} huh? Tell me you're down bad without telling me you're down bad.", + "Searching {query}... Your future self is already crying.", + "Ah yes, the classic '{query}' search. Originality is dead.", + "{query}? At this point just give AmiAmi your whole paycheck.", + "Looking for {query}? Groundbreaking. Revolutionary. Never seen before. ๐Ÿ™„", + "{query}... Your credit card just flinched.", + "Searching {query}? Your savings account has left the group chat.", + "{query}? Bold move for someone whose shelf is already crying for help.", + "Ah, {query}. I see you've chosen violence today. Against your wallet.", + "{query}? Real original. Let me guess, you also like breathing?", + "Oh wow, {query}. No one has EVER searched that before. You're so unique.", + "{query}... At this point you're not collecting, you're hoarding.", + "Let me search {query} for you, you absolute financial disaster.", + "{query}? Your future spouse is gonna have QUESTIONS about the shrine.", + "Searching {query}... *sigh* Here we go again.", + "{query}? In this economy? With these prices? Incredible decision-making.", + "Ah yes, {query}. Because therapy is expensive but figures are... also expensive.", + "{query}? I'm not mad, I'm just disappointed. Actually no, I'm mad too.", + "Looking for {query}? Your shelf space is writing its resignation letter.", + "{query}... Tell me you're single without telling me you're single.", + "Searching {query} at this hour? Touch grass. Please.", + "{query}? Your financial advisor just felt a chill down their spine.", + "Oh look, {query}. What a surprise. Much shock. Very wow.", + "{query}? Bestie your 'collection' is becoming a 'situation'.", + ], + character_specific: { + rem: [ + "Rem? AGAIN? You know there are OTHER characters, right?", + "Another Rem figure? Bro you could build a Rem army at this point.", + "Rem? Emilia fans are typing...", + "Your Rem collection has its own zip code doesn't it?", + "Rem? Who's Rem? (I'm kidding please don't hurt me)", + "At this point you could open a Rem museum. Charge admission.", + "Rem huh? Down catastrophically bad. Critical levels.", + "Another Rem? The blue maid has you in a chokehold fr fr.", + "Rem figure #47... but who's counting? (You are. You definitely are.)", + "Rem again? Ram erasure is real and you're part of the problem.", + "Your 'Rem appreciation' has become 'Rem obsession' and we need to talk.", + "Rem? Original. Unique. Never been done. (That's sarcasm btw)", + ], + miku: [ + "Miku? There are literally 47,000 Miku figures. Pick a struggle.", + "Another Miku? Your room must sound like a Vocaloid concert.", + "Miku fans when they see literally any figure with twintails: ๐Ÿ‘€", + "At what point does it become a Miku shrine?", + "Miku again? At this point she's your landlord.", + "How many Mikus do you need?? (Trick question, the answer is always 'more')", + "Miku? Let me guess, you cried at the concerts too.", + "Another Miku variant? They really said 'print money' huh.", + "Miku collectors be like: 'I'll just get ONE more version...'", + "Your Miku collection could form a choir. A very expensive choir.", + "At this point Miku should be paying YOU rent.", + "Racing Miku, Snow Miku, Sakura Miku... You have a type. It's teal.", + ], + marin: [ + "Marin? Ah, a fellow My Dress-Up Darling victim I see.", + "Let me guess, you've rewatched the anime 12 times?", + "Marin figure? Your cosplay budget could never.", + "Another Marin? Your wallet is dressed up in PAIN.", + "Marin? The gyaru has your wallet in a death grip.", + "Searching Marin at 2 AM? Down astronomical.", + "Marin figure? Gojo-kun would be disappointed. Or proud. Hard to tell.", + "Another Marin? The seasonal waifu has become permanent I see.", + "Marin fans: 'She's just like me fr fr' (She is not.)", + "Your Marin collection is giving... main character syndrome.", + "Marin? More like Marin-ating your wallet in debt!", + "At this point just cosplay as Marin yourself. Cheaper than figures.", + ], + asuna: [ + "Asuna? SAO was mid but the figures go hard ngl.", + "Another Asuna? Kirito's gonna get jealous.", + "Asuna fans still eating good in 2026. Respect.", + "Asuna huh? Old school waifu energy. I respect it.", + ], + zero_two: [ + "Zero Two? Darling in the Franxx ended years ago. Let go.", + "Another Zero Two? The dinosaur has you fossilized.", + "Zero Two fans refusing to move on. We get it. She said darling.", + "Zero Two? More like Zero money after this purchase.", + ], + power: [ + "Power? The gremlin energy is strong with this one.", + "Another Power figure? Chainsaws and chaos, your type is clear.", + "Power huh? Excellent trash goblin taste.", + "Power collector? Based and deranged. Respect.", + ], + makima: [ + "Makima? Oh no. Ohhhh no. We need to talk about your taste.", + "Another Makima? The manipulation kink is showing.", + "Makima figure? Blink twice if you need help.", + "Makima collector? You DEFINITELY have a type. (It's danger.)", + "Makima huh? Your red flags are showing. All of them.", + ], + nezuko: [ + "Nezuko? mmmMMMMMMM NEZUKO-CHAN!!!", + "Another Nezuko? Tanjiro approves (probably).", + "Nezuko figure? Adorable. Your wallet? Demolished.", + "Nezuko collector? The bamboo gag was foreshadowing for your wallet.", + ], + gojo: [ + "Gojo? The blindfold stays ON during purchase.", + "Another Gojo? Infinity couldn't protect your wallet.", + "Gojo figure? Strongest sorcerer, weakest wallet defense.", + "Gojo huh? Throughout heaven and earth, he alone makes you broke.", + ], + levi: [ + "Levi? The cleaning arc hit different huh.", + "Another Levi? Humanity's strongest, wallet's weakest.", + "Levi figure? Short king supremacy.", + "Levi collector? Clean your room first. He's judging.", + ], + }, + expensive: [ + "ยฅ{price}?! In THIS economy?!", + "ยฅ{price}... that's like {meals} convenience store meals but go off I guess.", + "For ยฅ{price} this figure better do my taxes.", + "ยฅ{price}?? Just say you hate money bro.", + "Imagine explaining ยฅ{price} on a figure to your parents.", + "ยฅ{price}?! That's rent! That's RENT!!!", + "ยฅ{price}... Your wallet just filed a restraining order.", + "For ยฅ{price} I expect this figure to pay its own shipping.", + "ยฅ{price}?? Bezos is taking notes on your spending habits.", + "ยฅ{price}... *nervous laughter* surely you're joking... right?", + "That's ยฅ{price}. Think about that. Really think.", + "ยฅ{price} for plastic. PLASTIC. (gorgeous plastic but still)", + "ยฅ{price}?? Even whales are looking at you concerned.", + "For ยฅ{price} this figure better come with a house.", + "ยฅ{price}... Your ancestors didn't struggle for this.", + "That's {meals} meals. Or one (1) figure. Choose wisely. (You'll choose the figure.)", + ], + cheap: [ + "ยฅ{price}? That's suspiciously cheap... what's wrong with it? ๐Ÿค”", + "ยฅ{price}?? At that price it's either a steal or a scam. No in-between.", + "Only ยฅ{price}? Even I'm tempted ngl...", + "ยฅ{price}? Okay that's actually kinda valid.", + "ยฅ{price}?? The figure gods smile upon the budget collectors today.", + "Only ยฅ{price}? What's the catch? There's always a catch.", + "ยฅ{price}... did someone make a typo or...", + "At ยฅ{price} you're basically LOSING money by NOT buying it. (Don't quote me.)", + "ยฅ{price}? The box must be a war crime or something.", + "ยฅ{price}?? Okay this is actually acceptable. I'm shook.", + ], + soldout: [ + "Sold out? L + Ratio + You hesitated + Someone else's shelf now.", + "SOLD OUT ๐Ÿ’€ Imagine not having notifications on.", + "Gone. Reduced to atoms. Should've been faster.", + "Sold out? Skill issue tbh.", + "Sold out?? *Nelson laugh* HA HA!", + "SOLD OUT. The fastest fingers win and yours were slow.", + "Gone. Just like your chances. Vanished.", + "Sold out? Someone else is unboxing YOUR figure rn.", + "SOLD OUT ๐Ÿ’€ The snooze button has consequences.", + "Sold out... The early bird gets the figure. You got salt.", + "Gone faster than your motivation to save money.", + "SOLD OUT. Hesitation is defeat. - Isshin, probably", + "Sold out? Let me play you a sad song on the world's smallest violin.", + "Out of stock? Congratulations, you played yourself.", + "Sold out... *price is right losing horn*", + "GONE. Someone out there is thanking you for hesitating.", + ], +}; + +// ===== COPIUM MODE ===== +const COPIUM_TEMPLATES = { + sold_out: [ + "๐Ÿ’จ *inhales copium* It wasn't even that good tbh...", + "๐Ÿคก You didn't want it anyway. Your shelf is already full.", + "๐Ÿ˜ค Think of all the money you SAVED by being slow!", + "๐Ÿง˜ The figure chose a different collector. It's fate.", + "๐Ÿ’ญ \"I'll just wait for a rerelease\" - copium maximum", + "๐ŸŽญ At least you still have your... uh... dignity? Maybe?", + "๐Ÿ“ฆ Your other figures would've been jealous anyway.", + "๐ŸŒ™ It's a sign from the universe to save money... lol jk", + "๐Ÿคท Someone else needed it more. You're basically a saint.", + "๐Ÿ’ธ Your wallet is sending a thank you card as we speak.", + "๐Ÿซ  It's fine. This is fine. Everything is FINE.", + "๐Ÿ’ญ The aftermarket will have it. (At 3x the price. It's fine.)", + "๐Ÿง  You're too smart to buy scalped anyway. Big brain.", + "โœจ Main character energy: the figure will come back to you.", + "๐ŸŒˆ There's ALWAYS another figure. Probably. Maybe.", + "๐Ÿ™ This is the universe's way of saying 'save for the grail'.", + "๐Ÿค” Was it even pre-order worthy? (Yes. Yes it was. But cope.)", + "๐Ÿ’ซ Your soulmate figure is still out there. This wasn't it.", + "๐ŸงŠ Cool collectors don't cry over sold out figures. *sniff*", + "๐ŸŽช This is just life's way of building your character arc.", + "๐Ÿ“‰ Think of it as... involuntary financial responsibility.", + "๐ŸŽฒ RNG wasn't on your side. The gacha gods looked away.", + "๐ŸŒธ Let it go~ Let it go~ Can't buy it anymore~", + "๐Ÿ’€ What doesn't kill your wallet makes it stronger. Allegedly.", + "๐Ÿซง Like bubbles, figures come and go. This one just... went.", + ], + expensive: [ + "๐Ÿ’จ *inhales* The quality probably isn't worth it anyway...", + "๐Ÿง˜ Money is temporary, but also so are figures... wait", + "๐Ÿคก \"I have expensive taste\" = \"I'm broke with extra steps\"", + "๐Ÿ’ญ In 10 years you won't even remember this figure... maybe...", + "๐Ÿ˜ค It's mass produced. It's not THAT special. Right? RIGHT?", + "๐ŸŽญ You're not poor, you're just... financially selective.", + "๐Ÿ’ธ Think of all the instant ramen you can buy instead!", + "๐ŸงŠ Being responsible with money is actually kind of based.", + "๐Ÿ“Š The price-to-joy ratio is clearly not optimized here.", + "๐ŸŽช You're paying for the BRAND. The brand! That's all!", + "๐Ÿ’ก What if... you invested this money instead? (lol)", + "๐ŸŒ™ Sleep on it. For like... 6-8 weeks. Then decide.", + "๐Ÿฆด Your skeleton doesn't care what figures you own.", + "๐Ÿค” Can you REALLY tell the difference from bootlegs? (Yes but cope)", + "๐Ÿ’ซ The joy of NOT spending is also a kind of joy. Maybe.", + "๐ŸŽฐ At least you're not gambling... oh wait, gacha exists.", + "๐Ÿ“‰ Think of the depreciation! (Figures don't depreciate but shh)", + "๐Ÿง  Smart collectors wait for sales. You're a SMART collector.", + "๐ŸŒˆ Somewhere, a cheaper version exists. Probably. Hopefully.", + "๐Ÿซ  Your organs are worth more than this figure. Keep them.", + "๐Ÿ’ญ 'Do I want it or do I just want to WANT it?' - philosopher mode", + "๐Ÿ  Houses exist. Cars exist. Retirement exists. Just saying.", + "๐Ÿœ That's like {meals} cup noodles. You LOVE cup noodles. Right?", + ], + no_results: [ + "๐Ÿ’จ *copium clouds forming* Maybe it's a sign to go outside...", + "๐Ÿคก No results? The universe is protecting your wallet!", + "๐Ÿง˜ Sometimes the best figure is the one you didn't buy.", + "๐Ÿ’ญ Maybe try a different search? Or touch grass?", + "๐Ÿ˜ค AmiAmi doesn't deserve your money anyway!", + "๐ŸŽญ This is character development. You're growing.", + "๐ŸŒ™ The figure void stares back... and it's empty.", + "โœจ Congratulations! Your search returned: peace of mind.", + "๐Ÿฆ— *cricket noises* Your wallet applauds the silence.", + "๐ŸŽช Plot twist: the real figure was the friends we made along the way.", + "๐Ÿ“ญ No figures, no problems. (This is a lie but believe it.)", + "๐Ÿง  Your brain didn't need more dopamine anyway. It's fine.", + "๐Ÿ’ซ An empty search means an empty cart. This is winning.", + "๐ŸŒˆ Maybe your grail hasn't been made yet. Patience, grasshopper.", + "๐Ÿซง *poof* Your money stays in your account. Magic!", + "๐ŸŽฒ The search was the journey. The results were always zero.", + "๐Ÿ’€ On the bright side, you can't buy what doesn't exist!", + "๐Ÿค” Maybe branch out? Try... other hobbies? (Impossible but try)", + ], + damaged_box: [ + "๐Ÿ’จ Who even LOOKS at the box after unboxing?", + "๐Ÿคก Box damage = discount = big brain moves", + "๐Ÿ˜ค You're not a box collector, you're a FIGURE collector!", + "๐Ÿ’ญ The figure doesn't know its box is damaged. It's fine.", + "๐Ÿง˜ Imperfect box, perfect figure. This is the way.", + "๐ŸŽญ \"Battle damage\" on the box just adds character.", + "๐Ÿ“ฆ The box's sacrifice means YOUR wallet survives.", + "โœจ Open box collectors are evolved. Accept your evolution.", + "๐Ÿ’ธ You saved money AND the figure is mint? WIN-WIN.", + "๐ŸŒ™ In the dark, all boxes look the same. Think about it.", + "๐Ÿฆด The box is just... a protective husk. Let it go.", + "๐ŸŽช Display the FIGURE. Hide the BOX. Problem solved.", + "๐Ÿ’ซ Damaged box? More like DISCOUNTED BOX. Rebrand it.", + "๐Ÿง  Only nerds keep the boxes anyway. (You keep them but whatever)", + "๐ŸŒˆ The figure is perfect. The box took one for the team.", + "๐Ÿซ  Boxes are temporary. Figures are... also temporary. But still!", + "๐Ÿ“ญ AmiAmi's box grading system is just capitalism anyway.", + "๐Ÿค” Will YOU be graded when you're old and creased? Exactly.", + "๐Ÿ’€ Box-kun died so your wallet could live. Honor the sacrifice.", + "๐ŸŽฒ The figure said 'I'm worth it even if my house is ugly'.", + ], + general: [ + "๐Ÿ’จ *maximum copium* This is fine. Everything is fine.", + "๐Ÿง˜ Deep breaths. In with the copium, out with the reality.", + "๐Ÿคก At least you have your health? (And crippling figure addiction)", + "๐Ÿ’ญ Money comes back. Time doesn't. Or wait, is it the other way?", + "๐Ÿ˜ค Other people have worse problems. Like... no figures at all.", + "๐ŸŽญ This is all part of God's plan. The plan is chaos.", + "๐Ÿ’ซ The universe works in mysterious ways. Mostly against you.", + "๐ŸŒ™ Tomorrow is a new day. With new figures. To not afford.", + "๐Ÿง  You're not addicted. You can stop anytime. ANYTIME.", + "๐ŸŒˆ It's not a problem if you enjoy it. (It's still a problem.)", + "๐Ÿ’ธ Money is just... numbers. Fake. Not real. (It's real.)", + "๐Ÿซง Float away on your copium cloud. It's safe there.", + "๐ŸŽช Life is a circus and you're the star. Of the finance tragedy show.", + "๐Ÿ’€ We're all gonna make it. Eventually. Maybe.", + "โœจ Manifesting good deals and strong yen. ๐Ÿ™", + ], +}; + +module.exports = { + TEMPLATES, + SPICY_KEYWORDS, + HUSBANDO_KEYWORDS, + FIGURE_TYPE_KEYWORDS, + GACHA_TEMPLATES, + ROAST_TEMPLATES, + COPIUM_TEMPLATES, +};