Twitch stream event platform with OBS browser source overlay. Features: - Twitch OAuth login (streamer + mod access) - EventSub WebSocket for subs, gift subs, bits, channel points, follows, raids - Chat commands via tmi.js in the overlay - Gap audio — tone plays on new chat message after silence, volume/pitch scale with gap length - Self-hosted media library (upload sounds and videos) - Action configuration — map any event to sound, video, or alert - Per-action cooldowns, test button, enable/disable toggle - Multiple actions per event (all matching actions fire) - Activity log dashboard with EventSub connection status - Layout editor — iframe-based WYSIWYG with drag handles, live style preview - Custom CSS and custom JS injection into overlay - Custom DOM events (bashyoverlay:sub, bashyoverlay:raid, etc.) for custom JS hooks - deploy.sh — one-shot setup and launch script Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
141 lines
5.8 KiB
Python
141 lines
5.8 KiB
Python
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
|
|
import httpx
|
|
import websockets
|
|
|
|
from app.dispatch import dispatch_event
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
EVENTSUB_WS_URL = "wss://eventsub.wss.twitch.tv/ws"
|
|
TWITCH_CLIENT_ID = os.getenv("TWITCH_CLIENT_ID", "")
|
|
TWITCH_CLIENT_SECRET = os.getenv("TWITCH_CLIENT_SECRET", "")
|
|
|
|
SUBSCRIPTIONS = [
|
|
("channel.subscribe", "1", lambda bid: {"broadcaster_user_id": bid}),
|
|
("channel.subscription.message", "1", lambda bid: {"broadcaster_user_id": bid}),
|
|
("channel.subscription.gift", "1", lambda bid: {"broadcaster_user_id": bid}),
|
|
("channel.cheer", "1", lambda bid: {"broadcaster_user_id": bid}),
|
|
("channel.channel_points_custom_reward_redemption.add", "1", lambda bid: {"broadcaster_user_id": bid}),
|
|
("channel.follow", "2", lambda bid: {"broadcaster_user_id": bid, "moderator_user_id": bid}),
|
|
("channel.raid", "1", lambda bid: {"to_broadcaster_user_id": bid}),
|
|
]
|
|
|
|
|
|
async def run_eventsub(broadcaster_id: str, access_token: str) -> None:
|
|
while True:
|
|
try:
|
|
async with websockets.connect(EVENTSUB_WS_URL) as ws:
|
|
logger.info("EventSub connected")
|
|
async for raw in ws:
|
|
msg = json.loads(raw)
|
|
msg_type = msg.get("metadata", {}).get("message_type")
|
|
|
|
if msg_type == "session_welcome":
|
|
session_id = msg["payload"]["session"]["id"]
|
|
token = await _subscribe_all(session_id, broadcaster_id, access_token)
|
|
if token:
|
|
access_token = token
|
|
|
|
elif msg_type == "notification":
|
|
await _handle_notification(msg["payload"])
|
|
|
|
elif msg_type == "session_reconnect":
|
|
reconnect_url = msg["payload"]["session"]["reconnect_url"]
|
|
logger.info("EventSub reconnect requested")
|
|
break
|
|
|
|
except asyncio.CancelledError:
|
|
logger.info("EventSub task cancelled")
|
|
return
|
|
except Exception as e:
|
|
logger.warning(f"EventSub error: {e}, reconnecting in 10s")
|
|
await asyncio.sleep(10)
|
|
|
|
|
|
async def _subscribe_all(session_id: str, broadcaster_id: str, access_token: str) -> str | None:
|
|
transport = {"method": "websocket", "session_id": session_id}
|
|
condition = {"broadcaster_user_id": broadcaster_id}
|
|
headers = {
|
|
"Authorization": f"Bearer {access_token}",
|
|
"Client-Id": TWITCH_CLIENT_ID,
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
for sub_type, version, condition_fn in SUBSCRIPTIONS:
|
|
condition = condition_fn(broadcaster_id)
|
|
r = await client.post(
|
|
"https://api.twitch.tv/helix/eventsub/subscriptions",
|
|
headers=headers,
|
|
json={"type": sub_type, "version": version, "condition": condition, "transport": transport},
|
|
)
|
|
if r.status_code == 401:
|
|
new_token = await _refresh_token(access_token)
|
|
if new_token:
|
|
headers["Authorization"] = f"Bearer {new_token}"
|
|
await client.post(
|
|
"https://api.twitch.tv/helix/eventsub/subscriptions",
|
|
headers=headers,
|
|
json={"type": sub_type, "version": version, "condition": condition, "transport": transport},
|
|
)
|
|
return new_token
|
|
elif r.status_code not in (200, 202, 409):
|
|
logger.warning(f"Failed to subscribe to {sub_type}: {r.status_code} {r.text}")
|
|
return None
|
|
|
|
|
|
async def _refresh_token(old_token: str) -> str | None:
|
|
from app.database import engine
|
|
from app.models import User
|
|
from sqlmodel import Session, select
|
|
|
|
with Session(engine) as session:
|
|
user = session.exec(select(User).where(User.access_token == old_token)).first()
|
|
if not user:
|
|
return None
|
|
async with httpx.AsyncClient() as client:
|
|
r = await client.post("https://id.twitch.tv/oauth2/token", data={
|
|
"client_id": TWITCH_CLIENT_ID,
|
|
"client_secret": TWITCH_CLIENT_SECRET,
|
|
"grant_type": "refresh_token",
|
|
"refresh_token": user.refresh_token,
|
|
})
|
|
if r.status_code != 200:
|
|
return None
|
|
data = r.json()
|
|
user.access_token = data["access_token"]
|
|
user.refresh_token = data.get("refresh_token", user.refresh_token)
|
|
session.add(user)
|
|
session.commit()
|
|
return user.access_token
|
|
|
|
|
|
async def _handle_notification(payload: dict) -> None:
|
|
sub_type = payload["subscription"]["type"]
|
|
event = payload["event"]
|
|
username = event.get("user_name") or event.get("user_login")
|
|
|
|
if sub_type in ("channel.subscribe", "channel.subscription.message"):
|
|
await dispatch_event("sub", username=username, event_detail=event.get("tier", "1000"))
|
|
|
|
elif sub_type == "channel.subscription.gift":
|
|
await dispatch_event("gift_sub", username=username, event_detail=event.get("tier", "1000"))
|
|
|
|
elif sub_type == "channel.cheer":
|
|
await dispatch_event("bits", username=username, bits_amount=event.get("bits", 0))
|
|
|
|
elif sub_type == "channel.channel_points_custom_reward_redemption.add":
|
|
reward_id = event.get("reward", {}).get("id")
|
|
await dispatch_event("channel_points", username=username, event_detail=reward_id)
|
|
|
|
elif sub_type == "channel.follow":
|
|
await dispatch_event("follow", username=username)
|
|
|
|
elif sub_type == "channel.raid":
|
|
raider = event.get("from_broadcaster_user_name") or event.get("from_broadcaster_user_login")
|
|
viewers = str(event.get("viewers", ""))
|
|
await dispatch_event("raid", username=raider, event_detail=viewers)
|