Add Telegram remote control

This commit is contained in:
kk K
2026-04-20 10:03:55 +08:00
parent 7b7135de8c
commit d5a081cade
7 changed files with 937 additions and 17 deletions

804
TelegramControlService.php Normal file
View File

@@ -0,0 +1,804 @@
<?php
class TelegramControlService
{
private $db;
private $pdo;
private $configManager;
private $app;
private $settings;
public function __construct(Database $db, ConfigManager $configManager, $app)
{
$this->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 ?: '-');
}
}