diff --git a/.agents/agents/test.agent.md b/.agents/agents/test.agent.md deleted file mode 100644 index 0832e0c..0000000 --- a/.agents/agents/test.agent.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: test -description: Describe what this custom agent does and when to use it. -argument-hint: The inputs this agent expects, e.g., "a task to implement" or "a question to answer". -# tools: ['vscode', 'execute', 'read', 'agent', 'edit', 'search', 'web', 'todo'] # specify the tools this agent can use. If not set, all enabled tools are allowed. ---- - - - -Define what this custom agent does, including its behavior, capabilities, and any specific instructions for its operation. \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7028253..edbc9a2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ -*.env +.env +.env.* instance/ documents/ __pycache__/ -*.pyc \ No newline at end of file +*.pyc diff --git a/README.md b/README.md index e143bd6..c1e771d 100644 --- a/README.md +++ b/README.md @@ -1 +1,53 @@ -### Plateforme centralisée de tryouts \ No newline at end of file +### Plateforme centralisée de tryouts + +## Discord Integration for One on One Requests + +The application supports sending Discord direct messages to coaches when players request One on One sessions. + +### Setup Instructions + +#### 1. Create a Discord Bot + +1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) +2. Create a new application +3. Go to the "Bot" tab and create a bot user +4. Copy the bot token - this will be your `DISCORD_BOT_TOKEN` +5. Enable the "Message Content Intent" under Privileged Gateway Intents (required for sending messages) + +#### 2. Configure Environment Variables + +Add the following to your `.env` file (create one if it doesn't exist): + +``` +DISCORD_BOT_TOKEN=your_bot_token_here +DISCORD_WEBHOOK_URL=optional_webhook_url_for_backup +``` + +- `DISCORD_BOT_TOKEN`: Required for sending direct messages to coaches +- `DISCORD_WEBHOOK_URL`: Optional fallback for webhook-based notifications + +#### 3. Add Coaches to the Bot + +For the bot to send DMs to coaches: +1. Each coach must have the bot added to their Discord server OR be friends with the bot +2. Coaches need to add their Discord User ID to their profile: + - Enable Developer Mode in Discord (User Settings → Advanced → Developer Mode) + - Right-click on their profile → Copy ID + - Enter this numeric ID in the "Discord User ID" field in their profile settings + +### How It Works + +When a player submits a One on One request: +1. The system checks if the coach has a Discord User ID configured +2. If configured, a direct message is sent to the coach via the Discord bot +3. If the bot fails or no Discord User ID is set, the system falls back to the webhook URL (if configured) +4. The message includes player name, team, requested date/time, and discussion points + +### Message Format + +The Discord DM includes: +- Player name +- Team name +- Requested date and time slot +- Discussion points (if provided) +- Link to the application for approval/rejection \ No newline at end of file diff --git a/__pycache__/app.cpython-313.pyc b/__pycache__/app.cpython-313.pyc index cfa6192..0d68171 100644 Binary files a/__pycache__/app.cpython-313.pyc and b/__pycache__/app.cpython-313.pyc differ diff --git a/__pycache__/models.cpython-313.pyc b/__pycache__/models.cpython-313.pyc index ecf4b09..8b8238c 100644 Binary files a/__pycache__/models.cpython-313.pyc and b/__pycache__/models.cpython-313.pyc differ diff --git a/__pycache__/seed.cpython-313.pyc b/__pycache__/seed.cpython-313.pyc index 7a933de..32c0875 100644 Binary files a/__pycache__/seed.cpython-313.pyc and b/__pycache__/seed.cpython-313.pyc differ diff --git a/app.py b/app.py index 9b035d9..19e7c74 100644 --- a/app.py +++ b/app.py @@ -88,6 +88,14 @@ def create_app(): from seed import seed_database seed_database() + # Start the Discord bot for notifications + try: + from discord_bot import start_bot + start_bot() + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"Could not start Discord bot: {e}") + return app diff --git a/discord_bot.py b/discord_bot.py new file mode 100644 index 0000000..765463f --- /dev/null +++ b/discord_bot.py @@ -0,0 +1,548 @@ +"""Unified Discord bot for Team Tryouts notifications. + +This module provides a persistent bot that handles: +- One on One request approvals/rejections via reactions +- Match/tryout schedule addition notifications with attendance confirmation +- Daily reminders at 18:00 EDT for upcoming events +""" + +import os +import logging +import asyncio +import threading +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from queue import Queue, Empty +from discord import Forbidden, HTTPException, NotFound, Intents +from discord.ext import commands +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger +from dotenv import load_dotenv + +load_dotenv() +DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN') + +# Configure logging +logger = logging.getLogger(__name__) + +# Emoji constants +CHECK_EMOJI = '✅' # Green checkmark +CROSS_EMOJI = '❌' # Red X + + +class TeamTryoutsBot(commands.Bot): + """Unified Discord bot for Team Tryouts notifications. + + Handles One on One requests, schedule additions, and daily reminders. + """ + + def __init__(self): + 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.pending_requests = {} # Maps message_id to {type, id} for reaction handling + self.message_queue = Queue() # Thread-safe queue for messages from Flask + self.scheduler = AsyncIOScheduler() + self.timezone = ZoneInfo('America/Toronto') # EDT timezone + + async def setup_hook(self): + """Called when the bot is ready.""" + logger.info(f'TeamTryoutsBot logged in as {self.user}') + + async def on_ready(self): + """Log when the bot is ready and start background tasks.""" + logger.info(f'TeamTryoutsBot is ready! Logged in as {self.user}') + + # Start the queue processing task + self.loop.create_task(self.process_queue()) + + # Start the daily reminder scheduler + self.loop.create_task(self.start_scheduler()) + + async def start_scheduler(self): + """Start the APScheduler for daily reminders.""" + try: + self.scheduler.add_job( + self.send_daily_reminders, + trigger=CronTrigger(hour=18, minute=0, timezone=self.timezone), + id='daily_reminders', + replace_existing=True + ) + self.scheduler.start() + logger.info('Daily reminder scheduler started (18:00 EDT)') + except Exception as e: + logger.error(f'Error starting scheduler: {e}') + + async def process_queue(self): + """Process messages from the queue (runs continuously).""" + while True: + try: + try: + item = self.message_queue.get_nowait() + except Empty: + await asyncio.sleep(0.5) + continue + + if item.get('type') == 'one_on_one_request': + await self._send_one_on_one_dm(**item['data']) + elif item.get('type') == 'schedule_addition': + await self._send_schedule_notification(**item['data']) + + except Exception as e: + logger.error(f"Error processing queue: {e}") + await asyncio.sleep(0.1) + + def is_dm_channel(self, channel) -> bool: + """Check if a channel is a DM channel.""" + return hasattr(channel, 'recipient') or hasattr(channel, 'recipients') + + async def on_reaction_add(self, reaction, user): + """Handle when a reaction is added to a message.""" + if user.bot: + return + + if not self.is_dm_channel(reaction.message.channel): + return + + message_id = reaction.message.id + + if message_id not in self.pending_requests: + return + + request_info = self.pending_requests[message_id] + emoji_str = str(reaction.emoji) + + handler_type = request_info.get('type') + request_id = request_info.get('id') + + if emoji_str == CHECK_EMOJI: + if handler_type == 'one_on_one': + await self.handle_one_on_one_approve(user, message_id, request_id, reaction.message) + elif handler_type == 'schedule_addition': + await self.handle_attendance_confirm(user, message_id, request_id, reaction.message) + elif emoji_str == CROSS_EMOJI: + if handler_type == 'one_on_one': + await self.handle_one_on_one_reject(user, message_id, request_id, reaction.message) + elif handler_type == 'schedule_addition': + await self.handle_attendance_decline(user, message_id, request_id, reaction.message) + + async def _send_one_on_one_dm(self, coach_name: str, coach_discord_id: str, player_name: str, + team_name: str, date_str: str, start_time: str, end_time: str, + points: str, request_id: int) -> int: + """Send a One on One request DM to a coach with reactions.""" + try: + user_id = int(coach_discord_id) + except (ValueError, TypeError): + logger.warning(f"Invalid coach_discord_id '{coach_discord_id}'") + return None + + try: + user = await self.fetch_user(user_id) + if not user: + return None + + message = ( + "📅 **One on One Request**\n\n" + f"**Player:** {player_name}\n" + f"**Team:** {team_name or 'Unknown Team'}\n" + f"**Date:** {date_str}\n" + f"**Time:** {start_time} - {end_time}\n" + f"**Discussion Points:** {points or 'No specific points provided'}\n\n" + "Please respond by clicking a reaction below:\n" + f"{CHECK_EMOJI} - Confirm the meeting\n" + f"{CROSS_EMOJI} - Decline (you can add a reason by replying before clicking)" + ) + + msg = await user.send(message) + await msg.add_reaction(CHECK_EMOJI) + await msg.add_reaction(CROSS_EMOJI) + + # Track this pending request + self.pending_requests[msg.id] = {'type': 'one_on_one', 'id': request_id} + + logger.info(f"Sent One on One DM with reactions, message_id={msg.id}") + return msg.id + + except Exception as e: + logger.error(f"Error sending One on One DM: {e}") + return None + + async def _send_schedule_notification(self, user_id: int, event_type: str, + event_title: str, event_date: str, + event_time: str, reference_id: int) -> int: + """Send a schedule addition notification to a player.""" + try: + user = await self.fetch_user(user_id) + if not user: + return None + + event_name = "Match" if event_type == 'match' else "Tryout" + + message = ( + f"📅 **{event_name} Scheduled**\n\n" + f"You have been added to the following {event_type}:\n" + f"**{event_title}**\n" + f"**Date:** {event_date}\n" + f"**Time:** {event_time}\n\n" + "Please confirm your attendance:\n" + f"{CHECK_EMOJI} - Confirm attendance\n" + f"{CROSS_EMOJI} - Decline" + ) + + msg = await user.send(message) + await msg.add_reaction(CHECK_EMOJI) + await msg.add_reaction(CROSS_EMOJI) + + # Track this pending request + self.pending_requests[msg.id] = {'type': 'schedule_addition', 'id': reference_id, 'event_type': event_type} + + logger.info(f"Sent {event_type} schedule notification, message_id={msg.id}") + return msg.id + + except Exception as e: + logger.error(f"Error sending schedule notification: {e}") + return None + + async def handle_one_on_one_approve(self, coach, message_id, request_id, original_message): + """Handle coach approving a One on One request.""" + try: + from models import OneOnOneRequest, db + from sqlalchemy.orm import joinedload + + request = OneOnOneRequest.query.options( + joinedload(OneOnOneRequest.player), + joinedload(OneOnOneRequest.coach) + ).get(request_id) + if not request: + return + + if request.coach.discord_user_id != str(coach.id): + await original_message.channel.send("⚠️ You are not the intended recipient.") + return + + request.status = 'approved' + request.responded_at = datetime.utcnow() + db.session.commit() + + await original_message.channel.send( + f"✅ You have **approved** the One on One session with {request.player.full_name}." + ) + + await self.notify_player_about_one_on_one(request, approved=True) + del self.pending_requests[message_id] + + except Exception as e: + logger.error(f"Error handling approval: {e}") + + async def handle_one_on_one_reject(self, coach, message_id, request_id, original_message): + """Handle coach rejecting a One on One request.""" + try: + from models import OneOnOneRequest, db + from sqlalchemy.orm import joinedload + + request = OneOnOneRequest.query.options( + joinedload(OneOnOneRequest.player), + joinedload(OneOnOneRequest.coach) + ).get(request_id) + if not request: + return + + if request.coach.discord_user_id != str(coach.id): + await original_message.channel.send("⚠️ You are not the intended recipient.") + return + + refusal_note = None + try: + async for reply in original_message.channel.history(limit=20): + if reply.author.id == coach.id and reply.reference and reply.reference.message_id == message_id: + refusal_note = reply.content + break + except Exception as e: + logger.warning(f"Could not check for reply message: {e}") + + request.status = 'rejected' + request.responded_at = datetime.utcnow() + if refusal_note: + request.coach_rejection_message = refusal_note + db.session.commit() + + rejection_msg = f"❌ You have **rejected** the One on One session with {request.player.full_name}." + if refusal_note: + rejection_msg += f"\n**Reason:** {refusal_note}" + else: + rejection_msg += "\n\nℹ️ The player has been notified that you are not available." + + await original_message.channel.send(rejection_msg) + await self.notify_player_about_one_on_one(request, approved=False, refusal_note=refusal_note) + del self.pending_requests[message_id] + + except Exception as e: + logger.error(f"Error handling rejection: {e}") + + async def handle_attendance_confirm(self, player, message_id, reference_id, original_message): + """Handle player confirming attendance for a match/tryout.""" + try: + from models import MatchParticipant, TryoutRegistration, Match, Tryout, db + + request_info = self.pending_requests[message_id] + event_type = request_info.get('event_type') + + if event_type == 'match': + participant = MatchParticipant.query.get(reference_id) + if participant: + participant.attendance_confirmed = True + elif event_type == 'tryout': + registration = TryoutRegistration.query.get(reference_id) + if registration: + registration.attendance_confirmed = True + + db.session.commit() + + await original_message.channel.send("✅ Your attendance has been confirmed!") + del self.pending_requests[message_id] + + except Exception as e: + logger.error(f"Error handling attendance confirmation: {e}") + + async def handle_attendance_decline(self, player, message_id, reference_id, original_message): + """Handle player declining attendance for a match/tryout.""" + try: + from models import MatchParticipant, TryoutRegistration, Match, Tryout, db + + request_info = self.pending_requests[message_id] + event_type = request_info.get('event_type') + + if event_type == 'match': + participant = MatchParticipant.query.get(reference_id) + if participant: + db.session.delete(participant) + elif event_type == 'tryout': + registration = TryoutRegistration.query.get(reference_id) + if registration: + registration.status = 'no_show' + + db.session.commit() + + await original_message.channel.send("❌ Your attendance has been declined.") + del self.pending_requests[message_id] + + except Exception as e: + logger.error(f"Error handling attendance decline: {e}") + + async def notify_player_about_one_on_one(self, request, approved=True, refusal_note=None): + """Send confirmation to player about One on One response.""" + try: + # Ensure player and coach relationships are loaded + player = request.player + coach = request.coach + + if not player or not player.discord_user_id: + logger.warning(f"Player has no Discord user ID for request {request.id}") + return + + player_user = await self.fetch_user(int(player.discord_user_id)) + if not player_user: + logger.warning(f"Could not fetch Discord user for player {player.id}") + return + + if approved: + message = ( + "🎉 **One on One Session Confirmed!**\n\n" + f"Your coach **{request.coach.full_name}** has approved your request:\n" + f"**Date:** {request.date.strftime('%A, %B %d, %Y')}\n" + f"**Time:** {request.start_time.strftime('%I:%M %p')} - {request.end_time.strftime('%I:%M %p')}\n" + f"**Discussion Points:** {request.points or 'No specific points provided'}\n\n" + "Please prepare for your session!" + ) + else: + if refusal_note: + message = ( + "😞 **One on One Session Rejected**\n\n" + f"Your coach **{request.coach.full_name}** has declined:\n" + f"**Reason:** {refusal_note}\n\n" + "Please try selecting a different time slot." + ) + else: + message = ( + "😞 **One on One Session Unavailable**\n\n" + f"Your coach **{request.coach.full_name}** is not available.\n\n" + "Please try selecting a different time slot." + ) + + await player_user.send(message) + + except Exception as e: + logger.error(f"Error notifying player: {e}") + + async def send_daily_reminders(self): + """Send daily reminders at 18:00 EDT for events in 24-48 hours.""" + try: + from models import Match, Tryout, MatchParticipant, TryoutRegistration, OneOnOneRequest, db + from sqlalchemy.orm import joinedload + + now = datetime.now(self.timezone) + tomorrow = now.date() + timedelta(days=1) + + # Find matches for tomorrow + matches = Match.query.filter(Match.date == tomorrow).all() + for match in matches: + participants = MatchParticipant.query.filter_by(match_id=match.id).all() + for participant in participants: + if participant.player.discord_user_id: + await self.send_match_reminder(participant.player, match) + + # Find tryouts for tomorrow + tryouts = Tryout.query.filter(Tryout.date == tomorrow).all() + for tryout in tryouts: + registrations = TryoutRegistration.query.filter_by(tryout_id=tryout.id).all() + for reg in registrations: + if reg.player.discord_user_id: + await self.send_tryout_reminder(reg.player, tryout) + + # Find One on One sessions for tomorrow (only approved ones) + one_on_ones = OneOnOneRequest.query.options( + joinedload(OneOnOneRequest.player), + joinedload(OneOnOneRequest.coach) + ).filter( + OneOnOneRequest.date == tomorrow, + OneOnOneRequest.status == 'approved' + ).all() + for session in one_on_ones: + if session.player and session.player.discord_user_id: + await self.send_one_on_one_reminder(session.player, session) + + except Exception as e: + logger.error(f"Error sending daily reminders: {e}") + + async def send_match_reminder(self, player, match): + """Send match reminder to player.""" + try: + player_user = await self.fetch_user(int(player.discord_user_id)) + message = ( + "🔔 **Match Reminder**\n\n" + f"Your match **{match.title}** is scheduled for tomorrow:\n" + f"**Date:** {match.date.strftime('%A, %B %d, %Y')}\n" + f"**Time:** {match.start_time.strftime('%I:%M %p') if match.start_time else 'TBD'} - " + f"{match.end_time.strftime('%I:%M %p') if match.end_time else 'TBD'}\n" + f"**Location:** {match.location or 'TBD'}\n\n" + "Please confirm your attendance in the app." + ) + await player_user.send(message) + except Exception as e: + logger.error(f"Error sending match reminder: {e}") + + async def send_tryout_reminder(self, player, tryout): + """Send tryout reminder to player.""" + try: + player_user = await self.fetch_user(int(player.discord_user_id)) + message = ( + "🔔 **Tryout Reminder**\n\n" + f"Your tryout **{tryout.title}** is scheduled for tomorrow:\n" + f"**Date:** {tryout.date.strftime('%A, %B %d, %Y')}\n" + f"**Location:** {tryout.location or 'TBD'}\n\n" + "Please confirm your attendance in the app." + ) + await player_user.send(message) + except Exception as e: + logger.error(f"Error sending tryout reminder: {e}") + + async def send_one_on_one_reminder(self, player, session): + """Send One on One reminder to player.""" + try: + player_user = await self.fetch_user(int(player.discord_user_id)) + message = ( + "🔔 **One on One Reminder**\n\n" + f"Your One on One session with **{session.coach.full_name}** is scheduled for tomorrow:\n" + f"**Date:** {session.date.strftime('%A, %B %d, %Y')}\n" + f"**Time:** {session.start_time.strftime('%I:%M %p')} - {session.end_time.strftime('%I:%M %p')}\n" + f"**Discussion Points:** {session.points or 'No specific points provided'}\n\n" + "Please prepare for your session!" + ) + await player_user.send(message) + except Exception as e: + logger.error(f"Error sending One on One reminder: {e}") + + +# Global bot instance +bot_instance = None +bot_thread = None + + +def get_bot(): + """Get or create the bot instance.""" + global bot_instance + if bot_instance is None: + bot_instance = TeamTryoutsBot() + return bot_instance + + +def send_one_on_one_dm(coach_name: str, coach_discord_id: str, player_name: str, + team_name: str, date_str: str, start_time: str, end_time: str, + points: str, request_id: int) -> bool: + """Queue a One on One request DM to be sent by the bot.""" + bot = get_bot() + try: + bot.message_queue.put({ + 'type': 'one_on_one_request', + 'data': { + 'coach_name': coach_name, + 'coach_discord_id': coach_discord_id, + 'player_name': player_name, + 'team_name': team_name, + 'date_str': date_str, + 'start_time': start_time, + 'end_time': end_time, + 'points': points, + 'request_id': request_id + } + }) + return True + except Exception as e: + logger.error(f"Error queuing One on One DM: {e}") + return False + + +def send_schedule_notification(user_id: int, event_type: str, event_title: str, + event_date: str, event_time: str, reference_id: int) -> bool: + """Queue a schedule addition notification to be sent by the bot.""" + bot = get_bot() + try: + bot.message_queue.put({ + 'type': 'schedule_addition', + 'data': { + 'user_id': user_id, + 'event_type': event_type, + 'event_title': event_title, + 'event_date': event_date, + 'event_time': event_time, + 'reference_id': reference_id + } + }) + return True + except Exception as e: + logger.error(f"Error queuing schedule notification: {e}") + return False + + +def start_bot(): + """Start the Discord bot in the background.""" + global bot_thread + + bot = get_bot() + if DISCORD_BOT_TOKEN and bot_thread is None: + def run_bot(): + try: + bot.run(DISCORD_BOT_TOKEN) + except Exception as e: + logger.error(f"Bot error: {e}") + + bot_thread = threading.Thread(target=run_bot, daemon=True) + bot_thread.start() + logger.info("TeamTryoutsBot started in background thread") + elif not DISCORD_BOT_TOKEN: + logger.warning("DISCORD_BOT_TOKEN not set, bot not started") \ No newline at end of file diff --git a/instance/team_tryouts.db b/instance/team_tryouts.db index 9ab6cdd..ca98f70 100644 Binary files a/instance/team_tryouts.db and b/instance/team_tryouts.db differ diff --git a/models.py b/models.py index f458d0c..82d761a 100644 --- a/models.py +++ b/models.py @@ -85,7 +85,8 @@ class User(UserMixin, db.Model): # E-Sports specific fields games = db.Column(db.Text, nullable=True) # Comma-separated list of games - discord_username = db.Column(db.String(128), nullable=True) # Discord handle + discord_username = db.Column(db.String(128), nullable=True) # Discord handle (e.g., Username#1234) + discord_user_id = db.Column(db.String(64), nullable=True) # Discord User ID for DMs (numeric, e.g., 123456789012345678) league_os_profile = db.Column(db.String(256), nullable=True) # League OS profile URL or ID evaluations_given = db.relationship('Evaluation', foreign_keys='Evaluation.evaluator_id', backref='evaluator', lazy='dynamic') @@ -773,6 +774,8 @@ class OneOnOneRequest(db.Model): status: Request status (pending, approved, rejected, scheduled). created_at: Timestamp of creation. responded_at: Timestamp when coach responded. + discord_message_id: Discord message ID for reaction handling. + coach_rejection_message: Optional justification from coach when rejecting. """ __tablename__ = 'one_on_one_requests' id = db.Column(db.Integer, primary_key=True) @@ -786,7 +789,9 @@ class OneOnOneRequest(db.Model): status = db.Column(db.String(20), default='pending') # pending, approved, rejected, scheduled created_at = db.Column(db.DateTime, default=datetime.utcnow) responded_at = db.Column(db.DateTime, nullable=True) + discord_message_id = db.Column(db.BigInteger, nullable=True) # Discord message ID for reaction handling + coach_rejection_message = db.Column(db.Text, nullable=True) # Optional justification when rejecting player = db.relationship('User', foreign_keys=[player_id], backref='one_on_one_requests') coach = db.relationship('User', foreign_keys=[coach_id]) - team = db.relationship('OrgTeam', foreign_keys=[org_team_id]) + team = db.relationship('OrgTeam', foreign_keys=[org_team_id]) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 5c75244..179f748 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/routes/__pycache__/users.cpython-313.pyc b/routes/__pycache__/users.cpython-313.pyc index cf78966..e0b6b5c 100644 Binary files a/routes/__pycache__/users.cpython-313.pyc and b/routes/__pycache__/users.cpython-313.pyc differ diff --git a/routes/users.py b/routes/users.py index aec24ed..0453ff7 100644 --- a/routes/users.py +++ b/routes/users.py @@ -102,6 +102,7 @@ def edit_user(user_id): selected_games = request.form.getlist('games') discord_username = request.form.get('discord_username', '').strip() + discord_user_id = request.form.get('discord_user_id', '').strip() league_os_profile = request.form.get('league_os_profile', '').strip() user.full_name = full_name @@ -111,6 +112,7 @@ def edit_user(user_id): user.is_active_account = is_active user.games = ','.join(selected_games) if selected_games else None user.discord_username = discord_username or None + user.discord_user_id = discord_user_id or None user.league_os_profile = league_os_profile or None # Update gamertags using shared function @@ -241,6 +243,7 @@ def edit_profile(): selected_games = request.form.getlist('games') discord_username = request.form.get('discord_username', '').strip() + discord_user_id = request.form.get('discord_user_id', '').strip() league_os_profile = request.form.get('league_os_profile', '').strip() if email != current_user.email and User.query.filter_by(email=email).first(): @@ -252,6 +255,7 @@ def edit_profile(): current_user.phone = phone current_user.games = ','.join(selected_games) if selected_games else None current_user.discord_username = discord_username or None + current_user.discord_user_id = discord_user_id or None current_user.league_os_profile = league_os_profile or None # Update gamertags using shared function @@ -726,8 +730,11 @@ def download_signed_contract(contract_id): DISCORD_WEBHOOK_URL = os.environ.get('DISCORD_WEBHOOK_URL', '') -def send_discord_notification(player_name, points, date_str, start_time_str, end_time_str, team_name, coach_name, coach_discord): - """Send a Discord webhook notification for a One on One request. +def send_discord_notification(player_name, points, date_str, start_time_str, end_time_str, team_name, coach_name, coach_discord, coach_discord_id, request_id=None): + """Send a Discord notification for a One on One request. + + Sends a DM to the coach via Discord bot if their Discord User ID is configured, + otherwise falls back to webhook notification. Args: player_name: Name of the player making the request. @@ -738,32 +745,67 @@ def send_discord_notification(player_name, points, date_str, start_time_str, end team_name: Name of the player's team. coach_name: Name of the coach. coach_discord: Coach's Discord username. + coach_discord_id: Coach's Discord User ID (for DMs). """ - if not DISCORD_WEBHOOK_URL: - return # No webhook configured, skip notification - - embed = { - "embeds": [{ - "title": "One on One Request", - "color": 3447003, # Blue color - "fields": [ - {"name": "Player", "value": player_name, "inline": True}, - {"name": "Team", "value": team_name or "Unknown Team", "inline": True}, - {"name": "Date", "value": date_str, "inline": True}, - {"name": "Time", "value": f"{start_time_str} - {end_time_str}", "inline": True}, - {"name": "Discussion Points", "value": points or "No specific points provided", "inline": False} - ], - "footer": { - "text": f"Coach: {coach_name}" + (f" (Discord: {coach_discord})" if coach_discord else "") - } - }] - } - - try: - requests.post(DISCORD_WEBHOOK_URL, json=embed, timeout=5) - except Exception: - pass # Silently fail if webhook doesn't work + import logging + logger = logging.getLogger(__name__) + + # Try to send DM via Discord bot first (requires coach's Discord User ID) + if coach_discord_id: + try: + from discord_bot import send_one_on_one_dm + send_one_on_one_dm( + 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_str, + end_time=end_time_str, + points=points, + request_id=request_id + ) + except Exception as e: + logger.warning(f"Failed to send Discord DM: {e}") + # Also send to webhook as backup/fallback + if DISCORD_WEBHOOK_URL: + try: + from discord_bot import send_one_on_one_dm + + # Try to send DM to webhook URL if it's a user ID + if DISCORD_WEBHOOK_URL.isdigit() and not coach_discord_id: + send_one_on_one_dm( + coach_name=coach_name, + coach_discord_id=DISCORD_WEBHOOK_URL, + player_name=player_name, + team_name=team_name, + date_str=date_str, + start_time=start_time_str, + end_time=end_time_str, + points=points + ) + elif not DISCORD_WEBHOOK_URL.isdigit(): + # Legacy webhook URL - send traditional webhook embed + embed = { + "embeds": [{ + "title": "One on One Request", + "color": 3447003, + "fields": [ + {"name": "Player", "value": player_name, "inline": True}, + {"name": "Team", "value": team_name or "Unknown Team", "inline": True}, + {"name": "Date", "value": date_str, "inline": True}, + {"name": "Time", "value": f"{start_time_str} - {end_time_str}", "inline": True}, + {"name": "Discussion Points", "value": points or "No specific points provided", "inline": False} + ], + "footer": { + "text": f"Coach: {coach_name}" + (f" (Discord: {coach_discord})" if coach_discord else "") + } + }] + } + requests.post(DISCORD_WEBHOOK_URL, json=embed, timeout=5) + except Exception as e: + logger.warning(f"Failed to send Discord notification: {e}") @users_bp.route('/one-on-one', methods=['GET', 'POST']) @login_required @@ -858,7 +900,9 @@ def one_on_one(): end_time_str=end_time_str, team_name=org_team.name if org_team else None, coach_name=coach.full_name, - coach_discord=coach.discord_username + coach_discord=coach.discord_username, + coach_discord_id=coach.discord_user_id, + request_id=request_obj.id ) flash('One on One request sent to your coach!', 'success') diff --git a/seed.py b/seed.py index 09af244..8886abf 100644 --- a/seed.py +++ b/seed.py @@ -45,7 +45,7 @@ def seed_database(): {'username': 'president', 'password': 'password', 'role': 'president', 'full_name': 'Sarah Johnson', 'email': 'sarah@teampro.com', 'phone': '555-0101'}, {'username': 'manager1', 'password': 'password', 'role': 'manager', 'full_name': 'Mike Williams', 'email': 'mike@teampro.com', 'phone': '555-0102'}, {'username': 'manager2', 'password': 'password', 'role': 'manager', 'full_name': 'Emily Davis', 'email': 'emily@teampro.com', 'phone': '555-0103'}, - {'username': 'coach1', 'password': 'password', 'role': 'coach', 'full_name': 'Coach Thompson', 'email': 'coach.t@teampro.com', 'phone': '555-0104'}, + {'username': 'coach1', 'password': 'password', 'role': 'coach', 'full_name': 'Coach Thompson', 'email': 'coach.t@teampro.com', 'phone': '555-0104', 'discord_user_id': '484107446298738689'}, {'username': 'coach2', 'password': 'password', 'role': 'coach', 'full_name': 'Coach Martinez', 'email': 'coach.m@teampro.com', 'phone': '555-0105'}, {'username': 'coach3', 'password': 'password', 'role': 'coach', 'full_name': 'Coach Anderson', 'email': 'coach.a@teampro.com', 'phone': '555-0106'}, {'username': 'scout1', 'password': 'password', 'role': 'scout', 'full_name': 'Alex Rivera', 'email': 'alex@teampro.com', 'phone': '555-0107'}, @@ -53,9 +53,9 @@ def seed_database(): # Create players with E-Sports profile data (gamertags per game) player_data = [ - {'username': 'jplayer1', 'full_name': 'nordjan', 'email': 'nordjan27@gmail.com', 'games': 'Valorant,Counter-Strike 2, Rainbow Six Siege, Rocket League, Overwatch 2', + {'username': 'jplayer1', 'full_name': 'nordjan', 'email': 'nordjan27@gmail.com', 'games': 'Valorant, Counter-Strike 2, Rainbow Six Siege, Rocket League, Overwatch 2', 'gamertags': {'Valorant': 'nordjan#bad', 'Counter-Strike 2': 'nordjan', 'Rainbow Six Siege': 'n0rd-vpn', 'Rocket League': 'nordjiano'}, - 'discord': 'nordjan', 'league_os': 'https://leagueos.gg/player/nordjan'}, + 'discord': 'nordjan', 'discord_user_id': '484107446298738689', 'league_os': 'https://leagueos.gg/player/nordjan'}, {'username': 'jplayer2', 'full_name': 'Emma Garcia', 'email': 'emma@email.com', 'games': 'League of Legends,Valorant', 'gamertags': {'League of Legends': 'emmagarcia_lol', 'Valorant': 'emmagarcia_val'}, 'discord': 'EmmaG#4452', 'league_os': 'https://leagueos.gg/player/emmagarcia'}, @@ -90,7 +90,7 @@ def seed_database(): 'username': data['username'], 'password': 'password', 'role': 'player', 'full_name': data['full_name'], 'email': data['email'], 'phone': f'555-01{i:02d}', 'games': data['games'], 'gamertags': data.get('gamertags', {}), - 'discord_username': data['discord'], 'league_os_profile': data['league_os'] + 'discord_username': data['discord'], 'discord_user_id': data['discord_user_id'], 'league_os_profile': data['league_os'] }) users = [] @@ -105,6 +105,7 @@ def seed_database(): phone=data.get('phone', ''), games=data.get('games'), discord_username=data.get('discord_username'), + discord_user_id=data.get('discord_user_id'), league_os_profile=data.get('league_os_profile') ) db.session.add(user) @@ -124,8 +125,11 @@ def seed_database(): # Create gamertags for players gamertag_data = [ - {'user': users[7], 'game': 'Valorant', 'gamertag': 'jameswilson_val'}, - {'user': users[7], 'game': 'Counter-Strike 2', 'gamertag': 'jameswilson_cs'}, + {'user': users[7], 'game': 'Valorant', 'gamertag': 'nordjan#bad'}, + {'user': users[7], 'game': 'Counter-Strike 2', 'gamertag': 'nordjan'}, + {'user': users[7], 'game': 'Rainbow Six Siege', 'gamertag': 'nordjan', 'platform': 'Ubisoft'}, + {'user': users[7], 'game': 'Rocket League', 'gamertag': 'nordjiano', 'platform': 'Epic'}, + {'user': users[7], 'game': 'Overwatch 2', 'gamertag': 'nordjan', 'platform': 'PC'}, {'user': users[8], 'game': 'League of Legends', 'gamertag': 'emmagarcia_lol'}, {'user': users[8], 'game': 'Valorant', 'gamertag': 'emmagarcia_val'}, {'user': users[9], 'game': 'Apex Legends', 'gamertag': 'liambrown_apex', 'platform': 'PC'}, diff --git a/templates/pages/edit_profile.html b/templates/pages/edit_profile.html index 4d43df3..83b4e2e 100644 --- a/templates/pages/edit_profile.html +++ b/templates/pages/edit_profile.html @@ -79,6 +79,13 @@ +
+ + + Enable Developer Mode in Discord → Right-click profile → Copy ID +
+ +
diff --git a/templates/pages/edit_user.html b/templates/pages/edit_user.html index 8c35fad..9a3e0d1 100644 --- a/templates/pages/edit_user.html +++ b/templates/pages/edit_user.html @@ -87,11 +87,16 @@
-
+
-
+
+ + + Enable Developer Mode in Discord → Right-click profile → Copy ID +
+