commit 11e5a4e6f8cb3fe1edf215d5eb3f53926a963654 Author: Franz Rolfsvaag Date: Sat May 30 20:37:42 2026 +0200 Initial sanitized import diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ece332b --- /dev/null +++ b/.env.example @@ -0,0 +1,39 @@ +# Server +PORT=3000 +COMMAND_PREFIX=! + +# Feature toggles +PLATFORM_DISCORD_ENABLED=true +PLATFORM_TWITCH_ENABLED=true +PLATFORM_YOUTUBE_ENABLED=false +PLATFORM_KICK_ENABLED=false + +# Discord +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +DISCORD_BOT_TOKEN= +DISCORD_GUILD_ID= +DISCORD_ADMIN_ROLE_ID= +DISCORD_MOD_ROLE_ID= +DISCORD_REDIRECT_URI=http://localhost:3000/auth/discord/callback + +# Twitch +TWITCH_CLIENT_ID= +TWITCH_CLIENT_SECRET= +TWITCH_REDIRECT_URI=http://localhost:3000/auth/twitch/callback +TWITCH_BOT_USERNAME= +TWITCH_BOT_OAUTH= +TWITCH_CHANNELS= + +# YouTube +YOUTUBE_CLIENT_ID= +YOUTUBE_CLIENT_SECRET= +YOUTUBE_REDIRECT_URI=http://localhost:3000/auth/youtube/callback +YOUTUBE_BOT_REFRESH_TOKEN= +YOUTUBE_BOT_CHANNEL_ID= + +# Updates +AUTO_UPDATE_ENABLED=false +AUTO_UPDATE_INTERVAL_MINUTES=60 +GIT_REMOTE=origin +GIT_BRANCH=main diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b6c109 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +data/ +updates/ +.env +.env.* +!.env.example +.bot details.md +*.db +*.db-* +*.sqlite +*.sqlite-* +npm-debug.log diff --git a/Discord profile banner.png b/Discord profile banner.png new file mode 100644 index 0000000..77c1e66 Binary files /dev/null and b/Discord profile banner.png differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6d7067 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# Lumi Bot + +Discord bot + WebUI with role-based access, plugin management, and self-update support. + +## Quick start + +Requires Node.js 18+. + +1. Install dependencies: + ``` + npm install + ``` +2. Run with auto-restart: + ``` + npm run run + ``` +3. Open `http://localhost:3000/setup` and enter your Discord app + bot settings. + +You can also seed local configuration with a `.env` file. Use `.env.example` +as the template; `.env` is ignored by git. + +## Discord app setup + +- OAuth2 redirect URI: `http://localhost:3000/auth/discord/callback` +- OAuth2 scopes: `identify`, `guilds`, `guilds.members.read` +- Add the bot to your server and copy the Guild ID. + +## WebUI roles + +The WebUI maps Discord roles to access levels: +- `DISCORD_ADMIN_ROLE_ID` +- `DISCORD_MOD_ROLE_ID` + +You can set these in `.env` or change role IDs in **Admin → Settings**. + +## Plugins + +Use **Admin → Plugins** to install, enable, update, or uninstall plugins. +You can also create a local plugin from the WebUI. + +## Twitch bot + +Configure Twitch chat settings in **Admin → Settings**: +- `twitch_bot_username` +- `twitch_bot_oauth` (OAuth token) +- `twitch_channels` (comma-separated) + +Custom commands can target Discord, Twitch, or both from **Admin → Commands**. + +## Users and linking + +Users have an internal UUID and username. Link Twitch accounts in **Profile** and manage usernames in **Profile** or **Admin → Users**. + +## Theming + +Use **Admin → Theming** to adjust light and dark mode colors. The UI uses your OS theme preference. + +## Notes + +- Auto-update uses `git pull` from the configured remote + branch. +- Auto-restart uses `run.js` to respawn the process after updates or crashes. diff --git a/Twitch.png b/Twitch.png new file mode 100644 index 0000000..4a5f58f Binary files /dev/null and b/Twitch.png differ diff --git a/codex-guidelines b/codex-guidelines new file mode 100644 index 0000000..8a2b278 --- /dev/null +++ b/codex-guidelines @@ -0,0 +1,171 @@ +Project: Lumi Bot (Discord + Twitch + YouTube) — WebUI-first management + +Purpose of this file +- Single source of truth for cross‑conversation context, conventions, and packaging. +- Update when project behavior changes (routes, APIs, packaging, DB schema). +- Refer back here before making changes. + +Repository layout +- Core: + - src/main.js (entry) + - src/web/server.js (WebUI + routes + wizards) + - src/services (auth, platforms, users, plugins, update-manager, etc.) + - src/web/views (EJS pages, partials/layout) + - src/web/public (styles.css, app.js) +- Plugins: plugins// (plugin.json + index.js + optional views/) +- Data: data/app.db (SQLite), snapshots, uploads (should be excluded from updates) +- Updates output: updates/ (all update zips go here) + +Platform integration (current) +- Discord, Twitch, YouTube supported; modular via src/services/platforms.js +- Wizards in /setup/*: + - /setup/discord, /setup/twitch, /setup/youtube (cancelable) +- OAuth routes in /auth/*: + - /auth/discord, /auth/twitch, /auth/youtube +- Role mapping: + - Discord roles from settings discord_admin_role_id / discord_mod_role_id (supports comma-separated) + - Twitch: broadcaster/admin/mod via tags/badges + - YouTube: chat owner/moderator flags + +WebUI key routes (core) +- / (home) +- /commands, /leaderboards, /stats, /profile +- /moderator (Mods List) +- /admin + - /admin/settings + - /admin/navigation + - /admin/theming + - /admin/privileges + - /admin/logs + - /admin/updates + - /admin/commands + - /admin/pages + - /admin/users + - /admin/plugins + +WebUI profile hook (core) +- web.addProfileSection({ id, label, view?, content?, role?, order?, locals? }) + - view: EJS include path; content: raw HTML string + - role defaults to "public" if omitted + - In profile template, sections render under "Personalized" + - Profile view receives: user, profile, accounts + section.locals + +Update system (core) +- applyBotUpdate(zipPath, { mode: "full"|"patch" }) + - Full update requires: package.json, safe-mode.js, src/main.js, src/web/server.js + - Patch mode accepts any files and overlays them (no deletes) +- applyPluginUpdate(zipPath) expects plugin.json in root + entry file +- Snapshots: data/snapshots (keeps last 20 successful) +- Safe Mode: safe-mode.js supports rollback + +Update packaging standards +- Always place update zips in updates/ +- Core full update (default): + - Zip from repo root EXCLUDING: .git, node_modules, data, plugins, updates + - Filename: updates/lumi-update-.zip +- Core patch update: + - Zip only changed files/folders + - Use Patch Mode in UI + - Filename: updates/lumi-update--patch.zip (or similar) +- Plugin update: + - Zip contents of plugins// (root = plugin folder) + - Filename: updates/lumi-plugin--vX.Y.Z.zip +- Preferred zip tool on Windows: + - tar -a -c -f -C . + +Command framework (core) +- commandRouter.registerCommands(pluginId, [{ id, triggers, platforms, handler }]) +- Platforms enumerated by services/platforms.js +- Plugins can expose cmds.json for admin command list ingestion +- Core dynamic command: !top (categories pulled from leaderboards/providers via src/services/top.js) + +Database schema (core) +- data/app.db (SQLite) +- user_profiles table includes: + - internal_username (unique, NOCASE) + - username_updated_at (added for 90‑day cooldown) +- user_identities: provider/user mapping +- plugin_settings: plugin key/value store +- plugins: plugin registry + +Profile username cooldown (core) +- User can update once every 90 days +- Stored in user_profiles.username_updated_at +- /profile/username checks cooldown server‑side +- UI: modal with disabled button + hint when on cooldown + +Sidebar UX standards (core) +- User chip is clickable to /profile +- Collapsed sidebar: + - Icons centered, tooltips on items/sections + - Subitem icons remain visible (default or admin-uploaded) + - Consistent padding/width +- Enable/disable inputs should use green/red switch toggles (not plain checkboxes) +- /admin/navigation uses drag-and-drop layout with an Advanced JSON editor + +Plugins (important) +- Plugin system loads from plugins/ directory +- Each plugin: + - plugin.json with id, name, version, main (index.js) + - index.js exports { id, init(...) } +- web.mount("/plugins/", router, navItem) to add nav entry +- Plugins should avoid core edits unless explicitly requested + +Current notable plugins +- echonomy-framework: + - Currency framework only (no items/betting) + - Banking UI: /profile/banking (plugin) + - Response templates with random/weighted replies + - Community funds (renamable) + - Commands under root (default "coins") + - Activity rewards (discord/twitch messages, discord voice) are queued per user per hour and flushed as one hourly transaction note "Activity Reward" with metadata breakdown +- echonomy-games: + - Uses echonomy-framework currency + - Hot Potato / Coinflip / Mystery Box + - Per-platform enable + configurable triggers/aliases + - Admin UI at /plugins/echonomy-games + - Stats stored in echonomy_game_stats (plays, coins won/lost, last played) +- moderation: + - Global moderation actions, notes, sanctions + - Ban/timeout UI at /plugins/moderation + - TOs & Bans view at /plugins/moderation/tos-bans + - Login gating shows moderation status screen + - Evidence uploads stored in data/moderation/evidence (download via /plugins/moderation/evidence/:id) +- quotes: + - Quote storage/search with WebUI at /plugins/quotes + - quotes table fields include quoter_user_id (internal user id) + game_name + - Stats provider adds total quotes, top quoters, and top quoted games + +Database schema (core) +- mod_role_periods table tracks mod/admin time for Mods List +- auto-vc: + - Auto VC creation based on lobby channels + - Game name detection uses Discord presence PLAYING/STREAMING/COMPETING only +- expression-interaction: + - Action commands with stats tracking + +Important settings keys (core) +- discord: discord_client_id, discord_client_secret, discord_bot_token, discord_guild_id, discord_admin_role_id, discord_mod_role_id, discord_redirect_uri +- twitch: twitch_client_id, twitch_client_secret, twitch_bot_username, twitch_bot_oauth, twitch_channels, twitch_redirect_uri +- youtube: youtube_client_id, youtube_client_secret, youtube_bot_refresh_token, youtube_bot_channel_id, youtube_redirect_uri +- site: site_title, bot_avatar_url, command_prefix +- nav: nav_item_icons (map of nav item id -> filename in data/nav-icons), nav_structure (custom sidebar layout) + +Known file locations +- Layout partials: src/web/views/partials/layout-top.ejs, layout-bottom.ejs +- Global CSS: src/web/public/styles.css +- Global JS: src/web/public/app.js + - Asset versioning: res.locals.assetVersion (cache-bust for styles/app) +- Nav icons: src/web/public/icons/nav (defaults), data/nav-icons (admin uploads) + +Packaging sanity checks (before shipping) +- Plugin zips contain plugin.json at root +- Core zip contains package.json and src/main.js +- No data/ or node_modules included +- Update zip placed in updates/ + +TODOs / Open questions +- Align plugin profile sections with new core hook (replace direct injection if used) +- Standardize plugin response templates UI to avoid duplication +- Consider centralized search API for profile user lookup +- Add docs for web.addProfileSection usage (when stable) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8a368ed --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1860 @@ +{ + "name": "lumi-bot", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lumi-bot", + "version": "0.1.0", + "dependencies": { + "adm-zip": "^0.5.12", + "better-sqlite3": "^11.5.0", + "better-sqlite3-session-store": "^0.1.0", + "discord.js": "^13.17.1", + "ejs": "^3.1.10", + "express": "^4.19.2", + "express-session": "^1.18.1", + "multer": "^1.4.5-lts.1", + "tmi.js": "^1.8.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@discordjs/builders": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-0.16.0.tgz", + "integrity": "sha512-9/NCiZrLivgRub2/kBc0Vm5pMBE5AUdYbdXsLu/yg9ANgvnaJ0bZKTY8yYnLbsEc/LYUP79lEIdC73qEYhWq7A==", + "deprecated": "no longer supported", + "license": "Apache-2.0", + "dependencies": { + "@sapphire/shapeshift": "^3.5.1", + "discord-api-types": "^0.36.2", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/@discordjs/builders/node_modules/discord-api-types": { + "version": "0.36.3", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.36.3.tgz", + "integrity": "sha512-bz/NDyG0KBo/tY14vSkrwQ/n3HKPf87a0WFW/1M9+tXYK+vp5Z5EksawfCWo2zkAc6o7CClc0eff1Pjrqznlwg==", + "license": "MIT" + }, + "node_modules/@discordjs/collection": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-0.7.0.tgz", + "integrity": "sha512-R5i8Wb8kIcBAFEPLLf7LVBQKBDYUL+ekb23sOgpkpyGT+V4P7V83wTxcsqmX+PbqHt4cEHn053uMWfRqh/Z/nA==", + "deprecated": "no longer supported", + "license": "Apache-2.0", + "engines": { + "node": ">=16.9.0" + } + }, + "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": "3.9.7", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-3.9.7.tgz", + "integrity": "sha512-4It2mxPSr4OGn4HSQWGmhFMsNFGfFVhWeRPCRwbH972Ek2pzfGRZtb0pJ4Ze6oIzcyh2jw7nUDa6qGlWofgd9g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@types/node": { + "version": "25.0.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz", + "integrity": "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "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/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/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "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/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/better-sqlite3-session-store": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/better-sqlite3-session-store/-/better-sqlite3-session-store-0.1.0.tgz", + "integrity": "sha512-O4EO5jOGTEa/c1DbZpP3C7VTDLSWe5lrOu1S/j86ipdGZxrSb8bSUVuRgWCgl/SCgEGmyeEqvlMY9HtyOSMOWA==", + "license": "GPL-3.0-only", + "dependencies": { + "date-fns": "2.16.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "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.14.0", + "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": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "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", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "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/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/concat-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/concat-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "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/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.16.1.tgz", + "integrity": "sha512-sAJVKx/FqrLYHAQeN7VpJrPhagZc9R4ImZIWYRFZaaohR3KzmuK88touwsSwSVT8Qcbd4zoDsnGfX4GFB4imyQ==", + "license": "MIT", + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "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/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/discord-api-types": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.33.5.tgz", + "integrity": "sha512-dvO5M52v7m7Dy96+XUnzXNsQ/0npsYpU6dL205kAtEDueswoz3aU3bh1UMoK4cQmcGtB1YRyLKqp+DXi05lzFg==", + "license": "MIT" + }, + "node_modules/discord.js": { + "version": "13.17.1", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-13.17.1.tgz", + "integrity": "sha512-h13kUf+7ZaP5ZWggzooCxFutvJJvugcAO54oTEIdVr3zQWi0Sf/61S1kETtuY9nVAyYebXR/Ey4C+oWbsgEkew==", + "deprecated": "Version 13 is no longer supported.", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^0.16.0", + "@discordjs/collection": "^0.7.0", + "@sapphire/async-queue": "^1.5.0", + "@types/node-fetch": "^2.6.3", + "@types/ws": "^8.5.4", + "discord-api-types": "^0.33.5", + "form-data": "^4.0.0", + "node-fetch": "^2.6.7", + "ws": "^8.13.0" + }, + "engines": { + "node": ">=16.6.0", + "npm": ">=7.0.0" + } + }, + "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/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "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/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "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.14.0", + "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/express-session": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz", + "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "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/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "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/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "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/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "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-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/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "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/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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": "BSD-3-Clause" + }, + "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/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "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/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "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/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "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/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "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-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "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/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "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.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "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/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "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.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "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.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "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.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "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-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "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/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "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", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "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/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tmi.js": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/tmi.js/-/tmi.js-1.8.5.tgz", + "integrity": "sha512-A9qrydfe1e0VWM9MViVhhxVgvLpnk7pFShVUWePsSTtoi+A1X+Zjdoa7OJd7/YsgHXGj3GkNEvnWop/1WwZuew==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "ws": "^8.2.0" + }, + "engines": { + "node": ">=10.0.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/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "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/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "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/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "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/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/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "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 + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7b93c08 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "lumi-bot", + "version": "0.1.0", + "private": true, + "type": "commonjs", + "scripts": { + "start": "node src/main.js", + "run": "node run.js" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "adm-zip": "^0.5.12", + "better-sqlite3": "^11.5.0", + "better-sqlite3-session-store": "^0.1.0", + "discord.js": "^13.17.1", + "ejs": "^3.1.10", + "express": "^4.19.2", + "express-session": "^1.18.1", + "multer": "^1.4.5-lts.1", + "tmi.js": "^1.8.5" + } +} diff --git a/plugins/auto-vc/cmds.json b/plugins/auto-vc/cmds.json new file mode 100644 index 0000000..d787eb4 --- /dev/null +++ b/plugins/auto-vc/cmds.json @@ -0,0 +1,113 @@ +{ + "pluginId": "auto-vc", + "pluginName": "Auto VC", + "commands": [ + { + "id": "vc", + "trigger": "vc", + "usage": "vc", + "name": "Auto VC", + "description": "Manage Auto VC rooms. Expand to see available subcommands.", + "level": "conditional", + "levelHelp": "You must be in the Auto VC; some actions require the owner to be absent.", + "platforms": ["discord"] + }, + { + "id": "vc-claim", + "trigger": "vc", + "subcommand": "claim", + "usage": "vc claim", + "name": "Claim room", + "description": "Claim ownership if the current owner is gone.", + "level": "conditional", + "levelHelp": "You must be in the Auto VC; some actions require the owner to be absent.", + "platforms": ["discord"] + }, + { + "id": "vc-rename", + "trigger": "vc", + "subcommand": "rename", + "usage": "vc rename ", + "name": "Rename room", + "description": "Rename your Auto VC room.", + "level": "conditional", + "levelHelp": "You must be in the Auto VC; some actions require the owner to be absent.", + "platforms": ["discord"] + }, + { + "id": "vc-lock", + "trigger": "vc", + "subcommand": "lock", + "usage": "vc lock", + "name": "Lock room", + "description": "Toggle room lock to restrict entry.", + "level": "conditional", + "levelHelp": "You must be in the Auto VC; some actions require the owner to be absent.", + "platforms": ["discord"] + }, + { + "id": "vc-unlock", + "trigger": "vc", + "subcommand": "unlock", + "usage": "vc unlock", + "name": "Unlock room", + "description": "Unlock the room so anyone can join again.", + "level": "conditional", + "levelHelp": "You must be in the Auto VC; some actions require the owner to be absent.", + "platforms": ["discord"] + }, + { + "id": "vc-allow", + "trigger": "vc", + "subcommand": "allow", + "usage": "vc allow ", + "name": "Allow user", + "description": "Allow a user to join your locked room.", + "level": "conditional", + "levelHelp": "You must be in the Auto VC; some actions require the owner to be absent.", + "platforms": ["discord"] + }, + { + "id": "vc-disallow", + "trigger": "vc", + "subcommand": "disallow", + "usage": "vc disallow ", + "name": "Disallow user", + "description": "Remove a user from the allowed list or clear all.", + "level": "conditional", + "levelHelp": "You must be in the Auto VC; some actions require the owner to be absent.", + "platforms": ["discord"] + }, + { + "id": "vc-transfer", + "trigger": "vc", + "subcommand": "transfer", + "usage": "vc transfer ", + "name": "Transfer room", + "description": "Transfer room ownership to another user in the room.", + "level": "conditional", + "levelHelp": "You must be in the Auto VC; some actions require the owner to be absent.", + "platforms": ["discord"] + }, + { + "id": "vc-ban", + "trigger": "vc", + "subcommand": "ban", + "usage": "vc ban ", + "name": "Ban from Auto VC", + "description": "Prevent a user from creating Auto VC rooms.", + "level": "mod", + "platforms": ["discord"] + }, + { + "id": "vc-unban", + "trigger": "vc", + "subcommand": "unban", + "usage": "vc unban ", + "name": "Unban from Auto VC", + "description": "Allow a user to create Auto VC rooms again.", + "level": "mod", + "platforms": ["discord"] + } + ] +} diff --git a/plugins/auto-vc/index.js b/plugins/auto-vc/index.js new file mode 100644 index 0000000..4c67e29 --- /dev/null +++ b/plugins/auto-vc/index.js @@ -0,0 +1,1456 @@ +const path = require("path"); +const fs = require("fs"); +const ejs = require("ejs"); +const { Permissions } = require("discord.js"); +const { ensureUserForIdentity } = require("../../src/services/users"); + +const PLUGIN_ID = "auto-vc"; +const DEFAULT_TEMPLATE = "[username]'s room"; +const DEFAULT_TIMEOUT = 30; +const GAME_NAME_TOKEN = "[game_name]"; +const NAME_REFRESH_INTERVAL_MS = 5 * 60 * 1000; +const ALLOW_CONNECT_VIEW = + Permissions.FLAGS.CONNECT | Permissions.FLAGS.VIEW_CHANNEL; +const DEFAULT_CREATE_LIMIT = { max: 3, windowSeconds: 600 }; +const DEFAULT_ACTION_LIMIT = { max: 8, windowSeconds: 60 }; + +module.exports = { + id: PLUGIN_ID, + init({ web, discordClient, db, settings }) { + ensureTables(db); + const state = { + rooms: new Map(), + cleanupTimers: new Map(), + emptySince: new Map(), + sweepTimer: null, + nameSweepTimer: null, + rateLimits: { + create: new Map(), + action: new Map() + } + }; + + const router = web.createRouter(); + router.get("/", async (req, res) => { + const config = getConfig(db); + const user = req.session.user || null; + const stats = getStats(db); + const bans = getBans(db); + const channelOptions = getChannelOptions(discordClient); + const lobbies = await enrichLobbiesWithPermissions(discordClient, config.lobbies); + const locals = { + ...res.locals, + title: "Auto VC", + lobbies, + stats, + bans, + limits: config.limits, + voiceChannels: channelOptions.voiceChannels, + categoryChannels: channelOptions.categoryChannels, + isAdmin: Boolean(user?.isAdmin), + canModerate: Boolean(user?.isAdmin || user?.isMod) + }; + const html = await renderPage(locals); + res.send(html); + }); + + router.post("/settings", (req, res) => { + if (!req.session.user || !req.session.user.isAdmin) { + return res.status(403).render("error", { + title: "Access denied", + message: "You do not have access to that page." + }); + } + const config = parseConfigFromForm(req.body); + saveConfig(db, config); + state.rooms.clear(); + req.session.flash = { + type: "success", + message: "Auto VC settings saved." + }; + res.redirect("/plugins/auto-vc"); + }); + + router.post("/bans", (req, res) => { + if (!req.session.user || !(req.session.user.isAdmin || req.session.user.isMod)) { + return res.status(403).render("error", { + title: "Access denied", + message: "You do not have access to that page." + }); + } + const input = (req.body.ban_input || "").trim(); + const reason = (req.body.ban_reason || "").trim(); + const id = parseDiscordId(input); + if (!id) { + req.session.flash = { + type: "error", + message: "Enter a valid user ID or mention." + }; + return res.redirect("/plugins/auto-vc"); + } + banUser(db, id, reason); + req.session.flash = { + type: "success", + message: "User banned from Auto VC creation." + }; + res.redirect("/plugins/auto-vc"); + }); + + router.post("/unban", (req, res) => { + if (!req.session.user || !(req.session.user.isAdmin || req.session.user.isMod)) { + return res.status(403).render("error", { + title: "Access denied", + message: "You do not have access to that page." + }); + } + const ids = toArray(req.body.unban_ids).filter(Boolean); + ids.forEach((id) => unbanUser(db, id)); + req.session.flash = { + type: "success", + message: ids.length ? "User(s) unbanned." : "No users selected." + }; + res.redirect("/plugins/auto-vc"); + }); + + web.mount("/plugins/auto-vc", router, { + label: "Auto VC", + role: "public", + section: "plugins" + }); + + if (!discordClient) { + return; + } + + const attach = () => { + bootstrapRooms(discordClient, db, state, settings).catch((error) => { + console.error("Auto VC bootstrap failed", error); + }); + discordClient.on("voiceStateUpdate", (oldState, newState) => { + handleVoiceStateUpdate(oldState, newState, db, settings, state); + }); + discordClient.on("messageCreate", (message) => { + handleMessage(message, db, settings, state); + }); + discordClient.on("channelDelete", (channel) => { + if (channel && channel.id) { + removeRoom(db, state, channel.id); + } + }); + startSweepTimer(discordClient, db, settings, state); + startNameRefreshTimer(discordClient, db, settings, state); + }; + + if (discordClient.readyAt) { + attach(); + } else { + discordClient.once("ready", attach); + } + } +}; + +async function renderPage(locals) { + const viewsRoot = path.join(__dirname, "..", "..", "src", "web", "views"); + const layoutTop = path.join(viewsRoot, "partials", "layout-top.ejs"); + const layoutBottom = path.join(viewsRoot, "partials", "layout-bottom.ejs"); + const pagePath = path.join(__dirname, "views", "auto-vc.ejs"); + const bodyTemplate = fs.readFileSync(pagePath, "utf8"); + const body = ejs.render(bodyTemplate, locals, { filename: pagePath }); + const top = await ejs.renderFile(layoutTop, locals); + const bottom = await ejs.renderFile(layoutBottom, locals); + return `${top}${body}${bottom}`; +} + +function getChannelOptions(discordClient) { + const voiceChannels = []; + const categoryChannels = []; + if (!discordClient || !discordClient.guilds?.cache) { + return { voiceChannels, categoryChannels }; + } + for (const guild of discordClient.guilds.cache.values()) { + const channels = guild.channels?.cache; + if (!channels) { + continue; + } + channels.forEach((channel) => { + if (channel.type === "GUILD_VOICE") { + voiceChannels.push({ + id: channel.id, + label: `${guild.name} • ${channel.name}` + }); + } + if (channel.type === "GUILD_CATEGORY") { + categoryChannels.push({ + id: channel.id, + label: `${guild.name} • ${channel.name}` + }); + } + }); + } + voiceChannels.sort((a, b) => a.label.localeCompare(b.label)); + categoryChannels.sort((a, b) => a.label.localeCompare(b.label)); + return { voiceChannels, categoryChannels }; +} + +async function enrichLobbiesWithPermissions(discordClient, lobbies) { + if (!Array.isArray(lobbies)) { + return []; + } + const enriched = []; + for (const lobby of lobbies) { + const permissions = await buildLobbyPermissionChecks(discordClient, lobby); + enriched.push({ ...lobby, permissions }); + } + return enriched; +} + +async function buildLobbyPermissionChecks(discordClient, lobby) { + const baseChecks = [ + "Bot in guild", + "Lobby channel exists", + "View lobby channel", + "Connect to lobby", + "Move members", + "Target category visible", + "Manage rooms" + ]; + + if (!discordClient || !discordClient.user) { + return baseChecks.map((label) => + buildPermissionCheck( + label, + false, + "Start the Discord bot so it can evaluate permissions." + ) + ); + } + + const lobbyChannel = await resolveChannel(discordClient, lobby?.lobbyChannelId); + const categoryChannel = + (await resolveChannel(discordClient, lobby?.categoryId)) || + (lobbyChannel?.parentId + ? await resolveChannel(discordClient, lobbyChannel.parentId) + : null); + const guild = lobbyChannel?.guild || categoryChannel?.guild || null; + const botMember = guild ? await resolveBotMember(guild, discordClient) : null; + + if (!guild || !botMember) { + return baseChecks.map((label) => + buildPermissionCheck( + label, + false, + "Ensure the bot is in this server and has access to the lobby." + ) + ); + } + + const lobbyPerms = lobbyChannel ? lobbyChannel.permissionsFor(botMember) : null; + const categoryPerms = categoryChannel + ? categoryChannel.permissionsFor(botMember) + : null; + const guildPerms = botMember.permissions || null; + const lobbyIsVoice = lobbyChannel?.type === "GUILD_VOICE"; + + const canViewLobby = Boolean(lobbyPerms?.has(Permissions.FLAGS.VIEW_CHANNEL)); + const canConnectLobby = Boolean(lobbyPerms?.has(Permissions.FLAGS.CONNECT)); + const canMoveMembers = Boolean( + lobbyPerms?.has(Permissions.FLAGS.MOVE_MEMBERS) || + guildPerms?.has(Permissions.FLAGS.MOVE_MEMBERS) + ); + const canViewCategory = Boolean( + categoryChannel && categoryPerms?.has(Permissions.FLAGS.VIEW_CHANNEL) + ); + const canManageChannels = Boolean( + categoryPerms?.has(Permissions.FLAGS.MANAGE_CHANNELS) || + lobbyPerms?.has(Permissions.FLAGS.MANAGE_CHANNELS) || + guildPerms?.has(Permissions.FLAGS.MANAGE_CHANNELS) + ); + + return [ + buildPermissionCheck("Bot in guild", true, ""), + buildPermissionCheck( + "Lobby channel exists", + Boolean(lobbyChannel) && lobbyIsVoice, + lobbyChannel + ? "The lobby must be a voice channel." + : "Set a valid lobby voice channel ID and save." + ), + buildPermissionCheck( + "View lobby channel", + Boolean(lobbyChannel) && lobbyIsVoice && canViewLobby, + "Allow View Channel for the bot on the lobby channel or its category." + ), + buildPermissionCheck( + "Connect to lobby", + Boolean(lobbyChannel) && lobbyIsVoice && canConnectLobby, + "Allow Connect for the bot on the lobby channel or its category." + ), + buildPermissionCheck( + "Move members", + canMoveMembers, + "Allow Move Members for the bot role on the server or lobby channel." + ), + buildPermissionCheck( + "Target category visible", + Boolean(categoryChannel) && canViewCategory, + categoryChannel + ? "Allow View Channel for the bot on the target category." + : "Set a valid target category ID or place the lobby inside a category." + ), + buildPermissionCheck( + "Manage rooms", + canManageChannels && (categoryChannel ? canViewCategory : true), + "Allow Manage Channels (and View Channel) on the target category so the bot can create, rename, and delete rooms." + ) + ]; +} + +function buildPermissionCheck(label, granted, help) { + return { + label, + granted: Boolean(granted), + help: granted ? "" : help + }; +} + +async function resolveChannel(discordClient, channelId) { + if (!discordClient || !channelId) { + return null; + } + const cached = discordClient.channels?.cache?.get(channelId) || null; + if (cached) { + return cached; + } + if (typeof discordClient.channels?.fetch === "function") { + return discordClient.channels.fetch(channelId).catch(() => null); + } + return null; +} + +async function resolveBotMember(guild, discordClient) { + if (!guild || !discordClient?.user?.id) { + return null; + } + const cached = guild.members?.cache?.get(discordClient.user.id) || null; + if (cached) { + return cached; + } + if (typeof guild.members?.fetch === "function") { + return guild.members.fetch(discordClient.user.id).catch(() => null); + } + return null; +} + +function ensureTables(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS auto_vc_rooms ( + channel_id TEXT PRIMARY KEY, + guild_id TEXT NOT NULL, + lobby_id TEXT NOT NULL, + category_id TEXT NOT NULL, + owner_discord_id TEXT NOT NULL, + owner_user_id TEXT NOT NULL, + room_number INTEGER NOT NULL, + name_template TEXT NOT NULL, + locked INTEGER NOT NULL DEFAULT 0, + allowed_user_ids TEXT NOT NULL DEFAULT '[]', + base_overwrites TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS auto_vc_stats ( + user_id TEXT PRIMARY KEY, + created_count INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS auto_vc_bans ( + discord_user_id TEXT PRIMARY KEY, + reason TEXT, + created_at INTEGER NOT NULL + ); + `); +} + +function getConfig(db) { + const row = db + .prepare("SELECT value FROM plugin_settings WHERE plugin_id = ? AND key = ?") + .get(PLUGIN_ID, "config"); + if (!row) { + return normalizeConfig({}); + } + try { + const parsed = JSON.parse(row.value); + return normalizeConfig(parsed); + } catch { + return normalizeConfig({}); + } +} + +function normalizeConfig(config) { + const lobbies = Array.isArray(config?.lobbies) ? config.lobbies : []; + return { + lobbies: lobbies.map((lobby) => normalizeLobby(lobby)), + limits: normalizeRateLimits(config?.limits) + }; +} + +function normalizeLobby(lobby) { + const timeout = Number(lobby?.emptyTimeoutSeconds || DEFAULT_TIMEOUT); + return { + id: lobby?.id || cryptoRandomId(), + lobbyChannelId: (lobby?.lobbyChannelId || "").toString().trim(), + categoryId: (lobby?.categoryId || "").toString().trim(), + nameTemplate: (lobby?.nameTemplate || DEFAULT_TEMPLATE).toString(), + emptyTimeoutSeconds: Number.isNaN(timeout) ? DEFAULT_TIMEOUT : Math.max(5, timeout) + }; +} + +function normalizeRateLimits(limits) { + return { + create: normalizeLimit(limits?.create, DEFAULT_CREATE_LIMIT), + action: normalizeLimit(limits?.action, DEFAULT_ACTION_LIMIT) + }; +} + +function normalizeLimit(limit, defaults) { + const max = clampNumber(limit?.max, defaults.max, 1, 100); + const windowSeconds = clampNumber( + limit?.windowSeconds, + defaults.windowSeconds, + 10, + 3600 + ); + return { max, windowSeconds }; +} + +function clampNumber(value, fallback, min, max) { + const parsed = Number(value); + if (Number.isNaN(parsed)) { + return fallback; + } + return Math.min(Math.max(parsed, min), max); +} + +function saveConfig(db, config) { + const now = Date.now(); + db.prepare( + "INSERT INTO plugin_settings (plugin_id, key, value, updated_at) VALUES (?, ?, ?, ?) " + + "ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at" + ).run(PLUGIN_ID, "config", JSON.stringify(config), now); +} + +function parseConfigFromForm(body) { + const ids = toArray(body.lobby_id); + const lobbyChannelIds = toArray(body.lobby_channel_id); + const categoryIds = toArray(body.lobby_category_id); + const templates = toArray(body.lobby_name_template); + const timeouts = toArray(body.lobby_empty_timeout); + const removeIds = new Set(toArray(body.lobby_remove)); + + const lobbies = ids.map((id, index) => { + const timeout = Number(timeouts[index] || DEFAULT_TIMEOUT); + return normalizeLobby({ + id, + lobbyChannelId: lobbyChannelIds[index] || "", + categoryId: categoryIds[index] || "", + nameTemplate: templates[index] || DEFAULT_TEMPLATE, + emptyTimeoutSeconds: Number.isNaN(timeout) ? DEFAULT_TIMEOUT : timeout + }); + }); + + const createLimit = normalizeLimit( + { + max: body.rate_create_count, + windowSeconds: body.rate_create_window + }, + DEFAULT_CREATE_LIMIT + ); + const actionLimit = normalizeLimit( + { + max: body.rate_action_count, + windowSeconds: body.rate_action_window + }, + DEFAULT_ACTION_LIMIT + ); + + return { + lobbies: lobbies.filter((lobby) => !removeIds.has(lobby.id)), + limits: { + create: createLimit, + action: actionLimit + } + }; +} + +function getStats(db) { + const rows = db + .prepare( + "SELECT auto_vc_stats.user_id AS user_id, auto_vc_stats.created_count AS created_count, user_profiles.internal_username AS username " + + "FROM auto_vc_stats " + + "LEFT JOIN user_profiles ON user_profiles.id = auto_vc_stats.user_id " + + "ORDER BY auto_vc_stats.created_count DESC LIMIT 10" + ) + .all(); + return rows.map((row) => ({ + label: row.username || row.user_id, + count: row.created_count + })); +} + +function getBans(db) { + const rows = db + .prepare("SELECT discord_user_id, reason FROM auto_vc_bans ORDER BY created_at DESC") + .all(); + return rows.map((row) => ({ + discord_user_id: row.discord_user_id, + reason: row.reason, + label: row.discord_user_id + })); +} + +function banUser(db, discordUserId, reason) { + db.prepare( + "INSERT INTO auto_vc_bans (discord_user_id, reason, created_at) VALUES (?, ?, ?) " + + "ON CONFLICT(discord_user_id) DO UPDATE SET reason = excluded.reason, created_at = excluded.created_at" + ).run(discordUserId, reason || "", Date.now()); +} + +function unbanUser(db, discordUserId) { + db.prepare("DELETE FROM auto_vc_bans WHERE discord_user_id = ?").run(discordUserId); +} + +function isBanned(db, discordUserId) { + const row = db + .prepare("SELECT discord_user_id FROM auto_vc_bans WHERE discord_user_id = ?") + .get(discordUserId); + return Boolean(row); +} + +async function bootstrapRooms(discordClient, db, state, settings) { + const rooms = db + .prepare("SELECT * FROM auto_vc_rooms") + .all(); + + for (const room of rooms) { + const guild = discordClient.guilds.cache.get(room.guild_id); + if (!guild) { + removeRoom(db, state, room.channel_id); + continue; + } + const channel = await guild.channels.fetch(room.channel_id).catch(() => null); + if (!channel || channel.type !== "GUILD_VOICE") { + removeRoom(db, state, room.channel_id); + continue; + } + if (channel.members.size === 0) { + await deleteChannel(channel); + removeRoom(db, state, room.channel_id); + continue; + } + state.rooms.set(room.channel_id, normalizeRoom(room)); + } +} + +function normalizeRoom(room) { + return { + channel_id: room.channel_id, + guild_id: room.guild_id, + lobby_id: room.lobby_id, + category_id: room.category_id, + owner_discord_id: room.owner_discord_id, + owner_user_id: room.owner_user_id, + room_number: Number(room.room_number || 1), + name_template: room.name_template, + locked: Boolean(room.locked), + allowed_user_ids: parseJsonArray(room.allowed_user_ids), + base_overwrites: room.base_overwrites, + created_at: room.created_at + }; +} + +function handleVoiceStateUpdate(oldState, newState, db, settings, state) { + const config = getConfig(db); + const lobby = config.lobbies.find( + (item) => item.lobbyChannelId && item.lobbyChannelId === newState.channelId + ); + + if (lobby && newState.channelId !== oldState.channelId) { + createRoomFromLobby(newState, lobby, db, settings, state, config).catch((error) => { + console.error("Auto VC creation failed", error); + }); + } + + if (oldState.channelId && oldState.channelId !== newState.channelId) { + const room = state.rooms.get(oldState.channelId) || getRoomById(db, oldState.channelId, state); + if (room) { + const channel = oldState.guild.channels.cache.get(oldState.channelId); + if (channel) { + scheduleCleanup(channel, room, db, state, config); + } + } + } + + if (newState.channelId) { + const room = state.rooms.get(newState.channelId) || getRoomById(db, newState.channelId, state); + if (room) { + clearCleanupTimer(state, newState.channelId); + clearEmpty(state, newState.channelId); + } + } +} + +async function createRoomFromLobby(newState, lobby, db, settings, state, config) { + const member = newState.member; + if (!member || !newState.guild) { + return; + } + if (isBanned(db, member.id)) { + await safeNotify(member, "You are banned from creating Auto VCs."); + return; + } + + const lobbyChannel = newState.guild.channels.cache.get(lobby.lobbyChannelId); + if (!lobbyChannel || lobbyChannel.type !== "GUILD_VOICE") { + return; + } + const createLimit = consumeRateLimit(state, config, "create", member.id); + if (!createLimit.ok) { + await safeNotify( + member, + `You're creating rooms too quickly. Try again in ${formatCooldown( + createLimit.retryAfter + )}.` + ); + return; + } + + const roomNumber = getNextAvailableRoomNumber(db, lobby.id); + const gameName = resolveGameName(member); + const roomName = buildRoomName(lobby.nameTemplate, member, roomNumber, gameName); + const baseOverwrites = extractOverwrites(lobbyChannel); + + const channel = await newState.guild.channels.create(roomName, { + type: "GUILD_VOICE", + parent: lobby.categoryId || lobbyChannel.parentId || null, + permissionOverwrites: baseOverwrites + }); + + const profile = ensureUserForIdentity({ + provider: "discord", + providerUserId: member.id, + displayName: member.displayName + }); + + const room = { + channel_id: channel.id, + guild_id: newState.guild.id, + lobby_id: lobby.id, + category_id: lobby.categoryId || lobbyChannel.parentId || "", + owner_discord_id: member.id, + owner_user_id: profile.id, + room_number: roomNumber, + name_template: lobby.nameTemplate, + locked: false, + allowed_user_ids: [], + base_overwrites: JSON.stringify(baseOverwrites), + created_at: Date.now() + }; + + saveRoom(db, room); + state.rooms.set(channel.id, normalizeRoom(room)); + incrementUserStat(db, profile.id); + + const moved = await moveMemberToChannel(member, channel); + if (!moved) { + await safeNotify( + member, + "I couldn't move you to the new VC. Please make sure the bot has Move Members permission." + ); + } +} + +function buildRoomName(template, member, roomNumber, gameName) { + const safeTemplate = (template || DEFAULT_TEMPLATE).toString(); + const hasGameToken = templateHasGameName(safeTemplate); + const shouldFallback = hasGameToken && !gameName; + const templateToUse = shouldFallback ? DEFAULT_TEMPLATE : safeTemplate; + const username = member.displayName || member.user?.username || "user"; + const replacements = [ + ["[username]", username], + ["[room_number]", String(roomNumber)], + ["[game_name]", gameName || ""] + ]; + let name = templateToUse; + for (const [token, value] of replacements) { + name = replaceToken(name, token, value); + } + name = name.replace(/\s{2,}/g, " ").trim(); + if (!name) { + name = `${username}'s room`; + } + return name.slice(0, 100); +} + +function templateHasGameName(template) { + if (!template) { + return false; + } + return template.toLowerCase().includes(GAME_NAME_TOKEN); +} + +function replaceToken(text, token, value) { + if (!text) { + return text; + } + const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return text.replace(new RegExp(escaped, "gi"), value); +} + +function resolveGameName(member) { + const activities = member?.presence?.activities || []; + const activity = activities.find((item) => isGameActivity(item)); + return activity?.name || ""; +} + +function isGameActivity(activity) { + if (!activity || !activity.name) { + return false; + } + const type = activity.type; + if (type === "PLAYING" || type === "STREAMING" || type === "COMPETING") { + return true; + } + if (type === 0 || type === 1 || type === 5) { + return true; + } + return false; +} + +function getNextAvailableRoomNumber(db, lobbyId) { + const rows = db + .prepare("SELECT room_number FROM auto_vc_rooms WHERE lobby_id = ?") + .all(lobbyId); + const used = new Set(); + for (const row of rows) { + const value = Number(row.room_number); + if (!Number.isNaN(value) && value > 0) { + used.add(value); + } + } + let candidate = 1; + while (used.has(candidate)) { + candidate += 1; + } + return candidate; +} + +function extractOverwrites(channel) { + return channel.permissionOverwrites.cache.map((overwrite) => ({ + id: overwrite.id, + type: overwrite.type, + allow: serializeBitfield(overwrite.allow.bitfield), + deny: serializeBitfield(overwrite.deny.bitfield) + })); +} + +function saveRoom(db, room) { + db.prepare( + "INSERT INTO auto_vc_rooms (channel_id, guild_id, lobby_id, category_id, owner_discord_id, owner_user_id, room_number, name_template, locked, allowed_user_ids, base_overwrites, created_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT(channel_id) DO UPDATE SET owner_discord_id = excluded.owner_discord_id, owner_user_id = excluded.owner_user_id, room_number = excluded.room_number, name_template = excluded.name_template, locked = excluded.locked, allowed_user_ids = excluded.allowed_user_ids, base_overwrites = excluded.base_overwrites" + ).run( + room.channel_id, + room.guild_id, + room.lobby_id, + room.category_id, + room.owner_discord_id, + room.owner_user_id, + room.room_number, + room.name_template, + room.locked ? 1 : 0, + JSON.stringify(room.allowed_user_ids || []), + room.base_overwrites, + room.created_at + ); +} + +function updateRoom(db, room) { + db.prepare( + "UPDATE auto_vc_rooms SET owner_discord_id = ?, owner_user_id = ?, room_number = ?, name_template = ?, locked = ?, allowed_user_ids = ? WHERE channel_id = ?" + ).run( + room.owner_discord_id, + room.owner_user_id, + room.room_number, + room.name_template, + room.locked ? 1 : 0, + JSON.stringify(room.allowed_user_ids || []), + room.channel_id + ); +} + +function getRoomById(db, channelId, state) { + const row = db + .prepare("SELECT * FROM auto_vc_rooms WHERE channel_id = ?") + .get(channelId); + if (!row) { + return null; + } + const room = normalizeRoom(row); + state.rooms.set(channelId, room); + return room; +} + +function removeRoom(db, state, channelId) { + clearCleanupTimer(state, channelId); + clearEmpty(state, channelId); + state.rooms.delete(channelId); + db.prepare("DELETE FROM auto_vc_rooms WHERE channel_id = ?").run(channelId); +} + +function incrementUserStat(db, userId) { + const now = Date.now(); + db.prepare( + "INSERT INTO auto_vc_stats (user_id, created_count, updated_at) VALUES (?, 1, ?) " + + "ON CONFLICT(user_id) DO UPDATE SET created_count = created_count + 1, updated_at = excluded.updated_at" + ).run(userId, now); +} + +function consumeRateLimit(state, config, type, userId) { + const limit = config?.limits?.[type]; + const buckets = state?.rateLimits?.[type]; + if (!limit || !buckets || !userId) { + return { ok: true }; + } + const now = Date.now(); + const windowMs = Math.max(1, Number(limit.windowSeconds || 0)) * 1000; + if (!windowMs || limit.max <= 0) { + return { ok: true }; + } + const current = buckets.get(userId) || { + count: 0, + resetAt: now + windowMs + }; + if (now >= current.resetAt) { + current.count = 0; + current.resetAt = now + windowMs; + } + if (current.count >= limit.max) { + const retryAfter = Math.max(1, Math.ceil((current.resetAt - now) / 1000)); + return { ok: false, retryAfter }; + } + current.count += 1; + buckets.set(userId, current); + return { + ok: true, + remaining: Math.max(0, limit.max - current.count), + resetAt: current.resetAt + }; +} + +function formatCooldown(seconds) { + const safeSeconds = Math.max(1, Math.round(seconds || 0)); + if (safeSeconds < 60) { + return `${safeSeconds}s`; + } + const minutes = Math.ceil(safeSeconds / 60); + return `${minutes}m`; +} + +function enforceActionRateLimit(message, state, config) { + const userId = message?.member?.id; + if (!userId) { + return true; + } + const result = consumeRateLimit(state, config, "action", userId); + if (result.ok) { + return true; + } + const wait = formatCooldown(result.retryAfter); + message.reply(`Slow down a bit. Try again in ${wait}.`).catch(() => null); + return false; +} + +function scheduleCleanup(channel, room, db, state, config) { + if (!channel || channel.members.size > 0) { + clearEmpty(state, channel?.id); + return; + } + const timeout = getLobbyTimeout(config, room.lobby_id); + markEmpty(state, channel.id); + clearCleanupTimer(state, channel.id); + + const timer = setTimeout(async () => { + const refreshed = channel.guild.channels.cache.get(channel.id); + if (!refreshed || refreshed.members.size > 0) { + clearEmpty(state, channel.id); + return; + } + await deleteChannel(refreshed); + removeRoom(db, state, channel.id); + }, timeout * 1000); + + state.cleanupTimers.set(channel.id, timer); +} + +function clearCleanupTimer(state, channelId) { + const timer = state.cleanupTimers.get(channelId); + if (timer) { + clearTimeout(timer); + state.cleanupTimers.delete(channelId); + } +} + +function markEmpty(state, channelId) { + if (!state.emptySince.has(channelId)) { + state.emptySince.set(channelId, Date.now()); + } +} + +function clearEmpty(state, channelId) { + state.emptySince.delete(channelId); +} + +function getLobbyTimeout(config, lobbyId) { + const lobby = config.lobbies.find((item) => item.id === lobbyId); + return lobby ? lobby.emptyTimeoutSeconds : DEFAULT_TIMEOUT; +} + +async function deleteChannel(channel) { + try { + await channel.delete("Auto VC cleanup"); + } catch (error) { + console.error("Failed to delete Auto VC channel", error); + } +} + +function handleMessage(message, db, settings, state) { + if (!message.guild || message.author.bot) { + return; + } + const config = getConfig(db); + const prefix = settings.getSetting("command_prefix", "!"); + const content = message.content.trim(); + if (!content.toLowerCase().startsWith(`${prefix}vc`)) { + return; + } + + const tokens = content.slice(prefix.length).trim().split(/\s+/); + if (tokens[0].toLowerCase() !== "vc") { + return; + } + + const command = (tokens[1] || "").toLowerCase(); + const args = tokens.slice(2); + const member = message.member; + if (!member) { + return; + } + + if (command === "ban" || command === "unban") { + handleBanCommands(message, command, args, db, settings); + return; + } + + const voiceChannel = member.voice?.channel; + if (!voiceChannel) { + message.reply("You must be in an Auto VC to use that command.").catch(() => null); + return; + } + + const room = state.rooms.get(voiceChannel.id) || getRoomById(db, voiceChannel.id, state); + if (!room) { + message.reply("That voice channel is not managed by Auto VC.").catch(() => null); + return; + } + + const isAdminOrMod = checkIsModerator(member, settings); + const isOwner = room.owner_discord_id === member.id; + + if (command === "claim") { + handleClaim(message, room, member, db, settings, state, config); + return; + } + + if (!isOwner && !isAdminOrMod) { + message.reply("Only the room owner or moderators can use that command.").catch(() => null); + return; + } + + switch (command) { + case "rename": + handleRename(message, room, args, db, settings, state, config); + break; + case "lock": + handleLock(message, room, db, settings, state, config); + break; + case "unlock": + handleUnlock(message, room, db, settings, state, config); + break; + case "allow": + handleAllow(message, room, args, db, settings, state, config); + break; + case "disallow": + handleDisallow(message, room, args, db, settings, state, config); + break; + case "transfer": + handleTransfer(message, room, args, db, settings, state, config); + break; + default: + message + .reply( + "Available commands: rename, lock, unlock, allow, disallow, transfer, claim." + ) + .catch(() => null); + } +} + +function handleBanCommands(message, command, args, db, settings) { + const member = message.member; + if (!member || !checkIsModerator(member, settings)) { + message.reply("Only moderators and admins can manage bans.").catch(() => null); + return; + } + const targetToken = args[0] || ""; + const id = parseDiscordId(targetToken); + if (!id) { + message.reply("Provide a valid user ID or mention.").catch(() => null); + return; + } + if (command === "ban") { + banUser(db, id, ""); + message.reply("User banned from Auto VC creation.").catch(() => null); + } else { + unbanUser(db, id); + message.reply("User unbanned from Auto VC creation.").catch(() => null); + } +} + +async function handleRename(message, room, args, db, settings, state, config) { + const template = args.join(" ").trim(); + if (!template) { + message.reply("Usage: !vc rename ").catch(() => null); + return; + } + const channel = message.guild.channels.cache.get(room.channel_id); + if (!channel) { + message.reply("Channel not found.").catch(() => null); + return; + } + if (!enforceActionRateLimit(message, state, config)) { + return; + } + const gameName = resolveGameName(message.member); + const name = buildRoomName(template, message.member, room.room_number, gameName); + await channel.setName(name).catch(() => null); + room.name_template = template; + updateRoom(db, room); + state.rooms.set(room.channel_id, room); + message.reply(`Renamed this room to "${name}".`).catch(() => null); +} + +async function handleLock(message, room, db, settings, state, config) { + const channel = message.guild.channels.cache.get(room.channel_id); + if (!channel) { + message.reply("Channel not found.").catch(() => null); + return; + } + if (!enforceActionRateLimit(message, state, config)) { + return; + } + room.locked = !room.locked; + await applyRoomPermissions(channel, room, db, settings); + updateRoom(db, room); + state.rooms.set(room.channel_id, room); + message + .reply(room.locked ? "Room locked." : "Room unlocked.") + .catch(() => null); +} + +async function handleUnlock(message, room, db, settings, state, config) { + const channel = message.guild.channels.cache.get(room.channel_id); + if (!channel) { + message.reply("Channel not found.").catch(() => null); + return; + } + if (!enforceActionRateLimit(message, state, config)) { + return; + } + if (!room.locked) { + message.reply("Room is already unlocked.").catch(() => null); + return; + } + room.locked = false; + await applyRoomPermissions(channel, room, db, settings); + updateRoom(db, room); + state.rooms.set(room.channel_id, room); + message.reply("Room unlocked.").catch(() => null); +} + +async function handleAllow(message, room, args, db, settings, state, config) { + const token = args[0]; + if (!token) { + message.reply("Usage: !vc allow ").catch(() => null); + return; + } + const target = await resolveMember(message.guild, token); + if (!target) { + message.reply("User not found.").catch(() => null); + return; + } + if (!enforceActionRateLimit(message, state, config)) { + return; + } + if (!room.allowed_user_ids.includes(target.id)) { + room.allowed_user_ids.push(target.id); + } + const channel = message.guild.channels.cache.get(room.channel_id); + if (channel) { + await applyRoomPermissions(channel, room, db, settings); + } + updateRoom(db, room); + state.rooms.set(room.channel_id, room); + message.reply(`Allowed ${target.displayName} to join.`).catch(() => null); +} + +async function handleDisallow(message, room, args, db, settings, state, config) { + const token = (args[0] || "").toLowerCase(); + if (!token) { + message.reply("Usage: !vc disallow ").catch(() => null); + return; + } + if (!enforceActionRateLimit(message, state, config)) { + return; + } + if (token === "all") { + room.allowed_user_ids = []; + } else { + const target = await resolveMember(message.guild, token); + if (!target) { + message.reply("User not found.").catch(() => null); + return; + } + room.allowed_user_ids = room.allowed_user_ids.filter((id) => id !== target.id); + } + const channel = message.guild.channels.cache.get(room.channel_id); + if (channel) { + await applyRoomPermissions(channel, room, db, settings); + } + updateRoom(db, room); + state.rooms.set(room.channel_id, room); + message.reply("Access updated.").catch(() => null); +} + +async function handleTransfer(message, room, args, db, settings, state, config) { + const token = args[0]; + if (!token) { + message.reply("Usage: !vc transfer ").catch(() => null); + return; + } + const target = await resolveMember(message.guild, token); + if (!target || !message.member.voice?.channel?.members?.has(target.id)) { + message.reply("Target must be in the voice channel.").catch(() => null); + return; + } + if (!enforceActionRateLimit(message, state, config)) { + return; + } + room.owner_discord_id = target.id; + const profile = ensureUserForIdentity({ + provider: "discord", + providerUserId: target.id, + displayName: target.displayName + }); + room.owner_user_id = profile.id; + const channel = message.guild.channels.cache.get(room.channel_id); + if (channel) { + await applyRoomPermissions(channel, room, db, settings); + } + updateRoom(db, room); + state.rooms.set(room.channel_id, room); + message.reply(`Ownership transferred to ${target.displayName}.`).catch(() => null); +} + +async function handleClaim(message, room, member, db, settings, state, config) { + const channel = message.guild.channels.cache.get(room.channel_id); + if (!channel) { + message.reply("Channel not found.").catch(() => null); + return; + } + if (channel.members.has(room.owner_discord_id)) { + message.reply("The current owner is still here.").catch(() => null); + return; + } + if (!enforceActionRateLimit(message, state, config)) { + return; + } + room.owner_discord_id = member.id; + const profile = ensureUserForIdentity({ + provider: "discord", + providerUserId: member.id, + displayName: member.displayName + }); + room.owner_user_id = profile.id; + await applyRoomPermissions(channel, room, db, settings); + updateRoom(db, room); + state.rooms.set(room.channel_id, room); + message.reply("You are now the owner of this room.").catch(() => null); +} + +async function applyRoomPermissions(channel, room, db, settings) { + const base = parseJsonArray(room.base_overwrites); + const overrides = new Map(); + for (const overwrite of base) { + overrides.set(overwrite.id, { + ...overwrite, + allow: parseBitfield(overwrite.allow), + deny: parseBitfield(overwrite.deny) + }); + } + + overrides.set(room.owner_discord_id, { + id: room.owner_discord_id, + type: 1, + allow: ALLOW_CONNECT_VIEW, + deny: 0n + }); + + for (const userId of room.allowed_user_ids) { + overrides.set(userId, { + id: userId, + type: 1, + allow: ALLOW_CONNECT_VIEW, + deny: 0n + }); + } + + if (room.locked) { + const everyoneId = channel.guild.roles.everyone.id; + overrides.set(everyoneId, { + id: everyoneId, + type: 0, + allow: 0n, + deny: Permissions.FLAGS.CONNECT + }); + const { adminRoleIds, modRoleIds } = getRoleIds(settings); + for (const roleId of [...adminRoleIds, ...modRoleIds]) { + overrides.set(roleId, { + id: roleId, + type: 0, + allow: ALLOW_CONNECT_VIEW, + deny: 0n + }); + } + } + + await channel.permissionOverwrites.set(Array.from(overrides.values())).catch(() => null); +} + +function checkIsModerator(member, settings) { + const roles = member.roles.cache.map((role) => role.id); + const { adminRoleIds, modRoleIds } = getRoleIds(settings); + const isAdmin = roles.some((roleId) => adminRoleIds.includes(roleId)); + const isMod = roles.some((roleId) => modRoleIds.includes(roleId)); + return isAdmin || isMod; +} + +function getRoleIds(settings) { + const adminRoleIds = parseRoleList(settings.getSetting("discord_admin_role_id")); + const modRoleIds = parseRoleList(settings.getSetting("discord_mod_role_id")); + return { adminRoleIds, modRoleIds }; +} + +function parseRoleList(value) { + return (value || "") + .toString() + .split(/[,\s]+/) + .map((item) => item.trim()) + .filter(Boolean); +} + +async function resolveMember(guild, token) { + const id = parseDiscordId(token); + if (id) { + return guild.members.fetch(id).catch(() => null); + } + const needle = token.toLowerCase(); + return guild.members.cache.find((member) => { + const name = (member.displayName || member.user.username || "").toLowerCase(); + return name === needle; + }); +} + +function parseDiscordId(value) { + if (!value) { + return null; + } + const match = value.match(/^<@!?(\d+)>$/) || value.match(/^(\d{15,})$/); + return match ? match[1] : null; +} + +function parseJsonArray(value) { + if (!value) { + return []; + } + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function serializeBitfield(value) { + if (typeof value === "bigint") { + return value.toString(); + } + if (typeof value === "number") { + return String(value); + } + if (!value) { + return "0"; + } + return value.toString(); +} + +function parseBitfield(value) { + if (typeof value === "bigint") { + return value; + } + if (typeof value === "number") { + return BigInt(value); + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) { + return 0n; + } + try { + return BigInt(trimmed); + } catch { + return 0n; + } + } + return 0n; +} + +function toArray(value) { + if (!value) { + return []; + } + return Array.isArray(value) ? value : [value]; +} + +function cryptoRandomId() { + return require("crypto").randomUUID(); +} + +async function safeNotify(member, message) { + try { + await member.send(message); + } catch { + // ignore + } +} + +async function moveMemberToChannel(member, channel) { + try { + await member.voice.setChannel(channel); + return true; + } catch (error) { + console.error("Failed to move member", error); + try { + await member.edit({ channel: channel.id }); + return true; + } catch (fallbackError) { + console.error("Fallback move failed", fallbackError); + return false; + } + } +} + +function startNameRefreshTimer(discordClient, db, settings, state) { + if (state.nameSweepTimer) { + clearInterval(state.nameSweepTimer); + } + const runSweep = () => { + refreshRoomNames(discordClient, db, settings, state).catch((error) => { + console.error("Auto VC name refresh failed", error); + }); + }; + runSweep(); + state.nameSweepTimer = setInterval(runSweep, NAME_REFRESH_INTERVAL_MS); +} + +async function refreshRoomNames(discordClient, db, settings, state) { + const rooms = db.prepare("SELECT * FROM auto_vc_rooms").all(); + for (const row of rooms) { + const room = normalizeRoom(row); + if (!templateHasGameName(room.name_template)) { + continue; + } + const guild = discordClient.guilds.cache.get(room.guild_id); + if (!guild) { + continue; + } + const channel = await guild.channels.fetch(room.channel_id).catch(() => null); + if (!channel || channel.type !== "GUILD_VOICE") { + continue; + } + const member = await guild.members.fetch(room.owner_discord_id).catch(() => null); + if (!member) { + continue; + } + const gameName = resolveGameName(member); + const desiredName = buildRoomName(room.name_template, member, room.room_number, gameName); + if (desiredName && desiredName !== channel.name) { + await channel.setName(desiredName).catch((error) => { + console.error("Failed to update Auto VC name", error); + }); + } + } +} + +function startSweepTimer(discordClient, db, settings, state) { + if (state.sweepTimer) { + clearInterval(state.sweepTimer); + } + const runSweep = () => { + sweepRooms(discordClient, db, state).catch((error) => { + console.error("Auto VC sweep failed", error); + }); + }; + runSweep(); + state.sweepTimer = setInterval(runSweep, 15000); +} + +async function sweepRooms(discordClient, db, state) { + const config = getConfig(db); + const rooms = db.prepare("SELECT * FROM auto_vc_rooms").all(); + for (const row of rooms) { + const room = normalizeRoom(row); + state.rooms.set(room.channel_id, room); + const guild = discordClient.guilds.cache.get(room.guild_id); + if (!guild) { + removeRoom(db, state, room.channel_id); + continue; + } + const channel = await guild.channels.fetch(room.channel_id).catch(() => null); + if (!channel || channel.type !== "GUILD_VOICE") { + removeRoom(db, state, room.channel_id); + continue; + } + if (channel.members.size === 0) { + markEmpty(state, channel.id); + const timeout = getLobbyTimeout(config, room.lobby_id); + const emptyAt = state.emptySince.get(channel.id) || Date.now(); + if (Date.now() - emptyAt >= timeout * 1000) { + await deleteChannel(channel); + removeRoom(db, state, channel.id); + } + } else { + clearCleanupTimer(state, channel.id); + clearEmpty(state, channel.id); + } + } +} diff --git a/plugins/auto-vc/plugin.json b/plugins/auto-vc/plugin.json new file mode 100644 index 0000000..50bae97 --- /dev/null +++ b/plugins/auto-vc/plugin.json @@ -0,0 +1,7 @@ +{ + "id": "auto-vc", + "name": "Auto VC", + "version": "0.1.5", + "description": "Auto-create managed voice channels from lobby rooms.", + "main": "index.js" +} diff --git a/plugins/auto-vc/stats.js b/plugins/auto-vc/stats.js new file mode 100644 index 0000000..e4a8bd6 --- /dev/null +++ b/plugins/auto-vc/stats.js @@ -0,0 +1,53 @@ +const { db } = require("../../src/services/db"); + +function getProfileStats({ userId }) { + if (!userId) { + return { stats: [] }; + } + const row = db + .prepare("SELECT created_count FROM auto_vc_stats WHERE user_id = ?") + .get(userId); + if (!row) { + return { stats: [] }; + } + return { + stats: [ + { + label: "Rooms created", + value: row.created_count + } + ] + }; +} + +function getLeaderboards({ limit = 10 }) { + const rows = db + .prepare( + "SELECT auto_vc_stats.user_id AS user_id, " + + "auto_vc_stats.created_count AS total, " + + "user_profiles.internal_username AS username " + + "FROM auto_vc_stats " + + "LEFT JOIN user_profiles ON user_profiles.id = auto_vc_stats.user_id " + + "ORDER BY auto_vc_stats.created_count DESC LIMIT ?" + ) + .all(limit); + + return { + boards: [ + { + id: "rooms-created", + title: "Most rooms created", + valueLabel: "Rooms", + rows: rows.map((row) => ({ + username: row.username || row.user_id, + value: row.total + })) + } + ] + }; +} + +module.exports = { + getProfileStats, + getLeaderboards +}; diff --git a/plugins/auto-vc/stats.json b/plugins/auto-vc/stats.json new file mode 100644 index 0000000..15e2abf --- /dev/null +++ b/plugins/auto-vc/stats.json @@ -0,0 +1,13 @@ +{ + "pluginId": "auto-vc", + "pluginName": "Auto VC", + "provider": "stats.js", + "profile": { + "title": "Auto VC", + "emptyMessage": "Create an Auto VC room to see stats here." + }, + "leaderboards": { + "title": "Auto VC", + "emptyMessage": "No Auto VC rooms created yet." + } +} diff --git a/plugins/auto-vc/views/auto-vc.ejs b/plugins/auto-vc/views/auto-vc.ejs new file mode 100644 index 0000000..1cf03e4 --- /dev/null +++ b/plugins/auto-vc/views/auto-vc.ejs @@ -0,0 +1,560 @@ +
+

Auto VC

+

Automatically create temporary voice channels when members join your lobby channels. Rooms inherit lobby permissions and can be managed with !vc commands.

+
+ Placeholders +

Use [username], [room_number], and [game_name] inside channel names.

+

[game_name] is pulled from the creator's Discord presence.

+
+
+ + + +
+

Stats

+ <% if (!stats || !stats.length) { %> +

No rooms created yet.

+ <% } else { %> + + + + + + + + + <% stats.forEach((row) => { %> + + + + + <% }) %> + +
UserRooms created
<%= row.label %><%= row.count %>
+ <% } %> +
+ +<% if (isAdmin) { %> +
+

Lobby setup

+
+
+

Rate limits

+
+
+ +
+ + per + + seconds +
+
+
+ +
+ + per + + seconds +
+
+
+

Applies to room creation and commands that update channels or permissions.

+
+
+
+ <% lobbies.forEach((lobby, index) => { %> +
+
+

Lobby <%= index + 1 %>

+
+ + +
+
+ + <% const lobbyVoice = voiceChannels?.find((channel) => channel.id === lobby.lobbyChannelId); %> + <% const lobbyCategory = categoryChannels?.find((channel) => channel.id === lobby.categoryId); %> +
+
+ + <% if (voiceChannels && voiceChannels.length) { %> + +
Selected ID: <%= lobby.lobbyChannelId || "-" %>
+ <% } else { %> + + <% } %> +
+
+ + <% if (categoryChannels && categoryChannels.length) { %> + +
Selected ID: <%= lobby.categoryId || "-" %>
+ <% } else { %> + + <% } %> +
+
+ + +

Examples: [username]'s room, [game_name] [room_number]

+
+
+ + +
+
+ <% if (lobby.permissions && lobby.permissions.length) { %> + <% const totalPerms = lobby.permissions.length; %> + <% const okPerms = lobby.permissions.filter((perm) => perm.granted).length; %> + <% const allOk = okPerms === totalPerms; %> +
> + + Permissions Check (<%= okPerms %>/<%= totalPerms %> + <% if (allOk) { %> + all ok + <% } %> + ) + +
+ <% lobby.permissions.forEach((perm) => { %> +
+ + <%= perm.label %> +
+ <% }) %> +
+

Hover red checks to see how to fix missing permissions.

+
+ <% } %> +
+ <% }) %> +
+
+
+ + +
+
+
+<% } %> + +<% if (canModerate) { %> +
+

VC creation bans

+
+
+ + +
+
+ + +
+
+ +
+
+
+

Currently banned

+ <% if (!bans.length) { %> +

No banned users.

+ <% } else { %> +
+ + + + + + + + + + <% bans.forEach((ban) => { %> + + + + + + <% }) %> + +
UserReasonRemove
<%= ban.label %><%= ban.reason || '-' %> + +
+
+ +
+
+ <% } %> +
+
+<% } %> + + + + + + diff --git a/plugins/echonomy-framework/cmds.json b/plugins/echonomy-framework/cmds.json new file mode 100644 index 0000000..8928880 --- /dev/null +++ b/plugins/echonomy-framework/cmds.json @@ -0,0 +1,120 @@ +{ + "pluginId": "echonomy-framework", + "pluginName": "Echonomy Framework", + "platformKeys": { + "discord": "platform_discord", + "twitch": "platform_twitch", + "youtube": "platform_youtube" + }, + "commands": [ + { + "id": "root", + "trigger": "coins", + "name": "Coins", + "description": "Root command for the Echonomy currency system.", + "level": "public", + "platforms": ["discord", "twitch", "youtube"], + "triggerKey": "command_root", + "usage": "coins " + }, + { + "id": "balance", + "trigger": "coins", + "subcommand": "balance", + "name": "Balance", + "description": "Show your current currency balance.", + "level": "public", + "platforms": ["discord", "twitch", "youtube"], + "triggerKey": "command_root", + "usage": "coins balance" + }, + { + "id": "pay", + "trigger": "coins", + "subcommand": "pay", + "name": "Pay", + "description": "Send currency to another user.", + "level": "public", + "platforms": ["discord", "twitch", "youtube"], + "triggerKey": "command_root", + "usage": "coins pay [note]" + }, + { + "id": "leaderboard", + "trigger": "coins", + "subcommand": "top", + "name": "Leaderboard", + "description": "View the top balances.", + "level": "public", + "platforms": ["discord", "twitch", "youtube"], + "triggerKey": "command_root", + "usage": "coins top" + }, + { + "id": "stats", + "trigger": "coins", + "subcommand": "stats", + "name": "Stats", + "description": "Show global currency stats.", + "level": "public", + "platforms": ["discord", "twitch", "youtube"], + "triggerKey": "command_root", + "usage": "coins stats" + }, + { + "id": "funds", + "trigger": "coins", + "subcommand": "funds", + "name": "Community funds", + "description": "List community funds and progress.", + "level": "public", + "platforms": ["discord", "twitch", "youtube"], + "triggerKey": "command_root", + "usage": "coins funds" + }, + { + "id": "donate", + "trigger": "coins", + "subcommand": "donate", + "name": "Donate", + "description": "Donate currency to a community fund.", + "level": "public", + "platforms": ["discord", "twitch", "youtube"], + "triggerKey": "command_root", + "usage": "coins donate " + }, + { + "id": "grant", + "trigger": "coins", + "subcommand": "grant", + "name": "Grant", + "description": "Grant currency to a user.", + "level": "mod", + "platforms": ["discord", "twitch", "youtube"], + "triggerKey": "command_root", + "usage": "coins grant [note]" + }, + { + "id": "take", + "trigger": "coins", + "subcommand": "take", + "name": "Take", + "description": "Remove currency from a user.", + "level": "mod", + "platforms": ["discord", "twitch", "youtube"], + "triggerKey": "command_root", + "usage": "coins take [note]" + }, + { + "id": "reward", + "trigger": "coins", + "subcommand": "reward", + "name": "Reward", + "description": "Award a configured event reward.", + "level": "mod", + "platforms": ["discord", "twitch", "youtube"], + "triggerKey": "command_root", + "usage": "coins reward " + } + ] +} diff --git a/plugins/echonomy-framework/index.js b/plugins/echonomy-framework/index.js new file mode 100644 index 0000000..f0dc716 --- /dev/null +++ b/plugins/echonomy-framework/index.js @@ -0,0 +1,2362 @@ + +const path = require("path"); +const fs = require("fs"); +const crypto = require("crypto"); +const express = require("express"); +const multer = require("multer"); +const EventEmitter = require("events"); +const { ensureUserForIdentity } = require("../../src/services/users"); + +const PLUGIN_ID = "echonomy-framework"; +const DEFAULT_SETTINGS = { + currency_name: "Coin", + currency_name_plural: "Coins", + currency_icon_path: "", + command_root: "coins", + command_aliases: "", + banking_label: "Banking", + banking_enabled: "1", + community_fund_name: "Community fund", + community_fund_name_plural: "Community funds", + platform_discord: "1", + platform_twitch: "1", + platform_youtube: "1", + transfer_cooldown_seconds: "10", + earn_discord_message_enabled: "1", + earn_discord_message_amount: "1", + earn_discord_message_cooldown: "30", + earn_twitch_message_enabled: "1", + earn_twitch_message_amount: "1", + earn_twitch_message_cooldown: "30", + earn_discord_voice_enabled: "0", + earn_discord_voice_amount_per_min: "2", + earn_discord_voice_tick_minutes: "1", + tier_discord_booster_multiplier: "1.25", + tier_twitch_sub_multiplier: "1.5", + tier_twitch_mod_multiplier: "1.2", + tier_twitch_vip_multiplier: "1.1", + tier_twitch_broadcaster_multiplier: "2.0", + custom_events: "[]", + response_templates: "" +}; + +const DEFAULT_RESPONSES = { + balance_self: { + label: "Balance (self)", + mode: "random", + replies: [ + { text: "Your balance is {balance_text}.", weight: 1 }, + { text: "You have {balance_text} available.", weight: 1 } + ] + }, + top_list: { + label: "Top balances", + mode: "random", + replies: [{ text: "Top balances: {lines}", weight: 1 }] + }, + top_empty: { + label: "Top balances (empty)", + mode: "random", + replies: [{ text: "No balances yet.", weight: 1 }] + }, + stats: { + label: "Global stats", + mode: "random", + replies: [ + { + text: "Total in circulation: {total_balance_text}. Total spent: {total_spent_text}.", + weight: 1 + } + ] + }, + pay_success: { + label: "Pay success", + mode: "random", + replies: [ + { text: "Sent {amount_text} to {target}.", weight: 1 }, + { text: "Transfer complete: {target} received {amount_text}.", weight: 1 } + ] + }, + pay_missing: { + label: "Pay missing arguments", + mode: "random", + replies: [{ text: "Usage: {usage}", weight: 1 }] + }, + pay_cooldown: { + label: "Pay cooldown", + mode: "random", + replies: [{ text: "Please wait {cooldown}s before sending again.", weight: 1 }] + }, + pay_self: { + label: "Pay self", + mode: "random", + replies: [{ text: "You cannot pay yourself.", weight: 1 }] + }, + pay_not_found: { + label: "Pay user not found", + mode: "random", + replies: [{ text: "I couldn't find that user.", weight: 1 }] + }, + pay_insufficient: { + label: "Pay insufficient balance", + mode: "random", + replies: [{ text: "{reason}", weight: 1 }] + }, + grant_success: { + label: "Grant success", + mode: "random", + replies: [{ text: "Granted {amount_text} to {target}.", weight: 1 }] + }, + take_success: { + label: "Take success", + mode: "random", + replies: [{ text: "Removed {amount_text} from {target}.", weight: 1 }] + }, + funds_list: { + label: "Community funds list", + mode: "random", + replies: [{ text: "{funds_label}: {lines}", weight: 1 }] + }, + funds_empty: { + label: "Community funds (empty)", + mode: "random", + replies: [{ text: "No {funds_label} are active yet.", weight: 1 }] + }, + fund_missing: { + label: "Fund missing arguments", + mode: "random", + replies: [{ text: "Usage: {usage}", weight: 1 }] + }, + fund_not_found: { + label: "Fund not found", + mode: "random", + replies: [{ text: "That {fund_label} is not active.", weight: 1 }] + }, + fund_donate_success: { + label: "Fund donation success", + mode: "random", + replies: [ + { text: "Donated {amount_text} to {fund}.", weight: 1 }, + { text: "Thanks! {amount_text} added to {fund}.", weight: 1 } + ] + }, + permission_denied: { + label: "Permission denied", + mode: "random", + replies: [{ text: "You do not have permission to do that.", weight: 1 }] + }, + reward_missing: { + label: "Event reward missing arguments", + mode: "random", + replies: [{ text: "Usage: {usage}", weight: 1 }] + }, + reward_not_found: { + label: "Event reward not found", + mode: "random", + replies: [{ text: "That event is not configured.", weight: 1 }] + }, + reward_success: { + label: "Event reward success", + mode: "random", + replies: [{ text: "Awarded {amount_text} to {target}.", weight: 1 }] + }, + help: { + label: "Help", + mode: "random", + replies: [{ text: "{help}", weight: 1 }] + } +}; + +const emitter = new EventEmitter(); +const messageCooldowns = new Map(); +const transferCooldowns = new Map(); +const voiceStates = new Map(); +let voiceTimer = null; +let activityFlushTimer = null; +let cachedConfig = null; +let cachedConfigAt = 0; +let settingsApi = null; +const ACTIVITY_REWARD_NOTE = "Activity Reward"; +const ACTIVITY_REWARD_SOURCES = { + discord_message: "Discord Message", + twitch_message: "Twitch Message", + discord_voice: "Discord Voice" +}; + +module.exports = { + id: PLUGIN_ID, + init({ + app, + web, + settings, + db, + commandRouter, + discordClient, + twitchClient + }) { + settingsApi = settings; + ensureTables(db); + ensureDefaults(db); + startActivityRewardFlusher(db); + + const api = buildApi({ db }); + registerFramework(api); + const refreshCommands = registerCommands({ db, settings, commandRouter }); + + attachDiscordListeners({ db, settings, discordClient }); + attachTwitchListeners({ db, settings, twitchClient }); + installProfileHook(app, () => getConfig(db)); + + const repoRoot = path.join(__dirname, "..", ".."); + const uploadDir = path.join(repoRoot, "data", "echonomy-framework"); + fs.mkdirSync(uploadDir, { recursive: true }); + const upload = multer({ + dest: uploadDir, + fileFilter: (_req, file, cb) => { + if (file.mimetype === "image/png") { + return cb(null, true); + } + cb(new Error("Only PNG files are allowed.")); + } + }); + + const router = web.createRouter(); + router.use("/assets", express.static(uploadDir)); + + router.get("/", (req, res) => { + const config = getConfig(db); + const user = req.session.user || null; + const isAdmin = Boolean(user?.isAdmin); + const isMod = Boolean(user?.isAdmin || user?.isMod); + const userBalance = user ? getBalance(db, user.id) : 0; + const transactions = listTransactions(db, { + userId: isAdmin ? null : user?.id, + limit: 1000 + }); + const globalStats = buildGlobalStats(db); + const topBalances = listTopBalances(db, 10); + const funds = listFunds(db); + const events = getCustomEvents(config); + const responses = Object.values(config.responses || {}); + + res.render(path.join(__dirname, "views", "echonomy.ejs"), { + title: "Echonomy Framework", + config, + user, + isAdmin, + isMod, + userBalance, + transactions, + globalStats, + topBalances, + funds, + events, + responses + }); + }); + + router.post("/settings/currency", (req, res) => { + if (!req.session.user?.isAdmin) { + return deny(res); + } + setPluginSetting(db, "currency_name", (req.body.currency_name || "").trim()); + setPluginSetting( + db, + "currency_name_plural", + (req.body.currency_name_plural || "").trim() + ); + setPluginSetting(db, "command_root", (req.body.command_root || "").trim()); + setPluginSetting(db, "command_aliases", (req.body.command_aliases || "").trim()); + invalidateConfigCache(); + if (refreshCommands) { + refreshCommands(); + } + req.session.flash = { + type: "success", + message: "Currency settings updated." + }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/settings/platforms", (req, res) => { + if (!req.session.user?.isAdmin) { + return deny(res); + } + setPluginSetting(db, "platform_discord", req.body.platform_discord ? "1" : "0"); + setPluginSetting(db, "platform_twitch", req.body.platform_twitch ? "1" : "0"); + setPluginSetting(db, "platform_youtube", req.body.platform_youtube ? "1" : "0"); + invalidateConfigCache(); + if (refreshCommands) { + refreshCommands(); + } + req.session.flash = { + type: "success", + message: "Platform settings updated." + }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/settings/earn", (req, res) => { + if (!req.session.user?.isAdmin) { + return deny(res); + } + setPluginSetting( + db, + "earn_discord_message_enabled", + req.body.earn_discord_message_enabled ? "1" : "0" + ); + setPluginSetting( + db, + "earn_discord_message_amount", + (req.body.earn_discord_message_amount || "0").trim() + ); + setPluginSetting( + db, + "earn_discord_message_cooldown", + (req.body.earn_discord_message_cooldown || "0").trim() + ); + setPluginSetting( + db, + "earn_twitch_message_enabled", + req.body.earn_twitch_message_enabled ? "1" : "0" + ); + setPluginSetting( + db, + "earn_twitch_message_amount", + (req.body.earn_twitch_message_amount || "0").trim() + ); + setPluginSetting( + db, + "earn_twitch_message_cooldown", + (req.body.earn_twitch_message_cooldown || "0").trim() + ); + setPluginSetting( + db, + "earn_discord_voice_enabled", + req.body.earn_discord_voice_enabled ? "1" : "0" + ); + setPluginSetting( + db, + "earn_discord_voice_amount_per_min", + (req.body.earn_discord_voice_amount_per_min || "0").trim() + ); + setPluginSetting( + db, + "earn_discord_voice_tick_minutes", + (req.body.earn_discord_voice_tick_minutes || "1").trim() + ); + invalidateConfigCache(); + req.session.flash = { + type: "success", + message: "Earning rules updated." + }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/settings/tiers", (req, res) => { + if (!req.session.user?.isAdmin) { + return deny(res); + } + setPluginSetting( + db, + "tier_discord_booster_multiplier", + (req.body.tier_discord_booster_multiplier || "1").trim() + ); + setPluginSetting( + db, + "tier_twitch_sub_multiplier", + (req.body.tier_twitch_sub_multiplier || "1").trim() + ); + setPluginSetting( + db, + "tier_twitch_mod_multiplier", + (req.body.tier_twitch_mod_multiplier || "1").trim() + ); + setPluginSetting( + db, + "tier_twitch_vip_multiplier", + (req.body.tier_twitch_vip_multiplier || "1").trim() + ); + setPluginSetting( + db, + "tier_twitch_broadcaster_multiplier", + (req.body.tier_twitch_broadcaster_multiplier || "1").trim() + ); + invalidateConfigCache(); + req.session.flash = { + type: "success", + message: "Tier multipliers updated." + }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/settings/banking", (req, res) => { + if (!req.session.user?.isAdmin) { + return deny(res); + } + setPluginSetting(db, "banking_label", (req.body.banking_label || "").trim()); + setPluginSetting( + db, + "banking_enabled", + req.body.banking_enabled ? "1" : "0" + ); + setPluginSetting( + db, + "community_fund_name", + (req.body.community_fund_name || "").trim() + ); + setPluginSetting( + db, + "community_fund_name_plural", + (req.body.community_fund_name_plural || "").trim() + ); + invalidateConfigCache(); + req.session.flash = { + type: "success", + message: "Banking labels updated." + }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/settings/responses", (req, res) => { + if (!req.session.user?.isAdmin) { + return deny(res); + } + const key = (req.body.response_key || "").trim(); + if (!key) { + req.session.flash = { + type: "error", + message: "Response key is required." + }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + const mode = (req.body.response_mode || "random").trim(); + const texts = Array.isArray(req.body.response_text) + ? req.body.response_text + : [req.body.response_text]; + const weights = Array.isArray(req.body.response_weight) + ? req.body.response_weight + : [req.body.response_weight]; + const replies = (texts || []) + .map((text, index) => ({ + text: (text || "").trim(), + weight: Number(weights?.[index] || 1) + })) + .filter((entry) => entry.text); + const current = getResponseTemplates(db); + current[key] = { + ...current[key], + mode: mode === "weighted" ? "weighted" : "random", + replies: replies.length ? replies : current[key]?.replies || [] + }; + setPluginSetting(db, "response_templates", JSON.stringify(current)); + invalidateConfigCache(); + req.session.flash = { + type: "success", + message: "Responses updated." + }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/settings/icon", upload.single("currency_icon"), (req, res) => { + if (!req.session.user?.isAdmin) { + return deny(res); + } + if (!req.file) { + req.session.flash = { type: "error", message: "Upload a PNG icon." }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + const ext = path.extname(req.file.originalname || "").toLowerCase(); + if (ext && ext !== ".png") { + fs.rmSync(req.file.path, { force: true }); + req.session.flash = { type: "error", message: "Only PNG files are allowed." }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + const filename = `currency-${Date.now()}-${crypto.randomUUID()}.png`; + const targetPath = path.join(uploadDir, filename); + fs.renameSync(req.file.path, targetPath); + const relativePath = `/plugins/${PLUGIN_ID}/assets/${filename}`; + setPluginSetting(db, "currency_icon_path", relativePath); + invalidateConfigCache(); + req.session.flash = { + type: "success", + message: "Currency icon updated." + }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/accounts/adjust", (req, res) => { + if (!req.session.user || !req.session.user.isMod) { + return deny(res); + } + const targetName = (req.body.username || "").trim(); + const amount = parseSignedAmount(req.body.amount); + if (!targetName || !Number.isFinite(amount)) { + req.session.flash = { + type: "error", + message: "Username and amount are required." + }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + const target = findUserByInternalName(db, targetName); + if (!target) { + req.session.flash = { type: "error", message: "User not found." }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + const note = (req.body.note || "").trim(); + if (amount === 0) { + req.session.flash = { + type: "error", + message: "Amount must be non-zero." + }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + adjustBalance(db, { + userId: target.id, + amount, + note, + meta: { + actorId: req.session.user.id, + actorName: req.session.user.username + } + }); + req.session.flash = { + type: "success", + message: "Balance updated." + }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/funds/create", (req, res) => { + if (!req.session.user?.isAdmin) { + return deny(res); + } + const name = (req.body.name || "").trim(); + const description = (req.body.description || "").trim(); + const target = parseInt(req.body.target_amount || "0", 10); + if (!name) { + req.session.flash = { type: "error", message: "Fund name is required." }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + createFund(db, { + name, + description, + targetAmount: Number.isFinite(target) ? target : 0 + }); + req.session.flash = { type: "success", message: "Fund created." }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/funds/:id/update", (req, res) => { + if (!req.session.user?.isAdmin) { + return deny(res); + } + updateFund(db, { + id: req.params.id, + name: (req.body.name || "").trim(), + description: (req.body.description || "").trim(), + targetAmount: parseInt(req.body.target_amount || "0", 10), + status: req.body.status || "active" + }); + req.session.flash = { type: "success", message: "Fund updated." }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/events/create", (req, res) => { + if (!req.session.user?.isAdmin) { + return deny(res); + } + const name = (req.body.name || "").trim(); + const amount = parseInt(req.body.amount || "0", 10); + if (!name || !Number.isFinite(amount)) { + req.session.flash = { + type: "error", + message: "Event name and amount are required." + }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + const config = getConfig(db); + const events = getCustomEvents(config); + events.push({ id: crypto.randomUUID(), name, amount }); + setPluginSetting(db, "custom_events", JSON.stringify(events)); + invalidateConfigCache(); + req.session.flash = { type: "success", message: "Event added." }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/events/:id/delete", (req, res) => { + if (!req.session.user?.isAdmin) { + return deny(res); + } + const config = getConfig(db); + const events = getCustomEvents(config).filter( + (event) => event.id !== req.params.id + ); + setPluginSetting(db, "custom_events", JSON.stringify(events)); + invalidateConfigCache(); + req.session.flash = { type: "success", message: "Event removed." }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + const bankRouter = web.createRouter(); + bankRouter.use((req, res, next) => { + if (!req.session.user) { + return res.redirect("/"); + } + const config = getConfig(db); + if (!config.banking.enabled) { + return res.redirect("/profile"); + } + req.bankingConfig = config; + next(); + }); + + bankRouter.get("/", (req, res) => { + const config = req.bankingConfig || getConfig(db); + const user = req.session.user; + const userStats = buildUserStats(db, user.id); + const transactions = listTransactions(db, { + userId: user.id, + limit: 1000 + }); + const funds = listFunds(db).filter((fund) => fund.status === "active"); + const userDirectory = listUserDirectory(db); + + res.render(path.join(__dirname, "views", "banking.ejs"), { + title: config.banking.label, + config, + user, + userStats, + transactions, + funds, + userDirectory + }); + }); + + bankRouter.post("/transfer", (req, res) => { + if (!req.session.user) { + return res.redirect("/"); + } + const config = req.bankingConfig || getConfig(db); + const targetName = (req.body.username || "").trim(); + const amount = parseAmount(req.body.amount); + const note = (req.body.note || "").trim(); + if (!targetName || !Number.isFinite(amount)) { + req.session.flash = { + type: "error", + message: "Recipient and amount are required." + }; + return res.redirect("/profile/banking"); + } + const cooldownLeft = getCooldownLeft(req.session.user.id, config); + if (cooldownLeft > 0) { + req.session.flash = { + type: "error", + message: `Please wait ${cooldownLeft}s before sending again.` + }; + return res.redirect("/profile/banking"); + } + const target = findUserByInternalName(db, targetName.replace(/^@/, "")); + if (!target) { + req.session.flash = { type: "error", message: "User not found." }; + return res.redirect("/profile/banking"); + } + if (target.id === req.session.user.id) { + req.session.flash = { + type: "error", + message: "You cannot transfer funds to yourself." + }; + return res.redirect("/profile/banking"); + } + const success = transferBalance(db, { + fromUserId: req.session.user.id, + toUserId: target.id, + amount, + note, + meta: { source: "banking_ui" } + }); + if (!success.ok) { + req.session.flash = { type: "error", message: success.message }; + return res.redirect("/profile/banking"); + } + setCooldown(req.session.user.id); + req.session.flash = { type: "success", message: "Transfer completed." }; + return res.redirect("/profile/banking"); + }); + + bankRouter.post("/funds/:id/donate", (req, res) => { + if (!req.session.user) { + return res.redirect("/"); + } + const config = req.bankingConfig || getConfig(db); + const amount = parseAmount(req.body.amount); + const note = (req.body.note || "").trim(); + if (!Number.isFinite(amount)) { + req.session.flash = { + type: "error", + message: "Enter a valid amount." + }; + return res.redirect("/profile/banking"); + } + const cooldownLeft = getCooldownLeft(req.session.user.id, config); + if (cooldownLeft > 0) { + req.session.flash = { + type: "error", + message: `Please wait ${cooldownLeft}s before donating again.` + }; + return res.redirect("/profile/banking"); + } + const fund = db + .prepare("SELECT * FROM echonomy_pots WHERE id = ?") + .get(req.params.id); + if (!fund || fund.status !== "active") { + req.session.flash = { + type: "error", + message: "That fund is not active." + }; + return res.redirect("/profile/banking"); + } + const result = spendBalance(db, { + userId: req.session.user.id, + amount, + note: note || `Donation to ${fund.name}`, + meta: { fundId: fund.id, source: "banking_ui" } + }); + if (!result.ok) { + req.session.flash = { type: "error", message: result.message }; + return res.redirect("/profile/banking"); + } + addFundContribution(db, fund.id, req.session.user.id, amount); + setCooldown(req.session.user.id); + req.session.flash = { type: "success", message: "Donation completed." }; + return res.redirect("/profile/banking"); + }); + + web.mount(`/plugins/${PLUGIN_ID}`, router, { + label: "Echonomy", + role: "public", + section: "plugins" + }); + web.mount("/profile/banking", bankRouter); + } +}; + +function deny(res) { + return res.status(403).render("error", { + title: "Access denied", + message: "You do not have access to that page." + }); +} + +function ensureTables(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS echonomy_accounts ( + user_id TEXT PRIMARY KEY, + balance INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS echonomy_transactions ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + amount INTEGER NOT NULL, + from_user_id TEXT, + to_user_id TEXT, + note TEXT, + meta TEXT, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS echonomy_pots ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + target_amount INTEGER NOT NULL DEFAULT 0, + current_amount INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS echonomy_pot_contributions ( + id TEXT PRIMARY KEY, + pot_id TEXT NOT NULL, + user_id TEXT NOT NULL, + amount INTEGER NOT NULL, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS echonomy_activity_reward_hourly ( + user_id TEXT NOT NULL, + hour_start INTEGER NOT NULL, + source TEXT NOT NULL, + amount INTEGER NOT NULL DEFAULT 0, + hits INTEGER NOT NULL DEFAULT 0, + minutes INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, hour_start, source) + ); + + CREATE INDEX IF NOT EXISTS echonomy_transactions_created_at_idx + ON echonomy_transactions (created_at); + + CREATE INDEX IF NOT EXISTS echonomy_activity_reward_hourly_hour_idx + ON echonomy_activity_reward_hourly (hour_start); + `); +} + +function ensureDefaults(db) { + const existing = getPluginSettings(db); + for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) { + if (existing[key] === undefined) { + setPluginSetting(db, key, value); + } + } +} + +function getPluginSettings(db) { + const rows = db + .prepare("SELECT key, value FROM plugin_settings WHERE plugin_id = ?") + .all(PLUGIN_ID); + return rows.reduce((acc, row) => { + acc[row.key] = row.value; + return acc; + }, {}); +} + +function setPluginSetting(db, key, value) { + db.prepare( + "INSERT INTO plugin_settings (plugin_id, key, value, updated_at) VALUES (?, ?, ?, ?) " + + "ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at" + ).run(PLUGIN_ID, key, value, Date.now()); +} + +function parseBoolean(value, fallback) { + if (value === undefined || value === null || value === "") { + return fallback; + } + if (typeof value === "boolean") { + return value; + } + const normalized = value.toString().toLowerCase(); + return ["1", "true", "yes", "on"].includes(normalized); +} + +function parseNumber(value, fallback) { + if (value === undefined || value === null || value === "") { + return fallback; + } + const number = Number(value); + if (!Number.isFinite(number)) { + return fallback; + } + return number; +} + +function parseJson(value, fallback) { + if (value === undefined || value === null || value === "") { + return fallback; + } + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function parseList(value) { + return (value || "") + .split(/[\,\s]+/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function normalizeCommandRoot(value) { + const raw = (value || "").trim().replace(/^!+/, ""); + if (!raw) { + return ""; + } + return raw.toLowerCase().replace(/\s+/g, "-"); +} + +function buildPlural(name) { + if (!name) { + return ""; + } + if (name.endsWith("s")) { + return name; + } + return `${name}s`; +} + +function getConfig(db) { + const now = Date.now(); + if (cachedConfig && now - cachedConfigAt < 2000) { + return cachedConfig; + } + const settings = getPluginSettings(db); + const currencyName = settings.currency_name || DEFAULT_SETTINGS.currency_name; + const currencyPlural = + settings.currency_name_plural || + buildPlural(currencyName) || + DEFAULT_SETTINGS.currency_name_plural; + const bankingLabel = + settings.banking_label || DEFAULT_SETTINGS.banking_label || "Banking"; + const bankingEnabled = parseBoolean(settings.banking_enabled, true); + const fundName = + settings.community_fund_name || DEFAULT_SETTINGS.community_fund_name; + const fundPlural = + settings.community_fund_name_plural || + buildPlural(fundName) || + DEFAULT_SETTINGS.community_fund_name_plural; + const root = normalizeCommandRoot(settings.command_root || currencyPlural); + const aliases = parseList(settings.command_aliases); + const responseTemplates = buildResponseTemplates( + parseJson(settings.response_templates, null) + ); + const config = { + currency: { + name: currencyName, + plural: currencyPlural, + icon: settings.currency_icon_path || "" + }, + banking: { + label: bankingLabel, + enabled: bankingEnabled + }, + communityFunds: { + name: fundName || "Community fund", + plural: fundPlural || "Community funds" + }, + command: { + root: root || normalizeCommandRoot(currencyPlural) || "coins", + aliases + }, + platforms: { + discord: parseBoolean(settings.platform_discord, true), + twitch: parseBoolean(settings.platform_twitch, true), + youtube: parseBoolean(settings.platform_youtube, true) + }, + cooldownSeconds: parseNumber(settings.transfer_cooldown_seconds, 10), + earn: { + discordMessage: { + enabled: parseBoolean(settings.earn_discord_message_enabled, true), + amount: parseNumber(settings.earn_discord_message_amount, 1), + cooldown: parseNumber(settings.earn_discord_message_cooldown, 30) + }, + twitchMessage: { + enabled: parseBoolean(settings.earn_twitch_message_enabled, true), + amount: parseNumber(settings.earn_twitch_message_amount, 1), + cooldown: parseNumber(settings.earn_twitch_message_cooldown, 30) + }, + discordVoice: { + enabled: parseBoolean(settings.earn_discord_voice_enabled, false), + amountPerMin: parseNumber(settings.earn_discord_voice_amount_per_min, 2), + tickMinutes: parseNumber(settings.earn_discord_voice_tick_minutes, 1) + } + }, + tiers: { + discordBooster: parseNumber(settings.tier_discord_booster_multiplier, 1.25), + twitchSub: parseNumber(settings.tier_twitch_sub_multiplier, 1.5), + twitchMod: parseNumber(settings.tier_twitch_mod_multiplier, 1.2), + twitchVip: parseNumber(settings.tier_twitch_vip_multiplier, 1.1), + twitchBroadcaster: parseNumber(settings.tier_twitch_broadcaster_multiplier, 2.0) + }, + responses: responseTemplates, + eventsRaw: settings.custom_events || "[]" + }; + cachedConfig = config; + cachedConfigAt = now; + return config; +} + +function getCustomEvents(config) { + try { + const events = JSON.parse(config.eventsRaw || "[]"); + if (Array.isArray(events)) { + return events + .map((event) => ({ + id: event.id, + name: event.name, + amount: Number(event.amount || 0) + })) + .filter((event) => event.id && event.name); + } + } catch { + // ignore invalid custom event config + } + return []; +} + +function normalizeReplies(list, fallback) { + const source = Array.isArray(list) ? list : []; + const cleaned = source + .map((entry) => ({ + text: (entry?.text || "").toString().trim(), + weight: Number(entry?.weight || 1) + })) + .filter((entry) => entry.text); + if (cleaned.length) { + return cleaned; + } + const fallbackList = Array.isArray(fallback) ? fallback : []; + return fallbackList.map((entry) => ({ + text: (entry?.text || "").toString(), + weight: Number(entry?.weight || 1) + })); +} + +function buildResponseTemplates(raw) { + const parsed = raw && typeof raw === "object" ? raw : {}; + const templates = {}; + for (const [key, base] of Object.entries(DEFAULT_RESPONSES)) { + const override = parsed[key] || {}; + templates[key] = { + key, + label: base.label || key, + mode: override.mode === "weighted" ? "weighted" : base.mode || "random", + replies: normalizeReplies(override.replies, base.replies) + }; + } + for (const [key, entry] of Object.entries(parsed)) { + if (templates[key]) { + continue; + } + templates[key] = { + key, + label: entry?.label || key, + mode: entry?.mode === "weighted" ? "weighted" : "random", + replies: normalizeReplies(entry?.replies, []) + }; + } + return templates; +} + +function getResponseTemplates(db) { + const settings = getPluginSettings(db); + return buildResponseTemplates(parseJson(settings.response_templates, {})); +} + +function invalidateConfigCache() { + cachedConfig = null; + cachedConfigAt = 0; +} + +function getHourStart(timestamp = Date.now()) { + const hourMs = 60 * 60 * 1000; + return Math.floor(timestamp / hourMs) * hourMs; +} + +function queueActivityReward( + db, + { userId, source, amount, hits = 1, minutes = 0, occurredAt = Date.now() } +) { + const numericAmount = Number(amount || 0); + if (!userId || !source || !Number.isFinite(numericAmount) || numericAmount <= 0) { + return; + } + const hourStart = getHourStart(occurredAt); + const numericHits = Number.isFinite(Number(hits)) ? Number(hits) : 0; + const numericMinutes = Number.isFinite(Number(minutes)) ? Number(minutes) : 0; + db.prepare( + "INSERT INTO echonomy_activity_reward_hourly (user_id, hour_start, source, amount, hits, minutes) " + + "VALUES (?, ?, ?, ?, ?, ?) " + + "ON CONFLICT(user_id, hour_start, source) DO UPDATE SET " + + "amount = amount + excluded.amount, " + + "hits = hits + excluded.hits, " + + "minutes = minutes + excluded.minutes" + ).run( + userId, + hourStart, + source, + Math.floor(numericAmount), + Math.max(0, Math.floor(numericHits)), + Math.max(0, Math.floor(numericMinutes)) + ); +} + +function startActivityRewardFlusher(db) { + flushActivityRewards(db); + if (activityFlushTimer) { + return; + } + activityFlushTimer = setInterval(() => { + try { + flushActivityRewards(db); + } catch (error) { + console.error("Activity reward flush failed", error); + } + }, 60 * 1000); +} + +function flushActivityRewards(db) { + const currentHourStart = getHourStart(); + const rows = db + .prepare( + "SELECT user_id, hour_start, source, amount, hits, minutes " + + "FROM echonomy_activity_reward_hourly " + + "WHERE hour_start < ? " + + "ORDER BY hour_start ASC" + ) + .all(currentHourStart); + if (!rows.length) { + return; + } + + const groups = new Map(); + rows.forEach((row) => { + const key = `${row.user_id}:${row.hour_start}`; + if (!groups.has(key)) { + groups.set(key, { + userId: row.user_id, + hourStart: row.hour_start, + rows: [] + }); + } + groups.get(key).rows.push(row); + }); + + for (const group of groups.values()) { + const rewards = group.rows.map((entry) => ({ + source: entry.source, + amount: Number(entry.amount || 0), + hits: Number(entry.hits || 0), + minutes: Number(entry.minutes || 0), + label: ACTIVITY_REWARD_SOURCES[entry.source] || entry.source + })); + const totalAmount = rewards.reduce( + (sum, entry) => sum + Math.max(0, Number(entry.amount || 0)), + 0 + ); + if (totalAmount <= 0) { + db.prepare( + "DELETE FROM echonomy_activity_reward_hourly WHERE user_id = ? AND hour_start = ?" + ).run(group.userId, group.hourStart); + continue; + } + try { + grantBalance(db, { + userId: group.userId, + amount: totalAmount, + note: ACTIVITY_REWARD_NOTE, + meta: { + source: "activity_reward", + hourStart: group.hourStart, + hourEnd: group.hourStart + 60 * 60 * 1000, + rewards + } + }); + db.prepare( + "DELETE FROM echonomy_activity_reward_hourly WHERE user_id = ? AND hour_start = ?" + ).run(group.userId, group.hourStart); + } catch (error) { + console.error("Failed to apply queued activity reward", error); + } + } +} + +function registerFramework(api) { + if (!global.lumiFrameworks) { + global.lumiFrameworks = {}; + } + global.lumiFrameworks.echonomy = api; +} + +function buildApi({ db }) { + return { + getConfig: () => getConfig(db), + getBalance: (userId) => getBalance(db, userId), + addBalance: ({ userId, amount, note, meta, allowFrozen }) => + grantBalance(db, { userId, amount, note, meta, allowFrozen }), + removeBalance: ({ userId, amount, note, meta, allowFrozen }) => + spendBalance(db, { userId, amount, note, meta, allowFrozen }), + transferBalance: ({ fromUserId, toUserId, amount, note, meta, allowFrozen }) => + transferBalance(db, { fromUserId, toUserId, amount, note, meta, allowFrozen }), + createTransaction: (payload) => applyTransaction(db, payload), + on: (event, handler) => emitter.on(event, handler), + off: (event, handler) => emitter.off(event, handler) + }; +} + +function registerCommands({ db, settings, commandRouter }) { + if (!commandRouter) { + return null; + } + const rebuild = () => { + const config = getConfig(db); + const platforms = []; + if (config.platforms.discord) { + platforms.push("discord"); + } + if (config.platforms.twitch) { + platforms.push("twitch"); + } + if (config.platforms.youtube) { + platforms.push("youtube"); + } + if (!platforms.length) { + commandRouter.registerCommands(PLUGIN_ID, []); + return; + } + const triggers = [config.command.root, ...config.command.aliases]; + commandRouter.registerCommands(PLUGIN_ID, [ + { + id: "echonomy:root", + triggers, + platforms, + handler: (ctx) => handleCoinsCommand({ ctx, db, settings }) + } + ]); + }; + rebuild(); + return rebuild; +} + +async function handleCoinsCommand({ ctx, db, settings }) { + const config = getConfig(db); + const prefix = settings.getSetting("command_prefix", "!"); + const root = config.command.root; + const subcommand = (ctx.args[0] || "balance").toLowerCase(); + const args = ctx.args.slice(1); + const usageRoot = `${prefix}${root}`; + const baseTokens = { + currency_name: config.currency.name, + currency_plural: config.currency.plural, + funds_label: config.communityFunds.plural, + fund_label: config.communityFunds.name + }; + + if (subcommand === "help") { + await respond(ctx, config, "help", { + ...baseTokens, + help: buildHelpText({ prefix, root }) + }); + return true; + } + + if (["balance", "bal", "me"].includes(subcommand)) { + const balance = getBalance(db, ctx.user.id); + await respond(ctx, config, "balance_self", { + ...baseTokens, + balance, + balance_text: formatCurrency(balance, config) + }); + return true; + } + + if (["top", "leaderboard"].includes(subcommand)) { + const top = listTopBalances(db, 5); + if (!top.length) { + await respond(ctx, config, "top_empty", baseTokens); + return true; + } + const lines = top + .map((entry, index) => `${index + 1}. ${entry.username}: ${entry.balance}`) + .join(" | "); + await respond(ctx, config, "top_list", { + ...baseTokens, + lines + }); + return true; + } + + if (subcommand === "stats") { + const stats = buildGlobalStats(db); + await respond(ctx, config, "stats", { + ...baseTokens, + total_balance: stats.totalBalance, + total_balance_text: formatCurrency(stats.totalBalance, config), + total_spent: stats.totalSpent, + total_spent_text: formatCurrency(stats.totalSpent, config) + }); + return true; + } + + if (["pay", "give", "transfer"].includes(subcommand)) { + const targetToken = args[0]; + const amount = parseAmount(args[1]); + if (!targetToken || !Number.isFinite(amount)) { + await respond(ctx, config, "pay_missing", { + ...baseTokens, + usage: `${usageRoot} pay [note]` + }); + return true; + } + const cooldownLeft = getCooldownLeft(ctx.user.id, config); + if (cooldownLeft > 0) { + await respond(ctx, config, "pay_cooldown", { + ...baseTokens, + cooldown: cooldownLeft + }); + return true; + } + const note = args.slice(2).join(" ").trim(); + const target = await resolveTargetUser(db, ctx, targetToken); + if (!target) { + await respond(ctx, config, "pay_not_found", baseTokens); + return true; + } + if (target.profile.id === ctx.user.id) { + await respond(ctx, config, "pay_self", baseTokens); + return true; + } + const success = transferBalance(db, { + fromUserId: ctx.user.id, + toUserId: target.profile.id, + amount, + note, + meta: { platform: ctx.platform } + }); + if (!success.ok) { + await respond(ctx, config, "pay_insufficient", { + ...baseTokens, + reason: success.message || "Transfer failed." + }); + return true; + } + setCooldown(ctx.user.id); + await respond(ctx, config, "pay_success", { + ...baseTokens, + amount, + amount_text: formatCurrency(amount, config), + target: target.label + }); + return true; + } + + if (["grant", "giveadmin"].includes(subcommand)) { + const role = getRoleFlags(ctx); + if (!role.isAdmin && !role.isMod) { + await respond(ctx, config, "permission_denied", baseTokens); + return true; + } + const targetToken = args[0]; + const amount = parseAmount(args[1]); + if (!targetToken || !Number.isFinite(amount)) { + await respond(ctx, config, "pay_missing", { + ...baseTokens, + usage: `${usageRoot} grant [note]` + }); + return true; + } + const note = args.slice(2).join(" ").trim(); + const target = await resolveTargetUser(db, ctx, targetToken); + if (!target) { + await respond(ctx, config, "pay_not_found", baseTokens); + return true; + } + grantBalance(db, { + userId: target.profile.id, + amount, + note, + meta: { actorId: ctx.user.id, platform: ctx.platform } + }); + await respond(ctx, config, "grant_success", { + ...baseTokens, + amount, + amount_text: formatCurrency(amount, config), + target: target.label + }); + return true; + } + + if (["take", "remove"].includes(subcommand)) { + const role = getRoleFlags(ctx); + if (!role.isAdmin && !role.isMod) { + await respond(ctx, config, "permission_denied", baseTokens); + return true; + } + const targetToken = args[0]; + const amount = parseAmount(args[1]); + if (!targetToken || !Number.isFinite(amount)) { + await respond(ctx, config, "pay_missing", { + ...baseTokens, + usage: `${usageRoot} take [note]` + }); + return true; + } + const note = args.slice(2).join(" ").trim(); + const target = await resolveTargetUser(db, ctx, targetToken); + if (!target) { + await respond(ctx, config, "pay_not_found", baseTokens); + return true; + } + spendBalance(db, { + userId: target.profile.id, + amount, + note, + meta: { actorId: ctx.user.id, platform: ctx.platform } + }); + await respond(ctx, config, "take_success", { + ...baseTokens, + amount, + amount_text: formatCurrency(amount, config), + target: target.label + }); + return true; + } + + if (["funds", "fund", "goals"].includes(subcommand)) { + const funds = listFunds(db); + if (!funds.length) { + await respond(ctx, config, "funds_empty", baseTokens); + return true; + } + const lines = funds + .map((fund) => `${fund.name}: ${fund.current_amount}/${fund.target_amount}`) + .join(" | "); + await respond(ctx, config, "funds_list", { + ...baseTokens, + lines + }); + return true; + } + + if (subcommand === "donate") { + const fundName = args[0]; + const amount = parseAmount(args[1]); + if (!fundName || !Number.isFinite(amount)) { + await respond(ctx, config, "fund_missing", { + ...baseTokens, + usage: `${usageRoot} donate ` + }); + return true; + } + const cooldownLeft = getCooldownLeft(ctx.user.id, config); + if (cooldownLeft > 0) { + await respond(ctx, config, "pay_cooldown", { + ...baseTokens, + cooldown: cooldownLeft + }); + return true; + } + const fund = findFund(db, fundName); + if (!fund || fund.status !== "active") { + await respond(ctx, config, "fund_not_found", baseTokens); + return true; + } + const success = spendBalance(db, { + userId: ctx.user.id, + amount, + note: `Donation to ${fund.name}`, + meta: { fundId: fund.id } + }); + if (!success.ok) { + await respond(ctx, config, "pay_insufficient", { + ...baseTokens, + reason: success.message || "Donation failed." + }); + return true; + } + addFundContribution(db, fund.id, ctx.user.id, amount); + setCooldown(ctx.user.id); + await respond(ctx, config, "fund_donate_success", { + ...baseTokens, + amount, + amount_text: formatCurrency(amount, config), + fund: fund.name + }); + return true; + } + + if (subcommand === "reward") { + const role = getRoleFlags(ctx); + if (!role.isAdmin && !role.isMod) { + await respond(ctx, config, "permission_denied", baseTokens); + return true; + } + const eventKey = args[0]; + const targetToken = args[1]; + if (!eventKey || !targetToken) { + await respond(ctx, config, "reward_missing", { + ...baseTokens, + usage: `${usageRoot} reward ` + }); + return true; + } + const event = getCustomEvents(config).find( + (entry) => entry.id === eventKey || entry.name.toLowerCase() === eventKey.toLowerCase() + ); + if (!event) { + await respond(ctx, config, "reward_not_found", baseTokens); + return true; + } + const target = await resolveTargetUser(db, ctx, targetToken); + if (!target) { + await respond(ctx, config, "pay_not_found", baseTokens); + return true; + } + grantBalance(db, { + userId: target.profile.id, + amount: event.amount, + note: `Event reward: ${event.name}`, + meta: { eventId: event.id } + }); + await respond(ctx, config, "reward_success", { + ...baseTokens, + amount: event.amount, + amount_text: formatCurrency(event.amount, config), + target: target.label + }); + return true; + } + + await respond(ctx, config, "help", { + ...baseTokens, + help: buildHelpText({ prefix, root }) + }); + return true; +} + +function buildHelpText({ prefix, root }) { + return ( + `Commands: ${prefix}${root} balance | ${prefix}${root} pay | ` + + `${prefix}${root} top | ${prefix}${root} stats | ${prefix}${root} funds | ` + + `${prefix}${root} donate ` + ); +} + +function getRoleFlags(ctx) { + if (ctx.platform === "discord") { + const roles = ctx.meta?.message?.member?.roles?.cache; + if (!roles) { + return { isAdmin: false, isMod: false }; + } + const adminIds = parseList(settingsApi?.getSetting?.("discord_admin_role_id")); + const modIds = parseList(settingsApi?.getSetting?.("discord_mod_role_id")); + const roleIds = Array.from(roles.keys()); + const isAdmin = roleIds.some((roleId) => adminIds.includes(roleId)); + const isMod = roleIds.some((roleId) => modIds.includes(roleId)); + return { isAdmin, isMod }; + } + if (ctx.platform === "twitch") { + const badges = ctx.meta?.tags?.badges || {}; + const isAdmin = Boolean(badges.broadcaster); + const isMod = Boolean(ctx.meta?.tags?.mod || badges.moderator); + return { isAdmin, isMod }; + } + if (ctx.platform === "youtube") { + const author = ctx.meta?.author || {}; + const isAdmin = Boolean(author.isChatOwner); + const isMod = Boolean(author.isChatModerator); + return { isAdmin, isMod }; + } + return { isAdmin: false, isMod: false }; +} + +function parseAmount(value) { + if (value === undefined || value === null) { + return NaN; + } + const number = Number(value); + if (!Number.isFinite(number)) { + return NaN; + } + if (number <= 0) { + return NaN; + } + return Math.floor(number); +} + +function parseSignedAmount(value) { + if (value === undefined || value === null) { + return NaN; + } + const number = Number(value); + if (!Number.isFinite(number)) { + return NaN; + } + if (number === 0) { + return 0; + } + const rounded = number > 0 ? Math.floor(number) : Math.ceil(number); + return rounded; +} + +function formatCurrency(amount, config) { + const name = amount === 1 ? config.currency.name : config.currency.plural; + return `${amount} ${name}`; +} + +function pickResponse(template) { + const replies = Array.isArray(template?.replies) ? template.replies : []; + if (!replies.length) { + return ""; + } + if (template.mode === "weighted") { + const total = replies.reduce( + (sum, entry) => sum + Math.max(0, Number(entry.weight || 0)), + 0 + ); + if (total > 0) { + let roll = Math.random() * total; + for (const entry of replies) { + roll -= Math.max(0, Number(entry.weight || 0)); + if (roll <= 0) { + return entry.text || ""; + } + } + } + } + const fallback = replies[Math.floor(Math.random() * replies.length)]; + return fallback?.text || ""; +} + +function renderTemplate(text, tokens) { + const safeText = (text || "").toString(); + return safeText.replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, key) => { + if (Object.prototype.hasOwnProperty.call(tokens, key)) { + return tokens[key]; + } + return `{${key}}`; + }); +} + +function buildResponse(config, key, tokens) { + const template = config.responses?.[key] || DEFAULT_RESPONSES[key]; + if (!template) { + return ""; + } + const text = pickResponse(template); + if (!text) { + return ""; + } + return renderTemplate(text, tokens); +} + +async function respond(ctx, config, key, tokens) { + const message = buildResponse(config, key, tokens); + if (!message) { + return; + } + await ctx.reply(message); +} + +function escapeHtml(value) { + return (value || "") + .toString() + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function installProfileHook(app, getConfig) { + if (!app || app.__echonomyProfileHookInstalled) { + return; + } + app.__echonomyProfileHookInstalled = true; + const originalRender = app.render.bind(app); + app.render = (view, options, callback) => { + if (typeof options === "function") { + callback = options; + options = {}; + } + if (typeof callback !== "function" || view !== "profile") { + return originalRender(view, options, callback); + } + const config = getConfig ? getConfig() : null; + if (!config?.banking?.enabled) { + return originalRender(view, options, callback); + } + return originalRender(view, options, (err, html) => { + if (err) { + return callback(err); + } + try { + if (!html.includes('href="/profile/banking"')) { + const label = escapeHtml(config.banking.label || "Banking"); + const marker = '
'; + if (html.includes(marker)) { + html = html.replace( + marker, + `${marker}\n ${label}` + ); + } + } + } catch { + // ignore injection errors + } + return callback(null, html); + }); + }; +} + +function getCooldownLeft(userId, config) { + const last = transferCooldowns.get(userId) || 0; + const now = Date.now(); + const cooldown = (config.cooldownSeconds || 10) * 1000; + const diff = cooldown - (now - last); + return diff > 0 ? Math.ceil(diff / 1000) : 0; +} + +function setCooldown(userId) { + transferCooldowns.set(userId, Date.now()); +} + +async function resolveTargetUser(db, ctx, token) { + if (!token) { + return null; + } + if (ctx.platform === "discord") { + const message = ctx.meta?.message; + if (message?.mentions?.users?.first) { + const mention = message.mentions.users.first(); + const display = + mention.globalName || mention.username || mention.tag || mention.id; + const profile = ensureUserForIdentity({ + provider: "discord", + providerUserId: mention.id, + displayName: display, + avatar: mention.avatar + ? `https://cdn.discordapp.com/avatars/${mention.id}/${mention.avatar}.png?size=128` + : null + }); + return { profile, label: `<@${mention.id}>` }; + } + const idMatch = token.match(/^<@!?(\d+)>$/) || token.match(/^(\d{15,})$/); + if (idMatch) { + const profile = ensureUserForIdentity({ + provider: "discord", + providerUserId: idMatch[1], + displayName: idMatch[1] + }); + return { profile, label: `<@${idMatch[1]}>` }; + } + } + + const cleaned = token.replace(/^@/, "").trim(); + if (!cleaned) { + return null; + } + + const internal = findUserByInternalName(db, cleaned); + if (internal) { + return { profile: internal, label: internal.internal_username }; + } + + if (ctx.platform === "twitch") { + const profile = ensureUserForIdentity({ + provider: "twitch_login", + providerUserId: cleaned.toLowerCase(), + displayName: cleaned, + fallbackName: cleaned + }); + return { profile, label: `@${cleaned}` }; + } + + if (ctx.platform === "youtube") { + const profile = ensureUserForIdentity({ + provider: "youtube_name", + providerUserId: cleaned.toLowerCase(), + displayName: cleaned, + fallbackName: cleaned + }); + return { profile, label: cleaned }; + } + + const profile = ensureUserForIdentity({ + provider: "echonomy_name", + providerUserId: cleaned.toLowerCase(), + displayName: cleaned, + fallbackName: cleaned + }); + return { profile, label: cleaned }; +} + +function findUserByInternalName(db, name) { + return db + .prepare( + "SELECT id, internal_username FROM user_profiles WHERE lower(internal_username) = lower(?)" + ) + .get(name); +} + +function ensureAccount(db, userId) { + db.prepare( + "INSERT INTO echonomy_accounts (user_id, balance, updated_at) VALUES (?, 0, ?) " + + "ON CONFLICT(user_id) DO UPDATE SET updated_at = excluded.updated_at" + ).run(userId, Date.now()); +} + +function getBalance(db, userId) { + if (!userId) { + return 0; + } + const row = db + .prepare("SELECT balance FROM echonomy_accounts WHERE user_id = ?") + .get(userId); + return row?.balance ?? 0; +} + +function updateBalance(db, userId, delta) { + ensureAccount(db, userId); + db.prepare( + "UPDATE echonomy_accounts SET balance = balance + ?, updated_at = ? WHERE user_id = ?" + ).run(delta, Date.now(), userId); +} + +function isFrozenUser(userId) { + try { + return Boolean(global.lumiModeration?.isFrozen?.(userId)); + } catch { + return false; + } +} + +function applyTransaction(db, payload) { + const amount = Math.abs(Number(payload.amount)); + if (!Number.isFinite(amount) || amount <= 0) { + throw new Error("Invalid amount."); + } + const id = payload.id || crypto.randomUUID(); + const now = Date.now(); + const fromUserId = payload.fromUserId || null; + const toUserId = payload.toUserId || null; + const note = payload.note || null; + const meta = payload.meta ? JSON.stringify(payload.meta) : null; + if (!payload.allowFrozen) { + if (fromUserId && isFrozenUser(fromUserId)) { + throw new Error("Account is frozen."); + } + if (toUserId && isFrozenUser(toUserId)) { + throw new Error("Account is frozen."); + } + } + + db.transaction(() => { + if (fromUserId) { + ensureAccount(db, fromUserId); + const current = getBalance(db, fromUserId); + if (!payload.allowNegative && current < amount) { + throw new Error("Insufficient balance."); + } + updateBalance(db, fromUserId, -amount); + } + if (toUserId) { + ensureAccount(db, toUserId); + updateBalance(db, toUserId, amount); + } + db.prepare( + "INSERT INTO echonomy_transactions (id, type, amount, from_user_id, to_user_id, note, meta, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ).run( + id, + payload.type || "transaction", + amount, + fromUserId, + toUserId, + note, + meta, + now + ); + })(); + + emitter.emit("transaction", { + id, + type: payload.type, + amount, + fromUserId, + toUserId, + note, + meta: payload.meta || null, + createdAt: now + }); + return id; +} + +function transferBalance(db, { fromUserId, toUserId, amount, note, meta, allowFrozen }) { + try { + applyTransaction(db, { + type: "transfer", + amount, + fromUserId, + toUserId, + note, + meta, + allowNegative: false, + allowFrozen: Boolean(allowFrozen) + }); + return { ok: true }; + } catch (error) { + return { ok: false, message: error.message || "Transfer failed." }; + } +} + +function grantBalance(db, { userId, amount, note, meta, allowFrozen }) { + return applyTransaction(db, { + type: "earn", + amount, + fromUserId: null, + toUserId: userId, + note, + meta, + allowNegative: true, + allowFrozen: Boolean(allowFrozen) + }); +} + +function spendBalance(db, { userId, amount, note, meta, allowFrozen }) { + try { + applyTransaction(db, { + type: "spend", + amount, + fromUserId: userId, + toUserId: null, + note, + meta, + allowNegative: false, + allowFrozen: Boolean(allowFrozen) + }); + return { ok: true }; + } catch (error) { + return { ok: false, message: error.message || "Spend failed." }; + } +} + +function adjustBalance(db, { userId, amount, note, meta }) { + if (amount === 0) { + return; + } + if (amount > 0) { + applyTransaction(db, { + type: "adjust", + amount, + fromUserId: null, + toUserId: userId, + note, + meta, + allowNegative: true + }); + return; + } + applyTransaction(db, { + type: "adjust", + amount: Math.abs(amount), + fromUserId: userId, + toUserId: null, + note, + meta, + allowNegative: true + }); +} + +function listTransactions(db, { userId, limit }) { + const params = []; + let where = ""; + if (userId) { + where = "WHERE t.from_user_id = ? OR t.to_user_id = ?"; + params.push(userId, userId); + } + params.push(limit || 100); + return db + .prepare( + "SELECT t.*, fromUser.internal_username AS from_name, toUser.internal_username AS to_name " + + "FROM echonomy_transactions t " + + "LEFT JOIN user_profiles AS fromUser ON fromUser.id = t.from_user_id " + + "LEFT JOIN user_profiles AS toUser ON toUser.id = t.to_user_id " + + `${where} ORDER BY t.created_at DESC LIMIT ?` + ) + .all(...params) + .map((row) => normalizeTransactionRow(row)); +} + +function normalizeTransactionRow(row) { + const tx = { ...row }; + const note = (row.note || "").toString(); + const meta = parseTransactionMeta(row.meta); + tx.meta_object = meta; + tx.note_display = note || "-"; + tx.note_search = note || ""; + tx.activity_reward = null; + + if (meta?.source === "activity_reward") { + const rewards = Array.isArray(meta.rewards) + ? meta.rewards + .map((entry) => ({ + source: (entry?.source || "").toString(), + label: + ACTIVITY_REWARD_SOURCES[(entry?.source || "").toString()] || + (entry?.label || entry?.source || "Activity"), + amount: Number(entry?.amount || 0), + hits: Number(entry?.hits || 0), + minutes: Number(entry?.minutes || 0) + })) + .filter((entry) => entry.amount > 0) + : []; + tx.activity_reward = { + hourStart: Number(meta.hourStart || 0), + hourEnd: Number(meta.hourEnd || 0), + rewards + }; + tx.note_display = ACTIVITY_REWARD_NOTE; + tx.note_search = [ + ACTIVITY_REWARD_NOTE, + ...rewards.map((entry) => `${entry.label} ${entry.amount} ${entry.hits} ${entry.minutes}`) + ].join(" "); + } + return tx; +} + +function parseTransactionMeta(rawMeta) { + if (!rawMeta) { + return null; + } + if (typeof rawMeta === "object") { + return rawMeta; + } + try { + return JSON.parse(rawMeta); + } catch { + return null; + } +} + +function buildGlobalStats(db) { + const totalBalance = db + .prepare("SELECT COALESCE(SUM(balance), 0) AS total FROM echonomy_accounts") + .get(); + const totalSpent = db + .prepare( + "SELECT COALESCE(SUM(amount), 0) AS total FROM echonomy_transactions " + + "WHERE from_user_id IS NOT NULL AND (to_user_id IS NULL OR to_user_id = '')" + ) + .get(); + const totalTransactions = db + .prepare("SELECT COUNT(*) AS count FROM echonomy_transactions") + .get(); + return { + totalBalance: totalBalance?.total || 0, + totalSpent: totalSpent?.total || 0, + totalTransactions: totalTransactions?.count || 0 + }; +} + +function buildUserStats(db, userId) { + if (!userId) { + return { + balance: 0, + totalEarned: 0, + totalSpent: 0, + totalReceived: 0, + totalSent: 0 + }; + } + const balance = getBalance(db, userId); + const totalEarned = db + .prepare( + "SELECT COALESCE(SUM(amount), 0) AS total FROM echonomy_transactions " + + "WHERE to_user_id = ? AND (from_user_id IS NULL OR from_user_id = '')" + ) + .get(userId); + const totalSpent = db + .prepare( + "SELECT COALESCE(SUM(amount), 0) AS total FROM echonomy_transactions " + + "WHERE from_user_id = ? AND (to_user_id IS NULL OR to_user_id = '')" + ) + .get(userId); + const totalReceived = db + .prepare( + "SELECT COALESCE(SUM(amount), 0) AS total FROM echonomy_transactions " + + "WHERE to_user_id = ? AND from_user_id IS NOT NULL AND from_user_id != ''" + ) + .get(userId); + const totalSent = db + .prepare( + "SELECT COALESCE(SUM(amount), 0) AS total FROM echonomy_transactions " + + "WHERE from_user_id = ? AND to_user_id IS NOT NULL AND to_user_id != ''" + ) + .get(userId); + return { + balance, + totalEarned: totalEarned?.total || 0, + totalSpent: totalSpent?.total || 0, + totalReceived: totalReceived?.total || 0, + totalSent: totalSent?.total || 0 + }; +} + +function listTopBalances(db, limit) { + return db + .prepare( + "SELECT user_profiles.internal_username AS username, echonomy_accounts.balance AS balance " + + "FROM echonomy_accounts " + + "JOIN user_profiles ON user_profiles.id = echonomy_accounts.user_id " + + "ORDER BY echonomy_accounts.balance DESC LIMIT ?" + ) + .all(limit); +} + +function listFunds(db) { + return db + .prepare("SELECT * FROM echonomy_pots WHERE status != 'archived' ORDER BY name") + .all(); +} + +function formatProviderLabel(provider) { + const normalized = (provider || "").toLowerCase(); + const map = { + discord: "Discord", + twitch: "Twitch", + twitch_login: "Twitch", + youtube: "YouTube", + youtube_name: "YouTube", + echonomy_name: "Internal" + }; + if (map[normalized]) { + return map[normalized]; + } + if (!normalized) { + return "Account"; + } + return normalized.charAt(0).toUpperCase() + normalized.slice(1); +} + +function listUserDirectory(db) { + const rows = db + .prepare( + "SELECT user_profiles.id AS user_id, user_profiles.internal_username AS internal_username, " + + "user_identities.provider AS provider, user_identities.display_name AS display_name, " + + "user_identities.provider_user_id AS provider_user_id " + + "FROM user_profiles " + + "LEFT JOIN user_identities ON user_identities.user_id = user_profiles.id " + + "ORDER BY user_profiles.internal_username" + ) + .all(); + const map = new Map(); + rows.forEach((row) => { + if (!map.has(row.user_id)) { + map.set(row.user_id, { + id: row.user_id, + internal: row.internal_username || "", + identities: [] + }); + } + if (row.provider) { + const display = row.display_name || row.provider_user_id || ""; + map.get(row.user_id).identities.push({ + provider: row.provider, + label: formatProviderLabel(row.provider), + display + }); + } + }); + return Array.from(map.values()); +} + +function findFund(db, name) { + return db + .prepare("SELECT * FROM echonomy_pots WHERE lower(name) = lower(?)") + .get(name); +} + +function createFund(db, { name, description, targetAmount }) { + const now = Date.now(); + db.prepare( + "INSERT INTO echonomy_pots (id, name, description, target_amount, current_amount, status, created_at, updated_at) VALUES (?, ?, ?, ?, 0, 'active', ?, ?)" + ).run(crypto.randomUUID(), name, description || "", targetAmount || 0, now, now); +} + +function updateFund(db, { id, name, description, targetAmount, status }) { + db.prepare( + "UPDATE echonomy_pots SET name = ?, description = ?, target_amount = ?, status = ?, updated_at = ? WHERE id = ?" + ).run( + name, + description || "", + Number.isFinite(targetAmount) ? targetAmount : 0, + status || "active", + Date.now(), + id + ); +} + +function addFundContribution(db, fundId, userId, amount) { + const now = Date.now(); + db.transaction(() => { + db.prepare( + "INSERT INTO echonomy_pot_contributions (id, pot_id, user_id, amount, created_at) VALUES (?, ?, ?, ?, ?)" + ).run(crypto.randomUUID(), fundId, userId, amount, now); + db.prepare( + "UPDATE echonomy_pots SET current_amount = current_amount + ?, updated_at = ? WHERE id = ?" + ).run(amount, now, fundId); + })(); +} + +function attachDiscordListeners({ db, settings, discordClient }) { + if (!discordClient) { + return; + } + discordClient.on("messageCreate", (message) => { + if (!message || message.author?.bot) { + return; + } + const config = getConfig(db); + if (!config.platforms.discord || !config.earn.discordMessage.enabled) { + return; + } + const userId = message.author.id; + const key = `discord:${userId}`; + const last = messageCooldowns.get(key) || 0; + const now = Date.now(); + if (now - last < config.earn.discordMessage.cooldown * 1000) { + return; + } + const displayName = + message.author.globalName || message.author.username || message.author.tag; + const profile = ensureUserForIdentity({ + provider: "discord", + providerUserId: userId, + displayName, + avatar: message.author.avatar + ? `https://cdn.discordapp.com/avatars/${userId}/${message.author.avatar}.png?size=128` + : null + }); + const multiplier = getDiscordTierMultiplier(message, config); + const reward = Math.max( + 0, + Math.floor(config.earn.discordMessage.amount * multiplier) + ); + if (reward > 0) { + queueActivityReward(db, { + userId: profile.id, + source: "discord_message", + amount: reward, + hits: 1 + }); + messageCooldowns.set(key, now); + } + }); + + discordClient.on("voiceStateUpdate", (_oldState, newState) => { + if (!newState?.member || newState.member.user?.bot) { + return; + } + const userId = newState.member.id; + const joined = Boolean(newState.channelId); + if (!joined) { + voiceStates.delete(userId); + return; + } + if (!voiceStates.has(userId)) { + voiceStates.set(userId, { + member: newState.member, + lastAwardAt: Date.now() + }); + } + }); + + if (!voiceTimer) { + voiceTimer = setInterval(() => { + const config = getConfig(db); + if (!config.platforms.discord || !config.earn.discordVoice.enabled) { + return; + } + const tickMs = Math.max(1, config.earn.discordVoice.tickMinutes) * 60 * 1000; + const rewardBase = config.earn.discordVoice.amountPerMin; + const now = Date.now(); + for (const [userId, state] of voiceStates.entries()) { + const elapsed = now - state.lastAwardAt; + if (elapsed < tickMs) { + continue; + } + const minutes = Math.floor(elapsed / tickMs); + const multiplier = getDiscordVoiceMultiplier(state.member, config); + const reward = Math.max(0, Math.floor(rewardBase * minutes * multiplier)); + if (reward > 0) { + const profile = ensureUserForIdentity({ + provider: "discord", + providerUserId: userId, + displayName: + state.member.user.globalName || + state.member.user.username || + state.member.user.tag + }); + queueActivityReward(db, { + userId: profile.id, + source: "discord_voice", + amount: reward, + hits: 1, + minutes: minutes * Math.max(1, config.earn.discordVoice.tickMinutes) + }); + } + state.lastAwardAt = now; + } + }, 30000); + } +} + +function getDiscordTierMultiplier(message, config) { + const boosterRoleId = message.guild?.premiumSubscriberRole?.id; + if (!boosterRoleId) { + return 1; + } + const hasBooster = message.member?.roles?.cache?.has(boosterRoleId); + return hasBooster ? config.tiers.discordBooster : 1; +} + +function getDiscordVoiceMultiplier(member, config) { + const boosterRoleId = member?.guild?.premiumSubscriberRole?.id; + if (!boosterRoleId) { + return 1; + } + const hasBooster = member.roles?.cache?.has(boosterRoleId); + return hasBooster ? config.tiers.discordBooster : 1; +} + +function attachTwitchListeners({ db, settings, twitchClient }) { + if (!twitchClient) { + return; + } + twitchClient.on("message", (_channel, tags, _message, self) => { + if (self) { + return; + } + const config = getConfig(db); + if (!config.platforms.twitch || !config.earn.twitchMessage.enabled) { + return; + } + const userId = tags["user-id"]; + if (!userId) { + return; + } + const key = `twitch:${userId}`; + const last = messageCooldowns.get(key) || 0; + const now = Date.now(); + if (now - last < config.earn.twitchMessage.cooldown * 1000) { + return; + } + const displayName = tags["display-name"] || tags.username; + const profile = ensureUserForIdentity({ + provider: "twitch", + providerUserId: userId, + displayName + }); + const multiplier = getTwitchTierMultiplier(tags, config); + const reward = Math.max(0, Math.floor(config.earn.twitchMessage.amount * multiplier)); + if (reward > 0) { + queueActivityReward(db, { + userId: profile.id, + source: "twitch_message", + amount: reward, + hits: 1 + }); + messageCooldowns.set(key, now); + } + }); +} + +function getTwitchTierMultiplier(tags, config) { + const badges = tags.badges || {}; + if (badges.broadcaster) { + return config.tiers.twitchBroadcaster; + } + if (badges.moderator || tags.mod) { + return config.tiers.twitchMod; + } + if (badges.vip) { + return config.tiers.twitchVip; + } + if (tags.subscriber) { + return config.tiers.twitchSub; + } + return 1; +} diff --git a/plugins/echonomy-framework/plugin.json b/plugins/echonomy-framework/plugin.json new file mode 100644 index 0000000..9bc74d9 --- /dev/null +++ b/plugins/echonomy-framework/plugin.json @@ -0,0 +1,7 @@ +{ + "id": "echonomy-framework", + "name": "Echonomy Framework", + "version": "0.2.6", + "description": "Cross-platform currency framework with shared balances and extensible hooks.", + "main": "index.js" +} diff --git a/plugins/echonomy-framework/stats.js b/plugins/echonomy-framework/stats.js new file mode 100644 index 0000000..caf644b --- /dev/null +++ b/plugins/echonomy-framework/stats.js @@ -0,0 +1,68 @@ +function getProfileStats({ db, userId }) { + if (!userId) { + return { stats: [] }; + } + const account = db + .prepare("SELECT balance FROM echonomy_accounts WHERE user_id = ?") + .get(userId); + const earned = db + .prepare( + "SELECT COALESCE(SUM(amount), 0) AS total FROM echonomy_transactions " + + "WHERE to_user_id = ? AND (from_user_id IS NULL OR from_user_id = '')" + ) + .get(userId); + const spent = db + .prepare( + "SELECT COALESCE(SUM(amount), 0) AS total FROM echonomy_transactions " + + "WHERE from_user_id = ? AND (to_user_id IS NULL OR to_user_id = '')" + ) + .get(userId); + const transfersOut = db + .prepare( + "SELECT COALESCE(SUM(amount), 0) AS total FROM echonomy_transactions " + + "WHERE from_user_id = ? AND to_user_id IS NOT NULL AND to_user_id != ''" + ) + .get(userId); + const transfersIn = db + .prepare( + "SELECT COALESCE(SUM(amount), 0) AS total FROM echonomy_transactions " + + "WHERE to_user_id = ? AND from_user_id IS NOT NULL AND from_user_id != ''" + ) + .get(userId); + + return { + stats: [ + { label: "Balance", value: account?.balance ?? 0 }, + { label: "Total earned", value: earned?.total ?? 0 }, + { label: "Total spent", value: spent?.total ?? 0 }, + { label: "Given to others", value: transfersOut?.total ?? 0 }, + { label: "Received from others", value: transfersIn?.total ?? 0 } + ] + }; +} + +function getLeaderboards({ db, limit = 10 }) { + const rows = db + .prepare( + "SELECT user_profiles.internal_username AS username, echonomy_accounts.balance AS value " + + "FROM echonomy_accounts " + + "JOIN user_profiles ON user_profiles.id = echonomy_accounts.user_id " + + "ORDER BY echonomy_accounts.balance DESC LIMIT ?" + ) + .all(limit); + + return { + boards: [ + { + title: "Top balances", + valueLabel: "Balance", + rows + } + ] + }; +} + +module.exports = { + getProfileStats, + getLeaderboards +}; diff --git a/plugins/echonomy-framework/stats.json b/plugins/echonomy-framework/stats.json new file mode 100644 index 0000000..49b707d --- /dev/null +++ b/plugins/echonomy-framework/stats.json @@ -0,0 +1,13 @@ +{ + "pluginId": "echonomy-framework", + "pluginName": "Echonomy Framework", + "provider": "stats.js", + "profile": { + "title": "Echonomy", + "emptyMessage": "No currency activity yet." + }, + "leaderboards": { + "title": "Echonomy", + "emptyMessage": "No currency activity yet." + } +} diff --git a/plugins/echonomy-framework/test.txt b/plugins/echonomy-framework/test.txt new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/plugins/echonomy-framework/test.txt @@ -0,0 +1 @@ +ok diff --git a/plugins/echonomy-framework/views/banking.ejs b/plugins/echonomy-framework/views/banking.ejs new file mode 100644 index 0000000..1ee4525 --- /dev/null +++ b/plugins/echonomy-framework/views/banking.ejs @@ -0,0 +1,481 @@ +<%- include("../../../src/web/views/partials/layout-top", { title }) %> + + +
+
+
+

<%= config.banking.label %>

+

Review balances, transfer funds, and track your transaction history.

+
+
+ <% if (config.currency.icon) { %> + Currency icon + <% } %> + <%= config.currency.name %> +
+
+
+ +
+

Account snapshot

+
+
+ Current balance + <%= userStats.balance %> +
+
+ Total earned + <%= userStats.totalEarned %> +
+
+ Total spent + <%= userStats.totalSpent %> +
+
+ Sent to others + <%= userStats.totalSent %> +
+
+ Received from others + <%= userStats.totalReceived %> +
+
+
+ +
+

Transfer to another user

+
+ +
+ + +
+
+ + +
+ +
+
+ +
+
+
+

<%= config.communityFunds.plural %>

+

Support shared community goals with direct deposits.

+
+
+ <% if (!funds.length) { %> +

No <%= config.communityFunds.plural.toLowerCase() %> are active right now.

+ <% } else { %> +
+ <% funds.forEach((fund) => { %> +
+
+ <%= fund.name %> + <%= fund.current_amount %>/<%= fund.target_amount %> +
+ <%= fund.description || '' %> +
+
+ + +
+
+ + +
+ +
+
+ <% }) %> +
+ <% } %> +
+ +
+
+
+

Transaction history

+

All account activity with UUID records.

+
+
+ +
+ +
+
+
+
+ + + + + + + + + + + + + + <% transactions.forEach((tx) => { %> + <% const fromName = tx.from_name || 'System'; %> + <% const toName = tx.to_name || 'System'; %> + + + + + + + + + + <% }) %> + +
UUIDTypeAmountFromToNoteDate
+ + <%= tx.type %><%= tx.amount %><%= fromName %><%= toName %> + <% if (tx.activity_reward) { %> +
+ <%= tx.note_display %> + <% if (tx.activity_reward.hourStart && tx.activity_reward.hourEnd) { %> +
+ <%= new Date(tx.activity_reward.hourStart).toLocaleString() %> - + <%= new Date(tx.activity_reward.hourEnd).toLocaleString() %> +
+ <% } %> +
    + <% tx.activity_reward.rewards.forEach((reward) => { %> +
  • + <%= reward.label %>: <%= reward.amount %> + <% if (reward.hits > 0) { %> (<%= reward.hits %> events)<% } %> + <% if (reward.minutes > 0) { %> (<%= reward.minutes %> min)<% } %> +
  • + <% }) %> +
+
+ <% } else { %> + <%= tx.note_display || tx.note || '-' %> + <% } %> +
<%= new Date(tx.created_at).toLocaleString() %>
+
+
+ + Page 1 of 1 + +
+
+ + + +<%- include("../../../src/web/views/partials/layout-bottom") %> diff --git a/plugins/echonomy-framework/views/echonomy.ejs b/plugins/echonomy-framework/views/echonomy.ejs new file mode 100644 index 0000000..a2ba899 --- /dev/null +++ b/plugins/echonomy-framework/views/echonomy.ejs @@ -0,0 +1,768 @@ + +<%- include("../../../src/web/views/partials/layout-top", { title }) %> + + +
+
+
+

Echonomy Framework

+

Unified, cross-platform currency tooling and stats.

+
+ <% if (config.currency.icon) { %> + Currency icon + <% } %> + <%= config.currency.name %> + (<%= config.currency.plural %>) +
+
+
+
+ +
+

Overview

+
+
+ Your balance + <%= userBalance %> +
+
+ Command root + !<%= config.command.root %> +
+
+ Cooldown + <%= config.cooldownSeconds %>s +
+ <% if (isAdmin) { %> +
+ Total in circulation + <%= globalStats.totalBalance %> +
+
+ Total spent + <%= globalStats.totalSpent %> +
+ <% } %> +
+
+<% if (isAdmin) { %> +
+

Currency settings

+
+
+ + +
+
+ + +
+
+ + + Example: coins, souls, shards +
+
+ + + Comma separated aliases that also trigger the root command. +
+ +
+
+ +
+

Currency icon

+
+
+ + + PNG only. Used across the WebUI. +
+ +
+
+ +
+

Banking labels

+
+
+ + + Shown on the profile page button and the banking page title. +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+

Platforms

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+

Currency earning rules

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+

Monetization tiers

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+

<%= config.communityFunds.plural %>

+ <% if (!funds.length) { %> +

No <%= config.communityFunds.plural.toLowerCase() %> configured yet.

+ <% } else { %> +
    + <% funds.forEach((fund) => { %> +
  • + <%= fund.name %> - <%= fund.current_amount %>/<%= fund.target_amount %> + <%= fund.description || '' %> +
  • + <% }) %> +
+ <% } %> + <% if (isAdmin) { %> +

Create <%= config.communityFunds.name %>

+
+
+ + +
+
+ + +
+
+ + +
+ +
+ +

Update <%= config.communityFunds.plural %>

+ <% funds.forEach((fund) => { %> +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ <% }) %> + <% } %> +
+ +<% } %> + +<% if (isAdmin) { %> +
+

Event rewards

+ <% if (!events.length) { %> +

No custom events configured yet.

+ <% } else { %> +
    + <% events.forEach((event) => { %> +
  • + <%= event.name %> (<%= event.amount %>) +
    + +
    +
  • + <% }) %> +
+ <% } %> +
+
+ + +
+
+ + +
+ +
+
+ +
+
+
+

Response templates

+

Customize bot replies. Tokens: {amount_text}, {balance_text}, {target}, {fund}, {lines}, {cooldown}, {usage}, {help}.

+
+
+
+ <% responses.forEach((response) => { %> +
+ <%= response.label %> +
+ <% response.replies.slice(0, 2).forEach((reply) => { %> + • <%= reply.text %> + <% }) %> + <% if (response.replies.length > 2) { %> + …and <%= response.replies.length - 2 %> more + <% } %> +
+ +
+ + + <% }) %> +
+
+<% } %> + +<% if (isMod) { %> +
+

Adjust user balance

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+<% } %> +
+
+
+

Top balances

+

Snapshot of the richest accounts.

+
+
+ <% if (!topBalances.length) { %> +

No balances yet.

+ <% } else { %> + + + + + + + + + <% topBalances.forEach((entry) => { %> + + + + + <% }) %> + +
UserBalance
<%= entry.username %><%= entry.balance %>
+ <% } %> +
+ +
+
+
+

Transaction history

+

Every change is logged with a UUID.

+
+
+ +
+ +
+
+
+
+ + + + + + + + + + + + + + <% transactions.forEach((tx) => { %> + <% const fromName = tx.from_name || 'System'; %> + <% const toName = tx.to_name || 'System'; %> + + + + + + + + + + <% }) %> + +
UUIDTypeAmountFromToNoteDate
+ + <%= tx.type %><%= tx.amount %><%= fromName %><%= toName %> + <% if (tx.activity_reward) { %> +
+ <%= tx.note_display %> + <% if (tx.activity_reward.hourStart && tx.activity_reward.hourEnd) { %> +
+ <%= new Date(tx.activity_reward.hourStart).toLocaleString() %> - + <%= new Date(tx.activity_reward.hourEnd).toLocaleString() %> +
+ <% } %> +
    + <% tx.activity_reward.rewards.forEach((reward) => { %> +
  • + <%= reward.label %>: <%= reward.amount %> + <% if (reward.hits > 0) { %> (<%= reward.hits %> events)<% } %> + <% if (reward.minutes > 0) { %> (<%= reward.minutes %> min)<% } %> +
  • + <% }) %> +
+
+ <% } else { %> + <%= tx.note_display || tx.note || '-' %> + <% } %> +
<%= new Date(tx.created_at).toLocaleString() %>
+
+
+ + Page 1 of 1 + +
+
+ + + +<%- include("../../../src/web/views/partials/layout-bottom") %> diff --git a/plugins/echonomy-games/cmds.json b/plugins/echonomy-games/cmds.json new file mode 100644 index 0000000..fd9ab5c --- /dev/null +++ b/plugins/echonomy-games/cmds.json @@ -0,0 +1,36 @@ +{ + "pluginId": "echonomy-games", + "pluginName": "Echonomy Games", + "commands": [ + { + "id": "hotpotato", + "trigger": "hotpotato", + "name": "Hot Potato", + "description": "Start or toss the hot potato game.", + "level": "public", + "platforms": ["discord", "twitch", "youtube"], + "triggerKey": "hotpotato_trigger", + "usage": "hotpotato | hotpotato toss" + }, + { + "id": "coinflip", + "trigger": "coinflip", + "name": "Coinflip", + "description": "Flip a coin to double your bet.", + "level": "public", + "platforms": ["discord", "twitch", "youtube"], + "triggerKey": "coinflip_trigger", + "usage": "coinflip " + }, + { + "id": "mysterybox", + "trigger": "mysterybox", + "name": "Mystery Box", + "description": "Spend coins for a random payout.", + "level": "public", + "platforms": ["discord", "twitch", "youtube"], + "triggerKey": "mysterybox_trigger", + "usage": "mysterybox " + } + ] +} diff --git a/plugins/echonomy-games/index.js b/plugins/echonomy-games/index.js new file mode 100644 index 0000000..2e856c0 --- /dev/null +++ b/plugins/echonomy-games/index.js @@ -0,0 +1,1218 @@ +const path = require("path"); +const crypto = require("crypto"); +const { ensureUserForIdentity } = require("../../src/services/users"); + +const PLUGIN_ID = "echonomy-games"; +const DEFAULT_SETTINGS = { + hotpotato_enabled: "1", + hotpotato_platform_discord: "1", + hotpotato_platform_twitch: "1", + hotpotato_platform_youtube: "1", + hotpotato_name: "Hot Potato", + hotpotato_trigger: "hotpotato", + hotpotato_aliases: "potato", + hotpotato_min_cost: "10", + hotpotato_max_cost: "250", + hotpotato_toss_min: "10", + hotpotato_toss_max: "25", + hotpotato_loss_multiplier: "1", + hotpotato_loss_additive: "0", + hotpotato_presence_window: "300", + coinflip_enabled: "1", + coinflip_platform_discord: "1", + coinflip_platform_twitch: "1", + coinflip_platform_youtube: "1", + coinflip_name: "Coinflip", + coinflip_trigger: "coinflip", + coinflip_aliases: "flip", + coinflip_min_bet: "10", + coinflip_max_bet: "500", + coinflip_multiplier: "2", + coinflip_cooldown: "10", + mystery_enabled: "1", + mystery_platform_discord: "1", + mystery_platform_twitch: "1", + mystery_platform_youtube: "1", + mystery_name: "Mystery Box", + mystery_trigger: "mysterybox", + mystery_aliases: "box", + mystery_min_bet: "10", + mystery_max_bet: "500", + mystery_multiplier: "2", + mystery_cooldown: "10", + responses_json: "" +}; + +const DEFAULT_RESPONSES = { + hotpotato_start: [ + "{user} started {game} with {amount}. {target} has the potato! Toss within {seconds}s." + ], + hotpotato_toss: [ + "{user} tossed the potato to {target}. Toss within {seconds}s!" + ], + hotpotato_timeout: [ + "{loser} ran out of time and paid {loss} total. Winners: {winners}." + ], + hotpotato_no_targets: [ + "No active users to pass the potato to yet." + ], + hotpotato_already_active: [ + "{game} is already active. {holder} has the potato." + ], + hotpotato_not_holder: [ + "Only the current holder can toss the potato." + ], + hotpotato_not_active: [ + "{game} is not active yet. Start it with {trigger} ." + ], + hotpotato_invalid_amount: [ + "Enter an amount between {min} and {max}." + ], + coinflip_win: [ + "{user} flipped heads and won {payout}!" + ], + coinflip_lose: [ + "{user} flipped tails and lost {amount}." + ], + coinflip_invalid: [ + "Enter a bet between {min} and {max}." + ], + coinflip_cooldown: [ + "Wait {seconds}s before flipping again." + ], + coinflip_insufficient: [ + "{reason}" + ], + mystery_result: [ + "{user} opened a box and got {payout} (from {amount})." + ], + mystery_invalid: [ + "Enter a bet between {min} and {max}." + ], + mystery_cooldown: [ + "Wait {seconds}s before opening another box." + ], + mystery_insufficient: [ + "{reason}" + ] +}; + +const presence = { + discord: new Map(), + twitch: new Map(), + youtube: new Map() +}; + +const hotPotatoGames = new Map(); +const cooldowns = new Map(); +let cachedConfig = null; +let cachedConfigAt = 0; + +module.exports = { + id: PLUGIN_ID, + init({ web, settings, db, commandRouter, discordClient, twitchClient }) { + ensureDefaults(db); + ensureStatsTable(db); + const refreshCommands = registerCommands({ db, commandRouter, settings }); + + attachDiscordPresence({ discordClient }); + attachTwitchPresence({ twitchClient }); + + const router = web.createRouter(); + router.get("/", (req, res) => { + const config = getConfig(db); + const responses = buildResponses(db); + const responsesByGame = { + hotpotato: Object.values(responses).filter((entry) => entry.key.startsWith("hotpotato_")), + coinflip: Object.values(responses).filter((entry) => entry.key.startsWith("coinflip_")), + mystery: Object.values(responses).filter((entry) => entry.key.startsWith("mystery_")) + }; + const framework = getFramework(); + const currencyLabel = + framework?.getConfig?.().currency?.plural || + framework?.getConfig?.().currency?.name || + "Coins"; + const stats = { + hotpotato: getGameStatsView(db, "hotpotato"), + coinflip: getGameStatsView(db, "coinflip"), + mystery: getGameStatsView(db, "mystery") + }; + res.render(path.join(__dirname, "views", "games.ejs"), { + title: "Echonomy Games", + config, + responses, + responsesByGame, + frameworkReady: Boolean(framework), + currencyLabel, + stats + }); + }); + + router.post("/settings/hotpotato", (req, res) => { + if (!req.session.user?.isAdmin) { + return deny(res); + } + setPluginSetting(db, "hotpotato_enabled", req.body.hotpotato_enabled ? "1" : "0"); + setPluginSetting( + db, + "hotpotato_platform_discord", + req.body.hotpotato_platform_discord ? "1" : "0" + ); + setPluginSetting( + db, + "hotpotato_platform_twitch", + req.body.hotpotato_platform_twitch ? "1" : "0" + ); + setPluginSetting( + db, + "hotpotato_platform_youtube", + req.body.hotpotato_platform_youtube ? "1" : "0" + ); + setPluginSetting(db, "hotpotato_name", (req.body.hotpotato_name || "").trim()); + setPluginSetting( + db, + "hotpotato_trigger", + (req.body.hotpotato_trigger || "").trim() + ); + setPluginSetting( + db, + "hotpotato_aliases", + (req.body.hotpotato_aliases || "").trim() + ); + setPluginSetting(db, "hotpotato_min_cost", (req.body.hotpotato_min_cost || "0").trim()); + setPluginSetting(db, "hotpotato_max_cost", (req.body.hotpotato_max_cost || "0").trim()); + setPluginSetting(db, "hotpotato_toss_min", (req.body.hotpotato_toss_min || "1").trim()); + setPluginSetting(db, "hotpotato_toss_max", (req.body.hotpotato_toss_max || "1").trim()); + setPluginSetting( + db, + "hotpotato_loss_multiplier", + (req.body.hotpotato_loss_multiplier || "1").trim() + ); + setPluginSetting( + db, + "hotpotato_loss_additive", + (req.body.hotpotato_loss_additive || "0").trim() + ); + setPluginSetting( + db, + "hotpotato_presence_window", + (req.body.hotpotato_presence_window || "300").trim() + ); + invalidateConfigCache(); + refreshCommands?.(); + req.session.flash = { type: "success", message: "Hot potato updated." }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/settings/coinflip", (req, res) => { + if (!req.session.user?.isAdmin) { + return deny(res); + } + setPluginSetting(db, "coinflip_enabled", req.body.coinflip_enabled ? "1" : "0"); + setPluginSetting( + db, + "coinflip_platform_discord", + req.body.coinflip_platform_discord ? "1" : "0" + ); + setPluginSetting( + db, + "coinflip_platform_twitch", + req.body.coinflip_platform_twitch ? "1" : "0" + ); + setPluginSetting( + db, + "coinflip_platform_youtube", + req.body.coinflip_platform_youtube ? "1" : "0" + ); + setPluginSetting(db, "coinflip_name", (req.body.coinflip_name || "").trim()); + setPluginSetting( + db, + "coinflip_trigger", + (req.body.coinflip_trigger || "").trim() + ); + setPluginSetting( + db, + "coinflip_aliases", + (req.body.coinflip_aliases || "").trim() + ); + setPluginSetting(db, "coinflip_min_bet", (req.body.coinflip_min_bet || "0").trim()); + setPluginSetting(db, "coinflip_max_bet", (req.body.coinflip_max_bet || "0").trim()); + setPluginSetting( + db, + "coinflip_multiplier", + (req.body.coinflip_multiplier || "2").trim() + ); + setPluginSetting( + db, + "coinflip_cooldown", + (req.body.coinflip_cooldown || "10").trim() + ); + invalidateConfigCache(); + refreshCommands?.(); + req.session.flash = { type: "success", message: "Coinflip updated." }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/settings/mystery", (req, res) => { + if (!req.session.user?.isAdmin) { + return deny(res); + } + setPluginSetting(db, "mystery_enabled", req.body.mystery_enabled ? "1" : "0"); + setPluginSetting( + db, + "mystery_platform_discord", + req.body.mystery_platform_discord ? "1" : "0" + ); + setPluginSetting( + db, + "mystery_platform_twitch", + req.body.mystery_platform_twitch ? "1" : "0" + ); + setPluginSetting( + db, + "mystery_platform_youtube", + req.body.mystery_platform_youtube ? "1" : "0" + ); + setPluginSetting(db, "mystery_name", (req.body.mystery_name || "").trim()); + setPluginSetting( + db, + "mystery_trigger", + (req.body.mystery_trigger || "").trim() + ); + setPluginSetting( + db, + "mystery_aliases", + (req.body.mystery_aliases || "").trim() + ); + setPluginSetting(db, "mystery_min_bet", (req.body.mystery_min_bet || "0").trim()); + setPluginSetting(db, "mystery_max_bet", (req.body.mystery_max_bet || "0").trim()); + setPluginSetting( + db, + "mystery_multiplier", + (req.body.mystery_multiplier || "2").trim() + ); + setPluginSetting( + db, + "mystery_cooldown", + (req.body.mystery_cooldown || "10").trim() + ); + invalidateConfigCache(); + refreshCommands?.(); + req.session.flash = { type: "success", message: "Mystery box updated." }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/settings/responses", (req, res) => { + if (!req.session.user?.isAdmin) { + return deny(res); + } + const existing = loadCustomResponses(db); + const payload = { ...existing }; + for (const key of Object.keys(DEFAULT_RESPONSES)) { + const field = `response_${key}`; + if (!Object.prototype.hasOwnProperty.call(req.body, field)) { + continue; + } + const raw = (req.body[field] || "").toString(); + const lines = raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + if (lines.length) { + payload[key] = lines; + } else { + delete payload[key]; + } + } + setPluginSetting(db, "responses_json", JSON.stringify(payload)); + invalidateConfigCache(); + req.session.flash = { type: "success", message: "Responses updated." }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + web.mount(`/plugins/${PLUGIN_ID}`, router, { + label: "Echonomy Games", + role: "admin", + section: "plugins" + }); + } +}; + +function deny(res) { + return res.status(403).render("error", { + title: "Access denied", + message: "You do not have access to that page." + }); +} + +function ensureDefaults(db) { + const existing = getPluginSettings(db); + for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) { + if (existing[key] === undefined) { + setPluginSetting(db, key, value); + } + } +} + +function getPluginSettings(db) { + const rows = db + .prepare("SELECT key, value FROM plugin_settings WHERE plugin_id = ?") + .all(PLUGIN_ID); + return rows.reduce((acc, row) => { + acc[row.key] = row.value; + return acc; + }, {}); +} + +function setPluginSetting(db, key, value) { + db.prepare( + "INSERT INTO plugin_settings (plugin_id, key, value, updated_at) VALUES (?, ?, ?, ?) " + + "ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at" + ).run(PLUGIN_ID, key, value, Date.now()); +} + +function parseBoolean(value, fallback) { + if (value === undefined || value === null || value === "") { + return fallback; + } + if (typeof value === "boolean") { + return value; + } + const normalized = value.toString().toLowerCase(); + return ["1", "true", "yes", "on"].includes(normalized); +} + +function parseNumber(value, fallback) { + if (value === undefined || value === null || value === "") { + return fallback; + } + const number = Number(value); + if (!Number.isFinite(number)) { + return fallback; + } + return number; +} + +function parseList(value) { + return (value || "") + .split(/[,\s]+/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function normalizeCommand(value, fallback) { + const raw = (value || fallback || "").trim().replace(/^!+/, ""); + return raw.toLowerCase().replace(/\s+/g, "-"); +} + +function getConfig(db) { + const now = Date.now(); + if (cachedConfig && now - cachedConfigAt < 2000) { + return cachedConfig; + } + const settings = getPluginSettings(db); + const config = { + hotpotato: { + enabled: parseBoolean(settings.hotpotato_enabled, true), + name: settings.hotpotato_name || "Hot Potato", + trigger: normalizeCommand(settings.hotpotato_trigger, "hotpotato"), + aliases: parseList(settings.hotpotato_aliases), + minCost: parseNumber(settings.hotpotato_min_cost, 10), + maxCost: parseNumber(settings.hotpotato_max_cost, 250), + tossMin: parseNumber(settings.hotpotato_toss_min, 10), + tossMax: parseNumber(settings.hotpotato_toss_max, 25), + lossMultiplier: parseNumber(settings.hotpotato_loss_multiplier, 1), + lossAdditive: parseNumber(settings.hotpotato_loss_additive, 0), + presenceWindow: parseNumber(settings.hotpotato_presence_window, 300), + platforms: { + discord: parseBoolean(settings.hotpotato_platform_discord, true), + twitch: parseBoolean(settings.hotpotato_platform_twitch, true), + youtube: parseBoolean(settings.hotpotato_platform_youtube, true) + } + }, + coinflip: { + enabled: parseBoolean(settings.coinflip_enabled, true), + name: settings.coinflip_name || "Coinflip", + trigger: normalizeCommand(settings.coinflip_trigger, "coinflip"), + aliases: parseList(settings.coinflip_aliases), + minBet: parseNumber(settings.coinflip_min_bet, 10), + maxBet: parseNumber(settings.coinflip_max_bet, 500), + multiplier: parseNumber(settings.coinflip_multiplier, 2), + cooldown: parseNumber(settings.coinflip_cooldown, 10), + platforms: { + discord: parseBoolean(settings.coinflip_platform_discord, true), + twitch: parseBoolean(settings.coinflip_platform_twitch, true), + youtube: parseBoolean(settings.coinflip_platform_youtube, true) + } + }, + mystery: { + enabled: parseBoolean(settings.mystery_enabled, true), + name: settings.mystery_name || "Mystery Box", + trigger: normalizeCommand(settings.mystery_trigger, "mysterybox"), + aliases: parseList(settings.mystery_aliases), + minBet: parseNumber(settings.mystery_min_bet, 10), + maxBet: parseNumber(settings.mystery_max_bet, 500), + multiplier: parseNumber(settings.mystery_multiplier, 2), + cooldown: parseNumber(settings.mystery_cooldown, 10), + platforms: { + discord: parseBoolean(settings.mystery_platform_discord, true), + twitch: parseBoolean(settings.mystery_platform_twitch, true), + youtube: parseBoolean(settings.mystery_platform_youtube, true) + } + } + }; + cachedConfig = config; + cachedConfigAt = now; + return config; +} + +function invalidateConfigCache() { + cachedConfig = null; + cachedConfigAt = 0; +} + +function buildResponses(db) { + const settings = getPluginSettings(db); + const custom = loadCustomResponses(db); + const responses = {}; + for (const [key, list] of Object.entries(DEFAULT_RESPONSES)) { + const override = Array.isArray(custom[key]) ? custom[key] : []; + responses[key] = { + key, + label: toLabel(key), + lines: override.length ? override : list + }; + } + return responses; +} + +function getResponseLines(db, key) { + const custom = loadCustomResponses(db); + if (Array.isArray(custom[key]) && custom[key].length) { + return custom[key]; + } + return DEFAULT_RESPONSES[key] || []; +} + +function loadCustomResponses(db) { + const settings = getPluginSettings(db); + let custom = {}; + try { + custom = JSON.parse(settings.responses_json || "{}"); + } catch { + custom = {}; + } + return custom || {}; +} + +function ensureStatsTable(db) { + db.prepare( + "CREATE TABLE IF NOT EXISTS echonomy_game_stats (" + + "game_key TEXT PRIMARY KEY," + + "plays INTEGER NOT NULL DEFAULT 0," + + "coins_won INTEGER NOT NULL DEFAULT 0," + + "coins_lost INTEGER NOT NULL DEFAULT 0," + + "last_played_at INTEGER," + + "last_played_user_id TEXT," + + "last_played_username TEXT" + + ")" + ).run(); +} + +function getGameStats(db, gameKey) { + return db + .prepare( + "SELECT game_key, plays, coins_won, coins_lost, last_played_at, last_played_user_id, last_played_username " + + "FROM echonomy_game_stats WHERE game_key = ?" + ) + .get(gameKey); +} + +function getGameStatsView(db, gameKey) { + const stats = getGameStats(db, gameKey); + if (!stats) { + return { + plays: 0, + coinsWon: 0, + coinsLost: 0, + lastPlayedLabel: "Never", + lastPlayedUser: null + }; + } + return { + plays: stats.plays || 0, + coinsWon: stats.coins_won || 0, + coinsLost: stats.coins_lost || 0, + lastPlayedLabel: stats.last_played_at + ? new Date(stats.last_played_at).toLocaleString() + : "Never", + lastPlayedUser: stats.last_played_username || null + }; +} + +function recordGamePlay(db, gameKey, { userId, username }) { + const now = Date.now(); + db.prepare( + "INSERT INTO echonomy_game_stats " + + "(game_key, plays, coins_won, coins_lost, last_played_at, last_played_user_id, last_played_username) " + + "VALUES (?, 1, 0, 0, ?, ?, ?) " + + "ON CONFLICT(game_key) DO UPDATE SET " + + "plays = plays + 1, " + + "last_played_at = excluded.last_played_at, " + + "last_played_user_id = excluded.last_played_user_id, " + + "last_played_username = excluded.last_played_username" + ).run(gameKey, now, userId || null, username || null); +} + +function recordGameTotals(db, gameKey, { coinsWon = 0, coinsLost = 0 }) { + const won = Math.floor(coinsWon || 0); + const lost = Math.floor(coinsLost || 0); + if (!won && !lost) { + return; + } + db.prepare( + "INSERT INTO echonomy_game_stats (game_key, plays, coins_won, coins_lost) " + + "VALUES (?, 0, ?, ?) " + + "ON CONFLICT(game_key) DO UPDATE SET " + + "coins_won = coins_won + ?, " + + "coins_lost = coins_lost + ?" + ).run(gameKey, won, lost, won, lost); +} + +function pickReply(lines) { + if (!lines || !lines.length) { + return ""; + } + return lines[Math.floor(Math.random() * lines.length)]; +} + +function render(template, tokens) { + return (template || "").replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, key) => { + if (Object.prototype.hasOwnProperty.call(tokens, key)) { + return tokens[key]; + } + return `{${key}}`; + }); +} + +function replyWith(db, ctx, key, tokens) { + const line = pickReply(getResponseLines(db, key)); + if (!line) { + return null; + } + return render(line, tokens); +} + +function toLabel(key) { + return key + .replace(/_/g, " ") + .replace(/\b\w/g, (match) => match.toUpperCase()); +} + +function getFramework() { + return global.lumiFrameworks?.echonomy || null; +} + +function registerCommands({ db, commandRouter, settings }) { + if (!commandRouter) { + return null; + } + const rebuild = () => { + const config = getConfig(db); + const commands = []; + + if (config.hotpotato.enabled && config.hotpotato.trigger) { + const triggers = [config.hotpotato.trigger, ...config.hotpotato.aliases]; + const platforms = platformsFromConfig(config.hotpotato.platforms); + if (platforms.length && triggers.length) { + commands.push({ + id: "echonomy-games:hotpotato", + triggers, + platforms, + handler: (ctx) => handleHotPotato({ ctx, db, settings }) + }); + } + } + + if (config.coinflip.enabled && config.coinflip.trigger) { + const triggers = [config.coinflip.trigger, ...config.coinflip.aliases]; + const platforms = platformsFromConfig(config.coinflip.platforms); + if (platforms.length && triggers.length) { + commands.push({ + id: "echonomy-games:coinflip", + triggers, + platforms, + handler: (ctx) => handleCoinflip({ ctx, db, settings }) + }); + } + } + + if (config.mystery.enabled && config.mystery.trigger) { + const triggers = [config.mystery.trigger, ...config.mystery.aliases]; + const platforms = platformsFromConfig(config.mystery.platforms); + if (platforms.length && triggers.length) { + commands.push({ + id: "echonomy-games:mystery", + triggers, + platforms, + handler: (ctx) => handleMystery({ ctx, db, settings }) + }); + } + } + + commandRouter.registerCommands(PLUGIN_ID, commands); + }; + rebuild(); + return rebuild; +} + +function platformsFromConfig(platforms) { + return Object.entries(platforms || {}) + .filter(([, enabled]) => enabled) + .map(([platform]) => platform); +} + +function getChannelKey(ctx) { + if (ctx.platform === "discord") { + return ctx.meta?.message?.channelId || "discord"; + } + if (ctx.platform === "twitch") { + return ctx.meta?.channel || "twitch"; + } + if (ctx.platform === "youtube") { + return ctx.meta?.liveChatId || "youtube"; + } + return "default"; +} + +function recordPresence(platform, channelKey, userId, name) { + if (!platform || !channelKey || !userId) { + return; + } + const channelMap = presence[platform] || new Map(); + if (!presence[platform]) { + presence[platform] = channelMap; + } + const users = channelMap.get(channelKey) || new Map(); + users.set(userId, { name: name || "User", lastSeen: Date.now() }); + channelMap.set(channelKey, users); +} + +function getActiveUsers(platform, channelKey, windowSeconds) { + const channelMap = presence[platform]; + if (!channelMap) { + return []; + } + const users = channelMap.get(channelKey); + if (!users) { + return []; + } + const now = Date.now(); + const cutoff = now - windowSeconds * 1000; + const list = []; + for (const [userId, info] of users.entries()) { + if (info.lastSeen < cutoff) { + users.delete(userId); + continue; + } + list.push({ id: userId, name: info.name || "User" }); + } + return list; +} + +function attachDiscordPresence({ discordClient }) { + if (!discordClient) { + return; + } + discordClient.on("messageCreate", (message) => { + if (!message || message.author?.bot) { + return; + } + const display = + message.member?.displayName || + message.author.globalName || + message.author.username || + message.author.tag; + const profile = ensureUserForIdentity({ + provider: "discord", + providerUserId: message.author.id, + displayName: display, + avatar: message.author.avatar + ? `https://cdn.discordapp.com/avatars/${message.author.id}/${message.author.avatar}.png?size=128` + : null + }); + recordPresence("discord", message.channelId, profile.id, display); + }); +} + +function attachTwitchPresence({ twitchClient }) { + if (!twitchClient) { + return; + } + twitchClient.on("message", (channel, tags, _message, self) => { + if (self) { + return; + } + const userId = tags["user-id"]; + if (!userId) { + return; + } + const display = tags["display-name"] || tags.username || "Twitch User"; + const profile = ensureUserForIdentity({ + provider: "twitch", + providerUserId: userId, + displayName: display + }); + recordPresence("twitch", channel, profile.id, display); + }); +} + +function recordPresenceFromCtx(ctx) { + const platform = ctx.platform; + const channelKey = getChannelKey(ctx); + const name = ctx.user.displayName || ctx.user.username || "User"; + recordPresence(platform, channelKey, ctx.user.id, name); +} + +function randomBetween(min, max) { + const low = Math.min(min, max); + const high = Math.max(min, max); + return Math.floor(Math.random() * (high - low + 1)) + low; +} + +function parseAmount(value) { + const number = Number(value); + if (!Number.isFinite(number)) { + return NaN; + } + if (number <= 0) { + return NaN; + } + return Math.floor(number); +} + +function getCooldownKey(ctx, key) { + return `${ctx.platform}:${ctx.user.id}:${key}`; +} + +function getCooldownLeft(ctx, key, cooldownSeconds) { + const lookup = getCooldownKey(ctx, key); + const last = cooldowns.get(lookup) || 0; + const diff = cooldownSeconds * 1000 - (Date.now() - last); + return diff > 0 ? Math.ceil(diff / 1000) : 0; +} + +function setCooldown(ctx, key) { + cooldowns.set(getCooldownKey(ctx, key), Date.now()); +} + +async function handleHotPotato({ ctx, db }) { + recordPresenceFromCtx(ctx); + const config = getConfig(db); + const framework = getFramework(); + if (!framework) { + await ctx.reply("Echonomy framework is not available."); + return true; + } + if (!config.hotpotato.enabled || !config.hotpotato.platforms[ctx.platform]) { + return false; + } + + const channelKey = getChannelKey(ctx); + const gameKey = `${ctx.platform}:${channelKey}`; + const current = hotPotatoGames.get(gameKey); + const sub = (ctx.args[0] || "").toLowerCase(); + const isToss = ["toss", "pass", "throw", "retoss"].includes(sub); + + if (isToss) { + if (!current) { + const msg = replyWith(db, ctx, "hotpotato_not_active", { + game: config.hotpotato.name, + trigger: config.hotpotato.trigger + }); + if (msg) { + await ctx.reply(msg); + } + return true; + } + if (current.holderId !== ctx.user.id) { + const msg = replyWith(db, ctx, "hotpotato_not_holder", {}); + if (msg) { + await ctx.reply(msg); + } + return true; + } + const next = pickRandomUser({ + platform: ctx.platform, + channelKey, + exclude: [ctx.user.id], + windowSeconds: config.hotpotato.presenceWindow + }); + if (!next) { + const msg = replyWith(db, ctx, "hotpotato_no_targets", {}); + if (msg) { + await ctx.reply(msg); + } + return true; + } + touchUser(current, ctx.user.id, ctx.user.displayName || ctx.user.username); + touchUser(current, next.id, next.name); + current.holderId = next.id; + current.holderName = next.name; + current.reply = ctx.reply; + resetHotPotatoTimer(gameKey, current, config); + const msg = replyWith(db, ctx, "hotpotato_toss", { + user: ctx.user.displayName || ctx.user.username, + target: next.name, + seconds: current.seconds + }); + if (msg) { + await ctx.reply(msg); + } + return true; + } + + if (current) { + const msg = replyWith(db, ctx, "hotpotato_already_active", { + game: config.hotpotato.name, + holder: current.holderName || "Someone" + }); + if (msg) { + await ctx.reply(msg); + } + return true; + } + + const amount = parseAmount(ctx.args[0]); + if (!Number.isFinite(amount) || + amount < config.hotpotato.minCost || + amount > config.hotpotato.maxCost) { + const msg = replyWith(db, ctx, "hotpotato_invalid_amount", { + min: config.hotpotato.minCost, + max: config.hotpotato.maxCost + }); + if (msg) { + await ctx.reply(msg); + } + return true; + } + + const stakeResult = framework.removeBalance({ + userId: ctx.user.id, + amount, + note: `${config.hotpotato.name} entry` + }); + if (stakeResult?.ok === false) { + const msg = replyWith(db, ctx, "coinflip_insufficient", { + reason: stakeResult.message || "Insufficient balance." + }); + if (msg) { + await ctx.reply(msg); + } + return true; + } + + const target = pickRandomUser({ + platform: ctx.platform, + channelKey, + exclude: [ctx.user.id], + windowSeconds: config.hotpotato.presenceWindow + }); + if (!target) { + const msg = replyWith(db, ctx, "hotpotato_no_targets", {}); + if (msg) { + await ctx.reply(msg); + } + return true; + } + + const displayName = ctx.user.displayName || ctx.user.username || "User"; + recordGamePlay(db, "hotpotato", { userId: ctx.user.id, username: displayName }); + recordGameTotals(db, "hotpotato", { coinsLost: amount }); + + const state = { + id: crypto.randomUUID(), + amount, + holderId: target.id, + holderName: target.name, + touched: new Map(), + reply: ctx.reply, + platform: ctx.platform, + channelKey, + db + }; + touchUser(state, ctx.user.id, displayName); + touchUser(state, target.id, target.name); + hotPotatoGames.set(gameKey, state); + resetHotPotatoTimer(gameKey, state, config); + + const msg = replyWith(db, ctx, "hotpotato_start", { + user: ctx.user.displayName || ctx.user.username, + target: target.name, + amount, + game: config.hotpotato.name, + seconds: state.seconds + }); + if (msg) { + await ctx.reply(msg); + } + return true; +} + +function resetHotPotatoTimer(gameKey, state, config) { + if (state.timer) { + clearTimeout(state.timer); + } + const seconds = randomBetween(config.hotpotato.tossMin, config.hotpotato.tossMax); + state.seconds = seconds; + state.deadlineAt = Date.now() + seconds * 1000; + state.timer = setTimeout(() => resolveHotPotato(gameKey, state.id), seconds * 1000); +} + +function touchUser(state, userId, name) { + if (!state.touched.has(userId)) { + state.touched.set(userId, name || "User"); + } +} + +function pickRandomUser({ platform, channelKey, exclude, windowSeconds }) { + const list = getActiveUsers(platform, channelKey, windowSeconds).filter( + (user) => !exclude.includes(user.id) + ); + if (!list.length) { + return null; + } + return list[Math.floor(Math.random() * list.length)]; +} + +async function resolveHotPotato(gameKey, gameId) { + const state = hotPotatoGames.get(gameKey); + if (!state || state.id !== gameId) { + return; + } + const framework = getFramework(); + if (!framework) { + hotPotatoGames.delete(gameKey); + return; + } + const config = getConfig(state.db); + const penaltyBase = state.amount || 0; + const lossTotal = Math.max( + 0, + Math.floor(penaltyBase * config.hotpotato.lossMultiplier + config.hotpotato.lossAdditive) + ); + const recipients = Array.from(state.touched.entries()).filter( + ([id]) => id !== state.holderId + ); + const perRecipient = + recipients.length && lossTotal > 0 ? Math.floor(lossTotal / recipients.length) : 0; + const winners = []; + if (perRecipient > 0 && recipients.length) { + for (const [userId, name] of recipients) { + try { + framework.createTransaction({ + type: "hotpotato_payout", + amount: perRecipient, + fromUserId: state.holderId, + toUserId: userId, + note: "Hot potato penalty", + meta: { game: "hotpotato" }, + allowNegative: true + }); + winners.push(name); + } catch { + // ignore individual payout failures + } + } + recordGameTotals(state.db, "hotpotato", { + coinsLost: lossTotal, + coinsWon: perRecipient * recipients.length + }); + } else if (lossTotal > 0) { + try { + framework.createTransaction({ + type: "hotpotato_penalty", + amount: lossTotal, + fromUserId: state.holderId, + toUserId: null, + note: "Hot potato penalty", + meta: { game: "hotpotato" }, + allowNegative: true + }); + } catch { + // ignore + } + recordGameTotals(state.db, "hotpotato", { coinsLost: lossTotal }); + } + + const msg = replyWith(state.db, { reply: state.reply }, "hotpotato_timeout", { + loser: state.holderName || "Someone", + loss: lossTotal, + winners: winners.length ? winners.join(", ") : "No one" + }); + if (msg && state.reply) { + try { + await state.reply(msg); + } catch { + // ignore + } + } + hotPotatoGames.delete(gameKey); +} + +async function handleCoinflip({ ctx, db }) { + recordPresenceFromCtx(ctx); + const config = getConfig(db); + const framework = getFramework(); + if (!framework) { + await ctx.reply("Echonomy framework is not available."); + return true; + } + if (!config.coinflip.enabled || !config.coinflip.platforms[ctx.platform]) { + return false; + } + + const amount = parseAmount(ctx.args[0]); + if (!Number.isFinite(amount) || amount < config.coinflip.minBet || amount > config.coinflip.maxBet) { + const msg = replyWith(db, ctx, "coinflip_invalid", { + min: config.coinflip.minBet, + max: config.coinflip.maxBet + }); + if (msg) { + await ctx.reply(msg); + } + return true; + } + + const cooldownLeft = getCooldownLeft(ctx, "coinflip", config.coinflip.cooldown); + if (cooldownLeft > 0) { + const msg = replyWith(db, ctx, "coinflip_cooldown", { + seconds: cooldownLeft + }); + if (msg) { + await ctx.reply(msg); + } + return true; + } + + const stakeResult = framework.removeBalance({ + userId: ctx.user.id, + amount, + note: `${config.coinflip.name} bet` + }); + if (stakeResult?.ok === false) { + const msg = replyWith(db, ctx, "coinflip_insufficient", { + reason: stakeResult.message || "Insufficient balance." + }); + if (msg) { + await ctx.reply(msg); + } + return true; + } + + setCooldown(ctx, "coinflip"); + const displayName = ctx.user.displayName || ctx.user.username || "User"; + recordGamePlay(db, "coinflip", { userId: ctx.user.id, username: displayName }); + const win = Math.random() >= 0.5; + if (win) { + const payout = Math.floor(amount * config.coinflip.multiplier); + framework.addBalance({ + userId: ctx.user.id, + amount: payout, + note: `${config.coinflip.name} win` + }); + recordGameTotals(db, "coinflip", { coinsLost: amount, coinsWon: payout }); + const msg = replyWith(db, ctx, "coinflip_win", { + user: displayName, + payout + }); + if (msg) { + await ctx.reply(msg); + } + return true; + } + + recordGameTotals(db, "coinflip", { coinsLost: amount, coinsWon: 0 }); + const msg = replyWith(db, ctx, "coinflip_lose", { + user: displayName, + amount + }); + if (msg) { + await ctx.reply(msg); + } + return true; +} + +async function handleMystery({ ctx, db }) { + recordPresenceFromCtx(ctx); + const config = getConfig(db); + const framework = getFramework(); + if (!framework) { + await ctx.reply("Echonomy framework is not available."); + return true; + } + if (!config.mystery.enabled || !config.mystery.platforms[ctx.platform]) { + return false; + } + + const amount = parseAmount(ctx.args[0]); + if (!Number.isFinite(amount) || amount < config.mystery.minBet || amount > config.mystery.maxBet) { + const msg = replyWith(db, ctx, "mystery_invalid", { + min: config.mystery.minBet, + max: config.mystery.maxBet + }); + if (msg) { + await ctx.reply(msg); + } + return true; + } + + const cooldownLeft = getCooldownLeft(ctx, "mystery", config.mystery.cooldown); + if (cooldownLeft > 0) { + const msg = replyWith(db, ctx, "mystery_cooldown", { + seconds: cooldownLeft + }); + if (msg) { + await ctx.reply(msg); + } + return true; + } + + const stakeResult = framework.removeBalance({ + userId: ctx.user.id, + amount, + note: `${config.mystery.name} entry` + }); + if (stakeResult?.ok === false) { + const msg = replyWith(db, ctx, "mystery_insufficient", { + reason: stakeResult.message || "Insufficient balance." + }); + if (msg) { + await ctx.reply(msg); + } + return true; + } + + setCooldown(ctx, "mystery"); + const displayName = ctx.user.displayName || ctx.user.username || "User"; + const maxPayout = Math.max(0, Math.floor(amount * config.mystery.multiplier)); + const payout = Math.floor(Math.random() * (maxPayout + 1)); + if (payout > 0) { + framework.addBalance({ + userId: ctx.user.id, + amount: payout, + note: `${config.mystery.name} payout` + }); + } + recordGamePlay(db, "mystery", { userId: ctx.user.id, username: displayName }); + recordGameTotals(db, "mystery", { coinsLost: amount, coinsWon: payout }); + const msg = replyWith(db, ctx, "mystery_result", { + user: displayName, + amount, + payout + }); + if (msg) { + await ctx.reply(msg); + } + return true; +} diff --git a/plugins/echonomy-games/plugin.json b/plugins/echonomy-games/plugin.json new file mode 100644 index 0000000..5392ece --- /dev/null +++ b/plugins/echonomy-games/plugin.json @@ -0,0 +1,7 @@ +{ + "id": "echonomy-games", + "name": "Echonomy Games", + "version": "0.1.5", + "description": "Cross-platform mini-games that use the Echonomy currency framework.", + "main": "index.js" +} diff --git a/plugins/echonomy-games/views/games.ejs b/plugins/echonomy-games/views/games.ejs new file mode 100644 index 0000000..67f2547 --- /dev/null +++ b/plugins/echonomy-games/views/games.ejs @@ -0,0 +1,608 @@ +<%- include("../../../src/web/views/partials/layout-top", { title }) %> + + +
+
+
+

Echonomy Games

+

Mini-games that spend and reward coins via the Echonomy framework.

+
+ + <%= frameworkReady ? 'Framework connected' : 'Framework missing' %> + +
+
+ +<% const renderLastPlayed = (entry) => { + if (!entry || entry.lastPlayedLabel === "Never") { + return "Never"; + } + const user = entry.lastPlayedUser ? ` (${entry.lastPlayedUser})` : ""; + return `${entry.lastPlayedLabel}${user}`; +}; %> + +
+
+ +
+

<%= config.hotpotato.name %>

+
+
+ + <%= config.hotpotato.enabled ? 'Enabled' : 'Disabled' %> + +
+
+ Discord + Twitch + YouTube +
+
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + Active users are pulled from recent chatters on the same platform. +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ Edit replies +
+

One reply per line. Tokens: {user}, {target}, {amount}, {payout}, {seconds}, {min}, {max}, {game}, {holder}, {loss}, {winners}, {trigger}.

+
+ <% responsesByGame.hotpotato.forEach((response) => { %> +
+ + +
+ <% }) %> +
+ +
+
+
+
+
+
+ +
+ +
+ +
+

<%= config.coinflip.name %>

+
+
+ + <%= config.coinflip.enabled ? 'Enabled' : 'Disabled' %> + +
+
+ Discord + Twitch + YouTube +
+
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ Edit replies +
+

One reply per line. Tokens: {user}, {amount}, {payout}, {min}, {max}, {seconds}.

+
+ <% responsesByGame.coinflip.forEach((response) => { %> +
+ + +
+ <% }) %> +
+ +
+
+
+
+
+
+ +
+ +
+ +
+

<%= config.mystery.name %>

+
+
+ + <%= config.mystery.enabled ? 'Enabled' : 'Disabled' %> + +
+
+ Discord + Twitch + YouTube +
+
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ Edit replies +
+

One reply per line. Tokens: {user}, {amount}, {payout}, {min}, {max}, {seconds}.

+
+ <% responsesByGame.mystery.forEach((response) => { %> +
+ + +
+ <% }) %> +
+ +
+
+
+
+
+
+ +
+
+ +<%- include("../../../src/web/views/partials/layout-bottom") %> diff --git a/plugins/expression-interaction/cmds.json b/plugins/expression-interaction/cmds.json new file mode 100644 index 0000000..54a3319 --- /dev/null +++ b/plugins/expression-interaction/cmds.json @@ -0,0 +1,9 @@ +{ + "pluginId": "expression-interaction", + "pluginName": "Expression Interaction", + "platformKeys": { + "discord": "platform_discord", + "twitch": "platform_twitch" + }, + "commands": [] +} diff --git a/plugins/expression-interaction/index.js b/plugins/expression-interaction/index.js new file mode 100644 index 0000000..4acbac8 --- /dev/null +++ b/plugins/expression-interaction/index.js @@ -0,0 +1,931 @@ +const fs = require("fs"); +const path = require("path"); + +const DEFAULT_ACTIONS = [ + { id: "hug", verb: "hugs", past: "hugged" }, + { id: "bonk", verb: "bonks", past: "bonked" }, + { id: "comfort", verb: "comforts", past: "comforted" }, + { id: "pat", verb: "pats", past: "patted" }, + { id: "cuddle", verb: "cuddles", past: "cuddled" }, + { id: "boop", verb: "boops", past: "booped" }, + { id: "highfive", verb: "high-fives", past: "high-fived", aliases: ["high-five", "hf"] }, + { id: "snuggle", verb: "snuggles", past: "snuggled" }, + { id: "cheer", verb: "cheers for", past: "cheered for" }, + { id: "headpat", verb: "headpats", past: "headpatted", aliases: ["head-pat"] }, + { id: "support", verb: "supports", past: "supported" }, + { id: "encourage", verb: "encourages", past: "encouraged" }, + { id: "stalk", verb: "stalks", past: "stalked", category: "yandere" }, + { id: "kidnap", verb: "kidnaps", past: "kidnapped", category: "yandere" }, + { id: "stab", verb: "stabs", past: "stabbed", category: "yandere" }, + { id: "claim", verb: "claims", past: "claimed", category: "yandere" } +]; + +const PLUGIN_ID = "expression-interaction"; + +let cachedConfig = null; +let cachedConfigAt = 0; + +let cachedAppToken = null; +let cachedAppTokenExpiry = 0; +let refreshCommands = null; +let pluginMeta = { dir: __dirname, name: "Expression Interaction" }; + +module.exports = { + id: PLUGIN_ID, + init({ web, settings, db, commandRouter, plugin }) { + ensureTables(db); + ensureDefaultActions(db); + pluginMeta = { + dir: plugin?.dir || __dirname, + name: plugin?.name || "Expression Interaction" + }; + writeCommandsManifest(getExpressionConfig(db)); + refreshCommands = registerExpressionCommands({ commandRouter, settings, db }); + + const router = web.createRouter(); + router.get("/", (req, res) => { + const config = getExpressionConfig(db); + const user = req.session.user || null; + res.render(path.join(__dirname, "views", "expression.ejs"), { + title: "Expression Interaction", + actions: config.actions, + platforms: config.platforms, + conflicts: config.conflicts, + stats: user ? getUserStats(db, user.id) : null, + globalStats: getGlobalStats(db), + isAdmin: Boolean(user?.isAdmin) + }); + }); + router.post("/settings", (req, res) => { + if (!req.session.user || !req.session.user.isAdmin) { + return res.status(403).render("error", { + title: "Access denied", + message: "You do not have access to that page." + }); + } + savePlatformSettings(db, req.body); + if (refreshCommands) { + refreshCommands(); + } else { + writeCommandsManifest(getExpressionConfig(db)); + } + req.session.flash = { + type: "success", + message: "Expression settings updated." + }; + res.redirect("/plugins/expression-interaction"); + }); + router.post("/actions/create", (req, res) => { + if (!req.session.user || !req.session.user.isAdmin) { + return res.status(403).render("error", { + title: "Access denied", + message: "You do not have access to that page." + }); + } + const result = createExpressionAction(db, req.body); + if (!result.ok) { + req.session.flash = { type: "error", message: result.message }; + return res.redirect("/plugins/expression-interaction"); + } + if (refreshCommands) { + refreshCommands(); + } else { + writeCommandsManifest(getExpressionConfig(db)); + } + req.session.flash = { type: "success", message: "Expression added." }; + res.redirect("/plugins/expression-interaction"); + }); + router.post("/actions/:id/update", (req, res) => { + if (!req.session.user || !req.session.user.isAdmin) { + return res.status(403).render("error", { + title: "Access denied", + message: "You do not have access to that page." + }); + } + const result = updateExpressionAction(db, req.params.id, req.body); + if (!result.ok) { + req.session.flash = { type: "error", message: result.message }; + return res.redirect("/plugins/expression-interaction"); + } + if (refreshCommands) { + refreshCommands(); + } else { + writeCommandsManifest(getExpressionConfig(db)); + } + req.session.flash = { type: "success", message: "Expression updated." }; + res.redirect("/plugins/expression-interaction"); + }); + router.post("/actions/:id/toggle", (req, res) => { + if (!req.session.user || !req.session.user.isAdmin) { + return res.status(403).render("error", { + title: "Access denied", + message: "You do not have access to that page." + }); + } + toggleExpressionAction(db, req.params.id); + invalidateConfigCache(); + if (refreshCommands) { + refreshCommands(); + } else { + writeCommandsManifest(getExpressionConfig(db)); + } + res.redirect("/plugins/expression-interaction"); + }); + router.post("/actions/:id/archive", (req, res) => { + if (!req.session.user || !req.session.user.isAdmin) { + return res.status(403).render("error", { + title: "Access denied", + message: "You do not have access to that page." + }); + } + setExpressionActionArchived(db, req.params.id, true); + invalidateConfigCache(); + if (refreshCommands) { + refreshCommands(); + } else { + writeCommandsManifest(getExpressionConfig(db)); + } + res.redirect("/plugins/expression-interaction"); + }); + router.post("/actions/:id/restore", (req, res) => { + if (!req.session.user || !req.session.user.isAdmin) { + return res.status(403).render("error", { + title: "Access denied", + message: "You do not have access to that page." + }); + } + setExpressionActionArchived(db, req.params.id, false); + invalidateConfigCache(); + if (refreshCommands) { + refreshCommands(); + } else { + writeCommandsManifest(getExpressionConfig(db)); + } + res.redirect("/plugins/expression-interaction"); + }); + web.mount("/plugins/expression-interaction", router, { + label: "Expression Interaction", + role: "public", + section: "plugins" + }); + } +}; + +function ensureTables(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS expression_actions ( + id TEXT PRIMARY KEY, + command TEXT NOT NULL, + verb TEXT, + past TEXT, + aliases TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + archived INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS expression_interactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action TEXT NOT NULL, + platform TEXT NOT NULL, + actor_user_id TEXT NOT NULL, + target_user_id TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS expression_pair_stats ( + action TEXT NOT NULL, + actor_user_id TEXT NOT NULL, + target_user_id TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (action, actor_user_id, target_user_id) + ); + + CREATE TABLE IF NOT EXISTS expression_user_stats ( + action TEXT NOT NULL, + user_id TEXT NOT NULL, + given_count INTEGER NOT NULL DEFAULT 0, + received_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (action, user_id) + ); + `); +} + +function parseBoolean(value, fallback) { + if (value === undefined || value === null || value === "") { + return fallback; + } + if (typeof value === "boolean") { + return value; + } + const normalized = value.toString().toLowerCase(); + return ["1", "true", "yes", "on"].includes(normalized); +} + +function getPluginSettings(db) { + const rows = db + .prepare("SELECT key, value FROM plugin_settings WHERE plugin_id = ?") + .all(PLUGIN_ID); + return rows.reduce((acc, row) => { + acc[row.key] = row.value; + return acc; + }, {}); +} + +function setPluginSetting(db, key, value) { + db.prepare( + "INSERT INTO plugin_settings (plugin_id, key, value, updated_at) VALUES (?, ?, ?, ?) " + + "ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at" + ).run(PLUGIN_ID, key, value, Date.now()); +} + +function normalizeCommandName(name, fallback) { + const raw = (name || fallback || "").trim().replace(/^!+/, ""); + if (!raw) { + return (fallback || "").toLowerCase(); + } + return raw.toLowerCase().replace(/\s+/g, "-"); +} + +function normalizeActionId(name) { + const raw = (name || "").trim().replace(/^!+/, "").toLowerCase(); + if (!raw) { + return ""; + } + return raw + .replace(/[^a-z0-9-_]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function conjugateVerb(name) { + const word = name.toLowerCase(); + if (word.endsWith("y") && !/[aeiou]y$/.test(word)) { + return `${word.slice(0, -1)}ies`; + } + if (/(s|x|z|ch|sh)$/.test(word)) { + return `${word}es`; + } + return `${word}s`; +} + +function conjugatePast(name) { + const word = name.toLowerCase(); + if (word.endsWith("e")) { + return `${word}d`; + } + if (word.endsWith("y") && !/[aeiou]y$/.test(word)) { + return `${word.slice(0, -1)}ied`; + } + return `${word}ed`; +} + +function parseList(value) { + return (value || "") + .toString() + .split(/[,\s]+/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function parseAliasList(value) { + if (value === undefined || value === null || value === "") { + return []; + } + if (Array.isArray(value)) { + return value.map((item) => item.toString()); + } + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) { + return parsed.map((item) => item.toString()); + } + } catch { + // ignore invalid JSON + } + return parseList(value); +} + +function normalizeAliasList(list, command) { + const seen = new Set(); + const normalized = []; + for (const entry of list || []) { + const alias = normalizeCommandName(entry, ""); + if (!alias || alias === command || seen.has(alias)) { + continue; + } + seen.add(alias); + normalized.push(alias); + } + return normalized; +} + +function ensureDefaultActions(db) { + const existing = db + .prepare("SELECT COUNT(*) AS count FROM expression_actions") + .get(); + const rows = existing?.count || 0; + const settings = getPluginSettings(db); + const now = Date.now(); + const insert = db.prepare( + "INSERT INTO expression_actions (id, command, verb, past, aliases, enabled, archived, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)" + ); + + const addAction = (action) => { + const storedName = settings[`action_${action.id}_name`] || action.id; + const command = normalizeCommandName(storedName, action.id); + const enabled = parseBoolean( + settings[`action_${action.id}_enabled`], + true + ); + const useDefaultAliases = command === normalizeCommandName(action.id, action.id); + const aliases = useDefaultAliases + ? normalizeAliasList(action.aliases || [], command) + : []; + insert.run( + action.id, + command, + action.verb || "", + action.past || "", + JSON.stringify(aliases), + enabled ? 1 : 0, + now, + now + ); + }; + + if (!rows) { + DEFAULT_ACTIONS.forEach(addAction); + return; + } + + for (const action of DEFAULT_ACTIONS) { + const existingAction = db + .prepare("SELECT id FROM expression_actions WHERE id = ?") + .get(action.id); + if (!existingAction) { + addAction(action); + } + } +} + +function getExpressionActions(db) { + const rows = db + .prepare( + "SELECT id, command, verb, past, aliases, enabled, archived, created_at, updated_at " + + "FROM expression_actions ORDER BY created_at, id" + ) + .all(); + return rows.map((row) => { + const command = normalizeCommandName(row.command, row.id); + const aliasList = normalizeAliasList(parseAliasList(row.aliases), command); + const verbOverride = (row.verb || "").toString().trim(); + const pastOverride = (row.past || "").toString().trim(); + const verb = verbOverride || conjugateVerb(command); + const past = pastOverride || conjugatePast(command); + return { + id: row.id, + command, + verb, + past, + verbOverride, + pastOverride, + aliases: aliasList, + enabled: Boolean(row.enabled), + archived: Boolean(row.archived), + createdAt: row.created_at, + updatedAt: row.updated_at + }; + }); +} + +function getExpressionConfig(db) { + const now = Date.now(); + if (cachedConfig && now - cachedConfigAt < 5000) { + return cachedConfig; + } + + const settings = getPluginSettings(db); + const platforms = { + discord: parseBoolean(settings.platform_discord, true), + twitch: parseBoolean(settings.platform_twitch, true) + }; + + const conflicts = new Set(); + const actionByTrigger = new Map(); + const actions = getExpressionActions(db); + actions + .filter((action) => action.enabled && !action.archived) + .forEach((action) => { + const triggers = new Set([action.command, ...(action.aliases || [])]); + for (const trigger of triggers) { + if (actionByTrigger.has(trigger)) { + conflicts.add(trigger); + continue; + } + actionByTrigger.set(trigger, action); + } + }); + + cachedConfig = { + platforms, + actions, + actionByTrigger, + conflicts: Array.from(conflicts) + }; + cachedConfigAt = now; + return cachedConfig; +} + +function invalidateConfigCache() { + cachedConfig = null; + cachedConfigAt = 0; +} + +function savePlatformSettings(db, body) { + const platformDiscord = body.platform_discord === "on"; + const platformTwitch = body.platform_twitch === "on"; + setPluginSetting(db, "platform_discord", platformDiscord ? "1" : "0"); + setPluginSetting(db, "platform_twitch", platformTwitch ? "1" : "0"); + invalidateConfigCache(); +} + +function createExpressionAction(db, body) { + const rawId = (body.action_id || "").trim(); + const rawCommand = (body.action_command || "").trim(); + const id = normalizeActionId(rawId || rawCommand); + if (!id) { + return { ok: false, message: "Action id is required." }; + } + const existing = db + .prepare("SELECT id FROM expression_actions WHERE id = ?") + .get(id); + if (existing) { + return { ok: false, message: "That action id already exists." }; + } + const command = normalizeCommandName(rawCommand || id, id); + if (!command) { + return { ok: false, message: "Command name is required." }; + } + const verb = (body.action_verb || "").toString().trim(); + const past = (body.action_past || "").toString().trim(); + const aliases = normalizeAliasList( + parseList(body.action_aliases || ""), + command + ); + const enabled = body.action_enabled === "on"; + const now = Date.now(); + db.prepare( + "INSERT INTO expression_actions (id, command, verb, past, aliases, enabled, archived, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)" + ).run( + id, + command, + verb, + past, + JSON.stringify(aliases), + enabled ? 1 : 0, + now, + now + ); + invalidateConfigCache(); + return { ok: true }; +} + +function updateExpressionAction(db, id, body) { + const existing = db + .prepare("SELECT id FROM expression_actions WHERE id = ?") + .get(id); + if (!existing) { + return { ok: false, message: "Expression not found." }; + } + const rawCommand = (body.action_command || "").trim(); + const command = normalizeCommandName(rawCommand || id, id); + if (!command) { + return { ok: false, message: "Command name is required." }; + } + const verb = (body.action_verb || "").toString().trim(); + const past = (body.action_past || "").toString().trim(); + const aliases = normalizeAliasList( + parseList(body.action_aliases || ""), + command + ); + const enabled = body.action_enabled === "on"; + const now = Date.now(); + db.prepare( + "UPDATE expression_actions SET command = ?, verb = ?, past = ?, aliases = ?, enabled = ?, updated_at = ? WHERE id = ?" + ).run( + command, + verb, + past, + JSON.stringify(aliases), + enabled ? 1 : 0, + now, + id + ); + invalidateConfigCache(); + return { ok: true }; +} + +function toggleExpressionAction(db, id) { + const row = db + .prepare("SELECT enabled FROM expression_actions WHERE id = ?") + .get(id); + if (!row) { + return; + } + const next = row.enabled ? 0 : 1; + db.prepare( + "UPDATE expression_actions SET enabled = ?, updated_at = ? WHERE id = ?" + ).run(next, Date.now(), id); +} + +function setExpressionActionArchived(db, id, archived) { + db.prepare( + "UPDATE expression_actions SET archived = ?, updated_at = ? WHERE id = ?" + ).run(archived ? 1 : 0, Date.now(), id); +} + +function getUserStats(db, userId) { + const rows = db + .prepare( + "SELECT action, given_count, received_count FROM expression_user_stats WHERE user_id = ?" + ) + .all(userId); + const totals = rows.reduce( + (acc, row) => { + acc.given += row.given_count; + acc.received += row.received_count; + return acc; + }, + { given: 0, received: 0 } + ); + const byAction = rows.reduce((acc, row) => { + acc[row.action] = row; + return acc; + }, {}); + return { totals, byAction }; +} + +function getGlobalStats(db) { + const total = db + .prepare("SELECT COUNT(*) AS count FROM expression_interactions") + .get(); + const byAction = db + .prepare( + "SELECT action, COUNT(*) AS count FROM expression_interactions GROUP BY action ORDER BY count DESC" + ) + .all(); + return { + total: total?.count || 0, + byAction + }; +} + +function registerExpressionCommands({ commandRouter, settings, db }) { + if (!commandRouter) { + return null; + } + + const rebuild = () => { + const config = getExpressionConfig(db); + const platforms = []; + if (config.platforms.discord) { + platforms.push("discord"); + } + if (config.platforms.twitch) { + platforms.push("twitch"); + } + + if (!platforms.length) { + writeCommandsManifest(config); + commandRouter.registerCommands(PLUGIN_ID, []); + return; + } + + const commands = config.actions + .filter((action) => action.enabled && !action.archived) + .map((action) => { + const triggers = new Set([action.command, ...(action.aliases || [])]); + const filtered = Array.from(triggers).filter((trigger) => { + const mapped = config.actionByTrigger.get(trigger); + return mapped && mapped.id === action.id; + }); + if (!filtered.length) { + return null; + } + return { + triggers: filtered, + platforms, + handler: async (ctx) => { + return await handleExpressionCommand({ + ctx, + actionId: action.id, + settings, + db + }); + } + }; + }) + .filter(Boolean); + + commandRouter.registerCommands(PLUGIN_ID, commands); + writeCommandsManifest(config); + }; + + rebuild(); + return rebuild; +} + +function writeCommandsManifest(config) { + if (!pluginMeta?.dir) { + return; + } + const toTitle = (value) => + (value || "").replace(/(^|\s|-)(\w)/g, (_m, sep, char) => + `${sep || ""}${char.toUpperCase()}` + ); + const commands = (config.actions || []) + .filter((action) => action.enabled && !action.archived) + .map((action) => ({ + id: action.id, + trigger: action.command, + usage: `${action.command} `, + name: toTitle(action.command), + description: `Send a ${action.command} to another user.`, + level: "public", + platforms: ["discord", "twitch"], + aliases: action.aliases || [] + })); + const manifest = { + pluginId: PLUGIN_ID, + pluginName: pluginMeta?.name || "Expression Interaction", + platformKeys: { + discord: "platform_discord", + twitch: "platform_twitch" + }, + commands + }; + try { + const target = path.join(pluginMeta.dir, "cmds.json"); + fs.writeFileSync(target, JSON.stringify(manifest, null, 2), "utf8"); + } catch (error) { + console.error("Failed to write expression command manifest", error); + } +} + +async function handleExpressionCommand({ ctx, actionId, settings, db }) { + const { ensureUserForIdentity } = require("../../src/services/users"); + const config = getExpressionConfig(db); + if (!config.platforms[ctx.platform]) { + return false; + } + const action = config.actions.find((item) => item.id === actionId); + if (!action || !action.enabled || action.archived) { + return false; + } + + const prefix = settings.getSetting("command_prefix", "!"); + const targetToken = ctx.args[0]; + if (!targetToken) { + const usageTarget = ctx.platform === "discord" ? "@username" : "username"; + await ctx.reply(`Usage: ${prefix}${action.command} ${usageTarget}`); + return true; + } + + if (ctx.platform === "discord") { + const message = ctx.meta?.message; + const targetInfo = await resolveDiscordTarget( + message, + targetToken, + ensureUserForIdentity + ); + if (!targetInfo) { + await ctx.reply("I couldn't find that user. Try mentioning them."); + return true; + } + const stats = recordInteraction( + db, + action.id, + "discord", + ctx.user.id, + targetInfo.profile.id + ); + const response = buildResponse({ + action, + actorLabel: `<@${ctx.platformUser.id}>`, + targetLabel: targetInfo.label, + actorName: ctx.user.username, + targetName: targetInfo.profile.internal_username, + stats + }); + await ctx.reply(response); + return true; + } + + if (ctx.platform === "twitch") { + const targetLogin = targetToken.replace(/^@/, "").trim(); + if (!targetLogin) { + await ctx.reply(`Usage: ${prefix}${action.command} username`); + return true; + } + const targetResolved = await resolveTwitchTarget( + targetLogin, + settings, + ensureUserForIdentity + ); + const stats = recordInteraction( + db, + action.id, + "twitch", + ctx.user.id, + targetResolved.profile.id + ); + const response = buildResponse({ + action, + actorLabel: `@${ctx.platformUser.username || ctx.platformUser.displayName}`, + targetLabel: targetResolved.label, + actorName: ctx.user.username, + targetName: targetResolved.profile.internal_username, + stats + }); + await ctx.reply(response); + return true; + } + + return false; +} + +async function resolveDiscordTarget(message, token, ensureUserForIdentity) { + if (message?.mentions?.users?.first) { + const mention = message.mentions.users.first(); + if (mention) { + const display = + mention.globalName || mention.username || mention.tag || mention.id; + const profile = ensureUserForIdentity({ + provider: "discord", + providerUserId: mention.id, + displayName: display + }); + return { profile, label: `<@${mention.id}>` }; + } + } + + const idMatch = token.match(/^<@!?(\d+)>$/) || token.match(/^(\d{15,})$/); + if (idMatch && message?.client?.users?.fetch) { + const id = idMatch[1]; + const user = await message.client.users.fetch(id).catch(() => null); + if (user) { + const display = user.globalName || user.username || user.tag || user.id; + const profile = ensureUserForIdentity({ + provider: "discord", + providerUserId: user.id, + displayName: display + }); + return { profile, label: `<@${user.id}>` }; + } + } + + const name = token.replace(/^@/, "").trim(); + if (!name) { + return null; + } + const profile = ensureUserForIdentity({ + provider: "discord_name", + providerUserId: name.toLowerCase(), + displayName: name, + fallbackName: name + }); + return { profile, label: name }; +} + +function recordInteraction(db, action, platform, actorUserId, targetUserId) { + const now = Date.now(); + db.prepare( + "INSERT INTO expression_interactions (action, platform, actor_user_id, target_user_id, created_at) VALUES (?, ?, ?, ?, ?)" + ).run(action, platform, actorUserId, targetUserId, now); + + db.prepare( + "INSERT INTO expression_pair_stats (action, actor_user_id, target_user_id, count) VALUES (?, ?, ?, 1) " + + "ON CONFLICT(action, actor_user_id, target_user_id) DO UPDATE SET count = count + 1" + ).run(action, actorUserId, targetUserId); + + db.prepare( + "INSERT INTO expression_user_stats (action, user_id, given_count, received_count) VALUES (?, ?, 1, 0) " + + "ON CONFLICT(action, user_id) DO UPDATE SET given_count = given_count + 1" + ).run(action, actorUserId); + + db.prepare( + "INSERT INTO expression_user_stats (action, user_id, given_count, received_count) VALUES (?, ?, 0, 1) " + + "ON CONFLICT(action, user_id) DO UPDATE SET received_count = received_count + 1" + ).run(action, targetUserId); + + const pair = db + .prepare( + "SELECT count FROM expression_pair_stats WHERE action = ? AND actor_user_id = ? AND target_user_id = ?" + ) + .get(action, actorUserId, targetUserId); + const actorTotals = db + .prepare( + "SELECT given_count FROM expression_user_stats WHERE action = ? AND user_id = ?" + ) + .get(action, actorUserId); + const targetTotals = db + .prepare( + "SELECT received_count FROM expression_user_stats WHERE action = ? AND user_id = ?" + ) + .get(action, targetUserId); + const globalTotals = db + .prepare("SELECT COUNT(*) AS count FROM expression_interactions WHERE action = ?") + .get(action); + + return { + pairCount: pair?.count || 1, + actorTotal: actorTotals?.given_count || 1, + targetTotal: targetTotals?.received_count || 1, + globalTotal: globalTotals?.count || 1 + }; +} + +function buildResponse({ action, actorLabel, targetLabel, actorName, targetName, stats }) { + const main = `${actorLabel} ${action.verb} ${targetLabel}.`; + const options = [ + `${actorName} has ${action.past} ${targetName} ${stats.pairCount} times.`, + `${actorName} has ${action.past} ${stats.actorTotal} times total.`, + `${targetName} has been ${action.past} ${stats.targetTotal} times.`, + `This action has been used ${stats.globalTotal} times.` + ]; + const detail = options[Math.floor(Math.random() * options.length)]; + return `${main} ${detail}`; +} + +async function resolveTwitchTarget(login, settings, ensureUserForIdentity) { + const cleaned = login.toLowerCase(); + const resolved = await fetchTwitchUser(cleaned, settings); + if (resolved) { + const profile = ensureUserForIdentity({ + provider: "twitch", + providerUserId: resolved.id, + displayName: resolved.display_name + }); + return { profile, label: `@${resolved.login || cleaned}` }; + } + const profile = ensureUserForIdentity({ + provider: "twitch_login", + providerUserId: cleaned, + displayName: cleaned, + fallbackName: cleaned + }); + return { profile, label: `@${cleaned}` }; +} + +async function fetchTwitchUser(login, settings) { + const clientId = settings.getSetting("twitch_client_id"); + const clientSecret = settings.getSetting("twitch_client_secret"); + if (!clientId || !clientSecret) { + return null; + } + const token = await getTwitchAppToken(clientId, clientSecret); + if (!token) { + return null; + } + const response = await fetch( + `https://api.twitch.tv/helix/users?login=${encodeURIComponent(login)}`, + { + headers: { + "Client-Id": clientId, + Authorization: `Bearer ${token}` + } + } + ); + if (!response.ok) { + return null; + } + const data = await response.json(); + return data.data?.[0] || null; +} + +async function getTwitchAppToken(clientId, clientSecret) { + const now = Date.now(); + if (cachedAppToken && now < cachedAppTokenExpiry) { + return cachedAppToken; + } + const url = + "https://id.twitch.tv/oauth2/token" + + `?client_id=${encodeURIComponent(clientId)}` + + `&client_secret=${encodeURIComponent(clientSecret)}` + + "&grant_type=client_credentials"; + const response = await fetch(url, { method: "POST" }); + if (!response.ok) { + return null; + } + const data = await response.json(); + if (!data.access_token || !data.expires_in) { + return null; + } + cachedAppToken = data.access_token; + cachedAppTokenExpiry = now + (data.expires_in - 60) * 1000; + return cachedAppToken; +} diff --git a/plugins/expression-interaction/plugin.json b/plugins/expression-interaction/plugin.json new file mode 100644 index 0000000..d049774 --- /dev/null +++ b/plugins/expression-interaction/plugin.json @@ -0,0 +1,7 @@ +{ + "id": "expression-interaction", + "name": "Expression Interaction", + "version": "0.2.0", + "description": "Express yourself through interactions with other users, such as hugging, bonking, comforting, etc", + "main": "index.js" +} diff --git a/plugins/expression-interaction/views/expression.ejs b/plugins/expression-interaction/views/expression.ejs new file mode 100644 index 0000000..4df33df --- /dev/null +++ b/plugins/expression-interaction/views/expression.ejs @@ -0,0 +1,259 @@ +<%- include("../../../src/web/views/partials/layout-top", { title }) %> +
+

Expression Interaction

+

Roleplay friendly interactions from Discord or Twitch with quick commands.

+

+ Commands: + <% const enabledActions = actions.filter((action) => action.enabled && !action.archived); %> + <% if (!enabledActions.length) { %> + None enabled yet. + <% } else { %> + <%= enabledActions.map((action) => `!${action.command}`).join(", ") %> + <% } %> +

+
+ +
+

Your stats

+ <% if (!stats) { %> +

Sign in to see how many actions you have given or received.

+ <% } else { %> +
+
+ Given + <%= stats.totals.given %> +
+
+ Received + <%= stats.totals.received %> +
+
+ + + + + + + + + + <% const statActions = actions.filter((action) => !action.archived); %> + <% statActions.forEach((action) => { %> + <% const row = stats.byAction[action.id] || { given_count: 0, received_count: 0 }; %> + + + + + + <% }) %> + +
ActionGivenReceived
<%= action.command %><%= row.given_count %><%= row.received_count %>
+ <% } %> +
+ +
+

Global stats

+
+
+ Total interactions + <%= globalStats.total %> +
+
+ <% if (!globalStats.byAction.length) { %> +

No interactions recorded yet.

+ <% } else { %> + + + + + + + + + <% globalStats.byAction.forEach((row) => { %> + <% const action = actions.find((item) => item.id === row.action); %> + + + + + <% }) %> + +
ActionTotal
<%= action ? action.command : row.action %><%= row.count %>
+ <% } %> +
+ +<% if (isAdmin) { %> +
+

Settings

+ <% if (conflicts && conflicts.length) { %> +
+ Conflicting command names: <%= conflicts.join(", ") %>. Rename the duplicates. +
+ <% } %> +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+

Expressions

+
+
+ + + Used for stats and tracking. Avoid changing it once created. +
+
+ + +
+
+ + +
+
+ + +
+
+ + + Command names are lowercased; spaces become dashes. +
+
+ + +
+
+ +
+
+ + <% if (!actions.length) { %> +

No expressions yet.

+ <% } else { %> + + + + + + + + + + + + + + <% actions.forEach((action) => { %> + + + + + + + + + + + + + <% }) %> + +
ActionCommandVerbPastAliasesStatusActions
<%= action.id %><%= action.command %><%= action.verb %><%= action.past %><%= action.aliases.length ? action.aliases.join(", ") : '-' %> + <%= action.archived ? 'Archived' : action.enabled ? 'Enabled' : 'Disabled' %> + +
+ +
+ <% if (action.archived) { %> +
+ +
+ <% } else { %> +
+ +
+ <% } %> + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + Leave tense fields blank to auto-conjugate. +
+
+ + +
+
+ +
+
+
+ <% } %> +
+<% } %> + +<%- include("../../../src/web/views/partials/layout-bottom") %> diff --git a/plugins/moderation/index.js b/plugins/moderation/index.js new file mode 100644 index 0000000..cadd75c --- /dev/null +++ b/plugins/moderation/index.js @@ -0,0 +1,1139 @@ +const path = require("path"); +const fs = require("fs"); +const crypto = require("crypto"); +const multer = require("multer"); + +const PLUGIN_ID = "moderation"; +const EVIDENCE_DIR = path.join(__dirname, "..", "..", "data", "moderation", "evidence"); +const PRESET_DURATIONS = [ + { label: "1 hour", seconds: 60 * 60 }, + { label: "3 hours", seconds: 3 * 60 * 60 }, + { label: "6 hours", seconds: 6 * 60 * 60 }, + { label: "12 hours", seconds: 12 * 60 * 60 }, + { label: "1 day", seconds: 24 * 60 * 60 }, + { label: "7 days", seconds: 7 * 24 * 60 * 60 }, + { label: "14 days", seconds: 14 * 24 * 60 * 60 }, + { label: "1 month", seconds: 30 * 24 * 60 * 60 }, + { label: "3 months", seconds: 90 * 24 * 60 * 60 }, + { label: "6 months", seconds: 180 * 24 * 60 * 60 }, + { label: "9 months", seconds: 270 * 24 * 60 * 60 }, + { label: "1 year", seconds: 365 * 24 * 60 * 60 } +]; + +module.exports = { + id: PLUGIN_ID, + init({ app, web, db, settings, discordClient, twitchClient, youtubeClient }) { + ensureTables(db); + ensureBanPot(db); + ensureEvidenceDir(); + + const upload = multer({ + storage: multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, EVIDENCE_DIR), + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname || ".png").slice(0, 10); + cb(null, `${crypto.randomUUID()}${ext}`); + } + }) + }); + + installGlobalGate(app, (req, res, next) => { + if (!req.session?.user) { + return next(); + } + if (req.path.startsWith("/auth")) { + return next(); + } + if (req.path.startsWith("/moderation/status")) { + return next(); + } + linkSubjectToUser(db, req.session.user.id); + const sanction = getActiveSanctionForUser(db, req.session.user.id); + if (!sanction) { + return next(); + } + res.status(403).render(path.join(__dirname, "views", "status.ejs"), { + title: "Account restricted", + sanction + }); + }); + + const router = web.createRouter(); + + router.get("/status", (req, res) => { + if (!req.session?.user) { + return res.redirect("/"); + } + linkSubjectToUser(db, req.session.user.id); + const sanction = getActiveSanctionForUser(db, req.session.user.id); + if (!sanction) { + return res.redirect("/"); + } + res.status(403).render(path.join(__dirname, "views", "status.ejs"), { + title: "Account restricted", + sanction + }); + }); + + router.get("/", (req, res) => { + if (!req.session.user) { + return res.redirect("/"); + } + const isAdmin = Boolean(req.session.user?.isAdmin); + const isMod = Boolean(req.session.user?.isAdmin || req.session.user?.isMod); + const userDirectory = listUserDirectory(db); + const actions = listActions(db, { limit: 500 }); + const actionEvidence = listEvidenceForActions( + db, + actions.map((action) => action.id) + ); + const notes = listNotes(db, { limit: 1000 }); + const activeSanctions = listActiveSanctions(db); + const banPot = getBanPot(db); + res.render(path.join(__dirname, "views", "moderation.ejs"), { + title: "Moderation Center", + isAdmin, + isMod, + userDirectory, + actions, + actionEvidence, + notes, + activeSanctions, + banPot, + presets: PRESET_DURATIONS + }); + }); + + router.get("/tos-bans", (req, res) => { + if (!req.session.user) { + return res.redirect("/"); + } + const isAdmin = Boolean(req.session.user?.isAdmin); + const isMod = Boolean(req.session.user?.isAdmin || req.session.user?.isMod); + if (!isMod) { + return deny(res); + } + const actions = listActions(db, { limit: 500 }); + const actionEvidence = listEvidenceForActions( + db, + actions.map((action) => action.id) + ); + const activeSanctions = listActiveSanctions(db); + res.render(path.join(__dirname, "views", "tos-bans.ejs"), { + title: "TOs & Bans", + isAdmin, + isMod, + actions, + actionEvidence, + activeSanctions, + presets: PRESET_DURATIONS + }); + }); + + router.get("/evidence/:id", (req, res) => { + if (!req.session.user) { + return res.redirect("/"); + } + const isMod = Boolean(req.session.user?.isAdmin || req.session.user?.isMod); + if (!isMod) { + return deny(res); + } + const row = db + .prepare("SELECT file_path, file_name FROM moderation_evidence WHERE id = ?") + .get(req.params.id); + if (!row?.file_path) { + return res.status(404).render("error", { + title: "Not found", + message: "Evidence file not found." + }); + } + res.download(row.file_path, row.file_name || path.basename(row.file_path)); + }); + + router.post("/actions", upload.array("evidence_files", 4), async (req, res) => { + if (!req.session.user) { + return res.redirect("/"); + } + const isAdmin = Boolean(req.session.user?.isAdmin); + const isMod = Boolean(req.session.user?.isAdmin || req.session.user?.isMod); + if (!isMod) { + return deny(res); + } + + const actionType = (req.body.action_type || "").toLowerCase(); + if (actionType === "kick") { + req.session.flash = { + type: "info", + message: "Kick actions are coming soon." + }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + + const target = resolveTarget(db, req.body); + if (!target) { + req.session.flash = { type: "error", message: "Target not found." }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + + const reasonShort = (req.body.reason_short || "").trim(); + const reasonDetail = (req.body.reason_detail || "").trim(); + if (!reasonShort || !reasonDetail) { + req.session.flash = { + type: "error", + message: "Both summary and detailed reasons are required." + }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + + const durationSeconds = + actionType === "timeout" + ? buildDurationSeconds(req.body, isAdmin, isMod) + : null; + const createdBy = req.session.user.username || "Moderator"; + const createdById = req.session.user.id; + const action = createAction(db, { + subjectId: target.subjectId, + actionType, + scope: "global", + platform: "global", + reasonShort, + reasonDetail, + durationSeconds, + createdById, + createdByName: createdBy, + source: "manual" + }); + + const evidenceFiles = (req.files || []).map((file) => ({ + path: file.path, + name: file.originalname + })); + evidenceFiles.forEach((file) => { + addEvidence(db, action.id, file.path, file.name, createdById); + }); + + const identities = listSubjectIdentities(db, target.subjectId); + await enforceAction({ + action, + identities, + settings, + discordClient, + twitchClient, + youtubeClient, + reasonShort, + reasonDetail + }); + + if (actionType === "ban") { + distributeBanAssets(db, target.subjectId, { reason: reasonShort }); + } + + req.session.flash = { type: "success", message: "Moderation action recorded." }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/actions/:id/update-timeout", (req, res) => { + if (!req.session.user) { + return res.redirect("/"); + } + const isMod = Boolean(req.session.user?.isAdmin || req.session.user?.isMod); + if (!isMod) { + return deny(res); + } + const action = getAction(db, req.params.id); + if (!action || action.action_type !== "timeout") { + req.session.flash = { type: "error", message: "Timeout not found." }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + const durationSeconds = buildDurationSeconds(req.body, req.session.user?.isAdmin, true); + const expiresAt = durationSeconds ? Date.now() + durationSeconds * 1000 : null; + updateActionDuration(db, action.id, durationSeconds, expiresAt); + req.session.flash = { type: "success", message: "Timeout updated." }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/actions/:id/revoke", async (req, res) => { + if (!req.session.user) { + return res.redirect("/"); + } + const action = getAction(db, req.params.id); + if (!action) { + req.session.flash = { type: "error", message: "Action not found." }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + const isAdmin = Boolean(req.session.user?.isAdmin); + const isMod = Boolean(req.session.user?.isAdmin || req.session.user?.isMod); + if (action.action_type === "ban" && !isAdmin) { + return deny(res); + } + if (action.action_type === "timeout" && !isMod) { + return deny(res); + } + setActionStatus(db, action.id, "revoked"); + + const identities = listSubjectIdentities(db, action.subject_id); + await revokeAction({ + action, + identities, + settings, + discordClient, + twitchClient, + youtubeClient + }); + + req.session.flash = { type: "success", message: "Action revoked." }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/notes", (req, res) => { + if (!req.session.user) { + return res.redirect("/"); + } + const isMod = Boolean(req.session.user?.isAdmin || req.session.user?.isMod); + if (!isMod) { + return deny(res); + } + const target = resolveTarget(db, req.body); + const note = (req.body.note || "").trim(); + if (!target || !note) { + req.session.flash = { type: "error", message: "Target and note are required." }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + addNote(db, target.subjectId, note, req.session.user.id, req.session.user.username || "Moderator"); + req.session.flash = { type: "success", message: "Note added." }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + web.mount(`/plugins/${PLUGIN_ID}`, router, { + label: "Moderation", + role: "mod", + section: "moderation" + }); + + web.addNavItem({ + label: "TOs & Bans", + path: `/plugins/${PLUGIN_ID}/tos-bans`, + role: "mod", + section: "moderation" + }); + + if (discordClient) { + startDiscordAuditPolling(db, settings, discordClient); + } + if (twitchClient) { + attachTwitchModerationEvents(db, twitchClient); + } + installFreezeHook(db); + } +}; + +function deny(res) { + return res.status(403).render("error", { + title: "Access denied", + message: "You do not have access to that page." + }); +} + +function ensureEvidenceDir() { + fs.mkdirSync(EVIDENCE_DIR, { recursive: true }); +} + +function ensureTables(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS moderation_subjects ( + id TEXT PRIMARY KEY, + internal_user_id TEXT, + display_name TEXT, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS moderation_identities ( + id TEXT PRIMARY KEY, + subject_id TEXT NOT NULL, + platform TEXT NOT NULL, + platform_user_id TEXT, + platform_username TEXT, + created_at INTEGER NOT NULL, + UNIQUE(platform, platform_user_id) + ); + + CREATE TABLE IF NOT EXISTS moderation_actions ( + id TEXT PRIMARY KEY, + subject_id TEXT NOT NULL, + action_type TEXT NOT NULL, + scope TEXT NOT NULL, + platform TEXT, + source TEXT, + status TEXT NOT NULL, + duration_seconds INTEGER, + reason_short TEXT NOT NULL, + reason_detail TEXT NOT NULL, + created_by_user_id TEXT, + created_by_name TEXT, + created_at INTEGER NOT NULL, + expires_at INTEGER, + external_ref TEXT + ); + + CREATE TABLE IF NOT EXISTS moderation_evidence ( + id TEXT PRIMARY KEY, + action_id TEXT NOT NULL, + file_path TEXT NOT NULL, + file_name TEXT, + uploaded_by TEXT, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS moderation_notes ( + id TEXT PRIMARY KEY, + subject_id TEXT NOT NULL, + note TEXT NOT NULL, + created_by_user_id TEXT, + created_by_name TEXT, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS moderation_ban_pot ( + id INTEGER PRIMARY KEY CHECK (id = 1), + balance INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL + ); + `); +} + +function ensureBanPot(db) { + const existing = db.prepare("SELECT id FROM moderation_ban_pot WHERE id = 1").get(); + if (!existing) { + db.prepare("INSERT INTO moderation_ban_pot (id, balance, updated_at) VALUES (1, 0, ?)") + .run(Date.now()); + } +} + +function listUserDirectory(db) { + const users = db.prepare("SELECT id, internal_username FROM user_profiles ORDER BY internal_username").all(); + const identities = db + .prepare("SELECT user_id, provider, provider_user_id, display_name FROM user_identities") + .all(); + const map = new Map(); + users.forEach((user) => { + map.set(user.id, { id: user.id, internal: user.internal_username, identities: [] }); + }); + identities.forEach((row) => { + if (!map.has(row.user_id)) { + map.set(row.user_id, { id: row.user_id, internal: row.user_id, identities: [] }); + } + map.get(row.user_id).identities.push({ + label: row.provider, + id: row.provider_user_id, + display: row.display_name || row.provider_user_id + }); + }); + return Array.from(map.values()); +} + +function resolveTarget(db, body) { + const internal = (body.target_username || "").trim(); + if (internal) { + const user = db + .prepare("SELECT id, internal_username FROM user_profiles WHERE internal_username = ?") + .get(internal); + if (user) { + const subjectId = getOrCreateSubjectByUser(db, user.id, user.internal_username); + syncSubjectIdentities(db, subjectId, user.id); + return { subjectId, internalUserId: user.id }; + } + } + + const platform = (body.target_platform || "").trim().toLowerCase(); + const platformId = (body.target_platform_id || "").trim(); + const platformUsername = (body.target_platform_username || "").trim(); + if (!platform) { + return null; + } + const key = platformId || platformUsername; + if (!key) { + return null; + } + const subjectId = getOrCreateSubjectByIdentity( + db, + platform, + key, + platformUsername || platformId + ); + return { subjectId, internalUserId: null }; +} + +function getOrCreateSubjectByUser(db, userId, displayName) { + const existing = db + .prepare("SELECT id FROM moderation_subjects WHERE internal_user_id = ?") + .get(userId); + if (existing) { + return existing.id; + } + const id = crypto.randomUUID(); + db.prepare( + "INSERT INTO moderation_subjects (id, internal_user_id, display_name, created_at) VALUES (?, ?, ?, ?)" + ).run(id, userId, displayName || null, Date.now()); + return id; +} + +function getOrCreateSubjectByIdentity(db, platform, platformUserId, platformUsername) { + const existing = db + .prepare("SELECT subject_id FROM moderation_identities WHERE platform = ? AND platform_user_id = ?") + .get(platform, platformUserId); + if (existing) { + return existing.subject_id; + } + const subjectId = crypto.randomUUID(); + db.prepare( + "INSERT INTO moderation_subjects (id, internal_user_id, display_name, created_at) VALUES (?, NULL, ?, ?)" + ).run(subjectId, platformUsername || platformUserId, Date.now()); + db.prepare( + "INSERT INTO moderation_identities (id, subject_id, platform, platform_user_id, platform_username, created_at) VALUES (?, ?, ?, ?, ?, ?)" + ).run(crypto.randomUUID(), subjectId, platform, platformUserId, platformUsername || null, Date.now()); + return subjectId; +} + +function syncSubjectIdentities(db, subjectId, userId) { + const identities = db + .prepare("SELECT provider, provider_user_id, display_name FROM user_identities WHERE user_id = ?") + .all(userId); + identities.forEach((identity) => { + const existing = db + .prepare( + "SELECT id FROM moderation_identities WHERE platform = ? AND platform_user_id = ?" + ) + .get(identity.provider, identity.provider_user_id); + if (existing) { + return; + } + db.prepare( + "INSERT INTO moderation_identities (id, subject_id, platform, platform_user_id, platform_username, created_at) VALUES (?, ?, ?, ?, ?, ?)" + ).run( + crypto.randomUUID(), + subjectId, + identity.provider, + identity.provider_user_id, + identity.display_name || identity.provider_user_id, + Date.now() + ); + }); +} + +function linkSubjectToUser(db, userId) { + const subject = db + .prepare("SELECT id FROM moderation_subjects WHERE internal_user_id = ?") + .get(userId); + if (subject) { + return subject.id; + } + const identities = db + .prepare("SELECT provider, provider_user_id, display_name FROM user_identities WHERE user_id = ?") + .all(userId); + for (const identity of identities) { + const existing = db + .prepare( + "SELECT subject_id FROM moderation_identities WHERE platform = ? AND platform_user_id = ?" + ) + .get(identity.provider, identity.provider_user_id); + if (existing) { + db.prepare( + "UPDATE moderation_subjects SET internal_user_id = ?, display_name = COALESCE(display_name, ?) WHERE id = ?" + ).run(userId, identity.display_name || identity.provider_user_id, existing.subject_id); + return existing.subject_id; + } + } + return null; +} + +function listSubjectIdentities(db, subjectId) { + return db + .prepare( + "SELECT platform, platform_user_id, platform_username FROM moderation_identities WHERE subject_id = ?" + ) + .all(subjectId); +} + +function buildDurationSeconds(body, isAdmin, isMod) { + if (body.permanent === "on") { + return null; + } + if (isAdmin && body.duration_value) { + const value = Number(body.duration_value); + const unit = (body.duration_unit || "hours").toLowerCase(); + if (Number.isFinite(value) && value > 0) { + const multipliers = { + hour: 3600, + hours: 3600, + day: 86400, + days: 86400, + week: 604800, + weeks: 604800, + month: 2592000, + months: 2592000, + year: 31536000, + years: 31536000 + }; + const multiplier = multipliers[unit] || 3600; + return Math.floor(value * multiplier); + } + } + if (isMod && body.duration_preset) { + const preset = PRESET_DURATIONS.find( + (entry) => entry.seconds.toString() === body.duration_preset.toString() + ); + return preset ? preset.seconds : null; + } + return null; +} + +function createAction(db, payload) { + if (payload.externalRef) { + const existing = db + .prepare("SELECT id FROM moderation_actions WHERE external_ref = ?") + .get(payload.externalRef); + if (existing) { + return getAction(db, existing.id); + } + } + const now = Date.now(); + const expiresAt = payload.durationSeconds ? now + payload.durationSeconds * 1000 : null; + const action = { + id: crypto.randomUUID(), + subject_id: payload.subjectId, + action_type: payload.actionType, + scope: payload.scope || "global", + platform: payload.platform || "global", + source: payload.source || "manual", + status: "active", + duration_seconds: payload.durationSeconds || null, + reason_short: payload.reasonShort, + reason_detail: payload.reasonDetail, + created_by_user_id: payload.createdById || null, + created_by_name: payload.createdByName || null, + created_at: now, + expires_at: expiresAt, + external_ref: payload.externalRef || null + }; + db.prepare( + "INSERT INTO moderation_actions (id, subject_id, action_type, scope, platform, source, status, duration_seconds, reason_short, reason_detail, created_by_user_id, created_by_name, created_at, expires_at, external_ref) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ).run( + action.id, + action.subject_id, + action.action_type, + action.scope, + action.platform, + action.source, + action.status, + action.duration_seconds, + action.reason_short, + action.reason_detail, + action.created_by_user_id, + action.created_by_name, + action.created_at, + action.expires_at, + action.external_ref + ); + return action; +} + +function addEvidence(db, actionId, filePath, fileName, uploadedBy) { + db.prepare( + "INSERT INTO moderation_evidence (id, action_id, file_path, file_name, uploaded_by, created_at) VALUES (?, ?, ?, ?, ?, ?)" + ).run(crypto.randomUUID(), actionId, filePath, fileName, uploadedBy, Date.now()); +} + +function addNote(db, subjectId, note, createdById, createdByName) { + db.prepare( + "INSERT INTO moderation_notes (id, subject_id, note, created_by_user_id, created_by_name, created_at) VALUES (?, ?, ?, ?, ?, ?)" + ).run(crypto.randomUUID(), subjectId, note, createdById, createdByName, Date.now()); +} + +function listActions(db, { limit = 200 } = {}) { + return db + .prepare( + "SELECT a.*, s.display_name FROM moderation_actions a " + + "LEFT JOIN moderation_subjects s ON s.id = a.subject_id " + + "ORDER BY a.created_at DESC LIMIT ?" + ) + .all(limit); +} + +function listEvidenceForActions(db, ids) { + if (!ids || !ids.length) { + return {}; + } + const rows = db + .prepare( + `SELECT id, action_id, file_name, file_path FROM moderation_evidence WHERE action_id IN (${ids + .map(() => "?") + .join(",")})` + ) + .all(...ids); + return rows.reduce((acc, row) => { + if (!acc[row.action_id]) { + acc[row.action_id] = []; + } + acc[row.action_id].push({ + id: row.id, + name: row.file_name || path.basename(row.file_path) + }); + return acc; + }, {}); +} + +function listNotes(db, { limit = 200 } = {}) { + return db + .prepare( + "SELECT n.*, s.display_name, s.internal_user_id FROM moderation_notes n " + + "LEFT JOIN moderation_subjects s ON s.id = n.subject_id " + + "ORDER BY n.created_at DESC LIMIT ?" + ) + .all(limit); +} + +function listActiveSanctions(db) { + const now = Date.now(); + return db + .prepare( + "SELECT a.*, s.display_name FROM moderation_actions a " + + "LEFT JOIN moderation_subjects s ON s.id = a.subject_id " + + "WHERE a.status = 'active' AND a.action_type IN ('ban', 'timeout') " + + "AND (a.expires_at IS NULL OR a.expires_at > ?) " + + "ORDER BY a.created_at DESC" + ) + .all(now); +} + +function getAction(db, id) { + return db + .prepare("SELECT * FROM moderation_actions WHERE id = ?") + .get(id); +} + +function updateActionDuration(db, id, durationSeconds, expiresAt) { + db.prepare( + "UPDATE moderation_actions SET duration_seconds = ?, expires_at = ? WHERE id = ?" + ).run(durationSeconds, expiresAt, id); +} + +function setActionStatus(db, id, status) { + db.prepare("UPDATE moderation_actions SET status = ? WHERE id = ?").run(status, id); +} + +function getActiveSanctionForUser(db, userId) { + const subject = db + .prepare("SELECT id FROM moderation_subjects WHERE internal_user_id = ?") + .get(userId); + if (!subject) { + return null; + } + const now = Date.now(); + const action = db + .prepare( + "SELECT a.*, s.display_name FROM moderation_actions a " + + "LEFT JOIN moderation_subjects s ON s.id = a.subject_id " + + "WHERE a.subject_id = ? AND a.status = 'active' AND a.action_type IN ('ban', 'timeout') " + + "AND (a.expires_at IS NULL OR a.expires_at > ?) " + + "ORDER BY a.created_at DESC LIMIT 1" + ) + .get(subject.id, now); + return action || null; +} + +function getBanPot(db) { + const row = db.prepare("SELECT balance FROM moderation_ban_pot WHERE id = 1").get(); + return row ? row.balance : 0; +} + +function addBanPot(db, amount) { + const current = getBanPot(db); + db.prepare("UPDATE moderation_ban_pot SET balance = ?, updated_at = ? WHERE id = 1").run( + current + amount, + Date.now() + ); +} + +async function enforceAction({ + action, + identities, + settings, + discordClient, + twitchClient, + youtubeClient, + reasonShort, + reasonDetail +}) { + const summary = reasonShort || "Moderation action"; + const detail = reasonDetail || ""; + const duration = action.duration_seconds || null; + + for (const identity of identities) { + if (identity.platform === "discord") { + await enforceDiscord( + discordClient, + settings, + identity.platform_user_id, + action.action_type, + summary, + detail, + duration + ); + } + if (identity.platform === "twitch") { + await enforceTwitch( + twitchClient, + identity.platform_username || identity.platform_user_id, + action.action_type, + summary, + duration + ); + } + if (identity.platform === "youtube") { + // Placeholder for YouTube enforcement + // Future: apply chat bans/timeouts with YouTube API + continue; + } + } +} + +async function revokeAction({ action, identities, settings, discordClient, twitchClient }) { + for (const identity of identities) { + if (identity.platform === "discord") { + if (action.action_type === "ban") { + await revokeDiscordBan(discordClient, settings, identity.platform_user_id); + } + if (action.action_type === "timeout") { + await revokeDiscordTimeout(discordClient, settings, identity.platform_user_id); + } + } + if (identity.platform === "twitch") { + if (action.action_type === "ban") { + await revokeTwitchBan(twitchClient, identity.platform_username || identity.platform_user_id); + } + if (action.action_type === "timeout") { + await revokeTwitchTimeout(twitchClient, identity.platform_username || identity.platform_user_id); + } + } + } +} + +async function enforceDiscord(client, settings, userId, actionType, reasonShort, reasonDetail, durationSeconds) { + if (!client || !userId) { + return; + } + const guildId = settings?.getSetting?.("discord_guild_id", null); + if (!guildId) { + return; + } + const guild = client.guilds?.cache?.get(guildId) || null; + if (!guild) { + return; + } + const reason = `${reasonShort}${reasonDetail ? ` | ${reasonDetail}` : ""}`.slice(0, 480); + if (actionType === "ban") { + await guild.members.ban(userId, { reason }).catch(() => null); + await notifyDiscordMember(guild, userId, reasonShort, reasonDetail, "ban"); + return; + } + if (actionType === "timeout") { + const member = await guild.members.fetch(userId).catch(() => null); + if (!member) { + return; + } + const durationMs = durationSeconds ? durationSeconds * 1000 : null; + if (durationMs) { + await member.timeout(durationMs, reason).catch(() => null); + await notifyDiscordMember(guild, userId, reasonShort, reasonDetail, "timeout"); + } + } +} + +async function revokeDiscordBan(client, settings, userId) { + if (!client || !userId) { + return; + } + const guildId = settings?.getSetting?.("discord_guild_id", null); + if (!guildId) { + return; + } + const guild = client.guilds?.cache?.get(guildId) || null; + if (!guild) { + return; + } + await guild.members.unban(userId).catch(() => null); +} + +async function revokeDiscordTimeout(client, settings, userId) { + if (!client || !userId) { + return; + } + const guildId = settings?.getSetting?.("discord_guild_id", null); + const guild = client.guilds?.cache?.get(guildId) || null; + if (!guild) { + return; + } + const member = await guild.members.fetch(userId).catch(() => null); + if (!member) { + return; + } + await member.timeout(null).catch(() => null); +} + +async function notifyDiscordMember(guild, userId, reasonShort, reasonDetail, type) { + const member = await guild.members.fetch(userId).catch(() => null); + if (!member) { + return; + } + const title = type === "ban" ? "You have been banned" : "You have been timed out"; + const message = `${title} from ${guild.name}.\nSummary: ${reasonShort}\nDetails: ${reasonDetail}`.slice(0, 1900); + await member.send(message).catch(() => null); +} + +async function enforceTwitch(client, username, actionType, reasonShort, durationSeconds) { + if (!client || !username) { + return; + } + const reason = reasonShort || "Moderation action"; + const channels = typeof client.getChannels === "function" ? client.getChannels() : []; + const channel = channels[0] || null; + if (!channel) { + return; + } + if (actionType === "ban") { + await client.ban(channel, username, reason).catch(() => null); + await tryWhisper(client, username, `You have been banned. Reason: ${reason}`); + return; + } + if (actionType === "timeout") { + const duration = durationSeconds || 3600; + await client.timeout(channel, username, duration, reason).catch(() => null); + await tryWhisper(client, username, `You have been timed out. Reason: ${reason}`); + } +} + +async function revokeTwitchBan(client, username) { + if (!client || !username) { + return; + } + const channels = typeof client.getChannels === "function" ? client.getChannels() : []; + const channel = channels[0] || null; + if (!channel) { + return; + } + await client.unban(channel, username).catch(() => null); +} + +async function revokeTwitchTimeout(client, username) { + if (!client || !username) { + return; + } + const channels = typeof client.getChannels === "function" ? client.getChannels() : []; + const channel = channels[0] || null; + if (!channel) { + return; + } + await client.unban(channel, username).catch(() => null); +} + +async function tryWhisper(client, username, message) { + if (!client || typeof client.whisper !== "function") { + return; + } + await client.whisper(username, message).catch(() => null); +} + +function distributeBanAssets(db, subjectId, { reason }) { + const framework = global.lumiFrameworks?.echonomy; + if (!framework) { + return; + } + const subject = db + .prepare("SELECT internal_user_id FROM moderation_subjects WHERE id = ?") + .get(subjectId); + if (!subject?.internal_user_id) { + return; + } + const balance = framework.getBalance(subject.internal_user_id); + if (!balance || balance <= 0) { + return; + } + const result = framework.removeBalance({ + userId: subject.internal_user_id, + amount: balance, + note: `Ban distribution${reason ? `: ${reason}` : ""}`, + meta: { source: "moderation", type: "ban" }, + allowFrozen: true + }); + if (result?.ok === false) { + return; + } + addBanPot(db, balance); +} + +function installFreezeHook(db) { + global.lumiModeration = { + isFrozen: (userId) => isUserFrozen(db, userId), + getBanPot: () => getBanPot(db) + }; +} + +function isUserFrozen(db, userId) { + const sanction = getActiveSanctionForUser(db, userId); + if (!sanction) { + return false; + } + return true; +} + +function startDiscordAuditPolling(db, settings, client) { + const poll = async () => { + if (!client?.guilds?.cache) { + return; + } + for (const guild of client.guilds.cache.values()) { + await harvestDiscordAuditLogs(db, guild, settings); + } + }; + poll(); + setInterval(poll, 60000); +} + +async function harvestDiscordAuditLogs(db, guild, settings) { + const types = [ + "MEMBER_BAN_ADD", + "MEMBER_BAN_REMOVE", + "MEMBER_KICK", + "MEMBER_UPDATE" + ]; + for (const type of types) { + const entries = await guild.fetchAuditLogs({ type, limit: 10 }).catch(() => null); + if (!entries) { + continue; + } + const lastKey = `discord_audit_${guild.id}_${type}`; + const lastIdRow = db.prepare("SELECT value FROM plugin_settings WHERE plugin_id = ? AND key = ?") + .get(PLUGIN_ID, lastKey); + const lastId = lastIdRow?.value || null; + const items = Array.from(entries.entries.values()); + for (const entry of items) { + if (lastId && entry.id === lastId) { + break; + } + const target = entry.target; + if (!target?.id) { + continue; + } + const subjectId = getOrCreateSubjectByIdentity(db, "discord", target.id, target.tag || target.username); + const actionType = mapDiscordAuditType(entry, type); + if (!actionType) { + continue; + } + const duration = actionType === "timeout" ? computeTimeoutDuration(entry) : null; + createAction(db, { + subjectId, + actionType, + scope: "global", + platform: "discord", + reasonShort: entry.reason || "Discord moderation action", + reasonDetail: entry.reason || "", + durationSeconds: duration, + createdById: entry.executor?.id || null, + createdByName: entry.executor?.tag || entry.executor?.username || null, + source: "external", + externalRef: entry.id + }); + } + if (items[0]) { + setPluginSetting(db, lastKey, items[0].id); + } + } +} + +function mapDiscordAuditType(entry, type) { + if (type === "MEMBER_BAN_ADD") { + return "ban"; + } + if (type === "MEMBER_BAN_REMOVE") { + return "unban"; + } + if (type === "MEMBER_KICK") { + return "kick"; + } + if (type === "MEMBER_UPDATE") { + const change = entry.changes?.find((item) => item.key === "communication_disabled_until"); + if (!change) { + return null; + } + if (change.new) { + return "timeout"; + } + return "untimeout"; + } + return null; +} + +function computeTimeoutDuration(entry) { + const change = entry.changes?.find((item) => item.key === "communication_disabled_until"); + if (!change || !change.new) { + return null; + } + const until = new Date(change.new).getTime(); + const now = entry.createdTimestamp || Date.now(); + const diff = Math.max(0, Math.floor((until - now) / 1000)); + return diff || null; +} + +function setPluginSetting(db, key, value) { + db.prepare( + "INSERT INTO plugin_settings (plugin_id, key, value, updated_at) VALUES (?, ?, ?, ?) " + + "ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at" + ).run(PLUGIN_ID, key, value, Date.now()); +} + +function attachTwitchModerationEvents(db, client) { + client.on("ban", (channel, username, reason, userstate) => { + const targetId = userstate?.["target-user-id"] || username; + const subjectId = getOrCreateSubjectByIdentity(db, "twitch", targetId, username); + createAction(db, { + subjectId, + actionType: "ban", + scope: "global", + platform: "twitch", + reasonShort: reason || "Twitch ban", + reasonDetail: reason || "", + durationSeconds: null, + createdById: userstate?.["room-id"] || null, + createdByName: userstate?.["display-name"] || null, + source: "external", + externalRef: `${channel}:${username}:${Date.now()}` + }); + }); + + client.on("timeout", (channel, username, reason, duration, userstate) => { + const targetId = userstate?.["target-user-id"] || username; + const subjectId = getOrCreateSubjectByIdentity(db, "twitch", targetId, username); + createAction(db, { + subjectId, + actionType: "timeout", + scope: "global", + platform: "twitch", + reasonShort: reason || "Twitch timeout", + reasonDetail: reason || "", + durationSeconds: duration || null, + createdById: userstate?.["room-id"] || null, + createdByName: userstate?.["display-name"] || null, + source: "external", + externalRef: `${channel}:${username}:${Date.now()}` + }); + }); +} + +function installGlobalGate(app, middleware) { + app.use(middleware); + const stack = app._router?.stack; + if (Array.isArray(stack) && stack.length) { + const layer = stack.pop(); + stack.unshift(layer); + } +} diff --git a/plugins/moderation/plugin.json b/plugins/moderation/plugin.json new file mode 100644 index 0000000..081489d --- /dev/null +++ b/plugins/moderation/plugin.json @@ -0,0 +1,7 @@ +{ + "id": "moderation", + "name": "Moderation Center", + "version": "0.1.3", + "description": "Cross-platform moderation actions, notes, and sanctions.", + "main": "index.js" +} diff --git a/plugins/moderation/views/moderation.ejs b/plugins/moderation/views/moderation.ejs new file mode 100644 index 0000000..c482783 --- /dev/null +++ b/plugins/moderation/views/moderation.ejs @@ -0,0 +1,591 @@ +<%- include("../../../src/web/views/partials/layout-top", { title }) %> + + +
+
+
+

Moderation Center

+

Global moderation actions, notes, and audit tracking.

+
+
+
+ +
+
+ +
+ Issue action + Global bans and timeouts with required reasoning. +
+ +
+
+
+ +
+ + +
+
+ + + Use platform IDs when possible. Twitch can use username. +
+
+ + +
+
+ + +
+ <% if (!isAdmin) { %> +
+ + +
+ <% } %> + <% if (isAdmin) { %> +
+ +
+ + +
+
+ <% } %> +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ +
+

Ban pot

+
+ Current balance + <%= banPot %> + Funds from bans are collected here. +
+
+ +
+
+
+

User notes

+

Search or filter notes and keep context handy.

+
+ +
+ +
+ +
+ <% const noteUsers = Array.from(new Set(notes.map((note) => (note.internal_user_id || note.display_name || note.subject_id)).filter(Boolean))).sort((a, b) => a.localeCompare(b)); %> + + +
+
+ + <% if (!notes.length) { %> +

No notes yet.

+ <% } else { %> +
+ + + + + + + + + + + <% notes.forEach((note) => { %> + <% const noteName = note.display_name || note.subject_id; %> + <% const noteUser = (note.internal_user_id || noteName || '').toLowerCase(); %> + + + + + + + <% }) %> + +
UserNoteByDate
<%= noteName %><%= note.note %><%= note.created_by_name || 'Staff' %><%= new Date(note.created_at).toLocaleString() %>
+
+
+ + Page 1 of 1 + +
+ <% } %> +
+
+ + + + + +<%- include("../../../src/web/views/partials/layout-bottom") %> diff --git a/plugins/moderation/views/status.ejs b/plugins/moderation/views/status.ejs new file mode 100644 index 0000000..5e7e2e8 --- /dev/null +++ b/plugins/moderation/views/status.ejs @@ -0,0 +1,42 @@ + + + + + + <%= title %> + + + +
+
+

Access restricted

+

Your account is currently restricted by moderation.

+
+
+ Action + <%= sanction.action_type %> +
+
+ Status + <%= sanction.status %> +
+
+ When + <%= new Date(sanction.created_at).toLocaleString() %> +
+
+ Expires + <%= sanction.expires_at ? new Date(sanction.expires_at).toLocaleString() : 'Permanent' %> +
+
+
+

Summary

+

<%= sanction.reason_short %>

+

Details

+

<%= sanction.reason_detail %>

+

Moderator: <%= sanction.created_by_name || 'Staff' %>

+
+
+
+ + diff --git a/plugins/moderation/views/tos-bans.ejs b/plugins/moderation/views/tos-bans.ejs new file mode 100644 index 0000000..df4e0c0 --- /dev/null +++ b/plugins/moderation/views/tos-bans.ejs @@ -0,0 +1,252 @@ +<%- include("../../../src/web/views/partials/layout-top", { title }) %> + + +
+
+
+

TOs & Bans

+

Monitor active sanctions and moderation history.

+
+
+
+ +
+

Current Timeouts & Bans

+ <% if (!activeSanctions.length) { %> +

No active bans or timeouts.

+ <% } else { %> + + + + + + + + + + + + <% activeSanctions.forEach((sanction) => { %> + + + + + + + + <% }) %> + +
UserTypeReasonExpiresActions
<%= sanction.display_name || sanction.subject_id %> + <%= sanction.action_type %> + <%= sanction.reason_short %><%= sanction.expires_at ? new Date(sanction.expires_at).toLocaleString() : "Permanent" %> +
+ <% if (sanction.action_type === 'timeout') { %> +
+ <% if (!isAdmin) { %> + + <% } %> + <% if (isAdmin) { %> +
+ + +
+ <% } %> + + +
+ <% } %> +
+ +
+
+
+ <% } %> +
+ +
+
+ + History + Searchable log of every moderation action. + +
+ +
+ +
+
+ <% if (!actions.length) { %> +

No actions recorded.

+ <% } else { %> +
+ + + + + + + + + + + + + + <% actions.forEach((action) => { %> + <% const evidence = actionEvidence[action.id] || []; %> + <% const displayName = action.display_name || action.subject_id; %> + <% const byName = action.created_by_name || action.source || 'Staff'; %> + <% const evidenceNames = evidence.map((item) => item.name).join(' '); %> + + + + + + + + + + <% }) %> + +
UserTypePlatformReasonByEvidenceDate
<%= displayName %><%= action.action_type %><%= action.platform || 'global' %><%= action.reason_short %><%= byName %> + <% if (!evidence.length) { %> + None + <% } else { %> +
+ <% evidence.forEach((item) => { %> + <%= item.name %> + <% }) %> +
+ <% } %> +
<%= new Date(action.created_at).toLocaleString() %>
+
+
+ + Page 1 of 1 + +
+ <% } %> +
+
+ + + +<%- include("../../../src/web/views/partials/layout-bottom") %> diff --git a/plugins/quotes/cmds.json b/plugins/quotes/cmds.json new file mode 100644 index 0000000..03edbdc --- /dev/null +++ b/plugins/quotes/cmds.json @@ -0,0 +1,55 @@ +{ + "pluginId": "quotes", + "pluginName": "Quotes", + "commands": [ + { + "id": "quote", + "trigger": "quote", + "name": "Quote", + "description": "Show a quote by id, or use subcommands.", + "level": "public", + "platforms": ["discord", "twitch", "youtube"], + "usage": "quote " + }, + { + "id": "quote-add", + "trigger": "quote", + "subcommand": "add", + "name": "Add quote", + "description": "Add a new quote.", + "level": "mod", + "platforms": ["discord", "twitch", "youtube"], + "usage": "quote add " + }, + { + "id": "quote-search", + "trigger": "quote", + "subcommand": "search", + "name": "Search quotes", + "description": "Find the best matching quote.", + "level": "public", + "platforms": ["discord", "twitch", "youtube"], + "usage": "quote search " + }, + { + "id": "quote-remove", + "trigger": "quote", + "subcommand": "remove", + "name": "Remove quote", + "description": "Archive a quote by id.", + "level": "mod", + "platforms": ["discord", "twitch", "youtube"], + "usage": "quote remove " + }, + { + "id": "quote-random", + "trigger": "quote", + "subcommand": "random", + "name": "Random quote", + "description": "Show a random quote.", + "level": "public", + "platforms": ["discord", "twitch", "youtube"], + "usage": "quote random" + } + ] +} diff --git a/plugins/quotes/index.js b/plugins/quotes/index.js new file mode 100644 index 0000000..3f8b30e --- /dev/null +++ b/plugins/quotes/index.js @@ -0,0 +1,794 @@ +const path = require("path"); + +const PLUGIN_ID = "quotes"; + +let cachedAppToken = null; +let cachedAppTokenExpiry = 0; + +module.exports = { + id: PLUGIN_ID, + init({ web, db, settings, commandRouter }) { + ensureTables(db); + registerQuoteCommands({ db, settings, commandRouter }); + + const router = web.createRouter(); + + router.get("/", (req, res) => { + const user = req.session.user || null; + const isMod = Boolean(user?.isAdmin || user?.isMod); + if (!isMod) { + return res.status(403).render("error", { + title: "Access denied", + message: "You do not have access to that page." + }); + } + const quotes = listQuotes(db, { includeHidden: true, includeArchived: true }); + const editId = parseInt(req.query.edit, 10); + const editingQuote = Number.isFinite(editId) + ? getQuoteById(db, editId, { includeHidden: true, includeArchived: true }) + : null; + res.render(path.join(__dirname, "views", "quotes.ejs"), { + title: "Quotes", + quotes, + editingQuote, + formatDateTime, + formatDateInput + }); + }); + + router.post("/quotes/create", (req, res) => { + const user = req.session.user || null; + const isMod = Boolean(user?.isAdmin || user?.isMod); + if (!isMod) { + return res.status(403).render("error", { + title: "Access denied", + message: "You do not have access to that page." + }); + } + const quoteText = (req.body.quote_text || "").trim(); + if (!quoteText) { + req.session.flash = { type: "error", message: "Quote text is required." }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + const quoteDatetime = parseDateInput(req.body.quote_datetime) || Date.now(); + const quoter = (req.body.quoter || user?.username || "Unknown").trim(); + const quoterUserId = resolveUserIdByUsername(db, quoter); + const gameName = (req.body.game_name || "").trim(); + const hidden = req.body.hidden === "on"; + const archived = req.body.archived === "on"; + const now = Date.now(); + addQuote(db, { + quoteText, + quoter: quoter || "Unknown", + quoterUserId, + gameName: gameName || null, + quoteDatetime, + editedBy: user?.username || "system", + editedLast: now, + hidden, + archived + }); + req.session.flash = { type: "success", message: "Quote added." }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/quotes/:id/update", (req, res) => { + const user = req.session.user || null; + const isMod = Boolean(user?.isAdmin || user?.isMod); + if (!isMod) { + return res.status(403).render("error", { + title: "Access denied", + message: "You do not have access to that page." + }); + } + const id = parseInt(req.params.id, 10); + if (!Number.isFinite(id)) { + req.session.flash = { type: "error", message: "Invalid quote id." }; + return res.redirect(`/plugins/${PLUGIN_ID}`); + } + const quoteText = (req.body.quote_text || "").trim(); + if (!quoteText) { + req.session.flash = { type: "error", message: "Quote text is required." }; + return res.redirect(`/plugins/${PLUGIN_ID}?edit=${id}`); + } + const quoteDatetime = parseDateInput(req.body.quote_datetime); + const quoter = (req.body.quoter || "").trim(); + const quoterUserId = resolveUserIdByUsername(db, quoter); + const gameName = (req.body.game_name || "").trim(); + const hidden = req.body.hidden === "on"; + const archived = req.body.archived === "on"; + updateQuote(db, id, { + quoteText, + quoter: quoter || "Unknown", + quoterUserId, + gameName: gameName || null, + quoteDatetime, + hidden, + archived, + editedBy: user?.username || "system", + editedLast: Date.now() + }); + req.session.flash = { type: "success", message: "Quote updated." }; + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/quotes/:id/hide", (req, res) => { + const user = req.session.user || null; + const isMod = Boolean(user?.isAdmin || user?.isMod); + if (!isMod) { + return res.status(403).render("error", { + title: "Access denied", + message: "You do not have access to that page." + }); + } + setQuoteHidden(db, req.params.id, true, user?.username || "system"); + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/quotes/:id/unhide", (req, res) => { + const user = req.session.user || null; + const isMod = Boolean(user?.isAdmin || user?.isMod); + if (!isMod) { + return res.status(403).render("error", { + title: "Access denied", + message: "You do not have access to that page." + }); + } + setQuoteHidden(db, req.params.id, false, user?.username || "system"); + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/quotes/:id/archive", (req, res) => { + const user = req.session.user || null; + const isMod = Boolean(user?.isAdmin || user?.isMod); + if (!isMod) { + return res.status(403).render("error", { + title: "Access denied", + message: "You do not have access to that page." + }); + } + setQuoteArchived(db, req.params.id, true, user?.username || "system"); + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.post("/quotes/:id/restore", (req, res) => { + const user = req.session.user || null; + const isMod = Boolean(user?.isAdmin || user?.isMod); + if (!isMod) { + return res.status(403).render("error", { + title: "Access denied", + message: "You do not have access to that page." + }); + } + setQuoteArchived(db, req.params.id, false, user?.username || "system"); + res.redirect(`/plugins/${PLUGIN_ID}`); + }); + + router.get("/api/quotes", (req, res) => { + const user = req.session.user || null; + const isMod = Boolean(user?.isAdmin || user?.isMod); + if (!isMod) { + return res.status(403).json({ error: "Access denied." }); + } + const quotes = listQuotes(db, { includeHidden: true, includeArchived: false }); + res.json({ quotes }); + }); + + router.get("/api/quotes/:id", (req, res) => { + const user = req.session.user || null; + const isMod = Boolean(user?.isAdmin || user?.isMod); + if (!isMod) { + return res.status(403).json({ error: "Access denied." }); + } + const id = parseInt(req.params.id, 10); + if (!Number.isFinite(id)) { + return res.status(400).json({ error: "Invalid quote id." }); + } + const quote = getQuoteById(db, id, { includeHidden: true, includeArchived: false }); + if (!quote) { + return res.status(404).json({ error: "Quote not found." }); + } + res.json({ quote }); + }); + + web.mount(`/plugins/${PLUGIN_ID}`, router, { + label: "Quotes", + role: "mod", + section: "plugins" + }); + } +}; + +function ensureTables(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS quotes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + quote_text TEXT NOT NULL, + quoter TEXT NOT NULL, + quoter_user_id TEXT, + game_name TEXT, + quote_datetime INTEGER NOT NULL, + edited_by TEXT, + edited_last INTEGER, + hidden INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0 + ); + + CREATE INDEX IF NOT EXISTS quotes_archived_idx ON quotes (archived); + CREATE INDEX IF NOT EXISTS quotes_hidden_idx ON quotes (hidden); + CREATE INDEX IF NOT EXISTS quotes_quote_datetime_idx ON quotes (quote_datetime); + `); + + const columns = db + .prepare("PRAGMA table_info(quotes)") + .all() + .map((column) => column.name); + if (!columns.includes("quoter_user_id")) { + db.exec("ALTER TABLE quotes ADD COLUMN quoter_user_id TEXT"); + } + db.exec("CREATE INDEX IF NOT EXISTS quotes_quoter_user_id_idx ON quotes (quoter_user_id)"); + try { + db.prepare( + "UPDATE quotes SET quoter_user_id = (" + + "SELECT id FROM user_profiles WHERE internal_username = quotes.quoter COLLATE NOCASE LIMIT 1" + + ") WHERE quoter_user_id IS NULL" + ).run(); + } catch { + // ignore backfill errors + } +} + +function registerQuoteCommands({ db, settings, commandRouter }) { + if (!commandRouter) { + return null; + } + + const platforms = ["discord", "twitch", "youtube"]; + commandRouter.registerCommands(PLUGIN_ID, [ + { + id: "quote", + triggers: ["quote"], + platforms, + handler: async (ctx) => await handleQuoteCommand({ ctx, db, settings }) + } + ]); + return null; +} + +async function handleQuoteCommand({ ctx, db, settings }) { + const prefix = settings.getSetting("command_prefix", "!"); + const subcommand = (ctx.args[0] || "").toLowerCase(); + const role = getRoleFlags(ctx, settings); + + if (!subcommand) { + await ctx.reply( + `Usage: ${prefix}quote | ${prefix}quote random` + ); + return true; + } + + if (subcommand === "add") { + if (!role.isAdmin && !role.isMod) { + await ctx.reply("You do not have permission to add quotes."); + return true; + } + const quoteText = ctx.args.slice(1).join(" ").trim(); + if (!quoteText) { + await ctx.reply(`Usage: ${prefix}quote add `); + return true; + } + const gameName = await resolveGameName(ctx, settings); + const now = Date.now(); + const quoter = ctx.user.displayName || ctx.user.username || "Unknown"; + const editor = ctx.user.username || quoter; + const id = addQuote(db, { + quoteText, + quoter, + quoterUserId: ctx.user.id, + gameName, + quoteDatetime: now, + editedBy: editor, + editedLast: now, + hidden: false, + archived: false + }); + await ctx.reply(`Quote #${id} added.`); + return true; + } + + if (subcommand === "search") { + const searchText = ctx.args.slice(1).join(" ").trim(); + if (!searchText) { + await ctx.reply(`Usage: ${prefix}quote search `); + return true; + } + const match = searchQuotes(db, searchText); + if (!match) { + await ctx.reply("No matching quotes found."); + return true; + } + await replyWithQuote(ctx, match); + return true; + } + + if (subcommand === "remove" || subcommand === "delete") { + if (!role.isAdmin && !role.isMod) { + await ctx.reply("You do not have permission to remove quotes."); + return true; + } + const id = parseInt(ctx.args[1], 10); + if (!Number.isFinite(id)) { + await ctx.reply(`Usage: ${prefix}quote remove `); + return true; + } + const removed = setQuoteArchived(db, id, true, ctx.user.username); + if (!removed) { + await ctx.reply("Quote not found."); + return true; + } + await ctx.reply(`Quote #${id} archived.`); + return true; + } + + if (subcommand === "random") { + const quote = getRandomQuote(db); + if (!quote) { + await ctx.reply("No quotes available yet."); + return true; + } + await replyWithQuote(ctx, quote); + return true; + } + + if (/^\d+$/.test(subcommand)) { + const id = parseInt(subcommand, 10); + const quote = getQuoteById(db, id, { includeHidden: false, includeArchived: false }); + if (!quote) { + await ctx.reply("Quote not found."); + return true; + } + await replyWithQuote(ctx, quote); + return true; + } + + await ctx.reply( + `Usage: ${prefix}quote | ${prefix}quote random` + ); + return true; +} + +function getRoleFlags(ctx, settings) { + if (ctx.platform === "discord") { + const roles = ctx.meta?.message?.member?.roles?.cache; + if (!roles) { + return { isAdmin: false, isMod: false }; + } + const adminIds = parseList(settings.getSetting("discord_admin_role_id")); + const modIds = parseList(settings.getSetting("discord_mod_role_id")); + const roleIds = Array.from(roles.keys()); + const isAdmin = roleIds.some((roleId) => adminIds.includes(roleId)); + const isMod = roleIds.some((roleId) => modIds.includes(roleId)); + return { isAdmin, isMod }; + } + if (ctx.platform === "twitch") { + const badges = ctx.meta?.tags?.badges || {}; + const isAdmin = Boolean(badges.broadcaster); + const isMod = Boolean(ctx.meta?.tags?.mod || badges.moderator); + return { isAdmin, isMod }; + } + if (ctx.platform === "youtube") { + const author = ctx.meta?.author || {}; + const isAdmin = Boolean(author.isChatOwner); + const isMod = Boolean(author.isChatModerator); + return { isAdmin, isMod }; + } + return { isAdmin: false, isMod: false }; +} + +function parseList(value) { + return (value || "") + .toString() + .split(/[,\s]+/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function resolveUserIdByUsername(db, username) { + const desired = (username || "").trim(); + if (!desired) { + return null; + } + const row = db + .prepare("SELECT id FROM user_profiles WHERE internal_username = ? LIMIT 1") + .get(desired); + return row?.id || null; +} + +function listQuotes(db, { includeHidden = true, includeArchived = true } = {}) { + const where = []; + if (!includeHidden) { + where.push("hidden = 0"); + } + if (!includeArchived) { + where.push("archived = 0"); + } + const clause = where.length ? `WHERE ${where.join(" AND ")}` : ""; + return db + .prepare( + "SELECT id, quote_text, quoter, quoter_user_id, game_name, quote_datetime, edited_by, edited_last, hidden, archived " + + `FROM quotes ${clause} ORDER BY quote_datetime DESC, id DESC` + ) + .all() + .map(normalizeQuoteRow); +} + +function getQuoteById(db, id, { includeHidden, includeArchived }) { + const where = ["id = ?"]; + const params = [id]; + if (!includeHidden) { + where.push("hidden = 0"); + } + if (!includeArchived) { + where.push("archived = 0"); + } + const row = db + .prepare( + `SELECT id, quote_text, quoter, quoter_user_id, game_name, quote_datetime, edited_by, edited_last, hidden, archived FROM quotes WHERE ${where.join( + " AND " + )} LIMIT 1` + ) + .get(...params); + return row ? normalizeQuoteRow(row) : null; +} + +function getRandomQuote(db) { + const row = db + .prepare( + "SELECT id, quote_text, quoter, quoter_user_id, game_name, quote_datetime, edited_by, edited_last, hidden, archived " + + "FROM quotes WHERE hidden = 0 AND archived = 0 ORDER BY RANDOM() LIMIT 1" + ) + .get(); + return row ? normalizeQuoteRow(row) : null; +} + +function addQuote(db, { + quoteText, + quoter, + quoterUserId, + gameName, + quoteDatetime, + editedBy, + editedLast, + hidden, + archived +}) { + const result = db + .prepare( + "INSERT INTO quotes (quote_text, quoter, quoter_user_id, game_name, quote_datetime, edited_by, edited_last, hidden, archived) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + ) + .run( + quoteText, + quoter, + quoterUserId || null, + gameName || null, + quoteDatetime || Date.now(), + editedBy || null, + editedLast || null, + hidden ? 1 : 0, + archived ? 1 : 0 + ); + return result.lastInsertRowid; +} + +function updateQuote(db, id, { + quoteText, + quoter, + quoterUserId, + gameName, + quoteDatetime, + hidden, + archived, + editedBy, + editedLast +}) { + let effectiveDatetime = quoteDatetime; + let effectiveQuoterUserId = quoterUserId; + if (!effectiveDatetime) { + const existing = db + .prepare("SELECT quote_datetime, quoter_user_id FROM quotes WHERE id = ?") + .get(id); + effectiveDatetime = existing?.quote_datetime || Date.now(); + if (effectiveQuoterUserId === undefined) { + effectiveQuoterUserId = existing?.quoter_user_id || null; + } + } else if (effectiveQuoterUserId === undefined) { + const existing = db + .prepare("SELECT quoter_user_id FROM quotes WHERE id = ?") + .get(id); + effectiveQuoterUserId = existing?.quoter_user_id || null; + } + const updates = [ + quoteText, + quoter, + effectiveQuoterUserId || null, + gameName || null, + effectiveDatetime, + editedBy || null, + editedLast || null, + hidden ? 1 : 0, + archived ? 1 : 0, + id + ]; + db.prepare( + "UPDATE quotes SET quote_text = ?, quoter = ?, quoter_user_id = ?, game_name = ?, quote_datetime = ?, edited_by = ?, edited_last = ?, hidden = ?, archived = ? WHERE id = ?" + ).run(...updates); +} + +function setQuoteHidden(db, id, hidden, editor) { + const parsed = parseInt(id, 10); + if (!Number.isFinite(parsed)) { + return false; + } + const result = db + .prepare( + "UPDATE quotes SET hidden = ?, edited_by = ?, edited_last = ? WHERE id = ?" + ) + .run(hidden ? 1 : 0, editor || null, Date.now(), parsed); + return result.changes > 0; +} + +function setQuoteArchived(db, id, archived, editor) { + const parsed = parseInt(id, 10); + if (!Number.isFinite(parsed)) { + return false; + } + const result = db + .prepare( + "UPDATE quotes SET archived = ?, edited_by = ?, edited_last = ? WHERE id = ?" + ) + .run(archived ? 1 : 0, editor || null, Date.now(), parsed); + return result.changes > 0; +} + +function searchQuotes(db, searchText, { includeHidden = false } = {}) { + const term = (searchText || "").trim().toLowerCase(); + if (!term) { + return null; + } + const tokens = term.split(/\s+/).filter(Boolean); + if (!tokens.length) { + return null; + } + const where = ["archived = 0"]; + if (!includeHidden) { + where.push("hidden = 0"); + } + const rows = db + .prepare( + "SELECT id, quote_text, quoter, quoter_user_id, game_name, quote_datetime, edited_by, edited_last, hidden, archived " + + `FROM quotes WHERE ${where.join(" AND ")}` + ) + .all() + .map(normalizeQuoteRow); + let best = null; + let bestScore = 0; + + for (const row of rows) { + const hayText = (row.quote_text || "").toLowerCase(); + const hayGame = (row.game_name || "").toLowerCase(); + const hayQuoter = (row.quoter || "").toLowerCase(); + const hayId = row.id.toString(); + const hayDate = buildSearchDate(row.quote_datetime); + + let score = 0; + let matchesAll = true; + + for (const token of tokens) { + const matchesText = hayText.includes(token); + const matchesGame = hayGame.includes(token); + const matchesQuoter = hayQuoter.includes(token); + const matchesId = hayId.includes(token); + const matchesDate = hayDate.includes(token); + if (!matchesText && !matchesGame && !matchesQuoter && !matchesId && !matchesDate) { + matchesAll = false; + break; + } + if (matchesText) score += 5; + if (matchesGame) score += 4; + if (matchesQuoter) score += 2; + if (matchesId) score += 3; + if (matchesDate) score += 1; + } + + if (!matchesAll) { + continue; + } + if (hayText.includes(term)) { + score += 6; + } + if (score > bestScore) { + bestScore = score; + best = row; + } else if (score === bestScore && best && row.quote_datetime > best.quote_datetime) { + best = row; + } + } + + return best; +} + +async function replyWithQuote(ctx, quote) { + if (ctx.platform === "discord") { + const embed = buildQuoteEmbed(quote); + await ctx.reply({ embeds: [embed] }); + return; + } + await ctx.reply(buildQuoteText(quote)); +} + +function buildQuoteText(quote) { + const dateLabel = formatDateLabel(quote.quote_datetime); + const quoter = quote.quoter || "Unknown"; + return `#${quote.id} "${quote.quote_text}" - quoted by ${quoter} ${dateLabel}`; +} + +function buildQuoteEmbed(quote) { + const fields = [ + { name: "Quoted by", value: quote.quoter || "Unknown", inline: true } + ]; + if (quote.game_name) { + fields.push({ name: "Game", value: quote.game_name, inline: true }); + } + fields.push({ + name: "Date", + value: formatDateTime(quote.quote_datetime), + inline: true + }); + return { + title: `Quote #${quote.id}`, + description: `"${quote.quote_text}"`, + fields, + timestamp: new Date(quote.quote_datetime).toISOString() + }; +} + +function normalizeQuoteRow(row) { + return { + id: row.id, + quote_text: row.quote_text, + quoter: row.quoter, + quoter_user_id: row.quoter_user_id, + game_name: row.game_name, + quote_datetime: row.quote_datetime, + edited_by: row.edited_by, + edited_last: row.edited_last, + hidden: Boolean(row.hidden), + archived: Boolean(row.archived) + }; +} + +function formatDateTime(timestamp) { + if (!timestamp) { + return "Unknown"; + } + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return "Unknown"; + } + return new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short" + }).format(date); +} + +function formatDateLabel(timestamp) { + if (!timestamp) { + return ""; + } + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return ""; + } + return new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "short", + day: "numeric" + }).format(date); +} + +function formatDateInput(timestamp) { + if (!timestamp) { + return ""; + } + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return ""; + } + const pad = (value) => value.toString().padStart(2, "0"); + const yyyy = date.getFullYear(); + const mm = pad(date.getMonth() + 1); + const dd = pad(date.getDate()); + const hh = pad(date.getHours()); + const min = pad(date.getMinutes()); + return `${yyyy}-${mm}-${dd}T${hh}:${min}`; +} + +function parseDateInput(value) { + if (!value) { + return null; + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return null; + } + return parsed.getTime(); +} + +function buildSearchDate(timestamp) { + if (!timestamp) { + return ""; + } + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return ""; + } + return `${date.toISOString().slice(0, 10)} ${formatDateLabel(timestamp)}`.toLowerCase(); +} + +async function resolveGameName(ctx, settings) { + if (ctx.platform !== "twitch") { + return null; + } + const roomId = ctx.meta?.tags?.["room-id"] || ctx.meta?.tags?.roomId; + if (!roomId) { + return null; + } + const clientId = settings.getSetting("twitch_client_id"); + const clientSecret = settings.getSetting("twitch_client_secret"); + if (!clientId || !clientSecret) { + return null; + } + const token = await getTwitchAppToken(clientId, clientSecret); + if (!token) { + return null; + } + const response = await fetch( + `https://api.twitch.tv/helix/channels?broadcaster_id=${encodeURIComponent(roomId)}`, + { + headers: { + "Client-Id": clientId, + Authorization: `Bearer ${token}` + } + } + ); + if (!response.ok) { + return null; + } + const data = await response.json(); + const channel = data.data?.[0]; + return channel?.game_name || null; +} + +async function getTwitchAppToken(clientId, clientSecret) { + const now = Date.now(); + if (cachedAppToken && now < cachedAppTokenExpiry) { + return cachedAppToken; + } + const url = + "https://id.twitch.tv/oauth2/token" + + `?client_id=${encodeURIComponent(clientId)}` + + `&client_secret=${encodeURIComponent(clientSecret)}` + + "&grant_type=client_credentials"; + const response = await fetch(url, { method: "POST" }); + if (!response.ok) { + return null; + } + const data = await response.json(); + if (!data.access_token || !data.expires_in) { + return null; + } + cachedAppToken = data.access_token; + cachedAppTokenExpiry = now + (data.expires_in - 60) * 1000; + return cachedAppToken; +} diff --git a/plugins/quotes/plugin.json b/plugins/quotes/plugin.json new file mode 100644 index 0000000..00d1598 --- /dev/null +++ b/plugins/quotes/plugin.json @@ -0,0 +1,7 @@ +{ + "id": "quotes", + "name": "Quotes", + "version": "0.1.1", + "description": "Store, search, and manage community quotes.", + "main": "index.js" +} diff --git a/plugins/quotes/stats.js b/plugins/quotes/stats.js new file mode 100644 index 0000000..599275d --- /dev/null +++ b/plugins/quotes/stats.js @@ -0,0 +1,119 @@ +function getProfileStats({ db, userId }) { + if (!userId) { + return { stats: [] }; + } + const row = db + .prepare( + "SELECT COUNT(*) AS total FROM quotes WHERE quoter_user_id = ? AND hidden = 0 AND archived = 0" + ) + .get(userId); + return { + stats: [ + { + label: "Quotes made", + value: row?.total || 0 + } + ] + }; +} + +function getLeaderboards({ db, limit }) { + const totalQuotes = getQuoteTotals(db); + const topQuoters = getQuoteLeaders(db, limit); + const topGames = getQuoteGameLeaders(db, limit); + return { + boards: [ + { + id: "total", + title: "Total quotes", + description: "Total quotes recorded.", + rowType: "text", + valueLabel: "Quotes", + rows: totalQuotes > 0 ? [{ label: "All quotes", value: totalQuotes }] : [], + emptyMessage: "No quotes recorded yet.", + topId: "quotes", + topAliases: ["quote", "totalquotes"] + }, + { + id: "quoters", + title: "Top quoters", + description: "Users who created the most quotes.", + rowType: "user", + valueLabel: "Quotes", + rows: topQuoters, + emptyMessage: "No quotes recorded yet.", + topId: "quoter", + topAliases: ["quoters", "quotesmade"] + }, + { + id: "games", + title: "Top quoted games", + description: "Games mentioned most in quotes.", + rowType: "game", + valueLabel: "Quotes", + rows: topGames, + emptyMessage: "No quoted games recorded yet.", + topId: "games", + topAliases: ["game", "quotegames"], + topOverride: true + } + ] + }; +} + +function getQuoteTotals(db) { + const row = db + .prepare( + "SELECT COUNT(*) AS total FROM quotes WHERE hidden = 0 AND archived = 0" + ) + .get(); + return row?.total || 0; +} + +function getQuoteLeaders(db, limit = 10) { + if (!hasColumn(db, "quotes", "quoter_user_id")) { + return []; + } + return db + .prepare( + "SELECT user_profiles.internal_username AS username, COUNT(*) AS value " + + "FROM quotes " + + "JOIN user_profiles ON user_profiles.id = quotes.quoter_user_id " + + "WHERE quotes.hidden = 0 AND quotes.archived = 0 " + + "GROUP BY quotes.quoter_user_id " + + "ORDER BY value DESC LIMIT ?" + ) + .all(limit); +} + +function getQuoteGameLeaders(db, limit = 10) { + return db + .prepare( + "SELECT game_name AS label, COUNT(*) AS value " + + "FROM quotes " + + "WHERE hidden = 0 AND archived = 0 AND game_name IS NOT NULL AND game_name != '' " + + "GROUP BY game_name " + + "ORDER BY value DESC LIMIT ?" + ) + .all(limit); +} + +function hasColumn(db, table, column) { + try { + const columns = db + .prepare(`PRAGMA table_info(${table})`) + .all() + .map((entry) => entry.name); + return columns.includes(column); + } catch { + return false; + } +} + +module.exports = { + getProfileStats, + getLeaderboards, + getQuoteTotals, + getQuoteLeaders, + getQuoteGameLeaders +}; diff --git a/plugins/quotes/stats.json b/plugins/quotes/stats.json new file mode 100644 index 0000000..4500431 --- /dev/null +++ b/plugins/quotes/stats.json @@ -0,0 +1,13 @@ +{ + "pluginId": "quotes", + "pluginName": "Quotes", + "provider": "stats.js", + "profile": { + "title": "Quotes", + "emptyMessage": "No quote stats yet." + }, + "leaderboards": { + "title": "Quotes", + "emptyMessage": "No quote data available yet." + } +} diff --git a/plugins/quotes/views/quotes.ejs b/plugins/quotes/views/quotes.ejs new file mode 100644 index 0000000..928f10f --- /dev/null +++ b/plugins/quotes/views/quotes.ejs @@ -0,0 +1,203 @@ +<%- include("../../../src/web/views/partials/layout-top", { title }) %> +
+
+
+

Quotes

+

Store, search, and manage memorable quotes.

+
+
+
+ +<% if (editingQuote) { %> +
+

Edit quote #<%= editingQuote.id %>

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Cancel +
+
+

+ Last edited by <%= editingQuote.edited_by || 'system' %> + <%= editingQuote.edited_last ? `on ${formatDateTime(editingQuote.edited_last)}` : '' %> +

+
+
+
+<% } %> + +
+

Add quote

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+

All quotes

+ <% if (!quotes.length) { %> +

No quotes recorded yet.

+ <% } else { %> +
+ +
+ + +
+
+
+ + + + + + + + + + + + + + <% quotes.forEach((quote) => { %> + <% const status = quote.archived ? 'archived' : quote.hidden ? 'hidden' : 'active'; %> + <% const dateLabel = formatDateTime(quote.quote_datetime); %> + + + + + + + + + + <% }) %> + +
IDQuoteQuoted byGameDateStatusActions
#<%= quote.id %><%= quote.quote_text %><%= quote.quoter %><%= quote.game_name || '-' %><%= dateLabel %><%= status %> + Edit + <% if (quote.hidden) { %> +
+ +
+ <% } else { %> +
+ +
+ <% } %> + <% if (quote.archived) { %> +
+ +
+ <% } else { %> +
+ +
+ <% } %> +
+
+
+ + Page 1 of 1 + +
+ <% } %> +
+<%- include("../../../src/web/views/partials/layout-bottom") %> diff --git a/plugins/sample-plugin/index.js b/plugins/sample-plugin/index.js new file mode 100644 index 0000000..ed86618 --- /dev/null +++ b/plugins/sample-plugin/index.js @@ -0,0 +1,17 @@ +module.exports = { + id: "sample-plugin", + init({ web, commandRouter }) { + const router = web.createRouter(); + router.get("/", (req, res) => { + res.render("plugin-page", { + title: "Sample Plugin", + content: + "This is a starter plugin. Edit it or replace it with your own modules." + }); + }); + web.mount("/plugins/sample-plugin", router, { + label: "Sample Plugin", + role: "admin" + }); + } +}; diff --git a/plugins/sample-plugin/plugin.json b/plugins/sample-plugin/plugin.json new file mode 100644 index 0000000..f2c2b44 --- /dev/null +++ b/plugins/sample-plugin/plugin.json @@ -0,0 +1,7 @@ +{ + "id": "sample-plugin", + "name": "Sample Plugin", + "version": "0.1.0", + "description": "Example plugin with a simple page.", + "main": "index.js" +} diff --git a/run.js b/run.js new file mode 100644 index 0000000..010ad66 --- /dev/null +++ b/run.js @@ -0,0 +1,65 @@ +const path = require("path"); +const { spawn } = require("child_process"); + +const entry = path.join(__dirname, "src", "main.js"); +const safeModeEntry = path.join(__dirname, "safe-mode.js"); +const maxRestarts = Number(process.env.MAX_RESTARTS || 25); +const restartDelayMs = Number(process.env.RESTART_DELAY_MS || 1500); +const restartCodes = new Set([10, 100]); + +let restarts = 0; +let safeModeStarted = false; + +function startSafeMode() { + if (safeModeStarted) { + return; + } + safeModeStarted = true; + const child = spawn(process.execPath, [safeModeEntry], { + stdio: "inherit", + env: { ...process.env, SAFE_MODE: "1" } + }); + child.on("exit", (code) => { + safeModeStarted = false; + if (code === 10) { + restarts = 0; + startChild(); + } + }); +} + +function startChild() { + const child = spawn(process.execPath, [entry], { + stdio: "inherit", + env: { ...process.env, BOT_WRAPPER: "1" } + }); + + child.on("exit", (code, signal) => { + if (signal) { + process.exit(0); + return; + } + + const shouldRestart = + restartCodes.has(code) || (code !== 0 && restarts < maxRestarts); + + if (code === 100) { + startSafeMode(); + return; + } + + if (!shouldRestart) { + if (code && restarts >= maxRestarts) { + startSafeMode(); + return; + } + process.exit(code || 0); + return; + } + + restarts += 1; + setTimeout(startChild, restartDelayMs); + }); +} + +startChild(); diff --git a/safe-mode.js b/safe-mode.js new file mode 100644 index 0000000..e0061a7 --- /dev/null +++ b/safe-mode.js @@ -0,0 +1,216 @@ +const express = require("express"); +const crypto = require("crypto"); +const session = require("express-session"); +const BetterSqlite3Store = require("better-sqlite3-session-store")(session); + +const { db } = require("./src/services/db"); +const { getSetting, setSetting } = require("./src/services/settings"); +const { + buildDiscordAuthUrl, + exchangeDiscordCode, + fetchDiscordUser, + fetchDiscordGuildMember +} = require("./src/services/auth"); +const { getRoleFlags, hasAccess } = require("./src/services/rbac"); +const { listSnapshots, restoreSnapshot } = require("./src/services/update-manager"); +const { requestRestart } = require("./src/services/updater"); + +function ensureSessionSecret() { + let secret = getSetting("session_secret"); + if (!secret) { + secret = crypto.randomBytes(32).toString("hex"); + setSetting("session_secret", secret); + } + return secret; +} + +function isConfigured() { + return Boolean( + getSetting("discord_client_id") && + getSetting("discord_client_secret") && + getSetting("discord_guild_id") + ); +} + +function renderPage(title, content) { + return ` + + + + + ${title} + + + +
+ Safe Mode +
+
+ ${content} +
+ +`; +} + +function buildSnapshotTable(snapshots) { + if (!snapshots.length) { + return "

No snapshots available.

"; + } + const rows = snapshots + .map((snap) => { + const label = snap.type === "plugin" ? `Plugin: ${snap.pluginId}` : "Bot core"; + const when = new Date(snap.createdAt).toLocaleString(); + return ` + + ${label} + ${when} + +
+ +
+ + + `; + }) + .join(""); + return ` + + + + + + + + + + ${rows} + +
SnapshotCreatedAction
+ `; +} + +const app = express(); +const sessionStore = new BetterSqlite3Store({ client: db }); +app.use( + session({ + secret: ensureSessionSecret(), + resave: false, + saveUninitialized: false, + store: sessionStore + }) +); +app.use(express.urlencoded({ extended: false })); + +app.get("/", (req, res) => { + if (!isConfigured()) { + return res.send( + renderPage( + "Safe Mode", + `

Discord not configured

Discord settings are required to enter safe mode.

` + ) + ); + } + if (!req.session.user) { + return res.send( + renderPage( + "Safe Mode", + `

Login required

Authenticate with Discord to access rollback tools.

Login with Discord
` + ) + ); + } + if (!hasAccess(req.session.user, "admin")) { + return res.send( + renderPage( + "Safe Mode", + `

Access denied

You do not have administrator access.

` + ) + ); + } + const snapshots = listSnapshots(); + const table = buildSnapshotTable(snapshots); + res.send( + renderPage( + "Safe Mode", + `

Rollback snapshots

Use these snapshots to roll back failed updates. The server will restart after rollback.

${table}
` + ) + ); +}); + +app.get("/auth/discord", (req, res) => { + if (!isConfigured()) { + return res.redirect("/"); + } + const state = crypto.randomBytes(16).toString("hex"); + req.session.discordState = state; + const baseUrl = `${req.protocol}://${req.get("host")}`; + const redirectUri = `${baseUrl}/auth/discord/callback`; + const url = buildDiscordAuthUrl(state, redirectUri); + res.redirect(url); +}); + +app.get("/auth/discord/callback", async (req, res) => { + const { code, state } = req.query; + if (!code || state !== req.session.discordState) { + return res.send(renderPage("Safe Mode", "
Invalid login state.
")); + } + try { + const baseUrl = `${req.protocol}://${req.get("host")}`; + const redirectUri = `${baseUrl}/auth/discord/callback`; + const token = await exchangeDiscordCode(code, redirectUri); + const user = await fetchDiscordUser(token.access_token); + const guildId = getSetting("discord_guild_id"); + const member = guildId + ? await fetchDiscordGuildMember(token.access_token, guildId) + : null; + const roles = member?.roles || []; + const flags = getRoleFlags(roles); + req.session.user = { + id: user.id, + username: user.global_name || user.username, + roles, + ...flags + }; + res.redirect("/"); + } catch (error) { + console.error(error); + res.send(renderPage("Safe Mode", "
Login failed.
")); + } +}); + +app.post("/rollback/:id", (req, res) => { + if (!req.session.user || !hasAccess(req.session.user, "admin")) { + return res.status(403).send(renderPage("Safe Mode", "
Access denied.
")); + } + try { + restoreSnapshot(req.params.id); + res.send( + renderPage( + "Safe Mode", + "

Rollback complete

Restarting the bot now...

" + ) + ); + requestRestart(); + } catch (error) { + res.send( + renderPage( + "Safe Mode", + `

Rollback failed

${error.message}

` + ) + ); + } +}); + +const port = Number(process.env.SAFE_MODE_PORT || 3001); +app.listen(port, () => { + console.log(`Safe mode listening on http://localhost:${port}`); +}); diff --git a/security-audit-findings.json b/security-audit-findings.json new file mode 100644 index 0000000..aa4f854 --- /dev/null +++ b/security-audit-findings.json @@ -0,0 +1,63 @@ +[ + { + "id": "LUMI-001", + "severity": "High", + "title": "Missing CSRF protection on state-changing routes", + "affected": [ + "/admin/*", + "/profile/*", + "/auth/logout" + ], + "evidence": "No CSRF middleware in src/web/server.js; POST routes rely solely on session cookies.", + "impact": "Logged-in admins can be tricked into executing sensitive actions (plugin install/update, settings changes, restart).", + "fix": "Add CSRF tokens or Origin/Referer checks and set SameSite cookies." + }, + { + "id": "LUMI-002", + "severity": "Medium", + "title": "Session cookie missing Secure and SameSite", + "affected": [ + "/" + ], + "evidence": "Set-Cookie: connect.sid=...; Path=/; HttpOnly (no Secure/SameSite)", + "impact": "Session cookie may be sent over HTTP or cross-site requests; increases CSRF/session hijack risk.", + "fix": "Configure express-session cookie options with Secure and SameSite=Lax; set trust proxy behind TLS." + }, + { + "id": "LUMI-003", + "severity": "Medium", + "title": "Session fixation risk after OAuth login", + "affected": [ + "/auth/discord/callback", + "/auth/twitch/callback" + ], + "evidence": "Session is populated without regeneration in src/web/server.js (req.session.user set directly).", + "impact": "An attacker who can set a session ID before login could reuse it after the victim authenticates.", + "fix": "Call req.session.regenerate() before setting authenticated session data." + }, + { + "id": "LUMI-004", + "severity": "Medium", + "title": "Plugin route role not enforced", + "affected": [ + "/plugins/sample-plugin", + "/plugins/*" + ], + "evidence": "web.mount uses app.use without role guard; sample plugin labeled admin is accessible publicly.", + "impact": "Plugin pages intended for admins can be reachable by unauthenticated users.", + "fix": "Enforce navItem.role in web.mount with requireRole(role)." + }, + { + "id": "LUMI-005", + "severity": "Low", + "title": "Missing baseline security headers", + "affected": [ + "/", + "/commands", + "/leaderboards" + ], + "evidence": "No CSP/XFO/XCTO/Referrer-Policy/Permissions-Policy headers; X-Powered-By present.", + "impact": "Increases exposure to clickjacking/XSS/mime sniffing and framework fingerprinting.", + "fix": "Use helmet and disable x-powered-by." + } +] \ No newline at end of file diff --git a/security-audit-report.md b/security-audit-report.md new file mode 100644 index 0000000..4208dad --- /dev/null +++ b/security-audit-report.md @@ -0,0 +1,286 @@ +# Lumi Bot Public Surface Security Audit + +Date: 2026-01-22 +Target: https://lumi.ookamikun.tv (unauthenticated/public surface) +Scope: Public pages, unauthenticated endpoints, and code review of the local repo. + +## 0) Guardrails +- No destructive actions performed. +- No authentication performed. +- Only safe, read-only checks and code review. + +## 1) Public Attack Surface Inventory + +### Discovered public URLs (unauthenticated) +- https://lumi.ookamikun.tv/ +- https://lumi.ookamikun.tv/commands +- https://lumi.ookamikun.tv/leaderboards +- https://lumi.ookamikun.tv/plugins/expression-interaction +- https://lumi.ookamikun.tv/plugins/sample-plugin + +### Authentication entrypoints +- https://lumi.ookamikun.tv/auth/discord (302 to Discord OAuth) +- https://lumi.ookamikun.tv/auth/twitch/login (302 to Twitch OAuth) + +### Admin endpoints (unauthenticated behavior) +- https://lumi.ookamikun.tv/admin (302 to /auth/discord) +- https://lumi.ookamikun.tv/admin/commands (302 to /auth/discord) +- https://lumi.ookamikun.tv/admin/settings (302 to /auth/discord) + +### Hidden endpoint probes (all 404) +- /api, /api/, /api/v1 +- /oauth, /callback, /webhook, /webhook/discord +- /health, /metrics, /status, /debug, /logs +- /swagger, /openapi, /graphql + +### Third-party integrations observed +- Discord OAuth (authorize, token, user fetch) via /auth/discord +- Twitch OAuth (authorize, token, user fetch) via /auth/twitch/login + +### JS/CSS assets +- https://lumi.ookamikun.tv/app.js +- https://lumi.ookamikun.tv/styles.css + +### Source maps +- /app.js.map and /styles.css.map return 404 (not exposed) + +## 2) Passive Misconfiguration Checks + +### TLS +- HTTP -> HTTPS redirect observed for http://lumi.ookamikun.tv/ +- HSTS present: `Strict-Transport-Security: max-age=63072000; preload` + +### Mixed Content +- No `http://` links detected in homepage HTML. + +### Security Headers (public HTML endpoints) +Observed on `/`, `/commands`, `/leaderboards`: +- Present: `Strict-Transport-Security` +- Present: `X-Powered-By: Express` +- Missing: `Content-Security-Policy`, `X-Frame-Options`/`frame-ancestors`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy` + +### Cookies +- `connect.sid` set for public pages with `HttpOnly` only. +- Missing: `Secure`, `SameSite` + +### CORS +- No CORS headers observed on public endpoints. + +### Caching +- HTML responses do not set explicit `Cache-Control`. +- `/app.js` uses `Cache-Control: public, max-age=0`. + +### Error handling +- 404 returns default Express `Cannot GET /path` page (no stack trace or env leaks). + +## 3) OAuth Flow Hardening (Discord/Twitch) + +Checklist: +- State present and random: Yes (`crypto.randomBytes(16)`) +- State validated on callback: Yes +- Rejects missing/invalid state: Yes +- Session binding: Uses session-stored state +- Session fixation protection: Missing (no session regeneration after login) +- Redirect handling: No open redirect parameter observed + +## 4) Access Control + +Public pages returning data: +- `/commands`: command list and counts (expected public?) +- `/leaderboards`: leaderboards and expression summary +- `/plugins/expression-interaction`: public plugin page with global stats +- `/plugins/sample-plugin`: public content (despite role labeled `admin` in plugin) + +Admin endpoints: +- All `/admin/*` routes protected by `requireRole("admin")`. + +## 5) CSRF and Cross-site Request Risks +- No CSRF middleware detected for POST routes (logout, profile updates, admin actions). +- Session cookies do not set `SameSite`, increasing CSRF risk. +- High-impact admin actions include plugin install/update, updates, and restart. + +## 6) Injection Testing (safe) +- No reflected parameters observed on public pages. +- User-controlled content in HTML uses EJS escaped output (`<%= ... %>`). +- Custom pages use raw HTML (`<%- page.content %>`), but creation is admin-gated. + +## 7) Rate Limiting and Enumeration +- No rate limiting middleware observed (e.g. `express-rate-limit`). +- Public endpoints (`/commands`, `/leaderboards`) are enumerable without throttling. + +## 8) Static Asset and Source Map Leakage +- No `.map` files exposed. +- No hardcoded secrets found in `src/web/public/app.js`. + +## 9) Node/Express Pitfalls Review +- No `helmet` usage for security headers. +- `express-session` cookie flags missing. +- No CSRF protection. +- No session ID rotation after OAuth login. +- `X-Powered-By` header enabled. +- `web.mount` does not enforce `navItem.role` for plugin routes. +- Dependency audit: `npm` not available in this environment, audit not executed. + +## Findings + +| Severity | Title | Affected URL/endpoint | Evidence | Impact | Fix | +| --- | --- | --- | --- | --- | --- | +| High | Missing CSRF protection on state-changing routes | Multiple POST endpoints (e.g. `/admin/*`, `/profile/*`, `/auth/logout`) | No CSRF middleware in `src/web/server.js` and no Origin/Referer checks | Logged-in admins can be forced to install plugins, change settings, or trigger updates | Add CSRF tokens and/or Origin checks; set `SameSite=Lax` cookies | +| Medium | Session cookie missing Secure and SameSite attributes | `/` (and other public pages) | `Set-Cookie: connect.sid=...; Path=/; HttpOnly` (no `Secure`/`SameSite`) | Session cookie can be sent over HTTP or cross-site requests; increases CSRF/session hijack risk | Configure `express-session` cookie flags; enable `trust proxy` when behind TLS terminator | +| Medium | Session fixation risk after OAuth login | `/auth/discord/callback`, `/auth/twitch/callback` | Session is populated without regeneration (`req.session.user = ...`) | Attacker may fixate session ID before login and reuse it after victim authenticates | Call `req.session.regenerate()` on successful OAuth login | +| Medium | Plugin route role not enforced | `/plugins/sample-plugin` (and any plugin using `web.mount`) | `web.mount` uses `app.use` without role guard; sample plugin labeled `admin` is public | Plugin pages intended for admins can be publicly accessible | Enforce `navItem.role` in `web.mount` with `requireRole` | +| Low | Missing baseline security headers | `/`, `/commands`, `/leaderboards` | No CSP/XFO/XCTO/Referrer-Policy/Permissions-Policy; `X-Powered-By: Express` | Increases exposure to clickjacking/XSS/mime sniffing and framework fingerprinting | Use `helmet` and disable `x-powered-by` | + +## Reproduction Steps + +1) Missing CSRF protection +- Log in as admin in a normal browser session. +- Host a page that auto-submits a POST form to `https://lumi.ookamikun.tv/admin/plugins/install` with a malicious repo URL. +- Visit the page while logged in; request succeeds without CSRF token. + +2) Session cookie missing Secure/SameSite +- Run `curl -I https://lumi.ookamikun.tv/`. +- Observe `Set-Cookie: connect.sid=...; Path=/; HttpOnly` without `Secure`/`SameSite`. + +3) Session fixation risk +- Start a session and capture the `connect.sid` cookie. +- Complete OAuth login; the session ID remains the same (no regeneration). + +4) Plugin route role not enforced +- Visit `https://lumi.ookamikun.tv/plugins/sample-plugin` while unauthenticated. +- The page loads (200) despite being labeled `role: admin` in the plugin. + +5) Missing security headers +- Run `curl -I https://lumi.ookamikun.tv/`. +- Confirm absence of CSP/XFO/XCTO/Referrer-Policy/Permissions-Policy headers. + +## Suggested Patches / Config Snippets + +### A) Session cookie hardening +```diff +--- a/src/web/server.js ++++ b/src/web/server.js +@@ + const app = express(); ++ app.set("trust proxy", 1); ++ app.disable("x-powered-by"); +@@ + app.use( + session({ + secret: ensureSessionSecret(), + resave: false, + saveUninitialized: false, +- store: sessionStore ++ store: sessionStore, ++ cookie: { ++ httpOnly: true, ++ secure: true, ++ sameSite: "lax" ++ } + }) + ); +``` + +### B) Add helmet with baseline headers +```diff +--- a/src/web/server.js ++++ b/src/web/server.js +@@ +-const express = require("express"); ++const express = require("express"); ++const helmet = require("helmet"); +@@ + const app = express(); ++ ++ app.use( ++ helmet({ ++ contentSecurityPolicy: { ++ useDefaults: true, ++ directives: { ++ "script-src": ["'self'"], ++ "style-src": ["'self'", "'unsafe-inline'"] ++ } ++ }, ++ referrerPolicy: { policy: "strict-origin-when-cross-origin" } ++ }) ++ ); +``` + +### C) Rotate session ID after OAuth login +```diff +--- a/src/web/server.js ++++ b/src/web/server.js +@@ +- req.session.user = { ++ req.session.regenerate(() => { ++ req.session.user = { + id: profile.id, + username: profile.internal_username, + avatar: user.avatar, + roles, + ...flags +- }; +- req.session.discordToken = token; +- setFlash(req, "success", "Logged in."); +- res.redirect("/"); ++ }; ++ req.session.discordToken = token; ++ setFlash(req, "success", "Logged in."); ++ res.redirect("/"); ++ }); +``` + +### D) Enforce role protection for plugin mounts +```diff +--- a/src/web/server.js ++++ b/src/web/server.js +@@ +- mount: (mountPath, router, navItem) => { +- app.use(mountPath, router); ++ mount: (mountPath, router, navItem) => { ++ const role = navItem?.role || "public"; ++ const middleware = role && role !== "public" ? requireRole(role) : null; ++ if (middleware) { ++ app.use(mountPath, middleware, router); ++ } else { ++ app.use(mountPath, router); ++ } + if (navItem) { + navItems.push({ ...navItem, path: mountPath }); + } + }, +``` + +### E) CSRF protection for state-changing routes +```diff +--- a/src/web/server.js ++++ b/src/web/server.js +@@ ++const csrf = require("csurf"); +@@ + const app = express(); ++ const csrfProtection = csrf(); +@@ +- app.post("/auth/logout", (req, res) => { ++ app.post("/auth/logout", csrfProtection, (req, res) => { + req.session.destroy(() => { + res.redirect("/"); + }); + }); +``` +Add hidden `_csrf` fields in forms and/or apply CSRF middleware globally after session initialization. + +## Top 5 Fixes This Week +1) Add CSRF protections for all state-changing routes. +2) Enforce Secure/SameSite cookie flags for `connect.sid`. +3) Rotate session IDs after OAuth login to prevent fixation. +4) Add helmet (CSP/XFO/XCTO/Referrer-Policy/Permissions-Policy). +5) Enforce `navItem.role` for plugin routes. + +## Verification Checklist (Post-fix) +- `curl -I https://lumi.ookamikun.tv/` shows CSP, XFO/frame-ancestors, XCTO, Referrer-Policy, Permissions-Policy. +- `Set-Cookie` includes `Secure` and `SameSite=Lax`. +- OAuth login changes the session ID. +- CSRF token required for POST to `/admin/*` endpoints. +- `/plugins/sample-plugin` is no longer accessible without auth when role is `admin`. + diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..3b4a5fc --- /dev/null +++ b/src/main.js @@ -0,0 +1,96 @@ +const { migrate } = require("./services/db"); +const { ensureDefaults, getSetting, setSetting } = require("./services/settings"); +const { createWebServer } = require("./web/server"); +const { startBot, stopBot } = require("./services/discord"); +const { startTwitchBot, stopTwitchBot } = require("./services/twitch"); +const { startYouTubeBot, stopYouTubeBot } = require("./services/youtube"); +const { loadEnabled } = require("./services/plugins"); +const { checkForUpdates, pullUpdates, requestRestart } = require("./services/updater"); +const { createCommandRouter } = require("./services/command-router"); +const { registerTopCommand } = require("./services/top"); +const logger = require("./services/logger"); +const { isPlatformEnabled } = require("./services/platforms"); + +async function main() { + migrate(); + ensureDefaults(); + logger.hookConsole(); + + const settingsApi = { getSetting, setSetting }; + const commandRouter = createCommandRouter({ settings: settingsApi }); + registerTopCommand({ commandRouter, settings: settingsApi }); + let discordClient = null; + let twitchClient = null; + let youtubeClient = null; + + if (isPlatformEnabled("discord")) { + try { + discordClient = await startBot({ commandRouter }); + } catch (error) { + console.error("Discord bot failed to start", error); + } + } + + if (isPlatformEnabled("twitch")) { + try { + twitchClient = await startTwitchBot({ commandRouter }); + } catch (error) { + console.error("Twitch bot failed to start", error); + } + } + + if (isPlatformEnabled("youtube")) { + try { + youtubeClient = await startYouTubeBot({ commandRouter }); + } catch (error) { + console.error("YouTube bot failed to start", error); + } + } + + const app = createWebServer({ + discordClient, + loadPlugins: (appInstance, web) => { + loadEnabled({ + app: appInstance, + discordClient, + twitchClient, + youtubeClient, + settings: settingsApi, + web, + commandRouter + }); + } + }); + + const port = Number(process.env.PORT || 3000); + app.listen(port, () => { + console.log(`WebUI listening on http://localhost:${port}`); + }); + + const autoUpdateEnabled = getSetting("auto_update_enabled", false); + const intervalMinutes = getSetting("auto_update_interval_minutes", 60); + if (autoUpdateEnabled) { + const intervalMs = Math.max(5, Number(intervalMinutes)) * 60 * 1000; + setInterval(() => { + try { + const remote = getSetting("git_remote", "origin"); + const branch = getSetting("git_branch", "main"); + if (checkForUpdates(remote, branch)) { + pullUpdates(remote, branch); + requestRestart(); + } + } catch (error) { + console.error("Auto-update failed", error); + } + }, intervalMs); + } + + process.on("SIGINT", async () => { + await stopBot(); + await stopTwitchBot(); + await stopYouTubeBot(); + process.exit(0); + }); +} + +main(); diff --git a/src/services/auth.js b/src/services/auth.js new file mode 100644 index 0000000..def8fad --- /dev/null +++ b/src/services/auth.js @@ -0,0 +1,188 @@ +const { getSetting } = require("./settings"); + +const YOUTUBE_SCOPES = [ + "https://www.googleapis.com/auth/youtube", + "https://www.googleapis.com/auth/youtube.force-ssl", + "https://www.googleapis.com/auth/youtube.readonly", + "https://www.googleapis.com/auth/youtube.channel-memberships.creator" +]; + +function getDiscordRedirectUri(override) { + return override || getSetting("discord_redirect_uri", null); +} + +function buildDiscordAuthUrl(state, redirectOverride) { + const clientId = getSetting("discord_client_id"); + const redirectUri = getDiscordRedirectUri(redirectOverride); + const params = new URLSearchParams({ + client_id: clientId || "", + redirect_uri: redirectUri || "", + response_type: "code", + scope: "identify guilds guilds.members.read", + state + }); + return `https://discord.com/api/oauth2/authorize?${params.toString()}`; +} + +async function exchangeDiscordCode(code, redirectOverride) { + const clientId = getSetting("discord_client_id"); + const clientSecret = getSetting("discord_client_secret"); + const redirectUri = getDiscordRedirectUri(redirectOverride); + const body = new URLSearchParams({ + client_id: clientId || "", + client_secret: clientSecret || "", + grant_type: "authorization_code", + code, + redirect_uri: redirectUri || "" + }); + + const response = await fetch("https://discord.com/api/oauth2/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body + }); + + if (!response.ok) { + throw new Error(`Discord token exchange failed: ${response.status}`); + } + + return response.json(); +} + +async function fetchDiscordUser(accessToken) { + const response = await fetch("https://discord.com/api/users/@me", { + headers: { Authorization: `Bearer ${accessToken}` } + }); + if (!response.ok) { + throw new Error(`Discord user fetch failed: ${response.status}`); + } + return response.json(); +} + +async function fetchDiscordGuildMember(accessToken, guildId) { + const response = await fetch( + `https://discord.com/api/users/@me/guilds/${guildId}/member`, + { + headers: { Authorization: `Bearer ${accessToken}` } + } + ); + if (!response.ok) { + return null; + } + return response.json(); +} + +function buildTwitchAuthUrl(state, redirectOverride) { + const clientId = getSetting("twitch_client_id"); + const redirectUri = redirectOverride || getSetting("twitch_redirect_uri"); + const params = new URLSearchParams({ + client_id: clientId || "", + redirect_uri: redirectUri || "", + response_type: "code", + scope: "user:read:email", + state + }); + return `https://id.twitch.tv/oauth2/authorize?${params.toString()}`; +} + +async function exchangeTwitchCode(code, redirectOverride) { + const clientId = getSetting("twitch_client_id"); + const clientSecret = getSetting("twitch_client_secret"); + const redirectUri = redirectOverride || getSetting("twitch_redirect_uri"); + const params = new URLSearchParams({ + client_id: clientId || "", + client_secret: clientSecret || "", + code, + grant_type: "authorization_code", + redirect_uri: redirectUri || "" + }); + const response = await fetch( + `https://id.twitch.tv/oauth2/token?${params.toString()}`, + { method: "POST" } + ); + if (!response.ok) { + throw new Error(`Twitch token exchange failed: ${response.status}`); + } + return response.json(); +} + +async function fetchTwitchUser(accessToken) { + const clientId = getSetting("twitch_client_id"); + const response = await fetch("https://api.twitch.tv/helix/users", { + headers: { + "Client-Id": clientId || "", + Authorization: `Bearer ${accessToken}` + } + }); + if (!response.ok) { + throw new Error(`Twitch user fetch failed: ${response.status}`); + } + const data = await response.json(); + return data.data?.[0] || null; +} + +function buildYouTubeAuthUrl(state, redirectOverride, options = {}) { + const clientId = getSetting("youtube_client_id"); + const redirectUri = redirectOverride || getSetting("youtube_redirect_uri"); + const scopes = options.scopes || YOUTUBE_SCOPES; + const params = new URLSearchParams({ + client_id: clientId || "", + redirect_uri: redirectUri || "", + response_type: "code", + scope: scopes.join(" "), + state, + access_type: "offline", + include_granted_scopes: "true" + }); + if (options.prompt) { + params.set("prompt", options.prompt); + } + return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`; +} + +async function exchangeYouTubeCode(code, redirectOverride) { + const clientId = getSetting("youtube_client_id"); + const clientSecret = getSetting("youtube_client_secret"); + const redirectUri = redirectOverride || getSetting("youtube_redirect_uri"); + const body = new URLSearchParams({ + client_id: clientId || "", + client_secret: clientSecret || "", + grant_type: "authorization_code", + code, + redirect_uri: redirectUri || "" + }); + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body + }); + if (!response.ok) { + throw new Error(`YouTube token exchange failed: ${response.status}`); + } + return response.json(); +} + +async function fetchYouTubeChannel(accessToken) { + const response = await fetch( + "https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true", + { headers: { Authorization: `Bearer ${accessToken}` } } + ); + if (!response.ok) { + throw new Error(`YouTube channel fetch failed: ${response.status}`); + } + const data = await response.json(); + return data.items?.[0] || null; +} + +module.exports = { + buildDiscordAuthUrl, + exchangeDiscordCode, + fetchDiscordUser, + fetchDiscordGuildMember, + buildTwitchAuthUrl, + exchangeTwitchCode, + fetchTwitchUser, + buildYouTubeAuthUrl, + exchangeYouTubeCode, + fetchYouTubeChannel +}; diff --git a/src/services/command-router.js b/src/services/command-router.js new file mode 100644 index 0000000..068561d --- /dev/null +++ b/src/services/command-router.js @@ -0,0 +1,237 @@ +const { db } = require("./db"); +const { incrementCommands } = require("./stats"); +const { + buildCommandContext, + runAdvancedCommand, + normalizeCommandResult +} = require("./commands"); +const { getEnabledPlatformIds, normalizePlatformSelection } = require("./platforms"); + +function createCommandRouter({ settings }) { + const commandMap = new Map(); + const pluginCommands = new Map(); + + function clearCommands(pluginId) { + const existing = pluginCommands.get(pluginId) || []; + for (const entry of existing) { + const handlers = commandMap.get(entry.trigger) || []; + const nextHandlers = handlers.filter((handler) => handler !== entry.handler); + if (nextHandlers.length) { + commandMap.set(entry.trigger, nextHandlers); + } else { + commandMap.delete(entry.trigger); + } + } + pluginCommands.delete(pluginId); + } + + function registerCommands(pluginId, commands = []) { + if (!pluginId) { + throw new Error("Plugin id is required to register commands."); + } + clearCommands(pluginId); + const entries = []; + for (const command of commands) { + const triggers = (command.triggers || []) + .map((trigger) => trigger.toLowerCase()) + .filter(Boolean); + const handler = buildHandler(command); + for (const trigger of triggers) { + const list = commandMap.get(trigger) || []; + list.push(handler); + commandMap.set(trigger, list); + entries.push({ trigger, handler }); + } + } + pluginCommands.set(pluginId, entries); + } + + function buildHandler(command) { + const handler = async (ctx) => { + if (command.platforms && command.platforms.length) { + if (!command.platforms.includes(ctx.platform)) { + return false; + } + } + return await command.handler(ctx); + }; + handler.commandId = command.id || null; + return handler; + } + + async function handleMessage({ platform, raw, user, platformUser, reply, meta }) { + const prefix = settings.getSetting("command_prefix", "!"); + if (!raw.startsWith(prefix)) { + return false; + } + const rawCommand = raw.slice(prefix.length).trim(); + if (!rawCommand) { + return false; + } + const parts = rawCommand.split(/\s+/); + const trigger = parts[0].toLowerCase(); + const args = parts.slice(1); + const argsText = args.join(" "); + const ctx = { + platform, + trigger, + raw, + args, + argsText, + user: { + id: user.id, + username: user.internal_username || user.username, + platformId: platformUser.id, + displayName: platformUser.displayName || platformUser.username, + tag: platformUser.tag + }, + platformUser, + meta, + reply + }; + + const customHandled = await handleCustomCommand({ + trigger, + platform, + ctx, + raw, + reply + }); + if (customHandled) { + incrementCommands(user.id); + return true; + } + + const handlers = commandMap.get(trigger) || []; + for (const handler of handlers) { + try { + const result = await handler(ctx); + if (typeof result === "string" && result) { + await safeReply(reply, result); + recordCommandUsage(handler.commandId); + incrementCommands(user.id); + return true; + } + if (result === true) { + recordCommandUsage(handler.commandId); + incrementCommands(user.id); + return true; + } + } catch (error) { + console.error("Command handler failed", error); + await safeReply(reply, "Command failed to execute."); + return true; + } + } + + return false; + } + + return { + registerCommands, + clearCommands, + handleMessage + }; +} + +async function handleCustomCommand({ trigger, platform, ctx, raw, reply }) { + const row = db + .prepare( + "SELECT response, mode, language, code, platform FROM custom_commands WHERE trigger = ? AND enabled = 1" + ) + .get(trigger); + if (!row) { + return false; + } + const enabledPlatforms = getEnabledPlatformIds(); + const allowedPlatforms = normalizePlatformSelection(row.platform, enabledPlatforms); + if (!allowedPlatforms.includes(platform)) { + return false; + } + try { + if (row.mode === "advanced" && row.code) { + const messageInfo = buildMessageInfo(ctx, raw); + const commandCtx = buildCommandContext({ + platform, + user: { + id: ctx.user.id, + platformId: ctx.user.platformId, + username: ctx.user.username, + displayName: ctx.user.displayName, + tag: ctx.user.tag + }, + message: messageInfo, + args: ctx.args, + argsText: ctx.argsText + }); + const result = await runAdvancedCommand( + { code: row.code, language: row.language }, + commandCtx + ); + const output = normalizeCommandResult(result); + if (output) { + await safeReply(reply, output); + } else { + await safeReply(reply, "Command ran but returned no output."); + } + } else { + await safeReply(reply, row.response); + } + recordCommandUsage(`custom:${trigger}`); + return true; + } catch (error) { + console.error("Failed to reply to command", error); + await safeReply(reply, "Command failed to execute."); + return true; + } +} + +function buildMessageInfo(ctx, raw) { + if (ctx.platform === "discord" && ctx.meta?.message) { + const message = ctx.meta.message; + return { + id: message.id, + content: raw, + channelId: message.channelId, + guildId: message.guildId + }; + } + if (ctx.platform === "twitch") { + return { + channel: ctx.meta?.channel, + content: raw + }; + } + if (ctx.platform === "youtube") { + return { + liveChatId: ctx.meta?.liveChatId, + messageId: ctx.meta?.messageId, + channelId: ctx.meta?.author?.channelId, + content: raw + }; + } + return { content: raw }; +} + +async function safeReply(reply, content) { + try { + await reply(content); + } catch (error) { + console.error("Command reply failed", error); + } +} + +function recordCommandUsage(commandId) { + if (!commandId) { + return; + } + const now = Date.now(); + db.prepare( + "INSERT INTO command_usage (command_id, count, updated_at) VALUES (?, 1, ?) " + + "ON CONFLICT(command_id) DO UPDATE SET count = count + 1, updated_at = excluded.updated_at" + ).run(commandId, now); +} + +module.exports = { + createCommandRouter +}; diff --git a/src/services/commands.js b/src/services/commands.js new file mode 100644 index 0000000..9af9198 --- /dev/null +++ b/src/services/commands.js @@ -0,0 +1,164 @@ +const vm = require("vm"); +const { spawn } = require("child_process"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +function buildCommandContext({ platform, user, message, args, argsText }) { + return { + platform, + user, + message, + args: args || [], + argsText: argsText || "" + }; +} + +async function runAdvancedCommand({ code, language }, ctx) { + if (language === "python") { + return await runPythonCommand(code, ctx); + } + return await runJsCommand(code, ctx); +} + +async function runJsCommand(code, ctx) { + const logs = []; + const safeConsole = { + log: (...args) => logs.push(args.join(" ")) + }; + const sandbox = { + ctx, + console: safeConsole, + module: { exports: {} }, + exports: {} + }; + const context = vm.createContext(sandbox); + const script = new vm.Script(code, { filename: "command.js" }); + script.runInContext(context, { timeout: 1000 }); + + const handler = context.run || context.module.exports || context.exports; + if (typeof handler !== "function") { + throw new Error("Advanced commands must export a run(ctx) function."); + } + const result = handler(ctx); + if (result && typeof result.then === "function") { + return await promiseWithTimeout(result, 1500); + } + return result; +} + +async function runPythonCommand(code, ctx) { + return await new Promise((resolve, reject) => { + const payload = JSON.stringify(ctx); + const encoded = Buffer.from(code, "utf8").toString("base64"); + const script = ` +import base64, json, sys, traceback +ctx = json.loads(sys.stdin.read() or "{}") +code = base64.b64decode("${encoded}").decode("utf-8") +globals_dict = {} +try: + exec(code, globals_dict) + if "run" not in globals_dict: + raise Exception("Define a run(ctx) function.") + result = globals_dict["run"](ctx) + if result is None: + sys.exit(0) + if isinstance(result, (dict, list)): + print(json.dumps(result)) + else: + print(str(result)) +except Exception: + traceback.print_exc() + sys.exit(2) +`; + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-cmd-")); + const filePath = path.join(tmpDir, "runner.py"); + fs.writeFileSync(filePath, script, "utf8"); + + const child = spawn("python", ["-u", filePath], { + stdio: ["pipe", "pipe", "pipe"] + }); + let stdout = ""; + let stderr = ""; + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error("Python command timed out.")); + }, 2000); + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", (error) => { + clearTimeout(timeout); + cleanupTemp(tmpDir); + reject(error); + }); + child.on("close", (code) => { + clearTimeout(timeout); + cleanupTemp(tmpDir); + if (code && code !== 0) { + reject(new Error(stderr || "Python command failed.")); + return; + } + resolve(stdout.trim()); + }); + + child.stdin.write(payload); + child.stdin.end(); + }); +} + +function cleanupTemp(dir) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors. + } +} + +function normalizeCommandResult(result) { + if (result === null || result === undefined) { + return ""; + } + if (typeof result === "string") { + return result; + } + if (typeof result === "number" || typeof result === "boolean") { + return String(result); + } + if (typeof result === "object" && result.content) { + return String(result.content); + } + try { + return JSON.stringify(result); + } catch { + return String(result); + } +} + +function promiseWithTimeout(promise, timeoutMs) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Command timed out.")); + }, timeoutMs); + promise + .then((value) => { + clearTimeout(timeout); + resolve(value); + }) + .catch((error) => { + clearTimeout(timeout); + reject(error); + }); + }); +} + +module.exports = { + buildCommandContext, + runAdvancedCommand, + normalizeCommandResult +}; diff --git a/src/services/config.js b/src/services/config.js new file mode 100644 index 0000000..c25f422 --- /dev/null +++ b/src/services/config.js @@ -0,0 +1,65 @@ +const fs = require("fs"); +const path = require("path"); + +const envPath = path.join(__dirname, "..", "..", ".env"); + +function loadEnvFile(filePath = envPath) { + if (!fs.existsSync(filePath)) { + return; + } + + const contents = fs.readFileSync(filePath, "utf8"); + for (const line of contents.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) { + continue; + } + + const separator = trimmed.indexOf("="); + if (separator === -1) { + continue; + } + + const key = trimmed.slice(0, separator).trim(); + let value = trimmed.slice(separator + 1).trim(); + if (!key || Object.prototype.hasOwnProperty.call(process.env, key)) { + continue; + } + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + process.env[key] = value; + } +} + +function envString(key, fallback = "") { + const value = process.env[key]; + return value === undefined ? fallback : value; +} + +function envBoolean(key, fallback = false) { + const value = process.env[key]; + if (value === undefined) { + return fallback; + } + return ["1", "true", "yes", "on"].includes(value.toLowerCase()); +} + +function envNumber(key, fallback) { + const value = Number(process.env[key]); + return Number.isFinite(value) ? value : fallback; +} + +loadEnvFile(); + +module.exports = { + envBoolean, + envNumber, + envString, + loadEnvFile +}; diff --git a/src/services/db.js b/src/services/db.js new file mode 100644 index 0000000..67accd9 --- /dev/null +++ b/src/services/db.js @@ -0,0 +1,264 @@ +const path = require("path"); +const fs = require("fs"); +const Database = require("better-sqlite3"); + +const dataDir = path.join(__dirname, "..", "..", "data"); +const dbPath = path.join(dataDir, "app.db"); + +fs.mkdirSync(dataDir, { recursive: true }); + +const db = new Database(dbPath); +db.pragma("journal_mode = WAL"); + +function migrate() { + db.exec(` + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + avatar TEXT, + last_login INTEGER NOT NULL, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS user_profiles ( + id TEXT PRIMARY KEY, + internal_username TEXT NOT NULL UNIQUE COLLATE NOCASE, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS user_identities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + provider TEXT NOT NULL, + provider_user_id TEXT NOT NULL, + display_name TEXT, + avatar TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(provider, provider_user_id) + ); + + CREATE TABLE IF NOT EXISTS mod_role_periods ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + start_at INTEGER NOT NULL, + end_at INTEGER + ); + + CREATE TABLE IF NOT EXISTS linked_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + provider TEXT NOT NULL, + provider_user_id TEXT NOT NULL, + display_name TEXT, + access_token TEXT, + refresh_token TEXT, + expires_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(user_id, provider) + ); + + CREATE TABLE IF NOT EXISTS plugins ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + version TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + source TEXT, + path TEXT NOT NULL, + installed_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS plugin_settings ( + plugin_id TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (plugin_id, key) + ); + + CREATE TABLE IF NOT EXISTS stats ( + user_id TEXT PRIMARY KEY, + messages INTEGER NOT NULL DEFAULT 0, + commands INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS custom_pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + nav_label TEXT, + content TEXT NOT NULL, + content_css TEXT NOT NULL DEFAULT '', + format TEXT NOT NULL DEFAULT 'html', + role TEXT NOT NULL DEFAULT 'public', + show_in_nav INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS custom_commands ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trigger TEXT NOT NULL UNIQUE, + response TEXT NOT NULL, + platform TEXT NOT NULL DEFAULT 'both', + mode TEXT NOT NULL DEFAULT 'plain', + language TEXT NOT NULL DEFAULT 'js', + code TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS command_usage ( + command_id TEXT PRIMARY KEY, + count INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + level TEXT NOT NULL, + message TEXT NOT NULL, + details TEXT, + created_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS logs_created_at_idx ON logs (created_at); + `); + + const columns = db + .prepare("PRAGMA table_info(custom_commands)") + .all() + .map((column) => column.name); + + if (!columns.includes("mode")) { + db.exec("ALTER TABLE custom_commands ADD COLUMN mode TEXT NOT NULL DEFAULT 'plain'"); + } + if (!columns.includes("language")) { + db.exec("ALTER TABLE custom_commands ADD COLUMN language TEXT NOT NULL DEFAULT 'js'"); + } + if (!columns.includes("code")) { + db.exec("ALTER TABLE custom_commands ADD COLUMN code TEXT"); + } + if (!columns.includes("platform")) { + db.exec( + "ALTER TABLE custom_commands ADD COLUMN platform TEXT NOT NULL DEFAULT 'both'" + ); + } + + const pageColumns = db + .prepare("PRAGMA table_info(custom_pages)") + .all() + .map((column) => column.name); + + if (!pageColumns.includes("content_css")) { + db.exec( + "ALTER TABLE custom_pages ADD COLUMN content_css TEXT NOT NULL DEFAULT ''" + ); + } + if (!pageColumns.includes("format")) { + db.exec( + "ALTER TABLE custom_pages ADD COLUMN format TEXT NOT NULL DEFAULT 'html'" + ); + } + + const profileColumns = db + .prepare("PRAGMA table_info(user_profiles)") + .all() + .map((column) => column.name); + + if (!profileColumns.includes("username_updated_at")) { + db.exec("ALTER TABLE user_profiles ADD COLUMN username_updated_at INTEGER"); + } + + migrateLegacyUsers(); +} + +function migrateLegacyUsers() { + const legacyUsers = db + .prepare("SELECT id, username, avatar FROM users") + .all(); + if (!legacyUsers.length) { + return; + } + + const now = Date.now(); + const mapping = new Map(); + + for (const legacy of legacyUsers) { + const existingIdentity = db + .prepare( + "SELECT user_id FROM user_identities WHERE provider = 'discord' AND provider_user_id = ?" + ) + .get(legacy.id); + + if (existingIdentity?.user_id) { + mapping.set(legacy.id, existingIdentity.user_id); + continue; + } + + const username = generateUniqueLegacyUsername(legacy.username); + const userId = cryptoRandomId(); + db.prepare( + "INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)" + ).run(userId, username, now, now); + db.prepare( + "INSERT INTO user_identities (user_id, provider, provider_user_id, display_name, avatar, created_at, updated_at) VALUES (?, 'discord', ?, ?, ?, ?, ?)" + ).run(userId, legacy.id, legacy.username, legacy.avatar, now, now); + mapping.set(legacy.id, userId); + } + + for (const [legacyId, userId] of mapping.entries()) { + db.prepare("UPDATE stats SET user_id = ? WHERE user_id = ?").run( + userId, + legacyId + ); + db.prepare("UPDATE linked_accounts SET user_id = ? WHERE user_id = ?").run( + userId, + legacyId + ); + } +} + +function generateUniqueLegacyUsername(name) { + const base = (name || "user").trim() || "user"; + if (isLegacyUsernameAvailable(base)) { + return base; + } + let suffix = 2; + let candidate = `${base}-${suffix}`; + while (!isLegacyUsernameAvailable(candidate)) { + suffix += 1; + candidate = `${base}-${suffix}`; + } + return candidate; +} + +function isLegacyUsernameAvailable(name) { + const row = db + .prepare( + "SELECT id FROM user_profiles WHERE internal_username = ? LIMIT 1" + ) + .get(name); + return !row; +} + +function cryptoRandomId() { + return require("crypto").randomUUID(); +} + +module.exports = { + db, + migrate +}; diff --git a/src/services/discord.js b/src/services/discord.js new file mode 100644 index 0000000..0090143 --- /dev/null +++ b/src/services/discord.js @@ -0,0 +1,140 @@ +const discord = require("discord.js"); +const Client = discord.Client; +const GatewayIntentBits = discord.GatewayIntentBits; +const IntentsBitField = discord.IntentsBitField; +const Intents = discord.Intents; +const Partials = discord.Partials; +const { getSetting, setSetting } = require("./settings"); +const { incrementMessages } = require("./stats"); +const { ensureUserForIdentity } = require("./users"); + +let client = null; + +async function startBot({ commandRouter } = {}) { + const token = getSetting("discord_bot_token"); + if (!token) { + return null; + } + + const intents = [ + resolveIntent("Guilds", "GUILDS"), + resolveIntent("GuildMessages", "GUILD_MESSAGES"), + resolveIntent("MessageContent", "MESSAGE_CONTENT"), + resolveIntent("GuildVoiceStates", "GUILD_VOICE_STATES"), + resolveIntent("GuildPresences", "GUILD_PRESENCES") + ].filter(Boolean); + + const options = {}; + if (intents.length) { + options.intents = intents; + } + if (Partials?.Channel) { + options.partials = [Partials.Channel]; + } + + client = new Client(options); + + client.on("ready", () => { + console.log(`Discord bot ready: ${client.user?.tag}`); + const avatarUrl = getBotAvatarUrl(client.user); + if (avatarUrl) { + setSetting("bot_avatar_url", avatarUrl); + } + }); + + client.on("messageCreate", async (message) => { + if (!message.guild || message.author.bot) { + return; + } + const displayName = + message.author.globalName || message.author.username || message.author.tag; + let avatarUrl = null; + if (typeof message.author.displayAvatarURL === "function") { + try { + avatarUrl = message.author.displayAvatarURL({ format: "png", size: 128 }); + } catch { + avatarUrl = message.author.displayAvatarURL(); + } + } + const profile = ensureUserForIdentity({ + provider: "discord", + providerUserId: message.author.id, + displayName, + avatar: avatarUrl + }); + incrementMessages(profile.id); + + if (commandRouter) { + await commandRouter.handleMessage({ + platform: "discord", + raw: message.content, + user: profile, + platformUser: { + id: message.author.id, + displayName, + username: message.author.username, + tag: message.author.tag, + avatar: avatarUrl + }, + meta: { message, client }, + reply: async (content) => { + try { + await message.reply(content); + } catch (error) { + console.error("Discord command reply failed", error); + } + } + }); + } + }); + + await client.login(token); + return client; +} + +function getBotAvatarUrl(user) { + if (!user) { + return null; + } + if (typeof user.displayAvatarURL === "function") { + try { + return user.displayAvatarURL({ format: "png", size: 128 }); + } catch { + return user.displayAvatarURL(); + } + } + if (user.avatar) { + return `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=128`; + } + return null; +} + +function resolveIntent(key, legacyKey) { + if (GatewayIntentBits?.[key]) { + return GatewayIntentBits[key]; + } + if (IntentsBitField?.Flags?.[key]) { + return IntentsBitField.Flags[key]; + } + if (Intents?.FLAGS?.[legacyKey]) { + return Intents.FLAGS[legacyKey]; + } + return null; +} + +async function stopBot() { + if (client) { + await client.destroy(); + client = null; + } +} + +function getClient() { + return client; +} + +module.exports = { + startBot, + stopBot, + getClient +}; diff --git a/src/services/logger.js b/src/services/logger.js new file mode 100644 index 0000000..c5ad3a0 --- /dev/null +++ b/src/services/logger.js @@ -0,0 +1,143 @@ +const util = require("util"); +const { db } = require("./db"); + +const LEVELS = new Set(["debug", "info", "warn", "error"]); +let consoleHooked = false; + +function log(level, ...args) { + const safeLevel = LEVELS.has(level) ? level : "info"; + const entry = normalizeArgs(args); + const createdAt = Date.now(); + try { + db.prepare( + "INSERT INTO logs (level, message, details, created_at) VALUES (?, ?, ?, ?)" + ).run(safeLevel, entry.message, entry.details, createdAt); + } catch { + // Avoid throwing from logger. + } +} + +function listLogs(options = {}) { + const limit = + Number.isFinite(options.limit) && options.limit !== null + ? Math.max(1, options.limit) + : null; + const sinceMs = + Number.isFinite(options.sinceMs) && options.sinceMs > 0 + ? options.sinceMs + : null; + const levels = Array.isArray(options.levels) + ? options.levels.filter((level) => LEVELS.has(level)) + : []; + + const clauses = []; + const params = []; + if (sinceMs) { + clauses.push("created_at >= ?"); + params.push(sinceMs); + } + if (levels.length) { + clauses.push(`level IN (${levels.map(() => "?").join(",")})`); + params.push(...levels); + } + + let query = + "SELECT id, level, message, details, created_at FROM logs"; + if (clauses.length) { + query += ` WHERE ${clauses.join(" AND ")}`; + } + query += " ORDER BY created_at DESC"; + if (limit) { + query += " LIMIT ?"; + params.push(limit); + } + + return db.prepare(query).all(...params); +} + +function hookConsole() { + if (consoleHooked) { + return; + } + consoleHooked = true; + + const original = { + log: console.log, + info: console.info || console.log, + warn: console.warn || console.log, + error: console.error || console.log + }; + + console.log = (...args) => { + log("info", ...args); + original.log.apply(console, args); + }; + console.info = (...args) => { + log("info", ...args); + original.info.apply(console, args); + }; + console.warn = (...args) => { + log("warn", ...args); + original.warn.apply(console, args); + }; + console.error = (...args) => { + log("error", ...args); + original.error.apply(console, args); + }; +} + +function normalizeArgs(args) { + if (!args || args.length === 0) { + return { message: "Log entry", details: "" }; + } + let message = ""; + const detailParts = []; + + const first = args[0]; + if (first instanceof Error) { + message = first.message || "Error"; + detailParts.push(first.stack || String(first)); + } else { + message = formatArg(first); + } + + for (const arg of args.slice(1)) { + if (arg instanceof Error) { + detailParts.push(arg.stack || arg.message || String(arg)); + if (!message) { + message = arg.message || "Error"; + } + } else { + detailParts.push(formatArg(arg)); + } + } + + if (!message) { + message = "Log entry"; + } + + return { + message, + details: detailParts.filter(Boolean).join("\n") + }; +} + +function formatArg(value) { + if (typeof value === "string") { + return value; + } + if (value instanceof Error) { + return value.stack || value.message || String(value); + } + return util.inspect(value, { + depth: 4, + maxArrayLength: 50, + breakLength: 120 + }); +} + +module.exports = { + log, + listLogs, + hookConsole +}; diff --git a/src/services/platforms.js b/src/services/platforms.js new file mode 100644 index 0000000..79edbe2 --- /dev/null +++ b/src/services/platforms.js @@ -0,0 +1,187 @@ +const { getSetting } = require("./settings"); + +const PLATFORM_DEFS = [ + { + id: "discord", + label: "Discord", + enabledKey: "platform_discord_enabled", + enabledByDefault: true, + supported: true, + supportsLogin: true, + supportsLink: true, + wizardPath: "/admin/discord-wizard", + loginPath: "/auth/discord", + linkPath: "/auth/discord", + badge: "discord", + isConfigured: () => + Boolean( + getSetting("discord_client_id") && + getSetting("discord_client_secret") && + getSetting("discord_guild_id") && + getSetting("discord_bot_token") + ) + }, + { + id: "kick", + label: "Kick", + enabledKey: "platform_kick_enabled", + enabledByDefault: false, + supported: false, + supportsLogin: false, + supportsLink: false, + wizardPath: null, + loginPath: null, + linkPath: null, + badge: "kick", + isConfigured: () => false + }, + { + id: "twitch", + label: "Twitch", + enabledKey: "platform_twitch_enabled", + enabledByDefault: true, + supported: true, + supportsLogin: true, + supportsLink: true, + wizardPath: "/admin/twitch-wizard", + loginPath: "/auth/twitch/login", + linkPath: "/auth/twitch", + badge: "twitch", + isConfigured: () => + Boolean(getSetting("twitch_client_id") && getSetting("twitch_client_secret")) + }, + { + id: "youtube", + label: "YouTube", + enabledKey: "platform_youtube_enabled", + enabledByDefault: false, + supported: true, + supportsLogin: true, + supportsLink: true, + wizardPath: "/admin/youtube-wizard", + loginPath: "/auth/youtube/login", + linkPath: "/auth/youtube", + badge: "youtube", + isConfigured: () => + Boolean(getSetting("youtube_client_id") && getSetting("youtube_client_secret")) + } +]; + +function getPlatforms() { + return PLATFORM_DEFS.slice().sort((a, b) => a.label.localeCompare(b.label)); +} + +function getPlatformById(id) { + return PLATFORM_DEFS.find((platform) => platform.id === id) || null; +} + +function isPlatformEnabled(id) { + const platform = getPlatformById(id); + if (!platform) { + return false; + } + return Boolean(getSetting(platform.enabledKey, platform.enabledByDefault)); +} + +function isPlatformConfigured(id) { + const platform = getPlatformById(id); + if (!platform || !platform.supported) { + return false; + } + if (typeof platform.isConfigured !== "function") { + return false; + } + return Boolean(platform.isConfigured()); +} + +function getPlatformStatus() { + return getPlatforms().map((platform) => ({ + ...platform, + enabled: isPlatformEnabled(platform.id), + configured: isPlatformConfigured(platform.id) + })); +} + +function getEnabledPlatforms({ supportedOnly = true } = {}) { + return getPlatforms().filter((platform) => { + if (supportedOnly && !platform.supported) { + return false; + } + return isPlatformEnabled(platform.id); + }); +} + +function getEnabledPlatformIds({ supportedOnly = true } = {}) { + return getEnabledPlatforms({ supportedOnly }).map((platform) => platform.id); +} + +function getLoginPlatforms() { + return getEnabledPlatforms({ supportedOnly: true }).filter( + (platform) => platform.supportsLogin + ); +} + +function getLinkPlatforms() { + return getEnabledPlatforms({ supportedOnly: true }).filter( + (platform) => platform.supportsLink + ); +} + +function getPlatformLabel(id) { + const platform = getPlatformById(id); + return platform?.label || id; +} + +function getPlatformBadge(id) { + const platform = getPlatformById(id); + return platform?.badge || id; +} + +function normalizePlatformSelection(value, availablePlatforms) { + const available = + Array.isArray(availablePlatforms) && availablePlatforms.length + ? availablePlatforms + : getEnabledPlatformIds(); + if (!value) { + return available; + } + const raw = value.toString().trim().toLowerCase(); + if (!raw) { + return available; + } + if (raw === "all") { + return available; + } + if (raw === "both") { + return ["discord", "twitch"].filter((platform) => available.includes(platform)); + } + const selected = raw + .split(/[,\s]+/) + .map((entry) => entry.trim()) + .filter(Boolean); + const unique = Array.from(new Set(selected)); + return unique.filter((platform) => available.includes(platform)); +} + +function serializePlatformSelection(platforms) { + if (!Array.isArray(platforms) || platforms.length === 0) { + return ""; + } + return Array.from(new Set(platforms)).join(","); +} + +module.exports = { + getPlatforms, + getPlatformById, + getPlatformStatus, + isPlatformEnabled, + isPlatformConfigured, + getEnabledPlatforms, + getEnabledPlatformIds, + getLoginPlatforms, + getLinkPlatforms, + getPlatformLabel, + getPlatformBadge, + normalizePlatformSelection, + serializePlatformSelection +}; diff --git a/src/services/plugin-stats.js b/src/services/plugin-stats.js new file mode 100644 index 0000000..9ddab35 --- /dev/null +++ b/src/services/plugin-stats.js @@ -0,0 +1,107 @@ +const fs = require("fs"); +const path = require("path"); +const { db } = require("./db"); +const { getPlugins } = require("./plugins"); + +function readJsonSafe(filePath) { + try { + const raw = fs.readFileSync(filePath, "utf8"); + return JSON.parse(raw); + } catch { + return null; + } +} + +function loadStatProviders() { + const providers = []; + const plugins = getPlugins().filter((plugin) => plugin.enabled); + for (const plugin of plugins) { + const manifestPath = path.join(plugin.path, "stats.json"); + if (!fs.existsSync(manifestPath)) { + continue; + } + const manifest = readJsonSafe(manifestPath); + if (!manifest) { + continue; + } + const providerPath = path.join( + plugin.path, + manifest.provider || "stats.js" + ); + if (!fs.existsSync(providerPath)) { + continue; + } + let provider = null; + try { + provider = require(providerPath); + } catch (error) { + console.error("Failed to load plugin stats provider", error); + continue; + } + providers.push({ plugin, manifest, provider }); + } + return providers; +} + +function buildProfileSection({ plugin, manifest, provider, userId }) { + if (!manifest.profile || typeof provider.getProfileStats !== "function") { + return null; + } + let result = null; + try { + result = provider.getProfileStats({ db, userId, plugin, manifest }); + } catch (error) { + console.error("Failed to load plugin profile stats", error); + return null; + } + const stats = Array.isArray(result?.stats) ? result.stats : []; + return { + id: manifest.pluginId || plugin.id, + title: manifest.profile.title || manifest.pluginName || plugin.name, + emptyMessage: + manifest.profile.emptyMessage || "No stats available yet.", + stats + }; +} + +function buildLeaderboardSection({ plugin, manifest, provider, limit }) { + if (!manifest.leaderboards || typeof provider.getLeaderboards !== "function") { + return null; + } + let result = null; + try { + result = provider.getLeaderboards({ db, limit, plugin, manifest }); + } catch (error) { + console.error("Failed to load plugin leaderboards", error); + return null; + } + const boards = Array.isArray(result?.boards) ? result.boards : []; + return { + id: manifest.pluginId || plugin.id, + title: + manifest.leaderboards.title || manifest.pluginName || plugin.name, + emptyMessage: + manifest.leaderboards.emptyMessage || "No data available yet.", + boards + }; +} + +function getPluginProfileStats(userId) { + if (!userId) { + return []; + } + return loadStatProviders() + .map((entry) => buildProfileSection({ ...entry, userId })) + .filter(Boolean); +} + +function getPluginLeaderboards(limit = 10) { + return loadStatProviders() + .map((entry) => buildLeaderboardSection({ ...entry, limit })) + .filter(Boolean); +} + +module.exports = { + getPluginProfileStats, + getPluginLeaderboards +}; diff --git a/src/services/plugins.js b/src/services/plugins.js new file mode 100644 index 0000000..18d8622 --- /dev/null +++ b/src/services/plugins.js @@ -0,0 +1,229 @@ +const path = require("path"); +const fs = require("fs"); +const { spawnSync } = require("child_process"); +const { db } = require("./db"); + +const pluginsDir = path.join(__dirname, "..", "..", "plugins"); + +function readJson(filePath) { + const raw = fs.readFileSync(filePath, "utf8"); + return JSON.parse(raw); +} + +function scanPluginDirectories() { + if (!fs.existsSync(pluginsDir)) { + return []; + } + const entries = fs.readdirSync(pluginsDir, { withFileTypes: true }); + const plugins = []; + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const manifestPath = path.join(pluginsDir, entry.name, "plugin.json"); + if (!fs.existsSync(manifestPath)) { + continue; + } + try { + const manifest = readJson(manifestPath); + plugins.push({ + id: manifest.id, + name: manifest.name || manifest.id, + version: manifest.version || "0.0.0", + description: manifest.description || "", + main: manifest.main || "index.js", + dir: path.join(pluginsDir, entry.name) + }); + } catch { + continue; + } + } + return plugins; +} + +function syncPluginRegistry() { + const now = Date.now(); + const plugins = scanPluginDirectories(); + const insert = db.prepare( + "INSERT INTO plugins (id, name, version, enabled, source, path, installed_at, updated_at) " + + "VALUES (?, ?, ?, 1, ?, ?, ?, ?) " + + "ON CONFLICT(id) DO UPDATE SET name = excluded.name, version = excluded.version, path = excluded.path, updated_at = excluded.updated_at" + ); + + for (const plugin of plugins) { + insert.run( + plugin.id, + plugin.name, + plugin.version, + "local", + plugin.dir, + now, + now + ); + } + return plugins; +} + +function getPlugins() { + return db.prepare("SELECT * FROM plugins ORDER BY name").all(); +} + +function setPluginEnabled(id, enabled) { + db.prepare("UPDATE plugins SET enabled = ?, updated_at = ? WHERE id = ?").run( + enabled ? 1 : 0, + Date.now(), + id + ); +} + +function removePlugin(id) { + db.prepare("DELETE FROM plugins WHERE id = ?").run(id); + db.prepare("DELETE FROM plugin_settings WHERE plugin_id = ?").run(id); +} + +function clearPluginCache(pluginPath) { + for (const key of Object.keys(require.cache)) { + if (key.startsWith(pluginPath)) { + delete require.cache[key]; + } + } +} + +function loadEnabled({ + app, + discordClient, + twitchClient, + youtubeClient, + settings, + web, + commandRouter +}) { + const installed = scanPluginDirectories(); + syncPluginRegistry(); + const enabled = new Set( + db + .prepare("SELECT id FROM plugins WHERE enabled = 1") + .all() + .map((row) => row.id) + ); + + for (const plugin of installed) { + if (!enabled.has(plugin.id)) { + continue; + } + const mainPath = path.join(plugin.dir, plugin.main); + if (!fs.existsSync(mainPath)) { + continue; + } + clearPluginCache(plugin.dir); + try { + const mod = require(mainPath); + if (mod && typeof mod.init === "function") { + mod.init({ + app, + discordClient, + twitchClient, + youtubeClient, + settings, + web, + db, + plugin, + commandRouter + }); + } + } catch (error) { + console.error(`Plugin ${plugin.id} failed to load`, error); + } + } +} + +function installFromGit(url, targetFolder) { + if (!fs.existsSync(pluginsDir)) { + fs.mkdirSync(pluginsDir, { recursive: true }); + } + const folderName = + targetFolder || + url + .split("/") + .pop() + .replace(/\.git$/i, "") + .replace(/[^a-zA-Z0-9-_]/g, ""); + const targetPath = path.join(pluginsDir, folderName); + if (fs.existsSync(targetPath)) { + throw new Error("Plugin folder already exists."); + } + const result = spawnSync("git", ["clone", url, targetPath], { + stdio: "pipe", + encoding: "utf8" + }); + if (result.status !== 0) { + throw new Error(result.stderr || "Git clone failed."); + } + return targetPath; +} + +function updatePluginFromGit(pluginPath) { + const result = spawnSync("git", ["-C", pluginPath, "pull"], { + stdio: "pipe", + encoding: "utf8" + }); + if (result.status !== 0) { + throw new Error(result.stderr || "Git pull failed."); + } + return result.stdout; +} + +function createLocalPlugin({ id, name, description }) { + const safeId = id.replace(/[^a-zA-Z0-9-_]/g, ""); + if (!safeId) { + throw new Error("Invalid plugin id."); + } + const pluginDir = path.join(pluginsDir, safeId); + if (fs.existsSync(pluginDir)) { + throw new Error("Plugin already exists."); + } + fs.mkdirSync(pluginDir, { recursive: true }); + const safeName = name || safeId; + const manifest = { + id: safeId, + name: safeName, + version: "0.1.0", + description: description || "", + main: "index.js" + }; + const manifestPath = path.join(pluginDir, "plugin.json"); + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf8"); + const mainPath = path.join(pluginDir, "index.js"); + const escapedName = safeName.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const starter = `module.exports = { + id: "${safeId}", + init({ web, commandRouter }) { + const router = web.createRouter(); + router.get("/", (req, res) => { + res.render("plugin-page", { + title: "${escapedName}", + content: "Edit this plugin to add features." + }); + }); + web.mount("/plugins/${safeId}", router, { + label: "${escapedName}", + role: "admin" + }); + } +};\n`; + fs.writeFileSync(mainPath, starter, "utf8"); + return pluginDir; +} + +module.exports = { + pluginsDir, + scanPluginDirectories, + syncPluginRegistry, + getPlugins, + setPluginEnabled, + removePlugin, + loadEnabled, + installFromGit, + updatePluginFromGit, + createLocalPlugin +}; diff --git a/src/services/rbac.js b/src/services/rbac.js new file mode 100644 index 0000000..ea11c7f --- /dev/null +++ b/src/services/rbac.js @@ -0,0 +1,38 @@ +const { getSetting } = require("./settings"); + +function parseRoleList(value) { + return (value || "") + .toString() + .split(/[,\s]+/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function getRoleFlags(roles = []) { + const adminRoleIds = parseRoleList(getSetting("discord_admin_role_id")); + const modRoleIds = parseRoleList(getSetting("discord_mod_role_id")); + const isAdmin = roles.some((role) => adminRoleIds.includes(role)); + const isMod = roles.some((role) => modRoleIds.includes(role)); + return { isAdmin, isMod }; +} + +function hasAccess(user, requiredRole) { + if (!requiredRole || requiredRole === "public") { + return true; + } + if (!user) { + return false; + } + if (requiredRole === "admin") { + return Boolean(user.isAdmin); + } + if (requiredRole === "mod") { + return Boolean(user.isAdmin || user.isMod); + } + return false; +} + +module.exports = { + getRoleFlags, + hasAccess +}; diff --git a/src/services/settings.js b/src/services/settings.js new file mode 100644 index 0000000..c1f9a74 --- /dev/null +++ b/src/services/settings.js @@ -0,0 +1,138 @@ +const { db } = require("./db"); +const { envBoolean, envNumber, envString } = require("./config"); + +const ENV_DEFAULTS = { + discord_client_id: "DISCORD_CLIENT_ID", + discord_client_secret: "DISCORD_CLIENT_SECRET", + discord_bot_token: "DISCORD_BOT_TOKEN", + discord_guild_id: "DISCORD_GUILD_ID", + discord_admin_role_id: "DISCORD_ADMIN_ROLE_ID", + discord_mod_role_id: "DISCORD_MOD_ROLE_ID", + discord_redirect_uri: "DISCORD_REDIRECT_URI", + twitch_client_id: "TWITCH_CLIENT_ID", + twitch_client_secret: "TWITCH_CLIENT_SECRET", + twitch_redirect_uri: "TWITCH_REDIRECT_URI", + twitch_bot_username: "TWITCH_BOT_USERNAME", + twitch_bot_oauth: "TWITCH_BOT_OAUTH", + twitch_channels: "TWITCH_CHANNELS", + youtube_client_id: "YOUTUBE_CLIENT_ID", + youtube_client_secret: "YOUTUBE_CLIENT_SECRET", + youtube_redirect_uri: "YOUTUBE_REDIRECT_URI", + youtube_bot_refresh_token: "YOUTUBE_BOT_REFRESH_TOKEN", + youtube_bot_channel_id: "YOUTUBE_BOT_CHANNEL_ID" +}; + +function getSetting(key, fallback = null) { + const row = db.prepare("SELECT value FROM settings WHERE key = ?").get(key); + if (!row) { + return getEnvDefault(key, fallback); + } + try { + return JSON.parse(row.value); + } catch { + return row.value; + } +} + +function setSetting(key, value) { + const now = Date.now(); + db.prepare( + "INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) " + + "ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at" + ).run(key, JSON.stringify(value), now); +} + +function getAllSettings() { + const rows = db.prepare("SELECT key, value FROM settings").all(); + const settings = {}; + for (const row of rows) { + try { + settings[row.key] = JSON.parse(row.value); + } catch { + settings[row.key] = row.value; + } + } + return settings; +} + +function ensureDefaults() { + const defaults = { + site_title: "Lumi Bot", + command_prefix: envString("COMMAND_PREFIX", "!"), + discord_client_id: envString("DISCORD_CLIENT_ID", ""), + discord_client_secret: envString("DISCORD_CLIENT_SECRET", ""), + discord_bot_token: envString("DISCORD_BOT_TOKEN", ""), + discord_guild_id: envString("DISCORD_GUILD_ID", ""), + discord_admin_role_id: envString("DISCORD_ADMIN_ROLE_ID", ""), + discord_mod_role_id: envString("DISCORD_MOD_ROLE_ID", ""), + discord_redirect_uri: envString("DISCORD_REDIRECT_URI", ""), + auto_update_enabled: envBoolean("AUTO_UPDATE_ENABLED", false), + auto_update_interval_minutes: envNumber("AUTO_UPDATE_INTERVAL_MINUTES", 60), + git_remote: envString("GIT_REMOTE", "origin"), + git_branch: envString("GIT_BRANCH", "main"), + bot_avatar_url: null, + platform_discord_enabled: envBoolean("PLATFORM_DISCORD_ENABLED", true), + platform_kick_enabled: envBoolean("PLATFORM_KICK_ENABLED", false), + platform_twitch_enabled: envBoolean("PLATFORM_TWITCH_ENABLED", true), + platform_youtube_enabled: envBoolean("PLATFORM_YOUTUBE_ENABLED", false), + twitch_client_id: envString("TWITCH_CLIENT_ID", ""), + twitch_client_secret: envString("TWITCH_CLIENT_SECRET", ""), + twitch_redirect_uri: envString("TWITCH_REDIRECT_URI", ""), + twitch_bot_username: envString("TWITCH_BOT_USERNAME", ""), + twitch_bot_oauth: envString("TWITCH_BOT_OAUTH", ""), + twitch_channels: envString("TWITCH_CHANNELS", ""), + youtube_client_id: envString("YOUTUBE_CLIENT_ID", ""), + youtube_client_secret: envString("YOUTUBE_CLIENT_SECRET", ""), + youtube_redirect_uri: envString("YOUTUBE_REDIRECT_URI", ""), + youtube_bot_refresh_token: envString("YOUTUBE_BOT_REFRESH_TOKEN", ""), + youtube_bot_channel_id: envString("YOUTUBE_BOT_CHANNEL_ID", ""), + theme_light_bg_1: "#ffe5c4", + theme_light_bg_2: "#f4efe8", + theme_light_bg_3: "#e9f3f1", + theme_light_text: "#121518", + theme_light_text_muted: "#2c3137", + theme_light_accent: "#0f6a78", + theme_light_accent_alt: "#f4a340", + theme_light_danger: "#d66d5c", + theme_light_surface: "#ffffff", + theme_light_surface_2: "#fbf9f6", + theme_light_surface_3: "#f9f5ef", + theme_light_border: "#e3ddd6", + theme_dark_bg_1: "#1b1d1f", + theme_dark_bg_2: "#16181b", + theme_dark_bg_3: "#0f1113", + theme_dark_text: "#f2f0ec", + theme_dark_text_muted: "#c5bfb7", + theme_dark_accent: "#4fb6c2", + theme_dark_accent_alt: "#f1b765", + theme_dark_danger: "#e08173", + theme_dark_surface: "#232629", + theme_dark_surface_2: "#2b2f33", + theme_dark_surface_3: "#30353a", + theme_dark_border: "#34393d", + theme_role_public: "#ffffff", + theme_role_mod: "#2cb678", + theme_role_admin: "#e35678" + }; + + for (const [key, value] of Object.entries(defaults)) { + if (getSetting(key) === null) { + setSetting(key, value); + } + } +} + +function getEnvDefault(key, fallback) { + const envKey = ENV_DEFAULTS[key]; + if (!envKey) { + return fallback; + } + return envString(envKey, fallback); +} + +module.exports = { + getSetting, + setSetting, + getAllSettings, + ensureDefaults +}; diff --git a/src/services/stats.js b/src/services/stats.js new file mode 100644 index 0000000..aeb3089 --- /dev/null +++ b/src/services/stats.js @@ -0,0 +1,48 @@ +const { db } = require("./db"); + +function touchUserStats(userId) { + const now = Date.now(); + db.prepare( + "INSERT INTO stats (user_id, messages, commands, updated_at) VALUES (?, 0, 0, ?) " + + "ON CONFLICT(user_id) DO UPDATE SET updated_at = excluded.updated_at" + ).run(userId, now); +} + +function incrementMessages(userId) { + const now = Date.now(); + db.prepare( + "INSERT INTO stats (user_id, messages, commands, updated_at) VALUES (?, 1, 0, ?) " + + "ON CONFLICT(user_id) DO UPDATE SET messages = messages + 1, updated_at = excluded.updated_at" + ).run(userId, now); +} + +function incrementCommands(userId) { + const now = Date.now(); + db.prepare( + "INSERT INTO stats (user_id, messages, commands, updated_at) VALUES (?, 0, 1, ?) " + + "ON CONFLICT(user_id) DO UPDATE SET commands = commands + 1, updated_at = excluded.updated_at" + ).run(userId, now); +} + +function getLeaderboard(limit = 20) { + return db + .prepare( + "SELECT user_profiles.internal_username AS username, " + + "COALESCE(discord.avatar, twitch.avatar, youtube.avatar) AS avatar, " + + "stats.messages, stats.commands " + + "FROM stats " + + "JOIN user_profiles ON user_profiles.id = stats.user_id " + + "LEFT JOIN user_identities AS discord ON discord.user_id = user_profiles.id AND discord.provider = 'discord' " + + "LEFT JOIN user_identities AS twitch ON twitch.user_id = user_profiles.id AND twitch.provider = 'twitch' " + + "LEFT JOIN user_identities AS youtube ON youtube.user_id = user_profiles.id AND youtube.provider = 'youtube' " + + "ORDER BY stats.messages DESC LIMIT ?" + ) + .all(limit); +} + +module.exports = { + touchUserStats, + incrementMessages, + incrementCommands, + getLeaderboard +}; diff --git a/src/services/top.js b/src/services/top.js new file mode 100644 index 0000000..ce36ddd --- /dev/null +++ b/src/services/top.js @@ -0,0 +1,487 @@ +const { db } = require("./db"); +const { getSetting } = require("./settings"); +const { getEnabledPlatformIds } = require("./platforms"); +const { getPluginLeaderboards } = require("./plugin-stats"); + +const coreProviders = new Map(); +const coreOrder = []; +let coreRegistered = false; + +function registerTopProvider(provider) { + if (!provider || !provider.id) { + return; + } + const id = normalizeProviderId(provider.id); + if (!id) { + return; + } + const entry = { + id, + label: provider.label || id, + section: provider.section || "Community", + description: provider.description || "", + valueLabel: provider.valueLabel || "Total", + rowType: provider.rowType || "user", + aliases: Array.isArray(provider.aliases) ? provider.aliases : [], + order: Number.isFinite(provider.order) ? provider.order : coreOrder.length, + getRows: typeof provider.getRows === "function" ? provider.getRows : null + }; + if (!coreProviders.has(id)) { + coreOrder.push(id); + } + coreProviders.set(id, entry); +} + +function ensureCoreProviders() { + if (coreRegistered) { + return; + } + coreRegistered = true; + registerTopProvider({ + id: "messages", + label: "Top messages", + section: "Community Interaction", + valueLabel: "Messages", + description: "Users with the most messages.", + getRows: ({ limit }) => buildStatRows("messages", limit) + }); + registerTopProvider({ + id: "commands", + label: "Top commands", + section: "Community Interaction", + valueLabel: "Commands", + description: "Users who ran the most commands.", + getRows: ({ limit }) => buildStatRows("commands", limit) + }); + registerTopProvider({ + id: "modage", + label: "Top mod age", + section: "Moderation", + valueLabel: "Time", + description: "Moderators with the longest mod tenure.", + getRows: ({ limit }) => buildModAgeRows(limit) + }); + registerTopProvider({ + id: "coins", + label: "Top currency", + section: "Economy", + valueLabel: getCurrencyLabel(), + description: "Top balances from the currency framework.", + getRows: ({ limit }) => buildCurrencyRows(limit) + }); + registerTopProvider({ + id: "interactors", + label: "Top interactors", + section: "Expression Interaction", + valueLabel: "Interactions", + description: "Most interaction actions given.", + getRows: ({ limit }) => buildExpressionRows(limit, "given") + }); + registerTopProvider({ + id: "interactions_received", + label: "Top interactions received", + section: "Expression Interaction", + valueLabel: "Interactions", + description: "Most interaction actions received.", + getRows: ({ limit }) => buildExpressionRows(limit, "received") + }); + registerTopProvider({ + id: "commands_run", + label: "Top commands run", + section: "Commands", + valueLabel: "Runs", + rowType: "command", + description: "Most popular commands across platforms.", + getRows: ({ limit }) => buildCommandUsageRows(limit) + }); + registerTopProvider({ + id: "followage", + label: "Top followage", + section: "Platforms", + valueLabel: "Days", + description: "Longest follower durations.", + getRows: () => ({ + rows: [], + emptyMessage: "Followage tracking is not configured yet." + }) + }); + registerTopProvider({ + id: "watchtime", + label: "Top watchtime", + section: "Platforms", + valueLabel: "Hours", + description: "Most watch time recorded.", + getRows: () => ({ + rows: [], + emptyMessage: "Watchtime tracking is not configured yet." + }) + }); + registerTopProvider({ + id: "games", + label: "Top games", + section: "Games", + valueLabel: "Mentions", + rowType: "game", + description: "Most popular games tracked by plugins.", + getRows: () => ({ + rows: [], + emptyMessage: "Game tracking is not configured yet." + }) + }); +} + +function getTopProviders({ includePlugins = true, limit = 10 } = {}) { + ensureCoreProviders(); + const providers = coreOrder.map((id) => coreProviders.get(id)).filter(Boolean); + if (!includePlugins) { + return providers; + } + const pluginProviders = buildPluginProviders({ limit }); + return mergeProviders(providers, pluginProviders); +} + +function getTopBoards({ limit = 10 } = {}) { + const providers = getTopProviders({ includePlugins: true, limit }); + return providers.map((provider) => { + if (provider.getRows) { + const result = provider.getRows({ db, limit, settings: { getSetting } }); + return normalizeProviderResult(provider, result); + } + return normalizeProviderResult(provider, { + rows: provider.rows || [], + rowType: provider.rowType, + valueLabel: provider.valueLabel, + emptyMessage: provider.emptyMessage + }); + }); +} + +function getLeaderboardSections({ limit = 10 } = {}) { + const boards = getTopBoards({ limit }); + const sections = []; + const sectionMap = new Map(); + boards.forEach((board) => { + const title = board.section || "Leaderboards"; + if (!sectionMap.has(title)) { + sectionMap.set(title, { title, boards: [] }); + sections.push(sectionMap.get(title)); + } + sectionMap.get(title).boards.push(board); + }); + return sections; +} + +function getTopCommandOptions() { + return getTopProviders({ includePlugins: true }).map((provider) => ({ + id: provider.id, + label: provider.label, + description: provider.description || "", + aliases: provider.aliases || [] + })); +} + +function registerTopCommand({ commandRouter, settings }) { + if (!commandRouter) { + return; + } + ensureCoreProviders(); + const platforms = getEnabledPlatformIds(); + commandRouter.registerCommands("core", [ + { + id: "top", + triggers: ["top"], + platforms, + handler: (ctx) => handleTopCommand({ ctx, settings }) + } + ]); +} + +async function handleTopCommand({ ctx, settings }) { + const prefix = settings.getSetting("command_prefix", "!"); + const rawId = (ctx.args[0] || "").trim().toLowerCase(); + if (!rawId || ["help", "list"].includes(rawId)) { + await ctx.reply(buildTopHelp(prefix)); + return true; + } + const board = findTopBoard(rawId, 5); + if (!board) { + await ctx.reply(buildTopHelp(prefix)); + return true; + } + if (!board.rows.length) { + await ctx.reply(board.emptyMessage || "No data recorded yet."); + return true; + } + const list = board.rows + .slice(0, 5) + .map((entry, index) => { + const label = entry.username || entry.label || entry.name || "Unknown"; + return `${index + 1}) ${label} (${entry.value})`; + }) + .join(" | "); + await ctx.reply(`${board.label}: ${list}`); + return true; +} + +function findTopBoard(id, limit) { + const boards = getTopBoards({ limit }); + const normalized = id.toLowerCase(); + return boards.find((board) => { + if (board.id === normalized) { + return true; + } + return Array.isArray(board.aliases) + ? board.aliases.map((alias) => alias.toLowerCase()).includes(normalized) + : false; + }); +} + +function buildTopHelp(prefix) { + const options = getTopCommandOptions().map((entry) => entry.id); + if (!options.length) { + return "No top categories are available yet."; + } + return `Usage: ${prefix}top . Available: ${options.join(", ")}`; +} + +function mergeProviders(coreList, pluginList) { + const merged = coreList.slice(); + const indexMap = new Map(merged.map((provider, index) => [provider.id, index])); + for (const provider of pluginList) { + const existingIndex = indexMap.get(provider.id); + if (existingIndex !== undefined) { + if (provider.override) { + merged[existingIndex] = provider; + } + continue; + } + indexMap.set(provider.id, merged.length); + merged.push(provider); + } + return merged; +} + +function normalizeProviderResult(provider, result) { + const rows = normalizeRows(result?.rows || []); + return { + id: provider.id, + label: provider.label, + section: provider.section, + rowType: result?.rowType || provider.rowType, + valueLabel: result?.valueLabel || provider.valueLabel || "Total", + rows, + emptyMessage: + result?.emptyMessage || provider.emptyMessage || "No data recorded yet.", + aliases: result?.aliases || provider.aliases || [] + }; +} + +function normalizeRows(rows) { + return (rows || []).map((row) => { + const value = row.value ?? row.total ?? row.count ?? 0; + return { + username: row.username || row.user || row.label || row.name || null, + label: row.label || row.username || row.name || null, + value: formatNumber(value), + rawValue: Number(value) || 0 + }; + }); +} + +function buildPluginProviders({ limit }) { + const pluginSections = getPluginLeaderboards(limit); + const providers = []; + pluginSections.forEach((section) => { + const sectionId = slugify(section.id || section.title || "plugin"); + const sectionLabel = section.title || "Plugin"; + (section.boards || []).forEach((board) => { + const boardKey = slugify(board.id || board.title || "board"); + const topId = board.topId ? normalizeProviderId(board.topId) : ""; + const id = topId || `${sectionId}-${boardKey}`; + const rows = board.rows || []; + const aliases = Array.isArray(board.topAliases) + ? board.topAliases.map(normalizeProviderId).filter(Boolean) + : Array.isArray(board.aliases) + ? board.aliases.map(normalizeProviderId).filter(Boolean) + : []; + providers.push({ + id, + label: board.title || boardKey, + section: sectionLabel, + description: board.description || "", + rowType: board.rowType || "user", + valueLabel: board.valueLabel || "Total", + rows, + emptyMessage: board.emptyMessage || section.emptyMessage || "No data yet.", + aliases, + override: Boolean(board.topOverride) + }); + }); + }); + return providers; +} + +function slugify(value) { + return (value || "") + .toString() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .trim(); +} + +function normalizeProviderId(value) { + return (value || "") + .toString() + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function buildStatRows(column, limit) { + const rows = db + .prepare( + `SELECT user_profiles.internal_username AS username, stats.${column} AS value ` + + "FROM stats " + + "JOIN user_profiles ON user_profiles.id = stats.user_id " + + `ORDER BY stats.${column} DESC LIMIT ?` + ) + .all(limit); + return { rows }; +} + +function buildModAgeRows(limit) { + if (!tableExists("mod_role_periods")) { + return { rows: [], emptyMessage: "No moderator history recorded yet." }; + } + const now = Date.now(); + const rows = db + .prepare( + "SELECT mod_role_periods.user_id AS user_id, " + + "SUM(CASE WHEN mod_role_periods.end_at IS NULL THEN ? - mod_role_periods.start_at ELSE mod_role_periods.end_at - mod_role_periods.start_at END) AS total_ms " + + "FROM mod_role_periods " + + "GROUP BY mod_role_periods.user_id " + + "ORDER BY total_ms DESC LIMIT ?" + ) + .all(now, limit); + const mapped = rows + .map((row) => { + const profile = db + .prepare("SELECT internal_username FROM user_profiles WHERE id = ?") + .get(row.user_id); + return { + username: profile?.internal_username || row.user_id, + value: formatDuration(row.total_ms || 0) + }; + }) + .filter((row) => row.username); + return { rows: mapped, valueLabel: "Time" }; +} + +function buildExpressionRows(limit, type) { + if (!tableExists("expression_user_stats")) { + return { rows: [], emptyMessage: "No interactions recorded yet." }; + } + const column = type === "received" ? "received_count" : "given_count"; + const rows = db + .prepare( + `SELECT user_profiles.internal_username AS username, SUM(expression_user_stats.${column}) AS value ` + + "FROM expression_user_stats " + + "JOIN user_profiles ON user_profiles.id = expression_user_stats.user_id " + + "GROUP BY expression_user_stats.user_id " + + `ORDER BY value DESC LIMIT ?` + ) + .all(limit); + return { rows }; +} + +function buildCommandUsageRows(limit) { + if (!tableExists("command_usage")) { + return { rows: [], emptyMessage: "No command usage recorded yet." }; + } + const rows = db + .prepare("SELECT command_id, count FROM command_usage ORDER BY count DESC LIMIT ?") + .all(limit) + .map((row) => ({ + label: formatCommandId(row.command_id), + value: row.count + })); + return { rows, rowType: "command" }; +} + +function buildCurrencyRows(limit) { + if (!tableExists("echonomy_accounts")) { + return { rows: [], emptyMessage: "Currency framework not active." }; + } + const rows = db + .prepare( + "SELECT user_profiles.internal_username AS username, echonomy_accounts.balance AS value " + + "FROM echonomy_accounts " + + "JOIN user_profiles ON user_profiles.id = echonomy_accounts.user_id " + + "ORDER BY echonomy_accounts.balance DESC LIMIT ?" + ) + .all(limit); + return { rows, valueLabel: getCurrencyLabel() }; +} + +function formatCommandId(commandId) { + if (!commandId) { + return "Unknown"; + } + if (commandId.startsWith("custom:")) { + return `!${commandId.slice(7)}`; + } + return commandId; +} + +function getCurrencyLabel() { + const plural = getPluginSetting("echonomy-framework", "currency_name_plural"); + const singular = getPluginSetting("echonomy-framework", "currency_name"); + return plural || singular || "Coins"; +} + +function getPluginSetting(pluginId, key) { + const row = db + .prepare("SELECT value FROM plugin_settings WHERE plugin_id = ? AND key = ?") + .get(pluginId, key); + return row?.value ? row.value.toString() : null; +} + +function tableExists(name) { + const row = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(name); + return Boolean(row); +} + +function formatNumber(value) { + const number = Number(value); + if (!Number.isFinite(number)) { + return value ?? "0"; + } + return number.toLocaleString("en-US"); +} + +function formatDuration(totalMs) { + const totalSeconds = Math.max(0, Math.floor(totalMs / 1000)); + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + if (days > 0) { + return `${days}d ${hours}h`; + } + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + return `${minutes}m`; +} + +module.exports = { + registerTopProvider, + getTopProviders, + getTopBoards, + getLeaderboardSections, + getTopCommandOptions, + registerTopCommand +}; diff --git a/src/services/twitch.js b/src/services/twitch.js new file mode 100644 index 0000000..d623991 --- /dev/null +++ b/src/services/twitch.js @@ -0,0 +1,95 @@ +const tmi = require("tmi.js"); +const { getSetting } = require("./settings"); +const { incrementMessages } = require("./stats"); +const { ensureUserForIdentity } = require("./users"); + +let client = null; + +function parseChannels(raw) { + return (raw || "") + .split(/[,\s]+/) + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => (entry.startsWith("#") ? entry : `#${entry}`)); +} + +async function startTwitchBot({ commandRouter } = {}) { + const username = getSetting("twitch_bot_username"); + const oauth = getSetting("twitch_bot_oauth"); + const channels = parseChannels(getSetting("twitch_channels")); + if (!username || !oauth || !channels.length) { + return null; + } + + const password = oauth.startsWith("oauth:") ? oauth : `oauth:${oauth}`; + client = new tmi.Client({ + options: { debug: false }, + identity: { + username, + password + }, + channels + }); + + client.on("connected", (address, port) => { + console.log(`Twitch bot connected to ${address}:${port}`); + }); + + client.on("message", async (channel, tags, message, self) => { + if (self) { + return; + } + const userId = tags["user-id"]; + if (!userId) { + return; + } + const displayName = tags["display-name"] || tags.username; + const profile = ensureUserForIdentity({ + provider: "twitch", + providerUserId: userId, + displayName + }); + incrementMessages(profile.id); + + if (commandRouter) { + await commandRouter.handleMessage({ + platform: "twitch", + raw: message, + user: profile, + platformUser: { + id: userId, + displayName, + username: tags.username + }, + meta: { channel, tags, client }, + reply: async (content) => { + try { + await client.say(channel, content); + } catch (error) { + console.error("Twitch command reply failed", error); + } + } + }); + } + }); + + await client.connect(); + return client; +} + +async function stopTwitchBot() { + if (client) { + await client.disconnect(); + client = null; + } +} + +function getClient() { + return client; +} + +module.exports = { + startTwitchBot, + stopTwitchBot, + getClient +}; diff --git a/src/services/update-manager.js b/src/services/update-manager.js new file mode 100644 index 0000000..d98f751 --- /dev/null +++ b/src/services/update-manager.js @@ -0,0 +1,455 @@ +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const crypto = require("crypto"); +let AdmZip = null; +try { + AdmZip = require("adm-zip"); +} catch { + AdmZip = null; +} +const { db } = require("./db"); + +const repoRoot = path.join(__dirname, "..", ".."); +const dataDir = path.join(repoRoot, "data"); +const snapshotsDir = path.join(dataDir, "snapshots"); +const indexPath = path.join(snapshotsDir, "index.json"); +const maxSnapshots = 20; + +function ensureSnapshotsDir() { + fs.mkdirSync(snapshotsDir, { recursive: true }); +} + +function loadIndex() { + if (!fs.existsSync(indexPath)) { + return []; + } + try { + const raw = fs.readFileSync(indexPath, "utf8"); + const data = JSON.parse(raw); + return Array.isArray(data) ? data : []; + } catch { + return []; + } +} + +function saveIndex(entries) { + ensureSnapshotsDir(); + fs.writeFileSync(indexPath, JSON.stringify(entries, null, 2), "utf8"); +} + +async function backupDatabase(targetPath) { + if (typeof db.backup === "function") { + await db.backup(targetPath); + return; + } + const source = path.join(dataDir, "app.db"); + if (fs.existsSync(source)) { + fs.copyFileSync(source, targetPath); + } +} + +async function createSnapshot({ type, pluginId }) { + ensureSnapshotsDir(); + const id = `${Date.now()}-${crypto.randomUUID()}`; + const snapshotPath = path.join(snapshotsDir, id); + fs.mkdirSync(snapshotPath, { recursive: true }); + + const dbPath = path.join(snapshotPath, "app.db"); + await backupDatabase(dbPath); + + let pluginExisted = false; + let pluginZip = null; + if (type === "bot") { + const coreZip = path.join(snapshotPath, "core.zip"); + zipCore(coreZip); + } + + if (type === "plugin" && pluginId) { + const pluginDir = path.join(repoRoot, "plugins", pluginId); + pluginExisted = fs.existsSync(pluginDir); + if (pluginExisted) { + pluginZip = path.join(snapshotPath, "plugin.zip"); + zipFolder(pluginDir, pluginZip, { base: pluginDir }); + } + } + + return { id, type, pluginId, pluginExisted, pluginZip, snapshotPath }; +} + +function finalizeSnapshot(snapshot) { + const entries = loadIndex(); + const record = { + id: snapshot.id, + type: snapshot.type, + pluginId: snapshot.pluginId || null, + pluginExisted: snapshot.pluginExisted || false, + createdAt: Date.now(), + status: "available", + path: snapshot.snapshotPath + }; + entries.push(record); + saveIndex(pruneEntries(entries)); + return record; +} + +function discardSnapshot(snapshot) { + if (!snapshot?.snapshotPath) { + return; + } + try { + fs.rmSync(snapshot.snapshotPath, { recursive: true, force: true }); + } catch { + // Ignore cleanup failures. + } +} + +function pruneEntries(entries) { + const available = entries + .filter((entry) => entry.status === "available") + .sort((a, b) => b.createdAt - a.createdAt); + const keep = new Set(available.slice(0, maxSnapshots).map((entry) => entry.id)); + const pruned = entries.filter((entry) => entry.status !== "available" || keep.has(entry.id)); + + for (const entry of entries) { + if (entry.status === "available" && !keep.has(entry.id)) { + try { + fs.rmSync(entry.path, { recursive: true, force: true }); + } catch { + // Ignore cleanup failures. + } + } + } + + return pruned; +} + +function listSnapshots() { + return loadIndex() + .filter((entry) => entry.status === "available") + .sort((a, b) => b.createdAt - a.createdAt); +} + +function markSnapshotRolledBack(id) { + const entries = loadIndex(); + const entry = entries.find((item) => item.id === id); + if (!entry) { + return null; + } + entry.status = "rolled_back"; + entry.rolledBackAt = Date.now(); + saveIndex(entries); + return entry; +} + +function extractZip(zipPath, targetDir) { + if (!AdmZip) { + throw new Error("adm-zip is not installed. Run npm install."); + } + const zip = new AdmZip(zipPath); + zip.extractAllTo(targetDir, true); +} + +function resolveZipRoot(extractedDir) { + const packagePath = path.join(extractedDir, "package.json"); + if (fs.existsSync(packagePath)) { + return extractedDir; + } + const entries = fs.readdirSync(extractedDir, { withFileTypes: true }); + const dirs = entries.filter((entry) => entry.isDirectory()); + if (dirs.length === 1) { + const candidate = path.join(extractedDir, dirs[0].name); + if (fs.existsSync(path.join(candidate, "package.json"))) { + return candidate; + } + } + return extractedDir; +} + +function resolvePatchRoot(extractedDir) { + const entries = fs.readdirSync(extractedDir, { withFileTypes: true }); + const dirs = entries.filter((entry) => entry.isDirectory()); + const files = entries.filter((entry) => entry.isFile()); + if (files.length > 0) { + return extractedDir; + } + if (dirs.length === 1) { + return path.join(extractedDir, dirs[0].name); + } + return extractedDir; +} + +function resolvePluginRoot(extractedDir) { + const pluginPath = path.join(extractedDir, "plugin.json"); + if (fs.existsSync(pluginPath)) { + return extractedDir; + } + const entries = fs.readdirSync(extractedDir, { withFileTypes: true }); + const dirs = entries.filter((entry) => entry.isDirectory()); + if (dirs.length === 1) { + const candidate = path.join(extractedDir, dirs[0].name); + if (fs.existsSync(path.join(candidate, "plugin.json"))) { + return candidate; + } + } + return extractedDir; +} + +function verifyBotPackage(rootPath) { + const required = [ + path.join(rootPath, "package.json"), + path.join(rootPath, "safe-mode.js"), + path.join(rootPath, "src", "main.js"), + path.join(rootPath, "src", "web", "server.js") + ]; + for (const filePath of required) { + if (!fs.existsSync(filePath)) { + throw new Error(`Missing required file: ${path.relative(rootPath, filePath)}`); + } + } + JSON.parse(fs.readFileSync(required[0], "utf8")); +} + +function verifyPatchPackage(rootPath) { + if (!hasAnyFiles(rootPath)) { + throw new Error("Patch archive is empty."); + } +} + +function verifyPluginPackage(rootPath) { + const pluginPath = path.join(rootPath, "plugin.json"); + if (!fs.existsSync(pluginPath)) { + throw new Error("plugin.json not found in plugin package."); + } + const manifest = JSON.parse(fs.readFileSync(pluginPath, "utf8")); + if (!manifest.id) { + throw new Error("plugin.json must include an id."); + } + const mainFile = manifest.main || "index.js"; + const mainPath = path.join(rootPath, mainFile); + if (!fs.existsSync(mainPath)) { + throw new Error(`Plugin entry ${mainFile} not found.`); + } + return manifest; +} + +function zipCore(destination) { + if (!AdmZip) { + throw new Error("adm-zip is not installed. Run npm install."); + } + const zip = new AdmZip(); + addFolder(zip, repoRoot, repoRoot, new Set([".git", "node_modules", "data", "plugins"])); + zip.writeZip(destination); +} + +function zipFolder(source, destination, options) { + if (!AdmZip) { + throw new Error("adm-zip is not installed. Run npm install."); + } + const zip = new AdmZip(); + const base = options?.base || source; + addFolder(zip, source, base, new Set(["node_modules"])); + zip.writeZip(destination); +} + +function addFolder(zip, folderPath, basePath, ignore) { + const entries = fs.readdirSync(folderPath, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(folderPath, entry.name); + const relPath = path.relative(basePath, fullPath); + const topLevel = relPath.split(path.sep)[0]; + if (ignore.has(topLevel)) { + continue; + } + if (entry.isDirectory()) { + addFolder(zip, fullPath, basePath, ignore); + } else if (entry.isFile()) { + zip.addLocalFile(fullPath, path.dirname(relPath)); + } + } +} + +function resetCoreFiles() { + const ignore = new Set([".git", "node_modules", "data", "plugins"]); + const entries = fs.readdirSync(repoRoot, { withFileTypes: true }); + for (const entry of entries) { + if (ignore.has(entry.name)) { + continue; + } + const fullPath = path.join(repoRoot, entry.name); + fs.rmSync(fullPath, { recursive: true, force: true }); + } +} + +function copyDirectory(source, target, ignore) { + const entries = fs.readdirSync(source, { withFileTypes: true }); + for (const entry of entries) { + if (ignore.has(entry.name)) { + continue; + } + const srcPath = path.join(source, entry.name); + const destPath = path.join(target, entry.name); + if (entry.isDirectory()) { + fs.mkdirSync(destPath, { recursive: true }); + copyDirectory(srcPath, destPath, ignore); + } else if (entry.isFile()) { + fs.copyFileSync(srcPath, destPath); + } + } +} + +function applyCoreUpdate(rootPath) { + resetCoreFiles(); + copyDirectory( + rootPath, + repoRoot, + new Set([".git", "node_modules", "data", "plugins"]) + ); +} + +function applyCorePatch(rootPath) { + copyDirectory( + rootPath, + repoRoot, + new Set([".git", "node_modules", "data", "plugins"]) + ); +} + +function hasAnyFiles(rootPath) { + const entries = fs.readdirSync(rootPath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile()) { + return true; + } + if (entry.isDirectory()) { + if (hasAnyFiles(path.join(rootPath, entry.name))) { + return true; + } + } + } + return false; +} + +function applyPluginFiles(rootPath, pluginId) { + const pluginsDir = path.join(repoRoot, "plugins"); + const targetDir = path.join(pluginsDir, pluginId); + fs.rmSync(targetDir, { recursive: true, force: true }); + fs.mkdirSync(pluginsDir, { recursive: true }); + fs.mkdirSync(targetDir, { recursive: true }); + copyDirectory(rootPath, targetDir, new Set(["node_modules"])); +} + +async function applyBotUpdate(zipPath, options = {}) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-update-")); + try { + extractZip(zipPath, tempDir); + const mode = options.mode === "patch" ? "patch" : "full"; + const rootPath = + mode === "patch" ? resolvePatchRoot(tempDir) : resolveZipRoot(tempDir); + if (mode === "patch") { + verifyPatchPackage(rootPath); + } else { + verifyBotPackage(rootPath); + } + + const snapshot = await createSnapshot({ type: "bot" }); + try { + if (mode === "patch") { + applyCorePatch(rootPath); + } else { + applyCoreUpdate(rootPath); + } + return finalizeSnapshot(snapshot); + } catch (error) { + discardSnapshot(snapshot); + throw error; + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +async function applyPluginUpdate(zipPath) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-plugin-")); + try { + extractZip(zipPath, tempDir); + const rootPath = resolvePluginRoot(tempDir); + const manifest = verifyPluginPackage(rootPath); + + const snapshot = await createSnapshot({ type: "plugin", pluginId: manifest.id }); + try { + applyPluginFiles(rootPath, manifest.id); + return finalizeSnapshot(snapshot); + } catch (error) { + discardSnapshot(snapshot); + throw error; + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function restoreDatabase(snapshotPath) { + const source = path.join(snapshotPath, "app.db"); + const target = path.join(dataDir, "app.db"); + if (!fs.existsSync(source)) { + throw new Error("Snapshot database not found."); + } + fs.copyFileSync(source, target); + const wal = path.join(dataDir, "app.db-wal"); + const shm = path.join(dataDir, "app.db-shm"); + fs.rmSync(wal, { force: true }); + fs.rmSync(shm, { force: true }); +} + +function restoreSnapshot(id) { + const entries = loadIndex(); + const entry = entries.find((item) => item.id === id); + if (!entry) { + throw new Error("Snapshot not found."); + } + if (entry.status !== "available") { + throw new Error("Snapshot is no longer available."); + } + + if (entry.type === "bot") { + const coreZip = path.join(entry.path, "core.zip"); + if (!fs.existsSync(coreZip)) { + throw new Error("Snapshot core archive missing."); + } + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-restore-")); + extractZip(coreZip, tempDir); + const rootPath = resolveZipRoot(tempDir); + applyCoreUpdate(rootPath); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + + if (entry.type === "plugin") { + const pluginsDir = path.join(repoRoot, "plugins"); + const targetDir = path.join(pluginsDir, entry.pluginId); + if (entry.pluginExisted) { + const pluginZip = path.join(entry.path, "plugin.zip"); + if (!fs.existsSync(pluginZip)) { + throw new Error("Snapshot plugin archive missing."); + } + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-restore-")); + extractZip(pluginZip, tempDir); + const rootPath = resolvePluginRoot(tempDir); + applyPluginFiles(rootPath, entry.pluginId); + fs.rmSync(tempDir, { recursive: true, force: true }); + } else { + fs.rmSync(targetDir, { recursive: true, force: true }); + } + } + + restoreDatabase(entry.path); + markSnapshotRolledBack(id); + return entry; +} + +module.exports = { + applyBotUpdate, + applyPluginUpdate, + listSnapshots, + restoreSnapshot +}; diff --git a/src/services/updater.js b/src/services/updater.js new file mode 100644 index 0000000..08d58f5 --- /dev/null +++ b/src/services/updater.js @@ -0,0 +1,39 @@ +const path = require("path"); +const { spawnSync } = require("child_process"); + +const repoRoot = path.join(__dirname, "..", ".."); + +function runGit(args) { + const result = spawnSync("git", args, { + cwd: repoRoot, + encoding: "utf8" + }); + if (result.status !== 0) { + throw new Error(result.stderr || "Git command failed."); + } + return result.stdout.trim(); +} + +function checkForUpdates(remote, branch) { + runGit(["fetch", remote]); + const count = runGit([ + "rev-list", + `HEAD..${remote}/${branch}`, + "--count" + ]); + return Number(count) > 0; +} + +function pullUpdates(remote, branch) { + return runGit(["pull", remote, branch]); +} + +function requestRestart() { + setTimeout(() => process.exit(10), 500); +} + +module.exports = { + checkForUpdates, + pullUpdates, + requestRestart +}; diff --git a/src/services/users.js b/src/services/users.js new file mode 100644 index 0000000..4653453 --- /dev/null +++ b/src/services/users.js @@ -0,0 +1,220 @@ +const crypto = require("crypto"); +const { db } = require("./db"); + +function normalizeUsername(name) { + return (name || "").trim(); +} + +function isUsernameAvailable(name, excludeUserId) { + const trimmed = normalizeUsername(name); + if (!trimmed) { + return false; + } + const row = db + .prepare( + "SELECT id FROM user_profiles WHERE internal_username = ? LIMIT 1" + ) + .get(trimmed); + if (!row) { + return true; + } + return excludeUserId ? row.id === excludeUserId : false; +} + +function generateUniqueUsername(primary, fallback, excludeUserId) { + const candidates = [primary, fallback] + .map(normalizeUsername) + .filter(Boolean); + + for (const candidate of candidates) { + if (isUsernameAvailable(candidate, excludeUserId)) { + return candidate; + } + } + + const base = candidates[0] || "user"; + let suffix = 2; + let attempt = `${base}-${suffix}`; + while (!isUsernameAvailable(attempt, excludeUserId)) { + suffix += 1; + attempt = `${base}-${suffix}`; + } + return attempt; +} + +function getUserProfileById(userId) { + return db + .prepare( + "SELECT id, internal_username, username_updated_at FROM user_profiles WHERE id = ?" + ) + .get(userId); +} + +function getUserIdentities(userId) { + return db + .prepare( + "SELECT provider, provider_user_id, display_name, avatar FROM user_identities WHERE user_id = ?" + ) + .all(userId); +} + +function getIdentity(provider, providerUserId) { + return db + .prepare( + "SELECT user_id, provider, provider_user_id, display_name, avatar FROM user_identities WHERE provider = ? AND provider_user_id = ?" + ) + .get(provider, providerUserId); +} + +function createUserProfile(internalUsername) { + const now = Date.now(); + const id = crypto.randomUUID(); + db.prepare( + "INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)" + ).run(id, internalUsername, now, now); + return getUserProfileById(id); +} + +function updateIdentity(userId, provider, providerUserId, displayName, avatar) { + const now = Date.now(); + db.prepare( + "INSERT INTO user_identities (user_id, provider, provider_user_id, display_name, avatar, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT(provider, provider_user_id) DO UPDATE SET user_id = excluded.user_id, display_name = excluded.display_name, avatar = excluded.avatar, updated_at = excluded.updated_at" + ).run( + userId, + provider, + providerUserId, + displayName || null, + avatar || null, + now, + now + ); +} + +function ensureUserForIdentity({ provider, providerUserId, displayName, avatar, fallbackName }) { + const existing = getIdentity(provider, providerUserId); + if (existing) { + updateIdentity( + existing.user_id, + provider, + providerUserId, + displayName, + avatar + ); + return getUserProfileById(existing.user_id); + } + + const internalUsername = generateUniqueUsername( + displayName, + fallbackName + ); + const profile = createUserProfile(internalUsername); + updateIdentity( + profile.id, + provider, + providerUserId, + displayName, + avatar + ); + return profile; +} + +function mergeUsers(fromUserId, toUserId) { + if (fromUserId === toUserId) { + return toUserId; + } + db.transaction(() => { + db.prepare("UPDATE user_identities SET user_id = ? WHERE user_id = ?").run( + toUserId, + fromUserId + ); + db.prepare("UPDATE stats SET user_id = ? WHERE user_id = ?").run( + toUserId, + fromUserId + ); + const accounts = db + .prepare("SELECT id, provider FROM linked_accounts WHERE user_id = ?") + .all(fromUserId); + for (const account of accounts) { + const existing = db + .prepare( + "SELECT id FROM linked_accounts WHERE user_id = ? AND provider = ?" + ) + .get(toUserId, account.provider); + if (existing) { + db.prepare("DELETE FROM linked_accounts WHERE id = ?").run(account.id); + } else { + db.prepare("UPDATE linked_accounts SET user_id = ? WHERE id = ?").run( + toUserId, + account.id + ); + } + } + db.prepare("DELETE FROM user_profiles WHERE id = ?").run(fromUserId); + })(); + return toUserId; +} + +function linkIdentityToUser({ userId, provider, providerUserId, displayName, avatar }) { + const existing = getIdentity(provider, providerUserId); + let targetUserId = userId; + if (existing && existing.user_id !== userId) { + targetUserId = mergeUsers(existing.user_id, userId); + } + updateIdentity( + targetUserId, + provider, + providerUserId, + displayName, + avatar + ); + return getUserProfileById(targetUserId); +} + +function updateInternalUsername(userId, desiredName) { + const trimmed = normalizeUsername(desiredName); + if (!trimmed) { + return { ok: false, reason: "Username cannot be empty." }; + } + if (!isUsernameAvailable(trimmed, userId)) { + return { ok: false, reason: "That username is already taken." }; + } + db.prepare( + "UPDATE user_profiles SET internal_username = ?, updated_at = ?, username_updated_at = ? WHERE id = ?" + ).run(trimmed, Date.now(), Date.now(), userId); + return { ok: true, username: trimmed }; +} + +function listUsersWithIdentities() { + const profiles = db + .prepare( + "SELECT id, internal_username, created_at, updated_at FROM user_profiles ORDER BY internal_username" + ) + .all(); + const identitiesByUser = new Map(); + const identities = db + .prepare( + "SELECT user_id, provider, provider_user_id, display_name FROM user_identities ORDER BY provider" + ) + .all(); + for (const identity of identities) { + if (!identitiesByUser.has(identity.user_id)) { + identitiesByUser.set(identity.user_id, []); + } + identitiesByUser.get(identity.user_id).push(identity); + } + return profiles.map((profile) => ({ + ...profile, + identities: identitiesByUser.get(profile.id) || [] + })); +} + +module.exports = { + generateUniqueUsername, + getUserProfileById, + getUserIdentities, + ensureUserForIdentity, + linkIdentityToUser, + updateInternalUsername, + listUsersWithIdentities +}; diff --git a/src/services/youtube.js b/src/services/youtube.js new file mode 100644 index 0000000..5a2c876 --- /dev/null +++ b/src/services/youtube.js @@ -0,0 +1,285 @@ +const { getSetting, setSetting } = require("./settings"); +const { incrementMessages } = require("./stats"); +const { ensureUserForIdentity } = require("./users"); + +let client = null; +let pollTimer = null; + +async function startYouTubeBot({ commandRouter } = {}) { + const clientId = getSetting("youtube_client_id"); + const clientSecret = getSetting("youtube_client_secret"); + const refreshToken = getSetting("youtube_bot_refresh_token"); + if (!clientId || !clientSecret || !refreshToken) { + return null; + } + + const state = { + clientId, + clientSecret, + refreshToken, + accessToken: null, + accessTokenExpiresAt: 0, + channelId: getSetting("youtube_bot_channel_id", "") || null, + channelName: null, + channelAvatar: null, + liveChatId: null, + nextPageToken: null, + skipHistory: true, + stopped: false, + commandRouter + }; + + client = state; + + try { + await hydrateBotChannel(state); + } catch (error) { + console.error("YouTube bot failed to load channel details", error); + } + + schedulePoll(state, 1000); + return state; +} + +async function stopYouTubeBot() { + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } + if (client) { + client.stopped = true; + } + client = null; +} + +function getClient() { + return client; +} + +function schedulePoll(state, delayMs) { + if (state.stopped) { + return; + } + if (pollTimer) { + clearTimeout(pollTimer); + } + pollTimer = setTimeout(() => { + pollTimer = null; + pollLiveChat(state).catch((error) => { + console.error("YouTube chat poll failed", error); + schedulePoll(state, 10000); + }); + }, delayMs); +} + +async function pollLiveChat(state) { + if (state.stopped) { + return; + } + const liveChatId = await ensureLiveChatId(state); + if (!liveChatId) { + schedulePoll(state, 30000); + return; + } + + const response = await listLiveChatMessages(state, liveChatId, state.nextPageToken); + state.nextPageToken = response.nextPageToken || state.nextPageToken; + if (state.skipHistory) { + state.skipHistory = false; + schedulePoll(state, response.pollingIntervalMillis || 5000); + return; + } + + const items = Array.isArray(response.items) ? response.items : []; + for (const item of items) { + await handleChatItem(state, liveChatId, item); + } + + schedulePoll(state, response.pollingIntervalMillis || 5000); +} + +async function handleChatItem(state, liveChatId, item) { + const snippet = item?.snippet; + const author = item?.authorDetails; + if (!snippet || !author) { + return; + } + const messageText = snippet.displayMessage; + if (!messageText) { + return; + } + if (state.channelId && author.channelId === state.channelId) { + return; + } + + const displayName = author.displayName || "YouTube User"; + const avatar = author.profileImageUrl || null; + const profile = ensureUserForIdentity({ + provider: "youtube", + providerUserId: author.channelId, + displayName, + avatar + }); + incrementMessages(profile.id); + + if (!state.commandRouter) { + return; + } + + await state.commandRouter.handleMessage({ + platform: "youtube", + raw: messageText, + user: profile, + platformUser: { + id: author.channelId, + displayName, + username: displayName + }, + meta: { + liveChatId, + messageId: item.id, + snippet, + author + }, + reply: async (content) => { + try { + await sendChatMessage(state, liveChatId, content); + } catch (error) { + console.error("YouTube command reply failed", error); + } + } + }); +} + +async function ensureLiveChatId(state) { + const liveChatId = state.liveChatId || (await findActiveLiveChatId(state)); + if (liveChatId && liveChatId !== state.liveChatId) { + state.liveChatId = liveChatId; + state.nextPageToken = null; + state.skipHistory = true; + } + return state.liveChatId; +} + +async function hydrateBotChannel(state) { + const channel = await fetchMyChannel(state); + if (!channel) { + return; + } + state.channelId = channel.id; + state.channelName = channel.snippet?.title || null; + state.channelAvatar = channel.snippet?.thumbnails?.default?.url || null; + if (state.channelId) { + setSetting("youtube_bot_channel_id", state.channelId); + } +} + +async function ensureAccessToken(state) { + const now = Date.now(); + if (state.accessToken && now < state.accessTokenExpiresAt) { + return state.accessToken; + } + const token = await refreshAccessToken(state); + state.accessToken = token.access_token; + const expiresIn = Number(token.expires_in || 0); + state.accessTokenExpiresAt = now + Math.max(30, expiresIn - 60) * 1000; + return state.accessToken; +} + +async function refreshAccessToken(state) { + const body = new URLSearchParams({ + client_id: state.clientId, + client_secret: state.clientSecret, + refresh_token: state.refreshToken, + grant_type: "refresh_token" + }); + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body + }); + if (!response.ok) { + throw new Error(`YouTube token refresh failed: ${response.status}`); + } + return response.json(); +} + +async function fetchMyChannel(state) { + const accessToken = await ensureAccessToken(state); + const response = await fetch( + "https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true", + { headers: { Authorization: `Bearer ${accessToken}` } } + ); + if (!response.ok) { + throw new Error(`YouTube channel fetch failed: ${response.status}`); + } + const data = await response.json(); + return data.items?.[0] || null; +} + +async function findActiveLiveChatId(state) { + const accessToken = await ensureAccessToken(state); + const response = await fetch( + "https://www.googleapis.com/youtube/v3/liveBroadcasts?part=snippet,status&broadcastStatus=active&broadcastType=all", + { headers: { Authorization: `Bearer ${accessToken}` } } + ); + if (!response.ok) { + return null; + } + const data = await response.json(); + const broadcast = data.items?.[0]; + return broadcast?.snippet?.liveChatId || null; +} + +async function listLiveChatMessages(state, liveChatId, pageToken) { + const accessToken = await ensureAccessToken(state); + const params = new URLSearchParams({ + liveChatId, + part: "snippet,authorDetails", + maxResults: "200" + }); + if (pageToken) { + params.set("pageToken", pageToken); + } + const response = await fetch( + `https://www.googleapis.com/youtube/v3/liveChatMessages?${params.toString()}`, + { headers: { Authorization: `Bearer ${accessToken}` } } + ); + if (!response.ok) { + throw new Error(`YouTube chat fetch failed: ${response.status}`); + } + return response.json(); +} + +async function sendChatMessage(state, liveChatId, messageText) { + const accessToken = await ensureAccessToken(state); + const response = await fetch( + "https://www.googleapis.com/youtube/v3/liveChatMessages?part=snippet", + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + snippet: { + liveChatId, + type: "textMessageEvent", + textMessageDetails: { + messageText + } + } + }) + } + ); + if (!response.ok) { + throw new Error(`YouTube chat send failed: ${response.status}`); + } + return response.json(); +} + +module.exports = { + startYouTubeBot, + stopYouTubeBot, + getClient +}; diff --git a/src/web/public/app.js b/src/web/public/app.js new file mode 100644 index 0000000..c3186cf --- /dev/null +++ b/src/web/public/app.js @@ -0,0 +1,538 @@ +(() => { + const body = document.body; + const media = window.matchMedia("(max-width: 900px)"); + + document.querySelectorAll("[data-sidebar-toggle]").forEach((button) => { + button.addEventListener("click", () => { + if (media.matches) { + body.classList.toggle("sidebar-open"); + } else { + body.classList.toggle("sidebar-collapsed"); + } + }); + }); + + document.querySelectorAll(".nav-link").forEach((link) => { + link.addEventListener("click", () => { + if (body.classList.contains("sidebar-open")) { + body.classList.remove("sidebar-open"); + } + }); + }); + + const editToggles = Array.from( + document.querySelectorAll("[data-edit-toggle]") + ); + if (editToggles.length) { + const editRows = Array.from(document.querySelectorAll("[data-edit-row]")); + const updateToggleStates = () => { + editToggles.forEach((button) => { + const key = button.dataset.editToggle; + const row = editRows.find((item) => item.dataset.editRow === key); + const isOpen = row?.classList.contains("is-open"); + button.setAttribute("aria-expanded", isOpen ? "true" : "false"); + }); + }; + + editToggles.forEach((button) => { + button.addEventListener("click", () => { + const key = button.dataset.editToggle; + const target = editRows.find((item) => item.dataset.editRow === key); + const willOpen = target ? !target.classList.contains("is-open") : false; + editRows.forEach((row) => { + row.classList.remove("is-open"); + }); + if (target && willOpen) { + target.classList.add("is-open"); + } + updateToggleStates(); + }); + }); + } + + document.querySelectorAll("[data-table]").forEach((table) => { + const tbody = table.tBodies[0]; + if (!tbody) { + return; + } + let rows = Array.from(tbody.rows); + const tableId = table.getAttribute("data-table"); + const isCommandTable = tableId === "commands"; + const isPageable = + table.dataset.pageable !== undefined && table.dataset.pageable !== "false"; + const pageSizes = (table.dataset.pageSizes || "25,50,100,250") + .split(",") + .map((value) => Number(value.trim())) + .filter((value) => Number.isFinite(value) && value > 0); + const defaultSize = Number(table.dataset.pageSize) || pageSizes[0] || 25; + const sizeSelect = document.querySelector(`[data-table-size="${tableId}"]`); + const pagination = document.querySelector( + `[data-table-pagination="${tableId}"]` + ); + const prevButton = pagination?.querySelector("[data-page-prev]"); + const nextButton = pagination?.querySelector("[data-page-next]"); + const pageLabel = pagination?.querySelector("[data-page-label]"); + let currentPage = 1; + let currentPageSize = defaultSize; + + const buildCommandGroups = () => { + const groupMap = new Map(); + rows.forEach((row) => { + const key = row.dataset.commandRoot; + if (!key) { + return; + } + groupMap.set(key, { root: row, subRows: [] }); + }); + rows.forEach((row) => { + const parent = row.dataset.commandParent; + if (!parent) { + return; + } + const group = groupMap.get(parent); + if (group) { + group.subRows.push(row); + } + }); + return groupMap; + }; + + const commandGroups = isCommandTable ? buildCommandGroups() : null; + let highlightTimeout = null; + + const setGroupExpanded = (group, expanded) => { + if (!group || !group.root) { + return; + } + group.root.dataset.expanded = expanded ? "true" : "false"; + group.root.classList.toggle("is-expanded", expanded); + group.subRows.forEach((row) => { + row.classList.toggle("is-visible", expanded); + }); + const toggle = group.root.querySelector("[data-command-toggle]"); + if (toggle) { + toggle.setAttribute("aria-expanded", expanded ? "true" : "false"); + } + }; + + const clearCommandHighlights = () => { + tbody.querySelectorAll(".command-highlight").forEach((row) => { + row.classList.remove("command-highlight"); + }); + }; + + const highlightCommandRow = (row) => { + if (!row || !tbody.contains(row)) { + return; + } + clearCommandHighlights(); + row.classList.add("command-highlight"); + if (highlightTimeout) { + window.clearTimeout(highlightTimeout); + } + highlightTimeout = window.setTimeout(() => { + row.classList.remove("command-highlight"); + }, 2200); + }; + + const revealAnchorRow = () => { + if (!isCommandTable || !commandGroups) { + return; + } + const anchor = window.location.hash.slice(1); + if (!anchor) { + return; + } + const target = document.getElementById(anchor); + if (!target || !tbody.contains(target)) { + return; + } + if (target.dataset.commandParent) { + const group = commandGroups.get(target.dataset.commandParent); + if (group) { + setGroupExpanded(group, true); + } + } + highlightCommandRow(target); + }; + + if (isCommandTable && commandGroups) { + commandGroups.forEach((group) => { + setGroupExpanded(group, false); + }); + + tbody.querySelectorAll("[data-command-toggle]").forEach((button) => { + button.addEventListener("click", (event) => { + event.preventDefault(); + const rootRow = button.closest("tr"); + if (!rootRow) { + return; + } + const key = rootRow.dataset.commandRoot; + const group = key ? commandGroups.get(key) : null; + if (!group) { + return; + } + const expanded = rootRow.dataset.expanded === "true"; + setGroupExpanded(group, !expanded); + }); + }); + + revealAnchorRow(); + window.addEventListener("hashchange", () => { + revealAnchorRow(); + }); + } + + const filterInput = document.querySelector( + `[data-table-filter="${tableId}"]` + ); + const filterSelect = document.querySelector( + `[data-table-filter-select="${tableId}"]` + ); + const filterKey = filterSelect?.dataset.filterKey || "filter"; + + const refreshRows = () => { + rows = Array.from(tbody.rows); + }; + + const getFilteredRows = () => { + const term = (filterInput?.value || "").trim().toLowerCase(); + const filterValue = (filterSelect?.value || "").trim().toLowerCase(); + return rows.filter((row) => { + const haystack = (row.dataset.search || row.textContent || "") + .toLowerCase() + .trim(); + const matchesTerm = !term || haystack.includes(term); + const rowFilter = (row.dataset[filterKey] || "").toLowerCase(); + const matchesFilter = !filterValue || rowFilter === filterValue; + return matchesTerm && matchesFilter; + }); + }; + + const applyPagination = () => { + if (!isPageable || isCommandTable) { + return; + } + refreshRows(); + const filtered = getFilteredRows(); + const totalPages = Math.max( + 1, + Math.ceil(filtered.length / currentPageSize) + ); + currentPage = Math.min(currentPage, totalPages); + const start = (currentPage - 1) * currentPageSize; + const end = start + currentPageSize; + const visible = new Set(filtered.slice(start, end)); + + rows.forEach((row) => { + row.style.display = visible.has(row) ? "" : "none"; + }); + + if (pageLabel) { + pageLabel.textContent = `Page ${currentPage} of ${totalPages}`; + } + if (prevButton) { + prevButton.disabled = currentPage <= 1; + } + if (nextButton) { + nextButton.disabled = currentPage >= totalPages; + } + }; + if (filterInput) { + filterInput.addEventListener("input", () => { + const term = filterInput.value.trim().toLowerCase(); + const filterValue = (filterSelect?.value || "").trim().toLowerCase(); + if (!isCommandTable || !commandGroups) { + if (isPageable) { + currentPage = 1; + applyPagination(); + return; + } + rows.forEach((row) => { + const haystack = (row.dataset.search || row.textContent || "") + .toLowerCase() + .trim(); + const rowFilter = (row.dataset[filterKey] || "").toLowerCase(); + const matchesTerm = !term || haystack.includes(term); + const matchesFilter = !filterValue || rowFilter === filterValue; + row.style.display = matchesTerm && matchesFilter ? "" : "none"; + }); + return; + } + + commandGroups.forEach((group) => { + const root = group.root; + const rootHaystack = (root.dataset.search || root.textContent || "") + .toLowerCase() + .trim(); + const rootMatches = rootHaystack.includes(term); + const subMatches = group.subRows.filter((row) => { + const haystack = (row.dataset.search || row.textContent || "") + .toLowerCase() + .trim(); + return haystack.includes(term); + }); + + const showGroup = !term || rootMatches || subMatches.length > 0; + root.style.display = showGroup ? "" : "none"; + if (!term) { + const expanded = root.dataset.expanded === "true"; + group.subRows.forEach((row) => { + row.style.display = expanded ? "" : "none"; + }); + return; + } + if (subMatches.length > 0) { + setGroupExpanded(group, true); + } + group.subRows.forEach((row) => { + row.style.display = subMatches.includes(row) ? "" : "none"; + }); + }); + }); + } + if (filterSelect) { + filterSelect.addEventListener("change", () => { + if (isPageable) { + currentPage = 1; + applyPagination(); + } else if (filterInput) { + filterInput.dispatchEvent(new Event("input")); + } + }); + } + + const headers = table.querySelectorAll("th[data-sort]"); + headers.forEach((header) => { + header.addEventListener("click", () => { + const key = header.dataset.sort; + const currentKey = table.dataset.sortKey; + const currentDir = table.dataset.sortDir || "asc"; + const nextDir = currentKey === key && currentDir === "asc" ? "desc" : "asc"; + table.dataset.sortKey = key; + table.dataset.sortDir = nextDir; + + const compare = (a, b) => { + const aValue = (a.dataset[key] || "").toString(); + const bValue = (b.dataset[key] || "").toString(); + const aNumber = Number(aValue); + const bNumber = Number(bValue); + if (!Number.isNaN(aNumber) && !Number.isNaN(bNumber)) { + return aNumber - bNumber; + } + return aValue.localeCompare(bValue); + }; + + if (isCommandTable && commandGroups) { + const roots = Array.from(commandGroups.values()).map((group) => group.root); + const sorted = roots.slice().sort(compare); + if (nextDir === "desc") { + sorted.reverse(); + } + sorted.forEach((root) => { + const group = commandGroups.get(root.dataset.commandRoot); + tbody.appendChild(root); + group?.subRows.forEach((row) => tbody.appendChild(row)); + }); + return; + } + + const sorted = rows.slice().sort(compare); + if (nextDir === "desc") { + sorted.reverse(); + } + sorted.forEach((row) => tbody.appendChild(row)); + refreshRows(); + if (isPageable) { + currentPage = 1; + applyPagination(); + } + }); + }); + + if (isPageable && sizeSelect) { + sizeSelect.value = currentPageSize.toString(); + sizeSelect.addEventListener("change", () => { + const nextSize = Number(sizeSelect.value); + if (Number.isFinite(nextSize) && nextSize > 0) { + currentPageSize = nextSize; + currentPage = 1; + applyPagination(); + } + }); + } + + if (isPageable && prevButton && nextButton) { + prevButton.addEventListener("click", () => { + if (currentPage > 1) { + currentPage -= 1; + applyPagination(); + } + }); + nextButton.addEventListener("click", () => { + currentPage += 1; + applyPagination(); + }); + } + + if (isPageable) { + applyPagination(); + } + }); + + const logList = document.querySelector("[data-log-list]"); + if (logList) { + const entries = Array.from(logList.querySelectorAll("[data-log-entry]")); + const searchInput = document.querySelector("[data-log-search]"); + const levelSelect = document.querySelector("[data-log-level]"); + const rangeSelect = document.querySelector("[data-log-range]"); + const limitSelect = document.querySelector("[data-log-limit]"); + + const applyLogFilters = () => { + const term = (searchInput?.value || "").trim().toLowerCase(); + + entries.forEach((entry) => { + const haystack = (entry.dataset.search || entry.textContent || "") + .toLowerCase() + .trim(); + + const matchesTerm = !term || haystack.includes(term); + entry.style.display = matchesTerm ? "" : "none"; + }); + }; + + searchInput?.addEventListener("input", applyLogFilters); + + applyLogFilters(); + + const reloadLogView = () => { + const url = new URL(window.location.href); + const rangeValue = rangeSelect?.value || "all"; + const levelValue = levelSelect?.value || "all"; + const limitValue = limitSelect?.value || "50"; + url.searchParams.set("range", rangeValue); + url.searchParams.set("level", levelValue); + url.searchParams.set("limit", limitValue); + window.location.assign(url.toString()); + }; + + levelSelect?.addEventListener("change", reloadLogView); + rangeSelect?.addEventListener("change", reloadLogView); + limitSelect?.addEventListener("change", reloadLogView); + } + + const logModal = document.querySelector("[data-log-modal]"); + const logModalOpen = document.querySelector("[data-log-download]"); + if (logModal && logModalOpen) { + const closeButtons = logModal.querySelectorAll("[data-modal-close]"); + const closeModal = () => { + logModal.classList.remove("is-open"); + logModal.setAttribute("aria-hidden", "true"); + }; + const openModal = () => { + logModal.classList.add("is-open"); + logModal.setAttribute("aria-hidden", "false"); + }; + logModalOpen.addEventListener("click", openModal); + closeButtons.forEach((button) => { + button.addEventListener("click", closeModal); + }); + logModal.addEventListener("click", (event) => { + if (event.target === logModal) { + closeModal(); + } + }); + window.addEventListener("keydown", (event) => { + if (event.key === "Escape" && logModal.classList.contains("is-open")) { + closeModal(); + } + }); + } + + const compareToggle = document.querySelector("[data-compare-toggle]"); + if (compareToggle) { + const defaultLabel = compareToggle.textContent.trim(); + const altLabel = compareToggle.getAttribute("data-compare-label") || "Back"; + compareToggle.addEventListener("click", () => { + const active = document.body.classList.toggle("stats-compare-mode"); + compareToggle.textContent = active ? altLabel : defaultLabel; + }); + } + + const healthEndpoint = "/health"; + let connectionLost = false; + const checkConnection = async () => { + try { + const response = await fetch(healthEndpoint, { cache: "no-store" }); + if (response.ok) { + if (connectionLost) { + window.location.reload(); + return; + } + connectionLost = false; + } else { + connectionLost = true; + } + } catch { + connectionLost = true; + } + }; + + window.addEventListener("online", () => { + checkConnection(); + }); + + window.addEventListener("offline", () => { + connectionLost = true; + }); + + window.setInterval(checkConnection, 5000); + + document.querySelectorAll("[data-copy]").forEach((button) => { + button.addEventListener("click", async () => { + const text = button.getAttribute("data-copy") || ""; + if (!text) { + return; + } + const label = button.querySelector("[data-copy-label]"); + const originalLabel = label ? label.textContent : ""; + + const markCopied = () => { + button.classList.add("copied"); + if (label) { + label.textContent = "Copied"; + } + window.setTimeout(() => { + button.classList.remove("copied"); + if (label) { + label.textContent = originalLabel; + } + }, 1200); + }; + + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + markCopied(); + return; + } + } catch { + // Fall back to legacy copy. + } + + const tempInput = document.createElement("input"); + tempInput.value = text; + document.body.appendChild(tempInput); + tempInput.select(); + try { + document.execCommand("copy"); + markCopied(); + } catch { + // Ignore copy errors. + } finally { + tempInput.remove(); + } + }); + }); +})(); diff --git a/src/web/public/icons/nav/admin.svg b/src/web/public/icons/nav/admin.svg new file mode 100644 index 0000000..5008c3b --- /dev/null +++ b/src/web/public/icons/nav/admin.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/icons/nav/commands.svg b/src/web/public/icons/nav/commands.svg new file mode 100644 index 0000000..90f0b6b --- /dev/null +++ b/src/web/public/icons/nav/commands.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/icons/nav/home.svg b/src/web/public/icons/nav/home.svg new file mode 100644 index 0000000..b22f086 --- /dev/null +++ b/src/web/public/icons/nav/home.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/icons/nav/leaderboards.svg b/src/web/public/icons/nav/leaderboards.svg new file mode 100644 index 0000000..256077b --- /dev/null +++ b/src/web/public/icons/nav/leaderboards.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/icons/nav/logs.svg b/src/web/public/icons/nav/logs.svg new file mode 100644 index 0000000..17b3567 --- /dev/null +++ b/src/web/public/icons/nav/logs.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/icons/nav/moderation.svg b/src/web/public/icons/nav/moderation.svg new file mode 100644 index 0000000..5332c0b --- /dev/null +++ b/src/web/public/icons/nav/moderation.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/icons/nav/pages.svg b/src/web/public/icons/nav/pages.svg new file mode 100644 index 0000000..a7d9bb5 --- /dev/null +++ b/src/web/public/icons/nav/pages.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/icons/nav/plugins.svg b/src/web/public/icons/nav/plugins.svg new file mode 100644 index 0000000..a01a1c0 --- /dev/null +++ b/src/web/public/icons/nav/plugins.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/icons/nav/privileges.svg b/src/web/public/icons/nav/privileges.svg new file mode 100644 index 0000000..6c6a6c0 --- /dev/null +++ b/src/web/public/icons/nav/privileges.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/icons/nav/profile.svg b/src/web/public/icons/nav/profile.svg new file mode 100644 index 0000000..75ef761 --- /dev/null +++ b/src/web/public/icons/nav/profile.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/icons/nav/settings.svg b/src/web/public/icons/nav/settings.svg new file mode 100644 index 0000000..c80b5bd --- /dev/null +++ b/src/web/public/icons/nav/settings.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/icons/nav/stats.svg b/src/web/public/icons/nav/stats.svg new file mode 100644 index 0000000..5956324 --- /dev/null +++ b/src/web/public/icons/nav/stats.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/icons/nav/theming.svg b/src/web/public/icons/nav/theming.svg new file mode 100644 index 0000000..80f6919 --- /dev/null +++ b/src/web/public/icons/nav/theming.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/icons/nav/updates.svg b/src/web/public/icons/nav/updates.svg new file mode 100644 index 0000000..797f265 --- /dev/null +++ b/src/web/public/icons/nav/updates.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/icons/nav/users.svg b/src/web/public/icons/nav/users.svg new file mode 100644 index 0000000..1d720bd --- /dev/null +++ b/src/web/public/icons/nav/users.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/styles.css b/src/web/public/styles.css new file mode 100644 index 0000000..e7e3f8c --- /dev/null +++ b/src/web/public/styles.css @@ -0,0 +1,1663 @@ +@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&family=Source+Sans+3:wght@400;600&display=swap"); + +:root { + --ink: #121518; + --ink-soft: #2c3137; + --cream: #f7f3ef; + --sea: #0f6a78; + --sun: #f4a340; + --rose: #d66d5c; + --card: #ffffff; + --surface-2: #fbf9f6; + --surface-3: #f9f5ef; + --border: #e3ddd6; + --bg-1: #ffe5c4; + --bg-2: #f4efe8; + --bg-3: #e9f3f1; + --role-public: #ffffff; + --role-mod: #2cb678; + --role-admin: #e35678; + --shadow: 0 18px 45px rgba(16, 20, 24, 0.12); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + color: var(--ink); + font-family: "Source Sans 3", sans-serif; + background: radial-gradient( + circle at 20% 20%, + var(--bg-1) 0, + var(--bg-2) 45%, + var(--bg-3) 100% + ); + min-height: 100vh; +} + +.app-shell { + display: grid; + grid-template-columns: 260px 1fr; + min-height: 100vh; +} + +.sidebar { + background: var(--card); + border-right: 1px solid var(--border); + padding: 20px 16px; + position: sticky; + top: 0; + height: 100vh; + display: flex; + flex-direction: column; + gap: 18px; +} + +.sidebar-brand { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 12px; +} + +.brand-link { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 6px 8px; + text-decoration: none; + color: inherit; + font-family: "Space Grotesk", sans-serif; + font-weight: 700; +} + +.sidebar-toggle { + align-self: flex-start; +} + +.logo { + display: grid; + place-items: center; + width: 38px; + height: 38px; + border-radius: 12px; + background: linear-gradient(135deg, var(--sea), var(--sun)); + color: white; + font-weight: 700; + overflow: hidden; +} + +.logo-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.sidebar-nav { + display: flex; + flex-direction: column; + gap: 12px; + flex: 1; + overflow-y: auto; + padding-right: 4px; +} + +.nav-section { + border-radius: 16px; + background: var(--surface-2); + padding: 10px 12px; + border: 1px solid transparent; +} + +.nav-section[open] { + border-color: var(--border); +} + +.nav-section summary { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + font-weight: 700; + color: var(--ink); + list-style: none; +} + +.nav-section summary::-webkit-details-marker { + display: none; +} + +.nav-icon { + width: 22px; + height: 22px; + display: grid; + place-items: center; + color: var(--sea); +} + +.nav-icon svg { + width: 20px; + height: 20px; +} + +.nav-links { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 0 0 32px; +} + +.nav-link { + text-decoration: none; + color: var(--ink-soft); + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 10px; + transition: background 0.15s ease, color 0.15s ease; + min-height: 32px; +} + +.nav-link:hover { + background: var(--surface-3); + color: var(--ink); +} + +.nav-link.active { + background: color-mix(in srgb, var(--sea) 18%, transparent); + color: var(--ink); +} + +.nav-item-icon { + width: 18px; + height: 18px; + display: grid; + place-items: center; + flex-shrink: 0; +} + +.nav-item-icon img { + width: 16px; + height: 16px; + object-fit: contain; + opacity: 0.82; +} + +.nav-link.active .nav-item-icon img, +.nav-link:hover .nav-item-icon img { + opacity: 1; +} + +.nav-dot { + width: 6px; + height: 6px; + border-radius: 999px; + background: var(--sun); + flex-shrink: 0; +} + +.sidebar-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.user-chip { + font-weight: 600; + padding: 6px 10px; + background: var(--surface-2); + border-radius: 999px; + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + color: inherit; + border: 1px solid transparent; +} + +.user-chip-link:hover { + background: var(--surface-3); + border-color: var(--border); +} + +.user-chip-link:focus-visible { + outline: 2px solid color-mix(in srgb, var(--sea) 70%, transparent); + outline-offset: 2px; +} + +.user-avatar { + width: 30px; + height: 30px; + border-radius: 50%; + overflow: hidden; + display: grid; + place-items: center; + background: linear-gradient(135deg, var(--sea), var(--sun)); + color: white; + font-weight: 700; + font-size: 0.85rem; +} + +.user-avatar img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.icon-button { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 12px; + width: 36px; + height: 36px; + display: grid; + place-items: center; + cursor: pointer; + color: var(--ink); +} + +.icon-button svg { + width: 18px; + height: 18px; +} + +.page { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.mobile-topbar { + display: none; + align-items: center; + gap: 12px; + padding: 16px 20px; + border-bottom: 1px solid var(--border); + background: var(--card); + position: sticky; + top: 0; + z-index: 10; +} + +.mobile-title { + font-family: "Space Grotesk", sans-serif; + font-weight: 700; +} + +.content { + padding: 32px; + display: flex; + flex-direction: column; + gap: 24px; + flex: 1; +} + +.hero { + padding: 30px; + border-radius: 24px; + background: linear-gradient( + 120deg, + color-mix(in srgb, var(--sea) 18%, transparent), + color-mix(in srgb, var(--sun) 28%, transparent) + ); + box-shadow: var(--shadow); + animation: fadeInUp 0.8s ease forwards; +} + +.hero h1 { + font-family: "Space Grotesk", sans-serif; + font-size: 2.4rem; + margin-top: 0; +} + +.hero-actions { + display: flex; + gap: 12px; + margin-top: 16px; + flex-wrap: wrap; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 20px; +} + +.grid .card { + animation-delay: 0.06s; +} + +.grid .card:nth-child(2) { + animation-delay: 0.12s; +} + +.grid .card:nth-child(3) { + animation-delay: 0.18s; +} + +.grid .card:nth-child(4) { + animation-delay: 0.24s; +} + +.card { + background: var(--card); + border-radius: 18px; + padding: 24px; + border: 1px solid var(--border); + box-shadow: var(--shadow); + animation: fadeInUp 0.8s ease forwards; +} + +.card h1, +.card h2 { + font-family: "Space Grotesk", sans-serif; +} + +.button { + background: var(--sea); + color: white; + padding: 10px 18px; + border-radius: 12px; + text-decoration: none; + border: none; + cursor: pointer; + font-weight: 600; +} + +.button.subtle { + background: var(--surface-2); + color: var(--ink); +} + +.button.danger { + background: var(--rose); +} + +.platform-toggle { + position: relative; + display: inline-block; + width: 100%; + max-width: 320px; +} + +.platform-checkboxes { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.platform-check { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface-2); + font-weight: 600; +} + +.platform-check.is-disabled { + opacity: 0.6; +} + +.platform-check input { + width: 16px; + height: 16px; +} + +.platform-toggle input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.platform-track { + display: grid; + grid-template-columns: repeat(3, 1fr); + position: relative; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 999px; + padding: 4px; + gap: 4px; + overflow: hidden; +} + +.platform-track label { + text-align: center; + font-weight: 600; + padding: 6px 8px; + cursor: pointer; + z-index: 2; + color: var(--ink-soft); +} + +.platform-thumb { + position: absolute; + top: 4px; + left: 4px; + width: calc(33.333% - 8px); + height: calc(100% - 8px); + border-radius: 999px; + background: #5865f2; + transition: transform 0.2s ease, background 0.2s ease; + z-index: 1; +} + +.platform-toggle input[value="both"]:checked ~ .platform-track .platform-thumb { + transform: translateX(100%); + background: #2cb678; +} + +.platform-toggle input[value="twitch"]:checked ~ .platform-track .platform-thumb { + transform: translateX(200%); + background: #9146ff; +} + +.platform-toggle input[value="discord"]:checked ~ .platform-track label[for^="platform-discord"], +.platform-toggle input[value="both"]:checked ~ .platform-track label[for^="platform-both"], +.platform-toggle input[value="twitch"]:checked ~ .platform-track label[for^="platform-twitch"] { + color: var(--ink); +} + +.command-meta { + display: flex; + align-items: center; + gap: 8px; +} + +.badge { + text-transform: uppercase; + font-size: 0.7rem; + letter-spacing: 0.08em; + padding: 4px 8px; + border-radius: 999px; + font-weight: 700; + color: white; +} + +.badge.discord { + background: #5865f2; +} + +.badge.both { + background: #2cb678; +} + +.badge.twitch { + background: #9146ff; +} + +.badge.kick { + background: #53fc18; + color: #0b1802; +} + +.badge.youtube { + background: #ff0000; +} + +.link { + color: var(--sea); + font-weight: 600; + text-decoration: none; +} + +.table { + width: 100%; + border-collapse: collapse; +} + +.table th, +.table td { + text-align: left; + padding: 10px; + border-bottom: 1px solid var(--border); +} + +.list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px; + background: var(--surface-2); + border-radius: 12px; +} + +.table-tools { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + gap: 12px; + flex-wrap: wrap; +} + +.table-search { + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--border); + font-family: inherit; + min-width: 220px; +} + +.table-controls { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.table-page-size { + display: inline-flex; + align-items: center; + gap: 8px; + font-weight: 600; + color: var(--ink-soft); +} + +.table-page-size select { + padding: 6px 10px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text); +} + +.table-pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-top: 10px; + flex-wrap: wrap; +} + +.table-page-label { + font-weight: 600; + color: var(--muted); +} + +.table-pagination .button:disabled { + opacity: 0.45; + cursor: not-allowed; + pointer-events: none; +} + +.table th[data-sort] { + cursor: pointer; +} + +.table th[data-sort]:hover { + color: var(--sea); +} + +.commands-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.section-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.log-controls { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + justify-content: flex-end; +} + +.command-subtitle { + margin: 6px 0 0; + color: var(--ink-soft); +} + +.table-note { + margin: 12px 0; + padding: 10px 12px; + background: var(--surface-2); + border: 1px dashed var(--border); + border-radius: 12px; + color: var(--ink-soft); +} + +.table-note code { + background: var(--surface-3); + padding: 2px 6px; + border-radius: 6px; +} + +.table-wrap { + overflow-x: auto; +} + +.commands-table th { + font-family: "Space Grotesk", sans-serif; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ink-soft); +} + +.commands-table td { + vertical-align: top; +} + +.command-trigger { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.command-toggle { + width: 24px; + height: 24px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--ink); + font-weight: 700; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.command-toggle::before { + content: "+"; +} + +.command-root.is-expanded .command-toggle::before { + content: "-"; +} + +.command-toggle.spacer { + visibility: hidden; +} + +.command-subrow { + display: none; +} + +.command-subrow.is-visible { + display: table-row; +} + +.command-subrow .command-trigger { + padding-left: 16px; +} + +.command-highlight td { + animation: commandPulse 2.4s ease-out; +} + +.command-name { + font-weight: 600; +} + +.command-desc { + display: inline-block; + max-width: 320px; + color: var(--ink-soft); + font-size: 0.9rem; +} + +.copy-pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--surface-2); + cursor: pointer; + font-family: "Space Grotesk", sans-serif; + position: relative; + color: var(--ink); +} + +.copy-pill code { + font-family: "Space Grotesk", sans-serif; + font-weight: 600; + color: inherit; +} + +.copy-pill:hover { + border-color: var(--sea); + color: var(--ink); +} + +.copy-pill.copied::after { + content: "Copied"; + position: absolute; + top: -10px; + right: -6px; + background: var(--sea); + color: white; + font-size: 0.65rem; + padding: 2px 6px; + border-radius: 999px; +} + +.copy-link { + min-width: 96px; + justify-content: center; +} + +.platform-pills { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.level-pill, +.origin-pill { + display: inline-flex; + align-items: center; + padding: 4px 8px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + background: var(--surface-3); + color: var(--ink-soft); + border: 1px solid var(--border); +} + +.level-pill.level-public { + background: var(--role-public); + color: #121518; + border-color: color-mix(in srgb, var(--role-public) 60%, var(--border)); +} + +.level-pill.level-mod { + background: var(--role-mod); + color: white; + border-color: color-mix(in srgb, var(--role-mod) 70%, var(--border)); +} + +.level-pill.level-admin { + background: var(--role-admin); + color: white; + border-color: color-mix(in srgb, var(--role-admin) 70%, var(--border)); +} + +.command-count { + font-weight: 700; + text-align: right; +} + +.edit-row { + display: none; +} + +.edit-row.is-open { + display: table-row; +} + +.edit-row td { + padding: 16px; + background: var(--surface-2); + border-bottom: 1px solid var(--border); +} + +@keyframes commandPulse { + 0% { + background-color: color-mix(in srgb, var(--sea) 16%, transparent); + } + 60% { + background-color: color-mix(in srgb, var(--sea) 26%, transparent); + } + 100% { + background-color: transparent; + } +} + +.conflict-card { + margin: 12px 0; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--rose) 60%, var(--border)); + background: color-mix(in srgb, var(--rose) 12%, var(--surface-2)); +} + +.conflict-card strong { + color: var(--ink); +} + +.conflict-card ul { + margin: 8px 0 0; + padding-left: 18px; +} + +.conflict-card code { + background: var(--surface-3); + padding: 2px 6px; + border-radius: 6px; +} + +.perm-toggle { + position: relative; + display: inline-flex; + align-items: center; + width: 46px; + height: 22px; + border-radius: 999px; + background: var(--rose); + border: 1px solid var(--border); + margin-right: 8px; + flex-shrink: 0; +} + +.perm-toggle.on { + background: #2cb678; +} + +.perm-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: white; + transform: translateX(2px); + transition: transform 0.2s ease; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18); +} + +.perm-toggle.on .perm-thumb { + transform: translateX(24px); +} + +.perm-label { + font-weight: 600; + color: var(--ink-soft); +} + +.log-window { + margin-top: 16px; + border-radius: 16px; + border: 1px solid var(--border); + background: var(--surface-2); + padding: 6px; +} + +.log-entry { + border-radius: 12px; + border: 1px solid transparent; + padding: 8px 10px; + margin-bottom: 8px; + background: var(--card); +} + +.log-entry summary { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + list-style: none; + font-weight: 600; +} + +.log-entry summary::-webkit-details-marker { + display: none; +} + +.log-marker { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--sea); + flex-shrink: 0; +} + +.log-entry.level-error .log-marker { + background: var(--rose); +} + +.log-entry.level-warn .log-marker { + background: var(--sun); +} + +.log-entry.level-info .log-marker { + background: var(--sea); +} + +.log-entry.level-debug .log-marker { + background: color-mix(in srgb, var(--ink-soft) 60%, transparent); +} + +.log-message { + flex: 1; +} + +.log-level-pill { + text-transform: uppercase; + font-size: 0.7rem; + letter-spacing: 0.08em; + padding: 4px 8px; + border-radius: 999px; + background: var(--surface-3); + color: var(--ink-soft); + border: 1px solid var(--border); +} + +.log-time { + font-size: 0.85rem; + color: var(--ink-soft); + white-space: nowrap; +} + +.log-details { + margin-top: 10px; + padding: 10px 12px; + border-radius: 12px; + background: var(--surface-3); + border: 1px solid var(--border); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", + monospace; + font-size: 0.85rem; + white-space: pre-wrap; +} + +.log-details.empty { + font-family: inherit; + font-size: 0.9rem; + color: var(--ink-soft); +} + +.identity-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.identity-list li { + display: flex; + gap: 8px; + align-items: center; +} + +.form-grid { + display: grid; + gap: 16px; +} + +.form-grid h2 { + grid-column: 1 / -1; + margin-bottom: 0; +} + +.theme-grid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.form-grid .field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.form-grid .field.full { + grid-column: 1 / -1; +} + +.form-grid input, +.form-grid textarea, +.form-grid select { + padding: 10px; + border-radius: 10px; + border: 1px solid var(--border); + font-family: inherit; +} + +.platform-grid { + display: grid; + gap: 14px; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + margin-top: 10px; +} + +.platform-card { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 14px; + padding: 14px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.platform-card-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.platform-toggle-row { + display: flex; + align-items: center; + gap: 10px; + font-weight: 600; +} + +.platform-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.checkbox-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 8px; +} + +.checkbox-grid label { + display: inline-flex; + align-items: center; + gap: 8px; + font-weight: 600; +} + +.switch { + display: inline-flex; + align-items: center; + gap: 10px; + font-weight: 600; + cursor: pointer; +} + +.switch-input { + position: absolute; + opacity: 0; + width: 1px; + height: 1px; + overflow: hidden; +} + +.switch-track { + width: 46px; + height: 26px; + border-radius: 999px; + background: rgba(255, 112, 112, 0.18); + border: 1px solid rgba(255, 112, 112, 0.5); + position: relative; + transition: background 0.2s ease, border-color 0.2s ease; +} + +.switch-track::after { + content: ""; + width: 20px; + height: 20px; + border-radius: 50%; + background: #fff; + position: absolute; + top: 2px; + left: 2px; + transition: transform 0.2s ease; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); +} + +.switch-input:checked + .switch-track { + background: rgba(96, 211, 148, 0.25); + border-color: rgba(96, 211, 148, 0.6); +} + +.switch-input:checked + .switch-track::after { + transform: translateX(20px); +} + +.switch-input:focus-visible + .switch-track { + box-shadow: 0 0 0 3px rgba(120, 200, 255, 0.35); +} + +.switch-input:disabled + .switch-track { + opacity: 0.5; + cursor: not-allowed; +} + +.switch-text { + color: var(--text); +} + +.switch--compact .switch-text { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.form-grid input[type="color"] { + padding: 0; + width: 56px; + height: 40px; + border-radius: 12px; + border: 1px solid var(--border); + background: transparent; + cursor: pointer; +} + +.form-grid input[type="color"]::-webkit-color-swatch-wrapper { + padding: 0; +} + +.form-grid input[type="color"]::-webkit-color-swatch { + border: none; + border-radius: 10px; +} + +.form-grid input[type="color"]::-moz-color-swatch { + border: none; + border-radius: 10px; +} + +.inline-form { + display: inline-block; + margin-right: 8px; +} + +.inline-details { + display: inline-block; + margin-right: 8px; +} + +.inline-details summary { + list-style: none; + display: inline-block; +} + +.inline-details summary::-webkit-details-marker { + display: none; +} + +.flash { + padding: 12px 16px; + border-radius: 12px; + font-weight: 600; +} + +.flash.success { + background: #e0f3ee; + color: #0b5b5f; +} + +.flash.error { + background: #ffe4df; + color: #8d3a2f; +} + +.flash.info { + background: #f0efe9; + color: var(--ink); +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 16px; +} + +.stat { + background: var(--surface-3); + border-radius: 14px; + padding: 16px; +} + +.stats-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.stats-compare { + display: none; +} + +body.stats-compare-mode .stats-default { + display: none; +} + +body.stats-compare-mode .stats-compare { + display: block; +} + +.stats-compare-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.stat-label { + display: block; + color: var(--ink-soft); +} + +.stat-value { + font-size: 2rem; + font-weight: 700; + font-family: "Space Grotesk", sans-serif; +} + +.hint { + color: var(--ink-soft); + font-size: 0.95rem; +} + +.wizard-steps { + padding-left: 20px; + margin: 0 0 16px; + color: var(--ink-soft); +} + +.wizard-list { + margin: 8px 0 0; + padding-left: 18px; +} + +.site-footer { + padding: 24px 32px 40px; + color: var(--ink-soft); +} + +.toast { + position: fixed; + right: 24px; + bottom: 24px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 14px; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 4px; + box-shadow: var(--shadow); + z-index: 50; + max-width: 320px; + animation: toast-in 0.2s ease; +} + +.toast strong { + font-size: 0.9rem; +} + +.toast-error { + border-color: rgba(214, 109, 92, 0.6); + background: color-mix(in srgb, var(--rose) 12%, var(--surface-2)); +} + +.missing-block { + display: flex; + align-items: flex-start; + gap: 16px; +} + +.missing-icon { + width: 46px; + height: 46px; + border-radius: 14px; + display: grid; + place-items: center; + background: color-mix(in srgb, var(--rose) 18%, transparent); + color: var(--rose); + font-weight: 700; + font-size: 1.4rem; +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.page-content { + display: grid; + gap: 12px; +} + +.custom-page-frame { + width: 100%; + border: none; + background: transparent; + display: block; +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(10, 12, 14, 0.45); + display: none; + align-items: center; + justify-content: center; + z-index: 40; + padding: 24px; +} + +.modal-backdrop.is-open { + display: flex; +} + +.modal { + width: min(560px, 92vw); + background: var(--card); + border-radius: 18px; + border: 1px solid var(--border); + box-shadow: var(--shadow); + padding: 20px; + display: grid; + gap: 12px; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 1100px) { + .app-shell { + grid-template-columns: 220px 1fr; + } +} + +@media (max-width: 900px) { + .app-shell { + grid-template-columns: 1fr; + } + + .sidebar { + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: 260px; + transform: translateX(-100%); + transition: transform 0.2s ease; + z-index: 20; + } + + body.sidebar-open .sidebar { + transform: translateX(0); + } + + .mobile-topbar { + display: flex; + } + + .content { + padding: 20px; + } +} + +body.sidebar-collapsed .sidebar { + width: 90px; + padding: 18px 10px; +} + +body.sidebar-collapsed .app-shell { + grid-template-columns: 90px 1fr; +} + +body.sidebar-collapsed .brand-link .title, +body.sidebar-collapsed .nav-text, +body.sidebar-collapsed .nav-link-text, +body.sidebar-collapsed .user-name { + display: none; +} + +body.sidebar-collapsed .nav-links { + padding: 8px 0 0; + align-items: center; +} + +body.sidebar-collapsed .nav-link { + justify-content: center; + width: 100%; + padding: 6px; + gap: 0; +} + + + + +.button.disabled { + opacity: 0.6; + cursor: not-allowed; + pointer-events: none; +} + +.login-actions { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; +} + +body.sidebar-collapsed .sidebar-footer { + flex-direction: column; + align-items: center; + gap: 8px; +} + +body.sidebar-collapsed .user-chip { + padding: 6px; + border-radius: 12px; + justify-content: center; + width: 100%; +} + +body.sidebar-collapsed .sidebar-footer form { + display: flex; + justify-content: center; + width: 100%; +} + +body.sidebar-collapsed .icon-button { + width: 34px; + height: 34px; +} + +body.sidebar-collapsed .nav-section summary { + justify-content: center; +} + +body.sidebar-collapsed .nav-section { + padding: 8px; +} + +body.sidebar-collapsed .nav-dot { + width: 8px; + height: 8px; +} + +body.sidebar-collapsed .nav-item-icon { + width: 20px; + height: 20px; +} + +body.sidebar-collapsed .nav-item-icon img { + width: 18px; + height: 18px; +} + + + +.nav-link-text { + display: inline-block; + max-width: 160px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.nav-icon-grid { + display: grid; + gap: 12px; +} + +.nav-icon-row { + display: grid; + gap: 12px; + grid-template-columns: 1fr auto; + padding: 12px; + border-radius: 14px; + border: 1px solid var(--border); + background: var(--surface-2); +} + +.nav-icon-info { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.nav-icon-preview { + width: 32px; + height: 32px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--surface-3); + padding: 4px; + object-fit: contain; +} + +.nav-icon-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.moderation-note { + padding: 10px 12px; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--surface-3); +} + +.profile-card { + display: flex; + flex-direction: column; + gap: 24px; +} + +.profile-section { + display: flex; + flex-direction: column; + gap: 14px; +} + +.profile-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.profile-username { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border-radius: 12px; + background: var(--surface-2); +} + +.profile-username-label { + font-size: 0.85rem; + color: var(--ink-soft); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.profile-username-value { + font-weight: 700; + font-size: 1.05rem; +} + +.profile-widgets { + display: grid; + gap: 16px; +} + +.profile-widget { + background: var(--surface-2); + border-radius: 16px; + padding: 16px; + border: 1px solid var(--border); +} + +.profile-widget h3 { + margin-top: 0; + margin-bottom: 10px; + font-family: "Space Grotesk", sans-serif; +} + + + diff --git a/src/web/server.js b/src/web/server.js new file mode 100644 index 0000000..784556b --- /dev/null +++ b/src/web/server.js @@ -0,0 +1,4771 @@ +const express = require("express"); +const path = require("path"); +const crypto = require("crypto"); +const fs = require("fs"); +const { Permissions } = require("discord.js"); +let multer = null; +try { + multer = require("multer"); +} catch { + multer = null; +} +const session = require("express-session"); +const BetterSqlite3Store = require("better-sqlite3-session-store")(session); + +const { db } = require("../services/db"); +const { getSetting, setSetting, getAllSettings } = require("../services/settings"); +const { getRoleFlags, hasAccess } = require("../services/rbac"); +const { + buildDiscordAuthUrl, + exchangeDiscordCode, + fetchDiscordUser, + fetchDiscordGuildMember, + buildTwitchAuthUrl, + exchangeTwitchCode, + fetchTwitchUser, + buildYouTubeAuthUrl, + exchangeYouTubeCode, + fetchYouTubeChannel +} = require("../services/auth"); +const { getPluginProfileStats } = require("../services/plugin-stats"); +const { + getLeaderboardSections, + getTopCommandOptions +} = require("../services/top"); +const { log, listLogs } = require("../services/logger"); +const { + getPlatformStatus, + getEnabledPlatformIds, + getLoginPlatforms, + getLinkPlatforms, + getPlatformLabel, + getPlatformBadge, + isPlatformEnabled, + isPlatformConfigured, + normalizePlatformSelection, + serializePlatformSelection +} = require("../services/platforms"); +const { getClient: getTwitchClient } = require("../services/twitch"); +const { getClient: getYouTubeClient } = require("../services/youtube"); +const { + ensureUserForIdentity, + linkIdentityToUser, + getUserProfileById, + getUserIdentities, + updateInternalUsername, + listUsersWithIdentities +} = require("../services/users"); +const { + getPlugins, + syncPluginRegistry, + setPluginEnabled, + removePlugin, + installFromGit, + updatePluginFromGit, + createLocalPlugin +} = require("../services/plugins"); +const { checkForUpdates, pullUpdates, requestRestart } = require("../services/updater"); +const { + applyBotUpdate, + applyPluginUpdate, + listSnapshots +} = require("../services/update-manager"); + +function ensureSessionSecret() { + let secret = getSetting("session_secret"); + if (!secret) { + secret = crypto.randomBytes(32).toString("hex"); + setSetting("session_secret", secret); + } + return secret; +} + +function isConfigured() { + const platforms = getPlatformStatus().filter( + (platform) => platform.supported && platform.enabled + ); + if (!platforms.length) { + return false; + } + return platforms.some((platform) => platform.configured); +} + +function getPrimaryLoginPlatform() { + const platforms = getPlatformStatus().filter( + (platform) => + platform.supported && platform.enabled && platform.supportsLogin + ); + if (!platforms.length) { + return null; + } + return platforms.find((platform) => platform.configured) || platforms[0]; +} + +function getLoginRedirectPath() { + const platform = getPrimaryLoginPlatform(); + return platform?.loginPath || "/setup"; +} + +function requireConfigured(req, res, next) { + if (!isConfigured() && !req.path.startsWith("/setup")) { + return res.redirect("/setup"); + } + next(); +} + +function requireAuth(req, res, next) { + if (!req.session.user) { + return res.redirect(getLoginRedirectPath()); + } + next(); +} + +function trackModRole(db, user) { + if (!user?.id) { + return; + } + const isMod = Boolean(user.isAdmin || user.isMod); + const active = db + .prepare( + "SELECT id FROM mod_role_periods WHERE user_id = ? AND end_at IS NULL" + ) + .get(user.id); + if (isMod && !active) { + db.prepare( + "INSERT INTO mod_role_periods (user_id, start_at, end_at) VALUES (?, ?, NULL)" + ).run(user.id, Date.now()); + } else if (!isMod && active) { + db.prepare("UPDATE mod_role_periods SET end_at = ? WHERE id = ?").run( + Date.now(), + active.id + ); + } +} + +function formatDuration(totalMs) { + const totalSeconds = Math.max(0, Math.floor(totalMs / 1000)); + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + if (days > 0) { + return `${days}d ${hours}h`; + } + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + return `${minutes}m`; +} + +function requireRole(role) { + return (req, res, next) => { + if (!req.session.user) { + return res.redirect(getLoginRedirectPath()); + } + if (!hasAccess(req.session.user, role)) { + return res.status(403).render("error", { + title: "Access denied", + message: "You do not have access to that page." + }); + } + next(); + }; +} + +function storeDiscordUser(user) { + const avatar = user.avatar + ? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=128` + : null; + const displayName = user.global_name || user.username; + return ensureUserForIdentity({ + provider: "discord", + providerUserId: user.id, + displayName, + avatar + }); +} + +async function fetchDiscordRolesForUser(discordUserId) { + const guildId = getSetting("discord_guild_id"); + const botToken = getSetting("discord_bot_token"); + if (!guildId || !botToken || !discordUserId) { + return []; + } + try { + const response = await fetch( + `https://discord.com/api/guilds/${guildId}/members/${discordUserId}`, + { headers: { Authorization: `Bot ${botToken}` } } + ); + if (!response.ok) { + return []; + } + const member = await response.json(); + return member?.roles || []; + } catch { + return []; + } +} + +function getPreferredAvatar(userId) { + if (!userId) { + return null; + } + const identities = db + .prepare("SELECT provider, avatar FROM user_identities WHERE user_id = ?") + .all(userId); + const preferredOrder = ["discord", "twitch", "youtube"]; + for (const provider of preferredOrder) { + const match = identities.find((identity) => identity.provider === provider); + if (match?.avatar) { + return match.avatar; + } + } + const fallback = identities.find((identity) => identity.avatar); + return fallback?.avatar || null; +} + +function hasExpressionTables() { + const row = db + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'expression_user_stats'" + ) + .get(); + return Boolean(row); +} + +function getExpressionUserSummary(userId) { + if (!hasExpressionTables()) { + return null; + } + const rows = db + .prepare( + "SELECT action, given_count, received_count FROM expression_user_stats WHERE user_id = ?" + ) + .all(userId); + const totals = rows.reduce( + (acc, row) => { + acc.given += row.given_count; + acc.received += row.received_count; + return acc; + }, + { given: 0, received: 0 } + ); + return { totals }; +} + +function buildUserStatsPayload(userId) { + if (!userId) { + return { stats: null, expression: null, pluginStats: [] }; + } + const stats = db + .prepare("SELECT * FROM stats WHERE user_id = ?") + .get(userId); + return { + stats, + expression: getExpressionUserSummary(userId), + pluginStats: getPluginProfileStats(userId) + }; +} + +function buildCompareRows(leftStats, rightStats) { + const rows = []; + const pushSection = (section, leftList, rightList) => { + const leftMap = new Map((leftList || []).map((item) => [item.label, item.value])); + const rightMap = new Map((rightList || []).map((item) => [item.label, item.value])); + const labels = []; + for (const label of leftMap.keys()) { + labels.push(label); + } + for (const label of rightMap.keys()) { + if (!leftMap.has(label)) { + labels.push(label); + } + } + labels.forEach((label) => { + rows.push({ + section, + label, + left: leftMap.has(label) ? leftMap.get(label) : null, + right: rightMap.has(label) ? rightMap.get(label) : null + }); + }); + }; + + const leftCommunity = [ + { label: "Messages", value: leftStats.stats?.messages ?? 0 }, + { label: "Commands", value: leftStats.stats?.commands ?? 0 } + ]; + const rightCommunity = [ + { label: "Messages", value: rightStats.stats?.messages ?? 0 }, + { label: "Commands", value: rightStats.stats?.commands ?? 0 } + ]; + pushSection("Community Interaction", leftCommunity, rightCommunity); + + if (leftStats.expression || rightStats.expression) { + const leftExpression = leftStats.expression + ? [ + { label: "Actions given", value: leftStats.expression.totals.given }, + { label: "Actions received", value: leftStats.expression.totals.received } + ] + : []; + const rightExpression = rightStats.expression + ? [ + { label: "Actions given", value: rightStats.expression.totals.given }, + { label: "Actions received", value: rightStats.expression.totals.received } + ] + : []; + pushSection("Expression Interaction", leftExpression, rightExpression); + } + + const pluginSections = new Map(); + leftStats.pluginStats.forEach((section) => { + pluginSections.set(section.title, { + left: section.stats || [], + right: [] + }); + }); + rightStats.pluginStats.forEach((section) => { + const entry = pluginSections.get(section.title) || { left: [], right: [] }; + entry.right = section.stats || []; + pluginSections.set(section.title, entry); + }); + for (const [title, lists] of pluginSections.entries()) { + pushSection(title, lists.left, lists.right); + } + + return rows; +} + +function getExpressionLeaderboards(limit = 10) { + if (!hasExpressionTables()) { + return null; + } + const given = db + .prepare( + "SELECT user_profiles.internal_username AS username, SUM(expression_user_stats.given_count) AS total " + + "FROM expression_user_stats " + + "JOIN user_profiles ON user_profiles.id = expression_user_stats.user_id " + + "GROUP BY expression_user_stats.user_id " + + "ORDER BY total DESC LIMIT ?" + ) + .all(limit); + const received = db + .prepare( + "SELECT user_profiles.internal_username AS username, SUM(expression_user_stats.received_count) AS total " + + "FROM expression_user_stats " + + "JOIN user_profiles ON user_profiles.id = expression_user_stats.user_id " + + "GROUP BY expression_user_stats.user_id " + + "ORDER BY total DESC LIMIT ?" + ) + .all(limit); + return { given, received }; +} + +const DISCORD_PERMISSION_DETAILS = { + CREATE_INSTANT_INVITE: { + label: "Create instant invites", + description: "Create invitations to the server." + }, + KICK_MEMBERS: { + label: "Kick members", + description: "Kick members from the server." + }, + BAN_MEMBERS: { + label: "Ban members", + description: "Ban or unban members from the server." + }, + ADMINISTRATOR: { + label: "Administrator", + description: "Bypasses all permission checks and grants every permission." + }, + MANAGE_CHANNELS: { + label: "Manage channels", + description: "Create, edit, or delete channels." + }, + MANAGE_GUILD: { + label: "Manage server", + description: "Edit server settings and features." + }, + ADD_REACTIONS: { + label: "Add reactions", + description: "Add new reactions to messages." + }, + VIEW_AUDIT_LOG: { + label: "View audit log", + description: "View the server audit log." + }, + PRIORITY_SPEAKER: { + label: "Priority speaker", + description: "Use priority speaker in voice channels." + }, + STREAM: { + label: "Stream", + description: "Stream video in voice channels." + }, + VIEW_CHANNEL: { + label: "View channels", + description: "View channels and read messages." + }, + SEND_MESSAGES: { + label: "Send messages", + description: "Send messages in text channels." + }, + SEND_TTS_MESSAGES: { + label: "Send TTS messages", + description: "Send text-to-speech messages." + }, + MANAGE_MESSAGES: { + label: "Manage messages", + description: "Delete messages and manage pins." + }, + EMBED_LINKS: { + label: "Embed links", + description: "Send embedded link previews." + }, + ATTACH_FILES: { + label: "Attach files", + description: "Attach files and media." + }, + READ_MESSAGE_HISTORY: { + label: "Read message history", + description: "Read messages posted before the bot joined the channel." + }, + MENTION_EVERYONE: { + label: "Mention everyone", + description: "Use @everyone, @here, and role mentions." + }, + USE_EXTERNAL_EMOJIS: { + label: "Use external emojis", + description: "Use emojis from other servers." + }, + VIEW_GUILD_INSIGHTS: { + label: "View server insights", + description: "Access server insights." + }, + CONNECT: { + label: "Connect to voice", + description: "Join voice channels." + }, + SPEAK: { + label: "Speak in voice", + description: "Speak in voice channels." + }, + MUTE_MEMBERS: { + label: "Mute members", + description: "Server mute members in voice." + }, + DEAFEN_MEMBERS: { + label: "Deafen members", + description: "Server deafen members in voice." + }, + MOVE_MEMBERS: { + label: "Move members", + description: "Move members between voice channels." + }, + USE_VAD: { + label: "Use voice activity detection", + description: "Use voice activity detection." + }, + CHANGE_NICKNAME: { + label: "Change nickname", + description: "Change the bot's nickname." + }, + MANAGE_NICKNAMES: { + label: "Manage nicknames", + description: "Change other members' nicknames." + }, + MANAGE_ROLES: { + label: "Manage roles", + description: "Create, edit, and assign roles." + }, + MANAGE_WEBHOOKS: { + label: "Manage webhooks", + description: "Create, edit, and delete webhooks." + }, + MANAGE_EMOJIS_AND_STICKERS: { + label: "Manage emojis and stickers", + description: "Create, edit, and delete emojis or stickers." + }, + USE_APPLICATION_COMMANDS: { + label: "Use application commands", + description: "Use slash commands and context menu commands." + }, + REQUEST_TO_SPEAK: { + label: "Request to speak", + description: "Request to speak in stage channels." + }, + MANAGE_EVENTS: { + label: "Manage events", + description: "Create and manage scheduled events." + }, + MANAGE_THREADS: { + label: "Manage threads", + description: "Manage threads and thread settings." + }, + USE_PUBLIC_THREADS: { + label: "Use public threads", + description: "Use public threads (deprecated flag)." + }, + CREATE_PUBLIC_THREADS: { + label: "Create public threads", + description: "Create public threads." + }, + USE_PRIVATE_THREADS: { + label: "Use private threads", + description: "Use private threads (deprecated flag)." + }, + CREATE_PRIVATE_THREADS: { + label: "Create private threads", + description: "Create private threads." + }, + USE_EXTERNAL_STICKERS: { + label: "Use external stickers", + description: "Use stickers from other servers." + }, + SEND_MESSAGES_IN_THREADS: { + label: "Send messages in threads", + description: "Send messages in threads." + }, + START_EMBEDDED_ACTIVITIES: { + label: "Start embedded activities", + description: "Start embedded activities in voice channels." + }, + MODERATE_MEMBERS: { + label: "Moderate members", + description: "Timeout members." + }, + VIEW_CREATOR_MONETIZATION_ANALYTICS: { + label: "View monetization analytics", + description: "View creator monetization analytics." + }, + USE_SOUNDBOARD: { + label: "Use soundboard", + description: "Use the soundboard in voice." + }, + SEND_VOICE_MESSAGES: { + label: "Send voice messages", + description: "Send voice messages." + } +}; + +const TWITCH_SCOPE_DEFS = [ + { + scope: "analytics:read:extensions", + label: "Extensions analytics", + description: "Read analytics data for extensions." + }, + { + scope: "analytics:read:games", + label: "Games analytics", + description: "Read analytics data for games." + }, + { + scope: "bits:read", + label: "Bits", + description: "Read Bits leaderboard data." + }, + { + scope: "channel:bot", + label: "Channel bot", + description: "Act as a bot within a channel." + }, + { + scope: "channel:edit:commercial", + label: "Run commercials", + description: "Run commercials on a channel." + }, + { + scope: "channel:manage:ads", + label: "Manage ads", + description: "Manage ad settings for a channel." + }, + { + scope: "channel:manage:broadcast", + label: "Manage broadcast", + description: "Manage stream metadata and settings." + }, + { + scope: "channel:manage:extensions", + label: "Manage extensions", + description: "Manage channel extensions." + }, + { + scope: "channel:manage:moderators", + label: "Manage moderators", + description: "Add or remove moderators." + }, + { + scope: "channel:manage:polls", + label: "Manage polls", + description: "Create and manage polls." + }, + { + scope: "channel:manage:predictions", + label: "Manage predictions", + description: "Create and manage predictions." + }, + { + scope: "channel:manage:redemptions", + label: "Manage redemptions", + description: "Manage channel points rewards." + }, + { + scope: "channel:manage:schedule", + label: "Manage schedule", + description: "Manage channel schedule." + }, + { + scope: "channel:manage:videos", + label: "Manage videos", + description: "Manage videos for a channel." + }, + { + scope: "channel:manage:vips", + label: "Manage VIPs", + description: "Add or remove VIPs." + }, + { + scope: "channel:read:ads", + label: "Read ads", + description: "Read ad schedule data." + }, + { + scope: "channel:read:charity", + label: "Read charity", + description: "Read charity campaign data." + }, + { + scope: "channel:manage:charity", + label: "Manage charity", + description: "Manage charity campaign settings." + }, + { + scope: "channel:read:editors", + label: "Read editors", + description: "Read channel editor list." + }, + { + scope: "channel:read:goals", + label: "Read goals", + description: "Read creator goal data." + }, + { + scope: "channel:read:hype_train", + label: "Read hype train", + description: "Read hype train data." + }, + { + scope: "channel:read:polls", + label: "Read polls", + description: "Read poll data." + }, + { + scope: "channel:read:predictions", + label: "Read predictions", + description: "Read prediction data." + }, + { + scope: "channel:read:redemptions", + label: "Read redemptions", + description: "Read channel points rewards." + }, + { + scope: "channel:read:stream_key", + label: "Read stream key", + description: "Read the channel stream key." + }, + { + scope: "channel:read:subscriptions", + label: "Read subscriptions", + description: "Read subscriber data." + }, + { + scope: "channel:read:vips", + label: "Read VIPs", + description: "Read the VIP list." + }, + { + scope: "channel:moderate", + label: "Channel moderation", + description: "Perform channel moderation actions." + }, + { + scope: "chat:edit", + label: "Chat edit", + description: "Send chat messages." + }, + { + scope: "chat:read", + label: "Chat read", + description: "Read chat messages." + }, + { + scope: "clips:edit", + label: "Edit clips", + description: "Create or edit clips." + }, + { + scope: "moderation:read", + label: "Read moderation", + description: "Read moderation data." + }, + { + scope: "moderator:manage:announcements", + label: "Manage announcements", + description: "Send announcements in chat." + }, + { + scope: "moderator:manage:automod", + label: "Manage AutoMod", + description: "Manage AutoMod actions." + }, + { + scope: "moderator:manage:automod_settings", + label: "Manage AutoMod settings", + description: "Update AutoMod settings." + }, + { + scope: "moderator:manage:banned_users", + label: "Manage banned users", + description: "Ban or unban users." + }, + { + scope: "moderator:manage:blocked_terms", + label: "Manage blocked terms", + description: "Manage blocked terms." + }, + { + scope: "moderator:manage:chat_messages", + label: "Manage chat messages", + description: "Delete or manage chat messages." + }, + { + scope: "moderator:manage:chat_settings", + label: "Manage chat settings", + description: "Update chat settings." + }, + { + scope: "moderator:manage:shield_mode", + label: "Manage shield mode", + description: "Enable or disable shield mode." + }, + { + scope: "moderator:manage:shoutouts", + label: "Manage shoutouts", + description: "Send or manage shoutouts." + }, + { + scope: "moderator:read:automod_settings", + label: "Read AutoMod settings", + description: "Read AutoMod settings." + }, + { + scope: "moderator:read:blocked_terms", + label: "Read blocked terms", + description: "Read blocked terms." + }, + { + scope: "moderator:read:chat_settings", + label: "Read chat settings", + description: "Read chat settings." + }, + { + scope: "moderator:read:followers", + label: "Read followers", + description: "Read follower list." + }, + { + scope: "moderator:read:shield_mode", + label: "Read shield mode", + description: "Read shield mode status." + }, + { + scope: "moderator:read:shoutouts", + label: "Read shoutouts", + description: "Read shoutout settings." + }, + { + scope: "moderator:read:vips", + label: "Read VIPs", + description: "Read VIP list." + }, + { + scope: "user:bot", + label: "User bot", + description: "Act as a bot on behalf of a user." + }, + { + scope: "user:edit", + label: "Edit user", + description: "Edit a user's profile." + }, + { + scope: "user:edit:follows", + label: "Edit follows", + description: "Manage follows for a user." + }, + { + scope: "user:manage:blocked_users", + label: "Manage blocked users", + description: "Block or unblock users." + }, + { + scope: "user:manage:chat_color", + label: "Manage chat color", + description: "Change chat color." + }, + { + scope: "user:manage:whispers", + label: "Manage whispers", + description: "Send and manage whispers." + }, + { + scope: "user:read:blocked_users", + label: "Read blocked users", + description: "Read blocked users list." + }, + { + scope: "user:read:broadcast", + label: "Read broadcast", + description: "Read broadcast settings." + }, + { + scope: "user:read:email", + label: "Read email", + description: "Read the user's email address." + }, + { + scope: "user:read:follows", + label: "Read follows", + description: "Read the user's follows." + }, + { + scope: "user:read:subscriptions", + label: "Read subscriptions", + description: "Read user subscription data." + }, + { + scope: "user:read:chat", + label: "Read chat", + description: "Read chat messages via API." + }, + { + scope: "user:write:chat", + label: "Write chat", + description: "Send chat messages via API." + }, + { + scope: "whispers:read", + label: "Read whispers", + description: "Read whispers." + }, + { + scope: "whispers:edit", + label: "Send whispers", + description: "Send whispers." + } +]; + +function buildPrivilegeRow(id, label, description, granted) { + const safeLabel = (label || "").toString(); + const safeDescription = (description || "").toString(); + const grantedFlag = Boolean(granted); + return { + id, + label: safeLabel, + description: safeDescription, + granted: grantedFlag, + search: `${safeLabel} ${safeDescription} ${grantedFlag ? "granted" : "missing"}`.trim(), + sort: { + label: safeLabel.toLowerCase(), + description: safeDescription.toLowerCase(), + status: grantedFlag ? 1 : 0 + } + }; +} + +function parseChannelList(value) { + return (value || "") + .split(/[,\s]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +async function buildDiscordPrivileges(discordClient) { + const rows = []; + const guildId = getSetting("discord_guild_id"); + const hasClient = Boolean(discordClient && discordClient.user); + + rows.push( + buildPrivilegeRow( + "discord-client-ready", + "Client connected", + "The Discord client is logged in and ready.", + hasClient + ) + ); + rows.push( + buildPrivilegeRow( + "discord-guild-configured", + "Guild configured", + "A server ID is configured in settings.", + Boolean(guildId) + ) + ); + + let guild = null; + if (hasClient && guildId) { + guild = discordClient.guilds?.cache?.get(guildId) || null; + if (!guild && typeof discordClient.guilds?.fetch === "function") { + try { + guild = await discordClient.guilds.fetch(guildId); + } catch { + guild = null; + } + } + } + + rows.push( + buildPrivilegeRow( + "discord-guild-access", + "Bot in guild", + "The bot can access the configured server.", + Boolean(guild) + ) + ); + + let permissions = null; + if (guild && discordClient?.user?.id) { + try { + const cachedMember = guild.members?.cache?.get(discordClient.user.id) || null; + const member = cachedMember || (await guild.members.fetch(discordClient.user.id)); + permissions = member?.permissions || null; + } catch { + permissions = null; + } + } + + const permissionFlags = Permissions?.FLAGS || {}; + const canCheck = Boolean(permissions && typeof permissions.has === "function"); + for (const key of Object.keys(permissionFlags)) { + const details = DISCORD_PERMISSION_DETAILS[key] || { + label: toTitleCase(key), + description: "Discord permission flag." + }; + const flag = permissionFlags[key]; + const granted = canCheck && flag ? permissions.has(flag) : false; + rows.push( + buildPrivilegeRow( + `discord-perm-${key.toLowerCase()}`, + details.label, + details.description, + granted + ) + ); + } + + return { + rows, + guildName: guild?.name || null + }; +} + +async function buildTwitchPrivileges() { + const clientId = getSetting("twitch_client_id"); + const clientSecret = getSetting("twitch_client_secret"); + const redirectUri = getSetting("twitch_redirect_uri"); + const botUsername = getSetting("twitch_bot_username"); + const botOauth = getSetting("twitch_bot_oauth"); + const channels = parseChannelList(getSetting("twitch_channels")); + const twitchClient = getTwitchClient(); + const connected = Boolean(twitchClient); + const joinedChannels = + twitchClient && typeof twitchClient.getChannels === "function" + ? twitchClient.getChannels() + : []; + + const rows = [ + buildPrivilegeRow( + "twitch-client-id", + "Client ID", + "A Twitch app client ID is configured.", + Boolean(clientId) + ), + buildPrivilegeRow( + "twitch-client-secret", + "Client secret", + "A Twitch app client secret is configured.", + Boolean(clientSecret) + ), + buildPrivilegeRow( + "twitch-redirect", + "OAuth redirect URL", + "The OAuth redirect URL is configured.", + Boolean(redirectUri) + ), + buildPrivilegeRow( + "twitch-bot-username", + "Bot username", + "The chat bot username is configured.", + Boolean(botUsername) + ), + buildPrivilegeRow( + "twitch-bot-oauth", + "Bot OAuth token", + "The chat bot OAuth token is configured.", + Boolean(botOauth) + ), + buildPrivilegeRow( + "twitch-channels-configured", + "Channels configured", + "At least one channel is configured for chat.", + channels.length > 0 + ), + buildPrivilegeRow( + "twitch-chat-connected", + "Chat connected", + "The Twitch chat client is connected.", + connected + ), + buildPrivilegeRow( + "twitch-channels-joined", + "Channels joined", + "The chat client has joined its configured channels.", + joinedChannels.length > 0 + ) + ]; + + let grantedScopes = []; + if (botOauth) { + const rawToken = botOauth.startsWith("oauth:") ? botOauth.slice(6) : botOauth; + try { + const response = await fetch("https://id.twitch.tv/oauth2/validate", { + headers: { Authorization: `OAuth ${rawToken}` } + }); + if (response.ok) { + const data = await response.json(); + if (Array.isArray(data.scopes)) { + grantedScopes = data.scopes; + } + } + } catch { + grantedScopes = []; + } + } + + const grantedSet = new Set(grantedScopes.map((scope) => scope.toLowerCase())); + const knownScopeSet = new Set(); + const scopeRows = TWITCH_SCOPE_DEFS.map((def) => { + const scopeKey = def.scope.toLowerCase(); + knownScopeSet.add(scopeKey); + return buildPrivilegeRow( + `twitch-scope-${slugify(def.scope)}`, + def.label, + def.description, + grantedSet.has(scopeKey) + ); + }); + + const unknownScopes = grantedScopes.filter( + (scope) => !knownScopeSet.has(scope.toLowerCase()) + ); + for (const scope of unknownScopes) { + scopeRows.push( + buildPrivilegeRow( + `twitch-scope-${slugify(scope)}`, + scope, + "Granted by the token but not recognized by this version of the bot.", + true + ) + ); + } + + return { + rows: [...rows, ...scopeRows], + channelCount: channels.length + }; +} + +function readJsonSafe(filePath) { + try { + const raw = fs.readFileSync(filePath, "utf8"); + return JSON.parse(raw); + } catch { + return null; + } +} + +function parseBooleanSetting(value, fallback) { + if (value === undefined || value === null || value === "") { + return fallback; + } + if (typeof value === "boolean") { + return value; + } + const normalized = value.toString().toLowerCase(); + return ["1", "true", "yes", "on"].includes(normalized); +} + +function getPluginSettingsMap(pluginId) { + const rows = db + .prepare("SELECT key, value FROM plugin_settings WHERE plugin_id = ?") + .all(pluginId); + return rows.reduce((acc, row) => { + acc[row.key] = row.value; + return acc; + }, {}); +} + +function normalizeCommandTrigger(value, fallback = "") { + const raw = (value || fallback || "").toString().trim().replace(/^!+/, ""); + if (!raw) { + return ""; + } + return raw.split(/\s+/)[0].toLowerCase(); +} + +function normalizeSubcommand(value) { + const raw = (value || "").toString().trim().replace(/^!+/, ""); + if (!raw) { + return ""; + } + return raw.split(/\s+/)[0].toLowerCase(); +} + +function buildUsage(baseTrigger, usage) { + const raw = (usage || baseTrigger || "").toString().trim().replace(/^!+/, ""); + return raw || baseTrigger; +} + +function normalizeCustomPlatforms(value, availablePlatforms) { + return normalizePlatformSelection(value, availablePlatforms); +} + +function parsePlatformSelectionFromBody(body, availablePlatforms) { + const raw = Array.isArray(body.platforms) + ? body.platforms.join(",") + : body.platforms || body.platform; + return normalizePlatformSelection(raw, availablePlatforms); +} + +function buildPlatformLabels(platforms) { + return platforms.map((platform) => ({ + key: getPlatformBadge(platform), + label: getPlatformLabel(platform) + })); +} + +const LOG_LEVELS = new Set(["debug", "info", "warn", "error"]); +const LOG_LIMITS = new Set([50, 100, 250, 500]); +const DEFAULT_LOG_RANGE_MS = 24 * 60 * 60 * 1000; + +function normalizeLogLevel(value) { + const normalized = (value || "").toString().trim().toLowerCase(); + return LOG_LEVELS.has(normalized) ? normalized : ""; +} + +function parseLogLevels(value) { + if (!value || value === "all") { + return []; + } + const raw = Array.isArray(value) ? value : value.toString().split(","); + return raw.map(normalizeLogLevel).filter(Boolean); +} + +function parseLogRange(value) { + if (value === undefined || value === null || value === "") { + return { rangeMs: DEFAULT_LOG_RANGE_MS, rangeValue: `${DEFAULT_LOG_RANGE_MS}` }; + } + const normalized = value.toString().trim().toLowerCase(); + if (normalized === "all") { + return { rangeMs: null, rangeValue: "all" }; + } + const parsed = Number(normalized); + if (!Number.isNaN(parsed) && parsed > 0) { + return { rangeMs: parsed, rangeValue: `${parsed}` }; + } + return { rangeMs: DEFAULT_LOG_RANGE_MS, rangeValue: `${DEFAULT_LOG_RANGE_MS}` }; +} + +function parseLogLimit(value, { allowAll = false } = {}) { + if (value === undefined || value === null || value === "") { + return { limit: 50, limitValue: "50" }; + } + const normalized = value.toString().trim().toLowerCase(); + if (allowAll && normalized === "all") { + return { limit: null, limitValue: "all" }; + } + const parsed = Number(normalized); + if (!Number.isNaN(parsed) && LOG_LIMITS.has(parsed)) { + return { limit: parsed, limitValue: `${parsed}` }; + } + return { limit: 50, limitValue: "50" }; +} + +function normalizePageFormat(value) { + const normalized = (value || "").toString().trim().toLowerCase(); + return normalized === "markdown" ? "markdown" : "html"; +} + +function escapeHtml(value) { + const map = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + }; + return (value || "").toString().replace(/[&<>"']/g, (char) => map[char]); +} + +function renderMarkdownInline(value) { + let output = escapeHtml(value); + output = output.replace(/`([^`]+)`/g, "$1"); + output = output.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => { + return `${text}`; + }); + output = output.replace(/\*\*([^*]+)\*\*/g, "$1"); + output = output.replace(/\*([^*]+)\*/g, "$1"); + return output; +} + +function renderMarkdown(value) { + const lines = (value || "").toString().replace(/\r\n?/g, "\n").split("\n"); + let html = ""; + let paragraph = []; + let listType = null; + let inCode = false; + let codeLang = ""; + let codeLines = []; + + const flushParagraph = () => { + if (!paragraph.length) { + return; + } + html += `

${renderMarkdownInline(paragraph.join(" "))}

`; + paragraph = []; + }; + + const closeList = () => { + if (!listType) { + return; + } + html += ``; + listType = null; + }; + + lines.forEach((line) => { + const trimmed = line.trim(); + if (inCode) { + if (trimmed.startsWith("```")) { + const codeBlock = escapeHtml(codeLines.join("\n")); + const langClass = codeLang ? ` class="language-${codeLang}"` : ""; + html += `
${codeBlock}
`; + inCode = false; + codeLang = ""; + codeLines = []; + return; + } + codeLines.push(line); + return; + } + + if (trimmed.startsWith("```")) { + flushParagraph(); + closeList(); + inCode = true; + codeLang = trimmed.slice(3).trim(); + return; + } + + if (!trimmed) { + flushParagraph(); + closeList(); + return; + } + + const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/); + if (headingMatch) { + flushParagraph(); + closeList(); + const level = headingMatch[1].length; + html += `${renderMarkdownInline(headingMatch[2])}`; + return; + } + + const listMatch = trimmed.match(/^([*-]|\d+\.)\s+(.*)$/); + if (listMatch) { + flushParagraph(); + const isOrdered = listMatch[1].endsWith("."); + const nextListType = isOrdered ? "ol" : "ul"; + if (listType && listType !== nextListType) { + closeList(); + } + if (!listType) { + listType = nextListType; + html += `<${listType}>`; + } + html += `
  • ${renderMarkdownInline(listMatch[2])}
  • `; + return; + } + + paragraph.push(trimmed); + }); + + if (inCode) { + const codeBlock = escapeHtml(codeLines.join("\n")); + const langClass = codeLang ? ` class="language-${codeLang}"` : ""; + html += `
    ${codeBlock}
    `; + } + + flushParagraph(); + closeList(); + return html || "

    "; +} + +function buildCustomPageSrcdoc(page, theme) { + const content = (page?.content || "").toString(); + const css = (page?.content_css || "").toString(); + const themeCss = theme + ? [ + ":root {", + ` --ink: ${theme.light.text};`, + ` --ink-soft: ${theme.light.muted};`, + ` --sea: ${theme.light.accent};`, + ` --sun: ${theme.light.accentAlt};`, + ` --rose: ${theme.light.danger};`, + ` --card: ${theme.light.surface};`, + ` --surface-2: ${theme.light.surface2};`, + ` --surface-3: ${theme.light.surface3};`, + ` --border: ${theme.light.border};`, + ` --bg-1: ${theme.light.bg1};`, + ` --bg-2: ${theme.light.bg2};`, + ` --bg-3: ${theme.light.bg3};`, + "}", + "@media (prefers-color-scheme: dark) {", + " :root {", + ` --ink: ${theme.dark.text};`, + ` --ink-soft: ${theme.dark.muted};`, + ` --sea: ${theme.dark.accent};`, + ` --sun: ${theme.dark.accentAlt};`, + ` --rose: ${theme.dark.danger};`, + ` --card: ${theme.dark.surface};`, + ` --surface-2: ${theme.dark.surface2};`, + ` --surface-3: ${theme.dark.surface3};`, + ` --border: ${theme.dark.border};`, + ` --bg-1: ${theme.dark.bg1};`, + ` --bg-2: ${theme.dark.bg2};`, + ` --bg-3: ${theme.dark.bg3};`, + " }", + "}" + ].join("\n") + : ""; + const baseCss = [ + "* { box-sizing: border-box; }", + "html, body { margin: 0; padding: 0; }", + "body {", + " font-family: \"Source Sans 3\", sans-serif;", + " color: var(--ink, #121518);", + " background: transparent;", + "}" + ].join("\n"); + const fullCss = [themeCss, baseCss, css].filter(Boolean).join("\n\n"); + + return [ + "", + "", + "", + " ", + " ", + " ", + " ", + "", + "", + content, + "", + "" + ].join("\n"); +} + +function buildCommandUsageMap() { + const rows = db.prepare("SELECT command_id, count FROM command_usage").all(); + const map = new Map(); + for (const row of rows) { + map.set(row.command_id, row.count); + } + return map; +} + +function slugify(value) { + const raw = (value || "").toString().toLowerCase(); + const slug = raw.replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, ""); + return slug || "command"; +} + +function toTitleCase(value) { + const raw = (value || "").toString().replace(/[-_]+/g, " ").trim(); + if (!raw) { + return ""; + } + return raw.replace(/\b\w/g, (match) => match.toUpperCase()); +} + +function truncateText(value, maxLength) { + const raw = (value || "").toString(); + if (raw.length <= maxLength) { + return raw; + } + return `${raw.slice(0, Math.max(0, maxLength - 3))}...`; +} + +function setFlash(req, type, message) { + req.session.flash = { type, message }; +} + +function getThemeSettings() { + return { + light: { + bg1: getSetting("theme_light_bg_1", "#ffe5c4"), + bg2: getSetting("theme_light_bg_2", "#f4efe8"), + bg3: getSetting("theme_light_bg_3", "#e9f3f1"), + text: getSetting("theme_light_text", "#121518"), + muted: getSetting("theme_light_text_muted", "#2c3137"), + accent: getSetting("theme_light_accent", "#0f6a78"), + accentAlt: getSetting("theme_light_accent_alt", "#f4a340"), + danger: getSetting("theme_light_danger", "#d66d5c"), + surface: getSetting("theme_light_surface", "#ffffff"), + surface2: getSetting("theme_light_surface_2", "#fbf9f6"), + surface3: getSetting("theme_light_surface_3", "#f9f5ef"), + border: getSetting("theme_light_border", "#e3ddd6") + }, + dark: { + bg1: getSetting("theme_dark_bg_1", "#1b1d1f"), + bg2: getSetting("theme_dark_bg_2", "#16181b"), + bg3: getSetting("theme_dark_bg_3", "#0f1113"), + text: getSetting("theme_dark_text", "#f2f0ec"), + muted: getSetting("theme_dark_text_muted", "#c5bfb7"), + accent: getSetting("theme_dark_accent", "#4fb6c2"), + accentAlt: getSetting("theme_dark_accent_alt", "#f1b765"), + danger: getSetting("theme_dark_danger", "#e08173"), + surface: getSetting("theme_dark_surface", "#232629"), + surface2: getSetting("theme_dark_surface_2", "#2b2f33"), + surface3: getSetting("theme_dark_surface_3", "#30353a"), + border: getSetting("theme_dark_border", "#34393d") + }, + role: { + public: getSetting("theme_role_public", "#ffffff"), + mod: getSetting("theme_role_mod", "#2cb678"), + admin: getSetting("theme_role_admin", "#e35678") + } + }; +} + +function getDiscordSettings() { + return { + discord_client_id: getSetting("discord_client_id", ""), + discord_client_secret: getSetting("discord_client_secret", ""), + discord_redirect_uri: getSetting("discord_redirect_uri", ""), + discord_bot_token: getSetting("discord_bot_token", ""), + discord_guild_id: getSetting("discord_guild_id", ""), + discord_admin_role_id: getSetting("discord_admin_role_id", ""), + discord_mod_role_id: getSetting("discord_mod_role_id", "") + }; +} + +function getTwitchSettings() { + return { + twitch_client_id: getSetting("twitch_client_id", ""), + twitch_client_secret: getSetting("twitch_client_secret", ""), + twitch_redirect_uri: getSetting("twitch_redirect_uri", ""), + twitch_bot_username: getSetting("twitch_bot_username", ""), + twitch_bot_oauth: getSetting("twitch_bot_oauth", ""), + twitch_channels: getSetting("twitch_channels", "") + }; +} + +function getYouTubeSettings() { + return { + youtube_client_id: getSetting("youtube_client_id", ""), + youtube_client_secret: getSetting("youtube_client_secret", ""), + youtube_redirect_uri: getSetting("youtube_redirect_uri", ""), + youtube_bot_channel_id: getSetting("youtube_bot_channel_id", "") + }; +} + +function saveSettingsMap(settings) { + for (const [key, value] of Object.entries(settings)) { + setSetting(key, value ?? ""); + } +} + +function storeSnapshot(req, key, settings) { + req.session[key] = settings; +} + +function restoreSnapshot(req, key) { + const snapshot = req.session[key]; + if (!snapshot) { + return; + } + saveSettingsMap(snapshot); + delete req.session[key]; +} + +function mergeSecrets(existing, incoming, secretKeys) { + const merged = { ...existing }; + for (const [key, value] of Object.entries(incoming)) { + const trimmed = typeof value === "string" ? value.trim() : value; + if (secretKeys.has(key) && !trimmed) { + continue; + } + merged[key] = trimmed; + } + return merged; +} + +async function verifyDiscordSettings(settings) { + const errors = []; + const checks = []; + const required = [ + "discord_client_id", + "discord_client_secret", + "discord_bot_token", + "discord_guild_id" + ]; + const missing = required.filter((field) => !settings[field]); + if (missing.length) { + errors.push("Client ID, Client Secret, Bot Token, and Guild ID are required."); + return { ok: false, errors, checks }; + } + + try { + const botResponse = await fetch("https://discord.com/api/users/@me", { + headers: { Authorization: `Bot ${settings.discord_bot_token}` } + }); + if (!botResponse.ok) { + errors.push("Bot token is invalid or missing permissions."); + } else { + const botUser = await botResponse.json(); + checks.push(`Bot token verified (${botUser.username}).`); + } + } catch { + errors.push("Unable to reach Discord to verify bot token."); + } + + try { + const guildResponse = await fetch( + `https://discord.com/api/guilds/${settings.discord_guild_id}`, + { headers: { Authorization: `Bot ${settings.discord_bot_token}` } } + ); + if (!guildResponse.ok) { + errors.push("Guild ID is invalid or the bot is not in the guild."); + } else { + const guild = await guildResponse.json(); + checks.push(`Guild verified (${guild.name}).`); + } + } catch { + errors.push("Unable to verify the Discord guild."); + } + + try { + const body = new URLSearchParams({ + client_id: settings.discord_client_id, + client_secret: settings.discord_client_secret, + grant_type: "client_credentials", + scope: "identify" + }); + const tokenResponse = await fetch("https://discord.com/api/oauth2/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body + }); + if (!tokenResponse.ok) { + errors.push("Client ID/Secret failed verification."); + } else { + checks.push("Client ID and secret verified."); + } + } catch { + errors.push("Unable to verify Discord client credentials."); + } + + return { ok: errors.length === 0, errors, checks }; +} + +async function verifyTwitchSettings(settings) { + const errors = []; + const checks = []; + + if (!settings.twitch_client_id || !settings.twitch_client_secret) { + errors.push("Twitch Client ID and Client Secret are required."); + return { ok: false, errors, checks }; + } + + let appToken = null; + try { + const tokenUrl = + "https://id.twitch.tv/oauth2/token" + + `?client_id=${encodeURIComponent(settings.twitch_client_id)}` + + `&client_secret=${encodeURIComponent(settings.twitch_client_secret)}` + + "&grant_type=client_credentials"; + const tokenResponse = await fetch(tokenUrl, { method: "POST" }); + if (!tokenResponse.ok) { + errors.push("Client ID/Secret failed verification."); + } else { + const data = await tokenResponse.json(); + appToken = data.access_token; + checks.push("Client ID and secret verified."); + } + } catch { + errors.push("Unable to verify Twitch client credentials."); + } + + if (settings.twitch_bot_username || settings.twitch_bot_oauth) { + if (!settings.twitch_bot_username || !settings.twitch_bot_oauth) { + errors.push("Bot username and OAuth token must both be filled in."); + } else { + const rawToken = settings.twitch_bot_oauth.startsWith("oauth:") + ? settings.twitch_bot_oauth.slice(6) + : settings.twitch_bot_oauth; + try { + const validateResponse = await fetch( + "https://id.twitch.tv/oauth2/validate", + { headers: { Authorization: `OAuth ${rawToken}` } } + ); + if (!validateResponse.ok) { + errors.push("Bot OAuth token failed validation."); + } else { + const data = await validateResponse.json(); + checks.push(`Bot token verified (${data.login}).`); + if ( + settings.twitch_bot_username && + data.login && + data.login.toLowerCase() !== settings.twitch_bot_username.toLowerCase() + ) { + errors.push("Bot username does not match the OAuth token."); + } + } + } catch { + errors.push("Unable to verify Twitch bot OAuth token."); + } + } + } + + if (appToken && settings.twitch_bot_username) { + try { + const userResponse = await fetch( + `https://api.twitch.tv/helix/users?login=${encodeURIComponent( + settings.twitch_bot_username + )}`, + { + headers: { + "Client-Id": settings.twitch_client_id, + Authorization: `Bearer ${appToken}` + } + } + ); + if (userResponse.ok) { + const data = await userResponse.json(); + if (data.data && data.data.length) { + checks.push(`Bot account found (${data.data[0].display_name}).`); + } else { + errors.push("Bot username was not found on Twitch."); + } + } + } catch { + errors.push("Unable to verify bot username with Twitch."); + } + } + + return { ok: errors.length === 0, errors, checks }; +} + +async function verifyYouTubeSettings(settings) { + const errors = []; + const checks = []; + + if (!settings.youtube_client_id || !settings.youtube_client_secret) { + errors.push("YouTube Client ID and Client Secret are required."); + return { ok: false, errors, checks }; + } + + const refreshToken = + settings.youtube_bot_refresh_token || getSetting("youtube_bot_refresh_token", ""); + if (!refreshToken) { + checks.push("Bot account not connected yet."); + return { ok: errors.length === 0, errors, checks }; + } + + try { + const body = new URLSearchParams({ + client_id: settings.youtube_client_id, + client_secret: settings.youtube_client_secret, + refresh_token: refreshToken, + grant_type: "refresh_token" + }); + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body + }); + if (!response.ok) { + errors.push("Bot refresh token failed verification."); + return { ok: false, errors, checks }; + } + const token = await response.json(); + checks.push("Bot refresh token verified."); + const channel = await fetchYouTubeChannel(token.access_token); + if (channel?.snippet?.title) { + checks.push(`Bot channel verified (${channel.snippet.title}).`); + } else { + checks.push("Bot channel verified."); + } + return { ok: errors.length === 0, errors, checks, channel }; + } catch { + errors.push("Unable to verify YouTube bot credentials."); + return { ok: false, errors, checks }; + } +} + +function createWebServer({ loadPlugins, discordClient }) { + const app = express(); + const assetVersion = Date.now().toString(); + const sessionStore = new BetterSqlite3Store({ + client: db + }); + + app.set("view engine", "ejs"); + app.set("views", path.join(__dirname, "views")); + + const originalAppRender = app.render.bind(app); + app.render = (view, options, callback) => { + if (typeof options === "function") { + callback = options; + options = {}; + } + const safeOptions = options || {}; + const respond = (err, html) => { + if (!err) { + if (typeof callback === "function") { + return callback(null, html); + } + return html; + } + const message = err?.message || ""; + const detail = { + view, + message, + stack: err?.stack || "" + }; + const isMissing = message.includes("Failed to lookup view"); + if (!isMissing) { + log("error", "View render failed", detail); + if (typeof callback === "function") { + return callback(err); + } + throw err; + } + log("warn", "Missing view fallback", detail); + if (view === "missing-view") { + const fallback = + "Content missing

    Content unavailable

    Some content could not be loaded.

    "; + if (typeof callback === "function") { + return callback(null, fallback); + } + return fallback; + } + return originalAppRender( + "missing-view", + { + ...safeOptions, + title: "Content missing", + resource: "A page component failed to load. Please try again.", + softError: "Some content could not be loaded." + }, + callback + ); + }; + return originalAppRender(view, safeOptions, respond); + }; + + app.use( + session({ + secret: ensureSessionSecret(), + resave: false, + saveUninitialized: false, + store: sessionStore + }) + ); + app.use(express.urlencoded({ extended: false })); + app.use(express.static(path.join(__dirname, "public"))); + + const uploadDir = path.join(__dirname, "..", "..", "data", "uploads"); + fs.mkdirSync(uploadDir, { recursive: true }); + const navIconDir = path.join(__dirname, "..", "..", "data", "nav-icons"); + fs.mkdirSync(navIconDir, { recursive: true }); + app.use("/assets/nav-icons", express.static(navIconDir)); + const upload = multer ? multer({ dest: uploadDir }) : null; + const uploadSingle = (fieldName) => { + if (!upload) { + return (req, _res, next) => { + req.uploadError = "File uploads require npm install."; + next(); + }; + } + return upload.single(fieldName); + }; + const navIconUpload = + multer && + multer({ + storage: multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, navIconDir), + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname || ".svg").slice(0, 8); + cb(null, `${crypto.randomUUID()}${ext}`); + } + }), + fileFilter: (_req, file, cb) => { + if (file.mimetype === "image/png" || file.mimetype === "image/svg+xml") { + return cb(null, true); + } + cb(new Error("Only SVG or PNG files are allowed.")); + } + }); + const navIconSingle = (req, _res, next) => { + if (!navIconUpload) { + req.uploadError = "File uploads require npm install."; + return next(); + } + return navIconUpload.single("icon_file")(req, _res, next); + }; + + const navItems = []; + const profileSections = []; + const web = { + createRouter: () => express.Router(), + mount: (mountPath, router, navItem) => { + app.use(mountPath, router); + if (navItem) { + navItems.push({ ...navItem, path: mountPath }); + } + }, + addNavItem: (navItem) => { + navItems.push(navItem); + }, + addProfileSection: (section) => { + if (!section || (!section.view && !section.content)) { + return; + } + profileSections.push(section); + } + }; + + app.use(requireConfigured); + app.use((req, res, next) => { + res.locals.siteTitle = getSetting("site_title", "Lumi Bot"); + res.locals.assetVersion = assetVersion; + res.locals.user = req.session.user || null; + res.locals.flash = req.session.flash || null; + res.locals.softError = null; + res.locals.theme = getThemeSettings(); + res.locals.botAvatar = getSetting("bot_avatar_url", null); + const platformStatus = getPlatformStatus(); + res.locals.platforms = platformStatus; + res.locals.platformLogins = platformStatus.filter( + (platform) => platform.supported && platform.enabled && platform.supportsLogin + ); + res.locals.platformLinks = platformStatus.filter( + (platform) => platform.supported && platform.enabled && platform.supportsLink + ); + const twitchPlatform = platformStatus.find((platform) => platform.id === "twitch"); + res.locals.twitchConfigured = Boolean(twitchPlatform?.configured); + res.locals.currentPath = req.path; + res.locals.userAvatar = req.session.user + ? getPreferredAvatar(req.session.user.id) + : null; + res.locals.userInitial = req.session.user?.username + ? req.session.user.username.charAt(0).toUpperCase() + : ""; + req.session.flash = null; + trackModRole(db, req.session.user); + res.locals.navSections = buildNavSections( + req.session.user, + navItems, + req.path + ); + next(); + }); + + app.use((req, res, next) => { + const originalRender = res.render.bind(res); + res.render = (view, options, callback) => { + if (typeof options === "function") { + callback = options; + options = {}; + } + const handleError = (err) => { + if (!err) { + return; + } + const message = err?.message || ""; + const isMissing = message.includes("Failed to lookup view"); + const context = { + view, + method: req.method, + path: req.path, + userId: req.session.user?.id || null, + message, + stack: err?.stack || "" + }; + if (!isMissing) { + log("error", "Render failed", context); + return originalRender( + "error", + { + title: "Something went wrong", + message: "An unexpected error occurred. Please try again." + }, + callback + ); + } + log("warn", "Missing view fallback", context); + res.locals.softError = "Some content could not be loaded."; + if (view === "missing-view") { + return res + .status(200) + .send( + "Content missing

    Content unavailable

    Some content could not be loaded.

    " + ); + } + return originalRender( + "missing-view", + { + title: "Content missing", + resource: "A page component failed to load. Please try again." + }, + callback + ); + }; + + return originalRender(view, options, (err, html) => { + if (err) { + return handleError(err); + } + if (typeof callback === "function") { + return callback(null, html); + } + res.send(html); + }); + }; + next(); + }); + + app.get("/", (req, res) => { + res.render("home", { + title: "Home" + }); + }); + + app.get("/setup", (req, res) => { + if (isConfigured()) { + return res.redirect("/"); + } + const platforms = getPlatformStatus(); + res.render("setup", { + title: "Initial setup", + platforms + }); + }); + + app.get("/setup/discord", (req, res) => { + if (!isPlatformEnabled("discord")) { + return res.redirect("/setup"); + } + if (isConfigured()) { + return res.redirect("/"); + } + const current = getDiscordSettings(); + const baseUrl = `${req.protocol}://${req.get("host")}`; + const form = { + ...current, + discord_client_secret: "", + discord_bot_token: "", + discord_redirect_uri: + current.discord_redirect_uri || `${baseUrl}/auth/discord/callback` + }; + storeSnapshot(req, "discordWizardSnapshot", current); + res.render("wizard-discord", { + title: "Discord setup", + form, + checks: [], + errors: [], + actionBase: "/setup/discord", + cancelPath: "/setup" + }); + }); + + app.post("/setup/discord/verify", async (req, res) => { + if (!isPlatformEnabled("discord")) { + return res.redirect("/setup"); + } + if (isConfigured()) { + return res.redirect("/"); + } + const current = getDiscordSettings(); + const incoming = { + discord_client_id: req.body.discord_client_id || "", + discord_client_secret: req.body.discord_client_secret || "", + discord_redirect_uri: req.body.discord_redirect_uri || "", + discord_bot_token: req.body.discord_bot_token || "", + discord_guild_id: req.body.discord_guild_id || "", + discord_admin_role_id: req.body.discord_admin_role_id || "", + discord_mod_role_id: req.body.discord_mod_role_id || "" + }; + const merged = mergeSecrets( + current, + incoming, + new Set(["discord_client_secret", "discord_bot_token"]) + ); + if (!merged.discord_redirect_uri) { + const baseUrl = `${req.protocol}://${req.get("host")}`; + merged.discord_redirect_uri = `${baseUrl}/auth/discord/callback`; + } + const result = await verifyDiscordSettings(merged); + if (!result.ok) { + res.render("wizard-discord", { + title: "Discord setup", + form: { + ...incoming, + discord_client_secret: "", + discord_bot_token: "", + discord_redirect_uri: merged.discord_redirect_uri + }, + checks: result.checks, + errors: result.errors, + actionBase: "/setup/discord", + cancelPath: "/setup" + }); + return; + } + saveSettingsMap(merged); + delete req.session.discordWizardSnapshot; + setFlash(req, "success", "Discord setup saved. Please log in."); + res.redirect("/auth/discord"); + }); + + app.post("/setup/discord/cancel", (req, res) => { + if (!isPlatformEnabled("discord")) { + return res.redirect("/setup"); + } + if (isConfigured()) { + return res.redirect("/"); + } + restoreSnapshot(req, "discordWizardSnapshot"); + setFlash(req, "info", "Discord setup canceled."); + res.redirect("/setup"); + }); + + app.get("/setup/twitch", (req, res) => { + if (!isPlatformEnabled("twitch")) { + return res.redirect("/setup"); + } + if (isConfigured()) { + return res.redirect("/"); + } + const current = getTwitchSettings(); + const baseUrl = `${req.protocol}://${req.get("host")}`; + const form = { + ...current, + twitch_client_secret: "", + twitch_bot_oauth: "", + twitch_redirect_uri: + current.twitch_redirect_uri || `${baseUrl}/auth/twitch/callback` + }; + storeSnapshot(req, "twitchWizardSnapshot", current); + res.render("wizard-twitch", { + title: "Twitch setup", + form, + checks: [], + errors: [], + actionBase: "/setup/twitch", + cancelPath: "/setup" + }); + }); + + app.post("/setup/twitch/verify", async (req, res) => { + if (!isPlatformEnabled("twitch")) { + return res.redirect("/setup"); + } + if (isConfigured()) { + return res.redirect("/"); + } + const current = getTwitchSettings(); + const incoming = { + twitch_client_id: req.body.twitch_client_id || "", + twitch_client_secret: req.body.twitch_client_secret || "", + twitch_redirect_uri: req.body.twitch_redirect_uri || "", + twitch_bot_username: req.body.twitch_bot_username || "", + twitch_bot_oauth: req.body.twitch_bot_oauth || "", + twitch_channels: req.body.twitch_channels || "" + }; + const merged = mergeSecrets( + current, + incoming, + new Set(["twitch_client_secret", "twitch_bot_oauth"]) + ); + if (!merged.twitch_redirect_uri) { + const baseUrl = `${req.protocol}://${req.get("host")}`; + merged.twitch_redirect_uri = `${baseUrl}/auth/twitch/callback`; + } + const result = await verifyTwitchSettings(merged); + if (!result.ok) { + res.render("wizard-twitch", { + title: "Twitch setup", + form: { + ...incoming, + twitch_client_secret: "", + twitch_bot_oauth: "", + twitch_redirect_uri: merged.twitch_redirect_uri + }, + checks: result.checks, + errors: result.errors, + actionBase: "/setup/twitch", + cancelPath: "/setup" + }); + return; + } + saveSettingsMap(merged); + delete req.session.twitchWizardSnapshot; + setFlash(req, "success", "Twitch setup saved."); + res.redirect("/setup"); + }); + + app.post("/setup/twitch/cancel", (req, res) => { + if (!isPlatformEnabled("twitch")) { + return res.redirect("/setup"); + } + if (isConfigured()) { + return res.redirect("/"); + } + restoreSnapshot(req, "twitchWizardSnapshot"); + setFlash(req, "info", "Twitch setup canceled."); + res.redirect("/setup"); + }); + + app.get("/setup/youtube", (req, res) => { + if (!isPlatformEnabled("youtube")) { + return res.redirect("/setup"); + } + if (isConfigured()) { + return res.redirect("/"); + } + const current = getYouTubeSettings(); + const baseUrl = `${req.protocol}://${req.get("host")}`; + const form = { + ...current, + youtube_client_secret: "", + youtube_redirect_uri: + current.youtube_redirect_uri || `${baseUrl}/auth/youtube/callback` + }; + const snapshot = { + ...current, + youtube_bot_refresh_token: getSetting("youtube_bot_refresh_token", ""), + youtube_bot_channel_id: getSetting("youtube_bot_channel_id", "") + }; + storeSnapshot(req, "youtubeWizardSnapshot", snapshot); + res.render("wizard-youtube", { + title: "YouTube setup", + form, + checks: [], + errors: [], + actionBase: "/setup/youtube", + cancelPath: "/setup", + connectPath: "/setup/youtube/connect", + botConnected: Boolean(getSetting("youtube_bot_refresh_token", "")), + botChannelId: getSetting("youtube_bot_channel_id", ""), + botChannelName: getYouTubeClient()?.channelName || null + }); + }); + + app.post("/setup/youtube/connect", (req, res) => { + if (!isPlatformEnabled("youtube")) { + return res.redirect("/setup"); + } + if (isConfigured()) { + return res.redirect("/"); + } + const baseUrl = `${req.protocol}://${req.get("host")}`; + const clientId = (req.body.youtube_client_id || "").trim(); + const clientSecret = (req.body.youtube_client_secret || "").trim(); + const redirectUri = + (req.body.youtube_redirect_uri || "").trim() || + `${baseUrl}/auth/youtube/callback`; + if (!clientId || !clientSecret) { + setFlash(req, "error", "Client ID and Client Secret are required."); + return res.redirect("/setup/youtube"); + } + setSetting("youtube_client_id", clientId); + setSetting("youtube_client_secret", clientSecret); + setSetting("youtube_redirect_uri", redirectUri); + const state = crypto.randomBytes(16).toString("hex"); + req.session.youtubeBotState = state; + req.session.youtubeBotReturnTo = "/setup/youtube"; + const url = buildYouTubeAuthUrl(state, redirectUri, { prompt: "consent" }); + res.redirect(url); + }); + + app.post("/setup/youtube/verify", async (req, res) => { + if (!isPlatformEnabled("youtube")) { + return res.redirect("/setup"); + } + if (isConfigured()) { + return res.redirect("/"); + } + const current = getYouTubeSettings(); + const incoming = { + youtube_client_id: req.body.youtube_client_id || "", + youtube_client_secret: req.body.youtube_client_secret || "", + youtube_redirect_uri: req.body.youtube_redirect_uri || "" + }; + const merged = mergeSecrets( + current, + incoming, + new Set(["youtube_client_secret"]) + ); + if (!merged.youtube_redirect_uri) { + const baseUrl = `${req.protocol}://${req.get("host")}`; + merged.youtube_redirect_uri = `${baseUrl}/auth/youtube/callback`; + } + const result = await verifyYouTubeSettings({ + ...merged, + youtube_bot_refresh_token: getSetting("youtube_bot_refresh_token", "") + }); + if (!result.ok) { + res.render("wizard-youtube", { + title: "YouTube setup", + form: { + ...incoming, + youtube_client_secret: "", + youtube_redirect_uri: merged.youtube_redirect_uri + }, + checks: result.checks, + errors: result.errors, + actionBase: "/setup/youtube", + cancelPath: "/setup", + connectPath: "/setup/youtube/connect", + botConnected: Boolean(getSetting("youtube_bot_refresh_token", "")), + botChannelId: getSetting("youtube_bot_channel_id", ""), + botChannelName: getYouTubeClient()?.channelName || null + }); + return; + } + if (result.channel?.id) { + merged.youtube_bot_channel_id = result.channel.id; + } + saveSettingsMap(merged); + delete req.session.youtubeWizardSnapshot; + setFlash(req, "success", "YouTube setup saved."); + res.redirect("/setup"); + }); + + app.post("/setup/youtube/cancel", (req, res) => { + if (!isPlatformEnabled("youtube")) { + return res.redirect("/setup"); + } + if (isConfigured()) { + return res.redirect("/"); + } + restoreSnapshot(req, "youtubeWizardSnapshot"); + setFlash(req, "info", "YouTube setup canceled."); + res.redirect("/setup"); + }); + + app.get("/auth/discord", (req, res) => { + if (!isPlatformEnabled("discord")) { + setFlash(req, "error", "Discord is disabled in Platform Integration."); + return res.redirect("/setup"); + } + if (!isPlatformConfigured("discord")) { + setFlash(req, "error", "Discord is not configured yet."); + return res.redirect("/setup"); + } + const state = crypto.randomBytes(16).toString("hex"); + if (req.session.user) { + req.session.discordLinkState = state; + } else { + req.session.discordState = state; + } + const baseUrl = `${req.protocol}://${req.get("host")}`; + const redirectUri = + getSetting("discord_redirect_uri") || + `${baseUrl}/auth/discord/callback`; + const url = buildDiscordAuthUrl(state, redirectUri); + res.redirect(url); + }); + + app.get("/auth/discord/callback", async (req, res) => { + if (!isPlatformEnabled("discord")) { + return res.status(400).render("error", { + title: "Login failed", + message: "Discord is disabled in Platform Integration." + }); + } + const { code, state } = req.query; + const isLogin = state && state === req.session.discordState; + const isLink = state && state === req.session.discordLinkState && req.session.user; + if (!code || (!isLogin && !isLink)) { + return res.status(400).render("error", { + title: "Login failed", + message: "Invalid OAuth state." + }); + } + try { + if (isLogin) { + delete req.session.discordState; + } + if (isLink) { + delete req.session.discordLinkState; + } + const baseUrl = `${req.protocol}://${req.get("host")}`; + const redirectUri = + getSetting("discord_redirect_uri") || + `${baseUrl}/auth/discord/callback`; + const token = await exchangeDiscordCode(code, redirectUri); + const user = await fetchDiscordUser(token.access_token); + const guildId = getSetting("discord_guild_id"); + const member = guildId + ? await fetchDiscordGuildMember(token.access_token, guildId) + : null; + const roles = member?.roles || []; + const flags = getRoleFlags(roles); + if (isLink) { + const linked = linkIdentityToUser({ + userId: req.session.user.id, + provider: "discord", + providerUserId: user.id, + displayName: user.global_name || user.username, + avatar: user.avatar + ? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=128` + : null + }); + req.session.user = { + id: linked.id, + username: linked.internal_username, + avatar: user.avatar, + roles, + ...flags + }; + req.session.discordToken = token; + setFlash(req, "success", "Discord account linked."); + res.redirect("/profile"); + return; + } + const profile = storeDiscordUser(user); + req.session.user = { + id: profile.id, + username: profile.internal_username, + avatar: user.avatar, + roles, + ...flags + }; + req.session.discordToken = token; + setFlash(req, "success", "Logged in."); + res.redirect("/"); + } catch (error) { + console.error(error); + res.status(500).render("error", { + title: "Login failed", + message: "Discord authentication failed." + }); + } + }); + + app.post("/auth/logout", (req, res) => { + req.session.destroy(() => { + res.redirect("/"); + }); + }); + + app.get("/auth/twitch", requireAuth, (req, res) => { + if (!isPlatformEnabled("twitch")) { + setFlash(req, "error", "Twitch is disabled in Platform Integration."); + return res.redirect("/profile"); + } + if (!isPlatformConfigured("twitch")) { + setFlash(req, "error", "Twitch is not configured yet."); + return res.redirect("/profile"); + } + const state = crypto.randomBytes(16).toString("hex"); + req.session.twitchState = state; + const baseUrl = `${req.protocol}://${req.get("host")}`; + const redirectUri = + getSetting("twitch_redirect_uri") || + `${baseUrl}/auth/twitch/callback`; + const url = buildTwitchAuthUrl(state, redirectUri); + res.redirect(url); + }); + + app.get("/auth/twitch/login", (req, res) => { + if (!isPlatformEnabled("twitch")) { + setFlash(req, "error", "Twitch is disabled in Platform Integration."); + return res.redirect("/setup"); + } + if (!isPlatformConfigured("twitch")) { + setFlash(req, "error", "Twitch is not configured yet."); + return res.redirect("/setup"); + } + const state = crypto.randomBytes(16).toString("hex"); + req.session.twitchLoginState = state; + const baseUrl = `${req.protocol}://${req.get("host")}`; + const redirectUri = + getSetting("twitch_redirect_uri") || + `${baseUrl}/auth/twitch/callback`; + const url = buildTwitchAuthUrl(state, redirectUri); + res.redirect(url); + }); + + app.get("/auth/twitch/callback", async (req, res) => { + if (!isPlatformEnabled("twitch")) { + return res.status(400).render("error", { + title: "Auth failed", + message: "Twitch is disabled in Platform Integration." + }); + } + const { code, state } = req.query; + const isLogin = state && state === req.session.twitchLoginState; + const isLink = state && state === req.session.twitchState && req.session.user; + if (!code || (!isLogin && !isLink)) { + return res.status(400).render("error", { + title: "Auth failed", + message: "Invalid Twitch OAuth state." + }); + } + try { + const baseUrl = `${req.protocol}://${req.get("host")}`; + const redirectUri = + getSetting("twitch_redirect_uri") || + `${baseUrl}/auth/twitch/callback`; + const token = await exchangeTwitchCode(code, redirectUri); + const twitchUser = await fetchTwitchUser(token.access_token); + if (!twitchUser) { + throw new Error("Twitch user not found."); + } + const now = Date.now(); + if (isLogin) { + const profile = ensureUserForIdentity({ + provider: "twitch", + providerUserId: twitchUser.id, + displayName: twitchUser.display_name, + avatar: twitchUser.profile_image_url || null + }); + db.prepare( + "INSERT INTO linked_accounts (user_id, provider, provider_user_id, display_name, access_token, refresh_token, expires_at, created_at, updated_at) " + + "VALUES (?, 'twitch', ?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT(user_id, provider) DO UPDATE SET provider_user_id = excluded.provider_user_id, display_name = excluded.display_name, access_token = excluded.access_token, refresh_token = excluded.refresh_token, expires_at = excluded.expires_at, updated_at = excluded.updated_at" + ).run( + profile.id, + twitchUser.id, + twitchUser.display_name, + token.access_token, + token.refresh_token || "", + token.expires_in ? now + token.expires_in * 1000 : null, + now, + now + ); + const discordIdentity = db + .prepare( + "SELECT provider_user_id FROM user_identities WHERE user_id = ? AND provider = 'discord'" + ) + .get(profile.id); + const roles = discordIdentity?.provider_user_id + ? await fetchDiscordRolesForUser(discordIdentity.provider_user_id) + : []; + const flags = getRoleFlags(roles); + req.session.user = { + id: profile.id, + username: profile.internal_username, + roles, + ...flags + }; + req.session.twitchToken = token; + setFlash(req, "success", "Logged in with Twitch."); + res.redirect("/"); + } else { + const profile = linkIdentityToUser({ + userId: req.session.user.id, + provider: "twitch", + providerUserId: twitchUser.id, + displayName: twitchUser.display_name, + avatar: twitchUser.profile_image_url || null + }); + db.prepare( + "INSERT INTO linked_accounts (user_id, provider, provider_user_id, display_name, access_token, refresh_token, expires_at, created_at, updated_at) " + + "VALUES (?, 'twitch', ?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT(user_id, provider) DO UPDATE SET provider_user_id = excluded.provider_user_id, display_name = excluded.display_name, access_token = excluded.access_token, refresh_token = excluded.refresh_token, expires_at = excluded.expires_at, updated_at = excluded.updated_at" + ).run( + profile.id, + twitchUser.id, + twitchUser.display_name, + token.access_token, + token.refresh_token || "", + token.expires_in ? now + token.expires_in * 1000 : null, + now, + now + ); + req.session.user.id = profile.id; + req.session.user.username = profile.internal_username; + setFlash(req, "success", "Twitch account linked."); + res.redirect("/profile"); + } + } catch (error) { + console.error(error); + res.status(500).render("error", { + title: isLogin ? "Login failed" : "Link failed", + message: isLogin + ? "Unable to sign in with Twitch." + : "Unable to link Twitch account." + }); + } + }); + + app.get("/auth/youtube", requireAuth, (req, res) => { + if (!isPlatformEnabled("youtube")) { + setFlash(req, "error", "YouTube is disabled in Platform Integration."); + return res.redirect("/profile"); + } + if (!isPlatformConfigured("youtube")) { + setFlash(req, "error", "YouTube is not configured yet."); + return res.redirect("/profile"); + } + const state = crypto.randomBytes(16).toString("hex"); + req.session.youtubeState = state; + const baseUrl = `${req.protocol}://${req.get("host")}`; + const redirectUri = + getSetting("youtube_redirect_uri") || + `${baseUrl}/auth/youtube/callback`; + const url = buildYouTubeAuthUrl(state, redirectUri); + res.redirect(url); + }); + + app.get("/auth/youtube/login", (req, res) => { + if (!isPlatformEnabled("youtube")) { + setFlash(req, "error", "YouTube is disabled in Platform Integration."); + return res.redirect("/setup"); + } + if (!isPlatformConfigured("youtube")) { + setFlash(req, "error", "YouTube is not configured yet."); + return res.redirect("/setup"); + } + const state = crypto.randomBytes(16).toString("hex"); + req.session.youtubeLoginState = state; + const baseUrl = `${req.protocol}://${req.get("host")}`; + const redirectUri = + getSetting("youtube_redirect_uri") || + `${baseUrl}/auth/youtube/callback`; + const url = buildYouTubeAuthUrl(state, redirectUri); + res.redirect(url); + }); + + app.get("/auth/youtube/callback", async (req, res) => { + if (!isPlatformEnabled("youtube")) { + return res.status(400).render("error", { + title: "Auth failed", + message: "YouTube is disabled in Platform Integration." + }); + } + const { code, state } = req.query; + const isLogin = state && state === req.session.youtubeLoginState; + const isLink = state && state === req.session.youtubeState && req.session.user; + const isBot = state && state === req.session.youtubeBotState; + if (!code || (!isLogin && !isLink && !isBot)) { + return res.status(400).render("error", { + title: "Auth failed", + message: "Invalid YouTube OAuth state." + }); + } + try { + const baseUrl = `${req.protocol}://${req.get("host")}`; + const redirectUri = + getSetting("youtube_redirect_uri") || + `${baseUrl}/auth/youtube/callback`; + const token = await exchangeYouTubeCode(code, redirectUri); + const channel = await fetchYouTubeChannel(token.access_token); + if (!channel) { + throw new Error("YouTube channel not found."); + } + const displayName = channel.snippet?.title || "YouTube Channel"; + const avatar = channel.snippet?.thumbnails?.default?.url || null; + const now = Date.now(); + + if (isBot) { + const refreshToken = + token.refresh_token || getSetting("youtube_bot_refresh_token", ""); + if (!refreshToken) { + throw new Error("Missing YouTube refresh token."); + } + setSetting("youtube_bot_refresh_token", refreshToken); + setSetting("youtube_bot_channel_id", channel.id || ""); + req.session.youtubeBotState = null; + const returnTo = req.session.youtubeBotReturnTo || "/admin/youtube-wizard"; + req.session.youtubeBotReturnTo = null; + setFlash(req, "success", "YouTube bot connected."); + return res.redirect(returnTo); + } + + if (isLogin) { + const profile = ensureUserForIdentity({ + provider: "youtube", + providerUserId: channel.id, + displayName, + avatar + }); + db.prepare( + "INSERT INTO linked_accounts (user_id, provider, provider_user_id, display_name, access_token, refresh_token, expires_at, created_at, updated_at) " + + "VALUES (?, 'youtube', ?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT(user_id, provider) DO UPDATE SET provider_user_id = excluded.provider_user_id, display_name = excluded.display_name, access_token = excluded.access_token, refresh_token = excluded.refresh_token, expires_at = excluded.expires_at, updated_at = excluded.updated_at" + ).run( + profile.id, + channel.id, + displayName, + token.access_token, + token.refresh_token || "", + token.expires_in ? now + token.expires_in * 1000 : null, + now, + now + ); + const discordIdentity = db + .prepare( + "SELECT provider_user_id FROM user_identities WHERE user_id = ? AND provider = 'discord'" + ) + .get(profile.id); + const roles = discordIdentity?.provider_user_id + ? await fetchDiscordRolesForUser(discordIdentity.provider_user_id) + : []; + const flags = getRoleFlags(roles); + req.session.user = { + id: profile.id, + username: profile.internal_username, + roles, + ...flags + }; + req.session.youtubeToken = token; + setFlash(req, "success", "Logged in with YouTube."); + return res.redirect("/"); + } + + const profile = linkIdentityToUser({ + userId: req.session.user.id, + provider: "youtube", + providerUserId: channel.id, + displayName, + avatar + }); + db.prepare( + "INSERT INTO linked_accounts (user_id, provider, provider_user_id, display_name, access_token, refresh_token, expires_at, created_at, updated_at) " + + "VALUES (?, 'youtube', ?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT(user_id, provider) DO UPDATE SET provider_user_id = excluded.provider_user_id, display_name = excluded.display_name, access_token = excluded.access_token, refresh_token = excluded.refresh_token, expires_at = excluded.expires_at, updated_at = excluded.updated_at" + ).run( + profile.id, + channel.id, + displayName, + token.access_token, + token.refresh_token || "", + token.expires_in ? now + token.expires_in * 1000 : null, + now, + now + ); + req.session.user.id = profile.id; + req.session.user.username = profile.internal_username; + setFlash(req, "success", "YouTube account linked."); + res.redirect("/profile"); + } catch (error) { + console.error(error); + res.status(500).render("error", { + title: isBot ? "Bot connect failed" : isLogin ? "Login failed" : "Link failed", + message: isBot + ? "Unable to connect the YouTube bot." + : isLogin + ? "Unable to sign in with YouTube." + : "Unable to link YouTube account." + }); + } + }); + + app.get("/profile", requireAuth, (req, res) => { + const profile = getUserProfileById(req.session.user.id); + if (!profile) { + req.session.destroy(() => { + res.redirect("/"); + }); + return; + } + const accounts = getUserIdentities(req.session.user.id); + const cooldownDays = 90; + const cooldownMs = cooldownDays * 24 * 60 * 60 * 1000; + const lastUpdate = profile.username_updated_at || 0; + const nextAllowedAt = lastUpdate ? lastUpdate + cooldownMs : 0; + const canChangeUsername = !lastUpdate || Date.now() >= nextAllowedAt; + const remainingMs = canChangeUsername ? 0 : nextAllowedAt - Date.now(); + const remainingDays = canChangeUsername + ? 0 + : Math.max(1, Math.ceil(remainingMs / (24 * 60 * 60 * 1000))); + const profileWidgets = profileSections + .filter((section) => hasAccess(req.session.user, section.role || "public")) + .slice() + .sort((a, b) => (a.order || 100) - (b.order || 100)) + .map((section) => ({ + ...section, + locals: { + ...(section.locals || {}), + user: req.session.user, + profile, + accounts + } + })); + res.render("profile", { + title: "Your profile", + accounts, + profile, + profileSections: profileWidgets, + canChangeUsername, + usernameCooldownDays: cooldownDays, + usernameCooldownRemainingDays: remainingDays, + usernameNextAllowedAt: nextAllowedAt + }); + }); + + app.post("/profile/unlink/:provider", requireAuth, (req, res) => { + const provider = req.params.provider; + if (provider === "discord") { + setFlash(req, "error", "Discord cannot be unlinked from the WebUI."); + return res.redirect("/profile"); + } + db.prepare( + "DELETE FROM linked_accounts WHERE user_id = ? AND provider = ?" + ).run(req.session.user.id, provider); + db.prepare( + "DELETE FROM user_identities WHERE user_id = ? AND provider = ?" + ).run(req.session.user.id, provider); + setFlash(req, "success", "Account unlinked."); + res.redirect("/profile"); + }); + + app.post("/profile/username", requireAuth, (req, res) => { + const desired = (req.body.internal_username || "").trim(); + const profile = getUserProfileById(req.session.user.id); + const cooldownMs = 90 * 24 * 60 * 60 * 1000; + if (profile?.username_updated_at) { + const nextAllowed = profile.username_updated_at + cooldownMs; + if (Date.now() < nextAllowed) { + setFlash( + req, + "error", + "You can change your username once every 90 days." + ); + return res.redirect("/profile"); + } + } + const result = updateInternalUsername(req.session.user.id, desired); + if (!result.ok) { + setFlash(req, "error", result.reason); + return res.redirect("/profile"); + } + req.session.user.username = result.username; + setFlash(req, "success", "Username updated."); + res.redirect("/profile"); + }); + + app.get("/health", (req, res) => { + res.set("Cache-Control", "no-store"); + res.json({ ok: true, ts: Date.now() }); + }); + + app.get("/commands", (req, res) => { + const prefix = getSetting("command_prefix", "!"); + const baseUrl = `${req.protocol}://${req.get("host")}`; + const usageMap = buildCommandUsageMap(); + const commands = []; + const conflictMap = new Map(); + const enabledPlatforms = getEnabledPlatformIds(); + + const addConflictEntries = (command) => { + const triggers = command.conflictTriggers || [command.trigger]; + for (const trigger of triggers) { + for (const platform of command.platforms) { + const subcommand = command.subcommand || ""; + const key = `${platform}:${trigger}:${subcommand}`; + const entries = conflictMap.get(key) || []; + if (!entries.some((entry) => entry.id === command.id)) { + entries.push({ + id: command.id, + name: command.name, + origin: command.origin + }); + } + conflictMap.set(key, entries); + } + } + }; + + const addCommand = (command) => { + command.count = usageMap.get(command.id) || 0; + command.anchor = `cmd-${slugify(command.id)}`; + command.link = `${baseUrl}/commands#${command.anchor}`; + const subcommand = command.subcommand || ""; + command.search = [ + command.triggerDisplay, + subcommand, + command.name, + command.description, + command.origin, + command.platforms.join(" ") + ] + .join(" ") + .toLowerCase(); + command.sort = { + trigger: subcommand ? `${command.trigger} ${subcommand}` : command.trigger, + name: command.name.toLowerCase(), + description: command.description.toLowerCase(), + level: command.level.toLowerCase(), + platform: command.platforms.join(" "), + origin: command.origin.toLowerCase(), + count: command.count + }; + if (command.levelHelp) { + command.levelHelp = command.levelHelp.toString(); + } + commands.push(command); + addConflictEntries(command); + }; + + const customCommands = db + .prepare( + "SELECT id, trigger, response, mode, language, platform FROM custom_commands WHERE enabled = 1 ORDER BY trigger" + ) + .all(); + for (const row of customCommands) { + const trigger = normalizeCommandTrigger(row.trigger); + if (!trigger) { + continue; + } + const platforms = normalizeCustomPlatforms(row.platform, enabledPlatforms); + const description = + row.mode === "advanced" + ? `Advanced command (${row.language})` + : truncateText(row.response, 140); + addCommand({ + id: `custom:${trigger}`, + trigger, + triggerDisplay: `${prefix}${trigger}`, + name: toTitleCase(trigger) || trigger, + description, + level: "public", + origin: "Custom", + platforms, + platformLabels: buildPlatformLabels(platforms), + conflictTriggers: [trigger] + }); + } + + const topOptions = getTopCommandOptions(); + if (topOptions.length) { + const topPlatforms = enabledPlatforms.slice(); + const topPlatformLabels = buildPlatformLabels(topPlatforms); + const topUsage = buildUsage("top", "top "); + addCommand({ + id: "top", + trigger: "top", + triggerDisplay: `${prefix}${topUsage}`, + name: "Top", + description: "Show leaderboard rankings.", + level: "public", + origin: "Core", + platforms: topPlatforms, + platformLabels: topPlatformLabels, + conflictTriggers: ["top"] + }); + for (const option of topOptions) { + const subcommand = normalizeSubcommand(option.id); + if (!subcommand) { + continue; + } + const usage = buildUsage("top", `top ${subcommand}`); + addCommand({ + id: `top:${subcommand}`, + trigger: "top", + subcommand, + triggerDisplay: `${prefix}${usage}`, + name: option.label || toTitleCase(subcommand) || subcommand, + description: + option.description || + `Show ${option.label || toTitleCase(subcommand) || subcommand} rankings.`, + level: "public", + origin: "Core", + platforms: topPlatforms, + platformLabels: topPlatformLabels, + conflictTriggers: ["top"] + }); + } + } + + const plugins = getPlugins().filter((plugin) => plugin.enabled); + for (const plugin of plugins) { + const cmdsPath = path.join(plugin.path, "cmds.json"); + if (!fs.existsSync(cmdsPath)) { + continue; + } + const manifest = readJsonSafe(cmdsPath); + if (!manifest || !Array.isArray(manifest.commands)) { + continue; + } + const pluginSettings = getPluginSettingsMap(plugin.id); + const platformKeys = manifest.platformKeys || {}; + const platformFlags = {}; + for (const platform of enabledPlatforms) { + platformFlags[platform] = platformKeys[platform] + ? parseBooleanSetting(pluginSettings[platformKeys[platform]], true) + : true; + } + const pluginName = manifest.pluginName || plugin.name || plugin.id; + + for (const command of manifest.commands) { + if (!command || !command.trigger) { + continue; + } + const enabled = command.enabledKey + ? parseBooleanSetting(pluginSettings[command.enabledKey], true) + : true; + if (!enabled) { + continue; + } + const override = command.triggerKey + ? pluginSettings[command.triggerKey] + : ""; + const trigger = normalizeCommandTrigger(override, command.trigger); + if (!trigger) { + continue; + } + const subcommand = normalizeSubcommand(command.subcommand); + const usage = buildUsage( + trigger, + command.usage || (subcommand ? `${trigger} ${subcommand}` : trigger) + ); + const platforms = (Array.isArray(command.platforms) && command.platforms.length + ? command.platforms + : enabledPlatforms + ).filter((platform) => platformFlags[platform] && enabledPlatforms.includes(platform)); + if (!platforms.length) { + continue; + } + const defaultTrigger = normalizeCommandTrigger(command.trigger); + const useAliases = + Array.isArray(command.aliases) && + command.aliases.length && + (!command.aliasesEnabledWhenDefault || trigger === defaultTrigger); + const aliasTriggers = useAliases + ? command.aliases + .map((alias) => normalizeCommandTrigger(alias)) + .filter(Boolean) + : []; + const description = truncateText(command.description || "", 140); + addCommand({ + id: `${plugin.id}:${command.id || trigger}`, + trigger, + subcommand, + triggerDisplay: `${prefix}${usage}`, + name: command.name || toTitleCase(trigger) || trigger, + description, + level: command.level || "public", + levelHelp: command.levelHelp || "", + origin: pluginName, + platforms, + platformLabels: buildPlatformLabels(platforms), + conflictTriggers: [trigger, ...aliasTriggers] + }); + } + } + + commands.sort((a, b) => + a.triggerDisplay.localeCompare(b.triggerDisplay) + ); + + const conflicts = []; + for (const [key, entries] of conflictMap.entries()) { + if (entries.length < 2) { + continue; + } + const [platform, trigger, subcommand] = key.split(":"); + const triggerLabel = subcommand ? `${trigger} ${subcommand}` : trigger; + const sources = entries.map((entry) => { + if (entry.origin === entry.name) { + return entry.origin; + } + return `${entry.origin} (${entry.name})`; + }); + conflicts.push({ + trigger: triggerLabel, + triggerDisplay: `${prefix}${triggerLabel}`, + platform, + platformLabel: getPlatformLabel(platform), + sourcesLabel: sources.join(", ") + }); + } + conflicts.sort((a, b) => { + if (a.trigger === b.trigger) { + return a.platform.localeCompare(b.platform); + } + return a.trigger.localeCompare(b.trigger); + }); + const subcommandGroups = new Map(); + for (const command of commands) { + if (!command.subcommand) { + continue; + } + const group = subcommandGroups.get(command.trigger) || { + root: null, + subcommands: [] + }; + group.subcommands.push(command); + subcommandGroups.set(command.trigger, group); + } + + for (const command of commands) { + if (command.subcommand) { + continue; + } + const group = subcommandGroups.get(command.trigger); + if (group && !group.root) { + group.root = command; + } + } + + const buildRootFromSubcommands = (trigger, group) => { + const platforms = Array.from( + new Set(group.subcommands.flatMap((item) => item.platforms)) + ); + const origins = Array.from( + new Set(group.subcommands.map((item) => item.origin)) + ); + const levels = Array.from( + new Set(group.subcommands.map((item) => item.level)) + ); + const platformLabels = buildPlatformLabels(platforms); + const name = toTitleCase(trigger) || trigger; + const subcommandLabels = group.subcommands + .map((item) => item.subcommand) + .filter(Boolean); + const description = subcommandLabels.length + ? `Subcommands: ${subcommandLabels.join(", ")}` + : "Subcommand group."; + const origin = origins.length === 1 ? origins[0] : "Multiple"; + const level = levels.length === 1 ? levels[0] : "mixed"; + const count = group.subcommands.reduce( + (total, item) => total + (item.count || 0), + 0 + ); + const id = `group:${trigger}`; + const triggerDisplay = `${prefix}${trigger}`; + const anchor = `cmd-${slugify(id)}`; + const link = `${baseUrl}/commands#${anchor}`; + const search = [ + triggerDisplay, + name, + description, + origin, + platforms.join(" "), + subcommandLabels.join(" ") + ] + .join(" ") + .trim(); + const sort = { + trigger, + name: name.toLowerCase(), + description: description.toLowerCase(), + level: level.toLowerCase(), + platform: platforms.join(" "), + origin: origin.toLowerCase(), + count + }; + return { + id, + groupKey: trigger, + trigger, + triggerDisplay, + name, + description, + level, + origin, + platforms, + platformLabels, + count, + anchor, + link, + search, + sort + }; + }; + + const commandGroups = []; + const processed = new Set(); + for (const command of commands) { + const group = subcommandGroups.get(command.trigger); + if (!group) { + command.groupKey = command.id; + commandGroups.push({ root: command, subcommands: [] }); + continue; + } + if (processed.has(command.trigger)) { + continue; + } + const root = group.root || buildRootFromSubcommands(command.trigger, group); + root.groupKey = root.groupKey || command.trigger; + const subcommands = group.subcommands + .slice() + .sort((a, b) => a.triggerDisplay.localeCompare(b.triggerDisplay)) + .map((item) => ({ ...item, groupKey: root.groupKey })); + if (subcommands.length) { + const extraSearch = subcommands + .map((item) => [item.subcommand, item.name].filter(Boolean).join(" ")) + .join(" "); + root.search = `${root.search} ${extraSearch}`.trim(); + } + commandGroups.push({ root, subcommands }); + processed.add(command.trigger); + } + + res.render("commands", { + title: "Commands", + commandGroups, + conflicts, + isAdmin: Boolean(req.session.user?.isAdmin) + }); + }); + + app.get("/leaderboards", (req, res) => { + res.render("leaderboards", { + title: "Leaderboards", + sections: getLeaderboardSections({ limit: 25 }) + }); + }); + + app.get("/stats", requireAuth, (req, res) => { + const payload = buildUserStatsPayload(req.session.user.id); + res.render("stats", { + title: "Your stats", + stats: payload.stats, + expression: payload.expression, + pluginStats: payload.pluginStats, + statsOwner: { + username: req.session.user.username, + isSelf: true + }, + compare: null + }); + }); + + app.get("/stats/:username", (req, res) => { + const username = (req.params.username || "").trim(); + if (!username) { + return res.status(404).render("error", { + title: "Not found", + message: "User not found." + }); + } + const profile = db + .prepare( + "SELECT id, internal_username FROM user_profiles WHERE internal_username = ? LIMIT 1" + ) + .get(username); + if (!profile) { + return res.status(404).render("error", { + title: "Not found", + message: "User not found." + }); + } + + const payload = buildUserStatsPayload(profile.id); + let compare = null; + if (req.session.user && req.session.user.id !== profile.id) { + const currentPayload = buildUserStatsPayload(req.session.user.id); + compare = { + leftLabel: req.session.user.username || "You", + rightLabel: profile.internal_username, + rows: buildCompareRows(currentPayload, payload) + }; + } + + res.render("stats", { + title: `${profile.internal_username}'s stats`, + stats: payload.stats, + expression: payload.expression, + pluginStats: payload.pluginStats, + statsOwner: { + username: profile.internal_username, + isSelf: req.session.user?.id === profile.id + }, + compare + }); + }); + + app.get("/pages/:slug", (req, res) => { + const page = db + .prepare( + "SELECT * FROM custom_pages WHERE slug = ? AND enabled = 1 LIMIT 1" + ) + .get(req.params.slug); + if (!page) { + return res.status(404).render("error", { + title: "Not found", + message: "That page does not exist." + }); + } + if (!hasAccess(req.session.user, page.role)) { + return res.status(403).render("error", { + title: "Access denied", + message: "You do not have access to that page." + }); + } + const format = normalizePageFormat(page.format); + const pageData = { + ...page, + format, + content_css: page.content_css || "" + }; + const renderedContent = + format === "markdown" ? renderMarkdown(pageData.content) : ""; + const pageSrcdoc = + format === "html" ? buildCustomPageSrcdoc(pageData, res.locals.theme) : ""; + const safePageSrcdoc = pageSrcdoc.replace(/<\/script/gi, "<\\/script"); + res.render("custom-page", { + title: page.title, + page: pageData, + renderedContent, + pageSrcdoc: safePageSrcdoc + }); + }); + + app.get("/moderator", requireRole("mod"), (req, res) => { + const now = Date.now(); + const totals = db + .prepare( + "SELECT user_id, SUM(CASE WHEN end_at IS NULL THEN ? - start_at ELSE end_at - start_at END) AS total_ms " + + "FROM mod_role_periods GROUP BY user_id" + ) + .all(now); + const totalsMap = new Map( + totals.map((row) => [row.user_id, Number(row.total_ms || 0)]) + ); + const users = listUsersWithIdentities() + .filter((user) => totalsMap.has(user.id)) + .map((user) => { + const identities = (user.identities || []).map((identity) => ({ + ...identity, + label: getPlatformLabel(identity.provider) || identity.provider + })); + const aliases = identities + .map( + (identity) => + `${identity.label}: ${identity.display_name || identity.provider_user_id}` + ) + .join(" | "); + return { + id: user.id, + username: user.internal_username, + identities, + aliasText: aliases, + totalMs: totalsMap.get(user.id) || 0 + }; + }) + .sort((a, b) => b.totalMs - a.totalMs); + + res.render("moderator", { + title: "Mods List", + mods: users, + formatDuration + }); + }); + + app.get("/admin", requireRole("admin"), (req, res) => { + res.render("admin-dashboard", { + title: "Admin dashboard" + }); + }); + + app.get("/admin/settings", requireRole("admin"), (req, res) => { + res.render("admin-settings", { + title: "Settings", + settings: getAllSettings(), + platforms: getPlatformStatus(), + navIconItems: buildNavIconItems(req.session.user, navItems, req.path) + }); + }); + + app.post("/admin/settings", requireRole("admin"), (req, res) => { + const fields = [ + "site_title", + "command_prefix", + "auto_update_enabled", + "auto_update_interval_minutes", + "git_remote", + "git_branch" + ]; + for (const field of fields) { + if (req.body[field] !== undefined) { + const value = req.body[field]; + if (field === "auto_update_enabled") { + setSetting(field, value === "on"); + } else if (field === "auto_update_interval_minutes") { + setSetting(field, Number(value)); + } else { + setSetting(field, value.trim()); + } + } + } + const platformStatus = getPlatformStatus(); + const nextPlatformValues = new Map(); + for (const platform of platformStatus) { + nextPlatformValues.set(platform.id, req.body[platform.enabledKey] === "on"); + } + const hasLoginPlatform = platformStatus.some( + (platform) => + platform.supported && + platform.supportsLogin && + nextPlatformValues.get(platform.id) + ); + if (!hasLoginPlatform) { + setFlash( + req, + "error", + "At least one login platform must remain enabled." + ); + return res.redirect("/admin/settings"); + } + + let restartNeeded = false; + for (const platform of platformStatus) { + const nextValue = nextPlatformValues.get(platform.id); + const currentValue = isPlatformEnabled(platform.id); + if (nextValue !== currentValue) { + restartNeeded = true; + } + setSetting(platform.enabledKey, nextValue); + } + setFlash( + req, + "success", + restartNeeded ? "Settings saved. Restarting..." : "Settings saved." + ); + res.redirect("/admin/settings"); + if (restartNeeded) { + requestRestart(); + } + }); + + app.post( + "/admin/settings/nav-icons", + requireRole("admin"), + navIconSingle, + (req, res) => { + if (req.uploadError) { + setFlash(req, "error", req.uploadError); + return res.redirect("/admin/settings"); + } + const itemId = (req.body.item_id || "").trim(); + if (!itemId) { + setFlash(req, "error", "Missing navigation item."); + return res.redirect("/admin/settings"); + } + if (!req.file) { + setFlash(req, "error", "Upload an SVG or PNG icon."); + return res.redirect("/admin/settings"); + } + const map = getSetting("nav_item_icons", {}) || {}; + const previous = map[itemId]; + map[itemId] = req.file.filename; + setSetting("nav_item_icons", map); + if (previous) { + try { + fs.rmSync(path.join(navIconDir, previous), { force: true }); + } catch { + // ignore cleanup errors + } + } + setFlash(req, "success", "Navigation icon updated."); + res.redirect("/admin/settings"); + } + ); + + app.post("/admin/settings/nav-icons/reset", requireRole("admin"), (req, res) => { + const itemId = (req.body.item_id || "").trim(); + if (!itemId) { + setFlash(req, "error", "Missing navigation item."); + return res.redirect("/admin/settings"); + } + const map = getSetting("nav_item_icons", {}) || {}; + const previous = map[itemId]; + delete map[itemId]; + setSetting("nav_item_icons", map); + if (previous) { + try { + fs.rmSync(path.join(navIconDir, previous), { force: true }); + } catch { + // ignore cleanup errors + } + } + setFlash(req, "success", "Navigation icon reset."); + res.redirect("/admin/settings"); + }); + + app.get("/admin/navigation", requireRole("admin"), (req, res) => { + const availableItems = collectNavItems(req.session.user, navItems, req.path); + const sortedItems = availableItems + .slice() + .sort((a, b) => { + if (a.section === b.section) { + return a.label.localeCompare(b.label); + } + return a.section.localeCompare(b.section); + }); + const defaultStructure = buildDefaultNavStructure(sortedItems); + const storedStructure = normalizeNavStructure(getSetting("nav_structure", null)); + const navStructure = storedStructure || { + ...defaultStructure, + enabled: false, + includeUnassigned: true + }; + const navSections = storedStructure?.sections?.length + ? storedStructure.sections + : defaultStructure.sections; + res.render("admin-navigation", { + title: "Navigation", + navStructure, + navSectionsJson: JSON.stringify(navSections, null, 2), + navSectionsData: JSON.stringify(navSections), + defaultSectionsJson: JSON.stringify(defaultStructure.sections, null, 2), + navItems: sortedItems, + navItemsJson: JSON.stringify(sortedItems), + sectionIcons: NAV_SECTION_ICONS + }); + }); + + app.post("/admin/navigation", requireRole("admin"), (req, res) => { + const enabled = req.body.nav_enabled === "on"; + const includeUnassigned = req.body.nav_include_unassigned === "on"; + const unassignedLabel = (req.body.nav_unassigned_label || "").trim(); + const unassignedIcon = (req.body.nav_unassigned_icon || "").trim(); + const unassignedId = (req.body.nav_unassigned_id || "").trim(); + const rawSections = (req.body.nav_sections || "").trim(); + let sections = []; + if (rawSections) { + try { + sections = JSON.parse(rawSections); + } catch { + setFlash(req, "error", "Navigation JSON is invalid."); + return res.redirect("/admin/navigation"); + } + } + if (!Array.isArray(sections)) { + setFlash(req, "error", "Navigation JSON must be an array of sections."); + return res.redirect("/admin/navigation"); + } + const navStructure = normalizeNavStructure({ + enabled, + includeUnassigned, + unassignedLabel, + unassignedIcon, + unassignedId, + sections + }); + if (!navStructure) { + setFlash(req, "error", "Navigation settings could not be saved."); + return res.redirect("/admin/navigation"); + } + setSetting("nav_structure", navStructure); + setFlash(req, "success", "Navigation updated."); + res.redirect("/admin/navigation"); + }); + + app.post("/admin/navigation/reset", requireRole("admin"), (req, res) => { + setSetting("nav_structure", null); + setFlash(req, "success", "Navigation reset to default."); + res.redirect("/admin/navigation"); + }); + + app.get("/admin/discord-wizard", requireRole("admin"), (req, res) => { + if (!isPlatformEnabled("discord")) { + setFlash(req, "error", "Discord is disabled in Platform Integration."); + return res.redirect("/admin/settings"); + } + const current = getDiscordSettings(); + const baseUrl = `${req.protocol}://${req.get("host")}`; + const form = { + ...current, + discord_client_secret: "", + discord_bot_token: "", + discord_redirect_uri: + current.discord_redirect_uri || `${baseUrl}/auth/discord/callback` + }; + storeSnapshot(req, "discordWizardSnapshot", current); + res.render("wizard-discord", { + title: "Discord setup", + form, + checks: [], + errors: [], + actionBase: "/admin/discord-wizard", + cancelPath: "/admin/settings" + }); + }); + + app.post("/admin/discord-wizard/verify", requireRole("admin"), async (req, res) => { + if (!isPlatformEnabled("discord")) { + setFlash(req, "error", "Discord is disabled in Platform Integration."); + return res.redirect("/admin/settings"); + } + const current = getDiscordSettings(); + const incoming = { + discord_client_id: req.body.discord_client_id || "", + discord_client_secret: req.body.discord_client_secret || "", + discord_redirect_uri: req.body.discord_redirect_uri || "", + discord_bot_token: req.body.discord_bot_token || "", + discord_guild_id: req.body.discord_guild_id || "", + discord_admin_role_id: req.body.discord_admin_role_id || "", + discord_mod_role_id: req.body.discord_mod_role_id || "" + }; + const merged = mergeSecrets( + current, + incoming, + new Set(["discord_client_secret", "discord_bot_token"]) + ); + if (!merged.discord_redirect_uri) { + const baseUrl = `${req.protocol}://${req.get("host")}`; + merged.discord_redirect_uri = `${baseUrl}/auth/discord/callback`; + } + const result = await verifyDiscordSettings(merged); + if (!result.ok) { + res.render("wizard-discord", { + title: "Discord setup", + form: { + ...incoming, + discord_client_secret: "", + discord_bot_token: "", + discord_redirect_uri: merged.discord_redirect_uri + }, + checks: result.checks, + errors: result.errors, + actionBase: "/admin/discord-wizard", + cancelPath: "/admin/settings" + }); + return; + } + saveSettingsMap(merged); + delete req.session.discordWizardSnapshot; + setFlash(req, "success", "Discord setup saved."); + res.redirect("/admin/settings"); + }); + + app.post("/admin/discord-wizard/cancel", requireRole("admin"), (req, res) => { + if (!isPlatformEnabled("discord")) { + return res.redirect("/admin/settings"); + } + restoreSnapshot(req, "discordWizardSnapshot"); + setFlash(req, "info", "Discord setup canceled."); + res.redirect("/admin/settings"); + }); + + app.get("/admin/twitch-wizard", requireRole("admin"), (req, res) => { + if (!isPlatformEnabled("twitch")) { + setFlash(req, "error", "Twitch is disabled in Platform Integration."); + return res.redirect("/admin/settings"); + } + const current = getTwitchSettings(); + const baseUrl = `${req.protocol}://${req.get("host")}`; + const form = { + ...current, + twitch_client_secret: "", + twitch_bot_oauth: "", + twitch_redirect_uri: + current.twitch_redirect_uri || `${baseUrl}/auth/twitch/callback` + }; + storeSnapshot(req, "twitchWizardSnapshot", current); + res.render("wizard-twitch", { + title: "Twitch setup", + form, + checks: [], + errors: [], + actionBase: "/admin/twitch-wizard", + cancelPath: "/admin/settings" + }); + }); + + app.post("/admin/twitch-wizard/verify", requireRole("admin"), async (req, res) => { + if (!isPlatformEnabled("twitch")) { + setFlash(req, "error", "Twitch is disabled in Platform Integration."); + return res.redirect("/admin/settings"); + } + const current = getTwitchSettings(); + const incoming = { + twitch_client_id: req.body.twitch_client_id || "", + twitch_client_secret: req.body.twitch_client_secret || "", + twitch_redirect_uri: req.body.twitch_redirect_uri || "", + twitch_bot_username: req.body.twitch_bot_username || "", + twitch_bot_oauth: req.body.twitch_bot_oauth || "", + twitch_channels: req.body.twitch_channels || "" + }; + const merged = mergeSecrets( + current, + incoming, + new Set(["twitch_client_secret", "twitch_bot_oauth"]) + ); + if (!merged.twitch_redirect_uri) { + const baseUrl = `${req.protocol}://${req.get("host")}`; + merged.twitch_redirect_uri = `${baseUrl}/auth/twitch/callback`; + } + const result = await verifyTwitchSettings(merged); + if (!result.ok) { + res.render("wizard-twitch", { + title: "Twitch setup", + form: { + ...incoming, + twitch_client_secret: "", + twitch_bot_oauth: "", + twitch_redirect_uri: merged.twitch_redirect_uri + }, + checks: result.checks, + errors: result.errors, + actionBase: "/admin/twitch-wizard", + cancelPath: "/admin/settings" + }); + return; + } + saveSettingsMap(merged); + delete req.session.twitchWizardSnapshot; + setFlash(req, "success", "Twitch setup saved."); + res.redirect("/admin/settings"); + }); + + app.post("/admin/twitch-wizard/cancel", requireRole("admin"), (req, res) => { + if (!isPlatformEnabled("twitch")) { + return res.redirect("/admin/settings"); + } + restoreSnapshot(req, "twitchWizardSnapshot"); + setFlash(req, "info", "Twitch setup canceled."); + res.redirect("/admin/settings"); + }); + + app.get("/admin/youtube-wizard", requireRole("admin"), (req, res) => { + if (!isPlatformEnabled("youtube")) { + setFlash(req, "error", "YouTube is disabled in Platform Integration."); + return res.redirect("/admin/settings"); + } + const current = getYouTubeSettings(); + const baseUrl = `${req.protocol}://${req.get("host")}`; + const form = { + ...current, + youtube_client_secret: "", + youtube_redirect_uri: + current.youtube_redirect_uri || `${baseUrl}/auth/youtube/callback` + }; + const snapshot = { + ...current, + youtube_bot_refresh_token: getSetting("youtube_bot_refresh_token", ""), + youtube_bot_channel_id: getSetting("youtube_bot_channel_id", "") + }; + storeSnapshot(req, "youtubeWizardSnapshot", snapshot); + res.render("wizard-youtube", { + title: "YouTube setup", + form, + checks: [], + errors: [], + actionBase: "/admin/youtube-wizard", + cancelPath: "/admin/settings", + connectPath: "/admin/youtube-wizard/connect", + botConnected: Boolean(getSetting("youtube_bot_refresh_token", "")), + botChannelId: getSetting("youtube_bot_channel_id", ""), + botChannelName: getYouTubeClient()?.channelName || null + }); + }); + + app.post("/admin/youtube-wizard/connect", requireRole("admin"), (req, res) => { + if (!isPlatformEnabled("youtube")) { + setFlash(req, "error", "YouTube is disabled in Platform Integration."); + return res.redirect("/admin/settings"); + } + const baseUrl = `${req.protocol}://${req.get("host")}`; + const clientId = (req.body.youtube_client_id || "").trim(); + const clientSecret = (req.body.youtube_client_secret || "").trim(); + const redirectUri = + (req.body.youtube_redirect_uri || "").trim() || + `${baseUrl}/auth/youtube/callback`; + if (!clientId || !clientSecret) { + setFlash(req, "error", "Client ID and Client Secret are required."); + return res.redirect("/admin/youtube-wizard"); + } + setSetting("youtube_client_id", clientId); + setSetting("youtube_client_secret", clientSecret); + setSetting("youtube_redirect_uri", redirectUri); + const state = crypto.randomBytes(16).toString("hex"); + req.session.youtubeBotState = state; + req.session.youtubeBotReturnTo = "/admin/youtube-wizard"; + const url = buildYouTubeAuthUrl(state, redirectUri, { prompt: "consent" }); + res.redirect(url); + }); + + app.post("/admin/youtube-wizard/verify", requireRole("admin"), async (req, res) => { + if (!isPlatformEnabled("youtube")) { + setFlash(req, "error", "YouTube is disabled in Platform Integration."); + return res.redirect("/admin/settings"); + } + const current = getYouTubeSettings(); + const incoming = { + youtube_client_id: req.body.youtube_client_id || "", + youtube_client_secret: req.body.youtube_client_secret || "", + youtube_redirect_uri: req.body.youtube_redirect_uri || "" + }; + const merged = mergeSecrets( + current, + incoming, + new Set(["youtube_client_secret"]) + ); + if (!merged.youtube_redirect_uri) { + const baseUrl = `${req.protocol}://${req.get("host")}`; + merged.youtube_redirect_uri = `${baseUrl}/auth/youtube/callback`; + } + const result = await verifyYouTubeSettings({ + ...merged, + youtube_bot_refresh_token: getSetting("youtube_bot_refresh_token", "") + }); + if (!result.ok) { + res.render("wizard-youtube", { + title: "YouTube setup", + form: { + ...incoming, + youtube_client_secret: "", + youtube_redirect_uri: merged.youtube_redirect_uri + }, + checks: result.checks, + errors: result.errors, + actionBase: "/admin/youtube-wizard", + cancelPath: "/admin/settings", + connectPath: "/admin/youtube-wizard/connect", + botConnected: Boolean(getSetting("youtube_bot_refresh_token", "")), + botChannelId: getSetting("youtube_bot_channel_id", ""), + botChannelName: getYouTubeClient()?.channelName || null + }); + return; + } + if (result.channel?.id) { + merged.youtube_bot_channel_id = result.channel.id; + } + saveSettingsMap(merged); + delete req.session.youtubeWizardSnapshot; + setFlash(req, "success", "YouTube setup saved."); + res.redirect("/admin/settings"); + }); + + app.post("/admin/youtube-wizard/cancel", requireRole("admin"), (req, res) => { + if (!isPlatformEnabled("youtube")) { + return res.redirect("/admin/settings"); + } + restoreSnapshot(req, "youtubeWizardSnapshot"); + setFlash(req, "info", "YouTube setup canceled."); + res.redirect("/admin/settings"); + }); + + app.get("/admin/theming", requireRole("admin"), (req, res) => { + res.render("admin-theme", { + title: "Theming", + theme: getThemeSettings() + }); + }); + + app.post("/admin/theming", requireRole("admin"), (req, res) => { + const fields = [ + "theme_light_bg_1", + "theme_light_bg_2", + "theme_light_bg_3", + "theme_light_text", + "theme_light_text_muted", + "theme_light_accent", + "theme_light_accent_alt", + "theme_light_danger", + "theme_light_surface", + "theme_light_surface_2", + "theme_light_surface_3", + "theme_light_border", + "theme_dark_bg_1", + "theme_dark_bg_2", + "theme_dark_bg_3", + "theme_dark_text", + "theme_dark_text_muted", + "theme_dark_accent", + "theme_dark_accent_alt", + "theme_dark_danger", + "theme_dark_surface", + "theme_dark_surface_2", + "theme_dark_surface_3", + "theme_dark_border", + "theme_role_public", + "theme_role_mod", + "theme_role_admin" + ]; + for (const field of fields) { + if (req.body[field] !== undefined) { + setSetting(field, req.body[field].trim()); + } + } + setFlash(req, "success", "Theme updated."); + res.redirect("/admin/theming"); + }); + + app.get("/admin/logs", requireRole("admin"), (req, res) => { + const range = parseLogRange(req.query.range); + const limit = parseLogLimit(req.query.limit); + const levelValue = normalizeLogLevel(req.query.level) || "all"; + const levels = levelValue === "all" ? [] : [levelValue]; + const sinceMs = range.rangeMs ? Date.now() - range.rangeMs : null; + const logs = listLogs({ limit: limit.limit, sinceMs, levels }); + res.render("admin-logs", { + title: "Logs", + logs, + logFilters: { + range: range.rangeValue, + level: levelValue, + limit: limit.limitValue + } + }); + }); + + app.get("/admin/logs/download", requireRole("admin"), (req, res) => { + const range = parseLogRange(req.query.range); + const limit = parseLogLimit(req.query.limit, { allowAll: true }); + const levels = parseLogLevels(req.query.level); + const sinceMs = range.rangeMs ? Date.now() - range.rangeMs : null; + const logs = listLogs({ limit: limit.limit, sinceMs, levels }); + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + res.setHeader( + "Content-Disposition", + `attachment; filename="lumi-logs-${stamp}.txt"` + ); + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + const lines = logs.map((log) => { + const timestamp = new Date(log.created_at).toISOString(); + const header = `${timestamp} [${log.level.toUpperCase()}] ${log.message}`; + if (log.details) { + return `${header}\n${log.details}\n`; + } + return `${header}\n`; + }); + res.send(lines.join("\n")); + }); + + app.get("/admin/privileges", requireRole("admin"), async (req, res) => { + const discord = await buildDiscordPrivileges(discordClient); + const twitch = await buildTwitchPrivileges(); + res.render("admin-privileges", { + title: "Privileges", + discord, + twitch + }); + }); + + app.get("/admin/commands", requireRole("mod"), (req, res) => { + const platformStatus = getPlatformStatus().filter((platform) => platform.supported); + const availablePlatforms = platformStatus.map((platform) => platform.id); + const commands = db + .prepare("SELECT * FROM custom_commands ORDER BY trigger") + .all() + .map((command) => ({ + ...command, + platforms: normalizeCustomPlatforms(command.platform, availablePlatforms) + })); + res.render("admin-commands", { + title: "Custom commands", + commands, + isAdmin: Boolean(req.session.user?.isAdmin), + platforms: platformStatus.map((platform) => ({ + id: platform.id, + label: platform.label, + enabled: platform.enabled + })) + }); + }); + + app.post("/admin/commands", requireRole("mod"), (req, res) => { + const isAdmin = Boolean(req.session.user?.isAdmin); + const availablePlatforms = getPlatformStatus() + .filter((platform) => platform.supported) + .map((platform) => platform.id); + const trigger = (req.body.trigger || "").trim().toLowerCase(); + const mode = (req.body.mode || "plain").trim(); + const language = (req.body.language || "js").trim(); + const response = (req.body.response || "").trim(); + const code = (req.body.code || "").trim(); + const selectedPlatforms = parsePlatformSelectionFromBody( + req.body, + availablePlatforms + ); + if (!trigger) { + setFlash(req, "error", "Trigger is required."); + return res.redirect("/admin/commands"); + } + if (!selectedPlatforms.length) { + setFlash(req, "error", "Select at least one platform."); + return res.redirect("/admin/commands"); + } + if (!isAdmin && mode === "advanced") { + setFlash(req, "error", "Advanced commands are restricted to admins."); + return res.redirect("/admin/commands"); + } + if (mode === "advanced") { + if (!code) { + setFlash(req, "error", "Advanced commands require code."); + return res.redirect("/admin/commands"); + } + } else if (!response) { + setFlash(req, "error", "Plain commands require a response."); + return res.redirect("/admin/commands"); + } + const now = Date.now(); + try { + db.prepare( + "INSERT INTO custom_commands (trigger, response, mode, language, code, platform, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)" + ).run( + trigger, + response || "", + isAdmin ? mode : "plain", + isAdmin ? language : "js", + isAdmin && mode === "advanced" ? code : null, + serializePlatformSelection(selectedPlatforms), + now, + now + ); + setFlash(req, "success", "Command created."); + res.redirect("/admin/commands"); + } catch (error) { + setFlash(req, "error", "That command already exists."); + res.redirect("/admin/commands"); + } + }); + + app.post("/admin/commands/:id/toggle", requireRole("mod"), (req, res) => { + const row = db + .prepare("SELECT enabled FROM custom_commands WHERE id = ?") + .get(req.params.id); + if (row) { + db.prepare( + "UPDATE custom_commands SET enabled = ?, updated_at = ? WHERE id = ?" + ).run(row.enabled ? 0 : 1, Date.now(), req.params.id); + } + res.redirect("/admin/commands"); + }); + + app.post("/admin/commands/:id/delete", requireRole("mod"), (req, res) => { + db.prepare("DELETE FROM custom_commands WHERE id = ?").run(req.params.id); + setFlash(req, "success", "Command deleted."); + res.redirect("/admin/commands"); + }); + + app.post("/admin/commands/:id/update", requireRole("mod"), (req, res) => { + const isAdmin = Boolean(req.session.user?.isAdmin); + const existing = db + .prepare("SELECT mode FROM custom_commands WHERE id = ?") + .get(req.params.id); + if (existing?.mode === "advanced" && !isAdmin) { + setFlash(req, "error", "Advanced commands can only be edited by admins."); + return res.redirect("/admin/commands"); + } + const availablePlatforms = getPlatformStatus() + .filter((platform) => platform.supported) + .map((platform) => platform.id); + const trigger = (req.body.trigger || "").trim().toLowerCase(); + const mode = (req.body.mode || "plain").trim(); + const language = (req.body.language || "js").trim(); + const response = (req.body.response || "").trim(); + const code = (req.body.code || "").trim(); + const selectedPlatforms = parsePlatformSelectionFromBody( + req.body, + availablePlatforms + ); + if (!trigger) { + setFlash(req, "error", "Trigger is required."); + return res.redirect("/admin/commands"); + } + if (!selectedPlatforms.length) { + setFlash(req, "error", "Select at least one platform."); + return res.redirect("/admin/commands"); + } + if (!isAdmin && mode === "advanced") { + setFlash(req, "error", "Advanced commands are restricted to admins."); + return res.redirect("/admin/commands"); + } + if (mode === "advanced") { + if (!code) { + setFlash(req, "error", "Advanced commands require code."); + return res.redirect("/admin/commands"); + } + } else if (!response) { + setFlash(req, "error", "Plain commands require a response."); + return res.redirect("/admin/commands"); + } + try { + db.prepare( + "UPDATE custom_commands SET trigger = ?, response = ?, mode = ?, language = ?, code = ?, platform = ?, updated_at = ? WHERE id = ?" + ).run( + trigger, + response || "", + isAdmin ? mode : "plain", + isAdmin ? language : "js", + isAdmin && mode === "advanced" ? code : null, + serializePlatformSelection(selectedPlatforms), + Date.now(), + req.params.id + ); + setFlash(req, "success", "Command updated."); + res.redirect("/admin/commands"); + } catch (error) { + setFlash(req, "error", "Unable to update command."); + res.redirect("/admin/commands"); + } + }); + + app.get("/admin/pages", requireRole("admin"), (req, res) => { + const pages = db + .prepare("SELECT * FROM custom_pages ORDER BY created_at DESC") + .all(); + res.render("admin-pages", { + title: "Custom pages", + pages + }); + }); + + app.get("/admin/users", requireRole("mod"), (req, res) => { + const users = listUsersWithIdentities(); + const notes = db + .prepare( + "SELECT n.*, s.internal_user_id FROM moderation_notes n " + + "LEFT JOIN moderation_subjects s ON s.id = n.subject_id" + ) + .all(); + const notesByUser = notes.reduce((acc, note) => { + if (!note.internal_user_id) { + return acc; + } + if (!acc[note.internal_user_id]) { + acc[note.internal_user_id] = []; + } + acc[note.internal_user_id].push(note); + return acc; + }, {}); + res.render("admin-users", { + title: "Users", + users, + notesByUser, + isAdmin: Boolean(req.session.user?.isAdmin) + }); + }); + + app.post("/admin/users/:id/username", requireRole("admin"), (req, res) => { + const desired = (req.body.internal_username || "").trim(); + const result = updateInternalUsername(req.params.id, desired); + if (!result.ok) { + setFlash(req, "error", result.reason); + return res.redirect("/admin/users"); + } + setFlash(req, "success", "Username updated."); + res.redirect("/admin/users"); + }); + + app.post("/admin/pages", requireRole("admin"), (req, res) => { + const slug = (req.body.slug || "").trim(); + const title = (req.body.title || "").trim(); + const navLabel = (req.body.nav_label || "").trim(); + const content = (req.body.content || "").trim(); + const format = normalizePageFormat(req.body.format); + const contentCss = + format === "html" ? (req.body.content_css || "").trim() : ""; + const role = (req.body.role || "public").trim(); + const showInNav = req.body.show_in_nav === "on"; + if (!slug || !title || !content) { + setFlash(req, "error", "Slug, title, and content are required."); + return res.redirect("/admin/pages"); + } + const now = Date.now(); + try { + db.prepare( + "INSERT INTO custom_pages (slug, title, nav_label, content, content_css, format, role, show_in_nav, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)" + ).run( + slug, + title, + navLabel || null, + content, + contentCss, + format, + role, + showInNav ? 1 : 0, + now, + now + ); + setFlash(req, "success", "Page created."); + res.redirect("/admin/pages"); + } catch (error) { + setFlash(req, "error", "That page already exists."); + res.redirect("/admin/pages"); + } + }); + + app.post("/admin/pages/:id/toggle", requireRole("admin"), (req, res) => { + const row = db + .prepare("SELECT enabled FROM custom_pages WHERE id = ?") + .get(req.params.id); + if (row) { + db.prepare( + "UPDATE custom_pages SET enabled = ?, updated_at = ? WHERE id = ?" + ).run(row.enabled ? 0 : 1, Date.now(), req.params.id); + } + res.redirect("/admin/pages"); + }); + + app.post("/admin/pages/:id/delete", requireRole("admin"), (req, res) => { + db.prepare("DELETE FROM custom_pages WHERE id = ?").run(req.params.id); + setFlash(req, "success", "Page deleted."); + res.redirect("/admin/pages"); + }); + + app.post("/admin/pages/:id/update", requireRole("admin"), (req, res) => { + const slug = (req.body.slug || "").trim(); + const title = (req.body.title || "").trim(); + const navLabel = (req.body.nav_label || "").trim(); + const content = (req.body.content || "").trim(); + const format = normalizePageFormat(req.body.format); + const contentCss = + format === "html" ? (req.body.content_css || "").trim() : ""; + const role = (req.body.role || "public").trim(); + const showInNav = req.body.show_in_nav === "on"; + if (!slug || !title || !content) { + setFlash(req, "error", "Slug, title, and content are required."); + return res.redirect("/admin/pages"); + } + try { + db.prepare( + "UPDATE custom_pages SET slug = ?, title = ?, nav_label = ?, content = ?, content_css = ?, format = ?, role = ?, show_in_nav = ?, updated_at = ? WHERE id = ?" + ).run( + slug, + title, + navLabel || null, + content, + contentCss, + format, + role, + showInNav ? 1 : 0, + Date.now(), + req.params.id + ); + setFlash(req, "success", "Page updated."); + res.redirect("/admin/pages"); + } catch (error) { + setFlash(req, "error", "Unable to update page."); + res.redirect("/admin/pages"); + } + }); + + app.get("/admin/plugins", requireRole("admin"), (req, res) => { + syncPluginRegistry(); + res.render("admin-plugins", { + title: "Plugins", + plugins: getPlugins() + }); + }); + + app.post("/admin/plugins/:id/toggle", requireRole("admin"), (req, res) => { + setPluginEnabled(req.params.id, req.body.enabled === "true"); + setFlash(req, "success", "Plugin updated. Restarting..."); + res.redirect("/admin/plugins"); + requestRestart(); + }); + + app.post("/admin/plugins/:id/uninstall", requireRole("admin"), (req, res) => { + const plugin = db + .prepare("SELECT path FROM plugins WHERE id = ?") + .get(req.params.id); + if (plugin?.path) { + try { + fs.rmSync(plugin.path, { recursive: true, force: true }); + } catch (error) { + console.error(error); + } + } + removePlugin(req.params.id); + setFlash(req, "success", "Plugin uninstalled. Restarting..."); + res.redirect("/admin/plugins"); + requestRestart(); + }); + + app.post( + "/admin/plugins/upload", + requireRole("admin"), + uploadSingle("plugin_zip"), + async (req, res) => { + if (req.uploadError) { + setFlash(req, "error", req.uploadError); + return res.redirect("/admin/plugins"); + } + if (!req.file) { + setFlash(req, "error", "Upload a ZIP archive."); + return res.redirect("/admin/plugins"); + } + try { + await applyPluginUpdate(req.file.path); + setFlash(req, "success", "Plugin uploaded. Restarting..."); + res.redirect("/admin/plugins"); + requestRestart(); + } catch (error) { + setFlash(req, "error", error.message); + res.redirect("/admin/plugins"); + } finally { + try { + fs.rmSync(req.file.path, { force: true }); + } catch { + // ignore cleanup errors + } + } + } + ); + + app.post("/admin/plugins/install", requireRole("admin"), (req, res) => { + const url = (req.body.url || "").trim(); + if (!url) { + setFlash(req, "error", "Plugin URL is required."); + return res.redirect("/admin/plugins"); + } + try { + installFromGit(url); + setFlash(req, "success", "Plugin installed. Restarting..."); + res.redirect("/admin/plugins"); + requestRestart(); + } catch (error) { + setFlash(req, "error", error.message); + res.redirect("/admin/plugins"); + } + }); + + app.post("/admin/plugins/create", requireRole("admin"), (req, res) => { + const id = (req.body.id || "").trim(); + const name = (req.body.name || "").trim(); + const description = (req.body.description || "").trim(); + try { + createLocalPlugin({ id, name, description }); + setFlash(req, "success", "Plugin created. Restarting..."); + res.redirect("/admin/plugins"); + requestRestart(); + } catch (error) { + setFlash(req, "error", error.message); + res.redirect("/admin/plugins"); + } + }); + + app.post("/admin/plugins/:id/update", requireRole("admin"), (req, res) => { + const plugin = db + .prepare("SELECT path FROM plugins WHERE id = ?") + .get(req.params.id); + if (!plugin?.path) { + setFlash(req, "error", "Plugin not found."); + return res.redirect("/admin/plugins"); + } + try { + updatePluginFromGit(plugin.path); + setFlash(req, "success", "Plugin updated. Restarting..."); + res.redirect("/admin/plugins"); + requestRestart(); + } catch (error) { + setFlash(req, "error", error.message); + res.redirect("/admin/plugins"); + } + }); + + app.get("/admin/updates", requireRole("admin"), (req, res) => { + res.render("admin-updates", { + title: "Updates", + snapshots: listSnapshots() + }); + }); + + app.post( + "/admin/updates/bot", + requireRole("admin"), + uploadSingle("update_zip"), + async (req, res) => { + if (req.uploadError) { + setFlash(req, "error", req.uploadError); + return res.redirect("/admin/updates"); + } + if (!req.file) { + setFlash(req, "error", "Upload a ZIP archive."); + return res.redirect("/admin/updates"); + } + try { + const patchMode = req.body.patch_mode === "1"; + await applyBotUpdate(req.file.path, { + mode: patchMode ? "patch" : "full" + }); + setFlash( + req, + "success", + patchMode ? "Patch applied. Restarting..." : "Update applied. Restarting..." + ); + res.redirect("/admin/updates"); + requestRestart(); + } catch (error) { + setFlash(req, "error", error.message); + res.redirect("/admin/updates"); + } finally { + try { + fs.rmSync(req.file.path, { force: true }); + } catch { + // ignore cleanup errors + } + } + } + ); + + app.post( + "/admin/updates/plugin", + requireRole("admin"), + uploadSingle("plugin_zip"), + async (req, res) => { + if (req.uploadError) { + setFlash(req, "error", req.uploadError); + return res.redirect("/admin/updates"); + } + if (!req.file) { + setFlash(req, "error", "Upload a ZIP archive."); + return res.redirect("/admin/updates"); + } + try { + await applyPluginUpdate(req.file.path); + setFlash(req, "success", "Plugin update applied. Restarting..."); + res.redirect("/admin/updates"); + requestRestart(); + } catch (error) { + setFlash(req, "error", error.message); + res.redirect("/admin/updates"); + } finally { + try { + fs.rmSync(req.file.path, { force: true }); + } catch { + // ignore cleanup errors + } + } + } + ); + + app.post("/admin/update", requireRole("admin"), (req, res) => { + try { + const remote = getSetting("git_remote", "origin"); + const branch = getSetting("git_branch", "main"); + pullUpdates(remote, branch); + setFlash(req, "success", "Update applied. Restarting..."); + res.redirect("/admin"); + requestRestart(); + } catch (error) { + setFlash(req, "error", error.message); + res.redirect("/admin"); + } + }); + + app.post("/admin/check-update", requireRole("admin"), (req, res) => { + try { + const remote = getSetting("git_remote", "origin"); + const branch = getSetting("git_branch", "main"); + const hasUpdate = checkForUpdates(remote, branch); + setFlash( + req, + "info", + hasUpdate ? "Updates are available." : "No updates found." + ); + res.redirect("/admin"); + } catch (error) { + setFlash(req, "error", error.message); + res.redirect("/admin"); + } + }); + + app.post("/admin/restart", requireRole("admin"), (req, res) => { + setFlash(req, "success", "Restarting..."); + res.redirect("/admin"); + requestRestart(); + }); + + app.use((err, req, res, next) => { + if (res.headersSent) { + return next(err); + } + const message = err?.message || ""; + const isViewMissing = message.includes("Failed to lookup view"); + log("error", "Unhandled error", { + path: req.path, + method: req.method, + userId: req.session.user?.id || null, + message, + stack: err?.stack || "" + }); + if (isViewMissing) { + res.locals.softError = "Some content could not be loaded."; + return res.status(200).render("missing-view", { + title: "Content missing", + resource: "A page component failed to load. Please try again." + }); + } + const status = err?.status || 500; + res.status(status).render("error", { + title: "Something went wrong", + message: "An unexpected error occurred. Please try again." + }); + }); + + if (typeof loadPlugins === "function") { + loadPlugins(app, web); + } + + return app; +} + +const NAV_SECTION_ICONS = ["home", "spark", "shield", "gear", "blocks"]; +const DEFAULT_NAV_SECTIONS = [ + { id: "overview", label: "Overview", icon: "home" }, + { id: "community", label: "Community", icon: "spark" }, + { id: "moderation", label: "Mod", icon: "shield" }, + { id: "admin", label: "Admin", icon: "gear" }, + { id: "plugins", label: "Plugins", icon: "blocks" } +]; + +function collectNavItems(user, pluginNav, currentPath) { + const iconMap = getSetting("nav_item_icons", {}) || {}; + const base = [ + { label: "Home", path: "/", role: "public", section: "overview" }, + { label: "Commands", path: "/commands", role: "public", section: "community" }, + { + label: "Leaderboards", + path: "/leaderboards", + role: "public", + section: "community" + }, + { + label: "Stats", + path: "/stats", + role: "public", + authRequired: true, + section: "community" + }, + { + label: "Profile", + path: "/profile", + role: "public", + authRequired: true, + section: "community" + }, + { label: "Mods List", path: "/moderator", role: "mod", section: "moderation" }, + { label: "Admin", path: "/admin", role: "admin", section: "admin" }, + { + label: "Settings", + path: "/admin/settings", + role: "admin", + section: "admin" + }, + { + label: "Navigation", + path: "/admin/navigation", + role: "admin", + section: "admin" + }, + { + label: "Theming", + path: "/admin/theming", + role: "admin", + section: "admin" + }, + { + label: "Privileges", + path: "/admin/privileges", + role: "admin", + section: "admin" + }, + { label: "Logs", path: "/admin/logs", role: "admin", section: "admin" }, + { label: "Updates", path: "/admin/updates", role: "admin", section: "admin" }, + { + label: "Custom commands", + path: "/admin/commands", + role: "mod", + section: "moderation" + }, + { label: "Pages", path: "/admin/pages", role: "admin", section: "admin" }, + { label: "Users", path: "/admin/users", role: "mod", section: "moderation" }, + { label: "Plugins", path: "/admin/plugins", role: "admin", section: "admin" } + ]; + const pages = db + .prepare( + "SELECT slug, title, nav_label, role FROM custom_pages WHERE show_in_nav = 1 AND enabled = 1 ORDER BY created_at ASC" + ) + .all() + .map((page) => ({ + label: page.nav_label || page.title, + path: `/pages/${page.slug}`, + role: page.role, + section: "community" + })); + const pluginItems = (pluginNav || []).map((item) => ({ + ...item, + section: item.section || "plugins" + })); + const allItems = [...base, ...pages, ...pluginItems].filter((item) => { + if (item.authRequired && !user) { + return false; + } + return hasAccess(user, item.role); + }); + return allItems.map((item) => { + const navId = buildNavItemId(item); + const customIcon = iconMap[navId] || null; + const defaultIcon = getDefaultNavIcon(item); + const icon = customIcon + ? `/assets/nav-icons/${customIcon}` + : defaultIcon + ? `/icons/nav/${defaultIcon}.svg` + : null; + return { + ...item, + navId, + icon, + active: isActivePath(item.path, currentPath) + }; + }); +} + +function buildNavSections(user, pluginNav, currentPath) { + const items = collectNavItems(user, pluginNav, currentPath); + const navStructure = normalizeNavStructure(getSetting("nav_structure", null)); + if (navStructure?.enabled && navStructure.sections.length) { + return buildNavSectionsFromStructure(items, navStructure); + } + return buildDefaultNavSections(items); +} + +function buildDefaultNavSections(items) { + const sections = DEFAULT_NAV_SECTIONS.map((section) => ({ + ...section, + items: [] + })); + const sectionMap = new Map(sections.map((section) => [section.id, section])); + + for (const item of items) { + const section = sectionMap.get(item.section); + if (section) { + section.items.push(item); + } + } + + return sections + .filter((section) => section.items.length) + .map((section) => ({ + ...section, + items: section.items.slice().sort((a, b) => a.label.localeCompare(b.label)), + open: section.items.some((item) => item.active) + })); +} + +function buildNavSectionsFromStructure(items, structure) { + const itemMap = new Map(items.map((item) => [item.navId, item])); + const used = new Set(); + const sections = []; + + const pushSection = (section, sectionItems) => { + if (!sectionItems.length) { + return; + } + sections.push({ + id: section.id, + label: section.label || toTitleCase(section.id) || section.id, + icon: normalizeNavSectionIcon(section.icon) || "blocks", + items: sectionItems, + open: sectionItems.some((item) => item.active) + }); + }; + + for (const section of structure.sections) { + if (!section?.id) { + continue; + } + const sectionItems = []; + for (const rawId of section.items || []) { + const navId = (rawId || "").toString().trim(); + if (!navId || used.has(navId)) { + continue; + } + const item = itemMap.get(navId); + if (!item) { + continue; + } + sectionItems.push(item); + used.add(navId); + } + pushSection(section, sectionItems); + } + + if (structure.includeUnassigned !== false) { + const unassigned = items.filter((item) => !used.has(item.navId)); + if (unassigned.length) { + pushSection( + { + id: structure.unassignedId || "other", + label: structure.unassignedLabel || "Other", + icon: structure.unassignedIcon || "blocks" + }, + unassigned + ); + } + } + + return sections; +} + +function buildNavIconItems(user, pluginNav, currentPath) { + return collectNavItems(user, pluginNav, currentPath).map((item) => ({ + id: item.navId, + label: item.label, + path: item.path, + icon: item.icon + })); +} + +function buildDefaultNavStructure(items) { + const sections = DEFAULT_NAV_SECTIONS.map((section) => ({ + id: section.id, + label: section.label, + icon: section.icon, + items: [] + })); + const sectionMap = new Map(sections.map((section) => [section.id, section])); + items.forEach((item) => { + const section = sectionMap.get(item.section); + if (section) { + section.items.push(item); + } + }); + sections.forEach((section) => { + section.items = section.items + .slice() + .sort((a, b) => a.label.localeCompare(b.label)) + .map((item) => item.navId); + }); + return { + enabled: false, + includeUnassigned: true, + sections + }; +} + +function normalizeNavStructure(raw) { + if (!raw) { + return null; + } + let value = raw; + if (typeof value === "string") { + try { + value = JSON.parse(value); + } catch { + return null; + } + } + if (!value || typeof value !== "object") { + return null; + } + const sections = Array.isArray(value.sections) + ? value.sections.map(normalizeNavSection).filter(Boolean) + : []; + return { + enabled: Boolean(value.enabled), + includeUnassigned: value.includeUnassigned !== false, + unassignedLabel: + typeof value.unassignedLabel === "string" && value.unassignedLabel.trim() + ? value.unassignedLabel.trim() + : null, + unassignedIcon: normalizeNavSectionIcon(value.unassignedIcon), + unassignedId: + typeof value.unassignedId === "string" && value.unassignedId.trim() + ? value.unassignedId.trim() + : null, + sections + }; +} + +function normalizeNavSection(section, index) { + if (!section || typeof section !== "object") { + return null; + } + const id = (section.id || section.label || `section-${index + 1}`) + .toString() + .trim(); + if (!id) { + return null; + } + const label = (section.label || toTitleCase(id) || id).toString().trim(); + const icon = normalizeNavSectionIcon(section.icon) || "blocks"; + const items = Array.isArray(section.items) + ? section.items.map((item) => item.toString().trim()).filter(Boolean) + : []; + return { id, label, icon, items }; +} + +function normalizeNavSectionIcon(value) { + if (!value) { + return null; + } + const normalized = value.toString().trim().toLowerCase(); + return NAV_SECTION_ICONS.includes(normalized) ? normalized : null; +} + +function buildNavItemId(item) { + const base = (item.path || item.label || "item").toString().toLowerCase(); + return base.replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); +} + +function getDefaultNavIcon(item) { + const pathName = (item.path || "").toLowerCase(); + if (pathName === "/") return "home"; + if (pathName === "/commands") return "commands"; + if (pathName === "/leaderboards") return "leaderboards"; + if (pathName === "/stats") return "stats"; + if (pathName === "/profile") return "profile"; + if (pathName === "/moderator") return "users"; + if (pathName.startsWith("/pages/")) return "pages"; + if (pathName === "/admin") return "admin"; + if (pathName === "/admin/settings") return "settings"; + if (pathName === "/admin/navigation") return "settings"; + if (pathName === "/admin/theming") return "theming"; + if (pathName === "/admin/privileges") return "privileges"; + if (pathName === "/admin/logs") return "logs"; + if (pathName === "/admin/updates") return "updates"; + if (pathName === "/admin/commands") return "commands"; + if (pathName === "/admin/pages") return "pages"; + if (pathName === "/admin/users") return "users"; + if (pathName === "/admin/plugins") return "plugins"; + if (pathName === "/moderator") return "users"; + if (pathName.startsWith("/plugins/moderation/tos-bans")) return "moderation"; + if (pathName.startsWith("/plugins/moderation")) return "moderation"; + if (pathName.startsWith("/plugins")) return "plugins"; + if (pathName.startsWith("/moderator")) return "moderation"; + return "pages"; +} + +function isActivePath(itemPath, currentPath) { + if (!itemPath || !currentPath) { + return false; + } + if (itemPath === "/") { + return currentPath === "/"; + } + return currentPath === itemPath || currentPath.startsWith(`${itemPath}/`); +} + +module.exports = { + createWebServer +}; diff --git a/src/web/views/admin-commands.ejs b/src/web/views/admin-commands.ejs new file mode 100644 index 0000000..99dec17 --- /dev/null +++ b/src/web/views/admin-commands.ejs @@ -0,0 +1,197 @@ +<%- include("partials/layout-top", { title }) %> +
    +

    Custom commands

    + <% const platformLabelMap = new Map((platforms || []).map((item) => [item.id, item.label])); %> +
    +
    + + +
    +
    + +
    + <% (platforms || []).forEach((platform) => { %> + + <% }) %> +
    + <% if (!platforms || !platforms.length) { %> +

    Enable platforms in Platform Integration to assign them here.

    + <% } %> +
    + <% if (isAdmin) { %> +
    + + +
    +
    + + +
    + <% } else { %> + + + <% } %> +
    + + +
    + <% if (isAdmin) { %> +
    + + +
    + <% } %> + +
    + <% if (isAdmin) { %> +

    Advanced commands must export a run(ctx) function. Return a string to reply.

    + <% } else { %> +

    Moderators can create plain text commands only.

    + <% } %> +

    Existing commands

    + <% if (!commands.length) { %> +

    No commands created yet.

    + <% } else { %> + + + + + + + + + + + <% commands.forEach((command) => { %> + + + + + + + + + + <% }) %> + +
    TriggerResponseStatusActions
    <%= command.trigger %> +
    + + <% (command.platforms || []).forEach((platform) => { %> + <%= platformLabelMap.get(platform) || platform %> + <% }) %> + + + <%= command.mode === "advanced" ? "Advanced (" + command.language + ")" : command.response %> + +
    +
    <%= command.enabled ? "Enabled" : "Disabled" %> +
    + +
    +
    + +
    + <% if (isAdmin || command.mode === "plain") { %> + + <% } %> +
    +
    +
    + + +
    +
    + +
    + <% (platforms || []).forEach((platform) => { %> + + <% }) %> +
    +
    + <% if (isAdmin) { %> +
    + + +
    +
    + + +
    + <% } else { %> + + + <% } %> +
    + + +
    + <% if (isAdmin) { %> +
    + + +
    + <% } %> + +
    + <% if (!isAdmin && command.mode === 'advanced') { %> +

    Advanced commands can only be edited by admins.

    + <% } %> +
    + <% } %> +
    + +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/admin-dashboard.ejs b/src/web/views/admin-dashboard.ejs new file mode 100644 index 0000000..b1888ca --- /dev/null +++ b/src/web/views/admin-dashboard.ejs @@ -0,0 +1,56 @@ +<%- include("partials/layout-top", { title }) %> +
    +

    Admin dashboard

    +
    +
    +

    Settings

    +

    Update site settings and automation preferences.

    + Edit settings +
    +
    +

    Theming

    +

    Adjust light and dark mode colors.

    + Edit theme +
    +
    +

    Commands

    +

    Create and manage custom bot commands.

    + Manage commands +
    +
    +

    Users

    +

    View linked accounts and manage usernames.

    + Manage users +
    +
    +

    Pages

    +

    Create public, moderator, or admin pages.

    + Manage pages +
    +
    +

    Plugins

    +

    Install, enable, and update modules.

    + Manage plugins +
    +
    +

    Updates

    +

    Upload bot or plugin ZIP updates and review snapshots.

    + Manage updates +
    +
    +
    +
    +

    Maintenance

    +
    + +
    +
    + +
    +
    + +
    +
    +<%- include("partials/layout-bottom") %> + + diff --git a/src/web/views/admin-logs.ejs b/src/web/views/admin-logs.ejs new file mode 100644 index 0000000..a3dada7 --- /dev/null +++ b/src/web/views/admin-logs.ejs @@ -0,0 +1,116 @@ +<%- include("partials/layout-top", { title }) %> +<% const filters = logFilters || { range: '86400000', level: 'all', limit: '50' }; %> +
    +
    +
    +

    Logs

    +

    Core system logs with severity, timestamps, and details.

    +
    +
    + + + + + +
    +
    +
    + <% if (!logs || !logs.length) { %> +

    No log events yet.

    + <% } else { %> + <% logs.forEach((log) => { %> +
    " + > + + + <%= log.message %> + <%= log.level %> + <%= new Date(log.created_at).toLocaleString() %> + + <% if (log.details) { %> +
    <%= log.details %>
    + <% } else { %> +
    No additional details.
    + <% } %> +
    + <% }) %> + <% } %> +
    +
    + +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/admin-navigation.ejs b/src/web/views/admin-navigation.ejs new file mode 100644 index 0000000..8ec3d60 --- /dev/null +++ b/src/web/views/admin-navigation.ejs @@ -0,0 +1,427 @@ +<%- include("partials/layout-top", { title }) %> + + +
    +

    Navigation

    +

    Drag items between sections to build the sidebar layout.

    + +
    + +
    +
    + + + + + +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/admin-pages.ejs b/src/web/views/admin-pages.ejs new file mode 100644 index 0000000..17fb2f1 --- /dev/null +++ b/src/web/views/admin-pages.ejs @@ -0,0 +1,170 @@ +<%- include("partials/layout-top", { title }) %> +
    +

    Custom pages

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    +

    Existing pages

    + <% if (!pages.length) { %> +

    No pages created yet.

    + <% } else { %> + + + + + + + + + + + + <% pages.forEach((page) => { %> + <% const pageFormat = (page.format || "html").toString().toLowerCase(); %> + + + + + + + + + + + <% }) %> + +
    SlugTitleRoleStatusActions
    <%= page.slug %><%= page.title %><%= page.role %><%= page.enabled ? "Enabled" : "Disabled" %> +
    + +
    +
    + +
    + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    + <% } %> +
    + +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/admin-plugins.ejs b/src/web/views/admin-plugins.ejs new file mode 100644 index 0000000..2089003 --- /dev/null +++ b/src/web/views/admin-plugins.ejs @@ -0,0 +1,78 @@ +<%- include("partials/layout-top", { title }) %> +
    +

    Plugins

    +

    Installed plugins

    + <% if (!plugins.length) { %> +

    No plugins installed.

    + <% } else { %> + + + + + + + + + + + <% plugins.forEach((plugin) => { %> + + + + + + + <% }) %> + +
    NameVersionStatusActions
    <%= plugin.name %><%= plugin.version || "-" %><%= plugin.enabled ? "Enabled" : "Disabled" %> +
    + + +
    +
    + +
    +
    + +
    +
    + <% } %> +
    +
    +

    Install plugin from ZIP

    +
    +
    + +
    + +
    +
    +
    +

    Install plugin from git

    +
    +
    + + +
    + +
    +
    +
    +

    Create local plugin

    +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/admin-privileges.ejs b/src/web/views/admin-privileges.ejs new file mode 100644 index 0000000..494e30f --- /dev/null +++ b/src/web/views/admin-privileges.ejs @@ -0,0 +1,105 @@ +<%- include("partials/layout-top", { title }) %> +
    +
    +
    +

    Discord Privileges

    +

    Verify the bot's permissions inside the configured server.

    +

    + Guild: + <%= discord && discord.guildName ? discord.guildName : "Not connected" %> +

    +
    +
    + +
    +
    +
    + + + + + + + + + + <% (discord && discord.rows ? discord.rows : []).forEach((row) => { %> + + + + + + <% }) %> + +
    PrivilegeDetailsStatus
    <%= row.label %><%= row.description || "-" %> + " aria-hidden="true"> + + + <%= row.granted ? "Granted" : "Missing" %> +
    +
    +
    + +
    +
    +
    +

    Twitch Privileges

    +

    Confirm Twitch configuration and chat connectivity.

    +

    + Channels configured: + <%= twitch && Number.isFinite(twitch.channelCount) ? twitch.channelCount : 0 %> +

    +
    +
    + +
    +
    +
    + + + + + + + + + + <% (twitch && twitch.rows ? twitch.rows : []).forEach((row) => { %> + + + + + + <% }) %> + +
    PrivilegeDetailsStatus
    <%= row.label %><%= row.description || "-" %> + " aria-hidden="true"> + + + <%= row.granted ? "Granted" : "Missing" %> +
    +
    +
    +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/admin-settings.ejs b/src/web/views/admin-settings.ejs new file mode 100644 index 0000000..fb9f613 --- /dev/null +++ b/src/web/views/admin-settings.ejs @@ -0,0 +1,106 @@ +<%- include("partials/layout-top", { title }) %> +
    +

    Settings

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    +

    Platform Integration

    +

    Enable or disable platform adapters and run the setup wizards.

    +
    + <% (platforms || []).forEach((platform) => { %> +
    +
    + <%= platform.label %> + <% if (!platform.supported) { %> + Coming soon + <% } %> +
    + + <% if (platform.supported) { %> +
    + Open wizard + <%= platform.configured ? 'Configured' : 'Not configured' %> +
    + <% } else { %> +

    Support planned for a future update.

    + <% } %> +
    + <% }) %> +
    +
    + + +
    +
    +
    +

    Navigation icons

    +

    Upload SVG or PNG icons for sidebar sublinks.

    + +
    +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/admin-theme.ejs b/src/web/views/admin-theme.ejs new file mode 100644 index 0000000..d30847a --- /dev/null +++ b/src/web/views/admin-theme.ejs @@ -0,0 +1,122 @@ +<%- include("partials/layout-top", { title }) %> +
    +

    Theming

    +

    Update light and dark mode colors used across the WebUI.

    +
    +

    Light mode

    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +

    Dark mode

    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +

    Role colors

    +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/admin-updates.ejs b/src/web/views/admin-updates.ejs new file mode 100644 index 0000000..f053fe1 --- /dev/null +++ b/src/web/views/admin-updates.ejs @@ -0,0 +1,63 @@ +<%- include("partials/layout-top", { title }) %> +
    +

    Updates

    +

    Upload ZIP archives for core bot updates or plugin updates. A snapshot is taken before each update.

    +

    Rollback is handled from Safe Mode if something breaks.

    +
    + +
    +

    Upload bot update

    +
    +
    + +
    +
    + + +
    +
    + +
    +
    +
    + +
    +

    Upload plugin update

    +
    +
    + +
    +
    + +
    +
    +
    + +
    +

    Snapshots

    + <% if (!snapshots.length) { %> +

    No snapshots yet.

    + <% } else { %> + + + + + + + + + <% snapshots.forEach((snap) => { %> + + + + + <% }) %> + +
    SnapshotCreated
    <%= snap.type === 'plugin' ? `Plugin: ${snap.pluginId}` : 'Bot core' %><%= new Date(snap.createdAt).toLocaleString() %>
    + <% } %> +
    +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/admin-users.ejs b/src/web/views/admin-users.ejs new file mode 100644 index 0000000..4aa0d36 --- /dev/null +++ b/src/web/views/admin-users.ejs @@ -0,0 +1,110 @@ +<%- include("partials/layout-top", { title }) %> +
    +

    Users

    + <% if (!users.length) { %> +

    No users yet.

    + <% } else { %> +
    + +
    + + + + + + + <% if (isAdmin) { %> + + <% } %> + + + + <% users.forEach((user) => { %> + <% const notes = (notesByUser && notesByUser[user.id]) ? notesByUser[user.id] : []; %> + + + + + <% if (isAdmin) { %> + + <% } %> + + <% }) %> + +
    Internal usernameIdentitiesNotesUpdate username
    <%= user.internal_username %> + <% if (!user.identities.length) { %> + None + <% } else { %> +
      + <% user.identities.forEach((identity) => { %> +
    • + <%= identity.provider %> + <%= identity.display_name || identity.provider_user_id %> +
    • + <% }) %> +
    + <% } %> +
    + <% if (notes.length) { %> + + <% } else { %> + No notes + <% } %> + +
    + + +
    +
    + <% } %> +
    + + +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/commands.ejs b/src/web/views/commands.ejs new file mode 100644 index 0000000..5bfab8a --- /dev/null +++ b/src/web/views/commands.ejs @@ -0,0 +1,176 @@ +<%- include("partials/layout-top", { title }) %> +
    +
    +
    +

    Commands

    +

    Auto-updated list of core and plugin commands.

    +
    +
    + +
    +
    +

    + Usage: Use <arg> for required arguments and [arg] for optional ones. +

    + <% if (isAdmin && conflicts.length) { %> +
    + Command conflicts detected. +

    These triggers overlap on the same platform and may shadow each other.

    +
      + <% conflicts.forEach((conflict) => { %> +
    • + <%= conflict.triggerDisplay %> on <%= conflict.platformLabel %>: <%= conflict.sourcesLabel %> +
    • + <% }) %> +
    +
    + <% } %> + <% if (!commandGroups.length) { %> +

    No commands registered yet.

    + <% } else { %> +
    + + + + + + + + + + + + + + + <% commandGroups.forEach((group) => { %> + <% const root = group.root; %> + <% const subcommands = group.subcommands || []; %> + <% const hasSubcommands = subcommands.length > 0; %> + <% const rootLevelClass = (root.level || "").toString().toLowerCase().replace(/[^a-z0-9-]/g, ""); %> + + + + + + + + + + + <% subcommands.forEach((command) => { %> + <% const commandLevelClass = (command.level || "").toString().toLowerCase().replace(/[^a-z0-9-]/g, ""); %> + + + + + + + + + + + <% }) %> + <% }) %> + +
    TriggerNameDescriptionLevelPlatformOriginCountLink
    +
    + <% if (hasSubcommands) { %> + + <% } else { %> + + <% } %> + +
    +
    + <%= root.name %> + + <%= root.description || "-" %> + + title="<%= root.levelHelp %>"<% } %> + > + <%= root.level %> + + + + <% root.platformLabels.forEach((platform) => { %> + <%= platform.label %> + <% }) %> + + <%= root.origin %><%= root.count %> + +
    +
    + + +
    +
    + <%= command.name %> + + <%= command.description || "-" %> + + title="<%= command.levelHelp %>"<% } %> + > + <%= command.level %> + + + + <% command.platformLabels.forEach((platform) => { %> + <%= platform.label %> + <% }) %> + + <%= command.origin %><%= command.count %> + +
    +
    + <% } %> +
    +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/custom-page.ejs b/src/web/views/custom-page.ejs new file mode 100644 index 0000000..318b27b --- /dev/null +++ b/src/web/views/custom-page.ejs @@ -0,0 +1,62 @@ +<%- include("partials/layout-top", { title }) %> +
    +

    <%= page.title %>

    + <% if (page.format === "markdown") { %> +
    <%- renderedContent %>
    + <% } else { %> +
    + +
    + + <% } %> +
    +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/error.ejs b/src/web/views/error.ejs new file mode 100644 index 0000000..32bb764 --- /dev/null +++ b/src/web/views/error.ejs @@ -0,0 +1,7 @@ +<%- include("partials/layout-top", { title }) %> +
    +

    <%= title %>

    +

    <%= message %>

    + Return home +
    +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/home.ejs b/src/web/views/home.ejs new file mode 100644 index 0000000..eb0f8b0 --- /dev/null +++ b/src/web/views/home.ejs @@ -0,0 +1,27 @@ +<%- include("partials/layout-top", { title }) %> +
    +

    Welcome to <%= siteTitle %>

    +

    Manage the bot, explore stats, and extend features from the WebUI.

    + +
    +
    +
    +

    Bot control

    +

    Configure settings, update the bot, and manage plugins from the dashboard.

    + Open admin dashboard +
    +
    +

    Community stats

    +

    Track activity, view leaderboards, and browse profile stats.

    + View leaderboards +
    +
    +

    Custom modules

    +

    Create commands and pages directly in the WebUI.

    + Manage commands +
    +
    +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/leaderboards.ejs b/src/web/views/leaderboards.ejs new file mode 100644 index 0000000..9ad5937 --- /dev/null +++ b/src/web/views/leaderboards.ejs @@ -0,0 +1,55 @@ +<%- include("partials/layout-top", { title }) %> +
    +

    Leaderboards

    +
    + +<% if (!sections || !sections.length) { %> +
    +

    No activity recorded yet.

    +
    +<% } else { %> +<% (sections || []).forEach((section) => { %> +
    +

    <%= section.title %>

    + <% if (!section.boards || !section.boards.length) { %> +

    <%= section.emptyMessage || "No data recorded yet." %>

    + <% } else { %> + <% section.boards.forEach((board) => { %> + <% const rowType = board.rowType || "user"; %> +

    <%= board.label || board.title || "Leaderboard" %>

    + <% if (!board.rows || !board.rows.length) { %> +

    <%= board.emptyMessage || "No data recorded yet." %>

    + <% } else { %> + + + + + + + + + <% board.rows.forEach((entry) => { %> + + + + + <% }) %> + +
    + <%= rowType === "command" ? "Command" : rowType === "game" ? "Game" : rowType === "text" ? "Item" : "User" %> + <%= board.valueLabel || "Total" %>
    + <% if (rowType === "user" && entry.username) { %> + <%= entry.username %> + <% } else if (rowType === "command") { %> + <%= entry.label || entry.username || "Unknown" %> + <% } else { %> + <%= entry.label || entry.username || "Unknown" %> + <% } %> + <%= entry.value %>
    + <% } %> + <% }) %> + <% } %> +
    +<% }) %> +<% } %> +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/missing-view.ejs b/src/web/views/missing-view.ejs new file mode 100644 index 0000000..f7a4f8c --- /dev/null +++ b/src/web/views/missing-view.ejs @@ -0,0 +1,12 @@ +<%- include("partials/layout-top", { title }) %> +
    +
    +
    !
    +
    +

    Content unavailable

    +

    <%= resource || "Some content could not be loaded." %>

    +

    Please refresh the page or try again in a moment.

    +
    +
    +
    +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/moderator.ejs b/src/web/views/moderator.ejs new file mode 100644 index 0000000..00e8b27 --- /dev/null +++ b/src/web/views/moderator.ejs @@ -0,0 +1,91 @@ +<%- include("partials/layout-top", { title }) %> +
    +
    +
    +

    Mods List

    +

    Active moderators and admins with linked platform aliases.

    +
    +
    +
    + +
    + <% if (!mods.length) { %> +

    No moderator history recorded yet.

    + <% } else { %> +
    + +
    + +
    +
    +
    + + + + + + + + + + + <% mods.forEach((mod) => { %> + <% const platformLabels = mod.identities.map((identity) => identity.label).join(" "); %> + <% const aliasText = mod.aliasText || ""; %> + + + + + + + <% }) %> + +
    Internal usernamePlatformsAliasesMod age
    <%= mod.username %> + + <% if (!mod.identities.length) { %> + Internal + <% } else { %> + <% mod.identities.forEach((identity) => { %> + <%= identity.label %> + <% }) %> + <% } %> + + + <% if (!aliasText) { %> + No linked accounts + <% } else { %> + View aliases + <% } %> + <%= formatDuration(mod.totalMs) %>
    +
    +
    + + Page 1 of 1 + +
    + <% } %> +
    +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/mods-list.ejs b/src/web/views/mods-list.ejs new file mode 100644 index 0000000..00e8b27 --- /dev/null +++ b/src/web/views/mods-list.ejs @@ -0,0 +1,91 @@ +<%- include("partials/layout-top", { title }) %> +
    +
    +
    +

    Mods List

    +

    Active moderators and admins with linked platform aliases.

    +
    +
    +
    + +
    + <% if (!mods.length) { %> +

    No moderator history recorded yet.

    + <% } else { %> +
    + +
    + +
    +
    +
    + + + + + + + + + + + <% mods.forEach((mod) => { %> + <% const platformLabels = mod.identities.map((identity) => identity.label).join(" "); %> + <% const aliasText = mod.aliasText || ""; %> + + + + + + + <% }) %> + +
    Internal usernamePlatformsAliasesMod age
    <%= mod.username %> + + <% if (!mod.identities.length) { %> + Internal + <% } else { %> + <% mod.identities.forEach((identity) => { %> + <%= identity.label %> + <% }) %> + <% } %> + + + <% if (!aliasText) { %> + No linked accounts + <% } else { %> + View aliases + <% } %> + <%= formatDuration(mod.totalMs) %>
    +
    +
    + + Page 1 of 1 + +
    + <% } %> +
    +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/partials/layout-bottom.ejs b/src/web/views/partials/layout-bottom.ejs new file mode 100644 index 0000000..f1ce42d --- /dev/null +++ b/src/web/views/partials/layout-bottom.ejs @@ -0,0 +1,15 @@ + +
    + Powered by Lumi Bot
    © OokamiKunTV & Jejeee
    +
    + <% if (softError) { %> +
    + Notice + <%= softError %> +
    + <% } %> +
    + + + + diff --git a/src/web/views/partials/layout-top.ejs b/src/web/views/partials/layout-top.ejs new file mode 100644 index 0000000..e880081 --- /dev/null +++ b/src/web/views/partials/layout-top.ejs @@ -0,0 +1,141 @@ + + + + + + <%= title %> - <%= siteTitle %> + + <% if (theme) { %> + + <% } %> + + + <% const icons = { + home: '', + spark: '', + shield: '', + gear: '', + blocks: '' + }; %> +
    + +
    +
    + + <%= siteTitle %> +
    +
    + <% if (flash) { %> +
    <%= flash.message %>
    + <% } %> + diff --git a/src/web/views/plugin-expression.ejs b/src/web/views/plugin-expression.ejs new file mode 100644 index 0000000..27a033b --- /dev/null +++ b/src/web/views/plugin-expression.ejs @@ -0,0 +1,163 @@ +<%- include("partials/layout-top", { title }) %> +
    +

    Expression Interaction

    +

    Roleplay friendly interactions from Discord or Twitch with quick commands.

    +

    + Commands: + <% const enabledActions = actions.filter((action) => action.enabled); %> + <% if (!enabledActions.length) { %> + None enabled yet. + <% } else { %> + <%= enabledActions.map((action) => `!${action.command}`).join(", ") %> + <% } %> +

    +
    + +
    +

    Your stats

    + <% if (!stats) { %> +

    Sign in to see how many actions you have given or received.

    + <% } else { %> +
    +
    + Given + <%= stats.totals.given %> +
    +
    + Received + <%= stats.totals.received %> +
    +
    + + + + + + + + + + <% actions.forEach((action) => { %> + <% const row = stats.byAction[action.id] || { given_count: 0, received_count: 0 }; %> + + + + + + <% }) %> + +
    ActionGivenReceived
    <%= action.command %><%= row.given_count %><%= row.received_count %>
    + <% } %> +
    + +
    +

    Global stats

    +
    +
    + Total interactions + <%= globalStats.total %> +
    +
    + <% if (!globalStats.byAction.length) { %> +

    No interactions recorded yet.

    + <% } else { %> + + + + + + + + + <% globalStats.byAction.forEach((row) => { %> + <% const action = actions.find((item) => item.id === row.action); %> + + + + + <% }) %> + +
    ActionTotal
    <%= action ? action.command : row.action %><%= row.count %>
    + <% } %> +
    + +<% if (isAdmin) { %> +
    +

    Settings

    + <% if (conflicts && conflicts.length) { %> +
    + Conflicting command names: <%= conflicts.join(", ") %>. Rename the duplicates. +
    + <% } %> +
    +
    + + +
    +
    + + +
    +
    + + + + + + + + + + <% actions.forEach((action) => { %> + + + + + + <% }) %> + +
    ActionEnabledCommand name
    <%= action.id %> + + + +
    +

    Command names are lowercased; spaces become dashes.

    +
    +
    + +
    +
    +
    +<% } %> + +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/plugin-page.ejs b/src/web/views/plugin-page.ejs new file mode 100644 index 0000000..2ce0537 --- /dev/null +++ b/src/web/views/plugin-page.ejs @@ -0,0 +1,6 @@ +<%- include("partials/layout-top", { title }) %> +
    +

    <%= title %>

    +

    <%= content %>

    +
    +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/profile.ejs b/src/web/views/profile.ejs new file mode 100644 index 0000000..4c3f0dd --- /dev/null +++ b/src/web/views/profile.ejs @@ -0,0 +1,150 @@ +<%- include("partials/layout-top", { title }) %> +
    +
    +
    +

    Your profile

    +

    Link external accounts to unlock integrations.

    +
    +
    + +
    +
    +
    +

    Username

    +

    Shown across the bot and community features.

    +
    + <% if (canChangeUsername) { %> + + <% } else { %> + + <% } %> +
    +
    + Current username + <%= profile.internal_username %> +
    + <% if (canChangeUsername) { %> +

    You can change your username once every <%= usernameCooldownDays %> days.

    + <% } else { %> +

    Username changes are on cooldown. Try again in <%= usernameCooldownRemainingDays %> day(s).

    + <% } %> +
    + +
    +

    Link external accounts

    + <% const linkedProviders = new Set((accounts || []).map((account) => account.provider)); %> +
    + <% if (platformLinks && platformLinks.length) { %> + <% platformLinks.forEach((platform) => { %> + <% if (linkedProviders.has(platform.id)) { %> + Linked <%= platform.label %> + <% } else if (platform.configured) { %> + Link <%= platform.label %> + <% } else { %> + Link <%= platform.label %> + <% } %> + <% }) %> + <% } else { %> +

    No additional platform links are available.

    + <% } %> +
    +
    + +
    +

    Linked accounts

    + <% if (!accounts.length) { %> +

    No linked accounts yet.

    + <% } else { %> +
      + <% accounts.forEach((account) => { %> +
    • + <%= account.provider %> + <%= account.display_name || account.provider_user_id %> + <% if (account.provider !== "discord") { %> +
      + +
      + <% } %> +
    • + <% }) %> +
    + <% } %> +
    + + <% if (profileSections && profileSections.length) { %> +
    +

    Personalized

    +
    + <% profileSections.forEach((section) => { %> +
    + <% if (section.label) { %> +

    <%= section.label %>

    + <% } %> + <% if (section.view) { %> + <%- include(section.view, section.locals) %> + <% } else if (section.content) { %> + <%- section.content %> + <% } %> +
    + <% }) %> +
    +
    + <% } %> +
    + + + + + +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/setup.ejs b/src/web/views/setup.ejs new file mode 100644 index 0000000..89034ac --- /dev/null +++ b/src/web/views/setup.ejs @@ -0,0 +1,20 @@ +<%- include("partials/layout-top", { title }) %> +
    +

    Initial setup

    +

    Enable the platforms you plan to use, then run each wizard to configure credentials.

    +

    Once at least one platform is configured, you can log in and manage everything from the WebUI.

    +
    + <% (platforms || []).forEach((platform) => { %> +
    +

    <%= platform.label %>

    + <% if (!platform.supported) { %> +

    Support coming soon.

    + <% } else { %> +

    Run the guided setup for <%= platform.label %>.

    + Start <%= platform.label %> setup + <% } %> +
    + <% }) %> +
    +
    +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/stats.ejs b/src/web/views/stats.ejs new file mode 100644 index 0000000..9b0ce33 --- /dev/null +++ b/src/web/views/stats.ejs @@ -0,0 +1,119 @@ +<%- include("partials/layout-top", { title }) %> +<% const statsTitle = statsOwner && !statsOwner.isSelf ? `${statsOwner.username}'s stats` : "Your stats"; %> +
    +
    +

    <%= statsTitle %>

    + <% if (compare) { %> + + <% } %> +
    +
    + +
    +
    +

    Community Interaction

    + <% if (!stats) { %> +

    No stats yet. Send a message or run a command to appear here.

    + <% } else { %> +
    +
    + Messages + <%= stats.messages %> +
    +
    + Commands + <%= stats.commands %> +
    +
    + <% } %> +
    + +
    +

    Expression Interaction

    + <% if (!expression) { %> +

    This category will appear once the Expression Interaction plugin is active.

    + <% } else { %> +
    +
    + Actions given + <%= expression.totals.given %> +
    +
    + Actions received + <%= expression.totals.received %> +
    +
    + <% } %> +
    + + <% (pluginStats || []).forEach((section) => { %> +
    +

    <%= section.title %>

    + <% if (!section.stats || !section.stats.length) { %> +

    <%= section.emptyMessage %>

    + <% } else { %> +
    + <% section.stats.forEach((stat) => { %> +
    + <%= stat.label %> + <%= stat.value %> +
    + <% }) %> +
    + <% } %> +
    + <% }) %> +
    + +<% if (compare) { %> +
    +
    +

    Compare stats

    +
    + +
    +
    +
    + + + + + + + + + + + <% compare.rows.forEach((row) => { %> + + + + + + + <% }) %> + +
    SectionStat<%= compare.leftLabel %><%= compare.rightLabel %>
    <%= row.section %><%= row.label %><%= row.left ?? '-' %><%= row.right ?? '-' %>
    +
    +
    +<% } %> +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/wizard-discord.ejs b/src/web/views/wizard-discord.ejs new file mode 100644 index 0000000..ff5f549 --- /dev/null +++ b/src/web/views/wizard-discord.ejs @@ -0,0 +1,85 @@ +<%- include("partials/layout-top", { title }) %> +
    +

    Discord setup wizard

    +

    Follow the steps below. We will verify your details before saving anything.

    +
      +
    1. Create a Discord application and bot in the Discord Developer Portal.
    2. +
    3. Copy the Client ID, Client Secret, Bot Token, and your Server (Guild) ID.
    4. +
    5. Paste them here and click Verify and save.
    6. +
    + + <% if (errors && errors.length) { %> +
    + We could not verify your Discord settings: +
      + <% errors.forEach((error) => { %> +
    • <%= error %>
    • + <% }) %> +
    +
    + <% } %> + + <% if (checks && checks.length) { %> +
    + Verification checks passed: +
      + <% checks.forEach((check) => { %> +
    • <%= check %>
    • + <% }) %> +
    +
    + <% } %> + +
    +
    + + + Find this in Discord Developer Portal → OAuth2 → General. +
    +
    + + + Create or copy it from OAuth2 → General. +
    +
    + + + Find this in the Bot tab (Reset Token if needed). +
    +
    + + + Enable Developer Mode in Discord, then right-click your server to copy the ID. +
    +
    + + +
    +
    + + +
    +
    + + + Use the same redirect URI in the Discord app settings. +
    +
    + Required OAuth scopes: identify, guilds, guilds.members.read. +
    + + +
    +
    +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/wizard-twitch.ejs b/src/web/views/wizard-twitch.ejs new file mode 100644 index 0000000..2104fcf --- /dev/null +++ b/src/web/views/wizard-twitch.ejs @@ -0,0 +1,80 @@ +<%- include("partials/layout-top", { title }) %> +
    +

    Twitch setup wizard

    +

    We will verify your Twitch settings before saving anything.

    +
      +
    1. Create a Twitch developer app and copy the Client ID and Client Secret.
    2. +
    3. Optional: generate a bot chat token and paste it with the bot username.
    4. +
    5. Click Verify and save.
    6. +
    + + <% if (errors && errors.length) { %> +
    + We could not verify your Twitch settings: +
      + <% errors.forEach((error) => { %> +
    • <%= error %>
    • + <% }) %> +
    +
    + <% } %> + + <% if (checks && checks.length) { %> +
    + Verification checks passed: +
      + <% checks.forEach((check) => { %> +
    • <%= check %>
    • + <% }) %> +
    +
    + <% } %> + +
    +
    + + + Find this in the Twitch Developer Console for your app. +
    +
    + + + Generate it in the Twitch Developer Console. +
    +
    + + + Use the same redirect URI in the Twitch app settings. +
    + +
    + + +
    +
    + + + Get a chat token from https://twitchapps.com/tmi/ +
    +
    + + + Example: cozycarnage, anotherchannel +
    + + + +
    +
    +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/wizard-youtube.ejs b/src/web/views/wizard-youtube.ejs new file mode 100644 index 0000000..56bdffd --- /dev/null +++ b/src/web/views/wizard-youtube.ejs @@ -0,0 +1,91 @@ +<%- include("partials/layout-top", { title }) %> +
    +

    YouTube setup wizard

    +

    We will verify your YouTube settings before saving anything.

    +
      +
    1. Create a Google Cloud project and enable the YouTube Data API v3.
    2. +
    3. Create OAuth credentials and add the redirect URI below.
    4. +
    5. Connect the YouTube bot account to grant chat and moderation access.
    6. +
    7. Click Verify and save.
    8. +
    + + <% if (errors && errors.length) { %> +
    + We could not verify your YouTube settings: +
      + <% errors.forEach((error) => { %> +
    • <%= error %>
    • + <% }) %> +
    +
    + <% } %> + + <% if (checks && checks.length) { %> +
    + Verification checks passed: +
      + <% checks.forEach((check) => { %> +
    • <%= check %>
    • + <% }) %> +
    +
    + <% } %> + +
    +
    + + + Copy this from Google Cloud OAuth credentials. +
    +
    + + + Use the client secret from your OAuth credentials. +
    +
    + + + Use the same redirect URI in the OAuth client settings. +
    + +
    + + <% if (botConnected) { %> + + Connected as + <%= botChannelName || "YouTube channel" %> + <% if (botChannelId) { %> + (Channel ID: <%= botChannelId %>) + <% } %> + + <% } else { %> + Not connected yet. + <% } %> +
    + +
    + +
    + + + +
    +
    +<%- include("partials/layout-bottom") %>