diff --git a/README.md b/README.md index 22ea0e4..fd1c9e7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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, `!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. +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. If several watched games in the same channel have news the same day (say, a bunch go on sale at once during a big Steam sale), it's grouped into one digest message for that channel instead of a separate ping per game. 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. diff --git a/index.js b/index.js index 55a39bd..ee67110 100644 --- a/index.js +++ b/index.js @@ -1676,6 +1676,14 @@ async function checkPrices(client) { let changed = false; let alerted = 0; + const newSalesByChannel = new Map(); + const endingSoonByChannel = new Map(); + + function pushLine(map, channelId, line) { + if (!map.has(channelId)) map.set(channelId, []); + map.get(channelId).push(line); + } + for (const game of games) { const entry = priceById[game.id]; const deal = entry && pickBestDeal(entry.deals); @@ -1686,17 +1694,10 @@ async function checkPrices(client) { 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.price.currency) + - " (" + deal.cut + "% off, was " + formatMoney(deal.regular.amountInt, deal.regular.currency) + - (expiryText ? ", " + expiryText : "") + ")\n" + deal.url, - flags: MessageFlags.SuppressEmbeds - }); - } catch (err) { - console.error("Could not send alert for " + game.title + ":", err.message); - } + pushLine(newSalesByChannel, game.channelId, + "**" + game.title + "** is on sale at " + deal.shop.name + ": " + formatMoney(deal.price.amountInt, deal.price.currency) + + " (" + deal.cut + "% off, was " + formatMoney(deal.regular.amountInt, deal.regular.currency) + + (expiryText ? ", " + expiryText : "") + ")\n" + deal.url); game.lastAlertedPrice = deal.price.amountInt; game.lastEndingSoonExpiry = deal.expiry || null; changed = true; @@ -1704,16 +1705,9 @@ async function checkPrices(client) { } 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.price.currency) + " (" + deal.cut + "% off)\n" + deal.url, - flags: MessageFlags.SuppressEmbeds - }); - } catch (err) { - console.error("Could not send ending-soon alert for " + game.title + ":", err.message); - } + pushLine(endingSoonByChannel, game.channelId, + "**" + game.title + "**'s sale at " + deal.shop.name + " is " + expiryText + " - " + + formatMoney(deal.price.amountInt, deal.price.currency) + " (" + deal.cut + "% off)\n" + deal.url); game.lastEndingSoonExpiry = deal.expiry; changed = true; } @@ -1729,9 +1723,54 @@ async function checkPrices(client) { saveGames(games); } + const channelIds = new Set([...newSalesByChannel.keys(), ...endingSoonByChannel.keys()]); + for (const channelId of channelIds) { + const sections = []; + const newSales = newSalesByChannel.get(channelId); + if (newSales && newSales.length > 0) { + sections.push("**On sale:**\n" + newSales.join("\n\n")); + } + const endingSoon = endingSoonByChannel.get(channelId); + if (endingSoon && endingSoon.length > 0) { + sections.push("**Ending soon:**\n" + endingSoon.join("\n\n")); + } + await sendDigest(channelId, sections); + } + return { checked: games.length, alerted: alerted }; } +async function sendDigest(channelId, sections) { + let channel; + try { + channel = await client.channels.fetch(channelId); + } catch (err) { + console.error("Could not fetch channel " + channelId + " for digest:", err.message); + return; + } + + const chunks = []; + let chunk = ""; + for (const section of sections) { + const candidate = chunk ? chunk + "\n\n" + section : section; + if (candidate.length > 1900 && chunk) { + chunks.push(chunk); + chunk = section; + } else { + chunk = candidate; + } + } + if (chunk) chunks.push(chunk); + + for (const c of chunks) { + try { + await channel.send({ content: c, flags: MessageFlags.SuppressEmbeds }); + } catch (err) { + console.error("Could not send digest to channel " + channelId + ":", err.message); + } + } +} + const notifiedEvents = new Set(); const dmGreetedUsers = new Set();