"""Discord bot lifecycle, class definition, and supervisor.""" import logging import threading import time from datetime import datetime, timedelta from zoneinfo import ZoneInfo from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger from sqlalchemy.exc import SQLAlchemyError from app.discord.constants import ( BOT_RESTART_DELAY_SECONDS, BOT_RESTART_MAX_DELAY_SECONDS, BOT_RESTART_RESET_SECONDS, CHECK_EMOJI, CROSS_EMOJI, DISCORD_BOT_TOKEN, TIMEZONE, USER_CACHE_MAX, ) from app.discord.pending import ( forget_pending, load_pending, save_pending, ) from app.discord.queue import message_queue from app.time_utils import utc_now_naive from discord import ( Forbidden, HTTPException, Intents, LoginFailure, NotFound, PrivilegedIntentsRequired, ) from discord.ext import commands _logger = logging.getLogger(__name__) bot_instance = None bot_thread = None bot_flask_app = None _stop_bot = threading.Event() class TeamTryoutsBot(commands.Bot): """Unified Discord bot for Team Tryouts notifications.""" def __init__(self, flask_app=None): 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 = load_pending() self.message_queue = message_queue self.scheduler = AsyncIOScheduler() self.timezone = ZoneInfo(TIMEZONE) self._user_cache = {} self.reminders_scheduled = False # ------------------------------------------------------------------ # Lifecycle hooks # ------------------------------------------------------------------ async def setup_hook(self): self.loop.create_task(self.process_queue()) async def on_error(self, event_method, *args, **kwargs): _logger.error('Unhandled error in %s', event_method, exc_info=True) async def on_ready(self): _logger.info('Bot logged in as %s', self.user) await self.start_scheduler() async def start_scheduler(self): if self.reminders_scheduled: _logger.debug('Reminders already scheduled, skipping') return try: self.scheduler.add_job( self._send_daily_reminders_impl, CronTrigger(hour=18, minute=0, timezone=self.timezone), id='daily_match_check', replace_existing=True, ) self.scheduler.start() self.reminders_scheduled = True _logger.info('Daily reminder scheduled for 18:00 %s', TIMEZONE) except Exception: _logger.exception('Failed to start the daily reminder scheduler') self.reminders_scheduled = False # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ async def _reply(self, channel, message): try: await channel.send(message) except (HTTPException, Forbidden) as exc: _logger.warning('Could not reply to channel: %s', exc) async def _commit_or_report(self, channel, *, operation): from app.extensions import db try: operation() db.session.commit() except SQLAlchemyError: db.session.rollback() _logger.exception('DB commit failed for %s', operation.__name__) await self._reply(channel, 'Something went wrong saving that. Please try again.') def _forget_pending(self, message_id): self.pending_requests = forget_pending(self.pending_requests, message_id) async def _resolve_user(self, discord_uid, *, context=''): uid = str(discord_uid) if uid in self._user_cache: return self._user_cache[uid] try: user = await self.fetch_user(int(uid)) except (NotFound, HTTPException) as exc: _logger.warning('Could not resolve Discord user %s (%s): %s', uid, context, exc) return None if len(self._user_cache) >= USER_CACHE_MAX: self._user_cache.pop(next(iter(self._user_cache))) self._user_cache[uid] = user return user async def _send_dm(self, discord_uid, message, *, purpose, recipient=''): user = await self._resolve_user(discord_uid, context=purpose) if not user: _logger.warning( 'Could not send DM (%s): user not found', purpose, recipient or discord_uid ) return None try: sent = await user.send(message) _logger.info( 'DM sent (%s) to %s (%s)', purpose, recipient or user.display_name, discord_uid ) return sent except Forbidden: _logger.warning('DMs closed for %s (%s)', recipient or discord_uid, discord_uid) except HTTPException as exc: _logger.warning('Failed to DM %s: %s', discord_uid, exc) except Exception: _logger.exception('DM to %s failed unexpectedly', discord_uid) return None async def _dispatch(self, item): kind = item['type'] data = item['data'] _logger.debug('Dispatching %s', kind) try: if kind == 'one_on_one_request': await self._send_one_on_one_dm(**data) elif kind == 'schedule_addition': await self._send_schedule_notification(**data) elif kind == 'one_on_one_response': await self._send_one_on_one_response_dm(**data) elif kind == 'purge_pending': pids = data.get('participant_ids', []) self.pending_requests = { mid: e for mid, e in self.pending_requests.items() if e.get('type') != 'attendance' or e.get('reference_id') not in pids } save_pending(self.pending_requests) else: _logger.warning('Unknown queue item type: %s', kind) except Exception: _logger.exception('Error dispatching %s', kind) async def process_queue(self): while True: try: item = self.message_queue.get(timeout=5) except __import__('queue').Empty: continue await self._dispatch(item) def is_dm_channel(self, channel) -> bool: return channel.type.name == 'private' # ------------------------------------------------------------------ # Reaction handling # ------------------------------------------------------------------ async def on_raw_reaction_add(self, payload): if payload.user_id == self.user.id: return message_id = payload.message_id entry = self.pending_requests.get(str(message_id)) if not entry: return channel = self.get_channel(payload.channel_id) if not channel: return reacting_user = payload.member or await self._resolve_user( payload.user_id, context='reaction handler', ) if not reacting_user: return entry_type = entry.get('type') reference_id = entry.get('reference_id') emoji = str(payload.emoji) if emoji == CHECK_EMOJI: if entry_type == 'one_on_one': await self.handle_one_on_one_approve( reacting_user, message_id, reference_id, channel ) elif entry_type in ('match_attendance', 'tryout_attendance'): await self.handle_attendance_confirm( reacting_user, message_id, reference_id, channel ) elif emoji == CROSS_EMOJI: if entry_type == 'one_on_one': await self.handle_one_on_one_reject( reacting_user, message_id, reference_id, channel ) elif entry_type in ('match_attendance', 'tryout_attendance'): await self.handle_attendance_decline( reacting_user, message_id, reference_id, channel ) # ------------------------------------------------------------------ # One-on-One handlers # ------------------------------------------------------------------ async def _send_one_on_one_dm( self, coach_name='', coach_discord_id='', player_name='', team_name='', date_str='', start_time='', end_time='', points='', request_id=None, ): embed = { 'embeds': [ { 'title': 'One on One Request', 'color': 3447003, 'fields': [ {'name': 'Player', 'value': player_name, 'inline': True}, {'name': 'Team', 'value': team_name or 'Unknown', 'inline': True}, {'name': 'Date', 'value': date_str, 'inline': True}, {'name': 'Time', 'value': f'{start_time} - {end_time}', 'inline': True}, {'name': 'Discussion Points', 'value': points or 'None', 'inline': False}, { 'name': 'Quick Reply', 'value': f'React with {CHECK_EMOJI} to approve, {CROSS_EMOJI} to reject.', 'inline': False, }, ], 'footer': {'text': f'Request #{request_id}'}, } ] } sent = await self._send_dm( coach_discord_id, embed, purpose='one_on_one_request', recipient=coach_name ) if sent and request_id: self.pending_requests[str(sent.id)] = { 'type': 'one_on_one', 'reference_id': request_id, 'created_at': time.time(), } save_pending(self.pending_requests) async def handle_one_on_one_approve(self, coach, message_id, request_id, channel): from app.extensions import db from app.models import OneOnOneRequest, User req = db.session.get(OneOnOneRequest, request_id) if not req: await self._reply(channel, 'This request no longer exists.') self._forget_pending(message_id) return if str(coach.id) != str(req.coach.discord_user_id): await self._reply(channel, 'This request is not for you.') return if req.status != 'pending': await self._reply(channel, 'This request has already been handled.') self._forget_pending(message_id) return async def _approve(): req.status = 'approved' req.responded_at = utc_now_naive() await self._commit_or_report(channel, operation=_approve) await self._reply(channel, f'One on One request from {req.player.full_name} approved!') self._forget_pending(message_id) player = db.session.get(User, req.player_id) if player and player.discord_user_id: await self._send_one_on_one_response_dm( player_discord_id=player.discord_user_id, player_full_name=player.full_name, coach_full_name=getattr(coach, 'display_name', 'Coach'), date_str=req.date.strftime('%A, %B %d, %Y'), start_time=req.start_time.strftime('%I:%M %p') if req.start_time else 'TBD', end_time=req.end_time.strftime('%I:%M %p') if req.end_time else 'TBD', points=req.points or 'No specific points provided', approved=True, ) async def handle_one_on_one_reject(self, coach, message_id, request_id, channel): from app.extensions import db from app.models import OneOnOneRequest, User req = db.session.get(OneOnOneRequest, request_id) if not req: await self._reply(channel, 'This request no longer exists.') self._forget_pending(message_id) return if str(coach.id) != str(req.coach.discord_user_id): await self._reply(channel, 'This request is not for you.') return if req.status != 'pending': await self._reply(channel, 'This request has already been handled.') self._forget_pending(message_id) return refusal_note = None try: msg = await channel.fetch_message(message_id) if msg and msg.reference and msg.reference.resolved: reply_to = msg.reference.resolved if reply_to.author.id == coach.id: refusal_note = reply_to.content if isinstance(reply_to.content, str) else None except (NotFound, HTTPException) as exc: _logger.warning('Could not read rejection reply: %s', exc) async def _reject(): req.status = 'rejected' req.responded_at = utc_now_naive() if refusal_note: req.coach_rejection_message = refusal_note await self._commit_or_report(channel, operation=_reject) await self._reply(channel, f'One on One request from {req.player.full_name} rejected.') self._forget_pending(message_id) player = db.session.get(User, req.player_id) if player and player.discord_user_id: await self._send_one_on_one_response_dm( player_discord_id=player.discord_user_id, player_full_name=player.full_name, coach_full_name=getattr(coach, 'display_name', 'Coach'), date_str=req.date.strftime('%A, %B %d, %Y'), start_time=req.start_time.strftime('%I:%M %p') if req.start_time else 'TBD', end_time=req.end_time.strftime('%I:%M %p') if req.end_time else 'TBD', points=req.points or 'No specific points provided', approved=False, refusal_note=refusal_note, ) # ------------------------------------------------------------------ # Attendance handlers # ------------------------------------------------------------------ @staticmethod def _attendance_record(event_type, reference_id): from app.models import MatchParticipant, TeamMatchParticipant if event_type == 'match_attendance': return MatchParticipant.query.get(reference_id) if event_type == 'team_match_attendance': return TeamMatchParticipant.query.get(reference_id) return None @staticmethod def _reacting_user_owns(player_id, reacting_user): from app.models import User player = User.query.get(player_id) if not player: return False return str(player.discord_user_id) == str(reacting_user.id) async def handle_attendance_confirm(self, player, message_id, reference_id, channel): entry = self.pending_requests.get(str(message_id)) if not entry: return event_type = entry.get('type', '') record = self._attendance_record(event_type, reference_id) if not record: await self._reply(channel, 'This event no longer exists.') self._forget_pending(message_id) return if not self._reacting_user_owns(record.player_id, player): await self._reply(channel, 'You cannot confirm attendance for someone else.') return async def _confirm(): record.attendance_confirmed = True record.is_confirmed = True await self._commit_or_report(channel, operation=_confirm) await self._reply(channel, 'Attendance confirmed! āœ…') self._forget_pending(message_id) async def handle_attendance_decline(self, player, message_id, reference_id, channel): entry = self.pending_requests.get(str(message_id)) if not entry: return event_type = entry.get('type', '') record = self._attendance_record(event_type, reference_id) if not record: await self._reply(channel, 'This event no longer exists.') self._forget_pending(message_id) return if not self._reacting_user_owns(record.player_id, player): await self._reply(channel, 'You cannot decline attendance for someone else.') return async def _decline(): record.attendance_confirmed = False record.is_confirmed = False await self._commit_or_report(channel, operation=_decline) await self._reply(channel, 'Attendance declined. āŒ') self._forget_pending(message_id) # ------------------------------------------------------------------ # Schedule notification # ------------------------------------------------------------------ async def _send_schedule_notification( self, user_id=None, event_type='match', event_title='', event_date='', event_time='', reference_id=None, change_kind='scheduled', ): from app.models import User user = User.query.get(user_id) if not user or not user.discord_user_id: _logger.warning('Cannot notify user %s: no Discord user id', user_id) return title = 'šŸŽÆ Match Updated' if change_kind == 'modified' else 'šŸŽÆ New Match Scheduled' desc = f'**{event_title}**\nšŸ“… {event_date}\nā° {event_time}' embed = { 'embeds': [ { 'title': title, 'color': 7506394 if change_kind == 'modified' else 3447003, 'description': desc, 'fields': [ { 'name': 'Quick RSVP', 'value': f'React with {CHECK_EMOJI} to confirm, {CROSS_EMOJI} to decline.', 'inline': False, } ], } ] } sent = await self._send_dm( user.discord_user_id, embed, purpose=f'{change_kind}_{event_type}', recipient=user.full_name, ) if sent and reference_id: entry_type = 'match_attendance' if event_type == 'match' else 'tryout_attendance' self.pending_requests[str(sent.id)] = { 'type': entry_type, 'reference_id': reference_id, 'created_at': time.time(), } save_pending(self.pending_requests) # ------------------------------------------------------------------ # One-on-One response DM # ------------------------------------------------------------------ async def _send_one_on_one_response_dm( self, player_discord_id='', player_full_name='', coach_full_name='', date_str='', start_time='', end_time='', points='', approved=True, refusal_note=None, ): status = 'āœ… Approved' if approved else 'āŒ Rejected' color = 3066993 if approved else 15158332 desc = f'Your One on One request has been **{"approved" if approved else "rejected"}**.' embed = { 'embeds': [ { 'title': f'One on One {status}', 'color': color, 'description': desc, 'fields': [ {'name': 'Coach', 'value': coach_full_name, 'inline': True}, {'name': 'Date', 'value': date_str, 'inline': True}, {'name': 'Time', 'value': f'{start_time} - {end_time}', 'inline': True}, {'name': 'Discussion Points', 'value': points or 'None', 'inline': False}, ], } ] } if refusal_note: embed['embeds'][0]['fields'].append( {'name': 'Reason', 'value': refusal_note, 'inline': False} ) await self._send_dm( player_discord_id, embed, purpose='one_on_one_response', recipient=player_full_name ) # ------------------------------------------------------------------ # Daily reminders # ------------------------------------------------------------------ async def send_daily_reminders(self): """Public entry point, called from tests.""" await self._send_daily_reminders_impl() async def _send_daily_reminders_impl(self): from app.extensions import db from app.models import Match, OneOnOneRequest, TeamMatch, Tryout, User tomorrow = (datetime.now(self.timezone) + timedelta(days=1)).date() _logger.info('Running daily reminder check for %s', tomorrow) for match in Match.query.filter(Match.date == tomorrow).all(): for participant in match.participants: await self.send_match_reminder(participant.player, match) for tm in TeamMatch.query.filter(TeamMatch.date == tomorrow).all(): for participant in tm.participants: await self.send_match_reminder(participant.player, tm) for tryout in Tryout.query.all(): if any(m.date == tomorrow for m in tryout.matches): for reg in tryout.registrations: await self.send_tryout_reminder(reg.player, tryout) sessions = OneOnOneRequest.query.filter( OneOnOneRequest.status == 'pending', OneOnOneRequest.date == tomorrow ).all() for session in sessions: player = db.session.get(User, session.player_id) await self.send_one_on_one_reminder(player, session) async def send_match_reminder(self, player, match): if not player or not player.discord_user_id: return embed = { 'embeds': [ { 'title': 'ā° Match Tomorrow!', 'color': 15844367, 'description': f'**{match.title}**\nšŸ“… {match.date.strftime("%A, %B %d, %Y")}\nā° {match.start_time.strftime("%I:%M %p") if match.start_time else "TBD"}', } ] } await self._send_dm( player.discord_user_id, embed, purpose='match_reminder', recipient=player.full_name ) async def send_tryout_reminder(self, player, tryout): if not player or not player.discord_user_id: return embed = { 'embeds': [ { 'title': 'ā° Tryout Tomorrow!', 'color': 15844367, 'description': f'**{tryout.name}**\nšŸ“… {tryout.date.strftime("%A, %B %d, %Y")}\nā° {tryout.time.strftime("%I:%M %p") if tryout.time else "TBD"}', } ] } await self._send_dm( player.discord_user_id, embed, purpose='tryout_reminder', recipient=player.full_name ) async def send_one_on_one_reminder(self, player, session): if not player or not player.discord_user_id: return embed = { 'embeds': [ { 'title': 'ā° One on One Tomorrow!', 'color': 15844367, 'description': f'šŸ“… {session.date.strftime("%A, %B %d, %Y")}\nā° {session.start_time.strftime("%I:%M %p") if session.start_time else "TBD"}', } ] } await self._send_dm( player.discord_user_id, embed, purpose='one_on_one_reminder', recipient=player.full_name ) # ----------------------------------------------------------------- # Legacy alias # ----------------------------------------------------------------- async def notify_player_about_one_on_one_direct( self, player, coach, coach_name, coach_discord_id, date_str, start_time, end_time, points ): embed = { 'embeds': [ { 'title': 'One on One Scheduled', 'color': 3447003, 'fields': [ {'name': 'Coach', 'value': coach_name, 'inline': True}, {'name': 'Date', 'value': date_str, 'inline': True}, {'name': 'Time', 'value': f'{start_time} - {end_time}', 'inline': True}, {'name': 'Discussion Points', 'value': points or 'None', 'inline': False}, ], } ] } await self._send_dm( player.discord_user_id, embed, purpose='one_on_one_direct', recipient=player.full_name ) # ====================================================================== # Module-level API # ====================================================================== def get_bot(flask_app=None): global bot_instance, bot_flask_app if flask_app is not None: bot_flask_app = flask_app if bot_instance is None: bot_instance = TeamTryoutsBot(flask_app=flask_app or bot_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 supervise_bot(run_once=False): delay = BOT_RESTART_DELAY_SECONDS while True: bot = get_bot() try: bot.run(DISCORD_BOT_TOKEN, reconnect=True) except LoginFailure: _logger.critical('Discord bot token is invalid. Bot will not start.') return except PrivilegedIntentsRequired as exc: _logger.critical('Missing privileged intent: %s. Bot will not start.', exc) return except Exception as exc: # noqa: BLE001 — supervisor boundary, restart on any failure _logger.error('Discord bot stopped unexpectedly: %s', exc) if run_once: _logger.info('Bot run_once completed.') return bot_age = time.time() - getattr(bot, '_started_at', time.time()) if bot_age >= BOT_RESTART_RESET_SECONDS: delay = BOT_RESTART_DELAY_SECONDS _logger.warning('Discord bot disconnected. Restarting in %s seconds.', delay) if _stop_bot.wait(delay): _logger.info('Discord bot supervisor asked to stop.') return delay = min(delay * 2, BOT_RESTART_MAX_DELAY_SECONDS) def start_bot(flask_app=None): global bot_thread, bot_flask_app if flask_app is not None: bot_flask_app = flask_app get_bot(flask_app=flask_app) if DISCORD_BOT_TOKEN and bot_thread is None: _stop_bot.clear() bot_thread = threading.Thread(target=supervise_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') def bot_status(): running = bool(bot_thread and bot_thread.is_alive()) instance = bot_instance connected = bool(instance and not instance.is_closed() and instance.is_ready()) return { 'configured': bool(DISCORD_BOT_TOKEN), 'running': running, 'connected': connected, 'pending': len(instance.pending_requests) if instance else 0, 'reminders_scheduled': bool(instance and instance.reminders_scheduled), }