add ITAD_COUNTRY for non-US pricing and currency

This commit is contained in:
Justin Oro
2026-09-11 11:51:26 -07:00
parent 06c454936f
commit 8f73c38951
4 changed files with 45 additions and 19 deletions
+2 -1
View File
@@ -68,6 +68,7 @@ To change the token or key later, edit `.env` directly, then run the installer a
## 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.
- `ITAD_COUNTRY` in `.env` sets which country's prices and currency the bot uses everywhere - watching, `!price`, `!prices`, `!info`, `!list`, `!history`, and the daily check alerts. It's a 2-letter country code (`US`, `GB`, `DE`, `CA`, `AU`, and so on); defaults to `US` if left blank. This is one setting for the whole bot, not per-person, since watched games and their alerts are shared across everyone in the channel. Change it in `.env` (or re-run the installer, which asks for this too) and restart (`pm2 restart discord-bot`) to pick it up.
- `!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.
@@ -79,7 +80,7 @@ To change the token or key later, edit `.env` directly, then run the installer a
- 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-bot`). If no channel with that name exists, nothing posts and `bot.error.log` will note it.
- Any Discord Scheduled Event on your server gets an automatic reminder posted to that same channel 15 minutes before it starts, like "Movie Night starting in 15 minutes!" Each event only gets reminded once. To change how far ahead it reminds, edit `EVENT_REMINDER_MINUTES` near the top of `index.js` and restart it.
- Any Discord Scheduled Event on your server gets an automatic reminder posted to that same channel 15 minutes before it starts, like "EVENT: Movie Night starting in 15 minutes!" Each event only gets reminded once. To change how far ahead it reminds, edit `EVENT_REMINDER_MINUTES` near the top of `index.js` and restart 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.
- `!popular` combines both sources across every game, not just one at a time: anything self-reported with `!own`, plus a full scan of every Steam-linked member's library (Steam already hands back the game name with that lookup, so no extra API calls are needed). The same game reported through `!own` and detected in someone's Steam library counts as one shared total, not two. Games with only one confirmed owner are left out, and everything is sorted most-owned first.
+24 -18
View File
@@ -13,6 +13,7 @@ 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 ITAD_COUNTRY = (process.env.ITAD_COUNTRY || "US").toUpperCase();
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";
@@ -393,7 +394,7 @@ function pickSearchMatch(query, results) {
}
async function fetchPrices(ids) {
const url = ITAD_BASE + "/games/prices/v3?key=" + ITAD_API_KEY + "&country=US";
const url = ITAD_BASE + "/games/prices/v3?key=" + ITAD_API_KEY + "&country=" + ITAD_COUNTRY;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -427,7 +428,7 @@ function toItadDateTime(date) {
async function fetchSteamDetails(appid) {
const url = "https://store.steampowered.com/api/appdetails?appids=" + appid +
"&cc=us&filters=categories,platforms,basic";
"&cc=" + ITAD_COUNTRY.toLowerCase() + "&filters=categories,platforms,basic";
const res = await fetch(url);
if (!res.ok) throw new Error("steam appdetails failed: " + res.status);
const data = await res.json();
@@ -447,7 +448,7 @@ async function fetchSteamDetails(appid) {
async function fetchSteamLiveDeal(appid) {
const url = "https://store.steampowered.com/api/appdetails?appids=" + appid +
"&cc=us&filters=price_overview";
"&cc=" + ITAD_COUNTRY.toLowerCase() + "&filters=price_overview";
const res = await fetch(url);
if (!res.ok) throw new Error("steam price failed: " + res.status);
const data = await res.json();
@@ -459,8 +460,8 @@ async function fetchSteamLiveDeal(appid) {
return {
shop: { name: "Steam" },
price: { amountInt: overview.final },
regular: { amountInt: overview.initial },
price: { amountInt: overview.final, currency: overview.currency },
regular: { amountInt: overview.initial, currency: overview.currency },
cut: overview.discount_percent,
url: "https://store.steampowered.com/app/" + appid
};
@@ -530,7 +531,7 @@ async function fetchGameInfo(id) {
async function fetchHistorySince(id, since) {
const url = ITAD_BASE + "/games/history/v2?key=" + ITAD_API_KEY +
"&id=" + encodeURIComponent(id) + "&country=US&since=" + encodeURIComponent(since);
"&id=" + encodeURIComponent(id) + "&country=" + ITAD_COUNTRY + "&since=" + encodeURIComponent(since);
const res = await fetch(url);
if (!res.ok) {
const body = await res.text();
@@ -557,8 +558,13 @@ function pickBestDeal(deals) {
}, null);
}
function formatMoney(cents) {
return "$" + (cents / 100).toFixed(2);
function formatMoney(cents, currency) {
const amount = cents / 100;
try {
return new Intl.NumberFormat("en-US", { style: "currency", currency: currency || "USD" }).format(amount);
} catch (err) {
return "$" + amount.toFixed(2);
}
}
function formatExpiry(expiryIso) {
@@ -632,7 +638,7 @@ async function handleWatch(message, query) {
const deal = pickBestDeal(deals);
if (deal) {
const cutNote = dealNote(deal, "right now");
priceNote = " Current best price: " + formatMoney(deal.price.amountInt) + cutNote +
priceNote = " Current best price: " + formatMoney(deal.price.amountInt, deal.price.currency) + cutNote +
" at " + deal.shop.name + ".\n" + deal.url;
} else {
priceNote = " Could not find a current listing for it yet.";
@@ -687,7 +693,7 @@ async function handlePrice(message, query) {
}
const cutNote = dealNote(deal);
await message.reply({
content: "\"" + match.title + "\": " + formatMoney(deal.price.amountInt) + cutNote +
content: "\"" + match.title + "\": " + formatMoney(deal.price.amountInt, deal.price.currency) + cutNote +
" at " + deal.shop.name + "\n" + deal.url,
flags: MessageFlags.SuppressEmbeds
});
@@ -742,7 +748,7 @@ async function handleAllPrices(message, query) {
const lines = ["\"" + match.title + "\" is on sale at:"];
top.forEach((deal) => {
const cutNote = dealNote(deal);
lines.push(deal.shop.name + ": " + formatMoney(deal.price.amountInt) + cutNote);
lines.push(deal.shop.name + ": " + formatMoney(deal.price.amountInt, deal.price.currency) + cutNote);
});
const remaining = sorted.length - top.length;
@@ -796,8 +802,8 @@ async function handleHistory(message, query) {
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);
const price = formatMoney(h.deal.price.amountInt, h.deal.price.currency);
const regular = formatMoney(h.deal.regular.amountInt, h.deal.regular.currency);
return "- " + date + ": " + price + " (" + h.deal.cut + "% off, was " + regular + ") at " + h.shop.name;
});
@@ -1136,7 +1142,7 @@ async function handleInfo(message, query) {
const deal = pickBestDeal(deals);
if (deal) {
const cutNote = dealNote(deal);
priceLine = formatMoney(deal.price.amountInt) + cutNote;
priceLine = formatMoney(deal.price.amountInt, deal.price.currency) + cutNote;
dealUrl = deal.url;
}
} catch (err) {
@@ -1417,7 +1423,7 @@ async function handleList(message) {
if (!deal) {
return "- **" + g.title + "** (price unavailable)";
}
const price = formatMoney(deal.price.amountInt);
const price = formatMoney(deal.price.amountInt, deal.price.currency);
return "- **" + g.title + "** - " + price + " at " + deal.shop.name + " - [🔗 link](" + deal.url + ")";
});
@@ -1683,8 +1689,8 @@ async function checkPrices(client) {
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) +
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
});
@@ -1702,7 +1708,7 @@ async function checkPrices(client) {
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,
formatMoney(deal.price.amountInt, deal.price.currency) + " (" + deal.cut + "% off)\n" + deal.url,
flags: MessageFlags.SuppressEmbeds
});
} catch (err) {
+9
View File
@@ -133,6 +133,15 @@ if ([string]::IsNullOrWhiteSpace((Get-EnvValue "TAVILY_API_KEY"))) {
Set-EnvValue "TAVILY_API_KEY" $tavilyKeyInput
}
if ([string]::IsNullOrWhiteSpace((Get-EnvValue "ITAD_COUNTRY"))) {
Write-Host ""
Write-Host "Optional - sets which country's prices and currency to use (2-letter code, e.g. US, GB, DE, CA, AU). Defaults to US if you skip this."
Write-Host ""
$itadCountryInput = Read-Host "Country code, or press Enter for US"
if ([string]::IsNullOrWhiteSpace($itadCountryInput)) { $itadCountryInput = "US" }
Set-EnvValue "ITAD_COUNTRY" $itadCountryInput
}
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]"
+10
View File
@@ -143,6 +143,16 @@ if [[ -z "$CURRENT_TAVILY_KEY" ]]; then
set_env_value TAVILY_API_KEY "$TAVILY_KEY_INPUT"
fi
CURRENT_ITAD_COUNTRY=$(grep -E "^ITAD_COUNTRY=" .env | cut -d "=" -f2-)
if [[ -z "$CURRENT_ITAD_COUNTRY" ]]; then
echo ""
echo "Optional - sets which country's prices and currency to use (2-letter code, e.g. US, GB, DE, CA, AU). Defaults to US if you skip this."
echo ""
read -p "Country code, or press Enter for US: " ITAD_COUNTRY_INPUT
set_env_value ITAD_COUNTRY "${ITAD_COUNTRY_INPUT:-US}"
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