Compare commits
180 Commits
2ed89fecbc
...
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 | |||
| ba1d486d07 | |||
| 0967983483 | |||
| 8829228295 | |||
| 8236d5e837 | |||
| f2ebde841d | |||
| cafb39d9df | |||
| 35c2210305 | |||
| 6ed78cf3fb | |||
| 3fb360aa77 | |||
| f15f339de8 | |||
| 603eb84d34 | |||
| 6a7c96a82c | |||
| 9b44c088b8 | |||
| 98c1417bf1 | |||
| c4edd88b71 | |||
| 3827744154 | |||
| 11254095ff | |||
| 264e4c276d | |||
| 60417d4594 | |||
| 8825421335 | |||
| e4df8d0e15 | |||
| 21f175af9a | |||
| aa5c63296a | |||
| 5931a0bdfe | |||
| 407a6447fd | |||
| 643f6f4272 | |||
| dab3fb4bb4 | |||
| 582a12fbe7 | |||
| 55672d4631 | |||
| ecd8518bd9 | |||
| 3f462e34a1 | |||
| a661ab0b9f | |||
| 2b6a4cc3ab | |||
| bd9e801042 | |||
| 761659fffb | |||
| 1a53135050 | |||
| 3d6e78b356 | |||
| 49dcf3b8a8 | |||
| 8c09a9ac82 | |||
| 7ee83501ba | |||
| 48747edb9c | |||
| 78da04fece | |||
| f6e2fedebc | |||
| 1ad2be8dea | |||
| 0475315bab | |||
| 36efa91e1f | |||
| 545ec0ad0f | |||
| fb551af208 | |||
| e31b6701db | |||
| 8678b508b7 | |||
| 9e4f7da306 | |||
| 4a0b08fa25 | |||
| 81338c1ecf | |||
| ce8dd866ec | |||
| f157c8e7df | |||
| 2265d24578 | |||
| b7c7ca6745 | |||
| c4dbb7eca9 | |||
| 7aa6b8a675 | |||
| ca096e8639 | |||
| 99c46f2e47 | |||
| bb70dcfa05 | |||
| be23f184c9 | |||
| d48188cfa7 | |||
| 098dc52248 | |||
| 2e6e605f24 | |||
| 1b9bf938d6 | |||
| 232d24fd10 | |||
| 8ff003e393 | |||
| ac87daec68 | |||
| d09e15ae86 | |||
| 873dc6e736 | |||
| 55c55a7055 | |||
| 2e19fd6f40 | |||
| 6aec15c408 | |||
| d702343db6 | |||
| 0eacb12c73 | |||
| 1186c91dcf | |||
| 56bf044bb6 | |||
| 4a951df300 | |||
| 8f16c2c4df | |||
| 4d2566b48a | |||
| 62ee9ad954 | |||
| e31ccbbb19 | |||
| cdc5c32429 | |||
| 2427be3a53 | |||
| 8554be1c51 | |||
|
|
6a50156ca8 | ||
|
|
8e713ec506 | ||
|
|
56bb514efa | ||
|
|
562e7792e9 | ||
|
|
b1b7759e13 | ||
|
|
fcd2b30b58 |
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
# This is an example file. Copy it to .env and fill in your actual secrets.
|
||||
# The .env file is ignored by Git and should NEVER be committed.
|
||||
|
||||
ENCRYPTION_KEY=your_32_byte_url_safe_base64_encoded_key_goes_here
|
||||
|
||||
TWITCH_CLIENT_ID=your_twitch_client_id_goes_here
|
||||
TWITCH_CLIENT_SECRET=your_twitch_client_secret_goes_here
|
||||
|
||||
APP_BASE_URL=http://localhost:8000
|
||||
48
.gitignore
vendored
Normal file
48
.gitignore
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual Environments
|
||||
venv/
|
||||
env/
|
||||
.venv/
|
||||
.env/
|
||||
|
||||
# SQLite databases
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Personal notes - will not be tracked by git
|
||||
personal_notes.md
|
||||
|
||||
# Local-only directory for untracked notes and context
|
||||
local/
|
||||
182
CONTEXT.md
Normal file
182
CONTEXT.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# Gemini Code Assist: Project Context
|
||||
|
||||
This document outlines my core instructions and my current understanding of the MultiChatOverlay project.
|
||||
|
||||
---
|
||||
|
||||
## Part 1: My Core Instructions
|
||||
|
||||
My primary function is to act as a world-class senior software engineering assistant. My goal is to provide insightful answers that prioritize code quality, clarity, and adherence to best practices.
|
||||
|
||||
My core instructions are:
|
||||
* **Persona:** Gemini Code Assist, a very experienced coding assistant.
|
||||
* **Objective:** Answer questions thoroughly, review code, and suggest improvements.
|
||||
* **Output Format:**
|
||||
* Provide all code changes as diffs in the unified format, using full absolute file paths.
|
||||
* Ensure all code blocks are valid and well-formatted.
|
||||
* Suggest relevant next steps or prompts for our workflow.
|
||||
* Maintain a conversational, accurate, and helpful tone.
|
||||
|
||||
---
|
||||
|
||||
## Part 2: My Understanding of the Project
|
||||
|
||||
### Project Goal
|
||||
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
|
||||
**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 (`/`).
|
||||
### 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.
|
||||
* **Twitch Chat Ingestion:** Use the stable and scalable Twitch IRC protocol (via `twitchio`).
|
||||
* **YouTube Chat Ingestion:** Use an unofficial, reverse-engineered "InnerTube" API (via `pytchat`). This is the primary technical risk of the project due to its fragility and will require careful implementation with proxy rotation and monitoring.
|
||||
|
||||
### Immediate Next Task
|
||||
Based on the `TASKS.md` file, the only remaining task for Phase 1 is:
|
||||
* **Task 1.4: Basic Session Management:** After a user successfully logs in, we need to create a persistent session for them. This will allow us to "remember" who is logged in, protect routes like the future `/dashboard`, and provide a seamless user experience. The current flow correctly authenticates the user but does not yet establish this persistent session.
|
||||
|
||||
## References:
|
||||
|
||||
### Development plan
|
||||
```
|
||||
# Multi-Platform Chat Overlay Development Plan (v4 - Simplified Stack)
|
||||
|
||||
This document outlines the development plan for a multi-user, web-based chat overlay service using a simplified technology stack.
|
||||
|
||||
## 1. Project Overview
|
||||
|
||||
The goal is to create a service where streamers can log in using their platform accounts (Twitch, YouTube), configure a personalized chat overlay, and use it in their streaming software (e.g., OBS). The service will aggregate chat from their connected accounts and provide moderation tools.
|
||||
|
||||
## 2. Technology Stack
|
||||
|
||||
* **Team Communications:** Discord and Nextcloud, primarily. This can change. There's a list of links in the [README.md](README.md)
|
||||
* **Backend:** Python 3.13+ (FastAPI)
|
||||
* **Database:** SQLite (for initial development) with SQLAlchemy ORM
|
||||
* **Frontend:** Vanilla HTML, CSS, and JavaScript
|
||||
* **Chat Listeners:** `twitchio` (Twitch), `pytchat` (YouTube)
|
||||
|
||||
## 3. Implementation Roadmap
|
||||
|
||||
### Phase 1: User Authentication & Database (FastAPI)
|
||||
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.
|
||||
4. **Session Management:** Create a system to manage logged-in user sessions.
|
||||
5. **Basic Frontend:** Develop a simple login page.
|
||||
|
||||
### Phase 2: User Dashboard & Configuration
|
||||
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.
|
||||
|
||||
### Phase 3: Dynamic Listeners & Basic Overlay
|
||||
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
|
||||
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.
|
||||
|
||||
## 4. Requirements for Completion (Initial Version)
|
||||
|
||||
The project will be considered complete for its initial version when Phases 1, 2, and 3 are functional:
|
||||
1. Users can log in with their Twitch account.
|
||||
2. Users can see their unique overlay URL on a dashboard.
|
||||
3. The overlay successfully connects to their Twitch chat and displays messages when opened in a browser source.
|
||||
|
||||
## 6. Future Enhancements from Gitea Issues
|
||||
|
||||
These are enhancement suggestions gathered from the project's Gitea issues, representing potential future features or considerations:
|
||||
|
||||
* **Issue #1: Multi select chat display order**
|
||||
* Allow streamer to click on messages that appear whilst discussing chat message already on screen. This will enable quick progress through important messages without having to scroll back up chat.
|
||||
* **Issue #2: Moderator chat assistance with streamer over ride**
|
||||
* Moderators can select messages on their end, marking them for discussion, freeing up the streamer to simply stream. The streamer would have override to reject messages as the stream owner.
|
||||
* **Issue #3: Chat Speed toggle for busier chat streams**
|
||||
* Implement a toggle to adjust the display speed of chat messages, useful for very active streams.
|
||||
* **Issue #4: Auto add YT Superchats to Highlights**
|
||||
* Add a setting to automatically include YouTube Superchats in the highlighted messages.
|
||||
* **Issue #5: Donations page somewhere**
|
||||
* Consider integrating a donations page or feature within the service.
|
||||
```
|
||||
|
||||
### Tasks
|
||||
|
||||
```
|
||||
# Project Task List
|
||||
|
||||
This file tracks all active development tasks. It is based on the official `DEVELOPMENT_PLAN.md`.
|
||||
|
||||
## 📋 How to Use This List
|
||||
|
||||
1. Find a task in the "To Do" section that you want to work on.
|
||||
2. Add your name next to it (e.g., `[ ] Task Name - @YourName`).
|
||||
3. When you start, move it to "In Progress" and follow the `CONTRIBUTING.md` workflow.
|
||||
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) )
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Phase 1: User Authentication & Database
|
||||
|
||||
* **Goal:** Get the basic API, database, and Twitch login flow working.
|
||||
|
||||
### To Do
|
||||
|
||||
### Done
|
||||
* `[✔️]` **1.0: Project Skeleton** - @ramforth
|
||||
* *Task:* Setup `main.py`, `requirements.txt`, and `.gitignore`.
|
||||
* `[✔️]` **1.1: Database Schema:** Define SQLAlchemy models for `User` (id, username, platform, encrypted_tokens) and `Settings`. @ramforth
|
||||
* `[✔️]` **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. Now that Phase 1 is done, these tasks are ready to be worked on.
|
||||
|
||||
### To Do
|
||||
|
||||
* `[ ]` **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: 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
|
||||
|
||||
---
|
||||
### 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
|
||||
|
||||
* `[ ]` Implement YouTube OAuth & `pytchat` listener (Phase 4).
|
||||
* `[ ]` "Single Message Focus" feature (Issue #1).
|
||||
* `[ ]` Moderator panels (Issue #2).
|
||||
```
|
||||
99
CONTRIBUTING.md
Normal file
99
CONTRIBUTING.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Contribution & Workflow Guide
|
||||
|
||||
Welcome to the MultiChatOverlay project! To ensure we can collaborate effectively and avoid errors, we follow a strict and professional development workflow.
|
||||
|
||||
## 📜 The Golden Rules
|
||||
|
||||
1. **Gitea is the Source of Truth.** The `main` branch on our Gitea server is the *only* source of truth.
|
||||
2. **NEVER Commit to `main`.** All work must be done in a separate "feature branch" and submitted as a Pull Request.
|
||||
3. **NEVER Work on the Server.** The staging server (`192.168.10.33`) is for *testing* the `main` branch. It is **NOT** a development environment. All development must be done on your **local machine**.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Your Local Setup (One Time)
|
||||
|
||||
You only need to do this once.
|
||||
|
||||
1. **Install Tools:**
|
||||
* [Git](https://git-scm.com/downloads)
|
||||
* [Visual Studio Code](https://code.visualstudio.com/)
|
||||
* [Python 3.9+](https://www.python.org/downloads/)
|
||||
2. **Clone the Repo:** Clone the project from our Gitea server to your local computer:
|
||||
```bash
|
||||
git clone [https://gitea.ramforth.net/ramforth/MultiChatOverlay.git](https://gitea.ramforth.net/ramforth/MultiChatOverlay.git)
|
||||
cd MultiChatOverlay
|
||||
```
|
||||
3. **Install VS Code Extensions:**
|
||||
* Open the `MultiChatOverlay` folder in VS Code.
|
||||
* Go to the Extensions tab and install:
|
||||
* `Python` (Microsoft)
|
||||
* `Gemini` (Google)
|
||||
4. **Create Your Virtual Environment:**
|
||||
```bash
|
||||
# From the terminal in VS Code
|
||||
python -m venv venv
|
||||
```
|
||||
* VS Code should auto-detect this and ask to use it. Click "Yes."
|
||||
5. **Install Dependencies:**
|
||||
```bash
|
||||
# Make sure your 'venv' is activated
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
You are now ready to develop!
|
||||
|
||||
---
|
||||
|
||||
## 💡 Your Daily Workflow (The "Loop")
|
||||
|
||||
This is the process you will follow *every time* you want to add a new feature or fix a bug.
|
||||
|
||||
1. **Get Latest Code:** Make sure your local `main` branch is up-to-date.
|
||||
```bash
|
||||
git checkout main
|
||||
git pull
|
||||
```
|
||||
2. **Create a New Branch:** Create a new branch for your task. Name it clearly (e.g., `feature/twitch-auth`, `bugfix/css-error`).
|
||||
```bash
|
||||
git checkout -b feature/my-new-feature
|
||||
```
|
||||
3. **Write Code!**
|
||||
* This is where you do your work.
|
||||
* Use the **Gemini plugin** in VS Code to help you.
|
||||
* **Pro-tip:** Open the Gemini chat and give it context by pasting in files like `DEVELOPMENT_PLAN.md` or the `TASKS.md` file so it understands the goal.
|
||||
4. **Test Locally:** Run the FastAPI server on your *local* machine to make sure your feature works and doesn't break anything.
|
||||
```bash
|
||||
uvicorn main:app --reload
|
||||
```
|
||||
5. **Commit Your Work:** Once it's working, save your changes.
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Add new feature: brief description here"
|
||||
```
|
||||
6. **Push Your Branch:** Push your *new branch* (not `main`) to Gitea.
|
||||
```bash
|
||||
git push -u origin feature/my-new-feature
|
||||
```
|
||||
7. **Open a Pull Request:**
|
||||
* Go to the Gitea website.
|
||||
* You will see a prompt to "Open a Pull Request" for your new branch.
|
||||
* Fill it out, describe your changes, and submit it for review.
|
||||
|
||||
A project lead will then review your code, and once approved, it will be merged into the `main` branch and deployed to the staging server for final testing.
|
||||
|
||||
|
||||
🚀 Automatic Deployment (The Webhook)
|
||||
|
||||
We have set up an automated "hotline" that connects our code storage (Gitea) to our live server.
|
||||
|
||||
Here's how it works:
|
||||
|
||||
**Code is Saved**: A developer saves new code to our Gitea project.
|
||||
|
||||
**Gitea Calls the Server**: Gitea immediately "calls" a special, secret address on our server.
|
||||
|
||||
**Server Verifies the Call**: A "listener" program on the server answers and checks a secret password to make sure the call is genuinely from Gitea and not an impostor.
|
||||
|
||||
**Server Updates Itself**: Once verified, the listener automatically runs our deploy.sh script. This script fetches all the new code and restarts the application.
|
||||
|
||||
The result: The server is always running the latest version of the code, and no one has to log in to update it manually. It's completely automatic.
|
||||
@@ -8,44 +8,51 @@ The goal is to create a service where streamers can log in using their platform
|
||||
|
||||
## 2. Technology Stack
|
||||
|
||||
* **Backend (API & Chat Listeners):** Python (for Twitch/YouTube chat listeners), Node.js (for WebSocket server and potentially other APIs), PHP (for user management and web serving).
|
||||
* **Database:** MySQL
|
||||
* **Frontend:** HTML, CSS, JavaScript
|
||||
* **Team Communications:** Discord and Nextcloud, primarily. This can change. There's a list of links in the [README.md](README.md)
|
||||
* **Backend:** Python 3.13+ (FastAPI)
|
||||
* **Database:** SQLite (for initial development) with SQLAlchemy ORM
|
||||
* **Frontend:** Vanilla HTML, CSS, and JavaScript
|
||||
* **Chat Listeners:** `twitchio` (Twitch), `pytchat` (YouTube)
|
||||
|
||||
## 3. Implementation Roadmap
|
||||
|
||||
### Phase 1: Basic Setup & Twitch Chat Listener (Python)
|
||||
1. **Project Structure:** Establish a clear directory structure for PHP, Python, Node.js, and static assets.
|
||||
2. **Python Environment:** Set up a Python virtual environment and install `twitchio`.
|
||||
3. **Twitch Chat Listener (Python Script):** Create a standalone Python script that connects to Twitch chat, listens for messages, and prints them to standard output. This script will be run as a background process.
|
||||
4. **Twitch OAuth2 (Python):** Implement a simple Python script or a PHP endpoint to handle Twitch OAuth2 to obtain user access tokens. Store these securely in MySQL.
|
||||
### Phase 1: User Authentication & Database (FastAPI)
|
||||
**Status: ✔️ Complete**
|
||||
|
||||
### Phase 2: MySQL Database & User Management (PHP)
|
||||
1. **MySQL Setup:** Set up a MySQL database and create a `users` table to store user information (Twitch ID, username, access token, refresh token).
|
||||
2. **PHP Web Server:** Configure a basic PHP web server.
|
||||
3. **User Registration/Login (PHP):** Implement PHP scripts for user registration and login, integrating with the MySQL database.
|
||||
4. **Dashboard (PHP/HTML):** Create a basic dashboard where logged-in users can see their Twitch connection status and their unique overlay URL.
|
||||
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.
|
||||
4. **Session Management:** Create a system to manage logged-in user sessions.
|
||||
5. **Basic Frontend:** Develop a simple login page.
|
||||
|
||||
### Phase 3: WebSocket Server (Node.js) & Overlay (HTML/CSS/JS)
|
||||
1. **Node.js Environment:** Set up a Node.js environment and install `ws` (WebSocket library).
|
||||
2. **WebSocket Server (Node.js):** Create a Node.js WebSocket server that:
|
||||
* Accepts connections from overlay clients.
|
||||
* Receives chat messages from the Python Twitch listener (via a simple inter-process communication mechanism, e.g., writing to a file or a local socket).
|
||||
* Broadcasts messages to connected overlay clients.
|
||||
3. **Overlay Frontend (HTML/CSS/JS):** Create a basic `overlay.html` that:
|
||||
* Connects to the Node.js WebSocket server.
|
||||
* Displays incoming chat messages.
|
||||
4. **Inter-process Communication:** Implement a mechanism for the Python Twitch listener to send messages to the Node.js WebSocket server.
|
||||
### 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.
|
||||
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
|
||||
1. **Dynamic Listener Management:** Develop a system (e.g., a PHP script or a Node.js API) to start and stop Python Twitch listener processes based on user activity.
|
||||
2. **YouTube Integration:** Add YouTube chat listening capabilities (Python `pytchat`) and integrate with the existing system.
|
||||
3. **Advanced Overlay Customization:** Implement options for users to customize their overlay.
|
||||
**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.
|
||||
|
||||
## 4. Requirements for Completion (Initial Version)
|
||||
|
||||
The project will be considered complete for its initial version when Phases 1, 2, and 3 are functional:
|
||||
1. Users can log in with their Twitch account.
|
||||
1. Users can log in with their Twitch account.
|
||||
2. Users can see their unique overlay URL on a dashboard.
|
||||
3. The overlay successfully connects to their Twitch chat and displays messages when opened in a browser source.
|
||||
|
||||
|
||||
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.
|
||||
43
README.md
Normal file
43
README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# MultiChatOverlay
|
||||
|
||||
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 2: User Dashboard & Configuration**.
|
||||
|
||||
## 🚀 Project Goal
|
||||
|
||||
* **Unified Chat:** Aggregate chat from multiple platforms (starting with Twitch & YouTube) into one browser source.
|
||||
* **Customization:** Allow users to save their own custom CSS and use templates.
|
||||
* **Interaction:** Provide "single message focus" and other moderation tools for streamers and their teams.
|
||||
* **Self-Hosted:** The service is hosted by the project owner (you) and provided to users.
|
||||
|
||||
## 🔒 Security & Privacy
|
||||
|
||||
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.
|
||||
|
||||
## 🖥️ Technology Stack
|
||||
|
||||
* **Backend:** Python 3.9+ (FastAPI)
|
||||
* **Database:** SQLite (initially, for simplicity) with SQLAlchemy
|
||||
* **Chat Listeners:** `twitchio` (Twitch), `pytchat` (YouTube)
|
||||
* **Frontend:** Vanilla HTML5, CSS3, and JavaScript (Fetch API, WebSockets)
|
||||
* **Authentication:** OAuth2 for all external platforms.
|
||||
|
||||
## 📖 Development & Contribution
|
||||
|
||||
This project follows a professional development workflow. Gitea is our single source of truth.
|
||||
|
||||
* **Want to contribute?** See our [CONTRIBUTING.md](CONTRIBUTING.md) file for the complete setup guide and workflow rules.
|
||||
* **Looking for a task?** See the [TASKS.md](TASKS.md) file for a list of current jobs, broken down by phase.
|
||||
* **Want the full plan?** See the [DEVELOPMENT_PLAN.md](DEVELOPMENT_PLAN.md) for the complete project roadmap.
|
||||
|
||||
## ⁉️ Acknowledgements
|
||||
|
||||
For the project we are using Discord 💬 and Nextcloud ☁️ for communications.
|
||||
* ☁️ [Nextcloud](https://cloud9.ramforth.net/)
|
||||
* 💬 [Discord](https://discord.gg/Zaxp6ch9hs)
|
||||
* 🌐 [Public website](https://multichat.ramforth.net/)
|
||||
|
||||
##
|
||||
|
||||
👨💻 - Coded on and for Linux - 2025
|
||||
497
RESEARCH_REPORT.md
Normal file
497
RESEARCH_REPORT.md
Normal file
@@ -0,0 +1,497 @@
|
||||
# Architect's Design Report: A Hybrid Model for Scalable Multi-Platform Chat Ingestion
|
||||
|
||||
## Executive Summary: The Hybrid Architecture for Scalable Chat Ingestion
|
||||
|
||||
This document serves as the principal architectural blueprint for the proposed multi-tenant (SaaS) chat overlay platform. It provides a definitive technical design that directly addresses the core challenge: building a scalable, low-latency ingestion pipeline for real-time chat from Twitch and YouTube, capable of supporting thousands of concurrent users on a Python (FastAPI) backend.
|
||||
|
||||
The central conflict at the heart of this design problem is the profound mismatch between the platform's real-time, high-frequency requirements and the intended use of official, public-facing APIs. The official APIs, particularly the YouTube Data API v3, are designed for low-frequency data retrieval and information management, not for high-frequency, low-latency streaming. This is enforced via a strict quota system that makes them quantitatively non-viable for this application.
|
||||
|
||||
For example, the YouTube Data API v3's default quota of 10,000 units per day is the primary blocker.[1, 2, 3] A single call to the `liveChatMessages.list` endpoint, which is the official method for fetching chat, costs 5 quota units.[4] A reasonable poll rate of 3 seconds (20 polls per minute) for a single user would exhaust the _entire_ platform's 10,000-unit quota in approximately 100 minutes of streaming.[4] This renders the official API completely unusable for a scalable SaaS.
|
||||
|
||||
The mandated solution is a "hybrid" architecture that bifurcates the system's logic, separating user-facing authentication from high-performance chat ingestion.
|
||||
|
||||
1. **Authentication Path:** This path will use the 100% official, secure, and documented OAuth 2.0 Authorization Code Grant Flows for both Twitch and Google.[5, 6] This ensures that all user-facing interactions are secure, trustworthy, and handled according to industry-best practices. The platform will securely manage user tokens for API calls.
|
||||
|
||||
2. **Ingestion Path:** This path will completely bypass the non-viable "front door" APIs, opting instead for more direct, high-performance protocols.
|
||||
|
||||
- **For Twitch:** The system will bypass the modern EventSub API [7] and instead utilize the legacy, but massively scalable, Twitch IRC protocol over a secure WebSocket.[8] This protocol is purpose-built for high-volume, "unlimited read" chat.[9]
|
||||
|
||||
- **For YouTube:** The system will bypass the _entire_ official Data API v3. Ingestion will be handled by a "scraper" component that reverse-engineers YouTube's internal, unauthenticated, and undocumented "InnerTube" API.[10] This is the same internal API used by the YouTube web application itself to display chat.
|
||||
|
||||
|
||||
This hybrid model presents a clear architectural trade-off. For Twitch, the solution is robust and relies on a stable, albeit legacy, protocol. For YouTube, the solution is highly efficient but operationally fragile. The primary technical bottleneck for the entire platform will be the maintenance and risk-management of the YouTube "InnerTube" client, which is subject to unannounced changes by Google that could break ingestion for all users. The architecture must be built with this fragility as a core assumption, incorporating robust mitigation strategies.
|
||||
|
||||
The following table summarizes the definitive architectural choices detailed in this report.
|
||||
|
||||
| | | | |
|
||||
|---|---|---|---|
|
||||
|**Platform**|**Challenge**|**Recommended Method**|**Rationale**|
|
||||
|**Twitch**|**Authentication**|Official OAuth 2.0 Authorization Code Flow [5]|Server-side security standard; required for `client_secret` storage.|
|
||||
|**Twitch**|**Chat Ingestion**|**Twitch IRC** (over WebSocket) [8]|Massively scalable; no read/connection limits.[9] Architecturally simpler for this use case than EventSub.[7, 11]|
|
||||
|**YouTube**|**Authentication**|Official Google OAuth 2.0 Server-Side Flow [6]|Server-side security standard; allows for `offline` access to get refresh tokens.[12]|
|
||||
|**YouTube**|**Chat Ingestion**|**Unofficial "InnerTube" API** (The `pytchat` method) [10, 13]|The _only_ viable method. Official API quota is catastrophically insufficient (10k units/day).[3, 4]|
|
||||
|
||||
## Part 1: Twitch Platform Integration Blueprint
|
||||
|
||||
### 1.1. User Authentication Protocol (OAuth 2.0)
|
||||
|
||||
For a server-side application (FastAPI) that must securely store a `client_secret` and manage tokens on behalf of users, the **Authorization Code Grant Flow** is the required and recommended OAuth 2.0 flow.[5, 14, 15]
|
||||
|
||||
#### Step-by-Step Technical Walkthrough
|
||||
|
||||
The flow involves a secure, five-step server-side process:
|
||||
|
||||
1. **Step 1: Redirect User to Twitch:** The FastAPI server generates a unique `state` token for CSRF protection and constructs a URL. The user is then redirected to the Twitch authorization endpoint.[16]
|
||||
|
||||
- **Endpoint:** `GET https://id.twitch.tv/oauth2/authorize` [17]
|
||||
|
||||
- **Query Parameters:**
|
||||
|
||||
- `client_id`: Your application's registered client ID.[5]
|
||||
|
||||
- `redirect_uri`: Your server's pre-registered callback endpoint.[18]
|
||||
|
||||
- `response_type`: Must be `code`.[18]
|
||||
|
||||
- `scope`: A space-delimited string of requested scopes (see below).[17]
|
||||
|
||||
- `state`: The server-generated CSRF token.
|
||||
|
||||
2. **Step 2: User Authorizes:** The user is prompted to log into Twitch (if not already) and presented with the consent screen detailing the requested `scope` permissions.[14] Upon clicking "Authorize," Twitch proceeds to the next step.
|
||||
|
||||
3. **Step 3: Twitch Redirects Back to Server:** Twitch redirects the user's browser back to the `redirect_uri` specified in Step 1. This request will include two query parameters:
|
||||
|
||||
- `code`: A temporary, single-use authorization `code`.[5, 16]
|
||||
|
||||
- `state`: The original CSRF token. Your server must first validate that the returned `state` matches the one generated in Step 1.
|
||||
|
||||
4. **Step 4: Server Exchanges Code for Token:** Upon validating the `state`, your FastAPI backend must _immediately_ make a secure, server-to-server HTTP `POST` request to Twitch's token endpoint to exchange the `code` for a permanent token.[5]
|
||||
|
||||
- **Endpoint:** `POST https://id.twitch.tv/oauth2/token` [5, 18]
|
||||
|
||||
- **Request Body (`application/x-www-form-urlencoded`):**
|
||||
|
||||
- `client_id`: Your app's client ID.[5]
|
||||
|
||||
- `client_secret`: Your app's client secret.[5]
|
||||
|
||||
- `code`: The `code` received in Step 3.[5]
|
||||
|
||||
- `grant_type`: Must be `authorization_code`.[5, 18]
|
||||
|
||||
- `redirect_uri`: The _exact_ same URI used in Step 1.[18]
|
||||
|
||||
5. **Step 5: Store Tokens and Validate User:** Twitch will respond with a JSON object containing the `access_token` and `refresh_token`.[5, 19] These must be encrypted (e.g., using the `cryptography` library) and stored securely in the database, associated with the user's account.
|
||||
|
||||
|
||||
#### Minimum Scope Requirements
|
||||
|
||||
For this architecture, the minimum required scopes are:
|
||||
|
||||
- **`chat:read`**: Explicitly required to connect to the IRC server and read chat messages.[8]
|
||||
|
||||
- **`chat:write`**: (Recommended) Required to send chat messages via IRC, which is a likely feature for an overlay.[8]
|
||||
|
||||
|
||||
The scope `user:read:chat` is associated with the EventSub method [20, 21] and is **not required** for the recommended IRC architecture.
|
||||
|
||||
#### Post-Authentication User Validation
|
||||
|
||||
Immediately following Step 5, the service must perform a validation call to fetch the user's stable identifiers. This call bridges the gap between the modern OAuth system and the legacy IRC system. The `access_token` just received is used to call the `Get Users` endpoint.[22, 23]
|
||||
|
||||
- **API Call:** `GET https://api.twitch.tv/helix/users`
|
||||
|
||||
- **Headers:**
|
||||
|
||||
- `Authorization: Bearer <user_access_token>` [22, 23]
|
||||
|
||||
- `Client-Id: <your_client_id>` [22]
|
||||
|
||||
|
||||
This request, made without any query parameters, returns the user object associated with the token.[22] The response contains a `data` array with the user's:
|
||||
|
||||
- **`id`**: The stable, unique User ID. This must be stored as the primary key for this user in the database.
|
||||
|
||||
- **`login`**: The user's lowercase login name (e.g., `twitchdev`).
|
||||
|
||||
|
||||
This step is non-negotiable. The `login` name is **required** by the IRC protocol for the `NICK` command [8] and to `JOIN` the correct channel. This API call is the critical link that translates an OAuth token into the credentials needed for the chat ingestion system.
|
||||
|
||||
### 1.2. Critical Analysis: Real-time Chat Ingestion
|
||||
|
||||
This is the core of the Twitch problem. A choice must be made between Twitch's modern, recommended API (EventSub) and its legacy, high-performance protocol (IRC).
|
||||
|
||||
#### Method A: EventSub (Webhooks or WebSocket)
|
||||
|
||||
- **Mechanism:** EventSub is a modern, push-based system where your application subscribes to specific event topics, such as `channel.chat.message`.[11, 24] When a chat message occurs, Twitch sends your server a JSON payload notification. This can be delivered via two transports:
|
||||
|
||||
1. **Webhooks:** Twitch sends an HTTP `POST` to a public endpoint you provide and manage.[11]
|
||||
|
||||
2. **WebSocket:** Your server maintains a persistent WebSocket connection to Twitch, which then pushes event messages to you.[11]
|
||||
|
||||
- **Latency:** Generally low, but it is an _event notification system_, not a raw stream.[7] It is designed for "at least once" delivery, meaning your service must be architected to handle and de-duplicate messages.[11]
|
||||
|
||||
- **Viability Assessment:** This method is **not recommended** for this specific multi-tenant SaaS architecture. While Twitch's documentation _recommends_ EventSub over IRC [7, 8], this advice is aimed at smaller, single-channel bots. For a SaaS platform supporting thousands of users, the EventSub-via-webhook model creates massive architectural complexity. The service would need to create, manage, and renew thousands of individual webhook subscriptions, and its API (FastAPI) would be subjected to a high-volume "storm" of inbound HTTP `POST` requests from Twitch. The WebSocket transport is better, but the IRC method is simpler, more direct, and purpose-built for this exact task.
|
||||
|
||||
|
||||
#### Method B: Twitch IRC (via `twitchio` or similar)
|
||||
|
||||
- **Mechanism:** This is the definitive, recommended method. Twitch's chat system is, at its core, a modified IRC server.[8] The Python `twitchio` library is a robust, async-first (asyncio) client for this service.[25] Under the hood, the client opens a single, persistent, secure WebSocket (or raw TCP) connection to Twitch's chat server.[8, 26]
|
||||
|
||||
- **Server URI:** `wss://irc-ws.chat.twitch.tv:443` (Secure WebSocket) [8]
|
||||
|
||||
- **Authentication:** Authentication is performed _per-connection_ immediately after the socket is opened. The client must send three commands:
|
||||
|
||||
1. `PASS oauth:<user_access_token>` (This is the token obtained in section 1.1) [8]
|
||||
|
||||
2. `NICK <user_login_name>` (This is the `login` name obtained in section 1.1) [8]
|
||||
|
||||
3. `JOIN #<user_login_name>` (To join the user's own channel)
|
||||
|
||||
- **Rate Limits and Scalability:** This is the most critical factor.
|
||||
|
||||
- **Connections:** "There is no limit to connections a single bot can have".[9] Furthermore, connecting multiple clients from a single IP address is an explicitly supported scaling strategy.[27, 28]
|
||||
|
||||
- **Read Rate:** "There is no limit to messages these connections can receive".[9] This means the platform can scale to thousands of users by simply opening one persistent connection for each authenticated, active user. A single server running an asynchronous Python application can handle thousands of concurrent WebSocket connections.
|
||||
|
||||
- **The Real Bottleneck:** The _only_ significant scaling bottleneck for Twitch is not reading chat, but managing the **`JOIN` rate** during a "thundering herd" scenario (e.g., your service restarts and 10,000 clients try to reconnect and `JOIN` channels simultaneously).
|
||||
|
||||
- The `JOIN` rate limit is **20 `JOIN`s per 10 seconds**.[9]
|
||||
|
||||
- Your connection management service _must_ implement a global, distributed rate-limiter (e.g., using Redis) to ensure that `JOIN` commands are queued and dispatched at a rate just under this limit.
|
||||
|
||||
|
||||
#### Recommendation
|
||||
|
||||
Use **Method B (Twitch IRC)**. It is purpose-built for high-volume, low-latency, "unlimited read" chat ingestion [9] and scales horizontally by simply adding more connections from one or more servers.[27] The `twitchio` library [25] is a suitable async Python client for this architecture.
|
||||
|
||||
### 1.3. Service-Side Token Lifecycle Management
|
||||
|
||||
The service must be built to handle the entire lifecycle of an OAuth token, including refresh and revocation.
|
||||
|
||||
#### Refresh Logic
|
||||
|
||||
Access tokens expire. When an API call (e.g., to `/helix/users`) returns a 401 Unauthorized error [14], or when an IRC connection fails with a login error [8], the server must assume the token is expired and trigger the refresh logic.
|
||||
|
||||
- **API Call:** `POST https://id.twitch.tv/oauth2/token`
|
||||
|
||||
- **Headers:** `Content-Type: application/x-www-form-urlencoded` [19]
|
||||
|
||||
- **Request Body (URL-encoded):**
|
||||
|
||||
- `grant_type=refresh_token` [19]
|
||||
|
||||
- `refresh_token`: The user's stored `refresh_token` [19]
|
||||
|
||||
- `client_id`: Your app's `client_id` [19]
|
||||
|
||||
- `client_secret`: Your app's `client_secret` [19]
|
||||
|
||||
|
||||
The server will respond with a JSON object containing a _new_ `access_token` and, crucially, a _new_ `refresh_token`.[5, 19] The server _must_ update both of these new credentials in the database, overwriting the old ones.
|
||||
|
||||
#### Revocation Logic
|
||||
|
||||
When a user disconnects their account from the SaaS platform, their token must be revoked and their data processing must stop. This is a critical two-step process.
|
||||
|
||||
1. **Step 1: API Revocation:** The server must call the revocation endpoint to externally invalidate the token, preventing future use.
|
||||
|
||||
- **API Call:** `POST https://id.twitch.tv/oauth2/revoke` [29]
|
||||
|
||||
- **Headers:** `Content-Type: application/x-www-form-urlencoded`
|
||||
|
||||
- **Request Body (URL-encoded):**
|
||||
|
||||
- `client_id`: Your app's `client_id` [30]
|
||||
|
||||
- `token`: The `access_token` that is being revoked [30, 31]
|
||||
|
||||
2. **Step 2: Internal Connection Termination:** Calling the `/revoke` endpoint **does not** disconnect an already-active IRC session.[32] The OAuth token is only validated by the IRC server _at the time of login_.[32] An active connection will remain connected and continue to receive chat messages even after its token is revoked.
|
||||
|
||||
- Therefore, your application _must_ maintain an in-memory mapping (e.g., a dictionary or Redis cache) of `user_id` to its active `twitchio` client or WebSocket connection.
|
||||
|
||||
- Immediately after a successful revocation API call, the server must look up the user's active connection and **forcibly close the socket**. This ensures all data processing for that user ceases immediately.
|
||||
|
||||
|
||||
## Part 2: YouTube Platform Integration Blueprint
|
||||
|
||||
### 2.1. User Authentication Protocol (Google OAuth 2.0)
|
||||
|
||||
The process for YouTube (Google) is analogous to Twitch, using the **Server-Side Web Apps Flow**.[6, 33]
|
||||
|
||||
#### Step-by-Step Technical Walkthrough
|
||||
|
||||
1. **Step 1: Redirect User to Google:** The server generates a `state` token and redirects the user to Google's OAuth 2.0 server.[6]
|
||||
|
||||
- **Endpoint:** `GET https://accounts.google.com/o/oauth2/v2/auth`
|
||||
|
||||
- **Query Parameters:**
|
||||
|
||||
- `client_id`: Your app's client ID.[34]
|
||||
|
||||
- `redirect_uri`: Your server's pre-registered callback.[6]
|
||||
|
||||
- `response_type`: Must be `code`.
|
||||
|
||||
- `scope`: A space-delimited string of requested scopes (see below).
|
||||
|
||||
- `access_type`: Must be `offline`. This is **critical** as it is the only way to obtain a `refresh_token`.[12]
|
||||
|
||||
- `prompt`: Recommended to be `consent` to ensure a `refresh_token` is returned even on re-authentication.
|
||||
|
||||
2. **Step 2: User Authorizes:** The user logs in, selects the Google Account associated with their YouTube Channel, and grants the requested permissions.[6]
|
||||
|
||||
3. **Step 3: Google Redirects Back to Server:** Google redirects the user to your `redirect_uri` with the `code` and `state`.[6]
|
||||
|
||||
4. **Step 4: Server Exchanges Code for Token:** The FastAPI backend validates the `state` and makes a secure, server-to-server `POST` request.[6]
|
||||
|
||||
- **Endpoint:** `POST https://www.googleapis.com/oauth2/v4/token` [35]
|
||||
|
||||
- **Request Body (`application/x-www-form-urlencoded` or JSON):**
|
||||
|
||||
- `client_id`: Your client ID.[35]
|
||||
|
||||
- `client_secret`: Your client secret.[35]
|
||||
|
||||
- `code`: The `code` from Step 3.
|
||||
|
||||
- `grant_type`: Must be `authorization_code`.[6]
|
||||
|
||||
- `redirect_uri`: The _exact_ same URI from Step 1.
|
||||
|
||||
5. **Step 5: Store Tokens and Validate Channel:** Google responds with an `access_token` and `refresh_token` (because `access_type=offline` was specified).[36] These must be encrypted and stored.
|
||||
|
||||
|
||||
#### Minimum Scope Requirements
|
||||
|
||||
The minimum scope required for this hybrid architecture is:
|
||||
|
||||
- **`https://www.googleapis.com/auth/youtube.readonly`** [6, 37]
|
||||
|
||||
|
||||
This is a significant finding. The full-access `.../auth/youtube` scope [38] is _not_ required. The `.../readonly` scope is sufficient to "View your YouTube account" [37], which allows for the necessary post-authentication API calls (like `channels.list` and `liveBroadcasts.list`).[39, 40]
|
||||
|
||||
The chat _ingestion_ (reading messages) will be handled by the unauthenticated "scraper" method (see 2.2.B), which requires **no scopes at all**. This allows the platform to request minimal, "read-only" permissions, which vastly increases user trust.
|
||||
|
||||
#### Post-Authentication Channel Validation
|
||||
|
||||
Immediately after getting the token, the server must find the user's stable YouTube Channel ID.
|
||||
|
||||
- **API Call:** `GET https://www.googleapis.com/youtube/v3/channels?part=id&mine=true` [41, 42]
|
||||
|
||||
- **Headers:** `Authorization: Bearer <user_access_token>`
|
||||
|
||||
- **Quota Cost:** 1 Unit.[3, 41] This is a negligible, one-time cost.
|
||||
|
||||
|
||||
This request will return a JSON object containing the `channelId` for the authenticated user.[42] This `channelId` must be stored as the primary identifier for the user's YouTube account.
|
||||
|
||||
### 2.2. Critical Analysis: Real-time Chat Ingestion
|
||||
|
||||
This is the most critical design problem for the entire platform. The official API method is unworkable, necessitating an unofficial approach.
|
||||
|
||||
#### Method A: Official Data API v3 (`liveChatMessages.list`)
|
||||
|
||||
- **Mechanism:** A polling-based REST endpoint. The service would repeatedly call `liveChatMessages.list` with the `liveChatId` of an active stream.[43] New messages are retrieved by passing the `nextPageToken` from the previous response on the next poll.[44]
|
||||
|
||||
- **Rate Limits & Quota:** This method is **catastrophically non-viable** for a SaaS application.
|
||||
|
||||
- **Default Daily Quota:** 10,000 units per project.[1, 2, 3, 45]
|
||||
|
||||
- **Quota Cost:** A single call to `liveChatMessages.list` costs **5 quota units**.[4]
|
||||
|
||||
|
||||
#### Feasibility Analysis (The "Quota Burn" Calculation)
|
||||
|
||||
The following analysis demonstrates the non-viability of the official API for even a single user.
|
||||
|
||||
| | | |
|
||||
|---|---|---|
|
||||
|**Parameter**|**Value**|**Source**|
|
||||
|Default Daily Quota|10,000 units|[3]|
|
||||
|Cost of `liveChatMessages.list`|5 units / poll|[4]|
|
||||
|Total Polls Available (per day)|10,000 / 5 = **2,000 polls**||
|
||||
|Target Poll Rate (for low latency)|1 poll every 3 seconds|(Query)|
|
||||
|Polls per Minute|20||
|
||||
|Polls per Hour|1,200||
|
||||
|Time for **One User** to Exhaust **Entire 10k Quota**|2,000 polls / 20 polls/min = **100 minutes**||
|
||||
|
||||
**Conclusion:** A single user streaming for just over an hour and a half would exhaust the entire 10,000-unit quota for the _entire platform_, shutting down chat services for all other users.[4] This endpoint was not designed for real-time, high-frequency polling.
|
||||
|
||||
#### Method B: Unofficial/Scraping (The `pytchat` Method)
|
||||
|
||||
- **Mechanism:** This is the **only viable method**. This approach is not traditional HTML scraping (e.g., with BeautifulSoup), which `pytchat` explicitly avoids.[13, 46] Instead, this method involves reverse-engineering and mimicking the internal, undocumented JSON API that the YouTube web application itself uses to populate the chat window. This internal API is sometimes referred to as the "InnerTube" API.[10] The process involves:
|
||||
|
||||
1. An initial HTTP request to get a "continuation" token.
|
||||
|
||||
2. Subsequent HTTP `POST` requests to an internal endpoint (like `.../get_live_chat`) with the `video_id` and the latest "continuation" token.
|
||||
|
||||
3. The server responds with a JSON payload containing a list of new messages and the _next_ "continuation" token. The `pytchat` library [13] is a Python implementation of this reverse-engineered client.
|
||||
|
||||
- **Authentication:** **None required.** The client operates in an unauthenticated "visitor" state [10], identical to an anonymous user watching the stream in a browser. This is a massive architectural advantage, as it completely bypasses the OAuth requirement for ingestion.
|
||||
|
||||
- **Finding the Chat:** This method only requires the `video_id` of the live stream.[13] It does _not_ need the `liveChatId` from the official API.
|
||||
|
||||
- **Rate Limits and Risk:** This is the primary trade-off.
|
||||
|
||||
- **Risk 1: Mechanism Breakage:** Because this API is undocumented, Google can (and does) change the endpoint, the request parameters, or the JSON response structure at any time without warning.[10] This can instantly break the entire YouTube ingestion pipeline.
|
||||
|
||||
- **Risk 2: IP-Banning:** The rate limits are unknown and enforced by Google's anti-bot detection systems.[47] A single server IP making thousands of high-frequency polls (one for each active user) will be quickly identified as a bot, rate-limited, served CAPTCHAs, or permanently IP-banned.[48, 49]
|
||||
|
||||
- **Viability:** This is the only technically feasible method for low-latency, high-frequency, multi-tenant YouTube chat ingestion. The entire architecture must be designed to mitigate its inherent risks.
|
||||
|
||||
|
||||
### 2.3. The `liveChatId` / `video_id` Discovery Problem: A Quota-Free Solution
|
||||
|
||||
The `pytchat` method (2.2.B) requires a `video_id` to start. The official API methods for finding a channel's active `video_id` (`search.list`, `liveBroadcasts.list`) cost quota.[3, 50, 51]
|
||||
|
||||
Polling the official API _even for discovery_ is unviable at scale.
|
||||
|
||||
- `search.list` costs 100 units.[3] Polling this is impossible.
|
||||
|
||||
- `liveBroadcasts.list` costs 1 unit.[4] This _seems_ cheap, but polling this for 1,000 users every 2 minutes (to check _if_ they are live) would consume `(1,000 users * 30 polls/hr * 24 hr) = 720,000` units per day. This is 72 times the default 10k quota.
|
||||
|
||||
|
||||
Therefore, the discovery of the `video_id` must _also_ be a quota-free, "scraping" operation. This will be a **Two-Stage Scrape**:
|
||||
|
||||
1. **Stage 1: Low-Frequency "Live" Polling:** The service will run a low-frequency background worker (e.g., every 1-2 minutes) for each authenticated YouTube user. This worker will perform a simple `GET` request on the user's public channel page.
|
||||
|
||||
- `GET https://www.youtube.com/channel/<channel_id>`
|
||||
|
||||
- It will parse the returned HTML for a simple, unique string that indicates a live stream is in progress. Reliable indicators include the presence of a "live" thumbnail (e.g., `hqdefault_live.jpg` [52]) or, more reliably, the string `"text":" watching"`.[53]
|
||||
|
||||
- Alternatively, the worker can poll the channel's public RSS feed: `https://www.youtube.com/feeds/videos.xml?channel_id=<channel_id>`.[54, 55] A new entry in this feed often corresponds to a stream starting.
|
||||
|
||||
2. **Stage 2: `video_id` Extraction and Handoff:**
|
||||
|
||||
- Once the worker detects the "live" string, it knows a stream is active. It then performs a more detailed parse of the _same_ channel page HTML.
|
||||
|
||||
- The `video_id` is located within a large JSON blob embedded inside a `<script>` tag, assigned to a variable named `ytInitialData`.[56] The worker will parse this JSON to extract the `video_id` of the live stream.
|
||||
|
||||
- This `video_id` is then passed (e.g., via a Redis queue) to the high-frequency ingestion service, which will "spin up" a `pytchat` instance (Method B) for that `video_id`.
|
||||
|
||||
|
||||
This two-stage process allows the platform to discover active streams for thousands of users without consuming a single unit of API quota.
|
||||
|
||||
### 2.4. Service-Side Token Lifecycle Management
|
||||
|
||||
While the ingestion path is unauthenticated, the service still needs to manage tokens for the initial (and rare) official API calls, such as `channels.list`.
|
||||
|
||||
#### Refresh Logic
|
||||
|
||||
When an official API call returns a 401, the server must use the `refresh_token`.
|
||||
|
||||
- **API Call:** `POST https://www.googleapis.com/oauth2/v4/token` [35]
|
||||
|
||||
- **Headers:** `Content-Type: application/x-www-form-urlencoded`
|
||||
|
||||
- **Request Body (URL-encoded):**
|
||||
|
||||
- `client_id`: Your app's `client_id` [35]
|
||||
|
||||
- `client_secret`: Your app's `client_secret` [3T]
|
||||
|
||||
- `refresh_token`: The user's stored `refresh_token` [35]
|
||||
|
||||
- `grant_type`: Must be `refresh_token` [35]
|
||||
|
||||
|
||||
The server will respond with a JSON object containing a _new_ `access_token`.[35] Unlike Twitch, Google refresh tokens generally do not expire, so a new one is not typically issued.[36] The service must store the new `access_token`.
|
||||
|
||||
#### Revocation Logic
|
||||
|
||||
When a user disconnects their Google account, the server must revoke the token. Google's revocation endpoint is a simple `GET` request.
|
||||
|
||||
- **API Call:** `GET https://accounts.google.com/o/oauth2/revoke?token=<token_to_revoke>` [57]
|
||||
|
||||
- The `<token_to_revoke>` can be either the `access_token` or the `refresh_token`. Revoking the refresh token will invalidate the entire grant.
|
||||
|
||||
|
||||
This is simpler than Twitch's revocation, as it's a single `GET` request and does not require a `client_id` or `client_secret`.[57]
|
||||
|
||||
## Part 3: Synthesis and Recommended Architecture
|
||||
|
||||
### 3.1. The Definitive Hybrid Model
|
||||
|
||||
The analysis compels the adoption of a hybrid architecture. The following table provides the definitive model for the platform's authentication and ingestion stack.
|
||||
|
||||
| | | | |
|
||||
|---|---|---|---|
|
||||
|**Platform**|**Challenge**|**Recommended Method**|**Implementation Detail**|
|
||||
|**Twitch**|**Authentication**|Official OAuth 2.0 Authorization Code Flow [5]|`POST /oauth2/token` with `grant_type=authorization_code`|
|
||||
|**Twitch**|**Chat Ingestion**|**Twitch IRC** (via `twitchio`) [8]|`wss://irc-ws.chat.twitch.tv:443`. Auth with `PASS oauth:...` and `NICK...`. One connection per user.|
|
||||
|**YouTube**|**Authentication**|Official Google OAuth 2.0 Server-Side Flow [6]|`POST /oauth2/v4/token` with `grant_type=authorization_code` and `access_type=offline`.|
|
||||
|**YouTube**|**Chat Ingestion**|**Unofficial "InnerTube" API** (The `pytchat` method) [10, 13]|Unauthenticated. Polls internal `get_live_chat` endpoint using a `video_id` and "continuation" tokens.|
|
||||
|
||||
### 3.2. Primary Architectural Bottleneck and Mitigation
|
||||
|
||||
The single biggest technical bottleneck for this hybrid model is the **extreme fragility and platform risk of the YouTube ingestion method**.
|
||||
|
||||
The Twitch IRC protocol is stable, documented, and built to be scaled.[9, 27] It is a "solved problem."
|
||||
|
||||
The YouTube ingestion method, conversely, relies on a chain of three distinct, undocumented, and fragile reverse-engineered steps:
|
||||
|
||||
1. Scraping the channel HTML page for a "live" indicator string.[53]
|
||||
|
||||
2. Parsing an embedded `ytInitialData` JSON blob from that HTML [56] to find the `video_id`.
|
||||
|
||||
3. Calling an undocumented, internal API (`get_live_chat`) [10] with the correct parameters to get chat "continuation" tokens.[13]
|
||||
|
||||
|
||||
A change by Google to any of these three components—which can happen at any time without warning—will instantly break the platform's entire YouTube chat ingestion pipeline. Furthermore, the high-frequency polling from a central SaaS IP block creates a high risk of being programmatically identified as a bot and IP-banned.[47, 48, 49]
|
||||
|
||||
#### Mitigation Strategy
|
||||
|
||||
The architecture must be designed from the ground up to treat this fragility as a given.
|
||||
|
||||
- **IP Rotation:** All HTTP requests directed at YouTube (for both Stage 1/2 discovery and Stage 3 ingestion) **must not** originate directly from the service's own IP addresses. All requests must be routed through a **large, commercial-grade, rotating proxy pool**.[49] This distributes the load and makes it difficult for Google's anti-bot systems to identify the service's servers as a single entity.
|
||||
|
||||
- **User-Agent and Header Randomization:** Every request sent via the proxy pool must also mimic a real web browser by rotating its `User-Agent` string and other HTTP headers (e.g., `Accept-Language`, `Accept-Encoding`) from a large list of valid browser profiles.[58]
|
||||
|
||||
- **Circuit Breaker and Monitoring:** The ingestion service must have robust, real-time monitoring and a "circuit breaker" pattern. If the `get_live_chat` endpoint starts returning non-200 status codes, or if the `ytInitialData` JSON parsing fails, the system must:
|
||||
|
||||
1. Immediately stop polling for that stream (open the circuit) to avoid triggering a ban.
|
||||
|
||||
2. Trigger a high-priority alert to the on-call engineering team, who must be prepared to investigate and patch the scraper.
|
||||
|
||||
- **Library Maintenance:** Using a library like `pytchat` [13] offloads the initial maintenance, but the platform must be prepared to fork the library or write its own internal client if `pytchat` breaks and is not updated quickly.
|
||||
|
||||
|
||||
### 3.3. Data Flow Summary: A Single YouTube Chat Message
|
||||
|
||||
This is the complete data flow for a YouTube message, from inception to overlay, based on the recommended hybrid model.
|
||||
|
||||
**Context:**
|
||||
|
||||
- User "Streamer_A" has authenticated with the SaaS. The database contains their `channel_id` (from the one-time `channels.list?mine=true` call [42]).
|
||||
|
||||
- A low-frequency "live-check" worker is assigned to `Streamer_A`'s `channel_id`.
|
||||
|
||||
- A viewer, "Viewer_B," is watching the stream.
|
||||
|
||||
|
||||
**Data Flow:**
|
||||
|
||||
1. **** The "live-check" worker sends a `GET` request to `https://www.youtube.com/channel/Streamer_A_channel_id`. This request is routed through a rotating proxy [49] and has a randomized `User-Agent` header.
|
||||
|
||||
2. **** The worker receives the channel's HTML and scans it for the string `"text":" watching"`.[53] It finds the string, confirming the stream is live.
|
||||
|
||||
3. **** The worker now parses the _same_ HTML, finds the `<script>` tag containing `var ytInitialData = {...};`, and extracts the JSON blob.[56] It traverses this JSON to find the active `video_id` (e.g., `xyz123`).
|
||||
|
||||
4. **** The worker publishes a message to an internal queue (e.g., Redis or RabbitMQ) containing `{"platform": "youtube", "video_id": "xyz123", "user_id": "Streamer_A_internal_id"}`.
|
||||
|
||||
5. **** The high-frequency "Chat Ingestion" service consumes this message. It spawns a new asynchronous task (e.g., an `asyncio` coroutine) that instantiates a `pytchat` client [13] for `video_id` `xyz123`.
|
||||
|
||||
6. **** This new task begins its polling loop, sending HTTP `POST` requests to YouTube's internal `get_live_chat` API [10] every 3 seconds. These requests are _also_ routed through the rotating proxy pool.[49]
|
||||
|
||||
7. **[Viewer Action]** "Viewer_B" types "Hello!" into `Streamer_A`'s YouTube chat and hits send.
|
||||
|
||||
8. **** Within 3 seconds, the `pytchat` task's next poll to `get_live_chat` receives a JSON response from YouTube's "InnerTube" server. This JSON payload contains a list of new chat actions, including "Viewer_B"'s "Hello!" message.[13]
|
||||
|
||||
9. **** The `pytchat` client parses this JSON, extracts the message, author, timestamp, and other metadata, and yields a standardized Python object.
|
||||
|
||||
10. **** The ingestion service places this normalized message onto an internal bus (e.g., Redis pub/sub).
|
||||
|
||||
11. **** The main FastAPI server, which holds an active WebSocket connection to `Streamer_A`'s browser overlay, receives this message from the bus and immediately pushes it to the browser.
|
||||
|
||||
12. **** The "Hello!" message appears in `Streamer_A`'s chat overlay, having been ingested with low latency, at scale, and without consuming any official API quota.
|
||||
72
SETUP_AND_TESTING.md
Normal file
72
SETUP_AND_TESTING.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# MultiChat Overlay - Setup and Testing Instructions
|
||||
|
||||
This document provides instructions to set up and test the MultiChat Overlay application.
|
||||
|
||||
## 1. MySQL Database Setup
|
||||
|
||||
1. **Install MySQL:** Ensure you have MySQL server installed and running on your system.
|
||||
2. **Create Database:** Log in to your MySQL server and create a new database.
|
||||
```sql
|
||||
CREATE DATABASE multichat_overlay;
|
||||
```
|
||||
3. **Create User (Optional but Recommended):** Create a dedicated MySQL user for the application and grant it privileges to the `multichat_overlay` database.
|
||||
```sql
|
||||
CREATE USER 'multichat_user'@'localhost' IDENTIFIED BY 'your_secure_password';
|
||||
GRANT ALL PRIVILEGES ON multichat_overlay.* TO 'multichat_user'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
4. **Update `php/config.php`:** Open `/home/joe/MultiChatOverlay/php/config.php` and update the `DB_USER` and `DB_PASS` constants with your MySQL credentials.
|
||||
|
||||
## 2. PHP Development Server Setup
|
||||
|
||||
1. **Install PHP:** Ensure you have PHP installed on your system (PHP 7.4+ recommended).
|
||||
2. **Start Server:** Navigate to the `/home/joe/MultiChatOverlay/php` directory in your terminal and start the PHP built-in web server:
|
||||
```bash
|
||||
cd /home/joe/MultiChatOverlay/php
|
||||
php -S localhost:80 -t .
|
||||
```
|
||||
* **Note:** The `-t .` flag tells the server to use the current directory (`php/`) as the document root. This means your application will be accessible at `http://localhost/`.
|
||||
* If port 80 is already in use, you can choose another port (e.g., `php -S localhost:8000 -t .`) and update the `BASE_URL` in `php/config.php` accordingly.
|
||||
|
||||
## 3. Configure Twitch API Credentials and Application Secrets
|
||||
|
||||
**IMPORTANT:** Before proceeding, you must update the following values in `/home/joe/MultiChatOverlay/php/config.php`:
|
||||
|
||||
* `TWITCH_CLIENT_ID`: Your Twitch application's Client ID.
|
||||
* `TWITCH_CLIENT_SECRET`: Your Twitch application's Client Secret.
|
||||
* `SESSION_SECRET`: A very long, random, and secure string for session encryption. Generate this once and keep it consistent. **DO NOT use the placeholder value in production.**
|
||||
|
||||
1. **Create Twitch Application:** Go to the Twitch Developer Console (dev.twitch.tv/console/apps) and create a new application.
|
||||
* Set the **OAuth Redirect URLs** to `http://localhost/auth.php` for local development. When deploying to your external domain (e.g., `https://multichat.ramforth.net`), you must update this to `https://multichat.ramforth.net/auth.php` in both the Twitch Developer Console and `php/config.php`.
|
||||
2. **Get Client ID and Secret:** Note down your Client ID and generate a Client Secret.
|
||||
3. **Update `php/config.php`:** Open `/home/joe/MultiChatOverlay/php/config.php` and update the `TWITCH_CLIENT_ID`, `TWITCH_CLIENT_SECRET` constants with your application's credentials. Also, ensure `BASE_URL` is set correctly for your environment (e.g., `http://localhost` for local, `https://multichat.ramforth.net` for external).
|
||||
|
||||
## 4. Test the Application
|
||||
|
||||
1. **Ensure PHP Server is Running:** Make sure your PHP development server is running as described in step 2.
|
||||
2. **Access Application:** Open your web browser and navigate to `http://localhost/`.
|
||||
3. **Login with Twitch:**
|
||||
* Click on "Get Started" or navigate to `http://localhost/login`.
|
||||
* Click "Login with Twitch".
|
||||
* You will be redirected to Twitch for authorization. Ensure you grant the requested permissions (`user:read:email` and `chat:read`).
|
||||
* You should then be redirected to the dashboard (`http://localhost/dashboard`).
|
||||
4. **Verify Dashboard:**
|
||||
* Check if your Twitch username is displayed.
|
||||
* Verify that the "Twitch: Connected" status is shown.
|
||||
* Note the "Your Overlay URL" – it should now contain your `user_id`.
|
||||
5. **Test Twitch Chat Listener (Manual):**
|
||||
* Open a new terminal.
|
||||
* Navigate to the `/home/joe/MultiChatOverlay` directory.
|
||||
* Activate your Python virtual environment: `source python/venv/bin/activate`
|
||||
* Run the Twitch chat listener script, replacing placeholders with your actual values:
|
||||
```bash
|
||||
export TWITCH_CLIENT_ID="YOUR_TWITCH_CLIENT_ID"
|
||||
export TWITCH_CLIENT_SECRET="YOUR_TWITCH_CLIENT_SECRET"
|
||||
python python/twitch_chat_listener.py "YOUR_TWITCH_ACCESS_TOKEN" "YOUR_TWITCH_CHANNEL_NAME" "YOUR_USER_ID_FROM_DASHBOARD"
|
||||
```
|
||||
* **Note:** You will need to manually retrieve your `TWITCH_ACCESS_TOKEN` from the MySQL database for now (e.g., by querying the `users` table).
|
||||
* `YOUR_TWITCH_CHANNEL_NAME` is your Twitch username.
|
||||
* `YOUR_USER_ID_FROM_DASHBOARD` is the `user_id` displayed in your overlay URL on the dashboard.
|
||||
* Send a message in your Twitch channel. You should see the chat message printed in the terminal where `twitch_chat_listener.py` is running.
|
||||
|
||||
This completes the basic setup and verification of the Twitch login and chat listener. The next step will be to integrate the Node.js WebSocket server.
|
||||
69
TASKS.md
Normal file
69
TASKS.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Project Task List
|
||||
|
||||
This file tracks all active development tasks. It is based on the official `DEVELOPMENT_PLAN.md`.
|
||||
|
||||
## 📋 How to Use This List
|
||||
|
||||
1. Find a task in the "To Do" section that you want to work on.
|
||||
2. Add your name next to it (e.g., `[ ] Task Name - @YourName`).
|
||||
3. When you start, move it to "In Progress" and follow the `CONTRIBUTING.md` workflow.
|
||||
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) )
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Phase 1: User Authentication & Database
|
||||
|
||||
* **Goal:** Get the basic API, database, and Twitch login flow working.
|
||||
|
||||
### To Do
|
||||
|
||||
### Done
|
||||
* `[✔️]` **1.0: Project Skeleton** - @ramforth
|
||||
* *Task:* Setup `main.py`, `requirements.txt`, and `.gitignore`.
|
||||
* `[✔️]` **1.1: Database Schema:** Define SQLAlchemy models for `User` (id, username, platform, encrypted_tokens) and `Settings`. @ramforth
|
||||
* `[✔️]` **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. Now that Phase 1 is done, these tasks are ready to be worked on.
|
||||
|
||||
### To Do
|
||||
|
||||
* `[ ]` **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: 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
|
||||
|
||||
---
|
||||
### 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
|
||||
|
||||
* `[ ]` Implement YouTube OAuth & `pytchat` listener (Phase 4).
|
||||
* `[ ]` "Single Message Focus" feature (Issue #1).
|
||||
* `[ ]` Moderator panels (Issue #2).
|
||||
120
auth.py
Normal file
120
auth.py
Normal file
@@ -0,0 +1,120 @@
|
||||
import httpx
|
||||
import secrets
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from config import settings
|
||||
from database import SessionLocal
|
||||
import models
|
||||
import security
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Dependency to get a DB session
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.get("/login/twitch")
|
||||
async def login_with_twitch(request: Request):
|
||||
"""
|
||||
Step 1 of OAuth flow: Redirect the user to Twitch's authorization page.
|
||||
"""
|
||||
# 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"
|
||||
|
||||
# Construct the authorization URL
|
||||
auth_url = (
|
||||
f"https://id.twitch.tv/oauth2/authorize"
|
||||
f"?response_type=code"
|
||||
f"&client_id={settings.TWITCH_CLIENT_ID}"
|
||||
f"&redirect_uri={settings.APP_BASE_URL}/auth/twitch/callback"
|
||||
f"&scope={scopes}"
|
||||
f"&state={state}"
|
||||
)
|
||||
return RedirectResponse(url=auth_url)
|
||||
|
||||
@router.get("/auth/twitch/callback")
|
||||
async def auth_twitch_callback(code: str, state: str, request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Step 2 of OAuth flow: Handle the callback from Twitch after user authorization.
|
||||
"""
|
||||
# CSRF Protection: Validate the state
|
||||
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
|
||||
token_url = "https://id.twitch.tv/oauth2/token"
|
||||
token_data = {
|
||||
"client_id": settings.TWITCH_CLIENT_ID,
|
||||
"client_secret": settings.TWITCH_CLIENT_SECRET,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": f"{settings.APP_BASE_URL}/auth/twitch/callback",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_response = await client.post(token_url, data=token_data)
|
||||
if token_response.status_code != 200:
|
||||
raise HTTPException(status_code=400, detail="Failed to retrieve access token from Twitch.")
|
||||
|
||||
token_json = token_response.json()
|
||||
access_token = token_json["access_token"]
|
||||
refresh_token = token_json["refresh_token"]
|
||||
|
||||
# Step 5: Validate the user and get their details from Twitch API
|
||||
users_url = "https://api.twitch.tv/helix/users"
|
||||
headers = {
|
||||
"Client-ID": settings.TWITCH_CLIENT_ID,
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
}
|
||||
user_response = await client.get(users_url, headers=headers)
|
||||
user_data = user_response.json()["data"][0]
|
||||
|
||||
# Encrypt the tokens for storage
|
||||
encrypted_tokens = security.encrypt_tokens(access_token, refresh_token)
|
||||
|
||||
# --- Database Upsert Logic ---
|
||||
# Check if the user already exists in our database
|
||||
user = db.query(models.User).filter(models.User.platform_user_id == user_data['id']).first()
|
||||
|
||||
if user:
|
||||
# If user exists, update their details
|
||||
user.username = user_data['login']
|
||||
user.encrypted_tokens = encrypted_tokens
|
||||
else:
|
||||
# If user does not exist, create a new record
|
||||
user = models.User(
|
||||
platform_user_id=user_data['id'],
|
||||
username=user_data['login'],
|
||||
platform="twitch",
|
||||
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()
|
||||
|
||||
# Create a session for the user by storing their database ID.
|
||||
request.session['user_id'] = user.id
|
||||
|
||||
# Redirect to a future dashboard page for a better user experience
|
||||
# This prepares us for Task 1.4 (Session Management) and Task 2.1 (Dashboard UI)
|
||||
return RedirectResponse(url="/dashboard")
|
||||
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}.")
|
||||
18
config.py
Normal file
18
config.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
class Settings:
|
||||
"""
|
||||
A simple class to hold all application settings, loaded from environment variables.
|
||||
"""
|
||||
ENCRYPTION_KEY: str = os.getenv("ENCRYPTION_KEY")
|
||||
TWITCH_CLIENT_ID: str = os.getenv("TWITCH_CLIENT_ID")
|
||||
TWITCH_CLIENT_SECRET: str = os.getenv("TWITCH_CLIENT_SECRET")
|
||||
|
||||
# The full URL where our app is running, needed for the redirect_uri
|
||||
APP_BASE_URL: str = os.getenv("APP_BASE_URL", "http://localhost:8000")
|
||||
|
||||
settings = Settings()
|
||||
19
database.py
Normal file
19
database.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# Define the location of our SQLite database file.
|
||||
# The ./. indicates it will be in the same directory as our project.
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///./multichat_overlay.db"
|
||||
|
||||
# Create the SQLAlchemy engine. The `connect_args` is needed only for SQLite
|
||||
# to allow it to be used by multiple threads, which FastAPI does.
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||
)
|
||||
|
||||
# Each instance of SessionLocal will be a database session.
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# This Base will be used by our model classes to inherit from.
|
||||
Base = declarative_base()
|
||||
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.")
|
||||
211
main.py
Normal file
211
main.py
Normal file
@@ -0,0 +1,211 @@
|
||||
import os
|
||||
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, 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
|
||||
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
|
||||
logger.info("Application startup: Creating database tables...")
|
||||
app.state.websocket_manager = WebSocketManager()
|
||||
app.state.listener_manager = ListenerManager()
|
||||
models.Base.metadata.create_all(bind=engine)
|
||||
logger.info("Application startup: Database tables created.")
|
||||
|
||||
# Decouple listener startup from the main application startup
|
||||
asyncio.create_task(background_listener_startup(app))
|
||||
yield
|
||||
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
|
||||
# --- Template Dependency ---
|
||||
def get_templates():
|
||||
return Jinja2Templates(directory=TEMPLATES_DIR)
|
||||
|
||||
@app.get("/")
|
||||
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),
|
||||
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:
|
||||
# If not, redirect them to the login page.
|
||||
return RedirectResponse(url="/")
|
||||
|
||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||
overlay_url = f"{settings.APP_BASE_URL}/overlay/{user.id}"
|
||||
|
||||
# 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.")
|
||||
39
models.py
Normal file
39
models.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from database import Base
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
# The user's unique ID from the platform (e.g., Twitch ID, YouTube Channel ID)
|
||||
platform_user_id = Column(String, unique=True, index=True, nullable=False)
|
||||
username = Column(String, index=True, nullable=False)
|
||||
platform = Column(String, nullable=False) # e.g., "twitch", "youtube"
|
||||
|
||||
# A JSON string or other format holding the encrypted access and refresh tokens
|
||||
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")
|
||||
19
php/auth/twitch/login.php
Normal file
19
php/auth/twitch/login.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
// php/auth/twitch/login.php - Initiates Twitch OAuth flow
|
||||
|
||||
require_once '../../config.php';
|
||||
|
||||
// Scopes required to get user's email and channel info
|
||||
$scopes = "user:read:email chat:read"; // Ensure chat:read is included
|
||||
|
||||
$auth_url = (
|
||||
"https://id.twitch.tv/oauth2/authorize" .
|
||||
"?client_id=" . TWITCH_CLIENT_ID .
|
||||
"&redirect_uri=" . TWITCH_REDIRECT_URI .
|
||||
"&response_type=code" .
|
||||
"&scope=" . urlencode($scopes)
|
||||
);
|
||||
|
||||
header("Location: " . $auth_url);
|
||||
exit();
|
||||
?>
|
||||
0
php/config.php
Normal file
0
php/config.php
Normal file
11
requirements.txt
Normal file
11
requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
sqlalchemy
|
||||
httpx
|
||||
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
|
||||
47
security.py
Normal file
47
security.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import os
|
||||
import json
|
||||
from cryptography.fernet import Fernet
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from a .env file for local development
|
||||
load_dotenv()
|
||||
|
||||
def _get_fernet_instance() -> Fernet:
|
||||
"""
|
||||
Helper function to get the Fernet instance.
|
||||
This ensures the key is checked only when encryption/decryption is needed.
|
||||
"""
|
||||
# It is CRITICAL that this key is set in your environment and kept secret.
|
||||
# It should be a 32-url-safe-base64-encoded key.
|
||||
encryption_key = os.getenv("ENCRYPTION_KEY")
|
||||
|
||||
if not encryption_key:
|
||||
raise ValueError("ENCRYPTION_KEY is not set in the environment. Please generate a key and add it to your .env file.")
|
||||
|
||||
# Ensure the key is in bytes for the Fernet instance
|
||||
return Fernet(encryption_key.encode())
|
||||
|
||||
def encrypt_tokens(access_token: str, refresh_token: str) -> str:
|
||||
"""
|
||||
Combines access and refresh tokens into a JSON object, then encrypts it.
|
||||
"""
|
||||
fernet = _get_fernet_instance()
|
||||
tokens = {"access_token": access_token, "refresh_token": refresh_token}
|
||||
tokens_json_string = json.dumps(tokens)
|
||||
encrypted_data = fernet.encrypt(tokens_json_string.encode())
|
||||
return encrypted_data.decode()
|
||||
|
||||
def decrypt_tokens(encrypted_data_str: str) -> dict:
|
||||
"""
|
||||
Decrypts the token string back into a dictionary of tokens.
|
||||
"""
|
||||
fernet = _get_fernet_instance()
|
||||
decrypted_data_bytes = fernet.decrypt(encrypted_data_str.encode())
|
||||
tokens_json_string = decrypted_data_bytes.decode()
|
||||
return json.loads(tokens_json_string)
|
||||
|
||||
def generate_key():
|
||||
"""
|
||||
Utility function to generate a new encryption key. Run this once.
|
||||
"""
|
||||
return Fernet.generate_key().decode()
|
||||
1
static/.gitkeep
Normal file
1
static/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# This file ensures the 'static' directory is tracked by Git.
|
||||
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 */
|
||||
}
|
||||
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 |
21
static/login.html
Normal file
21
static/login.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - 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>Welcome to MultiChatOverlay</h1>
|
||||
<p>Connect your streaming accounts to get started.</p>
|
||||
<a href="/login/twitch" class="twitch-btn">Login with Twitch</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
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 */
|
||||
}
|
||||
77
static/style.css
Normal file
77
static/style.css
Normal file
@@ -0,0 +1,77 @@
|
||||
body {
|
||||
/* Use a very dark grey background for contrast, making the container pop */
|
||||
font-family: 'Inter', sans-serif;
|
||||
/* Suggest a modern font (requires import) */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
/* Use min-height for responsiveness */
|
||||
background-color: #0d1117;
|
||||
/* Dark Mode base color */
|
||||
background-image: linear-gradient(135deg, #0d1117 0%, #161b22 100%);
|
||||
/* Subtle gradient */
|
||||
margin: 0;
|
||||
color: #e6edf3;
|
||||
/* Light text color for contrast */
|
||||
}
|
||||
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 50px;
|
||||
/* Slightly more padding */
|
||||
background-color: #161b22;
|
||||
/* Lighter dark-mode color for the box */
|
||||
border-radius: 12px;
|
||||
/* Smoother corners */
|
||||
|
||||
/* Modern, subtle layered shadows for depth */
|
||||
box-shadow:
|
||||
0 4px 15px rgba(0, 0, 0, 0.4),
|
||||
/* Primary shadow */
|
||||
0 10px 30px rgba(0, 0, 0, 0.7);
|
||||
/* Deep, soft shadow */
|
||||
|
||||
/* Optional: Small border for definition */
|
||||
border: 1px solid #30363d;
|
||||
|
||||
/* Slightly increase size */
|
||||
max-width: 380px;
|
||||
width: 90%;
|
||||
}
|
||||
.twitch-btn {
|
||||
display: inline-flex;
|
||||
/* Use flex for easy icon alignment if you add one */
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
/* Use a slightly brighter, but still core Twitch purple */
|
||||
background-color: #9146FF;
|
||||
color: white;
|
||||
padding: 12px 28px;
|
||||
/* Slightly larger padding */
|
||||
border-radius: 8px;
|
||||
/* Smoother corners */
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
/* Medium bold */
|
||||
letter-spacing: 0.5px;
|
||||
/* Better readability */
|
||||
transition: all 0.3s ease;
|
||||
/* Enable smooth transitions */
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
/* Subtle inner shadow for 'pressed' look */
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Add a sleek hover effect */
|
||||
.twitch-btn:hover {
|
||||
background-color: #772ce8;
|
||||
/* Slightly darker purple on hover */
|
||||
transform: translateY(-2px);
|
||||
/* Lift the button slightly */
|
||||
box-shadow: 0 6px 12px rgba(145, 70, 255, 0.3);
|
||||
/* Glow effect on hover */
|
||||
}
|
||||
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