449 lines
15 KiB
Python
449 lines
15 KiB
Python
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") |