Initial sanitized import

This commit is contained in:
Franz Rolfsvaag 2026-05-30 20:37:42 +02:00
commit 11e5a4e6f8
114 changed files with 28693 additions and 0 deletions

39
.env.example Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

61
README.md Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

171
codex-guidelines Normal file
View File

@ -0,0 +1,171 @@
Project: Lumi Bot (Discord + Twitch + YouTube) — WebUI-first management
Purpose of this file
- Single source of truth for crossconversation 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 90day 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 serverside
- 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

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

View 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
View 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
};

View 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."
}
}

View 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>

View 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>"
}
]
}

File diff suppressed because it is too large Load Diff

View 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"
}

View 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
};

View 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."
}
}

View File

@ -0,0 +1 @@
ok

View 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") %>

View 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") %>

View 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>"
}
]
}

File diff suppressed because it is too large Load Diff

View 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"
}

View 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") %>

View File

@ -0,0 +1,9 @@
{
"pluginId": "expression-interaction",
"pluginName": "Expression Interaction",
"platformKeys": {
"discord": "platform_discord",
"twitch": "platform_twitch"
},
"commands": []
}

View 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;
}

View 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"
}

View 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

File diff suppressed because it is too large Load Diff

View 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"
}

View 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") %>

View 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>

View 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
View 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
View 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;
}

View 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
View 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
View 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."
}
}

View 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") %>

View 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"
});
}
};

View 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
View 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
View 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}`);
});

View 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
View 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
View 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
View 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
};

View 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
View 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
View 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
View 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
View 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
View 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
View 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
};

View 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
View 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
View 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
View 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
View 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
View 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
View 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
};

View 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
View 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
View 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
View 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
View 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();
}
});
});
})();

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

File diff suppressed because it is too large Load Diff

4771
src/web/server.js Normal file

File diff suppressed because it is too large Load Diff

View 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") %>

View 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") %>

View 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") %>

View 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 "&amp;";
case "<": return "&lt;";
case ">": return "&gt;";
case "\"": return "&quot;";
case "'": return "&#39;";
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") %>

View 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") %>

View 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") %>

View 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") %>

View 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") %>

View 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") %>

View 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") %>

View 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
View 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>&lt;arg&gt;</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") %>

View 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
View 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
View 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") %>

Some files were not shown because too many files have changed in this diff Show More