752 lines
35 KiB
PHP
752 lines
35 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
putenv('PATH=' . getenv('PATH') . ':/usr/bin:/usr/local/bin');
|
|
|
|
require_once __DIR__ . '/vendor/autoload.php';
|
|
require_once __DIR__ . '/db/config.php';
|
|
|
|
use Discord\Discord;
|
|
use Discord\Voice\VoiceClient;
|
|
use Discord\Parts\Channel\Channel;
|
|
use Discord\WebSockets\Event;
|
|
use Discord\WebSockets\Intents;
|
|
use Discord\Builders\CommandBuilder;
|
|
use Discord\Builders\MessageBuilder;
|
|
use Discord\Parts\Interactions\Interaction;
|
|
use Discord\Parts\Interactions\Command\Option;
|
|
use React\ChildProcess\Process;
|
|
|
|
$db = db();
|
|
// Ensure tables exist
|
|
$db->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);
|
|
|
|
// If we are already in the correct channel, just proceed
|
|
if ($vc && (string)$vc->channel->id === (string)$channel->id) {
|
|
logToDb("Already in channel {$channel->name}.");
|
|
if ($onSuccess) $onSuccess($vc);
|
|
return;
|
|
}
|
|
|
|
// Mark as joining
|
|
$joiningGuilds[$guildId] = true;
|
|
|
|
if ($interaction) {
|
|
try {
|
|
$interaction->updateOriginalResponse(MessageBuilder::new()->setContent("β³ Initializing voice connection..."));
|
|
} catch (\Throwable $e) {}
|
|
}
|
|
|
|
$doJoin = function() use ($discord, $channel, $interaction, $onSuccess, $guildId) {
|
|
global $joiningGuilds;
|
|
|
|
logToDb("Executing joinVoiceChannel for guild $guildId, channel " . $channel->name);
|
|
|
|
$timeoutTimer = $discord->getLoop()->addTimer(25.0, function() use ($guildId, $interaction) {
|
|
global $joiningGuilds;
|
|
if (isset($joiningGuilds[$guildId]) && $joiningGuilds[$guildId] === true) {
|
|
logToDb("Safety timeout: Clearing stuck join state for guild $guildId");
|
|
unset($joiningGuilds[$guildId]);
|
|
if ($interaction) {
|
|
try {
|
|
$interaction->updateOriginalResponse(MessageBuilder::new()->setContent("β οΈ Joining timed out. Discord's voice servers are slow. Please try again."));
|
|
} catch (\Throwable $e) {}
|
|
}
|
|
}
|
|
});
|
|
|
|
$discord->joinVoiceChannel($channel, false, true)->then(function (VoiceClient $vc) use ($onSuccess, $guildId, $discord, $interaction, $timeoutTimer) {
|
|
global $joiningGuilds;
|
|
logToDb("Successfully joined " . $vc->channel->name);
|
|
$discord->getLoop()->cancelTimer($timeoutTimer);
|
|
unset($joiningGuilds[$guildId]);
|
|
|
|
if ($onSuccess) $onSuccess($vc);
|
|
}, 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 ($interaction) {
|
|
try {
|
|
$interaction->updateOriginalResponse(MessageBuilder::new()->setContent("β Error joining voice channel: " . $e->getMessage()));
|
|
} catch (\Throwable $err) {}
|
|
}
|
|
});
|
|
};
|
|
|
|
// If we have a VC in a different channel, close it first
|
|
if ($vc) {
|
|
logToDb("Closing connection in different channel: " . $vc->channel->name);
|
|
$vc->close();
|
|
// Give it a bit of time to clear state
|
|
$discord->getLoop()->addTimer(1.5, $doJoin);
|
|
} else {
|
|
$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)
|
|
}
|
|
}
|
|
|
|
$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);
|
|
|
|
// If the bot was disconnected or moved
|
|
if ($state->channel_id === null) {
|
|
logToDb("Bot was disconnected from voice channel. Cleaning up...");
|
|
global $joiningGuilds;
|
|
unset($joiningGuilds[$state->guild_id]);
|
|
|
|
// Ensure VoiceClient is closed if it still exists
|
|
$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) {
|
|
try {
|
|
$interaction->updateOriginalResponse(MessageBuilder::new()->setContent("πΆ Loading audio info from link..."));
|
|
} catch (\Throwable $e) {
|
|
echo "Failed to update interaction in streamAudio: " . $e->getMessage() . "\n";
|
|
}
|
|
}
|
|
|
|
$userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36";
|
|
$safeUrl = escapeshellarg($url);
|
|
|
|
// Base yt-dlp command with common stability flags
|
|
$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\" --user-agent \"$userAgent\" --extractor-args \"youtube:player-client=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) {
|
|
$interaction->updateOriginalResponse(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) {
|
|
$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);
|
|
|
|
$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) {
|
|
$interaction->updateOriginalResponse(MessageBuilder::new()->setContent("β Failed to parse audio stream URL."));
|
|
}
|
|
return;
|
|
}
|
|
|
|
echo "Title: $title, Stream URL found: " . substr($streamUrl, 0, 60) . "...\n";
|
|
if ($interaction) {
|
|
$interaction->updateOriginalResponse(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(2.0, function() use (&$playFunc) {
|
|
$playFunc();
|
|
});
|
|
} else {
|
|
echo "Failed to play $title after $maxRetries retries: Voice client never became ready.\n";
|
|
if ($interaction) {
|
|
$interaction->updateOriginalResponse(MessageBuilder::new()->setContent("β Failed to play: Voice client timed out waiting to be ready."));
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if ($playAttempted && !$isFallback) return;
|
|
$playAttempted = true;
|
|
|
|
echo "Calling playFile for $title...\n";
|
|
$vc->playFile($streamUrl)->then(function() use ($title) {
|
|
echo "Finished playing $title\n";
|
|
}, function($e) use ($vc, $streamUrl, $title, &$playFunc, &$playAttempted, &$retryCount, $maxRetries, $interaction) {
|
|
echo "Error playing $title: " . $e->getMessage() . "\n";
|
|
logToDb("Error playing $title: " . $e->getMessage(), 'error');
|
|
|
|
if ($retryCount < $maxRetries && (strpos($e->getMessage(), 'ready') !== false || strpos($e->getMessage(), 'connected') !== false || strpos($e->getMessage(), 'Voice Client') !== false)) {
|
|
$retryCount++;
|
|
$playAttempted = false;
|
|
echo "Retrying $title (attempt $retryCount/$maxRetries) in 3 seconds...\n";
|
|
$vc->discord->getLoop()->addTimer(3.0, function() use (&$playFunc) {
|
|
$playFunc();
|
|
});
|
|
} else {
|
|
if ($interaction) {
|
|
$interaction->updateOriginalResponse(MessageBuilder::new()->setContent("β Error playing audio: " . $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";
|
|
if ($interaction) {
|
|
$interaction->updateOriginalResponse(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) {
|
|
$interaction->updateOriginalResponse(MessageBuilder::new()->setContent("β οΈ Voice connection closed."));
|
|
}
|
|
});
|
|
}
|
|
} else {
|
|
echo "Failed to fetch stream URL for $url. Code: $code\nError Output: $errorOutput\n";
|
|
|
|
// Strip ANSI escape codes from error output for cleaner matching
|
|
$cleanError = preg_replace('/\x1B\[[0-9;]*[JKmsu]/', '', $errorOutput);
|
|
|
|
$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) {
|
|
$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.";
|
|
}
|
|
|
|
if ($interaction) {
|
|
$interaction->updateOriginalResponse(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) $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);
|
|
if ($isReady) {
|
|
streamAudio($vc, $alarm['audio_url']);
|
|
} else {
|
|
$vc->once('ready', function() use ($vc, $alarm) {
|
|
streamAudio($vc, $alarm['audio_url']);
|
|
});
|
|
$vc->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 ($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':
|
|
$interaction->updateOriginalResponse(MessageBuilder::new()->setContent("Pong! π"));
|
|
break;
|
|
|
|
case 'help':
|
|
$interaction->updateOriginalResponse(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";
|
|
|
|
$interaction->updateOriginalResponse(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 β";
|
|
}
|
|
|
|
$interaction->updateOriginalResponse(MessageBuilder::new()->setContent($msg));
|
|
break;
|
|
|
|
case 'reset':
|
|
global $joiningGuilds;
|
|
$joiningGuilds = [];
|
|
$vc = $discord->getVoiceClient($interaction->guild_id);
|
|
if ($vc) {
|
|
try { $vc->close(); } catch (\Throwable $e) {}
|
|
}
|
|
$discord->getLoop()->addTimer(0.5, function() use ($discord, $interaction) {
|
|
$interaction->updateOriginalResponse(MessageBuilder::new()->setContent("π **Hard Reset complete.** Voice states cleared. Try `/join` again."));
|
|
});
|
|
break;
|
|
|
|
case 'rejoin':
|
|
case 'join':
|
|
$userChannel = $interaction->member->getVoiceChannel();
|
|
if (!$userChannel) {
|
|
$interaction->updateOriginalResponse(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]);
|
|
}
|
|
|
|
$interaction->updateOriginalResponse(MessageBuilder::new()->setContent("β³ Joining " . $userChannel->name . "..."));
|
|
|
|
safeJoin($discord, $userChannel, $interaction, function($vc) use ($interaction) {
|
|
try {
|
|
$interaction->updateOriginalResponse(MessageBuilder::new()->setContent("β
Successfully joined " . $vc->channel->name));
|
|
} catch (\Throwable $e) {}
|
|
});
|
|
break;
|
|
|
|
case 'play':
|
|
$opts = $interaction->data->options;
|
|
$url = getOptionValue($opts, 'url');
|
|
if (!$url) {
|
|
$interaction->updateOriginalResponse(MessageBuilder::new()->setContent("β URL is missing!"));
|
|
return;
|
|
}
|
|
$userChannel = $interaction->member->getVoiceChannel();
|
|
if (!$userChannel) {
|
|
$interaction->updateOriginalResponse(MessageBuilder::new()->setContent("β Join a VC first!"));
|
|
return;
|
|
}
|
|
|
|
$interaction->updateOriginalResponse(MessageBuilder::new()->setContent("β³ Joining and preparing audio..."));
|
|
|
|
safeJoin($discord, $userChannel, $interaction, function($vc) use ($url, $interaction) {
|
|
try {
|
|
$interaction->updateOriginalResponse(MessageBuilder::new()->setContent("β
Connected to voice! Now loading audio..."));
|
|
} catch (\Throwable $e) {}
|
|
|
|
$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();
|
|
$interaction->updateOriginalResponse(MessageBuilder::new()->setContent("Stopped playback."));
|
|
} else {
|
|
$interaction->updateOriginalResponse(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();
|
|
$interaction->updateOriginalResponse(MessageBuilder::new()->setContent("β
Left voice channel."));
|
|
} catch (\Throwable $e) {
|
|
$interaction->updateOriginalResponse(MessageBuilder::new()->setContent("β
Left with error: " . $e->getMessage()));
|
|
}
|
|
} else {
|
|
$interaction->updateOriginalResponse(MessageBuilder::new()->setContent("π¨ Requested to leave voice channel."));
|
|
}
|
|
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)) {
|
|
$interaction->updateOriginalResponse(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)) {
|
|
$interaction->updateOriginalResponse(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]);
|
|
$interaction->updateOriginalResponse(MessageBuilder::new()->setContent("β
Alarm set to $time in " . $channel->name));
|
|
break;
|
|
|
|
case 'setalarm':
|
|
$opts = $interaction->data->options;
|
|
$link = getOptionValue($opts, 'link');
|
|
if (!$link) {
|
|
$interaction->updateOriginalResponse(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)) {
|
|
$interaction->updateOriginalResponse(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]);
|
|
$interaction->updateOriginalResponse(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();
|