This commit is contained in:
+2
-2
@@ -550,7 +550,7 @@ def create_app(config=None):
|
||||
# not down, and a 503 here would take the site out of the load
|
||||
# balancer for it (OPS-012).
|
||||
if app.config['ENABLE_DISCORD_BOT']:
|
||||
from app.discord_bot import bot_status
|
||||
from app.discord import bot_status
|
||||
|
||||
health_data['discord_bot'] = bot_status()
|
||||
|
||||
@@ -750,7 +750,7 @@ def create_app(config=None):
|
||||
# Start the Discord bot for notifications
|
||||
if app.config['ENABLE_DISCORD_BOT']:
|
||||
try:
|
||||
from app.discord_bot import start_bot
|
||||
from app.discord import start_bot
|
||||
|
||||
start_bot(flask_app=app)
|
||||
except Exception: # the site must come up even if the bot cannot
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Discord notification subsystem.
|
||||
|
||||
Split from a single 1654-line module (app/discord_bot.py) into a package with
|
||||
one concern per module (ARCH-003).
|
||||
|
||||
Public API — these are the functions that Flask routes and services import:
|
||||
start_bot, bot_status, get_bot → .bot
|
||||
send_one_on_one_dm, send_schedule_notification,
|
||||
send_one_on_one_response → .messaging
|
||||
purge_pending_for_participants → .pending
|
||||
"""
|
||||
|
||||
from app.discord.bot import bot_status, get_bot, start_bot
|
||||
from app.discord.messaging import (
|
||||
send_one_on_one_dm,
|
||||
send_one_on_one_response,
|
||||
send_schedule_notification,
|
||||
)
|
||||
from app.discord.pending import purge_pending_for_participants
|
||||
|
||||
__all__ = [
|
||||
'bot_status',
|
||||
'get_bot',
|
||||
'purge_pending_for_participants',
|
||||
'send_one_on_one_dm',
|
||||
'send_one_on_one_response',
|
||||
'send_schedule_notification',
|
||||
'start_bot',
|
||||
]
|
||||
@@ -0,0 +1,702 @@
|
||||
"""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),
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,45 @@
|
||||
"""Constants used across the Discord notification subsystem."""
|
||||
|
||||
import os
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token
|
||||
# ---------------------------------------------------------------------------
|
||||
#: Discord bot token. Loaded once at import time so the bot can decide whether
|
||||
#: to start before the application factory runs.
|
||||
DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN')
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Emoji
|
||||
# ---------------------------------------------------------------------------
|
||||
CHECK_EMOJI = '✅' # Green checkmark
|
||||
CROSS_EMOJI = '❌' # Red X
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Timing
|
||||
# ---------------------------------------------------------------------------
|
||||
#: Entries older than this are dropped when the pending file is loaded.
|
||||
PENDING_MAX_AGE_DAYS = 30
|
||||
|
||||
#: How late the daily reminder may still be sent.
|
||||
REMINDER_GRACE_SECONDS = 3600
|
||||
|
||||
#: First wait before restarting a bot that stopped (OPS-004).
|
||||
BOT_RESTART_DELAY_SECONDS = 5
|
||||
|
||||
#: Ceiling on that wait.
|
||||
BOT_RESTART_MAX_DELAY_SECONDS = 300
|
||||
|
||||
#: A connection that lasted this long resets the back-off.
|
||||
BOT_RESTART_RESET_SECONDS = 600
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Caching
|
||||
# ---------------------------------------------------------------------------
|
||||
#: How many Discord user objects to keep resolved (PERF-005).
|
||||
USER_CACHE_MAX = 512
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Timezone
|
||||
# ---------------------------------------------------------------------------
|
||||
TIMEZONE = 'America/Toronto'
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Public API wrappers that enqueue notifications for the bot thread.
|
||||
|
||||
These are the functions that Flask routes and services import. They run in the
|
||||
request thread and hand the actual Discord work to the bot's async loop via the
|
||||
shared queue.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from app.discord.queue import enqueue
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
coach_name: Display name of the coach.
|
||||
coach_discord_id: Discord snowflake of the coach.
|
||||
player_name: Display name of the requesting player.
|
||||
team_name: The player's team, or None/empty.
|
||||
date_str, start_time, end_time: Already formatted for display.
|
||||
points: Free-text discussion points.
|
||||
request_id: OneOnOnRequest primary key for reaction routing.
|
||||
|
||||
Returns:
|
||||
True if queued, False on failure.
|
||||
"""
|
||||
return enqueue(
|
||||
'one_on_one_request',
|
||||
{
|
||||
'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,
|
||||
},
|
||||
description=f'the One on One request DM for request {request_id}',
|
||||
)
|
||||
|
||||
|
||||
def send_schedule_notification(
|
||||
user_id: int,
|
||||
event_type: str,
|
||||
event_title: str,
|
||||
event_date: str,
|
||||
event_time: str,
|
||||
reference_id: int,
|
||||
change_kind: str = 'scheduled',
|
||||
) -> bool:
|
||||
"""Queue a schedule notification DM to be sent by the bot.
|
||||
|
||||
Args:
|
||||
user_id: The player's database id (resolved to a snowflake in the bot).
|
||||
event_type: 'match' or 'tryout'.
|
||||
event_title: Display title of the event.
|
||||
event_date: Formatted date string.
|
||||
event_time: Formatted time range, or 'TBD'.
|
||||
reference_id: The participant id (or fallback match/tryout id) for
|
||||
reaction routing.
|
||||
change_kind: 'scheduled' for new events, 'modified' for changes.
|
||||
|
||||
Returns:
|
||||
True if queued, False on failure.
|
||||
"""
|
||||
return enqueue(
|
||||
'schedule_addition',
|
||||
{
|
||||
'user_id': user_id,
|
||||
'event_type': event_type,
|
||||
'event_title': event_title,
|
||||
'event_date': event_date,
|
||||
'event_time': event_time,
|
||||
'reference_id': reference_id,
|
||||
'change_kind': change_kind,
|
||||
},
|
||||
description=f'the {change_kind} notification for event {event_title}',
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Returns:
|
||||
True if queued, False on failure.
|
||||
"""
|
||||
return enqueue(
|
||||
'one_on_one_response',
|
||||
{
|
||||
'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,
|
||||
},
|
||||
description=f'the One on One response DM for {player_full_name}',
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Persistence of pending reaction-request mappings across bot restarts.
|
||||
|
||||
The bot uses reaction messages (✅ / ❌) on DMs to let a coach approve or reject
|
||||
a one-on-one request without visiting the site. The mapping from Discord
|
||||
message_id → {request_type, primary_key} is stored in a JSON file so that a
|
||||
bot restart does not orphan reactions that were already sent.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.discord.constants import PENDING_MAX_AGE_DAYS
|
||||
from app.discord.queue import enqueue
|
||||
|
||||
_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.dirname(os.path.abspath(__file__)))),
|
||||
'discord_pending.json',
|
||||
)
|
||||
|
||||
|
||||
def load_pending() -> dict:
|
||||
"""Load pending requests from the JSON file.
|
||||
|
||||
Returns:
|
||||
dict mapping message_id (int) → entry dict.
|
||||
"""
|
||||
if not os.path.exists(PENDING_FILE):
|
||||
return {}
|
||||
try:
|
||||
with open(PENDING_FILE, encoding='utf-8') as f:
|
||||
entries = json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
_logger.warning('Could not read pending file, starting empty.', exc_info=True)
|
||||
return {}
|
||||
if not isinstance(entries, dict):
|
||||
return {}
|
||||
cleaned = drop_expired(entries)
|
||||
if len(cleaned) < len(entries):
|
||||
save_pending(cleaned)
|
||||
return cleaned
|
||||
|
||||
|
||||
def drop_expired(entries: dict) -> dict:
|
||||
"""Remove entries whose creation timestamp is too old.
|
||||
|
||||
Args:
|
||||
entries: dict mapping message_id → entry.
|
||||
|
||||
Returns:
|
||||
Filtered dict.
|
||||
"""
|
||||
now = datetime.now(UTC).timestamp()
|
||||
cutoff = now - PENDING_MAX_AGE_DAYS * 86400
|
||||
return {mid: e for mid, e in entries.items() if e.get('created_at', 0) >= cutoff}
|
||||
|
||||
|
||||
def save_pending(entries: dict) -> None:
|
||||
"""Persist pending entries to the JSON file."""
|
||||
try:
|
||||
with open(PENDING_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(entries, f, indent=2)
|
||||
except OSError:
|
||||
_logger.exception('Could not write pending file')
|
||||
|
||||
|
||||
def forget_pending(entries: dict, message_id: int) -> dict:
|
||||
"""Remove one message from the pending map and persist.
|
||||
|
||||
Args:
|
||||
entries: The current pending map.
|
||||
message_id: The message whose entry to remove.
|
||||
|
||||
Returns:
|
||||
Updated dict (already persisted).
|
||||
"""
|
||||
entries.pop(str(message_id), None)
|
||||
save_pending(entries)
|
||||
return entries
|
||||
|
||||
|
||||
def purge_pending_for_participants(participant_ids: list[int]) -> None:
|
||||
"""Queue a purge of stale participant pending entries.
|
||||
|
||||
Called after a match edit deletes participants. The bot thread removes
|
||||
matching ``schedule_addition`` pending entries so that a reaction on an
|
||||
old message cannot produce a false confirmation.
|
||||
|
||||
Args:
|
||||
participant_ids: MatchParticipant primary keys that no longer exist.
|
||||
"""
|
||||
if not participant_ids:
|
||||
return
|
||||
enqueue(
|
||||
'purge_pending',
|
||||
{'participant_ids': participant_ids},
|
||||
description=f'purge of {len(participant_ids)} stale participant records',
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Shared queue that bridges Flask request threads to the bot's async loop.
|
||||
|
||||
Module-level, not per-instance: the supervisor replaces the bot object on every
|
||||
restart (discord.py cannot reuse a client whose run() has returned). A queue
|
||||
living on the old object would be discarded with it, taking every pending
|
||||
notification along.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from queue import Empty, Queue # noqa: F401 — re-exported for bot.py
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
#: The shared queue instance.
|
||||
message_queue: Queue = Queue()
|
||||
|
||||
|
||||
def enqueue(kind: str, data: dict, *, description: str) -> bool:
|
||||
"""Hand one notification to the bot thread. Never raises.
|
||||
|
||||
The three public senders were the same fifteen lines three times, each with
|
||||
its own blanket catch. There is one now, and it is the only place where
|
||||
breadth is a property of the caller rather than of the failure: this runs
|
||||
in a Flask request thread, and the work the notification announces is
|
||||
already committed. The meeting is booked whether or not Discord hears about
|
||||
it, so a failure here must not become a 500 on a page whose job is done.
|
||||
|
||||
What it is *not* is somewhere to lose a defect: the traceback is logged
|
||||
and the caller is told False.
|
||||
|
||||
Args:
|
||||
kind: Event type name ('one_on_one_request', 'schedule_addition', …).
|
||||
data: Payload dict.
|
||||
description: Human-readable label for the log message.
|
||||
|
||||
Returns:
|
||||
True on success, False on failure.
|
||||
"""
|
||||
try:
|
||||
message_queue.put({'type': kind, 'data': data})
|
||||
except Exception: # a notification must never fail the request that caused it # noqa: BLE001
|
||||
_logger.exception('Could not queue %s', description)
|
||||
return False
|
||||
return True
|
||||
+25
-1649
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -211,7 +211,7 @@ def restore_backup(backup_id):
|
||||
|
||||
# Create a safety backup of the current state before restoring.
|
||||
try:
|
||||
safety = _create_backup_record(
|
||||
_create_backup_record(
|
||||
backup_type='pre_restore',
|
||||
notes='Automatic safety backup before restoring ' + record.filename,
|
||||
)
|
||||
|
||||
@@ -29,6 +29,40 @@ from app.validators import EvaluationSchema
|
||||
evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
|
||||
|
||||
|
||||
def validate_score(value):
|
||||
"""Convert a raw form value to an int 1..10, or None if blank/invalid.
|
||||
|
||||
Args:
|
||||
value: Raw value from request.form — a string, or None for a missing field.
|
||||
|
||||
Returns:
|
||||
int | None: Score 1-10, or None.
|
||||
"""
|
||||
if value is None or (isinstance(value, str) and value.strip() == ''):
|
||||
return None
|
||||
try:
|
||||
score = int(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if score < 1 or score > 10:
|
||||
return None
|
||||
return score
|
||||
|
||||
|
||||
def _apply_evaluation(evaluation, scores, comments, position):
|
||||
"""Write scores, comments and recommended position onto an Evaluation row.
|
||||
|
||||
Args:
|
||||
evaluation: The Evaluation instance (new or existing).
|
||||
scores: Dict mapping criterion name to int or None.
|
||||
comments: Free-text comment string, or None.
|
||||
position: Recommended position string, or None.
|
||||
"""
|
||||
evaluation.apply_scores(scores)
|
||||
evaluation.comments = comments
|
||||
evaluation.position_recommendation = position
|
||||
|
||||
|
||||
def _users_by_id(user_ids):
|
||||
"""Load a set of users once for aggregate/list views."""
|
||||
wanted = {user_id for user_id in user_ids if user_id}
|
||||
|
||||
@@ -649,14 +649,14 @@ def edit_match(match_id):
|
||||
|
||||
# Purge Discord pending entries for participants that no longer exist.
|
||||
if removed_ids:
|
||||
from app.discord_bot import purge_pending_for_participants
|
||||
from app.discord import purge_pending_for_participants
|
||||
|
||||
purge_pending_for_participants(removed_ids)
|
||||
|
||||
# Decide whether and what to notify.
|
||||
if time_changed:
|
||||
# All current participants get the "modified" message.
|
||||
all_current = list(zip(notified_player_ids, notified_participant_ids))
|
||||
all_current = list(zip(notified_player_ids, notified_participant_ids, strict=True))
|
||||
notify_participants(
|
||||
title=match.title,
|
||||
date=match.date,
|
||||
@@ -671,7 +671,7 @@ def edit_match(match_id):
|
||||
new_player_ids = desired - set(existing.keys())
|
||||
new_pairs = [
|
||||
(pid, nid)
|
||||
for pid, nid in zip(notified_player_ids, notified_participant_ids)
|
||||
for pid, nid in zip(notified_player_ids, notified_participant_ids, strict=True)
|
||||
if pid in new_player_ids
|
||||
]
|
||||
notify_participants(
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from flask import flash, redirect, render_template, request, send_file, url_for
|
||||
from flask import flash, render_template, request, send_file, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from app.extensions import db
|
||||
from app.responders import respond_after_change
|
||||
from app.models import Admin, Coach, Contract, Manager, Player, User
|
||||
from app.models.admin_settings import AppSettings
|
||||
from app.permissions import can_manage_player_contract, coach_player_ids
|
||||
from app.responders import respond_after_change
|
||||
from app.routes.users._shared import (
|
||||
ALLOWED_CONTRACT_EXTENSIONS,
|
||||
ALLOWED_SIGNED_EXTENSIONS,
|
||||
|
||||
@@ -10,7 +10,6 @@ from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from app.extensions import db
|
||||
from app.responders import respond_after_change
|
||||
from app.forms import flash_validation_errors, form_payload
|
||||
from app.models import (
|
||||
Coach,
|
||||
@@ -32,6 +31,7 @@ from app.permissions import (
|
||||
coach_player_ids,
|
||||
coach_tryouts,
|
||||
)
|
||||
from app.responders import respond_after_change
|
||||
from app.routes.users.blueprint import users_bp
|
||||
from app.validators import NoteContentSchema, PersonalNoteSchema
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from app.extensions import db
|
||||
from app.responders import respond_after_change
|
||||
from app.forms import flash_validation_errors, form_payload
|
||||
from app.models import Coach, CoachAvailability, OneOnOneRequest, PersonalNote, Player, TeamNote
|
||||
from app.responders import respond_after_change
|
||||
from app.routes.users.blueprint import users_bp
|
||||
from app.services.notifications import send_discord_notification
|
||||
from app.time_utils import utc_now_naive
|
||||
@@ -183,7 +183,7 @@ def accept_one_on_one(request_id):
|
||||
|
||||
# Notify player via Discord (same message as if approved through Discord reactions)
|
||||
if player and player.discord_user_id:
|
||||
from app.discord_bot import send_one_on_one_response
|
||||
from app.discord import send_one_on_one_response
|
||||
|
||||
send_one_on_one_response(
|
||||
player_discord_id=player.discord_user_id,
|
||||
@@ -242,7 +242,7 @@ def reject_one_on_one(request_id):
|
||||
|
||||
# Notify player via Discord (same message as if rejected through Discord reactions)
|
||||
if player and player.discord_user_id:
|
||||
from app.discord_bot import send_one_on_one_response
|
||||
from app.discord import send_one_on_one_response
|
||||
|
||||
send_one_on_one_response(
|
||||
player_discord_id=player.discord_user_id,
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"""The signed-in user's own profile."""
|
||||
|
||||
from flask import flash, redirect, render_template, request, url_for
|
||||
from flask import flash, render_template, request, url_for
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from app.extensions import db, hash_password
|
||||
from app.responders import respond_after_change
|
||||
from app.logging_config import log_auth_event
|
||||
from app.models import (
|
||||
ESPORT_GAMES,
|
||||
@@ -17,6 +16,7 @@ from app.models import (
|
||||
Player,
|
||||
User,
|
||||
)
|
||||
from app.responders import respond_after_change
|
||||
from app.routes.users._shared import (
|
||||
flash_validation_errors,
|
||||
form_payload,
|
||||
|
||||
@@ -53,7 +53,7 @@ def send_discord_notification(
|
||||
|
||||
if coach_discord_id:
|
||||
try:
|
||||
from app.discord_bot import send_one_on_one_dm
|
||||
from app.discord import send_one_on_one_dm
|
||||
|
||||
send_one_on_one_dm(
|
||||
coach_name=coach_name,
|
||||
@@ -76,7 +76,7 @@ def send_discord_notification(
|
||||
# coach has no id of their own. Queueing it cannot raise (see _enqueue).
|
||||
if DISCORD_WEBHOOK_URL.isdigit():
|
||||
if not coach_discord_id:
|
||||
from app.discord_bot import send_one_on_one_dm
|
||||
from app.discord import send_one_on_one_dm
|
||||
|
||||
send_one_on_one_dm(
|
||||
coach_name=coach_name,
|
||||
|
||||
@@ -15,7 +15,7 @@ two.
|
||||
|
||||
import logging
|
||||
|
||||
from app.discord_bot import send_schedule_notification
|
||||
from app.discord import send_schedule_notification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user