Compare commits
87 Commits
feature/da
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ba480e8409 | |||
| e69d423deb | |||
| 773288faf0 | |||
| 7a18b5b402 | |||
| cfc082a6f8 | |||
| 8521510215 | |||
| 4b640861a6 | |||
| 7748e55a71 | |||
| edfe38113a | |||
| c249010cbd | |||
| 1650f9343e | |||
| 12c69f4797 | |||
| 845e74bd5e | |||
| 1c93a09c74 | |||
| 53c1966494 | |||
| 303c8430e8 | |||
| afa27271fa | |||
| 45a511b8e9 | |||
| 67ae5065f5 | |||
| 8abb76a55b | |||
| 555ba5a2d0 | |||
| c6450bc7be | |||
| 9eb614b9a3 | |||
| c52560aaae | |||
| aea75918b2 | |||
| a0877d8276 | |||
| 4b96b17368 | |||
| e74553a482 | |||
| 2fffb4318a | |||
| 08526cfa60 | |||
| 2392d21e17 | |||
| 3f2f0fcb4e | |||
| 036e9da25e | |||
| bdd8674645 | |||
| c3d77974e3 | |||
| 2f0e817e12 | |||
| fdda47da7b | |||
| 9b7c9e8671 | |||
| ab832e928f | |||
| 691c704ecc | |||
| 5806cb08e1 | |||
| e7801287c2 | |||
| d808c17ab6 | |||
| 3e604a9816 | |||
| 31f7e00538 | |||
| 4d360abd21 | |||
| 35410dec0a | |||
| 8e6bd96d44 | |||
| 4936966c32 | |||
| 63780b19b1 | |||
| ae2cfc60a0 | |||
| 6520ea0b63 | |||
| 7f22cec452 | |||
| 6b2d296774 | |||
| d1c4c931ef | |||
| d8086afab2 | |||
| b60c642fd6 | |||
| 2fe07abecf | |||
| 30b9df0ebb | |||
| c061f2ff78 | |||
| 034c8fe604 | |||
| 5dc73bd06a | |||
| 4013d3d23d | |||
| 98cda57d90 | |||
| aa9688d811 | |||
| 8bc24fc80a | |||
| 1535d90842 | |||
| 0fa46d4cca | |||
| 589ac73b25 | |||
| a120e30e03 | |||
| 65adbf1aaa | |||
| 5ff361b97c | |||
| 883439e66f | |||
| 67c0d0124b | |||
| 9d95e1fd3c | |||
| e0502f93d6 | |||
| 519cc907af | |||
| 77f0a8a395 | |||
| 7f08ad86b0 | |||
| 0e0135be55 | |||
| 6af58808ad | |||
| 6010666dcf | |||
| 43b8610aa6 | |||
| d72e27ad2e | |||
| 0c00ff7ee0 | |||
| bce05652ed | |||
| 830a16d693 |
51
CONTEXT.md
51
CONTEXT.md
@@ -25,19 +25,11 @@ My core instructions are:
|
||||
The objective is to build a multi-platform chat overlay SaaS (Software as a Service) for streamers. The service will aggregate chat from Twitch and YouTube into a single, customizable browser source for use in streaming software like OBS.
|
||||
|
||||
### Current Status
|
||||
The project is in **Phase 1: User Authentication & Database**. Most of this phase is complete.
|
||||
* A solid FastAPI application skeleton is in place.
|
||||
* The database schema (`User`, `Setting` models) is defined using SQLAlchemy and a SQLite database.
|
||||
* A secure Twitch OAuth2 authentication flow is fully functional. It correctly:
|
||||
1. Redirects users to Twitch.
|
||||
2. Handles the callback.
|
||||
3. Exchanges the authorization code for tokens.
|
||||
4. Fetches user details from the Twitch API.
|
||||
5. Encrypts the tokens using the `cryptography` library.
|
||||
6. Saves or updates the user's record in the database.
|
||||
**Phases 1, 2, and 3 are complete.** The application is now a fully functional chat overlay service for Twitch.
|
||||
* **Phase 1 (Authentication):** A secure Twitch OAuth2 flow is implemented, with user data and encrypted tokens stored in a SQLite database.
|
||||
* **Phase 2 (Dashboard & Configuration):** A dynamic user dashboard is available after login. It includes a theme switcher (light/dark), a theme selector for the overlay, and a full CRUD system for users to create and manage their own private CSS themes.
|
||||
* **Phase 3 (Real-time Chat):** A decoupled background listener manager successfully starts `twitchio` listeners for each user. A WebSocket manager broadcasts incoming chat messages to the correct user's overlay in real-time.
|
||||
* A basic HTML login page is served at the root URL (`/`).
|
||||
* Configuration and secrets are managed securely via a `config.py` file that reads from a `.env` file.
|
||||
|
||||
### Core Architecture
|
||||
The project is built on the "hybrid architecture" detailed in the `RESEARCH_REPORT.md`:
|
||||
* **Authentication:** Always use the official, secure OAuth2 flows for each platform.
|
||||
@@ -130,7 +122,7 @@ This file tracks all active development tasks. It is based on the official `DEVE
|
||||
4. When your Pull Request is *merged*, move it to "Done."
|
||||
|
||||
If you want to use emojis for visibility, here's some I have used:
|
||||
✔️ - Done | 🧑🔧 - In progress | ↗️ - Task evolved (should correspond with an edit in the [DEVELOPMENT_PLAN.md](DEVELOPMENT_PLAN.md)
|
||||
✔️ - Done | 🧑🔧 - In progress | ↗️ - Task evolved (should correspond with an edit in the [DEVELOPMENT_PLAN.md](DEVELOPMENT_PLAN.md) )
|
||||
|
||||
---
|
||||
|
||||
@@ -140,10 +132,6 @@ If you want to use emojis for visibility, here's some I have used:
|
||||
|
||||
### To Do
|
||||
|
||||
* `[ ]` **1.4: Basic Session Management:** Create a simple session/JWT system to know *who* is logged in.
|
||||
|
||||
### In Progress
|
||||
|
||||
### Done
|
||||
* `[✔️]` **1.0: Project Skeleton** - @ramforth
|
||||
* *Task:* Setup `main.py`, `requirements.txt`, and `.gitignore`.
|
||||
@@ -151,39 +139,44 @@ If you want to use emojis for visibility, here's some I have used:
|
||||
* `[✔️]` **1.1.5: Discord Overview:** Create an automated 'TASK-LIST' and post to Discord whenever someone pushes a change to the repository. @ramforth
|
||||
* `[✔️]` **1.2: Twitch OAuth API:** Create FastAPI endpoints for `/login/twitch` (redirect) and `/auth/twitch/callback` (handles token exchange). @ramforth
|
||||
* `[✔️]` **1.3: Secure Token Storage:** Implement helper functions to `encrypt` and `decrypt` OAuth tokens before storing them in the database. @ramforth
|
||||
* `[✔️]` **1.4: Basic Session Management:** Create a simple session/JWT system to know *who* is logged in. @ramforth
|
||||
* `[✔️]` **1.5: Login Frontend:** Create a basic `login.html` file with a "Login with Twitch" button. @ramforth
|
||||
---
|
||||
|
||||
## ⏳ Phase 2: User Dashboard & Configuration
|
||||
|
||||
* **Goal:** Allow logged-in users to see a dashboard, get their overlay URL, and save settings.
|
||||
* *(All tasks for this phase are on hold until Phase 1 is complete)*
|
||||
* **Goal:** Allow logged-in users to see a dashboard, get their overlay URL, and save settings. Now that Phase 1 is done, these tasks are ready to be worked on.
|
||||
|
||||
### To Do
|
||||
* `[ ]` **2.1: Dashboard UI:** Create `dashboard.html` (only for logged-in users).
|
||||
* `[ ]` **2.2: Config API:** Create API endpoints (`GET`, `POST`) for `/api/settings` to save/load user preferences (e.g., custom CSS).
|
||||
* `[ ]` **2.3: Overlay URL:** Generate and display the unique overlay URL for the user (e.g., `/overlay/{user_id}`).
|
||||
|
||||
* `[ ]` **2.4: Create Logo and Favicon:** The project should have a logo and a favicon.
|
||||
|
||||
### Done
|
||||
* `[✔️]` **2.0: CSS Refactor & Styling:** Improved the general look and feel of the application pages, including a light/dark theme switcher.
|
||||
* `[✔️]` **2.1: Dashboard UI:** Created `dashboard.html` for logged-in users to manage settings.
|
||||
* `[✔️]` **2.2: Config API:** Created API endpoints for `/api/settings` to save user preferences.
|
||||
* `[✔️]` **2.3: Overlay URL:** Generated and displayed the unique overlay URL for the user on the dashboard.
|
||||
* `[✔️]` **2.5: Custom CSS Themes:** Implemented a system for users to create, preview, and delete their own private CSS overlay themes.
|
||||
* `[✔️]` **2.6: CSS Help Page:** Created a guide for users on how to write custom CSS for their overlays.
|
||||
|
||||
---
|
||||
|
||||
## 💬 Phase 3: Dynamic Listeners & Basic Overlay
|
||||
## 💬 Phase 3: Real-time Chat & Overlay
|
||||
|
||||
* **Goal:** The core magic. Start chat listeners for users and show messages in the overlay.
|
||||
* *(All tasks for this phase are on hold until Phase 2 is complete)*
|
||||
|
||||
### To Do
|
||||
* `[ ]` **3.1: Dynamic Listener Manager (The Big One):** Design a system (e.g., background service) to start/stop listener processes for users.
|
||||
* `[ ]` **3.2: User-Specific Broadcasting:** Update the WebSocket system to use "rooms" (e.g., `/ws/{user_id}`) so users only get their *own* chat.
|
||||
* `[ ]` **3.3: Basic Overlay UI:** Create the `overlay.html` page that connects to the WebSocket and displays messages.
|
||||
|
||||
---
|
||||
### Done
|
||||
* `[✔️]` **3.1: Dynamic Listener Manager:** Designed and implemented a system to start/stop listener processes for users on application startup/shutdown.
|
||||
* `[✔️]` **3.2: User-Specific Broadcasting:** Implemented a WebSocket manager and endpoint (`/ws/{user_id}`) to broadcast messages to the correct user's overlay.
|
||||
* `[✔️]` **3.3: Basic Overlay UI:** Created dynamic overlay templates that connect to the WebSocket and display incoming chat messages.
|
||||
|
||||
## 💡 Backlog & Future Features
|
||||
|
||||
* *(Tasks from Phase 4, Gitea Issues, etc., will be added here as we go)*
|
||||
* `[ ]` Implement YouTube OAuth & `pytchat` listener (Phase 4).
|
||||
* `[ ]` "Single Message Focus" feature (Issue #1).
|
||||
* `[ ]` Moderator panels (Issue #2).
|
||||
* `[ ]` Custom CSS storage & injection (Issue #6).
|
||||
|
||||
```
|
||||
@@ -17,6 +17,8 @@ The goal is to create a service where streamers can log in using their platform
|
||||
## 3. Implementation Roadmap
|
||||
|
||||
### Phase 1: User Authentication & Database (FastAPI)
|
||||
**Status: ✔️ Complete**
|
||||
|
||||
1. **Project Skeleton:** Establish the core FastAPI application structure, dependencies, and version control.
|
||||
2. **Database Schema:** Define the data models for users and settings using SQLAlchemy.
|
||||
3. **Twitch OAuth2:** Implement the server-side OAuth2 flow within FastAPI to authenticate users and securely store encrypted tokens in the database.
|
||||
@@ -24,16 +26,25 @@ The goal is to create a service where streamers can log in using their platform
|
||||
5. **Basic Frontend:** Develop a simple login page.
|
||||
|
||||
### Phase 2: User Dashboard & Configuration
|
||||
**Status: ✔️ Complete**
|
||||
|
||||
1. **Dashboard UI:** Create a dashboard page accessible only to authenticated users.
|
||||
2. **Settings API:** Build API endpoints for users to save and retrieve their overlay settings (e.g., custom CSS).
|
||||
3. **Overlay URL Generation:** Display a unique, persistent overlay URL for each user on their dashboard.
|
||||
2. **Settings API:** Build API endpoints for users to save and retrieve their overlay settings.
|
||||
3. **Overlay URL Generation:** Display a unique, persistent overlay URL for each user.
|
||||
4. **Theming System:** Implement a site-wide light/dark theme switcher.
|
||||
5. **Custom CSS Themes:** Develop a full CRUD (Create, Read, Update, Delete) system allowing users to create, manage, and preview their own private CSS overlay themes.
|
||||
6. **Help & Documentation:** Add a help page to guide users in creating their custom CSS.
|
||||
|
||||
### Phase 3: Dynamic Listeners & Basic Overlay
|
||||
**Status: ✔️ Complete**
|
||||
|
||||
1. **Dynamic Listener Manager:** Design and build a background service that starts and stops chat listener processes (`twitchio`, `pytchat`) based on user activity.
|
||||
2. **Real-time Message Broadcasting:** Implement a WebSocket system within FastAPI to push chat messages to the correct user's overlay in real-time.
|
||||
3. **Basic Overlay UI:** Create the `overlay.html` page that connects to the WebSocket and renders incoming chat messages.
|
||||
|
||||
### Phase 4: Integration & Refinement
|
||||
**Status: ⏳ Not Started**
|
||||
|
||||
1. **YouTube Integration:** Implement the full YouTube OAuth2 flow and integrate the `pytchat` listener into the dynamic listener manager.
|
||||
2. **Advanced Overlay Customization:** Add more features for users to customize their overlay's appearance and behavior.
|
||||
3. **Twitch Chat Writeback:** Re-introduce the `chat:write` scope during authentication to allow the service (and potentially moderators, as per Issue #2) to send messages to the user's Twitch chat.
|
||||
|
||||
61
FAILED_APPROACH_6.md
Normal file
61
FAILED_APPROACH_6.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Analysis of Failed Approach #6: `twitchio` Initialization Conflict
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This document outlines the reasons for the persistent failure to initialize the `twitchio` chat listeners within our FastAPI application. Our attempts have been caught in a cyclical error pattern, switching between a `TypeError` for a missing argument and an `OSError` for a port conflict.
|
||||
|
||||
The root cause is a fundamental design conflict: we are attempting to use a high-level abstraction (`twitchio.ext.commands.Bot`) in a way it is not designed for. This class is architected as a **standalone application** that includes its own web server for handling Twitch EventSub. Our project requires a simple, "headless" IRC client component to be embedded within our existing FastAPI web server. The `commands.Bot` class is not this component, and our attempts to force it into this role have failed.
|
||||
|
||||
**Conclusion:** Continuing to patch this approach is inefficient and unreliable. A new strategy is required.
|
||||
|
||||
## 2. The Cyclical Error Pattern
|
||||
|
||||
Our efforts have resulted in a loop between three primary, contradictory errors, demonstrating the library's conflicting internal states.
|
||||
|
||||
### Error A: `OSError: [Errno 98] address already in use`
|
||||
|
||||
* **Trigger:** Occurs with a default `twitchio.ext.commands.Bot` initialization.
|
||||
* **Implication:** The library, by default, attempts to start its own `AiohttpAdapter` web server (e.g., on port 4343) for EventSub, which immediately conflicts with our main Uvicorn process or any other service.
|
||||
|
||||
### Error B: `TypeError: Bot.__init__() missing 1 required keyword-only argument: 'bot_id'`
|
||||
|
||||
* **Trigger:** Occurs when we successfully disable the internal web server using a `NullAdapter`.
|
||||
* **Implication:** By disabling the web server, we seem to place the `Bot` into a different initialization path that now strictly requires the `bot_id` argument, which it previously did not.
|
||||
|
||||
### Error C: Back to `OSError: [Errno 98] address already in use`
|
||||
|
||||
* **Trigger:** Occurs when we satisfy Error B by providing the `bot_id` while the `NullAdapter` is active.
|
||||
* **Implication:** This is the most critical failure. It demonstrates that providing the `bot_id` causes the library's constructor to **ignore our `NullAdapter`** and fall back to its default behavior of starting a web server, thus bringing us back to Error A.
|
||||
|
||||
### Error D: `TypeError: Client.start() got an unexpected keyword argument 'web_server'`
|
||||
|
||||
* **Trigger:** Occurred when we attempted to bypass the adapter system entirely and use `bot.start(web_server=False)`.
|
||||
* **Implication:** This proves the `start()` method's API does not support this parameter, closing another potential avenue for controlling the library's behavior.
|
||||
|
||||
## 3. The Homelab & Nginx Proxy Manager Conflict
|
||||
|
||||
This architectural mismatch is especially problematic in our homelab environment using Nginx Proxy Manager.
|
||||
|
||||
1. **Single Entry Point:** Our architecture is designed for a single entry point. Nginx Proxy Manager accepts all traffic on ports 80/443 and forwards it to a single backend service: our FastAPI application on port 8000.
|
||||
|
||||
2. **Unwanted Second Service:** `twitchio`'s attempt to start a second web server on a different port (e.g., 4343) is fundamentally incompatible with this model. It forces us to treat our single Python application as two distinct backend services.
|
||||
|
||||
3. **Unnecessary Complexity:** To make this work, we would have to configure Nginx Proxy Manager with complex location-based routing rules (e.g., route `/` to port 8000, but route `/eventsub` to port 4343). This is brittle, hard to maintain, and completely unnecessary for our goal, which is **IRC chat only**.
|
||||
|
||||
4. **Port Conflicts:** In a managed homelab environment (using Docker, etc.), ports are explicitly allocated resources. A library that randomly tries to bind to an arbitrary port is an unstable and unpredictable component that will inevitably conflict with other services.
|
||||
|
||||
## 4. Root Cause: Architectural Mismatch
|
||||
|
||||
The `twitchio.ext.commands.Bot` class is a powerful, feature-rich tool designed for building **standalone bots**. It is not designed to be a simple, embeddable component within a larger web application that has its own server.
|
||||
|
||||
Our application architecture requires a "headless" IRC client—a component that does nothing but connect to Twitch's chat servers and listen for messages. The `commands.Bot` class is not that component. It brings along a suite of other features, including its own web server, which we cannot reliably disable.
|
||||
|
||||
Our attempts to "trick" the library into behaving like a simple client have failed because we are fighting against its core design:
|
||||
|
||||
## 5. Recommendation: Pivot to a Low-Level Client
|
||||
|
||||
We must abandon the use of `twitchio.ext.commands.Bot`.
|
||||
|
||||
The correct path forward is to use the lower-level `twitchio.Client` class instead. This class is designed to be a more fundamental IRC client without the high-level command handling and, crucially, without the tightly coupled web server components.
|
||||
|
||||
By switching to `twitchio.Client`, we can build a `TwitchBot` class that is truly "headless" and will integrate cleanly into our existing FastAPI application and `ListenerManager` without causing port conflicts or argument mismatches. This aligns our implementation with our architectural needs.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
MultiChatOverlay is a web-based, multi-platform chat overlay service designed for streamers. The goal is to create a "SaaS" (Software as a Service) project where users can log in with their platform accounts (Twitch, YouTube, etc.) and get a single, unified, and customizable chat overlay for their stream.
|
||||
|
||||
This project is currently in **Phase 1: Initial Development**.
|
||||
This project is currently in **Phase 2: User Dashboard & Configuration**.
|
||||
|
||||
## 🚀 Project Goal
|
||||
|
||||
@@ -15,7 +15,7 @@ This project is currently in **Phase 1: Initial Development**.
|
||||
|
||||
User privacy and security are paramount. All sensitive user credentials, such as OAuth access and refresh tokens from external platforms, are **always encrypted** before being stored in the database. They are never stored in plain text, ensuring a high standard of security for user data.
|
||||
|
||||
## <EFBFBD> Technology Stack
|
||||
## 🖥️ Technology Stack
|
||||
|
||||
* **Backend:** Python 3.9+ (FastAPI)
|
||||
* **Database:** SQLite (initially, for simplicity) with SQLAlchemy
|
||||
|
||||
32
TASKS.md
32
TASKS.md
@@ -10,7 +10,7 @@ This file tracks all active development tasks. It is based on the official `DEVE
|
||||
4. When your Pull Request is *merged*, move it to "Done."
|
||||
|
||||
If you want to use emojis for visibility, here's some I have used:
|
||||
✔️ - Done | 🧑🔧 - In progress | ↗️ - Task evolved (should correspond with an edit in the [DEVELOPMENT_PLAN.md](DEVELOPMENT_PLAN.md)
|
||||
✔️ - Done | 🧑🔧 - In progress | ↗️ - Task evolved (should correspond with an edit in the [DEVELOPMENT_PLAN.md](DEVELOPMENT_PLAN.md) )
|
||||
|
||||
---
|
||||
|
||||
@@ -20,8 +20,6 @@ If you want to use emojis for visibility, here's some I have used:
|
||||
|
||||
### To Do
|
||||
|
||||
### In Progress
|
||||
|
||||
### Done
|
||||
* `[✔️]` **1.0: Project Skeleton** - @ramforth
|
||||
* *Task:* Setup `main.py`, `requirements.txt`, and `.gitignore`.
|
||||
@@ -35,33 +33,37 @@ If you want to use emojis for visibility, here's some I have used:
|
||||
|
||||
## ⏳ Phase 2: User Dashboard & Configuration
|
||||
|
||||
* **Goal:** Allow logged-in users to see a dashboard, get their overlay URL, and save settings.
|
||||
* *(All tasks for this phase are on hold until Phase 1 is complete)*
|
||||
* **Goal:** Allow logged-in users to see a dashboard, get their overlay URL, and save settings. Now that Phase 1 is done, these tasks are ready to be worked on.
|
||||
|
||||
### To Do
|
||||
* `[ ]` **2.0: CSS Refactor & Styling:** Improve the general look and feel of the application pages.
|
||||
* `[ ]` **2.1: Dashboard UI:** Create `dashboard.html` (only for logged-in users).
|
||||
* `[ ]` **2.2: Config API:** Create API endpoints (`GET`, `POST`) for `/api/settings` to save/load user preferences (e.g., custom CSS).
|
||||
* `[ ]` **2.3: Overlay URL:** Generate and display the unique overlay URL for the user (e.g., `/overlay/{user_id}`).
|
||||
|
||||
* `[ ]` **2.4: Create Logo and Favicon:** The project should have a logo and a favicon.
|
||||
|
||||
### Done
|
||||
* `[✔️]` **2.0: CSS Refactor & Styling:** Improved the general look and feel of the application pages, including a light/dark theme switcher.
|
||||
* `[✔️]` **2.1: Dashboard UI:** Created `dashboard.html` for logged-in users to manage settings.
|
||||
* `[✔️]` **2.2: Config API:** Created API endpoints for `/api/settings` to save user preferences.
|
||||
* `[✔️]` **2.3: Overlay URL:** Generated and displayed the unique overlay URL for the user on the dashboard.
|
||||
* `[✔️]` **2.5: Custom CSS Themes:** Implemented a system for users to create, preview, and delete their own private CSS overlay themes.
|
||||
* `[✔️]` **2.6: CSS Help Page:** Created a guide for users on how to write custom CSS for their overlays.
|
||||
|
||||
---
|
||||
|
||||
## 💬 Phase 3: Dynamic Listeners & Basic Overlay
|
||||
## 💬 Phase 3: Real-time Chat & Overlay
|
||||
|
||||
* **Goal:** The core magic. Start chat listeners for users and show messages in the overlay.
|
||||
* *(All tasks for this phase are on hold until Phase 2 is complete)*
|
||||
|
||||
### To Do
|
||||
* `[ ]` **3.1: Dynamic Listener Manager (The Big One):** Design a system (e.g., background service) to start/stop listener processes for users.
|
||||
* `[ ]` **3.2: User-Specific Broadcasting:** Update the WebSocket system to use "rooms" (e.g., `/ws/{user_id}`) so users only get their *own* chat.
|
||||
* `[ ]` **3.3: Basic Overlay UI:** Create the `overlay.html` page that connects to the WebSocket and displays messages.
|
||||
|
||||
---
|
||||
### Done
|
||||
* `[✔️]` **3.1: Dynamic Listener Manager:** Designed and implemented a system to start/stop listener processes for users on application startup/shutdown.
|
||||
* `[✔️]` **3.2: User-Specific Broadcasting:** Implemented a WebSocket manager and endpoint (`/ws/{user_id}`) to broadcast messages to the correct user's overlay.
|
||||
* `[✔️]` **3.3: Basic Overlay UI:** Created dynamic overlay templates that connect to the WebSocket and display incoming chat messages.
|
||||
|
||||
## 💡 Backlog & Future Features
|
||||
|
||||
* *(Tasks from Phase 4, Gitea Issues, etc., will be added here as we go)*
|
||||
* `[ ]` Implement YouTube OAuth & `pytchat` listener (Phase 4).
|
||||
* `[ ]` "Single Message Focus" feature (Issue #1).
|
||||
* `[ ]` Moderator panels (Issue #2).
|
||||
* `[ ]` Custom CSS storage & injection (Issue #6).
|
||||
12
auth.py
12
auth.py
@@ -1,5 +1,6 @@
|
||||
import httpx
|
||||
import secrets
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -11,6 +12,9 @@ import security
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Dependency to get a DB session
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
@@ -27,6 +31,7 @@ async def login_with_twitch(request: Request):
|
||||
# Generate a random state token for CSRF protection
|
||||
state = secrets.token_urlsafe(16)
|
||||
request.session['oauth_state'] = state
|
||||
logger.info(f"Generated OAuth state: {state} for session.")
|
||||
|
||||
# As per RESEARCH_REPORT.md, these are the minimum required scopes
|
||||
scopes = "chat:read"
|
||||
@@ -48,7 +53,9 @@ async def auth_twitch_callback(code: str, state: str, request: Request, db: Sess
|
||||
Step 2 of OAuth flow: Handle the callback from Twitch after user authorization.
|
||||
"""
|
||||
# CSRF Protection: Validate the state
|
||||
if state != request.session.pop('oauth_state', None):
|
||||
session_state = request.session.pop('oauth_state', None)
|
||||
if state != session_state:
|
||||
logger.error(f"OAuth state mismatch! Received state: '{state}', Session state: '{session_state}'")
|
||||
raise HTTPException(status_code=403, detail="Invalid state parameter. CSRF attack suspected.")
|
||||
|
||||
# Step 4: Exchange the authorization code for an access token
|
||||
@@ -99,6 +106,9 @@ async def auth_twitch_callback(code: str, state: str, request: Request, db: Sess
|
||||
encrypted_tokens=encrypted_tokens
|
||||
)
|
||||
db.add(user)
|
||||
# Also create a default settings object for the new user
|
||||
new_settings = models.Setting(owner=user)
|
||||
db.add(new_settings)
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
80
chat_listener.py
Normal file
80
chat_listener.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import logging
|
||||
import twitchio
|
||||
from sqlalchemy.orm import Session
|
||||
from database import SessionLocal
|
||||
import models
|
||||
import security
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class TwitchBot(twitchio.Client):
|
||||
def __init__(self, websocket_manager, db_user_id: int):
|
||||
self.websocket_manager = websocket_manager
|
||||
# Store our application's database user ID to avoid conflict with twitchio's internal 'owner_id'
|
||||
self.db_user_id = db_user_id
|
||||
self.is_initialized = False # Health check flag
|
||||
|
||||
async def start(self, access_token: str, refresh_token: str, client_id: str, client_secret: str, channel_name: str):
|
||||
"""
|
||||
A custom start method that also handles initialization. This makes the
|
||||
entire setup process an awaitable, atomic operation.
|
||||
"""
|
||||
logger.info(f"DIAGNOSTIC: Initializing and connecting for user {self.db_user_id}...")
|
||||
|
||||
# The sensitive __init__ call is now inside the awaitable task.
|
||||
# FIX: Do not pass client_secret/refresh_token to super().__init__.
|
||||
# This prevents twitchio from starting its internal web server (AiohttpAdapter) on port 4343,
|
||||
# which causes "Address already in use" errors when multiple bots run.
|
||||
super().__init__(token=access_token, client_id=client_id, initial_channels=[channel_name], ssl=True)
|
||||
self.channel_name = channel_name
|
||||
self.is_initialized = True
|
||||
|
||||
try:
|
||||
await super().start()
|
||||
except Exception as e:
|
||||
logger.error(f"Twitch connection failed for user {self.db_user_id}: {e}")
|
||||
|
||||
async def event_ready(self):
|
||||
"""Called once when the bot goes online."""
|
||||
# Diagnostic Logging: Confirming the bot is ready and joined the channel.
|
||||
logger.info(f"DIAGNOSTIC: Listener connected and ready for user_id: {self.db_user_id}, channel: #{self.channel_name}")
|
||||
|
||||
async def event_token_refresh(self, token: str, refresh_token: str):
|
||||
"""
|
||||
Called when twitchio automatically refreshes the token.
|
||||
We must save the new tokens back to our database.
|
||||
"""
|
||||
logger.info(f"DIAGNOSTIC: Token refreshed for user {self.db_user_id}. Saving new tokens to database.")
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
user = db.query(models.User).filter(models.User.id == self.db_user_id).first()
|
||||
if user:
|
||||
user.encrypted_tokens = security.encrypt_tokens(token, refresh_token)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
async def event_message(self, message): # Mandate: Type hint removed to prevent import errors.
|
||||
"""Runs every time a message is sent in chat."""
|
||||
# Diagnostic Logging: Checkpoint 1 - A raw message is received from Twitch.
|
||||
logger.info(f"DIAGNOSTIC: Message received for user {self.db_user_id} in channel {self.channel_name}: '{message.content}'")
|
||||
|
||||
# Ignore messages sent by the bot itself to prevent loops.
|
||||
if message.echo:
|
||||
return
|
||||
|
||||
# Prepare the message data to be sent to the frontend
|
||||
chat_data = {
|
||||
"author": message.author.name if message.author else "Twitch",
|
||||
"text": message.content,
|
||||
"platform": "twitch"
|
||||
}
|
||||
# Diagnostic Logging: Checkpoint 2 - The message data has been prepared for broadcasting.
|
||||
logger.info(f"DIAGNOSTIC: Prepared chat_data for user {self.db_user_id}: {chat_data}")
|
||||
|
||||
# Broadcast the message to the specific user's overlay
|
||||
# We need the user's ID to know which WebSocket connection to send to.
|
||||
user_id = self.db_user_id
|
||||
await self.websocket_manager.broadcast_to_user(user_id, chat_data)
|
||||
# Diagnostic Logging: Checkpoint 3 - The broadcast function was called.
|
||||
logger.info(f"DIAGNOSTIC: Broadcast called for user {self.db_user_id}.")
|
||||
@@ -1,27 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dashboard - MultiChatOverlay</title>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
|
||||
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Dashboard</h1>
|
||||
<p>Welcome, <strong>{{ user.username }}</strong>! You are successfully logged in.</p>
|
||||
|
||||
<div class="overlay-url-container">
|
||||
<p>Your unique overlay URL:</p>
|
||||
<code>{{ overlay_url }}</code>
|
||||
</div>
|
||||
|
||||
<p><a href="/logout">Logout</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
75
filename
Normal file
75
filename
Normal file
@@ -0,0 +1,75 @@
|
||||
import asyncio
|
||||
from typing import Dict
|
||||
|
||||
from chat_listener import TwitchBot
|
||||
import security # To decrypt tokens
|
||||
from config import settings # To get client_id and client_secret
|
||||
|
||||
class ListenerManager:
|
||||
def __init__(self):
|
||||
# This dictionary will hold our running listener tasks.
|
||||
# The key will be the user_id and the value will be the asyncio.Task.
|
||||
self.active_listeners: Dict[int, Dict] = {}
|
||||
print("ListenerManager initialized.")
|
||||
|
||||
async def start_listener_for_user(self, user, websocket_manager):
|
||||
"""
|
||||
Starts a chat listener for a given user if one isn't already running.
|
||||
"""
|
||||
if user.id in self.active_listeners:
|
||||
print(f"Listener for user {user.id} is already running.")
|
||||
return
|
||||
|
||||
# Guard Clause: Ensure the user has a valid platform ID required by twitchio.
|
||||
if not user.platform_user_id:
|
||||
print(f"ERROR: Cannot start listener for user {user.id}. Missing platform_user_id.")
|
||||
return
|
||||
|
||||
print(f"Starting listener for user {user.id} ({user.username})...")
|
||||
|
||||
try:
|
||||
tokens = security.decrypt_tokens(user.encrypted_tokens)
|
||||
access_token = tokens['access_token']
|
||||
refresh_token = tokens['refresh_token']
|
||||
|
||||
# Initialize the bot object without credentials first.
|
||||
bot = TwitchBot(
|
||||
websocket_manager=websocket_manager,
|
||||
db_user_id=user.id
|
||||
)
|
||||
|
||||
# Create a task that runs our new start method with all credentials.
|
||||
# If super().__init__ fails inside bot.start(), the exception will be
|
||||
# caught by our try/except block here.
|
||||
task = asyncio.create_task(bot.start(
|
||||
access_token=access_token, refresh_token=refresh_token,
|
||||
client_id=settings.TWITCH_CLIENT_ID, client_secret=settings.TWITCH_CLIENT_SECRET,
|
||||
channel_name=user.username
|
||||
))
|
||||
# Store both the task and the bot instance for graceful shutdown
|
||||
self.active_listeners[user.id] = {"task": task, "bot": bot}
|
||||
except Exception as e:
|
||||
# This will catch errors during bot instantiation (e.g., bad token)
|
||||
print(f"ERROR: Failed to instantiate or start listener for user {user.id}: {e}")
|
||||
|
||||
async def stop_listener_for_user(self, user_id: int):
|
||||
"""Stops a chat listener for a given user."""
|
||||
if user_id not in self.active_listeners:
|
||||
print(f"No active listener found for user {user_id}.")
|
||||
return
|
||||
|
||||
print(f"Stopping listener for user {user_id}...")
|
||||
listener_info = self.active_listeners.pop(user_id)
|
||||
task = listener_info["task"]
|
||||
bot = listener_info["bot"]
|
||||
|
||||
# Gracefully close the bot's connection
|
||||
if bot and not bot.is_closed():
|
||||
await bot.close()
|
||||
|
||||
# Cancel the asyncio task
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
print(f"Listener for user {user_id} successfully stopped.")
|
||||
79
listener_manager.py
Normal file
79
listener_manager.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
from chat_listener import TwitchBot
|
||||
import security # To decrypt tokens
|
||||
from config import settings # To get client_id and client_secret
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ListenerManager:
|
||||
def __init__(self):
|
||||
# This dictionary will hold our running listener tasks.
|
||||
# The key will be the user_id and the value will be the asyncio.Task.
|
||||
self.active_listeners: Dict[int, Dict] = {}
|
||||
logger.info("ListenerManager initialized.")
|
||||
|
||||
async def start_listener_for_user(self, user, websocket_manager):
|
||||
"""
|
||||
Starts a chat listener for a given user if one isn't already running.
|
||||
"""
|
||||
if user.id in self.active_listeners:
|
||||
logger.info(f"Listener for user {user.id} is already running.")
|
||||
return
|
||||
|
||||
# Guard Clause: Ensure the user has a valid platform ID required by twitchio.
|
||||
if not user.platform_user_id:
|
||||
logger.error(f"Cannot start listener for user {user.id}. Missing platform_user_id.")
|
||||
return
|
||||
|
||||
logger.info(f"Starting listener for user {user.id} ({user.username})...")
|
||||
|
||||
try:
|
||||
tokens = security.decrypt_tokens(user.encrypted_tokens)
|
||||
access_token = tokens['access_token']
|
||||
refresh_token = tokens['refresh_token']
|
||||
|
||||
# Initialize the bot object without credentials first. It's just a lightweight container.
|
||||
bot = TwitchBot(
|
||||
websocket_manager=websocket_manager,
|
||||
db_user_id=user.id
|
||||
)
|
||||
|
||||
# Create a task that runs our new start method with all credentials.
|
||||
# If super().__init__ fails inside bot.start(), the exception will be
|
||||
# caught by our try/except block here, preventing hollow objects.
|
||||
task = asyncio.create_task(bot.start(
|
||||
access_token=access_token, refresh_token=refresh_token,
|
||||
client_id=settings.TWITCH_CLIENT_ID, client_secret=settings.TWITCH_CLIENT_SECRET,
|
||||
channel_name=user.username
|
||||
))
|
||||
# Store both the task and the bot instance for graceful shutdown
|
||||
self.active_listeners[user.id] = {"task": task, "bot": bot}
|
||||
except Exception as e:
|
||||
# This will catch errors during bot instantiation (e.g., bad token)
|
||||
logger.error(f"Failed to instantiate or start listener for user {user.id}: {e}")
|
||||
|
||||
async def stop_listener_for_user(self, user_id: int):
|
||||
"""Stops a chat listener for a given user."""
|
||||
if user_id not in self.active_listeners:
|
||||
logger.info(f"No active listener found for user {user_id}.")
|
||||
return
|
||||
|
||||
logger.info(f"Stopping listener for user {user_id}...")
|
||||
listener_info = self.active_listeners.pop(user_id)
|
||||
task = listener_info["task"]
|
||||
bot = listener_info["bot"]
|
||||
|
||||
# Gracefully close the bot's connection
|
||||
# The getattr check prevents the shutdown crash if the bot was never initialized.
|
||||
if bot and getattr(bot, 'is_initialized', False) and not bot.is_closed():
|
||||
await bot.close()
|
||||
|
||||
# Cancel the asyncio task
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"Listener for user {user_id} successfully stopped.")
|
||||
174
main.py
174
main.py
@@ -1,15 +1,24 @@
|
||||
import os
|
||||
from fastapi import FastAPI, Request, Depends
|
||||
import logging
|
||||
import asyncio
|
||||
from fastapi import FastAPI, Request, Depends, HTTPException
|
||||
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from starlette.responses import FileResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.websockets import WebSocket
|
||||
from contextlib import asynccontextmanager
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import models
|
||||
from database import engine
|
||||
from database import engine, SessionLocal
|
||||
import auth # Import the new auth module
|
||||
import schemas
|
||||
from starlette.responses import Response
|
||||
from config import settings # Import settings to get the secret key
|
||||
from listener_manager import ListenerManager
|
||||
from websocket_manager import WebSocketManager
|
||||
|
||||
# --- Absolute Path Configuration ---
|
||||
# Get the absolute path of the directory where this file is located
|
||||
@@ -17,36 +26,71 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
STATIC_DIR = os.path.join(BASE_DIR, "static")
|
||||
TEMPLATES_DIR = os.path.join(BASE_DIR, "templates")
|
||||
|
||||
# --- Logging Configuration ---
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def background_listener_startup(app: FastAPI):
|
||||
"""A non-blocking task to start listeners after the app has started."""
|
||||
logger.info("Background task: Starting listeners for all users...")
|
||||
db = SessionLocal()
|
||||
users = db.query(models.User).all()
|
||||
db.close()
|
||||
for user in users:
|
||||
# Use try/except to ensure one failing listener doesn't stop others
|
||||
try:
|
||||
await app.state.listener_manager.start_listener_for_user(user, app.state.websocket_manager)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start listener for user {user.id} ({user.username}): {e}")
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# This code runs on startup
|
||||
print("Application startup: Creating database tables...")
|
||||
logger.info("Application startup: Creating database tables...")
|
||||
app.state.websocket_manager = WebSocketManager()
|
||||
app.state.listener_manager = ListenerManager()
|
||||
models.Base.metadata.create_all(bind=engine)
|
||||
print("Application startup: Database tables created.")
|
||||
logger.info("Application startup: Database tables created.")
|
||||
|
||||
# Decouple listener startup from the main application startup
|
||||
asyncio.create_task(background_listener_startup(app))
|
||||
yield
|
||||
# Code below yield runs on shutdown, if needed
|
||||
|
||||
# This code runs on shutdown
|
||||
logger.info("Application shutdown: Stopping all listeners...")
|
||||
manager = app.state.listener_manager
|
||||
# Create a copy of keys to avoid runtime errors from changing dict size
|
||||
for user_id in list(manager.active_listeners.keys()):
|
||||
await manager.stop_listener_for_user(user_id)
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
# Configure Jinja2 templates
|
||||
templates = Jinja2Templates(directory=TEMPLATES_DIR)
|
||||
# Add session middleware. A secret key is required for signing the session cookie.
|
||||
# We can reuse our encryption key for this, but in production you might want a separate key.
|
||||
# Note: Middleware is applied in reverse order (last added is first executed).
|
||||
# We want ProxyHeaders to run FIRST (outermost) to fix the scheme/host,
|
||||
# then SessionMiddleware to run SECOND (inner) so it sees the correct scheme.
|
||||
app.add_middleware(SessionMiddleware, secret_key=settings.ENCRYPTION_KEY)
|
||||
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
|
||||
|
||||
# Mount the 'static' directory using an absolute path for reliability
|
||||
# This MUST be done before the routes that depend on it are defined.
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
|
||||
# Add the authentication router
|
||||
app.include_router(auth.router)
|
||||
|
||||
# Add session middleware. A secret key is required for signing the session cookie.
|
||||
# We can reuse our encryption key for this, but in production you might want a separate key.
|
||||
app.add_middleware(SessionMiddleware, secret_key=settings.ENCRYPTION_KEY)
|
||||
# --- Template Dependency ---
|
||||
def get_templates():
|
||||
return Jinja2Templates(directory=TEMPLATES_DIR)
|
||||
|
||||
@app.get("/")
|
||||
async def read_root():
|
||||
return FileResponse(os.path.join(STATIC_DIR, "login.html"))
|
||||
async def read_root(request: Request, templates: Jinja2Templates = Depends(get_templates)):
|
||||
return templates.TemplateResponse("login.html", {"request": request})
|
||||
|
||||
@app.get("/dashboard")
|
||||
async def read_dashboard(request: Request, db: Session = Depends(auth.get_db)):
|
||||
async def read_dashboard(request: Request, db: Session = Depends(auth.get_db),
|
||||
templates: Jinja2Templates = Depends(get_templates)):
|
||||
# This is our protected route. It checks if a user_id exists in the session.
|
||||
user_id = request.session.get('user_id')
|
||||
if not user_id:
|
||||
@@ -56,10 +100,112 @@ async def read_dashboard(request: Request, db: Session = Depends(auth.get_db)):
|
||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||
overlay_url = f"{settings.APP_BASE_URL}/overlay/{user.id}"
|
||||
|
||||
return templates.TemplateResponse("dashboard.html", {"request": request, "user": user, "overlay_url": overlay_url})
|
||||
# Ensure user has settings, create if they don't for some reason
|
||||
if not user.settings:
|
||||
user.settings = models.Setting()
|
||||
db.commit()
|
||||
|
||||
return templates.TemplateResponse("dashboard.html", {
|
||||
"request": request,
|
||||
"user": user,
|
||||
"overlay_url": overlay_url,
|
||||
"current_theme": user.settings.overlay_theme,
|
||||
"settings": settings,
|
||||
"custom_themes": user.custom_themes
|
||||
})
|
||||
|
||||
@app.get("/logout")
|
||||
async def logout(request: Request):
|
||||
# Clear the session cookie
|
||||
request.session.clear()
|
||||
return RedirectResponse(url="/")
|
||||
|
||||
@app.get("/help/css")
|
||||
async def css_help(request: Request, templates: Jinja2Templates = Depends(get_templates)):
|
||||
return templates.TemplateResponse("help_css.html", {"request": request})
|
||||
|
||||
@app.get("/overlay/{user_id}")
|
||||
async def read_overlay(request: Request, user_id: int, theme_override: str = None,
|
||||
db: Session = Depends(auth.get_db), templates: Jinja2Templates = Depends(get_templates)):
|
||||
# This endpoint serves the overlay page.
|
||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
theme_name = "dark-purple" # Default theme
|
||||
if theme_override:
|
||||
theme_name = theme_override
|
||||
elif user.settings and user.settings.overlay_theme:
|
||||
theme_name = user.settings.overlay_theme
|
||||
|
||||
# Check if it's a custom theme
|
||||
if theme_name.startswith("custom-"):
|
||||
theme_id = int(theme_name.split("-")[1])
|
||||
theme = db.query(models.CustomTheme).filter(models.CustomTheme.id == theme_id, models.CustomTheme.owner_id == user.id).first()
|
||||
if not theme:
|
||||
raise HTTPException(status_code=404, detail="Custom theme not found")
|
||||
# Use a generic overlay template that will link to the dynamic CSS
|
||||
return templates.TemplateResponse("overlay-custom.html", {"request": request, "theme_id": theme.id})
|
||||
|
||||
return templates.TemplateResponse(f"overlay-{theme_name}.html", {"request": request})
|
||||
|
||||
@app.post("/api/settings")
|
||||
async def update_settings(settings_data: schemas.SettingsUpdate, request: Request, db: Session = Depends(auth.get_db)):
|
||||
user_id = request.session.get('user_id')
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||
if not user.settings:
|
||||
user.settings = models.Setting()
|
||||
|
||||
user.settings.overlay_theme = settings_data.overlay_theme
|
||||
db.commit()
|
||||
|
||||
return {"message": "Settings updated successfully"}
|
||||
|
||||
@app.post("/api/themes", response_model=schemas.CustomTheme)
|
||||
async def create_theme(theme_data: schemas.CustomThemeCreate, request: Request, db: Session = Depends(auth.get_db)):
|
||||
user_id = request.session.get('user_id')
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
new_theme = models.CustomTheme(**theme_data.dict(), owner_id=user_id)
|
||||
db.add(new_theme)
|
||||
db.commit()
|
||||
db.refresh(new_theme)
|
||||
return new_theme
|
||||
|
||||
@app.delete("/api/themes/{theme_id}")
|
||||
async def delete_theme(theme_id: int, request: Request, db: Session = Depends(auth.get_db)):
|
||||
user_id = request.session.get('user_id')
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
theme = db.query(models.CustomTheme).filter(models.CustomTheme.id == theme_id, models.CustomTheme.owner_id == user_id).first()
|
||||
if not theme:
|
||||
raise HTTPException(status_code=404, detail="Theme not found")
|
||||
|
||||
db.delete(theme)
|
||||
db.commit()
|
||||
return {"message": "Theme deleted successfully"}
|
||||
|
||||
@app.get("/css/custom/{theme_id}")
|
||||
async def get_custom_css(theme_id: int, db: Session = Depends(auth.get_db)):
|
||||
theme = db.query(models.CustomTheme).filter(models.CustomTheme.id == theme_id).first()
|
||||
if not theme:
|
||||
raise HTTPException(status_code=404, detail="Custom theme not found")
|
||||
|
||||
return Response(content=theme.css_content, media_type="text/css")
|
||||
|
||||
@app.websocket("/ws/{user_id}")
|
||||
async def websocket_endpoint(websocket: WebSocket, user_id: int):
|
||||
manager = websocket.app.state.websocket_manager
|
||||
await manager.connect(user_id, websocket)
|
||||
try:
|
||||
while True:
|
||||
# Keep the connection alive
|
||||
await websocket.receive_text()
|
||||
except Exception:
|
||||
manager.disconnect(user_id, websocket)
|
||||
logger.info(f"WebSocket for user {user_id} disconnected.")
|
||||
12
models.py
12
models.py
@@ -16,12 +16,24 @@ class User(Base):
|
||||
encrypted_tokens = Column(Text, nullable=False)
|
||||
|
||||
settings = relationship("Setting", back_populates="owner", uselist=False)
|
||||
custom_themes = relationship("CustomTheme", back_populates="owner")
|
||||
|
||||
class Setting(Base):
|
||||
__tablename__ = "settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
custom_css = Column(Text, nullable=True)
|
||||
overlay_theme = Column(String, default="dark-purple")
|
||||
user_id = Column(Integer, ForeignKey("users.id"))
|
||||
|
||||
owner = relationship("User", back_populates="settings")
|
||||
|
||||
class CustomTheme(Base):
|
||||
__tablename__ = "custom_themes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
css_content = Column(Text, nullable=False)
|
||||
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
owner = relationship("User", back_populates="custom_themes")
|
||||
@@ -6,3 +6,6 @@ cryptography
|
||||
python-dotenv
|
||||
itsdangerous
|
||||
jinja2
|
||||
pydantic
|
||||
python-jose[cryptography]
|
||||
twitchio==3.1.0
|
||||
19
schemas.py
Normal file
19
schemas.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
overlay_theme: str
|
||||
|
||||
class CustomThemeBase(BaseModel):
|
||||
name: str
|
||||
css_content: str
|
||||
|
||||
class CustomThemeCreate(CustomThemeBase):
|
||||
pass
|
||||
|
||||
class CustomTheme(CustomThemeBase):
|
||||
id: int
|
||||
owner_id: int
|
||||
|
||||
class Config:
|
||||
# This allows the Pydantic model to be created from a SQLAlchemy ORM object
|
||||
from_attributes = True
|
||||
290
static/css/main.css
Normal file
290
static/css/main.css
Normal file
@@ -0,0 +1,290 @@
|
||||
/* --- Theme Variables and CSS Reset --- */
|
||||
|
||||
:root,
|
||||
[data-theme="dark"] {
|
||||
--background-color: #1a1a2e;
|
||||
--surface-color: #16213e;
|
||||
--text-color: #e0e0e0;
|
||||
--text-muted-color: #a0a0a0;
|
||||
--text-inverted-color: #ffffff;
|
||||
--border-color: #4f4f7a;
|
||||
--primary-color: #7f5af0;
|
||||
--primary-hover-color: #6a48d7;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--background-color: #f0f2f5;
|
||||
--surface-color: #ffffff;
|
||||
--text-color: #1c1e21;
|
||||
--text-muted-color: #606770;
|
||||
--text-inverted-color: #ffffff;
|
||||
--border-color: #dddfe2;
|
||||
--primary-color: #7f5af0;
|
||||
--primary-hover-color: #6a48d7;
|
||||
}
|
||||
|
||||
/* Box-sizing reset */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Reset margins and paddings on most elements */
|
||||
body,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p,
|
||||
figure,
|
||||
blockquote,
|
||||
dl,
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Basic body styling */
|
||||
body {
|
||||
min-height: 100vh;
|
||||
line-height: 1.5;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
/* Make images responsive */
|
||||
img,
|
||||
picture {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* General Container for login and dashboard */
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Login Page Styles */
|
||||
.login-container {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
margin-top: 5vh;
|
||||
padding: 2.5rem;
|
||||
background-color: var(--surface-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-box h1 {
|
||||
color: var(--text-color);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.login-box p {
|
||||
margin-bottom: 2rem;
|
||||
color: var(--text-muted-color);
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted-color);
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--background-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-inverted-color);
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-hover-color);
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.login-footer a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.login-footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Header Styles */
|
||||
.logo h1 {
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.logo {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
width: 100%;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-inverted-color);
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background-color: var(--primary-hover-color);
|
||||
}
|
||||
|
||||
.theme-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.theme-btn:hover {
|
||||
background-color: var(--surface-color);
|
||||
}
|
||||
[data-theme="light"] .moon-icon { display: block; }
|
||||
[data-theme="light"] .sun-icon { display: none; }
|
||||
[data-theme="dark"] .moon-icon { display: none; }
|
||||
[data-theme="dark"] .sun-icon { display: block; }
|
||||
|
||||
/* Dashboard Card Styles */
|
||||
.card {
|
||||
background-color: var(--surface-color);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.card h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.card p {
|
||||
margin-top: 0;
|
||||
color: var(--text-muted-color);
|
||||
}
|
||||
|
||||
/* Custom Theme Form Styles */
|
||||
.theme-form {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.custom-theme-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.5rem;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
.delete-theme-btn {
|
||||
background-color: #e43f5a;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.3rem 0.8rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Styles for help page code examples */
|
||||
pre {
|
||||
background-color: var(--background-color);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-family: monospace;
|
||||
}
|
||||
12
static/css/overlay-base.css
Normal file
12
static/css/overlay-base.css
Normal file
@@ -0,0 +1,12 @@
|
||||
body {
|
||||
background-color: transparent;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
padding: 10px;
|
||||
height: 100vh;
|
||||
}
|
||||
28
static/css/overlay-bright-green.css
Normal file
28
static/css/overlay-bright-green.css
Normal file
@@ -0,0 +1,28 @@
|
||||
body {
|
||||
background-color: transparent;
|
||||
color: #f0f0f0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
text-shadow: 0 0 5px rgba(57, 255, 20, 0.5);
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
padding: 6px 10px;
|
||||
background-color: rgba(10, 30, 10, 0.6);
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid #39FF14; /* Neon green accent */
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: 800;
|
||||
color: #39FF14; /* Neon green */
|
||||
}
|
||||
28
static/css/overlay-dark-purple.css
Normal file
28
static/css/overlay-dark-purple.css
Normal file
@@ -0,0 +1,28 @@
|
||||
body {
|
||||
background-color: transparent;
|
||||
color: #e0e0e0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
padding: 6px 10px;
|
||||
background-color: rgba(26, 26, 46, 0.7); /* Dark blue/purple transparent background */
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(127, 90, 240, 0.3);
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: 800;
|
||||
color: #a970ff; /* Twitch-like purple */
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dashboard - MultiChatOverlay</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Dashboard</h1>
|
||||
<p>Welcome! You are successfully logged in.</p>
|
||||
<p><a href="/logout">Logout</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
BIN
static/gitea-svgrepo-com.png
Normal file
BIN
static/gitea-svgrepo-com.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
26
static/overlay-bright-green.css
Normal file
26
static/overlay-bright-green.css
Normal file
@@ -0,0 +1,26 @@
|
||||
body {
|
||||
background-color: transparent;
|
||||
color: #1a1a1a; /* Dark text for readability on light backgrounds */
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 18px;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
font-weight: 600;
|
||||
-webkit-text-stroke: 0.5px white; /* White outline for text */
|
||||
text-shadow: 1px 1px 2px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.chat-message {
|
||||
padding: 5px 10px;
|
||||
background-color: rgba(240, 240, 240, 0.5); /* Semi-transparent light background */
|
||||
border-radius: 5px;
|
||||
border-left: 3px solid #00ff7f; /* Spring green accent */
|
||||
}
|
||||
.username {
|
||||
font-weight: 800;
|
||||
color: #008000; /* Dark green for usernames */
|
||||
}
|
||||
24
static/overlay-dark-purple.css
Normal file
24
static/overlay-dark-purple.css
Normal file
@@ -0,0 +1,24 @@
|
||||
body {
|
||||
background-color: transparent;
|
||||
color: #e6edf3; /* Light text for dark backgrounds */
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
.chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.chat-message {
|
||||
padding: 4px 8px;
|
||||
background-color: rgba(22, 27, 34, 0.6); /* Semi-transparent dark background */
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #9146FF; /* Twitch purple accent */
|
||||
}
|
||||
.username {
|
||||
font-weight: 800;
|
||||
color: #a970ff; /* Lighter purple for usernames */
|
||||
}
|
||||
55
templates/base.html
Normal file
55
templates/base.html
Normal file
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}MultiChat Overlay{% endblock %}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/main.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header class="main-header">
|
||||
<a href="/" class="logo"><h1>MultiChat Overlay</h1></a>
|
||||
<div class="header-actions">
|
||||
<button id="theme-toggle" class="theme-btn" title="Toggle theme">
|
||||
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.64 5.64c-.39-.39-1.02-.39-1.41 0-.39.39-.39 1.02 0 1.41l1.06 1.06c.39.39 1.02.39 1.41 0s.39-1.02 0-1.41L5.64 5.64zm12.72 12.72c-.39-.39-1.02-.39-1.41 0-.39.39-.39 1.02 0 1.41l1.06 1.06c.39.39 1.02.39 1.41 0 .39-.39.39-1.02 0-1.41l-1.06-1.06zM5.64 18.36l-1.06-1.06c-.39-.39-.39-1.02 0-1.41s1.02-.39 1.41 0l1.06 1.06c.39.39.39 1.02 0 1.41s-1.02.39-1.41 0zm12.72-12.72l-1.06-1.06c-.39-.39-.39-1.02 0-1.41s1.02-.39 1.41 0l1.06 1.06c.39.39.39 1.02 0 1.41-.39.39-1.02.39-1.41 0z"></path></svg>
|
||||
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M9.37 5.51A7.35 7.35 0 0 0 9 6c0 4.42 3.58 8 8 8 .36 0 .72-.02 1.08-.06A7.5 7.5 0 0 1 9.37 5.51zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10A9.96 9.96 0 0 0 12 2z"></path></svg>
|
||||
</button>
|
||||
{% if user %}
|
||||
<a href="/logout" class="logout-btn">Logout</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const getTheme = () => {
|
||||
const storedTheme = localStorage.getItem('theme');
|
||||
if (storedTheme) return storedTheme;
|
||||
// For new visitors, default to dark mode regardless of their OS setting.
|
||||
return 'dark';
|
||||
};
|
||||
|
||||
const setTheme = (theme) => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
};
|
||||
|
||||
themeToggle.addEventListener('click', () => {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme');
|
||||
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||
setTheme(newTheme);
|
||||
});
|
||||
|
||||
// Set initial theme on page load
|
||||
setTheme(getTheme());
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
240
templates/dashboard.html
Normal file
240
templates/dashboard.html
Normal file
@@ -0,0 +1,240 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard - {{ super() }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<h2>Welcome, {{ user.username }}!</h2>
|
||||
<p>This is your personal dashboard. Here you can manage your overlay settings and find your unique URL.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Your Overlay URL</h2>
|
||||
<p>Copy this URL and add it as a "Browser Source" in your streaming software (e.g., OBS, Streamlabs).</p>
|
||||
<div class="url-box">
|
||||
<input type="text" id="overlayUrl" value="{{ overlay_url }}" readonly>
|
||||
<button id="copyButton" class="btn">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Overlay Theme</h2>
|
||||
<p>Choose a theme for your chat overlay. Your selection will be saved automatically.</p>
|
||||
<div class="theme-selector-container">
|
||||
<div class="theme-options">
|
||||
<label for="theme-select">Select a theme:</label>
|
||||
<select id="theme-select" name="theme">
|
||||
<option value="dark-purple" {% if current_theme == 'dark-purple' %}selected{% endif %}>Dark Purple</option>
|
||||
<option value="bright-green" {% if current_theme == 'bright-green' %}selected{% endif %}>Bright Green</option>
|
||||
<option value="minimal-light" {% if current_theme == 'minimal-light' %}selected{% endif %}>Minimal Light</option>
|
||||
<option value="hacker-green" {% if current_theme == 'hacker-green' %}selected{% endif %}>Hacker Green</option>
|
||||
{% if custom_themes %}
|
||||
<optgroup label="Your Themes">
|
||||
{% for theme in custom_themes %}
|
||||
<option value="custom-{{ theme.id }}" {% if current_theme == 'custom-' ~ theme.id %}selected{% endif %}>{{ theme.name }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="theme-preview">
|
||||
<h3>Preview</h3>
|
||||
<iframe id="theme-preview-frame" src="" frameborder="0" scrolling="no"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>
|
||||
Custom Themes
|
||||
<a href="/help/css" target="_blank" class="help-link" title="Open CSS guide in new window">(?)</a>
|
||||
</h2>
|
||||
<p>Create your own themes with CSS. These are private to your account.</p>
|
||||
|
||||
<div id="custom-themes-list">
|
||||
{% for theme in custom_themes %}
|
||||
<div class="custom-theme-item" id="theme-item-{{ theme.id }}">
|
||||
<span>{{ theme.name }}</span>
|
||||
<button class="delete-theme-btn" data-theme-id="{{ theme.id }}">Delete</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<form id="theme-form" class="theme-form">
|
||||
<h3>Create New Theme</h3>
|
||||
<div class="form-group">
|
||||
<label for="theme-name">Theme Name</label>
|
||||
<input type="text" id="theme-name" name="name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="theme-css">CSS Content</label>
|
||||
<textarea id="theme-css" name="css_content" rows="8" required placeholder="body { color: red; }"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Save Theme</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.help-link {
|
||||
font-size: 0.9rem;
|
||||
vertical-align: middle;
|
||||
text-decoration: none;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.url-box {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.url-box input {
|
||||
flex-grow: 1;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
border-radius: 6px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.theme-selector-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
align-items: start;
|
||||
}
|
||||
.theme-options select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--surface-color);
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
.theme-preview {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
.theme-preview h3 {
|
||||
margin: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
#theme-preview-frame {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// --- Copy URL Logic ---
|
||||
const copyButton = document.getElementById('copyButton');
|
||||
const overlayUrlInput = document.getElementById('overlayUrl');
|
||||
copyButton.addEventListener('click', () => {
|
||||
overlayUrlInput.select();
|
||||
document.execCommand('copy');
|
||||
copyButton.textContent = 'Copied!';
|
||||
setTimeout(() => { copyButton.textContent = 'Copy'; }, 2000);
|
||||
});
|
||||
|
||||
// --- Theme Selector Logic ---
|
||||
const themeSelect = document.getElementById('theme-select');
|
||||
const previewFrame = document.getElementById('theme-preview-frame');
|
||||
const baseUrl = "{{ settings.APP_BASE_URL }}";
|
||||
const userId = "{{ user.id }}";
|
||||
|
||||
// Function to update preview and save settings
|
||||
function updateTheme(selectedTheme) {
|
||||
// Update iframe preview
|
||||
previewFrame.src = `${baseUrl}/overlay/${userId}?theme_override=${selectedTheme}`;
|
||||
|
||||
// Save the setting to the backend
|
||||
fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ overlay_theme: selectedTheme }),
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log('Settings saved:', data.message);
|
||||
})
|
||||
.catch(error => console.error('Error saving settings:', error));
|
||||
}
|
||||
|
||||
// Event listener for dropdown change
|
||||
themeSelect.addEventListener('change', (event) => {
|
||||
updateTheme(event.target.value);
|
||||
});
|
||||
|
||||
// Set initial preview on page load
|
||||
updateTheme(themeSelect.value);
|
||||
|
||||
// --- Custom Theme Creation Logic ---
|
||||
const themeForm = document.getElementById('theme-form');
|
||||
themeForm.addEventListener('submit', function(event) {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(themeForm);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
fetch('/api/themes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(newTheme => {
|
||||
// Add new theme to the list and dropdown without reloading
|
||||
addThemeToList(newTheme);
|
||||
addThemeToSelect(newTheme);
|
||||
themeForm.reset(); // Clear the form
|
||||
})
|
||||
.catch(error => console.error('Error creating theme:', error));
|
||||
});
|
||||
|
||||
// --- Custom Theme Deletion Logic ---
|
||||
const themesListContainer = document.getElementById('custom-themes-list');
|
||||
themesListContainer.addEventListener('click', function(event) {
|
||||
if (event.target.classList.contains('delete-theme-btn')) {
|
||||
const themeId = event.target.dataset.themeId;
|
||||
if (confirm('Are you sure you want to delete this theme?')) {
|
||||
fetch(`/api/themes/${themeId}`, { method: 'DELETE' })
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
// Remove theme from the list and dropdown
|
||||
document.getElementById(`theme-item-${themeId}`).remove();
|
||||
document.querySelector(`#theme-select option[value="custom-${themeId}"]`).remove();
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error deleting theme:', error));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function addThemeToList(theme) {
|
||||
const list = document.getElementById('custom-themes-list');
|
||||
const item = document.createElement('div');
|
||||
item.className = 'custom-theme-item';
|
||||
item.id = `theme-item-${theme.id}`;
|
||||
item.innerHTML = `<span>${theme.name}</span><button class="delete-theme-btn" data-theme-id="${theme.id}">Delete</button>`;
|
||||
list.appendChild(item);
|
||||
}
|
||||
|
||||
function addThemeToSelect(theme) {
|
||||
let optgroup = document.querySelector('#theme-select optgroup[label="Your Themes"]');
|
||||
if (!optgroup) {
|
||||
optgroup = document.createElement('optgroup');
|
||||
optgroup.label = 'Your Themes';
|
||||
themeSelect.appendChild(optgroup);
|
||||
}
|
||||
const option = document.createElement('option');
|
||||
option.value = `custom-${theme.id}`;
|
||||
option.textContent = theme.name;
|
||||
optgroup.appendChild(option);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
46
templates/help_css.html
Normal file
46
templates/help_css.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}CSS Overlay Help - {{ super() }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<h2>Custom Overlay CSS Guide</h2>
|
||||
<p>This guide will help you create your own custom CSS for the chat overlay. Your CSS is applied on top of a base stylesheet, so you only need to override the styles you want to change.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>HTML Structure</h3>
|
||||
<p>Your CSS will be applied to a simple HTML structure. Here are the key classes you can target:</p>
|
||||
<ul>
|
||||
<li><code>.chat-container</code>: The main container holding all messages.</li>
|
||||
<li><code>.chat-message</code>: The container for a single message, including the username and text.</li>
|
||||
<li><code>.username</code>: The part of the message that shows the chatter's name.</li>
|
||||
<li><code>.message-text</code>: The actual content of the chat message.</li>
|
||||
</ul>
|
||||
<p>The <code>body</code> of the overlay is transparent by default, so you only need to style the message elements.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Example: "Bubbly" Theme</h3>
|
||||
<p>Here is a simple example to get you started. This creates chat bubbles with a gradient background.</p>
|
||||
<pre><code>body {
|
||||
font-family: 'Comic Sans MS', cursive, sans-serif;
|
||||
font-size: 18px;
|
||||
text-shadow: 1px 1px 2px #333;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
background: linear-gradient(135deg, #7f5af0, #a970ff);
|
||||
color: white;
|
||||
padding: 10px 15px;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: bold;
|
||||
color: #e0e0e0;
|
||||
}</code></pre>
|
||||
</div>
|
||||
{% endblock %}
|
||||
16
templates/login.html
Normal file
16
templates/login.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login - MultiChat Overlay{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="login-container">
|
||||
<div class="login-box">
|
||||
<h1>MultiChat Overlay</h1>
|
||||
<p>Connect your streaming accounts to get started.</p>
|
||||
<a href="/login/twitch" class="btn-primary twitch-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style="margin-right: 10px;"><path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0h1.714v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0H6zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714v9.429z"></path></svg>
|
||||
Login with Twitch
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
0
templates/overlay-bright-green.css
Normal file
0
templates/overlay-bright-green.css
Normal file
18
templates/overlay-bright-green.html
Normal file
18
templates/overlay-bright-green.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Chat Overlay - Bright Green</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/overlay-bright-green.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="chat-container">
|
||||
<div class="chat-message"><span class="username">Streamer:</span> This is the bright green theme!</div>
|
||||
<div class="chat-message"><span class="username">VerdantViewer:</span> Looks so fresh and clean!</div>
|
||||
<div class="chat-message"><span class="username">NightBot:</span> Welcome to the channel!</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
15
templates/overlay-custom.html
Normal file
15
templates/overlay-custom.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Custom Chat Overlay</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/overlay-base.css') }}">
|
||||
<link rel="stylesheet" href="/css/custom/{{ theme_id }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="chat-container">
|
||||
<!-- Chat messages will be injected here by the WebSocket client -->
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
0
templates/overlay-dark-purple.css
Normal file
0
templates/overlay-dark-purple.css
Normal file
18
templates/overlay-dark-purple.html
Normal file
18
templates/overlay-dark-purple.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Chat Overlay - Dark Purple</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/overlay-dark-purple.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="chat-container">
|
||||
<div class="chat-message"><span class="username">Streamer:</span> This is the dark purple theme!</div>
|
||||
<div class="chat-message"><span class="username">PurpleHaze:</span> Very cool, feels like Twitch!</div>
|
||||
<div class="chat-message"><span class="username">NightBot:</span> Welcome to the channel!</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
50
templates/overlay-hacker-green.html
Normal file
50
templates/overlay-hacker-green.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hacker Green Overlay</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
background-color: transparent;
|
||||
color: #0f0;
|
||||
font-family: 'VT323', monospace;
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
text-shadow: 0 0 5px #0f0;
|
||||
}
|
||||
.chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.username {
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="chat-container" class="chat-container">
|
||||
<!-- Messages will be injected here -->
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const userId = window.location.pathname.split('/')[2];
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${wsProtocol}//${window.location.host}/ws/${userId}`);
|
||||
|
||||
ws.onmessage = function(event) {
|
||||
const messageData = JSON.parse(event.data);
|
||||
const chatContainer = document.getElementById('chat-container');
|
||||
const messageElement = document.createElement('div');
|
||||
messageElement.innerHTML = `<span class="username">${messageData.author}:</span> ${messageData.text}`;
|
||||
chatContainer.appendChild(messageElement);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
38
templates/overlay-minimal-light.html
Normal file
38
templates/overlay-minimal-light.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Minimal Light Overlay</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap');
|
||||
body {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: #111827;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chat-message {
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
backdrop-filter: blur(4px);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
}
|
||||
.username {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Example Message -->
|
||||
<div class="chat-message">
|
||||
<span class="username">User:</span>
|
||||
<span class="message">This is the minimal light theme.</span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
42
templates/overlay.html
Normal file
42
templates/overlay.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Chat Overlay</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
background-color: transparent;
|
||||
color: white;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.chat-message {
|
||||
padding: 4px 8px;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.username {
|
||||
font-weight: 800;
|
||||
color: #a970ff; /* A nice purple for usernames */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="chat-container">
|
||||
<div class="chat-message"><span class="username">User123:</span> This is an example chat message!</div>
|
||||
<div class="chat-message"><span class="username">StreamFan:</span> Looks great! Can't wait to use this.</div>
|
||||
<div class="chat-message"><span class="username">AnotherViewer:</span> Hello world!</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
30
templates/overlay_test.html
Normal file
30
templates/overlay_test.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Live Test Overlay</title>
|
||||
<style>
|
||||
body {
|
||||
background-color: black;
|
||||
color: white;
|
||||
font-family: sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
.message {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Live Test Overlay</h1>
|
||||
<div id="chat-messages">
|
||||
<!-- Messages will appear here -->
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// JavaScript to connect to WebSocket and display messages will go here
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
63
twitch_test.py
Normal file
63
twitch_test.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import asyncio
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
import twitchio
|
||||
from twitchio.ext import commands
|
||||
|
||||
# --- Standalone Twitch IRC Connection Test ---
|
||||
|
||||
class TestBot(twitchio.Client):
|
||||
"""A minimal twitchio client for testing IRC connectivity."""
|
||||
|
||||
def __init__(self, channel_name: str):
|
||||
# Load credentials from environment variables
|
||||
self.TMI_TOKEN = os.getenv("TWITCH_TEST_TOKEN")
|
||||
self.CLIENT_ID = os.getenv("TWITCH_CLIENT_ID")
|
||||
self.TARGET_CHANNEL = channel_name
|
||||
|
||||
# Pre-flight checks
|
||||
if not all([self.TMI_TOKEN, self.CLIENT_ID, self.TARGET_CHANNEL]):
|
||||
raise ValueError("Missing required environment variables. Ensure TWITCH_TEST_TOKEN, TWITCH_CLIENT_ID, and a channel are provided.")
|
||||
|
||||
print("--- Configuration ---")
|
||||
print(f"CLIENT_ID: {self.CLIENT_ID[:4]}...{self.CLIENT_ID[-4:]}")
|
||||
print(f"TOKEN: {self.TMI_TOKEN[:12]}...")
|
||||
print(f"TARGET CHANNEL: {self.TARGET_CHANNEL}")
|
||||
print("-----------------------")
|
||||
|
||||
super().__init__(
|
||||
token=f"oauth:{self.TMI_TOKEN}",
|
||||
client_id=self.CLIENT_ID,
|
||||
initial_channels=[self.TARGET_CHANNEL],
|
||||
ssl=True
|
||||
)
|
||||
|
||||
async def event_ready(self):
|
||||
"""Called once when the bot goes online."""
|
||||
print("\n--- Connection Successful ---")
|
||||
print(f"Logged in as: {self.nick}")
|
||||
print(f"Listening for messages in #{self.TARGET_CHANNEL}...")
|
||||
print("---------------------------\n")
|
||||
|
||||
async def event_message(self, message):
|
||||
"""Runs every time a message is sent in chat."""
|
||||
if message.echo:
|
||||
return
|
||||
print(f"#{message.channel.name} | {message.author.name}: {message.content}")
|
||||
|
||||
async def main():
|
||||
"""Main function to run the test bot."""
|
||||
# IMPORTANT: Replace 'ramforth' with the channel you want to test.
|
||||
channel_to_test = "ramforth"
|
||||
|
||||
print(f"Attempting to connect to Twitch IRC for channel: {channel_to_test}")
|
||||
try:
|
||||
bot = TestBot(channel_name=channel_to_test)
|
||||
await bot.start()
|
||||
except Exception as e:
|
||||
print(f"\n--- AN ERROR OCCURRED ---")
|
||||
print(f"Error: {e}")
|
||||
print("Please check your credentials and network connection.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
22
websocket_manager.py
Normal file
22
websocket_manager.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from typing import Dict, List
|
||||
from fastapi import WebSocket
|
||||
|
||||
class WebSocketManager:
|
||||
def __init__(self):
|
||||
# Maps user_id to a list of active WebSocket connections
|
||||
self.active_connections: Dict[int, List[WebSocket]] = {}
|
||||
print("WebSocketManager initialized.")
|
||||
|
||||
async def connect(self, user_id: int, websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
if user_id not in self.active_connections:
|
||||
self.active_connections[user_id] = []
|
||||
self.active_connections[user_id].append(websocket)
|
||||
|
||||
def disconnect(self, user_id: int, websocket: WebSocket):
|
||||
self.active_connections[user_id].remove(websocket)
|
||||
|
||||
async def broadcast_to_user(self, user_id: int, message: dict):
|
||||
if user_id in self.active_connections:
|
||||
for connection in self.active_connections[user_id]:
|
||||
await connection.send_json(message)
|
||||
Reference in New Issue
Block a user