diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23d5f30..d23d6d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,16 +2,14 @@ name: CI on: push: - branches: [ "**" ] + branches: ["**"] pull_request: - branches: [ "**" ] + branches: ["**"] jobs: lint: name: Lint & Format runs-on: ubuntu-latest - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - uses: actions/checkout@v6 @@ -19,21 +17,20 @@ jobs: - uses: actions/setup-python@v6 with: python-version: "3.10" + cache: pip - run: pip install ".[dev]" - - run: ruff check . && ruff format --check . && mypy pyfragment + - run: ruff check . && ruff format --check . && mypy pyfragment --explicit-package-bases test: name: Tests (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true strategy: fail-fast: false matrix: - python-version: [ "3.10", "3.11", "3.12" ] # 3.13, 3.14 are not supported by some dependencies yet + python-version: [ "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v6 @@ -41,6 +38,7 @@ jobs: - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} + cache: pip - name: Install package and dev dependencies run: pip install ".[dev]" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9b9892e..b14613f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,17 +2,15 @@ name: Publish on: workflow_run: - workflows: [ "CI" ] - types: [ completed ] - branches: [ master ] + workflows: ["CI"] + types: [completed] + branches: [master] jobs: version-check: name: Version Check if: github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true outputs: version: ${{ steps.version.outputs.value }} is-new: ${{ steps.tag.outputs.is-new }} @@ -42,8 +40,6 @@ jobs: needs: version-check if: needs.version-check.outputs.is-new == 'true' runs-on: ubuntu-latest - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - uses: actions/checkout@v6 @@ -63,10 +59,8 @@ jobs: publish: name: Publish to PyPI - needs: [ version-check, build ] + needs: [version-check, build] runs-on: ubuntu-latest - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true environment: name: pypi url: https://pypi.org/project/pyfragment/ @@ -83,35 +77,21 @@ jobs: release: name: GitHub Release - needs: [ version-check, build ] + needs: [version-check, build] runs-on: ubuntu-latest - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true permissions: contents: write steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - uses: actions/download-artifact@v8 with: name: dist path: dist - - name: Extract latest changelog entry - id: changelog - run: | - body=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md) - echo "body<> $GITHUB_OUTPUT - echo "$body" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - uses: softprops/action-gh-release@v3 with: tag_name: v${{ needs.version-check.outputs.version }} name: v${{ needs.version-check.outputs.version }} files: dist/* - body: ${{ steps.changelog.outputs.body }} + generate_release_notes: true make_latest: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e3e246..6edf17e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,34 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI --- +## [2026.3.1] — 2026-05-29 + +### Added + +- Python 3.13 and 3.14 are now officially supported and included in the CI test matrix and PyPI classifiers. +- `WalletVersion` is now exported from the top-level `pyfragment` package. + +### Changed + +- `process_transaction` (internal) refactored into focused subfunctions: `_extract_message`, `_check_payment_balances`, `_broadcast_with_retry`. +- `raw_api_call()` moved from `FragmentClient` into `pyfragment.domains.base` and exposed as a standalone helper. +- `tonapi` domain internal helpers removed from public `__init__.py` exports; only `TonapiService` is exported. +- README rewritten with badges, structured sections, and complete usage examples. +- Added `CONTRIBUTING.md` and `SECURITY.md`. + +### Fixed + +- CI: `mypy` now runs with `--explicit-package-bases` to avoid false-positive import errors. +- CI: `pip` dependency cache enabled to speed up workflow runs. +- CI: `warn_unused_ignores` suppressed for `pyfragment.core.cookies` to handle the optional `rookiepy` dependency correctly across environments where the package may or may not be installed. +- Publish workflow now uses `generate_release_notes: true` instead of manual changelog extraction. + +### Removed + +- `tonapi/transfer.py` and associated `TonTransferResult` / `UsdtTransferResult` models (internal, unused). + +--- + ## [2026.3.0] — 2026-05-21 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..581a776 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,56 @@ +# Contributing to pyfragment + +## Development setup + +```bash +git clone https://github.com/bohd4nx/pyfragment.git +cd pyfragment +pip install -e ".[dev]" +``` + +## Running checks + +```bash +# Lint and format +ruff check . --fix && ruff format . + +# Type check +mypy . --explicit-package-bases + +# Tests +pytest +``` + +All three must pass before opening a PR. + +## Project structure + +``` +pyfragment/ + client.py — FragmentClient (public entry point) + core/ — transport, cookies, constants + domains/ — one package per feature domain + ads/ — recharge_ads, topup_ton + anonymous_numbers/— buy_number, manage_number + giveaways/ — giveaway_stars, giveaway_premium + marketplace/ — search_usernames, search_numbers, search_gifts + purchases/ — purchase_stars, purchase_premium + tonapi/ — wallet info, transaction signing + models/ — result dataclasses and enums + exceptions.py — exception hierarchy +tests/ — unit tests (pytest) +examples/ — runnable usage examples (excluded from CI) +``` + +## Conventions + +- All public async methods live on `FragmentClient` and delegate to a domain service. +- Domain functions receive a `FragmentClient` instance, never raw httpx clients. +- Patch targets in tests use the module where the name is **defined**, e.g. `pyfragment.domains.tonapi.transaction.process_transaction`. +- Versioning follows [CalVer](https://calver.org/): `YYYY.MINOR.MICRO`. Bump in `pyproject.toml`; tag as `vYYYY.MINOR.MICRO`. + +## Pull requests + +- Keep PRs focused — one feature or fix per PR. +- Update `CHANGELOG.md` under `[Unreleased]`. +- Add or update tests for any changed behaviour. diff --git a/README.md b/README.md index 3b4c4a6..95ee4b5 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,23 @@
- Fragment Logo + pyfragment

-

Fragment API

+# pyfragment -

- Async Python client for the Fragment API. Buy Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and search Fragment listings. -

+[![PyPI](https://img.shields.io/pypi/v/pyfragment?style=flat&color=blue&label=PyPI)](https://pypi.org/project/pyfragment/) +[![Downloads](https://img.shields.io/pepy/dt/pyfragment?style=flat&color=blue&label=Downloads)](https://pepy.tech/projects/pyfragment) +[![Python](https://img.shields.io/pypi/pyversions/pyfragment?style=flat&color=blue&label=Python)](https://python.org) +[![Tests](https://img.shields.io/github/actions/workflow/status/bohd4nx/pyfragment/ci.yml?style=flat&label=Tests&logo=github)](https://github.com/bohd4nx/pyfragment/actions) +[![License](https://img.shields.io/github/license/bohd4nx/pyfragment?style=flat&color=blue&label=License)](LICENSE) -[![PyPI version](https://img.shields.io/pypi/v/pyfragment?style=flat&color=blue)](https://pypi.org/project/pyfragment/) -[![PyPI Downloads](https://static.pepy.tech/personalized-badge/pyfragment?period=total&units=INTERNATIONAL_SYSTEM&left_color=GREY&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/pyfragment) -[![Python](https://img.shields.io/badge/Python-3.10+-3776AB?style=flat&logo=python&logoColor=white)](https://python.org) -[![Tests](https://img.shields.io/github/actions/workflow/status/bohd4nx/pyfragment/ci.yml?style=flat&label=tests&logo=github)](https://github.com/bohd4nx/pyfragment/actions) -[![License](https://img.shields.io/github/license/bohd4nx/pyfragment?style=flat&color=green)](https://github.com/bohd4nx/pyfragment/blob/master/LICENSE) +Async Python client for the **[Fragment](https://fragment.com)** marketplace API. +Buy Stars & Premium, run giveaways, top up TON and Ads balances, +manage anonymous numbers, and search Fragment listings. -[Documentation](https://bohd4nx.gitbook.io/pyfragment/) · [Report Bug](https://github.com/bohd4nx/pyfragment/issues) · [Request Feature](https://github.com/bohd4nx/pyfragment/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5) +**[Documentation](https://bohd4nx.gitbook.io/pyfragment/)** · **[Examples](https://github.com/bohd4nx/pyfragment/tree/master/examples)** · **[Changelog](CHANGELOG.md)** · **[Donate TON](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)**
-> **Disclaimer:** This project is not affiliated with, endorsed by, or in any way officially connected with [Fragment](https://fragment.com) or [Telegram](https://telegram.org). +> **Disclaimer:** This project is not affiliated with [Fragment](https://fragment.com) or [Telegram](https://telegram.org). --- @@ -27,57 +27,14 @@ pip install pyfragment ``` -To install the latest unreleased changes from the `dev` branch: - ```bash +# Latest dev build pip install git+https://github.com/bohd4nx/pyfragment.git@dev ``` -Requires Python 3.10+. - --- -## Configuration - -| Parameter | Type | Default | Description | -| ---------------- | ------------- | -------- | -------------------------------------------------------- | -| `seed` | `str` | — | 24-word TON wallet mnemonic | -| `api_key` | `str` | — | Tonapi key from [tonconsole.com](https://tonconsole.com) | -| `cookies` | `dict \| str` | — | Fragment session cookies | -| `wallet_version` | `str` | `"V5R1"` | `"V4R2"` or `"V5R1"` | -| `timeout` | `float` | `30.0` | HTTP request timeout in seconds | - ---- - -## Credentials - -**Fragment cookies** — log in to [fragment.com](https://fragment.com) and connect your TON wallet. You can get cookies in two ways: - -- **Automatically** (recommended) — install the optional browser extra and use `get_cookies_from_browser()`, which reads them directly from your browser's on-disk store. No extension needed: - - ```bash - pip install "pyfragment[browser]" - ``` - - ```python - from pyfragment import get_cookies_from_browser - - result = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... - # result.cookies — dict[str, str] to pass to FragmentClient - # result.expires — ISO 8601 expiry of stel_ssid, or None for session cookies - ``` - -- **Manually** — install [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) and export these four keys: `stel_ssid`, `stel_dt`, `stel_token`, `stel_ton_token`. Pass them as a `dict` or JSON string. - -Refresh when you get authentication errors. - -**Tonapi key** — generate at [tonconsole.com](https://tonconsole.com). - -**Seed phrase** — 24-word mnemonic from your TON wallet (Tonkeeper → Settings → Backup). Never share it. - ---- - -## Usage +## Quick Start ```python import asyncio @@ -96,34 +53,108 @@ async def main() -> None: }, ) as client: wallet = await client.get_wallet() - print(f"Wallet: {wallet.address} | TON: {wallet.ton_balance} | USDT: {wallet.usdt_balance}") + print(f"TON: {wallet.ton_balance} | USDT: {wallet.usdt_balance}") - recipient = "https://t.me/username" # also supports: @username, username + recipient = "https://t.me/username" # also: @username, username stars = await client.purchase_stars(recipient, amount=500, payment_method="usdt_ton") - print(f"Stars sent: {stars.amount} to {stars.username} | tx: {stars.transaction_id}") + print(f"Sent {stars.amount} Stars to {stars.username} | tx: {stars.transaction_id}") premium = await client.purchase_premium(recipient, months=6, payment_method="ton") - print(f"Premium sent: {premium.amount} months to {premium.username} | tx: {premium.transaction_id}") + print(f"Sent Premium {premium.amount}m to {premium.username} | tx: {premium.transaction_id}") asyncio.run(main()) ``` -Full runnable examples: +--- -- https://github.com/bohd4nx/pyfragment/tree/master/examples +## Configuration -Payload debug/decode helper (thanks): +| Parameter | Type | Default | Description | +| ---------------- | ------------- | -------- | ----------------------------------------------------------- | +| `seed` | `str` | — | 12- or 24-word TON wallet mnemonic | +| `api_key` | `str` | — | Tonapi key from [tonconsole.com](https://tonconsole.com) | +| `cookies` | `dict \| str` | — | Fragment session cookies | +| `wallet_version` | `str` | `"V5R1"` | `"V4R2"` or `"V5R1"` — also accepts `WalletVersion` literal | +| `timeout` | `float` | `30.0` | HTTP request timeout in seconds | -- https://ton-cell-abi-viewer.vercel.app/ +--- + +## Credentials + +### Fragment cookies + +Log in to [fragment.com](https://fragment.com) and connect your TON wallet. + +**Automatically** (recommended) — reads directly from your browser, no extension needed: + +```bash +pip install "pyfragment[browser]" +``` + +```python +from pyfragment import get_cookies_from_browser + +result = get_cookies_from_browser("chrome") # firefox, edge, brave, ... +# result.cookies — dict[str, str] ready to pass to FragmentClient +# result.expires — ISO 8601 expiry of stel_ssid, or None for session cookies +``` + +**Manually** — use [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) and export: `stel_ssid`, `stel_dt`, `stel_token`, `stel_ton_token`. Pass as a `dict` or JSON string. + +Refresh cookies when you get authentication errors. + +### Tonapi key + +Generate at [tonconsole.com](https://tonconsole.com). + +### Seed phrase + +12- or 24-word mnemonic from your TON wallet (**Tonkeeper → Settings → Backup**). Never share it. + +--- + +## Logging + +`pyfragment` uses the standard `logging` module under the `pyfragment` namespace and is silent by default: + +```python +import logging + +logging.basicConfig(level=logging.INFO) +logging.getLogger("pyfragment").setLevel(logging.DEBUG) # for detailed request logs +``` + +--- + +## Error Handling + +All exceptions inherit from `FragmentError`: + +```python +from pyfragment import ( + ConfigurationError, # invalid arguments (amount, months, payment_method…) + UserNotFoundError, # recipient not found on Fragment + WalletError, # insufficient TON or USDT balance + TransactionError, # broadcast failed, duplicate seqno, invalid payload + FragmentAPIError, # Fragment API returned an error response + FragmentPageError, # page fetch or hash extraction failed + AnonymousNumberError, # number not owned, wrong state, login code issues + CookieError, # missing or malformed session cookies + ParseError, # failed to decode Fragment payload + VerificationError, # on-chain verification step failed + OperationError, # generic operation-level failure + UnexpectedError, # unexpected API response structure +) +``` ---
-### Made with ❤️ by [@bohd4nx](https://t.me/bohd4nx) +Made with ❤️ by [@bohd4nx](https://t.me/bohd4nx) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md) -**Star ⭐ this repo if you found it useful!** +**Star ⭐ if you found it useful**
diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..475c2e7 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,19 @@ +# Security Policy + +## Reporting a vulnerability + +Please **do not** open a public GitHub issue for security vulnerabilities. + +Report them privately via GitHub's [Security Advisory](https://github.com/bohd4nx/pyfragment/security/advisories/new) feature, or contact the maintainer directly at [@bohd4nx](https://t.me/bohd4nx) on Telegram. + +Include: + +- A description of the vulnerability and its potential impact. +- Steps to reproduce or a proof-of-concept. +- Affected versions. + +You will receive a response within 72 hours. Once the fix is released, the advisory will be published. + +## Scope + +This library handles sensitive credentials (TON seed phrases, Fragment session cookies, Tonapi keys). Please treat any finding that could expose or misuse these credentials as high severity. diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index ac06351..fc9d194 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -1,3 +1,4 @@ +import logging from importlib.metadata import version from pyfragment.client import FragmentClient @@ -20,12 +21,14 @@ from pyfragment.exceptions import ( ) from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult from pyfragment.models.cookies import CookieResult -from pyfragment.models.enums import PaymentMethod +from pyfragment.models.enums import PaymentMethod, WalletVersion from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult from pyfragment.models.wallet import WalletInfo +logging.getLogger("pyfragment").addHandler(logging.NullHandler()) + __version__: str = version("pyfragment") __all__ = [ @@ -62,5 +65,6 @@ __all__ = [ "UnexpectedError", # literal types "PaymentMethod", + "WalletVersion", "get_cookies_from_browser", ] diff --git a/pyfragment/client.py b/pyfragment/client.py index f452581..aa2bd11 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -3,12 +3,10 @@ from __future__ import annotations import json from typing import Any, cast, get_args -import httpx - -from pyfragment.core.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL, REQUIRED_COOKIE_KEYS -from pyfragment.core.transport import fragment_request, get_fragment_hash +from pyfragment.core.constants import DEFAULT_TIMEOUT, FRAGMENT_BASE_URL, REQUIRED_COOKIE_KEYS from pyfragment.domains.ads.service import AdsService from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService +from pyfragment.domains.base import raw_api_call from pyfragment.domains.giveaways.service import GiveawaysService from pyfragment.domains.marketplace.service import MarketplaceService from pyfragment.domains.purchases.service import PurchasesService @@ -31,7 +29,7 @@ class FragmentClient: connected with Fragment or Telegram. Args: - seed: 24-word mnemonic phrase for the TON wallet. + seed: 12- or 24-word mnemonic phrase for the TON wallet. api_key: Tonapi API key — get one at https://tonconsole.com. cookies: Fragment session cookies as a dict or JSON string. wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default). @@ -53,14 +51,17 @@ class FragmentClient: print(result.transaction_id) """ - def __init__( - self, - seed: str, - api_key: str, - cookies: dict[str, Any] | str, - wallet_version: str = "V5R1", - timeout: float = DEFAULT_TIMEOUT, - ) -> None: + @staticmethod + def _parse_cookies(cookies: dict[str, Any] | str) -> dict[str, Any]: + if isinstance(cookies, str): + try: + cookies = json.loads(cookies) + except Exception as exc: + raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc + return cast(dict[str, Any], cookies) + + @staticmethod + def _validate_required(seed: str, api_key: str) -> None: missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()] if missing: raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing))) @@ -72,16 +73,14 @@ class FragmentClient: if len(api_key.strip()) < 68: raise ConfigurationError(ConfigurationError.INVALID_API_KEY.format(length=len(api_key.strip()))) - if isinstance(cookies, str): - try: - cookies = json.loads(cookies) - except Exception as exc: - raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc - - missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cast(dict[str, Any], cookies).get(k, "")).strip()] + @staticmethod + def _validate_cookie_keys(cookies: dict[str, Any]) -> None: + missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cookies.get(k, "")).strip()] if missing_keys: raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys))) + @staticmethod + def _normalize_wallet_version(wallet_version: str) -> WalletVersion: version = wallet_version.strip().upper() if version not in get_args(WalletVersion): raise ConfigurationError( @@ -89,11 +88,25 @@ class FragmentClient: version=version, supported=", ".join(sorted(get_args(WalletVersion))) ) ) + return cast(WalletVersion, version) + + def __init__( + self, + seed: str, + api_key: str, + cookies: dict[str, Any] | str, + wallet_version: str = "V5R1", + timeout: float = DEFAULT_TIMEOUT, + ) -> None: + self._validate_required(seed, api_key) + parsed_cookies = self._parse_cookies(cookies) + self._validate_cookie_keys(parsed_cookies) + version = self._normalize_wallet_version(wallet_version) self.seed: str = seed.strip() self.api_key: str = api_key.strip() - self.cookies: dict[str, Any] = cast(dict[str, Any], cookies) - self.wallet_version: WalletVersion = version # type: ignore[assignment] + self.cookies: dict[str, Any] = parsed_cookies + self.wallet_version: WalletVersion = version self.timeout: float = timeout self.marketplace = MarketplaceService(self) self.purchases = PurchasesService(self) @@ -371,7 +384,4 @@ class FragmentClient: page_url="https://fragment.com/premium/gift", ) """ - headers = {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url} - async with httpx.AsyncClient(cookies=self.cookies, timeout=self.timeout) as session: - fragment_hash = await get_fragment_hash(self.cookies, headers, page_url, self.timeout) - return await fragment_request(session, fragment_hash, headers, {"method": method, **(data or {})}) + return await raw_api_call(self.cookies, self.timeout, method, data, page_url) diff --git a/pyfragment/core/cookies.py b/pyfragment/core/cookies.py index 85c568d..12cb6f1 100644 --- a/pyfragment/core/cookies.py +++ b/pyfragment/core/cookies.py @@ -9,9 +9,9 @@ from pyfragment.exceptions import CookieError from pyfragment.models.cookies import CookieResult try: - import rookiepy # type: ignore[import-not-found] -except Exception: - rookiepy = None + import rookiepy +except Exception: # noqa: BLE001 + rookiepy = None # type: ignore[assignment] def get_cookies_from_browser(browser: str = "chrome") -> CookieResult: diff --git a/pyfragment/domains/ads/recharge.py b/pyfragment/domains/ads/recharge.py index 5c6285e..4b0163e 100644 --- a/pyfragment/domains/ads/recharge.py +++ b/pyfragment/domains/ads/recharge.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import logging from typing import TYPE_CHECKING from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE @@ -13,6 +14,9 @@ if TYPE_CHECKING: from pyfragment.client import FragmentClient +logger = logging.getLogger(__name__) + + async def recharge_ads(client: FragmentClient, account: str, amount: int) -> AdsRechargeResult: if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000): raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) @@ -42,7 +46,9 @@ async def recharge_ads(client: FragmentClient, account: str, amount: int) -> Ads tx_hash = await process_transaction(client, transaction) return AdsRechargeResult(transaction_id=tx_hash, amount=amount) - except FragmentError: + except FragmentError as exc: + logger.error("Failed to recharge Ads account '%s' for %s TON: %s", account, amount, exc, exc_info=True) raise except Exception as exc: + logger.exception("Failed to recharge Ads account '%s' for %s TON due to an unexpected error", account, amount) raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/domains/ads/tonup.py b/pyfragment/domains/ads/tonup.py index c86a77c..0b30c26 100644 --- a/pyfragment/domains/ads/tonup.py +++ b/pyfragment/domains/ads/tonup.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import logging from typing import TYPE_CHECKING from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE @@ -21,6 +22,9 @@ if TYPE_CHECKING: from pyfragment.client import FragmentClient +logger = logging.getLogger(__name__) + + async def topup_ton(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000): raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) @@ -57,7 +61,9 @@ async def topup_ton(client: FragmentClient, username: str, amount: int, show_sen tx_hash = await process_transaction(client, transaction, required_payment_amount=required_payment_amount) return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount) - except FragmentError: + except FragmentError as exc: + logger.error("Failed to top up TON for user '%s' with %s TON: %s", username, amount, exc, exc_info=True) raise except Exception as exc: + logger.exception("Failed to top up TON for user '%s' with %s TON due to an unexpected error", username, amount) raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/domains/anonymous_numbers/number.py b/pyfragment/domains/anonymous_numbers/number.py index 9a84c48..8a432be 100644 --- a/pyfragment/domains/anonymous_numbers/number.py +++ b/pyfragment/domains/anonymous_numbers/number.py @@ -1,6 +1,7 @@ from __future__ import annotations import html +import logging from typing import TYPE_CHECKING from pyfragment.core.constants import NUMBERS_PAGE @@ -12,6 +13,9 @@ if TYPE_CHECKING: from pyfragment.client import FragmentClient +logger = logging.getLogger(__name__) + + def _strip_plus(number: str) -> str: return number.lstrip("+") if isinstance(number, str) else number @@ -32,9 +36,11 @@ async def get_login_code(client: FragmentClient, number: str) -> LoginCodeResult return LoginCodeResult(number=number, code=code, active_sessions=active_sessions) - except FragmentError: + except FragmentError as exc: + logger.error("Failed to get login code for number '%s': %s", number, exc, exc_info=True) raise except Exception as exc: + logger.exception("Failed to get login code for number '%s' due to an unexpected error", number) raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc @@ -50,9 +56,21 @@ async def toggle_login_codes(client: FragmentClient, number: str, can_receive: b if result.get("error"): raise FragmentAPIError(html.unescape(result["error"])) - except FragmentError: + except FragmentError as exc: + logger.error( + "Failed to toggle login code delivery for number '%s' (can_receive=%s): %s", + number, + can_receive, + exc, + exc_info=True, + ) raise except Exception as exc: + logger.exception( + "Failed to toggle login code delivery for number '%s' (can_receive=%s) due to an unexpected error", + number, + can_receive, + ) raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc @@ -88,7 +106,9 @@ async def terminate_sessions(client: FragmentClient, number: str) -> TerminateSe return TerminateSessionsResult(number=number, message=result.get("msg")) - except FragmentError: + except FragmentError as exc: + logger.error("Failed to terminate sessions for number '%s': %s", number, exc, exc_info=True) raise except Exception as exc: + logger.exception("Failed to terminate sessions for number '%s' due to an unexpected error", number) raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/domains/base.py b/pyfragment/domains/base.py index 01d5ca5..6dce538 100644 --- a/pyfragment/domains/base.py +++ b/pyfragment/domains/base.py @@ -1,10 +1,39 @@ from __future__ import annotations -from typing import TYPE_CHECKING +import logging +from typing import TYPE_CHECKING, Any + +import httpx + +from pyfragment.core.constants import BASE_HEADERS +from pyfragment.core.transport import fragment_request, get_fragment_hash if TYPE_CHECKING: from pyfragment.client import FragmentClient +logger = logging.getLogger(__name__) + + +async def raw_api_call( + cookies: dict[str, Any], + timeout: float, + method: str, + data: dict[str, Any] | None, + page_url: str, +) -> dict[str, Any]: + payload = {"method": method, **(data or {})} + headers = {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url} + logger.debug("Starting Fragment API call '%s' on %s", method, page_url) + try: + async with httpx.AsyncClient(cookies=cookies, timeout=timeout) as session: + fragment_hash = await get_fragment_hash(cookies, headers, page_url, timeout) + response = await fragment_request(session, fragment_hash, headers, payload) + logger.debug("Completed Fragment API call '%s' with response keys: %s", method, sorted(response.keys())) + return response + except Exception: + logger.exception("Failed to call Fragment API method '%s' on %s", method, page_url) + raise + class BaseService: def __init__(self, client: FragmentClient) -> None: diff --git a/pyfragment/domains/giveaways/giveaway.py b/pyfragment/domains/giveaways/giveaway.py index ab1af30..e8ab668 100644 --- a/pyfragment/domains/giveaways/giveaway.py +++ b/pyfragment/domains/giveaways/giveaway.py @@ -1,6 +1,8 @@ from __future__ import annotations import json +import logging +import random from typing import TYPE_CHECKING, get_args from pyfragment.core.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, STARS_GIVEAWAY_PAGE @@ -22,6 +24,9 @@ if TYPE_CHECKING: from pyfragment.client import FragmentClient +logger = logging.getLogger(__name__) + + async def giveaway_stars( client: FragmentClient, channel: str, @@ -47,6 +52,12 @@ async def giveaway_stars( if not recipient: raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel)) + await client.call( + "updateStarsGiveawayState", + {"mode": "new", "lv": "false", "dh": str(random.randint(100_000_000, 999_999_999))}, + page_url=STARS_GIVEAWAY_PAGE, + ) + result = await client.call( "initGiveawayStarsRequest", { @@ -84,9 +95,25 @@ async def giveaway_stars( ) return StarsGiveawayResult(transaction_id=tx_hash, channel=channel, winners=winners, amount=amount) - except FragmentError: + except FragmentError as exc: + logger.error( + "Failed to run Stars giveaway for channel '%s' (winners=%s, amount=%s, payment_method='%s'): %s", + channel, + winners, + amount, + payment_method, + exc, + exc_info=True, + ) raise except Exception as exc: + logger.exception( + "Failed to run Stars giveaway for channel '%s' (winners=%s, amount=%s, payment_method='%s') due to an unexpected error", + channel, + winners, + amount, + payment_method, + ) raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc @@ -119,6 +146,17 @@ async def giveaway_premium( if not recipient: raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel)) + await client.call( + "updatePremiumGiveawayState", + { + "mode": "new", + "lv": "false", + "dh": str(random.randint(100_000_000, 999_999_999)), + "quantity": "", + }, + page_url=PREMIUM_GIVEAWAY_PAGE, + ) + result = await client.call( "initGiveawayPremiumRequest", { @@ -156,7 +194,23 @@ async def giveaway_premium( ) return PremiumGiveawayResult(transaction_id=tx_hash, channel=channel, winners=winners, amount=months) - except FragmentError: + except FragmentError as exc: + logger.error( + "Failed to run Premium giveaway for channel '%s' (winners=%s, months=%s, payment_method='%s'): %s", + channel, + winners, + months, + payment_method, + exc, + exc_info=True, + ) raise except Exception as exc: + logger.exception( + "Failed to run Premium giveaway for channel '%s' (winners=%s, months=%s, payment_method='%s') due to an unexpected error", + channel, + winners, + months, + payment_method, + ) raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/domains/marketplace/search.py b/pyfragment/domains/marketplace/search.py index a7f6807..2f5b6d5 100644 --- a/pyfragment/domains/marketplace/search.py +++ b/pyfragment/domains/marketplace/search.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from typing import TYPE_CHECKING, Any from pyfragment.core.constants import FRAGMENT_BASE_URL, GIFTS_PAGE, NUMBERS_PAGE @@ -11,6 +12,9 @@ if TYPE_CHECKING: from pyfragment.client import FragmentClient +logger = logging.getLogger(__name__) + + async def search_usernames( client: FragmentClient, query: str = "", @@ -36,9 +40,19 @@ async def search_usernames( next_offset_id = str(raw_noi) if raw_noi else None return UsernamesResult(items=items, next_offset_id=next_offset_id) - except FragmentError: + except FragmentError as exc: + logger.error( + "Failed to search usernames (query='%s', sort='%s', filter='%s', offset_id='%s'): %s", + query, + sort, + filter, + offset_id, + exc, + exc_info=True, + ) raise except Exception as exc: + logger.exception("Failed to search usernames for query '%s' due to an unexpected error", query) raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc @@ -67,9 +81,19 @@ async def search_numbers( next_offset_id = str(raw_noi) if raw_noi else None return NumbersResult(items=items, next_offset_id=next_offset_id) - except FragmentError: + except FragmentError as exc: + logger.error( + "Failed to search numbers (query='%s', sort='%s', filter='%s', offset_id='%s'): %s", + query, + sort, + filter, + offset_id, + exc, + exc_info=True, + ) raise except Exception as exc: + logger.exception("Failed to search numbers for query '%s' due to an unexpected error", query) raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc @@ -106,7 +130,19 @@ async def search_gifts( items, next_offset = parse_gift_items(result.get("html") or "") return GiftsResult(items=items, next_offset=next_offset) - except FragmentError: + except FragmentError as exc: + logger.error( + "Failed to search gifts (query='%s', collection='%s', sort='%s', filter='%s', view='%s', offset='%s'): %s", + query, + collection, + sort, + filter, + view, + offset, + exc, + exc_info=True, + ) raise except Exception as exc: + logger.exception("Failed to search gifts for query '%s' due to an unexpected error", query) raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/domains/purchases/purchase.py b/pyfragment/domains/purchases/purchase.py index 52d24e4..5d60c5d 100644 --- a/pyfragment/domains/purchases/purchase.py +++ b/pyfragment/domains/purchases/purchase.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import logging import time from typing import TYPE_CHECKING, get_args @@ -23,6 +24,9 @@ if TYPE_CHECKING: from pyfragment.client import FragmentClient +logger = logging.getLogger(__name__) + + async def purchase_stars( client: FragmentClient, username: str, @@ -84,9 +88,23 @@ async def purchase_stars( ) return StarsResult(transaction_id=tx_hash, username=username, amount=amount) - except FragmentError: + except FragmentError as exc: + logger.error( + "Failed to purchase %s Stars for user '%s' using '%s': %s", + amount, + username, + payment_method, + exc, + exc_info=True, + ) raise except Exception as exc: + logger.exception( + "Failed to purchase %s Stars for user '%s' using '%s' due to an unexpected error", + amount, + username, + payment_method, + ) raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc @@ -151,7 +169,21 @@ async def purchase_premium( ) return PremiumResult(transaction_id=tx_hash, username=username, amount=months) - except FragmentError: + except FragmentError as exc: + logger.error( + "Failed to purchase %s months of Premium for user '%s' using '%s': %s", + months, + username, + payment_method, + exc, + exc_info=True, + ) raise except Exception as exc: + logger.exception( + "Failed to purchase %s months of Premium for user '%s' using '%s' due to an unexpected error", + months, + username, + payment_method, + ) raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/domains/tonapi/__init__.py b/pyfragment/domains/tonapi/__init__.py index e8f85e3..42b4b85 100644 --- a/pyfragment/domains/tonapi/__init__.py +++ b/pyfragment/domains/tonapi/__init__.py @@ -1,23 +1,5 @@ -from pyfragment.domains.tonapi.account import ( - check_ton_payment_balance, - check_usdt_payment_balance, - get_account_info, - get_usdt_balance, - get_wallet_info, -) from pyfragment.domains.tonapi.service import TonapiService -from pyfragment.domains.tonapi.transaction import clean_decode, process_transaction -from pyfragment.domains.tonapi.transfer import send_ton_transfer, send_usdt_transfer __all__ = [ "TonapiService", - "clean_decode", - "check_ton_payment_balance", - "check_usdt_payment_balance", - "get_account_info", - "get_usdt_balance", - "get_wallet_info", - "process_transaction", - "send_ton_transfer", - "send_usdt_transfer", ] diff --git a/pyfragment/domains/tonapi/account.py b/pyfragment/domains/tonapi/account.py index cd150fd..c532d35 100644 --- a/pyfragment/domains/tonapi/account.py +++ b/pyfragment/domains/tonapi/account.py @@ -1,6 +1,7 @@ from __future__ import annotations import base64 +import logging from typing import TYPE_CHECKING, Any from ton_core import NetworkGlobalID @@ -16,6 +17,9 @@ if TYPE_CHECKING: from pyfragment.client import FragmentClient +logger = logging.getLogger(__name__) + + async def get_usdt_balance(ton: Any, wallet_address: str) -> float: """Return the USDT balance for a Fragment-linked TON wallet.""" try: @@ -29,9 +33,12 @@ async def get_usdt_balance(ton: Any, wallet_address: str) -> float: return float(raw_balance) / 1_000_000.0 except ProviderResponseError as exc: if exc.code == 404: + logger.debug("No USDT jetton wallet found for '%s'; treating balance as 0", wallet_address) return 0.0 + logger.error("Failed to load USDT balance for wallet '%s': %s", wallet_address, exc, exc_info=True) raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc except Exception as exc: + logger.exception("Failed to load USDT balance for wallet '%s' due to an unexpected error", wallet_address) raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc @@ -47,6 +54,11 @@ async def check_ton_payment_balance( required_ton = max(tx_price_ton, MIN_TON_BALANCE) if balance_ton < required_ton: + logger.error( + "Failed TON balance check: balance=%s TON, required=%s TON", + round(balance_ton, 6), + round(required_ton, 6), + ) raise WalletError(WalletError.LOW_TON_BALANCE.format(balance=balance_ton, required=required_ton)) @@ -58,11 +70,22 @@ async def check_usdt_payment_balance( ) -> None: """Validate that the wallet can cover a USDT-denominated payment.""" if balance_ton < MIN_TON_BALANCE: + logger.error( + "Failed TON gas reserve check for USDT payment: balance=%s TON, required=%s TON", + round(balance_ton, 6), + MIN_TON_BALANCE, + ) raise WalletError(WalletError.LOW_TON_BALANCE.format(balance=balance_ton, required=MIN_TON_BALANCE)) usdt_balance = await get_usdt_balance(ton, wallet_address) required_usdt = required_payment_amount if required_payment_amount is not None else MIN_USDT_BALANCE if usdt_balance < required_usdt: + logger.error( + "Failed USDT balance check for wallet '%s': balance=%s USDT, required=%s USDT", + wallet_address, + round(usdt_balance, 6), + round(required_usdt, 6), + ) raise WalletError(WalletError.LOW_USDT_BALANCE.format(balance=usdt_balance, required=required_usdt)) @@ -80,6 +103,7 @@ async def get_account_info(client: FragmentClient) -> dict[str, Any]: "walletStateInit": base64.b64encode(boc).decode(), } except Exception as exc: + logger.exception("Failed to build Fragment account info from the configured wallet") raise WalletError(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc @@ -99,4 +123,5 @@ async def get_wallet_info(client: FragmentClient) -> WalletInfo: usdt_balance=round(usdt_balance, 4), ) except Exception as exc: + logger.exception("Failed to fetch wallet info from Tonapi") raise WalletError(WalletError.WALLET_INFO_FAILED.format(exc=exc)) from exc diff --git a/pyfragment/domains/tonapi/transaction.py b/pyfragment/domains/tonapi/transaction.py index 157ba7b..7cb5263 100644 --- a/pyfragment/domains/tonapi/transaction.py +++ b/pyfragment/domains/tonapi/transaction.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import base64 +import logging import random import ssl from typing import TYPE_CHECKING, Any @@ -19,6 +20,9 @@ if TYPE_CHECKING: from pyfragment.client import FragmentClient +logger = logging.getLogger(__name__) + + def clean_decode(payload: str) -> str | Cell: """Decode a base64 BOC comment from Fragment into text when possible. @@ -43,9 +47,81 @@ def clean_decode(payload: str) -> str | Cell: except UnicodeDecodeError: return cell except Exception as exc: + logger.exception("Failed to decode Fragment payload") raise ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc +def _extract_message(transaction_data: dict[str, Any]) -> dict[str, Any]: + """Validate and extract the first message from a Fragment transaction payload.""" + if "transaction" not in transaction_data or not transaction_data["transaction"].get("messages"): + logger.error("Failed to process transaction: missing transaction payload or messages") + raise TransactionError(TransactionError.INVALID_PAYLOAD) + result: dict[str, Any] = transaction_data["transaction"]["messages"][0] + return result + + +async def _check_payment_balances( + wallet: Any, + payment_method: PaymentMethod, + amount_ton: float, + required_payment_amount: float | None, + transaction_data: dict[str, Any], + ton: Any, +) -> None: + """Refresh wallet and verify sufficient balance before broadcasting.""" + try: + await wallet.refresh() + balance_ton = wallet.balance / 1_000_000_000 + if payment_method == "ton": + await check_ton_payment_balance(balance_ton, amount_ton, required_payment_amount) + else: + # USDT is paid from the Fragment-linked wallet, not the signing wallet. + fragment_wallet_address = transaction_data["transaction"].get("from", "") + await check_usdt_payment_balance(balance_ton, required_payment_amount, ton, fragment_wallet_address) + except WalletError: + raise + except Exception as exc: + logger.exception("Failed to validate balances before broadcasting transaction") + raise WalletError(WalletError.TON_BALANCE_CHECK_FAILED.format(exc=exc)) from exc + + +async def _broadcast_with_retry(wallet: Any, message: dict[str, Any], payload: str | Cell) -> str: + """Attempt to broadcast a transaction up to 3 times, handling rate-limit and seqno errors.""" + for attempt in range(3): + try: + result = await wallet.transfer( + destination=message["address"], + amount=int(message["amount"]), # nanotons, not TON + body=payload, + ) + return str(result.normalized_hash) + except ProviderResponseError as exc: + if exc.code == 429 and attempt == 0: + logger.warning( + "Broadcast rate-limited (429), retrying transaction once: %s", + exc, + exc_info=True, + ) + await asyncio.sleep(1 + random.uniform(0, 0.5)) + continue + if exc.code == 406 and "seqno" in str(exc).lower(): + if attempt < 2: + logger.warning( + "Broadcast seqno conflict (406), retrying attempt %s: %s", + attempt + 2, + exc, + exc_info=True, + ) + await asyncio.sleep(2 + random.uniform(0, 1)) + continue + logger.error("Failed to broadcast transaction after seqno retries") + raise TransactionError(TransactionError.DUPLICATE_SEQNO) from exc + raise + + logger.error("Failed to broadcast transaction: transfer loop exited without result") + raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result")) + + async def process_transaction( client: FragmentClient, transaction_data: dict[str, Any], @@ -63,62 +139,32 @@ async def process_transaction( Returns: Normalized transaction hash string. """ - if "transaction" not in transaction_data or not transaction_data["transaction"].get("messages"): - raise TransactionError(TransactionError.INVALID_PAYLOAD) - - message = transaction_data["transaction"]["messages"][0] + message = _extract_message(transaction_data) amount_ton = int(message["amount"]) / 1_000_000_000 async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton: wallet_cls = WALLET_CLASSES[client.wallet_version] wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed) - # Check balance first so we fail before trying to broadcast on-chain. - try: - await wallet.refresh() - balance_ton = wallet.balance / 1_000_000_000 - if payment_method == "ton": - wallet.address.to_str(False, False) - await check_ton_payment_balance(balance_ton, amount_ton, required_payment_amount) - else: - # USDT is paid from the Fragment-linked wallet, not the signing wallet. - fragment_wallet_address = transaction_data["transaction"].get("from", "") - await check_usdt_payment_balance(balance_ton, required_payment_amount, ton, fragment_wallet_address) - except WalletError: - raise - except Exception as exc: - raise WalletError(WalletError.TON_BALANCE_CHECK_FAILED.format(exc=exc)) from exc + await _check_payment_balances(wallet, payment_method, amount_ton, required_payment_amount, transaction_data, ton) + + payload = clean_decode(str(message.get("payload", ""))) try: - raw_payload = str(message.get("payload", "")) - payload = clean_decode(raw_payload) - - for attempt in range(3): - try: - result = await wallet.transfer( - destination=message["address"], - amount=int(message["amount"]), # nanotons, not TON - body=payload, - ) - return str(result.normalized_hash) - except ProviderResponseError as exc: - if exc.code == 429 and attempt == 0: - await asyncio.sleep(1 + random.uniform(0, 0.5)) - continue - if exc.code == 406 and "seqno" in str(exc).lower(): - if attempt < 2: - await asyncio.sleep(2 + random.uniform(0, 1)) - continue - raise TransactionError(TransactionError.DUPLICATE_SEQNO) from exc - raise + return await _broadcast_with_retry(wallet, message, payload) except (WalletError, TransactionError): raise except Exception as exc: cause: BaseException | None = exc while cause is not None: if isinstance(cause, ssl.SSLError): + logger.exception("Failed to broadcast transaction due to SSL error") raise TransactionError(TransactionError.BROADCAST_FAILED_SSL.format(exc=exc)) from exc cause = cause.__cause__ or cause.__context__ + logger.exception( + "Failed to broadcast transaction to '%s' for %s nanotons using payment method '%s'", + message["address"], + message["amount"], + payment_method, + ) raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc - - raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result")) diff --git a/pyfragment/domains/tonapi/transfer.py b/pyfragment/domains/tonapi/transfer.py deleted file mode 100644 index 0870a89..0000000 --- a/pyfragment/domains/tonapi/transfer.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from ton_core import Address, NetworkGlobalID -from tonutils.clients import ToncenterClient -from tonutils.contracts import JettonTransferBuilder, TONTransferBuilder - -from pyfragment.core.constants import USDT_TON_MASTER_ADDRESS, WALLET_CLASSES -from pyfragment.exceptions import TransactionError, WalletError -from pyfragment.models.wallet import TonTransferResult, UsdtTransferResult - -if TYPE_CHECKING: - from pyfragment.client import FragmentClient - - -async def send_ton_transfer( - client: FragmentClient, - destination: str, - amount: int, - body: str | None = None, -) -> TonTransferResult: - """Send a direct TON transfer from the seeded wallet. - - Args: - client: Authenticated `FragmentClient` instance. - destination: Recipient address in any TON-compatible format. - amount: Amount in nanotons. - body: Optional on-chain comment. - """ - try: - async with ToncenterClient(network=NetworkGlobalID.MAINNET) as ton: - wallet_cls = WALLET_CLASSES[client.wallet_version] - wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed) - result = await wallet.transfer_message( - TONTransferBuilder( - destination=Address(destination), - amount=amount, - body=body, - ) - ) - return TonTransferResult( - transaction_id=str(result.normalized_hash), - destination=destination, - amount=amount, - ) - except (TransactionError, WalletError): - raise - except Exception as exc: - raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc - - -async def send_usdt_transfer( - client: FragmentClient, - destination: str, - usdt_amount: int, - forward_payload: str | None = None, - ton_for_gas: int = 50_000_000, -) -> UsdtTransferResult: - """Send a direct USDT transfer from the seeded wallet. - - Args: - client: Authenticated `FragmentClient` instance. - destination: Recipient address in any TON-compatible format. - usdt_amount: Amount in USDT base units (6 decimals). - forward_payload: Optional comment passed through to the recipient. - ton_for_gas: TON attached for gas in nanotons. - """ - try: - async with ToncenterClient(network=NetworkGlobalID.MAINNET) as ton: - wallet_cls = WALLET_CLASSES[client.wallet_version] - wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed) - result = await wallet.transfer_message( - JettonTransferBuilder( - destination=Address(destination), - jetton_amount=usdt_amount, - jetton_master_address=Address(USDT_TON_MASTER_ADDRESS), - forward_payload=forward_payload, - forward_amount=1, - amount=ton_for_gas, - ) - ) - return UsdtTransferResult( - transaction_id=str(result.normalized_hash), - destination=destination, - amount=usdt_amount, - ) - except (TransactionError, WalletError): - raise - except Exception as exc: - raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc diff --git a/pyfragment/models/__init__.py b/pyfragment/models/__init__.py index fc4a5cb..21b9b7e 100644 --- a/pyfragment/models/__init__.py +++ b/pyfragment/models/__init__.py @@ -4,7 +4,7 @@ from pyfragment.models.enums import PaymentMethod, WalletVersion from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult -from pyfragment.models.wallet import TonTransferResult, UsdtTransferResult, WalletInfo +from pyfragment.models.wallet import WalletInfo __all__ = [ "AdsRechargeResult", @@ -19,9 +19,7 @@ __all__ = [ "StarsGiveawayResult", "StarsResult", "TerminateSessionsResult", - "TonTransferResult", "UsernamesResult", - "UsdtTransferResult", "WalletInfo", "WalletVersion", ] diff --git a/pyfragment/models/wallet.py b/pyfragment/models/wallet.py index 17d38ff..c52fdac 100644 --- a/pyfragment/models/wallet.py +++ b/pyfragment/models/wallet.py @@ -17,24 +17,4 @@ class WalletInfo: ) -@dataclass -class TonTransferResult: - transaction_id: str - destination: str - amount: int - - def __repr__(self) -> str: - return f"TonTransferResult(destination='{self.destination}', amount={self.amount} TON, tx='{self.transaction_id}')" - - -@dataclass -class UsdtTransferResult: - transaction_id: str - destination: str - amount: int - - def __repr__(self) -> str: - return f"UsdtTransferResult(destination='{self.destination}', amount={self.amount} USDT, tx='{self.transaction_id}')" - - -__all__ = ["TonTransferResult", "UsdtTransferResult", "WalletInfo"] +__all__ = ["WalletInfo"] diff --git a/pyproject.toml b/pyproject.toml index 423257f..dcda488 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pyfragment" -version = "2026.3.0" +version = "2026.3.1" description = "Async Python client for the Fragment API. Buy Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and search Fragment listings." readme = "README.md" license = { text = "MIT" } @@ -21,7 +21,6 @@ keywords = [ "telegram-ads", "ton", "ton-blockchain", - "tonkeeper", "tonapi", "anonymous-numbers", "username-auctions", @@ -36,7 +35,6 @@ keywords = [ classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", - "Intended Audience :: Financial and Insurance Industry", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: OS Independent", @@ -45,13 +43,13 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Framework :: AsyncIO", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", - "Topic :: Office/Business :: Financial", - "Topic :: Office/Business :: Financial :: Investment", "Typing :: Typed", ] dependencies = ["httpx>=0.25", "tonutils>=2.0.1"] @@ -78,7 +76,7 @@ addopts = "-v --tb=short" [tool.ruff] line-length = 128 -target-version = "py312" +target-version = "py310" [tool.ruff.lint] # E — pycodestyle errors, F — pyflakes, W — warnings, I — isort, UP — pyupgrade @@ -89,9 +87,18 @@ ignore = ["E501", "UP017"] [tool.ruff.lint.per-file-ignores] "tests/*" = ["E402"] -"systests/*" = ["E402"] [tool.mypy] python_version = "3.10" strict = true -exclude = ["^systests/", "^examples/"] +exclude = ["^examples/"] + +[[tool.mypy.overrides]] +module = "rookiepy" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +# rookiepy = None assignment is valid when the package is installed (type: ModuleType) +# but mypy on CI (without rookiepy) sees it as Any and flags the comment as unused. +module = "pyfragment.core.cookies" +warn_unused_ignores = false diff --git a/tests/004_test_stars.py b/tests/004_test_stars.py index 69428d7..e6c583a 100644 --- a/tests/004_test_stars.py +++ b/tests/004_test_stars.py @@ -1,12 +1,11 @@ """Cover stars purchase and giveaway flows, including validation and request wiring.""" -import importlib from unittest.mock import AsyncMock, patch import pytest -_purchase_stars_mod = importlib.import_module("pyfragment.domains.purchases.purchase") -_giveaway_stars_mod = importlib.import_module("pyfragment.domains.giveaways.giveaway") +import pyfragment.domains.giveaways.giveaway as _giveaway_stars_mod +import pyfragment.domains.purchases.purchase as _purchase_stars_mod from pyfragment import ConfigurationError, FragmentClient, StarsGiveawayResult, StarsResult, UserNotFoundError from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH @@ -165,6 +164,7 @@ async def test_giveaway_stars_success(client: FragmentClient) -> None: AsyncMock( side_effect=[ {"found": {"recipient": FAKE_RECIPIENT}}, + {}, {"req_id": FAKE_REQ_ID}, FAKE_TRANSACTION, ] @@ -187,6 +187,7 @@ async def test_giveaway_stars_passes_payment_method(client: FragmentClient) -> N call_mock = AsyncMock( side_effect=[ {"found": {"recipient": FAKE_RECIPIENT}}, + {}, {"req_id": FAKE_REQ_ID}, FAKE_TRANSACTION, ] @@ -199,7 +200,7 @@ async def test_giveaway_stars_passes_payment_method(client: FragmentClient) -> N ): await client.giveaway_stars("@channel", winners=3, amount=1000, payment_method="usdt_ton") - init_call = call_mock.await_args_list[1] + init_call = call_mock.await_args_list[2] assert init_call.args[0] == "initGiveawayStarsRequest" assert init_call.args[1]["payment_method"] == "usdt_ton" assert proc_mock.await_args is not None diff --git a/tests/005_test_premium.py b/tests/005_test_premium.py index 5ff8caf..df07816 100644 --- a/tests/005_test_premium.py +++ b/tests/005_test_premium.py @@ -1,12 +1,11 @@ """Cover premium purchase and giveaway flows, including validation and request wiring.""" -import importlib from unittest.mock import AsyncMock, patch import pytest -_purchase_premium_mod = importlib.import_module("pyfragment.domains.purchases.purchase") -_giveaway_premium_mod = importlib.import_module("pyfragment.domains.giveaways.giveaway") +import pyfragment.domains.giveaways.giveaway as _giveaway_premium_mod +import pyfragment.domains.purchases.purchase as _purchase_premium_mod from pyfragment import ConfigurationError, FragmentClient, PremiumGiveawayResult, PremiumResult, UserNotFoundError from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH @@ -150,6 +149,7 @@ async def test_giveaway_premium_success(client: FragmentClient) -> None: AsyncMock( side_effect=[ {"found": {"recipient": FAKE_RECIPIENT}}, + {}, {"req_id": FAKE_REQ_ID}, FAKE_TRANSACTION, ] @@ -172,6 +172,7 @@ async def test_giveaway_premium_passes_payment_method(client: FragmentClient) -> call_mock = AsyncMock( side_effect=[ {"found": {"recipient": FAKE_RECIPIENT}}, + {}, {"req_id": FAKE_REQ_ID}, FAKE_TRANSACTION, ] @@ -184,7 +185,7 @@ async def test_giveaway_premium_passes_payment_method(client: FragmentClient) -> ): await client.giveaway_premium("@channel", winners=10, months=6, payment_method="usdt_ton") - init_call = call_mock.await_args_list[1] + init_call = call_mock.await_args_list[2] assert init_call.args[0] == "initGiveawayPremiumRequest" assert init_call.args[1]["payment_method"] == "usdt_ton" assert proc_mock.await_args is not None diff --git a/tests/006_test_topup.py b/tests/006_test_topup.py index 0fd366d..f860ecc 100644 --- a/tests/006_test_topup.py +++ b/tests/006_test_topup.py @@ -1,11 +1,10 @@ """Cover TON top-up through Telegram Ads, including recipient lookup and transaction building.""" -import importlib from unittest.mock import AsyncMock, patch import pytest -_topup_ton_mod = importlib.import_module("pyfragment.domains.ads.tonup") +import pyfragment.domains.ads.tonup as _topup_ton_mod from pyfragment import AdsTopupResult, ConfigurationError, FragmentClient, UserNotFoundError from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH diff --git a/tests/008_test_call.py b/tests/008_test_call.py index 2ff6e75..17c42a1 100644 --- a/tests/008_test_call.py +++ b/tests/008_test_call.py @@ -15,8 +15,8 @@ from tests.shared import FAKE_HASH, FAKE_RESPONSE @pytest.mark.asyncio async def test_call_returns_api_response(client: FragmentClient) -> None: with ( - patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)), + patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.domains.base.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)), ): result = await client.call("anyMethod", {"key": "value"}) @@ -26,8 +26,8 @@ async def test_call_returns_api_response(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_call_default_page_url(client: FragmentClient) -> None: with ( - patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)), + patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.domains.base.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)), ): result = await client.call("anyMethod") @@ -39,8 +39,8 @@ async def test_call_no_data(client: FragmentClient) -> None: mock_request = AsyncMock(return_value={}) with ( - patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.client.fragment_request", mock_request), + patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.domains.base.fragment_request", mock_request), ): await client.call("anyMethod") @@ -53,8 +53,8 @@ async def test_call_merges_extra_data(client: FragmentClient) -> None: mock_request = AsyncMock(return_value={}) with ( - patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.client.fragment_request", mock_request), + patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.domains.base.fragment_request", mock_request), ): await client.call("anyMethod", {"key": "value", "num": 7}) diff --git a/tests/010_test_recharge_ads.py b/tests/010_test_recharge_ads.py index 9706b48..5feff7a 100644 --- a/tests/010_test_recharge_ads.py +++ b/tests/010_test_recharge_ads.py @@ -1,11 +1,10 @@ """Cover Telegram Ads recharge flow, including request preparation and KYC handling.""" -import importlib from unittest.mock import AsyncMock, patch import pytest -_recharge_ads_mod = importlib.import_module("pyfragment.domains.ads.recharge") +import pyfragment.domains.ads.recharge as _recharge_ads_mod from pyfragment import AdsRechargeResult, ConfigurationError, FragmentClient from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH