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");
}