parent
c46f2f2fc3
commit
6c7facadce
1831 changed files with 477243 additions and 2607 deletions
224
CLAUDE.md
224
CLAUDE.md
|
|
@ -3,128 +3,214 @@
|
|||
## Running the app
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
uvicorn app.main:app --reload
|
||||
npm install
|
||||
npm run dev # development (nodemon)
|
||||
npm start # production
|
||||
```
|
||||
|
||||
Requires `.env` — copy from `.env.example`. Database is created automatically on startup (`SQLModel.metadata.create_all`).
|
||||
Requires `.env` — copy from `.env.example`. Database is created automatically on startup (better-sqlite3 runs migrations on init).
|
||||
|
||||
## Stack
|
||||
|
||||
- **Node.js** · Express · EJS templates
|
||||
- **better-sqlite3** — SQLite, synchronous, simple
|
||||
- **ws** — WebSocket server for pushing alerts to overlay clients
|
||||
- **tmi.js** — Twitch IRC, runs in the browser on the overlay page (no server-side IRC)
|
||||
- **multer** — file uploads
|
||||
- **uuid** — UUID generation
|
||||
- **dotenv** — env vars
|
||||
- **express-session** — session management (signed cookie)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Twitch ──EventSub WebSocket──► app/eventsub.py ──► dispatch.py ──► ws.py ──► OBS overlay
|
||||
Twitch ──IRC (tmi.js)────────────────────────────────────────────────────► OBS overlay
|
||||
Twitch ──EventSub WebSocket──► src/eventsub.js ──► dispatch.js ──► ws.js ──► OBS overlay
|
||||
Twitch ──IRC (tmi.js in browser) ────────────────────────────────────────► OBS overlay
|
||||
```
|
||||
|
||||
- `eventsub.py` runs as an asyncio background task started in `main.py` lifespan
|
||||
- When it receives a notification, it calls `dispatch.dispatch_event()`
|
||||
- `dispatch_event()` opens its own DB session (not request-scoped), resolves matching `EventAction` rows, checks cooldowns, logs to `ActivityLog`, and calls `ws.manager.broadcast()`
|
||||
- `ws.manager` holds a list of active WebSocket connections from `/ws/overlay`
|
||||
- `eventsub.js` opens a WebSocket to Twitch on startup, managed as a module-level singleton
|
||||
- On notification it calls `dispatch.dispatchEvent()`
|
||||
- `dispatch.dispatchEvent()` queries matching `event_actions` rows, checks cooldowns, logs to `activity_log`, calls `wss.broadcast()`
|
||||
- `wss` (ws.js) holds all active WebSocket connections from `/ws/overlay`
|
||||
- The overlay JS receives the broadcast and plays the action
|
||||
- Chat messages come directly from tmi.js running in the browser — no server relay
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
src/
|
||||
app.js — Express app setup, middleware, router mounting
|
||||
server.js — HTTP server + WebSocket server startup, EventSub init
|
||||
db.js — better-sqlite3 setup, migrations, singleton helpers
|
||||
eventsub.js — Twitch EventSub WebSocket client (module singleton)
|
||||
dispatch.js — event → action resolution, cooldowns, DB log, broadcast
|
||||
ws.js — WebSocket connection manager
|
||||
cooldowns.js — in-memory cooldown tracking
|
||||
routers/
|
||||
auth.js — /auth/* (Twitch OAuth)
|
||||
dashboard.js — / (activity log, EventSub status)
|
||||
overlay.js — /overlay/:uuid, /overlay/:uuid/preview, /ws/overlay
|
||||
actions.js — /actions CRUD
|
||||
media.js — /media upload/delete
|
||||
layout.js — /layout editor + /layout/positions
|
||||
settings.js — /settings
|
||||
views/
|
||||
layout.ejs — base shell (nav, Pico CSS)
|
||||
login.ejs
|
||||
dashboard.ejs
|
||||
overlay.ejs — OBS browser source
|
||||
overlay_preview.ejs
|
||||
layout_editor.ejs
|
||||
actions.ejs
|
||||
media.ejs
|
||||
settings.ejs
|
||||
public/
|
||||
media/ — uploaded files (gitignored)
|
||||
```
|
||||
|
||||
## Key conventions
|
||||
|
||||
**No passwords anywhere.** Auth is Twitch OAuth only. Session is a signed cookie via `SessionMiddleware`. `request.session["user_id"]` stores the DB user ID after login.
|
||||
**No passwords anywhere.** Auth is Twitch OAuth only. Session stored via `express-session` with a signed cookie. `req.session.userId` stores the DB user ID after login.
|
||||
|
||||
**Singleton tables.** `Settings` and `OverlayLayout` each have one row. `database.get_settings()` and `database.get_layout()` create the row on first call. Both return detached objects (safe to use after session close — no lazy relationships).
|
||||
**Singleton tables.** `settings` and `overlay_layout` each have one row (id=1). `db.getSettings()` and `db.getLayout()` create the row on first call if missing.
|
||||
|
||||
**Background DB sessions.** `eventsub.py` and `dispatch.py` use `with Session(engine) as session` directly — not FastAPI's `Depends(get_session)` which is request-scoped. Never pass a request-scoped session to a background task.
|
||||
**Cooldowns are in-memory.** `cooldowns.js` tracks `{ actionId: triggeredAt }`. Restarting the server resets all cooldowns. Intentional — DB cooldowns not worth the complexity.
|
||||
|
||||
**Cooldowns are in-memory.** `app/cooldowns.py` tracks `{action_id: (triggered_at, duration)}`. Restarting the server resets all cooldowns. This is intentional — keeping them in the DB is not worth the complexity for this use case.
|
||||
|
||||
**Multiple actions per event.** `dispatch_event()` loops through ALL matching `EventAction` rows for the event type and fires each one (subject to its own cooldown). There is no "first match wins" logic.
|
||||
**Multiple actions per event.** `dispatchEvent()` loops through ALL matching `event_actions` rows and fires each one (subject to its own cooldown). No "first match wins".
|
||||
|
||||
**Event matching rules:**
|
||||
- `event_type="command"`: `event_detail` must match the command name (case-insensitive, required)
|
||||
- `event_type="bits"`: if `event_detail` is set, it's treated as a minimum bits threshold (`bits_amount >= int(event_detail)`)
|
||||
- `event_type="channel_points"`: `event_detail` is the reward ID; None matches any reward
|
||||
- `event_type="sub"/"gift_sub"`: `event_detail` is the tier ("1000", "2000", "3000"); None matches any tier
|
||||
- `event_type="follow"/"raid"`: `event_detail` not used for matching
|
||||
- `event_type="command"`: `event_detail` must match the command name (case-insensitive)
|
||||
- `event_type="bits"`: if `event_detail` set, treat as minimum bits threshold
|
||||
- `event_type="channel_points"`: `event_detail` is the reward ID; null matches any reward
|
||||
- `event_type="sub"/"gift_sub"`: `event_detail` is tier ("1000", "2000", "3000"); null matches any
|
||||
- `event_type="follow"/"raid"`: `event_detail` not used
|
||||
|
||||
**UUID overlays.** `settings.overlay_id` is a UUID generated on first run. OBS browser source URL is `/overlay/:uuid`. Future multi-user support creates multiple settings rows.
|
||||
|
||||
## Route map
|
||||
|
||||
| Method | Path | File | Notes |
|
||||
|---|---|---|---|
|
||||
| GET | `/` | routers/dashboard.py | Activity log, EventSub status |
|
||||
| GET | `/auth/login` | routers/auth.py | Login page |
|
||||
| GET | `/auth/twitch` | routers/auth.py | Redirect to Twitch OAuth |
|
||||
| GET | `/auth/callback` | routers/auth.py | Exchange code, save user, start EventSub |
|
||||
| GET | `/auth/logout` | routers/auth.py | Clear session |
|
||||
| GET | `/overlay` | routers/overlay.py | Live OBS browser source |
|
||||
| GET | `/overlay/preview` | routers/overlay.py | Mock preview for layout editor |
|
||||
| WS | `/ws/overlay` | routers/overlay.py | Bidirectional: commands up, actions down |
|
||||
| GET/POST | `/actions` | routers/actions.py | List and create actions (HTMX) |
|
||||
| POST | `/actions/{id}/test` | routers/actions.py | Fire action immediately, skip cooldown |
|
||||
| POST | `/actions/{id}/toggle` | routers/actions.py | Enable/disable (HTMX) |
|
||||
| DELETE | `/actions/{id}` | routers/actions.py | Delete (HTMX) |
|
||||
| GET/POST | `/media` | routers/media.py | Upload list (HTMX) |
|
||||
| DELETE | `/media/{id}` | routers/media.py | Delete file from disk and DB |
|
||||
| GET/POST | `/layout` | routers/layout.py | Style form |
|
||||
| POST | `/layout/positions` | routers/layout.py | JSON endpoint called by drag JS |
|
||||
| GET/POST | `/settings` | routers/settings.py | Channel, gap audio, custom CSS/JS |
|
||||
| POST | `/settings/reconnect` | routers/settings.py | Restart EventSub task |
|
||||
| GET | `/` | routers/dashboard.js | Activity log, EventSub status |
|
||||
| GET | `/auth/login` | routers/auth.js | Login page |
|
||||
| GET | `/auth/twitch` | routers/auth.js | Redirect to Twitch OAuth |
|
||||
| GET | `/auth/callback` | routers/auth.js | Exchange code, save user, start EventSub |
|
||||
| GET | `/auth/logout` | routers/auth.js | Clear session |
|
||||
| GET | `/overlay/:uuid` | routers/overlay.js | Live OBS browser source |
|
||||
| GET | `/overlay/:uuid/preview` | routers/overlay.js | Mock preview for layout editor |
|
||||
| WS | `/ws/overlay` | routers/overlay.js | Bidirectional: commands up, actions down |
|
||||
| GET/POST | `/actions` | routers/actions.js | List and create actions |
|
||||
| POST | `/actions/:id/test` | routers/actions.js | Fire immediately, skip cooldown |
|
||||
| POST | `/actions/:id/toggle` | routers/actions.js | Enable/disable |
|
||||
| DELETE | `/actions/:id` | routers/actions.js | Delete |
|
||||
| GET/POST | `/media` | routers/media.js | Upload, list |
|
||||
| DELETE | `/media/:id` | routers/media.js | Delete file + DB row |
|
||||
| GET/POST | `/layout` | routers/layout.js | Style form |
|
||||
| POST | `/layout/positions` | routers/layout.js | JSON, called by drag JS |
|
||||
| GET/POST | `/settings` | routers/settings.js | Channel, gap audio, custom CSS/JS |
|
||||
| POST | `/settings/reconnect` | routers/settings.js | Restart EventSub |
|
||||
|
||||
## Overlay CSS variable system
|
||||
|
||||
All overlay positioning and styling is driven by CSS custom properties injected into `overlay.html` and `overlay_preview.html` from `OverlayLayout`:
|
||||
All overlay positioning and styling uses CSS custom properties rendered into `overlay.ejs` and `overlay_preview.ejs` from the `overlay_layout` row:
|
||||
|
||||
| Variable | Controls |
|
||||
|---|---|
|
||||
| `--chat-left`, `--chat-top` | Chat box position (%) |
|
||||
| `--chat-width`, `--chat-height` | Chat box size (%) |
|
||||
| `--chat-font-size` | Chat font size (px) |
|
||||
| `--chat-font-family` | Chat font family |
|
||||
| `--chat-bg-opacity` | Chat message background opacity |
|
||||
| `--chat-text-color` | Chat message text color |
|
||||
| `--chat-text-color` | Chat message text colour |
|
||||
| `--chat-border-radius` | Message border radius (px) |
|
||||
| `--alert-left`, `--alert-top` | Alert position (%, centered via transform) |
|
||||
| `--alert-font-size` | Alert font size (px) |
|
||||
| `--alert-bg-opacity` | Alert background opacity |
|
||||
| `--alert-text-color` | Alert text color |
|
||||
| `--alert-text-color` | Alert text colour |
|
||||
|
||||
The layout editor (`/layout`) has drag handles in the parent document overlaid on an iframe pointing to `/overlay/preview`. Since both are same-origin, the editor updates the iframe's CSS variables directly via `iframe.contentDocument.documentElement.style.setProperty(...)` for live preview. Positions are persisted via `POST /layout/positions` (JSON) on drag end.
|
||||
The layout editor (`/layout`) overlays drag handles on an iframe pointing to `/overlay/:uuid/preview`. Same-origin, so the editor sets CSS variables directly on the iframe's document for live preview. Saved via `POST /layout/positions`.
|
||||
|
||||
## Gap / new-message audio
|
||||
|
||||
Configured in `settings`. When a chat message arrives in the overlay, tmi.js checks:
|
||||
1. Is `gap_audio_enabled` true and `gap_audio_url` set?
|
||||
2. Has at least `gap_audio_min_silence` seconds passed since the last message?
|
||||
3. If yes: play the audio. Volume = linear interpolation between `gap_audio_min_volume` and `gap_audio_max_volume` based on silence duration vs `gap_audio_loud_after`.
|
||||
|
||||
All logic runs in the browser. The server only serves the config.
|
||||
|
||||
## Custom events dispatched to overlay JS
|
||||
|
||||
When the overlay WebSocket receives an `execute` message, it dispatches a `CustomEvent` on `document` before playing the alert:
|
||||
When the overlay WebSocket receives an action broadcast:
|
||||
|
||||
```javascript
|
||||
document.dispatchEvent(new CustomEvent(`bashyoverlay:${msg.event_type}`, { detail: msg }));
|
||||
```
|
||||
|
||||
Available event types: `sub`, `gift_sub`, `bits`, `channel_points`, `command`, `follow`, `raid`.
|
||||
Available: `sub`, `gift_sub`, `bits`, `channel_points`, `command`, `follow`, `raid`.
|
||||
|
||||
`msg.detail` contains `{ event_type, action_type, media_url, alert_text }`.
|
||||
`msg` shape: `{ event_type, action_type, media_url, alert_text, username }`.
|
||||
|
||||
## Data model
|
||||
## Database schema
|
||||
|
||||
```sql
|
||||
users — Twitch OAuth user (twitch_id unique)
|
||||
settings — singleton id=1: overlay_id, channel, gap audio, message_timeout, custom_css/js
|
||||
overlay_layout — singleton id=1: widget positions and styles
|
||||
media_files — uploaded sounds/videos
|
||||
event_actions — event → action mapping (media_file_id FK)
|
||||
activity_log — append-only event log
|
||||
```
|
||||
User ──────────────────────────────── Twitch OAuth user (one per login)
|
||||
Settings ──────────────────────────── Singleton: channel, gap audio, custom CSS/JS
|
||||
OverlayLayout ─────────────────────── Singleton: widget positions and styles
|
||||
MediaFile ──────────────────────────── Uploaded sound or video file
|
||||
EventAction ──── media_file (FK) ───── Event → action mapping
|
||||
ActivityLog ────────────────────────── Append-only event log
|
||||
```
|
||||
|
||||
`Claim` unique constraint on `(item_id, attendee_id)` prevents double-claiming — wait, that's IllBring. BashyOverlay has no Claim model. `EventAction` has no unique constraints; multiple rows with the same `event_type` are allowed and all fire.
|
||||
|
||||
## EventSub subscriptions
|
||||
|
||||
| Twitch event | Condition | Scope required |
|
||||
|---|---|---|
|
||||
| `channel.subscribe` v1 | `broadcaster_user_id` | `channel:read:subscriptions` |
|
||||
| `channel.subscription.message` v1 | `broadcaster_user_id` | `channel:read:subscriptions` |
|
||||
| `channel.subscription.gift` v1 | `broadcaster_user_id` | `channel:read:subscriptions` |
|
||||
| `channel.cheer` v1 | `broadcaster_user_id` | `bits:read` |
|
||||
| `channel.channel_points_custom_reward_redemption.add` v1 | `broadcaster_user_id` | `channel:read:redemptions` |
|
||||
| `channel.follow` v2 | `broadcaster_user_id` + `moderator_user_id` | `moderator:read:followers` |
|
||||
| `channel.raid` v1 | `to_broadcaster_user_id` | none |
|
||||
| Twitch event | Version | Condition | Scope |
|
||||
|---|---|---|---|
|
||||
| `channel.subscribe` | 1 | `broadcaster_user_id` | `channel:read:subscriptions` |
|
||||
| `channel.subscription.message` | 1 | `broadcaster_user_id` | `channel:read:subscriptions` |
|
||||
| `channel.subscription.gift` | 1 | `broadcaster_user_id` | `channel:read:subscriptions` |
|
||||
| `channel.cheer` | 1 | `broadcaster_user_id` | `bits:read` |
|
||||
| `channel.channel_points_custom_reward_redemption.add` | 1 | `broadcaster_user_id` | `channel:read:redemptions` |
|
||||
| `channel.follow` | 2 | `broadcaster_user_id` + `moderator_user_id` | `moderator:read:followers` |
|
||||
| `channel.raid` | 1 | `to_broadcaster_user_id` | none |
|
||||
|
||||
Token refresh is handled in `eventsub._refresh_token()` — triggered on 401 during subscription.
|
||||
Token refresh triggered on 401 during subscription.
|
||||
|
||||
## Production deployment
|
||||
|
||||
Runs behind Apache on the VPS as a reverse proxy to Node on port 3000.
|
||||
Production domain: `https://overlay.bashynx.com`
|
||||
|
||||
Minimal Apache vhost config (requires `mod_proxy`, `mod_proxy_http`, `mod_proxy_wstunnel`):
|
||||
```apache
|
||||
<VirtualHost *:443>
|
||||
ServerName overlay.bashynx.com
|
||||
|
||||
ProxyPreserveHost On
|
||||
ProxyPass /ws/overlay ws://127.0.0.1:3000/ws/overlay
|
||||
ProxyPassReverse /ws/overlay ws://127.0.0.1:3000/ws/overlay
|
||||
ProxyPass / http://127.0.0.1:3000/
|
||||
ProxyPassReverse / http://127.0.0.1:3000/
|
||||
|
||||
# SSL config here (certbot/Let's Encrypt)
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
The `/ws/overlay` must be proxied separately with `ws://` via `mod_proxy_wstunnel` — if it's not listed before the catch-all `/`, WebSocket connections will fail. Enable the required modules with:
|
||||
```bash
|
||||
sudo a2enmod proxy proxy_http proxy_wstunnel
|
||||
sudo systemctl reload apache2
|
||||
```
|
||||
|
||||
Twitch requires HTTPS for OAuth redirect URIs in production. Register both in the Twitch dev console:
|
||||
- `http://localhost:3000/auth/callback` (dev)
|
||||
- `https://overlay.bashynx.com/auth/callback` (production)
|
||||
|
||||
## Not yet implemented
|
||||
|
||||
- Alert variable interpolation (`{username}` in alert text)
|
||||
- Mod access (currently streamer-only login)
|
||||
- Blerp / HudFX integrations
|
||||
- Crypto payment notifications
|
||||
- Morning-of reminder emails (IllBring feature, not applicable here)
|
||||
- Mod access (streamer-only login for now)
|
||||
- Chat commands triggering actions
|
||||
- Stripe / PayPal / ETH payment notifications
|
||||
- Multiple overlays (multi-user; UUID foundation is in place)
|
||||
- HudFX integration
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue