importer 3x-ui/CLI update/upload file/clearing logs

This commit is contained in:
Vladless
2025-07-20 02:12:26 +03:00
parent f3be55e037
commit 0e8bfd46ad
11 changed files with 178 additions and 97 deletions
-3
View File
@@ -75,7 +75,6 @@ async def edit_key_by_email(
session: AsyncSession = Depends(get_session),
admin: Admin = Depends(verify_admin_token),
):
logger.info(f"[API] edit_key_by_email called for email={email}, payload={key_update.dict(exclude_unset=False)}")
result = await session.execute(select(Key).where(Key.email == email))
db_key = result.scalar_one_or_none()
@@ -96,7 +95,6 @@ async def edit_key_by_email(
try:
new_expiry_time = db_key.expiry_time
logger.info(f"[API] renew_key_in_cluster new_expiry_time (ms) = {new_expiry_time}")
await renew_key_in_cluster(
cluster_id=db_key.server_id,
email=db_key.email,
@@ -123,7 +121,6 @@ async def create_key_api(
session: AsyncSession = Depends(get_session),
admin: Admin = Depends(verify_admin_token),
):
logger.info(f"[API] Запрос на создание ключа: {payload.dict()}")
try:
await create_key_on_cluster(
+49 -29
View File
@@ -1,6 +1,8 @@
import os
import subprocess
import traceback
import time
from functools import lru_cache
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
@@ -21,59 +23,61 @@ storage = MemoryStorage()
dp = Dispatcher(bot=bot, storage=storage)
def get_git_commit_number() -> str:
_last_check_time = 0
_last_git_info = ""
def _get_git_commit_number_uncached() -> str:
repo_url = "https://github.com/Vladless/Solo_bot"
cwd = os.path.abspath(os.path.dirname(__file__))
if not os.path.isdir(os.path.join(cwd, ".git")):
cwd = "/root/Prod/Solo_bot"
logger.info(f"[Git] .git не найден в текущем каталоге, используем {cwd}")
env = os.environ.copy()
env["GIT_DIR"] = os.path.join(cwd, ".git")
env["GIT_WORK_TREE"] = cwd
try:
local_number = (
subprocess.check_output(["git", "rev-list", "--count", "HEAD"], cwd=cwd, env=env).decode().strip()
)
local_hash = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=cwd, env=env).decode().strip()
local_number = subprocess.check_output(
["git", "rev-list", "--count", "HEAD"], cwd=cwd, env=env
).decode().strip()
local_hash = subprocess.check_output(
["git", "rev-parse", "HEAD"], cwd=cwd, env=env
).decode().strip()
try:
branch = (
subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd, env=env).decode().strip()
)
branch = subprocess.check_output(
["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd, env=env
).decode().strip()
if branch == "HEAD":
describe = (
subprocess.check_output(
["git", "describe", "--tags", "--exact-match"], cwd=cwd, env=env, stderr=subprocess.DEVNULL
)
.decode()
.strip()
)
if describe.startswith("v") or "release" in describe.lower():
branch = "main"
else:
branch = "dev"
describe = subprocess.check_output(
["git", "describe", "--tags", "--exact-match"],
cwd=cwd,
env=env,
stderr=subprocess.DEVNULL,
).decode().strip()
branch = "main" if describe.startswith("v") or "release" in describe.lower() else "dev"
except Exception:
branch = "dev"
except Exception as e:
logger.error(f"[Git] Ошибка при получении локального коммита: {e}")
return f"\n(Требуется обновление через CLI (команда <code>sudo solobot</code>): {e})"
try:
subprocess.check_output(["git", "fetch", "origin"], cwd=cwd, env=env)
remote_commit = subprocess.check_output(
["git", "ls-remote", "origin", f"refs/heads/{branch}"], cwd=cwd, env=env
).decode()
remote_hash = remote_commit.split()[0]
remote_number = (
subprocess.check_output(["git", "rev-list", "--count", remote_hash], cwd=cwd, env=env).decode().strip()
)
remote_number = subprocess.check_output(
["git", "rev-list", "--count", remote_hash], cwd=cwd, env=env
).decode().strip()
if local_hash == remote_hash:
logger.info("[Git] Локальная версия актуальна")
return "\n(Актуальная версия)"
return (
@@ -81,12 +85,28 @@ def get_git_commit_number() -> str:
f"#{local_number}</a> / actual commit "
f'<a href="{repo_url}/commit/{remote_hash}">#{remote_number}</a>)'
)
except Exception:
except Exception as e:
logger.error(f"[Git] Ошибка при получении удалённого коммита: {e}")
return "\n(Требуется обновление через CLI, команда <code>sudo solobot</code>)"
version = f"v4.4-preRelease{get_git_commit_number()}"
@lru_cache(maxsize=1)
def _cached_git_info() -> str:
return _get_git_commit_number_uncached()
def get_git_commit_number() -> str:
global _last_check_time, _last_git_info
now = time.time()
if now - _last_check_time > 3600:
_last_check_time = now
_cached_git_info.cache_clear()
_last_git_info = _cached_git_info()
return _last_git_info
def get_version() -> str:
return f"v4.4-preRelease{get_git_commit_number()}"
dp.message.filter(IsPrivateFilter())
+54 -38
View File
@@ -2,6 +2,7 @@ import os
import re
import subprocess
import sys
import shutil
import requests
@@ -69,18 +70,51 @@ def backup_project():
console.print(f"[green]✅ Бэкап сохранён в: {BACK_DIR}[/green]")
def auto_update_cli():
"""Обновляет CLI, если отличается от последней версии. Перезапускает при необходимости."""
console.print("[yellow]🔄 Проверка обновлений CLI...[/yellow]")
try:
url = "https://raw.githubusercontent.com/Vladless/Solo_bot/dev/cli_launcher.py"
response = requests.get(url, timeout=10)
if response.status_code != 200:
console.print("[red]⚠️ Не удалось получить обновление CLI[/red]")
return
latest_text = response.text
current_path = os.path.realpath(__file__)
with open(current_path, encoding="utf-8") as f:
current_text = f.read()
if current_text != latest_text:
console.print("[green]🆕 Доступна новая версия CLI. Обновляю...[/green]")
with open(current_path, "w", encoding="utf-8") as f:
f.write(latest_text)
os.chmod(current_path, 0o755)
console.print("[green]✅ CLI обновлён. Перезапуск...[/green]")
os.execv(sys.executable, [sys.executable, current_path])
else:
console.print("[green]✅ CLI уже актуален[/green]")
except Exception as e:
console.print(f"[red]❌ Ошибка при автообновлении CLI: {e}[/red]")
def fix_permissions():
"""Устанавливает корректные права на файлы проекта"""
console.print("[yellow]🔧 Устанавливаю права на файлы...[/yellow]")
try:
user = os.getenv("SUDO_USER") or os.getenv("USER")
if user:
subprocess.run(["sudo", "chown", "-R", f"{user}:{user}", PROJECT_DIR], check=True)
if os.geteuid() != 0:
console.print("[yellow]⚠️ Некоторые действия могут требовать прав администратора (sudo)[/yellow]")
try:
stat_info = os.stat(PROJECT_DIR)
uid = stat_info.st_uid
user = subprocess.check_output(["id", "-nu", str(uid)], text=True).strip()
subprocess.run(["sudo", "chown", "-R", f"{user}:{user}", PROJECT_DIR], check=True)
subprocess.run(["sudo", "chmod", "-R", "u=rwX,go=rX", PROJECT_DIR], check=True)
console.print("[green]✅ Права успешно установлены[/green]")
except subprocess.CalledProcessError as e:
console.print(f"[green]✅ Права установлены для пользователя {user}[/green]")
except Exception as e:
console.print(f"[red]❌ Ошибка при установке прав: {e}[/red]")
@@ -138,19 +172,26 @@ def install_git_if_needed():
def install_dependencies():
console.print("[blue]🔧 Установка зависимостей...[/blue]")
python312_path = shutil.which("python3.12")
if not python312_path:
console.print("[red]❌ Не найден python3.12 в системе[/red]")
console.print("[yellow]📦 Установите Python 3.12: sudo apt install python3.12 python3.12-venv[/yellow]")
sys.exit(1)
with console.status("[bold green]Устанавливаются зависимости...[/bold green]"):
try:
if not os.path.exists("venv"):
console.print("[yellow]⚠️ Виртуальное окружение не найдено. Создаю...[/yellow]")
subprocess.run("python3 -m venv venv", shell=True, check=True)
console.print("[yellow]⚠️ Виртуальное окружение не найдено. Создаю через python3.12...[/yellow]")
subprocess.run(f"{python312_path} -m venv venv", shell=True, check=True)
subprocess.run(
"bash -c 'source venv/bin/activate && pip install -r requirements.txt'",
shell=True,
check=True,
)
except subprocess.CalledProcessError:
console.print("[red]❌ Ошибка при установке зависимостей.[/red]")
except subprocess.CalledProcessError as e:
console.print(f"[red]❌ Ошибка при установке зависимостей: {e}[/red]")
def restart_service():
@@ -189,7 +230,6 @@ def get_remote_version(branch="main"):
def update_from_beta():
update_cli_launcher()
local_version = get_local_version()
remote_version = get_remote_version(branch="dev")
@@ -246,7 +286,6 @@ def update_from_beta():
def update_from_release():
update_cli_launcher()
if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot до одного из последних релизов[/yellow]"):
return
@@ -340,7 +379,7 @@ def show_update_menu():
def show_menu():
table = Table(title="Solobot CLI v0.2.0", title_style="bold magenta", header_style="bold blue")
table = Table(title="Solobot CLI v0.2.7", title_style="bold magenta", header_style="bold blue")
table.add_column("", justify="center", style="cyan", no_wrap=True)
table.add_column("Операция", style="white")
table.add_row("1", "Запустить бота (systemd)")
@@ -350,31 +389,13 @@ def show_menu():
table.add_row("5", "Показать логи (80 строк)")
table.add_row("6", "Показать статус")
table.add_row("7", "Обновить Solobot")
table.add_row("8", "Обновить CLI лаунчер")
table.add_row("9", "Выход")
table.add_row("8", "Выход")
console.print(table)
def update_cli_launcher():
"""Обновляет CLI лаунчер с dev ветки"""
console.print("[yellow]🔄 Обновление CLI лаунчера...[/yellow]")
try:
url = "https://raw.githubusercontent.com/Vladless/Solo_bot/dev/cli_launcher.py"
response = requests.get(url, timeout=10)
if response.status_code == 200:
with open(os.path.join(PROJECT_DIR, "cli_launcher.py"), "w", encoding="utf-8") as f:
f.write(response.text)
console.print("[green]✅ CLI лаунчер успешно обновлён[/green]")
os.chmod(os.path.join(PROJECT_DIR, "cli_launcher.py"), 0o755)
else:
console.print("[red]❌ Не удалось загрузить новый CLI[/red]")
except Exception as e:
console.print(f"[red]❌ Ошибка при обновлении CLI: {e}[/red]")
def main():
os.chdir(PROJECT_DIR)
auto_update_cli()
print_logo()
try:
while True:
@@ -424,11 +445,6 @@ def main():
elif choice == "7":
show_update_menu()
elif choice == "8":
if Confirm.ask("[yellow]Обновить CLI лаунчер с dev ветки?[/yellow]"):
update_cli_launcher()
elif choice == "9":
if Confirm.ask("[yellow]Хотите обновить CLI перед выходом?[/yellow]"):
update_cli_launcher()
console.print("[bold cyan]Выход из CLI. Удачного дня![/bold cyan]")
break
except KeyboardInterrupt:
+20 -11
View File
@@ -8,6 +8,7 @@ from itertools import cycle
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from config import USE_COUNTRY_SELECTION
from database.models import Key, Server, User
@@ -16,15 +17,23 @@ async def import_keys_from_3xui_db(db_path: str, session: AsyncSession) -> tuple
imported = 0
skipped = 0
result = await session.execute(
select(Server.cluster_name)
.where(Server.enabled is True, Server.panel_type == "3x-ui", Server.cluster_name.isnot(None))
.distinct()
)
clusters = [row[0] for row in result.fetchall()]
if not clusters:
raise RuntimeError("❌ Не найдено доступных кластеров для 3x-ui")
cluster_cycle = cycle(clusters)
if USE_COUNTRY_SELECTION:
result = await session.execute(
select(Server.name)
.where(Server.enabled == True, Server.panel_type == "3x-ui")
)
else:
result = await session.execute(
select(Server.cluster_name)
.where(Server.enabled == True, Server.panel_type == "3x-ui", Server.cluster_name.isnot(None))
.distinct()
)
server_ids = [row[0] for row in result.fetchall()]
if not server_ids:
raise RuntimeError("❌ Не найдено доступных серверов или кластеров для 3x-ui")
server_cycle = cycle(server_ids)
try:
conn = sqlite3.connect(db_path)
@@ -58,7 +67,7 @@ async def import_keys_from_3xui_db(db_path: str, session: AsyncSession) -> tuple
email = c.get("email")
expiry_time = int(c.get("expiryTime") or now_ts)
created_at = now_ts
server_id = next(cluster_cycle)
server_id = next(server_cycle)
if not tg_id or not client_id:
continue
@@ -112,4 +121,4 @@ async def import_keys_from_3xui_db(db_path: str, session: AsyncSession) -> tuple
continue
await session.commit()
return imported, skipped
return imported, skipped
-1
View File
@@ -130,7 +130,6 @@ async def check_tariff_exists(session: AsyncSession, tariff_id: int):
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id, Tariff.is_active.is_(True)))
tariff = result.scalar_one_or_none()
if tariff:
logger.info(f"[TARIFF] Тариф {tariff_id} найден в БД: {tariff.group_code}")
return True
logger.warning(f"[TARIFF] Тариф {tariff_id} не найден в БД")
return False
+7 -3
View File
@@ -28,13 +28,17 @@ def build_management_kb(admin_role: str) -> InlineKeyboardMarkup:
callback_data=AdminPanelCallback(action="restart").pack(),
)
builder.button(
text="🌐 Сменить домен",
text="🌐 Сменить домен подписок",
callback_data=AdminPanelCallback(action="change_domain").pack(),
)
builder.button(
text="🔑 Восстановить пробники",
callback_data=AdminPanelCallback(action="restore_trials").pack(),
)
builder.button(
text="📤 Загрузить файл",
callback_data=AdminPanelCallback(action="upload_file").pack(),
)
maintenance_text = "🛠️ Выключить тех. работы" if maintenance.maintenance_mode else "🛠️ Включить тех. работы"
builder.button(
text=maintenance_text,
@@ -54,11 +58,11 @@ def build_database_kb() -> InlineKeyboardMarkup:
callback_data=AdminPanelCallback(action="backups").pack(),
)
builder.button(
text="♻️ Восстановить БД",
text="♻️ Восстановить БД из бэкапа",
callback_data=AdminPanelCallback(action="restore_db").pack(),
)
builder.button(
text="📤 Получить данные БД",
text="📤 Получить данные БД из панели",
callback_data=AdminPanelCallback(action="export_db").pack(),
)
builder.row(build_admin_back_btn())
@@ -54,6 +54,10 @@ class Import3xuiStates(StatesGroup):
waiting_for_file = State()
class FileUploadState(StatesGroup):
waiting_for_file = State()
class DatabaseState(StatesGroup):
waiting_for_backup_file = State()
@@ -92,7 +96,6 @@ async def request_new_domain(callback_query: CallbackQuery, state: FSMContext):
async def process_new_domain(message: Message, state: FSMContext, session: AsyncSession):
"""Обновляет домен в таблице keys."""
new_domain = message.text.strip()
logger.info(f"[DomainChange] Новый домен, введённый администратором: '{new_domain}'")
if not new_domain or " " in new_domain or not new_domain.replace(".", "").isalnum():
logger.warning("[DomainChange] Некорректный домен")
@@ -103,7 +106,6 @@ async def process_new_domain(message: Message, state: FSMContext, session: Async
return
new_domain_url = f"https://{new_domain}"
logger.info(f"[DomainChange] Новый домен с протоколом: '{new_domain_url}'")
try:
stmt = (
@@ -326,8 +328,6 @@ async def restore_database(message: Message, state: FSMContext, bot: Bot):
if signature == b"PGDMP":
is_custom_dump = True
logger.info(f"[Restore] Определён формат: {'custom' if is_custom_dump else 'plain'}")
subprocess.run(
[
"sudo",
@@ -645,6 +645,46 @@ async def handle_resync_after_import(callback: CallbackQuery, session: AsyncSess
await callback.message.edit_text(
f"🔁 Перевыпуск завершён:\n✅ Успешно: <b>{success}</b>\n❌ Ошибки: <b>{failed}</b>",
parse_mode="HTML",
reply_markup=build_back_to_db_menu(),
)
@router.callback_query(AdminPanelCallback.filter(F.action == "upload_file"))
async def prompt_for_file_upload(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_text(
"📤 <b>Загрузка файла</b>\n\n"
"Вы можете заменить файл в корневой директории бота.\n\n"
"📁 <b>Отправьте файл с таким же именем и расширением</b>, "
"как у уже существующего файла. Он будет автоматически заменён.",
reply_markup=build_admin_back_kb("management"),
)
await state.set_state(FileUploadState.waiting_for_file)
@router.message(FileUploadState.waiting_for_file, F.document)
async def handle_admin_file_upload(message: Message, state: FSMContext):
document = message.document
file_name = document.file_name
if not file_name or "." not in file_name:
await message.answer("❌ У файла должно быть имя с расширением.")
return
dest_path = os.path.abspath(f"./{file_name}")
try:
await message.bot.download(document, destination=dest_path)
await message.answer(
f"✅ Файл <code>{file_name}</code> успешно загружен и заменён.\n\n"
"🔄 <b>Перезагрузите бота, чтобы изменения вступили в силу.</b>",
reply_markup=build_admin_back_kb("management"),
)
except Exception as e:
logger.error(f"[Upload File] Ошибка при загрузке файла {file_name}: {e}")
await message.answer(
f"❌ Не удалось сохранить файл: {e}",
reply_markup=build_admin_back_kb("management"),
)
await state.clear()
+3 -3
View File
@@ -6,7 +6,7 @@ from aiogram.types import CallbackQuery, Message
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from bot import version
from bot import get_version
from database.models import Admin
from filters.admin import IsAdminFilter
from logger import logger
@@ -19,7 +19,7 @@ router = Router()
@router.callback_query(AdminPanelCallback.filter(F.action == "admin"), IsAdminFilter())
async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
text = f"🤖 Панель администратора\n\nВерсия бота:\n<blockquote>{version}</blockquote>"
text = f"🤖 Панель администратора\n\nВерсия бота:\n<blockquote>{get_version()}</blockquote>"
await state.clear()
@@ -60,7 +60,7 @@ async def handle_admin_callback_query_simple(callback_query: CallbackQuery, stat
@router.message(Command("admin"), IsAdminFilter())
async def handle_admin_message(message: Message, state: FSMContext, session: AsyncSession):
text = f"🤖 Панель администратора\n\nВерсия бота:\n<blockquote>{version}</blockquote>"
text = f"🤖 Панель администратора\n\nВерсия бота:\n<blockquote>{get_version()}</blockquote>"
await state.clear()
-1
View File
@@ -98,7 +98,6 @@ async def process_callback_unfreeze_subscription_confirm(callback_query: Callbac
now_ms = int(time.time() * 1000)
leftover = record["expiry_time"]
logger.info(f"[Unfreeze Debug] expiry_time из БД: {leftover}")
if leftover < 0:
leftover = 0
new_expiry_time = now_ms + leftover
-1
View File
@@ -70,7 +70,6 @@ async def process_callback_view_profile(
username = "Пользователь"
image_path = os.path.join("img", "profile.jpg")
logger.info(f"Переход в профиль. Используется изображение: {image_path}")
key_count = await get_key_count(session, chat_id)
balance = await get_balance(session, chat_id) or 0
-2
View File
@@ -300,7 +300,6 @@ async def handle_utm_link(
async def show_start_menu(message: Message, admin: bool, session: AsyncSession):
"""Функция для отображения стандартного меню через редактирование сообщения."""
logger.info(f"Показываю главное меню для пользователя {message.chat.id}")
image_path = os.path.join("img", "pic.jpg")
builder = InlineKeyboardBuilder()
@@ -308,7 +307,6 @@ async def show_start_menu(message: Message, admin: bool, session: AsyncSession):
trial_status = None
if session is not None:
trial_status = await get_trial(session, message.chat.id)
logger.info(f"Trial status для {message.chat.id}: {trial_status}")
else:
logger.warning(f"Сессия базы данных отсутствует, пропускаем проверку триала для {message.chat.id}")