Compare commits

...

33 Commits

Author SHA1 Message Date
ba480e8409 Working on: The Twitch authentication 2025-12-25 21:22:24 +01:00
e69d423deb Working on: The Twitch authentication 2025-12-25 21:05:11 +01:00
773288faf0 Working on: The Twitch authentication 2025-12-25 18:40:54 +01:00
7a18b5b402 Working on: The Twitch authentication 2025-12-25 18:39:14 +01:00
cfc082a6f8 Working on: The Twitch authentication 2025-12-25 18:34:44 +01:00
8521510215 Working on: The Twitch authentication 2025-12-25 18:22:46 +01:00
4b640861a6 rftdgsh 2025-11-18 01:57:12 +01:00
7748e55a71 sdfghb 2025-11-18 01:53:39 +01:00
edfe38113a edrsfx 2025-11-18 01:51:49 +01:00
c249010cbd ergqa 2025-11-18 01:50:12 +01:00
1650f9343e ruining a good night's sleep 2025-11-18 01:48:36 +01:00
12c69f4797 oauth token mis-read 2025-11-18 01:47:39 +01:00
845e74bd5e ssl back on 2025-11-18 01:45:53 +01:00
1c93a09c74 dsf 2025-11-18 01:43:43 +01:00
53c1966494 fucked 2025-11-18 01:40:58 +01:00
303c8430e8 re-formatting client_ID and client__Secret 2025-11-18 01:37:17 +01:00
afa27271fa Swapped twitch connector twitchio.Client 2025-11-18 01:33:19 +01:00
45a511b8e9 debug text in logs 2025-11-18 01:18:54 +01:00
67ae5065f5 reverted bot_id 2025-11-18 01:17:27 +01:00
8abb76a55b Later bot start 2025-11-18 01:14:42 +01:00
555ba5a2d0 garrr! 2025-11-18 01:13:08 +01:00
c6450bc7be test3 on the listener 2025-11-18 01:11:18 +01:00
9eb614b9a3 Incorrectly passed user_id corrected to bot_id and id=bot_id 2025-11-18 01:09:22 +01:00
c52560aaae repairing listener 2025-11-18 01:01:46 +01:00
aea75918b2 Trying to bring chat messages past proxy by ensuring SSL traffic 2025-11-18 00:58:11 +01:00
a0877d8276 proxy handling of css 2025-11-18 00:55:32 +01:00
4b96b17368 css moar correction 2025-11-18 00:53:12 +01:00
e74553a482 Trying to bring back default css 2025-11-18 00:49:06 +01:00
2fffb4318a -- 2025-11-18 00:45:51 +01:00
08526cfa60 Re-arranged call of static/ 2025-11-18 00:44:01 +01:00
2392d21e17 Small correction in main.py 2025-11-18 00:39:41 +01:00
3f2f0fcb4e eod: status 2025-11-17 19:08:36 +01:00
036e9da25e asyncio improvement 2025-11-17 18:43:32 +01:00
9 changed files with 378 additions and 115 deletions

View File

@@ -25,19 +25,11 @@ My core instructions are:
The objective is to build a multi-platform chat overlay SaaS (Software as a Service) for streamers. The service will aggregate chat from Twitch and YouTube into a single, customizable browser source for use in streaming software like OBS. 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 ### Current Status
The project is in **Phase 1: User Authentication & Database**. Most of this phase is complete. **Phases 1, 2, and 3 are complete.** The application is now a fully functional chat overlay service for Twitch.
* A solid FastAPI application skeleton is in place. * **Phase 1 (Authentication):** A secure Twitch OAuth2 flow is implemented, with user data and encrypted tokens stored in a SQLite database.
* The database schema (`User`, `Setting` models) is defined using SQLAlchemy and 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.
* A secure Twitch OAuth2 authentication flow is fully functional. It correctly: * **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.
1. Redirects users to Twitch.
2. Handles the callback.
3. Exchanges the authorization code for tokens.
4. Fetches user details from the Twitch API.
5. Encrypts the tokens using the `cryptography` library.
6. Saves or updates the user's record in the database.
* A basic HTML login page is served at the root URL (`/`). * A basic HTML login page is served at the root URL (`/`).
* Configuration and secrets are managed securely via a `config.py` file that reads from a `.env` file.
### Core Architecture ### Core Architecture
The project is built on the "hybrid architecture" detailed in the `RESEARCH_REPORT.md`: 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. * **Authentication:** Always use the official, secure OAuth2 flows for each platform.
@@ -48,13 +40,6 @@ The project is built on the "hybrid architecture" detailed in the `RESEARCH_REPO
Based on the `TASKS.md` file, the only remaining task for Phase 1 is: 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. * **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.
### Dashboard Progress (Phase 2)
We have started work on Phase 2, focusing on the user dashboard. The following features have been implemented on the `feature/dashboard-ui` branch:
* **Dynamic Dashboard:** The dashboard now uses the Jinja2 templating engine to dynamically display the logged-in user's username and their unique overlay URL.
* **Theme Selection:** A theme selection dropdown has been added to the dashboard, allowing users to choose between a "Dark Purple" and a "Bright Green" overlay theme.
* **Settings API:** A `/api/settings` endpoint has been created. This endpoint handles saving the user's chosen theme to the database.
* **Dynamic Overlay Theming:** The `/overlay/{user_id}` endpoint now dynamically loads the correct HTML template based on the user's saved theme preference, providing a personalized overlay.
## References: ## References:
### Development plan ### Development plan
@@ -137,7 +122,7 @@ This file tracks all active development tasks. It is based on the official `DEVE
4. When your Pull Request is *merged*, move it to "Done." 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: If you want to use emojis for visibility, here's some I have used:
✔️ - Done | 🧑‍🔧 - In progress | ↗️ - Task evolved (should correspond with an edit in the [DEVELOPMENT_PLAN.md](DEVELOPMENT_PLAN.md) ✔️ - Done | 🧑‍🔧 - In progress | ↗️ - Task evolved (should correspond with an edit in the [DEVELOPMENT_PLAN.md](DEVELOPMENT_PLAN.md) )
--- ---
@@ -147,10 +132,6 @@ If you want to use emojis for visibility, here's some I have used:
### To Do ### To Do
* `[ ]` **1.4: Basic Session Management:** Create a simple session/JWT system to know *who* is logged in.
### In Progress
### Done ### Done
* `[✔️]` **1.0: Project Skeleton** - @ramforth * `[✔️]` **1.0: Project Skeleton** - @ramforth
* *Task:* Setup `main.py`, `requirements.txt`, and `.gitignore`. * *Task:* Setup `main.py`, `requirements.txt`, and `.gitignore`.
@@ -158,39 +139,44 @@ If you want to use emojis for visibility, here's some I have used:
* `[✔️]` **1.1.5: Discord Overview:** Create an automated 'TASK-LIST' and post to Discord whenever someone pushes a change to the repository. @ramforth * `[✔️]` **1.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.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.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 * `[✔️]` **1.5: Login Frontend:** Create a basic `login.html` file with a "Login with Twitch" button. @ramforth
--- ---
## ⏳ Phase 2: User Dashboard & Configuration ## ⏳ Phase 2: User Dashboard & Configuration
* **Goal:** Allow logged-in users to see a dashboard, get their overlay URL, and save settings. * **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.
* *(All tasks for this phase are on hold until Phase 1 is complete)*
### To Do ### To Do
* `[ ]` **2.1: Dashboard UI:** Create `dashboard.html` (only for logged-in users).
* `[ ]` **2.2: Config API:** Create API endpoints (`GET`, `POST`) for `/api/settings` to save/load user preferences (e.g., custom CSS). * `[ ]` **2.4: Create Logo and Favicon:** The project should have a logo and a favicon.
* `[ ]` **2.3: Overlay URL:** Generate and display the unique overlay URL for the user (e.g., `/overlay/{user_id}`).
### Done
* `[✔️]` **2.0: CSS Refactor & Styling:** Improved the general look and feel of the application pages, including a light/dark theme switcher.
* `[✔️]` **2.1: Dashboard UI:** Created `dashboard.html` for logged-in users to manage settings.
* `[✔️]` **2.2: Config API:** Created API endpoints for `/api/settings` to save user preferences.
* `[✔️]` **2.3: Overlay URL:** Generated and displayed the unique overlay URL for the user on the dashboard.
* `[✔️]` **2.5: Custom CSS Themes:** Implemented a system for users to create, preview, and delete their own private CSS overlay themes.
* `[✔️]` **2.6: CSS Help Page:** Created a guide for users on how to write custom CSS for their overlays.
--- ---
## 💬 Phase 3: Dynamic Listeners & Basic Overlay ## 💬 Phase 3: Real-time Chat & Overlay
* **Goal:** The core magic. Start chat listeners for users and show messages in the overlay. * **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)* * *(All tasks for this phase are on hold until Phase 2 is complete)*
### To Do ### To Do
* `[ ]` **3.1: Dynamic Listener Manager (The Big One):** Design a system (e.g., background service) to start/stop listener processes for users.
* `[ ]` **3.2: User-Specific Broadcasting:** Update the WebSocket system to use "rooms" (e.g., `/ws/{user_id}`) so users only get their *own* chat.
* `[ ]` **3.3: Basic Overlay UI:** Create the `overlay.html` page that connects to the WebSocket and displays messages.
--- ---
### Done
* `[✔️]` **3.1: Dynamic Listener Manager:** Designed and implemented a system to start/stop listener processes for users on application startup/shutdown.
* `[✔️]` **3.2: User-Specific Broadcasting:** Implemented a WebSocket manager and endpoint (`/ws/{user_id}`) to broadcast messages to the correct user's overlay.
* `[✔️]` **3.3: Basic Overlay UI:** Created dynamic overlay templates that connect to the WebSocket and display incoming chat messages.
## 💡 Backlog & Future Features ## 💡 Backlog & Future Features
* *(Tasks from Phase 4, Gitea Issues, etc., will be added here as we go)*
* `[ ]` Implement YouTube OAuth & `pytchat` listener (Phase 4). * `[ ]` Implement YouTube OAuth & `pytchat` listener (Phase 4).
* `[ ]` "Single Message Focus" feature (Issue #1). * `[ ]` "Single Message Focus" feature (Issue #1).
* `[ ]` Moderator panels (Issue #2). * `[ ]` Moderator panels (Issue #2).
* `[ ]` Custom CSS storage & injection (Issue #6).
``` ```

61
FAILED_APPROACH_6.md Normal file
View 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.

View File

@@ -1,5 +1,6 @@
import httpx import httpx
import secrets import secrets
import logging
from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -11,6 +12,9 @@ import security
router = APIRouter() router = APIRouter()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Dependency to get a DB session # Dependency to get a DB session
def get_db(): def get_db():
db = SessionLocal() db = SessionLocal()
@@ -27,6 +31,7 @@ async def login_with_twitch(request: Request):
# Generate a random state token for CSRF protection # Generate a random state token for CSRF protection
state = secrets.token_urlsafe(16) state = secrets.token_urlsafe(16)
request.session['oauth_state'] = state 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 # As per RESEARCH_REPORT.md, these are the minimum required scopes
scopes = "chat:read" scopes = "chat:read"
@@ -48,7 +53,9 @@ async def auth_twitch_callback(code: str, state: str, request: Request, db: Sess
Step 2 of OAuth flow: Handle the callback from Twitch after user authorization. Step 2 of OAuth flow: Handle the callback from Twitch after user authorization.
""" """
# CSRF Protection: Validate the state # CSRF Protection: Validate the state
if state != request.session.pop('oauth_state', None): session_state = request.session.pop('oauth_state', None)
if state != session_state:
logger.error(f"OAuth state mismatch! Received state: '{state}', Session state: '{session_state}'")
raise HTTPException(status_code=403, detail="Invalid state parameter. CSRF attack suspected.") raise HTTPException(status_code=403, detail="Invalid state parameter. CSRF attack suspected.")
# Step 4: Exchange the authorization code for an access token # Step 4: Exchange the authorization code for an access token

View File

@@ -1,46 +1,63 @@
from twitchio.ext import commands import logging
import twitchio
from sqlalchemy.orm import Session
from database import SessionLocal
import models
import security
class NullAdapter: logger = logging.getLogger(__name__)
"""A dummy web adapter that does nothing to prevent twitchio from starting a webserver."""
async def start(self):
pass
async def stop(self): class TwitchBot(twitchio.Client):
pass def __init__(self, websocket_manager, db_user_id: int):
class TwitchBot(commands.Bot):
def __init__(self, access_token: str, client_id: str, client_secret: str, bot_id: str, channel_name: str, websocket_manager, db_user_id: int):
self.websocket_manager = websocket_manager self.websocket_manager = websocket_manager
# Store our application's database user ID to avoid conflict with twitchio's internal 'owner_id' # Store our application's database user ID to avoid conflict with twitchio's internal 'owner_id'
self.db_user_id = db_user_id self.db_user_id = db_user_id
self.is_initialized = False # Health check flag
# Force the bot to use our dummy adapter that does nothing. async def start(self, access_token: str, refresh_token: str, client_id: str, client_secret: str, channel_name: str):
# This is the definitive fix to prevent any port conflicts. """
adapter = NullAdapter() 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}...")
super().__init__( # The sensitive __init__ call is now inside the awaitable task.
token=access_token, # FIX: Do not pass client_secret/refresh_token to super().__init__.
web_server_adapter=adapter, # This prevents twitchio from starting its internal web server (AiohttpAdapter) on port 4343,
eventsub_url=None, # Explicitly disable EventSub # which causes "Address already in use" errors when multiple bots run.
prefix='!', # A prefix is required but won't be used for reading chat super().__init__(token=access_token, client_id=client_id, initial_channels=[channel_name], ssl=True)
initial_channels=[channel_name],
# These are required by twitchio for authentication
client_id=client_id,
client_secret=client_secret,
# The 'bot_id' is the Twitch ID of the user account the bot is running as
id=bot_id
)
self.channel_name = channel_name 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): async def event_ready(self):
"""Called once when the bot goes online.""" """Called once when the bot goes online."""
print(f"Listener ready for #{self.channel_name}") # 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_message(self, message): 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.""" """Runs every time a message is sent in chat."""
# This is CRITICAL when overriding a listener in commands.Bot # Diagnostic Logging: Checkpoint 1 - A raw message is received from Twitch.
# to ensure the bot can still process commands if any are added. logger.info(f"DIAGNOSTIC: Message received for user {self.db_user_id} in channel {self.channel_name}: '{message.content}'")
await super().event_message(message)
# Ignore messages sent by the bot itself to prevent loops. # Ignore messages sent by the bot itself to prevent loops.
if message.echo: if message.echo:
@@ -48,12 +65,16 @@ class TwitchBot(commands.Bot):
# Prepare the message data to be sent to the frontend # Prepare the message data to be sent to the frontend
chat_data = { chat_data = {
"author": message.author.name, "author": message.author.name if message.author else "Twitch",
"text": message.content, "text": message.content,
"platform": "twitch" "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 # Broadcast the message to the specific user's overlay
# We need the user's ID to know which WebSocket connection to send to. # We need the user's ID to know which WebSocket connection to send to.
user_id = self.db_user_id user_id = self.db_user_id
await self.websocket_manager.broadcast_to_user(user_id, chat_data) 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}.")

75
filename Normal file
View 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.")

View File

@@ -1,51 +1,79 @@
import asyncio import asyncio
import logging
from typing import Dict from typing import Dict
from chat_listener import TwitchBot from chat_listener import TwitchBot
import security # To decrypt tokens import security # To decrypt tokens
from config import settings # To get client_id and client_secret from config import settings # To get client_id and client_secret
logger = logging.getLogger(__name__)
class ListenerManager: class ListenerManager:
def __init__(self): def __init__(self):
# This dictionary will hold our running listener tasks. # This dictionary will hold our running listener tasks.
# The key will be the user_id and the value will be the asyncio.Task. # The key will be the user_id and the value will be the asyncio.Task.
self.active_listeners: Dict[int, asyncio.Task] = {} self.active_listeners: Dict[int, Dict] = {}
print("ListenerManager initialized.") logger.info("ListenerManager initialized.")
async def start_listener_for_user(self, user, websocket_manager): async def start_listener_for_user(self, user, websocket_manager):
""" """
Starts a chat listener for a given user if one isn't already running. Starts a chat listener for a given user if one isn't already running.
""" """
if user.id in self.active_listeners: if user.id in self.active_listeners:
print(f"Listener for user {user.id} is already running.") logger.info(f"Listener for user {user.id} is already running.")
return return
print(f"Starting listener for user {user.id} ({user.username})...") # 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) tokens = security.decrypt_tokens(user.encrypted_tokens)
access_token = tokens['access_token'] 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( bot = TwitchBot(
access_token=access_token,
channel_name=user.username,
client_id=settings.TWITCH_CLIENT_ID,
client_secret=settings.TWITCH_CLIENT_SECRET,
bot_id=user.platform_user_id,
websocket_manager=websocket_manager, websocket_manager=websocket_manager,
db_user_id=user.id db_user_id=user.id
) )
task = asyncio.create_task(bot.start())
self.active_listeners[user.id] = task # 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): async def stop_listener_for_user(self, user_id: int):
"""Stops a chat listener for a given user.""" """Stops a chat listener for a given user."""
if user_id not in self.active_listeners: if user_id not in self.active_listeners:
print(f"No active listener found for user {user_id}.") logger.info(f"No active listener found for user {user_id}.")
return return
print(f"Stopping listener for user {user_id}...") logger.info(f"Stopping listener for user {user_id}...")
task = self.active_listeners.pop(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() task.cancel()
try: try:
await task await task
except asyncio.CancelledError: except asyncio.CancelledError:
print(f"Listener for user {user_id} successfully stopped.") logger.info(f"Listener for user {user_id} successfully stopped.")

63
main.py
View File

@@ -1,5 +1,8 @@
import os import os
import logging
import asyncio
from fastapi import FastAPI, Request, Depends, HTTPException from fastapi import FastAPI, Request, Depends, HTTPException
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from starlette.staticfiles import StaticFiles from starlette.staticfiles import StaticFiles
from starlette.responses import FileResponse, RedirectResponse from starlette.responses import FileResponse, RedirectResponse
@@ -23,26 +26,38 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
STATIC_DIR = os.path.join(BASE_DIR, "static") STATIC_DIR = os.path.join(BASE_DIR, "static")
TEMPLATES_DIR = os.path.join(BASE_DIR, "templates") TEMPLATES_DIR = os.path.join(BASE_DIR, "templates")
@asynccontextmanager # --- Logging Configuration ---
async def lifespan(app: FastAPI): logging.basicConfig(level=logging.INFO)
# This code runs on startup logger = logging.getLogger(__name__)
print("Application startup: Creating database tables...")
app.state.websocket_manager = WebSocketManager()
app.state.listener_manager = ListenerManager()
models.Base.metadata.create_all(bind=engine)
print("Application startup: Database tables created.")
# Start listeners for all existing users 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() db = SessionLocal()
users = db.query(models.User).all() users = db.query(models.User).all()
db.close() db.close()
for user in users: 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) 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 yield
# This code runs on shutdown # This code runs on shutdown
print("Application shutdown: Stopping all listeners...") logger.info("Application shutdown: Stopping all listeners...")
manager = app.state.listener_manager manager = app.state.listener_manager
# Create a copy of keys to avoid runtime errors from changing dict size # Create a copy of keys to avoid runtime errors from changing dict size
for user_id in list(manager.active_listeners.keys()): for user_id in list(manager.active_listeners.keys()):
@@ -50,25 +65,32 @@ async def lifespan(app: FastAPI):
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
# Configure Jinja2 templates # Add session middleware. A secret key is required for signing the session cookie.
templates = Jinja2Templates(directory=TEMPLATES_DIR) # 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 # 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") app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# Add the authentication router # Add the authentication router
app.include_router(auth.router) app.include_router(auth.router)
# Add session middleware. A secret key is required for signing the session cookie. # --- Template Dependency ---
# We can reuse our encryption key for this, but in production you might want a separate key. def get_templates():
app.add_middleware(SessionMiddleware, secret_key=settings.ENCRYPTION_KEY) return Jinja2Templates(directory=TEMPLATES_DIR)
@app.get("/") @app.get("/")
async def read_root(request: Request): async def read_root(request: Request, templates: Jinja2Templates = Depends(get_templates)):
return templates.TemplateResponse("login.html", {"request": request}) return templates.TemplateResponse("login.html", {"request": request})
@app.get("/dashboard") @app.get("/dashboard")
async def read_dashboard(request: Request, db: Session = Depends(auth.get_db)): async def read_dashboard(request: Request, db: Session = Depends(auth.get_db),
templates: Jinja2Templates = Depends(get_templates)):
# This is our protected route. It checks if a user_id exists in the session. # This is our protected route. It checks if a user_id exists in the session.
user_id = request.session.get('user_id') user_id = request.session.get('user_id')
if not user_id: if not user_id:
@@ -99,11 +121,12 @@ async def logout(request: Request):
return RedirectResponse(url="/") return RedirectResponse(url="/")
@app.get("/help/css") @app.get("/help/css")
async def css_help(request: Request): async def css_help(request: Request, templates: Jinja2Templates = Depends(get_templates)):
return templates.TemplateResponse("help_css.html", {"request": request}) return templates.TemplateResponse("help_css.html", {"request": request})
@app.get("/overlay/{user_id}") @app.get("/overlay/{user_id}")
async def read_overlay(request: Request, user_id: int, theme_override: str = None, db: Session = Depends(auth.get_db)): 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. # This endpoint serves the overlay page.
user = db.query(models.User).filter(models.User.id == user_id).first() user = db.query(models.User).filter(models.User.id == user_id).first()
if not user: if not user:
@@ -185,4 +208,4 @@ async def websocket_endpoint(websocket: WebSocket, user_id: int):
await websocket.receive_text() await websocket.receive_text()
except Exception: except Exception:
manager.disconnect(user_id, websocket) manager.disconnect(user_id, websocket)
print(f"WebSocket for user {user_id} disconnected.") logger.info(f"WebSocket for user {user_id} disconnected.")

View File

@@ -288,4 +288,3 @@ pre {
word-wrap: break-word; word-wrap: break-word;
font-family: monospace; font-family: monospace;
} }
}

63
twitch_test.py Normal file
View 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())