add game role commands (roles, role, unroll) with emoji role picker
This commit is contained in:
@@ -5,6 +5,7 @@ owned.json
|
||||
steamlinks.json
|
||||
memory.yaml
|
||||
scores.json
|
||||
roles.yaml
|
||||
.link-steam-profiles-owner.json
|
||||
bot.log
|
||||
bot.error.log
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Discord Game Price Bot
|
||||
|
||||
A Discord bot for your server. Type `!watch GAME NAME` in any channel to start tracking a PC game's price, `!remove GAME NAME` (or `!unwatch GAME NAME`) to stop, `!list` to see what is being watched, `!price GAME NAME` to check the best price without watching it, `!prices GAME NAME` to list every store it's on sale at, cheapest first, `!history GAME NAME` to see every recorded sale over the past 12 months with date and price, `!predict GAME NAME` to guess when it might go on sale next, `!info GAME NAME` to see its platforms, multiplayer/co-op/cross-play info, a link to its store page, and how many people on the server own it, `!own GAME NAME` to mark a game as owned by you, `!unown GAME NAME` to remove it from your owned list, `!my-steam-profile LINK` to link your Steam profile so `!info` can count games you own automatically, `!who GAME NAME` to list who has confirmed they own a game, `!popular` to list games owned by more than one member, most owned first, `!remember SOMETHING` to permanently teach it a fact that survives restarts and is shared with everyone it talks to, `!forget SOMETHING` (or `!forget all`) to make it forget something you had it remember, `!memories` to see everything it remembers about you, `!check` to run the sale check on demand, `!shops` to see which stores it covers, `!wiki QUESTION` to ask a specific game question (item locations, boss strategies, release dates, and so on) and get a real, searched answer. Once a day it checks the best current price across PC stores for everything watched and posts in the channel it was added from when something is on sale, with the title, sale price, which store it is at, and how long the sale lasts if that store reports an end date. If a sale is already running and about to end (within 48 hours) by the time of a later daily check, it posts a separate one-time reminder for that same channel so you don't miss it. 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.
|
||||
|
||||
@@ -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.
|
||||
3. Under Privileged Gateway Intents, turn on "Message Content Intent" (required to read `!watch` style commands) and "Server Members Intent" (required to detect when someone joins and send a greeting). Click Save Changes.
|
||||
4. Click Reset Token, copy it. This is your `DISCORD_BOT_TOKEN`, keep it secret.
|
||||
5. Go to OAuth2 > URL Generator, check the "bot" scope, then under Bot Permissions check "Send Messages" and "Read Message History". A URL appears at the bottom of the page.
|
||||
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.
|
||||
|
||||
### 2. Get a free IsThereAnyDeal API key
|
||||
@@ -85,10 +85,14 @@ To change the token or key later, edit `.env` directly, then run the installer a
|
||||
- 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.
|
||||
- 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 added game roles after people had already been using `!own`, run `bash install.sh --backfill-roles` (macOS/Linux) or `powershell -ExecutionPolicy Bypass -File install.ps1 -BackfillRoles` (Windows) once to catch up: it reads everyone's existing `owned.json` entries, creates any missing roles, and assigns each person every role for a game they already marked as owned. It only looks at `!own` data, not Steam-linked libraries. Safe to run more than once, it skips anyone who already has the role. This does not restart the bot.
|
||||
- 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.
|
||||
- `!remember SOMETHING` teaches it a permanent fact - a name, a preference, a correction like "don't call me buddy", or something about another member mentioned by name - that it will always take into account, even after the bot restarts. These facts are shared: anyone can ask about a fact someone else taught it, not just the person who typed `!remember`. They're saved to `memory.yaml` under whoever typed the command, and never expire on their own, unlike the short-term memory described below. `!forget SOMETHING` removes one you personally taught it (it has to match what you typed to `!remember`, word for word - use `!memories` to see the exact wording it has stored under your name), and `!forget all` clears everything you've had it remember. Each person can have up to 20 things remembered at a time.
|
||||
- `OLLAMA_MODEL` in `.env` controls which model it uses (`llama3.2` by default - small, fast, and good enough for short in-character replies). To use a different one, pull it yourself with `ollama pull MODEL_NAME`, set `OLLAMA_MODEL=MODEL_NAME` in `.env`, and restart it (re-run the installer, or `pm2 restart discord-bot`). Bigger models give better answers but take longer to reply and use more of your computer's memory.
|
||||
- `TAVILY_API_KEY` in `.env` is optional and off by default, 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.
|
||||
- `!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.
|
||||
- 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,7 @@ require("dotenv").config({ path: path.join(__dirname, ".env") });
|
||||
const fs = require("fs");
|
||||
const cron = require("node-cron");
|
||||
const yaml = require("js-yaml");
|
||||
const { Client, GatewayIntentBits, Partials, MessageFlags, EmbedBuilder, GuildScheduledEventStatus } = require("discord.js");
|
||||
const { Client, GatewayIntentBits, Partials, MessageFlags, EmbedBuilder, GuildScheduledEventStatus, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder } = require("discord.js");
|
||||
|
||||
const rawLog = console.log.bind(console);
|
||||
const rawError = console.error.bind(console);
|
||||
@@ -36,6 +36,7 @@ const STEAMLINKS_PATH = path.join(__dirname, "steamlinks.json");
|
||||
const SCORES_PATH = path.join(__dirname, "scores.json");
|
||||
const TRIVIA_PATH = path.join(__dirname, "trivia.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 GOODBYES_PATH = path.join(__dirname, "goodbyes.yaml");
|
||||
const README_PATH = path.join(__dirname, "README.md");
|
||||
@@ -370,6 +371,217 @@ function saveOwned(owned) {
|
||||
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 buildRolePickerComponents(entries) {
|
||||
if (entries.length === 0) return { components: [], overflow: false };
|
||||
|
||||
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(entry.title.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: entry.title.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() {
|
||||
return JSON.parse(fs.readFileSync(STEAMLINKS_PATH, "utf8"));
|
||||
}
|
||||
@@ -1265,7 +1477,154 @@ async function handleOwn(message, query) {
|
||||
owned[message.author.id] = list;
|
||||
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 lines = entries.map((entry) => {
|
||||
const role = guild && guild.roles.cache.get(entry.roleId);
|
||||
const count = role ? role.members.size : 0;
|
||||
return entry.emoji + " **" + entry.title + "** - " + count + (count === 1 ? " member" : " members");
|
||||
});
|
||||
|
||||
const chunks = [];
|
||||
let chunk = "";
|
||||
for (const line of lines) {
|
||||
const candidate = chunk ? chunk + "\n" + line : line;
|
||||
if (candidate.length > 4000) {
|
||||
chunks.push(chunk);
|
||||
chunk = line;
|
||||
} else {
|
||||
chunk = candidate;
|
||||
}
|
||||
}
|
||||
if (chunk) chunks.push(chunk);
|
||||
|
||||
const picker = buildRolePickerComponents(entries);
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(i === 0 ? "Game roles" : "Game roles (continued)")
|
||||
.setDescription(chunks[i]);
|
||||
const payload = { embeds: [embed] };
|
||||
if (i === chunks.length - 1 && picker.components.length > 0) {
|
||||
payload.components = picker.components;
|
||||
payload.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 on a button below to add yourself to a role:";
|
||||
}
|
||||
await message.reply(payload);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -1692,6 +2051,9 @@ async function handleHelp(message, note) {
|
||||
"!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" +
|
||||
"!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" +
|
||||
"!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" +
|
||||
"!memories - show everything I remember about you\n" +
|
||||
@@ -1891,11 +2253,20 @@ const client = new Client({
|
||||
partials: [Partials.Channel]
|
||||
});
|
||||
|
||||
client.once("clientReady", () => {
|
||||
client.once("clientReady", async () => {
|
||||
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"));
|
||||
cron.schedule("0 15 * * *", () => checkPrices(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) => {
|
||||
@@ -1916,6 +2287,18 @@ client.on("guildMemberAdd", async (member) => {
|
||||
} catch (err) {
|
||||
console.error("Could not send greeting:", err.message);
|
||||
}
|
||||
|
||||
const entries = sortedRoleEntries();
|
||||
if (entries.length > 0) {
|
||||
const picker = buildRolePickerComponents(entries);
|
||||
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) => {
|
||||
@@ -1939,6 +2322,39 @@ 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;
|
||||
}
|
||||
|
||||
try {
|
||||
if (interaction.member.roles.cache.has(roleId)) {
|
||||
await interaction.member.roles.remove(roleId);
|
||||
await interaction.reply({ content: "Removed you from the " + entry.emoji + " " + entry.title + " role.", flags: MessageFlags.Ephemeral });
|
||||
} else {
|
||||
await interaction.member.roles.add(roleId);
|
||||
await interaction.reply({ content: "Added you to the " + entry.emoji + " " + entry.title + " role.", flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Role button interaction failed:", err.message);
|
||||
await interaction.reply({ 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 });
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (message.author.bot) return;
|
||||
|
||||
@@ -2004,6 +2420,12 @@ client.on("messageCreate", async (message) => {
|
||||
await handleWho(message, content.slice(5).trim());
|
||||
} else if (lower === "!popular") {
|
||||
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.startsWith("!remember ")) {
|
||||
await handleRemember(message, content.slice(10).trim());
|
||||
} else if (lower.startsWith("!forget ")) {
|
||||
|
||||
+42
-2
@@ -1,5 +1,8 @@
|
||||
param(
|
||||
[switch]$LinkSteamProfiles
|
||||
[switch]$LinkSteamProfiles,
|
||||
[switch]$BackfillRoles,
|
||||
[string]$BulkOwn,
|
||||
[switch]$SyncRoles
|
||||
)
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
@@ -16,6 +19,39 @@ if ($LinkSteamProfiles) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($BackfillRoles) {
|
||||
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 backfill-roles.js
|
||||
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) {
|
||||
return $null -ne (Get-Command $name -ErrorAction SilentlyContinue)
|
||||
}
|
||||
@@ -83,6 +119,10 @@ if (-not (Test-Path "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"))) {
|
||||
Write-Host ""
|
||||
Write-Host "Need a Discord bot token:"
|
||||
@@ -92,7 +132,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 " The toggles by themselves do not save. If you skip Save Changes, the bot will fail to log in with a disallowed intents error."
|
||||
Write-Host "5. Click Reset Token (or Copy) and copy the token"
|
||||
Write-Host "6. Click OAuth2 > URL Generator, check bot under Scopes, then check Send Messages and Read Message History under Bot Permissions"
|
||||
Write-Host "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 " 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 ""
|
||||
|
||||
+39
-1
@@ -15,6 +15,40 @@ if [[ "$1" == "--link-steam-profiles" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$1" == "--backfill-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 backfill-roles.js
|
||||
exit 0
|
||||
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)"
|
||||
|
||||
if [[ "$OS_NAME" != "Darwin" && "$OS_NAME" != "Linux" ]]; then
|
||||
@@ -86,6 +120,10 @@ if [[ ! -f scores.json ]]; then
|
||||
cp scores.json.example scores.json
|
||||
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_KEY=$(grep -E "^ITAD_API_KEY=" .env | cut -d "=" -f2-)
|
||||
|
||||
@@ -98,7 +136,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 " The toggles by themselves do not save. If you skip Save Changes, the bot will fail to log in with a disallowed intents error."
|
||||
echo "5. Click Reset Token (or Copy) and copy the token"
|
||||
echo "6. Click OAuth2 > URL Generator, check bot under Scopes, then check Send Messages and Read Message History under Bot Permissions"
|
||||
echo "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 " 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 ""
|
||||
|
||||
@@ -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