Skip to content

Commit a77d2af

Browse files
feat: Add Webhook Support for New World Records (#52)
Co-authored-by: Nick Bottone <[email protected]>
1 parent 24e7f96 commit a77d2af

File tree

7 files changed

+374
-10
lines changed

7 files changed

+374
-10
lines changed

.github/workflows/tests.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ jobs:
1010

1111
steps:
1212
- uses: actions/checkout@v2
13-
- name: 🔨 Set up Python 3.9.20
13+
- name: 🔨 Set up Python 3.12
1414
uses: actions/setup-python@v2
1515
with:
16-
python-version: 3.9.20
16+
python-version: 3.12
1717

1818
- name: Install dependencies
1919
run: |

SRCweb/settings.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,3 +259,6 @@
259259

260260
SESSION_COOKIE_AGE = 60 * 60 * 24 * 30 # 30 days
261261
MAX_UPLOAD_SIZE = "5242880"
262+
263+
# Discord Webhook URL for Highscores
264+
DISCORD_WEBHOOK_URL = os.getenv("HIGHSCORES_WEBHOOK_URL")

highscores/lib.py

Lines changed: 186 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
from django.http import HttpRequest
22
from django.core.mail import send_mail
3+
from django.utils import timezone
4+
import logging
35

46
from .models import Score, CleanCodeSubmission, ExemptedIP
57
from .forms import ScoreForm
6-
from SRCweb.settings import NEW_AES_KEY, DEBUG, ADMIN_EMAILS, EMAIL_HOST_USER
8+
from SRCweb.settings import NEW_AES_KEY, DEBUG, ADMIN_EMAILS, EMAIL_HOST_USER, DISCORD_WEBHOOK_URL
9+
import os
710

811
from typing import Callable, Union
912
from Crypto.Cipher import AES
1013
from urllib.request import urlopen, Request
14+
import json
1115

1216
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'
1317

@@ -21,6 +25,167 @@
2125
WRONG_AUTO_OR_TELEOP_MESSAGE = 'Incorrect choice for control mode! Ensure you are submitting to the correct leaderboard for autonomous or tele-operated play.'
2226

2327

28+
def send_world_record_webhook(new_score: Score, previous_record: Score = None) -> None:
29+
"""Send Discord webhook notification for new world record"""
30+
if not DISCORD_WEBHOOK_URL:
31+
logging.error("Discord webhook URL not configured")
32+
return
33+
34+
try:
35+
# Calculate duration and get previous record holder info
36+
if previous_record is None:
37+
# Get the current world record (which will become the previous one)
38+
previous_record = Score.objects.filter(
39+
leaderboard=new_score.leaderboard,
40+
approved=True
41+
).order_by('-score', 'time_set').first()
42+
43+
if previous_record and previous_record.score < new_score.score:
44+
# Calculate how long the previous record stood
45+
duration_diff = new_score.time_set - previous_record.time_set
46+
47+
# Calculate duration in a readable format
48+
total_seconds = int(duration_diff.total_seconds())
49+
days = total_seconds // 86400
50+
hours = (total_seconds % 86400) // 3600
51+
minutes = (total_seconds % 3600) // 60
52+
53+
if days > 0:
54+
duration_text = f"{days} day{'s' if days != 1 else ''}, {hours} hour{'s' if hours != 1 else ''}"
55+
elif hours > 0:
56+
duration_text = f"{hours} hour{'s' if hours != 1 else ''}, {minutes} minute{'s' if minutes != 1 else ''}"
57+
elif minutes > 0:
58+
duration_text = f"{minutes} minute{'s' if minutes != 1 else ''}"
59+
else:
60+
duration_text = "less than a minute"
61+
62+
previous_record_info = f"**{previous_record.player.username}**'s record ({previous_record.score:,} points) stood for **{duration_text}**"
63+
else:
64+
previous_record_info = "**First record set for this category!**"
65+
66+
# Create the embed message
67+
embed = {
68+
"title": "🏆 NEW WORLD RECORD ACHIEVED! 🏆",
69+
"description": f"**{new_score.player.username}** has set a new world record!",
70+
"color": 0xFFD700, # Gold color
71+
"fields": [
72+
{
73+
"name": "🎮 Game & Robot",
74+
"value": f"**{new_score.leaderboard.game}**\n`{new_score.leaderboard.name}`",
75+
"inline": True
76+
},
77+
{
78+
"name": "🎯 Score",
79+
"value": f"**{new_score.score:,} points**",
80+
"inline": True
81+
},
82+
{
83+
"name": "⏱️ Previous Record",
84+
"value": previous_record_info,
85+
"inline": False
86+
},
87+
{
88+
"name": "📎 Proof",
89+
"value": f"[View Submission]({new_score.source})",
90+
"inline": False
91+
}
92+
],
93+
"footer": {
94+
"text": f"Record set on {new_score.time_set.strftime('%B %d, %Y at %I:%M %p UTC')}",
95+
"icon_url": "https://cdn.discordapp.com/emojis/1306393882618114139.png"
96+
},
97+
"author": {
98+
"name": "Second Robotics Competition",
99+
"url": "https://secondrobotics.org",
100+
"icon_url": "https://secondrobotics.org/static/images/logo.png"
101+
},
102+
"timestamp": new_score.time_set.isoformat()
103+
}
104+
105+
payload = {
106+
"embeds": [embed],
107+
"username": "World Record Bot"
108+
}
109+
110+
# Send the webhook
111+
data = json.dumps(payload).encode('utf-8')
112+
req = Request(DISCORD_WEBHOOK_URL, data=data, headers={
113+
'Content-Type': 'application/json',
114+
'User-Agent': USER_AGENT
115+
})
116+
117+
response = urlopen(req)
118+
if response.status != 204:
119+
logging.error(f"Discord webhook failed with status: {response.status}")
120+
121+
except Exception as e:
122+
logging.error(f"Failed to send Discord webhook: {e}")
123+
124+
125+
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:
126+
"""Test function for Discord webhook - returns True if successful"""
127+
if not DISCORD_WEBHOOK_URL:
128+
logging.error("Discord webhook URL not configured")
129+
return False
130+
131+
try:
132+
embed = {
133+
"title": "🧪 TEST WORLD RECORD NOTIFICATION 🧪",
134+
"description": f"**{player_name}** has set a new world record! *(This is a test)*",
135+
"color": 0x00FF00, # Green color for test
136+
"fields": [
137+
{
138+
"name": "🎮 Game & Robot",
139+
"value": f"**{game}**\n`{robot}`",
140+
"inline": True
141+
},
142+
{
143+
"name": "🎯 Score",
144+
"value": f"**{score:,} points**",
145+
"inline": True
146+
},
147+
{
148+
"name": "⏱️ Previous Record",
149+
"value": f"**{previous_player}**'s record ({previous_score:,} points) stood for **{duration}**",
150+
"inline": False
151+
},
152+
{
153+
"name": "📎 Proof",
154+
"value": "[Test Submission](https://secondrobotics.org)",
155+
"inline": False
156+
}
157+
],
158+
"footer": {
159+
"text": f"TEST - Record set on {timezone.now().strftime('%B %d, %Y at %I:%M %p UTC')}",
160+
"icon_url": "https://cdn.discordapp.com/emojis/1306393882618114139.png"
161+
},
162+
"author": {
163+
"name": "Second Robotics Competition (TEST MODE)",
164+
"url": "https://secondrobotics.org",
165+
"icon_url": "https://secondrobotics.org/static/images/logo.png"
166+
},
167+
"timestamp": timezone.now().isoformat()
168+
}
169+
170+
payload = {
171+
"embeds": [embed],
172+
"username": "World Record Bot (TEST)"
173+
}
174+
175+
data = json.dumps(payload).encode('utf-8')
176+
req = Request(DISCORD_WEBHOOK_URL, data=data, headers={
177+
'Content-Type': 'application/json',
178+
'User-Agent': USER_AGENT
179+
})
180+
181+
response = urlopen(req)
182+
return response.status == 204
183+
184+
except Exception as e:
185+
logging.error(f"Failed to send test Discord webhook: {e}")
186+
return False
187+
188+
24189
def submit_score(score_obj: Score, clean_code_check_func: Callable[[Score], Union[str, None]]) -> Union[str, None]:
25190
# Check to ensure image / video is proper
26191
res = submission_screenshot_check(score_obj)
@@ -151,13 +316,32 @@ def extract_form_data(form: ScoreForm, request: HttpRequest) -> Score:
151316

152317

153318
def approve_score(score_obj: Score, prev_submissions):
154-
# Delete previous submissions with lower or equal scores
319+
# Check if this is a new world record before deleting previous submissions
320+
current_world_record = Score.objects.filter(
321+
leaderboard=score_obj.leaderboard,
322+
approved=True
323+
).order_by('-score', 'time_set').first()
324+
325+
is_world_record = (current_world_record is None or
326+
score_obj.score > current_world_record.score)
327+
328+
# Delete previous submissions with lower or equal scores in the category
155329
prev_submissions.filter(score__lte=score_obj.score).delete()
156330

157331
# Save the new submission
158332
score_obj.approved = True
159333
score_obj.save()
160334

335+
# Send Discord webhook if this is a world record
336+
if is_world_record:
337+
if not DEBUG:
338+
try:
339+
send_world_record_webhook(score_obj, current_world_record)
340+
except Exception as e:
341+
logging.error(f"Failed to send world record webhook: {e}")
342+
else:
343+
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)")
344+
161345
code_obj = CleanCodeSubmission()
162346
code_obj.clean_code = score_obj.clean_code
163347
code_obj.player = score_obj.player
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
{% extends 'home/base.html' %}
2+
{% load static %}
3+
4+
{% block content %}
5+
<div class="container mt-4">
6+
<h1 class="mb-4">Discord Webhook Test</h1>
7+
<p class="text-muted">Admin only - Test Discord world record notifications</p>
8+
9+
{% if success_message %}
10+
<div class="alert alert-success alert-dismissible fade show" role="alert">
11+
{{ success_message }}
12+
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
13+
</div>
14+
{% endif %}
15+
16+
{% if error_message %}
17+
<div class="alert alert-danger alert-dismissible fade show" role="alert">
18+
{{ error_message }}
19+
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
20+
</div>
21+
{% endif %}
22+
23+
<div class="row">
24+
<div class="col-md-8">
25+
<div class="card">
26+
<div class="card-header">
27+
<h5 class="card-title mb-0">Send Test Webhook</h5>
28+
</div>
29+
<div class="card-body">
30+
<form method="post">
31+
{% csrf_token %}
32+
33+
<div class="mb-3">
34+
<label for="player_name" class="form-label">Player Name</label>
35+
<input type="text" class="form-control" id="player_name" name="player_name" value="Test Player" required>
36+
</div>
37+
38+
<div class="mb-3">
39+
<label for="score" class="form-label">Score</label>
40+
<input type="number" class="form-control" id="score" name="score" value="100000" required>
41+
</div>
42+
43+
<div class="mb-3">
44+
<label for="game" class="form-label">Game</label>
45+
<select class="form-control" id="game" name="game">
46+
<option value="Test Game">Test Game</option>
47+
{% for game in games %}
48+
<option value="{{ game }}">{{ game }}</option>
49+
{% endfor %}
50+
</select>
51+
</div>
52+
53+
<div class="mb-3">
54+
<label for="robot" class="form-label">Robot</label>
55+
<select class="form-control" id="robot" name="robot">
56+
<option value="Test Robot">Test Robot</option>
57+
{% for robot in robots %}
58+
<option value="{{ robot }}">{{ robot }}</option>
59+
{% endfor %}
60+
</select>
61+
</div>
62+
63+
<div class="mb-3">
64+
<label for="previous_player" class="form-label">Previous Record Holder</label>
65+
<input type="text" class="form-control" id="previous_player" name="previous_player" value="PreviousPlayer" required>
66+
</div>
67+
68+
<div class="mb-3">
69+
<label for="previous_score" class="form-label">Previous Record Score</label>
70+
<input type="number" class="form-control" id="previous_score" name="previous_score" value="95000" required>
71+
</div>
72+
73+
<div class="mb-3">
74+
<label for="duration" class="form-label">Previous Record Duration</label>
75+
<input type="text" class="form-control" id="duration" name="duration" value="2 days, 3 hours" required>
76+
<small class="form-text text-muted">Example: "2 days, 3 hours" or "45 minutes"</small>
77+
</div>
78+
79+
<button type="submit" class="btn btn-primary">Send Test Webhook</button>
80+
</form>
81+
</div>
82+
</div>
83+
</div>
84+
85+
<div class="col-md-4">
86+
<div class="card">
87+
<div class="card-header">
88+
<h5 class="card-title mb-0">Information</h5>
89+
</div>
90+
<div class="card-body">
91+
<p class="card-text">
92+
This page allows administrators to test the Discord webhook system that sends notifications when world records are broken.
93+
</p>
94+
<p class="card-text">
95+
<strong>What gets sent:</strong>
96+
</p>
97+
<ul>
98+
<li>Player who got the record</li>
99+
<li>Score achieved</li>
100+
<li>Game name</li>
101+
<li>Robot type</li>
102+
<li>Duration the previous record stood</li>
103+
<li>Link to submission source</li>
104+
</ul>
105+
<p class="card-text text-muted">
106+
<small>Test messages will be clearly marked as tests in Discord.</small>
107+
</p>
108+
</div>
109+
</div>
110+
</div>
111+
</div>
112+
</div>
113+
{% endblock %}

highscores/urls.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33

44

55
urlpatterns = [
6-
path('', views.home, name='home'),
7-
# path("<str:game_slug>/submit/", views.submit_form, name="score submit"),
8-
path("<str:game_slug>/combined/", views.leaderboard_combined,
9-
name="game leaderboard"),
10-
path("<str:game_slug>/<str:name>/",
11-
views.leaderboard_robot, name="robot leaderboard"),
6+
path('', views.home, name='home'),
7+
# path("<str:game_slug>/submit/", views.submit_form, name="score submit"),
8+
path("<str:game_slug>/combined/", views.leaderboard_combined,
9+
name="game leaderboard"),
10+
path("<str:game_slug>/<str:name>/",
11+
views.leaderboard_robot, name="robot leaderboard"),
1212
path('world-records/', views.world_records, name='world-records'),
1313
path('overall/', views.overall_singleplayer_leaderboard, name='overall-singleplayer-leaderboard'),
14+
path('webhook-test/', views.webhook_test, name='webhook-test'),
1415
]

0 commit comments

Comments
 (0)