Compare commits
171 Commits
cdc5c32429
...
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 |
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,39 +8,46 @@ The goal is to create a service where streamers can log in using their platform
|
|||||||
|
|
||||||
## 2. Technology Stack
|
## 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).
|
* **Team Communications:** Discord and Nextcloud, primarily. This can change. There's a list of links in the [README.md](README.md)
|
||||||
* **Database:** MySQL
|
* **Backend:** Python 3.13+ (FastAPI)
|
||||||
* **Frontend:** HTML, CSS, JavaScript
|
* **Database:** SQLite (for initial development) with SQLAlchemy ORM
|
||||||
|
* **Frontend:** Vanilla HTML, CSS, and JavaScript
|
||||||
|
* **Chat Listeners:** `twitchio` (Twitch), `pytchat` (YouTube)
|
||||||
|
|
||||||
## 3. Implementation Roadmap
|
## 3. Implementation Roadmap
|
||||||
|
|
||||||
### Phase 1: Basic Setup & Twitch Chat Listener (Python)
|
### Phase 1: User Authentication & Database (FastAPI)
|
||||||
1. **Project Structure:** Establish a clear directory structure for PHP, Python, Node.js, and static assets.
|
**Status: ✔️ Complete**
|
||||||
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 2: MySQL Database & User Management (PHP)
|
1. **Project Skeleton:** Establish the core FastAPI application structure, dependencies, and version control.
|
||||||
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. **Database Schema:** Define the data models for users and settings using SQLAlchemy.
|
||||||
2. **PHP Web Server:** Configure a basic PHP web server.
|
3. **Twitch OAuth2:** Implement the server-side OAuth2 flow within FastAPI to authenticate users and securely store encrypted tokens in the database.
|
||||||
3. **User Registration/Login (PHP):** Implement PHP scripts for user registration and login, integrating with the MySQL database.
|
4. **Session Management:** Create a system to manage logged-in user sessions.
|
||||||
4. **Dashboard (PHP/HTML):** Create a basic dashboard where logged-in users can see their Twitch connection status and their unique overlay URL.
|
5. **Basic Frontend:** Develop a simple login page.
|
||||||
|
|
||||||
### Phase 3: WebSocket Server (Node.js) & Overlay (HTML/CSS/JS)
|
### Phase 2: User Dashboard & Configuration
|
||||||
1. **Node.js Environment:** Set up a Node.js environment and install `ws` (WebSocket library).
|
**Status: ✔️ Complete**
|
||||||
2. **WebSocket Server (Node.js):** Create a Node.js WebSocket server that:
|
|
||||||
* Accepts connections from overlay clients.
|
1. **Dashboard UI:** Create a dashboard page accessible only to authenticated users.
|
||||||
* 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).
|
2. **Settings API:** Build API endpoints for users to save and retrieve their overlay settings.
|
||||||
* Broadcasts messages to connected overlay clients.
|
3. **Overlay URL Generation:** Display a unique, persistent overlay URL for each user.
|
||||||
3. **Overlay Frontend (HTML/CSS/JS):** Create a basic `overlay.html` that:
|
4. **Theming System:** Implement a site-wide light/dark theme switcher.
|
||||||
* Connects to the Node.js WebSocket server.
|
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.
|
||||||
* Displays incoming chat messages.
|
6. **Help & Documentation:** Add a help page to guide users in creating their custom CSS.
|
||||||
4. **Inter-process Communication:** Implement a mechanism for the Python Twitch listener to send messages to the Node.js WebSocket server.
|
|
||||||
|
### 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
|
### 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.
|
**Status: ⏳ Not Started**
|
||||||
2. **YouTube Integration:** Add YouTube chat listening capabilities (Python `pytchat`) and integrate with the existing system. This will be implemented after the core Twitch functionality is stable.
|
|
||||||
3. **Advanced Overlay Customization:** Implement options for users to customize their overlay.
|
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)
|
## 4. Requirements for Completion (Initial Version)
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
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")
|
||||||
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