Files
team-tryouts/app/discord_bot.py
GGThed e15b3c1293
CI - Security, Lint & Tests / validate (push) Failing after 16m22s
fix(audit): centraliser l'horloge UTC
2026-08-17 15:21:08 -04:00

1587 lines
63 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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. Tomorrows 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),
}