Files
remnawave-bedolaga-telegram…/app/external/heleket.py
Fringg 1f0fef114b refactor: complete structlog migration with contextvars, kwargs, and logging hardening
- Add ContextVarsMiddleware for automatic user_id/chat_id/username binding
  via structlog contextvars (aiogram) and http_method/http_path (FastAPI)
- Use bound_contextvars() context manager instead of clear_contextvars()
  to safely restore previous state instead of wiping all context
- Register ContextVarsMiddleware as outermost middleware (before GlobalError)
  so all error logs include user context
- Replace structlog.get_logger() with structlog.get_logger(__name__) across
  270 calls in 265 files for meaningful logger names
- Switch wrapper_class from BoundLogger to make_filtering_bound_logger()
  for pre-processor level filtering (performance optimization)
- Migrate 1411 %-style positional arg logger calls to structlog kwargs
  style across 161 files via AST script
- Migrate log_rotation_service.py from stdlib logging to structlog
- Add payment module prefixes to TelegramNotifierProcessor.IGNORED_LOGGER_PREFIXES
  and ExcludePaymentFilter.PAYMENT_MODULES to prevent payment data leaking
  to Telegram notifications and general log files
- Fix LoggingMiddleware: add from_user null-safety for channel posts,
  switch time.time() to time.monotonic() for duration measurement
- Remove duplicate logger assignments in purchase.py, config.py,
  inline.py, and admin/payments.py
2026-02-16 09:18:12 +03:00

182 lines
5.8 KiB
Python

"""HTTP client for Heleket payment API."""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
from typing import Any
import aiohttp
import structlog
from app.config import settings
logger = structlog.get_logger(__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', content_type=response.content_type, text=text)
return None
try:
data = json.loads(text)
except json.JSONDecodeError:
logger.error('Ошибка парсинга Heleket JSON', text=text)
return None
if response.status >= 400:
logger.error(
'Heleket API вернул статус', endpoint=endpoint, response_status=response.status, data=data
)
return None
if isinstance(data, dict) and data.get('state') == 0:
return data
logger.error('Heleket API вернул ошибку', data=data)
return None
except Exception as error: # pragma: no cover - defensive
logger.error('Ошибка запроса к Heleket API', error=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', payload=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: ожидается , получено', expected=expected, signature=signature
)
return is_valid