Files
cedrick2711 e53bc3b80d
CI - Security, Lint & Tests / validate (push) Failing after 25m59s
first cleanup iter
2026-09-09 08:26:47 -04:00

703 lines
27 KiB
Python

"""Discord bot lifecycle, class definition, and supervisor."""
import logging
import threading
import time
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy.exc import SQLAlchemyError
from app.discord.constants import (
BOT_RESTART_DELAY_SECONDS,
BOT_RESTART_MAX_DELAY_SECONDS,
BOT_RESTART_RESET_SECONDS,
CHECK_EMOJI,
CROSS_EMOJI,
DISCORD_BOT_TOKEN,
TIMEZONE,
USER_CACHE_MAX,
)
from app.discord.pending import (
forget_pending,
load_pending,
save_pending,
)
from app.discord.queue import message_queue
from app.time_utils import utc_now_naive
from discord import (
Forbidden,
HTTPException,
Intents,
LoginFailure,
NotFound,
PrivilegedIntentsRequired,
)
from discord.ext import commands
_logger = logging.getLogger(__name__)
bot_instance = None
bot_thread = None
bot_flask_app = None
_stop_bot = threading.Event()
class TeamTryoutsBot(commands.Bot):
"""Unified Discord bot for Team Tryouts notifications."""
def __init__(self, flask_app=None):
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 = load_pending()
self.message_queue = message_queue
self.scheduler = AsyncIOScheduler()
self.timezone = ZoneInfo(TIMEZONE)
self._user_cache = {}
self.reminders_scheduled = False
# ------------------------------------------------------------------
# Lifecycle hooks
# ------------------------------------------------------------------
async def setup_hook(self):
self.loop.create_task(self.process_queue())
async def on_error(self, event_method, *args, **kwargs):
_logger.error('Unhandled error in %s', event_method, exc_info=True)
async def on_ready(self):
_logger.info('Bot logged in as %s', self.user)
await self.start_scheduler()
async def start_scheduler(self):
if self.reminders_scheduled:
_logger.debug('Reminders already scheduled, skipping')
return
try:
self.scheduler.add_job(
self._send_daily_reminders_impl,
CronTrigger(hour=18, minute=0, timezone=self.timezone),
id='daily_match_check',
replace_existing=True,
)
self.scheduler.start()
self.reminders_scheduled = True
_logger.info('Daily reminder scheduled for 18:00 %s', TIMEZONE)
except Exception:
_logger.exception('Failed to start the daily reminder scheduler')
self.reminders_scheduled = False
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
async def _reply(self, channel, message):
try:
await channel.send(message)
except (HTTPException, Forbidden) as exc:
_logger.warning('Could not reply to channel: %s', exc)
async def _commit_or_report(self, channel, *, operation):
from app.extensions import db
try:
operation()
db.session.commit()
except SQLAlchemyError:
db.session.rollback()
_logger.exception('DB commit failed for %s', operation.__name__)
await self._reply(channel, 'Something went wrong saving that. Please try again.')
def _forget_pending(self, message_id):
self.pending_requests = forget_pending(self.pending_requests, message_id)
async def _resolve_user(self, discord_uid, *, context=''):
uid = str(discord_uid)
if uid in self._user_cache:
return self._user_cache[uid]
try:
user = await self.fetch_user(int(uid))
except (NotFound, HTTPException) as exc:
_logger.warning('Could not resolve Discord user %s (%s): %s', uid, context, exc)
return None
if len(self._user_cache) >= USER_CACHE_MAX:
self._user_cache.pop(next(iter(self._user_cache)))
self._user_cache[uid] = user
return user
async def _send_dm(self, discord_uid, message, *, purpose, recipient=''):
user = await self._resolve_user(discord_uid, context=purpose)
if not user:
_logger.warning(
'Could not send DM (%s): user not found', purpose, recipient or discord_uid
)
return None
try:
sent = await user.send(message)
_logger.info(
'DM sent (%s) to %s (%s)', purpose, recipient or user.display_name, discord_uid
)
return sent
except Forbidden:
_logger.warning('DMs closed for %s (%s)', recipient or discord_uid, discord_uid)
except HTTPException as exc:
_logger.warning('Failed to DM %s: %s', discord_uid, exc)
except Exception:
_logger.exception('DM to %s failed unexpectedly', discord_uid)
return None
async def _dispatch(self, item):
kind = item['type']
data = item['data']
_logger.debug('Dispatching %s', kind)
try:
if kind == 'one_on_one_request':
await self._send_one_on_one_dm(**data)
elif kind == 'schedule_addition':
await self._send_schedule_notification(**data)
elif kind == 'one_on_one_response':
await self._send_one_on_one_response_dm(**data)
elif kind == 'purge_pending':
pids = data.get('participant_ids', [])
self.pending_requests = {
mid: e
for mid, e in self.pending_requests.items()
if e.get('type') != 'attendance' or e.get('reference_id') not in pids
}
save_pending(self.pending_requests)
else:
_logger.warning('Unknown queue item type: %s', kind)
except Exception:
_logger.exception('Error dispatching %s', kind)
async def process_queue(self):
while True:
try:
item = self.message_queue.get(timeout=5)
except __import__('queue').Empty:
continue
await self._dispatch(item)
def is_dm_channel(self, channel) -> bool:
return channel.type.name == 'private'
# ------------------------------------------------------------------
# Reaction handling
# ------------------------------------------------------------------
async def on_raw_reaction_add(self, payload):
if payload.user_id == self.user.id:
return
message_id = payload.message_id
entry = self.pending_requests.get(str(message_id))
if not entry:
return
channel = self.get_channel(payload.channel_id)
if not channel:
return
reacting_user = payload.member or await self._resolve_user(
payload.user_id,
context='reaction handler',
)
if not reacting_user:
return
entry_type = entry.get('type')
reference_id = entry.get('reference_id')
emoji = str(payload.emoji)
if emoji == CHECK_EMOJI:
if entry_type == 'one_on_one':
await self.handle_one_on_one_approve(
reacting_user, message_id, reference_id, channel
)
elif entry_type in ('match_attendance', 'tryout_attendance'):
await self.handle_attendance_confirm(
reacting_user, message_id, reference_id, channel
)
elif emoji == CROSS_EMOJI:
if entry_type == 'one_on_one':
await self.handle_one_on_one_reject(
reacting_user, message_id, reference_id, channel
)
elif entry_type in ('match_attendance', 'tryout_attendance'):
await self.handle_attendance_decline(
reacting_user, message_id, reference_id, channel
)
# ------------------------------------------------------------------
# One-on-One handlers
# ------------------------------------------------------------------
async def _send_one_on_one_dm(
self,
coach_name='',
coach_discord_id='',
player_name='',
team_name='',
date_str='',
start_time='',
end_time='',
points='',
request_id=None,
):
embed = {
'embeds': [
{
'title': 'One on One Request',
'color': 3447003,
'fields': [
{'name': 'Player', 'value': player_name, 'inline': True},
{'name': 'Team', 'value': team_name or 'Unknown', 'inline': True},
{'name': 'Date', 'value': date_str, 'inline': True},
{'name': 'Time', 'value': f'{start_time} - {end_time}', 'inline': True},
{'name': 'Discussion Points', 'value': points or 'None', 'inline': False},
{
'name': 'Quick Reply',
'value': f'React with {CHECK_EMOJI} to approve, {CROSS_EMOJI} to reject.',
'inline': False,
},
],
'footer': {'text': f'Request #{request_id}'},
}
]
}
sent = await self._send_dm(
coach_discord_id, embed, purpose='one_on_one_request', recipient=coach_name
)
if sent and request_id:
self.pending_requests[str(sent.id)] = {
'type': 'one_on_one',
'reference_id': request_id,
'created_at': time.time(),
}
save_pending(self.pending_requests)
async def handle_one_on_one_approve(self, coach, message_id, request_id, channel):
from app.extensions import db
from app.models import OneOnOneRequest, User
req = db.session.get(OneOnOneRequest, request_id)
if not req:
await self._reply(channel, 'This request no longer exists.')
self._forget_pending(message_id)
return
if str(coach.id) != str(req.coach.discord_user_id):
await self._reply(channel, 'This request is not for you.')
return
if req.status != 'pending':
await self._reply(channel, 'This request has already been handled.')
self._forget_pending(message_id)
return
async def _approve():
req.status = 'approved'
req.responded_at = utc_now_naive()
await self._commit_or_report(channel, operation=_approve)
await self._reply(channel, f'One on One request from {req.player.full_name} approved!')
self._forget_pending(message_id)
player = db.session.get(User, req.player_id)
if player and player.discord_user_id:
await self._send_one_on_one_response_dm(
player_discord_id=player.discord_user_id,
player_full_name=player.full_name,
coach_full_name=getattr(coach, 'display_name', 'Coach'),
date_str=req.date.strftime('%A, %B %d, %Y'),
start_time=req.start_time.strftime('%I:%M %p') if req.start_time else 'TBD',
end_time=req.end_time.strftime('%I:%M %p') if req.end_time else 'TBD',
points=req.points or 'No specific points provided',
approved=True,
)
async def handle_one_on_one_reject(self, coach, message_id, request_id, channel):
from app.extensions import db
from app.models import OneOnOneRequest, User
req = db.session.get(OneOnOneRequest, request_id)
if not req:
await self._reply(channel, 'This request no longer exists.')
self._forget_pending(message_id)
return
if str(coach.id) != str(req.coach.discord_user_id):
await self._reply(channel, 'This request is not for you.')
return
if req.status != 'pending':
await self._reply(channel, 'This request has already been handled.')
self._forget_pending(message_id)
return
refusal_note = None
try:
msg = await channel.fetch_message(message_id)
if msg and msg.reference and msg.reference.resolved:
reply_to = msg.reference.resolved
if reply_to.author.id == coach.id:
refusal_note = reply_to.content if isinstance(reply_to.content, str) else None
except (NotFound, HTTPException) as exc:
_logger.warning('Could not read rejection reply: %s', exc)
async def _reject():
req.status = 'rejected'
req.responded_at = utc_now_naive()
if refusal_note:
req.coach_rejection_message = refusal_note
await self._commit_or_report(channel, operation=_reject)
await self._reply(channel, f'One on One request from {req.player.full_name} rejected.')
self._forget_pending(message_id)
player = db.session.get(User, req.player_id)
if player and player.discord_user_id:
await self._send_one_on_one_response_dm(
player_discord_id=player.discord_user_id,
player_full_name=player.full_name,
coach_full_name=getattr(coach, 'display_name', 'Coach'),
date_str=req.date.strftime('%A, %B %d, %Y'),
start_time=req.start_time.strftime('%I:%M %p') if req.start_time else 'TBD',
end_time=req.end_time.strftime('%I:%M %p') if req.end_time else 'TBD',
points=req.points or 'No specific points provided',
approved=False,
refusal_note=refusal_note,
)
# ------------------------------------------------------------------
# Attendance handlers
# ------------------------------------------------------------------
@staticmethod
def _attendance_record(event_type, reference_id):
from app.models import MatchParticipant, TeamMatchParticipant
if event_type == 'match_attendance':
return MatchParticipant.query.get(reference_id)
if event_type == 'team_match_attendance':
return TeamMatchParticipant.query.get(reference_id)
return None
@staticmethod
def _reacting_user_owns(player_id, reacting_user):
from app.models import User
player = User.query.get(player_id)
if not player:
return False
return str(player.discord_user_id) == str(reacting_user.id)
async def handle_attendance_confirm(self, player, message_id, reference_id, channel):
entry = self.pending_requests.get(str(message_id))
if not entry:
return
event_type = entry.get('type', '')
record = self._attendance_record(event_type, reference_id)
if not record:
await self._reply(channel, 'This event no longer exists.')
self._forget_pending(message_id)
return
if not self._reacting_user_owns(record.player_id, player):
await self._reply(channel, 'You cannot confirm attendance for someone else.')
return
async def _confirm():
record.attendance_confirmed = True
record.is_confirmed = True
await self._commit_or_report(channel, operation=_confirm)
await self._reply(channel, 'Attendance confirmed! ✅')
self._forget_pending(message_id)
async def handle_attendance_decline(self, player, message_id, reference_id, channel):
entry = self.pending_requests.get(str(message_id))
if not entry:
return
event_type = entry.get('type', '')
record = self._attendance_record(event_type, reference_id)
if not record:
await self._reply(channel, 'This event no longer exists.')
self._forget_pending(message_id)
return
if not self._reacting_user_owns(record.player_id, player):
await self._reply(channel, 'You cannot decline attendance for someone else.')
return
async def _decline():
record.attendance_confirmed = False
record.is_confirmed = False
await self._commit_or_report(channel, operation=_decline)
await self._reply(channel, 'Attendance declined. ❌')
self._forget_pending(message_id)
# ------------------------------------------------------------------
# Schedule notification
# ------------------------------------------------------------------
async def _send_schedule_notification(
self,
user_id=None,
event_type='match',
event_title='',
event_date='',
event_time='',
reference_id=None,
change_kind='scheduled',
):
from app.models import User
user = User.query.get(user_id)
if not user or not user.discord_user_id:
_logger.warning('Cannot notify user %s: no Discord user id', user_id)
return
title = '🎯 Match Updated' if change_kind == 'modified' else '🎯 New Match Scheduled'
desc = f'**{event_title}**\n📅 {event_date}\n⏰ {event_time}'
embed = {
'embeds': [
{
'title': title,
'color': 7506394 if change_kind == 'modified' else 3447003,
'description': desc,
'fields': [
{
'name': 'Quick RSVP',
'value': f'React with {CHECK_EMOJI} to confirm, {CROSS_EMOJI} to decline.',
'inline': False,
}
],
}
]
}
sent = await self._send_dm(
user.discord_user_id,
embed,
purpose=f'{change_kind}_{event_type}',
recipient=user.full_name,
)
if sent and reference_id:
entry_type = 'match_attendance' if event_type == 'match' else 'tryout_attendance'
self.pending_requests[str(sent.id)] = {
'type': entry_type,
'reference_id': reference_id,
'created_at': time.time(),
}
save_pending(self.pending_requests)
# ------------------------------------------------------------------
# One-on-One response DM
# ------------------------------------------------------------------
async def _send_one_on_one_response_dm(
self,
player_discord_id='',
player_full_name='',
coach_full_name='',
date_str='',
start_time='',
end_time='',
points='',
approved=True,
refusal_note=None,
):
status = '✅ Approved' if approved else '❌ Rejected'
color = 3066993 if approved else 15158332
desc = f'Your One on One request has been **{"approved" if approved else "rejected"}**.'
embed = {
'embeds': [
{
'title': f'One on One {status}',
'color': color,
'description': desc,
'fields': [
{'name': 'Coach', 'value': coach_full_name, 'inline': True},
{'name': 'Date', 'value': date_str, 'inline': True},
{'name': 'Time', 'value': f'{start_time} - {end_time}', 'inline': True},
{'name': 'Discussion Points', 'value': points or 'None', 'inline': False},
],
}
]
}
if refusal_note:
embed['embeds'][0]['fields'].append(
{'name': 'Reason', 'value': refusal_note, 'inline': False}
)
await self._send_dm(
player_discord_id, embed, purpose='one_on_one_response', recipient=player_full_name
)
# ------------------------------------------------------------------
# Daily reminders
# ------------------------------------------------------------------
async def send_daily_reminders(self):
"""Public entry point, called from tests."""
await self._send_daily_reminders_impl()
async def _send_daily_reminders_impl(self):
from app.extensions import db
from app.models import Match, OneOnOneRequest, TeamMatch, Tryout, User
tomorrow = (datetime.now(self.timezone) + timedelta(days=1)).date()
_logger.info('Running daily reminder check for %s', tomorrow)
for match in Match.query.filter(Match.date == tomorrow).all():
for participant in match.participants:
await self.send_match_reminder(participant.player, match)
for tm in TeamMatch.query.filter(TeamMatch.date == tomorrow).all():
for participant in tm.participants:
await self.send_match_reminder(participant.player, tm)
for tryout in Tryout.query.all():
if any(m.date == tomorrow for m in tryout.matches):
for reg in tryout.registrations:
await self.send_tryout_reminder(reg.player, tryout)
sessions = OneOnOneRequest.query.filter(
OneOnOneRequest.status == 'pending', OneOnOneRequest.date == tomorrow
).all()
for session in sessions:
player = db.session.get(User, session.player_id)
await self.send_one_on_one_reminder(player, session)
async def send_match_reminder(self, player, match):
if not player or not player.discord_user_id:
return
embed = {
'embeds': [
{
'title': '⏰ Match Tomorrow!',
'color': 15844367,
'description': f'**{match.title}**\n📅 {match.date.strftime("%A, %B %d, %Y")}\n⏰ {match.start_time.strftime("%I:%M %p") if match.start_time else "TBD"}',
}
]
}
await self._send_dm(
player.discord_user_id, embed, purpose='match_reminder', recipient=player.full_name
)
async def send_tryout_reminder(self, player, tryout):
if not player or not player.discord_user_id:
return
embed = {
'embeds': [
{
'title': '⏰ Tryout Tomorrow!',
'color': 15844367,
'description': f'**{tryout.name}**\n📅 {tryout.date.strftime("%A, %B %d, %Y")}\n⏰ {tryout.time.strftime("%I:%M %p") if tryout.time else "TBD"}',
}
]
}
await self._send_dm(
player.discord_user_id, embed, purpose='tryout_reminder', recipient=player.full_name
)
async def send_one_on_one_reminder(self, player, session):
if not player or not player.discord_user_id:
return
embed = {
'embeds': [
{
'title': '⏰ One on One Tomorrow!',
'color': 15844367,
'description': f'📅 {session.date.strftime("%A, %B %d, %Y")}\n⏰ {session.start_time.strftime("%I:%M %p") if session.start_time else "TBD"}',
}
]
}
await self._send_dm(
player.discord_user_id, embed, purpose='one_on_one_reminder', recipient=player.full_name
)
# -----------------------------------------------------------------
# Legacy alias
# -----------------------------------------------------------------
async def notify_player_about_one_on_one_direct(
self, player, coach, coach_name, coach_discord_id, date_str, start_time, end_time, points
):
embed = {
'embeds': [
{
'title': 'One on One Scheduled',
'color': 3447003,
'fields': [
{'name': 'Coach', 'value': coach_name, 'inline': True},
{'name': 'Date', 'value': date_str, 'inline': True},
{'name': 'Time', 'value': f'{start_time} - {end_time}', 'inline': True},
{'name': 'Discussion Points', 'value': points or 'None', 'inline': False},
],
}
]
}
await self._send_dm(
player.discord_user_id, embed, purpose='one_on_one_direct', recipient=player.full_name
)
# ======================================================================
# Module-level API
# ======================================================================
def get_bot(flask_app=None):
global bot_instance, bot_flask_app
if flask_app is not None:
bot_flask_app = flask_app
if bot_instance is None:
bot_instance = TeamTryoutsBot(flask_app=flask_app or bot_flask_app)
elif flask_app is not None and bot_instance.flask_app is None:
bot_instance.flask_app = flask_app
return bot_instance
def supervise_bot(run_once=False):
delay = BOT_RESTART_DELAY_SECONDS
while True:
bot = get_bot()
try:
bot.run(DISCORD_BOT_TOKEN, reconnect=True)
except LoginFailure:
_logger.critical('Discord bot token is invalid. Bot will not start.')
return
except PrivilegedIntentsRequired as exc:
_logger.critical('Missing privileged intent: %s. Bot will not start.', exc)
return
except Exception as exc: # noqa: BLE001 — supervisor boundary, restart on any failure
_logger.error('Discord bot stopped unexpectedly: %s', exc)
if run_once:
_logger.info('Bot run_once completed.')
return
bot_age = time.time() - getattr(bot, '_started_at', time.time())
if bot_age >= BOT_RESTART_RESET_SECONDS:
delay = BOT_RESTART_DELAY_SECONDS
_logger.warning('Discord bot disconnected. Restarting in %s seconds.', delay)
if _stop_bot.wait(delay):
_logger.info('Discord bot supervisor asked to stop.')
return
delay = min(delay * 2, BOT_RESTART_MAX_DELAY_SECONDS)
def start_bot(flask_app=None):
global bot_thread, bot_flask_app
if flask_app is not None:
bot_flask_app = flask_app
get_bot(flask_app=flask_app)
if DISCORD_BOT_TOKEN and bot_thread is None:
_stop_bot.clear()
bot_thread = threading.Thread(target=supervise_bot, daemon=True)
bot_thread.start()
_logger.info('TeamTryoutsBot started in background thread')
elif not DISCORD_BOT_TOKEN:
_logger.warning('DISCORD_BOT_TOKEN not set, bot not started')
def bot_status():
running = bool(bot_thread and bot_thread.is_alive())
instance = bot_instance
connected = bool(instance and not instance.is_closed() and instance.is_ready())
return {
'configured': bool(DISCORD_BOT_TOKEN),
'running': running,
'connected': connected,
'pending': len(instance.pending_requests) if instance else 0,
'reminders_scheduled': bool(instance and instance.reminders_scheduled),
}