Initial commit: VPN Telegram bot (sanitized defaults)
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
"""Клиент Lava.ru Business API (карта / СБП)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
API_BASE = "https://api.lava.ru"
|
||||
PAID_STATUSES = frozenset({"success", "paid"})
|
||||
|
||||
|
||||
class LavaError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LavaInvoice:
|
||||
invoice_id: str
|
||||
order_id: str
|
||||
amount: float
|
||||
status: str
|
||||
pay_url: str
|
||||
expired: str | None = None
|
||||
|
||||
|
||||
def _json_body(payload: dict[str, Any]) -> bytes:
|
||||
# Порядок ключей и separators критичны для Signature
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def sign_request(payload: dict[str, Any], secret_key: str) -> str:
|
||||
return hmac.new(
|
||||
secret_key.encode("utf-8"),
|
||||
_json_body(payload),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def verify_webhook(
|
||||
*,
|
||||
data: dict[str, Any],
|
||||
raw_body: bytes,
|
||||
authorization: str | None,
|
||||
additional_key: str,
|
||||
) -> bool:
|
||||
"""Проверка подписи webhook (два формата Lava)."""
|
||||
key = (additional_key or "").strip()
|
||||
if not key:
|
||||
# без доп. ключа принимаем только если нет подписи вообще (нежелательно)
|
||||
return not data.get("sign") and not (authorization or "").strip()
|
||||
|
||||
# Новый формат SDK: HMAC SHA256(raw body) в Authorization
|
||||
auth = (authorization or "").strip()
|
||||
if auth:
|
||||
expected = hmac.new(key.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
|
||||
if hmac.compare_digest(auth.lower(), expected.lower()):
|
||||
return True
|
||||
|
||||
# Классический формат: md5(invoice_id:amount:pay_time:secret_key_2)
|
||||
received = str(data.get("sign") or "").strip()
|
||||
if received:
|
||||
invoice_id = str(data.get("invoice_id") or "")
|
||||
amount = data.get("amount")
|
||||
pay_time = data.get("pay_time")
|
||||
# amount иногда float — приводим как в доке (как пришло / без лишних нулей)
|
||||
amount_s = str(amount)
|
||||
raw = f"{invoice_id}:{amount_s}:{pay_time}:{key}"
|
||||
expected_md5 = hashlib.md5(raw.encode("utf-8")).hexdigest()
|
||||
if hmac.compare_digest(received.lower(), expected_md5.lower()):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class Lava:
|
||||
def __init__(
|
||||
self,
|
||||
shop_id: str,
|
||||
secret_key: str,
|
||||
*,
|
||||
additional_key: str = "",
|
||||
hook_url: str = "",
|
||||
success_url: str = "",
|
||||
fail_url: str = "",
|
||||
expire_minutes: int = 60,
|
||||
) -> None:
|
||||
self.shop_id = (shop_id or "").strip()
|
||||
self.secret_key = (secret_key or "").strip()
|
||||
self.additional_key = (additional_key or "").strip()
|
||||
self.hook_url = (hook_url or "").strip().rstrip("/")
|
||||
self.success_url = (success_url or "").strip()
|
||||
self.fail_url = (fail_url or "").strip()
|
||||
self.expire_minutes = max(5, min(int(expire_minutes or 60), 5 * 24 * 60))
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self.shop_id and self.secret_key)
|
||||
|
||||
async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if not self.enabled:
|
||||
raise LavaError("Lava не настроен (LAVA_SHOP_ID / LAVA_SECRET_KEY)")
|
||||
body = _json_body(payload)
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Signature": sign_request(payload, self.secret_key),
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(
|
||||
f"{API_BASE}{path}",
|
||||
headers=headers,
|
||||
content=body,
|
||||
)
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise LavaError(f"HTTP {resp.status_code}: {resp.text[:300]}") from exc
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise LavaError(f"Неожиданный ответ: {data}")
|
||||
|
||||
if resp.status_code >= 400 or not data.get("status_check", True):
|
||||
err = data.get("error") or data.get("message") or data
|
||||
raise LavaError(str(err))
|
||||
|
||||
result = data.get("data")
|
||||
if not isinstance(result, dict):
|
||||
raise LavaError(f"Нет data в ответе: {data}")
|
||||
return result
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
*,
|
||||
amount_rub: float,
|
||||
order_id: str,
|
||||
comment: str = "",
|
||||
custom_fields: str = "",
|
||||
) -> LavaInvoice:
|
||||
# Порядок ключей фиксируем — от него зависит Signature
|
||||
payload: dict[str, Any] = {
|
||||
"shopId": self.shop_id,
|
||||
"sum": float(f"{amount_rub:.2f}"),
|
||||
"orderId": order_id,
|
||||
}
|
||||
if comment:
|
||||
payload["comment"] = comment[:255]
|
||||
if custom_fields:
|
||||
payload["customFields"] = custom_fields[:500]
|
||||
if self.fail_url:
|
||||
payload["failUrl"] = self.fail_url
|
||||
if self.success_url:
|
||||
payload["successUrl"] = self.success_url
|
||||
if self.hook_url:
|
||||
payload["hookUrl"] = self.hook_url
|
||||
payload["expire"] = self.expire_minutes
|
||||
|
||||
result = await self._post("/business/invoice/create", payload)
|
||||
return self._parse(result, order_id_fallback=order_id)
|
||||
|
||||
async def get_invoice(
|
||||
self,
|
||||
*,
|
||||
order_id: str | None = None,
|
||||
invoice_id: str | None = None,
|
||||
) -> LavaInvoice | None:
|
||||
payload: dict[str, Any] = {"shopId": self.shop_id}
|
||||
if order_id:
|
||||
payload["orderId"] = order_id
|
||||
if invoice_id:
|
||||
payload["invoiceId"] = invoice_id
|
||||
if "orderId" not in payload and "invoiceId" not in payload:
|
||||
raise LavaError("Нужен order_id или invoice_id")
|
||||
try:
|
||||
result = await self._post("/business/invoice/status", payload)
|
||||
except LavaError:
|
||||
return None
|
||||
return self._parse(result, order_id_fallback=order_id or "")
|
||||
|
||||
@staticmethod
|
||||
def is_paid(status: str) -> bool:
|
||||
return str(status or "").lower() in PAID_STATUSES
|
||||
|
||||
@staticmethod
|
||||
def _parse(raw: dict[str, Any], *, order_id_fallback: str = "") -> LavaInvoice:
|
||||
return LavaInvoice(
|
||||
invoice_id=str(raw.get("id") or raw.get("invoice_id") or ""),
|
||||
order_id=str(raw.get("orderId") or raw.get("order_id") or order_id_fallback),
|
||||
amount=float(raw.get("amount") or raw.get("sum") or 0),
|
||||
status=str(raw.get("status") or ""),
|
||||
pay_url=str(raw.get("url") or raw.get("payUrl") or ""),
|
||||
expired=str(raw.get("expired") or raw.get("expire") or "") or None,
|
||||
)
|
||||
Reference in New Issue
Block a user