Compare commits
33 Commits
bdd8674645
...
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 |
58
CONTEXT.md
58
CONTEXT.md
@@ -25,19 +25,11 @@ My core instructions are:
|
|||||||
The objective is to build a multi-platform chat overlay SaaS (Software as a Service) for streamers. The service will aggregate chat from Twitch and YouTube into a single, customizable browser source for use in streaming software like OBS.
|
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
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.
|
||||||
9
auth.py
9
auth.py
@@ -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
|
||||||
|
|||||||
@@ -1,59 +1,80 @@
|
|||||||
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_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}'")
|
||||||
|
|
||||||
async def event_message(self, message):
|
|
||||||
"""Runs every time a message is sent in chat."""
|
|
||||||
# This is CRITICAL when overriding a listener in commands.Bot
|
|
||||||
# to ensure the bot can still process commands if any are added.
|
|
||||||
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:
|
||||||
return
|
return
|
||||||
|
|
||||||
# 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
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.")
|
||||||
@@ -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})...")
|
||||||
|
|
||||||
tokens = security.decrypt_tokens(user.encrypted_tokens)
|
try:
|
||||||
access_token = tokens['access_token']
|
tokens = security.decrypt_tokens(user.encrypted_tokens)
|
||||||
bot = TwitchBot(
|
access_token = tokens['access_token']
|
||||||
access_token=access_token,
|
refresh_token = tokens['refresh_token']
|
||||||
channel_name=user.username,
|
|
||||||
client_id=settings.TWITCH_CLIENT_ID,
|
# Initialize the bot object without credentials first. It's just a lightweight container.
|
||||||
client_secret=settings.TWITCH_CLIENT_SECRET,
|
bot = TwitchBot(
|
||||||
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())
|
# Create a task that runs our new start method with all credentials.
|
||||||
self.active_listeners[user.id] = task
|
# 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.")
|
||||||
65
main.py
65
main.py
@@ -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:
|
||||||
await app.state.listener_manager.start_listener_for_user(user, app.state.websocket_manager)
|
# 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
|
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.")
|
||||||
@@ -287,5 +287,4 @@ pre {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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())
|
||||||
Reference in New Issue
Block a user