From 3c10ec23de0ae6a2cf7d267675c075744dcd5312 Mon Sep 17 00:00:00 2001 From: Anindita Date: Fri, 7 Aug 2026 20:05:18 +0530 Subject: [PATCH] Add contest notification bot --- scripts/refresh_contest_cache.sh | 13 ++ website/contest_notifications/__init__.py | 0 website/contest_notifications/api/__init__.py | 0 website/contest_notifications/api/urls.py | 10 + website/contest_notifications/api/views.py | 57 +++++ website/contest_notifications/apps.py | 6 + website/contest_notifications/cache_utils.py | 36 ++++ .../management/__init__.py | 0 .../management/commands/__init__.py | 0 .../commands/refresh_contest_cache.py | 24 +++ website/contest_notifications/services.py | 199 ++++++++++++++++++ website/website/settings.py | 5 + website/website/urls.py | 4 + 13 files changed, 354 insertions(+) create mode 100644 scripts/refresh_contest_cache.sh create mode 100644 website/contest_notifications/__init__.py create mode 100644 website/contest_notifications/api/__init__.py create mode 100644 website/contest_notifications/api/urls.py create mode 100644 website/contest_notifications/api/views.py create mode 100644 website/contest_notifications/apps.py create mode 100644 website/contest_notifications/cache_utils.py create mode 100644 website/contest_notifications/management/__init__.py create mode 100644 website/contest_notifications/management/commands/__init__.py create mode 100644 website/contest_notifications/management/commands/refresh_contest_cache.py create mode 100644 website/contest_notifications/services.py diff --git a/scripts/refresh_contest_cache.sh b/scripts/refresh_contest_cache.sh new file mode 100644 index 00000000..473b640c --- /dev/null +++ b/scripts/refresh_contest_cache.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Schedule daily contest cache refresh at 12:01 AM IST. +# Add to crontab with: crontab -e +# 1 0 * * * /path/to/Recursioncursor/backend/scripts/refresh_contest_cache.sh >> /var/log/contest_cache.log 2>&1 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +MANAGE_PY="$PROJECT_DIR/website/manage.py" + +cd "$PROJECT_DIR/website" +python "$MANAGE_PY" refresh_contest_cache --force diff --git a/website/contest_notifications/__init__.py b/website/contest_notifications/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/contest_notifications/api/__init__.py b/website/contest_notifications/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/contest_notifications/api/urls.py b/website/contest_notifications/api/urls.py new file mode 100644 index 00000000..4aebd6e7 --- /dev/null +++ b/website/contest_notifications/api/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from .views import ContestNotificationsRefreshView, ContestNotificationsView + +app_name = 'contest_notifications_api' + +urlpatterns = [ + path('', ContestNotificationsView.as_view(), name='contest_notifications'), + path('refresh/', ContestNotificationsRefreshView.as_view(), name='contest_notifications_refresh'), +] diff --git a/website/contest_notifications/api/views.py b/website/contest_notifications/api/views.py new file mode 100644 index 00000000..04bcca7e --- /dev/null +++ b/website/contest_notifications/api/views.py @@ -0,0 +1,57 @@ +from rest_framework.response import Response +from rest_framework.views import APIView + +from contest_notifications.services import get_cached_contests, refresh_contest_cache + + +class ContestNotificationsView(APIView): + authentication_classes = () + permission_classes = () + + def get(self, request): + platform = request.query_params.get('platform') + + try: + payload = get_cached_contests() + except ValueError as exc: + return Response({'detail': str(exc)}, status=503) + except Exception as exc: + return Response( + {'detail': f'Failed to fetch contests: {exc}'}, + status=502, + ) + + contests = payload.get('contests', []) + if platform: + contests = [ + contest for contest in contests + if contest.get('platform') == platform.lower() + ] + + return Response({ + 'cached_at': payload.get('cached_at'), + 'refresh_boundary': payload.get('refresh_boundary'), + 'contests': contests, + }) + + +class ContestNotificationsRefreshView(APIView): + authentication_classes = () + permission_classes = () + + def post(self, request): + try: + payload = refresh_contest_cache(force=True) + except ValueError as exc: + return Response({'detail': str(exc)}, status=503) + except Exception as exc: + return Response( + {'detail': f'Failed to refresh contests: {exc}'}, + status=502, + ) + + return Response({ + 'cached_at': payload.get('cached_at'), + 'refresh_boundary': payload.get('refresh_boundary'), + 'contest_count': len(payload.get('contests', [])), + }) diff --git a/website/contest_notifications/apps.py b/website/contest_notifications/apps.py new file mode 100644 index 00000000..8ba45f4f --- /dev/null +++ b/website/contest_notifications/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ContestNotificationsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'contest_notifications' diff --git a/website/contest_notifications/cache_utils.py b/website/contest_notifications/cache_utils.py new file mode 100644 index 00000000..a6779900 --- /dev/null +++ b/website/contest_notifications/cache_utils.py @@ -0,0 +1,36 @@ +from datetime import datetime, timedelta + +import pytz + +CACHE_KEY = 'contest_notifications_data' +IST = pytz.timezone('Asia/Kolkata') + + +def get_last_refresh_boundary(now=None): + """Return the most recent 12:01 AM IST boundary before *now*.""" + if now is None: + now = datetime.now(IST) + elif now.tzinfo is None: + now = IST.localize(now) + else: + now = now.astimezone(IST) + + boundary = now.replace(hour=0, minute=1, second=0, microsecond=0) + if now < boundary: + boundary -= timedelta(days=1) + return boundary + + +def is_cache_fresh(cached_at): + if not cached_at: + return False + + if isinstance(cached_at, str): + cached_at = datetime.fromisoformat(cached_at.replace('Z', '+00:00')) + + if cached_at.tzinfo is None: + cached_at = IST.localize(cached_at) + else: + cached_at = cached_at.astimezone(IST) + + return cached_at >= get_last_refresh_boundary() diff --git a/website/contest_notifications/management/__init__.py b/website/contest_notifications/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/contest_notifications/management/commands/__init__.py b/website/contest_notifications/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/website/contest_notifications/management/commands/refresh_contest_cache.py b/website/contest_notifications/management/commands/refresh_contest_cache.py new file mode 100644 index 00000000..4455fbb6 --- /dev/null +++ b/website/contest_notifications/management/commands/refresh_contest_cache.py @@ -0,0 +1,24 @@ +from django.core.management.base import BaseCommand + +from contest_notifications.services import refresh_contest_cache + + +class Command(BaseCommand): + help = 'Fetch upcoming contests from Clist and refresh the server cache.' + + def add_arguments(self, parser): + parser.add_argument( + '--force', + action='store_true', + help='Refresh even if the current cache is still valid.', + ) + + def handle(self, *args, **options): + payload = refresh_contest_cache(force=options['force']) + contest_count = len(payload.get('contests', [])) + self.stdout.write( + self.style.SUCCESS( + f'Contest cache refreshed at {payload.get("cached_at")} ' + f'with {contest_count} contests.' + ) + ) diff --git a/website/contest_notifications/services.py b/website/contest_notifications/services.py new file mode 100644 index 00000000..273306d1 --- /dev/null +++ b/website/contest_notifications/services.py @@ -0,0 +1,199 @@ +from datetime import datetime, timezone + +import pytz +import requests +from django.conf import settings +from django.core.cache import cache + +from .cache_utils import CACHE_KEY, IST, get_last_refresh_boundary, is_cache_fresh + +CLIST_BASE_URL = 'https://clist.by/api/v4/json/contest/' +PLATFORM_RESOURCES = { + 'codeforces': 'codeforces.com', + 'codechef': 'codechef.com', + 'atcoder': 'atcoder.jp', +} +RESOURCE_ID_PLATFORMS = { + 1: 'codeforces', + 2: 'codechef', + 93: 'atcoder', +} +RESOURCE_LABELS = { + 'codeforces.com': 'Codeforces', + 'codechef.com': 'CodeChef', + 'atcoder.jp': 'AtCoder', + 'codeforces': 'Codeforces', + 'codechef': 'CodeChef', + 'atcoder': 'AtCoder', +} + + +def _ordinal(day): + if 11 <= day % 100 <= 13: + suffix = 'th' + else: + suffix = {1: 'st', 2: 'nd', 3: 'rd'}.get(day % 10, 'th') + return f'{day}{suffix}' + + +def _format_start_time(dt): + dt = dt.astimezone(IST) + hour = dt.hour + minute = dt.minute + period = 'am' if hour < 12 else 'pm' + display_hour = hour % 12 or 12 + return ( + f'{_ordinal(dt.day)} {dt.strftime("%B")}, {dt.year} ' + f'at {display_hour:02d}:{minute:02d} {period} IST' + ) + + +def _format_duration(seconds): + if not seconds: + return 'unknown duration' + + total_minutes = int(seconds) // 60 + hours, minutes = divmod(total_minutes, 60) + + if hours and minutes: + hour_label = 'hour' if hours == 1 else 'hours' + minute_label = 'minute' if minutes == 1 else 'minutes' + return f'{hours} {hour_label} {minutes} {minute_label}' + + if hours: + return f'{hours} hour{"s" if hours != 1 else ""}' + + return f'{minutes} minute{"s" if minutes != 1 else ""}' + + +def _build_notification_text(name, start_dt, duration_seconds, url): + start_text = _format_start_time(start_dt) + duration_text = _format_duration(duration_seconds) + return ( + f'{name} will start on {start_text}.\n' + f'Contest duration is {duration_text}.\n\n' + f'Contest link: {url}\n' + f'Happy Coding! 😀' + ) + + +def _parse_clist_datetime(value): + if not value: + return None + + parsed = datetime.fromisoformat(value.replace('Z', '+00:00')) + if parsed.tzinfo is None: + # Clist returns UTC timestamps without a timezone suffix. + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(IST) + + +def _should_include_contest(platform, name): + normalized_name = (name or '').lower() + + if platform == 'codeforces': + return True + if platform == 'codechef': + return 'starter' in normalized_name + if platform == 'atcoder': + return 'beginner' in normalized_name + + return True + + +def _normalize_contest(item): + resource = item.get('resource', '') + resource_id = item.get('resource_id') + + platform = RESOURCE_ID_PLATFORMS.get(resource_id) + if not platform: + platform = next( + (key for key, host in PLATFORM_RESOURCES.items() if host == resource), + resource.replace('.com', '').replace('.jp', ''), + ) + + start_dt = _parse_clist_datetime(item.get('start')) + end_dt = _parse_clist_datetime(item.get('end')) + + duration_seconds = item.get('duration') + if not duration_seconds and start_dt and end_dt: + duration_seconds = int((end_dt - start_dt).total_seconds()) + + name = item.get('event') or item.get('name') or 'Upcoming Contest' + url = item.get('href') or item.get('url') or '' + + return { + 'platform': platform, + 'platform_label': RESOURCE_LABELS.get(platform, RESOURCE_LABELS.get(resource, platform.title())), + 'name': name, + 'start_time': start_dt.isoformat() if start_dt else None, + 'start_time_ist': _format_start_time(start_dt) if start_dt else None, + 'duration_seconds': duration_seconds, + 'duration_text': _format_duration(duration_seconds), + 'url': url, + 'notification_text': _build_notification_text(name, start_dt, duration_seconds, url) + if start_dt and url + else None, + } + + +def fetch_contests_from_clist(): + username = settings.CLIST_USERNAME + api_key = settings.CLIST_API_KEY + + if not username or not api_key: + raise ValueError('CLIST_USERNAME and CLIST_API_KEY must be configured.') + + now = datetime.now(IST) + params = { + 'username': username, + 'api_key': api_key, + 'resource_id__in': settings.CLIST_RESOURCE_IDS, + 'upcoming': 'true', + 'order_by': 'start', + 'limit': 100, + } + + response = requests.get( + CLIST_BASE_URL, + params=params, + timeout=30, + headers={'User-Agent': 'RecursionContestBot/1.0'}, + ) + response.raise_for_status() + payload = response.json() + + contests = [] + for item in payload.get('objects', []): + normalized = _normalize_contest(item) + if ( + normalized['notification_text'] + and _should_include_contest(normalized['platform'], normalized['name']) + ): + contests.append(normalized) + + contests.sort(key=lambda contest: contest['start_time'] or '') + return { + 'cached_at': now.isoformat(), + 'refresh_boundary': get_last_refresh_boundary(now).isoformat(), + 'contests': contests, + } + + +def refresh_contest_cache(force=False): + cached_payload = cache.get(CACHE_KEY) + + if not force and cached_payload and is_cache_fresh(cached_payload.get('cached_at')): + return cached_payload + + payload = fetch_contests_from_clist() + cache.set(CACHE_KEY, payload, timeout=60 * 60 * 26) + return payload + + +def get_cached_contests(): + cached_payload = cache.get(CACHE_KEY) + if cached_payload and is_cache_fresh(cached_payload.get('cached_at')): + return cached_payload + + return refresh_contest_cache(force=True) diff --git a/website/website/settings.py b/website/website/settings.py index 141e649a..02bacbed 100644 --- a/website/website/settings.py +++ b/website/website/settings.py @@ -51,6 +51,10 @@ FRONTEND_BASE_URL = config('FRONTEND_BASE_URL', default='http://localhost:3000') +CLIST_USERNAME = config('CLIST_USERNAME', default='') +CLIST_API_KEY = config('CLIST_API_KEY', default='') +CLIST_RESOURCE_IDS = config('CLIST_RESOURCE_IDS', default='1,2,93') + # CORS_ALLOW_ALL_ORIGINS = True CORS_ALLOWED_ORIGINS = [ @@ -83,6 +87,7 @@ 'django_filters', 'django_prometheus', 'events_calendar', + 'contest_notifications', 'corsheaders', 'rest_framework_simplejwt.token_blacklist', 'url_shortener', diff --git a/website/website/urls.py b/website/website/urls.py index 1f374b20..e8d52c09 100755 --- a/website/website/urls.py +++ b/website/website/urls.py @@ -53,8 +53,12 @@ path('api/events/', include('events_calendar.api.urls', namespace='events_api')), path('api/team/', include('team.api.urls', namespace='team_api')), path('api/getting_started/', include('getting_started.api.urls', namespace='getting_started_api')), + path('api/url/', include('url_shortener.urls', namespace='url_shortener')), + path('api/contests/', include('contest_notifications.api.urls', namespace='contest_notifications_api')), + + # JWT path('api/token/google/', LoginWithGoogleView.as_view(), name='token_for_google'), path('api/token/', MyTokenObtainPairView.as_view(), name='token_obtain_pair'),