Compare commits

...

10 Commits

Author SHA1 Message Date
SimolZimol
76b1a99e01 modified: app.py 2025-10-30 22:21:55 +01:00
SimolZimol
f06935acd5 modified: app.py 2025-10-30 21:59:15 +01:00
SimolZimol
4e3fe16692 modified: app.py 2025-10-30 21:53:40 +01:00
SimolZimol
5f4939367a modified: app.py 2025-10-30 21:42:09 +01:00
SimolZimol
8e6a2c7ef6 modified: Dockerfile
modified:   app.py
	modified:   requirements.txt
2025-10-28 16:18:09 +01:00
SimolZimol
67c5c9016a modified: Dockerfile
modified:   app.py
2025-10-28 16:13:05 +01:00
SimolZimol
a493d4eaf5 modified: Dockerfile
modified:   app.py
2025-10-28 16:08:02 +01:00
SimolZimol
56c3b769cb modified: app.py
modified:   requirements.txt
2025-10-28 16:02:22 +01:00
SimolZimol
6bc6984876 modified: Dockerfile
modified:   app.py
	modified:   requirements.txt
2025-10-28 15:04:02 +01:00
SimolZimol
caec9b326a modified: app.py 2025-10-28 15:00:04 +01:00
3 changed files with 257 additions and 288 deletions

View File

@@ -5,8 +5,6 @@ WORKDIR /app
RUN apt-get update && apt-get install -y \
libffi-dev \
libnacl-dev \
libopus0 \
ffmpeg \
python3-dev \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

537
app.py
View File

@@ -7,8 +7,7 @@ from dotenv import load_dotenv
import aiomysql
import json
from datetime import datetime
from typing import Optional, List, Dict, Tuple
import yt_dlp
from typing import Optional, List, Dict
# Load environment variables
load_dotenv()
@@ -79,7 +78,6 @@ intents = discord.Intents.default()
intents.message_content = True
intents.guilds = True
intents.members = True
intents.voice_states = True
bot = commands.Bot(command_prefix='!', intents=intents)
@@ -93,12 +91,6 @@ async def on_ready():
# Initialize database
await init_database()
# Preload opus if available (voice)
try:
if not discord.opus.is_loaded():
discord.opus.load_opus('libopus')
except Exception:
pass
# Set bot status
await bot.change_presence(
@@ -113,248 +105,6 @@ async def on_ready():
except Exception as e:
print(f'Failed to sync commands: {e}')
# =========================
# YouTube/Voice Player
# =========================
COOKIE_FILE = os.getenv('YT_COOKIES_PATH', '/app/cookie.txt')
class Song:
def __init__(self, title: str, url: str, webpage_url: str, duration: Optional[int]):
self.title = title
self.url = url
self.webpage_url = webpage_url
self.duration = duration
class GuildMusic:
def __init__(self):
self.queue: asyncio.Queue[Song] = asyncio.Queue()
self.current: Optional[Song] = None
self.play_next = asyncio.Event()
self.player_task: Optional[asyncio.Task] = None
music_states: Dict[int, GuildMusic] = {}
def get_guild_music(guild_id: int) -> GuildMusic:
st = music_states.get(guild_id)
if not st:
st = GuildMusic()
music_states[guild_id] = st
return st
def ytdlp_opts() -> dict:
opts = {
'format': 'bestaudio/best',
'noplaylist': True,
'quiet': True,
'no_warnings': True,
'default_search': 'auto',
'nocheckcertificate': True,
'source_address': '0.0.0.0',
}
try:
if os.path.exists(COOKIE_FILE):
opts['cookiefile'] = COOKIE_FILE
except Exception:
pass
return opts
async def yt_extract(query: str) -> Song:
loop = asyncio.get_event_loop()
def _extract() -> Tuple[str, str, str, Optional[int]]:
with yt_dlp.YoutubeDL(ytdlp_opts()) as ydl:
info = ydl.extract_info(query, download=False)
if 'entries' in info:
info = info['entries'][0]
title = info.get('title')
url = info.get('url')
webpage_url = info.get('webpage_url') or info.get('original_url') or query
duration = info.get('duration')
return title, url, webpage_url, duration
title, url, webpage_url, duration = await loop.run_in_executor(None, _extract)
return Song(title, url, webpage_url, duration)
FFMPEG_BEFORE_OPTS = '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5'
FFMPEG_OPTS = '-vn'
async def ensure_voice(ctx: commands.Context) -> Optional[discord.VoiceClient]:
if not ctx.author or not getattr(ctx.author, 'voice', None) or not ctx.author.voice:
await ctx.reply("❌ You need to be in a voice channel.")
return None
channel = ctx.author.voice.channel
if not channel:
await ctx.reply("❌ Can't find your voice channel.")
return None
if ctx.voice_client is None:
try:
return await channel.connect(timeout=30, reconnect=True, self_deaf=True)
except Exception as e:
await ctx.reply(f"❌ Failed to join: {e}")
return None
elif ctx.voice_client.channel != channel:
try:
await ctx.voice_client.move_to(channel)
except Exception as e:
await ctx.reply(f"❌ Failed to move: {e}")
return None
return ctx.voice_client
async def start_player_loop(ctx: commands.Context):
guild = ctx.guild
if not guild:
return
state = get_guild_music(guild.id)
if state.player_task and not state.player_task.done():
return
async def runner():
while True:
state.play_next.clear()
try:
song = await asyncio.wait_for(state.queue.get(), timeout=300)
except asyncio.TimeoutError:
# idle for 5 minutes, disconnect
if ctx.voice_client and ctx.voice_client.is_connected():
await ctx.voice_client.disconnect()
break
state.current = song
source = discord.FFmpegPCMAudio(song.url, before_options=FFMPEG_BEFORE_OPTS, options=FFMPEG_OPTS)
ctx.voice_client.play(source, after=lambda e: state.play_next.set())
await state.play_next.wait()
state.current = None
state.player_task = asyncio.create_task(runner())
@bot.hybrid_command(name='join', description='Join your voice channel')
async def cmd_join(ctx):
vc = await ensure_voice(ctx)
if vc:
await ctx.reply(f"✅ Joined {vc.channel.mention}")
@bot.hybrid_command(name='leave', description='Leave the voice channel')
async def cmd_leave(ctx):
if ctx.voice_client and ctx.voice_client.is_connected():
await ctx.voice_client.disconnect()
await ctx.reply("👋 Disconnected.")
else:
await ctx.reply(" Not connected.")
@bot.hybrid_command(name='play', description='Play a YouTube URL or search term')
async def cmd_play(ctx, *, query: str):
vc = await ensure_voice(ctx)
if not vc:
return
try:
song = await yt_extract(query)
state = get_guild_music(ctx.guild.id)
await state.queue.put(song)
await ctx.reply(f"▶️ Queued: **{song.title}**")
await start_player_loop(ctx)
except Exception as e:
await ctx.reply(f"❌ Failed to add: {e}")
@bot.hybrid_command(name='skip', description='Skip the current track')
async def cmd_skip(ctx):
if ctx.voice_client and ctx.voice_client.is_playing():
ctx.voice_client.stop()
await ctx.reply("⏭️ Skipped.")
else:
await ctx.reply(" Nothing is playing.")
@bot.hybrid_command(name='stop', description='Stop playback and clear the queue')
async def cmd_stop(ctx):
state = get_guild_music(ctx.guild.id)
# Clear queue
try:
while True:
state.queue.get_nowait()
except asyncio.QueueEmpty:
pass
if ctx.voice_client and ctx.voice_client.is_playing():
ctx.voice_client.stop()
await ctx.reply("⏹️ Stopped and cleared queue.")
@bot.hybrid_command(name='pause', description='Pause playback')
async def cmd_pause(ctx):
if ctx.voice_client and ctx.voice_client.is_playing():
ctx.voice_client.pause()
await ctx.reply("⏸️ Paused.")
else:
await ctx.reply(" Nothing is playing.")
@bot.hybrid_command(name='resume', description='Resume playback')
async def cmd_resume(ctx):
if ctx.voice_client and ctx.voice_client.is_paused():
ctx.voice_client.resume()
await ctx.reply("▶️ Resumed.")
else:
await ctx.reply(" Nothing is paused.")
@bot.hybrid_command(name='np', description='Show the currently playing track')
async def cmd_nowplaying(ctx):
state = get_guild_music(ctx.guild.id)
if state.current:
dur = f" ({state.current.duration//60}:{state.current.duration%60:02d})" if state.current.duration else ""
await ctx.reply(f"🎶 Now playing: **{state.current.title}**{dur}\n{state.current.webpage_url}")
else:
await ctx.reply(" Nothing is playing.")
@bot.hybrid_command(name='queue', description='Show queued tracks')
async def cmd_queue(ctx):
state = get_guild_music(ctx.guild.id)
if state.queue.empty():
await ctx.reply("🗒️ Queue is empty.")
return
# Snapshot queue without draining
items: List[Song] = []
try:
while True:
items.append(state.queue.get_nowait())
except asyncio.QueueEmpty:
pass
# Put them back
for s in items:
await state.queue.put(s)
desc = "\n".join([f"{i+1}. {s.title}" for i, s in enumerate(items[:10])])
more = state.queue.qsize() - len(items[:10])
if more > 0:
desc += f"\n...and {more} more"
embed = discord.Embed(title="🎼 Queue", description=desc, color=discord.Color.purple())
await ctx.reply(embed=embed)
@bot.hybrid_command(name='voicediag', description='Diagnose voice playback environment')
async def cmd_voicediag(ctx):
details = []
details.append(f"discord.py: {discord.__version__}")
# PyNaCl check
try:
import nacl
details.append(f"PyNaCl: {getattr(nacl, '__version__', 'present')}")
except Exception as e:
details.append(f"PyNaCl: missing ({e})")
# Opus check
try:
loaded = discord.opus.is_loaded()
details.append(f"Opus loaded: {loaded}")
except Exception as e:
details.append(f"Opus check failed: {e}")
# FFmpeg check
ff = "unknown"
try:
proc = await asyncio.create_subprocess_exec('ffmpeg', '-version', stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT)
out, _ = await proc.communicate()
ff = out.decode(errors='ignore').splitlines()[0][:120]
except FileNotFoundError:
ff = "not found"
except Exception as e:
ff = f"error: {e}"
details.append(f"ffmpeg: {ff}")
# Cookie file
details.append(f"cookie.txt present: {os.path.exists(COOKIE_FILE)} at {COOKIE_FILE}")
embed = discord.Embed(title='Voice Diagnostics', description='\n'.join(details), color=discord.Color.dark_grey())
await ctx.reply(embed=embed)
@bot.event
async def on_guild_join(guild):
"""Event triggered when the bot joins a server"""
@@ -714,11 +464,22 @@ async def init_database():
status VARCHAR(50) DEFAULT 'setup',
players JSON NOT NULL,
winner_team VARCHAR(255),
notification_message_id BIGINT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
finished_at TIMESTAMP NULL
)
''')
# Add notification_message_id column if it doesn't exist (for existing databases)
try:
await cursor.execute('''
ALTER TABLE games
ADD COLUMN notification_message_id BIGINT NULL
''')
except:
# Column already exists, ignore error
pass
# Create game_results table (MySQL syntax)
await cursor.execute('''
CREATE TABLE IF NOT EXISTS game_results (
@@ -872,6 +633,88 @@ async def reload_bot(ctx):
)
await ctx.send(embed=embed)
# Game notification channel
GAME_NOTIFICATION_CHANNEL_ID = 1432368177685332030
async def create_game_notification_embed(game_data: Dict, players_data: List[Dict]) -> discord.Embed:
"""Create an embed for game notifications"""
embed = discord.Embed(
title=f"🎮 {game_data['game_name']}",
description=f"**Type:** {game_data['game_type'].title()}\n**Status:** Setup Phase",
color=discord.Color.blue()
)
if not players_data:
embed.add_field(name="Players", value="No players yet", inline=False)
return embed
# Group players by team
teams = {}
for p in players_data:
team = p['team_name']
if team not in teams:
teams[team] = []
teams[team].append(p)
# Add team fields
for team_name, members in teams.items():
team_emoji = "🎖️" # Simple fallback since we don't have ctx here
avg_elo = sum(m['current_elo'] for m in members) / len(members) if members else 0
field_name = f"{team_emoji} {team_name} (avg {avg_elo:.0f})"
lines = []
for m in sorted(members, key=lambda mm: (-mm.get('t_level', 2), mm['username'])):
t_level = m.get('t_level', 2)
t_emoji = {1: "🔹", 2: "🔸", 3: "🔺"}.get(t_level, "🔹")
country = m.get('country')
country_text = f" [{country}]" if country else ""
lines.append(f"{t_emoji} {m['username']}{country_text} ({m['current_elo']})")
embed.add_field(name=field_name, value="\n".join(lines), inline=True)
embed.set_footer(text=f"Players: {len(players_data)} | Teams: {len(teams)}")
return embed
async def update_game_notification(game_data: Dict, players_data: List[Dict]):
"""Update or create game notification in the notification channel"""
try:
channel = bot.get_channel(GAME_NOTIFICATION_CHANNEL_ID)
if not channel:
return
embed = await create_game_notification_embed(game_data, players_data)
if game_data.get('notification_message_id'):
# Try to edit existing message
try:
message = await channel.fetch_message(game_data['notification_message_id'])
await message.edit(embed=embed)
except (discord.NotFound, discord.HTTPException):
# Message not found, create new one
message = await channel.send(embed=embed)
# Update DB with new message ID
async with db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute(
"UPDATE games SET notification_message_id = %s WHERE id = %s",
(message.id, game_data['id'])
)
else:
# Create new message
message = await channel.send(embed=embed)
# Store message ID in database
async with db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute(
"UPDATE games SET notification_message_id = %s WHERE id = %s",
(message.id, game_data['id'])
)
except Exception as e:
logging.warning(f"Failed to update game notification: {e}")
# HOI4 ELO Commands
@bot.hybrid_command(name='hoi4create', description='Create a new HOI4 game')
async def hoi4create(ctx, game_type: str, game_name: str):
@@ -900,6 +743,13 @@ async def hoi4create(ctx, game_type: str, game_name: str):
(game_name, game_type.lower(), '[]')
)
# Get the created game data for notification
await cursor.execute(
"SELECT * FROM games WHERE game_name = %s AND game_type = %s ORDER BY id DESC LIMIT 1",
(game_name, game_type.lower())
)
game_data = await cursor.fetchone()
embed = discord.Embed(
title="🎮 Game Created",
description=f"HOI4 {game_type.title()} game '{game_name}' has been created!",
@@ -912,16 +762,131 @@ async def hoi4create(ctx, game_type: str, game_name: str):
await ctx.send(embed=embed)
# Create notification in notification channel
if game_data:
await update_game_notification(dict(game_data), [])
except Exception as e:
await ctx.send(f"❌ Error creating game: {str(e)}")
@bot.hybrid_command(name='hoi4delete', description='Delete a game lobby')
async def hoi4delete(ctx, game_name: str):
"""Delete a game lobby that is in setup phase"""
try:
async with db_pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
# Get the game
await cursor.execute(
"SELECT * FROM games WHERE game_name = %s AND status = 'setup'",
(game_name,)
)
game = await cursor.fetchone()
if not game:
await ctx.send(f"❌ No active game found with name '{game_name}' in setup phase!")
return
# Delete the game
await cursor.execute(
"DELETE FROM games WHERE id = %s",
(game['id'],)
)
embed = discord.Embed(
title="🗑️ Game Deleted",
description=f"Game '{game_name}' has been deleted!",
color=discord.Color.red()
)
embed.add_field(name="Game Name", value=game_name, inline=True)
embed.add_field(name="Type", value=game['game_type'].title(), inline=True)
embed.set_footer(text=f"Deleted by {ctx.author}")
await ctx.send(embed=embed)
except Exception as e:
await ctx.send(f"❌ Error deleting game: {str(e)}")
@bot.hybrid_command(name='hoi4remove', description='Remove a player from an existing game')
async def hoi4remove(ctx, game_name: str, user: discord.Member):
"""Remove a player from an existing game"""
try:
async with db_pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
# Get the game
await cursor.execute(
"SELECT * FROM games WHERE game_name = %s AND status = 'setup'",
(game_name,)
)
game = await cursor.fetchone()
if not game:
await ctx.send(f"❌ No game found with name '{game_name}' in setup phase!")
return
# Parse existing players
players = json.loads(game['players']) if game['players'] else []
# Find and remove the player
player_found = False
new_players = []
for p in players:
if p['discord_id'] == user.id:
player_found = True
# Skip this player (don't add to new_players)
else:
new_players.append(p)
if not player_found:
await ctx.send(f"{user.display_name} is not in this game!")
return
# Update game with new player list
await cursor.execute(
"UPDATE games SET players = %s WHERE id = %s",
(json.dumps(new_players), game['id'])
)
embed = discord.Embed(
title="✅ Player Removed",
description=f"{user.display_name} has been removed from '{game_name}'!",
color=discord.Color.orange()
)
embed.add_field(name="Player", value=user.display_name, inline=True)
embed.add_field(name="Game", value=game_name, inline=True)
embed.add_field(name="Players Left", value=len(new_players), inline=True)
embed.set_footer(text=f"Removed by {ctx.author}")
await ctx.send(embed=embed)
# Update notification in notification channel
await update_game_notification(dict(game), new_players)
except Exception as e:
await ctx.send(f"❌ Error removing player: {str(e)}")
@bot.hybrid_command(name='hoi4setup', description='Add a player to an existing game')
async def hoi4setup(ctx, game_name: str, user: discord.Member, team_name: str, t_level: int, country: Optional[str] = None):
"""Add a player to an existing game"""
async def hoi4setup(ctx, game_name: str, user: discord.Member, team_name: str, t_level: int, country: Optional[str] = None, modifier: Optional[str] = None):
"""Add a player to an existing game. Use modifier='--force' to bypass MP ban."""
if t_level not in [1, 2, 3]:
await ctx.send("❌ T-Level must be 1, 2, or 3")
return
# Check for MP ban role (unless --force is used)
MP_BAN_ROLE_ID = 1432368177052127353
if modifier != "--force":
mp_ban_role = discord.utils.get(user.roles, id=MP_BAN_ROLE_ID)
if mp_ban_role:
embed = discord.Embed(
title="🚫 Player Banned",
description=f"{user.display_name} is currently banned from multiplayer games!",
color=discord.Color.red()
)
embed.add_field(name="Banned Player", value=user.mention, inline=True)
embed.add_field(name="Ban Role", value=mp_ban_role.name, inline=True)
embed.set_footer(text="Contact an administrator if this is an error")
await ctx.send(embed=embed)
return
try:
async with db_pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
@@ -983,6 +948,9 @@ async def hoi4setup(ctx, game_name: str, user: discord.Member, team_name: str, t
await ctx.send(embed=embed)
# Update notification in notification channel
await update_game_notification(dict(game), players)
except Exception as e:
await ctx.send(f"❌ Error adding player: {str(e)}")
@@ -1178,6 +1146,34 @@ async def hoi4end(ctx, game_name: str, winner_team: str):
await ctx.send(embed=embed)
# Post final game result to notification channel
try:
channel = bot.get_channel(GAME_NOTIFICATION_CHANNEL_ID)
if channel:
final_embed = discord.Embed(
title=f"🏁 Game Finished: {game_name}",
description=f"**Result:** {'Draw' if is_draw else f'{winner_team} Victory'}",
color=discord.Color.gold() if not is_draw else discord.Color.orange()
)
final_embed.add_field(name="Game Type", value=game['game_type'].title(), inline=True)
final_embed.add_field(name="Players", value=len(players), inline=True)
final_embed.add_field(name="Teams", value=len(teams), inline=True)
# Add team results
for team, team_changes in teams_results.items():
avg_change = sum(c['elo_change'] for c in team_changes) / len(team_changes)
emoji = "🏆" if team == winner_team and not is_draw else "🤝" if is_draw else "💔"
final_embed.add_field(
name=f"{emoji} {team}",
value=f"{len(team_changes)} players\nAvg ELO change: {avg_change:+.1f}",
inline=True
)
await channel.send(embed=final_embed)
except Exception as e:
logging.warning(f"Failed to post final game notification: {e}")
except Exception as e:
await ctx.send(f"❌ Error ending game: {str(e)}")
@@ -1598,14 +1594,14 @@ async def hoi4leaderboard(ctx, game_type: Optional[str] = "standard", limit: Opt
async def on_command_error(ctx, error):
"""Handles command errors"""
if isinstance(error, commands.CheckFailure):
await _safe_send(ctx, "❌ You don't have permission to use this command!")
await ctx.send("❌ You don't have permission to use this command!")
elif isinstance(error, commands.CommandNotFound):
# Silently ignore command not found errors
pass
elif isinstance(error, commands.MissingRequiredArgument):
await _safe_send(ctx, f"❌ Missing arguments! Command: `{ctx.command}`")
await ctx.send(f"❌ Missing arguments! Command: `{ctx.command}`")
elif isinstance(error, commands.BadArgument):
await _safe_send(ctx, "❌ Invalid argument!")
await ctx.send("❌ Invalid argument!")
else:
# Log detailed error information
print(f"❌ Unknown error in command '{ctx.command}': {type(error).__name__}: {error}")
@@ -1614,34 +1610,9 @@ async def on_command_error(ctx, error):
# Send detailed error to user if owner
if ctx.author.id == OWNER_ID:
await _safe_send(ctx, f"❌ **Error Details (Owner only):**\n```python\n{type(error).__name__}: {str(error)[:1800]}\n```")
await ctx.send(f"❌ **Error Details (Owner only):**\n```python\n{type(error).__name__}: {str(error)[:1800]}\n```")
else:
await _safe_send(ctx, "❌ An unknown error occurred!")
async def _safe_send(ctx: commands.Context, content: str = None, **kwargs):
"""Send a message safely for both message and slash contexts, even if the interaction timed out."""
try:
if getattr(ctx, 'interaction', None):
# If we have an interaction, try normal response first
interaction = ctx.interaction
if not interaction.response.is_done():
await interaction.response.send_message(content=content, **kwargs)
return
# Otherwise use followup
await interaction.followup.send(content=content, **kwargs)
return
# Fallback to classic send
await ctx.send(content=content, **kwargs)
except discord.NotFound:
# Interaction unknown/expired, try channel.send
try:
channel = getattr(ctx, 'channel', None)
if channel:
await channel.send(content=content, **kwargs)
except Exception:
pass
except Exception:
pass
await ctx.send("❌ An unknown error occurred!")
async def main():
"""Main function to start the bot"""

View File

@@ -1,7 +1,7 @@
discord.py==2.3.2
python-dotenv==1.0.0
aiohttp==3.9.1
asyncpg==0.29.0
psycopg2-binary==2.9.9
aiomysql==0.2.0
PyMySQL==1.1.0
yt-dlp==2025.1.26
PyNaCl==1.5.0