Initial commit: VPN Telegram bot (sanitized defaults)
This commit is contained in:
@@ -0,0 +1,488 @@
|
||||
"""Покупка и управление AmneziaWG 2.0 конфигами через Amnezia Web Panel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from remnawave import RemnawaveSDK
|
||||
|
||||
from services.awg_client import DEFAULT_PROTOCOL, AwgClient, AwgError
|
||||
from services.db import Database
|
||||
from services.xui_wg import subscription_is_active, _subscription_limits
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_AWG_CONFIGS = 10
|
||||
SETTING_ENABLED = "awg_purchase_enabled"
|
||||
SETTING_PRICE = "awg_config_price"
|
||||
DEFAULT_PRICE = 0.0
|
||||
|
||||
|
||||
def _truthy(value: str) -> bool:
|
||||
return (value or "").strip().lower() in ("1", "true", "yes", "on", "да")
|
||||
|
||||
|
||||
def _format_price(price: float) -> str:
|
||||
if price <= 0:
|
||||
return "бесплатно"
|
||||
if price == int(price):
|
||||
return f"{int(price)} ₽"
|
||||
return f"{price:.2f} ₽"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AwgPurchaseSettings:
|
||||
enabled: bool = False
|
||||
price: float = DEFAULT_PRICE
|
||||
max_configs: int = MAX_AWG_CONFIGS
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"price": self.price,
|
||||
"price_label": _format_price(self.price),
|
||||
"max_configs": self.max_configs,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AwgActionResult:
|
||||
ok: bool
|
||||
message: str
|
||||
balance: float = 0.0
|
||||
config: dict[str, Any] | None = None
|
||||
configs: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
def load_awg_purchase_settings(db: Database) -> AwgPurchaseSettings:
|
||||
raw = db.get_settings_map([SETTING_ENABLED, SETTING_PRICE])
|
||||
price_raw = (raw.get(SETTING_PRICE) or "").strip()
|
||||
try:
|
||||
price = float(price_raw) if price_raw else DEFAULT_PRICE
|
||||
except ValueError:
|
||||
price = DEFAULT_PRICE
|
||||
return AwgPurchaseSettings(
|
||||
enabled=_truthy(raw.get(SETTING_ENABLED, "")),
|
||||
price=max(0.0, price),
|
||||
max_configs=MAX_AWG_CONFIGS,
|
||||
)
|
||||
|
||||
|
||||
def save_awg_purchase_settings(
|
||||
db: Database,
|
||||
*,
|
||||
enabled: bool | None = None,
|
||||
price: float | None = None,
|
||||
) -> AwgPurchaseSettings:
|
||||
data: dict[str, str] = {}
|
||||
if enabled is not None:
|
||||
data[SETTING_ENABLED] = "1" if enabled else "0"
|
||||
if price is not None:
|
||||
data[SETTING_PRICE] = str(max(0.0, float(price)))
|
||||
if data:
|
||||
db.set_settings_map(data)
|
||||
return load_awg_purchase_settings(db)
|
||||
|
||||
|
||||
def _client_from_server(row: dict[str, Any]) -> AwgClient:
|
||||
return AwgClient(
|
||||
str(row["api_url"]),
|
||||
api_token=str(row.get("api_token") or ""),
|
||||
verify_ssl=bool(row.get("verify_ssl", True)),
|
||||
)
|
||||
|
||||
|
||||
def _new_client_name(telegram_id: int, remark: str = "") -> str:
|
||||
if remark.strip():
|
||||
return remark.strip()[:80]
|
||||
suffix = "".join(
|
||||
secrets.choice(string.ascii_lowercase + string.digits) for _ in range(4)
|
||||
)
|
||||
return f"awg_{telegram_id}_{suffix}"
|
||||
|
||||
|
||||
def serialize_awg_config(
|
||||
row: dict[str, Any], server: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
expire = row.get("expire_at")
|
||||
return {
|
||||
"id": int(row["id"]),
|
||||
"server_id": int(row["server_id"]),
|
||||
"server_name": (server or {}).get("name") or row.get("server_name") or "",
|
||||
"client_id": row.get("client_id") or "",
|
||||
"client_name": row.get("client_name") or "",
|
||||
"remark": row.get("remark") or "",
|
||||
"vpn_link": row.get("vpn_link") or "",
|
||||
"config": row.get("config_text") or "",
|
||||
"enabled": bool(row.get("enabled", True)),
|
||||
"protocol": row.get("protocol") or DEFAULT_PROTOCOL,
|
||||
"expire_at": expire.isoformat() if isinstance(expire, datetime) else expire,
|
||||
"qr_url": f"/api/awg/configs/{int(row['id'])}/qr",
|
||||
"created_at": row["created_at"].isoformat()
|
||||
if isinstance(row.get("created_at"), datetime)
|
||||
else row.get("created_at"),
|
||||
}
|
||||
|
||||
|
||||
def list_available_awg_servers(db: Database) -> list[dict[str, Any]]:
|
||||
rows = db.list_awg_servers(enabled_only=True)
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
if not str(r.get("api_token") or "").strip():
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"id": int(r["id"]),
|
||||
"name": r.get("name") or f"AWG #{r['id']}",
|
||||
"protocol": r.get("protocol") or DEFAULT_PROTOCOL,
|
||||
"panel_server_id": int(r.get("panel_server_id") or 0),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def list_user_awg_configs(db: Database, telegram_id: int) -> list[dict[str, Any]]:
|
||||
rows = db.list_awg_configs(telegram_id)
|
||||
return [serialize_awg_config(r) for r in rows]
|
||||
|
||||
|
||||
async def purchase_awg_config(
|
||||
*,
|
||||
db: Database,
|
||||
sdk: RemnawaveSDK,
|
||||
telegram_id: int,
|
||||
server_id: int,
|
||||
remark: str = "",
|
||||
login: str | None = None,
|
||||
) -> AwgActionResult:
|
||||
settings = load_awg_purchase_settings(db)
|
||||
bot_user = db.ensure_user(telegram_id)
|
||||
balance = bot_user.balance
|
||||
login = login or bot_user.login
|
||||
|
||||
if not settings.enabled:
|
||||
return AwgActionResult(
|
||||
ok=False,
|
||||
message="Покупка AmneziaWG временно отключена.",
|
||||
balance=balance,
|
||||
)
|
||||
|
||||
active, reason = await subscription_is_active(
|
||||
sdk, telegram_id, login=login, db=db
|
||||
)
|
||||
if not active:
|
||||
return AwgActionResult(ok=False, message=reason, balance=balance)
|
||||
|
||||
count = db.count_awg_configs(telegram_id)
|
||||
if count >= settings.max_configs:
|
||||
return AwgActionResult(
|
||||
ok=False,
|
||||
message=f"Лимит: максимум {settings.max_configs} AWG-конфигов на аккаунт.",
|
||||
balance=balance,
|
||||
)
|
||||
|
||||
server = db.get_awg_server(server_id)
|
||||
if not server or not server.get("enabled"):
|
||||
return AwgActionResult(ok=False, message="Сервер недоступен.", balance=balance)
|
||||
|
||||
price = float(settings.price)
|
||||
if price > 0:
|
||||
ok, balance = db.try_charge(
|
||||
telegram_id,
|
||||
price,
|
||||
kind="awg_config",
|
||||
meta=f"awg:{server_id}",
|
||||
)
|
||||
if not ok:
|
||||
return AwgActionResult(
|
||||
ok=False,
|
||||
message=(
|
||||
f"Недостаточно средств.\n"
|
||||
f"Нужно: <b>{_format_price(price)}</b>\n"
|
||||
f"Баланс: <b>{balance:.0f} ₽</b>"
|
||||
),
|
||||
balance=balance,
|
||||
)
|
||||
|
||||
client_name = _new_client_name(telegram_id, remark)
|
||||
remark = (remark or "").strip()[:80] or client_name
|
||||
protocol = str(server.get("protocol") or DEFAULT_PROTOCOL)
|
||||
panel_sid = int(server.get("panel_server_id") or 0)
|
||||
|
||||
try:
|
||||
expire_at, _traffic = await _subscription_limits(
|
||||
sdk, telegram_id, login=login, db=db
|
||||
)
|
||||
async with _client_from_server(server) as client:
|
||||
created = await client.add_connection(
|
||||
panel_sid, name=client_name, protocol=protocol
|
||||
)
|
||||
client_id = str(created.get("client_id") or "")
|
||||
config_text = str(created.get("config") or "")
|
||||
vpn_link = str(created.get("vpn_link") or "")
|
||||
if not vpn_link and config_text:
|
||||
import base64
|
||||
|
||||
vpn_link = "vpn://" + base64.b64encode(
|
||||
config_text.strip().encode("utf-8")
|
||||
).decode("utf-8")
|
||||
row = db.create_awg_config(
|
||||
telegram_id=telegram_id,
|
||||
server_id=server_id,
|
||||
client_id=client_id,
|
||||
client_name=client_name,
|
||||
remark=remark,
|
||||
config_text=config_text,
|
||||
vpn_link=vpn_link,
|
||||
protocol=protocol,
|
||||
enabled=True,
|
||||
expire_at=expire_at,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("AWG config create failed")
|
||||
if price > 0:
|
||||
balance = db.add_balance(
|
||||
telegram_id,
|
||||
price,
|
||||
kind="refund",
|
||||
meta=f"awg_refund:{server_id}",
|
||||
)
|
||||
return AwgActionResult(
|
||||
ok=False,
|
||||
message=f"Не удалось создать конфиг: {exc}",
|
||||
balance=balance,
|
||||
)
|
||||
|
||||
return AwgActionResult(
|
||||
ok=True,
|
||||
message="AmneziaWG-конфиг создан",
|
||||
balance=balance,
|
||||
config=serialize_awg_config(row, server),
|
||||
)
|
||||
|
||||
|
||||
async def set_awg_config_enabled(
|
||||
*,
|
||||
db: Database,
|
||||
telegram_id: int,
|
||||
config_id: int,
|
||||
enabled: bool,
|
||||
sdk: RemnawaveSDK | None = None,
|
||||
login: str | None = None,
|
||||
) -> AwgActionResult:
|
||||
bot_user = db.ensure_user(telegram_id)
|
||||
login = login or bot_user.login
|
||||
row = db.get_awg_config(config_id, telegram_id=telegram_id)
|
||||
if not row:
|
||||
return AwgActionResult(ok=False, message="Конфиг не найден", balance=bot_user.balance)
|
||||
|
||||
if enabled:
|
||||
if sdk is None:
|
||||
return AwgActionResult(
|
||||
ok=False,
|
||||
message="Не удалось проверить подписку.",
|
||||
balance=bot_user.balance,
|
||||
)
|
||||
active, reason = await subscription_is_active(
|
||||
sdk, telegram_id, login=login, db=db
|
||||
)
|
||||
if not active:
|
||||
return AwgActionResult(ok=False, message=reason, balance=bot_user.balance)
|
||||
|
||||
server = db.get_awg_server(int(row["server_id"]))
|
||||
if not server:
|
||||
return AwgActionResult(ok=False, message="Сервер не найден", balance=bot_user.balance)
|
||||
try:
|
||||
async with _client_from_server(server) as client:
|
||||
await client.toggle_connection(
|
||||
int(server["panel_server_id"]),
|
||||
client_id=str(row["client_id"]),
|
||||
enable=enabled,
|
||||
protocol=str(row.get("protocol") or server.get("protocol") or DEFAULT_PROTOCOL),
|
||||
)
|
||||
db.update_awg_config(config_id, enabled=enabled)
|
||||
except AwgError as exc:
|
||||
return AwgActionResult(ok=False, message=str(exc), balance=bot_user.balance)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("AWG toggle failed")
|
||||
return AwgActionResult(ok=False, message=f"Ошибка: {exc}", balance=bot_user.balance)
|
||||
refreshed = db.get_awg_config(config_id, telegram_id=telegram_id)
|
||||
return AwgActionResult(
|
||||
ok=True,
|
||||
message="Включено" if enabled else "Выключено",
|
||||
balance=bot_user.balance,
|
||||
config=serialize_awg_config(refreshed, server) if refreshed else None,
|
||||
)
|
||||
|
||||
|
||||
async def delete_awg_config(
|
||||
*,
|
||||
db: Database,
|
||||
telegram_id: int,
|
||||
config_id: int,
|
||||
) -> AwgActionResult:
|
||||
row = db.get_awg_config(config_id, telegram_id=telegram_id)
|
||||
if not row:
|
||||
return AwgActionResult(ok=False, message="Конфиг не найден")
|
||||
server = db.get_awg_server(int(row["server_id"]))
|
||||
if server:
|
||||
try:
|
||||
async with _client_from_server(server) as client:
|
||||
await client.remove_connection(
|
||||
int(server["panel_server_id"]),
|
||||
client_id=str(row["client_id"]),
|
||||
protocol=str(
|
||||
row.get("protocol") or server.get("protocol") or DEFAULT_PROTOCOL
|
||||
),
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("AWG remove on panel failed (deleting local anyway)")
|
||||
db.delete_awg_config(config_id, telegram_id=telegram_id)
|
||||
return AwgActionResult(ok=True, message="Удалено")
|
||||
|
||||
|
||||
async def refresh_awg_config_payload(
|
||||
*,
|
||||
db: Database,
|
||||
telegram_id: int,
|
||||
config_id: int,
|
||||
) -> AwgActionResult:
|
||||
row = db.get_awg_config(config_id, telegram_id=telegram_id)
|
||||
if not row:
|
||||
return AwgActionResult(ok=False, message="Конфиг не найден")
|
||||
server = db.get_awg_server(int(row["server_id"]))
|
||||
if not server:
|
||||
return AwgActionResult(ok=False, message="Сервер не найден")
|
||||
try:
|
||||
async with _client_from_server(server) as client:
|
||||
data = await client.get_connection_config(
|
||||
int(server["panel_server_id"]),
|
||||
client_id=str(row["client_id"]),
|
||||
protocol=str(
|
||||
row.get("protocol") or server.get("protocol") or DEFAULT_PROTOCOL
|
||||
),
|
||||
)
|
||||
config_text = str(data.get("config") or row.get("config_text") or "")
|
||||
vpn_link = str(data.get("vpn_link") or "")
|
||||
if not vpn_link and config_text:
|
||||
import base64
|
||||
|
||||
vpn_link = "vpn://" + base64.b64encode(
|
||||
config_text.strip().encode("utf-8")
|
||||
).decode("utf-8")
|
||||
db.update_awg_config(
|
||||
config_id,
|
||||
config_text=config_text,
|
||||
vpn_link=vpn_link or row.get("vpn_link") or "",
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("AWG refresh failed")
|
||||
return AwgActionResult(ok=False, message=f"Не удалось обновить: {exc}")
|
||||
refreshed = db.get_awg_config(config_id, telegram_id=telegram_id)
|
||||
return AwgActionResult(
|
||||
ok=True,
|
||||
message="Обновлено",
|
||||
config=serialize_awg_config(refreshed, server) if refreshed else None,
|
||||
)
|
||||
|
||||
|
||||
async def sync_awg_configs_expiry(
|
||||
*,
|
||||
db: Database,
|
||||
sdk: RemnawaveSDK,
|
||||
telegram_id: int,
|
||||
login: str | None = None,
|
||||
restore_enabled: bool = False,
|
||||
) -> None:
|
||||
"""Обновляет expire_at у локальных AWG-конфигов по подписке Remnawave."""
|
||||
rows = db.list_awg_configs(telegram_id)
|
||||
if not rows:
|
||||
return
|
||||
bot_user = db.ensure_user(telegram_id)
|
||||
login = login or bot_user.login
|
||||
try:
|
||||
expire_at, _ = await _subscription_limits(
|
||||
sdk, telegram_id, login=login, db=db
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
return
|
||||
for row in rows:
|
||||
db.update_awg_config(int(row["id"]), expire_at=expire_at)
|
||||
if restore_enabled and not row.get("enabled"):
|
||||
server = db.get_awg_server(int(row["server_id"]))
|
||||
if not server:
|
||||
continue
|
||||
try:
|
||||
async with _client_from_server(server) as client:
|
||||
await client.toggle_connection(
|
||||
int(server["panel_server_id"]),
|
||||
client_id=str(row["client_id"]),
|
||||
enable=True,
|
||||
protocol=str(
|
||||
row.get("protocol")
|
||||
or server.get("protocol")
|
||||
or DEFAULT_PROTOCOL
|
||||
),
|
||||
)
|
||||
db.update_awg_config(int(row["id"]), enabled=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(
|
||||
"AWG restore enable failed config=%s: %s",
|
||||
row.get("id"),
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
async def _disable_awg_row(db: Database, row: dict[str, Any]) -> bool:
|
||||
if not row.get("enabled"):
|
||||
return False
|
||||
server = db.get_awg_server(int(row["server_id"]))
|
||||
if server:
|
||||
try:
|
||||
async with _client_from_server(server) as client:
|
||||
await client.toggle_connection(
|
||||
int(server["panel_server_id"]),
|
||||
client_id=str(row["client_id"]),
|
||||
enable=False,
|
||||
protocol=str(
|
||||
row.get("protocol") or server.get("protocol") or DEFAULT_PROTOCOL
|
||||
),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(
|
||||
"AWG auto-disable panel failed config=%s: %s",
|
||||
row.get("id"),
|
||||
exc,
|
||||
)
|
||||
db.update_awg_config(int(row["id"]), enabled=False)
|
||||
return True
|
||||
|
||||
|
||||
async def enforce_awg_configs_subscription(
|
||||
*,
|
||||
db: Database,
|
||||
sdk: RemnawaveSDK,
|
||||
telegram_id: int,
|
||||
login: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Нет активной подписки → выключить все AWG-конфиги на панели и в БД."""
|
||||
bot_user = db.ensure_user(telegram_id)
|
||||
login = login or bot_user.login
|
||||
active, reason = await subscription_is_active(
|
||||
sdk, telegram_id, login=login, db=db
|
||||
)
|
||||
disabled = 0
|
||||
if not active:
|
||||
for row in db.list_awg_configs(telegram_id):
|
||||
if await _disable_awg_row(db, row):
|
||||
disabled += 1
|
||||
return {
|
||||
"subscription_active": active,
|
||||
"reason": "" if active else reason,
|
||||
"disabled": disabled,
|
||||
}
|
||||
Reference in New Issue
Block a user