0) { if (ob_end_flush() === false) break; } if (function_exists('ob_implicit_flush')) { ob_implicit_flush(true); } flush(); if (function_exists('fastcgi_finish_request')) { @fastcgi_finish_request(); } if ($isLiteSpeed && function_exists('litespeed_finish_request')) { @litespeed_finish_request(); } if (function_exists('ignore_user_abort')) { ignore_user_abort(true); } if (function_exists('set_time_limit')) { @set_time_limit(300); } } // ============================================================================ // Singleton Redis // ============================================================================ $_redisSingleton = null; function getRedisConnection() { global $_redisSingleton, $redisHost, $redisPort, $redisTimeout; if ($_redisSingleton !== null) { return $_redisSingleton; } try { $_redisSingleton = new Redis(); if (!$_redisSingleton->connect($redisHost, $redisPort, $redisTimeout)) { $_redisSingleton = null; throw new Exception("connect failed"); } $_redisSingleton->setOption(Redis::OPT_READ_TIMEOUT, 5); return $_redisSingleton; } catch (Exception $e) { $_redisSingleton = null; logGeneralError("Redis connection failed: " . $e->getMessage()); return null; } } function closeRedisConnection() { global $_redisSingleton; if ($_redisSingleton !== null) { try { $_redisSingleton->close(); } catch (Exception $e) {} $_redisSingleton = null; } } function resetRedisConnection() { global $_redisSingleton; if ($_redisSingleton !== null) { try { $_redisSingleton->close(); } catch (Exception $e) {} $_redisSingleton = null; } } // ============================================================================ // Singleton DB — global برای قابلیت reset از هر جا // ============================================================================ function getDbConnection() { global $_dbSingleton; if ($_dbSingleton !== null) { return $_dbSingleton; } $_dbSingleton = createDbConnection(); return $_dbSingleton; } function resetDbConnection() { global $_dbSingleton; $_dbSingleton = null; } function createDbConnection() { global $dbHost, $dbUser, $dbPass, $dbName; if (empty($dbHost) || empty($dbUser) || empty($dbName)) { logGeneralError('Database configuration missing'); return null; } try { $dsn = "mysql:host={$dbHost};dbname={$dbName};charset=utf8mb4"; $options = [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, PDO::ATTR_TIMEOUT => 2, PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true, ]; $db = new PDO($dsn, $dbUser, $dbPass, $options); try { $db->exec("SET sql_mode = 'STRICT_ALL_TABLES,NO_ENGINE_SUBSTITUTION'"); } catch (PDOException $e) { logGeneralError("SET sql_mode skipped: " . $e->getMessage()); } try { $db->exec("SET time_zone = '+00:00'"); } catch (PDOException $e) { logGeneralError("SET time_zone skipped: " . $e->getMessage()); } try { $db->exec("SET wait_timeout = 300, interactive_timeout = 300"); } catch (PDOException $e) { logGeneralError("SET wait_timeout skipped: " . $e->getMessage()); } return $db; } catch (PDOException $e) { logGeneralError('Database connection failed: ' . $e->getMessage()); return null; } } function isConnectionLostError(PDOException $e): bool { $msg = strtolower($e->getMessage()); $code = (int)$e->getCode(); return ( strpos($msg, 'gone away') !== false || strpos($msg, 'lost connection') !== false || strpos($msg, 'broken pipe') !== false || strpos($msg, 'connection reset') !== false || strpos($msg, 'no connection') !== false || strpos($msg, 'server has gone') !== false || $code === 2006 || $code === 2013 ); } function handleDbReconnect(string $context = ''): ?PDO { if ($context) logGeneralError("DB connection lost" . ($context ? " in {$context}" : "") . ", reconnecting"); resetDbConnection(); return getDbConnection(); } // ============================================================================ // لاگ‌گیری (batch) // ============================================================================ $botErrorLogs = []; $generalErrorLogs = []; define('DEBUG_MODE', false); $GLOBALS['_debug_mode_cache'] = null; function isDebugMode(): bool { if (isset($GLOBALS['_debug_mode_cache']) && $GLOBALS['_debug_mode_cache'] !== null) { return (bool)$GLOBALS['_debug_mode_cache']; } if (DEBUG_MODE) { $GLOBALS['_debug_mode_cache'] = true; return true; } $redis = getRedisConnection(); if ($redis) { try { $val = $redis->get(redisKey('debug_mode')); $GLOBALS['_debug_mode_cache'] = ($val === '1'); return (bool)$GLOBALS['_debug_mode_cache']; } catch (Exception $e) { $GLOBALS['_debug_mode_cache'] = false; return false; } } $GLOBALS['_debug_mode_cache'] = false; return false; } function resetDebugCache(): void { $GLOBALS['_debug_mode_cache'] = null; } function sanitizeLogData($data) { if (is_array($data)) { return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } $data = str_replace(["\r", "\n"], ['\\r', '\n'], $data); return mb_substr($data, 0, 1000, 'UTF-8'); } function sanitizeErrorMessage($message) { $message = preg_replace("/user '.*?'/i", "user '[REDACTED]'", $message); $message = preg_replace("/@'.*?'/i", "@'[REDACTED]'", $message); $message = preg_replace("/\b(host|hostname|server|username)\s*[:=]\s*['\"]?[^'\"\n]+['\"]?/i", "$1: [REDACTED]", $message); $message = preg_replace("/\bpassword\s*[:=]\s*['\"]?[^'\"\n]+['\"]?/i", "password: [REDACTED]", $message); $message = preg_replace("/\btoken\s*[:=]\s*['\"]?[^'\"\n]+['\"]?/i", "token: [REDACTED]", $message); $message = preg_replace("/\bbot\d{8,10}:[A-Za-z0-9_-]{35,}\b/i", "bot[REDACTED_TOKEN]", $message); $message = preg_replace("/\[(tcp|unix):\/\/.*?\]/i", "[REDACTED]", $message); $message = preg_replace("/\b(url|path|file)\s*[:=]\s*['\"]?[^'\"\n]+['\"]?/i", "$1: [REDACTED]", $message); $message = preg_replace('/\buser[_\s]?id[:\s=]+\d+/i', 'user_id=[REDACTED]', $message); $message = preg_replace('/\bupdate[_\s]?id[:\s=]+\d+/i', 'update_id=[REDACTED]', $message); $message = preg_replace('/\bchat[_\s]?id[:\s=]+-?\d+/i', 'chat_id=[REDACTED]', $message); $message = preg_replace('/\b(?:\d{1,3}\.){3}\d{1,3}\b/', '[REDACTED IP]', $message); $message = preg_replace('/\b(?:[0-9a-fA-F]{1,4}:){1,7}:|\b:(?::[0-9a-fA-F]{1,4}){1,7}\b|\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b/i', '[REDACTED IPv6]', $message); $message = preg_replace('/\b(https?|ftp|file):\/\/[-A-Za-z0-9+&@#\/%?=\~_|!:,.;]*[-A-Za-z0-9+&@#\/%=\~_|]/i', '[REDACTED URL]', $message); return $message; } function secureFilePutContents($filename, $data, $flags = FILE_APPEND | LOCK_EX) { global $logDirectory; $allowedDir = realpath($logDirectory); if ($allowedDir === false) { if (!@mkdir($logDirectory, 0755, true)) { throw new Exception('Log directory does not exist and cannot be created'); } $allowedDir = realpath($logDirectory); if ($allowedDir === false) { throw new Exception('Log directory path resolution failed'); } } if (pathinfo($filename, PATHINFO_EXTENSION) !== 'log') { throw new Exception('Invalid log extension: only .log files allowed'); } $fileDir = dirname($filename); $realFileDir = realpath($fileDir); if ($realFileDir === false) { $checkPath = $fileDir; $parentExists = false; $depth = 0; while ($depth < 10) { $checkPath = dirname($checkPath); if ($checkPath === '.' || $checkPath === '/' || $checkPath === '') break; $realCheckPath = realpath($checkPath); if ($realCheckPath !== false) { if (strpos($realCheckPath, $allowedDir) === 0) { $parentExists = true; break; } else { throw new Exception('Invalid log path: outside allowed directory'); } } $depth++; } if (!$parentExists) throw new Exception('Invalid log path: cannot verify directory'); if (!@mkdir($fileDir, 0755, true)) throw new Exception('Cannot create log directory'); $realFileDir = realpath($fileDir); if ($realFileDir === false) throw new Exception('Log directory path resolution failed after creation'); } if ($realFileDir !== $allowedDir) throw new Exception('Invalid log path: directory mismatch'); $normalizedFilename = realpath(dirname($filename)) . DIRECTORY_SEPARATOR . basename($filename); $result = @file_put_contents($normalizedFilename, $data, $flags); if ($result === false) throw new Exception('Failed to write log file'); return $result; } function logBotError($errorMessage, $userId = null, $chatId = null) { global $botErrorLogs; $timestamp = date('Y-m-d H:i:s'); $safeErrorMessage = sanitizeLogData(sanitizeErrorMessage($errorMessage)); if (empty($safeErrorMessage)) return; $context = ''; if ($userId) $context .= " User: [REDACTED]"; if ($chatId) $context .= " Chat: [REDACTED]"; $botErrorLogs[] = "[{$timestamp}] {$safeErrorMessage}{$context}" . PHP_EOL; } function logGeneralError($errorMessage) { global $generalErrorLogs; $timestamp = date('Y-m-d H:i:s'); $safeErrorMessage = sanitizeLogData(sanitizeErrorMessage($errorMessage)); if (empty($safeErrorMessage)) return; $generalErrorLogs[] = "[{$timestamp}] [ERROR] {$safeErrorMessage}" . PHP_EOL; } function logInfo($message) { if (!isDebugMode()) return; global $generalErrorLogs; $timestamp = date('Y-m-d H:i:s'); $safeMessage = sanitizeLogData($message); if (empty($safeMessage)) return; $generalErrorLogs[] = "[{$timestamp}] [INFO] {$safeMessage}" . PHP_EOL; } // ============================================================================ // بارگذاری تنظیمات // ============================================================================ function loadConfig() { $redis = getRedisConnection(); $configHashKey = redisKey('bot_config'); if ($redis) { try { $cachedConfig = $redis->hGetAll($configHashKey); if (!empty($cachedConfig)) return $cachedConfig; } catch (Exception $e) { logGeneralError("loadConfig Redis failed: " . $e->getMessage()); resetRedisConnection(); } } if (!file_exists(CONFIG_FILE)) exit('Configuration error'); $config = parse_ini_file(CONFIG_FILE, true); if (!$config) exit('Configuration error'); $flatConfig = []; foreach ($config as $section => $values) { foreach ($values as $key => $value) { $flatConfig[$section . '.' . $key] = $value; } } if ($redis) { try { $redis->hMSet($configHashKey, $flatConfig); $redis->expire($configHashKey, 86400); } catch (Exception $e) { logGeneralError("loadConfig Redis cache set failed: " . $e->getMessage()); } } return $flatConfig; } $flatConfig = loadConfig(); $botToken = $flatConfig['bot.token'] ?? ''; $adminId = $flatConfig['bot.admin_id'] ?? ''; $channel = $flatConfig['bot.support_channel'] ?? ''; $channelUrl = $flatConfig['bot.channel_url'] ?? ''; $supportUrl = $flatConfig['bot.support_url'] ?? 'https://t.me/imadmintwo'; $link1 = $flatConfig['bot.link1'] ?? ''; $link2 = $flatConfig['bot.link2'] ?? ''; $link3 = $flatConfig['bot.link3'] ?? ''; $link4 = $flatConfig['bot.link4'] ?? ''; $link5 = $flatConfig['bot.link5'] ?? ''; $secretToken = $flatConfig['bot.secret_token'] ?? ''; $dbHost = $flatConfig['database.host'] ?? ''; $dbUser = $flatConfig['database.username'] ?? ''; $dbPass = $flatConfig['database.password'] ?? ''; $dbName = $flatConfig['database.dbname'] ?? ''; $logDirectory = $flatConfig['logs.directory'] ?? __DIR__; $botErrorsLog = $logDirectory . '/' . BOT_ID . '_bot_errors.log'; $errorLog = $logDirectory . '/' . BOT_ID . '_error.log'; if (empty($botToken) || empty($dbHost) || empty($dbUser) || empty($dbName) || empty($adminId)) { exit('Configuration error'); } // ============================================================================ // مسیر CA // ============================================================================ $caInfoDetected = null; $caPaths = ['/etc/ssl/certs/ca-certificates.crt', '/etc/pki/tls/certs/ca-bundle.crt', '/usr/share/ssl/certs/ca-bundle.crt', '/etc/ssl/certs/ca-bundle.crt', '/etc/pki/tls/cacert.pem', '/etc/ssl/cert.pem']; foreach ($caPaths as $p) { if (file_exists($p)) { $caInfoDetected = $p; break; } } define('CA_INFO_PATH', $caInfoDetected); // ============================================================================ // Shutdown function // ============================================================================ register_shutdown_function(function () { global $botErrorLogs, $generalErrorLogs, $botErrorsLog, $errorLog; if (!empty($botErrorLogs) || !empty($generalErrorLogs)) { try { if (!empty($botErrorLogs)) { try { secureFilePutContents($botErrorsLog, implode('', $botErrorLogs)); } catch (Exception $e) { @file_put_contents($botErrorsLog, implode('', $botErrorLogs), FILE_APPEND | LOCK_EX); } } if (!empty($generalErrorLogs)) { try { secureFilePutContents($errorLog, implode('', $generalErrorLogs)); } catch (Exception $e) { @file_put_contents($errorLog, implode('', $generalErrorLogs), FILE_APPEND | LOCK_EX); } } } catch (Exception $e) { @file_put_contents( $errorLog, "[" . date('Y-m-d H:i:s') . "] Shutdown error: " . $e->getMessage() . PHP_EOL, FILE_APPEND | LOCK_EX ); } } closeRedisConnection(); }); // هدرهای امنیتی header("X-Content-Type-Options: nosniff"); header("X-Frame-Options: DENY"); header("X-XSS-Protection: 1; mode=block"); header("Strict-Transport-Security: max-age=31536000; includeSubDomains"); header("Content-Security-Policy: default-src 'self'"); header("Referrer-Policy: strict-origin-when-cross-origin"); header("Permissions-Policy: geolocation=(), microphone=(), camera=()"); if (!file_exists($logDirectory)) @mkdir($logDirectory, 0755, true); ini_set('log_errors', 1); ini_set('error_log', $errorLog); ini_set('max_execution_time', 300); error_reporting(E_ALL); ini_set('display_errors', 0); // ============================================================================ // atomicMarkAndEnqueue // ============================================================================ function atomicMarkAndEnqueue($updateId, $update) { $redis = getRedisConnection(); $updateData = json_encode($update); $ttl = 86400; $queueKey = redisKey('incoming_queue'); if ($redis) { if (!$updateId || !is_numeric($updateId) || $updateId <= 0 || $updateId > PHP_INT_MAX) { try { $redis->rPush($queueKey, $updateData); $redis->expire($queueKey, $ttl); return 'enqueued'; } catch (Exception $e) { logGeneralError("atomicMarkAndEnqueue rPush failed: " . $e->getMessage()); resetRedisConnection(); } } else { $processedKey = redisKey("processed_update:{$updateId}"); $luaScript = " local processedKey = KEYS[1] local queueKey = KEYS[2] local updateData = ARGV[1] local ttl = tonumber(ARGV[2]) if redis.call('EXISTS', processedKey) == 1 then return 0 end redis.call('SET', processedKey, '1', 'EX', ttl) redis.call('RPUSH', queueKey, updateData) redis.call('EXPIRE', queueKey, ttl) return 1 "; try { $result = $redis->eval($luaScript, [$processedKey, $queueKey, $updateData, $ttl], 2); if ($result == 1) return 'enqueued'; if ($result == 0) return 'duplicate'; } catch (Exception $e) { logGeneralError("atomicMarkAndEnqueue Lua failed: " . $e->getMessage()); resetRedisConnection(); } } } logGeneralError("atomicMarkAndEnqueue: Redis unavailable, falling back to MySQL"); return atomicMarkAndEnqueueMySQL($updateId, $update); } function atomicMarkAndEnqueueMySQL($updateId, $update) { if (!$updateId || !is_numeric($updateId) || $updateId <= 0 || $updateId > PHP_INT_MAX) { return 'process_direct'; } $db = getDbConnection(); if (!$db) { logGeneralError("atomicMarkAndEnqueueMySQL: DB unavailable"); return 'process_direct'; } try { $stmt = $db->prepare("INSERT IGNORE INTO processed_updates (update_id) VALUES (:update_id)"); $stmt->execute([':update_id' => $updateId]); return ($stmt->rowCount() > 0) ? 'process_direct' : 'duplicate'; } catch (PDOException $e) { if (isConnectionLostError($e)) { $db = handleDbReconnect('atomicMarkAndEnqueueMySQL'); if ($db) { try { $stmt = $db->prepare("INSERT IGNORE INTO processed_updates (update_id) VALUES (:update_id)"); $stmt->execute([':update_id' => $updateId]); return ($stmt->rowCount() > 0) ? 'process_direct' : 'duplicate'; } catch (PDOException $e2) { logGeneralError("atomicMarkAndEnqueueMySQL retry failed: " . $e2->getMessage()); } } } else { logGeneralError("atomicMarkAndEnqueueMySQL failed: " . $e->getMessage()); } return 'process_direct'; } } // ============================================================================ // atomicBatchPop // ============================================================================ function atomicBatchPop($queueKey, $count) { $redis = getRedisConnection(); if (!$redis) return []; $luaScript = " local key = KEYS[1] local count = tonumber(ARGV[1]) local results = {} for i = 1, count do local val = redis.call('LPOP', key) if val then table.insert(results, val) else break end end return results "; try { $results = $redis->eval($luaScript, [$queueKey, $count], 1); return $results ?: []; } catch (Exception $e) { logGeneralError("atomicBatchPop failed: " . $e->getMessage()); resetRedisConnection(); return []; } } // ============================================================================ // markUpdateAsProcessed // ============================================================================ function markUpdateAsProcessed($updateId) { if (!$updateId || !is_numeric($updateId) || $updateId <= 0 || $updateId > PHP_INT_MAX) return false; $redis = getRedisConnection(); if ($redis) { try { $key = redisKey("processed_update:{$updateId}"); $result = $redis->set($key, '1', ['NX', 'EX' => 86400]); return (bool)$result; } catch (Exception $e) { logGeneralError("markUpdateAsProcessed Redis failed: " . $e->getMessage()); resetRedisConnection(); } } $db = getDbConnection(); if (!$db) return true; try { $stmt = $db->prepare("INSERT IGNORE INTO processed_updates (update_id) VALUES (:update_id)"); $stmt->execute([':update_id' => $updateId]); return $stmt->rowCount() > 0; } catch (PDOException $e) { logGeneralError("markUpdateAsProcessed DB failed: " . $e->getMessage()); return true; } } function unmarkUpdate($updateId) { if (!$updateId || !is_numeric($updateId)) return; $redis = getRedisConnection(); if ($redis) { try { $redis->del(redisKey("processed_update:{$updateId}")); } catch (Exception $e) { logGeneralError("unmarkUpdate failed: " . $e->getMessage()); } } } // ============================================================================ // updateLastProcessedUpdateId // ============================================================================ function updateLastProcessedUpdateId($updateId) { if (!$updateId || !is_numeric($updateId)) return; $redis = getRedisConnection(); if ($redis) { try { $key = redisKey("last_processed_update_id"); $script = " local key = KEYS[1] local new_id = tonumber(ARGV[1]) local ttl = tonumber(ARGV[2]) local curr = tonumber(redis.call('GET', key) or '0') if new_id > curr then redis.call('SET', key, new_id, 'EX', ttl) return 1 end redis.call('EXPIRE', key, ttl) return 0 "; $redis->eval($script, [$key, $updateId, 604800], 1); } catch (Exception $e) { logGeneralError("updateLastProcessedUpdateId failed: " . $e->getMessage()); } } } // ============================================================================ // autoCleanupDatabase — تفکیک به دو فاز // فاز ۱ (autoCleanupCheck): فقط تشخیص/claim می‌کند — سبک و قبل از پاسخ اجرا می‌شود. // فاز ۲ (runAutoCleanupIfDue): DELETE های واقعی — فقط بعد از sendResponseAndContinue اجرا می‌شود. // ============================================================================ $_autoCleanupDue = false; function autoCleanupCheck() { global $_autoCleanupDue; $cleanupInterval = 86400; $cleanupKey = redisKey('last_cleanup_time'); $redis = getRedisConnection(); if ($redis) { try { $lastCleanup = (int)$redis->get($cleanupKey); if ($lastCleanup === 0 || (time() - $lastCleanup) >= $cleanupInterval) { $result = $redis->set($cleanupKey, time(), ['NX', 'EX' => $cleanupInterval + 3600]); if ($result) $_autoCleanupDue = true; } } catch (Exception $e) { logGeneralError("autoCleanupCheck Redis check failed: " . $e->getMessage()); resetRedisConnection(); } } else { $cleanupFile = $GLOBALS['logDirectory'] . '/' . BOT_ID . '_last_cleanup.lock'; if (file_exists($cleanupFile)) { $lastCleanup = (int)@file_get_contents($cleanupFile); if ((time() - $lastCleanup) < $cleanupInterval) return; } $fp = @fopen($cleanupFile, 'c+'); if ($fp && flock($fp, LOCK_EX | LOCK_NB)) { @file_put_contents($cleanupFile, time()); flock($fp, LOCK_UN); fclose($fp); $_autoCleanupDue = true; } else { if ($fp) fclose($fp); } } } function runAutoCleanupIfDue() { global $_autoCleanupDue; if (!$_autoCleanupDue) return; $_autoCleanupDue = false; $db = getDbConnection(); if (!$db) { logGeneralError("autoCleanupDatabase: DB unavailable"); return; } try { $db->prepare("DELETE FROM processed_updates WHERE created_at < NOW() - INTERVAL 24 HOUR")->execute(); $db->prepare("DELETE FROM user_requests WHERE created_at < NOW() - INTERVAL 7 DAY")->execute(); $db->prepare("DELETE FROM user_messages WHERE created_at < NOW() - INTERVAL 7 DAY")->execute(); $db->prepare("DELETE FROM start_command_usage WHERE created_at < NOW() - INTERVAL 30 DAY")->execute(); $db->prepare("DELETE FROM request_queue WHERE created_at < NOW() - INTERVAL 1 DAY")->execute(); $db->prepare("DELETE FROM failed_requests WHERE created_at < NOW() - INTERVAL 30 DAY")->execute(); logInfo("autoCleanupDatabase completed successfully"); } catch (PDOException $e) { if (isConnectionLostError($e)) { handleDbReconnect('autoCleanupDatabase'); } else { logGeneralError("autoCleanupDatabase failed: " . $e->getMessage()); } } } // ============================================================================ // isStaleUpdateId // ============================================================================ function isStaleUpdateId($updateId) { if (!$updateId || !is_numeric($updateId)) return false; $redis = getRedisConnection(); if (!$redis) return false; try { $lastProcessedKey = redisKey('last_processed_update_id'); $lastProcessed = $redis->get($lastProcessedKey); if ($lastProcessed === false || $lastProcessed === null) return false; $lastProcessed = (int)$lastProcessed; $stalenessThreshold = 1000; if ((int)$updateId < ($lastProcessed - $stalenessThreshold)) { logGeneralError("Stale update_id detected, gap exceeds threshold"); return true; } return false; } catch (Exception $e) { logGeneralError("isStaleUpdateId failed: " . $e->getMessage()); resetRedisConnection(); return false; } } // ============================================================================ // Global Rate Limiting // ============================================================================ function simpleGlobalRateLimit() { global $_apiCallCount; $_apiCallCount++; $redis = getRedisConnection(); if ($redis) { $key = redisKey('global_send_window'); $adaptiveMaxKey = redisKey('global_rate_max'); $now = microtime(true); try { $rawMax = $redis->get($adaptiveMaxKey); } catch (Exception $e) { resetRedisConnection(); usleep(10000); return; } $maxReq = ($rawMax !== false) ? (int)$rawMax : 28; $maxReq = max(15, min(28, $maxReq)); $luaScript = " local key = KEYS[1] local now = tonumber(ARGV[1]) local max_req = tonumber(ARGV[2]) local window = tonumber(ARGV[3]) local uid = ARGV[4] redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window) local count = redis.call('ZCARD', key) if count < max_req then redis.call('ZADD', key, now, uid) redis.call('EXPIRE', key, window * 2) return 1 else local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES') if oldest and #oldest > 0 then return math.floor(tonumber(oldest[2]) * 1000) end return 0 end "; try { $uniqueId = uniqid('', true); $result = $redis->eval($luaScript, [$key, $now, $maxReq, 1.0, $uniqueId], 1); if ($result !== 1) { $oldestMs = (int)$result; if ($oldestMs > 0) { $oldestScore = $oldestMs / 1000.0; $sleep = ($oldestScore + 1.0 - $now) + 0.05; if ($sleep > 0 && $sleep < 2) { usleep((int)($sleep * 1000000)); } } } } catch (Exception $e) { logGeneralError("simpleGlobalRateLimit error: " . $e->getMessage()); resetRedisConnection(); usleep(10000); } } else { usleep(10000); } } // ============================================================================ // دریافت و اعتبارسنجی ورودی // ============================================================================ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit('Method Not Allowed'); } $contentType = $_SERVER['CONTENT_TYPE'] ?? ''; if (stripos($contentType, 'application/json') === false && stripos($contentType, 'text/plain') === false) { http_response_code(415); exit('Unsupported Media Type'); } $maxInputSize = 1024 * 1024; $input = file_get_contents('php://input'); if (strlen($input) > $maxInputSize) { logGeneralError("Input size exceeded"); http_response_code(413); exit; } $update = json_decode($input, true); if (!is_array($update)) { $rawInputSample = trim($input); if ($rawInputSample === '') { $rawInputSample = '[EMPTY BODY]'; } else { $rawInputSample = mb_substr($rawInputSample, 0, 150, 'UTF-8') . (mb_strlen($rawInputSample, 'UTF-8') > 150 ? '...' : ''); } $jsonError = json_last_error_msg(); logGeneralError("Invalid JSON input. Method: {$_SERVER['REQUEST_METHOD']} | Content-Type: {$contentType} | Payload: {$rawInputSample} | JSON Error: {$jsonError}"); http_response_code(400); exit; } if (empty($update)) { sendResponseAndContinue('OK (empty update)'); exit; } $receivedSecret = $_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN'] ?? ''; if (!empty($secretToken) && !hash_equals($secretToken, $receivedSecret)) { http_response_code(403); exit('Unauthorized'); } $currentUpdateId = $update['update_id'] ?? null; if ($currentUpdateId && (!is_numeric($currentUpdateId) || $currentUpdateId <= 0 || $currentUpdateId > PHP_INT_MAX)) { logGeneralError("Invalid update_id received"); sendResponseAndContinue('OK (invalid update_id)'); exit; } if ($currentUpdateId && isStaleUpdateId($currentUpdateId)) { sendResponseAndContinue('OK (stale update_id ignored)'); exit; } // ============================================================================ // اتصال DB // ============================================================================ $db = getDbConnection(); if (!$db) { logGeneralError('Database connection failed at startup'); $redis = getRedisConnection(); if ($redis && $currentUpdateId) { $enqueued = atomicMarkAndEnqueue($currentUpdateId, $update); if ($enqueued === 'enqueued') { sendResponseAndContinue('OK'); closeRedisConnection(); exit; } } http_response_code(500); exit('Database connection error'); } // ============================================================================ // ساخت جداول // ============================================================================ $createUsersTable = "CREATE TABLE IF NOT EXISTS users (user_id BIGINT PRIMARY KEY, username VARCHAR(255), first_name VARCHAR(255), last_name VARCHAR(255), is_member BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"; $createUserRequestsTable = "CREATE TABLE IF NOT EXISTS user_requests (id INT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT, request_type VARCHAR(50), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX(user_id, created_at), INDEX(created_at)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"; $createUserMessagesTable = "CREATE TABLE IF NOT EXISTS user_messages (id INT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT, message_text TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX(user_id, created_at), INDEX(created_at)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"; $createBlockedUsersTable = "CREATE TABLE IF NOT EXISTS blocked_users (user_id BIGINT PRIMARY KEY, reason VARCHAR(255), blocked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"; $createStartCommandTable = "CREATE TABLE IF NOT EXISTS start_command_usage (id INT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX(user_id, created_at), INDEX(created_at)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"; $createRequestQueueTable = "CREATE TABLE IF NOT EXISTS request_queue (id INT AUTO_INCREMENT PRIMARY KEY, method VARCHAR(50), params TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, original_created_at TIMESTAMP NULL DEFAULT NULL, INDEX(created_at)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"; $createProcessedUpdatesTable = "CREATE TABLE IF NOT EXISTS processed_updates (update_id BIGINT PRIMARY KEY, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX(created_at)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"; $createFailedRequestsTable = "CREATE TABLE IF NOT EXISTS failed_requests (id INT AUTO_INCREMENT PRIMARY KEY, method VARCHAR(50), params TEXT, retry_count INT DEFAULT 0, last_error VARCHAR(500), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX(created_at), INDEX(method)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"; $createPanelTextsTable = "CREATE TABLE IF NOT EXISTS panel_texts (panel_name VARCHAR(50) PRIMARY KEY, content TEXT NOT NULL, entities TEXT NULL DEFAULT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"; $schemaFlagFile = __DIR__ . '/' . BOT_ID . '_db_schema_ready.flag'; if (!file_exists($schemaFlagFile)) { $lockFile = __DIR__ . '/' . BOT_ID . '_schema_creation.lock'; $fp = @fopen($lockFile, 'c+'); if ($fp && flock($fp, LOCK_EX)) { if (!file_exists($schemaFlagFile)) { try { $db->exec($createUsersTable); $db->exec($createUserRequestsTable); $db->exec($createUserMessagesTable); $db->exec($createBlockedUsersTable); $db->exec($createStartCommandTable); $db->exec($createRequestQueueTable); $db->exec($createProcessedUpdatesTable); $db->exec($createFailedRequestsTable); $db->exec($createPanelTextsTable); file_put_contents($schemaFlagFile, date('Y-m-d H:i:s')); logInfo("Database schema created successfully."); } catch (PDOException $e) { logGeneralError('Table creation error: ' . $e->getMessage()); } } flock($fp, LOCK_UN); } if ($fp) fclose($fp); } // ============================================================================ // مهاجرت یک‌باره: افزودن ستون entities به panel_texts (برای نصب‌های قبلی که این جدول را از قبل داشتند) // ✅ مستقل از schemaFlagFile بالا، چون آن فلگ روی هاست‌های فعلی از قبل ست شده و دیگر اجرا نمی‌شود // ============================================================================ $entitiesMigrationFlag = __DIR__ . '/' . BOT_ID . '_panel_entities_migration.flag'; if (!file_exists($entitiesMigrationFlag)) { $migLockFile = __DIR__ . '/' . BOT_ID . '_panel_entities_migration.lock'; $migFp = @fopen($migLockFile, 'c+'); if ($migFp && flock($migFp, LOCK_EX)) { if (!file_exists($entitiesMigrationFlag)) { try { $db->exec("ALTER TABLE panel_texts ADD COLUMN IF NOT EXISTS entities TEXT NULL DEFAULT NULL"); file_put_contents($entitiesMigrationFlag, date('Y-m-d H:i:s')); logInfo("panel_texts.entities migration applied successfully."); } catch (PDOException $e) { logGeneralError('panel_texts entities migration error: ' . $e->getMessage()); } } flock($migFp, LOCK_UN); } if ($migFp) fclose($migFp); } // ============================================================================ // مهاجرت یک‌باره: افزودن ستون original_created_at به request_queue (برای رفع باگ صف‌شدن سرِ خط) // ============================================================================ $rqMigrationFlag = __DIR__ . '/' . BOT_ID . '_request_queue_original_ts_migration.flag'; if (!file_exists($rqMigrationFlag)) { $rqMigLockFile = __DIR__ . '/' . BOT_ID . '_request_queue_original_ts_migration.lock'; $rqMigFp = @fopen($rqMigLockFile, 'c+'); if ($rqMigFp && flock($rqMigFp, LOCK_EX)) { if (!file_exists($rqMigrationFlag)) { try { $db->exec("ALTER TABLE request_queue ADD COLUMN IF NOT EXISTS original_created_at TIMESTAMP NULL DEFAULT NULL"); file_put_contents($rqMigrationFlag, date('Y-m-d H:i:s')); logInfo("request_queue.original_created_at migration applied successfully."); } catch (PDOException $e) { logGeneralError('request_queue original_created_at migration error: ' . $e->getMessage()); } } flock($rqMigFp, LOCK_UN); } if ($rqMigFp) fclose($rqMigFp); } autoCleanupCheck(); // ✅ فقط تشخیص/claim سبک — DELETE واقعی بعد از پاسخ در runAutoCleanupIfDue() اجرا می‌شود // ============================================================================ // Pre-check بلاک // ============================================================================ $precheckUserId = $update['message']['from']['id'] ?? $update['callback_query']['from']['id'] ?? null; if ($precheckUserId && $precheckUserId != $adminId && isUserBlocked($precheckUserId)) { logInfo("Pre-queue rejection: blocked user detected"); sendResponseAndContinue('OK (blocked user)'); runAutoCleanupIfDue(); // ✅ اگر claim شده بود، همین‌جا بعد از پاسخ اجرا شود تا از دست نرود closeRedisConnection(); exit; } // ============================================================================ // منطق اصلی با سه حالت enqueue // ============================================================================ $enqueued = atomicMarkAndEnqueue($currentUpdateId, $update); if ($enqueued === 'duplicate') { sendResponseAndContinue('OK (duplicate update ignored)'); runAutoCleanupIfDue(); // ✅ اگر claim شده بود، همین‌جا بعد از پاسخ اجرا شود تا از دست نرود closeRedisConnection(); exit; } if ($enqueued === 'enqueued') { sendResponseAndContinue('OK'); runAutoCleanupIfDue(); // ✅ فاز سنگین پاک‌سازی، حالا که پاسخ به تلگرام قبلاً ارسال شده $processingStartTime = microtime(true); $_apiCallCount = 0; processIncomingQueue(); $elapsed = microtime(true) - $processingStartTime; $remainingBudget = $processingTimeBudget - $elapsed; if ($remainingBudget > 3) { $_apiCallCount = 0; processRequestQueue(15, $remainingBudget); } try { if (!empty($GLOBALS['botErrorLogs'])) { secureFilePutContents($botErrorsLog, implode('', $GLOBALS['botErrorLogs'])); $GLOBALS['botErrorLogs'] = []; } if (!empty($GLOBALS['generalErrorLogs'])) { secureFilePutContents($errorLog, implode('', $GLOBALS['generalErrorLogs'])); $GLOBALS['generalErrorLogs'] = []; } } catch (Exception $e) { @file_put_contents($errorLog, '[' . date('Y-m-d H:i:s') . '] Log flush error: ' . $e->getMessage() . PHP_EOL, FILE_APPEND | LOCK_EX); } closeRedisConnection(); exit; } // ============================================================================ // process_direct (با مکانیزم قفل Redis و Fallback به File Lock) // ============================================================================ if ($enqueued === 'process_direct') { sendResponseAndContinue('OK'); runAutoCleanupIfDue(); // ✅ فاز سنگین پاک‌سازی، حالا که پاسخ به تلگرام قبلاً ارسال شده $_apiCallCount = 0; $lockAcquired = false; $usedRedisLock = false; $mutexKey = redisKey('process_direct_lock'); $mutexVal = uniqid(BOT_ID . '_pd_', true); $mutexTtl = 5; $redis = getRedisConnection(); if ($redis) { $maxWaitMs = 3000; $waitedMs = 0; $sleepMs = 250; while ($waitedMs < $maxWaitMs) { try { if ($redis->set($mutexKey, $mutexVal, ['NX', 'EX' => $mutexTtl])) { $lockAcquired = true; $usedRedisLock = true; break; } } catch (Exception $e) { logGeneralError("process_direct: Redis lock error"); break; } usleep($sleepMs * 1000); $waitedMs += $sleepMs; } } if (!$lockAcquired && !$redis) { $lockFile = $logDirectory . '/' . BOT_ID . '_process_update.lock'; $fp = @fopen($lockFile, 'c+'); $maxWaitSeconds = 3; $waitedMs = 0; $sleepIntervalMs = 250; if ($fp !== false) { while ($waitedMs < ($maxWaitSeconds * 1000)) { if (flock($fp, LOCK_EX | LOCK_NB)) { $lockAcquired = true; break; } usleep($sleepIntervalMs * 1000); $waitedMs += $sleepIntervalMs; } } else { logGeneralError("process_direct: Failed to open lock file at {$lockFile}"); } } if ($lockAcquired) { $fallbackStartTime = microtime(true); try { processUpdate($update); if ($currentUpdateId) updateLastProcessedUpdateId($currentUpdateId); logInfo("process_direct: update processed successfully"); } catch (\Throwable $e) { if ($currentUpdateId) unmarkUpdate($currentUpdateId); logGeneralError("processUpdate (direct) failed: " . $e->getMessage()); } $elapsed = microtime(true) - $fallbackStartTime; $remainingBudget = $processingTimeBudget - $elapsed; if ($remainingBudget > 3) { $_apiCallCount = 0; processRequestQueue(15, $remainingBudget); } if ($usedRedisLock) { try { $releaseLua = " if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end"; $redis->eval($releaseLua, [$mutexKey, $mutexVal], 1); } catch (Exception $e) { logGeneralError("process_direct: Redis lock release failed"); } } else { if (isset($fp) && $fp) { flock($fp, LOCK_UN); fclose($fp); } } } else { logGeneralError("process_direct: lock not acquired, attempting deferred queue"); $alreadyProcessed = false; if ($currentUpdateId) { $redis = getRedisConnection(); if ($redis) { try { $alreadyProcessed = (bool)$redis->exists(redisKey("processed_update:{$currentUpdateId}")); if ($alreadyProcessed) logInfo("process_direct: update already marked, skipping"); } catch (Exception $e) { logGeneralError("process_direct: Redis exists check failed"); } } else { $dbCheck = getDbConnection(); if ($dbCheck) { try { $stmtCheck = $dbCheck->prepare("SELECT COUNT(*) FROM processed_updates WHERE update_id = :uid"); $stmtCheck->execute([':uid' => $currentUpdateId]); $alreadyProcessed = ((int)$stmtCheck->fetchColumn() > 0); } catch (PDOException $e) { logGeneralError("process_direct: DB exists check failed"); } } } } if (!$alreadyProcessed) { $savedToQueue = false; $dbFallback = getDbConnection(); if ($dbFallback) { try { $stmt = $dbFallback->prepare("INSERT INTO request_queue (method, params, created_at) VALUES ('__processUpdate', :params, NOW())"); $stmt->execute([':params' => json_encode(['__update_payload' => $update, '__queued_at' => time(), '__reason' => 'process_direct_lock_timeout'])]); $savedToQueue = true; logInfo("process_direct: update saved to deferred queue"); } catch (PDOException $e) { if (isConnectionLostError($e)) { $dbFallback = handleDbReconnect('process_direct fallback'); if ($dbFallback) { try { $stmt = $dbFallback->prepare("INSERT INTO request_queue (method, params, created_at) VALUES ('__processUpdate', :params, NOW())"); $stmt->execute([':params' => json_encode(['__update_payload' => $update, '__queued_at' => time(), '__reason' => 'process_direct_lock_timeout'])]); $savedToQueue = true; } catch (PDOException $e2) { logGeneralError("process_direct: MySQL fallback retry failed"); } } } else { logGeneralError("process_direct: MySQL fallback failed"); } } } else { logGeneralError("process_direct: lock timeout AND DB unavailable"); } if ($savedToQueue && $currentUpdateId) { unmarkUpdate($currentUpdateId); } } } closeRedisConnection(); exit; } // ============================================================================ // processIncomingQueue // ============================================================================ function processIncomingQueue() { $redis = getRedisConnection(); if (!$redis) { logGeneralError("processIncomingQueue: Redis unavailable"); return; } $mutexKey = redisKey('process_queue_lock'); $mutexTtl = 15; $mutexVal = uniqid(BOT_ID . '_', true); try { $locked = $redis->set($mutexKey, $mutexVal, ['NX', 'EX' => $mutexTtl]); } catch (Exception $e) { logGeneralError("processIncomingQueue: mutex set failed: " . $e->getMessage()); resetRedisConnection(); return; } if (!$locked) { logInfo("processIncomingQueue: skipped (another worker holds the mutex)"); return; } $queueKey = redisKey('incoming_queue'); $failedKey = redisKey('failed_updates'); global $processingStartTime, $processingTimeBudget; $maxExecutionTime = $processingTimeBudget - 5; if ($maxExecutionTime < 5) $maxExecutionTime = 5; $maxTotalUpdates = 15; $totalProcessed = 0; try { while ($totalProcessed < $maxTotalUpdates) { if ((microtime(true) - $processingStartTime) >= $maxExecutionTime) { logGeneralError("Max execution time reached, stopping queue"); break; } try { $renewScript = " if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('EXPIRE', KEYS[1], ARGV[2]) end return 0 "; $redis->eval($renewScript, [$mutexKey, $mutexVal, $mutexTtl], 1); } catch (Exception $e) { logGeneralError("processIncomingQueue: mutex renew failed"); } $messages = atomicBatchPop($queueKey, 5); if (empty($messages)) break; foreach ($messages as $rawUpdate) { $upd = json_decode($rawUpdate, true); if (!is_array($upd)) { logGeneralError("Invalid JSON in queue"); continue; } $updateId = $upd['update_id'] ?? null; try { processUpdate($upd); if ($updateId) updateLastProcessedUpdateId($updateId); $totalProcessed++; } catch (\Throwable $e) { try { $r = getRedisConnection(); if ($r) { $r->rPush($failedKey, $rawUpdate); $r->expire($failedKey, 86400); } } catch (Exception $re) {} if ($updateId) unmarkUpdate($updateId); logGeneralError("Failed to process update: " . $e->getMessage()); } } } $failedCount = 0; $maxFailedTotal = 10; while ($failedCount < $maxFailedTotal) { if ((microtime(true) - $processingStartTime) >= $maxExecutionTime) break; try { $renewScript = " if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('EXPIRE', KEYS[1], ARGV[2]) end return 0 "; $redis->eval($renewScript, [$mutexKey, $mutexVal, $mutexTtl], 1); } catch (Exception $e) {} $messages = atomicBatchPop($failedKey, 3); if (empty($messages)) break; foreach ($messages as $rawUpdate) { $upd = json_decode($rawUpdate, true); if (!is_array($upd)) continue; $updateRetryCount = $upd['_retry_count'] ?? 0; if ($updateRetryCount >= 3) { logGeneralError("Update discarded after max retries, saving to failed_requests"); $db = getDbConnection(); if ($db) { try { $db->prepare("INSERT INTO failed_requests (method, params, retry_count, last_error) VALUES (:m, :p, :r, :e)") ->execute([ ':m' => '__processUpdate', ':p' => $rawUpdate, ':r' => $updateRetryCount, ':e' => 'Max retries exceeded in processIncomingQueue' ]); } catch (PDOException $dbEx) { logGeneralError("Failed to save discarded update to failed_requests: " . $dbEx->getMessage()); } } $failedCount++; continue; } $updateId = $upd['update_id'] ?? null; try { processUpdate($upd); if ($updateId) { markUpdateAsProcessed($updateId); updateLastProcessedUpdateId($updateId); } $failedCount++; } catch (\Throwable $e) { $upd['_retry_count'] = $updateRetryCount + 1; try { $r = getRedisConnection(); if ($r) $r->rPush($failedKey, json_encode($upd)); } catch (Exception $re) {} logGeneralError("Retry failed (attempt " . ($updateRetryCount + 1) . ")"); $failedCount++; } } } if ($totalProcessed > 0 || $failedCount > 0) { logInfo("Processed {$totalProcessed} updates, {$failedCount} retried"); } } finally { try { $releaseLua = " if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end "; $r = getRedisConnection(); if ($r) $r->eval($releaseLua, [$mutexKey, $mutexVal], 1); } catch (Exception $e) { logGeneralError("processIncomingQueue: mutex release failed"); } } } // ============================================================================ // متون پیش‌فرض پنل‌ها // ============================================================================ $panelTextDefaults = [ 'multi_location' => "تنظیم نشده است ❌", 'tutorial' => "🧩 لیست نرم افزارها\nآخرین ورژن نرم افزار مربوطه را دانلود و نصب کنید.", // ✅ متن (برچسب) دکمه‌های پنل «لیست نرم‌افزارها» — هرکدام مثل یک متن پنل مستقل، قابل ویرایش با /edit 'tutorial_btn1' => "📱 آخرین نسخه V2rayNG برای آندروید", 'tutorial_btn2' => "🍎 آخرین نسخه V2box برای ios و mac", 'tutorial_btn3' => "🖥️ آخرین نسخه V2rayN برای ویندوز", 'tutorial_btn4' => "🔗 لینک اضافی ۱", 'tutorial_btn5' => "🔗 لینک اضافی ۲", 'premium' => "⭐ اشتراک تلگرام پرمیوم بصورت گیفتی ⭐\nاشتراک پرمیوم 3 ماهه 2350 تومان\nاشتراک پرمیوم 6 ماهه 3090 تومان\nاشتراک پرمیوم 12 ماهه 5450 تومان\n💳 برای خرید به پشتیبانی پیام بدین 👇", 'membership_alert' => "❌ شما هنوز عضو کانال نیستید.\nبرای مشاهده اطلاعیه ها، ابتدا در کانال پشتیبانی عضو شوید.", 'test_account' => "فعلا اکانت تست موجود نیست.", 'main_menu' => "🟢 برای مشاهده سرویسهای V2ray، دکمه های 1⃣ و 2⃣ رو لمس کنین؛\n✅ فعال با تمام اپراتورها\n✅ آندروید، آیفون، کامپیوتر\n✅ پشتیبانی تا آخرین روز اشتراک\n✅ سازگار با انواع هوش مصنوعی، اینستاگرام، یوتیوب و سرویسهای گوگل", 'channel_join' => "📥 کاربر عزیز ؛\nبرای مشاهده اطلاعیه ها، تخفیف ها و آموزش ها ، بهتر است در کانال پشتیبانی عضو شوید.", ]; // ============================================================================ // توابع مدیریت متن پنل‌ها (DB + Redis) // ============================================================================ function getPanelText($panelName) { global $panelTextDefaults; $redis = getRedisConnection(); $keyMap = [ 'multi_location' => 'multi_location_text', 'tutorial' => 'tutorial_text', 'premium' => 'premium_text', 'membership_alert' => 'membership_alert_text', 'test_account' => 'test_account_alert', 'main_menu' => 'main_menu_text', 'channel_join' => 'channel_join_text', 'tutorial_btn1' => 'tutorial_btn1_text', 'tutorial_btn2' => 'tutorial_btn2_text', 'tutorial_btn3' => 'tutorial_btn3_text', 'tutorial_btn4' => 'tutorial_btn4_text', 'tutorial_btn5' => 'tutorial_btn5_text', ]; $redisSuffix = $keyMap[$panelName] ?? $panelName; $cacheKey = redisKey($redisSuffix); if ($redis) { try { $cached = $redis->get($cacheKey); if ($cached !== false && $cached !== null) return $cached; } catch (Exception $e) {} } $db = getDbConnection(); if ($db) { try { $stmt = $db->prepare("SELECT content FROM panel_texts WHERE panel_name = :pn"); $stmt->execute([':pn' => $panelName]); $row = $stmt->fetch(); if ($row) { $text = $row['content']; if ($redis) { try { $redis->set($cacheKey, $text, ['EX' => 2592000]); } catch (Exception $e) {} } return $text; } } catch (PDOException $e) { if (isConnectionLostError($e)) handleDbReconnect('getPanelText'); } } return $panelTextDefaults[$panelName] ?? 'تنظیم نشده است ❌'; } function savePanelText($panelName, $newText, $entities = null) { $db = getDbConnection(); if (!$db) return false; try { if ($entities !== null) { // ✅ برای پنل‌های حفظ-فرمت (main_menu / multi_location): متن خام + entities (فرمت‌بندی دقیق تلگرام) با هم ذخیره می‌شن $entitiesJson = json_encode($entities); $stmt = $db->prepare("INSERT INTO panel_texts (panel_name, content, entities) VALUES (:pn, :ct, :en) ON DUPLICATE KEY UPDATE content = :ct2, entities = :en2"); $stmt->execute([':pn' => $panelName, ':ct' => $newText, ':en' => $entitiesJson, ':ct2' => $newText, ':en2' => $entitiesJson]); } else { $stmt = $db->prepare("INSERT INTO panel_texts (panel_name, content) VALUES (:pn, :ct) ON DUPLICATE KEY UPDATE content = :ct2"); $stmt->execute([':pn' => $panelName, ':ct' => $newText, ':ct2' => $newText]); } $redis = getRedisConnection(); if ($redis) { $keyMap = [ 'multi_location' => 'multi_location_text', 'tutorial' => 'tutorial_text', 'premium' => 'premium_text', 'membership_alert' => 'membership_alert_text', 'test_account' => 'test_account_alert', 'main_menu' => 'main_menu_text', 'channel_join' => 'channel_join_text', 'tutorial_btn1' => 'tutorial_btn1_text', 'tutorial_btn2' => 'tutorial_btn2_text', 'tutorial_btn3' => 'tutorial_btn3_text', 'tutorial_btn4' => 'tutorial_btn4_text', 'tutorial_btn5' => 'tutorial_btn5_text', ]; $cacheKey = redisKey($keyMap[$panelName] ?? $panelName); try { $redis->set($cacheKey, $newText, ['EX' => 2592000]); } catch (Exception $e) {} // ✅ چون برچسب دکمه بخشی از کیبورد کامل پنل «tutorial» است که جداگانه کش می‌شود، // با تغییر هر برچسب دکمه، کش کل کیبورد باطل می‌شود تا بلافاصله رفرش شود. if (str_starts_with($panelName, 'tutorial_btn')) { try { $redis->del(redisKey('tutorial_panel_keyboard_v2')); } catch (Exception $e) {} } // ✅ کش مخصوص پنل‌های حفظ-فرمت رو هم به‌روز/باطل می‌کنیم تا بلافاصله فرمت جدید اعمال بشه if ($entities !== null) { try { $redis->set(redisKey($panelName . '_fmt'), json_encode(['text' => $newText, 'entities' => $entities]), ['EX' => 2592000]); } catch (Exception $e) {} } } return true; } catch (PDOException $e) { if (isConnectionLostError($e)) { handleDbReconnect('savePanelText'); return false; } else { logGeneralError("savePanelText failed: " . $e->getMessage()); return false; } } } // ✅ نسخه‌ی حفظ-فرمت getPanelText — فقط برای پنل‌هایی که باید دقیقاً همان فرمتی که ادمین تایپ کرده (Bold/Italic/Link و...) نمایش داده بشه. // برخلاف getPanelText، این تابع متن خام + آرایه‌ی entities تلگرام رو برمی‌گردونه؛ چیزی escape یا تغییر داده نمی‌شه. function getPanelTextWithEntities($panelName) { global $panelTextDefaults; $redis = getRedisConnection(); $cacheKey = redisKey($panelName . '_fmt'); if ($redis) { try { $cached = $redis->get($cacheKey); if ($cached !== false && $cached !== null) { $decoded = json_decode($cached, true); if (is_array($decoded) && array_key_exists('text', $decoded)) { return ['text' => $decoded['text'], 'entities' => $decoded['entities'] ?? []]; } } } catch (Exception $e) {} } $db = getDbConnection(); if ($db) { try { $stmt = $db->prepare("SELECT content, entities FROM panel_texts WHERE panel_name = :pn"); $stmt->execute([':pn' => $panelName]); $row = $stmt->fetch(); if ($row) { $entities = []; if (!empty($row['entities'])) { $decodedEntities = json_decode($row['entities'], true); if (is_array($decodedEntities)) $entities = $decodedEntities; } $result = ['text' => $row['content'], 'entities' => $entities]; if ($redis) { try { $redis->set($cacheKey, json_encode($result), ['EX' => 2592000]); } catch (Exception $e) {} } return $result; } } catch (PDOException $e) { if (isConnectionLostError($e)) handleDbReconnect('getPanelTextWithEntities'); else logGeneralError("getPanelTextWithEntities failed: " . $e->getMessage()); } } return ['text' => $panelTextDefaults[$panelName] ?? 'تنظیم نشده است ❌', 'entities' => []]; } // ============================================================================ // processUpdate // ============================================================================ function processUpdate($update) { global $adminId, $channel, $botToken, $supportUrl, $link3, $link4, $link5; $db = getDbConnection(); if (!$db) { logGeneralError('Database unavailable in processUpdate'); return; } $message = $update['message'] ?? null; if ($message) { $userId = $message['from']['id'] ?? 0; $chatId = $message['chat']['id'] ?? 0; $text = $message['text'] ?? ''; if ($userId == $adminId) { $redis = getRedisConnection(); if ($redis) { $stateKey = redisKey("edit_state:{$userId}"); $pendingPanel = $redis->get($stateKey); if ($pendingPanel && !empty($text) && !str_starts_with($text, '/')) { $allowedPanels = [ 'multi_location' => ['label' => 'سرویس خرید V2ray'], 'tutorial' => ['label' => 'لیست نرم‌افزارها'], 'premium' => ['label' => 'تلگرام پرمیوم'], 'membership_alert' => ['label' => 'اخطار عضویت'], 'test_account' => ['label' => 'اکانت تست'], 'main_menu' => ['label' => 'منوی اصلی'], 'channel_join' => ['label' => 'پیام عضویت در کانال'], // ✅ برچسب دکمه‌های پنل «لیست نرم‌افزارها» — همان استراتژی، فقط سقف کاراکتر کوتاه‌تر (چون متن دکمه است، نه بدنه‌ی پیام) 'tutorial_btn1' => ['label' => 'دکمه آندروید (V2rayNG)'], 'tutorial_btn2' => ['label' => 'دکمه iOS/Mac (V2box)'], 'tutorial_btn3' => ['label' => 'دکمه ویندوز (V2rayN)'], 'tutorial_btn4' => ['label' => 'دکمه اضافه ۱'], 'tutorial_btn5' => ['label' => 'دکمه اضافه ۲'], ]; if (isset($allowedPanels[$pendingPanel])) { $isButtonLabel = str_starts_with($pendingPanel, 'tutorial_btn'); $maxLen = $isButtonLabel ? 60 : 4000; $textLength = mb_strlen($text, 'UTF-8'); if ($textLength > $maxLen) { sendTelegramRequest('sendMessage', [ 'chat_id' => $chatId, 'text' => $isButtonLabel ? "❌ متن دکمه خیلی طولانی است ({$textLength} کاراکتر). حداکثر مجاز برای متن دکمه: {$maxLen} کاراکتر." : "❌ متن خیلی طولانی است ({$textLength} کاراکتر). لطفاً متن کوتاه‌تری ارسال کنید.", 'parse_mode' => 'HTML' ]); } else { // ✅ برای این دو پنل، فرمت‌بندی دقیق تلگرام (Bold/Italic/Link/...) هم گرفته و ذخیره می‌شه $isFormatPreserving = in_array($pendingPanel, ['main_menu', 'multi_location'], true); if ($isFormatPreserving) { savePanelText($pendingPanel, $text, $message['entities'] ?? []); } else { savePanelText($pendingPanel, $text); } $redis->del($stateKey); sendTelegramRequest('sendMessage', [ 'chat_id' => $chatId, 'text' => $isButtonLabel ? "✅ متن دکمه «{$allowedPanels[$pendingPanel]['label']}» با موفقیت به‌روزرسانی شد." : "✅ متن پنل «{$allowedPanels[$pendingPanel]['label']}» با موفقیت به‌روزرسانی شد.", 'parse_mode' => 'HTML' ]); } } else { $redis->del($stateKey); sendTelegramRequest('sendMessage', [ 'chat_id' => $chatId, 'text' => "⚠️ پنل نامعتبر. لطفاً دوباره /edit را اجرا کنید.", 'parse_mode' => 'HTML' ]); } return; } } } } if (isset($update['message'])) { $message = $update['message']; $chatId = $message['chat']['id']; $userId = $message['from']['id']; $text = $message['text'] ?? ''; if (!is_numeric($userId) || $userId <= 0 || !is_numeric($chatId) || $chatId <= 0) return; $isAdminUnblockCommand = ($userId == $adminId && str_starts_with($text, '/unblock ')); $isAdminClearCommand = ($userId == $adminId && $text == '/clear'); $isAdminRedisCommand = ($userId == $adminId && $text == '/redis'); $isAdminClearCacheCommand = ($userId == $adminId && $text == '/clearcache'); $isAdminHelpCommand = ($userId == $adminId && $text == '/help'); $isAdminAmarCommand = ($userId == $adminId && $text == '/amar'); $isAdminFailedCommand = ($userId == $adminId && $text == '/failed'); $isAdminClearFailedCommand = ($userId == $adminId && $text == '/clearfailed'); $isAdminDebugOnCommand = ($userId == $adminId && $text == '/debug on'); $isAdminDebugOffCommand = ($userId == $adminId && $text == '/debug off'); $isAdminDebugStatusCommand = ($userId == $adminId && $text == '/debug'); $isAdminReloadConfigCommand = ($userId == $adminId && $text == '/reloadconfig'); $isAdminEditCommand = ($userId == $adminId && $text === '/edit'); if (!$isAdminUnblockCommand && !$isAdminClearCommand && !$isAdminRedisCommand && !$isAdminClearCacheCommand && !$isAdminHelpCommand && !$isAdminAmarCommand && !$isAdminFailedCommand && !$isAdminClearFailedCommand && !$isAdminDebugOnCommand && !$isAdminDebugOffCommand && !$isAdminDebugStatusCommand && !$isAdminReloadConfigCommand && !$isAdminEditCommand && !handleUserSafety($userId, $text, 'message')) { return; } if ($userId != $adminId && $text != '/start') { blockUser($userId, "Unauthorized message"); return; } try { $username = $message['from']['username'] ?? ''; $firstName = $message['from']['first_name'] ?? ''; $lastName = $message['from']['last_name'] ?? ''; $safeUsername = htmlspecialchars(mb_substr($username, 0, 255, 'UTF-8'), ENT_QUOTES, 'UTF-8'); $safeFirstName = htmlspecialchars(mb_substr($firstName, 0, 255, 'UTF-8'), ENT_QUOTES, 'UTF-8'); $safeLastName = htmlspecialchars(mb_substr($lastName, 0, 255, 'UTF-8'), ENT_QUOTES, 'UTF-8'); $stmt = $db->prepare("INSERT INTO users (user_id, username, first_name, last_name) VALUES (:uid, :un, :fn, :ln) ON DUPLICATE KEY UPDATE username=:un2, first_name=:fn2, last_name=:ln2"); $stmt->execute([ ':uid' => $userId, ':un' => $safeUsername, ':fn' => $safeFirstName, ':ln' => $safeLastName, ':un2' => $safeUsername, ':fn2' => $safeFirstName, ':ln2' => $safeLastName, ]); $redis = getRedisConnection(); if ($redis) { try { $redis->hDel(redisKey("user_hash:{$userId}"), 'info'); } catch (Exception $e) {} } } catch (PDOException $e) { if (isConnectionLostError($e)) { $db = handleDbReconnect('processUpdate user save'); } else { logGeneralError("User DB operation failed: " . $e->getMessage()); } } if ($text == '/start') { try { $stmtStart = $db->prepare("INSERT INTO start_command_usage (user_id) VALUES (:uid)"); $stmtStart->execute([':uid' => $userId]); } catch (PDOException $e) { if (isConnectionLostError($e)) { $db = handleDbReconnect('start_command_usage insert'); } else { logGeneralError("start_command_usage insert failed: " . $e->getMessage()); } } if (userIsMemberInDB($userId)) showMainMenu($chatId); else showChannelMenu($chatId); } elseif ($isAdminUnblockCommand) { $targetUserId = trim(substr($text, 9)); if (is_numeric($targetUserId) && $targetUserId > 0) { $targetUserIdInt = (int)$targetUserId; $targetUser = getUserInfo($targetUserIdInt); $targetUsername = htmlspecialchars($targetUser['username'] ?? 'N/A', ENT_QUOTES, 'UTF-8'); $targetFirstName = htmlspecialchars($targetUser['first_name'] ?? 'N/A', ENT_QUOTES, 'UTF-8'); $targetLastName = htmlspecialchars($targetUser['last_name'] ?? '', ENT_QUOTES, 'UTF-8'); $targetFullName = trim($targetFirstName . ' ' . $targetLastName); $success = unblockUser($targetUserIdInt); if ($success) { $msg = "✅ User Successfully Unblocked\n" . "🆔 ID: {$targetUserIdInt}\n" . "👤 Name: {$targetFullName}\n" . "📛 Username: @{$targetUsername}\n" . "🕒 Time: " . date('Y-m-d H:i:s') . "\n" . "ℹ️ The user can now use the bot again."; sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => $msg, 'parse_mode' => 'HTML']); sendUnblockNotificationToAdmin($targetUserIdInt, true); } else { $msg = "❌ User Not Found\n" . "🆔 Requested ID: {$targetUserIdInt}\n" . "⚠️ This user is not in the blocked users list.\n" . "💡 Use /amar to view blocked users statistics."; sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => $msg, 'parse_mode' => 'HTML']); } } else { $msg = "❌ Invalid Command Format\n" . "📝 Correct Usage:\n" . "/unblock 123456789\n" . "💡 Replace 123456789 with the numeric user ID."; sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => $msg, 'parse_mode' => 'HTML']); } } elseif ($isAdminFailedCommand) { try { $total = $db->query("SELECT COUNT(*) FROM failed_requests")->fetchColumn() ?? 0; $byMethod = $db->query("SELECT method, COUNT(*) as cnt FROM failed_requests GROUP BY method ORDER BY cnt DESC")->fetchAll(); $msg = "📋 Failed Requests Report\n"; $msg .= "🔢 Total: {$total} items\n"; if (!empty($byMethod)) { $msg .= "📊 Breakdown by Method:\n"; $totalByMethod = 0; foreach ($byMethod as $row) { $totalByMethod += $row['cnt']; $msg .= "├─ {$row['method']}: {$row['cnt']} items\n"; } if ($totalByMethod < $total) { $msg .= "└─ Other methods: " . ($total - $totalByMethod) . " items\n"; } } else { $msg .= "✨ No failed requests found.\n"; } $msg .= "\n💡 Related Commands:\n"; $msg .= "• /clearfailed — Clear the entire list\n"; $msg .= "• /amar — View general bot statistics"; sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => $msg, 'parse_mode' => 'HTML']); } catch (PDOException $e) { logGeneralError("failed stats query failed: " . $e->getMessage()); sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => "❌ Error retrieving failed requests statistics.", 'parse_mode' => 'HTML']); } } elseif ($isAdminClearFailedCommand) { try { $total = $db->query("SELECT COUNT(*) FROM failed_requests")->fetchColumn() ?? 0; if ($total > 0) { $db->exec("TRUNCATE TABLE failed_requests"); $msg = "✅ Cleanup Completed Successfully\n" . "🗑️ Items Removed: {$total}\n" . "📋 Table: failed_requests\n" . "🕒 Time: " . date('Y-m-d H:i:s') . "\n" . "ℹ️ This action is irreversible."; } else { $msg = "ℹ️ List Already Empty\n" . "No failed requests to clear."; } sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => $msg, 'parse_mode' => 'HTML']); } catch (PDOException $e) { logGeneralError("clearfailed failed: " . $e->getMessage()); sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => "❌ Error clearing failed requests.", 'parse_mode' => 'HTML']); } } elseif ($isAdminClearCommand) { global $botErrorsLog, $errorLog, $logDirectory; $tables = ['users', 'user_requests', 'user_messages', 'start_command_usage', 'request_queue', 'processed_updates', 'failed_requests']; $tableCounts = []; $totalCleared = 0; $failedTables = []; foreach ($tables as $table) { try { $count = $db->query("SELECT COUNT(*) FROM {$table}")->fetchColumn(); $tableCounts[$table] = (int)$count; $totalCleared += (int)$count; $db->exec("TRUNCATE TABLE {$table}"); } catch (PDOException $e) { logGeneralError("/clear: TRUNCATE {$table} failed"); $tableCounts[$table] = 0; $failedTables[] = $table; } } $logsCleared = 0; if (file_exists($botErrorsLog)) { try { $logsCleared++; secureFilePutContents($botErrorsLog, '', LOCK_EX); } catch (Exception $e) { logGeneralError("/clear: bot_errors clear failed"); } } if (file_exists($errorLog)) { try { $logsCleared++; secureFilePutContents($errorLog, '', LOCK_EX); } catch (Exception $e) { logGeneralError("/clear: error.log clear failed"); } } $GLOBALS['botErrorLogs'] = []; $GLOBALS['generalErrorLogs'] = []; $redisCleared = 0; $redis = getRedisConnection(); if ($redis) { try { $mutexKey = redisKey('process_queue_lock'); $rqMutexKey = redisKey('rq_lock'); $mutexVal = null; $rqMutexVal = null; try { $mutexVal = $redis->get($mutexKey); $rqMutexVal = $redis->get($rqMutexKey); } catch (Exception $e) {} $iterator = null; $pattern = redisKey('*'); while ($keys = $redis->scan($iterator, $pattern, 1000)) { if (!empty($keys)) { $keysToDelete = array_filter($keys, fn($k) => $k !== $mutexKey && $k !== $rqMutexKey); if (!empty($keysToDelete)) { $redisCleared += count($keysToDelete); // ✅ استفاده از UNLINK برای حذف غیرمسدودکننده if (method_exists($redis, 'unlink')) { $redis->unlink(array_values($keysToDelete)); } else { $redis->del(array_values($keysToDelete)); } } } } if ($mutexVal !== null && $mutexVal !== false) { try { $redis->set($mutexKey, $mutexVal, ['EX' => 15]); } catch (Exception $e) {} } if ($rqMutexVal !== null && $rqMutexVal !== false) { try { $redis->set($rqMutexKey, $rqMutexVal, ['EX' => 15]); } catch (Exception $e) {} } logInfo("/clear: deleted {$redisCleared} Redis keys (mutexes preserved)"); } catch (Exception $e) { logGeneralError("/clear: Redis SCAN failed"); resetRedisConnection(); } } resetDebugCache(); if (function_exists('opcache_reset')) opcache_reset(); $tempDir = $logDirectory; $tempLocks = glob($tempDir . '/' . BOT_ID . '_*'); $tempCleared = 0; if ($tempLocks) { foreach ($tempLocks as $file) { if (is_file($file) && pathinfo($file, PATHINFO_EXTENSION) === 'lock') { @unlink($file); $tempCleared++; } } } $msg = "🧹 Full Cleanup Completed Successfully\n"; $msg .= "📊 Operation Summary:\n"; $msg .= "├─ 🔢 Total rows deleted: {$totalCleared}\n"; $msg .= "├─ 🗄️ Tables cleared: " . count($tables) . "\n"; $msg .= "├─ 🔑 Redis keys deleted: {$redisCleared}\n"; $msg .= "├─ 📝 Log files cleared: {$logsCleared}\n"; $msg .= "└─ 📁 Temp files removed: {$tempCleared}\n"; $msg .= "📋 Table Details:\n"; $tableLabels = [ 'users' => '👥 Users', 'user_requests' => '📬 User Requests', 'user_messages' => '💬 User Messages', 'start_command_usage' => '🚀 /start Usage', 'request_queue' => '📋 Request Queue', 'processed_updates' => '✅ Processed Updates', 'failed_requests' => '❌ Failed Requests', ]; foreach ($tables as $table) { $label = $tableLabels[$table] ?? $table; $count = $tableCounts[$table]; $status = in_array($table, $failedTables) ? ' ⚠️' : ' ✅'; $msg .= "─ {$label}: {$count} items{$status}\n"; } $msg .= "\n⚠️ Important Notes:\n"; $msg .= "• The blocked_users and panel_texts tables were preserved\n"; $msg .= "• Use /unblock to unblock users\n"; $msg .= "• This operation is irreversible"; sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => $msg, 'parse_mode' => 'HTML']); } elseif ($isAdminRedisCommand) { $redis = getRedisConnection(); if ($redis) { try { $info = $redis->info(); $adaptiveMax = (int)($redis->get(redisKey('global_rate_max')) ?: 28); $usedMemory = isset($info['used_memory_human']) ? $info['used_memory_human'] : 'Unknown'; $connectedClients = $info['connected_clients'] ?? 0; $uptime = $info['uptime_in_seconds'] ?? 0; $uptimeDays = floor($uptime / 86400); $uptimeHours = floor(($uptime % 86400) / 3600); $uptimeMinutes = floor(($uptime % 3600) / 60); $uptimeStr = ''; if ($uptimeDays > 0) $uptimeStr .= "{$uptimeDays}d "; if ($uptimeHours > 0) $uptimeStr .= "{$uptimeHours}h "; $uptimeStr .= "{$uptimeMinutes}m"; $totalKeys = 0; try { $totalKeys = $redis->dbSize(); } catch (Exception $e) {} $msg = "🟢 Redis Status Report\n"; $msg .= "📌 Version : {$info['redis_version']}\n"; $msg .= "⏱️ Uptime : {$uptimeStr}\n"; $msg .= "👥 Connected Clients : {$connectedClients}\n"; $msg .= "🔑 Total Keys : {$totalKeys}\n"; $msg .= "💾 Memory Usage : {$usedMemory}\n"; $msg .= "📊 Current Rate Limit : {$adaptiveMax}/s\n"; $msg .= "✅ Status : Redis is working properly"; sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => $msg, 'parse_mode' => 'HTML']); } catch (Exception $e) { sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => "✅ Redis is connected but retrieving info failed.\n❌ Error: " . mb_substr($e->getMessage(), 0, 200, 'UTF-8'), 'parse_mode' => 'HTML']); resetRedisConnection(); } } else { sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => "❌ Redis Connection Failed\nPlease check the Redis service status on your host.", 'parse_mode' => 'HTML']); } } elseif ($isAdminClearCacheCommand) { $redis = getRedisConnection(); if ($redis) { $cacheItems = [ 'bot_config' => '⚙️ Main Config', 'main_menu_keyboard' => '🎹 Main Menu Keyboard', 'channel_menu_keyboard' => '🎹 Channel Menu Keyboard', 'tutorial_panel_keyboard_v2' => '🎹 Tutorial Panel Keyboard', 'tutorial_text' => '📝 Tutorial Text', 'tutorial_btn1_text' => '📝 Tutorial Button 1 Text', 'tutorial_btn2_text' => '📝 Tutorial Button 2 Text', 'tutorial_btn3_text' => '📝 Tutorial Button 3 Text', 'tutorial_btn4_text' => '📝 Tutorial Button 4 Text', 'tutorial_btn5_text' => '📝 Tutorial Button 5 Text', 'premium_panel_keyboard' => '🎹 Premium Panel Keyboard', 'premium_text' => '📝 Premium Text', 'test_account_alert' => '💬 Test Account Alert', 'membership_alert_text' => '💬 Membership Alert', 'multi_location_keyboard' => '🎹 Multi Location Keyboard', 'multi_location_text' => '📝 Multi Location Text', 'multi_location_fmt' => '📝 Multi Location Formatted (entities)', 'main_menu_text' => '📝 Main Menu Text', 'main_menu_fmt' => '📝 Main Menu Formatted (entities)', 'channel_join_text' => '📝 Channel Join Text', ]; $clearedItems = []; $failedItems = []; foreach (array_keys($cacheItems) as $k) { try { $redis->del(redisKey($k)); $clearedItems[] = $cacheItems[$k]; } catch (Exception $e) { $failedItems[] = $cacheItems[$k]; } } // ✅ بهینه‌سازی: حذف SCAN و استفاده از Set Registry و UNLINK $channelMemberCleared = 0; $registryKey = redisKey('channel_members_registry'); try { $members = $redis->sMembers($registryKey); if (!empty($members)) { $channelMemberCleared = count($members); if (method_exists($redis, 'unlink')) { $redis->unlink($members); } else { $redis->del($members); } $redis->del($registryKey); $clearedItems[] = "👥 Channel Membership Cache ({$channelMemberCleared} users)"; } } catch (Exception $e) { $failedItems[] = "👥 Channel Membership Cache"; } resetDebugCache(); $msg = "🧹 Cache Cleanup Completed Successfully\n"; $msg .= "✅ Cleared Items (" . count($clearedItems) . "):\n"; foreach ($clearedItems as $item) { $msg .= "─ {$item}\n"; } if (!empty($failedItems)) { $msg .= "\n⚠️ Failed Items (" . count($failedItems) . "):\n"; foreach ($failedItems as $item) { $msg .= "├─ {$item}\n"; } } $msg .= "\n🕒 Time: " . date('Y-m-d H:i:s') . "\n"; $msg .= "ℹ️ Caches will be rebuilt from database on the next request."; sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => $msg, 'parse_mode' => 'HTML']); } else { sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => "❌ Redis Unavailable\nRedis connection is required to clear the cache.", 'parse_mode' => 'HTML']); } } elseif ($isAdminDebugOnCommand) { $redis = getRedisConnection(); if ($redis) { try { $redis->set(redisKey('debug_mode'), '1', ['EX' => 3600]); resetDebugCache(); $msg = "🔍 Debug Mode Enabled\n" . "⏱️ Duration: 1 hour (60 minutes)\n" . "🕒 Activation Time: " . date('Y-m-d H:i:s') . "\n" . "🕔 Expiration Time: " . date('Y-m-d H:i:s', time() + 3600) . "\n" . "📋 Effects:\n" . "• INFO logs will be recorded from now on\n" . "• More details will be saved in error.log\n" . "• Useful for troubleshooting issues\n" . "💡 Disable: /debug off\n" . "📊 Check Status: /debug"; sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => $msg, 'parse_mode' => 'HTML']); } catch (Exception $e) { logGeneralError("/debug on Redis set failed"); sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => "❌ Error enabling Debug Mode\n" . mb_substr($e->getMessage(), 0, 200, 'UTF-8'), 'parse_mode' => 'HTML']); } } else { sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => "❌ Redis Unavailable\nDebug Mode requires Redis to store settings.", 'parse_mode' => 'HTML']); } } elseif ($isAdminDebugOffCommand) { $redis = getRedisConnection(); if ($redis) { try { $existed = $redis->del(redisKey('debug_mode')); resetDebugCache(); if ($existed) { $msg = "✅ Debug Mode Disabled\n" . "🕒 Time: " . date('Y-m-d H:i:s') . "\n" . "📋 Effects:\n" . "• INFO logs will no longer be recorded\n" . "• Only errors will be saved in logs\n" . "• Bot performance returned to normal"; } else { $msg = "ℹ️ Debug Mode Was Already Disabled\n" . "No changes were made."; } sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => $msg, 'parse_mode' => 'HTML']); } catch (Exception $e) { logGeneralError("/debug off Redis del failed"); sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => "❌ Error disabling Debug Mode", 'parse_mode' => 'HTML']); } } else { sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => "❌ Redis Unavailable", 'parse_mode' => 'HTML']); } } elseif ($isAdminDebugStatusCommand) { $redis = getRedisConnection(); $debugActive = false; $ttlRemaining = 0; $adaptiveMax = 28; if ($redis) { try { $val = $redis->get(redisKey('debug_mode')); $debugActive = ($val === '1'); $ttlRemaining = $debugActive ? (int)$redis->ttl(redisKey('debug_mode')) : 0; $adaptiveMax = (int)($redis->get(redisKey('global_rate_max')) ?: 28); } catch (Exception $e) { logGeneralError("/debug status Redis error"); } } $constStatus = DEBUG_MODE ? "✅ Enabled (hardcoded)" : "❌ Disabled (hardcoded)"; if ($debugActive) { $minutes = floor($ttlRemaining / 60); $seconds = $ttlRemaining % 60; $msg = "🔍 Debug Mode Status\n" . "🟢 Redis Status: Enabled\n" . "⏳ Time Remaining: {$minutes}m {$seconds}s\n" . "🕔 Expiration Time: " . date('Y-m-d H:i:s', time() + $ttlRemaining) . "\n" . "📌 Constant: {$constStatus}\n" . "📊 Current Rate Max: {$adaptiveMax}/s\n" . "💡 Disable: /debug off\n" . "🔄 Renew: /debug on"; } else { $msg = "🔍 Debug Mode Status\n" . "🔴 Redis Status: Disabled\n" . "📌 Constant: {$constStatus}\n" . "📊 Current Rate Max: {$adaptiveMax}/s\n" . "💡 Enable: /debug on (for 1 hour)"; } sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => $msg, 'parse_mode' => 'HTML']); } elseif ($isAdminHelpCommand) { $helpText = "🛠️ Bot Admin Panel\n" . "━━━━━━━━━━━━━━━━━━━━\n" . "👤 User Management:\n" . "├─ /amar — View full bot statistics\n" . "└─ /unblock ID — Unblock a blocked user\n" . "🗄️ System Management:\n" . "├─ /clear — Full database & cache cleanup\n" . "├─ /redis — View Redis status\n" . "├─ /clearcache — Clear bot cache (texts preserved in DB)\n" . "└─ /reloadconfig — Reload configuration file\n" . "✏️ Content Management:\n" . "└─ /edit — Edit panel texts (interactive)\n" . " 1. Send /edit command\n" . " 2. Select a panel from the inline keyboard\n" . " 3. Send the new text message (max 4000 chars)\n" . " Available panels: multi_location, tutorial, premium,\n" . " membership_alert, test_account, main_menu, channel_join\n" . "❌ Error Management:\n" . "├─ /failed — View failed requests\n" . "└─ /clearfailed — Clear failed requests list\n" . "🔍 Debug Mode:\n" . "├─ /debug — View Debug Mode status\n" . "├─ /debug on — Enable (for 1 hour)\n" . "└─ /debug off — Disable\n" . "━━━━━━━━━━━━━━━━━━━━\n" . "💡 Notes:\n" . "• blocked_users & panel_texts tables are NOT cleared by /clear\n" . "• Debug Mode auto-disables after 1 hour\n" . "• Panel texts are stored in database and cached in Redis"; sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => $helpText, 'parse_mode' => 'HTML']); } elseif ($isAdminAmarCommand) { try { $totalUsers = $db->query("SELECT COUNT(*) FROM users")->fetchColumn() ?? 0; $memberUsers = $db->query("SELECT COUNT(*) FROM users WHERE is_member=TRUE")->fetchColumn() ?? 0; $nonMemberUsers = $totalUsers - $memberUsers; $blockedUsers = $db->query("SELECT COUNT(*) FROM blocked_users")->fetchColumn() ?? 0; $failedReqs = $db->query("SELECT COUNT(*) FROM failed_requests")->fetchColumn() ?? 0; $queuePending = $db->query("SELECT COUNT(*) FROM request_queue")->fetchColumn() ?? 0; $processedUpdates = $db->query("SELECT COUNT(*) FROM processed_updates")->fetchColumn() ?? 0; $yesterday = date('Y-m-d H:i:s', time() - 86400); $stmt = $db->prepare("SELECT COUNT(*) FROM user_requests WHERE created_at > :y"); $stmt->execute([':y' => $yesterday]); $recentReqs = $stmt->fetchColumn() ?? 0; $startToday = date('Y-m-d H:i:s', strtotime('today')); $stmtStart = $db->prepare("SELECT COUNT(*) FROM start_command_usage WHERE created_at >= :t"); $stmtStart->execute([':t' => $startToday]); $startCount = $stmtStart->fetchColumn() ?? 0; $debugStatus = isDebugMode() ? "🟢 Enabled" : "🔴 Disabled"; $adaptiveMax = 28; $redis = getRedisConnection(); if ($redis) { try { $adaptiveMax = (int)($redis->get(redisKey('global_rate_max')) ?: 28); } catch (Exception $e) {} } $ts = date('Y-m-d H:i:s'); $memberPercent = $totalUsers > 0 ? round(($memberUsers / $totalUsers) * 100, 1) : 0; $msg = "📊 Complete Bot Statistics\n" . "🕒 Time: {$ts}\n" . "━━━━━━━━━━━━━━━━━━━━\n" . "👥 Users :\n" . "├─ Total registered users: {$totalUsers}\n" . "├─ ✅ Channel members: {$memberUsers} ({$memberPercent}%)\n" . "├─ ❌ Non-members: {$nonMemberUsers}\n" . "└─ 🚫 Blocked: {$blockedUsers}\n" . "📈 Activity :\n" . "├─ 🚀 /start today: {$startCount}\n" . "├─ 📬 Requests (24h): {$recentReqs}\n" . "└─ ✅ Processed updates: {$processedUpdates}\n" . "⚙️ System :\n" . "├─ 📋 Pending queue: {$queuePending}\n" . "├─ ❌ Failed: {$failedReqs}\n" . "├─ 🔍 Debug: {$debugStatus}\n" . "└─ 📊 Rate Max: {$adaptiveMax}/s\n" . "━━━━━━━━━━━━━━━━━━━━\n" . "💡 Quick Commands:\n" . "• /failed — Check failed requests\n" . "• /clear — Full cleanup"; sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => $msg, 'parse_mode' => 'HTML']); } catch (PDOException $e) { if (isConnectionLostError($e)) { handleDbReconnect('amar command'); } else { logGeneralError("amar query failed: " . $e->getMessage()); } sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => "❌ Error retrieving statistics.\n" . mb_substr($e->getMessage(), 0, 200, 'UTF-8'), 'parse_mode' => 'HTML']); } } elseif ($isAdminReloadConfigCommand) { try { $redis = getRedisConnection(); if ($redis) { try { $redis->del(redisKey('bot_config')); } catch (Exception $e) { logGeneralError("/reloadconfig: Redis del failed"); } } $newConfig = loadConfig(); $GLOBALS['botToken'] = $newConfig['bot.token'] ?? ''; $GLOBALS['adminId'] = $newConfig['bot.admin_id'] ?? ''; $GLOBALS['channel'] = $newConfig['bot.support_channel'] ?? ''; $GLOBALS['channelUrl'] = $newConfig['bot.channel_url'] ?? ''; $GLOBALS['supportUrl'] = $newConfig['bot.support_url'] ?? 'https://t.me/imadmintwo'; $GLOBALS['link1'] = $newConfig['bot.link1'] ?? ''; $GLOBALS['link2'] = $newConfig['bot.link2'] ?? ''; $GLOBALS['link3'] = $newConfig['bot.link3'] ?? ''; $GLOBALS['link4'] = $newConfig['bot.link4'] ?? ''; $GLOBALS['link5'] = $newConfig['bot.link5'] ?? ''; $GLOBALS['secretToken'] = $newConfig['bot.secret_token'] ?? ''; $GLOBALS['dbHost'] = $newConfig['database.host'] ?? ''; $GLOBALS['dbUser'] = $newConfig['database.username'] ?? ''; $GLOBALS['dbPass'] = $newConfig['database.password'] ?? ''; $GLOBALS['dbName'] = $newConfig['database.dbname'] ?? ''; resetDbConnection(); $msg = "✅ Configuration Reloaded Successfully\n" . "📄 Source: " . basename(CONFIG_FILE) . "\n" . "🔑 Keys Loaded: " . count($newConfig) . "\n" . "🕒 Time: " . date('Y-m-d H:i:s') . "\n" . "ℹ️ Database connection will be re-established on next query.\n" . "💡 Related: /clearcache for full cache cleanup."; sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => $msg, 'parse_mode' => 'HTML']); } catch (Exception $e) { logGeneralError("/reloadconfig failed: " . $e->getMessage()); sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => "❌ Error reloading configuration.\n" . mb_substr($e->getMessage(), 0, 200, 'UTF-8'), 'parse_mode' => 'HTML']); } } elseif ($isAdminEditCommand) { $keyboard = ['inline_keyboard' => [ [['text' => '💳 خرید سرویس V2ray', 'callback_data' => 'edit_panel:multi_location']], [['text' => '🧩 لیست نرم‌افزارها', 'callback_data' => 'edit_panel:tutorial']], [['text' => '⭐ تلگرام پرمیوم', 'callback_data' => 'edit_panel:premium']], [['text' => '🚫 اخطار عضویت', 'callback_data' => 'edit_panel:membership_alert']], [['text' => '🥚 اکانت تست', 'callback_data' => 'edit_panel:test_account']], [['text' => '🏠 منوی اصلی', 'callback_data' => 'edit_panel:main_menu']], [['text' => '📢 پیام عضویت در کانال', 'callback_data' => 'edit_panel:channel_join']], [['text' => '🔘 دکمه‌های پنل «لیست نرم‌افزارها»', 'callback_data' => 'edit_tutorial_buttons_menu']], ]]; sendTelegramRequest('sendMessage', [ 'chat_id' => $chatId, 'text' => "📝 ویرایش متن پنل‌ها\nلطفاً پنل مورد نظر را انتخاب کنید:", 'parse_mode' => 'HTML', 'reply_markup' => json_encode($keyboard) ]); } } if (isset($update['callback_query'])) { $callback = $update['callback_query']; $userId = $callback['from']['id']; $chatId = $callback['message']['chat']['id']; $messageId = $callback['message']['message_id']; $data = $callback['data']; if (!is_numeric($userId) || $userId <= 0 || !is_numeric($chatId) || $chatId <= 0) return; if (!is_string($data) || strlen($data) > 64 || !preg_match('/^[a-z0-9_:]+$/i', $data)) { logGeneralError("Invalid callback_data received"); blockUser($userId, "Invalid callback_data"); return; } $isAdminUnblockCallback = (str_starts_with($data, 'unblock_user:') && $userId == $adminId); $isAdminEditPanelCallback = (str_starts_with($data, 'edit_panel:') && $userId == $adminId); if (!$isAdminUnblockCallback && !$isAdminEditPanelCallback && !handleUserSafety($userId, '', 'callback')) return; $allowedCallbacks = ['check_membership', 'back_to_main', 'test_account', 'tutorial', 'telegram_premium', 'multi_location']; if ($userId != $adminId && !in_array($data, $allowedCallbacks) && !$isAdminUnblockCallback && !$isAdminEditPanelCallback) { blockUser($userId, "Unauthorized callback"); return; } if ($userId != $adminId) { try { $db->prepare("INSERT INTO user_requests (user_id, request_type) VALUES (:uid, :rt)")->execute([':uid' => $userId, ':rt' => 'callback:' . mb_substr($data, 0, 40, 'UTF-8')]); } catch (PDOException $e) { if (isConnectionLostError($e)) { $db = handleDbReconnect('callback user_requests insert'); } else { logGeneralError("callback user_requests insert failed"); } } } if ($isAdminEditPanelCallback) { $panel = substr($data, 11); $allowedPanels = [ 'multi_location' => ['label' => 'سرویس خرید V2ray'], 'tutorial' => ['label' => 'لیست نرم‌افزارها'], 'premium' => ['label' => 'تلگرام پرمیوم'], 'membership_alert' => ['label' => 'اخطار عضویت'], 'test_account' => ['label' => 'اکانت تست'], 'main_menu' => ['label' => 'منوی اصلی'], 'channel_join' => ['label' => 'پیام عضویت در کانال'], 'tutorial_btn1' => ['label' => 'دکمه آندروید (V2rayNG)'], 'tutorial_btn2' => ['label' => 'دکمه iOS/Mac (V2box)'], 'tutorial_btn3' => ['label' => 'دکمه ویندوز (V2rayN)'], 'tutorial_btn4' => ['label' => 'دکمه اضافه ۱'], 'tutorial_btn5' => ['label' => 'دکمه اضافه ۲'], ]; if (isset($allowedPanels[$panel])) { $redis = getRedisConnection(); if ($redis) { $stateKey = redisKey("edit_state:{$userId}"); $redis->set($stateKey, $panel, ['EX' => 300]); $isButtonLabelPrompt = str_starts_with($panel, 'tutorial_btn'); sendTelegramRequest('sendMessage', [ 'chat_id' => $chatId, 'text' => $isButtonLabelPrompt ? "✏️ لطفاً متن جدید برای «{$allowedPanels[$panel]['label']}» را ارسال کنید.\n⚠️ حداکثر ۶۰ کاراکتر (چون متن روی دکمه نمایش داده می‌شود)." : "✏️ لطفاً متن جدید برای پنل «{$allowedPanels[$panel]['label']}» را ارسال کنید.\n⚠️ حداکثر ۴۰۰۰ کاراکتر.", 'parse_mode' => 'HTML' ]); } else { sendTelegramRequest('answerCallbackQuery', [ 'callback_query_id' => $callback['id'], 'text' => 'Redis در دسترس نیست.', 'show_alert' => true ]); } } else { sendTelegramRequest('answerCallbackQuery', [ 'callback_query_id' => $callback['id'], 'text' => 'پنل نامعتبر.', 'show_alert' => true ]); } return; } // ✅ زیرمنوی ویرایش برچسب دکمه‌های پنل «لیست نرم‌افزارها» — همان استراتژی edit_panel، فقط در یک زیرمنوی جداگانه if ($data === 'edit_tutorial_buttons_menu' && $userId == $adminId) { $btnKeyboard = ['inline_keyboard' => [ [['text' => '1️⃣ دکمه آندروید (V2rayNG)', 'callback_data' => 'edit_panel:tutorial_btn1']], [['text' => '2️⃣ دکمه iOS/Mac (V2box)', 'callback_data' => 'edit_panel:tutorial_btn2']], [['text' => '3️⃣ دکمه ویندوز (V2rayN)', 'callback_data' => 'edit_panel:tutorial_btn3']], [['text' => '4️⃣ دکمه اضافه ۱', 'callback_data' => 'edit_panel:tutorial_btn4']], [['text' => '5️⃣ دکمه اضافه ۲', 'callback_data' => 'edit_panel:tutorial_btn5']], [['text' => '🔙 بازگشت', 'callback_data' => 'edit_menu_root']], ]]; $result = sendTelegramRequest('editMessageText', [ 'chat_id' => $chatId, 'message_id' => $messageId, 'text' => "🔘 ویرایش متن دکمه‌های پنل «لیست نرم‌افزارها»\nدکمه مورد نظر را انتخاب کنید (دکمه‌ی «بازگشت» قابل ویرایش نیست):", 'parse_mode' => 'HTML', 'reply_markup' => json_encode($btnKeyboard) ]); if ($result === false) { // اگه ویرایش پیام ممکن نبود (مثلاً پیام خیلی قدیمی)، پیام جدید بفرست sendTelegramRequest('sendMessage', [ 'chat_id' => $chatId, 'text' => "🔘 ویرایش متن دکمه‌های پنل «لیست نرم‌افزارها»\nدکمه مورد نظر را انتخاب کنید (دکمه‌ی «بازگشت» قابل ویرایش نیست):", 'parse_mode' => 'HTML', 'reply_markup' => json_encode($btnKeyboard) ]); } sendTelegramRequest('answerCallbackQuery', ['callback_query_id' => $callback['id']]); return; } // ✅ بازگشت از زیرمنوی دکمه‌ها به منوی اصلی /edit if ($data === 'edit_menu_root' && $userId == $adminId) { $rootKeyboard = ['inline_keyboard' => [ [['text' => '💳 خرید سرویس V2ray', 'callback_data' => 'edit_panel:multi_location']], [['text' => '🧩 لیست نرم‌افزارها', 'callback_data' => 'edit_panel:tutorial']], [['text' => '⭐ تلگرام پرمیوم', 'callback_data' => 'edit_panel:premium']], [['text' => '🚫 اخطار عضویت', 'callback_data' => 'edit_panel:membership_alert']], [['text' => '🥚 اکانت تست', 'callback_data' => 'edit_panel:test_account']], [['text' => '🏠 منوی اصلی', 'callback_data' => 'edit_panel:main_menu']], [['text' => '📢 پیام عضویت در کانال', 'callback_data' => 'edit_panel:channel_join']], [['text' => '🔘 دکمه‌های پنل «لیست نرم‌افزارها»', 'callback_data' => 'edit_tutorial_buttons_menu']], ]]; $result = sendTelegramRequest('editMessageText', [ 'chat_id' => $chatId, 'message_id' => $messageId, 'text' => "📝 ویرایش متن پنل‌ها\nلطفاً پنل مورد نظر را انتخاب کنید:", 'parse_mode' => 'HTML', 'reply_markup' => json_encode($rootKeyboard) ]); if ($result === false) { sendTelegramRequest('sendMessage', [ 'chat_id' => $chatId, 'text' => "📝 ویرایش متن پنل‌ها\nلطفاً پنل مورد نظر را انتخاب کنید:", 'parse_mode' => 'HTML', 'reply_markup' => json_encode($rootKeyboard) ]); } sendTelegramRequest('answerCallbackQuery', ['callback_query_id' => $callback['id']]); return; } if ($data == 'check_membership') { if (isChannelMember($userId, $channel)) { try { $db->prepare("UPDATE users SET is_member=TRUE WHERE user_id=:uid")->execute([':uid' => $userId]); } catch (PDOException $e) { if (isConnectionLostError($e)) { $db = handleDbReconnect('membership update'); } else { logGeneralError("membership update failed"); } } showMainMenuWithEdit($chatId, $messageId); sendTelegramRequest('answerCallbackQuery', ['callback_query_id' => $callback['id'], 'text' => '✅ عضویت تایید شد!']); } else { $alertText = getPanelText('membership_alert'); sendTelegramRequest('answerCallbackQuery', ['callback_query_id' => $callback['id'], 'text' => $alertText, 'show_alert' => true]); } } elseif ($data == 'back_to_main') { showMainMenuWithEdit($chatId, $messageId); sendTelegramRequest('answerCallbackQuery', ['callback_query_id' => $callback['id']]); } elseif ($data == 'test_account') { $alertText = getPanelText('test_account'); sendTelegramRequest('answerCallbackQuery', ['callback_query_id' => $callback['id'], 'text' => $alertText, 'show_alert' => true]); } elseif ($data == 'tutorial') { showTutorialPanel($chatId, $messageId); sendTelegramRequest('answerCallbackQuery', ['callback_query_id' => $callback['id']]); } elseif ($data == 'telegram_premium') { showTelegramPremiumPanel($chatId, $messageId); sendTelegramRequest('answerCallbackQuery', ['callback_query_id' => $callback['id']]); } elseif ($data == 'multi_location') { showMultiLocationPanel($chatId, $messageId); sendTelegramRequest('answerCallbackQuery', ['callback_query_id' => $callback['id']]); } elseif (str_starts_with($data, 'unblock_user:')) { if ($userId == $adminId) { $targetUserId = (int)substr($data, 13); if ($targetUserId > 0) { $targetUser = getUserInfo($targetUserId); $targetUsername = htmlspecialchars($targetUser['username'] ?? 'N/A', ENT_QUOTES, 'UTF-8'); $targetFirstName = htmlspecialchars($targetUser['first_name'] ?? 'N/A', ENT_QUOTES, 'UTF-8'); $targetLastName = htmlspecialchars($targetUser['last_name'] ?? '', ENT_QUOTES, 'UTF-8'); $targetFullName = trim($targetFirstName . ' ' . $targetLastName); $success = unblockUser($targetUserId); sendUnblockNotificationToAdmin($targetUserId, $success); $originalMessage = $callback['message']['text'] ?? ''; if ($success) { $updatedMessage = $originalMessage . "\n✅ Unblocked by Admin\n" . "🆔 {$targetUserId} | {$targetFullName}"; $alertText = "✅ User unblocked"; } else { $updatedMessage = $originalMessage . "\n❌ Unblock Error"; $alertText = "❌ Unblock error"; } sendTelegramRequest('editMessageText', ['chat_id' => $chatId, 'message_id' => $messageId, 'text' => $updatedMessage, 'parse_mode' => 'HTML']); sendTelegramRequest('answerCallbackQuery', ['callback_query_id' => $callback['id'], 'text' => $alertText]); } else { sendTelegramRequest('answerCallbackQuery', ['callback_query_id' => $callback['id'], 'text' => '❌ Invalid ID', 'show_alert' => true]); } } else { sendTelegramRequest('answerCallbackQuery', ['callback_query_id' => $callback['id'], 'text' => '❌ Admin only', 'show_alert' => true]); } } } } // ============================================================================ // sendTelegramRequest // ============================================================================ // ============================================================================ // Circuit Breaker برای خطاهای SSL خروجی (ربات → تلگرام) // وقتی SSL محلی هاست موقتاً ناپایدار بشه، به‌جای تلاش‌های پیاپی و شکست‌خورده، // درخواست‌ها مستقیم صف می‌شن تا بعد از بازگشت SSL خودکار ارسال بشن. // ============================================================================ function isSslCurlError($errno) { // کدهای خطای cURL که مشخصاً مربوط به SSL/TLS هستند (نه خطای عمومی شبکه مثل تایم‌اوت یا DNS) static $sslErrorCodes = [35, 51, 53, 54, 58, 59, 60, 77, 80, 82, 83, 90, 91, 98]; return in_array($errno, $sslErrorCodes, true); } function isSslCircuitOpen() { $redis = getRedisConnection(); if (!$redis) return false; // بدون Redis، circuit breaker غیرفعاله (نه fail-open روی SSL، فقط خودِ مکانیزم در دسترس نیست) try { return (bool)$redis->get(redisKey('ssl_circuit_open')); } catch (Exception $e) { return false; } } function recordSslOutcome($success, $errno = 0) { $redis = getRedisConnection(); if (!$redis) return; try { if ($success) { // ✅ هر پاسخ واقعی (صرف‌نظر از کد HTTP تلگرام) یعنی TLS handshake موفق بوده؛ شمارنده صفر می‌شود $redis->del(redisKey('ssl_failure_count')); return; } if (!isSslCurlError($errno)) return; // فقط خطاهای مشخصاً SSL شمارش می‌شن $countKey = redisKey('ssl_failure_count'); $count = $redis->incr($countKey); if ($count === 1) $redis->expire($countKey, 60); if ($count >= 3) { $redis->set(redisKey('ssl_circuit_open'), '1', ['EX' => 45]); logGeneralError("SSL Circuit Breaker OPENED: {$count} consecutive SSL failures detected (errno={$errno})."); } } catch (Exception $e) { logGeneralError("recordSslOutcome failed: " . $e->getMessage()); } } // ============================================================================ function sendTelegramRequest($method, $params = [], $maxRetries = 2) { global $botToken, $processingStartTime, $processingTimeBudget; // ✅ فقط متدهایی که «ارسال/ویرایش محتوا» هستن معنای صف‌شدن دارن. // answerCallbackQuery زمان‌محدوده (اگه دیر برسه رد می‌شه) و getChatMember یک کوئری خواندنیه // (تماس‌گیرنده منتظر پاسخ فوریه، صف کردنش فقط یک درخواست بی‌فایده به تلگرام اضافه می‌کنه) — این دو صف نمی‌شن. static $deferrableMethods = ['sendMessage', 'editMessageText', 'editMessageReplyMarkup', 'deleteMessage']; if (isSslCircuitOpen()) { if (in_array($method, $deferrableMethods, true)) { $db = getDbConnection(); if ($db) { try { $db->prepare("INSERT INTO request_queue (method, params, created_at, original_created_at) VALUES (:m, :p, NOW(), NOW())") ->execute([':m' => $method, ':p' => json_encode($params)]); logGeneralError("SSL circuit open — deferred '{$method}' to queue instead of sending directly."); } catch (PDOException $e) { logGeneralError("SSL circuit open, and failed to queue '{$method}': " . $e->getMessage()); } } } else { logGeneralError("SSL circuit open — skipped non-deferrable '{$method}' (not queued)."); } return false; } simpleGlobalRateLimit(); $url = "https://api.telegram.org/bot{$botToken}/{$method}"; $retryCount = 0; $backoff = 1; while ($retryCount < $maxRetries) { $ch = curl_init(); if ($ch === false) { logBotError("curl_init() failed for method: {$method}"); return false; } $opts = [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_POSTFIELDS => $params, CURLOPT_TIMEOUT => 10, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_FOLLOWLOCATION => false, CURLOPT_HTTPHEADER => ['User-Agent: Telegram Bot PHP Client'], ]; if (CA_INFO_PATH) { $opts[CURLOPT_CAINFO] = CA_INFO_PATH; } else { $opts[CURLOPT_CAPATH] = '/etc/ssl/certs'; } curl_setopt_array($ch, $opts); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlError = curl_error($ch); $curlErrno = curl_errno($ch); curl_close($ch); if ($response === false) { recordSslOutcome(false, $curlErrno); logBotError("cURL Error ({$curlErrno}): {$curlError}"); return false; } recordSslOutcome(true); if ($httpCode === 200) return $response; if ($httpCode === 429) { $data = json_decode($response, true); $retryAfter = isset($data['parameters']['retry_after']) ? (int)$data['parameters']['retry_after'] : $backoff; $retryAfter = max(1, min($retryAfter, 30)); logBotError("Rate limit 429: retry_after={$retryAfter}s method={$method}"); notifyAdminRateLimit($method, $retryAfter, 'sendTelegramRequest (webhook)'); $elapsed = microtime(true) - $processingStartTime; $remainingBudget = $processingTimeBudget - $elapsed; if ($processingStartTime > 0 && ($retryAfter > 3 || $retryAfter >= ($remainingBudget - 5))) { logBotError("429 Defer: retry_after={$retryAfter}s is too long or budget low. Deferring to DB queue."); $db = getDbConnection(); if ($db) { try { $params['__retry_429'] = ($params['__retry_429'] ?? 0) + 1; if ($params['__retry_429'] <= 3) { $db->prepare( "INSERT INTO request_queue (method, params, created_at, original_created_at) VALUES (:method, :params, NOW(), NOW())" )->execute([ ':method' => $method, ':params' => json_encode($params), ]); } return false; } catch (PDOException $e) { logBotError("Failed to defer 429 request to DB: " . $e->getMessage()); } } return false; } $safeSleep = min($retryAfter, max(1, $remainingBudget - 5)); logBotError("429 Short Sleep: {$safeSleep}s for method {$method}"); sleep($safeSleep); $redis = getRedisConnection(); if ($redis) { try { $maxKey = redisKey('global_rate_max'); $currentMax = (int)($redis->get($maxKey) ?: 28); $newMax = max(15, $currentMax - 2); $redis->set($maxKey, $newMax, ['EX' => 300]); logGeneralError("Rate limit: global_rate_max reduced to {$newMax}"); } catch (Exception $e) { logGeneralError("Rate limit Redis update failed"); resetRedisConnection(); } } $backoff = min($backoff * 2, 30); $retryCount++; } else { logBotError("HTTP Error: {$httpCode} - " . mb_substr($response, 0, 500, 'UTF-8')); return false; } } logBotError("Max retries exceeded for: {$method}"); return false; } // ============================================================================ // processRequestQueue // ============================================================================ function requeueRowUnchanged($db, $method, $rawParamsJson, $originalCreatedAt, $context = 'requeueRowUnchanged') { // ✅ برگرداندن ردیف بدون تغییر محتوا (بدون افزایش شمارنده‌ی retry) — برای مواردی مثل // اتمام بودجه‌ی زمانی قبل از ارسال، مدار باز SSL، یا شکست curl_init که اصلاً تلاشی برای ارسال انجام نشده است. // ✅ created_at = NOW() تا ردیف به انتهای صف منتقل شود (نه اینکه فوراً دوباره سرِ صف بیفتد و تلاش‌های // پشت‌سرهم بی‌فاصله ایجاد کند) — original_created_at ثابت می‌ماند تا سن واقعی پیام برای تشخیص staleness حفظ شود. try { $db->prepare("INSERT INTO request_queue (method, params, created_at, original_created_at) VALUES (:m, :p, NOW(), :oca)") ->execute([':m' => $method, ':p' => $rawParamsJson, ':oca' => $originalCreatedAt]); } catch (PDOException $e) { if (isConnectionLostError($e)) { handleDbReconnect($context); } else { logGeneralError("requeueRowUnchanged failed: " . $e->getMessage()); } } } function requeueOrFail($db, $method, array $params, $internalRetryCount, $originalCreatedAt, $errorMessage) { // ✅ منطق retry/failed_requests که قبلاً داخل تراکنش SELECT...FOR UPDATE اجرا می‌شد، // حالا خارج از هرگونه تراکنش/قفل ردیف اجرا می‌شود (چون ردیف قبلاً claim/حذف شده است). try { if ($internalRetryCount < 3) { $params['__queue_retry'] = $internalRetryCount + 1; // ✅ created_at = NOW() (نه زمان اصلی) تا این ردیف به انتهای صف برود و فرصت پردازش سایر // پیام‌ها فراهم شود — در غیر این صورت همین ردیف با ORDER BY created_at ASC بلافاصله دوباره // اول صف قرار می‌گرفت و در کسری از ثانیه دوباره retry می‌شد (حلقه‌ی تنگ و بی‌فایده). // original_created_at ثابت می‌ماند تا سن واقعی پیام برای تشخیص staleness درست محاسبه شود. $db->prepare("INSERT INTO request_queue (method, params, created_at, original_created_at) VALUES (:m, :p, NOW(), :oca)") ->execute([':m' => $method, ':p' => json_encode($params), ':oca' => $originalCreatedAt]); } else { $db->prepare("INSERT INTO failed_requests (method, params, retry_count, last_error) VALUES (:m, :p, :r, :e)") ->execute([ ':m' => $method, ':p' => json_encode($params), ':r' => $internalRetryCount, ':e' => mb_substr((string)$errorMessage, 0, 500, 'UTF-8'), ]); } } catch (PDOException $e) { if (isConnectionLostError($e)) { handleDbReconnect('requeueOrFail'); } else { logGeneralError("requeueOrFail insert failed: " . $e->getMessage()); } } } function processRequestQueue($maxRequests, $timeBudget) { global $botToken; $db = getDbConnection(); if (!$db) return; $redis = getRedisConnection(); $rqMutex = false; $rqMutexKey = redisKey('rq_lock'); $rqMutexVal = uniqid(BOT_ID . '_rq_', true); if ($redis) { try { $rqMutex = $redis->set($rqMutexKey, $rqMutexVal, ['NX', 'EX' => (int)$timeBudget + 2]); if (!$rqMutex) { logInfo("processRequestQueue: skipped (another worker holds rq_lock)"); return; } } catch (Exception $e) { logGeneralError("processRequestQueue: mutex check failed"); } } $startTime = microtime(true); $processed = 0; try { while ($processed < $maxRequests) { if ((microtime(true) - $startTime) >= $timeBudget) { logInfo("processRequestQueue budget reached, processed: {$processed}"); break; } $db = getDbConnection(); if (!$db) { logGeneralError("processRequestQueue: DB lost"); break; } // --------------------------------------------------------------- // فاز ۱ (تراکنشی و کوتاه): فقط claim کردن یک ردیف از صف. // ✅ تغییر کلیدی نسبت به نسخه‌ی قبلی: تراکنش/قفل ردیف (FOR UPDATE) دیگر در طول // فراخوانی شبکه‌ای curl_exec به تلگرام باز نمی‌ماند — همین‌جا با DELETE+commit آزاد می‌شود // و فراخوانی شبکه کاملاً خارج از تراکنش (فاز ۲) انجام می‌گیرد. // --------------------------------------------------------------- $reqId = null; $method = null; $params = null; $rawParams = null; $originalCreatedAt = null; $isNormalApiRequest = false; $db->beginTransaction(); try { $stmt = $db->prepare("SELECT id, method, params, created_at, original_created_at FROM request_queue ORDER BY created_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED"); $stmt->execute(); $req = $stmt->fetch(); if (!$req) { $db->commit(); break; } $reqId = $req['id']; $method = $req['method']; $rawParams = $req['params']; // ✅ original_created_at سن واقعی پیام رو نگه می‌داره (حتی بعد از چند بار requeue شدن)؛ // برای ردیف‌های قدیمی/مهاجرت‌نشده که این ستون NULL است، به created_at همان ردیف fallback می‌کنیم. $originalCreatedAt = $req['original_created_at'] ?? $req['created_at']; $params = json_decode($rawParams, true); if (!is_array($params)) { $db->prepare("DELETE FROM request_queue WHERE id=:id")->execute([':id' => $reqId]); $db->commit(); try { $db->prepare("INSERT INTO failed_requests (method, params, retry_count, last_error) VALUES (:m, :p, 0, 'Invalid JSON')")->execute([':m' => $method, ':p' => $rawParams]); } catch (PDOException $e) {} $processed++; continue; } if ($method === '__processUpdate') { $db->prepare("DELETE FROM request_queue WHERE id=:id")->execute([':id' => $reqId]); $db->commit(); $deferredUpdate = $params['__update_payload'] ?? null; if (is_array($deferredUpdate)) { try { processUpdate($deferredUpdate); $deferredUpdateId = $deferredUpdate['update_id'] ?? null; if ($deferredUpdateId) updateLastProcessedUpdateId($deferredUpdateId); logInfo("processRequestQueue: deferred update handled"); } catch (\Throwable $e) { logGeneralError("processRequestQueue: deferred update failed: " . $e->getMessage()); try { $dbRetry = getDbConnection(); if ($dbRetry) { $dbRetry->prepare("INSERT INTO failed_requests (method, params, retry_count, last_error) VALUES (:m, :p, 0, :e)") ->execute([ ':m' => '__processUpdate', ':p' => json_encode(['__update_payload' => $deferredUpdate]), ':e' => mb_substr($e->getMessage(), 0, 500, 'UTF-8') ]); } } catch (PDOException $dbEx) { logGeneralError("Failed to save deferred update to failed_requests: " . $dbEx->getMessage()); } } } else { logGeneralError("processRequestQueue: __processUpdate has no valid payload"); } $processed++; continue; } // درخواست معمولی API تلگرام: همین‌جا claim می‌کنیم (حذف از صف) و بلافاصله commit — // از این نقطه به بعد هیچ قفل/تراکنشی باز نیست، حتی قبل از فراخوانی شبکه. $db->prepare("DELETE FROM request_queue WHERE id=:id")->execute([':id' => $reqId]); $db->commit(); $isNormalApiRequest = true; } catch (PDOException $e) { if ($db->inTransaction()) $db->rollBack(); if (isConnectionLostError($e)) { $db = handleDbReconnect('processRequestQueue'); if (!$db) break; } else { logGeneralError("processRequestQueue error: " . $e->getMessage()); break; } } catch (\Throwable $e) { if ($db->inTransaction()) $db->rollBack(); logGeneralError("processRequestQueue unexpected error: " . $e->getMessage()); break; } // --------------------------------------------------------------- // فاز ۲ (کاملاً خارج از تراکنش DB): rate limit + فراخوانی شبکه‌ای به تلگرام. // فقط وقتی اجرا می‌شود که فاز ۱ یک درخواست معمولی API را claim کرده باشد. // --------------------------------------------------------------- if ($isNormalApiRequest) { // ✅ بررسی قدیمی‌بودن: پیامی که بیش از ۱۵ دقیقه در صف مانده، احتمالاً دیگر مرتبط نیست — نادیده گرفته می‌شود (نه retry، نه failed_requests) $ageSeconds = time() - strtotime($originalCreatedAt); if ($ageSeconds > 900) { logGeneralError("processRequestQueue: dropped stale queued item (method={$method}, age={$ageSeconds}s)"); $processed++; continue; } // ✅ اگر Circuit Breaker به‌خاطر خرابی SSL باز باشد، اصلاً تلاش برای ارسال نمی‌کنیم — // بدون تغییر به صف برمی‌گردد و از کل چرخه خارج می‌شویم (بقیه‌ی آیتم‌ها هم همین وضعیت را دارند) if (isSslCircuitOpen()) { requeueRowUnchanged($db, $method, $rawParams, $originalCreatedAt, 'processRequestQueue ssl-circuit-open'); break; } $internalRetryCount = 0; if (isset($params['__queue_retry'])) { $internalRetryCount = (int)$params['__queue_retry']; unset($params['__queue_retry']); } simpleGlobalRateLimit(); if ((microtime(true) - $startTime) >= $timeBudget) { // ردیف قبلاً از صف حذف شده؛ چون هنوز تلاشی برای ارسال نشده، بدون تغییر برمی‌گردانیم requeueRowUnchanged($db, $method, $rawParams, $originalCreatedAt, 'processRequestQueue budget'); break; } $url = "https://api.telegram.org/bot{$botToken}/{$method}"; $ch = curl_init($url); if ($ch === false) { logBotError("curl_init() failed in processRequestQueue for method: {$method}"); requeueRowUnchanged($db, $method, $rawParams, $originalCreatedAt, 'processRequestQueue curl_init'); $processed++; usleep(10000); continue; } curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $params); curl_setopt($ch, CURLOPT_TIMEOUT, 10); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false); curl_setopt($ch, CURLOPT_HTTPHEADER, ['User-Agent: Telegram Bot PHP Client']); if (CA_INFO_PATH) { curl_setopt($ch, CURLOPT_CAINFO, CA_INFO_PATH); } else { curl_setopt($ch, CURLOPT_CAPATH, '/etc/ssl/certs'); } $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlError = curl_error($ch); $curlErrno = curl_errno($ch); curl_close($ch); recordSslOutcome($response !== false, $curlErrno); if ($response !== false && $httpCode === 200) { // موفق — ردیف در فاز ۱ قبلاً از صف حذف شده، کار دیگری لازم نیست } else { $errorMessage = $response === false ? "cURL ({$curlErrno}): " . mb_substr($curlError, 0, 400, 'UTF-8') : "HTTP {$httpCode}"; logBotError("Queue request failed: method={$method}"); if ($httpCode === 429) { $rqData = json_decode($response, true); $rqRetryAfter = isset($rqData['parameters']['retry_after']) ? (int)$rqData['parameters']['retry_after'] : 5; notifyAdminRateLimit($method, $rqRetryAfter, 'processRequestQueue'); $redisRq = getRedisConnection(); if ($redisRq) { try { $maxKey = redisKey('global_rate_max'); $currentMax = (int)($redisRq->get($maxKey) ?: 28); $newMax = max(15, $currentMax - 2); $redisRq->set($maxKey, $newMax, ['EX' => 300]); } catch (Exception $e) { logGeneralError("processRequestQueue rate limit Redis update failed"); } } } requeueOrFail($db, $method, $params, $internalRetryCount, $originalCreatedAt, $errorMessage); } } $processed++; usleep(10000); } } finally { if ($rqMutex && $redis) { try { $releaseLua = " if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end "; $redis->eval($releaseLua, [$rqMutexKey, $rqMutexVal], 1); } catch (Exception $e) { logGeneralError("processRequestQueue: mutex release failed"); } } } } // ============================================================================ // isRateLimited // ============================================================================ function isRateLimited($userId, $maxRequests = 30, $timeWindow = 86400) { global $adminId; if (!is_numeric($userId) || $userId <= 0) return true; if ($userId == $adminId) return false; $redis = getRedisConnection(); if ($redis) { try { $key = redisKey("rate_limit:user:{$userId}"); $script = " local key = KEYS[1] local limit = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) local current = redis.call('INCR', key) if current == 1 then redis.call('EXPIRE', key, window) end return current > limit and 1 or 0 "; $isLimited = $redis->eval($script, [$key, $maxRequests, $timeWindow], 1); return $isLimited == 1; } catch (Exception $e) { logGeneralError("Redis rate limit error"); resetRedisConnection(); } } $db = getDbConnection(); if (!$db) return true; try { $timeAgo = date('Y-m-d H:i:s', time() - $timeWindow); // ✅ فقط ردیف‌های مخصوص خودِ این تابع شمرده می‌شن (نه لاگ‌های عمومی مثل 'callback:...' که جای دیگه‌ای // در کد نوشته می‌شن)؛ در غیر این صورت وقتی Redis قطع باشه، هر تعامل کاربر ۲ بار شمرده می‌شد // (یک‌بار توسط این تابع، یک‌بار توسط لاگ عمومی) و سقف ۳۰ در روز عملاً نصف می‌شد. $stmt = $db->prepare("SELECT COUNT(*) FROM user_requests WHERE user_id=:uid AND request_type='rate_limit_check' AND created_at > :t"); $stmt->execute([':uid' => $userId, ':t' => $timeAgo]); $count = (int)$stmt->fetchColumn(); if ($count >= $maxRequests) return true; $db->prepare("INSERT INTO user_requests (user_id, request_type) VALUES (:uid, 'rate_limit_check')")->execute([':uid' => $userId]); return false; } catch (PDOException $e) { if (isConnectionLostError($e)) { handleDbReconnect('isRateLimited'); } else { logGeneralError("MySQL rate limit failed"); } return true; } } // ============================================================================ // توابع کمکی // ============================================================================ function makeTextBold($text) { return "" . htmlspecialchars($text, ENT_QUOTES, 'UTF-8') . ""; } function isUserBlocked($userId) { if (!is_numeric($userId) || $userId <= 0) return true; $redis = getRedisConnection(); $cacheKey = redisKey("blocked:user:{$userId}"); if ($redis) { try { $cached = $redis->get($cacheKey); if ($cached !== false) return (bool)json_decode($cached); } catch (Exception $e) { logGeneralError("isUserBlocked Redis get failed"); } } $db = getDbConnection(); if (!$db) return false; try { $stmt = $db->prepare("SELECT COUNT(*) FROM blocked_users WHERE user_id=:uid"); $stmt->execute([':uid' => $userId]); $isBlocked = ((int)$stmt->fetchColumn() > 0); if ($redis) { try { $redis->set($cacheKey, json_encode($isBlocked), ['EX' => 86400]); } catch (Exception $e) {} } return $isBlocked; } catch (PDOException $e) { if (isConnectionLostError($e)) { handleDbReconnect('isUserBlocked'); } else { logGeneralError("isUserBlocked DB failed"); } return false; } } function blockUser($userId, $reason) { global $adminId; if (!is_numeric($userId) || $userId <= 0 || $userId == $adminId) return false; $safeReason = htmlspecialchars(mb_substr($reason, 0, 250, 'UTF-8'), ENT_QUOTES, 'UTF-8'); $db = getDbConnection(); if (!$db) return false; try { $stmt = $db->prepare("INSERT IGNORE INTO blocked_users (user_id, reason) VALUES (:uid, :r)"); $success = $stmt->execute([':uid' => $userId, ':r' => $safeReason]); if ($success && $stmt->rowCount() > 0) { logGeneralError("User blocked, Reason: {$safeReason}"); sendBlockNotificationToAdmin($userId, $safeReason); $redis = getRedisConnection(); if ($redis) { try { $redis->del(redisKey("blocked:user:{$userId}")); } catch (Exception $e) { try { $redis->set(redisKey("blocked:user:{$userId}"), json_encode(true), ['EX' => 86400]); } catch (Exception $e2) {} } } return true; } return false; } catch (PDOException $e) { if (isConnectionLostError($e)) { handleDbReconnect('blockUser'); } else { logGeneralError("blockUser failed: " . $e->getMessage()); } return false; } } function unblockUser($userId) { if (!is_numeric($userId) || $userId <= 0) return false; $db = getDbConnection(); if (!$db) return false; try { $stmt = $db->prepare("DELETE FROM blocked_users WHERE user_id=:uid"); $success = $stmt->execute([':uid' => $userId]); if ($success && $stmt->rowCount() > 0) { try { $db->prepare("DELETE FROM user_requests WHERE user_id=:uid")->execute([':uid' => $userId]); } catch (PDOException $e) {} try { $db->prepare("DELETE FROM start_command_usage WHERE user_id=:uid")->execute([':uid' => $userId]); } catch (PDOException $e) {} logInfo("User unblocked successfully"); $redis = getRedisConnection(); if ($redis) { try { $redis->del(redisKey("blocked:user:{$userId}")); } catch (Exception $e) { try { $redis->set(redisKey("blocked:user:{$userId}"), json_encode(false), ['EX' => 86400]); } catch (Exception $e2) {} } try { $redis->del(redisKey("rate_limit:user:{$userId}")); } catch (Exception $e) {} } return true; } return false; } catch (PDOException $e) { if (isConnectionLostError($e)) { handleDbReconnect('unblockUser'); } else { logGeneralError("unblockUser failed: " . $e->getMessage()); } return false; } } // ============================================================================ // Admin Notifications // ============================================================================ function sendBlockNotificationToAdmin($blockedUserId, $reason) { global $adminId; if (!is_numeric($blockedUserId) || $blockedUserId <= 0 || empty($adminId)) return; $user = getUserInfo($blockedUserId); $username = htmlspecialchars($user['username'] ?? 'N/A', ENT_QUOTES, 'UTF-8'); $firstName = htmlspecialchars($user['first_name'] ?? 'N/A', ENT_QUOTES, 'UTF-8'); $lastName = htmlspecialchars($user['last_name'] ?? 'N/A', ENT_QUOTES, 'UTF-8'); $msg = "🚨 User Blocked\n" . "🆔 ID: {$blockedUserId}\n" . "👤 Username: @{$username}\n" . "📛 Name: {$firstName} {$lastName}\n" . "⚠️ Reason: {$reason}\n" . "🕒 " . date('Y-m-d H:i:s'); $keyboard = ['inline_keyboard' => [[['text' => '🔓 Unblock', 'callback_data' => 'unblock_user:' . $blockedUserId]]]]; $result = sendTelegramRequest('sendMessage', ['chat_id' => $adminId, 'text' => $msg, 'reply_markup' => json_encode($keyboard), 'parse_mode' => 'HTML']); if ($result === false) logBotError("sendBlockNotification failed"); } function sendUnblockNotificationToAdmin($unblockedUserId, $success) { global $adminId; if (!is_numeric($unblockedUserId) || $unblockedUserId <= 0 || empty($adminId)) return; $user = getUserInfo($unblockedUserId); $username = htmlspecialchars($user['username'] ?? 'N/A', ENT_QUOTES, 'UTF-8'); $firstName = htmlspecialchars($user['first_name'] ?? 'N/A', ENT_QUOTES, 'UTF-8'); $lastName = htmlspecialchars($user['last_name'] ?? '', ENT_QUOTES, 'UTF-8'); if ($success) { $msg = "✅ User Unblocked\n" . "🆔 {$unblockedUserId}\n" . "👤 @{$username} | {$firstName} {$lastName}\n" . "🕒 " . date('Y-m-d H:i:s'); } else { $msg = "❌ Unblock Error\n" . "🆔 {$unblockedUserId}"; } sendTelegramRequest('sendMessage', ['chat_id' => $adminId, 'text' => $msg, 'parse_mode' => 'HTML']); } // ============================================================================ // notifyAdminRateLimit // ============================================================================ function notifyAdminRateLimit($method, $retryAfter, $context = '') { global $adminId; if (empty($adminId)) return; $redis = getRedisConnection(); if (!$redis) return; $cooldownKey = redisKey('429_admin_notify_cooldown'); $inProgressKey = redisKey('429_notify_in_progress'); try { $inProgress = $redis->get($inProgressKey); if ($inProgress === '1') { return; } $canNotify = $redis->set($cooldownKey, '1', ['NX', 'EX' => 300]); if (!$canNotify) { return; } $redis->set($inProgressKey, '1', ['EX' => 30]); } catch (Exception $e) { logGeneralError("notifyAdminRateLimit: Redis check failed: " . $e->getMessage()); return; } $safeMethod = htmlspecialchars(mb_substr((string)$method, 0, 60, 'UTF-8'), ENT_QUOTES, 'UTF-8'); $safeContext = htmlspecialchars(mb_substr((string)$context, 0, 60, 'UTF-8'), ENT_QUOTES, 'UTF-8'); $safeRetryAfter = (int)$retryAfter; $msg = "⚠️ Telegram Rate Limit Warning (429)\n" . "🔧 Method: {$safeMethod}\n" . "⏱️ Retry After: {$safeRetryAfter}s\n" . "📍 Location: {$safeContext}\n" . "ℹ️ Request rate limit has been automatically reduced.\n" . "🕒 " . date('Y-m-d H:i:s') . "\n" . "💡 This notification is sent at most once every 5 minutes."; try { $result = sendTelegramRequest('sendMessage', [ 'chat_id' => $adminId, 'text' => $msg, 'parse_mode' => 'HTML', ]); if ($result === false) { logGeneralError("notifyAdminRateLimit: sendTelegramRequest returned false"); } } catch (Exception $e) { logGeneralError("notifyAdminRateLimit: send failed: " . $e->getMessage()); } finally { try { $redis->del($inProgressKey); } catch (Exception $e) {} } } function getUserInfo($userId) { $redis = getRedisConnection(); $cacheKey = redisKey("user_hash:{$userId}"); if ($redis) { try { $cached = $redis->hGet($cacheKey, 'info'); if ($cached !== false) return json_decode($cached, true) ?: []; } catch (Exception $e) {} } $db = getDbConnection(); if (!$db) return []; try { $stmt = $db->prepare("SELECT username, first_name, last_name FROM users WHERE user_id=:uid"); $stmt->execute([':uid' => $userId]); $user = $stmt->fetch() ?: []; if ($redis) { try { $redis->hSet($cacheKey, 'info', json_encode($user)); $redis->expire($cacheKey, 3600); } catch (Exception $e) {} } return $user; } catch (PDOException $e) { if (isConnectionLostError($e)) { handleDbReconnect('getUserInfo'); } else { logGeneralError("getUserInfo failed"); } return []; } } function userIsMemberInDB($userId) { $db = getDbConnection(); if (!$db) return false; try { $stmt = $db->prepare("SELECT is_member FROM users WHERE user_id=:uid"); $stmt->execute([':uid' => $userId]); $row = $stmt->fetch(); return (bool)($row['is_member'] ?? false); } catch (PDOException $e) { if (isConnectionLostError($e)) { handleDbReconnect('userIsMemberInDB'); } else { logGeneralError("userIsMemberInDB failed"); } return false; } } function handleUserSafety($userId, $messageText = '', $requestType = 'message') { global $adminId; if ($userId == $adminId) { if (!empty($messageText) && $requestType === 'message') { $safe = htmlspecialchars(mb_substr($messageText, 0, 1000, 'UTF-8'), ENT_QUOTES, 'UTF-8'); $db = getDbConnection(); if ($db) { try { $db->prepare("INSERT INTO user_messages (user_id, message_text) VALUES (:uid, :msg)")->execute([':uid' => $userId, ':msg' => $safe]); } catch (PDOException $e) { if (isConnectionLostError($e)) handleDbReconnect('handleUserSafety admin'); } } } return true; } if (isUserBlocked($userId)) return false; if (isRateLimited($userId)) { blockUser($userId, "Rate limiting violation"); return false; } if (!empty($messageText) && $requestType === 'message') { $safe = htmlspecialchars(mb_substr($messageText, 0, 1000, 'UTF-8'), ENT_QUOTES, 'UTF-8'); $db = getDbConnection(); if ($db) { try { $db->prepare("INSERT INTO user_messages (user_id, message_text) VALUES (:uid, :msg)")->execute([':uid' => $userId, ':msg' => $safe]); } catch (PDOException $e) { if (isConnectionLostError($e)) handleDbReconnect('handleUserSafety user'); } } } return true; } // ============================================================================ // isChannelMember (با ثبت کلیدها در Registry Set) // ============================================================================ function isChannelMember($userId, $channel) { if (!is_numeric($userId) || $userId <= 0 || empty($channel)) return false; $redis = getRedisConnection(); $cacheKey = redisKey("channel_member:{$userId}:{$channel}"); if ($redis) { try { $cached = $redis->get($cacheKey); if ($cached === '1') return true; } catch (Exception $e) { logGeneralError("isChannelMember Redis get failed: " . $e->getMessage()); } } if (userIsMemberInDB($userId)) { if ($redis) { try { $redis->set($cacheKey, '1', ['EX' => 2592000]); // ✅ ثبت کلید در رجیستری برای حذف آسان در آینده بدون نیاز به SCAN $redis->sAdd(redisKey('channel_members_registry'), $cacheKey); } catch (Exception $e) { logGeneralError("isChannelMember Redis set failed: " . $e->getMessage()); } } return true; } $response = sendTelegramRequest('getChatMember', [ 'chat_id' => $channel, 'user_id' => $userId ]); $isMember = false; if ($response !== false) { $data = json_decode($response, true); if ($data && $data['ok']) { $isMember = in_array($data['result']['status'], ['member', 'administrator', 'creator']); } } else { logBotError("getChatMember failed"); } if ($isMember && $redis) { try { $redis->set($cacheKey, '1', ['EX' => 2592000]); // ✅ ثبت کلید در رجیستری $redis->sAdd(redisKey('channel_members_registry'), $cacheKey); } catch (Exception $e) { logGeneralError("isChannelMember Redis set failed: " . $e->getMessage()); } } return $isMember; } // ============================================================================ // 🇮🇷 User-facing messages — Persian // ============================================================================ function getMainMenuKeyboard() { global $supportUrl; $redis = getRedisConnection(); $cacheKey = redisKey("main_menu_keyboard"); if ($redis) { try { $cached = $redis->get($cacheKey); if ($cached) return json_decode($cached, true); } catch (Exception $e) {} } $safeSupportUrl = filter_var($supportUrl, FILTER_VALIDATE_URL) ? $supportUrl : ''; $keyboard = ['inline_keyboard' => [ [['text' => '💳 خرید سرویس V2ray', 'callback_data' => 'multi_location', 'style' => 'success']], [['text' => ' پشتیبانی', 'url' => $safeSupportUrl], ['text' => '🥚 اکانت تست', 'callback_data' => 'test_account']], [['text' => '🧩 لیست نرم افزارها', 'callback_data' => 'tutorial']], [['text' => '⭐ تلگرام پرمیوم ⭐', 'callback_data' => 'telegram_premium', 'style' => 'primary']], ]]; if ($redis) { try { $redis->set($cacheKey, json_encode($keyboard), ['EX' => 86400]); } catch (Exception $e) {} } return $keyboard; } function showMainMenu($chatId) { // ✅ حفظ دقیق فرمت: متن خام + entities تلگرام (Bold/Italic/Link/...) عیناً همان‌طور که ادمین تایپ کرده ارسال می‌شود $panelData = getPanelTextWithEntities('main_menu'); $keyboard = getMainMenuKeyboard(); $result = sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => $panelData['text'], 'entities' => json_encode($panelData['entities']), 'reply_markup' => json_encode($keyboard), 'disable_web_page_preview' => true]); if ($result === false) logBotError("showMainMenu failed"); } function showMainMenuWithEdit($chatId, $messageId) { $panelData = getPanelTextWithEntities('main_menu'); $keyboard = getMainMenuKeyboard(); $result = sendTelegramRequest('editMessageText', ['chat_id' => $chatId, 'message_id' => $messageId, 'text' => $panelData['text'], 'entities' => json_encode($panelData['entities']), 'reply_markup' => json_encode($keyboard), 'disable_web_page_preview' => true]); if ($result === false) logBotError("showMainMenuWithEdit failed"); } function showChannelMenu($chatId) { global $channelUrl; $redis = getRedisConnection(); $cacheKey = redisKey("channel_menu_keyboard"); $safeUrl = filter_var($channelUrl, FILTER_VALIDATE_URL) ? $channelUrl : ''; $keyboard = null; if ($redis) { try { $cached = $redis->get($cacheKey); if ($cached) $keyboard = json_decode($cached, true); } catch (Exception $e) {} } if (!$keyboard) { $keyboard = ['inline_keyboard' => [ [['text' => '🌍 عضویت در کانال', 'url' => $safeUrl]], [['text' => '✅ عضو شدم', 'callback_data' => 'check_membership', 'style' => 'success']], ]]; if ($redis) { try { $redis->set($cacheKey, json_encode($keyboard), ['EX' => 86400]); } catch (Exception $e) {} } } $messageText = getPanelText('channel_join'); $messageText = htmlspecialchars($messageText, ENT_QUOTES, 'UTF-8'); $messageText = '' . $messageText . ''; $result = sendTelegramRequest('sendMessage', ['chat_id' => $chatId, 'text' => $messageText, 'reply_markup' => json_encode($keyboard), 'parse_mode' => 'HTML', 'disable_web_page_preview' => true]); if ($result === false) logBotError("showChannelMenu failed"); } function showTutorialPanel($chatId, $messageId = null) { global $link1, $link2, $link3, $link4, $link5; $redis = getRedisConnection(); // ✅ نام کش به v2 تغییر کرد تا بعد از افزودن ۲ دکمه‌ی جدید، هرگز کیبورد قدیمیِ ۳ دکمه‌ای از کش قدیمی سرو نشود $keyboardCacheKey = redisKey("tutorial_panel_keyboard_v2"); $keyboard = null; if ($redis) { try { $cached = $redis->get($keyboardCacheKey); if ($cached) $keyboard = json_decode($cached, true); } catch (Exception $e) {} } if (!$keyboard) { $safeLink1 = filter_var($link1, FILTER_VALIDATE_URL) ? $link1 : ''; $safeLink2 = filter_var($link2, FILTER_VALIDATE_URL) ? $link2 : ''; $safeLink3 = filter_var($link3, FILTER_VALIDATE_URL) ? $link3 : ''; $safeLink4 = filter_var($link4, FILTER_VALIDATE_URL) ? $link4 : ''; $safeLink5 = filter_var($link5, FILTER_VALIDATE_URL) ? $link5 : ''; $keyboard = ['inline_keyboard' => [ [['text' => getPanelText('tutorial_btn1'), 'url' => $safeLink3]], [['text' => getPanelText('tutorial_btn2'), 'url' => $safeLink4]], [['text' => getPanelText('tutorial_btn3'), 'url' => $safeLink5]], [['text' => getPanelText('tutorial_btn4'), 'url' => $safeLink1]], [['text' => getPanelText('tutorial_btn5'), 'url' => $safeLink2]], [['text' => '🔙 بازگشت', 'callback_data' => 'back_to_main', 'style' => 'danger']], ]]; if ($redis) { try { $redis->set($keyboardCacheKey, json_encode($keyboard), ['EX' => 86400]); } catch (Exception $e) {} } } $messageText = getPanelText('tutorial'); $messageText = htmlspecialchars($messageText, ENT_QUOTES, 'UTF-8'); $messageText = '' . $messageText . ''; $params = ['chat_id' => $chatId, 'text' => $messageText, 'reply_markup' => json_encode($keyboard), 'parse_mode' => 'HTML', 'disable_web_page_preview' => true]; if ($messageId) { $params['message_id'] = $messageId; $result = sendTelegramRequest('editMessageText', $params); } else { $result = sendTelegramRequest('sendMessage', $params); } if ($result === false) logBotError("showTutorialPanel failed"); } function showTelegramPremiumPanel($chatId, $messageId = null) { global $supportUrl; $redis = getRedisConnection(); $keyboardCacheKey = redisKey("premium_panel_keyboard"); $keyboard = null; if ($redis) { try { $cached = $redis->get($keyboardCacheKey); if ($cached) $keyboard = json_decode($cached, true); } catch (Exception $e) {} } if (!$keyboard) { $safeUrl = filter_var($supportUrl, FILTER_VALIDATE_URL) ? $supportUrl : ''; $keyboard = ['inline_keyboard' => [ [['text' => '👤 پشتیبانی', 'url' => $safeUrl]], [['text' => '🔙 بازگشت', 'callback_data' => 'back_to_main', 'style' => 'danger']], ]]; if ($redis) { try { $redis->set($keyboardCacheKey, json_encode($keyboard), ['EX' => 86400]); } catch (Exception $e) {} } } $messageText = getPanelText('premium'); $messageText = htmlspecialchars($messageText, ENT_QUOTES, 'UTF-8'); $messageText = '' . $messageText . ''; $params = ['chat_id' => $chatId, 'text' => $messageText, 'reply_markup' => json_encode($keyboard), 'parse_mode' => 'HTML', 'disable_web_page_preview' => true]; if ($messageId) { $params['message_id'] = $messageId; $result = sendTelegramRequest('editMessageText', $params); } else { $result = sendTelegramRequest('sendMessage', $params); } if ($result === false) logBotError("showTelegramPremiumPanel failed"); } function showMultiLocationPanel($chatId, $messageId = null) { $redis = getRedisConnection(); $keyboardCacheKey = redisKey("multi_location_keyboard"); $keyboard = null; if ($redis) { try { $cached = $redis->get($keyboardCacheKey); if ($cached) $keyboard = json_decode($cached, true); } catch (Exception $e) {} } if (!$keyboard) { $keyboard = ['inline_keyboard' => [ [['text' => '🔙 بازگشت', 'callback_data' => 'back_to_main', 'style' => 'danger']], ]]; if ($redis) { try { $redis->set($keyboardCacheKey, json_encode($keyboard), ['EX' => 86400]); } catch (Exception $e) {} } } // ✅ حفظ دقیق فرمت: متن خام + entities تلگرام عیناً همان‌طور که ادمین تایپ کرده ارسال می‌شود $panelData = getPanelTextWithEntities('multi_location'); $params = ['chat_id' => $chatId, 'text' => $panelData['text'], 'entities' => json_encode($panelData['entities']), 'reply_markup' => json_encode($keyboard), 'disable_web_page_preview' => true]; if ($messageId) { $params['message_id'] = $messageId; $result = sendTelegramRequest('editMessageText', $params); } else { $result = sendTelegramRequest('sendMessage', $params); } if ($result === false) logBotError("showMultiLocationPanel failed"); }