From 3d5fbd79e15323d29aab542fa930588c30de7275 Mon Sep 17 00:00:00 2001 From: Brennan Bibic Date: Wed, 11 Jun 2025 15:54:27 -0400 Subject: [PATCH 1/7] webhook support for new wrs --- highscores/lib.py | 185 ++++++++++++++++++ .../templates/highscores/webhook_test.html | 113 +++++++++++ highscores/urls.py | 1 + highscores/views.py | 50 +++++ home/templates/home/base.html | 15 +- 5 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 highscores/templates/highscores/webhook_test.html diff --git a/highscores/lib.py b/highscores/lib.py index bfad5e4..b3ef38f 100644 --- a/highscores/lib.py +++ b/highscores/lib.py @@ -1,16 +1,21 @@ from django.http import HttpRequest from django.core.mail import send_mail +from django.utils import timezone from .models import Score, CleanCodeSubmission, ExemptedIP from .forms import ScoreForm from SRCweb.settings import NEW_AES_KEY, DEBUG, ADMIN_EMAILS, EMAIL_HOST_USER +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' +DISCORD_WEBHOOK_URL = os.getenv("HIGHSCORES_WEBHOOK_URL") + WRONG_ROBOT_MESSAGE = 'Double-check the robot type that you selected!' HIGHER_SCORE_MESSAGE = 'You already have a submission with an equal or higher score than this!' ERROR_WRONG_GAME_MESSAGE = 'There is something wrong with your clean code! Are you submitting for the right game?' @@ -21,6 +26,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: + print("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: + print(f"Discord webhook failed with status: {response.status}") + + except Exception as e: + print(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: + print("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: + print(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) @@ -150,6 +316,15 @@ def extract_form_data(form: ScoreForm, request: HttpRequest) -> Score: def approve_score(score_obj: Score, prev_submissions): + # 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 for this category prev_submissions.delete() @@ -157,6 +332,16 @@ def approve_score(score_obj: Score, prev_submissions): 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: + print(f"Failed to send world record webhook: {e}") + else: + print(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 %} +
+

Discord Webhook Test

+

Admin only - Test Discord world record notifications

+ + {% if success_message %} + + {% endif %} + + {% if error_message %} + + {% endif %} + +
+
+
+
+
Send Test Webhook
+
+
+
+ {% csrf_token %} + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + Example: "2 days, 3 hours" or "45 minutes" +
+ + +
+
+
+
+ +
+
+
+
Information
+
+
+

+ This page allows administrators to test the Discord webhook system that sends notifications when world records are broken. +

+

+ What gets sent: +

+
    +
  • Player who got the record
  • +
  • Score achieved
  • +
  • Game name
  • +
  • Robot type
  • +
  • Duration the previous record stood
  • +
  • Link to submission source
  • +
+

+ Test messages will be clearly marked as tests in Discord. +

+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/highscores/urls.py b/highscores/urls.py index a18ba52..baeb4b4 100644 --- a/highscores/urls.py +++ b/highscores/urls.py @@ -11,4 +11,5 @@ views.leaderboard_robot, name="robot leaderboard"), path('world-records/', views.world_records, name='world-records'), path('overall/', views.overall_singleplayer_leaderboard, name='overall-singleplayer-leaderboard'), + path('webhook-test/', views.webhook_test, name='webhook-test'), ] diff --git a/highscores/views.py b/highscores/views.py index 545af0e..69392b9 100644 --- a/highscores/views.py +++ b/highscores/views.py @@ -253,3 +253,53 @@ def overall_singleplayer_leaderboard(request: HttpRequest) -> HttpResponse: @login_required(login_url='/login') def submit_form(request: HttpRequest, game_slug: str) -> HttpResponse: return submit_form_view(request, get_score_form(game_slug), game_slug_to_submit_func[game_slug]) + + +def admin_required(view_func): + """Custom decorator to require admin access (staff or superuser)""" + def wrapper(request, *args, **kwargs): + if not request.user.is_authenticated: + return HttpResponseRedirect('/login') + if not (request.user.is_staff or request.user.is_superuser): + return HttpResponseRedirect('/') + return view_func(request, *args, **kwargs) + return wrapper + + +@admin_required +def webhook_test(request: HttpRequest) -> HttpResponse: + """Admin-only page for testing Discord webhooks""" + from .lib import test_world_record_webhook + + success_message = None + error_message = None + + if request.method == 'POST': + # Get form data + player_name = request.POST.get('player_name', 'Test Player') + score = int(request.POST.get('score', 100000)) + game = request.POST.get('game', 'Test Game') + robot = request.POST.get('robot', 'Test Robot') + previous_player = request.POST.get('previous_player', 'PreviousPlayer') + previous_score = int(request.POST.get('previous_score', 95000)) + duration = request.POST.get('duration', '2 days, 3 hours') + + # Send test webhook + if test_world_record_webhook(player_name, score, game, robot, previous_player, previous_score, duration): + success_message = "Test webhook sent successfully!" + else: + error_message = "Failed to send test webhook. Check server logs." + + # Get sample data for dropdowns + leaderboards = Leaderboard.objects.all() + games = list(set(lb.game for lb in leaderboards)) + robots = list(set(lb.name for lb in leaderboards)) + + context = { + 'success_message': success_message, + 'error_message': error_message, + 'games': games, + 'robots': robots, + } + + return render(request, 'highscores/webhook_test.html', context) diff --git a/home/templates/home/base.html b/home/templates/home/base.html index 8da6940..bf110bf 100644 --- a/home/templates/home/base.html +++ b/home/templates/home/base.html @@ -79,6 +79,19 @@ p.center { text-align: center; } + + /* Prevent horizontal overflow */ + body { + overflow-x: hidden; + } + + .navbar-nav { + flex-wrap: nowrap; + } + + .navbar-collapse { + overflow-x: hidden; + }