Compare commits
10
Commits
c0d5281234
...
abbd24f336
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abbd24f336 | ||
|
|
a700c9512b | ||
|
|
b5dfa7cd14 | ||
|
|
5ff8bbbc9b | ||
|
|
22298e91d2 | ||
|
|
20b3e514a0 | ||
|
|
4601e34e93 | ||
|
|
f0912e1d03 | ||
|
|
5c2d69371a | ||
|
|
8dd94bcca6 |
No files matched your search
@@ -5,6 +5,7 @@ owned.json
|
|||||||
steamlinks.json
|
steamlinks.json
|
||||||
memory.yaml
|
memory.yaml
|
||||||
scores.json
|
scores.json
|
||||||
|
roles.yaml
|
||||||
.link-steam-profiles-owner.json
|
.link-steam-profiles-owner.json
|
||||||
bot.log
|
bot.log
|
||||||
bot.error.log
|
bot.error.log
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Discord Game Price Bot
|
# 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. 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.
|
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, `!roles` to list every game role and how many members have each, `!role GAME NAME` to create (if it doesn't already exist) and join that game's role, `!unroll GAME NAME` to leave a game's role, `!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.
|
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.
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ It runs as a background service on a computer you leave on (a Mac, Linux box, or
|
|||||||
2. Go to the Bot tab, click Add Bot.
|
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.
|
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.
|
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.
|
5. Go to OAuth2 > URL Generator, check the "bot" scope, then under Bot Permissions check "Send Messages", "Read Message History", and "Manage Roles". 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.
|
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
|
### 2. Get a free IsThereAnyDeal API key
|
||||||
@@ -80,15 +80,19 @@ 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.
|
- 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).
|
- 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.
|
- 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 "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.
|
- Any Discord Scheduled Event on your server gets an automatic reminder posted to that same channel 5 minutes before it starts, like "EVENT: Movie Night starting soon!" If the event's title matches an existing game role, that role gets tagged too, like "@The Forest EVENT: The Forest starting soon!" 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."
|
- `!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.
|
- 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.
|
- `!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.
|
||||||
- If you don't want to make each person run `!my-steam-profile` themselves, you can 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. This is terminal-only by design - there's no Discord command for it, so members can't link (or relink) each other's profiles themselves.
|
- If you don't want to make each person run `!my-steam-profile` themselves, you can 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. This is terminal-only by design - there's no Discord command for it, so members can't link (or relink) each other's profiles themselves.
|
||||||
|
- To mark every current server member as owning a game at once, without each person running `!own` themselves, run `bash install.sh --bulk-own GAME NAME` (macOS/Linux) or `powershell -ExecutionPolicy Bypass -File install.ps1 -BulkOwn "GAME NAME"` (Windows). It matches the game the same way `!own` does, adds it to `owned.json` for everyone in the server, and creates/assigns the role for it too. Safe to run again later for new members, it only adds what's missing. This does not restart the bot.
|
||||||
|
- To change a role's emoji (or fix a title) after the fact, edit its `emoji` (or `title`) field directly in `roles.yaml`, then run `bash install.sh --sync-roles` (macOS/Linux) or `powershell -ExecutionPolicy Bypass -File install.ps1 -SyncRoles` (Windows) to push that change to the actual Discord role name - editing `roles.yaml` alone doesn't update Discord on its own. Safe to run any time, it only renames roles that are out of sync and leaves the rest alone. This does not restart the bot.
|
||||||
- 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.
|
- 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.
|
- `!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.
|
- `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, 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.
|
- `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.
|
||||||
|
- `!own GAME NAME` automatically creates a Discord role for that game (if one doesn't already exist) and gives it to you, each with its own randomly assigned emoji set as that role's actual Discord icon (the small badge next to the role name), not just text stuck in front of it. A handful of servers don't support role icons, in which case it falls back to putting the emoji in the role's name instead - this is automatic, no setup needed. `!role GAME NAME` does the same without marking it owned, for anyone who just wants to be pingable for a game without confirming ownership. When no emoji is picked for you, and AI is set up (see `TAVILY_API_KEY`/Ollama notes below), it asks the local AI model to suggest one that actually fits the game instead of a purely random one - if AI isn't available, or it doesn't reply with a usable emoji, it falls back to a random one the same as before. To pick your own emoji instead of the suggested one, put it right after `!role`: `!role 🌴 Green Hell`. This also works on a role that already exists, to change its emoji at any time - no need to touch `roles.yaml` by hand unless you'd rather do it that way. `!unroll GAME NAME` removes the role from you personally, it doesn't delete the role itself. `!roles` lists every game role that exists and how many members currently have each one, with clickable buttons (or a dropdown once there are more than 25 roles) underneath so people can join or leave a role with one click instead of typing a command. Clicking a role you already have removes it, same as `!unroll`. New members get this same clickable list posted right after their welcome greeting, so joining a role for the games they own or play takes one click. All of this is tracked in `roles.yaml` in this folder (game name, Discord role ID, and its emoji), left out of git the same as the other data files. There's deliberately no command to delete a role entirely - if you want one gone, delete it from your server's role list in Discord and remove its line from `roles.yaml` by hand. If you're upgrading an existing install, the bot needs the "Manage Roles" permission to use this - grant it under Server Settings > Roles for the bot's role, or re-run the OAuth2 authorize URL from step 5 above. The bot's own role also needs to sit above any role it creates in your server's role list (Server Settings > Roles, drag order) or it won't be able to assign them.
|
||||||
|
- `!docker ps` and `!docker restart CONTAINER NAME` let people with a specific Discord role manage Docker containers on a remote server over SSH (handy for restarting a game server without shelling in yourself). Optional and off by default - set `DOCKER_SSH_HOST`, `DOCKER_SSH_USER`, `DOCKER_SSH_KEY_PATH`, and `DOCKER_ROLE_ID` in `.env` to turn it on (the installer asks for these too and lets you skip them). The bot connects with the system `ssh` command using that key, so SSH key access from wherever the bot runs to that host needs to already work before you set this up. `DOCKER_ROLE_ID` is the numeric Discord role ID required to use either command - right-click the role in Discord (with Developer Mode on under User Settings > Advanced) and choose Copy Role ID. `DOCKER_PROTECTED_CONTAINERS` is an optional comma-separated list of container names (e.g. `Plex-Media-Server`) that `!docker restart` will always refuse, no matter who asks. `!docker restart` matches by container name (not case-sensitive) or container ID. Anyone without the role gets a permission error. Unlike every other command here, this one also works in a DM to the bot, not just in a server text channel.
|
||||||
- `!wiki` also links an interactive map from [mapgenie.io](https://mapgenie.io) when it has one for the game being asked about, using `maps.json` in this folder - a plain list of `"game name": "mapgenie URL"` pairs. It ships with one entry (Sons Of The Forest) as an example; add more yourself as you find them by browsing to the game on mapgenie.io and copying its URL. This is a direct lookup, not a live search, because mapgenie's map pages are built with client-side JavaScript that search engines mostly can't read the content of, so relying on search alone to find them essentially never works even when a page exists. Edit `maps.json` and restart the bot (re-run the installer, or `pm2 restart discord-bot`) to pick up changes.
|
- `!wiki` also links an interactive map from [mapgenie.io](https://mapgenie.io) when it has one for the game being asked about, using `maps.json` in this folder - a plain list of `"game name": "mapgenie URL"` pairs. It ships with one entry (Sons Of The Forest) as an example; add more yourself as you find them by browsing to the game on mapgenie.io and copying its URL. This is a direct lookup, not a live search, because mapgenie's map pages are built with client-side JavaScript that search engines mostly can't read the content of, so relying on search alone to find them essentially never works even when a page exists. Edit `maps.json` and restart the bot (re-run the installer, or `pm2 restart discord-bot`) to pick up changes.
|
||||||
- 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.
|
- 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:
|
- The bot's name, trigger word, and personality all come from `personality.yaml`. It ships set up as BMO from Adventure Time:
|
||||||
|
|||||||
+314
@@ -0,0 +1,314 @@
|
|||||||
|
require("dotenv").config();
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const yaml = require("js-yaml");
|
||||||
|
const readline = require("node:readline/promises");
|
||||||
|
const { stdin: input, stdout: output } = require("node:process");
|
||||||
|
|
||||||
|
const DISCORD_BOT_TOKEN = process.env.DISCORD_BOT_TOKEN;
|
||||||
|
const ITAD_API_KEY = process.env.ITAD_API_KEY;
|
||||||
|
const DISCORD_API = "https://discord.com/api/v10";
|
||||||
|
const ITAD_BASE = "https://api.isthereanydeal.com";
|
||||||
|
const OWNED_PATH = path.join(__dirname, "owned.json");
|
||||||
|
const ROLES_PATH = path.join(__dirname, "roles.yaml");
|
||||||
|
|
||||||
|
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 (!ITAD_API_KEY) {
|
||||||
|
console.error("ITAD_API_KEY is not set in .env. Run bash install.sh first to set it up.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_EMOJI_POOL = [
|
||||||
|
"🎮", "🕹️", "🎲", "🎯", "🏆", "🥇", "🥈", "🥉", "🎖️", "🏅",
|
||||||
|
"⚔️", "🛡️", "🏹", "🗡️", "🔪", "🪓", "💣", "🧨", "🔫", "🔱",
|
||||||
|
"🧙", "🧝", "🧛", "🧟", "🐉", "🐲", "👑", "💎", "🔮", "🗝️",
|
||||||
|
"🔑", "🚀", "🛸", "🤖", "👾", "💀", "☠️", "🔥", "❄️", "⚡",
|
||||||
|
"🌊", "🌪️", "🌋", "🌟", "⭐", "✨", "🌙", "☀️", "🍀", "🌵",
|
||||||
|
"🌲", "🍄", "🦅", "🐺", "🦊", "🦁", "🐯", "🐻", "🐸", "🐍",
|
||||||
|
"🕷️", "🦂", "🦇", "🦄", "🐴", "🐙", "🦑", "🦖", "🦕", "🐊",
|
||||||
|
"🦈", "🐳", "🎃", "🥷", "🧭", "🏰", "🛶", "⛵", "🚁", "🚂",
|
||||||
|
"🎸", "🎺", "🥁", "🎻", "🍕", "🍔", "🌮", "🍩", "☕", "🍺"
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizeTitle(str) {
|
||||||
|
return str.toLowerCase().replace(/[®™©]/g, "").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanTitle(str) {
|
||||||
|
return str.replace(/[®™©]/g, "").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadOwned() {
|
||||||
|
if (!fs.existsSync(OWNED_PATH)) return {};
|
||||||
|
return JSON.parse(fs.readFileSync(OWNED_PATH, "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveOwned(owned) {
|
||||||
|
fs.writeFileSync(OWNED_PATH, JSON.stringify(owned, null, 2) + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRoles() {
|
||||||
|
try {
|
||||||
|
const data = yaml.load(fs.readFileSync(ROLES_PATH, "utf8"));
|
||||||
|
return (data && typeof data === "object") ? data : {};
|
||||||
|
} catch (err) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRoles(roles) {
|
||||||
|
fs.writeFileSync(ROLES_PATH, yaml.dump(roles));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickRoleEmoji(roles) {
|
||||||
|
const used = new Set(Object.values(roles).map((r) => r.emoji));
|
||||||
|
const available = ROLE_EMOJI_POOL.filter((e) => !used.has(e));
|
||||||
|
const pool = available.length > 0 ? available : ROLE_EMOJI_POOL;
|
||||||
|
return pool[Math.floor(Math.random() * pool.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 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 fetchGuildRoles(guildId) {
|
||||||
|
const res = await fetch(DISCORD_API + "/guilds/" + guildId + "/roles", {
|
||||||
|
headers: { Authorization: "Bot " + DISCORD_BOT_TOKEN }
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Discord role list failed: " + res.status);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRole(guildId, title, emoji) {
|
||||||
|
let res = await fetch(DISCORD_API + "/guilds/" + guildId + "/roles", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bot " + DISCORD_BOT_TOKEN,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: title, unicode_emoji: emoji, mentionable: true })
|
||||||
|
});
|
||||||
|
if (res.ok) return { role: await res.json(), iconMode: "native" };
|
||||||
|
|
||||||
|
res = await fetch(DISCORD_API + "/guilds/" + guildId + "/roles", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bot " + DISCORD_BOT_TOKEN,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: emoji + " " + title, mentionable: true })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("role create failed: " + res.status + " " + (await res.text()));
|
||||||
|
return { role: await res.json(), iconMode: "name" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assignRole(guildId, userId, roleId) {
|
||||||
|
const res = await fetch(
|
||||||
|
DISCORD_API + "/guilds/" + guildId + "/members/" + userId + "/roles/" + roleId,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: "Bot " + DISCORD_BOT_TOKEN }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error("role assign failed: " + res.status + " " + (await res.text()));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 query = process.argv.slice(2).join(" ").trim();
|
||||||
|
if (!query) {
|
||||||
|
query = (await rl.question("Game name to mark everyone as owning: ")).trim();
|
||||||
|
}
|
||||||
|
if (!query) {
|
||||||
|
console.error("No game name given.");
|
||||||
|
rl.close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let results;
|
||||||
|
try {
|
||||||
|
results = await searchGame(query);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Search failed:", err.message);
|
||||||
|
rl.close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = pickSearchMatch(query, results);
|
||||||
|
if (!match) {
|
||||||
|
console.error("Could not find a game called \"" + query + "\".");
|
||||||
|
rl.close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Matched \"" + match.title + "\".");
|
||||||
|
|
||||||
|
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 members;
|
||||||
|
try {
|
||||||
|
members = await fetchGuildMembers(guild.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Could not load server members:", err.message);
|
||||||
|
rl.close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
members = members.filter((m) => !m.user.bot);
|
||||||
|
|
||||||
|
if (members.length === 0) {
|
||||||
|
console.error("No members found in that server.");
|
||||||
|
rl.close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let discordRoles;
|
||||||
|
try {
|
||||||
|
discordRoles = await fetchGuildRoles(guild.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Could not load server roles:", err.message);
|
||||||
|
rl.close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const validRoleIds = new Set(discordRoles.map((r) => r.id));
|
||||||
|
|
||||||
|
const roles = loadRoles();
|
||||||
|
const cleanedTitle = cleanTitle(match.title);
|
||||||
|
const key = normalizeTitle(match.title);
|
||||||
|
let entry = roles[key];
|
||||||
|
|
||||||
|
if (entry && !validRoleIds.has(entry.roleId)) {
|
||||||
|
console.log("Role for \"" + entry.title + "\" no longer exists in Discord, recreating.");
|
||||||
|
entry = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
const emoji = pickRoleEmoji(roles);
|
||||||
|
try {
|
||||||
|
const created = await createRole(guild.id, cleanedTitle, emoji);
|
||||||
|
entry = { title: cleanedTitle, roleId: created.role.id, emoji: emoji, iconMode: created.iconMode };
|
||||||
|
roles[key] = entry;
|
||||||
|
saveRoles(roles);
|
||||||
|
console.log("Created role for \"" + cleanedTitle + "\".");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Could not create a role for \"" + cleanedTitle + "\":", err.message);
|
||||||
|
rl.close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const owned = loadOwned();
|
||||||
|
let ownedAdded = 0;
|
||||||
|
let assigned = 0;
|
||||||
|
let assignmentsFailed = 0;
|
||||||
|
|
||||||
|
for (const member of members) {
|
||||||
|
const userId = member.user.id;
|
||||||
|
const list = owned[userId] || [];
|
||||||
|
if (!list.some((g) => g.id === match.id)) {
|
||||||
|
list.push({ id: match.id, title: match.title });
|
||||||
|
owned[userId] = list;
|
||||||
|
ownedAdded++;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assignRole(guild.id, userId, entry.roleId);
|
||||||
|
assigned++;
|
||||||
|
} catch (err) {
|
||||||
|
assignmentsFailed++;
|
||||||
|
console.error("Could not assign the role to " + userId + ":", err.message);
|
||||||
|
}
|
||||||
|
await sleep(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveOwned(owned);
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
console.log("Done. \"" + cleanedTitle + "\" marked as owned for " + ownedAdded + " new member(s), role assigned to " +
|
||||||
|
assigned + " member(s)" + (assignmentsFailed > 0 ? ", " + assignmentsFailed + " failed" : "") + ".");
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -3,7 +3,9 @@ require("dotenv").config({ path: path.join(__dirname, ".env") });
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const cron = require("node-cron");
|
const cron = require("node-cron");
|
||||||
const yaml = require("js-yaml");
|
const yaml = require("js-yaml");
|
||||||
const { Client, GatewayIntentBits, Partials, MessageFlags, EmbedBuilder, GuildScheduledEventStatus } = require("discord.js");
|
const util = require("util");
|
||||||
|
const execFileAsync = util.promisify(require("child_process").execFile);
|
||||||
|
const { Client, GatewayIntentBits, Partials, MessageFlags, EmbedBuilder, GuildScheduledEventStatus, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder } = require("discord.js");
|
||||||
|
|
||||||
const rawLog = console.log.bind(console);
|
const rawLog = console.log.bind(console);
|
||||||
const rawError = console.error.bind(console);
|
const rawError = console.error.bind(console);
|
||||||
@@ -23,7 +25,17 @@ const WEB_SEARCH_RESULT_COUNT = 5;
|
|||||||
const WEB_SEARCH_TIMEOUT_MS = 12000;
|
const WEB_SEARCH_TIMEOUT_MS = 12000;
|
||||||
const AI_COOLDOWN_MS = 5000;
|
const AI_COOLDOWN_MS = 5000;
|
||||||
const GREETING_CHANNEL_NAME = "general";
|
const GREETING_CHANNEL_NAME = "general";
|
||||||
const EVENT_REMINDER_MINUTES = 15;
|
const EVENT_REMINDER_MINUTES = 5;
|
||||||
|
const DOCKER_SSH_HOST = process.env.DOCKER_SSH_HOST;
|
||||||
|
const DOCKER_SSH_USER = process.env.DOCKER_SSH_USER;
|
||||||
|
const DOCKER_SSH_KEY_PATH = process.env.DOCKER_SSH_KEY_PATH;
|
||||||
|
const DOCKER_ROLE_ID = process.env.DOCKER_ROLE_ID;
|
||||||
|
const DOCKER_PROTECTED_CONTAINERS = (process.env.DOCKER_PROTECTED_CONTAINERS || "")
|
||||||
|
.split(",")
|
||||||
|
.map((name) => name.trim().toLowerCase())
|
||||||
|
.filter((name) => name.length > 0);
|
||||||
|
const DOCKER_ENABLED = !!(DOCKER_SSH_HOST && DOCKER_SSH_USER && DOCKER_SSH_KEY_PATH && DOCKER_ROLE_ID);
|
||||||
|
const DOCKER_SSH_TIMEOUT_MS = 15000;
|
||||||
const POPULAR_TOP_N = 10;
|
const POPULAR_TOP_N = 10;
|
||||||
const ALL_PRICES_TOP_N = 10;
|
const ALL_PRICES_TOP_N = 10;
|
||||||
const SALE_ENDING_SOON_HOURS = 48;
|
const SALE_ENDING_SOON_HOURS = 48;
|
||||||
@@ -36,6 +48,7 @@ const STEAMLINKS_PATH = path.join(__dirname, "steamlinks.json");
|
|||||||
const SCORES_PATH = path.join(__dirname, "scores.json");
|
const SCORES_PATH = path.join(__dirname, "scores.json");
|
||||||
const TRIVIA_PATH = path.join(__dirname, "trivia.json");
|
const TRIVIA_PATH = path.join(__dirname, "trivia.json");
|
||||||
const MAPS_PATH = path.join(__dirname, "maps.json");
|
const MAPS_PATH = path.join(__dirname, "maps.json");
|
||||||
|
const ROLES_PATH = path.join(__dirname, "roles.yaml");
|
||||||
const GREETINGS_PATH = path.join(__dirname, "greetings.yaml");
|
const GREETINGS_PATH = path.join(__dirname, "greetings.yaml");
|
||||||
const GOODBYES_PATH = path.join(__dirname, "goodbyes.yaml");
|
const GOODBYES_PATH = path.join(__dirname, "goodbyes.yaml");
|
||||||
const README_PATH = path.join(__dirname, "README.md");
|
const README_PATH = path.join(__dirname, "README.md");
|
||||||
@@ -370,6 +383,231 @@ function saveOwned(owned) {
|
|||||||
fs.writeFileSync(OWNED_PATH, JSON.stringify(owned, null, 2) + "\n");
|
fs.writeFileSync(OWNED_PATH, JSON.stringify(owned, null, 2) + "\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ROLE_EMOJI_POOL = [
|
||||||
|
"🎮", "🕹️", "🎲", "🎯", "🏆", "🥇", "🥈", "🥉", "🎖️", "🏅",
|
||||||
|
"⚔️", "🛡️", "🏹", "🗡️", "🔪", "🪓", "💣", "🧨", "🔫", "🔱",
|
||||||
|
"🧙", "🧝", "🧛", "🧟", "🐉", "🐲", "👑", "💎", "🔮", "🗝️",
|
||||||
|
"🔑", "🚀", "🛸", "🤖", "👾", "💀", "☠️", "🔥", "❄️", "⚡",
|
||||||
|
"🌊", "🌪️", "🌋", "🌟", "⭐", "✨", "🌙", "☀️", "🍀", "🌵",
|
||||||
|
"🌲", "🍄", "🦅", "🐺", "🦊", "🦁", "🐯", "🐻", "🐸", "🐍",
|
||||||
|
"🕷️", "🦂", "🦇", "🦄", "🐴", "🐙", "🦑", "🦖", "🦕", "🐊",
|
||||||
|
"🦈", "🐳", "🎃", "🥷", "🧭", "🏰", "🛶", "⛵", "🚁", "🚂",
|
||||||
|
"🎸", "🎺", "🥁", "🎻", "🍕", "🍔", "🌮", "🍩", "☕", "🍺"
|
||||||
|
];
|
||||||
|
|
||||||
|
function loadRoles() {
|
||||||
|
try {
|
||||||
|
const data = yaml.load(fs.readFileSync(ROLES_PATH, "utf8"));
|
||||||
|
return (data && typeof data === "object") ? data : {};
|
||||||
|
} catch (err) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRoles(roles) {
|
||||||
|
fs.writeFileSync(ROLES_PATH, yaml.dump(roles));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickRoleEmoji(roles) {
|
||||||
|
const used = new Set(Object.values(roles).map((r) => r.emoji));
|
||||||
|
const available = ROLE_EMOJI_POOL.filter((e) => !used.has(e));
|
||||||
|
const pool = available.length > 0 ? available : ROLE_EMOJI_POOL;
|
||||||
|
return pool[Math.floor(Math.random() * pool.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMOJI_PATTERN = /^\p{Extended_Pictographic}(️|\p{Extended_Pictographic})*$/u;
|
||||||
|
const EMOJI_SUGGEST_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
|
function isEmoji(str) {
|
||||||
|
return EMOJI_PATTERN.test(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function suggestGameEmoji(title) {
|
||||||
|
if (!AI_ENABLED) return null;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), EMOJI_SUGGEST_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
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: 10 },
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: "You only ever reply with a single emoji character, nothing else - no words, no explanation, no punctuation." },
|
||||||
|
{ role: "user", content: "Pick one emoji that best fits the video game \"" + title + "\"." }
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
const text = ((data.message && data.message.content) || "").trim();
|
||||||
|
const match = text.match(/\p{Extended_Pictographic}(️|\p{Extended_Pictographic})*/u);
|
||||||
|
return match ? match[0] : null;
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickFittingEmoji(roles, title) {
|
||||||
|
const suggested = await suggestGameEmoji(title);
|
||||||
|
if (suggested) {
|
||||||
|
const used = new Set(Object.values(roles).map((r) => r.emoji));
|
||||||
|
if (!used.has(suggested)) return suggested;
|
||||||
|
}
|
||||||
|
return pickRoleEmoji(roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findGameRole(roles, query) {
|
||||||
|
const normalized = normalizeTitle(query);
|
||||||
|
if (roles[normalized]) return { key: normalized, entry: roles[normalized] };
|
||||||
|
for (const key of Object.keys(roles)) {
|
||||||
|
if (normalized.includes(key) || key.includes(normalized)) return { key: key, entry: roles[key] };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortedRoleEntries() {
|
||||||
|
const roles = loadRoles();
|
||||||
|
return Object.values(roles).sort((a, b) => a.title.localeCompare(b.title));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_PICKER_MAX = 25;
|
||||||
|
|
||||||
|
function roleMemberCounts(guild, entries) {
|
||||||
|
const counts = {};
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const role = guild && guild.roles.cache.get(entry.roleId);
|
||||||
|
counts[entry.roleId] = role ? role.members.size : 0;
|
||||||
|
});
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRolePickerComponents(entries, counts) {
|
||||||
|
if (entries.length === 0) return { components: [], overflow: false };
|
||||||
|
|
||||||
|
const labelFor = (entry) => {
|
||||||
|
const count = counts ? counts[entry.roleId] : undefined;
|
||||||
|
return count === undefined ? entry.title : entry.title + " (" + count + ")";
|
||||||
|
};
|
||||||
|
|
||||||
|
if (entries.length <= ROLE_PICKER_MAX) {
|
||||||
|
const rows = [];
|
||||||
|
for (let i = 0; i < entries.length; i += 5) {
|
||||||
|
const row = new ActionRowBuilder();
|
||||||
|
entries.slice(i, i + 5).forEach((entry) => {
|
||||||
|
row.addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId("role:" + entry.roleId)
|
||||||
|
.setLabel(labelFor(entry).slice(0, 80))
|
||||||
|
.setEmoji(entry.emoji)
|
||||||
|
.setStyle(ButtonStyle.Secondary)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
return { components: rows, overflow: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const shown = entries.slice(0, ROLE_PICKER_MAX);
|
||||||
|
const menu = new StringSelectMenuBuilder()
|
||||||
|
.setCustomId("role-select")
|
||||||
|
.setPlaceholder("Choose a game role")
|
||||||
|
.addOptions(shown.map((entry) => ({
|
||||||
|
label: labelFor(entry).slice(0, 100),
|
||||||
|
value: entry.roleId,
|
||||||
|
emoji: entry.emoji
|
||||||
|
})));
|
||||||
|
return { components: [new ActionRowBuilder().addComponents(menu)], overflow: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanTitle(title) {
|
||||||
|
return title.replace(/[®™©]/g, "").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureGameRole(guild, title, preferredEmoji) {
|
||||||
|
const cleanedTitle = cleanTitle(title);
|
||||||
|
const roles = loadRoles();
|
||||||
|
const key = normalizeTitle(title);
|
||||||
|
const entry = roles[key];
|
||||||
|
|
||||||
|
if (entry) {
|
||||||
|
const existing = guild.roles.cache.get(entry.roleId) || await guild.roles.fetch(entry.roleId).catch(() => null);
|
||||||
|
if (existing) {
|
||||||
|
let changed = false;
|
||||||
|
if (entry.title !== cleanedTitle) {
|
||||||
|
entry.title = cleanedTitle;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (preferredEmoji && preferredEmoji !== entry.emoji) {
|
||||||
|
entry.emoji = preferredEmoji;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tryNative = entry.iconMode !== "name";
|
||||||
|
const desiredName = tryNative ? cleanedTitle : (entry.emoji + " " + cleanedTitle);
|
||||||
|
const nameOk = existing.name === desiredName;
|
||||||
|
const iconOk = !tryNative || existing.unicodeEmoji === entry.emoji;
|
||||||
|
|
||||||
|
if (!nameOk || !iconOk) {
|
||||||
|
if (tryNative) {
|
||||||
|
try {
|
||||||
|
await existing.edit({ name: cleanedTitle, unicodeEmoji: entry.emoji });
|
||||||
|
entry.iconMode = "native";
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
await existing.setName(entry.emoji + " " + cleanedTitle);
|
||||||
|
entry.iconMode = "name";
|
||||||
|
} catch (err2) {
|
||||||
|
console.error("Could not update role:", err2.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await existing.setName(entry.emoji + " " + cleanedTitle);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Could not update role:", err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
roles[key] = entry;
|
||||||
|
saveRoles(roles);
|
||||||
|
}
|
||||||
|
return { role: existing, emoji: entry.emoji, created: false, title: cleanedTitle };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emoji = preferredEmoji || await pickFittingEmoji(roles, cleanedTitle);
|
||||||
|
let role;
|
||||||
|
let iconMode;
|
||||||
|
try {
|
||||||
|
role = await guild.roles.create({
|
||||||
|
name: cleanedTitle,
|
||||||
|
unicodeEmoji: emoji,
|
||||||
|
mentionable: true,
|
||||||
|
reason: "Game role for " + cleanedTitle
|
||||||
|
});
|
||||||
|
iconMode = "native";
|
||||||
|
} catch (err) {
|
||||||
|
role = await guild.roles.create({
|
||||||
|
name: emoji + " " + cleanedTitle,
|
||||||
|
mentionable: true,
|
||||||
|
reason: "Game role for " + cleanedTitle
|
||||||
|
});
|
||||||
|
iconMode = "name";
|
||||||
|
}
|
||||||
|
roles[key] = { title: cleanedTitle, roleId: role.id, emoji: emoji, iconMode: iconMode };
|
||||||
|
saveRoles(roles);
|
||||||
|
return { role: role, emoji: emoji, created: true, title: cleanedTitle };
|
||||||
|
}
|
||||||
|
|
||||||
function loadSteamLinks() {
|
function loadSteamLinks() {
|
||||||
return JSON.parse(fs.readFileSync(STEAMLINKS_PATH, "utf8"));
|
return JSON.parse(fs.readFileSync(STEAMLINKS_PATH, "utf8"));
|
||||||
}
|
}
|
||||||
@@ -1265,7 +1503,126 @@ async function handleOwn(message, query) {
|
|||||||
owned[message.author.id] = list;
|
owned[message.author.id] = list;
|
||||||
saveOwned(owned);
|
saveOwned(owned);
|
||||||
|
|
||||||
await message.reply("Marked \"" + match.title + "\" as owned.");
|
let roleNote = "";
|
||||||
|
if (message.guild && message.member) {
|
||||||
|
try {
|
||||||
|
const roleResult = await ensureGameRole(message.guild, match.title);
|
||||||
|
await message.member.roles.add(roleResult.role.id);
|
||||||
|
roleNote = " and gave you the " + roleResult.emoji + " " + roleResult.title + " role.";
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Role assignment failed:", err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await message.reply("Marked \"" + match.title + "\" as owned" + (roleNote || "."));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRoles(message) {
|
||||||
|
const entries = sortedRoleEntries();
|
||||||
|
if (entries.length === 0) {
|
||||||
|
await message.reply("No game roles yet. Use !own GAME or !role GAME to create one.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = message.guild;
|
||||||
|
if (guild) {
|
||||||
|
try {
|
||||||
|
await guild.members.fetch();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Could not refresh member cache:", err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = roleMemberCounts(guild, entries);
|
||||||
|
const picker = buildRolePickerComponents(entries, counts);
|
||||||
|
const content = picker.overflow
|
||||||
|
? "Use the dropdown below to add yourself to a role (showing the first " + ROLE_PICKER_MAX + " alphabetically - use !role GAME for any others):"
|
||||||
|
: "Click a button below to add or remove yourself from a role:";
|
||||||
|
await message.reply({ content: content, components: picker.components });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRole(message, input) {
|
||||||
|
if (!input) {
|
||||||
|
await message.reply("Usage: !role GAME NAME or !role EMOJI GAME NAME");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!message.guild) {
|
||||||
|
await message.reply("Roles only work inside a server, not in DMs.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let customEmoji = null;
|
||||||
|
let query = input;
|
||||||
|
const firstSpace = input.indexOf(" ");
|
||||||
|
const firstToken = firstSpace === -1 ? input : input.slice(0, firstSpace);
|
||||||
|
if (isEmoji(firstToken)) {
|
||||||
|
customEmoji = firstToken;
|
||||||
|
query = firstSpace === -1 ? "" : input.slice(firstSpace + 1).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
await message.reply("Usage: !role GAME NAME or !role EMOJI 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 roleResult = await ensureGameRole(message.guild, match.title, customEmoji);
|
||||||
|
if (message.member.roles.cache.has(roleResult.role.id)) {
|
||||||
|
await message.reply("You already have the " + roleResult.emoji + " " + roleResult.title + " role.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await message.member.roles.add(roleResult.role.id);
|
||||||
|
await message.reply((roleResult.created ? "Created the " : "Added you to the ") + roleResult.emoji + " " + roleResult.title + " role.");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Role assignment failed:", err.message);
|
||||||
|
await message.reply("Could not assign that role - make sure I have Manage Roles permission and my role is above the roles I manage.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUnroll(message, query) {
|
||||||
|
if (!query) {
|
||||||
|
await message.reply("Usage: !unroll GAME NAME");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!message.guild) {
|
||||||
|
await message.reply("Roles only work inside a server, not in DMs.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles = loadRoles();
|
||||||
|
const found = findGameRole(roles, query);
|
||||||
|
if (!found) {
|
||||||
|
await message.reply("No role exists for \"" + query + "\".");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message.member.roles.cache.has(found.entry.roleId)) {
|
||||||
|
await message.reply("You don't have the " + found.entry.emoji + " " + found.entry.title + " role.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await message.member.roles.remove(found.entry.roleId);
|
||||||
|
await message.reply("Removed you from the " + found.entry.emoji + " " + found.entry.title + " role.");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Role removal failed:", err.message);
|
||||||
|
await message.reply("Could not remove that role - make sure I have Manage Roles permission.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUnown(message, query) {
|
async function handleUnown(message, query) {
|
||||||
@@ -1676,8 +2033,145 @@ async function handleWiki(message, query) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shQuote(str) {
|
||||||
|
return "'" + String(str).replace(/'/g, "'\\''") + "'";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDockerCommand(args) {
|
||||||
|
const remoteCommand = "docker " + args.map(shQuote).join(" ");
|
||||||
|
const { stdout } = await execFileAsync(
|
||||||
|
"ssh",
|
||||||
|
[
|
||||||
|
"-i", DOCKER_SSH_KEY_PATH,
|
||||||
|
"-o", "StrictHostKeyChecking=no",
|
||||||
|
"-o", "ConnectTimeout=10",
|
||||||
|
DOCKER_SSH_USER + "@" + DOCKER_SSH_HOST,
|
||||||
|
remoteCommand
|
||||||
|
],
|
||||||
|
{ timeout: DOCKER_SSH_TIMEOUT_MS }
|
||||||
|
);
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDockerContainers() {
|
||||||
|
const stdout = await runDockerCommand(["ps", "-a", "--format", "{{.ID}}|{{.Names}}|{{.Status}}"]);
|
||||||
|
return stdout
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
.map((line) => {
|
||||||
|
const parts = line.split("|");
|
||||||
|
return { id: parts[0], name: parts[1], status: parts[2] };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProtectedContainer(name) {
|
||||||
|
return DOCKER_PROTECTED_CONTAINERS.includes(name.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDockerPs(message) {
|
||||||
|
try {
|
||||||
|
const containers = await fetchDockerContainers().then((list) =>
|
||||||
|
list.filter((c) => !isProtectedContainer(c.name))
|
||||||
|
);
|
||||||
|
if (containers.length === 0) {
|
||||||
|
await message.reply("No containers found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lines = containers.map((c) => c.name + " - " + c.status);
|
||||||
|
await message.reply("```\n" + lines.join("\n") + "\n```");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("docker ps failed:", err.message);
|
||||||
|
await message.reply("Could not reach the Docker host.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDockerRestart(message, name) {
|
||||||
|
if (!name) {
|
||||||
|
await message.reply("Usage: !docker restart CONTAINER NAME");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isProtectedContainer(name)) {
|
||||||
|
await message.reply("The \"" + name + "\" container is protected and can't be restarted through the bot.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let containers;
|
||||||
|
try {
|
||||||
|
containers = await fetchDockerContainers();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("docker ps failed:", err.message);
|
||||||
|
await message.reply("Could not reach the Docker host.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = containers.find((c) =>
|
||||||
|
c.name.toLowerCase() === name.toLowerCase() || c.id === name || c.id.startsWith(name.toLowerCase())
|
||||||
|
);
|
||||||
|
if (!match) {
|
||||||
|
await message.reply("No container named \"" + name + "\" found. Use !docker ps to see the list.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isProtectedContainer(match.name)) {
|
||||||
|
await message.reply("The \"" + match.name + "\" container is protected and can't be restarted through the bot.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runDockerCommand(["restart", match.name]);
|
||||||
|
await message.reply("Restarted \"" + match.name + "\".");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("docker restart failed:", err.message);
|
||||||
|
await message.reply("Could not restart \"" + match.name + "\".");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasDockerRole(message) {
|
||||||
|
if (message.guild) {
|
||||||
|
return message.member.roles.cache.has(DOCKER_ROLE_ID);
|
||||||
|
}
|
||||||
|
for (const guild of message.client.guilds.cache.values()) {
|
||||||
|
let member = guild.members.cache.get(message.author.id);
|
||||||
|
if (!member) {
|
||||||
|
member = await guild.members.fetch(message.author.id).catch(() => null);
|
||||||
|
}
|
||||||
|
if (member && member.roles.cache.has(DOCKER_ROLE_ID)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDocker(message, input) {
|
||||||
|
if (!DOCKER_ENABLED) {
|
||||||
|
await message.reply("Docker control isn't set up. Set DOCKER_SSH_HOST, DOCKER_SSH_USER, DOCKER_SSH_KEY_PATH, and DOCKER_ROLE_ID in .env to enable it.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(await hasDockerRole(message))) {
|
||||||
|
await message.reply("You don't have permission to use !docker.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = input.trim().split(/\s+/).filter((p) => p.length > 0);
|
||||||
|
const subcommand = (parts[0] || "").toLowerCase();
|
||||||
|
|
||||||
|
if (subcommand === "ps") {
|
||||||
|
await handleDockerPs(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (subcommand === "restart") {
|
||||||
|
await handleDockerRestart(message, parts.slice(1).join(" "));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await message.reply("Usage: !docker ps or !docker restart CONTAINER NAME");
|
||||||
|
}
|
||||||
|
|
||||||
async function handleHelp(message, note) {
|
async function handleHelp(message, note) {
|
||||||
await message.reply(
|
const dockerLines = (DOCKER_ENABLED && (await hasDockerRole(message)))
|
||||||
|
? "!docker ps - list containers on the game server (requires the Docker role)\n" +
|
||||||
|
"!docker restart CONTAINER NAME - restart a container by name (requires the Docker role, some containers may be protected)\n"
|
||||||
|
: "";
|
||||||
|
const description =
|
||||||
"Commands:\n" +
|
"Commands:\n" +
|
||||||
"!watch GAME - start watching a game for sales\n" +
|
"!watch GAME - start watching a game for sales\n" +
|
||||||
"!remove GAME - stop watching a game (!unwatch also works)\n" +
|
"!remove GAME - stop watching a game (!unwatch also works)\n" +
|
||||||
@@ -1692,6 +2186,10 @@ async function handleHelp(message, note) {
|
|||||||
"!my-steam-profile LINK - link your Steam profile so !info can count games you own automatically\n" +
|
"!my-steam-profile LINK - link your Steam profile so !info can count games you own automatically\n" +
|
||||||
"!who GAME - list who has confirmed they own a game\n" +
|
"!who GAME - list who has confirmed they own a game\n" +
|
||||||
"!popular - list games owned by more than one member, most owned first\n" +
|
"!popular - list games owned by more than one member, most owned first\n" +
|
||||||
|
"!roles - list every game role and how many members have each\n" +
|
||||||
|
"!role GAME - create (if it doesn't exist) and join that game's role, with an emoji picked for you; !role EMOJI GAME to pick your own emoji instead\n" +
|
||||||
|
"!unroll GAME - leave a game's role\n" +
|
||||||
|
dockerLines +
|
||||||
"!remember SOMETHING - permanently teach me a fact (about you or someone else by name), shared with everyone and remembered across restarts\n" +
|
"!remember SOMETHING - permanently teach me a fact (about you or someone else by name), shared with everyone and remembered across restarts\n" +
|
||||||
"!forget SOMETHING - make me forget something you had me remember (must match exactly), or !forget all\n" +
|
"!forget SOMETHING - make me forget something you had me remember (must match exactly), or !forget all\n" +
|
||||||
"!memories - show everything I remember about you\n" +
|
"!memories - show everything I remember about you\n" +
|
||||||
@@ -1700,8 +2198,9 @@ async function handleHelp(message, note) {
|
|||||||
"!trivia - video game trivia, multiple choice, first correct answer in chat wins 100 points\n" +
|
"!trivia - video game trivia, multiple choice, first correct answer in chat wins 100 points\n" +
|
||||||
"!score - show the top 10 members by trivia points\n" +
|
"!score - show the top 10 members by trivia points\n" +
|
||||||
"!wiki QUESTION - ask a specific game question (item locations, boss strategies, and so on) and get an accurate, searched answer" +
|
"!wiki QUESTION - ask a specific game question (item locations, boss strategies, and so on) and get an accurate, searched answer" +
|
||||||
(note ? "\n\n" + note : "")
|
(note ? "\n\n" + note : "");
|
||||||
);
|
const embed = new EmbedBuilder().setDescription(description);
|
||||||
|
await message.reply({ embeds: [embed] });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkPrices(client) {
|
async function checkPrices(client) {
|
||||||
@@ -1853,7 +2352,11 @@ async function checkUpcomingEvents(client) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await channel.send("EVENT: " + event.name + " starting in " + EVENT_REMINDER_MINUTES + " minutes!");
|
const roles = loadRoles();
|
||||||
|
const match = findGameRole(roles, event.name);
|
||||||
|
const role = match && guild.roles.cache.get(match.entry.roleId);
|
||||||
|
const mention = role ? role.toString() + " " : "";
|
||||||
|
await channel.send(mention + "EVENT: " + event.name + " starting soon!");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Could not send event reminder:", err.message);
|
console.error("Could not send event reminder:", err.message);
|
||||||
}
|
}
|
||||||
@@ -1891,11 +2394,20 @@ const client = new Client({
|
|||||||
partials: [Partials.Channel]
|
partials: [Partials.Channel]
|
||||||
});
|
});
|
||||||
|
|
||||||
client.once("clientReady", () => {
|
client.once("clientReady", async () => {
|
||||||
console.log("Logged in as " + client.user.tag);
|
console.log("Logged in as " + client.user.tag);
|
||||||
console.log("Web search: " + (WEB_SEARCH_ENABLED ? "enabled (TAVILY_API_KEY is set)" : "disabled - set TAVILY_API_KEY in .env to enable"));
|
console.log("Web search: " + (WEB_SEARCH_ENABLED ? "enabled (TAVILY_API_KEY is set)" : "disabled - set TAVILY_API_KEY in .env to enable"));
|
||||||
cron.schedule("0 15 * * *", () => checkPrices(client));
|
cron.schedule("0 15 * * *", () => checkPrices(client));
|
||||||
cron.schedule("* * * * *", () => checkUpcomingEvents(client));
|
cron.schedule("* * * * *", () => checkUpcomingEvents(client));
|
||||||
|
|
||||||
|
for (const guild of client.guilds.cache.values()) {
|
||||||
|
try {
|
||||||
|
await guild.members.fetch();
|
||||||
|
console.log("Cached " + guild.memberCount + " members for " + guild.name + ".");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Could not cache members for " + guild.name + ":", err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on("guildMemberAdd", async (member) => {
|
client.on("guildMemberAdd", async (member) => {
|
||||||
@@ -1916,6 +2428,19 @@ client.on("guildMemberAdd", async (member) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Could not send greeting:", err.message);
|
console.error("Could not send greeting:", err.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const entries = sortedRoleEntries();
|
||||||
|
if (entries.length > 0) {
|
||||||
|
const counts = roleMemberCounts(member.guild, entries);
|
||||||
|
const picker = buildRolePickerComponents(entries, counts);
|
||||||
|
const content = "Add yourself to a role for games you own or play, " + member.toString() + ":" +
|
||||||
|
(picker.overflow ? " (showing the first " + ROLE_PICKER_MAX + " alphabetically in the dropdown, !role GAME works for the rest)" : "");
|
||||||
|
try {
|
||||||
|
await channel.send({ content: content, components: picker.components });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Could not send role picker:", err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on("guildMemberRemove", async (member) => {
|
client.on("guildMemberRemove", async (member) => {
|
||||||
@@ -1939,6 +2464,60 @@ client.on("guildMemberRemove", async (member) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function handleRoleInteraction(interaction, roleId) {
|
||||||
|
const roles = loadRoles();
|
||||||
|
const entry = Object.values(roles).find((r) => r.roleId === roleId);
|
||||||
|
if (!entry) {
|
||||||
|
await interaction.reply({ content: "That role no longer exists.", flags: MessageFlags.Ephemeral });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.deferUpdate();
|
||||||
|
|
||||||
|
const hadRole = interaction.member.roles.cache.has(roleId);
|
||||||
|
let confirmation;
|
||||||
|
try {
|
||||||
|
if (hadRole) {
|
||||||
|
await interaction.member.roles.remove(roleId);
|
||||||
|
confirmation = "Removed you from the " + entry.emoji + " " + entry.title + " role.";
|
||||||
|
} else {
|
||||||
|
await interaction.member.roles.add(roleId);
|
||||||
|
confirmation = "Added you to the " + entry.emoji + " " + entry.title + " role.";
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Role button interaction failed:", err.message);
|
||||||
|
await interaction.followUp({ content: "Could not update that role - make sure I have Manage Roles permission and my role is above the roles I manage.", flags: MessageFlags.Ephemeral });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = sortedRoleEntries();
|
||||||
|
const counts = roleMemberCounts(interaction.guild, entries);
|
||||||
|
const role = interaction.guild.roles.cache.get(roleId);
|
||||||
|
const cacheHasMember = role ? role.members.has(interaction.member.id) : false;
|
||||||
|
const shouldHaveMember = !hadRole;
|
||||||
|
if (counts[roleId] !== undefined && cacheHasMember !== shouldHaveMember) {
|
||||||
|
counts[roleId] = Math.max(0, counts[roleId] + (shouldHaveMember ? 1 : -1));
|
||||||
|
}
|
||||||
|
const picker = buildRolePickerComponents(entries, counts);
|
||||||
|
try {
|
||||||
|
await interaction.editReply({ components: picker.components });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Could not refresh role picker message:", err.message);
|
||||||
|
}
|
||||||
|
await interaction.followUp({ content: confirmation, flags: MessageFlags.Ephemeral });
|
||||||
|
}
|
||||||
|
|
||||||
|
client.on("interactionCreate", async (interaction) => {
|
||||||
|
if (interaction.isButton() && interaction.customId.startsWith("role:")) {
|
||||||
|
await handleRoleInteraction(interaction, interaction.customId.slice(5));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (interaction.isStringSelectMenu() && interaction.customId === "role-select") {
|
||||||
|
await handleRoleInteraction(interaction, interaction.values[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
client.on("messageCreate", async (message) => {
|
client.on("messageCreate", async (message) => {
|
||||||
if (message.author.bot) return;
|
if (message.author.bot) return;
|
||||||
|
|
||||||
@@ -1951,6 +2530,11 @@ client.on("messageCreate", async (message) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dmContent === "!docker" || dmContent.startsWith("!docker ")) {
|
||||||
|
await handleDocker(message, message.content.trim().slice(7).trim());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const displayName = message.author.globalName || message.author.username;
|
const displayName = message.author.globalName || message.author.username;
|
||||||
if (dmGreetedUsers.has(message.author.id)) {
|
if (dmGreetedUsers.has(message.author.id)) {
|
||||||
await message.reply("Type !help for a list of my commands to be used in Text Channels.");
|
await message.reply("Type !help for a list of my commands to be used in Text Channels.");
|
||||||
@@ -2004,6 +2588,14 @@ client.on("messageCreate", async (message) => {
|
|||||||
await handleWho(message, content.slice(5).trim());
|
await handleWho(message, content.slice(5).trim());
|
||||||
} else if (lower === "!popular") {
|
} else if (lower === "!popular") {
|
||||||
await handlePopular(message);
|
await handlePopular(message);
|
||||||
|
} else if (lower === "!roles") {
|
||||||
|
await handleRoles(message);
|
||||||
|
} else if (lower.startsWith("!role ")) {
|
||||||
|
await handleRole(message, content.slice(6).trim());
|
||||||
|
} else if (lower.startsWith("!unroll ")) {
|
||||||
|
await handleUnroll(message, content.slice(8).trim());
|
||||||
|
} else if (lower === "!docker" || lower.startsWith("!docker ")) {
|
||||||
|
await handleDocker(message, content.slice(7).trim());
|
||||||
} else if (lower.startsWith("!remember ")) {
|
} else if (lower.startsWith("!remember ")) {
|
||||||
await handleRemember(message, content.slice(10).trim());
|
await handleRemember(message, content.slice(10).trim());
|
||||||
} else if (lower.startsWith("!forget ")) {
|
} else if (lower.startsWith("!forget ")) {
|
||||||
|
|||||||
+50
-2
@@ -1,5 +1,7 @@
|
|||||||
param(
|
param(
|
||||||
[switch]$LinkSteamProfiles
|
[switch]$LinkSteamProfiles,
|
||||||
|
[string]$BulkOwn,
|
||||||
|
[switch]$SyncRoles
|
||||||
)
|
)
|
||||||
|
|
||||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
@@ -16,6 +18,28 @@ if ($LinkSteamProfiles) {
|
|||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($BulkOwn) {
|
||||||
|
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 bulk-own.js $BulkOwn
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($SyncRoles) {
|
||||||
|
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 sync-roles.js
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
function Test-CommandExists($name) {
|
function Test-CommandExists($name) {
|
||||||
return $null -ne (Get-Command $name -ErrorAction SilentlyContinue)
|
return $null -ne (Get-Command $name -ErrorAction SilentlyContinue)
|
||||||
}
|
}
|
||||||
@@ -83,6 +107,10 @@ if (-not (Test-Path "scores.json")) {
|
|||||||
Copy-Item "scores.json.example" "scores.json"
|
Copy-Item "scores.json.example" "scores.json"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path "roles.yaml")) {
|
||||||
|
Copy-Item "roles.yaml.example" "roles.yaml"
|
||||||
|
}
|
||||||
|
|
||||||
if ([string]::IsNullOrWhiteSpace((Get-EnvValue "DISCORD_BOT_TOKEN"))) {
|
if ([string]::IsNullOrWhiteSpace((Get-EnvValue "DISCORD_BOT_TOKEN"))) {
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Need a Discord bot token:"
|
Write-Host "Need a Discord bot token:"
|
||||||
@@ -92,7 +120,7 @@ if ([string]::IsNullOrWhiteSpace((Get-EnvValue "DISCORD_BOT_TOKEN"))) {
|
|||||||
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 "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 " 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 "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 "6. Click OAuth2 > URL Generator, check bot under Scopes, then check Send Messages, Read Message History, and Manage Roles 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 "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 " 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 ""
|
Write-Host ""
|
||||||
@@ -142,6 +170,26 @@ if ([string]::IsNullOrWhiteSpace((Get-EnvValue "ITAD_COUNTRY"))) {
|
|||||||
Set-EnvValue "ITAD_COUNTRY" $itadCountryInput
|
Set-EnvValue "ITAD_COUNTRY" $itadCountryInput
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace((Get-EnvValue "DOCKER_SSH_HOST"))) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Optional - lets people with a specific Discord role run !docker ps and !docker restart CONTAINER to manage Docker containers on a remote server over SSH (e.g. an Unraid box running your game servers). Requires SSH key access to that server already set up:"
|
||||||
|
Write-Host ""
|
||||||
|
$dockerHostInput = Read-Host "SSH host for the Docker server (e.g. chappie.local), or press Enter to skip"
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($dockerHostInput)) {
|
||||||
|
Set-EnvValue "DOCKER_SSH_HOST" $dockerHostInput
|
||||||
|
$dockerUserInput = Read-Host "SSH username (e.g. root)"
|
||||||
|
Set-EnvValue "DOCKER_SSH_USER" $dockerUserInput
|
||||||
|
$dockerKeyInput = Read-Host "Path to the SSH private key to use"
|
||||||
|
Set-EnvValue "DOCKER_SSH_KEY_PATH" $dockerKeyInput
|
||||||
|
$dockerRoleInput = Read-Host "Discord role ID allowed to use !docker (right-click the role in Discord, Copy Role ID)"
|
||||||
|
Set-EnvValue "DOCKER_ROLE_ID" $dockerRoleInput
|
||||||
|
$dockerProtectedInput = Read-Host "Container names to protect from !docker restart, comma-separated (e.g. Plex-Media-Server), or press Enter for none"
|
||||||
|
Set-EnvValue "DOCKER_PROTECTED_CONTAINERS" $dockerProtectedInput
|
||||||
|
} else {
|
||||||
|
Write-Host "Skipping - !docker won't be available. Run this script again any time to set it up."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (-not (Test-CommandExists ollama)) {
|
if (-not (Test-CommandExists ollama)) {
|
||||||
Write-Host ""
|
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]"
|
$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]"
|
||||||
|
|||||||
+50
-1
@@ -15,6 +15,29 @@ if [[ "$1" == "--link-steam-profiles" ]]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "$1" == "--bulk-own" ]]; 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
|
||||||
|
shift
|
||||||
|
echo "Installing dependencies..."
|
||||||
|
npm install --silent
|
||||||
|
node bulk-own.js "$@"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$1" == "--sync-roles" ]]; 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 sync-roles.js
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
OS_NAME="$(uname -s)"
|
OS_NAME="$(uname -s)"
|
||||||
|
|
||||||
if [[ "$OS_NAME" != "Darwin" && "$OS_NAME" != "Linux" ]]; then
|
if [[ "$OS_NAME" != "Darwin" && "$OS_NAME" != "Linux" ]]; then
|
||||||
@@ -86,6 +109,10 @@ if [[ ! -f scores.json ]]; then
|
|||||||
cp scores.json.example scores.json
|
cp scores.json.example scores.json
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f roles.yaml ]]; then
|
||||||
|
cp roles.yaml.example roles.yaml
|
||||||
|
fi
|
||||||
|
|
||||||
CURRENT_TOKEN=$(grep -E "^DISCORD_BOT_TOKEN=" .env | cut -d "=" -f2-)
|
CURRENT_TOKEN=$(grep -E "^DISCORD_BOT_TOKEN=" .env | cut -d "=" -f2-)
|
||||||
CURRENT_KEY=$(grep -E "^ITAD_API_KEY=" .env | cut -d "=" -f2-)
|
CURRENT_KEY=$(grep -E "^ITAD_API_KEY=" .env | cut -d "=" -f2-)
|
||||||
|
|
||||||
@@ -98,7 +125,7 @@ if [[ -z "$CURRENT_TOKEN" ]]; then
|
|||||||
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 "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 " 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 "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 "6. Click OAuth2 > URL Generator, check bot under Scopes, then check Send Messages, Read Message History, and Manage Roles 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 "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 " 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 ""
|
echo ""
|
||||||
@@ -153,6 +180,28 @@ if [[ -z "$CURRENT_ITAD_COUNTRY" ]]; then
|
|||||||
set_env_value ITAD_COUNTRY "${ITAD_COUNTRY_INPUT:-US}"
|
set_env_value ITAD_COUNTRY "${ITAD_COUNTRY_INPUT:-US}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
CURRENT_DOCKER_HOST=$(grep -E "^DOCKER_SSH_HOST=" .env | cut -d "=" -f2-)
|
||||||
|
|
||||||
|
if [[ -z "$CURRENT_DOCKER_HOST" ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "Optional - lets people with a specific Discord role run !docker ps and !docker restart CONTAINER to manage Docker containers on a remote server over SSH (e.g. an Unraid box running your game servers). Requires SSH key access to that server already set up:"
|
||||||
|
echo ""
|
||||||
|
read -p "SSH host for the Docker server (e.g. chappie.local), or press Enter to skip: " DOCKER_HOST_INPUT
|
||||||
|
if [[ -n "$DOCKER_HOST_INPUT" ]]; then
|
||||||
|
set_env_value DOCKER_SSH_HOST "$DOCKER_HOST_INPUT"
|
||||||
|
read -p "SSH username (e.g. root): " DOCKER_USER_INPUT
|
||||||
|
set_env_value DOCKER_SSH_USER "$DOCKER_USER_INPUT"
|
||||||
|
read -p "Path to the SSH private key to use: " DOCKER_KEY_INPUT
|
||||||
|
set_env_value DOCKER_SSH_KEY_PATH "$DOCKER_KEY_INPUT"
|
||||||
|
read -p "Discord role ID allowed to use !docker (right-click the role in Discord, Copy Role ID): " DOCKER_ROLE_INPUT
|
||||||
|
set_env_value DOCKER_ROLE_ID "$DOCKER_ROLE_INPUT"
|
||||||
|
read -p "Container names to protect from !docker restart, comma-separated (e.g. Plex-Media-Server), or press Enter for none: " DOCKER_PROTECTED_INPUT
|
||||||
|
set_env_value DOCKER_PROTECTED_CONTAINERS "$DOCKER_PROTECTED_INPUT"
|
||||||
|
else
|
||||||
|
echo "Skipping - !docker won't be available. Run this script again any time to set it up."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if ! command -v ollama >/dev/null 2>&1; then
|
if ! command -v ollama >/dev/null 2>&1; then
|
||||||
echo ""
|
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
|
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
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
{
|
||||||
|
"007 first light": "https://mapgenie.io/007-first-light",
|
||||||
|
"ac freedom cry": "https://mapgenie.io/assassins-creed-freedom-cry",
|
||||||
|
"ac iii: liberation": "https://mapgenie.io/assassins-creed-3-liberation",
|
||||||
|
"ac iv: black flag": "https://mapgenie.io/assassins-creed-4-black-flag",
|
||||||
|
"ac: brotherhood": "https://mapgenie.io/assassins-creed-brotherhood",
|
||||||
|
"ac: revelations": "https://mapgenie.io/assassins-creed-revelations",
|
||||||
|
"ac: syndicate": "https://mapgenie.io/assassins-creed-syndicate",
|
||||||
|
"american truck simulator": "https://mapgenie.io/american-truck-simulator",
|
||||||
|
"animal well": "https://mapgenie.io/animal-well/maps/world",
|
||||||
|
"arc raiders": "https://mapgenie.io/arc-raiders",
|
||||||
|
"assassin's creed": "https://mapgenie.io/assassins-creed",
|
||||||
|
"assassin's creed black flag resynced": "https://mapgenie.io/assassins-creed-black-flag-resynced",
|
||||||
|
"assassin's creed ii": "https://mapgenie.io/assassins-creed-2",
|
||||||
|
"assassin's creed iii": "https://mapgenie.io/assassins-creed-3",
|
||||||
|
"assassin's creed mirage": "https://mapgenie.io/assassins-creed-mirage",
|
||||||
|
"assassin's creed odyssey": "https://mapgenie.io/assassins-creed-odyssey",
|
||||||
|
"assassin's creed origins": "https://mapgenie.io/assassins-creed-origins",
|
||||||
|
"assassin's creed rogue": "https://mapgenie.io/assassins-creed-rogue",
|
||||||
|
"assassin's creed shadows": "https://mapgenie.io/assassins-creed-shadows",
|
||||||
|
"assassin's creed unity": "https://mapgenie.io/assassins-creed-unity",
|
||||||
|
"assassin's creed valhalla": "https://mapgenie.io/assassins-creed-valhalla",
|
||||||
|
"atomfall": "https://mapgenie.io/atomfall",
|
||||||
|
"avatar: frontiers of pandora": "https://mapgenie.io/avatar-frontiers-of-pandora",
|
||||||
|
"avowed": "https://mapgenie.io/avowed",
|
||||||
|
"baby steps": "https://mapgenie.io/baby-steps/maps/world",
|
||||||
|
"baldur's gate 3": "https://mapgenie.io/baldurs-gate-3",
|
||||||
|
"batman: arkham asylum": "https://mapgenie.io/batman-arkham-asylum",
|
||||||
|
"beast of reincarnation": "https://mapgenie.io/beast-of-reincarnation",
|
||||||
|
"big walk": "https://mapgenie.io/big-walk/maps/island",
|
||||||
|
"black myth: wukong": "https://mapgenie.io/black-myth-wukong",
|
||||||
|
"blasphemous": "https://mapgenie.io/blasphemous/maps/cvstodia",
|
||||||
|
"blasphemous 2": "https://mapgenie.io/blasphemous-2",
|
||||||
|
"blue prince": "https://mapgenie.io/blue-prince/maps/mount-holly",
|
||||||
|
"borderlands": "https://mapgenie.io/borderlands/maps/world",
|
||||||
|
"borderlands 2": "https://mapgenie.io/borderlands-2",
|
||||||
|
"borderlands 3": "https://mapgenie.io/borderlands-3",
|
||||||
|
"borderlands 4": "https://mapgenie.io/borderlands-4",
|
||||||
|
"borderlands: the pre-sequel": "https://mapgenie.io/borderlands-tps/maps/world",
|
||||||
|
"bully": "https://mapgenie.io/bully",
|
||||||
|
"call of duty: black ops 6": "https://mapgenie.io/call-of-duty-black-ops-6",
|
||||||
|
"call of duty: black ops 7": "https://mapgenie.io/cod-black-ops-7",
|
||||||
|
"call of duty: modern warfare iii": "https://mapgenie.io/call-of-duty-modern-warfare-iii/maps/urzikstan-zombies",
|
||||||
|
"call of duty: warzone": "https://mapgenie.io/cod-warzone",
|
||||||
|
"chicory: a colorful tale": "https://mapgenie.io/chicory-a-colorful-tale/maps/picnic-province",
|
||||||
|
"clair obscur: expedition 33": "https://mapgenie.io/clair-obscur-expedition-33",
|
||||||
|
"cloudheim": "https://mapgenie.io/cloudheim/maps/world",
|
||||||
|
"cod: warzone 2.0": "https://mapgenie.io/cod-warzone-2",
|
||||||
|
"conan exiles": "https://mapgenie.io/conan-exiles",
|
||||||
|
"control": "https://mapgenie.io/control/maps/oldest-house",
|
||||||
|
"crimson desert": "https://mapgenie.io/crimson-desert",
|
||||||
|
"cyberpunk 2077": "https://mapgenie.io/cyberpunk-2077",
|
||||||
|
"dark and darker": "https://mapgenie.io/dark-and-darker",
|
||||||
|
"days gone": "https://mapgenie.io/days-gone",
|
||||||
|
"dead island 2": "https://mapgenie.io/dead-island-2",
|
||||||
|
"death stranding": "https://mapgenie.io/death-stranding/maps/world",
|
||||||
|
"death stranding 2": "https://mapgenie.io/death-stranding-2",
|
||||||
|
"diablo immortal": "https://mapgenie.io/diablo-immortal",
|
||||||
|
"diablo iv": "https://mapgenie.io/diablo-4",
|
||||||
|
"disney dreamlight valley": "https://mapgenie.io/disney-dreamlight-valley",
|
||||||
|
"divinity: original sin 2": "https://mapgenie.io/divinity-original-sin-2",
|
||||||
|
"donkey kong bananza": "https://mapgenie.io/donkey-kong-bananza",
|
||||||
|
"doom: the dark ages": "https://mapgenie.io/doom-the-dark-ages/maps/world",
|
||||||
|
"dragon age: the veilguard": "https://mapgenie.io/dragon-age-veilguard",
|
||||||
|
"dragon quest i & ii hd-2d remake": "https://mapgenie.io/dragon-quest-1-and-2-remake",
|
||||||
|
"dragon quest vii: reimagined": "https://mapgenie.io/dragon-quest-vii",
|
||||||
|
"dragon's dogma 2": "https://mapgenie.io/dragons-dogma-2",
|
||||||
|
"dredge": "https://mapgenie.io/dredge",
|
||||||
|
"dune: awakening": "https://mapgenie.io/dune-awakening",
|
||||||
|
"dying light": "https://mapgenie.io/dying-light",
|
||||||
|
"dying light 2": "https://mapgenie.io/dying-light-2",
|
||||||
|
"dying light: the beast": "https://mapgenie.io/dying-light-the-beast/maps/castor-woods",
|
||||||
|
"elden ring": "https://mapgenie.io/elden-ring",
|
||||||
|
"elden ring nightreign": "https://mapgenie.io/elden-ring-nightreign",
|
||||||
|
"enshrouded": "https://mapgenie.io/enshrouded",
|
||||||
|
"escape from duckov": "https://mapgenie.io/escape-from-duckov",
|
||||||
|
"escape from tarkov": "https://mapgenie.io/tarkov",
|
||||||
|
"euro truck simulator 2": "https://mapgenie.io/euro-truck-simulator-2",
|
||||||
|
"exanima": "https://mapgenie.io/exanima/maps/world",
|
||||||
|
"expeditions: a mudrunner game": "https://mapgenie.io/expeditions-a-mudrunner-game",
|
||||||
|
"fallout 3": "https://mapgenie.io/fallout-3",
|
||||||
|
"fallout 4": "https://fallout4map.com",
|
||||||
|
"fallout 76": "https://fo76map.com",
|
||||||
|
"fallout: new vegas": "https://mapgenie.io/fallout-new-vegas",
|
||||||
|
"fantasy life i: the girl who steals time": "https://mapgenie.io/fantasy-life-i-the-girl-who-steals-time/maps/ginormosia",
|
||||||
|
"far cry 3": "https://mapgenie.io/far-cry-3/maps/rook-islands",
|
||||||
|
"far cry 3: blood dragon": "https://mapgenie.io/far-cry-3-blood-dragon/maps/world",
|
||||||
|
"far cry 4": "https://mapgenie.io/far-cry-4",
|
||||||
|
"far cry 5": "https://mapgenie.io/far-cry-5",
|
||||||
|
"far cry 6": "https://mapgenie.io/far-cry-6",
|
||||||
|
"far cry new dawn": "https://mapgenie.io/far-cry-new-dawn/maps/hope-county",
|
||||||
|
"far cry primal": "https://mapgenie.io/far-cry-primal/maps/oros",
|
||||||
|
"final fantasy vii rebirth": "https://mapgenie.io/final-fantasy-vii-rebirth/maps/midgar",
|
||||||
|
"final fantasy xiv": "https://mapgenie.io/final-fantasy-xiv",
|
||||||
|
"final fantasy xvi": "https://mapgenie.io/final-fantasy-xvi",
|
||||||
|
"fortnite (chapter 5)": "https://mapgenie.io/fortnite",
|
||||||
|
"forza horizon 1": "https://mapgenie.io/forza-horizon-1/maps/colorado",
|
||||||
|
"forza horizon 4": "https://mapgenie.io/forza-horizon-4",
|
||||||
|
"forza horizon 5": "https://mapgenie.io/forza-horizon-5",
|
||||||
|
"forza horizon 6": "https://mapgenie.io/forza-horizon-6",
|
||||||
|
"genshin impact": "https://mapgenie.io/genshin-impact",
|
||||||
|
"ghost of tsushima": "https://mapgenie.io/ghost-of-tsushima",
|
||||||
|
"ghost of yōtei": "https://mapgenie.io/ghost-of-yotei",
|
||||||
|
"ghostwire: tokyo": "https://mapgenie.io/ghostwire-tokyo",
|
||||||
|
"goat simulator 3": "https://mapgenie.io/goat-simulator-3",
|
||||||
|
"god of war (2018)": "https://mapgenie.io/god-of-war-2018",
|
||||||
|
"god of war ragnarök": "https://mapgenie.io/god-of-war-ragnarok",
|
||||||
|
"gotham knights": "https://mapgenie.io/gotham-knights/maps/gotham-city",
|
||||||
|
"gothic 1 remake": "https://mapgenie.io/gothic-1-remake",
|
||||||
|
"grand theft auto": "https://mapgenie.io/grand-theft-auto",
|
||||||
|
"grand theft auto 5": "https://gta-5-map.com",
|
||||||
|
"grand theft auto: chinatown wars": "https://mapgenie.io/grand-theft-auto-chinatown-wars/maps/liberty-city",
|
||||||
|
"gray zone warfare": "https://mapgenie.io/gray-zone-warfare",
|
||||||
|
"green hell": "https://mapgenie.io/green-hell",
|
||||||
|
"grounded": "https://mapgenie.io/grounded",
|
||||||
|
"grounded 2": "https://mapgenie.io/grounded-2",
|
||||||
|
"gta ii": "https://mapgenie.io/grand-theft-auto-2",
|
||||||
|
"gta iii": "https://mapgenie.io/grand-theft-auto-3",
|
||||||
|
"gta iv": "https://mapgenie.io/grand-theft-auto-4",
|
||||||
|
"gta san andreas": "https://mapgenie.io/grand-theft-auto-san-andreas",
|
||||||
|
"gta vice city": "https://mapgenie.io/grand-theft-auto-vice-city",
|
||||||
|
"halo infinite": "https://mapgenie.io/halo-infinite",
|
||||||
|
"hello kitty island adventure": "https://mapgenie.io/hello-kitty-island-adventure",
|
||||||
|
"hitman": "https://mapgenie.io/hitman",
|
||||||
|
"hitman 2": "https://mapgenie.io/hitman-2",
|
||||||
|
"hitman 3": "https://mapgenie.io/hitman-3",
|
||||||
|
"hogwarts legacy": "https://mapgenie.io/hogwarts-legacy",
|
||||||
|
"hollow knight": "https://mapgenie.io/hollow-knight",
|
||||||
|
"hollow knight: silksong": "https://mapgenie.io/hollow-knight-silksong",
|
||||||
|
"horizon forbidden west": "https://mapgenie.io/horizon-forbidden-west",
|
||||||
|
"horizon zero dawn": "https://mapgenie.io/horizon-zero-dawn",
|
||||||
|
"icarus": "https://mapgenie.io/icarus",
|
||||||
|
"immortals fenyx rising": "https://mapgenie.io/immortals-fenyx-rising",
|
||||||
|
"indiana jones and the great circle": "https://mapgenie.io/indiana-jones-and-the-great-circle",
|
||||||
|
"infinity nikki": "https://mapgenie.io/infinity-nikki",
|
||||||
|
"just cause": "https://mapgenie.io/just-cause/maps/san-esperito",
|
||||||
|
"just cause 2": "https://mapgenie.io/just-cause-2",
|
||||||
|
"just cause 3": "https://mapgenie.io/just-cause-3",
|
||||||
|
"just cause 4": "https://mapgenie.io/just-cause-4",
|
||||||
|
"kena: bridge of spirits": "https://mapgenie.io/kena-bridge-of-spirits",
|
||||||
|
"kingdom come: deliverance": "https://mapgenie.io/kingdom-come-deliverance",
|
||||||
|
"kingdom come: deliverance ii": "https://mapgenie.io/kingdom-come-deliverance-2",
|
||||||
|
"kingdom hearts 3d": "https://mapgenie.io/kingdom-hearts-3d",
|
||||||
|
"l.a. noire": "https://mapgenie.io/la-noire/maps/los-angeles",
|
||||||
|
"lego batman: legacy of the dark knight": "https://mapgenie.io/lego-batman-legacy-of-the-dark-knight",
|
||||||
|
"like a dragon: infinite wealth": "https://mapgenie.io/like-a-dragon-infinite-wealth",
|
||||||
|
"lost ark": "https://mapgenie.io/lost-ark",
|
||||||
|
"luigi's mansion": "https://mapgenie.io/luigis-mansion/maps/luigis-mansion",
|
||||||
|
"luigi's mansion 2": "https://mapgenie.io/luigis-mansion-2",
|
||||||
|
"luigi's mansion 3": "https://mapgenie.io/luigis-mansion-3/maps/the-last-resort",
|
||||||
|
"mafia ii": "https://mapgenie.io/mafia-2/maps/empire-bay",
|
||||||
|
"mafia iii": "https://mapgenie.io/mafia-3/maps/new-bordeaux",
|
||||||
|
"mafia: definitive edition": "https://mapgenie.io/mafia",
|
||||||
|
"mafia: the old country": "https://mapgenie.io/mafia-the-old-country",
|
||||||
|
"maneater": "https://mapgenie.io/maneater",
|
||||||
|
"marathon": "https://mapgenie.io/marathon",
|
||||||
|
"mario kart world": "https://mapgenie.io/mario-kart-world/maps/world",
|
||||||
|
"marvel's spider-man": "https://mapgenie.io/marvels-spider-man",
|
||||||
|
"marvel's spider-man 2": "https://mapgenie.io/marvels-spider-man-2",
|
||||||
|
"marvel's spider-man: miles morales": "https://mapgenie.io/marvels-spider-man-miles-morales/maps/manhattan",
|
||||||
|
"mass effect": "https://mapgenie.io/mass-effect",
|
||||||
|
"medieval dynasty": "https://mapgenie.io/medieval-dynasty/maps/world",
|
||||||
|
"metroid dread": "https://mapgenie.io/metroid-dread/maps/zdr-planet-map",
|
||||||
|
"mina the hollower": "https://mapgenie.io/mina-the-hollower",
|
||||||
|
"minishoot' adventures": "https://mapgenie.io/minishoot-adventures/maps/world",
|
||||||
|
"mortal shell ii": "https://mapgenie.io/mortal-shell-ii",
|
||||||
|
"my time at sandrock": "https://mapgenie.io/my-time-at-sandrock",
|
||||||
|
"new world": "https://mapgenie.io/new-world",
|
||||||
|
"nfs: most wanted": "https://mapgenie.io/need-for-speed-most-wanted-2012/maps/fairhaven-city",
|
||||||
|
"once human [wip]": "https://mapgenie.io/once-human",
|
||||||
|
"onimusha: way of the sword": "https://mapgenie.io/onimusha-way-of-the-sword",
|
||||||
|
"ori and the blind forest: definitive edition": "https://mapgenie.io/ori-and-the-blind-forest",
|
||||||
|
"outriders": "https://mapgenie.io/outriders",
|
||||||
|
"palia": "https://mapgenie.io/palia",
|
||||||
|
"palworld": "https://mapgenie.io/palworld",
|
||||||
|
"phasmophobia": "https://mapgenie.io/phasmophobia",
|
||||||
|
"pokémon firered/leafgreen": "https://mapgenie.io/pokemon-firered-leafgreen",
|
||||||
|
"pokémon legends: arceus": "https://mapgenie.io/pokemon-legends-arceus",
|
||||||
|
"pokémon legends: z-a": "https://mapgenie.io/pokemon-legends-z-a",
|
||||||
|
"pokémon pokopia": "https://mapgenie.io/pokopia",
|
||||||
|
"pokémon red/blue/yellow": "https://mapgenie.io/pokemon-red-blue-yellow",
|
||||||
|
"pokémon scarlet & violet": "https://mapgenie.io/pokemon-scarlet-violet",
|
||||||
|
"pragmata": "https://mapgenie.io/pragmata",
|
||||||
|
"prey": "https://mapgenie.io/prey-2017",
|
||||||
|
"prince of persia: the lost crown": "https://mapgenie.io/prince-of-persia-the-lost-crown",
|
||||||
|
"rage 2": "https://mapgenie.io/rage-2/maps/wasteland",
|
||||||
|
"ratchet & clank: rift apart": "https://mapgenie.io/ratchet-and-clank-rift-apart",
|
||||||
|
"red dead redemption": "https://mapgenie.io/red-dead-redemption",
|
||||||
|
"red dead redemption 2": "https://rdr2map.com",
|
||||||
|
"red faction: guerrilla": "https://mapgenie.io/red-faction-guerrilla",
|
||||||
|
"resident evil 2 (remake)": "https://mapgenie.io/resident-evil-2-remake",
|
||||||
|
"resident evil 4 (remake)": "https://mapgenie.io/resident-evil-4-remake/maps/world",
|
||||||
|
"resident evil 5": "https://mapgenie.io/resident-evil-5",
|
||||||
|
"resident evil 7: biohazard": "https://mapgenie.io/resident-evil-7-biohazard",
|
||||||
|
"resident evil requiem": "https://mapgenie.io/resident-evil-requiem",
|
||||||
|
"resident evil village": "https://mapgenie.io/resident-evil-village",
|
||||||
|
"rise of the ronin": "https://mapgenie.io/rise-of-the-ronin",
|
||||||
|
"runescape (old school)": "https://mapgenie.io/old-school-runescape/maps/runescape-surface",
|
||||||
|
"runescape: dragonwilds": "https://mapgenie.io/runescape-dragonwilds",
|
||||||
|
"s.t.a.l.k.e.r. 2: heart of chornobyl": "https://mapgenie.io/stalker-2-heart-of-chornobyl",
|
||||||
|
"sable": "https://mapgenie.io/sable",
|
||||||
|
"saints row (2022)": "https://mapgenie.io/saints-row-2022/maps/santo-ileso",
|
||||||
|
"saints row iv": "https://mapgenie.io/saints-row-4",
|
||||||
|
"sand land": "https://mapgenie.io/sand-land",
|
||||||
|
"satisfactory": "https://mapgenie.io/satisfactory",
|
||||||
|
"schedule i": "https://mapgenie.io/schedule-1/maps/hyland-point",
|
||||||
|
"silent hill f": "https://mapgenie.io/silent-hill-f",
|
||||||
|
"skull and bones": "https://mapgenie.io/skull-and-bones/maps/indian-ocean",
|
||||||
|
"skyrim": "https://mapgenie.io/skyrim",
|
||||||
|
"sleeping dogs": "https://mapgenie.io/sleeping-dogs/maps/hong-kong",
|
||||||
|
"slime rancher": "https://mapgenie.io/slime-rancher",
|
||||||
|
"slime rancher 2": "https://mapgenie.io/slime-rancher-2",
|
||||||
|
"sniper elite 4": "https://mapgenie.io/sniper-elite-4/maps/italia",
|
||||||
|
"sniper elite iii": "https://mapgenie.io/sniper-elite-3",
|
||||||
|
"sniper elite v2": "https://mapgenie.io/sniper-elite-v2",
|
||||||
|
"sonic frontiers": "https://mapgenie.io/sonic-frontiers",
|
||||||
|
"sons of the forest": "https://mapgenie.io/sons-of-the-forest/maps/world",
|
||||||
|
"south park: the stick of truth": "https://mapgenie.io/south-park-the-stick-of-truth",
|
||||||
|
"star wars jedi: survivor": "https://mapgenie.io/star-wars-jedi-survivor",
|
||||||
|
"star wars outlaws": "https://mapgenie.io/star-wars-outlaws",
|
||||||
|
"starfield": "https://mapgenie.io/starfield",
|
||||||
|
"starrupture": "https://mapgenie.io/starrupture/maps/arcadia-7",
|
||||||
|
"stellar blade": "https://mapgenie.io/stellar-blade",
|
||||||
|
"subnautica": "https://mapgenie.io/subnautica",
|
||||||
|
"subnautica 2": "https://mapgenie.io/subnautica-2",
|
||||||
|
"subnautica: below zero": "https://mapgenie.io/subnautica-below-zero",
|
||||||
|
"sw: jedi fallen order": "https://mapgenie.io/star-wars-jedi-fallen-order",
|
||||||
|
"tainted grail: the fall of avalon": "https://mapgenie.io/tainted-grail-the-fall-of-avalon",
|
||||||
|
"tales of arise": "https://mapgenie.io/tales-of-arise",
|
||||||
|
"temtem": "https://mapgenie.io/temtem/maps/airborne-archipelago",
|
||||||
|
"the adventures of elliot: the millennium tales": "https://mapgenie.io/the-adventures-of-elliot",
|
||||||
|
"the blood of dawnwalker": "https://mapgenie.io/the-blood-of-dawnwalker",
|
||||||
|
"the cycle: frontier": "https://mapgenie.io/the-cycle-frontier",
|
||||||
|
"the division 2": "https://division2map.com",
|
||||||
|
"the elder scrolls iv: oblivion": "https://mapgenie.io/oblivion",
|
||||||
|
"the forest": "https://mapgenie.io/the-forest/maps/the-peninsula",
|
||||||
|
"the legend of zelda": "https://mapgenie.io/the-legend-of-zelda",
|
||||||
|
"the outer worlds": "https://mapgenie.io/the-outer-worlds",
|
||||||
|
"the outer worlds 2": "https://mapgenie.io/the-outer-worlds-2",
|
||||||
|
"the texas chain saw massacre": "https://mapgenie.io/texas-chain-saw-massacre",
|
||||||
|
"the witcher 3": "https://mapgenie.io/witcher-3",
|
||||||
|
"tiny tina's wonderlands": "https://mapgenie.io/tiny-tinas-wonderlands",
|
||||||
|
"titan quest ii": "https://mapgenie.io/titan-quest-2",
|
||||||
|
"tunic": "https://mapgenie.io/tunic",
|
||||||
|
"v rising": "https://mapgenie.io/v-rising",
|
||||||
|
"vampyr": "https://mapgenie.io/vampyr/maps/london",
|
||||||
|
"watch dogs 2": "https://mapgenie.io/watch-dogs-2/maps/san-francisco",
|
||||||
|
"watch dogs: legion": "https://mapgenie.io/watch-dogs-legion/maps/london",
|
||||||
|
"where winds meet": "https://mapgenie.io/where-winds-meet",
|
||||||
|
"wild hearts": "https://mapgenie.io/wild-hearts",
|
||||||
|
"world of warcraft": "https://mapgenie.io/world-of-warcraft",
|
||||||
|
"wuthering waves": "https://mapgenie.io/wuthering-waves",
|
||||||
|
"yakuza 0": "https://mapgenie.io/yakuza-0",
|
||||||
|
"zelda ii: the adventure of link": "https://mapgenie.io/zelda-2-the-adventure-of-link/maps/hyrule",
|
||||||
|
"zelda: a link between worlds": "https://mapgenie.io/zelda-a-link-between-worlds/maps/hyrule-lorule",
|
||||||
|
"zelda: a link to the past": "https://mapgenie.io/zelda-a-link-to-the-past/maps/world",
|
||||||
|
"zelda: breath of the wild": "https://mapgenie.io/zelda-breath-of-the-wild/maps/hyrule",
|
||||||
|
"zelda: echoes of wisdom": "https://mapgenie.io/zelda-echoes-of-wisdom",
|
||||||
|
"zelda: link's awakening": "https://mapgenie.io/zelda-links-awakening/maps/koholint-island",
|
||||||
|
"zelda: tears of the kingdom": "https://mapgenie.io/zelda-tears-of-the-kingdom",
|
||||||
|
"zelda: wind waker": "https://mapgenie.io/zelda-wind-waker/maps/great-sea"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
require("dotenv").config();
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const yaml = require("js-yaml");
|
||||||
|
const readline = require("node:readline/promises");
|
||||||
|
const { stdin: input, stdout: output } = require("node:process");
|
||||||
|
|
||||||
|
const DISCORD_BOT_TOKEN = process.env.DISCORD_BOT_TOKEN;
|
||||||
|
const DISCORD_API = "https://discord.com/api/v10";
|
||||||
|
const ROLES_PATH = path.join(__dirname, "roles.yaml");
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRoles() {
|
||||||
|
try {
|
||||||
|
const data = yaml.load(fs.readFileSync(ROLES_PATH, "utf8"));
|
||||||
|
return (data && typeof data === "object") ? data : {};
|
||||||
|
} catch (err) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 fetchGuildRoles(guildId) {
|
||||||
|
const res = await fetch(DISCORD_API + "/guilds/" + guildId + "/roles", {
|
||||||
|
headers: { Authorization: "Bot " + DISCORD_BOT_TOKEN }
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Discord role list failed: " + res.status);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameRole(guildId, roleId, name) {
|
||||||
|
const res = await fetch(DISCORD_API + "/guilds/" + guildId + "/roles/" + roleId, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bot " + DISCORD_BOT_TOKEN,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: name })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("role rename failed: " + res.status + " " + (await res.text()));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 roles = loadRoles();
|
||||||
|
const keys = Object.keys(roles);
|
||||||
|
if (keys.length === 0) {
|
||||||
|
console.log("No roles in roles.yaml, nothing to sync.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let guilds;
|
||||||
|
try {
|
||||||
|
guilds = await fetchBotGuilds();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Could not load your Discord server list:", err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!guilds || guilds.length === 0) {
|
||||||
|
console.error("The bot is not in any Discord server yet.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let guild = guilds[0];
|
||||||
|
if (guilds.length > 1) {
|
||||||
|
const rl = readline.createInterface({ input, output });
|
||||||
|
guild = await pickFromList(rl, "Which Discord server?", guilds, (g) => g.name);
|
||||||
|
rl.close();
|
||||||
|
if (!guild) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let discordRoles;
|
||||||
|
try {
|
||||||
|
discordRoles = await fetchGuildRoles(guild.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Could not load server roles:", err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const byId = {};
|
||||||
|
discordRoles.forEach((r) => {
|
||||||
|
byId[r.id] = r;
|
||||||
|
});
|
||||||
|
|
||||||
|
let updated = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const entry = roles[key];
|
||||||
|
const desiredName = entry.emoji + " " + entry.title;
|
||||||
|
const current = byId[entry.roleId];
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
console.error("Role for \"" + entry.title + "\" no longer exists in Discord, skipping.");
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.name === desiredName) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await renameRole(guild.id, entry.roleId, desiredName);
|
||||||
|
console.log("Updated \"" + current.name + "\" to \"" + desiredName + "\".");
|
||||||
|
updated++;
|
||||||
|
} catch (err) {
|
||||||
|
failed++;
|
||||||
|
console.error("Could not update the role for \"" + entry.title + "\":", err.message);
|
||||||
|
}
|
||||||
|
await sleep(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
console.log("Done. Updated: " + updated + ", already in sync or skipped: " + skipped +
|
||||||
|
(failed > 0 ? ", failed: " + failed : "") + ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
Reference in new issue
Block a user