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 \ RUN apt-get update && apt-get install -y \
libffi-dev \ libffi-dev \
libnacl-dev \ libnacl-dev \
libopus0 \
ffmpeg \
python3-dev \ python3-dev \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*

537
app.py
View File

@@ -7,8 +7,7 @@ from dotenv import load_dotenv
import aiomysql import aiomysql
import json import json
from datetime import datetime from datetime import datetime
from typing import Optional, List, Dict, Tuple from typing import Optional, List, Dict
import yt_dlp
# Load environment variables # Load environment variables
load_dotenv() load_dotenv()
@@ -79,7 +78,6 @@ intents = discord.Intents.default()
intents.message_content = True intents.message_content = True
intents.guilds = True intents.guilds = True
intents.members = True intents.members = True
intents.voice_states = True
bot = commands.Bot(command_prefix='!', intents=intents) bot = commands.Bot(command_prefix='!', intents=intents)
@@ -93,12 +91,6 @@ async def on_ready():
# Initialize database # Initialize database
await init_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 # Set bot status
await bot.change_presence( await bot.change_presence(
@@ -113,248 +105,6 @@ async def on_ready():
except Exception as e: except Exception as e:
print(f'Failed to sync commands: {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 @bot.event
async def on_guild_join(guild): async def on_guild_join(guild):
"""Event triggered when the bot joins a server""" """Event triggered when the bot joins a server"""
@@ -714,11 +464,22 @@ async def init_database():
status VARCHAR(50) DEFAULT 'setup', status VARCHAR(50) DEFAULT 'setup',
players JSON NOT NULL, players JSON NOT NULL,
winner_team VARCHAR(255), winner_team VARCHAR(255),
notification_message_id BIGINT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
finished_at TIMESTAMP NULL 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) # Create game_results table (MySQL syntax)
await cursor.execute(''' await cursor.execute('''
CREATE TABLE IF NOT EXISTS game_results ( CREATE TABLE IF NOT EXISTS game_results (
@@ -872,6 +633,88 @@ async def reload_bot(ctx):
) )
await ctx.send(embed=embed) 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 # HOI4 ELO Commands
@bot.hybrid_command(name='hoi4create', description='Create a new HOI4 game') @bot.hybrid_command(name='hoi4create', description='Create a new HOI4 game')
async def hoi4create(ctx, game_type: str, game_name: str): async def hoi4create(ctx, game_type: str, game_name: str):
@@ -899,6 +742,13 @@ async def hoi4create(ctx, game_type: str, game_name: str):
"INSERT INTO games (game_name, game_type, status, players) VALUES (%s, %s, 'setup', %s)", "INSERT INTO games (game_name, game_type, status, players) VALUES (%s, %s, 'setup', %s)",
(game_name, game_type.lower(), '[]') (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( embed = discord.Embed(
title="🎮 Game Created", title="🎮 Game Created",
@@ -912,16 +762,131 @@ async def hoi4create(ctx, game_type: str, game_name: str):
await ctx.send(embed=embed) await ctx.send(embed=embed)
# Create notification in notification channel
if game_data:
await update_game_notification(dict(game_data), [])
except Exception as e: except Exception as e:
await ctx.send(f"❌ Error creating game: {str(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') @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): 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""" """Add a player to an existing game. Use modifier='--force' to bypass MP ban."""
if t_level not in [1, 2, 3]: if t_level not in [1, 2, 3]:
await ctx.send("❌ T-Level must be 1, 2, or 3") await ctx.send("❌ T-Level must be 1, 2, or 3")
return 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: try:
async with db_pool.acquire() as conn: async with db_pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor: 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) await ctx.send(embed=embed)
# Update notification in notification channel
await update_game_notification(dict(game), players)
except Exception as e: except Exception as e:
await ctx.send(f"❌ Error adding player: {str(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) 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: except Exception as e:
await ctx.send(f"❌ Error ending game: {str(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): async def on_command_error(ctx, error):
"""Handles command errors""" """Handles command errors"""
if isinstance(error, commands.CheckFailure): 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): elif isinstance(error, commands.CommandNotFound):
# Silently ignore command not found errors # Silently ignore command not found errors
pass pass
elif isinstance(error, commands.MissingRequiredArgument): 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): elif isinstance(error, commands.BadArgument):
await _safe_send(ctx, "❌ Invalid argument!") await ctx.send("❌ Invalid argument!")
else: else:
# Log detailed error information # Log detailed error information
print(f"❌ Unknown error in command '{ctx.command}': {type(error).__name__}: {error}") 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 # Send detailed error to user if owner
if ctx.author.id == OWNER_ID: 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: else:
await _safe_send(ctx, "❌ An unknown error occurred!") await ctx.send("❌ 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
async def main(): async def main():
"""Main function to start the bot""" """Main function to start the bot"""

View File

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