diff --git a/README.md b/README.md index fd9985c..93b2102 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # 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 the best price without watching it, `!prices GAME NAME` to list every store it's on sale at, cheapest first, `!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 games you own automatically, `!who GAME NAME` to list who has confirmed they own a game, `!popular` to list games owned by more than one member, most owned first, `!remember SOMETHING` to permanently teach it a fact that survives restarts and is shared with everyone it talks to, `!forget SOMETHING` (or `!forget all`) to make it forget something you had it remember, `!memories` to see everything it remembers about you, `!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. +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 the best price without watching it, `!prices GAME NAME` to list every store it's on sale at, cheapest first, `!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 games you own automatically, `!who GAME NAME` to list who has confirmed they own a game, `!popular` to list games owned by more than one member, most owned first, `!remember SOMETHING` to permanently teach it a fact that survives restarts and is shared with everyone it talks to, `!forget SOMETHING` (or `!forget all`) to make it forget something you had it remember, `!memories` to see everything it remembers about you, `!check` to run the sale check on demand, `!shops` to see which stores it covers, `!wiki QUESTION` to ask a specific game question (item locations, boss strategies, release dates, and so on) and get a real, searched answer. 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, which store it is at, and how long the sale lasts if that store reports an end date. If a sale is already running and about to end (within 48 hours) by the time of a later daily check, it posts a separate one-time reminder for that same channel so you don't miss it. -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. It also handles general video game questions this way (item locations, boss strategies, release dates, and so on), not just questions about the bot itself - see `TAVILY_API_KEY` below for making those more accurate. 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. +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. For a specific game question you want an accurate answer to rather than just banter, use `!wiki QUESTION` instead of a mention - see `TAVILY_API_KEY` below for what that needs to work. 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. @@ -71,6 +71,7 @@ To change the token or key later, edit `.env` directly, then run the installer a - `!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. +- Not every store reports when a sale ends, so the "ends in X days" note only shows up when that data is available - no note doesn't mean the sale is permanent, just that the end date isn't known. If a sale you're already watching is within 48 hours of ending by the time of a later daily check, you get a separate one-time "ending soon" reminder in the same channel, even if the price itself hasn't changed since the original alert. To change that 48-hour window, edit `SALE_ENDING_SOON_HOURS` near the top of `index.js` and restart it. - 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`, `memory.yaml`, 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 each of these from its matching `.example` template automatically if it doesn'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-bot` (live logs), `pm2 restart discord-bot`, `pm2 stop discord-bot`, and `pm2 delete discord-bot` (remove it from pm2 entirely - re-run the installer to add it back). @@ -86,7 +87,7 @@ To change the token or key later, edit `.env` directly, then run the installer a - 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 5 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. - `!remember SOMETHING` teaches it a permanent fact - a name, a preference, a correction like "don't call me buddy", or something about another member mentioned by name - that it will always take into account, even after the bot restarts. These facts are shared: anyone can ask about a fact someone else taught it, not just the person who typed `!remember`. They're saved to `memory.yaml` under whoever typed the command, and never expire on their own, unlike the short-term memory described below. `!forget SOMETHING` removes one you personally taught it (it has to match what you typed to `!remember`, word for word - use `!memories` to see the exact wording it has stored under your name), and `!forget all` clears everything you've had it remember. Each person can have up to 20 things remembered at a time. - `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-bot`). Bigger models give better answers but take longer to reply and use more of your computer's memory. -- `TAVILY_API_KEY` in `.env` is optional and off by default. Without it, the AI only answers from what its model already knows, which for specific game facts (an exact item location, a boss's weak point) is often wrong or a guess, since small local models don't have that level of detail memorized. With it set, the bot runs a quick web search alongside every mention reply and gives the AI the top few results to work from, so those specific answers are much more likely to be accurate - unlike the AI chat itself, this does send your message (with the trigger word stripped out) to Tavily's search API over the internet. Get a free key (no credit card required, 1,000 searches a month free) at [app.tavily.com](https://app.tavily.com/) (the installer asks for this too and lets you skip it), then restart the bot (re-run the installer, or `pm2 restart discord-bot`) to pick it up. +- `TAVILY_API_KEY` in `.env` is optional and off by default, and is what `!wiki QUESTION` needs to work. Without it, `!wiki` just says AI isn't set up for that. With it set, `!wiki` runs a real web search and answers with what it finds, which for specific game facts (an exact item location, a boss's weak point) is far more likely to be accurate than the small local model guessing on its own - unlike the AI chat itself, this does send your question to Tavily's search API over the internet. Get a free key (no credit card required, 1,000 searches a month free) at [app.tavily.com](https://app.tavily.com/) (the installer asks for this too and lets you skip it), then restart the bot (re-run the installer, or `pm2 restart discord-bot`) to pick it up. Plain mentions of the trigger word never use this - they always stay pure in-character chat from the local model, so `!wiki` is the one to reach for when you want a real answer instead of banter. - 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: ``` diff --git a/index.js b/index.js index 7c0aa58..4656a4e 100644 --- a/index.js +++ b/index.js @@ -25,6 +25,7 @@ const GREETING_CHANNEL_NAME = "general"; const EVENT_REMINDER_MINUTES = 15; const POPULAR_TOP_N = 10; const ALL_PRICES_TOP_N = 10; +const SALE_ENDING_SOON_HOURS = 48; const TRIVIA_POINTS = 100; const SCORE_TOP_N = 10; const ITAD_BASE = "https://api.isthereanydeal.com"; @@ -560,6 +561,25 @@ function formatMoney(cents) { return "$" + (cents / 100).toFixed(2); } +function formatExpiry(expiryIso) { + if (!expiryIso) return null; + const expiryDate = new Date(expiryIso); + if (isNaN(expiryDate.getTime())) return null; + const hoursLeft = (expiryDate.getTime() - Date.now()) / (1000 * 60 * 60); + if (hoursLeft <= 0) return null; + if (hoursLeft < 24) return "ends within a day"; + const daysLeft = Math.round(hoursLeft / 24); + if (daysLeft === 1) return "ends tomorrow"; + if (daysLeft <= 21) return "ends in " + daysLeft + " days"; + return "ends " + formatReleaseDate(expiryDate.toISOString().slice(0, 10)); +} + +function dealNote(deal, label) { + if (!deal || deal.cut <= 0) return ""; + const expiryText = formatExpiry(deal.expiry); + return " (" + deal.cut + "% off" + (label ? " " + label : "") + (expiryText ? ", " + expiryText : "") + ")"; +} + async function handleWatch(message, query) { if (!query) { await message.reply("Usage: !watch GAME NAME"); @@ -611,7 +631,7 @@ async function handleWatch(message, query) { const deals = await mergeLiveSteamDeal((entry && entry.deals) || [], watchAppid); const deal = pickBestDeal(deals); if (deal) { - const cutNote = deal.cut > 0 ? " (" + deal.cut + "% off right now)" : ""; + const cutNote = dealNote(deal, "right now"); priceNote = " Current best price: " + formatMoney(deal.price.amountInt) + cutNote + " at " + deal.shop.name + ".\n" + deal.url; } else { @@ -665,7 +685,7 @@ async function handlePrice(message, query) { await message.reply("\"" + match.title + "\" - could not find a current listing for it."); return; } - const cutNote = deal.cut > 0 ? " (" + deal.cut + "% off)" : ""; + const cutNote = dealNote(deal); await message.reply({ content: "\"" + match.title + "\": " + formatMoney(deal.price.amountInt) + cutNote + " at " + deal.shop.name + "\n" + deal.url, @@ -721,7 +741,7 @@ async function handleAllPrices(message, query) { const lines = ["\"" + match.title + "\" is on sale at:"]; top.forEach((deal) => { - const cutNote = deal.cut > 0 ? " (" + deal.cut + "% off)" : ""; + const cutNote = dealNote(deal); lines.push(deal.shop.name + ": " + formatMoney(deal.price.amountInt) + cutNote); }); @@ -1115,7 +1135,7 @@ async function handleInfo(message, query) { const deals = await mergeLiveSteamDeal((entry && entry.deals) || [], info.appid || null); const deal = pickBestDeal(deals); if (deal) { - const cutNote = deal.cut > 0 ? " (" + deal.cut + "% off)" : ""; + const cutNote = dealNote(deal); priceLine = formatMoney(deal.price.amountInt) + cutNote; dealUrl = deal.url; } @@ -1658,23 +1678,43 @@ async function checkPrices(client) { if (deal.cut > 0) { const isNewAlert = game.lastAlertedPrice === null || game.lastAlertedPrice === undefined || deal.price.amountInt < game.lastAlertedPrice; + const expiryText = formatExpiry(deal.expiry); 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, + " (" + deal.cut + "% off, was " + formatMoney(deal.regular.amountInt) + + (expiryText ? ", " + expiryText : "") + ")\n" + deal.url, flags: MessageFlags.SuppressEmbeds }); } catch (err) { console.error("Could not send alert for " + game.title + ":", err.message); } game.lastAlertedPrice = deal.price.amountInt; + game.lastEndingSoonExpiry = deal.expiry || null; changed = true; alerted++; + } else if (deal.expiry && expiryText && + new Date(deal.expiry).getTime() - Date.now() <= SALE_ENDING_SOON_HOURS * 60 * 60 * 1000 && + game.lastEndingSoonExpiry !== deal.expiry) { + try { + const channel = await client.channels.fetch(game.channelId); + await channel.send({ + content: game.title + "'s sale at " + deal.shop.name + " is " + expiryText + " - " + + formatMoney(deal.price.amountInt) + " (" + deal.cut + "% off)\n" + deal.url, + flags: MessageFlags.SuppressEmbeds + }); + } catch (err) { + console.error("Could not send ending-soon alert for " + game.title + ":", err.message); + } + game.lastEndingSoonExpiry = deal.expiry; + changed = true; } - } else if (game.lastAlertedPrice !== null && game.lastAlertedPrice !== undefined) { + } else if ((game.lastAlertedPrice !== null && game.lastAlertedPrice !== undefined) || + (game.lastEndingSoonExpiry !== null && game.lastEndingSoonExpiry !== undefined)) { game.lastAlertedPrice = null; + game.lastEndingSoonExpiry = null; changed = true; } }