From 22298e91d2b756575cf247b4b3267039819f34cb Mon Sep 17 00:00:00 2001 From: Justin Oros Date: Fri, 25 Sep 2026 18:17:24 -0700 Subject: [PATCH] add docker command for restarting containers over ssh --- README.md | 1 + index.js | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++ install.ps1 | 32 ++++++++----- install.sh | 33 ++++++++----- 4 files changed, 175 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 46954f6..b5ba0d1 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ To change the token or key later, edit `.env` directly, then run the installer a - `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. +- `!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; nothing here works in DMs. - `!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: diff --git a/index.js b/index.js index eb49460..b9cc4d4 100644 --- a/index.js +++ b/index.js @@ -3,6 +3,8 @@ require("dotenv").config({ path: path.join(__dirname, ".env") }); const fs = require("fs"); const cron = require("node-cron"); const yaml = require("js-yaml"); +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); @@ -24,6 +26,16 @@ const WEB_SEARCH_TIMEOUT_MS = 12000; const AI_COOLDOWN_MS = 5000; const GREETING_CHANNEL_NAME = "general"; 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 ALL_PRICES_TOP_N = 10; const SALE_ENDING_SOON_HOURS = 48; @@ -2021,6 +2033,122 @@ async function handleWiki(message, query) { } } +async function runDockerCommand(args) { + const { stdout } = await execFileAsync( + "ssh", + [ + "-i", DOCKER_SSH_KEY_PATH, + "-o", "StrictHostKeyChecking=no", + "-o", "ConnectTimeout=10", + DOCKER_SSH_USER + "@" + DOCKER_SSH_HOST, + "docker", ...args + ], + { 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(); + 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 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 (!message.guild) { + await message.reply("!docker only works inside a server, not in DMs."); + return; + } + if (!message.member.roles.cache.has(DOCKER_ROLE_ID)) { + 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) { await message.reply( "Commands:\n" + @@ -2040,6 +2168,8 @@ async function handleHelp(message, note) { "!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" + + "!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" + "!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" + @@ -2438,6 +2568,8 @@ client.on("messageCreate", async (message) => { 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 ")) { await handleRemember(message, content.slice(10).trim()); } else if (lower.startsWith("!forget ")) { diff --git a/install.ps1 b/install.ps1 index b848fb3..9764fb1 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,6 +1,5 @@ param( [switch]$LinkSteamProfiles, - [switch]$BackfillRoles, [string]$BulkOwn, [switch]$SyncRoles ) @@ -19,17 +18,6 @@ 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." @@ -182,6 +170,26 @@ if ([string]::IsNullOrWhiteSpace((Get-EnvValue "ITAD_COUNTRY"))) { 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)) { 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]" diff --git a/install.sh b/install.sh index 196608f..710129a 100755 --- a/install.sh +++ b/install.sh @@ -15,17 +15,6 @@ 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." @@ -191,6 +180,28 @@ if [[ -z "$CURRENT_ITAD_COUNTRY" ]]; then set_env_value ITAD_COUNTRY "${ITAD_COUNTRY_INPUT:-US}" 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 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