pubg/discord/teammakerv2.py
2025-04-15 17:12:33 +02:00

567 lines
25 KiB
Python

# -*- coding: utf-8 -*-
"""
Discord bot for team creation, stats reporting, and event logging.
"""
import json
import os
import discord
import random
import asyncio
import re
import logging
from discord.ext import commands
# --- Configuration ---
# TODO: Move hardcoded channel names to a config file or environment variables
TEAMIFY_VOICE_CHANNEL = "teamify"
TEAMIFY_TEXT_CHANNEL = "teamify"
TEMP_CATEGORY_NAME = "Temporary Teams"
LOGGING_CHANNEL = "logging"
WELCOME_CHANNEL = "raadhuisplein"
GOD_CHANNEL = "GOD_CHANNEL" # Channel to ignore for voice logging
# TODO: Move stats file path to config
STATS_FILE_PATH = os.path.join("..", "data", "player_last_stats.json")
CONFIG_PHP_PATH = "config.php" # Path relative to this script's location
# --- Logging Setup ---
# Basic logging to console
logging.basicConfig(level=logging.INFO, format='%(asctime)s:%(levelname)s:%(name)s: %(message)s')
logger = logging.getLogger('discord_bot')
# --- Helper Functions ---
def get_token_from_php(config_path):
"""
Reads the bot token from a PHP config file.
WARNING: This method of storing secrets is insecure.
Consider using environment variables or a dedicated secrets management solution.
"""
try:
with open(config_path, "r", encoding="utf-8") as file:
content = file.read()
# Regex to find $bottoken = 'TOKEN_VALUE';
match = re.search(r"\$bottoken\s*=\s*'(.+?)'", content)
if match:
logger.info("Successfully extracted bot token from %s", config_path)
return match.group(1)
else:
logger.error("Bot token pattern not found in %s", config_path)
return None
except FileNotFoundError:
logger.error("Config file not found at %s", config_path)
return None
except Exception as e:
logger.exception("Error reading or parsing config file %s: %s", config_path, e)
return None
async def cleanup_empty_channels(guild, temp_channels, ctx_channel):
"""
Periodically checks and removes empty temporary voice channels.
"""
while temp_channels:
await asyncio.sleep(60) # Check every 60 seconds
for channel in temp_channels[:]: # Iterate over a copy
try:
# Refresh channel state
current_channel = guild.get_channel(channel.id)
if current_channel and isinstance(current_channel, discord.VoiceChannel):
if len(current_channel.members) == 0:
logger.info("Deleting empty temporary channel: %s", current_channel.name)
await current_channel.delete(reason="Channel empty")
temp_channels.remove(channel)
# Optionally notify in the original context channel
# await ctx_channel.send(f"Kanaal {channel.name} is opgeruimd omdat het leeg was!")
elif current_channel is None:
# Channel might have been deleted manually
logger.warning("Temporary channel %s (ID: %d) not found, removing from tracking list.", channel.name, channel.id)
temp_channels.remove(channel)
except discord.Forbidden:
logger.error("Missing permissions to delete channel %s", channel.name)
# Remove from list to avoid repeated attempts if permissions are missing
temp_channels.remove(channel)
except discord.NotFound:
logger.warning("Attempted to delete channel %s but it was already gone.", channel.name)
if channel in temp_channels:
temp_channels.remove(channel)
except Exception as e:
logger.exception("Error during temporary channel cleanup for %s: %s", channel.name, e)
# Remove from list to avoid potential infinite loops on persistent errors
if channel in temp_channels:
temp_channels.remove(channel)
# --- Bot Setup ---
token = get_token_from_php(CONFIG_PHP_PATH)
if not token:
logger.critical("Bot token not found or could not be read. Exiting.")
exit() # Exit if token is essential
# Define Intents
intents = discord.Intents.default()
intents.voice_states = True
intents.guilds = True
intents.messages = True
intents.message_content = True
intents.presences = True # Required if the bot needs to see presences
intents.members = True # Required to see members in voice channels and for join/remove events
bot = commands.Bot(command_prefix="!", intents=intents)
# --- Events ---
@bot.event
async def on_ready():
"""Called when the bot is ready and connected."""
logger.info('Bot is ingelogd als %s (ID: %s)', bot.user, bot.user.id)
print(f'Bot is ingelogd als {bot.user}') # Keep console output for convenience
@bot.event
async def on_command_error(ctx, error):
"""Global command error handler."""
if isinstance(error, commands.CommandNotFound):
# Optionally ignore 'Command not found' errors or send a message
# await ctx.send("Onbekend commando.")
logger.warning("Command not found: %s", ctx.message.content)
elif isinstance(error, commands.MissingRequiredArgument):
await ctx.send(f"Je mist een argument: {error.param.name}")
elif isinstance(error, commands.BadArgument):
await ctx.send("Ongeldig argument opgegeven.")
elif isinstance(error, commands.CheckFailure):
await ctx.send("Je hebt geen permissies voor dit commando of kanaal.")
else:
# Log other errors
logger.exception("Unhandled command error in '%s': %s", ctx.command, error)
try:
# Attempt to notify user about unexpected error
await ctx.send("Er is een onverwachte fout opgetreden.")
except discord.HTTPException:
pass # Ignore if we can't send the error message
@bot.event
async def on_voice_state_update(member, before, after):
"""Logs user movements between voice channels."""
# Ignore bots
if member.bot:
return
logging_channel = discord.utils.get(member.guild.text_channels, name=LOGGING_CHANNEL)
if not logging_channel:
logger.warning("Logging channel '%s' not found.", LOGGING_CHANNEL)
return
# Ignore movements involving the GOD_CHANNEL
if (before.channel and before.channel.name == GOD_CHANNEL) or \
(after.channel and after.channel.name == GOD_CHANNEL):
return
try:
if before.channel is None and after.channel is not None:
# Member joins a voice channel
logger.info("%s joined voice channel: %s", member.display_name, after.channel.name)
await logging_channel.send(f"🔊 {member.mention} is gejoined in voice kanaal: **{after.channel.name}**")
elif before.channel is not None and after.channel is None:
# Member leaves a voice channel
logger.info("%s left voice channel: %s", member.display_name, before.channel.name)
await logging_channel.send(f"🔇 {member.mention} heeft het voice kanaal **{before.channel.name}** verlaten.")
elif before.channel != after.channel:
# Member switches voice channels
logger.info("%s switched from %s to %s", member.display_name, before.channel.name, after.channel.name)
await logging_channel.send(f"🔄 {member.mention} is van **{before.channel.name}** naar **{after.channel.name}** gegaan.")
except discord.Forbidden:
logger.error("Missing permissions to send message in logging channel '%s'", LOGGING_CHANNEL)
except Exception as e:
logger.exception("Error during on_voice_state_update: %s", e)
@bot.event
async def on_member_join(member):
"""Welcomes new members."""
if member.bot: return # Ignore bots
welcome_channel = discord.utils.get(member.guild.text_channels, name=WELCOME_CHANNEL)
if welcome_channel:
logger.info("Member joined: %s", member.display_name)
try:
await welcome_channel.send(f"🎉 Welkom {member.mention} op de server! We hopen dat je een leuke tijd hebt!")
except discord.Forbidden:
logger.error("Missing permissions to send message in welcome channel '%s'", WELCOME_CHANNEL)
except Exception as e:
logger.exception("Error during on_member_join: %s", e)
else:
logger.warning("Welcome channel '%s' not found.", WELCOME_CHANNEL)
@bot.event
async def on_member_remove(member):
"""Logs when members leave."""
if member.bot: return # Ignore bots
goodbye_channel = discord.utils.get(member.guild.text_channels, name=WELCOME_CHANNEL) # Using same channel as welcome
if goodbye_channel:
logger.info("Member left: %s", member.display_name)
try:
await goodbye_channel.send(f"😢 {member.display_name} heeft de server verlaten. We zullen je missen!") # Use display_name as mention won't work
except discord.Forbidden:
logger.error("Missing permissions to send message in goodbye channel '%s'", WELCOME_CHANNEL)
except Exception as e:
logger.exception("Error during on_member_remove: %s", e)
else:
logger.warning("Goodbye channel '%s' not found.", WELCOME_CHANNEL)
# --- Commands ---
@bot.command()
async def test(ctx):
"""A simple test command."""
await ctx.send("Test geslaagd!")
@bot.command()
async def teamify(ctx, *args):
"""
Splits members in the 'teamify' voice channel into random teams.
Usage:
!teamify - Auto-split into teams of 4.
!teamify <num_teams> - Split into a specific number of teams.
!teamify move - Auto-split and move to temporary channels.
!teamify <num_teams> move - Split into specific teams and move.
!teamify help - Show help message.
"""
# Handle help argument first
if "help" in [arg.lower() for arg in args]:
help_message = (
"**Gebruik van !teamify:**\n"
f"`!teamify` - Verdeel spelers in `{TEAMIFY_VOICE_CHANNEL}` in teams van max. 4.\n"
"`!teamify <aantal_teams>` - Verdeel spelers in een opgegeven aantal teams.\n"
"`!teamify move` - Verdeel spelers automatisch en verplaats ze naar tijdelijke kanalen.\n"
"`!teamify <aantal_teams> move` - Verdeel spelers en verplaats ze.\n"
"`!teamify help` - Toon dit bericht."
)
await ctx.send(help_message)
return
# Check if command is used in the correct text channel
if ctx.channel.name != TEAMIFY_TEXT_CHANNEL:
await ctx.send(f"Dit commando kan alleen worden gebruikt in het #{TEAMIFY_TEXT_CHANNEL} kanaal.")
return
guild = ctx.guild
source_voice_channel = discord.utils.get(guild.voice_channels, name=TEAMIFY_VOICE_CHANNEL)
if not source_voice_channel:
await ctx.send(f"Voice kanaal '{TEAMIFY_VOICE_CHANNEL}' niet gevonden!")
return
if not source_voice_channel.members:
await ctx.send(f"Er zijn geen mensen in het kanaal '{TEAMIFY_VOICE_CHANNEL}' om teams van te maken!")
return
members = list(source_voice_channel.members) # Get a mutable list
random.shuffle(members)
member_count = len(members)
# Parse arguments
num_teams_arg = 0
move_players = False
for arg in args:
if arg.isdigit():
num_teams_arg = int(arg)
elif arg.lower() == "move":
move_players = True
# Determine number of teams
if num_teams_arg <= 0:
# Auto-calculate based on team size 4, minimum 1 team
num_teams = (member_count + 3) // 4 if member_count > 0 else 1
else:
num_teams = num_teams_arg
# Ensure num_teams is valid
num_teams = max(1, min(num_teams, member_count)) # At least 1 team, max members count
# Create teams
teams = [[] for _ in range(num_teams)]
for i, member in enumerate(members):
teams[i % num_teams].append(member)
# Find the output text channel
output_text_channel = discord.utils.get(guild.text_channels, name=TEAMIFY_TEXT_CHANNEL)
if not output_text_channel:
# Fallback to context channel if specific channel not found
logger.warning("Output text channel '%s' not found, using context channel.", TEAMIFY_TEXT_CHANNEL)
output_text_channel = ctx.channel
# await ctx.send(f"Tekst kanaal '{TEAMIFY_TEXT_CHANNEL}' bestaat niet! Resultaten worden hier gepost.")
# Prepare message and temporary channels if moving
message = f"Willekeurige teams uit {source_voice_channel.mention}:\n\n"
temp_channels = []
category = None
if move_players:
try:
category = discord.utils.get(guild.categories, name=TEMP_CATEGORY_NAME)
if not category:
logger.info("Creating category '%s'", TEMP_CATEGORY_NAME)
category = await guild.create_category(TEMP_CATEGORY_NAME, reason="Teamify temporary channels")
except discord.Forbidden:
await ctx.send(f"Ik heb geen permissies om de categorie '{TEMP_CATEGORY_NAME}' te maken.")
move_players = False # Disable moving if category creation fails
logger.error("Missing permissions to create category '%s'", TEMP_CATEGORY_NAME)
except Exception as e:
await ctx.send("Fout bij het maken/vinden van de categorie.")
move_players = False
logger.exception("Error finding/creating category '%s': %s", TEMP_CATEGORY_NAME, e)
# Build message and move players
move_tasks = []
for i, team in enumerate(teams, start=1):
team_names = ', '.join([member.mention for member in team])
message += f"**Team {i}:** {team_names}\n"
if move_players and category:
try:
channel_name = f"Squad {i}"
logger.info("Creating temporary voice channel: %s", channel_name)
temp_channel = await guild.create_voice_channel(channel_name, category=category, reason="Teamify command")
temp_channels.append(temp_channel)
for member in team:
# Add move operation to a list to run concurrently
move_tasks.append(member.move_to(temp_channel))
except discord.Forbidden:
await ctx.send(f"Ik heb geen permissies om voice kanaal 'Squad {i}' te maken of leden te verplaatsen.")
logger.error("Missing permissions to create/move for Squad %d", i)
# Optionally stop creating more channels if one fails
except Exception as e:
await ctx.send(f"Fout bij het maken van kanaal 'Squad {i}'.")
logger.exception("Error creating channel 'Squad %d': %s", i, e)
# Send the team message
try:
await output_text_channel.send(message)
logger.info("Posted team message to #%s", output_text_channel.name)
except discord.Forbidden:
await ctx.send(f"Ik heb geen permissies om berichten te sturen in #{output_text_channel.name}.")
logger.error("Missing permissions to send message in #%s", output_text_channel.name)
except Exception as e:
await ctx.send("Fout bij het versturen van het team bericht.")
logger.exception("Error sending team message: %s", e)
# Execute moves concurrently
if move_tasks:
logger.info("Moving %d members to temporary channels...", len(move_tasks))
results = await asyncio.gather(*move_tasks, return_exceptions=True)
moved_count = 0
for i, result in enumerate(results):
member_to_move = move_tasks[i].__self__ # Get the member object from the coroutine
if isinstance(result, Exception):
logger.error("Failed to move member %s: %s", member_to_move.display_name, result)
try:
# Try to notify in context channel about failed move
await ctx.send(f"Kon {member_to_move.mention} niet verplaatsen: {result}")
except discord.HTTPException: pass # Ignore if sending fails
else:
moved_count += 1
logger.info("Finished moving members. Successful moves: %d", moved_count)
await ctx.send(f"Teams zijn gepost in #{output_text_channel.name}! {moved_count} speler(s) verplaatst.")
elif move_players: # If move was intended but failed (e.g., category issue)
await ctx.send(f"Teams zijn gepost in #{output_text_channel.name}. Kon spelers niet verplaatsen.")
else:
await ctx.send(f"Teams zijn gepost in #{output_text_channel.name}.")
# Start background task for cleaning up empty channels if channels were created
if temp_channels:
logger.info("Starting background task to clean up %d temporary channels.", len(temp_channels))
bot.loop.create_task(cleanup_empty_channels(guild, temp_channels, ctx.channel))
@bot.command()
async def whoisbest(ctx, category="Casual", matchesback: int = 18):
"""
Shows top 3 players by winrate and AHD for a given category.
Usage: !whoisbest [category] [min_matches]
Example: !whoisbest Ranked 10
"""
if category.lower() == "help":
help_message = (
"**Gebruik van `!whoisbest`:**\n"
"`!whoisbest [category] [min_matches]`\n\n"
"**Parameters:**\n"
"`category` - Stats categorie (bijv. 'Casual', 'Ranked', 'Intense'). Niet hoofdlettergevoelig. Standaard: 'Casual'.\n"
"`min_matches` - Minimum aantal matches gespeeld. Standaard: 18.\n\n"
"**Voorbeeld:** `!whoisbest Ranked 10`\n"
"Toont top 3 Win% en AHD voor Ranked met minimaal 10 matches.\n"
)
await ctx.send(help_message)
return
logger.info("Whoisbest command invoked: category=%s, matchesback=%d", category, matchesback)
try:
# Read JSON file
if not os.path.exists(STATS_FILE_PATH):
await ctx.send(f"Statistieken bestand niet gevonden ({STATS_FILE_PATH}).")
logger.error("Stats file not found: %s", STATS_FILE_PATH)
return
with open(STATS_FILE_PATH, "r", encoding="utf-8") as file:
data = json.load(file)
# Create case-insensitive mapping for categories
category_mapping = {cat.lower(): cat for cat in data.keys() if isinstance(data[cat], list)} # Only map list categories
lower_category = category.lower()
if lower_category not in category_mapping:
available_categories = ', '.join(category_mapping.values()) # Show original names
await ctx.send(f"Ongeldige categorie '{category}'. Beschikbaar: {available_categories}")
logger.warning("Invalid category requested: %s", category)
return
actual_category = category_mapping[lower_category]
logger.info("Processing category: %s", actual_category)
# Filter players based on minimum matches
players_in_category = data.get(actual_category, [])
if not isinstance(players_in_category, list):
await ctx.send(f"Data voor categorie '{actual_category}' is niet in het verwachte formaat.")
logger.error("Invalid data format for category '%s'", actual_category)
return
filtered_players = [
player for player in players_in_category
if isinstance(player, dict) and player.get("matches", 0) >= matchesback
]
logger.info("Found %d players meeting criteria (>=%d matches) in %s", len(filtered_players), matchesback, actual_category)
if not filtered_players:
await ctx.send(f"Geen spelers gevonden voor '{actual_category}' met ≥ {matchesback} matches.")
return
# Safely sort players, handling potential missing keys or non-numeric data
def safe_get_float(player_dict, key, default=0.0):
val = player_dict.get(key)
if isinstance(val, (int, float)):
return float(val)
# Try converting comma decimal separator if needed
if isinstance(val, str):
try: return float(val.replace(',', '.'))
except ValueError: return default
return default
top_winratio = sorted(filtered_players, key=lambda x: safe_get_float(x, "winratio"), reverse=True)[:3]
top_ahd = sorted(filtered_players, key=lambda x: safe_get_float(x, "ahd"), reverse=True)[:3]
# Build the response message
response_message = f"**📊 Top Stats voor '{actual_category}' (min {matchesback} matches)**\n\n"
response_message += "**\U0001F3C6 Top 3 Winratio**\n"
if top_winratio:
for i, player in enumerate(top_winratio, start=1):
player_name = player.get('playername', 'Onbekend')
win_ratio = safe_get_float(player, 'winratio')
response_message += f"{i}. **{player_name}** - {win_ratio:.2f}%\n"
else:
response_message += "_Geen data beschikbaar_\n"
response_message += f"\n**\U0001F4A5 Top 3 Average Human Damage (AHD)**\n" # Changed emoji
if top_ahd:
for i, player in enumerate(top_ahd, start=1):
player_name = player.get('playername', 'Onbekend')
ahd_val = safe_get_float(player, 'ahd')
response_message += f"{i}. **{player_name}** - {ahd_val:.2f}\n"
else:
response_message += "_Geen data beschikbaar_\n"
await ctx.send(response_message)
logger.info("Successfully sent whoisbest stats for %s", actual_category)
except FileNotFoundError:
await ctx.send(f"Statistieken bestand niet gevonden ({STATS_FILE_PATH}).")
logger.error("Stats file not found: %s", STATS_FILE_PATH)
except json.JSONDecodeError:
await ctx.send("Fout bij het lezen van de statistieken (ongeldig JSON).")
logger.exception("JSONDecodeError reading stats file: %s", STATS_FILE_PATH)
except Exception as e:
await ctx.send(f"Onverwachte fout bij het ophalen van statistieken: {e}")
logger.exception("Error in whoisbest command: %s", e)
@bot.command()
@commands.has_permissions(move_members=True) # Add permission check
async def moveall(ctx):
"""Moves all members from other voice channels to the 'teamify' channel."""
# Check if command is used in the correct text channel
if ctx.channel.name != TEAMIFY_TEXT_CHANNEL:
await ctx.send(f"Dit commando kan alleen worden gebruikt in het #{TEAMIFY_TEXT_CHANNEL} tekstkanaal.")
return
guild = ctx.guild
target_channel = discord.utils.get(guild.voice_channels, name=TEAMIFY_VOICE_CHANNEL)
if not target_channel:
await ctx.send(f"Het '{TEAMIFY_VOICE_CHANNEL}' voice-kanaal bestaat niet!")
logger.error("Target voice channel '%s' not found for moveall.", TEAMIFY_VOICE_CHANNEL)
return
moved_members_count = 0
move_tasks = []
members_to_move = []
logger.info("Moveall command initiated by %s", ctx.author.display_name)
for channel in guild.voice_channels:
# Skip the target channel itself and channels with no members
if channel == target_channel or not channel.members:
continue
for member in channel.members:
# Avoid moving bots unless intended
# if member.bot: continue
members_to_move.append(member)
move_tasks.append(member.move_to(target_channel))
if not move_tasks:
await ctx.send("Er waren geen spelers in andere kanalen om te verplaatsen.")
logger.info("Moveall: No members found in other channels.")
return
logger.info("Attempting to move %d members to #%s", len(move_tasks), target_channel.name)
results = await asyncio.gather(*move_tasks, return_exceptions=True)
for i, result in enumerate(results):
member = members_to_move[i]
if isinstance(result, Exception):
logger.error("Failed to move member %s: %s", member.display_name, result)
try:
await ctx.send(f"Kon {member.mention} niet verplaatsen: {result}")
except discord.HTTPException: pass
else:
moved_members_count += 1
logger.info("Moveall finished. Successfully moved %d members.", moved_members_count)
if moved_members_count > 0:
await ctx.send(f"{moved_members_count} speler(s) zijn verplaatst naar het {target_channel.mention} kanaal.")
else:
await ctx.send("Kon geen spelers verplaatsen (controleer permissies?).")
@moveall.error
async def moveall_error(ctx, error):
"""Error handler specific to moveall command."""
if isinstance(error, commands.MissingPermissions):
await ctx.send("Je hebt geen permissies om leden te verplaatsen.")
else:
# Log and notify for other errors related to moveall
logger.exception("Error in moveall command: %s", error)
await ctx.send("Er is een fout opgetreden bij het uitvoeren van `moveall`.")
# --- Run Bot ---
if __name__ == "__main__":
logger.info("Starting bot...")
bot.run(token)