diff --git a/app.py b/app.py new file mode 100644 index 0000000..a32e892 --- /dev/null +++ b/app.py @@ -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/') +def serve_static(path): + return send_from_directory('static', path) + +@app.route("/") +def home(): + return f'šŸ”— Connect Twitch' + +@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/", 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") \ No newline at end of file diff --git a/bot_token.txt b/bot_token.txt new file mode 100644 index 0000000..417a7e5 --- /dev/null +++ b/bot_token.txt @@ -0,0 +1 @@ +6exdio209f716ixylaq1gtrxnrkr7g \ No newline at end of file diff --git a/clap.wav b/clap.wav new file mode 100644 index 0000000..ec1545c Binary files /dev/null and b/clap.wav differ diff --git a/config.py b/config.py new file mode 100644 index 0000000..bca7506 --- /dev/null +++ b/config.py @@ -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 \ No newline at end of file diff --git a/requirements b/requirements new file mode 100644 index 0000000..e69de29 diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..d19ac02 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,886 @@ + + + + + + Twitch Stream Dashboard + + + + + + + + +
+
+ +
+
+
+
+
0
+
Current Viewers
+
+
+
+
+
0
+
Messages/Minute
+
+
+
+
+
0
+
Today's Followers
+
+
+
+
+
0
+
Today's Subs
+
+
+
+ + +
+
+ Stream Activity +
+
+ +
+
+ + +
+
+ Recent Events +
+
+ +
+
+
+ + +
+ +
+
+ Live Chat +
+
+
+ +
+
+
+ +
+
+ System Messages +
+
+
+
+
+
+ + +
+
+ Channel Point Redemptions +
+
+
+ +
+
+
+ + +
+
+ Alert Settings +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/templates/overlay.html b/templates/overlay.html new file mode 100644 index 0000000..820444e --- /dev/null +++ b/templates/overlay.html @@ -0,0 +1,144 @@ + + + + + + Overlay + + + + +
+
+
+ + + + diff --git a/twitch_auth.py b/twitch_auth.py new file mode 100644 index 0000000..6b13538 --- /dev/null +++ b/twitch_auth.py @@ -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() + diff --git a/twitch_events.py b/twitch_events.py new file mode 100644 index 0000000..b56edef --- /dev/null +++ b/twitch_events.py @@ -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() \ No newline at end of file