54 Commits

Author SHA1 Message Date
Bohdan 👾 3d0cf38f13 Merge pull request #26 from bohd4nx/dev
Dev
2026-05-29 01:58:54 +03:00
bohd4nx 5c472b68a2 docs: update changelog for version 2026.3.1 with new features, changes, fixes, and removals 2026-05-29 01:53:57 +03:00
bohd4nx b2d06a7f22 feat: add support for Python 3.13 and 3.14 in classifiers; update mypy overrides 2026-05-29 01:50:24 +03:00
bohd4nx 34ee31e299 style: format YAML files for consistency and remove unused environment variables 2026-05-29 01:43:05 +03:00
bohd4nx c1ff78c484 docs: clarify seed parameter description in FragmentClient 2026-05-29 01:37:49 +03:00
bohd4nx f8ad801e0c feat: enhance documentation with contributing guidelines and security policy
refactor: update README configuration parameters and add error handling section
refactor: streamline tonapi imports and add WalletVersion to __all__
chore: update project metadata in pyproject.toml
2026-05-29 01:27:29 +03:00
bohd4nx 6cb6e3fe05 refactor: remove unused transfer functions and streamline imports in wallet and models 2026-05-29 01:17:57 +03:00
bohd4nx d4e3d3491b chore: bump version to 2026.3.1 in pyproject.toml 2026-05-29 01:04:51 +03:00
bohd4nx 4a7131acdb refactor: update patch targets in test cases to use domains.base module 2026-05-29 01:04:51 +03:00
bohd4nx 546bcb337c feat: implement logging throughout the application and enhance error handling 2026-05-29 01:04:51 +03:00
Bohdan 👾 5f8197b710 Merge pull request #25 from bohd4nx/refactor/domain-oriented-sdk
Refactor/domain oriented sdk
2026-05-21 17:19:25 +03:00
bohd4nx e9dd706fa6 refactor: add documentation link to README and pyproject.toml 2026-05-21 17:16:19 +03:00
bohd4nx f8ac7f9594 refactor: streamline rookiepy import and enhance module loading 2026-05-21 00:29:52 +03:00
bohd4nx 3f5e0bc03f chore: update version to 2026.3.0 and enhance changelog with recent changes 2026-05-21 00:23:25 +03:00
bohd4nx 92384b96f0 refactor: remove unused import of get_cookies_from_browser across multiple example scripts 2026-05-21 00:21:41 +03:00
bohd4nx 516e69f820 refactor: improve exception messages and enhance CookieResult representation 2026-05-20 23:50:03 +03:00
bohd4nx 2a6c5ef1f9 refactor: reorganize tonapi module and update imports for account management 2026-05-20 23:45:27 +03:00
bohd4nx 01a5befd87 Refactor wallet transaction handling and introduce tonapi module
- Removed wallet transaction and transfer logic from the wallet domain.
- Introduced a new tonapi module to handle transactions and balance checks.
- Updated tests to reflect the new structure and ensure functionality remains intact.
- Added functionality for sending TON and USDT transfers through the tonapi module.
- Improved error handling and validation for payment balances.
- Cleaned up and organized imports across the codebase.
2026-05-20 23:42:24 +03:00
bohd4nx ae164d4fe7 feat: Implement marketplace and purchases services
- Added MarketplaceService for searching usernames, numbers, and gifts.
- Introduced PurchasesService for purchasing stars and premium subscriptions.
- Created WalletService for wallet operations including balance checks and top-ups.
- Developed transaction handling for TON and USDT transfers.
- Added models for payments, marketplace results, and wallet information.
- Implemented error handling for various operations including wallet and transaction errors.
- Established domain structure for purchases, marketplace, and wallet functionalities.
2026-05-20 23:33:01 +03:00
bohd4nx e7dc0e051e feat: update README and pyproject.toml to clarify browser dependency installation 2026-05-20 22:55:18 +03:00
Bohdan 👾 e1478ec8b0 Merge pull request #24 from bohd4nx/dev
Bump version to 2026.2.3 and refactor wallet utilities
2026-05-12 02:03:12 +03:00
bohd4nx 363e0719ab Bump version to 2026.2.3 and refactor wallet utilities
- Updated version in pyproject.toml to 2026.2.3.
- Refactored wallet utilities:
  - Moved `clean_decode` and `process_transaction` to `transaction.py`.
  - Created `balance.py` for balance-related functions.
  - Created `info.py` for wallet information retrieval.
  - Created `transfer.py` for sending TON and USDT transfers.
- Updated tests to reflect new module structure and imports.
- Added new API utility functions for handling Fragment API requests.
2026-05-12 02:01:15 +03:00
Bohdan 👾 35ab0e337d Merge pull request #22 from bohd4nx/dev
Release 2026.2.2: payment_method cleanup, wallet balance behavior updates, docs/tests sync
2026-05-11 13:26:04 +03:00
bohd4nx b67415bcc2 chore: remove redundant reference to examples directory in README 2026-05-11 13:21:18 +03:00
bohd4nx 5df95bea29 chore: update version to 2026.2.2 and modify changelog for release 2026-05-11 13:19:56 +03:00
bohd4nx 7412448f9c feat: enhance payment processing with minimum balance checks and update documentation 2026-05-11 13:11:28 +03:00
bohd4nx 6dd9dcb5d4 feat: update wallet handling to support separate TON and USDT balances
- Refactor `get_wallet()` to return `ton_balance` and `usdt_balance` in `WalletInfo`.
- Update transaction processing to validate balances for both TON and USDT payment methods.
- Introduce `parse_required_payment_amount` utility to extract payment amounts from responses.
- Modify tests to cover new balance checks and payment method handling.
- Upgrade GitHub Actions artifact upload action to v7.
- Enhance documentation and examples to reflect changes in wallet balance handling.
2026-05-11 13:02:00 +03:00
bohd4nx 311222d478 feat: add payment method options for purchases and giveaways, enhance documentation and examples 2026-05-11 12:12:07 +03:00
bohd4nx 72918a6dd6 feat: add payment method support for premium and stars purchases, including validation and tests 2026-05-11 11:57:52 +03:00
Bohdan 👾 eec59f7e12 Merge pull request #20 from bohd4nx/dev
Dev
2026-05-03 19:21:35 +03:00
bohd4nx c1ae5f0f28 fix: update download-artifact action version to v8 for consistency 2026-05-03 19:20:30 +03:00
bohd4nx 4873d6d6dd fix: update PyPI publish action to use the latest release version
Co-authored-by: Copilot <copilot@github.com>
2026-05-03 19:19:54 +03:00
Bohdan 👾 1f6085acdc Merge pull request #18 from bohd4nx/dev
Dev
2026-05-03 19:16:19 +03:00
bohd4nx d5dfed5f1a fix: update CI and publish workflows to use consistent action versions and improve error handling in API requests
Co-authored-by: Copilot <copilot@github.com>
2026-05-03 19:10:11 +03:00
bohd4nx 7923dff8b2 fix: remove unnecessary 'method' key from API request data and improve error handling for expired cookies and non-200 HTTP responses 2026-04-21 20:06:11 +03:00
bohd4nx 8c7423a6ab fix: update Python version in CI workflow and refine project metadata for clarity 2026-04-18 21:11:14 +03:00
bohd4nx 8f3e9f4bd8 chore: update action versions in CI workflow and adjust linter ignores for compatibility 2026-04-18 21:05:12 +03:00
bohd4nx fca60135a6 refactor: update type hints for better clarity and consistency across the codebase 2026-04-18 21:02:53 +03:00
bohd4nx d66602b646 fix: replace Miniconda setup with Python setup in CI workflow for consistency 2026-04-14 02:03:22 +03:00
bohd4nx 333b6f45ce fix: update Miniconda setup action version and adjust activation settings 2026-04-14 02:01:16 +03:00
Bohdan 👾 1a070ce8b9 Merge pull request #13 from bohd4nx/dev
feat: browser cookie extraction, CookieResult type, Python 3.10+ support
2026-04-14 02:00:46 +03:00
bohd4nx 415d9d9a9c docs: update examples and changelog to reflect CookieResult usage and Python version changes 2026-04-14 01:57:12 +03:00
bohd4nx 6a5e007a1b refactor: update import statements and patching methods in test files for better modularity 2026-04-14 01:45:04 +03:00
bohd4nx d81b83ac91 fix: reorder import statements in conftest.py for better organization 2026-04-14 01:37:52 +03:00
bohd4nx 6b6ca37f10 fix: update Python version requirements to support 3.10 and adjust CI workflow 2026-04-14 01:34:27 +03:00
bohd4nx 028984155b fix: update Python version matrix in CI workflow to include 3.10, 3.11, and 3.12 2026-04-14 01:30:52 +03:00
bohd4nx 391d15221b feat: enhance cookie handling by introducing CookieResult and updating get_cookies_from_browser to return structured results 2026-04-14 01:25:25 +03:00
bohd4nx 269551da2f feat: Update CI workflows, add changelog extraction, and enhance README with cookie retrieval methods 2026-04-14 01:13:45 +03:00
bohd4nx ebd45d2991 Add unit tests for FragmentClient methods
- Implement tests for Stars methods: purchase_stars and giveaway_stars, including validation and success scenarios.
- Add tests for Premium methods: purchase_premium and giveaway_premium, covering validation and success cases.
- Create tests for topup_ton functionality, validating input and mocking successful operations.
- Introduce wallet tests to verify get_wallet() functionality, ensuring correct wallet info retrieval.
- Add tests for FragmentClient.call() method, validating API response handling and data merging.
- Implement tests for anonymous number methods, including login codes and session management.
- Create recharge_ads tests for self-service Telegram Ads recharge, validating input and success scenarios.
- Add username search tests for the Fragment marketplace, ensuring correct result parsing and parameter forwarding.
- Implement number search tests for the Fragment marketplace, validating result parsing and parameter handling.
- Create gift search tests for the Fragment gifts marketplace, ensuring correct result parsing and parameter forwarding.
- Add cookie extraction tests for get_cookies_from_browser(), validating successful extraction and error handling.
2026-04-14 00:22:48 +03:00
bohd4nx 733d138fcc feat: Enhance cookie handling and browser support
- Added new exceptions in `exceptions.py` for unsupported browsers and cookie read failures.
- Introduced `get_cookies_from_browser` function in `cookies.py` to extract session cookies from various browsers.
- Modified tests to cover new cookie extraction functionality, including success and failure cases.
- Refactored existing tests to use `patch.object` for mocking client calls.
- Updated dependencies in `pyproject.toml` to include `rookiepy` for cookie extraction.
2026-04-14 00:12:22 +03:00
bohd4nx 503baa9a94 ci: update test job to use matrix strategy for multiple Python versions and switch to conda setup
fix: specify type for headers parameter in execute_transaction_request
fix: specify type for transaction_data parameter in process_transaction
deps: update tonutils dependency version to 2.0.5
2026-04-07 00:52:39 +03:00
bohd4nx d4ac44f698 style: remove unnecessary blank line in test_hash.py 2026-03-25 18:35:09 +02:00
bohd4nx 3aedd619dc Merge remote-tracking branch 'origin/master' into dev 2026-03-25 18:34:30 +02:00
bohd4nx bae2e1b400 tests: add section comments to all test files 2026-03-25 18:33:47 +02:00
104 changed files with 3698 additions and 3027 deletions
+86 -86
View File
@@ -1,101 +1,101 @@
name: Bug report
description: Report an issue or unexpected behavior in pyfragment.
labels:
- bug
- bug
body:
- type: checkboxes
attributes:
label: Checklist
options:
- label: I am sure the error is coming from pyfragment code
required: true
- label: I have searched the issue tracker for similar bug reports, including closed ones
required: true
- type: markdown
attributes:
value: |
## Context
Please provide as much detail as possible to help us reproduce and fix the issue.
- type: input
attributes:
label: Operating system
placeholder: e.g. Ubuntu 22.04 / macOS 14 / Windows 11
validations:
- type: checkboxes
attributes:
label: Checklist
options:
- label: I am sure the error is coming from pyfragment code
required: true
- label: I have searched the issue tracker for similar bug reports, including closed ones
required: true
- type: input
attributes:
label: Python version
description: Run `python --version` inside your virtualenv
placeholder: e.g. 3.12.3
validations:
required: true
- type: markdown
attributes:
value: |
## Context
Please provide as much detail as possible to help us reproduce and fix the issue.
- type: input
attributes:
label: pyfragment version
description: Run `pip show pyfragment` inside your virtualenv
placeholder: e.g. 2026.1.0
validations:
required: true
- type: input
attributes:
label: Operating system
placeholder: e.g. Ubuntu 22.04 / macOS 14 / Windows 11
validations:
required: true
- type: textarea
attributes:
label: Expected behavior
description: Describe what you expected to happen.
placeholder: e.g. Stars should be purchased and StarsResult returned.
validations:
required: true
- type: input
attributes:
label: Python version
description: Run `python --version` inside your virtualenv
placeholder: e.g. 3.12.3
validations:
required: true
- type: textarea
attributes:
label: Current behavior
description: Describe what is actually happening.
placeholder: e.g. ParseError is raised with status 400.
validations:
required: true
- type: input
attributes:
label: pyfragment version
description: Run `pip show pyfragment` inside your virtualenv
placeholder: e.g. 2026.1.0
validations:
required: true
- type: textarea
attributes:
label: Steps to reproduce
description: Minimal steps that reproduce the issue.
placeholder: |
1. Create FragmentClient with valid credentials
2. Call purchase_stars("@username", amount=100)
3. See error
validations:
required: true
- type: textarea
attributes:
label: Expected behavior
description: Describe what you expected to happen.
placeholder: e.g. Stars should be purchased and StarsResult returned.
validations:
required: true
- type: textarea
attributes:
label: Code example
description: Provide a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) if applicable.
placeholder: |
import asyncio
from pyfragment import FragmentClient
- type: textarea
attributes:
label: Current behavior
description: Describe what is actually happening.
placeholder: e.g. ParseError is raised with status 400.
validations:
required: true
async def main():
client = FragmentClient(...)
result = await client.purchase_stars("@username", amount=100)
- type: textarea
attributes:
label: Steps to reproduce
description: Minimal steps that reproduce the issue.
placeholder: |
1. Create FragmentClient with valid credentials
2. Call purchase_stars("@username", amount=100)
3. See error
validations:
required: true
asyncio.run(main())
render: python
- type: textarea
attributes:
label: Code example
description: Provide a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) if applicable.
placeholder: |
import asyncio
from pyfragment import FragmentClient
- type: textarea
attributes:
label: Traceback / logs
description: Paste the full traceback or relevant logs.
placeholder: |
Traceback (most recent call last):
File "main.py", line 7, in main
...
pyfragment.types.ParseError: ...
render: sh
async def main():
client = FragmentClient(...)
result = await client.purchase_stars("@username", amount=100)
- type: textarea
attributes:
label: Additional information
description: Anything else that might help us diagnose the problem.
placeholder: e.g. Only happens with V5R1 wallet version.
asyncio.run(main())
render: python
- type: textarea
attributes:
label: Traceback / logs
description: Paste the full traceback or relevant logs.
placeholder: |
Traceback (most recent call last):
File "main.py", line 7, in main
...
pyfragment.types.ParseError: ...
render: sh
- type: textarea
attributes:
label: Additional information
description: Anything else that might help us diagnose the problem.
placeholder: e.g. Only happens with V5R1 wallet version.
+3 -3
View File
@@ -1,5 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Ask a question or start a discussion
url: https://github.com/bohd4nx/pyfragment/discussions
about: General questions, ideas, and community help go here — not in the issue tracker.
- name: Ask a question or start a discussion
url: https://github.com/bohd4nx/pyfragment/discussions
about: General questions, ideas, and community help go here — not in the issue tracker.
+42 -42
View File
@@ -1,51 +1,51 @@
name: Feature request
description: Suggest an improvement or new feature for pyfragment.
labels:
- enhancement
- enhancement
body:
- type: dropdown
attributes:
label: pyfragment version
description: Which version are you running?
options:
- latest
- older
- n/a
validations:
required: true
- type: dropdown
attributes:
label: pyfragment version
description: Which version are you running?
options:
- latest
- older
- n/a
validations:
required: true
- type: textarea
attributes:
label: Problem
description: Is your request related to a specific problem? Describe it.
placeholder: e.g. There is no way to check my current TON balance before sending.
validations:
required: true
- type: textarea
attributes:
label: Problem
description: Is your request related to a specific problem? Describe it.
placeholder: e.g. There is no way to check my current TON balance before sending.
validations:
required: true
- type: textarea
attributes:
label: Proposed solution
description: Describe what you would like to see added or changed.
placeholder: e.g. Add a get_balance() method to FragmentClient.
validations:
required: true
- type: textarea
attributes:
label: Proposed solution
description: Describe what you would like to see added or changed.
placeholder: e.g. Add a get_balance() method to FragmentClient.
validations:
required: true
- type: textarea
attributes:
label: Alternatives considered
description: Any workarounds or alternative approaches you have thought of.
placeholder: e.g. I manually call the Fragment API, but it's not ergonomic.
- type: textarea
attributes:
label: Alternatives considered
description: Any workarounds or alternative approaches you have thought of.
placeholder: e.g. I manually call the Fragment API, but it's not ergonomic.
- type: textarea
attributes:
label: Code example
description: A short example demonstrating the desired API, if applicable.
placeholder: |
balance = await client.get_balance()
print(balance.ton)
render: python
- type: textarea
attributes:
label: Code example
description: A short example demonstrating the desired API, if applicable.
placeholder: |
balance = await client.get_balance()
print(balance.ton)
render: python
- type: textarea
attributes:
label: Additional information
description: Any other context, screenshots, or references.
- type: textarea
attributes:
label: Additional information
description: Any other context, screenshots, or references.
+16 -16
View File
@@ -1,19 +1,19 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
labels:
- "dependencies"
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
labels:
- "dependencies"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
labels:
- "dependencies"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
labels:
- "dependencies"
+22 -22
View File
@@ -10,38 +10,38 @@ jobs:
lint:
name: Lint & Format
runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6
- uses: actions/setup-python@v6.2.0
with:
python-version: "3.12"
- uses: actions/setup-python@v6
with:
python-version: "3.10"
cache: pip
- uses: astral-sh/setup-uv@v7.6.0
- run: pip install ".[dev]"
- run: uv pip install --system ".[dev]"
- run: ruff check . && black --check . --target-version py312 && mypy pyfragment
- run: ruff check . && ruff format --check . && mypy pyfragment --explicit-package-bases
test:
name: Tests
name: Tests (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
COOKIES_JSON: ${{ secrets.COOKIES_JSON }}
strategy:
fail-fast: false
matrix:
python-version: [ "3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6
- uses: actions/setup-python@v6.2.0
with:
python-version: "3.12"
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: pip
- uses: astral-sh/setup-uv@v7.6.0
- name: Install package and dev dependencies
run: pip install ".[dev]"
- run: uv pip install --system ".[dev]"
- run: pytest
- name: Run tests
run: pytest
+42 -54
View File
@@ -11,62 +11,56 @@ 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 }}
steps:
- uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Read version
id: version
run: |
value=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
echo "value=$value" >> $GITHUB_OUTPUT
- name: Read version
id: version
run: |
value=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
echo "value=$value" >> $GITHUB_OUTPUT
- name: Check tag
id: tag
run: |
if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then
echo "is-new=false" >> $GITHUB_OUTPUT
else
echo "is-new=true" >> $GITHUB_OUTPUT
fi
- name: Check tag
id: tag
run: |
if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then
echo "is-new=false" >> $GITHUB_OUTPUT
else
echo "is-new=true" >> $GITHUB_OUTPUT
fi
build:
name: Build
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
- uses: actions/checkout@v6
- uses: actions/setup-python@v6.2.0
with:
python-version: "3.12"
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- uses: astral-sh/setup-uv@v7.6.0
- uses: astral-sh/setup-uv@v8.1.0
- run: uv build
- run: uv build
- uses: actions/upload-artifact@v7
with:
name: dist
path: dist/*
- uses: actions/upload-artifact@v7
with:
name: dist
path: dist/*
publish:
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/
@@ -74,36 +68,30 @@ jobs:
id-token: write
steps:
- uses: actions/download-artifact@v8.0.1
with:
name: dist
path: dist
- uses: actions/download-artifact@v8
with:
name: dist
path: dist
- uses: pypa/gh-action-pypi-publish@v1.13.0
- uses: pypa/gh-action-pypi-publish@release/v1
release:
name: GitHub Release
needs: [version-check, build]
runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions:
contents: write
steps:
- uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- uses: actions/download-artifact@v8
with:
name: dist
path: dist
- uses: actions/download-artifact@v8.0.1
with:
name: dist
path: dist
- uses: softprops/action-gh-release@v2.6.1
with:
tag_name: v${{ needs.version-check.outputs.version }}
name: v${{ needs.version-check.outputs.version }}
files: dist/*
generate_release_notes: true
make_latest: true
- uses: softprops/action-gh-release@v3
with:
tag_name: v${{ needs.version-check.outputs.version }}
name: v${{ needs.version-check.outputs.version }}
files: dist/*
generate_release_notes: true
make_latest: true
+1 -1
View File
@@ -31,7 +31,7 @@ Thumbs.db
.ruff_cache/
.coverage
htmlcov/
demo.run.py
systests/
# Build & distribution
dist/
+142
View File
@@ -7,42 +7,175 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
---
## [2026.3.1] — 2026-05-29
### Added
- Python 3.13 and 3.14 are now officially supported and included in the CI test matrix and PyPI classifiers.
- `WalletVersion` is now exported from the top-level `pyfragment` package.
### Changed
- `process_transaction` (internal) refactored into focused subfunctions: `_extract_message`, `_check_payment_balances`, `_broadcast_with_retry`.
- `raw_api_call()` moved from `FragmentClient` into `pyfragment.domains.base` and exposed as a standalone helper.
- `tonapi` domain internal helpers removed from public `__init__.py` exports; only `TonapiService` is exported.
- README rewritten with badges, structured sections, and complete usage examples.
- Added `CONTRIBUTING.md` and `SECURITY.md`.
### Fixed
- CI: `mypy` now runs with `--explicit-package-bases` to avoid false-positive import errors.
- CI: `pip` dependency cache enabled to speed up workflow runs.
- CI: `warn_unused_ignores` suppressed for `pyfragment.core.cookies` to handle the optional `rookiepy` dependency correctly across environments where the package may or may not be installed.
- Publish workflow now uses `generate_release_notes: true` instead of manual changelog extraction.
### Removed
- `tonapi/transfer.py` and associated `TonTransferResult` / `UsdtTransferResult` models (internal, unused).
---
## [2026.3.0] — 2026-05-21
### Changed
- Internal architecture reorganized around explicit domain packages:
- TON account and balance helpers are now unified under `pyfragment.domains.tonapi.account`
- service wrappers and operation modules are aligned by domain (`ads`, `purchases`, `giveaways`, `anonymous_numbers`, `marketplace`, `tonapi`)
- Package exports were cleaned up for domain and model packages (`__init__.py`) to provide clearer public symbols.
- Examples and system tests were updated to follow current public import paths and project structure.
### Fixed
- `get_cookies_from_browser()` is now patch-friendly in tests (`pyfragment.core.cookies.rookiepy` can be mocked reliably).
- Anonymous number `NOT_OWNED` error message wording was adjusted for test and backward-compatibility with existing matchers.
## [2026.2.3] — 2026-05-12
### Fixed
- Fixed USDT payment flow: the USDT balance check now correctly targets the wallet linked to the Fragment account (`transaction["from"]`), not the signing seed wallet. These are two distinct addresses — the seed wallet only signs the transaction and covers TON gas fees, while USDT is withdrawn from the Fragment-linked wallet.
- Fixed `clean_decode()` incorrectly treating binary TON cell payloads (e.g. jetton transfer messages with non-zero op codes) as text comments. Only cells with op code `0x00000000` are now decoded as snake-encoded UTF-8 strings; all other op codes return the raw `Cell` as-is.
- Restored and correctly wired USDT balance validation so `WalletError` is raised before broadcasting when the Fragment-linked wallet has insufficient USDT.
### Note
- USDT (`usdt_ton`) payments require USDT to be held in the TON wallet that is linked to your Fragment account profile. The seed wallet configured in `FragmentClient` is only used to sign transactions and pay TON network fees.
---
## [2026.2.2] — 2026-05-11
### Added
- `payment_method` option (`"ton"` / `"usdt_ton"`) for:
- `purchase_stars()`
- `purchase_premium()`
- `giveaway_stars()`
- `giveaway_premium()`
### Changed
- Added runtime validation for `payment_method` via `SUPPORTED_PAYMENT_METHODS` and `ConfigurationError.INVALID_PAYMENT_METHOD`
- Updated method docstrings to explicitly document recipient/channel formats:
- `@username` / `username` / `https://t.me/username`
- `get_wallet()` now returns balances as separate fields: `ton_balance` and `usdt_balance`
- Wallet/system test output now prints TON and USDT balances on separate lines
- Balance checks are now method-aware with explicit thresholds:
- `ton`: minimum TON balance threshold via `MIN_TON_BALANCE` (based on current 50 Stars purchase amount)
- `usdt_ton`: minimum USDT balance threshold via `MIN_USDT_BALANCE` (based on current 50 Stars purchase amount)
### Tests
- Extended stars and premium test suites to cover:
- invalid payment method
- payment method propagation to `init*Request` payloads
- accepted query formats (`@`, plain username, `t.me` link)
- Extended wallet tests to verify separate TON/USDT balance values in `WalletInfo`
### Documentation
- Simplified `README` usage example
## [2026.2.1] — 2026-05-03
### Fixed
- Fragment API 429 responses are now retried automatically (up to 3 attempts) with exponential backoff and jitter in `fragment_request`
- Retry delays in TON transaction broadcasting now include jitter to reduce contention under concurrent calls
- Improved handling of non-200 HTTP responses in `get_fragment_hash`
- Removed unnecessary `method` key leaking into certain API request payloads
### Changed
- Type hints refined across the codebase for better clarity and `mypy` strict compliance
---
## [2026.2.0] — 2026-04-14
### Added
- `get_cookies_from_browser(browser)` — extract Fragment session cookies directly from an installed browser (Chrome, Firefox, Edge, Brave, Arc, Opera, Safari, and more); no browser extension or manual copy-paste required
```python
from pyfragment import get_cookies_from_browser
result = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ...
client = FragmentClient(seed="...", api_key="...", cookies=result.cookies)
print(result.expires) # ISO 8601 expiry of stel_ssid, or None for session cookies
```
- `CookieResult` — return type of `get_cookies_from_browser()`; exposes `.cookies` (`dict[str, str]`) and `.expires` (ISO 8601 string or `None`)
### Changed
- `DEVICE` Tonkeeper fingerprint updated: `appVersion` → `26.04.0`
- `tonutils` upgraded to **2.1.0**
- Minimum Python version lowered to **3.10** (previously 3.12)
---
## [2026.1.0] — 2026-03-25
### Added
**Giveaways**
- `giveaway_stars(channel, winners, amount)` — Stars giveaway; 15 winners, 5001 000 000 stars each
- `giveaway_premium(channel, winners, months)` — Premium giveaway; 124 000 winners, 3/6/12 months each
- `StarsGiveawayResult`, `PremiumGiveawayResult` result types
**Telegram Ads**
- `recharge_ads(account, amount)` — top up a Telegram Ads account; 11 000 000 000 TON
- `AdsRechargeResult` result type
**Marketplace**
- `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
**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
**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/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 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
@@ -55,15 +188,18 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
## [2026.0.2] — 2026-03-20
### 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__`
@@ -72,6 +208,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
## [2026.0.1] — 2026-03-16
### Added
- Initial stable release of `pyfragment`
- `FragmentClient` — async client for the Fragment.com API with context manager support (`async with`)
- `purchase_premium(username, months)` — purchase Telegram Premium for any user (3, 6, or 12 months)
@@ -83,6 +220,11 @@ 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.3.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.3.0
[2026.2.3]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.3
[2026.2.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.2
[2026.2.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.1
[2026.2.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.0
[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
+56
View File
@@ -0,0 +1,56 @@
# Contributing to pyfragment
## Development setup
```bash
git clone https://github.com/bohd4nx/pyfragment.git
cd pyfragment
pip install -e ".[dev]"
```
## Running checks
```bash
# Lint and format
ruff check . --fix && ruff format .
# Type check
mypy . --explicit-package-bases
# Tests
pytest
```
All three must pass before opening a PR.
## Project structure
```
pyfragment/
client.py — FragmentClient (public entry point)
core/ — transport, cookies, constants
domains/ — one package per feature domain
ads/ — recharge_ads, topup_ton
anonymous_numbers/— buy_number, manage_number
giveaways/ — giveaway_stars, giveaway_premium
marketplace/ — search_usernames, search_numbers, search_gifts
purchases/ — purchase_stars, purchase_premium
tonapi/ — wallet info, transaction signing
models/ — result dataclasses and enums
exceptions.py — exception hierarchy
tests/ — unit tests (pytest)
examples/ — runnable usage examples (excluded from CI)
```
## Conventions
- All public async methods live on `FragmentClient` and delegate to a domain service.
- Domain functions receive a `FragmentClient` instance, never raw httpx clients.
- Patch targets in tests use the module where the name is **defined**, e.g. `pyfragment.domains.tonapi.transaction.process_transaction`.
- Versioning follows [CalVer](https://calver.org/): `YYYY.MINOR.MICRO`. Bump in `pyproject.toml`; tag as `vYYYY.MINOR.MICRO`.
## Pull requests
- Keep PRs focused — one feature or fix per PR.
- Update `CHANGELOG.md` under `[Unreleased]`.
- Add or update tests for any changed behaviour.
+108 -80
View File
@@ -1,24 +1,23 @@
<div align="center">
<img src="https://www.bohd4n.dev/assets/projects/pyfragment.svg" alt="Fragment Logo" width="120" height="120" style="border-radius: 24px;">
<img src="https://www.bohd4n.dev/assets/projects/pyfragment.svg" alt="pyfragment" width="96" height="96" style="border-radius: 20px;"><br><br>
<h1 style="margin-top: 24px;">Fragment API</h1>
# pyfragment
<p style="font-size: 18px; margin-bottom: 24px;">
<b>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.</b>
</p>
[![PyPI](https://img.shields.io/pypi/v/pyfragment?style=flat&color=blue&label=PyPI)](https://pypi.org/project/pyfragment/)
[![Downloads](https://img.shields.io/pepy/dt/pyfragment?style=flat&color=blue&label=Downloads)](https://pepy.tech/projects/pyfragment)
[![Python](https://img.shields.io/pypi/pyversions/pyfragment?style=flat&color=blue&label=Python)](https://python.org)
[![Tests](https://img.shields.io/github/actions/workflow/status/bohd4nx/pyfragment/ci.yml?style=flat&label=Tests&logo=github)](https://github.com/bohd4nx/pyfragment/actions)
[![License](https://img.shields.io/github/license/bohd4nx/pyfragment?style=flat&color=blue&label=License)](LICENSE)
[![PyPI version](https://img.shields.io/pypi/v/pyfragment?style=flat&color=blue)](https://pypi.org/project/pyfragment/)
[![PyPI downloads](https://img.shields.io/pypi/dm/pyfragment?style=flat&color=brightgreen)](https://pypi.org/project/pyfragment/)
[![Python](https://img.shields.io/badge/Python-3.12+-3776AB?style=flat&logo=python&logoColor=white)](https://python.org)
[![License](https://img.shields.io/github/license/bohd4nx/pyfragment?style=flat&color=lightgrey)](LICENSE)
[![Stars](https://img.shields.io/github/stars/bohd4nx/pyfragment?style=flat&color=yellow)](https://github.com/bohd4nx/pyfragment/stargazers)
[![CI](https://img.shields.io/github/actions/workflow/status/bohd4nx/pyfragment/ci.yml?style=flat&label=tests&logo=github)](https://github.com/bohd4nx/pyfragment/actions)
Async Python client for the **[Fragment](https://fragment.com)** marketplace API.
Buy Stars & Premium, run giveaways, top up TON and Ads balances,
manage anonymous numbers, and search Fragment listings.
[Report Bug](https://github.com/bohd4nx/pyfragment/issues) · [Request Feature](https://github.com/bohd4nx/pyfragment/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)
**[Documentation](https://bohd4nx.gitbook.io/pyfragment/)** · **[Examples](https://github.com/bohd4nx/pyfragment/tree/master/examples)** · **[Changelog](CHANGELOG.md)** · **[Donate TON](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)**
</div>
> **Disclaimer:** This project is not affiliated with, endorsed by, or in any way officially connected with [Fragment](https://fragment.com) or [Telegram](https://telegram.org).
> **Disclaimer:** This project is not affiliated with [Fragment](https://fragment.com) or [Telegram](https://telegram.org).
---
@@ -28,58 +27,24 @@
pip install pyfragment
```
To install the latest unreleased changes from the `dev` branch:
```bash
# Latest dev build
pip install git+https://github.com/bohd4nx/pyfragment.git@dev
```
Requires Python 3.12+.
---
## 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
## Quick Start
```python
import asyncio
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
)
from pyfragment import FragmentClient
async def main() -> None:
async with FragmentClient(
seed="word1 word2 ... word24", # 24-word TON wallet mnemonic
api_key="YOUR_TONAPI_KEY", # from tonconsole.com
seed="word1 word2 ... word24",
api_key="YOUR_TONAPI_KEY",
cookies={
"stel_ssid": "...",
"stel_dt": "...",
@@ -87,35 +52,16 @@ async def main() -> None:
"stel_ton_token": "...",
},
) as client:
try:
# Purchase 6 months of Telegram Premium
result = await client.purchase_premium("@username", months=6)
print(f"{result.amount} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}")
wallet = await client.get_wallet()
print(f"TON: {wallet.ton_balance} | USDT: {wallet.usdt_balance}")
# Purchase 500 Stars
result = await client.purchase_stars("@username", amount=500)
print(f"{result.amount} Stars successfully sent to {result.username} | tx: {result.transaction_id}")
recipient = "https://t.me/username" # also: @username, username
# 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}")
stars = await client.purchase_stars(recipient, amount=500, payment_method="usdt_ton")
print(f"Sent {stars.amount} Stars to {stars.username} | tx: {stars.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}")
premium = await client.purchase_premium(recipient, months=6, payment_method="ton")
print(f"Sent Premium {premium.amount}m to {premium.username} | tx: {premium.transaction_id}")
asyncio.run(main())
@@ -123,10 +69,92 @@ asyncio.run(main())
---
## Configuration
| Parameter | Type | Default | Description |
| ---------------- | ------------- | -------- | ----------------------------------------------------------- |
| `seed` | `str` | — | 12- or 24-word TON wallet mnemonic |
| `api_key` | `str` | — | Tonapi key from [tonconsole.com](https://tonconsole.com) |
| `cookies` | `dict \| str` | — | Fragment session cookies |
| `wallet_version` | `str` | `"V5R1"` | `"V4R2"` or `"V5R1"` — also accepts `WalletVersion` literal |
| `timeout` | `float` | `30.0` | HTTP request timeout in seconds |
---
## Credentials
### Fragment cookies
Log in to [fragment.com](https://fragment.com) and connect your TON wallet.
**Automatically** (recommended) — reads directly from your browser, no extension needed:
```bash
pip install "pyfragment[browser]"
```
```python
from pyfragment import get_cookies_from_browser
result = get_cookies_from_browser("chrome") # firefox, edge, brave, ...
# result.cookies — dict[str, str] ready to pass to FragmentClient
# result.expires — ISO 8601 expiry of stel_ssid, or None for session cookies
```
**Manually** — use [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) and export: `stel_ssid`, `stel_dt`, `stel_token`, `stel_ton_token`. Pass as a `dict` or JSON string.
Refresh cookies when you get authentication errors.
### Tonapi key
Generate at [tonconsole.com](https://tonconsole.com).
### Seed phrase
12- or 24-word mnemonic from your TON wallet (**Tonkeeper → Settings → Backup**). Never share it.
---
## Logging
`pyfragment` uses the standard `logging` module under the `pyfragment` namespace and is silent by default:
```python
import logging
logging.basicConfig(level=logging.INFO)
logging.getLogger("pyfragment").setLevel(logging.DEBUG) # for detailed request logs
```
---
## Error Handling
All exceptions inherit from `FragmentError`:
```python
from pyfragment import (
ConfigurationError, # invalid arguments (amount, months, payment_method…)
UserNotFoundError, # recipient not found on Fragment
WalletError, # insufficient TON or USDT balance
TransactionError, # broadcast failed, duplicate seqno, invalid payload
FragmentAPIError, # Fragment API returned an error response
FragmentPageError, # page fetch or hash extraction failed
AnonymousNumberError, # number not owned, wrong state, login code issues
CookieError, # missing or malformed session cookies
ParseError, # failed to decode Fragment payload
VerificationError, # on-chain verification step failed
OperationError, # generic operation-level failure
UnexpectedError, # unexpected API response structure
)
```
---
<div align="center">
### Made with ❤️ by [@bohd4nx](https://t.me/bohd4nx)
Made with ❤️ by [@bohd4nx](https://t.me/bohd4nx) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md)
**Star ⭐ this repo if you found it useful!**
**Star ⭐ if you found it useful**
</div>
+19
View File
@@ -0,0 +1,19 @@
# Security Policy
## Reporting a vulnerability
Please **do not** open a public GitHub issue for security vulnerabilities.
Report them privately via GitHub's [Security Advisory](https://github.com/bohd4nx/pyfragment/security/advisories/new) feature, or contact the maintainer directly at [@bohd4nx](https://t.me/bohd4nx) on Telegram.
Include:
- A description of the vulnerability and its potential impact.
- Steps to reproduce or a proof-of-concept.
- Affected versions.
You will receive a response within 72 hours. Once the fix is released, the advisory will be published.
## Scope
This library handles sensitive credentials (TON seed phrases, Fragment session cookies, Tonapi keys). Please treat any finding that could expose or misuse these credentials as high severity.
+5
View File
@@ -14,6 +14,11 @@ from pyfragment import FragmentClient, GiftsResult
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
# Option B: provide cookies manually
COOKIES = {
"stel_ssid": "YOUR_STEL_SSID",
"stel_dt": "YOUR_STEL_DT",
+5
View File
@@ -13,6 +13,11 @@ from pyfragment import FragmentClient, NumbersResult
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
# Option B: provide cookies manually
COOKIES = {
"stel_ssid": "YOUR_STEL_SSID",
"stel_dt": "YOUR_STEL_DT",
+5
View File
@@ -13,6 +13,11 @@ from pyfragment import FragmentClient, UsernamesResult
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
# Option B: provide cookies manually
COOKIES = {
"stel_ssid": "YOUR_STEL_SSID",
"stel_dt": "YOUR_STEL_DT",
+5
View File
@@ -15,6 +15,11 @@ from pyfragment import FragmentClient
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
# Option B: provide cookies manually
COOKIES = {
"stel_ssid": "YOUR_STEL_SSID",
"stel_dt": "YOUR_STEL_DT",
+8 -2
View File
@@ -1,5 +1,5 @@
"""
Example: fetch wallet address, state, and balance.
Example: fetch wallet address, state, and separate TON/USDT balances.
Cookies can be passed as a dict or as a JSON string.
wallet_version defaults to "V5R1" — change to "V4R2" for older wallets.
@@ -11,6 +11,11 @@ from pyfragment import FragmentClient
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
# Option B: provide cookies manually
COOKIES = {
"stel_ssid": "YOUR_STEL_SSID",
"stel_dt": "YOUR_STEL_DT",
@@ -29,7 +34,8 @@ async def main() -> None:
wallet = await client.get_wallet()
print(f"Address: {wallet.address}")
print(f"State: {wallet.state}")
print(f"Balance: {wallet.balance} TON")
print(f"Balance: {wallet.ton_balance} TON")
print(f"Balance: {wallet.usdt_balance} USDT")
if __name__ == "__main__":
+5
View File
@@ -12,6 +12,11 @@ from pyfragment import AnonymousNumberError, FragmentClient
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
# Option B: provide cookies manually
COOKIES = {
"stel_ssid": "YOUR_STEL_SSID",
"stel_dt": "YOUR_STEL_DT",
+6 -1
View File
@@ -2,7 +2,7 @@
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.
Your wallet must satisfy the current minimum TON threshold and transaction cost.
"""
import asyncio
@@ -16,6 +16,11 @@ from pyfragment import (
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
# Option B: provide cookies manually
COOKIES = {
"stel_ssid": "YOUR_STEL_SSID",
"stel_dt": "YOUR_STEL_DT",
+15 -2
View File
@@ -3,6 +3,8 @@ 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.
payment_method can be "ton" or "usdt_ton".
Channel can be "@channel", "channel", or "https://t.me/channel".
"""
import asyncio
@@ -11,6 +13,11 @@ from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
# Option B: provide cookies manually
COOKIES = {
"stel_ssid": "YOUR_STEL_SSID",
"stel_dt": "YOUR_STEL_DT",
@@ -18,15 +25,21 @@ COOKIES = {
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
}
CHANNEL = "@channel"
CHANNEL = "https://t.me/channel"
WINNERS = 10 # 124 000
MONTHS = 3 # 3, 6 or 12
PAYMENT_METHOD = "ton" # "ton" or "usdt_ton"
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,
payment_method=PAYMENT_METHOD,
)
except UserNotFoundError:
print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.")
return
+15 -2
View File
@@ -3,6 +3,8 @@ 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.
payment_method can be "ton" or "usdt_ton".
Channel can be "@channel", "channel", or "https://t.me/channel".
"""
import asyncio
@@ -11,6 +13,11 @@ from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
# Option B: provide cookies manually
COOKIES = {
"stel_ssid": "YOUR_STEL_SSID",
"stel_dt": "YOUR_STEL_DT",
@@ -18,15 +25,21 @@ COOKIES = {
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
}
CHANNEL = "@channel"
CHANNEL = "https://t.me/channel"
WINNERS = 3 # 15
AMOUNT = 1000 # 5001 000 000 stars per winner
PAYMENT_METHOD = "usdt_ton" # "ton" or "usdt_ton"
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,
payment_method=PAYMENT_METHOD,
)
except UserNotFoundError:
print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.")
return
+15 -2
View File
@@ -3,6 +3,8 @@ Example: purchase Telegram Premium for a user.
Supported durations: 3, 6, or 12 months.
Set show_sender=False to send anonymously.
payment_method can be "ton" or "usdt_ton".
Username can be "@username", "username", or "https://t.me/username".
"""
import asyncio
@@ -11,6 +13,11 @@ from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
# Option B: provide cookies manually
COOKIES = {
"stel_ssid": "YOUR_STEL_SSID",
"stel_dt": "YOUR_STEL_DT",
@@ -18,14 +25,20 @@ COOKIES = {
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
}
USERNAME = "@username"
USERNAME = "https://t.me/username"
MONTHS = 3 # 3, 6 or 12
PAYMENT_METHOD = "ton" # "ton" or "usdt_ton"
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,
payment_method=PAYMENT_METHOD,
)
except UserNotFoundError:
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
return
+15 -2
View File
@@ -3,6 +3,8 @@ Example: purchase Telegram Stars for a user.
Amount must be an integer between 50 and 1 000 000.
Set show_sender=False to send anonymously.
payment_method can be "ton" or "usdt_ton".
Username can be "@username", "username", or "https://t.me/username".
"""
import asyncio
@@ -11,6 +13,11 @@ from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
# Option B: provide cookies manually
COOKIES = {
"stel_ssid": "YOUR_STEL_SSID",
"stel_dt": "YOUR_STEL_DT",
@@ -18,14 +25,20 @@ COOKIES = {
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
}
USERNAME = "@username"
USERNAME = "https://t.me/username"
AMOUNT = 500 # 501 000 000 stars
PAYMENT_METHOD = "usdt_ton" # "ton" or "usdt_ton"
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,
payment_method=PAYMENT_METHOD,
)
except UserNotFoundError:
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
return
+8 -3
View File
@@ -1,10 +1,10 @@
"""
Example: Topup ton to recipient's Telegram balance.
Example: top up TON to a recipient's Telegram balance.
For adding TON to Telegram Ads account, use recharge_ads() instead.
For adding TON to a 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.
Your wallet must satisfy the current minimum TON threshold and transaction cost.
"""
import asyncio
@@ -18,6 +18,11 @@ from pyfragment import (
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
# Option B: provide cookies manually
COOKIES = {
"stel_ssid": "YOUR_STEL_SSID",
"stel_dt": "YOUR_STEL_DT",
+33 -32
View File
@@ -1,14 +1,9 @@
# Copyright (c) 2026 bohd4nx
#
# This source code is licensed under the MIT License found in the
# LICENSE file in the root directory of this source tree.
import logging
from importlib.metadata import version
from pyfragment.client import FragmentClient
from pyfragment.types import (
AdsRechargeResult,
AdsTopupResult,
from pyfragment.core.cookies import get_cookies_from_browser
from pyfragment.exceptions import (
AnonymousNumberError,
ClientError,
ConfigurationError,
@@ -16,54 +11,60 @@ from pyfragment.types import (
FragmentAPIError,
FragmentError,
FragmentPageError,
GiftsResult,
LoginCodeResult,
NumbersResult,
OperationError,
ParseError,
PremiumGiveawayResult,
PremiumResult,
StarsGiveawayResult,
StarsResult,
TerminateSessionsResult,
TransactionError,
UnexpectedError,
UsernamesResult,
UserNotFoundError,
VerificationError,
WalletError,
WalletInfo,
)
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
from pyfragment.models.cookies import CookieResult
from pyfragment.models.enums import PaymentMethod, WalletVersion
from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult
from pyfragment.models.wallet import WalletInfo
logging.getLogger("pyfragment").addHandler(logging.NullHandler())
__version__: str = version("pyfragment")
__all__ = [
"__version__",
"FragmentClient",
"AdsRechargeResult",
# results
"StarsResult",
"StarsGiveawayResult",
"PremiumResult",
"PremiumGiveawayResult",
"WalletInfo",
"AdsTopupResult",
"AdsRechargeResult",
"CookieResult",
"GiftsResult",
"LoginCodeResult",
"NumbersResult",
"PremiumGiveawayResult",
"PremiumResult",
"StarsGiveawayResult",
"StarsResult",
"TerminateSessionsResult",
"UsernamesResult",
"WalletInfo",
"ClientError",
"ConfigurationError",
"CookieError",
"FragmentAPIError",
# exceptions
"FragmentError",
"FragmentAPIError",
"FragmentPageError",
"ConfigurationError",
"UserNotFoundError",
"WalletError",
"VerificationError",
"TransactionError",
"AnonymousNumberError",
"ClientError",
"CookieError",
"OperationError",
"ParseError",
"TransactionError",
"UnexpectedError",
"UserNotFoundError",
"VerificationError",
"WalletError",
# literal types
"PaymentMethod",
"WalletVersion",
"get_cookies_from_browser",
]
+105 -87
View File
@@ -1,42 +1,23 @@
from __future__ import annotations
import json
from typing import Any, cast
from typing import Any, cast, get_args
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
from pyfragment.methods.purchase_stars import purchase_stars
from pyfragment.methods.recharge_ads import recharge_ads
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,
ConfigurationError,
CookieError,
GiftsResult,
LoginCodeResult,
NumbersResult,
PremiumResult,
StarsResult,
TerminateSessionsResult,
UsernamesResult,
WalletInfo,
)
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
from pyfragment.core.constants import DEFAULT_TIMEOUT, FRAGMENT_BASE_URL, REQUIRED_COOKIE_KEYS
from pyfragment.domains.ads.service import AdsService
from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService
from pyfragment.domains.base import raw_api_call
from pyfragment.domains.giveaways.service import GiveawaysService
from pyfragment.domains.marketplace.service import MarketplaceService
from pyfragment.domains.purchases.service import PurchasesService
from pyfragment.domains.tonapi.service import TonapiService
from pyfragment.exceptions import ConfigurationError, CookieError
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
from pyfragment.models.enums import PaymentMethod, WalletVersion
from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult
from pyfragment.models.wallet import WalletInfo
class FragmentClient:
@@ -48,7 +29,7 @@ class FragmentClient:
connected with Fragment or Telegram.
Args:
seed: 24-word mnemonic phrase for the TON wallet.
seed: 12- or 24-word mnemonic phrase for the TON wallet.
api_key: Tonapi API key — get one at https://tonconsole.com.
cookies: Fragment session cookies as a dict or JSON string.
wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default).
@@ -70,14 +51,17 @@ class FragmentClient:
print(result.transaction_id)
"""
def __init__(
self,
seed: str,
api_key: str,
cookies: dict | str,
wallet_version: str = "V5R1",
timeout: float = DEFAULT_TIMEOUT,
) -> None:
@staticmethod
def _parse_cookies(cookies: dict[str, Any] | str) -> dict[str, Any]:
if isinstance(cookies, str):
try:
cookies = json.loads(cookies)
except Exception as exc:
raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc
return cast(dict[str, Any], cookies)
@staticmethod
def _validate_required(seed: str, api_key: str) -> None:
missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()]
if missing:
raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing)))
@@ -89,31 +73,49 @@ class FragmentClient:
if len(api_key.strip()) < 68:
raise ConfigurationError(ConfigurationError.INVALID_API_KEY.format(length=len(api_key.strip())))
if isinstance(cookies, str):
try:
cookies = json.loads(cookies)
except Exception as exc:
raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc
missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cast(dict, cookies).get(k, "")).strip()]
@staticmethod
def _validate_cookie_keys(cookies: dict[str, Any]) -> None:
missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cookies.get(k, "")).strip()]
if missing_keys:
raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys)))
@staticmethod
def _normalize_wallet_version(wallet_version: str) -> WalletVersion:
version = wallet_version.strip().upper()
if version not in SUPPORTED_WALLET_VERSIONS:
if version not in get_args(WalletVersion):
raise ConfigurationError(
ConfigurationError.UNSUPPORTED_VERSION.format(
version=version, supported=", ".join(sorted(SUPPORTED_WALLET_VERSIONS))
version=version, supported=", ".join(sorted(get_args(WalletVersion)))
)
)
return cast(WalletVersion, version)
def __init__(
self,
seed: str,
api_key: str,
cookies: dict[str, Any] | str,
wallet_version: str = "V5R1",
timeout: float = DEFAULT_TIMEOUT,
) -> None:
self._validate_required(seed, api_key)
parsed_cookies = self._parse_cookies(cookies)
self._validate_cookie_keys(parsed_cookies)
version = self._normalize_wallet_version(wallet_version)
self.seed: str = seed.strip()
self.api_key: str = api_key.strip()
self.cookies: dict = cast(dict, cookies)
self.wallet_version: WalletVersion = version # type: ignore[assignment]
self.cookies: dict[str, Any] = parsed_cookies
self.wallet_version: WalletVersion = version
self.timeout: float = timeout
self.marketplace = MarketplaceService(self)
self.purchases = PurchasesService(self)
self.giveaways = GiveawaysService(self)
self.tonapi = TonapiService(self)
self.anonymous_numbers = AnonymousNumbersService(self)
self.ads = AdsService(self)
async def __aenter__(self) -> "FragmentClient":
async def __aenter__(self) -> FragmentClient:
return self
async def __aexit__(self, *_: object) -> None:
@@ -122,34 +124,48 @@ class FragmentClient:
def __repr__(self) -> str:
return f"FragmentClient(wallet_version='{self.wallet_version}', cookies={len(self.cookies)} keys)"
async def purchase_premium(self, username: str, months: int, show_sender: bool = True) -> PremiumResult:
"""Purchase Telegram Premium for a user.
async def purchase_premium(
self,
username: str,
months: int,
show_sender: bool = True,
payment_method: PaymentMethod = "ton",
) -> PremiumResult:
"""Gift Telegram Premium to a user.
Args:
username: Recipient's Telegram username (with or without ``@``).
username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
months: Duration — ``3``, ``6``, or ``12``.
show_sender: Show your name as the sender. Defaults to ``True``.
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
Returns:
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
"""
return await purchase_premium(self, username, months, show_sender)
return await self.purchases.purchase_premium(username, months, show_sender=show_sender, payment_method=payment_method)
async def purchase_stars(self, username: str, amount: int, show_sender: bool = True) -> StarsResult:
"""Purchase Telegram Stars for a user.
async def purchase_stars(
self,
username: str,
amount: int,
show_sender: bool = True,
payment_method: PaymentMethod = "ton",
) -> StarsResult:
"""Send Telegram Stars to a user.
Args:
username: Recipient's Telegram username (with or without ``@``).
username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
amount: Number of stars — integer from ``50`` to ``1 000 000``.
show_sender: Show your name as the gift sender. Defaults to ``True``.
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
Returns:
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
"""
return await purchase_stars(self, username, amount, show_sender)
return await self.purchases.purchase_stars(username, amount, show_sender=show_sender, payment_method=payment_method)
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
"""Topup ton to recipient's Telegram balance.
"""Top up TON to a recipient's Telegram balance.
Args:
username: Recipient's Telegram username (with or without ``@``).
@@ -159,7 +175,7 @@ class FragmentClient:
Returns:
:class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``.
"""
return await topup_ton(self, username, amount, show_sender)
return await self.ads.topup_ton(username, amount, show_sender=show_sender)
async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
"""Add funds to your own Telegram Ads account.
@@ -172,54 +188,59 @@ class FragmentClient:
Returns:
:class:`AdsRechargeResult` with ``transaction_id`` and ``amount``.
"""
return await recharge_ads(self, account, amount)
return await self.ads.recharge_ads(account, amount)
async def get_wallet(self) -> WalletInfo:
"""Return the address, state and balance of the TON wallet.
"""Return the address, state, and balances of the wallet.
Returns:
:class:`WalletInfo` with ``address`` (``"UQ..."``), ``state``
(``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``), and ``balance`` in TON.
(``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``),
``ton_balance`` in TON, and ``usdt_balance`` in USDT.
"""
return await get_wallet_info(self)
return await self.tonapi.get_wallet()
async def giveaway_stars(
self,
channel: str,
winners: int,
amount: int,
payment_method: PaymentMethod = "ton",
) -> StarsGiveawayResult:
"""Run a Telegram Stars giveaway for a channel.
Args:
channel: Channel username (with or without ``@``).
channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
winners: Number of winners — integer from ``1`` to ``5``.
amount: Stars each winner receives — integer from ``500`` to ``1 000 000``.
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
Returns:
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``amount``.
"""
return await giveaway_stars(self, channel, winners, amount)
return await self.giveaways.giveaway_stars(channel, winners, amount, payment_method=payment_method)
async def giveaway_premium(
self,
channel: str,
winners: int,
months: int = 3,
payment_method: PaymentMethod = "ton",
) -> PremiumGiveawayResult:
"""Run a Telegram Premium giveaway for a channel.
Args:
channel: Channel username (with or without ``@``).
channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
winners: Number of winners — positive integer.
months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``.
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
Returns:
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``amount``.
"""
return await giveaway_premium(self, channel, winners, months)
return await self.giveaways.giveaway_premium(channel, winners, months, payment_method=payment_method)
async def get_login_code(self, number: str) -> LoginCodeResult:
"""Fetch the current pending login code for an anonymous number.
@@ -231,7 +252,7 @@ class FragmentClient:
:class:`LoginCodeResult` with ``number``, ``code`` (``None`` if none pending),
and ``active_sessions`` count.
"""
return await get_login_code(self, number)
return await self.anonymous_numbers.get_login_code(number)
async def toggle_login_codes(self, number: str, can_receive: bool) -> None:
"""Enable or disable login code delivery for an anonymous number.
@@ -240,7 +261,7 @@ class FragmentClient:
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)
return await self.anonymous_numbers.toggle_login_codes(number, can_receive)
async def terminate_sessions(self, number: str) -> TerminateSessionsResult:
"""Terminate all active Telegram sessions for an anonymous number.
@@ -254,7 +275,7 @@ class FragmentClient:
Raises:
AnonymousNumberError: If the number is not owned by this account or has no active sessions.
"""
return await terminate_sessions(self, number)
return await self.anonymous_numbers.terminate_sessions(number)
async def search_usernames(
self,
@@ -278,7 +299,7 @@ class FragmentClient:
:class:`UsernamesResult` with ``items`` (parsed list of item dicts)
and ``next_offset_id`` (``None`` on the last page).
"""
return await search_usernames(self, query, sort=sort, filter=filter, offset_id=offset_id)
return await self.marketplace.search_usernames(query, sort=sort, filter=filter, offset_id=offset_id)
async def search_numbers(
self,
@@ -302,7 +323,7 @@ class FragmentClient:
: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)
return await self.marketplace.search_numbers(query, sort=sort, filter=filter, offset_id=offset_id)
async def search_gifts(
self,
@@ -334,8 +355,8 @@ class FragmentClient:
: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
return await self.marketplace.search_gifts(
query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset
)
async def call(
@@ -363,7 +384,4 @@ class FragmentClient:
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 {})})
return await raw_api_call(self.cookies, self.timeout, method, data, page_url)
View File
@@ -1,26 +1,22 @@
from __future__ import annotations
import json
from typing import Any, Literal, get_args
from typing import Any
from tonutils.contracts.wallet import WalletV4R2, WalletV5R1
# Single source of truth for supported wallet versions
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, Any] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1}
# Minimum wallet balance required to cover TON network gas fees.
MIN_TON_BALANCE: float = 0.056
MIN_TON_BALANCE: float = 0.33
USDT_TON_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs"
MIN_USDT_BALANCE: float = 0.75
# 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")
# Fragment page URLs
FRAGMENT_BASE_URL: str = "https://fragment.com"
FRAGMENT_DOMAIN: str = "fragment.com"
FRAGMENT_BASE_URL: str = f"https://{FRAGMENT_DOMAIN}"
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"
@@ -29,12 +25,29 @@ 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.
SUPPORTED_BROWSERS: frozenset[str] = frozenset(
{
"arc",
"brave",
"chrome",
"chromium",
"chromium_based",
"edge",
"firefox",
"firefox_based",
"librewolf",
"opera",
"opera_gx",
"safari",
"vivaldi",
}
)
DEVICE: str = json.dumps(
{
"platform": "iphone",
"appName": "Tonkeeper",
"appVersion": "5.5.2",
"appVersion": "26.04.0",
"maxProtocolVersion": 2,
"features": [
"SendTransaction",
@@ -44,20 +57,21 @@ DEVICE: str = json.dumps(
}
)
# Base HTTP headers — shared across all Fragment API requests.
# Each method merges these with its own "referer" and "x-aj-referer".
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": FRAGMENT_BASE_URL,
"priority": "u=1, i",
"sec-ch-ua": '"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"',
"sec-ch-ua-mobile": "?1",
"sec-ch-ua-platform": '"Android"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"user-agent": (
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1"
"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36"
),
"x-requested-with": "XMLHttpRequest",
}
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
import importlib
from datetime import datetime, timezone
from typing import Any
from pyfragment.core.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS, SUPPORTED_BROWSERS
from pyfragment.exceptions import CookieError
from pyfragment.models.cookies import CookieResult
try:
import rookiepy
except Exception: # noqa: BLE001
rookiepy = None # type: ignore[assignment]
def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
global rookiepy
key = browser.lower()
if key not in SUPPORTED_BROWSERS:
supported = ", ".join(sorted(SUPPORTED_BROWSERS))
raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported))
try:
if rookiepy is None:
rookiepy = importlib.import_module("rookiepy")
jar: list[dict[str, Any]] = getattr(rookiepy, key)([FRAGMENT_DOMAIN])
except Exception as exc:
raise CookieError(CookieError.BROWSER_READ_FAILED.format(browser=browser, exc=exc, url=FRAGMENT_BASE_URL)) from exc
cookie_map: dict[str, str] = {c["name"]: c["value"] for c in jar if c.get("name") and c.get("value")}
missing = [k for k in REQUIRED_COOKIE_KEYS if not str(cookie_map.get(k, "")).strip()]
if missing:
raise CookieError(CookieError.MISSING_BROWSER_KEYS.format(browser=browser, keys=missing, url=FRAGMENT_BASE_URL))
expires_iso: str | None = None
for cookie in jar:
if cookie.get("name") == "stel_ssid":
raw = cookie.get("expires")
if isinstance(raw, (int, float)):
expires_iso = datetime.fromtimestamp(raw, tz=timezone.utc).isoformat()
elif isinstance(raw, str) and raw:
for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"):
try:
expires_iso = datetime.strptime(raw, fmt).replace(tzinfo=timezone.utc).isoformat()
break
except ValueError:
continue
break
if expires_iso:
expires_dt = datetime.fromisoformat(expires_iso)
if expires_dt < datetime.now(timezone.utc):
raise CookieError(CookieError.EXPIRED.format(expires=expires_iso))
return CookieResult(cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS}, expires=expires_iso)
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
import asyncio
import random
import re
from typing import Any, cast
import httpx
from pyfragment.core.constants import DEFAULT_TIMEOUT, FRAGMENT_BASE_URL
from pyfragment.exceptions import FragmentPageError, ParseError, VerificationError
async def get_fragment_hash(
cookies: dict[str, Any],
headers: dict[str, str],
page_url: str,
timeout: float = DEFAULT_TIMEOUT,
) -> str:
page_headers = {
k: v
for k, v in headers.items()
if k not in ("accept", "accept-encoding", "content-type", "x-requested-with", "x-aj-referer")
}
page_headers.update(
{
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"referer": f"{FRAGMENT_BASE_URL}/",
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"upgrade-insecure-requests": "1",
}
)
async with httpx.AsyncClient(cookies=cookies, timeout=timeout) as session:
response = await session.get(page_url, headers=page_headers)
if response.status_code != 200:
raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=response.status_code, url=page_url))
match = re.search(r"(?:https://fragment\.com)?/api\?hash=([a-f0-9]+)", response.text)
if not match:
raise FragmentPageError(FragmentPageError.NOT_FOUND.format(url=page_url))
return match.group(1)
def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any]:
try:
return cast(dict[str, Any], response.json())
except Exception as exc:
raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc
async def fragment_request(
session: httpx.AsyncClient,
fragment_hash: str,
headers: dict[str, str],
data: dict[str, Any],
) -> dict[str, Any]:
for attempt in range(3):
resp = await session.post(
f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}",
headers=headers,
data=data,
)
if resp.status_code == 429 and attempt < 2:
await asyncio.sleep(1 + attempt + random.uniform(0, 0.5))
continue
if resp.status_code != 200:
raise FragmentPageError(
FragmentPageError.BAD_STATUS.format(status=resp.status_code, url=f"{FRAGMENT_BASE_URL}/api")
)
return parse_json_response(resp, data.get("method", "request"))
raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=429, url=f"{FRAGMENT_BASE_URL}/api"))
async def execute_transaction_request(
session: httpx.AsyncClient,
headers: dict[str, str],
tx_data: dict[str, Any],
fragment_hash: str,
) -> dict[str, Any]:
transaction = await fragment_request(session, fragment_hash, headers, tx_data)
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
return transaction
+1
View File
@@ -0,0 +1 @@
"""Domain-level helpers for Fragment operations."""
+5
View File
@@ -0,0 +1,5 @@
from pyfragment.domains.ads.recharge import recharge_ads
from pyfragment.domains.ads.service import AdsService
from pyfragment.domains.ads.tonup import topup_ton
__all__ = ["AdsService", "recharge_ads", "topup_ton"]
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
from pyfragment.domains.tonapi.account import get_account_info
from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.exceptions import ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, VerificationError
from pyfragment.models.payments import AdsRechargeResult
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
logger = logging.getLogger(__name__)
async def recharge_ads(client: FragmentClient, account: str, amount: int) -> AdsRechargeResult:
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
try:
await client.call("updateAdsState", {"mode": "new"}, page_url=ADS_TOPUP_PAGE)
result = await client.call("initAdsRechargeRequest", {"account": account, "amount": amount}, page_url=ADS_TOPUP_PAGE)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Ads recharge"))
account_info = await get_account_info(client)
transaction = await client.call(
"getAdsRechargeLink",
{
"account": json.dumps(account_info),
"device": DEVICE,
"transaction": 1,
"id": req_id,
},
page_url=ADS_TOPUP_PAGE,
)
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
tx_hash = await process_transaction(client, transaction)
return AdsRechargeResult(transaction_id=tx_hash, amount=amount)
except FragmentError as exc:
logger.error("Failed to recharge Ads account '%s' for %s TON: %s", account, amount, exc, exc_info=True)
raise
except Exception as exc:
logger.exception("Failed to recharge Ads account '%s' for %s TON due to an unexpected error", account, amount)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
+19
View File
@@ -0,0 +1,19 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from pyfragment.domains.ads.recharge import recharge_ads
from pyfragment.domains.ads.tonup import topup_ton
from pyfragment.domains.base import BaseService
from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult
if TYPE_CHECKING:
pass
class AdsService(BaseService):
async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
return await recharge_ads(self._client, account, amount)
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
return await topup_ton(self._client, username, amount, show_sender=show_sender)
+69
View File
@@ -0,0 +1,69 @@
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
from pyfragment.domains.payments import parse_required_payment_amount
from pyfragment.domains.tonapi.account import get_account_info
from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.exceptions import (
ConfigurationError,
FragmentAPIError,
FragmentError,
UnexpectedError,
UserNotFoundError,
VerificationError,
)
from pyfragment.models.payments import AdsTopupResult
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
logger = logging.getLogger(__name__)
async def topup_ton(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
try:
await client.call("updateAdsTopupState", {"mode": "new"}, page_url=ADS_TOPUP_PAGE)
result = await client.call("searchAdsTopupRecipient", {"query": username}, page_url=ADS_TOPUP_PAGE)
recipient = result.get("found", {}).get("recipient")
if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
result = await client.call("initAdsTopupRequest", {"recipient": recipient, "amount": amount}, page_url=ADS_TOPUP_PAGE)
required_payment_amount = parse_required_payment_amount(result)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup"))
account = await get_account_info(client)
transaction = await client.call(
"getAdsTopupLink",
{
"account": json.dumps(account),
"device": DEVICE,
"transaction": 1,
"id": req_id,
"show_sender": int(show_sender),
},
page_url=ADS_TOPUP_PAGE,
)
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
tx_hash = await process_transaction(client, transaction, required_payment_amount=required_payment_amount)
return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount)
except FragmentError as exc:
logger.error("Failed to top up TON for user '%s' with %s TON: %s", username, amount, exc, exc_info=True)
raise
except Exception as exc:
logger.exception("Failed to top up TON for user '%s' with %s TON due to an unexpected error", username, amount)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
@@ -0,0 +1,12 @@
from pyfragment.domains.anonymous_numbers.number import get_login_code, terminate_sessions, toggle_login_codes
from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
__all__ = [
"AnonymousNumbersService",
"LoginCodeResult",
"TerminateSessionsResult",
"get_login_code",
"terminate_sessions",
"toggle_login_codes",
]
@@ -0,0 +1,114 @@
from __future__ import annotations
import html
import logging
from typing import TYPE_CHECKING
from pyfragment.core.constants import NUMBERS_PAGE
from pyfragment.domains.anonymous_numbers.parser import parse_login_code
from pyfragment.exceptions import AnonymousNumberError, FragmentAPIError, FragmentError, UnexpectedError
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
logger = logging.getLogger(__name__)
def _strip_plus(number: str) -> str:
return number.lstrip("+") if isinstance(number, str) else number
async def get_login_code(client: FragmentClient, number: str) -> LoginCodeResult:
try:
clean = _strip_plus(number)
result = await client.call(
"updateLoginCodes",
{"number": clean, "lt": "0", "from_app": "1"},
page_url=NUMBERS_PAGE,
)
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 as exc:
logger.error("Failed to get login code for number '%s': %s", number, exc, exc_info=True)
raise
except Exception as exc:
logger.exception("Failed to get login code for number '%s' due to an unexpected error", number)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
async def toggle_login_codes(client: FragmentClient, number: str, can_receive: bool) -> None:
try:
clean = _strip_plus(number)
result = await client.call(
"toggleLoginCodes",
{"number": clean, "can_receive": 1 if can_receive else 0},
page_url=NUMBERS_PAGE,
)
if result.get("error"):
raise FragmentAPIError(html.unescape(result["error"]))
except FragmentError as exc:
logger.error(
"Failed to toggle login code delivery for number '%s' (can_receive=%s): %s",
number,
can_receive,
exc,
exc_info=True,
)
raise
except Exception as exc:
logger.exception(
"Failed to toggle login code delivery for number '%s' (can_receive=%s) due to an unexpected error",
number,
can_receive,
)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
async def terminate_sessions(client: FragmentClient, number: str) -> TerminateSessionsResult:
try:
clean = _strip_plus(number)
confirmation = await client.call(
"terminatePhoneSessions",
{"number": clean},
page_url=NUMBERS_PAGE,
)
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))
result = await client.call(
"terminatePhoneSessions",
{"number": clean, "terminate_hash": terminate_hash},
page_url=NUMBERS_PAGE,
)
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 as exc:
logger.error("Failed to terminate sessions for number '%s': %s", number, exc, exc_info=True)
raise
except Exception as exc:
logger.exception("Failed to terminate sessions for number '%s' due to an unexpected error", number)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
@@ -0,0 +1,13 @@
from __future__ import annotations
import re
CODE_RE = re.compile(r'class="[^"]*table-cell-value[^"]*"[^>]*>([^<]+)<')
ROW_RE = re.compile(r"<tr[\s>]")
def parse_login_code(html: str) -> tuple[str | None, int]:
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
@@ -0,0 +1,21 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from pyfragment.domains.anonymous_numbers.number import get_login_code, terminate_sessions, toggle_login_codes
from pyfragment.domains.base import BaseService
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
if TYPE_CHECKING:
pass
class AnonymousNumbersService(BaseService):
async def get_login_code(self, number: str) -> LoginCodeResult:
return await get_login_code(self._client, number)
async def toggle_login_codes(self, number: str, can_receive: bool) -> None:
return await toggle_login_codes(self._client, number, can_receive)
async def terminate_sessions(self, number: str) -> TerminateSessionsResult:
return await terminate_sessions(self._client, number)
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
import httpx
from pyfragment.core.constants import BASE_HEADERS
from pyfragment.core.transport import fragment_request, get_fragment_hash
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
logger = logging.getLogger(__name__)
async def raw_api_call(
cookies: dict[str, Any],
timeout: float,
method: str,
data: dict[str, Any] | None,
page_url: str,
) -> dict[str, Any]:
payload = {"method": method, **(data or {})}
headers = {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url}
logger.debug("Starting Fragment API call '%s' on %s", method, page_url)
try:
async with httpx.AsyncClient(cookies=cookies, timeout=timeout) as session:
fragment_hash = await get_fragment_hash(cookies, headers, page_url, timeout)
response = await fragment_request(session, fragment_hash, headers, payload)
logger.debug("Completed Fragment API call '%s' with response keys: %s", method, sorted(response.keys()))
return response
except Exception:
logger.exception("Failed to call Fragment API method '%s' on %s", method, page_url)
raise
class BaseService:
def __init__(self, client: FragmentClient) -> None:
self._client = client
+11
View File
@@ -0,0 +1,11 @@
from pyfragment.domains.giveaways.giveaway import giveaway_premium, giveaway_stars
from pyfragment.domains.giveaways.service import GiveawaysService
from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
__all__ = [
"GiveawaysService",
"PremiumGiveawayResult",
"StarsGiveawayResult",
"giveaway_premium",
"giveaway_stars",
]
+216
View File
@@ -0,0 +1,216 @@
from __future__ import annotations
import json
import logging
import random
from typing import TYPE_CHECKING, get_args
from pyfragment.core.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, STARS_GIVEAWAY_PAGE
from pyfragment.domains.payments import parse_required_payment_amount
from pyfragment.domains.tonapi.account import get_account_info
from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.exceptions import (
ConfigurationError,
FragmentAPIError,
FragmentError,
UnexpectedError,
UserNotFoundError,
VerificationError,
)
from pyfragment.models.enums import PaymentMethod
from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
logger = logging.getLogger(__name__)
async def giveaway_stars(
client: FragmentClient,
channel: str,
winners: int,
amount: int,
payment_method: PaymentMethod = "ton",
) -> StarsGiveawayResult:
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)
if payment_method not in get_args(PaymentMethod):
raise ConfigurationError(
ConfigurationError.INVALID_PAYMENT_METHOD.format(
method=payment_method,
supported=", ".join(sorted(get_args(PaymentMethod))),
)
)
try:
result = await client.call("searchStarsGiveawayRecipient", {"query": channel}, page_url=STARS_GIVEAWAY_PAGE)
recipient = result.get("found", {}).get("recipient")
if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel))
await client.call(
"updateStarsGiveawayState",
{"mode": "new", "lv": "false", "dh": str(random.randint(100_000_000, 999_999_999))},
page_url=STARS_GIVEAWAY_PAGE,
)
result = await client.call(
"initGiveawayStarsRequest",
{
"recipient": recipient,
"quantity": str(winners),
"stars": str(amount),
"payment_method": payment_method,
},
page_url=STARS_GIVEAWAY_PAGE,
)
required_payment_amount = parse_required_payment_amount(result)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars giveaway"))
account = await get_account_info(client)
transaction = await client.call(
"getGiveawayStarsLink",
{
"account": json.dumps(account),
"device": DEVICE,
"transaction": 1,
"id": req_id,
},
page_url=STARS_GIVEAWAY_PAGE,
)
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
tx_hash = await process_transaction(
client,
transaction,
payment_method=payment_method,
required_payment_amount=required_payment_amount,
)
return StarsGiveawayResult(transaction_id=tx_hash, channel=channel, winners=winners, amount=amount)
except FragmentError as exc:
logger.error(
"Failed to run Stars giveaway for channel '%s' (winners=%s, amount=%s, payment_method='%s'): %s",
channel,
winners,
amount,
payment_method,
exc,
exc_info=True,
)
raise
except Exception as exc:
logger.exception(
"Failed to run Stars giveaway for channel '%s' (winners=%s, amount=%s, payment_method='%s') due to an unexpected error",
channel,
winners,
amount,
payment_method,
)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
async def giveaway_premium(
client: FragmentClient,
channel: str,
winners: int,
months: int = 3,
payment_method: PaymentMethod = "ton",
) -> PremiumGiveawayResult:
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)
if payment_method not in get_args(PaymentMethod):
raise ConfigurationError(
ConfigurationError.INVALID_PAYMENT_METHOD.format(
method=payment_method,
supported=", ".join(sorted(get_args(PaymentMethod))),
)
)
try:
result = await client.call(
"searchPremiumGiveawayRecipient",
{"query": channel, "quantity": winners, "months": months},
page_url=PREMIUM_GIVEAWAY_PAGE,
)
recipient = result.get("found", {}).get("recipient")
if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel))
await client.call(
"updatePremiumGiveawayState",
{
"mode": "new",
"lv": "false",
"dh": str(random.randint(100_000_000, 999_999_999)),
"quantity": "",
},
page_url=PREMIUM_GIVEAWAY_PAGE,
)
result = await client.call(
"initGiveawayPremiumRequest",
{
"recipient": recipient,
"quantity": str(winners),
"months": str(months),
"payment_method": payment_method,
},
page_url=PREMIUM_GIVEAWAY_PAGE,
)
required_payment_amount = parse_required_payment_amount(result)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium giveaway"))
account = await get_account_info(client)
transaction = await client.call(
"getGiveawayPremiumLink",
{
"account": json.dumps(account),
"device": DEVICE,
"transaction": 1,
"id": req_id,
},
page_url=PREMIUM_GIVEAWAY_PAGE,
)
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
tx_hash = await process_transaction(
client,
transaction,
payment_method=payment_method,
required_payment_amount=required_payment_amount,
)
return PremiumGiveawayResult(transaction_id=tx_hash, channel=channel, winners=winners, amount=months)
except FragmentError as exc:
logger.error(
"Failed to run Premium giveaway for channel '%s' (winners=%s, months=%s, payment_method='%s'): %s",
channel,
winners,
months,
payment_method,
exc,
exc_info=True,
)
raise
except Exception as exc:
logger.exception(
"Failed to run Premium giveaway for channel '%s' (winners=%s, months=%s, payment_method='%s') due to an unexpected error",
channel,
winners,
months,
payment_method,
)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from pyfragment.domains.base import BaseService
from pyfragment.domains.giveaways.giveaway import giveaway_premium, giveaway_stars
from pyfragment.models.enums import PaymentMethod
from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
if TYPE_CHECKING:
pass
class GiveawaysService(BaseService):
async def giveaway_stars(
self,
channel: str,
winners: int,
amount: int,
payment_method: PaymentMethod = "ton",
) -> StarsGiveawayResult:
return await giveaway_stars(self._client, channel, winners, amount, payment_method=payment_method)
async def giveaway_premium(
self,
channel: str,
winners: int,
months: int = 3,
payment_method: PaymentMethod = "ton",
) -> PremiumGiveawayResult:
return await giveaway_premium(self._client, channel, winners, months, payment_method=payment_method)
@@ -0,0 +1,13 @@
from pyfragment.domains.marketplace.search import search_gifts, search_numbers, search_usernames
from pyfragment.domains.marketplace.service import MarketplaceService
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
__all__ = [
"GiftsResult",
"MarketplaceService",
"NumbersResult",
"UsernamesResult",
"search_gifts",
"search_numbers",
"search_usernames",
]
@@ -1,22 +1,16 @@
from __future__ import annotations
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"<tr[\s>]")
# Auction table row parsing
ROW_BLOCK_RE = re.compile(r'<tr\b[^>]*class="[^"]*tm-row-selectable[^"]*"[^>]*>(.*?)</tr>', 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'<time[^>]+datetime="([^"]+)"[^>]*data-relative="text"[^>]*>')
DATETIME_SHORT_RE = re.compile(r'<time[^>]+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,. ]+$")
# Gift grid item parsing
GRID_ITEM_RE = re.compile(r'<a\b[^>]*class="[^"]*tm-grid-item[^"]*"[^>]*>(.*?)</a>', re.DOTALL)
GRID_HREF_RE = re.compile(r'href="(/gift/([^?"]+))')
GRID_NAME_RE = re.compile(r'class="item-name">([^<]+)<')
@@ -26,39 +20,7 @@ GRID_STATUS_RE = re.compile(r'class="[^"]*tm-grid-item-status[^"]*"[^>]*>\s*([^<
GRID_DATETIME_RE = re.compile(r'<time[^>]+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.
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 ``<tr>``
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
def parse_auction_rows(html: str) -> list[dict[str, Any]]:
"""Parse Fragment marketplace HTML into structured item dicts.
Extracts each ``<tr class="tm-row-selectable">`` 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.
- ``date`` ISO 8601 datetime string: auction end date, sale date, or listing date, 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)
@@ -66,21 +28,17 @@ def parse_auction_rows(html: str) -> list[dict[str, Any]]:
href_m = HREF_RE.search(row)
if not href_m:
continue
slug = href_m.group(1).lstrip("/") # e.g. "username/durov"
slug = href_m.group(1).lstrip("/")
# 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:
@@ -90,38 +48,15 @@ def parse_auction_rows(html: str) -> list[dict[str, Any]]:
except ValueError:
price = raw_price
# Datetime (ISO 8601) — auction end, sale date, or listing date.
time_m = DATETIME_RE.search(row) or DATETIME_SHORT_RE.search(row)
date: str | None = time_m.group(1) if time_m else None
items.append(
{
"slug": slug,
"name": name,
"status": status,
"price": price,
"date": date,
}
)
items.append({"slug": slug, "name": name, "status": status, "price": price, "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 ``<a class="tm-grid-item">`` 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)
@@ -129,7 +64,7 @@ def parse_gift_items(html: str) -> tuple[list[dict[str, Any]], int | None]:
href_m = GRID_HREF_RE.search(block)
if not href_m:
continue
slug = href_m.group(1).lstrip("/") # e.g. "gift/plushpepe-1821"
slug = href_m.group(1).lstrip("/")
name_m = GRID_NAME_RE.search(block)
num_m = GRID_NUM_RE.search(block)
@@ -154,7 +89,6 @@ def parse_gift_items(html: str) -> tuple[list[dict[str, Any]], int | 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
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from pyfragment.core.constants import FRAGMENT_BASE_URL, GIFTS_PAGE, NUMBERS_PAGE
from pyfragment.domains.marketplace.parser import parse_auction_rows, parse_gift_items
from pyfragment.exceptions import FragmentAPIError, FragmentError, UnexpectedError
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
logger = logging.getLogger(__name__)
async def search_usernames(
client: FragmentClient,
query: str = "",
sort: str | None = None,
filter: str | None = None,
offset_id: str | None = None,
) -> UsernamesResult:
data: dict[str, Any] = {"type": "usernames", "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:
result = await client.call("searchAuctions", data, page_url=FRAGMENT_BASE_URL)
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 UsernamesResult(items=items, next_offset_id=next_offset_id)
except FragmentError as exc:
logger.error(
"Failed to search usernames (query='%s', sort='%s', filter='%s', offset_id='%s'): %s",
query,
sort,
filter,
offset_id,
exc,
exc_info=True,
)
raise
except Exception as exc:
logger.exception("Failed to search usernames for query '%s' due to an unexpected error", query)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
async def search_numbers(
client: FragmentClient,
query: str = "",
sort: str | None = None,
filter: str | None = None,
offset_id: str | None = None,
) -> NumbersResult:
data: dict[str, Any] = {"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:
result = await client.call("searchAuctions", data, page_url=NUMBERS_PAGE)
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 as exc:
logger.error(
"Failed to search numbers (query='%s', sort='%s', filter='%s', offset_id='%s'): %s",
query,
sort,
filter,
offset_id,
exc,
exc_info=True,
)
raise
except Exception as exc:
logger.exception("Failed to search numbers for query '%s' due to an unexpected error", query)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
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:
data: dict[str, Any] = {"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:
result = await client.call("searchAuctions", data, page_url=GIFTS_PAGE)
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 as exc:
logger.error(
"Failed to search gifts (query='%s', collection='%s', sort='%s', filter='%s', view='%s', offset='%s'): %s",
query,
collection,
sort,
filter,
view,
offset,
exc,
exc_info=True,
)
raise
except Exception as exc:
logger.exception("Failed to search gifts for query '%s' due to an unexpected error", query)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from pyfragment.domains.base import BaseService
from pyfragment.domains.marketplace.search import search_gifts, search_numbers, search_usernames
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
if TYPE_CHECKING:
pass
class MarketplaceService(BaseService):
async def search_usernames(
self,
query: str = "",
sort: str | None = None,
filter: str | None = None,
offset_id: str | None = None,
) -> UsernamesResult:
return await search_usernames(self._client, 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:
return await search_numbers(self._client, 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:
return await search_gifts(
self._client, query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset
)
+11
View File
@@ -0,0 +1,11 @@
from __future__ import annotations
from typing import Any
def parse_required_payment_amount(init_response: dict[str, Any]) -> float | None:
raw_amount = init_response.get("amount")
try:
return float(str(raw_amount))
except (TypeError, ValueError):
return None
+5
View File
@@ -0,0 +1,5 @@
from pyfragment.domains.purchases.purchase import purchase_premium, purchase_stars
from pyfragment.domains.purchases.service import PurchasesService
from pyfragment.models.payments import PremiumResult, StarsResult
__all__ = ["PremiumResult", "PurchasesService", "StarsResult", "purchase_premium", "purchase_stars"]
+189
View File
@@ -0,0 +1,189 @@
from __future__ import annotations
import json
import logging
import time
from typing import TYPE_CHECKING, get_args
from pyfragment.core.constants import DEVICE, PREMIUM_PAGE, STARS_PAGE
from pyfragment.domains.payments import parse_required_payment_amount
from pyfragment.domains.tonapi.account import get_account_info
from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.exceptions import (
ConfigurationError,
FragmentAPIError,
FragmentError,
UnexpectedError,
UserNotFoundError,
VerificationError,
)
from pyfragment.models.enums import PaymentMethod
from pyfragment.models.payments import PremiumResult, StarsResult
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
logger = logging.getLogger(__name__)
async def purchase_stars(
client: FragmentClient,
username: str,
amount: int,
show_sender: bool = True,
payment_method: PaymentMethod = "ton",
) -> StarsResult:
if not isinstance(amount, int) or not (50 <= amount <= 1_000_000):
raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT)
if payment_method not in get_args(PaymentMethod):
raise ConfigurationError(
ConfigurationError.INVALID_PAYMENT_METHOD.format(
method=payment_method,
supported=", ".join(sorted(get_args(PaymentMethod))),
)
)
try:
result = await client.call("searchStarsRecipient", {"query": username, "quantity": ""}, page_url=STARS_PAGE)
recipient = result.get("found", {}).get("recipient")
if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
await client.call(
"updateStarsBuyState",
{"mode": "new", "lv": "false", "dh": str(int(time.time()))},
page_url=STARS_PAGE,
)
result = await client.call(
"initBuyStarsRequest",
{"recipient": recipient, "quantity": amount, "payment_method": payment_method},
page_url=STARS_PAGE,
)
required_payment_amount = parse_required_payment_amount(result)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase"))
account = await get_account_info(client)
transaction = await client.call(
"getBuyStarsLink",
{
"account": json.dumps(account),
"device": DEVICE,
"transaction": 1,
"id": req_id,
"show_sender": int(show_sender),
},
page_url=STARS_PAGE,
)
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
tx_hash = await process_transaction(
client,
transaction,
payment_method=payment_method,
required_payment_amount=required_payment_amount,
)
return StarsResult(transaction_id=tx_hash, username=username, amount=amount)
except FragmentError as exc:
logger.error(
"Failed to purchase %s Stars for user '%s' using '%s': %s",
amount,
username,
payment_method,
exc,
exc_info=True,
)
raise
except Exception as exc:
logger.exception(
"Failed to purchase %s Stars for user '%s' using '%s' due to an unexpected error",
amount,
username,
payment_method,
)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
async def purchase_premium(
client: FragmentClient,
username: str,
months: int,
show_sender: bool = True,
payment_method: PaymentMethod = "ton",
) -> PremiumResult:
if months not in (3, 6, 12):
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
if payment_method not in get_args(PaymentMethod):
raise ConfigurationError(
ConfigurationError.INVALID_PAYMENT_METHOD.format(
method=payment_method,
supported=", ".join(sorted(get_args(PaymentMethod))),
)
)
try:
result = await client.call("searchPremiumGiftRecipient", {"query": username, "months": months}, page_url=PREMIUM_PAGE)
recipient = result.get("found", {}).get("recipient")
if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
await client.call(
"updatePremiumState",
{"mode": "new", "lv": "false", "dh": str(int(time.time()))},
page_url=PREMIUM_PAGE,
)
result = await client.call(
"initGiftPremiumRequest",
{"recipient": recipient, "months": months, "payment_method": payment_method},
page_url=PREMIUM_PAGE,
)
required_payment_amount = parse_required_payment_amount(result)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase"))
account = await get_account_info(client)
transaction = await client.call(
"getGiftPremiumLink",
{
"account": json.dumps(account),
"device": DEVICE,
"transaction": 1,
"id": req_id,
"show_sender": int(show_sender),
},
page_url=PREMIUM_PAGE,
)
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
tx_hash = await process_transaction(
client,
transaction,
payment_method=payment_method,
required_payment_amount=required_payment_amount,
)
return PremiumResult(transaction_id=tx_hash, username=username, amount=months)
except FragmentError as exc:
logger.error(
"Failed to purchase %s months of Premium for user '%s' using '%s': %s",
months,
username,
payment_method,
exc,
exc_info=True,
)
raise
except Exception as exc:
logger.exception(
"Failed to purchase %s months of Premium for user '%s' using '%s' due to an unexpected error",
months,
username,
payment_method,
)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from pyfragment.domains.base import BaseService
from pyfragment.domains.purchases.purchase import purchase_premium, purchase_stars
from pyfragment.models.enums import PaymentMethod
from pyfragment.models.payments import PremiumResult, StarsResult
if TYPE_CHECKING:
pass
class PurchasesService(BaseService):
async def purchase_stars(
self,
username: str,
amount: int,
show_sender: bool = True,
payment_method: PaymentMethod = "ton",
) -> StarsResult:
return await purchase_stars(self._client, username, amount, show_sender=show_sender, payment_method=payment_method)
async def purchase_premium(
self,
username: str,
months: int,
show_sender: bool = True,
payment_method: PaymentMethod = "ton",
) -> PremiumResult:
return await purchase_premium(self._client, username, months, show_sender=show_sender, payment_method=payment_method)
+5
View File
@@ -0,0 +1,5 @@
from pyfragment.domains.tonapi.service import TonapiService
__all__ = [
"TonapiService",
]
+127
View File
@@ -0,0 +1,127 @@
from __future__ import annotations
import base64
import logging
from typing import TYPE_CHECKING, Any
from ton_core import NetworkGlobalID
from tonutils.clients import TonapiClient
from tonutils.contracts.jetton import get_wallet_address_get_method, get_wallet_data_get_method
from tonutils.exceptions import ProviderResponseError
from pyfragment.core.constants import MIN_TON_BALANCE, MIN_USDT_BALANCE, USDT_TON_MASTER_ADDRESS, WALLET_CLASSES
from pyfragment.exceptions import WalletError
from pyfragment.models.wallet import WalletInfo
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
logger = logging.getLogger(__name__)
async def get_usdt_balance(ton: Any, wallet_address: str) -> float:
"""Return the USDT balance for a Fragment-linked TON wallet."""
try:
jetton_wallet_address = await get_wallet_address_get_method(
client=ton,
address=USDT_TON_MASTER_ADDRESS,
owner_address=wallet_address,
)
wallet_data = await get_wallet_data_get_method(client=ton, address=jetton_wallet_address)
raw_balance = int(wallet_data[0]) if wallet_data else 0
return float(raw_balance) / 1_000_000.0
except ProviderResponseError as exc:
if exc.code == 404:
logger.debug("No USDT jetton wallet found for '%s'; treating balance as 0", wallet_address)
return 0.0
logger.error("Failed to load USDT balance for wallet '%s': %s", wallet_address, exc, exc_info=True)
raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
except Exception as exc:
logger.exception("Failed to load USDT balance for wallet '%s' due to an unexpected error", wallet_address)
raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
async def check_ton_payment_balance(
balance_ton: float,
amount_ton: float,
required_payment_amount: float | None,
) -> None:
"""Validate that the TON wallet can cover a TON-denominated payment."""
tx_price_ton = amount_ton
if required_payment_amount is not None and required_payment_amount > 0:
tx_price_ton = max(tx_price_ton, required_payment_amount)
required_ton = max(tx_price_ton, MIN_TON_BALANCE)
if balance_ton < required_ton:
logger.error(
"Failed TON balance check: balance=%s TON, required=%s TON",
round(balance_ton, 6),
round(required_ton, 6),
)
raise WalletError(WalletError.LOW_TON_BALANCE.format(balance=balance_ton, required=required_ton))
async def check_usdt_payment_balance(
balance_ton: float,
required_payment_amount: float | None,
ton: Any,
wallet_address: str,
) -> None:
"""Validate that the wallet can cover a USDT-denominated payment."""
if balance_ton < MIN_TON_BALANCE:
logger.error(
"Failed TON gas reserve check for USDT payment: balance=%s TON, required=%s TON",
round(balance_ton, 6),
MIN_TON_BALANCE,
)
raise WalletError(WalletError.LOW_TON_BALANCE.format(balance=balance_ton, required=MIN_TON_BALANCE))
usdt_balance = await get_usdt_balance(ton, wallet_address)
required_usdt = required_payment_amount if required_payment_amount is not None else MIN_USDT_BALANCE
if usdt_balance < required_usdt:
logger.error(
"Failed USDT balance check for wallet '%s': balance=%s USDT, required=%s USDT",
wallet_address,
round(usdt_balance, 6),
round(required_usdt, 6),
)
raise WalletError(WalletError.LOW_USDT_BALANCE.format(balance=usdt_balance, required=required_usdt))
async def get_account_info(client: FragmentClient) -> dict[str, Any]:
"""Build the wallet payload Fragment needs to prepare a transaction."""
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
try:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
boc = wallet.state_init.serialize().to_boc()
return {
"address": wallet.address.to_str(False, False),
"publicKey": pub_key.as_hex,
"chain": "-239",
"walletStateInit": base64.b64encode(boc).decode(),
}
except Exception as exc:
logger.exception("Failed to build Fragment account info from the configured wallet")
raise WalletError(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc
async def get_wallet_info(client: FragmentClient) -> WalletInfo:
"""Fetch the wallet address, chain state, and TON/USDT balances."""
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
try:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
await wallet.refresh()
wallet_address = wallet.address.to_str(False, False)
usdt_balance = await get_usdt_balance(ton, wallet_address)
return WalletInfo(
address=wallet.address.to_str(is_user_friendly=True, is_bounceable=False),
state=wallet.state.value,
ton_balance=round(wallet.balance / 1_000_000_000, 4),
usdt_balance=round(usdt_balance, 4),
)
except Exception as exc:
logger.exception("Failed to fetch wallet info from Tonapi")
raise WalletError(WalletError.WALLET_INFO_FAILED.format(exc=exc)) from exc
+15
View File
@@ -0,0 +1,15 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from pyfragment.domains.base import BaseService
from pyfragment.domains.tonapi.account import get_wallet_info
from pyfragment.models.wallet import WalletInfo
if TYPE_CHECKING:
pass
class TonapiService(BaseService):
async def get_wallet(self) -> WalletInfo:
return await get_wallet_info(self._client)
+170
View File
@@ -0,0 +1,170 @@
from __future__ import annotations
import asyncio
import base64
import logging
import random
import ssl
from typing import TYPE_CHECKING, Any
from ton_core import Cell, NetworkGlobalID
from tonutils.clients import TonapiClient
from tonutils.exceptions import ProviderResponseError
from pyfragment.core.constants import WALLET_CLASSES
from pyfragment.domains.tonapi.account import check_ton_payment_balance, check_usdt_payment_balance
from pyfragment.exceptions import ParseError, TransactionError, WalletError
from pyfragment.models.enums import PaymentMethod
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
logger = logging.getLogger(__name__)
def clean_decode(payload: str) -> str | Cell:
"""Decode a base64 BOC comment from Fragment into text when possible.
Some Fragment payloads are plain text comments, while others are structured
TON messages such as jetton transfers. Non-text payloads are returned as a
`Cell` so the caller can keep the raw binary structure.
"""
s = payload.strip()
if not s:
return ""
s += "=" * (-len(s) % 4)
try:
boc = base64.b64decode(s, altchars=b"-_", validate=True)
cell = Cell.one_from_boc(boc)
sl = cell.begin_parse()
op = sl.load_uint(32)
if op != 0:
# Non-zero op code means this is a structured TON message, not a plain text comment.
return cell
try:
return sl.load_snake_string().strip()
except UnicodeDecodeError:
return cell
except Exception as exc:
logger.exception("Failed to decode Fragment payload")
raise ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc
def _extract_message(transaction_data: dict[str, Any]) -> dict[str, Any]:
"""Validate and extract the first message from a Fragment transaction payload."""
if "transaction" not in transaction_data or not transaction_data["transaction"].get("messages"):
logger.error("Failed to process transaction: missing transaction payload or messages")
raise TransactionError(TransactionError.INVALID_PAYLOAD)
result: dict[str, Any] = transaction_data["transaction"]["messages"][0]
return result
async def _check_payment_balances(
wallet: Any,
payment_method: PaymentMethod,
amount_ton: float,
required_payment_amount: float | None,
transaction_data: dict[str, Any],
ton: Any,
) -> None:
"""Refresh wallet and verify sufficient balance before broadcasting."""
try:
await wallet.refresh()
balance_ton = wallet.balance / 1_000_000_000
if payment_method == "ton":
await check_ton_payment_balance(balance_ton, amount_ton, required_payment_amount)
else:
# USDT is paid from the Fragment-linked wallet, not the signing wallet.
fragment_wallet_address = transaction_data["transaction"].get("from", "")
await check_usdt_payment_balance(balance_ton, required_payment_amount, ton, fragment_wallet_address)
except WalletError:
raise
except Exception as exc:
logger.exception("Failed to validate balances before broadcasting transaction")
raise WalletError(WalletError.TON_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
async def _broadcast_with_retry(wallet: Any, message: dict[str, Any], payload: str | Cell) -> str:
"""Attempt to broadcast a transaction up to 3 times, handling rate-limit and seqno errors."""
for attempt in range(3):
try:
result = await wallet.transfer(
destination=message["address"],
amount=int(message["amount"]), # nanotons, not TON
body=payload,
)
return str(result.normalized_hash)
except ProviderResponseError as exc:
if exc.code == 429 and attempt == 0:
logger.warning(
"Broadcast rate-limited (429), retrying transaction once: %s",
exc,
exc_info=True,
)
await asyncio.sleep(1 + random.uniform(0, 0.5))
continue
if exc.code == 406 and "seqno" in str(exc).lower():
if attempt < 2:
logger.warning(
"Broadcast seqno conflict (406), retrying attempt %s: %s",
attempt + 2,
exc,
exc_info=True,
)
await asyncio.sleep(2 + random.uniform(0, 1))
continue
logger.error("Failed to broadcast transaction after seqno retries")
raise TransactionError(TransactionError.DUPLICATE_SEQNO) from exc
raise
logger.error("Failed to broadcast transaction: transfer loop exited without result")
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result"))
async def process_transaction(
client: FragmentClient,
transaction_data: dict[str, Any],
payment_method: PaymentMethod = "ton",
required_payment_amount: float | None = None,
) -> str:
"""Sign and broadcast a Fragment transaction with the seeded TON wallet.
Args:
client: Authenticated `FragmentClient` instance.
transaction_data: Raw Fragment transaction payload returned by the API.
payment_method: Payment currency to use for the purchase flow.
required_payment_amount: Optional amount returned by Fragment's init request.
Returns:
Normalized transaction hash string.
"""
message = _extract_message(transaction_data)
amount_ton = int(message["amount"]) / 1_000_000_000
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
await _check_payment_balances(wallet, payment_method, amount_ton, required_payment_amount, transaction_data, ton)
payload = clean_decode(str(message.get("payload", "")))
try:
return await _broadcast_with_retry(wallet, message, payload)
except (WalletError, TransactionError):
raise
except Exception as exc:
cause: BaseException | None = exc
while cause is not None:
if isinstance(cause, ssl.SSLError):
logger.exception("Failed to broadcast transaction due to SSL error")
raise TransactionError(TransactionError.BROADCAST_FAILED_SSL.format(exc=exc)) from exc
cause = cause.__cause__ or cause.__context__
logger.exception(
"Failed to broadcast transaction to '%s' for %s nanotons using payment method '%s'",
message["address"],
message["amount"],
payment_method,
)
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
@@ -1,69 +1,73 @@
from __future__ import annotations
class FragmentError(Exception):
"""Base exception for all pyfragment library errors."""
"""Base exception for all pyfragment errors."""
class ClientError(FragmentError):
"""Raised for client configuration and setup issues (bad params, invalid cookies)."""
"""Raised for client configuration and setup issues."""
class ConfigurationError(ClientError):
"""Raised when required client parameters are missing or invalid."""
MISSING_VARS = "Missing required parameter(s): {keys}."
UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}."
INVALID_MNEMONIC = "Invalid mnemonic: expected 12, 18, or 24 words, got {count}."
UNSUPPORTED_VERSION = "Unsupported wallet version '{version}'. Supported values: {supported}."
INVALID_MNEMONIC = "Invalid mnemonic phrase: 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 Tonapi API key: expected at least 68 characters, got {length}. Get a key at https://tonconsole.com."
)
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_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 532 characters and contain only letters (AZ, az), digits (09), or underscores (_)."
"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."
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."
INVALID_PAYMENT_METHOD = "Invalid payment method '{method}'. Supported values: {supported}."
class CookieError(ClientError):
"""Raised when cookies are unreadable or missing required fields."""
READ_FAILED = "Failed to parse cookies expected a JSON string or a dict, got: {exc}"
READ_FAILED = "Failed to parse cookies: expected a JSON string or a dict, got {exc}."
MISSING_KEYS = (
"Fragment cookies are missing or empty for key(s): {keys}. "
"Open fragment.com in your browser, log in, and copy fresh cookies."
)
UNSUPPORTED_BROWSER = "Unsupported browser '{browser}'. Supported values: {supported}."
BROWSER_READ_FAILED = (
"Failed to read {browser} cookies: {exc}. Make sure {browser} is installed and you are logged in to {url}."
)
MISSING_BROWSER_KEYS = (
"Fragment cookies not found in {browser}: {keys}. "
"Make sure you are logged in to {url} and have connected your TON wallet in {browser}."
)
EXPIRED = "Fragment session cookie expired at {expires}. Log in to fragment.com in your browser and extract fresh cookies."
class FragmentAPIError(FragmentError):
"""Raised for errors returned by Fragment's API responses."""
NO_REQUEST_ID = (
"Fragment did not return a request ID for '{context}'. "
"Your session may have expired — log in to fragment.com and refresh your cookies."
)
NO_REQUEST_ID = "Fragment did not return a request ID for '{context}'. Your session may have expired. Refresh your cookies and try again."
class FragmentPageError(FragmentAPIError):
"""Raised when the Fragment page cannot be fetched or the API hash is not found."""
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 = (
"Could not extract the API hash from {url}. "
"The page structure may have changed, or you are not logged in — refresh your cookies."
)
BAD_STATUS = "Fragment returned HTTP {status} when loading {url}. Your cookies may be invalid or expired. Refresh them and try again."
NOT_FOUND = "Could not extract the API hash from {url}. The page structure may have changed, or you may not be logged in. Refresh your cookies."
class UserNotFoundError(FragmentAPIError):
"""Raised when the target Telegram user is not found on Fragment."""
NOT_FOUND = (
"Telegram user '{username}' was not found on Fragment. " "Double-check the username and make sure the account exists."
"Telegram user '{username}' was not found on Fragment. Double-check the username and make sure the account exists."
)
@@ -77,9 +81,7 @@ class AnonymousNumberError(FragmentAPIError):
class TransactionError(FragmentAPIError):
"""Raised when a TON transaction fails to build or broadcast."""
INVALID_PAYLOAD = (
"Fragment returned an invalid transaction payload — " "'transaction.messages' is missing or empty in the API response."
)
INVALID_PAYLOAD = "Fragment returned an invalid transaction payload: 'transaction.messages' is missing or empty."
BROADCAST_FAILED = "Transaction broadcast failed: {exc}"
BROADCAST_FAILED_SSL = (
"Transaction broadcast failed due to an SSL certificate error: {exc}\n"
@@ -118,11 +120,10 @@ class OperationError(FragmentError):
class WalletError(OperationError):
"""Raised for TON wallet issues (connection, balance, account info)."""
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}"
LOW_TON_BALANCE = "Insufficient TON balance: {balance:.4f} TON available, {required:.4f} TON required."
LOW_USDT_BALANCE = "Insufficient USDT balance: {balance:.4f} USDT available, {required:.4f} USDT required."
TON_BALANCE_CHECK_FAILED = "Failed to fetch TON balance: {exc}"
USDT_BALANCE_CHECK_FAILED = "Failed to fetch USDT 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}"
-25
View File
@@ -1,25 +0,0 @@
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.recharge_ads import recharge_ads
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__ = [
"get_login_code",
"giveaway_premium",
"giveaway_stars",
"purchase_premium",
"purchase_stars",
"recharge_ads",
"search_gifts",
"search_numbers",
"search_usernames",
"terminate_sessions",
"toggle_login_codes",
"topup_ton",
]
-151
View File
@@ -1,151 +0,0 @@
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
-135
View File
@@ -1,135 +0,0 @@
import json
from typing import TYPE_CHECKING
import httpx
from pyfragment.types import (
ConfigurationError,
FragmentAPIError,
FragmentError,
UnexpectedError,
UserNotFoundError,
)
from pyfragment.types.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE
from pyfragment.types.results import PremiumGiveawayResult
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(PREMIUM_GIVEAWAY_PAGE)
async def _search_recipient(
session: httpx.AsyncClient,
fragment_hash: str,
channel: str,
winners: int,
months: int,
) -> str:
result = await fragment_request(
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_request(
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 ``amount``.
Raises:
ConfigurationError: If ``winners`` is not 124 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
-131
View File
@@ -1,131 +0,0 @@
import json
from typing import TYPE_CHECKING
import httpx
from pyfragment.types import (
ConfigurationError,
FragmentAPIError,
FragmentError,
UnexpectedError,
UserNotFoundError,
)
from pyfragment.types.constants import DEVICE, STARS_GIVEAWAY_PAGE
from pyfragment.types.results import StarsGiveawayResult
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(STARS_GIVEAWAY_PAGE)
async def _search_recipient(
session: httpx.AsyncClient,
fragment_hash: str,
channel: str,
) -> str:
result = await fragment_request(
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_request(
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 15 or ``amount`` is not 5001 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
-131
View File
@@ -1,131 +0,0 @@
import json
import time
from typing import TYPE_CHECKING
import httpx
from pyfragment.types import (
ConfigurationError,
FragmentAPIError,
FragmentError,
PremiumResult,
UnexpectedError,
UserNotFoundError,
)
from pyfragment.types.constants import DEVICE, PREMIUM_PAGE
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(PREMIUM_PAGE)
async def _search_recipient(
session: httpx.AsyncClient,
fragment_hash: str,
username: str,
months: int,
) -> str:
result = await fragment_request(
session,
fragment_hash,
HEADERS,
{
"query": username,
"months": months,
"method": "searchPremiumGiftRecipient",
},
)
recipient = result.get("found", {}).get("recipient")
if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
return recipient
async def _init_request(
session: httpx.AsyncClient,
fragment_hash: str,
recipient: str,
months: int,
) -> str:
await fragment_request(
session,
fragment_hash,
HEADERS,
{
"mode": "new",
"lv": "false",
"dh": str(int(time.time())),
"method": "updatePremiumState",
},
)
result = await fragment_request(
session,
fragment_hash,
HEADERS,
{
"recipient": recipient,
"months": months,
"method": "initGiftPremiumRequest",
},
)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase"))
return req_id
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 ``amount``.
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)
try:
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, timeout=client.timeout) as session:
recipient = await _search_recipient(session, fragment_hash, username, months)
req_id = await _init_request(session, fragment_hash, recipient, months)
tx_data = {
"account": json.dumps(account),
"device": DEVICE,
"transaction": 1,
"id": req_id,
"show_sender": int(show_sender),
"method": "getGiftPremiumLink",
}
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, amount=months)
except FragmentError:
raise
except Exception as exc:
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
-118
View File
@@ -1,118 +0,0 @@
import json
from typing import TYPE_CHECKING
import httpx
from pyfragment.types import (
ConfigurationError,
FragmentAPIError,
FragmentError,
StarsResult,
UnexpectedError,
UserNotFoundError,
)
from pyfragment.types.constants import DEVICE, STARS_PAGE
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(STARS_PAGE)
async def _search_recipient(
session: httpx.AsyncClient,
fragment_hash: str,
username: str,
) -> str:
result = await fragment_request(
session,
fragment_hash,
HEADERS,
{
"query": username,
"quantity": "",
"method": "searchStarsRecipient",
},
)
recipient = result.get("found", {}).get("recipient")
if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
return recipient
async def _init_request(
session: httpx.AsyncClient,
fragment_hash: str,
recipient: str,
amount: int,
) -> str:
result = await fragment_request(
session,
fragment_hash,
HEADERS,
{
"recipient": recipient,
"quantity": amount,
"method": "initBuyStarsRequest",
},
)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase"))
return req_id
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 ``amount``.
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)
try:
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, timeout=client.timeout) as session:
recipient = await _search_recipient(session, fragment_hash, username)
req_id = await _init_request(session, fragment_hash, recipient, amount)
tx_data = {
"account": json.dumps(account),
"device": DEVICE,
"transaction": 1,
"id": req_id,
"show_sender": int(show_sender),
"method": "getBuyStarsLink",
}
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, amount=amount)
except FragmentError:
raise
except Exception as exc:
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
-94
View File
@@ -1,94 +0,0 @@
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
-80
View File
@@ -1,80 +0,0 @@
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
-67
View File
@@ -1,67 +0,0 @@
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
-67
View File
@@ -1,67 +0,0 @@
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 UsernamesResult
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_usernames(
client: "FragmentClient",
query: str = "",
sort: str | None = None,
filter: str | None = None,
offset_id: str | None = None,
) -> UsernamesResult:
"""Search the Fragment marketplace for Telegram usernames.
Args:
client: Authenticated :class:`FragmentClient` instance.
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:`UsernamesResult`.
Pass ``next_offset_id`` to fetch the next page.
Returns:
: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", "type": "usernames", "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, 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 UsernamesResult(items=items, next_offset_id=next_offset_id)
except FragmentError:
raise
except Exception as exc:
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
-118
View File
@@ -1,118 +0,0 @@
import json
from typing import TYPE_CHECKING
import httpx
from pyfragment.types import (
AdsTopupResult,
ConfigurationError,
FragmentAPIError,
FragmentError,
UnexpectedError,
UserNotFoundError,
)
from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE
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 _search_recipient(
session: httpx.AsyncClient,
fragment_hash: str,
username: str,
) -> str:
await fragment_request(session, fragment_hash, HEADERS, {"mode": "new", "method": "updateAdsTopupState"})
result = await fragment_request(
session,
fragment_hash,
HEADERS,
{
"query": username,
"method": "searchAdsTopupRecipient",
},
)
recipient = result.get("found", {}).get("recipient")
if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
return recipient
async def _init_request(
session: httpx.AsyncClient,
fragment_hash: str,
recipient: str,
amount: int,
) -> str:
result = await fragment_request(
session,
fragment_hash,
HEADERS,
{
"recipient": recipient,
"amount": amount,
"method": "initAdsTopupRequest",
},
)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup"))
return req_id
async def topup_ton(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
"""Topup ton to recipient's Telegram balance.
Args:
client: Authenticated :class:`FragmentClient` instance.
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``.
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 recipient 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)
try:
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:
recipient = await _search_recipient(session, fragment_hash, username)
req_id = await _init_request(session, fragment_hash, recipient, amount)
tx_data = {
"account": json.dumps(account),
"device": DEVICE,
"transaction": 1,
"id": req_id,
"show_sender": int(show_sender),
"method": "getAdsTopupLink",
}
transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash)
tx_hash = await process_transaction(client, transaction)
return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount)
except FragmentError:
raise
except Exception as exc:
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
+25
View File
@@ -0,0 +1,25 @@
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
from pyfragment.models.cookies import CookieResult
from pyfragment.models.enums import PaymentMethod, WalletVersion
from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult
from pyfragment.models.wallet import WalletInfo
__all__ = [
"AdsRechargeResult",
"AdsTopupResult",
"CookieResult",
"GiftsResult",
"LoginCodeResult",
"NumbersResult",
"PaymentMethod",
"PremiumGiveawayResult",
"PremiumResult",
"StarsGiveawayResult",
"StarsResult",
"TerminateSessionsResult",
"UsernamesResult",
"WalletInfo",
"WalletVersion",
]
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class LoginCodeResult:
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:
number: str
message: str | None
def __repr__(self) -> str:
return f"TerminateSessionsResult(number='{self.number}', message={self.message!r})"
__all__ = ["LoginCodeResult", "TerminateSessionsResult"]
+15
View File
@@ -0,0 +1,15 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class CookieResult:
cookies: dict[str, str]
expires: str | None
def __repr__(self) -> str:
return f"CookieResult(cookies={self.cookies!r}, expires={self.expires!r})"
__all__ = ["CookieResult"]
+8
View File
@@ -0,0 +1,8 @@
from __future__ import annotations
from typing import Literal
PaymentMethod = Literal["ton", "usdt_ton"]
WalletVersion = Literal["V4R2", "V5R1"]
__all__ = ["PaymentMethod", "WalletVersion"]
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class StarsGiveawayResult:
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:
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}')"
)
__all__ = ["PremiumGiveawayResult", "StarsGiveawayResult"]
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass
class UsernamesResult:
items: list[dict[str, Any]]
next_offset_id: str | None
def __repr__(self) -> str:
return f"UsernamesResult(items={len(self.items)}, next_offset_id={self.next_offset_id!r})"
@dataclass
class NumbersResult:
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:
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__ = ["GiftsResult", "NumbersResult", "UsernamesResult"]
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class PremiumResult:
transaction_id: str
username: str
amount: int
def __repr__(self) -> str:
return f"PremiumResult(username='{self.username}', amount={self.amount} months, tx='{self.transaction_id}')"
@dataclass
class StarsResult:
transaction_id: str
username: str
amount: int
def __repr__(self) -> str:
return f"StarsResult(username='{self.username}', amount={self.amount} stars, tx='{self.transaction_id}')"
@dataclass
class AdsTopupResult:
transaction_id: str
username: str
amount: int
def __repr__(self) -> str:
return f"AdsTopupResult(username='{self.username}', amount={self.amount} TON, tx='{self.transaction_id}')"
@dataclass
class AdsRechargeResult:
transaction_id: str
amount: int
def __repr__(self) -> str:
return f"AdsRechargeResult(amount={self.amount} TON, tx='{self.transaction_id}')"
__all__ = ["AdsRechargeResult", "AdsTopupResult", "PremiumResult", "StarsResult"]
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class WalletInfo:
address: str
state: str
ton_balance: float
usdt_balance: float
def __repr__(self) -> str:
return (
f"WalletInfo(address='{self.address}', state='{self.state}', "
f"ton_balance={self.ton_balance} TON, usdt_balance={self.usdt_balance} USDT)"
)
__all__ = ["WalletInfo"]
-62
View File
@@ -1,62 +0,0 @@
from pyfragment.types.exceptions import (
AnonymousNumberError,
ClientError,
ConfigurationError,
CookieError,
FragmentAPIError,
FragmentError,
FragmentPageError,
OperationError,
ParseError,
TransactionError,
UnexpectedError,
UserNotFoundError,
VerificationError,
WalletError,
)
from pyfragment.types.results import (
AdsRechargeResult,
AdsTopupResult,
GiftsResult,
LoginCodeResult,
NumbersResult,
PremiumGiveawayResult,
PremiumResult,
StarsGiveawayResult,
StarsResult,
TerminateSessionsResult,
UsernamesResult,
WalletInfo,
)
__all__ = [
# client exceptions
"ClientError",
"ConfigurationError",
"CookieError",
# fragment exceptions
"FragmentAPIError",
"FragmentError",
"FragmentPageError",
"AnonymousNumberError",
"OperationError",
"ParseError",
"TransactionError",
"UnexpectedError",
"UserNotFoundError",
"VerificationError",
"WalletError",
# result types
"AdsRechargeResult",
"AdsTopupResult",
"GiftsResult",
"LoginCodeResult",
"NumbersResult",
"PremiumGiveawayResult",
"PremiumResult",
"StarsGiveawayResult",
"StarsResult",
"TerminateSessionsResult",
"UsernamesResult",
"WalletInfo",
]
-199
View File
@@ -1,199 +0,0 @@
from dataclasses import dataclass
from typing import Any
@dataclass
class WalletInfo:
"""Wallet state returned by :meth:`FragmentClient.get_wallet`."""
address: str
state: str
balance: float
def __repr__(self) -> str:
return f"WalletInfo(address='{self.address}', state='{self.state}', balance={self.balance} TON)"
@dataclass
class PremiumResult:
"""Result of a successful Telegram Premium gift."""
transaction_id: str
username: str
amount: int
def __repr__(self) -> str:
return f"PremiumResult(username='{self.username}', amount={self.amount} months, tx='{self.transaction_id}')"
@dataclass
class StarsResult:
"""Result of a successful Telegram Stars purchase."""
transaction_id: str
username: str
amount: int
def __repr__(self) -> str:
return f"StarsResult(username='{self.username}', amount={self.amount} stars, tx='{self.transaction_id}')"
@dataclass
class AdsTopupResult:
"""Result of a successful Telegram Ads balance top-up."""
transaction_id: str
username: str
amount: int
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}')"
)
@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 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`."""
number: str
message: str | None
def __repr__(self) -> str:
return f"TerminateSessionsResult(number='{self.number}', message={self.message!r})"
@dataclass
class UsernamesResult:
"""Result of :meth:`FragmentClient.search_usernames`.
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``.
- ``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"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",
"GiftsResult",
"LoginCodeResult",
"NumbersResult",
"PremiumGiveawayResult",
"PremiumResult",
"StarsGiveawayResult",
"StarsResult",
"TerminateSessionsResult",
"UsernamesResult",
"WalletInfo",
]
-24
View File
@@ -1,24 +0,0 @@
from pyfragment.utils.decoder import clean_decode
from pyfragment.utils.html import parse_auction_rows, parse_gift_items, parse_login_code
from pyfragment.utils.http import (
execute_transaction_request,
fragment_request,
get_fragment_hash,
make_headers,
parse_json_response,
)
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",
"get_account_info",
"get_fragment_hash",
"make_headers",
"parse_json_response",
"process_transaction",
]
-35
View File
@@ -1,35 +0,0 @@
import base64
from pytoniq_core import Cell
from pyfragment.types import ParseError
def clean_decode(payload: str) -> str:
"""Decode a base64-encoded BOC payload to a plain-text comment string.
Fragment transaction payloads are BOC-serialised TVM cells. This function
base64-decodes the payload, parses the cell, skips the 32-bit op-code
prefix, and reads the snake-encoded UTF-8 comment.
Args:
payload: Base64url-encoded BOC string (padding is added automatically).
Returns:
Decoded comment string, or ``""`` for an empty payload.
Raises:
ParseError: If the payload cannot be decoded or parsed.
"""
s = payload.strip()
if not s:
return ""
s += "=" * (-len(s) % 4)
try:
boc = base64.b64decode(s)
cell = Cell.one_from_boc(boc)
sl = cell.begin_parse()
sl.load_uint(32) # op code — always 0 for text comment
return sl.load_snake_string().strip()
except Exception as exc:
raise ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc
-141
View File
@@ -1,141 +0,0 @@
import re
from typing import Any
import httpx
from pyfragment.types import FragmentPageError, ParseError, VerificationError
from pyfragment.types.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL
def make_headers(page_url: str = FRAGMENT_BASE_URL) -> dict[str, str]:
return {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url}
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.
Fragment embeds a short-lived hash in each page's HTML that must be
included in every subsequent API request. This function loads the page
as a real browser navigation (not XHR) so Fragment returns full HTML.
Args:
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.
Raises:
FragmentPageError: If the page returns a non-200 status or the hash
is not found in the response HTML.
"""
page_headers = {
k: v
for k, v in headers.items()
if k not in ("accept", "accept-encoding", "content-type", "x-requested-with", "x-aj-referer")
}
page_headers.update(
{
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"referer": f"{FRAGMENT_BASE_URL}/",
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"upgrade-insecure-requests": "1",
}
)
async with httpx.AsyncClient(cookies=cookies, timeout=timeout) as session:
response = await session.get(page_url, headers=page_headers)
if response.status_code != 200:
raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=response.status_code, url=page_url))
match = re.search(r"(?:https://fragment\.com)?/api\?hash=([a-f0-9]+)", response.text)
if not match:
raise FragmentPageError(FragmentPageError.NOT_FOUND.format(url=page_url))
return match.group(1)
def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any]:
"""Parse a Fragment API JSON response.
Args:
response: The HTTP response object.
context: Human-readable name of the API method, used in error messages.
Returns:
Parsed response as a dict.
Raises:
ParseError: If the response body cannot be decoded as JSON.
"""
try:
return response.json()
except Exception as exc:
raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc
async def fragment_request(
session: httpx.AsyncClient,
fragment_hash: str,
headers: dict[str, str],
data: dict[str, Any],
) -> dict[str, Any]:
"""POST a single request to the Fragment API.
Builds the ``/api?hash=`` URL, sends the request, and returns the
parsed JSON body. Use this for every API method call — search,
init, state updates, etc.
Args:
session: Active httpx session with Fragment cookies.
fragment_hash: Short-lived hash from the Fragment page HTML.
headers: Page-specific HTTP headers.
data: Form data payload; must include a ``"method"`` key.
Returns:
Parsed API response as a dict.
"""
resp = await session.post(
f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}",
headers=headers,
data=data,
)
return parse_json_response(resp, data.get("method", "request"))
async def execute_transaction_request(
session: httpx.AsyncClient,
headers: dict,
tx_data: dict[str, Any],
fragment_hash: str,
) -> dict[str, Any]:
"""Post a transaction request to the Fragment API.
Args:
session: Active httpx session with Fragment cookies.
headers: Page-specific HTTP headers.
tx_data: Form data payload for the API method.
fragment_hash: Short-lived hash from the Fragment page.
Returns:
Parsed API response dict containing transaction data.
Raises:
VerificationError: If Fragment requires KYC verification.
ParseError: If the response cannot be parsed.
"""
transaction = await fragment_request(session, fragment_hash, headers, tx_data)
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
return transaction
-147
View File
@@ -1,147 +0,0 @@
import asyncio
import base64
import ssl
from typing import TYPE_CHECKING, Any
from tonutils.clients import TonapiClient
from tonutils.exceptions import ProviderResponseError
from tonutils.types import NetworkGlobalID
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
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def process_transaction(client: "FragmentClient", transaction_data: dict) -> str:
"""Sign and broadcast a Fragment transaction to the TON network.
Validates the payload structure, checks the wallet balance, decodes the
on-chain comment, and calls ``wallet.transfer``.
Args:
client: Authenticated :class:`FragmentClient` instance.
transaction_data: Raw transaction dict from ``execute_transaction_request``.
Returns:
Normalised transaction hash string.
Raises:
TransactionError: If the payload is malformed or the broadcast fails.
WalletError: If the wallet balance is too low or cannot be fetched.
"""
if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]:
raise TransactionError(TransactionError.INVALID_PAYLOAD)
message = transaction_data["transaction"]["messages"][0]
amount_ton = int(message["amount"]) / 1_000_000_000
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
# Check balance covers transaction amount + gas reserve
try:
await wallet.refresh()
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, gas=MIN_TON_BALANCE))
except WalletError:
raise
except Exception as exc:
raise WalletError(WalletError.BALANCE_CHECK_FAILED.format(exc=exc)) from exc
try:
payload = clean_decode(message["payload"])
for attempt in range(3):
try:
result = await wallet.transfer(
destination=message["address"],
amount=int(message["amount"]), # nanotons, not TON
body=payload,
)
return result.normalized_hash
except ProviderResponseError as exc:
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"))
async def get_account_info(client: "FragmentClient") -> dict[str, Any]:
"""Fetch wallet address, public key, and state-init for the Fragment API.
Fragment requires account info to build each transaction payload. The
returned dict is JSON-serialised and passed as the ``account`` field in
``getBuy*Link`` / ``get*Link`` requests.
Args:
client: Authenticated :class:`FragmentClient` instance.
Returns:
Dict with ``address``, ``publicKey``, ``chain``, ``walletStateInit``.
Raises:
WalletError: If account info cannot be retrieved.
"""
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
try:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
boc = wallet.state_init.serialize().to_boc()
return {
"address": wallet.address.to_str(False, False),
"publicKey": pub_key.as_hex,
"chain": "-239",
"walletStateInit": base64.b64encode(boc).decode(),
}
except Exception as exc:
raise WalletError(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc
async def get_wallet_info(client: "FragmentClient") -> "WalletInfo":
"""Return the address, state and balance of the TON wallet.
Args:
client: Authenticated :class:`FragmentClient` instance.
Returns:
:class:`WalletInfo` with ``address``, ``state``, and ``balance`` in TON.
Raises:
WalletError: If the wallet state cannot be fetched.
"""
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
try:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
await wallet.refresh()
return WalletInfo(
address=wallet.address.to_str(is_user_friendly=True, is_bounceable=False),
state=wallet.state.value,
balance=round(wallet.balance / 1_000_000_000, 4),
)
except Exception as exc:
raise WalletError(WalletError.WALLET_INFO_FAILED.format(exc=exc)) from exc
+60 -28
View File
@@ -4,45 +4,64 @@ build-backend = "hatchling.build"
[project]
name = "pyfragment"
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."
version = "2026.3.1"
description = "Async Python client for the Fragment API. Buy Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and search Fragment listings."
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.12"
authors = [{ name = "bohd4nx", url = "https://github.com/bohd4nx" }]
keywords = ["fragment", "telegram", "ton", "stars", "premium", "crypto", "blockchain"]
requires-python = ">=3.10"
authors = [{ name = "bohd4nx" }]
keywords = [
"fragment",
"fragment-api",
"telegram",
"telegram-api",
"telegram-stars",
"telegram-premium",
"telegram-giveaway",
"telegram-ads",
"ton",
"ton-blockchain",
"tonapi",
"anonymous-numbers",
"username-auctions",
"gift-marketplace",
"crypto-payments",
"nft-marketplace",
"web3",
"python-client",
"typed",
"asyncio",
]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Framework :: AsyncIO",
"Topic :: Software Development :: Libraries",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Internet",
"Topic :: Office/Business :: Financial",
"Topic :: Internet :: WWW/HTTP",
"Typing :: Typed",
]
dependencies = [
"httpx==0.28.1",
"tonutils[pytoniq]==2.0.4",
]
dependencies = ["httpx>=0.25", "tonutils>=2.0.1"]
[project.optional-dependencies]
dev = [
"pytest==9.0.2",
"pytest-asyncio==1.3.0",
"pytest-mock",
"mypy",
"ruff",
"black",
]
dev = ["pytest", "pytest-asyncio", "pytest-mock", "mypy", "ruff"]
browser = ["rookiepy>=0.5.6"]
[project.urls]
Homepage = "https://github.com/bohd4nx/pyfragment"
Repository = "https://github.com/bohd4nx/pyfragment"
Documentation = "https://bohd4nx.gitbook.io/pyfragment/"
Issues = "https://github.com/bohd4nx/pyfragment/issues"
Changelog = "https://github.com/bohd4nx/pyfragment/blob/master/CHANGELOG.md"
@@ -50,23 +69,36 @@ Changelog = "https://github.com/bohd4nx/pyfragment/blob/master/CHANGELOG.md"
packages = ["pyfragment"]
[tool.pytest.ini_options]
testpaths = ["tests"]
testpaths = ["tests"]
python_files = ["[0-9][0-9][0-9]_test_*.py"]
asyncio_mode = "auto"
addopts = "-v --tb=short"
[tool.black]
line-length = 128
target-version = ["py312"]
addopts = "-v --tb=short"
[tool.ruff]
line-length = 128
target-version = "py312"
target-version = "py310"
[tool.ruff.lint]
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort
select = ["E", "F", "W", "I"]
ignore = ["E501"]
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort, UP — pyupgrade
select = ["E", "F", "W", "I", "UP"]
# E501 — line too long (covered by line-length above)
# UP017 — use datetime.UTC (only available in Python 3.11+, we support 3.10)
ignore = ["E501", "UP017"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["E402"]
[tool.mypy]
python_version = "3.10"
strict = true
exclude = ["^examples/"]
[[tool.mypy.overrides]]
module = "rookiepy"
ignore_missing_imports = true
[[tool.mypy.overrides]]
# rookiepy = None assignment is valid when the package is installed (type: ModuleType)
# but mypy on CI (without rookiepy) sees it as Any and flags the comment as unused.
module = "pyfragment.core.cookies"
warn_unused_ignores = false
-1
View File
@@ -1 +0,0 @@
-e .[dev]
-1
View File
@@ -1 +0,0 @@
-e .
+95 -9
View File
@@ -1,37 +1,62 @@
"""Tests for clean_decode() — TON BOC payload decoding."""
"""Decode Fragment BOC payloads so comments become text and structured messages stay raw."""
import base64
import re
from unittest.mock import patch
import pytest
from ton_core import Cell
from pyfragment.types import ParseError
from pyfragment.utils.decoder import clean_decode
from pyfragment import ParseError
from pyfragment.domains.tonapi.transaction import clean_decode
PAYLOADS = [
PAYLOAD_CASES = [
pytest.param(
"te6ccgEBAgEALwABTgAAAAAxMDAwMDAwIFRlbGVncmFtIFN0YXJzIAoKUmVmI1RQb01wegEABkM3ZQ",
True,
id="stars",
),
pytest.param(
"te6ccgEBAgEANAABTgAAAABUZWxlZ3JhbSBQcmVtaXVtIGZvciAxIHllYXIgCgpSZWYjcgEAEE9OQnM2cmNt",
True,
id="premium",
),
pytest.param(
"te6ccgEBAgEAMAABTgAAAABUZWxlZ3JhbSBhY2NvdW50IHRvcCB1cCAKClJlZiNrMXpDRQEACFkxd3g",
True,
id="topup",
),
pytest.param(
"te6ccgEBAgEAfgABqA-KfqVP885dhccidjC3GwgBCkiH8LM_zUu0afyGCTWJwX1mDjdlf2rMa9UoQlD4UHUAF1jLlcMomlo5RJTwl8jnDDdfdhc7EgQQWPqFQ9IjyLPCAwEASgAAAAA1MCBUZWxlZ3JhbSBTdGFycyAKClJlZiNtOUpoWndBcFE",
False,
id="real_stars_50",
),
pytest.param(
"te6ccgEBAgEANgABTgAAAABUZWxlZ3JhbSBQcmVtaXVtIGZvciAzIG1vbnRocyAKClJlZgEAFCMzcFdKdGJkYnU",
False,
id="real_premium_3m",
),
pytest.param(
"te6ccgEBAwEAhgABqg-KfqWibdDaYaJCPUWWgvAIAQpIh_CzP81LtGn8hgk1icF9Zg43ZX9qzGvVKEJQ-FB1ABdYy5XDKJpaOUSU8JfI5ww3X3YXOxIEEFj6hUPSI8izwgMBAU4AAAAAMTAwMDAwIFRlbGVncmFtIFN0YXJzIAoKUmVmIzBoZ0RmNEYCAAQ5VA",
False,
id="real_stars_100k",
),
]
# Decode valid payload tests
@pytest.mark.parametrize("payload", PAYLOADS)
def test_decode_payload(payload: str) -> None:
@pytest.mark.parametrize(("payload", "strict_ref"), PAYLOAD_CASES)
def test_decode_payload(payload: str, strict_ref: bool) -> None:
result = clean_decode(payload)
assert "Telegram" in result
assert re.search(r"Ref#[A-Za-z0-9]+", result), f"no Ref# in {result!r}"
assert all(ord(c) < 128 for c in result), f"non-ASCII chars in {result!r}"
if isinstance(result, str):
assert "Telegram" in result
if strict_ref:
assert re.search(r"Ref#[A-Za-z0-9]+", result), f"no Ref# in {result!r}"
assert all(ord(c) < 128 for c in result), f"non-ASCII chars in {result!r}"
else:
assert isinstance(result, Cell)
# Edge case tests
@@ -44,3 +69,64 @@ def test_empty_payload_returns_empty_string() -> None:
def test_invalid_payload_raises_parse_error() -> None:
with pytest.raises(ParseError):
clean_decode("!!!not-valid-base64!!!")
def test_decode_payload_accepts_base64url_alphabet() -> None:
class _FakeSlice:
def load_uint(self, _: int) -> int:
return 0
def load_snake_string(self) -> str:
return "Telegram Stars Ref#abc"
class _FakeCell:
def begin_parse(self) -> _FakeSlice:
return _FakeSlice()
raw = b"\xfb\xef\xff\x00"
payload = base64.urlsafe_b64encode(raw).decode().rstrip("=")
with patch("pyfragment.domains.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()) as mocked:
result = clean_decode(payload)
mocked.assert_called_once_with(raw)
assert result == "Telegram Stars Ref#abc"
def test_clean_decode_returns_text_comment_when_utf8() -> None:
class _FakeSlice:
def load_uint(self, _: int) -> int:
return 0
def load_snake_string(self) -> str:
return "Telegram Premium Ref#abc"
class _FakeCell:
def begin_parse(self) -> _FakeSlice:
return _FakeSlice()
payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=")
with patch("pyfragment.domains.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()):
parsed = clean_decode(payload)
assert parsed == "Telegram Premium Ref#abc"
def test_clean_decode_returns_cell_for_binary_payload() -> None:
class _FakeSlice:
def load_uint(self, _: int) -> int:
return 0
def load_snake_string(self) -> str:
raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte")
class _FakeCell:
def begin_parse(self) -> _FakeSlice:
return _FakeSlice()
payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=")
fake_cell: object = _FakeCell()
with patch("pyfragment.domains.tonapi.transaction.Cell.one_from_boc", return_value=fake_cell):
parsed = clean_decode(payload)
assert parsed is fake_cell
+2 -3
View File
@@ -1,11 +1,10 @@
"""Unit tests for FragmentClient — initialization, validation, and cookie parsing."""
"""Validate FragmentClient setup, cookie parsing, and wallet version checks."""
import json
import pytest
from pyfragment import FragmentClient
from pyfragment.types import ConfigurationError, CookieError
from pyfragment import ConfigurationError, CookieError, FragmentClient
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
# Client init tests
@@ -1,13 +1,14 @@
"""Unit tests for process_transaction() — balance validation and broadcast retry logic."""
"""Exercise transaction signing, balance checks, and retry behavior for TON and USDT flows."""
from collections.abc import Generator
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 pyfragment import TransactionError, WalletError
from pyfragment.domains.tonapi.transaction import process_transaction
from tests.shared import VALID_SEED
@@ -45,10 +46,10 @@ def _make_wallet(balance_nanotons: int) -> MagicMock:
@contextmanager
def _patch_wallet(wallet: MagicMock):
def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
with (
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
patch("pyfragment.domains.tonapi.transaction.TonapiClient") as mock_tonapi,
patch("pyfragment.domains.tonapi.transaction.WALLET_CLASSES") as mock_classes,
):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
@@ -61,8 +62,8 @@ def _patch_wallet(wallet: MagicMock):
@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"):
wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, above threshold
with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"):
result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123"
wallet.transfer.assert_called_once()
@@ -70,7 +71,7 @@ async def test_sufficient_balance_broadcasts() -> None:
@pytest.mark.asyncio
async def test_insufficient_balance_raises() -> None:
wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 TON, needs 0.556 TON
wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 TON, below threshold
with _patch_wallet(wallet):
with pytest.raises(WalletError, match="required"):
await process_transaction(_make_client(), TRANSACTION_DATA)
@@ -79,15 +80,15 @@ async def test_insufficient_balance_raises() -> None:
@pytest.mark.asyncio
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"):
wallet = _make_wallet(balance_nanotons=500_000_000) # exactly transaction amount threshold
with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.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:
wallet = _make_wallet(balance_nanotons=555_999_999) # 1 nanoton below threshold
wallet = _make_wallet(balance_nanotons=499_999_999) # 1 nanoton below transaction amount threshold
with _patch_wallet(wallet):
with pytest.raises(WalletError, match="required"):
await process_transaction(_make_client(), TRANSACTION_DATA)
@@ -102,6 +103,12 @@ async def test_invalid_payload_raises() -> None:
await process_transaction(_make_client(), {"transaction": {}})
@pytest.mark.asyncio
async def test_empty_messages_list_raises() -> None:
with pytest.raises(TransactionError):
await process_transaction(_make_client(), {"transaction": {"messages": []}})
@pytest.mark.asyncio
async def test_balance_check_failed_raises_wallet_error() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000)
@@ -116,7 +123,7 @@ async def test_balance_check_failed_raises_wallet_error() -> None:
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=""):
with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""):
result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123"
assert wallet.transfer.call_count == 2
@@ -127,7 +134,45 @@ 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 _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""):
with pytest.raises(TransactionError, match="seqno"):
await process_transaction(_make_client(), TRANSACTION_DATA)
assert wallet.transfer.call_count == 3
@pytest.mark.asyncio
async def test_usdt_payment_requires_min_ton_gas_reserve() -> None:
wallet = _make_wallet(balance_nanotons=10_000_000) # 0.01 TON below MIN_TON_BALANCE
with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=100.0)):
with pytest.raises(WalletError, match="Insufficient TON balance"):
await process_transaction(_make_client(), TRANSACTION_DATA, payment_method="usdt_ton")
@pytest.mark.asyncio
async def test_usdt_payment_checks_usdt_balance() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000)
transaction = {
"transaction": {
"messages": [
{
"address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a",
"amount": "50000000",
"payload": "",
}
]
},
"required_usdt": 12.5,
}
with (
_patch_wallet(wallet),
patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""),
patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=5.0)),
):
with pytest.raises(WalletError, match="Insufficient USDT balance"):
await process_transaction(
_make_client(),
transaction,
payment_method="usdt_ton",
required_payment_amount=12.5,
)
-16
View File
@@ -1,16 +0,0 @@
"""Tests for get_fragment_hash() — API hash extraction from Fragment page source."""
import re
import pytest
from pyfragment.types.constants import BASE_HEADERS, STARS_PAGE
from pyfragment.utils import get_fragment_hash
@pytest.mark.asyncio
async def test_hash_is_valid_hex(cookies: dict) -> None:
result = await get_fragment_hash(cookies, BASE_HEADERS, STARS_PAGE)
assert isinstance(result, str)
assert len(result) >= 10, f"hash too short: {result!r}"
assert re.fullmatch(r"[a-f0-9]+", result), f"not a hex string: {result!r}"
+227
View File
@@ -0,0 +1,227 @@
"""Cover stars purchase and giveaway flows, including validation and request wiring."""
from unittest.mock import AsyncMock, patch
import pytest
import pyfragment.domains.giveaways.giveaway as _giveaway_stars_mod
import pyfragment.domains.purchases.purchase as _purchase_stars_mod
from pyfragment import ConfigurationError, FragmentClient, StarsGiveawayResult, StarsResult, UserNotFoundError
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# 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]
@pytest.mark.asyncio
async def test_purchase_stars_invalid_payment_method(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError, match="Invalid payment method"):
await client.purchase_stars("@user", amount=500, payment_method="btc") # type: ignore[arg-type]
# Stars purchase mocked tests
@pytest.mark.asyncio
async def test_purchase_stars_success(client: FragmentClient) -> None:
call_mock = AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{}, # updateStarsBuyState
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
)
with (
patch.object(client, "call", call_mock),
patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_purchase_stars_mod, "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_passes_payment_method(client: FragmentClient) -> None:
call_mock = AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{}, # updateStarsBuyState
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
)
proc_mock = AsyncMock(return_value=FAKE_TX_HASH)
with (
patch.object(client, "call", call_mock),
patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_purchase_stars_mod, "process_transaction", proc_mock),
):
await client.purchase_stars("@user", amount=500, payment_method="usdt_ton")
init_call = call_mock.await_args_list[2]
assert init_call.args[0] == "initBuyStarsRequest"
assert init_call.args[1]["payment_method"] == "usdt_ton"
assert proc_mock.await_args is not None
assert proc_mock.await_args.kwargs["payment_method"] == "usdt_ton"
@pytest.mark.asyncio
@pytest.mark.parametrize("query", ["@user", "monk", "https://t.me/monk"])
async def test_purchase_stars_accepts_query_formats(client: FragmentClient, query: str) -> None:
call_mock = AsyncMock(return_value={"found": {}})
with patch.object(client, "call", call_mock):
with pytest.raises(UserNotFoundError):
await client.purchase_stars(query, amount=500)
search_call = call_mock.await_args_list[0]
assert search_call.args[0] == "searchStarsRecipient"
assert search_call.args[1]["query"] == query
@pytest.mark.asyncio
async def test_purchase_stars_user_not_found(client: FragmentClient) -> None:
with patch.object(client, "call", 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)
@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]
@pytest.mark.asyncio
async def test_giveaway_stars_invalid_payment_method(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError, match="Invalid payment method"):
await client.giveaway_stars("@channel", winners=1, amount=500, payment_method="btc") # type: ignore[arg-type]
# Stars giveaway mocked tests
@pytest.mark.asyncio
async def test_giveaway_stars_success(client: FragmentClient) -> None:
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{},
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
),
),
patch.object(_giveaway_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_giveaway_stars_mod, "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_passes_payment_method(client: FragmentClient) -> None:
call_mock = AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{},
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
)
proc_mock = AsyncMock(return_value=FAKE_TX_HASH)
with (
patch.object(client, "call", call_mock),
patch.object(_giveaway_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_giveaway_stars_mod, "process_transaction", proc_mock),
):
await client.giveaway_stars("@channel", winners=3, amount=1000, payment_method="usdt_ton")
init_call = call_mock.await_args_list[2]
assert init_call.args[0] == "initGiveawayStarsRequest"
assert init_call.args[1]["payment_method"] == "usdt_ton"
assert proc_mock.await_args is not None
assert proc_mock.await_args.kwargs["payment_method"] == "usdt_ton"
@pytest.mark.asyncio
@pytest.mark.parametrize("query", ["@channel", "monk", "https://t.me/id2757542991"])
async def test_giveaway_stars_accepts_query_formats(client: FragmentClient, query: str) -> None:
call_mock = AsyncMock(return_value={"found": {}})
with patch.object(client, "call", call_mock):
with pytest.raises(UserNotFoundError):
await client.giveaway_stars(query, winners=1, amount=500)
search_call = call_mock.await_args_list[0]
assert search_call.args[0] == "searchStarsGiveawayRecipient"
assert search_call.args[1]["query"] == query
@pytest.mark.asyncio
async def test_giveaway_stars_channel_not_found(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
with pytest.raises(UserNotFoundError):
await client.giveaway_stars("@ghost", winners=1, amount=500)
+212
View File
@@ -0,0 +1,212 @@
"""Cover premium purchase and giveaway flows, including validation and request wiring."""
from unittest.mock import AsyncMock, patch
import pytest
import pyfragment.domains.giveaways.giveaway as _giveaway_premium_mod
import pyfragment.domains.purchases.purchase as _purchase_premium_mod
from pyfragment import ConfigurationError, FragmentClient, PremiumGiveawayResult, PremiumResult, UserNotFoundError
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# 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)
@pytest.mark.asyncio
async def test_purchase_premium_invalid_payment_method(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError, match="Invalid payment method"):
await client.purchase_premium("@user", months=3, payment_method="btc") # type: ignore[arg-type]
# Premium purchase mocked tests
@pytest.mark.asyncio
async def test_purchase_premium_success(client: FragmentClient) -> None:
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{}, # updatePremiumState
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
),
),
patch.object(_purchase_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_purchase_premium_mod, "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_passes_payment_method(client: FragmentClient) -> None:
call_mock = AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{}, # updatePremiumState
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
)
proc_mock = AsyncMock(return_value=FAKE_TX_HASH)
with (
patch.object(client, "call", call_mock),
patch.object(_purchase_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_purchase_premium_mod, "process_transaction", proc_mock),
):
await client.purchase_premium("@user", months=6, payment_method="usdt_ton")
init_call = call_mock.await_args_list[2]
assert init_call.args[0] == "initGiftPremiumRequest"
assert init_call.args[1]["payment_method"] == "usdt_ton"
assert proc_mock.await_args is not None
assert proc_mock.await_args.kwargs["payment_method"] == "usdt_ton"
@pytest.mark.asyncio
@pytest.mark.parametrize("query", ["@user", "monk", "https://t.me/monk"])
async def test_purchase_premium_accepts_query_formats(client: FragmentClient, query: str) -> None:
call_mock = AsyncMock(return_value={"found": {}})
with patch.object(client, "call", call_mock):
with pytest.raises(UserNotFoundError):
await client.purchase_premium(query, months=6)
search_call = call_mock.await_args_list[0]
assert search_call.args[0] == "searchPremiumGiftRecipient"
assert search_call.args[1]["query"] == query
@pytest.mark.asyncio
async def test_purchase_premium_user_not_found(client: FragmentClient) -> None:
with patch.object(client, "call", 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_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):
await client.giveaway_premium("@channel", winners=10, months=5)
@pytest.mark.asyncio
async def test_giveaway_premium_invalid_payment_method(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError, match="Invalid payment method"):
await client.giveaway_premium("@channel", winners=10, months=3, payment_method="btc") # type: ignore[arg-type]
# Premium giveaway mocked tests
@pytest.mark.asyncio
async def test_giveaway_premium_success(client: FragmentClient) -> None:
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{},
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
),
),
patch.object(_giveaway_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_giveaway_premium_mod, "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_passes_payment_method(client: FragmentClient) -> None:
call_mock = AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{},
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
)
proc_mock = AsyncMock(return_value=FAKE_TX_HASH)
with (
patch.object(client, "call", call_mock),
patch.object(_giveaway_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_giveaway_premium_mod, "process_transaction", proc_mock),
):
await client.giveaway_premium("@channel", winners=10, months=6, payment_method="usdt_ton")
init_call = call_mock.await_args_list[2]
assert init_call.args[0] == "initGiveawayPremiumRequest"
assert init_call.args[1]["payment_method"] == "usdt_ton"
assert proc_mock.await_args is not None
assert proc_mock.await_args.kwargs["payment_method"] == "usdt_ton"
@pytest.mark.asyncio
@pytest.mark.parametrize("query", ["@channel", "monk", "https://t.me/id2757542991"])
async def test_giveaway_premium_accepts_query_formats(client: FragmentClient, query: str) -> None:
call_mock = AsyncMock(return_value={"found": {}})
with patch.object(client, "call", call_mock):
with pytest.raises(UserNotFoundError):
await client.giveaway_premium(query, winners=10, months=3)
search_call = call_mock.await_args_list[0]
assert search_call.args[0] == "searchPremiumGiveawayRecipient"
assert search_call.args[1]["query"] == query
@pytest.mark.asyncio
async def test_giveaway_premium_channel_not_found(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
with pytest.raises(UserNotFoundError):
await client.giveaway_premium("@ghost", winners=1, months=3)
-137
View File
@@ -1,137 +0,0 @@
"""Unit tests for Stars methods — purchase_stars and giveaway_stars."""
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)
@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
@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)
-125
View File
@@ -1,125 +0,0 @@
"""Unit tests for Premium methods — purchase_premium and giveaway_premium."""
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_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):
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)
@@ -1,12 +1,12 @@
"""Unit tests for topup_ton — TON Ads balance top-up."""
"""Cover TON top-up through Telegram Ads, including recipient lookup and transaction building."""
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
import pyfragment.domains.ads.tonup as _topup_ton_mod
from pyfragment import AdsTopupResult, ConfigurationError, FragmentClient, UserNotFoundError
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# Topup TON validation tests
@@ -35,20 +35,20 @@ async def test_topup_ton_float_amount(client: FragmentClient) -> None:
@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",
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{}, # updateAdsTopupState
{"found": {"recipient": FAKE_RECIPIENT}},
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
),
),
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)),
patch.object(_topup_ton_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_topup_ton_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
):
result = await client.topup_ton("@user", amount=10)
@@ -60,12 +60,14 @@ 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.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 patch.object(
client,
"call",
AsyncMock(
side_effect=[
{}, # updateAdsTopupState
{"found": {}},
]
),
):
with pytest.raises(UserNotFoundError):
@@ -1,4 +1,4 @@
"""Unit tests for get_wallet() — wallet address and TON balance lookup."""
"""Verify wallet inspection returns friendly TON and USDT balances from Tonapi."""
from unittest.mock import AsyncMock, MagicMock, patch
@@ -7,7 +7,7 @@ import pytest
from pyfragment import FragmentClient, WalletInfo
from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOTON
# Wallet mocked tests
# Wallet mocked tests (TON and USDT balances are returned separately)
@pytest.mark.asyncio
@@ -19,8 +19,9 @@ async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
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,
patch("pyfragment.domains.tonapi.account.TonapiClient") as mock_tonapi,
patch("pyfragment.domains.tonapi.account.WALLET_CLASSES") as mock_classes,
patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=12.3456)),
):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
@@ -31,7 +32,8 @@ async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
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)
assert result.ton_balance == round(FAKE_BALANCE_NANOTON / 1_000_000_000, 4)
assert result.usdt_balance == 12.3456
@pytest.mark.asyncio
@@ -43,8 +45,9 @@ async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None:
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,
patch("pyfragment.domains.tonapi.account.TonapiClient") as mock_tonapi,
patch("pyfragment.domains.tonapi.account.WALLET_CLASSES") as mock_classes,
patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=0.0)),
):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
@@ -52,5 +55,6 @@ async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None:
result = await client.get_wallet()
assert result.balance == 0.0
assert result.ton_balance == 0.0
assert result.usdt_balance == 0.0
assert result.state == "uninit"
+77
View File
@@ -0,0 +1,77 @@
"""Check raw Fragment API calls and transport error handling."""
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from pyfragment import FragmentClient, FragmentPageError
from pyfragment.core.transport import fragment_request
from tests.shared import FAKE_HASH, FAKE_RESPONSE
# client.call() mocked tests
@pytest.mark.asyncio
async def test_call_returns_api_response(client: FragmentClient) -> None:
with (
patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.domains.base.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
):
result = await client.call("anyMethod", {"key": "value"})
assert result == FAKE_RESPONSE
@pytest.mark.asyncio
async def test_call_default_page_url(client: FragmentClient) -> None:
with (
patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.domains.base.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
):
result = await client.call("anyMethod")
assert result == FAKE_RESPONSE
@pytest.mark.asyncio
async def test_call_no_data(client: FragmentClient) -> None:
mock_request = AsyncMock(return_value={})
with (
patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.domains.base.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:
mock_request = AsyncMock(return_value={})
with (
patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.domains.base.fragment_request", mock_request),
):
await client.call("anyMethod", {"key": "value", "num": 7})
_, _, _, sent_data = mock_request.call_args.args
assert sent_data == {"method": "anyMethod", "key": "value", "num": 7}
# fragment_request HTTP status tests
@pytest.mark.asyncio
async def test_fragment_request_non_200_raises() -> None:
response = MagicMock(spec=httpx.Response)
response.status_code = 429
session = AsyncMock(spec=httpx.AsyncClient)
session.post = AsyncMock(return_value=response)
with pytest.raises(FragmentPageError, match="429"):
await fragment_request(session, FAKE_HASH, {}, {"method": "anyMethod"})
-63
View File
@@ -1,63 +0,0 @@
"""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
# 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}
+128
View File
@@ -0,0 +1,128 @@
"""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_HTML_NO_CODE, FAKE_HTML_WITH_CODE, FAKE_TERMINATE_HASH
# get_login_code mocked tests
@pytest.mark.asyncio
async def test_get_login_code_returns_code(client: FragmentClient) -> None:
with patch.object(client, "call", 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 <tr> in the HTML
@pytest.mark.asyncio
async def test_get_login_code_no_pending_code(client: FragmentClient) -> None:
with patch.object(client, "call", 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.object(client, "call", AsyncMock(return_value={})):
result = await client.get_login_code("+1234567890")
assert result.code is None
assert result.active_sessions == 0
# terminate_sessions mocked tests
@pytest.mark.asyncio
async def test_terminate_sessions_success(client: FragmentClient) -> None:
with patch.object(
client,
"call",
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.object(client, "call", 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.object(client, "call", 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.object(
client,
"call",
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 mocked tests
@pytest.mark.asyncio
async def test_toggle_login_codes_enable(client: FragmentClient) -> None:
mock_call = AsyncMock(return_value={"ok": True})
with patch.object(client, "call", mock_call):
await client.toggle_login_codes("+1234567890", can_receive=True)
call_data = mock_call.call_args[0][1]
assert call_data["can_receive"] == 1
@pytest.mark.asyncio
async def test_toggle_login_codes_disable(client: FragmentClient) -> None:
mock_call = AsyncMock(return_value={"ok": True})
with patch.object(client, "call", mock_call):
await client.toggle_login_codes("+1234567890", can_receive=False)
call_data = mock_call.call_args[0][1]
assert call_data["can_receive"] == 0
# strip_plus mocked tests
@pytest.mark.asyncio
async def test_get_login_code_strips_plus(client: FragmentClient) -> None:
"""Number passed with '+' is stripped before the API call."""
mock_call = AsyncMock(return_value={})
with patch.object(client, "call", mock_call):
await client.get_login_code("+1234567890")
call_data = mock_call.call_args[0][1]
assert call_data["number"] == "1234567890"
-163
View File
@@ -1,163 +0,0 @@
"""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_NO_CODE, FAKE_HTML_WITH_CODE, FAKE_TERMINATE_HASH
# 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 <tr> 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"
@@ -1,12 +1,12 @@
"""Unit tests for recharge_ads — self-service Telegram Ads recharge."""
"""Cover Telegram Ads recharge flow, including request preparation and KYC handling."""
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
import pyfragment.domains.ads.recharge as _recharge_ads_mod
from pyfragment import AdsRechargeResult, ConfigurationError, FragmentClient
from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# recharge_ads validation tests
@@ -35,19 +35,19 @@ async def test_recharge_ads_float_amount(client: FragmentClient) -> None:
@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",
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{}, # updateAdsState
{"req_id": FAKE_REQ_ID}, # initAdsRechargeRequest
FAKE_TRANSACTION, # getAdsRechargeLink
]
),
),
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)),
patch.object(_recharge_ads_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_recharge_ads_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
):
result = await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=10)
@@ -1,12 +1,10 @@
"""Unit tests for search_usernames — Fragment marketplace username search."""
"""Verify username search parsing and query forwarding."""
from unittest.mock import AsyncMock, patch
import pytest
from pyfragment import FragmentClient
from pyfragment.types import UsernamesResult
from tests.shared import FAKE_HASH
from pyfragment import FragmentClient, UsernamesResult
FAKE_HTML = """
<tr class="tm-row-selectable">
@@ -22,15 +20,12 @@ FAKE_HTML = """
"""
# search_usernames result parsing tests
@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}),
),
):
with patch.object(client, "call", AsyncMock(return_value={"ok": True, "html": FAKE_HTML})):
result = await client.search_usernames("coolname")
assert isinstance(result, UsernamesResult)
@@ -43,13 +38,7 @@ async def test_search_usernames_basic(client: FragmentClient) -> 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}),
),
):
with patch.object(client, "call", AsyncMock(return_value={"ok": True})):
result = await client.search_usernames("zzz_no_results")
assert isinstance(result, UsernamesResult)
@@ -57,21 +46,18 @@ async def test_search_usernames_empty_html(client: FragmentClient) -> None:
assert result.next_offset_id is None
# search_usernames parameter forwarding tests
@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,
):
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML})
with patch.object(client, "call", mock_call):
result = await client.search_usernames("durov", sort="price_desc", filter="auction")
assert isinstance(result, UsernamesResult)
call_data = mock_request.call_args[0][3]
call_data = mock_call.call_args[0][1]
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"
@@ -79,33 +65,23 @@ async def test_search_usernames_with_sort_and_filter(client: FragmentClient) ->
@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,
):
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_99"})
with patch.object(client, "call", mock_call):
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]
call_data = mock_call.call_args[0][1]
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,
):
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML})
with patch.object(client, "call", mock_call):
result = await client.search_usernames()
assert isinstance(result, UsernamesResult)
call_data = mock_request.call_args[0][3]
call_data = mock_call.call_args[0][1]
assert call_data["query"] == ""
assert call_data["type"] == "usernames"
@@ -1,12 +1,10 @@
"""Unit tests for search_numbers — Fragment marketplace number search."""
"""Verify anonymous number search parsing and query forwarding."""
from unittest.mock import AsyncMock, patch
import pytest
from pyfragment import FragmentClient
from pyfragment.types import NumbersResult
from tests.shared import FAKE_HASH
from pyfragment import FragmentClient, NumbersResult
FAKE_HTML = """
<tr class="tm-row-selectable">
@@ -22,15 +20,12 @@ FAKE_HTML = """
"""
# search_numbers result parsing tests
@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}),
),
):
with patch.object(client, "call", AsyncMock(return_value={"ok": True, "html": FAKE_HTML})):
result = await client.search_numbers("888")
assert isinstance(result, NumbersResult)
@@ -43,13 +38,7 @@ async def test_search_numbers_basic(client: FragmentClient) -> 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}),
),
):
with patch.object(client, "call", AsyncMock(return_value={"ok": True})):
result = await client.search_numbers("zzz_no_results")
assert isinstance(result, NumbersResult)
@@ -57,21 +46,18 @@ async def test_search_numbers_empty_html(client: FragmentClient) -> None:
assert result.next_offset_id is None
# search_numbers parameter forwarding tests
@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,
):
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML})
with patch.object(client, "call", mock_call):
result = await client.search_numbers("888", sort="price_asc", filter="sale")
assert isinstance(result, NumbersResult)
call_data = mock_request.call_args[0][3]
call_data = mock_call.call_args[0][1]
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"
@@ -79,33 +65,23 @@ async def test_search_numbers_with_sort_and_filter(client: FragmentClient) -> No
@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,
):
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_50"})
with patch.object(client, "call", mock_call):
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]
call_data = mock_call.call_args[0][1]
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,
):
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML})
with patch.object(client, "call", mock_call):
result = await client.search_numbers()
assert isinstance(result, NumbersResult)
call_data = mock_request.call_args[0][3]
call_data = mock_call.call_args[0][1]
assert call_data["query"] == ""
assert call_data["type"] == "numbers"

Some files were not shown because too many files have changed in this diff Show More