diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d8b10f7..1b5b072 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,10 +10,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: ๐จ Set up Python 3.9.20 + - name: ๐จ Set up Python 3.12 uses: actions/setup-python@v2 with: - python-version: 3.9.20 + python-version: 3.12 - name: Install dependencies run: | diff --git a/SRCweb/settings.py b/SRCweb/settings.py index b69187f..0ed2e5f 100644 --- a/SRCweb/settings.py +++ b/SRCweb/settings.py @@ -259,3 +259,6 @@ SESSION_COOKIE_AGE = 60 * 60 * 24 * 30 # 30 days MAX_UPLOAD_SIZE = "5242880" + +# Discord Webhook URL for Highscores +DISCORD_WEBHOOK_URL = os.getenv("HIGHSCORES_WEBHOOK_URL") diff --git a/highscores/lib.py b/highscores/lib.py index 0d467c3..d6ce34c 100644 --- a/highscores/lib.py +++ b/highscores/lib.py @@ -1,13 +1,17 @@ from django.http import HttpRequest from django.core.mail import send_mail +from django.utils import timezone +import logging from .models import Score, CleanCodeSubmission, ExemptedIP from .forms import ScoreForm -from SRCweb.settings import NEW_AES_KEY, DEBUG, ADMIN_EMAILS, EMAIL_HOST_USER +from SRCweb.settings import NEW_AES_KEY, DEBUG, ADMIN_EMAILS, EMAIL_HOST_USER, DISCORD_WEBHOOK_URL +import os from typing import Callable, Union from Crypto.Cipher import AES from urllib.request import urlopen, Request +import json USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/35.0.1916.47 Safari/537.36' @@ -21,6 +25,167 @@ WRONG_AUTO_OR_TELEOP_MESSAGE = 'Incorrect choice for control mode! Ensure you are submitting to the correct leaderboard for autonomous or tele-operated play.' +def send_world_record_webhook(new_score: Score, previous_record: Score = None) -> None: + """Send Discord webhook notification for new world record""" + if not DISCORD_WEBHOOK_URL: + logging.error("Discord webhook URL not configured") + return + + try: + # Calculate duration and get previous record holder info + if previous_record is None: + # Get the current world record (which will become the previous one) + previous_record = Score.objects.filter( + leaderboard=new_score.leaderboard, + approved=True + ).order_by('-score', 'time_set').first() + + if previous_record and previous_record.score < new_score.score: + # Calculate how long the previous record stood + duration_diff = new_score.time_set - previous_record.time_set + + # Calculate duration in a readable format + total_seconds = int(duration_diff.total_seconds()) + days = total_seconds // 86400 + hours = (total_seconds % 86400) // 3600 + minutes = (total_seconds % 3600) // 60 + + if days > 0: + duration_text = f"{days} day{'s' if days != 1 else ''}, {hours} hour{'s' if hours != 1 else ''}" + elif hours > 0: + duration_text = f"{hours} hour{'s' if hours != 1 else ''}, {minutes} minute{'s' if minutes != 1 else ''}" + elif minutes > 0: + duration_text = f"{minutes} minute{'s' if minutes != 1 else ''}" + else: + duration_text = "less than a minute" + + previous_record_info = f"**{previous_record.player.username}**'s record ({previous_record.score:,} points) stood for **{duration_text}**" + else: + previous_record_info = "**First record set for this category!**" + + # Create the embed message + embed = { + "title": "๐ NEW WORLD RECORD ACHIEVED! ๐", + "description": f"**{new_score.player.username}** has set a new world record!", + "color": 0xFFD700, # Gold color + "fields": [ + { + "name": "๐ฎ Game & Robot", + "value": f"**{new_score.leaderboard.game}**\n`{new_score.leaderboard.name}`", + "inline": True + }, + { + "name": "๐ฏ Score", + "value": f"**{new_score.score:,} points**", + "inline": True + }, + { + "name": "โฑ๏ธ Previous Record", + "value": previous_record_info, + "inline": False + }, + { + "name": "๐ Proof", + "value": f"[View Submission]({new_score.source})", + "inline": False + } + ], + "footer": { + "text": f"Record set on {new_score.time_set.strftime('%B %d, %Y at %I:%M %p UTC')}", + "icon_url": "https://cdn.discordapp.com/emojis/1306393882618114139.png" + }, + "author": { + "name": "Second Robotics Competition", + "url": "https://secondrobotics.org", + "icon_url": "https://secondrobotics.org/static/images/logo.png" + }, + "timestamp": new_score.time_set.isoformat() + } + + payload = { + "embeds": [embed], + "username": "World Record Bot" + } + + # Send the webhook + data = json.dumps(payload).encode('utf-8') + req = Request(DISCORD_WEBHOOK_URL, data=data, headers={ + 'Content-Type': 'application/json', + 'User-Agent': USER_AGENT + }) + + response = urlopen(req) + if response.status != 204: + logging.error(f"Discord webhook failed with status: {response.status}") + + except Exception as e: + logging.error(f"Failed to send Discord webhook: {e}") + + +def test_world_record_webhook(player_name: str, score: int, game: str, robot: str, previous_player: str = "TestPlayer", previous_score: int = 95000, duration: str = "2 days, 3 hours") -> bool: + """Test function for Discord webhook - returns True if successful""" + if not DISCORD_WEBHOOK_URL: + logging.error("Discord webhook URL not configured") + return False + + try: + embed = { + "title": "๐งช TEST WORLD RECORD NOTIFICATION ๐งช", + "description": f"**{player_name}** has set a new world record! *(This is a test)*", + "color": 0x00FF00, # Green color for test + "fields": [ + { + "name": "๐ฎ Game & Robot", + "value": f"**{game}**\n`{robot}`", + "inline": True + }, + { + "name": "๐ฏ Score", + "value": f"**{score:,} points**", + "inline": True + }, + { + "name": "โฑ๏ธ Previous Record", + "value": f"**{previous_player}**'s record ({previous_score:,} points) stood for **{duration}**", + "inline": False + }, + { + "name": "๐ Proof", + "value": "[Test Submission](https://secondrobotics.org)", + "inline": False + } + ], + "footer": { + "text": f"TEST - Record set on {timezone.now().strftime('%B %d, %Y at %I:%M %p UTC')}", + "icon_url": "https://cdn.discordapp.com/emojis/1306393882618114139.png" + }, + "author": { + "name": "Second Robotics Competition (TEST MODE)", + "url": "https://secondrobotics.org", + "icon_url": "https://secondrobotics.org/static/images/logo.png" + }, + "timestamp": timezone.now().isoformat() + } + + payload = { + "embeds": [embed], + "username": "World Record Bot (TEST)" + } + + data = json.dumps(payload).encode('utf-8') + req = Request(DISCORD_WEBHOOK_URL, data=data, headers={ + 'Content-Type': 'application/json', + 'User-Agent': USER_AGENT + }) + + response = urlopen(req) + return response.status == 204 + + except Exception as e: + logging.error(f"Failed to send test Discord webhook: {e}") + return False + + def submit_score(score_obj: Score, clean_code_check_func: Callable[[Score], Union[str, None]]) -> Union[str, None]: # Check to ensure image / video is proper res = submission_screenshot_check(score_obj) @@ -151,13 +316,32 @@ def extract_form_data(form: ScoreForm, request: HttpRequest) -> Score: def approve_score(score_obj: Score, prev_submissions): - # Delete previous submissions with lower or equal scores + # Check if this is a new world record before deleting previous submissions + current_world_record = Score.objects.filter( + leaderboard=score_obj.leaderboard, + approved=True + ).order_by('-score', 'time_set').first() + + is_world_record = (current_world_record is None or + score_obj.score > current_world_record.score) + + # Delete previous submissions with lower or equal scores in the category prev_submissions.filter(score__lte=score_obj.score).delete() # Save the new submission score_obj.approved = True score_obj.save() + # Send Discord webhook if this is a world record + if is_world_record: + if not DEBUG: + try: + send_world_record_webhook(score_obj, current_world_record) + except Exception as e: + logging.error(f"Failed to send world record webhook: {e}") + else: + logging.info(f"DEBUG: World record detected for {score_obj.player.username} - {score_obj.score} on {score_obj.leaderboard.name} (webhook disabled in debug mode)") + code_obj = CleanCodeSubmission() code_obj.clean_code = score_obj.clean_code code_obj.player = score_obj.player diff --git a/highscores/templates/highscores/webhook_test.html b/highscores/templates/highscores/webhook_test.html new file mode 100644 index 0000000..f3128a9 --- /dev/null +++ b/highscores/templates/highscores/webhook_test.html @@ -0,0 +1,113 @@ +{% extends 'home/base.html' %} +{% load static %} + +{% block content %} +
Admin only - Test Discord world record notifications
+ + {% if success_message %} ++ This page allows administrators to test the Discord webhook system that sends notifications when world records are broken. +
++ What gets sent: +
++ Test messages will be clearly marked as tests in Discord. +
+