"""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 Three families of failure, and what each one does (ARCH-008 / QUA-004) ---------------------------------------------------------------------- Every handler in this file used to wrap its whole body in one `try/except Exception` that logged and carried on. A database refusal, a closed inbox and a typo in this module all produced the same line, and the person who clicked the reaction was told nothing either way. The handlers now separate three cases, because they need three different answers: 1. **The database refused the write** — `SQLAlchemyError`. The session is rolled back, the pending entry is kept so the same reaction can be tried again, and the person is told that nothing was recorded. Silence here was the worst of the three: a confirmation that failed to commit looked exactly like a bot that was not running. 2. **Discord could not be reached** — `HTTPException` and its subclasses (`Forbidden`, `NotFound`). Expected, per recipient, and already named one by one in `_send_dm`. After a commit these are best-effort: the decision is recorded whether or not the message got through, and undoing it because a DM bounced would be worse than the bounce. 3. **Anything else is a defect** — it propagates. `on_error` records it with its traceback, under this application's logger rather than discord.py's. A defect that reads like a delivery failure never gets fixed. Four catches are still broad, and all four are boundaries with nothing above them to catch anything: the queue loop, the APScheduler job, the thread the bot runs in, and the hand-off from a Flask request. Each says on its line why. Ruff's `BLE` rule is enabled (QUA-004), and what it enforces is not "never catch broadly" — it is satisfied by logging the traceback. Which is the whole discipline: a boundary may swallow anything, provided it leaves behind enough to tell a defect from an outage. """ import asyncio import json import logging import os import tempfile import threading import time from datetime import datetime, timedelta from queue import Empty, Queue from zoneinfo import ZoneInfo from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger from discord import ( Forbidden, HTTPException, Intents, LoginFailure, NotFound, PrivilegedIntentsRequired, ) from discord.ext import commands from dotenv import load_dotenv from sqlalchemy.exc import SQLAlchemyError from app.time_utils import utc_now_naive 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 #: Entries older than this are dropped when the file is loaded: a message #: nobody reacted to in a month will not be reacted to (OPS-007). PENDING_MAX_AGE_DAYS = 30 #: How late the daily reminder may still be sent. Long enough to survive a #: restart or a slow start-up, short of the next day's occurrence. REMINDER_GRACE_SECONDS = 3600 #: How many Discord user objects to keep resolved (PERF-005). #: #: A direct message costs two sequential API calls: resolve the snowflake to #: a user, then send. The first is identical every time for the same person, #: and the bot writes to the same few dozen people over and over — a season #: of matches, then a reminder every evening at 18:00. #: #: What this does not fix: the first notification of a twenty-player match #: still resolves twenty distinct users. The saving is across notifications, #: not within one. Cutting the second call would need Discord's bulk DM #: endpoints, which do not exist. #: #: Bounded because the process is long-lived. Eviction is oldest-first on the #: insertion order of the dict, close enough to least-recently-used for a #: roster that fits several times over. USER_CACHE_MAX = 512 CHECK_EMOJI = '✅' # Green checkmark CROSS_EMOJI = '❌' # Red X #: First wait before restarting a bot that stopped (OPS-004). Short, because #: the common case is a brief network blip. BOT_RESTART_DELAY_SECONDS = 5 #: Ceiling on that wait. Five minutes is late enough not to hammer Discord #: through a long outage, early enough that nobody plans an evening around #: the reminder never arriving. BOT_RESTART_MAX_DELAY_SECONDS = 300 #: A connection that lasted this long counts as healthy, and resets the #: back-off. Without it, a bot that ran for a month and then dropped would #: wait five minutes before its first retry, on the strength of an incident #: that is long over. BOT_RESTART_RESET_SECONDS = 600 #: Notifications waiting for the bot thread. #: #: Module level, not per-instance: the supervisor replaces the bot object on #: every restart, because discord.py cannot reuse a client whose run() has #: returned. A queue living on the old object would be discarded with it, #: taking every pending notification along. message_queue = Queue() #: Set to ask the supervisor to stop waiting and return. Only tests set it; #: in production the thread is a daemon and the process exit ends it. _stop_bot = threading.Event() 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 # The shared module-level queue, not a new one: a restart builds a # fresh bot, and notifications queued against the old one must not # go with it (OPS-004). self.message_queue = message_queue self.scheduler = AsyncIOScheduler() self.timezone = ZoneInfo('America/Toronto') # EDT timezone self._user_cache = {} # Discord snowflake -> User, bounded (PERF-005) #: Whether the 18:00 reminder job is actually scheduled. Starts False #: and is only set by start_scheduler succeeding, so /health reports #: 'not yet' rather than guessing (ARCH-008). self.reminders_scheduled = False def _load_pending(self): """Load pending requests from the JSON file. A corrupt file used to be logged at error level and then silently replaced by an empty dict: every in-flight Discord reaction stopped having any effect, and nothing said so (OPS-006). The file is now moved aside instead of overwritten, so the mapping can be recovered by hand, and the message says what was lost. Entries older than PENDING_MAX_AGE_DAYS are dropped here rather than kept forever (OPS-007). A message nobody reacted to in a month is not going to be reacted to. """ if not os.path.exists(PENDING_FILE): logger.info('No pending requests file found, starting fresh.') return try: with open(PENDING_FILE, encoding='utf-8') as handle: data = json.load(handle) if not isinstance(data, dict): raise ValueError(f'expected an object, found {type(data).__name__}') # Keys are Discord message ids; JSON turns them into strings. loaded = {int(key): value for key, value in data.items()} except (OSError, ValueError) as exc: quarantine = f'{PENDING_FILE}.corrupt-{int(time.time())}' try: os.replace(PENDING_FILE, quarantine) except OSError: quarantine = '(could not be moved aside)' logger.error( 'Pending requests file unreadable (%s). Moved to %s. Reactions on ' 'messages already sent will no longer be recognised until those ' 'requests are answered in the web interface.', exc, quarantine, ) return kept, expired = self._drop_expired(loaded) self.pending_requests = kept logger.info( 'Loaded %d pending requests from %s (%d expired and dropped)', len(kept), PENDING_FILE, expired, ) if expired: self._save_pending() @staticmethod def _drop_expired(entries): """Split loaded entries into the ones still worth keeping. Args: entries: message_id → record. Returns: tuple[dict, int]: Entries to keep, and how many were dropped. A record without a timestamp predates this field and is kept — dropping it would be guessing. """ cutoff = time.time() - PENDING_MAX_AGE_DAYS * 86400 kept = {} expired = 0 for message_id, record in entries.items(): created = record.get('created_at') if isinstance(record, dict) else None if created is not None and created < cutoff: expired += 1 continue kept[message_id] = record return kept, expired def _save_pending(self): """Write the pending requests to disk, atomically. The previous version opened the destination for writing and then serialised into it: an interruption anywhere in between left a truncated JSON file, which the loader could not read. Writing to a neighbouring temporary file and renaming means the destination is either the old content or the new one, never half of either. """ directory = os.path.dirname(PENDING_FILE) or '.' try: with tempfile.NamedTemporaryFile( 'w', encoding='utf-8', dir=directory, prefix='.pending-', delete=False ) as handle: json.dump(self.pending_requests, handle, indent=2) handle.flush() os.fsync(handle.fileno()) temporary = handle.name os.replace(temporary, PENDING_FILE) except OSError as exc: logger.error('Could not save pending requests: %s', exc) 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_error(self, event_method, *args, **kwargs): """Record an exception that escaped an event handler. This is the third family: not a database refusal, not Discord being unreachable — a defect. discord.py's own handler logs it on the `discord` logger, which `configure_logging` does not wire to any of this application's files: it attaches handlers to the `app` package (logging_config.py). So an unexpected failure inside a reaction handler went to a logger nobody reads, and the reaction looked ignored. Deliberately says nothing to the user. At this point what did and did not happen is exactly what is unknown, and a reassuring message would be a guess. """ logger.exception( 'Unhandled exception in %s. This is a defect, not a delivery failure: ' 'expected Discord and database errors are handled where they occur.', event_method, ) 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 AttributeError: 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. `coalesce` and `misfire_grace_time` are the two settings this job needs and did not have (OPS-005). Without a grace time, a restart at 18:05 dropped the day's reminders with no trace; without coalescing, a scheduler that wakes up behind schedule fires once per missed run, so players receive the same reminder several times. The grace period is deliberately short of the next occurrence: a reminder for tonight is worth sending an hour late, not tomorrow. A failure here is permanent and total — no reminder is ever sent again — and used to leave one line in a log. It is now visible from outside, in /health, because that is the difference between finding out at start-up and finding out when a player asks why the reminders stopped (ARCH-008). """ try: self.scheduler.add_job( self.send_daily_reminders, trigger=CronTrigger(hour=18, minute=0, timezone=self.timezone), id='daily_reminders', replace_existing=True, coalesce=True, misfire_grace_time=REMINDER_GRACE_SECONDS, max_instances=1, ) self.scheduler.start() except Exception: # a scheduler that will not start must not take on_ready with it self.reminders_scheduled = False logger.exception( 'Daily reminder scheduler could not be started. No reminder will be ' 'sent until the process is restarted; /health reports this.' ) return self.reminders_scheduled = True logger.info('Daily reminder scheduler started (18:00 EDT)') async def _reply(self, channel, message): """Answer the person who reacted, and never let that answer be fatal. Returns True when it went out. A reply is the only feedback a reaction gets, so a failed one is worth a line — but it must not undo a state change that has already been committed. """ try: await channel.send(message) except HTTPException as exc: logger.error('Could not answer a reaction on Discord: %s', exc) return False return True async def _commit_or_report(self, channel, *, operation): """Make the pending change durable, or say plainly that it is not. Returns True when the write landed. On a database refusal the session is rolled back, the pending entry is left in place so the same reaction can be tried again, and the person is told that nothing was recorded — previously the failure was logged and the coroutine returned, which is indistinguishable from the bot being down. Anything that is not a database error propagates: it is a defect, and on_error records it as one. """ from app.extensions import db try: db.session.commit() except SQLAlchemyError: db.session.rollback() logger.exception('%s was not recorded: the database refused the write', operation) await self._reply( channel, "⚠️ Nothing was recorded — the database refused the change. " "Please answer from the web interface instead.", ) return False return True def _forget_pending(self, message_id): """Drop a message from the pending map, once its answer is durable. `pop` rather than `del`: two reactions on the same message race through two coroutines, and the loser raised KeyError inside the old blanket handler, where it read as an error with no consequence. """ if self.pending_requests.pop(message_id, None) is not None: self._save_pending() async def _resolve_user(self, discord_uid, *, context=''): """Return the Discord user behind a snowflake, or None. Tries three sources in order of cost: the library's own cache, which is free but mostly empty since the members intent was dropped (OPS-014); ours, which survives across notifications; then the API. A failure here is logged once, saying which of the two reasons it was — a snowflake nobody owns is a data problem to fix in the account, an HTTP error is Discord being Discord (PERF-005). """ try: uid = int(discord_uid) except (TypeError, ValueError): logger.warning('Invalid Discord user id %r%s', discord_uid, context) return None user = self.get_user(uid) or self._user_cache.get(uid) if user is not None: return user try: user = await self.fetch_user(uid) except NotFound: logger.warning( 'Discord user %s does not exist%s. The id stored on the account is ' 'wrong or the account was deleted; nothing will ever be delivered ' 'to it.', uid, context, ) return None except HTTPException as exc: logger.warning('Could not resolve Discord user %s%s: %s', uid, context, exc) return None if user is None: return None self._user_cache[uid] = user while len(self._user_cache) > USER_CACHE_MAX: self._user_cache.pop(next(iter(self._user_cache))) return user async def _send_dm(self, discord_uid, message, *, purpose, recipient=''): """Send one direct message and record what became of it. Returns the sent Message, or None. Every path logs exactly once, with the recipient and the purpose, so that nineteen deliveries out of twenty read as nineteen successes and one named failure instead of looking like twenty (PERF-005). The three failures are not the same problem and must not read alike: `blocked` is permanent until the person reopens their DMs and no retry will change it, `failed` is transient, `unreachable` means the account itself could not be resolved. """ context = f' ({purpose}{", " + recipient if recipient else ""})' user = await self._resolve_user(discord_uid, context=context) if user is None: logger.warning('Notification not delivered — %s: recipient unreachable', purpose) return None who = recipient or getattr(user, 'name', str(discord_uid)) try: sent = await user.send(message) except Forbidden: logger.warning( 'Notification not delivered — %s to %s: their direct messages are ' 'closed to this bot. Retrying will not help; they have to allow ' 'DMs from server members.', purpose, who, ) return None except HTTPException as exc: logger.error('Notification not delivered — %s to %s: %s', purpose, who, exc) return None logger.info('Delivered %s to %s (message_id=%s)', purpose, who, sent.id) return sent async def _dispatch(self, item): """Send one queued notification, inside an app context where needed.""" kind = item.get('type') if kind == 'one_on_one_request': await self._send_one_on_one_dm(**item['data']) elif kind == '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 kind == 'one_on_one_response': await self._send_one_on_one_response_dm(**item['data']) else: logger.error('Unknown queue item type %r; the notification was dropped', kind) async def process_queue(self): """Drain the queue fed by the Flask threads, for the life of the bot. The loop is the last thing standing between one bad item and every subsequent notification, so it catches everything. What reaches it is narrower than it used to be: `_send_dm` already reports a closed inbox or an outage per recipient and returns None, so anything arriving here got past the typed handling and is a defect. It reads as one. """ while True: try: item = self.message_queue.get_nowait() except Empty: await asyncio.sleep(0.5) continue try: await self._dispatch(item) except Exception: # the loop must outlive a single bad item logger.exception( 'Unexpected failure while sending a %r notification. It is lost; ' 'delivery failures that are merely expected do not reach here.', item.get('type'), ) 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 HTTPException as exc: # Silent before. The reaction is now unanswerable — there is no # channel to answer in — so the log line is the only trace there # will ever be that someone clicked and nothing happened. logger.warning( 'Could not open the channel of a reaction on message %s: %s. ' 'The reaction was not acted on.', payload.message_id, exc, ) return if not self.is_dm_channel(channel): return # Fetch the user who reacted user = await self._resolve_user(payload.user_id, context=' (reaction handler)') 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.""" 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 self._send_dm( coach_discord_id, message, purpose='one-on-one request', recipient=coach_name, ) if msg is None: return None try: await msg.add_reaction(CHECK_EMOJI) await msg.add_reaction(CROSS_EMOJI) except HTTPException as exc: # The message is out; without reactions the coach cannot answer # from Discord, but the web interface still works. logger.error( 'One on One request %s reached %s without its reactions (%s). It can ' 'only be answered from the web interface.', request_id, coach_name, exc, ) return None # Track this pending request self.pending_requests[msg.id] = { 'type': 'one_on_one', 'id': request_id, 'created_at': time.time(), } self._save_pending() return msg.id 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. """ # Look up the DB user to get their Discord user ID from app.extensions import db from app.models import User as DBUser db_user = db.session.get(DBUser, 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 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 self._send_dm( db_user.discord_user_id, message, purpose=f'{event_type} schedule notification', recipient=db_user.username, ) if msg is None: return None try: await msg.add_reaction(CHECK_EMOJI) await msg.add_reaction(CROSS_EMOJI) except HTTPException as exc: # Without the reactions the player has no way to answer: the # message asks them to click something that is not there. logger.error( 'Schedule notification reached %s without its reactions (%s). They ' 'cannot confirm attendance from Discord.', db_user.username, exc, ) return None # Track this pending request self.pending_requests[msg.id] = { 'type': 'schedule_addition', 'id': reference_id, 'event_type': event_type, 'created_at': time.time(), } self._save_pending() return msg.id async def handle_one_on_one_approve(self, coach, message_id, request_id, channel): """Record a coach's approval, then tell the coach and the player. Four steps, and they do not fail alike: read the request and stage the change (database), commit it (database, and the only step whose failure means nothing happened), answer the coach (Discord), tell the player (Discord). Everything after the commit is best-effort by definition — the decision is recorded, and a DM that bounces does not un-record it. """ from app.extensions import db from app.models import OneOnOneRequest try: request = db.session.get(OneOnOneRequest, request_id) if not request: # The row is gone; no reaction on this message can ever mean # anything again. Keeping the mapping is what PENDING_MAX_AGE_DAYS # was invented to bound. logger.info( 'One on One request %s no longer exists; its pending message was dropped.', request_id, ) self._forget_pending(message_id) return if request.coach.discord_user_id != str(coach.id): await self._reply(channel, "⚠️ 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 = utc_now_naive() except SQLAlchemyError: db.session.rollback() logger.exception('Could not read One on One request %s to approve it', request_id) await self._reply( channel, "⚠️ Nothing was recorded — the request could not be read. " "Please answer from the web interface instead.", ) return if not await self._commit_or_report( channel, operation=f'Approval of One on One request {request_id}' ): return # Durable from here. Order matters: forget the pending entry before # the two messages, so that a bounced DM cannot leave a request that # is already approved answerable a second time. self._forget_pending(message_id) await self._reply( channel, f"✅ You have **approved** the One on One session with {player_full_name}." ) 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, ) async def handle_one_on_one_reject(self, coach, message_id, request_id, channel): """Record a coach's refusal, with the reason they replied, if any. Same four steps as the approval, plus a fifth that fails on its own terms: reading back the coach's reply to find a refusal note. That one is optional by construction — a refusal without a note is a refusal — so it is caught where it happens and the rest continues. """ from app.extensions import db from app.models import OneOnOneRequest try: request = db.session.get(OneOnOneRequest, request_id) if not request: logger.info( 'One on One request %s no longer exists; its pending message was dropped.', request_id, ) self._forget_pending(message_id) return if request.coach.discord_user_id != str(coach.id): await self._reply(channel, "⚠️ 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 except SQLAlchemyError: db.session.rollback() logger.exception('Could not read One on One request %s to reject it', request_id) await self._reply( channel, "⚠️ Nothing was recorded — the request could not be read. " "Please answer from the web interface instead.", ) return 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 HTTPException as exc: logger.warning( 'Could not read the channel history for a refusal note on request %s: %s. ' 'The refusal is still recorded, without a reason.', request_id, exc, ) try: request.status = 'rejected' request.responded_at = utc_now_naive() if refusal_note: request.coach_rejection_message = refusal_note except SQLAlchemyError: db.session.rollback() logger.exception('Could not stage the refusal of One on One request %s', request_id) await self._reply(channel, "⚠️ Nothing was recorded. Please use the web interface.") return if not await self._commit_or_report( channel, operation=f'Refusal of One on One request {request_id}' ): return self._forget_pending(message_id) 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 self._reply(channel, 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, ) @staticmethod def _attendance_record(event_type, reference_id): """The row an attendance reaction refers to, and whose it is. Args: event_type: 'match' or 'tryout'. reference_id: Primary key of the participation row. Returns: tuple: (row, player_id) — either may be None. """ from app.extensions import db from app.models import MatchParticipant, TryoutRegistration if event_type == 'match': row = db.session.get(MatchParticipant, reference_id) elif event_type == 'tryout': row = db.session.get(TryoutRegistration, reference_id) else: row = None return row, getattr(row, 'player_id', None) @staticmethod def _reacting_user_owns(player_id, reacting_user): """Whether the Discord account that reacted is the row's owner. handle_one_on_one_approve and _reject have always compared the reacting account against the coach the request was sent to. The two attendance handlers did not (OPS-009) — same message shape, same threat, one of them checked. The asymmetry was the bug. """ from app.extensions import db from app.models import User if not player_id: return False owner = db.session.get(User, player_id) return bool(owner and owner.discord_user_id == str(reacting_user.id)) async def handle_attendance_confirm(self, player, message_id, reference_id, channel): """Record a player confirming attendance for a match or tryout. This is the handler the audit used to describe ARCH-008. The player was told "✅ Your attendance has been confirmed!" from inside the same blanket handler that swallowed the commit — so a database refusal produced no message at all, and the reaction was indistinguishable from a bot that had stopped running. The commit and the message are now separate steps with separate outcomes. """ from app.extensions import db request_info = self.pending_requests.get(message_id) if request_info is None: # Two awaits happened since on_raw_reaction_add checked; the # other reaction on the same message got here first. return event_type = request_info.get('event_type') try: row, player_id = self._attendance_record(event_type, reference_id) if row is not None and not self._reacting_user_owns(player_id, player): await self._reply(channel, "⚠️ You are not the intended recipient.") return if row is not None: row = db.session.merge(row) if event_type == 'match': row.attendance_confirmed = True else: # TryoutRegistration has no attendance_confirmed column # (DB-008, waiting on Alembic). Assigning here sets a # Python attribute that is never written, so the player # was told "confirmed" and nothing was recorded. Still # true — but no longer silent. logger.warning( 'Tryout attendance confirmation from user_id=%s for ' 'registration %s was not persisted: TryoutRegistration ' 'has no attendance_confirmed column (DB-008).', player.id, reference_id, ) except SQLAlchemyError: db.session.rollback() logger.exception( 'Could not read the %s participation row %s to confirm attendance', event_type, reference_id, ) await self._reply( channel, "⚠️ Nothing was recorded. Please confirm from the web interface instead.", ) return if not await self._commit_or_report( channel, operation=f'Attendance confirmation for {event_type} row {reference_id}' ): return self._forget_pending(message_id) await self._reply(channel, "✅ Your attendance has been confirmed!") async def handle_attendance_decline(self, player, message_id, reference_id, channel): """Record a player declining attendance for a match or tryout. The mirror of handle_attendance_confirm, and the same reasoning: a declined match deletes a participation row, which is exactly the kind of write a foreign key can refuse. The player now hears about it. """ from app.extensions import db request_info = self.pending_requests.get(message_id) if request_info is None: return event_type = request_info.get('event_type') try: row, player_id = self._attendance_record(event_type, reference_id) if row is not None and not self._reacting_user_owns(player_id, player): await self._reply(channel, "⚠️ You are not the intended recipient.") return if row is not None: row = db.session.merge(row) if event_type == 'match': db.session.delete(row) else: row.status = 'no_show' except SQLAlchemyError: db.session.rollback() logger.exception( 'Could not read the %s participation row %s to decline attendance', event_type, reference_id, ) await self._reply( channel, "⚠️ Nothing was recorded. Please answer from the web interface instead.", ) return if not await self._commit_or_report( channel, operation=f'Attendance refusal for {event_type} row {reference_id}' ): return self._forget_pending(message_id) await self._reply(channel, "❌ Your attendance has been declined.") 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. Catches nothing of its own. Every caller reaches this after the coach's answer is committed, so a failure here costs the player a message and nothing else — and if it is a defect (a request whose date is None, say) it must be seen as one, in on_error, rather than flattened into "Error in direct One on One notification". """ if not player_discord_id: logger.warning('Player has no Discord user ID for request %s', request.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!" ) elif 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 self._send_dm( player_discord_id, message, purpose=f'one-on-one response (request {request.id})', recipient=player_full_name, ) async def send_daily_reminders(self): """Run the 18:00 batch. The APScheduler job boundary. Broad on purpose: nothing above this is ours. An exception escaping an APScheduler job is logged by the library and the job stays scheduled, but the line it writes says nothing about what this batch was doing, and it lands on a logger this application does not collect. """ 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: # a failed batch must not unschedule tomorrow's logger.exception('The daily reminder batch failed. Tomorrow’s run is unaffected.') async def _send_daily_reminders_impl(self): """Internal implementation of daily reminders with proper app context. Only the database is caught here. A batch that cannot read the schedule has nothing to send and is worth one clear line; a batch that fails for any other reason is a defect and belongs in the traceback that send_daily_reminders writes. """ from sqlalchemy.orm import joinedload from app.models import ( Match, MatchParticipant, OneOnOneRequest, Tryout, TryoutRegistration, ) now = datetime.now(self.timezone) tomorrow = now.date() + timedelta(days=1) # Counted so that a partial run is visible as such. Per-recipient # failures are logged by _send_dm; without this total, a batch in # which half the reminders bounced looked exactly like one where # they all went out (PERF-005). attempted = delivered = 0 try: # 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: attempted += 1 delivered += 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: attempted += 1 delivered += 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: attempted += 1 delivered += await self.send_one_on_one_reminder(session.player, session) except SQLAlchemyError: db_reached = f'{delivered} of {attempted} sent before it stopped' logger.exception( 'The daily reminder batch for %s could not read the schedule (%s)', tomorrow, db_reached, ) return if attempted and delivered < attempted: logger.warning( 'Daily reminders for %s: %d of %d delivered, %d failed. See the ' 'lines above for who and why.', tomorrow, delivered, attempted, attempted - delivered, ) else: logger.info('Daily reminders for %s: %d delivered', tomorrow, delivered) async def send_match_reminder(self, player, match): """Send match reminder to player. True if it was delivered.""" 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." ) sent = await self._send_dm( player.discord_user_id, message, purpose='match reminder', recipient=player.username, ) return sent is not None async def send_tryout_reminder(self, player, tryout): """Send tryout reminder to player. True if it was delivered.""" 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." ) sent = await self._send_dm( player.discord_user_id, message, purpose='tryout reminder', recipient=player.username, ) return sent is not None 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. Every argument is a string prepared by the caller, and _send_dm already handles a closed inbox and an outage. There is nothing left for a blanket catch to protect against, so anything that goes wrong here is a defect and process_queue records it as one. """ if not player_discord_id: logger.warning("Cannot send response DM: no 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!" ) elif 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." ) sent = await self._send_dm( player_discord_id, message, purpose=f'one-on-one response ({"approved" if approved else "declined"})', recipient=player_full_name, ) return sent is not None async def send_one_on_one_reminder(self, player, session): """Send One on One reminder to player. True if it was delivered.""" 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!" ) sent = await self._send_dm( player.discord_user_id, message, purpose='one-on-one reminder', recipient=player.username, ) return sent is not None # Global bot instance bot_instance = None bot_thread = None #: The application the bot binds its database work to. Held separately from #: the instance because the supervisor discards the instance on restart and #: has to build the next one with the same application (OPS-004). bot_flask_app = None def get_bot(flask_app=None): """Get or create the bot instance. Falls back to the application the supervisor was started with, so that a bot rebuilt after a disconnection still reaches the database — the caller that has the application is `create_app`, and it only calls this once (OPS-004). """ 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 _enqueue(kind: str, data: dict, *, description: str) -> bool: """Hand one notification to the bot thread. Never raises. The three public senders below were the same fifteen lines three times, each with its own blanket catch. There is one now, and it is the only place in this module where breadth is a property of the caller rather than of the failure: this runs in a Flask request thread, and the work the notification announces is already committed. The meeting is booked whether or not Discord hears about it, so a failure here must not become a 500 on a page whose job is done. What it is not is somewhere to lose a defect: the traceback is logged and the caller is told False. """ try: get_bot().message_queue.put({'type': kind, 'data': data}) except Exception: # a notification must never fail the request that caused it logger.exception('Could not queue %s', description) return False return True 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.""" return _enqueue( 'one_on_one_request', { '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, }, description=f'the One on One request DM for request {request_id}', ) 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.""" return _enqueue( 'schedule_addition', { 'user_id': user_id, 'event_type': event_type, 'event_title': event_title, 'event_date': event_date, 'event_time': event_time, 'reference_id': reference_id, }, description=f'the {event_type} notification for user {user_id}', ) 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. """ return _enqueue( 'one_on_one_response', { '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, }, description=f'the One on One response DM for {player_full_name}', ) def supervise_bot(run_once=False): """Keep a Discord bot running, or say clearly why one will not be. `bot.run()` returning means the connection is gone for good — discord.py reconnects on its own for anything recoverable. What happened next was nothing: the thread ended, `bot_thread` stayed non-None so `start_bot` would never start another, and the web application went on serving pages while every notification and every daily reminder had stopped. Nothing reported it; the outage could last weeks (OPS-004). Two kinds of ending are told apart, because retrying helps with exactly one of them. A rejected token or a missing privileged intent is a configuration error: retrying it accomplishes nothing except hammering Discord's login endpoint, which is how an application gets rate-limited or banned outright. Everything else is treated as an outage and retried with an exponential back-off, capped, for as long as the process lives. The back-off resets after a connection that lasted. Otherwise a bot that runs happily for a month and then drops would wait five minutes before its first retry, having "learned" from an incident that is long over. Args: run_once: Stop after a single attempt. For tests — the production caller never sets it. """ global bot_instance delay = BOT_RESTART_DELAY_SECONDS while True: # A fresh instance every time, deliberately. discord.py closes the # client when run() returns, and a closed Client will not log in # again — reusing it is how a "restart" turns into a thread that # spins on an exception. This is also why message_queue lives at # module level: a queue on the old object would take every pending # notification with it. bot = get_bot(flask_app=bot_flask_app) started = time.monotonic() try: bot.run(DISCORD_BOT_TOKEN) except (LoginFailure, PrivilegedIntentsRequired) as exc: logger.error( 'The Discord bot cannot log in (%s). This is a configuration error, ' 'not an outage: retrying would only hammer the login endpoint. No ' 'notification will be sent until the token or the intents are fixed ' 'and the process is restarted.', type(exc).__name__, ) return except Exception: # top of a thread; there is nothing above to catch it logger.exception('The Discord bot stopped on an unhandled error') else: logger.error('The Discord bot disconnected and did not recover on its own') bot_instance = None if time.monotonic() - started >= BOT_RESTART_RESET_SECONDS: delay = BOT_RESTART_DELAY_SECONDS if run_once: return logger.warning( 'Restarting the Discord bot in %ds. Notifications are not being sent ' 'until it reconnects.', 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): """Start the supervised Discord bot in a background thread.""" 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(): """What the bot is doing, for /health (OPS-012). The bot runs in a daemon thread inside the web process. When that thread dies — a revoked token, a fatal gateway error — the web application keeps serving pages and no notification is ever sent again. Nothing reported it, so nobody found out until someone asked why they had stopped receiving reminders. `reminders_scheduled` is the second silent-death case (ARCH-008): the thread can be alive and the gateway connected while the 18:00 job never got scheduled, in which case nothing is ever sent at 18:00 and the other three keys all read green. Returns: dict: `configured` (a token is set), `running` (the thread is alive), `connected` (the gateway session is up), `pending` (reaction mappings held in memory) and `reminders_scheduled`. """ 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), }