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")); function logToDb($message, $level = 'info') { try { $db = db(); $stmt = $db->prepare("INSERT INTO bot_logs (message, log_level) VALUES (?, ?)"); $stmt->execute([$message, $level]); } catch (Exception $e) { echo "Log failed: " . $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) { echo "Bot voice state updated: Channel=" . ($state->channel_id ?? 'None') . " Session=" . ($state->session_id ?? 'None') . "\n"; logToDb("Bot voice state updated: Channel=" . ($state->channel_id ?? 'None')); } }); // 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); foreach ($alarms as $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 registerCommands($discord); }); function registerCommands(Discord $discord) { $commands = [ CommandBuilder::new()->setName('join')->setDescription('Join your current 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'), ]; foreach ($commands as $command) { $discord->application->commands->save($discord->application->commands->create($command->toArray())); } } function streamAudio(VoiceClient $vc, string $url, $interaction = null) { global $discord; echo "Streaming audio: $url\n"; if ($interaction) { $interaction->updateOriginalResponse(MessageBuilder::new()->setContent("🎶 Loading audio info from link...")); } $safeUrl = escapeshellarg($url); $process = new Process("yt-dlp --no-warnings --no-check-certificates --print \"%(title)s\" --print \"%(url)s\" -f \"ba/b\" --no-playlist --js-runtimes node $safeUrl"); try { $process->start($discord->getLoop()); } catch (\Exception $e) { echo "Failed to start yt-dlp process: " . $e->getMessage() . "\n"; if ($interaction) { $interaction->updateOriginalResponse(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) { $interaction->updateOriginalResponse(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); $lines = array_values(array_filter(explode("\n", trim($output)), function($line) { return !empty(trim($line)) && strpos($line, 'WARNING:') !== 0; })); if ($code === 0 && count($lines) >= 2) { $title = $lines[0]; $streamUrl = $lines[count($lines) - 1]; // Take the last line as URL, in case there are other prints echo "Title: $title, Stream URL found: $streamUrl\n"; if ($interaction) { $interaction->updateOriginalResponse(MessageBuilder::new()->setContent("🎶 **Now Playing:** $title")); } $playFunc = function ($isFallback = false) use ($vc, $streamUrl, $title, $interaction) { try { $isReady = method_exists($vc, 'isReady') ? $vc->isReady() : ($vc->ready ?? false); echo "Attempting to play $title. VC Ready: " . ($isReady ? 'Yes' : 'No') . " (Fallback: " . ($isFallback ? 'Yes' : 'No') . ")\n"; if (!$isReady && !$isFallback) { echo "Voice client not ready yet for $title, waiting for ready event...\n"; return; } echo "Calling playFile for $title. URL: " . substr($streamUrl, 0, 50) . "...\n"; $vc->playFile($streamUrl)->then(function() use ($title) { echo "Finished playing $title\n"; }, function($e) use ($vc, $streamUrl, $title) { echo "Error playing $title: " . $e->getMessage() . "\n"; logToDb("Error playing $title: " . $e->getMessage(), 'error'); if (strpos($e->getMessage(), 'not ready') !== false) { echo "Retrying $title in 2 seconds due to 'not ready' error...\n"; $vc->discord->getLoop()->addTimer(2.0, function() use ($vc, $streamUrl) { try { $isReady = method_exists($vc, 'isReady') ? $vc->isReady() : ($vc->ready ?? false); if ($isReady) { $vc->playFile($streamUrl); } } catch (\Throwable $e) {} }); } }); } catch (\Throwable $e) { echo "Exception in playFunc for $title: " . $e->getMessage() . "\n"; } }; $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) { $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(45.0, function () use ($vc, $interaction, $title, $fallbackTimer) { 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 45s\n"; logToDb("Voice client timeout for $title after 45s", 'warning'); if ($interaction) { $interaction->updateOriginalResponse(MessageBuilder::new()->setContent("⚠️ Voice client timed out waiting to be ready. Please try using `/out` then `/join` again.")); try { $vc->close(); } catch (\Exception $e) {} } } } catch (\Exception $e) { echo "Error in safety timer: " . $e->getMessage() . "\n"; } }); $vc->once('ready', function() use ($vc, $playFunc, $fallbackTimer, $safetyTimer) { $vc->discord->getLoop()->cancelTimer($fallbackTimer); $vc->discord->getLoop()->cancelTimer($safetyTimer); $playFunc(); }); $vc->once('close', function() use ($vc, $interaction, $title, $fallbackTimer, $safetyTimer) { $vc->discord->getLoop()->cancelTimer($fallbackTimer); $vc->discord->getLoop()->cancelTimer($safetyTimer); echo "Voice client closed for $title while waiting for ready.\n"; if ($interaction) { $interaction->updateOriginalResponse(MessageBuilder::new()->setContent("⚠️ Voice connection closed before it was ready.")); } }); $vc->once('error', function ($e) use ($title) { echo "Voice client encountered error while waiting for ready for $title: " . $e->getMessage() . "\n"; logToDb("Voice client error for $title: " . $e->getMessage(), 'error'); }); } } else { echo "Failed to fetch stream URL for $url. Code: $code\nError Output: $errorOutput\n"; if ($interaction) { $interaction->updateOriginalResponse(MessageBuilder::new()->setContent("❌ Failed to fetch audio. Make sure the link is valid. (Code $code)")); } } }); } function playSahur(Discord $discord, $vcId) { $channel = $discord->getChannel($vcId); if (!$channel) return; $vc = $discord->getVoiceClient($channel->guild_id); if ($vc && (string)$vc->channel->id === (string)$channel->id) { echo "Sahur: Already in channel, playing directly.\n"; $db = db(); $source = $db->query("SELECT setting_value FROM bot_settings WHERE setting_key = 'sahur_source'")->fetchColumn() ?: 'sahur.mp3'; if (filter_var($source, FILTER_VALIDATE_URL)) { streamAudio($vc, $source); } else if (file_exists($source)) { $vc->playFile($source); } return; } if ($vc) { try { $vc->close(); } catch (\Throwable $e) {} } $delay = $vc ? 3.0 : 0; $discord->getLoop()->addTimer($delay, function() use ($discord, $channel) { if ($discord->getVoiceClient($channel->guild_id)) return; $discord->joinVoiceChannel($channel, false, false)->then(function (VoiceClient $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); $discord = $vc->discord; $discord->getLoop()->addTimer(10.0, function() use ($vc, $playAction) { $isReady = method_exists($vc, 'isReady') ? $vc->isReady() : ($vc->ready ?? false); if (!$isReady) $playAction(); }); } }); }); } function playAlarm(Discord $discord, $alarm) { $channel = $discord->getChannel($alarm['channel_id']); if (!$channel) return; $vc = $discord->getVoiceClient($channel->guild_id); if ($vc && (string)$vc->channel->id === (string)$channel->id) { echo "Alarm: Already in channel, playing directly.\n"; streamAudio($vc, $alarm['audio_url']); return; } if ($vc) { try { $vc->close(); } catch (\Throwable $e) {} } $delay = $vc ? 3.0 : 0; $discord->getLoop()->addTimer($delay, function() use ($discord, $channel, $alarm) { if ($discord->getVoiceClient($channel->guild_id)) { $vc = $discord->getVoiceClient($channel->guild_id); if ((string)$vc->channel->id === (string)$channel->id) streamAudio($vc, $alarm['audio_url']); return; } $discord->joinVoiceChannel($channel, false, false)->then(function (VoiceClient $vc) use ($alarm) { $isReady = method_exists($vc, 'isReady') ? $vc->isReady() : ($vc->ready ?? false); if ($isReady) { streamAudio($vc, $alarm['audio_url']); } else { $vc->once('ready', function() use ($vc, $alarm) { streamAudio($vc, $alarm['audio_url']); }); $discord = $vc->discord; $discord->getLoop()->addTimer(10.0, function() use ($vc, $alarm) { $isReady = method_exists($vc, 'isReady') ? $vc->isReady() : ($vc->ready ?? false); if (!$isReady) streamAudio($vc, $alarm['audio_url']); }); } }); }); } $discord->on(Event::INTERACTION_CREATE, function (Interaction $interaction, Discord $discord) use (&$voiceClient) { if ($interaction->type !== 2) return; $command = $interaction->data->name; logToDb("Received interaction: $command from " . $interaction->member->user->username); switch ($command) { case 'ping': $interaction->respondWithMessage(MessageBuilder::new()->setContent("Pong! 🏓")); break; case 'help': $interaction->respondWithMessage(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" )); break; case 'status': $vc = $discord->getVoiceClient($interaction->guild_id); $status = "Bot is online. " . ($vc ? "Connected to " . $vc->channel->name : "Not in voice channel."); $interaction->respondWithMessage(MessageBuilder::new()->setContent($status)); break; case 'join': $userChannel = $interaction->member->getVoiceChannel(); if (!$userChannel) { $interaction->respondWithMessage(MessageBuilder::new()->setContent("You must be in a voice channel!")); return; } $interaction->acknowledge(); try { $vc = $discord->getVoiceClient($interaction->guild_id); if ($vc && (string)$vc->channel->id === (string)$userChannel->id) { $interaction->updateOriginalResponse(MessageBuilder::new()->setContent("Already in " . $vc->channel->name)); return; } global $joiningGuilds; if (isset($joiningGuilds[$interaction->guild_id])) { $interaction->updateOriginalResponse(MessageBuilder::new()->setContent("❌ A join request is already in progress. Please wait.")); return; } $joiningGuilds[$interaction->guild_id] = true; if ($vc) { echo "Closing existing VC for guild " . $interaction->guild_id . " before joining new channel.\n"; $vc->close(); } $delay = $vc ? 3.0 : 0; echo "Joining channel: " . $userChannel->name . " (Join command, delay: $delay)\n"; $discord->getLoop()->addTimer($delay, function() use ($discord, $userChannel, $interaction, &$joiningGuilds) { $vc = $discord->getVoiceClient($interaction->guild_id); if ($vc) { unset($joiningGuilds[$interaction->guild_id]); if ((string)$vc->channel->id === (string)$userChannel->id) { $interaction->updateOriginalResponse(MessageBuilder::new()->setContent("Joined " . $vc->channel->name)); return; } } $responded = false; $joinTimeout = $discord->getLoop()->addTimer(20.0, function() use ($interaction, $discord, &$responded, &$joiningGuilds) { unset($joiningGuilds[$interaction->guild_id]); if (!$responded) { $interaction->updateOriginalResponse(MessageBuilder::new()->setContent("❌ Joining voice channel timed out (20s).")); $responded = true; } }); $discord->joinVoiceChannel($userChannel, false, false)->then(function (VoiceClient $vc) use ($interaction, $discord, $joinTimeout, &$responded, &$joiningGuilds, $userChannel) { unset($joiningGuilds[$interaction->guild_id]); if ($responded) return; $discord->getLoop()->cancelTimer($joinTimeout); $responded = true; $interaction->updateOriginalResponse(MessageBuilder::new()->setContent("Joined " . $vc->channel->name)); $vc->on('close', function() use ($userChannel) { echo "Voice client closed for channel " . $userChannel->name . "\n"; }); }, function ($e) use ($interaction, $discord, $joinTimeout, &$responded, &$joiningGuilds) { unset($joiningGuilds[$interaction->guild_id]); if ($responded) return; $discord->getLoop()->cancelTimer($joinTimeout); $responded = true; echo "Error joining VC: " . $e->getMessage() . "\n"; $interaction->updateOriginalResponse(MessageBuilder::new()->setContent("❌ Error joining voice channel: " . $e->getMessage())); }); }); } catch (\Throwable $e) { global $joiningGuilds; unset($joiningGuilds[$interaction->guild_id]); $interaction->updateOriginalResponse(MessageBuilder::new()->setContent("❌ Error: " . $e->getMessage())); } break; case 'play': $url = $interaction->data->options['url']->value; $userChannel = $interaction->member->getVoiceChannel(); if (!$userChannel) { $interaction->respondWithMessage(MessageBuilder::new()->setContent("Join a VC first!")); return; } $interaction->acknowledge(); try { $vc = $discord->getVoiceClient($interaction->guild_id); if ($vc && (string)$vc->channel->id === (string)$userChannel->id) { echo "Already in correct channel. Streaming...\n"; streamAudio($vc, $url, $interaction); } else { global $joiningGuilds; if (isset($joiningGuilds[$interaction->guild_id])) { $interaction->updateOriginalResponse(MessageBuilder::new()->setContent("❌ A join request is already in progress. Please wait.")); return; } $joiningGuilds[$interaction->guild_id] = true; if ($vc) { echo "Voice client in wrong channel, closing and rejoining...\n"; $vc->close(); } $delay = $vc ? 3.0 : 0; echo "Joining channel: " . $userChannel->name . " (Play command, delay: $delay)\n"; $discord->getLoop()->addTimer($delay, function() use ($discord, $userChannel, $interaction, $url, &$joiningGuilds) { $vc = $discord->getVoiceClient($interaction->guild_id); if ($vc) { unset($joiningGuilds[$interaction->guild_id]); if ((string)$vc->channel->id === (string)$userChannel->id) { streamAudio($vc, $url, $interaction); } else { $interaction->updateOriginalResponse(MessageBuilder::new()->setContent("❌ Connection busy or in another channel. Try again in a moment.")); } return; } $responded = false; $joinTimeout = $discord->getLoop()->addTimer(20.0, function() use ($interaction, $discord, &$responded, &$joiningGuilds) { unset($joiningGuilds[$interaction->guild_id]); if (!$responded) { $interaction->updateOriginalResponse(MessageBuilder::new()->setContent("❌ Joining voice channel timed out (20s). Try again.")); $responded = true; } }); $discord->joinVoiceChannel($userChannel, false, false)->then(function (VoiceClient $vc) use ($interaction, $discord, $url, $joinTimeout, &$responded, &$joiningGuilds) { unset($joiningGuilds[$interaction->guild_id]); if ($responded) return; $discord->getLoop()->cancelTimer($joinTimeout); $responded = true; echo "Joined voice channel, now streaming...\n"; streamAudio($vc, $url, $interaction); }, function ($e) use ($interaction, $discord, $joinTimeout, &$responded, &$joiningGuilds) { unset($joiningGuilds[$interaction->guild_id]); if ($responded) return; $discord->getLoop()->cancelTimer($joinTimeout); $responded = true; echo "Error joining VC: " . $e->getMessage() . "\n"; $interaction->updateOriginalResponse(MessageBuilder::new()->setContent("❌ Error joining voice channel: " . $e->getMessage())); }); }); } } catch (\Throwable $e) { global $joiningGuilds; unset($joiningGuilds[$interaction->guild_id]); echo "Fatal error in play command: " . $e->getMessage() . "\n"; $interaction->updateOriginalResponse(MessageBuilder::new()->setContent("❌ Fatal error: " . $e->getMessage())); } break; case 'stop': $vc = $discord->getVoiceClient($interaction->guild_id); if ($vc) { $vc->stop(); $interaction->respondWithMessage(MessageBuilder::new()->setContent("Stopped playback.")); } else { $interaction->respondWithMessage(MessageBuilder::new()->setContent("Nothing is playing.")); } break; case 'out': $vc = $discord->getVoiceClient($interaction->guild_id); if ($vc) { try { $vc->close(); $interaction->respondWithMessage(MessageBuilder::new()->setContent("Left voice channel.")); } catch (\Throwable $e) { $interaction->respondWithMessage(MessageBuilder::new()->setContent("Left voice channel.")); } } else { $interaction->respondWithMessage(MessageBuilder::new()->setContent("I'm not in a voice channel.")); } break; case 'settime': $time = $interaction->data->options['time']->value; if (!preg_match('/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/', $time)) { $interaction->respondWithMessage(MessageBuilder::new()->setContent("Invalid format. Use HH:MM (e.g. 03:00)")); return; } $userId = (string)$interaction->member->id; $guildId = (string)$interaction->guild_id; $channelId = (string)($interaction->member->getVoiceChannel()->id ?? ''); if (empty($channelId)) { $interaction->respondWithMessage(MessageBuilder::new()->setContent("Please join a voice channel first so I know where to wake you up!")); 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]); $interaction->respondWithMessage(MessageBuilder::new()->setContent("Alarm time set to $time. I will join " . $interaction->member->getVoiceChannel()->name . " at that time.")); break; case 'setalarm': $link = $interaction->data->options['link']->value; $userId = (string)$interaction->member->id; $guildId = (string)$interaction->guild_id; $channelId = (string)($interaction->member->getVoiceChannel()->id ?? ''); if (empty($channelId)) { $interaction->respondWithMessage(MessageBuilder::new()->setContent("Please 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]); $interaction->respondWithMessage(MessageBuilder::new()->setContent("Alarm audio set.")); break; } }); $discord->run();