feat: Implement Tour Schedule Engine with queue management and announcement features
- 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:
+13
-3
@@ -1,4 +1,14 @@
|
||||
DISCORD_TOKEN=
|
||||
DISCORD_CLIENT_ID=
|
||||
# Optional. If set, command registration targets this guild for instant updates.
|
||||
# Bootstrap environment variables.
|
||||
# Runtime configuration is loaded from bot_settings in Config DB.
|
||||
# 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=
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -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"
|
||||
@@ -1,65 +1,77 @@
|
||||
# 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
|
||||
|
||||
### 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"**.
|
||||
- **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.
|
||||
- Reaction-role onboarding message with emoji-to-role bindings
|
||||
- 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.
|
||||
- **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.
|
||||
- `/sign-up` subcommands: `join`, `leave`, `list`, `next`, `clear`
|
||||
- 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:
|
||||
- `#polaroids-from-the-van`: Instagram drops.
|
||||
- `#outtakes`: TikTok crowd work and detours.
|
||||
- `#screenings`: YouTube trailers and vlogs.
|
||||
- Adapter-based polling pipeline
|
||||
- YouTube RSS adapter implemented
|
||||
- Discord webhook dispatch with duplicate suppression
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
### Admin API + Dashboard
|
||||
|
||||
### 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.
|
||||
- **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.
|
||||
### OAuth2 Bridge
|
||||
|
||||
- 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)
|
||||
|
||||
This project uses the [arc42](https://arc42.org) architecture documentation template.
|
||||
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) |
|
||||
This project uses the arc42 template. Chapters are in `docs/`.
|
||||
|
||||
## Architecture & Tech Stack
|
||||
|
||||
| Layer | Choice |
|
||||
| --------------- | -------------------- |
|
||||
| Runtime | Node.js (Discord.js) |
|
||||
| Runtime | Node.js + TypeScript |
|
||||
| Discord SDK | discord.js |
|
||||
| Database | PostgreSQL |
|
||||
| Admin Dashboard | React |
|
||||
| Admin API | Express |
|
||||
| Admin Dashboard | React + Vite |
|
||||
| Auth | Discord OAuth2 |
|
||||
| Hosting | Coolify + Nixpacks |
|
||||
|
||||
@@ -67,52 +79,139 @@ All chapters are in `docs/`:
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js v16+ (or Python 3.9+)
|
||||
- A Discord Developer Application with Bot Token
|
||||
- Access to `openmicodyssey.com` backend for OAuth syncing
|
||||
- Node.js 22+
|
||||
- npm 10+
|
||||
- Discord Developer Application (bot token + OAuth2 app)
|
||||
- PostgreSQL instance
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository:
|
||||
1. Clone repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-org/open-mic-odyssey-bot.git
|
||||
cd open-mic-odyssey-bot
|
||||
|
||||
git clone https://git.allucanget.biz/allucanget/omo-bot.git
|
||||
cd omo-bot
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
2. Install root dependencies:
|
||||
|
||||
```bash
|
||||
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
|
||||
DISCORD_TOKEN=your_bot_token
|
||||
CLIENT_ID=your_client_id
|
||||
GUILD_ID=your_server_id
|
||||
DB_CONNECTION_STRING=your_db_uri
|
||||
YOUTUBE_API_KEY=your_yt_key
|
||||
|
||||
DATABASE_URL=postgres://...
|
||||
CONFIG_DB_ENABLED=true
|
||||
DISCORD_GUILD_ID=... # optional seed scope
|
||||
```
|
||||
|
||||
4. Deploy Slash Commands:
|
||||
4. Register slash commands:
|
||||
|
||||
```bash
|
||||
npm run deploy-commands
|
||||
|
||||
npm run register:commands
|
||||
```
|
||||
|
||||
5. Start the bot:
|
||||
5. Run bot in development:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
6. Build and run production bundle:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
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
|
||||
|
||||
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`.
|
||||
|
||||
@@ -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
|
||||
@@ -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?
|
||||
@@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
Generated
+2765
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 |
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
@@ -4,14 +4,14 @@ const tsParser = require("@typescript-eslint/parser");
|
||||
/** @type {import("eslint").Linter.FlatConfig[]} */
|
||||
module.exports = [
|
||||
{
|
||||
ignores: ["dist/**", "node_modules/**"],
|
||||
ignores: ["dist/**", "node_modules/**", "**/*.d.ts"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.ts"],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
project: "./tsconfig.eslint.json",
|
||||
sourceType: "module",
|
||||
},
|
||||
},
|
||||
|
||||
Generated
+1035
-6
File diff suppressed because it is too large
Load Diff
+9
-2
@@ -12,18 +12,25 @@
|
||||
"start": "node dist/index.js",
|
||||
"lint": "eslint . --ext .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": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"discord.js": "^14.26.4"
|
||||
"discord.js": "^14.26.4",
|
||||
"express": "^5.2.1",
|
||||
"pg": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||
"@typescript-eslint/parser": "^8.59.3",
|
||||
"eslint": "^10.4.0",
|
||||
|
||||
@@ -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
@@ -2,16 +2,95 @@ import {
|
||||
Client,
|
||||
Events,
|
||||
GatewayIntentBits,
|
||||
GuildMember,
|
||||
Interaction,
|
||||
Partials,
|
||||
} from "discord.js";
|
||||
import { AdminApiServer } from "./admin-api";
|
||||
import { registerCallSheetHandlers } from "./call-sheet";
|
||||
import { commandMap } from "./commands";
|
||||
import { ConfigurationDatabase } from "./configuration-database";
|
||||
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> {
|
||||
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({
|
||||
intents: [GatewayIntentBits.Guilds],
|
||||
partials: [Partials.Channel],
|
||||
intents: [
|
||||
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) => {
|
||||
@@ -34,6 +113,34 @@ export async function startBot(config: BotConfig): Promise<Client> {
|
||||
|
||||
try {
|
||||
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) {
|
||||
console.error(`Command failed: ${interaction.commandName}`, error);
|
||||
|
||||
@@ -52,5 +159,24 @@ export async function startBot(config: BotConfig): Promise<Client> {
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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
@@ -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 { signUpCommand } from "./sign-up";
|
||||
|
||||
export interface ChatCommand {
|
||||
data: SlashCommandBuilder;
|
||||
data: SlashCommandBuilder | SlashCommandSubcommandsOnlyBuilder;
|
||||
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>(
|
||||
commands.map((command) => [command.data.name, command]),
|
||||
|
||||
@@ -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
@@ -1,24 +1,491 @@
|
||||
import { Pool } from "pg";
|
||||
|
||||
export interface BotConfig {
|
||||
discordToken: string;
|
||||
discordClientId: string;
|
||||
discordGuildId?: string;
|
||||
callSheet: CallSheetConfig;
|
||||
mileage: MileageConfig;
|
||||
tourSchedule: TourScheduleConfig;
|
||||
dailies: DailiesConfig;
|
||||
adminApi: AdminApiConfig;
|
||||
oauthBridge: OAuthBridgeConfig;
|
||||
configurationDatabase: ConfigurationDatabaseConfig;
|
||||
}
|
||||
|
||||
function readRequiredEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
export interface ReactionRoleBinding {
|
||||
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) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
throw new Error(`Missing required configuration value: ${name}`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function loadConfig(): BotConfig {
|
||||
const discordGuildId = process.env.DISCORD_GUILD_ID;
|
||||
function parseReactionBindings(raw?: string): ReactionRoleBinding[] {
|
||||
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 {
|
||||
discordToken: readRequiredEnv("DISCORD_TOKEN"),
|
||||
discordClientId: readRequiredEnv("DISCORD_CLIENT_ID"),
|
||||
discordToken: readRequiredValue(readConfig, "DISCORD_TOKEN"),
|
||||
discordClientId: readRequiredValue(readConfig, "DISCORD_CLIENT_ID"),
|
||||
...(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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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[]>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ContentAdapter, ContentItem } from "./content-adapter";
|
||||
|
||||
function decodeXml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll(""", '"')
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { REST, Routes } from "discord.js";
|
||||
import { commands } from "./commands";
|
||||
import { loadConfig } from "./config";
|
||||
import { loadRuntimeConfig } from "./config";
|
||||
|
||||
async function registerCommands(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const config = await loadRuntimeConfig();
|
||||
const rest = new REST({ version: "10" }).setToken(config.discordToken);
|
||||
const body = commands.map((command) => command.data.toJSON());
|
||||
|
||||
|
||||
+7
-2
@@ -1,8 +1,13 @@
|
||||
import { startBot } from "./bot";
|
||||
import { loadConfig } from "./config";
|
||||
import { loadRuntimeConfig } from "./config";
|
||||
import { runMigrations } from "./db/migrations";
|
||||
|
||||
export async function bootstrap(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
if (process.env.DATABASE_URL?.trim()) {
|
||||
await runMigrations();
|
||||
}
|
||||
|
||||
const config = await loadRuntimeConfig();
|
||||
await startBot(config);
|
||||
}
|
||||
|
||||
|
||||
+322
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
export {};
|
||||
//# sourceMappingURL=ping.test.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"ping.test.d.ts","sourceRoot":"","sources":["ping.test.ts"],"names":[],"mappings":""}
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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!");
|
||||
});
|
||||
});
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
export {};
|
||||
//# sourceMappingURL=sign-up.test.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"sign-up.test.d.ts","sourceRoot":"","sources":["sign-up.test.ts"],"names":[],"mappings":""}
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -43,6 +43,6 @@
|
||||
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user