feat: Implement Tour Schedule Engine with queue management and announcement features
CI-CD / Bot Lint Test Build (push) Failing after 2m10s
CI-CD / Dashboard Lint Build (push) Successful in 17s
CI-CD / Deploy to Coolify (push) Has been skipped

- Added TourScheduleEngine class for managing user queues in a guild.
- Implemented methods for joining, leaving, listing, and clearing queues.
- Added functionality to promote users to speaker in a stage channel and send announcements.
- Created integration tests for the TourScheduleEngine to verify FIFO behavior and announcement dispatch.

test: Add unit tests for ping and sign-up commands

- Created tests for ping command to ensure it replies with "Pong!".
- Implemented tests for sign-up command to verify queue joining, listing, and permission checks.

test: Add integration tests for mileage engine flow

- Developed tests to validate mileage awarding, event persistence, and role upgrades based on mileage thresholds.

chore: Update TypeScript configuration for ESLint

- Added tsconfig.eslint.json for ESLint integration.
- Modified tsconfig.json to exclude test files from the main compilation.
This commit is contained in:
2026-05-17 17:02:23 +02:00
parent 168f4ea13c
commit 8041a39dfd
71 changed files with 8906 additions and 90 deletions
+13 -3
View File
@@ -1,4 +1,14 @@
DISCORD_TOKEN= # Bootstrap environment variables.
DISCORD_CLIENT_ID= # Runtime configuration is loaded from bot_settings in Config DB.
# Optional. If set, command registration targets this guild for instant updates. # Keep only database bootstrap values in .env.
DATABASE_URL=postgres://postgres:postgres@localhost:5432/omo_bot
CONFIG_DB_ENABLED=true
# Optional scope used by db:seed for guild-specific keys.
# If omitted, seed writes to __global__ scope.
DISCORD_GUILD_ID= DISCORD_GUILD_ID=
# One-time migration path (legacy env -> Config DB):
# 1) Temporarily set legacy env keys.
# 2) Run npm run db:seed.
# 3) Remove legacy env keys from .env.
+88
View File
@@ -0,0 +1,88 @@
name: CI-CD
on:
push:
branches:
- "**"
pull_request:
jobs:
bot-checks:
name: Bot Lint Test Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Build
run: npm run build
- name: Test
run: npm run test
dashboard-checks:
name: Dashboard Lint Build
runs-on: ubuntu-latest
defaults:
run:
working-directory: admin-dashboard
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: admin-dashboard/package-lock.json
- name: Install dashboard dependencies
run: npm ci
- name: Lint dashboard
run: npm run lint
- name: Build dashboard
run: npm run build
deploy-coolify:
name: Deploy to Coolify
runs-on: ubuntu-latest
needs:
- bot-checks
- dashboard-checks
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
steps:
- name: Trigger Coolify deploy hook
env:
COOLIFY_DEPLOY_HOOK_URL: ${{ secrets.COOLIFY_DEPLOY_HOOK_URL }}
COOLIFY_DEPLOY_TOKEN: ${{ secrets.COOLIFY_DEPLOY_TOKEN }}
run: |
if [ -z "$COOLIFY_DEPLOY_HOOK_URL" ]; then
echo "Missing COOLIFY_DEPLOY_HOOK_URL secret"
exit 1
fi
if [ -n "$COOLIFY_DEPLOY_TOKEN" ]; then
curl --fail --show-error --silent \
-X POST \
-H "Authorization: Bearer $COOLIFY_DEPLOY_TOKEN" \
"$COOLIFY_DEPLOY_HOOK_URL"
else
curl --fail --show-error --silent -X POST "$COOLIFY_DEPLOY_HOOK_URL"
fi
echo "Coolify deploy triggered"
+159 -60
View File
@@ -1,65 +1,77 @@
# Discord Bot for Open Mic Odyssey # Discord Bot for Open Mic Odyssey
A custom Discord bot and web integration layer designed to bridge the [openmicodyssey.com](https://openmicodyssey.com) experience with our community server. This application gamifies community engagement, automates content syndication, and provides a suite of event management tools centered around the themes of stand-up comedy, indie filmmaking, and a cross-country road trip. A modular Discord bot + admin platform that syncs community engagement between Discord and [openmicodyssey.com](https://openmicodyssey.com).
The system includes onboarding roles, stage queue management, mileage progression, content syndication, an admin API, and a React admin dashboard.
## Core Features ## Core Features
### The Call Sheet (User & Role Management) ### The Call Sheet (Onboarding + Roles)
- **The Experience:** New members are greeted with a customized onboarding menu to declare their interests (e.g., `@ComedyFan`, `@Filmmaker`). Active users progress through community ranks, leveling up from **"Extra"** to **"Roadie"** and eventually **"Executive Producer"**. - Reaction-role onboarding message with emoji-to-role bindings
- **Technical Implementation:** Utilizes Discord's Reaction Role payloads and interaction webhooks for automated Role-Based Access Control (RBAC). Implements dynamic permission bitfield assignment and role hierarchy state management based on user activity telemetry. - Role hierarchy safety checks before assignment/removal
- Optional onboarding completion role and welcome-channel message
### The Tour Schedule (Event Management) ### The Tour Schedule (Stage Queue)
- **The Experience:** Organizes virtual **"Tour Stops"** (screenings) and digital open mics. Users can utilize the `/sign-up` command to enter the stage queue, while attendees receive temporary **"VIP Backstage"** passes for live Q&As. - `/sign-up` subcommands: `join`, `leave`, `list`, `next`, `clear`
- **Technical Implementation:** Wraps the Discord Scheduled Events API. Implements a FIFO (First-In-First-Out) queue data structure for managing Voice/Stage channel speaker states. Handles automated role assignment/revocation for temporary event permissions. - FIFO queue per guild
- Optional stage-speaker promotion and queue announcement dispatch
### The Dailies (Content Webhooks & Syndication) ### The Dailies (Content Syndication)
- **The Experience:** Automatically delivers fresh behind-the-scenes content directly from the crew's socials to dedicated server channels: - Adapter-based polling pipeline
- `#polaroids-from-the-van`: Instagram drops. - YouTube RSS adapter implemented
- `#outtakes`: TikTok crowd work and detours. - Discord webhook dispatch with duplicate suppression
- `#screenings`: YouTube trailers and vlogs.
- **Technical Implementation:** Event-driven architecture utilizing incoming Discord Webhooks. Integrates external API polling (YouTube Data API, TikTok/IG endpoints) to fetch, parse, and format multimedia payloads into rich Discord Embeds. ### Mileage Engine
### Mileage & The Hidden Map (Gamified Progression) - PostgreSQL-backed mileage event/user persistence
- Configurable event scoring
- Role-tier upgrades on threshold milestones
- Retry behavior for transient write failures
- **The Experience:** Every interaction earns users **"Mileage"**. Accumulating miles unlocks secure passwords and GPS coordinates for the hidden `/map` route on the main website, granting access to deleted scenes and exclusive scripts. ### Admin API + Dashboard
- **Technical Implementation:** Requires Discord OAuth2 integration with the main web application. Message and event telemetry are captured, scored, and stored in a database (e.g., PostgreSQL/MongoDB), continuously syncing the user's Discord state with their authenticated web session.
### The Control Room (Admin Web Interface) - Express Admin API for config, queue, stats, OAuth bridge, configuration DB
- React/Vite dashboard for OAuth login + analytics views
- **The Experience:** A dedicated dashboard for the **"Producers"** and **"Directors"** to manage the server, schedule content drops, and view engagement without touching Discord commands. ### OAuth2 Bridge
- **Technical Implementation:** A standalone web portal (intended for `admin.openmicodyssey.com`). Exposes secure RESTful/GraphQL endpoints for bot configuration, CRON job scheduling for content drops, and data visualization for external link click-through rates.
- Discord OAuth2 code exchange to user profile
- Ephemeral session issuance
- Optional sync callback to openmicodyssey.com backend
### Configuration Database
- `bot_settings`, `content_schedules`, `engagement_stats` tables
- Admin CRUD endpoints for settings/schedules
- Engagement snapshot persistence from runtime events
## Implemented Modules
- `src/bot.ts`: gateway client lifecycle, command dispatch, service wiring
- `src/call-sheet.ts`: reaction role onboarding and role hierarchy guards
- `src/tour-schedule.ts`: FIFO queue, stage speaker promotion, announcements
- `src/mileage.ts`: mileage persistence, scoring, role-upgrade triggers
- `src/dailies/*`: adapter/poller/webhook syndication pipeline
- `src/admin-api.ts`: operational and configuration endpoints
- `src/oauth-bridge.ts`: Discord code exchange + openmic session sync
- `src/configuration-database.ts`: bot settings, schedules, engagement snapshots
- `admin-dashboard/src/*`: admin SPA, OAuth callback flow, analytics views
## Architecture Documentation (arc42) ## Architecture Documentation (arc42)
This project uses the [arc42](https://arc42.org) architecture documentation template. This project uses the arc42 template. Chapters are in `docs/`.
All chapters are in `docs/`:
| # | Chapter | File |
| --- | ------------------------ | ---------------------------------------------------------------------------- |
| 1 | Introduction & Goals | [`docs/01_introduction_and_goals.md`](docs/01_introduction_and_goals.md) |
| 2 | Architecture Constraints | [`docs/02_architecture_constraints.md`](docs/02_architecture_constraints.md) |
| 3 | Context & Scope | [`docs/03_context_and_scope.md`](docs/03_context_and_scope.md) |
| 4 | Solution Strategy | [`docs/04_solution_strategy.md`](docs/04_solution_strategy.md) |
| 5 | Building Block View | [`docs/05_building_block_view.md`](docs/05_building_block_view.md) |
| 6 | Runtime View | [`docs/06_runtime_view.md`](docs/06_runtime_view.md) |
| 7 | Deployment View | [`docs/07_deployment_view.md`](docs/07_deployment_view.md) |
| 8 | Cross-cutting Concepts | [`docs/08_concepts.md`](docs/08_concepts.md) |
| 9 | Architecture Decisions | [`docs/09_architecture_decisions.md`](docs/09_architecture_decisions.md) |
| 10 | Quality Requirements | [`docs/10_quality_requirements.md`](docs/10_quality_requirements.md) |
| 11 | Risks & Technical Debt | [`docs/11_technical_risks.md`](docs/11_technical_risks.md) |
| 12 | Glossary | [`docs/12_glossary.md`](docs/12_glossary.md) |
## Architecture & Tech Stack ## Architecture & Tech Stack
| Layer | Choice | | Layer | Choice |
| --------------- | -------------------- | | --------------- | -------------------- |
| Runtime | Node.js (Discord.js) | | Runtime | Node.js + TypeScript |
| Discord SDK | discord.js |
| Database | PostgreSQL | | Database | PostgreSQL |
| Admin Dashboard | React | | Admin API | Express |
| Admin Dashboard | React + Vite |
| Auth | Discord OAuth2 | | Auth | Discord OAuth2 |
| Hosting | Coolify + Nixpacks | | Hosting | Coolify + Nixpacks |
@@ -67,52 +79,139 @@ All chapters are in `docs/`:
### Prerequisites ### Prerequisites
- Node.js v16+ (or Python 3.9+) - Node.js 22+
- A Discord Developer Application with Bot Token - npm 10+
- Access to `openmicodyssey.com` backend for OAuth syncing - Discord Developer Application (bot token + OAuth2 app)
- PostgreSQL instance
### Installation ### Installation
1. Clone the repository: 1. Clone repository:
```bash ```bash
git clone https://github.com/your-org/open-mic-odyssey-bot.git git clone https://git.allucanget.biz/allucanget/omo-bot.git
cd open-mic-odyssey-bot cd omo-bot
``` ```
2. Install dependencies: 2. Install root dependencies:
```bash ```bash
npm install npm install
``` ```
3. Configure environment variables. Duplicate `.env.example` to `.env` and add your specific keys: 3. Configure bootstrap environment variables (`.env` from `.env.example`):
```env ```env
DISCORD_TOKEN=your_bot_token DATABASE_URL=postgres://...
CLIENT_ID=your_client_id CONFIG_DB_ENABLED=true
GUILD_ID=your_server_id DISCORD_GUILD_ID=... # optional seed scope
DB_CONNECTION_STRING=your_db_uri
YOUTUBE_API_KEY=your_yt_key
``` ```
4. Deploy Slash Commands: 4. Register slash commands:
```bash ```bash
npm run deploy-commands npm run register:commands
``` ```
5. Start the bot: 5. Run bot in development:
```bash ```bash
npm run dev
```
6. Build and run production bundle:
```bash
npm run build
npm start npm start
``` ```
### Admin Dashboard
```bash
cd admin-dashboard
npm install
npm run dev
```
Dashboard environment template: `admin-dashboard/.env.example`.
### Validation Commands
```bash
npm run lint
npm run build
npm run test
```
### Database Setup Commands
`DATABASE_URL` is required for each command.
```bash
npm run db:migrate
npm run db:seed
npm run db:setup
```
- `db:migrate`: applies schema migrations tracked in `schema_migrations`
- `db:seed`: inserts initial config values from environment into `bot_settings` when missing
- `db:setup`: runs migrate then seed
### Runtime Config Source
Runtime reads configuration keys from `bot_settings` table first, then falls back to environment values.
- Global scope: `guild_id = __global__`
- Guild override scope: `guild_id = DISCORD_GUILD_ID`
- Key names match previous env variable names (examples: `DISCORD_TOKEN`, `DISCORD_CLIENT_ID`, `ADMIN_API_TOKEN`)
Recommended flow:
1. Set bootstrap env (`DATABASE_URL`, optional `DISCORD_GUILD_ID`).
2. Run `npm run db:migrate`.
3. Run `npm run db:seed` once while legacy env vars are still present.
4. Remove legacy runtime vars from `.env` after confirming `bot_settings` contains required keys.
Dashboard quality check:
```bash
cd admin-dashboard
npm run lint
npm run build
```
## Admin API Endpoints
- `GET /health`
- `GET /admin/config`
- `GET /admin/stats?guildId=...`
- `GET /admin/schedule?guildId=...`
- `POST /admin/schedule/clear`
- `POST /admin/oauth/discord/exchange`
- `GET /admin/oauth/session/:sessionId`
- `GET /admin/db/settings?guildId=...`
- `PUT /admin/db/settings`
- `GET /admin/db/schedules?guildId=...`
- `PUT /admin/db/schedules`
- `DELETE /admin/db/schedules/:guildId/:scheduleKey`
- `GET /admin/db/engagement/latest?guildId=...`
## CI/CD (Gitea Actions)
Pipeline file: `.gitea/workflows/ci-cd.yml`
Flow:
- bot checks: `npm ci -> npm run lint -> npm run build -> npm run test`
- dashboard checks: `admin-dashboard/npm ci -> npm run lint -> npm run build`
- deploy: on push to `main`, trigger Coolify deploy webhook
Required Gitea secrets:
- `COOLIFY_DEPLOY_HOOK_URL` (required)
- `COOLIFY_DEPLOY_TOKEN` (optional bearer token)
## Contributing ## Contributing
For internal development only. Please refer to [CONTRIBUTING.md](CONTRIBUTING.md) for styling guidelines and PR review processes for "The Control Room" dashboard updates. Internal development only. See `CONTRIBUTING.md`.
+4
View File
@@ -0,0 +1,4 @@
VITE_ADMIN_API_BASE_URL=http://localhost:8787
VITE_DISCORD_CLIENT_ID=
# Must match Discord OAuth2 redirect URI whitelist.
VITE_DISCORD_REDIRECT_URI=http://localhost:5173
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+22
View File
@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin-dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2765
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "admin-dashboard",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+184
View File
@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+304
View File
@@ -0,0 +1,304 @@
import { useEffect, useMemo, useState } from "react";
import type { FormEvent, MouseEvent } from "react";
import { exchangeDiscordOAuthCode, fetchDashboardData } from "./api";
import type {
AdminConfig,
AdminSchedule,
AdminStats,
OAuthBridgeSession,
} from "./api";
import {
buildDiscordAuthorizeUrl,
clearOAuthQueryParams,
isExpectedOAuthState,
readOAuthUiState,
} from "./oauth";
const API_BASE_URL =
import.meta.env.VITE_ADMIN_API_BASE_URL ?? "http://localhost:8787";
const DISCORD_CLIENT_ID = import.meta.env.VITE_DISCORD_CLIENT_ID ?? "";
const DISCORD_REDIRECT_URI =
import.meta.env.VITE_DISCORD_REDIRECT_URI ?? window.location.origin;
interface DashboardData {
config: AdminConfig;
schedule: AdminSchedule;
stats: AdminStats;
}
function formatDuration(ms: number): string {
const minutes = Math.floor(ms / 60000);
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}m`;
}
function App() {
const oauth = useMemo(() => readOAuthUiState(), []);
const [guildId, setGuildId] = useState("");
const [token, setToken] = useState(
window.localStorage.getItem("omo.dashboard.api.token") ?? "",
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<DashboardData | null>(null);
const [oauthSession, setOauthSession] = useState<OAuthBridgeSession | null>(
null,
);
const [oauthExchangeError, setOauthExchangeError] = useState<string | null>(
null,
);
useEffect(() => {
window.localStorage.setItem("omo.dashboard.api.token", token);
}, [token]);
useEffect(() => {
const oauthCode = oauth.code;
if (!oauthCode) {
return;
}
void (async () => {
if (!isExpectedOAuthState(oauth.state)) {
setTimeout(() => {
setOauthExchangeError("OAuth state mismatch. Restart login.");
}, 0);
clearOAuthQueryParams();
return;
}
try {
const session = await exchangeDiscordOAuthCode(
{
baseUrl: API_BASE_URL,
token,
},
oauthCode,
);
setOauthSession(session);
setOauthExchangeError(null);
} catch (exchangeError: unknown) {
setOauthExchangeError(String(exchangeError));
} finally {
clearOAuthQueryParams();
}
})();
}, [oauth.code, oauth.state, token]);
async function refresh(): Promise<void> {
if (!guildId.trim()) {
setError("Guild ID required.");
return;
}
setLoading(true);
setError(null);
try {
const dashboardData = await fetchDashboardData(
{
baseUrl: API_BASE_URL,
token,
},
guildId.trim(),
);
setData(dashboardData);
} catch (requestError: unknown) {
setError(String(requestError));
} finally {
setLoading(false);
}
}
function onLoadSubmit(event: FormEvent<HTMLFormElement>): void {
event.preventDefault();
void refresh();
}
const oauthStatus = oauthExchangeError
? `OAuth exchange failed: ${oauthExchangeError}`
: oauth.error
? `OAuth error: ${oauth.error}`
: oauthSession
? oauthSession.syncedToOpenmic
? "OAuth bridge active. Session synced to openmicodyssey.com."
: "OAuth bridge active. Session created locally."
: "Not signed in via Discord OAuth.";
function onOAuthClick(event: MouseEvent<HTMLAnchorElement>): void {
if (!DISCORD_CLIENT_ID) {
event.preventDefault();
return;
}
event.preventDefault();
const oauthUrl = buildDiscordAuthorizeUrl(
DISCORD_CLIENT_ID,
DISCORD_REDIRECT_URI,
);
window.location.assign(oauthUrl);
}
return (
<div className="app-shell">
<header className="hero-panel">
<p className="eyebrow">OpenMic Odyssey</p>
<h1>Admin Dashboard</h1>
<p className="hero-copy">
Real-time control room for config, queue flow, and mileage analytics.
</p>
</header>
<section className="panel auth-panel">
<div>
<h2>OAuth2 Login</h2>
<p>Connect Discord identity for step 9 bridge handoff.</p>
</div>
<div className="auth-actions">
<a
className={`oauth-btn ${DISCORD_CLIENT_ID ? "" : "disabled"}`}
href="#"
onClick={onOAuthClick}
>
Connect Discord
</a>
<p className="oauth-status">{oauthStatus}</p>
{oauthSession ? (
<p className="oauth-status">
Session {oauthSession.sessionId} | expires{" "}
{new Date(oauthSession.expiresAt).toLocaleString()}
</p>
) : null}
</div>
</section>
<section className="panel controls-panel">
<h2>Analytics Query</h2>
<form onSubmit={onLoadSubmit} className="controls-grid">
<label>
<span>Guild ID</span>
<input
value={guildId}
onChange={(event) => setGuildId(event.target.value)}
placeholder="123456789012345678"
/>
</label>
<label>
<span>Admin API Bearer Token</span>
<input
value={token}
onChange={(event) => setToken(event.target.value)}
placeholder="Optional if ADMIN_API_TOKEN unset"
/>
</label>
<label>
<span>API Base URL</span>
<input value={API_BASE_URL} readOnly />
</label>
<button type="submit" disabled={loading}>
{loading ? "Loading..." : "Load Metrics"}
</button>
</form>
{error ? <p className="error">{error}</p> : null}
</section>
{data ? (
<>
<section className="cards-grid">
<article className="metric-card">
<h3>Commands Run</h3>
<p>{data.stats.runtime.commandCount}</p>
<small>
Since {new Date(data.stats.runtime.startedAt).toLocaleString()}
</small>
</article>
<article className="metric-card">
<h3>Bot Uptime</h3>
<p>{formatDuration(data.stats.runtime.uptimeMs)}</p>
<small>Live runtime sample</small>
</article>
<article className="metric-card">
<h3>Queue Depth</h3>
<p>{data.schedule.size}</p>
<small>Current sign-up backlog</small>
</article>
<article className="metric-card">
<h3>Mileage Total</h3>
<p>{data.stats.mileage.totalMilesAwarded}</p>
<small>{data.stats.mileage.totalEvents} events logged</small>
</article>
</section>
<section className="panel split-panel">
<div>
<h2>Top Mileage</h2>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>User</th>
<th>Miles</th>
</tr>
</thead>
<tbody>
{data.stats.mileage.topUsers.length ? (
data.stats.mileage.topUsers.map((row) => (
<tr key={row.userId}>
<td>@{row.userId}</td>
<td>{row.totalMiles}</td>
</tr>
))
) : (
<tr>
<td colSpan={2}>No mileage data.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<div>
<h2>Tour Queue</h2>
{data.schedule.queue.length ? (
<ol className="queue-list">
{data.schedule.queue.map((userId) => (
<li key={userId}>@{userId}</li>
))}
</ol>
) : (
<p className="queue-empty">Queue empty.</p>
)}
<h3>Config Snapshot</h3>
<ul className="config-list">
<li>
Call Sheet bindings: {data.config.callSheet.bindings.length}
</li>
<li>
Dailies enabled: {data.config.dailies.enabled ? "yes" : "no"}
</li>
<li>
Webhook targets: {data.config.dailies.webhookTargets.length}
</li>
<li>
Admin auth:{" "}
{data.config.adminApi.authConfigured ? "enabled" : "off"}
</li>
</ul>
</div>
</section>
</>
) : null}
</div>
);
}
export default App;
+161
View File
@@ -0,0 +1,161 @@
export interface AdminConfig {
discordGuildId: string | null;
callSheet: {
messageId: string | null;
onboardedRoleId: string | null;
welcomeChannelId: string | null;
bindings: Array<{
emoji: string;
roleId: string;
label?: string;
group?: string;
}>;
};
mileage: {
enabled: boolean;
roleTiers: Array<{ roleId: string; minMiles: number }>;
eventScores: Record<string, number>;
};
tourSchedule: {
stageChannelId?: string;
announceChannelId?: string;
};
dailies: {
enabled: boolean;
pollIntervalMs: number;
youtubeChannelIds: string[];
webhookTargets: Array<{ label: string | null }>;
};
adminApi: {
enabled: boolean;
host: string;
port: number;
authConfigured: boolean;
};
oauthBridge: {
enabled: boolean;
redirectConfigured: boolean;
openmicSyncConfigured: boolean;
};
}
export interface AdminSchedule {
guildId: string;
queue: string[];
size: number;
}
export interface AdminStats {
guildId: string;
runtime: {
startedAt: string;
uptimeMs: number;
commandCount: number;
};
queue: {
size: number;
};
mileage: {
enabled: boolean;
guildId: string;
totalMembersTracked: number;
totalMilesAwarded: number;
totalEvents: number;
topUsers: Array<{ userId: string; totalMiles: number }>;
};
}
export interface OAuthBridgeSession {
sessionId: string;
createdAt: string;
expiresAt: string;
syncedToOpenmic: boolean;
discordUser: {
id: string;
username: string;
displayName: string;
avatarUrl: string | null;
};
}
interface FetchOptions {
baseUrl: string;
token: string;
}
async function requestJson<T>(
path: string,
options: FetchOptions,
query?: Record<string, string>,
): Promise<T> {
const url = new URL(path, options.baseUrl);
if (query) {
for (const [key, value] of Object.entries(query)) {
url.searchParams.set(key, value);
}
}
const headers: Record<string, string> = {
Accept: "application/json",
};
if (options.token) {
headers.Authorization = `Bearer ${options.token}`;
}
const response = await fetch(url.toString(), {
headers,
});
if (!response.ok) {
const body = await response.text();
throw new Error(`API ${response.status}: ${body || response.statusText}`);
}
return (await response.json()) as T;
}
export async function fetchDashboardData(
options: FetchOptions,
guildId: string,
): Promise<{
config: AdminConfig;
schedule: AdminSchedule;
stats: AdminStats;
}> {
const [config, schedule, stats] = await Promise.all([
requestJson<AdminConfig>("/admin/config", options),
requestJson<AdminSchedule>("/admin/schedule", options, { guildId }),
requestJson<AdminStats>("/admin/stats", options, { guildId }),
]);
return { config, schedule, stats };
}
export async function exchangeDiscordOAuthCode(
options: FetchOptions,
code: string,
): Promise<OAuthBridgeSession> {
const url = new URL("/admin/oauth/discord/exchange", options.baseUrl);
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
};
if (options.token) {
headers.Authorization = `Bearer ${options.token}`;
}
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify({ code }),
});
if (!response.ok) {
const body = await response.text();
throw new Error(
`OAuth exchange ${response.status}: ${body || response.statusText}`,
);
}
return (await response.json()) as OAuthBridgeSession;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+286
View File
@@ -0,0 +1,286 @@
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=IBM+Plex+Mono:wght@400;500&display=swap");
:root {
--bg: #f2efe7;
--ink: #14213d;
--ink-soft: #4a5568;
--panel: #fffaf0;
--line: #d6d3c8;
--accent: #ff6b35;
--accent-2: #2ec4b6;
--danger: #b42318;
--shadow: 0 14px 36px rgba(20, 33, 61, 0.15);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Space Grotesk", "Segoe UI", sans-serif;
color: var(--ink);
background:
radial-gradient(
circle at 10% 20%,
rgba(46, 196, 182, 0.28),
transparent 36%
),
radial-gradient(
circle at 90% 10%,
rgba(255, 107, 53, 0.22),
transparent 42%
),
var(--bg);
}
#root {
min-height: 100vh;
}
.app-shell {
max-width: 1150px;
margin: 0 auto;
padding: 2.25rem 1rem 3rem;
display: grid;
gap: 1rem;
}
.hero-panel {
border: 1px solid var(--line);
border-radius: 24px;
padding: 1.5rem;
background:
linear-gradient(130deg, rgba(255, 107, 53, 0.16), rgba(46, 196, 182, 0.18)),
var(--panel);
box-shadow: var(--shadow);
}
.eyebrow {
margin: 0;
font-size: 0.8rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-soft);
}
h1 {
margin: 0.4rem 0;
font-size: clamp(2rem, 4vw, 3.5rem);
line-height: 1;
}
h2,
h3 {
margin: 0;
line-height: 1.15;
}
.hero-copy {
margin: 0;
max-width: 62ch;
color: var(--ink-soft);
}
.panel {
border: 1px solid var(--line);
border-radius: 20px;
background: var(--panel);
box-shadow: var(--shadow);
padding: 1.25rem;
}
.auth-panel {
display: grid;
grid-template-columns: 1.1fr 1fr;
gap: 1rem;
align-items: start;
}
.auth-panel p {
margin: 0.4rem 0 0;
color: var(--ink-soft);
}
.auth-actions {
display: grid;
gap: 0.65rem;
}
.oauth-btn {
display: inline-flex;
justify-content: center;
align-items: center;
padding: 0.72rem 1rem;
border-radius: 12px;
text-decoration: none;
font-weight: 700;
color: #fff;
background: linear-gradient(130deg, var(--accent), #f08a5d);
transition: transform 160ms ease;
}
.oauth-btn:hover {
transform: translateY(-2px);
}
.oauth-btn.disabled {
opacity: 0.5;
pointer-events: none;
}
.oauth-status {
margin: 0;
font-family: "IBM Plex Mono", Consolas, monospace;
font-size: 0.83rem;
}
.controls-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.9rem;
margin-top: 0.8rem;
}
.controls-grid label {
display: grid;
gap: 0.3rem;
font-size: 0.88rem;
}
.controls-grid span {
color: var(--ink-soft);
}
.controls-grid input {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.66rem 0.75rem;
font: inherit;
background: #fff;
}
.controls-grid button {
border: none;
border-radius: 10px;
padding: 0.74rem 1rem;
font: inherit;
font-weight: 700;
color: #fff;
background: linear-gradient(130deg, #2541b2, #2ec4b6);
cursor: pointer;
}
.controls-grid button:disabled {
cursor: wait;
opacity: 0.75;
}
.error {
margin: 0.75rem 0 0;
color: var(--danger);
font-family: "IBM Plex Mono", Consolas, monospace;
font-size: 0.83rem;
}
.cards-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.8rem;
}
.metric-card {
background: #fff;
border: 1px solid var(--line);
border-radius: 16px;
padding: 1rem;
}
.metric-card h3 {
font-size: 0.88rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--ink-soft);
}
.metric-card p {
margin: 0.35rem 0;
font-size: 2rem;
font-weight: 700;
}
.metric-card small {
color: var(--ink-soft);
}
.split-panel {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.table-wrap {
margin-top: 0.65rem;
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.93rem;
}
th,
td {
padding: 0.6rem;
border-bottom: 1px solid var(--line);
text-align: left;
}
.queue-list {
margin: 0.65rem 0 1rem;
padding-left: 1.15rem;
}
.queue-list li {
margin-bottom: 0.35rem;
}
.queue-empty {
margin: 0.65rem 0 1rem;
color: var(--ink-soft);
}
.config-list {
list-style: square;
margin: 0.6rem 0 0;
padding-left: 1.2rem;
color: var(--ink-soft);
}
@media (max-width: 920px) {
.auth-panel,
.split-panel,
.controls-grid {
grid-template-columns: 1fr;
}
.cards-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 620px) {
.app-shell {
padding: 1rem 0.65rem 2rem;
}
.cards-grid {
grid-template-columns: 1fr;
}
.metric-card p {
font-size: 1.7rem;
}
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+51
View File
@@ -0,0 +1,51 @@
export interface OAuthUiState {
code: string | null;
state: string | null;
error: string | null;
}
const STATE_STORAGE_KEY = "omo.dashboard.oauth.state";
export function readOAuthUiState(): OAuthUiState {
const params = new URLSearchParams(window.location.search);
return {
code: params.get("code"),
state: params.get("state"),
error: params.get("error"),
};
}
export function buildDiscordAuthorizeUrl(
clientId: string,
redirectUri: string,
): string {
const state = crypto.randomUUID();
window.localStorage.setItem(STATE_STORAGE_KEY, state);
const url = new URL("https://discord.com/oauth2/authorize");
url.searchParams.set("client_id", clientId);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", "identify guilds");
url.searchParams.set("redirect_uri", redirectUri);
url.searchParams.set("state", state);
url.searchParams.set("prompt", "consent");
return url.toString();
}
export function isExpectedOAuthState(state: string | null): boolean {
if (!state) {
return false;
}
const expected = window.localStorage.getItem(STATE_STORAGE_KEY);
return expected === state;
}
export function clearOAuthQueryParams(): void {
const url = new URL(window.location.href);
url.searchParams.delete("code");
url.searchParams.delete("state");
url.searchParams.delete("error");
window.history.replaceState({}, document.title, url.toString());
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})
+2 -2
View File
@@ -4,14 +4,14 @@ const tsParser = require("@typescript-eslint/parser");
/** @type {import("eslint").Linter.FlatConfig[]} */ /** @type {import("eslint").Linter.FlatConfig[]} */
module.exports = [ module.exports = [
{ {
ignores: ["dist/**", "node_modules/**"], ignores: ["dist/**", "node_modules/**", "**/*.d.ts"],
}, },
{ {
files: ["**/*.ts"], files: ["**/*.ts"],
languageOptions: { languageOptions: {
parser: tsParser, parser: tsParser,
parserOptions: { parserOptions: {
project: "./tsconfig.json", project: "./tsconfig.eslint.json",
sourceType: "module", sourceType: "module",
}, },
}, },
+1035 -6
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -12,18 +12,25 @@
"start": "node dist/index.js", "start": "node dist/index.js",
"lint": "eslint . --ext .ts", "lint": "eslint . --ext .ts",
"register:commands": "ts-node src/deploy-commands.ts", "register:commands": "ts-node src/deploy-commands.ts",
"test": "jest --passWithNoTests" "test": "jest --passWithNoTests",
"db:migrate": "ts-node src/db/cli.ts migrate",
"db:seed": "ts-node src/db/cli.ts seed",
"db:setup": "ts-node src/db/cli.ts setup"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"type": "commonjs", "type": "commonjs",
"dependencies": { "dependencies": {
"discord.js": "^14.26.4" "discord.js": "^14.26.4",
"express": "^5.2.1",
"pg": "^8.20.0"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^25.8.0", "@types/node": "^25.8.0",
"@types/pg": "^8.20.0",
"@typescript-eslint/eslint-plugin": "^8.59.3", "@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.59.3", "@typescript-eslint/parser": "^8.59.3",
"eslint": "^10.4.0", "eslint": "^10.4.0",
+368
View File
@@ -0,0 +1,368 @@
import express, { NextFunction, Request, Response } from "express";
import { AddressInfo } from "net";
import { ConfigurationDatabase } from "./configuration-database";
import { AdminApiConfig, BotConfig } from "./config";
import { MileageEngine } from "./mileage";
import { OAuthBridgeService } from "./oauth-bridge";
import { TourScheduleEngine } from "./tour-schedule";
export interface RuntimeStats {
startedAt: string;
commandCount: number;
}
export interface AdminApiContext {
config: BotConfig;
mileage: MileageEngine;
configurationDatabase: ConfigurationDatabase;
tourSchedule: TourScheduleEngine;
oauthBridge: OAuthBridgeService;
runtimeStats: RuntimeStats;
}
export class AdminApiServer {
private readonly config: AdminApiConfig;
private readonly context: AdminApiContext;
private readonly app = express();
private server: ReturnType<typeof this.app.listen> | null = null;
constructor(config: AdminApiConfig, context: AdminApiContext) {
this.config = config;
this.context = context;
this.app.use(express.json());
this.app.use((request, response, next) =>
this.authenticate(request, response, next),
);
this.registerRoutes();
}
async start(): Promise<void> {
if (!this.config.enabled) {
console.log(
"Admin API disabled: set ADMIN_API_ENABLED=true to activate.",
);
return;
}
await new Promise<void>((resolve) => {
this.server = this.app.listen(this.config.port, this.config.host, () => {
const address = this.server?.address();
const port =
typeof address === "object" && address
? (address as AddressInfo).port
: this.config.port;
console.log(`Admin API listening on ${this.config.host}:${port}`);
resolve();
});
});
}
async stop(): Promise<void> {
if (!this.server) {
return;
}
await new Promise<void>((resolve, reject) => {
this.server?.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
this.server = null;
}
private authenticate(
request: Request,
response: Response,
next: NextFunction,
): void {
// OAuth endpoints must stay callable before a dashboard session exists.
if (request.path.startsWith("/admin/oauth/")) {
next();
return;
}
const token = this.config.token;
if (!token) {
next();
return;
}
const authorization = request.header("authorization");
const expected = `Bearer ${token}`;
if (authorization !== expected) {
response.status(401).json({ error: "Unauthorized" });
return;
}
next();
}
private registerRoutes(): void {
this.app.get("/health", (_request, response) => {
response.json({ ok: true });
});
this.app.get("/admin/config", (_request, response) => {
const safeConfig = {
discordGuildId: this.context.config.discordGuildId ?? null,
callSheet: {
messageId: this.context.config.callSheet.messageId ?? null,
onboardedRoleId:
this.context.config.callSheet.onboardedRoleId ?? null,
welcomeChannelId:
this.context.config.callSheet.welcomeChannelId ?? null,
bindings: this.context.config.callSheet.bindings,
},
mileage: {
enabled: this.context.mileage.enabled,
roleTiers: this.context.config.mileage.roleTiers,
eventScores: this.context.config.mileage.eventScores,
},
tourSchedule: this.context.config.tourSchedule,
dailies: {
enabled: this.context.config.dailies.enabled,
pollIntervalMs: this.context.config.dailies.pollIntervalMs,
youtubeChannelIds: this.context.config.dailies.youtubeChannelIds,
webhookTargets: this.context.config.dailies.webhookTargets.map(
(target) => ({
label: target.label ?? null,
}),
),
},
adminApi: {
enabled: this.context.config.adminApi.enabled,
host: this.context.config.adminApi.host,
port: this.context.config.adminApi.port,
authConfigured: Boolean(this.context.config.adminApi.token),
},
oauthBridge: {
enabled: this.context.config.oauthBridge.enabled,
redirectConfigured: Boolean(
this.context.config.oauthBridge.redirectUri,
),
openmicSyncConfigured: Boolean(
this.context.config.oauthBridge.openmicSyncUrl,
),
},
configurationDatabase: {
enabled: this.context.configurationDatabase.enabled,
},
};
response.json(safeConfig);
});
this.app.get("/admin/schedule", (request, response) => {
const guildId = String(request.query.guildId ?? "").trim();
if (!guildId) {
response.status(400).json({ error: "guildId query param required" });
return;
}
const queue = this.context.tourSchedule.list(guildId);
response.json({ guildId, queue, size: queue.length });
});
this.app.post("/admin/schedule/clear", (request, response) => {
const guildId = String(request.body?.guildId ?? "").trim();
if (!guildId) {
response.status(400).json({ error: "guildId body field required" });
return;
}
const removed = this.context.tourSchedule.clear(guildId);
response.json({ guildId, removed });
});
this.app.get("/admin/stats", async (request, response) => {
const guildId = String(request.query.guildId ?? "").trim();
if (!guildId) {
response.status(400).json({ error: "guildId query param required" });
return;
}
const mileage = await this.context.mileage.getGuildStats(guildId);
const queueSize = this.context.tourSchedule.getQueueLength(guildId);
response.json({
guildId,
runtime: {
startedAt: this.context.runtimeStats.startedAt,
uptimeMs:
Date.now() - Date.parse(this.context.runtimeStats.startedAt),
commandCount: this.context.runtimeStats.commandCount,
},
queue: {
size: queueSize,
},
mileage,
});
});
this.app.get("/admin/db/settings", async (request, response) => {
const guildId = String(request.query.guildId ?? "").trim();
if (!guildId) {
response.status(400).json({ error: "guildId query param required" });
return;
}
try {
const settings =
await this.context.configurationDatabase.getSettings(guildId);
response.json({ guildId, settings });
} catch (error: unknown) {
response.status(500).json({ error: String(error) });
}
});
this.app.put("/admin/db/settings", async (request, response) => {
const guildId = String(request.body?.guildId ?? "").trim();
const settingKey = String(request.body?.settingKey ?? "").trim();
const settingValue = request.body?.settingValue;
if (!guildId || !settingKey || settingValue === undefined) {
response.status(400).json({
error: "guildId, settingKey, settingValue body fields required",
});
return;
}
try {
const setting = await this.context.configurationDatabase.upsertSetting(
guildId,
settingKey,
settingValue,
);
response.json(setting);
} catch (error: unknown) {
response.status(500).json({ error: String(error) });
}
});
this.app.get("/admin/db/schedules", async (request, response) => {
const guildId = String(request.query.guildId ?? "").trim();
if (!guildId) {
response.status(400).json({ error: "guildId query param required" });
return;
}
try {
const schedules =
await this.context.configurationDatabase.listSchedules(guildId);
response.json({ guildId, schedules });
} catch (error: unknown) {
response.status(500).json({ error: String(error) });
}
});
this.app.put("/admin/db/schedules", async (request, response) => {
const guildId = String(request.body?.guildId ?? "").trim();
const scheduleKey = String(request.body?.scheduleKey ?? "").trim();
const publishAt = String(request.body?.publishAt ?? "").trim();
const payload = request.body?.payload;
if (!guildId || !scheduleKey || !publishAt || payload === undefined) {
response.status(400).json({
error:
"guildId, scheduleKey, publishAt, payload body fields required",
});
return;
}
try {
const schedule =
await this.context.configurationDatabase.upsertSchedule(
guildId,
scheduleKey,
publishAt,
payload,
);
response.json(schedule);
} catch (error: unknown) {
response.status(500).json({ error: String(error) });
}
});
this.app.delete(
"/admin/db/schedules/:guildId/:scheduleKey",
async (request, response) => {
const guildId = String(request.params.guildId ?? "").trim();
const scheduleKey = String(request.params.scheduleKey ?? "").trim();
if (!guildId || !scheduleKey) {
response
.status(400)
.json({ error: "guildId and scheduleKey required" });
return;
}
try {
const removed =
await this.context.configurationDatabase.deleteSchedule(
guildId,
scheduleKey,
);
response.json({ guildId, scheduleKey, removed });
} catch (error: unknown) {
response.status(500).json({ error: String(error) });
}
},
);
this.app.get("/admin/db/engagement/latest", async (request, response) => {
const guildId = String(request.query.guildId ?? "").trim();
if (!guildId) {
response.status(400).json({ error: "guildId query param required" });
return;
}
try {
const engagement =
await this.context.configurationDatabase.getLatestEngagement(guildId);
response.json({ guildId, engagement });
} catch (error: unknown) {
response.status(500).json({ error: String(error) });
}
});
this.app.post(
"/admin/oauth/discord/exchange",
async (request, response) => {
const code = String(request.body?.code ?? "").trim();
if (!code) {
response.status(400).json({ error: "code body field required" });
return;
}
try {
const exchanged = await this.context.oauthBridge.exchangeCode(code);
response.json(exchanged);
} catch (error: unknown) {
response.status(400).json({ error: String(error) });
}
},
);
this.app.get("/admin/oauth/session/:sessionId", (request, response) => {
const sessionId = String(request.params.sessionId ?? "").trim();
if (!sessionId) {
response.status(400).json({ error: "sessionId required" });
return;
}
const session = this.context.oauthBridge.getSession(sessionId);
if (!session) {
response.status(404).json({ error: "session not found" });
return;
}
response.json(session);
});
}
}
+128 -2
View File
@@ -2,16 +2,95 @@ import {
Client, Client,
Events, Events,
GatewayIntentBits, GatewayIntentBits,
GuildMember,
Interaction, Interaction,
Partials, Partials,
} from "discord.js"; } from "discord.js";
import { AdminApiServer } from "./admin-api";
import { registerCallSheetHandlers } from "./call-sheet";
import { commandMap } from "./commands"; import { commandMap } from "./commands";
import { ConfigurationDatabase } from "./configuration-database";
import { BotConfig } from "./config"; import { BotConfig } from "./config";
import { initDailies } from "./dailies";
import { MileageEngine } from "./mileage";
import { OAuthBridgeService } from "./oauth-bridge";
import { initTourSchedule, TourScheduleEngine } from "./tour-schedule";
async function resolveInteractionMember(
interaction: Interaction,
): Promise<GuildMember | undefined> {
if (!interaction.isChatInputCommand() || !interaction.inGuild()) {
return undefined;
}
if (!interaction.guild) {
return undefined;
}
try {
return await interaction.guild.members.fetch(interaction.user.id);
} catch {
return undefined;
}
}
export async function startBot(config: BotConfig): Promise<Client> { export async function startBot(config: BotConfig): Promise<Client> {
const runtimeStats = {
startedAt: new Date().toISOString(),
commandCount: 0,
};
const mileageEngine = new MileageEngine(config.mileage);
await mileageEngine.init(config.discordGuildId);
const configurationDatabase = new ConfigurationDatabase(
config.configurationDatabase,
);
await configurationDatabase.init();
const tourSchedule: TourScheduleEngine = initTourSchedule(
config.tourSchedule,
);
const dailies = initDailies(config.dailies);
const oauthBridge = new OAuthBridgeService(
config.oauthBridge,
config.discordClientId,
);
const adminApi = new AdminApiServer(config.adminApi, {
config,
mileage: mileageEngine,
configurationDatabase,
tourSchedule,
oauthBridge,
runtimeStats,
});
const client = new Client({ const client = new Client({
intents: [GatewayIntentBits.Guilds], intents: [
partials: [Partials.Channel], GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessageReactions,
],
partials: [
Partials.Channel,
Partials.Message,
Partials.Reaction,
Partials.User,
],
});
registerCallSheetHandlers(client, config, {
onRoleAssigned: async (member, binding) => {
await mileageEngine.awardMiles({
guildId: member.guild.id,
userId: member.id,
eventType: "reaction_role_select",
metadata: {
roleId: binding.roleId,
emoji: binding.emoji,
source: "call_sheet",
},
member,
});
},
}); });
client.once(Events.ClientReady, (readyClient) => { client.once(Events.ClientReady, (readyClient) => {
@@ -34,6 +113,34 @@ export async function startBot(config: BotConfig): Promise<Client> {
try { try {
await command.execute(interaction); await command.execute(interaction);
runtimeStats.commandCount += 1;
if (interaction.inGuild()) {
const member = await resolveInteractionMember(interaction);
const mileageResult = await mileageEngine.awardMiles({
guildId: interaction.guildId,
userId: interaction.user.id,
eventType: "command_execute",
metadata: {
commandName: interaction.commandName,
},
...(member ? { member } : {}),
});
if (configurationDatabase.enabled) {
await configurationDatabase.insertEngagementSnapshot({
guildId: interaction.guildId,
commandCount: runtimeStats.commandCount,
queueSize: tourSchedule.getQueueLength(interaction.guildId),
mileageTotal: mileageResult.totalMiles,
activeUsers: 1,
payload: {
commandName: interaction.commandName,
source: "interaction",
},
});
}
}
} catch (error: unknown) { } catch (error: unknown) {
console.error(`Command failed: ${interaction.commandName}`, error); console.error(`Command failed: ${interaction.commandName}`, error);
@@ -52,5 +159,24 @@ export async function startBot(config: BotConfig): Promise<Client> {
}); });
await client.login(config.discordToken); await client.login(config.discordToken);
client.once(Events.ClientReady, () => {
dailies.start();
void adminApi.start();
process.once("SIGINT", () => {
dailies.stop();
void adminApi.stop();
void configurationDatabase.close();
void mileageEngine.close();
});
process.once("SIGTERM", () => {
dailies.stop();
void adminApi.stop();
void configurationDatabase.close();
void mileageEngine.close();
});
});
return client; return client;
} }
+235
View File
@@ -0,0 +1,235 @@
import {
ChannelType,
Client,
Events,
GuildMember,
MessageReaction,
PartialMessageReaction,
PartialUser,
Role,
User,
} from "discord.js";
import { BotConfig, ReactionRoleBinding } from "./config";
export interface CallSheetHooks {
onRoleAssigned?: (
member: GuildMember,
binding: ReactionRoleBinding,
) => Promise<void>;
}
function normalizeEmoji(
reaction: MessageReaction | PartialMessageReaction,
): string | null {
return reaction.emoji.id ?? reaction.emoji.name;
}
async function hydrateReaction(
reaction: MessageReaction | PartialMessageReaction,
): Promise<MessageReaction | PartialMessageReaction> {
if (reaction.partial) {
return reaction.fetch();
}
return reaction;
}
async function hydrateUser(user: User | PartialUser): Promise<User> {
if (user.partial) {
return user.fetch();
}
return user;
}
function getBinding(
reaction: MessageReaction | PartialMessageReaction,
config: BotConfig,
): ReactionRoleBinding | null {
const emoji = normalizeEmoji(reaction);
if (!emoji) {
return null;
}
return (
config.callSheet.bindings.find((binding) => binding.emoji === emoji) ?? null
);
}
function isTargetMessage(
reaction: MessageReaction | PartialMessageReaction,
config: BotConfig,
): boolean {
if (!config.callSheet.messageId) {
return false;
}
return reaction.message.id === config.callSheet.messageId;
}
function ensureRoleManageable(member: GuildMember, role: Role): boolean {
const botMember = member.guild.members.me;
if (!botMember) {
return false;
}
return botMember.roles.highest.position > role.position;
}
async function maybeSendWelcome(
member: GuildMember,
config: BotConfig,
): Promise<void> {
if (!config.callSheet.welcomeChannelId) {
return;
}
const channel = await member.guild.channels.fetch(
config.callSheet.welcomeChannelId,
);
if (!channel || channel.type !== ChannelType.GuildText) {
return;
}
await channel.send(`Welcome ${member.user}, onboarding complete.`);
}
async function applyBinding(
member: GuildMember,
binding: ReactionRoleBinding,
config: BotConfig,
): Promise<void> {
const role = await member.guild.roles.fetch(binding.roleId);
if (!role) {
throw new Error(`Role not found for binding ${binding.emoji}`);
}
if (!ensureRoleManageable(member, role)) {
throw new Error(`Cannot assign role ${role.id}. Check role hierarchy.`);
}
if (binding.group) {
const siblingBindings = config.callSheet.bindings.filter(
(candidate) =>
candidate.group === binding.group &&
candidate.roleId !== binding.roleId,
);
for (const sibling of siblingBindings) {
if (member.roles.cache.has(sibling.roleId)) {
await member.roles.remove(
sibling.roleId,
"Call Sheet single-role group update",
);
}
}
}
await member.roles.add(role, "Call Sheet reaction role selection");
if (
config.callSheet.onboardedRoleId &&
!member.roles.cache.has(config.callSheet.onboardedRoleId)
) {
const onboardedRole = await member.guild.roles.fetch(
config.callSheet.onboardedRoleId,
);
if (onboardedRole && ensureRoleManageable(member, onboardedRole)) {
await member.roles.add(onboardedRole, "Call Sheet onboarding complete");
await maybeSendWelcome(member, config);
}
}
}
async function removeBinding(
member: GuildMember,
binding: ReactionRoleBinding,
): Promise<void> {
if (member.roles.cache.has(binding.roleId)) {
await member.roles.remove(
binding.roleId,
"Call Sheet reaction role removal",
);
}
}
async function getMemberForUser(
reaction: MessageReaction | PartialMessageReaction,
user: User,
): Promise<GuildMember | null> {
const guild = reaction.message.guild;
if (!guild) {
return null;
}
return guild.members.fetch(user.id);
}
export function registerCallSheetHandlers(
client: Client,
config: BotConfig,
hooks: CallSheetHooks = {},
): void {
if (!config.callSheet.bindings.length) {
console.log("Call Sheet disabled: no bindings configured.");
return;
}
if (!config.callSheet.messageId) {
console.log(
"Call Sheet handlers idle: set CALL_SHEET_MESSAGE_ID to activate.",
);
return;
}
client.on(Events.MessageReactionAdd, async (rawReaction, rawUser) => {
try {
const reaction = await hydrateReaction(rawReaction);
const user = await hydrateUser(rawUser);
if (user.bot || !isTargetMessage(reaction, config)) {
return;
}
const binding = getBinding(reaction, config);
if (!binding) {
return;
}
const member = await getMemberForUser(reaction, user);
if (!member) {
return;
}
await applyBinding(member, binding, config);
if (hooks.onRoleAssigned) {
await hooks.onRoleAssigned(member, binding);
}
} catch (error: unknown) {
console.error("Call Sheet add reaction failed:", error);
}
});
client.on(Events.MessageReactionRemove, async (rawReaction, rawUser) => {
try {
const reaction = await hydrateReaction(rawReaction);
const user = await hydrateUser(rawUser);
if (user.bot || !isTargetMessage(reaction, config)) {
return;
}
const binding = getBinding(reaction, config);
if (!binding) {
return;
}
const member = await getMemberForUser(reaction, user);
if (!member) {
return;
}
await removeBinding(member, binding);
} catch (error: unknown) {
console.error("Call Sheet remove reaction failed:", error);
}
});
}
+86
View File
@@ -0,0 +1,86 @@
import {
ChatInputCommandInteraction,
PermissionsBitField,
SlashCommandBuilder,
} from "discord.js";
import { loadRuntimeConfig } from "../config";
function buildCallSheetMessage(
bindings: ReadonlyArray<{ emoji: string; roleId: string; label?: string }>,
): string {
if (!bindings.length) {
throw new Error(
"CALL_SHEET_REACTION_ROLES empty. Add bindings before posting call sheet.",
);
}
const lines = [
"# The Call Sheet",
"React below to pick your role.",
"",
...bindings.map((binding) => {
const title = binding.label ?? `<@&${binding.roleId}>`;
return `${binding.emoji} - ${title}`;
}),
];
return lines.join("\n");
}
export const callSheetCommand = {
data: new SlashCommandBuilder()
.setName("call-sheet")
.setDescription("Manage onboarding call sheet.")
.setDefaultMemberPermissions(PermissionsBitField.Flags.ManageRoles)
.addSubcommand((subcommand) =>
subcommand
.setName("post")
.setDescription("Post onboarding call sheet in current channel."),
),
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
if (interaction.options.getSubcommand() !== "post") {
await interaction.reply({
content: "Unsupported subcommand.",
ephemeral: true,
});
return;
}
if (
!interaction.inGuild() ||
!interaction.channel ||
!interaction.channel.isSendable()
) {
await interaction.reply({
content: "Run this in text channel.",
ephemeral: true,
});
return;
}
try {
const config = await loadRuntimeConfig();
const content = buildCallSheetMessage(config.callSheet.bindings);
const posted = await interaction.channel.send({ content });
for (const binding of config.callSheet.bindings) {
await posted.react(binding.emoji);
}
await interaction.reply({
content: [
`Call sheet posted: ${posted.url}`,
"Set CALL_SHEET_MESSAGE_ID to posted message id for reaction handling.",
`CALL_SHEET_MESSAGE_ID=${posted.id}`,
].join("\n"),
ephemeral: true,
});
} catch (error: unknown) {
await interaction.reply({
content: `Failed to post call sheet: ${String(error)}`,
ephemeral: true,
});
}
},
};
+41
View File
@@ -0,0 +1,41 @@
import {
ChatInputCommandInteraction,
PermissionFlagsBits,
SlashCommandBuilder,
} from "discord.js";
import { getDailies } from "../dailies";
export const dailiesCommand = {
data: new SlashCommandBuilder()
.setName("dailies")
.setDescription("Dailies controls.")
.addSubcommand((subcommand) =>
subcommand
.setName("poll")
.setDescription("Run dailies poll now and dispatch new items."),
)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild),
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
if (interaction.options.getSubcommand() !== "poll") {
await interaction.reply({
content: "Unsupported subcommand.",
ephemeral: true,
});
return;
}
await interaction.deferReply({ ephemeral: true });
try {
const published = await getDailies().pollAndDispatch();
await interaction.editReply(
published.length
? `Dailies dispatch complete. Published ${published.length} item(s).`
: "Dailies dispatch complete. No new items.",
);
} catch (error: unknown) {
await interaction.editReply(`Dailies poll failed: ${String(error)}`);
}
},
};
+15 -3
View File
@@ -1,12 +1,24 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; import {
ChatInputCommandInteraction,
SlashCommandBuilder,
SlashCommandSubcommandsOnlyBuilder,
} from "discord.js";
import { callSheetCommand } from "./call-sheet";
import { dailiesCommand } from "./dailies";
import { pingCommand } from "./ping"; import { pingCommand } from "./ping";
import { signUpCommand } from "./sign-up";
export interface ChatCommand { export interface ChatCommand {
data: SlashCommandBuilder; data: SlashCommandBuilder | SlashCommandSubcommandsOnlyBuilder;
execute(interaction: ChatInputCommandInteraction): Promise<void>; execute(interaction: ChatInputCommandInteraction): Promise<void>;
} }
export const commands: ChatCommand[] = [pingCommand]; export const commands: ChatCommand[] = [
pingCommand,
callSheetCommand,
dailiesCommand,
signUpCommand,
];
export const commandMap = new Map<string, ChatCommand>( export const commandMap = new Map<string, ChatCommand>(
commands.map((command) => [command.data.name, command]), commands.map((command) => [command.data.name, command]),
+155
View File
@@ -0,0 +1,155 @@
import {
ChatInputCommandInteraction,
PermissionFlagsBits,
SlashCommandBuilder,
} from "discord.js";
import { getTourSchedule } from "../tour-schedule";
function requireGuild(
interaction: ChatInputCommandInteraction,
): interaction is ChatInputCommandInteraction & { guildId: string } {
return interaction.inGuild() && Boolean(interaction.guildId);
}
function formatQueue(queue: string[]): string {
if (!queue.length) {
return "Queue empty.";
}
return queue.map((userId, index) => `${index + 1}. <@${userId}>`).join("\n");
}
function isQueueManager(interaction: ChatInputCommandInteraction): boolean {
if (!interaction.memberPermissions) {
return false;
}
return interaction.memberPermissions.has(PermissionFlagsBits.ManageChannels);
}
export const signUpCommand = {
data: new SlashCommandBuilder()
.setName("sign-up")
.setDescription("Tour schedule queue controls.")
.addSubcommand((subcommand) =>
subcommand.setName("join").setDescription("Join speaking queue."),
)
.addSubcommand((subcommand) =>
subcommand.setName("leave").setDescription("Leave speaking queue."),
)
.addSubcommand((subcommand) =>
subcommand
.setName("list")
.setDescription("Show current FIFO speaking queue."),
)
.addSubcommand((subcommand) =>
subcommand
.setName("next")
.setDescription("Advance queue and promote next stage speaker."),
)
.addSubcommand((subcommand) =>
subcommand.setName("clear").setDescription("Clear queue."),
),
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
if (!requireGuild(interaction)) {
await interaction.reply({
content: "Run this command in server.",
ephemeral: true,
});
return;
}
const queue = getTourSchedule();
const action = interaction.options.getSubcommand();
if (action === "join") {
const result = queue.join(interaction.guildId, interaction.user.id);
await interaction.reply({
content: result.joined
? `Added to queue at position ${result.position}.`
: `Already queued at position ${result.position}.`,
ephemeral: true,
});
return;
}
if (action === "leave") {
const removed = queue.leave(interaction.guildId, interaction.user.id);
await interaction.reply({
content: removed ? "Removed from queue." : "You are not in queue.",
ephemeral: true,
});
return;
}
if (action === "list") {
const list = queue.list(interaction.guildId);
await interaction.reply({
content: formatQueue(list),
ephemeral: true,
});
return;
}
if (!isQueueManager(interaction)) {
await interaction.reply({
content: "Need Manage Channels permission for this action.",
ephemeral: true,
});
return;
}
if (action === "clear") {
const cleared = queue.clear(interaction.guildId);
await interaction.reply({
content: `Queue cleared. Removed ${cleared} member(s).`,
ephemeral: true,
});
return;
}
if (action === "next") {
if (!interaction.guild) {
await interaction.reply({
content: "Guild context missing.",
ephemeral: true,
});
return;
}
const result = await queue.next(interaction.guild);
if (!result.nextUserId) {
await interaction.reply({
content: "Queue empty.",
ephemeral: true,
});
return;
}
const stageNote =
result.stageResult === "promoted"
? "Stage speaker promoted."
: result.stageResult === "not_configured"
? "Stage channel not configured."
: result.stageResult === "stage_not_found"
? "Configured stage channel not found."
: "User not in configured stage channel.";
await interaction.reply({
content: [
`Now up: <@${result.nextUserId}>`,
`Queue remaining: ${result.remaining}`,
stageNote,
].join("\n"),
ephemeral: false,
});
return;
}
await interaction.reply({
content: "Unsupported subcommand.",
ephemeral: true,
});
},
};
+474 -7
View File
@@ -1,24 +1,491 @@
import { Pool } from "pg";
export interface BotConfig { export interface BotConfig {
discordToken: string; discordToken: string;
discordClientId: string; discordClientId: string;
discordGuildId?: string; discordGuildId?: string;
callSheet: CallSheetConfig;
mileage: MileageConfig;
tourSchedule: TourScheduleConfig;
dailies: DailiesConfig;
adminApi: AdminApiConfig;
oauthBridge: OAuthBridgeConfig;
configurationDatabase: ConfigurationDatabaseConfig;
} }
function readRequiredEnv(name: string): string { export interface ReactionRoleBinding {
const value = process.env[name]; emoji: string;
roleId: string;
label?: string;
group?: string;
}
export interface CallSheetConfig {
messageId?: string;
onboardedRoleId?: string;
welcomeChannelId?: string;
bindings: ReactionRoleBinding[];
}
export interface MileageRoleTier {
roleId: string;
minMiles: number;
}
export interface MileageConfig {
databaseUrl?: string;
roleTiers: MileageRoleTier[];
eventScores: Record<string, number>;
}
export interface TourScheduleConfig {
stageChannelId?: string;
announceChannelId?: string;
}
export interface DailiesWebhookTarget {
url: string;
label?: string;
}
export interface DailiesConfig {
enabled: boolean;
pollIntervalMs: number;
youtubeChannelIds: string[];
webhookTargets: DailiesWebhookTarget[];
}
export interface AdminApiConfig {
enabled: boolean;
host: string;
port: number;
token?: string;
}
export interface OAuthBridgeConfig {
enabled: boolean;
clientSecret?: string;
redirectUri?: string;
sessionTtlMs: number;
openmicSyncUrl?: string;
openmicSyncToken?: string;
}
export interface ConfigurationDatabaseConfig {
enabled: boolean;
databaseUrl?: string;
}
const GLOBAL_CONFIG_SCOPE = "__global__";
type ConfigReader = (name: string) => string | undefined;
function readRequiredValue(readConfig: ConfigReader, name: string): string {
const value = readConfig(name);
if (!value) { if (!value) {
throw new Error(`Missing required environment variable: ${name}`); throw new Error(`Missing required configuration value: ${name}`);
} }
return value; return value;
} }
export function loadConfig(): BotConfig { function parseReactionBindings(raw?: string): ReactionRoleBinding[] {
const discordGuildId = process.env.DISCORD_GUILD_ID; if (!raw) {
return [];
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error: unknown) {
throw new Error(
`Invalid JSON in CALL_SHEET_REACTION_ROLES: ${String(error)}`,
);
}
if (!Array.isArray(parsed)) {
throw new Error("CALL_SHEET_REACTION_ROLES must be JSON array.");
}
return parsed.map((item, index) => {
if (
!item ||
typeof item !== "object" ||
!("emoji" in item) ||
!("roleId" in item)
) {
throw new Error(
`CALL_SHEET_REACTION_ROLES[${index}] must include emoji and roleId.`,
);
}
const emoji = String(item.emoji).trim();
const roleId = String(item.roleId).trim();
const label = "label" in item ? String(item.label).trim() : undefined;
const group = "group" in item ? String(item.group).trim() : undefined;
if (!emoji || !roleId) {
throw new Error(
`CALL_SHEET_REACTION_ROLES[${index}] emoji/roleId cannot be empty.`,
);
}
return {
emoji,
roleId,
...(label ? { label } : {}),
...(group ? { group } : {}),
};
});
}
function normalizeConfigValue(value: unknown): string | undefined {
if (value === null || value === undefined) {
return undefined;
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
try {
return JSON.stringify(value);
} catch {
return undefined;
}
}
async function loadConfigOverridesFromDatabase(
databaseUrl: string,
guildScope?: string,
): Promise<Map<string, string>> {
const pool = new Pool({
connectionString: databaseUrl,
});
const scopes = [GLOBAL_CONFIG_SCOPE];
if (guildScope && guildScope !== GLOBAL_CONFIG_SCOPE) {
scopes.push(guildScope);
}
try {
const result = await pool.query<{
guild_id: string;
setting_key: string;
setting_value: unknown;
}>(
`
SELECT guild_id, setting_key, setting_value
FROM bot_settings
WHERE guild_id = ANY($1::text[])
ORDER BY
CASE
WHEN guild_id = $2 THEN 1
ELSE 0
END ASC,
updated_at ASC
`,
[scopes, guildScope ?? GLOBAL_CONFIG_SCOPE],
);
const overrides = new Map<string, string>();
for (const row of result.rows) {
const normalized = normalizeConfigValue(row.setting_value);
if (normalized !== undefined) {
overrides.set(row.setting_key, normalized);
}
}
return overrides;
} finally {
await pool.end();
}
}
function buildConfig(readConfig: ConfigReader): BotConfig {
const discordGuildId = readConfig("DISCORD_GUILD_ID");
const callSheetMessageId = readConfig("CALL_SHEET_MESSAGE_ID");
const callSheetOnboardedRoleId = readConfig("CALL_SHEET_ONBOARDED_ROLE_ID");
const callSheetWelcomeChannelId = readConfig("CALL_SHEET_WELCOME_CHANNEL_ID");
const bindings = parseReactionBindings(
readConfig("CALL_SHEET_REACTION_ROLES"),
);
const mileageRoleTiers = parseMileageRoleTiers(
readConfig("MILEAGE_ROLE_TIERS"),
);
const mileageEventScores = parseMileageEventScores(
readConfig("MILEAGE_EVENT_SCORES"),
);
const databaseUrl = readConfig("DATABASE_URL");
const tourStageChannelId = readConfig("TOUR_STAGE_CHANNEL_ID");
const tourAnnounceChannelId = readConfig("TOUR_ANNOUNCE_CHANNEL_ID");
const dailiesEnabled = readConfig("DAILIES_ENABLED") === "true";
const dailiesPollIntervalMs = Number(
readConfig("DAILIES_POLL_INTERVAL_MS") ?? "300000",
);
if (Number.isNaN(dailiesPollIntervalMs) || dailiesPollIntervalMs < 10000) {
throw new Error("DAILIES_POLL_INTERVAL_MS must be number >= 10000.");
}
const dailiesYouTubeChannelIds = parseStringArray(
readConfig("DAILIES_YOUTUBE_CHANNEL_IDS"),
"DAILIES_YOUTUBE_CHANNEL_IDS",
);
const dailiesWebhookTargets = parseWebhookTargets(
readConfig("DAILIES_WEBHOOK_TARGETS"),
);
const adminApiEnabled = readConfig("ADMIN_API_ENABLED") === "true";
const adminApiHost = readConfig("ADMIN_API_HOST") ?? "0.0.0.0";
const adminApiPort = Number(readConfig("ADMIN_API_PORT") ?? "8787");
if (Number.isNaN(adminApiPort) || adminApiPort <= 0 || adminApiPort > 65535) {
throw new Error("ADMIN_API_PORT must be valid TCP port.");
}
const adminApiToken = readConfig("ADMIN_API_TOKEN")?.trim();
const oauthBridgeClientSecret = readConfig(
"OAUTH_BRIDGE_CLIENT_SECRET",
)?.trim();
const oauthBridgeRedirectUri = readConfig(
"OAUTH_BRIDGE_REDIRECT_URI",
)?.trim();
const oauthBridgeSessionTtlMs = Number(
readConfig("OAUTH_BRIDGE_SESSION_TTL_MS") ?? "3600000",
);
if (
Number.isNaN(oauthBridgeSessionTtlMs) ||
oauthBridgeSessionTtlMs < 60000
) {
throw new Error("OAUTH_BRIDGE_SESSION_TTL_MS must be number >= 60000.");
}
const openmicSyncUrl = readConfig("OPENMIC_SYNC_URL")?.trim();
const openmicSyncToken = readConfig("OPENMIC_SYNC_TOKEN")?.trim();
const oauthBridgeEnabled =
Boolean(oauthBridgeClientSecret) && Boolean(oauthBridgeRedirectUri);
const configurationDatabaseEnabled =
readConfig("CONFIG_DB_ENABLED") === "false" ? false : Boolean(databaseUrl);
return { return {
discordToken: readRequiredEnv("DISCORD_TOKEN"), discordToken: readRequiredValue(readConfig, "DISCORD_TOKEN"),
discordClientId: readRequiredEnv("DISCORD_CLIENT_ID"), discordClientId: readRequiredValue(readConfig, "DISCORD_CLIENT_ID"),
...(discordGuildId ? { discordGuildId } : {}), ...(discordGuildId ? { discordGuildId } : {}),
callSheet: {
...(callSheetMessageId ? { messageId: callSheetMessageId } : {}),
...(callSheetOnboardedRoleId
? { onboardedRoleId: callSheetOnboardedRoleId }
: {}),
...(callSheetWelcomeChannelId
? { welcomeChannelId: callSheetWelcomeChannelId }
: {}),
bindings,
},
mileage: {
...(databaseUrl ? { databaseUrl } : {}),
roleTiers: mileageRoleTiers,
eventScores: mileageEventScores,
},
tourSchedule: {
...(tourStageChannelId ? { stageChannelId: tourStageChannelId } : {}),
...(tourAnnounceChannelId
? { announceChannelId: tourAnnounceChannelId }
: {}),
},
dailies: {
enabled: dailiesEnabled,
pollIntervalMs: dailiesPollIntervalMs,
youtubeChannelIds: dailiesYouTubeChannelIds,
webhookTargets: dailiesWebhookTargets,
},
adminApi: {
enabled: adminApiEnabled,
host: adminApiHost,
port: adminApiPort,
...(adminApiToken ? { token: adminApiToken } : {}),
},
oauthBridge: {
enabled: oauthBridgeEnabled,
...(oauthBridgeClientSecret
? { clientSecret: oauthBridgeClientSecret }
: {}),
...(oauthBridgeRedirectUri
? { redirectUri: oauthBridgeRedirectUri }
: {}),
sessionTtlMs: oauthBridgeSessionTtlMs,
...(openmicSyncUrl ? { openmicSyncUrl } : {}),
...(openmicSyncToken ? { openmicSyncToken } : {}),
},
configurationDatabase: {
enabled: configurationDatabaseEnabled,
...(databaseUrl ? { databaseUrl } : {}),
},
}; };
} }
function parseMileageRoleTiers(raw?: string): MileageRoleTier[] {
if (!raw) {
return [];
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error: unknown) {
throw new Error(`Invalid JSON in MILEAGE_ROLE_TIERS: ${String(error)}`);
}
if (!Array.isArray(parsed)) {
throw new Error("MILEAGE_ROLE_TIERS must be JSON array.");
}
return parsed.map((item, index) => {
if (
!item ||
typeof item !== "object" ||
!("roleId" in item) ||
!("minMiles" in item)
) {
throw new Error(
`MILEAGE_ROLE_TIERS[${index}] must include roleId and minMiles.`,
);
}
const roleId = String(item.roleId).trim();
const minMiles = Number(item.minMiles);
if (!roleId || Number.isNaN(minMiles) || minMiles < 0) {
throw new Error(
`MILEAGE_ROLE_TIERS[${index}] invalid roleId or minMiles value.`,
);
}
return { roleId, minMiles };
});
}
function parseMileageEventScores(raw?: string): Record<string, number> {
const defaults: Record<string, number> = {
command_execute: 10,
reaction_role_select: 25,
};
if (!raw) {
return defaults;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error: unknown) {
throw new Error(`Invalid JSON in MILEAGE_EVENT_SCORES: ${String(error)}`);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("MILEAGE_EVENT_SCORES must be JSON object.");
}
const mapped: Record<string, number> = { ...defaults };
for (const [eventName, value] of Object.entries(parsed)) {
const score = Number(value);
if (Number.isNaN(score) || score < 0) {
throw new Error(
`MILEAGE_EVENT_SCORES.${eventName} must be non-negative.`,
);
}
mapped[eventName] = score;
}
return mapped;
}
function parseStringArray(raw?: string, envName?: string): string[] {
if (!raw) {
return [];
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error: unknown) {
throw new Error(`Invalid JSON in ${envName ?? "array"}: ${String(error)}`);
}
if (!Array.isArray(parsed)) {
throw new Error(`${envName ?? "value"} must be JSON array.`);
}
return parsed
.map((item) => String(item).trim())
.filter((item) => item.length > 0);
}
function parseWebhookTargets(raw?: string): DailiesWebhookTarget[] {
if (!raw) {
return [];
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error: unknown) {
throw new Error(
`Invalid JSON in DAILIES_WEBHOOK_TARGETS: ${String(error)}`,
);
}
if (!Array.isArray(parsed)) {
throw new Error("DAILIES_WEBHOOK_TARGETS must be JSON array.");
}
return parsed.map((item, index) => {
if (!item || typeof item !== "object" || !("url" in item)) {
throw new Error(`DAILIES_WEBHOOK_TARGETS[${index}] must include url.`);
}
const url = String(item.url).trim();
const label = "label" in item ? String(item.label).trim() : undefined;
if (!url.startsWith("http://") && !url.startsWith("https://")) {
throw new Error(`DAILIES_WEBHOOK_TARGETS[${index}].url must be http(s).`);
}
return {
url,
...(label ? { label } : {}),
};
});
}
export function loadConfig(): BotConfig {
return buildConfig((name) => process.env[name]);
}
export async function loadRuntimeConfig(): Promise<BotConfig> {
const databaseUrl = process.env.DATABASE_URL?.trim();
const configurationDatabaseEnabled =
process.env.CONFIG_DB_ENABLED === "false" ? false : Boolean(databaseUrl);
const guildScope = process.env.DISCORD_GUILD_ID?.trim();
if (!configurationDatabaseEnabled || !databaseUrl) {
return loadConfig();
}
try {
const overrides = await loadConfigOverridesFromDatabase(
databaseUrl,
guildScope,
);
return buildConfig((name) => overrides.get(name) ?? process.env[name]);
} catch (error: unknown) {
console.warn(
`Falling back to environment config. Config DB overrides unavailable: ${String(error)}`,
);
return loadConfig();
}
}
+385
View File
@@ -0,0 +1,385 @@
import { Pool } from "pg";
import { ConfigurationDatabaseConfig } from "./config";
export interface BotSettingRecord {
guildId: string;
settingKey: string;
settingValue: unknown;
updatedAt: string;
}
export interface ContentScheduleRecord {
guildId: string;
scheduleKey: string;
publishAt: string;
payload: unknown;
updatedAt: string;
}
export interface EngagementStatRecord {
guildId: string;
snapshotAt: string;
commandCount: number;
queueSize: number;
mileageTotal: number;
activeUsers: number;
payload: unknown;
}
export class ConfigurationDatabase {
private readonly config: ConfigurationDatabaseConfig;
private readonly pool?: Pool;
constructor(config: ConfigurationDatabaseConfig) {
this.config = config;
if (config.enabled && config.databaseUrl) {
this.pool = new Pool({
connectionString: config.databaseUrl,
});
}
}
get enabled(): boolean {
return Boolean(this.pool);
}
async init(): Promise<void> {
if (!this.pool) {
console.log(
"Configuration DB disabled: set DATABASE_URL and CONFIG_DB_ENABLED=true.",
);
return;
}
await this.pool.query(`
CREATE TABLE IF NOT EXISTS bot_settings (
guild_id TEXT NOT NULL,
setting_key TEXT NOT NULL,
setting_value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (guild_id, setting_key)
);
`);
await this.pool.query(`
CREATE TABLE IF NOT EXISTS content_schedules (
guild_id TEXT NOT NULL,
schedule_key TEXT NOT NULL,
publish_at TIMESTAMPTZ NOT NULL,
payload JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (guild_id, schedule_key)
);
`);
await this.pool.query(`
CREATE TABLE IF NOT EXISTS engagement_stats (
id BIGSERIAL PRIMARY KEY,
guild_id TEXT NOT NULL,
snapshot_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
command_count INTEGER NOT NULL,
queue_size INTEGER NOT NULL,
mileage_total INTEGER NOT NULL,
active_users INTEGER NOT NULL,
payload JSONB NOT NULL
);
`);
}
async close(): Promise<void> {
if (!this.pool) {
return;
}
await this.pool.end();
}
async getSettings(guildId: string): Promise<BotSettingRecord[]> {
if (!this.pool) {
return [];
}
const result = await this.pool.query<{
guild_id: string;
setting_key: string;
setting_value: unknown;
updated_at: string;
}>(
`
SELECT guild_id, setting_key, setting_value, updated_at::text AS updated_at
FROM bot_settings
WHERE guild_id = $1
ORDER BY setting_key ASC
`,
[guildId],
);
return result.rows.map((row) => ({
guildId: row.guild_id,
settingKey: row.setting_key,
settingValue: row.setting_value,
updatedAt: row.updated_at,
}));
}
async upsertSetting(
guildId: string,
settingKey: string,
settingValue: unknown,
): Promise<BotSettingRecord> {
if (!this.pool) {
throw new Error("Configuration DB disabled");
}
const result = await this.pool.query<{
guild_id: string;
setting_key: string;
setting_value: unknown;
updated_at: string;
}>(
`
INSERT INTO bot_settings (guild_id, setting_key, setting_value)
VALUES ($1, $2, $3::jsonb)
ON CONFLICT (guild_id, setting_key)
DO UPDATE SET
setting_value = EXCLUDED.setting_value,
updated_at = NOW()
RETURNING guild_id, setting_key, setting_value, updated_at::text AS updated_at
`,
[guildId, settingKey, JSON.stringify(settingValue)],
);
const row = result.rows[0];
if (!row) {
throw new Error("Failed to upsert bot setting");
}
return {
guildId: row.guild_id,
settingKey: row.setting_key,
settingValue: row.setting_value,
updatedAt: row.updated_at,
};
}
async listSchedules(guildId: string): Promise<ContentScheduleRecord[]> {
if (!this.pool) {
return [];
}
const result = await this.pool.query<{
guild_id: string;
schedule_key: string;
publish_at: string;
payload: unknown;
updated_at: string;
}>(
`
SELECT
guild_id,
schedule_key,
publish_at::text AS publish_at,
payload,
updated_at::text AS updated_at
FROM content_schedules
WHERE guild_id = $1
ORDER BY publish_at ASC
`,
[guildId],
);
return result.rows.map((row) => ({
guildId: row.guild_id,
scheduleKey: row.schedule_key,
publishAt: row.publish_at,
payload: row.payload,
updatedAt: row.updated_at,
}));
}
async upsertSchedule(
guildId: string,
scheduleKey: string,
publishAt: string,
payload: unknown,
): Promise<ContentScheduleRecord> {
if (!this.pool) {
throw new Error("Configuration DB disabled");
}
const result = await this.pool.query<{
guild_id: string;
schedule_key: string;
publish_at: string;
payload: unknown;
updated_at: string;
}>(
`
INSERT INTO content_schedules (guild_id, schedule_key, publish_at, payload)
VALUES ($1, $2, $3::timestamptz, $4::jsonb)
ON CONFLICT (guild_id, schedule_key)
DO UPDATE SET
publish_at = EXCLUDED.publish_at,
payload = EXCLUDED.payload,
updated_at = NOW()
RETURNING
guild_id,
schedule_key,
publish_at::text AS publish_at,
payload,
updated_at::text AS updated_at
`,
[guildId, scheduleKey, publishAt, JSON.stringify(payload)],
);
const row = result.rows[0];
if (!row) {
throw new Error("Failed to upsert content schedule");
}
return {
guildId: row.guild_id,
scheduleKey: row.schedule_key,
publishAt: row.publish_at,
payload: row.payload,
updatedAt: row.updated_at,
};
}
async deleteSchedule(guildId: string, scheduleKey: string): Promise<boolean> {
if (!this.pool) {
throw new Error("Configuration DB disabled");
}
const result = await this.pool.query(
`
DELETE FROM content_schedules
WHERE guild_id = $1 AND schedule_key = $2
`,
[guildId, scheduleKey],
);
return (result.rowCount ?? 0) > 0;
}
async insertEngagementSnapshot(
record: Omit<EngagementStatRecord, "snapshotAt"> & { snapshotAt?: string },
): Promise<EngagementStatRecord> {
if (!this.pool) {
throw new Error("Configuration DB disabled");
}
const result = await this.pool.query<{
guild_id: string;
snapshot_at: string;
command_count: number;
queue_size: number;
mileage_total: number;
active_users: number;
payload: unknown;
}>(
`
INSERT INTO engagement_stats (
guild_id,
snapshot_at,
command_count,
queue_size,
mileage_total,
active_users,
payload
)
VALUES (
$1,
COALESCE($2::timestamptz, NOW()),
$3,
$4,
$5,
$6,
$7::jsonb
)
RETURNING
guild_id,
snapshot_at::text AS snapshot_at,
command_count,
queue_size,
mileage_total,
active_users,
payload
`,
[
record.guildId,
// Allow explicit backfill timestamps while defaulting to ingestion time.
record.snapshotAt ?? null,
record.commandCount,
record.queueSize,
record.mileageTotal,
record.activeUsers,
JSON.stringify(record.payload),
],
);
const row = result.rows[0];
if (!row) {
throw new Error("Failed to insert engagement snapshot");
}
return {
guildId: row.guild_id,
snapshotAt: row.snapshot_at,
commandCount: row.command_count,
queueSize: row.queue_size,
mileageTotal: row.mileage_total,
activeUsers: row.active_users,
payload: row.payload,
};
}
async getLatestEngagement(
guildId: string,
): Promise<EngagementStatRecord | null> {
if (!this.pool) {
return null;
}
const result = await this.pool.query<{
guild_id: string;
snapshot_at: string;
command_count: number;
queue_size: number;
mileage_total: number;
active_users: number;
payload: unknown;
}>(
`
SELECT
guild_id,
snapshot_at::text AS snapshot_at,
command_count,
queue_size,
mileage_total,
active_users,
payload
FROM engagement_stats
WHERE guild_id = $1
ORDER BY snapshot_at DESC
LIMIT 1
`,
[guildId],
);
const row = result.rows[0];
if (!row) {
return null;
}
return {
guildId: row.guild_id,
snapshotAt: row.snapshot_at,
commandCount: row.command_count,
queueSize: row.queue_size,
mileageTotal: row.mileage_total,
activeUsers: row.active_users,
payload: row.payload,
};
}
}
+13
View File
@@ -0,0 +1,13 @@
export interface ContentItem {
id: string;
source: string;
title: string;
url: string;
publishedAt?: string;
summary?: string;
}
export interface ContentAdapter {
readonly name: string;
poll(): Promise<ContentItem[]>;
}
+17
View File
@@ -0,0 +1,17 @@
import { DailiesConfig } from "../config";
import { DailiesService } from "./service";
let dailiesService: DailiesService | undefined;
export function initDailies(config: DailiesConfig): DailiesService {
dailiesService = new DailiesService(config);
return dailiesService;
}
export function getDailies(): DailiesService {
if (!dailiesService) {
throw new Error("Dailies service not initialized.");
}
return dailiesService;
}
+79
View File
@@ -0,0 +1,79 @@
import { DailiesConfig } from "../config";
import { ContentAdapter, ContentItem } from "./content-adapter";
import { YouTubeAdapter } from "./youtube-adapter";
import { dispatchToWebhooks } from "./webhook-dispatcher";
export class DailiesService {
private readonly config: DailiesConfig;
private readonly adapters: ContentAdapter[];
private readonly seenItemIds = new Set<string>();
private timer: NodeJS.Timeout | null = null;
constructor(config: DailiesConfig) {
this.config = config;
this.adapters = this.buildAdapters(config);
}
start(): void {
if (!this.config.enabled) {
console.log("Dailies disabled: set DAILIES_ENABLED=true to activate.");
return;
}
if (!this.config.webhookTargets.length) {
console.log("Dailies disabled: no DAILIES_WEBHOOK_TARGETS configured.");
return;
}
if (!this.adapters.length) {
console.log("Dailies disabled: no content adapters configured.");
return;
}
this.timer = setInterval(() => {
void this.pollAndDispatch().catch((error: unknown) => {
console.error("Dailies poll failed:", error);
});
}, this.config.pollIntervalMs);
void this.pollAndDispatch().catch((error: unknown) => {
console.error("Dailies initial poll failed:", error);
});
}
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
async pollAndDispatch(): Promise<ContentItem[]> {
const publishedItems: ContentItem[] = [];
for (const adapter of this.adapters) {
const items = await adapter.poll();
for (const item of items) {
if (this.seenItemIds.has(item.id)) {
continue;
}
this.seenItemIds.add(item.id);
await dispatchToWebhooks(this.config.webhookTargets, item);
publishedItems.push(item);
}
}
return publishedItems;
}
private buildAdapters(config: DailiesConfig): ContentAdapter[] {
const adapters: ContentAdapter[] = [];
if (config.youtubeChannelIds.length) {
adapters.push(new YouTubeAdapter(config.youtubeChannelIds));
}
return adapters;
}
}
+36
View File
@@ -0,0 +1,36 @@
import { DailiesWebhookTarget } from "../config";
import { ContentItem } from "./content-adapter";
function buildMessage(item: ContentItem): string {
return [
`New ${item.source} drop:`,
item.title,
item.url,
item.publishedAt ? `Published: ${item.publishedAt}` : undefined,
]
.filter((part): part is string => Boolean(part))
.join("\n");
}
export async function dispatchToWebhooks(
targets: DailiesWebhookTarget[],
item: ContentItem,
): Promise<void> {
for (const target of targets) {
const response = await fetch(target.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
content: buildMessage(item),
}),
});
if (!response.ok) {
throw new Error(
`Webhook dispatch failed (${target.label ?? target.url}): ${response.status}`,
);
}
}
}
+89
View File
@@ -0,0 +1,89 @@
import { ContentAdapter, ContentItem } from "./content-adapter";
function decodeXml(value: string): string {
return value
.replaceAll("&amp;", "&")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&quot;", '"')
.replaceAll("&#39;", "'");
}
function extractTag(source: string, tagName: string): string | undefined {
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)</${tagName}>`, "i");
const match = source.match(regex);
return match?.[1]?.trim();
}
function extractLink(source: string): string | undefined {
const selfClosingLinkRegex = new RegExp(
'<link[^>]*href="([^"]+)"[^>]*/?>',
"i",
);
const wrappedLinkRegex = new RegExp(
'<link[^>]*href="([^"]+)"[^>]*></link>',
"i",
);
const attributeMatch =
source.match(selfClosingLinkRegex) ?? source.match(wrappedLinkRegex);
if (attributeMatch?.[1]) {
return attributeMatch[1].trim();
}
return extractTag(source, "link");
}
function parseFeed(xml: string, channelId: string): ContentItem[] {
const blocks = xml.match(/<entry>[\s\S]*?<\/entry>/gi) ?? [];
return blocks
.map((block): ContentItem | null => {
const videoId =
extractTag(block, "yt:videoId") ?? extractTag(block, "videoId");
const title = extractTag(block, "title");
const link = extractLink(block);
const publishedAt = extractTag(block, "published");
if (!videoId || !title || !link) {
return null;
}
return {
id: `${channelId}:${videoId}`,
source: "youtube",
title: decodeXml(title),
url: link,
...(publishedAt ? { publishedAt } : {}),
};
})
.filter((item): item is ContentItem => Boolean(item));
}
export class YouTubeAdapter implements ContentAdapter {
readonly name = "youtube";
private readonly channelIds: string[];
constructor(channelIds: string[]) {
this.channelIds = channelIds;
}
async poll(): Promise<ContentItem[]> {
const allItems: ContentItem[] = [];
for (const channelId of this.channelIds) {
const response = await fetch(
`https://www.youtube.com/feeds/videos.xml?channel_id=${encodeURIComponent(channelId)}`,
);
if (!response.ok) {
throw new Error(
`YouTube feed request failed for ${channelId}: ${response.status}`,
);
}
const xml = await response.text();
allItems.push(...parseFeed(xml, channelId));
}
return allItems;
}
}
+30
View File
@@ -0,0 +1,30 @@
import { migrateAndSeed, runMigrations, seedInitialConfig } from "./migrations";
async function main(): Promise<void> {
const command = (process.argv[2] ?? "migrate").trim();
if (command === "migrate") {
await runMigrations();
console.log("Database migrations applied.");
return;
}
if (command === "seed") {
const inserted = await seedInitialConfig();
console.log(`Database seed complete. Inserted ${inserted} config entries.`);
return;
}
if (command === "setup") {
const inserted = await migrateAndSeed();
console.log(`Database setup complete. Seeded ${inserted} config entries.`);
return;
}
throw new Error(`Unknown db command: ${command}. Use migrate|seed|setup.`);
}
void main().catch((error: unknown) => {
console.error("Database command failed:", error);
process.exit(1);
});
+213
View File
@@ -0,0 +1,213 @@
import { Pool } from "pg";
const MIGRATIONS_TABLE = "schema_migrations";
const GLOBAL_SCOPE = "__global__";
interface MigrationStep {
id: string;
sql: string;
}
const MIGRATIONS: MigrationStep[] = [
{
id: "2026051701_base_schema",
sql: `
CREATE TABLE IF NOT EXISTS mileage_users (
guild_id TEXT NOT NULL,
user_id TEXT NOT NULL,
total_miles INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (guild_id, user_id)
);
CREATE TABLE IF NOT EXISTS mileage_events (
id BIGSERIAL PRIMARY KEY,
guild_id TEXT NOT NULL,
user_id TEXT NOT NULL,
event_type TEXT NOT NULL,
miles_delta INTEGER NOT NULL,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS mileage_role_tiers (
guild_id TEXT NOT NULL,
role_id TEXT NOT NULL,
min_miles INTEGER NOT NULL,
PRIMARY KEY (guild_id, role_id)
);
CREATE TABLE IF NOT EXISTS bot_settings (
guild_id TEXT NOT NULL,
setting_key TEXT NOT NULL,
setting_value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (guild_id, setting_key)
);
CREATE TABLE IF NOT EXISTS content_schedules (
guild_id TEXT NOT NULL,
schedule_key TEXT NOT NULL,
publish_at TIMESTAMPTZ NOT NULL,
payload JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (guild_id, schedule_key)
);
CREATE TABLE IF NOT EXISTS engagement_stats (
id BIGSERIAL PRIMARY KEY,
guild_id TEXT NOT NULL,
snapshot_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
command_count INTEGER NOT NULL,
queue_size INTEGER NOT NULL,
mileage_total INTEGER NOT NULL,
active_users INTEGER NOT NULL,
payload JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mileage_events_guild_created
ON mileage_events (guild_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_engagement_stats_guild_snapshot
ON engagement_stats (guild_id, snapshot_at DESC);
`,
},
];
const SEEDED_CONFIG_KEYS = [
"DISCORD_TOKEN",
"DISCORD_CLIENT_ID",
"DISCORD_GUILD_ID",
"CALL_SHEET_REACTION_ROLES",
"CALL_SHEET_MESSAGE_ID",
"CALL_SHEET_ONBOARDED_ROLE_ID",
"CALL_SHEET_WELCOME_CHANNEL_ID",
"MILEAGE_EVENT_SCORES",
"MILEAGE_ROLE_TIERS",
"TOUR_STAGE_CHANNEL_ID",
"TOUR_ANNOUNCE_CHANNEL_ID",
"DAILIES_ENABLED",
"DAILIES_POLL_INTERVAL_MS",
"DAILIES_YOUTUBE_CHANNEL_IDS",
"DAILIES_WEBHOOK_TARGETS",
"ADMIN_API_ENABLED",
"ADMIN_API_HOST",
"ADMIN_API_PORT",
"ADMIN_API_TOKEN",
"OAUTH_BRIDGE_CLIENT_SECRET",
"OAUTH_BRIDGE_REDIRECT_URI",
"OAUTH_BRIDGE_SESSION_TTL_MS",
"OPENMIC_SYNC_URL",
"OPENMIC_SYNC_TOKEN",
"CONFIG_DB_ENABLED",
] as const;
function getDatabaseUrl(): string {
const databaseUrl = process.env.DATABASE_URL?.trim();
if (!databaseUrl) {
throw new Error("DATABASE_URL required for migrations and seeding.");
}
return databaseUrl;
}
async function ensureMigrationsTable(pool: Pool): Promise<void> {
await pool.query(`
CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (
id TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
}
async function hasMigration(pool: Pool, id: string): Promise<boolean> {
const result = await pool.query<{ id: string }>(
`SELECT id FROM ${MIGRATIONS_TABLE} WHERE id = $1 LIMIT 1`,
[id],
);
return Boolean(result.rows[0]);
}
export async function runMigrations(
databaseUrl = getDatabaseUrl(),
): Promise<void> {
const pool = new Pool({
connectionString: databaseUrl,
});
try {
await ensureMigrationsTable(pool);
for (const migration of MIGRATIONS) {
const applied = await hasMigration(pool, migration.id);
if (applied) {
continue;
}
await pool.query("BEGIN");
try {
await pool.query(migration.sql);
await pool.query(`INSERT INTO ${MIGRATIONS_TABLE} (id) VALUES ($1)`, [
migration.id,
]);
await pool.query("COMMIT");
} catch (error: unknown) {
await pool.query("ROLLBACK");
throw error;
}
}
} finally {
await pool.end();
}
}
function parseSeedValue(raw: string): unknown {
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
export async function seedInitialConfig(
databaseUrl = getDatabaseUrl(),
guildScope = process.env.DISCORD_GUILD_ID?.trim() || GLOBAL_SCOPE,
): Promise<number> {
const pool = new Pool({
connectionString: databaseUrl,
});
let inserted = 0;
try {
for (const key of SEEDED_CONFIG_KEYS) {
const raw = process.env[key]?.trim();
if (!raw) {
continue;
}
const result = await pool.query(
`
INSERT INTO bot_settings (guild_id, setting_key, setting_value)
VALUES ($1, $2, $3::jsonb)
ON CONFLICT (guild_id, setting_key)
DO NOTHING
`,
[guildScope, key, JSON.stringify(parseSeedValue(raw))],
);
inserted += result.rowCount ?? 0;
}
} finally {
await pool.end();
}
return inserted;
}
export async function migrateAndSeed(
databaseUrl = getDatabaseUrl(),
): Promise<number> {
await runMigrations(databaseUrl);
return seedInitialConfig(databaseUrl);
}
+2 -2
View File
@@ -1,9 +1,9 @@
import { REST, Routes } from "discord.js"; import { REST, Routes } from "discord.js";
import { commands } from "./commands"; import { commands } from "./commands";
import { loadConfig } from "./config"; import { loadRuntimeConfig } from "./config";
async function registerCommands(): Promise<void> { async function registerCommands(): Promise<void> {
const config = loadConfig(); const config = await loadRuntimeConfig();
const rest = new REST({ version: "10" }).setToken(config.discordToken); const rest = new REST({ version: "10" }).setToken(config.discordToken);
const body = commands.map((command) => command.data.toJSON()); const body = commands.map((command) => command.data.toJSON());
+7 -2
View File
@@ -1,8 +1,13 @@
import { startBot } from "./bot"; import { startBot } from "./bot";
import { loadConfig } from "./config"; import { loadRuntimeConfig } from "./config";
import { runMigrations } from "./db/migrations";
export async function bootstrap(): Promise<void> { export async function bootstrap(): Promise<void> {
const config = loadConfig(); if (process.env.DATABASE_URL?.trim()) {
await runMigrations();
}
const config = await loadRuntimeConfig();
await startBot(config); await startBot(config);
} }
+322
View File
@@ -0,0 +1,322 @@
import { GuildMember, Role } from "discord.js";
import { Pool, PoolClient } from "pg";
import { MileageConfig } from "./config";
export interface MileageAwardInput {
guildId: string;
userId: string;
eventType: string;
metadata?: Record<string, unknown>;
pointsOverride?: number;
member?: GuildMember;
}
export interface MileageAwardResult {
awardedMiles: number;
totalMiles: number;
upgradedRoleIds: string[];
}
export interface MileageGuildStats {
enabled: boolean;
guildId: string;
totalMembersTracked: number;
totalMilesAwarded: number;
totalEvents: number;
topUsers: Array<{
userId: string;
totalMiles: number;
}>;
}
const DEFAULT_RESULT: MileageAwardResult = {
awardedMiles: 0,
totalMiles: 0,
upgradedRoleIds: [],
};
async function runWithRetries<T>(
work: () => Promise<T>,
retries = 3,
): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= retries; attempt += 1) {
try {
return await work();
} catch (error: unknown) {
lastError = error;
if (attempt < retries) {
await new Promise((resolve) => setTimeout(resolve, 50 * attempt));
}
}
}
throw lastError;
}
function ensureRoleManageable(member: GuildMember, role: Role): boolean {
const botMember = member.guild.members.me;
if (!botMember) {
return false;
}
return botMember.roles.highest.position > role.position;
}
export class MileageEngine {
private readonly config: MileageConfig;
private readonly pool?: Pool;
constructor(config: MileageConfig) {
this.config = config;
if (config.databaseUrl) {
this.pool = new Pool({
connectionString: config.databaseUrl,
});
}
}
get enabled(): boolean {
return Boolean(this.pool);
}
async init(guildId?: string): Promise<void> {
if (!this.pool) {
console.log("Mileage disabled: set DATABASE_URL to enable persistence.");
return;
}
await this.ensureSchema();
if (guildId) {
await this.syncRoleTiers(guildId);
}
}
async close(): Promise<void> {
if (!this.pool) {
return;
}
await this.pool.end();
}
getScoreForEvent(eventType: string): number {
return this.config.eventScores[eventType] ?? 0;
}
async awardMiles(input: MileageAwardInput): Promise<MileageAwardResult> {
if (!this.pool) {
return DEFAULT_RESULT;
}
const points =
input.pointsOverride ?? this.getScoreForEvent(input.eventType);
if (points <= 0) {
return DEFAULT_RESULT;
}
const totalMiles = await runWithRetries(async () => {
const client = await this.pool!.connect();
try {
return await this.persistAward(client, input, points);
} finally {
client.release();
}
});
const upgradedRoleIds = await this.applyRoleUpgrades(
input.member,
totalMiles,
);
return {
awardedMiles: points,
totalMiles,
upgradedRoleIds,
};
}
async getGuildStats(guildId: string): Promise<MileageGuildStats> {
if (!this.pool) {
return {
enabled: false,
guildId,
totalMembersTracked: 0,
totalMilesAwarded: 0,
totalEvents: 0,
topUsers: [],
};
}
const [membersResult, mileageResult, eventsResult, topUsersResult] =
await Promise.all([
this.pool.query<{ count: string }>(
"SELECT COUNT(*)::text AS count FROM mileage_users WHERE guild_id = $1",
[guildId],
),
this.pool.query<{ total_miles: string }>(
"SELECT COALESCE(SUM(total_miles), 0)::text AS total_miles FROM mileage_users WHERE guild_id = $1",
[guildId],
),
this.pool.query<{ count: string }>(
"SELECT COUNT(*)::text AS count FROM mileage_events WHERE guild_id = $1",
[guildId],
),
this.pool.query<{ user_id: string; total_miles: number }>(
`
SELECT user_id, total_miles
FROM mileage_users
WHERE guild_id = $1
ORDER BY total_miles DESC
LIMIT 10
`,
[guildId],
),
]);
return {
enabled: true,
guildId,
totalMembersTracked: Number(membersResult.rows[0]?.count ?? "0"),
totalMilesAwarded: Number(mileageResult.rows[0]?.total_miles ?? "0"),
totalEvents: Number(eventsResult.rows[0]?.count ?? "0"),
topUsers: topUsersResult.rows.map((row) => ({
userId: row.user_id,
totalMiles: Number(row.total_miles),
})),
};
}
private async ensureSchema(): Promise<void> {
await this.pool!.query(`
CREATE TABLE IF NOT EXISTS mileage_users (
guild_id TEXT NOT NULL,
user_id TEXT NOT NULL,
total_miles INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (guild_id, user_id)
);
`);
await this.pool!.query(`
CREATE TABLE IF NOT EXISTS mileage_events (
id BIGSERIAL PRIMARY KEY,
guild_id TEXT NOT NULL,
user_id TEXT NOT NULL,
event_type TEXT NOT NULL,
miles_delta INTEGER NOT NULL,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`);
await this.pool!.query(`
CREATE TABLE IF NOT EXISTS mileage_role_tiers (
guild_id TEXT NOT NULL,
role_id TEXT NOT NULL,
min_miles INTEGER NOT NULL,
PRIMARY KEY (guild_id, role_id)
);
`);
}
private async syncRoleTiers(guildId: string): Promise<void> {
if (!this.config.roleTiers.length || !this.pool) {
return;
}
await this.pool.query(
"DELETE FROM mileage_role_tiers WHERE guild_id = $1",
[guildId],
);
for (const tier of this.config.roleTiers) {
await this.pool.query(
`
INSERT INTO mileage_role_tiers (guild_id, role_id, min_miles)
VALUES ($1, $2, $3)
ON CONFLICT (guild_id, role_id)
DO UPDATE SET min_miles = EXCLUDED.min_miles
`,
[guildId, tier.roleId, tier.minMiles],
);
}
}
private async persistAward(
client: PoolClient,
input: MileageAwardInput,
points: number,
): Promise<number> {
await client.query("BEGIN");
try {
await client.query(
`
INSERT INTO mileage_events (guild_id, user_id, event_type, miles_delta, metadata)
VALUES ($1, $2, $3, $4, $5::jsonb)
`,
[
input.guildId,
input.userId,
input.eventType,
points,
JSON.stringify(input.metadata ?? {}),
],
);
const result = await client.query<{ total_miles: number }>(
`
INSERT INTO mileage_users (guild_id, user_id, total_miles)
VALUES ($1, $2, $3)
ON CONFLICT (guild_id, user_id)
DO UPDATE SET
total_miles = mileage_users.total_miles + EXCLUDED.total_miles,
updated_at = NOW()
RETURNING total_miles
`,
[input.guildId, input.userId, points],
);
await client.query("COMMIT");
return result.rows[0]?.total_miles ?? 0;
} catch (error: unknown) {
await client.query("ROLLBACK");
throw error;
}
}
private async applyRoleUpgrades(
member: GuildMember | undefined,
totalMiles: number,
): Promise<string[]> {
if (!member || !this.config.roleTiers.length) {
return [];
}
const gainedRoleIds: string[] = [];
const sortedTiers = [...this.config.roleTiers].sort(
(left, right) => left.minMiles - right.minMiles,
);
for (const tier of sortedTiers) {
if (totalMiles < tier.minMiles || member.roles.cache.has(tier.roleId)) {
continue;
}
const role = await member.guild.roles.fetch(tier.roleId);
if (!role || !ensureRoleManageable(member, role)) {
continue;
}
await member.roles.add(
role,
`Mileage upgrade: reached ${tier.minMiles} miles`,
);
gainedRoleIds.push(tier.roleId);
}
return gainedRoleIds;
}
}
+198
View File
@@ -0,0 +1,198 @@
import { OAuthBridgeConfig } from "./config";
interface DiscordTokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
scope: string;
}
interface DiscordUser {
id: string;
username: string;
global_name: string | null;
avatar: string | null;
}
interface BridgeSession {
id: string;
discordUser: {
id: string;
username: string;
displayName: string;
avatarUrl: string | null;
};
createdAt: string;
expiresAt: string;
syncedToOpenmic: boolean;
}
export interface OAuthExchangeResult {
sessionId: string;
createdAt: string;
expiresAt: string;
syncedToOpenmic: boolean;
discordUser: BridgeSession["discordUser"];
}
export class OAuthBridgeService {
private readonly config: OAuthBridgeConfig;
private readonly discordClientId: string;
private readonly sessions = new Map<string, BridgeSession>();
constructor(config: OAuthBridgeConfig, discordClientId: string) {
this.config = config;
this.discordClientId = discordClientId;
}
get enabled(): boolean {
return this.config.enabled;
}
async exchangeCode(code: string): Promise<OAuthExchangeResult> {
if (
!this.config.enabled ||
!this.config.clientSecret ||
!this.config.redirectUri
) {
throw new Error(
"OAuth bridge disabled. Configure client secret and redirect URI.",
);
}
const token = await this.fetchDiscordToken(code);
const discordUser = await this.fetchDiscordUser(token.access_token);
const now = new Date();
const expiresAt = new Date(now.getTime() + this.config.sessionTtlMs);
const sessionId = crypto.randomUUID();
const session: BridgeSession = {
id: sessionId,
discordUser: {
id: discordUser.id,
username: discordUser.username,
displayName: discordUser.global_name ?? discordUser.username,
avatarUrl: discordUser.avatar
? `https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.png`
: null,
},
createdAt: now.toISOString(),
expiresAt: expiresAt.toISOString(),
syncedToOpenmic: false,
};
session.syncedToOpenmic = await this.syncOpenmicSession(session);
this.cleanupExpiredSessions();
this.sessions.set(session.id, session);
return {
sessionId: session.id,
createdAt: session.createdAt,
expiresAt: session.expiresAt,
syncedToOpenmic: session.syncedToOpenmic,
discordUser: session.discordUser,
};
}
getSession(sessionId: string): OAuthExchangeResult | null {
this.cleanupExpiredSessions();
const session = this.sessions.get(sessionId);
if (!session) {
return null;
}
return {
sessionId: session.id,
createdAt: session.createdAt,
expiresAt: session.expiresAt,
syncedToOpenmic: session.syncedToOpenmic,
discordUser: session.discordUser,
};
}
private cleanupExpiredSessions(): void {
const now = Date.now();
for (const [sessionId, session] of this.sessions.entries()) {
if (Date.parse(session.expiresAt) <= now) {
this.sessions.delete(sessionId);
}
}
}
private async fetchDiscordToken(code: string): Promise<DiscordTokenResponse> {
const body = new URLSearchParams({
client_id: this.discordClientId,
client_secret: this.config.clientSecret!,
grant_type: "authorization_code",
code,
redirect_uri: this.config.redirectUri!,
});
const response = await fetch("https://discord.com/api/v10/oauth2/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body,
});
if (!response.ok) {
const raw = await response.text();
throw new Error(
`Discord token exchange failed (${response.status}): ${raw}`,
);
}
return (await response.json()) as DiscordTokenResponse;
}
private async fetchDiscordUser(accessToken: string): Promise<DiscordUser> {
const response = await fetch("https://discord.com/api/v10/users/@me", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const raw = await response.text();
throw new Error(`Discord user fetch failed (${response.status}): ${raw}`);
}
return (await response.json()) as DiscordUser;
}
private async syncOpenmicSession(session: BridgeSession): Promise<boolean> {
if (!this.config.openmicSyncUrl) {
return false;
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (this.config.openmicSyncToken) {
headers.Authorization = `Bearer ${this.config.openmicSyncToken}`;
}
const response = await fetch(this.config.openmicSyncUrl, {
method: "POST",
headers,
body: JSON.stringify({
sessionId: session.id,
discordUser: session.discordUser,
createdAt: session.createdAt,
expiresAt: session.expiresAt,
}),
});
if (!response.ok) {
const raw = await response.text();
throw new Error(`Openmic sync failed (${response.status}): ${raw}`);
}
return true;
}
}
+182
View File
@@ -0,0 +1,182 @@
import { ChannelType, Guild, GuildMember } from "discord.js";
import { TourScheduleConfig } from "./config";
export interface QueueJoinResult {
joined: boolean;
position: number;
}
export interface QueueNextResult {
nextUserId?: string;
remaining: number;
stageResult:
| "promoted"
| "not_configured"
| "not_in_stage"
| "stage_not_found";
}
export class TourScheduleEngine {
private readonly queues = new Map<string, string[]>();
private config: TourScheduleConfig;
constructor(config: TourScheduleConfig) {
this.config = config;
}
updateConfig(config: TourScheduleConfig): void {
this.config = config;
}
join(guildId: string, userId: string): QueueJoinResult {
const queue = this.getQueue(guildId);
const index = queue.indexOf(userId);
if (index >= 0) {
return {
joined: false,
position: index + 1,
};
}
queue.push(userId);
return {
joined: true,
position: queue.length,
};
}
leave(guildId: string, userId: string): boolean {
const queue = this.getQueue(guildId);
const index = queue.indexOf(userId);
if (index < 0) {
return false;
}
queue.splice(index, 1);
return true;
}
list(guildId: string): string[] {
return [...this.getQueue(guildId)];
}
clear(guildId: string): number {
const queue = this.getQueue(guildId);
const size = queue.length;
queue.splice(0, queue.length);
return size;
}
getQueueLength(guildId: string): number {
return this.getQueue(guildId).length;
}
getAllQueues(): Record<string, string[]> {
const snapshot: Record<string, string[]> = {};
for (const [guildId, queue] of this.queues.entries()) {
snapshot[guildId] = [...queue];
}
return snapshot;
}
async next(guild: Guild): Promise<QueueNextResult> {
const queue = this.getQueue(guild.id);
const nextUserId = queue.shift();
if (!nextUserId) {
return {
remaining: 0,
stageResult: "not_configured",
};
}
const member = await guild.members.fetch(nextUserId).catch(() => null);
const stageResult = await this.promoteToSpeaker(guild, member);
await this.sendAnnouncement(guild, nextUserId, queue.length);
return {
nextUserId,
remaining: queue.length,
stageResult,
};
}
private getQueue(guildId: string): string[] {
let queue = this.queues.get(guildId);
if (!queue) {
queue = [];
this.queues.set(guildId, queue);
}
return queue;
}
private async promoteToSpeaker(
guild: Guild,
member: GuildMember | null,
): Promise<QueueNextResult["stageResult"]> {
const stageChannelId = this.config.stageChannelId;
if (!stageChannelId) {
return "not_configured";
}
const channel = await guild.channels
.fetch(stageChannelId)
.catch(() => null);
if (!channel || channel.type !== ChannelType.GuildStageVoice) {
return "stage_not_found";
}
if (!member || member.voice.channelId !== stageChannelId) {
return "not_in_stage";
}
await member.voice.setSuppressed(false);
return "promoted";
}
private async sendAnnouncement(
guild: Guild,
nextUserId: string,
remaining: number,
): Promise<void> {
const announceChannelId = this.config.announceChannelId;
if (!announceChannelId) {
return;
}
const channel = await guild.channels
.fetch(announceChannelId)
.catch(() => null);
if (!channel || !channel.isSendable()) {
return;
}
await channel.send(
`Next up: <@${nextUserId}>. Queue remaining: ${remaining}.`,
);
}
}
let tourScheduleEngine: TourScheduleEngine | undefined;
export function initTourSchedule(
config: TourScheduleConfig,
): TourScheduleEngine {
if (!tourScheduleEngine) {
tourScheduleEngine = new TourScheduleEngine(config);
return tourScheduleEngine;
}
tourScheduleEngine.updateConfig(config);
return tourScheduleEngine;
}
export function getTourSchedule(): TourScheduleEngine {
if (!tourScheduleEngine) {
throw new Error("Tour schedule engine not initialized.");
}
return tourScheduleEngine;
}
+2
View File
@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=ping.test.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"ping.test.d.ts","sourceRoot":"","sources":["ping.test.ts"],"names":[],"mappings":""}
+11
View File
@@ -0,0 +1,11 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const ping_1 = require("../../src/commands/ping");
describe("pingCommand", () => {
it("replies with Pong", async () => {
const reply = jest.fn().mockResolvedValue(undefined);
await ping_1.pingCommand.execute({ reply });
expect(reply).toHaveBeenCalledWith("Pong!");
});
});
//# sourceMappingURL=ping.test.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"ping.test.js","sourceRoot":"","sources":["ping.test.ts"],"names":[],"mappings":";;AAAA,kDAAsD;AAEtD,QAAQ,CAAC,aAAa,EAAE,GAAG,EAAE;IAC3B,EAAE,CAAC,mBAAmB,EAAE,KAAK,IAAI,EAAE;QACjC,MAAM,KAAK,GAAG,IAAI,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,SAAS,CAAC,CAAC;QAErD,MAAM,kBAAW,CAAC,OAAO,CAAC,EAAE,KAAK,EAAS,CAAC,CAAC;QAE5C,MAAM,CAAC,KAAK,CAAC,CAAC,oBAAoB,CAAC,OAAO,CAAC,CAAC;IAC9C,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
+11
View File
@@ -0,0 +1,11 @@
import { pingCommand } from "../../src/commands/ping";
describe("pingCommand", () => {
it("replies with Pong", async () => {
const reply = jest.fn().mockResolvedValue(undefined);
await pingCommand.execute({ reply } as any);
expect(reply).toHaveBeenCalledWith("Pong!");
});
});
+2
View File
@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=sign-up.test.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"sign-up.test.d.ts","sourceRoot":"","sources":["sign-up.test.ts"],"names":[],"mappings":""}
+78
View File
@@ -0,0 +1,78 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const discord_js_1 = require("discord.js");
const sign_up_1 = require("../../src/commands/sign-up");
const tour_schedule_1 = require("../../src/tour-schedule");
jest.mock("../../src/tour-schedule", () => ({
getTourSchedule: jest.fn(),
}));
describe("signUpCommand", () => {
const queue = {
join: jest.fn(),
leave: jest.fn(),
list: jest.fn(),
clear: jest.fn(),
next: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
tour_schedule_1.getTourSchedule.mockReturnValue(queue);
});
function createInteraction(action) {
return {
inGuild: () => true,
guildId: "guild-1",
user: { id: "user-1" },
guild: { id: "guild-1" },
memberPermissions: {
has: jest.fn((permission) => permission === discord_js_1.PermissionFlagsBits.ManageChannels),
},
options: {
getSubcommand: () => action,
},
reply: jest.fn().mockResolvedValue(undefined),
};
}
it("joins queue and replies with position", async () => {
queue.join.mockReturnValue({ joined: true, position: 2 });
const interaction = createInteraction("join");
await sign_up_1.signUpCommand.execute(interaction);
expect(queue.join).toHaveBeenCalledWith("guild-1", "user-1");
expect(interaction.reply).toHaveBeenCalledWith({
content: "Added to queue at position 2.",
ephemeral: true,
});
});
it("lists queue in FIFO order", async () => {
queue.list.mockReturnValue(["user-1", "user-2"]);
const interaction = createInteraction("list");
await sign_up_1.signUpCommand.execute(interaction);
expect(interaction.reply).toHaveBeenCalledWith({
content: "1. <@user-1>\n2. <@user-2>",
ephemeral: true,
});
});
it("blocks next action when missing Manage Channels", async () => {
const interaction = createInteraction("next");
interaction.memberPermissions.has = jest.fn().mockReturnValue(false);
await sign_up_1.signUpCommand.execute(interaction);
expect(interaction.reply).toHaveBeenCalledWith({
content: "Need Manage Channels permission for this action.",
ephemeral: true,
});
});
it("advances queue and announces stage status", async () => {
queue.next.mockResolvedValue({
nextUserId: "user-7",
remaining: 3,
stageResult: "promoted",
});
const interaction = createInteraction("next");
await sign_up_1.signUpCommand.execute(interaction);
expect(interaction.reply).toHaveBeenCalledWith({
content: "Now up: <@user-7>\nQueue remaining: 3\nStage speaker promoted.",
ephemeral: false,
});
});
});
//# sourceMappingURL=sign-up.test.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"sign-up.test.js","sourceRoot":"","sources":["sign-up.test.ts"],"names":[],"mappings":";;AAAA,2CAAiD;AACjD,wDAA2D;AAC3D,2DAA0D;AAE1D,IAAI,CAAC,IAAI,CAAC,yBAAyB,EAAE,GAAG,EAAE,CAAC,CAAC;IAC1C,eAAe,EAAE,IAAI,CAAC,EAAE,EAAE;CAC3B,CAAC,CAAC,CAAC;AAEJ,QAAQ,CAAC,eAAe,EAAE,GAAG,EAAE;IAC7B,MAAM,KAAK,GAAG;QACZ,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE;QACf,KAAK,EAAE,IAAI,CAAC,EAAE,EAAE;QAChB,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE;QACf,KAAK,EAAE,IAAI,CAAC,EAAE,EAAE;QAChB,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE;KAChB,CAAC;IAEF,UAAU,CAAC,GAAG,EAAE;QACd,IAAI,CAAC,aAAa,EAAE,CAAC;QACpB,+BAA6B,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC;IACxD,CAAC,CAAC,CAAC;IAEH,SAAS,iBAAiB,CAAC,MAAc;QACvC,OAAO;YACL,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI;YACnB,OAAO,EAAE,SAAS;YAClB,IAAI,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE;YACtB,KAAK,EAAE,EAAE,EAAE,EAAE,SAAS,EAAE;YACxB,iBAAiB,EAAE;gBACjB,GAAG,EAAE,IAAI,CAAC,EAAE,CAAC,CAAC,UAAkB,EAAE,EAAE,CAClC,UAAU,KAAK,gCAAmB,CAAC,cAAc,CAClD;aACF;YACD,OAAO,EAAE;gBACP,aAAa,EAAE,GAAG,EAAE,CAAC,MAAM;aAC5B;YACD,KAAK,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,SAAS,CAAC;SAC9C,CAAC;IACJ,CAAC;IAED,EAAE,CAAC,uCAAuC,EAAE,KAAK,IAAI,EAAE;QACrD,KAAK,CAAC,IAAI,CAAC,eAAe,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,EAAE,CAAC,CAAC;QAC1D,MAAM,WAAW,GAAG,iBAAiB,CAAC,MAAM,CAAC,CAAC;QAE9C,MAAM,uBAAa,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;QAEzC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,oBAAoB,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;QAC7D,MAAM,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,oBAAoB,CAAC;YAC7C,OAAO,EAAE,+BAA+B;YACxC,SAAS,EAAE,IAAI;SAChB,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,2BAA2B,EAAE,KAAK,IAAI,EAAE;QACzC,KAAK,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC,CAAC;QACjD,MAAM,WAAW,GAAG,iBAAiB,CAAC,MAAM,CAAC,CAAC;QAE9C,MAAM,uBAAa,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;QAEzC,MAAM,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,oBAAoB,CAAC;YAC7C,OAAO,EAAE,4BAA4B;YACrC,SAAS,EAAE,IAAI;SAChB,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,iDAAiD,EAAE,KAAK,IAAI,EAAE;QAC/D,MAAM,WAAW,GAAG,iBAAiB,CAAC,MAAM,CAAC,CAAC;QAC9C,WAAW,CAAC,iBAAiB,CAAC,GAAG,GAAG,IAAI,CAAC,EAAE,EAAE,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC;QAErE,MAAM,uBAAa,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;QAEzC,MAAM,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,oBAAoB,CAAC;YAC7C,OAAO,EAAE,kDAAkD;YAC3D,SAAS,EAAE,IAAI;SAChB,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,2CAA2C,EAAE,KAAK,IAAI,EAAE;QACzD,KAAK,CAAC,IAAI,CAAC,iBAAiB,CAAC;YAC3B,UAAU,EAAE,QAAQ;YACpB,SAAS,EAAE,CAAC;YACZ,WAAW,EAAE,UAAU;SACxB,CAAC,CAAC;QACH,MAAM,WAAW,GAAG,iBAAiB,CAAC,MAAM,CAAC,CAAC;QAE9C,MAAM,uBAAa,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;QAEzC,MAAM,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,oBAAoB,CAAC;YAC7C,OAAO,EAAE,gEAAgE;YACzE,SAAS,EAAE,KAAK;SACjB,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
+93
View File
@@ -0,0 +1,93 @@
import { PermissionFlagsBits } from "discord.js";
import { signUpCommand } from "../../src/commands/sign-up";
import { getTourSchedule } from "../../src/tour-schedule";
jest.mock("../../src/tour-schedule", () => ({
getTourSchedule: jest.fn(),
}));
describe("signUpCommand", () => {
const queue = {
join: jest.fn(),
leave: jest.fn(),
list: jest.fn(),
clear: jest.fn(),
next: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
(getTourSchedule as jest.Mock).mockReturnValue(queue);
});
function createInteraction(action: string): any {
return {
inGuild: () => true,
guildId: "guild-1",
user: { id: "user-1" },
guild: { id: "guild-1" },
memberPermissions: {
has: jest.fn((permission: bigint) =>
permission === PermissionFlagsBits.ManageChannels,
),
},
options: {
getSubcommand: () => action,
},
reply: jest.fn().mockResolvedValue(undefined),
};
}
it("joins queue and replies with position", async () => {
queue.join.mockReturnValue({ joined: true, position: 2 });
const interaction = createInteraction("join");
await signUpCommand.execute(interaction);
expect(queue.join).toHaveBeenCalledWith("guild-1", "user-1");
expect(interaction.reply).toHaveBeenCalledWith({
content: "Added to queue at position 2.",
ephemeral: true,
});
});
it("lists queue in FIFO order", async () => {
queue.list.mockReturnValue(["user-1", "user-2"]);
const interaction = createInteraction("list");
await signUpCommand.execute(interaction);
expect(interaction.reply).toHaveBeenCalledWith({
content: "1. <@user-1>\n2. <@user-2>",
ephemeral: true,
});
});
it("blocks next action when missing Manage Channels", async () => {
const interaction = createInteraction("next");
interaction.memberPermissions.has = jest.fn().mockReturnValue(false);
await signUpCommand.execute(interaction);
expect(interaction.reply).toHaveBeenCalledWith({
content: "Need Manage Channels permission for this action.",
ephemeral: true,
});
});
it("advances queue and announces stage status", async () => {
queue.next.mockResolvedValue({
nextUserId: "user-7",
remaining: 3,
stageResult: "promoted",
});
const interaction = createInteraction("next");
await signUpCommand.execute(interaction);
expect(interaction.reply).toHaveBeenCalledWith({
content: "Now up: <@user-7>\nQueue remaining: 3\nStage speaker promoted.",
ephemeral: false,
});
});
});
+2
View File
@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=mileage-engine-flow.test.d.ts.map
@@ -0,0 +1 @@
{"version":3,"file":"mileage-engine-flow.test.d.ts","sourceRoot":"","sources":["mileage-engine-flow.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,74 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const mockClient = {
query: jest.fn(),
release: jest.fn(),
};
const mockPool = {
connect: jest.fn(async () => mockClient),
query: jest.fn(),
end: jest.fn(),
};
jest.mock("pg", () => ({
Pool: jest.fn(() => mockPool),
}));
const mileage_1 = require("../../src/mileage");
describe("MileageEngine flow", () => {
beforeEach(() => {
jest.clearAllMocks();
mockClient.query.mockImplementation(async (query) => {
if (query.includes("RETURNING total_miles")) {
return { rows: [{ total_miles: 120 }] };
}
return { rows: [] };
});
});
it("awards miles, persists event, and upgrades role when threshold reached", async () => {
const roleAdd = jest.fn().mockResolvedValue(undefined);
const member = {
roles: {
cache: {
has: jest.fn().mockReturnValue(false),
},
add: roleAdd,
},
guild: {
members: {
me: {
roles: {
highest: {
position: 100,
},
},
},
},
roles: {
fetch: jest.fn().mockResolvedValue({
id: "role-1",
position: 10,
}),
},
},
};
const engine = new mileage_1.MileageEngine({
databaseUrl: "postgres://test",
roleTiers: [{ roleId: "role-1", minMiles: 100 }],
eventScores: { command_execute: 10 },
});
const result = await engine.awardMiles({
guildId: "guild-1",
userId: "user-1",
eventType: "command_execute",
member,
});
expect(result).toEqual({
awardedMiles: 10,
totalMiles: 120,
upgradedRoleIds: ["role-1"],
});
expect(mockClient.query).toHaveBeenCalledWith("BEGIN");
expect(mockClient.query).toHaveBeenCalledWith("COMMIT");
expect(roleAdd).toHaveBeenCalledWith(expect.objectContaining({ id: "role-1" }), "Mileage upgrade: reached 100 miles");
});
});
//# sourceMappingURL=mileage-engine-flow.test.js.map
@@ -0,0 +1 @@
{"version":3,"file":"mileage-engine-flow.test.js","sourceRoot":"","sources":["mileage-engine-flow.test.ts"],"names":[],"mappings":";;AAAA,MAAM,UAAU,GAAG;IACjB,KAAK,EAAE,IAAI,CAAC,EAAE,EAAE;IAChB,OAAO,EAAE,IAAI,CAAC,EAAE,EAAE;CACnB,CAAC;AAEF,MAAM,QAAQ,GAAG;IACf,OAAO,EAAE,IAAI,CAAC,EAAE,CAAC,KAAK,IAAI,EAAE,CAAC,UAAU,CAAC;IACxC,KAAK,EAAE,IAAI,CAAC,EAAE,EAAE;IAChB,GAAG,EAAE,IAAI,CAAC,EAAE,EAAE;CACf,CAAC;AAEF,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,GAAG,EAAE,CAAC,CAAC;IACrB,IAAI,EAAE,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC;CAC9B,CAAC,CAAC,CAAC;AAEJ,+CAAkD;AAElD,QAAQ,CAAC,oBAAoB,EAAE,GAAG,EAAE;IAClC,UAAU,CAAC,GAAG,EAAE;QACd,IAAI,CAAC,aAAa,EAAE,CAAC;QACrB,UAAU,CAAC,KAAK,CAAC,kBAAkB,CAAC,KAAK,EAAE,KAAa,EAAE,EAAE;YAC1D,IAAI,KAAK,CAAC,QAAQ,CAAC,uBAAuB,CAAC,EAAE,CAAC;gBAC5C,OAAO,EAAE,IAAI,EAAE,CAAC,EAAE,WAAW,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC;YAC1C,CAAC;YAED,OAAO,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC;QACtB,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,wEAAwE,EAAE,KAAK,IAAI,EAAE;QACtF,MAAM,OAAO,GAAG,IAAI,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,SAAS,CAAC,CAAC;QACvD,MAAM,MAAM,GAAG;YACb,KAAK,EAAE;gBACL,KAAK,EAAE;oBACL,GAAG,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,eAAe,CAAC,KAAK,CAAC;iBACtC;gBACD,GAAG,EAAE,OAAO;aACb;YACD,KAAK,EAAE;gBACL,OAAO,EAAE;oBACP,EAAE,EAAE;wBACF,KAAK,EAAE;4BACL,OAAO,EAAE;gCACP,QAAQ,EAAE,GAAG;6BACd;yBACF;qBACF;iBACF;gBACD,KAAK,EAAE;oBACL,KAAK,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC;wBACjC,EAAE,EAAE,QAAQ;wBACZ,QAAQ,EAAE,EAAE;qBACb,CAAC;iBACH;aACF;SACK,CAAC;QAET,MAAM,MAAM,GAAG,IAAI,uBAAa,CAAC;YAC/B,WAAW,EAAE,iBAAiB;YAC9B,SAAS,EAAE,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,GAAG,EAAE,CAAC;YAChD,WAAW,EAAE,EAAE,eAAe,EAAE,EAAE,EAAE;SACrC,CAAC,CAAC;QAEH,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,UAAU,CAAC;YACrC,OAAO,EAAE,SAAS;YAClB,MAAM,EAAE,QAAQ;YAChB,SAAS,EAAE,iBAAiB;YAC5B,MAAM;SACP,CAAC,CAAC;QAEH,MAAM,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC;YACrB,YAAY,EAAE,EAAE;YAChB,UAAU,EAAE,GAAG;YACf,eAAe,EAAE,CAAC,QAAQ,CAAC;SAC5B,CAAC,CAAC;QAEH,MAAM,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,oBAAoB,CAAC,OAAO,CAAC,CAAC;QACvD,MAAM,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,oBAAoB,CAAC,QAAQ,CAAC,CAAC;QACxD,MAAM,CAAC,OAAO,CAAC,CAAC,oBAAoB,CAClC,MAAM,CAAC,gBAAgB,CAAC,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC,EACzC,oCAAoC,CACrC,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
@@ -0,0 +1,84 @@
const mockClient = {
query: jest.fn(),
release: jest.fn(),
};
const mockPool = {
connect: jest.fn(async () => mockClient),
query: jest.fn(),
end: jest.fn(),
};
jest.mock("pg", () => ({
Pool: jest.fn(() => mockPool),
}));
import { MileageEngine } from "../../src/mileage";
describe("MileageEngine flow", () => {
beforeEach(() => {
jest.clearAllMocks();
mockClient.query.mockImplementation(async (query: string) => {
if (query.includes("RETURNING total_miles")) {
return { rows: [{ total_miles: 120 }] };
}
return { rows: [] };
});
});
it("awards miles, persists event, and upgrades role when threshold reached", async () => {
const roleAdd = jest.fn().mockResolvedValue(undefined);
const member = {
roles: {
cache: {
has: jest.fn().mockReturnValue(false),
},
add: roleAdd,
},
guild: {
members: {
me: {
roles: {
highest: {
position: 100,
},
},
},
},
roles: {
fetch: jest.fn().mockResolvedValue({
id: "role-1",
position: 10,
}),
},
},
} as any;
const engine = new MileageEngine({
databaseUrl: "postgres://test",
roleTiers: [{ roleId: "role-1", minMiles: 100 }],
eventScores: { command_execute: 10 },
});
const result = await engine.awardMiles({
guildId: "guild-1",
userId: "user-1",
eventType: "command_execute",
member,
});
expect(result).toEqual({
awardedMiles: 10,
totalMiles: 120,
upgradedRoleIds: ["role-1"],
});
expect(mockClient.query).toHaveBeenCalledWith("BEGIN");
expect(mockClient.query).toHaveBeenCalledWith("COMMIT");
expect(roleAdd).toHaveBeenCalledWith(
expect.objectContaining({ id: "role-1" }),
"Mileage upgrade: reached 100 miles",
);
});
});
+2
View File
@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=tour-schedule-flow.test.d.ts.map
@@ -0,0 +1 @@
{"version":3,"file":"tour-schedule-flow.test.d.ts","sourceRoot":"","sources":["tour-schedule-flow.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,59 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const discord_js_1 = require("discord.js");
const tour_schedule_1 = require("../../src/tour-schedule");
describe("TourScheduleEngine flow", () => {
it("handles FIFO next flow and announcement dispatch", async () => {
const send = jest.fn().mockResolvedValue(undefined);
const guild = {
id: "guild-1",
members: {
fetch: jest.fn().mockResolvedValue({
voice: {
channelId: "stage-1",
setSuppressed: jest.fn().mockResolvedValue(undefined),
},
}),
},
channels: {
fetch: jest
.fn()
.mockImplementation(async (channelId) => {
if (channelId === "stage-1") {
return {
type: discord_js_1.ChannelType.GuildStageVoice,
};
}
if (channelId === "announce-1") {
return {
isSendable: () => true,
send,
};
}
return null;
}),
},
};
const engine = new tour_schedule_1.TourScheduleEngine({
stageChannelId: "stage-1",
announceChannelId: "announce-1",
});
engine.join("guild-1", "user-a");
engine.join("guild-1", "user-b");
const first = await engine.next(guild);
const second = await engine.next(guild);
expect(first).toEqual({
nextUserId: "user-a",
remaining: 1,
stageResult: "promoted",
});
expect(second).toEqual({
nextUserId: "user-b",
remaining: 0,
stageResult: "promoted",
});
expect(send).toHaveBeenNthCalledWith(1, "Next up: <@user-a>. Queue remaining: 1.");
expect(send).toHaveBeenNthCalledWith(2, "Next up: <@user-b>. Queue remaining: 0.");
});
});
//# sourceMappingURL=tour-schedule-flow.test.js.map
@@ -0,0 +1 @@
{"version":3,"file":"tour-schedule-flow.test.js","sourceRoot":"","sources":["tour-schedule-flow.test.ts"],"names":[],"mappings":";;AAAA,2CAAyC;AACzC,2DAA6D;AAE7D,QAAQ,CAAC,yBAAyB,EAAE,GAAG,EAAE;IACvC,EAAE,CAAC,kDAAkD,EAAE,KAAK,IAAI,EAAE;QAChE,MAAM,IAAI,GAAG,IAAI,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,SAAS,CAAC,CAAC;QACpD,MAAM,KAAK,GAAG;YACZ,EAAE,EAAE,SAAS;YACb,OAAO,EAAE;gBACP,KAAK,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC;oBACjC,KAAK,EAAE;wBACL,SAAS,EAAE,SAAS;wBACpB,aAAa,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,SAAS,CAAC;qBACtD;iBACF,CAAC;aACH;YACD,QAAQ,EAAE;gBACR,KAAK,EAAE,IAAI;qBACR,EAAE,EAAE;qBACJ,kBAAkB,CAAC,KAAK,EAAE,SAAiB,EAAE,EAAE;oBAC9C,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC;wBAC5B,OAAO;4BACL,IAAI,EAAE,wBAAW,CAAC,eAAe;yBAClC,CAAC;oBACJ,CAAC;oBAED,IAAI,SAAS,KAAK,YAAY,EAAE,CAAC;wBAC/B,OAAO;4BACL,UAAU,EAAE,GAAG,EAAE,CAAC,IAAI;4BACtB,IAAI;yBACL,CAAC;oBACJ,CAAC;oBAED,OAAO,IAAI,CAAC;gBACd,CAAC,CAAC;aACL;SACK,CAAC;QAET,MAAM,MAAM,GAAG,IAAI,kCAAkB,CAAC;YACpC,cAAc,EAAE,SAAS;YACzB,iBAAiB,EAAE,YAAY;SAChC,CAAC,CAAC;QAEH,MAAM,CAAC,IAAI,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;QACjC,MAAM,CAAC,IAAI,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;QAEjC,MAAM,KAAK,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACvC,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAExC,MAAM,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC;YACpB,UAAU,EAAE,QAAQ;YACpB,SAAS,EAAE,CAAC;YACZ,WAAW,EAAE,UAAU;SACxB,CAAC,CAAC;QACH,MAAM,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC;YACrB,UAAU,EAAE,QAAQ;YACpB,SAAS,EAAE,CAAC;YACZ,WAAW,EAAE,UAAU;SACxB,CAAC,CAAC;QAEH,MAAM,CAAC,IAAI,CAAC,CAAC,uBAAuB,CAClC,CAAC,EACD,yCAAyC,CAC1C,CAAC;QACF,MAAM,CAAC,IAAI,CAAC,CAAC,uBAAuB,CAClC,CAAC,EACD,yCAAyC,CAC1C,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
@@ -0,0 +1,70 @@
import { ChannelType } from "discord.js";
import { TourScheduleEngine } from "../../src/tour-schedule";
describe("TourScheduleEngine flow", () => {
it("handles FIFO next flow and announcement dispatch", async () => {
const send = jest.fn().mockResolvedValue(undefined);
const guild = {
id: "guild-1",
members: {
fetch: jest.fn().mockResolvedValue({
voice: {
channelId: "stage-1",
setSuppressed: jest.fn().mockResolvedValue(undefined),
},
}),
},
channels: {
fetch: jest
.fn()
.mockImplementation(async (channelId: string) => {
if (channelId === "stage-1") {
return {
type: ChannelType.GuildStageVoice,
};
}
if (channelId === "announce-1") {
return {
isSendable: () => true,
send,
};
}
return null;
}),
},
} as any;
const engine = new TourScheduleEngine({
stageChannelId: "stage-1",
announceChannelId: "announce-1",
});
engine.join("guild-1", "user-a");
engine.join("guild-1", "user-b");
const first = await engine.next(guild);
const second = await engine.next(guild);
expect(first).toEqual({
nextUserId: "user-a",
remaining: 1,
stageResult: "promoted",
});
expect(second).toEqual({
nextUserId: "user-b",
remaining: 0,
stageResult: "promoted",
});
expect(send).toHaveBeenNthCalledWith(
1,
"Next up: <@user-a>. Queue remaining: 1.",
);
expect(send).toHaveBeenNthCalledWith(
2,
"Next up: <@user-b>. Queue remaining: 0.",
);
});
});
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"noEmit": true,
"declaration": false,
"declarationMap": false
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}
+1 -1
View File
@@ -43,6 +43,6 @@
"esModuleInterop": true "esModuleInterop": true
}, },
"include": ["src/**/*.ts", "tests/**/*.ts"], "include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"] "exclude": ["dist", "node_modules"]
} }