SEC-021 -- rien ne validait le contrat signe upload_signed_contract se contentait d un nom de fichier non vide. ALLOWED_SIGNED_EXTENSIONS etait declaree juste a cote et jamais lue. Le fichier atterrissait sur le disque sous un nom que download_signed_contract sert ensuite : ce qu un joueur televerse est ce qu un gerant ouvre. Le nom seul ne suffisait pas non plus cote upload_contract, qui verifiait `.pdf` en fin de chaine -- payload.pdf ne dit rien des octets. pdf_upload_error() couvre les deux routes : extension dans la liste, puis signature %PDF- en tete de flux. Le flux est rembobine, l appelant enregistre toujours le fichier entier. OPS-014 -- intent Discord privilegie inutile Le bot demandait GUILD_MEMBERS et ne s en servait pas : rien n enumere ni ne recherche de membre de serveur, les personnes sont jointes par le discord_user_id enregistre sur leur compte. Retire. message_content reste : on_raw_reaction_add lit le texte de la reponse d un coach pour consigner un motif de refus. CI-003 et CI-005 sont deja appliques (permissions: contents: read, checkout@v4, exclusions de deploiement). L epinglage par SHA des actions n est pas fait : ce sont des actions GitHub de premiere partie, et l epingler sans Dependabot echange une exposition contre une autre. 11 tests, dont deux verifient que signer un contrat marche toujours et qu un autre joueur ne peut pas le faire. Co-Authored-By: Claude Opus 5 <[email protected]>
818 lines
35 KiB
Python
818 lines
35 KiB
Python
"""Unified Discord bot for Team Tryouts notifications.
|
||
|
||
This module provides a persistent bot that handles:
|
||
- One on One request approvals/rejections via reactions
|
||
- Match/tryout schedule addition notifications with attendance confirmation
|
||
- Daily reminders at 18:00 EDT for upcoming events
|
||
"""
|
||
|
||
import os
|
||
import json
|
||
import logging
|
||
import asyncio
|
||
import threading
|
||
import traceback
|
||
from datetime import datetime, timedelta
|
||
from zoneinfo import ZoneInfo
|
||
from queue import Queue, Empty
|
||
from discord import Intents
|
||
from discord.ext import commands
|
||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||
from apscheduler.triggers.cron import CronTrigger
|
||
from dotenv import load_dotenv
|
||
|
||
load_dotenv()
|
||
DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN')
|
||
|
||
# Configure logging
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# File for persisting pending requests across bot restarts
|
||
PENDING_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'discord_pending.json')
|
||
|
||
# Emoji constants
|
||
CHECK_EMOJI = '✅' # Green checkmark
|
||
CROSS_EMOJI = '❌' # Red X
|
||
|
||
|
||
class TeamTryoutsBot(commands.Bot):
|
||
"""Unified Discord bot for Team Tryouts notifications.
|
||
|
||
Handles One on One requests, schedule additions, and daily reminders.
|
||
"""
|
||
|
||
def __init__(self, flask_app=None):
|
||
# Only what the bot actually reads. `members` — the privileged
|
||
# GUILD_MEMBERS intent — was requested and never used: nothing here
|
||
# lists or looks up guild members, the bot reaches people through
|
||
# the discord_user_id stored on their account (OPS-014).
|
||
#
|
||
# `message_content` stays: on_raw_reaction_add reads the text of a
|
||
# coach's reply to record a refusal note.
|
||
intents = Intents.default()
|
||
intents.message_content = True
|
||
intents.dm_messages = True
|
||
intents.dm_reactions = True
|
||
intents.reactions = True
|
||
intents.guilds = True
|
||
|
||
|
||
super().__init__(command_prefix='!', intents=intents)
|
||
self.flask_app = flask_app
|
||
self.pending_requests = {} # Maps message_id to {type, id} for reaction handling
|
||
self.message_queue = Queue() # Thread-safe queue for messages from Flask
|
||
self.scheduler = AsyncIOScheduler()
|
||
self.timezone = ZoneInfo('America/Toronto') # EDT timezone
|
||
|
||
def _load_pending(self):
|
||
"""Load pending requests from the JSON file."""
|
||
try:
|
||
if os.path.exists(PENDING_FILE):
|
||
with open(PENDING_FILE, 'r') as f:
|
||
data = json.load(f)
|
||
# Convert string keys back to int
|
||
self.pending_requests = {int(k): v for k, v in data.items()}
|
||
logger.info(f"Loaded {len(self.pending_requests)} pending requests from {PENDING_FILE}")
|
||
else:
|
||
logger.info("No pending requests file found, starting fresh.")
|
||
except Exception as e:
|
||
logger.error(f"Error loading pending requests: {e}")
|
||
|
||
def _save_pending(self):
|
||
"""Save pending requests to the JSON file."""
|
||
try:
|
||
with open(PENDING_FILE, 'w') as f:
|
||
json.dump(self.pending_requests, f, indent=2)
|
||
except Exception as e:
|
||
logger.error(f"Error saving pending requests: {e}")
|
||
|
||
async def setup_hook(self):
|
||
"""Called when the bot is ready."""
|
||
self._load_pending()
|
||
logger.info(f'TeamTryoutsBot logged in as {self.user}')
|
||
|
||
async def on_ready(self):
|
||
"""Log when the bot is ready and start background tasks."""
|
||
try:
|
||
guilds = [g.name for g in self.guilds]
|
||
logger.info(f'TeamTryoutsBot is ready! Logged in as {self.user} | Guilds: {guilds}')
|
||
except Exception:
|
||
logger.info(f'TeamTryoutsBot is ready! Logged in as {self.user}')
|
||
|
||
# Start the queue processing task
|
||
self.loop.create_task(self.process_queue())
|
||
|
||
# Start the daily reminder scheduler
|
||
self.loop.create_task(self.start_scheduler())
|
||
|
||
async def start_scheduler(self):
|
||
"""Start the APScheduler for daily reminders."""
|
||
try:
|
||
self.scheduler.add_job(
|
||
self.send_daily_reminders,
|
||
trigger=CronTrigger(hour=18, minute=0, timezone=self.timezone),
|
||
id='daily_reminders',
|
||
replace_existing=True
|
||
)
|
||
self.scheduler.start()
|
||
logger.info('Daily reminder scheduler started (18:00 EDT)')
|
||
except Exception as e:
|
||
logger.error(f'Error starting scheduler: {e}')
|
||
|
||
async def process_queue(self):
|
||
"""Process messages from the queue (runs continuously)."""
|
||
while True:
|
||
try:
|
||
try:
|
||
item = self.message_queue.get_nowait()
|
||
except Empty:
|
||
await asyncio.sleep(0.5)
|
||
continue
|
||
|
||
if item.get('type') == 'one_on_one_request':
|
||
await self._send_one_on_one_dm(**item['data'])
|
||
elif item.get('type') == 'schedule_addition':
|
||
if self.flask_app:
|
||
with self.flask_app.app_context():
|
||
await self._send_schedule_notification(**item['data'])
|
||
else:
|
||
await self._send_schedule_notification(**item['data'])
|
||
elif item.get('type') == 'one_on_one_response':
|
||
await self._send_one_on_one_response_dm(**item['data'])
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error processing queue: {e}\n{traceback.format_exc()}")
|
||
await asyncio.sleep(0.1)
|
||
|
||
def is_dm_channel(self, channel) -> bool:
|
||
"""Check if a channel is a DM channel."""
|
||
return hasattr(channel, 'recipient') or hasattr(channel, 'recipients')
|
||
|
||
async def on_raw_reaction_add(self, payload):
|
||
"""Handle when a reaction is added to a message (works even after bot restart)."""
|
||
# Ignore bot's own reactions
|
||
if payload.user_id == self.user.id:
|
||
return
|
||
|
||
# Check if this is a pending request we're tracking
|
||
if payload.message_id not in self.pending_requests:
|
||
return
|
||
|
||
# Fetch the channel and check if it's a DM
|
||
try:
|
||
channel = await self.fetch_channel(payload.channel_id)
|
||
except Exception:
|
||
return
|
||
|
||
if not self.is_dm_channel(channel):
|
||
return
|
||
|
||
# Fetch the user who reacted
|
||
try:
|
||
user = await self.fetch_user(payload.user_id)
|
||
except Exception:
|
||
return
|
||
|
||
if user is None:
|
||
return
|
||
|
||
request_info = self.pending_requests[payload.message_id]
|
||
emoji_str = str(payload.emoji)
|
||
|
||
handler_type = request_info.get('type')
|
||
request_id = request_info.get('id')
|
||
|
||
if emoji_str == CHECK_EMOJI:
|
||
if handler_type == 'one_on_one':
|
||
if self.flask_app:
|
||
with self.flask_app.app_context():
|
||
await self.handle_one_on_one_approve(user, payload.message_id, request_id, channel)
|
||
else:
|
||
await self.handle_one_on_one_approve(user, payload.message_id, request_id, channel)
|
||
elif handler_type == 'schedule_addition':
|
||
if self.flask_app:
|
||
with self.flask_app.app_context():
|
||
await self.handle_attendance_confirm(user, payload.message_id, request_id, channel)
|
||
else:
|
||
await self.handle_attendance_confirm(user, payload.message_id, request_id, channel)
|
||
elif emoji_str == CROSS_EMOJI:
|
||
if handler_type == 'one_on_one':
|
||
if self.flask_app:
|
||
with self.flask_app.app_context():
|
||
await self.handle_one_on_one_reject(user, payload.message_id, request_id, channel)
|
||
else:
|
||
await self.handle_one_on_one_reject(user, payload.message_id, request_id, channel)
|
||
elif handler_type == 'schedule_addition':
|
||
if self.flask_app:
|
||
with self.flask_app.app_context():
|
||
await self.handle_attendance_decline(user, payload.message_id, request_id, channel)
|
||
else:
|
||
await self.handle_attendance_decline(user, payload.message_id, request_id, channel)
|
||
|
||
async def _send_one_on_one_dm(self, coach_name: str, coach_discord_id: str, player_name: str,
|
||
team_name: str, date_str: str, start_time: str, end_time: str,
|
||
points: str, request_id: int) -> int:
|
||
"""Send a One on One request DM to a coach with reactions."""
|
||
try:
|
||
user_id = int(coach_discord_id)
|
||
except (ValueError, TypeError):
|
||
logger.warning(f"Invalid coach_discord_id '{coach_discord_id}'")
|
||
return None
|
||
|
||
try:
|
||
user = await self.fetch_user(user_id)
|
||
if not user:
|
||
return None
|
||
|
||
message = (
|
||
"📅 **One on One Request**\n\n"
|
||
f"**Player:** {player_name}\n"
|
||
f"**Team:** {team_name or 'Unknown Team'}\n"
|
||
f"**Date:** {date_str}\n"
|
||
f"**Time:** {start_time} - {end_time}\n"
|
||
f"**Discussion Points:** {points or 'No specific points provided'}\n\n"
|
||
"Please respond by clicking a reaction below:\n"
|
||
f"{CHECK_EMOJI} - Confirm the meeting\n"
|
||
f"{CROSS_EMOJI} - Decline (you can add a reason by replying before clicking)"
|
||
)
|
||
|
||
msg = await user.send(message)
|
||
await msg.add_reaction(CHECK_EMOJI)
|
||
await msg.add_reaction(CROSS_EMOJI)
|
||
|
||
# Track this pending request
|
||
self.pending_requests[msg.id] = {'type': 'one_on_one', 'id': request_id}
|
||
self._save_pending()
|
||
|
||
logger.info(f"Sent One on One DM with reactions, message_id={msg.id}")
|
||
return msg.id
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error sending One on One DM: {e}")
|
||
return None
|
||
|
||
async def _send_schedule_notification(self, user_id: int, event_type: str,
|
||
event_title: str, event_date: str,
|
||
event_time: str, reference_id: int) -> int:
|
||
"""Send a schedule addition notification to a player.
|
||
|
||
Args:
|
||
user_id: Database primary key of the User (NOT Discord user ID).
|
||
event_type: 'match' or 'tryout'.
|
||
event_title: Title of the event.
|
||
event_date: Date string.
|
||
event_time: Time string.
|
||
reference_id: ID of the MatchParticipant or TryoutRegistration record.
|
||
"""
|
||
try:
|
||
# Look up the DB user to get their Discord user ID
|
||
from app.models import User as DBUser
|
||
db_user = DBUser.query.get(user_id)
|
||
if not db_user:
|
||
logger.warning(f"DB user {user_id} not found for schedule notification")
|
||
return None
|
||
|
||
if not db_user.discord_user_id:
|
||
logger.warning(f"User {db_user.username} has no Discord user ID, cannot send DM")
|
||
return None
|
||
|
||
discord_uid = int(db_user.discord_user_id)
|
||
user = await self.fetch_user(discord_uid)
|
||
if not user:
|
||
logger.warning(f"Could not fetch Discord user {discord_uid}")
|
||
return None
|
||
|
||
event_name = "Match" if event_type == 'match' else "Tryout"
|
||
|
||
message = (
|
||
f"📅 **{event_name} Scheduled**\n\n"
|
||
f"You have been added to the following {event_type}:\n"
|
||
f"**{event_title}**\n"
|
||
f"**Date:** {event_date}\n"
|
||
f"**Time:** {event_time}\n\n"
|
||
"Please confirm your attendance:\n"
|
||
f"{CHECK_EMOJI} - Confirm attendance\n"
|
||
f"{CROSS_EMOJI} - Decline"
|
||
)
|
||
|
||
msg = await user.send(message)
|
||
await msg.add_reaction(CHECK_EMOJI)
|
||
await msg.add_reaction(CROSS_EMOJI)
|
||
|
||
# Track this pending request
|
||
self.pending_requests[msg.id] = {'type': 'schedule_addition', 'id': reference_id, 'event_type': event_type}
|
||
self._save_pending()
|
||
|
||
logger.info(f"Sent {event_type} schedule notification to {db_user.username}, message_id={msg.id}")
|
||
return msg.id
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error sending schedule notification: {e}")
|
||
return None
|
||
|
||
async def handle_one_on_one_approve(self, coach, message_id, request_id, channel):
|
||
"""Handle coach approving a One on One request."""
|
||
try:
|
||
from app.models import OneOnOneRequest
|
||
from app.extensions import db
|
||
|
||
request = OneOnOneRequest.query.get(request_id)
|
||
if not request:
|
||
return
|
||
|
||
if request.coach.discord_user_id != str(coach.id):
|
||
await channel.send("⚠️ You are not the intended recipient.")
|
||
return
|
||
|
||
# Re-attach to current session (object may be detached across app contexts)
|
||
request = db.session.merge(request)
|
||
|
||
# Capture data before commit
|
||
player_full_name = request.player.full_name if request.player else 'Unknown'
|
||
player_discord_id = request.player.discord_user_id if request.player else None
|
||
coach_obj = request.coach
|
||
|
||
request.status = 'approved'
|
||
request.responded_at = datetime.utcnow()
|
||
db.session.commit()
|
||
|
||
await channel.send(
|
||
f"✅ You have **approved** the One on One session with {player_full_name}."
|
||
)
|
||
|
||
# Notify player via Discord
|
||
if player_discord_id:
|
||
await self.notify_player_about_one_on_one_direct(
|
||
player_discord_id=player_discord_id,
|
||
player_full_name=player_full_name,
|
||
coach_full_name=coach_obj.full_name if coach_obj else 'Coach',
|
||
request=request,
|
||
approved=True
|
||
)
|
||
del self.pending_requests[message_id]
|
||
self._save_pending()
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error handling approval: {e}\n{traceback.format_exc()}")
|
||
|
||
async def handle_one_on_one_reject(self, coach, message_id, request_id, channel):
|
||
"""Handle coach rejecting a One on One request."""
|
||
try:
|
||
from app.models import OneOnOneRequest
|
||
from app.extensions import db
|
||
|
||
request = OneOnOneRequest.query.get(request_id)
|
||
if not request:
|
||
return
|
||
|
||
if request.coach.discord_user_id != str(coach.id):
|
||
await channel.send("⚠️ You are not the intended recipient.")
|
||
return
|
||
|
||
# Re-attach to current session (object may be detached across app contexts)
|
||
request = db.session.merge(request)
|
||
|
||
player_full_name = request.player.full_name if request.player else 'Unknown'
|
||
player_discord_id = request.player.discord_user_id if request.player else None
|
||
coach_obj = request.coach
|
||
|
||
refusal_note = None
|
||
try:
|
||
async for reply in channel.history(limit=20):
|
||
if reply.author.id == coach.id and reply.reference and reply.reference.message_id == message_id:
|
||
refusal_note = reply.content
|
||
break
|
||
except Exception as e:
|
||
logger.warning(f"Could not check for reply message: {e}")
|
||
|
||
request.status = 'rejected'
|
||
request.responded_at = datetime.utcnow()
|
||
if refusal_note:
|
||
request.coach_rejection_message = refusal_note
|
||
db.session.commit()
|
||
|
||
rejection_msg = f"❌ You have **rejected** the One on One session with {player_full_name}."
|
||
if refusal_note:
|
||
rejection_msg += f"\n**Reason:** {refusal_note}"
|
||
else:
|
||
rejection_msg += "\n\nℹ️ The player has been notified that you are not available."
|
||
|
||
await channel.send(rejection_msg)
|
||
if player_discord_id:
|
||
await self.notify_player_about_one_on_one_direct(
|
||
player_discord_id=player_discord_id,
|
||
player_full_name=player_full_name,
|
||
coach_full_name=coach_obj.full_name if coach_obj else 'Coach',
|
||
request=request,
|
||
approved=False,
|
||
refusal_note=refusal_note
|
||
)
|
||
del self.pending_requests[message_id]
|
||
self._save_pending()
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error handling rejection: {e}\n{traceback.format_exc()}")
|
||
|
||
async def handle_attendance_confirm(self, player, message_id, reference_id, channel):
|
||
"""Handle player confirming attendance for a match/tryout."""
|
||
try:
|
||
from app.models import MatchParticipant, TryoutRegistration
|
||
from app.extensions import db
|
||
|
||
request_info = self.pending_requests[message_id]
|
||
event_type = request_info.get('event_type')
|
||
|
||
if event_type == 'match':
|
||
participant = MatchParticipant.query.get(reference_id)
|
||
if participant:
|
||
participant = db.session.merge(participant)
|
||
participant.attendance_confirmed = True
|
||
elif event_type == 'tryout':
|
||
registration = TryoutRegistration.query.get(reference_id)
|
||
if registration:
|
||
registration = db.session.merge(registration)
|
||
registration.attendance_confirmed = True
|
||
|
||
db.session.commit()
|
||
|
||
await channel.send("✅ Your attendance has been confirmed!")
|
||
del self.pending_requests[message_id]
|
||
self._save_pending()
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error handling attendance confirmation: {e}\n{traceback.format_exc()}")
|
||
|
||
async def handle_attendance_decline(self, player, message_id, reference_id, channel):
|
||
"""Handle player declining attendance for a match/tryout."""
|
||
try:
|
||
from app.models import MatchParticipant, TryoutRegistration
|
||
from app.extensions import db
|
||
|
||
request_info = self.pending_requests[message_id]
|
||
event_type = request_info.get('event_type')
|
||
|
||
if event_type == 'match':
|
||
participant = MatchParticipant.query.get(reference_id)
|
||
if participant:
|
||
participant = db.session.merge(participant)
|
||
db.session.delete(participant)
|
||
elif event_type == 'tryout':
|
||
registration = TryoutRegistration.query.get(reference_id)
|
||
if registration:
|
||
registration = db.session.merge(registration)
|
||
registration.status = 'no_show'
|
||
|
||
db.session.commit()
|
||
|
||
await channel.send("❌ Your attendance has been declined.")
|
||
del self.pending_requests[message_id]
|
||
self._save_pending()
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error handling attendance decline: {e}\n{traceback.format_exc()}")
|
||
|
||
async def notify_player_about_one_on_one(self, request, approved=True, refusal_note=None):
|
||
"""Send confirmation to player about One on One response.
|
||
|
||
This is the legacy method kept for backward compatibility with any
|
||
callers that pass a fully-loaded request object.
|
||
"""
|
||
try:
|
||
player = request.player
|
||
coach = request.coach
|
||
|
||
if not player or not player.discord_user_id:
|
||
logger.warning(f"Player has no Discord user ID for request {request.id}")
|
||
return
|
||
|
||
if not coach:
|
||
logger.warning(f"Coach not found for request {request.id}")
|
||
return
|
||
|
||
await self.notify_player_about_one_on_one_direct(
|
||
player_discord_id=player.discord_user_id,
|
||
player_full_name=player.full_name,
|
||
coach_full_name=coach.full_name,
|
||
request=request,
|
||
approved=approved,
|
||
refusal_note=refusal_note
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Error notifying player about One on One: {e}")
|
||
|
||
async def notify_player_about_one_on_one_direct(self, player_discord_id, player_full_name,
|
||
coach_full_name, request,
|
||
approved=True, refusal_note=None):
|
||
"""Send confirmation to player about One on One response using pre-fetched data.
|
||
|
||
This method avoids session expiration issues by using data captured before
|
||
the database commit.
|
||
|
||
Args:
|
||
player_discord_id: The player's Discord user ID string.
|
||
player_full_name: The player's full name.
|
||
coach_full_name: The coach's full name.
|
||
request: The OneOnOneRequest object (for date/time/points data only).
|
||
approved: Whether the session was approved.
|
||
refusal_note: Optional coach refusal reason.
|
||
"""
|
||
try:
|
||
if not player_discord_id:
|
||
logger.warning(f"Player has no Discord user ID for request {request.id}")
|
||
return
|
||
|
||
player_user = await self.fetch_user(int(player_discord_id))
|
||
if not player_user:
|
||
logger.warning(f"Could not fetch Discord user {player_discord_id}")
|
||
return
|
||
|
||
if approved:
|
||
message = (
|
||
"🎉 **One on One Session Confirmed!**\n\n"
|
||
f"Your coach **{coach_full_name}** has approved your request:\n"
|
||
f"**Date:** {request.date.strftime('%A, %B %d, %Y')}\n"
|
||
f"**Time:** {request.start_time.strftime('%I:%M %p')} - {request.end_time.strftime('%I:%M %p')}\n"
|
||
f"**Discussion Points:** {request.points or 'No specific points provided'}\n\n"
|
||
"Please prepare for your session!"
|
||
)
|
||
else:
|
||
if refusal_note:
|
||
message = (
|
||
"😞 **One on One Session Rejected**\n\n"
|
||
f"Your coach **{coach_full_name}** has declined:\n"
|
||
f"**Reason:** {refusal_note}\n\n"
|
||
"Please try selecting a different time slot."
|
||
)
|
||
else:
|
||
message = (
|
||
"😞 **One on One Session Unavailable**\n\n"
|
||
f"Your coach **{coach_full_name}** is not available.\n\n"
|
||
"Please try selecting a different time slot."
|
||
)
|
||
|
||
await player_user.send(message)
|
||
logger.info(f"Sent One on One notification to player {player_full_name} (request {request.id})")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error in direct One on One notification: {e}")
|
||
|
||
async def send_daily_reminders(self):
|
||
"""Send daily reminders at 18:00 EDT for events in 24-48 hours."""
|
||
try:
|
||
if self.flask_app:
|
||
with self.flask_app.app_context():
|
||
await self._send_daily_reminders_impl()
|
||
else:
|
||
await self._send_daily_reminders_impl()
|
||
except Exception as e:
|
||
logger.error(f"Error sending daily reminders: {e}\n{traceback.format_exc()}")
|
||
|
||
async def _send_daily_reminders_impl(self):
|
||
"""Internal implementation of daily reminders with proper app context."""
|
||
try:
|
||
from app.models import Match, Tryout, MatchParticipant, TryoutRegistration, OneOnOneRequest
|
||
from sqlalchemy.orm import joinedload
|
||
|
||
now = datetime.now(self.timezone)
|
||
tomorrow = now.date() + timedelta(days=1)
|
||
|
||
# Find matches for tomorrow
|
||
matches = Match.query.filter(Match.date == tomorrow).all()
|
||
for match in matches:
|
||
participants = MatchParticipant.query.filter_by(match_id=match.id).all()
|
||
for participant in participants:
|
||
if participant.player.discord_user_id:
|
||
await self.send_match_reminder(participant.player, match)
|
||
|
||
# Find tryouts for tomorrow
|
||
tryouts = Tryout.query.filter(Tryout.date == tomorrow).all()
|
||
for tryout in tryouts:
|
||
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout.id).all()
|
||
for reg in registrations:
|
||
if reg.player.discord_user_id:
|
||
await self.send_tryout_reminder(reg.player, tryout)
|
||
|
||
# Find One on One sessions for tomorrow (only approved ones)
|
||
one_on_ones = OneOnOneRequest.query.options(
|
||
joinedload(OneOnOneRequest.player),
|
||
joinedload(OneOnOneRequest.coach)
|
||
).filter(
|
||
OneOnOneRequest.date == tomorrow,
|
||
OneOnOneRequest.status == 'approved'
|
||
).all()
|
||
for session in one_on_ones:
|
||
if session.player and session.player.discord_user_id:
|
||
await self.send_one_on_one_reminder(session.player, session)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error sending daily reminders: {e}")
|
||
|
||
async def send_match_reminder(self, player, match):
|
||
"""Send match reminder to player."""
|
||
try:
|
||
player_user = await self.fetch_user(int(player.discord_user_id))
|
||
message = (
|
||
"🔔 **Match Reminder**\n\n"
|
||
f"Your match **{match.title}** is scheduled for tomorrow:\n"
|
||
f"**Date:** {match.date.strftime('%A, %B %d, %Y')}\n"
|
||
f"**Time:** {match.start_time.strftime('%I:%M %p') if match.start_time else 'TBD'} - "
|
||
f"{match.end_time.strftime('%I:%M %p') if match.end_time else 'TBD'}\n"
|
||
f"**Location:** {match.location or 'TBD'}\n\n"
|
||
"Please confirm your attendance in the app."
|
||
)
|
||
await player_user.send(message)
|
||
except Exception as e:
|
||
logger.error(f"Error sending match reminder: {e}")
|
||
|
||
async def send_tryout_reminder(self, player, tryout):
|
||
"""Send tryout reminder to player."""
|
||
try:
|
||
player_user = await self.fetch_user(int(player.discord_user_id))
|
||
message = (
|
||
"🔔 **Tryout Reminder**\n\n"
|
||
f"Your tryout **{tryout.title}** is scheduled for tomorrow:\n"
|
||
f"**Date:** {tryout.date.strftime('%A, %B %d, %Y')}\n"
|
||
f"**Location:** {tryout.location or 'TBD'}\n\n"
|
||
"Please confirm your attendance in the app."
|
||
)
|
||
await player_user.send(message)
|
||
except Exception as e:
|
||
logger.error(f"Error sending tryout reminder: {e}")
|
||
|
||
async def _send_one_on_one_response_dm(self, player_discord_id: str, player_full_name: str,
|
||
coach_full_name: str, date_str: str, start_time: str,
|
||
end_time: str, points: str, approved: bool,
|
||
refusal_note: str = None) -> bool:
|
||
"""Send a DM to a player notifying them of their One on One request response.
|
||
|
||
Called from the message queue when a coach accepts/rejects via the web app.
|
||
"""
|
||
try:
|
||
if not player_discord_id:
|
||
logger.warning("Cannot send response DM: no player_discord_id")
|
||
return False
|
||
|
||
player_user = await self.fetch_user(int(player_discord_id))
|
||
if not player_user:
|
||
logger.warning(f"Could not fetch Discord user {player_discord_id}")
|
||
return False
|
||
|
||
if approved:
|
||
message = (
|
||
"🎉 **One on One Session Confirmed!**\n\n"
|
||
f"Your coach **{coach_full_name}** has approved your request:\n"
|
||
f"**Date:** {date_str}\n"
|
||
f"**Time:** {start_time} - {end_time}\n"
|
||
f"**Discussion Points:** {points or 'No specific points provided'}\n\n"
|
||
"Please prepare for your session!"
|
||
)
|
||
else:
|
||
if refusal_note:
|
||
message = (
|
||
"😞 **One on One Session Rejected**\n\n"
|
||
f"Your coach **{coach_full_name}** has declined:\n"
|
||
f"**Reason:** {refusal_note}\n\n"
|
||
"Please try selecting a different time slot."
|
||
)
|
||
else:
|
||
message = (
|
||
"😞 **One on One Session Unavailable**\n\n"
|
||
f"Your coach **{coach_full_name}** is not available.\n\n"
|
||
"Please try selecting a different time slot."
|
||
)
|
||
|
||
await player_user.send(message)
|
||
logger.info(f"Sent One on One response DM to player {player_full_name} (approved={approved})")
|
||
return True
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error sending One on One response DM: {e}")
|
||
return False
|
||
|
||
async def send_one_on_one_reminder(self, player, session):
|
||
"""Send One on One reminder to player."""
|
||
try:
|
||
player_user = await self.fetch_user(int(player.discord_user_id))
|
||
message = (
|
||
"🔔 **One on One Reminder**\n\n"
|
||
f"Your One on One session with **{session.coach.full_name}** is scheduled for tomorrow:\n"
|
||
f"**Date:** {session.date.strftime('%A, %B %d, %Y')}\n"
|
||
f"**Time:** {session.start_time.strftime('%I:%M %p')} - {session.end_time.strftime('%I:%M %p')}\n"
|
||
f"**Discussion Points:** {session.points or 'No specific points provided'}\n\n"
|
||
"Please prepare for your session!"
|
||
)
|
||
await player_user.send(message)
|
||
except Exception as e:
|
||
logger.error(f"Error sending One on One reminder: {e}")
|
||
|
||
|
||
# Global bot instance
|
||
bot_instance = None
|
||
bot_thread = None
|
||
|
||
|
||
def get_bot(flask_app=None):
|
||
"""Get or create the bot instance."""
|
||
global bot_instance
|
||
if bot_instance is None:
|
||
bot_instance = TeamTryoutsBot(flask_app=flask_app)
|
||
elif flask_app is not None and bot_instance.flask_app is None:
|
||
bot_instance.flask_app = flask_app
|
||
return bot_instance
|
||
|
||
|
||
def send_one_on_one_dm(coach_name: str, coach_discord_id: str, player_name: str,
|
||
team_name: str, date_str: str, start_time: str, end_time: str,
|
||
points: str, request_id: int) -> bool:
|
||
"""Queue a One on One request DM to be sent by the bot."""
|
||
bot = get_bot()
|
||
try:
|
||
bot.message_queue.put({
|
||
'type': 'one_on_one_request',
|
||
'data': {
|
||
'coach_name': coach_name,
|
||
'coach_discord_id': coach_discord_id,
|
||
'player_name': player_name,
|
||
'team_name': team_name,
|
||
'date_str': date_str,
|
||
'start_time': start_time,
|
||
'end_time': end_time,
|
||
'points': points,
|
||
'request_id': request_id
|
||
}
|
||
})
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"Error queuing One on One DM: {e}")
|
||
return False
|
||
|
||
|
||
def send_schedule_notification(user_id: int, event_type: str, event_title: str,
|
||
event_date: str, event_time: str, reference_id: int) -> bool:
|
||
"""Queue a schedule addition notification to be sent by the bot."""
|
||
bot = get_bot()
|
||
try:
|
||
bot.message_queue.put({
|
||
'type': 'schedule_addition',
|
||
'data': {
|
||
'user_id': user_id,
|
||
'event_type': event_type,
|
||
'event_title': event_title,
|
||
'event_date': event_date,
|
||
'event_time': event_time,
|
||
'reference_id': reference_id
|
||
}
|
||
})
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"Error queuing schedule notification: {e}")
|
||
return False
|
||
|
||
|
||
def send_one_on_one_response(player_discord_id: str, player_full_name: str,
|
||
coach_full_name: str, date_str: str, start_time: str,
|
||
end_time: str, points: str, approved: bool,
|
||
refusal_note: str = None) -> bool:
|
||
"""Queue a One on One response DM to be sent to the player by the bot.
|
||
|
||
Called from Flask routes when a coach accepts/rejects via the web app.
|
||
"""
|
||
bot = get_bot()
|
||
try:
|
||
bot.message_queue.put({
|
||
'type': 'one_on_one_response',
|
||
'data': {
|
||
'player_discord_id': player_discord_id,
|
||
'player_full_name': player_full_name,
|
||
'coach_full_name': coach_full_name,
|
||
'date_str': date_str,
|
||
'start_time': start_time,
|
||
'end_time': end_time,
|
||
'points': points,
|
||
'approved': approved,
|
||
'refusal_note': refusal_note,
|
||
}
|
||
})
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"Error queuing One on One response DM: {e}")
|
||
return False
|
||
|
||
|
||
def start_bot(flask_app=None):
|
||
"""Start the Discord bot in the background."""
|
||
global bot_thread
|
||
|
||
bot = get_bot(flask_app=flask_app)
|
||
if DISCORD_BOT_TOKEN and bot_thread is None:
|
||
def run_bot():
|
||
try:
|
||
bot.run(DISCORD_BOT_TOKEN)
|
||
except Exception as e:
|
||
logger.error(f"Bot error: {e}")
|
||
|
||
bot_thread = threading.Thread(target=run_bot, daemon=True)
|
||
bot_thread.start()
|
||
logger.info("TeamTryoutsBot started in background thread")
|
||
elif not DISCORD_BOT_TOKEN:
|
||
logger.warning("DISCORD_BOT_TOKEN not set, bot not started") |