heystreamer/app/eventsub.py
Bashy 6ad661c220 Initial commit — BashyOverlay v1
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>
2026-04-22 23:48:35 +03:00

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)