Files
team-tryouts/app/discord_bot.py
T
GGThedandClaude Opus 5 e5c29d8113 fix(ops): rendre visibles les pannes silencieuses du bot Discord
OPS-005, OPS-006, OPS-007, OPS-009 et OPS-012. Cinq constats, un motif
commun : le bot pouvait cesser de fonctionner correctement sans que rien,
nulle part, ne le dise.

OPS-006 -- etat en attente ecrit en place
_save_pending ouvrait le fichier de destination en ecriture puis
serialisait dedans : toute interruption laissait un JSON tronque. Et
_load_pending interceptait l erreur de lecture, la journalisait, puis
repartait avec un dictionnaire vide -- toutes les correspondances message
Discord <-> demande disparaissaient, les reactions en cours cessaient
d avoir un effet, et l interface n en montrait rien.

Ecriture par fichier temporaire voisin puis os.replace : la destination
contient l ancien contenu ou le nouveau, jamais la moitie d un des deux.
A la lecture, un fichier illisible est deplace en .corrupt-<horodatage>
plutot qu ecrase, et le message dit ce qui est perdu.

Ecart assume avec la recommandation d audit (« echouer bruyamment ») : le
bot demarre quand meme. Refuser de demarrer supprimerait toutes les
notifications au lieu de celles deja en vol.

OPS-007 -- fuite lente
Les entrees n etaient retirees qu apres reaction. Elles portent desormais
`created_at` et sont purgees au chargement au-dela de 30 jours. Une entree
sans horodatage est conservee : elle precede ce champ, la supprimer serait
deviner son age.

OPS-005 -- planificateur
`coalesce=True`, `misfire_grace_time=3600`, `max_instances=1`. Sans delai
de grace, un redemarrage a 18 h 05 perdait les rappels du jour sans trace ;
sans coalescence, un planificateur en retard envoie un rappel par
occurrence manquee, donc des messages en double.

OPS-009 -- controle d identite asymetrique
handle_one_on_one_approve et _reject comparent depuis toujours le compte
qui reagit au coach destinataire. Les deux gestionnaires de presence ne le
faisaient pas. Meme forme de message, meme risque, un seul verifiait :
c est l asymetrie qui etait le bug.

Au passage : confirmer sa presence a un tryout ecrivait un attribut qui n a
pas de colonne (DB-008, bloque sur Alembic). Le joueur lisait « confirme »
et rien n etait enregistre. Toujours vrai, mais desormais journalise en
warning avec l identifiant concerne.

OPS-012 -- etat du bot dans /health
Le bot tourne dans un fil demon du processus web. Quand ce fil meurt, le
site continue de servir des pages et plus aucune notification ne part.
/health expose maintenant configured / running / connected / pending.
Signale, pas fatal : un club sans rappels Discord est degrade, pas hors
service, et un 503 le sortirait du repartiteur de charge pour ca.

discord_pending.json passe hors suivi git. La regle d ignore etait en place
mais inerte. Consequence non relevee par l audit : le deploiement etant un
miroir de fichiers, chaque livraison ecrasait l etat vivant du serveur par
celui du depot.

13 tests, sans aucun appel a Discord.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 18:21:05 -04:00

1067 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unified Discord bot for Team Tryouts notifications.
This module provides a persistent bot that handles:
- One on One request approvals/rejections via reactions
- Match/tryout schedule addition notifications with attendance confirmation
- Daily reminders at 18:00 EDT for upcoming events
"""
import os
import json
import logging
import tempfile
import time
import asyncio
import threading
import traceback
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from queue import Queue, Empty
from discord import Intents
from discord.ext import commands
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
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
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
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 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
try:
user = await self.fetch_user(payload.user_id)
except Exception:
return
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."""
try:
user_id = int(coach_discord_id)
except (ValueError, TypeError):
logger.warning(f"Invalid coach_discord_id '{coach_discord_id}'")
return None
try:
user = await self.fetch_user(user_id)
if not user:
return None
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 user.send(message)
await msg.add_reaction(CHECK_EMOJI)
await msg.add_reaction(CROSS_EMOJI)
# Track this pending request
self.pending_requests[msg.id] = {
'type': 'one_on_one',
'id': request_id,
'created_at': time.time(),
}
self._save_pending()
logger.info(f"Sent One on One DM with reactions, message_id={msg.id}")
return msg.id
except Exception as e:
logger.error(f"Error sending One on One DM: {e}")
return None
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.
"""
try:
# 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
discord_uid = int(db_user.discord_user_id)
user = await self.fetch_user(discord_uid)
if not user:
logger.warning(f"Could not fetch Discord user {discord_uid}")
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 user.send(message)
await msg.add_reaction(CHECK_EMOJI)
await msg.add_reaction(CROSS_EMOJI)
# 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()
logger.info(
f"Sent {event_type} schedule notification to {db_user.username}, message_id={msg.id}"
)
return msg.id
except Exception as e:
logger.error(f"Error sending schedule notification: {e}")
return None
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.models import OneOnOneRequest
from app.extensions import db
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.models import OneOnOneRequest
from app.extensions import db
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
player_user = await self.fetch_user(int(player_discord_id))
if not player_user:
logger.warning(f"Could not fetch Discord user {player_discord_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 player_user.send(message)
logger.info(
f"Sent One on One notification to player {player_full_name} (request {request.id})"
)
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 app.models import (
Match,
Tryout,
MatchParticipant,
TryoutRegistration,
OneOnOneRequest,
)
from sqlalchemy.orm import joinedload
now = datetime.now(self.timezone)
tomorrow = now.date() + timedelta(days=1)
# 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:
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:
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:
await self.send_one_on_one_reminder(session.player, session)
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."""
try:
player_user = await self.fetch_user(int(player.discord_user_id))
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."
)
await player_user.send(message)
except Exception as e:
logger.error(f"Error sending match reminder: {e}")
async def send_tryout_reminder(self, player, tryout):
"""Send tryout reminder to player."""
try:
player_user = await self.fetch_user(int(player.discord_user_id))
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."
)
await player_user.send(message)
except Exception as e:
logger.error(f"Error sending tryout reminder: {e}")
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
player_user = await self.fetch_user(int(player_discord_id))
if not player_user:
logger.warning(f"Could not fetch Discord user {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."
)
await player_user.send(message)
logger.info(
f"Sent One on One response DM to player {player_full_name} (approved={approved})"
)
return True
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."""
try:
player_user = await self.fetch_user(int(player.discord_user_id))
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!"
)
await player_user.send(message)
except Exception as e:
logger.error(f"Error sending One on One reminder: {e}")
# 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,
}