557 lines
23 KiB
Python
557 lines
23 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 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')
|
||
print(DISCORD_BOT_TOKEN or 'FAILED TO PRINT 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") |