Chaque gestionnaire de discord_bot.py enveloppait tout son corps dans un except Exception qui journalisait et poursuivait. Un refus de la base, une boite de reception fermee et une faute de frappe dans ce module produisaient la meme ligne, et la personne qui avait clique n'apprenait rien dans aucun des trois cas. Le defaut que l'audit citait en exemple : dans handle_attendance_confirm, le commit et le message "Your attendance has been confirmed!" etaient dans le meme bloc protege. Si le commit levait, rien n'etait envoye et rien n'etait signale. La reaction devenait indiscernable d'un bot arrete. Trois familles, trois reponses. La base refuse : rollback, l'entree pending est conservee pour que la reaction reste reessayable, et la personne est prevenue que rien n'a ete enregistre. Discord est injoignable : apres un commit c'est du meilleur effort, un DM qui rebondit ne defait pas une decision prise. Tout le reste est un defaut et remonte, jusqu'a on_error, qui est ajoute parce que discord.py journalise sur le logger 'discord' que configure_logging ne collecte pas. Trouve en appliquant : deux reactions sur le meme message passent toutes deux le test d'appartenance puis s'attendent sur deux await, et la perdante levait un KeyError qui se lisait comme une erreur sans consequence ; un fetch_channel en echec renvoyait sans un mot, donc un clic sans effet et sans trace ; un start_scheduler en echec supprime tous les rappels a jamais et laissait trois cles de /health au vert, d'ou reminders_scheduled. L'ordre des etapes apres le commit est desormais fixe : oublier l'entree pending avant les messages, sinon un DM rebondi laisse une demande deja approuvee reactivable une seconde fois. Les tests ont ete verifies par mutation du code de production. La deuxieme mutation a trouve une faiblesse dans le test lui-meme, qui ne regardait que le premier message emis. QUA-004 (roadmap) / ARCH-008 (constats). Co-Authored-By: Claude Opus 5 <[email protected]>
1468 lines
58 KiB
Python
1468 lines
58 KiB
Python
"""Unified Discord bot for Team Tryouts notifications.
|
||
|
||
This module provides a persistent bot that handles:
|
||
- One on One request approvals/rejections via reactions
|
||
- Match/tryout schedule addition notifications with attendance confirmation
|
||
- Daily reminders at 18:00 EDT for upcoming events
|
||
|
||
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, NotFound
|
||
from discord.ext import commands
|
||
from dotenv import load_dotenv
|
||
from sqlalchemy.exc import SQLAlchemyError
|
||
|
||
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
|
||
|
||
|
||
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
|
||
self.message_queue = Queue() # Thread-safe queue for messages from Flask
|
||
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.models import User as DBUser
|
||
|
||
db_user = DBUser.query.get(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 = OneOnOneRequest.query.get(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 = datetime.utcnow()
|
||
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 = OneOnOneRequest.query.get(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 = datetime.utcnow()
|
||
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.models import MatchParticipant, TryoutRegistration
|
||
|
||
if event_type == 'match':
|
||
row = MatchParticipant.query.get(reference_id)
|
||
elif event_type == 'tryout':
|
||
row = TryoutRegistration.query.get(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.models import User
|
||
|
||
if not player_id:
|
||
return False
|
||
owner = User.query.get(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
|
||
|
||
|
||
def get_bot(flask_app=None):
|
||
"""Get or create the bot instance."""
|
||
global bot_instance
|
||
if bot_instance is None:
|
||
bot_instance = TeamTryoutsBot(flask_app=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 start_bot(flask_app=None):
|
||
"""Start the Discord bot in the background."""
|
||
global bot_thread
|
||
|
||
bot = get_bot(flask_app=flask_app)
|
||
if DISCORD_BOT_TOKEN and bot_thread is None:
|
||
|
||
def run_bot():
|
||
try:
|
||
bot.run(DISCORD_BOT_TOKEN)
|
||
except Exception: # top of a thread; there is nothing above to catch it
|
||
logger.exception('The Discord bot thread stopped on an unhandled error')
|
||
finally:
|
||
# bot.run() returning means the connection is gone for good:
|
||
# discord.py reconnects on its own for anything recoverable.
|
||
# Recording it is what makes bot_status() able to say 'stopped'
|
||
# instead of reporting a dead thread as running.
|
||
logger.error('Discord bot loop exited; notifications are no longer being sent')
|
||
|
||
bot_thread = threading.Thread(target=run_bot, daemon=True)
|
||
bot_thread.start()
|
||
logger.info("TeamTryoutsBot started in background thread")
|
||
elif not DISCORD_BOT_TOKEN:
|
||
logger.warning("DISCORD_BOT_TOKEN not set, bot not started")
|
||
|
||
|
||
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),
|
||
}
|