diff --git a/.env b/.env new file mode 100644 index 0000000..d47128e --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +BOT_TOKEN= Your token +WEBAPP_URL= diff --git a/README.md b/README.md index 09d0352..ed2e335 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,42 @@ # CollapseBot-Telegram -Our telegram bot + +A simple and efficient Telegram bot built with Python. + +## How to Create & Run + +### 1. Create a Bot via BotFather +1. Open Telegram and search for **@BotFather**. +2. Send the command `/newbot`. +3. Enter a name for your bot (e.g., `CollapseBot`). +4. Create a username for your bot (must end in `bot`, e.g., `collapse_test_bot`). +5. BotFather will send you an **HTTP API Token**. Copy it. + +### 2. Configure Settings +1. In BotFather, type `/mybots`. +2. Select your bot. +3. Go to **Bot Settings** > **Inline Mode** and turn it **ON** (if required). + +### 3. Setup Local Environment +1. Clone this repository. +2. Create a file named `.env` in the root directory. +3. Paste your API Token into the `.env` file: + ```env + BOT_TOKEN=your_copied_api_token_here + +### CLI Design main menu + +
+ +
+ +### CLI Design statistics + +
+ +
+ +### Design mini app + +
+ +
diff --git a/assets/.assets_photo b/assets/.assets_photo new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/assets/.assets_photo @@ -0,0 +1 @@ + diff --git a/assets/main.png b/assets/main.png new file mode 100644 index 0000000..0d0cff1 Binary files /dev/null and b/assets/main.png differ diff --git a/assets/miniapp.png b/assets/miniapp.png new file mode 100644 index 0000000..503ccb7 Binary files /dev/null and b/assets/miniapp.png differ diff --git a/assets/stat.png b/assets/stat.png new file mode 100644 index 0000000..42e8010 Binary files /dev/null and b/assets/stat.png differ diff --git a/config.py b/config.py index 0aadaba..627b525 100644 --- a/config.py +++ b/config.py @@ -4,6 +4,7 @@ load_dotenv() BOT_TOKEN = os.getenv("BOT_TOKEN") +ADMIN_ID = os.getenv("ADMIN_ID") if not BOT_TOKEN: raise ValueError( diff --git a/main.py b/main.py index 2ca2b8a..91553b7 100644 --- a/main.py +++ b/main.py @@ -1,68 +1,452 @@ import asyncio import logging -import yaml +import os +import subprocess +import json +import time +from aiohttp import web from aiogram import Bot, Dispatcher, types, F -from aiogram.types import InlineQueryResultArticle, InputTextMessageContent +from aiogram.types import InlineQueryResultArticle, InputTextMessageContent, InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.types.web_app_info import WebAppInfo from aiogram.filters import CommandStart -from config import BOT_TOKEN +from config import BOT_TOKEN, ADMIN_ID +from thefuzz import process, fuzz +from utils import ( + load_snippets, + safe_format, + get_cached_status, + get_cached_versions, + get_cached_clients, + get_msg, + add_subscriber, + remove_subscriber, + get_subscribers, + get_client, + get_client_info +) -logging.basicConfig(level=logging.INFO) - - -def load_snippets(): - with open("snippets.yaml", "r", encoding="utf-8") as f: - return yaml.safe_load(f) - - -snippets = load_snippets() +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) bot = Bot(token=BOT_TOKEN) dp = Dispatcher() +snippets = load_snippets() + +STATS_FILE = "stats.json" +def increment_stat(stat_name): + try: + stats = {} + if os.path.exists(STATS_FILE): + with open(STATS_FILE, "r", encoding="utf-8") as f: + stats = json.load(f) + + stats[stat_name] = stats.get(stat_name, 0) + 1 + + with open(STATS_FILE, "w", encoding="utf-8") as f: + json.dump(stats, f) + except Exception as e: + logger.error(f"Error updating stats: {e}") + +def get_webapp_url(): + try: + with open(".env", "r", encoding="utf-8") as f: + for line in f: + if line.startswith("WEBAPP_URL="): + return line.strip().split("=", 1)[1] + except Exception: + pass + return os.environ.get("WEBAPP_URL", "https://placeholder.loca.lt") @dp.message(CommandStart()) async def cmd_start(message: types.Message): + increment_stat("start_count") + bot_info = await bot.get_me() + lang = message.from_user.language_code + url = get_webapp_url() + markup = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🕹 Открыть Дашборд", web_app=WebAppInfo(url=url))] + ]) + await message.answer( + get_msg("start", lang, username=bot_info.username), + parse_mode="HTML", + reply_markup=markup + ) + +@dp.message(F.text == "/app") +async def cmd_app(message: types.Message): + url = get_webapp_url() + markup = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🕹 Открыть Дашборд", web_app=WebAppInfo(url=url))] + ]) + await message.answer( + "Collapse Dashboard:\n\nСмотрите статус, версии и список клиентов в удобном веб-интерфейсе!", + parse_mode="HTML", + reply_markup=markup + ) + +@dp.message(F.text == "/help") +async def cmd_help(message: types.Message): + bot_info = await bot.get_me() + lang = message.from_user.language_code + await message.answer( + get_msg("help", lang, username=bot_info.username), + parse_mode="HTML" + ) + +@dp.message(F.text == "/status") +async def cmd_status(message: types.Message): + lang = message.from_user.language_code + status_text = await get_cached_status(lang) + await message.answer( + f"{get_msg('status_title', lang)}\n\n{status_text}", + parse_mode="HTML" + ) + +@dp.message(F.text == "/version") +async def cmd_version(message: types.Message): + lang = message.from_user.language_code + v = await get_cached_versions() + tag_l, link_l, _ = v["latest"] + tag_p, link_p, _ = v["pre"] + + await message.answer( + f"{get_msg('version_title', lang)}\n\n" + f"{get_msg('stable', lang)} {tag_l} (GitHub)\n" + f"{get_msg('pre', lang)} {tag_p} (GitHub)", + parse_mode="HTML", + disable_web_page_preview=True + ) + +@dp.message(F.text == "/clients") +async def cmd_clients(message: types.Message): + lang = message.from_user.language_code + clients_text = await get_cached_clients(lang) + await message.answer( + f"{get_msg('clients_title', lang)}\n\n{clients_text}", + parse_mode="HTML" + ) + +@dp.message(F.text == "/changelog") +async def cmd_changelog(message: types.Message): + v = await get_cached_versions() + tag_l, link_l, body_l = v["latest"] + + if body_l: + safe_body = body_l[:3500].replace("<", "<").replace(">", ">") + ("..." if len(body_l) > 3500 else "") + else: + safe_body = "Нет описания." + await message.answer( - "Hello! I am an inline bot. Type @CollapseLoader_bot to see my snippets." + f"Changelog: {tag_l}\n\n
{safe_body}
\n\nGitHub", + parse_mode="HTML", + disable_web_page_preview=True ) +from aiogram.filters import Command +@dp.message(Command("client")) +async def cmd_client(message: types.Message): + parts = message.text.split(maxsplit=1) + if len(parts) < 2: + await message.answer("Укажите ID или название клиента. Пример: /client 47 или /client Vanilla", parse_mode="HTML") + return + + query = parts[1] + client_data = await get_client_info(query) + + if not client_data: + await message.answer(f"Клиент {query} не найден.", parse_mode="HTML") + return + + name = client_data.get("name", "Unknown") + version = client_data.get("version", "N/A") + c_id = client_data.get("id", "N/A") + file_name = client_data.get("filename", "N/A") + launches = client_data.get("launches", 0) + downloads = client_data.get("downloads", 0) + working = "Да" if client_data.get("working") else "Нет" + + text = ( + f"Клиент: {name}\n" + f"Версия: {version}\n" + f"ID: {c_id}\n" + f"Файл: {file_name}\n" + f"Работает: {working}\n\n" + f"Запусков: {launches}\n" + f"Скачиваний: {downloads}" + ) + await message.answer(text, parse_mode="HTML") + +@dp.message(F.text == "/subscribe") +async def cmd_subscribe(message: types.Message): + add_subscriber(message.from_user.id) + await message.answer(get_msg("sub_ok", message.from_user.language_code)) + +@dp.message(F.text == "/unsubscribe") +async def cmd_unsubscribe(message: types.Message): + remove_subscriber(message.from_user.id) + await message.answer(get_msg("unsub_ok", message.from_user.language_code)) @dp.inline_query() async def inline_query_handler(query: types.InlineQuery): + increment_stat("snippet_searches") query_text = query.query.lower().strip() + lang = query.from_user.language_code results = [] - for key, data in snippets.items(): - title = data.get("title", key) - content = data.get("content", "") + status_val = await get_cached_status(lang) + v = await get_cached_versions() + clients_val = await get_cached_clients(lang) + tag_l, link_l, _ = v["latest"] + tag_p, link_p, _ = v["pre"] - if ( - query_text in key.lower() - or query_text in title.lower() - or query_text in content.lower() - ): + all_ok = all("Online" in s for s in status_val.split("\n") if s) + status_summary = get_msg("online" if all_ok else "error", lang) - description = content.split("\n")[0] if content else "No content" - if len(description) > 50: - description = description[:47] + "..." + dynamic_items = [ + { + "id": "dynamic_status", + "title": f"{get_msg('status_title', lang).replace('', '').replace('', '')} {status_summary}", + "description": "Atlas, Auth, API", + "msg": f"{get_msg('status_title', lang)}\n\n{status_val}", + "keywords": ["status", "статус", "сервер", "server", "атлас", "atlas", "auth", "api"] + }, + { + "id": "dynamic_versions", + "title": f"{tag_l} | {tag_p}", + "description": get_msg('version_title', lang).replace('', '').replace('', ''), + "msg": ( + f"{get_msg('version_title', lang)}\n\n" + f"{get_msg('stable', lang)} {tag_l} (GitHub)\n" + f"{get_msg('pre', lang)} {tag_p} (GitHub)" + ), + "keywords": ["version", "версия", "update", "обнова", "pre", "пре", "релиз"] + }, + { + "id": "dynamic_clients", + "title": f"{get_msg('clients_title', lang).replace('', '').replace('', '')}", + "description": "Список клиентов", + "msg": f"{get_msg('clients_title', lang)}\n\n{clients_val}", + "keywords": ["clients", "клиенты", "список"] + } + ] + if not query_text: + for item in dynamic_items: results.append( InlineQueryResultArticle( - id=key, - title=title, - description=description, + id=item["id"], + title=item["title"], + description=item["description"], input_message_content=InputTextMessageContent( - message_text=content, parse_mode="Markdown" - ), + message_text=item["msg"], parse_mode="HTML", disable_web_page_preview=True + ) ) ) + else: + for item in dynamic_items: + if any(k in query_text for k in item["keywords"]): + results.append( + InlineQueryResultArticle( + id=item["id"], + title=item["title"], + description=item["description"], + input_message_content=InputTextMessageContent( + message_text=item["msg"], parse_mode="HTML", disable_web_page_preview=True + ) + ) + ) + + choices = [] + for key, data in snippets.items(): + if key in ["status", "version", "clients"]: + continue + search_text = f"{key} {data.get('title', '')} {data.get('content', '')}".lower() + choices.append((key, search_text)) + + if query_text: + matches = process.extract( + query_text, + {k: s for k, s in choices}, + limit=15, + scorer=fuzz.partial_token_set_ratio + ) + matched_keys = [m[2] for m in matches if m[1] > 40] + else: + matched_keys = [k for k in snippets.keys() if k not in ["status", "version", "clients"]] + + for key in matched_keys: + data = snippets.get(key) + if not data: continue + + title = data.get("title", key) + content = data.get("content", "") + description = content.split("\n")[0] if content else "No content" + + if len(description) > 50: + description = description[:47] + "..." + + formatted_content = safe_format(content) + + results.append( + InlineQueryResultArticle( + id=key, + title=title, + description=description, + input_message_content=InputTextMessageContent( + message_text=formatted_content, parse_mode="HTML" + ), + ) + ) + + try: + await query.answer(results[:50], cache_time=5) + except Exception as e: + logger.debug(f"Could not answer inline query: {e}") + +async def check_updates_task(): + last_tag = None + while True: + try: + v = await get_cached_versions() + tag = v["latest"][0] + url = v["latest"][1] + + if last_tag is None: + last_tag = tag + + if tag != last_tag and tag != "N/A": + last_tag = tag + logger.info(f"New version detected: {tag}") + subs = get_subscribers() + for user_id in subs: + try: + await bot.send_message( + user_id, + get_msg("new_update", "ru", tag=tag, url=url), + parse_mode="HTML" + ) + except Exception as e: + logger.error(f"Failed to notify {user_id}: {e}") + except Exception as e: + logger.error(f"Error in check_updates_task: {e}") + + await asyncio.sleep(1800) + +async def server_monitor_task(): + last_status = "online" + offline_start_time = 0 + + while True: + try: + client = await get_client() + url = "https://huggingface.co/datasets/Collapsecdn/collapsecdn" + + try: + resp = await client.get(url, timeout=10.0) + is_online = resp.status_code < 400 + except Exception: + is_online = False + + current_status = "online" if is_online else "offline" + + if current_status == "offline" and last_status == "online": + offline_start_time = time.time() + last_status = "offline" + logger.warning("CDN/API Server went offline! Notifying subscribers.") + subs = get_subscribers() + for user_id in subs: + try: + await bot.send_message( + chat_id=user_id, + text="CRITICAL: Сервер CDN/API временно недоступен!\n\nСлужба не отвечает на запросы, возможны перебои в работе лоадера.", + parse_mode="HTML" + ) + except Exception as e: + logger.error(f"Failed to notify {user_id}: {e}") + + elif current_status == "online" and last_status == "offline": + downtime = int((time.time() - offline_start_time) / 60) + last_status = "online" + logger.info("CDN/API Server is back online! Notifying subscribers.") + subs = get_subscribers() + for user_id in subs: + try: + await bot.send_message( + chat_id=user_id, + text=f"Сервер восстановлен!\n\nСистемы CDN/API снова работают стабильно. Примерное время простоя: {downtime} мин.", + parse_mode="HTML" + ) + except Exception as e: + logger.error(f"Failed to notify {user_id}: {e}") + + except Exception as e: + logger.error(f"Error in server_monitor_task: {e}") + + await asyncio.sleep(60) - await query.answer(results[:50], cache_time=1) +async def api_status(request): + status_text = await get_cached_status("ru") + return web.json_response({"status": status_text}) +async def api_versions(request): + v = await get_cached_versions() + return web.json_response(v) + +async def api_clients(request): + clients_text = await get_cached_clients("ru") + return web.json_response({"clients": clients_text}) + +async def handle_index(request): + return web.FileResponse('webapp/index.html') + +async def start_webapp_server(): + app = web.Application() + app.router.add_get('/api/status', api_status) + app.router.add_get('/api/versions', api_versions) + app.router.add_get('/api/clients', api_clients) + + if os.path.exists("webapp"): + app.router.add_get('/', handle_index) + app.router.add_static('/', 'webapp') + logger.info("Serving static WebApp files from 'webapp' directory") + + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, '127.0.0.1', 8085) + await site.start() + logger.info("WebApp server started locally on http://127.0.0.1:8085") async def main(): - await dp.start_polling(bot) + asyncio.create_task(start_webapp_server()) + bot_info = await bot.get_me() + logger.info(f"Starting bot @{bot_info.username}") + asyncio.create_task(get_cached_status("ru")) + asyncio.create_task(get_cached_status("en")) + asyncio.create_task(get_cached_clients("ru")) + asyncio.create_task(get_cached_clients("en")) + asyncio.create_task(get_cached_versions()) + asyncio.create_task(check_updates_task()) + asyncio.create_task(server_monitor_task()) + await dp.start_polling(bot) if __name__ == "__main__": - asyncio.run(main()) + import sys + + if "--worker" in sys.argv or os.name != 'nt' or os.environ.get("DOCKER_ENV"): + try: + if os.name == 'nt': + os.system(f"title CollapseBot Logs") + + asyncio.run(main()) + except (KeyboardInterrupt, SystemExit): + logger.info("Bot stopped!") + else: + try: + subprocess.Popen( + [sys.executable, "manager.py"], + creationflags=subprocess.CREATE_NEW_CONSOLE + ) + except Exception as e: + asyncio.run(main()) diff --git a/manager.py b/manager.py new file mode 100644 index 0000000..bbedbe0 --- /dev/null +++ b/manager.py @@ -0,0 +1,356 @@ +import subprocess +import sys +import os +import time +import ctypes +import ctypes.wintypes +import threading +from collections import deque +import json +import atexit +import signal + +try: + from rich.console import Console + from rich.panel import Panel + from rich.text import Text + from rich.table import Table + from rich import box + from rich.align import Align + from rich.style import Style + from rich.markup import escape +except ImportError: + print("Installing required UI packages...") + subprocess.call([sys.executable, "-m", "pip", "install", "rich"]) + from rich.console import Console + from rich.panel import Panel + from rich.text import Text + from rich.table import Table + from rich import box + from rich.align import Align + from rich.style import Style + from rich.markup import escape + +console = Console() +log_buffer = deque(maxlen=20) +full_logs = deque(maxlen=500) +log_counter = 0 +stats = {"start_time": time.time(), "engines_spawned": 0, "errors": 0} + +def log_reader(pipe): + global log_counter + try: + for line in iter(pipe.readline, ''): + if not line: break + decoded = line.strip() + log_buffer.append(decoded) + full_logs.append(decoded) + log_counter += 1 + except Exception: + pass + pipe.close() + +def clear_screen(): + os.system('cls' if os.name == 'nt' else 'clear') + +_current_bot_process = None +_current_tunnel_process = None +_tunnel_url = "Not Started" + +def update_env_url(url): + try: + if os.path.exists(".env"): + with open(".env", "r", encoding="utf-8") as f: + content = f.read() + import re + new_content = re.sub(r'(?m)^WEBAPP_URL=.*$', '', content).strip() + new_content += f'\nWEBAPP_URL={url}\n' + with open(".env", "w", encoding="utf-8") as f: + f.write(new_content) + else: + with open(".env", "w", encoding="utf-8") as f: + f.write(f'WEBAPP_URL={url}\n') + except Exception as e: + full_logs.append(f"Failed to update .env: {e}") + +def tunnel_reader(pipe): + global log_counter, _tunnel_url + import re + try: + for line in iter(pipe.readline, ''): + if not line: break + decoded = line.strip() + full_logs.append("[TUNNEL] " + decoded) + log_counter += 1 + + match = re.search(r'(https://[a-zA-Z0-9-]+\.lhr\.life|https://[a-zA-Z0-9-]+\.serveo\.net|https://[a-zA-Z0-9-]+\.serveousercontent\.com)', decoded) + if match: + _tunnel_url = match.group(1) + update_env_url(_tunnel_url) + except Exception: + pass + pipe.close() + +def start_tunnel(): + global _current_tunnel_process, _tunnel_url + if _current_tunnel_process and _current_tunnel_process.poll() is None: + try: + _current_tunnel_process.terminate() + except: + pass + + _tunnel_url = "Connecting..." + cmd = ["ssh", "-o", "StrictHostKeyChecking=no", "-R", "80:127.0.0.1:8085", "nokey@localhost.run"] + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + stdin=subprocess.DEVNULL, + creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0x08000000) if os.name == 'nt' else 0 + ) + _current_tunnel_process = process + threading.Thread(target=tunnel_reader, args=(process.stdout,), daemon=True).start() + +def cleanup(): + global _current_bot_process, _current_tunnel_process + if _current_bot_process and _current_bot_process.poll() is None: + try: + _current_bot_process.terminate() + _current_bot_process.wait(timeout=2) + except Exception: + try: + _current_bot_process.kill() + except Exception: + pass + + if _current_tunnel_process and _current_tunnel_process.poll() is None: + try: + _current_tunnel_process.terminate() + _current_tunnel_process.wait(timeout=2) + except: + try: + _current_tunnel_process.kill() + except: + pass + +atexit.register(cleanup) + +def signal_handler(sig, frame): + cleanup() + sys.exit(0) + +signal.signal(signal.SIGINT, signal_handler) +signal.signal(signal.SIGTERM, signal_handler) + +def start_bot(): + global _current_bot_process + process = subprocess.Popen( + [sys.executable, "main.py", "--worker"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1 + ) + _current_bot_process = process + threading.Thread(target=log_reader, args=(process.stdout,), daemon=True).start() + stats["engines_spawned"] += 1 + return process + +def draw_interface(bot_process): + is_running = bot_process.poll() is None + status_color = "#00FF66" if is_running else "#FF3B30" + status_text = "● ONLINE" if is_running else "○ OFFLINE" + + # Elegant minimalist header + header = Text("\n✦ COLLAPSE CONTROL PANEL ✦\n", style="bold #00E5FF", justify="center") + + # Sleek status table with simple lines + status_table = Table( + box=box.SIMPLE, + show_header=False, + expand=True, + border_style="#333333", + padding=(0, 1) + ) + status_table.add_column("Key", style="dim #A0A0A0", justify="right", width=20) + status_table.add_column("Value", style="white", justify="left") + + status_table.add_row("Bot Status", f"[{status_color}]{status_text}[/]") + status_table.add_row("WebApp Tunnel", f"[bold #00FF66]{escape(_tunnel_url)}[/]") + status_table.add_row("Python", f"[dim #808080]{escape(sys.version.split()[0])}[/]") + status_table.add_row("Directory", f"[dim #808080]{escape(os.path.basename(os.getcwd()))}[/]") + + # Minimalist control layout + controls = Table(box=box.SIMPLE, expand=True, show_header=False, border_style="#333333", padding=(0, 2)) + controls.add_column("Col1", justify="center") + controls.add_column("Col2", justify="center") + + controls.add_row( + "[bold #00FF66]1[/] Start Bot", + "[bold #9F7AEA]4[/] Live Logs" + ) + controls.add_row( + "[bold #FF3B30]2[/] Stop Bot", + "[bold #FFA500]5[/] Statistics" + ) + controls.add_row( + "[bold #FFCC00]3[/] Restart", + "[bold #E2E8F0]6[/] Exit" + ) + + # Combining into a single minimalist panel grid + grid = Table.grid(expand=True) + grid.add_row(header) + grid.add_row(Panel(status_table, border_style="#444444", title="[bold #A0A0A0] Status [/]", title_align="left")) + grid.add_row(Panel(controls, border_style="#444444", title="[bold #A0A0A0] Controls [/]", title_align="left")) + + return Panel( + grid, + border_style="#00E5FF", + box=box.ROUNDED, + padding=(0, 2) + ) + +def get_bot_stats(): + try: + if os.path.exists("stats.json"): + with open("stats.json", "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + pass + return {"start_count": 0, "snippet_searches": 0} + +def main_manager(): + bot_process = start_bot() + start_tunnel() + + import msvcrt + global _tunnel_url + last_tunnel_url = _tunnel_url + + try: + while True: + last_bot_status = bot_process.poll() is None + + clear_screen() + console.print(draw_interface(bot_process)) + console.print(" [bold #00E5FF]❯ Select Operation (1-6):[/] ", end="") + sys.stdout.flush() + + choice = None + while True: + if msvcrt.kbhit(): + char = msvcrt.getch().decode('utf-8', errors='ignore') + if char in ['1', '2', '3', '4', '5', '6']: + choice = char + print(choice) + time.sleep(0.2) + break + + current_running = bot_process.poll() is None + if _tunnel_url != last_tunnel_url or current_running != last_bot_status: + last_tunnel_url = _tunnel_url + break + + time.sleep(0.1) + + if choice is None: + continue + + if choice == '1': + if bot_process.poll() is not None: + with console.status("[bold #00FF7F]Starting Bot...[/]"): + bot_process = start_bot() + time.sleep(1.5) + else: + console.print(" [bold #FFD700]Bot is already active![/]") + time.sleep(1) + + elif choice == '2': + if bot_process.poll() is None: + with console.status("[bold #FF3366]Stopping Bot...[/]"): + bot_process.terminate() + time.sleep(1.5) + else: + console.print(" [bold #FFD700]Bot is already halted![/]") + time.sleep(1) + + elif choice == '3': + with console.status("[bold #FFD700]Restarting Bot...[/]"): + if bot_process.poll() is None: + bot_process.terminate() + bot_process.wait() + bot_process = start_bot() + start_tunnel() + time.sleep(1.5) + + elif choice == '4': + clear_screen() + console.print(Panel("[bold #9370DB]LIVE BOT LOGS[/]\n[dim]Press ANY KEY to return to the main menu...[/]", border_style="#9370DB")) + + import msvcrt + snapshot = list(full_logs) + for line in snapshot[-30:]: + console.print(line) + + last_seen_total = log_counter + + # Очищаем буфер от случайных нажатий (например Enter) + while msvcrt.kbhit(): + msvcrt.getch() + + while True: + if msvcrt.kbhit(): + msvcrt.getch() + break + + if log_counter > last_seen_total: + new_lines_count = log_counter - last_seen_total + actual_to_print = min(new_lines_count, len(full_logs)) + current_all_logs = list(full_logs) + for i in range(len(current_all_logs) - actual_to_print, len(current_all_logs)): + console.print(current_all_logs[i]) + last_seen_total = log_counter + + time.sleep(0.1) + + elif choice == '5': + clear_screen() + uptime = time.strftime("%Hh %Mm %Ss", time.gmtime(time.time() - stats["start_time"])) + bot_stats = get_bot_stats() + + stats_table = Table(box=box.ROUNDED, show_header=False, border_style="#FFA500") + stats_table.add_row("Session Uptime :", uptime) + stats_table.add_row("Bot Restarts :", str(stats["engines_spawned"])) + stats_table.add_row("Bot Starts (/start) :", str(bot_stats.get("start_count", 0))) + stats_table.add_row("Snippets Searched :", str(bot_stats.get("snippet_searches", 0))) + stats_table.add_row("Bot Health :", "[bold #00FF7F]OK[/]") + + stats_panel = Panel( + Align.center(stats_table), + title="[bold #FFA500] STATISTICS [/]", + border_style="#FFA500", + padding=(1, 4) + ) + console.print(stats_panel) + console.print("\n [dim]Press ANY KEY to return...[/]") + import msvcrt + while msvcrt.kbhit(): msvcrt.getch() + while not msvcrt.kbhit(): time.sleep(0.05) + msvcrt.getch() + + elif choice == '6': + cleanup() + console.print("\n [bold #FF00FF]Session Terminated. Goodbye.[/]") + break + finally: + cleanup() + +if __name__ == "__main__": + try: + main_manager() + except (KeyboardInterrupt, SystemExit): + cleanup() + sys.exit(0) diff --git a/requirements.txt b/requirements.txt index a1bb535..47310eb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,8 @@ -aiogram===3.22.0 +aiogram==3.25.0 python-dotenv==1.2.1 requests==2.32.5 httpx==0.28.1 -configparser===7.2.0 -PyYAML==6.0.3 \ No newline at end of file +configparser==7.2.0 +PyYAML==6.0.3 +thefuzz==0.22.1 +rich==13.7.0 \ No newline at end of file diff --git a/snippets.yaml b/snippets.yaml index a4e4b8d..b310ff5 100644 --- a/snippets.yaml +++ b/snippets.yaml @@ -4,6 +4,7 @@ data-clear: **Шаги для очистки данных CollapseLoader:** 1. **Закройте лоадер полностью** + 2. **Перейдите в папку:** - Windows: `%appdata%\CollapseLoader\` @@ -21,10 +22,18 @@ crash-logs: 1. **Нажмите правой кнопкой мыши на клиент** - Нажмите "Копировать логи" + 2. **Вставьте логи в текстовый редактор** + - Например, в Блокнот + + 3. **Сохраните файл** + + 4. **Отправьте файл чтоб получить помощь** + - Это поможет нам быстрее разобраться в проблеме + installation: title: '📥 Установка CollapseLoader' content: | - **Пошаговая установка:** + **Пошаговая установка collapseloader:** 1. **Скачайте лоадер:** - Официальный сайт: https://collapseloader.org @@ -45,7 +54,7 @@ social-link: - Telegram: https://t.me/collapseloader - Telegram DevLogs: https://t.me/collapseloader_devlogs - Discord: https://collapseloader.org/discord - - Github: github.com/dest4590/collapseloader + - Github: https://github.com/dest4590/collapseloader technical-support: title: '🔧 Техническая поддержка' @@ -62,12 +71,28 @@ technical-support: 3. Проверить, не удалил ли антивирус файлы клиента **💬 Где получить помощь:** - - <#1231330786481930347> - создание тикетов - - Поиск по серверу (частые вопросы) + - Откройте тикет в нашем Discord сервере где вам помогут с проблемой + - Так же вы можете обратиться в наш Telegram чат вам помогут с проблемой **⚡ Быстрая помощь:** - - Приложите скриншоты - - Укажите что уже пробовали + - Приложите файлы логов чтобы админи могли быстрее разобраться в проблеме + - Укажите что уже пробовал сделать для решения проблемы + +bug-report: + title: ' 🪲 Как сообщить о баге' + content: | + **Как сообщить о баге:** + + 1. **Соберите информацию:** + - Версия CollapseLoader + - Подробное описание проблемы + - Приложите скриншоты или видео, если это возможно + + 2. **Создайте публикацию в нашем дискорде в разделе bug-tracking** + - Укажите всю собранную информацию + + **⚡ Быстрая помощь:** + - Чем больше информации вы предоставите, тем быстрее мы сможем разобраться в проблеме clear-loader-files: title: ' 🧹 Очистка файлов лоадера' @@ -78,39 +103,29 @@ clear-loader-files: 2. Нажмите на кнопку "Сбросить файлы лоадера" 3. После сообщения об успешной очистке, попробуйте снова запустить клиент -not-loading: - title: '❗ Лоадер/Клиенты не загружается' +not-loading-method-1: + title: '❗ Метод 1 Лоадер/Клиенты не загружается' content: | **Если лоадер не запускается (зависает в экране загрузки, пишет что сервера офлайн)** 1. Попробуйте включить DPI Bypass в настройках лоадера, после этого перезапустите лоадер **от администратора** - 2. Проверьте, не блокирует ли ваш провайдер доступ к серверам лоадера: https://auth.collapseloader.org и https://cdn.collapseloader.org (Если у вас 0.2.7 НО если у вас новее версия используйте метод 2) + 2. Проверьте, не блокирует ли ваш провайдер доступ к серверам лоадера: https://atlas.collapseloader.org 3. Попробуйте подключиться через VPN -not-loading-new: +not-loading-method-2: title: '❗ Метод 2 Лоадер/Клиенты не загружаеться' content: | **Если лоадер не запускается (зависает в экране загрузки, пишет что сервера офлайн)** - 1. Добавьте в запрет-лист эти адреса: https://auth.collapseloader.org и https://cdn.collapseloader.org и https://api.collapseloader.org и https://atlas.collapseloader.org + 1. Добавьте в запрет-лист этот адреса: https://atlas.collapseloader.org 2. Если вам не помогло, попробуйте подключиться через VPN -client-download-stuck: - title: '⏳ Загрузка клиента зависла' - content: | - **Если загрузка клиента зависла на 0 процентов:** - - 1. Откройте настройки лоадера - 2. Включите опцию DPI Bypass - 3. Перезапустите лоадер от имени администратора - 4. Попробуйте снова скачать клиент - -Ti-ceryezno?: - title: 'Если пишет "Ти серьезно?" при попытке зайти в лоадер' +Tu-ceryezno?: + title: ' 😔 Если пишет "Ты серьезно?" при попытке зайти в лоадер' content: | - Если пишет "Ти серьезно?" при попытке зайти в лоадер + Если пишет "Ты серьезно?" при попытке зайти в лоадер **Советую вот что сделать:** 1. Закрой лоадер 2. Удали лоадер - 3. Проверь сколько у тебя айкю + 3. Проверь сколько у тебя айкю \ No newline at end of file diff --git a/stats.json b/stats.json new file mode 100644 index 0000000..78fde77 --- /dev/null +++ b/stats.json @@ -0,0 +1 @@ +{"start_count": 8} \ No newline at end of file diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..4413673 --- /dev/null +++ b/utils.py @@ -0,0 +1,292 @@ +import httpx +import time +import re +import html +import yaml +import os +import logging + +logger = logging.getLogger(__name__) + +cache = { + "status": {"data": None, "time": 0}, + "version": {"data": None, "time": 0}, + "clients": {"data": None, "time": 0}, + "clients_raw": {"data": {}, "time": 0} +} + +def load_snippets(): + if not os.path.exists("snippets.yaml"): + logger.error("snippets.yaml not found!") + return {} + with open("snippets.yaml", "r", encoding="utf-8") as f: + try: + return yaml.safe_load(f) or {} + except yaml.YAMLError as e: + logger.error(f"Error parsing snippets.yaml: {e}") + return {} + +def safe_format(text): + text = html.escape(text) + text = re.sub(r'\*\*(.*?)\*\*', r'\1', text) + text = re.sub(r'\*(.*?)\*', r'\1', text) + return text + +import asyncio +import json + +TRANSLATIONS = { + "ru": { + "start": "CollapseBot \n\nВведите @{username} в любом чате для поиска сниппетов.\n\nКоманды:\n/status - Состояние серверов\n/version - Версии лоадера\n/clients - Доступные клиенты\n/client <id> - Подробно о клиенте\n/changelog - Что нового\n/help - Справка", + "help": "Справка по CollapseBot:\n\nОсновные команды:\n/status - Текущее состояние серверов Atlas\n/version - Версии лоадера (stable / pre-release)\n/clients - Выборка доступных клиентов (Vanilla, Fabric, Forge)\n\nЛоадер и Клиенты:\n/changelog - Посмотреть, что нового в свежей версии лоадера\n/client <id/название> - Детальная статистика по клиенту (запуски, статус)\n\nУведомления:\n/subscribe - Получать пуши о новых обновлениях и статусе серверов\n/unsubscribe - Отписаться от рассылки\n\nПоиск параметров (Инлайн):\nНапишите @{username} запрос в любом чате, чтобы найти руководство или сниппет лоадера.", + "status_title": "Статус серверов Collapse:", + "version_title": "Версии CollapseLoader:", + "stable": "Стабильная:", + "pre": "Пре-релиз:", + "sub_ok": "Вы подписались на уведомления об обновлениях!", + "unsub_ok": "Вы отписались от уведомлений.", + "new_update": "Вышло обновление!\n\nВерсия: {tag}\nСсылка: GitHub", + "online": "Онлайн", + "error": "Ошибки", + "clients_title": "Доступные клиенты:", + "clients_empty": "Нет доступных клиентов." + }, + "en": { + "start": "CollapseBot \n\nType @{username} in any chat to search snippets.\n\nCommands:\n/status - Server status\n/version - Loader versions\n/clients - Available clients\n/client <id> - Client details\n/changelog - What's new\n/help - Help message", + "help": "CollapseBot Help:\n\nCommands:\n/status - Check Atlas server status\n/version - View loader versions\n/clients - View clients list \n\nLoader & Clients:\n/changelog - Check what's new in the latest loader update\n/client <id/name> - View detailed info about a specific client (launches, status)\n\nNotifications:\n/subscribe - Get push notifications for updates & downtime\n/unsubscribe - Opt out of notifications\n\nInline Search:\nType @{username} [query] in any chat to search loader snippets.", + "status_title": "Collapse Server Status:", + "version_title": "CollapseLoader Versions:", + "stable": "Stable:", + "pre": "Pre-release:", + "sub_ok": "You have subscribed to update notifications!", + "unsub_ok": "You have unsubscribed from notifications.", + "new_update": "New update available!\n\nVersion: {tag}\nLink: GitHub", + "online": "Online", + "error": "Error", + "clients_title": "Available clients:", + "clients_empty": "No clients available." + } +} + +def get_msg(key, lang="ru", **kwargs): + lang = lang if lang in TRANSLATIONS else "ru" + return TRANSLATIONS[lang].get(key, key).format(**kwargs) + +_client = None + +async def get_client(): + global _client + if _client is None or _client.is_closed: + _client = httpx.AsyncClient( + timeout=5.0, + follow_redirects=True, + headers={"User-Agent": "CollapseBot/2.5"} + ) + return _client + +_cache_locks = { + "status": asyncio.Lock(), + "version": asyncio.Lock(), + "clients": asyncio.Lock(), + "refresh_status": asyncio.Lock(), + "refresh_version": asyncio.Lock(), + "refresh_clients": asyncio.Lock() +} + +async def get_cached_status(lang="ru"): + now = time.time() + cache_key = f"status_{lang}" + + if cache_key in cache and now - cache[cache_key]["time"] < 60: + return cache[cache_key]["data"] + + if cache_key in cache and cache[cache_key]["data"]: + # Background refresh if not already refreshing + if not _cache_locks["refresh_status"].locked(): + asyncio.create_task(refresh_status_cache(lang)) + return cache[cache_key]["data"] + + async with _cache_locks["status"]: + if cache_key in cache and cache[cache_key]["data"]: + return cache[cache_key]["data"] + return await refresh_status_cache(lang) + +async def refresh_status_cache(lang="ru"): + if _cache_locks["refresh_status"].locked() and not _cache_locks["status"].locked(): + # Avoid redundant concurrent refreshes + return cache.get(f"status_{lang}", {}).get("data", "Initializing...") + + async with _cache_locks["refresh_status"]: + now = time.time() + cache_key = f"status_{lang}" + + services = { + "CDN/API": "https://huggingface.co/datasets/Collapsecdn/collapsecdn" + } + + async def check_service(name, url, client): + start = time.perf_counter() + try: + resp = await client.get(url) + elapsed = int((time.perf_counter() - start) * 1000) + if resp.status_code < 400: + return f"{name}: Online ({elapsed}ms)" + else: + return f"{name}: Error {resp.status_code} ({elapsed}ms)" + except Exception: + return f"{name}: Offline" + + try: + client = await get_client() + tasks = [check_service(name, url, client) for name, url in services.items()] + results = await asyncio.gather(*tasks) + + status = "\n".join(results) + cache[cache_key] = {"data": status, "time": now} + return status + except Exception as e: + logger.error(f"Error refreshing status cache: {e}") + return cache.get(cache_key, {}).get("data", "Error fetching status") + +async def get_cached_versions(): + now = time.time() + if cache["version"]["data"]: + if now - cache["version"]["time"] > 300: + if not _cache_locks["refresh_version"].locked(): + asyncio.create_task(refresh_version_cache()) + return cache["version"]["data"] + + async with _cache_locks["version"]: + if cache["version"]["data"]: + return cache["version"]["data"] + return await refresh_version_cache() + +async def refresh_version_cache(): + if _cache_locks["refresh_version"].locked() and not _cache_locks["version"].locked(): + return cache["version"]["data"] + + async with _cache_locks["refresh_version"]: + now = time.time() + try: + url_latest = "https://api.github.com/repos/dest4590/collapseloader/releases/latest" + url_all = "https://api.github.com/repos/dest4590/collapseloader/releases" + + client = await get_client() + resp_l = await client.get(url_latest) + data_l = resp_l.json() if resp_l.status_code == 200 else {} + + resp_all = await client.get(url_all) + releases = resp_all.json() if resp_all.status_code == 200 else [] + pre = next((r for r in releases if r.get("prerelease")), None) + + result = { + "latest": (data_l.get("tag_name", "N/A"), data_l.get("html_url", ""), data_l.get("body", "Нет данных")), + "pre": (pre.get("tag_name", "N/A"), pre.get("html_url", ""), pre.get("body", "Нет данных")) if pre else ("N/A", "", "Нет данных") + } + cache["version"] = {"data": result, "time": now} + return result + except Exception as e: + logger.error(f"Error refreshing version cache: {e}") + return cache["version"]["data"] or {"latest": ("N/A", ""), "pre": ("N/A", "")} + +async def get_cached_clients(lang="ru"): + now = time.time() + cache_key = f"clients_{lang}" + + if cache_key in cache and cache[cache_key].get("data"): + if now - cache[cache_key]["time"] > 300: + if not _cache_locks["refresh_clients"].locked(): + asyncio.create_task(refresh_clients_cache(lang)) + return cache[cache_key]["data"] + + async with _cache_locks["clients"]: + if cache_key in cache and cache[cache_key].get("data"): + return cache[cache_key]["data"] + return await refresh_clients_cache(lang) + +async def refresh_clients_cache(lang="ru"): + if _cache_locks["refresh_clients"].locked() and not _cache_locks["clients"].locked(): + return cache.get(f"clients_{lang}", {}).get("data", get_msg("clients_empty", lang)) + + async with _cache_locks["refresh_clients"]: + now = time.time() + cache_key = f"clients_{lang}" + try: + urls = { + "Vanilla / Custom": "https://huggingface.co/datasets/Collapsecdn/collapsecdn/raw/main/static/clients.json", + "Fabric": "https://huggingface.co/datasets/Collapsecdn/collapsecdn/raw/main/static/fabric-clients.json", + "Forge": "https://huggingface.co/datasets/Collapsecdn/collapsecdn/raw/main/static/forge-clients.json" + } + client = await get_client() + + lines = [] + raw_clients_map = {} + for category, url in urls.items(): + resp = await client.get(url) + if resp.status_code == 200: + data = resp.json() + if isinstance(data, list): + clients_list = data + elif isinstance(data, dict): + clients_list = data.get("data", []) + else: + clients_list = [] + + if clients_list: + lines.append(f"\n{category}:") + for c in clients_list: + if not c.get("show", True): + continue + name = c.get("name", "Unknown") + version = c.get("version", "N/A") + client_id = c.get("id", "N/A") + lines.append(f"{name} (v{version}) - ID: {client_id}") + raw_clients_map[str(client_id)] = c + raw_clients_map[name.lower()] = c + else: + lines.append(f"\n{category}: Error {resp.status_code}") + + if lines: + result = "\n".join(lines).strip() + else: + result = get_msg("clients_empty", lang) + + cache[cache_key] = {"data": result, "time": now} + cache["clients_raw"] = {"data": raw_clients_map, "time": now} + return result + except Exception as e: + logger.error(f"Error refreshing clients cache: {e}") + return cache.get(cache_key, {}).get("data", get_msg("clients_empty", lang)) + + +SUBS_FILE = "subscribers.json" + +async def get_client_info(query): + now = time.time() + if not cache["clients_raw"]["data"] or now - cache["clients_raw"]["time"] > 300: + await refresh_clients_cache("ru") + + raw_data = cache["clients_raw"]["data"] + query = str(query).lower() + return raw_data.get(query) + +def get_subscribers(): + if not os.path.exists(SUBS_FILE): + return [] + with open(SUBS_FILE, "r") as f: + try: + return json.load(f) + except: + return [] + +def add_subscriber(user_id): + subs = set(get_subscribers()) + subs.add(user_id) + with open(SUBS_FILE, "w") as f: + json.dump(list(subs), f) + +def remove_subscriber(user_id): + subs = set(get_subscribers()) + subs.discard(user_id) + with open(SUBS_FILE, "w") as f: + json.dump(list(subs), f) diff --git a/webapp/app.js b/webapp/app.js new file mode 100644 index 0000000..ea128bb --- /dev/null +++ b/webapp/app.js @@ -0,0 +1,183 @@ +const tg = window.Telegram.WebApp; +const isTg = !!tg.initDataUnsafe?.user; + +document.addEventListener('DOMContentLoaded', () => { + tg.ready(); + tg.expand(); + tg.setHeaderColor('secondary_bg_color'); + tg.setBackgroundColor('bg_color'); + + const userNameEl = document.getElementById('user-name'); + if (isTg && tg.initDataUnsafe.user) { + userNameEl.textContent = tg.initDataUnsafe.user.first_name; + } else { + userNameEl.textContent = 'Preview'; + } + + document.getElementById('btn-close').addEventListener('click', () => { + if (isTg) tg.close(); + }); + + document.getElementById('btn-refresh').addEventListener('click', () => { + updateAllData(); + if (isTg) tg.HapticFeedback.impactOccurred('light'); + }); + + updateAllData(); +}); + +async function updateAllData() { + try { + await Promise.all([ + fetchStatus(), + fetchVersions(), + fetchClients() + ]); + if (isTg) tg.HapticFeedback.notificationOccurred('success'); + } catch (e) { + console.error('Update error:', e); + if (isTg) tg.HapticFeedback.notificationOccurred('error'); + } +} + + +async function fetchStatus() { + const content = document.getElementById('status-content'); + const badge = document.getElementById('main-status-badge'); + const dot = document.getElementById('main-status-dot'); + const text = document.getElementById('main-status-text'); + + try { + const res = await fetch('/api/status'); + const data = await res.json(); + const statusStr = data.status || ''; + + const lines = statusStr.split('\n').filter(l => l.trim()); + let allOnline = true; + let html = ''; + + lines.forEach(line => { + const isOnline = line.toLowerCase().includes('online'); + if (!isOnline) allOnline = false; + + const parts = line.split(':'); + const name = parts[0]?.trim() || 'Unknown'; + const statusPart = parts.slice(1).join(':').trim(); + + + const pingMatch = statusPart.match(/\((\d+ms)\)/); + const ping = pingMatch ? pingMatch[1] : ''; + + html += ` +
+
+ + + + ${escapeHtml(name)} +
+ + ${isOnline ? 'Online' : 'Offline'}${ping ? ' · ' + ping : ''} + +
+ `; + }); + + content.innerHTML = html; + + if (allOnline) { + badge.className = 'status-badge online'; + text.textContent = 'Stable'; + } else { + badge.className = 'status-badge offline'; + text.textContent = 'Issues'; + } + } catch (e) { + content.innerHTML = '
Не удалось загрузить статус
'; + badge.className = 'status-badge offline'; + text.textContent = 'Error'; + } +} + +async function fetchVersions() { + const content = document.getElementById('versions-content'); + + try { + const res = await fetch('/api/versions'); + const data = await res.json(); + + const stableTag = data.latest?.[0] || 'N/A'; + const stableUrl = data.latest?.[1] || '#'; + const preTag = data.pre?.[0] || 'N/A'; + const preUrl = data.pre?.[1] || '#'; + + content.innerHTML = ` +
+
Stable
+
${escapeHtml(stableTag)}
+
+
+
Pre-release
+
${escapeHtml(preTag)}
+
+ `; + } catch (e) { + content.innerHTML = '
Не удалось загрузить версии
'; + } +} + + +async function fetchClients() { + const content = document.getElementById('clients-content'); + const countEl = document.getElementById('client-count').querySelector('span'); + + try { + const res = await fetch('/api/clients'); + const data = await res.json(); + const raw = data.clients || ''; + + const lines = raw.split('\n').filter(l => l.trim()); + let html = ''; + let totalClients = 0; + + lines.forEach(line => { + const stripped = line.replace(/<[^>]*>/g, '').trim(); + if (!stripped) return; + + if (line.includes('') && line.trim().endsWith(':')) { + const categoryName = stripped.replace(':', ''); + html += `
${escapeHtml(categoryName)}
`; + } else { + const nameMatch = stripped.match(/^(.+?)\s*\(v/); + const versionMatch = stripped.match(/\(v([\d.]+)\)/); + const idMatch = stripped.match(/ID:\s*(\d+)/); + + const name = nameMatch ? nameMatch[1].trim() : stripped; + const version = versionMatch ? 'v' + versionMatch[1] : ''; + const id = idMatch ? '#' + idMatch[1] : ''; + + if (name) { + totalClients++; + html += ` +
+ ${escapeHtml(name)} + ${escapeHtml(version)}${id ? ' · ' + escapeHtml(id) : ''} +
+ `; + } + } + }); + + content.innerHTML = html || '
Нет доступных клиентов
'; + countEl.textContent = totalClients > 0 ? totalClients + ' шт.' : '...'; + } catch (e) { + content.innerHTML = '
Не удалось загрузить клиентов
'; + } +} + + +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} diff --git a/webapp/index.html b/webapp/index.html new file mode 100644 index 0000000..f77d1ce --- /dev/null +++ b/webapp/index.html @@ -0,0 +1,147 @@ + + + + + + Collapse Dashboard + + + + + + +
+
+
+
+
+ +
+
+ +
+ + + + + Загрузка... +
+
+ +
+ +
+
+
+
+
+ + + + + +
+

Статус серверов

+
+
+
+ ... +
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+ + + + + +
+

Версии

+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+ + + + + +
+

Клиенты

+
+
+ + + + ... +
+
+
+
+
+
+
+
+
+
+
+ +
+ + +
+
+ + + + diff --git a/webapp/style.css b/webapp/style.css new file mode 100644 index 0000000..5feb87b --- /dev/null +++ b/webapp/style.css @@ -0,0 +1,550 @@ +:root { + --bg: #0a0a0f; + --glass-bg: rgba(255, 255, 255, 0.04); + --glass-border: rgba(255, 255, 255, 0.08); + --glass-highlight: rgba(255, 255, 255, 0.12); + --glass-shadow: rgba(0, 0, 0, 0.4); + --text: #f0f0f5; + --text-muted: rgba(255, 255, 255, 0.5); + --accent: #00d4ff; + --accent-soft: rgba(0, 212, 255, 0.15); + --accent-glow: rgba(0, 212, 255, 0.3); + --success: #34d399; + --success-soft: rgba(52, 211, 153, 0.15); + --danger: #f87171; + --danger-soft: rgba(248, 113, 113, 0.15); + --purple: #a78bfa; + --purple-soft: rgba(167, 139, 250, 0.15); + --amber: #fbbf24; + --amber-soft: rgba(251, 191, 36, 0.15); + --font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + --radius: 20px; + --radius-sm: 12px; + --radius-pill: 100px; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background: var(--tg-theme-bg-color, var(--bg)); + color: var(--tg-theme-text-color, var(--text)); + font-family: var(--font); + min-height: 100vh; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.bg-blobs { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 0; + overflow: hidden; + pointer-events: none; +} + +.blob { + position: absolute; + border-radius: 50%; + filter: blur(80px); + opacity: 0.35; + animation: blob-float 20s ease-in-out infinite; +} + +.blob-1 { + width: 300px; + height: 300px; + background: radial-gradient(circle, #00d4ff, transparent 70%); + top: -80px; + left: -60px; + animation-delay: 0s; +} + +.blob-2 { + width: 250px; + height: 250px; + background: radial-gradient(circle, #a78bfa, transparent 70%); + top: 40%; + right: -80px; + animation-delay: -7s; +} + +.blob-3 { + width: 200px; + height: 200px; + background: radial-gradient(circle, #34d399, transparent 70%); + bottom: -40px; + left: 30%; + animation-delay: -14s; +} + +@keyframes blob-float { + + 0%, + 100% { + transform: translate(0, 0) scale(1); + } + + 25% { + transform: translate(30px, -40px) scale(1.1); + } + + 50% { + transform: translate(-20px, 20px) scale(0.95); + } + + 75% { + transform: translate(15px, 35px) scale(1.05); + } +} + +.app-container { + position: relative; + z-index: 1; + width: 100%; + max-width: 480px; + margin: 0 auto; + padding: 16px; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0 20px; +} + +.logo { + display: flex; + align-items: center; + gap: 10px; +} + +.logo-icon { + width: 40px; + height: 40px; + border-radius: var(--radius-sm); + background: var(--accent-soft); + display: flex; + align-items: center; + justify-content: center; + color: var(--accent); +} + +.logo-text { + display: flex; + flex-direction: column; +} + +.logo-accent { + font-size: 1.15rem; + font-weight: 700; + letter-spacing: -0.3px; + color: var(--tg-theme-text-color, var(--text)); +} + +.logo-sub { + font-size: 0.7rem; + font-weight: 500; + color: var(--tg-theme-hint-color, var(--text-muted)); + letter-spacing: 0.5px; + text-transform: uppercase; +} + +.glass-pill { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: var(--radius-pill); + background: var(--glass-bg); + border: 1px solid var(--glass-border); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + font-size: 0.78rem; + font-weight: 500; + color: var(--tg-theme-hint-color, var(--text-muted)); +} + +/* ===== LiquidGlass Card ===== */ +.liquid-glass { + position: relative; + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: var(--radius); + backdrop-filter: blur(24px) saturate(1.4); + -webkit-backdrop-filter: blur(24px) saturate(1.4); + box-shadow: + 0 4px 24px var(--glass-shadow), + inset 0 1px 0 var(--glass-highlight), + inset 0 -1px 0 rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +.card-shine { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 50%; + background: linear-gradient(180deg, + rgba(255, 255, 255, 0.06) 0%, + transparent 100%); + pointer-events: none; + z-index: 1; +} + +.card { + margin-bottom: 16px; + transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.3s ease; +} + +.card:active { + transform: scale(0.98); +} + +/* Card Header */ +.card-header { + position: relative; + z-index: 2; + padding: 14px 16px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); +} + +.card-title { + display: flex; + align-items: center; + gap: 10px; +} + +.card-title h2 { + font-size: 0.95rem; + font-weight: 600; + letter-spacing: -0.2px; +} + +.icon-wrap { + width: 32px; + height: 32px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.status-icon-wrap { + background: var(--accent-soft); + color: var(--accent); +} + +.version-icon-wrap { + background: var(--purple-soft); + color: var(--purple); +} + +.client-icon-wrap { + background: var(--amber-soft); + color: var(--amber); +} + +.card-body { + position: relative; + z-index: 2; + padding: 14px 16px 16px; + font-size: 0.88rem; + line-height: 1.6; +} + +.status-badge { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: var(--radius-pill); + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + background: var(--glass-bg); + border: 1px solid var(--glass-border); +} + +.status-badge.online { + background: var(--success-soft); + border-color: rgba(52, 211, 153, 0.2); + color: var(--success); +} + +.status-badge.offline { + background: var(--danger-soft); + border-color: rgba(248, 113, 113, 0.2); + color: var(--danger); +} + +.status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-muted); +} + +.status-badge.online .status-dot { + background: var(--success); + box-shadow: 0 0 8px var(--success); + animation: pulse-dot 2s ease-in-out infinite; +} + +.status-badge.offline .status-dot { + background: var(--danger); + box-shadow: 0 0 8px var(--danger); +} + +@keyframes pulse-dot { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.4; + } +} + +.service-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + margin-bottom: 6px; + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.03); + transition: background 0.2s; +} + +.service-name { + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; + font-size: 0.88rem; +} + +.service-name svg { + color: var(--accent); + opacity: 0.8; +} + +.service-status { + font-size: 0.78rem; + font-weight: 600; + padding: 3px 10px; + border-radius: var(--radius-pill); +} + +.service-status.ok { + background: var(--success-soft); + color: var(--success); +} + +.service-status.err { + background: var(--danger-soft); + color: var(--danger); +} + +.versions-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.version-box { + padding: 14px; + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.025); + border: 1px solid rgba(255, 255, 255, 0.04); + text-align: center; + cursor: pointer; + transition: background 0.2s, transform 0.15s; +} + +.version-box:active { + transform: scale(0.97); + background: rgba(255, 255, 255, 0.05); +} + +.version-label { + font-size: 0.68rem; + font-weight: 600; + color: var(--tg-theme-hint-color, var(--text-muted)); + text-transform: uppercase; + letter-spacing: 0.8px; + margin-bottom: 8px; +} + +.version-tag { + font-size: 1.05rem; + font-weight: 700; + letter-spacing: -0.3px; + color: var(--tg-theme-text-color, var(--text)); +} + +.version-box:first-child .version-tag { + color: var(--success); +} + +.version-box:last-child .version-tag { + color: var(--purple); +} + +.clients-section-title { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--tg-theme-hint-color, var(--text-muted)); + margin: 12px 0 8px 4px; +} + +.clients-section-title:first-child { + margin-top: 0; +} + +.client-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + margin-bottom: 4px; + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.03); + transition: background 0.2s; +} + +.client-name { + font-weight: 500; + font-size: 0.88rem; +} + +.client-meta { + font-size: 0.75rem; + color: var(--tg-theme-hint-color, var(--text-muted)); + font-weight: 500; +} + +.action-bar { + display: flex; + gap: 10px; + margin-top: 24px; + padding-bottom: 20px; +} + +.btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 13px; + border: none; + border-radius: var(--radius-sm); + font-weight: 600; + font-size: 0.88rem; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); + font-family: inherit; + -webkit-tap-highlight-color: transparent; +} + +.btn:active { + transform: scale(0.96); +} + +.btn-primary { + background: linear-gradient(135deg, rgba(0, 212, 255, 0.2), rgba(167, 139, 250, 0.15)); + color: var(--accent); + border: 1px solid rgba(0, 212, 255, 0.15); +} + +.btn-secondary { + background: var(--glass-bg); + color: var(--tg-theme-hint-color, var(--text-muted)); + border: 1px solid var(--glass-border); +} + +.skeleton-loader { + display: flex; + flex-direction: column; + gap: 10px; +} + +.skeleton-line { + height: 14px; + border-radius: 8px; + background: linear-gradient(90deg, + rgba(255, 255, 255, 0.03) 25%, + rgba(255, 255, 255, 0.08) 50%, + rgba(255, 255, 255, 0.03) 75%); + background-size: 200% 100%; + animation: skeleton-shimmer 1.5s ease-in-out infinite; +} + +.skeleton-line.short { + width: 60%; +} + +@keyframes skeleton-shimmer { + 0% { + background-position: 200% 0; + } + + 100% { + background-position: -200% 0; + } +} + + +.card { + opacity: 0; + transform: translateY(20px); + animation: card-enter 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; +} + +#status-card { + animation-delay: 0.05s; +} + +#versions-card { + animation-delay: 0.12s; +} + +#clients-card { + animation-delay: 0.19s; +} + +@keyframes card-enter { + to { + opacity: 1; + transform: translateY(0); + } +} + +.error-text { + font-size: 0.85rem; + color: var(--danger); + text-align: center; + padding: 8px; +} \ No newline at end of file