From d5a081cadef9d219a3667660ca4f4c377b636a4d Mon Sep 17 00:00:00 2001 From: kk K Date: Mon, 20 Apr 2026 10:03:55 +0800 Subject: [PATCH] Add Telegram remote control --- AliyunTrafficCheck.php | 46 ++- ConfigManager.php | 30 +- Database.php | 18 + TelegramControlService.php | 804 +++++++++++++++++++++++++++++++++++++ docker/entrypoint.sh | 11 +- telegram_worker.php | 32 ++ template.html | 13 +- 7 files changed, 937 insertions(+), 17 deletions(-) create mode 100644 TelegramControlService.php create mode 100644 telegram_worker.php diff --git a/AliyunTrafficCheck.php b/AliyunTrafficCheck.php index fe86807..3b6a759 100644 --- a/AliyunTrafficCheck.php +++ b/AliyunTrafficCheck.php @@ -6,6 +6,7 @@ require_once 'ConfigManager.php'; require_once 'AliyunService.php'; require_once 'NotificationService.php'; require_once 'DdnsService.php'; +require_once 'TelegramControlService.php'; use AlibabaCloud\Client\Exception\ClientException; use AlibabaCloud\Client\Exception\ServerException; @@ -141,9 +142,24 @@ class AliyunTrafficCheck return substr((string) ($account['access_key_id'] ?? ''), 0, 7) . '***'; } - private function resolveSecretFromDatabase($accessKeyId, $regionId) + private function resolveSecretFromDatabase($accessKeyId, $regionId, $groupKey = '') { $pdo = $this->db->getPdo(); + $groupKey = trim((string) $groupKey); + + if ($groupKey !== '') { + $stmt = $pdo->prepare("SELECT access_key_secret FROM accounts WHERE group_key = ? LIMIT 1"); + $stmt->execute([$groupKey]); + $row = $stmt->fetch(); + + if ($row && !empty($row['access_key_secret'])) { + $secret = $this->configManager->decryptAccountSecret($row['access_key_secret']); + if (!empty($secret)) { + return $secret; + } + } + } + $stmt = $pdo->prepare("SELECT access_key_secret FROM accounts WHERE access_key_id = ? AND region_id = ? LIMIT 1"); $stmt->execute([$accessKeyId, $regionId]); $row = $stmt->fetch(); @@ -157,8 +173,10 @@ class AliyunTrafficCheck foreach ($this->configManager->getAccountGroups() as $group) { if ( - ($group['AccessKeyId'] ?? '') === $accessKeyId - && ($group['regionId'] ?? '') === $regionId + ( + ($groupKey !== '' && ($group['groupKey'] ?? '') === $groupKey) + || (($group['AccessKeyId'] ?? '') === $accessKeyId && ($group['regionId'] ?? '') === $regionId) + ) && !empty($group['AccessKeySecret']) && $group['AccessKeySecret'] !== '********' ) { @@ -280,7 +298,9 @@ class AliyunTrafficCheck 'proxy_ip' => $settings['notify_tg_proxy_ip'] ?? '', 'proxy_port' => $settings['notify_tg_proxy_port'] ?? '', 'proxy_user' => $settings['notify_tg_proxy_user'] ?? '', - 'proxy_pass' => !empty($settings['notify_tg_proxy_pass']) ? '********' : '' + 'proxy_pass' => !empty($settings['notify_tg_proxy_pass']) ? '********' : '', + 'allowed_user_ids' => $settings['notify_tg_allowed_user_ids'] ?? '', + 'confirm_ttl' => (int) ($settings['notify_tg_confirm_ttl'] ?? 60) ], 'webhook' => [ 'enabled' => ($settings['notify_wh_enabled'] ?? '0') === '1', @@ -664,9 +684,21 @@ class AliyunTrafficCheck // 执行异步彻底销毁循环 $this->processPendingReleases(); + $this->processTelegramControl(); + return implode(PHP_EOL, $logs); } + private function processTelegramControl() + { + try { + $service = new TelegramControlService($this->db, $this->configManager, $this); + $service->processUpdates(); + } catch (\Exception $e) { + $this->db->addLog('error', 'Telegram 控制处理失败: ' . strip_tags($e->getMessage())); + } + } + public function getStatusForFrontend($includeSensitive = false) { if ($this->initError) @@ -839,7 +871,7 @@ class AliyunTrafficCheck } if ($accessKeySecret === '********') { - $accessKeySecret = $this->resolveSecretFromDatabase($accessKeyId, $regionId); + $accessKeySecret = $this->resolveSecretFromDatabase($accessKeyId, $regionId, $account['groupKey'] ?? ''); } try { @@ -1644,7 +1676,7 @@ class AliyunTrafficCheck return $metrics; } - public function controlInstanceAction($accountId, $action, $shutdownMode = 'KeepCharging') + public function controlInstanceAction($accountId, $action, $shutdownMode = 'KeepCharging', $waitForSync = true) { if ($this->initError) return false; @@ -1660,7 +1692,7 @@ class AliyunTrafficCheck $newStatus = $action === 'stop' ? 'Stopping' : 'Starting'; $this->configManager->updateAccountStatus($accountId, $targetAccount['traffic_used'], $newStatus, time()); $this->configManager->updateAutoStartBlocked($accountId, $action === 'stop'); - if ($action === 'start') { + if ($action === 'start' && $waitForSync) { sleep(8); $this->configManager->syncAccountGroups(true); $this->configManager->load(); diff --git a/ConfigManager.php b/ConfigManager.php index 702bf90..7bf9e52 100644 --- a/ConfigManager.php +++ b/ConfigManager.php @@ -565,6 +565,8 @@ class ConfigManager $this->saveSetting('notify_tg_proxy_ip', $telegram['proxy_ip'] ?? ''); $this->saveSetting('notify_tg_proxy_port', $telegram['proxy_port'] ?? ''); $this->saveSetting('notify_tg_proxy_user', $telegram['proxy_user'] ?? ''); + $this->saveSetting('notify_tg_allowed_user_ids', trim((string) ($telegram['allowed_user_ids'] ?? ''))); + $this->saveSetting('notify_tg_confirm_ttl', max(30, (int) ($telegram['confirm_ttl'] ?? 60))); if (isset($telegram['proxy_pass']) && $telegram['proxy_pass'] !== '********') { $this->saveSetting('notify_tg_proxy_pass', $telegram['proxy_pass'] ?? ''); @@ -606,7 +608,11 @@ class ConfigManager $isPlaceholder = $accessKeySecret === '********'; if ($isPlaceholder) { - $accessKeySecret = $this->resolveExistingSecret($accessKeyId, $regionId); + $accessKeySecret = $this->resolveExistingSecret( + $accessKeyId, + $regionId, + trim((string) ($group['groupKey'] ?? '')) + ); } if (!$allowEmpty && $accessKeyId === '' && $accessKeySecret === '' && $regionId === '') { @@ -642,10 +648,19 @@ class ConfigManager return array_values($normalized); } - private function resolveExistingSecret($accessKeyId, $regionId) + private function resolveExistingSecret($accessKeyId, $regionId, $groupKey = '') { $accessKeyId = trim((string) $accessKeyId); $regionId = trim((string) $regionId); + $requestedGroupKey = trim((string) $groupKey); + + if ($requestedGroupKey !== '') { + foreach ($this->accountsCache as $row) { + if (($row['group_key'] ?? '') === $requestedGroupKey && !empty($row['access_key_secret'])) { + return $row['access_key_secret']; + } + } + } foreach ($this->accountsCache as $row) { if ($row['access_key_id'] === $accessKeyId && $row['region_id'] === $regionId) { @@ -653,9 +668,9 @@ class ConfigManager } } - $groupKey = $this->buildGroupKey($accessKeyId, $regionId); + $derivedGroupKey = $this->buildGroupKey($accessKeyId, $regionId); foreach ($this->accountsCache as $row) { - if (($row['group_key'] === $groupKey) && !empty($row['access_key_secret'])) { + if (($row['group_key'] === $derivedGroupKey) && !empty($row['access_key_secret'])) { return $row['access_key_secret']; } } @@ -663,13 +678,16 @@ class ConfigManager $rawGroups = json_decode((string) ($this->configCache['account_groups'] ?? ''), true); if (is_array($rawGroups)) { foreach ($rawGroups as $group) { + $savedGroupKey = trim((string) ($group['groupKey'] ?? '')); $savedAccessKeyId = trim((string) ($group['AccessKeyId'] ?? '')); $savedRegionId = trim((string) ($group['regionId'] ?? '')); $savedSecret = trim((string) ($group['AccessKeySecret'] ?? '')); if ( - $savedAccessKeyId === $accessKeyId - && $savedRegionId === $regionId + ( + ($requestedGroupKey !== '' && $savedGroupKey === $requestedGroupKey) + || ($savedAccessKeyId === $accessKeyId && $savedRegionId === $regionId) + ) && $savedSecret !== '' && $savedSecret !== '********' ) { diff --git a/Database.php b/Database.php index 2784678..5fb83cb 100644 --- a/Database.php +++ b/Database.php @@ -185,6 +185,24 @@ class Database updated_at INTEGER NOT NULL )"); + $this->pdo->exec("CREATE TABLE IF NOT EXISTS telegram_bot_state ( + key TEXT PRIMARY KEY, + value TEXT + )"); + + $this->pdo->exec("CREATE TABLE IF NOT EXISTS telegram_action_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT UNIQUE NOT NULL, + user_id TEXT NOT NULL, + chat_id TEXT NOT NULL, + action TEXT NOT NULL, + account_id INTEGER NOT NULL, + payload TEXT DEFAULT '', + expires_at INTEGER NOT NULL, + used_at INTEGER DEFAULT 0, + created_at INTEGER NOT NULL + )"); + $this->ensureColumn('accounts', 'traffic_used', 'REAL DEFAULT 0'); $this->ensureColumn('accounts', 'traffic_billing_month', "TEXT DEFAULT ''"); $this->ensureColumn('accounts', 'instance_status', "TEXT DEFAULT 'Unknown'"); diff --git a/TelegramControlService.php b/TelegramControlService.php new file mode 100644 index 0000000..59c7a96 --- /dev/null +++ b/TelegramControlService.php @@ -0,0 +1,804 @@ +db = $db; + $this->pdo = $db->getPdo(); + $this->configManager = $configManager; + $this->app = $app; + $this->settings = $configManager->getAllSettings(); + } + + public function processUpdates() + { + return $this->processUpdatesWithTimeout(1); + } + + public function processUpdatesWithTimeout($timeout = 1) + { + if (!$this->isConfigured()) { + return 0; + } + + $lock = $this->acquireLock(); + if (!$lock) { + return 0; + } + + try { + $this->cleanupExpiredTokens(); + $offset = ((int) $this->getState('last_update_id', '0')) + 1; + $response = $this->api('getUpdates', [ + 'offset' => $offset, + 'limit' => 20, + 'timeout' => max(0, (int) $timeout), + 'allowed_updates' => json_encode(['message', 'callback_query']) + ]); + + if (!$response['ok']) { + $message = $response['description'] ?? '未知错误'; + $this->db->addLog('error', 'Telegram 控制拉取消息失败: ' . $message); + return 0; + } + + $processed = 0; + foreach (($response['result'] ?? []) as $update) { + $updateId = (int) ($update['update_id'] ?? 0); + if ($updateId > 0) { + // 先确认 offset,避免发送消息过程中被重启后重复响应旧指令。 + $this->setState('last_update_id', (string) $updateId); + } + + try { + if (isset($update['callback_query'])) { + $this->handleCallback($update['callback_query']); + } elseif (isset($update['message'])) { + $this->handleMessage($update['message']); + } + $processed++; + } catch (\Exception $e) { + $this->db->addLog('error', 'Telegram 控制指令处理失败: ' . strip_tags($e->getMessage())); + } + } + return $processed; + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + } + + private function acquireLock() + { + $dir = __DIR__ . '/data'; + if (!is_dir($dir)) { + @mkdir($dir, 0775, true); + } + + $lock = @fopen($dir . '/telegram-control.lock', 'c'); + if (!$lock) { + return null; + } + + if (!flock($lock, LOCK_EX | LOCK_NB)) { + fclose($lock); + return null; + } + + return $lock; + } + + private function isConfigured() + { + return trim((string) ($this->settings['notify_tg_token'] ?? '')) !== '' + && trim((string) ($this->settings['notify_tg_chat_id'] ?? '')) !== ''; + } + + private function handleMessage(array $message) + { + $chat = $message['chat'] ?? []; + $from = $message['from'] ?? []; + $chatId = (string) ($chat['id'] ?? ''); + $userId = (string) ($from['id'] ?? ''); + if (!$this->isAllowed($chatId, $userId)) { + return; + } + + $text = trim((string) ($message['text'] ?? '')); + $command = strtolower(preg_replace('/@.+$/', '', strtok($text, " \n\t") ?: '')); + + if (in_array($command, ['/traffic', '流量'], true)) { + $this->sendMessage($chatId, $this->buildTrafficText(), $this->trafficKeyboard()); + return; + } + + if (in_array($command, ['/instances', '实例'], true)) { + $this->sendMessage($chatId, $this->buildInstancesText(1), $this->instancesKeyboard(1)); + return; + } + + $this->sendMessage($chatId, $this->mainMenuText(), $this->mainMenuKeyboard()); + } + + private function handleCallback(array $callback) + { + $id = (string) ($callback['id'] ?? ''); + $from = $callback['from'] ?? []; + $message = $callback['message'] ?? []; + $chat = $message['chat'] ?? []; + $chatId = (string) ($chat['id'] ?? ''); + $userId = (string) ($from['id'] ?? ''); + $messageId = (int) ($message['message_id'] ?? 0); + $data = (string) ($callback['data'] ?? ''); + + if (!$this->isAllowed($chatId, $userId)) { + $this->answerCallback($id, '没有权限执行该操作'); + return; + } + + $parts = explode(':', $data); + if (($parts[0] ?? '') !== 'm') { + $this->answerCallback($id); + return; + } + + $action = $parts[1] ?? 'home'; + if ($action === 'home') { + $this->answerCallback($id); + $this->editMessage($chatId, $messageId, $this->mainMenuText(), $this->mainMenuKeyboard()); + } elseif ($action === 'help') { + $this->answerCallback($id); + $this->editMessage($chatId, $messageId, $this->helpText(), $this->mainMenuKeyboard()); + } elseif ($action === 'traffic') { + $this->answerCallback($id, '正在刷新流量...'); + $this->refreshAllData(); + $this->editMessage($chatId, $messageId, $this->buildTrafficText(), $this->trafficKeyboard()); + return; + } elseif ($action === 'list') { + $this->answerCallback($id); + $page = max(1, (int) ($parts[2] ?? 1)); + $this->editMessage($chatId, $messageId, $this->buildInstancesText($page), $this->instancesKeyboard($page)); + } elseif ($action === 'listrefresh') { + $this->answerCallback($id, '正在刷新列表...'); + $page = max(1, (int) ($parts[2] ?? 1)); + $this->refreshAllData(); + $this->editMessage($chatId, $messageId, $this->buildInstancesText($page), $this->instancesKeyboard($page)); + return; + } elseif ($action === 'inst') { + $this->answerCallback($id); + $accountId = (int) ($parts[2] ?? 0); + $this->editMessage($chatId, $messageId, $this->buildInstanceDetailText($accountId), $this->instanceKeyboard($accountId)); + } elseif ($action === 'refresh') { + $this->answerCallback($id, '正在刷新状态...'); + $accountId = (int) ($parts[2] ?? 0); + if ($accountId > 0) { + $this->app->refreshAccount($accountId); + } + $this->editMessage($chatId, $messageId, $this->buildInstanceDetailText($accountId), $this->instanceKeyboard($accountId)); + return; + } elseif ($action === 'refreshall') { + $this->answerCallback($id, '正在同步数据...'); + $this->refreshAllData(); + $this->editMessage($chatId, $messageId, $this->buildTrafficText(), $this->trafficKeyboard()); + return; + } elseif ($action === 'start') { + $this->answerCallback($id, '正在提交开机指令...'); + $accountId = (int) ($parts[2] ?? 0); + $this->startInstance($chatId, $messageId, $accountId); + return; + } elseif ($action === 'stop') { + $this->answerCallback($id, '正在提交停机指令...'); + $accountId = (int) ($parts[2] ?? 0); + $this->stopInstance($chatId, $messageId, $accountId); + return; + } elseif ($action === 'release') { + $this->answerCallback($id); + $accountId = (int) ($parts[2] ?? 0); + if (!$this->findInstance($accountId)) { + $this->editMessage($chatId, $messageId, "释放失败:实例不存在或已被清理。", $this->mainMenuKeyboard()); + return; + } + $token = $this->createActionToken($userId, $chatId, 'release', $accountId); + $this->editMessage($chatId, $messageId, $this->buildReleaseConfirmText($accountId), $this->releaseConfirmKeyboard($token, $accountId)); + } elseif ($action === 'confirm') { + $this->answerCallback($id, '正在提交释放指令...'); + $token = (string) ($parts[2] ?? ''); + $this->confirmRelease($chatId, $messageId, $userId, $token); + return; + } elseif ($action === 'cancel') { + $this->answerCallback($id); + $token = (string) ($parts[2] ?? ''); + $this->useActionToken($token, $userId, $chatId, false); + $this->editMessage($chatId, $messageId, "已取消释放操作。", $this->mainMenuKeyboard()); + } else { + $this->answerCallback($id); + } + } + + private function isAllowed($chatId, $userId) + { + $configuredChatId = trim((string) ($this->settings['notify_tg_chat_id'] ?? '')); + if ($configuredChatId === '' || $chatId !== $configuredChatId) { + return false; + } + + $allowed = $this->parseCsvIds($this->settings['notify_tg_allowed_user_ids'] ?? ''); + if (!empty($allowed)) { + return in_array($userId, $allowed, true); + } + + // 私聊场景下 chat_id 通常等于 from.id;群聊未配置白名单时不允许控制。 + return $chatId !== '' && $chatId[0] !== '-' && $chatId === $userId; + } + + private function parseCsvIds($value) + { + $items = preg_split('/[\s,;,;]+/', trim((string) $value)) ?: []; + return array_values(array_filter(array_map('trim', $items), function ($item) { + return $item !== ''; + })); + } + + private function mainMenuText() + { + return "🛡️ ECS 服务器管家\n\n请选择要执行的操作:"; + } + + private function helpText() + { + return "📘 使用说明\n\n" + . "配置 Telegram 通知后,当前 Bot 默认支持远程控制。\n\n" + . "可用功能:\n" + . "📊 查看账号概览\n" + . "🖥️ 查看实例列表和详情\n" + . "🚀 对已停止实例一键开机\n" + . "🗑️ 二次确认后释放实例\n\n" + . "⚠️ 释放实例会进入后台安全队列,系统会继续处理停机、托管 EIP 回收、ECS 删除和 DDNS 清理。"; + } + + private function mainMenuKeyboard() + { + return [ + 'inline_keyboard' => [ + [ + ['text' => '📊 账号概览', 'callback_data' => 'm:traffic'], + ['text' => '🖥️ 实例列表', 'callback_data' => 'm:list:1'] + ], + [ + ['text' => '🔄 刷新数据', 'callback_data' => 'm:refreshall'], + ['text' => '📘 帮助说明', 'callback_data' => 'm:help'] + ] + ] + ]; + } + + private function trafficKeyboard() + { + return [ + 'inline_keyboard' => [ + [ + ['text' => '🔄 刷新流量', 'callback_data' => 'm:traffic'], + ['text' => '🖥️ 查看实例', 'callback_data' => 'm:list:1'] + ], + [ + ['text' => '🏠 返回主菜单', 'callback_data' => 'm:home'] + ] + ] + ]; + } + + private function buildTrafficText() + { + $config = $this->app->getConfigForFrontend(); + $accounts = $config['Accounts'] ?? []; + if (empty($accounts)) { + return "📊 账号概览\n\n暂无账号数据,请先在控制台添加账号。"; + } + + $lines = ["📊 账号概览"]; + foreach ($accounts as $account) { + $used = (float) ($account['usageUsed'] ?? 0); + $total = (float) ($account['maxTraffic'] ?? 0); + $percent = (float) ($account['usagePercent'] ?? 0); + $status = $percent >= 100 ? '已超量' : ($percent >= (float) ($config['traffic_threshold'] ?? 95) ? '接近阈值' : '正常'); + if (($account['trafficStatus'] ?? 'ok') !== 'ok') { + $status = $account['trafficMessage'] ?: '流量同步异常'; + } + + $lines[] = ""; + $lines[] = "👤 账号:" . ($account['remark'] ?: '未命名账号'); + $lines[] = "📍 区域:" . $this->regionName($account['regionId'] ?? ''); + $lines[] = "📦 已用:" . $this->formatTraffic($used) . " / " . $this->formatTraffic($total); + $lines[] = "📈 使用率:" . $percent . "%"; + $lines[] = $this->trafficStatusIcon($status) . " 状态:" . $status; + } + + return implode("\n", $lines); + } + + private function buildInstancesText($page) + { + $instances = $this->getInstances(); + if (empty($instances)) { + return "🖥️ 实例列表\n\n暂无实例数据。"; + } + + $pageSize = 6; + $totalPages = max(1, (int) ceil(count($instances) / $pageSize)); + $page = min(max(1, $page), $totalPages); + $slice = array_slice($instances, ($page - 1) * $pageSize, $pageSize); + + $lines = ["🖥️ 实例列表 第 {$page}/{$totalPages} 页"]; + foreach ($slice as $inst) { + $lines[] = ""; + $status = $inst['instanceStatus'] ?? ''; + $lines[] = "🖥️ " . ($inst['remark'] ?: $inst['instanceName'] ?: $inst['instanceId']); + $lines[] = $this->statusIcon($status) . " 状态:" . $this->statusLabel($status); + $lines[] = "📦 流量:" . $this->formatTraffic((float) ($inst['flow_used'] ?? 0)) . " / " . $this->formatTraffic((float) ($inst['flow_total'] ?? 0)); + $lines[] = "🌐 IP:" . (($inst['publicIp'] ?? '') ?: '-'); + } + + return implode("\n", $lines); + } + + private function refreshAllData() + { + if (method_exists($this->app, 'getAllManagedInstances')) { + $this->app->getAllManagedInstances(true); + $this->configManager->load(); + } + } + + private function instancesKeyboard($page) + { + $instances = $this->getInstances(); + $pageSize = 6; + $totalPages = max(1, (int) ceil(count($instances) / $pageSize)); + $page = min(max(1, $page), $totalPages); + $slice = array_slice($instances, ($page - 1) * $pageSize, $pageSize); + + $keyboard = []; + foreach ($slice as $inst) { + $label = $this->shortButtonText($this->statusIcon($inst['instanceStatus'] ?? '') . ' ' . ($inst['remark'] ?: $inst['instanceName'] ?: $inst['instanceId']) . ' / ' . $this->statusLabel($inst['instanceStatus'] ?? '')); + $keyboard[] = [['text' => $label, 'callback_data' => 'm:inst:' . (int) $inst['accountId']]]; + } + + $pager = []; + if ($page > 1) { + $pager[] = ['text' => '⬅️ 上一页', 'callback_data' => 'm:list:' . ($page - 1)]; + } + if ($page < $totalPages) { + $pager[] = ['text' => '下一页 ➡️', 'callback_data' => 'm:list:' . ($page + 1)]; + } + if (!empty($pager)) { + $keyboard[] = $pager; + } + + $keyboard[] = [ + ['text' => '🔄 刷新列表', 'callback_data' => 'm:listrefresh:' . $page], + ['text' => '🏠 返回主菜单', 'callback_data' => 'm:home'] + ]; + + return ['inline_keyboard' => $keyboard]; + } + + private function buildInstanceDetailText($accountId) + { + $inst = $this->findInstance($accountId); + if (!$inst) { + return "🖥️ 实例详情\n\n实例不存在或已被清理。"; + } + + $status = $inst['instanceStatus'] ?? ''; + return "🖥️ 实例详情\n\n" + . "🏷️ 备注:" . ($inst['remark'] ?: '-') . "\n" + . "📍 区域:" . ($inst['regionName'] ?? $this->regionName($inst['regionId'] ?? '')) . "\n" + . $this->statusIcon($status) . " 状态:" . $this->statusLabel($status) . "\n" + . "🆔 实例 ID:" . ($inst['instanceId'] ?: '-') . "\n" + . "🌐 公网 IP:" . (($inst['publicIp'] ?? '') ?: '-') . "\n" + . "🔌 公网类型:" . (($inst['publicIpMode'] ?? '') === 'eip' ? 'EIP' : 'ECS 公网') . "\n" + . "⚙️ 规格:" . (($inst['instanceType'] ?? '') ?: '-') . "\n" + . "📦 出口流量:" . $this->formatTraffic((float) ($inst['flow_used'] ?? 0)) . " / " . $this->formatTraffic((float) ($inst['flow_total'] ?? 0)) . "\n" + . "📈 使用率:" . ((float) ($inst['percentageOfUse'] ?? 0)) . "%\n" + . "🚧 阈值:" . ((int) ($inst['threshold'] ?? 95)) . "%"; + } + + private function instanceKeyboard($accountId) + { + $inst = $this->findInstance($accountId); + if (!$inst) { + return ['inline_keyboard' => [[['text' => '🖥️ 返回实例列表', 'callback_data' => 'm:list:1']]]]; + } + + $status = $inst['instanceStatus'] ?? ''; + $locked = !empty($inst['operationLocked']) || in_array($status, ['Releasing', 'Released'], true); + $keyboard = []; + if (!$locked && $status === 'Stopped') { + $keyboard[] = [['text' => '🚀 开机', 'callback_data' => 'm:start:' . (int) $accountId]]; + } + if (!$locked && $status === 'Running') { + $keyboard[] = [['text' => '🛑 停机', 'callback_data' => 'm:stop:' . (int) $accountId]]; + } + if (!$locked && !in_array($status, ['Releasing', 'Released'], true)) { + $keyboard[] = [['text' => '🗑️ 释放实例', 'callback_data' => 'm:release:' . (int) $accountId]]; + } + $keyboard[] = [['text' => '🔄 刷新状态', 'callback_data' => 'm:refresh:' . (int) $accountId]]; + $keyboard[] = [ + ['text' => '🖥️ 返回实例列表', 'callback_data' => 'm:list:1'], + ['text' => '🏠 返回主菜单', 'callback_data' => 'm:home'] + ]; + return ['inline_keyboard' => $keyboard]; + } + + private function buildReleaseConfirmText($accountId) + { + $inst = $this->findInstance($accountId); + if (!$inst) { + return "🗑️ 确认释放实例\n\n实例不存在或已被清理。"; + } + + $ttl = $this->confirmTtl(); + return "⚠️ 确认释放实例?\n\n" + . "🖥️ 实例:" . ($inst['remark'] ?: $inst['instanceName'] ?: '-') . "\n" + . "📍 区域:" . ($inst['regionName'] ?? $this->regionName($inst['regionId'] ?? '')) . "\n" + . "🆔 实例 ID:" . ($inst['instanceId'] ?: '-') . "\n" + . "🔌 公网类型:" . (($inst['publicIpMode'] ?? '') === 'eip' ? 'EIP' : 'ECS 公网') . "\n\n" + . "🗑️ 释放后 ECS 会被删除,系统托管 EIP 和 DDNS 解析会同步清理,操作不可恢复。\n\n" + . "⏱️ 请在 {$ttl} 秒内确认。"; + } + + private function releaseConfirmKeyboard($token, $accountId) + { + return [ + 'inline_keyboard' => [ + [ + ['text' => '⚠️ 确认释放', 'callback_data' => 'm:confirm:' . $token], + ['text' => '取消', 'callback_data' => 'm:cancel:' . $token] + ], + [ + ['text' => '🖥️ 返回实例详情', 'callback_data' => 'm:inst:' . (int) $accountId] + ] + ] + ]; + } + + private function startInstance($chatId, $messageId, $accountId) + { + $inst = $this->findInstance($accountId); + if (!$inst) { + $this->editMessage($chatId, $messageId, "❌ 开机失败:实例不存在。", $this->mainMenuKeyboard()); + return; + } + + if (($inst['instanceStatus'] ?? '') !== 'Stopped') { + $this->editMessage($chatId, $messageId, "ℹ️ 当前实例不是已停机状态,无需开机。", $this->instanceKeyboard($accountId)); + return; + } + + $success = $this->app->controlInstanceAction($accountId, 'start', 'KeepCharging', false); + $this->db->addLog($success ? 'info' : 'error', "Telegram 控制开机" . ($success ? '成功' : '失败') . " [{$inst['remark']}] {$inst['instanceId']}"); + $this->editMessage( + $chatId, + $messageId, + $success ? "🚀 开机指令已提交。\n\n🖥️ 实例:" . ($inst['remark'] ?: $inst['instanceId']) . "\n🟡 当前状态:启动中" : "❌ 开机失败,请查看系统日志。", + $this->instanceKeyboard($accountId) + ); + } + + private function stopInstance($chatId, $messageId, $accountId) + { + $inst = $this->findInstance($accountId); + if (!$inst) { + $this->editMessage($chatId, $messageId, "❌ 停机失败:实例不存在。", $this->mainMenuKeyboard()); + return; + } + + if (($inst['instanceStatus'] ?? '') !== 'Running') { + $this->editMessage($chatId, $messageId, "ℹ️ 当前实例不是运行中状态,无需停机。", $this->instanceKeyboard($accountId)); + return; + } + + $success = $this->app->controlInstanceAction($accountId, 'stop', 'KeepCharging', false); + $this->db->addLog($success ? 'info' : 'error', "Telegram 控制停机" . ($success ? '成功' : '失败') . " [{$inst['remark']}] {$inst['instanceId']}"); + $this->editMessage( + $chatId, + $messageId, + $success ? "🛑 停机指令已提交。\n\n🖥️ 实例:" . ($inst['remark'] ?: $inst['instanceId']) . "\n🟠 当前状态:停机中" : "❌ 停机失败,请查看系统日志。", + $this->instanceKeyboard($accountId) + ); + } + + private function confirmRelease($chatId, $messageId, $userId, $token) + { + $record = $this->useActionToken($token, $userId, $chatId, true); + if (!$record) { + $this->editMessage($chatId, $messageId, "⏱️ 释放确认已失效,请重新发起释放操作。", $this->mainMenuKeyboard()); + return; + } + + $accountId = (int) $record['account_id']; + $inst = $this->findInstance($accountId); + $success = $this->app->deleteInstanceAction($accountId); + $label = $inst ? ($inst['remark'] ?: $inst['instanceId']) : ('实例 #' . $accountId); + $this->db->addLog($success ? 'warning' : 'error', "Telegram 提交释放" . ($success ? '成功' : '失败') . " [{$label}]"); + $this->editMessage( + $chatId, + $messageId, + $success + ? "🗑️ 释放指令已提交。\n\n🖥️ 实例:{$label}\n后台释放队列已接管,会继续处理停机、托管 EIP 回收、ECS 删除和 DDNS 清理。" + : "❌ 释放指令提交失败,请查看系统日志。", + $this->mainMenuKeyboard() + ); + } + + private function getInstances() + { + $status = $this->app->getStatusForFrontend(true); + $items = $status['data'] ?? []; + usort($items, function ($a, $b) { + return strcmp(($a['regionName'] ?? '') . ($a['remark'] ?? ''), ($b['regionName'] ?? '') . ($b['remark'] ?? '')); + }); + return $items; + } + + private function findInstance($accountId) + { + foreach ($this->getInstances() as $inst) { + if ((int) ($inst['accountId'] ?? 0) === (int) $accountId) { + return $inst; + } + } + return null; + } + + private function createActionToken($userId, $chatId, $action, $accountId, array $payload = []) + { + $token = bin2hex(random_bytes(8)); + $stmt = $this->pdo->prepare(" + INSERT INTO telegram_action_tokens + (token, user_id, chat_id, action, account_id, payload, expires_at, used_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?) + "); + $now = time(); + $stmt->execute([ + $token, + (string) $userId, + (string) $chatId, + $action, + (int) $accountId, + json_encode($payload, JSON_UNESCAPED_UNICODE), + $now + $this->confirmTtl(), + $now + ]); + return $token; + } + + private function useActionToken($token, $userId, $chatId, $markUsed) + { + $stmt = $this->pdo->prepare(" + SELECT * + FROM telegram_action_tokens + WHERE token = ? + AND user_id = ? + AND chat_id = ? + AND used_at = 0 + AND expires_at >= ? + LIMIT 1 + "); + $stmt->execute([(string) $token, (string) $userId, (string) $chatId, time()]); + $record = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$record) { + return null; + } + + $update = $this->pdo->prepare("UPDATE telegram_action_tokens SET used_at = ? WHERE id = ?"); + $update->execute([time(), (int) $record['id']]); + + return $record; + } + + private function cleanupExpiredTokens() + { + $stmt = $this->pdo->prepare("DELETE FROM telegram_action_tokens WHERE expires_at < ? OR (used_at > 0 AND used_at < ?)"); + $stmt->execute([time() - 3600, time() - 86400]); + } + + private function getState($key, $default = '') + { + $stmt = $this->pdo->prepare("SELECT value FROM telegram_bot_state WHERE key = ? LIMIT 1"); + $stmt->execute([$key]); + $value = $stmt->fetchColumn(); + return $value === false ? $default : (string) $value; + } + + private function setState($key, $value) + { + $stmt = $this->pdo->prepare(" + INSERT INTO telegram_bot_state (key, value) + VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + "); + $stmt->execute([$key, $value]); + } + + private function sendMessage($chatId, $text, array $keyboard = null) + { + $payload = [ + 'chat_id' => $chatId, + 'text' => $text + ]; + if ($keyboard) { + $payload['reply_markup'] = json_encode($keyboard, JSON_UNESCAPED_UNICODE); + } + return $this->api('sendMessage', $payload); + } + + private function editMessage($chatId, $messageId, $text, array $keyboard = null) + { + if ($messageId <= 0) { + return $this->sendMessage($chatId, $text, $keyboard); + } + + $payload = [ + 'chat_id' => $chatId, + 'message_id' => $messageId, + 'text' => $text + ]; + if ($keyboard) { + $payload['reply_markup'] = json_encode($keyboard, JSON_UNESCAPED_UNICODE); + } + $response = $this->api('editMessageText', $payload); + if (!$response['ok']) { + return $this->sendMessage($chatId, $text, $keyboard); + } + return $response; + } + + private function answerCallback($callbackId, $text = '') + { + if ($callbackId === '') { + return; + } + $payload = ['callback_query_id' => $callbackId]; + if ($text !== '') { + $payload['text'] = $text; + } + $this->api('answerCallbackQuery', $payload); + } + + private function api($method, array $payload = []) + { + $token = trim((string) ($this->settings['notify_tg_token'] ?? '')); + $proxyType = $this->settings['notify_tg_proxy_type'] ?? 'none'; + $url = "https://api.telegram.org/bot{$token}/{$method}"; + if ($proxyType === 'custom' && !empty($this->settings['notify_tg_proxy_url'])) { + $url = rtrim($this->settings['notify_tg_proxy_url'], '/') . "/bot{$token}/{$method}"; + } + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($payload)); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 8); + curl_setopt($ch, CURLOPT_TIMEOUT, 15); + + if ($proxyType === 'socks5') { + $proxyIp = trim((string) ($this->settings['notify_tg_proxy_ip'] ?? '')); + $proxyPort = trim((string) ($this->settings['notify_tg_proxy_port'] ?? '')); + if ($proxyIp !== '' && $proxyPort !== '') { + curl_setopt($ch, CURLOPT_PROXYTYPE, CURLPROXY_SOCKS5_HOSTNAME); + curl_setopt($ch, CURLOPT_PROXY, "{$proxyIp}:{$proxyPort}"); + $proxyUser = $this->settings['notify_tg_proxy_user'] ?? ''; + $proxyPass = $this->settings['notify_tg_proxy_pass'] ?? ''; + if ($proxyUser !== '' || $proxyPass !== '') { + curl_setopt($ch, CURLOPT_PROXYUSERPWD, "{$proxyUser}:{$proxyPass}"); + } + } + } + + $raw = curl_exec($ch); + $error = curl_error($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($error) { + return ['ok' => false, 'description' => '网络请求错误: ' . $error]; + } + + $decoded = json_decode((string) $raw, true); + if (!is_array($decoded)) { + return ['ok' => false, 'description' => "接口返回异常 {$httpCode}: " . (string) $raw]; + } + return $decoded; + } + + private function confirmTtl() + { + return max(30, (int) ($this->settings['notify_tg_confirm_ttl'] ?? 60)); + } + + private function shortButtonText($text) + { + $text = trim((string) $text); + if (function_exists('mb_strlen') && mb_strlen($text, 'UTF-8') > 28) { + return mb_substr($text, 0, 25, 'UTF-8') . '...'; + } + return $text; + } + + private function formatTraffic($value) + { + $value = (float) $value; + if ($value <= 0) { + return '0 MB'; + } + if ($value < 1) { + return round($value * 1024, 2) . ' MB'; + } + return round($value, 2) . ' GB'; + } + + private function statusLabel($status) + { + $map = [ + 'Running' => '运行中', + 'Starting' => '启动中', + 'Stopping' => '停机中', + 'Stopped' => '已停止', + 'Pending' => '创建中', + 'Releasing' => '释放中', + 'Released' => '已释放', + 'Unknown' => '未知' + ]; + return $map[$status] ?? ($status ?: '未知'); + } + + private function statusIcon($status) + { + $map = [ + 'Running' => '🟢', + 'Starting' => '🟡', + 'Stopping' => '🟠', + 'Stopped' => '🔴', + 'Pending' => '🟡', + 'Releasing' => '🗑️', + 'Released' => '⚫', + 'Unknown' => '⚪' + ]; + return $map[$status] ?? '⚪'; + } + + private function trafficStatusIcon($status) + { + if (strpos($status, '超量') !== false || strpos($status, '异常') !== false) { + return '🔴'; + } + if (strpos($status, '接近') !== false) { + return '🟠'; + } + return '🟢'; + } + + private function regionName($regionId) + { + $map = [ + 'cn-hongkong' => '中国香港', + 'ap-southeast-1' => '新加坡', + 'ap-northeast-1' => '日本(东京)', + 'us-west-1' => '美国(硅谷)', + 'us-east-1' => '美国(弗吉尼亚)', + 'cn-hangzhou' => '华东 1(杭州)', + 'cn-shanghai' => '华东 2(上海)', + 'cn-beijing' => '华北 2(北京)', + 'cn-shenzhen' => '华南 1(深圳)' + ]; + return $map[$regionId] ?? ($regionId ?: '-'); + } +} diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 131099c..eb1656e 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -14,12 +14,17 @@ fi crond -b -l 8 echo "Cron daemon started." -# 3. 启动 PHP-FPM (后台运行) +# 3. 启动 Telegram 控制轮询 (后台运行) +# 如果没有配置 Telegram,进程会保持低频等待;配置后按钮控制可秒级响应。 +su -s /bin/sh www-data -c "php /var/www/html/telegram_worker.php" >/dev/null 2>&1 & +echo "Telegram control worker started." + +# 4. 启动 PHP-FPM (后台运行) # -D 表示 Daemonize (守护进程模式) php-fpm -D echo "PHP-FPM started." -# 4. 启动 Nginx (前台运行) +# 5. 启动 Nginx (前台运行) # 保持 Nginx 在前台运行,防止容器退出 echo "Nginx started." -nginx -g 'daemon off;' \ No newline at end of file +nginx -g 'daemon off;' diff --git a/telegram_worker.php b/telegram_worker.php new file mode 100644 index 0000000..14017cd --- /dev/null +++ b/telegram_worker.php @@ -0,0 +1,32 @@ +getProperty('db'); +$dbProp->setAccessible(true); +$db = $dbProp->getValue($app); + +$configProp = $ref->getProperty('configManager'); +$configProp->setAccessible(true); +$configManager = $configProp->getValue($app); + +$service = new TelegramControlService($db, $configManager, $app); + +while (true) { + try { + $service->processUpdatesWithTimeout(20); + } catch (\Throwable $e) { + $db->addLog('error', 'Telegram 控制常驻进程异常: ' . strip_tags($e->getMessage())); + sleep(5); + } +} diff --git a/template.html b/template.html index d5e999a..73d3bbb 100644 --- a/template.html +++ b/template.html @@ -2573,6 +2573,15 @@ +
+

Telegram 远程控制

+

配置 Telegram 通知后默认可通过 Bot 控制实例。发送 /start 打开按钮菜单,支持查看流量、开机和二次确认释放。

+
+ + +
+

私聊 Bot 时可不填用户 ID;群聊使用时建议填写,避免群成员误操作。

+