From 3e14a01c92904fe8823407ac10f2baa042226581 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Fri, 20 Mar 2026 20:16:43 +0200 Subject: [PATCH 01/21] refactor: harden client, clean tests, and fix timeouts - Security & validation: tighten hash regex, add HTTP timeouts to all requests, pass client.timeout through get_fragment_hash and AsyncClient - Constants: move all constants to types/constants.py; remove re-exports from types/__init__.py; add DEFAULT_TIMEOUT, REQUIRED_COOKIE_KEYS - FragmentClient: add timeout param (default 30 s); async-context-manager support; remove WALLET_CLASSES from public API - Exceptions: remove dead INVALID_USERNAME constant (Fragment validates server-side); keep full hierarchy intact - Tests: add 006_test_methods_mock.py (6 mock tests for all 3 methods); DRY-refactor 004_test_balance.py (_patch_wallet context manager); clean up 005_test_methods.py (remove fragile network test, rename tests) - Examples: switch all 4 examples to async-with; align error messages; replace %-format with f-strings - README: rewrite usage section with single comprehensive async-with example covering all 3 methods and full exception hierarchy - CI: add mypy step to lint job; add pytest-mock and mypy to dev deps; set FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 on all jobs; fix COOKIES_JSON to job-level env var --- .github/workflows/ci.yml | 13 +-- .github/workflows/publish.yml | 8 ++ README.md | 182 ++++++++++++--------------------- examples/get_wallet.py | 20 ++-- examples/purchase_premium.py | 25 ++--- examples/purchase_stars.py | 25 ++--- examples/topup_ton.py | 31 +++--- pyfragment/client.py | 7 +- pyfragment/methods/premium.py | 8 +- pyfragment/methods/stars.py | 8 +- pyfragment/methods/ton.py | 8 +- pyfragment/types/__init__.py | 23 ----- pyfragment/types/constants.py | 3 + pyfragment/types/exceptions.py | 3 + pyfragment/utils/http.py | 5 +- pyfragment/utils/wallet.py | 3 +- pyproject.toml | 2 + tests/003_test_hash.py | 2 +- tests/004_test_balance.py | 88 +++++----------- tests/005_test_methods.py | 21 ++-- tests/006_test_methods_mock.py | 146 ++++++++++++++++++++++++++ tests/conftest.py | 13 ++- 22 files changed, 335 insertions(+), 309 deletions(-) create mode 100644 tests/006_test_methods_mock.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57e35cb..80e25d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,8 @@ jobs: lint: name: Lint & Format runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - uses: actions/checkout@v6.0.2 @@ -26,9 +28,14 @@ jobs: - run: black --check . --target-version py312 + - run: mypy pyfragment + test: name: Tests runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + COOKIES_JSON: ${{ secrets.COOKIES_JSON }} steps: - uses: actions/checkout@v6.0.2 @@ -41,10 +48,4 @@ jobs: - run: uv pip install --system ".[dev]" - - name: Write cookies.json - if: ${{ env.COOKIES_JSON != '' }} - run: echo "$COOKIES_JSON" > cookies.json - env: - COOKIES_JSON: ${{ secrets.COOKIES_JSON }} - - run: pytest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1b575e9..2f33c1f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,6 +11,8 @@ jobs: 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 }} @@ -40,6 +42,8 @@ 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.0.2 @@ -61,6 +65,8 @@ jobs: name: Publish to PyPI needs: [version-check, build] runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true environment: name: pypi url: https://pypi.org/project/pyfragment/ @@ -79,6 +85,8 @@ jobs: name: GitHub Release needs: [version-check, build] runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true permissions: contents: write diff --git a/README.md b/README.md index ccb7c8b..6cd5668 100644 --- a/README.md +++ b/README.md @@ -22,36 +22,58 @@ --- -## ✨ Features - -- 💰 **TON Advertisement Topups** — Top up Telegram Ads balance (1–1,000,000,000 TON) -- 👑 **Telegram Premium** — Purchase Premium for any user (3, 6, or 12 months) -- ⭐ **Telegram Stars Purchases** — Purchase Stars for any Telegram user (50–1,000,000 Stars) -- 🔐 **Multi-wallet support** — V4R2 and V5R1 wallet contract versions -- ⚡ **Async-first** — Built on `httpx` and `asyncio` - ---- - -## 📦 Installation +## Installation ```bash pip install pyfragment ``` -Requires **Python 3.12+**. +Requires Python 3.12+. --- -## 🚀 Quick Start +## 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), 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 as a 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 ```python import asyncio -from pyfragment import FragmentClient +from pyfragment import ( + FragmentClient, + FragmentError, # base — catches everything below + UserNotFoundError, # username doesn't exist on Fragment + WalletError, # insufficient balance or misconfiguration + CookieError, # cookies are missing or expired + TransactionError, # on-chain broadcast failed + ConfigurationError, # invalid argument (months, amount, etc.) + FragmentAPIError, # unexpected Fragment API response +) -async def main(): + +async def main() -> None: async with FragmentClient( - seed="word1 word2 ... word24", - api_key="YOUR_TONAPI_KEY", + seed="word1 word2 ... word24", # 24-word TON wallet mnemonic + api_key="YOUR_TONAPI_KEY", # from tonconsole.com cookies={ "stel_ssid": "...", "stel_dt": "...", @@ -59,110 +81,40 @@ async def main(): "stel_ton_token": "...", }, ) as client: - # Purchase 6 months of Telegram Premium - result = await client.purchase_premium("@username", months=6) - print(result.transaction_id) + try: + # Purchase 6 months of Telegram Premium for a user + result = await client.purchase_premium("@username", months=6) + print(f"{result.months} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}") - # Purchase 500 Stars - result = await client.purchase_stars("@username", amount=500) - print(result.transaction_id) + # Purchase 500 Stars for a user (50–1 000 000) + result = await client.purchase_stars("@username", amount=500) + print(f"{result.stars} Stars successfully sent to {result.username} | tx: {result.transaction_id}") + + # Top up 10 TON to Telegram Ads balance + # wallet must hold at least amount + ~0.056 TON for gas + result = await client.topup_ton("@username", amount=10) + print(f"{result.amount} TON successfully sent to {result.username} | tx: {result.transaction_id}") + + except UserNotFoundError: + print(f"User was not found on fragment.com — check the username and try again.") + except WalletError as e: + print(f"Wallet error — insufficient balance or misconfiguration: {e}") + except CookieError: + print("Authentication failed — session cookies are missing or expired. Refresh them and retry.") + except TransactionError as e: + print(f"Transaction failed to broadcast on-chain: {e}") + except ConfigurationError as e: + print(f"Invalid argument: {e}") + except FragmentAPIError as e: + print(f"Unexpected response from Fragment API: {e}") + except FragmentError as e: + # catch-all for any other pyfragment error + print(f"Unexpected error: {e}") - # Top up 10 TON to Ads balance - result = await client.topup_ton("@username", amount=10) - print(result.transaction_id) asyncio.run(main()) ``` -See the [`examples/`](examples/) folder for ready-to-run scripts. - ---- - -## 🔧 Configuration - -### `FragmentClient` parameters - -| Parameter | Type | Required | Default | Description | -| ---------------- | ------------- | -------- | -------- | -------------------------------------------------------- | -| `seed` | `str` | ✅ | — | 24-word TON wallet mnemonic phrase | -| `api_key` | `str` | ✅ | — | Tonapi key from [tonconsole.com](https://tonconsole.com) | -| `cookies` | `dict \| str` | ✅ | — | Fragment session cookies (dict or JSON string) | -| `wallet_version` | `str` | ❌ | `"V5R1"` | Wallet contract version: `"V4R2"` or `"V5R1"` | - -### Methods - -> Usernames can be passed with or without `@`. - -| Method | Returns | Description | Limits | -| -------------------------------------------------- | ---------------- | ---------------------------------- | ------------------------- | -| `purchase_premium(username, months, show_sender=True)` | `PremiumResult` | Purchase Telegram Premium | `months`: 3, 6, or 12 | -| `purchase_stars(username, amount, show_sender=True)` | `StarsResult` | Purchase Telegram Stars | `amount`: 50–1,000,000 | -| `topup_ton(username, amount, show_sender=True)` | `AdsTopupResult` | Top up Telegram Ads balance | `amount`: 1–1,000,000,000 | -| `get_wallet()` | `WalletInfo` | Get wallet address, state, balance | — | - ---- - -## ⚙️ Getting Required Credentials - -### 🍪 Fragment.com Cookies - -**Prerequisites**: Log in to [fragment.com](https://fragment.com), connect your TON wallet. - -1. Install [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) -2. Open [fragment.com](https://fragment.com) while logged in -3. Click the extension → **Export** → **Header String** -4. Extract these four fields: - -```json -{ - "stel_ssid": "...", - "stel_dt": "...", - "stel_token": "...", - "stel_ton_token": "..." -} -``` - -> ⚠️ Cookies expire. Refresh them if you start getting `FragmentPageError` or auth errors. - -### 🔑 Tonapi Key - -1. Go to [tonconsole.com](https://tonconsole.com) -2. Register and generate a new API key -3. Pass it as `api_key` to `FragmentClient` - -### 🌱 Wallet Seed Phrase - -If you don't have a TON wallet, create one in [Tonkeeper](https://tonkeeper.com). -Go to **Settings → Backup** → copy the 24 words. - -> ⚠️ Never share your seed phrase. Store it offline. - -### 🔐 Wallet Version - -| Version | Use when | -| ------- | -------------------------------------------------------------- | -| `V5R1` | Default — Tonkeeper / MyTonWallet (wallets created after 2024) | -| `V4R2` | Older Tonkeeper or hardware wallets | - ---- - -## 🗂️ Error Handling - -All exceptions inherit from `FragmentError` — see [`pyfragment/types/exceptions.py`](pyfragment/types/exceptions.py) for the full list. - -```python -from pyfragment import FragmentClient, UserNotFoundError, ConfigurationError, WalletError - -try: - result = await client.purchase_stars("@unknown", amount=100) -except UserNotFoundError: - print("User not found on Fragment") -except WalletError as e: - print(f"Wallet issue: {e}") -except ConfigurationError as e: - print(f"Bad params: {e}") -``` - ---
diff --git a/examples/get_wallet.py b/examples/get_wallet.py index 936636e..84c8db4 100644 --- a/examples/get_wallet.py +++ b/examples/get_wallet.py @@ -1,5 +1,5 @@ """ -Example: initializing FragmentClient. +Example: fetch wallet address, state, and balance. Cookies can be passed as a dict or as a JSON string. wallet_version defaults to "V5R1" — change to "V4R2" for older wallets. @@ -9,7 +9,7 @@ import asyncio from pyfragment import FragmentClient -SEED = "word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12 word13 word14 word15 word16 word17 word18 word19 word20 word21 word22 word23 word24" +SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" COOKIES = { "stel_ssid": "YOUR_STEL_SSID", @@ -20,20 +20,16 @@ COOKIES = { async def main() -> None: - client = FragmentClient( + async with FragmentClient( seed=SEED, api_key=API_KEY, cookies=COOKIES, wallet_version="V5R1", # or "V4R2" - ) - - wallet = await client.get_wallet() - - print("FragmentClient initialized") - print(" %-16s %s" % ("Wallet version:", client.wallet_version)) - print(" %-16s %s" % ("Address:", wallet.address)) - print(" %-16s %s" % ("State:", wallet.state)) - print(" %-16s %s TON" % ("Balance:", wallet.balance)) + ) as client: + wallet = await client.get_wallet() + print(f"Address: {wallet.address}") + print(f"State: {wallet.state}") + print(f"Balance: {wallet.balance} TON") if __name__ == "__main__": diff --git a/examples/purchase_premium.py b/examples/purchase_premium.py index d4366f2..d35f7c4 100644 --- a/examples/purchase_premium.py +++ b/examples/purchase_premium.py @@ -23,22 +23,17 @@ MONTHS = 3 # 3, 6, or 12 async def main() -> None: - client = FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + try: + result = await client.purchase_premium(USERNAME, months=MONTHS, show_sender=True) + except UserNotFoundError: + print(f"User {USERNAME} was not found on fragment.com — check the username and try again.") + return + except ConfigurationError as e: + print(f"Invalid argument: {e}") + return - try: - result = await client.purchase_premium(USERNAME, months=MONTHS, show_sender=True) - except UserNotFoundError: - print(f"User {USERNAME!r} not found on Fragment.") - return - except ConfigurationError as e: - print(f"Invalid parameters: {e}") - return - - print("Premium purchased") - print(" %-14s %s" % ("Username:", result.username)) - print(" %-14s %s months" % ("Duration:", result.months)) - print(" %-14s %s" % ("Transaction:", result.transaction_id)) - print(" %-14s %s" % ("Timestamp:", result.timestamp)) + print(f"{result.months} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}") if __name__ == "__main__": diff --git a/examples/purchase_stars.py b/examples/purchase_stars.py index c0902a4..4f44ab0 100644 --- a/examples/purchase_stars.py +++ b/examples/purchase_stars.py @@ -23,22 +23,17 @@ AMOUNT = 500 # 50–1 000 000 async def main() -> None: - client = FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + try: + result = await client.purchase_stars(USERNAME, amount=AMOUNT, show_sender=True) + except UserNotFoundError: + print(f"User {USERNAME} was not found on fragment.com — check the username and try again.") + return + except ConfigurationError as e: + print(f"Invalid argument: {e}") + return - try: - result = await client.purchase_stars(USERNAME, amount=AMOUNT, show_sender=True) - except UserNotFoundError: - print(f"User {USERNAME!r} not found on Fragment.") - return - except ConfigurationError as e: - print(f"Invalid parameters: {e}") - return - - print("Stars purchased") - print(" %-14s %s" % ("Username:", result.username)) - print(" %-14s %s" % ("Stars:", result.stars)) - print(" %-14s %s" % ("Transaction:", result.transaction_id)) - print(" %-14s %s" % ("Timestamp:", result.timestamp)) + print(f"{result.stars} Stars successfully sent to {result.username} | tx: {result.transaction_id}") if __name__ == "__main__": diff --git a/examples/topup_ton.py b/examples/topup_ton.py index f362256..98c891d 100644 --- a/examples/topup_ton.py +++ b/examples/topup_ton.py @@ -23,25 +23,20 @@ AMOUNT = 10 # TON, integer — 1–1 000 000 000 async def main() -> None: - client = FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + try: + result = await client.topup_ton(USERNAME, amount=AMOUNT, show_sender=True) + except UserNotFoundError: + print(f"User {USERNAME} was not found on fragment.com — check the username and try again.") + return + except WalletError as e: + print(f"Wallet error — insufficient balance or misconfiguration: {e}") + return + except ConfigurationError as e: + print(f"Invalid argument: {e}") + return - try: - result = await client.topup_ton(USERNAME, amount=AMOUNT, show_sender=True) - except UserNotFoundError: - print(f"User {USERNAME!r} not found on Fragment.") - return - except WalletError as e: - print(f"Wallet error: {e}") - return - except ConfigurationError as e: - print(f"Invalid parameters: {e}") - return - - print("TON topped up") - print(" %-14s %s" % ("Username:", result.username)) - print(" %-14s %s TON" % ("Amount:", result.amount)) - print(" %-14s %s" % ("Transaction:", result.transaction_id)) - print(" %-14s %s" % ("Timestamp:", result.timestamp)) + print(f"{result.amount} TON successfully sent to {result.username} | tx: {result.transaction_id}") if __name__ == "__main__": diff --git a/pyfragment/client.py b/pyfragment/client.py index 09a62df..9436aed 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -4,16 +4,14 @@ from pyfragment.methods.premium import purchase_premium from pyfragment.methods.stars import purchase_stars from pyfragment.methods.ton import topup_ton from pyfragment.types import ( - REQUIRED_COOKIE_KEYS, - SUPPORTED_WALLET_VERSIONS, AdsTopupResult, ConfigurationError, CookieError, PremiumResult, StarsResult, WalletInfo, - WalletVersion, ) +from pyfragment.types.constants import DEFAULT_TIMEOUT, REQUIRED_COOKIE_KEYS, SUPPORTED_WALLET_VERSIONS, WalletVersion from pyfragment.utils.wallet import get_wallet_info @@ -30,6 +28,7 @@ class FragmentClient: 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). + timeout: HTTP request timeout in seconds. Defaults to ``30.0``. Raises: ConfigurationError: If ``seed``, ``api_key``, or ``wallet_version`` are missing or invalid. @@ -53,6 +52,7 @@ class FragmentClient: api_key: str, cookies: dict | str, wallet_version: str = "V5R1", + timeout: float = DEFAULT_TIMEOUT, ) -> None: missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()] if missing: @@ -87,6 +87,7 @@ class FragmentClient: self.api_key: str = api_key.strip() self.cookies: dict = cookies self.wallet_version: WalletVersion = version # type: ignore[assignment] + self.timeout: float = timeout async def __aenter__(self) -> "FragmentClient": return self diff --git a/pyfragment/methods/premium.py b/pyfragment/methods/premium.py index 0c19c6b..598a787 100644 --- a/pyfragment/methods/premium.py +++ b/pyfragment/methods/premium.py @@ -5,9 +5,6 @@ from typing import TYPE_CHECKING import httpx from pyfragment.types import ( - BASE_HEADERS, - DEVICE, - PREMIUM_PAGE, ConfigurationError, FragmentAPIError, FragmentError, @@ -15,6 +12,7 @@ from pyfragment.types import ( UnexpectedError, UserNotFoundError, ) +from pyfragment.types.constants import BASE_HEADERS, DEVICE, PREMIUM_PAGE from pyfragment.utils import ( execute_transaction_request, fragment_post, @@ -94,10 +92,10 @@ async def purchase_premium(client: "FragmentClient", username: str, months: int, raise ConfigurationError(ConfigurationError.INVALID_MONTHS) try: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, PREMIUM_PAGE) + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, PREMIUM_PAGE, client.timeout) account = await get_account_info(client) - async with httpx.AsyncClient(cookies=client.cookies) as session: + async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: recipient = await _search_recipient(session, fragment_hash, username, months) req_id = await _init_request(session, fragment_hash, recipient, months) diff --git a/pyfragment/methods/stars.py b/pyfragment/methods/stars.py index bc58fa8..874593d 100644 --- a/pyfragment/methods/stars.py +++ b/pyfragment/methods/stars.py @@ -4,9 +4,6 @@ from typing import TYPE_CHECKING import httpx from pyfragment.types import ( - BASE_HEADERS, - DEVICE, - STARS_PAGE, ConfigurationError, FragmentAPIError, FragmentError, @@ -14,6 +11,7 @@ from pyfragment.types import ( UnexpectedError, UserNotFoundError, ) +from pyfragment.types.constants import BASE_HEADERS, DEVICE, STARS_PAGE from pyfragment.utils import ( execute_transaction_request, fragment_post, @@ -81,10 +79,10 @@ async def purchase_stars(client: "FragmentClient", username: str, amount: int, s raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT) try: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, STARS_PAGE) + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, STARS_PAGE, client.timeout) account = await get_account_info(client) - async with httpx.AsyncClient(cookies=client.cookies) as session: + async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: recipient = await _search_recipient(session, fragment_hash, username) req_id = await _init_request(session, fragment_hash, recipient, amount) diff --git a/pyfragment/methods/ton.py b/pyfragment/methods/ton.py index ef5871b..44da5bb 100644 --- a/pyfragment/methods/ton.py +++ b/pyfragment/methods/ton.py @@ -4,9 +4,6 @@ from typing import TYPE_CHECKING import httpx from pyfragment.types import ( - BASE_HEADERS, - DEVICE, - TON_PAGE, AdsTopupResult, ConfigurationError, FragmentAPIError, @@ -14,6 +11,7 @@ from pyfragment.types import ( UnexpectedError, UserNotFoundError, ) +from pyfragment.types.constants import BASE_HEADERS, DEVICE, TON_PAGE from pyfragment.utils import ( execute_transaction_request, fragment_post, @@ -81,10 +79,10 @@ async def topup_ton(client: "FragmentClient", username: str, amount: int, show_s raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) try: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, TON_PAGE) + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, TON_PAGE, client.timeout) account = await get_account_info(client) - async with httpx.AsyncClient(cookies=client.cookies) as session: + async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: recipient = await _search_recipient(session, fragment_hash, username) req_id = await _init_request(session, fragment_hash, recipient, amount) diff --git a/pyfragment/types/__init__.py b/pyfragment/types/__init__.py index c254997..042f02b 100644 --- a/pyfragment/types/__init__.py +++ b/pyfragment/types/__init__.py @@ -1,15 +1,3 @@ -from pyfragment.types.constants import ( - BASE_HEADERS, - DEVICE, - MIN_TON_BALANCE, - PREMIUM_PAGE, - REQUIRED_COOKIE_KEYS, - STARS_PAGE, - SUPPORTED_WALLET_VERSIONS, - TON_PAGE, - WALLET_CLASSES, - WalletVersion, -) from pyfragment.types.exceptions import ( ClientError, ConfigurationError, @@ -28,17 +16,6 @@ from pyfragment.types.exceptions import ( from pyfragment.types.results import AdsTopupResult, PremiumResult, StarsResult, WalletInfo __all__ = [ - # constants - "BASE_HEADERS", - "DEVICE", - "MIN_TON_BALANCE", - "PREMIUM_PAGE", - "REQUIRED_COOKIE_KEYS", - "STARS_PAGE", - "SUPPORTED_WALLET_VERSIONS", - "TON_PAGE", - "WALLET_CLASSES", - "WalletVersion", # client exceptions "ClientError", "ConfigurationError", diff --git a/pyfragment/types/constants.py b/pyfragment/types/constants.py index 2bb799d..74a8660 100644 --- a/pyfragment/types/constants.py +++ b/pyfragment/types/constants.py @@ -13,6 +13,9 @@ WALLET_CLASSES: dict[str, type] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1} # Minimum wallet balance required to cover TON network gas fees. MIN_TON_BALANCE: float = 0.056 +# Default HTTP request timeout in seconds. +DEFAULT_TIMEOUT: float = 30.0 + # Required Fragment session cookie keys REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token") diff --git a/pyfragment/types/exceptions.py b/pyfragment/types/exceptions.py index 435a50c..1cc2283 100644 --- a/pyfragment/types/exceptions.py +++ b/pyfragment/types/exceptions.py @@ -16,6 +16,9 @@ class ConfigurationError(ClientError): INVALID_MONTHS = "Invalid duration. Choose 3, 6, or 12 months." INVALID_STARS_AMOUNT = "Amount must be an integer between 50 and 1 000 000 stars." INVALID_TON_AMOUNT = "Amount must be an integer between 1 and 1 000 000 000 TON." + INVALID_USERNAME = ( + "Invalid username '{username}'. Must be 5–32 characters: letters (A–Z, a–z), digits (0–9), or underscores (_)." + ) class CookieError(ClientError): diff --git a/pyfragment/utils/http.py b/pyfragment/utils/http.py index a1a5e1f..cbdb6e9 100644 --- a/pyfragment/utils/http.py +++ b/pyfragment/utils/http.py @@ -4,12 +4,14 @@ from typing import Any import httpx from pyfragment.types import FragmentPageError, ParseError, VerificationError +from pyfragment.types.constants import DEFAULT_TIMEOUT async def get_fragment_hash( cookies: dict[str, Any], headers: dict[str, str], page_url: str, + timeout: float = DEFAULT_TIMEOUT, ) -> str: """Fetch the API hash from a Fragment page. @@ -21,6 +23,7 @@ async def get_fragment_hash( cookies: Active Fragment session cookies. headers: Base headers for the relevant Fragment page. page_url: URL of the Fragment page to fetch the hash from. + timeout: HTTP request timeout in seconds. Defaults to ``DEFAULT_TIMEOUT``. Returns: Lowercase hex hash string. @@ -44,7 +47,7 @@ async def get_fragment_hash( } ) - async with httpx.AsyncClient(cookies=cookies) as session: + async with httpx.AsyncClient(cookies=cookies, timeout=timeout) as session: response = await session.get(page_url, headers=page_headers) if response.status_code != 200: diff --git a/pyfragment/utils/wallet.py b/pyfragment/utils/wallet.py index 55680f2..42d2167 100644 --- a/pyfragment/utils/wallet.py +++ b/pyfragment/utils/wallet.py @@ -6,7 +6,8 @@ from tonutils.clients import TonapiClient from tonutils.exceptions import ProviderResponseError from tonutils.types import NetworkGlobalID -from pyfragment.types import MIN_TON_BALANCE, WALLET_CLASSES, TransactionError, WalletError +from pyfragment.types import TransactionError, WalletError +from pyfragment.types.constants import MIN_TON_BALANCE, WALLET_CLASSES from pyfragment.types.results import WalletInfo from pyfragment.utils.decoder import clean_decode diff --git a/pyproject.toml b/pyproject.toml index 1f41d60..2dd39a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,8 @@ dependencies = [ dev = [ "pytest==9.0.2", "pytest-asyncio==1.3.0", + "pytest-mock", + "mypy", "ruff", "black", ] diff --git a/tests/003_test_hash.py b/tests/003_test_hash.py index 4bb6f18..bb883ba 100644 --- a/tests/003_test_hash.py +++ b/tests/003_test_hash.py @@ -5,7 +5,7 @@ import re import pytest -from pyfragment.types import BASE_HEADERS, STARS_PAGE +from pyfragment.types.constants import BASE_HEADERS, STARS_PAGE from pyfragment.utils import get_fragment_hash diff --git a/tests/004_test_balance.py b/tests/004_test_balance.py index e2023ee..a784300 100644 --- a/tests/004_test_balance.py +++ b/tests/004_test_balance.py @@ -1,21 +1,21 @@ """Unit tests for process_transaction() — balance checks before broadcast.""" +from contextlib import contextmanager from unittest.mock import AsyncMock, MagicMock, patch import pytest -from pyfragment.types import WalletError +from pyfragment.types import TransactionError, WalletError from pyfragment.utils.wallet import process_transaction VALID_SEED = "abandon " * 23 + "about" -# Minimal transaction payload: 0.5 TON = 500_000_000 nanotons TRANSACTION_DATA = { "transaction": { "messages": [ { "address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a", - "amount": "500000000", + "amount": "500000000", # 0.5 TON "payload": "", } ] @@ -23,9 +23,9 @@ TRANSACTION_DATA = { } -def _make_client(api_key: str = "test_key") -> MagicMock: +def _make_client() -> MagicMock: client = MagicMock() - client.api_key = api_key + client.api_key = "test_key" client.seed = VALID_SEED.split() client.wallet_version = "V5R1" return client @@ -39,89 +39,53 @@ def _make_wallet(balance_nanotons: int) -> MagicMock: return wallet -@pytest.mark.asyncio -async def test_sufficient_balance_broadcasts() -> None: - # 0.5 TON amount + 0.056 TON gas = 0.556 TON required; wallet has 1 TON - client = _make_client() - wallet = _make_wallet(balance_nanotons=1_000_000_000) - +@contextmanager +def _patch_wallet(wallet: MagicMock): with ( patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi, patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes, - patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"), ): mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None) + yield - result = await process_transaction(client, TRANSACTION_DATA) +@pytest.mark.asyncio +async def test_sufficient_balance_broadcasts() -> None: + wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, needs 0.556 TON + with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"): + result = await process_transaction(_make_client(), TRANSACTION_DATA) assert result == "abc123" wallet.transfer.assert_called_once() @pytest.mark.asyncio -async def test_insufficient_balance_raises_wallet_error() -> None: - # wallet has 0.1 TON, needs 0.556 TON - client = _make_client() - wallet = _make_wallet(balance_nanotons=100_000_000) - - with ( - patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi, - patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes, - ): - mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) - mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) - mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None) - +async def test_insufficient_balance_raises() -> None: + wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 TON, needs 0.556 TON + with _patch_wallet(wallet): with pytest.raises(WalletError, match="required"): - await process_transaction(client, TRANSACTION_DATA) - + await process_transaction(_make_client(), TRANSACTION_DATA) wallet.transfer.assert_not_called() @pytest.mark.asyncio -async def test_exactly_minimum_balance_broadcasts() -> None: - # exactly amount + gas: 500_000_000 + 56_000_000 = 556_000_000 nanotons - client = _make_client() - wallet = _make_wallet(balance_nanotons=556_000_000) - - with ( - patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi, - patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes, - patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"), - ): - mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) - mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) - mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None) - - result = await process_transaction(client, TRANSACTION_DATA) - +async def test_exact_minimum_balance_broadcasts() -> None: + wallet = _make_wallet(balance_nanotons=556_000_000) # exactly 0.5 + 0.056 TON + with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"): + result = await process_transaction(_make_client(), TRANSACTION_DATA) assert result == "abc123" @pytest.mark.asyncio async def test_one_nanoton_below_minimum_raises() -> None: - # 556_000_000 - 1 nanoton: just below threshold - client = _make_client() - wallet = _make_wallet(balance_nanotons=555_999_999) - - with ( - patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi, - patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes, - ): - mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) - mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) - mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None) - + wallet = _make_wallet(balance_nanotons=555_999_999) # 1 nanoton below threshold + with _patch_wallet(wallet): with pytest.raises(WalletError, match="required"): - await process_transaction(client, TRANSACTION_DATA) + await process_transaction(_make_client(), TRANSACTION_DATA) @pytest.mark.asyncio -async def test_invalid_payload_raises_transaction_error() -> None: - from pyfragment.types import TransactionError - - client = _make_client() +async def test_invalid_payload_raises() -> None: with pytest.raises(TransactionError): - await process_transaction(client, {"transaction": {}}) + await process_transaction(_make_client(), {"transaction": {}}) diff --git a/tests/005_test_methods.py b/tests/005_test_methods.py index 5a7f019..563e023 100644 --- a/tests/005_test_methods.py +++ b/tests/005_test_methods.py @@ -21,45 +21,36 @@ def client() -> FragmentClient: @pytest.mark.asyncio -async def test_purchase_premium_invalid_months_raises(client: FragmentClient) -> None: +async def test_purchase_premium_invalid_months(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): await client.purchase_premium("@user", months=5) @pytest.mark.asyncio -async def test_purchase_premium_valid_months(client: FragmentClient) -> None: - """Validation passes for 3/6/12 — network error expected, not ConfigurationError.""" - for months in (3, 6, 12): - with pytest.raises(Exception) as exc_info: - await client.purchase_premium("@user", months=months) - assert not isinstance(exc_info.value, ConfigurationError) - - -@pytest.mark.asyncio -async def test_purchase_stars_amount_too_low_raises(client: FragmentClient) -> None: +async def test_purchase_stars_amount_too_low(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): await client.purchase_stars("@user", amount=49) @pytest.mark.asyncio -async def test_purchase_stars_amount_too_high_raises(client: FragmentClient) -> None: +async def test_purchase_stars_amount_too_high(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): await client.purchase_stars("@user", amount=1_000_001) @pytest.mark.asyncio -async def test_purchase_stars_float_raises(client: FragmentClient) -> None: +async def test_purchase_stars_float_amount(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): await client.purchase_stars("@user", amount=100.5) # type: ignore[arg-type] @pytest.mark.asyncio -async def test_topup_ton_amount_zero_raises(client: FragmentClient) -> None: +async def test_topup_ton_amount_zero(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): await client.topup_ton("@user", amount=0) @pytest.mark.asyncio -async def test_topup_ton_amount_too_high_raises(client: FragmentClient) -> None: +async def test_topup_ton_amount_too_high(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): await client.topup_ton("@user", amount=1_000_000_001) diff --git a/tests/006_test_methods_mock.py b/tests/006_test_methods_mock.py new file mode 100644 index 0000000..f86c69e --- /dev/null +++ b/tests/006_test_methods_mock.py @@ -0,0 +1,146 @@ +"""Tests for purchase methods with all network calls mocked.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import FragmentClient +from pyfragment.types import AdsTopupResult, PremiumResult, StarsResult, UserNotFoundError + +VALID_SEED = "abandon " * 23 + "about" +VALID_API_KEY = "A" * 68 +VALID_COOKIES = { + "stel_ssid": "x", + "stel_dt": "x", + "stel_token": "x", + "stel_ton_token": "x", +} +FAKE_HASH = "abc123" +FAKE_RECIPIENT = "recipient_token" +FAKE_REQ_ID = "req_42" +FAKE_TX_HASH = "deadbeef" * 8 +FAKE_ACCOUNT = {"address": "0:abc", "publicKey": "pub", "chain": "-239", "walletStateInit": "base64=="} +FAKE_TRANSACTION = {"transaction": {"messages": [{"address": "0:abc", "amount": "100000000", "payload": ""}]}} + + +@pytest.fixture +def client() -> FragmentClient: + return FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES) + + +@pytest.mark.asyncio +async def test_purchase_stars_success(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch( + "pyfragment.methods.stars.fragment_post", + AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, # searchStarsRecipient + {"req_id": FAKE_REQ_ID}, # initBuyStarsRequest + ] + ), + ), + patch("pyfragment.methods.stars.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.stars.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + ): + result = await client.purchase_stars("testuser", amount=100) + + assert isinstance(result, StarsResult) + assert result.transaction_id == FAKE_TX_HASH + assert result.username == "testuser" + assert result.stars == 100 + + +@pytest.mark.asyncio +async def test_purchase_stars_user_not_found(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch("pyfragment.methods.stars.fragment_post", AsyncMock(return_value={"found": {}})), + ): + with pytest.raises(UserNotFoundError): + await client.purchase_stars("ghost", amount=100) + + +@pytest.mark.asyncio +async def test_purchase_premium_success(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch( + "pyfragment.methods.premium.fragment_post", + AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, # searchPremiumGiftRecipient + {}, # updatePremiumState + {"req_id": FAKE_REQ_ID}, # initGiftPremiumRequest + ] + ), + ), + patch("pyfragment.methods.premium.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.premium.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + ): + result = await client.purchase_premium("testuser", months=6) + + assert isinstance(result, PremiumResult) + assert result.transaction_id == FAKE_TX_HASH + assert result.username == "testuser" + assert result.months == 6 + + +@pytest.mark.asyncio +async def test_purchase_premium_user_not_found(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch("pyfragment.methods.premium.fragment_post", AsyncMock(return_value={"found": {}})), + ): + with pytest.raises(UserNotFoundError): + await client.purchase_premium("ghost", months=3) + + +@pytest.mark.asyncio +async def test_topup_ton_success(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.ton.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch( + "pyfragment.methods.ton.fragment_post", + AsyncMock( + side_effect=[ + {}, # updateAdsTopupState + {"found": {"recipient": FAKE_RECIPIENT}}, # searchAdsTopupRecipient + {"req_id": FAKE_REQ_ID}, # initAdsTopupRequest + ] + ), + ), + patch("pyfragment.methods.ton.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.ton.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + ): + result = await client.topup_ton("testuser", amount=10) + + assert isinstance(result, AdsTopupResult) + assert result.transaction_id == FAKE_TX_HASH + assert result.username == "testuser" + assert result.amount == 10 + + +@pytest.mark.asyncio +async def test_topup_ton_user_not_found(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.ton.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch( + "pyfragment.methods.ton.fragment_post", + AsyncMock( + side_effect=[ + {}, # updateAdsTopupState + {"found": {}}, # searchAdsTopupRecipient → not found + ] + ), + ), + ): + with pytest.raises(UserNotFoundError): + await client.topup_ton("ghost", amount=10) diff --git a/tests/conftest.py b/tests/conftest.py index 9b60a88..68c5e75 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,17 +1,16 @@ import json -from pathlib import Path +import os import pytest @pytest.fixture def cookies(): - """Load Fragment cookies from cookies.json; skip the test if unavailable.""" - cookies_path = Path(__file__).resolve().parents[1] / "cookies.json" - if not cookies_path.exists(): - pytest.skip("cookies.json not found") + """Load Fragment cookies from COOKIES_JSON env var; skip if unavailable.""" + raw = os.environ.get("COOKIES_JSON") + if not raw: + pytest.skip("COOKIES_JSON env var not set") try: - with cookies_path.open("r", encoding="utf-8") as f: - return json.load(f) + return json.loads(raw) except Exception as exc: pytest.skip(f"Cookies unavailable — {exc}") From 75ee76b60e7b14881cc307d8edca4598ae229768 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Fri, 20 Mar 2026 20:22:33 +0200 Subject: [PATCH 02/21] fix: cast cookies to dict for type safety and improve error handling in FragmentClient --- pyfragment/client.py | 5 +++-- pyfragment/types/constants.py | 4 ++-- pyfragment/utils/wallet.py | 2 ++ 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/pyfragment/client.py b/pyfragment/client.py index 9436aed..066f444 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -1,4 +1,5 @@ import json +from typing import cast from pyfragment.methods.premium import purchase_premium from pyfragment.methods.stars import purchase_stars @@ -71,7 +72,7 @@ class FragmentClient: 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(cookies.get(k, "")).strip()] + missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cast(dict, cookies).get(k, "")).strip()] if missing_keys: raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys))) @@ -85,7 +86,7 @@ class FragmentClient: self.seed: str = seed.strip() self.api_key: str = api_key.strip() - self.cookies: dict = cookies + self.cookies: dict = cast(dict, cookies) self.wallet_version: WalletVersion = version # type: ignore[assignment] self.timeout: float = timeout diff --git a/pyfragment/types/constants.py b/pyfragment/types/constants.py index 74a8660..362a850 100644 --- a/pyfragment/types/constants.py +++ b/pyfragment/types/constants.py @@ -1,5 +1,5 @@ import json -from typing import Literal, get_args +from typing import Any, Literal, get_args from tonutils.contracts.wallet import WalletV4R2, WalletV5R1 @@ -8,7 +8,7 @@ WalletVersion = Literal["V4R2", "V5R1"] SUPPORTED_WALLET_VERSIONS: frozenset[str] = frozenset(get_args(WalletVersion)) # Wallet class map — used to resolve the correct contract from WALLET_VERSION -WALLET_CLASSES: dict[str, type] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1} +WALLET_CLASSES: dict[str, Any] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1} # Minimum wallet balance required to cover TON network gas fees. MIN_TON_BALANCE: float = 0.056 diff --git a/pyfragment/utils/wallet.py b/pyfragment/utils/wallet.py index 42d2167..0a453ea 100644 --- a/pyfragment/utils/wallet.py +++ b/pyfragment/utils/wallet.py @@ -78,6 +78,8 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict) except Exception as exc: raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc + raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result")) + async def get_account_info(client: "FragmentClient") -> dict[str, Any]: """Fetch wallet address, public key, and state-init for the Fragment API. From 949796df17d3ab4ba107ba7118531f33538b425a Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Fri, 20 Mar 2026 20:25:35 +0200 Subject: [PATCH 03/21] chore: streamline CI linting steps and update changelog with recent changes --- .github/workflows/ci.yml | 6 +----- CHANGELOG.md | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80e25d5..56ecbd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,11 +24,7 @@ jobs: - run: uv pip install --system ".[dev]" - - run: ruff check . - - - run: black --check . --target-version py312 - - - run: mypy pyfragment + - run: ruff check . && black --check . --target-version py312 && mypy pyfragment test: name: Tests diff --git a/CHANGELOG.md b/CHANGELOG.md index e9ff67c..500236b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI --- +## [Unreleased] + +### Added +- `timeout` parameter on `FragmentClient` (default `30.0` s) — passed through to every HTTP request + +### Changed +- Cookie validation: narrowed type internally so no `# type: ignore` is needed in `FragmentClient.__init__` +- `WALLET_CLASSES` typed as `dict[str, Any]` so mypy resolves `from_mnemonic` correctly +- All four `examples/` files updated to `async with FragmentClient`, f-strings, and aligned error messages +- README usage section rewritten with a single comprehensive `async with` example + +### Fixed +- mypy: missing return path in `process_transaction` after retry loop +- mypy: `cookies` union-attr error in `FragmentClient.__init__` + +--- + ## [2026.0.1] — 2026-03-16 ### Added From 581886938b42ebde5e56bafb957d92ad689af8de Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Fri, 20 Mar 2026 20:36:59 +0200 Subject: [PATCH 04/21] chore: update setup-uv action to v7.6.0 in CI and publish workflows; fix image source in README --- .github/workflows/ci.yml | 4 ++-- .github/workflows/publish.yml | 2 +- README.md | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56ecbd6..7696bb7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: with: python-version: "3.12" - - uses: astral-sh/setup-uv@v7.5.0 + - uses: astral-sh/setup-uv@v7.6.0 - run: uv pip install --system ".[dev]" @@ -40,7 +40,7 @@ jobs: with: python-version: "3.12" - - uses: astral-sh/setup-uv@v7.5.0 + - uses: astral-sh/setup-uv@v7.6.0 - run: uv pip install --system ".[dev]" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2f33c1f..5497f05 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -52,7 +52,7 @@ jobs: with: python-version: "3.12" - - uses: astral-sh/setup-uv@v7.5.0 + - uses: astral-sh/setup-uv@v7.6.0 - run: uv build diff --git a/README.md b/README.md index 6cd5668..571bee8 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@
- Fragment Logo + Fragment Logo

Fragment API

@@ -82,15 +82,15 @@ async def main() -> None: }, ) as client: try: - # Purchase 6 months of Telegram Premium for a user + # Purchase 6 months of Telegram Premium result = await client.purchase_premium("@username", months=6) print(f"{result.months} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}") - # Purchase 500 Stars for a user (50–1 000 000) + # Purchase 500 Stars result = await client.purchase_stars("@username", amount=500) print(f"{result.stars} Stars successfully sent to {result.username} | tx: {result.transaction_id}") - # Top up 10 TON to Telegram Ads balance + # Top up 10 TON to Telegram balance # wallet must hold at least amount + ~0.056 TON for gas result = await client.topup_ton("@username", amount=10) print(f"{result.amount} TON successfully sent to {result.username} | tx: {result.transaction_id}") From b07b4670fbac052936dbde0d73a7f0f752fac903 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Fri, 20 Mar 2026 20:45:35 +0200 Subject: [PATCH 05/21] docs: update installation instructions to include unreleased changes from the dev branch --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 571bee8..34ecbf6 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,12 @@ pip install pyfragment ``` +To install the latest unreleased changes from the `dev` branch: + +```bash +pip install git+https://github.com/bohd4nx/pyfragment.git@dev +``` + Requires Python 3.12+. --- From 0af5ce5935491db74c6c96843e24e6ee46a6bd06 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Fri, 20 Mar 2026 20:57:37 +0200 Subject: [PATCH 06/21] chore: release 2026.0.2 --- CHANGELOG.md | 3 ++- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 500236b..8946978 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI --- -## [Unreleased] +## [2026.0.2] — 2026-03-20 ### Added - `timeout` parameter on `FragmentClient` (default `30.0` s) — passed through to every HTTP request @@ -38,4 +38,5 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI - `py.typed` marker — full PEP 561 typing support for type-checkers - `__repr__` on all result types for readable debug output +[2026.0.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.0.2 [2026.0.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.0.1 diff --git a/pyproject.toml b/pyproject.toml index 2dd39a0..c422aa6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pyfragment" -version = "2026.0.1" +version = "2026.0.2" description = "Python library for the Fragment.com API — purchase Telegram Stars, Premium, and top up TON Ads balance." readme = "README.md" license = { text = "MIT" } From 902b692dcbed7ba66ef3c75eafd9c56ce14465e1 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sat, 21 Mar 2026 16:05:38 +0200 Subject: [PATCH 07/21] Refactor methods for Telegram Premium and Stars giveaways - Added new methods for handling giveaways: `giveaway_premium` and `giveaway_stars`. - Refactored existing purchase methods into separate files: `purchase_premium.py` and `purchase_stars.py`. - Removed old premium and stars methods from the codebase. - Updated `__init__.py` to include new giveaway methods in the public API. - Introduced new result types for giveaways: `PremiumGiveawayResult` and `StarsGiveawayResult`. - Updated constants for new giveaway pages. - Enhanced error handling for configuration and user validation in giveaway methods. - Updated tests to cover new functionality and refactored existing tests to match new method locations. --- CHANGELOG.md | 16 ++ examples/purchase_premium.py | 2 +- examples/purchase_stars.py | 4 +- examples/topup_ton.py | 2 +- pyfragment/__init__.py | 4 + pyfragment/client.py | 53 ++++++- pyfragment/methods/__init__.py | 10 +- pyfragment/methods/giveaway_premium.py | 139 ++++++++++++++++++ pyfragment/methods/giveaway_stars.py | 135 +++++++++++++++++ .../{premium.py => purchase_premium.py} | 19 ++- .../methods/{stars.py => purchase_stars.py} | 19 ++- pyfragment/methods/{ton.py => topup_ton.py} | 17 +++ pyfragment/types/__init__.py | 11 +- pyfragment/types/constants.py | 2 + pyfragment/types/exceptions.py | 3 + pyfragment/types/results.py | 48 ++++-- tests/006_test_methods_mock.py | 52 +++---- 17 files changed, 483 insertions(+), 53 deletions(-) create mode 100644 pyfragment/methods/giveaway_premium.py create mode 100644 pyfragment/methods/giveaway_stars.py rename pyfragment/methods/{premium.py => purchase_premium.py} (81%) rename pyfragment/methods/{stars.py => purchase_stars.py} (80%) rename pyfragment/methods/{ton.py => topup_ton.py} (80%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8946978..93c6253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI --- +## [Unreleased] + +### Added +- `giveaway_stars(channel, winners, amount)` — run a Telegram Stars giveaway for a channel (1–5 winners, 500–1 000 000 stars each) +- `giveaway_premium(channel, winners, months)` — run a Telegram Premium giveaway for a channel (1–24 000 winners, 3/6/12 months each) +- `StarsGiveawayResult` and `PremiumGiveawayResult` result types +- `STARS_GIVEAWAY_PAGE` and `PREMIUM_GIVEAWAY_PAGE` URL constants + +### Changed +- All result types (`PremiumResult`, `StarsResult`, `StarsGiveawayResult`, `PremiumGiveawayResult`) now use a single unified `amount` field instead of `months`, `stars` — consistent API across every method +- `__repr__` on result types now includes the unit (`3 months`, `500 stars`) for clarity +- Method module files renamed to match their function: `premium.py` → `purchase_premium.py`, `stars.py` → `purchase_stars.py`, `ton.py` → `topup_ton.py` +- `timestamp` field removed from all result dataclasses + +--- + ## [2026.0.2] — 2026-03-20 ### Added diff --git a/examples/purchase_premium.py b/examples/purchase_premium.py index d35f7c4..f50ee0f 100644 --- a/examples/purchase_premium.py +++ b/examples/purchase_premium.py @@ -33,7 +33,7 @@ async def main() -> None: print(f"Invalid argument: {e}") return - print(f"{result.months} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}") + print(f"{result.amount} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}") if __name__ == "__main__": diff --git a/examples/purchase_stars.py b/examples/purchase_stars.py index 4f44ab0..97410ca 100644 --- a/examples/purchase_stars.py +++ b/examples/purchase_stars.py @@ -19,7 +19,7 @@ COOKIES = { } USERNAME = "@username" -AMOUNT = 500 # 50–1 000 000 +AMOUNT = 500 # 50 to 1 000 000 stars, integer async def main() -> None: @@ -33,7 +33,7 @@ async def main() -> None: print(f"Invalid argument: {e}") return - print(f"{result.stars} Stars successfully sent to {result.username} | tx: {result.transaction_id}") + print(f"{result.amount} Stars successfully sent to {result.username} | tx: {result.transaction_id}") if __name__ == "__main__": diff --git a/examples/topup_ton.py b/examples/topup_ton.py index 98c891d..96130f4 100644 --- a/examples/topup_ton.py +++ b/examples/topup_ton.py @@ -19,7 +19,7 @@ COOKIES = { } USERNAME = "@username" -AMOUNT = 10 # TON, integer — 1–1 000 000 000 +AMOUNT = 10 # TON, integer — 1 to 1 000 000 000 async def main() -> None: diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index d08f26e..a3d92e8 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -16,7 +16,9 @@ from pyfragment.types import ( FragmentPageError, OperationError, ParseError, + PremiumGiveawayResult, PremiumResult, + StarsGiveawayResult, StarsResult, TransactionError, UnexpectedError, @@ -32,7 +34,9 @@ __all__ = [ "__version__", "FragmentClient", "AdsTopupResult", + "PremiumGiveawayResult", "PremiumResult", + "StarsGiveawayResult", "StarsResult", "WalletInfo", "ClientError", diff --git a/pyfragment/client.py b/pyfragment/client.py index 066f444..cc01d7a 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -1,9 +1,11 @@ import json from typing import cast -from pyfragment.methods.premium import purchase_premium -from pyfragment.methods.stars import purchase_stars -from pyfragment.methods.ton import topup_ton +from pyfragment.methods.giveaway_premium import giveaway_premium +from pyfragment.methods.giveaway_stars import giveaway_stars +from pyfragment.methods.purchase_premium import purchase_premium +from pyfragment.methods.purchase_stars import purchase_stars +from pyfragment.methods.topup_ton import topup_ton from pyfragment.types import ( AdsTopupResult, ConfigurationError, @@ -13,6 +15,7 @@ from pyfragment.types import ( WalletInfo, ) from pyfragment.types.constants import DEFAULT_TIMEOUT, REQUIRED_COOKIE_KEYS, SUPPORTED_WALLET_VERSIONS, WalletVersion +from pyfragment.types.results import PremiumGiveawayResult, StarsGiveawayResult from pyfragment.utils.wallet import get_wallet_info @@ -108,7 +111,7 @@ class FragmentClient: show_sender: Show your name as the sender. Defaults to ``True``. Returns: - :class:`PremiumResult` with ``transaction_id``, ``username``, ``months``, ``timestamp``. + :class:`PremiumResult` with ``transaction_id``, ``username``, and ``months``. """ return await purchase_premium(self, username, months, show_sender) @@ -121,7 +124,7 @@ class FragmentClient: show_sender: Show your name as the gift sender. Defaults to ``True``. Returns: - :class:`StarsResult` with ``transaction_id``, ``username``, ``stars``, ``timestamp``. + :class:`StarsResult` with ``transaction_id``, ``username``, and ``stars``. """ return await purchase_stars(self, username, amount, show_sender) @@ -134,7 +137,7 @@ class FragmentClient: show_sender: Show your name as the sender. Defaults to ``True``. Returns: - :class:`AdsTopupResult` with ``transaction_id``, ``username``, ``amount``, ``timestamp``. + :class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``. """ return await topup_ton(self, username, amount, show_sender) @@ -146,3 +149,41 @@ class FragmentClient: (``"active"``, ``"uninit"``, or ``"frozen"``), and ``balance`` in TON. """ return await get_wallet_info(self) + + async def giveaway_stars( + self, + channel: str, + winners: int, + amount: int, + ) -> StarsGiveawayResult: + """Run a Telegram Stars giveaway for a channel. + + Args: + channel: Channel username (with or without ``@``). + winners: Number of winners — integer from ``1`` to ``5``. + amount: Stars each winner receives — integer from ``500`` to ``1 000 000``. + + Returns: + :class:`StarsGiveawayResult` with ``transaction_id``, ``channel``, + ``winners``, and ``amount``. + """ + return await giveaway_stars(self, channel, winners, amount) + + async def giveaway_premium( + self, + channel: str, + winners: int, + months: int = 3, + ) -> PremiumGiveawayResult: + """Run a Telegram Premium giveaway for a channel. + + Args: + channel: Channel username (with or without ``@``). + winners: Number of winners — positive integer. + months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``. + + Returns: + :class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``, + ``winners``, and ``amount``. + """ + return await giveaway_premium(self, channel, winners, months) diff --git a/pyfragment/methods/__init__.py b/pyfragment/methods/__init__.py index c0f4e2e..21ddeee 100644 --- a/pyfragment/methods/__init__.py +++ b/pyfragment/methods/__init__.py @@ -1,5 +1,7 @@ -from pyfragment.methods.premium import purchase_premium -from pyfragment.methods.stars import purchase_stars -from pyfragment.methods.ton import topup_ton +from pyfragment.methods.giveaway_premium import giveaway_premium +from pyfragment.methods.giveaway_stars import giveaway_stars +from pyfragment.methods.purchase_premium import purchase_premium +from pyfragment.methods.purchase_stars import purchase_stars +from pyfragment.methods.topup_ton import topup_ton -__all__ = ["purchase_premium", "purchase_stars", "topup_ton"] +__all__ = ["giveaway_premium", "giveaway_stars", "purchase_premium", "purchase_stars", "topup_ton"] diff --git a/pyfragment/methods/giveaway_premium.py b/pyfragment/methods/giveaway_premium.py new file mode 100644 index 0000000..25c4ea3 --- /dev/null +++ b/pyfragment/methods/giveaway_premium.py @@ -0,0 +1,139 @@ +import json +from typing import TYPE_CHECKING + +import httpx + +from pyfragment.types import ( + ConfigurationError, + FragmentAPIError, + FragmentError, + UnexpectedError, + UserNotFoundError, +) +from pyfragment.types.constants import BASE_HEADERS, DEVICE, PREMIUM_GIVEAWAY_PAGE +from pyfragment.types.results import PremiumGiveawayResult +from pyfragment.utils import ( + execute_transaction_request, + fragment_post, + get_account_info, + get_fragment_hash, + process_transaction, +) + +if TYPE_CHECKING: + from pyfragment.client import FragmentClient + +# Page-specific headers +HEADERS: dict[str, str] = { + **BASE_HEADERS, + "referer": PREMIUM_GIVEAWAY_PAGE, + "x-aj-referer": PREMIUM_GIVEAWAY_PAGE, +} + + +async def _search_recipient( + session: httpx.AsyncClient, + fragment_hash: str, + channel: str, + winners: int, + months: int, +) -> str: + result = await fragment_post( + session, + fragment_hash, + HEADERS, + { + "query": channel, + "quantity": winners, + "months": months, + "method": "searchPremiumGiveawayRecipient", + }, + ) + recipient = result.get("found", {}).get("recipient") + if not recipient: + raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel)) + return recipient + + +async def _init_request( + session: httpx.AsyncClient, + fragment_hash: str, + recipient: str, + winners: int, + months: int, +) -> str: + result = await fragment_post( + session, + fragment_hash, + HEADERS, + { + "recipient": recipient, + "quantity": str(winners), + "months": str(months), + "method": "initGiveawayPremiumRequest", + }, + ) + req_id = result.get("req_id") + if not req_id: + raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium giveaway")) + return req_id + + +async def giveaway_premium( + client: "FragmentClient", + channel: str, + winners: int, + months: int = 3, +) -> PremiumGiveawayResult: + """Run a Telegram Premium giveaway for a channel. + + Args: + client: Authenticated :class:`FragmentClient` instance. + channel: Channel username (with or without ``@``). + winners: Number of winners — integer from ``1`` to ``24 000``. + months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``. + + Returns: + :class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``, + ``winners``, and ``months``. + + Raises: + ConfigurationError: If ``winners`` is not 1–24 000 or ``months`` is not 3, 6, or 12. + UserNotFoundError: If the channel is not found on Fragment. + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ + if not isinstance(winners, int) or not (1 <= winners <= 24_000): + raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM) + if months not in (3, 6, 12): + raise ConfigurationError(ConfigurationError.INVALID_MONTHS) + + try: + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, PREMIUM_GIVEAWAY_PAGE, client.timeout) + account = await get_account_info(client) + + async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: + recipient = await _search_recipient(session, fragment_hash, channel, winners, months) + req_id = await _init_request(session, fragment_hash, recipient, winners, months) + + tx_data = { + "account": json.dumps(account), + "device": DEVICE, + "transaction": 1, + "id": req_id, + "method": "getGiveawayPremiumLink", + } + transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash) + + tx_hash = await process_transaction(client, transaction) + return PremiumGiveawayResult( + transaction_id=tx_hash, + channel=channel, + winners=winners, + amount=months, + ) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/methods/giveaway_stars.py b/pyfragment/methods/giveaway_stars.py new file mode 100644 index 0000000..c3e5c8f --- /dev/null +++ b/pyfragment/methods/giveaway_stars.py @@ -0,0 +1,135 @@ +import json +from typing import TYPE_CHECKING + +import httpx + +from pyfragment.types import ( + ConfigurationError, + FragmentAPIError, + FragmentError, + UnexpectedError, + UserNotFoundError, +) +from pyfragment.types.constants import BASE_HEADERS, DEVICE, STARS_GIVEAWAY_PAGE +from pyfragment.types.results import StarsGiveawayResult +from pyfragment.utils import ( + execute_transaction_request, + fragment_post, + get_account_info, + get_fragment_hash, + process_transaction, +) + +if TYPE_CHECKING: + from pyfragment.client import FragmentClient + +# Page-specific headers +HEADERS: dict[str, str] = { + **BASE_HEADERS, + "referer": STARS_GIVEAWAY_PAGE, + "x-aj-referer": STARS_GIVEAWAY_PAGE, +} + + +async def _search_recipient( + session: httpx.AsyncClient, + fragment_hash: str, + channel: str, +) -> str: + result = await fragment_post( + session, + fragment_hash, + HEADERS, + { + "query": channel, + "method": "searchStarsGiveawayRecipient", + }, + ) + recipient = result.get("found", {}).get("recipient") + if not recipient: + raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel)) + return recipient + + +async def _init_request( + session: httpx.AsyncClient, + fragment_hash: str, + recipient: str, + winners: int, + amount: int, +) -> str: + result = await fragment_post( + session, + fragment_hash, + HEADERS, + { + "recipient": recipient, + "quantity": str(winners), + "stars": str(amount), + "method": "initGiveawayStarsRequest", + }, + ) + req_id = result.get("req_id") + if not req_id: + raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars giveaway")) + return req_id + + +async def giveaway_stars( + client: "FragmentClient", + channel: str, + winners: int, + amount: int, +) -> StarsGiveawayResult: + """Run a Telegram Stars giveaway for a channel. + + Args: + client: Authenticated :class:`FragmentClient` instance. + channel: Channel username (with or without ``@``). + winners: Number of winners — integer from ``1`` to ``5``. + amount: Stars each winner receives — integer from ``500`` to ``1 000 000``. + + Returns: + :class:`StarsGiveawayResult` with ``transaction_id``, ``channel``, + ``winners``, and ``amount``. + + Raises: + ConfigurationError: If ``winners`` is not 1–5 or ``amount`` is not 500–1 000 000. + UserNotFoundError: If the channel is not found on Fragment. + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ + if not isinstance(winners, int) or not (1 <= winners <= 5): + raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS) + if not isinstance(amount, int) or not (500 <= amount <= 1_000_000): + raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER) + + try: + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, STARS_GIVEAWAY_PAGE, client.timeout) + account = await get_account_info(client) + + async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: + recipient = await _search_recipient(session, fragment_hash, channel) + req_id = await _init_request(session, fragment_hash, recipient, winners, amount) + + tx_data = { + "account": json.dumps(account), + "device": DEVICE, + "transaction": 1, + "id": req_id, + "method": "getGiveawayStarsLink", + } + transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash) + + tx_hash = await process_transaction(client, transaction) + return StarsGiveawayResult( + transaction_id=tx_hash, + channel=channel, + winners=winners, + amount=amount, + ) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/methods/premium.py b/pyfragment/methods/purchase_premium.py similarity index 81% rename from pyfragment/methods/premium.py rename to pyfragment/methods/purchase_premium.py index 598a787..f6a482a 100644 --- a/pyfragment/methods/premium.py +++ b/pyfragment/methods/purchase_premium.py @@ -88,6 +88,23 @@ async def _init_request( async def purchase_premium(client: "FragmentClient", username: str, months: int, show_sender: bool = True) -> PremiumResult: + """Gift Telegram Premium to a user. + + Args: + client: Authenticated :class:`FragmentClient` instance. + username: Recipient's Telegram username (with or without ``@``). + months: Premium duration — ``3``, ``6``, or ``12``. + show_sender: Show your name as the gift sender. Defaults to ``True``. + + Returns: + :class:`PremiumResult` with ``transaction_id``, ``username``, and ``months``. + + Raises: + ConfigurationError: If ``months`` is not ``3``, ``6``, or ``12``. + UserNotFoundError: If the user is not found on Fragment. + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ if months not in (3, 6, 12): raise ConfigurationError(ConfigurationError.INVALID_MONTHS) @@ -110,7 +127,7 @@ async def purchase_premium(client: "FragmentClient", username: str, months: int, transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash) tx_hash = await process_transaction(client, transaction) - return PremiumResult(transaction_id=tx_hash, username=username, months=months) + return PremiumResult(transaction_id=tx_hash, username=username, amount=months) except FragmentError: raise diff --git a/pyfragment/methods/stars.py b/pyfragment/methods/purchase_stars.py similarity index 80% rename from pyfragment/methods/stars.py rename to pyfragment/methods/purchase_stars.py index 874593d..cd9b371 100644 --- a/pyfragment/methods/stars.py +++ b/pyfragment/methods/purchase_stars.py @@ -75,6 +75,23 @@ async def _init_request( async def purchase_stars(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> StarsResult: + """Send Telegram Stars to a user. + + Args: + client: Authenticated :class:`FragmentClient` instance. + username: Recipient's Telegram username (with or without ``@``). + amount: Number of Stars to send — integer from ``50`` to ``1 000 000``. + show_sender: Show your name as the gift sender. Defaults to ``True``. + + Returns: + :class:`StarsResult` with ``transaction_id``, ``username``, and ``stars``. + + Raises: + ConfigurationError: If ``amount`` is not an integer between 50 and 1 000 000. + UserNotFoundError: If the user is not found on Fragment. + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ if not isinstance(amount, int) or not (50 <= amount <= 1_000_000): raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT) @@ -97,7 +114,7 @@ async def purchase_stars(client: "FragmentClient", username: str, amount: int, s transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash) tx_hash = await process_transaction(client, transaction) - return StarsResult(transaction_id=tx_hash, username=username, stars=amount) + return StarsResult(transaction_id=tx_hash, username=username, amount=amount) except FragmentError: raise diff --git a/pyfragment/methods/ton.py b/pyfragment/methods/topup_ton.py similarity index 80% rename from pyfragment/methods/ton.py rename to pyfragment/methods/topup_ton.py index 44da5bb..ce094c8 100644 --- a/pyfragment/methods/ton.py +++ b/pyfragment/methods/topup_ton.py @@ -75,6 +75,23 @@ async def _init_request( async def topup_ton(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: + """Top up a Telegram Ads account balance with TON. + + Args: + client: Authenticated :class:`FragmentClient` instance. + username: Ads account username (with or without ``@``). + amount: Amount in TON — integer from ``1`` to ``1 000 000 000``. + show_sender: Show your name as the sender. Defaults to ``True``. + + Returns: + :class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``. + + Raises: + ConfigurationError: If ``amount`` is not an integer between 1 and 1 000 000 000. + UserNotFoundError: If the user is not found on Fragment. + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000): raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) diff --git a/pyfragment/types/__init__.py b/pyfragment/types/__init__.py index 042f02b..5de01d6 100644 --- a/pyfragment/types/__init__.py +++ b/pyfragment/types/__init__.py @@ -13,7 +13,14 @@ from pyfragment.types.exceptions import ( VerificationError, WalletError, ) -from pyfragment.types.results import AdsTopupResult, PremiumResult, StarsResult, WalletInfo +from pyfragment.types.results import ( + AdsTopupResult, + PremiumGiveawayResult, + PremiumResult, + StarsGiveawayResult, + StarsResult, + WalletInfo, +) __all__ = [ # client exceptions @@ -33,7 +40,9 @@ __all__ = [ "WalletError", # result types "AdsTopupResult", + "PremiumGiveawayResult", "PremiumResult", + "StarsGiveawayResult", "StarsResult", "WalletInfo", ] diff --git a/pyfragment/types/constants.py b/pyfragment/types/constants.py index 362a850..dd118bc 100644 --- a/pyfragment/types/constants.py +++ b/pyfragment/types/constants.py @@ -21,7 +21,9 @@ REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", " # Fragment page URLs STARS_PAGE: str = "https://fragment.com/stars/buy" +STARS_GIVEAWAY_PAGE: str = "https://fragment.com/stars/giveaway" PREMIUM_PAGE: str = "https://fragment.com/premium/gift" +PREMIUM_GIVEAWAY_PAGE: str = "https://fragment.com/premium/giveaway" TON_PAGE: str = "https://fragment.com/ads/topup" # Tonkeeper device fingerprint — serialized once, reused in every tx_data payload. diff --git a/pyfragment/types/exceptions.py b/pyfragment/types/exceptions.py index 1cc2283..949f002 100644 --- a/pyfragment/types/exceptions.py +++ b/pyfragment/types/exceptions.py @@ -19,6 +19,9 @@ class ConfigurationError(ClientError): INVALID_USERNAME = ( "Invalid username '{username}'. Must be 5–32 characters: letters (A–Z, a–z), digits (0–9), or underscores (_)." ) + INVALID_WINNERS_STARS = "winners must be an integer between 1 and 5." + INVALID_WINNERS_PREMIUM = "winners must be an integer between 1 and 24 000." + INVALID_STARS_PER_WINNER = "amount must be an integer between 500 and 1 000 000 stars." class CookieError(ClientError): diff --git a/pyfragment/types/results.py b/pyfragment/types/results.py index a6ad138..1cf129f 100644 --- a/pyfragment/types/results.py +++ b/pyfragment/types/results.py @@ -1,7 +1,6 @@ -import time -from dataclasses import dataclass, field +from dataclasses import dataclass -__all__ = ["AdsTopupResult", "PremiumResult", "StarsResult", "WalletInfo"] +__all__ = ["AdsTopupResult", "PremiumGiveawayResult", "PremiumResult", "StarsGiveawayResult", "StarsResult", "WalletInfo"] @dataclass @@ -22,11 +21,10 @@ class PremiumResult: transaction_id: str username: str - months: int - timestamp: int = field(default_factory=lambda: int(time.time())) + amount: int def __repr__(self) -> str: - return f"PremiumResult(username='{self.username}', months={self.months}, tx='{self.transaction_id}')" + return f"PremiumResult(username='{self.username}', amount={self.amount} months, tx='{self.transaction_id}')" @dataclass @@ -35,11 +33,10 @@ class StarsResult: transaction_id: str username: str - stars: int - timestamp: int = field(default_factory=lambda: int(time.time())) + amount: int def __repr__(self) -> str: - return f"StarsResult(username='{self.username}', stars={self.stars}, tx='{self.transaction_id}')" + return f"StarsResult(username='{self.username}', amount={self.amount} stars, tx='{self.transaction_id}')" @dataclass @@ -49,7 +46,38 @@ class AdsTopupResult: transaction_id: str username: str amount: int - timestamp: int = field(default_factory=lambda: int(time.time())) def __repr__(self) -> str: return f"AdsTopupResult(username='{self.username}', amount={self.amount} TON, tx='{self.transaction_id}')" + + +@dataclass +class StarsGiveawayResult: + """Result of a successful Telegram Stars giveaway.""" + + transaction_id: str + channel: str + winners: int + amount: int + + def __repr__(self) -> str: + return ( + f"StarsGiveawayResult(channel='{self.channel}', winners={self.winners}, " + f"amount={self.amount} stars per winner, tx='{self.transaction_id}')" + ) + + +@dataclass +class PremiumGiveawayResult: + """Result of a successful Telegram Premium giveaway.""" + + transaction_id: str + channel: str + winners: int + amount: int + + def __repr__(self) -> str: + return ( + f"PremiumGiveawayResult(channel='{self.channel}', winners={self.winners}, " + f"amount={self.amount} months per winner, tx='{self.transaction_id}')" + ) diff --git a/tests/006_test_methods_mock.py b/tests/006_test_methods_mock.py index f86c69e..d67910d 100644 --- a/tests/006_test_methods_mock.py +++ b/tests/006_test_methods_mock.py @@ -31,10 +31,10 @@ def client() -> FragmentClient: @pytest.mark.asyncio async def test_purchase_stars_success(client: FragmentClient) -> None: with ( - patch("pyfragment.methods.stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch("pyfragment.methods.purchase_stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.purchase_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch( - "pyfragment.methods.stars.fragment_post", + "pyfragment.methods.purchase_stars.fragment_post", AsyncMock( side_effect=[ {"found": {"recipient": FAKE_RECIPIENT}}, # searchStarsRecipient @@ -42,23 +42,23 @@ async def test_purchase_stars_success(client: FragmentClient) -> None: ] ), ), - patch("pyfragment.methods.stars.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), - patch("pyfragment.methods.stars.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + patch("pyfragment.methods.purchase_stars.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.purchase_stars.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), ): result = await client.purchase_stars("testuser", amount=100) assert isinstance(result, StarsResult) assert result.transaction_id == FAKE_TX_HASH assert result.username == "testuser" - assert result.stars == 100 + assert result.amount == 100 @pytest.mark.asyncio async def test_purchase_stars_user_not_found(client: FragmentClient) -> None: with ( - patch("pyfragment.methods.stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch("pyfragment.methods.stars.fragment_post", AsyncMock(return_value={"found": {}})), + patch("pyfragment.methods.purchase_stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.purchase_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch("pyfragment.methods.purchase_stars.fragment_post", AsyncMock(return_value={"found": {}})), ): with pytest.raises(UserNotFoundError): await client.purchase_stars("ghost", amount=100) @@ -67,10 +67,10 @@ async def test_purchase_stars_user_not_found(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_purchase_premium_success(client: FragmentClient) -> None: with ( - patch("pyfragment.methods.premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch("pyfragment.methods.purchase_premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.purchase_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch( - "pyfragment.methods.premium.fragment_post", + "pyfragment.methods.purchase_premium.fragment_post", AsyncMock( side_effect=[ {"found": {"recipient": FAKE_RECIPIENT}}, # searchPremiumGiftRecipient @@ -79,23 +79,23 @@ async def test_purchase_premium_success(client: FragmentClient) -> None: ] ), ), - patch("pyfragment.methods.premium.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), - patch("pyfragment.methods.premium.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + patch("pyfragment.methods.purchase_premium.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.purchase_premium.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), ): result = await client.purchase_premium("testuser", months=6) assert isinstance(result, PremiumResult) assert result.transaction_id == FAKE_TX_HASH assert result.username == "testuser" - assert result.months == 6 + assert result.amount == 6 @pytest.mark.asyncio async def test_purchase_premium_user_not_found(client: FragmentClient) -> None: with ( - patch("pyfragment.methods.premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch("pyfragment.methods.premium.fragment_post", AsyncMock(return_value={"found": {}})), + patch("pyfragment.methods.purchase_premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.purchase_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch("pyfragment.methods.purchase_premium.fragment_post", AsyncMock(return_value={"found": {}})), ): with pytest.raises(UserNotFoundError): await client.purchase_premium("ghost", months=3) @@ -104,10 +104,10 @@ async def test_purchase_premium_user_not_found(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_topup_ton_success(client: FragmentClient) -> None: with ( - patch("pyfragment.methods.ton.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch("pyfragment.methods.topup_ton.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.topup_ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch( - "pyfragment.methods.ton.fragment_post", + "pyfragment.methods.topup_ton.fragment_post", AsyncMock( side_effect=[ {}, # updateAdsTopupState @@ -116,8 +116,8 @@ async def test_topup_ton_success(client: FragmentClient) -> None: ] ), ), - patch("pyfragment.methods.ton.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), - patch("pyfragment.methods.ton.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + patch("pyfragment.methods.topup_ton.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.topup_ton.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), ): result = await client.topup_ton("testuser", amount=10) @@ -130,10 +130,10 @@ async def test_topup_ton_success(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_topup_ton_user_not_found(client: FragmentClient) -> None: with ( - patch("pyfragment.methods.ton.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch("pyfragment.methods.topup_ton.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.topup_ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch( - "pyfragment.methods.ton.fragment_post", + "pyfragment.methods.topup_ton.fragment_post", AsyncMock( side_effect=[ {}, # updateAdsTopupState From 2d1a11976884e3545b6ec238f1ba6800d1b2fe61 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sat, 21 Mar 2026 16:16:14 +0200 Subject: [PATCH 08/21] feat: update purchase and giveaway methods; improve header management and add example scripts --- examples/giveaway_premium.py | 43 ++++++++ examples/giveaway_stars.py | 43 ++++++++ examples/purchase_premium.py | 2 +- examples/purchase_stars.py | 2 +- examples/topup_ton.py | 2 +- fragment.svg | 1 - pyfragment/client.py | 2 +- pyfragment/methods/giveaway_premium.py | 16 +-- pyfragment/methods/giveaway_stars.py | 16 +-- pyfragment/methods/purchase_premium.py | 18 ++- pyfragment/methods/purchase_stars.py | 16 +-- pyfragment/methods/topup_ton.py | 18 ++- pyfragment/utils/__init__.py | 6 +- pyfragment/utils/http.py | 10 +- tests/005_test_methods.py | 56 ---------- tests/006_test_methods_mock.py | 146 ------------------------- 16 files changed, 133 insertions(+), 264 deletions(-) create mode 100644 examples/giveaway_premium.py create mode 100644 examples/giveaway_stars.py delete mode 100644 fragment.svg delete mode 100644 tests/005_test_methods.py delete mode 100644 tests/006_test_methods_mock.py diff --git a/examples/giveaway_premium.py b/examples/giveaway_premium.py new file mode 100644 index 0000000..70ce456 --- /dev/null +++ b/examples/giveaway_premium.py @@ -0,0 +1,43 @@ +""" +Example: run a Telegram Premium giveaway for a channel. + +winners must be an integer between 1 and 24 000. +months (Premium duration per winner) must be 3, 6, or 12. +""" + +import asyncio + +from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError + +SEED = "word1 word2 ... word24" +API_KEY = "YOUR_TONAPI_KEY" +COOKIES = { + "stel_ssid": "YOUR_STEL_SSID", + "stel_dt": "YOUR_STEL_DT", + "stel_token": "YOUR_STEL_TOKEN", + "stel_ton_token": "YOUR_STEL_TON_TOKEN", +} + +CHANNEL = "@channel" +WINNERS = 10 # 1–24 000 +MONTHS = 3 # 3, 6 or 12 + + +async def main() -> None: + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + try: + result = await client.giveaway_premium(CHANNEL, winners=WINNERS, months=MONTHS) + except UserNotFoundError: + print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.") + return + except ConfigurationError as e: + print(f"Invalid argument: {e}") + return + + print( + f"Premium giveaway created for {result.channel} — {result.winners} winners × {result.amount} months | tx: {result.transaction_id}" + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/giveaway_stars.py b/examples/giveaway_stars.py new file mode 100644 index 0000000..c35193f --- /dev/null +++ b/examples/giveaway_stars.py @@ -0,0 +1,43 @@ +""" +Example: run a Telegram Stars giveaway for a channel. + +winners must be an integer between 1 and 5. +amount (stars per winner) must be an integer between 500 and 1 000 000. +""" + +import asyncio + +from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError + +SEED = "word1 word2 ... word24" +API_KEY = "YOUR_TONAPI_KEY" +COOKIES = { + "stel_ssid": "YOUR_STEL_SSID", + "stel_dt": "YOUR_STEL_DT", + "stel_token": "YOUR_STEL_TOKEN", + "stel_ton_token": "YOUR_STEL_TON_TOKEN", +} + +CHANNEL = "@channel" +WINNERS = 3 # 1–5 +AMOUNT = 1000 # 500–1 000 000 stars per winner + + +async def main() -> None: + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + try: + result = await client.giveaway_stars(CHANNEL, winners=WINNERS, amount=AMOUNT) + except UserNotFoundError: + print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.") + return + except ConfigurationError as e: + print(f"Invalid argument: {e}") + return + + print( + f"Stars giveaway created for {result.channel} — {result.winners} winners × {result.amount} stars | tx: {result.transaction_id}" + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/purchase_premium.py b/examples/purchase_premium.py index f50ee0f..fc90f3b 100644 --- a/examples/purchase_premium.py +++ b/examples/purchase_premium.py @@ -19,7 +19,7 @@ COOKIES = { } USERNAME = "@username" -MONTHS = 3 # 3, 6, or 12 +MONTHS = 3 # 3, 6 or 12 async def main() -> None: diff --git a/examples/purchase_stars.py b/examples/purchase_stars.py index 97410ca..594f883 100644 --- a/examples/purchase_stars.py +++ b/examples/purchase_stars.py @@ -19,7 +19,7 @@ COOKIES = { } USERNAME = "@username" -AMOUNT = 500 # 50 to 1 000 000 stars, integer +AMOUNT = 500 # 50–1 000 000 stars async def main() -> None: diff --git a/examples/topup_ton.py b/examples/topup_ton.py index 96130f4..eb0d4ed 100644 --- a/examples/topup_ton.py +++ b/examples/topup_ton.py @@ -19,7 +19,7 @@ COOKIES = { } USERNAME = "@username" -AMOUNT = 10 # TON, integer — 1 to 1 000 000 000 +AMOUNT = 10 # 1–1 000 000 000 TON async def main() -> None: diff --git a/fragment.svg b/fragment.svg deleted file mode 100644 index 3d95464..0000000 --- a/fragment.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/pyfragment/client.py b/pyfragment/client.py index cc01d7a..ee6cb33 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -146,7 +146,7 @@ class FragmentClient: Returns: :class:`WalletInfo` with ``address`` (``"UQ..."``), ``state`` - (``"active"``, ``"uninit"``, or ``"frozen"``), and ``balance`` in TON. + (``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``), and ``balance`` in TON. """ return await get_wallet_info(self) diff --git a/pyfragment/methods/giveaway_premium.py b/pyfragment/methods/giveaway_premium.py index 25c4ea3..41148c4 100644 --- a/pyfragment/methods/giveaway_premium.py +++ b/pyfragment/methods/giveaway_premium.py @@ -10,25 +10,21 @@ from pyfragment.types import ( UnexpectedError, UserNotFoundError, ) -from pyfragment.types.constants import BASE_HEADERS, DEVICE, PREMIUM_GIVEAWAY_PAGE +from pyfragment.types.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE from pyfragment.types.results import PremiumGiveawayResult from pyfragment.utils import ( execute_transaction_request, - fragment_post, + fragment_request, get_account_info, get_fragment_hash, + make_headers, process_transaction, ) if TYPE_CHECKING: from pyfragment.client import FragmentClient -# Page-specific headers -HEADERS: dict[str, str] = { - **BASE_HEADERS, - "referer": PREMIUM_GIVEAWAY_PAGE, - "x-aj-referer": PREMIUM_GIVEAWAY_PAGE, -} +HEADERS: dict[str, str] = make_headers(PREMIUM_GIVEAWAY_PAGE) async def _search_recipient( @@ -38,7 +34,7 @@ async def _search_recipient( winners: int, months: int, ) -> str: - result = await fragment_post( + result = await fragment_request( session, fragment_hash, HEADERS, @@ -62,7 +58,7 @@ async def _init_request( winners: int, months: int, ) -> str: - result = await fragment_post( + result = await fragment_request( session, fragment_hash, HEADERS, diff --git a/pyfragment/methods/giveaway_stars.py b/pyfragment/methods/giveaway_stars.py index c3e5c8f..5eb4ef8 100644 --- a/pyfragment/methods/giveaway_stars.py +++ b/pyfragment/methods/giveaway_stars.py @@ -10,25 +10,21 @@ from pyfragment.types import ( UnexpectedError, UserNotFoundError, ) -from pyfragment.types.constants import BASE_HEADERS, DEVICE, STARS_GIVEAWAY_PAGE +from pyfragment.types.constants import DEVICE, STARS_GIVEAWAY_PAGE from pyfragment.types.results import StarsGiveawayResult from pyfragment.utils import ( execute_transaction_request, - fragment_post, + fragment_request, get_account_info, get_fragment_hash, + make_headers, process_transaction, ) if TYPE_CHECKING: from pyfragment.client import FragmentClient -# Page-specific headers -HEADERS: dict[str, str] = { - **BASE_HEADERS, - "referer": STARS_GIVEAWAY_PAGE, - "x-aj-referer": STARS_GIVEAWAY_PAGE, -} +HEADERS: dict[str, str] = make_headers(STARS_GIVEAWAY_PAGE) async def _search_recipient( @@ -36,7 +32,7 @@ async def _search_recipient( fragment_hash: str, channel: str, ) -> str: - result = await fragment_post( + result = await fragment_request( session, fragment_hash, HEADERS, @@ -58,7 +54,7 @@ async def _init_request( winners: int, amount: int, ) -> str: - result = await fragment_post( + result = await fragment_request( session, fragment_hash, HEADERS, diff --git a/pyfragment/methods/purchase_premium.py b/pyfragment/methods/purchase_premium.py index f6a482a..6cd75f9 100644 --- a/pyfragment/methods/purchase_premium.py +++ b/pyfragment/methods/purchase_premium.py @@ -12,24 +12,20 @@ from pyfragment.types import ( UnexpectedError, UserNotFoundError, ) -from pyfragment.types.constants import BASE_HEADERS, DEVICE, PREMIUM_PAGE +from pyfragment.types.constants import DEVICE, PREMIUM_PAGE from pyfragment.utils import ( execute_transaction_request, - fragment_post, + fragment_request, get_account_info, get_fragment_hash, + make_headers, process_transaction, ) if TYPE_CHECKING: from pyfragment.client import FragmentClient -# Page-specific headers -HEADERS: dict[str, str] = { - **BASE_HEADERS, - "referer": PREMIUM_PAGE, - "x-aj-referer": PREMIUM_PAGE, -} +HEADERS: dict[str, str] = make_headers(PREMIUM_PAGE) async def _search_recipient( @@ -38,7 +34,7 @@ async def _search_recipient( username: str, months: int, ) -> str: - result = await fragment_post( + result = await fragment_request( session, fragment_hash, HEADERS, @@ -60,7 +56,7 @@ async def _init_request( recipient: str, months: int, ) -> str: - await fragment_post( + await fragment_request( session, fragment_hash, HEADERS, @@ -71,7 +67,7 @@ async def _init_request( "method": "updatePremiumState", }, ) - result = await fragment_post( + result = await fragment_request( session, fragment_hash, HEADERS, diff --git a/pyfragment/methods/purchase_stars.py b/pyfragment/methods/purchase_stars.py index cd9b371..76a1675 100644 --- a/pyfragment/methods/purchase_stars.py +++ b/pyfragment/methods/purchase_stars.py @@ -11,24 +11,20 @@ from pyfragment.types import ( UnexpectedError, UserNotFoundError, ) -from pyfragment.types.constants import BASE_HEADERS, DEVICE, STARS_PAGE +from pyfragment.types.constants import DEVICE, STARS_PAGE from pyfragment.utils import ( execute_transaction_request, - fragment_post, + fragment_request, get_account_info, get_fragment_hash, + make_headers, process_transaction, ) if TYPE_CHECKING: from pyfragment.client import FragmentClient -# Page-specific headers -HEADERS: dict[str, str] = { - **BASE_HEADERS, - "referer": STARS_PAGE, - "x-aj-referer": STARS_PAGE, -} +HEADERS: dict[str, str] = make_headers(STARS_PAGE) async def _search_recipient( @@ -36,7 +32,7 @@ async def _search_recipient( fragment_hash: str, username: str, ) -> str: - result = await fragment_post( + result = await fragment_request( session, fragment_hash, HEADERS, @@ -58,7 +54,7 @@ async def _init_request( recipient: str, amount: int, ) -> str: - result = await fragment_post( + result = await fragment_request( session, fragment_hash, HEADERS, diff --git a/pyfragment/methods/topup_ton.py b/pyfragment/methods/topup_ton.py index ce094c8..95528f5 100644 --- a/pyfragment/methods/topup_ton.py +++ b/pyfragment/methods/topup_ton.py @@ -11,24 +11,20 @@ from pyfragment.types import ( UnexpectedError, UserNotFoundError, ) -from pyfragment.types.constants import BASE_HEADERS, DEVICE, TON_PAGE +from pyfragment.types.constants import DEVICE, TON_PAGE from pyfragment.utils import ( execute_transaction_request, - fragment_post, + fragment_request, get_account_info, get_fragment_hash, + make_headers, process_transaction, ) if TYPE_CHECKING: from pyfragment.client import FragmentClient -# Page-specific headers -HEADERS: dict[str, str] = { - **BASE_HEADERS, - "referer": TON_PAGE, - "x-aj-referer": TON_PAGE, -} +HEADERS: dict[str, str] = make_headers(TON_PAGE) async def _search_recipient( @@ -36,8 +32,8 @@ async def _search_recipient( fragment_hash: str, username: str, ) -> str: - await fragment_post(session, fragment_hash, HEADERS, {"mode": "new", "method": "updateAdsTopupState"}) - result = await fragment_post( + await fragment_request(session, fragment_hash, HEADERS, {"mode": "new", "method": "updateAdsTopupState"}) + result = await fragment_request( session, fragment_hash, HEADERS, @@ -58,7 +54,7 @@ async def _init_request( recipient: str, amount: int, ) -> str: - result = await fragment_post( + result = await fragment_request( session, fragment_hash, HEADERS, diff --git a/pyfragment/utils/__init__.py b/pyfragment/utils/__init__.py index 59d3693..406d506 100644 --- a/pyfragment/utils/__init__.py +++ b/pyfragment/utils/__init__.py @@ -1,8 +1,9 @@ from pyfragment.utils.decoder import clean_decode from pyfragment.utils.http import ( execute_transaction_request, - fragment_post, + fragment_request, get_fragment_hash, + make_headers, parse_json_response, ) from pyfragment.utils.wallet import get_account_info, process_transaction @@ -10,9 +11,10 @@ from pyfragment.utils.wallet import get_account_info, process_transaction __all__ = [ "clean_decode", "execute_transaction_request", - "fragment_post", + "fragment_request", "get_account_info", "get_fragment_hash", + "make_headers", "parse_json_response", "process_transaction", ] diff --git a/pyfragment/utils/http.py b/pyfragment/utils/http.py index cbdb6e9..02cb3a6 100644 --- a/pyfragment/utils/http.py +++ b/pyfragment/utils/http.py @@ -4,7 +4,11 @@ from typing import Any import httpx from pyfragment.types import FragmentPageError, ParseError, VerificationError -from pyfragment.types.constants import DEFAULT_TIMEOUT +from pyfragment.types.constants import BASE_HEADERS, DEFAULT_TIMEOUT + + +def make_headers(page_url: str) -> dict[str, str]: + return {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url} async def get_fragment_hash( @@ -79,7 +83,7 @@ def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc -async def fragment_post( +async def fragment_request( session: httpx.AsyncClient, fragment_hash: str, headers: dict[str, str], @@ -129,7 +133,7 @@ async def execute_transaction_request( VerificationError: If Fragment requires KYC verification. ParseError: If the response cannot be parsed. """ - transaction = await fragment_post(session, fragment_hash, headers, tx_data) + transaction = await fragment_request(session, fragment_hash, headers, tx_data) if transaction.get("need_verify"): raise VerificationError(VerificationError.KYC_REQUIRED) diff --git a/tests/005_test_methods.py b/tests/005_test_methods.py deleted file mode 100644 index 563e023..0000000 --- a/tests/005_test_methods.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Unit tests for method-level input validation — no network calls.""" - -import pytest - -from pyfragment import FragmentClient -from pyfragment.types import ConfigurationError - -VALID_SEED = "abandon " * 23 + "about" -VALID_API_KEY = "A" * 68 -VALID_COOKIES = { - "stel_ssid": "x", - "stel_dt": "x", - "stel_token": "x", - "stel_ton_token": "x", -} - - -@pytest.fixture -def client() -> FragmentClient: - return FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES) - - -@pytest.mark.asyncio -async def test_purchase_premium_invalid_months(client: FragmentClient) -> None: - with pytest.raises(ConfigurationError): - await client.purchase_premium("@user", months=5) - - -@pytest.mark.asyncio -async def test_purchase_stars_amount_too_low(client: FragmentClient) -> None: - with pytest.raises(ConfigurationError): - await client.purchase_stars("@user", amount=49) - - -@pytest.mark.asyncio -async def test_purchase_stars_amount_too_high(client: FragmentClient) -> None: - with pytest.raises(ConfigurationError): - await client.purchase_stars("@user", amount=1_000_001) - - -@pytest.mark.asyncio -async def test_purchase_stars_float_amount(client: FragmentClient) -> None: - with pytest.raises(ConfigurationError): - await client.purchase_stars("@user", amount=100.5) # type: ignore[arg-type] - - -@pytest.mark.asyncio -async def test_topup_ton_amount_zero(client: FragmentClient) -> None: - with pytest.raises(ConfigurationError): - await client.topup_ton("@user", amount=0) - - -@pytest.mark.asyncio -async def test_topup_ton_amount_too_high(client: FragmentClient) -> None: - with pytest.raises(ConfigurationError): - await client.topup_ton("@user", amount=1_000_000_001) diff --git a/tests/006_test_methods_mock.py b/tests/006_test_methods_mock.py deleted file mode 100644 index d67910d..0000000 --- a/tests/006_test_methods_mock.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Tests for purchase methods with all network calls mocked.""" - -from unittest.mock import AsyncMock, patch - -import pytest - -from pyfragment import FragmentClient -from pyfragment.types import AdsTopupResult, PremiumResult, StarsResult, UserNotFoundError - -VALID_SEED = "abandon " * 23 + "about" -VALID_API_KEY = "A" * 68 -VALID_COOKIES = { - "stel_ssid": "x", - "stel_dt": "x", - "stel_token": "x", - "stel_ton_token": "x", -} -FAKE_HASH = "abc123" -FAKE_RECIPIENT = "recipient_token" -FAKE_REQ_ID = "req_42" -FAKE_TX_HASH = "deadbeef" * 8 -FAKE_ACCOUNT = {"address": "0:abc", "publicKey": "pub", "chain": "-239", "walletStateInit": "base64=="} -FAKE_TRANSACTION = {"transaction": {"messages": [{"address": "0:abc", "amount": "100000000", "payload": ""}]}} - - -@pytest.fixture -def client() -> FragmentClient: - return FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES) - - -@pytest.mark.asyncio -async def test_purchase_stars_success(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.purchase_stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.purchase_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch( - "pyfragment.methods.purchase_stars.fragment_post", - AsyncMock( - side_effect=[ - {"found": {"recipient": FAKE_RECIPIENT}}, # searchStarsRecipient - {"req_id": FAKE_REQ_ID}, # initBuyStarsRequest - ] - ), - ), - patch("pyfragment.methods.purchase_stars.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), - patch("pyfragment.methods.purchase_stars.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), - ): - result = await client.purchase_stars("testuser", amount=100) - - assert isinstance(result, StarsResult) - assert result.transaction_id == FAKE_TX_HASH - assert result.username == "testuser" - assert result.amount == 100 - - -@pytest.mark.asyncio -async def test_purchase_stars_user_not_found(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.purchase_stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.purchase_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch("pyfragment.methods.purchase_stars.fragment_post", AsyncMock(return_value={"found": {}})), - ): - with pytest.raises(UserNotFoundError): - await client.purchase_stars("ghost", amount=100) - - -@pytest.mark.asyncio -async def test_purchase_premium_success(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.purchase_premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.purchase_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch( - "pyfragment.methods.purchase_premium.fragment_post", - AsyncMock( - side_effect=[ - {"found": {"recipient": FAKE_RECIPIENT}}, # searchPremiumGiftRecipient - {}, # updatePremiumState - {"req_id": FAKE_REQ_ID}, # initGiftPremiumRequest - ] - ), - ), - patch("pyfragment.methods.purchase_premium.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), - patch("pyfragment.methods.purchase_premium.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), - ): - result = await client.purchase_premium("testuser", months=6) - - assert isinstance(result, PremiumResult) - assert result.transaction_id == FAKE_TX_HASH - assert result.username == "testuser" - assert result.amount == 6 - - -@pytest.mark.asyncio -async def test_purchase_premium_user_not_found(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.purchase_premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.purchase_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch("pyfragment.methods.purchase_premium.fragment_post", AsyncMock(return_value={"found": {}})), - ): - with pytest.raises(UserNotFoundError): - await client.purchase_premium("ghost", months=3) - - -@pytest.mark.asyncio -async def test_topup_ton_success(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.topup_ton.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.topup_ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch( - "pyfragment.methods.topup_ton.fragment_post", - AsyncMock( - side_effect=[ - {}, # updateAdsTopupState - {"found": {"recipient": FAKE_RECIPIENT}}, # searchAdsTopupRecipient - {"req_id": FAKE_REQ_ID}, # initAdsTopupRequest - ] - ), - ), - patch("pyfragment.methods.topup_ton.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), - patch("pyfragment.methods.topup_ton.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), - ): - result = await client.topup_ton("testuser", amount=10) - - assert isinstance(result, AdsTopupResult) - assert result.transaction_id == FAKE_TX_HASH - assert result.username == "testuser" - assert result.amount == 10 - - -@pytest.mark.asyncio -async def test_topup_ton_user_not_found(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.topup_ton.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.topup_ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch( - "pyfragment.methods.topup_ton.fragment_post", - AsyncMock( - side_effect=[ - {}, # updateAdsTopupState - {"found": {}}, # searchAdsTopupRecipient → not found - ] - ), - ), - ): - with pytest.raises(UserNotFoundError): - await client.topup_ton("ghost", amount=10) From c7b6c6c93318be23de42c2294c0650e170c25405 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sat, 21 Mar 2026 16:30:44 +0200 Subject: [PATCH 09/21] feat: add comprehensive unit tests for FragmentClient methods; include shared test constants --- tests/001_test_decode.py | 6 ++ tests/002_test_client.py | 62 ++++++++++--------- tests/004_test_balance.py | 9 ++- tests/005_test_stars.py | 125 ++++++++++++++++++++++++++++++++++++++ tests/006_test_premium.py | 119 ++++++++++++++++++++++++++++++++++++ tests/007_test_topup.py | 72 ++++++++++++++++++++++ tests/008_test_wallet.py | 59 ++++++++++++++++++ tests/conftest.py | 9 +++ tests/shared.py | 23 +++++++ 9 files changed, 454 insertions(+), 30 deletions(-) create mode 100644 tests/005_test_stars.py create mode 100644 tests/006_test_premium.py create mode 100644 tests/007_test_topup.py create mode 100644 tests/008_test_wallet.py create mode 100644 tests/shared.py diff --git a/tests/001_test_decode.py b/tests/001_test_decode.py index 170fa1e..0b59b82 100644 --- a/tests/001_test_decode.py +++ b/tests/001_test_decode.py @@ -23,6 +23,9 @@ PAYLOADS = [ ] +# Decode valid payload tests + + @pytest.mark.parametrize("payload", PAYLOADS) def test_decode_payload(payload: str) -> None: result = clean_decode(payload) @@ -31,6 +34,9 @@ def test_decode_payload(payload: str) -> None: assert all(ord(c) < 128 for c in result), f"non-ASCII chars in {result!r}" +# Edge case tests + + def test_empty_payload_returns_empty_string() -> None: assert clean_decode("") == "" diff --git a/tests/002_test_client.py b/tests/002_test_client.py index ccf10fe..8e5607e 100644 --- a/tests/002_test_client.py +++ b/tests/002_test_client.py @@ -6,15 +6,9 @@ import pytest from pyfragment import FragmentClient from pyfragment.types import ConfigurationError, CookieError +from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED -VALID_SEED = "abandon " * 23 + "about" -VALID_API_KEY = "A" * 68 -VALID_COOKIES = { - "stel_ssid": "x", - "stel_dt": "x", - "stel_token": "x", - "stel_ton_token": "x", -} +# Client init tests def test_valid_init() -> None: @@ -24,6 +18,9 @@ def test_valid_init() -> None: assert client.wallet_version == "V5R1" +# Wallet version tests + + def test_wallet_version_v4r2() -> None: client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="V4R2") assert client.wallet_version == "V4R2" @@ -34,6 +31,14 @@ def test_wallet_version_is_case_insensitive() -> None: assert client.wallet_version == "V5R1" +def test_unsupported_wallet_version_raises() -> None: + with pytest.raises(ConfigurationError): + FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="V3R2") + + +# Seed and mnemonic validation tests + + def test_missing_seed_raises() -> None: with pytest.raises(ConfigurationError): FragmentClient(seed="", api_key=VALID_API_KEY, cookies=VALID_COOKIES) @@ -44,14 +49,33 @@ def test_whitespace_only_seed_raises() -> None: FragmentClient(seed=" ", api_key=VALID_API_KEY, cookies=VALID_COOKIES) +def test_invalid_mnemonic_length_raises() -> None: + bad_seed = " ".join(["word"] * 23) + with pytest.raises(ConfigurationError): + FragmentClient(seed=bad_seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES) + + +def test_valid_mnemonic_lengths() -> None: + for length in (12, 18, 24): + seed = " ".join(["abandon"] * (length - 1) + ["about"]) + client = FragmentClient(seed=seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES) + assert len(client.seed.split()) == length + + +# API key validation tests + + def test_missing_api_key_raises() -> None: with pytest.raises(ConfigurationError): FragmentClient(seed=VALID_SEED, api_key="", cookies=VALID_COOKIES) -def test_unsupported_wallet_version_raises() -> None: +def test_short_api_key_raises() -> None: with pytest.raises(ConfigurationError): - FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="V3R2") + FragmentClient(seed=VALID_SEED, api_key="A" * 42, cookies=VALID_COOKIES) + + +# Cookie validation tests def test_cookies_as_json_string() -> None: @@ -81,24 +105,6 @@ def test_whitespace_cookie_value_raises() -> None: FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=bad) -def test_invalid_mnemonic_length_raises() -> None: - bad_seed = " ".join(["word"] * 23) - with pytest.raises(ConfigurationError): - FragmentClient(seed=bad_seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES) - - -def test_valid_mnemonic_lengths() -> None: - for length in (12, 18, 24): - seed = " ".join(["abandon"] * (length - 1) + ["about"]) - client = FragmentClient(seed=seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES) - assert len(client.seed.split()) == length - - -def test_short_api_key_raises() -> None: - with pytest.raises(ConfigurationError): - FragmentClient(seed=VALID_SEED, api_key="A" * 42, cookies=VALID_COOKIES) - - def test_repr() -> None: client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES) r = repr(client) diff --git a/tests/004_test_balance.py b/tests/004_test_balance.py index a784300..6d48f23 100644 --- a/tests/004_test_balance.py +++ b/tests/004_test_balance.py @@ -7,8 +7,7 @@ import pytest from pyfragment.types import TransactionError, WalletError from pyfragment.utils.wallet import process_transaction - -VALID_SEED = "abandon " * 23 + "about" +from tests.shared import VALID_SEED TRANSACTION_DATA = { "transaction": { @@ -51,6 +50,9 @@ def _patch_wallet(wallet: MagicMock): yield +# Balance threshold tests + + @pytest.mark.asyncio async def test_sufficient_balance_broadcasts() -> None: wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, needs 0.556 TON @@ -85,6 +87,9 @@ async def test_one_nanoton_below_minimum_raises() -> None: await process_transaction(_make_client(), TRANSACTION_DATA) +# Error handling tests + + @pytest.mark.asyncio async def test_invalid_payload_raises() -> None: with pytest.raises(TransactionError): diff --git a/tests/005_test_stars.py b/tests/005_test_stars.py new file mode 100644 index 0000000..826dc04 --- /dev/null +++ b/tests/005_test_stars.py @@ -0,0 +1,125 @@ +"""Unit tests for purchase_stars and giveaway_stars — validation and mocked network calls.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import FragmentClient +from pyfragment.types import ConfigurationError, StarsGiveawayResult, StarsResult, UserNotFoundError +from tests.shared import FAKE_ACCOUNT, FAKE_HASH, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH + +# Stars purchase validation tests + + +@pytest.mark.asyncio +async def test_purchase_stars_amount_too_low(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.purchase_stars("@user", amount=49) + + +@pytest.mark.asyncio +async def test_purchase_stars_amount_too_high(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.purchase_stars("@user", amount=1_000_001) + + +@pytest.mark.asyncio +async def test_purchase_stars_float_amount(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.purchase_stars("@user", amount=100.5) # type: ignore[arg-type] + + +# Stars purchase mocked tests + + +@pytest.mark.asyncio +async def test_purchase_stars_success(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.purchase_stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.purchase_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch( + "pyfragment.methods.purchase_stars.fragment_request", + AsyncMock(side_effect=[{"found": {"recipient": FAKE_RECIPIENT}}, {"req_id": FAKE_REQ_ID}]), + ), + patch("pyfragment.methods.purchase_stars.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.purchase_stars.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + ): + result = await client.purchase_stars("@user", amount=500) + + assert isinstance(result, StarsResult) + assert result.transaction_id == FAKE_TX_HASH + assert result.username == "@user" + assert result.amount == 500 + + +@pytest.mark.asyncio +async def test_purchase_stars_user_not_found(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.purchase_stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.purchase_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch("pyfragment.methods.purchase_stars.fragment_request", AsyncMock(return_value={"found": {}})), + ): + with pytest.raises(UserNotFoundError): + await client.purchase_stars("@ghost", amount=500) + + +# Stars giveaway validation tests + + +@pytest.mark.asyncio +async def test_giveaway_stars_winners_too_low(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.giveaway_stars("@channel", winners=0, amount=500) + + +@pytest.mark.asyncio +async def test_giveaway_stars_winners_too_high(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.giveaway_stars("@channel", winners=6, amount=500) + + +@pytest.mark.asyncio +async def test_giveaway_stars_amount_too_low(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.giveaway_stars("@channel", winners=1, amount=499) + + +@pytest.mark.asyncio +async def test_giveaway_stars_amount_too_high(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.giveaway_stars("@channel", winners=1, amount=1_000_001) + + +# Stars giveaway mocked tests + + +@pytest.mark.asyncio +async def test_giveaway_stars_success(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.giveaway_stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.giveaway_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch( + "pyfragment.methods.giveaway_stars.fragment_request", + AsyncMock(side_effect=[{"found": {"recipient": FAKE_RECIPIENT}}, {"req_id": FAKE_REQ_ID}]), + ), + patch("pyfragment.methods.giveaway_stars.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.giveaway_stars.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + ): + result = await client.giveaway_stars("@channel", winners=3, amount=1000) + + assert isinstance(result, StarsGiveawayResult) + assert result.transaction_id == FAKE_TX_HASH + assert result.channel == "@channel" + assert result.winners == 3 + assert result.amount == 1000 + + +@pytest.mark.asyncio +async def test_giveaway_stars_channel_not_found(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.giveaway_stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.giveaway_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch("pyfragment.methods.giveaway_stars.fragment_request", AsyncMock(return_value={"found": {}})), + ): + with pytest.raises(UserNotFoundError): + await client.giveaway_stars("@ghost", winners=1, amount=500) diff --git a/tests/006_test_premium.py b/tests/006_test_premium.py new file mode 100644 index 0000000..1495474 --- /dev/null +++ b/tests/006_test_premium.py @@ -0,0 +1,119 @@ +"""Unit tests for purchase_premium and giveaway_premium — validation and mocked network calls.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import FragmentClient +from pyfragment.types import ConfigurationError, PremiumGiveawayResult, PremiumResult, UserNotFoundError +from tests.shared import FAKE_ACCOUNT, FAKE_HASH, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH + +# Premium purchase validation tests + + +@pytest.mark.asyncio +async def test_purchase_premium_invalid_months(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.purchase_premium("@user", months=5) + + +@pytest.mark.asyncio +async def test_purchase_premium_months_zero(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.purchase_premium("@user", months=0) + + +# Premium purchase mocked tests + + +@pytest.mark.asyncio +async def test_purchase_premium_success(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.purchase_premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.purchase_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch( + "pyfragment.methods.purchase_premium.fragment_request", + AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, + {}, # updatePremiumState + {"req_id": FAKE_REQ_ID}, + ] + ), + ), + patch("pyfragment.methods.purchase_premium.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.purchase_premium.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + ): + result = await client.purchase_premium("@user", months=3) + + assert isinstance(result, PremiumResult) + assert result.transaction_id == FAKE_TX_HASH + assert result.username == "@user" + assert result.amount == 3 + + +@pytest.mark.asyncio +async def test_purchase_premium_user_not_found(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.purchase_premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.purchase_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch("pyfragment.methods.purchase_premium.fragment_request", AsyncMock(return_value={"found": {}})), + ): + with pytest.raises(UserNotFoundError): + await client.purchase_premium("@ghost", months=3) + + +# Premium giveaway validation tests + + +@pytest.mark.asyncio +async def test_giveaway_premium_winners_too_low(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.giveaway_premium("@channel", winners=0, months=3) + + +@pytest.mark.asyncio +async def test_giveaway_premium_winners_too_high(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.giveaway_premium("@channel", winners=24_001, months=3) + + +@pytest.mark.asyncio +async def test_giveaway_premium_invalid_months(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.giveaway_premium("@channel", winners=10, months=5) + + +# Premium giveaway mocked tests + + +@pytest.mark.asyncio +async def test_giveaway_premium_success(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.giveaway_premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.giveaway_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch( + "pyfragment.methods.giveaway_premium.fragment_request", + AsyncMock(side_effect=[{"found": {"recipient": FAKE_RECIPIENT}}, {"req_id": FAKE_REQ_ID}]), + ), + patch("pyfragment.methods.giveaway_premium.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.giveaway_premium.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + ): + result = await client.giveaway_premium("@channel", winners=10, months=3) + + assert isinstance(result, PremiumGiveawayResult) + assert result.transaction_id == FAKE_TX_HASH + assert result.channel == "@channel" + assert result.winners == 10 + assert result.amount == 3 + + +@pytest.mark.asyncio +async def test_giveaway_premium_channel_not_found(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.giveaway_premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.giveaway_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch("pyfragment.methods.giveaway_premium.fragment_request", AsyncMock(return_value={"found": {}})), + ): + with pytest.raises(UserNotFoundError): + await client.giveaway_premium("@ghost", winners=1, months=3) diff --git a/tests/007_test_topup.py b/tests/007_test_topup.py new file mode 100644 index 0000000..3cad838 --- /dev/null +++ b/tests/007_test_topup.py @@ -0,0 +1,72 @@ +"""Unit tests for topup_ton — validation and mocked network calls.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import FragmentClient +from pyfragment.types import AdsTopupResult, ConfigurationError, UserNotFoundError +from tests.shared import FAKE_ACCOUNT, FAKE_HASH, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH + +# Topup TON validation tests + + +@pytest.mark.asyncio +async def test_topup_ton_amount_zero(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.topup_ton("@user", amount=0) + + +@pytest.mark.asyncio +async def test_topup_ton_amount_too_high(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.topup_ton("@user", amount=1_000_000_001) + + +@pytest.mark.asyncio +async def test_topup_ton_float_amount(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.topup_ton("@user", amount=1.5) # type: ignore[arg-type] + + +# Topup TON mocked tests + + +@pytest.mark.asyncio +async def test_topup_ton_success(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.topup_ton.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.topup_ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch( + "pyfragment.methods.topup_ton.fragment_request", + AsyncMock( + side_effect=[ + {}, # updateAdsTopupState + {"found": {"recipient": FAKE_RECIPIENT}}, + {"req_id": FAKE_REQ_ID}, + ] + ), + ), + patch("pyfragment.methods.topup_ton.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.topup_ton.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + ): + result = await client.topup_ton("@user", amount=10) + + assert isinstance(result, AdsTopupResult) + assert result.transaction_id == FAKE_TX_HASH + assert result.username == "@user" + assert result.amount == 10 + + +@pytest.mark.asyncio +async def test_topup_ton_user_not_found(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.topup_ton.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.topup_ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch( + "pyfragment.methods.topup_ton.fragment_request", + AsyncMock(side_effect=[{}, {"found": {}}]), + ), + ): + with pytest.raises(UserNotFoundError): + await client.topup_ton("@ghost", amount=10) diff --git a/tests/008_test_wallet.py b/tests/008_test_wallet.py new file mode 100644 index 0000000..b4279dd --- /dev/null +++ b/tests/008_test_wallet.py @@ -0,0 +1,59 @@ +"""Unit tests for get_wallet — mocked network calls.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from pyfragment import FragmentClient, WalletInfo + +FAKE_ADDRESS = "UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5" +FAKE_BALANCE_NANOTON = 1_500_000_000 # 1.5 TON + + +# Wallet mocked tests + + +@pytest.mark.asyncio +async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None: + mock_wallet = MagicMock() + mock_wallet.refresh = AsyncMock() + mock_wallet.balance = FAKE_BALANCE_NANOTON + mock_wallet.state = MagicMock(value="active") + mock_wallet.address.to_str.return_value = FAKE_ADDRESS + + with ( + patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi, + patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes, + ): + mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) + mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) + mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None) + + result = await client.get_wallet() + + assert isinstance(result, WalletInfo) + assert result.address == FAKE_ADDRESS + assert result.state == "active" + assert result.balance == round(FAKE_BALANCE_NANOTON / 1_000_000_000, 4) + + +@pytest.mark.asyncio +async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None: + mock_wallet = MagicMock() + mock_wallet.refresh = AsyncMock() + mock_wallet.balance = 0 + mock_wallet.state = MagicMock(value="uninit") + mock_wallet.address.to_str.return_value = FAKE_ADDRESS + + with ( + patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi, + patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes, + ): + mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) + mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) + mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None) + + result = await client.get_wallet() + + assert result.balance == 0.0 + assert result.state == "uninit" diff --git a/tests/conftest.py b/tests/conftest.py index 68c5e75..a05bfd4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,9 @@ import os import pytest +from pyfragment import FragmentClient +from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED + @pytest.fixture def cookies(): @@ -14,3 +17,9 @@ def cookies(): return json.loads(raw) except Exception as exc: pytest.skip(f"Cookies unavailable — {exc}") + + +@pytest.fixture +def client() -> FragmentClient: + """Pre-built FragmentClient with dummy credentials.""" + return FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES) diff --git a/tests/shared.py b/tests/shared.py new file mode 100644 index 0000000..042095b --- /dev/null +++ b/tests/shared.py @@ -0,0 +1,23 @@ +"""Shared test constants — imported by all test modules that need them.""" + +from typing import Any + +# Shared test credentials + +VALID_SEED: str = "abandon " * 23 + "about" +VALID_API_KEY: str = "A" * 68 +VALID_COOKIES: dict[str, str] = { + "stel_ssid": "x", + "stel_dt": "x", + "stel_token": "x", + "stel_ton_token": "x", +} + +# Fake values for mocked tests + +FAKE_HASH: str = "abc123" +FAKE_RECIPIENT: str = "recipient_token" +FAKE_REQ_ID: str = "req_42" +FAKE_TX_HASH: str = "deadbeef" * 8 +FAKE_ACCOUNT: dict[str, Any] = {"address": "0:abc", "publicKey": "pub", "chain": "-239", "walletStateInit": "base64=="} +FAKE_TRANSACTION: dict[str, Any] = {"transaction": {"messages": [{"address": "0:abc", "amount": "100000000", "payload": ""}]}} From 25d70a77ed35f35f188e1fab1c23e33e127dee20 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sat, 21 Mar 2026 16:45:52 +0200 Subject: [PATCH 10/21] feat: implement client.call() method for raw Fragment API requests; add example and unit tests --- examples/call.py | 36 +++++++++++++++++++ pyfragment/client.py | 43 +++++++++++++++++++++-- pyfragment/types/constants.py | 13 +++---- pyfragment/utils/http.py | 8 ++--- tests/009_test_call.py | 66 +++++++++++++++++++++++++++++++++++ 5 files changed, 154 insertions(+), 12 deletions(-) create mode 100644 examples/call.py create mode 100644 tests/009_test_call.py diff --git a/examples/call.py b/examples/call.py new file mode 100644 index 0000000..e0a6a22 --- /dev/null +++ b/examples/call.py @@ -0,0 +1,36 @@ +""" +Example: send a raw request to any Fragment API method. + +Use client.call() when you need to access a method that is not yet +wrapped by the library, or to inspect raw API responses directly. + +page_url must match the Fragment page the method belongs to — Fragment +requires a hash derived from each specific page. +""" + +import asyncio + +from pyfragment import FragmentClient + +SEED = "word1 word2 ... word24" +API_KEY = "YOUR_TONAPI_KEY" +COOKIES = { + "stel_ssid": "YOUR_STEL_SSID", + "stel_dt": "YOUR_STEL_DT", + "stel_token": "YOUR_STEL_TOKEN", + "stel_ton_token": "YOUR_STEL_TON_TOKEN", +} + +METHOD = "anyFragmentMethod" # replace with the actual method name +DATA = {"key": "value"} # replace with the actual request payload +PAGE_URL = "https://fragment.com/stars/buy" # replace with the matching Fragment page + + +async def main() -> None: + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + result = await client.call(METHOD, DATA, page_url=PAGE_URL) + print(result) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyfragment/client.py b/pyfragment/client.py index ee6cb33..4fe9ecc 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -1,5 +1,7 @@ import json -from typing import cast +from typing import Any, cast + +import httpx from pyfragment.methods.giveaway_premium import giveaway_premium from pyfragment.methods.giveaway_stars import giveaway_stars @@ -14,8 +16,15 @@ from pyfragment.types import ( StarsResult, WalletInfo, ) -from pyfragment.types.constants import DEFAULT_TIMEOUT, REQUIRED_COOKIE_KEYS, SUPPORTED_WALLET_VERSIONS, WalletVersion +from pyfragment.types.constants import ( + DEFAULT_TIMEOUT, + FRAGMENT_BASE_URL, + REQUIRED_COOKIE_KEYS, + SUPPORTED_WALLET_VERSIONS, + WalletVersion, +) from pyfragment.types.results import PremiumGiveawayResult, StarsGiveawayResult +from pyfragment.utils.http import fragment_request, get_fragment_hash, make_headers from pyfragment.utils.wallet import get_wallet_info @@ -187,3 +196,33 @@ class FragmentClient: ``winners``, and ``amount``. """ return await giveaway_premium(self, channel, winners, months) + + async def call( + self, method: str, data: dict[str, Any] | None = None, *, page_url: str = FRAGMENT_BASE_URL + ) -> dict[str, Any]: + """Send a raw request to the Fragment API. + + Useful for accessing undocumented or future Fragment API methods + without waiting for a library update. + + Args: + method: Fragment API method name, e.g. ``"searchPremiumGiftRecipient"``. + data: Additional form-data fields to include in the request body. + page_url: Fragment page URL used to derive the API hash and headers. + Defaults to ``FRAGMENT_BASE_URL`` (``"https://fragment.com"``). + + Returns: + Raw parsed JSON response as a dict. + + Example:: + + result = await client.call( + "searchPremiumGiftRecipient", + {"query": "@username", "months": 3}, + page_url="https://fragment.com/premium/gift", + ) + """ + headers = make_headers(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 {})}) diff --git a/pyfragment/types/constants.py b/pyfragment/types/constants.py index dd118bc..b5f64fe 100644 --- a/pyfragment/types/constants.py +++ b/pyfragment/types/constants.py @@ -20,11 +20,12 @@ DEFAULT_TIMEOUT: float = 30.0 REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token") # Fragment page URLs -STARS_PAGE: str = "https://fragment.com/stars/buy" -STARS_GIVEAWAY_PAGE: str = "https://fragment.com/stars/giveaway" -PREMIUM_PAGE: str = "https://fragment.com/premium/gift" -PREMIUM_GIVEAWAY_PAGE: str = "https://fragment.com/premium/giveaway" -TON_PAGE: str = "https://fragment.com/ads/topup" +FRAGMENT_BASE_URL: str = "https://fragment.com" +STARS_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/buy" +STARS_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/giveaway" +PREMIUM_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/gift" +PREMIUM_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/giveaway" +TON_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup" # Tonkeeper device fingerprint — serialized once, reused in every tx_data payload. DEVICE: str = json.dumps( @@ -47,7 +48,7 @@ BASE_HEADERS: dict[str, str] = { "accept": "application/json, text/javascript, */*; q=0.01", "accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7", "content-type": "application/x-www-form-urlencoded; charset=UTF-8", - "origin": "https://fragment.com", + "origin": FRAGMENT_BASE_URL, "priority": "u=1, i", "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", diff --git a/pyfragment/utils/http.py b/pyfragment/utils/http.py index 02cb3a6..d21702e 100644 --- a/pyfragment/utils/http.py +++ b/pyfragment/utils/http.py @@ -4,10 +4,10 @@ from typing import Any import httpx from pyfragment.types import FragmentPageError, ParseError, VerificationError -from pyfragment.types.constants import BASE_HEADERS, DEFAULT_TIMEOUT +from pyfragment.types.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL -def make_headers(page_url: str) -> dict[str, str]: +def make_headers(page_url: str = FRAGMENT_BASE_URL) -> dict[str, str]: return {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url} @@ -44,7 +44,7 @@ async def get_fragment_hash( page_headers.update( { "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "referer": "https://fragment.com/", + "referer": f"{FRAGMENT_BASE_URL}/", "sec-fetch-dest": "document", "sec-fetch-mode": "navigate", "upgrade-insecure-requests": "1", @@ -105,7 +105,7 @@ async def fragment_request( Parsed API response as a dict. """ resp = await session.post( - f"https://fragment.com/api?hash={fragment_hash}", + f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}", headers=headers, data=data, ) diff --git a/tests/009_test_call.py b/tests/009_test_call.py new file mode 100644 index 0000000..42be933 --- /dev/null +++ b/tests/009_test_call.py @@ -0,0 +1,66 @@ +"""Unit tests for client.call() — raw Fragment API request.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import FragmentClient +from tests.shared import FAKE_HASH + +FAKE_RESPONSE = {"status": "ok", "data": {"value": 42}} + + +# client.call() mocked tests + + +@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)), + ): + result = await client.call("anyMethod", {"key": "value"}) + + assert result == FAKE_RESPONSE + + +@pytest.mark.asyncio +async def test_call_default_page_url(client: FragmentClient) -> None: + """call() works without explicitly passing page_url (defaults to FRAGMENT_BASE_URL).""" + with ( + patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)), + ): + result = await client.call("anyMethod") + + assert result == FAKE_RESPONSE + + +@pytest.mark.asyncio +async def test_call_no_data(client: FragmentClient) -> None: + """call() with no extra data passes only the method field.""" + mock_request = AsyncMock(return_value={}) + + with ( + patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.client.fragment_request", mock_request), + ): + await client.call("anyMethod") + + _, _, _, sent_data = mock_request.call_args.args + assert sent_data == {"method": "anyMethod"} + + +@pytest.mark.asyncio +async def test_call_merges_extra_data(client: FragmentClient) -> None: + """call() merges caller-supplied data with the method field.""" + mock_request = AsyncMock(return_value={}) + + with ( + patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.client.fragment_request", mock_request), + ): + await client.call("anyMethod", {"key": "value", "num": 7}) + + _, _, _, sent_data = mock_request.call_args.args + assert sent_data == {"method": "anyMethod", "key": "value", "num": 7} From b55cd0020578793ad503d7abd8ea5e566c2a6598 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sat, 21 Mar 2026 17:12:26 +0200 Subject: [PATCH 11/21] feat: update changelog and examples; unify result fields and improve error messages --- CHANGELOG.md | 7 +++ examples/giveaway_premium.py | 2 +- examples/giveaway_stars.py | 2 +- examples/topup_ton.py | 2 +- pyfragment/client.py | 4 +- pyfragment/methods/giveaway_premium.py | 2 +- pyfragment/methods/purchase_premium.py | 2 +- pyfragment/methods/purchase_stars.py | 2 +- pyfragment/types/exceptions.py | 76 ++++++++++++++++++-------- pyfragment/utils/wallet.py | 19 +++++-- 10 files changed, 81 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93c6253..14b491b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,19 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI - `giveaway_premium(channel, winners, months)` — run a Telegram Premium giveaway for a channel (1–24 000 winners, 3/6/12 months each) - `StarsGiveawayResult` and `PremiumGiveawayResult` result types - `STARS_GIVEAWAY_PAGE` and `PREMIUM_GIVEAWAY_PAGE` URL constants +- `FragmentClient.call(method, data, *, page_url)` — send a raw request to any Fragment API method without waiting for a library update +- `FRAGMENT_BASE_URL` constant — single source of truth for the Fragment base URL used across all page constants and headers +- `examples/call.py` — usage example for `client.call()` ### Changed - All result types (`PremiumResult`, `StarsResult`, `StarsGiveawayResult`, `PremiumGiveawayResult`) now use a single unified `amount` field instead of `months`, `stars` — consistent API across every method - `__repr__` on result types now includes the unit (`3 months`, `500 stars`) for clarity - Method module files renamed to match their function: `premium.py` → `purchase_premium.py`, `stars.py` → `purchase_stars.py`, `ton.py` → `topup_ton.py` - `timestamp` field removed from all result dataclasses +- All page URL constants (`STARS_PAGE`, `PREMIUM_PAGE`, etc.) now built from `FRAGMENT_BASE_URL` instead of hardcoded strings +- `TransactionError` now includes an SSL hint when a broadcast fails due to certificate verification errors +- `TransactionError.DUPLICATE_SEQNO` — dedicated error raised after 3 failed attempts due to a `406 Duplicate msg_seqno` response from the TON network; broadcast is automatically retried (up to 2 retries, 2 s apart) before giving up +- All error message templates rewritten to follow a "what happened → why → what to do" pattern — every template is now actionable and includes a fix hint where applicable --- diff --git a/examples/giveaway_premium.py b/examples/giveaway_premium.py index 70ce456..e2a5818 100644 --- a/examples/giveaway_premium.py +++ b/examples/giveaway_premium.py @@ -35,7 +35,7 @@ async def main() -> None: return print( - f"Premium giveaway created for {result.channel} — {result.winners} winners × {result.amount} months | tx: {result.transaction_id}" + f"Premium giveaway created for {result.channel} — {result.winners} winner(s) × {result.amount} months each | tx: {result.transaction_id}" ) diff --git a/examples/giveaway_stars.py b/examples/giveaway_stars.py index c35193f..400a707 100644 --- a/examples/giveaway_stars.py +++ b/examples/giveaway_stars.py @@ -35,7 +35,7 @@ async def main() -> None: return print( - f"Stars giveaway created for {result.channel} — {result.winners} winners × {result.amount} stars | tx: {result.transaction_id}" + f"Stars giveaway created for {result.channel} — {result.winners} winner(s) × {result.amount} stars each | tx: {result.transaction_id}" ) diff --git a/examples/topup_ton.py b/examples/topup_ton.py index eb0d4ed..f7730c8 100644 --- a/examples/topup_ton.py +++ b/examples/topup_ton.py @@ -36,7 +36,7 @@ async def main() -> None: print(f"Invalid argument: {e}") return - print(f"{result.amount} TON successfully sent to {result.username} | tx: {result.transaction_id}") + print(f"{result.amount} TON successfully topped up for {result.username} | tx: {result.transaction_id}") if __name__ == "__main__": diff --git a/pyfragment/client.py b/pyfragment/client.py index 4fe9ecc..4e544a1 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -120,7 +120,7 @@ class FragmentClient: show_sender: Show your name as the sender. Defaults to ``True``. Returns: - :class:`PremiumResult` with ``transaction_id``, ``username``, and ``months``. + :class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``. """ return await purchase_premium(self, username, months, show_sender) @@ -133,7 +133,7 @@ class FragmentClient: show_sender: Show your name as the gift sender. Defaults to ``True``. Returns: - :class:`StarsResult` with ``transaction_id``, ``username``, and ``stars``. + :class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``. """ return await purchase_stars(self, username, amount, show_sender) diff --git a/pyfragment/methods/giveaway_premium.py b/pyfragment/methods/giveaway_premium.py index 41148c4..6dfe462 100644 --- a/pyfragment/methods/giveaway_premium.py +++ b/pyfragment/methods/giveaway_premium.py @@ -91,7 +91,7 @@ async def giveaway_premium( Returns: :class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``, - ``winners``, and ``months``. + ``winners``, and ``amount``. Raises: ConfigurationError: If ``winners`` is not 1–24 000 or ``months`` is not 3, 6, or 12. diff --git a/pyfragment/methods/purchase_premium.py b/pyfragment/methods/purchase_premium.py index 6cd75f9..6e189e5 100644 --- a/pyfragment/methods/purchase_premium.py +++ b/pyfragment/methods/purchase_premium.py @@ -93,7 +93,7 @@ async def purchase_premium(client: "FragmentClient", username: str, months: int, show_sender: Show your name as the gift sender. Defaults to ``True``. Returns: - :class:`PremiumResult` with ``transaction_id``, ``username``, and ``months``. + :class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``. Raises: ConfigurationError: If ``months`` is not ``3``, ``6``, or ``12``. diff --git a/pyfragment/methods/purchase_stars.py b/pyfragment/methods/purchase_stars.py index 76a1675..21db354 100644 --- a/pyfragment/methods/purchase_stars.py +++ b/pyfragment/methods/purchase_stars.py @@ -80,7 +80,7 @@ async def purchase_stars(client: "FragmentClient", username: str, amount: int, s show_sender: Show your name as the gift sender. Defaults to ``True``. Returns: - :class:`StarsResult` with ``transaction_id``, ``username``, and ``stars``. + :class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``. Raises: ConfigurationError: If ``amount`` is not an integer between 50 and 1 000 000. diff --git a/pyfragment/types/exceptions.py b/pyfragment/types/exceptions.py index 949f002..3c5f390 100644 --- a/pyfragment/types/exceptions.py +++ b/pyfragment/types/exceptions.py @@ -11,25 +11,29 @@ class ConfigurationError(ClientError): MISSING_VARS = "Missing required parameter(s): {keys}." UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}." - INVALID_MNEMONIC = "Invalid mnemonic: got {count} words, expected 12, 18, or 24." - INVALID_API_KEY = "Invalid Tonapi key: got {length} characters, expected at least 68. Get one at https://tonconsole.com." - INVALID_MONTHS = "Invalid duration. Choose 3, 6, or 12 months." - INVALID_STARS_AMOUNT = "Amount must be an integer between 50 and 1 000 000 stars." - INVALID_TON_AMOUNT = "Amount must be an integer between 1 and 1 000 000 000 TON." - INVALID_USERNAME = ( - "Invalid username '{username}'. Must be 5–32 characters: letters (A–Z, a–z), digits (0–9), or underscores (_)." + INVALID_MNEMONIC = "Invalid mnemonic: expected 12, 18, or 24 words, got {count}." + INVALID_API_KEY = ( + "Invalid Tonapi API key: expected at least 68 characters, got {length}. " "Generate a key at https://tonconsole.com." ) - INVALID_WINNERS_STARS = "winners must be an integer between 1 and 5." - INVALID_WINNERS_PREMIUM = "winners must be an integer between 1 and 24 000." - INVALID_STARS_PER_WINNER = "amount must be an integer between 500 and 1 000 000 stars." + INVALID_MONTHS = "Invalid Premium duration: choose 3, 6, or 12 months." + INVALID_STARS_AMOUNT = "Invalid Stars amount: must be an integer between 50 and 1 000 000." + INVALID_TON_AMOUNT = "Invalid TON amount: must be an integer between 1 and 1 000 000 000." + INVALID_USERNAME = ( + "Invalid username '{username}'. " + "Must be 5–32 characters and contain only letters (A–Z, a–z), digits (0–9), or underscores (_)." + ) + INVALID_WINNERS_STARS = "Invalid winners count: must be an integer between 1 and 5." + INVALID_WINNERS_PREMIUM = "Invalid winners count: must be an integer between 1 and 24 000." + INVALID_STARS_PER_WINNER = "Invalid Stars per winner: must be an integer between 500 and 1 000 000." class CookieError(ClientError): """Raised when cookies are unreadable or missing required fields.""" - READ_FAILED = "Failed to parse cookies: {exc}" + READ_FAILED = "Failed to parse cookies — expected a JSON string or a dict, got: {exc}" MISSING_KEYS = ( - "Cookies are missing or have empty values for: {keys}. " "Open Fragment.com in your browser and copy fresh cookies." + "Fragment cookies are missing or empty for key(s): {keys}. " + "Open fragment.com in your browser, log in, and copy fresh cookies." ) @@ -37,16 +41,21 @@ class FragmentAPIError(FragmentError): """Raised for errors returned by Fragment's API responses.""" NO_REQUEST_ID = ( - "Fragment did not return a request ID for '{context}'. " "The session may have expired — refresh your cookies." + "Fragment did not return a request ID for '{context}'. " + "Your session may have expired — log in to fragment.com and refresh your cookies." ) class FragmentPageError(FragmentAPIError): """Raised when the Fragment page cannot be fetched or the API hash is not found.""" - BAD_STATUS = "Fragment returned HTTP {status} for {url}. " "Check that your cookies are valid and not expired." + BAD_STATUS = ( + "Fragment returned HTTP {status} when loading {url}. " + "Your cookies may be invalid or expired — log in to fragment.com and refresh them." + ) NOT_FOUND = ( - "Fragment hash not found in the page source of {url}. " "The page structure may have changed or you are not logged in." + "Could not extract the API hash from {url}. " + "The page structure may have changed, or you are not logged in — refresh your cookies." ) @@ -54,7 +63,7 @@ class UserNotFoundError(FragmentAPIError): """Raised when the target Telegram user is not found on Fragment.""" NOT_FOUND = ( - "Telegram user '{username}' was not found on Fragment. " "Make sure the username is correct and the account exists." + "Telegram user '{username}' was not found on Fragment. " "Double-check the username and make sure the account exists." ) @@ -62,21 +71,37 @@ class TransactionError(FragmentAPIError): """Raised when a TON transaction fails to build or broadcast.""" INVALID_PAYLOAD = ( - "Fragment returned an invalid transaction payload. " "The API response is missing expected 'transaction.messages' data." + "Fragment returned an invalid transaction payload — " "'transaction.messages' is missing or empty in the API response." ) BROADCAST_FAILED = "Transaction broadcast failed: {exc}" + BROADCAST_FAILED_SSL = ( + "Transaction broadcast failed due to an SSL certificate error: {exc}\n" + "This usually means your system's CA bundle is missing or outdated.\n" + "Fix: run `pip install --upgrade certifi` and retry. " + "On macOS you may also need to run the 'Install Certificates.command' " + "located in your Python installation folder." + ) + DUPLICATE_SEQNO = ( + "Transaction broadcast failed: the TON wallet rejected the message " + "because a previous transaction with the same sequence number (seqno) " + "is still pending confirmation on-chain.\n" + "Wait a few seconds for the previous transaction to confirm, then retry." + ) class ParseError(FragmentAPIError): """Raised when a Fragment API response or payload cannot be parsed.""" - UNPARSEABLE = "Fragment API returned an unparseable response for '{context}': {exc}" + UNPARSEABLE = "Failed to parse the Fragment API response for '{context}': {exc}" class VerificationError(FragmentAPIError): """Raised when Fragment requires KYC verification before proceeding.""" - KYC_REQUIRED = "Fragment requires identity (KYC) verification. " "Complete it at https://fragment.com/my/profile and retry." + KYC_REQUIRED = ( + "Fragment requires identity verification (KYC) before this action can be completed. " + "Complete verification at https://fragment.com/my/profile and retry." + ) class OperationError(FragmentError): @@ -86,16 +111,19 @@ class OperationError(FragmentError): class WalletError(OperationError): """Raised for TON wallet issues (connection, balance, account info).""" - LOW_BALANCE = "TON wallet balance is too low: {balance:.4f} TON available, {required:.4f} TON required." - BALANCE_CHECK_FAILED = "Wallet balance check failed: {exc}" - ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info: {exc}" - WALLET_INFO_FAILED = "Failed to retrieve wallet info: {exc}" + LOW_BALANCE = ( + "Insufficient TON balance: {balance:.4f} TON available, {required:.4f} TON required " + "(transaction amount + {gas:.3f} TON gas reserve)." + ) + BALANCE_CHECK_FAILED = "Failed to fetch wallet balance: {exc}" + ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info from TON network: {exc}" + WALLET_INFO_FAILED = "Failed to retrieve wallet info from TON network: {exc}" class UnexpectedError(OperationError): """Raised when an unexpected error occurs during an API call.""" - UNEXPECTED = "An unexpected error occurred: {exc}" + UNEXPECTED = "An unexpected error occurred during the operation: {exc}" __all__ = [ diff --git a/pyfragment/utils/wallet.py b/pyfragment/utils/wallet.py index 0a453ea..db4620c 100644 --- a/pyfragment/utils/wallet.py +++ b/pyfragment/utils/wallet.py @@ -1,5 +1,6 @@ import asyncio import base64 +import ssl from typing import TYPE_CHECKING, Any from tonutils.clients import TonapiClient @@ -38,9 +39,6 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict) message = transaction_data["transaction"]["messages"][0] amount_ton = int(message["amount"]) / 1_000_000_000 - # TODO: Investigate 406 'inbound external message rejected before smart-contract execution'. - # This happens when the previous transaction's seqno hasn't been confirmed on-chain yet, - # causing the wallet contract to reject the new message. 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) @@ -51,7 +49,7 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict) balance_ton = wallet.balance / 1_000_000_000 required = amount_ton + MIN_TON_BALANCE if balance_ton < required: - raise WalletError(WalletError.LOW_BALANCE.format(balance=balance_ton, required=required)) + raise WalletError(WalletError.LOW_BALANCE.format(balance=balance_ton, required=required, gas=MIN_TON_BALANCE)) except WalletError: raise except Exception as exc: @@ -60,7 +58,7 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict) try: payload = clean_decode(message["payload"]) - for attempt in range(2): + for attempt in range(3): try: result = await wallet.transfer( destination=message["address"], @@ -72,10 +70,21 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict) if exc.code == 429 and attempt == 0: await asyncio.sleep(1) continue + if exc.code == 406 and "seqno" in str(exc).lower(): + # Previous tx seqno not yet confirmed — wallet will re-fetch seqno on retry + if attempt < 2: + await asyncio.sleep(2) + continue + raise TransactionError(TransactionError.DUPLICATE_SEQNO) from exc raise except (WalletError, TransactionError): raise except Exception as exc: + cause: BaseException | None = exc + while cause is not None: + if isinstance(cause, ssl.SSLError): + raise TransactionError(TransactionError.BROADCAST_FAILED_SSL.format(exc=exc)) from exc + cause = cause.__cause__ or cause.__context__ raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result")) From ab8d463d54847cc74cf5257fa28d5cb5f879aea9 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sat, 21 Mar 2026 17:18:56 +0200 Subject: [PATCH 12/21] feat: add unit tests for balance checks, giveaway stars, and premium methods; validate float inputs for winners and amounts --- tests/004_test_balance.py | 37 +++++++++++++++++++++++++++++++++++++ tests/005_test_stars.py | 12 ++++++++++++ tests/006_test_premium.py | 6 ++++++ 3 files changed, 55 insertions(+) diff --git a/tests/004_test_balance.py b/tests/004_test_balance.py index 6d48f23..5201db6 100644 --- a/tests/004_test_balance.py +++ b/tests/004_test_balance.py @@ -4,11 +4,17 @@ from contextlib import contextmanager from unittest.mock import AsyncMock, MagicMock, patch import pytest +from tonutils.exceptions import ProviderResponseError from pyfragment.types import TransactionError, WalletError from pyfragment.utils.wallet import process_transaction from tests.shared import VALID_SEED + +def _provider_error(code: int, message: str = "error") -> ProviderResponseError: + return ProviderResponseError(code=code, message=message, endpoint="api.tonapi.io") + + TRANSACTION_DATA = { "transaction": { "messages": [ @@ -94,3 +100,34 @@ async def test_one_nanoton_below_minimum_raises() -> None: async def test_invalid_payload_raises() -> None: with pytest.raises(TransactionError): await process_transaction(_make_client(), {"transaction": {}}) + + +@pytest.mark.asyncio +async def test_balance_check_failed_raises_wallet_error() -> None: + wallet = _make_wallet(balance_nanotons=1_000_000_000) + wallet.refresh = AsyncMock(side_effect=RuntimeError("network timeout")) + with _patch_wallet(wallet): + with pytest.raises(WalletError, match="balance"): + await process_transaction(_make_client(), TRANSACTION_DATA) + wallet.transfer.assert_not_called() + + +@pytest.mark.asyncio +async def test_rate_limit_retries_and_succeeds() -> None: + wallet = _make_wallet(balance_nanotons=1_000_000_000) + wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")]) + with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value=""): + result = await process_transaction(_make_client(), TRANSACTION_DATA) + assert result == "abc123" + assert wallet.transfer.call_count == 2 + + +@pytest.mark.asyncio +async def test_duplicate_seqno_raises_after_retries() -> None: + wallet = _make_wallet(balance_nanotons=1_000_000_000) + err = _provider_error(406, "Duplicate msg_seqno") + wallet.transfer = AsyncMock(side_effect=[err, err, err]) + with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value=""): + with pytest.raises(TransactionError, match="seqno"): + await process_transaction(_make_client(), TRANSACTION_DATA) + assert wallet.transfer.call_count == 3 diff --git a/tests/005_test_stars.py b/tests/005_test_stars.py index 826dc04..c3a38b8 100644 --- a/tests/005_test_stars.py +++ b/tests/005_test_stars.py @@ -90,6 +90,18 @@ async def test_giveaway_stars_amount_too_high(client: FragmentClient) -> None: await client.giveaway_stars("@channel", winners=1, amount=1_000_001) +@pytest.mark.asyncio +async def test_giveaway_stars_float_winners(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.giveaway_stars("@channel", winners=1.5, amount=500) # type: ignore[arg-type] + + +@pytest.mark.asyncio +async def test_giveaway_stars_float_amount(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.giveaway_stars("@channel", winners=1, amount=500.5) # type: ignore[arg-type] + + # Stars giveaway mocked tests diff --git a/tests/006_test_premium.py b/tests/006_test_premium.py index 1495474..6e06a22 100644 --- a/tests/006_test_premium.py +++ b/tests/006_test_premium.py @@ -78,6 +78,12 @@ async def test_giveaway_premium_winners_too_high(client: FragmentClient) -> None await client.giveaway_premium("@channel", winners=24_001, months=3) +@pytest.mark.asyncio +async def test_giveaway_premium_float_winners(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.giveaway_premium("@channel", winners=2.5, months=3) # type: ignore[arg-type] + + @pytest.mark.asyncio async def test_giveaway_premium_invalid_months(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): From 6e4d4da740395b0eb835c88034fa18af2cf34ad8 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sat, 21 Mar 2026 17:43:58 +0200 Subject: [PATCH 13/21] feat: add anonymous number management methods; implement get_login_code, toggle_login_codes, and terminate_sessions with associated results and error handling --- CHANGELOG.md | 23 +++- examples/anonymous_number.py | 43 ++++++ pyfragment/__init__.py | 6 + pyfragment/client.py | 38 ++++++ pyfragment/methods/__init__.py | 12 +- pyfragment/methods/anonymous_number.py | 151 +++++++++++++++++++++ pyfragment/types/__init__.py | 6 + pyfragment/types/constants.py | 1 + pyfragment/types/exceptions.py | 8 ++ pyfragment/types/results.py | 35 ++++- pyfragment/utils/__init__.py | 2 + pyfragment/utils/html.py | 23 ++++ tests/010_test_number.py | 179 +++++++++++++++++++++++++ 13 files changed, 521 insertions(+), 6 deletions(-) create mode 100644 examples/anonymous_number.py create mode 100644 pyfragment/methods/anonymous_number.py create mode 100644 pyfragment/utils/html.py create mode 100644 tests/010_test_number.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 14b491b..7e0980c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,13 +10,28 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI ## [Unreleased] ### Added -- `giveaway_stars(channel, winners, amount)` — run a Telegram Stars giveaway for a channel (1–5 winners, 500–1 000 000 stars each) -- `giveaway_premium(channel, winners, months)` — run a Telegram Premium giveaway for a channel (1–24 000 winners, 3/6/12 months each) + +**Giveaways** +- `giveaway_stars(channel, winners, amount)` — run a Telegram Stars giveaway for a channel (1–5 winners, 500–1 000 000 stars each); raises `UserNotFoundError` if the channel is not found on Fragment, `ConfigurationError` if `winners` or `amount` are out of range +- `giveaway_premium(channel, winners, months)` — run a Telegram Premium giveaway for a channel (1–24 000 winners, 3/6/12 months each); raises `UserNotFoundError` if the channel is not found on Fragment, `ConfigurationError` if `winners` or `months` are invalid - `StarsGiveawayResult` and `PremiumGiveawayResult` result types -- `STARS_GIVEAWAY_PAGE` and `PREMIUM_GIVEAWAY_PAGE` URL constants + +**Raw API access** - `FragmentClient.call(method, data, *, page_url)` — send a raw request to any Fragment API method without waiting for a library update - `FRAGMENT_BASE_URL` constant — single source of truth for the Fragment base URL used across all page constants and headers -- `examples/call.py` — usage example for `client.call()` + +**Anonymous number management** +- `get_login_code(number)` — fetch the current pending login code for an anonymous number +- `toggle_login_codes(number, can_receive)` — enable or disable login code delivery for an anonymous number +- `terminate_sessions(number)` — terminate all active Telegram sessions for an anonymous number (two-step flow handled internally) +- `LoginCodeResult` and `TerminateSessionsResult` result types +- `AnonymousNumberError` exception — raised when a number is not owned or session termination fails + +**Examples** +- `examples/giveaway_stars.py` — Stars giveaway with `UserNotFoundError` / `ConfigurationError` handling +- `examples/giveaway_premium.py` — Premium giveaway with `UserNotFoundError` / `ConfigurationError` handling +- `examples/call.py` — raw API call via `client.call()` +- `examples/anonymous_number.py` — login code fetch and session termination with `AnonymousNumberError` handling ### Changed - All result types (`PremiumResult`, `StarsResult`, `StarsGiveawayResult`, `PremiumGiveawayResult`) now use a single unified `amount` field instead of `months`, `stars` — consistent API across every method diff --git a/examples/anonymous_number.py b/examples/anonymous_number.py new file mode 100644 index 0000000..3355b22 --- /dev/null +++ b/examples/anonymous_number.py @@ -0,0 +1,43 @@ +""" +Example: manage an anonymous Telegram number — read login code and terminate sessions. + +Use get_login_code() to fetch the current pending login code for your number. +Use toggle_login_codes() to enable or disable receiving codes. +Use terminate_sessions() to forcefully end all active Telegram sessions. +""" + +import asyncio + +from pyfragment import AnonymousNumberError, FragmentClient + +SEED = "word1 word2 ... word24" +API_KEY = "YOUR_TONAPI_KEY" +COOKIES = { + "stel_ssid": "YOUR_STEL_SSID", + "stel_dt": "YOUR_STEL_DT", + "stel_token": "YOUR_STEL_TOKEN", + "stel_ton_token": "YOUR_STEL_TON_TOKEN", +} + +NUMBER = "+88888888888" + + +async def main() -> None: + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + # Fetch the latest login code + result = await client.get_login_code(NUMBER) + if result.code: + print(f"Login code for {result.number}: {result.code} ({result.active_sessions} active session(s))") + else: + print(f"No pending login code for {result.number} ({result.active_sessions} active session(s))") + + # Terminate all active sessions + try: + terminated = await client.terminate_sessions(NUMBER) + print(f"Sessions terminated for {terminated.number}" + (f": {terminated.message}" if terminated.message else "")) + except AnonymousNumberError as e: + print(f"Could not terminate sessions: {e}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index a3d92e8..6fa5225 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -8,18 +8,21 @@ from importlib.metadata import version from pyfragment.client import FragmentClient from pyfragment.types import ( AdsTopupResult, + AnonymousNumberError, ClientError, ConfigurationError, CookieError, FragmentAPIError, FragmentError, FragmentPageError, + LoginCodeResult, OperationError, ParseError, PremiumGiveawayResult, PremiumResult, StarsGiveawayResult, StarsResult, + TerminateSessionsResult, TransactionError, UnexpectedError, UserNotFoundError, @@ -34,10 +37,12 @@ __all__ = [ "__version__", "FragmentClient", "AdsTopupResult", + "LoginCodeResult", "PremiumGiveawayResult", "PremiumResult", "StarsGiveawayResult", "StarsResult", + "TerminateSessionsResult", "WalletInfo", "ClientError", "ConfigurationError", @@ -45,6 +50,7 @@ __all__ = [ "FragmentAPIError", "FragmentError", "FragmentPageError", + "AnonymousNumberError", "OperationError", "ParseError", "TransactionError", diff --git a/pyfragment/client.py b/pyfragment/client.py index 4e544a1..d900568 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -3,6 +3,7 @@ from typing import Any, cast import httpx +from pyfragment.methods.anonymous_number import get_login_code, terminate_sessions, toggle_login_codes from pyfragment.methods.giveaway_premium import giveaway_premium from pyfragment.methods.giveaway_stars import giveaway_stars from pyfragment.methods.purchase_premium import purchase_premium @@ -12,8 +13,10 @@ from pyfragment.types import ( AdsTopupResult, ConfigurationError, CookieError, + LoginCodeResult, PremiumResult, StarsResult, + TerminateSessionsResult, WalletInfo, ) from pyfragment.types.constants import ( @@ -197,6 +200,41 @@ class FragmentClient: """ return await giveaway_premium(self, channel, winners, months) + async def get_login_code(self, number: str) -> LoginCodeResult: + """Fetch the current pending login code for an anonymous number. + + Args: + number: Phone number with or without leading ``+`` (e.g. ``"+1234567890"``). + + Returns: + :class:`LoginCodeResult` with ``number``, ``code`` (``None`` if none pending), + and ``active_sessions`` count. + """ + return await get_login_code(self, number) + + async def toggle_login_codes(self, number: str, can_receive: bool) -> None: + """Enable or disable login code delivery for an anonymous number. + + Args: + number: Phone number with or without leading ``+``. + can_receive: ``True`` to allow receiving codes, ``False`` to block them. + """ + return await toggle_login_codes(self, number, can_receive) + + async def terminate_sessions(self, number: str) -> TerminateSessionsResult: + """Terminate all active Telegram sessions for an anonymous number. + + Args: + number: Phone number with or without leading ``+``. + + Returns: + :class:`TerminateSessionsResult` with ``number`` and ``message``. + + Raises: + AnonymousNumberError: If the number is not owned by this account or has no active sessions. + """ + return await terminate_sessions(self, number) + async def call( self, method: str, data: dict[str, Any] | None = None, *, page_url: str = FRAGMENT_BASE_URL ) -> dict[str, Any]: diff --git a/pyfragment/methods/__init__.py b/pyfragment/methods/__init__.py index 21ddeee..71af4f5 100644 --- a/pyfragment/methods/__init__.py +++ b/pyfragment/methods/__init__.py @@ -1,7 +1,17 @@ +from pyfragment.methods.anonymous_number import get_login_code, terminate_sessions, toggle_login_codes from pyfragment.methods.giveaway_premium import giveaway_premium from pyfragment.methods.giveaway_stars import giveaway_stars from pyfragment.methods.purchase_premium import purchase_premium from pyfragment.methods.purchase_stars import purchase_stars from pyfragment.methods.topup_ton import topup_ton -__all__ = ["giveaway_premium", "giveaway_stars", "purchase_premium", "purchase_stars", "topup_ton"] +__all__ = [ + "get_login_code", + "giveaway_premium", + "giveaway_stars", + "purchase_premium", + "purchase_stars", + "terminate_sessions", + "toggle_login_codes", + "topup_ton", +] diff --git a/pyfragment/methods/anonymous_number.py b/pyfragment/methods/anonymous_number.py new file mode 100644 index 0000000..b8facfe --- /dev/null +++ b/pyfragment/methods/anonymous_number.py @@ -0,0 +1,151 @@ +import html +from typing import TYPE_CHECKING + +import httpx + +from pyfragment.types import AnonymousNumberError, FragmentAPIError, FragmentError, UnexpectedError +from pyfragment.types.constants import NUMBERS_PAGE +from pyfragment.types.results import LoginCodeResult, TerminateSessionsResult +from pyfragment.utils import fragment_request, get_fragment_hash, make_headers, parse_login_code + +if TYPE_CHECKING: + from pyfragment.client import FragmentClient + +HEADERS: dict[str, str] = make_headers(NUMBERS_PAGE) + + +def _strip_plus(number: str) -> str: + return number.lstrip("+") if isinstance(number, str) else number + + +async def get_login_code(client: "FragmentClient", number: str) -> LoginCodeResult: + """Fetch the current pending login code for an anonymous number. + + Args: + client: Authenticated :class:`FragmentClient` instance. + number: Phone number with or without leading ``+`` (e.g. ``"+1234567890"``). + + Returns: + :class:`LoginCodeResult` with ``number``, ``code`` (``None`` if no pending code), + and ``active_sessions`` count. + + Raises: + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ + try: + clean = _strip_plus(number) + async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, NUMBERS_PAGE, client.timeout) + result = await fragment_request( + session, + fragment_hash, + HEADERS, + {"number": clean, "lt": "0", "from_app": "1", "method": "updateLoginCodes"}, + ) + + if result.get("html"): + code, active_sessions = parse_login_code(result["html"]) + else: + code, active_sessions = None, 0 + + return LoginCodeResult(number=number, code=code, active_sessions=active_sessions) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc + + +async def toggle_login_codes(client: "FragmentClient", number: str, can_receive: bool) -> None: + """Enable or disable login code delivery for an anonymous number. + + Args: + client: Authenticated :class:`FragmentClient` instance. + number: Phone number with or without leading ``+``. + can_receive: ``True`` to allow receiving codes, ``False`` to block them. + + Raises: + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ + try: + clean = _strip_plus(number) + async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, NUMBERS_PAGE, client.timeout) + result = await fragment_request( + session, + fragment_hash, + HEADERS, + {"number": clean, "can_receive": 1 if can_receive else 0, "method": "toggleLoginCodes"}, + ) + + if result.get("error"): + raise FragmentAPIError(html.unescape(result["error"])) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc + + +async def terminate_sessions(client: "FragmentClient", number: str) -> TerminateSessionsResult: + """Terminate all active Telegram sessions for an anonymous number. + + This is a two-step operation: Fragment first returns a confirmation hash, + which is then submitted to confirm the termination. + + Args: + client: Authenticated :class:`FragmentClient` instance. + number: Phone number with or without leading ``+``. + + Returns: + :class:`TerminateSessionsResult` with ``number`` and ``message``. + + Raises: + AnonymousNumberError: If the number is not owned by this account or has no active sessions, + or if Fragment returns an error during termination. + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ + try: + clean = _strip_plus(number) + async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, NUMBERS_PAGE, client.timeout) + + # Step 1: initiate — Fragment returns a confirmation hash. + confirmation = await fragment_request( + session, + fragment_hash, + HEADERS, + {"number": clean, "method": "terminatePhoneSessions"}, + ) + + if confirmation.get("error"): + raise AnonymousNumberError( + AnonymousNumberError.TERMINATE_FAILED.format(number=number, error=html.unescape(confirmation["error"])) + ) + + terminate_hash = confirmation.get("terminate_hash") + if not terminate_hash: + raise AnonymousNumberError(AnonymousNumberError.NOT_OWNED.format(number=number)) + + # Step 2: confirm with the hash. + result = await fragment_request( + session, + fragment_hash, + HEADERS, + {"number": clean, "terminate_hash": terminate_hash, "method": "terminatePhoneSessions"}, + ) + + if result.get("error"): + raise AnonymousNumberError( + AnonymousNumberError.TERMINATE_FAILED.format(number=number, error=html.unescape(result["error"])) + ) + + return TerminateSessionsResult(number=number, message=result.get("msg")) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/types/__init__.py b/pyfragment/types/__init__.py index 5de01d6..395d68f 100644 --- a/pyfragment/types/__init__.py +++ b/pyfragment/types/__init__.py @@ -1,4 +1,5 @@ from pyfragment.types.exceptions import ( + AnonymousNumberError, ClientError, ConfigurationError, CookieError, @@ -15,10 +16,12 @@ from pyfragment.types.exceptions import ( ) from pyfragment.types.results import ( AdsTopupResult, + LoginCodeResult, PremiumGiveawayResult, PremiumResult, StarsGiveawayResult, StarsResult, + TerminateSessionsResult, WalletInfo, ) @@ -31,6 +34,7 @@ __all__ = [ "FragmentAPIError", "FragmentError", "FragmentPageError", + "AnonymousNumberError", "OperationError", "ParseError", "TransactionError", @@ -40,9 +44,11 @@ __all__ = [ "WalletError", # result types "AdsTopupResult", + "LoginCodeResult", "PremiumGiveawayResult", "PremiumResult", "StarsGiveawayResult", "StarsResult", + "TerminateSessionsResult", "WalletInfo", ] diff --git a/pyfragment/types/constants.py b/pyfragment/types/constants.py index b5f64fe..86c9f12 100644 --- a/pyfragment/types/constants.py +++ b/pyfragment/types/constants.py @@ -26,6 +26,7 @@ STARS_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/giveaway" PREMIUM_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/gift" PREMIUM_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/giveaway" TON_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup" +NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers" # Tonkeeper device fingerprint — serialized once, reused in every tx_data payload. DEVICE: str = json.dumps( diff --git a/pyfragment/types/exceptions.py b/pyfragment/types/exceptions.py index 3c5f390..5207a94 100644 --- a/pyfragment/types/exceptions.py +++ b/pyfragment/types/exceptions.py @@ -67,6 +67,13 @@ class UserNotFoundError(FragmentAPIError): ) +class AnonymousNumberError(FragmentAPIError): + """Raised for Fragment anonymous number API failures.""" + + NOT_OWNED = "Number '{number}' is not associated with your Fragment account or has no active sessions to terminate." + TERMINATE_FAILED = "Failed to terminate sessions for '{number}': {error}" + + class TransactionError(FragmentAPIError): """Raised when a TON transaction fails to build or broadcast.""" @@ -133,6 +140,7 @@ __all__ = [ "CookieError", "FragmentAPIError", "FragmentPageError", + "AnonymousNumberError", "UserNotFoundError", "TransactionError", "ParseError", diff --git a/pyfragment/types/results.py b/pyfragment/types/results.py index 1cf129f..a91d7a7 100644 --- a/pyfragment/types/results.py +++ b/pyfragment/types/results.py @@ -1,6 +1,15 @@ from dataclasses import dataclass -__all__ = ["AdsTopupResult", "PremiumGiveawayResult", "PremiumResult", "StarsGiveawayResult", "StarsResult", "WalletInfo"] +__all__ = [ + "AdsTopupResult", + "LoginCodeResult", + "PremiumGiveawayResult", + "PremiumResult", + "StarsGiveawayResult", + "StarsResult", + "TerminateSessionsResult", + "WalletInfo", +] @dataclass @@ -81,3 +90,27 @@ class PremiumGiveawayResult: f"PremiumGiveawayResult(channel='{self.channel}', winners={self.winners}, " f"amount={self.amount} months per winner, tx='{self.transaction_id}')" ) + + +@dataclass +class LoginCodeResult: + """Result of :meth:`FragmentClient.get_login_code`.""" + + number: str + code: str | None + active_sessions: int + + def __repr__(self) -> str: + code_str = f"'{self.code}'" if self.code else "None" + return f"LoginCodeResult(number='{self.number}', code={code_str}, active_sessions={self.active_sessions})" + + +@dataclass +class TerminateSessionsResult: + """Result of :meth:`FragmentClient.terminate_sessions`.""" + + number: str + message: str | None + + def __repr__(self) -> str: + return f"TerminateSessionsResult(number='{self.number}', message={self.message!r})" diff --git a/pyfragment/utils/__init__.py b/pyfragment/utils/__init__.py index 406d506..4c092bb 100644 --- a/pyfragment/utils/__init__.py +++ b/pyfragment/utils/__init__.py @@ -1,4 +1,5 @@ from pyfragment.utils.decoder import clean_decode +from pyfragment.utils.html import parse_login_code from pyfragment.utils.http import ( execute_transaction_request, fragment_request, @@ -10,6 +11,7 @@ from pyfragment.utils.wallet import get_account_info, process_transaction __all__ = [ "clean_decode", + "parse_login_code", "execute_transaction_request", "fragment_request", "get_account_info", diff --git a/pyfragment/utils/html.py b/pyfragment/utils/html.py new file mode 100644 index 0000000..30004f4 --- /dev/null +++ b/pyfragment/utils/html.py @@ -0,0 +1,23 @@ +import re + +# Matches the login code inside a table-cell-value element. +CODE_RE = re.compile(r'class="[^"]*table-cell-value[^"]*"[^>]*>([^<]+)<') +# Counts active session rows in the HTML table. +ROW_RE = re.compile(r"]") + + +def parse_login_code(html: str) -> tuple[str | None, int]: + """Extract the pending login code and active session count from a Fragment numbers page HTML snippet. + + Args: + html: Raw HTML string returned by the Fragment API. + + Returns: + A tuple of ``(code, active_sessions)`` where ``code`` is ``None`` if no + pending code is present, and ``active_sessions`` is the number of ```` + rows found (each row represents one active session). + """ + match = CODE_RE.search(html) + code = match.group(1).strip() if match else None + active_sessions = len(ROW_RE.findall(html)) + return code, active_sessions diff --git a/tests/010_test_number.py b/tests/010_test_number.py new file mode 100644 index 0000000..4138b4f --- /dev/null +++ b/tests/010_test_number.py @@ -0,0 +1,179 @@ +"""Unit tests for number methods — get_login_code, toggle_login_codes, terminate_sessions.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import AnonymousNumberError, FragmentClient, LoginCodeResult, TerminateSessionsResult +from tests.shared import FAKE_HASH + +FAKE_HTML_WITH_CODE = """ + + + + + + + +
12345
session data
+""" + +FAKE_HTML_NO_CODE = "
no code here
" + +FAKE_TERMINATE_HASH = "terminate_hash_abc123" + + +# get_login_code tests + + +@pytest.mark.asyncio +async def test_get_login_code_returns_code(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.anonymous_number.fragment_request", + AsyncMock(return_value={"html": FAKE_HTML_WITH_CODE}), + ), + ): + result = await client.get_login_code("+1234567890") + + assert isinstance(result, LoginCodeResult) + assert result.number == "+1234567890" + assert result.code == "12345" + assert result.active_sessions == 2 # 2 in the HTML + + +@pytest.mark.asyncio +async def test_get_login_code_no_pending_code(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.anonymous_number.fragment_request", + AsyncMock(return_value={"html": FAKE_HTML_NO_CODE}), + ), + ): + result = await client.get_login_code("1234567890") + + assert result.code is None + assert result.active_sessions == 1 + + +@pytest.mark.asyncio +async def test_get_login_code_no_html_returns_none(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.anonymous_number.fragment_request", AsyncMock(return_value={})), + ): + result = await client.get_login_code("+1234567890") + + assert result.code is None + assert result.active_sessions == 0 + + +# terminate_sessions tests + + +@pytest.mark.asyncio +async def test_terminate_sessions_success(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.anonymous_number.fragment_request", + AsyncMock( + side_effect=[ + {"terminate_hash": FAKE_TERMINATE_HASH}, # step 1: confirmation + {"msg": "All sessions terminated"}, # step 2: confirmed + ] + ), + ), + ): + result = await client.terminate_sessions("+1234567890") + + assert isinstance(result, TerminateSessionsResult) + assert result.number == "+1234567890" + assert result.message == "All sessions terminated" + + +@pytest.mark.asyncio +async def test_terminate_sessions_not_owned_raises(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.anonymous_number.fragment_request", AsyncMock(return_value={})), + ): + with pytest.raises(AnonymousNumberError, match="not associated"): + await client.terminate_sessions("+1234567890") + + +@pytest.mark.asyncio +async def test_terminate_sessions_api_error_raises(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.anonymous_number.fragment_request", + AsyncMock(return_value={"error": "SESSION_ALREADY_TERMINATED"}), + ), + ): + with pytest.raises(AnonymousNumberError, match="SESSION_ALREADY_TERMINATED"): + await client.terminate_sessions("+1234567890") + + +@pytest.mark.asyncio +async def test_terminate_sessions_confirm_error_raises(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.anonymous_number.fragment_request", + AsyncMock( + side_effect=[ + {"terminate_hash": FAKE_TERMINATE_HASH}, + {"error": "INTERNAL_ERROR"}, + ] + ), + ), + ): + with pytest.raises(AnonymousNumberError, match="INTERNAL_ERROR"): + await client.terminate_sessions("+1234567890") + + +# toggle_login_codes tests + + +@pytest.mark.asyncio +async def test_toggle_login_codes_enable(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.anonymous_number.fragment_request", AsyncMock(return_value={"ok": True})) as mock_req, + ): + await client.toggle_login_codes("+1234567890", can_receive=True) + + call_data = mock_req.call_args[0][3] + assert call_data["can_receive"] == 1 + assert call_data["method"] == "toggleLoginCodes" + + +@pytest.mark.asyncio +async def test_toggle_login_codes_disable(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.anonymous_number.fragment_request", AsyncMock(return_value={"ok": True})) as mock_req, + ): + await client.toggle_login_codes("+1234567890", can_receive=False) + + call_data = mock_req.call_args[0][3] + assert call_data["can_receive"] == 0 + + +# strip_plus tests + + +@pytest.mark.asyncio +async def test_get_login_code_strips_plus(client: FragmentClient) -> None: + """Number passed with '+' is stripped before the API call.""" + with ( + patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.anonymous_number.fragment_request", AsyncMock(return_value={})) as mock_req, + ): + await client.get_login_code("+1234567890") + + call_data = mock_req.call_args[0][3] + assert call_data["number"] == "1234567890" From 327ca351903121ee7906c51b55bda00d8fd4b649 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sat, 21 Mar 2026 17:51:19 +0200 Subject: [PATCH 14/21] feat: update README and pyproject description; enhance test docstrings for clarity and consistency --- README.md | 2 +- pyproject.toml | 2 +- tests/001_test_decode.py | 2 +- tests/002_test_client.py | 2 +- tests/003_test_hash.py | 3 +-- tests/004_test_balance.py | 2 +- tests/005_test_stars.py | 2 +- tests/006_test_premium.py | 2 +- tests/007_test_topup.py | 2 +- tests/008_test_wallet.py | 7 ++----- tests/009_test_call.py | 7 ++----- tests/010_test_number.py | 20 ++------------------ tests/shared.py | 34 +++++++++++++++++++++++++++++----- 13 files changed, 44 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 34ecbf6..490b3a8 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

Fragment API

- Python library for the Fragment.com API — purchase Telegram Stars, Premium, and top up TON Ads balance. + Async Python client for the Fragment.com API — buy Telegram Stars and Premium, run giveaways, top up TON Ads balance, and manage anonymous numbers.

[![PyPI version](https://img.shields.io/pypi/v/pyfragment?style=flat&color=blue)](https://pypi.org/project/pyfragment/) diff --git a/pyproject.toml b/pyproject.toml index c422aa6..47c9cbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "pyfragment" version = "2026.0.2" -description = "Python library for the Fragment.com API — purchase Telegram Stars, Premium, and top up TON Ads balance." +description = "Async Python client for the Fragment.com API — buy Telegram Stars and Premium, run giveaways, top up TON Ads balance, and manage anonymous numbers." readme = "README.md" license = { text = "MIT" } requires-python = ">=3.12" diff --git a/tests/001_test_decode.py b/tests/001_test_decode.py index 0b59b82..8a60ac3 100644 --- a/tests/001_test_decode.py +++ b/tests/001_test_decode.py @@ -1,4 +1,4 @@ -"""Tests for clean_decode() — BOC-encoded Fragment payloads decode to UTF-8.""" +"""Tests for clean_decode() — TON BOC payload decoding.""" import re diff --git a/tests/002_test_client.py b/tests/002_test_client.py index 8e5607e..16ced03 100644 --- a/tests/002_test_client.py +++ b/tests/002_test_client.py @@ -1,4 +1,4 @@ -"""Unit tests for FragmentClient — init validation and cookie parsing (no network calls).""" +"""Unit tests for FragmentClient — initialization, validation, and cookie parsing.""" import json diff --git a/tests/003_test_hash.py b/tests/003_test_hash.py index bb883ba..d11f05c 100644 --- a/tests/003_test_hash.py +++ b/tests/003_test_hash.py @@ -1,5 +1,4 @@ -"""Tests for get_fragment_hash() — fetches a valid lowercase hex hash -from the fragment.com/stars/buy page source.""" +"""Tests for get_fragment_hash() — API hash extraction from Fragment page source.""" import re diff --git a/tests/004_test_balance.py b/tests/004_test_balance.py index 5201db6..185ba30 100644 --- a/tests/004_test_balance.py +++ b/tests/004_test_balance.py @@ -1,4 +1,4 @@ -"""Unit tests for process_transaction() — balance checks before broadcast.""" +"""Unit tests for process_transaction() — balance validation and broadcast retry logic.""" from contextlib import contextmanager from unittest.mock import AsyncMock, MagicMock, patch diff --git a/tests/005_test_stars.py b/tests/005_test_stars.py index c3a38b8..0a87f94 100644 --- a/tests/005_test_stars.py +++ b/tests/005_test_stars.py @@ -1,4 +1,4 @@ -"""Unit tests for purchase_stars and giveaway_stars — validation and mocked network calls.""" +"""Unit tests for Stars methods — purchase_stars and giveaway_stars.""" from unittest.mock import AsyncMock, patch diff --git a/tests/006_test_premium.py b/tests/006_test_premium.py index 6e06a22..0a0b11e 100644 --- a/tests/006_test_premium.py +++ b/tests/006_test_premium.py @@ -1,4 +1,4 @@ -"""Unit tests for purchase_premium and giveaway_premium — validation and mocked network calls.""" +"""Unit tests for Premium methods — purchase_premium and giveaway_premium.""" from unittest.mock import AsyncMock, patch diff --git a/tests/007_test_topup.py b/tests/007_test_topup.py index 3cad838..2b491bc 100644 --- a/tests/007_test_topup.py +++ b/tests/007_test_topup.py @@ -1,4 +1,4 @@ -"""Unit tests for topup_ton — validation and mocked network calls.""" +"""Unit tests for topup_ton — TON Ads balance top-up.""" from unittest.mock import AsyncMock, patch diff --git a/tests/008_test_wallet.py b/tests/008_test_wallet.py index b4279dd..d13c081 100644 --- a/tests/008_test_wallet.py +++ b/tests/008_test_wallet.py @@ -1,14 +1,11 @@ -"""Unit tests for get_wallet — mocked network calls.""" +"""Unit tests for get_wallet() — wallet address and TON balance lookup.""" from unittest.mock import AsyncMock, MagicMock, patch import pytest from pyfragment import FragmentClient, WalletInfo - -FAKE_ADDRESS = "UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5" -FAKE_BALANCE_NANOTON = 1_500_000_000 # 1.5 TON - +from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOTON # Wallet mocked tests diff --git a/tests/009_test_call.py b/tests/009_test_call.py index 42be933..14b6b59 100644 --- a/tests/009_test_call.py +++ b/tests/009_test_call.py @@ -1,14 +1,11 @@ -"""Unit tests for client.call() — raw Fragment API request.""" +"""Unit tests for FragmentClient.call() — raw Fragment API access.""" from unittest.mock import AsyncMock, patch import pytest from pyfragment import FragmentClient -from tests.shared import FAKE_HASH - -FAKE_RESPONSE = {"status": "ok", "data": {"value": 42}} - +from tests.shared import FAKE_HASH, FAKE_RESPONSE # client.call() mocked tests diff --git a/tests/010_test_number.py b/tests/010_test_number.py index 4138b4f..bdcdb02 100644 --- a/tests/010_test_number.py +++ b/tests/010_test_number.py @@ -1,27 +1,11 @@ -"""Unit tests for number methods — get_login_code, toggle_login_codes, terminate_sessions.""" +"""Unit tests for anonymous number methods — login codes and session management.""" from unittest.mock import AsyncMock, patch import pytest from pyfragment import AnonymousNumberError, FragmentClient, LoginCodeResult, TerminateSessionsResult -from tests.shared import FAKE_HASH - -FAKE_HTML_WITH_CODE = """ - - - - - - - -
12345
session data
-""" - -FAKE_HTML_NO_CODE = "
no code here
" - -FAKE_TERMINATE_HASH = "terminate_hash_abc123" - +from tests.shared import FAKE_HASH, FAKE_HTML_NO_CODE, FAKE_HTML_WITH_CODE, FAKE_TERMINATE_HASH # get_login_code tests diff --git a/tests/shared.py b/tests/shared.py index 042095b..183ca6d 100644 --- a/tests/shared.py +++ b/tests/shared.py @@ -1,9 +1,13 @@ -"""Shared test constants — imported by all test modules that need them.""" +"""Shared test constants for the pyfragment test suite. + +pyfragment is an async Python client for Fragment.com — purchase Telegram Premium +and Stars, run Stars and Premium giveaways for channels, top up TON Ads balance, +and manage anonymous numbers, all through a clean typed API. +""" from typing import Any -# Shared test credentials - +# Credentials and config VALID_SEED: str = "abandon " * 23 + "about" VALID_API_KEY: str = "A" * 68 VALID_COOKIES: dict[str, str] = { @@ -13,11 +17,31 @@ VALID_COOKIES: dict[str, str] = { "stel_ton_token": "x", } -# Fake values for mocked tests - +# Generic test data FAKE_HASH: str = "abc123" FAKE_RECIPIENT: str = "recipient_token" FAKE_REQ_ID: str = "req_42" FAKE_TX_HASH: str = "deadbeef" * 8 FAKE_ACCOUNT: dict[str, Any] = {"address": "0:abc", "publicKey": "pub", "chain": "-239", "walletStateInit": "base64=="} FAKE_TRANSACTION: dict[str, Any] = {"transaction": {"messages": [{"address": "0:abc", "amount": "100000000", "payload": ""}]}} + +# client.call() +FAKE_RESPONSE: dict[str, Any] = {"status": "ok", "data": {"value": 42}} + +# get_wallet() +FAKE_ADDRESS: str = "UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5" +FAKE_BALANCE_NANOTON: int = 1_500_000_000 # 1.5 TON + +# Anonymous number +FAKE_HTML_WITH_CODE: str = """ + + + + + + + +
12345
session data
+""" +FAKE_HTML_NO_CODE: str = "
no code here
" +FAKE_TERMINATE_HASH: str = "terminate_hash_abc123" From dfe7bb4140a120835aea821f58ce75914ed47fdc Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Wed, 25 Mar 2026 00:57:31 +0200 Subject: [PATCH 15/21] feat: implement recharge_ads method for Telegram Ads account funding; add associated result type and example; enhance tests for validation and success cases --- CHANGELOG.md | 6 ++ examples/call.py | 7 ++- examples/recharge_ads.py | 40 +++++++++++++ pyfragment/__init__.py | 2 + pyfragment/client.py | 15 +++++ pyfragment/methods/__init__.py | 2 + pyfragment/methods/recharge_ads.py | 94 ++++++++++++++++++++++++++++++ pyfragment/methods/topup_ton.py | 6 +- pyfragment/types/__init__.py | 2 + pyfragment/types/constants.py | 2 +- pyfragment/types/results.py | 35 +++++++---- tests/011_test_recharge_ads.py | 56 ++++++++++++++++++ tests/shared.py | 7 +++ 13 files changed, 256 insertions(+), 18 deletions(-) create mode 100644 examples/recharge_ads.py create mode 100644 pyfragment/methods/recharge_ads.py create mode 100644 tests/011_test_recharge_ads.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e0980c..80a2dd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI - `giveaway_premium(channel, winners, months)` — run a Telegram Premium giveaway for a channel (1–24 000 winners, 3/6/12 months each); raises `UserNotFoundError` if the channel is not found on Fragment, `ConfigurationError` if `winners` or `months` are invalid - `StarsGiveawayResult` and `PremiumGiveawayResult` result types +**Telegram Ads** +- `recharge_ads(account, amount)` — add funds to your own Telegram Ads account; `account` is the channel or bot username linked to your Ads account; raises `ConfigurationError` if `amount` is out of range (1–1 000 000 000 TON) +- `AdsRechargeResult` result type + **Raw API access** - `FragmentClient.call(method, data, *, page_url)` — send a raw request to any Fragment API method without waiting for a library update - `FRAGMENT_BASE_URL` constant — single source of truth for the Fragment base URL used across all page constants and headers @@ -32,6 +36,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI - `examples/giveaway_premium.py` — Premium giveaway with `UserNotFoundError` / `ConfigurationError` handling - `examples/call.py` — raw API call via `client.call()` - `examples/anonymous_number.py` — login code fetch and session termination with `AnonymousNumberError` handling +- `examples/recharge_ads.py` — self-service Ads recharge with `ConfigurationError` / `WalletError` handling ### Changed - All result types (`PremiumResult`, `StarsResult`, `StarsGiveawayResult`, `PremiumGiveawayResult`) now use a single unified `amount` field instead of `months`, `stars` — consistent API across every method @@ -39,6 +44,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI - Method module files renamed to match their function: `premium.py` → `purchase_premium.py`, `stars.py` → `purchase_stars.py`, `ton.py` → `topup_ton.py` - `timestamp` field removed from all result dataclasses - All page URL constants (`STARS_PAGE`, `PREMIUM_PAGE`, etc.) now built from `FRAGMENT_BASE_URL` instead of hardcoded strings +- `STARS_REVENUE_PAGE` and `ADS_PAGE` constants replaced by a single `ADS_TOPUP_PAGE` shared by both `topup_ton` and `recharge_ads` - `TransactionError` now includes an SSL hint when a broadcast fails due to certificate verification errors - `TransactionError.DUPLICATE_SEQNO` — dedicated error raised after 3 failed attempts due to a `406 Duplicate msg_seqno` response from the TON network; broadcast is automatically retried (up to 2 retries, 2 s apart) before giving up - All error message templates rewritten to follow a "what happened → why → what to do" pattern — every template is now actionable and includes a fix hint where applicable diff --git a/examples/call.py b/examples/call.py index e0a6a22..6bd9594 100644 --- a/examples/call.py +++ b/examples/call.py @@ -4,8 +4,9 @@ Example: send a raw request to any Fragment API method. Use client.call() when you need to access a method that is not yet wrapped by the library, or to inspect raw API responses directly. -page_url must match the Fragment page the method belongs to — Fragment -requires a hash derived from each specific page. +page_url is optional — only set it when the target method belongs to a +specific Fragment page (Fragment derives the API hash per page). +Defaults to the Fragment base URL. """ import asyncio @@ -23,7 +24,7 @@ COOKIES = { METHOD = "anyFragmentMethod" # replace with the actual method name DATA = {"key": "value"} # replace with the actual request payload -PAGE_URL = "https://fragment.com/stars/buy" # replace with the matching Fragment page +PAGE_URL = "https://fragment.com/stars/buy" # replace with the matching Fragment page (optional) async def main() -> None: diff --git a/examples/recharge_ads.py b/examples/recharge_ads.py new file mode 100644 index 0000000..50d0a44 --- /dev/null +++ b/examples/recharge_ads.py @@ -0,0 +1,40 @@ +""" +Example: recharge your own Telegram Ads account with TON. + +Amount must be an integer between 1 and 1 000 000 000 TON. +Your wallet must hold at least the recharge amount + ~0.056 TON for gas. +""" + +import asyncio + +from pyfragment import AdsRechargeResult, ConfigurationError, FragmentClient, WalletError + +SEED = "word1 word2 ... word24" +API_KEY = "YOUR_TONAPI_KEY" +COOKIES = { + "stel_ssid": "YOUR_STEL_SSID", + "stel_dt": "YOUR_STEL_DT", + "stel_token": "YOUR_STEL_TOKEN", + "stel_ton_token": "YOUR_STEL_TON_TOKEN", +} + +ACCOUNT = "@mychannel" # channel or bot username linked to your Telegram Ads account +AMOUNT = 10 # 1–1 000 000 000 TON + + +async def main() -> None: + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + try: + result: AdsRechargeResult = await client.recharge_ads(ACCOUNT, amount=AMOUNT) + except WalletError as e: + print(f"Wallet error — insufficient balance or misconfiguration: {e}") + return + except ConfigurationError as e: + print(f"Invalid argument: {e}") + return + + print(f"{result.amount} TON recharged to Ads account {ACCOUNT} | tx: {result.transaction_id}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index 6fa5225..ac2af4f 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -7,6 +7,7 @@ from importlib.metadata import version from pyfragment.client import FragmentClient from pyfragment.types import ( + AdsRechargeResult, AdsTopupResult, AnonymousNumberError, ClientError, @@ -36,6 +37,7 @@ __version__: str = version("pyfragment") __all__ = [ "__version__", "FragmentClient", + "AdsRechargeResult", "AdsTopupResult", "LoginCodeResult", "PremiumGiveawayResult", diff --git a/pyfragment/client.py b/pyfragment/client.py index d900568..66a1fe3 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -8,8 +8,10 @@ from pyfragment.methods.giveaway_premium import giveaway_premium from pyfragment.methods.giveaway_stars import giveaway_stars from pyfragment.methods.purchase_premium import purchase_premium from pyfragment.methods.purchase_stars import purchase_stars +from pyfragment.methods.recharge_ads import recharge_ads from pyfragment.methods.topup_ton import topup_ton from pyfragment.types import ( + AdsRechargeResult, AdsTopupResult, ConfigurationError, CookieError, @@ -153,6 +155,19 @@ class FragmentClient: """ return await topup_ton(self, username, amount, show_sender) + async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult: + """Add funds to your own Telegram Ads account. + + Args: + account: Your Fragment Ads account identifier — the channel or bot username + the Ads account is linked to (e.g. ``"@mychannel"``). + amount: Amount in TON — integer from ``1`` to ``1 000 000 000``. + + Returns: + :class:`AdsRechargeResult` with ``transaction_id`` and ``amount``. + """ + return await recharge_ads(self, account, amount) + async def get_wallet(self) -> WalletInfo: """Return the address, state and balance of the TON wallet. diff --git a/pyfragment/methods/__init__.py b/pyfragment/methods/__init__.py index 71af4f5..719e8d5 100644 --- a/pyfragment/methods/__init__.py +++ b/pyfragment/methods/__init__.py @@ -3,6 +3,7 @@ from pyfragment.methods.giveaway_premium import giveaway_premium from pyfragment.methods.giveaway_stars import giveaway_stars from pyfragment.methods.purchase_premium import purchase_premium from pyfragment.methods.purchase_stars import purchase_stars +from pyfragment.methods.recharge_ads import recharge_ads from pyfragment.methods.topup_ton import topup_ton __all__ = [ @@ -11,6 +12,7 @@ __all__ = [ "giveaway_stars", "purchase_premium", "purchase_stars", + "recharge_ads", "terminate_sessions", "toggle_login_codes", "topup_ton", diff --git a/pyfragment/methods/recharge_ads.py b/pyfragment/methods/recharge_ads.py new file mode 100644 index 0000000..7d27669 --- /dev/null +++ b/pyfragment/methods/recharge_ads.py @@ -0,0 +1,94 @@ +import json +from typing import TYPE_CHECKING + +import httpx + +from pyfragment.types import ( + ConfigurationError, + FragmentAPIError, + FragmentError, + UnexpectedError, +) +from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE +from pyfragment.types.results import AdsRechargeResult +from pyfragment.utils import ( + execute_transaction_request, + fragment_request, + get_account_info, + get_fragment_hash, + make_headers, + process_transaction, +) + +if TYPE_CHECKING: + from pyfragment.client import FragmentClient + +HEADERS: dict[str, str] = make_headers(ADS_TOPUP_PAGE) + + +async def _init_request( + session: httpx.AsyncClient, + fragment_hash: str, + account: str, + amount: int, +) -> str: + result = await fragment_request( + session, + fragment_hash, + HEADERS, + { + "account": account, + "amount": amount, + "method": "initAdsRechargeRequest", + }, + ) + req_id = result.get("req_id") + if not req_id: + raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Ads recharge")) + return req_id + + +async def recharge_ads(client: "FragmentClient", account: str, amount: int) -> AdsRechargeResult: + """Add funds to your own Telegram Ads account. + + Args: + client: Authenticated :class:`FragmentClient` instance. + account: Your Fragment Ads account identifier — the channel or bot username + the Ads account is linked to (e.g. ``"@mychannel"``). + amount: Amount in TON — integer from ``1`` to ``1 000 000 000``. + + Returns: + :class:`AdsRechargeResult` with ``transaction_id`` and ``amount``. + + Raises: + ConfigurationError: If ``amount`` is not a valid integer in the allowed range. + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ + if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000): + raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) + + try: + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, ADS_TOPUP_PAGE, client.timeout) + account_info = await get_account_info(client) + + async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: + await fragment_request(session, fragment_hash, HEADERS, {"method": "updateAdsState", "mode": "new"}) + req_id = await _init_request(session, fragment_hash, account, amount) + + tx_data = { + "account": json.dumps(account_info), + "device": DEVICE, + "transaction": 1, + "id": req_id, + "method": "getAdsRechargeLink", + } + transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash) + + tx_hash = await process_transaction(client, transaction) + return AdsRechargeResult(transaction_id=tx_hash, amount=amount) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/methods/topup_ton.py b/pyfragment/methods/topup_ton.py index 95528f5..cebe7c3 100644 --- a/pyfragment/methods/topup_ton.py +++ b/pyfragment/methods/topup_ton.py @@ -11,7 +11,7 @@ from pyfragment.types import ( UnexpectedError, UserNotFoundError, ) -from pyfragment.types.constants import DEVICE, TON_PAGE +from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE from pyfragment.utils import ( execute_transaction_request, fragment_request, @@ -24,7 +24,7 @@ from pyfragment.utils import ( if TYPE_CHECKING: from pyfragment.client import FragmentClient -HEADERS: dict[str, str] = make_headers(TON_PAGE) +HEADERS: dict[str, str] = make_headers(ADS_TOPUP_PAGE) async def _search_recipient( @@ -92,7 +92,7 @@ async def topup_ton(client: "FragmentClient", username: str, amount: int, show_s raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) try: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, TON_PAGE, client.timeout) + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, ADS_TOPUP_PAGE, client.timeout) account = await get_account_info(client) async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: diff --git a/pyfragment/types/__init__.py b/pyfragment/types/__init__.py index 395d68f..6d77339 100644 --- a/pyfragment/types/__init__.py +++ b/pyfragment/types/__init__.py @@ -15,6 +15,7 @@ from pyfragment.types.exceptions import ( WalletError, ) from pyfragment.types.results import ( + AdsRechargeResult, AdsTopupResult, LoginCodeResult, PremiumGiveawayResult, @@ -43,6 +44,7 @@ __all__ = [ "VerificationError", "WalletError", # result types + "AdsRechargeResult", "AdsTopupResult", "LoginCodeResult", "PremiumGiveawayResult", diff --git a/pyfragment/types/constants.py b/pyfragment/types/constants.py index 86c9f12..29896d3 100644 --- a/pyfragment/types/constants.py +++ b/pyfragment/types/constants.py @@ -25,7 +25,7 @@ STARS_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/buy" STARS_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/giveaway" PREMIUM_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/gift" PREMIUM_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/giveaway" -TON_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup" +ADS_TOPUP_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup" NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers" # Tonkeeper device fingerprint — serialized once, reused in every tx_data payload. diff --git a/pyfragment/types/results.py b/pyfragment/types/results.py index a91d7a7..f73d5a7 100644 --- a/pyfragment/types/results.py +++ b/pyfragment/types/results.py @@ -1,16 +1,5 @@ from dataclasses import dataclass -__all__ = [ - "AdsTopupResult", - "LoginCodeResult", - "PremiumGiveawayResult", - "PremiumResult", - "StarsGiveawayResult", - "StarsResult", - "TerminateSessionsResult", - "WalletInfo", -] - @dataclass class WalletInfo: @@ -105,6 +94,17 @@ class LoginCodeResult: return f"LoginCodeResult(number='{self.number}', code={code_str}, active_sessions={self.active_sessions})" +@dataclass +class AdsRechargeResult: + """Result of a successful self-recharge of Telegram Ads balance.""" + + transaction_id: str + amount: int + + def __repr__(self) -> str: + return f"AdsRechargeResult(amount={self.amount} TON, tx='{self.transaction_id}')" + + @dataclass class TerminateSessionsResult: """Result of :meth:`FragmentClient.terminate_sessions`.""" @@ -114,3 +114,16 @@ class TerminateSessionsResult: def __repr__(self) -> str: return f"TerminateSessionsResult(number='{self.number}', message={self.message!r})" + + +__all__ = [ + "AdsRechargeResult", + "AdsTopupResult", + "LoginCodeResult", + "PremiumGiveawayResult", + "PremiumResult", + "StarsGiveawayResult", + "StarsResult", + "TerminateSessionsResult", + "WalletInfo", +] diff --git a/tests/011_test_recharge_ads.py b/tests/011_test_recharge_ads.py new file mode 100644 index 0000000..e1bf593 --- /dev/null +++ b/tests/011_test_recharge_ads.py @@ -0,0 +1,56 @@ +"""Unit tests for recharge_ads — self-service Telegram Ads recharge.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import FragmentClient +from pyfragment.types import AdsRechargeResult, ConfigurationError +from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_HASH, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH + +# recharge_ads validation tests + + +@pytest.mark.asyncio +async def test_recharge_ads_amount_zero(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=0) + + +@pytest.mark.asyncio +async def test_recharge_ads_amount_too_high(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=1_000_000_001) + + +@pytest.mark.asyncio +async def test_recharge_ads_float_amount(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=5.5) # type: ignore[arg-type] + + +# recharge_ads mocked tests + + +@pytest.mark.asyncio +async def test_recharge_ads_success(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.recharge_ads.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.recharge_ads.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch( + "pyfragment.methods.recharge_ads.fragment_request", + AsyncMock( + side_effect=[ + {}, # updateAdsState + {"req_id": FAKE_REQ_ID}, # initAdsRechargeRequest + ] + ), + ), + patch("pyfragment.methods.recharge_ads.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.recharge_ads.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + ): + result = await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=10) + + assert isinstance(result, AdsRechargeResult) + assert result.transaction_id == FAKE_TX_HASH + assert result.amount == 10 diff --git a/tests/shared.py b/tests/shared.py index 183ca6d..ee20584 100644 --- a/tests/shared.py +++ b/tests/shared.py @@ -32,6 +32,13 @@ FAKE_RESPONSE: dict[str, Any] = {"status": "ok", "data": {"value": 42}} FAKE_ADDRESS: str = "UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5" FAKE_BALANCE_NANOTON: int = 1_500_000_000 # 1.5 TON +# recharge_ads +FAKE_ADS_ACCOUNT: str = "@mychannel" + +# Revenue withdrawals +FAKE_WITHDRAWAL_WALLET: str = "EQDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +FAKE_REVENUE_TX: str = "revenue_tx_abc123" + # Anonymous number FAKE_HTML_WITH_CODE: str = """ From f37a4ab98534888c58e9d7339e28ce64bbe059ee Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Wed, 25 Mar 2026 17:40:02 +0200 Subject: [PATCH 16/21] feat: implement search_auctions method for Fragment marketplace; add AuctionsResult type; enhance topup_ton example and tests --- CHANGELOG.md | 4 + examples/search_auctions.py | 43 +++++++++ examples/topup_ton.py | 4 +- pyfragment/__init__.py | 2 + pyfragment/client.py | 33 ++++++- pyfragment/methods/__init__.py | 2 + pyfragment/methods/search_auctions.py | 72 +++++++++++++++ pyfragment/methods/topup_ton.py | 6 +- pyfragment/types/__init__.py | 2 + pyfragment/types/results.py | 24 +++++ pyfragment/utils/__init__.py | 3 +- pyfragment/utils/html.py | 74 +++++++++++++++ tests/014_test_search.py | 124 ++++++++++++++++++++++++++ 13 files changed, 386 insertions(+), 7 deletions(-) create mode 100644 examples/search_auctions.py create mode 100644 pyfragment/methods/search_auctions.py create mode 100644 tests/014_test_search.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 80a2dd0..527c182 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI - `recharge_ads(account, amount)` — add funds to your own Telegram Ads account; `account` is the channel or bot username linked to your Ads account; raises `ConfigurationError` if `amount` is out of range (1–1 000 000 000 TON) - `AdsRechargeResult` result type +**Marketplace** +- `search_auctions(query, type?, sort?, filter?, offset_id?)` — search the Fragment marketplace for usernames, numbers, or collectibles; `type` is one of `"usernames"`, `"numbers"`, `"collectibles"` (optional); `sort` is one of `"price_desc"`, `"price_asc"`, `"listed"`, `"ending"`; `filter` is one of `""`, `"auction"`, `"sale"`, `"sold"`; supports pagination via `offset_id`; returns parsed item dicts with `slug`, `name`, `status`, `price`, `ends_at` +- `AuctionsResult` result type + **Raw API access** - `FragmentClient.call(method, data, *, page_url)` — send a raw request to any Fragment API method without waiting for a library update - `FRAGMENT_BASE_URL` constant — single source of truth for the Fragment base URL used across all page constants and headers diff --git a/examples/search_auctions.py b/examples/search_auctions.py new file mode 100644 index 0000000..fa026f2 --- /dev/null +++ b/examples/search_auctions.py @@ -0,0 +1,43 @@ +""" +Example: search the Fragment marketplace for auction listings. + +type can be "usernames", "numbers", or "collectibles". +sort can be "price_desc", "price_asc", "listed", or "ending". +filter can be "", "auction", "sale", or "sold". +Use next_offset_id for pagination. +""" + +import asyncio + +from pyfragment import AuctionsResult, FragmentClient + +SEED = "word1 word2 ... word24" +API_KEY = "YOUR_TONAPI_KEY" +COOKIES = { + "stel_ssid": "YOUR_STEL_SSID", + "stel_dt": "YOUR_STEL_DT", + "stel_token": "YOUR_STEL_TOKEN", + "stel_ton_token": "YOUR_STEL_TON_TOKEN", +} + +QUERY = "durov" # search term +TYPE = "usernames" # "usernames", "numbers", or "collectibles" — or omit +SORT = "price_desc" # "price_desc", "price_asc", "listed", "ending" — or omit +FILTER = "auction" # "", "auction", "sale", "sold" — or omit + + +async def main() -> None: + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + result: AuctionsResult = await client.search_auctions(QUERY, type=TYPE, sort=SORT, filter=FILTER) + + print(f"Found {len(result.items)} result(s):") + for item in result.items: + price = f"{item['price']} TON" if item["price"] else "n/a" + print(f" {item['name']:20s} {item['status'] or '':15s} {price:10s} {item['ends_at'] or '—'}") + + if result.next_offset_id: + print(f"\nMore results available — next page offset: {result.next_offset_id}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/topup_ton.py b/examples/topup_ton.py index f7730c8..e614d58 100644 --- a/examples/topup_ton.py +++ b/examples/topup_ton.py @@ -1,5 +1,7 @@ """ -Example: top up a Telegram Ads account with TON. +Example: Topup ton to recipient's Telegram balance. + +For adding TON to Telegram Ads account, use recharge_ads() instead. Amount must be an integer between 1 and 1 000 000 000 TON. Your wallet must hold at least the topup amount + ~0.056 TON for gas. diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index ac2af4f..e3eec8c 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -10,6 +10,7 @@ from pyfragment.types import ( AdsRechargeResult, AdsTopupResult, AnonymousNumberError, + AuctionsResult, ClientError, ConfigurationError, CookieError, @@ -39,6 +40,7 @@ __all__ = [ "FragmentClient", "AdsRechargeResult", "AdsTopupResult", + "AuctionsResult", "LoginCodeResult", "PremiumGiveawayResult", "PremiumResult", diff --git a/pyfragment/client.py b/pyfragment/client.py index 66a1fe3..3d0d4db 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -9,10 +9,12 @@ from pyfragment.methods.giveaway_stars import giveaway_stars from pyfragment.methods.purchase_premium import purchase_premium from pyfragment.methods.purchase_stars import purchase_stars from pyfragment.methods.recharge_ads import recharge_ads +from pyfragment.methods.search_auctions import search_auctions from pyfragment.methods.topup_ton import topup_ton from pyfragment.types import ( AdsRechargeResult, AdsTopupResult, + AuctionsResult, ConfigurationError, CookieError, LoginCodeResult, @@ -143,10 +145,10 @@ class FragmentClient: return await purchase_stars(self, username, amount, show_sender) async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: - """Top up Telegram Ads balance with TON. + """Topup ton to recipient's Telegram balance. Args: - username: Ads account username (with or without ``@``). + username: Recipient's Telegram username (with or without ``@``). amount: Amount in TON — integer from ``1`` to ``1 000 000 000``. show_sender: Show your name as the sender. Defaults to ``True``. @@ -250,6 +252,33 @@ class FragmentClient: """ return await terminate_sessions(self, number) + async def search_auctions( + self, + query: str, + type: str | None = None, + sort: str | None = None, + filter: str | None = None, + offset_id: str | None = None, + ) -> AuctionsResult: + """Search the Fragment marketplace for auction listings. + + Args: + query: Search text (e.g. ``"durov"`` or ``"888"``). + type: Narrow results by item type — ``"usernames"``, ``"numbers"``, or + ``"collectibles"``. Omit to search across all types. + sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or + ``"ending"``. Omit to use Fragment's default ordering. + filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or + ``""`` (available items). Omit to return all. + offset_id: Pagination cursor — pass :attr:`AuctionsResult.next_offset_id` + from a previous result to fetch the next page. + + Returns: + :class:`AuctionsResult` with ``items`` (parsed list of item dicts) + and ``next_offset_id`` (``None`` on the last page). + """ + return await search_auctions(self, query, type=type, sort=sort, filter=filter, offset_id=offset_id) + async def call( self, method: str, data: dict[str, Any] | None = None, *, page_url: str = FRAGMENT_BASE_URL ) -> dict[str, Any]: diff --git a/pyfragment/methods/__init__.py b/pyfragment/methods/__init__.py index 719e8d5..98b3326 100644 --- a/pyfragment/methods/__init__.py +++ b/pyfragment/methods/__init__.py @@ -4,6 +4,7 @@ from pyfragment.methods.giveaway_stars import giveaway_stars from pyfragment.methods.purchase_premium import purchase_premium from pyfragment.methods.purchase_stars import purchase_stars from pyfragment.methods.recharge_ads import recharge_ads +from pyfragment.methods.search_auctions import search_auctions from pyfragment.methods.topup_ton import topup_ton __all__ = [ @@ -13,6 +14,7 @@ __all__ = [ "purchase_premium", "purchase_stars", "recharge_ads", + "search_auctions", "terminate_sessions", "toggle_login_codes", "topup_ton", diff --git a/pyfragment/methods/search_auctions.py b/pyfragment/methods/search_auctions.py new file mode 100644 index 0000000..fdcd2cd --- /dev/null +++ b/pyfragment/methods/search_auctions.py @@ -0,0 +1,72 @@ +from typing import TYPE_CHECKING, Any + +import httpx + +from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError +from pyfragment.types.constants import FRAGMENT_BASE_URL +from pyfragment.types.results import AuctionsResult +from pyfragment.utils import fragment_request, get_fragment_hash, make_headers, parse_auction_rows + +if TYPE_CHECKING: + from pyfragment.client import FragmentClient + +HEADERS: dict[str, str] = make_headers(FRAGMENT_BASE_URL) + + +async def search_auctions( + client: "FragmentClient", + query: str, + type: str | None = None, + sort: str | None = None, + filter: str | None = None, + offset_id: str | None = None, +) -> AuctionsResult: + """Search the Fragment marketplace for usernames, numbers, or collectibles. + + Args: + client: Authenticated :class:`FragmentClient` instance. + query: Search text (e.g. ``"durov"`` or ``"888"``). + type: Narrow results by item type — ``"usernames"``, ``"numbers"``, or + ``"collectibles"``. Omit to search across all types. + sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or + ``"ending"``. Omit to use Fragment's default ordering. + filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` + (available items). Omit to return all. + offset_id: Pagination cursor from a previous :class:`AuctionsResult`. + Pass ``next_offset_id`` to fetch the next page. + + Returns: + :class:`AuctionsResult` with ``items`` (parsed list of item dicts) and + ``next_offset_id`` (``None`` when there are no more pages). + + Raises: + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ + data: dict[str, Any] = {"method": "searchAuctions", "query": query} + if type is not None: + data["type"] = type + if sort is not None: + data["sort"] = sort + if filter is not None: + data["filter"] = filter + if offset_id is not None: + data["offset_id"] = offset_id + + try: + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, FRAGMENT_BASE_URL, client.timeout) + async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: + result = await fragment_request(session, fragment_hash, HEADERS, data) + + if result.get("error"): + raise FragmentAPIError(result["error"]) + + items = parse_auction_rows(result.get("html") or "") + raw_noi = result.get("next_offset_id") + next_offset_id = str(raw_noi) if raw_noi else None + return AuctionsResult(items=items, next_offset_id=next_offset_id) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/methods/topup_ton.py b/pyfragment/methods/topup_ton.py index cebe7c3..117ea87 100644 --- a/pyfragment/methods/topup_ton.py +++ b/pyfragment/methods/topup_ton.py @@ -71,11 +71,11 @@ async def _init_request( async def topup_ton(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: - """Top up a Telegram Ads account balance with TON. + """Topup ton to recipient's Telegram balance. Args: client: Authenticated :class:`FragmentClient` instance. - username: Ads account username (with or without ``@``). + username: Recipient's Telegram username (with or without ``@``). amount: Amount in TON — integer from ``1`` to ``1 000 000 000``. show_sender: Show your name as the sender. Defaults to ``True``. @@ -84,7 +84,7 @@ async def topup_ton(client: "FragmentClient", username: str, amount: int, show_s Raises: ConfigurationError: If ``amount`` is not an integer between 1 and 1 000 000 000. - UserNotFoundError: If the user is not found on Fragment. + UserNotFoundError: If the recipient is not found on Fragment. FragmentAPIError: If the Fragment API returns an error. UnexpectedError: For any other unexpected failure. """ diff --git a/pyfragment/types/__init__.py b/pyfragment/types/__init__.py index 6d77339..5544706 100644 --- a/pyfragment/types/__init__.py +++ b/pyfragment/types/__init__.py @@ -17,6 +17,7 @@ from pyfragment.types.exceptions import ( from pyfragment.types.results import ( AdsRechargeResult, AdsTopupResult, + AuctionsResult, LoginCodeResult, PremiumGiveawayResult, PremiumResult, @@ -46,6 +47,7 @@ __all__ = [ # result types "AdsRechargeResult", "AdsTopupResult", + "AuctionsResult", "LoginCodeResult", "PremiumGiveawayResult", "PremiumResult", diff --git a/pyfragment/types/results.py b/pyfragment/types/results.py index f73d5a7..c8128fd 100644 --- a/pyfragment/types/results.py +++ b/pyfragment/types/results.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Any @dataclass @@ -116,9 +117,32 @@ class TerminateSessionsResult: return f"TerminateSessionsResult(number='{self.number}', message={self.message!r})" +@dataclass +class AuctionsResult: + """Result of :meth:`FragmentClient.search_auctions`. + + Each dict in ``items`` has the keys: + + - ``slug`` — URL path (e.g. ``"username/durov"``). + - ``name`` — display value (e.g. ``"@durov"``). + - ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``). + - ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``), or ``None``. + - ``ends_at`` — ISO 8601 auction-end datetime, or ``None``. + + Use ``next_offset_id`` to paginate to the next page of results. + """ + + items: list[dict[str, Any]] + next_offset_id: str | None + + def __repr__(self) -> str: + return f"AuctionsResult(items={len(self.items)}, next_offset_id={self.next_offset_id!r})" + + __all__ = [ "AdsRechargeResult", "AdsTopupResult", + "AuctionsResult", "LoginCodeResult", "PremiumGiveawayResult", "PremiumResult", diff --git a/pyfragment/utils/__init__.py b/pyfragment/utils/__init__.py index 4c092bb..538252b 100644 --- a/pyfragment/utils/__init__.py +++ b/pyfragment/utils/__init__.py @@ -1,5 +1,5 @@ from pyfragment.utils.decoder import clean_decode -from pyfragment.utils.html import parse_login_code +from pyfragment.utils.html import parse_auction_rows, parse_login_code from pyfragment.utils.http import ( execute_transaction_request, fragment_request, @@ -11,6 +11,7 @@ from pyfragment.utils.wallet import get_account_info, process_transaction __all__ = [ "clean_decode", + "parse_auction_rows", "parse_login_code", "execute_transaction_request", "fragment_request", diff --git a/pyfragment/utils/html.py b/pyfragment/utils/html.py index 30004f4..d7e9813 100644 --- a/pyfragment/utils/html.py +++ b/pyfragment/utils/html.py @@ -1,10 +1,21 @@ import re +from typing import Any # Matches the login code inside a table-cell-value element. CODE_RE = re.compile(r'class="[^"]*table-cell-value[^"]*"[^>]*>([^<]+)<') # Counts active session rows in the HTML table. ROW_RE = re.compile(r"]") +# Auction table row parsing +ROW_BLOCK_RE = re.compile(r']*class="[^"]*tm-row-selectable[^"]*"[^>]*>(.*?)', re.DOTALL) +HREF_RE = re.compile(r'href="(/(?:username|number|nft)/([^"]+))"') +VALUE_RE = re.compile(r'class="[^"]*tm-value[^"]*"[^>]*>\s*([^<]+?)\s*<') +PRICE_RE = re.compile(r"icon-before\s+icon-ton[^>]*>\s*([0-9][^<]*?)\s*<") +DATETIME_RE = re.compile(r']+datetime="([^"]+)"[^>]*data-relative="text"[^>]*>') +DATETIME_SHORT_RE = re.compile(r']+datetime="([^"]+)"[^>]*data-relative="short-text"[^>]*>') +# Matches numeric-only values (plain integers, formatted prices like "150,492", phone numbers like "+888 0088 8888") +NUMERIC_RE = re.compile(r"^\+?[\d,. ]+$") + def parse_login_code(html: str) -> tuple[str | None, int]: """Extract the pending login code and active session count from a Fragment numbers page HTML snippet. @@ -21,3 +32,66 @@ def parse_login_code(html: str) -> tuple[str | None, int]: code = match.group(1).strip() if match else None active_sessions = len(ROW_RE.findall(html)) return code, active_sessions + + +def parse_auction_rows(html: str) -> list[dict[str, Any]]: + """Parse Fragment marketplace HTML into structured item dicts. + + Extracts each ```` and returns a list of dicts + with the following keys: + + - ``slug`` — URL path segment (e.g. ``"username/durov"``). + - ``name`` — display value (e.g. ``"@durov"`` or ``"+888..."``) + - ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``). + - ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``), + or ``None`` if not listed. + - ``ends_at`` — ISO 8601 datetime string of auction end, or ``None``. + + Returns: + List of item dicts, one per table row. + """ + items: list[dict[str, Any]] = [] + for row_match in ROW_BLOCK_RE.finditer(html): + row = row_match.group(1) + + href_m = HREF_RE.search(row) + if not href_m: + continue + slug = href_m.group(1).lstrip("/") # e.g. "username/durov" + + # All tm-value spans in the row — first is the display name + values = [m.group(1).strip() for m in VALUE_RE.finditer(row)] + name = values[0] if values else slug + + # Status: find the human-readable label from subsequent tm-value spans. + # Skip usernames (@), numeric-only values (prices like "150,492", phone numbers like "+888 0088 8888"). + status: str | None = None + for v in values[1:]: + if v and v not in ("Unknown",) and not v.startswith("@") and not NUMERIC_RE.match(v): + status = v + break + + # Price — look for icon-ton pattern, format as two decimal places + price_m = PRICE_RE.search(row) + price: str | None = None + if price_m: + raw_price = price_m.group(1).strip().replace(",", "") + try: + price = f"{float(raw_price):.2f}" + except ValueError: + price = raw_price + + # Auction end datetime (ISO 8601) + time_m = DATETIME_RE.search(row) or DATETIME_SHORT_RE.search(row) + ends_at: str | None = time_m.group(1) if time_m else None + + items.append( + { + "slug": slug, + "name": name, + "status": status, + "price": price, + "ends_at": ends_at, + } + ) + return items diff --git a/tests/014_test_search.py b/tests/014_test_search.py new file mode 100644 index 0000000..5e837af --- /dev/null +++ b/tests/014_test_search.py @@ -0,0 +1,124 @@ +"""Unit tests for search_auctions — Fragment marketplace search.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import FragmentClient +from pyfragment.types import AuctionsResult +from tests.shared import FAKE_HASH + +FAKE_HTML = """ + + + + + +""" + + +@pytest.mark.asyncio +async def test_search_auctions_basic(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_auctions.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_auctions.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), + ), + ): + result = await client.search_auctions("coolname") + + assert isinstance(result, AuctionsResult) + assert len(result.items) == 1 + assert result.items[0]["slug"] == "username/coolname" + assert result.items[0]["name"] == "@coolname" + assert result.next_offset_id is None + + +@pytest.mark.asyncio +async def test_search_auctions_with_type(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_auctions.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_auctions.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_99"}), + ), + ): + result = await client.search_auctions("coolname", type="usernames") + + assert isinstance(result, AuctionsResult) + assert len(result.items) == 1 + assert result.next_offset_id == "offset_99" + + +@pytest.mark.asyncio +async def test_search_auctions_numbers(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_auctions.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_auctions.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), + ), + ): + result = await client.search_auctions("888", type="numbers") + + assert isinstance(result, AuctionsResult) + assert isinstance(result.items, list) + + +@pytest.mark.asyncio +async def test_search_auctions_empty_html(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_auctions.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_auctions.fragment_request", + AsyncMock(return_value={"ok": True}), + ), + ): + result = await client.search_auctions("zzz_no_results") + + assert isinstance(result, AuctionsResult) + assert result.items == [] + assert result.next_offset_id is None + + +@pytest.mark.asyncio +async def test_search_auctions_with_sort_and_filter(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_auctions.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_auctions.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), + ) as mock_request, + ): + result = await client.search_auctions("durov", type="usernames", sort="price_desc", filter="auction") + + assert isinstance(result, AuctionsResult) + call_data = mock_request.call_args[0][3] + assert call_data["sort"] == "price_desc" + assert call_data["filter"] == "auction" + assert call_data["type"] == "usernames" + assert call_data["query"] == "durov" + + +@pytest.mark.asyncio +async def test_search_auctions_with_offset_id(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_auctions.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_auctions.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), + ) as mock_request, + ): + result = await client.search_auctions("durov", type="usernames", offset_id="offset_10") + + assert isinstance(result, AuctionsResult) + call_data = mock_request.call_args[0][3] + assert call_data["offset_id"] == "offset_10" + assert call_data["type"] == "usernames" + assert call_data["query"] == "durov" From 8163ed1a9d220d71e7a40b32cc2917839c56230b Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Wed, 25 Mar 2026 18:01:22 +0200 Subject: [PATCH 17/21] feat: Add gift and number search functionality to FragmentClient - Implemented `search_gifts` method for searching gifts in the Fragment marketplace. - Implemented `search_numbers` method for searching anonymous Telegram numbers. - Added new example scripts for searching gifts, numbers, and usernames. - Updated HTML parsing logic to handle gift items and number listings. - Added unit tests for the new search methods and updated existing tests for usernames and numbers. - Removed obsolete test file for auction searches. --- CHANGELOG.md | 9 +- README.md | 6 +- examples/search_gifts.py | 43 ++++ examples/search_numbers.py | 41 ++++ ...search_auctions.py => search_usernames.py} | 10 +- pyfragment/__init__.py | 8 +- pyfragment/client.py | 85 ++++++-- pyfragment/methods/__init__.py | 8 +- pyfragment/methods/search_gifts.py | 80 ++++++++ pyfragment/methods/search_numbers.py | 67 +++++++ ...search_auctions.py => search_usernames.py} | 25 +-- pyfragment/types/__init__.py | 8 +- pyfragment/types/constants.py | 1 + pyfragment/types/results.py | 56 +++++- pyfragment/utils/__init__.py | 3 +- pyfragment/utils/html.py | 72 ++++++- tests/013_test_usernames.py | 111 +++++++++++ tests/014_test_numbers.py | 111 +++++++++++ tests/014_test_search.py | 124 ------------ tests/015_test_gifts.py | 188 ++++++++++++++++++ 20 files changed, 877 insertions(+), 179 deletions(-) create mode 100644 examples/search_gifts.py create mode 100644 examples/search_numbers.py rename examples/{search_auctions.py => search_usernames.py} (73%) create mode 100644 pyfragment/methods/search_gifts.py create mode 100644 pyfragment/methods/search_numbers.py rename pyfragment/methods/{search_auctions.py => search_usernames.py} (72%) create mode 100644 tests/013_test_usernames.py create mode 100644 tests/014_test_numbers.py delete mode 100644 tests/014_test_search.py create mode 100644 tests/015_test_gifts.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 527c182..d1a0934 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,10 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI - `AdsRechargeResult` result type **Marketplace** -- `search_auctions(query, type?, sort?, filter?, offset_id?)` — search the Fragment marketplace for usernames, numbers, or collectibles; `type` is one of `"usernames"`, `"numbers"`, `"collectibles"` (optional); `sort` is one of `"price_desc"`, `"price_asc"`, `"listed"`, `"ending"`; `filter` is one of `""`, `"auction"`, `"sale"`, `"sold"`; supports pagination via `offset_id`; returns parsed item dicts with `slug`, `name`, `status`, `price`, `ends_at` -- `AuctionsResult` result type +- `search_usernames(query?, sort?, filter?, offset_id?)` — search the Fragment marketplace for Telegram usernames; `sort` is one of `"price_desc"`, `"price_asc"`, `"listed"`, `"ending"`; `filter` is one of `""`, `"auction"`, `"sale"`, `"sold"`; supports pagination via `offset_id`; returns parsed item dicts with `slug`, `name`, `status`, `price`, `date` +- `search_numbers(query?, sort?, filter?, offset_id?)` — search the Fragment marketplace for anonymous Telegram numbers; same `sort` / `filter` / `offset_id` semantics as `search_usernames` +- `search_gifts(query?, collection?, sort?, filter?, view?, attr?, offset?)` — search the Fragment gifts marketplace; `collection` is the gift collection slug (e.g. `"artisanbrick"`); `attr` accepts trait filters as `{"Model": ["Foosball"], "Backdrop": ["Celtic Blue"]}`; supports integer-based pagination via `offset`; returns parsed item dicts with `slug`, `name`, `status`, `price`, `date` +- `UsernamesResult`, `NumbersResult`, `GiftsResult` result types **Raw API access** - `FragmentClient.call(method, data, *, page_url)` — send a raw request to any Fragment API method without waiting for a library update @@ -41,6 +43,9 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI - `examples/call.py` — raw API call via `client.call()` - `examples/anonymous_number.py` — login code fetch and session termination with `AnonymousNumberError` handling - `examples/recharge_ads.py` — self-service Ads recharge with `ConfigurationError` / `WalletError` handling +- `examples/search_usernames.py` — username marketplace search with pagination +- `examples/search_numbers.py` — number marketplace search with pagination +- `examples/search_gifts.py` — gifts marketplace search with collection, attr, and pagination ### Changed - All result types (`PremiumResult`, `StarsResult`, `StarsGiveawayResult`, `PremiumGiveawayResult`) now use a single unified `amount` field instead of `months`, `stars` — consistent API across every method diff --git a/README.md b/README.md index 490b3a8..adc6204 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

Fragment API

- Async Python client for the Fragment.com API — buy Telegram Stars and Premium, run giveaways, top up TON Ads balance, and manage anonymous numbers. + Async Python client for the Fragment.com API — buy Telegram Stars and Premium, run giveaways, top up TON, Ads balance, manage anonymous numbers, and search the marketplace for usernames, numbers, and gifts.

[![PyPI version](https://img.shields.io/pypi/v/pyfragment?style=flat&color=blue)](https://pypi.org/project/pyfragment/) @@ -90,11 +90,11 @@ async def main() -> None: try: # Purchase 6 months of Telegram Premium result = await client.purchase_premium("@username", months=6) - print(f"{result.months} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}") + print(f"{result.amount} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}") # Purchase 500 Stars result = await client.purchase_stars("@username", amount=500) - print(f"{result.stars} Stars successfully sent to {result.username} | tx: {result.transaction_id}") + print(f"{result.amount} Stars successfully sent to {result.username} | tx: {result.transaction_id}") # Top up 10 TON to Telegram balance # wallet must hold at least amount + ~0.056 TON for gas diff --git a/examples/search_gifts.py b/examples/search_gifts.py new file mode 100644 index 0000000..a6d63a9 --- /dev/null +++ b/examples/search_gifts.py @@ -0,0 +1,43 @@ +""" +Example: search the Fragment gifts marketplace. + +collection filters by gift type slug (e.g. "plushpepe", "swisswatch"). +sort can be "price_desc", "price_asc", "listed", or "ending". +filter can be "", "auction", "sale", or "sold". +Use next_offset for pagination. +""" + +import asyncio + +from pyfragment import FragmentClient, GiftsResult + +SEED = "word1 word2 ... word24" +API_KEY = "YOUR_TONAPI_KEY" +COOKIES = { + "stel_ssid": "YOUR_STEL_SSID", + "stel_dt": "YOUR_STEL_DT", + "stel_token": "YOUR_STEL_TOKEN", + "stel_ton_token": "YOUR_STEL_TON_TOKEN", +} + +QUERY = "" # search text — or omit for all +COLLECTION = "plushpepe" # gift collection slug — or omit for all +SORT = "price_desc" # "price_desc", "price_asc", "listed", "ending" — or omit +FILTER = "" # "", "auction", "sale", "sold" — or omit + + +async def main() -> None: + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + result: GiftsResult = await client.search_gifts(QUERY, collection=COLLECTION, sort=SORT, filter=FILTER) + + print(f"Found {len(result.items)} result(s):") + for item in result.items: + price = f"{item['price']} TON" if item["price"] else "n/a" + print(f" {item['name']:30s} {item['status'] or '':15s} {price:12s} {item['date'] or '—'}") + + if result.next_offset: + print(f"\nMore results available — next page offset: {result.next_offset}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/search_numbers.py b/examples/search_numbers.py new file mode 100644 index 0000000..c24a18e --- /dev/null +++ b/examples/search_numbers.py @@ -0,0 +1,41 @@ +""" +Example: search the Fragment marketplace for anonymous Telegram numbers. + +sort can be "price_desc", "price_asc", "listed", or "ending". +filter can be "", "auction", "sale", or "sold". +Use next_offset_id for pagination. +""" + +import asyncio + +from pyfragment import FragmentClient, NumbersResult + +SEED = "word1 word2 ... word24" +API_KEY = "YOUR_TONAPI_KEY" +COOKIES = { + "stel_ssid": "YOUR_STEL_SSID", + "stel_dt": "YOUR_STEL_DT", + "stel_token": "YOUR_STEL_TOKEN", + "stel_ton_token": "YOUR_STEL_TON_TOKEN", +} + +QUERY = "888" # search term — or omit for all +SORT = "price_asc" # "price_desc", "price_asc", "listed", "ending" — or omit +FILTER = "" # "", "auction", "sale", "sold" — or omit + + +async def main() -> None: + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + result: NumbersResult = await client.search_numbers(QUERY, sort=SORT, filter=FILTER) + + print(f"Found {len(result.items)} result(s):") + for item in result.items: + price = f"{item['price']} TON" if item["price"] else "n/a" + print(f" {item['name']:20s} {item['status'] or '':15s} {price:10s} {item['date'] or '—'}") + + if result.next_offset_id: + print(f"\nMore results available — next page offset: {result.next_offset_id}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/search_auctions.py b/examples/search_usernames.py similarity index 73% rename from examples/search_auctions.py rename to examples/search_usernames.py index fa026f2..c139785 100644 --- a/examples/search_auctions.py +++ b/examples/search_usernames.py @@ -1,7 +1,6 @@ """ -Example: search the Fragment marketplace for auction listings. +Example: search the Fragment marketplace for Telegram usernames. -type can be "usernames", "numbers", or "collectibles". sort can be "price_desc", "price_asc", "listed", or "ending". filter can be "", "auction", "sale", or "sold". Use next_offset_id for pagination. @@ -9,7 +8,7 @@ Use next_offset_id for pagination. import asyncio -from pyfragment import AuctionsResult, FragmentClient +from pyfragment import FragmentClient, UsernamesResult SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" @@ -21,19 +20,18 @@ COOKIES = { } QUERY = "durov" # search term -TYPE = "usernames" # "usernames", "numbers", or "collectibles" — or omit SORT = "price_desc" # "price_desc", "price_asc", "listed", "ending" — or omit FILTER = "auction" # "", "auction", "sale", "sold" — or omit async def main() -> None: async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: - result: AuctionsResult = await client.search_auctions(QUERY, type=TYPE, sort=SORT, filter=FILTER) + result: UsernamesResult = await client.search_usernames(QUERY, sort=SORT, filter=FILTER) print(f"Found {len(result.items)} result(s):") for item in result.items: price = f"{item['price']} TON" if item["price"] else "n/a" - print(f" {item['name']:20s} {item['status'] or '':15s} {price:10s} {item['ends_at'] or '—'}") + print(f" {item['name']:20s} {item['status'] or '':15s} {price:10s} {item['date'] or '—'}") if result.next_offset_id: print(f"\nMore results available — next page offset: {result.next_offset_id}") diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index e3eec8c..495897d 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -10,14 +10,15 @@ from pyfragment.types import ( AdsRechargeResult, AdsTopupResult, AnonymousNumberError, - AuctionsResult, ClientError, ConfigurationError, CookieError, FragmentAPIError, FragmentError, FragmentPageError, + GiftsResult, LoginCodeResult, + NumbersResult, OperationError, ParseError, PremiumGiveawayResult, @@ -27,6 +28,7 @@ from pyfragment.types import ( TerminateSessionsResult, TransactionError, UnexpectedError, + UsernamesResult, UserNotFoundError, VerificationError, WalletError, @@ -40,13 +42,15 @@ __all__ = [ "FragmentClient", "AdsRechargeResult", "AdsTopupResult", - "AuctionsResult", + "GiftsResult", "LoginCodeResult", + "NumbersResult", "PremiumGiveawayResult", "PremiumResult", "StarsGiveawayResult", "StarsResult", "TerminateSessionsResult", + "UsernamesResult", "WalletInfo", "ClientError", "ConfigurationError", diff --git a/pyfragment/client.py b/pyfragment/client.py index 3d0d4db..08bd9ab 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -9,18 +9,22 @@ from pyfragment.methods.giveaway_stars import giveaway_stars from pyfragment.methods.purchase_premium import purchase_premium from pyfragment.methods.purchase_stars import purchase_stars from pyfragment.methods.recharge_ads import recharge_ads -from pyfragment.methods.search_auctions import search_auctions +from pyfragment.methods.search_gifts import search_gifts +from pyfragment.methods.search_numbers import search_numbers +from pyfragment.methods.search_usernames import search_usernames from pyfragment.methods.topup_ton import topup_ton from pyfragment.types import ( AdsRechargeResult, AdsTopupResult, - AuctionsResult, ConfigurationError, CookieError, + GiftsResult, LoginCodeResult, + NumbersResult, PremiumResult, StarsResult, TerminateSessionsResult, + UsernamesResult, WalletInfo, ) from pyfragment.types.constants import ( @@ -252,32 +256,87 @@ class FragmentClient: """ return await terminate_sessions(self, number) - async def search_auctions( + async def search_usernames( self, - query: str, - type: str | None = None, + query: str = "", sort: str | None = None, filter: str | None = None, offset_id: str | None = None, - ) -> AuctionsResult: - """Search the Fragment marketplace for auction listings. + ) -> UsernamesResult: + """Search the Fragment marketplace for Telegram usernames. Args: - query: Search text (e.g. ``"durov"`` or ``"888"``). - type: Narrow results by item type — ``"usernames"``, ``"numbers"``, or - ``"collectibles"``. Omit to search across all types. + query: Search text (e.g. ``"durov"``). Omit or pass ``""`` to browse all. sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or ``"ending"``. Omit to use Fragment's default ordering. filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` (available items). Omit to return all. - offset_id: Pagination cursor — pass :attr:`AuctionsResult.next_offset_id` + offset_id: Pagination cursor — pass :attr:`UsernamesResult.next_offset_id` from a previous result to fetch the next page. Returns: - :class:`AuctionsResult` with ``items`` (parsed list of item dicts) + :class:`UsernamesResult` with ``items`` (parsed list of item dicts) and ``next_offset_id`` (``None`` on the last page). """ - return await search_auctions(self, query, type=type, sort=sort, filter=filter, offset_id=offset_id) + return await search_usernames(self, query, sort=sort, filter=filter, offset_id=offset_id) + + async def search_numbers( + self, + query: str = "", + sort: str | None = None, + filter: str | None = None, + offset_id: str | None = None, + ) -> NumbersResult: + """Search the Fragment marketplace for anonymous Telegram numbers. + + Args: + query: Search text (e.g. ``"888"``). Omit or pass ``""`` to browse all. + sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or + ``"ending"``. Omit to use Fragment's default ordering. + filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or + ``""`` (available items). Omit to return all. + offset_id: Pagination cursor — pass :attr:`NumbersResult.next_offset_id` + from a previous result to fetch the next page. + + Returns: + :class:`NumbersResult` with ``items`` (parsed list of item dicts) + and ``next_offset_id`` (``None`` on the last page). + """ + return await search_numbers(self, query, sort=sort, filter=filter, offset_id=offset_id) + + async def search_gifts( + self, + query: str = "", + collection: str | None = None, + sort: str | None = None, + filter: str | None = None, + view: str | None = None, + attr: dict[str, list[str]] | None = None, + offset: int | None = None, + ) -> GiftsResult: + """Search the Fragment gifts marketplace. + + Args: + query: Search text. Omit or pass ``""`` to browse without filtering by name. + collection: Filter by gift collection slug (e.g. ``"artisanbrick"``). Omit for all. + sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or + ``"ending"``. Omit to use Fragment's default ordering. + filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or + ``""`` (available items). Omit to return all. + view: Active attribute tab name (e.g. ``"Model"``, ``"Backdrop"``). Omit for default. + attr: Attribute filters — mapping of trait name to accepted values, e.g. + ``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue", "Orange"]}``. + Each key is sent as ``attr[Key]`` with its list of values. + offset: Integer page offset from a previous :class:`GiftsResult`. + Pass ``next_offset`` to fetch the next page. + + Returns: + :class:`GiftsResult` with ``items`` (parsed list of item dicts) + and ``next_offset`` (``None`` on the last page). + """ + return await search_gifts( + self, query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset + ) async def call( self, method: str, data: dict[str, Any] | None = None, *, page_url: str = FRAGMENT_BASE_URL diff --git a/pyfragment/methods/__init__.py b/pyfragment/methods/__init__.py index 98b3326..9714024 100644 --- a/pyfragment/methods/__init__.py +++ b/pyfragment/methods/__init__.py @@ -4,7 +4,9 @@ from pyfragment.methods.giveaway_stars import giveaway_stars from pyfragment.methods.purchase_premium import purchase_premium from pyfragment.methods.purchase_stars import purchase_stars from pyfragment.methods.recharge_ads import recharge_ads -from pyfragment.methods.search_auctions import search_auctions +from pyfragment.methods.search_gifts import search_gifts +from pyfragment.methods.search_numbers import search_numbers +from pyfragment.methods.search_usernames import search_usernames from pyfragment.methods.topup_ton import topup_ton __all__ = [ @@ -14,7 +16,9 @@ __all__ = [ "purchase_premium", "purchase_stars", "recharge_ads", - "search_auctions", + "search_gifts", + "search_numbers", + "search_usernames", "terminate_sessions", "toggle_login_codes", "topup_ton", diff --git a/pyfragment/methods/search_gifts.py b/pyfragment/methods/search_gifts.py new file mode 100644 index 0000000..d1276b4 --- /dev/null +++ b/pyfragment/methods/search_gifts.py @@ -0,0 +1,80 @@ +from typing import TYPE_CHECKING, Any + +import httpx + +from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError +from pyfragment.types.constants import GIFTS_PAGE +from pyfragment.types.results import GiftsResult +from pyfragment.utils import fragment_request, get_fragment_hash, make_headers, parse_gift_items + +if TYPE_CHECKING: + from pyfragment.client import FragmentClient + +HEADERS: dict[str, str] = make_headers(GIFTS_PAGE) + + +async def search_gifts( + client: "FragmentClient", + query: str = "", + collection: str | None = None, + sort: str | None = None, + filter: str | None = None, + view: str | None = None, + attr: dict[str, list[str]] | None = None, + offset: int | None = None, +) -> GiftsResult: + """Search the Fragment gifts marketplace. + + Args: + client: Authenticated :class:`FragmentClient` instance. + query: Search text. Omit or pass ``""`` to browse without filtering by name. + collection: Filter by gift collection slug (e.g. ``"artisanbrick"``). Omit for all. + sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or + ``"ending"``. Omit to use Fragment's default ordering. + filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` + (available items). Omit to return all. + view: Active attribute tab name (e.g. ``"Model"``, ``"Backdrop"``). Omit for default. + attr: Attribute filters as a mapping of trait name to list of accepted values, e.g. + ``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue", "Orange"]}``. + Each key is sent as ``attr[Key]`` with its list of values. + offset: Integer page offset from a previous :class:`GiftsResult`. + Pass ``next_offset`` to fetch the next page. + + Returns: + :class:`GiftsResult` with ``items`` (parsed list of item dicts) and + ``next_offset`` (``None`` on the last page). + + Raises: + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ + data: dict[str, Any] = {"method": "searchAuctions", "type": "gifts", "query": query} + if collection is not None: + data["collection"] = collection + if sort is not None: + data["sort"] = sort + if filter is not None: + data["filter"] = filter + if view is not None: + data["view"] = view + if attr is not None: + for trait, values in attr.items(): + data[f"attr[{trait}]"] = values + if offset is not None: + data["offset"] = offset + + try: + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, GIFTS_PAGE, client.timeout) + async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: + result = await fragment_request(session, fragment_hash, HEADERS, data) + + if result.get("error"): + raise FragmentAPIError(result["error"]) + + items, next_offset = parse_gift_items(result.get("html") or "") + return GiftsResult(items=items, next_offset=next_offset) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/methods/search_numbers.py b/pyfragment/methods/search_numbers.py new file mode 100644 index 0000000..e6f459a --- /dev/null +++ b/pyfragment/methods/search_numbers.py @@ -0,0 +1,67 @@ +from typing import TYPE_CHECKING, Any + +import httpx + +from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError +from pyfragment.types.constants import NUMBERS_PAGE +from pyfragment.types.results import NumbersResult +from pyfragment.utils import fragment_request, get_fragment_hash, make_headers, parse_auction_rows + +if TYPE_CHECKING: + from pyfragment.client import FragmentClient + +HEADERS: dict[str, str] = make_headers(NUMBERS_PAGE) + + +async def search_numbers( + client: "FragmentClient", + query: str = "", + sort: str | None = None, + filter: str | None = None, + offset_id: str | None = None, +) -> NumbersResult: + """Search the Fragment marketplace for anonymous Telegram numbers. + + Args: + client: Authenticated :class:`FragmentClient` instance. + query: Search text (e.g. ``"888"``). Omit or pass ``""`` to browse all. + sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or + ``"ending"``. Omit to use Fragment's default ordering. + filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` + (available items). Omit to return all. + offset_id: Pagination cursor from a previous :class:`NumbersResult`. + Pass ``next_offset_id`` to fetch the next page. + + Returns: + :class:`NumbersResult` with ``items`` (parsed list of item dicts) and + ``next_offset_id`` (``None`` when there are no more pages). + + Raises: + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ + data: dict[str, Any] = {"method": "searchAuctions", "type": "numbers", "query": query} + if sort is not None: + data["sort"] = sort + if filter is not None: + data["filter"] = filter + if offset_id is not None: + data["offset_id"] = offset_id + + try: + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, NUMBERS_PAGE, client.timeout) + async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: + result = await fragment_request(session, fragment_hash, HEADERS, data) + + if result.get("error"): + raise FragmentAPIError(result["error"]) + + items = parse_auction_rows(result.get("html") or "") + raw_noi = result.get("next_offset_id") + next_offset_id = str(raw_noi) if raw_noi else None + return NumbersResult(items=items, next_offset_id=next_offset_id) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/methods/search_auctions.py b/pyfragment/methods/search_usernames.py similarity index 72% rename from pyfragment/methods/search_auctions.py rename to pyfragment/methods/search_usernames.py index fdcd2cd..23af084 100644 --- a/pyfragment/methods/search_auctions.py +++ b/pyfragment/methods/search_usernames.py @@ -4,7 +4,7 @@ import httpx from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError from pyfragment.types.constants import FRAGMENT_BASE_URL -from pyfragment.types.results import AuctionsResult +from pyfragment.types.results import UsernamesResult from pyfragment.utils import fragment_request, get_fragment_hash, make_headers, parse_auction_rows if TYPE_CHECKING: @@ -13,39 +13,34 @@ if TYPE_CHECKING: HEADERS: dict[str, str] = make_headers(FRAGMENT_BASE_URL) -async def search_auctions( +async def search_usernames( client: "FragmentClient", - query: str, - type: str | None = None, + query: str = "", sort: str | None = None, filter: str | None = None, offset_id: str | None = None, -) -> AuctionsResult: - """Search the Fragment marketplace for usernames, numbers, or collectibles. +) -> UsernamesResult: + """Search the Fragment marketplace for Telegram usernames. Args: client: Authenticated :class:`FragmentClient` instance. - query: Search text (e.g. ``"durov"`` or ``"888"``). - type: Narrow results by item type — ``"usernames"``, ``"numbers"``, or - ``"collectibles"``. Omit to search across all types. + query: Search text (e.g. ``"durov"``). Omit or pass ``""`` to browse all. sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or ``"ending"``. Omit to use Fragment's default ordering. filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` (available items). Omit to return all. - offset_id: Pagination cursor from a previous :class:`AuctionsResult`. + offset_id: Pagination cursor from a previous :class:`UsernamesResult`. Pass ``next_offset_id`` to fetch the next page. Returns: - :class:`AuctionsResult` with ``items`` (parsed list of item dicts) and + :class:`UsernamesResult` with ``items`` (parsed list of item dicts) and ``next_offset_id`` (``None`` when there are no more pages). Raises: FragmentAPIError: If the Fragment API returns an error. UnexpectedError: For any other unexpected failure. """ - data: dict[str, Any] = {"method": "searchAuctions", "query": query} - if type is not None: - data["type"] = type + data: dict[str, Any] = {"method": "searchAuctions", "type": "usernames", "query": query} if sort is not None: data["sort"] = sort if filter is not None: @@ -64,7 +59,7 @@ async def search_auctions( items = parse_auction_rows(result.get("html") or "") raw_noi = result.get("next_offset_id") next_offset_id = str(raw_noi) if raw_noi else None - return AuctionsResult(items=items, next_offset_id=next_offset_id) + return UsernamesResult(items=items, next_offset_id=next_offset_id) except FragmentError: raise diff --git a/pyfragment/types/__init__.py b/pyfragment/types/__init__.py index 5544706..0563be0 100644 --- a/pyfragment/types/__init__.py +++ b/pyfragment/types/__init__.py @@ -17,13 +17,15 @@ from pyfragment.types.exceptions import ( from pyfragment.types.results import ( AdsRechargeResult, AdsTopupResult, - AuctionsResult, + GiftsResult, LoginCodeResult, + NumbersResult, PremiumGiveawayResult, PremiumResult, StarsGiveawayResult, StarsResult, TerminateSessionsResult, + UsernamesResult, WalletInfo, ) @@ -47,12 +49,14 @@ __all__ = [ # result types "AdsRechargeResult", "AdsTopupResult", - "AuctionsResult", + "GiftsResult", "LoginCodeResult", + "NumbersResult", "PremiumGiveawayResult", "PremiumResult", "StarsGiveawayResult", "StarsResult", "TerminateSessionsResult", + "UsernamesResult", "WalletInfo", ] diff --git a/pyfragment/types/constants.py b/pyfragment/types/constants.py index 29896d3..fee850a 100644 --- a/pyfragment/types/constants.py +++ b/pyfragment/types/constants.py @@ -27,6 +27,7 @@ PREMIUM_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/gift" PREMIUM_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/giveaway" ADS_TOPUP_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup" NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers" +GIFTS_PAGE: str = f"{FRAGMENT_BASE_URL}/gifts" # Tonkeeper device fingerprint — serialized once, reused in every tx_data payload. DEVICE: str = json.dumps( diff --git a/pyfragment/types/results.py b/pyfragment/types/results.py index c8128fd..89eb403 100644 --- a/pyfragment/types/results.py +++ b/pyfragment/types/results.py @@ -118,8 +118,8 @@ class TerminateSessionsResult: @dataclass -class AuctionsResult: - """Result of :meth:`FragmentClient.search_auctions`. +class UsernamesResult: + """Result of :meth:`FragmentClient.search_usernames`. Each dict in ``items`` has the keys: @@ -127,7 +127,7 @@ class AuctionsResult: - ``name`` — display value (e.g. ``"@durov"``). - ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``). - ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``), or ``None``. - - ``ends_at`` — ISO 8601 auction-end datetime, or ``None``. + - ``date`` — ISO 8601 datetime: auction end date, sale date, or listing date, or ``None``. Use ``next_offset_id`` to paginate to the next page of results. """ @@ -136,18 +136,64 @@ class AuctionsResult: next_offset_id: str | None def __repr__(self) -> str: - return f"AuctionsResult(items={len(self.items)}, next_offset_id={self.next_offset_id!r})" + return f"UsernamesResult(items={len(self.items)}, next_offset_id={self.next_offset_id!r})" + + +@dataclass +class NumbersResult: + """Result of :meth:`FragmentClient.search_numbers`. + + Each dict in ``items`` has the keys: + + - ``slug`` — URL path (e.g. ``"number/8880000111"``). + - ``name`` — display value (e.g. ``"+888 0000 111"``). + - ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``). + - ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``), or ``None``. + - ``date`` — ISO 8601 datetime: auction end date, sale date, or listing date, or ``None``. + + Use ``next_offset_id`` to paginate to the next page of results. + """ + + items: list[dict[str, Any]] + next_offset_id: str | None + + def __repr__(self) -> str: + return f"NumbersResult(items={len(self.items)}, next_offset_id={self.next_offset_id!r})" + + +@dataclass +class GiftsResult: + """Result of :meth:`FragmentClient.search_gifts`. + + Each dict in ``items`` has the keys: + + - ``slug`` — URL path (e.g. ``"gift/plushpepe-1821"``). + - ``name`` — display name with number (e.g. ``"Plush Pepe #1821"``). + - ``status`` — human-readable Fragment label (e.g. ``"Sold"``, ``"For sale"``). + - ``price`` — price in TON formatted to two decimal places (e.g. ``"88888.00"``), or ``None``. + - ``date`` — ISO 8601 datetime of the sale/listing, or ``None``. + + Use ``next_offset`` to paginate to the next page of results. + """ + + items: list[dict[str, Any]] + next_offset: int | None + + def __repr__(self) -> str: + return f"GiftsResult(items={len(self.items)}, next_offset={self.next_offset!r})" __all__ = [ "AdsRechargeResult", "AdsTopupResult", - "AuctionsResult", + "GiftsResult", "LoginCodeResult", + "NumbersResult", "PremiumGiveawayResult", "PremiumResult", "StarsGiveawayResult", "StarsResult", "TerminateSessionsResult", + "UsernamesResult", "WalletInfo", ] diff --git a/pyfragment/utils/__init__.py b/pyfragment/utils/__init__.py index 538252b..a52cf8b 100644 --- a/pyfragment/utils/__init__.py +++ b/pyfragment/utils/__init__.py @@ -1,5 +1,5 @@ from pyfragment.utils.decoder import clean_decode -from pyfragment.utils.html import parse_auction_rows, parse_login_code +from pyfragment.utils.html import parse_auction_rows, parse_gift_items, parse_login_code from pyfragment.utils.http import ( execute_transaction_request, fragment_request, @@ -12,6 +12,7 @@ from pyfragment.utils.wallet import get_account_info, process_transaction __all__ = [ "clean_decode", "parse_auction_rows", + "parse_gift_items", "parse_login_code", "execute_transaction_request", "fragment_request", diff --git a/pyfragment/utils/html.py b/pyfragment/utils/html.py index d7e9813..f3a82e5 100644 --- a/pyfragment/utils/html.py +++ b/pyfragment/utils/html.py @@ -16,6 +16,15 @@ DATETIME_SHORT_RE = re.compile(r']+datetime="([^"]+)"[^>]*data-relative= # Matches numeric-only values (plain integers, formatted prices like "150,492", phone numbers like "+888 0088 8888") NUMERIC_RE = re.compile(r"^\+?[\d,. ]+$") +# Gift grid item parsing +GRID_ITEM_RE = re.compile(r']*class="[^"]*tm-grid-item[^"]*"[^>]*>(.*?)', re.DOTALL) +GRID_HREF_RE = re.compile(r'href="(/gift/([^?"]+))') +GRID_NAME_RE = re.compile(r'class="item-name">([^<]+)<') +GRID_NUM_RE = re.compile(r'class="item-num">[^#]*#(\w+)<') +GRID_PRICE_RE = re.compile(r'class="[^"]*tm-grid-item-value[^"]*icon-ton[^"]*"[^>]*>\s*([0-9][^<]*?)\s*<') +GRID_STATUS_RE = re.compile(r'class="[^"]*tm-grid-item-status[^"]*"[^>]*>\s*([^<]+?)\s*<') +GRID_DATETIME_RE = re.compile(r']+datetime="([^"]+)"') + def parse_login_code(html: str) -> tuple[str | None, int]: """Extract the pending login code and active session count from a Fragment numbers page HTML snippet. @@ -45,7 +54,7 @@ def parse_auction_rows(html: str) -> list[dict[str, Any]]: - ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``). - ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``), or ``None`` if not listed. - - ``ends_at`` — ISO 8601 datetime string of auction end, or ``None``. + - ``date`` — ISO 8601 datetime string: auction end date, sale date, or listing date, or ``None``. Returns: List of item dicts, one per table row. @@ -81,9 +90,9 @@ def parse_auction_rows(html: str) -> list[dict[str, Any]]: except ValueError: price = raw_price - # Auction end datetime (ISO 8601) + # Datetime (ISO 8601) — auction end, sale date, or listing date. time_m = DATETIME_RE.search(row) or DATETIME_SHORT_RE.search(row) - ends_at: str | None = time_m.group(1) if time_m else None + date: str | None = time_m.group(1) if time_m else None items.append( { @@ -91,7 +100,62 @@ def parse_auction_rows(html: str) -> list[dict[str, Any]]: "name": name, "status": status, "price": price, - "ends_at": ends_at, + "date": date, } ) return items + + +def parse_gift_items(html: str) -> tuple[list[dict[str, Any]], int | None]: + """Parse Fragment gifts grid HTML into structured item dicts. + + Extracts each ```` block and returns a list of dicts + with the following keys: + + - ``slug`` — URL path segment (e.g. ``"gift/plushpepe-1821"``). + - ``name`` — display name with number (e.g. ``"Plush Pepe #1821"``). + - ``status`` — human-readable Fragment label (e.g. ``"Sold"``, ``"For sale"``). + - ``price`` — price in TON formatted to two decimal places, or ``None``. + - ``date`` — ISO 8601 datetime of the sale/listing, or ``None``. + + Returns: + Tuple of ``(items, next_offset)`` where ``next_offset`` is an integer + page offset from ``data-next-offset``, or ``None`` on the last page. + """ + items: list[dict[str, Any]] = [] + for item_match in GRID_ITEM_RE.finditer(html): + block = item_match.group(0) + + href_m = GRID_HREF_RE.search(block) + if not href_m: + continue + slug = href_m.group(1).lstrip("/") # e.g. "gift/plushpepe-1821" + + name_m = GRID_NAME_RE.search(block) + num_m = GRID_NUM_RE.search(block) + item_name = name_m.group(1).strip() if name_m else slug + item_num = f" #{num_m.group(1)}" if num_m else "" + name = f"{item_name}{item_num}" + + status_m = GRID_STATUS_RE.search(block) + status: str | None = status_m.group(1).strip() if status_m else None + + price_m = GRID_PRICE_RE.search(block) + price: str | None = None + if price_m: + raw_price = price_m.group(1).strip().replace(",", "") + try: + price = f"{float(raw_price):.2f}" + except ValueError: + price = raw_price + + time_m = GRID_DATETIME_RE.search(block) + date: str | None = time_m.group(1) if time_m else None + + items.append({"slug": slug, "name": name, "status": status, "price": price, "date": date}) + + # Pagination offset from data-next-offset attribute + next_offset_m = re.search(r'data-next-offset="(\d+)"', html) + next_offset = int(next_offset_m.group(1)) if next_offset_m else None + + return items, next_offset diff --git a/tests/013_test_usernames.py b/tests/013_test_usernames.py new file mode 100644 index 0000000..7f482ff --- /dev/null +++ b/tests/013_test_usernames.py @@ -0,0 +1,111 @@ +"""Unit tests for search_usernames — Fragment marketplace username search.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import FragmentClient +from pyfragment.types import UsernamesResult +from tests.shared import FAKE_HASH + +FAKE_HTML = """ +
+ + + + +""" + + +@pytest.mark.asyncio +async def test_search_usernames_basic(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_usernames.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), + ), + ): + result = await client.search_usernames("coolname") + + assert isinstance(result, UsernamesResult) + assert len(result.items) == 1 + assert result.items[0]["slug"] == "username/coolname" + assert result.items[0]["name"] == "@coolname" + assert result.items[0]["date"] == "2026-06-01T12:00:00+00:00" + assert result.next_offset_id is None + + +@pytest.mark.asyncio +async def test_search_usernames_empty_html(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_usernames.fragment_request", + AsyncMock(return_value={"ok": True}), + ), + ): + result = await client.search_usernames("zzz_no_results") + + assert isinstance(result, UsernamesResult) + assert result.items == [] + assert result.next_offset_id is None + + +@pytest.mark.asyncio +async def test_search_usernames_with_sort_and_filter(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_usernames.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), + ) as mock_request, + ): + result = await client.search_usernames("durov", sort="price_desc", filter="auction") + + assert isinstance(result, UsernamesResult) + call_data = mock_request.call_args[0][3] + assert call_data["type"] == "usernames" + assert call_data["method"] == "searchAuctions" + assert call_data["sort"] == "price_desc" + assert call_data["filter"] == "auction" + assert call_data["query"] == "durov" + + +@pytest.mark.asyncio +async def test_search_usernames_with_offset_id(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_usernames.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_99"}), + ) as mock_request, + ): + result = await client.search_usernames("durov", offset_id="offset_10") + + assert isinstance(result, UsernamesResult) + assert result.next_offset_id == "offset_99" + call_data = mock_request.call_args[0][3] + assert call_data["offset_id"] == "offset_10" + + +@pytest.mark.asyncio +async def test_search_usernames_default_query(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_usernames.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), + ) as mock_request, + ): + result = await client.search_usernames() + + assert isinstance(result, UsernamesResult) + call_data = mock_request.call_args[0][3] + assert call_data["query"] == "" + assert call_data["type"] == "usernames" diff --git a/tests/014_test_numbers.py b/tests/014_test_numbers.py new file mode 100644 index 0000000..62d6fd5 --- /dev/null +++ b/tests/014_test_numbers.py @@ -0,0 +1,111 @@ +"""Unit tests for search_numbers — Fragment marketplace number search.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import FragmentClient +from pyfragment.types import NumbersResult +from tests.shared import FAKE_HASH + +FAKE_HTML = """ + + + + + +""" + + +@pytest.mark.asyncio +async def test_search_numbers_basic(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_numbers.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), + ), + ): + result = await client.search_numbers("888") + + assert isinstance(result, NumbersResult) + assert len(result.items) == 1 + assert result.items[0]["slug"] == "number/8880000888" + assert result.items[0]["name"] == "+888 0000 888" + assert result.items[0]["date"] == "2026-05-15T10:00:00+00:00" + assert result.next_offset_id is None + + +@pytest.mark.asyncio +async def test_search_numbers_empty_html(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_numbers.fragment_request", + AsyncMock(return_value={"ok": True}), + ), + ): + result = await client.search_numbers("zzz_no_results") + + assert isinstance(result, NumbersResult) + assert result.items == [] + assert result.next_offset_id is None + + +@pytest.mark.asyncio +async def test_search_numbers_with_sort_and_filter(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_numbers.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), + ) as mock_request, + ): + result = await client.search_numbers("888", sort="price_asc", filter="sale") + + assert isinstance(result, NumbersResult) + call_data = mock_request.call_args[0][3] + assert call_data["type"] == "numbers" + assert call_data["method"] == "searchAuctions" + assert call_data["sort"] == "price_asc" + assert call_data["filter"] == "sale" + assert call_data["query"] == "888" + + +@pytest.mark.asyncio +async def test_search_numbers_with_offset_id(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_numbers.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_50"}), + ) as mock_request, + ): + result = await client.search_numbers("888", offset_id="offset_50") + + assert isinstance(result, NumbersResult) + assert result.next_offset_id == "offset_50" + call_data = mock_request.call_args[0][3] + assert call_data["offset_id"] == "offset_50" + + +@pytest.mark.asyncio +async def test_search_numbers_default_query(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_numbers.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), + ) as mock_request, + ): + result = await client.search_numbers() + + assert isinstance(result, NumbersResult) + call_data = mock_request.call_args[0][3] + assert call_data["query"] == "" + assert call_data["type"] == "numbers" diff --git a/tests/014_test_search.py b/tests/014_test_search.py deleted file mode 100644 index 5e837af..0000000 --- a/tests/014_test_search.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Unit tests for search_auctions — Fragment marketplace search.""" - -from unittest.mock import AsyncMock, patch - -import pytest - -from pyfragment import FragmentClient -from pyfragment.types import AuctionsResult -from tests.shared import FAKE_HASH - -FAKE_HTML = """ - - - - - -""" - - -@pytest.mark.asyncio -async def test_search_auctions_basic(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.search_auctions.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.search_auctions.fragment_request", - AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), - ), - ): - result = await client.search_auctions("coolname") - - assert isinstance(result, AuctionsResult) - assert len(result.items) == 1 - assert result.items[0]["slug"] == "username/coolname" - assert result.items[0]["name"] == "@coolname" - assert result.next_offset_id is None - - -@pytest.mark.asyncio -async def test_search_auctions_with_type(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.search_auctions.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.search_auctions.fragment_request", - AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_99"}), - ), - ): - result = await client.search_auctions("coolname", type="usernames") - - assert isinstance(result, AuctionsResult) - assert len(result.items) == 1 - assert result.next_offset_id == "offset_99" - - -@pytest.mark.asyncio -async def test_search_auctions_numbers(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.search_auctions.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.search_auctions.fragment_request", - AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), - ), - ): - result = await client.search_auctions("888", type="numbers") - - assert isinstance(result, AuctionsResult) - assert isinstance(result.items, list) - - -@pytest.mark.asyncio -async def test_search_auctions_empty_html(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.search_auctions.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.search_auctions.fragment_request", - AsyncMock(return_value={"ok": True}), - ), - ): - result = await client.search_auctions("zzz_no_results") - - assert isinstance(result, AuctionsResult) - assert result.items == [] - assert result.next_offset_id is None - - -@pytest.mark.asyncio -async def test_search_auctions_with_sort_and_filter(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.search_auctions.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.search_auctions.fragment_request", - AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), - ) as mock_request, - ): - result = await client.search_auctions("durov", type="usernames", sort="price_desc", filter="auction") - - assert isinstance(result, AuctionsResult) - call_data = mock_request.call_args[0][3] - assert call_data["sort"] == "price_desc" - assert call_data["filter"] == "auction" - assert call_data["type"] == "usernames" - assert call_data["query"] == "durov" - - -@pytest.mark.asyncio -async def test_search_auctions_with_offset_id(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.search_auctions.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.search_auctions.fragment_request", - AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), - ) as mock_request, - ): - result = await client.search_auctions("durov", type="usernames", offset_id="offset_10") - - assert isinstance(result, AuctionsResult) - call_data = mock_request.call_args[0][3] - assert call_data["offset_id"] == "offset_10" - assert call_data["type"] == "usernames" - assert call_data["query"] == "durov" diff --git a/tests/015_test_gifts.py b/tests/015_test_gifts.py new file mode 100644 index 0000000..475fb00 --- /dev/null +++ b/tests/015_test_gifts.py @@ -0,0 +1,188 @@ +"""Unit tests for search_gifts — Fragment gifts marketplace search.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import FragmentClient +from pyfragment.types import GiftsResult +from tests.shared import FAKE_HASH + +FAKE_GIFTS_HTML = """ + +""" + + +@pytest.mark.asyncio +async def test_search_gifts_basic(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_gifts.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}), + ), + ): + result = await client.search_gifts() + + assert isinstance(result, GiftsResult) + assert len(result.items) == 2 + assert result.items[0]["slug"] == "gift/plushpepe-1821" + assert result.items[0]["name"] == "Plush Pepe #1821" + assert result.items[0]["status"] == "Sold" + assert result.items[0]["price"] == "88888.00" + assert result.items[0]["date"] == "2026-02-05T14:41:27+00:00" + assert result.items[1]["slug"] == "gift/swisswatch-7799" + assert result.items[1]["price"] == "13588.00" + assert result.next_offset == 60 + + +@pytest.mark.asyncio +async def test_search_gifts_empty(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_gifts.fragment_request", + AsyncMock(return_value={"ok": True}), + ), + ): + result = await client.search_gifts(query="zzz_no_results") + + assert isinstance(result, GiftsResult) + assert result.items == [] + assert result.next_offset is None + + +@pytest.mark.asyncio +async def test_search_gifts_with_collection_and_sort(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_gifts.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}), + ) as mock_request, + ): + result = await client.search_gifts(collection="plushpepe", sort="price_desc", filter="sold") + + assert isinstance(result, GiftsResult) + call_data = mock_request.call_args[0][3] + assert call_data["collection"] == "plushpepe" + assert call_data["sort"] == "price_desc" + assert call_data["filter"] == "sold" + assert call_data["type"] == "gifts" + assert call_data["method"] == "searchAuctions" + + +@pytest.mark.asyncio +async def test_search_gifts_with_offset(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_gifts.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}), + ) as mock_request, + ): + result = await client.search_gifts(offset=60) + + assert isinstance(result, GiftsResult) + call_data = mock_request.call_args[0][3] + assert call_data["offset"] == 60 + + +@pytest.mark.asyncio +async def test_search_gifts_with_view(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_gifts.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}), + ) as mock_request, + ): + result = await client.search_gifts(collection="artisanbrick", view="Model") + + assert isinstance(result, GiftsResult) + call_data = mock_request.call_args[0][3] + assert call_data["view"] == "Model" + assert call_data["collection"] == "artisanbrick" + + +@pytest.mark.asyncio +async def test_search_gifts_with_attr(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_gifts.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}), + ) as mock_request, + ): + result = await client.search_gifts( + collection="artisanbrick", + sort="listed", + filter="auction", + attr={ + "Model": ["Delicate Wash", "Foosball", "Chocolate"], + "Backdrop": ["Celtic Blue", "Carrot Juice", "Orange"], + "Symbol": ["Crystal Ball", "Tetsubin", "Acorn"], + }, + ) + + assert isinstance(result, GiftsResult) + call_data = mock_request.call_args[0][3] + assert call_data["attr[Model]"] == ["Delicate Wash", "Foosball", "Chocolate"] + assert call_data["attr[Backdrop]"] == ["Celtic Blue", "Carrot Juice", "Orange"] + assert call_data["attr[Symbol]"] == ["Crystal Ball", "Tetsubin", "Acorn"] + assert call_data["collection"] == "artisanbrick" + assert call_data["sort"] == "listed" + assert call_data["filter"] == "auction" + assert call_data["type"] == "gifts" + + +@pytest.mark.asyncio +async def test_search_gifts_attr_not_in_data_when_none(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_gifts.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}), + ) as mock_request, + ): + result = await client.search_gifts() + + assert isinstance(result, GiftsResult) + call_data = mock_request.call_args[0][3] + assert "view" not in call_data + assert not any(k.startswith("attr[") for k in call_data) From 315616077835067abf887904985cb90026c5a5c3 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Wed, 25 Mar 2026 18:09:41 +0200 Subject: [PATCH 18/21] Refactor examples: reorganize and update scripts for managing Telegram services - Removed outdated example scripts for managing anonymous numbers, making raw API calls, fetching wallet info, and various giveaways. - Introduced new scripts for searching gifts, numbers, and usernames in the Fragment marketplace. - Added examples for recharging Telegram Ads accounts and managing user balances with TON. - Updated example scripts to improve clarity and maintainability, ensuring they align with the latest API changes. --- CHANGELOG.md | 58 ++++++++----------- examples/{ => auctions}/search_gifts.py | 8 ++- examples/{ => auctions}/search_numbers.py | 12 +++- examples/{ => auctions}/search_usernames.py | 12 +++- examples/{call.py => client/raw_api_call.py} | 0 .../{get_wallet.py => client/wallet_info.py} | 0 .../manage_number.py} | 13 ++++- .../recharge_ads_balance.py} | 15 ++++- .../run_premium_giveaway.py} | 8 ++- .../run_stars_giveaway.py} | 8 ++- .../send_premium.py} | 12 +++- .../send_stars.py} | 12 +++- .../topup_ton_balance.py} | 15 ++++- 13 files changed, 113 insertions(+), 60 deletions(-) rename examples/{ => auctions}/search_gifts.py (82%) rename examples/{ => auctions}/search_numbers.py (73%) rename examples/{ => auctions}/search_usernames.py (77%) rename examples/{call.py => client/raw_api_call.py} (100%) rename examples/{get_wallet.py => client/wallet_info.py} (100%) rename examples/{anonymous_number.py => numbers/manage_number.py} (71%) rename examples/{recharge_ads.py => purchase/recharge_ads_balance.py} (78%) rename examples/{giveaway_premium.py => purchase/run_premium_giveaway.py} (80%) rename examples/{giveaway_stars.py => purchase/run_stars_giveaway.py} (80%) rename examples/{purchase_premium.py => purchase/send_premium.py} (68%) rename examples/{purchase_stars.py => purchase/send_stars.py} (69%) rename examples/{topup_ton.py => purchase/topup_ton_balance.py} (75%) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1a0934..16c7559 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,51 +12,43 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI ### Added **Giveaways** -- `giveaway_stars(channel, winners, amount)` — run a Telegram Stars giveaway for a channel (1–5 winners, 500–1 000 000 stars each); raises `UserNotFoundError` if the channel is not found on Fragment, `ConfigurationError` if `winners` or `amount` are out of range -- `giveaway_premium(channel, winners, months)` — run a Telegram Premium giveaway for a channel (1–24 000 winners, 3/6/12 months each); raises `UserNotFoundError` if the channel is not found on Fragment, `ConfigurationError` if `winners` or `months` are invalid -- `StarsGiveawayResult` and `PremiumGiveawayResult` result types +- `giveaway_stars(channel, winners, amount)` — Stars giveaway; 1–5 winners, 500–1 000 000 stars each +- `giveaway_premium(channel, winners, months)` — Premium giveaway; 1–24 000 winners, 3/6/12 months each +- `StarsGiveawayResult`, `PremiumGiveawayResult` result types **Telegram Ads** -- `recharge_ads(account, amount)` — add funds to your own Telegram Ads account; `account` is the channel or bot username linked to your Ads account; raises `ConfigurationError` if `amount` is out of range (1–1 000 000 000 TON) +- `recharge_ads(account, amount)` — top up a Telegram Ads account; 1–1 000 000 000 TON - `AdsRechargeResult` result type **Marketplace** -- `search_usernames(query?, sort?, filter?, offset_id?)` — search the Fragment marketplace for Telegram usernames; `sort` is one of `"price_desc"`, `"price_asc"`, `"listed"`, `"ending"`; `filter` is one of `""`, `"auction"`, `"sale"`, `"sold"`; supports pagination via `offset_id`; returns parsed item dicts with `slug`, `name`, `status`, `price`, `date` -- `search_numbers(query?, sort?, filter?, offset_id?)` — search the Fragment marketplace for anonymous Telegram numbers; same `sort` / `filter` / `offset_id` semantics as `search_usernames` -- `search_gifts(query?, collection?, sort?, filter?, view?, attr?, offset?)` — search the Fragment gifts marketplace; `collection` is the gift collection slug (e.g. `"artisanbrick"`); `attr` accepts trait filters as `{"Model": ["Foosball"], "Backdrop": ["Celtic Blue"]}`; supports integer-based pagination via `offset`; returns parsed item dicts with `slug`, `name`, `status`, `price`, `date` +- `search_usernames(query?, sort?, filter?, offset_id?)` — search Fragment usernames; `sort`: `price_desc / price_asc / listed / ending`, `filter`: `auction / sale / sold` +- `search_numbers(query?, sort?, filter?, offset_id?)` — search Fragment anonymous numbers; same `sort` / `filter` / pagination semantics +- `search_gifts(query?, collection?, sort?, filter?, view?, attr?, offset?)` — search Fragment gifts; `attr` accepts `{"Model": ["Foosball"], "Backdrop": ["Celtic Blue"]}` - `UsernamesResult`, `NumbersResult`, `GiftsResult` result types -**Raw API access** -- `FragmentClient.call(method, data, *, page_url)` — send a raw request to any Fragment API method without waiting for a library update -- `FRAGMENT_BASE_URL` constant — single source of truth for the Fragment base URL used across all page constants and headers +**Anonymous numbers** +- `get_login_code(number)` — fetch the current pending login code +- `toggle_login_codes(number, can_receive)` — enable or disable login code delivery +- `terminate_sessions(number)` — terminate all active Telegram sessions (two-step flow handled internally) +- `LoginCodeResult`, `TerminateSessionsResult` result types; `AnonymousNumberError` exception -**Anonymous number management** -- `get_login_code(number)` — fetch the current pending login code for an anonymous number -- `toggle_login_codes(number, can_receive)` — enable or disable login code delivery for an anonymous number -- `terminate_sessions(number)` — terminate all active Telegram sessions for an anonymous number (two-step flow handled internally) -- `LoginCodeResult` and `TerminateSessionsResult` result types -- `AnonymousNumberError` exception — raised when a number is not owned or session termination fails +**Raw API** +- `FragmentClient.call(method, data, *, page_url)` — raw request to any Fragment API method +- `FRAGMENT_BASE_URL` constant — base URL shared across all page constants and headers **Examples** -- `examples/giveaway_stars.py` — Stars giveaway with `UserNotFoundError` / `ConfigurationError` handling -- `examples/giveaway_premium.py` — Premium giveaway with `UserNotFoundError` / `ConfigurationError` handling -- `examples/call.py` — raw API call via `client.call()` -- `examples/anonymous_number.py` — login code fetch and session termination with `AnonymousNumberError` handling -- `examples/recharge_ads.py` — self-service Ads recharge with `ConfigurationError` / `WalletError` handling -- `examples/search_usernames.py` — username marketplace search with pagination -- `examples/search_numbers.py` — number marketplace search with pagination -- `examples/search_gifts.py` — gifts marketplace search with collection, attr, and pagination +- `examples/client/` — `wallet_info.py` (wallet info), `raw_api_call.py` (raw API call) +- `examples/numbers/` — `manage_number.py` (login code fetch, session termination) +- `examples/auctions/` — `search_usernames.py`, `search_numbers.py`, `search_gifts.py` (marketplace search with pagination) +- `examples/purchase/` — `send_stars.py`, `send_premium.py`, `topup_ton_balance.py`, `run_stars_giveaway.py`, `run_premium_giveaway.py`, `recharge_ads_balance.py` ### Changed -- All result types (`PremiumResult`, `StarsResult`, `StarsGiveawayResult`, `PremiumGiveawayResult`) now use a single unified `amount` field instead of `months`, `stars` — consistent API across every method -- `__repr__` on result types now includes the unit (`3 months`, `500 stars`) for clarity -- Method module files renamed to match their function: `premium.py` → `purchase_premium.py`, `stars.py` → `purchase_stars.py`, `ton.py` → `topup_ton.py` -- `timestamp` field removed from all result dataclasses -- All page URL constants (`STARS_PAGE`, `PREMIUM_PAGE`, etc.) now built from `FRAGMENT_BASE_URL` instead of hardcoded strings -- `STARS_REVENUE_PAGE` and `ADS_PAGE` constants replaced by a single `ADS_TOPUP_PAGE` shared by both `topup_ton` and `recharge_ads` -- `TransactionError` now includes an SSL hint when a broadcast fails due to certificate verification errors -- `TransactionError.DUPLICATE_SEQNO` — dedicated error raised after 3 failed attempts due to a `406 Duplicate msg_seqno` response from the TON network; broadcast is automatically retried (up to 2 retries, 2 s apart) before giving up -- All error message templates rewritten to follow a "what happened → why → what to do" pattern — every template is now actionable and includes a fix hint where applicable +- All result types now expose a unified `amount` field (`months` and `stars` removed) +- `__repr__` includes the unit — `3 months`, `500 stars`, etc. +- `timestamp` removed from all result dataclasses +- All page URL constants built from `FRAGMENT_BASE_URL`; +- `TransactionError` includes an SSL hint; `DUPLICATE_SEQNO` variant auto-retried up to 2 times (2 s apart) +- Error messages rewritten: "what happened → why → what to do" --- diff --git a/examples/search_gifts.py b/examples/auctions/search_gifts.py similarity index 82% rename from examples/search_gifts.py rename to examples/auctions/search_gifts.py index a6d63a9..ec68a03 100644 --- a/examples/search_gifts.py +++ b/examples/auctions/search_gifts.py @@ -28,12 +28,16 @@ FILTER = "" # "", "auction", "sale", "sold" — or omit async def main() -> None: async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: - result: GiftsResult = await client.search_gifts(QUERY, collection=COLLECTION, sort=SORT, filter=FILTER) + result: GiftsResult = await client.search_gifts( + QUERY, collection=COLLECTION, sort=SORT, filter=FILTER + ) print(f"Found {len(result.items)} result(s):") for item in result.items: price = f"{item['price']} TON" if item["price"] else "n/a" - print(f" {item['name']:30s} {item['status'] or '':15s} {price:12s} {item['date'] or '—'}") + print( + f" {item['name']:30s} {item['status'] or '':15s} {price:12s} {item['date'] or '—'}" + ) if result.next_offset: print(f"\nMore results available — next page offset: {result.next_offset}") diff --git a/examples/search_numbers.py b/examples/auctions/search_numbers.py similarity index 73% rename from examples/search_numbers.py rename to examples/auctions/search_numbers.py index c24a18e..37e80fb 100644 --- a/examples/search_numbers.py +++ b/examples/auctions/search_numbers.py @@ -26,15 +26,21 @@ FILTER = "" # "", "auction", "sale", "sold" — or omit async def main() -> None: async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: - result: NumbersResult = await client.search_numbers(QUERY, sort=SORT, filter=FILTER) + result: NumbersResult = await client.search_numbers( + QUERY, sort=SORT, filter=FILTER + ) print(f"Found {len(result.items)} result(s):") for item in result.items: price = f"{item['price']} TON" if item["price"] else "n/a" - print(f" {item['name']:20s} {item['status'] or '':15s} {price:10s} {item['date'] or '—'}") + print( + f" {item['name']:20s} {item['status'] or '':15s} {price:10s} {item['date'] or '—'}" + ) if result.next_offset_id: - print(f"\nMore results available — next page offset: {result.next_offset_id}") + print( + f"\nMore results available — next page offset: {result.next_offset_id}" + ) if __name__ == "__main__": diff --git a/examples/search_usernames.py b/examples/auctions/search_usernames.py similarity index 77% rename from examples/search_usernames.py rename to examples/auctions/search_usernames.py index c139785..c71b6fe 100644 --- a/examples/search_usernames.py +++ b/examples/auctions/search_usernames.py @@ -26,15 +26,21 @@ FILTER = "auction" # "", "auction", "sale", "sold" — or omit async def main() -> None: async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: - result: UsernamesResult = await client.search_usernames(QUERY, sort=SORT, filter=FILTER) + result: UsernamesResult = await client.search_usernames( + QUERY, sort=SORT, filter=FILTER + ) print(f"Found {len(result.items)} result(s):") for item in result.items: price = f"{item['price']} TON" if item["price"] else "n/a" - print(f" {item['name']:20s} {item['status'] or '':15s} {price:10s} {item['date'] or '—'}") + print( + f" {item['name']:20s} {item['status'] or '':15s} {price:10s} {item['date'] or '—'}" + ) if result.next_offset_id: - print(f"\nMore results available — next page offset: {result.next_offset_id}") + print( + f"\nMore results available — next page offset: {result.next_offset_id}" + ) if __name__ == "__main__": diff --git a/examples/call.py b/examples/client/raw_api_call.py similarity index 100% rename from examples/call.py rename to examples/client/raw_api_call.py diff --git a/examples/get_wallet.py b/examples/client/wallet_info.py similarity index 100% rename from examples/get_wallet.py rename to examples/client/wallet_info.py diff --git a/examples/anonymous_number.py b/examples/numbers/manage_number.py similarity index 71% rename from examples/anonymous_number.py rename to examples/numbers/manage_number.py index 3355b22..e9f60a4 100644 --- a/examples/anonymous_number.py +++ b/examples/numbers/manage_number.py @@ -27,14 +27,21 @@ async def main() -> None: # Fetch the latest login code result = await client.get_login_code(NUMBER) if result.code: - print(f"Login code for {result.number}: {result.code} ({result.active_sessions} active session(s))") + print( + f"Login code for {result.number}: {result.code} ({result.active_sessions} active session(s))" + ) else: - print(f"No pending login code for {result.number} ({result.active_sessions} active session(s))") + print( + f"No pending login code for {result.number} ({result.active_sessions} active session(s))" + ) # Terminate all active sessions try: terminated = await client.terminate_sessions(NUMBER) - print(f"Sessions terminated for {terminated.number}" + (f": {terminated.message}" if terminated.message else "")) + print( + f"Sessions terminated for {terminated.number}" + + (f": {terminated.message}" if terminated.message else "") + ) except AnonymousNumberError as e: print(f"Could not terminate sessions: {e}") diff --git a/examples/recharge_ads.py b/examples/purchase/recharge_ads_balance.py similarity index 78% rename from examples/recharge_ads.py rename to examples/purchase/recharge_ads_balance.py index 50d0a44..3b02de2 100644 --- a/examples/recharge_ads.py +++ b/examples/purchase/recharge_ads_balance.py @@ -7,7 +7,12 @@ Your wallet must hold at least the recharge amount + ~0.056 TON for gas. import asyncio -from pyfragment import AdsRechargeResult, ConfigurationError, FragmentClient, WalletError +from pyfragment import ( + AdsRechargeResult, + ConfigurationError, + FragmentClient, + WalletError, +) SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" @@ -25,7 +30,9 @@ AMOUNT = 10 # 1–1 000 000 000 TON async def main() -> None: async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: try: - result: AdsRechargeResult = await client.recharge_ads(ACCOUNT, amount=AMOUNT) + result: AdsRechargeResult = await client.recharge_ads( + ACCOUNT, amount=AMOUNT + ) except WalletError as e: print(f"Wallet error — insufficient balance or misconfiguration: {e}") return @@ -33,7 +40,9 @@ async def main() -> None: print(f"Invalid argument: {e}") return - print(f"{result.amount} TON recharged to Ads account {ACCOUNT} | tx: {result.transaction_id}") + print( + f"{result.amount} TON recharged to Ads account {ACCOUNT} | tx: {result.transaction_id}" + ) if __name__ == "__main__": diff --git a/examples/giveaway_premium.py b/examples/purchase/run_premium_giveaway.py similarity index 80% rename from examples/giveaway_premium.py rename to examples/purchase/run_premium_giveaway.py index e2a5818..3a88f26 100644 --- a/examples/giveaway_premium.py +++ b/examples/purchase/run_premium_giveaway.py @@ -26,9 +26,13 @@ MONTHS = 3 # 3, 6 or 12 async def main() -> None: async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: try: - result = await client.giveaway_premium(CHANNEL, winners=WINNERS, months=MONTHS) + result = await client.giveaway_premium( + CHANNEL, winners=WINNERS, months=MONTHS + ) except UserNotFoundError: - print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.") + print( + f"Channel {CHANNEL} was not found on fragment.com — check the username and try again." + ) return except ConfigurationError as e: print(f"Invalid argument: {e}") diff --git a/examples/giveaway_stars.py b/examples/purchase/run_stars_giveaway.py similarity index 80% rename from examples/giveaway_stars.py rename to examples/purchase/run_stars_giveaway.py index 400a707..19627d3 100644 --- a/examples/giveaway_stars.py +++ b/examples/purchase/run_stars_giveaway.py @@ -26,9 +26,13 @@ AMOUNT = 1000 # 500–1 000 000 stars per winner async def main() -> None: async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: try: - result = await client.giveaway_stars(CHANNEL, winners=WINNERS, amount=AMOUNT) + result = await client.giveaway_stars( + CHANNEL, winners=WINNERS, amount=AMOUNT + ) except UserNotFoundError: - print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.") + print( + f"Channel {CHANNEL} was not found on fragment.com — check the username and try again." + ) return except ConfigurationError as e: print(f"Invalid argument: {e}") diff --git a/examples/purchase_premium.py b/examples/purchase/send_premium.py similarity index 68% rename from examples/purchase_premium.py rename to examples/purchase/send_premium.py index fc90f3b..a2d571e 100644 --- a/examples/purchase_premium.py +++ b/examples/purchase/send_premium.py @@ -25,15 +25,21 @@ MONTHS = 3 # 3, 6 or 12 async def main() -> None: async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: try: - result = await client.purchase_premium(USERNAME, months=MONTHS, show_sender=True) + result = await client.purchase_premium( + USERNAME, months=MONTHS, show_sender=True + ) except UserNotFoundError: - print(f"User {USERNAME} was not found on fragment.com — check the username and try again.") + print( + f"User {USERNAME} was not found on fragment.com — check the username and try again." + ) return except ConfigurationError as e: print(f"Invalid argument: {e}") return - print(f"{result.amount} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}") + print( + f"{result.amount} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}" + ) if __name__ == "__main__": diff --git a/examples/purchase_stars.py b/examples/purchase/send_stars.py similarity index 69% rename from examples/purchase_stars.py rename to examples/purchase/send_stars.py index 594f883..5106732 100644 --- a/examples/purchase_stars.py +++ b/examples/purchase/send_stars.py @@ -25,15 +25,21 @@ AMOUNT = 500 # 50–1 000 000 stars async def main() -> None: async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: try: - result = await client.purchase_stars(USERNAME, amount=AMOUNT, show_sender=True) + result = await client.purchase_stars( + USERNAME, amount=AMOUNT, show_sender=True + ) except UserNotFoundError: - print(f"User {USERNAME} was not found on fragment.com — check the username and try again.") + print( + f"User {USERNAME} was not found on fragment.com — check the username and try again." + ) return except ConfigurationError as e: print(f"Invalid argument: {e}") return - print(f"{result.amount} Stars successfully sent to {result.username} | tx: {result.transaction_id}") + print( + f"{result.amount} Stars successfully sent to {result.username} | tx: {result.transaction_id}" + ) if __name__ == "__main__": diff --git a/examples/topup_ton.py b/examples/purchase/topup_ton_balance.py similarity index 75% rename from examples/topup_ton.py rename to examples/purchase/topup_ton_balance.py index e614d58..12ce171 100644 --- a/examples/topup_ton.py +++ b/examples/purchase/topup_ton_balance.py @@ -9,7 +9,12 @@ Your wallet must hold at least the topup amount + ~0.056 TON for gas. import asyncio -from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError, WalletError +from pyfragment import ( + ConfigurationError, + FragmentClient, + UserNotFoundError, + WalletError, +) SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" @@ -29,7 +34,9 @@ async def main() -> None: try: result = await client.topup_ton(USERNAME, amount=AMOUNT, show_sender=True) except UserNotFoundError: - print(f"User {USERNAME} was not found on fragment.com — check the username and try again.") + print( + f"User {USERNAME} was not found on fragment.com — check the username and try again." + ) return except WalletError as e: print(f"Wallet error — insufficient balance or misconfiguration: {e}") @@ -38,7 +45,9 @@ async def main() -> None: print(f"Invalid argument: {e}") return - print(f"{result.amount} TON successfully topped up for {result.username} | tx: {result.transaction_id}") + print( + f"{result.amount} TON successfully topped up for {result.username} | tx: {result.transaction_id}" + ) if __name__ == "__main__": From da2eb37a107fe84d70852f5324d7769a7aceef2b Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Wed, 25 Mar 2026 18:18:40 +0200 Subject: [PATCH 19/21] chore: update examples and README for improved clarity and consistency; streamline print statements and descriptions --- README.md | 2 +- examples/auctions/search_gifts.py | 11 +++-------- examples/auctions/search_numbers.py | 15 ++++----------- examples/auctions/search_usernames.py | 15 ++++----------- examples/numbers/manage_number.py | 13 +++---------- examples/purchase/recharge_ads_balance.py | 8 ++------ examples/purchase/run_premium_giveaway.py | 8 ++------ examples/purchase/run_stars_giveaway.py | 8 ++------ examples/purchase/send_premium.py | 12 +++--------- examples/purchase/send_stars.py | 12 +++--------- examples/purchase/topup_ton_balance.py | 8 ++------ pyproject.toml | 2 +- 12 files changed, 30 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index adc6204..7ab264c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

Fragment API

- Async Python client for the Fragment.com API — buy Telegram Stars and Premium, run giveaways, top up TON, Ads balance, manage anonymous numbers, and search the marketplace for usernames, numbers, and gifts. + Async Python client for the Fragment API — a unified toolkit to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and explore the marketplace for usernames, numbers, and gifts.

[![PyPI version](https://img.shields.io/pypi/v/pyfragment?style=flat&color=blue)](https://pypi.org/project/pyfragment/) diff --git a/examples/auctions/search_gifts.py b/examples/auctions/search_gifts.py index ec68a03..70ad338 100644 --- a/examples/auctions/search_gifts.py +++ b/examples/auctions/search_gifts.py @@ -8,6 +8,7 @@ Use next_offset for pagination. """ import asyncio +import json from pyfragment import FragmentClient, GiftsResult @@ -28,16 +29,10 @@ FILTER = "" # "", "auction", "sale", "sold" — or omit async def main() -> None: async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: - result: GiftsResult = await client.search_gifts( - QUERY, collection=COLLECTION, sort=SORT, filter=FILTER - ) + result: GiftsResult = await client.search_gifts(QUERY, collection=COLLECTION, sort=SORT, filter=FILTER) print(f"Found {len(result.items)} result(s):") - for item in result.items: - price = f"{item['price']} TON" if item["price"] else "n/a" - print( - f" {item['name']:30s} {item['status'] or '':15s} {price:12s} {item['date'] or '—'}" - ) + print(json.dumps(result.items, indent=2)) if result.next_offset: print(f"\nMore results available — next page offset: {result.next_offset}") diff --git a/examples/auctions/search_numbers.py b/examples/auctions/search_numbers.py index 37e80fb..9a98a27 100644 --- a/examples/auctions/search_numbers.py +++ b/examples/auctions/search_numbers.py @@ -7,6 +7,7 @@ Use next_offset_id for pagination. """ import asyncio +import json from pyfragment import FragmentClient, NumbersResult @@ -26,21 +27,13 @@ FILTER = "" # "", "auction", "sale", "sold" — or omit async def main() -> None: async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: - result: NumbersResult = await client.search_numbers( - QUERY, sort=SORT, filter=FILTER - ) + result: NumbersResult = await client.search_numbers(QUERY, sort=SORT, filter=FILTER) print(f"Found {len(result.items)} result(s):") - for item in result.items: - price = f"{item['price']} TON" if item["price"] else "n/a" - print( - f" {item['name']:20s} {item['status'] or '':15s} {price:10s} {item['date'] or '—'}" - ) + print(json.dumps(result.items, indent=2)) if result.next_offset_id: - print( - f"\nMore results available — next page offset: {result.next_offset_id}" - ) + print(f"\nMore results available — next page offset: {result.next_offset_id}") if __name__ == "__main__": diff --git a/examples/auctions/search_usernames.py b/examples/auctions/search_usernames.py index c71b6fe..68e5ea1 100644 --- a/examples/auctions/search_usernames.py +++ b/examples/auctions/search_usernames.py @@ -7,6 +7,7 @@ Use next_offset_id for pagination. """ import asyncio +import json from pyfragment import FragmentClient, UsernamesResult @@ -26,21 +27,13 @@ FILTER = "auction" # "", "auction", "sale", "sold" — or omit async def main() -> None: async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: - result: UsernamesResult = await client.search_usernames( - QUERY, sort=SORT, filter=FILTER - ) + result: UsernamesResult = await client.search_usernames(QUERY, sort=SORT, filter=FILTER) print(f"Found {len(result.items)} result(s):") - for item in result.items: - price = f"{item['price']} TON" if item["price"] else "n/a" - print( - f" {item['name']:20s} {item['status'] or '':15s} {price:10s} {item['date'] or '—'}" - ) + print(json.dumps(result.items, indent=2)) if result.next_offset_id: - print( - f"\nMore results available — next page offset: {result.next_offset_id}" - ) + print(f"\nMore results available — next page offset: {result.next_offset_id}") if __name__ == "__main__": diff --git a/examples/numbers/manage_number.py b/examples/numbers/manage_number.py index e9f60a4..3355b22 100644 --- a/examples/numbers/manage_number.py +++ b/examples/numbers/manage_number.py @@ -27,21 +27,14 @@ async def main() -> None: # Fetch the latest login code result = await client.get_login_code(NUMBER) if result.code: - print( - f"Login code for {result.number}: {result.code} ({result.active_sessions} active session(s))" - ) + print(f"Login code for {result.number}: {result.code} ({result.active_sessions} active session(s))") else: - print( - f"No pending login code for {result.number} ({result.active_sessions} active session(s))" - ) + print(f"No pending login code for {result.number} ({result.active_sessions} active session(s))") # Terminate all active sessions try: terminated = await client.terminate_sessions(NUMBER) - print( - f"Sessions terminated for {terminated.number}" - + (f": {terminated.message}" if terminated.message else "") - ) + print(f"Sessions terminated for {terminated.number}" + (f": {terminated.message}" if terminated.message else "")) except AnonymousNumberError as e: print(f"Could not terminate sessions: {e}") diff --git a/examples/purchase/recharge_ads_balance.py b/examples/purchase/recharge_ads_balance.py index 3b02de2..6ec6121 100644 --- a/examples/purchase/recharge_ads_balance.py +++ b/examples/purchase/recharge_ads_balance.py @@ -30,9 +30,7 @@ AMOUNT = 10 # 1–1 000 000 000 TON async def main() -> None: async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: try: - result: AdsRechargeResult = await client.recharge_ads( - ACCOUNT, amount=AMOUNT - ) + result: AdsRechargeResult = await client.recharge_ads(ACCOUNT, amount=AMOUNT) except WalletError as e: print(f"Wallet error — insufficient balance or misconfiguration: {e}") return @@ -40,9 +38,7 @@ async def main() -> None: print(f"Invalid argument: {e}") return - print( - f"{result.amount} TON recharged to Ads account {ACCOUNT} | tx: {result.transaction_id}" - ) + print(f"{result.amount} TON recharged to Ads account {ACCOUNT} | tx: {result.transaction_id}") if __name__ == "__main__": diff --git a/examples/purchase/run_premium_giveaway.py b/examples/purchase/run_premium_giveaway.py index 3a88f26..e2a5818 100644 --- a/examples/purchase/run_premium_giveaway.py +++ b/examples/purchase/run_premium_giveaway.py @@ -26,13 +26,9 @@ MONTHS = 3 # 3, 6 or 12 async def main() -> None: async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: try: - result = await client.giveaway_premium( - CHANNEL, winners=WINNERS, months=MONTHS - ) + result = await client.giveaway_premium(CHANNEL, winners=WINNERS, months=MONTHS) except UserNotFoundError: - print( - f"Channel {CHANNEL} was not found on fragment.com — check the username and try again." - ) + print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.") return except ConfigurationError as e: print(f"Invalid argument: {e}") diff --git a/examples/purchase/run_stars_giveaway.py b/examples/purchase/run_stars_giveaway.py index 19627d3..400a707 100644 --- a/examples/purchase/run_stars_giveaway.py +++ b/examples/purchase/run_stars_giveaway.py @@ -26,13 +26,9 @@ AMOUNT = 1000 # 500–1 000 000 stars per winner async def main() -> None: async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: try: - result = await client.giveaway_stars( - CHANNEL, winners=WINNERS, amount=AMOUNT - ) + result = await client.giveaway_stars(CHANNEL, winners=WINNERS, amount=AMOUNT) except UserNotFoundError: - print( - f"Channel {CHANNEL} was not found on fragment.com — check the username and try again." - ) + print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.") return except ConfigurationError as e: print(f"Invalid argument: {e}") diff --git a/examples/purchase/send_premium.py b/examples/purchase/send_premium.py index a2d571e..fc90f3b 100644 --- a/examples/purchase/send_premium.py +++ b/examples/purchase/send_premium.py @@ -25,21 +25,15 @@ MONTHS = 3 # 3, 6 or 12 async def main() -> None: async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: try: - result = await client.purchase_premium( - USERNAME, months=MONTHS, show_sender=True - ) + result = await client.purchase_premium(USERNAME, months=MONTHS, show_sender=True) except UserNotFoundError: - print( - f"User {USERNAME} was not found on fragment.com — check the username and try again." - ) + print(f"User {USERNAME} was not found on fragment.com — check the username and try again.") return except ConfigurationError as e: print(f"Invalid argument: {e}") return - print( - f"{result.amount} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}" - ) + print(f"{result.amount} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}") if __name__ == "__main__": diff --git a/examples/purchase/send_stars.py b/examples/purchase/send_stars.py index 5106732..594f883 100644 --- a/examples/purchase/send_stars.py +++ b/examples/purchase/send_stars.py @@ -25,21 +25,15 @@ AMOUNT = 500 # 50–1 000 000 stars async def main() -> None: async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: try: - result = await client.purchase_stars( - USERNAME, amount=AMOUNT, show_sender=True - ) + result = await client.purchase_stars(USERNAME, amount=AMOUNT, show_sender=True) except UserNotFoundError: - print( - f"User {USERNAME} was not found on fragment.com — check the username and try again." - ) + print(f"User {USERNAME} was not found on fragment.com — check the username and try again.") return except ConfigurationError as e: print(f"Invalid argument: {e}") return - print( - f"{result.amount} Stars successfully sent to {result.username} | tx: {result.transaction_id}" - ) + print(f"{result.amount} Stars successfully sent to {result.username} | tx: {result.transaction_id}") if __name__ == "__main__": diff --git a/examples/purchase/topup_ton_balance.py b/examples/purchase/topup_ton_balance.py index 12ce171..10681ad 100644 --- a/examples/purchase/topup_ton_balance.py +++ b/examples/purchase/topup_ton_balance.py @@ -34,9 +34,7 @@ async def main() -> None: try: result = await client.topup_ton(USERNAME, amount=AMOUNT, show_sender=True) except UserNotFoundError: - print( - f"User {USERNAME} was not found on fragment.com — check the username and try again." - ) + print(f"User {USERNAME} was not found on fragment.com — check the username and try again.") return except WalletError as e: print(f"Wallet error — insufficient balance or misconfiguration: {e}") @@ -45,9 +43,7 @@ async def main() -> None: print(f"Invalid argument: {e}") return - print( - f"{result.amount} TON successfully topped up for {result.username} | tx: {result.transaction_id}" - ) + print(f"{result.amount} TON successfully topped up for {result.username} | tx: {result.transaction_id}") if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 47c9cbb..d015d8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "pyfragment" version = "2026.0.2" -description = "Async Python client for the Fragment.com API — buy Telegram Stars and Premium, run giveaways, top up TON Ads balance, and manage anonymous numbers." +description = "Async Python client for the Fragment API — a unified toolkit to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and explore the marketplace for usernames, numbers, and gifts." readme = "README.md" license = { text = "MIT" } requires-python = ">=3.12" From 3c60bf5e5aebc2edc254794dc7b3403a2ed1e632 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Wed, 25 Mar 2026 18:22:25 +0200 Subject: [PATCH 20/21] feat: add unit tests for search_usernames, search_numbers, and search_gifts methods in FragmentClient --- tests/{013_test_usernames.py => 012_test_usernames.py} | 0 tests/{014_test_numbers.py => 013_test_numbers.py} | 0 tests/{015_test_gifts.py => 014_test_gifts.py} | 0 tests/shared.py | 7 ++++--- 4 files changed, 4 insertions(+), 3 deletions(-) rename tests/{013_test_usernames.py => 012_test_usernames.py} (100%) rename tests/{014_test_numbers.py => 013_test_numbers.py} (100%) rename tests/{015_test_gifts.py => 014_test_gifts.py} (100%) diff --git a/tests/013_test_usernames.py b/tests/012_test_usernames.py similarity index 100% rename from tests/013_test_usernames.py rename to tests/012_test_usernames.py diff --git a/tests/014_test_numbers.py b/tests/013_test_numbers.py similarity index 100% rename from tests/014_test_numbers.py rename to tests/013_test_numbers.py diff --git a/tests/015_test_gifts.py b/tests/014_test_gifts.py similarity index 100% rename from tests/015_test_gifts.py rename to tests/014_test_gifts.py diff --git a/tests/shared.py b/tests/shared.py index ee20584..1ce6be4 100644 --- a/tests/shared.py +++ b/tests/shared.py @@ -1,8 +1,9 @@ """Shared test constants for the pyfragment test suite. -pyfragment is an async Python client for Fragment.com — purchase Telegram Premium -and Stars, run Stars and Premium giveaways for channels, top up TON Ads balance, -and manage anonymous numbers, all through a clean typed API. +pyfragment is an async Python client for the Fragment API — a unified toolkit +to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances, +run giveaways, manage anonymous numbers, and explore the marketplace for usernames, +numbers, and gifts. """ from typing import Any From fb0b82e429c49fe3501f618d0d08cb7347395674 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Wed, 25 Mar 2026 18:23:21 +0200 Subject: [PATCH 21/21] chore: update version to 2026.1.0 in pyproject.toml and finalize changelog --- CHANGELOG.md | 3 ++- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16c7559..84e7598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI --- -## [Unreleased] +## [2026.1.0] — 2026-03-25 ### Added @@ -83,5 +83,6 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI - `py.typed` marker — full PEP 561 typing support for type-checkers - `__repr__` on all result types for readable debug output +[2026.1.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.1.0 [2026.0.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.0.2 [2026.0.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.0.1 diff --git a/pyproject.toml b/pyproject.toml index d015d8b..753fb5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pyfragment" -version = "2026.0.2" +version = "2026.1.0" description = "Async Python client for the Fragment API — a unified toolkit to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and explore the marketplace for usernames, numbers, and gifts." readme = "README.md" license = { text = "MIT" }
+
@coolname
+
On auction
+
5
+ +
On auction
+
@coolname
+
On auction
+
5
+ +
On auction
+
+888 0000 888
+
For sale
+
150
+ +
For sale
-
@coolname
-
On auction
-
5
- -
On auction