mirror of
https://github.com/PRVTPRO/Amnezia-Web-Panel.git
synced 2026-09-18 01:44:10 +02:00
29def95df8
Add SOCKS5 support Add AdGuard Home integration Add node status and latency tracking Add drag-and-drop server management Upgrade Xray from v1.8 to v26.3.27 with backward compatibility Fix Telemt dependency installation Extend Telegram bot to support all protocols Improve server cleanup process Enhance API structure (system routes moved to a separate group) Add JWT token generation for external integrations
405 lines
15 KiB
Python
405 lines
15 KiB
Python
"""
|
|
Telegram bot for Amnezia Web Panel.
|
|
Uses raw Telegram Bot API via httpx — no library version conflicts.
|
|
Runs as a background asyncio task alongside the FastAPI app.
|
|
"""
|
|
import asyncio
|
|
import logging
|
|
from typing import Optional, Callable
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ----------------------------------------------------------------------- #
|
|
# Global state
|
|
# ----------------------------------------------------------------------- #
|
|
_bot_task: Optional[asyncio.Task] = None
|
|
|
|
|
|
def is_running() -> bool:
|
|
return _bot_task is not None and not _bot_task.done()
|
|
|
|
|
|
def launch_bot(token: str, load_data_fn: Callable, generate_vpn_link_fn: Callable):
|
|
global _bot_task
|
|
_bot_task = asyncio.create_task(
|
|
_run_bot(token, load_data_fn, generate_vpn_link_fn),
|
|
name="telegram_bot",
|
|
)
|
|
return _bot_task
|
|
|
|
|
|
async def stop_bot():
|
|
global _bot_task
|
|
if _bot_task and not _bot_task.done():
|
|
_bot_task.cancel()
|
|
try:
|
|
await _bot_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
_bot_task = None
|
|
logger.info("Telegram bot stopped.")
|
|
|
|
|
|
# ----------------------------------------------------------------------- #
|
|
# Low-level Telegram API helpers
|
|
# ----------------------------------------------------------------------- #
|
|
class TelegramAPI:
|
|
def __init__(self, token: str, client: httpx.AsyncClient):
|
|
self.base = f"https://api.telegram.org/bot{token}"
|
|
self.client = client
|
|
|
|
async def call(self, method: str, **params) -> dict:
|
|
r = await self.client.post(f"{self.base}/{method}", json=params, timeout=30)
|
|
return r.json()
|
|
|
|
async def get_updates(self, offset: int = 0, timeout: int = 25) -> list:
|
|
r = await self.client.post(
|
|
f"{self.base}/getUpdates",
|
|
json={"offset": offset, "timeout": timeout, "allowed_updates": ["message", "callback_query"]},
|
|
timeout=timeout + 10,
|
|
)
|
|
data = r.json()
|
|
if data.get("ok"):
|
|
return data["result"]
|
|
return []
|
|
|
|
async def send_message(self, chat_id, text: str, reply_markup=None, parse_mode="HTML") -> dict:
|
|
import json
|
|
params = {"chat_id": chat_id, "text": text, "parse_mode": parse_mode}
|
|
if reply_markup:
|
|
params["reply_markup"] = json.dumps(reply_markup)
|
|
return (await self.call("sendMessage", **params))
|
|
|
|
async def edit_message(self, chat_id, message_id, text: str, reply_markup=None, parse_mode="HTML"):
|
|
import json
|
|
params = {"chat_id": chat_id, "message_id": message_id, "text": text, "parse_mode": parse_mode}
|
|
if reply_markup:
|
|
params["reply_markup"] = json.dumps(reply_markup)
|
|
await self.call("editMessageText", **params)
|
|
|
|
async def answer_callback(self, callback_query_id: str, text: str = ""):
|
|
await self.call("answerCallbackQuery", callback_query_id=callback_query_id, text=text)
|
|
|
|
async def send_document(self, chat_id, filename: str, content: bytes, caption: str = ""):
|
|
files = {"document": (filename, content, "text/plain")}
|
|
data = {"chat_id": str(chat_id), "caption": caption}
|
|
r = await self.client.post(f"{self.base}/sendDocument", data=data, files=files, timeout=30)
|
|
return r.json()
|
|
|
|
|
|
# ----------------------------------------------------------------------- #
|
|
# Helpers
|
|
# ----------------------------------------------------------------------- #
|
|
def _find_user(load_data_fn: Callable, tg_id: str):
|
|
data = load_data_fn()
|
|
tg_id_clean = str(tg_id).lstrip("@")
|
|
for u in data.get("users", []):
|
|
stored = str(u.get("telegramId", "") or "").lstrip("@")
|
|
if stored and stored == tg_id_clean:
|
|
return u
|
|
return None
|
|
|
|
|
|
def _build_connections_keyboard(conns: list, data: dict) -> dict:
|
|
"""Build inline keyboard where each button = one connection."""
|
|
rows = []
|
|
servers = data.get("servers", [])
|
|
for c in conns:
|
|
sid = c.get("server_id", 0)
|
|
server_name = "Unknown"
|
|
if sid < len(servers):
|
|
srv = servers[sid]
|
|
server_name = srv.get("name") or srv.get("host", "Unknown")[:20]
|
|
proto = c.get("protocol", "").upper()
|
|
name = c.get("name", "Connection")
|
|
label = f"🔐 {name} · {proto} · {server_name}"
|
|
# callback_data must be ≤ 64 bytes — use short prefix
|
|
rows.append([{"text": label, "callback_data": f"cfg:{c['id']}"}])
|
|
rows.append([{"text": "🔄 Обновить список", "callback_data": "refresh"}])
|
|
return {"inline_keyboard": rows}
|
|
|
|
|
|
# ----------------------------------------------------------------------- #
|
|
# /start handler — shows connections list immediately
|
|
# ----------------------------------------------------------------------- #
|
|
async def _handle_start(api: TelegramAPI, msg: dict, load_data_fn: Callable):
|
|
chat_id = msg["chat"]["id"]
|
|
tg_id = str(msg["from"]["id"])
|
|
first_name = msg["from"].get("first_name", "")
|
|
|
|
panel_user = _find_user(load_data_fn, tg_id)
|
|
|
|
if not panel_user:
|
|
await api.send_message(
|
|
chat_id,
|
|
f"👋 Hi, <b>{first_name}</b>!\n\n"
|
|
"Your Telegram account is not linked to any panel user.\n"
|
|
"Please contact your administrator — they need to add your Telegram ID to your profile.\n\n"
|
|
f"Your Telegram ID: <code>{tg_id}</code>",
|
|
)
|
|
return
|
|
|
|
data = load_data_fn()
|
|
conns = [c for c in data.get("user_connections", []) if c["user_id"] == panel_user["id"]]
|
|
|
|
if not conns:
|
|
await api.send_message(
|
|
chat_id,
|
|
f"👋 Hi, <b>{first_name}</b>!\n\n"
|
|
f"You are registered as <b>{panel_user['username']}</b>.\n\n"
|
|
"You have no connections yet. Please contact your administrator.",
|
|
)
|
|
return
|
|
|
|
kb = _build_connections_keyboard(conns, data)
|
|
await api.send_message(
|
|
chat_id,
|
|
f"👋 Hi, <b>{first_name}</b>!\n\n"
|
|
f"You are registered as <b>{panel_user['username']}</b>.\n\n"
|
|
f"<b>Your connections</b> ({len(conns)}) — tap to get config:",
|
|
reply_markup=kb,
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------- #
|
|
# Refresh — edit existing message with updated list
|
|
# ----------------------------------------------------------------------- #
|
|
async def _handle_refresh(
|
|
api: TelegramAPI, chat_id: int, message_id: int, callback_id: str, tg_id: str, load_data_fn: Callable
|
|
):
|
|
await api.answer_callback(callback_id, "Updated!")
|
|
panel_user = _find_user(load_data_fn, tg_id)
|
|
if not panel_user:
|
|
await api.edit_message(chat_id, message_id, "❌ Access denied.")
|
|
return
|
|
data = load_data_fn()
|
|
conns = [c for c in data.get("user_connections", []) if c["user_id"] == panel_user["id"]]
|
|
if not conns:
|
|
await api.edit_message(chat_id, message_id, "You have no connections.")
|
|
return
|
|
kb = _build_connections_keyboard(conns, data)
|
|
await api.edit_message(
|
|
chat_id, message_id,
|
|
f"<b>Your connections</b> ({len(conns)}) — tap to get config:",
|
|
reply_markup=kb,
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------- #
|
|
# Get config — send multiple messages with different formats
|
|
# ----------------------------------------------------------------------- #
|
|
async def _handle_get_config(
|
|
api: TelegramAPI,
|
|
chat_id: int,
|
|
message_id: int,
|
|
callback_id: str,
|
|
conn_id: str,
|
|
tg_id: str,
|
|
load_data_fn: Callable,
|
|
generate_vpn_link_fn: Callable,
|
|
):
|
|
await api.answer_callback(callback_id, "Fetching config...")
|
|
|
|
panel_user = _find_user(load_data_fn, tg_id)
|
|
if not panel_user:
|
|
await api.send_message(chat_id, "❌ Access denied.")
|
|
return
|
|
|
|
data = load_data_fn()
|
|
conn = next(
|
|
(c for c in data.get("user_connections", [])
|
|
if c["id"] == conn_id and c["user_id"] == panel_user["id"]),
|
|
None,
|
|
)
|
|
if not conn:
|
|
await api.send_message(chat_id, "❌ Connection not found.")
|
|
return
|
|
|
|
sid = conn["server_id"]
|
|
servers = data.get("servers", [])
|
|
if sid >= len(servers):
|
|
await api.send_message(chat_id, "❌ Server not found.")
|
|
return
|
|
|
|
server = servers[sid]
|
|
proto = conn.get("protocol", "awg")
|
|
conn_name = conn.get("name", "Connection")
|
|
|
|
# Send "Loading..." as new message
|
|
loading_result = await api.send_message(chat_id, f"⏳ Fetching config for <b>{conn_name}</b>...")
|
|
loading_msg_id = loading_result.get("result", {}).get("message_id")
|
|
|
|
try:
|
|
import sys, os
|
|
sys.path.insert(0, os.path.dirname(__file__))
|
|
from managers.ssh_manager import SSHManager
|
|
from managers.awg_manager import AWGManager
|
|
from managers.xray_manager import XrayManager
|
|
|
|
ssh = SSHManager(
|
|
server["host"],
|
|
server.get("ssh_port", 22),
|
|
server["username"],
|
|
server.get("password", ""),
|
|
server.get("private_key", ""),
|
|
)
|
|
|
|
proto_info = server.get("protocols", {}).get(proto, {})
|
|
port = proto_info.get("port", "55424")
|
|
|
|
def _get_cfg():
|
|
ssh.connect()
|
|
from managers.wireguard_manager import WireGuardManager
|
|
from managers.telemt_manager import TelemtManager
|
|
|
|
if proto == "xray":
|
|
mgr = XrayManager(ssh)
|
|
cfg = mgr.get_client_config(proto, conn["client_id"], server["host"], port)
|
|
elif proto == "wireguard":
|
|
mgr = WireGuardManager(ssh)
|
|
cfg = mgr.get_client_config(conn["client_id"], server["host"])
|
|
elif proto == "telemt":
|
|
mgr = TelemtManager(ssh)
|
|
cfg = mgr.get_client_config(proto, conn["client_id"], server["host"], port)
|
|
else:
|
|
# awg, awg2, awg_legacy
|
|
mgr = AWGManager(ssh)
|
|
cfg = mgr.get_client_config(proto, conn["client_id"], server["host"], port)
|
|
ssh.disconnect()
|
|
return cfg
|
|
|
|
config = await asyncio.to_thread(_get_cfg)
|
|
|
|
if not config:
|
|
if loading_msg_id:
|
|
await api.edit_message(chat_id, loading_msg_id, "❌ Failed to retrieve configuration.")
|
|
return
|
|
|
|
vpn_link = generate_vpn_link_fn(config) if config else ""
|
|
|
|
# Delete loading message
|
|
if loading_msg_id:
|
|
await api.call("deleteMessage", chat_id=chat_id, message_id=loading_msg_id)
|
|
|
|
# ------- 1. Header -------
|
|
server_name = server.get("name") or server.get("host", "Unknown")
|
|
await api.send_message(
|
|
chat_id,
|
|
f"✅ <b>{conn_name}</b>\n"
|
|
f"🌐 Server: <b>{server_name}</b>\n"
|
|
f"🔌 Protocol: <b>{proto.upper()}</b>",
|
|
)
|
|
|
|
# ------- 2. Send config (format depends on protocol) -------
|
|
# Protocols that return a link/URI rather than an INI-style config file
|
|
is_link_proto = proto in ("xray", "telemt")
|
|
|
|
if is_link_proto:
|
|
# Show as a tappable link — no .conf file needed
|
|
await api.send_message(
|
|
chat_id,
|
|
f"🔗 <b>Connection link</b> (tap to copy):\n<code>{config}</code>",
|
|
)
|
|
else:
|
|
# AWG / WireGuard — INI config text
|
|
MAX_LEN = 4000
|
|
if len(config) <= MAX_LEN:
|
|
await api.send_message(chat_id, f"<b>📄 Configuration:</b>\n<pre>{config}</pre>")
|
|
else:
|
|
chunks = [config[i:i+MAX_LEN] for i in range(0, len(config), MAX_LEN)]
|
|
for i, chunk in enumerate(chunks, 1):
|
|
await api.send_message(chat_id, f"<b>📄 Configuration (part {i}/{len(chunks)}):</b>\n<pre>{chunk}</pre>")
|
|
|
|
# VPN deep-link (vpn:// base64 URI for the Amnezia app)
|
|
vpn_link = generate_vpn_link_fn(config)
|
|
if vpn_link:
|
|
await api.send_message(
|
|
chat_id,
|
|
f"🔗 <b>VPN Link</b> (tap to copy):\n<code>{vpn_link}</code>",
|
|
)
|
|
|
|
# .conf file attachment
|
|
filename = f"{conn_name.replace(' ', '_')}.conf"
|
|
await api.send_document(
|
|
chat_id,
|
|
filename=filename,
|
|
content=config.encode("utf-8"),
|
|
caption=f"📁 Config file: {conn_name}",
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.exception("Bot: error getting config")
|
|
if loading_msg_id:
|
|
await api.edit_message(chat_id, loading_msg_id, f"❌ Error: {e}")
|
|
else:
|
|
await api.send_message(chat_id, f"❌ Error: {e}")
|
|
|
|
|
|
# ----------------------------------------------------------------------- #
|
|
# Main polling loop
|
|
# ----------------------------------------------------------------------- #
|
|
async def _run_bot(token: str, load_data_fn: Callable, generate_vpn_link_fn: Callable):
|
|
offset = 0
|
|
logger.info("Telegram bot started (raw httpx polling).")
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
api = TelegramAPI(token, client)
|
|
|
|
me = await api.call("getMe")
|
|
if not me.get("ok"):
|
|
logger.error(f"Telegram bot: invalid token or API error: {me}")
|
|
return
|
|
logger.info(f"Telegram bot logged in as @{me['result']['username']}")
|
|
|
|
while True:
|
|
try:
|
|
updates = await api.get_updates(offset=offset, timeout=25)
|
|
except asyncio.CancelledError:
|
|
logger.info("Telegram bot polling cancelled.")
|
|
return
|
|
except Exception as e:
|
|
logger.warning(f"Telegram bot polling error: {e}")
|
|
await asyncio.sleep(5)
|
|
continue
|
|
|
|
for update in updates:
|
|
offset = update["update_id"] + 1
|
|
try:
|
|
await _dispatch(api, update, load_data_fn, generate_vpn_link_fn)
|
|
except asyncio.CancelledError:
|
|
return
|
|
except Exception as e:
|
|
logger.exception(f"Telegram bot: error handling update {update['update_id']}: {e}")
|
|
|
|
|
|
async def _dispatch(api: TelegramAPI, update: dict, load_data_fn: Callable, generate_vpn_link_fn: Callable):
|
|
# --- Text messages ---
|
|
if "message" in update:
|
|
msg = update["message"]
|
|
text = msg.get("text", "")
|
|
if text.startswith("/start"):
|
|
await _handle_start(api, msg, load_data_fn)
|
|
elif text.startswith("/connections"):
|
|
# Alias for /start
|
|
await _handle_start(api, msg, load_data_fn)
|
|
|
|
# --- Inline button callbacks ---
|
|
elif "callback_query" in update:
|
|
cq = update["callback_query"]
|
|
callback_id = cq["id"]
|
|
data_str = cq.get("data", "")
|
|
chat_id = cq["message"]["chat"]["id"]
|
|
message_id = cq["message"]["message_id"]
|
|
tg_id = str(cq["from"]["id"])
|
|
|
|
if data_str == "refresh":
|
|
await _handle_refresh(api, chat_id, message_id, callback_id, tg_id, load_data_fn)
|
|
elif data_str.startswith("cfg:"):
|
|
conn_id = data_str[4:]
|
|
await _handle_get_config(
|
|
api, chat_id, message_id, callback_id,
|
|
conn_id, tg_id, load_data_fn, generate_vpn_link_fn
|
|
)
|