import logging from dataclasses import dataclass from typing import Any import httpx from pydantic_settings import BaseSettings, SettingsConfigDict from telegram import Update from telegram.ext import ( Application, CommandHandler, ContextTypes, ) logger = logging.getLogger("server_panel.telegram_bot") class BotSettings(BaseSettings): telegram_bot_token: str = "" telegram_mode: str = "polling" backend_api_base_url: str = "http://backend:8000/api/v1" telegram_bot_api_secret: str = "" model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", case_sensitive=False, ) class BackendApiError(Exception): pass @dataclass(slots=True) class BackendClient: base_url: str bot_secret: str async def post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: async with httpx.AsyncClient(timeout=15) as client: response = await client.post( f"{self.base_url}{path}", json=payload, headers={"X-Telegram-Bot-Secret": self.bot_secret}, ) try: body = response.json() except ValueError: body = {} if response.is_success: return body detail = body.get("detail", {}) if isinstance(body, dict) else {} if isinstance(detail, dict): message = detail.get("message", f"Backend returned {response.status_code}.") else: message = str(detail) or f"Backend returned {response.status_code}." raise BackendApiError(message) async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if update.effective_user is None or update.effective_chat is None or update.message is None: return backend: BackendClient = context.application.bot_data["backend_client"] if not context.args: await update.message.reply_text( "Отправь /start LINK_CODE чтобы привязать Telegram к своему аккаунту Server Panel." ) return link_code = context.args[0].strip() try: await backend.post( "/telegram/bot/link/complete", { "link_code": link_code, "telegram_user_id": str(update.effective_user.id), "chat_id": str(update.effective_chat.id), }, ) except BackendApiError as exc: await update.message.reply_text(f"Не удалось завершить привязку: {exc}") return await update.message.reply_text( "Привязка завершена. Теперь доступны /status, /services и /restart." ) async def status_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if update.effective_user is None or update.message is None: return backend: BackendClient = context.application.bot_data["backend_client"] try: payload = await backend.post( "/telegram/bot/status", {"telegram_user_id": str(update.effective_user.id)}, ) except BackendApiError as exc: await update.message.reply_text(f"Не удалось получить статус: {exc}") return lines = [f"Пользователь: {payload['username']} ({payload['role']})"] for server in payload["servers"]: marker = "OK" if server["reachable"] else "DOWN" lines.append( f"{marker} {server['name']} [{server['environment']}] - {server['status']}" ) await update.message.reply_text("\n".join(lines)) async def services_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if update.effective_user is None or update.message is None: return backend: BackendClient = context.application.bot_data["backend_client"] server_name = " ".join(context.args).strip() or None try: payload = await backend.post( "/telegram/bot/services", { "telegram_user_id": str(update.effective_user.id), "server_name": server_name, }, ) except BackendApiError as exc: await update.message.reply_text(f"Не удалось получить сервисы: {exc}") return if not payload["services"]: await update.message.reply_text("Сервисы не найдены.") return lines = [f"Пользователь: {payload['username']} ({payload['role']})"] for service in payload["services"]: lines.append( f"{service['server_name']}: {service['service_name']} [{service['service_type']}] - {service['status']}" ) await update.message.reply_text("\n".join(lines)) async def restart_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if update.effective_user is None or update.message is None: return if len(context.args) < 2: await update.message.reply_text("Использование: /restart SERVER_NAME SERVICE_NAME") return backend: BackendClient = context.application.bot_data["backend_client"] server_name = context.args[0].strip() service_name = context.args[1].strip() try: payload = await backend.post( "/telegram/bot/restart", { "telegram_user_id": str(update.effective_user.id), "server_name": server_name, "service_name": service_name, }, ) except BackendApiError as exc: await update.message.reply_text(f"Не удалось запустить restart: {exc}") return # The backend keeps the real execution state, while the bot only relays the # action ID and initial queue status back to the operator. await update.message.reply_text( f"Restart поставлен в очередь: action #{payload['id']} status={payload['status']}" ) def configure_logging() -> None: logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s", ) def build_application(settings: BotSettings) -> Application: application = Application.builder().token(settings.telegram_bot_token).build() application.bot_data["backend_client"] = BackendClient( base_url=settings.backend_api_base_url, bot_secret=settings.telegram_bot_api_secret, ) application.add_handler(CommandHandler("start", start_command)) application.add_handler(CommandHandler("status", status_command)) application.add_handler(CommandHandler("services", services_command)) application.add_handler(CommandHandler("restart", restart_command)) return application def main() -> None: configure_logging() settings = BotSettings() if not settings.telegram_bot_token: print("Telegram bot scaffold is configured, but TELEGRAM_BOT_TOKEN is empty.") return if not settings.telegram_bot_api_secret: print("Telegram bot scaffold is configured, but TELEGRAM_BOT_API_SECRET is empty.") return logger.info("Starting Telegram bot in %s mode.", settings.telegram_mode) application = build_application(settings) application.run_polling() if __name__ == "__main__": main()