From 382f0ec782f359f735a6dc2b9f29e0732765b347 Mon Sep 17 00:00:00 2001 From: Jo Eskil Date: Thu, 13 Nov 2025 17:05:16 +0100 Subject: [PATCH] Implement Phase 1: User Authentication & Database (Twitch OAuth, Login Page) --- auth.py | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++ database.py | 37 +++++++++++++++++++++++++ login.html | 14 ++++++++++ main.py | 57 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 187 insertions(+) create mode 100644 auth.py create mode 100644 database.py create mode 100644 login.html create mode 100644 main.py diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..6dce2ad --- /dev/null +++ b/auth.py @@ -0,0 +1,79 @@ +# auth.py +from fastapi import APIRouter, Depends +from fastapi.responses import RedirectResponse +from sqlalchemy.orm import Session +import httpx +from database import get_db, User + +# IMPORTANT: These must be replaced with your actual Twitch application credentials +TWITCH_CLIENT_ID = "YOUR_TWITCH_CLIENT_ID" +TWITCH_CLIENT_SECRET = "YOUR_TWITCH_CLIENT_SECRET" +REDIRECT_URI = "http://localhost:8000/auth/twitch/callback" + +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() + + # TODO: Set a session cookie to keep the user logged in + return {"message": f"Successfully logged in as {twitch_username}"} diff --git a/database.py b/database.py new file mode 100644 index 0000000..6c09ba1 --- /dev/null +++ b/database.py @@ -0,0 +1,37 @@ +from sqlalchemy import create_engine, Column, Integer, String +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +DATABASE_URL = "sqlite:///./multichat.db" + +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +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) + +if __name__ == "__main__": + create_tables() diff --git a/login.html b/login.html new file mode 100644 index 0000000..fef3ef5 --- /dev/null +++ b/login.html @@ -0,0 +1,14 @@ + + + + Login to MultiChat Overlay + + +

Login to MultiChat Overlay

+

Connect your streaming accounts to get started.

+ + + + + + diff --git a/main.py b/main.py new file mode 100644 index 0000000..3fec21b --- /dev/null +++ b/main.py @@ -0,0 +1,57 @@ +import asyncio +import json +from fastapi import FastAPI, WebSocket +from fastapi.responses import HTMLResponse +from starlette.websockets import WebSocketDisconnect + +from chat_listeners import listen_youtube_chat, listen_twitch_chat +from auth import router as auth_router # Import the auth router + +app = FastAPI() + +app.include_router(auth_router, prefix="/auth") # Include the auth router + +connected_clients = [] + +async def broadcast_message(message: dict): + # Convert the message dictionary to a JSON string before sending + message_json = json.dumps(message) + for client in connected_clients: + try: + await client.send_text(message_json) + except RuntimeError: + # Handle cases where client might have disconnected + connected_clients.remove(client) + +@app.on_event("startup") +async def startup_event(): + # Start chat listeners in the background + # Replace with actual video ID and Twitch token/channel + # For now, using placeholders. These will need to be configured. + asyncio.create_task(listen_youtube_chat("YOUR_YOUTUBE_VIDEO_ID", broadcast_message)) + asyncio.create_task(listen_twitch_chat("YOUR_TWITCH_OAUTH_TOKEN", "YOUR_TWITCH_CHANNEL", broadcast_message)) + +@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("/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)