Files
ecs-controller/TelegramControlService.php
2026-04-20 10:03:55 +08:00

805 lines
31 KiB
PHP
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 ?: '-');
}
}