diff --git a/.claude/launch.json b/.claude/launch.json index 69c2e1a..050b95e 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -6,6 +6,18 @@ "runtimeExecutable": "npm", "runtimeArgs": ["run", "dev", "--prefix", "client"], "port": 5173 + }, + { + "name": "server", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev", "--prefix", "server"], + "port": 3000 + }, + { + "name": "bot", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev", "--prefix", "bot"], + "port": 4100 } ] } diff --git a/.env.example b/.env.example index fa3617f..33c3b84 100644 --- a/.env.example +++ b/.env.example @@ -12,7 +12,8 @@ LOG_TO_FILE=true # set false for console-only LOG_DIR=/app/logs # log directory inside the container (bind-mounted to ./logs) LOG_FILE=app.log -# Database (the values here are shared by the `db` and `app` containers) +# Database (the values here are shared by the `db`, `app`, and `bot` containers — +# the bot only ever touches its own tables: guild_config, mod_actions, warnings) DB_HOST=db DB_PORT=3306 DB_NAME=uomysticmoon @@ -58,3 +59,12 @@ CONTACT_TO=UOMysticmoon@gmail.com # CORS — only needed for local dev when the Vite dev server is a different origin. CLIENT_ORIGIN=http://localhost:5173 + +# Discord bot — internal API (server <-> bot/, see docker-compose.yml's `bot` +# service). BOT_INTERNAL_KEY MUST be byte-for-byte identical to the same +# variable in bot/.env.example — it is the only auth on both sides' /internal/* +# routes, so a mismatch silently breaks every server<->bot call with 401s. +# The Discord bot TOKEN itself is not an env var — it's entered in the admin +# panel (Discord Bot page) and stored encrypted in the DB (see bot_config table). +BOT_INTERNAL_URL=http://bot:4100 +BOT_INTERNAL_KEY=change-me-to-a-long-random-string diff --git a/bot/.env.example b/bot/.env.example new file mode 100644 index 0000000..ac7d5e9 --- /dev/null +++ b/bot/.env.example @@ -0,0 +1,44 @@ +# ─── UOMysticmoon Discord bot — local dev environment ─── +# Copy to bot/.env for running `npm run dev` outside Docker. +# (In Docker, the root .env / docker-compose provides these instead.) +# +# NOTE: there is no Discord bot token here on purpose. The token is entered +# in the admin panel (Discord Bot page), stored encrypted in the main site's +# DB, and pushed to this process in-memory over the internal API. It is +# never read from an env var and never written to this process's disk. + +PORT=4100 + +# Logging — written to BOTH the console and a log file (default /logs/bot.log). +LOG_LEVEL=debug # console verbosity: error | warn | info | debug +FILE_LOG_LEVEL=debug # file verbosity +LOG_TO_FILE=true # set false for console-only +# LOG_DIR= # defaults to bot/logs +# LOG_FILE=bot.log + +# Shared secret for the internal API between this bot and the main site +# (server/). MUST be byte-for-byte identical to BOT_INTERNAL_KEY in +# server/.env.example / the root .env.example — it is the only auth on both +# sides' /internal/* routes, so a mismatch silently breaks every server<->bot +# call with 401s. Generate one long random string and copy it to both places. +BOT_INTERNAL_KEY=dev-only-change-me-bot-key + +# Where this bot calls back to the main site to fetch its config on boot +# (GET .../api/v1/internal/bot-config), so a restart self-reconnects without +# needing the admin panel to push config again. +SITE_INTERNAL_URL=http://localhost:3000/api/v1/internal/bot-config + +# Read-only PUBLIC API base (Phase 7) — no shared secret, same data any +# visitor's browser can fetch. Used by /wiki (search) and /announce +# (re-post an existing news item). +SITE_PUBLIC_URL=http://localhost:3000/api/v1/public + +# Database (Phase 2+) — same physical DB as the main site, but the bot only +# ever reads/writes its OWN tables (guild_config, mod_actions, warnings, and +# more in later phases). It never touches site tables (users, bot_config, +# etc.) directly. Point this at the same DB the server/ uses. +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_NAME=uomysticmoon +DB_USER=uomm +DB_PASSWORD=change-me-db-password diff --git a/bot/.gitignore b/bot/.gitignore new file mode 100644 index 0000000..615e386 --- /dev/null +++ b/bot/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env +logs/ diff --git a/bot/Dockerfile b/bot/Dockerfile new file mode 100644 index 0000000..22cc939 --- /dev/null +++ b/bot/Dockerfile @@ -0,0 +1,16 @@ +FROM node:20-alpine + +WORKDIR /app/bot + +COPY bot/package*.json ./ +RUN npm install --omit=dev + +COPY bot/ . + +RUN mkdir -p /app/bot/logs && chown -R node:node /app/bot/logs + +USER node + +EXPOSE 4100 + +CMD ["node", "src/server.js"] diff --git a/bot/package-lock.json b/bot/package-lock.json new file mode 100644 index 0000000..1c1c9d4 --- /dev/null +++ b/bot/package-lock.json @@ -0,0 +1,1609 @@ +{ + "name": "uomysticmoon-bot", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "uomysticmoon-bot", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "discord.js": "^14.16.3", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "mariadb": "^3.3.1", + "node-cron": "^3.0.3" + }, + "devDependencies": { + "nodemon": "^3.1.4" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.2.0", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.5", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/discord-api-types": { + "version": "0.38.49", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.49.tgz", + "integrity": "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.26.4", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.4.tgz", + "integrity": "sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.14.1", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.1", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, + "node_modules/mariadb": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/mariadb/-/mariadb-3.5.3.tgz", + "integrity": "sha512-i053Kc0MgdUv/hu9mCyq67TYfPXFj3/MV8I7ZW5wvJNixIyXC0VztMPUjIVj/449nQo+BsxFD4Fdk/sA/uqKPQ==", + "license": "LGPL-2.1-or-later", + "dependencies": { + "@types/geojson": "^7946.0.16", + "@types/node": ">=20", + "denque": "^2.1.0", + "iconv-lite": "^0.7.2", + "lru-cache": "^11.5.0" + }, + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/mariadb/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/bot/package.json b/bot/package.json new file mode 100644 index 0000000..3796135 --- /dev/null +++ b/bot/package.json @@ -0,0 +1,24 @@ +{ + "name": "uomysticmoon-bot", + "version": "1.0.0", + "description": "Discord bot for the UOMysticmoon community server", + "private": true, + "main": "src/server.js", + "scripts": { + "start": "node src/server.js", + "dev": "nodemon src/server.js" + }, + "keywords": ["discord", "discord.js"], + "author": "whitlocktech", + "license": "ISC", + "dependencies": { + "discord.js": "^14.16.3", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "mariadb": "^3.3.1", + "node-cron": "^3.0.3" + }, + "devDependencies": { + "nodemon": "^3.1.4" + } +} diff --git a/bot/src/app.js b/bot/src/app.js new file mode 100644 index 0000000..1029d90 --- /dev/null +++ b/bot/src/app.js @@ -0,0 +1,12 @@ +const express = require('express') + +const internalRouter = require('./internal/internal.routes') + +const app = express() + +app.use(express.json()) + +app.get('/health', (req, res) => res.json({ status: 'ok' })) +app.use('/internal', internalRouter) + +module.exports = app diff --git a/bot/src/bootstrap.js b/bot/src/bootstrap.js new file mode 100644 index 0000000..1d862ff --- /dev/null +++ b/bot/src/bootstrap.js @@ -0,0 +1,38 @@ +// Runs once at process start, before the internal Express server is +// considered ready. Fetches current config from the main site (token, +// guildId, enabled) and reconnects immediately if enabled — so a bot +// container restart (crash, `docker compose restart`, host reboot) self-heals +// without any admin-panel interaction. Node 20's built-in fetch is used; no +// extra HTTP client dependency needed for a single startup call. +const discordManager = require('./discord/discordManager') +const createLogger = require('./utils/logger') + +const log = createLogger('bootstrap') + +async function bootstrap() { + const siteUrl = process.env.SITE_INTERNAL_URL + const key = process.env.BOT_INTERNAL_KEY + if (!siteUrl || !key) { + log.warn('SITE_INTERNAL_URL or BOT_INTERNAL_KEY not set — skipping boot-time config fetch, staying disconnected until the admin panel pushes config') + return + } + + try { + const res = await fetch(siteUrl, { headers: { 'X-Internal-Key': key } }) + if (!res.ok) { + log.error('boot-time config fetch failed', { status: res.status }) + return + } + const config = await res.json() + if (config.enabled) { + log.info('boot-time config says enabled — reconnecting', { guildId: config.guildId }) + await discordManager.start({ token: config.token, guildId: config.guildId }) + } else { + log.info('boot-time config says disabled — staying disconnected') + } + } catch (err) { + log.error('boot-time config fetch errored', { message: err.message }) + } +} + +module.exports = bootstrap diff --git a/bot/src/db.js b/bot/src/db.js new file mode 100644 index 0000000..5d01a08 --- /dev/null +++ b/bot/src/db.js @@ -0,0 +1,41 @@ +// DB pool for the bot's OWN tables (guild_config, mod_actions, warnings) — +// mirrors server/src/utils/db.js. The bot never reads/writes any table it +// doesn't own; site-owned tables (users, bot_config, etc.) are reached only +// through the internal API, never directly. Schema for these tables lives in +// server/db/schema.sql (same physical database, ensured by the main server on +// boot) — there's no separate migration tool to justify a second database for +// a single-guild v1 bot. +const mariadb = require('mariadb') + +const pool = mariadb.createPool({ + host: process.env.DB_HOST || '127.0.0.1', + port: Number(process.env.DB_PORT) || 3306, + user: process.env.DB_USER || 'root', + password: process.env.DB_PASSWORD || '', + database: process.env.DB_NAME || 'uomysticmoon', + connectionLimit: 5, + insertIdAsNumber: true, + bigIntAsNumber: true, + decimalAsNumber: true, + // The driver defaults to 'local' — silently serializing bound JS Date + // params using the HOST MACHINE's local offset instead of the DB session's + // timezone (discovered via temp_roles.expires_at coming back hours off in + // dev, CDT vs the container's UTC). 'auto' negotiates the actual session + // timezone so Date round-trips correctly regardless of host TZ. + timezone: 'auto', +}) + +async function query(sql, params) { + const conn = await pool.getConnection() + try { + return await conn.query(sql, params) + } finally { + conn.release() + } +} + +async function close() { + await pool.end() +} + +module.exports = { query, close } diff --git a/bot/src/discord/commands/announce.command.js b/bot/src/discord/commands/announce.command.js new file mode 100644 index 0000000..a370438 --- /dev/null +++ b/bot/src/discord/commands/announce.command.js @@ -0,0 +1,49 @@ +const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js') + +const siteApiClient = require('../../site/siteApiClient') +const newsAnnounce = require('../newsAnnounce') + +function siteOrigin() { + const base = process.env.SITE_PUBLIC_URL || 'http://localhost:3000/api/v1/public' + return new URL(base).origin +} + +module.exports = { + data: { + name: 'announce', + description: 'Re-post or boost an existing news item.', + default_member_permissions: PermissionFlagsBits.ManageGuild.toString(), + options: [ + { name: 'post', description: 'News post id or slug', type: ApplicationCommandOptionType.String, required: true }, + ], + }, + async execute(interaction) { + const idOrSlug = interaction.options.getString('post', true) + await interaction.deferReply({ ephemeral: true }) + + const result = await siteApiClient.getNewsPost(idOrSlug) + if (result.maintenance) { + await interaction.editReply({ content: `Can't reach the site right now: ${result.message || 'maintenance mode'}` }) + return + } + if (!result.ok) { + await interaction.editReply({ content: `Couldn't find that news post ("${idOrSlug}").` }) + return + } + + const post = result.data + const origin = siteOrigin() + try { + await newsAnnounce.postAnnounce(interaction.client, interaction.guildId, { + title: post.title, + excerpt: post.excerpt, + url: `${origin}/site/news`, + // image_url is stored relative — Discord embeds require an absolute URL. + imageUrl: post.image_url ? new URL(post.image_url, origin).toString() : null, + }) + await interaction.editReply({ content: `Posted "${post.title}" to the news channel.` }) + } catch (err) { + await interaction.editReply({ content: `Couldn't post: ${err.message}` }) + } + }, +} diff --git a/bot/src/discord/commands/autorole.command.js b/bot/src/discord/commands/autorole.command.js new file mode 100644 index 0000000..ea64cbd --- /dev/null +++ b/bot/src/discord/commands/autorole.command.js @@ -0,0 +1,30 @@ +const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js') + +const guildConfig = require('../../model/guildConfig') + +module.exports = { + data: { + name: 'autorole', + description: 'View or set the role automatically assigned to new members on join.', + default_member_permissions: PermissionFlagsBits.ManageGuild.toString(), + options: [ + { + name: 'role', + description: 'Role to auto-assign on join. Omit to view the current setting.', + type: ApplicationCommandOptionType.Role, + required: false, + }, + ], + }, + async execute(interaction) { + const role = interaction.options.getRole('role') + if (!role) { + const currentId = await guildConfig.getAutoRoleId(interaction.guildId) + const content = currentId ? `Auto-role is set to <@&${currentId}>.` : 'No auto-role is set yet.' + await interaction.reply({ content, ephemeral: true }) + return + } + await guildConfig.setAutoRoleId(interaction.guildId, role.id) + await interaction.reply({ content: `Auto-role set to ${role}. New members will get this automatically.`, ephemeral: true }) + }, +} diff --git a/bot/src/discord/commands/ban.command.js b/bot/src/discord/commands/ban.command.js new file mode 100644 index 0000000..e38d177 --- /dev/null +++ b/bot/src/discord/commands/ban.command.js @@ -0,0 +1,34 @@ +const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js') + +const modLog = require('../modLog') + +module.exports = { + data: { + name: 'ban', + description: 'Ban a member from the server.', + default_member_permissions: PermissionFlagsBits.BanMembers.toString(), + options: [ + { name: 'user', description: 'Member to ban', type: ApplicationCommandOptionType.User, required: true }, + { name: 'reason', description: 'Reason for the ban', type: ApplicationCommandOptionType.String, required: true }, + ], + }, + async execute(interaction) { + const user = interaction.options.getUser('user', true) + const reason = interaction.options.getString('reason', true) + + if (user.id === interaction.user.id) { + await interaction.reply({ content: "You can't ban yourself.", ephemeral: true }) + return + } + + const member = interaction.guild.members.cache.get(user.id) + if (member && !member.bannable) { + await interaction.reply({ content: "I don't have permission to ban that member (role hierarchy).", ephemeral: true }) + return + } + + await interaction.guild.members.ban(user, { reason }) + await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'ban', target: user, staffUser: interaction.user, reason }) + await interaction.reply({ content: `Banned ${user.tag}.`, ephemeral: true }) + }, +} diff --git a/bot/src/discord/commands/filter.command.js b/bot/src/discord/commands/filter.command.js new file mode 100644 index 0000000..57af5e9 --- /dev/null +++ b/bot/src/discord/commands/filter.command.js @@ -0,0 +1,73 @@ +const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js') + +const filterWords = require('../../model/filterWords') +const filterCache = require('../../filter/filterCache') + +module.exports = { + data: { + name: 'filter', + description: 'Manage the banned-word filter.', + default_member_permissions: PermissionFlagsBits.ManageGuild.toString(), + options: [ + { + name: 'add', + description: 'Add a word to the filter.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { name: 'word', description: 'Word or phrase to ban', type: ApplicationCommandOptionType.String, required: true }, + { + name: 'severity', + description: 'Auto-action when triggered (default: delete)', + type: ApplicationCommandOptionType.String, + required: false, + choices: [ + { name: 'Delete only', value: 'delete' }, + { name: 'Delete + warn', value: 'warn' }, + { name: 'Delete + mute (10m)', value: 'mute' }, + ], + }, + ], + }, + { + name: 'remove', + description: 'Remove a word from the filter.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { name: 'word', description: 'Word or phrase to remove', type: ApplicationCommandOptionType.String, required: true }, + ], + }, + { + name: 'list', + description: 'List all filtered words.', + type: ApplicationCommandOptionType.Subcommand, + options: [], + }, + ], + }, + async execute(interaction) { + const sub = interaction.options.getSubcommand() + + if (sub === 'add') { + const word = interaction.options.getString('word', true) + const severity = interaction.options.getString('severity') || 'delete' + await filterWords.add({ guildId: interaction.guildId, word, severity, addedBy: interaction.user.id, addedByTag: interaction.user.tag }) + await filterCache.refresh(interaction.guildId) + await interaction.reply({ content: `Added "${word}" to the filter (${severity}).`, ephemeral: true }) + return + } + + if (sub === 'remove') { + const word = interaction.options.getString('word', true) + const removed = await filterWords.remove(interaction.guildId, word) + await filterCache.refresh(interaction.guildId) + await interaction.reply({ content: removed ? `Removed "${word}" from the filter.` : `"${word}" wasn't in the filter.`, ephemeral: true }) + return + } + + if (sub === 'list') { + const words = await filterWords.list(interaction.guildId) + const content = words.length === 0 ? 'The filter list is empty.' : words.map((w) => `${w.word} (${w.severity})`).join('\n') + await interaction.reply({ content, ephemeral: true }) + } + }, +} diff --git a/bot/src/discord/commands/filterallow.command.js b/bot/src/discord/commands/filterallow.command.js new file mode 100644 index 0000000..a59d94d --- /dev/null +++ b/bot/src/discord/commands/filterallow.command.js @@ -0,0 +1,61 @@ +const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js') + +const filterAllowlist = require('../../model/filterAllowlist') +const filterCache = require('../../filter/filterCache') + +module.exports = { + data: { + name: 'filterallow', + description: 'Manage roles/channels that bypass the filter entirely.', + default_member_permissions: PermissionFlagsBits.ManageGuild.toString(), + options: [ + { + name: 'role', + description: 'Toggle a role in/out of the filter bypass list.', + type: ApplicationCommandOptionType.Subcommand, + options: [{ name: 'role', description: 'Role to toggle', type: ApplicationCommandOptionType.Role, required: true }], + }, + { + name: 'channel', + description: 'Toggle a channel in/out of the filter bypass list.', + type: ApplicationCommandOptionType.Subcommand, + options: [{ name: 'channel', description: 'Channel to toggle', type: ApplicationCommandOptionType.Channel, required: true }], + }, + { + name: 'list', + description: 'Show current filter bypass roles/channels.', + type: ApplicationCommandOptionType.Subcommand, + options: [], + }, + ], + }, + async execute(interaction) { + const sub = interaction.options.getSubcommand() + + if (sub === 'role') { + const role = interaction.options.getRole('role', true) + const nowAllowed = await filterAllowlist.toggleRole(interaction.guildId, role.id) + await filterCache.refresh(interaction.guildId) + await interaction.reply({ content: `${role} is ${nowAllowed ? 'now' : 'no longer'} bypassing the filter.`, ephemeral: true }) + return + } + + if (sub === 'channel') { + const channel = interaction.options.getChannel('channel', true) + const nowAllowed = await filterAllowlist.toggleChannel(interaction.guildId, channel.id) + await filterCache.refresh(interaction.guildId) + await interaction.reply({ content: `${channel} is ${nowAllowed ? 'now' : 'no longer'} bypassing the filter.`, ephemeral: true }) + return + } + + if (sub === 'list') { + const [roles, channels] = await Promise.all([ + filterAllowlist.getRoles(interaction.guildId), + filterAllowlist.getChannels(interaction.guildId), + ]) + const roleText = roles.length ? roles.map((id) => `<@&${id}>`).join(', ') : 'none' + const channelText = channels.length ? channels.map((id) => `<#${id}>`).join(', ') : 'none' + await interaction.reply({ content: `Bypass roles: ${roleText}\nBypass channels: ${channelText}`, ephemeral: true }) + } + }, +} diff --git a/bot/src/discord/commands/index.js b/bot/src/discord/commands/index.js new file mode 100644 index 0000000..8074b46 --- /dev/null +++ b/bot/src/discord/commands/index.js @@ -0,0 +1,31 @@ +// Command registry. Each module exports { data, execute } — `data` is the +// slash-command definition pushed to Discord (registerCommands), `execute` is +// the interactionCreate handler (dispatch). Adding a new command is just +// adding a file here — discordManager.js never needs to change. +const commands = [ + require('./ping.command'), + require('./modlog.command'), + require('./ban.command'), + require('./kick.command'), + require('./mute.command'), + require('./warn.command'), + require('./warnings.command'), + require('./filter.command'), + require('./filterallow.command'), + require('./schedule.command'), + require('./rolemenu.command'), + require('./autorole.command'), + require('./role.command'), + require('./roles.command'), + require('./invite.command'), + require('./news.command'), + require('./announce.command'), + require('./wiki.command'), +] + +const byName = new Map(commands.map((c) => [c.data.name, c])) + +module.exports = { + all: commands, + get: (name) => byName.get(name), +} diff --git a/bot/src/discord/commands/invite.command.js b/bot/src/discord/commands/invite.command.js new file mode 100644 index 0000000..e72ca31 --- /dev/null +++ b/bot/src/discord/commands/invite.command.js @@ -0,0 +1,85 @@ +const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js') + +const guildConfig = require('../../model/guildConfig') +const inviteLog = require('../../model/inviteLog') +const inviteRotator = require('../../invites/inviteRotator') + +module.exports = { + data: { + name: 'invite', + description: 'Manage the auto-rotating primary server invite.', + default_member_permissions: PermissionFlagsBits.ManageGuild.toString(), + options: [ + { + name: 'channel', + description: 'View or set the channel new invites are created in.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'channel', + description: 'Channel to create invites in. Omit to view the current setting.', + type: ApplicationCommandOptionType.Channel, + channel_types: [ChannelType.GuildText], + required: false, + }, + ], + }, + { + name: 'rotate', + description: 'Revoke the current invite and generate a new one now.', + type: ApplicationCommandOptionType.Subcommand, + options: [], + }, + { + name: 'log', + description: 'Show recent invite rotation history.', + type: ApplicationCommandOptionType.Subcommand, + options: [], + }, + ], + }, + async execute(interaction) { + const sub = interaction.options.getSubcommand() + + if (sub === 'channel') { + const channel = interaction.options.getChannel('channel') + if (!channel) { + const currentId = await guildConfig.getInviteChannelId(interaction.guildId) + const content = currentId ? `Invites are created in <#${currentId}>.` : 'No invite channel is set yet.' + await interaction.reply({ content, ephemeral: true }) + return + } + await guildConfig.setInviteChannelId(interaction.guildId, channel.id) + await interaction.reply({ content: `Invite channel set to ${channel}.`, ephemeral: true }) + return + } + + if (sub === 'rotate') { + await interaction.deferReply({ ephemeral: true }) + try { + const invite = await inviteRotator.rotate(interaction.client, interaction.guildId, { + triggeredBy: interaction.user.id, + triggeredByTag: interaction.user.tag, + }) + await interaction.editReply({ content: `New invite: https://discord.gg/${invite.code}` }) + } catch (err) { + await interaction.editReply({ content: `Couldn't rotate the invite: ${err.message}` }) + } + return + } + + if (sub === 'log') { + const rows = await inviteLog.list(interaction.guildId, 10) + if (rows.length === 0) { + await interaction.reply({ content: 'No invite rotations logged yet.', ephemeral: true }) + return + } + const lines = rows.map((r) => { + const who = r.triggered_by_tag || 'automatic (scheduled)' + const status = r.revoked_at ? `revoked ${new Date(r.revoked_at).toLocaleString()}` : 'active' + return `\`${r.invite_code}\` — by ${who} on ${new Date(r.created_at).toLocaleString()} (${status})` + }) + await interaction.reply({ content: lines.join('\n'), ephemeral: true }) + } + }, +} diff --git a/bot/src/discord/commands/kick.command.js b/bot/src/discord/commands/kick.command.js new file mode 100644 index 0000000..1c8ee83 --- /dev/null +++ b/bot/src/discord/commands/kick.command.js @@ -0,0 +1,38 @@ +const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js') + +const modLog = require('../modLog') + +module.exports = { + data: { + name: 'kick', + description: 'Kick a member from the server.', + default_member_permissions: PermissionFlagsBits.KickMembers.toString(), + options: [ + { name: 'user', description: 'Member to kick', type: ApplicationCommandOptionType.User, required: true }, + { name: 'reason', description: 'Reason for the kick', type: ApplicationCommandOptionType.String, required: true }, + ], + }, + async execute(interaction) { + const user = interaction.options.getUser('user', true) + const reason = interaction.options.getString('reason', true) + + if (user.id === interaction.user.id) { + await interaction.reply({ content: "You can't kick yourself.", ephemeral: true }) + return + } + + const member = interaction.guild.members.cache.get(user.id) + if (!member) { + await interaction.reply({ content: 'That user is not a member of this server.', ephemeral: true }) + return + } + if (!member.kickable) { + await interaction.reply({ content: "I don't have permission to kick that member (role hierarchy).", ephemeral: true }) + return + } + + await member.kick(reason) + await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'kick', target: user, staffUser: interaction.user, reason }) + await interaction.reply({ content: `Kicked ${user.tag}.`, ephemeral: true }) + }, +} diff --git a/bot/src/discord/commands/modlog.command.js b/bot/src/discord/commands/modlog.command.js new file mode 100644 index 0000000..fcfd03b --- /dev/null +++ b/bot/src/discord/commands/modlog.command.js @@ -0,0 +1,33 @@ +const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js') + +const guildConfig = require('../../model/guildConfig') + +module.exports = { + data: { + name: 'modlog', + description: 'View or set the mod-log channel (ban/kick/mute/warn actions post here).', + // Configuration, not a moderation action — gated to Manage Server rather + // than the ModerateMembers bit the action commands use. + default_member_permissions: PermissionFlagsBits.ManageGuild.toString(), + options: [ + { + name: 'channel', + description: 'Channel to post mod-log entries to. Omit to view the current setting.', + type: ApplicationCommandOptionType.Channel, + channel_types: [ChannelType.GuildText], + required: false, + }, + ], + }, + async execute(interaction) { + const channel = interaction.options.getChannel('channel') + if (!channel) { + const currentId = await guildConfig.getModLogChannelId(interaction.guildId) + const content = currentId ? `Mod-log channel is set to <#${currentId}>.` : 'No mod-log channel is set yet.' + await interaction.reply({ content, ephemeral: true }) + return + } + await guildConfig.setModLogChannelId(interaction.guildId, channel.id) + await interaction.reply({ content: `Mod-log channel set to ${channel}.`, ephemeral: true }) + }, +} diff --git a/bot/src/discord/commands/mute.command.js b/bot/src/discord/commands/mute.command.js new file mode 100644 index 0000000..e0f5778 --- /dev/null +++ b/bot/src/discord/commands/mute.command.js @@ -0,0 +1,49 @@ +const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js') + +const modLog = require('../modLog') +const { parseDuration, MAX_TIMEOUT_MS } = require('../../utils/duration') + +module.exports = { + data: { + name: 'mute', + description: 'Timeout a member for a duration (e.g. 10m, 2h, 1d).', + default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(), + options: [ + { name: 'user', description: 'Member to mute', type: ApplicationCommandOptionType.User, required: true }, + { name: 'duration', description: 'e.g. 30s, 10m, 2h, 1d (max 28d)', type: ApplicationCommandOptionType.String, required: true }, + { name: 'reason', description: 'Reason for the mute', type: ApplicationCommandOptionType.String, required: true }, + ], + }, + async execute(interaction) { + const user = interaction.options.getUser('user', true) + const durationInput = interaction.options.getString('duration', true) + const reason = interaction.options.getString('reason', true) + + if (user.id === interaction.user.id) { + await interaction.reply({ content: "You can't mute yourself.", ephemeral: true }) + return + } + + const ms = parseDuration(durationInput) + if (!ms) { + await interaction.reply({ content: 'Invalid duration — use a number plus s/m/h/d, e.g. `10m`, `2h`, `1d`.', ephemeral: true }) + return + } + const clampedMs = Math.min(ms, MAX_TIMEOUT_MS) + + const member = interaction.guild.members.cache.get(user.id) + if (!member) { + await interaction.reply({ content: 'That user is not a member of this server.', ephemeral: true }) + return + } + if (!member.moderatable) { + await interaction.reply({ content: "I don't have permission to timeout that member (role hierarchy).", ephemeral: true }) + return + } + + await member.timeout(clampedMs, reason) + const durationSeconds = Math.round(clampedMs / 1000) + await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'mute', target: user, staffUser: interaction.user, reason, durationSeconds }) + await interaction.reply({ content: `Muted ${user.tag} for ${durationInput}.`, ephemeral: true }) + }, +} diff --git a/bot/src/discord/commands/news.command.js b/bot/src/discord/commands/news.command.js new file mode 100644 index 0000000..f5295d6 --- /dev/null +++ b/bot/src/discord/commands/news.command.js @@ -0,0 +1,31 @@ +const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js') + +const guildConfig = require('../../model/guildConfig') + +module.exports = { + data: { + name: 'news', + description: 'View or set the channel news posts are announced to.', + default_member_permissions: PermissionFlagsBits.ManageGuild.toString(), + options: [ + { + name: 'channel', + description: 'Channel for news announcements. Omit to view the current setting.', + type: ApplicationCommandOptionType.Channel, + channel_types: [ChannelType.GuildText], + required: false, + }, + ], + }, + async execute(interaction) { + const channel = interaction.options.getChannel('channel') + if (!channel) { + const currentId = await guildConfig.getNewsChannelId(interaction.guildId) + const content = currentId ? `News channel is set to <#${currentId}>.` : 'No news channel is set yet.' + await interaction.reply({ content, ephemeral: true }) + return + } + await guildConfig.setNewsChannelId(interaction.guildId, channel.id) + await interaction.reply({ content: `News channel set to ${channel}.`, ephemeral: true }) + }, +} diff --git a/bot/src/discord/commands/ping.command.js b/bot/src/discord/commands/ping.command.js new file mode 100644 index 0000000..bb9b476 --- /dev/null +++ b/bot/src/discord/commands/ping.command.js @@ -0,0 +1,15 @@ +const { PermissionFlagsBits } = require('discord.js') + +module.exports = { + data: { + name: 'ping', + description: 'Health-check — replies pong if the bot is alive and staff-permitted.', + // Restricted by default to members with Moderate Members — proves slash + // commands can be permission-gated via Discord's own permission model, + // per the spec's "restrict staff commands via Discord's permission system". + default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(), + }, + async execute(interaction) { + await interaction.reply({ content: 'pong', ephemeral: true }) + }, +} diff --git a/bot/src/discord/commands/role.command.js b/bot/src/discord/commands/role.command.js new file mode 100644 index 0000000..52ba594 --- /dev/null +++ b/bot/src/discord/commands/role.command.js @@ -0,0 +1,71 @@ +const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js') + +const tempRoles = require('../../model/tempRoles') +const { parseDuration } = require('../../utils/duration') + +module.exports = { + data: { + name: 'role', + description: 'Assign or remove a role for a single member.', + default_member_permissions: PermissionFlagsBits.ManageRoles.toString(), + options: [ + { + name: 'add', + description: 'Add a role to a member, optionally temporary.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { name: 'user', description: 'Member', type: ApplicationCommandOptionType.User, required: true }, + { name: 'role', description: 'Role to add', type: ApplicationCommandOptionType.Role, required: true }, + { name: 'duration', description: 'Optional — makes this temporary, e.g. 1h, 2d, 7d', type: ApplicationCommandOptionType.String, required: false }, + ], + }, + { + name: 'remove', + description: 'Remove a role from a member.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { name: 'user', description: 'Member', type: ApplicationCommandOptionType.User, required: true }, + { name: 'role', description: 'Role to remove', type: ApplicationCommandOptionType.Role, required: true }, + ], + }, + ], + }, + async execute(interaction) { + const sub = interaction.options.getSubcommand() + const user = interaction.options.getUser('user', true) + const role = interaction.options.getRole('role', true) + const member = interaction.guild.members.cache.get(user.id) + + if (!member) { + await interaction.reply({ content: 'That user is not a member of this server.', ephemeral: true }) + return + } + + if (sub === 'add') { + await member.roles.add(role.id) + const durationInput = interaction.options.getString('duration') + if (!durationInput) { + await interaction.reply({ content: `Added ${role} to ${user.tag}.`, ephemeral: true }) + return + } + const ms = parseDuration(durationInput) + if (!ms) { + await interaction.reply({ + content: `Added ${role}, but "${durationInput}" isn't a valid duration so it won't expire automatically. Use e.g. 1h, 2d, 7d.`, + ephemeral: true, + }) + return + } + const expiresAt = new Date(Date.now() + ms) + await tempRoles.add({ guildId: interaction.guildId, userId: user.id, roleId: role.id, expiresAt, createdBy: interaction.user.id }) + await interaction.reply({ content: `Added ${role} to ${user.tag} until ${expiresAt.toLocaleString()}.`, ephemeral: true }) + return + } + + if (sub === 'remove') { + await member.roles.remove(role.id) + await tempRoles.remove(interaction.guildId, user.id, role.id) + await interaction.reply({ content: `Removed ${role} from ${user.tag}.`, ephemeral: true }) + } + }, +} diff --git a/bot/src/discord/commands/rolemenu.command.js b/bot/src/discord/commands/rolemenu.command.js new file mode 100644 index 0000000..e073f17 --- /dev/null +++ b/bot/src/discord/commands/rolemenu.command.js @@ -0,0 +1,85 @@ +const { + PermissionFlagsBits, + ApplicationCommandOptionType, + ChannelType, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, +} = require('discord.js') + +const roleMenus = require('../../model/roleMenus') + +// Capped at 5 roles per menu — a single Discord action row holds at most 5 +// buttons, and one row keeps this a single simple slash command instead of +// needing a multi-step builder/modal flow. +const MAX_ROLES = 5 + +// role1/label1 are declared inline in `data` (ahead of the optional +// `description` option, per Discord's required-before-optional rule) — this +// generates the rest, all optional. +function roleOptions(from, to) { + const opts = [] + for (let i = from; i <= to; i++) { + opts.push({ name: `role${i}`, description: `Role #${i}`, type: ApplicationCommandOptionType.Role, required: false }) + opts.push({ name: `label${i}`, description: `Button label for role #${i} (default: role name)`, type: ApplicationCommandOptionType.String, required: false }) + } + return opts +} + +module.exports = { + data: { + name: 'rolemenu', + description: 'Post a button menu for self-assignable roles (up to 5).', + default_member_permissions: PermissionFlagsBits.ManageGuild.toString(), + // Discord requires all required options before any optional ones across + // the whole array — role1 (required) must come before description + // (optional), even though they read more naturally in the other order. + options: [ + { name: 'channel', description: 'Channel to post the menu in', type: ApplicationCommandOptionType.Channel, channel_types: [ChannelType.GuildText], required: true }, + { name: 'title', description: 'Menu title', type: ApplicationCommandOptionType.String, required: true }, + { name: 'role1', description: 'Role #1', type: ApplicationCommandOptionType.Role, required: true }, + { name: 'description', description: 'Menu description', type: ApplicationCommandOptionType.String, required: false }, + { name: 'label1', description: 'Button label for role #1 (default: role name)', type: ApplicationCommandOptionType.String, required: false }, + ...roleOptions(2, MAX_ROLES), + ], + }, + async execute(interaction) { + const channel = interaction.options.getChannel('channel', true) + const title = interaction.options.getString('title', true) + const description = interaction.options.getString('description') || undefined + + const entries = [] + for (let i = 1; i <= MAX_ROLES; i++) { + const role = interaction.options.getRole(`role${i}`) + if (!role) continue + const label = interaction.options.getString(`label${i}`) || role.name + entries.push({ roleId: role.id, label }) + } + + if (entries.length === 0) { + await interaction.reply({ content: 'Provide at least one role (role1).', ephemeral: true }) + return + } + + const embed = new EmbedBuilder().setTitle(title).setColor(0x6a8fc2) + if (description) embed.setDescription(description) + + const row = new ActionRowBuilder().addComponents( + entries.map((e) => + new ButtonBuilder().setCustomId(`rolemenu:${e.roleId}`).setLabel(e.label).setStyle(ButtonStyle.Secondary), + ), + ) + + const message = await channel.send({ embeds: [embed], components: [row] }) + await roleMenus.add({ + guildId: interaction.guildId, + channelId: channel.id, + messageId: message.id, + mapping: entries, + createdBy: interaction.user.id, + }) + + await interaction.reply({ content: `Role menu posted in ${channel}.`, ephemeral: true }) + }, +} diff --git a/bot/src/discord/commands/roles.command.js b/bot/src/discord/commands/roles.command.js new file mode 100644 index 0000000..655efc5 --- /dev/null +++ b/bot/src/discord/commands/roles.command.js @@ -0,0 +1,68 @@ +const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js') + +// Bulk targeting is "by existing role" only — the spec also mentions an +// explicit list of members, but Discord slash commands have no multi-user +// picker, so that variant is deferred rather than faked with a handful of +// user1..user5 options that would feel arbitrary and cramped. +module.exports = { + data: { + name: 'roles', + description: 'Bulk role operations across members who share an existing role.', + default_member_permissions: PermissionFlagsBits.ManageRoles.toString(), + options: [ + { + name: 'bulk-assign', + description: 'Add a role to every member who has another role.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { name: 'has-role', description: 'Members with this role are targeted', type: ApplicationCommandOptionType.Role, required: true }, + { name: 'add-role', description: 'Role to add to those members', type: ApplicationCommandOptionType.Role, required: true }, + ], + }, + { + name: 'bulk-remove', + description: 'Remove a role from every member who has another role.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { name: 'has-role', description: 'Members with this role are targeted', type: ApplicationCommandOptionType.Role, required: true }, + { name: 'remove-role', description: 'Role to remove from those members', type: ApplicationCommandOptionType.Role, required: true }, + ], + }, + ], + }, + async execute(interaction) { + const sub = interaction.options.getSubcommand() + // Fetching every member + looping role updates can easily exceed + // Discord's 3-second initial-response window. + await interaction.deferReply({ ephemeral: true }) + + const hasRole = interaction.options.getRole('has-role', true) + const members = await interaction.guild.members.fetch() + const targets = members.filter((m) => m.roles.cache.has(hasRole.id)) + + if (sub === 'bulk-assign') { + const addRole = interaction.options.getRole('add-role', true) + let count = 0 + for (const member of targets.values()) { + if (!member.roles.cache.has(addRole.id)) { + await member.roles.add(addRole.id).catch(() => {}) + count++ + } + } + await interaction.editReply({ content: `Added ${addRole} to ${count} member(s) who have ${hasRole}.` }) + return + } + + if (sub === 'bulk-remove') { + const removeRole = interaction.options.getRole('remove-role', true) + let count = 0 + for (const member of targets.values()) { + if (member.roles.cache.has(removeRole.id)) { + await member.roles.remove(removeRole.id).catch(() => {}) + count++ + } + } + await interaction.editReply({ content: `Removed ${removeRole} from ${count} member(s) who have ${hasRole}.` }) + } + }, +} diff --git a/bot/src/discord/commands/schedule.command.js b/bot/src/discord/commands/schedule.command.js new file mode 100644 index 0000000..bf9e3b6 --- /dev/null +++ b/bot/src/discord/commands/schedule.command.js @@ -0,0 +1,119 @@ +const { PermissionFlagsBits, ApplicationCommandOptionType, ChannelType } = require('discord.js') +const cron = require('node-cron') + +const scheduledMessages = require('../../model/scheduledMessages') +const scheduler = require('../../scheduler/scheduler') +const { parseDuration } = require('../../utils/duration') + +module.exports = { + data: { + name: 'schedule', + description: 'Manage recurring and one-off scheduled channel messages.', + default_member_permissions: PermissionFlagsBits.ManageGuild.toString(), + options: [ + { + name: 'recurring', + description: 'Schedule a recurring message on a cron schedule.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { name: 'channel', description: 'Channel to post in', type: ApplicationCommandOptionType.Channel, channel_types: [ChannelType.GuildText], required: true }, + { name: 'cron', description: 'Cron expression, e.g. "0 9 * * 5" (Fridays 9am)', type: ApplicationCommandOptionType.String, required: true }, + { name: 'message', description: 'Message content to post', type: ApplicationCommandOptionType.String, required: true }, + ], + }, + { + name: 'once', + description: 'Schedule a one-off message for a future time.', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { name: 'channel', description: 'Channel to post in', type: ApplicationCommandOptionType.Channel, channel_types: [ChannelType.GuildText], required: true }, + { name: 'in', description: 'When to post, e.g. 30m, 2h, 1d', type: ApplicationCommandOptionType.String, required: true }, + { name: 'message', description: 'Message content to post', type: ApplicationCommandOptionType.String, required: true }, + ], + }, + { + name: 'remove', + description: 'Remove a scheduled message by id.', + type: ApplicationCommandOptionType.Subcommand, + options: [{ name: 'id', description: 'Scheduled message id (see /schedule list)', type: ApplicationCommandOptionType.Integer, required: true }], + }, + { + name: 'list', + description: 'List all scheduled messages.', + type: ApplicationCommandOptionType.Subcommand, + options: [], + }, + ], + }, + async execute(interaction) { + const sub = interaction.options.getSubcommand() + + if (sub === 'recurring') { + const channel = interaction.options.getChannel('channel', true) + const cronExpr = interaction.options.getString('cron', true) + const message = interaction.options.getString('message', true) + if (!cron.validate(cronExpr)) { + await interaction.reply({ content: `"${cronExpr}" isn't a valid cron expression.`, ephemeral: true }) + return + } + const id = await scheduledMessages.addRecurring({ + guildId: interaction.guildId, + channelId: channel.id, + content: message, + cronExpression: cronExpr, + createdBy: interaction.user.id, + createdByTag: interaction.user.tag, + }) + await scheduler.refresh() + await interaction.reply({ content: `Scheduled recurring message #${id} in ${channel} on \`${cronExpr}\`.`, ephemeral: true }) + return + } + + if (sub === 'once') { + const channel = interaction.options.getChannel('channel', true) + const inInput = interaction.options.getString('in', true) + const message = interaction.options.getString('message', true) + const ms = parseDuration(inInput) + if (!ms) { + await interaction.reply({ content: 'Invalid time — use a number plus s/m/h/d, e.g. `30m`, `2h`, `1d`.', ephemeral: true }) + return + } + const runAt = new Date(Date.now() + ms) + const id = await scheduledMessages.addOnce({ + guildId: interaction.guildId, + channelId: channel.id, + content: message, + runAt, + createdBy: interaction.user.id, + createdByTag: interaction.user.tag, + }) + await interaction.reply({ content: `Scheduled one-off message #${id} in ${channel} for ${runAt.toLocaleString()}.`, ephemeral: true }) + return + } + + if (sub === 'remove') { + const id = interaction.options.getInteger('id', true) + const removed = await scheduledMessages.remove(interaction.guildId, id) + await scheduler.refresh() + await interaction.reply({ content: removed ? `Removed scheduled message #${id}.` : `No scheduled message #${id} found.`, ephemeral: true }) + return + } + + if (sub === 'list') { + const rows = await scheduledMessages.list(interaction.guildId) + if (rows.length === 0) { + await interaction.reply({ content: 'No scheduled messages.', ephemeral: true }) + return + } + const lines = rows.map((r) => { + const kind = r.cron_expression + ? `cron \`${r.cron_expression}\`` + : r.sent_at + ? `sent ${new Date(r.sent_at).toLocaleString()}` + : `due ${new Date(r.run_at).toLocaleString()}` + return `**#${r.id}** <#${r.channel_id}> — ${kind}${r.enabled ? '' : ' (disabled)'}` + }) + await interaction.reply({ content: lines.join('\n'), ephemeral: true }) + } + }, +} diff --git a/bot/src/discord/commands/warn.command.js b/bot/src/discord/commands/warn.command.js new file mode 100644 index 0000000..9518f61 --- /dev/null +++ b/bot/src/discord/commands/warn.command.js @@ -0,0 +1,40 @@ +const { PermissionFlagsBits, ApplicationCommandOptionType } = require('discord.js') + +const modLog = require('../modLog') +const warnings = require('../../model/warnings') + +// Escalation (e.g. "3 active warns -> auto-mute for X hours") and warning +// decay/expiry are in the original spec but deferred past this phase — this +// just records the warning and posts it to the mod-log, matching the +// "Suggested Build Order" step 2 scope (core moderation). +module.exports = { + data: { + name: 'warn', + description: 'Log a warning against a member.', + default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(), + options: [ + { name: 'user', description: 'Member to warn', type: ApplicationCommandOptionType.User, required: true }, + { name: 'reason', description: 'Reason for the warning', type: ApplicationCommandOptionType.String, required: true }, + ], + }, + async execute(interaction) { + const user = interaction.options.getUser('user', true) + const reason = interaction.options.getString('reason', true) + + if (user.id === interaction.user.id) { + await interaction.reply({ content: "You can't warn yourself.", ephemeral: true }) + return + } + + await warnings.add({ + guildId: interaction.guildId, + targetUserId: user.id, + targetTag: user.tag, + staffUserId: interaction.user.id, + staffTag: interaction.user.tag, + reason, + }) + await modLog.record({ client: interaction.client, guildId: interaction.guildId, actionType: 'warn', target: user, staffUser: interaction.user, reason }) + await interaction.reply({ content: `Warned ${user.tag}.`, ephemeral: true }) + }, +} diff --git a/bot/src/discord/commands/warnings.command.js b/bot/src/discord/commands/warnings.command.js new file mode 100644 index 0000000..a5910d3 --- /dev/null +++ b/bot/src/discord/commands/warnings.command.js @@ -0,0 +1,34 @@ +const { PermissionFlagsBits, ApplicationCommandOptionType, EmbedBuilder } = require('discord.js') + +const warnings = require('../../model/warnings') + +module.exports = { + data: { + name: 'warnings', + description: "List a member's active warnings.", + default_member_permissions: PermissionFlagsBits.ModerateMembers.toString(), + options: [ + { name: 'user', description: 'Member to look up', type: ApplicationCommandOptionType.User, required: true }, + ], + }, + async execute(interaction) { + const user = interaction.options.getUser('user', true) + const rows = await warnings.listActive(interaction.guildId, user.id) + + if (rows.length === 0) { + await interaction.reply({ content: `${user.tag} has no active warnings.`, ephemeral: true }) + return + } + + const embed = new EmbedBuilder() + .setColor(0xe0b070) + .setTitle(`Warnings — ${user.tag}`) + .setDescription( + rows + .map((w, i) => `**${i + 1}.** ${w.reason || '(no reason given)'} — by ${w.staff_tag || 'unknown'} on ${new Date(w.created_at).toLocaleDateString()}`) + .join('\n'), + ) + + await interaction.reply({ embeds: [embed], ephemeral: true }) + }, +} diff --git a/bot/src/discord/commands/wiki.command.js b/bot/src/discord/commands/wiki.command.js new file mode 100644 index 0000000..54c5783 --- /dev/null +++ b/bot/src/discord/commands/wiki.command.js @@ -0,0 +1,40 @@ +const { ApplicationCommandOptionType } = require('discord.js') + +const siteApiClient = require('../../site/siteApiClient') + +// Public command — no default_member_permissions restriction. Read-only: +// searches wiki titles/content and links to the best match. Never posts to or +// edits the wiki. Category-scoped search (spec's optional "/wiki spells +// fireball") is deferred — the site's public search endpoint currently +// ignores category filters whenever a text query is given. +function siteOrigin() { + const base = process.env.SITE_PUBLIC_URL || 'http://localhost:3000/api/v1/public' + return new URL(base).origin +} + +module.exports = { + data: { + name: 'wiki', + description: 'Search the wiki.', + options: [{ name: 'query', description: 'What to search for', type: ApplicationCommandOptionType.String, required: true }], + }, + async execute(interaction) { + const query = interaction.options.getString('query', true) + await interaction.deferReply() + + const result = await siteApiClient.searchWiki(query) + if (result.maintenance) { + await interaction.editReply({ content: `The wiki is unavailable right now: ${result.message || 'maintenance mode'}` }) + return + } + if (!result.ok || !result.data || result.data.length === 0) { + await interaction.editReply({ content: `No wiki results for "${query}".` }) + return + } + + const best = result.data[0] + const url = `${siteOrigin()}/wiki/${best.slug}` + const content = best.excerpt ? `**${best.title}**\n${best.excerpt}\n${url}` : `**${best.title}**\n${url}` + await interaction.editReply({ content }) + }, +} diff --git a/bot/src/discord/discordManager.js b/bot/src/discord/discordManager.js new file mode 100644 index 0000000..8460530 --- /dev/null +++ b/bot/src/discord/discordManager.js @@ -0,0 +1,135 @@ +// Owns the single discord.js Client instance for this process: lifecycle +// (start/stop/status) and slash-command registration/dispatch. Command +// definitions themselves live in ./commands — this file only wires them up. +const { Client, GatewayIntentBits, REST, Routes } = require('discord.js') + +const createLogger = require('../utils/logger') +const commands = require('./commands') +const messageFilter = require('./messageFilter') +const scheduler = require('../scheduler/scheduler') +const roleMenuHandler = require('./roleMenuHandler') +const { handleGuildMemberAdd } = require('./guildMemberAdd') +const tempRoleSweeper = require('../roles/tempRoleSweeper') +const inviteScheduler = require('../invites/inviteScheduler') + +const log = createLogger('discord') + +let client = null +let guildId = null +let status = 'disconnected' // disconnected | connecting | connected | error +let statusDetail = null +let lastConnectedAt = null + +async function registerCommands(applicationId, targetGuildId) { + const rest = new REST({ version: '10' }).setToken(client.token) + await rest.put(Routes.applicationGuildCommands(applicationId, targetGuildId), { + body: commands.all.map((c) => c.data), + }) + log.info('registered guild slash commands', { guildId: targetGuildId, count: commands.all.length }) +} + +async function stop() { + if (!client) { + status = 'disconnected' + statusDetail = null + return + } + scheduler.stop() + tempRoleSweeper.stop() + inviteScheduler.stop() + try { + await client.destroy() + } catch (err) { + log.warn('error while destroying client', { message: err.message }) + } + client = null + status = 'disconnected' + statusDetail = null + log.info('discord client disconnected') +} + +// start({ token, guildId }) — (re)connects. Always stops any existing client +// first so re-saving config or toggling Enabled off/on is idempotent. +async function start({ token, guildId: gid }) { + await stop() + guildId = gid + status = 'connecting' + statusDetail = null + + // GuildMessages + MessageContent (Phase 3, filter) and GuildMembers + // (Phase 5, auto-role + bulk role ops) are all privileged — must be enabled + // in the Discord Developer Portal, see the Phase 1 setup notes. + client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.GuildMembers, + ], + }) + + client.once('ready', async () => { + try { + await registerCommands(client.application.id, guildId) + await scheduler.start(client) + tempRoleSweeper.start(client) + inviteScheduler.start(client, guildId) + status = 'connected' + statusDetail = null + lastConnectedAt = new Date() + log.info('discord client ready', { user: client.user?.tag, guildId }) + } catch (err) { + status = 'error' + statusDetail = `startup failed: ${err.message}` + log.error('post-login startup failed (commands/scheduler/temp-roles/invites)', { message: err.message }) + } + }) + + client.on('interactionCreate', async (interaction) => { + if (await roleMenuHandler.handleInteraction(interaction)) return + if (!interaction.isChatInputCommand()) return + const command = commands.get(interaction.commandName) + if (!command) return + try { + await command.execute(interaction) + } catch (err) { + log.error('command execution failed', { command: interaction.commandName, message: err.message }) + const payload = { content: 'Something went wrong running that command.', ephemeral: true } + if (interaction.replied || interaction.deferred) await interaction.followUp(payload) + else await interaction.reply(payload) + } + }) + + client.on('messageCreate', messageFilter.handleMessageCreate) + client.on('guildMemberAdd', handleGuildMemberAdd) + + client.on('error', (err) => { + status = 'error' + statusDetail = err.message + log.error('discord client error', { message: err.message }) + }) + + try { + await client.login(token) + } catch (err) { + status = 'error' + statusDetail = err.message + client = null + log.error('discord login failed', { message: err.message }) + throw err + } +} + +function getStatus() { + return { status, statusDetail, guildId, lastConnectedAt } +} + +// For code that needs the live client + which guild it's connected to (the +// /internal/announce handler, slash commands already get both from the +// interaction itself so they don't need this). Returns null if disconnected. +function getConnection() { + if (!client || status !== 'connected') return null + return { client, guildId } +} + +module.exports = { start, stop, getStatus, getConnection } diff --git a/bot/src/discord/guildMemberAdd.js b/bot/src/discord/guildMemberAdd.js new file mode 100644 index 0000000..9932176 --- /dev/null +++ b/bot/src/discord/guildMemberAdd.js @@ -0,0 +1,19 @@ +// Auto-role on join. Requires the Server Members privileged intent (already +// enabled in the Discord Developer Portal per the Phase 1 setup notes). +const guildConfig = require('../model/guildConfig') +const createLogger = require('../utils/logger') + +const log = createLogger('autorole') + +async function handleGuildMemberAdd(member) { + try { + const roleId = await guildConfig.getAutoRoleId(member.guild.id) + if (!roleId) return + await member.roles.add(roleId) + log.info('auto-role assigned', { userId: member.id, roleId }) + } catch (err) { + log.warn('auto-role assignment failed', { userId: member.id, message: err.message }) + } +} + +module.exports = { handleGuildMemberAdd } diff --git a/bot/src/discord/messageFilter.js b/bot/src/discord/messageFilter.js new file mode 100644 index 0000000..56d3575 --- /dev/null +++ b/bot/src/discord/messageFilter.js @@ -0,0 +1,92 @@ +// messageCreate orchestration: allowlist bypass -> invite link -> banned word +// -> spam/mass-mention/mass-emoji. Invite/spam triggers always delete + warn +// (no severity tiers for those, unlike the word filter) — kept simple per the +// spec's "start simple" guidance. Filter-triggered mutes use a fixed 10-minute +// duration; per-severity-configurable durations are a future refinement. +const filterCache = require('../filter/filterCache') +const { findMatch } = require('../filter/normalize') +const inviteFilter = require('../filter/inviteFilter') +const spamFilter = require('../filter/spamFilter') +const warnings = require('../model/warnings') +const modLog = require('./modLog') +const createLogger = require('../utils/logger') + +const log = createLogger('filter') + +const FILTER_MUTE_SECONDS = 600 // 10 minutes + +function botActor(client) { + return { id: client.user.id, tag: client.user.tag } +} + +async function isBypassed(message, cache) { + if (cache.allowChannels.has(message.channelId)) return true + const memberRoles = message.member ? message.member.roles.cache : null + if (memberRoles && [...memberRoles.keys()].some((id) => cache.allowRoles.has(id))) return true + return false +} + +async function applyWarnAction(message, reason) { + const staff = botActor(message.client) + await warnings.add({ + guildId: message.guildId, + targetUserId: message.author.id, + targetTag: message.author.tag, + staffUserId: staff.id, + staffTag: staff.tag, + reason, + }) + await modLog.record({ client: message.client, guildId: message.guildId, actionType: 'warn', target: message.author, staffUser: staff, reason }) +} + +async function applyMuteAction(message, reason) { + const staff = botActor(message.client) + if (message.member && message.member.moderatable) { + await message.member.timeout(FILTER_MUTE_SECONDS * 1000, reason) + } + await modLog.record({ + client: message.client, + guildId: message.guildId, + actionType: 'mute', + target: message.author, + staffUser: staff, + reason, + durationSeconds: FILTER_MUTE_SECONDS, + }) +} + +async function handleMessageCreate(message) { + if (message.author.bot || !message.guildId) return + + try { + const cache = await filterCache.getOrLoad(message.guildId) + if (await isBypassed(message, cache)) return + + if (await inviteFilter.containsForeignInvite(message)) { + await message.delete().catch(() => {}) + await applyWarnAction(message, 'Posted a Discord invite link') + return + } + + const match = findMatch(message.content, cache.words) + if (match) { + await message.delete().catch(() => {}) + if (match.severity === 'mute') await applyMuteAction(message, `Filtered word: ${match.word}`) + else if (match.severity === 'warn') await applyWarnAction(message, `Filtered word: ${match.word}`) + return + } + + if ( + spamFilter.isRateLimited(message.guildId, message.author.id) || + spamFilter.isMassMention(message) || + spamFilter.isMassEmoji(message.content) + ) { + await message.delete().catch(() => {}) + await applyWarnAction(message, 'Automated spam detection (rate limit / mass mention / mass emoji)') + } + } catch (err) { + log.error('messageFilter failed', { message: err.message }) + } +} + +module.exports = { handleMessageCreate } diff --git a/bot/src/discord/modLog.js b/bot/src/discord/modLog.js new file mode 100644 index 0000000..0551e80 --- /dev/null +++ b/bot/src/discord/modLog.js @@ -0,0 +1,53 @@ +// Shared by every moderation command (ban/kick/mute/warn): writes the audit +// row and posts the embed to the configured mod-log channel. Takes `client` +// as a parameter (from interaction.client) rather than importing +// discordManager directly, to avoid a require cycle (discordManager -> commands +// -> modLog -> discordManager). +const { EmbedBuilder } = require('discord.js') + +const db = require('../db') +const guildConfig = require('../model/guildConfig') +const createLogger = require('../utils/logger') + +const log = createLogger('modlog') + +const COLOR = { ban: 0xd98b84, kick: 0xe0b070, mute: 0xe0b070, warn: 0xe0b070 } + +async function record({ client, guildId, actionType, target, staffUser, reason, durationSeconds }) { + await db.query( + `INSERT INTO mod_actions (guild_id, action_type, target_user_id, target_tag, staff_user_id, staff_tag, reason, duration_seconds) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [guildId, actionType, target.id, target.tag || null, staffUser.id, staffUser.tag || null, reason || null, durationSeconds || null], + ) + + try { + const channelId = await guildConfig.getModLogChannelId(guildId) + if (!channelId) return + const channel = await client.channels.fetch(channelId) + if (!channel || !channel.isTextBased()) return + + const embed = new EmbedBuilder() + .setColor(COLOR[actionType] || 0x9aa5b1) + .setTitle(actionType.toUpperCase()) + .addFields( + { name: 'Target', value: `${target.tag || target.id} (${target.id})`, inline: true }, + { name: 'Staff', value: `${staffUser.tag || staffUser.id} (${staffUser.id})`, inline: true }, + ) + .setTimestamp() + if (reason) embed.addFields({ name: 'Reason', value: reason }) + if (durationSeconds) embed.addFields({ name: 'Duration', value: formatDuration(durationSeconds), inline: true }) + + await channel.send({ embeds: [embed] }) + } catch (err) { + log.warn('failed to post mod-log embed', { message: err.message }) + } +} + +function formatDuration(seconds) { + if (seconds % 86400 === 0) return `${seconds / 86400}d` + if (seconds % 3600 === 0) return `${seconds / 3600}h` + if (seconds % 60 === 0) return `${seconds / 60}m` + return `${seconds}s` +} + +module.exports = { record } diff --git a/bot/src/discord/newsAnnounce.js b/bot/src/discord/newsAnnounce.js new file mode 100644 index 0000000..7f8cad3 --- /dev/null +++ b/bot/src/discord/newsAnnounce.js @@ -0,0 +1,26 @@ +// Shared by the /internal/announce webhook (site publishes a news post) and +// the manual /announce command (staff re-posts/boosts an existing one) — so +// both paths produce an identical embed. +const { EmbedBuilder } = require('discord.js') + +const guildConfig = require('../model/guildConfig') +const createLogger = require('../utils/logger') + +const log = createLogger('news') + +async function postAnnounce(client, guildId, { title, excerpt, url, imageUrl }) { + const channelId = await guildConfig.getNewsChannelId(guildId) + if (!channelId) throw new Error('No news channel configured — set one with /news first.') + + const channel = await client.channels.fetch(channelId) + if (!channel || !channel.isTextBased()) throw new Error('Configured news channel is missing or not text-based.') + + const embed = new EmbedBuilder().setColor(0x6a8fc2).setTitle(title).setURL(url) + if (excerpt) embed.setDescription(excerpt) + if (imageUrl) embed.setImage(imageUrl) + + await channel.send({ embeds: [embed] }) + log.info('news announced', { title, channelId }) +} + +module.exports = { postAnnounce } diff --git a/bot/src/discord/roleMenuHandler.js b/bot/src/discord/roleMenuHandler.js new file mode 100644 index 0000000..b11ea6c --- /dev/null +++ b/bot/src/discord/roleMenuHandler.js @@ -0,0 +1,41 @@ +// Button-based self-assignable role menus. customId is `rolemenu:` — +// the message's own id (not known until after it's sent, so it can't be +// embedded in the customId itself) is instead used to look up the tracked +// role_menus row and confirm the clicked roleId is really part of that +// menu's mapping, so a stale/foreign button can't toggle an untracked role. +const roleMenus = require('../model/roleMenus') +const createLogger = require('../utils/logger') + +const log = createLogger('rolemenu') + +const PREFIX = 'rolemenu:' + +// Returns true if this handler owned the interaction (caller should stop +// looking for another handler), false if it's not a role-menu button at all. +async function handleInteraction(interaction) { + if (!interaction.isButton() || !interaction.customId.startsWith(PREFIX)) return false + + const roleId = interaction.customId.slice(PREFIX.length) + try { + const menu = await roleMenus.getByMessageId(interaction.message.id) + if (!menu || !menu.mapping.some((m) => m.roleId === roleId)) { + await interaction.reply({ content: 'This role menu is no longer valid.', ephemeral: true }) + return true + } + + const member = interaction.member + if (member.roles.cache.has(roleId)) { + await member.roles.remove(roleId) + await interaction.reply({ content: `Removed <@&${roleId}>.`, ephemeral: true }) + } else { + await member.roles.add(roleId) + await interaction.reply({ content: `Added <@&${roleId}>.`, ephemeral: true }) + } + } catch (err) { + log.error('role menu toggle failed', { message: err.message }) + await interaction.reply({ content: 'Something went wrong toggling that role.', ephemeral: true }).catch(() => {}) + } + return true +} + +module.exports = { handleInteraction } diff --git a/bot/src/filter/filterCache.js b/bot/src/filter/filterCache.js new file mode 100644 index 0000000..e414be1 --- /dev/null +++ b/bot/src/filter/filterCache.js @@ -0,0 +1,31 @@ +// In-memory per-guild filter state (word list + allowlist), loaded at startup +// and refreshed on config change — the messageCreate handler runs on every +// message, so it must never hit the DB per message (per the spec's +// performance note). +const filterWords = require('../model/filterWords') +const filterAllowlist = require('../model/filterAllowlist') + +const cache = new Map() // guildId -> { words, allowRoles: Set, allowChannels: Set } + +async function load(guildId) { + const [words, roles, channels] = await Promise.all([ + filterWords.list(guildId), + filterAllowlist.getRoles(guildId), + filterAllowlist.getChannels(guildId), + ]) + const entry = { words, allowRoles: new Set(roles), allowChannels: new Set(channels) } + cache.set(guildId, entry) + return entry +} + +// Lazy-loads on first access per guild (e.g. the first message after boot). +async function getOrLoad(guildId) { + return cache.get(guildId) || load(guildId) +} + +// Called by /filter and /filterallow after any mutation. +function refresh(guildId) { + return load(guildId) +} + +module.exports = { getOrLoad, refresh } diff --git a/bot/src/filter/inviteFilter.js b/bot/src/filter/inviteFilter.js new file mode 100644 index 0000000..1805ea7 --- /dev/null +++ b/bot/src/filter/inviteFilter.js @@ -0,0 +1,23 @@ +// Detects Discord invite links and blocks any that don't resolve to the +// current guild (anti-raid/anti-advertising). An invite that fails to resolve +// (expired/invalid/vanity-only) is treated as foreign too — safer default +// than silently letting an unresolvable link through. +const INVITE_REGEX = /(?:discord\.gg|discord(?:app)?\.com\/invite)\/([a-zA-Z0-9-]+)/gi + +async function containsForeignInvite(message) { + const matches = [...message.content.matchAll(INVITE_REGEX)] + if (matches.length === 0) return false + + for (const match of matches) { + const code = match[1] + try { + const invite = await message.client.fetchInvite(code) + if (invite.guild?.id !== message.guildId) return true + } catch { + return true + } + } + return false +} + +module.exports = { containsForeignInvite } diff --git a/bot/src/filter/normalize.js b/bot/src/filter/normalize.js new file mode 100644 index 0000000..e304ef4 --- /dev/null +++ b/bot/src/filter/normalize.js @@ -0,0 +1,33 @@ +// Basic obfuscation-resistant normalization for the word filter: lowercase, +// common leetspeak substitutions, and collapsing 3+ repeated characters +// ("sooooo" -> "so") to one. Deliberately simple per the spec ("start simple, +// leave room to tighten later") — spaced-out letters ("b a d") and more exotic +// unicode lookalikes aren't handled yet. +const SUBS = { 4: 'a', '@': 'a', 3: 'e', 1: 'i', '!': 'i', 0: 'o', $: 's', 5: 's', 7: 't' } +const SUB_CHARS = /[4@31!05$7]/g + +function normalize(text) { + return text + .toLowerCase() + .replace(SUB_CHARS, (ch) => SUBS[ch] || ch) + .replace(/(.)\1{2,}/g, '$1') +} + +function escapeRegex(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +// Word-boundary match against already-normalized text. `word` is normalized +// here too, so callers can pass the raw stored value. +function matches(normalizedText, word) { + const pattern = new RegExp(`\\b${escapeRegex(normalize(word))}\\b`, 'i') + return pattern.test(normalizedText) +} + +// Returns the first matching filter_words row ({word, severity}) or null. +function findMatch(content, words) { + const normalizedText = normalize(content) + return words.find((w) => matches(normalizedText, w.word)) || null +} + +module.exports = { normalize, matches, findMatch } diff --git a/bot/src/filter/spamFilter.js b/bot/src/filter/spamFilter.js new file mode 100644 index 0000000..84cf80d --- /dev/null +++ b/bot/src/filter/spamFilter.js @@ -0,0 +1,42 @@ +// Basic in-memory spam/rate-limit detection. Per-user message-rate tracking is +// the only stateful piece here (mass-mention/mass-emoji are per-message +// counts) — kept in memory rather than the DB since this runs on every +// message and needs to be fast. +const RATE_LIMIT_COUNT = 5 +const RATE_LIMIT_WINDOW_MS = 5000 +const MENTION_THRESHOLD = 5 +const EMOJI_THRESHOLD = 10 +const SWEEP_INTERVAL_MS = 5 * 60 * 1000 + +const history = new Map() // `${guildId}:${userId}` -> timestamps[] + +function isRateLimited(guildId, userId) { + const key = `${guildId}:${userId}` + const now = Date.now() + const timestamps = (history.get(key) || []).filter((t) => now - t < RATE_LIMIT_WINDOW_MS) + timestamps.push(now) + history.set(key, timestamps) + return timestamps.length > RATE_LIMIT_COUNT +} + +function isMassMention(message) { + return message.mentions.users.size + message.mentions.roles.size > MENTION_THRESHOLD +} + +const EMOJI_REGEX = /|\p{Extended_Pictographic}/gu + +function isMassEmoji(content) { + const count = (content.match(EMOJI_REGEX) || []).length + return count > EMOJI_THRESHOLD +} + +// Periodic cleanup so `history` doesn't grow unbounded over a long-running +// process — drops any key with no recent activity. +setInterval(() => { + const now = Date.now() + for (const [key, timestamps] of history) { + if (timestamps.every((t) => now - t >= RATE_LIMIT_WINDOW_MS)) history.delete(key) + } +}, SWEEP_INTERVAL_MS).unref() + +module.exports = { isRateLimited, isMassMention, isMassEmoji } diff --git a/bot/src/internal/internal.controller.js b/bot/src/internal/internal.controller.js new file mode 100644 index 0000000..f36c8c9 --- /dev/null +++ b/bot/src/internal/internal.controller.js @@ -0,0 +1,50 @@ +const discordManager = require('../discord/discordManager') +const newsAnnounce = require('../discord/newsAnnounce') +const createLogger = require('../utils/logger') + +const log = createLogger('internal') + +// POST /internal/config — called by the main server right after an admin +// saves the Discord Bot panel, and by the bot's own bootstrap on startup +// (via a GET to the server for the current config, then this same start/stop +// logic locally). Body: { token, guildId, enabled }. +async function setConfig(req, res) { + const { token, guildId, enabled } = req.body || {} + try { + if (enabled) { + if (!token || !guildId) { + return res.status(400).json({ message: 'token and guildId are required when enabled' }) + } + await discordManager.start({ token, guildId }) + } else { + await discordManager.stop() + } + return res.json(discordManager.getStatus()) + } catch (err) { + log.error('setConfig failed', { message: err.message }) + // Still 200 with an error status — the caller (admin panel) should surface + // discordManager's status/statusDetail rather than treat this as a 5xx. + return res.json(discordManager.getStatus()) + } +} + +// GET /internal/status — live connection state, polled by the admin panel. +function getStatusHandler(req, res) { + return res.json(discordManager.getStatus()) +} + +// POST /internal/announce — called by the main server right after a news +// post is published. Body: { title, excerpt, url, imageUrl }. +async function announce(req, res) { + const connection = discordManager.getConnection() + if (!connection) return res.status(503).json({ message: 'Bot is not connected' }) + try { + await newsAnnounce.postAnnounce(connection.client, connection.guildId, req.body || {}) + return res.json({ posted: true }) + } catch (err) { + log.warn('announce failed', { message: err.message }) + return res.status(400).json({ message: err.message }) + } +} + +module.exports = { setConfig, getStatus: getStatusHandler, announce } diff --git a/bot/src/internal/internal.routes.js b/bot/src/internal/internal.routes.js new file mode 100644 index 0000000..25efa3f --- /dev/null +++ b/bot/src/internal/internal.routes.js @@ -0,0 +1,14 @@ +const express = require('express') + +const requireInternalKey = require('./requireInternalKey') +const ctrl = require('./internal.controller') + +const router = express.Router() + +router.use(requireInternalKey) + +router.post('/config', ctrl.setConfig) +router.get('/status', ctrl.getStatus) +router.post('/announce', ctrl.announce) + +module.exports = router diff --git a/bot/src/internal/requireInternalKey.js b/bot/src/internal/requireInternalKey.js new file mode 100644 index 0000000..efb20c7 --- /dev/null +++ b/bot/src/internal/requireInternalKey.js @@ -0,0 +1,19 @@ +// Gate for the bot's /internal/* API. The only caller is the main UOMysticmoon +// server, over the private compose network — never expose this route through +// the public reverse proxy. Timing-safe compare so response time can't be used +// to brute-force the shared secret one byte at a time. +const crypto = require('crypto') + +function requireInternalKey(req, res, next) { + const expected = process.env.BOT_INTERNAL_KEY || '' + const provided = req.get('X-Internal-Key') || '' + + const a = Buffer.from(expected) + const b = Buffer.from(provided) + const match = expected.length > 0 && a.length === b.length && crypto.timingSafeEqual(a, b) + + if (!match) return res.status(401).json({ message: 'Unauthorized' }) + return next() +} + +module.exports = requireInternalKey diff --git a/bot/src/invites/inviteRotator.js b/bot/src/invites/inviteRotator.js new file mode 100644 index 0000000..e254197 --- /dev/null +++ b/bot/src/invites/inviteRotator.js @@ -0,0 +1,37 @@ +// Shared by both /invite rotate and the weekly cron job (inviteScheduler.js) +// so manual and automatic rotations log identically. maxAge is set to match +// the rotation cadence as defense-in-depth: if the scheduled rotation were +// ever to silently stop running, the invite still expires on its own instead +// of staying live forever. +const guildConfig = require('../model/guildConfig') +const inviteLog = require('../model/inviteLog') +const createLogger = require('../utils/logger') + +const log = createLogger('invites') + +const ROTATION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 // 7 days + +async function rotate(client, guildId, { triggeredBy, triggeredByTag } = {}) { + const channelId = await guildConfig.getInviteChannelId(guildId) + if (!channelId) throw new Error('No invite channel configured — set one with /invite channel first.') + + const channel = await client.channels.fetch(channelId) + if (!channel || !channel.isTextBased()) throw new Error('Configured invite channel is missing or not text-based.') + + const current = await inviteLog.getCurrent(guildId) + if (current) { + try { + await channel.guild.invites.delete(current.invite_code, 'Invite rotation') + } catch (err) { + log.warn('failed to revoke previous invite (may already be gone)', { message: err.message }) + } + await inviteLog.markRevoked(current.id) + } + + const invite = await channel.createInvite({ maxAge: ROTATION_MAX_AGE_SECONDS, unique: true, reason: 'Invite rotation' }) + await inviteLog.record({ guildId, channelId, inviteCode: invite.code, triggeredBy, triggeredByTag }) + log.info('invite rotated', { code: invite.code, triggeredBy: triggeredByTag || 'automatic (scheduled)' }) + return invite +} + +module.exports = { rotate } diff --git a/bot/src/invites/inviteScheduler.js b/bot/src/invites/inviteScheduler.js new file mode 100644 index 0000000..e459bbb --- /dev/null +++ b/bot/src/invites/inviteScheduler.js @@ -0,0 +1,31 @@ +// Weekly automatic invite rotation (Sundays at midnight). A missing invite +// channel config just skips quietly (warn-logged) — most guilds won't set +// this up on day one, and that shouldn't spam errors every week until they do. +const cron = require('node-cron') + +const inviteRotator = require('./inviteRotator') +const createLogger = require('../utils/logger') + +const log = createLogger('invites') + +let task = null + +function start(client, guildId) { + task = cron.schedule('0 0 * * 0', async () => { + try { + await inviteRotator.rotate(client, guildId, {}) + } catch (err) { + log.warn('scheduled invite rotation skipped', { message: err.message }) + } + }) + log.info('invite rotation scheduler started') +} + +function stop() { + if (task) { + task.stop() + task = null + } +} + +module.exports = { start, stop } diff --git a/bot/src/model/filterAllowlist.js b/bot/src/model/filterAllowlist.js new file mode 100644 index 0000000..adea944 --- /dev/null +++ b/bot/src/model/filterAllowlist.js @@ -0,0 +1,40 @@ +// Roles/channels that bypass word/invite/spam filtering entirely (staff roles, +// bot-commands channels, etc.). Stored as CSV in guild_config rather than a +// separate table — short, rarely-changed lists. +const guildConfig = require('./guildConfig') + +const ROLES_KEY = 'filter_allow_roles' +const CHANNELS_KEY = 'filter_allow_channels' + +function parseCsv(value) { + return value ? value.split(',').filter(Boolean) : [] +} + +async function getRoles(guildId) { + return parseCsv(await guildConfig.get(guildId, ROLES_KEY)) +} + +async function getChannels(guildId) { + return parseCsv(await guildConfig.get(guildId, CHANNELS_KEY)) +} + +// Toggle: adds the id if absent, removes it if present. Returns the new state (true = now allowed). +async function toggleRole(guildId, roleId) { + const roles = await getRoles(guildId) + const idx = roles.indexOf(roleId) + if (idx === -1) roles.push(roleId) + else roles.splice(idx, 1) + await guildConfig.set(guildId, ROLES_KEY, roles.join(',')) + return idx === -1 +} + +async function toggleChannel(guildId, channelId) { + const channels = await getChannels(guildId) + const idx = channels.indexOf(channelId) + if (idx === -1) channels.push(channelId) + else channels.splice(idx, 1) + await guildConfig.set(guildId, CHANNELS_KEY, channels.join(',')) + return idx === -1 +} + +module.exports = { getRoles, getChannels, toggleRole, toggleChannel } diff --git a/bot/src/model/filterWords.js b/bot/src/model/filterWords.js new file mode 100644 index 0000000..15b9fd3 --- /dev/null +++ b/bot/src/model/filterWords.js @@ -0,0 +1,22 @@ +const db = require('../db') + +async function add({ guildId, word, severity, addedBy, addedByTag }) { + await db.query( + `INSERT INTO filter_words (guild_id, word, severity, added_by, added_by_tag) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE severity = VALUES(severity), added_by = VALUES(added_by), added_by_tag = VALUES(added_by_tag)`, + [guildId, word.toLowerCase(), severity || 'delete', addedBy || null, addedByTag || null], + ) +} + +// Returns true if a row was actually removed. +async function remove(guildId, word) { + const res = await db.query('DELETE FROM filter_words WHERE guild_id = ? AND word = ?', [guildId, word.toLowerCase()]) + return Number(res.affectedRows || 0) > 0 +} + +async function list(guildId) { + return db.query('SELECT word, severity FROM filter_words WHERE guild_id = ? ORDER BY word ASC', [guildId]) +} + +module.exports = { add, remove, list } diff --git a/bot/src/model/guildConfig.js b/bot/src/model/guildConfig.js new file mode 100644 index 0000000..5de5a04 --- /dev/null +++ b/bot/src/model/guildConfig.js @@ -0,0 +1,47 @@ +// Per-guild key/value config the bot owns (see guild_config in +// server/db/schema.sql). Generic get/set now; filters/schedules/role-menu +// config reuses this same table in later phases. +const db = require('../db') + +const MOD_LOG_CHANNEL_KEY = 'mod_log_channel_id' +const AUTO_ROLE_KEY = 'auto_role_id' +const INVITE_CHANNEL_KEY = 'invite_channel_id' +const NEWS_CHANNEL_KEY = 'news_channel_id' + +async function get(guildId, key) { + const rows = await db.query('SELECT value FROM guild_config WHERE guild_id = ? AND `key` = ? LIMIT 1', [guildId, key]) + return rows[0] ? rows[0].value : null +} + +async function set(guildId, key, value) { + await db.query( + `INSERT INTO guild_config (guild_id, \`key\`, value) VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE value = VALUES(value)`, + [guildId, key, value], + ) +} + +const getModLogChannelId = (guildId) => get(guildId, MOD_LOG_CHANNEL_KEY) +const setModLogChannelId = (guildId, channelId) => set(guildId, MOD_LOG_CHANNEL_KEY, channelId) + +const getAutoRoleId = (guildId) => get(guildId, AUTO_ROLE_KEY) +const setAutoRoleId = (guildId, roleId) => set(guildId, AUTO_ROLE_KEY, roleId) + +const getInviteChannelId = (guildId) => get(guildId, INVITE_CHANNEL_KEY) +const setInviteChannelId = (guildId, channelId) => set(guildId, INVITE_CHANNEL_KEY, channelId) + +const getNewsChannelId = (guildId) => get(guildId, NEWS_CHANNEL_KEY) +const setNewsChannelId = (guildId, channelId) => set(guildId, NEWS_CHANNEL_KEY, channelId) + +module.exports = { + get, + set, + getModLogChannelId, + setModLogChannelId, + getAutoRoleId, + setAutoRoleId, + getInviteChannelId, + setInviteChannelId, + getNewsChannelId, + setNewsChannelId, +} diff --git a/bot/src/model/inviteLog.js b/bot/src/model/inviteLog.js new file mode 100644 index 0000000..4f2ac47 --- /dev/null +++ b/bot/src/model/inviteLog.js @@ -0,0 +1,29 @@ +const db = require('../db') + +async function record({ guildId, channelId, inviteCode, triggeredBy, triggeredByTag }) { + const res = await db.query( + `INSERT INTO invite_log (guild_id, channel_id, invite_code, triggered_by, triggered_by_tag) + VALUES (?, ?, ?, ?, ?)`, + [guildId, channelId, inviteCode, triggeredBy || null, triggeredByTag || null], + ) + return res.insertId +} + +// The active (not-yet-revoked) invite for a guild, if any. +async function getCurrent(guildId) { + const rows = await db.query( + 'SELECT * FROM invite_log WHERE guild_id = ? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1', + [guildId], + ) + return rows[0] || null +} + +async function markRevoked(id) { + await db.query('UPDATE invite_log SET revoked_at = NOW() WHERE id = ?', [id]) +} + +async function list(guildId, limit = 10) { + return db.query('SELECT * FROM invite_log WHERE guild_id = ? ORDER BY created_at DESC LIMIT ?', [guildId, limit]) +} + +module.exports = { record, getCurrent, markRevoked, list } diff --git a/bot/src/model/roleMenus.js b/bot/src/model/roleMenus.js new file mode 100644 index 0000000..505bc3e --- /dev/null +++ b/bot/src/model/roleMenus.js @@ -0,0 +1,17 @@ +const db = require('../db') + +async function add({ guildId, channelId, messageId, mapping, createdBy }) { + await db.query( + `INSERT INTO role_menus (guild_id, channel_id, message_id, mapping, created_by) + VALUES (?, ?, ?, ?, ?)`, + [guildId, channelId, messageId, JSON.stringify(mapping), createdBy || null], + ) +} + +async function getByMessageId(messageId) { + const rows = await db.query('SELECT * FROM role_menus WHERE message_id = ? LIMIT 1', [messageId]) + if (!rows[0]) return null + return { ...rows[0], mapping: JSON.parse(rows[0].mapping) } +} + +module.exports = { add, getByMessageId } diff --git a/bot/src/model/scheduledMessages.js b/bot/src/model/scheduledMessages.js new file mode 100644 index 0000000..9a8a11e --- /dev/null +++ b/bot/src/model/scheduledMessages.js @@ -0,0 +1,57 @@ +const db = require('../db') + +async function addRecurring({ guildId, channelId, content, cronExpression, createdBy, createdByTag }) { + const res = await db.query( + `INSERT INTO scheduled_messages (guild_id, channel_id, content, cron_expression, created_by, created_by_tag) + VALUES (?, ?, ?, ?, ?, ?)`, + [guildId, channelId, content, cronExpression, createdBy || null, createdByTag || null], + ) + return res.insertId +} + +async function addOnce({ guildId, channelId, content, runAt, createdBy, createdByTag }) { + const res = await db.query( + `INSERT INTO scheduled_messages (guild_id, channel_id, content, run_at, created_by, created_by_tag) + VALUES (?, ?, ?, ?, ?, ?)`, + [guildId, channelId, content, runAt, createdBy || null, createdByTag || null], + ) + return res.insertId +} + +// Returns true if a row was actually removed (scoped to the guild so one +// guild can't remove another's rows). +async function remove(guildId, id) { + const res = await db.query('DELETE FROM scheduled_messages WHERE id = ? AND guild_id = ?', [id, guildId]) + return Number(res.affectedRows || 0) > 0 +} + +async function list(guildId) { + return db.query( + `SELECT id, channel_id, content, cron_expression, run_at, enabled, sent_at FROM scheduled_messages + WHERE guild_id = ? ORDER BY id ASC`, + [guildId], + ) +} + +// All enabled recurring rows across every guild the bot serves — v1 only +// ever has one, but the scheduler doesn't need to special-case that. +async function listEnabledRecurring() { + return db.query( + `SELECT id, guild_id, channel_id, content, cron_expression FROM scheduled_messages + WHERE cron_expression IS NOT NULL AND enabled = 1`, + ) +} + +// One-off rows due to post right now. +async function listDueOneOff() { + return db.query( + `SELECT id, guild_id, channel_id, content FROM scheduled_messages + WHERE run_at IS NOT NULL AND sent_at IS NULL AND enabled = 1 AND run_at <= NOW()`, + ) +} + +async function markSent(id) { + await db.query('UPDATE scheduled_messages SET sent_at = NOW() WHERE id = ?', [id]) +} + +module.exports = { addRecurring, addOnce, remove, list, listEnabledRecurring, listDueOneOff, markSent } diff --git a/bot/src/model/tempRoles.js b/bot/src/model/tempRoles.js new file mode 100644 index 0000000..2091a07 --- /dev/null +++ b/bot/src/model/tempRoles.js @@ -0,0 +1,26 @@ +const db = require('../db') + +// Upsert — re-granting the same temp role refreshes its expiry instead of +// creating a duplicate row (see UNIQUE(guild,user,role) in schema.sql). +async function add({ guildId, userId, roleId, expiresAt, createdBy }) { + await db.query( + `INSERT INTO temp_roles (guild_id, user_id, role_id, expires_at, created_by) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE expires_at = VALUES(expires_at), created_by = VALUES(created_by)`, + [guildId, userId, roleId, expiresAt, createdBy || null], + ) +} + +async function remove(guildId, userId, roleId) { + await db.query('DELETE FROM temp_roles WHERE guild_id = ? AND user_id = ? AND role_id = ?', [guildId, userId, roleId]) +} + +async function listExpired() { + return db.query('SELECT id, guild_id, user_id, role_id FROM temp_roles WHERE expires_at <= NOW()') +} + +async function removeById(id) { + await db.query('DELETE FROM temp_roles WHERE id = ?', [id]) +} + +module.exports = { add, remove, listExpired, removeById } diff --git a/bot/src/model/warnings.js b/bot/src/model/warnings.js new file mode 100644 index 0000000..8a61f3f --- /dev/null +++ b/bot/src/model/warnings.js @@ -0,0 +1,26 @@ +// Standing warnings (separate from mod_actions so /warnings can list a +// user's active warnings). expires_at is always NULL for now — decay/escalation +// (e.g. "3 active warns -> auto-mute") is deferred past Phase 2, see +// warn.command.js. +const db = require('../db') + +async function add({ guildId, targetUserId, targetTag, staffUserId, staffTag, reason }) { + await db.query( + `INSERT INTO warnings (guild_id, target_user_id, target_tag, staff_user_id, staff_tag, reason) + VALUES (?, ?, ?, ?, ?, ?)`, + [guildId, targetUserId, targetTag || null, staffUserId, staffTag || null, reason || null], + ) +} + +// Active = not expired. Every row is active today since expires_at is never +// set, but the query is written to already respect it once decay lands. +async function listActive(guildId, targetUserId) { + return db.query( + `SELECT id, reason, staff_tag, created_at FROM warnings + WHERE guild_id = ? AND target_user_id = ? AND (expires_at IS NULL OR expires_at > NOW()) + ORDER BY created_at DESC`, + [guildId, targetUserId], + ) +} + +module.exports = { add, listActive } diff --git a/bot/src/roles/tempRoleSweeper.js b/bot/src/roles/tempRoleSweeper.js new file mode 100644 index 0000000..18504e3 --- /dev/null +++ b/bot/src/roles/tempRoleSweeper.js @@ -0,0 +1,48 @@ +// Once-a-minute sweep for expired temp_roles: removes the Discord role (best +// effort — the member/guild/role may already be gone) then deletes the row +// regardless, so a stale row can never block future re-grants of the same +// role to the same member. +const cron = require('node-cron') + +const tempRoles = require('../model/tempRoles') +const createLogger = require('../utils/logger') + +const log = createLogger('temproles') + +let client = null +let task = null + +async function sweep() { + try { + const expired = await tempRoles.listExpired() + for (const row of expired) { + try { + const guild = await client.guilds.fetch(row.guild_id) + const member = await guild.members.fetch(row.user_id).catch(() => null) + if (member) await member.roles.remove(row.role_id).catch(() => {}) + } catch (err) { + log.warn('failed to remove expired temp role', { message: err.message, roleId: row.role_id, userId: row.user_id }) + } finally { + await tempRoles.removeById(row.id) + } + } + } catch (err) { + log.error('temp role sweep failed', { message: err.message }) + } +} + +function start(discordClient) { + client = discordClient + task = cron.schedule('* * * * *', sweep) + log.info('temp role sweeper started') +} + +function stop() { + if (task) { + task.stop() + task = null + } + client = null +} + +module.exports = { start, stop } diff --git a/bot/src/scheduler/scheduler.js b/bot/src/scheduler/scheduler.js new file mode 100644 index 0000000..fd2a00a --- /dev/null +++ b/bot/src/scheduler/scheduler.js @@ -0,0 +1,83 @@ +// Recurring + one-off scheduled channel messages. Recurring rows are each +// registered as their own node-cron task; one-off rows are picked up by a +// once-a-minute sweep that checks for anything due and marks it sent so it +// never reposts. Needs a live discord.js Client to actually send — wired up +// by discordManager.js (start() once the client is ready, stop() alongside +// client teardown). +const cron = require('node-cron') + +const scheduledMessages = require('../model/scheduledMessages') +const createLogger = require('../utils/logger') + +const log = createLogger('scheduler') + +let discordClient = null +const recurringTasks = new Map() // id -> node-cron ScheduledTask +let sweepTask = null + +async function sendToChannel(channelId, content) { + try { + const channel = await discordClient.channels.fetch(channelId) + if (!channel || !channel.isTextBased()) { + log.warn('scheduled message skipped — channel missing or not text-based', { channelId }) + return + } + await channel.send({ content }) + log.info('sent scheduled message', { channelId }) + } catch (err) { + log.warn('failed to send scheduled message', { channelId, message: err.message }) + } +} + +async function loadRecurring() { + for (const task of recurringTasks.values()) task.stop() + recurringTasks.clear() + + const rows = await scheduledMessages.listEnabledRecurring() + for (const row of rows) { + if (!cron.validate(row.cron_expression)) { + log.warn('skipping scheduled message with invalid cron expression', { id: row.id, cron: row.cron_expression }) + continue + } + const task = cron.schedule(row.cron_expression, () => sendToChannel(row.channel_id, row.content)) + recurringTasks.set(row.id, task) + } + log.info('loaded recurring scheduled messages', { count: recurringTasks.size }) +} + +async function sweepDueOneOff() { + try { + const due = await scheduledMessages.listDueOneOff() + for (const row of due) { + await sendToChannel(row.channel_id, row.content) + await scheduledMessages.markSent(row.id) + } + } catch (err) { + log.error('one-off sweep failed', { message: err.message }) + } +} + +async function start(client) { + discordClient = client + await loadRecurring() + sweepTask = cron.schedule('* * * * *', sweepDueOneOff) + log.info('scheduler started') +} + +// Called by /schedule after any add/remove so changes apply without a restart. +async function refresh() { + if (!discordClient) return + await loadRecurring() +} + +function stop() { + for (const task of recurringTasks.values()) task.stop() + recurringTasks.clear() + if (sweepTask) { + sweepTask.stop() + sweepTask = null + } + discordClient = null +} + +module.exports = { start, stop, refresh } diff --git a/bot/src/server.js b/bot/src/server.js new file mode 100644 index 0000000..70cdc7d --- /dev/null +++ b/bot/src/server.js @@ -0,0 +1,52 @@ +require('dotenv').config() + +const app = require('./app') +const bootstrap = require('./bootstrap') +const createLogger = require('./utils/logger') +const discordManager = require('./discord/discordManager') +const pkg = require('../package.json') + +const log = createLogger('server') +const PORT = Number(process.env.PORT) || 4100 +const HOST = '0.0.0.0' + +async function start() { + log.info(`starting UOMysticmoon bot v${pkg.version}`, { + node: process.version, + logFile: createLogger.logFilePath || 'disabled (console only)', + }) + + const server = app.listen(PORT, HOST, () => { + log.info(`internal API listening on http://${HOST}:${PORT}`) + }) + + await bootstrap() + + setupShutdown(server) +} + +function setupShutdown(server) { + let closing = false + const shutdown = async (signal) => { + if (closing) return + closing = true + log.warn(`${signal} received — shutting down gracefully`) + server.close(() => log.info('internal API closed')) + await discordManager.stop() + await createLogger.close() + process.exit(0) + } + + process.on('SIGINT', () => shutdown('SIGINT')) + process.on('SIGTERM', () => shutdown('SIGTERM')) + process.on('unhandledRejection', (reason) => log.error('unhandledRejection', { reason: String(reason) })) + process.on('uncaughtException', (err) => { + log.error('uncaughtException', err) + process.exit(1) + }) +} + +start().catch((err) => { + log.error('failed to start bot', err) + process.exit(1) +}) diff --git a/bot/src/site/siteApiClient.js b/bot/src/site/siteApiClient.js new file mode 100644 index 0000000..0fe8bfe --- /dev/null +++ b/bot/src/site/siteApiClient.js @@ -0,0 +1,42 @@ +// Read-only client for the main site's PUBLIC API (no shared secret — this is +// the same unauthenticated data any visitor's browser can fetch). Used by +// /wiki (search) and /announce (re-post an existing news item). Distinct from +// botInternalClient.js, which is the shared-secret-gated server<->bot channel. +const createLogger = require('../utils/logger') + +const log = createLogger('site-api') + +const BASE_URL = (process.env.SITE_PUBLIC_URL || 'http://localhost:3000/api/v1/public').replace(/\/+$/, '') +const TIMEOUT_MS = 5000 + +async function call(path) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS) + try { + const res = await fetch(`${BASE_URL}${path}`, { signal: controller.signal }) + const data = await res.json().catch(() => null) + // Public content routes 503 with this shape while the site is in + // maintenance mode (see server/src/middleware/siteMode.js) — surface it + // distinctly so commands can show a clear message instead of a generic error. + if (res.status === 503 && data?.mode === 'maintenance') { + return { ok: false, maintenance: true, message: data.message } + } + if (!res.ok) return { ok: false, error: `site responded ${res.status}` } + return { ok: true, data } + } catch (err) { + log.warn('site API call failed', { path, message: err.message }) + return { ok: false, error: err.message } + } finally { + clearTimeout(timeout) + } +} + +function getNewsPost(idOrSlug) { + return call(`/posts/news/${encodeURIComponent(idOrSlug)}`) +} + +function searchWiki(query) { + return call(`/wiki?q=${encodeURIComponent(query)}`) +} + +module.exports = { getNewsPost, searchWiki } diff --git a/bot/src/utils/duration.js b/bot/src/utils/duration.js new file mode 100644 index 0000000..3bfab3b --- /dev/null +++ b/bot/src/utils/duration.js @@ -0,0 +1,16 @@ +// Parses simple duration strings ("30s", "10m", "2h", "1d") to milliseconds. +// Returns null for anything unparseable. Discord's own timeout API caps at 28 +// days — callers should clamp to MAX_TIMEOUT_MS rather than trust user input. +const UNIT_MS = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 } + +const MAX_TIMEOUT_MS = 28 * 86_400_000 + +function parseDuration(input) { + if (!input) return null + const match = /^(\d+)\s*(s|m|h|d)$/i.exec(input.trim()) + if (!match) return null + const [, amount, unit] = match + return Number(amount) * UNIT_MS[unit.toLowerCase()] +} + +module.exports = { parseDuration, MAX_TIMEOUT_MS } diff --git a/bot/src/utils/logger.js b/bot/src/utils/logger.js new file mode 100644 index 0000000..a165ecf --- /dev/null +++ b/bot/src/utils/logger.js @@ -0,0 +1,97 @@ +// Dual-transport logger: writes to the console AND to a log file. +// Levels: error | warn | info | debug. +// LOG_LEVEL console verbosity (default info) +// FILE_LOG_LEVEL file verbosity (default debug — keep a full record on disk) +// LOG_TO_FILE enable file logging (default true) +// LOG_DIR log directory (default /logs) +// LOG_FILE log file name (default bot.log) +// +// Copied from server/src/utils/logger.js rather than shared — the bot is an +// independently deployable process with its own package.json/Dockerfile. +const fs = require('fs') +const path = require('path') + +const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 } + +const consoleThreshold = LEVELS[(process.env.LOG_LEVEL || 'info').toLowerCase()] ?? LEVELS.info +const fileThreshold = LEVELS[(process.env.FILE_LOG_LEVEL || 'debug').toLowerCase()] ?? LEVELS.debug + +// Color only on an interactive TTY — never in files or Docker logs. +const useColor = Boolean(process.stdout.isTTY) && process.env.NO_COLOR == null +const COLOR = { error: '\x1b[31m', warn: '\x1b[33m', info: '\x1b[36m', debug: '\x1b[90m' } +const RESET = '\x1b[0m' + +// ── File transport ──────────────────────────────────────────────────── +const fileEnabled = (process.env.LOG_TO_FILE || 'true').toLowerCase() !== 'false' +let fileStream = null +let logFilePath = null + +if (fileEnabled) { + try { + const dir = process.env.LOG_DIR || path.join(__dirname, '..', '..', 'logs') + fs.mkdirSync(dir, { recursive: true }) + logFilePath = path.join(dir, process.env.LOG_FILE || 'bot.log') + fileStream = fs.createWriteStream(logFilePath, { flags: 'a' }) + fileStream.on('error', (err) => { + process.stderr.write(`[logger] file logging disabled: ${err.message}\n`) + fileStream = null + }) + } catch (err) { + process.stderr.write(`[logger] could not open log file: ${err.message}\n`) + fileStream = null + } +} + +function fmt(meta) { + if (meta == null) return '' + if (typeof meta === 'string') return meta + if (meta instanceof Error) return JSON.stringify({ message: meta.message, stack: meta.stack }) + try { + return JSON.stringify(meta) + } catch { + return String(meta) + } +} + +function emit(level, tag, msg, meta) { + const levelNum = LEVELS[level] + if (levelNum === undefined) return + + const ts = new Date().toISOString() + const lvl = level.toUpperCase().padEnd(5) + const label = tag ? ` [${tag}]` : '' + const metaStr = meta === undefined ? '' : ` ${fmt(meta)}` + const plain = `${ts} ${lvl}${label} ${msg}${metaStr}` + + // Console transport + if (levelNum <= consoleThreshold) { + const line = useColor ? `${COLOR[level] || ''}${plain}${RESET}` : plain + const stream = level === 'error' || level === 'warn' ? process.stderr : process.stdout + stream.write(`${line}\n`) + } + + // File transport (plain text, no color) + if (fileStream && levelNum <= fileThreshold) { + fileStream.write(`${plain}\n`) + } +} + +function createLogger(tag) { + return { + error: (msg, meta) => emit('error', tag, msg, meta), + warn: (msg, meta) => emit('warn', tag, msg, meta), + info: (msg, meta) => emit('info', tag, msg, meta), + debug: (msg, meta) => emit('debug', tag, msg, meta), + } +} + +// Flush and close the file stream (called on graceful shutdown). +createLogger.close = () => + new Promise((resolve) => { + if (fileStream) fileStream.end(resolve) + else resolve() + }) + +createLogger.emit = emit +createLogger.logFilePath = logFilePath +module.exports = createLogger diff --git a/client/src/App.jsx b/client/src/App.jsx index c0af506..2796c51 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -27,6 +27,7 @@ import HeroEditor from './routes/admin/views/HeroEditor.jsx' import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx' import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx' import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx' +import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx' import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx' import UsersAdmin from './routes/admin/views/UsersAdmin.jsx' import AccountAdmin from './routes/admin/views/AccountAdmin.jsx' @@ -74,6 +75,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index d05a040..fd027ba 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -132,6 +132,10 @@ export const api = { createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }), updateAuthProvider: (id, data) => req(`/admin/auth/providers/${id}`, { method: 'PUT', body: data }), deleteAuthProvider: (id) => req(`/admin/auth/providers/${id}`, { method: 'DELETE' }), + + // ----- Discord bot control (admin only) ----- + getDiscordBotConfig: () => req('/admin/discord-bot/config'), + saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }), }, } diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index cb921ca..ac0c82c 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -12,6 +12,7 @@ const NAV = [ { to: '/admin/settings', label: 'Settings' }, { to: '/admin/activity', label: 'Activity' }, { to: '/admin/bot-activity', label: 'Bot Activity' }, + { to: '/admin/discord-bot', label: 'Discord Bot' }, { to: '/admin/auth-providers', label: 'Authentication' }, { to: '/admin/users', label: 'Users' }, { to: '/admin/account', label: 'Account' }, @@ -25,6 +26,7 @@ const TITLES = { '/admin/settings': 'Site Settings', '/admin/activity': 'Activity Log', '/admin/bot-activity': 'Bot Activity', + '/admin/discord-bot': 'Discord Bot', '/admin/auth-providers': 'Authentication', '/admin/users': 'Users', '/admin/account': 'Account Security', diff --git a/client/src/routes/admin/views/DiscordBotAdmin.jsx b/client/src/routes/admin/views/DiscordBotAdmin.jsx new file mode 100644 index 0000000..f8009bd --- /dev/null +++ b/client/src/routes/admin/views/DiscordBotAdmin.jsx @@ -0,0 +1,151 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Loading, ErrorState } from '../../../components/PageState.jsx' +import { api } from '../../../api/client.js' + +// Discord bot control panel (Phase 1). The bot token is write-only over this +// API — stored encrypted in the DB, never returned — same convention as the +// Google/Discord login-SSO secrets on the Authentication page. Saving pushes +// the config straight to the bot process, so Enabled takes effect immediately +// with no redeploy. + +function Toggle({ checked, onChange, label }) { + return ( + + ) +} + +const STATUS_COLOR = { + connected: '#7fd0a4', + connecting: '#e0b070', + error: '#d98b84', + disconnected: 'var(--muted)', +} + +function StatusPanel({ config }) { + const color = STATUS_COLOR[config.status] || 'var(--muted)' + return ( +
+
+ + + {config.status || 'disconnected'} + +
+ {config.statusDetail && ( +

{config.statusDetail}

+ )} + {config.lastConnectedAt && ( +

+ Last connected: {new Date(config.lastConnectedAt).toLocaleString()} +

+ )} +
+ ) +} + +export default function DiscordBotAdmin() { + const [config, setConfig] = useState(null) + const [error, setError] = useState('') + const [guildId, setGuildId] = useState('') + const [token, setToken] = useState('') + const [enabled, setEnabled] = useState(false) + const [busy, setBusy] = useState(false) + const [msg, setMsg] = useState('') + const [saveError, setSaveError] = useState('') + const pollRef = useRef(null) + + // Only the very first load seeds the editable fields (guildId/enabled). + // Every subsequent poll tick updates `config` (status/hasToken/etc.) so the + // live-status panel stays fresh, but must NOT touch the form state — doing + // so would silently overwrite whatever the admin is mid-typing/toggling + // before they get a chance to hit Save. + const initializedRef = useRef(false) + + const load = useCallback(async () => { + try { + const c = await api.admin.getDiscordBotConfig() + setConfig(c) + if (!initializedRef.current) { + setGuildId(c.guildId || '') + setEnabled(c.enabled) + initializedRef.current = true + } + } catch { + setError('Could not load Discord bot config.') + } + }, []) + + useEffect(() => { + load() + pollRef.current = setInterval(load, 5000) + return () => clearInterval(pollRef.current) + }, [load]) + + async function save() { + setBusy(true) + setMsg('') + setSaveError('') + try { + const body = { guildId, enabled } + if (token) body.token = token // only send a new token when entered + const saved = await api.admin.saveDiscordBotConfig(body) + setConfig(saved) + setToken('') + setMsg('Saved.') + } catch (err) { + setSaveError(err.message || 'Could not save.') + } finally { + setBusy(false) + } + } + + if (error) return + if (!config) return + + return ( +
+

+ Discord Bot +

+ + + + + + + + + +
+ + {msg && {msg}} + {saveError && {saveError}} +
+
+ ) +} diff --git a/docker-compose.yml b/docker-compose.yml index f0306a5..f16de16 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,28 @@ services: ports: - "3000:3000" + bot: + build: + context: . + dockerfile: bot/Dockerfile + restart: unless-stopped + env_file: .env + environment: + DB_HOST: db + SITE_INTERNAL_URL: http://app:3000/api/v1/internal/bot-config + SITE_PUBLIC_URL: http://app:3000/api/v1/public + LOG_DIR: /app/bot/logs + depends_on: + db: + condition: service_healthy + app: + condition: service_started + volumes: + - ./bot/logs:/app/bot/logs + # No published port — the bot's internal API (/internal/*) is reached only + # by `app` over the private compose network, and must NEVER be exposed + # through Pangolin/the public reverse proxy. + volumes: dbdata: uploads: diff --git a/package.json b/package.json index 1d93da5..b79d706 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,11 @@ "scripts": { "install-server": "npm install --prefix server", "install-client": "npm install --prefix client", - "install-all": "npm run install-server && npm run install-client", + "install-bot": "npm install --prefix bot", + "install-all": "npm run install-server && npm run install-client && npm run install-bot", "server": "npm run dev --prefix server", "client": "npm run dev --prefix client", + "bot": "npm run dev --prefix bot", "seed": "npm run seed --prefix server", "build": "npm run build --prefix client", "start": "npm start --prefix server" diff --git a/server/.env.example b/server/.env.example index 98a71e1..c5930d9 100644 --- a/server/.env.example +++ b/server/.env.example @@ -73,3 +73,12 @@ SMTP_PASS= CONTACT_TO=UOMysticmoon@gmail.com CLIENT_ORIGIN=http://localhost:5173 + +# Discord bot — internal API (server <-> bot/). BOT_INTERNAL_KEY MUST be +# byte-for-byte identical to the same variable in bot/.env.example — it is the +# only auth on both sides' /internal/* routes, so a mismatch silently breaks +# every server<->bot call with 401s. The Discord bot TOKEN itself is not an env +# var — it's entered in the admin panel (Discord Bot page) and stored +# encrypted in the DB (see the bot_config table / SECRET_ENC_KEY above). +BOT_INTERNAL_URL=http://localhost:4100 +BOT_INTERNAL_KEY=dev-only-change-me-bot-key diff --git a/server/db/schema.sql b/server/db/schema.sql index faf363b..6a77dc6 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -176,6 +176,179 @@ CREATE TABLE IF NOT EXISTS mobile_refresh_tokens ( INDEX idx_mrt_expires (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- Discord bot control (Phase 1). Singleton row (id = 1) holding the bot's +-- config — the token is encrypted at rest (bot_token_enc) the same way OAuth +-- client secrets are, and is only ever decrypted server-side to push to the +-- bot process over the internal API; it is never returned to the admin UI +-- and the bot process never reads this table directly. `status`/`status_detail` +-- /`last_connected_at` are last-known-state mirrors of what the bot reported, +-- shown in the admin panel between polls. +CREATE TABLE IF NOT EXISTS bot_config ( + id INT PRIMARY KEY DEFAULT 1, + guild_id VARCHAR(32) NULL, + bot_token_enc TEXT NULL, + application_id VARCHAR(32) NULL, + enabled TINYINT(1) NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'disconnected', + status_detail VARCHAR(500) NULL, + last_connected_at DATETIME NULL, + updated_by INT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_bot_config_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT chk_bot_config_singleton CHECK (id = 1) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Discord bot moderation core (Phase 2). These tables are owned by the bot +-- process (its own DB pool, bot/src/db.js) — the main server never reads or +-- writes them. They live in the same physical database as everything else +-- (per the spec's "shared instance, clearly prefixed where needed" option) +-- purely because there's no separate migration tooling to stand up a second +-- database for a single-guild v1 bot. + +-- Per-guild key/value config the bot needs at runtime (currently just the +-- mod-log channel; filters/schedules/role-menu config lands here in later +-- phases). Set via the `/modlog set` slash command, not the admin panel — +-- unlike bot_config (identity/connection secrets), this is routine Discord +-- server administration staff already do inside Discord. +CREATE TABLE IF NOT EXISTS guild_config ( + guild_id VARCHAR(32) NOT NULL, + `key` VARCHAR(64) NOT NULL, + value VARCHAR(500) NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (guild_id, `key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Audit trail + mod-log source of truth for ban/kick/mute/warn actions. +-- duration_seconds is only set for timed mutes; NULL for permanent +-- ban/kick/warn actions. +CREATE TABLE IF NOT EXISTS mod_actions ( + id INT AUTO_INCREMENT PRIMARY KEY, + guild_id VARCHAR(32) NOT NULL, + action_type ENUM('ban','kick','mute','warn') NOT NULL, + target_user_id VARCHAR(32) NOT NULL, + target_tag VARCHAR(120) NULL, + staff_user_id VARCHAR(32) NOT NULL, + staff_tag VARCHAR(120) NULL, + reason VARCHAR(500) NULL, + duration_seconds INT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_mod_actions_target (guild_id, target_user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Standing warnings, separate from mod_actions so /warnings can list active +-- warnings per user. expires_at is unused in Phase 2 (no decay/escalation +-- yet — deferred, see mute/warn command comments) but the column is cheap to +-- add now rather than migrate in later. +CREATE TABLE IF NOT EXISTS warnings ( + id INT AUTO_INCREMENT PRIMARY KEY, + guild_id VARCHAR(32) NOT NULL, + target_user_id VARCHAR(32) NOT NULL, + target_tag VARCHAR(120) NULL, + staff_user_id VARCHAR(32) NOT NULL, + staff_tag VARCHAR(120) NULL, + reason VARCHAR(500) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NULL, + INDEX idx_warnings_target (guild_id, target_user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Banned-word list (Phase 3). `word` is stored as the admin typed it; matching +-- normalizes both sides at runtime (case, leetspeak, repeated chars — see +-- bot/src/filter/normalize.js), so the stored value doesn't need every +-- obfuscated variant. severity drives the auto-action: delete-only, delete + +-- warn, or delete + mute (see messageFilter.js). The role/channel allowlist +-- that bypasses filtering entirely lives in guild_config (keys +-- filter_allow_roles / filter_allow_channels, CSV of snowflake ids) rather +-- than a separate table — it's a short, rarely-changed list. +CREATE TABLE IF NOT EXISTS filter_words ( + id INT AUTO_INCREMENT PRIMARY KEY, + guild_id VARCHAR(32) NOT NULL, + word VARCHAR(200) NOT NULL, + severity ENUM('delete','warn','mute') NOT NULL DEFAULT 'delete', + added_by VARCHAR(32) NULL, + added_by_tag VARCHAR(120) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_filter_words_guild_word (guild_id, word) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Scheduled/recurring messages (Phase 4). A row is EITHER recurring +-- (cron_expression set, run_at NULL — reposts on the node-cron schedule +-- forever until disabled/removed) OR one-off (run_at set, cron_expression +-- NULL — posted once, then sent_at is stamped so the scheduler's due-message +-- sweep never reposts it). content is plain text for now — the original spec +-- allows richer embed JSON here, deferred since authoring embed JSON through a +-- single slash-command string option isn't practical without a modal/admin UI. +CREATE TABLE IF NOT EXISTS scheduled_messages ( + id INT AUTO_INCREMENT PRIMARY KEY, + guild_id VARCHAR(32) NOT NULL, + channel_id VARCHAR(32) NOT NULL, + content VARCHAR(2000) NOT NULL, + cron_expression VARCHAR(100) NULL, + run_at DATETIME NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + sent_at DATETIME NULL, + created_by VARCHAR(32) NULL, + created_by_tag VARCHAR(120) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_schedule_kind CHECK ( + (cron_expression IS NOT NULL AND run_at IS NULL) OR + (cron_expression IS NULL AND run_at IS NOT NULL) + ), + INDEX idx_scheduled_due (run_at, sent_at, enabled) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Self-assignable role menus (Phase 5). Button-based, not reaction-based — +-- avoids needing the messageReactionAdd/Remove events and their own intent. +-- `mapping` is a JSON array of {roleId, label}, validated against at click +-- time (see bot/src/discord/roleMenuHandler.js) so a stale/foreign button +-- customId can't toggle an untracked role. Auto-role-on-join is simpler and +-- reuses guild_config (key auto_role_id) rather than a table of its own. +CREATE TABLE IF NOT EXISTS role_menus ( + id INT AUTO_INCREMENT PRIMARY KEY, + guild_id VARCHAR(32) NOT NULL, + channel_id VARCHAR(32) NOT NULL, + message_id VARCHAR(32) NOT NULL, + mapping TEXT NOT NULL, + created_by VARCHAR(32) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_role_menus_message (message_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Timed role assignments (temp-mute-equivalent roles, timed event roles). +-- Swept once a minute (bot/src/roles/tempRoleSweeper.js) — expired rows have +-- their Discord role removed and the row deleted. UNIQUE(guild,user,role) so +-- re-granting the same temp role just refreshes its expiry via ON DUPLICATE +-- KEY UPDATE rather than stacking duplicate rows. +CREATE TABLE IF NOT EXISTS temp_roles ( + id INT AUTO_INCREMENT PRIMARY KEY, + guild_id VARCHAR(32) NOT NULL, + user_id VARCHAR(32) NOT NULL, + role_id VARCHAR(32) NOT NULL, + expires_at DATETIME NOT NULL, + created_by VARCHAR(32) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_temp_roles_user_role (guild_id, user_id, role_id), + INDEX idx_temp_roles_expires (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Audit trail for the auto-rotating primary invite (Phase 6). triggered_by +-- NULL means the weekly scheduled rotation did it, not a staff member — see +-- bot/src/invites/inviteRotator.js, shared by both /invite rotate and the +-- cron job so both paths log identically. The channel invites are created in +-- is configured separately in guild_config (key invite_channel_id). +CREATE TABLE IF NOT EXISTS invite_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + guild_id VARCHAR(32) NOT NULL, + channel_id VARCHAR(32) NOT NULL, + invite_code VARCHAR(20) NOT NULL, + triggered_by VARCHAR(32) NULL, + triggered_by_tag VARCHAR(120) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + revoked_at DATETIME NULL, + INDEX idx_invite_log_guild (guild_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- Migrations for databases created before the wiki upgrade. Each statement uses -- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get -- these columns from the CREATE TABLE above; existing installs get them here. diff --git a/server/src/middleware/requireInternalKey.js b/server/src/middleware/requireInternalKey.js new file mode 100644 index 0000000..ca66bbc --- /dev/null +++ b/server/src/middleware/requireInternalKey.js @@ -0,0 +1,20 @@ +// Gate for server-side /internal/* routes. The only caller is the bot process, +// on its own boot, over the private compose network — never expose this route +// through the public reverse proxy. Timing-safe compare so response time can't +// be used to brute-force the shared secret one byte at a time. Same pattern as +// bot/src/internal/requireInternalKey.js on the other side of this call. +const crypto = require('crypto') + +function requireInternalKey(req, res, next) { + const expected = process.env.BOT_INTERNAL_KEY || '' + const provided = req.get('X-Internal-Key') || '' + + const a = Buffer.from(expected) + const b = Buffer.from(provided) + const match = expected.length > 0 && a.length === b.length && crypto.timingSafeEqual(a, b) + + if (!match) return res.status(401).json({ message: 'Unauthorized' }) + return next() +} + +module.exports = requireInternalKey diff --git a/server/src/model/botConfig/botConfig.db.js b/server/src/model/botConfig/botConfig.db.js new file mode 100644 index 0000000..007e426 --- /dev/null +++ b/server/src/model/botConfig/botConfig.db.js @@ -0,0 +1,28 @@ +const { query } = require('../../utils/db') + +const COLS = + 'id, guild_id, bot_token_enc, application_id, enabled, status, status_detail, last_connected_at, updated_by, created_at, updated_at' + +// Singleton row (id = 1). Returns null until the admin saves it for the first time. +async function get() { + const rows = await query(`SELECT ${COLS} FROM bot_config WHERE id = 1 LIMIT 1`) + return rows[0] || null +} + +// Upsert the singleton row. `fields` are column values already prepared by the +// model (token pre-encrypted). Only the provided columns are written/updated. +async function upsert(fields) { + const cols = Object.keys(fields) + const vals = cols.map((c) => fields[c]) + const insertCols = ['id', ...cols].map((c) => `\`${c}\``).join(', ') + const placeholders = ['1', ...cols.map(() => '?')].join(', ') + const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ') + await query( + `INSERT INTO bot_config (${insertCols}) VALUES (${placeholders}) + ON DUPLICATE KEY UPDATE ${updates}`, + vals, + ) + return get() +} + +module.exports = { get, upsert } diff --git a/server/src/model/botConfig/botConfig.model.js b/server/src/model/botConfig/botConfig.model.js new file mode 100644 index 0000000..91f95e9 --- /dev/null +++ b/server/src/model/botConfig/botConfig.model.js @@ -0,0 +1,73 @@ +// Discord bot config store (Phase 1). Mirrors the authProviders model split: +// the DB layer only ever sees ciphertext, and only getWithToken() (used +// internally to push config to the bot process / to the bot-config internal +// endpoint) decrypts it. The admin-facing getSafe() never includes the token. + +const db = require('./botConfig.db') +const secretBox = require('../../utils/secretBox') + +function toSafe(row) { + if (!row) { + return { + guildId: null, + applicationId: null, + enabled: false, + hasToken: false, + status: 'disconnected', + statusDetail: null, + lastConnectedAt: null, + } + } + return { + guildId: row.guild_id || null, + applicationId: row.application_id || null, + enabled: Boolean(row.enabled), + hasToken: Boolean(row.bot_token_enc), + status: row.status || 'disconnected', + statusDetail: row.status_detail || null, + lastConnectedAt: row.last_connected_at || null, + } +} + +async function getSafe() { + return toSafe(await db.get()) +} + +// Decrypted token included — server-side only (pushing config to the bot, or +// serving the shared-secret-gated /internal/bot-config route). +async function getWithToken() { + const row = await db.get() + if (!row) return null + return { ...toSafe(row), token: row.bot_token_enc ? secretBox.decrypt(row.bot_token_enc) : null } +} + +// Save admin-supplied config. `token` undefined or '' means "leave the +// existing token unchanged" (same convention as authProviders.save). +async function save({ guildId, applicationId, token, enabled, updatedBy }) { + const fields = {} + if (guildId !== undefined) fields.guild_id = guildId + if (applicationId !== undefined) fields.application_id = applicationId + if (token) fields.bot_token_enc = secretBox.encrypt(token) + if (enabled !== undefined) fields.enabled = enabled ? 1 : 0 + if (updatedBy !== undefined) fields.updated_by = updatedBy + const row = await db.upsert(fields) + return toSafe(row) +} + +// Mirror the bot's last-reported status into the DB so the admin panel has +// something to show even if the bot is briefly unreachable. +async function recordStatus({ status, statusDetail, lastConnectedAt }) { + const fields = {} + if (status !== undefined) fields.status = status + if (statusDetail !== undefined) fields.status_detail = statusDetail + // lastConnectedAt arrives over HTTP as a JSON-serialized ISO string (e.g. + // "2026-07-04T18:49:51.429Z") — MariaDB's DATETIME parser rejects the "T"/ + // "Z"/milliseconds in that format. Convert to a real Date so the mariadb + // driver formats it correctly on the wire. + if (lastConnectedAt !== undefined) fields.last_connected_at = lastConnectedAt ? new Date(lastConnectedAt) : null + if (Object.keys(fields).length === 0) return getSafe() + const row = await db.upsert(fields) + return toSafe(row) +} + +module.exports = { getSafe, getWithToken, save, recordStatus } diff --git a/server/src/router/v1/admin/admin.controller.js b/server/src/router/v1/admin/admin.controller.js index 7d24996..9c4465b 100644 --- a/server/src/router/v1/admin/admin.controller.js +++ b/server/src/router/v1/admin/admin.controller.js @@ -3,9 +3,38 @@ const wiki = require('../../../model/wiki/wiki.model') const settings = require('../../../model/settings/settings.model') const users = require('../../../model/users/users.model') const activity = require('../../../model/activity/activity.model') +const botInternalClient = require('../../../utils/botInternalClient') const log = require('../../../utils/logger')('admin') +// Public base URL for links back to the site — same fallback pattern as +// sso.controller.js's redirect_uri builder. +function appBaseUrl(req) { + const configured = process.env.APP_BASE_URL + if (configured) return configured.replace(/\/+$/, '') + return `${req.protocol}://${req.get('host')}` +} + +// Fire-and-forget: announce a news post to Discord the moment it actually +// transitions from unpublished to published — not on every save or on a +// no-op re-publish of an already-live post. Never throws (botInternalClient +// itself never rejects); a bot outage must never break publishing a post. +function announceIfNewlyPublished(req, post, wasPublished) { + if (!post || post.category !== 'news' || !post.published || wasPublished) return + const base = appBaseUrl(req) + // image_url is stored relative (e.g. "/uploads/xyz.png") — Discord embeds + // require an absolute URL. + const imageUrl = post.image_url ? new URL(post.image_url, base).toString() : null + botInternalClient + .announce({ + title: post.title, + excerpt: post.excerpt, + url: `${base}/site/news`, + imageUrl, + }) + .catch(() => {}) +} + // ── Dashboard & site mode ───────────────────────────────────────────── async function dashboard(req, res) { try { @@ -90,6 +119,7 @@ async function createPost(req, res) { author_id: req.user.id, }) await activity.log({ req, action: 'post.create', detail: { id: created.id, category: dbCategory } }) + announceIfNewlyPublished(req, created, false) return res.status(201).json(created) } catch (err) { log.error('createPost', err) @@ -119,6 +149,7 @@ async function updatePost(req, res) { const updated = await posts.update(id, fields) await activity.log({ req, action: 'post.update', detail: { id } }) + announceIfNewlyPublished(req, updated, Boolean(current.published)) return res.json(updated) } catch (err) { log.error('updatePost', err) @@ -129,13 +160,15 @@ async function updatePost(req, res) { async function publishPost(req, res) { const id = Number(req.params.id) try { + const current = await posts.getById(id) + if (!current) return res.status(404).json({ message: 'Not found' }) const updated = await posts.setPublished(id, Boolean(req.body.published)) - if (!updated) return res.status(404).json({ message: 'Not found' }) await activity.log({ req, action: 'post.publish', detail: { id, published: Boolean(req.body.published) }, }) + announceIfNewlyPublished(req, updated, Boolean(current.published)) return res.json(updated) } catch (err) { return res.status(500).json({ message: 'Internal Server Error' }) diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js index 906a537..febd012 100644 --- a/server/src/router/v1/admin/admin.routes.js +++ b/server/src/router/v1/admin/admin.routes.js @@ -9,6 +9,7 @@ const ctrl = require('./admin.controller') const account = require('./account.controller') const botActivity = require('./botActivity.controller') const authProviders = require('./authProviders.controller') +const discordBot = require('./discordBot.controller') const { isLoggedIn, requireRole } = require('../../../utils/auth') const noindex = require('../../../middleware/noindex') const validate = require('../../../middleware/validate') @@ -530,6 +531,39 @@ adminRouter.post( botActivity.unbanIp, ) +// ── Discord bot control (admin only) ────────────────────────────────── +// Phase 1: entering/enabling the bot token here — never an env var. The token +// is write-only over this API (SECURITY note in discordBot.controller.js). +adminRouter.get( + '/discord-bot/config', + // #swagger.tags = ['Admin · Discord Bot'] + // #swagger.summary = 'Get Discord bot config + live status (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Masked config + live status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + discordBot.getConfig, +) +adminRouter.put( + '/discord-bot/config', + // #swagger.tags = ['Admin · Discord Bot'] + // #swagger.summary = 'Save Discord bot config (admin only)' + // #swagger.description = 'token is write-only — omit/blank it to keep the existing one unchanged.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { guildId: { type: "string" }, token: { type: "string" }, enabled: { type: "boolean" } } } } } } */ + /* #swagger.responses[200] = { description: 'Updated config + live status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[400] = { description: 'Validation error, invalid token, or missing token while enabling', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + body('guildId').optional({ values: 'falsy' }).isString().trim(), + body('token').optional({ values: 'falsy' }).isString().trim(), + body('enabled').optional().isBoolean(), + validate, + discordBot.saveConfig, +) + // ── Authentication providers / SSO (admin only) ─────────────────────── adminRouter.get( '/auth/providers', diff --git a/server/src/router/v1/admin/discordBot.controller.js b/server/src/router/v1/admin/discordBot.controller.js new file mode 100644 index 0000000..41d14ae --- /dev/null +++ b/server/src/router/v1/admin/discordBot.controller.js @@ -0,0 +1,98 @@ +// ── Admin: Discord bot control ───────────────────────────────────────────── +// +// Phase 1: entering/enabling the bot token here (not an env var) and pushing +// it to the bot process over the internal API. SECURITY: the token is +// write-only over this API, same convention as auth provider secrets — it is +// stored encrypted and NEVER returned; responses expose only `hasToken`. A +// blank `token` on save means "leave the existing token unchanged". + +const botConfig = require('../../../model/botConfig/botConfig.model') +const botClient = require('../../../utils/botInternalClient') +const activity = require('../../../model/activity/activity.model') + +const log = require('../../../utils/logger')('admin') + +const SNOWFLAKE = /^\d{17,20}$/ + +// Confirm a bot token is real by asking Discord who it belongs to. Returns +// true/false only on a definitive answer; returns true (don't block the save) +// if Discord couldn't be reached at all, since a network hiccup shouldn't +// stop an admin from saving a token that may well be valid. Guild-membership +// validation is deliberately NOT done here — a bot token is valid before the +// bot has ever been invited to the guild, so checking guild access here would +// reject perfectly good first-time setups with a false negative. +async function isValidBotToken(token) { + try { + const res = await fetch('https://discord.com/api/users/@me', { + headers: { Authorization: `Bot ${token}` }, + }) + if (res.status === 401) return false + return true + } catch (err) { + log.warn('discord token validation unreachable — not blocking save', { message: err.message }) + return true + } +} + +// GET /admin/discord-bot/config — masked config + live status (falls back to +// the last-known DB-mirrored status if the bot process is unreachable). +async function getConfig(req, res) { + try { + const config = await botConfig.getSafe() + const live = await botClient.getStatus() + if (live.ok) { + config.status = live.data.status + config.statusDetail = live.data.statusDetail + config.lastConnectedAt = live.data.lastConnectedAt + await botConfig.recordStatus(live.data) + } else { + config.statusDetail = config.statusDetail || `bot unreachable: ${live.error}` + } + return res.json(config) + } catch (err) { + log.error('discordBot.getConfig', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// PUT /admin/discord-bot/config — save + push to the bot process. +async function saveConfig(req, res) { + const { guildId, token, enabled } = req.body + try { + if (guildId !== undefined && guildId !== '' && !SNOWFLAKE.test(guildId)) { + return res.status(400).json({ message: 'guildId does not look like a valid Discord server ID.' }) + } + + if (token) { + const ok = await isValidBotToken(token) + if (!ok) return res.status(400).json({ message: 'That bot token was rejected by Discord — check it and try again.' }) + } + + const current = await botConfig.getSafe() + const willHaveToken = Boolean(token) || current.hasToken + if (enabled && !willHaveToken) { + return res.status(400).json({ message: 'A bot token is required before enabling.' }) + } + + const saved = await botConfig.save({ guildId, token, enabled, updatedBy: req.user.id }) + const withToken = await botConfig.getWithToken() + const push = await botClient.pushConfig({ token: withToken.token, guildId: saved.guildId, enabled: saved.enabled }) + if (push.ok) { + await botConfig.recordStatus(push.data) + saved.status = push.data.status + saved.statusDetail = push.data.statusDetail + saved.lastConnectedAt = push.data.lastConnectedAt + } else { + saved.statusDetail = `bot unreachable: ${push.error}` + } + + await activity.log({ req, action: 'discordBot.config.update', detail: { guildId: saved.guildId, enabled: saved.enabled } }) + log.info('discord bot config updated', { by: req.user.username, enabled: saved.enabled }) + return res.json(saved) + } catch (err) { + log.error('discordBot.saveConfig', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +module.exports = { getConfig, saveConfig } diff --git a/server/src/router/v1/internal/internal.controller.js b/server/src/router/v1/internal/internal.controller.js new file mode 100644 index 0000000..4ae78ab --- /dev/null +++ b/server/src/router/v1/internal/internal.controller.js @@ -0,0 +1,19 @@ +const botConfig = require('../../../model/botConfig/botConfig.model') +const log = require('../../../utils/logger')('internal') + +// GET /internal/bot-config — called by the bot process on its own boot so a +// restart self-reconnects without any admin-panel interaction. Returns the +// DECRYPTED token — this route must never be reachable outside the private +// compose network (see requireInternalKey + deployment notes). +async function getBotConfig(req, res) { + try { + const config = await botConfig.getWithToken() + if (!config) return res.json({ enabled: false, token: null, guildId: null }) + return res.json({ enabled: config.enabled, token: config.token, guildId: config.guildId }) + } catch (err) { + log.error('internal.getBotConfig', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +module.exports = { getBotConfig } diff --git a/server/src/router/v1/internal/internal.routes.js b/server/src/router/v1/internal/internal.routes.js new file mode 100644 index 0000000..6a0d1cb --- /dev/null +++ b/server/src/router/v1/internal/internal.routes.js @@ -0,0 +1,18 @@ +const express = require('express') + +const requireInternalKey = require('../../../middleware/requireInternalKey') +const ctrl = require('./internal.controller') + +const router = express.Router() + +// Shared-secret gated, not session-gated — the caller is the bot process, not +// a logged-in browser. Mounted before any auth/session middleware in v1.router. +router.use(requireInternalKey) + +router.get( + '/bot-config', + // #swagger.ignore = true + ctrl.getBotConfig, +) + +module.exports = router diff --git a/server/src/router/v1/v1.router.js b/server/src/router/v1/v1.router.js index 5bfa37f..ac6dac6 100644 --- a/server/src/router/v1/v1.router.js +++ b/server/src/router/v1/v1.router.js @@ -5,9 +5,13 @@ const v1Router = express.Router() const authRouter = require('./auth/auth.routes') const publicRouter = require('./public/public.routes') const adminRouter = require('./admin/admin.routes') +const internalRouter = require('./internal/internal.routes') v1Router.use('/auth', authRouter) v1Router.use('/public', publicRouter) v1Router.use('/admin', adminRouter) +// Shared-secret gated (not session-gated) — server<->bot only, never exposed +// through the public reverse proxy. See server/src/middleware/requireInternalKey.js. +v1Router.use('/internal', internalRouter) module.exports = v1Router diff --git a/server/src/utils/botInternalClient.js b/server/src/utils/botInternalClient.js new file mode 100644 index 0000000..5cf818a --- /dev/null +++ b/server/src/utils/botInternalClient.js @@ -0,0 +1,55 @@ +// Tiny fetch wrapper for calling the bot process's /internal/* API (shared +// secret, same pattern as requireInternalKey on both sides). Used by the +// Discord Bot admin controller to push config after a save and to poll live +// status for the admin panel. Never throws — callers get { ok: false, error } +// on any failure (bot unreachable, timeout, non-2xx) so an admin save/poll +// never 500s just because the bot container is down or restarting. +const log = require('./logger')('bot-internal-client') + +const BASE_URL = process.env.BOT_INTERNAL_URL || 'http://localhost:4100' +const KEY = process.env.BOT_INTERNAL_KEY || '' +const TIMEOUT_MS = 4000 + +async function call(path, { method = 'GET', body } = {}) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS) + try { + const res = await fetch(`${BASE_URL}${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + 'X-Internal-Key': KEY, + }, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }) + if (!res.ok) { + return { ok: false, error: `bot responded ${res.status}` } + } + return { ok: true, data: await res.json() } + } catch (err) { + log.warn('bot internal call failed', { path, message: err.message }) + return { ok: false, error: err.message } + } finally { + clearTimeout(timeout) + } +} + +// Push a config change (start/stop the bot's Discord client). +function pushConfig({ token, guildId, enabled }) { + return call('/internal/config', { method: 'POST', body: { token, guildId, enabled } }) +} + +// Live connection status, for the admin panel. +function getStatus() { + return call('/internal/status') +} + +// Site -> bot: a news post was published, post it to the configured #news +// channel. Fire-and-forget from the caller's perspective — never throws, so +// a bot outage never breaks publishing a post. +function announce({ title, excerpt, url, imageUrl }) { + return call('/internal/announce', { method: 'POST', body: { title, excerpt, url, imageUrl } }) +} + +module.exports = { pushConfig, getStatus, announce } diff --git a/server/src/utils/db.js b/server/src/utils/db.js index 3ccba1f..168a9ad 100644 --- a/server/src/utils/db.js +++ b/server/src/utils/db.js @@ -16,6 +16,13 @@ const pool = mariadb.createPool({ insertIdAsNumber: true, bigIntAsNumber: true, decimalAsNumber: true, + // The driver defaults to 'local' — silently serializing bound JS Date + // params using the HOST MACHINE's local offset instead of the DB session's + // timezone (discovered via the Discord bot's temp_roles.expires_at coming + // back hours off in dev). 'auto' negotiates the actual session timezone so + // Date round-trips correctly regardless of host TZ — affects any write of + // a JS Date param, e.g. botConfig.model.js's last_connected_at. + timezone: 'auto', }) /**