From 166b2faed2ee31970ca77f03d0c2095b3482ad26 Mon Sep 17 00:00:00 2001 From: Tolmachev Igor Date: Thu, 17 Jul 2025 15:18:44 +0900 Subject: Release v.1.0.0 --- src/async_crypto_pay_api/__init__.py | 16 + src/async_crypto_pay_api/__meta__.py | 2 + src/async_crypto_pay_api/client.py | 371 +++++++++++++++++++++++ src/async_crypto_pay_api/exceptions.py | 26 ++ src/async_crypto_pay_api/models/__init__.py | 52 ++++ src/async_crypto_pay_api/models/app_info.py | 14 + src/async_crypto_pay_api/models/app_stats.py | 19 ++ src/async_crypto_pay_api/models/assets.py | 73 +++++ src/async_crypto_pay_api/models/balance.py | 16 + src/async_crypto_pay_api/models/check.py | 26 ++ src/async_crypto_pay_api/models/currency.py | 18 ++ src/async_crypto_pay_api/models/exchange_rate.py | 19 ++ src/async_crypto_pay_api/models/invoice.py | 82 +++++ src/async_crypto_pay_api/models/items.py | 14 + src/async_crypto_pay_api/models/response.py | 22 ++ src/async_crypto_pay_api/models/transfer.py | 23 ++ 16 files changed, 793 insertions(+) create mode 100644 src/async_crypto_pay_api/__init__.py create mode 100644 src/async_crypto_pay_api/__meta__.py create mode 100644 src/async_crypto_pay_api/client.py create mode 100644 src/async_crypto_pay_api/exceptions.py create mode 100644 src/async_crypto_pay_api/models/__init__.py create mode 100644 src/async_crypto_pay_api/models/app_info.py create mode 100644 src/async_crypto_pay_api/models/app_stats.py create mode 100644 src/async_crypto_pay_api/models/assets.py create mode 100644 src/async_crypto_pay_api/models/balance.py create mode 100644 src/async_crypto_pay_api/models/check.py create mode 100644 src/async_crypto_pay_api/models/currency.py create mode 100644 src/async_crypto_pay_api/models/exchange_rate.py create mode 100644 src/async_crypto_pay_api/models/invoice.py create mode 100644 src/async_crypto_pay_api/models/items.py create mode 100644 src/async_crypto_pay_api/models/response.py create mode 100644 src/async_crypto_pay_api/models/transfer.py (limited to 'src') diff --git a/src/async_crypto_pay_api/__init__.py b/src/async_crypto_pay_api/__init__.py new file mode 100644 index 0000000..8689f7f --- /dev/null +++ b/src/async_crypto_pay_api/__init__.py @@ -0,0 +1,16 @@ +# TODO: Add doc strings +# TODO: Add webhooks + +# isort: off +from . import models +from . import exceptions +from . import client + +from .client import CryptoPayApi + +__all__ = [ + "models", + "exceptions", + "client", + "CryptoPayApi", +] diff --git a/src/async_crypto_pay_api/__meta__.py b/src/async_crypto_pay_api/__meta__.py new file mode 100644 index 0000000..d53578f --- /dev/null +++ b/src/async_crypto_pay_api/__meta__.py @@ -0,0 +1,2 @@ +__version__ = "1.0.0" +__api_version__ = "1.5.1" diff --git a/src/async_crypto_pay_api/client.py b/src/async_crypto_pay_api/client.py new file mode 100644 index 0000000..93551fd --- /dev/null +++ b/src/async_crypto_pay_api/client.py @@ -0,0 +1,371 @@ +from datetime import datetime, timedelta +from decimal import Decimal +from enum import Enum +from secrets import token_hex +from typing import Any, Literal, TypeVar, overload + +from aiohttp import ClientSession +from pydantic import ValidationError + +from async_crypto_pay_api import models as m +from async_crypto_pay_api.exceptions import InvalidResponseError, RequestError + + +def serialize_value(value: Any) -> str: + if isinstance(value, str): + return value + elif isinstance(value, (int, float, Decimal)): + return str(value) + elif isinstance(value, bool): + return "true" if value else "false" + elif isinstance(value, list): + return ",".join(map(serialize_value, value)) + elif isinstance(value, datetime): + return value.isoformat() + elif isinstance(value, Enum): + return value.value + else: + raise ValueError(f"unsupported type for serialization: '{type(value)}'") + + +def serialize_body(body: dict[str, Any]) -> dict[str, Any]: + return {k: serialize_value(v) for k, v in body.items() if v is not None} + + +R = TypeVar("R") + + +class CryptoPayApi: + __token: str + __is_test_backend: bool + __session: ClientSession | None + + def __init__(self, token: str, test_backend: bool = False) -> None: + self.__token = token + self.__is_test_backend = test_backend + self.__session = None + + async def __aenter__(self) -> "CryptoPayApi": + self.__get_session() + return self + + async def __aexit__(self, *_) -> bool: + await self.close() + return False + + def __get_session(self) -> ClientSession: + if self.__session is None: + self.__session = ClientSession( + base_url=( + "https://testnet-pay.crypt.bot/api/" + if self.__is_test_backend + else "https://pay.crypt.bot/api/" + ), + headers={"Crypto-Pay-API-Token": self.__token}, + ) + + return self.__session + + async def __process_request( + self, + method_name: str, + result_model: type[R], + body: dict[str, Any] | None = None, + ) -> R: + async with ( + self.__get_session().post( + method_name, + json={} if body is None else body, + ) as http_response, + ): + json = await http_response.json() + try: + response = m.Response[result_model].model_validate(json) + except ValidationError: + raise InvalidResponseError("invalid response body") + + if response.ok: + if response.result is None: + raise InvalidResponseError( + "response.result is empty while response.ok = True" + ) + + return response.result + else: + if response.error is None: + raise InvalidResponseError( + "response.error is empty while response.ok = False" + ) + + raise RequestError(response.error.code, response.error.name) + + @property + def token(self) -> str: + return self.__token + + async def close(self) -> None: + if self.__session is None: + return + + await self.__session.close() + self.__session = None + + async def get_me(self) -> m.AppInfo: + return await self.__process_request("getMe", result_model=m.AppInfo) + + @overload + async def create_invoice( + self, + *, + currency_type: Literal[m.CurrencyType.CRYPTO], + asset: m.CryptoAsset, + amount: int | float | Decimal, + swap_to: m.SwapAsset | None = None, + description: str | None = None, + hidden_message: str | None = None, + paid_btn_name: None = None, + paid_btn_url: None = None, + payload: str | None = None, + allow_comments: bool = True, + allow_anonymous: bool = True, + expires_in: timedelta | None = None, + ) -> m.Invoice: ... + + @overload + async def create_invoice( + self, + *, + currency_type: Literal[m.CurrencyType.CRYPTO], + asset: m.CryptoAsset, + amount: int | float | Decimal, + paid_btn_name: m.PaidButtonName, + paid_btn_url: str, + swap_to: m.SwapAsset | None = None, + description: str | None = None, + hidden_message: str | None = None, + payload: str | None = None, + allow_comments: bool = True, + allow_anonymous: bool = True, + expires_in: timedelta | None = None, + ) -> m.Invoice: ... + + @overload + async def create_invoice( + self, + *, + currency_type: Literal[m.CurrencyType.FIAT], + fiat: m.FiatAsset, + accepted_assets: list[m.CryptoAsset] | None = None, + amount: int | float | Decimal, + swap_to: m.SwapAsset | None = None, + description: str | None = None, + hidden_message: str | None = None, + paid_btn_name: None = None, + paid_btn_url: None = None, + payload: str | None = None, + allow_comments: bool = True, + allow_anonymous: bool = True, + expires_in: timedelta | None = None, + ) -> m.Invoice: ... + + @overload + async def create_invoice( + self, + *, + currency_type: Literal[m.CurrencyType.FIAT], + fiat: m.FiatAsset, + amount: int | float | Decimal, + paid_btn_name: m.PaidButtonName, + paid_btn_url: str, + accepted_assets: list[m.CryptoAsset] | None = None, + swap_to: m.SwapAsset | None = None, + description: str | None = None, + hidden_message: str | None = None, + payload: str | None = None, + allow_comments: bool = True, + allow_anonymous: bool = True, + expires_in: timedelta | None = None, + ) -> m.Invoice: ... + + async def create_invoice(self, **body: Any) -> m.Invoice: + return await self.__process_request( + "createInvoice", + result_model=m.Invoice, + body=serialize_body(body), + ) + + async def delete_invoice(self, invoice_id: int) -> bool: + return await self.__process_request( + "deleteInvoice", + result_model=bool, + body=serialize_body(dict(invoice_id=invoice_id)), + ) + + async def create_check( + self, + *, + asset: m.CryptoAsset, + amount: int | float | Decimal, + pin_to_user_id: int | None = None, + pin_to_username: str | None = None, + ) -> m.Check: + return await self.__process_request( + "createCheck", + result_model=m.Check, + body=serialize_body( + dict( + asset=asset, + amount=amount, + pin_to_user_id=pin_to_user_id, + pin_to_username=pin_to_username, + ) + ), + ) + + async def delete_check(self, check_id: int) -> bool: + return await self.__process_request( + "deleteCheck", + result_model=bool, + body=serialize_body(dict(check_id=check_id)), + ) + + async def transfer( + self, + *, + user_id: int, + asset: m.CryptoAsset, + amount: int | float | Decimal, + comment: str | None = None, + disable_send_notification: bool = False, + ) -> m.Transfer: + return await self.__process_request( + "transfer", + m.Transfer, + body=serialize_body( + dict( + user_id=user_id, + asset=asset, + amount=amount, + spend_id=token_hex(32), + comment=comment, + disable_send_notification=disable_send_notification, + ) + ), + ) + + @overload + async def get_invoices( + self, + *, + asset: m.CryptoAsset, + invoice_ids: list[int] | None = None, + status: m.InvoiceSearchStatus | None = None, + offset: int = 0, + count: int = 100, + ) -> list[m.Invoice]: ... + + @overload + async def get_invoices( + self, + *, + fiat: m.FiatAsset, + invoice_ids: list[int] | None = None, + status: m.InvoiceSearchStatus | None = None, + offset: int = 0, + count: int = 100, + ) -> list[m.Invoice]: ... + + @overload + async def get_invoices( + self, + *, + asset: None = None, + fiat: None = None, + invoice_ids: list[int] | None = None, + status: m.InvoiceSearchStatus | None = None, + offset: int = 0, + count: int = 100, + ) -> list[m.Invoice]: ... + + async def get_invoices(self, **body: Any) -> list[m.Invoice]: + items = await self.__process_request( + "getInvoices", + m.Items[m.Invoice], + serialize_body(body), + ) + return items.items + + async def get_transfers( + self, + asset: m.CryptoAsset | None = None, + transfer_ids: list[int] | None = None, + offset: int = 0, + count: int = 100, + ) -> list[m.Transfer]: + items = await self.__process_request( + "getTransfers", + m.Items[m.Transfer], + serialize_body( + dict( + asset=asset, + transfer_ids=transfer_ids, + offset=offset, + count=count, + ) + ), + ) + return items.items + + async def get_checks( + self, + asset: m.CryptoAsset | None = None, + check_ids: list[int] | None = None, + status: m.CheckStatus | None = None, + offset: int = 0, + count: int = 100, + ) -> list[m.Check]: + items = await self.__process_request( + "getChecks", + m.Items[m.Check], + serialize_body( + dict( + asset=asset, + check_ids=check_ids, + status=status, + offset=offset, + count=count, + ) + ), + ) + return items.items + + async def get_balance(self) -> list[m.Balance]: + return await self.__process_request("getBalance", list[m.Balance]) + + async def get_exchange_rates(self) -> list[m.ExchangeRate]: + return await self.__process_request("getExchangeRates", list[m.ExchangeRate]) + + async def get_currencies(self) -> list[m.Currency]: + return await self.__process_request("getCurrencies", list[m.Currency]) + + async def get_stats( + self, + *, + start_at: datetime | None = None, + end_at: datetime | None = None, + ) -> m.AppStats: + return await self.__process_request( + "getStats", + m.AppStats, + serialize_body( + dict( + start_at=start_at, + end_at=end_at, + ) + ), + ) + + +__all__ = [ + "CryptoPayApi", +] diff --git a/src/async_crypto_pay_api/exceptions.py b/src/async_crypto_pay_api/exceptions.py new file mode 100644 index 0000000..a5bcf4e --- /dev/null +++ b/src/async_crypto_pay_api/exceptions.py @@ -0,0 +1,26 @@ +class CryptoPayError(Exception): + def __init__(self, message: str) -> None: + super().__init__(message) + + +class RequestError(CryptoPayError): + status_code: int + name: str + + def __init__(self, status_code: int, name: str) -> None: + super().__init__(f"{name} [{status_code}]") + + self.status_code = status_code + self.name = name + + +class InvalidResponseError(CryptoPayError): + def __init__(self, info: str) -> None: + super().__init__(f"server response is invalid: {info}") + + +__all__ = [ + "CryptoPayError", + "RequestError", + "InvalidResponseError", +] diff --git a/src/async_crypto_pay_api/models/__init__.py b/src/async_crypto_pay_api/models/__init__.py new file mode 100644 index 0000000..36bde28 --- /dev/null +++ b/src/async_crypto_pay_api/models/__init__.py @@ -0,0 +1,52 @@ +# isort: off +from .response import Error, Response +from .app_info import AppInfo +from .items import Items +from .assets import CryptoAsset, FiatAsset, SwapAsset +from .invoice import ( + CurrencyType, + InvoiceStatus, + PaidButtonName, + Invoice, + InvoiceSearchStatus, +) +from .check import CheckStatus, Check +from .transfer import Transfer +from .balance import Balance +from .exchange_rate import ExchangeRate +from .app_stats import AppStats +from .currency import Currency + +__all__ = [ + # response + "Error", + "Response", + # app_info + "AppInfo", + # assets + "CryptoAsset", + "FiatAsset", + "SwapAsset", + # items + "Items", + # invoice, + "CurrencyType", + "InvoiceStatus", + "SwapAsset", + "PaidButtonName", + "Invoice", + "InvoiceSearchStatus", + # check + "CheckStatus", + "Check", + # transfer + "Transfer", + # balance + "Balance", + # exchange_rate + "ExchangeRate", + # currency + "Currency", + # app_stats + "AppStats", +] diff --git a/src/async_crypto_pay_api/models/app_info.py b/src/async_crypto_pay_api/models/app_info.py new file mode 100644 index 0000000..e923212 --- /dev/null +++ b/src/async_crypto_pay_api/models/app_info.py @@ -0,0 +1,14 @@ +from typing import Literal + +from pydantic import BaseModel + + +class AppInfo(BaseModel, frozen=True): + app_id: int + name: str + payment_processing_bot_username: Literal["CryptoBot", "CryptoTestnetBot"] + + +__all__ = [ + "AppInfo", +] diff --git a/src/async_crypto_pay_api/models/app_stats.py b/src/async_crypto_pay_api/models/app_stats.py new file mode 100644 index 0000000..1f98554 --- /dev/null +++ b/src/async_crypto_pay_api/models/app_stats.py @@ -0,0 +1,19 @@ +from datetime import datetime as Datetime +from decimal import Decimal + +from pydantic import BaseModel + + +class AppStats(BaseModel, frozen=True): + volume: Decimal + conversion: Decimal + unique_users_count: int + created_invoice_count: int + paid_invoice_count: int + start_at: Datetime + end_at: Datetime + + +__all__ = [ + "AppStats", +] diff --git a/src/async_crypto_pay_api/models/assets.py b/src/async_crypto_pay_api/models/assets.py new file mode 100644 index 0000000..2616668 --- /dev/null +++ b/src/async_crypto_pay_api/models/assets.py @@ -0,0 +1,73 @@ +from enum import Enum + + +class EnumWithUnknown(Enum): + UNKNOWN = "UNKNOWN" + + +class CryptoAsset(Enum): + USDT = "USDT" + TON = "TON" + SOL = "SOL" + TRX = "TRX" + GRAM = "GRAM" + BTC = "BTC" + ETH = "ETH" + DOGE = "DOGE" + LTC = "LTC" + NOT = "NOT" + TRUMP = "TRUMP" + MELANIA = "MELANIA" + PEPE = "PEPE" + WIF = "WIF" + BONK = "BONK" + MAJOR = "MAJOR" + MY = "MY" + DOGS = "DOGS" + MEMHASH = "MEMHASH" + BNB = "BNB" + HMSTR = "HMSTR" + CATI = "CATI" + USDC = "USDC" + + +class FiatAsset(Enum): + RUB = "RUB" + USD = "USD" + EUR = "EUR" + BYN = "BYN" + UAH = "UAH" + GBP = "GBP" + CNY = "CNY" + KZT = "KZT" + UZS = "UZS" + GEL = "GEL" + TRY = "TRY" + AMD = "AMD" + THB = "THB" + INR = "INR" + BRL = "BRL" + IDR = "IDR" + AZN = "AZN" + AED = "AED" + PLN = "PLN" + ILS = "ILS" + KGS = "KGS" + TJS = "TJS" + + +class SwapAsset(Enum): + USDT = "USDT" + TON = "TON" + TRX = "TRX" + ETH = "ETH" + SOL = "SOL" + BTC = "BTC" + LTC = "LTC" + + +__all__ = [ + "CryptoAsset", + "FiatAsset", + "SwapAsset", +] diff --git a/src/async_crypto_pay_api/models/balance.py b/src/async_crypto_pay_api/models/balance.py new file mode 100644 index 0000000..3c64de6 --- /dev/null +++ b/src/async_crypto_pay_api/models/balance.py @@ -0,0 +1,16 @@ +from decimal import Decimal + +from pydantic import BaseModel + +from async_crypto_pay_api.models import CryptoAsset + + +class Balance(BaseModel, frozen=True): + currency_code: CryptoAsset + available: Decimal + onhold: Decimal + + +__all__ = [ + "Balance", +] diff --git a/src/async_crypto_pay_api/models/check.py b/src/async_crypto_pay_api/models/check.py new file mode 100644 index 0000000..b8f2b7b --- /dev/null +++ b/src/async_crypto_pay_api/models/check.py @@ -0,0 +1,26 @@ +from datetime import datetime as Datetime +from decimal import Decimal +from enum import Enum + +from pydantic import BaseModel + +from async_crypto_pay_api.models import CryptoAsset + + +class CheckStatus(Enum): + ACTIVE = "active" + ACTIVATED = "activated" + + +class Check(BaseModel, frozen=True): + check_id: int + hash: str + asset: CryptoAsset + amount: Decimal + bot_check_url: str + status: CheckStatus + created_at: Datetime + activated_at: Datetime | None = None + + +__all__ = [] diff --git a/src/async_crypto_pay_api/models/currency.py b/src/async_crypto_pay_api/models/currency.py new file mode 100644 index 0000000..64a71a2 --- /dev/null +++ b/src/async_crypto_pay_api/models/currency.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel + +from async_crypto_pay_api.models import CryptoAsset, FiatAsset + + +class Currency(BaseModel, frozen=True): + is_blockchain: bool + is_stablecoin: bool + is_fiat: bool + name: str + code: CryptoAsset | FiatAsset + url: str | None = None + decimals: int + + +__all__ = [ + "Currency", +] diff --git a/src/async_crypto_pay_api/models/exchange_rate.py b/src/async_crypto_pay_api/models/exchange_rate.py new file mode 100644 index 0000000..bf07719 --- /dev/null +++ b/src/async_crypto_pay_api/models/exchange_rate.py @@ -0,0 +1,19 @@ +from decimal import Decimal + +from pydantic import BaseModel + +from async_crypto_pay_api.models import CryptoAsset, FiatAsset + + +class ExchangeRate(BaseModel, frozen=True): + is_valid: bool + is_crypto: bool + is_fiat: bool + source: CryptoAsset | FiatAsset + target: FiatAsset + rate: Decimal + + +__all__ = [ + "ExchangeRate", +] diff --git a/src/async_crypto_pay_api/models/invoice.py b/src/async_crypto_pay_api/models/invoice.py new file mode 100644 index 0000000..e42dde0 --- /dev/null +++ b/src/async_crypto_pay_api/models/invoice.py @@ -0,0 +1,82 @@ +from datetime import datetime as Datetime +from decimal import Decimal +from enum import Enum + +from pydantic import BaseModel, Field + +from async_crypto_pay_api.models import CryptoAsset, FiatAsset, SwapAsset + + +class CurrencyType(Enum): + CRYPTO = "crypto" + FIAT = "fiat" + + +class InvoiceStatus(Enum): + ACTIVE = "active" + PAID = "paid" + EXPIRED = "expired" + + +class PaidButtonName(Enum): + VIEW_ITEM = "viewItem" + OPEN_CHANNEL = "openChannel" + OPEN_BOT = "openBot" + CALLBACK = "callback" + + +class Invoice(BaseModel, frozen=True): + invoice_id: int + hash: str + currency_type: CurrencyType + asset: CryptoAsset | None = None + fiat: FiatAsset | None = None + amount: Decimal + paid_asset: CryptoAsset | None = None + paid_amount: Decimal | None = None + paid_fiat_rate: Decimal | None = None + accepted_assets: list[CryptoAsset] | None = None + fee_asset: CryptoAsset | None = None + fee_amount: Decimal | None = None + # fee: Decimal | None = Field(deprecated=True) + pay_url: str | None = Field(default=None, deprecated=True) + bot_invoice_url: str + mini_app_invoice_url: str + web_app_invoice_url: str + description: str | None = None + status: InvoiceStatus + swap_to: SwapAsset | None = None + is_swapped: bool | None = None + swapped_uid: str | None = None + swapped_to: SwapAsset | None = None + swapped_rate: Decimal | None = None + swapped_output: Decimal | None = None + swapped_usd_amount: Decimal | None = None + swapped_usd_rate: Decimal | None = None + created_at: Datetime + paid_usd_rate: Decimal | None = None + # usd_rate: Decimal | None = Field(deprecated=True) + allow_comments: bool + allow_anonymous: bool + expiration_date: Datetime | None = None + paid_at: Datetime | None = None + paid_anonymously: bool | None = None + comment: str | None = None + hidden_message: str | None = None + payload: str | None = None + paid_btn_name: PaidButtonName | None = None + paid_btn_url: str | None = None + + +class InvoiceSearchStatus(Enum): + ACTIVE = "active" + PAID = "paid" + + +__all__ = [ + "CurrencyType", + "InvoiceStatus", + "PaidButtonName", + "Invoice", + "InvoiceSearchStatus", +] diff --git a/src/async_crypto_pay_api/models/items.py b/src/async_crypto_pay_api/models/items.py new file mode 100644 index 0000000..05d60ea --- /dev/null +++ b/src/async_crypto_pay_api/models/items.py @@ -0,0 +1,14 @@ +from typing import Generic, TypeVar + +from pydantic import BaseModel + +T = TypeVar("T") + + +class Items(BaseModel, Generic[T], frozen=True): + items: list[T] + + +__all__ = [ + "Items", +] diff --git a/src/async_crypto_pay_api/models/response.py b/src/async_crypto_pay_api/models/response.py new file mode 100644 index 0000000..6fcfa92 --- /dev/null +++ b/src/async_crypto_pay_api/models/response.py @@ -0,0 +1,22 @@ +from typing import Generic, TypeVar + +from pydantic import BaseModel + +R = TypeVar("R") + + +class Error(BaseModel, frozen=True): + code: int + name: str + + +class Response(BaseModel, Generic[R], frozen=True): + ok: bool + result: R | None = None + error: Error | None = None + + +__all__ = [ + "Error", + "Response", +] diff --git a/src/async_crypto_pay_api/models/transfer.py b/src/async_crypto_pay_api/models/transfer.py new file mode 100644 index 0000000..fbc9ac3 --- /dev/null +++ b/src/async_crypto_pay_api/models/transfer.py @@ -0,0 +1,23 @@ +from datetime import datetime as Datetime +from decimal import Decimal +from typing import Literal + +from pydantic import BaseModel + +from async_crypto_pay_api.models import CryptoAsset + + +class Transfer(BaseModel, frozen=True): + transfer_id: int + spend_id: str + user_id: int + asset: CryptoAsset + amount: Decimal + status: Literal["completed"] + completed_at: Datetime + comment: str | None = None + + +__all__ = [ + "Transfer", +] -- cgit v1.2.3