QUA-002, seconde moitie. Le depot etant formate, l elargissement porte sur
des defauts et non sur du brassage.
Ajoute a la selection : B (bugbear), C4, RET, SIM, UP. Le lot entier n a
produit que 24 signalements sur 76 fichiers -- le code etait plus propre
que l audit ne le craignait. Neuf corriges automatiquement, quinze a la
main.
SIM108 est ignore : forcer un ternaire se lit moins bien que le if/else
qu il remplace, au seul endroit ou il se declenche.
isort (I) n est PAS active. Il reordonnerait les imports de 48 fichiers,
soit une seconde passe de pur brassage juste apres le commit de formatage.
A faire, mais seul.
Deux vrais defauts trouves par les nouvelles regles
- team_matches.edit_match faisait `except ValueError: pass` sur l heure de
debut et l heure de fin, trois lignes sous un champ date qui, lui,
signale et redirige. Une heure mal saisie etait donc acceptee par le
formulaire, jetee, l ancienne valeur conservee -- et la page annoncait
la reussite. Meme traitement que la date desormais.
- backup.py levait BackupError depuis deux blocs `except` sans `from`,
ce qui perdait la cause d origine dans la trace.
Ainsi que : un `return` explicite dans force_https, `%`-formatage remplace
dans log_auth_event (operations de chaine avant journalisation, pas des
gabarits de logger -- la redaction n est pas affectee), une compréhension
inutile, un `set(...)` en compréhension d ensemble, `open(..., 'r')`, une
variable de boucle inutilisee, et `contextlib.suppress` dans conftest.
CI : `ruff format --check` remplace le commentaire qui expliquait pourquoi
il etait absent.
263 tests passent. Les deux nouveaux messages sont traduits ; attention,
pybabel les avait apparies en `fuzzy` avec des entrees « date » existantes,
et une entree fuzzy est ignoree a l execution.
Co-Authored-By: Claude Opus 5 <[email protected]>
889 lines
34 KiB
Python
889 lines
34 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 os
|
||
import json
|
||
import logging
|
||
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
|
||
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."""
|
||
try:
|
||
if os.path.exists(PENDING_FILE):
|
||
with open(PENDING_FILE) as f:
|
||
data = json.load(f)
|
||
# Convert string keys back to int
|
||
self.pending_requests = {int(k): v for k, v in data.items()}
|
||
logger.info(
|
||
f"Loaded {len(self.pending_requests)} pending requests from {PENDING_FILE}"
|
||
)
|
||
else:
|
||
logger.info("No pending requests file found, starting fresh.")
|
||
except Exception as e:
|
||
logger.error(f"Error loading pending requests: {e}")
|
||
|
||
def _save_pending(self):
|
||
"""Save pending requests to the JSON file."""
|
||
try:
|
||
with open(PENDING_FILE, 'w') as f:
|
||
json.dump(self.pending_requests, f, indent=2)
|
||
except Exception as e:
|
||
logger.error(f"Error saving pending requests: {e}")
|
||
|
||
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."""
|
||
try:
|
||
self.scheduler.add_job(
|
||
self.send_daily_reminders,
|
||
trigger=CronTrigger(hour=18, minute=0, timezone=self.timezone),
|
||
id='daily_reminders',
|
||
replace_existing=True,
|
||
)
|
||
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}
|
||
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,
|
||
}
|
||
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()}")
|
||
|
||
async def handle_attendance_confirm(self, player, message_id, reference_id, channel):
|
||
"""Handle player confirming attendance for a match/tryout."""
|
||
try:
|
||
from app.models import MatchParticipant, TryoutRegistration
|
||
from app.extensions import db
|
||
|
||
request_info = self.pending_requests[message_id]
|
||
event_type = request_info.get('event_type')
|
||
|
||
if event_type == 'match':
|
||
participant = MatchParticipant.query.get(reference_id)
|
||
if participant:
|
||
participant = db.session.merge(participant)
|
||
participant.attendance_confirmed = True
|
||
elif event_type == 'tryout':
|
||
registration = TryoutRegistration.query.get(reference_id)
|
||
if registration:
|
||
registration = db.session.merge(registration)
|
||
registration.attendance_confirmed = True
|
||
|
||
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.models import MatchParticipant, TryoutRegistration
|
||
from app.extensions import db
|
||
|
||
request_info = self.pending_requests[message_id]
|
||
event_type = request_info.get('event_type')
|
||
|
||
if event_type == 'match':
|
||
participant = MatchParticipant.query.get(reference_id)
|
||
if participant:
|
||
participant = db.session.merge(participant)
|
||
db.session.delete(participant)
|
||
elif event_type == 'tryout':
|
||
registration = TryoutRegistration.query.get(reference_id)
|
||
if registration:
|
||
registration = db.session.merge(registration)
|
||
registration.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}")
|
||
|
||
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")
|