PERF-006. Le bloc location /static/ etait commente : 59 Ko de CSS et de JS
passaient par Waitress a chaque page. L activer tel quel aurait ete une
regression : ces URL ne changent jamais, donc un cache de 30 jours sert une
feuille de style vieille d un mois apres chaque deploiement, sans moyen de
l invalider. url_for('static') estampille maintenant chaque URL du mtime du
fichier ; c est ce qui rend le immutable vrai et pas seulement rapide.
Deux pieges nginx consignes dans le fichier : un add_header dans un location
annule tous les add_header herites du server (nosniff disparaissait du
JavaScript), et un statique manquant doit renvoyer 404 plutot que retomber
sur Flask, sinon un deploiement casse se cache derriere une page qui marche.
PERF-005. Les objets utilisateur Discord sont mis en cache. A etre precis
sur le gain : un envoi coute deux appels reseau, resoudre puis envoyer, et
seul le premier est economise — un premier match a vingt joueurs fait
toujours vingt resolutions. Ce qui est gagne l est entre notifications, la
ou le bot ecrit aux memes personnes soir apres soir.
Chaque message dit desormais ce qu il est devenu, avec le destinataire et
la raison. Les trois echecs ne se ressemblent pas et ne se lisent plus
pareil : une boite fermee est definitive et ne se retente pas, une erreur
HTTP est passagere, un identifiant sans proprietaire est un compte a
corriger. Le lot quotidien annonce son propre deficit.
Piege trouve en ecrivant les tests : configure_logging met propagate=False
sur le logger 'app', et le handler de caplog est sur la racine. Les
assertions sur les journaux passaient seules et echouaient dans la suite
complete, ou une application avait deja ete construite — elles lisaient un
journal vide, pas un bot silencieux.
417 tests.
1193 lines
45 KiB
Python
1193 lines
45 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
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import os
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
import traceback
|
||
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
|
||
|
||
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)
|
||
|
||
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_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 Exception:
|
||
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.
|
||
"""
|
||
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()
|
||
logger.info('Daily reminder scheduler started (18:00 EDT)')
|
||
except Exception as e:
|
||
logger.error(f'Error starting scheduler: {e}')
|
||
|
||
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 process_queue(self):
|
||
"""Process messages from the queue (runs continuously)."""
|
||
while True:
|
||
try:
|
||
try:
|
||
item = self.message_queue.get_nowait()
|
||
except Empty:
|
||
await asyncio.sleep(0.5)
|
||
continue
|
||
|
||
if item.get('type') == 'one_on_one_request':
|
||
await self._send_one_on_one_dm(**item['data'])
|
||
elif item.get('type') == '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 item.get('type') == 'one_on_one_response':
|
||
await self._send_one_on_one_response_dm(**item['data'])
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error processing queue: {e}\n{traceback.format_exc()}")
|
||
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 Exception:
|
||
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):
|
||
"""Handle coach approving a One on One request."""
|
||
try:
|
||
from app.extensions import db
|
||
from app.models import OneOnOneRequest
|
||
|
||
request = OneOnOneRequest.query.get(request_id)
|
||
if not request:
|
||
return
|
||
|
||
if request.coach.discord_user_id != str(coach.id):
|
||
await channel.send("⚠️ 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()
|
||
db.session.commit()
|
||
|
||
await channel.send(
|
||
f"✅ You have **approved** the One on One session with {player_full_name}."
|
||
)
|
||
|
||
# Notify player via Discord
|
||
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,
|
||
)
|
||
del self.pending_requests[message_id]
|
||
self._save_pending()
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error handling approval: {e}\n{traceback.format_exc()}")
|
||
|
||
async def handle_one_on_one_reject(self, coach, message_id, request_id, channel):
|
||
"""Handle coach rejecting a One on One request."""
|
||
try:
|
||
from app.extensions import db
|
||
from app.models import OneOnOneRequest
|
||
|
||
request = OneOnOneRequest.query.get(request_id)
|
||
if not request:
|
||
return
|
||
|
||
if request.coach.discord_user_id != str(coach.id):
|
||
await channel.send("⚠️ 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
|
||
|
||
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 Exception as e:
|
||
logger.warning(f"Could not check for reply message: {e}")
|
||
|
||
request.status = 'rejected'
|
||
request.responded_at = datetime.utcnow()
|
||
if refusal_note:
|
||
request.coach_rejection_message = refusal_note
|
||
db.session.commit()
|
||
|
||
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 channel.send(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,
|
||
)
|
||
del self.pending_requests[message_id]
|
||
self._save_pending()
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error handling rejection: {e}\n{traceback.format_exc()}")
|
||
|
||
@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):
|
||
"""Handle player confirming attendance for a match/tryout."""
|
||
try:
|
||
from app.extensions import db
|
||
|
||
request_info = self.pending_requests[message_id]
|
||
event_type = request_info.get('event_type')
|
||
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 channel.send("⚠️ 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,
|
||
)
|
||
|
||
db.session.commit()
|
||
|
||
await channel.send("✅ Your attendance has been confirmed!")
|
||
del self.pending_requests[message_id]
|
||
self._save_pending()
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error handling attendance confirmation: {e}\n{traceback.format_exc()}")
|
||
|
||
async def handle_attendance_decline(self, player, message_id, reference_id, channel):
|
||
"""Handle player declining attendance for a match/tryout."""
|
||
try:
|
||
from app.extensions import db
|
||
|
||
request_info = self.pending_requests[message_id]
|
||
event_type = request_info.get('event_type')
|
||
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 channel.send("⚠️ 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'
|
||
|
||
db.session.commit()
|
||
|
||
await channel.send("❌ Your attendance has been declined.")
|
||
del self.pending_requests[message_id]
|
||
self._save_pending()
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error handling attendance decline: {e}\n{traceback.format_exc()}")
|
||
|
||
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.
|
||
"""
|
||
try:
|
||
if not player_discord_id:
|
||
logger.warning(f"Player has no Discord user ID for request {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!"
|
||
)
|
||
else:
|
||
if 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,
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error in direct One on One notification: {e}")
|
||
|
||
async def send_daily_reminders(self):
|
||
"""Send daily reminders at 18:00 EDT for events in 24-48 hours."""
|
||
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 as e:
|
||
logger.error(f"Error sending daily reminders: {e}\n{traceback.format_exc()}")
|
||
|
||
async def _send_daily_reminders_impl(self):
|
||
"""Internal implementation of daily reminders with proper app context."""
|
||
try:
|
||
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
|
||
|
||
# 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)
|
||
|
||
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)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error sending daily reminders: {e}")
|
||
|
||
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.
|
||
"""
|
||
try:
|
||
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!"
|
||
)
|
||
else:
|
||
if 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
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error sending One on One response DM: {e}")
|
||
return False
|
||
|
||
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 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."""
|
||
bot = get_bot()
|
||
try:
|
||
bot.message_queue.put(
|
||
{
|
||
'type': 'one_on_one_request',
|
||
'data': {
|
||
'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,
|
||
},
|
||
}
|
||
)
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"Error queuing One on One DM: {e}")
|
||
return False
|
||
|
||
|
||
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."""
|
||
bot = get_bot()
|
||
try:
|
||
bot.message_queue.put(
|
||
{
|
||
'type': 'schedule_addition',
|
||
'data': {
|
||
'user_id': user_id,
|
||
'event_type': event_type,
|
||
'event_title': event_title,
|
||
'event_date': event_date,
|
||
'event_time': event_time,
|
||
'reference_id': reference_id,
|
||
},
|
||
}
|
||
)
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"Error queuing schedule notification: {e}")
|
||
return False
|
||
|
||
|
||
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.
|
||
"""
|
||
bot = get_bot()
|
||
try:
|
||
bot.message_queue.put(
|
||
{
|
||
'type': 'one_on_one_response',
|
||
'data': {
|
||
'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,
|
||
},
|
||
}
|
||
)
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"Error queuing One on One response DM: {e}")
|
||
return False
|
||
|
||
|
||
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 as e:
|
||
logger.error(f"Bot error: {e}")
|
||
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.
|
||
|
||
Returns:
|
||
dict: `configured` (a token is set), `running` (the thread is
|
||
alive), `connected` (the gateway session is up) and `pending`
|
||
(reaction mappings held in memory).
|
||
"""
|
||
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,
|
||
}
|