main: Initial commit
This commit is contained in:
@@ -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")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
6exdio209f716ixylaq1gtrxnrkr7g
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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()
|
||||||
|
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user