Cleaned project directory and added new development plan for simplified stack
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +0,0 @@
|
||||
.gemini/
|
||||
17
Dockerfile
17
Dockerfile
@@ -1,17 +0,0 @@
|
||||
# Use an official Python runtime as a parent image
|
||||
FROM python:3.9-slim
|
||||
|
||||
# Set the working directory in the container
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the requirements file into the container
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install any needed packages specified in requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy the rest of the application code into the container
|
||||
COPY . .
|
||||
|
||||
# Command to run the application
|
||||
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "-w", "4", "-b", "0.0.0.0:8000", "main:app"]
|
||||
@@ -1,38 +0,0 @@
|
||||
# Project Progress Report
|
||||
|
||||
This document summarizes the key steps and decisions made during the initial setup and planning of the MultiChat Overlay project.
|
||||
|
||||
## 1. Initial Setup & Environment Assessment
|
||||
- **Environment Assessed:** The initial container environment was assessed, and `python3` and `git` were confirmed to be installed.
|
||||
- **Project Directory:** The project directory was created at `/home/joe/MultiChatOverlay`.
|
||||
- **Collaboration:** The directory permissions were configured to allow for collaboration by setting the group to `collaborators` and permissions to `775`.
|
||||
- **Git Repository:** The project was cloned from the Gitea repository at `https://gitea.ramforth.net/ramforth/MultiChatOverlay`.
|
||||
|
||||
## 2. Initial Development (Single-User Model)
|
||||
- **Virtual Environment:** A Python virtual environment was created at `/home/joe/MultiChatOverlay/venv`.
|
||||
- **Dependencies:** Initial Python dependencies (`fastapi`, `uvicorn`, `websockets`, `pytchat`, `twitchio`) were installed.
|
||||
- **Basic Framework:** A basic FastAPI application was created with a WebSocket endpoint and a simple HTML overlay.
|
||||
- **Chat Listeners:** Placeholder chat listener modules were created for YouTube and Twitch.
|
||||
|
||||
## 3. Pivot to a Multi-User Service
|
||||
- **New Requirement:** The project direction was updated to create a multi-user, web-based service where users can log in with their streaming accounts.
|
||||
- **Revised Development Plan:** A new development plan (`DEVELOPMENT_PLAN.md` v3) was created to reflect this change. The new plan focuses on user authentication, a database, and dynamic management of chat listeners.
|
||||
- **Plan Synced:** The revised development plan was pushed to the Gitea repository.
|
||||
|
||||
## 4. Implementation of the Multi-User Framework
|
||||
- **Database:** A SQLite database was initialized, and a `users` table schema was defined using SQLAlchemy.
|
||||
- **Authentication:** A placeholder Twitch OAuth2 implementation was created in `auth.py`.
|
||||
- **Login Frontend:** A `login.html` page and a corresponding FastAPI endpoint were created.
|
||||
- **Session Management:** Basic session management using signed cookies was implemented to keep users logged in.
|
||||
- **Dashboard:** A protected `/dashboard` endpoint and a simple `dashboard.html` page were created.
|
||||
- **Usage Guide:** A `USAGE.md` file was created to document the login process for end-users.
|
||||
- **Code Synced:** All changes for the initial multi-user framework were pushed to the Gitea repository.
|
||||
|
||||
## 5. Pivot to a Production-Ready Docker-Based Framework
|
||||
- **New Requirement:** The need for a more robust, scalable, and easily accessible framework was identified.
|
||||
- **Revised Plan:** A new plan was created to use Docker Compose to manage the application services.
|
||||
- **Docker Compose Plan:**
|
||||
- A `web` service for the FastAPI application.
|
||||
- A `db` service using PostgreSQL.
|
||||
- The use of `gunicorn` with `uvicorn` workers for the production server.
|
||||
- The use of Docker volumes to allow for easy collaboration on frontend files.
|
||||
@@ -1,8 +0,0 @@
|
||||
There is a need for a chat overlay in live streaming circles that allows streamers to read chat from several of the primary platforms. Twitch and Youtube at the very least.
|
||||
|
||||
The idea:
|
||||
Use self hosted web server to present the user integration with account linking and setup instructions. Let the end user log in with Youtube and Twitch. Have preset templates for html overlay for OBS use.
|
||||
|
||||
Must have functions to highlight single messages, preferably from a dockable html browser dock in OBS studio.
|
||||
|
||||
This is a specific extension of project: /home/joe/Cloud9/Documents/Obisdian/projects/youtube-chat-webhook-v2
|
||||
30
README.md
30
README.md
@@ -1,30 +0,0 @@
|
||||
# Multi-Platform Chat Overlay
|
||||
|
||||
A self-hosted tool to aggregate chat from multiple streaming platforms (YouTube, Twitch) and display it in a customizable OBS overlay. This project also provides a suite of tools for both the streamer (host) and their moderators to manage the chat.
|
||||
|
||||
## Core Features
|
||||
|
||||
* **Combined Chat:** Aggregates chat from YouTube and Twitch into a single feed.
|
||||
* **OBS Overlay:** A customizable web-based overlay to display the chat in your stream.
|
||||
* **Host Control Panel:** A dockable OBS panel for the streamer to:
|
||||
* Highlight important messages to feature them on the overlay.
|
||||
* View a queue of messages tagged by moderators.
|
||||
* Dismiss queued messages after they have been addressed.
|
||||
* **Moderator Panel:** A separate, secure web page for moderators to:
|
||||
* View the combined chat feed.
|
||||
* "Tag" or "Queue" important messages for the host's attention.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
* **Backend:** Python 3.9+ with FastAPI and WebSockets.
|
||||
* **Frontend:** HTML5, CSS3, and vanilla JavaScript.
|
||||
* **Chat Libraries:** `pytchat` for YouTube and `TwitchIO` for Twitch.
|
||||
|
||||
## Getting Started
|
||||
|
||||
_(Instructions will be added here once the initial version is complete)_
|
||||
|
||||
1. Clone the repository.
|
||||
2. Install Python dependencies.
|
||||
3. Run the backend server.
|
||||
4. Add the frontend components to OBS as Browser Sources/Docks.
|
||||
41
USAGE.md
41
USAGE.md
@@ -1,41 +0,0 @@
|
||||
# MultiChat Overlay Usage Guide
|
||||
|
||||
This guide explains how to log in to the MultiChat Overlay service and connect your streaming accounts to generate a personalized chat overlay.
|
||||
|
||||
## 1. Logging In
|
||||
|
||||
To use the MultiChat Overlay, you first need to log in with your streaming platform account.
|
||||
|
||||
1. **Access the Login Page:** Open your web browser and navigate to the service's login page (e.g., `http://localhost:8000/login`).
|
||||
2. **Choose Your Platform:** On the login page, you will see options to log in with different streaming platforms.
|
||||
3. **Authorize MultiChat Overlay:**
|
||||
* Click on the "Login with Twitch" button.
|
||||
* You will be redirected to Twitch's authorization page. Review the permissions requested by MultiChat Overlay and click "Authorize" to grant access.
|
||||
* After successful authorization, you will be redirected back to the MultiChat Overlay dashboard.
|
||||
|
||||
## 2. Connecting Services
|
||||
|
||||
Currently, only Twitch is supported for connection. YouTube integration will be added in a future update.
|
||||
|
||||
### Connecting Twitch
|
||||
|
||||
Once you have logged in with your Twitch account, your Twitch channel will automatically be connected. You can verify this on your dashboard.
|
||||
|
||||
## 3. Your Personalized Overlay
|
||||
|
||||
After logging in and connecting your services, you will find your unique overlay URL on the dashboard.
|
||||
|
||||
1. **Copy Overlay URL:** On the dashboard, locate and copy the provided "Overlay URL". This URL is unique to your account.
|
||||
2. **Add to OBS (or other streaming software):**
|
||||
* In OBS Studio, add a new "Browser Source".
|
||||
* Paste your copied Overlay URL into the "URL" field of the Browser Source properties.
|
||||
* Adjust the width and height of the browser source to match your desired overlay size.
|
||||
* Click "OK".
|
||||
|
||||
Your chat overlay should now appear in your streaming software, displaying messages from your connected Twitch channel.
|
||||
|
||||
## 4. Future Integrations (Coming Soon)
|
||||
|
||||
* **YouTube Integration:** Connect your YouTube channel to aggregate chat from both platforms.
|
||||
* **Customization Options:** Personalize the look and feel of your chat overlay directly from the dashboard.
|
||||
* **Moderator & Host Panels:** Access dedicated panels for advanced moderation and stream management.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
87
auth.py
87
auth.py
@@ -1,87 +0,0 @@
|
||||
import os
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
import httpx
|
||||
from database import get_db, User
|
||||
from itsdangerous import URLSafeTimedSerializer
|
||||
|
||||
# Get configuration from environment variables
|
||||
TWITCH_CLIENT_ID = os.environ.get("TWITCH_CLIENT_ID")
|
||||
TWITCH_CLIENT_SECRET = os.environ.get("TWITCH_CLIENT_SECRET")
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY")
|
||||
REDIRECT_URI = "http://localhost:8000/auth/twitch/callback" # This will need to be updated for production
|
||||
|
||||
serializer = URLSafeTimedSerializer(SECRET_KEY)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/login/twitch")
|
||||
async def login_with_twitch():
|
||||
# Scopes required to get user's email and channel info
|
||||
scopes = "user:read:email"
|
||||
auth_url = (
|
||||
f"https://id.twitch.tv/oauth2/authorize"
|
||||
f"?client_id={TWITCH_CLIENT_ID}"
|
||||
f"&redirect_uri={REDIRECT_URI}"
|
||||
f"&response_type=code"
|
||||
f"&scope={scopes}"
|
||||
)
|
||||
return RedirectResponse(url=auth_url)
|
||||
|
||||
@router.get("/auth/twitch/callback")
|
||||
async def auth_twitch_callback(code: str, db: Session = Depends(get_db)):
|
||||
# Exchange the authorization code for an access token
|
||||
token_url = "https://id.twitch.tv/oauth2/token"
|
||||
token_data = {
|
||||
"client_id": TWITCH_CLIENT_ID,
|
||||
"client_secret": TWITCH_CLIENT_SECRET,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
}
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_response = await client.post(token_url, data=token_data)
|
||||
token_json = token_response.json()
|
||||
access_token = token_json.get("access_token")
|
||||
refresh_token = token_json.get("refresh_token")
|
||||
|
||||
if not access_token:
|
||||
return {"error": "Could not fetch access token"}
|
||||
|
||||
# Get user info from Twitch API
|
||||
user_info_url = "https://api.twitch.tv/helix/users"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Client-Id": TWITCH_CLIENT_ID,
|
||||
}
|
||||
user_response = await client.get(user_info_url, headers=headers)
|
||||
user_data = user_response.json()["data"][0]
|
||||
|
||||
twitch_id = user_data["id"]
|
||||
twitch_username = user_data["login"]
|
||||
|
||||
# Check if user exists in the database, otherwise create them
|
||||
user = db.query(User).filter(User.twitch_id == twitch_id).first()
|
||||
if not user:
|
||||
user = User(
|
||||
twitch_id=twitch_id,
|
||||
twitch_username=twitch_username,
|
||||
twitch_access_token=access_token, # TODO: Encrypt this
|
||||
twitch_refresh_token=refresh_token, # TODO: Encrypt this
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
else:
|
||||
# Update tokens for existing user
|
||||
user.twitch_access_token = access_token # TODO: Encrypt this
|
||||
user.twitch_refresh_token = refresh_token # TODO: Encrypt this
|
||||
db.commit()
|
||||
|
||||
# Create a session cookie
|
||||
response = RedirectResponse(url="/dashboard")
|
||||
session_data = {"user_id": user.id}
|
||||
session_cookie = serializer.dumps(session_data)
|
||||
response.set_cookie(key="session", value=session_cookie)
|
||||
return response
|
||||
@@ -1,65 +0,0 @@
|
||||
# chat_listeners.py
|
||||
import asyncio
|
||||
from pytchat import LiveChat
|
||||
from twitchio.ext import commands
|
||||
|
||||
# YouTube Chat Listener (Placeholder)
|
||||
async def listen_youtube_chat(video_id: str, callback):
|
||||
livechat = LiveChat(video_id=video_id)
|
||||
while True:
|
||||
try:
|
||||
chatdata = await livechat.get().as_dict()
|
||||
for c in chatdata['items']:
|
||||
message = {
|
||||
"platform": "youtube",
|
||||
"author": c['author']['name'],
|
||||
"message": c['message'],
|
||||
"is_moderator": False # Placeholder
|
||||
}
|
||||
await callback(message)
|
||||
except Exception as e:
|
||||
print(f"YouTube chat error: {e}")
|
||||
await asyncio.sleep(1) # Don't hammer the API
|
||||
|
||||
# Twitch Chat Listener (Placeholder)
|
||||
class TwitchBot(commands.Bot):
|
||||
def __init__(self, token: str, channel: str, callback):
|
||||
super().__init__(token=token, prefix='!', initial_channels=[channel])
|
||||
self.callback = callback
|
||||
|
||||
async def event_ready(self):
|
||||
print(f'Logged in as | {self.nick}')
|
||||
print(f'User ID is | {self.user_id}')
|
||||
|
||||
async def event_message(self, message):
|
||||
if message.echo:
|
||||
return
|
||||
|
||||
is_moderator = 'moderator' in message.tags and message.tags['moderator'] == '1'
|
||||
|
||||
chat_message = {
|
||||
"platform": "twitch",
|
||||
"author": message.author.name,
|
||||
"message": message.content,
|
||||
"is_moderator": is_moderator
|
||||
}
|
||||
await self.callback(chat_message)
|
||||
|
||||
async def listen_twitch_chat(token: str, channel: str, callback):
|
||||
bot = TwitchBot(token, channel, callback)
|
||||
await bot.start()
|
||||
|
||||
# Example usage (for testing purposes, not part of the main application flow)
|
||||
async def main():
|
||||
async def print_message(message):
|
||||
print(f"[{message['platform']}] {message['author']}: {message['message']} (Mod: {message['is_moderator']})")
|
||||
|
||||
# Replace with actual YouTube video ID and Twitch token/channel
|
||||
# asyncio.create_task(listen_youtube_chat("YOUR_YOUTUBE_VIDEO_ID", print_message))
|
||||
# asyncio.create_task(listen_twitch_chat("YOUR_TWITCH_OAUTH_TOKEN", "YOUR_TWITCH_CHANNEL", print_message))
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(3600) # Keep main running
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,22 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>MultiChat Overlay Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Welcome to your Dashboard</h1>
|
||||
<p>This is your personalized dashboard. You can manage your connected accounts and configure your overlay here.</p>
|
||||
|
||||
<h2>Your Overlay URL</h2>
|
||||
<p>Use this URL as a browser source in your streaming software:</p>
|
||||
<pre id="overlay-url"></pre>
|
||||
|
||||
<script>
|
||||
// In a real application, we would fetch the user's unique overlay URL
|
||||
// from an API and display it here. For now, we'll just show a placeholder.
|
||||
const overlayUrlElement = document.getElementById('overlay-url');
|
||||
// This would be something like `http://localhost:8000/overlay/USER_ID`
|
||||
overlayUrlElement.textContent = `http://localhost:8000/overlay/YOUR_USER_ID`;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
35
database.py
35
database.py
@@ -1,35 +0,0 @@
|
||||
import os
|
||||
from sqlalchemy import create_engine, Column, Integer, String
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL")
|
||||
|
||||
engine = create_engine(DATABASE_URL)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
twitch_id = Column(String, unique=True, index=True)
|
||||
twitch_username = Column(String)
|
||||
# We will store encrypted tokens
|
||||
twitch_access_token = Column(String)
|
||||
twitch_refresh_token = Column(String)
|
||||
|
||||
youtube_id = Column(String, unique=True, index=True, nullable=True)
|
||||
youtube_username = Column(String, nullable=True)
|
||||
youtube_access_token = Column(String, nullable=True)
|
||||
youtube_refresh_token = Column(String, nullable=True)
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def create_tables():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@@ -1,30 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
command: gunicorn -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000 main:app
|
||||
volumes:
|
||||
- .:/app
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://user:password@db/mydatabase
|
||||
- SECRET_KEY=YOUR_SECRET_KEY # This should be a long, random string
|
||||
- TWITCH_CLIENT_ID=ogxx1fhpxbg8g89rov6oswuxeup2pb
|
||||
- TWITCH_CLIENT_SECRET=2660uqpk2e1leayhpwcu35a27zidmh
|
||||
- REDIRECT_URI=https://multichat.ramforth.net/auth/twitch/callback
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
db:
|
||||
image: postgres:13
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data/
|
||||
environment:
|
||||
- POSTGRES_USER=user
|
||||
- POSTGRES_PASSWORD=password
|
||||
- POSTGRES_DB=mydatabase
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
21
index.html
21
index.html
@@ -1,21 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>MultiChat Overlay</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Chat Messages</h1>
|
||||
<ul id="messages"></ul>
|
||||
<script>
|
||||
const messages = document.getElementById('messages');
|
||||
const ws = new WebSocket("ws://localhost:8000/ws");
|
||||
|
||||
ws.onmessage = function(event) {
|
||||
const chatMessage = JSON.parse(event.data);
|
||||
const messageElement = document.createElement('li');
|
||||
messageElement.textContent = `[${chatMessage.platform}] ${chatMessage.author}: ${chatMessage.message}`;
|
||||
messages.appendChild(messageElement);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
14
login.html
14
login.html
@@ -1,14 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Login to MultiChat Overlay</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Login to MultiChat Overlay</h1>
|
||||
<p>Connect your streaming accounts to get started.</p>
|
||||
<a href="/auth/login/twitch">
|
||||
<button>Login with Twitch</button>
|
||||
</a>
|
||||
<!-- YouTube login will be added in a later phase -->
|
||||
</body>
|
||||
</html>
|
||||
67
main.py
67
main.py
@@ -1,67 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
from fastapi import FastAPI, WebSocket, Request, Depends
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from chat_listeners import listen_youtube_chat, listen_twitch_chat
|
||||
from auth import router as auth_router, serializer
|
||||
from database import get_db, User, create_tables
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
create_tables()
|
||||
# The chat listeners will be started dynamically based on user activity
|
||||
# and not on application startup.
|
||||
|
||||
class SessionMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
session_cookie = request.cookies.get("session")
|
||||
if session_cookie:
|
||||
try:
|
||||
data = serializer.loads(session_cookie, max_age=3600 * 24 * 7) # 1 week
|
||||
db = next(get_db())
|
||||
user = db.query(User).filter(User.id == data["user_id"]).first()
|
||||
request.state.user = user
|
||||
except Exception:
|
||||
request.state.user = None
|
||||
else:
|
||||
request.state.user = None
|
||||
return response
|
||||
|
||||
@app.get("/")
|
||||
async def read_root():
|
||||
return {"Hello": "World"}
|
||||
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
async def get_login_page():
|
||||
with open("login.html", "r") as f:
|
||||
return f.read()
|
||||
|
||||
@app.get("/dashboard", response_class=HTMLResponse)
|
||||
async def get_dashboard(user: User = Depends(get_current_user)):
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
with open("dashboard.html", "r") as f:
|
||||
return f.read()
|
||||
|
||||
@app.get("/overlay", response_class=HTMLResponse)
|
||||
async def get_overlay():
|
||||
with open("index.html", "r") as f:
|
||||
return f.read()
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
connected_clients.append(websocket)
|
||||
try:
|
||||
while True:
|
||||
# Keep the connection alive, or handle incoming messages if needed
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
connected_clients.remove(websocket)
|
||||
BIN
multichat.db
BIN
multichat.db
Binary file not shown.
@@ -1,37 +0,0 @@
|
||||
aiohappyeyeballs==2.6.1
|
||||
aiohttp==3.13.2
|
||||
aiosignal==1.4.0
|
||||
annotated-doc==0.0.4
|
||||
annotated-types==0.7.0
|
||||
anyio==4.11.0
|
||||
attrs==25.4.0
|
||||
certifi==2025.11.12
|
||||
click==8.3.0
|
||||
fastapi==0.121.1
|
||||
frozenlist==1.8.0
|
||||
greenlet==3.2.4
|
||||
gunicorn==23.0.0
|
||||
h11==0.16.0
|
||||
h2==4.3.0
|
||||
hpack==4.1.0
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
hyperframe==6.1.0
|
||||
idna==3.11
|
||||
itsdangerous==2.2.0
|
||||
multidict==6.7.0
|
||||
packaging==25.0
|
||||
propcache==0.4.1
|
||||
psycopg2-binary==2.9.11
|
||||
pydantic==2.12.4
|
||||
pydantic_core==2.41.5
|
||||
pytchat==0.5.5
|
||||
sniffio==1.3.1
|
||||
SQLAlchemy==2.0.44
|
||||
starlette==0.49.3
|
||||
twitchio==3.1.0
|
||||
typing-inspection==0.4.2
|
||||
typing_extensions==4.15.0
|
||||
uvicorn==0.38.0
|
||||
websockets==15.0.1
|
||||
yarl==1.22.0
|
||||
Reference in New Issue
Block a user