703 lines
27 KiB
Python
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),
|
|
}
|