Initial commit: VPN Telegram bot (sanitized defaults)
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
"""Клиент Digiseller: проверка оплаты по уникальному коду."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
API_BASE = "https://api.digiseller.com"
|
||||
API_BASE_FALLBACK = "https://oplata.info"
|
||||
|
||||
|
||||
class DigisellerError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DigisellerPurchase:
|
||||
unique_code: str
|
||||
inv: int
|
||||
product_id: int
|
||||
amount: float
|
||||
currency: str
|
||||
cnt_goods: float
|
||||
email: str | None
|
||||
state: int
|
||||
raw: dict[str, Any]
|
||||
|
||||
@property
|
||||
def delivered(self) -> bool:
|
||||
# 2/3/4 — уже отмечено как выдано
|
||||
return self.state in {2, 3, 4}
|
||||
|
||||
|
||||
class Digiseller:
|
||||
def __init__(
|
||||
self,
|
||||
seller_id: str | int,
|
||||
api_key: str,
|
||||
*,
|
||||
product_ids: tuple[int, ...] = (),
|
||||
pay_url: str = "",
|
||||
credit_from: str = "cnt_goods",
|
||||
) -> None:
|
||||
self.seller_id = str(seller_id or "").strip()
|
||||
self.api_key = (api_key or "").strip()
|
||||
self.product_ids = product_ids
|
||||
self.pay_url = (pay_url or "").strip()
|
||||
self.credit_from = (credit_from or "cnt_goods").strip().lower()
|
||||
self._token: str | None = None
|
||||
self._token_expires: float = 0.0
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self.seller_id and self.api_key)
|
||||
|
||||
def _sign(self, timestamp: int) -> str:
|
||||
raw = f"{self.api_key}{timestamp}".encode("utf-8")
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
async def _login(self) -> str:
|
||||
now = time.time()
|
||||
if self._token and now < self._token_expires - 60:
|
||||
return self._token
|
||||
|
||||
timestamp = int(now)
|
||||
payload = {
|
||||
"seller_id": int(self.seller_id),
|
||||
"timestamp": timestamp,
|
||||
"sign": self._sign(timestamp),
|
||||
}
|
||||
data = await self._request("POST", "/api/apilogin", json_body=payload, auth=False)
|
||||
if int(data.get("retval") or -1) != 0 or not data.get("token"):
|
||||
raise DigisellerError(data.get("desc") or data.get("retdesc") or "login failed")
|
||||
self._token = str(data["token"])
|
||||
# токен живёт ~2 часа
|
||||
self._token_expires = now + 110 * 60
|
||||
return self._token
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json_body: dict[str, Any] | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
auth: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
if not self.enabled:
|
||||
raise DigisellerError("Digiseller не настроен")
|
||||
|
||||
headers = {"Accept": "application/json", "Content-Type": "application/json"}
|
||||
query = dict(params or {})
|
||||
if auth:
|
||||
query["token"] = await self._login()
|
||||
|
||||
last_exc: Exception | None = None
|
||||
for base in (API_BASE, API_BASE_FALLBACK):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.request(
|
||||
method,
|
||||
f"{base}{path}",
|
||||
headers=headers,
|
||||
params=query or None,
|
||||
json=json_body,
|
||||
)
|
||||
if resp.status_code == 401 and auth:
|
||||
self._token = None
|
||||
query["token"] = await self._login()
|
||||
resp = await client.request(
|
||||
method,
|
||||
f"{base}{path}",
|
||||
headers=headers,
|
||||
params=query,
|
||||
json=json_body,
|
||||
)
|
||||
data = resp.json()
|
||||
if not isinstance(data, dict):
|
||||
raise DigisellerError(f"bad response: {resp.text[:200]}")
|
||||
return data
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_exc = exc
|
||||
logger.warning("Digiseller %s %s via %s failed: %s", method, path, base, exc)
|
||||
raise DigisellerError(str(last_exc or "request failed"))
|
||||
|
||||
async def get_by_unique_code(self, unique_code: str) -> DigisellerPurchase:
|
||||
code = "".join(ch for ch in unique_code.strip() if ch.isalnum())
|
||||
if len(code) < 10:
|
||||
raise DigisellerError("Некорректный уникальный код")
|
||||
|
||||
data = await self._request(
|
||||
"GET",
|
||||
f"/api/purchases/unique-code/{code}",
|
||||
)
|
||||
retval = int(data.get("retval") if data.get("retval") is not None else -1)
|
||||
if retval != 0:
|
||||
raise DigisellerError(
|
||||
str(data.get("retdesc") or data.get("desc") or f"код ошибки {retval}")
|
||||
)
|
||||
|
||||
state_obj = data.get("unique_code_state") or {}
|
||||
state = int(state_obj.get("state") or 0)
|
||||
inv = int(data.get("inv") or 0)
|
||||
product_id = int(data.get("id_goods") or 0)
|
||||
if not inv:
|
||||
raise DigisellerError("В ответе Digiseller нет номера счёта (inv)")
|
||||
|
||||
return DigisellerPurchase(
|
||||
unique_code=code,
|
||||
inv=inv,
|
||||
product_id=product_id,
|
||||
amount=float(data.get("amount") or 0),
|
||||
currency=str(data.get("type_curr") or ""),
|
||||
cnt_goods=float(data.get("cnt_goods") or 0),
|
||||
email=(str(data.get("email")).strip() if data.get("email") else None),
|
||||
state=state,
|
||||
raw=data,
|
||||
)
|
||||
|
||||
def assert_payable(self, purchase: DigisellerPurchase) -> None:
|
||||
"""Проверка, что код можно принять к зачислению."""
|
||||
# 1 — ещё не верифицирован (первый запрос), 5 — верифицирован, товар не выдан
|
||||
# 2/3 — уже отмечен выданным (допускаем для дозачисления после сбоя)
|
||||
# 4 — оспорен
|
||||
if purchase.state == 4:
|
||||
raise DigisellerError("Оплата по этому коду оспорена")
|
||||
if purchase.state not in {1, 2, 3, 5}:
|
||||
raise DigisellerError(
|
||||
f"Код ещё не готов к выдаче (state={purchase.state}). "
|
||||
"Оплати товар и попробуй снова."
|
||||
)
|
||||
date_pay = str((purchase.raw or {}).get("date_pay") or "").strip()
|
||||
if not date_pay and purchase.state == 1:
|
||||
raise DigisellerError("Код ещё не оплачен")
|
||||
|
||||
async def mark_delivered(self, unique_code: str) -> None:
|
||||
code = "".join(ch for ch in unique_code.strip() if ch.isalnum())
|
||||
data = await self._request(
|
||||
"PUT",
|
||||
f"/api/purchases/unique-code/{code}/deliver",
|
||||
)
|
||||
retval = int(data.get("retval") if data.get("retval") is not None else -1)
|
||||
# 4 — уже выдано ранее (идемпотентно ок)
|
||||
if retval not in (0, 4):
|
||||
raise DigisellerError(
|
||||
str(data.get("retdesc") or data.get("desc") or f"deliver error {retval}")
|
||||
)
|
||||
|
||||
def credit_amount(self, purchase: DigisellerPurchase) -> float:
|
||||
"""Сколько рублей зачислить на баланс бота."""
|
||||
if self.product_ids and purchase.product_id not in self.product_ids:
|
||||
raise DigisellerError(
|
||||
f"Товар id={purchase.product_id} не разрешён для пополнения"
|
||||
)
|
||||
|
||||
if self.credit_from == "amount":
|
||||
value = purchase.amount
|
||||
else:
|
||||
# Типичная схема: товар без фикс. цены, cnt_goods = сумма в ₽
|
||||
value = purchase.cnt_goods if purchase.cnt_goods > 0 else purchase.amount
|
||||
|
||||
if value <= 0:
|
||||
raise DigisellerError("Сумма пополнения равна 0")
|
||||
return float(value)
|
||||
|
||||
def default_pay_url(self) -> str:
|
||||
if self.pay_url:
|
||||
return self.pay_url
|
||||
if self.product_ids:
|
||||
return f"https://oplata.info/asp2/pay.asp?id_d={self.product_ids[0]}"
|
||||
return "https://my.digiseller.com/"
|
||||
|
||||
def pay_url_for(self, telegram_id: int) -> str:
|
||||
"""Ссылка на оплату с привязкой к Telegram ID (уходит в query_string)."""
|
||||
base = self.default_pay_url()
|
||||
parsed = urlparse(base)
|
||||
q = parse_qs(parsed.query, keep_blank_values=True)
|
||||
q["telegram_id"] = [str(int(telegram_id))]
|
||||
# совместимость с разными интеграциями Digiseller
|
||||
q["tg_id"] = [str(int(telegram_id))]
|
||||
new_query = urlencode({k: v[-1] for k, v in q.items()})
|
||||
return urlunparse(parsed._replace(query=new_query))
|
||||
|
||||
@staticmethod
|
||||
def extract_telegram_id(purchase: DigisellerPurchase) -> int | None:
|
||||
"""Достать telegram_id из query_string / options платежа."""
|
||||
raw = purchase.raw or {}
|
||||
|
||||
qs_b64 = str(raw.get("query_string") or "").strip()
|
||||
if qs_b64:
|
||||
try:
|
||||
pad = "=" * (-len(qs_b64) % 4)
|
||||
decoded = base64.b64decode(qs_b64 + pad).decode("utf-8", "ignore")
|
||||
params = parse_qs(decoded, keep_blank_values=True)
|
||||
for key in ("telegram_id", "tg_id", "tg", "user_id", "uid"):
|
||||
vals = params.get(key) or []
|
||||
if vals:
|
||||
tid = int(str(vals[0]).strip())
|
||||
if tid > 0:
|
||||
return tid
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("Digiseller query_string parse failed", exc_info=True)
|
||||
|
||||
for opt in raw.get("options") or []:
|
||||
if not isinstance(opt, dict):
|
||||
continue
|
||||
name = str(opt.get("name") or "").strip().lower()
|
||||
value = str(opt.get("value") or "").strip()
|
||||
if not value:
|
||||
continue
|
||||
if any(
|
||||
x in name
|
||||
for x in ("telegram", "телеграм", "tg id", "tg_id", "userid", "user id")
|
||||
):
|
||||
digits = "".join(ch for ch in value if ch.isdigit())
|
||||
if digits:
|
||||
tid = int(digits)
|
||||
if tid > 0:
|
||||
return tid
|
||||
# иногда покупатель просто вписывает числовой ID
|
||||
if value.isdigit() and len(value) >= 5:
|
||||
return int(value)
|
||||
|
||||
return None
|
||||
Reference in New Issue
Block a user