diff --git a/.env.example b/.env.example index fbc64b9..73d19c1 100644 --- a/.env.example +++ b/.env.example @@ -31,3 +31,26 @@ XRAY_SHORT_ID_XHTTP= # Public hostname clients connect to for the local node (A record -> this server) DE1_ADDRESS=de1.example.com + +# Payments — off by default, bot keeps handing out free subscriptions on button press. +# Flip to true only once at least one provider below is configured and its webhook is live. +PAYMENTS_ENABLED=false + +# Prices in RUB per plan (whole numbers). Only used when PAYMENTS_ENABLED=true. +PRICE_7D=150 +PRICE_1M=399 +PRICE_3M=999 +PRICE_6M=1799 +PRICE_1Y=2999 + +# ЮKassa — https://yookassa.ru, shop id + secret key from your account settings. +# Webhook to add in their dashboard: https:///payments/webhook/yookassa +YOOKASSA_ENABLED=false +YOOKASSA_SHOP_ID= +YOOKASSA_SECRET_KEY= + +# Platega — https://platega.io, merchant id + secret from your manager. +# Webhook to add in their dashboard: https:///payments/webhook/platega +PLATEGA_ENABLED=false +PLATEGA_MERCHANT_ID= +PLATEGA_SECRET= diff --git a/api.py b/api.py index e9b5031..eebf73c 100644 --- a/api.py +++ b/api.py @@ -9,10 +9,11 @@ from fastapi.responses import HTMLResponse, PlainTextResponse, FileResponse import db import links import nodeprov +import payments import xray_manager from config import ( PLANS, PLANS_BY_CODE, SITE_DOMAIN, SUB_DOMAIN, PANEL_DOMAIN, - ADMIN_PANEL_PASSWORD, BOT_USERNAME, + ADMIN_PANEL_PASSWORD, BOT_USERNAME, BOT_TOKEN, ) db.init_db() @@ -251,6 +252,80 @@ def install_script(token: str): return nodeprov.render_install_script(node) +def _tg_send_message(tg_id: int, text: str): + import urllib.request + url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage" + data = json.dumps({"chat_id": tg_id, "text": text, "parse_mode": "HTML"}).encode() + req = urllib.request.Request(url, data=data, method="POST", headers={"Content-Type": "application/json"}) + try: + urllib.request.urlopen(req, timeout=10) + except Exception: + pass + + +def _grant_paid_subscription(payment_id: str): + payment = db.mark_payment_paid(payment_id) + if not payment: + return + plan = PLANS_BY_CODE.get(payment["plan"]) + node = db.get_node(payment["node"]) + if not plan or not node: + return + sub = db.create_subscription(payment["tg_id"], payment["node"], plan["days"], payment["plan"], source="payment") + xray_manager.add_client_to_node(node, sub["uuid"], email=sub["uuid"]) + user = db.get_or_create_user(payment["tg_id"], None) + _tg_send_message( + payment["tg_id"], + f"Оплата получена\n\n" + f"Сервер: {node['label']}\n" + f"Срок: {plan['label']} — до {sub['expires_at'][:10]}\n\n" + f"Ссылка-подписка:\nhttps://{SUB_DOMAIN}/sub/{user['token']}", + ) + + +@app.post("/payments/webhook/yookassa") +async def yookassa_webhook(request: Request): + body = await request.json() + if not payments.verify_yookassa_notification(body): + raise HTTPException(400, "unexpected event") + obj = body.get("object", {}) + if obj.get("status") != "succeeded": + return {"ok": True} + payment_id = (obj.get("metadata") or {}).get("payment_id") + if not payment_id: + raise HTTPException(400, "missing payment_id") + _grant_paid_subscription(payment_id) + return {"ok": True} + + +@app.post("/payments/webhook/platega") +async def platega_webhook(request: Request): + raw = await request.body() + signature = request.headers.get("x-signature") or request.headers.get("signature") or "" + if not payments.verify_platega_signature(raw, signature): + raise HTTPException(401, "bad signature") + body = json.loads(raw) + status = (body.get("status") or "").lower() + payment_id = body.get("id") or body.get("paymentId") + if status not in ("succeeded", "success", "paid") or not payment_id: + return {"ok": True} + _grant_paid_subscription(payment_id) + return {"ok": True} + + +@app.get("/pay/done", response_class=HTMLResponse) +def pay_done(): + return ( + "" + "" + "Оплата" + "" + "

Спасибо!

Возвращайся в Telegram — подписка придёт туда автоматически " + "в течение минуты после подтверждения оплаты.

" + ) + + @app.post("/nodes/register/{token}") async def register_node(token: str, request: Request): node = db.get_node_by_token(token) diff --git a/bot.py b/bot.py index 3605458..a26d77b 100644 --- a/bot.py +++ b/bot.py @@ -9,8 +9,9 @@ from aiogram.enums import ParseMode import db import links +import payments import xray_manager -from config import BOT_TOKEN, ADMIN_IDS, PLANS, PLANS_BY_CODE, SUB_DOMAIN, SITE_DOMAIN +from config import BOT_TOKEN, ADMIN_IDS, PLANS, PLANS_BY_CODE, SUB_DOMAIN, SITE_DOMAIN, PAYMENTS_ENABLED logging.basicConfig(level=logging.INFO) log = logging.getLogger("mbs-bot") @@ -49,7 +50,8 @@ def nodes_kb(prefix: str) -> InlineKeyboardMarkup: def plans_kb(prefix: str, node_code: str) -> InlineKeyboardMarkup: rows = [] for p in PLANS: - rows.append([InlineKeyboardButton(text=p["label"], callback_data=f"{prefix}:{node_code}:{p['code']}")]) + label = f"{p['label']} — {p['price']} ₽" if PAYMENTS_ENABLED and p["price"] > 0 else p["label"] + rows.append([InlineKeyboardButton(text=label, callback_data=f"{prefix}:{node_code}:{p['code']}")]) rows.append([InlineKeyboardButton(text="Назад", callback_data="menu:get")]) return InlineKeyboardMarkup(inline_keyboard=rows) @@ -145,10 +147,27 @@ async def cb_node(cb: CallbackQuery): await cb.answer() +def providers_kb(node_code: str, plan_code: str) -> InlineKeyboardMarkup: + rows = [] + for p in payments.available_providers(): + rows.append([InlineKeyboardButton(text=payments.PROVIDER_NAMES[p], callback_data=f"pay:{p}:{node_code}:{plan_code}")]) + rows.append([InlineKeyboardButton(text="Назад", callback_data=f"node:{node_code}")]) + return InlineKeyboardMarkup(inline_keyboard=rows) + + @dp.callback_query(F.data.startswith("plan:")) async def cb_plan(cb: CallbackQuery): _, node_code, plan_code = cb.data.split(":") plan = PLANS_BY_CODE[plan_code] + db.get_or_create_user(cb.from_user.id, cb.from_user.username) + + if PAYMENTS_ENABLED and plan["price"] > 0 and payments.available_providers(): + await cb.message.edit_text( + f"{plan['label']} — {plan['price']} ₽\n\nВыбери способ оплаты:", + reply_markup=providers_kb(node_code, plan_code), + ) + return await cb.answer() + user = db.get_or_create_user(cb.from_user.id, cb.from_user.username) sub = db.create_subscription(cb.from_user.id, node_code, plan["days"], plan_code, source="bot") node_row = db.get_node(node_code) @@ -168,6 +187,33 @@ async def cb_plan(cb: CallbackQuery): await cb.answer("Подписка выдана") +@dp.callback_query(F.data.startswith("pay:")) +async def cb_pay(cb: CallbackQuery): + _, provider, node_code, plan_code = cb.data.split(":") + plan = PLANS_BY_CODE[plan_code] + node_row = db.get_node(node_code) + payment_id = payments.new_payment_id() + db.create_payment(payment_id, cb.from_user.id, node_code, plan_code, provider, plan["price"]) + try: + external_id, pay_url = payments.create_payment_link( + provider, payment_id, plan["price"], f"MBS Panel — {node_row['label']}, {plan['label']}", + ) + except Exception: + log.exception("payment creation failed") + db.mark_payment_failed(payment_id) + return await cb.answer("Не получилось создать платёж, попробуй позже", show_alert=True) + db.set_payment_external(payment_id, external_id, pay_url) + kb = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="Оплатить", url=pay_url)], + [InlineKeyboardButton(text="Назад", callback_data=f"plan:{node_code}:{plan_code}")], + ]) + await cb.message.edit_text( + f"Счёт на {plan['price']} ₽ создан.\nПосле оплаты подписка выдастся автоматически.", + reply_markup=kb, + ) + await cb.answer() + + @dp.callback_query(F.data == "menu:mysub") async def cb_mysub(cb: CallbackQuery): user = db.get_or_create_user(cb.from_user.id, cb.from_user.username) diff --git a/config.py b/config.py index 3f3e06c..94e0e2a 100644 --- a/config.py +++ b/config.py @@ -98,10 +98,20 @@ DE1_TRANSPORTS = [ ] PLANS = [ - {"code": "7d", "label": "7 дней", "days": 7}, - {"code": "1m", "label": "1 месяц", "days": 30}, - {"code": "3m", "label": "3 месяца", "days": 90}, - {"code": "6m", "label": "6 месяцев", "days": 180}, - {"code": "1y", "label": "1 год", "days": 365}, + {"code": "7d", "label": "7 дней", "days": 7, "price": int(env("PRICE_7D", "150"))}, + {"code": "1m", "label": "1 месяц", "days": 30, "price": int(env("PRICE_1M", "399"))}, + {"code": "3m", "label": "3 месяца", "days": 90, "price": int(env("PRICE_3M", "999"))}, + {"code": "6m", "label": "6 месяцев", "days": 180, "price": int(env("PRICE_6M", "1799"))}, + {"code": "1y", "label": "1 год", "days": 365, "price": int(env("PRICE_1Y", "2999"))}, ] PLANS_BY_CODE = {p["code"]: p for p in PLANS} + +PAYMENTS_ENABLED = env("PAYMENTS_ENABLED", "false").lower() == "true" + +YOOKASSA_ENABLED = env("YOOKASSA_ENABLED", "false").lower() == "true" +YOOKASSA_SHOP_ID = env("YOOKASSA_SHOP_ID", "") +YOOKASSA_SECRET_KEY = env("YOOKASSA_SECRET_KEY", "") + +PLATEGA_ENABLED = env("PLATEGA_ENABLED", "false").lower() == "true" +PLATEGA_MERCHANT_ID = env("PLATEGA_MERCHANT_ID", "") +PLATEGA_SECRET = env("PLATEGA_SECRET", "") diff --git a/db.py b/db.py index 0a1a215..209ec5a 100644 --- a/db.py +++ b/db.py @@ -40,6 +40,20 @@ CREATE TABLE IF NOT EXISTS admin_sessions ( expires_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS payments ( + id TEXT PRIMARY KEY, + tg_id INTEGER NOT NULL, + node TEXT NOT NULL, + plan TEXT NOT NULL, + provider TEXT NOT NULL, + external_id TEXT, + amount INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + pay_url TEXT, + created_at TEXT NOT NULL, + paid_at TEXT +); + CREATE TABLE IF NOT EXISTS nodes ( code TEXT PRIMARY KEY, label TEXT NOT NULL, @@ -342,3 +356,57 @@ def stats(): "gifts_created": gifts_created, "gifts_used": gifts_used, } + + +def create_payment(payment_id: str, tg_id: int, node: str, plan: str, provider: str, amount: int): + with get_conn() as conn: + conn.execute( + "INSERT INTO payments (id, tg_id, node, plan, provider, amount, status, created_at) " + "VALUES (?,?,?,?,?,?, 'pending', ?)", + (payment_id, tg_id, node, plan, provider, amount, now_iso()), + ) + return get_payment(payment_id) + + +def get_payment(payment_id: str): + with get_conn() as conn: + row = conn.execute("SELECT * FROM payments WHERE id=?", (payment_id,)).fetchone() + return dict(row) if row else None + + +def set_payment_external(payment_id: str, external_id: str, pay_url: str): + with get_conn() as conn: + conn.execute( + "UPDATE payments SET external_id=?, pay_url=? WHERE id=?", + (external_id, pay_url, payment_id), + ) + return get_payment(payment_id) + + +def mark_payment_paid(payment_id: str): + with get_conn() as conn: + row = conn.execute("SELECT status FROM payments WHERE id=?", (payment_id,)).fetchone() + if not row or row["status"] == "paid": + return None + conn.execute( + "UPDATE payments SET status='paid', paid_at=? WHERE id=?", + (now_iso(), payment_id), + ) + return get_payment(payment_id) + + +def mark_payment_failed(payment_id: str): + with get_conn() as conn: + conn.execute( + "UPDATE payments SET status='failed' WHERE id=? AND status='pending'", + (payment_id,), + ) + return get_payment(payment_id) + + +def list_payments(limit: int = 200): + with get_conn() as conn: + rows = conn.execute( + "SELECT * FROM payments ORDER BY created_at DESC LIMIT ?", (limit,) + ).fetchall() + return [dict(r) for r in rows] diff --git a/payments.py b/payments.py new file mode 100644 index 0000000..4449def --- /dev/null +++ b/payments.py @@ -0,0 +1,96 @@ +import base64 +import hashlib +import hmac +import json +import secrets +import urllib.request + +from config import ( + PANEL_DOMAIN, + YOOKASSA_ENABLED, YOOKASSA_SHOP_ID, YOOKASSA_SECRET_KEY, + PLATEGA_ENABLED, PLATEGA_MERCHANT_ID, PLATEGA_SECRET, +) + +PROVIDER_NAMES = {"yookassa": "ЮKassa", "platega": "Platega"} + + +def available_providers() -> list[str]: + providers = [] + if YOOKASSA_ENABLED: + providers.append("yookassa") + if PLATEGA_ENABLED: + providers.append("platega") + return providers + + +def new_payment_id() -> str: + return secrets.token_hex(16) + + +def _post_json(url: str, body: dict, headers: dict, timeout: int = 15) -> dict: + data = json.dumps(body).encode() + req = urllib.request.Request(url, data=data, method="POST", headers=headers) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode()) + + +def create_yookassa_payment(payment_id: str, amount_rub: int, description: str) -> str: + auth = base64.b64encode(f"{YOOKASSA_SHOP_ID}:{YOOKASSA_SECRET_KEY}".encode()).decode() + data = _post_json( + "https://api.yookassa.ru/v3/payments", + { + "amount": {"value": f"{amount_rub}.00", "currency": "RUB"}, + "confirmation": {"type": "redirect", "return_url": f"https://{PANEL_DOMAIN}/pay/done"}, + "capture": True, + "description": description, + "metadata": {"payment_id": payment_id}, + }, + { + "Content-Type": "application/json", + "Authorization": f"Basic {auth}", + "Idempotence-Key": payment_id, + }, + ) + external_id = data["id"] + pay_url = data["confirmation"]["confirmation_url"] + return external_id, pay_url + + +def verify_yookassa_notification(body: dict) -> bool: + return body.get("event") == "payment.succeeded" and "object" in body + + +def create_platega_payment(payment_id: str, amount_rub: int, description: str) -> str: + data = _post_json( + "https://app.platega.io/transaction/process", + { + "paymentMethod": 2, + "id": payment_id, + "paymentDetails": {"amount": amount_rub, "currency": "RUB"}, + "description": description, + "return": f"https://{PANEL_DOMAIN}/pay/done", + }, + { + "Content-Type": "application/json", + "X-MerchantId": PLATEGA_MERCHANT_ID, + "X-Secret": PLATEGA_SECRET, + }, + ) + external_id = data.get("id") or data.get("transactionId") + pay_url = data.get("redirectUrl") or data.get("url") + return external_id, pay_url + + +def verify_platega_signature(raw_body: bytes, signature: str) -> bool: + if not signature: + return False + expected = hmac.new(PLATEGA_SECRET.encode(), raw_body, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, signature) + + +def create_payment_link(provider: str, payment_id: str, amount_rub: int, description: str): + if provider == "yookassa": + return create_yookassa_payment(payment_id, amount_rub, description) + if provider == "platega": + return create_platega_payment(payment_id, amount_rub, description) + raise ValueError(f"unknown provider: {provider}")