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