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=
|
# Bootstrap environment variables.
|
||||||
DISCORD_CLIENT_ID=
|
# Runtime configuration is loaded from bot_settings in Config DB.
|
||||||
# Optional. If set, command registration targets this guild for instant updates.
|
# Keep only database bootstrap values in .env.
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5432/omo_bot
|
||||||
|
CONFIG_DB_ENABLED=true
|
||||||
|
|
||||||
|
# Optional scope used by db:seed for guild-specific keys.
|
||||||
|
# If omitted, seed writes to __global__ scope.
|
||||||
DISCORD_GUILD_ID=
|
DISCORD_GUILD_ID=
|
||||||
|
|
||||||
|
# One-time migration path (legacy env -> Config DB):
|
||||||
|
# 1) Temporarily set legacy env keys.
|
||||||
|
# 2) Run npm run db:seed.
|
||||||
|
# 3) Remove legacy env keys from .env.
|
||||||
|
|||||||
@@ -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
|
# Discord Bot for Open Mic Odyssey
|
||||||
|
|
||||||
A custom Discord bot and web integration layer designed to bridge the [openmicodyssey.com](https://openmicodyssey.com) experience with our community server. This application gamifies community engagement, automates content syndication, and provides a suite of event management tools centered around the themes of stand-up comedy, indie filmmaking, and a cross-country road trip.
|
A modular Discord bot + admin platform that syncs community engagement between Discord and [openmicodyssey.com](https://openmicodyssey.com).
|
||||||
|
The system includes onboarding roles, stage queue management, mileage progression, content syndication, an admin API, and a React admin dashboard.
|
||||||
|
|
||||||
## Core Features
|
## Core Features
|
||||||
|
|
||||||
### The Call Sheet (User & Role Management)
|
### The Call Sheet (Onboarding + Roles)
|
||||||
|
|
||||||
- **The Experience:** New members are greeted with a customized onboarding menu to declare their interests (e.g., `@ComedyFan`, `@Filmmaker`). Active users progress through community ranks, leveling up from **"Extra"** to **"Roadie"** and eventually **"Executive Producer"**.
|
- Reaction-role onboarding message with emoji-to-role bindings
|
||||||
- **Technical Implementation:** Utilizes Discord's Reaction Role payloads and interaction webhooks for automated Role-Based Access Control (RBAC). Implements dynamic permission bitfield assignment and role hierarchy state management based on user activity telemetry.
|
- Role hierarchy safety checks before assignment/removal
|
||||||
|
- Optional onboarding completion role and welcome-channel message
|
||||||
|
|
||||||
### The Tour Schedule (Event Management)
|
### The Tour Schedule (Stage Queue)
|
||||||
|
|
||||||
- **The Experience:** Organizes virtual **"Tour Stops"** (screenings) and digital open mics. Users can utilize the `/sign-up` command to enter the stage queue, while attendees receive temporary **"VIP Backstage"** passes for live Q&As.
|
- `/sign-up` subcommands: `join`, `leave`, `list`, `next`, `clear`
|
||||||
- **Technical Implementation:** Wraps the Discord Scheduled Events API. Implements a FIFO (First-In-First-Out) queue data structure for managing Voice/Stage channel speaker states. Handles automated role assignment/revocation for temporary event permissions.
|
- FIFO queue per guild
|
||||||
|
- Optional stage-speaker promotion and queue announcement dispatch
|
||||||
|
|
||||||
### The Dailies (Content Webhooks & Syndication)
|
### The Dailies (Content Syndication)
|
||||||
|
|
||||||
- **The Experience:** Automatically delivers fresh behind-the-scenes content directly from the crew's socials to dedicated server channels:
|
- Adapter-based polling pipeline
|
||||||
- `#polaroids-from-the-van`: Instagram drops.
|
- YouTube RSS adapter implemented
|
||||||
- `#outtakes`: TikTok crowd work and detours.
|
- Discord webhook dispatch with duplicate suppression
|
||||||
- `#screenings`: YouTube trailers and vlogs.
|
|
||||||
|
|
||||||
- **Technical Implementation:** Event-driven architecture utilizing incoming Discord Webhooks. Integrates external API polling (YouTube Data API, TikTok/IG endpoints) to fetch, parse, and format multimedia payloads into rich Discord Embeds.
|
### Mileage Engine
|
||||||
|
|
||||||
### Mileage & The Hidden Map (Gamified Progression)
|
- PostgreSQL-backed mileage event/user persistence
|
||||||
|
- Configurable event scoring
|
||||||
|
- Role-tier upgrades on threshold milestones
|
||||||
|
- Retry behavior for transient write failures
|
||||||
|
|
||||||
- **The Experience:** Every interaction earns users **"Mileage"**. Accumulating miles unlocks secure passwords and GPS coordinates for the hidden `/map` route on the main website, granting access to deleted scenes and exclusive scripts.
|
### Admin API + Dashboard
|
||||||
- **Technical Implementation:** Requires Discord OAuth2 integration with the main web application. Message and event telemetry are captured, scored, and stored in a database (e.g., PostgreSQL/MongoDB), continuously syncing the user's Discord state with their authenticated web session.
|
|
||||||
|
|
||||||
### The Control Room (Admin Web Interface)
|
- Express Admin API for config, queue, stats, OAuth bridge, configuration DB
|
||||||
|
- React/Vite dashboard for OAuth login + analytics views
|
||||||
|
|
||||||
- **The Experience:** A dedicated dashboard for the **"Producers"** and **"Directors"** to manage the server, schedule content drops, and view engagement without touching Discord commands.
|
### OAuth2 Bridge
|
||||||
- **Technical Implementation:** A standalone web portal (intended for `admin.openmicodyssey.com`). Exposes secure RESTful/GraphQL endpoints for bot configuration, CRON job scheduling for content drops, and data visualization for external link click-through rates.
|
|
||||||
|
- Discord OAuth2 code exchange to user profile
|
||||||
|
- Ephemeral session issuance
|
||||||
|
- Optional sync callback to openmicodyssey.com backend
|
||||||
|
|
||||||
|
### Configuration Database
|
||||||
|
|
||||||
|
- `bot_settings`, `content_schedules`, `engagement_stats` tables
|
||||||
|
- Admin CRUD endpoints for settings/schedules
|
||||||
|
- Engagement snapshot persistence from runtime events
|
||||||
|
|
||||||
|
## Implemented Modules
|
||||||
|
|
||||||
|
- `src/bot.ts`: gateway client lifecycle, command dispatch, service wiring
|
||||||
|
- `src/call-sheet.ts`: reaction role onboarding and role hierarchy guards
|
||||||
|
- `src/tour-schedule.ts`: FIFO queue, stage speaker promotion, announcements
|
||||||
|
- `src/mileage.ts`: mileage persistence, scoring, role-upgrade triggers
|
||||||
|
- `src/dailies/*`: adapter/poller/webhook syndication pipeline
|
||||||
|
- `src/admin-api.ts`: operational and configuration endpoints
|
||||||
|
- `src/oauth-bridge.ts`: Discord code exchange + openmic session sync
|
||||||
|
- `src/configuration-database.ts`: bot settings, schedules, engagement snapshots
|
||||||
|
- `admin-dashboard/src/*`: admin SPA, OAuth callback flow, analytics views
|
||||||
|
|
||||||
## Architecture Documentation (arc42)
|
## Architecture Documentation (arc42)
|
||||||
|
|
||||||
This project uses the [arc42](https://arc42.org) architecture documentation template.
|
This project uses the arc42 template. Chapters are in `docs/`.
|
||||||
All chapters are in `docs/`:
|
|
||||||
|
|
||||||
| # | Chapter | File |
|
|
||||||
| --- | ------------------------ | ---------------------------------------------------------------------------- |
|
|
||||||
| 1 | Introduction & Goals | [`docs/01_introduction_and_goals.md`](docs/01_introduction_and_goals.md) |
|
|
||||||
| 2 | Architecture Constraints | [`docs/02_architecture_constraints.md`](docs/02_architecture_constraints.md) |
|
|
||||||
| 3 | Context & Scope | [`docs/03_context_and_scope.md`](docs/03_context_and_scope.md) |
|
|
||||||
| 4 | Solution Strategy | [`docs/04_solution_strategy.md`](docs/04_solution_strategy.md) |
|
|
||||||
| 5 | Building Block View | [`docs/05_building_block_view.md`](docs/05_building_block_view.md) |
|
|
||||||
| 6 | Runtime View | [`docs/06_runtime_view.md`](docs/06_runtime_view.md) |
|
|
||||||
| 7 | Deployment View | [`docs/07_deployment_view.md`](docs/07_deployment_view.md) |
|
|
||||||
| 8 | Cross-cutting Concepts | [`docs/08_concepts.md`](docs/08_concepts.md) |
|
|
||||||
| 9 | Architecture Decisions | [`docs/09_architecture_decisions.md`](docs/09_architecture_decisions.md) |
|
|
||||||
| 10 | Quality Requirements | [`docs/10_quality_requirements.md`](docs/10_quality_requirements.md) |
|
|
||||||
| 11 | Risks & Technical Debt | [`docs/11_technical_risks.md`](docs/11_technical_risks.md) |
|
|
||||||
| 12 | Glossary | [`docs/12_glossary.md`](docs/12_glossary.md) |
|
|
||||||
|
|
||||||
## Architecture & Tech Stack
|
## Architecture & Tech Stack
|
||||||
|
|
||||||
| Layer | Choice |
|
| Layer | Choice |
|
||||||
| --------------- | -------------------- |
|
| --------------- | -------------------- |
|
||||||
| Runtime | Node.js (Discord.js) |
|
| Runtime | Node.js + TypeScript |
|
||||||
|
| Discord SDK | discord.js |
|
||||||
| Database | PostgreSQL |
|
| Database | PostgreSQL |
|
||||||
| Admin Dashboard | React |
|
| Admin API | Express |
|
||||||
|
| Admin Dashboard | React + Vite |
|
||||||
| Auth | Discord OAuth2 |
|
| Auth | Discord OAuth2 |
|
||||||
| Hosting | Coolify + Nixpacks |
|
| Hosting | Coolify + Nixpacks |
|
||||||
|
|
||||||
@@ -67,52 +79,139 @@ All chapters are in `docs/`:
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js v16+ (or Python 3.9+)
|
- Node.js 22+
|
||||||
- A Discord Developer Application with Bot Token
|
- npm 10+
|
||||||
- Access to `openmicodyssey.com` backend for OAuth syncing
|
- Discord Developer Application (bot token + OAuth2 app)
|
||||||
|
- PostgreSQL instance
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
1. Clone the repository:
|
1. Clone repository:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/your-org/open-mic-odyssey-bot.git
|
git clone https://git.allucanget.biz/allucanget/omo-bot.git
|
||||||
cd open-mic-odyssey-bot
|
cd omo-bot
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Install dependencies:
|
2. Install root dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Configure environment variables. Duplicate `.env.example` to `.env` and add your specific keys:
|
3. Configure bootstrap environment variables (`.env` from `.env.example`):
|
||||||
|
|
||||||
```env
|
```env
|
||||||
DISCORD_TOKEN=your_bot_token
|
DATABASE_URL=postgres://...
|
||||||
CLIENT_ID=your_client_id
|
CONFIG_DB_ENABLED=true
|
||||||
GUILD_ID=your_server_id
|
DISCORD_GUILD_ID=... # optional seed scope
|
||||||
DB_CONNECTION_STRING=your_db_uri
|
|
||||||
YOUTUBE_API_KEY=your_yt_key
|
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Deploy Slash Commands:
|
4. Register slash commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run deploy-commands
|
npm run register:commands
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Start the bot:
|
5. Run bot in development:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Build and run production bundle:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
npm start
|
npm start
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Admin Dashboard
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd admin-dashboard
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Dashboard environment template: `admin-dashboard/.env.example`.
|
||||||
|
|
||||||
|
### Validation Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
npm run test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Setup Commands
|
||||||
|
|
||||||
|
`DATABASE_URL` is required for each command.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:migrate
|
||||||
|
npm run db:seed
|
||||||
|
npm run db:setup
|
||||||
|
```
|
||||||
|
|
||||||
|
- `db:migrate`: applies schema migrations tracked in `schema_migrations`
|
||||||
|
- `db:seed`: inserts initial config values from environment into `bot_settings` when missing
|
||||||
|
- `db:setup`: runs migrate then seed
|
||||||
|
|
||||||
|
### Runtime Config Source
|
||||||
|
|
||||||
|
Runtime reads configuration keys from `bot_settings` table first, then falls back to environment values.
|
||||||
|
|
||||||
|
- Global scope: `guild_id = __global__`
|
||||||
|
- Guild override scope: `guild_id = DISCORD_GUILD_ID`
|
||||||
|
- Key names match previous env variable names (examples: `DISCORD_TOKEN`, `DISCORD_CLIENT_ID`, `ADMIN_API_TOKEN`)
|
||||||
|
|
||||||
|
Recommended flow:
|
||||||
|
|
||||||
|
1. Set bootstrap env (`DATABASE_URL`, optional `DISCORD_GUILD_ID`).
|
||||||
|
2. Run `npm run db:migrate`.
|
||||||
|
3. Run `npm run db:seed` once while legacy env vars are still present.
|
||||||
|
4. Remove legacy runtime vars from `.env` after confirming `bot_settings` contains required keys.
|
||||||
|
|
||||||
|
Dashboard quality check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd admin-dashboard
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Admin API Endpoints
|
||||||
|
|
||||||
|
- `GET /health`
|
||||||
|
- `GET /admin/config`
|
||||||
|
- `GET /admin/stats?guildId=...`
|
||||||
|
- `GET /admin/schedule?guildId=...`
|
||||||
|
- `POST /admin/schedule/clear`
|
||||||
|
- `POST /admin/oauth/discord/exchange`
|
||||||
|
- `GET /admin/oauth/session/:sessionId`
|
||||||
|
- `GET /admin/db/settings?guildId=...`
|
||||||
|
- `PUT /admin/db/settings`
|
||||||
|
- `GET /admin/db/schedules?guildId=...`
|
||||||
|
- `PUT /admin/db/schedules`
|
||||||
|
- `DELETE /admin/db/schedules/:guildId/:scheduleKey`
|
||||||
|
- `GET /admin/db/engagement/latest?guildId=...`
|
||||||
|
|
||||||
|
## CI/CD (Gitea Actions)
|
||||||
|
|
||||||
|
Pipeline file: `.gitea/workflows/ci-cd.yml`
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
|
||||||
|
- bot checks: `npm ci -> npm run lint -> npm run build -> npm run test`
|
||||||
|
- dashboard checks: `admin-dashboard/npm ci -> npm run lint -> npm run build`
|
||||||
|
- deploy: on push to `main`, trigger Coolify deploy webhook
|
||||||
|
|
||||||
|
Required Gitea secrets:
|
||||||
|
|
||||||
|
- `COOLIFY_DEPLOY_HOOK_URL` (required)
|
||||||
|
- `COOLIFY_DEPLOY_TOKEN` (optional bearer token)
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
For internal development only. Please refer to [CONTRIBUTING.md](CONTRIBUTING.md) for styling guidelines and PR review processes for "The Control Room" dashboard updates.
|
Internal development only. See `CONTRIBUTING.md`.
|
||||||
|
|||||||
@@ -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[]} */
|
/** @type {import("eslint").Linter.FlatConfig[]} */
|
||||||
module.exports = [
|
module.exports = [
|
||||||
{
|
{
|
||||||
ignores: ["dist/**", "node_modules/**"],
|
ignores: ["dist/**", "node_modules/**", "**/*.d.ts"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ["**/*.ts"],
|
files: ["**/*.ts"],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parser: tsParser,
|
parser: tsParser,
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
project: "./tsconfig.json",
|
project: "./tsconfig.eslint.json",
|
||||||
sourceType: "module",
|
sourceType: "module",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+1035
-6
File diff suppressed because it is too large
Load Diff
+9
-2
@@ -12,18 +12,25 @@
|
|||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"lint": "eslint . --ext .ts",
|
"lint": "eslint . --ext .ts",
|
||||||
"register:commands": "ts-node src/deploy-commands.ts",
|
"register:commands": "ts-node src/deploy-commands.ts",
|
||||||
"test": "jest --passWithNoTests"
|
"test": "jest --passWithNoTests",
|
||||||
|
"db:migrate": "ts-node src/db/cli.ts migrate",
|
||||||
|
"db:seed": "ts-node src/db/cli.ts seed",
|
||||||
|
"db:setup": "ts-node src/db/cli.ts setup"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"discord.js": "^14.26.4"
|
"discord.js": "^14.26.4",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"pg": "^8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^25.8.0",
|
"@types/node": "^25.8.0",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||||
"@typescript-eslint/parser": "^8.59.3",
|
"@typescript-eslint/parser": "^8.59.3",
|
||||||
"eslint": "^10.4.0",
|
"eslint": "^10.4.0",
|
||||||
|
|||||||
@@ -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,
|
Client,
|
||||||
Events,
|
Events,
|
||||||
GatewayIntentBits,
|
GatewayIntentBits,
|
||||||
|
GuildMember,
|
||||||
Interaction,
|
Interaction,
|
||||||
Partials,
|
Partials,
|
||||||
} from "discord.js";
|
} from "discord.js";
|
||||||
|
import { AdminApiServer } from "./admin-api";
|
||||||
|
import { registerCallSheetHandlers } from "./call-sheet";
|
||||||
import { commandMap } from "./commands";
|
import { commandMap } from "./commands";
|
||||||
|
import { ConfigurationDatabase } from "./configuration-database";
|
||||||
import { BotConfig } from "./config";
|
import { BotConfig } from "./config";
|
||||||
|
import { initDailies } from "./dailies";
|
||||||
|
import { MileageEngine } from "./mileage";
|
||||||
|
import { OAuthBridgeService } from "./oauth-bridge";
|
||||||
|
import { initTourSchedule, TourScheduleEngine } from "./tour-schedule";
|
||||||
|
|
||||||
|
async function resolveInteractionMember(
|
||||||
|
interaction: Interaction,
|
||||||
|
): Promise<GuildMember | undefined> {
|
||||||
|
if (!interaction.isChatInputCommand() || !interaction.inGuild()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!interaction.guild) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await interaction.guild.members.fetch(interaction.user.id);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function startBot(config: BotConfig): Promise<Client> {
|
export async function startBot(config: BotConfig): Promise<Client> {
|
||||||
|
const runtimeStats = {
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
commandCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mileageEngine = new MileageEngine(config.mileage);
|
||||||
|
await mileageEngine.init(config.discordGuildId);
|
||||||
|
const configurationDatabase = new ConfigurationDatabase(
|
||||||
|
config.configurationDatabase,
|
||||||
|
);
|
||||||
|
await configurationDatabase.init();
|
||||||
|
const tourSchedule: TourScheduleEngine = initTourSchedule(
|
||||||
|
config.tourSchedule,
|
||||||
|
);
|
||||||
|
const dailies = initDailies(config.dailies);
|
||||||
|
const oauthBridge = new OAuthBridgeService(
|
||||||
|
config.oauthBridge,
|
||||||
|
config.discordClientId,
|
||||||
|
);
|
||||||
|
const adminApi = new AdminApiServer(config.adminApi, {
|
||||||
|
config,
|
||||||
|
mileage: mileageEngine,
|
||||||
|
configurationDatabase,
|
||||||
|
tourSchedule,
|
||||||
|
oauthBridge,
|
||||||
|
runtimeStats,
|
||||||
|
});
|
||||||
|
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
intents: [GatewayIntentBits.Guilds],
|
intents: [
|
||||||
partials: [Partials.Channel],
|
GatewayIntentBits.Guilds,
|
||||||
|
GatewayIntentBits.GuildMembers,
|
||||||
|
GatewayIntentBits.GuildMessageReactions,
|
||||||
|
],
|
||||||
|
partials: [
|
||||||
|
Partials.Channel,
|
||||||
|
Partials.Message,
|
||||||
|
Partials.Reaction,
|
||||||
|
Partials.User,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
registerCallSheetHandlers(client, config, {
|
||||||
|
onRoleAssigned: async (member, binding) => {
|
||||||
|
await mileageEngine.awardMiles({
|
||||||
|
guildId: member.guild.id,
|
||||||
|
userId: member.id,
|
||||||
|
eventType: "reaction_role_select",
|
||||||
|
metadata: {
|
||||||
|
roleId: binding.roleId,
|
||||||
|
emoji: binding.emoji,
|
||||||
|
source: "call_sheet",
|
||||||
|
},
|
||||||
|
member,
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
client.once(Events.ClientReady, (readyClient) => {
|
client.once(Events.ClientReady, (readyClient) => {
|
||||||
@@ -34,6 +113,34 @@ export async function startBot(config: BotConfig): Promise<Client> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await command.execute(interaction);
|
await command.execute(interaction);
|
||||||
|
runtimeStats.commandCount += 1;
|
||||||
|
|
||||||
|
if (interaction.inGuild()) {
|
||||||
|
const member = await resolveInteractionMember(interaction);
|
||||||
|
const mileageResult = await mileageEngine.awardMiles({
|
||||||
|
guildId: interaction.guildId,
|
||||||
|
userId: interaction.user.id,
|
||||||
|
eventType: "command_execute",
|
||||||
|
metadata: {
|
||||||
|
commandName: interaction.commandName,
|
||||||
|
},
|
||||||
|
...(member ? { member } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (configurationDatabase.enabled) {
|
||||||
|
await configurationDatabase.insertEngagementSnapshot({
|
||||||
|
guildId: interaction.guildId,
|
||||||
|
commandCount: runtimeStats.commandCount,
|
||||||
|
queueSize: tourSchedule.getQueueLength(interaction.guildId),
|
||||||
|
mileageTotal: mileageResult.totalMiles,
|
||||||
|
activeUsers: 1,
|
||||||
|
payload: {
|
||||||
|
commandName: interaction.commandName,
|
||||||
|
source: "interaction",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error(`Command failed: ${interaction.commandName}`, error);
|
console.error(`Command failed: ${interaction.commandName}`, error);
|
||||||
|
|
||||||
@@ -52,5 +159,24 @@ export async function startBot(config: BotConfig): Promise<Client> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await client.login(config.discordToken);
|
await client.login(config.discordToken);
|
||||||
|
|
||||||
|
client.once(Events.ClientReady, () => {
|
||||||
|
dailies.start();
|
||||||
|
void adminApi.start();
|
||||||
|
|
||||||
|
process.once("SIGINT", () => {
|
||||||
|
dailies.stop();
|
||||||
|
void adminApi.stop();
|
||||||
|
void configurationDatabase.close();
|
||||||
|
void mileageEngine.close();
|
||||||
|
});
|
||||||
|
process.once("SIGTERM", () => {
|
||||||
|
dailies.stop();
|
||||||
|
void adminApi.stop();
|
||||||
|
void configurationDatabase.close();
|
||||||
|
void mileageEngine.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { pingCommand } from "./ping";
|
||||||
|
import { signUpCommand } from "./sign-up";
|
||||||
|
|
||||||
export interface ChatCommand {
|
export interface ChatCommand {
|
||||||
data: SlashCommandBuilder;
|
data: SlashCommandBuilder | SlashCommandSubcommandsOnlyBuilder;
|
||||||
execute(interaction: ChatInputCommandInteraction): Promise<void>;
|
execute(interaction: ChatInputCommandInteraction): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const commands: ChatCommand[] = [pingCommand];
|
export const commands: ChatCommand[] = [
|
||||||
|
pingCommand,
|
||||||
|
callSheetCommand,
|
||||||
|
dailiesCommand,
|
||||||
|
signUpCommand,
|
||||||
|
];
|
||||||
|
|
||||||
export const commandMap = new Map<string, ChatCommand>(
|
export const commandMap = new Map<string, ChatCommand>(
|
||||||
commands.map((command) => [command.data.name, command]),
|
commands.map((command) => [command.data.name, command]),
|
||||||
|
|||||||
@@ -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 {
|
export interface BotConfig {
|
||||||
discordToken: string;
|
discordToken: string;
|
||||||
discordClientId: string;
|
discordClientId: string;
|
||||||
discordGuildId?: string;
|
discordGuildId?: string;
|
||||||
|
callSheet: CallSheetConfig;
|
||||||
|
mileage: MileageConfig;
|
||||||
|
tourSchedule: TourScheduleConfig;
|
||||||
|
dailies: DailiesConfig;
|
||||||
|
adminApi: AdminApiConfig;
|
||||||
|
oauthBridge: OAuthBridgeConfig;
|
||||||
|
configurationDatabase: ConfigurationDatabaseConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readRequiredEnv(name: string): string {
|
export interface ReactionRoleBinding {
|
||||||
const value = process.env[name];
|
emoji: string;
|
||||||
|
roleId: string;
|
||||||
|
label?: string;
|
||||||
|
group?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallSheetConfig {
|
||||||
|
messageId?: string;
|
||||||
|
onboardedRoleId?: string;
|
||||||
|
welcomeChannelId?: string;
|
||||||
|
bindings: ReactionRoleBinding[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MileageRoleTier {
|
||||||
|
roleId: string;
|
||||||
|
minMiles: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MileageConfig {
|
||||||
|
databaseUrl?: string;
|
||||||
|
roleTiers: MileageRoleTier[];
|
||||||
|
eventScores: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TourScheduleConfig {
|
||||||
|
stageChannelId?: string;
|
||||||
|
announceChannelId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailiesWebhookTarget {
|
||||||
|
url: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailiesConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
pollIntervalMs: number;
|
||||||
|
youtubeChannelIds: string[];
|
||||||
|
webhookTargets: DailiesWebhookTarget[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminApiConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OAuthBridgeConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
clientSecret?: string;
|
||||||
|
redirectUri?: string;
|
||||||
|
sessionTtlMs: number;
|
||||||
|
openmicSyncUrl?: string;
|
||||||
|
openmicSyncToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigurationDatabaseConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
databaseUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GLOBAL_CONFIG_SCOPE = "__global__";
|
||||||
|
|
||||||
|
type ConfigReader = (name: string) => string | undefined;
|
||||||
|
|
||||||
|
function readRequiredValue(readConfig: ConfigReader, name: string): string {
|
||||||
|
const value = readConfig(name);
|
||||||
if (!value) {
|
if (!value) {
|
||||||
throw new Error(`Missing required environment variable: ${name}`);
|
throw new Error(`Missing required configuration value: ${name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadConfig(): BotConfig {
|
function parseReactionBindings(raw?: string): ReactionRoleBinding[] {
|
||||||
const discordGuildId = process.env.DISCORD_GUILD_ID;
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid JSON in CALL_SHEET_REACTION_ROLES: ${String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
throw new Error("CALL_SHEET_REACTION_ROLES must be JSON array.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.map((item, index) => {
|
||||||
|
if (
|
||||||
|
!item ||
|
||||||
|
typeof item !== "object" ||
|
||||||
|
!("emoji" in item) ||
|
||||||
|
!("roleId" in item)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`CALL_SHEET_REACTION_ROLES[${index}] must include emoji and roleId.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emoji = String(item.emoji).trim();
|
||||||
|
const roleId = String(item.roleId).trim();
|
||||||
|
const label = "label" in item ? String(item.label).trim() : undefined;
|
||||||
|
const group = "group" in item ? String(item.group).trim() : undefined;
|
||||||
|
|
||||||
|
if (!emoji || !roleId) {
|
||||||
|
throw new Error(
|
||||||
|
`CALL_SHEET_REACTION_ROLES[${index}] emoji/roleId cannot be empty.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
emoji,
|
||||||
|
roleId,
|
||||||
|
...(label ? { label } : {}),
|
||||||
|
...(group ? { group } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfigValue(value: unknown): string | undefined {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfigOverridesFromDatabase(
|
||||||
|
databaseUrl: string,
|
||||||
|
guildScope?: string,
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: databaseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scopes = [GLOBAL_CONFIG_SCOPE];
|
||||||
|
if (guildScope && guildScope !== GLOBAL_CONFIG_SCOPE) {
|
||||||
|
scopes.push(guildScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pool.query<{
|
||||||
|
guild_id: string;
|
||||||
|
setting_key: string;
|
||||||
|
setting_value: unknown;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
SELECT guild_id, setting_key, setting_value
|
||||||
|
FROM bot_settings
|
||||||
|
WHERE guild_id = ANY($1::text[])
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN guild_id = $2 THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END ASC,
|
||||||
|
updated_at ASC
|
||||||
|
`,
|
||||||
|
[scopes, guildScope ?? GLOBAL_CONFIG_SCOPE],
|
||||||
|
);
|
||||||
|
|
||||||
|
const overrides = new Map<string, string>();
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const normalized = normalizeConfigValue(row.setting_value);
|
||||||
|
if (normalized !== undefined) {
|
||||||
|
overrides.set(row.setting_key, normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return overrides;
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfig(readConfig: ConfigReader): BotConfig {
|
||||||
|
const discordGuildId = readConfig("DISCORD_GUILD_ID");
|
||||||
|
const callSheetMessageId = readConfig("CALL_SHEET_MESSAGE_ID");
|
||||||
|
const callSheetOnboardedRoleId = readConfig("CALL_SHEET_ONBOARDED_ROLE_ID");
|
||||||
|
const callSheetWelcomeChannelId = readConfig("CALL_SHEET_WELCOME_CHANNEL_ID");
|
||||||
|
const bindings = parseReactionBindings(
|
||||||
|
readConfig("CALL_SHEET_REACTION_ROLES"),
|
||||||
|
);
|
||||||
|
const mileageRoleTiers = parseMileageRoleTiers(
|
||||||
|
readConfig("MILEAGE_ROLE_TIERS"),
|
||||||
|
);
|
||||||
|
const mileageEventScores = parseMileageEventScores(
|
||||||
|
readConfig("MILEAGE_EVENT_SCORES"),
|
||||||
|
);
|
||||||
|
const databaseUrl = readConfig("DATABASE_URL");
|
||||||
|
const tourStageChannelId = readConfig("TOUR_STAGE_CHANNEL_ID");
|
||||||
|
const tourAnnounceChannelId = readConfig("TOUR_ANNOUNCE_CHANNEL_ID");
|
||||||
|
const dailiesEnabled = readConfig("DAILIES_ENABLED") === "true";
|
||||||
|
const dailiesPollIntervalMs = Number(
|
||||||
|
readConfig("DAILIES_POLL_INTERVAL_MS") ?? "300000",
|
||||||
|
);
|
||||||
|
if (Number.isNaN(dailiesPollIntervalMs) || dailiesPollIntervalMs < 10000) {
|
||||||
|
throw new Error("DAILIES_POLL_INTERVAL_MS must be number >= 10000.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailiesYouTubeChannelIds = parseStringArray(
|
||||||
|
readConfig("DAILIES_YOUTUBE_CHANNEL_IDS"),
|
||||||
|
"DAILIES_YOUTUBE_CHANNEL_IDS",
|
||||||
|
);
|
||||||
|
const dailiesWebhookTargets = parseWebhookTargets(
|
||||||
|
readConfig("DAILIES_WEBHOOK_TARGETS"),
|
||||||
|
);
|
||||||
|
const adminApiEnabled = readConfig("ADMIN_API_ENABLED") === "true";
|
||||||
|
const adminApiHost = readConfig("ADMIN_API_HOST") ?? "0.0.0.0";
|
||||||
|
const adminApiPort = Number(readConfig("ADMIN_API_PORT") ?? "8787");
|
||||||
|
if (Number.isNaN(adminApiPort) || adminApiPort <= 0 || adminApiPort > 65535) {
|
||||||
|
throw new Error("ADMIN_API_PORT must be valid TCP port.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminApiToken = readConfig("ADMIN_API_TOKEN")?.trim();
|
||||||
|
const oauthBridgeClientSecret = readConfig(
|
||||||
|
"OAUTH_BRIDGE_CLIENT_SECRET",
|
||||||
|
)?.trim();
|
||||||
|
const oauthBridgeRedirectUri = readConfig(
|
||||||
|
"OAUTH_BRIDGE_REDIRECT_URI",
|
||||||
|
)?.trim();
|
||||||
|
const oauthBridgeSessionTtlMs = Number(
|
||||||
|
readConfig("OAUTH_BRIDGE_SESSION_TTL_MS") ?? "3600000",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
Number.isNaN(oauthBridgeSessionTtlMs) ||
|
||||||
|
oauthBridgeSessionTtlMs < 60000
|
||||||
|
) {
|
||||||
|
throw new Error("OAUTH_BRIDGE_SESSION_TTL_MS must be number >= 60000.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const openmicSyncUrl = readConfig("OPENMIC_SYNC_URL")?.trim();
|
||||||
|
const openmicSyncToken = readConfig("OPENMIC_SYNC_TOKEN")?.trim();
|
||||||
|
const oauthBridgeEnabled =
|
||||||
|
Boolean(oauthBridgeClientSecret) && Boolean(oauthBridgeRedirectUri);
|
||||||
|
const configurationDatabaseEnabled =
|
||||||
|
readConfig("CONFIG_DB_ENABLED") === "false" ? false : Boolean(databaseUrl);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
discordToken: readRequiredEnv("DISCORD_TOKEN"),
|
discordToken: readRequiredValue(readConfig, "DISCORD_TOKEN"),
|
||||||
discordClientId: readRequiredEnv("DISCORD_CLIENT_ID"),
|
discordClientId: readRequiredValue(readConfig, "DISCORD_CLIENT_ID"),
|
||||||
...(discordGuildId ? { discordGuildId } : {}),
|
...(discordGuildId ? { discordGuildId } : {}),
|
||||||
|
callSheet: {
|
||||||
|
...(callSheetMessageId ? { messageId: callSheetMessageId } : {}),
|
||||||
|
...(callSheetOnboardedRoleId
|
||||||
|
? { onboardedRoleId: callSheetOnboardedRoleId }
|
||||||
|
: {}),
|
||||||
|
...(callSheetWelcomeChannelId
|
||||||
|
? { welcomeChannelId: callSheetWelcomeChannelId }
|
||||||
|
: {}),
|
||||||
|
bindings,
|
||||||
|
},
|
||||||
|
mileage: {
|
||||||
|
...(databaseUrl ? { databaseUrl } : {}),
|
||||||
|
roleTiers: mileageRoleTiers,
|
||||||
|
eventScores: mileageEventScores,
|
||||||
|
},
|
||||||
|
tourSchedule: {
|
||||||
|
...(tourStageChannelId ? { stageChannelId: tourStageChannelId } : {}),
|
||||||
|
...(tourAnnounceChannelId
|
||||||
|
? { announceChannelId: tourAnnounceChannelId }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
dailies: {
|
||||||
|
enabled: dailiesEnabled,
|
||||||
|
pollIntervalMs: dailiesPollIntervalMs,
|
||||||
|
youtubeChannelIds: dailiesYouTubeChannelIds,
|
||||||
|
webhookTargets: dailiesWebhookTargets,
|
||||||
|
},
|
||||||
|
adminApi: {
|
||||||
|
enabled: adminApiEnabled,
|
||||||
|
host: adminApiHost,
|
||||||
|
port: adminApiPort,
|
||||||
|
...(adminApiToken ? { token: adminApiToken } : {}),
|
||||||
|
},
|
||||||
|
oauthBridge: {
|
||||||
|
enabled: oauthBridgeEnabled,
|
||||||
|
...(oauthBridgeClientSecret
|
||||||
|
? { clientSecret: oauthBridgeClientSecret }
|
||||||
|
: {}),
|
||||||
|
...(oauthBridgeRedirectUri
|
||||||
|
? { redirectUri: oauthBridgeRedirectUri }
|
||||||
|
: {}),
|
||||||
|
sessionTtlMs: oauthBridgeSessionTtlMs,
|
||||||
|
...(openmicSyncUrl ? { openmicSyncUrl } : {}),
|
||||||
|
...(openmicSyncToken ? { openmicSyncToken } : {}),
|
||||||
|
},
|
||||||
|
configurationDatabase: {
|
||||||
|
enabled: configurationDatabaseEnabled,
|
||||||
|
...(databaseUrl ? { databaseUrl } : {}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseMileageRoleTiers(raw?: string): MileageRoleTier[] {
|
||||||
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
throw new Error(`Invalid JSON in MILEAGE_ROLE_TIERS: ${String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
throw new Error("MILEAGE_ROLE_TIERS must be JSON array.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.map((item, index) => {
|
||||||
|
if (
|
||||||
|
!item ||
|
||||||
|
typeof item !== "object" ||
|
||||||
|
!("roleId" in item) ||
|
||||||
|
!("minMiles" in item)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`MILEAGE_ROLE_TIERS[${index}] must include roleId and minMiles.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleId = String(item.roleId).trim();
|
||||||
|
const minMiles = Number(item.minMiles);
|
||||||
|
|
||||||
|
if (!roleId || Number.isNaN(minMiles) || minMiles < 0) {
|
||||||
|
throw new Error(
|
||||||
|
`MILEAGE_ROLE_TIERS[${index}] invalid roleId or minMiles value.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { roleId, minMiles };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMileageEventScores(raw?: string): Record<string, number> {
|
||||||
|
const defaults: Record<string, number> = {
|
||||||
|
command_execute: 10,
|
||||||
|
reaction_role_select: 25,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
throw new Error(`Invalid JSON in MILEAGE_EVENT_SCORES: ${String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
throw new Error("MILEAGE_EVENT_SCORES must be JSON object.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped: Record<string, number> = { ...defaults };
|
||||||
|
|
||||||
|
for (const [eventName, value] of Object.entries(parsed)) {
|
||||||
|
const score = Number(value);
|
||||||
|
if (Number.isNaN(score) || score < 0) {
|
||||||
|
throw new Error(
|
||||||
|
`MILEAGE_EVENT_SCORES.${eventName} must be non-negative.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
mapped[eventName] = score;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStringArray(raw?: string, envName?: string): string[] {
|
||||||
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
throw new Error(`Invalid JSON in ${envName ?? "array"}: ${String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
throw new Error(`${envName ?? "value"} must be JSON array.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
.map((item) => String(item).trim())
|
||||||
|
.filter((item) => item.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWebhookTargets(raw?: string): DailiesWebhookTarget[] {
|
||||||
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid JSON in DAILIES_WEBHOOK_TARGETS: ${String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
throw new Error("DAILIES_WEBHOOK_TARGETS must be JSON array.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.map((item, index) => {
|
||||||
|
if (!item || typeof item !== "object" || !("url" in item)) {
|
||||||
|
throw new Error(`DAILIES_WEBHOOK_TARGETS[${index}] must include url.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = String(item.url).trim();
|
||||||
|
const label = "label" in item ? String(item.label).trim() : undefined;
|
||||||
|
|
||||||
|
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||||
|
throw new Error(`DAILIES_WEBHOOK_TARGETS[${index}].url must be http(s).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
...(label ? { label } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(): BotConfig {
|
||||||
|
return buildConfig((name) => process.env[name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadRuntimeConfig(): Promise<BotConfig> {
|
||||||
|
const databaseUrl = process.env.DATABASE_URL?.trim();
|
||||||
|
const configurationDatabaseEnabled =
|
||||||
|
process.env.CONFIG_DB_ENABLED === "false" ? false : Boolean(databaseUrl);
|
||||||
|
const guildScope = process.env.DISCORD_GUILD_ID?.trim();
|
||||||
|
|
||||||
|
if (!configurationDatabaseEnabled || !databaseUrl) {
|
||||||
|
return loadConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const overrides = await loadConfigOverridesFromDatabase(
|
||||||
|
databaseUrl,
|
||||||
|
guildScope,
|
||||||
|
);
|
||||||
|
|
||||||
|
return buildConfig((name) => overrides.get(name) ?? process.env[name]);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.warn(
|
||||||
|
`Falling back to environment config. Config DB overrides unavailable: ${String(error)}`,
|
||||||
|
);
|
||||||
|
return loadConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { REST, Routes } from "discord.js";
|
||||||
import { commands } from "./commands";
|
import { commands } from "./commands";
|
||||||
import { loadConfig } from "./config";
|
import { loadRuntimeConfig } from "./config";
|
||||||
|
|
||||||
async function registerCommands(): Promise<void> {
|
async function registerCommands(): Promise<void> {
|
||||||
const config = loadConfig();
|
const config = await loadRuntimeConfig();
|
||||||
const rest = new REST({ version: "10" }).setToken(config.discordToken);
|
const rest = new REST({ version: "10" }).setToken(config.discordToken);
|
||||||
const body = commands.map((command) => command.data.toJSON());
|
const body = commands.map((command) => command.data.toJSON());
|
||||||
|
|
||||||
|
|||||||
+7
-2
@@ -1,8 +1,13 @@
|
|||||||
import { startBot } from "./bot";
|
import { startBot } from "./bot";
|
||||||
import { loadConfig } from "./config";
|
import { loadRuntimeConfig } from "./config";
|
||||||
|
import { runMigrations } from "./db/migrations";
|
||||||
|
|
||||||
export async function bootstrap(): Promise<void> {
|
export async function bootstrap(): Promise<void> {
|
||||||
const config = loadConfig();
|
if (process.env.DATABASE_URL?.trim()) {
|
||||||
|
await runMigrations();
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await loadRuntimeConfig();
|
||||||
await startBot(config);
|
await startBot(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+322
@@ -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
|
"esModuleInterop": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["dist", "node_modules"]
|
"exclude": ["dist", "node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user