- README.md: feature list, stack, project structure, deferred items - CLAUDE.md: architecture, conventions, route map, CSS variable system, EventSub subscription table, data model, custom event names - INSTRUCTIONS.md: step-by-step initial deploy guide (Twitch app registration through OBS setup and custom CSS/JS), plus git workflow, first push, GitHub Actions, branching, and update procedure - .github/workflows/ci.yml: syntax check and import check on every push Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
7.5 KiB
CLAUDE.md
Running the app
source .venv/bin/activate
uvicorn app.main:app --reload
Requires .env — copy from .env.example. Database is created automatically on startup (SQLModel.metadata.create_all).
Architecture
Twitch ──EventSub WebSocket──► app/eventsub.py ──► dispatch.py ──► ws.py ──► OBS overlay
Twitch ──IRC (tmi.js)────────────────────────────────────────────────────► OBS overlay
eventsub.pyruns as an asyncio background task started inmain.pylifespan- When it receives a notification, it calls
dispatch.dispatch_event() dispatch_event()opens its own DB session (not request-scoped), resolves matchingEventActionrows, checks cooldowns, logs toActivityLog, and callsws.manager.broadcast()ws.managerholds a list of active WebSocket connections from/ws/overlay- The overlay JS receives the broadcast and plays the action
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.
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).
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. 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.
Event matching rules:
event_type="command":event_detailmust match the command name (case-insensitive, required)event_type="bits": ifevent_detailis set, it's treated as a minimum bits threshold (bits_amount >= int(event_detail))event_type="channel_points":event_detailis the reward ID; None matches any rewardevent_type="sub"/"gift_sub":event_detailis the tier ("1000", "2000", "3000"); None matches any tierevent_type="follow"/"raid":event_detailnot used for matching
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 |
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:
| Variable | Controls |
|---|---|
--chat-left, --chat-top |
Chat box position (%) |
--chat-width, --chat-height |
Chat box size (%) |
--chat-font-size |
Chat font size (px) |
--chat-bg-opacity |
Chat message background opacity |
--chat-text-color |
Chat message text color |
--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 |
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.
Custom events dispatched to overlay JS
When the overlay WebSocket receives an execute message, it dispatches a CustomEvent on document before playing the alert:
document.dispatchEvent(new CustomEvent(`bashyoverlay:${msg.event_type}`, { detail: msg }));
Available event types: sub, gift_sub, bits, channel_points, command, follow, raid.
msg.detail contains { event_type, action_type, media_url, alert_text }.
Data model
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 |
Token refresh is handled in eventsub._refresh_token() — triggered on 401 during subscription.
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)