Swapped twitch connector twitchio.Client

This commit is contained in:
2025-11-18 01:33:19 +01:00
parent 45a511b8e9
commit afa27271fa
3 changed files with 67 additions and 36 deletions

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,35 +1,14 @@
from twitchio.ext import commands import twitchio
class NullAdapter: class TwitchBot(twitchio.Client):
"""A dummy web adapter that does nothing to prevent twitchio from starting a webserver.""" def __init__(self, access_token: str, channel_name: str, websocket_manager, db_user_id: int):
async def start(self):
pass
async def stop(self):
pass
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
# Force the bot to use our dummy adapter that does nothing.
# This is the definitive fix to prevent any port conflicts.
adapter = NullAdapter()
super().__init__( super().__init__(
token=access_token, token=access_token,
# Mandate: Explicitly disable the internal web server. initial_channels=[channel_name]
web_server_adapter=adapter,
eventsub_url=None,
prefix='!', # A prefix is required but won't be used for reading chat
initial_channels=[channel_name],
# These are required by twitchio
client_id=client_id,
client_secret=client_secret,
ssl=True, # Mandate: Explicitly use SSL for the IRC connection.
bot_id=bot_id # This is required by the parent Bot class.
) )
self.channel_name = channel_name self.channel_name = channel_name
@@ -39,10 +18,6 @@ class TwitchBot(commands.Bot):
async def event_message(self, message): # Mandate: Type hint removed to prevent import errors. 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
# 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

View File

@@ -32,15 +32,10 @@ class ListenerManager:
bot = TwitchBot( bot = TwitchBot(
access_token=access_token, access_token=access_token,
channel_name=user.username, 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
) )
# The `web_server=False` argument is the most direct way to prevent task = asyncio.create_task(bot.start())
# twitchio from attempting to start its own web server.
task = asyncio.create_task(bot.start(web_server=False))
self.active_listeners[user.id] = task self.active_listeners[user.id] = task
async def stop_listener_for_user(self, user_id: int): async def stop_listener_for_user(self, user_id: int):