main: Initial commit

This commit is contained in:
2025-04-14 17:38:21 -04:00
parent 0296cd4f54
commit f2b8f75a2c
9 changed files with 1847 additions and 0 deletions
+449
View File
@@ -0,0 +1,449 @@
from flask import Flask, request, jsonify, render_template, send_from_directory
from flask_socketio import SocketIO
from flask_sqlalchemy import SQLAlchemy
from datetime import datetime, timedelta
import twitch_auth
import twitch_events
import requests
import threading
import time
import json
import os
# Before running code remember to start up ngrok server and set up all appropriate locations for the forwarded URL
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///twitch_events.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
socketio = SocketIO(app, cors_allowed_origins="*")
db = SQLAlchemy(app)
# Database models
class TwitchEvent(db.Model):
id = db.Column(db.Integer, primary_key=True)
event_type = db.Column(db.String(50))
user_name = db.Column(db.String(50))
details = db.Column(db.Text)
timestamp = db.Column(db.DateTime, default=datetime.now)
class ChatMessage(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(50))
message = db.Column(db.Text)
tags = db.Column(db.Text) # Store tags as JSON
timestamp = db.Column(db.DateTime, default=datetime.now)
class StreamStat(db.Model):
id = db.Column(db.Integer, primary_key=True)
viewer_count = db.Column(db.Integer, default=0)
timestamp = db.Column(db.DateTime, default=datetime.now)
class AlertSettings(db.Model):
id = db.Column(db.Integer, primary_key=True)
alert_type = db.Column(db.String(50), unique=True) # follow, sub, cheer, etc.
duration = db.Column(db.Integer, default=5) # seconds
sound_effect = db.Column(db.String(100), default="default.wav")
animation = db.Column(db.String(50), default="fade")
enabled = db.Column(db.Boolean, default=True)
min_value = db.Column(db.Integer, default=0) # For bits, minimum to display
font_size = db.Column(db.Integer, default=24) # For text size
@staticmethod
def get_default_settings():
default_settings = {
"follow": {
"duration": 5,
"sound_effect": "follow.wav",
"animation": "fade",
"enabled": True,
"min_value": 0,
"font_size": 24
},
"subscription": {
"duration": 8,
"sound_effect": "sub.wav",
"animation": "confetti",
"enabled": True,
"min_value": 0,
"font_size": 28
},
"cheer": {
"duration": 6,
"sound_effect": "cheer.wav",
"animation": "rain",
"enabled": True,
"min_value": 100, # Minimum bits to display
"font_size": 26
},
"raid": {
"duration": 10,
"sound_effect": "raid.wav",
"animation": "slide",
"enabled": True,
"min_value": 0,
"font_size": 30
},
"channel_points": {
"duration": 4,
"sound_effect": "redeem.wav",
"animation": "bounce",
"enabled": True,
"min_value": 0,
"font_size": 22
}
}
return default_settings
# Serve static files
@app.route('/static/<path:path>')
def serve_static(path):
return send_from_directory('static', path)
@app.route("/")
def home():
return f'<a href="{twitch_auth.get_oauth_url()}">🔗 Connect Twitch</a>'
@app.route("/callback")
def callback():
print("🔗 Received callback request:", request.args)
auth_code = request.args.get("code")
if not auth_code:
print("❌ No authorization code received!")
return "❌ Authorization failed! No code received.", 400
# ✅ Get the broadcaster's access token, not the bot's
access_token = twitch_auth.get_user_access_token(auth_code)
if not access_token:
print(f"❌ Failed to get access token for code: {auth_code}")
return "❌ Failed to get access token", 400
print("✅ Broadcaster access token retrieved successfully!")
twitch_events.subscribe_to_twitch_events() # ✅ Use broadcaster's token
return "✅ Twitch Alerts Connected!"
@app.route("/overlay")
def overlay():
return render_template("overlay.html")
@app.route("/dashboard")
def dashboard():
return render_template("dashboard.html")
# API endpoints for the dashboard
@app.route("/api/events")
def get_events():
events = TwitchEvent.query.order_by(TwitchEvent.timestamp.desc()).limit(50).all()
return jsonify([{
"type": e.event_type,
"data": json.loads(e.details),
"time": e.timestamp.isoformat(),
"user_name": e.user_name
} for e in events])
@app.route("/api/stats")
def get_stats():
# Get today's date at midnight
today_midnight = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
# Count today's follows
today_follows = TwitchEvent.query.filter(
TwitchEvent.event_type == "channel.follow",
TwitchEvent.timestamp >= today_midnight
).count()
# Count today's subs
today_subs = TwitchEvent.query.filter(
TwitchEvent.event_type == "channel.subscribe",
TwitchEvent.timestamp >= today_midnight
).count()
# Get latest viewer count
latest_stats = StreamStat.query.order_by(StreamStat.timestamp.desc()).first()
viewer_count = latest_stats.viewer_count if latest_stats else 0
# Calculate messages per minute
one_minute_ago = datetime.now() - timedelta(minutes=1)
messages_last_minute = ChatMessage.query.filter(
ChatMessage.timestamp >= one_minute_ago
).count()
return jsonify({
"today_followers": today_follows,
"today_subs": today_subs,
"viewer_count": viewer_count,
"messages_per_minute": messages_last_minute
})
@app.route("/api/chat")
def get_chat():
# Get recent chat messages
messages = ChatMessage.query.order_by(ChatMessage.timestamp.desc()).limit(100).all()
return jsonify([{
"username": m.username,
"message": m.message,
"tags": json.loads(m.tags) if m.tags else {},
"time": m.timestamp.isoformat()
} for m in messages])
@app.route("/api/alert-settings", methods=["GET"])
def get_alert_settings():
"""Get all alert settings."""
settings = AlertSettings.query.all()
# If no settings exist, create defaults
if not settings:
default_settings = AlertSettings.get_default_settings()
for alert_type, config in default_settings.items():
new_setting = AlertSettings(
alert_type=alert_type,
**config
)
db.session.add(new_setting)
db.session.commit()
settings = AlertSettings.query.all()
# Convert to dictionary
result = {}
for setting in settings:
result[setting.alert_type] = {
"duration": setting.duration,
"sound_effect": setting.sound_effect,
"animation": setting.animation,
"enabled": setting.enabled,
"min_value": setting.min_value,
"font_size": setting.font_size
}
return jsonify(result)
@app.route("/api/alert-settings/<alert_type>", methods=["PUT"])
def update_alert_setting(alert_type):
"""Update settings for a specific alert type."""
data = request.json
setting = AlertSettings.query.filter_by(alert_type=alert_type).first()
# If setting doesn't exist, create it
if not setting:
setting = AlertSettings(alert_type=alert_type)
db.session.add(setting)
# Update fields
if "duration" in data:
setting.duration = int(data["duration"])
if "sound_effect" in data:
setting.sound_effect = data["sound_effect"]
if "animation" in data:
setting.animation = data["animation"]
if "enabled" in data:
setting.enabled = bool(data["enabled"])
if "min_value" in data:
setting.min_value = int(data["min_value"])
if "font_size" in data:
setting.font_size = int(data["font_size"])
db.session.commit()
# Notify clients of the change
socketio.emit("alert_settings_updated", {
"type": alert_type,
"settings": {
"duration": setting.duration,
"sound_effect": setting.sound_effect,
"animation": setting.animation,
"enabled": setting.enabled,
"min_value": setting.min_value,
"font_size": setting.font_size
}
})
return jsonify({"success": True})
@app.route("/webhook", methods=["POST"])
def webhook():
payload = request.json
if "challenge" in payload:
return payload["challenge"]
print("🚀 Twitch Event Received:", payload)
event_type = payload["subscription"]["type"]
event_data = payload["event"]
# Store event in database
user_name = event_data.get("user_name", "")
if not user_name and event_type == "channel.raid":
user_name = event_data.get("from_broadcaster_user_name", "")
new_event = TwitchEvent(
event_type=event_type,
user_name=user_name,
details=json.dumps(event_data)
)
db.session.add(new_event)
db.session.commit()
# Emit to websocket clients
if event_type == "channel.channel_points_custom_reward_redemption.add":
socketio.emit("twitch_alert", {
"type": "channel_points",
"data": {
"user_name": event_data["user_name"],
"reward": event_data["reward"]["title"],
"message": event_data.get("user_input", ""),
"reward_color": event_data["reward"].get("background_color", "")
}
})
elif event_type == "channel.cheer":
# Get minimum bits setting
cheer_settings = AlertSettings.query.filter_by(alert_type="cheer").first()
min_bits = cheer_settings.min_value if cheer_settings else 0
# Only emit if bits are above minimum
if int(event_data.get("bits", 0)) >= min_bits:
socketio.emit("twitch_alert", {"type": event_type, "data": event_data})
else:
socketio.emit("twitch_alert", {"type": event_type, "data": event_data})
return jsonify({"message": "Received"}), 200
def handle_chat_message(username, message, tags=None):
# Create an application context for database operations
with app.app_context():
try:
# Store message in database
new_message = ChatMessage(
username=username,
message=message,
tags=json.dumps(tags) if tags else None
)
db.session.add(new_message)
db.session.commit()
# Emit to websocket clients
socketio.emit("chat_message", {
"username": username,
"message": message,
"tags": tags or {}
})
except Exception as e:
print(f"Error handling chat message: {e}")
def emit_system_message(text, level="info"):
"""Emit a system message to the dashboard.
Args:
text: The message text
level: The message level (info, warning, error)
"""
socketio.emit("system_message", {
"text": text,
"level": level,
"timestamp": datetime.now().isoformat()
})
# Function to periodically fetch viewer count
def fetch_viewer_count():
"""Background task to fetch viewer count from Twitch API periodically"""
while True:
try:
# Only fetch when we have valid credentials
if twitch_auth.config.USER_ACCESS_TOKEN:
url = f"https://api.twitch.tv/helix/streams?user_id={twitch_auth.config.BROADCASTER_ID}"
headers = {
"Client-ID": twitch_auth.config.CLIENT_ID,
"Authorization": f"Bearer {twitch_auth.config.USER_ACCESS_TOKEN}"
}
response = requests.get(url, headers=headers).json()
stream_data = response.get("data", [])
# Check if stream is live
if stream_data:
viewer_count = stream_data[0].get("viewer_count", 0)
# Store in database
new_stat = StreamStat(viewer_count=viewer_count)
db.session.add(new_stat)
db.session.commit()
# Emit to websocket clients
socketio.emit("viewer_count", viewer_count)
else:
# Stream is offline
socketio.emit("viewer_count", 0)
except Exception as e:
print(f"Error fetching viewer count: {e}")
# Sleep for 1 minute
time.sleep(60)
@socketio.on("connect")
def handle_connect():
print("✅ WebSocket Client Connected!")
client_type = request.args.get('client_type', 'overlay')
print(f"Client connected with type: {client_type}")
# Emit system message
emit_system_message(f"Client connected with type: {client_type}")
if client_type == 'dashboard':
# For dashboard, send recent events for display but don't trigger alerts
recent_events = TwitchEvent.query.order_by(TwitchEvent.timestamp.desc()).limit(10).all()
for event in recent_events:
socketio.emit("dashboard_event", {
"type": event.event_type,
"data": json.loads(event.details)
})
# Create database tables before running
def initialize_database():
with app.app_context():
db.create_all()
print("✅ Database initialized")
# Ensure default alert settings exist
if AlertSettings.query.count() == 0:
default_settings = AlertSettings.get_default_settings()
for alert_type, config in default_settings.items():
new_setting = AlertSettings(
alert_type=alert_type,
**config
)
db.session.add(new_setting)
db.session.commit()
print("✅ Default alert settings created")
if __name__ == "__main__":
try:
# Initialize the database
initialize_database()
# Ensure static folder exists
if not os.path.exists('static'):
os.makedirs('static')
os.makedirs('static/sounds')
os.makedirs('static/images')
print("✅ Created static folders")
# Ensure bot authentication
print("🔄 Ensuring bot authentication before starting...")
twitch_auth.ensure_bot_token() # Ensure bot token is valid or refresh it
# Start background tasks
print("🔄 Starting background tasks...")
viewer_thread = threading.Thread(target=fetch_viewer_count, daemon=True)
viewer_thread.start()
print("✅ Bot authentication complete. Starting Twitch chat...")
# Modify twitch_events.py to use our handle_chat_message function
twitch_events.handle_chat_message = handle_chat_message
twitch_events.start_twitch_chat() # Start chat listener
print("🚀 Starting Flask server...")
socketio.run(app, host="0.0.0.0", port=5000)
except KeyboardInterrupt:
print("\n🛑 Server shutting down gracefully. Goodbye!\n")
+1
View File
@@ -0,0 +1 @@
6exdio209f716ixylaq1gtrxnrkr7g
BIN
View File
Binary file not shown.
+14
View File
@@ -0,0 +1,14 @@
CLIENT_ID = "wo004y6xsfgpfhlzvx5jmbvkv8qnea"
CLIENT_SECRET = "ftjppfz98zan60crwj76b7u5mrkyef"
BROADCASTER_ID = "202641603"
REDIRECT_URI = "https://9453-2601-541-e01-8d30-00-e2e.ngrok-free.app/callback"
WEBHOOK_SECRET = "c3c50697b676ce8bca9ed4d0c81976868875ea48a6c7fb440cad6331afde3eb3"
WEBHOOK_CALLBACK = "https://9453-2601-541-e01-8d30-00-e2e.ngrok-free.app/webhook"
TWITCH_CHAT_URL = "wss://irc-ws.chat.twitch.tv:443"
TWITCH_BOT_NICKNAME= "pccbot"
BOT_OAUTH_TOKEN= "oauth:l6bf31p12o1dqgakr0uqnw8udbdhiu"
BOT_CLIENT_ID = "t9ydbg9oecyvz78wsulfub4qbu3zm5"
BOT_CLIENT_SECRET = "5ihxlxeoynmfa3t9etcvo3znpxc0to"
TWITCH_CHANNEL = "procptcasual"
USER_ACCESS_TOKEN = None
REFRESH_TOKEN = None
View File
+886
View File
@@ -0,0 +1,886 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Twitch Stream Dashboard</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js"></script>
<style>
:root {
--twitch-purple: #9146FF;
--twitch-light: #e2d7f4;
--dark-bg: #18181b;
--card-bg: #1f1f23;
--text-color: #efeff1;
--muted-text: #adadb8;
--border-color: #303032;
}
body {
background-color: var(--dark-bg);
color: var(--text-color);
font-family: 'Inter', sans-serif;
}
.navbar {
background-color: var(--card-bg);
border-bottom: 1px solid var(--border-color);
}
.navbar-brand {
color: var(--twitch-purple);
font-weight: bold;
}
.card {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
margin-bottom: 1rem;
}
.card-header {
background-color: rgba(0,0,0,0.2);
border-bottom: 1px solid var(--border-color);
font-weight: bold;
}
.stat-card {
text-align: center;
padding: 1rem;
}
.stat-value {
font-size: 2rem;
font-weight: bold;
color: var(--twitch-purple);
}
.stat-label {
font-size: 0.9rem;
color: var(--muted-text);
}
.events-list {
max-height: 400px;
overflow-y: auto;
}
.event-item {
border-left: 4px solid var(--twitch-purple);
margin-bottom: 0.5rem;
padding: 0.5rem;
background-color: rgba(145, 70, 255, 0.1);
}
.event-item.follow {
border-left-color: #9146FF;
}
.event-item.subscription {
border-left-color: #00b8ff;
}
.event-item.cheer {
border-left-color: #ffca61;
}
.event-item.raid {
border-left-color: #ff6446;
}
.event-item.channel_points {
border-left-color: #41f097;
}
.event-time {
font-size: 0.8rem;
color: var(--muted-text);
}
.nav-tabs {
border-bottom-color: var(--border-color);
}
.nav-tabs .nav-link {
color: var(--muted-text);
border: none;
}
.nav-tabs .nav-link:hover {
border-color: transparent;
color: var(--text-color);
}
.nav-tabs .nav-link.active {
background-color: transparent;
border-bottom: 2px solid var(--twitch-purple);
color: var(--twitch-purple);
}
.btn-twitch {
background-color: var(--twitch-purple);
border-color: var(--twitch-purple);
color: white;
}
.btn-twitch:hover {
background-color: #7e3bd9;
border-color: #7e3bd9;
color: white;
}
.form-control, .form-select {
background-color: var(--dark-bg);
border-color: var(--border-color);
color: var(--text-color);
}
.form-control:focus, .form-select:focus {
background-color: var(--dark-bg);
color: var(--text-color);
border-color: var(--twitch-purple);
box-shadow: 0 0 0 0.25rem rgba(145, 70, 255, 0.25);
}
.toast-notification {
position: fixed;
bottom: 20px;
right: 20px;
padding: 10px 20px;
background-color: rgba(65, 240, 151, 0.9);
color: #1f1f23;
border-radius: 5px;
transition: all 0.3s ease;
transform: translateY(100px);
opacity: 0;
z-index: 10000;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5);
}
.toast-notification.show {
transform: translateY(0);
opacity: 1;
}
.toast-notification.error {
background-color: rgba(255, 100, 70, 0.9);
color: white;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--dark-bg);
}
::-webkit-scrollbar-thumb {
background: #333339;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #3f3f47;
}
.chat-message {
padding: 0.5rem;
border-bottom: 1px solid var(--border-color);
}
.username {
font-weight: bold;
}
.message-text {
word-break: break-word;
}
.reward-item {
display: flex;
align-items: center;
padding: 0.5rem;
border-bottom: 1px solid var(--border-color);
}
.reward-color {
width: 16px;
height: 16px;
border-radius: 50%;
margin-right: 0.5rem;
}
.reward-title {
flex-grow: 1;
}
.reward-cost {
font-weight: bold;
color: #41f097;
}
.system-message {
padding: 0.5rem;
border-bottom: 1px solid var(--border-color);
font-family: monospace;
}
.system-time {
color: var(--muted-text);
margin-right: 0.5rem;
}
.system-text {
color: #41f097;
}
.system-text.error {
color: #ff6446;
}
.system-text.warning {
color: #ffca61;
}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg">
<div class="container-fluid">
<a class="navbar-brand" href="#">
<i class="fa-brands fa-twitch me-2"></i>Twitch Dashboard
</a>
<div class="navbar-text ms-auto">
<span id="connection-status" class="text-success">
<i class="fas fa-circle me-1"></i>Connected
</span>
</div>
</div>
</nav>
<div class="container-fluid py-4">
<div class="row">
<!-- Statistics Cards -->
<div class="col-lg-8">
<div class="row">
<div class="col-md-3 col-sm-6">
<div class="card stat-card">
<div class="stat-value" id="viewer-count">0</div>
<div class="stat-label">Current Viewers</div>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="card stat-card">
<div class="stat-value" id="chat-rate">0</div>
<div class="stat-label">Messages/Minute</div>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="card stat-card">
<div class="stat-value" id="follower-count">0</div>
<div class="stat-label">Today's Followers</div>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="card stat-card">
<div class="stat-value" id="sub-count">0</div>
<div class="stat-label">Today's Subs</div>
</div>
</div>
</div>
<!-- Activity Charts -->
<div class="card mt-4">
<div class="card-header">
Stream Activity
</div>
<div class="card-body">
<canvas id="activity-chart" height="250"></canvas>
</div>
</div>
<!-- Recent Events -->
<div class="card mt-4">
<div class="card-header">
Recent Events
</div>
<div class="card-body events-list p-2" id="events-container">
<!-- Events will be inserted here -->
</div>
</div>
</div>
<!-- Right Sidebar -->
<div class="col-lg-4">
<!-- Chat Box -->
<div class="card">
<div class="card-header">
Live Chat
</div>
<div class="card-body p-0">
<div class="chat-messages" id="chat-messages" style="height: 300px; overflow-y: auto;">
<!-- Chat messages will be inserted here -->
</div>
</div>
</div>
<div class="card mt-4">
<div class="card-header">
System Messages
</div>
<div class="card-body p-0">
<div class="chat-messages" id="system-messages" style="height: 200px; overflow-y: auto;">
</div>
</div>
</div>
<!-- Channel Point Redemptions -->
<div class="card mt-4">
<div class="card-header">
Channel Point Redemptions
</div>
<div class="card-body p-0">
<div class="reward-list" id="reward-list" style="max-height: 250px; overflow-y: auto;">
<!-- Redemptions will be inserted here -->
</div>
</div>
</div>
<!-- Alert Customization -->
<div class="card mt-4">
<div class="card-header">
Alert Settings
</div>
<div class="card-body">
<ul class="nav nav-tabs" id="alertTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="follows-tab" data-bs-toggle="tab" data-bs-target="#follows" type="button" role="tab">Follows</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="subs-tab" data-bs-toggle="tab" data-bs-target="#subs" type="button" role="tab">Subs</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="cheers-tab" data-bs-toggle="tab" data-bs-target="#cheers" type="button" role="tab">Cheers</button>
</li>
</ul>
<div class="tab-content p-3" id="alertTabsContent">
<div class="tab-pane fade show active" id="follows" role="tabpanel">
<div class="mb-3">
<label class="form-label">Alert Duration (seconds)</label>
<input type="number" class="form-control" id="follow-duration" value="5">
</div>
<div class="mb-3">
<label class="form-label">Sound Effect</label>
<select class="form-select" id="follow-sound">
<option value="follow.wav">Default Follow</option>
<option value="ding.wav">Ding</option>
<option value="none">No Sound</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Animation Style</label>
<select class="form-select" id="follow-animation">
<option value="fade">Fade</option>
<option value="slide">Slide</option>
<option value="bounce">Bounce</option>
</select>
</div>
<button class="btn btn-twitch" id="save-follow-settings">Save Follow Settings</button>
</div>
<div class="tab-pane fade" id="subs" role="tabpanel">
<div class="mb-3">
<label class="form-label">Alert Duration (seconds)</label>
<input type="number" class="form-control" id="sub-duration" value="8">
</div>
<div class="mb-3">
<label class="form-label">Sound Effect</label>
<select class="form-select" id="sub-sound">
<option value="sub.wav">Default Sub</option>
<option value="cheer.wav">Cheer</option>
<option value="none">No Sound</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Animation Style</label>
<select class="form-select" id="sub-animation">
<option value="confetti">Confetti</option>
<option value="slide">Slide</option>
<option value="bounce">Bounce</option>
</select>
</div>
<button class="btn btn-twitch" id="save-sub-settings">Save Sub Settings</button>
</div>
<div class="tab-pane fade" id="cheers" role="tabpanel">
<div class="mb-3">
<label class="form-label">Minimum Bits to Display</label>
<input type="number" class="form-control" id="bits-min" value="100">
</div>
<div class="mb-3">
<label class="form-label">Sound Effect</label>
<select class="form-select" id="cheer-sound">
<option value="cheer.wav">Default Cheer</option>
<option value="cash.wav">Cash Register</option>
<option value="none">No Sound</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Animation Style</label>
<select class="form-select" id="cheer-animation">
<option value="rain">Bit Rain</option>
<option value="explosion">Explosion</option>
<option value="simple">Simple</option>
</select>
</div>
<button class="btn btn-twitch" id="save-cheer-settings">Save Cheer Settings</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.5.1/socket.io.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.0/js/bootstrap.bundle.min.js"></script>
<script>
// Connect to Socket.IO server
const socket = io({
query: {
client_type: 'dashboard'
}
});
let eventCount = 0;
let chatMessages = [];
let lastMinuteMessages = [];
let chatRateUpdateInterval;
// Sample data for chart
let activityData = {
labels: [],
viewers: [],
chatRate: []
};
// Initialize for past 30 minutes
for (let i = 30; i > 0; i--) {
const time = new Date();
time.setMinutes(time.getMinutes() - i);
activityData.labels.push(time.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}));
activityData.viewers.push(0);
activityData.chatRate.push(0);
}
// Initialize Chart
const ctx = document.getElementById('activity-chart').getContext('2d');
const activityChart = new Chart(ctx, {
type: 'line',
data: {
labels: activityData.labels,
datasets: [
{
label: 'Viewers',
data: activityData.viewers,
borderColor: '#9146FF',
backgroundColor: 'rgba(145, 70, 255, 0.1)',
tension: 0.4,
fill: true
},
{
label: 'Messages/Minute',
data: activityData.chatRate,
borderColor: '#41f097',
backgroundColor: 'rgba(65, 240, 151, 0.1)',
tension: 0.4,
fill: true
}
]
},
options: {
responsive: true,
interaction: {
mode: 'index',
intersect: false,
},
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(255, 255, 255, 0.1)'
},
ticks: {
color: '#adadb8'
}
},
x: {
grid: {
color: 'rgba(255, 255, 255, 0.1)'
},
ticks: {
color: '#adadb8'
}
}
},
plugins: {
legend: {
labels: {
color: '#efeff1'
}
}
}
}
});
// Connect to Socket.IO
socket.on('connect', () => {
document.getElementById('connection-status').classList.remove('text-danger');
document.getElementById('connection-status').classList.add('text-success');
document.getElementById('connection-status').innerHTML = '<i class="fas fa-circle me-1"></i>Connected';
// Start tracking chat rate
startChatRateTracking();
});
socket.on('disconnect', () => {
document.getElementById('connection-status').classList.remove('text-success');
document.getElementById('connection-status').classList.add('text-danger');
document.getElementById('connection-status').innerHTML = '<i class="fas fa-circle me-1"></i>Disconnected';
// Stop tracking when disconnected
clearInterval(chatRateUpdateInterval);
});
// Listen for Twitch events
socket.on('twitch_alert', (data) => {
addEvent(data);
updateStats(data);
});
// Listen for dashboard-only events (non-alerting historical events)
socket.on('dashboard_event', (data) => {
addEvent(data);
// Don't call updateStats for historical events
});
// Listen for viewer count updates
socket.on('viewer_count', (count) => {
document.getElementById('viewer-count').textContent = count;
updateViewerChart(count);
});
// Listen for chat messages
socket.on('chat_message', (message) => {
addChatMessage(message);
lastMinuteMessages.push(new Date());
updateChatRate();
});
// Listen for system messages
socket.on('system_message', (message) => {
addSystemMessage(message);
});
function addSystemMessage(message) {
const systemContainer = document.getElementById('system-messages');
const messageDiv = document.createElement('div');
messageDiv.className = 'system-message';
const time = new Date().toLocaleTimeString();
messageDiv.innerHTML = `
<span class="system-time">[${time}]</span>
<span class="system-text">${message.text}</span>
`;
systemContainer.appendChild(messageDiv);
systemContainer.scrollTop = systemContainer.scrollHeight;
// Limit to last 100 messages
if (systemContainer.children.length > 100) {
systemContainer.removeChild(systemContainer.firstChild);
}
}
function startChatRateTracking() {
// Update chat rate every 10 seconds
chatRateUpdateInterval = setInterval(() => {
updateChatRate();
// Update chart every minute
if (new Date().getSeconds() < 10) {
updateActivityChart();
}
}, 10000);
}
function updateChatRate() {
// Filter messages from the last minute
const now = new Date();
lastMinuteMessages = lastMinuteMessages.filter(time => {
return (now - time) < 60000; // less than 1 minute old
});
// Update the display
document.getElementById('chat-rate').textContent = lastMinuteMessages.length;
}
function updateActivityChart() {
// Add new data point
const now = new Date();
const timeLabel = now.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
activityData.labels.push(timeLabel);
activityData.viewers.push(parseInt(document.getElementById('viewer-count').textContent));
activityData.chatRate.push(parseInt(document.getElementById('chat-rate').textContent));
// Remove oldest data point to keep 30 points
if (activityData.labels.length > 30) {
activityData.labels.shift();
activityData.viewers.shift();
activityData.chatRate.shift();
}
// Update chart
activityChart.data.labels = activityData.labels;
activityChart.data.datasets[0].data = activityData.viewers;
activityChart.data.datasets[1].data = activityData.chatRate;
activityChart.update();
}
function updateViewerChart(count) {
// Update last data point with current viewer count
activityChart.data.datasets[0].data[activityChart.data.datasets[0].data.length - 1] = count;
activityChart.update();
}
function addEvent(data) {
const eventsContainer = document.getElementById('events-container');
const eventItem = document.createElement('div');
eventItem.className = `event-item ${data.type}`;
let eventContent = '';
const time = new Date().toLocaleTimeString();
switch(data.type) {
case 'channel.follow':
eventContent = `<strong>${data.data.user_name}</strong> followed the channel`;
incrementStat('follower-count');
break;
case 'channel.subscribe':
const months = data.data.tier === "prime" ? "with Prime" : `(${data.data.tier})`;
eventContent = `<strong>${data.data.user_name}</strong> subscribed ${months}`;
incrementStat('sub-count');
break;
case 'channel.cheer':
eventContent = `<strong>${data.data.user_name}</strong> cheered ${data.data.bits} bits`;
break;
case 'channel.raid':
eventContent = `<strong>${data.data.from_broadcaster_user_name}</strong> raided with ${data.data.viewers} viewers`;
break;
case 'channel_points':
eventContent = `<strong>${data.data.user_name}</strong> redeemed ${data.data.reward}`;
addRedemption(data.data);
break;
}
eventItem.innerHTML = `
${eventContent}
<div class="event-time">${time}</div>
`;
eventsContainer.prepend(eventItem);
// Limit to last 50 events
if (eventsContainer.children.length > 50) {
eventsContainer.removeChild(eventsContainer.lastChild);
}
}
function addChatMessage(message) {
const chatContainer = document.getElementById('chat-messages');
const messageDiv = document.createElement('div');
messageDiv.className = 'chat-message';
let color = '#efeff1'; // Default color
if (message.tags && message.tags.color) {
color = message.tags.color;
}
messageDiv.innerHTML = `
<span class="username" style="color: ${color};">${message.username}:</span>
<span class="message-text">${message.message}</span>
`;
chatContainer.appendChild(messageDiv);
chatContainer.scrollTop = chatContainer.scrollHeight;
// Limit to last 100 messages
if (chatContainer.children.length > 100) {
chatContainer.removeChild(chatContainer.firstChild);
}
}
function addRedemption(data) {
const rewardList = document.getElementById('reward-list');
const rewardItem = document.createElement('div');
rewardItem.className = 'reward-item';
// Generate a random color if not provided
const color = data.reward_color || getRandomColor();
rewardItem.innerHTML = `
<div class="reward-color" style="background-color: ${color};"></div>
<div class="reward-title">${data.reward}</div>
<div class="reward-user">${data.user_name}</div>
`;
rewardList.prepend(rewardItem);
// Limit to last 20 redemptions
if (rewardList.children.length > 20) {
rewardList.removeChild(rewardList.lastChild);
}
}
function getRandomColor() {
const colors = ['#9146FF', '#00b8ff', '#ffca61', '#ff6446', '#41f097'];
return colors[Math.floor(Math.random() * colors.length)];
}
function incrementStat(id) {
const element = document.getElementById(id);
const currentValue = parseInt(element.textContent);
element.textContent = currentValue + 1;
}
// Load alert settings
function loadAlertSettings() {
fetch('/api/alert-settings')
.then(response => response.json())
.then(settings => {
// Populate the form fields with the current settings
if (settings.follow) {
document.getElementById('follow-duration').value = settings.follow.duration;
document.getElementById('follow-sound').value = settings.follow.sound_effect;
document.getElementById('follow-animation').value = settings.follow.animation;
}
if (settings.subscription) {
document.getElementById('sub-duration').value = settings.subscription.duration;
document.getElementById('sub-sound').value = settings.subscription.sound_effect;
document.getElementById('sub-animation').value = settings.subscription.animation;
}
if (settings.cheer) {
document.getElementById('bits-min').value = settings.cheer.min_value;
document.getElementById('cheer-sound').value = settings.cheer.sound_effect;
document.getElementById('cheer-animation').value = settings.cheer.animation;
}
})
.catch(error => console.error('Error loading alert settings:', error));
}
// Save alert settings
function saveAlertSettings(alertType, settings) {
fetch(`/api/alert-settings/${alertType}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(settings)
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Show success notification
showToast(`${alertType.charAt(0).toUpperCase() + alertType.slice(1)} settings saved successfully!`);
}
})
.catch(error => {
console.error(`Error saving ${alertType} settings:`, error);
showToast(`Error saving ${alertType} settings`, 'error');
});
}
// Show toast notification
function showToast(message, type = 'success') {
const toastContainer = document.createElement('div');
toastContainer.className = `toast-notification ${type}`;
toastContainer.innerHTML = message;
document.body.appendChild(toastContainer);
setTimeout(() => {
toastContainer.classList.add('show');
}, 100);
setTimeout(() => {
toastContainer.classList.remove('show');
setTimeout(() => {
document.body.removeChild(toastContainer);
}, 300);
}, 3000);
}
// Event listeners for save buttons
document.getElementById('save-follow-settings').addEventListener('click', () => {
const settings = {
duration: parseInt(document.getElementById('follow-duration').value),
sound_effect: document.getElementById('follow-sound').value,
animation: document.getElementById('follow-animation').value,
enabled: true
};
saveAlertSettings('follow', settings);
});
document.getElementById('save-sub-settings').addEventListener('click', () => {
const settings = {
duration: parseInt(document.getElementById('sub-duration').value),
sound_effect: document.getElementById('sub-sound').value,
animation: document.getElementById('sub-animation').value,
enabled: true
};
saveAlertSettings('subscription', settings);
});
document.getElementById('save-cheer-settings').addEventListener('click', () => {
const settings = {
duration: parseInt(document.getElementById('cheer-duration') ? document.getElementById('cheer-duration').value : 6),
sound_effect: document.getElementById('cheer-sound').value,
animation: document.getElementById('cheer-animation').value,
min_value: parseInt(document.getElementById('bits-min').value),
enabled: true
};
saveAlertSettings('cheer', settings);
});
// Fetch initial data on page load
window.addEventListener('load', () => {
// Load events
fetch('/api/events')
.then(response => response.json())
.then(events => {
events.forEach(event => addEvent(event));
})
.catch(error => console.error('Error fetching events:', error));
// Load stats
fetch('/api/stats')
.then(response => response.json())
.then(stats => {
document.getElementById('viewer-count').textContent = stats.viewer_count || 0;
document.getElementById('follower-count').textContent = stats.today_followers || 0;
document.getElementById('sub-count').textContent = stats.today_subs || 0;
})
.catch(error => console.error('Error fetching stats:', error));
// Load alert settings
loadAlertSettings();
});
</script>
+144
View File
@@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Overlay</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.1.3/socket.io.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Comic+Neue:wght@700&display=swap');
body {
margin: 0;
overflow: hidden;
font-family: 'Comic Code', 'Comic Sans MS', 'Comic Neue', cursive;
color: white;
}
.alert-box {
position: absolute;
top: 10px;
left: 10px;
background: rgba(0, 0, 0, 0.7);
padding: 10px 20px;
border-radius: 10px;
font-size: 25px;
white-space: nowrap;
display: none;
}
.cheer-box {
position: absolute;
top: 10px;
right: 10px;
background: rgba(255, 193, 7, 0.8);
padding: 10px 20px;
border-radius: 10px;
font-size: 25px;
white-space: nowrap;
display: none;
}
.points-box {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
background: rgba(102, 51, 153, 0.8); /* Purple color for channel points */
padding: 10px 20px;
border-radius: 10px;
font-size: 25px;
white-space: nowrap;
display: none;
}
</style>
</head>
<body>
<div id="alert-box" class="alert-box"></div>
<div id="cheer-box" class="cheer-box"></div>
<div id="points-box" class="points-box"></div>
<script>
const socket = io({
query: {
client_type: 'overlay'
}
});
let alertQueue = [];
let isAlertActive = false;
let isCheerActive = false;
let isPointsActive = false;
const alertDuration = 3000; // Time alert is visible
const alertSpacing = 1000; // Additional time between alerts
function showNextAlert() {
if (isAlertActive || alertQueue.length === 0) return;
isAlertActive = true;
const alertBox = document.getElementById("alert-box");
const alertData = alertQueue.shift();
alertBox.innerHTML = alertData;
alertBox.style.display = "block";
setTimeout(() => {
alertBox.style.display = "none";
setTimeout(() => {
isAlertActive = false;
showNextAlert();
}, alertSpacing);
}, alertDuration);
}
function showCheerAlert(message) {
if (isCheerActive) return;
isCheerActive = true;
const cheerBox = document.getElementById("cheer-box");
cheerBox.innerHTML = message;
cheerBox.style.display = "block";
setTimeout(() => {
cheerBox.style.display = "none";
isCheerActive = false;
}, 5000);
}
function showPointsAlert(message) {
if (isPointsActive) return;
isPointsActive = true;
const pointsBox = document.getElementById("points-box");
pointsBox.innerHTML = message;
pointsBox.style.display = "block";
setTimeout(() => {
pointsBox.style.display = "none";
isPointsActive = false;
}, 5000);
}
socket.on("twitch_alert", function(data) {
console.log("Alert received:", data);
let alertMessage = "";
if (data.type === "channel.follow") {
alertMessage = `🚀 New Follow: ${data.data.user_name}`;
} else if (data.type === "channel.subscribe") {
alertMessage = `💜 New Sub: ${data.data.user_name}`;
} else if (data.type === "channel.raid") {
alertMessage = `🔥 Raid from ${data.data.from_broadcaster_user_name} with ${data.data.viewers} viewers!`;
} else if (data.type === "channel.cheer") {
let cheerMessage = `🎉 ${data.data.user_name} cheered ${data.data.bits} bits: "${data.data.message}"`;
showCheerAlert(cheerMessage);
return;
} else if (data.type === "channel_points") {
let pointsMessage = `💎 ${data.data.user_name} redeemed: ${data.data.reward} ${data.data.message}`;
showPointsAlert(pointsMessage);
return;
}
if (alertMessage) {
alertQueue.push(alertMessage);
showNextAlert();
}
});
</script>
</body>
</html>
+185
View File
@@ -0,0 +1,185 @@
import requests
import config
import urllib.parse
import os
import webbrowser
from config import CLIENT_ID, CLIENT_SECRET, BOT_CLIENT_ID, BOT_CLIENT_SECRET, REDIRECT_URI
SCOPES = [
"channel:read:subscriptions",
"bits:read",
"moderator:read:followers",
"channel:read:redemptions",
"channel:manage:redemptions",
"channel:manage:raids",
"channel:manage:broadcast",
"user:read:email",
"chat:read",
"chat:edit",
]
BOT_SCOPES = ["chat:read", "chat:edit"] # Bot-only permissions
TOKEN_FILE = "bot_token.txt" # Stores bot token locally
def _request_token(payload, client_id, client_secret):
"""Internal function to request an OAuth token from Twitch."""
url = "https://id.twitch.tv/oauth2/token"
payload.update({
"client_id": client_id,
"client_secret": client_secret
})
response = requests.post(url, data=payload).json()
if "access_token" in response:
return response
else:
print(f"❌ Error getting token: {response}")
return None
### 🔹 MAIN ACCOUNT (BROADCASTER) AUTH ###
def get_oauth_url():
"""Generates OAuth URL for broadcaster authentication (event subscriptions, rewards, etc.)."""
scope_str = " ".join(SCOPES)
encoded_scope = urllib.parse.quote(scope_str)
url = (
f"https://id.twitch.tv/oauth2/authorize"
f"?client_id={CLIENT_ID}"
f"&redirect_uri={urllib.parse.quote(REDIRECT_URI)}"
f"&response_type=code"
f"&scope={encoded_scope}"
)
print(f"🔗 Opening browser for broadcaster authentication: {url}")
webbrowser.open(url)
return url
def get_user_access_token(auth_code):
"""Gets a user access token for the broadcaster account."""
print(f"🔄 Exchanging authorization code for token: {auth_code}")
token_data = _request_token({
"code": auth_code,
"grant_type": "authorization_code",
"redirect_uri": REDIRECT_URI
}, CLIENT_ID, CLIENT_SECRET)
if token_data:
config.USER_ACCESS_TOKEN = token_data["access_token"]
config.REFRESH_TOKEN = token_data.get("refresh_token", "")
print(f"✅ Broadcaster access token retrieved successfully: {config.USER_ACCESS_TOKEN[:10]}...")
return config.USER_ACCESS_TOKEN
else:
print(f"❌ Failed to exchange authorization code. Response: {token_data}")
return None
def refresh_user_access_token():
"""Refreshes the user access token when it expires."""
if not config.REFRESH_TOKEN:
print("❌ No refresh token available. Broadcaster must reauthenticate.")
return None
token_data = _request_token({
"grant_type": "refresh_token",
"refresh_token": config.REFRESH_TOKEN
}, CLIENT_ID, CLIENT_SECRET)
if token_data:
config.USER_ACCESS_TOKEN = token_data["access_token"]
config.REFRESH_TOKEN = token_data.get("refresh_token", "")
print("🔄 Broadcaster access token refreshed.")
return config.USER_ACCESS_TOKEN
def get_app_access_token():
"""Gets an app access token for general API use (not user-specific)."""
token_data = _request_token({"grant_type": "client_credentials"}, CLIENT_ID, CLIENT_SECRET)
return token_data.get("access_token") if token_data else None
### 🔹 BOT ACCOUNT AUTH ###
def get_bot_oauth_url():
"""Generates OAuth URL for bot authentication (chat only)."""
scope_str = " ".join(BOT_SCOPES)
encoded_scope = urllib.parse.quote(scope_str)
url = (
f"https://id.twitch.tv/oauth2/authorize"
f"?client_id={BOT_CLIENT_ID}"
f"&redirect_uri={urllib.parse.quote(REDIRECT_URI)}"
f"&response_type=code"
f"&scope={encoded_scope}"
)
print(f"🔗 Opening browser for bot authentication: {url}")
webbrowser.open(url)
return url
def load_bot_token():
"""Loads the bot token from a local file."""
if os.path.exists(TOKEN_FILE):
with open(TOKEN_FILE, "r") as f:
return f.read().strip()
return None
def save_bot_token(token):
"""Saves the bot token to a local file."""
with open(TOKEN_FILE, "w") as f:
f.write(token)
def get_bot_access_token(auth_code=None):
"""Gets a bot access token for chat authentication."""
if auth_code:
token_data = _request_token({
"code": auth_code,
"grant_type": "authorization_code",
"redirect_uri": REDIRECT_URI
}, BOT_CLIENT_ID, BOT_CLIENT_SECRET)
else:
print("❌ No bot authorization code provided. Re-authentication required.")
get_bot_oauth_url()
return None
if token_data:
config.BOT_OAUTH_TOKEN = token_data["access_token"]
save_bot_token(config.BOT_OAUTH_TOKEN)
print(f"✅ Bot access token retrieved successfully.")
return config.BOT_OAUTH_TOKEN
return None
def ensure_bot_token():
"""Ensures the bot has a valid token before starting chat."""
bot_token = load_bot_token()
if bot_token:
# Validate the token
headers = {
"Authorization": f"Bearer {bot_token}"
}
response = requests.get("https://id.twitch.tv/oauth2/validate", headers=headers)
if response.status_code == 200:
# Token is valid, format it correctly for IRC
if bot_token.startswith("oauth:"):
config.BOT_OAUTH_TOKEN = bot_token
else:
config.BOT_OAUTH_TOKEN = f"oauth:{bot_token}"
print("✅ Using saved bot token.")
return
else:
print("🔄 Existing bot token is invalid. Getting a new one.")
print("🔄 No valid bot token found. Redirecting to Twitch for bot authentication.")
get_bot_oauth_url()
+168
View File
@@ -0,0 +1,168 @@
import requests
import config
import twitch_auth
from config import BROADCASTER_ID
import websocket
import threading
import config
import re
import pygame
import json
# Initialize pygame for sound effects
pygame.mixer.init()
CLAP_SOUND = pygame.mixer.Sound("clap.wav")
# This variable will be set by app.py
handle_chat_message = None
def delete_existing_subscriptions():
"""Deletes all existing Twitch EventSub subscriptions to prevent duplicates."""
access_token = twitch_auth.get_app_access_token()
url = "https://api.twitch.tv/helix/eventsub/subscriptions"
headers = {
"Client-ID": config.CLIENT_ID,
"Authorization": f"Bearer {access_token}"
}
response = requests.get(url, headers=headers).json()
for sub in response.get("data", []):
delete_url = f"{url}?id={sub['id']}"
requests.delete(delete_url, headers=headers)
print(f"🗑 Deleted subscription: {sub['id']}")
def subscribe_to_twitch_events():
"""Subscribes to Twitch EventSub webhooks for follow, sub, cheer, raid, and channel points."""
delete_existing_subscriptions()
app_access_token = twitch_auth.get_app_access_token()
user_access_token = config.USER_ACCESS_TOKEN
if not user_access_token:
print("❌ Error: User access token is missing. Please re-authenticate.")
return
url = "https://api.twitch.tv/helix/eventsub/subscriptions"
def subscribe(event_type, version, condition, token):
headers = {
"Client-ID": config.CLIENT_ID,
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
data = {
"type": event_type,
"version": version,
"condition": condition,
"transport": {
"method": "webhook",
"callback": config.WEBHOOK_CALLBACK,
"secret": config.WEBHOOK_SECRET
}
}
response = requests.post(url, json=data, headers=headers)
print(f"✅ Subscribed to {event_type}: {response.json()}")
subscribe("channel.follow", "2", {"broadcaster_user_id": BROADCASTER_ID, "moderator_user_id": BROADCASTER_ID}, app_access_token)
subscribe("channel.subscribe", "1", {"broadcaster_user_id": BROADCASTER_ID}, app_access_token)
subscribe("channel.cheer", "1", {"broadcaster_user_id": BROADCASTER_ID}, app_access_token)
subscribe("channel.raid", "1", {"to_broadcaster_user_id": BROADCASTER_ID}, app_access_token)
subscribe("channel.channel_points_custom_reward_redemption.add", "1", {"broadcaster_user_id": BROADCASTER_ID}, app_access_token)
def parse_irc_tags(tag_string):
"""Parse IRC tags from the message prefix."""
if not tag_string:
return {}
# Remove the @ prefix
if tag_string.startswith('@'):
tag_string = tag_string[1:]
tags = {}
for tag in tag_string.split(';'):
if '=' in tag:
key, value = tag.split('=', 1)
tags[key] = value if value else None
return tags
def on_message(ws, message):
"""Handles incoming messages from Twitch chat and responds to PING."""
if message.startswith("PING"):
ws.send("PONG :tmi.twitch.tv")
return
message_lines = message.split("\r\n")
for line in message_lines:
if not line.strip():
continue
if "NOTICE" in line or "JOIN" in line or "PART" in line:
print(f"📢 System Alert: {line}")
if "PRIVMSG" in line:
try:
# Parse more complex IRC message format with tags
tags = {}
if line.startswith('@'):
tags_part, rest = line.split(' ', 1)
tags = parse_irc_tags(tags_part)
line = rest
# Extract username and message
match = re.search(r":(\w+)!\w+@\w+\.tmi\.twitch\.tv PRIVMSG #\w+ :(.+)", line)
if match:
user, message_content = match.groups()
# Handle clap emote
if "procptClap" in message_content:
print(f"👏 {user} clapped!")
CLAP_SOUND.play()
# If handle_chat_message function is set (by app.py), use it
if handle_chat_message:
# Create a copy of data to pass to the main thread
username = user
content = message_content
tags_copy = tags.copy() if tags else {}
# Use threading to avoid blocking the WebSocket thread
threading.Thread(
target=lambda: handle_chat_message(username, content, tags_copy),
daemon=True
).start()
print(f"💬 {user}: {message_content}")
except Exception as e:
print(f"Error parsing chat message: {e}")
def on_open(ws):
"""Handles connection opening to Twitch Chat."""
print("✅ Connected to Twitch Chat")
# Use the token directly from config
ws.send(f"PASS {config.BOT_OAUTH_TOKEN}")
ws.send(f"NICK {config.TWITCH_BOT_NICKNAME}")
ws.send(f"JOIN #{config.TWITCH_CHANNEL}")
# Request capabilities for additional message data
ws.send("CAP REQ :twitch.tv/tags twitch.tv/commands twitch.tv/membership")
print(f"🔗 Joined #{config.TWITCH_CHANNEL} as {config.TWITCH_BOT_NICKNAME}")
def connect_twitch_chat():
"""Creates a WebSocket connection to Twitch Chat."""
ws = websocket.WebSocketApp(
config.TWITCH_CHAT_URL,
on_message=on_message,
on_open=on_open
)
ws.run_forever()
def start_twitch_chat():
chat_thread = threading.Thread(target=connect_twitch_chat, daemon=True)
chat_thread.start()
if __name__ == "__main__":
subscribe_to_twitch_events()