"""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 logging import asyncio import threading from datetime import datetime, timedelta from zoneinfo import ZoneInfo from queue import Queue, Empty from discord import Forbidden, HTTPException, NotFound, 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__) # 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): 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.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 async def setup_hook(self): """Called when the bot is ready.""" logger.info(f'TeamTryoutsBot logged in as {self.user}') async def on_ready(self): """Log when the bot is ready and start background tasks.""" 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': await self._send_schedule_notification(**item['data']) except Exception as e: logger.error(f"Error processing queue: {e}") 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_reaction_add(self, reaction, user): """Handle when a reaction is added to a message.""" if user.bot: return if not self.is_dm_channel(reaction.message.channel): return message_id = reaction.message.id if message_id not in self.pending_requests: return request_info = self.pending_requests[message_id] emoji_str = str(reaction.emoji) handler_type = request_info.get('type') request_id = request_info.get('id') if emoji_str == CHECK_EMOJI: if handler_type == 'one_on_one': await self.handle_one_on_one_approve(user, message_id, request_id, reaction.message) elif handler_type == 'schedule_addition': await self.handle_attendance_confirm(user, message_id, request_id, reaction.message) elif emoji_str == CROSS_EMOJI: if handler_type == 'one_on_one': await self.handle_one_on_one_reject(user, message_id, request_id, reaction.message) elif handler_type == 'schedule_addition': await self.handle_attendance_decline(user, message_id, request_id, reaction.message) 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} 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.""" try: user = await self.fetch_user(user_id) if not user: 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} logger.info(f"Sent {event_type} schedule notification, 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, original_message): """Handle coach approving a One on One request.""" try: from models import OneOnOneRequest, db from sqlalchemy.orm import joinedload request = OneOnOneRequest.query.options( joinedload(OneOnOneRequest.player), joinedload(OneOnOneRequest.coach) ).get(request_id) if not request: return if request.coach.discord_user_id != str(coach.id): await original_message.channel.send("āš ļø You are not the intended recipient.") return request.status = 'approved' request.responded_at = datetime.utcnow() db.session.commit() await original_message.channel.send( f"āœ… You have **approved** the One on One session with {request.player.full_name}." ) await self.notify_player_about_one_on_one(request, approved=True) del self.pending_requests[message_id] except Exception as e: logger.error(f"Error handling approval: {e}") async def handle_one_on_one_reject(self, coach, message_id, request_id, original_message): """Handle coach rejecting a One on One request.""" try: from models import OneOnOneRequest, db from sqlalchemy.orm import joinedload request = OneOnOneRequest.query.options( joinedload(OneOnOneRequest.player), joinedload(OneOnOneRequest.coach) ).get(request_id) if not request: return if request.coach.discord_user_id != str(coach.id): await original_message.channel.send("āš ļø You are not the intended recipient.") return refusal_note = None try: async for reply in original_message.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 {request.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 original_message.channel.send(rejection_msg) await self.notify_player_about_one_on_one(request, approved=False, refusal_note=refusal_note) del self.pending_requests[message_id] except Exception as e: logger.error(f"Error handling rejection: {e}") async def handle_attendance_confirm(self, player, message_id, reference_id, original_message): """Handle player confirming attendance for a match/tryout.""" try: from models import MatchParticipant, TryoutRegistration, Match, Tryout, 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.attendance_confirmed = True elif event_type == 'tryout': registration = TryoutRegistration.query.get(reference_id) if registration: registration.attendance_confirmed = True db.session.commit() await original_message.channel.send("āœ… Your attendance has been confirmed!") del self.pending_requests[message_id] except Exception as e: logger.error(f"Error handling attendance confirmation: {e}") async def handle_attendance_decline(self, player, message_id, reference_id, original_message): """Handle player declining attendance for a match/tryout.""" try: from models import MatchParticipant, TryoutRegistration, Match, Tryout, 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: db.session.delete(participant) elif event_type == 'tryout': registration = TryoutRegistration.query.get(reference_id) if registration: registration.status = 'no_show' db.session.commit() await original_message.channel.send("āŒ Your attendance has been declined.") del self.pending_requests[message_id] except Exception as e: logger.error(f"Error handling attendance decline: {e}") async def notify_player_about_one_on_one(self, request, approved=True, refusal_note=None): """Send confirmation to player about One on One response.""" try: # Ensure player and coach relationships are loaded player = request.player coach = request.coach if not player: logger.warning(f"Player not found for request {request.id}") return if not coach: logger.warning(f"Coach not found for request {request.id}") return if not player.discord_user_id: logger.warning(f"Player has no Discord user ID for request {request.id}") return player_user = await self.fetch_user(int(player.discord_user_id)) if not player_user: logger.warning(f"Could not fetch Discord user for player {player.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 notifying player about One on One: {e}") async def send_daily_reminders(self): """Send daily reminders at 18:00 EDT for events in 24-48 hours.""" try: from models import Match, Tryout, MatchParticipant, TryoutRegistration, OneOnOneRequest, db 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_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(): """Get or create the bot instance.""" global bot_instance if bot_instance is None: bot_instance = TeamTryoutsBot() 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 start_bot(): """Start the Discord bot in the background.""" global bot_thread bot = get_bot() 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")