Initial sanitized import
39
.env.example
Normal file
@ -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
|
||||||
12
.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
node_modules/
|
||||||
|
data/
|
||||||
|
updates/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.bot details.md
|
||||||
|
*.db
|
||||||
|
*.db-*
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-*
|
||||||
|
npm-debug.log
|
||||||
BIN
Discord profile banner.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
61
README.md
Normal file
@ -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.
|
||||||
BIN
Twitch.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
171
codex-guidelines
Normal file
@ -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-id>/ (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-<topic>.zip
|
||||||
|
- Core patch update:
|
||||||
|
- Zip only changed files/folders
|
||||||
|
- Use Patch Mode in UI
|
||||||
|
- Filename: updates/lumi-update-<topic>-patch.zip (or similar)
|
||||||
|
- Plugin update:
|
||||||
|
- Zip contents of plugins/<plugin-id>/ (root = plugin folder)
|
||||||
|
- Filename: updates/lumi-plugin-<plugin-id>-vX.Y.Z.zip
|
||||||
|
- Preferred zip tool on Windows:
|
||||||
|
- tar -a -c -f <zip> -C <folder> .
|
||||||
|
|
||||||
|
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 <category> (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/<id>", 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)
|
||||||
1860
package-lock.json
generated
Normal file
24
package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
113
plugins/auto-vc/cmds.json
Normal file
@ -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 <new_name>",
|
||||||
|
"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 <user>",
|
||||||
|
"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 <user|all>",
|
||||||
|
"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 <user>",
|
||||||
|
"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 <user>",
|
||||||
|
"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 <user>",
|
||||||
|
"name": "Unban from Auto VC",
|
||||||
|
"description": "Allow a user to create Auto VC rooms again.",
|
||||||
|
"level": "mod",
|
||||||
|
"platforms": ["discord"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1456
plugins/auto-vc/index.js
Normal file
7
plugins/auto-vc/plugin.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
53
plugins/auto-vc/stats.js
Normal file
@ -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
|
||||||
|
};
|
||||||
13
plugins/auto-vc/stats.json
Normal file
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
560
plugins/auto-vc/views/auto-vc.ejs
Normal file
@ -0,0 +1,560 @@
|
|||||||
|
<section class="card">
|
||||||
|
<h1>Auto VC</h1>
|
||||||
|
<p>Automatically create temporary voice channels when members join your lobby channels. Rooms inherit lobby permissions and can be managed with <code>!vc</code> commands.</p>
|
||||||
|
<div class="callout">
|
||||||
|
<strong>Placeholders</strong>
|
||||||
|
<p>Use <code>[username]</code>, <code>[room_number]</code>, and <code>[game_name]</code> inside channel names.</p>
|
||||||
|
<p class="hint">[game_name] is pulled from the creator's Discord presence.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.lobby-permissions {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.lobby-permissions h4 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-family: "Space Grotesk", sans-serif;
|
||||||
|
}
|
||||||
|
.lobby-permissions-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.lobby-permission-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.lobby-permission-item[data-missing="true"] {
|
||||||
|
border-color: color-mix(in srgb, var(--rose) 40%, var(--border));
|
||||||
|
}
|
||||||
|
.lobby-permissions-hint {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.lobby-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.lobby-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.button.danger-hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.button.danger-hover:hover {
|
||||||
|
background: var(--rose);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.lobby-card.is-deleted {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.lobby-id-note {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.rate-limit-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
.rate-limit-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.rate-limit-row input {
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
.rate-limit-unit {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.permissions-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: "Space Grotesk", sans-serif;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.permissions-ok {
|
||||||
|
color: #2cb678;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.lobby-permissions summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.lobby-permissions summary::marker {
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
.modal.is-open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.modal-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
.modal-dialog {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
width: min(420px, calc(100% - 32px));
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Stats</h2>
|
||||||
|
<% if (!stats || !stats.length) { %>
|
||||||
|
<p>No rooms created yet.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Rooms created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% stats.forEach((row) => { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= row.label %></td>
|
||||||
|
<td><%= row.count %></td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<% if (isAdmin) { %>
|
||||||
|
<section class="card">
|
||||||
|
<h2>Lobby setup</h2>
|
||||||
|
<form method="post" action="/plugins/auto-vc/settings" class="form-grid">
|
||||||
|
<div class="card">
|
||||||
|
<h3>Rate limits</h3>
|
||||||
|
<div class="form-grid rate-limit-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Room creations per user</label>
|
||||||
|
<div class="rate-limit-row">
|
||||||
|
<input
|
||||||
|
name="rate_create_count"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value="<%= limits?.create?.max || 3 %>"
|
||||||
|
/>
|
||||||
|
<span class="rate-limit-unit">per</span>
|
||||||
|
<input
|
||||||
|
name="rate_create_window"
|
||||||
|
type="number"
|
||||||
|
min="10"
|
||||||
|
value="<%= limits?.create?.windowSeconds || 600 %>"
|
||||||
|
/>
|
||||||
|
<span class="rate-limit-unit">seconds</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Room action changes per user</label>
|
||||||
|
<div class="rate-limit-row">
|
||||||
|
<input
|
||||||
|
name="rate_action_count"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value="<%= limits?.action?.max || 8 %>"
|
||||||
|
/>
|
||||||
|
<span class="rate-limit-unit">per</span>
|
||||||
|
<input
|
||||||
|
name="rate_action_window"
|
||||||
|
type="number"
|
||||||
|
min="10"
|
||||||
|
value="<%= limits?.action?.windowSeconds || 60 %>"
|
||||||
|
/>
|
||||||
|
<span class="rate-limit-unit">seconds</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Applies to room creation and commands that update channels or permissions.</p>
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<div id="lobby-sections" class="form-grid">
|
||||||
|
<% lobbies.forEach((lobby, index) => { %>
|
||||||
|
<div class="card lobby-card" data-lobby>
|
||||||
|
<div class="lobby-header">
|
||||||
|
<h3>Lobby <%= index + 1 %></h3>
|
||||||
|
<div class="lobby-actions">
|
||||||
|
<input type="hidden" name="lobby_remove" value="<%= lobby.id %>" data-remove disabled />
|
||||||
|
<button type="button" class="button danger-hover" data-lobby-delete>Delete lobby</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="lobby_id" value="<%= lobby.id %>" />
|
||||||
|
<% const lobbyVoice = voiceChannels?.find((channel) => channel.id === lobby.lobbyChannelId); %>
|
||||||
|
<% const lobbyCategory = categoryChannels?.find((channel) => channel.id === lobby.categoryId); %>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Lobby voice channel</label>
|
||||||
|
<% if (voiceChannels && voiceChannels.length) { %>
|
||||||
|
<select name="lobby_channel_id" data-channel-select>
|
||||||
|
<% if (lobby.lobbyChannelId && !lobbyVoice) { %>
|
||||||
|
<option value="<%= lobby.lobbyChannelId %>" selected>
|
||||||
|
Unknown channel (<%= lobby.lobbyChannelId %>)
|
||||||
|
</option>
|
||||||
|
<% } %>
|
||||||
|
<option value="">Select a lobby voice channel</option>
|
||||||
|
<% voiceChannels.forEach((channel) => { %>
|
||||||
|
<option value="<%= channel.id %>" <%= channel.id === lobby.lobbyChannelId ? 'selected' : '' %>>
|
||||||
|
<%= channel.label %>
|
||||||
|
</option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
<div class="lobby-id-note">Selected ID: <span data-channel-id-display><%= lobby.lobbyChannelId || "-" %></span></div>
|
||||||
|
<% } else { %>
|
||||||
|
<input name="lobby_channel_id" value="<%= lobby.lobbyChannelId %>" placeholder="123456789012345678" />
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Target category</label>
|
||||||
|
<% if (categoryChannels && categoryChannels.length) { %>
|
||||||
|
<select name="lobby_category_id" data-category-select>
|
||||||
|
<% if (lobby.categoryId && !lobbyCategory) { %>
|
||||||
|
<option value="<%= lobby.categoryId %>" selected>
|
||||||
|
Unknown category (<%= lobby.categoryId %>)
|
||||||
|
</option>
|
||||||
|
<% } %>
|
||||||
|
<option value="">Select a category</option>
|
||||||
|
<% categoryChannels.forEach((channel) => { %>
|
||||||
|
<option value="<%= channel.id %>" <%= channel.id === lobby.categoryId ? 'selected' : '' %>>
|
||||||
|
<%= channel.label %>
|
||||||
|
</option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
<div class="lobby-id-note">Selected ID: <span data-category-id-display><%= lobby.categoryId || "-" %></span></div>
|
||||||
|
<% } else { %>
|
||||||
|
<input name="lobby_category_id" value="<%= lobby.categoryId %>" placeholder="123456789012345678" />
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Channel name template</label>
|
||||||
|
<input name="lobby_name_template" value="<%= lobby.nameTemplate %>" />
|
||||||
|
<p class="hint">Examples: <code>[username]'s room</code>, <code>[game_name] [room_number]</code></p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Empty room cleanup (seconds)</label>
|
||||||
|
<input name="lobby_empty_timeout" value="<%= lobby.emptyTimeoutSeconds %>" type="number" min="5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% if (lobby.permissions && lobby.permissions.length) { %>
|
||||||
|
<% const totalPerms = lobby.permissions.length; %>
|
||||||
|
<% const okPerms = lobby.permissions.filter((perm) => perm.granted).length; %>
|
||||||
|
<% const allOk = okPerms === totalPerms; %>
|
||||||
|
<details class="lobby-permissions" <%= allOk ? "" : "open" %>>
|
||||||
|
<summary class="permissions-summary">
|
||||||
|
Permissions Check (<%= okPerms %>/<%= totalPerms %>
|
||||||
|
<% if (allOk) { %>
|
||||||
|
<span class="permissions-ok">all ok</span>
|
||||||
|
<% } %>
|
||||||
|
)
|
||||||
|
</summary>
|
||||||
|
<div class="lobby-permissions-grid">
|
||||||
|
<% lobby.permissions.forEach((perm) => { %>
|
||||||
|
<div
|
||||||
|
class="lobby-permission-item"
|
||||||
|
data-missing="<%= perm.granted ? 'false' : 'true' %>"
|
||||||
|
title="<%= perm.granted ? '' : perm.help %>"
|
||||||
|
>
|
||||||
|
<span class="perm-toggle <%= perm.granted ? 'on' : 'off' %>" aria-hidden="true">
|
||||||
|
<span class="perm-thumb"></span>
|
||||||
|
</span>
|
||||||
|
<span class="perm-label"><%= perm.label %></span>
|
||||||
|
</div>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<p class="lobby-permissions-hint">Hover red checks to see how to fix missing permissions.</p>
|
||||||
|
</details>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<button type="button" class="button subtle" id="add-lobby">Add VC lobby</button>
|
||||||
|
<button type="submit" class="button">Save lobby settings</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (canModerate) { %>
|
||||||
|
<section class="card">
|
||||||
|
<h2>VC creation bans</h2>
|
||||||
|
<form method="post" action="/plugins/auto-vc/bans" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Ban user (mention or ID)</label>
|
||||||
|
<input name="ban_input" placeholder="@user or 123456789012345678" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Reason (optional)</label>
|
||||||
|
<input name="ban_reason" placeholder="Optional reason" />
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<button type="submit" class="button">Ban user</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Currently banned</h3>
|
||||||
|
<% if (!bans.length) { %>
|
||||||
|
<p>No banned users.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<form method="post" action="/plugins/auto-vc/unban" class="form-grid">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
<th>Remove</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% bans.forEach((ban) => { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= ban.label %></td>
|
||||||
|
<td><%= ban.reason || '-' %></td>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" name="unban_ids" value="<%= ban.discord_user_id %>" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="field full">
|
||||||
|
<button type="submit" class="button subtle">Unban selected</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<div class="modal" id="delete-lobby-modal" aria-hidden="true">
|
||||||
|
<div class="modal-backdrop" data-modal-close></div>
|
||||||
|
<div class="modal-dialog" role="dialog" aria-modal="true" aria-labelledby="delete-lobby-title">
|
||||||
|
<h3 id="delete-lobby-title">Delete lobby?</h3>
|
||||||
|
<p>This removes the lobby configuration and stops auto-creating rooms from it.</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="button subtle" data-modal-cancel>Cancel</button>
|
||||||
|
<button type="button" class="button danger" data-modal-confirm>Delete lobby</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template id="lobby-template">
|
||||||
|
<div class="card lobby-card" data-lobby>
|
||||||
|
<div class="lobby-header">
|
||||||
|
<h3>Lobby</h3>
|
||||||
|
<div class="lobby-actions">
|
||||||
|
<input type="hidden" name="lobby_remove" value="__ID__" data-remove disabled />
|
||||||
|
<button type="button" class="button danger-hover" data-lobby-delete>Delete lobby</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="lobby_id" value="__ID__" data-placeholder />
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Lobby voice channel</label>
|
||||||
|
<% if (voiceChannels && voiceChannels.length) { %>
|
||||||
|
<select name="lobby_channel_id" data-channel-select>
|
||||||
|
<option value="">Select a lobby voice channel</option>
|
||||||
|
<% voiceChannels.forEach((channel) => { %>
|
||||||
|
<option value="<%= channel.id %>"><%= channel.label %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
<div class="lobby-id-note">Selected ID: <span data-channel-id-display>-</span></div>
|
||||||
|
<% } else { %>
|
||||||
|
<input name="lobby_channel_id" placeholder="123456789012345678" />
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Target category</label>
|
||||||
|
<% if (categoryChannels && categoryChannels.length) { %>
|
||||||
|
<select name="lobby_category_id" data-category-select>
|
||||||
|
<option value="">Select a category</option>
|
||||||
|
<% categoryChannels.forEach((channel) => { %>
|
||||||
|
<option value="<%= channel.id %>"><%= channel.label %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
<div class="lobby-id-note">Selected ID: <span data-category-id-display>-</span></div>
|
||||||
|
<% } else { %>
|
||||||
|
<input name="lobby_category_id" placeholder="123456789012345678" />
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Channel name template</label>
|
||||||
|
<input name="lobby_name_template" value="[username]'s room" />
|
||||||
|
<p class="hint">Examples: <code>[username]'s room</code>, <code>[game_name] [room_number]</code></p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Empty room cleanup (seconds)</label>
|
||||||
|
<input name="lobby_empty_timeout" value="30" type="number" min="5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const addButton = document.getElementById("add-lobby");
|
||||||
|
const container = document.getElementById("lobby-sections");
|
||||||
|
const template = document.getElementById("lobby-template");
|
||||||
|
if (!addButton || !container || !template) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = document.getElementById("delete-lobby-modal");
|
||||||
|
const modalConfirm = modal?.querySelector("[data-modal-confirm]");
|
||||||
|
const modalCancel = modal?.querySelector("[data-modal-cancel]");
|
||||||
|
const modalClose = modal?.querySelector("[data-modal-close]");
|
||||||
|
let pendingDelete = null;
|
||||||
|
|
||||||
|
const generateId = () => {
|
||||||
|
if (window.crypto && window.crypto.randomUUID) {
|
||||||
|
return window.crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `lobby-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateHeaders = () => {
|
||||||
|
const cards = Array.from(container.querySelectorAll("[data-lobby]")).filter(
|
||||||
|
(card) => !card.classList.contains("is-deleted")
|
||||||
|
);
|
||||||
|
cards.forEach((card, index) => {
|
||||||
|
const heading = card.querySelector("h3");
|
||||||
|
if (heading) {
|
||||||
|
heading.textContent = `Lobby ${index + 1}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateIdDisplays = (card) => {
|
||||||
|
const channelSelect = card.querySelector("[data-channel-select]");
|
||||||
|
const channelDisplay = card.querySelector("[data-channel-id-display]");
|
||||||
|
if (channelSelect && channelDisplay) {
|
||||||
|
channelDisplay.textContent = channelSelect.value || "-";
|
||||||
|
}
|
||||||
|
const categorySelect = card.querySelector("[data-category-select]");
|
||||||
|
const categoryDisplay = card.querySelector("[data-category-id-display]");
|
||||||
|
if (categorySelect && categoryDisplay) {
|
||||||
|
categoryDisplay.textContent = categorySelect.value || "-";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const markLobbyDeleted = (card) => {
|
||||||
|
const removeInput = card.querySelector("[data-remove]");
|
||||||
|
if (removeInput) {
|
||||||
|
removeInput.disabled = false;
|
||||||
|
}
|
||||||
|
card.classList.add("is-deleted");
|
||||||
|
card.querySelectorAll("input, select, textarea").forEach((field) => {
|
||||||
|
if (field.dataset.remove !== undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
field.disabled = true;
|
||||||
|
});
|
||||||
|
updateHeaders();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openModal = (card) => {
|
||||||
|
if (!modal) {
|
||||||
|
markLobbyDeleted(card);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingDelete = card;
|
||||||
|
modal.classList.add("is-open");
|
||||||
|
modal.setAttribute("aria-hidden", "false");
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
pendingDelete = null;
|
||||||
|
modal?.classList.remove("is-open");
|
||||||
|
modal?.setAttribute("aria-hidden", "true");
|
||||||
|
};
|
||||||
|
|
||||||
|
modalConfirm?.addEventListener("click", () => {
|
||||||
|
if (pendingDelete) {
|
||||||
|
markLobbyDeleted(pendingDelete);
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
});
|
||||||
|
modalCancel?.addEventListener("click", closeModal);
|
||||||
|
modalClose?.addEventListener("click", closeModal);
|
||||||
|
|
||||||
|
const wireLobbyCard = (card) => {
|
||||||
|
const deleteButton = card.querySelector("[data-lobby-delete]");
|
||||||
|
if (deleteButton) {
|
||||||
|
deleteButton.addEventListener("click", () => openModal(card));
|
||||||
|
}
|
||||||
|
card.querySelectorAll("[data-channel-select]").forEach((select) => {
|
||||||
|
select.addEventListener("change", () => updateIdDisplays(card));
|
||||||
|
});
|
||||||
|
card.querySelectorAll("[data-category-select]").forEach((select) => {
|
||||||
|
select.addEventListener("change", () => updateIdDisplays(card));
|
||||||
|
});
|
||||||
|
updateIdDisplays(card);
|
||||||
|
};
|
||||||
|
|
||||||
|
addButton.addEventListener("click", () => {
|
||||||
|
const id = generateId();
|
||||||
|
const clone = template.content.cloneNode(true);
|
||||||
|
clone.querySelectorAll("[data-placeholder]").forEach((node) => {
|
||||||
|
node.value = node.value.replace(/__ID__/g, id);
|
||||||
|
});
|
||||||
|
clone.querySelectorAll("[data-remove]").forEach((node) => {
|
||||||
|
node.value = node.value.replace(/__ID__/g, id);
|
||||||
|
node.disabled = true;
|
||||||
|
});
|
||||||
|
const card = clone.querySelector("[data-lobby]");
|
||||||
|
container.appendChild(clone);
|
||||||
|
if (card) {
|
||||||
|
wireLobbyCard(card);
|
||||||
|
}
|
||||||
|
updateHeaders();
|
||||||
|
});
|
||||||
|
|
||||||
|
container.querySelectorAll("[data-lobby]").forEach((card) => {
|
||||||
|
wireLobbyCard(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
updateHeaders();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
120
plugins/echonomy-framework/cmds.json
Normal file
@ -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 <subcommand>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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 <user> <amount> [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 <fund> <amount>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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 <user> <amount> [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 <user> <amount> [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 <event> <user>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
2362
plugins/echonomy-framework/index.js
Normal file
7
plugins/echonomy-framework/plugin.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
68
plugins/echonomy-framework/stats.js
Normal file
@ -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
|
||||||
|
};
|
||||||
13
plugins/echonomy-framework/stats.json
Normal file
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
1
plugins/echonomy-framework/test.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
481
plugins/echonomy-framework/views/banking.ejs
Normal file
@ -0,0 +1,481 @@
|
|||||||
|
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
|
||||||
|
<style>
|
||||||
|
.banking-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
.banking-card {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.banking-label {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.banking-value {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.banking-currency {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.banking-currency img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.funds-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.fund-item {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.fund-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.fund-progress {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.uuid-chip {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.uuid-chip:hover {
|
||||||
|
border-color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.user-search {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.user-results {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.user-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.user-row.is-selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.user-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.user-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.user-pills {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.user-pill {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-3);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.user-expand {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.user-details {
|
||||||
|
display: none;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.user-row.is-open .user-details {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
.user-select {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.tx-note-details {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.tx-note-details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
.tx-note-details ul {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.tx-note-period {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h1><%= config.banking.label %></h1>
|
||||||
|
<p class="hint">Review balances, transfer funds, and track your transaction history.</p>
|
||||||
|
</div>
|
||||||
|
<div class="banking-currency">
|
||||||
|
<% if (config.currency.icon) { %>
|
||||||
|
<img src="<%= config.currency.icon %>" alt="Currency icon" />
|
||||||
|
<% } %>
|
||||||
|
<strong><%= config.currency.name %></strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Account snapshot</h2>
|
||||||
|
<div class="banking-grid">
|
||||||
|
<div class="banking-card">
|
||||||
|
<span class="banking-label">Current balance</span>
|
||||||
|
<span class="banking-value"><%= userStats.balance %></span>
|
||||||
|
</div>
|
||||||
|
<div class="banking-card">
|
||||||
|
<span class="banking-label">Total earned</span>
|
||||||
|
<span class="banking-value"><%= userStats.totalEarned %></span>
|
||||||
|
</div>
|
||||||
|
<div class="banking-card">
|
||||||
|
<span class="banking-label">Total spent</span>
|
||||||
|
<span class="banking-value"><%= userStats.totalSpent %></span>
|
||||||
|
</div>
|
||||||
|
<div class="banking-card">
|
||||||
|
<span class="banking-label">Sent to others</span>
|
||||||
|
<span class="banking-value"><%= userStats.totalSent %></span>
|
||||||
|
</div>
|
||||||
|
<div class="banking-card">
|
||||||
|
<span class="banking-label">Received from others</span>
|
||||||
|
<span class="banking-value"><%= userStats.totalReceived %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Transfer to another user</h2>
|
||||||
|
<form method="post" action="/profile/banking/transfer" class="form-grid">
|
||||||
|
<div class="field user-search">
|
||||||
|
<label>Recipient username</label>
|
||||||
|
<input name="username" id="banking-username" placeholder="Search by username or linked account" autocomplete="off" />
|
||||||
|
<div class="user-results" id="banking-results"></div>
|
||||||
|
<span class="hint">Matches show the platform(s) where the username appears. Expand to see linked accounts.</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Amount</label>
|
||||||
|
<input name="amount" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Note (optional)</label>
|
||||||
|
<input name="note" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button">Send transfer</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h2><%= config.communityFunds.plural %></h2>
|
||||||
|
<p class="hint">Support shared community goals with direct deposits.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% if (!funds.length) { %>
|
||||||
|
<p>No <%= config.communityFunds.plural.toLowerCase() %> are active right now.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="funds-grid">
|
||||||
|
<% funds.forEach((fund) => { %>
|
||||||
|
<div class="fund-item">
|
||||||
|
<div class="fund-meta">
|
||||||
|
<strong><%= fund.name %></strong>
|
||||||
|
<span class="fund-progress"><%= fund.current_amount %>/<%= fund.target_amount %></span>
|
||||||
|
</div>
|
||||||
|
<span class="hint"><%= fund.description || '' %></span>
|
||||||
|
<form method="post" action="/profile/banking/funds/<%= fund.id %>/donate" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Amount</label>
|
||||||
|
<input name="amount" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Note (optional)</label>
|
||||||
|
<input name="note" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button subtle">Donate</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h2>Transaction history</h2>
|
||||||
|
<p class="hint">All account activity with UUID records.</p>
|
||||||
|
</div>
|
||||||
|
<div class="table-tools">
|
||||||
|
<input
|
||||||
|
class="table-search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search transactions"
|
||||||
|
aria-label="Search transactions"
|
||||||
|
data-table-filter="banking-transactions"
|
||||||
|
/>
|
||||||
|
<div class="table-controls">
|
||||||
|
<label class="table-page-size">
|
||||||
|
Show
|
||||||
|
<select data-table-size="banking-transactions">
|
||||||
|
<option value="25">25</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
<option value="250">250</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table
|
||||||
|
class="table"
|
||||||
|
data-table="banking-transactions"
|
||||||
|
data-pageable="true"
|
||||||
|
data-page-size="25"
|
||||||
|
data-page-sizes="25,50,100,250"
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-sort="id">UUID</th>
|
||||||
|
<th data-sort="type">Type</th>
|
||||||
|
<th data-sort="amount">Amount</th>
|
||||||
|
<th data-sort="from">From</th>
|
||||||
|
<th data-sort="to">To</th>
|
||||||
|
<th>Note</th>
|
||||||
|
<th data-sort="date">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% transactions.forEach((tx) => { %>
|
||||||
|
<% const fromName = tx.from_name || 'System'; %>
|
||||||
|
<% const toName = tx.to_name || 'System'; %>
|
||||||
|
<tr
|
||||||
|
data-search="<%= `${tx.id} ${tx.type} ${tx.amount} ${fromName} ${toName} ${tx.note_search || tx.note || ''}` %>"
|
||||||
|
data-id="<%= tx.id %>"
|
||||||
|
data-type="<%= tx.type %>"
|
||||||
|
data-amount="<%= tx.amount %>"
|
||||||
|
data-from="<%= fromName %>"
|
||||||
|
data-to="<%= toName %>"
|
||||||
|
data-date="<%= tx.created_at %>"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="uuid-chip" data-copy="<%= tx.id %>" title="Copy UUID">
|
||||||
|
<%= tx.id %>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td><%= tx.type %></td>
|
||||||
|
<td><%= tx.amount %></td>
|
||||||
|
<td><%= fromName %></td>
|
||||||
|
<td><%= toName %></td>
|
||||||
|
<td>
|
||||||
|
<% if (tx.activity_reward) { %>
|
||||||
|
<details class="tx-note-details">
|
||||||
|
<summary><%= tx.note_display %></summary>
|
||||||
|
<% if (tx.activity_reward.hourStart && tx.activity_reward.hourEnd) { %>
|
||||||
|
<div class="tx-note-period">
|
||||||
|
<%= new Date(tx.activity_reward.hourStart).toLocaleString() %> -
|
||||||
|
<%= new Date(tx.activity_reward.hourEnd).toLocaleString() %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<ul>
|
||||||
|
<% tx.activity_reward.rewards.forEach((reward) => { %>
|
||||||
|
<li>
|
||||||
|
<%= reward.label %>: <%= reward.amount %>
|
||||||
|
<% if (reward.hits > 0) { %> (<%= reward.hits %> events)<% } %>
|
||||||
|
<% if (reward.minutes > 0) { %> (<%= reward.minutes %> min)<% } %>
|
||||||
|
</li>
|
||||||
|
<% }) %>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
<% } else { %>
|
||||||
|
<%= tx.note_display || tx.note || '-' %>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
<td><%= new Date(tx.created_at).toLocaleString() %></td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="table-pagination" data-table-pagination="banking-transactions">
|
||||||
|
<button type="button" class="button subtle" data-page-prev>Previous</button>
|
||||||
|
<span class="table-page-label" data-page-label>Page 1 of 1</span>
|
||||||
|
<button type="button" class="button subtle" data-page-next>Next</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const users = <%- JSON.stringify(userDirectory || []) %>;
|
||||||
|
const input = document.getElementById("banking-username");
|
||||||
|
const results = document.getElementById("banking-results");
|
||||||
|
if (!input || !results) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalize = (value) => (value || "").toString().toLowerCase();
|
||||||
|
|
||||||
|
const buildMatch = (user, term) => {
|
||||||
|
const internal = user.internal || "";
|
||||||
|
const internalMatch = normalize(internal).includes(term);
|
||||||
|
const identityMatches = (user.identities || []).filter((identity) =>
|
||||||
|
normalize(identity.display).includes(term)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!term) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const display =
|
||||||
|
internalMatch ? internal : identityMatches[0]?.display || internal;
|
||||||
|
const pills = [];
|
||||||
|
if (internalMatch) {
|
||||||
|
pills.push("Internal");
|
||||||
|
}
|
||||||
|
identityMatches.forEach((identity) => {
|
||||||
|
if (!pills.includes(identity.label)) {
|
||||||
|
pills.push(identity.label);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
display,
|
||||||
|
internal,
|
||||||
|
pills,
|
||||||
|
identities: user.identities || []
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderResults = (term) => {
|
||||||
|
results.innerHTML = "";
|
||||||
|
if (!term) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const matches = users
|
||||||
|
.map((user) => buildMatch(user, term))
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 8);
|
||||||
|
|
||||||
|
if (!matches.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
matches.forEach((match) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "user-row";
|
||||||
|
row.dataset.userId = match.id;
|
||||||
|
const details = match.identities
|
||||||
|
.map(
|
||||||
|
(identity) =>
|
||||||
|
`<span>${identity.label}: ${identity.display}</span>`
|
||||||
|
)
|
||||||
|
.join(" · ");
|
||||||
|
const displayText = match.display || match.internal || "";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="user-main">
|
||||||
|
<span class="user-name">${displayText}</span>
|
||||||
|
<div class="user-pills">
|
||||||
|
${match.pills.map((pill) => `<span class="user-pill">${pill}</span>`).join("")}
|
||||||
|
</div>
|
||||||
|
${
|
||||||
|
match.identities.length
|
||||||
|
? `<button type="button" class="user-expand">Linked</button>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
<span class="user-details">${details}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="user-select">Select</button>
|
||||||
|
`;
|
||||||
|
results.appendChild(row);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSelected = (value) => {
|
||||||
|
input.value = value;
|
||||||
|
results.querySelectorAll(".user-row").forEach((row) => {
|
||||||
|
row.classList.toggle(
|
||||||
|
"is-selected",
|
||||||
|
row.querySelector(".user-name")?.textContent === value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
renderResults(normalize(input.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
results.addEventListener("click", (event) => {
|
||||||
|
const row = event.target.closest(".user-row");
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.target.closest(".user-expand")) {
|
||||||
|
row.classList.toggle("is-open");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.target.closest(".user-select")) {
|
||||||
|
const name = row.querySelector(".user-name")?.textContent?.trim();
|
||||||
|
if (name) {
|
||||||
|
setSelected(name);
|
||||||
|
results.innerHTML = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<%- include("../../../src/web/views/partials/layout-bottom") %>
|
||||||
768
plugins/echonomy-framework/views/echonomy.ejs
Normal file
@ -0,0 +1,768 @@
|
|||||||
|
|
||||||
|
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
|
||||||
|
<style>
|
||||||
|
.echonomy-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
.echonomy-card {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.echonomy-label {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.echonomy-value {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.echonomy-currency {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.echonomy-currency img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.echonomy-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.echonomy-list li {
|
||||||
|
background: var(--surface-2);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.echonomy-table td small {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.response-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
}
|
||||||
|
.response-item {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.response-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.response-rows {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.response-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: 1fr 90px auto;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.response-row input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.response-row button {
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
.response-note {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.uuid-chip {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.uuid-chip:hover {
|
||||||
|
border-color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.tx-note-details {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.tx-note-details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
.tx-note-details ul {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.tx-note-period {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h1>Echonomy Framework</h1>
|
||||||
|
<p class="command-subtitle">Unified, cross-platform currency tooling and stats.</p>
|
||||||
|
<div class="echonomy-currency">
|
||||||
|
<% if (config.currency.icon) { %>
|
||||||
|
<img src="<%= config.currency.icon %>" alt="Currency icon" />
|
||||||
|
<% } %>
|
||||||
|
<strong><%= config.currency.name %></strong>
|
||||||
|
<span class="hint">(<%= config.currency.plural %>)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Overview</h2>
|
||||||
|
<div class="echonomy-grid">
|
||||||
|
<div class="echonomy-card">
|
||||||
|
<span class="echonomy-label">Your balance</span>
|
||||||
|
<span class="echonomy-value"><%= userBalance %></span>
|
||||||
|
</div>
|
||||||
|
<div class="echonomy-card">
|
||||||
|
<span class="echonomy-label">Command root</span>
|
||||||
|
<span class="echonomy-value">!<%= config.command.root %></span>
|
||||||
|
</div>
|
||||||
|
<div class="echonomy-card">
|
||||||
|
<span class="echonomy-label">Cooldown</span>
|
||||||
|
<span class="echonomy-value"><%= config.cooldownSeconds %>s</span>
|
||||||
|
</div>
|
||||||
|
<% if (isAdmin) { %>
|
||||||
|
<div class="echonomy-card">
|
||||||
|
<span class="echonomy-label">Total in circulation</span>
|
||||||
|
<span class="echonomy-value"><%= globalStats.totalBalance %></span>
|
||||||
|
</div>
|
||||||
|
<div class="echonomy-card">
|
||||||
|
<span class="echonomy-label">Total spent</span>
|
||||||
|
<span class="echonomy-value"><%= globalStats.totalSpent %></span>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<% if (isAdmin) { %>
|
||||||
|
<section class="card">
|
||||||
|
<h2>Currency settings</h2>
|
||||||
|
<form method="post" action="/plugins/echonomy-framework/settings/currency" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Currency name (singular)</label>
|
||||||
|
<input name="currency_name" value="<%= config.currency.name %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Currency name (plural)</label>
|
||||||
|
<input name="currency_name_plural" value="<%= config.currency.plural %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Command root</label>
|
||||||
|
<input name="command_root" value="<%= config.command.root %>" />
|
||||||
|
<span class="hint">Example: coins, souls, shards</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Command aliases</label>
|
||||||
|
<input name="command_aliases" value="<%= config.command.aliases.join(', ') %>" />
|
||||||
|
<span class="hint">Comma separated aliases that also trigger the root command.</span>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button">Save currency</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Currency icon</h2>
|
||||||
|
<form method="post" action="/plugins/echonomy-framework/settings/icon" enctype="multipart/form-data" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Upload PNG icon</label>
|
||||||
|
<input type="file" name="currency_icon" accept="image/png" />
|
||||||
|
<span class="hint">PNG only. Used across the WebUI.</span>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button">Upload icon</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Banking labels</h2>
|
||||||
|
<form method="post" action="/plugins/echonomy-framework/settings/banking" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Banking page label</label>
|
||||||
|
<input name="banking_label" value="<%= config.banking.label %>" />
|
||||||
|
<span class="hint">Shown on the profile page button and the banking page title.</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Enable banking page for users</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="banking_enabled"
|
||||||
|
<%= config.banking.enabled ? 'checked' : '' %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= config.banking.enabled ? 'Enabled' : 'Disabled' %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Community fund label (singular)</label>
|
||||||
|
<input name="community_fund_name" value="<%= config.communityFunds.name %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Community fund label (plural)</label>
|
||||||
|
<input name="community_fund_name_plural" value="<%= config.communityFunds.plural %>" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button">Save labels</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Platforms</h2>
|
||||||
|
<form method="post" action="/plugins/echonomy-framework/settings/platforms" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Enable on Discord</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="platform_discord"
|
||||||
|
<%= config.platforms.discord ? 'checked' : '' %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= config.platforms.discord ? 'On' : 'Off' %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Enable on Twitch</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="platform_twitch"
|
||||||
|
<%= config.platforms.twitch ? 'checked' : '' %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= config.platforms.twitch ? 'On' : 'Off' %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Enable on YouTube</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="platform_youtube"
|
||||||
|
<%= config.platforms.youtube ? 'checked' : '' %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= config.platforms.youtube ? 'On' : 'Off' %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button">Save platform access</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Currency earning rules</h2>
|
||||||
|
<form method="post" action="/plugins/echonomy-framework/settings/earn" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Discord message rewards</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="earn_discord_message_enabled"
|
||||||
|
<%= config.earn.discordMessage.enabled ? 'checked' : '' %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= config.earn.discordMessage.enabled ? 'On' : 'Off' %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Discord message amount</label>
|
||||||
|
<input name="earn_discord_message_amount" value="<%= config.earn.discordMessage.amount %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Discord message cooldown (seconds)</label>
|
||||||
|
<input name="earn_discord_message_cooldown" value="<%= config.earn.discordMessage.cooldown %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Twitch chat rewards</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="earn_twitch_message_enabled"
|
||||||
|
<%= config.earn.twitchMessage.enabled ? 'checked' : '' %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= config.earn.twitchMessage.enabled ? 'On' : 'Off' %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Twitch message amount</label>
|
||||||
|
<input name="earn_twitch_message_amount" value="<%= config.earn.twitchMessage.amount %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Twitch message cooldown (seconds)</label>
|
||||||
|
<input name="earn_twitch_message_cooldown" value="<%= config.earn.twitchMessage.cooldown %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Discord voice presence rewards</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="earn_discord_voice_enabled"
|
||||||
|
<%= config.earn.discordVoice.enabled ? 'checked' : '' %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= config.earn.discordVoice.enabled ? 'On' : 'Off' %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Voice reward per tick</label>
|
||||||
|
<input name="earn_discord_voice_amount_per_min" value="<%= config.earn.discordVoice.amountPerMin %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Voice tick minutes</label>
|
||||||
|
<input name="earn_discord_voice_tick_minutes" value="<%= config.earn.discordVoice.tickMinutes %>" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button">Save earning rules</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Monetization tiers</h2>
|
||||||
|
<form method="post" action="/plugins/echonomy-framework/settings/tiers" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Discord server booster multiplier</label>
|
||||||
|
<input name="tier_discord_booster_multiplier" value="<%= config.tiers.discordBooster %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Twitch subscriber multiplier</label>
|
||||||
|
<input name="tier_twitch_sub_multiplier" value="<%= config.tiers.twitchSub %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Twitch moderator multiplier</label>
|
||||||
|
<input name="tier_twitch_mod_multiplier" value="<%= config.tiers.twitchMod %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Twitch VIP multiplier</label>
|
||||||
|
<input name="tier_twitch_vip_multiplier" value="<%= config.tiers.twitchVip %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Twitch broadcaster multiplier</label>
|
||||||
|
<input name="tier_twitch_broadcaster_multiplier" value="<%= config.tiers.twitchBroadcaster %>" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button">Save multipliers</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2><%= config.communityFunds.plural %></h2>
|
||||||
|
<% if (!funds.length) { %>
|
||||||
|
<p>No <%= config.communityFunds.plural.toLowerCase() %> configured yet.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<ul class="echonomy-list">
|
||||||
|
<% funds.forEach((fund) => { %>
|
||||||
|
<li>
|
||||||
|
<span><strong><%= fund.name %></strong> - <%= fund.current_amount %>/<%= fund.target_amount %></span>
|
||||||
|
<span class="hint"><%= fund.description || '' %></span>
|
||||||
|
</li>
|
||||||
|
<% }) %>
|
||||||
|
</ul>
|
||||||
|
<% } %>
|
||||||
|
<% if (isAdmin) { %>
|
||||||
|
<h3>Create <%= config.communityFunds.name %></h3>
|
||||||
|
<form method="post" action="/plugins/echonomy-framework/funds/create" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Name</label>
|
||||||
|
<input name="name" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Description</label>
|
||||||
|
<input name="description" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Target amount</label>
|
||||||
|
<input name="target_amount" value="0" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button">Create fund</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h3>Update <%= config.communityFunds.plural %></h3>
|
||||||
|
<% funds.forEach((fund) => { %>
|
||||||
|
<form method="post" action="/plugins/echonomy-framework/funds/<%= fund.id %>/update" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Name</label>
|
||||||
|
<input name="name" value="<%= fund.name %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Description</label>
|
||||||
|
<input name="description" value="<%= fund.description || '' %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Target</label>
|
||||||
|
<input name="target_amount" value="<%= fund.target_amount %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Status</label>
|
||||||
|
<input name="status" value="<%= fund.status %>" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button subtle">Update fund</button>
|
||||||
|
</form>
|
||||||
|
<% }) %>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (isAdmin) { %>
|
||||||
|
<section class="card">
|
||||||
|
<h2>Event rewards</h2>
|
||||||
|
<% if (!events.length) { %>
|
||||||
|
<p>No custom events configured yet.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<ul class="echonomy-list">
|
||||||
|
<% events.forEach((event) => { %>
|
||||||
|
<li>
|
||||||
|
<span><strong><%= event.name %></strong> (<%= event.amount %>)</span>
|
||||||
|
<form method="post" action="/plugins/echonomy-framework/events/<%= event.id %>/delete">
|
||||||
|
<button type="submit" class="button subtle">Delete</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
<% }) %>
|
||||||
|
</ul>
|
||||||
|
<% } %>
|
||||||
|
<form method="post" action="/plugins/echonomy-framework/events/create" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Event name</label>
|
||||||
|
<input name="name" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Reward amount</label>
|
||||||
|
<input name="amount" value="0" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button">Add event</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h2>Response templates</h2>
|
||||||
|
<p class="hint">Customize bot replies. Tokens: {amount_text}, {balance_text}, {target}, {fund}, {lines}, {cooldown}, {usage}, {help}.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="response-grid">
|
||||||
|
<% responses.forEach((response) => { %>
|
||||||
|
<div class="response-item">
|
||||||
|
<strong><%= response.label %></strong>
|
||||||
|
<div class="response-preview">
|
||||||
|
<% response.replies.slice(0, 2).forEach((reply) => { %>
|
||||||
|
<span>• <%= reply.text %></span>
|
||||||
|
<% }) %>
|
||||||
|
<% if (response.replies.length > 2) { %>
|
||||||
|
<span>…and <%= response.replies.length - 2 %> more</span>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="button subtle" data-response-open="<%= response.key %>">Edit responses</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" data-response-modal="<%= response.key %>" aria-hidden="true">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Edit: <%= response.label %></h3>
|
||||||
|
<button type="button" class="button subtle" data-modal-close>Close</button>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/plugins/echonomy-framework/settings/responses" data-response-form>
|
||||||
|
<input type="hidden" name="response_key" value="<%= response.key %>" />
|
||||||
|
<div class="field">
|
||||||
|
<label>Selection mode</label>
|
||||||
|
<select name="response_mode" data-response-mode>
|
||||||
|
<option value="random" <%= response.mode === 'random' ? 'selected' : '' %>>Random</option>
|
||||||
|
<option value="weighted" <%= response.mode === 'weighted' ? 'selected' : '' %>>Weighted</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="response-rows" data-response-rows>
|
||||||
|
<% response.replies.forEach((reply) => { %>
|
||||||
|
<div class="response-row" data-response-row>
|
||||||
|
<input name="response_text" value="<%= reply.text %>" />
|
||||||
|
<input name="response_weight" value="<%= reply.weight || 1 %>" />
|
||||||
|
<button type="button" class="button subtle" data-response-remove>Remove</button>
|
||||||
|
</div>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="button subtle" data-response-add>Add response</button>
|
||||||
|
<div class="response-note">Weights are used only when "Weighted" is selected.</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="submit" class="button">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (isMod) { %>
|
||||||
|
<section class="card">
|
||||||
|
<h2>Adjust user balance</h2>
|
||||||
|
<form method="post" action="/plugins/echonomy-framework/accounts/adjust" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Username</label>
|
||||||
|
<input name="username" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Amount (use negative to remove)</label>
|
||||||
|
<input name="amount" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Note (optional)</label>
|
||||||
|
<input name="note" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button">Apply adjustment</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<% } %>
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h2>Top balances</h2>
|
||||||
|
<p class="hint">Snapshot of the richest accounts.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% if (!topBalances.length) { %>
|
||||||
|
<p>No balances yet.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Balance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% topBalances.forEach((entry) => { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= entry.username %></td>
|
||||||
|
<td><%= entry.balance %></td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h2>Transaction history</h2>
|
||||||
|
<p class="hint">Every change is logged with a UUID.</p>
|
||||||
|
</div>
|
||||||
|
<div class="table-tools">
|
||||||
|
<input
|
||||||
|
class="table-search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search transactions"
|
||||||
|
aria-label="Search transactions"
|
||||||
|
data-table-filter="echonomy-transactions"
|
||||||
|
/>
|
||||||
|
<div class="table-controls">
|
||||||
|
<label class="table-page-size">
|
||||||
|
Show
|
||||||
|
<select data-table-size="echonomy-transactions">
|
||||||
|
<option value="25">25</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
<option value="250">250</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table
|
||||||
|
class="table echonomy-table"
|
||||||
|
data-table="echonomy-transactions"
|
||||||
|
data-pageable="true"
|
||||||
|
data-page-size="25"
|
||||||
|
data-page-sizes="25,50,100,250"
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-sort="id">UUID</th>
|
||||||
|
<th data-sort="type">Type</th>
|
||||||
|
<th data-sort="amount">Amount</th>
|
||||||
|
<th data-sort="from">From</th>
|
||||||
|
<th data-sort="to">To</th>
|
||||||
|
<th>Note</th>
|
||||||
|
<th data-sort="date">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% transactions.forEach((tx) => { %>
|
||||||
|
<% const fromName = tx.from_name || 'System'; %>
|
||||||
|
<% const toName = tx.to_name || 'System'; %>
|
||||||
|
<tr
|
||||||
|
data-search="<%= `${tx.id} ${tx.type} ${tx.amount} ${fromName} ${toName} ${tx.note_search || tx.note || ''}` %>"
|
||||||
|
data-id="<%= tx.id %>"
|
||||||
|
data-type="<%= tx.type %>"
|
||||||
|
data-amount="<%= tx.amount %>"
|
||||||
|
data-from="<%= fromName %>"
|
||||||
|
data-to="<%= toName %>"
|
||||||
|
data-date="<%= tx.created_at %>"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="uuid-chip" data-copy="<%= tx.id %>" title="Copy UUID">
|
||||||
|
<%= tx.id %>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td><%= tx.type %></td>
|
||||||
|
<td><%= tx.amount %></td>
|
||||||
|
<td><%= fromName %></td>
|
||||||
|
<td><%= toName %></td>
|
||||||
|
<td>
|
||||||
|
<% if (tx.activity_reward) { %>
|
||||||
|
<details class="tx-note-details">
|
||||||
|
<summary><%= tx.note_display %></summary>
|
||||||
|
<% if (tx.activity_reward.hourStart && tx.activity_reward.hourEnd) { %>
|
||||||
|
<div class="tx-note-period">
|
||||||
|
<%= new Date(tx.activity_reward.hourStart).toLocaleString() %> -
|
||||||
|
<%= new Date(tx.activity_reward.hourEnd).toLocaleString() %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<ul>
|
||||||
|
<% tx.activity_reward.rewards.forEach((reward) => { %>
|
||||||
|
<li>
|
||||||
|
<%= reward.label %>: <%= reward.amount %>
|
||||||
|
<% if (reward.hits > 0) { %> (<%= reward.hits %> events)<% } %>
|
||||||
|
<% if (reward.minutes > 0) { %> (<%= reward.minutes %> min)<% } %>
|
||||||
|
</li>
|
||||||
|
<% }) %>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
<% } else { %>
|
||||||
|
<%= tx.note_display || tx.note || '-' %>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
<td><%= new Date(tx.created_at).toLocaleString() %></td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="table-pagination" data-table-pagination="echonomy-transactions">
|
||||||
|
<button type="button" class="button subtle" data-page-prev>Previous</button>
|
||||||
|
<span class="table-page-label" data-page-label>Page 1 of 1</span>
|
||||||
|
<button type="button" class="button subtle" data-page-next>Next</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const openButtons = document.querySelectorAll("[data-response-open]");
|
||||||
|
const modals = document.querySelectorAll("[data-response-modal]");
|
||||||
|
|
||||||
|
const openModal = (key) => {
|
||||||
|
const modal = document.querySelector(`[data-response-modal="${key}"]`);
|
||||||
|
if (!modal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modal.classList.add("is-open");
|
||||||
|
modal.setAttribute("aria-hidden", "false");
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = (modal) => {
|
||||||
|
if (!modal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modal.classList.remove("is-open");
|
||||||
|
modal.setAttribute("aria-hidden", "true");
|
||||||
|
};
|
||||||
|
|
||||||
|
openButtons.forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
openModal(button.getAttribute("data-response-open"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modals.forEach((modal) => {
|
||||||
|
modal.querySelectorAll("[data-modal-close]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => closeModal(modal));
|
||||||
|
});
|
||||||
|
modal.addEventListener("click", (event) => {
|
||||||
|
if (event.target === modal) {
|
||||||
|
closeModal(modal);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.addEventListener("click", (event) => {
|
||||||
|
const addButton = event.target.closest("[data-response-add]");
|
||||||
|
if (addButton) {
|
||||||
|
const rows = modal.querySelector("[data-response-rows]");
|
||||||
|
if (!rows) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "response-row";
|
||||||
|
row.setAttribute("data-response-row", "true");
|
||||||
|
row.innerHTML = `
|
||||||
|
<input name="response_text" value="" />
|
||||||
|
<input name="response_weight" value="1" />
|
||||||
|
<button type="button" class="button subtle" data-response-remove>Remove</button>
|
||||||
|
`;
|
||||||
|
rows.appendChild(row);
|
||||||
|
}
|
||||||
|
const removeButton = event.target.closest("[data-response-remove]");
|
||||||
|
if (removeButton) {
|
||||||
|
const row = removeButton.closest("[data-response-row]");
|
||||||
|
if (row) {
|
||||||
|
row.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key !== "Escape") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modals.forEach((modal) => {
|
||||||
|
if (modal.classList.contains("is-open")) {
|
||||||
|
closeModal(modal);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<%- include("../../../src/web/views/partials/layout-bottom") %>
|
||||||
36
plugins/echonomy-games/cmds.json
Normal file
@ -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 <amount> | 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 <amount>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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 <amount>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1218
plugins/echonomy-games/index.js
Normal file
7
plugins/echonomy-games/plugin.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
608
plugins/echonomy-games/views/games.ejs
Normal file
@ -0,0 +1,608 @@
|
|||||||
|
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
|
||||||
|
<style>
|
||||||
|
.game-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.game-row {
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.game-row summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.game-row summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.game-row__summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 1.2fr) auto minmax(160px, 1fr) auto;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.game-row__title h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
.game-row__status,
|
||||||
|
.game-row__platforms {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.status-pill,
|
||||||
|
.platform-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.status-pill.on {
|
||||||
|
color: #60d394;
|
||||||
|
background: rgba(96, 211, 148, 0.15);
|
||||||
|
border-color: rgba(96, 211, 148, 0.4);
|
||||||
|
}
|
||||||
|
.status-pill.off {
|
||||||
|
color: #ff7a7a;
|
||||||
|
background: rgba(255, 122, 122, 0.15);
|
||||||
|
border-color: rgba(255, 122, 122, 0.4);
|
||||||
|
}
|
||||||
|
.platform-pill {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--surface-3);
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
.platform-pill.on {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.platform-pill.discord.on {
|
||||||
|
color: #7c8dff;
|
||||||
|
border-color: rgba(124, 141, 255, 0.4);
|
||||||
|
background: rgba(124, 141, 255, 0.12);
|
||||||
|
}
|
||||||
|
.platform-pill.twitch.on {
|
||||||
|
color: #b88cff;
|
||||||
|
border-color: rgba(184, 140, 255, 0.4);
|
||||||
|
background: rgba(184, 140, 255, 0.12);
|
||||||
|
}
|
||||||
|
.platform-pill.youtube.on {
|
||||||
|
color: #ff6d6d;
|
||||||
|
border-color: rgba(255, 109, 109, 0.4);
|
||||||
|
background: rgba(255, 109, 109, 0.12);
|
||||||
|
}
|
||||||
|
.game-row__chevron {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--surface-3);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--muted);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.game-row[open] .game-row__chevron {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.game-row__body {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0 16px;
|
||||||
|
transition: max-height 0.35s ease, opacity 0.2s ease, padding 0.3s ease;
|
||||||
|
}
|
||||||
|
.game-row[open] .game-row__body {
|
||||||
|
max-height: 12000px;
|
||||||
|
opacity: 1;
|
||||||
|
padding: 0 16px 16px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.game-card {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.game-card h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
font-family: "Space Grotesk", sans-serif;
|
||||||
|
}
|
||||||
|
.game-row__footer {
|
||||||
|
padding: 12px 16px 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.game-footer {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.game-footer span {
|
||||||
|
background: var(--surface-3);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.responses-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.responses-grid .button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.reply-body {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.response-field textarea {
|
||||||
|
min-height: 120px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.reply-toggle {
|
||||||
|
margin-top: 16px;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
.reply-toggle summary {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.reply-toggle summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.reply-toggle summary::after {
|
||||||
|
content: "+";
|
||||||
|
margin-left: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.reply-toggle[open] summary::after {
|
||||||
|
content: "–";
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h1>Echonomy Games</h1>
|
||||||
|
<p class="command-subtitle">Mini-games that spend and reward coins via the Echonomy framework.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge <%= frameworkReady ? 'discord' : 'youtube' %>">
|
||||||
|
<%= frameworkReady ? 'Framework connected' : 'Framework missing' %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<% const renderLastPlayed = (entry) => {
|
||||||
|
if (!entry || entry.lastPlayedLabel === "Never") {
|
||||||
|
return "Never";
|
||||||
|
}
|
||||||
|
const user = entry.lastPlayedUser ? ` (${entry.lastPlayedUser})` : "";
|
||||||
|
return `${entry.lastPlayedLabel}${user}`;
|
||||||
|
}; %>
|
||||||
|
|
||||||
|
<div class="game-list">
|
||||||
|
<details class="game-row">
|
||||||
|
<summary class="game-row__summary">
|
||||||
|
<div class="game-row__title">
|
||||||
|
<h2><%= config.hotpotato.name %></h2>
|
||||||
|
</div>
|
||||||
|
<div class="game-row__status">
|
||||||
|
<span class="status-pill <%= config.hotpotato.enabled ? 'on' : 'off' %>">
|
||||||
|
<%= config.hotpotato.enabled ? 'Enabled' : 'Disabled' %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="game-row__platforms">
|
||||||
|
<span class="platform-pill discord <%= config.hotpotato.platforms.discord ? 'on' : 'off' %>">Discord</span>
|
||||||
|
<span class="platform-pill twitch <%= config.hotpotato.platforms.twitch ? 'on' : 'off' %>">Twitch</span>
|
||||||
|
<span class="platform-pill youtube <%= config.hotpotato.platforms.youtube ? 'on' : 'off' %>">YouTube</span>
|
||||||
|
</div>
|
||||||
|
<div class="game-row__chevron">›</div>
|
||||||
|
</summary>
|
||||||
|
<div class="game-row__body">
|
||||||
|
<section class="game-card">
|
||||||
|
<form method="post" action="/plugins/echonomy-games/settings/hotpotato" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Enable Hot Potato</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="hotpotato_enabled"
|
||||||
|
<%= config.hotpotato.enabled ? 'checked' : '' %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= config.hotpotato.enabled ? 'Enabled' : 'Disabled' %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Display name</label>
|
||||||
|
<input name="hotpotato_name" value="<%= config.hotpotato.name %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Command trigger</label>
|
||||||
|
<input name="hotpotato_trigger" value="<%= config.hotpotato.trigger %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Command aliases (comma or space separated)</label>
|
||||||
|
<input name="hotpotato_aliases" value="<%= config.hotpotato.aliases.join(', ') %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Minimum entry cost</label>
|
||||||
|
<input name="hotpotato_min_cost" value="<%= config.hotpotato.minCost %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Maximum entry cost</label>
|
||||||
|
<input name="hotpotato_max_cost" value="<%= config.hotpotato.maxCost %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Toss timer min (seconds)</label>
|
||||||
|
<input name="hotpotato_toss_min" value="<%= config.hotpotato.tossMin %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Toss timer max (seconds)</label>
|
||||||
|
<input name="hotpotato_toss_max" value="<%= config.hotpotato.tossMax %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Loss multiplier</label>
|
||||||
|
<input name="hotpotato_loss_multiplier" value="<%= config.hotpotato.lossMultiplier %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Loss additive</label>
|
||||||
|
<input name="hotpotato_loss_additive" value="<%= config.hotpotato.lossAdditive %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Presence window (seconds)</label>
|
||||||
|
<input name="hotpotato_presence_window" value="<%= config.hotpotato.presenceWindow %>" />
|
||||||
|
<span class="hint">Active users are pulled from recent chatters on the same platform.</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Discord</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="hotpotato_platform_discord"
|
||||||
|
<%= config.hotpotato.platforms.discord ? 'checked' : '' %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= config.hotpotato.platforms.discord ? 'On' : 'Off' %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Twitch</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="hotpotato_platform_twitch"
|
||||||
|
<%= config.hotpotato.platforms.twitch ? 'checked' : '' %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= config.hotpotato.platforms.twitch ? 'On' : 'Off' %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>YouTube</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="hotpotato_platform_youtube"
|
||||||
|
<%= config.hotpotato.platforms.youtube ? 'checked' : '' %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= config.hotpotato.platforms.youtube ? 'On' : 'Off' %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button">Save Hot Potato</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<details class="reply-toggle">
|
||||||
|
<summary>Edit replies</summary>
|
||||||
|
<div class="reply-body">
|
||||||
|
<p class="hint">One reply per line. Tokens: {user}, {target}, {amount}, {payout}, {seconds}, {min}, {max}, {game}, {holder}, {loss}, {winners}, {trigger}.</p>
|
||||||
|
<form method="post" action="/plugins/echonomy-games/settings/responses" class="form-grid responses-grid">
|
||||||
|
<% responsesByGame.hotpotato.forEach((response) => { %>
|
||||||
|
<div class="field response-field">
|
||||||
|
<label><%= response.label %></label>
|
||||||
|
<textarea name="response_<%= response.key %>"><%= response.lines.join('\n') %></textarea>
|
||||||
|
</div>
|
||||||
|
<% }) %>
|
||||||
|
<div class="field full">
|
||||||
|
<button type="submit" class="button">Save replies</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="game-row__footer">
|
||||||
|
<div class="game-footer">
|
||||||
|
<span>Last played: <%= renderLastPlayed(stats.hotpotato) %></span>
|
||||||
|
<span>Times played: <%= stats.hotpotato.plays %></span>
|
||||||
|
<span><%= currencyLabel %> lost: <%= stats.hotpotato.coinsLost %></span>
|
||||||
|
<span><%= currencyLabel %> won: <%= stats.hotpotato.coinsWon %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="game-row">
|
||||||
|
<summary class="game-row__summary">
|
||||||
|
<div class="game-row__title">
|
||||||
|
<h2><%= config.coinflip.name %></h2>
|
||||||
|
</div>
|
||||||
|
<div class="game-row__status">
|
||||||
|
<span class="status-pill <%= config.coinflip.enabled ? 'on' : 'off' %>">
|
||||||
|
<%= config.coinflip.enabled ? 'Enabled' : 'Disabled' %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="game-row__platforms">
|
||||||
|
<span class="platform-pill discord <%= config.coinflip.platforms.discord ? 'on' : 'off' %>">Discord</span>
|
||||||
|
<span class="platform-pill twitch <%= config.coinflip.platforms.twitch ? 'on' : 'off' %>">Twitch</span>
|
||||||
|
<span class="platform-pill youtube <%= config.coinflip.platforms.youtube ? 'on' : 'off' %>">YouTube</span>
|
||||||
|
</div>
|
||||||
|
<div class="game-row__chevron">›</div>
|
||||||
|
</summary>
|
||||||
|
<div class="game-row__body">
|
||||||
|
<section class="game-card">
|
||||||
|
<form method="post" action="/plugins/echonomy-games/settings/coinflip" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Enable Coinflip</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="coinflip_enabled"
|
||||||
|
<%= config.coinflip.enabled ? 'checked' : '' %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= config.coinflip.enabled ? 'Enabled' : 'Disabled' %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Display name</label>
|
||||||
|
<input name="coinflip_name" value="<%= config.coinflip.name %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Command trigger</label>
|
||||||
|
<input name="coinflip_trigger" value="<%= config.coinflip.trigger %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Command aliases (comma or space separated)</label>
|
||||||
|
<input name="coinflip_aliases" value="<%= config.coinflip.aliases.join(', ') %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Minimum bet</label>
|
||||||
|
<input name="coinflip_min_bet" value="<%= config.coinflip.minBet %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Maximum bet</label>
|
||||||
|
<input name="coinflip_max_bet" value="<%= config.coinflip.maxBet %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Payout multiplier</label>
|
||||||
|
<input name="coinflip_multiplier" value="<%= config.coinflip.multiplier %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Cooldown (seconds)</label>
|
||||||
|
<input name="coinflip_cooldown" value="<%= config.coinflip.cooldown %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Discord</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="coinflip_platform_discord"
|
||||||
|
<%= config.coinflip.platforms.discord ? 'checked' : '' %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= config.coinflip.platforms.discord ? 'On' : 'Off' %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Twitch</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="coinflip_platform_twitch"
|
||||||
|
<%= config.coinflip.platforms.twitch ? 'checked' : '' %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= config.coinflip.platforms.twitch ? 'On' : 'Off' %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>YouTube</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="coinflip_platform_youtube"
|
||||||
|
<%= config.coinflip.platforms.youtube ? 'checked' : '' %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= config.coinflip.platforms.youtube ? 'On' : 'Off' %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button">Save Coinflip</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<details class="reply-toggle">
|
||||||
|
<summary>Edit replies</summary>
|
||||||
|
<div class="reply-body">
|
||||||
|
<p class="hint">One reply per line. Tokens: {user}, {amount}, {payout}, {min}, {max}, {seconds}.</p>
|
||||||
|
<form method="post" action="/plugins/echonomy-games/settings/responses" class="form-grid responses-grid">
|
||||||
|
<% responsesByGame.coinflip.forEach((response) => { %>
|
||||||
|
<div class="field response-field">
|
||||||
|
<label><%= response.label %></label>
|
||||||
|
<textarea name="response_<%= response.key %>"><%= response.lines.join('\n') %></textarea>
|
||||||
|
</div>
|
||||||
|
<% }) %>
|
||||||
|
<div class="field full">
|
||||||
|
<button type="submit" class="button">Save replies</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="game-row__footer">
|
||||||
|
<div class="game-footer">
|
||||||
|
<span>Last played: <%= renderLastPlayed(stats.coinflip) %></span>
|
||||||
|
<span>Times played: <%= stats.coinflip.plays %></span>
|
||||||
|
<span><%= currencyLabel %> lost: <%= stats.coinflip.coinsLost %></span>
|
||||||
|
<span><%= currencyLabel %> won: <%= stats.coinflip.coinsWon %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="game-row">
|
||||||
|
<summary class="game-row__summary">
|
||||||
|
<div class="game-row__title">
|
||||||
|
<h2><%= config.mystery.name %></h2>
|
||||||
|
</div>
|
||||||
|
<div class="game-row__status">
|
||||||
|
<span class="status-pill <%= config.mystery.enabled ? 'on' : 'off' %>">
|
||||||
|
<%= config.mystery.enabled ? 'Enabled' : 'Disabled' %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="game-row__platforms">
|
||||||
|
<span class="platform-pill discord <%= config.mystery.platforms.discord ? 'on' : 'off' %>">Discord</span>
|
||||||
|
<span class="platform-pill twitch <%= config.mystery.platforms.twitch ? 'on' : 'off' %>">Twitch</span>
|
||||||
|
<span class="platform-pill youtube <%= config.mystery.platforms.youtube ? 'on' : 'off' %>">YouTube</span>
|
||||||
|
</div>
|
||||||
|
<div class="game-row__chevron">›</div>
|
||||||
|
</summary>
|
||||||
|
<div class="game-row__body">
|
||||||
|
<section class="game-card">
|
||||||
|
<form method="post" action="/plugins/echonomy-games/settings/mystery" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Enable Mystery Box</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="mystery_enabled"
|
||||||
|
<%= config.mystery.enabled ? 'checked' : '' %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= config.mystery.enabled ? 'Enabled' : 'Disabled' %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Display name</label>
|
||||||
|
<input name="mystery_name" value="<%= config.mystery.name %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Command trigger</label>
|
||||||
|
<input name="mystery_trigger" value="<%= config.mystery.trigger %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Command aliases (comma or space separated)</label>
|
||||||
|
<input name="mystery_aliases" value="<%= config.mystery.aliases.join(', ') %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Minimum bet</label>
|
||||||
|
<input name="mystery_min_bet" value="<%= config.mystery.minBet %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Maximum bet</label>
|
||||||
|
<input name="mystery_max_bet" value="<%= config.mystery.maxBet %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Payout multiplier</label>
|
||||||
|
<input name="mystery_multiplier" value="<%= config.mystery.multiplier %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Cooldown (seconds)</label>
|
||||||
|
<input name="mystery_cooldown" value="<%= config.mystery.cooldown %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Discord</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="mystery_platform_discord"
|
||||||
|
<%= config.mystery.platforms.discord ? 'checked' : '' %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= config.mystery.platforms.discord ? 'On' : 'Off' %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Twitch</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="mystery_platform_twitch"
|
||||||
|
<%= config.mystery.platforms.twitch ? 'checked' : '' %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= config.mystery.platforms.twitch ? 'On' : 'Off' %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>YouTube</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="mystery_platform_youtube"
|
||||||
|
<%= config.mystery.platforms.youtube ? 'checked' : '' %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= config.mystery.platforms.youtube ? 'On' : 'Off' %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button">Save Mystery Box</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<details class="reply-toggle">
|
||||||
|
<summary>Edit replies</summary>
|
||||||
|
<div class="reply-body">
|
||||||
|
<p class="hint">One reply per line. Tokens: {user}, {amount}, {payout}, {min}, {max}, {seconds}.</p>
|
||||||
|
<form method="post" action="/plugins/echonomy-games/settings/responses" class="form-grid responses-grid">
|
||||||
|
<% responsesByGame.mystery.forEach((response) => { %>
|
||||||
|
<div class="field response-field">
|
||||||
|
<label><%= response.label %></label>
|
||||||
|
<textarea name="response_<%= response.key %>"><%= response.lines.join('\n') %></textarea>
|
||||||
|
</div>
|
||||||
|
<% }) %>
|
||||||
|
<div class="field full">
|
||||||
|
<button type="submit" class="button">Save replies</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="game-row__footer">
|
||||||
|
<div class="game-footer">
|
||||||
|
<span>Last played: <%= renderLastPlayed(stats.mystery) %></span>
|
||||||
|
<span>Times played: <%= stats.mystery.plays %></span>
|
||||||
|
<span><%= currencyLabel %> lost: <%= stats.mystery.coinsLost %></span>
|
||||||
|
<span><%= currencyLabel %> won: <%= stats.mystery.coinsWon %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%- include("../../../src/web/views/partials/layout-bottom") %>
|
||||||
9
plugins/expression-interaction/cmds.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"pluginId": "expression-interaction",
|
||||||
|
"pluginName": "Expression Interaction",
|
||||||
|
"platformKeys": {
|
||||||
|
"discord": "platform_discord",
|
||||||
|
"twitch": "platform_twitch"
|
||||||
|
},
|
||||||
|
"commands": []
|
||||||
|
}
|
||||||
931
plugins/expression-interaction/index.js
Normal file
@ -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} <user>`,
|
||||||
|
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;
|
||||||
|
}
|
||||||
7
plugins/expression-interaction/plugin.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
259
plugins/expression-interaction/views/expression.ejs
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
|
||||||
|
<section class="card">
|
||||||
|
<h1>Expression Interaction</h1>
|
||||||
|
<p>Roleplay friendly interactions from Discord or Twitch with quick commands.</p>
|
||||||
|
<p class="hint">
|
||||||
|
Commands:
|
||||||
|
<% const enabledActions = actions.filter((action) => action.enabled && !action.archived); %>
|
||||||
|
<% if (!enabledActions.length) { %>
|
||||||
|
None enabled yet.
|
||||||
|
<% } else { %>
|
||||||
|
<%= enabledActions.map((action) => `!${action.command}`).join(", ") %>
|
||||||
|
<% } %>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Your stats</h2>
|
||||||
|
<% if (!stats) { %>
|
||||||
|
<p>Sign in to see how many actions you have given or received.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Given</span>
|
||||||
|
<span class="stat-value"><%= stats.totals.given %></span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Received</span>
|
||||||
|
<span class="stat-value"><%= stats.totals.received %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Given</th>
|
||||||
|
<th>Received</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% const statActions = actions.filter((action) => !action.archived); %>
|
||||||
|
<% statActions.forEach((action) => { %>
|
||||||
|
<% const row = stats.byAction[action.id] || { given_count: 0, received_count: 0 }; %>
|
||||||
|
<tr>
|
||||||
|
<td><%= action.command %></td>
|
||||||
|
<td><%= row.given_count %></td>
|
||||||
|
<td><%= row.received_count %></td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Global stats</h2>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Total interactions</span>
|
||||||
|
<span class="stat-value"><%= globalStats.total %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% if (!globalStats.byAction.length) { %>
|
||||||
|
<p>No interactions recorded yet.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% globalStats.byAction.forEach((row) => { %>
|
||||||
|
<% const action = actions.find((item) => item.id === row.action); %>
|
||||||
|
<tr>
|
||||||
|
<td><%= action ? action.command : row.action %></td>
|
||||||
|
<td><%= row.count %></td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<% if (isAdmin) { %>
|
||||||
|
<section class="card">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<% if (conflicts && conflicts.length) { %>
|
||||||
|
<div class="flash error">
|
||||||
|
Conflicting command names: <%= conflicts.join(", ") %>. Rename the duplicates.
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<form method="post" action="/plugins/expression-interaction/settings" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Enable on Discord</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="platform_discord"
|
||||||
|
<%= platforms.discord ? 'checked' : '' %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= platforms.discord ? 'On' : 'Off' %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Enable on Twitch</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="platform_twitch"
|
||||||
|
<%= platforms.twitch ? 'checked' : '' %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= platforms.twitch ? 'On' : 'Off' %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<button type="submit" class="button">Save settings</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Expressions</h2>
|
||||||
|
<form method="post" action="/plugins/expression-interaction/actions/create" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Action id</label>
|
||||||
|
<input name="action_id" placeholder="hug" />
|
||||||
|
<span class="hint">Used for stats and tracking. Avoid changing it once created.</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Command name</label>
|
||||||
|
<input name="action_command" placeholder="hug" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Present tense</label>
|
||||||
|
<input name="action_verb" placeholder="hugs" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Past tense</label>
|
||||||
|
<input name="action_past" placeholder="hugged" />
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label>Aliases (comma or space separated)</label>
|
||||||
|
<input name="action_aliases" placeholder="high-five hf" />
|
||||||
|
<span class="hint">Command names are lowercased; spaces become dashes.</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Enabled</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" class="switch-input" name="action_enabled" checked />
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text">Enabled</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<button type="submit" class="button">Add expression</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<% if (!actions.length) { %>
|
||||||
|
<p>No expressions yet.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Command</th>
|
||||||
|
<th>Verb</th>
|
||||||
|
<th>Past</th>
|
||||||
|
<th>Aliases</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% actions.forEach((action) => { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= action.id %></td>
|
||||||
|
<td><%= action.command %></td>
|
||||||
|
<td><%= action.verb %></td>
|
||||||
|
<td><%= action.past %></td>
|
||||||
|
<td><%= action.aliases.length ? action.aliases.join(", ") : '-' %></td>
|
||||||
|
<td>
|
||||||
|
<%= action.archived ? 'Archived' : action.enabled ? 'Enabled' : 'Disabled' %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/plugins/expression-interaction/actions/<%= action.id %>/toggle" class="inline-form">
|
||||||
|
<button type="submit" class="button subtle"><%= action.enabled ? "Disable" : "Enable" %></button>
|
||||||
|
</form>
|
||||||
|
<% if (action.archived) { %>
|
||||||
|
<form method="post" action="/plugins/expression-interaction/actions/<%= action.id %>/restore" class="inline-form">
|
||||||
|
<button type="submit" class="button">Restore</button>
|
||||||
|
</form>
|
||||||
|
<% } else { %>
|
||||||
|
<form method="post" action="/plugins/expression-interaction/actions/<%= action.id %>/archive" class="inline-form">
|
||||||
|
<button type="submit" class="button danger">Archive</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button subtle"
|
||||||
|
data-edit-toggle="expression-<%= action.id %>"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="edit-row" data-edit-row="expression-<%= action.id %>">
|
||||||
|
<td colspan="7">
|
||||||
|
<form method="post" action="/plugins/expression-interaction/actions/<%= action.id %>/update" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Command name</label>
|
||||||
|
<input name="action_command" value="<%= action.command %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Present tense</label>
|
||||||
|
<input name="action_verb" value="<%= action.verbOverride || '' %>" placeholder="<%= action.verb %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Past tense</label>
|
||||||
|
<input name="action_past" value="<%= action.pastOverride || '' %>" placeholder="<%= action.past %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label>Aliases (comma or space separated)</label>
|
||||||
|
<input name="action_aliases" value="<%= action.aliases.join(', ') %>" />
|
||||||
|
<span class="hint">Leave tense fields blank to auto-conjugate.</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Enabled</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="action_enabled"
|
||||||
|
<%= action.enabled ? 'checked' : '' %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= action.enabled ? 'Enabled' : 'Disabled' %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<button type="submit" class="button">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<%- include("../../../src/web/views/partials/layout-bottom") %>
|
||||||
1139
plugins/moderation/index.js
Normal file
7
plugins/moderation/plugin.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"id": "moderation",
|
||||||
|
"name": "Moderation Center",
|
||||||
|
"version": "0.1.3",
|
||||||
|
"description": "Cross-platform moderation actions, notes, and sanctions.",
|
||||||
|
"main": "index.js"
|
||||||
|
}
|
||||||
591
plugins/moderation/views/moderation.ejs
Normal file
@ -0,0 +1,591 @@
|
|||||||
|
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
|
||||||
|
<style>
|
||||||
|
.moderation-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.moderation-card {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.moderation-row {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.moderation-row[open] {
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
.moderation-row summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.moderation-row summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.moderation-row .row-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.moderation-row .row-title strong {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.row-chevron {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
transition: transform 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
.moderation-row[open] .row-chevron {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
background: var(--surface-3);
|
||||||
|
}
|
||||||
|
.moderation-row-body {
|
||||||
|
padding-top: 14px;
|
||||||
|
margin-top: 14px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.pill-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.pill.ban {
|
||||||
|
color: #ff7a7a;
|
||||||
|
border-color: rgba(255, 122, 122, 0.4);
|
||||||
|
background: rgba(255, 122, 122, 0.12);
|
||||||
|
}
|
||||||
|
.pill.timeout {
|
||||||
|
color: #f1b765;
|
||||||
|
border-color: rgba(241, 183, 101, 0.4);
|
||||||
|
background: rgba(241, 183, 101, 0.12);
|
||||||
|
}
|
||||||
|
.pill.kick {
|
||||||
|
color: #9aa1ad;
|
||||||
|
border-color: rgba(154, 161, 173, 0.4);
|
||||||
|
background: rgba(154, 161, 173, 0.12);
|
||||||
|
}
|
||||||
|
.pill.note {
|
||||||
|
color: #7c8dff;
|
||||||
|
border-color: rgba(124, 141, 255, 0.4);
|
||||||
|
background: rgba(124, 141, 255, 0.12);
|
||||||
|
}
|
||||||
|
.inline-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.duration-inputs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 140px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.duration-inputs input,
|
||||||
|
.duration-inputs select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.ban-pot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface-3);
|
||||||
|
}
|
||||||
|
.ban-pot strong {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
.user-search {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.user-results {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.user-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface-3);
|
||||||
|
}
|
||||||
|
.user-row.is-selected {
|
||||||
|
border-color: var(--sea);
|
||||||
|
}
|
||||||
|
.user-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.user-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.user-pills {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.user-pill {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-3);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.user-expand {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.user-details {
|
||||||
|
display: none;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
.user-row.is-open .user-details {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
.user-select {
|
||||||
|
background: var(--sea);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h1>Moderation Center</h1>
|
||||||
|
<p class="command-subtitle">Global moderation actions, notes, and audit tracking.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="moderation-grid">
|
||||||
|
<details class="moderation-row" open>
|
||||||
|
<summary>
|
||||||
|
<div class="row-title">
|
||||||
|
<strong>Issue action</strong>
|
||||||
|
<span class="hint">Global bans and timeouts with required reasoning.</span>
|
||||||
|
</div>
|
||||||
|
<span class="row-chevron" aria-hidden="true">›</span>
|
||||||
|
</summary>
|
||||||
|
<div class="moderation-row-body">
|
||||||
|
<form method="post" action="/plugins/moderation/actions" enctype="multipart/form-data" class="form-grid" data-duration-group>
|
||||||
|
<div class="field full user-search">
|
||||||
|
<label>Target internal username (optional)</label>
|
||||||
|
<input name="target_username" id="moderation-target-username" placeholder="ookamikuntv" autocomplete="off" />
|
||||||
|
<div class="user-results" id="moderation-target-results"></div>
|
||||||
|
<span class="hint">Search internal usernames or linked accounts.</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Target platform</label>
|
||||||
|
<select name="target_platform">
|
||||||
|
<option value="">Select platform</option>
|
||||||
|
<option value="discord">Discord</option>
|
||||||
|
<option value="twitch">Twitch</option>
|
||||||
|
<option value="youtube">YouTube</option>
|
||||||
|
<option value="kick" disabled>Kick (coming soon)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Target platform ID</label>
|
||||||
|
<input name="target_platform_id" placeholder="User ID / username" />
|
||||||
|
<span class="hint">Use platform IDs when possible. Twitch can use username.</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Target platform username (optional)</label>
|
||||||
|
<input name="target_platform_username" placeholder="Display name" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Action</label>
|
||||||
|
<select name="action_type">
|
||||||
|
<option value="ban">Ban (global)</option>
|
||||||
|
<option value="timeout">Timeout (global)</option>
|
||||||
|
<option value="kick" disabled>Kick (coming soon)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<% if (!isAdmin) { %>
|
||||||
|
<div class="field">
|
||||||
|
<label>Duration preset (mods)</label>
|
||||||
|
<select name="duration_preset" data-duration-field>
|
||||||
|
<% presets.forEach((preset) => { %>
|
||||||
|
<option value="<%= preset.seconds %>"><%= preset.label %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<% if (isAdmin) { %>
|
||||||
|
<div class="field full">
|
||||||
|
<label>Custom duration (admins)</label>
|
||||||
|
<div class="duration-inputs">
|
||||||
|
<input name="duration_value" placeholder="Enter number" data-duration-field />
|
||||||
|
<select name="duration_unit" data-duration-field>
|
||||||
|
<option value="hours">Hours</option>
|
||||||
|
<option value="days">Days</option>
|
||||||
|
<option value="weeks">Weeks</option>
|
||||||
|
<option value="months">Months</option>
|
||||||
|
<option value="years">Years</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<div class="field">
|
||||||
|
<label>Permanent</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" class="switch-input" name="permanent" data-duration-permanent />
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text">Permanent</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label>Reason summary</label>
|
||||||
|
<input name="reason_short" required />
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label>Reason details</label>
|
||||||
|
<textarea name="reason_detail" rows="4" required></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label>Evidence (optional)</label>
|
||||||
|
<input type="file" name="evidence_files" accept="image/*" multiple />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button">Submit action</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<section class="moderation-card">
|
||||||
|
<h2>Ban pot</h2>
|
||||||
|
<div class="ban-pot">
|
||||||
|
<span>Current balance</span>
|
||||||
|
<strong><%= banPot %></strong>
|
||||||
|
<span class="hint">Funds from bans are collected here.</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="moderation-card">
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h2>User notes</h2>
|
||||||
|
<p class="hint">Search or filter notes and keep context handy.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="button" data-note-open>Add user note</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-tools">
|
||||||
|
<input
|
||||||
|
class="table-search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search notes"
|
||||||
|
aria-label="Search notes"
|
||||||
|
data-table-filter="moderation-notes"
|
||||||
|
/>
|
||||||
|
<div class="table-controls">
|
||||||
|
<% 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)); %>
|
||||||
|
<label class="table-page-size">
|
||||||
|
User
|
||||||
|
<select data-table-filter-select="moderation-notes" data-filter-key="user">
|
||||||
|
<option value="">All</option>
|
||||||
|
<% noteUsers.forEach((name) => { %>
|
||||||
|
<option value="<%= name.toLowerCase() %>"><%= name %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="table-page-size">
|
||||||
|
Show
|
||||||
|
<select data-table-size="moderation-notes">
|
||||||
|
<option value="25">25</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
<option value="250">250</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (!notes.length) { %>
|
||||||
|
<p>No notes yet.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table
|
||||||
|
class="table"
|
||||||
|
data-table="moderation-notes"
|
||||||
|
data-pageable="true"
|
||||||
|
data-page-size="25"
|
||||||
|
data-page-sizes="25,50,100,250"
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-sort="user">User</th>
|
||||||
|
<th>Note</th>
|
||||||
|
<th data-sort="by">By</th>
|
||||||
|
<th data-sort="date">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% notes.forEach((note) => { %>
|
||||||
|
<% const noteName = note.display_name || note.subject_id; %>
|
||||||
|
<% const noteUser = (note.internal_user_id || noteName || '').toLowerCase(); %>
|
||||||
|
<tr
|
||||||
|
data-search="<%= `${noteName} ${note.note} ${note.created_by_name || ''}`.toLowerCase() %>"
|
||||||
|
data-user="<%= noteUser %>"
|
||||||
|
data-by="<%= note.created_by_name || '' %>"
|
||||||
|
data-date="<%= note.created_at %>"
|
||||||
|
>
|
||||||
|
<td><%= noteName %></td>
|
||||||
|
<td><%= note.note %></td>
|
||||||
|
<td><%= note.created_by_name || 'Staff' %></td>
|
||||||
|
<td><%= new Date(note.created_at).toLocaleString() %></td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="table-pagination" data-table-pagination="moderation-notes">
|
||||||
|
<button type="button" class="button subtle" data-page-prev>Previous</button>
|
||||||
|
<span class="table-page-label" data-page-label>Page 1 of 1</span>
|
||||||
|
<button type="button" class="button subtle" data-page-next>Next</button>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" data-note-modal aria-hidden="true">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Add user note</h2>
|
||||||
|
<button type="button" class="icon-button" data-note-close aria-label="Close">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6l-12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/plugins/moderation/notes" class="form-grid">
|
||||||
|
<div class="field full user-search">
|
||||||
|
<label>Target internal username (optional)</label>
|
||||||
|
<input name="target_username" id="moderation-note-username" placeholder="ookamikuntv" autocomplete="off" />
|
||||||
|
<div class="user-results" id="moderation-note-results"></div>
|
||||||
|
<span class="hint">Search by internal username or linked account.</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Target platform</label>
|
||||||
|
<select name="target_platform">
|
||||||
|
<option value="">Select platform</option>
|
||||||
|
<option value="discord">Discord</option>
|
||||||
|
<option value="twitch">Twitch</option>
|
||||||
|
<option value="youtube">YouTube</option>
|
||||||
|
<option value="kick" disabled>Kick (coming soon)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Target platform ID</label>
|
||||||
|
<input name="target_platform_id" placeholder="User ID / username" />
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label>Note</label>
|
||||||
|
<textarea name="note" rows="3" required></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="button subtle" data-note-close>Cancel</button>
|
||||||
|
<button type="submit" class="button">Save note</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const attachDurationToggle = (group) => {
|
||||||
|
const toggle = group.querySelector("[data-duration-permanent]");
|
||||||
|
const fields = group.querySelectorAll("[data-duration-field]");
|
||||||
|
if (!toggle || !fields.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sync = () => {
|
||||||
|
const disabled = toggle.checked;
|
||||||
|
fields.forEach((field) => {
|
||||||
|
field.disabled = disabled;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
toggle.addEventListener("change", sync);
|
||||||
|
sync();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-duration-group]").forEach(attachDurationToggle);
|
||||||
|
|
||||||
|
const users = <%- JSON.stringify(userDirectory || []) %>;
|
||||||
|
const normalize = (value) => (value || "").toString().toLowerCase();
|
||||||
|
|
||||||
|
const buildMatch = (user, term) => {
|
||||||
|
const internal = user.internal || "";
|
||||||
|
const internalMatch = normalize(internal).includes(term);
|
||||||
|
const identityMatches = (user.identities || []).filter((identity) =>
|
||||||
|
normalize(identity.display).includes(term)
|
||||||
|
);
|
||||||
|
if (!term) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const display = internalMatch ? internal : identityMatches[0]?.display || internal;
|
||||||
|
const pills = [];
|
||||||
|
if (internalMatch) {
|
||||||
|
pills.push("Internal");
|
||||||
|
}
|
||||||
|
identityMatches.forEach((identity) => {
|
||||||
|
if (!pills.includes(identity.label)) {
|
||||||
|
pills.push(identity.label);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
display,
|
||||||
|
internal,
|
||||||
|
pills,
|
||||||
|
identities: user.identities || []
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindUserLookup = ({ input, results }) => {
|
||||||
|
if (!input || !results) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const renderResults = (term) => {
|
||||||
|
results.innerHTML = "";
|
||||||
|
if (!term) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const matches = users
|
||||||
|
.map((user) => buildMatch(user, term))
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 8);
|
||||||
|
if (!matches.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
matches.forEach((match) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "user-row";
|
||||||
|
row.dataset.userId = match.id;
|
||||||
|
row.dataset.internal = match.internal;
|
||||||
|
const details = match.identities
|
||||||
|
.map((identity) => `${identity.label}: ${identity.display}`)
|
||||||
|
.join(" · ");
|
||||||
|
const internalLabel = match.internal && match.internal !== match.display
|
||||||
|
? `Internal: ${match.internal}`
|
||||||
|
: "";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="user-main">
|
||||||
|
<span class="user-name">${match.display || match.internal || ""}</span>
|
||||||
|
<div class="user-pills">
|
||||||
|
${match.pills.map((pill) => `<span class="user-pill">${pill}</span>`).join("")}
|
||||||
|
</div>
|
||||||
|
${match.identities.length ? '<button type="button" class="user-expand">Linked</button>' : ""}
|
||||||
|
<span class="user-details">${[internalLabel, details].filter(Boolean).join(" · ")}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="user-select">Select</button>
|
||||||
|
`;
|
||||||
|
results.appendChild(row);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
renderResults(normalize(input.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
results.addEventListener("click", (event) => {
|
||||||
|
const row = event.target.closest(".user-row");
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.target.closest(".user-expand")) {
|
||||||
|
row.classList.toggle("is-open");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.target.closest(".user-select")) {
|
||||||
|
const internal = row.dataset.internal;
|
||||||
|
if (internal) {
|
||||||
|
input.value = internal;
|
||||||
|
results.innerHTML = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
bindUserLookup({
|
||||||
|
input: document.getElementById("moderation-target-username"),
|
||||||
|
results: document.getElementById("moderation-target-results")
|
||||||
|
});
|
||||||
|
|
||||||
|
bindUserLookup({
|
||||||
|
input: document.getElementById("moderation-note-username"),
|
||||||
|
results: document.getElementById("moderation-note-results")
|
||||||
|
});
|
||||||
|
|
||||||
|
const modal = document.querySelector("[data-note-modal]");
|
||||||
|
const openButton = document.querySelector("[data-note-open]");
|
||||||
|
const closeButtons = modal?.querySelectorAll("[data-note-close]") || [];
|
||||||
|
const openModal = () => {
|
||||||
|
if (!modal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modal.classList.add("is-open");
|
||||||
|
modal.setAttribute("aria-hidden", "false");
|
||||||
|
};
|
||||||
|
const closeModal = () => {
|
||||||
|
if (!modal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modal.classList.remove("is-open");
|
||||||
|
modal.setAttribute("aria-hidden", "true");
|
||||||
|
};
|
||||||
|
if (openButton) {
|
||||||
|
openButton.addEventListener("click", openModal);
|
||||||
|
}
|
||||||
|
closeButtons.forEach((button) => {
|
||||||
|
button.addEventListener("click", closeModal);
|
||||||
|
});
|
||||||
|
modal?.addEventListener("click", (event) => {
|
||||||
|
if (event.target === modal) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape" && modal?.classList.contains("is-open")) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<%- include("../../../src/web/views/partials/layout-bottom") %>
|
||||||
42
plugins/moderation/views/status.ejs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title><%= title %></title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page" style="min-height: 100vh; display: flex; align-items: center; justify-content: center;">
|
||||||
|
<div class="card" style="max-width: 720px; width: 100%;">
|
||||||
|
<h1>Access restricted</h1>
|
||||||
|
<p class="hint">Your account is currently restricted by moderation.</p>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Action</span>
|
||||||
|
<span class="stat-value"><%= sanction.action_type %></span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Status</span>
|
||||||
|
<span class="stat-value"><%= sanction.status %></span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">When</span>
|
||||||
|
<span class="stat-value"><%= new Date(sanction.created_at).toLocaleString() %></span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Expires</span>
|
||||||
|
<span class="stat-value"><%= sanction.expires_at ? new Date(sanction.expires_at).toLocaleString() : 'Permanent' %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-top: 16px;">
|
||||||
|
<h2>Summary</h2>
|
||||||
|
<p><%= sanction.reason_short %></p>
|
||||||
|
<h2>Details</h2>
|
||||||
|
<p><%= sanction.reason_detail %></p>
|
||||||
|
<p class="hint">Moderator: <%= sanction.created_by_name || 'Staff' %></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
252
plugins/moderation/views/tos-bans.ejs
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
|
||||||
|
<style>
|
||||||
|
.pill {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.pill.ban {
|
||||||
|
color: #ff7a7a;
|
||||||
|
border-color: rgba(255, 122, 122, 0.4);
|
||||||
|
background: rgba(255, 122, 122, 0.12);
|
||||||
|
}
|
||||||
|
.pill.timeout {
|
||||||
|
color: #f1b765;
|
||||||
|
border-color: rgba(241, 183, 101, 0.4);
|
||||||
|
background: rgba(241, 183, 101, 0.12);
|
||||||
|
}
|
||||||
|
.pill.kick {
|
||||||
|
color: #9aa1ad;
|
||||||
|
border-color: rgba(154, 161, 173, 0.4);
|
||||||
|
background: rgba(154, 161, 173, 0.12);
|
||||||
|
}
|
||||||
|
.inline-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.duration-inputs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 140px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.duration-inputs input,
|
||||||
|
.duration-inputs select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.evidence-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.evidence-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface-3);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h1>TOs & Bans</h1>
|
||||||
|
<p class="command-subtitle">Monitor active sanctions and moderation history.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Current Timeouts & Bans</h2>
|
||||||
|
<% if (!activeSanctions.length) { %>
|
||||||
|
<p>No active bans or timeouts.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
<th>Expires</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% activeSanctions.forEach((sanction) => { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= sanction.display_name || sanction.subject_id %></td>
|
||||||
|
<td>
|
||||||
|
<span class="pill <%= sanction.action_type %>"><%= sanction.action_type %></span>
|
||||||
|
</td>
|
||||||
|
<td><%= sanction.reason_short %></td>
|
||||||
|
<td><%= sanction.expires_at ? new Date(sanction.expires_at).toLocaleString() : "Permanent" %></td>
|
||||||
|
<td>
|
||||||
|
<div class="inline-actions">
|
||||||
|
<% if (sanction.action_type === 'timeout') { %>
|
||||||
|
<form method="post" action="/plugins/moderation/actions/<%= sanction.id %>/update-timeout" class="inline-form" data-duration-group>
|
||||||
|
<% if (!isAdmin) { %>
|
||||||
|
<select name="duration_preset" data-duration-field>
|
||||||
|
<% presets.forEach((preset) => { %>
|
||||||
|
<option value="<%= preset.seconds %>"><%= preset.label %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
<% } %>
|
||||||
|
<% if (isAdmin) { %>
|
||||||
|
<div class="duration-inputs">
|
||||||
|
<input name="duration_value" placeholder="Custom" data-duration-field />
|
||||||
|
<select name="duration_unit" data-duration-field>
|
||||||
|
<option value="hours">Hours</option>
|
||||||
|
<option value="days">Days</option>
|
||||||
|
<option value="weeks">Weeks</option>
|
||||||
|
<option value="months">Months</option>
|
||||||
|
<option value="years">Years</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" class="switch-input" name="permanent" data-duration-permanent />
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text">Permanent</span>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="button subtle">Update</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
<form method="post" action="/plugins/moderation/actions/<%= sanction.id %>/revoke" class="inline-form">
|
||||||
|
<button type="submit" class="button danger">Revoke</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<details>
|
||||||
|
<summary>
|
||||||
|
<strong>History</strong>
|
||||||
|
<span class="hint">Searchable log of every moderation action.</span>
|
||||||
|
</summary>
|
||||||
|
<div class="table-tools" style="margin-top:12px;">
|
||||||
|
<input
|
||||||
|
class="table-search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search moderation actions"
|
||||||
|
aria-label="Search moderation actions"
|
||||||
|
data-table-filter="moderation-actions"
|
||||||
|
/>
|
||||||
|
<div class="table-controls">
|
||||||
|
<label class="table-page-size">
|
||||||
|
Show
|
||||||
|
<select data-table-size="moderation-actions">
|
||||||
|
<option value="25">25</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
<option value="250">250</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% if (!actions.length) { %>
|
||||||
|
<p>No actions recorded.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table
|
||||||
|
class="table"
|
||||||
|
data-table="moderation-actions"
|
||||||
|
data-pageable="true"
|
||||||
|
data-page-size="25"
|
||||||
|
data-page-sizes="25,50,100,250"
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-sort="user">User</th>
|
||||||
|
<th data-sort="type">Type</th>
|
||||||
|
<th data-sort="platform">Platform</th>
|
||||||
|
<th data-sort="reason">Reason</th>
|
||||||
|
<th data-sort="by">By</th>
|
||||||
|
<th>Evidence</th>
|
||||||
|
<th data-sort="date">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% 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(' '); %>
|
||||||
|
<tr
|
||||||
|
data-search="<%= `${displayName} ${action.action_type} ${action.platform || ''} ${action.reason_short || ''} ${action.reason_detail || ''} ${byName} ${evidenceNames}`.toLowerCase() %>"
|
||||||
|
data-user="<%= displayName %>"
|
||||||
|
data-type="<%= action.action_type %>"
|
||||||
|
data-platform="<%= action.platform || 'global' %>"
|
||||||
|
data-reason="<%= action.reason_short || '' %>"
|
||||||
|
data-by="<%= byName %>"
|
||||||
|
data-date="<%= action.created_at %>"
|
||||||
|
>
|
||||||
|
<td><%= displayName %></td>
|
||||||
|
<td><span class="pill <%= action.action_type %>"><%= action.action_type %></span></td>
|
||||||
|
<td><%= action.platform || 'global' %></td>
|
||||||
|
<td><%= action.reason_short %></td>
|
||||||
|
<td><%= byName %></td>
|
||||||
|
<td>
|
||||||
|
<% if (!evidence.length) { %>
|
||||||
|
<span class="hint">None</span>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="evidence-list">
|
||||||
|
<% evidence.forEach((item) => { %>
|
||||||
|
<a class="evidence-link" href="/plugins/moderation/evidence/<%= item.id %>" target="_blank" rel="noopener"> <%= item.name %> </a>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
<td><%= new Date(action.created_at).toLocaleString() %></td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="table-pagination" data-table-pagination="moderation-actions">
|
||||||
|
<button type="button" class="button subtle" data-page-prev>Previous</button>
|
||||||
|
<span class="table-page-label" data-page-label>Page 1 of 1</span>
|
||||||
|
<button type="button" class="button subtle" data-page-next>Next</button>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const attachDurationToggle = (group) => {
|
||||||
|
const toggle = group.querySelector("[data-duration-permanent]");
|
||||||
|
const fields = group.querySelectorAll("[data-duration-field]");
|
||||||
|
if (!toggle || !fields.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sync = () => {
|
||||||
|
const disabled = toggle.checked;
|
||||||
|
fields.forEach((field) => {
|
||||||
|
field.disabled = disabled;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
toggle.addEventListener("change", sync);
|
||||||
|
sync();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-duration-group]").forEach(attachDurationToggle);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<%- include("../../../src/web/views/partials/layout-bottom") %>
|
||||||
55
plugins/quotes/cmds.json
Normal file
@ -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|random|search|add|remove>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "quote-add",
|
||||||
|
"trigger": "quote",
|
||||||
|
"subcommand": "add",
|
||||||
|
"name": "Add quote",
|
||||||
|
"description": "Add a new quote.",
|
||||||
|
"level": "mod",
|
||||||
|
"platforms": ["discord", "twitch", "youtube"],
|
||||||
|
"usage": "quote add <quote text>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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 <text>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "quote-random",
|
||||||
|
"trigger": "quote",
|
||||||
|
"subcommand": "random",
|
||||||
|
"name": "Random quote",
|
||||||
|
"description": "Show a random quote.",
|
||||||
|
"level": "public",
|
||||||
|
"platforms": ["discord", "twitch", "youtube"],
|
||||||
|
"usage": "quote random"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
794
plugins/quotes/index.js
Normal file
@ -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 <id|random|search|add|remove> | ${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 <quote text>`);
|
||||||
|
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 <text>`);
|
||||||
|
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 <id>`);
|
||||||
|
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 <id|random|search|add|remove> | ${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;
|
||||||
|
}
|
||||||
7
plugins/quotes/plugin.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"id": "quotes",
|
||||||
|
"name": "Quotes",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"description": "Store, search, and manage community quotes.",
|
||||||
|
"main": "index.js"
|
||||||
|
}
|
||||||
119
plugins/quotes/stats.js
Normal file
@ -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
|
||||||
|
};
|
||||||
13
plugins/quotes/stats.json
Normal file
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
203
plugins/quotes/views/quotes.ejs
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
<%- include("../../../src/web/views/partials/layout-top", { title }) %>
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h1>Quotes</h1>
|
||||||
|
<p class="command-subtitle">Store, search, and manage memorable quotes.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<% if (editingQuote) { %>
|
||||||
|
<section class="card">
|
||||||
|
<h2>Edit quote #<%= editingQuote.id %></h2>
|
||||||
|
<form method="post" action="/plugins/quotes/quotes/<%= editingQuote.id %>/update" class="form-grid">
|
||||||
|
<div class="field full">
|
||||||
|
<label>Quote text</label>
|
||||||
|
<textarea name="quote_text" rows="3"><%= editingQuote.quote_text %></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Quoted by</label>
|
||||||
|
<input name="quoter" value="<%= editingQuote.quoter %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Game name</label>
|
||||||
|
<input name="game_name" value="<%= editingQuote.game_name || '' %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Quote date/time</label>
|
||||||
|
<input type="datetime-local" name="quote_datetime" value="<%= formatDateInput(editingQuote.quote_datetime) %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Hidden</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" class="switch-input" name="hidden" <%= editingQuote.hidden ? 'checked' : '' %> />
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= editingQuote.hidden ? 'Hidden' : 'Visible' %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Archived</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" class="switch-input" name="archived" <%= editingQuote.archived ? 'checked' : '' %> />
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= editingQuote.archived ? 'Archived' : 'Active' %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<button type="submit" class="button">Save changes</button>
|
||||||
|
<a class="button subtle" href="/plugins/quotes">Cancel</a>
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<p class="hint">
|
||||||
|
Last edited by <%= editingQuote.edited_by || 'system' %>
|
||||||
|
<%= editingQuote.edited_last ? `on ${formatDateTime(editingQuote.edited_last)}` : '' %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Add quote</h2>
|
||||||
|
<form method="post" action="/plugins/quotes/quotes/create" class="form-grid">
|
||||||
|
<div class="field full">
|
||||||
|
<label>Quote text</label>
|
||||||
|
<textarea name="quote_text" rows="3" placeholder="This is a quote"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Quoted by</label>
|
||||||
|
<input name="quoter" placeholder="Streamer" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Game name</label>
|
||||||
|
<input name="game_name" placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Quote date/time</label>
|
||||||
|
<input type="datetime-local" name="quote_datetime" value="<%= formatDateInput(Date.now()) %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Hidden</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" class="switch-input" name="hidden" />
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text">Visible</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Archived</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" class="switch-input" name="archived" />
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text">Active</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<button type="submit" class="button">Add quote</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>All quotes</h2>
|
||||||
|
<% if (!quotes.length) { %>
|
||||||
|
<p>No quotes recorded yet.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="table-tools">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
class="table-search"
|
||||||
|
placeholder="Search quotes"
|
||||||
|
data-table-filter="quotes"
|
||||||
|
/>
|
||||||
|
<div class="table-controls">
|
||||||
|
<select class="table-search" data-table-filter-select="quotes" data-filter-key="status">
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="hidden">Hidden</option>
|
||||||
|
<option value="archived">Archived</option>
|
||||||
|
</select>
|
||||||
|
<label class="table-page-size">
|
||||||
|
Show
|
||||||
|
<select data-table-size="quotes">
|
||||||
|
<option value="25">25</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
<option value="250">250</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table
|
||||||
|
class="table"
|
||||||
|
data-table="quotes"
|
||||||
|
data-pageable="true"
|
||||||
|
data-page-size="25"
|
||||||
|
data-page-sizes="25,50,100,250"
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-sort="id">ID</th>
|
||||||
|
<th data-sort="text">Quote</th>
|
||||||
|
<th data-sort="quoter">Quoted by</th>
|
||||||
|
<th data-sort="game">Game</th>
|
||||||
|
<th data-sort="date">Date</th>
|
||||||
|
<th data-sort="status">Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% quotes.forEach((quote) => { %>
|
||||||
|
<% const status = quote.archived ? 'archived' : quote.hidden ? 'hidden' : 'active'; %>
|
||||||
|
<% const dateLabel = formatDateTime(quote.quote_datetime); %>
|
||||||
|
<tr
|
||||||
|
data-search="<%= `${quote.id} ${quote.quote_text} ${quote.quoter} ${quote.game_name || ''} ${dateLabel}`.toLowerCase() %>"
|
||||||
|
data-id="<%= quote.id %>"
|
||||||
|
data-text="<%= (quote.quote_text || '').toLowerCase() %>"
|
||||||
|
data-quoter="<%= (quote.quoter || '').toLowerCase() %>"
|
||||||
|
data-game="<%= (quote.game_name || '').toLowerCase() %>"
|
||||||
|
data-date="<%= quote.quote_datetime %>"
|
||||||
|
data-status="<%= status %>"
|
||||||
|
>
|
||||||
|
<td>#<%= quote.id %></td>
|
||||||
|
<td><%= quote.quote_text %></td>
|
||||||
|
<td><%= quote.quoter %></td>
|
||||||
|
<td><%= quote.game_name || '-' %></td>
|
||||||
|
<td><%= dateLabel %></td>
|
||||||
|
<td><%= status %></td>
|
||||||
|
<td>
|
||||||
|
<a class="button subtle" href="/plugins/quotes?edit=<%= quote.id %>">Edit</a>
|
||||||
|
<% if (quote.hidden) { %>
|
||||||
|
<form method="post" action="/plugins/quotes/quotes/<%= quote.id %>/unhide" class="inline-form">
|
||||||
|
<button type="submit" class="button subtle">Unhide</button>
|
||||||
|
</form>
|
||||||
|
<% } else { %>
|
||||||
|
<form method="post" action="/plugins/quotes/quotes/<%= quote.id %>/hide" class="inline-form">
|
||||||
|
<button type="submit" class="button subtle">Hide</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
<% if (quote.archived) { %>
|
||||||
|
<form method="post" action="/plugins/quotes/quotes/<%= quote.id %>/restore" class="inline-form">
|
||||||
|
<button type="submit" class="button">Restore</button>
|
||||||
|
</form>
|
||||||
|
<% } else { %>
|
||||||
|
<form method="post" action="/plugins/quotes/quotes/<%= quote.id %>/archive" class="inline-form">
|
||||||
|
<button type="submit" class="button danger">Archive</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="table-pagination" data-table-pagination="quotes">
|
||||||
|
<button type="button" class="button subtle" data-page-prev>Previous</button>
|
||||||
|
<span class="table-page-label" data-page-label>Page 1 of 1</span>
|
||||||
|
<button type="button" class="button subtle" data-page-next>Next</button>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
<%- include("../../../src/web/views/partials/layout-bottom") %>
|
||||||
17
plugins/sample-plugin/index.js
Normal file
@ -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"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
7
plugins/sample-plugin/plugin.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"id": "sample-plugin",
|
||||||
|
"name": "Sample Plugin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Example plugin with a simple page.",
|
||||||
|
"main": "index.js"
|
||||||
|
}
|
||||||
65
run.js
Normal file
@ -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();
|
||||||
216
safe-mode.js
Normal file
@ -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 `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>${title}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; background: #f5f3ef; color: #1c1f23; margin: 0; }
|
||||||
|
header { padding: 20px 28px; background: #1c1f23; color: #fff; }
|
||||||
|
main { padding: 24px 28px; max-width: 900px; margin: 0 auto; }
|
||||||
|
.card { background: #fff; border-radius: 12px; padding: 18px 20px; margin-bottom: 16px; box-shadow: 0 10px 25px rgba(20, 24, 30, 0.08); }
|
||||||
|
.button { background: #0f6a78; color: #fff; border: none; padding: 8px 14px; border-radius: 8px; cursor: pointer; text-decoration: none; display: inline-block; }
|
||||||
|
.button.danger { background: #c24b3b; }
|
||||||
|
.muted { color: #5a616a; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #e1ddd7; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<strong>Safe Mode</strong>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
${content}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSnapshotTable(snapshots) {
|
||||||
|
if (!snapshots.length) {
|
||||||
|
return "<p class=\"muted\">No snapshots available.</p>";
|
||||||
|
}
|
||||||
|
const rows = snapshots
|
||||||
|
.map((snap) => {
|
||||||
|
const label = snap.type === "plugin" ? `Plugin: ${snap.pluginId}` : "Bot core";
|
||||||
|
const when = new Date(snap.createdAt).toLocaleString();
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${label}</td>
|
||||||
|
<td>${when}</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/rollback/${snap.id}">
|
||||||
|
<button class="button danger" type="submit">Rollback</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
return `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Snapshot</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${rows}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
`<section class="card"><h2>Discord not configured</h2><p class="muted">Discord settings are required to enter safe mode.</p></section>`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!req.session.user) {
|
||||||
|
return res.send(
|
||||||
|
renderPage(
|
||||||
|
"Safe Mode",
|
||||||
|
`<section class="card"><h2>Login required</h2><p class="muted">Authenticate with Discord to access rollback tools.</p><a class="button" href="/auth/discord">Login with Discord</a></section>`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasAccess(req.session.user, "admin")) {
|
||||||
|
return res.send(
|
||||||
|
renderPage(
|
||||||
|
"Safe Mode",
|
||||||
|
`<section class="card"><h2>Access denied</h2><p class="muted">You do not have administrator access.</p></section>`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const snapshots = listSnapshots();
|
||||||
|
const table = buildSnapshotTable(snapshots);
|
||||||
|
res.send(
|
||||||
|
renderPage(
|
||||||
|
"Safe Mode",
|
||||||
|
`<section class="card"><h2>Rollback snapshots</h2><p class="muted">Use these snapshots to roll back failed updates. The server will restart after rollback.</p>${table}</section>`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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", "<section class=\"card\">Invalid login state.</section>"));
|
||||||
|
}
|
||||||
|
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", "<section class=\"card\">Login failed.</section>"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/rollback/:id", (req, res) => {
|
||||||
|
if (!req.session.user || !hasAccess(req.session.user, "admin")) {
|
||||||
|
return res.status(403).send(renderPage("Safe Mode", "<section class=\"card\">Access denied.</section>"));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
restoreSnapshot(req.params.id);
|
||||||
|
res.send(
|
||||||
|
renderPage(
|
||||||
|
"Safe Mode",
|
||||||
|
"<section class=\"card\"><h2>Rollback complete</h2><p class=\"muted\">Restarting the bot now...</p></section>"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
requestRestart();
|
||||||
|
} catch (error) {
|
||||||
|
res.send(
|
||||||
|
renderPage(
|
||||||
|
"Safe Mode",
|
||||||
|
`<section class=\"card\"><h2>Rollback failed</h2><p class=\"muted\">${error.message}</p></section>`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = Number(process.env.SAFE_MODE_PORT || 3001);
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`Safe mode listening on http://localhost:${port}`);
|
||||||
|
});
|
||||||
63
security-audit-findings.json
Normal file
@ -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."
|
||||||
|
}
|
||||||
|
]
|
||||||
286
security-audit-report.md
Normal file
@ -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`.
|
||||||
|
|
||||||
96
src/main.js
Normal file
@ -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();
|
||||||
188
src/services/auth.js
Normal file
@ -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
|
||||||
|
};
|
||||||
237
src/services/command-router.js
Normal file
@ -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
|
||||||
|
};
|
||||||
164
src/services/commands.js
Normal file
@ -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
|
||||||
|
};
|
||||||
65
src/services/config.js
Normal file
@ -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
|
||||||
|
};
|
||||||
264
src/services/db.js
Normal file
@ -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
|
||||||
|
};
|
||||||
140
src/services/discord.js
Normal file
@ -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
|
||||||
|
};
|
||||||
143
src/services/logger.js
Normal file
@ -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
|
||||||
|
};
|
||||||
187
src/services/platforms.js
Normal file
@ -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
|
||||||
|
};
|
||||||
107
src/services/plugin-stats.js
Normal file
@ -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
|
||||||
|
};
|
||||||
229
src/services/plugins.js
Normal file
@ -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
|
||||||
|
};
|
||||||
38
src/services/rbac.js
Normal file
@ -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
|
||||||
|
};
|
||||||
138
src/services/settings.js
Normal file
@ -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
|
||||||
|
};
|
||||||
48
src/services/stats.js
Normal file
@ -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
|
||||||
|
};
|
||||||
487
src/services/top.js
Normal file
@ -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 <category>. 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
|
||||||
|
};
|
||||||
95
src/services/twitch.js
Normal file
@ -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
|
||||||
|
};
|
||||||
455
src/services/update-manager.js
Normal file
@ -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
|
||||||
|
};
|
||||||
39
src/services/updater.js
Normal file
@ -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
|
||||||
|
};
|
||||||
220
src/services/users.js
Normal file
@ -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
|
||||||
|
};
|
||||||
285
src/services/youtube.js
Normal file
@ -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
|
||||||
|
};
|
||||||
538
src/web/public/app.js
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
1
src/web/public/icons/nav/admin.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19.4 13.5l1.7-1-1-1.8-2 0.4a5.9 5.9 0 0 0-1.1-0.6l-0.3-2h-2.1l-0.3 2c-0.4 0.1-0.8 0.3-1.1 0.6l-2-0.4-1 1.8 1.7 1c-0.1 0.4-0.1 0.9 0 1.3l-1.7 1 1 1.8 2-0.4c0.3 0.2 0.7 0.4 1.1 0.6l0.3 2h2.1l0.3-2c0.4-0.1 0.8-0.3 1.1-0.6l2 0.4 1-1.8-1.7-1c0.1-0.4 0.1-0.9 0-1.3zM12 15.4a3.4 3.4 0 1 1 0-6.8 3.4 3.4 0 0 1 0 6.8z" fill="currentColor"/></svg>
|
||||||
|
After Width: | Height: | Size: 408 B |
1
src/web/public/icons/nav/commands.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M5 6h14M5 12h14M5 18h10" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/></svg>
|
||||||
|
After Width: | Height: | Size: 176 B |
1
src/web/public/icons/nav/home.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M4 10.5L12 4l8 6.5V20a1 1 0 0 1-1 1h-5v-6H10v6H5a1 1 0 0 1-1-1z" fill="currentColor"/></svg>
|
||||||
|
After Width: | Height: | Size: 162 B |
1
src/web/public/icons/nav/leaderboards.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M4 17l4-4 4 4 8-8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>
|
||||||
|
After Width: | Height: | Size: 194 B |
1
src/web/public/icons/nav/logs.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M5 4h14v16H5z" stroke="currentColor" stroke-width="2" fill="none"/><path d="M8 8h8M8 12h8M8 16h5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
After Width: | Height: | Size: 237 B |
1
src/web/public/icons/nav/moderation.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 3l7 3v6c0 4.4-3 8.4-7 9-4-0.6-7-4.6-7-9V6z" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||||
|
After Width: | Height: | Size: 176 B |
1
src/web/public/icons/nav/pages.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M5 6h14v12H5z" stroke="currentColor" stroke-width="2" fill="none"/><path d="M8 10h8M8 14h6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
After Width: | Height: | Size: 231 B |
1
src/web/public/icons/nav/plugins.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M7 3h10v6H7zM5 9h14v12H5z" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||||
|
After Width: | Height: | Size: 155 B |
1
src/web/public/icons/nav/privileges.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M4 12h16M12 4v16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
After Width: | Height: | Size: 157 B |
1
src/web/public/icons/nav/profile.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 12a4 4 0 1 0-4-4" stroke="currentColor" stroke-width="2" fill="none"/><path d="M4 20c1.5-3 5-5 8-5s6.5 2 8 5" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/></svg>
|
||||||
|
After Width: | Height: | Size: 265 B |
1
src/web/public/icons/nav/settings.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 3v4M12 17v4M3 12h4M17 12h4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||||
|
After Width: | Height: | Size: 253 B |
1
src/web/public/icons/nav/stats.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M4 20V10m8 10V4m8 16v-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/></svg>
|
||||||
|
After Width: | Height: | Size: 176 B |
1
src/web/public/icons/nav/theming.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 4a8 8 0 1 0 8 8h-8z" fill="currentColor"/></svg>
|
||||||
|
After Width: | Height: | Size: 122 B |
1
src/web/public/icons/nav/updates.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 4v12m0 0l-4-4m4 4l4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/><path d="M4 20h16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
After Width: | Height: | Size: 284 B |
1
src/web/public/icons/nav/users.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M4 7h7v7H4zM13 7h7v7h-7zM4 16h7v7H4zM13 16h7v7h-7z" fill="currentColor"/></svg>
|
||||||
|
After Width: | Height: | Size: 149 B |
1663
src/web/public/styles.css
Normal file
4771
src/web/server.js
Normal file
197
src/web/views/admin-commands.ejs
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
<%- include("partials/layout-top", { title }) %>
|
||||||
|
<section class="card">
|
||||||
|
<h1>Custom commands</h1>
|
||||||
|
<% const platformLabelMap = new Map((platforms || []).map((item) => [item.id, item.label])); %>
|
||||||
|
<form method="post" action="/admin/commands" class="form-grid command-form">
|
||||||
|
<div class="field">
|
||||||
|
<label>Trigger</label>
|
||||||
|
<input name="trigger" placeholder="hello" />
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label>Platforms</label>
|
||||||
|
<div class="platform-checkboxes">
|
||||||
|
<% (platforms || []).forEach((platform) => { %>
|
||||||
|
<label class="platform-check <%= platform.enabled ? '' : 'is-disabled' %>" title="<%= platform.enabled ? '' : 'Disabled in Platform Integration' %>">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="platforms"
|
||||||
|
value="<%= platform.id %>"
|
||||||
|
<%= platform.enabled ? 'checked' : '' %>
|
||||||
|
/>
|
||||||
|
<span><%= platform.label %></span>
|
||||||
|
</label>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<% if (!platforms || !platforms.length) { %>
|
||||||
|
<p class="hint">Enable platforms in Platform Integration to assign them here.</p>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% if (isAdmin) { %>
|
||||||
|
<div class="field">
|
||||||
|
<label>Mode</label>
|
||||||
|
<select name="mode" class="js-command-mode">
|
||||||
|
<option value="plain" selected>Plain</option>
|
||||||
|
<option value="advanced">Advanced</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field js-field-language">
|
||||||
|
<label>Language</label>
|
||||||
|
<select name="language">
|
||||||
|
<option value="js">JavaScript</option>
|
||||||
|
<option value="python">Python</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<input type="hidden" name="mode" value="plain" />
|
||||||
|
<input type="hidden" name="language" value="js" />
|
||||||
|
<% } %>
|
||||||
|
<div class="field">
|
||||||
|
<label>Response</label>
|
||||||
|
<input name="response" placeholder="Hello there!" class="js-field-response" />
|
||||||
|
</div>
|
||||||
|
<% if (isAdmin) { %>
|
||||||
|
<div class="field full js-field-code">
|
||||||
|
<label>Advanced code</label>
|
||||||
|
<textarea name="code" rows="6" placeholder="function run(ctx) { return `Hello ${ctx.user.username}`; }"></textarea>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<button type="submit" class="button">Create command</button>
|
||||||
|
</form>
|
||||||
|
<% if (isAdmin) { %>
|
||||||
|
<p class="hint">Advanced commands must export a <code>run(ctx)</code> function. Return a string to reply.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<p class="hint">Moderators can create plain text commands only.</p>
|
||||||
|
<% } %>
|
||||||
|
<h2>Existing commands</h2>
|
||||||
|
<% if (!commands.length) { %>
|
||||||
|
<p>No commands created yet.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Trigger</th>
|
||||||
|
<th>Response</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% commands.forEach((command) => { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= command.trigger %></td>
|
||||||
|
<td>
|
||||||
|
<div class="command-meta">
|
||||||
|
<span class="platform-pills">
|
||||||
|
<% (command.platforms || []).forEach((platform) => { %>
|
||||||
|
<span class="badge <%= platform %>"><%= platformLabelMap.get(platform) || platform %></span>
|
||||||
|
<% }) %>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<%= command.mode === "advanced" ? "Advanced (" + command.language + ")" : command.response %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><%= command.enabled ? "Enabled" : "Disabled" %></td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/admin/commands/<%= command.id %>/toggle" class="inline-form">
|
||||||
|
<button type="submit" class="button subtle"><%= command.enabled ? "Disable" : "Enable" %></button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/commands/<%= command.id %>/delete" class="inline-form">
|
||||||
|
<button type="submit" class="button danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
<% if (isAdmin || command.mode === "plain") { %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button subtle"
|
||||||
|
data-edit-toggle="command-<%= command.id %>"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="edit-row" data-edit-row="command-<%= command.id %>">
|
||||||
|
<td colspan="4">
|
||||||
|
<form method="post" action="/admin/commands/<%= command.id %>/update" class="form-grid command-form">
|
||||||
|
<div class="field">
|
||||||
|
<label>Trigger</label>
|
||||||
|
<input name="trigger" value="<%= command.trigger %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label>Platforms</label>
|
||||||
|
<div class="platform-checkboxes">
|
||||||
|
<% (platforms || []).forEach((platform) => { %>
|
||||||
|
<label class="platform-check <%= platform.enabled ? '' : 'is-disabled' %>" title="<%= platform.enabled ? '' : 'Disabled in Platform Integration' %>">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="platforms"
|
||||||
|
value="<%= platform.id %>"
|
||||||
|
<%= command.platforms && command.platforms.includes(platform.id) ? 'checked' : '' %>
|
||||||
|
/>
|
||||||
|
<span><%= platform.label %></span>
|
||||||
|
</label>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% if (isAdmin) { %>
|
||||||
|
<div class="field">
|
||||||
|
<label>Mode</label>
|
||||||
|
<select name="mode" class="js-command-mode">
|
||||||
|
<option value="plain" <%= command.mode === 'plain' ? 'selected' : '' %>>Plain</option>
|
||||||
|
<option value="advanced" <%= command.mode === 'advanced' ? 'selected' : '' %>>Advanced</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field js-field-language">
|
||||||
|
<label>Language</label>
|
||||||
|
<select name="language">
|
||||||
|
<option value="js" <%= command.language === 'js' ? 'selected' : '' %>>JavaScript</option>
|
||||||
|
<option value="python" <%= command.language === 'python' ? 'selected' : '' %>>Python</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<input type="hidden" name="mode" value="plain" />
|
||||||
|
<input type="hidden" name="language" value="js" />
|
||||||
|
<% } %>
|
||||||
|
<div class="field">
|
||||||
|
<label>Response</label>
|
||||||
|
<input name="response" value="<%= command.response %>" class="js-field-response" />
|
||||||
|
</div>
|
||||||
|
<% if (isAdmin) { %>
|
||||||
|
<div class="field full js-field-code">
|
||||||
|
<label>Advanced code</label>
|
||||||
|
<textarea name="code" rows="6"><%= command.code || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<button type="submit" class="button">Save</button>
|
||||||
|
</form>
|
||||||
|
<% if (!isAdmin && command.mode === 'advanced') { %>
|
||||||
|
<p class="hint">Advanced commands can only be edited by admins.</p>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
<script>
|
||||||
|
const forms = document.querySelectorAll(".command-form");
|
||||||
|
const toggleFields = (form) => {
|
||||||
|
const mode = form.querySelector(".js-command-mode")?.value || "plain";
|
||||||
|
const response = form.querySelector(".js-field-response")?.closest(".field");
|
||||||
|
const code = form.querySelector(".js-field-code");
|
||||||
|
const language = form.querySelector(".js-field-language");
|
||||||
|
const isAdvanced = mode === "advanced";
|
||||||
|
if (response) response.style.display = isAdvanced ? "none" : "";
|
||||||
|
if (code) code.style.display = isAdvanced ? "" : "none";
|
||||||
|
if (language) language.style.display = isAdvanced ? "" : "none";
|
||||||
|
};
|
||||||
|
forms.forEach((form) => {
|
||||||
|
toggleFields(form);
|
||||||
|
form.querySelector(".js-command-mode")?.addEventListener("change", () => {
|
||||||
|
toggleFields(form);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<%- include("partials/layout-bottom") %>
|
||||||
56
src/web/views/admin-dashboard.ejs
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<%- include("partials/layout-top", { title }) %>
|
||||||
|
<section class="card">
|
||||||
|
<h1>Admin dashboard</h1>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<p>Update site settings and automation preferences.</p>
|
||||||
|
<a href="/admin/settings" class="link">Edit settings</a>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Theming</h2>
|
||||||
|
<p>Adjust light and dark mode colors.</p>
|
||||||
|
<a href="/admin/theming" class="link">Edit theme</a>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Commands</h2>
|
||||||
|
<p>Create and manage custom bot commands.</p>
|
||||||
|
<a href="/admin/commands" class="link">Manage commands</a>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Users</h2>
|
||||||
|
<p>View linked accounts and manage usernames.</p>
|
||||||
|
<a href="/admin/users" class="link">Manage users</a>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Pages</h2>
|
||||||
|
<p>Create public, moderator, or admin pages.</p>
|
||||||
|
<a href="/admin/pages" class="link">Manage pages</a>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Plugins</h2>
|
||||||
|
<p>Install, enable, and update modules.</p>
|
||||||
|
<a href="/admin/plugins" class="link">Manage plugins</a>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Updates</h2>
|
||||||
|
<p>Upload bot or plugin ZIP updates and review snapshots.</p>
|
||||||
|
<a href="/admin/updates" class="link">Manage updates</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="card">
|
||||||
|
<h2>Maintenance</h2>
|
||||||
|
<form method="post" action="/admin/check-update" class="inline-form">
|
||||||
|
<button type="submit" class="button subtle">Check for updates</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/update" class="inline-form">
|
||||||
|
<button type="submit" class="button">Update from git</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/restart" class="inline-form">
|
||||||
|
<button type="submit" class="button subtle">Restart bot</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<%- include("partials/layout-bottom") %>
|
||||||
|
|
||||||
|
|
||||||
116
src/web/views/admin-logs.ejs
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
<%- include("partials/layout-top", { title }) %>
|
||||||
|
<% const filters = logFilters || { range: '86400000', level: 'all', limit: '50' }; %>
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h1>Logs</h1>
|
||||||
|
<p class="command-subtitle">Core system logs with severity, timestamps, and details.</p>
|
||||||
|
</div>
|
||||||
|
<div class="log-controls">
|
||||||
|
<input
|
||||||
|
class="table-search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search logs"
|
||||||
|
aria-label="Search logs"
|
||||||
|
data-log-search
|
||||||
|
/>
|
||||||
|
<select class="table-search" data-log-level aria-label="Filter log severity">
|
||||||
|
<option value="all" <%= filters.level === 'all' ? 'selected' : '' %>>All severities</option>
|
||||||
|
<option value="error" <%= filters.level === 'error' ? 'selected' : '' %>>Error</option>
|
||||||
|
<option value="warn" <%= filters.level === 'warn' ? 'selected' : '' %>>Warning</option>
|
||||||
|
<option value="info" <%= filters.level === 'info' ? 'selected' : '' %>>Info</option>
|
||||||
|
<option value="debug" <%= filters.level === 'debug' ? 'selected' : '' %>>Debug</option>
|
||||||
|
</select>
|
||||||
|
<select class="table-search" data-log-range aria-label="Filter by time range">
|
||||||
|
<option value="all" <%= filters.range === 'all' ? 'selected' : '' %>>All time</option>
|
||||||
|
<option value="<%= 60 * 60 * 1000 %>" <%= filters.range === `${60 * 60 * 1000}` ? 'selected' : '' %>>Last hour</option>
|
||||||
|
<option value="<%= 24 * 60 * 60 * 1000 %>" <%= filters.range === `${24 * 60 * 60 * 1000}` ? 'selected' : '' %>>Last 24 hours</option>
|
||||||
|
<option value="<%= 7 * 24 * 60 * 60 * 1000 %>" <%= filters.range === `${7 * 24 * 60 * 60 * 1000}` ? 'selected' : '' %>>Last week</option>
|
||||||
|
<option value="<%= 30 * 24 * 60 * 60 * 1000 %>" <%= filters.range === `${30 * 24 * 60 * 60 * 1000}` ? 'selected' : '' %>>Last month</option>
|
||||||
|
</select>
|
||||||
|
<select class="table-search" data-log-limit aria-label="Limit log entries">
|
||||||
|
<option value="50" <%= filters.limit === '50' ? 'selected' : '' %>>50 most recent</option>
|
||||||
|
<option value="100" <%= filters.limit === '100' ? 'selected' : '' %>>100 most recent</option>
|
||||||
|
<option value="250" <%= filters.limit === '250' ? 'selected' : '' %>>250 most recent</option>
|
||||||
|
<option value="500" <%= filters.limit === '500' ? 'selected' : '' %>>500 most recent</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="button subtle" data-log-download>Download logs</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="log-window" data-log-list>
|
||||||
|
<% if (!logs || !logs.length) { %>
|
||||||
|
<p class="hint">No log events yet.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<% logs.forEach((log) => { %>
|
||||||
|
<details
|
||||||
|
class="log-entry level-<%= log.level %>"
|
||||||
|
data-log-entry
|
||||||
|
data-level="<%= log.level %>"
|
||||||
|
data-timestamp="<%= log.created_at %>"
|
||||||
|
data-search="<%= `${log.message} ${log.details || ""}`.toLowerCase() %>"
|
||||||
|
>
|
||||||
|
<summary>
|
||||||
|
<span class="log-marker" aria-hidden="true"></span>
|
||||||
|
<span class="log-message"><%= log.message %></span>
|
||||||
|
<span class="log-level-pill"><%= log.level %></span>
|
||||||
|
<span class="log-time"><%= new Date(log.created_at).toLocaleString() %></span>
|
||||||
|
</summary>
|
||||||
|
<% if (log.details) { %>
|
||||||
|
<pre class="log-details"><%= log.details %></pre>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="log-details empty">No additional details.</div>
|
||||||
|
<% } %>
|
||||||
|
</details>
|
||||||
|
<% }) %>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div class="modal-backdrop" data-log-modal aria-hidden="true">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Download logs</h3>
|
||||||
|
<button type="button" class="icon-button" data-modal-close aria-label="Close">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M6 6l12 12M18 6l-12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form method="get" action="/admin/logs/download" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Timespan</label>
|
||||||
|
<select name="range">
|
||||||
|
<option value="all">All time</option>
|
||||||
|
<option value="<%= 60 * 60 * 1000 %>">Last hour</option>
|
||||||
|
<option value="<%= 24 * 60 * 60 * 1000 %>">Last 24 hours</option>
|
||||||
|
<option value="<%= 7 * 24 * 60 * 60 * 1000 %>">Last week</option>
|
||||||
|
<option value="<%= 30 * 24 * 60 * 60 * 1000 %>">Last month</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label>Severities</label>
|
||||||
|
<div class="checkbox-grid">
|
||||||
|
<label><input type="checkbox" name="level" value="error" /> Error</label>
|
||||||
|
<label><input type="checkbox" name="level" value="warn" /> Warning</label>
|
||||||
|
<label><input type="checkbox" name="level" value="info" /> Info</label>
|
||||||
|
<label><input type="checkbox" name="level" value="debug" /> Debug</label>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Leave unchecked for all severities.</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Entries</label>
|
||||||
|
<select name="limit">
|
||||||
|
<option value="50">50 most recent</option>
|
||||||
|
<option value="100">100 most recent</option>
|
||||||
|
<option value="250">250 most recent</option>
|
||||||
|
<option value="500">500 most recent</option>
|
||||||
|
<option value="all">All entries</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="button subtle" data-modal-close>Cancel</button>
|
||||||
|
<button type="submit" class="button">Download</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<%- include("partials/layout-bottom") %>
|
||||||
427
src/web/views/admin-navigation.ejs
Normal file
@ -0,0 +1,427 @@
|
|||||||
|
<%- include("partials/layout-top", { title }) %>
|
||||||
|
<style>
|
||||||
|
.nav-builder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
.nav-builder-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
.nav-builder-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.nav-section-card,
|
||||||
|
.nav-pool-card {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.nav-section-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.nav-section-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 1fr 1fr auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.nav-section-header input,
|
||||||
|
.nav-section-header select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.nav-dd-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px dashed transparent;
|
||||||
|
background: var(--surface-3);
|
||||||
|
}
|
||||||
|
.nav-dd-list.drag-over {
|
||||||
|
border-color: var(--sea);
|
||||||
|
background: rgba(79, 182, 194, 0.08);
|
||||||
|
}
|
||||||
|
.nav-dd-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--card);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.nav-dd-item.dragging {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.nav-dd-handle {
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
.nav-dd-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
.nav-dd-meta span {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.nav-advanced {
|
||||||
|
display: none;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.nav-advanced.open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.nav-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.nav-toolbar .button {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.nav-builder-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.nav-section-header {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h1>Navigation</h1>
|
||||||
|
<p class="hint">Drag items between sections to build the sidebar layout.</p>
|
||||||
|
<form method="post" action="/admin/navigation" class="form-grid" id="nav-form">
|
||||||
|
<div class="field">
|
||||||
|
<label>Enable custom navigation</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="nav_enabled"
|
||||||
|
<%= navStructure.enabled ? "checked" : "" %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= navStructure.enabled ? "Enabled" : "Disabled" %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Include unassigned items</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="nav_include_unassigned"
|
||||||
|
<%= navStructure.includeUnassigned ? "checked" : "" %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= navStructure.includeUnassigned ? "Enabled" : "Disabled" %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Unassigned section label</label>
|
||||||
|
<input name="nav_unassigned_label" value="<%= navStructure.unassignedLabel || 'Other' %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Unassigned section id</label>
|
||||||
|
<input name="nav_unassigned_id" value="<%= navStructure.unassignedId || 'other' %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Unassigned section icon</label>
|
||||||
|
<select name="nav_unassigned_icon">
|
||||||
|
<option value="">Default (blocks)</option>
|
||||||
|
<% (sectionIcons || []).forEach((icon) => { %>
|
||||||
|
<option value="<%= icon %>" <%= navStructure.unassignedIcon === icon ? 'selected' : '' %>><%= icon %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field full nav-builder">
|
||||||
|
<div class="nav-toolbar">
|
||||||
|
<button type="button" class="button subtle" data-add-section>Add section</button>
|
||||||
|
<button type="button" class="button subtle" data-advanced-toggle>Advanced</button>
|
||||||
|
</div>
|
||||||
|
<div class="nav-builder-body">
|
||||||
|
<div>
|
||||||
|
<div class="nav-builder-header">
|
||||||
|
<h2>Sections</h2>
|
||||||
|
</div>
|
||||||
|
<div class="nav-section-list" data-section-list></div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-pool-card">
|
||||||
|
<div class="nav-builder-header">
|
||||||
|
<h2>Unassigned items</h2>
|
||||||
|
</div>
|
||||||
|
<div class="nav-dd-list" data-unassigned-list></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-advanced" data-advanced-panel>
|
||||||
|
<div class="field full">
|
||||||
|
<label>Sections JSON (advanced)</label>
|
||||||
|
<textarea name="nav_sections" rows="14" id="nav-sections-json"><%- navSectionsJson %></textarea>
|
||||||
|
<p class="hint">Each section needs <code>id</code>, <code>label</code>, <code>icon</code>, and <code>items</code>.</p>
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<button type="button" class="button subtle" data-apply-json>Apply JSON to builder</button>
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label>Default structure</label>
|
||||||
|
<pre><code><%- defaultSectionsJson %></code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="button">Save navigation</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/navigation/reset" class="inline-form">
|
||||||
|
<button type="submit" class="button subtle">Reset to default</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script id="nav-items-data" type="application/json"><%- navItemsJson %></script>
|
||||||
|
<script id="nav-sections-data" type="application/json"><%- navSectionsData %></script>
|
||||||
|
<script id="nav-icons-data" type="application/json"><%- JSON.stringify(sectionIcons || []) %></script>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const navItems = JSON.parse(document.getElementById("nav-items-data").textContent || "[]");
|
||||||
|
const navSections = JSON.parse(document.getElementById("nav-sections-data").textContent || "[]");
|
||||||
|
const navIcons = JSON.parse(document.getElementById("nav-icons-data").textContent || "[]");
|
||||||
|
const form = document.getElementById("nav-form");
|
||||||
|
const sectionList = document.querySelector("[data-section-list]");
|
||||||
|
const unassignedList = document.querySelector("[data-unassigned-list]");
|
||||||
|
const jsonField = document.getElementById("nav-sections-json");
|
||||||
|
const advancedToggle = document.querySelector("[data-advanced-toggle]");
|
||||||
|
const advancedPanel = document.querySelector("[data-advanced-panel]");
|
||||||
|
const addSectionButton = document.querySelector("[data-add-section]");
|
||||||
|
const applyJsonButton = document.querySelector("[data-apply-json]");
|
||||||
|
const itemMap = new Map(navItems.map((item) => [item.navId, item]));
|
||||||
|
|
||||||
|
function buildItem(navId) {
|
||||||
|
const data = itemMap.get(navId) || { navId, label: navId, path: "", role: "public" };
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = "nav-dd-item";
|
||||||
|
item.setAttribute("draggable", "true");
|
||||||
|
item.dataset.navId = navId;
|
||||||
|
item.innerHTML = `
|
||||||
|
<span class="nav-dd-handle">||</span>
|
||||||
|
<div class="nav-dd-meta">
|
||||||
|
<strong>${escapeHtml(data.label || navId)}</strong>
|
||||||
|
<span>${escapeHtml(data.path || navId)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
item.addEventListener("dragstart", () => {
|
||||||
|
item.classList.add("dragging");
|
||||||
|
});
|
||||||
|
item.addEventListener("dragend", () => {
|
||||||
|
item.classList.remove("dragging");
|
||||||
|
syncJson();
|
||||||
|
});
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSection(section) {
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "nav-section-card";
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="nav-section-header">
|
||||||
|
<input type="text" class="nav-section-label" placeholder="Section label" value="${escapeHtml(section.label || "")}">
|
||||||
|
<input type="text" class="nav-section-id" placeholder="section-id" value="${escapeHtml(section.id || "")}">
|
||||||
|
<select class="nav-section-icon"></select>
|
||||||
|
<button type="button" class="button subtle nav-section-remove">Remove</button>
|
||||||
|
</div>
|
||||||
|
<div class="nav-dd-list" data-section-items></div>
|
||||||
|
`;
|
||||||
|
const select = card.querySelector(".nav-section-icon");
|
||||||
|
const selectedIcon = (section.icon || "").toString().toLowerCase();
|
||||||
|
const options = [`<option value="">Icon</option>`].concat(
|
||||||
|
navIcons.map((icon) => {
|
||||||
|
const selected = icon === selectedIcon ? "selected" : "";
|
||||||
|
return `<option value="${icon}" ${selected}>${icon}</option>`;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
select.innerHTML = options.join("");
|
||||||
|
const list = card.querySelector("[data-section-items]");
|
||||||
|
(section.items || []).forEach((navId) => {
|
||||||
|
if (itemMap.has(navId)) {
|
||||||
|
list.appendChild(buildItem(navId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
attachList(list);
|
||||||
|
card.querySelector(".nav-section-remove").addEventListener("click", () => {
|
||||||
|
const items = Array.from(list.querySelectorAll(".nav-dd-item"));
|
||||||
|
items.forEach((item) => unassignedList.appendChild(item));
|
||||||
|
card.remove();
|
||||||
|
syncJson();
|
||||||
|
});
|
||||||
|
card.querySelectorAll("input, select").forEach((input) => {
|
||||||
|
input.addEventListener("change", syncJson);
|
||||||
|
});
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachList(list) {
|
||||||
|
list.addEventListener("dragover", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
list.classList.add("drag-over");
|
||||||
|
const afterElement = getDragAfterElement(list, event.clientY);
|
||||||
|
const dragging = document.querySelector(".nav-dd-item.dragging");
|
||||||
|
if (!dragging) return;
|
||||||
|
if (afterElement == null) {
|
||||||
|
list.appendChild(dragging);
|
||||||
|
} else {
|
||||||
|
list.insertBefore(dragging, afterElement);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
list.addEventListener("dragleave", () => {
|
||||||
|
list.classList.remove("drag-over");
|
||||||
|
});
|
||||||
|
list.addEventListener("drop", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
list.classList.remove("drag-over");
|
||||||
|
syncJson();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDragAfterElement(container, y) {
|
||||||
|
const draggableElements = [
|
||||||
|
...container.querySelectorAll(".nav-dd-item:not(.dragging)")
|
||||||
|
];
|
||||||
|
return draggableElements.reduce(
|
||||||
|
(closest, child) => {
|
||||||
|
const box = child.getBoundingClientRect();
|
||||||
|
const offset = y - box.top - box.height / 2;
|
||||||
|
if (offset < 0 && offset > closest.offset) {
|
||||||
|
return { offset, element: child };
|
||||||
|
}
|
||||||
|
return closest;
|
||||||
|
},
|
||||||
|
{ offset: Number.NEGATIVE_INFINITY, element: null }
|
||||||
|
).element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSections() {
|
||||||
|
const sections = [];
|
||||||
|
sectionList.querySelectorAll(".nav-section-card").forEach((card, index) => {
|
||||||
|
const label = card.querySelector(".nav-section-label").value.trim();
|
||||||
|
const idInput = card.querySelector(".nav-section-id").value.trim();
|
||||||
|
const id = idInput || label || `section-${index + 1}`;
|
||||||
|
const icon = card.querySelector(".nav-section-icon").value.trim();
|
||||||
|
const items = Array.from(card.querySelectorAll(".nav-dd-item")).map(
|
||||||
|
(item) => item.dataset.navId
|
||||||
|
);
|
||||||
|
sections.push({
|
||||||
|
id,
|
||||||
|
label: label || id,
|
||||||
|
icon,
|
||||||
|
items
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncJson() {
|
||||||
|
if (!jsonField) return;
|
||||||
|
jsonField.value = JSON.stringify(collectSections(), null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return (value || "").replace(/[&<>"']/g, (char) => {
|
||||||
|
switch (char) {
|
||||||
|
case "&": return "&";
|
||||||
|
case "<": return "<";
|
||||||
|
case ">": return ">";
|
||||||
|
case "\"": return """;
|
||||||
|
case "'": return "'";
|
||||||
|
default: return char;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInitial() {
|
||||||
|
rebuildFromSections(navSections);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildFromSections(sections) {
|
||||||
|
sectionList.innerHTML = "";
|
||||||
|
unassignedList.innerHTML = "";
|
||||||
|
const assigned = new Set();
|
||||||
|
(sections || []).forEach((section) => {
|
||||||
|
const card = buildSection(section);
|
||||||
|
sectionList.appendChild(card);
|
||||||
|
(section.items || []).forEach((navId) => assigned.add(navId));
|
||||||
|
});
|
||||||
|
navItems.forEach((item) => {
|
||||||
|
if (!assigned.has(item.navId)) {
|
||||||
|
unassignedList.appendChild(buildItem(item.navId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
attachList(unassignedList);
|
||||||
|
syncJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
addSectionButton.addEventListener("click", () => {
|
||||||
|
const existingIds = new Set(
|
||||||
|
Array.from(sectionList.querySelectorAll(".nav-section-id")).map((input) =>
|
||||||
|
input.value.trim().toLowerCase()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
let index = sectionList.querySelectorAll(".nav-section-card").length + 1;
|
||||||
|
let nextId = `section-${index}`;
|
||||||
|
while (existingIds.has(nextId)) {
|
||||||
|
index += 1;
|
||||||
|
nextId = `section-${index}`;
|
||||||
|
}
|
||||||
|
const newSection = buildSection({
|
||||||
|
id: nextId,
|
||||||
|
label: `Section ${index}`,
|
||||||
|
icon: "",
|
||||||
|
items: []
|
||||||
|
});
|
||||||
|
sectionList.appendChild(newSection);
|
||||||
|
syncJson();
|
||||||
|
});
|
||||||
|
|
||||||
|
advancedToggle.addEventListener("click", () => {
|
||||||
|
advancedPanel.classList.toggle("open");
|
||||||
|
});
|
||||||
|
|
||||||
|
applyJsonButton.addEventListener("click", () => {
|
||||||
|
if (!jsonField) return;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonField.value || "[]");
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
window.alert("JSON must be an array of sections.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rebuildFromSections(parsed);
|
||||||
|
} catch (error) {
|
||||||
|
window.alert("JSON is invalid.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener("submit", () => {
|
||||||
|
syncJson();
|
||||||
|
});
|
||||||
|
|
||||||
|
buildInitial();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<%- include("partials/layout-bottom") %>
|
||||||
170
src/web/views/admin-pages.ejs
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
<%- include("partials/layout-top", { title }) %>
|
||||||
|
<section class="card">
|
||||||
|
<h1>Custom pages</h1>
|
||||||
|
<form method="post" action="/admin/pages" class="form-grid" data-page-form>
|
||||||
|
<div class="field">
|
||||||
|
<label>Slug</label>
|
||||||
|
<input name="slug" placeholder="welcome" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Title</label>
|
||||||
|
<input name="title" placeholder="Welcome" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Navigation label (optional)</label>
|
||||||
|
<input name="nav_label" placeholder="Welcome" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Format</label>
|
||||||
|
<select name="format" data-page-format>
|
||||||
|
<option value="html" selected>HTML + CSS</option>
|
||||||
|
<option value="markdown">Markdown</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Role</label>
|
||||||
|
<select name="role">
|
||||||
|
<option value="public">Public</option>
|
||||||
|
<option value="mod">Moderator</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Show in navigation</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" class="switch-input" name="show_in_nav" />
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text">Show in nav</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field full" data-page-css>
|
||||||
|
<label>CSS</label>
|
||||||
|
<textarea name="content_css" rows="4" placeholder=".hero { padding: 24px; }"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label data-page-content-label>HTML</label>
|
||||||
|
<textarea name="content" rows="6" data-page-content></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button">Create page</button>
|
||||||
|
</form>
|
||||||
|
<h2>Existing pages</h2>
|
||||||
|
<% if (!pages.length) { %>
|
||||||
|
<p>No pages created yet.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Slug</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% pages.forEach((page) => { %>
|
||||||
|
<% const pageFormat = (page.format || "html").toString().toLowerCase(); %>
|
||||||
|
<tr>
|
||||||
|
<td><%= page.slug %></td>
|
||||||
|
<td><%= page.title %></td>
|
||||||
|
<td><%= page.role %></td>
|
||||||
|
<td><%= page.enabled ? "Enabled" : "Disabled" %></td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/admin/pages/<%= page.id %>/toggle" class="inline-form">
|
||||||
|
<button type="submit" class="button subtle"><%= page.enabled ? "Disable" : "Enable" %></button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/pages/<%= page.id %>/delete" class="inline-form">
|
||||||
|
<button type="submit" class="button danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button subtle"
|
||||||
|
data-edit-toggle="page-<%= page.id %>"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="edit-row" data-edit-row="page-<%= page.id %>">
|
||||||
|
<td colspan="5">
|
||||||
|
<form method="post" action="/admin/pages/<%= page.id %>/update" class="form-grid" data-page-form>
|
||||||
|
<div class="field">
|
||||||
|
<label>Slug</label>
|
||||||
|
<input name="slug" value="<%= page.slug %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Title</label>
|
||||||
|
<input name="title" value="<%= page.title %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Navigation label</label>
|
||||||
|
<input name="nav_label" value="<%= page.nav_label || '' %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Format</label>
|
||||||
|
<select name="format" data-page-format>
|
||||||
|
<option value="html" <%= pageFormat === 'html' ? 'selected' : '' %>>HTML + CSS</option>
|
||||||
|
<option value="markdown" <%= pageFormat === 'markdown' ? 'selected' : '' %>>Markdown</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Role</label>
|
||||||
|
<select name="role">
|
||||||
|
<option value="public" <%= page.role === 'public' ? 'selected' : '' %>>Public</option>
|
||||||
|
<option value="mod" <%= page.role === 'mod' ? 'selected' : '' %>>Moderator</option>
|
||||||
|
<option value="admin" <%= page.role === 'admin' ? 'selected' : '' %>>Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Show in navigation</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="show_in_nav"
|
||||||
|
<%= page.show_in_nav ? 'checked' : '' %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= page.show_in_nav ? 'Visible' : 'Hidden' %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field full" data-page-css>
|
||||||
|
<label>CSS</label>
|
||||||
|
<textarea name="content_css" rows="4"><%= page.content_css || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label data-page-content-label>HTML</label>
|
||||||
|
<textarea name="content" rows="6" data-page-content><%= page.content %></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button">Save</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
<script>
|
||||||
|
const pageForms = document.querySelectorAll("[data-page-form]");
|
||||||
|
const updatePageFields = (form) => {
|
||||||
|
const format = form.querySelector("[data-page-format]")?.value || "html";
|
||||||
|
const cssField = form.querySelector("[data-page-css]");
|
||||||
|
const label = form.querySelector("[data-page-content-label]");
|
||||||
|
const content = form.querySelector("[data-page-content]");
|
||||||
|
const isMarkdown = format === "markdown";
|
||||||
|
if (cssField) cssField.style.display = isMarkdown ? "none" : "";
|
||||||
|
if (label) label.textContent = isMarkdown ? "Markdown" : "HTML";
|
||||||
|
if (content) {
|
||||||
|
content.placeholder = isMarkdown ? "# Welcome" : "<h1>Welcome</h1>";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pageForms.forEach((form) => {
|
||||||
|
updatePageFields(form);
|
||||||
|
form.querySelector("[data-page-format]")?.addEventListener("change", () => {
|
||||||
|
updatePageFields(form);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<%- include("partials/layout-bottom") %>
|
||||||
78
src/web/views/admin-plugins.ejs
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<%- include("partials/layout-top", { title }) %>
|
||||||
|
<section class="card">
|
||||||
|
<h1>Plugins</h1>
|
||||||
|
<h2>Installed plugins</h2>
|
||||||
|
<% if (!plugins.length) { %>
|
||||||
|
<p>No plugins installed.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Version</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% plugins.forEach((plugin) => { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= plugin.name %></td>
|
||||||
|
<td><%= plugin.version || "-" %></td>
|
||||||
|
<td><%= plugin.enabled ? "Enabled" : "Disabled" %></td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/admin/plugins/<%= plugin.id %>/toggle" class="inline-form">
|
||||||
|
<input type="hidden" name="enabled" value="<%= plugin.enabled ? 'false' : 'true' %>" />
|
||||||
|
<button type="submit" class="button subtle"><%= plugin.enabled ? "Disable" : "Enable" %></button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/plugins/<%= plugin.id %>/update" class="inline-form">
|
||||||
|
<button type="submit" class="button subtle">Update</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/plugins/<%= plugin.id %>/uninstall" class="inline-form">
|
||||||
|
<button type="submit" class="button danger">Uninstall</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
<section class="card">
|
||||||
|
<h2>Install plugin from ZIP</h2>
|
||||||
|
<form method="post" action="/admin/plugins/upload" enctype="multipart/form-data" class="form-grid">
|
||||||
|
<div class="field full">
|
||||||
|
<input type="file" name="plugin_zip" accept=".zip" required />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button">Upload plugin</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="card">
|
||||||
|
<h2>Install plugin from git</h2>
|
||||||
|
<form method="post" action="/admin/plugins/install" class="form-grid">
|
||||||
|
<div class="field full">
|
||||||
|
<label>Repository URL</label>
|
||||||
|
<input name="url" placeholder="https://gitea.example.com/org/plugin.git" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button">Install plugin</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="card">
|
||||||
|
<h2>Create local plugin</h2>
|
||||||
|
<form method="post" action="/admin/plugins/create" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Plugin ID</label>
|
||||||
|
<input name="id" placeholder="my-plugin" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Name</label>
|
||||||
|
<input name="name" />
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label>Description</label>
|
||||||
|
<input name="description" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button">Create plugin</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<%- include("partials/layout-bottom") %>
|
||||||
105
src/web/views/admin-privileges.ejs
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
<%- include("partials/layout-top", { title }) %>
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h1>Discord Privileges</h1>
|
||||||
|
<p class="command-subtitle">Verify the bot's permissions inside the configured server.</p>
|
||||||
|
<p class="hint">
|
||||||
|
Guild:
|
||||||
|
<strong><%= discord && discord.guildName ? discord.guildName : "Not connected" %></strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="table-tools">
|
||||||
|
<input
|
||||||
|
class="table-search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search Discord privileges"
|
||||||
|
aria-label="Search Discord privileges"
|
||||||
|
data-table-filter="discord-privileges"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table" data-table="discord-privileges">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-sort="label">Privilege</th>
|
||||||
|
<th data-sort="description">Details</th>
|
||||||
|
<th data-sort="status">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% (discord && discord.rows ? discord.rows : []).forEach((row) => { %>
|
||||||
|
<tr
|
||||||
|
data-search="<%= row.search %>"
|
||||||
|
data-label="<%= row.sort.label %>"
|
||||||
|
data-description="<%= row.sort.description %>"
|
||||||
|
data-status="<%= row.sort.status %>"
|
||||||
|
>
|
||||||
|
<td><strong><%= row.label %></strong></td>
|
||||||
|
<td><%= row.description || "-" %></td>
|
||||||
|
<td>
|
||||||
|
<span class="perm-toggle <%= row.granted ? "on" : "off" %>" aria-hidden="true">
|
||||||
|
<span class="perm-thumb"></span>
|
||||||
|
</span>
|
||||||
|
<span class="perm-label"><%= row.granted ? "Granted" : "Missing" %></span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-header">
|
||||||
|
<div>
|
||||||
|
<h1>Twitch Privileges</h1>
|
||||||
|
<p class="command-subtitle">Confirm Twitch configuration and chat connectivity.</p>
|
||||||
|
<p class="hint">
|
||||||
|
Channels configured:
|
||||||
|
<strong><%= twitch && Number.isFinite(twitch.channelCount) ? twitch.channelCount : 0 %></strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="table-tools">
|
||||||
|
<input
|
||||||
|
class="table-search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search Twitch privileges"
|
||||||
|
aria-label="Search Twitch privileges"
|
||||||
|
data-table-filter="twitch-privileges"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table" data-table="twitch-privileges">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-sort="label">Privilege</th>
|
||||||
|
<th data-sort="description">Details</th>
|
||||||
|
<th data-sort="status">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% (twitch && twitch.rows ? twitch.rows : []).forEach((row) => { %>
|
||||||
|
<tr
|
||||||
|
data-search="<%= row.search %>"
|
||||||
|
data-label="<%= row.sort.label %>"
|
||||||
|
data-description="<%= row.sort.description %>"
|
||||||
|
data-status="<%= row.sort.status %>"
|
||||||
|
>
|
||||||
|
<td><strong><%= row.label %></strong></td>
|
||||||
|
<td><%= row.description || "-" %></td>
|
||||||
|
<td>
|
||||||
|
<span class="perm-toggle <%= row.granted ? "on" : "off" %>" aria-hidden="true">
|
||||||
|
<span class="perm-thumb"></span>
|
||||||
|
</span>
|
||||||
|
<span class="perm-label"><%= row.granted ? "Granted" : "Missing" %></span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<%- include("partials/layout-bottom") %>
|
||||||
106
src/web/views/admin-settings.ejs
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
<%- include("partials/layout-top", { title }) %>
|
||||||
|
<section class="card">
|
||||||
|
<h1>Settings</h1>
|
||||||
|
<form method="post" action="/admin/settings" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Site title</label>
|
||||||
|
<input name="site_title" value="<%= settings.site_title || '' %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Command prefix</label>
|
||||||
|
<input name="command_prefix" value="<%= settings.command_prefix || '!' %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Auto update enabled</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="auto_update_enabled"
|
||||||
|
<%= settings.auto_update_enabled ? 'checked' : '' %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= settings.auto_update_enabled ? 'Enabled' : 'Disabled' %></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Auto update interval (minutes)</label>
|
||||||
|
<input name="auto_update_interval_minutes" value="<%= settings.auto_update_interval_minutes || 60 %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Git remote</label>
|
||||||
|
<input name="git_remote" value="<%= settings.git_remote || 'origin' %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Git branch</label>
|
||||||
|
<input name="git_branch" value="<%= settings.git_branch || 'main' %>" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field full">
|
||||||
|
<h2>Platform Integration</h2>
|
||||||
|
<p class="hint">Enable or disable platform adapters and run the setup wizards.</p>
|
||||||
|
<div class="platform-grid">
|
||||||
|
<% (platforms || []).forEach((platform) => { %>
|
||||||
|
<div class="platform-card">
|
||||||
|
<div class="platform-card-header">
|
||||||
|
<strong><%= platform.label %></strong>
|
||||||
|
<% if (!platform.supported) { %>
|
||||||
|
<span class="level-pill">Coming soon</span>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<label class="platform-toggle-row switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="switch-input"
|
||||||
|
name="<%= platform.enabledKey %>"
|
||||||
|
<%= platform.enabled ? 'checked' : '' %>
|
||||||
|
<%= platform.supported ? '' : 'disabled' %>
|
||||||
|
/>
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text"><%= platform.enabled ? 'Enabled' : 'Disabled' %></span>
|
||||||
|
</label>
|
||||||
|
<% if (platform.supported) { %>
|
||||||
|
<div class="platform-meta">
|
||||||
|
<a class="link" href="<%= platform.wizardPath %>">Open wizard</a>
|
||||||
|
<span class="hint"><%= platform.configured ? 'Configured' : 'Not configured' %></span>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<p class="hint">Support planned for a future update.</p>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="button">Save settings</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="card">
|
||||||
|
<h2>Navigation icons</h2>
|
||||||
|
<p class="hint">Upload SVG or PNG icons for sidebar sublinks.</p>
|
||||||
|
<div class="nav-icon-grid">
|
||||||
|
<% (navIconItems || []).forEach((item) => { %>
|
||||||
|
<div class="nav-icon-row">
|
||||||
|
<div class="nav-icon-info">
|
||||||
|
<img class="nav-icon-preview" src="<%= item.icon %>" alt="" />
|
||||||
|
<div>
|
||||||
|
<strong><%= item.label %></strong>
|
||||||
|
<div class="hint"><%= item.path %></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-icon-actions">
|
||||||
|
<form method="post" action="/admin/settings/nav-icons" enctype="multipart/form-data" class="inline-form">
|
||||||
|
<input type="hidden" name="item_id" value="<%= item.id %>" />
|
||||||
|
<input type="file" name="icon_file" accept="image/svg+xml,image/png" />
|
||||||
|
<button type="submit" class="button subtle">Upload</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/settings/nav-icons/reset" class="inline-form">
|
||||||
|
<input type="hidden" name="item_id" value="<%= item.id %>" />
|
||||||
|
<button type="submit" class="button subtle">Reset</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<%- include("partials/layout-bottom") %>
|
||||||
122
src/web/views/admin-theme.ejs
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
<%- include("partials/layout-top", { title }) %>
|
||||||
|
<section class="card">
|
||||||
|
<h1>Theming</h1>
|
||||||
|
<p>Update light and dark mode colors used across the WebUI.</p>
|
||||||
|
<form method="post" action="/admin/theming" class="form-grid theme-grid">
|
||||||
|
<h2>Light mode</h2>
|
||||||
|
<div class="field">
|
||||||
|
<label>Background 1</label>
|
||||||
|
<input type="color" name="theme_light_bg_1" value="<%= theme.light.bg1 %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Background 2</label>
|
||||||
|
<input type="color" name="theme_light_bg_2" value="<%= theme.light.bg2 %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Background 3</label>
|
||||||
|
<input type="color" name="theme_light_bg_3" value="<%= theme.light.bg3 %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Text</label>
|
||||||
|
<input type="color" name="theme_light_text" value="<%= theme.light.text %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Muted text</label>
|
||||||
|
<input type="color" name="theme_light_text_muted" value="<%= theme.light.muted %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Accent</label>
|
||||||
|
<input type="color" name="theme_light_accent" value="<%= theme.light.accent %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Accent alt</label>
|
||||||
|
<input type="color" name="theme_light_accent_alt" value="<%= theme.light.accentAlt %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Danger</label>
|
||||||
|
<input type="color" name="theme_light_danger" value="<%= theme.light.danger %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Surface</label>
|
||||||
|
<input type="color" name="theme_light_surface" value="<%= theme.light.surface %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Surface 2</label>
|
||||||
|
<input type="color" name="theme_light_surface_2" value="<%= theme.light.surface2 %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Surface 3</label>
|
||||||
|
<input type="color" name="theme_light_surface_3" value="<%= theme.light.surface3 %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Border</label>
|
||||||
|
<input type="color" name="theme_light_border" value="<%= theme.light.border %>" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Dark mode</h2>
|
||||||
|
<div class="field">
|
||||||
|
<label>Background 1</label>
|
||||||
|
<input type="color" name="theme_dark_bg_1" value="<%= theme.dark.bg1 %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Background 2</label>
|
||||||
|
<input type="color" name="theme_dark_bg_2" value="<%= theme.dark.bg2 %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Background 3</label>
|
||||||
|
<input type="color" name="theme_dark_bg_3" value="<%= theme.dark.bg3 %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Text</label>
|
||||||
|
<input type="color" name="theme_dark_text" value="<%= theme.dark.text %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Muted text</label>
|
||||||
|
<input type="color" name="theme_dark_text_muted" value="<%= theme.dark.muted %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Accent</label>
|
||||||
|
<input type="color" name="theme_dark_accent" value="<%= theme.dark.accent %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Accent alt</label>
|
||||||
|
<input type="color" name="theme_dark_accent_alt" value="<%= theme.dark.accentAlt %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Danger</label>
|
||||||
|
<input type="color" name="theme_dark_danger" value="<%= theme.dark.danger %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Surface</label>
|
||||||
|
<input type="color" name="theme_dark_surface" value="<%= theme.dark.surface %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Surface 2</label>
|
||||||
|
<input type="color" name="theme_dark_surface_2" value="<%= theme.dark.surface2 %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Surface 3</label>
|
||||||
|
<input type="color" name="theme_dark_surface_3" value="<%= theme.dark.surface3 %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Border</label>
|
||||||
|
<input type="color" name="theme_dark_border" value="<%= theme.dark.border %>" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Role colors</h2>
|
||||||
|
<div class="field">
|
||||||
|
<label>Public</label>
|
||||||
|
<input type="color" name="theme_role_public" value="<%= theme.role.public %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Moderator</label>
|
||||||
|
<input type="color" name="theme_role_mod" value="<%= theme.role.mod %>" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Admin</label>
|
||||||
|
<input type="color" name="theme_role_admin" value="<%= theme.role.admin %>" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="button">Save theme</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<%- include("partials/layout-bottom") %>
|
||||||
63
src/web/views/admin-updates.ejs
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
<%- include("partials/layout-top", { title }) %>
|
||||||
|
<section class="card">
|
||||||
|
<h1>Updates</h1>
|
||||||
|
<p>Upload ZIP archives for core bot updates or plugin updates. A snapshot is taken before each update.</p>
|
||||||
|
<p class="hint">Rollback is handled from Safe Mode if something breaks.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Upload bot update</h2>
|
||||||
|
<form method="post" action="/admin/updates/bot" enctype="multipart/form-data" class="form-grid">
|
||||||
|
<div class="field full">
|
||||||
|
<input type="file" name="update_zip" accept=".zip" required />
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label>Patch mode (apply only files in ZIP, skip full package verification)</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" class="switch-input" name="patch_mode" value="1" />
|
||||||
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
<span class="switch-text">Patch mode</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<button type="submit" class="button">Upload bot update</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Upload plugin update</h2>
|
||||||
|
<form method="post" action="/admin/updates/plugin" enctype="multipart/form-data" class="form-grid">
|
||||||
|
<div class="field full">
|
||||||
|
<input type="file" name="plugin_zip" accept=".zip" required />
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<button type="submit" class="button">Upload plugin update</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Snapshots</h2>
|
||||||
|
<% if (!snapshots.length) { %>
|
||||||
|
<p>No snapshots yet.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Snapshot</th>
|
||||||
|
<th>Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% snapshots.forEach((snap) => { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= snap.type === 'plugin' ? `Plugin: ${snap.pluginId}` : 'Bot core' %></td>
|
||||||
|
<td><%= new Date(snap.createdAt).toLocaleString() %></td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
<%- include("partials/layout-bottom") %>
|
||||||
110
src/web/views/admin-users.ejs
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
<%- include("partials/layout-top", { title }) %>
|
||||||
|
<section class="card">
|
||||||
|
<h1>Users</h1>
|
||||||
|
<% if (!users.length) { %>
|
||||||
|
<p>No users yet.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="table-tools">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
class="table-search"
|
||||||
|
placeholder="Search users or identities"
|
||||||
|
data-table-filter="user-list"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<table class="table" data-table="user-list">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-sort="username">Internal username</th>
|
||||||
|
<th data-sort="identities">Identities</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
<% if (isAdmin) { %>
|
||||||
|
<th>Update username</th>
|
||||||
|
<% } %>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% users.forEach((user) => { %>
|
||||||
|
<% const notes = (notesByUser && notesByUser[user.id]) ? notesByUser[user.id] : []; %>
|
||||||
|
<tr
|
||||||
|
data-username="<%= user.internal_username.toLowerCase() %>"
|
||||||
|
data-identities="<%= user.identities.length %>"
|
||||||
|
data-search="<%= [user.internal_username, ...user.identities.map((identity) => identity.display_name || identity.provider_user_id)].join(' ').toLowerCase() %>"
|
||||||
|
>
|
||||||
|
<td><%= user.internal_username %></td>
|
||||||
|
<td>
|
||||||
|
<% if (!user.identities.length) { %>
|
||||||
|
<span>None</span>
|
||||||
|
<% } else { %>
|
||||||
|
<ul class="identity-list">
|
||||||
|
<% user.identities.forEach((identity) => { %>
|
||||||
|
<li>
|
||||||
|
<strong><%= identity.provider %></strong>
|
||||||
|
<span><%= identity.display_name || identity.provider_user_id %></span>
|
||||||
|
</li>
|
||||||
|
<% }) %>
|
||||||
|
</ul>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<% if (notes.length) { %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button subtle"
|
||||||
|
data-notes-open="<%= user.id %>"
|
||||||
|
>
|
||||||
|
Notes (<%= notes.length %>)
|
||||||
|
</button>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="badge">No notes</span>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
<% if (isAdmin) { %>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/admin/users/<%= user.id %>/username" class="inline-form">
|
||||||
|
<input name="internal_username" value="<%= user.internal_username %>" />
|
||||||
|
<button type="submit" class="button subtle">Save</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<% } %>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
<div class="modal-backdrop" data-notes-modal aria-hidden="true">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>User notes</h2>
|
||||||
|
<button type="button" class="icon-button" data-notes-close aria-label="Close">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6l-12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div data-notes-body></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="button subtle" data-notes-close>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const notesByUser = <%- JSON.stringify(notesByUser || {}) %>;
|
||||||
|
const modal = document.querySelector("[data-notes-modal]");
|
||||||
|
const body = modal?.querySelector("[data-notes-body]");
|
||||||
|
const closeButtons = modal?.querySelectorAll("[data-notes-close]") || [];
|
||||||
|
if (!modal || !body) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const openButtons = document.querySelectorAll("[data-notes-open]");
|
||||||
|
const openModal = (userId) => {
|
||||||
|
const notes = notesByUser[userId] || [];
|
||||||
|
body.innerHTML = notes.length
|
||||||
|
? notes
|
||||||
|
.map(
|
||||||
|
(note) =>
|
||||||
|
`<div class=\"moderation-note\">\n<strong>${note.created_by_name || "Staff"}</strong>\n<p>${note.note}</p>\n<span class=\"hint\">${new Date(note.created_at).toLocaleString()}</span>\n</div>`
|
||||||
|
)
|
||||||
|
.join(\"\\n\")
|
||||||
|
: \"<p>No notes recorded.</p>\";\n modal.classList.add(\"is-open\");\n modal.setAttribute(\"aria-hidden\", \"false\");\n };\n const closeModal = () => {\n modal.classList.remove(\"is-open\");\n modal.setAttribute(\"aria-hidden\", \"true\");\n };\n openButtons.forEach((button) => {\n button.addEventListener(\"click\", () => openModal(button.dataset.notesOpen));\n });\n closeButtons.forEach((button) => {\n button.addEventListener(\"click\", closeModal);\n });\n modal.addEventListener(\"click\", (event) => {\n if (event.target === modal) {\n closeModal();\n }\n });\n })();\n</script>
|
||||||
|
<%- include("partials/layout-bottom") %>
|
||||||
176
src/web/views/commands.ejs
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
<%- include("partials/layout-top", { title }) %>
|
||||||
|
<section class="card">
|
||||||
|
<div class="commands-header">
|
||||||
|
<div>
|
||||||
|
<h1>Commands</h1>
|
||||||
|
<p class="command-subtitle">Auto-updated list of core and plugin commands.</p>
|
||||||
|
</div>
|
||||||
|
<div class="table-tools">
|
||||||
|
<input
|
||||||
|
class="table-search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search commands"
|
||||||
|
aria-label="Search commands"
|
||||||
|
data-table-filter="commands"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="table-note">
|
||||||
|
<strong>Usage:</strong> Use <code><arg></code> for required arguments and <code>[arg]</code> for optional ones.
|
||||||
|
</p>
|
||||||
|
<% if (isAdmin && conflicts.length) { %>
|
||||||
|
<div class="conflict-card">
|
||||||
|
<strong>Command conflicts detected.</strong>
|
||||||
|
<p>These triggers overlap on the same platform and may shadow each other.</p>
|
||||||
|
<ul>
|
||||||
|
<% conflicts.forEach((conflict) => { %>
|
||||||
|
<li>
|
||||||
|
<code><%= conflict.triggerDisplay %></code> on <strong><%= conflict.platformLabel %></strong>: <%= conflict.sourcesLabel %>
|
||||||
|
</li>
|
||||||
|
<% }) %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<% if (!commandGroups.length) { %>
|
||||||
|
<p>No commands registered yet.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table commands-table" data-table="commands">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-sort="trigger">Trigger</th>
|
||||||
|
<th data-sort="name">Name</th>
|
||||||
|
<th data-sort="description">Description</th>
|
||||||
|
<th data-sort="level">Level</th>
|
||||||
|
<th data-sort="platform">Platform</th>
|
||||||
|
<th data-sort="origin">Origin</th>
|
||||||
|
<th data-sort="count">Count</th>
|
||||||
|
<th>Link</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% 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, ""); %>
|
||||||
|
<tr
|
||||||
|
id="<%= root.anchor %>"
|
||||||
|
class="command-root"
|
||||||
|
data-command-root="<%= root.groupKey %>"
|
||||||
|
data-search="<%= root.search %>"
|
||||||
|
data-trigger="<%= root.sort.trigger %>"
|
||||||
|
data-name="<%= root.sort.name %>"
|
||||||
|
data-description="<%= root.sort.description %>"
|
||||||
|
data-level="<%= root.sort.level %>"
|
||||||
|
data-platform="<%= root.sort.platform %>"
|
||||||
|
data-origin="<%= root.sort.origin %>"
|
||||||
|
data-count="<%= root.sort.count %>"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<div class="command-trigger">
|
||||||
|
<% if (hasSubcommands) { %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="command-toggle"
|
||||||
|
data-command-toggle
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Toggle subcommands"
|
||||||
|
></button>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="command-toggle spacer" aria-hidden="true"></span>
|
||||||
|
<% } %>
|
||||||
|
<button type="button" class="copy-pill" data-copy="<%= root.triggerDisplay %>" title="Copy trigger">
|
||||||
|
<code><%= root.triggerDisplay %></code>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="command-name" title="<%= root.name %>"><%= root.name %></span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="command-desc" title="<%= root.description %>"><%= root.description || "-" %></span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="level-pill <%= rootLevelClass ? `level-${rootLevelClass}` : '' %>"
|
||||||
|
<% if (root.levelHelp) { %>title="<%= root.levelHelp %>"<% } %>
|
||||||
|
>
|
||||||
|
<%= root.level %>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="platform-pills">
|
||||||
|
<% root.platformLabels.forEach((platform) => { %>
|
||||||
|
<span class="badge <%= platform.key %>"><%= platform.label %></span>
|
||||||
|
<% }) %>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td><span class="origin-pill"><%= root.origin %></span></td>
|
||||||
|
<td class="command-count"><%= root.count %></td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="copy-pill copy-link" data-copy="<%= root.link %>" title="Copy link">
|
||||||
|
<span data-copy-label>Copy link</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% subcommands.forEach((command) => { %>
|
||||||
|
<% const commandLevelClass = (command.level || "").toString().toLowerCase().replace(/[^a-z0-9-]/g, ""); %>
|
||||||
|
<tr
|
||||||
|
id="<%= command.anchor %>"
|
||||||
|
class="command-subrow"
|
||||||
|
data-command-parent="<%= root.groupKey %>"
|
||||||
|
data-search="<%= command.search %>"
|
||||||
|
data-trigger="<%= command.sort.trigger %>"
|
||||||
|
data-name="<%= command.sort.name %>"
|
||||||
|
data-description="<%= command.sort.description %>"
|
||||||
|
data-level="<%= command.sort.level %>"
|
||||||
|
data-platform="<%= command.sort.platform %>"
|
||||||
|
data-origin="<%= command.sort.origin %>"
|
||||||
|
data-count="<%= command.sort.count %>"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<div class="command-trigger">
|
||||||
|
<span class="command-toggle spacer" aria-hidden="true"></span>
|
||||||
|
<button type="button" class="copy-pill" data-copy="<%= command.triggerDisplay %>" title="Copy trigger">
|
||||||
|
<code><%= command.triggerDisplay %></code>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="command-name" title="<%= command.name %>"><%= command.name %></span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="command-desc" title="<%= command.description %>"><%= command.description || "-" %></span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="level-pill <%= commandLevelClass ? `level-${commandLevelClass}` : '' %>"
|
||||||
|
<% if (command.levelHelp) { %>title="<%= command.levelHelp %>"<% } %>
|
||||||
|
>
|
||||||
|
<%= command.level %>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="platform-pills">
|
||||||
|
<% command.platformLabels.forEach((platform) => { %>
|
||||||
|
<span class="badge <%= platform.key %>"><%= platform.label %></span>
|
||||||
|
<% }) %>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td><span class="origin-pill"><%= command.origin %></span></td>
|
||||||
|
<td class="command-count"><%= command.count %></td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="copy-pill copy-link" data-copy="<%= command.link %>" title="Copy link">
|
||||||
|
<span data-copy-label>Copy link</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
<%- include("partials/layout-bottom") %>
|
||||||
62
src/web/views/custom-page.ejs
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
<%- include("partials/layout-top", { title }) %>
|
||||||
|
<section class="card">
|
||||||
|
<h1><%= page.title %></h1>
|
||||||
|
<% if (page.format === "markdown") { %>
|
||||||
|
<div class="page-content"><%- renderedContent %></div>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="page-content">
|
||||||
|
<iframe class="custom-page-frame" title="<%= page.title %>" sandbox="allow-same-origin"></iframe>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const frame = document.querySelector(".custom-page-frame");
|
||||||
|
if (!frame) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resizeFrame = () => {
|
||||||
|
try {
|
||||||
|
const doc = frame.contentDocument || frame.contentWindow?.document;
|
||||||
|
if (!doc || !doc.body) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const height = Math.max(
|
||||||
|
doc.body.scrollHeight || 0,
|
||||||
|
doc.body.offsetHeight || 0,
|
||||||
|
doc.documentElement?.scrollHeight || 0,
|
||||||
|
doc.documentElement?.offsetHeight || 0
|
||||||
|
);
|
||||||
|
if (height) {
|
||||||
|
frame.style.height = `${height}px`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore frame sizing errors.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
frame.addEventListener("load", () => {
|
||||||
|
resizeFrame();
|
||||||
|
window.setTimeout(resizeFrame, 200);
|
||||||
|
if (window.ResizeObserver) {
|
||||||
|
try {
|
||||||
|
const doc = frame.contentDocument || frame.contentWindow?.document;
|
||||||
|
if (doc?.body) {
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
resizeFrame();
|
||||||
|
});
|
||||||
|
observer.observe(doc.body);
|
||||||
|
if (doc.documentElement) {
|
||||||
|
observer.observe(doc.documentElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore observer errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener("resize", resizeFrame);
|
||||||
|
frame.srcdoc = <%- JSON.stringify(pageSrcdoc || "") %>;
|
||||||
|
window.setTimeout(resizeFrame, 0);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
<%- include("partials/layout-bottom") %>
|
||||||
7
src/web/views/error.ejs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<%- include("partials/layout-top", { title }) %>
|
||||||
|
<section class="card">
|
||||||
|
<h1><%= title %></h1>
|
||||||
|
<p><%= message %></p>
|
||||||
|
<a href="/" class="link">Return home</a>
|
||||||
|
</section>
|
||||||
|
<%- include("partials/layout-bottom") %>
|
||||||
27
src/web/views/home.ejs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<%- include("partials/layout-top", { title }) %>
|
||||||
|
<section class="hero">
|
||||||
|
<h1>Welcome to <%= siteTitle %></h1>
|
||||||
|
<p>Manage the bot, explore stats, and extend features from the WebUI.</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<a class="button" href="/commands">View commands</a>
|
||||||
|
<a class="button subtle" href="/leaderboards">Leaderboards</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Bot control</h2>
|
||||||
|
<p>Configure settings, update the bot, and manage plugins from the dashboard.</p>
|
||||||
|
<a href="/admin" class="link">Open admin dashboard</a>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Community stats</h2>
|
||||||
|
<p>Track activity, view leaderboards, and browse profile stats.</p>
|
||||||
|
<a href="/leaderboards" class="link">View leaderboards</a>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Custom modules</h2>
|
||||||
|
<p>Create commands and pages directly in the WebUI.</p>
|
||||||
|
<a href="/admin/commands" class="link">Manage commands</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<%- include("partials/layout-bottom") %>
|
||||||