first commit
This commit is contained in:
104
bot/bot.py
104
bot/bot.py
@@ -19,6 +19,7 @@ import bcrypt
|
||||
import pickle
|
||||
|
||||
from bot.command_registry import get_handler, list_registered
|
||||
import bot.prefix_command_registry as prefix_registry
|
||||
import ai.parser as ai_parser
|
||||
|
||||
DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN")
|
||||
@@ -32,6 +33,7 @@ CACHE_FILE = "/app/user_cache.pkl"
|
||||
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
intents.voice_states = True
|
||||
|
||||
client = discord.Client(intents=intents)
|
||||
|
||||
@@ -181,7 +183,7 @@ async def handleLoginStep(message):
|
||||
|
||||
async def sendHelpMessage(message):
|
||||
registered = list_registered()
|
||||
help_msg = f"**Available Modules:**\n{chr(10).join(f'- {m}' for m in registered) if registered else '- No modules registered'}\n\nJust talk naturally and I'll help you out!"
|
||||
help_msg = f"**Available Modules:**\n{chr(10).join(f'- {m}' for m in registered) if registered else '- No modules registered'}\n\nJust talk naturally and I'll try to help you out!"
|
||||
await message.channel.send(help_msg)
|
||||
|
||||
|
||||
@@ -190,41 +192,71 @@ async def routeCommand(message):
|
||||
session = user_sessions[discord_id]
|
||||
user_input = message.content.lower()
|
||||
|
||||
if "help" in user_input or "what can i say" in user_input:
|
||||
await sendHelpMessage(message)
|
||||
return
|
||||
# if "help" in user_input in user_input:
|
||||
# await sendHelpMessage(message)
|
||||
# return
|
||||
if isinstance(message.channel, discord.DMChannel):
|
||||
ADMIN_IDS = [
|
||||
int(uid.strip())
|
||||
for uid in os.getenv("ADMIN_USER_IDS", "").split(",")
|
||||
if uid.strip()
|
||||
]
|
||||
if discord_id in ADMIN_IDS:
|
||||
async with message.channel.typing():
|
||||
history = message_history.get(discord_id, [])
|
||||
parsed = ai_parser.parse(
|
||||
message.content, "command_parser", history=history
|
||||
)
|
||||
|
||||
async with message.channel.typing():
|
||||
history = message_history.get(discord_id, [])
|
||||
parsed = ai_parser.parse(message.content, "command_parser", history=history)
|
||||
if discord_id not in message_history:
|
||||
message_history[discord_id] = []
|
||||
message_history[discord_id].append((message.content, parsed))
|
||||
message_history[discord_id] = message_history[discord_id][-5:]
|
||||
|
||||
if discord_id not in message_history:
|
||||
message_history[discord_id] = []
|
||||
message_history[discord_id].append((message.content, parsed))
|
||||
message_history[discord_id] = message_history[discord_id][-5:]
|
||||
if "needs_clarification" in parsed:
|
||||
await message.channel.send(
|
||||
f"I'm not quite sure what you mean. {parsed['needs_clarification']}"
|
||||
)
|
||||
return
|
||||
|
||||
if "needs_clarification" in parsed:
|
||||
await message.channel.send(
|
||||
f"I'm not quite sure what you mean. {parsed['needs_clarification']}"
|
||||
)
|
||||
return
|
||||
if "error" in parsed:
|
||||
await message.channel.send(
|
||||
f"I had trouble understanding that: {parsed['error']}"
|
||||
)
|
||||
return
|
||||
|
||||
if "error" in parsed:
|
||||
await message.channel.send(
|
||||
f"I had trouble understanding that: {parsed['error']}"
|
||||
)
|
||||
return
|
||||
interaction_type = parsed.get("interaction_type")
|
||||
handler = get_handler(interaction_type)
|
||||
|
||||
interaction_type = parsed.get("interaction_type")
|
||||
handler = get_handler(interaction_type)
|
||||
if handler:
|
||||
await handler(message, session, parsed)
|
||||
else:
|
||||
registered = ", ".join(list_registered()) or "none"
|
||||
await message.channel.send(
|
||||
f"Unknown command type '{interaction_type}'. Registered modules: {registered}"
|
||||
)
|
||||
else:
|
||||
await message.channel.send("You don't have permission to use this bot.")
|
||||
|
||||
if handler:
|
||||
await handler(message, session, parsed)
|
||||
else:
|
||||
registered = ", ".join(list_registered()) or "none"
|
||||
await message.channel.send(
|
||||
f"Unknown command type '{interaction_type}'. Registered modules: {registered}"
|
||||
)
|
||||
elif hasattr(message.channel, "guild") and message.channel.guild is not None:
|
||||
# we are in a guild channel, now we need to determine which
|
||||
if message.channel.id == int(os.getenv("COMMAND_CHANNEL_ID", 0)):
|
||||
content = message.content.strip()
|
||||
if not content.startswith("!"):
|
||||
return
|
||||
|
||||
parts = content[1:].split()
|
||||
if not parts:
|
||||
return
|
||||
|
||||
command_name = parts[0].lower()
|
||||
args = parts[1:]
|
||||
|
||||
handler = prefix_registry.get_prefix_handler(command_name)
|
||||
if handler:
|
||||
await handler(message, args)
|
||||
else:
|
||||
await message.channel.send(f"Unknown command '{command_name}'.")
|
||||
|
||||
|
||||
@client.event
|
||||
@@ -267,4 +299,16 @@ async def beforeBackgroundLoop():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from bot.commands import load_commands
|
||||
|
||||
load_commands()
|
||||
|
||||
# Optional: load general plugins
|
||||
try:
|
||||
from plugins import load_plugins
|
||||
|
||||
load_plugins()
|
||||
except ImportError:
|
||||
pass # plugins folder not present
|
||||
|
||||
client.run(DISCORD_BOT_TOKEN)
|
||||
|
||||
26
bot/commands/__init__.py
Normal file
26
bot/commands/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import os
|
||||
import importlib.util
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def load_commands():
|
||||
"""
|
||||
Automatically discover and load command modules from bot/commands/.
|
||||
|
||||
Each module should call register_module() during import.
|
||||
"""
|
||||
commands_dir = os.path.dirname(__file__)
|
||||
for filename in os.listdir(commands_dir):
|
||||
if filename.endswith('.py') and filename != '__init__.py':
|
||||
module_name = filename[:-3] # remove .py
|
||||
module_path = os.path.join(commands_dir, filename)
|
||||
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
logger.info(f"Loaded command module: {module_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load command module {module_name}: {e}")
|
||||
67
bot/commands/music.py
Normal file
67
bot/commands/music.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
music.py - Prefix commands for music playback.
|
||||
|
||||
Register:
|
||||
!play <query> - Download and play a song (adds to queue if already playing)
|
||||
!skip - Skip current song, play next
|
||||
!stop - Stop playback, clear queue, leave voice
|
||||
!queue - Show current queue with details
|
||||
!nowplaying - Show currently playing track with remaining time
|
||||
"""
|
||||
|
||||
from bot.prefix_command_registry import register_prefix_command
|
||||
from bot import voice_manager
|
||||
|
||||
|
||||
async def handle_play(message, args):
|
||||
query = " ".join(args)
|
||||
if not query:
|
||||
await message.channel.send("Usage: `!play <song name or URL>`")
|
||||
return
|
||||
|
||||
await voice_manager.resolve_and_enqueue(message, query)
|
||||
|
||||
|
||||
async def handle_skip(message, args):
|
||||
guild_id = message.guild.id
|
||||
vc = voice_manager.connections.get(guild_id)
|
||||
if not vc:
|
||||
await message.channel.send("Not playing anything.")
|
||||
return
|
||||
|
||||
if not vc.is_playing():
|
||||
await message.channel.send("Not playing anything.")
|
||||
return
|
||||
|
||||
voice_manager.skip(guild_id)
|
||||
await message.channel.send("Skipped.")
|
||||
|
||||
|
||||
async def handle_stop(message, args):
|
||||
guild_id = message.guild.id
|
||||
vc = voice_manager.connections.get(guild_id)
|
||||
if not vc:
|
||||
await message.channel.send("Not playing anything.")
|
||||
return
|
||||
|
||||
voice_manager.stop(guild_id)
|
||||
await message.channel.send("Stopped and left voice channel.")
|
||||
|
||||
|
||||
async def handle_queue(message, args):
|
||||
guild_id = message.guild.id
|
||||
output = voice_manager.get_queue(guild_id)
|
||||
await message.channel.send(output)
|
||||
|
||||
|
||||
async def handle_nowplaying(message, args):
|
||||
guild_id = message.guild.id
|
||||
output = voice_manager.get_now_playing(guild_id)
|
||||
await message.channel.send(output)
|
||||
|
||||
|
||||
register_prefix_command("play", handle_play)
|
||||
register_prefix_command("skip", handle_skip)
|
||||
register_prefix_command("stop", handle_stop)
|
||||
register_prefix_command("queue", handle_queue)
|
||||
register_prefix_command("nowplaying", handle_nowplaying)
|
||||
40
bot/prefix_command_registry.py
Normal file
40
bot/prefix_command_registry.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
prefix_command_registry.py - Module registration for prefix commands
|
||||
|
||||
Same pattern as command_registry.py, but for !command-style handlers
|
||||
in the command text channel. No AI, no session, no auth.
|
||||
|
||||
Handler signature: async def handler(message, args) -> None
|
||||
message: discord.Message object
|
||||
args: list of strings (everything after the command name)
|
||||
"""
|
||||
|
||||
PREFIX_COMMANDS = {}
|
||||
|
||||
|
||||
def register_prefix_command(name, handler):
|
||||
"""
|
||||
Register a handler for a prefix command.
|
||||
|
||||
Args:
|
||||
name: String key (e.g., 'play', 'skip', 'queue')
|
||||
handler: Async function(message, args) -> None
|
||||
|
||||
Example:
|
||||
async def handle_play(message, args):
|
||||
query = " ".join(args)
|
||||
# ... search YouTube, join voice, play ...
|
||||
|
||||
register_prefix_command('play', handle_play)
|
||||
"""
|
||||
PREFIX_COMMANDS[name] = handler
|
||||
|
||||
|
||||
def get_prefix_handler(name):
|
||||
"""Get the registered handler for a prefix command name."""
|
||||
return PREFIX_COMMANDS.get(name)
|
||||
|
||||
|
||||
def list_prefix_commands():
|
||||
"""List all registered prefix command names."""
|
||||
return list(PREFIX_COMMANDS.keys())
|
||||
295
bot/voice_manager.py
Normal file
295
bot/voice_manager.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""
|
||||
voice_manager.py - Voice connection, queue, and playback for Discord music.
|
||||
|
||||
Uses yt-dlp to download bestaudio to disk, then plays via FFmpegPCMAudio.
|
||||
Files persist indefinitely in AUDIO_DIR (acts as a cache).
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import time
|
||||
import yt_dlp
|
||||
import discord
|
||||
|
||||
AUDIO_DIR = "/app/audio_cache"
|
||||
os.makedirs(AUDIO_DIR, exist_ok=True)
|
||||
|
||||
connections = {}
|
||||
queues = {}
|
||||
playing = {}
|
||||
download_locks = {}
|
||||
retries = {}
|
||||
command_channels = {}
|
||||
|
||||
|
||||
def _get_download_lock(guild_id):
|
||||
if guild_id not in download_locks:
|
||||
download_locks[guild_id] = asyncio.Lock()
|
||||
return download_locks[guild_id]
|
||||
|
||||
|
||||
def _format_duration(seconds):
|
||||
if not seconds:
|
||||
return "??:??"
|
||||
m, s = divmod(int(seconds), 60)
|
||||
return f"{m}:{s:02d}"
|
||||
|
||||
|
||||
def _get_cached_path(video_id):
|
||||
for ext in [".webm", ".opus", ".m4a", ".mp3", ".ogg", ".flac"]:
|
||||
path = os.path.join(AUDIO_DIR, f"{video_id}{ext}")
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _download_sync(query):
|
||||
ydl_opts = {
|
||||
"format": "bestaudio",
|
||||
"outtmpl": os.path.join(AUDIO_DIR, "%(id)s.%(ext)s"),
|
||||
"no-playlist": True,
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"socket_timeout": 30,
|
||||
}
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(query, download=False)
|
||||
video_id = info.get("id")
|
||||
title = info.get("title", "Unknown")
|
||||
duration = info.get("duration")
|
||||
artist = info.get("artist") or info.get("creator") or info.get("channel", "")
|
||||
|
||||
cached = _get_cached_path(video_id)
|
||||
if cached:
|
||||
return cached, title, duration, artist
|
||||
|
||||
ydl.download([query])
|
||||
|
||||
filepath = _get_cached_path(video_id)
|
||||
if not filepath:
|
||||
for f in os.listdir(AUDIO_DIR):
|
||||
if f.startswith(video_id):
|
||||
filepath = os.path.join(AUDIO_DIR, f)
|
||||
break
|
||||
|
||||
if not filepath:
|
||||
raise RuntimeError(f"Downloaded file not found for {video_id}")
|
||||
|
||||
return filepath, title, duration, artist
|
||||
|
||||
|
||||
async def join_voice(message):
|
||||
guild_id = message.guild.id
|
||||
|
||||
if guild_id in connections:
|
||||
return connections[guild_id]
|
||||
|
||||
voice_state = message.author.voice
|
||||
if not voice_state:
|
||||
return None
|
||||
|
||||
channel = voice_state.channel
|
||||
try:
|
||||
vc = await channel.connect()
|
||||
connections[guild_id] = vc
|
||||
return vc
|
||||
except Exception as e:
|
||||
print(f"Error joining voice channel: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def resolve_and_enqueue(message, query):
|
||||
guild_id = message.guild.id
|
||||
command_channels[guild_id] = message.channel
|
||||
|
||||
if guild_id not in queues:
|
||||
queues[guild_id] = []
|
||||
|
||||
vc = connections.get(guild_id)
|
||||
if not vc:
|
||||
vc = await join_voice(message)
|
||||
if not vc:
|
||||
await message.channel.send("You need to be in a voice channel first.")
|
||||
return None
|
||||
|
||||
lock = _get_download_lock(guild_id)
|
||||
|
||||
announce_msg = await message.channel.send("Preparing...")
|
||||
|
||||
def do_download():
|
||||
return _download_sync(query)
|
||||
|
||||
try:
|
||||
async with lock:
|
||||
position = len(queues[guild_id]) + 1
|
||||
filepath, title, duration, artist = await asyncio.to_thread(do_download)
|
||||
|
||||
if guild_id not in retries:
|
||||
retries[guild_id] = {}
|
||||
retries[guild_id][filepath] = 0
|
||||
|
||||
queues[guild_id].append(
|
||||
{
|
||||
"filepath": filepath,
|
||||
"title": title,
|
||||
"duration": duration,
|
||||
"artist": artist,
|
||||
}
|
||||
)
|
||||
|
||||
if guild_id not in playing or not playing[guild_id]:
|
||||
await announce_msg.delete()
|
||||
await play_next(guild_id)
|
||||
else:
|
||||
await announce_msg.edit(
|
||||
content=f'Added "{title}" to queue at position #{position}.'
|
||||
)
|
||||
|
||||
return queues[guild_id][-1]
|
||||
|
||||
except Exception as e:
|
||||
print(f"Download error for '{query}': {e}")
|
||||
await message.channel.send(f'Couldn\'t download "{query}".')
|
||||
return None
|
||||
|
||||
|
||||
# UNUSED: kept for reference — play_next uses its own inline _after closure
|
||||
# def _after_callback(guild_id):
|
||||
# playing.pop(guild_id, None)
|
||||
#
|
||||
# loop = asyncio.get_event_loop()
|
||||
# loop.call_soon_threadsafe(lambda: asyncio.ensure_future(_play_next_async(guild_id)))
|
||||
#
|
||||
#
|
||||
# async def _play_next_async(guild_id):
|
||||
# await play_next(guild_id)
|
||||
|
||||
|
||||
async def play_next(guild_id):
|
||||
vc = connections.get(guild_id)
|
||||
if not vc:
|
||||
return
|
||||
|
||||
if not vc.is_connected():
|
||||
connections.pop(guild_id, None)
|
||||
return
|
||||
|
||||
if vc.is_playing():
|
||||
return
|
||||
|
||||
queue = queues.get(guild_id, [])
|
||||
if not queue:
|
||||
return
|
||||
|
||||
item = queue.pop(0)
|
||||
filepath = item["filepath"]
|
||||
title = item["title"]
|
||||
duration = item["duration"]
|
||||
artist = item["artist"]
|
||||
|
||||
channel = command_channels.get(guild_id)
|
||||
|
||||
def _after(error):
|
||||
if error:
|
||||
print(f"Playback error: {error}")
|
||||
playing.pop(guild_id, None)
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.call_soon_threadsafe(lambda: asyncio.ensure_future(play_next(guild_id)))
|
||||
|
||||
try:
|
||||
source = discord.FFmpegPCMAudio(filepath)
|
||||
vc.play(source, after=_after)
|
||||
playing[guild_id] = {
|
||||
"started_at": time.time(),
|
||||
"duration": duration,
|
||||
"title": title,
|
||||
"artist": artist,
|
||||
}
|
||||
|
||||
display = title
|
||||
if artist:
|
||||
display = f"{title} ({artist})"
|
||||
|
||||
if channel:
|
||||
await channel.send(f"Now playing: {display}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Playback error for {filepath}: {e}")
|
||||
retry_count = retries.get(guild_id, {}).get(filepath, 0)
|
||||
if retry_count < 1:
|
||||
if guild_id not in retries:
|
||||
retries[guild_id] = {}
|
||||
retries[guild_id][filepath] = retry_count + 1
|
||||
queue.insert(0, item)
|
||||
if channel:
|
||||
await channel.send(f"Playback failed, retrying: {title}")
|
||||
await play_next(guild_id)
|
||||
else:
|
||||
if guild_id in retries:
|
||||
retries[guild_id].pop(filepath, None)
|
||||
if channel:
|
||||
await channel.send(f'Couldn\'t play "{title}", skipping.')
|
||||
await play_next(guild_id)
|
||||
|
||||
|
||||
def skip(guild_id):
|
||||
vc = connections.get(guild_id)
|
||||
if vc and vc.is_playing():
|
||||
vc.stop()
|
||||
|
||||
|
||||
def stop(guild_id):
|
||||
vc = connections.pop(guild_id, None)
|
||||
if vc:
|
||||
vc.stop()
|
||||
asyncio.ensure_future(vc.disconnect())
|
||||
queues.pop(guild_id, None)
|
||||
playing.pop(guild_id, None)
|
||||
retries.pop(guild_id, None)
|
||||
|
||||
|
||||
def get_queue(guild_id):
|
||||
queue = queues.get(guild_id, [])
|
||||
current = playing.get(guild_id)
|
||||
|
||||
lines = []
|
||||
|
||||
if current:
|
||||
duration = current.get("duration", 0)
|
||||
elapsed = time.time() - current.get("started_at", time.time())
|
||||
remaining = max(0, duration - elapsed)
|
||||
title = current.get("title", "Unknown")
|
||||
artist = current.get("artist", "")
|
||||
display = f"{title} ({artist})" if artist else title
|
||||
lines.append(
|
||||
f"**Now playing:** {display} ({_format_duration(remaining)} remaining)"
|
||||
)
|
||||
else:
|
||||
lines.append("Nothing currently playing.")
|
||||
|
||||
if queue:
|
||||
lines.append("")
|
||||
lines.append("**Up next:**")
|
||||
for i, item in enumerate(queue, 1):
|
||||
title = item.get("title", "Unknown")
|
||||
artist = item.get("artist", "")
|
||||
duration = _format_duration(item.get("duration"))
|
||||
display = f"{title} ({artist})" if artist else title
|
||||
lines.append(f"{i}. {display} ({duration})")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_now_playing(guild_id):
|
||||
current = playing.get(guild_id)
|
||||
if not current:
|
||||
return "Nothing currently playing."
|
||||
|
||||
duration = current.get("duration", 0)
|
||||
elapsed = time.time() - current.get("started_at", time.time())
|
||||
remaining = max(0, duration - elapsed)
|
||||
title = current.get("title", "Unknown")
|
||||
artist = current.get("artist", "")
|
||||
display = f"{title} ({artist})" if artist else title
|
||||
|
||||
return f"Currently playing: **{display}**\n{_format_duration(remaining)} remaining"
|
||||
Reference in New Issue
Block a user