215 lines
7.2 KiB
Python
215 lines
7.2 KiB
Python
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()
|