588 lines
20 KiB
Python
588 lines
20 KiB
Python
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from aiocryptopay import AioCryptoPay, Networks
|
|
from aiocryptopay.models.update import Update
|
|
from aiogram import Bot, F, Router, types
|
|
from aiohttp import web
|
|
from pydantic import Field, field_validator
|
|
from pydantic_settings import SettingsConfigDict
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from bot.middlewares.i18n import JsonI18n
|
|
from bot.services.referral_service import ReferralService
|
|
from bot.services.subscription_service import SubscriptionService
|
|
from config.settings import Settings
|
|
from db.dal import payment_dal
|
|
|
|
from .base import (
|
|
PaymentProviderSpec,
|
|
ProviderEnvConfig,
|
|
ProviderManifestField,
|
|
ServiceFactoryContext,
|
|
WebAppPaymentContext,
|
|
provider_env_file,
|
|
provider_runtime_enabled,
|
|
)
|
|
from .shared import (
|
|
PaymentSuccessRequest,
|
|
describe_payment,
|
|
finalize_successful_payment,
|
|
make_translator,
|
|
notify_callback_parse_error,
|
|
notify_service_unavailable,
|
|
parse_payment_callback,
|
|
payment_failed,
|
|
payment_link_response,
|
|
payment_record_amounts,
|
|
payment_unavailable,
|
|
quote_hwid_callback_parts,
|
|
render_payment_link,
|
|
sale_mode_base,
|
|
sale_mode_is_traffic,
|
|
sale_mode_tariff_key,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
_LOG = "cryptopay"
|
|
|
|
|
|
class CryptoPayConfig(ProviderEnvConfig):
|
|
model_config = SettingsConfigDict(
|
|
env_file=provider_env_file(),
|
|
env_file_encoding="utf-8",
|
|
env_prefix="CRYPTOPAY_",
|
|
extra="ignore",
|
|
)
|
|
|
|
ENABLED: bool = Field(default=True)
|
|
TOKEN: Optional[str] = None
|
|
NETWORK: str = Field(default="mainnet")
|
|
CURRENCY_TYPE: str = Field(default="fiat")
|
|
ASSET: str = Field(default="RUB")
|
|
|
|
@field_validator("TOKEN", mode="before")
|
|
@classmethod
|
|
def _strip_optional(cls, v):
|
|
if isinstance(v, str) and not v.strip():
|
|
return None
|
|
return v
|
|
|
|
@property
|
|
def webhook_path(self) -> str:
|
|
return "/webhook/cryptopay"
|
|
|
|
|
|
class CryptoPayPresentation(ProviderEnvConfig):
|
|
model_config = SettingsConfigDict(
|
|
env_file=provider_env_file(),
|
|
env_file_encoding="utf-8",
|
|
env_prefix="PAYMENT_CRYPTOPAY_",
|
|
extra="ignore",
|
|
)
|
|
|
|
WEBAPP_LABEL_RU: Optional[str] = None
|
|
WEBAPP_LABEL_EN: Optional[str] = None
|
|
WEBAPP_ICON: Optional[str] = None
|
|
TELEGRAM_LABEL_RU: Optional[str] = None
|
|
TELEGRAM_LABEL_EN: Optional[str] = None
|
|
TELEGRAM_EMOJI: Optional[str] = None
|
|
|
|
|
|
class CryptoPayService:
|
|
def __init__(
|
|
self,
|
|
bot: Bot,
|
|
settings: Settings,
|
|
config: CryptoPayConfig,
|
|
i18n: JsonI18n,
|
|
async_session_factory: sessionmaker,
|
|
subscription_service: SubscriptionService,
|
|
referral_service: ReferralService,
|
|
):
|
|
self.bot = bot
|
|
self.settings = settings
|
|
self.config = config
|
|
self.i18n = i18n
|
|
self.async_session_factory = async_session_factory
|
|
self.subscription_service = subscription_service
|
|
self.referral_service = referral_service
|
|
self._client = None
|
|
self._client_token = None
|
|
self._client_network = None
|
|
if not self.config.TOKEN:
|
|
logging.warning("CryptoPay token not provided. CryptoPay disabled")
|
|
|
|
@property
|
|
def token(self):
|
|
return self.config.TOKEN
|
|
|
|
@property
|
|
def configured(self) -> bool:
|
|
return bool(provider_runtime_enabled(self.config) and self.config.TOKEN)
|
|
|
|
@property
|
|
def client(self):
|
|
# Recreate the SDK client whenever the admin changes the token / network
|
|
# at runtime — otherwise we'd keep talking to the old account.
|
|
token = self.config.TOKEN
|
|
network = self.config.NETWORK
|
|
if not token:
|
|
return None
|
|
if self._client is None or token != self._client_token or network != self._client_network:
|
|
net = Networks.TEST_NET if str(network).lower() == "testnet" else Networks.MAIN_NET
|
|
self._client = AioCryptoPay(token=token, network=net)
|
|
self._client.register_pay_handler(self._invoice_paid_handler)
|
|
self._client_token = token
|
|
self._client_network = network
|
|
return self._client
|
|
|
|
async def close(self):
|
|
if self._client:
|
|
try:
|
|
await self._client.close()
|
|
logging.info("CryptoPay client session closed.")
|
|
except Exception as e:
|
|
logging.warning("Failed to close CryptoPay client: %s", e)
|
|
|
|
async def create_invoice(
|
|
self,
|
|
session: AsyncSession,
|
|
user_id: int,
|
|
months: int,
|
|
amount: float,
|
|
description: str,
|
|
sale_mode: str = "subscription",
|
|
url_kind: str = "bot",
|
|
hwid_quote: Optional[dict] = None,
|
|
) -> Optional[str]:
|
|
if not self.configured or not self.client:
|
|
logging.error("CryptoPayService not configured")
|
|
return None
|
|
|
|
sale_base = sale_mode_base(sale_mode)
|
|
amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
|
|
try:
|
|
payment_record = await payment_dal.create_payment_record(
|
|
session,
|
|
{
|
|
"user_id": user_id,
|
|
"amount": float(amount),
|
|
"currency": self.config.ASSET,
|
|
"status": "pending_cryptopay",
|
|
"description": description,
|
|
"subscription_duration_months": (
|
|
int(months) if sale_base == "subscription" else None
|
|
),
|
|
"provider": "cryptopay",
|
|
"sale_mode": sale_mode,
|
|
"tariff_key": sale_mode_tariff_key(sale_mode),
|
|
"purchased_gb": amounts.purchased_gb,
|
|
"purchased_hwid_devices": amounts.purchased_hwid_devices,
|
|
"hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None,
|
|
"hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
|
|
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
|
|
if hwid_quote
|
|
else None,
|
|
"hwid_proration_ratio": hwid_quote.get("proration_ratio")
|
|
if hwid_quote
|
|
else None,
|
|
"hwid_full_price": hwid_quote.get("full_price") if hwid_quote else None,
|
|
},
|
|
)
|
|
await session.commit()
|
|
except Exception:
|
|
await session.rollback()
|
|
logging.exception("Failed to create cryptopay payment record for user %s.", user_id)
|
|
return None
|
|
|
|
payload = json.dumps(
|
|
{
|
|
"user_id": str(user_id),
|
|
"subscription_months": str(months),
|
|
"payment_db_id": str(payment_record.payment_id),
|
|
"sale_mode": sale_mode,
|
|
"traffic_gb": str(months) if sale_mode_is_traffic(sale_mode) else None,
|
|
}
|
|
)
|
|
try:
|
|
invoice = await self.client.create_invoice(
|
|
amount=amount,
|
|
currency_type=self.config.CURRENCY_TYPE,
|
|
fiat=self.config.ASSET if self.config.CURRENCY_TYPE == "fiat" else None,
|
|
asset=self.config.ASSET if self.config.CURRENCY_TYPE == "crypto" else None,
|
|
description=description,
|
|
payload=payload,
|
|
)
|
|
try:
|
|
await payment_dal.update_provider_payment_and_status(
|
|
session,
|
|
payment_record.payment_id,
|
|
str(invoice.invoice_id),
|
|
str(invoice.status),
|
|
)
|
|
await session.commit()
|
|
except Exception:
|
|
await session.rollback()
|
|
logging.exception(
|
|
"Failed to update cryptopay payment record %s.",
|
|
payment_record.payment_id,
|
|
)
|
|
return None
|
|
if url_kind == "web":
|
|
return (
|
|
getattr(invoice, "web_app_invoice_url", None)
|
|
or getattr(invoice, "mini_app_invoice_url", None)
|
|
or invoice.bot_invoice_url
|
|
)
|
|
return invoice.bot_invoice_url
|
|
except Exception:
|
|
logging.exception("CryptoPay invoice creation failed.")
|
|
return None
|
|
|
|
async def _invoice_paid_handler(self, update: Update, app: web.Application):
|
|
invoice = update.payload
|
|
if not invoice.payload:
|
|
logging.warning("CryptoPay webhook without payload")
|
|
return
|
|
try:
|
|
meta = json.loads(invoice.payload)
|
|
user_id = int(meta["user_id"])
|
|
months = float(meta.get("subscription_months") or 0)
|
|
payment_db_id = int(meta["payment_db_id"])
|
|
sale_mode = meta.get("sale_mode") or (
|
|
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
|
)
|
|
traffic_gb = float(meta.get("traffic_gb")) if meta.get("traffic_gb") else months
|
|
except Exception:
|
|
logging.exception("Failed to parse CryptoPay payload.")
|
|
return
|
|
|
|
async_session_factory: sessionmaker = app["async_session_factory"]
|
|
bot: Bot = app["bot"]
|
|
settings: Settings = app["settings"]
|
|
i18n: JsonI18n = app["i18n"]
|
|
subscription_service: SubscriptionService = app["subscription_service"]
|
|
referral_service: ReferralService = app["referral_service"]
|
|
|
|
async with async_session_factory() as session:
|
|
try:
|
|
await payment_dal.update_provider_payment_and_status(
|
|
session,
|
|
payment_db_id,
|
|
str(invoice.invoice_id),
|
|
"succeeded",
|
|
)
|
|
await session.commit()
|
|
except Exception:
|
|
await session.rollback()
|
|
logging.exception(
|
|
"Failed to mark CryptoPay invoice %s as succeeded.",
|
|
payment_db_id,
|
|
)
|
|
return
|
|
|
|
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
|
if not payment:
|
|
logging.error(
|
|
"CryptoPay webhook: payment %s vanished after status update.",
|
|
payment_db_id,
|
|
)
|
|
return
|
|
|
|
currency = invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL
|
|
await finalize_successful_payment(
|
|
PaymentSuccessRequest(
|
|
bot=bot,
|
|
settings=settings,
|
|
i18n=i18n,
|
|
session=session,
|
|
subscription_service=subscription_service,
|
|
referral_service=referral_service,
|
|
payment=payment,
|
|
user_id=user_id,
|
|
amount=float(invoice.amount),
|
|
currency=str(currency),
|
|
sale_mode=sale_mode,
|
|
months=int(months) if months else int(traffic_gb),
|
|
traffic_amount=float(traffic_gb),
|
|
provider_subscription="cryptopay",
|
|
provider_notification="crypto_pay",
|
|
log_prefix="CryptoPay webhook",
|
|
)
|
|
)
|
|
|
|
def _validate_webhook_signature(self, raw_body: bytes, signature: str) -> bool:
|
|
if not self.token:
|
|
return False
|
|
|
|
expected_signature = hmac.new(
|
|
hashlib.sha256(self.token.encode("utf-8")).digest(),
|
|
raw_body,
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
if not hmac.compare_digest(expected_signature, signature or ""):
|
|
logger.error("CryptoPay signature mismatch")
|
|
return False
|
|
return True
|
|
|
|
async def webhook_route(self, request: web.Request) -> web.Response:
|
|
if not self.configured or not self.client:
|
|
return web.Response(status=503, text="cryptopay_disabled")
|
|
raw_body = await request.read()
|
|
signature = request.headers.get("crypto-pay-api-signature", "")
|
|
if not self._validate_webhook_signature(raw_body, signature):
|
|
return web.Response(status=401)
|
|
return await self.client.get_updates(request)
|
|
|
|
|
|
async def cryptopay_webhook_route(request: web.Request) -> web.Response:
|
|
service: CryptoPayService = request.app["cryptopay_service"]
|
|
return await service.webhook_route(request)
|
|
|
|
|
|
router = Router(name="user_subscription_payments_crypto_router")
|
|
|
|
|
|
@router.callback_query(F.data.startswith("pay_crypto:"))
|
|
async def pay_crypto_callback_handler(
|
|
callback: types.CallbackQuery,
|
|
settings: Settings,
|
|
i18n_data: dict,
|
|
session: AsyncSession,
|
|
cryptopay_service: CryptoPayService,
|
|
):
|
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
|
translator = make_translator(i18n, current_lang)
|
|
|
|
if not i18n or not callback.message:
|
|
await notify_callback_parse_error(callback, translator)
|
|
return
|
|
|
|
if not SPEC.is_available_to_user(
|
|
settings,
|
|
user_id=callback.from_user.id,
|
|
require_configured=False,
|
|
):
|
|
await notify_service_unavailable(callback, translator)
|
|
return
|
|
|
|
if not cryptopay_service or not getattr(cryptopay_service, "configured", False):
|
|
await notify_service_unavailable(callback, translator)
|
|
return
|
|
|
|
parts = parse_payment_callback(callback.data or "")
|
|
if not parts:
|
|
await notify_callback_parse_error(callback, translator)
|
|
return
|
|
parts, hwid_quote = await quote_hwid_callback_parts(
|
|
session=session,
|
|
user_id=callback.from_user.id,
|
|
parts=parts,
|
|
subscription_service=cryptopay_service.subscription_service,
|
|
currency="rub",
|
|
)
|
|
if not parts:
|
|
await notify_callback_parse_error(callback, translator)
|
|
return
|
|
|
|
payment_description = describe_payment(translator, parts)
|
|
invoice_url = await cryptopay_service.create_invoice(
|
|
session=session,
|
|
user_id=callback.from_user.id,
|
|
months=parts.months,
|
|
amount=parts.price,
|
|
description=payment_description,
|
|
sale_mode=parts.sale_mode,
|
|
hwid_quote=hwid_quote,
|
|
)
|
|
|
|
if invoice_url:
|
|
await render_payment_link(
|
|
callback,
|
|
translator=translator,
|
|
current_lang=current_lang,
|
|
i18n=i18n,
|
|
parts=parts,
|
|
payment_url=invoice_url,
|
|
log_prefix=_LOG,
|
|
)
|
|
return
|
|
|
|
from .shared import safe_callback_answer
|
|
|
|
await safe_callback_answer(callback, translator("error_payment_gateway"), show_alert=True)
|
|
|
|
|
|
def create_service(ctx: ServiceFactoryContext) -> CryptoPayService:
|
|
bundle = ctx.config_for("cryptopay_service")
|
|
config = (
|
|
bundle.config
|
|
if bundle and isinstance(bundle.config, CryptoPayConfig)
|
|
else CryptoPayConfig()
|
|
)
|
|
return CryptoPayService(
|
|
bot=ctx.bot,
|
|
settings=ctx.settings,
|
|
config=config,
|
|
i18n=ctx.i18n,
|
|
async_session_factory=ctx.async_session_factory,
|
|
subscription_service=ctx.subscription_service,
|
|
referral_service=ctx.referral_service,
|
|
)
|
|
|
|
|
|
async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
|
service: CryptoPayService = ctx.request.app["cryptopay_service"]
|
|
if not service or not service.configured:
|
|
return payment_unavailable()
|
|
url = await service.create_invoice(
|
|
session=ctx.session,
|
|
user_id=ctx.user_id,
|
|
months=ctx.months,
|
|
amount=ctx.price,
|
|
description=ctx.description,
|
|
sale_mode=ctx.sale_mode,
|
|
url_kind="web",
|
|
hwid_quote={
|
|
"valid_from": ctx.hwid_valid_from,
|
|
"valid_until": ctx.hwid_valid_until,
|
|
"pricing_period_months": ctx.hwid_pricing_period_months,
|
|
"proration_ratio": ctx.hwid_proration_ratio,
|
|
"full_price": ctx.hwid_full_price,
|
|
}
|
|
if ctx.hwid_valid_from and ctx.hwid_valid_until
|
|
else None,
|
|
)
|
|
if not url:
|
|
return payment_failed()
|
|
return payment_link_response(payment_url=url, payment_id=None)
|
|
|
|
|
|
_PRESENTATION_MANIFEST = tuple(
|
|
ProviderManifestField(
|
|
key=key,
|
|
type=type_,
|
|
label=label,
|
|
description=description,
|
|
placeholder=placeholder,
|
|
subsection="CryptoPay",
|
|
target="presentation",
|
|
attr=attr,
|
|
)
|
|
for key, type_, label, description, placeholder, attr in (
|
|
(
|
|
"PAYMENT_CRYPTOPAY_WEBAPP_LABEL_RU",
|
|
"string",
|
|
"WebApp button text (RU)",
|
|
"Custom Russian text shown in the Web App payment method button.",
|
|
"",
|
|
"WEBAPP_LABEL_RU",
|
|
),
|
|
(
|
|
"PAYMENT_CRYPTOPAY_WEBAPP_LABEL_EN",
|
|
"string",
|
|
"WebApp button text (EN)",
|
|
"Custom English text shown in the Web App payment method button.",
|
|
"",
|
|
"WEBAPP_LABEL_EN",
|
|
),
|
|
(
|
|
"PAYMENT_CRYPTOPAY_WEBAPP_ICON",
|
|
"icon",
|
|
"WebApp button icon",
|
|
"Lucide icon name rendered inside the Web App payment method button.",
|
|
"Bitcoin",
|
|
"WEBAPP_ICON",
|
|
),
|
|
(
|
|
"PAYMENT_CRYPTOPAY_TELEGRAM_LABEL_RU",
|
|
"string",
|
|
"Telegram button text (RU)",
|
|
"Custom Russian text shown in Telegram bot payment buttons.",
|
|
"",
|
|
"TELEGRAM_LABEL_RU",
|
|
),
|
|
(
|
|
"PAYMENT_CRYPTOPAY_TELEGRAM_LABEL_EN",
|
|
"string",
|
|
"Telegram button text (EN)",
|
|
"Custom English text shown in Telegram bot payment buttons.",
|
|
"",
|
|
"TELEGRAM_LABEL_EN",
|
|
),
|
|
(
|
|
"PAYMENT_CRYPTOPAY_TELEGRAM_EMOJI",
|
|
"string",
|
|
"Telegram button emoji",
|
|
"Emoji prepended to the Telegram bot payment button when customized.",
|
|
"₿",
|
|
"TELEGRAM_EMOJI",
|
|
),
|
|
)
|
|
)
|
|
|
|
_CONFIG_MANIFEST = (
|
|
ProviderManifestField(
|
|
"CRYPTOPAY_ENABLED", "bool", "Включена", subsection="CryptoPay", attr="ENABLED"
|
|
),
|
|
ProviderManifestField(
|
|
"CRYPTOPAY_TOKEN", "string", "Token", subsection="CryptoPay", secret=True, attr="TOKEN"
|
|
),
|
|
ProviderManifestField(
|
|
"CRYPTOPAY_NETWORK",
|
|
"string",
|
|
"Network",
|
|
placeholder="mainnet",
|
|
subsection="CryptoPay",
|
|
attr="NETWORK",
|
|
),
|
|
ProviderManifestField(
|
|
"CRYPTOPAY_CURRENCY_TYPE",
|
|
"string",
|
|
"Currency type",
|
|
description="fiat or crypto.",
|
|
placeholder="fiat",
|
|
subsection="CryptoPay",
|
|
attr="CURRENCY_TYPE",
|
|
),
|
|
ProviderManifestField(
|
|
"CRYPTOPAY_ASSET",
|
|
"string",
|
|
"Asset",
|
|
placeholder="RUB",
|
|
subsection="CryptoPay",
|
|
attr="ASSET",
|
|
),
|
|
)
|
|
|
|
|
|
SPEC = PaymentProviderSpec(
|
|
id="cryptopay",
|
|
provider_key="cryptopay",
|
|
label="CryptoPay",
|
|
webapp_label="CryptoPay",
|
|
webapp_labels={"ru": "CryptoPay", "en": "CryptoPay"},
|
|
webapp_icon="Bitcoin",
|
|
telegram_labels={"ru": "CryptoBot", "en": "CryptoBot"},
|
|
pending_status="pending_cryptopay",
|
|
enabled=lambda config: bool(getattr(config, "ENABLED", False)),
|
|
service_key="cryptopay_service",
|
|
callback_prefix="pay_crypto",
|
|
router=router,
|
|
create_service=create_service,
|
|
webhook_path=lambda source: "/webhook/cryptopay",
|
|
webhook_route=cryptopay_webhook_route,
|
|
create_webapp_payment=create_webapp_payment,
|
|
emoji="₿",
|
|
telegram_emoji="₿",
|
|
config_class=CryptoPayConfig,
|
|
presentation_class=CryptoPayPresentation,
|
|
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
|
|
)
|