exec("CREATE TABLE IF NOT EXISTS bot_logs (id INT AUTO_INCREMENT PRIMARY KEY, message TEXT, log_level VARCHAR(50), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"); $db->exec("CREATE TABLE IF NOT EXISTS bot_settings (setting_key VARCHAR(255) PRIMARY KEY, setting_value TEXT)"); $db->exec("CREATE TABLE IF NOT EXISTS bot_alarms (id INT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(255) UNIQUE, guild_id VARCHAR(255), channel_id VARCHAR(255), alarm_time TIME, audio_url TEXT, is_active TINYINT(1) DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"); // Default settings if not present $stmt = $db->prepare("INSERT IGNORE INTO bot_settings (setting_key, setting_value) VALUES (?, ?)"); $stmt->execute(['sahur_time', '03:00']); $stmt->execute(['voice_channel_id', '1457687430189682781']); $stmt->execute(['sahur_source', 'sahur.mp3']); $stmt->execute(['bot_token', '']); // User should set this manually or it might be already set $settings = $db->query("SELECT setting_key, setting_value FROM bot_settings")->fetchAll(PDO::FETCH_KEY_PAIR); $token = $settings['bot_token'] ?? ''; if (empty($token)) { // Try to get from env if missing in DB $token = getenv('BOT_TOKEN') ?: ''; } if (empty($token)) { echo "Error: Bot token is missing. Please set it in bot_settings table or as BOT_TOKEN env var.\n"; exit(1); } $discord = new Discord([ 'token' => $token, 'intents' => Intents::getDefaultIntents() | Intents::GUILD_VOICE_STATES | Intents::MESSAGE_CONTENT, ]); $voiceClient = null; $joiningGuilds = []; $ffmpegPath = trim((string)shell_exec("command -v ffmpeg")); if (!$ffmpegPath) { echo "Warning: ffmpeg not found in PATH. Voice playback might fail.\n"; } /** * Safely join a voice channel by ensuring any existing connection is closed first. */ function safeJoin(Discord $discord, $channel, $interaction = null, $onSuccess = null) { global $joiningGuilds; $guildId = $channel->guild_id; echo "DEBUG: safeJoin called for guild $guildId, channel {$channel->name}\n"; // Check if a join is already in progress if (isset($joiningGuilds[$guildId]) && $joiningGuilds[$guildId] === true) { logToDb("Join already in progress for guild $guildId"); if ($interaction) { try { $interaction->updateOriginalResponse(MessageBuilder::new()->setContent("⏳ A join request is already in progress... please wait.")); } catch (\Throwable $e) {} } return; } $vc = $discord->getVoiceClient($guildId); $isReady = $vc ? (method_exists($vc, 'isReady') ? $vc->isReady() : ($vc->ready ?? false)) : false; // If we are already in the correct channel AND it's ready, just proceed if ($vc && (string)$vc->channel->id === (string)$channel->id && $isReady) { logToDb("Already in channel {$channel->name} and ready."); if ($onSuccess) $onSuccess($vc); return; } // Mark as joining $joiningGuilds[$guildId] = true; if ($interaction) { safeUpdate($interaction, MessageBuilder::new()->setContent("⏳ Initializing voice connection...")); } $doJoin = function() use ($discord, $channel, $interaction, $onSuccess, $guildId) { global $joiningGuilds; // Final sanity check: if Discord still thinks we have a VC, try to close it again $existingVc = $discord->getVoiceClient($guildId); if ($existingVc) { logToDb("Still have an existing VC for guild $guildId during doJoin. Attempting force close."); try { $existingVc->close(); } catch (\Throwable $e) {} } logToDb("Executing joinVoiceChannel for guild $guildId, channel " . $channel->name); $timeoutTimer = $discord->getLoop()->addTimer(45.0, function() use ($guildId, $interaction, $discord) { global $joiningGuilds; if (isset($joiningGuilds[$guildId]) && $joiningGuilds[$guildId] === true) { logToDb("Safety timeout: Clearing stuck join state for guild $guildId"); unset($joiningGuilds[$guildId]); // Try to clean up the potentially stuck VC $vc = $discord->getVoiceClient($guildId); if ($vc) { try { $vc->close(); } catch (\Throwable $e) {} } if ($interaction) { safeUpdate($interaction, MessageBuilder::new()->setContent("⚠️ Joining timed out. Discord's voice servers are slow or unreachable. Try `/out` and try again.")); } } }); $discord->joinVoiceChannel($channel, false, false)->then(function (VoiceClient $vc) use ($onSuccess, $guildId, $discord, $interaction, $timeoutTimer) { global $joiningGuilds; logToDb("Joined channel " . $vc->channel->name . ". Waiting for ready state..."); $checkReady = function() use ($vc, $onSuccess, $guildId, $discord, $timeoutTimer) { global $joiningGuilds; $isReady = method_exists($vc, 'isReady') ? $vc->isReady() : ($vc->ready ?? false); if ($isReady) { logToDb("VoiceClient is ready for " . $vc->channel->name); $discord->getLoop()->cancelTimer($timeoutTimer); unset($joiningGuilds[$guildId]); if ($onSuccess) $onSuccess($vc); return true; } return false; }; if (!$checkReady()) { $vc->once('ready', function() use ($checkReady) { $checkReady(); }); // Backup check every 2 seconds $periodic = $discord->getLoop()->addPeriodicTimer(2.0, function($timer) use ($checkReady, $discord) { if ($checkReady()) { $discord->getLoop()->cancelTimer($timer); } }); // Ensure periodic timer is cancelled if timeout hits $discord->getLoop()->addTimer(46.0, function() use ($discord, $periodic) { $discord->getLoop()->cancelTimer($periodic); }); } }, function ($e) use ($interaction, $guildId, $discord, $timeoutTimer) { global $joiningGuilds; unset($joiningGuilds[$guildId]); $discord->getLoop()->cancelTimer($timeoutTimer); logToDb("Error joining VC in guild $guildId: " . $e->getMessage(), 'error'); if (strpos($e->getMessage(), 'more than one voice channel') !== false) { logToDb("Detected out-of-sync voice client. Attempting force reset for next time."); // Try to find it and close it $vc = $discord->getVoiceClient($guildId); if ($vc) { try { $vc->close(); } catch (\Throwable $e2) {} } } if ($interaction) { safeUpdate($interaction, MessageBuilder::new()->setContent("❌ Error joining voice channel: " . $e->getMessage() . ". Please try again in a few seconds.")); } }); }; // If we have a VC, always close it if it's not the "perfect" one we already checked if ($vc) { logToDb("Closing connection for guild $guildId before re-joining (Current Channel: " . ($vc->channel->name ?? 'Unknown') . ")"); try { $vc->close(); } catch (\Throwable $e) {} // Give it time to clear state $discord->getLoop()->addTimer(2.0, $doJoin); } else { logToDb("No existing VC for guild $guildId. Joining fresh."); $doJoin(); } } function logToDb($message, $level = 'info') { // Basic safety check for DB connection static $db_working = true; if (!$db_working && $level !== 'error') return; // Echo to console anyway echo "[" . date('Y-m-d H:i:s') . "] [$level] $message\n"; try { $db = db(); $stmt = $db->prepare("INSERT INTO bot_logs (message, log_level) VALUES (?, ?)"); $stmt->execute([$message, $level]); } catch (\Throwable $e) { $db_working = false; // Don't echo again to avoid loops if echo itself fails (unlikely) } } /** * Safely update interaction response with error handling for promises. */ function safeUpdate($interaction, $message) { if (!$interaction) return; try { $interaction->updateOriginalResponse($message)->then(null, function($e) { echo "DEBUG: Failed to update interaction: " . $e->getMessage() . "\n"; }); } catch (\Throwable $e) { echo "DEBUG: Exception while updating interaction: " . $e->getMessage() . "\n"; } } $discord->on('ready', function (Discord $discord) { echo "Bot is ready as " . $discord->user->username . "#" . $discord->user->discriminator . PHP_EOL; logToDb("Bot is online and ready: " . $discord->user->username); $db = db(); $db->prepare("INSERT INTO bot_settings (setting_key, setting_value) VALUES ('bot_status', 'online') ON DUPLICATE KEY UPDATE setting_value = 'online'")->execute(); // Listen for voice state updates to debug disconnecting issues $discord->on(Event::VOICE_STATE_UPDATE, function ($state, Discord $discord) { if ($state->user_id == $discord->id) { $channelName = 'None'; if ($state->channel_id) { $channel = $discord->getChannel($state->channel_id); $channelName = $channel ? $channel->name : $state->channel_id; } logToDb("Bot voice state updated: Channel=" . $channelName . " in Guild=" . $state->guild_id); // If the bot was disconnected if ($state->channel_id === null) { global $joiningGuilds; $isJoining = isset($joiningGuilds[$state->guild_id]) && $joiningGuilds[$state->guild_id] === true; logToDb("Bot disconnected from voice in guild " . $state->guild_id . ". (Currently joining: " . ($isJoining ? 'Yes' : 'No') . ")"); if (!$isJoining) { // Only clean up if we aren't actively trying to join right now $vc = $discord->getVoiceClient($state->guild_id); if ($vc) { try { $vc->close(); } catch (\Throwable $e) {} } } } } }); // Periodic timer for Sahur and Alarms $discord->getLoop()->addPeriodicTimer(60, function () use ($discord) { $now = date('H:i'); $db = db(); $settings = $db->query("SELECT setting_key, setting_value FROM bot_settings")->fetchAll(PDO::FETCH_KEY_PAIR); $sahurTime = $settings['sahur_time'] ?? '03:00'; $vcId = $settings['voice_channel_id'] ?? '1457687430189682781'; if ($now === $sahurTime) { echo "Sahur time triggered ($now)\n"; logToDb("Sahur time triggered."); playSahur($discord, $vcId); } $stmt = $db->prepare("SELECT * FROM bot_alarms WHERE alarm_time LIKE ? AND is_active = 1"); $stmt->execute([$now . '%']); $alarms = $stmt->fetchAll(PDO::FETCH_ASSOC); $alarmCount = 0; foreach ($alarms as $alarm) { $alarmCount++; $discord->getLoop()->addTimer($alarmCount * 2.0, function() use ($discord, $alarm) { echo "Alarm triggered for user {$alarm['user_id']} at {$alarm['alarm_time']}\n"; logToDb("Alarm triggered for user {$alarm['user_id']}"); playAlarm($discord, $alarm); }); } }); // Register Commands (Uncomment if you need to update commands) registerCommands($discord); echo "Commands registration updated.\n"; }); function getOptionValue($options, $name) { if (!$options) return null; foreach ($options as $option) { if ($option->name === $name) { return $option->value; } } return null; } function registerCommands(Discord $discord) { $commands = [ CommandBuilder::new()->setName('join')->setDescription('Join your current voice channel'), CommandBuilder::new()->setName('rejoin')->setDescription('Force rejoin the voice channel'), CommandBuilder::new()->setName('out')->setDescription('Leave the voice channel'), CommandBuilder::new()->setName('status')->setDescription('Check bot status'), CommandBuilder::new()->setName('play')->setDescription('Play music from URL') ->addOption((new Option($discord))->setName('url')->setDescription('YouTube/TikTok/SoundCloud URL')->setType(Option::STRING)->setRequired(true)), CommandBuilder::new()->setName('stop')->setDescription('Stop music'), CommandBuilder::new()->setName('settime')->setDescription('Set your personal alarm time (HH:MM)') ->addOption((new Option($discord))->setName('time')->setDescription('Time in HH:MM format')->setType(Option::STRING)->setRequired(true)), CommandBuilder::new()->setName('setalarm')->setDescription('Set your personal alarm audio link') ->addOption((new Option($discord))->setName('link')->setDescription('Audio URL for the alarm')->setType(Option::STRING)->setRequired(true)), CommandBuilder::new()->setName('help')->setDescription('Show help information'), CommandBuilder::new()->setName('ping')->setDescription('Check if bot is responsive'), CommandBuilder::new()->setName('where')->setDescription('Show which voice channel the bot is in'), CommandBuilder::new()->setName('reset')->setDescription('Hard reset voice state and connection'), ]; foreach ($commands as $command) { $discord->application->commands->save($discord->application->commands->create($command->toArray())); } } function streamAudio(VoiceClient $vc, string $url, $interaction = null) { global $discord; logToDb("Starting streamAudio for URL: " . substr($url, 0, 50) . "..."); echo "DEBUG: streamAudio called for URL: $url\n"; echo "Streaming audio: $url\n"; if ($interaction) { safeUpdate($interaction, MessageBuilder::new()->setContent("🎶 Loading audio info from link...")); } $safeUrl = escapeshellarg($url); // Base yt-dlp command with common stability flags // Improved player-client order and added more logging $baseYtDlp = "yt-dlp --no-warnings --no-check-certificates --js-runtimes node --force-ipv4 --geo-bypass --no-playlist --no-cache-dir --print \"%(title)s\" --print \"%(url)s\" -f \"bestaudio[ext=m4a]/bestaudio/best\" --extractor-args \"youtube:player-client=ios,android,web,mweb,tv\""; // Handle Spotify differently by searching on YouTube if (strpos($url, 'spotify.com') !== false) { logToDb("Spotify link detected, searching on YouTube..."); if ($interaction) { safeUpdate($interaction, MessageBuilder::new()->setContent("🎶 Spotify link detected, searching for audio on YouTube...")); } $safeSearch = escapeshellarg("ytsearch1:" . $url); $cmd = "$baseYtDlp $safeSearch"; } else { $cmd = "$baseYtDlp $safeUrl"; } $process = new Process($cmd); try { $process->start($discord->getLoop()); } catch (\Exception $e) { echo "Failed to start yt-dlp process: " . $e->getMessage() . "\n"; if ($interaction) { safeUpdate($interaction, MessageBuilder::new()->setContent("❌ Failed to start audio downloader.")); } return; } $timer = $discord->getLoop()->addTimer(60.0, function() use ($process, $interaction) { if ($process->isRunning()) { echo "yt-dlp process timed out after 60s\n"; $process->terminate(SIGKILL); if ($interaction) { safeUpdate($interaction, MessageBuilder::new()->setContent("❌ Audio loading timed out (60s).")); } } }); $output = ''; $errorOutput = ''; $process->stdout->on('data', function ($chunk) use (&$output) { $output .= $chunk; }); $process->stderr->on('data', function ($chunk) use (&$errorOutput) { $errorOutput .= $chunk; }); $process->on('exit', function ($code) use (&$output, &$errorOutput, $vc, $interaction, $url, $discord, $timer) { $discord->getLoop()->cancelTimer($timer); $outputLines = explode("\n", trim($output)); $lines = array_values(array_filter($outputLines, function($line) { $trimmed = trim($line); return !empty($trimmed) && strpos($trimmed, 'WARNING:') !== 0 && strpos($trimmed, 'ERROR:') !== 0; })); if ($code === 0 && count($lines) >= 1) { $title = count($lines) >= 2 ? trim($lines[0]) : "Unknown Title"; // Find the last line that looks like a URL $streamUrl = ''; for ($i = count($lines) - 1; $i >= 0; $i--) { if (filter_var(trim($lines[$i]), FILTER_VALIDATE_URL)) { $streamUrl = trim($lines[$i]); break; } } if (empty($streamUrl)) { echo "Failed to find valid stream URL in yt-dlp output for $url\n"; if ($interaction) { safeUpdate($interaction, MessageBuilder::new()->setContent("❌ Failed to parse audio stream URL.")); } return; } echo "Title: $title, Stream URL found: " . substr($streamUrl, 0, 60) . "...\n"; if ($interaction) { safeUpdate($interaction, MessageBuilder::new()->setContent("🎶 **Now Playing:** $title")); } $playAttempted = false; $isClosed = false; $retryCount = 0; $maxRetries = 25; $playFunc = function ($isFallback = false) use (&$playFunc, &$playAttempted, &$isClosed, &$retryCount, $maxRetries, $vc, $streamUrl, $title, $interaction) { if ($isClosed) return; $isReady = method_exists($vc, 'isReady') ? $vc->isReady() : ($vc->ready ?? false); echo "Attempting to play $title. VC Ready: " . ($isReady ? 'Yes' : 'No') . " (Retry: $retryCount/$maxRetries, Fallback: " . ($isFallback ? 'Yes' : 'No') . ")\n"; if (!$isReady) { if ($retryCount < $maxRetries) { $retryCount++; $vc->discord->getLoop()->addTimer(3.0, function() use (&$playFunc) { $playFunc(); }); } else { logToDb("Failed to play $title: Voice client never became ready.", 'error'); if ($interaction) { safeUpdate($interaction, MessageBuilder::new()->setContent("❌ Voice connection failed to stabilize. Try using `/out` then play again.")); } } return; } if ($playAttempted && !$isFallback) return; $playAttempted = true; logToDb("Calling playFile for $title..."); $vc->playFile($streamUrl)->then(function() use ($title) { logToDb("Finished playing $title"); }, function($e) use ($vc, $streamUrl, $title, &$playFunc, &$playAttempted, &$retryCount, $maxRetries, $interaction) { logToDb("Error playing $title: " . $e->getMessage(), 'error'); if ($retryCount < $maxRetries && (stripos($e->getMessage(), 'ready') !== false || stripos($e->getMessage(), 'connected') !== false || stripos($e->getMessage(), 'Voice Client') !== false)) { $retryCount++; $playAttempted = false; echo "Retrying $title (attempt $retryCount/$maxRetries) in 4 seconds...\n"; $vc->discord->getLoop()->addTimer(4.0, function() use (&$playFunc) { $playFunc(); }); } else { if ($interaction) { safeUpdate($interaction, MessageBuilder::new()->setContent("❌ Audio playback error: " . $e->getMessage())); } } }); }; $isReady = method_exists($vc, 'isReady') ? $vc->isReady() : ($vc->ready ?? false); if ($isReady) { $playFunc(); } else { echo "Voice client not ready yet for $title (initial check), adding listeners...\n"; $fallbackTimer = $vc->discord->getLoop()->addTimer(7.0, function() use ($vc, $playFunc, $title, &$isClosed) { if ($isClosed) return; $isReady = method_exists($vc, 'isReady') ? $vc->isReady() : ($vc->ready ?? false); if (!$isReady) { echo "Still not ready after 7s for $title, trying playFunc as fallback.\n"; $playFunc(true); } }); $safetyTimer = $vc->discord->getLoop()->addTimer(60.0, function () use ($vc, $interaction, $title, $fallbackTimer, &$isClosed) { if ($isClosed) return; try { $vc->discord->getLoop()->cancelTimer($fallbackTimer); $isReady = method_exists($vc, 'isReady') ? $vc->isReady() : ($vc->ready ?? false); if (!$isReady) { echo "Timed out waiting for voice client to be ready for $title after 60s\n"; logToDb("VoiceClient ready timeout for $title. Closing VC.", 'error'); try { $vc->close(); } catch (\Throwable $e) {} if ($interaction) { safeUpdate($interaction, MessageBuilder::new()->setContent("⚠️ Voice client timed out. Use `/out` and try again.")); } } } catch (\Exception $e) {} }); $vc->once('ready', function() use ($vc, &$playFunc, $fallbackTimer, $safetyTimer, &$isClosed) { if ($isClosed) return; $vc->discord->getLoop()->cancelTimer($fallbackTimer); $vc->discord->getLoop()->cancelTimer($safetyTimer); $playFunc(); }); $vc->once('close', function() use ($vc, $interaction, $title, $fallbackTimer, $safetyTimer, &$isClosed) { $isClosed = true; $vc->discord->getLoop()->cancelTimer($fallbackTimer); $vc->discord->getLoop()->cancelTimer($safetyTimer); if ($interaction) { safeUpdate($interaction, MessageBuilder::new()->setContent("⚠️ Voice connection closed.")); } }); } } else { // Strip ANSI escape codes from error output for cleaner matching $cleanError = preg_replace('/\x1B\[[0-9;]*[JKmsu]/', '', $errorOutput); logToDb("Failed to fetch stream URL for $url. Code: $code. Error: " . substr($cleanError, 0, 500), 'error'); echo "Failed to fetch stream URL for $url. Code: $code\nError Output: $errorOutput\n"; $errorMessage = "❌ Failed to fetch audio. Link may be invalid or blocked."; if (stripos($cleanError, 'Video unavailable') !== false || stripos($cleanError, 'not available') !== false) { $errorMessage = "❌ Video unavailable. It might be private, deleted, or region-locked."; } else if (stripos($cleanError, 'Private video') !== false) { $errorMessage = "❌ This video is private and cannot be played."; } else if (stripos($cleanError, 'blocked') !== false || stripos($cleanError, '403') !== false || stripos($cleanError, 'Sign in to confirm') !== false || stripos($cleanError, 'confirm your age') !== false || stripos($cleanError, 'bot') !== false) { $errorMessage = "❌ This video is restricted or the bot is being rate-limited. Try another link or a shorter video."; } else if (stripos($cleanError, 'Incomplete YouTube ID') !== false) { $errorMessage = "❌ Invalid YouTube link or ID. Please check the URL."; } else if (stripos($cleanError, 'confirm your age') !== false || stripos($cleanError, 'age restricted') !== false) { $errorMessage = "❌ This video is age-restricted and requires a sign-in."; } else { // If it's a generic error but we have output, maybe it's something else logToDb("Generic yt-dlp error for $url: $cleanError", 'error'); } if ($interaction) { safeUpdate($interaction, MessageBuilder::new()->setContent($errorMessage)); } } }); } function playSahur(Discord $discord, $vcId) { $channel = $discord->getChannel($vcId); if (!$channel) return; safeJoin($discord, $channel, null, function($vc) { $db = db(); $source = $db->query("SELECT setting_value FROM bot_settings WHERE setting_key = 'sahur_source'")->fetchColumn() ?: 'sahur.mp3'; $playAction = function() use ($vc, $source) { if (filter_var($source, FILTER_VALIDATE_URL)) { streamAudio($vc, $source); } else if (file_exists($source)) { $vc->playFile($source); } }; $isReady = method_exists($vc, 'isReady') ? $vc->isReady() : ($vc->ready ?? false); if ($isReady) { $playAction(); } else { $vc->once('ready', $playAction); $vc->discord->getLoop()->addTimer(10.0, function() use ($vc, $playAction) { $isReady = method_exists($vc, 'isReady') ? $vc->isReady() : ($vc->ready ?? false); if (!$isReady) { echo "DEBUG: 10s timer reached for Sahur, VC still not ready. Attempting playAction anyway.\n"; $playAction(); } }); } }); } function playAlarm(Discord $discord, $alarm) { $channel = $discord->getChannel($alarm['channel_id']); if (!$channel) return; safeJoin($discord, $channel, null, function($vc) use ($alarm) { $isReady = method_exists($vc, 'isReady') ? $vc->isReady() : ($vc->ready ?? false); $playAction = function() use ($vc, $alarm) { streamAudio($vc, $alarm['audio_url']); }; if ($isReady) { $playAction(); } else { $vc->once('ready', $playAction); $vc->discord->getLoop()->addTimer(10.0, function() use ($vc, $playAction) { $isReady = method_exists($vc, 'isReady') ? $vc->isReady() : ($vc->ready ?? false); if (!$isReady) { echo "DEBUG: 10s timer reached for Alarm, VC still not ready. Attempting playAction anyway.\n"; $playAction(); } }); } }); } $discord->on(Event::INTERACTION_CREATE, function (Interaction $interaction, Discord $discord) use ($ffmpegPath) { echo "DEBUG: Interaction received: " . ($interaction->data->name ?? 'unknown') . "\n"; if ($interaction->type !== 2) return; // 1. Acknowledge IMMEDIATELY to win the 3-second race with Discord try { $interaction->acknowledge(); echo "DEBUG: Interaction acknowledged\n"; } catch (\Throwable $e) { echo "DEBUG: Failed to acknowledge interaction: " . $e->getMessage() . "\n"; } $command = $interaction->data->name; logToDb("Received interaction: $command from " . ($interaction->member->user->username ?? "Unknown")); try { switch ($command) { case 'ping': safeUpdate($interaction, MessageBuilder::new()->setContent("Pong! 🏓")); break; case 'help': safeUpdate($interaction, MessageBuilder::new()->setContent( "**AsepSahur Bot Commands:**\n" . "`/join` - Join your voice channel\n" . "`/play [url]` - Play music from URL\n" . "`/stop` - Stop current playback\n" . "`/settime [HH:MM]` - Set your alarm time\n" . "`/setalarm [url]` - Set your alarm audio\n" . "`/status` - Check bot status\n" . "`/ping` - Test bot responsiveness\n" . "`/out` - Make bot leave voice channel\n" . "`/reset` - Hard reset voice state" )); break; case 'status': $vc = $discord->getVoiceClient($interaction->guild_id); $isReady = $vc ? (method_exists($vc, 'isReady') ? $vc->isReady() : ($vc->ready ?? false)) : false; $db = db(); $sahurTime = $db->query("SELECT setting_value FROM bot_settings WHERE setting_key = 'sahur_time'")->fetchColumn() ?: '03:00'; $status = "🤖 **Bot Status:** Online\n"; $status .= "🔊 **Voice:** " . ($vc ? "Connected to **" . $vc->channel->name . "**" . ($isReady ? " ✅" : " ⏳") : "Disconnected ❌") . "\n"; $status .= "⏰ **Sahur Time:** " . $sahurTime . "\n"; $status .= "🛠 **FFmpeg:** " . ($ffmpegPath ? "✅" : "❌") . "\n"; safeUpdate($interaction, MessageBuilder::new()->setContent($status)); break; case 'where': $guild = $discord->guilds->get('id', $interaction->guild_id); $voiceState = $guild ? $guild->voice_states->get('user_id', $discord->id) : null; $vc = $discord->getVoiceClient($interaction->guild_id); $msg = ""; if ($voiceState && $voiceState->channel_id) { $channel = $discord->getChannel($voiceState->channel_id); $msg .= "📍 **Discord thinks I am in:** " . ($channel ? $channel->name : $voiceState->channel_id) . "\n"; } else { $msg .= "📍 **Discord thinks I am:** Not in a channel\n"; } if ($vc) { $isReady = method_exists($vc, 'isReady') ? $vc->isReady() : ($vc->ready ?? false); $msg .= "🔊 **VoiceClient state:** Connected to **" . $vc->channel->name . "** (" . ($isReady ? "Ready ✅" : "Connecting ⏳") . ")"; } else { $msg .= "🔊 **VoiceClient state:** No active connection ❌"; } safeUpdate($interaction, MessageBuilder::new()->setContent($msg)); break; case 'join': $userChannel = $interaction->member->getVoiceChannel(); if (!$userChannel) { safeUpdate($interaction, MessageBuilder::new()->setContent("❌ You must be in a voice channel!")); return; } // Clear state for this guild if it's a rejoin if ($command === 'rejoin') { global $joiningGuilds; unset($joiningGuilds[$interaction->guild_id]); } safeUpdate($interaction, MessageBuilder::new()->setContent("⏳ Joining " . $userChannel->name . "...")); safeJoin($discord, $userChannel, $interaction, function($vc) use ($interaction) { safeUpdate($interaction, MessageBuilder::new()->setContent("✅ Successfully joined " . $vc->channel->name)); }); break; case 'play': $opts = $interaction->data->options; $url = getOptionValue($opts, 'url'); if (!$url) { safeUpdate($interaction, MessageBuilder::new()->setContent("❌ URL is missing!")); return; } $userChannel = $interaction->member->getVoiceChannel(); if (!$userChannel) { safeUpdate($interaction, MessageBuilder::new()->setContent("❌ Join a VC first!")); return; } safeUpdate($interaction, MessageBuilder::new()->setContent("⏳ Joining and preparing audio...")); safeJoin($discord, $userChannel, $interaction, function($vc) use ($url, $interaction) { safeUpdate($interaction, MessageBuilder::new()->setContent("✅ Connected to voice! Now loading audio...")); $vc->discord->getLoop()->addTimer(0.5, function() use ($vc, $url, $interaction) { streamAudio($vc, $url, $interaction); }); }); break; case 'stop': $vc = $discord->getVoiceClient($interaction->guild_id); if ($vc) { $vc->stop(); safeUpdate($interaction, MessageBuilder::new()->setContent("Stopped playback.")); } else { safeUpdate($interaction, MessageBuilder::new()->setContent("Nothing is playing.")); } break; case 'out': global $joiningGuilds; unset($joiningGuilds[$interaction->guild_id]); $vc = $discord->getVoiceClient($interaction->guild_id); if ($vc) { try { $vc->close(); safeUpdate($interaction, MessageBuilder::new()->setContent("✅ Left voice channel.")); } catch (\Throwable $e) { $msg = $e->getMessage(); // If it's already disconnected, that's fine if (stripos($msg, 'not connected') !== false || stripos($msg, 'Voice Client') !== false || stripos($msg, 'already closed') !== false) { safeUpdate($interaction, MessageBuilder::new()->setContent("✅ Disconnected from voice channel.")); } else { safeUpdate($interaction, MessageBuilder::new()->setContent("✅ Left with message: " . $msg)); } } } else { safeUpdate($interaction, MessageBuilder::new()->setContent("💨 No active voice connection found.")); } break; case 'reset': global $joiningGuilds; unset($joiningGuilds[$interaction->guild_id]); $vc = $discord->getVoiceClient($interaction->guild_id); if ($vc) { try { $vc->close(); } catch (\Throwable $e) {} } // Also force clear the voice state via gateway if possible try { $guild = $discord->guilds->get('id', $interaction->guild_id); if ($guild) { $discord->send([ 'op' => 4, 'd' => [ 'guild_id' => $interaction->guild_id, 'channel_id' => null, 'self_mute' => false, 'self_deaf' => false, ], ]); } } catch (\Throwable $e) {} $discord->getLoop()->addTimer(1.0, function() use ($interaction) { safeUpdate($interaction, MessageBuilder::new()->setContent("🔄 **Hard Reset complete.** Voice state cleared. You can try `/join` again now.")); }); break; case 'settime': $opts = $interaction->data->options; $time = getOptionValue($opts, 'time'); if (!$time || !preg_match('/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/', $time)) { safeUpdate($interaction, MessageBuilder::new()->setContent("❌ Invalid format. Use HH:MM (e.g. 03:00)")); return; } $userId = (string)$interaction->member->id; $guildId = (string)$interaction->guild_id; $channel = $interaction->member->getVoiceChannel(); $channelId = (string)($channel->id ?? ''); if (empty($channelId)) { safeUpdate($interaction, MessageBuilder::new()->setContent("❌ Join a voice channel first!")); return; } $db = db(); $stmt = $db->prepare("INSERT INTO bot_alarms (user_id, guild_id, channel_id, alarm_time, audio_url) VALUES (?, ?, ?, ?, 'sahur.mp3') ON DUPLICATE KEY UPDATE alarm_time = ?, guild_id = ?, channel_id = ?"); $stmt->execute([$userId, $guildId, $channelId, $time, $time, $guildId, $channelId]); safeUpdate($interaction, MessageBuilder::new()->setContent("✅ Alarm set to $time in " . $channel->name)); break; case 'setalarm': $opts = $interaction->data->options; $link = getOptionValue($opts, 'link'); if (!$link) { safeUpdate($interaction, MessageBuilder::new()->setContent("❌ Link is missing!")); return; } $userId = (string)$interaction->member->id; $guildId = (string)$interaction->guild_id; $channel = $interaction->member->getVoiceChannel(); $channelId = (string)($channel->id ?? ''); if (empty($channelId)) { safeUpdate($interaction, MessageBuilder::new()->setContent("❌ Join a voice channel first!")); return; } $db = db(); $stmt = $db->prepare("INSERT INTO bot_alarms (user_id, guild_id, channel_id, alarm_time, audio_url) VALUES (?, ?, ?, '03:00', ?) ON DUPLICATE KEY UPDATE audio_url = ?, guild_id = ?, channel_id = ?"); $stmt->execute([$userId, $guildId, $channelId, $link, $link, $guildId, $channelId]); safeUpdate($interaction, MessageBuilder::new()->setContent("✅ Alarm audio updated.")); break; } } catch (\Throwable $e) { logToDb("Command Error ($command): " . $e->getMessage(), 'error'); try { $interaction->updateOriginalResponse(MessageBuilder::new()->setContent("❌ An unexpected error occurred: " . $e->getMessage())); } catch (\Throwable $e2) {} } }); $discord->run();