mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-25 13:51:50 +00:00
- Add pyproject.toml with uv and ruff configuration - Pin Python version to 3.13 via .python-version - Add Makefile commands: lint, format, fix - Apply ruff formatting to entire codebase - Remove unused imports (base64 in yookassa/simple_subscription) - Update .gitignore for new config files
178 lines
5.6 KiB
Python
178 lines
5.6 KiB
Python
"""HTTP client for Heleket payment API."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
from typing import Any
|
|
|
|
import aiohttp
|
|
|
|
from app.config import settings
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class HeleketService:
|
|
"""Minimal wrapper around Heleket API endpoints."""
|
|
|
|
def __init__(self) -> None:
|
|
self.base_url = settings.HELEKET_BASE_URL.rstrip('/')
|
|
self.merchant_id = settings.HELEKET_MERCHANT_ID
|
|
self.api_key = settings.HELEKET_API_KEY
|
|
|
|
@property
|
|
def is_configured(self) -> bool:
|
|
return bool(self.merchant_id and self.api_key)
|
|
|
|
def _prepare_body(
|
|
self,
|
|
payload: dict[str, Any],
|
|
*,
|
|
ignore_none: bool,
|
|
sort_keys: bool,
|
|
) -> str:
|
|
if ignore_none:
|
|
cleaned = {key: value for key, value in payload.items() if value is not None}
|
|
else:
|
|
cleaned = dict(payload)
|
|
|
|
serialized = json.dumps(
|
|
cleaned,
|
|
ensure_ascii=False,
|
|
separators=(',', ':'),
|
|
sort_keys=sort_keys,
|
|
)
|
|
|
|
if '/' in serialized:
|
|
serialized = serialized.replace('/', '\\/')
|
|
|
|
return serialized
|
|
|
|
def _generate_signature(self, body: str) -> str:
|
|
api_key = self.api_key or ''
|
|
encoded = base64.b64encode(body.encode('utf-8')).decode('utf-8')
|
|
raw = f'{encoded}{api_key}'
|
|
return hashlib.md5(raw.encode('utf-8')).hexdigest()
|
|
|
|
async def _request(
|
|
self,
|
|
endpoint: str,
|
|
payload: dict[str, Any],
|
|
*,
|
|
params: dict[str, Any] | None = None,
|
|
) -> dict[str, Any] | None:
|
|
if not self.is_configured:
|
|
logger.error('Heleket сервис не настроен: merchant или api_key отсутствуют')
|
|
return None
|
|
|
|
body = self._prepare_body(payload, ignore_none=True, sort_keys=True)
|
|
signature = self._generate_signature(body)
|
|
|
|
url = f'{self.base_url}/{endpoint.lstrip("/")}'
|
|
headers = {
|
|
'merchant': self.merchant_id or '',
|
|
'sign': signature,
|
|
'Content-Type': 'application/json',
|
|
}
|
|
|
|
try:
|
|
timeout = aiohttp.ClientTimeout(total=30)
|
|
async with (
|
|
aiohttp.ClientSession(timeout=timeout) as session,
|
|
session.post(
|
|
url,
|
|
data=body.encode('utf-8'),
|
|
headers=headers,
|
|
params=params,
|
|
) as response,
|
|
):
|
|
text = await response.text()
|
|
if response.content_type != 'application/json':
|
|
logger.error('Ответ Heleket не JSON (%s): %s', response.content_type, text)
|
|
return None
|
|
|
|
try:
|
|
data = json.loads(text)
|
|
except json.JSONDecodeError:
|
|
logger.error('Ошибка парсинга Heleket JSON: %s', text)
|
|
return None
|
|
|
|
if response.status >= 400:
|
|
logger.error('Heleket API %s вернул статус %s: %s', endpoint, response.status, data)
|
|
return None
|
|
|
|
if isinstance(data, dict) and data.get('state') == 0:
|
|
return data
|
|
|
|
logger.error('Heleket API вернул ошибку: %s', data)
|
|
return None
|
|
except Exception as error: # pragma: no cover - defensive
|
|
logger.error('Ошибка запроса к Heleket API: %s', error)
|
|
return None
|
|
|
|
async def create_payment(self, payload: dict[str, Any]) -> dict[str, Any] | None:
|
|
return await self._request('payment', payload)
|
|
|
|
async def get_payment_info(
|
|
self,
|
|
*,
|
|
uuid: str | None = None,
|
|
order_id: str | None = None,
|
|
) -> dict[str, Any] | None:
|
|
if not uuid and not order_id:
|
|
raise ValueError('Нужно указать uuid или order_id для Heleket payment/info')
|
|
|
|
payload: dict[str, Any] = {}
|
|
if uuid:
|
|
payload['uuid'] = uuid
|
|
if order_id:
|
|
payload['order_id'] = order_id
|
|
|
|
return await self._request('payment/info', payload)
|
|
|
|
async def list_payments(
|
|
self,
|
|
*,
|
|
date_from: str | None = None,
|
|
date_to: str | None = None,
|
|
cursor: str | None = None,
|
|
) -> dict[str, Any] | None:
|
|
payload: dict[str, Any] = {}
|
|
if date_from:
|
|
payload['date_from'] = date_from
|
|
if date_to:
|
|
payload['date_to'] = date_to
|
|
|
|
params = {'cursor': cursor} if cursor else None
|
|
return await self._request('payment/list', payload, params=params)
|
|
|
|
def verify_webhook_signature(self, payload: dict[str, Any]) -> bool:
|
|
if not self.is_configured:
|
|
logger.warning('Heleket сервис не настроен, подпись пропускается')
|
|
return True
|
|
|
|
if not isinstance(payload, dict):
|
|
logger.error('Heleket webhook payload не dict: %s', payload)
|
|
return False
|
|
|
|
signature = payload.get('sign')
|
|
if not signature:
|
|
logger.error('Heleket webhook без подписи')
|
|
return False
|
|
|
|
data = dict(payload)
|
|
data.pop('sign', None)
|
|
body = self._prepare_body(data, ignore_none=False, sort_keys=False)
|
|
expected = self._generate_signature(body)
|
|
|
|
is_valid = hmac.compare_digest(expected, str(signature))
|
|
|
|
if not is_valid:
|
|
logger.error('Неверная подпись Heleket webhook: ожидается %s, получено %s', expected, signature)
|
|
return is_valid
|