commit 2fe0082973b773559ebab6764dc6645186977620 Author: Justin Oro Date: Sat Aug 29 17:00:21 2026 -0700 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1f6a391 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +DISCORD_BOT_TOKEN= +ITAD_API_KEY= +STEAM_API_KEY= +OLLAMA_MODEL=llama3.2 +AI_ENABLED=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b170b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +.env +games.json +owned.json +steamlinks.json +.link-steam-profiles-owner.json +bot.log +bot.error.log +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ec85df6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 justinoros + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b8ac5d --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# Discord Game Price Bot + +A Discord bot for your server. Type `!watch GAME NAME` in any channel to start tracking a PC game's price, `!remove GAME NAME` (or `!unwatch GAME NAME`) to stop, `!list` to see what is being watched, `!price GAME NAME` to check a price without watching it, `!history GAME NAME` to see every recorded sale over the past 12 months with date and price, `!predict GAME NAME` to guess when it might go on sale next, `!info GAME NAME` to see its platforms, multiplayer/co-op/cross-play info, a link to its store page, and how many people on the server own it, `!own GAME NAME` to mark a game as owned by you, `!unown GAME NAME` to remove it from your owned list, `!my-steam-profile LINK` to link your Steam profile so `!info` can count you automatically, `!bulk-link` to link several members' Steam profiles at once if you already have them, `!who GAME NAME` to list who has confirmed they own a game, `!check` to run the sale check on demand, `!shops` to see which stores it covers. Once a day it checks the best current price across PC stores for everything watched and posts in the channel it was added from when something is on sale, with the title, sale price, and which store it is at. + +Mention the bot's trigger word anywhere in a message (that doesn't start with `!`) and, if Ollama is installed and running (and `AI_ENABLED` isn't turned off), it thinks up a short, in-character answer to whatever you said using a free AI model running right on your own computer - no API key, no cost, nothing sent anywhere over the internet. Without Ollama running (or with `AI_ENABLED=false`), it doesn't reply to mentions at all. It never fires on `!` commands, even ones that happen to contain the trigger word. The bot's name, trigger word, and personality are entirely up to you - edit `personality.yaml` to set them (see [Notes](#notes)); it ships pre-filled as BMO from Adventure Time as an example. + +When someone new joins the server, it posts a random greeting from `greetings.yaml` in your #general channel. When someone leaves, it posts a random goodbye from `goodbyes.yaml` there too. + +It runs as a background service on a computer you leave on (a Mac, Linux box, or Windows PC all work), since a bot that reacts to typed commands has to stay connected to Discord all the time. There is no website and no cloud hosting involved. + +## Setup + +### 1. Create the Discord bot + +1. Go to [discord.com/developers/applications](https://discord.com/developers/applications) and click New Application. Name it whatever you like. +2. Go to the Bot tab, click Add Bot. +3. Under Privileged Gateway Intents, turn on "Message Content Intent" (required to read `!watch` style commands) and "Server Members Intent" (required to detect when someone joins and send a greeting). Click Save Changes. +4. Click Reset Token, copy it. This is your `DISCORD_BOT_TOKEN`, keep it secret. +5. Go to OAuth2 > URL Generator, check the "bot" scope, then under Bot Permissions check "Send Messages" and "Read Message History". A URL appears at the bottom of the page. +6. This step is required and easy to miss: copy that URL, paste it into your browser, press Enter, pick your server, and click Authorize. Creating the bot in steps 1 to 5 does not add it to your server, it only exists in the developer portal until you do this. + +### 2. Get a free IsThereAnyDeal API key + +Register a free account at [isthereanydeal.com](https://isthereanydeal.com), then go to [isthereanydeal.com/apps/](https://isthereanydeal.com/apps/) and click Register App to get a key. Free, instant, no card needed. This is your `ITAD_API_KEY`. + +### 3. Get a free Steam Web API key (optional) + +Only needed if you want `!my-steam-profile` and automatic Steam ownership counts on `!info`. Go to [steamcommunity.com/dev/apikey](https://steamcommunity.com/dev/apikey), sign in, enter any domain name (e.g. `localhost`), and submit. This is your `STEAM_API_KEY`. You can skip this during setup and add it to `.env` later if you change your mind. + +### 4. Install Ollama for the bot's AI answers (optional, free) + +Only needed for the bot to give smart, made-up-on-the-spot answers when asked a question in chat - completely free, since it runs a small AI model locally instead of calling a paid cloud API. The installer in the next step will offer to install [Ollama](https://ollama.com) for you and download the model, so you can skip this step and let it handle everything. If you'd rather do it yourself first, download it from [ollama.com/download](https://ollama.com/download). Skip this entirely if you don't want the bot replying to chat mentions at all - you can always set it up later and just run the installer again. + +### 5. Install and run it + +Works the same way on macOS, Linux, and Windows - a background process manager called [pm2](https://pm2.keymetrics.io) keeps the bot running and automatically restarts it if it ever crashes or the computer reboots. + +You need [Node.js](https://nodejs.org) installed first (18 or newer). On macOS or Linux with Homebrew, or on Windows with `winget`, the installer will offer to install it for you. + +**On macOS or Linux**, in Terminal: + +``` +cd discord-game-price-bot +bash install.sh +``` + +**On Windows**, in PowerShell: + +``` +cd discord-game-price-bot +powershell -ExecutionPolicy Bypass -File install.ps1 +``` + +Either way, it will ask for your Discord bot token, your IsThereAnyDeal key, and (optionally) your Steam Web API key the first time (nothing is shown or saved anywhere except your local `.env` file), offer to install Ollama and download its AI model if you don't have it yet, install dependencies, and set the bot up as a background service that starts on login/boot and restarts itself if it ever crashes. That is the whole setup. + +In your Discord server, try `!watch Hades` and `!list` to confirm it is working. + +Logs go to `bot.log` and `bot.error.log` inside the project folder, useful if a command does not seem to be working. `pm2 logs discord-game-price-bot` shows the same thing live in the terminal, and `pm2 status` shows whether it's currently running. + +To stop the bot: + +``` +pm2 stop discord-game-price-bot +``` + +To change the token or key later, edit `.env` directly, then run the installer again (`bash install.sh` or the PowerShell command above) to restart it with the new values. Re-running it any time (for example after editing `index.js`) is safe, it just reinstalls and restarts the service. + +## Notes + +- Price data comes from [IsThereAnyDeal](https://isthereanydeal.com), which covers Steam, GOG, Epic, Humble, Fanatical, and most other PC stores. `!watch` and the daily check always pick whichever store currently has the lowest price, and every price shown says which store it is at. Type `!shops` to see the live, current list of every store it checks. +- `!watch` matches the top search result for whatever you typed, and tells you what it matched so you can catch a wrong pick early. +- The daily check runs at 8am Arizona time. To change it, edit the `cron.schedule("0 15 * * *", ...)` line in `index.js` (that time is in UTC). +- The daily check only alerts once per discount level per game. If the price drops further while still on sale, you get a new alert. If it goes back to full price and later goes on sale again, that is a new alert too. +- Everything watched is stored in `games.json` in this folder, in plain text, so you can also open it directly if you ever want to see or edit the list by hand. +- `games.json`, `owned.json`, `steamlinks.json`, and `.env` hold your server's actual data and are left out of git (`.gitignore`) so they never end up in a public repo by accident. The installer creates the three JSON files from their `.json.example` templates automatically if they don't exist yet. +- The installer manages the bot with [pm2](https://pm2.keymetrics.io), so once it's set up you can check on it with plain pm2 commands any time: `pm2 status` (is it running), `pm2 logs discord-game-price-bot` (live logs), `pm2 restart discord-game-price-bot`, `pm2 stop discord-game-price-bot`, and `pm2 delete discord-game-price-bot` (remove it from pm2 entirely - re-run the installer to add it back). +- If you're upgrading from an older version of this bot that used launchd directly on macOS, `install.sh` detects and automatically removes that old service the first time you run it, so you don't end up with two copies of the bot running and double-replying in Discord. +- To add, remove, or change greetings, edit `greetings.yaml`. Use `{user}` anywhere in a greeting and it gets replaced with a mention of the person who joined. +- Same for goodbyes: edit `goodbyes.yaml`. Use `{user}` there too, it gets replaced with the username of whoever left (a plain name, not a mention, since Discord can't ping someone no longer in the server). +- The join greeting posts to whichever channel is named `general` in your server. To use a different channel, change `GREETING_CHANNEL_NAME` near the top of `index.js` to that channel's name (no `#`) and restart it (re-run the installer, or `pm2 restart discord-game-price-bot`). If no channel with that name exists, nothing posts and `bot.error.log` will note it. +- `!info` gets platforms and Multiplayer/Co-Op/Cross-Play from that game's Steam store page when it has one. For non-Steam games (GOG/Epic exclusives etc.) there is no such lookup available, so "not listed" there means "couldn't confirm it," not "definitely single-player only." +- The ownership count on `!info` combines two sources: anyone who ran `!own` for that game, plus anyone who linked their Steam profile with `!my-steam-profile` and actually owns it there. Discord itself has no concept of game ownership, so there is no fully automatic way to get this - `!own` needs people to mark their own games by hand, and `!my-steam-profile` only auto-detects Steam copies (not GOG/Epic) and only if that person's Steam profile has "game details" set to public. Everything self-reported through `!own` is stored in `owned.json`; Steam profile links are stored in `steamlinks.json`, both in this folder, in plain text. +- If you don't want to make each person run `!my-steam-profile` themselves, there are two ways to link them yourself: + - From the terminal (the easiest way if you already know your server members as Steam friends): run `bash install.sh --link-steam-profiles` (macOS/Linux) or `powershell -ExecutionPolicy Bypass -File install.ps1 -LinkSteamProfiles` (Windows). The first time, it asks for your own Steam profile once (to read your friends list from - it needs your Steam friends list privacy set to public, and remembers your profile after that so it won't ask again). Then it lists your Discord server's members for you to pick one by number, lists your Steam friends for you to pick one by number, links them, and asks if you want to link another (defaults to no). This does not restart the bot, so there is no need to run the installer again afterward. + - From Discord, use `!bulk-link` instead: type `!bulk-link`, then on the following lines list one real @mention and Steam profile URL per line, like: + ``` + !bulk-link + @Alex https://steamcommunity.com/id/alexname + @Sam https://steamcommunity.com/profiles/76561198012345678 + ``` + Each @mention has to actually be picked from Discord's autocomplete popup while typing (so it turns into a real link, shown highlighted) - plain typed text like `@Alex` that was never selected from that popup will not be recognized, and that line will be skipped. It replies back with who it managed to link and which lines it could not, so you can fix and re-run just those. +- The bot only replies to mentions of its trigger word if Ollama is installed, running, and has the model in `OLLAMA_MODEL` downloaded - without that, mentioning it gets no reply at all, and nothing is logged (check `bot.error.log` if you expect it to be working and it isn't replying). Each person can only trigger an AI answer once every 15 seconds; asking again sooner (or the Ollama request failing) just gets no reply rather than a second request, which keeps a burst of questions from bogging down your computer generating several replies at once. +- `OLLAMA_MODEL` in `.env` controls which model it uses (`llama3.2` by default - small, fast, and good enough for short in-character replies). To use a different one, pull it yourself with `ollama pull MODEL_NAME`, set `OLLAMA_MODEL=MODEL_NAME` in `.env`, and restart it (re-run the installer, or `pm2 restart discord-game-price-bot`). Bigger models give better answers but take longer to reply and use more of your computer's memory. +- It remembers each person's last 3 exchanges (their message and its reply) so it can follow up naturally and stick to things you told it earlier in the conversation, like a correction or a preference. This memory is per-person, kept only in the bot's running memory (not saved to disk), and forgotten automatically after 30 minutes of that person not mentioning it. +- The bot's name, trigger word, and personality all come from `personality.yaml`. It ships set up as BMO from Adventure Time: + ``` + name: BMO + trigger: bmo + persona: | + You are BMO, the small handheld game console robot from Adventure Time. You are cheerful, innocent, + a little literal-minded, and love games and your friends Finn and Jake. Playful, warm, a little silly, + occasionally saying things like "Oh my glob" or calling the user "buddy" or "friend". + ``` + Change `name` to whatever the bot should be called (used in a couple of log messages), `trigger` to the word that should make it reply (matched as a whole word, case-insensitive, anywhere in a message), and `persona` to a description of how it should talk and act - write it like you're describing a character to someone. It always stays an expert on this README and keeps replies to one short sentence, no matter what personality you give it. Edit the file and restart it (re-run the installer, or `pm2 restart discord-game-price-bot`) to pick up the changes. `greetings.yaml` and `goodbyes.yaml` have their own sample lines written in BMO's voice too - edit those separately if you want the join/leave messages to match a different personality. +- To turn the AI answers off without uninstalling Ollama (for example if you want your computer's resources back for something else), set `AI_ENABLED=false` in `.env` and restart it (re-run the installer, or `pm2 restart discord-game-price-bot`). Set it back to `true` (or remove the line entirely) to turn it back on. This only affects mention replies - everything else keeps working either way. + +## License + +[MIT](LICENSE) diff --git a/games.json.example b/games.json.example new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/games.json.example @@ -0,0 +1 @@ +[] diff --git a/goodbyes.yaml b/goodbyes.yaml new file mode 100644 index 0000000..b17821c --- /dev/null +++ b/goodbyes.yaml @@ -0,0 +1,11 @@ +goodbyes: + - "Aw man, {user} left the game. Save file corrupted." + - "{user} has disconnected. Connection lost, buddy." + - "Goodbye, {user}! BMO will miss pressing your buttons." + - "{user} logged off. BMO's battery feels a little lower now." + - "Achievement lost: {user} left the server." + - "{user} went on an adventure without us. Come back soon!" + - "Oh my glob, {user} is gone! BMO needs a hug." + - "Game over for {user}... for now. See you next load, buddy!" + - "{user} quit the party. BMO hopes it wasn't something BMO said." + - "Farewell, {user}! This does not compute, but BMO understands." diff --git a/greetings.yaml b/greetings.yaml new file mode 100644 index 0000000..722ce78 --- /dev/null +++ b/greetings.yaml @@ -0,0 +1,11 @@ +greetings: + - "Oh my glob, {user} is here! Let's play video games together!" + - "New player has entered the game! Welcome, {user}!" + - "BMO is happy! {user} joined the server. This does compute!" + - "Hooray! {user} arrived! Now we can be a full party." + - "Beep boop, {user} detected! Initiating friendship protocol." + - "{user}, welcome home! BMO has been waiting for a new friend." + - "A wild {user} appeared! Use the combo move to say hi!" + - "{user} joined! BMO's battery is at one hundred percent excitement." + - "Welcome, {user}! Do you wanna play video games?" + - "Achievement unlocked: {user} joined the server! Great job, buddy!" diff --git a/index.js b/index.js new file mode 100644 index 0000000..fb0f696 --- /dev/null +++ b/index.js @@ -0,0 +1,1263 @@ +const path = require("path"); +require("dotenv").config({ path: path.join(__dirname, ".env") }); +const fs = require("fs"); +const cron = require("node-cron"); +const yaml = require("js-yaml"); +const { Client, GatewayIntentBits, MessageFlags, EmbedBuilder } = require("discord.js"); + +const rawLog = console.log.bind(console); +const rawError = console.error.bind(console); +console.log = (...args) => rawLog(new Date().toISOString(), ...args); +console.error = (...args) => rawError(new Date().toISOString(), ...args); + +const DISCORD_BOT_TOKEN = process.env.DISCORD_BOT_TOKEN; +const ITAD_API_KEY = process.env.ITAD_API_KEY; +const STEAM_API_KEY = process.env.STEAM_API_KEY; +const AI_ENABLED = process.env.AI_ENABLED !== "false"; +const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434"; +const OLLAMA_MODEL = process.env.OLLAMA_MODEL || "llama3.2"; +const AI_COOLDOWN_MS = 5000; +const GREETING_CHANNEL_NAME = "general"; +const ITAD_BASE = "https://api.isthereanydeal.com"; +const GAMES_PATH = path.join(__dirname, "games.json"); +const OWNED_PATH = path.join(__dirname, "owned.json"); +const STEAMLINKS_PATH = path.join(__dirname, "steamlinks.json"); +const GREETINGS_PATH = path.join(__dirname, "greetings.yaml"); +const GOODBYES_PATH = path.join(__dirname, "goodbyes.yaml"); +const README_PATH = path.join(__dirname, "README.md"); +const PERSONALITY_PATH = path.join(__dirname, "personality.yaml"); +const MONTH_NAMES = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December" +]; + +if (!DISCORD_BOT_TOKEN || !ITAD_API_KEY) { + console.error("DISCORD_BOT_TOKEN and ITAD_API_KEY must be set in .env"); + process.exit(1); +} + +function loadReadme() { + try { + return fs.readFileSync(README_PATH, "utf8"); + } catch (err) { + return ""; + } +} + +const README_CONTENT = loadReadme(); + +function escapeRegex(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function loadPersonality() { + const data = yaml.load(fs.readFileSync(PERSONALITY_PATH, "utf8")); + return { + name: (data && data.name) || "Assistant", + trigger: (data && data.trigger) || "assistant", + persona: (data && data.persona) || "" + }; +} + +const PERSONALITY = loadPersonality(); +const TRIGGER_MENTION = new RegExp("\\b" + escapeRegex(PERSONALITY.trigger) + "\\b", "i"); + +const AI_SYSTEM_PROMPT = + PERSONALITY.persona.trim() + "\n\n" + + "You are running inside a Discord bot and you are the resident expert on it - you know every command " + + "and feature described in the README below, and can help people figure out how to use the bot. " + + "Reply in character, and keep it extremely short: never more than one sentence, ideally just a few " + + "words. Stay in character and do not mention that you are an AI, a language model, or any of the " + + "technical details behind you (Ollama, models, prompts, tokens, code, servers, and so on) - as far as " + + "you're concerned you just know things, the same way a real character would. If the person you are " + + "talking to has told you their name, a preference, or asked you to stop doing something (like using a " + + "certain nickname), always follow that - it overrides your default personality habits from then on in " + + "this conversation.\n\n" + + "Here is the bot's README, for reference when someone asks how to use something:\n" + README_CONTENT; + +const aiCooldowns = new Map(); +const aiHistories = new Map(); +const AI_HISTORY_EXCHANGES = 3; +const AI_HISTORY_TTL_MS = 30 * 60 * 1000; + +function canUseAI(userId) { + const now = Date.now(); + const last = aiCooldowns.get(userId) || 0; + if (now - last < AI_COOLDOWN_MS) return false; + aiCooldowns.set(userId, now); + return true; +} + +function getAiHistory(userId) { + const entry = aiHistories.get(userId); + if (!entry) return []; + if (Date.now() - entry.lastActive > AI_HISTORY_TTL_MS) { + aiHistories.delete(userId); + return []; + } + return entry.messages; +} + +function rememberAiExchange(userId, question, reply) { + const messages = getAiHistory(userId).concat( + { role: "user", content: question }, + { role: "assistant", content: reply } + ); + while (messages.length > AI_HISTORY_EXCHANGES * 2) { + messages.shift(); + } + aiHistories.set(userId, { messages, lastActive: Date.now() }); +} + +function trimIncompleteSentence(text) { + const trimmed = text.trim(); + if (/[.!?]["')]*$/.test(trimmed)) return trimmed; + const lastEnd = Math.max(trimmed.lastIndexOf("."), trimmed.lastIndexOf("!"), trimmed.lastIndexOf("?")); + if (lastEnd === -1) return trimmed; + return trimmed.slice(0, lastEnd + 1); +} + +async function askAI(question, history, displayName) { + const res = await fetch(OLLAMA_URL + "/api/chat", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + model: OLLAMA_MODEL, + stream: false, + options: { num_predict: 120 }, + messages: [ + { role: "system", content: AI_SYSTEM_PROMPT }, + { role: "system", content: "The Discord member you are talking to is called " + displayName + ". Do not address them by any other name." }, + ...history, + { role: "user", content: question } + ] + }) + }); + if (!res.ok) { + const body = await res.text(); + throw new Error("Ollama request failed: " + res.status + " " + body); + } + const data = await res.json(); + const reply = data.message && data.message.content; + return reply ? trimIncompleteSentence(reply) : null; +} + +function loadGreetings() { + const data = yaml.load(fs.readFileSync(GREETINGS_PATH, "utf8")); + return (data && data.greetings) || []; +} + +const GREETINGS = loadGreetings(); + +function loadGoodbyes() { + const data = yaml.load(fs.readFileSync(GOODBYES_PATH, "utf8")); + return (data && data.goodbyes) || []; +} + +const GOODBYES = loadGoodbyes(); + +function loadGames() { + return JSON.parse(fs.readFileSync(GAMES_PATH, "utf8")); +} + +function saveGames(games) { + fs.writeFileSync(GAMES_PATH, JSON.stringify(games, null, 2) + "\n"); +} + +function loadOwned() { + return JSON.parse(fs.readFileSync(OWNED_PATH, "utf8")); +} + +function saveOwned(owned) { + fs.writeFileSync(OWNED_PATH, JSON.stringify(owned, null, 2) + "\n"); +} + +function loadSteamLinks() { + return JSON.parse(fs.readFileSync(STEAMLINKS_PATH, "utf8")); +} + +function saveSteamLinks(links) { + fs.writeFileSync(STEAMLINKS_PATH, JSON.stringify(links, null, 2) + "\n"); +} + +async function searchGame(title) { + const url = ITAD_BASE + "/games/search/v1?key=" + ITAD_API_KEY + + "&title=" + encodeURIComponent(title) + "&results=5"; + const res = await fetch(url); + if (!res.ok) throw new Error("search failed: " + res.status); + return res.json(); +} + +function normalizeTitle(str) { + return str.toLowerCase().replace(/[®™©]/g, "").replace(/\s+/g, " ").trim(); +} + +function pickSearchMatch(query, results) { + if (!results || results.length === 0) return null; + + const needle = normalizeTitle(query); + const exact = results.find((r) => normalizeTitle(r.title) === needle); + if (exact) return exact; + + const containing = results.filter((r) => normalizeTitle(r.title).includes(needle)); + if (containing.length > 0) { + containing.sort((a, b) => a.title.length - b.title.length); + return containing[0]; + } + + return results[0]; +} + +async function fetchPrices(ids) { + const url = ITAD_BASE + "/games/prices/v3?key=" + ITAD_API_KEY + "&country=US"; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(ids) + }); + if (!res.ok) throw new Error("prices failed: " + res.status); + return res.json(); +} + +async function fetchShops() { + const url = ITAD_BASE + "/service/shops/v1?key=" + ITAD_API_KEY; + const res = await fetch(url); + if (!res.ok) throw new Error("shops failed: " + res.status); + return res.json(); +} + +function toItadDateTime(date) { + return date.toISOString().split(".")[0] + "+00:00"; +} + +async function fetchSteamDetails(appid) { + const url = "https://store.steampowered.com/api/appdetails?appids=" + appid + + "&cc=us&filters=categories,platforms"; + const res = await fetch(url); + if (!res.ok) throw new Error("steam appdetails failed: " + res.status); + const data = await res.json(); + const entry = data && data[appid]; + if (!entry || !entry.success || !entry.data) return null; + + const categories = (entry.data.categories || []).map((c) => c.description); + + const flags = entry.data.platforms || {}; + const platforms = []; + if (flags.windows) platforms.push("Windows"); + if (flags.mac) platforms.push("Mac"); + if (flags.linux) platforms.push("Linux"); + + return { categories: categories, platforms: platforms }; +} + +function parseSteamProfileInput(input) { + const trimmed = input.trim(); + let m = trimmed.match(/steamcommunity\.com\/profiles\/(\d{17})/i); + if (m) return { type: "id", value: m[1] }; + m = trimmed.match(/steamcommunity\.com\/id\/([^\/\s]+)/i); + if (m) return { type: "vanity", value: m[1] }; + if (/^\d{17}$/.test(trimmed)) return { type: "id", value: trimmed }; + return { type: "vanity", value: trimmed }; +} + +async function resolveSteamId(input) { + const parsed = parseSteamProfileInput(input); + if (parsed.type === "id") return parsed.value; + + const url = "https://api.steampowered.com/ISteamUser/ResolveVanityURL/v0001/?key=" + + STEAM_API_KEY + "&vanityurl=" + encodeURIComponent(parsed.value); + const res = await fetch(url); + if (!res.ok) throw new Error("resolve vanity url failed: " + res.status); + const data = await res.json(); + if (data.response && data.response.success === 1) return data.response.steamid; + return null; +} + +async function fetchOwnedAppIds(steamId) { + const url = "https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key=" + + STEAM_API_KEY + "&steamid=" + steamId + "&format=json&include_played_free_games=true"; + const res = await fetch(url); + if (!res.ok) throw new Error("owned games failed: " + res.status); + const data = await res.json(); + const games = (data.response && data.response.games) || []; + return games.map((g) => g.appid); +} + +async function fetchGameInfo(id) { + const url = ITAD_BASE + "/games/info/v2?key=" + ITAD_API_KEY + "&id=" + encodeURIComponent(id); + const res = await fetch(url); + if (!res.ok) { + const body = await res.text(); + throw new Error("info failed: " + res.status + " " + body); + } + return res.json(); +} + +async function fetchHistorySince(id, since) { + const url = ITAD_BASE + "/games/history/v2?key=" + ITAD_API_KEY + + "&id=" + encodeURIComponent(id) + "&country=US&since=" + encodeURIComponent(since); + const res = await fetch(url); + if (!res.ok) { + const body = await res.text(); + throw new Error("history failed: " + res.status + " " + body); + } + return res.json(); +} + +async function fetchHistory(id) { + const since = toItadDateTime(new Date(Date.now() - 366 * 24 * 60 * 60 * 1000)); + return fetchHistorySince(id, since); +} + +async function fetchFullHistory(id) { + const since = toItadDateTime(new Date("2000-01-01T00:00:00Z")); + return fetchHistorySince(id, since); +} + +function pickBestDeal(deals) { + if (!deals || deals.length === 0) return null; + return deals.reduce((best, d) => { + if (!best) return d; + return d.price.amountInt < best.price.amountInt ? d : best; + }, null); +} + +function formatMoney(cents) { + return "$" + (cents / 100).toFixed(2); +} + +async function handleWatch(message, query) { + if (!query) { + await message.reply("Usage: !watch GAME NAME"); + return; + } + + let results; + try { + results = await searchGame(query); + } catch (err) { + await message.reply("Search failed, try again in a moment."); + return; + } + + if (!results || results.length === 0) { + await message.reply("Could not find a game called \"" + query + "\"."); + return; + } + + const match = pickSearchMatch(query, results); + const games = loadGames(); + + if (games.some((g) => g.id === match.id)) { + await message.reply("\"" + match.title + "\" is already being watched."); + return; + } + + games.push({ + id: match.id, + title: match.title, + channelId: message.channel.id, + addedDate: new Date().toISOString().slice(0, 10), + lastAlertedPrice: null + }); + saveGames(games); + + let priceNote = ""; + try { + const priceData = await fetchPrices([match.id]); + const entry = priceData && priceData[0]; + const deal = entry && pickBestDeal(entry.deals); + if (deal) { + const cutNote = deal.cut > 0 ? " (" + deal.cut + "% off right now)" : ""; + priceNote = " Current best price: " + formatMoney(deal.price.amountInt) + cutNote + + " at " + deal.shop.name + ".\n" + deal.url; + } else { + priceNote = " Could not find a current listing for it yet."; + } + } catch (err) { + priceNote = ""; + } + + await message.reply({ + content: "Now watching \"" + match.title + "\" for sales." + priceNote, + flags: MessageFlags.SuppressEmbeds + }); +} + +async function handlePrice(message, query) { + if (!query) { + await message.reply("Usage: !price GAME NAME"); + return; + } + + let results; + try { + results = await searchGame(query); + } catch (err) { + await message.reply("Search failed, try again in a moment."); + return; + } + + if (!results || results.length === 0) { + await message.reply("Could not find a game called \"" + query + "\"."); + return; + } + + const match = pickSearchMatch(query, results); + + try { + const priceData = await fetchPrices([match.id]); + const entry = priceData && priceData[0]; + const deal = entry && pickBestDeal(entry.deals); + if (!deal) { + await message.reply("\"" + match.title + "\" - could not find a current listing for it."); + return; + } + const cutNote = deal.cut > 0 ? " (" + deal.cut + "% off)" : ""; + await message.reply({ + content: "\"" + match.title + "\": " + formatMoney(deal.price.amountInt) + cutNote + + " at " + deal.shop.name + "\n" + deal.url, + flags: MessageFlags.SuppressEmbeds + }); + } catch (err) { + await message.reply("Could not load the price right now."); + } +} + +async function handleHistory(message, query) { + if (!query) { + await message.reply("Usage: !history GAME"); + return; + } + + let results; + try { + results = await searchGame(query); + } catch (err) { + await message.reply("Search failed, try again in a moment."); + return; + } + + if (!results || results.length === 0) { + await message.reply("Could not find a game called \"" + query + "\"."); + return; + } + + const match = pickSearchMatch(query, results); + + let history; + try { + history = await fetchHistory(match.id); + } catch (err) { + console.error("History fetch failed:", err.message); + await message.reply("Could not load sale history right now."); + return; + } + + const sales = (history || []).filter((h) => h.deal && h.deal.cut > 0); + if (sales.length === 0) { + await message.reply("\"" + match.title + "\" has no recorded sales."); + return; + } + + sales.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); + + const lines = sales.map((h) => { + const date = h.timestamp.slice(0, 10); + const price = formatMoney(h.deal.price.amountInt); + const regular = formatMoney(h.deal.regular.amountInt); + return "- " + date + ": " + price + " (" + h.deal.cut + "% off, was " + regular + ") at " + h.shop.name; + }); + + const header = "Sale history for \"" + match.title + "\", past 12 months (" + sales.length + " events):\n"; + const full = header + lines.join("\n"); + + if (full.length <= 1900) { + await message.reply(full); + return; + } + + let chunk = header; + for (const line of lines) { + const candidate = chunk + line + "\n"; + if (candidate.length > 1900) { + await message.reply(chunk); + chunk = line + "\n"; + } else { + chunk = candidate; + } + } + if (chunk) { + await message.reply(chunk); + } +} + +function describeSaleTiming(sales) { + const monthYears = {}; + const monthDays = {}; + const years = new Set(); + + const seenYearMonth = new Set(); + for (const s of sales) { + const d = new Date(s.timestamp); + const year = d.getUTCFullYear(); + const month = d.getUTCMonth() + 1; + const day = d.getUTCDate(); + years.add(year); + + const key = year + "-" + month; + if (!seenYearMonth.has(key)) { + seenYearMonth.add(key); + monthYears[month] = (monthYears[month] || 0) + 1; + } + if (!monthDays[month]) monthDays[month] = []; + monthDays[month].push(day); + } + + const totalYears = years.size; + const threshold = Math.max(1, Math.ceil(totalYears * 0.4)); + + const qualifying = Object.keys(monthYears) + .map(Number) + .filter((m) => monthYears[m] >= threshold); + + if (qualifying.length === 0) return null; + + const currentMonth = new Date().getUTCMonth() + 1; + let targetMonth = null; + for (let offset = 1; offset <= 12; offset++) { + const candidate = ((currentMonth - 1 + offset) % 12) + 1; + if (qualifying.includes(candidate)) { + targetMonth = candidate; + break; + } + } + if (!targetMonth) return null; + + const days = monthDays[targetMonth]; + const avgDay = days.reduce((a, b) => a + b, 0) / days.length; + let qualifier; + if (avgDay <= 10) qualifier = "early"; + else if (avgDay <= 20) qualifier = "mid"; + else qualifier = "late"; + + return qualifier + " " + MONTH_NAMES[targetMonth - 1]; +} + +async function handlePredict(message, query) { + if (!query) { + await message.reply("Usage: !predict GAME"); + return; + } + + let results; + try { + results = await searchGame(query); + } catch (err) { + await message.reply("Search failed, try again in a moment."); + return; + } + + if (!results || results.length === 0) { + await message.reply("Could not find a game called \"" + query + "\"."); + return; + } + + const match = pickSearchMatch(query, results); + + let history; + try { + history = await fetchFullHistory(match.id); + } catch (err) { + console.error("Predict history fetch failed:", err.message); + await message.reply("Could not load sale history right now."); + return; + } + + const sales = (history || []).filter((h) => h.deal && h.deal.cut > 0); + if (sales.length < 3) { + await message.reply("\"" + match.title + "\" does not have enough sale history yet to make a good guess."); + return; + } + + const timing = describeSaleTiming(sales); + if (!timing) { + await message.reply("\"" + match.title + "\" does not show a clear enough sale pattern to predict."); + return; + } + + await message.reply( + "Based on \"" + match.title + "\"'s full sale history, it might go on sale " + timing + "!" + ); +} + +const FEATURE_TAG_PATTERNS = { + Multiplayer: /\bmulti-?player\b|\bmmo\b/i, + "Co-Op": /\bco-?op\b/i, + "Cross-Play": /\bcross-?(play|platform)\b/i +}; + +async function findOwnerIds(gameId, appid) { + const owned = loadOwned(); + const ownerIds = new Set(); + + for (const userId of Object.keys(owned)) { + if (owned[userId].some((g) => g.id === gameId)) { + ownerIds.add(userId); + } + } + + if (appid && STEAM_API_KEY) { + const links = loadSteamLinks(); + for (const userId of Object.keys(links)) { + if (ownerIds.has(userId)) continue; + try { + const appIds = await fetchOwnedAppIds(links[userId]); + if (appIds.includes(appid)) { + ownerIds.add(userId); + } + } catch (err) { + console.error("Owned games check failed for " + userId + ":", err.message); + } + } + } + + return Array.from(ownerIds); +} + +async function countOwners(gameId, appid) { + const ownerIds = await findOwnerIds(gameId, appid); + return ownerIds.length; +} + +async function handleWho(message, query) { + if (!query) { + await message.reply("Usage: !who GAME NAME"); + return; + } + + let results; + try { + results = await searchGame(query); + } catch (err) { + await message.reply("Search failed, try again in a moment."); + return; + } + + if (!results || results.length === 0) { + await message.reply("Could not find a game called \"" + query + "\"."); + return; + } + + const match = pickSearchMatch(query, results); + + let appid = null; + try { + const info = await fetchGameInfo(match.id); + appid = info.appid || null; + } catch (err) { + appid = null; + } + + const ownerIds = await findOwnerIds(match.id, appid); + + if (ownerIds.length === 0) { + await message.reply( + "Nobody on this Discord server has confirmed owning \"" + match.title + + "\" yet. (type !own GAME to confirm ownership)" + ); + return; + } + + const lines = ["\"" + match.title + "\" has been marked owned by:"]; + ownerIds.forEach((id) => lines.push("<@" + id + ">")); + lines.push(""); + lines.push("Type \"!own GAME\" or \"!my-steam-profile LINK\" to update your owned games."); + + await message.reply({ content: lines.join("\n"), allowedMentions: { users: [] } }); +} + +async function handleInfo(message, query) { + if (!query) { + await message.reply("Usage: !info GAME NAME"); + return; + } + + let results; + try { + results = await searchGame(query); + } catch (err) { + await message.reply("Search failed, try again in a moment."); + return; + } + + if (!results || results.length === 0) { + await message.reply("Could not find a game called \"" + query + "\"."); + return; + } + + const match = pickSearchMatch(query, results); + + let info; + try { + info = await fetchGameInfo(match.id); + } catch (err) { + console.error("Info fetch failed:", err.message); + await message.reply("Could not load game info right now."); + return; + } + + let platforms = (info.platforms || []).map((p) => p.name).join(", "); + let featureSource = info.tags || []; + + if (info.appid) { + try { + const steamDetails = await fetchSteamDetails(info.appid); + if (steamDetails) { + if (steamDetails.categories.length > 0) { + featureSource = steamDetails.categories; + } + if (!platforms && steamDetails.platforms.length > 0) { + platforms = steamDetails.platforms.join(", "); + } + } + } catch (err) { + console.error("Steam details fetch failed:", err.message); + } + } + + if (!platforms) platforms = "unknown"; + + let priceLine = "unavailable"; + let dealUrl = null; + try { + const priceData = await fetchPrices([match.id]); + const entry = priceData && priceData[0]; + const deal = entry && pickBestDeal(entry.deals); + if (deal) { + const cutNote = deal.cut > 0 ? " (" + deal.cut + "% off)" : ""; + priceLine = formatMoney(deal.price.amountInt) + cutNote; + dealUrl = deal.url; + } + } catch (err) { + priceLine = "unavailable"; + } + + const embed = new EmbedBuilder() + .setTitle(match.title) + .addFields( + { name: "Price", value: priceLine, inline: true }, + { name: "Platform(s)", value: platforms, inline: true } + ); + + for (const label of Object.keys(FEATURE_TAG_PATTERNS)) { + const pattern = FEATURE_TAG_PATTERNS[label]; + const hits = featureSource.filter((t) => pattern.test(t)); + embed.addFields({ name: label, value: hits.length > 0 ? "✅" : "❌", inline: true }); + } + + const storeUrl = dealUrl || (info.appid ? "https://store.steampowered.com/app/" + info.appid : null); + if (storeUrl) { + embed.addFields({ name: "Store page", value: "[🔗 link](" + storeUrl + ")", inline: true }); + } + + if (info.urls && info.urls.game) { + embed.addFields({ name: "More info", value: "[🔗 link](" + info.urls.game + ")", inline: true }); + } + + const ownerCount = await countOwners(match.id, info.appid); + embed.addFields({ + name: "Owned by", + value: ownerCount + " Member" + (ownerCount === 1 ? "" : "s"), + inline: true + }); + + await message.reply({ embeds: [embed] }); +} + +async function handleOwn(message, query) { + if (!query) { + await message.reply("Usage: !own GAME NAME"); + return; + } + + let results; + try { + results = await searchGame(query); + } catch (err) { + await message.reply("Search failed, try again in a moment."); + return; + } + + if (!results || results.length === 0) { + await message.reply("Could not find a game called \"" + query + "\"."); + return; + } + + const match = pickSearchMatch(query, results); + const owned = loadOwned(); + const list = owned[message.author.id] || []; + + if (list.some((g) => g.id === match.id)) { + await message.reply("You already have \"" + match.title + "\" marked as owned."); + return; + } + + list.push({ id: match.id, title: match.title }); + owned[message.author.id] = list; + saveOwned(owned); + + await message.reply("Marked \"" + match.title + "\" as owned."); +} + +async function handleUnown(message, query) { + if (!query) { + await message.reply("Usage: !unown GAME NAME"); + return; + } + + let results; + try { + results = await searchGame(query); + } catch (err) { + await message.reply("Search failed, try again in a moment."); + return; + } + + if (!results || results.length === 0) { + await message.reply("Could not find a game called \"" + query + "\"."); + return; + } + + const match = pickSearchMatch(query, results); + const owned = loadOwned(); + const list = owned[message.author.id] || []; + + if (!list.some((g) => g.id === match.id)) { + await message.reply("You don't have \"" + match.title + "\" marked as owned."); + return; + } + + owned[message.author.id] = list.filter((g) => g.id !== match.id); + saveOwned(owned); + + await message.reply("Removed \"" + match.title + "\" from your owned list."); +} + +async function handleLinkSteam(message, input) { + if (!STEAM_API_KEY) { + await message.reply("Steam linking is not set up on this bot yet - it needs a Steam Web API key added to .env."); + return; + } + + if (!input) { + await message.reply("Usage: !my-steam-profile YOUR_STEAM_PROFILE_URL"); + return; + } + + let steamId; + try { + steamId = await resolveSteamId(input); + } catch (err) { + console.error("Steam profile resolve failed:", err.message); + await message.reply("Could not look up that Steam profile right now."); + return; + } + + if (!steamId) { + await message.reply( + "Could not find a Steam profile matching that. Use your full profile URL, like " + + "https://steamcommunity.com/id/yourname or https://steamcommunity.com/profiles/7656119..." + ); + return; + } + + const links = loadSteamLinks(); + links[message.author.id] = steamId; + saveSteamLinks(links); + + await message.reply( + "Linked your Discord account to that Steam profile. !info will now count you automatically for any game " + + "you own there, as long as your Steam profile's game details are set to public." + ); +} + +async function handleBulkLink(message, body) { + if (!STEAM_API_KEY) { + await message.reply("Steam linking is not set up on this bot yet - it needs a Steam Web API key added to .env."); + return; + } + + const lines = body.split("\n").map((l) => l.trim()).filter((l) => l.length > 0); + + if (lines.length === 0) { + await message.reply( + "Usage: type !bulk-link, then on new lines list one @member and Steam profile per line, like:\n" + + "!bulk-link\n" + + "@user1 https://steamcommunity.com/id/name1\n" + + "@user2 https://steamcommunity.com/id/name2" + ); + return; + } + + const links = loadSteamLinks(); + const linked = []; + const failed = []; + + for (const line of lines) { + const mentionMatch = line.match(/<@!?(\d+)>/); + if (!mentionMatch) { + failed.push(line + " (no @mention found)"); + continue; + } + + const userId = mentionMatch[1]; + const rest = line.replace(/<@!?\d+>/, "").trim(); + if (!rest) { + failed.push(line + " (no Steam profile given)"); + continue; + } + + let steamId; + try { + steamId = await resolveSteamId(rest); + } catch (err) { + failed.push(line + " (lookup failed)"); + continue; + } + + if (!steamId) { + failed.push(line + " (Steam profile not found)"); + continue; + } + + links[userId] = steamId; + linked.push("<@" + userId + ">"); + } + + saveSteamLinks(links); + + let summary = "Linked " + linked.length + " member" + (linked.length === 1 ? "" : "s") + "."; + if (failed.length > 0) { + summary += "\nCould not link:\n" + failed.map((f) => "- " + f).join("\n"); + } + + await message.reply({ content: summary, allowedMentions: { users: [] } }); +} + +async function handleRemove(message, query, commandName) { + if (!query) { + await message.reply("Usage: " + (commandName || "!remove") + " GAME NAME"); + return; + } + + const games = loadGames(); + const needle = query.toLowerCase(); + const matches = games.filter((g) => g.title.toLowerCase().includes(needle)); + + if (matches.length === 0) { + await message.reply("Nothing in the watchlist matches \"" + query + "\"."); + return; + } + + const remaining = games.filter((g) => !matches.includes(g)); + saveGames(remaining); + + const titles = matches.map((g) => g.title).join(", "); + await message.reply("Removed from the watchlist: " + titles); +} + +async function handleList(message) { + const games = loadGames(); + if (games.length === 0) { + await message.reply("Nothing is being watched yet. Use !watch GAME NAME to add one."); + return; + } + + let priceById = {}; + try { + const priceData = await fetchPrices(games.map((g) => g.id)); + priceData.forEach((entry) => { + priceById[entry.id] = entry; + }); + } catch (err) { + console.error("List price fetch failed:", err.message); + } + + const lines = games.map((g) => { + const entry = priceById[g.id]; + const deal = entry && pickBestDeal(entry.deals); + if (!deal) { + return "- **" + g.title + "** (price unavailable)"; + } + const price = formatMoney(deal.price.amountInt); + return "- **" + g.title + "** - " + price + " at " + deal.shop.name + " - [🔗 link](" + deal.url + ")"; + }); + + const chunks = []; + let chunk = ""; + for (const line of lines) { + const candidate = chunk ? chunk + "\n" + line : line; + if (candidate.length > 4000) { + chunks.push(chunk); + chunk = line; + } else { + chunk = candidate; + } + } + if (chunk) chunks.push(chunk); + + for (let i = 0; i < chunks.length; i++) { + const embed = new EmbedBuilder() + .setTitle(i === 0 ? "Currently watching" : "Currently watching (continued)") + .setDescription(chunks[i]); + await message.reply({ embeds: [embed] }); + } +} + +async function handleShops(message) { + let shops; + try { + shops = await fetchShops(); + } catch (err) { + console.error("Shops fetch failed:", err.message); + await message.reply("Could not load the store list right now."); + return; + } + + if (!shops || shops.length === 0) { + await message.reply("No store list available."); + return; + } + + console.log("Sample shop entry:", JSON.stringify(shops[0])); + + const names = shops + .map((s) => s.name || s.title || s.label || (s.shop && s.shop.name) || "") + .filter((n) => n) + .sort((a, b) => a.localeCompare(b)); + const header = "Stores I check prices at (" + names.length + "):\n"; + const full = header + names.join(", "); + + if (full.length <= 1900) { + await message.reply(full); + return; + } + + const perMessage = 30; + for (let i = 0; i < names.length; i += perMessage) { + const slice = names.slice(i, i + perMessage).join(", "); + await message.reply(i === 0 ? header + slice : slice); + } +} + +async function handleHelp(message) { + await message.reply( + "Commands:\n" + + "!watch GAME - start watching a game for sales\n" + + "!remove GAME - stop watching a game (!unwatch also works)\n" + + "!list - show everything being watched\n" + + "!price GAME - check a game's current price without watching it\n" + + "!history GAME - show every recorded sale over the past 12 months, with date and price\n" + + "!predict GAME - guess when it might go on sale next, based on its full sale history\n" + + "!info GAME - show price, platforms, multiplayer/co-op/cross-play info, a store link, and how many people here own it\n" + + "!own GAME - mark a game as owned by you\n" + + "!unown GAME - remove a game from your owned list\n" + + "!my-steam-profile LINK - link your Steam profile so !info can count you automatically\n" + + "!bulk-link - link multiple members' Steam profiles at once, one @member and link per line\n" + + "!who GAME - list who has confirmed they own a game\n" + + "!check - run the sale check right now instead of waiting for the daily run\n" + + "!shops - list every store I check prices at" + ); +} + +async function checkPrices(client) { + const games = loadGames(); + if (games.length === 0) return { checked: 0, alerted: 0 }; + + let priceData; + try { + priceData = await fetchPrices(games.map((g) => g.id)); + } catch (err) { + console.error("Price check failed:", err.message); + return { checked: 0, alerted: 0, failed: true }; + } + + const priceById = {}; + priceData.forEach((entry) => { + priceById[entry.id] = entry; + }); + + let changed = false; + let alerted = 0; + + for (const game of games) { + const entry = priceById[game.id]; + const deal = entry && pickBestDeal(entry.deals); + if (!deal) continue; + + if (deal.cut > 0) { + const isNewAlert = game.lastAlertedPrice === null || game.lastAlertedPrice === undefined || + deal.price.amountInt < game.lastAlertedPrice; + if (isNewAlert) { + try { + const channel = await client.channels.fetch(game.channelId); + await channel.send({ + content: game.title + " is on sale at " + deal.shop.name + ": " + formatMoney(deal.price.amountInt) + + " (" + deal.cut + "% off, was " + formatMoney(deal.regular.amountInt) + ")\n" + deal.url, + flags: MessageFlags.SuppressEmbeds + }); + } catch (err) { + console.error("Could not send alert for " + game.title + ":", err.message); + } + game.lastAlertedPrice = deal.price.amountInt; + changed = true; + alerted++; + } + } else if (game.lastAlertedPrice !== null && game.lastAlertedPrice !== undefined) { + game.lastAlertedPrice = null; + changed = true; + } + } + + if (changed) { + saveGames(games); + } + + return { checked: games.length, alerted: alerted }; +} + +async function handleCheck(message, client) { + await message.reply("Checking prices now..."); + const result = await checkPrices(client); + if (result.failed) { + await message.reply("Price check failed, see bot.error.log."); + return; + } + await message.reply( + "Checked " + result.checked + " game" + (result.checked === 1 ? "" : "s") + + ", sent " + result.alerted + " new sale alert" + (result.alerted === 1 ? "" : "s") + "." + ); +} + +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.GuildMembers + ] +}); + +client.once("clientReady", () => { + console.log("Logged in as " + client.user.tag); + cron.schedule("0 15 * * *", () => checkPrices(client)); +}); + +client.on("guildMemberAdd", async (member) => { + if (GREETINGS.length === 0) return; + + const channel = member.guild.channels.cache.find( + (c) => c.name === GREETING_CHANNEL_NAME && c.isTextBased && c.isTextBased() + ); + if (!channel) { + console.error("Could not find a #" + GREETING_CHANNEL_NAME + " channel in " + member.guild.name); + return; + } + + const template = GREETINGS[Math.floor(Math.random() * GREETINGS.length)]; + const text = template.replace(/\{user\}/g, member.toString()); + try { + await channel.send(text); + } catch (err) { + console.error("Could not send greeting:", err.message); + } +}); + +client.on("guildMemberRemove", async (member) => { + if (GOODBYES.length === 0) return; + + const channel = member.guild.channels.cache.find( + (c) => c.name === GREETING_CHANNEL_NAME && c.isTextBased && c.isTextBased() + ); + if (!channel) { + console.error("Could not find a #" + GREETING_CHANNEL_NAME + " channel in " + member.guild.name); + return; + } + + const template = GOODBYES[Math.floor(Math.random() * GOODBYES.length)]; + const name = member.user ? member.user.username : "someone"; + const text = template.replace(/\{user\}/g, name); + try { + await channel.send(text); + } catch (err) { + console.error("Could not send goodbye:", err.message); + } +}); + +client.on("messageCreate", async (message) => { + if (message.author.bot) return; + const content = message.content.trim(); + const lower = content.toLowerCase(); + + if (lower.startsWith("!watch ")) { + await handleWatch(message, content.slice(7).trim()); + } else if (lower.startsWith("!remove ")) { + await handleRemove(message, content.slice(8).trim()); + } else if (lower.startsWith("!unwatch ")) { + await handleRemove(message, content.slice(9).trim(), "!unwatch"); + } else if (lower.startsWith("!price ")) { + await handlePrice(message, content.slice(7).trim()); + } else if (lower.startsWith("!history ")) { + await handleHistory(message, content.slice(9).trim()); + } else if (lower.startsWith("!predict ")) { + await handlePredict(message, content.slice(9).trim()); + } else if (lower.startsWith("!info ")) { + await handleInfo(message, content.slice(6).trim()); + } else if (lower.startsWith("!own ")) { + await handleOwn(message, content.slice(5).trim()); + } else if (lower.startsWith("!unown ")) { + await handleUnown(message, content.slice(7).trim()); + } else if (lower.startsWith("!my-steam-profile ")) { + await handleLinkSteam(message, content.slice(18).trim()); + } else if (lower.startsWith("!who ")) { + await handleWho(message, content.slice(5).trim()); + } else if (lower.startsWith("!bulk-link")) { + const lines = content.split("\n"); + await handleBulkLink(message, lines.slice(1).join("\n")); + } else if (lower === "!check") { + await handleCheck(message, client); + } else if (lower === "!list") { + await handleList(message); + } else if (lower === "!shops") { + await handleShops(message); + } else if (lower === "!help") { + await handleHelp(message); + } else if (!content.startsWith("!") && TRIGGER_MENTION.test(content) && AI_ENABLED) { + if (canUseAI(message.author.id)) { + try { + const history = getAiHistory(message.author.id); + const displayName = (message.member && message.member.displayName) || + message.author.globalName || message.author.username; + const aiReply = await askAI(content, history, displayName); + if (aiReply) { + await message.reply(aiReply); + rememberAiExchange(message.author.id, content, aiReply); + } + } catch (err) { + console.error(PERSONALITY.name + " AI reply failed:", err.message); + } + } + } +}); + +process.on("unhandledRejection", (err) => { + console.error("Unhandled error:", err && err.message ? err.message : err); +}); + +client.login(DISCORD_BOT_TOKEN).catch((err) => { + console.error("Login failed:", err && err.message ? err.message : err); + process.exit(1); +}); diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..271a30f --- /dev/null +++ b/install.ps1 @@ -0,0 +1,187 @@ +param( + [switch]$LinkSteamProfiles +) + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location $ScriptDir + +if ($LinkSteamProfiles) { + if (-not (Test-Path ".env")) { + Write-Host "No .env found yet. Run install.ps1 (with no flag) first to set up your keys." + exit 1 + } + Write-Host "Installing dependencies..." + npm install --silent + node link-steam-profiles.js + exit 0 +} + +function Test-CommandExists($name) { + return $null -ne (Get-Command $name -ErrorAction SilentlyContinue) +} + +function Get-EnvValue($key) { + if (-not (Test-Path ".env")) { return "" } + $line = Select-String -Path ".env" -Pattern "^$key=" -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($line) { + return ($line.Line -replace "^$key=", "") + } + return "" +} + +function Set-EnvValue($key, $value) { + $lines = @() + if (Test-Path ".env") { + $lines = Get-Content ".env" | Where-Object { $_ -notmatch "^$key=" } + } + $lines += "$key=$value" + $lines | Set-Content ".env" +} + +if (-not (Test-CommandExists node)) { + Write-Host "Node.js was not found." + if (Test-CommandExists winget) { + $answer = Read-Host "Install it now with winget? [y/N]" + if ($answer -eq "y" -or $answer -eq "Y") { + winget install --id OpenJS.NodeJS.LTS -e + Write-Host "Node.js was installed. Close and reopen this terminal, then run install.ps1 again." + exit 0 + } else { + Write-Host "Install Node.js from https://nodejs.org and run this script again." + exit 1 + } + } else { + Write-Host "Install Node.js from https://nodejs.org and run this script again." + exit 1 + } +} + +Write-Host "Installing dependencies..." +npm install --silent + +if (-not (Test-Path ".env")) { + Copy-Item ".env.example" ".env" +} + +if (-not (Test-Path "games.json")) { + Copy-Item "games.json.example" "games.json" +} + +if (-not (Test-Path "owned.json")) { + Copy-Item "owned.json.example" "owned.json" +} + +if (-not (Test-Path "steamlinks.json")) { + Copy-Item "steamlinks.json.example" "steamlinks.json" +} + +if ([string]::IsNullOrWhiteSpace((Get-EnvValue "DISCORD_BOT_TOKEN"))) { + Write-Host "" + Write-Host "Need a Discord bot token:" + Write-Host "1. Go to https://discord.com/developers/applications and sign in" + Write-Host "2. Click New Application, name it anything, create it" + Write-Host "3. Click Bot in the left sidebar" + Write-Host "4. IMPORTANT - scroll to Privileged Gateway Intents, turn ON both Message Content Intent and Server Members Intent, then click the Save Changes button at the bottom." + Write-Host " The toggles by themselves do not save. If you skip Save Changes, the bot will fail to log in with a disallowed intents error." + Write-Host "5. Click Reset Token (or Copy) and copy the token" + Write-Host "6. Click OAuth2 > URL Generator, check bot under Scopes, then check Send Messages and Read Message History under Bot Permissions" + Write-Host "7. IMPORTANT - a URL appears at the bottom of that page. Copy it, paste it into your web browser, press Enter, pick your server, and click Authorize." + Write-Host " Creating the bot does NOT add it to your server. It will not show up in your server until you open that URL and authorize it there." + Write-Host "" + $tokenInput = Read-Host "Paste your Discord bot token" + Set-EnvValue "DISCORD_BOT_TOKEN" $tokenInput +} + +if ([string]::IsNullOrWhiteSpace((Get-EnvValue "ITAD_API_KEY"))) { + Write-Host "" + Write-Host "Need a free IsThereAnyDeal API key:" + Write-Host "1. Register a free user account at https://isthereanydeal.com" + Write-Host "2. Go to https://isthereanydeal.com/apps/" + Write-Host "3. Click Register App, name it anything, submit" + Write-Host "4. Copy the API key it shows you" + Write-Host "" + $keyInput = Read-Host "Paste your IsThereAnyDeal API key" + Set-EnvValue "ITAD_API_KEY" $keyInput +} + +if ([string]::IsNullOrWhiteSpace((Get-EnvValue "STEAM_API_KEY"))) { + Write-Host "" + Write-Host "Optional - only needed for !my-steam-profile and automatic Steam ownership counts on !info:" + Write-Host "1. Go to https://steamcommunity.com/dev/apikey and sign in" + Write-Host "2. Enter any domain name (e.g. localhost), agree to the terms, and submit" + Write-Host "3. Copy the API key it shows you" + Write-Host "" + $steamKeyInput = Read-Host "Paste your Steam Web API key, or press Enter to skip" + Set-EnvValue "STEAM_API_KEY" $steamKeyInput +} + +if (-not (Test-CommandExists ollama)) { + Write-Host "" + $aiAnswer = Read-Host "Want the bot to use AI to give smarter, in-character answers when people chat with it? This is free - it installs Ollama and runs a small AI model locally on this machine, no API key or cost involved. [y/N]" + if ($aiAnswer -eq "y" -or $aiAnswer -eq "Y") { + if (Test-CommandExists winget) { + Write-Host "Installing Ollama..." + winget install --id Ollama.Ollama -e + } else { + Write-Host "Install it yourself from https://ollama.com/download, then run this script again to finish setting it up." + } + } else { + Write-Host "Skipping - the bot won't reply to chat mentions. Run this script again any time to set it up." + } +} + +if (Test-CommandExists ollama) { + $currentModel = Get-EnvValue "OLLAMA_MODEL" + if ([string]::IsNullOrWhiteSpace($currentModel)) { + $currentModel = "llama3.2" + Set-EnvValue "OLLAMA_MODEL" $currentModel + } + + $modelList = & ollama list 2>$null + $modelFound = $false + foreach ($line in $modelList) { + if ($line -match [regex]::Escape($currentModel)) { + $modelFound = $true + break + } + } + + if (-not $modelFound) { + Write-Host "" + Write-Host "Downloading the $currentModel model for the bot to use (one-time, may take a few minutes)..." + ollama pull $currentModel + if ($LASTEXITCODE -ne 0) { + Write-Host "Could not download the model automatically - make sure Ollama is running, then run: ollama pull $currentModel" + } + } +} + +if (-not (Test-CommandExists pm2)) { + Write-Host "" + Write-Host "Installing pm2 to keep the bot running in the background..." + npm install -g pm2 +} + +$AppName = "discord-game-price-bot" + +pm2 delete $AppName 2>$null | Out-Null +pm2 start index.js --name $AppName --cwd $ScriptDir --output "$ScriptDir\bot.log" --error "$ScriptDir\bot.error.log" +pm2 save + +$taskName = "PM2 Resurrect - $AppName" +$existingTask = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue +if (-not $existingTask) { + $pm2Command = Get-Command pm2 -ErrorAction SilentlyContinue + if ($pm2Command) { + $action = New-ScheduledTaskAction -Execute $pm2Command.Source -Argument "resurrect" + $trigger = New-ScheduledTaskTrigger -AtLogOn + Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Description "Restores PM2-managed apps (including $AppName) at logon" | Out-Null + } +} + +Write-Host "" +Write-Host "Installed and running in the background." +Write-Host "View logs: pm2 logs $AppName" +Write-Host "Check status: pm2 status" +Write-Host "To stop it: pm2 stop $AppName" +Write-Host "To restart it after editing index.js: powershell -ExecutionPolicy Bypass -File install.ps1" diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..d55e674 --- /dev/null +++ b/install.sh @@ -0,0 +1,185 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +if [[ "$1" == "--link-steam-profiles" ]]; then + if [[ ! -f .env ]]; then + echo "No .env found yet. Run bash install.sh (with no flag) first to set up your keys." + exit 1 + fi + echo "Installing dependencies..." + npm install --silent + node link-steam-profiles.js + exit 0 +fi + +OS_NAME="$(uname -s)" + +if [[ "$OS_NAME" != "Darwin" && "$OS_NAME" != "Linux" ]]; then + echo "This installer is for macOS and Linux. On Windows, run install.ps1 in PowerShell instead." + exit 1 +fi + +set_env_value() { + local key="$1" + local value="$2" + if [[ -f .env ]] && grep -q "^${key}=" .env; then + grep -v "^${key}=" .env > .env.tmp + mv .env.tmp .env + fi + echo "${key}=${value}" >> .env +} + +if [[ "$OS_NAME" == "Darwin" ]]; then + for OLD_LABEL in com.justinoros.discordgamepricebot com.justinoros.gamepricebot; do + OLD_PLIST="$HOME/Library/LaunchAgents/$OLD_LABEL.plist" + if [[ -f "$OLD_PLIST" ]]; then + echo "Found an old launchd service ($OLD_LABEL) from a previous version of this bot - switching it over to pm2 so you don't end up with two copies running." + launchctl unload "$OLD_PLIST" >/dev/null 2>&1 || true + rm -f "$OLD_PLIST" + fi + done +fi + +if ! command -v node >/dev/null 2>&1; then + echo "Node.js was not found." + if command -v brew >/dev/null 2>&1; then + read -p "Install it now with Homebrew? [y/N] " answer + if [[ "$answer" == "y" || "$answer" == "Y" ]]; then + brew install node + else + echo "Install Node.js from https://nodejs.org (or your package manager) and run this script again." + exit 1 + fi + else + echo "Install Node.js from https://nodejs.org (or your package manager, e.g. apt/dnf/pacman) and run this script again." + exit 1 + fi +fi + +echo "Installing dependencies..." +npm install --silent + +if [[ ! -f .env ]]; then + cp .env.example .env +fi + +if [[ ! -f games.json ]]; then + cp games.json.example games.json +fi + +if [[ ! -f owned.json ]]; then + cp owned.json.example owned.json +fi + +if [[ ! -f steamlinks.json ]]; then + cp steamlinks.json.example steamlinks.json +fi + +CURRENT_TOKEN=$(grep -E "^DISCORD_BOT_TOKEN=" .env | cut -d "=" -f2-) +CURRENT_KEY=$(grep -E "^ITAD_API_KEY=" .env | cut -d "=" -f2-) + +if [[ -z "$CURRENT_TOKEN" ]]; then + echo "" + echo "Need a Discord bot token:" + echo "1. Go to https://discord.com/developers/applications and sign in" + echo "2. Click New Application, name it anything, create it" + echo "3. Click Bot in the left sidebar" + echo "4. IMPORTANT - scroll to Privileged Gateway Intents, turn ON both Message Content Intent and Server Members Intent, then click the Save Changes button at the bottom." + echo " The toggles by themselves do not save. If you skip Save Changes, the bot will fail to log in with a disallowed intents error." + echo "5. Click Reset Token (or Copy) and copy the token" + echo "6. Click OAuth2 > URL Generator, check bot under Scopes, then check Send Messages and Read Message History under Bot Permissions" + echo "7. IMPORTANT - a URL appears at the bottom of that page. Copy it, paste it into your web browser, press Enter, pick your server, and click Authorize." + echo " Creating the bot does NOT add it to your server. It will not show up in your server until you open that URL and authorize it there." + echo "" + read -p "Paste your Discord bot token: " TOKEN_INPUT + set_env_value DISCORD_BOT_TOKEN "$TOKEN_INPUT" +fi + +if [[ -z "$CURRENT_KEY" ]]; then + echo "" + echo "Need a free IsThereAnyDeal API key:" + echo "1. Register a free user account at https://isthereanydeal.com" + echo "2. Go to https://isthereanydeal.com/apps/" + echo "3. Click Register App, name it anything, submit" + echo "4. Copy the API key it shows you" + echo "" + read -p "Paste your IsThereAnyDeal API key: " KEY_INPUT + set_env_value ITAD_API_KEY "$KEY_INPUT" +fi + +CURRENT_STEAM_KEY=$(grep -E "^STEAM_API_KEY=" .env | cut -d "=" -f2-) + +if [[ -z "$CURRENT_STEAM_KEY" ]]; then + echo "" + echo "Optional - only needed for !my-steam-profile and automatic Steam ownership counts on !info:" + echo "1. Go to https://steamcommunity.com/dev/apikey and sign in" + echo "2. Enter any domain name (e.g. localhost), agree to the terms, and submit" + echo "3. Copy the API key it shows you" + echo "" + read -p "Paste your Steam Web API key, or press Enter to skip: " STEAM_KEY_INPUT + set_env_value STEAM_API_KEY "$STEAM_KEY_INPUT" +fi + +if ! command -v ollama >/dev/null 2>&1; then + echo "" + read -p "Want the bot to use AI to give smarter, in-character answers when people chat with it? This is free - it installs Ollama and runs a small AI model locally on this machine, no API key or cost involved. [y/N] " AI_ANSWER + if [[ "$AI_ANSWER" == "y" || "$AI_ANSWER" == "Y" ]]; then + if command -v brew >/dev/null 2>&1; then + echo "Installing Ollama..." + brew install ollama + elif [[ "$OS_NAME" == "Linux" ]]; then + echo "Installing Ollama..." + curl -fsSL https://ollama.com/install.sh | sh + else + echo "Install it yourself from https://ollama.com/download, then run this script again to finish setting it up." + fi + else + echo "Skipping - the bot won't reply to chat mentions. Run this script again any time to set it up." + fi +fi + +if command -v ollama >/dev/null 2>&1; then + CURRENT_OLLAMA_MODEL=$(grep -E "^OLLAMA_MODEL=" .env | cut -d "=" -f2-) + if [[ -z "$CURRENT_OLLAMA_MODEL" ]]; then + CURRENT_OLLAMA_MODEL="llama3.2" + set_env_value OLLAMA_MODEL "$CURRENT_OLLAMA_MODEL" + fi + + if command -v brew >/dev/null 2>&1 && brew list ollama >/dev/null 2>&1; then + brew services start ollama >/dev/null 2>&1 || true + sleep 2 + fi + + if ! ollama list 2>/dev/null | grep -q "$CURRENT_OLLAMA_MODEL"; then + echo "" + echo "Downloading the $CURRENT_OLLAMA_MODEL model for the bot to use (one-time, may take a few minutes)..." + ollama pull "$CURRENT_OLLAMA_MODEL" || echo "Could not download the model automatically - make sure Ollama is running, then run: ollama pull $CURRENT_OLLAMA_MODEL" + fi +fi + +if ! command -v pm2 >/dev/null 2>&1; then + echo "" + echo "Installing pm2 to keep the bot running in the background..." + npm install -g pm2 +fi + +APP_NAME="discord-game-price-bot" + +pm2 delete "$APP_NAME" >/dev/null 2>&1 || true +pm2 start index.js --name "$APP_NAME" --cwd "$SCRIPT_DIR" --output "$SCRIPT_DIR/bot.log" --error "$SCRIPT_DIR/bot.error.log" +pm2 save + +STARTUP_CMD=$(pm2 startup 2>/dev/null | grep "^sudo " || true) +if [[ -n "$STARTUP_CMD" ]]; then + eval "$STARTUP_CMD" +fi + +echo "" +echo "Installed and running in the background." +echo "View logs: pm2 logs $APP_NAME" +echo "Check status: pm2 status" +echo "To stop it: pm2 stop $APP_NAME" +echo "To restart it after editing index.js: bash install.sh" diff --git a/link-steam-profiles.js b/link-steam-profiles.js new file mode 100644 index 0000000..7302310 --- /dev/null +++ b/link-steam-profiles.js @@ -0,0 +1,257 @@ +require("dotenv").config(); +const fs = require("fs"); +const path = require("path"); +const readline = require("node:readline/promises"); +const { stdin: input, stdout: output } = require("node:process"); + +const DISCORD_BOT_TOKEN = process.env.DISCORD_BOT_TOKEN; +const STEAM_API_KEY = process.env.STEAM_API_KEY; +const DISCORD_API = "https://discord.com/api/v10"; +const STEAMLINKS_PATH = path.join(__dirname, "steamlinks.json"); +const OWNER_CONFIG_PATH = path.join(__dirname, ".link-steam-profiles-owner.json"); + +if (!DISCORD_BOT_TOKEN) { + console.error("DISCORD_BOT_TOKEN is not set in .env. Run bash install.sh first to set it up."); + process.exit(1); +} + +if (!STEAM_API_KEY) { + console.error("STEAM_API_KEY is not set in .env. Run bash install.sh and provide a Steam Web API key first."); + process.exit(1); +} + +function loadSteamLinks() { + if (!fs.existsSync(STEAMLINKS_PATH)) return {}; + return JSON.parse(fs.readFileSync(STEAMLINKS_PATH, "utf8")); +} + +function saveSteamLinks(links) { + fs.writeFileSync(STEAMLINKS_PATH, JSON.stringify(links, null, 2) + "\n"); +} + +function loadOwnerSteamId() { + if (!fs.existsSync(OWNER_CONFIG_PATH)) return null; + try { + const data = JSON.parse(fs.readFileSync(OWNER_CONFIG_PATH, "utf8")); + return data.steamId || null; + } catch (err) { + return null; + } +} + +function saveOwnerSteamId(steamId) { + fs.writeFileSync(OWNER_CONFIG_PATH, JSON.stringify({ steamId: steamId }, null, 2) + "\n"); +} + +function parseSteamProfileInput(rawInput) { + const trimmed = rawInput.trim(); + let m = trimmed.match(/steamcommunity\.com\/profiles\/(\d{17})/i); + if (m) return { type: "id", value: m[1] }; + m = trimmed.match(/steamcommunity\.com\/id\/([^\/\s]+)/i); + if (m) return { type: "vanity", value: m[1] }; + if (/^\d{17}$/.test(trimmed)) return { type: "id", value: trimmed }; + return { type: "vanity", value: trimmed }; +} + +async function resolveSteamId(rawInput) { + const parsed = parseSteamProfileInput(rawInput); + if (parsed.type === "id") return parsed.value; + + const url = "https://api.steampowered.com/ISteamUser/ResolveVanityURL/v0001/?key=" + + STEAM_API_KEY + "&vanityurl=" + encodeURIComponent(parsed.value); + const res = await fetch(url); + if (!res.ok) throw new Error("resolve vanity url failed: " + res.status); + const data = await res.json(); + if (data.response && data.response.success === 1) return data.response.steamid; + return null; +} + +async function fetchBotGuilds() { + const res = await fetch(DISCORD_API + "/users/@me/guilds", { + headers: { Authorization: "Bot " + DISCORD_BOT_TOKEN } + }); + if (!res.ok) throw new Error("Discord guild list failed: " + res.status); + return res.json(); +} + +async function fetchGuildMembers(guildId) { + const res = await fetch(DISCORD_API + "/guilds/" + guildId + "/members?limit=1000", { + headers: { Authorization: "Bot " + DISCORD_BOT_TOKEN } + }); + if (!res.ok) throw new Error("Discord member list failed: " + res.status); + return res.json(); +} + +async function fetchFriendList(steamId) { + const url = "https://api.steampowered.com/ISteamUser/GetFriendList/v0001/?key=" + + STEAM_API_KEY + "&steamid=" + steamId + "&relationship=friend"; + const res = await fetch(url); + if (res.status === 401) { + throw new Error( + "your Steam friends list is set to private. Go to Steam > Settings > Privacy Settings > " + + "\"My friends list\" and set it to Public, then try again." + ); + } + if (!res.ok) throw new Error("Steam friend list failed: " + res.status); + const data = await res.json(); + return (data.friendslist && data.friendslist.friends) || []; +} + +async function fetchPlayerSummaries(steamIds) { + const url = "https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=" + + STEAM_API_KEY + "&steamids=" + steamIds.join(","); + const res = await fetch(url); + if (!res.ok) throw new Error("Steam player summaries failed: " + res.status); + const data = await res.json(); + return (data.response && data.response.players) || []; +} + +function memberDisplayName(member) { + return member.nick || member.user.global_name || member.user.username; +} + +async function pickFromList(rl, label, items, formatItem) { + console.log(""); + console.log(label); + items.forEach((item, i) => { + console.log(" " + (i + 1) + ". " + formatItem(item)); + }); + + while (true) { + const answer = (await rl.question("Enter a number (or 0 to cancel): ")).trim(); + const n = parseInt(answer, 10); + if (n === 0) return null; + if (!isNaN(n) && n >= 1 && n <= items.length) return items[n - 1]; + console.log("Not a valid choice, try again."); + } +} + +async function main() { + const rl = readline.createInterface({ input, output }); + + let ownerSteamId = loadOwnerSteamId(); + if (!ownerSteamId) { + console.log("First, we need your own Steam profile so we can read your friends list from it."); + const profileInput = await rl.question("Enter your Steam profile URL or SteamID: "); + try { + ownerSteamId = await resolveSteamId(profileInput); + } catch (err) { + console.error("Could not look up that Steam profile:", err.message); + rl.close(); + process.exit(1); + } + if (!ownerSteamId) { + console.error("Could not find a Steam profile matching that."); + rl.close(); + process.exit(1); + } + saveOwnerSteamId(ownerSteamId); + console.log("Saved, so you won't be asked again next time."); + } + + let guilds; + try { + guilds = await fetchBotGuilds(); + } catch (err) { + console.error("Could not load your Discord server list:", err.message); + rl.close(); + process.exit(1); + } + + if (!guilds || guilds.length === 0) { + console.error("The bot is not in any Discord server yet."); + rl.close(); + process.exit(1); + } + + let guild = guilds[0]; + if (guilds.length > 1) { + guild = await pickFromList(rl, "Which Discord server?", guilds, (g) => g.name); + if (!guild) { + rl.close(); + return; + } + } + + let friends; + try { + friends = await fetchFriendList(ownerSteamId); + } catch (err) { + console.error("Could not load your Steam friends list:", err.message); + rl.close(); + process.exit(1); + } + + if (friends.length === 0) { + console.error( + "No Steam friends were returned. This usually means your Steam friends list privacy " + + "is not set to public - check that in your Steam privacy settings and try again." + ); + rl.close(); + process.exit(1); + } + + let friendProfiles = []; + const friendIds = friends.map((f) => f.steamid); + for (let i = 0; i < friendIds.length; i += 100) { + const batch = friendIds.slice(i, i + 100); + try { + const summaries = await fetchPlayerSummaries(batch); + friendProfiles.push(...summaries); + } catch (err) { + console.error("Could not load some friend profiles:", err.message); + } + } + friendProfiles.sort((a, b) => (a.personaname || "").localeCompare(b.personaname || "")); + + let keepGoing = true; + while (keepGoing) { + let members; + try { + members = await fetchGuildMembers(guild.id); + } catch (err) { + console.error("Could not load server members:", err.message); + break; + } + + members = members.filter((m) => !m.user.bot); + members.sort((a, b) => memberDisplayName(a).localeCompare(memberDisplayName(b))); + + if (members.length === 0) { + console.error("No members found in that server."); + break; + } + + const currentLinks = loadSteamLinks(); + const member = await pickFromList( + rl, + "Which Discord server member?", + members, + (m) => memberDisplayName(m) + " (@" + m.user.username + ") - " + + (currentLinks[m.user.id] ? "(linked)" : "(unlinked)") + ); + if (!member) break; + + const friend = await pickFromList( + rl, + "Which Steam profile?", + friendProfiles, + (f) => (f.personaname || "unknown") + " - " + f.profileurl + ); + if (!friend) continue; + + const links = loadSteamLinks(); + links[member.user.id] = friend.steamid; + saveSteamLinks(links); + + console.log("Linked " + memberDisplayName(member) + " to " + (friend.personaname || friend.steamid) + "."); + + const again = (await rl.question("Link another? (y/N): ")).trim().toLowerCase(); + keepGoing = again === "y" || again === "yes"; + } + + console.log("Done."); + rl.close(); +} + +main(); diff --git a/owned.json.example b/owned.json.example new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/owned.json.example @@ -0,0 +1 @@ +{} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7c3379e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,379 @@ +{ + "name": "game-price-bot", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "game-price-bot", + "version": "1.0.0", + "dependencies": { + "discord.js": "^14.15.3", + "dotenv": "^16.4.5", + "js-yaml": "^4.1.1", + "node-cron": "^3.0.3" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.3.tgz", + "integrity": "sha512-wvOylxNYJkwKjctS/Mn5GP1w9r3/rzyH+ThD1JlAca6zEdlHs8QWBBUQJpU5Q+W6DoIj/Ljh1IPlZs7hTU+UAg==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.2.0", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.5", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.50", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "^6.27.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@types/node": { + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", + "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/discord-api-types": { + "version": "0.38.53", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.53.tgz", + "integrity": "sha512-HL1zz/UuZ+bbJjA/X8Kbxx9gk8v9rJAbTeWRNYKmIdjwJ7EovjlHgoJTxcLpATfNJ+AonOtMdy3Y5MVIJAAd/A==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.27.0", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.27.0.tgz", + "integrity": "sha512-qHbFlFG2N7y3LjPySYsL6A1+BnX6bkTVgo842EX0CqVPk/KTMwZkojPHEXKsQUpWZNyz5BISNHK1cPpQw0+m4A==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.14.1", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.2", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.5", + "discord-api-types": "^0.38.49", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "^6.27.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "node_modules/magic-bytes.js": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.1.tgz", + "integrity": "sha512-x5sn4UX2k5gCWlcfmoFwG4TPie8+dctESyqOBdhB5p6MsgWXdBKGmt9nXPObj/JI50TTL928lc5Yt1WntMn1bw==", + "license": "MIT" + }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/undici": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..aafd244 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "discord-game-price-bot", + "version": "1.0.0", + "description": "A Discord bot that tracks PC game prices, ownership, and chats in character using a local AI model.", + "private": true, + "main": "index.js", + "license": "MIT", + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "discord.js": "^14.15.3", + "dotenv": "^16.4.5", + "js-yaml": "^4.1.1", + "node-cron": "^3.0.3" + } +} diff --git a/personality.yaml b/personality.yaml new file mode 100644 index 0000000..3617964 --- /dev/null +++ b/personality.yaml @@ -0,0 +1,6 @@ +name: BMO +trigger: bmo +persona: | + You are BMO, the small handheld game console robot from Adventure Time. You are cheerful, innocent, + a little literal-minded, and love games and your friends Finn and Jake. Playful, warm, a little silly, + occasionally saying things like "Oh my glob" or calling the user "buddy" or "friend". diff --git a/steamlinks.json.example b/steamlinks.json.example new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/steamlinks.json.example @@ -0,0 +1 @@ +{}