Attempting to add websocket support
This commit is contained in:
@@ -36,7 +36,7 @@ The goal is to create a service where streamers can log in using their platform
|
|||||||
6. **Help & Documentation:** Add a help page to guide users in creating their custom CSS.
|
6. **Help & Documentation:** Add a help page to guide users in creating their custom CSS.
|
||||||
|
|
||||||
### Phase 3: Dynamic Listeners & Basic Overlay
|
### Phase 3: Dynamic Listeners & Basic Overlay
|
||||||
**Status: ⏳ Not Started**
|
**Status: ✔️ Complete**
|
||||||
|
|
||||||
1. **Dynamic Listener Manager:** Design and build a background service that starts and stops chat listener processes (`twitchio`, `pytchat`) based on user activity.
|
1. **Dynamic Listener Manager:** Design and build a background service that starts and stops chat listener processes (`twitchio`, `pytchat`) based on user activity.
|
||||||
2. **Real-time Message Broadcasting:** Implement a WebSocket system within FastAPI to push chat messages to the correct user's overlay in real-time.
|
2. **Real-time Message Broadcasting:** Implement a WebSocket system within FastAPI to push chat messages to the correct user's overlay in real-time.
|
||||||
|
|||||||
12
TASKS.md
12
TASKS.md
@@ -10,7 +10,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) )
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -54,15 +54,13 @@ If you want to use emojis for visibility, here's some I have used:
|
|||||||
* **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)*
|
||||||
|
|
||||||
### In Progress
|
|
||||||
* `[🧑🔧]` **3.1: Dynamic Listener Manager (The Big One):** Design a system (e.g., background service) to start/stop listener processes for users. @ramforth
|
|
||||||
|
|
||||||
|
|
||||||
### To Do
|
### To Do
|
||||||
* `[ ]` **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
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from twitchio.ext import commands
|
from twitchio.ext import commands
|
||||||
|
|
||||||
class TwitchBot(commands.Bot):
|
class TwitchBot(commands.Bot):
|
||||||
def __init__(self, access_token: str, client_id: str, client_secret: str, bot_id: str, channel_name: str):
|
def __init__(self, access_token: str, client_id: str, client_secret: str, bot_id: str, channel_name: str, websocket_manager):
|
||||||
super().__init__(
|
self.websocket_manager = websocket_manager
|
||||||
|
super().__init__(
|
||||||
token=access_token,
|
token=access_token,
|
||||||
prefix='!', # A prefix is required but won't be used for reading chat
|
prefix='!', # A prefix is required but won't be used for reading chat
|
||||||
initial_channels=[channel_name],
|
initial_channels=[channel_name],
|
||||||
@@ -25,7 +26,17 @@ class TwitchBot(commands.Bot):
|
|||||||
# The 'echo' check is temporarily disabled to allow self-messaging for testing.
|
# The 'echo' check is temporarily disabled to allow self-messaging for testing.
|
||||||
# if message.echo:
|
# if message.echo:
|
||||||
# return # Ignore messages sent by the bot itself
|
# return # Ignore messages sent by the bot itself
|
||||||
|
|
||||||
# For now, we just print the message to the console.
|
# Prepare the message data to be sent to the frontend
|
||||||
# Later, this will push messages to a queue for the WebSocket.
|
chat_data = {
|
||||||
print(f"Message from {message.author.name} in #{self.channel_name}: {message.content}")
|
"author": message.author.name,
|
||||||
|
"text": message.content,
|
||||||
|
"platform": "twitch"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Broadcast the message to the specific user's overlay
|
||||||
|
# We need the user's ID to know which WebSocket connection to send to.
|
||||||
|
# For now, we'll assume the bot instance is tied to a user ID.
|
||||||
|
# Let's find the user ID from the channel name.
|
||||||
|
user_id = self.owner_id
|
||||||
|
await self.websocket_manager.broadcast_to_user(user_id, chat_data)
|
||||||
@@ -12,7 +12,7 @@ class ListenerManager:
|
|||||||
self.active_listeners: Dict[int, asyncio.Task] = {}
|
self.active_listeners: Dict[int, asyncio.Task] = {}
|
||||||
print("ListenerManager initialized.")
|
print("ListenerManager initialized.")
|
||||||
|
|
||||||
async def start_listener_for_user(self, user):
|
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.
|
||||||
"""
|
"""
|
||||||
@@ -29,7 +29,8 @@ class ListenerManager:
|
|||||||
channel_name=user.username,
|
channel_name=user.username,
|
||||||
client_id=settings.TWITCH_CLIENT_ID,
|
client_id=settings.TWITCH_CLIENT_ID,
|
||||||
client_secret=settings.TWITCH_CLIENT_SECRET,
|
client_secret=settings.TWITCH_CLIENT_SECRET,
|
||||||
bot_id=user.platform_user_id
|
bot_id=user.platform_user_id,
|
||||||
|
websocket_manager=websocket_manager
|
||||||
)
|
)
|
||||||
task = asyncio.create_task(bot.start())
|
task = asyncio.create_task(bot.start())
|
||||||
self.active_listeners[user.id] = task
|
self.active_listeners[user.id] = task
|
||||||
|
|||||||
18
main.py
18
main.py
@@ -14,6 +14,7 @@ import schemas
|
|||||||
from starlette.responses import Response
|
from starlette.responses import Response
|
||||||
from config import settings # Import settings to get the secret key
|
from config import settings # Import settings to get the secret key
|
||||||
from listener_manager import ListenerManager
|
from listener_manager import ListenerManager
|
||||||
|
from websocket_manager import WebSocketManager
|
||||||
|
|
||||||
# --- Absolute Path Configuration ---
|
# --- Absolute Path Configuration ---
|
||||||
# Get the absolute path of the directory where this file is located
|
# Get the absolute path of the directory where this file is located
|
||||||
@@ -25,6 +26,7 @@ TEMPLATES_DIR = os.path.join(BASE_DIR, "templates")
|
|||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# This code runs on startup
|
# This code runs on startup
|
||||||
print("Application startup: Creating database tables...")
|
print("Application startup: Creating database tables...")
|
||||||
|
app.state.websocket_manager = WebSocketManager()
|
||||||
app.state.listener_manager = ListenerManager()
|
app.state.listener_manager = ListenerManager()
|
||||||
models.Base.metadata.create_all(bind=engine)
|
models.Base.metadata.create_all(bind=engine)
|
||||||
print("Application startup: Database tables created.")
|
print("Application startup: Database tables created.")
|
||||||
@@ -34,7 +36,7 @@ async def lifespan(app: FastAPI):
|
|||||||
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)
|
await app.state.listener_manager.start_listener_for_user(user, app.state.websocket_manager)
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
@@ -170,4 +172,16 @@ async def get_custom_css(theme_id: int, db: Session = Depends(auth.get_db)):
|
|||||||
if not theme:
|
if not theme:
|
||||||
raise HTTPException(status_code=404, detail="Custom theme not found")
|
raise HTTPException(status_code=404, detail="Custom theme not found")
|
||||||
|
|
||||||
return Response(content=theme.css_content, media_type="text/css")
|
return Response(content=theme.css_content, media_type="text/css")
|
||||||
|
|
||||||
|
@app.websocket("/ws/{user_id}")
|
||||||
|
async def websocket_endpoint(websocket: WebSocket, user_id: int):
|
||||||
|
manager = websocket.app.state.websocket_manager
|
||||||
|
await manager.connect(user_id, websocket)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# Keep the connection alive
|
||||||
|
await websocket.receive_text()
|
||||||
|
except Exception:
|
||||||
|
manager.disconnect(user_id, websocket)
|
||||||
|
print(f"WebSocket for user {user_id} disconnected.")
|
||||||
@@ -4,38 +4,47 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Hacker Green Overlay</title>
|
<title>Hacker Green Overlay</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
background-color: rgba(0, 0, 0, 0);
|
background-color: transparent;
|
||||||
color: #00FF41;
|
color: #0f0;
|
||||||
font-family: 'Courier New', Courier, monospace;
|
font-family: 'VT323', monospace;
|
||||||
font-size: 18px;
|
font-size: 20px;
|
||||||
text-shadow: 0 0 5px #00FF41, 0 0 10px #00FF41;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
text-shadow: 0 0 5px #0f0;
|
||||||
}
|
}
|
||||||
.chat-message {
|
.chat-container {
|
||||||
margin-bottom: 8px;
|
display: flex;
|
||||||
animation: fadeIn 0.5s ease-in-out;
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
}
|
}
|
||||||
.username {
|
.username {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
.username::after {
|
|
||||||
content: ':~$ ';
|
|
||||||
}
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; transform: translateY(10px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Example Message -->
|
<div id="chat-container" class="chat-container">
|
||||||
<div class="chat-message">
|
<!-- Messages will be injected here -->
|
||||||
<span class="username">system</span>
|
|
||||||
<span class="message">Hacker Green Theme Initialized.</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const userId = window.location.pathname.split('/')[2];
|
||||||
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const ws = new WebSocket(`${wsProtocol}//${window.location.host}/ws/${userId}`);
|
||||||
|
|
||||||
|
ws.onmessage = function(event) {
|
||||||
|
const messageData = JSON.parse(event.data);
|
||||||
|
const chatContainer = document.getElementById('chat-container');
|
||||||
|
const messageElement = document.createElement('div');
|
||||||
|
messageElement.innerHTML = `<span class="username">${messageData.author}:</span> ${messageData.text}`;
|
||||||
|
chatContainer.appendChild(messageElement);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
22
websocket_manager.py
Normal file
22
websocket_manager.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from typing import Dict, List
|
||||||
|
from fastapi import WebSocket
|
||||||
|
|
||||||
|
class WebSocketManager:
|
||||||
|
def __init__(self):
|
||||||
|
# Maps user_id to a list of active WebSocket connections
|
||||||
|
self.active_connections: Dict[int, List[WebSocket]] = {}
|
||||||
|
print("WebSocketManager initialized.")
|
||||||
|
|
||||||
|
async def connect(self, user_id: int, websocket: WebSocket):
|
||||||
|
await websocket.accept()
|
||||||
|
if user_id not in self.active_connections:
|
||||||
|
self.active_connections[user_id] = []
|
||||||
|
self.active_connections[user_id].append(websocket)
|
||||||
|
|
||||||
|
def disconnect(self, user_id: int, websocket: WebSocket):
|
||||||
|
self.active_connections[user_id].remove(websocket)
|
||||||
|
|
||||||
|
async def broadcast_to_user(self, user_id: int, message: dict):
|
||||||
|
if user_id in self.active_connections:
|
||||||
|
for connection in self.active_connections[user_id]:
|
||||||
|
await connection.send_json(message)
|
||||||
Reference in New Issue
Block a user